@zerwiz/ymir 0.1.4 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (865) hide show
  1. package/.agents/README.md +291 -0
  2. package/.agents/agents/bragi-marketer.md +111 -0
  3. package/.agents/agents/brokk.md +352 -0
  4. package/.agents/agents/forseti-reviewer.md +93 -0
  5. package/.agents/agents/frigg-consent.md +77 -0
  6. package/.agents/agents/groa-update.md +77 -0
  7. package/.agents/agents/hnoss-designer.md +69 -0
  8. package/.agents/agents/huginn-researcher.md +99 -0
  9. package/.agents/agents/jord-projects.md +78 -0
  10. package/.agents/agents/kvasir-scout.md +91 -0
  11. package/.agents/agents/mimir-planner.md +92 -0
  12. package/.agents/agents/muninn-stow.md +77 -0
  13. package/.agents/agents/saga-bearings.md +78 -0
  14. package/.agents/agents/sindri-developer.md +114 -0
  15. package/.agents/agents/snotra-documenter.md +105 -0
  16. package/.agents/agents/syn-recovery.md +78 -0
  17. package/.agents/agents/tyr-check.md +78 -0
  18. package/.agents/agents/urdh-hold.md +77 -0
  19. package/.agents/agents/volundr-smith.md +80 -0
  20. package/.agents/agents/vor-diagnostics.md +77 -0
  21. package/.agents/assets/agents/naming.md +50 -0
  22. package/.agents/assets/agents/registry.md +93 -0
  23. package/.agents/assets/agents/runtime.md +36 -0
  24. package/.agents/assets/agents/toon-tasks-cli.md +42 -0
  25. package/.agents/assets/templates/company_entity.template.md +37 -0
  26. package/.agents/backend/README.md +74 -0
  27. package/.agents/backend/backends/cmux.sh +649 -0
  28. package/.agents/backend/backends/herdr-eventwait.py +157 -0
  29. package/.agents/backend/backends/herdr-workspace-move.py +114 -0
  30. package/.agents/backend/backends/herdr.sh +3386 -0
  31. package/.agents/backend/backends/orca.sh +290 -0
  32. package/.agents/backend/backends/tmux.sh +357 -0
  33. package/.agents/backend/backends/zellij.sh +700 -0
  34. package/.agents/backend/fm-afk-launch.sh +654 -0
  35. package/.agents/backend/fm-afk-return.sh +241 -0
  36. package/.agents/backend/fm-afk-start.sh +172 -0
  37. package/.agents/backend/fm-arm-command-policy.mjs +960 -0
  38. package/.agents/backend/fm-arm-pretool-check.sh +200 -0
  39. package/.agents/backend/fm-backend-hometag-lib.sh +52 -0
  40. package/.agents/backend/fm-backend.sh +981 -0
  41. package/.agents/backend/fm-backlog-handoff.sh +909 -0
  42. package/.agents/backend/fm-backlog-receive.sh +187 -0
  43. package/.agents/backend/fm-backlog-transition-lib.sh +779 -0
  44. package/.agents/backend/fm-bearings-board.sh +204 -0
  45. package/.agents/backend/fm-bearings-snapshot.sh +546 -0
  46. package/.agents/backend/fm-bootstrap.sh +1568 -0
  47. package/.agents/backend/fm-branch-outcome.sh +203 -0
  48. package/.agents/backend/fm-branch-prompt.sh +101 -0
  49. package/.agents/backend/fm-brief.sh +452 -0
  50. package/.agents/backend/fm-busy-event.sh +234 -0
  51. package/.agents/backend/fm-busy-lib.sh +986 -0
  52. package/.agents/backend/fm-captain-hold.sh +1001 -0
  53. package/.agents/backend/fm-cd-command-policy.mjs +153 -0
  54. package/.agents/backend/fm-cd-pretool-check.sh +190 -0
  55. package/.agents/backend/fm-check-lib.sh +74 -0
  56. package/.agents/backend/fm-check-register.sh +42 -0
  57. package/.agents/backend/fm-check-unregister.sh +52 -0
  58. package/.agents/backend/fm-classify-lib.sh +1770 -0
  59. package/.agents/backend/fm-claude-stop-autoarm.sh +334 -0
  60. package/.agents/backend/fm-composer-lib.sh +1416 -0
  61. package/.agents/backend/fm-config-inherit-lib.sh +1202 -0
  62. package/.agents/backend/fm-config-push.sh +254 -0
  63. package/.agents/backend/fm-control-lib.sh +251 -0
  64. package/.agents/backend/fm-control.sh +878 -0
  65. package/.agents/backend/fm-crew-state.sh +625 -0
  66. package/.agents/backend/fm-cursor-lib.sh +243 -0
  67. package/.agents/backend/fm-decision-hold.sh +232 -0
  68. package/.agents/backend/fm-doc-audience-check.sh +269 -0
  69. package/.agents/backend/fm-dod-lib.sh +67 -0
  70. package/.agents/backend/fm-ensure-agents-md.sh +253 -0
  71. package/.agents/backend/fm-extension-launch-barrier.mjs +129 -0
  72. package/.agents/backend/fm-extension.mjs +2577 -0
  73. package/.agents/backend/fm-extension.sh +16 -0
  74. package/.agents/backend/fm-ff-lib.sh +420 -0
  75. package/.agents/backend/fm-fleet-snapshot.sh +1463 -0
  76. package/.agents/backend/fm-fleet-sync.sh +458 -0
  77. package/.agents/backend/fm-fleet-view.sh +96 -0
  78. package/.agents/backend/fm-gate-refuse-lib.sh +102 -0
  79. package/.agents/backend/fm-guard.sh +242 -0
  80. package/.agents/backend/fm-harness.sh +195 -0
  81. package/.agents/backend/fm-herdr-ci-cleanup.sh +115 -0
  82. package/.agents/backend/fm-herdr-lab.sh +346 -0
  83. package/.agents/backend/fm-herdr-session-cleanup.sh +337 -0
  84. package/.agents/backend/fm-home-seed.sh +984 -0
  85. package/.agents/backend/fm-home-summary-refresh.sh +255 -0
  86. package/.agents/backend/fm-hook-host-lib.sh +36 -0
  87. package/.agents/backend/fm-inactive-reconcile.sh +531 -0
  88. package/.agents/backend/fm-inbox.sh +399 -0
  89. package/.agents/backend/fm-install-actionlint.sh +84 -0
  90. package/.agents/backend/fm-install-herdr.sh +96 -0
  91. package/.agents/backend/fm-install-shellcheck.sh +84 -0
  92. package/.agents/backend/fm-install-treehouse.sh +95 -0
  93. package/.agents/backend/fm-kimi-turnend-hook.sh +276 -0
  94. package/.agents/backend/fm-lease-lib.sh +218 -0
  95. package/.agents/backend/fm-lease.sh +189 -0
  96. package/.agents/backend/fm-line-cap-lib.sh +51 -0
  97. package/.agents/backend/fm-lint-workflows.sh +137 -0
  98. package/.agents/backend/fm-lint.sh +591 -0
  99. package/.agents/backend/fm-lock-lib.sh +104 -0
  100. package/.agents/backend/fm-lock.sh +107 -0
  101. package/.agents/backend/fm-marker-lib.sh +12 -0
  102. package/.agents/backend/fm-merge-local.sh +74 -0
  103. package/.agents/backend/fm-merge-outcome-lib.sh +138 -0
  104. package/.agents/backend/fm-nm-run-lib.sh +124 -0
  105. package/.agents/backend/fm-on.sh +125 -0
  106. package/.agents/backend/fm-operational-input.sh +253 -0
  107. package/.agents/backend/fm-peek.sh +46 -0
  108. package/.agents/backend/fm-pending-reply-lib.sh +1409 -0
  109. package/.agents/backend/fm-pr-check.sh +135 -0
  110. package/.agents/backend/fm-pr-lib.sh +1016 -0
  111. package/.agents/backend/fm-pr-merge.sh +707 -0
  112. package/.agents/backend/fm-pr-poll.sh +110 -0
  113. package/.agents/backend/fm-primary-scope-lib.sh +33 -0
  114. package/.agents/backend/fm-procevent-extension-capture.pl +259 -0
  115. package/.agents/backend/fm-procevent-lavish.sh +628 -0
  116. package/.agents/backend/fm-procevent-lib.sh +892 -0
  117. package/.agents/backend/fm-procevent-quota.sh +290 -0
  118. package/.agents/backend/fm-procevent-remote-reply.sh +577 -0
  119. package/.agents/backend/fm-procevent-when.sh +504 -0
  120. package/.agents/backend/fm-procevent.sh +1466 -0
  121. package/.agents/backend/fm-project-mode.sh +92 -0
  122. package/.agents/backend/fm-project-origin-lib.sh +180 -0
  123. package/.agents/backend/fm-promote.sh +282 -0
  124. package/.agents/backend/fm-public-followup-emit.sh +260 -0
  125. package/.agents/backend/fm-public-followup-lib.sh +398 -0
  126. package/.agents/backend/fm-public-followup.sh +1330 -0
  127. package/.agents/backend/fm-push-transition-lib.sh +168 -0
  128. package/.agents/backend/fm-quota-axi-lib.sh +93 -0
  129. package/.agents/backend/fm-quota-choose.sh +384 -0
  130. package/.agents/backend/fm-remote-delta-read.sh +189 -0
  131. package/.agents/backend/fm-remote-doctor.sh +800 -0
  132. package/.agents/backend/fm-remote-entrypoint.sh +193 -0
  133. package/.agents/backend/fm-remote-file.sh +234 -0
  134. package/.agents/backend/fm-remote-home-provision.sh +259 -0
  135. package/.agents/backend/fm-remote-home-seed.sh +259 -0
  136. package/.agents/backend/fm-remote-inherit-push.sh +90 -0
  137. package/.agents/backend/fm-remote-inherit.sh +179 -0
  138. package/.agents/backend/fm-remote-job-lib.sh +1241 -0
  139. package/.agents/backend/fm-remote-job-reap-orphans.sh +137 -0
  140. package/.agents/backend/fm-remote-job-worker.sh +1124 -0
  141. package/.agents/backend/fm-remote-readiness-lib.sh +44 -0
  142. package/.agents/backend/fm-remote-secondmate-control.sh +340 -0
  143. package/.agents/backend/fm-review-diff.sh +158 -0
  144. package/.agents/backend/fm-secondmate-charter-lib.sh +44 -0
  145. package/.agents/backend/fm-secondmate-nudge-lib.sh +68 -0
  146. package/.agents/backend/fm-secondmate-parent-lib.sh +70 -0
  147. package/.agents/backend/fm-secondmate-reconcile.sh +371 -0
  148. package/.agents/backend/fm-secondmate-registry-lib.sh +311 -0
  149. package/.agents/backend/fm-secondmate-report.sh +88 -0
  150. package/.agents/backend/fm-send.sh +1059 -0
  151. package/.agents/backend/fm-session-lock-lib.sh +176 -0
  152. package/.agents/backend/fm-session-start.sh +970 -0
  153. package/.agents/backend/fm-sessionstart-cursor.sh +40 -0
  154. package/.agents/backend/fm-sessionstart-nudge.sh +45 -0
  155. package/.agents/backend/fm-sessionstart-run.sh +145 -0
  156. package/.agents/backend/fm-spawn.sh +3153 -0
  157. package/.agents/backend/fm-startup-memory-budget-lib.sh +224 -0
  158. package/.agents/backend/fm-startup-memory-budget.sh +94 -0
  159. package/.agents/backend/fm-startup-network.sh +642 -0
  160. package/.agents/backend/fm-stow-cascade.sh +251 -0
  161. package/.agents/backend/fm-subagent-pretool-check.sh +207 -0
  162. package/.agents/backend/fm-supervise-daemon.sh +1732 -0
  163. package/.agents/backend/fm-supervision-instructions.sh +215 -0
  164. package/.agents/backend/fm-supervision-lib.sh +90 -0
  165. package/.agents/backend/fm-supervisor-target-lib.sh +78 -0
  166. package/.agents/backend/fm-tangle-lib.sh +53 -0
  167. package/.agents/backend/fm-task-inbox-lib.sh +402 -0
  168. package/.agents/backend/fm-tasks-axi-lib.sh +123 -0
  169. package/.agents/backend/fm-teardown.sh +2922 -0
  170. package/.agents/backend/fm-test-isolation-proof.sh +588 -0
  171. package/.agents/backend/fm-test-run.sh +2191 -0
  172. package/.agents/backend/fm-timeout-lib.sh +141 -0
  173. package/.agents/backend/fm-timing-lib.sh +179 -0
  174. package/.agents/backend/fm-tmux-lib.sh +291 -0
  175. package/.agents/backend/fm-tool-update-check.sh +898 -0
  176. package/.agents/backend/fm-trace-context-lib.sh +227 -0
  177. package/.agents/backend/fm-transition-lib.sh +103 -0
  178. package/.agents/backend/fm-turnend-guard-cursor.sh +391 -0
  179. package/.agents/backend/fm-turnend-guard-grok.sh +90 -0
  180. package/.agents/backend/fm-turnend-guard.sh +432 -0
  181. package/.agents/backend/fm-update.sh +110 -0
  182. package/.agents/backend/fm-vendor-auth-probe.sh +190 -0
  183. package/.agents/backend/fm-voice-client.py +1373 -0
  184. package/.agents/backend/fm-voice-relay.py +1256 -0
  185. package/.agents/backend/fm-wake-drain.sh +592 -0
  186. package/.agents/backend/fm-wake-grant.sh +127 -0
  187. package/.agents/backend/fm-wake-lib.sh +1849 -0
  188. package/.agents/backend/fm-watch-arm.sh +602 -0
  189. package/.agents/backend/fm-watch-checkpoint.sh +109 -0
  190. package/.agents/backend/fm-watch.sh +1962 -0
  191. package/.agents/backend/fm-x-dismiss.sh +116 -0
  192. package/.agents/backend/fm-x-followup.sh +286 -0
  193. package/.agents/backend/fm-x-lib.sh +1000 -0
  194. package/.agents/backend/fm-x-link.sh +234 -0
  195. package/.agents/backend/fm-x-poll.sh +185 -0
  196. package/.agents/backend/fm-x-reply.sh +385 -0
  197. package/.agents/backend/fm_voice_frame.py +166 -0
  198. package/.agents/backend/fm_voice_records.py +574 -0
  199. package/.agents/backend/model-bridge.py +360 -0
  200. package/.agents/backend/opencode-go-bridge.py +30 -0
  201. package/.agents/bus/README.md +12 -0
  202. package/.agents/bus/messages.json +1 -0
  203. package/.agents/config/agents.machine.example.yaml +16 -0
  204. package/.agents/config/agents.yaml.example +72 -0
  205. package/.agents/config/crew-dispatch.json +22 -0
  206. package/.agents/config/cron.yaml +11 -0
  207. package/.agents/config/eindri-dispatch.json +67 -0
  208. package/.agents/config/eindri-harness +1 -0
  209. package/.agents/config/startup-memory-budget +1 -0
  210. package/.agents/filebrowser/.filebrowser.json +6 -0
  211. package/.agents/filebrowser/README.md +11 -0
  212. package/.agents/gateway/README.md +12 -0
  213. package/.agents/github/README.md +15 -0
  214. package/.agents/github/webhooks/.gitkeep +0 -0
  215. package/.agents/github/workflows/.gitkeep +0 -0
  216. package/.agents/harness/opencode/plugins/lib/rodd-operational-input.js +37 -0
  217. package/.agents/harness/opencode/plugins/package.json +4 -0
  218. package/.agents/harness/opencode/plugins/saga-sessionstart.js +77 -0
  219. package/.agents/harness/opencode/plugins/syn-cd-check.js +61 -0
  220. package/.agents/harness/opencode/plugins/syn-pretool-check.js +68 -0
  221. package/.agents/harness/opencode/plugins/syn-turnend-guard.js +105 -0
  222. package/.agents/harness/opencode/plugins/syn-watch-arm.js +521 -0
  223. package/.agents/memory/README.md +46 -0
  224. package/.agents/memory/runes_audit.md +5 -0
  225. package/.agents/migrations/0001-hodd-layout.sh +38 -0
  226. package/.agents/migrations/0002-a2a-mcp.sh +27 -0
  227. package/.agents/migrations/0003-private-data-separation.sh +121 -0
  228. package/.agents/migrations/0004-hoard-and-realms.sh +104 -0
  229. package/.agents/migrations/0005-roots-out-of-tree.sh +158 -0
  230. package/.agents/sandbox/Dockerfile.utgard +19 -0
  231. package/.agents/sandbox/README.md +14 -0
  232. package/.agents/sandbox/utgard.config.json +14 -0
  233. package/.agents/skills/README.md +59 -0
  234. package/.agents/skills/bragi-marketing/README.md +3 -0
  235. package/.agents/skills/bragi-marketing/SKILL.md +82 -0
  236. package/.agents/skills/eindri-homes/README.md +3 -0
  237. package/.agents/skills/eindri-homes/SKILL.md +291 -0
  238. package/.agents/skills/eindri-homes/assets/control-plane.md +122 -0
  239. package/.agents/skills/eindri-homes/assets/dispatch.md +80 -0
  240. package/.agents/skills/frigg-consent/README.md +3 -0
  241. package/.agents/skills/frigg-consent/SKILL.md +56 -0
  242. package/.agents/skills/galdr-ymirsystem/README.md +3 -0
  243. package/.agents/skills/galdr-ymirsystem/SKILL.md +159 -0
  244. package/.agents/skills/galdr-ymirsystem/assets/README.md +74 -0
  245. package/.agents/skills/galdr-ymirsystem/assets/brokk-distro-runtime.md +390 -0
  246. package/.agents/skills/galdr-ymirsystem/assets/build-method.md +113 -0
  247. package/.agents/skills/galdr-ymirsystem/assets/build-tool-categories.md +178 -0
  248. package/.agents/skills/galdr-ymirsystem/assets/eindri-orchestration.md +518 -0
  249. package/.agents/skills/galdr-ymirsystem/assets/eindri-profiles.md +136 -0
  250. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/README.md +736 -0
  251. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/claude-code.md +197 -0
  252. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/codex.md +191 -0
  253. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/cursor.md +193 -0
  254. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/opencode.md +262 -0
  255. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/pi.md +295 -0
  256. package/.agents/skills/galdr-ymirsystem/assets/hlidskjalf-ui.md +539 -0
  257. package/.agents/skills/galdr-ymirsystem/assets/installation.md +763 -0
  258. package/.agents/skills/galdr-ymirsystem/assets/local-models/HOST-DATA.example.md +72 -0
  259. package/.agents/skills/galdr-ymirsystem/assets/local-models.md +274 -0
  260. package/.agents/skills/galdr-ymirsystem/assets/memory-well.md +163 -0
  261. package/.agents/skills/galdr-ymirsystem/assets/nornir-jobs.md +415 -0
  262. package/.agents/skills/galdr-ymirsystem/assets/norse-naming.md +325 -0
  263. package/.agents/skills/galdr-ymirsystem/assets/odrerir-hall.md +133 -0
  264. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/einherjar-spawn.schema.json +81 -0
  265. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/herdr-profile.toml +75 -0
  266. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/pi-profile.yml +101 -0
  267. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/supervision-tree.yml +163 -0
  268. package/.agents/skills/galdr-ymirsystem/assets/pi-boot-guide.md +58 -0
  269. package/.agents/skills/galdr-ymirsystem/assets/porting-upstream-to-norse.md +301 -0
  270. package/.agents/skills/galdr-ymirsystem/assets/principles.md +385 -0
  271. package/.agents/skills/galdr-ymirsystem/assets/reference-adoption.md +99 -0
  272. package/.agents/skills/galdr-ymirsystem/assets/registry.md +379 -0
  273. package/.agents/skills/galdr-ymirsystem/assets/runtime-compliance.md +463 -0
  274. package/.agents/skills/galdr-ymirsystem/assets/runtime-components.md +553 -0
  275. package/.agents/skills/galdr-ymirsystem/assets/smidja.md +180 -0
  276. package/.agents/skills/galdr-ymirsystem/schemas/toon-schemas.md +190 -0
  277. package/.agents/skills/galdr-ymirsystem/scripts/README.md +45 -0
  278. package/.agents/skills/galdr-ymirsystem/scripts/bench-one.sh +248 -0
  279. package/.agents/skills/galdr-ymirsystem/scripts/compliance-check.sh +503 -0
  280. package/.agents/skills/galdr-ymirsystem/scripts/gpu-sample.sh +43 -0
  281. package/.agents/skills/galdr-ymirsystem/scripts/stop-all.sh +34 -0
  282. package/.agents/skills/galdr-ymirsystem/scripts/toon-check.py +188 -0
  283. package/.agents/skills/gjallarhorn-relay/README.md +3 -0
  284. package/.agents/skills/gjallarhorn-relay/SKILL.md +283 -0
  285. package/.agents/skills/groa-update/SKILL.md +90 -0
  286. package/.agents/skills/hamr-adapters/README.md +3 -0
  287. package/.agents/skills/hamr-adapters/SKILL.md +123 -0
  288. package/.agents/skills/hamr-adapters/references/common/control-and-recovery.md +37 -0
  289. package/.agents/skills/hamr-adapters/references/common/dispatch.md +32 -0
  290. package/.agents/skills/hamr-adapters/references/common/model-and-effort.md +42 -0
  291. package/.agents/skills/hamr-adapters/references/common/primary-hooks.md +40 -0
  292. package/.agents/skills/hamr-adapters/references/harness/claude.md +55 -0
  293. package/.agents/skills/hamr-adapters/references/harness/codex.md +43 -0
  294. package/.agents/skills/hamr-adapters/references/harness/cursor.md +75 -0
  295. package/.agents/skills/hamr-adapters/references/harness/grok.md +69 -0
  296. package/.agents/skills/hamr-adapters/references/harness/kimi.md +51 -0
  297. package/.agents/skills/hamr-adapters/references/harness/muse.md +70 -0
  298. package/.agents/skills/hamr-adapters/references/harness/opencode.md +42 -0
  299. package/.agents/skills/hamr-adapters/references/harness/pi.md +56 -0
  300. package/.agents/skills/herdr-panes/README.md +3 -0
  301. package/.agents/skills/herdr-panes/SKILL.md +92 -0
  302. package/.agents/skills/herdr-panes/assets/herdr-backend.md +346 -0
  303. package/.agents/skills/herdr-panes/assets/tmux-backend.md +115 -0
  304. package/.agents/skills/hnoss-design/DESIGN.md +19 -0
  305. package/.agents/skills/hnoss-design/README.md +3 -0
  306. package/.agents/skills/hnoss-design/SKILL.md +83 -0
  307. package/.agents/skills/hvild-afk/README.md +3 -0
  308. package/.agents/skills/hvild-afk/SKILL.md +241 -0
  309. package/.agents/skills/jord-projects/README.md +3 -0
  310. package/.agents/skills/jord-projects/SKILL.md +92 -0
  311. package/.agents/skills/lifecycle/SKILL.md +56 -0
  312. package/.agents/skills/lifecycle/smoke_test.sh +57 -0
  313. package/.agents/skills/lifecycle/start.sh +21 -0
  314. package/.agents/skills/lifecycle/status.sh +35 -0
  315. package/.agents/skills/lifecycle/stop.sh +16 -0
  316. package/.agents/skills/muninn-stow/README.md +3 -0
  317. package/.agents/skills/muninn-stow/SKILL.md +309 -0
  318. package/.agents/skills/no-mistakes/SKILL.md +379 -0
  319. package/.agents/skills/nornir-schedule/README.md +3 -0
  320. package/.agents/skills/nornir-schedule/SKILL.md +25 -0
  321. package/.agents/skills/nornir-schedule/assets/events.md +137 -0
  322. package/.agents/skills/nornir-schedule/assets/quota.md +131 -0
  323. package/.agents/skills/nsr-compliance/README.md +3 -0
  324. package/.agents/skills/nsr-compliance/SKILL.md +29 -0
  325. package/.agents/skills/nsr-compliance/assets/nsr/README.md +40 -0
  326. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/README.md +15 -0
  327. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/git-workflow.md +18 -0
  328. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/security.md +22 -0
  329. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/tech-stack.md +8 -0
  330. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/SKILL.md +71 -0
  331. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/config/core_four.yaml +16 -0
  332. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_danger.sh +102 -0
  333. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_wiring.sh +69 -0
  334. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/validate_code.sh +29 -0
  335. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/verify_docs.py +66 -0
  336. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/generate.sh +109 -0
  337. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/result_envelope.json +14 -0
  338. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/task_envelope.json +20 -0
  339. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/runner.py +303 -0
  340. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/telemetry/logger.py +112 -0
  341. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/README.md +18 -0
  342. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/config/README.md +10 -0
  343. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/README.md +12 -0
  344. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_env.sh +23 -0
  345. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_paths.sh +20 -0
  346. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_platform.sh +23 -0
  347. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/README.md +11 -0
  348. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/envelopes/README.md +10 -0
  349. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/README.md +10 -0
  350. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/install.sh +125 -0
  351. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/telemetry/README.md +11 -0
  352. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/README.md +20 -0
  353. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/README.md +36 -0
  354. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/SKILL-template.md +14 -0
  355. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/README.md +25 -0
  356. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/SKILL.md +13 -0
  357. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/create_branch.sh +6 -0
  358. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/safe_commit.sh +9 -0
  359. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/sync_upstream.sh +6 -0
  360. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/README.md +27 -0
  361. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/SKILL.md +15 -0
  362. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/smoke_test.sh +12 -0
  363. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/start.sh +19 -0
  364. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/status.sh +12 -0
  365. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/stop.sh +15 -0
  366. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/README.md +18 -0
  367. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/README.md +10 -0
  368. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/core_four.yaml +13 -0
  369. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/README.md +12 -0
  370. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_danger.sh +102 -0
  371. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_env.sh +23 -0
  372. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_paths.sh +20 -0
  373. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_platform.sh +23 -0
  374. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_wiring.sh +69 -0
  375. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/README.md +11 -0
  376. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/README.md +10 -0
  377. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/result_envelope.json +14 -0
  378. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/task_envelope.json +20 -0
  379. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/runner.py +303 -0
  380. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/README.md +10 -0
  381. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/install.sh +125 -0
  382. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/README.md +11 -0
  383. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/logger.py +112 -0
  384. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/README.md +14 -0
  385. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/api.md +17 -0
  386. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/compliance-wiring.md +155 -0
  387. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/frontend.md +15 -0
  388. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/README.md +11 -0
  389. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/README.md +23 -0
  390. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/README.md +16 -0
  391. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/template-README.md +11 -0
  392. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/deploy.sh +49 -0
  393. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/README.md +12 -0
  394. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/development.env.example +17 -0
  395. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/production.env.example +17 -0
  396. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/staging.env.example +17 -0
  397. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/README.md +11 -0
  398. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/template.env.example +8 -0
  399. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/README.md +26 -0
  400. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/agent.md +7 -0
  401. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/developers/dev-1.md +20 -0
  402. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/linux.md +9 -0
  403. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/macos.md +10 -0
  404. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/shared.md +19 -0
  405. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/windows.md +14 -0
  406. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/README.md +25 -0
  407. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/_template.md +41 -0
  408. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/hosting-1.md +41 -0
  409. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/README.md +21 -0
  410. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/README.md +15 -0
  411. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/deployment.md +19 -0
  412. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/disaster-recovery.md +15 -0
  413. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/ops-tasks.md +14 -0
  414. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/features/README.md +16 -0
  415. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/project-skills.md +146 -0
  416. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/README.md +11 -0
  417. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/software-compliance-research.md +269 -0
  418. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/working-with-agents.md +260 -0
  419. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/AGENTS.md +38 -0
  420. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/ARCHITECTURE.md +36 -0
  421. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/BEST_PRACTICES.md +27 -0
  422. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/CI_CD.md +23 -0
  423. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/FEATURES.md +15 -0
  424. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/README.md +22 -0
  425. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/RULES.md +37 -0
  426. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/STRUCTURE.md +45 -0
  427. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/TECH_STACK.md +28 -0
  428. package/.agents/skills/nsr-compliance/assets/nsr/scaffold-spec.md +63 -0
  429. package/.agents/skills/nsr-compliance/assets/nsr/scripts/scaffold.sh +103 -0
  430. package/.agents/skills/nsr-compliance/assets/nsr/scripts/validate.sh +52 -0
  431. package/.agents/skills/nsr-compliance/assets/nsrcompliance/SKILL.md +71 -0
  432. package/.agents/skills/nsr-compliance/assets/nsrcompliance/config/core_four.yaml +16 -0
  433. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_danger.sh +102 -0
  434. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_wiring.sh +69 -0
  435. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/validate_code.sh +29 -0
  436. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/verify_docs.py +66 -0
  437. package/.agents/skills/nsr-compliance/assets/nsrcompliance/generate.sh +109 -0
  438. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/result_envelope.json +14 -0
  439. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/task_envelope.json +20 -0
  440. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/runner.py +303 -0
  441. package/.agents/skills/nsr-compliance/assets/nsrcompliance/telemetry/logger.py +112 -0
  442. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/README.md +18 -0
  443. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/config/README.md +10 -0
  444. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/README.md +12 -0
  445. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_env.sh +23 -0
  446. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_paths.sh +20 -0
  447. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_platform.sh +23 -0
  448. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/README.md +11 -0
  449. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/envelopes/README.md +10 -0
  450. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/README.md +10 -0
  451. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/install.sh +125 -0
  452. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/telemetry/README.md +11 -0
  453. package/.agents/skills/pr-ops/README.md +3 -0
  454. package/.agents/skills/pr-ops/SKILL.md +109 -0
  455. package/.agents/skills/ratatoskr-a2a/README.md +3 -0
  456. package/.agents/skills/ratatoskr-a2a/SKILL.md +148 -0
  457. package/.agents/skills/rules-check-drift/SKILL.md +81 -0
  458. package/.agents/skills/saga-bearings/README.md +3 -0
  459. package/.agents/skills/saga-bearings/SKILL.md +26 -0
  460. package/.agents/skills/saga-bearings/assets/bearings.md +161 -0
  461. package/.agents/skills/saga-bearings/assets/board-template.html +731 -0
  462. package/.agents/skills/saga-bearings/assets/recap.md +56 -0
  463. package/.agents/skills/syn-recovery/README.md +3 -0
  464. package/.agents/skills/syn-recovery/SKILL.md +54 -0
  465. package/.agents/skills/tyr-check/README.md +3 -0
  466. package/.agents/skills/tyr-check/SKILL.md +218 -0
  467. package/.agents/skills/urdh-hold/README.md +3 -0
  468. package/.agents/skills/urdh-hold/SKILL.md +25 -0
  469. package/.agents/skills/urdh-hold/assets/decisions.md +15 -0
  470. package/.agents/skills/urdh-hold/assets/hold.md +54 -0
  471. package/.agents/skills/vor-diagnostics/README.md +3 -0
  472. package/.agents/skills/vor-diagnostics/SKILL.md +68 -0
  473. package/.agents/skills/ymir-host/README.md +3 -0
  474. package/.agents/skills/ymir-host/SKILL.md +35 -0
  475. package/.agents/skills/ymir-host/assets/desktop.md +36 -0
  476. package/.agents/skills/ymir-host/assets/install.md +69 -0
  477. package/.agents/skills/ymir-host/assets/omarchy.md +297 -0
  478. package/.agents/skills/ymir-host/assets/runbooks.md +15 -0
  479. package/.agents/skills/ymir-host/assets/thjazi.md +220 -0
  480. package/.agents/skills/ymir-host/assets/update.md +64 -0
  481. package/.agents/tests/assets/board-render-harness.mjs +117 -0
  482. package/.agents/tests/cmux-test-safety.sh +51 -0
  483. package/.agents/tests/fixtures.sh +291 -0
  484. package/.agents/tests/fm-afk-inject-e2e.test.sh +428 -0
  485. package/.agents/tests/fm-afk-inject-herdr-e2e.test.sh +536 -0
  486. package/.agents/tests/fm-afk-launch.test.sh +961 -0
  487. package/.agents/tests/fm-afk-pi-herdr-return-e2e.test.sh +297 -0
  488. package/.agents/tests/fm-afk-return.test.sh +283 -0
  489. package/.agents/tests/fm-arm-pretool-check.test.sh +482 -0
  490. package/.agents/tests/fm-ask-user-authority.test.sh +42 -0
  491. package/.agents/tests/fm-backend-autodetect-smoke.test.sh +176 -0
  492. package/.agents/tests/fm-backend-cmux-smoke.test.sh +188 -0
  493. package/.agents/tests/fm-backend-cmux.test.sh +1166 -0
  494. package/.agents/tests/fm-backend-herdr-eventwait-smoke.test.sh +136 -0
  495. package/.agents/tests/fm-backend-herdr-eventwait.test.py +106 -0
  496. package/.agents/tests/fm-backend-herdr-focus-flash-e2e.test.sh +409 -0
  497. package/.agents/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh +437 -0
  498. package/.agents/tests/fm-backend-herdr-presentation-e2e.test.sh +1424 -0
  499. package/.agents/tests/fm-backend-herdr-prune-safety-e2e.test.sh +180 -0
  500. package/.agents/tests/fm-backend-herdr-respawn-idem-e2e.test.sh +182 -0
  501. package/.agents/tests/fm-backend-herdr-smoke.test.sh +355 -0
  502. package/.agents/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh +251 -0
  503. package/.agents/tests/fm-backend-herdr.test.sh +4606 -0
  504. package/.agents/tests/fm-backend-orca.test.sh +1357 -0
  505. package/.agents/tests/fm-backend-tmux-smoke.test.sh +173 -0
  506. package/.agents/tests/fm-backend-zellij-smoke.test.sh +205 -0
  507. package/.agents/tests/fm-backend-zellij.test.sh +1360 -0
  508. package/.agents/tests/fm-backend.test.sh +1142 -0
  509. package/.agents/tests/fm-backlog-atomicity.test.sh +2302 -0
  510. package/.agents/tests/fm-backlog-handoff.test.sh +1360 -0
  511. package/.agents/tests/fm-bearings-board-render.test.sh +135 -0
  512. package/.agents/tests/fm-bearings-board.test.sh +414 -0
  513. package/.agents/tests/fm-bearings-snapshot.test.sh +1990 -0
  514. package/.agents/tests/fm-bootstrap-network-parallel.test.sh +316 -0
  515. package/.agents/tests/fm-bootstrap.test.sh +1178 -0
  516. package/.agents/tests/fm-branch-supervision.test.sh +552 -0
  517. package/.agents/tests/fm-brief.test.sh +785 -0
  518. package/.agents/tests/fm-busy-adapter-wiring.test.sh +324 -0
  519. package/.agents/tests/fm-busy-state.test.sh +465 -0
  520. package/.agents/tests/fm-calm-pi-extension.test.sh +3979 -0
  521. package/.agents/tests/fm-captain-hold-lifecycle.test.sh +1187 -0
  522. package/.agents/tests/fm-cd-pretool-check.test.sh +400 -0
  523. package/.agents/tests/fm-check-unregister.test.sh +198 -0
  524. package/.agents/tests/fm-classify-corr-token.test.sh +543 -0
  525. package/.agents/tests/fm-classify-decision-key.test.sh +340 -0
  526. package/.agents/tests/fm-claude-stop-autoarm-live-e2e.test.sh +164 -0
  527. package/.agents/tests/fm-claude-stop-autoarm.test.sh +1190 -0
  528. package/.agents/tests/fm-cmux-claude-composer-live-e2e.test.sh +110 -0
  529. package/.agents/tests/fm-codex-continuity-live-e2e.test.sh +55 -0
  530. package/.agents/tests/fm-composer-ghost.test.sh +716 -0
  531. package/.agents/tests/fm-composer-lib.test.sh +666 -0
  532. package/.agents/tests/fm-composer-matrix-live-e2e.test.sh +220 -0
  533. package/.agents/tests/fm-control-herdr-smoke.test.sh +149 -0
  534. package/.agents/tests/fm-control-relaunch.test.sh +1530 -0
  535. package/.agents/tests/fm-control.test.sh +910 -0
  536. package/.agents/tests/fm-crew-state.test.sh +1610 -0
  537. package/.agents/tests/fm-cursor-harness.test.sh +404 -0
  538. package/.agents/tests/fm-cursor-primary-live-e2e.test.sh +217 -0
  539. package/.agents/tests/fm-cursor-primary.test.sh +708 -0
  540. package/.agents/tests/fm-daemon.test.sh +2740 -0
  541. package/.agents/tests/fm-documentation-audiences.test.sh +141 -0
  542. package/.agents/tests/fm-ensure-agents-md.test.sh +371 -0
  543. package/.agents/tests/fm-extension-binding.test.sh +2187 -0
  544. package/.agents/tests/fm-fleet-snapshot-view.test.sh +816 -0
  545. package/.agents/tests/fm-fleet-sync.test.sh +721 -0
  546. package/.agents/tests/fm-gate-refuse.test.sh +356 -0
  547. package/.agents/tests/fm-gitignore-config.test.sh +89 -0
  548. package/.agents/tests/fm-gotmp.test.sh +230 -0
  549. package/.agents/tests/fm-grok-continuity-live-e2e.test.sh +112 -0
  550. package/.agents/tests/fm-grok-harness.test.sh +117 -0
  551. package/.agents/tests/fm-grok-stop-live-e2e.test.sh +219 -0
  552. package/.agents/tests/fm-guard-stale-banner.test.sh +709 -0
  553. package/.agents/tests/fm-harness-adapter-instructions-live-e2e.test.sh +134 -0
  554. package/.agents/tests/fm-harness-adapter-references.test.sh +30 -0
  555. package/.agents/tests/fm-harness-liveness-drift-live-e2e.test.sh +151 -0
  556. package/.agents/tests/fm-herdr-lab.test.sh +243 -0
  557. package/.agents/tests/fm-herdr-session-cleanup-e2e.test.sh +146 -0
  558. package/.agents/tests/fm-herdr-session-cleanup.test.sh +329 -0
  559. package/.agents/tests/fm-herdr-submit-confirm-live-e2e.test.sh +125 -0
  560. package/.agents/tests/fm-herdr-version-floor-live-e2e.test.sh +144 -0
  561. package/.agents/tests/fm-home-summary-refresh.test.sh +971 -0
  562. package/.agents/tests/fm-inactive-reconcile.test.sh +485 -0
  563. package/.agents/tests/fm-kimi-harness.test.sh +687 -0
  564. package/.agents/tests/fm-lint-workflows.test.sh +532 -0
  565. package/.agents/tests/fm-lint.test.sh +1023 -0
  566. package/.agents/tests/fm-muse-harness.test.sh +932 -0
  567. package/.agents/tests/fm-muse-signals-live-e2e.test.sh +205 -0
  568. package/.agents/tests/fm-no-mistakes-required.test.sh +72 -0
  569. package/.agents/tests/fm-on.test.sh +507 -0
  570. package/.agents/tests/fm-opencode-primary-live-e2e.test.sh +357 -0
  571. package/.agents/tests/fm-operational-input.test.sh +160 -0
  572. package/.agents/tests/fm-peek-remote.test.sh +110 -0
  573. package/.agents/tests/fm-pending-reply.test.sh +1288 -0
  574. package/.agents/tests/fm-pi-branch-extension.test.sh +3182 -0
  575. package/.agents/tests/fm-pi-branch-live-e2e.test.sh +527 -0
  576. package/.agents/tests/fm-pi-primary-live-e2e.test.sh +345 -0
  577. package/.agents/tests/fm-pi-primary-types.test.sh +68 -0
  578. package/.agents/tests/fm-pi-watch-extension.test.sh +2843 -0
  579. package/.agents/tests/fm-pr-check-security.test.sh +2140 -0
  580. package/.agents/tests/fm-pr-merge.test.sh +2135 -0
  581. package/.agents/tests/fm-procevent-quota.test.sh +225 -0
  582. package/.agents/tests/fm-procevent-when.test.sh +406 -0
  583. package/.agents/tests/fm-procevent.test.sh +1745 -0
  584. package/.agents/tests/fm-project-origin.test.sh +110 -0
  585. package/.agents/tests/fm-public-followup.test.sh +2272 -0
  586. package/.agents/tests/fm-quota-array-dispatch-live-e2e.test.sh +526 -0
  587. package/.agents/tests/fm-quota-choose.test.sh +626 -0
  588. package/.agents/tests/fm-remote-backlog-handoff.test.sh +453 -0
  589. package/.agents/tests/fm-remote-doctor.test.sh +625 -0
  590. package/.agents/tests/fm-remote-entrypoint.test.sh +59 -0
  591. package/.agents/tests/fm-remote-job-orphan-reap.test.sh +205 -0
  592. package/.agents/tests/fm-remote-job.test.sh +768 -0
  593. package/.agents/tests/fm-remote-reply.test.sh +520 -0
  594. package/.agents/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +1256 -0
  595. package/.agents/tests/fm-remote-secondmate-parent-binding.test.sh +326 -0
  596. package/.agents/tests/fm-remote-secondmate-trace-context.test.sh +310 -0
  597. package/.agents/tests/fm-remote-transport-lanes.test.sh +431 -0
  598. package/.agents/tests/fm-review-diff.test.sh +176 -0
  599. package/.agents/tests/fm-secondmate-harness.test.sh +2600 -0
  600. package/.agents/tests/fm-secondmate-lifecycle-e2e.test.sh +259 -0
  601. package/.agents/tests/fm-secondmate-liveness.test.sh +559 -0
  602. package/.agents/tests/fm-secondmate-reconcile.test.sh +732 -0
  603. package/.agents/tests/fm-secondmate-safety.test.sh +2972 -0
  604. package/.agents/tests/fm-secondmate-sync.test.sh +913 -0
  605. package/.agents/tests/fm-send-inbox-doorbell-live-e2e.test.sh +206 -0
  606. package/.agents/tests/fm-send-inbox.test.sh +352 -0
  607. package/.agents/tests/fm-send-popup-settle.test.sh +167 -0
  608. package/.agents/tests/fm-send-remote-delivery.test.sh +796 -0
  609. package/.agents/tests/fm-send-resolve-key.test.sh +551 -0
  610. package/.agents/tests/fm-send-secondmate-marker-herdr-e2e.test.sh +187 -0
  611. package/.agents/tests/fm-send-secondmate-marker.test.sh +278 -0
  612. package/.agents/tests/fm-send-settle.test.sh +147 -0
  613. package/.agents/tests/fm-send-strict.test.sh +241 -0
  614. package/.agents/tests/fm-session-lock-ancestry.test.sh +365 -0
  615. package/.agents/tests/fm-session-start.test.sh +2515 -0
  616. package/.agents/tests/fm-sessionstart-hook-live-e2e.test.sh +631 -0
  617. package/.agents/tests/fm-sessionstart-instruction-refresh-live-e2e.test.sh +230 -0
  618. package/.agents/tests/fm-sessionstart-nudge.test.sh +1039 -0
  619. package/.agents/tests/fm-shared-captain-inheritance.test.sh +404 -0
  620. package/.agents/tests/fm-spawn-batch.test.sh +148 -0
  621. package/.agents/tests/fm-spawn-dispatch-profile.test.sh +827 -0
  622. package/.agents/tests/fm-spawn-pool-base-freshen.test.sh +440 -0
  623. package/.agents/tests/fm-spawn-worktree-settle.test.sh +147 -0
  624. package/.agents/tests/fm-startup-memory-budget.test.sh +333 -0
  625. package/.agents/tests/fm-startup-network.test.sh +712 -0
  626. package/.agents/tests/fm-stow-cascade.test.sh +370 -0
  627. package/.agents/tests/fm-subagent-pretool-check.test.sh +291 -0
  628. package/.agents/tests/fm-supervision-events.test.sh +156 -0
  629. package/.agents/tests/fm-supervision-instructions.test.sh +189 -0
  630. package/.agents/tests/fm-tangle-guard.test.sh +277 -0
  631. package/.agents/tests/fm-task-delivery.test.sh +420 -0
  632. package/.agents/tests/fm-task-inbox.test.sh +513 -0
  633. package/.agents/tests/fm-teardown-endpoint-safety.test.sh +374 -0
  634. package/.agents/tests/fm-teardown.test.sh +2665 -0
  635. package/.agents/tests/fm-test-fixture-cleanup.test.sh +172 -0
  636. package/.agents/tests/fm-test-fixtures.test.sh +132 -0
  637. package/.agents/tests/fm-test-isolation-proof.test.sh +278 -0
  638. package/.agents/tests/fm-test-run.test.sh +1196 -0
  639. package/.agents/tests/fm-tmux-agent-liveness.test.sh +354 -0
  640. package/.agents/tests/fm-tmux-submit-busy.test.sh +340 -0
  641. package/.agents/tests/fm-tool-update-check.test.sh +1044 -0
  642. package/.agents/tests/fm-trace-context-lib.test.sh +253 -0
  643. package/.agents/tests/fm-trace-context-spawn.test.sh +581 -0
  644. package/.agents/tests/fm-transition-lib.test.sh +54 -0
  645. package/.agents/tests/fm-turnend-guard.test.sh +1822 -0
  646. package/.agents/tests/fm-update.test.sh +304 -0
  647. package/.agents/tests/fm-vendor-auth-probe.test.sh +395 -0
  648. package/.agents/tests/fm-voice-relay.test.sh +4741 -0
  649. package/.agents/tests/fm-wake-daemon-lifecycle-e2e.test.sh +171 -0
  650. package/.agents/tests/fm-wake-drain-open-decisions-cursor.test.sh +356 -0
  651. package/.agents/tests/fm-wake-drain-open-decisions.test.sh +226 -0
  652. package/.agents/tests/fm-wake-drain-unread-status.test.sh +379 -0
  653. package/.agents/tests/fm-wake-queue.test.sh +1224 -0
  654. package/.agents/tests/fm-watch-arm.test.sh +821 -0
  655. package/.agents/tests/fm-watch-checkpoint.test.sh +87 -0
  656. package/.agents/tests/fm-watch-recovery-loop.test.sh +223 -0
  657. package/.agents/tests/fm-watch-triage.test.sh +3889 -0
  658. package/.agents/tests/fm-watcher-lock.test.sh +1133 -0
  659. package/.agents/tests/fm-x-mode.test.sh +3042 -0
  660. package/.agents/tests/gleipnir-guard-pretool-check.test.sh +51 -0
  661. package/.agents/tests/gleipnir-machine-lock.test.sh +130 -0
  662. package/.agents/tests/herdr-test-safety.sh +42 -0
  663. package/.agents/tests/lib.sh +315 -0
  664. package/.agents/tests/remote-herdr-fixture.sh +125 -0
  665. package/.agents/tests/secondmate-helpers.sh +193 -0
  666. package/.agents/tests/smoke.test.sh +93 -0
  667. package/.agents/tests/wake-helpers.sh +338 -0
  668. package/.agents/tests/zellij-test-safety.sh +60 -0
  669. package/.agents/tools/README.md +10 -0
  670. package/.agents/tools/bin/tasks-cli +267 -0
  671. package/.agents/tools/tasks-cli.ts +216 -0
  672. package/AGENTS.md +498 -0
  673. package/CHANGELOG.md +1913 -0
  674. package/LICENSE +1 -1
  675. package/NOTICE +63 -0
  676. package/README.md +114 -60
  677. package/RULES/01-domains.md +51 -0
  678. package/RULES/02-agents.md +78 -0
  679. package/RULES/03-houses.md +61 -0
  680. package/RULES/04-hoard.md +97 -0
  681. package/RULES/05-platforms.md +67 -0
  682. package/RULES/06-append-only.md +108 -0
  683. package/RULES/07-config.md +101 -0
  684. package/RULES/08-delivery-gate.md +75 -0
  685. package/RULES/09-electron.md +58 -0
  686. package/RULES/README.md +22 -0
  687. package/bin/README.md +107 -0
  688. package/bin/a2a-mcp.sh +133 -0
  689. package/bin/a2a-serve.py +103 -0
  690. package/bin/a2a-serve.sh +8 -0
  691. package/bin/a2a-talk.sh +50 -0
  692. package/bin/a2abridge-ensure.sh +108 -0
  693. package/bin/agent-run.sh +43 -0
  694. package/bin/agents-config.sh +256 -0
  695. package/bin/apodex-smoke-test.sh +105 -0
  696. package/bin/bifrost-bridge.sh +131 -0
  697. package/bin/bifrost-ingress.sh +77 -0
  698. package/bin/bootstrap-macos.sh +112 -0
  699. package/bin/bootstrap-windows.ps1 +112 -0
  700. package/bin/branch-guard.sh +64 -0
  701. package/bin/brokk +73 -0
  702. package/bin/brokk-classify-lib.sh +1770 -0
  703. package/bin/brokk-lease-lib.sh +222 -0
  704. package/bin/brokk-lease.sh +193 -0
  705. package/bin/brokk-lint.sh +65 -0
  706. package/bin/brokk-send.sh +51 -0
  707. package/bin/brokk-update.sh +9 -0
  708. package/bin/brokk-wake-grant.sh +127 -0
  709. package/bin/brokk-wake-lib.sh +1849 -0
  710. package/bin/changelog-assemble.sh +154 -0
  711. package/bin/changelog-guard.sh +197 -0
  712. package/bin/crash-sense.sh +173 -0
  713. package/bin/design-check.sh +80 -0
  714. package/bin/design-icon.sh +153 -0
  715. package/bin/desktop-place.sh +224 -0
  716. package/bin/docs-guard.sh +39 -0
  717. package/bin/editor-place.sh +225 -0
  718. package/bin/eindri-acclaim.sh +47 -0
  719. package/bin/eindri-control.sh +28 -0
  720. package/bin/eindri-role.sh +83 -0
  721. package/bin/eindri-seat.sh +83 -0
  722. package/bin/eindri-seen.sh +54 -0
  723. package/bin/eindri-send.sh +15 -0
  724. package/bin/eindri-start.sh +98 -0
  725. package/bin/eindri-watch.sh +57 -0
  726. package/bin/einherjar-spawn.sh +576 -0
  727. package/bin/eir-doctor.sh +158 -0
  728. package/bin/erindi-brief.sh +271 -0
  729. package/bin/feature-inventory.sh +73 -0
  730. package/bin/fleet-apply.sh +81 -0
  731. package/bin/github-deploy.sh +94 -0
  732. package/bin/gjallarhorn-expose.sh +136 -0
  733. package/bin/gjallarhorn-notify.sh +71 -0
  734. package/bin/gjallarhorn-purge.sh +67 -0
  735. package/bin/gjallarhorn-tunnel.sh +85 -0
  736. package/bin/gleipnir-lock-lib.sh +284 -0
  737. package/bin/groa-update.sh +130 -0
  738. package/bin/hall-snapshot.sh +111 -0
  739. package/bin/hamr-harness.sh +246 -0
  740. package/bin/heimdall-ssh-keys.sh +374 -0
  741. package/bin/herdr-agents.py +44 -0
  742. package/bin/herdr-ensure.sh +104 -0
  743. package/bin/herdr-run.sh +452 -0
  744. package/bin/hermes-ensure.sh +88 -0
  745. package/bin/hlidskjalf-agents.sh +172 -0
  746. package/bin/hlidskjalf-usage.sh +196 -0
  747. package/bin/hoard-guard.sh +135 -0
  748. package/bin/hoard-lib.sh +105 -0
  749. package/bin/hodd.sh +102 -0
  750. package/bin/host-sense.sh +199 -0
  751. package/bin/huginn-research-worker.sh +121 -0
  752. package/bin/local-model-lock.sh +70 -0
  753. package/bin/mimir-bridge.py +259 -0
  754. package/bin/mimir-bridge.sh +100 -0
  755. package/bin/mimir-ingest.sh +131 -0
  756. package/bin/mimir-reflect.sh +27 -0
  757. package/bin/mimir.sh +113 -0
  758. package/bin/mjollnir-webhook.sh +72 -0
  759. package/bin/mjollnir.sh +104 -0
  760. package/bin/model-hardware.sh +96 -0
  761. package/bin/model-resolve.sh +160 -0
  762. package/bin/models-detect.sh +178 -0
  763. package/bin/nornir-cron-start.sh +162 -0
  764. package/bin/nornir-job-daily-briefing.sh +191 -0
  765. package/bin/nornir-job-git-sync.sh +152 -0
  766. package/bin/nornir-job-hall-snapshot.sh +28 -0
  767. package/bin/nornir-job-memory-housekeeping.sh +114 -0
  768. package/bin/nornir-job-nsr-compliance.sh +54 -0
  769. package/bin/nornir-job-observer.sh +166 -0
  770. package/bin/nornir-job-skillopt-sleep.sh +76 -0
  771. package/bin/npm-publish.sh +136 -0
  772. package/bin/omarchy-hook-install.sh +60 -0
  773. package/bin/omarchy-install.sh +161 -0
  774. package/bin/omarchy-plugins.sh +187 -0
  775. package/bin/omarchy-sense.sh +162 -0
  776. package/bin/perm-guard.sh +30 -0
  777. package/bin/pi-agent.sh +68 -0
  778. package/bin/pi-ensure.sh +77 -0
  779. package/bin/pi-local.sh +37 -0
  780. package/bin/pi-model-agnostic.sh +71 -0
  781. package/bin/pi-seat.sh +63 -0
  782. package/bin/prereq-ensure.sh +166 -0
  783. package/bin/private-guard.sh +58 -0
  784. package/bin/project-git.sh +69 -0
  785. package/bin/public-guard.sh +71 -0
  786. package/bin/ratatoskr.sh +49 -0
  787. package/bin/realm-lib.sh +32 -0
  788. package/bin/repo-scrub.sh +62 -0
  789. package/bin/rodd-operational-input.sh +250 -0
  790. package/bin/runes-append.sh +198 -0
  791. package/bin/saga-session-start.sh +172 -0
  792. package/bin/saga-sessionstart-run.sh +79 -0
  793. package/bin/saga-wake-drain.sh +41 -0
  794. package/bin/secret-guard.sh +63 -0
  795. package/bin/sessrumnir-ensure.sh +100 -0
  796. package/bin/sessrumnir-sync.sh +237 -0
  797. package/bin/sessrumnir.sh +104 -0
  798. package/bin/skillopt-setup.sh +76 -0
  799. package/bin/skuld-branch-outcome.sh +203 -0
  800. package/bin/skuld-branch-prompt.sh +101 -0
  801. package/bin/smidja-bootstrap.sh +94 -0
  802. package/bin/smidja-observe.sh +86 -0
  803. package/bin/syn-arm-pretool-check.sh +57 -0
  804. package/bin/syn-asset-pretool-check.sh +89 -0
  805. package/bin/syn-cd-pretool-check.sh +28 -0
  806. package/bin/syn-guard-pretool-check.sh +83 -0
  807. package/bin/syn-spawn-pretool-check.sh +58 -0
  808. package/bin/syn-turnend-guard.sh +36 -0
  809. package/bin/syn-watch-arm.sh +99 -0
  810. package/bin/tailscale-sync.sh +203 -0
  811. package/bin/telegram-bot.sh +82 -0
  812. package/bin/toolchain.sh +74 -0
  813. package/bin/ui-truth-check.sh +75 -0
  814. package/bin/utgard.sh +111 -0
  815. package/bin/valhalla.sh +115 -0
  816. package/bin/valknut-load.sh +298 -0
  817. package/bin/vor-crew-state.sh +124 -0
  818. package/bin/wedge-notify.sh +60 -0
  819. package/bin/workspace-provision.sh +80 -0
  820. package/bin/workspace-rag.sh +163 -0
  821. package/bin/wyrd-db.sh +47 -0
  822. package/bin/yggdrasil.sh +152 -0
  823. package/bin/ymir-edit.sh +145 -0
  824. package/bin/ymir-install.sh +806 -0
  825. package/bin/ymir-invite.sh +93 -0
  826. package/bin/ymir-isolation.sh +88 -0
  827. package/bin/ymir-migrate.sh +61 -0
  828. package/bin/ymir-plan.sh +356 -0
  829. package/bin/ymir-platform.sh +348 -0
  830. package/bin/ymir-say.sh +110 -0
  831. package/bin/ymir-setup-auth.sh +119 -0
  832. package/bin/ymir-validate.sh +193 -0
  833. package/bin/ymir.js +74 -16
  834. package/docs/Architecture.md +339 -0
  835. package/docs/design.md +351 -0
  836. package/docs/installations/README.md +63 -0
  837. package/docs/installations/ymir-home.md +93 -0
  838. package/docs/integration.md +105 -0
  839. package/docs/lore.md +1060 -0
  840. package/docs/research/agent-harnesses.md +290 -0
  841. package/docs/runbooks/README.md +19 -0
  842. package/docs/runbooks/a2a.md +91 -0
  843. package/docs/runbooks/agent-permissions.md +99 -0
  844. package/docs/runbooks/agents.md +57 -0
  845. package/docs/runbooks/models.md +69 -0
  846. package/docs/runbooks/realm-onboarding.md +60 -0
  847. package/docs/runbooks/secrets-and-hoard.md +60 -0
  848. package/docs/runbooks/updates-and-migrations.md +110 -0
  849. package/docs/runbooks/worktrees-isolation.md +72 -0
  850. package/docs/session-start.md +105 -0
  851. package/docs/skillopt-integration.md +321 -0
  852. package/docs/sync-system.md +66 -0
  853. package/docs/workspaces.md +104 -0
  854. package/docs/ymir-rut.md +260 -0
  855. package/package.json +54 -14
  856. package/packaging/build.sh +74 -0
  857. package/packaging/macos/Ymir Installer.command +43 -0
  858. package/packaging/windows/ymir-setup.nsi +74 -0
  859. package/scripts/README.md +22 -0
  860. package/scripts/electron.sh +269 -0
  861. package/scripts/lower.sh +34 -0
  862. package/scripts/raise.sh +42 -0
  863. package/scripts/start.sh +217 -0
  864. package/scripts/stop.sh +113 -0
  865. package/install.sh +0 -35
@@ -0,0 +1,3182 @@
1
+ #!/usr/bin/env bash
2
+ # Tests for the tracked Pi supervision-branch extension
3
+ # (.pi/extensions/fm-branch-supervision.ts): wake dispatch acceptance and
4
+ # gating, the two-stage noise filter's second stage (verdict-driven delivery
5
+ # into main), store-first durability through the real bin/fm-branch-outcome.sh,
6
+ # the byte-stable tool order and per-home prompt_cache_key hook, the dialog
7
+ # mirror, and branch-session persistence. The Pi SDK is stubbed (scriptable
8
+ # in-process sessions); every fleet-record behavior runs the REAL bin scripts.
9
+ set -u
10
+
11
+ # shellcheck source=tests/lib.sh
12
+ . "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
13
+
14
+ TMP_ROOT=$(fm_test_tmproot fm-pi-branch-extension)
15
+ EXT="$ROOT/.pi/extensions/fm-branch-supervision.ts"
16
+ export NODE_NO_WARNINGS=1
17
+
18
+ # Keep JavaScript heredocs outside command substitutions. Stock macOS Bash
19
+ # 3.2 reparses quotes and template literals inside that combination.
20
+ install_pi_branch_extension_fixture() {
21
+ local repo=$1
22
+ mkdir -p \
23
+ "$repo/.pi/extensions/lib" \
24
+ "$repo/node_modules/@earendil-works/pi-coding-agent" \
25
+ "$repo/node_modules/@earendil-works/pi-ai" \
26
+ "$repo/node_modules/@earendil-works/pi-tui" \
27
+ "$repo/node_modules/typebox"
28
+ cp "$EXT" "$repo/.pi/extensions/fm-branch-supervision.ts"
29
+ cp "$ROOT/.pi/extensions/lib/fm-branch-dispatch.ts" "$repo/.pi/extensions/lib/fm-branch-dispatch.ts"
30
+ cp "$ROOT/.pi/extensions/lib/fm-branch-model-picker.ts" "$repo/.pi/extensions/lib/fm-branch-model-picker.ts"
31
+ cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$repo/.pi/extensions/lib/fm-calm-visibility.ts"
32
+ cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$repo/.pi/extensions/lib/fm-operational-input.ts"
33
+ mkdir -p "$repo/bin"
34
+ cp "$ROOT/bin/fm-operational-input.sh" "$repo/bin/fm-operational-input.sh"
35
+ chmod +x "$repo/bin/fm-operational-input.sh"
36
+ cat > "$repo/node_modules/@earendil-works/pi-coding-agent/package.json" <<'JSON'
37
+ {"name":"@earendil-works/pi-coding-agent","type":"module","exports":"./index.js"}
38
+ JSON
39
+ cat > "$repo/node_modules/@earendil-works/pi-coding-agent/index.js" <<'JS'
40
+ import { writeFileSync } from "node:fs";
41
+
42
+ export function getAgentDir() {
43
+ return "/stub-agent-dir";
44
+ }
45
+
46
+ export function getMarkdownTheme() {
47
+ return {};
48
+ }
49
+
50
+ export function keyHint(_keybinding, description) {
51
+ return `ctrl+o ${description}`;
52
+ }
53
+
54
+ export class ToolExecutionComponent {
55
+ updateResult(result) {
56
+ this.result = result;
57
+ }
58
+ render() {
59
+ return (this.result?.content ?? [])
60
+ .filter((item) => item.type === "text")
61
+ .flatMap((item) => item.text.split("\n"));
62
+ }
63
+ }
64
+
65
+ export class UserMessageComponent {}
66
+
67
+ export class DynamicBorder {
68
+ constructor(color) {
69
+ this.color = color;
70
+ }
71
+ invalidate() {}
72
+ render() {
73
+ return ["--"];
74
+ }
75
+ }
76
+
77
+ export class ModelRuntime {
78
+ constructor() {
79
+ this.models = (globalThis.__fmBranchStaticModels?.() ?? []).map((model) => ({ ...model }));
80
+ this.authenticated = new Set(this.models.filter((model) => model.storedAuth !== false).map((model) => model.provider));
81
+ }
82
+ static async create() {
83
+ const queuedError = globalThis.__fmModelRuntimeErrors?.shift();
84
+ if (queuedError) throw new Error(queuedError);
85
+ if (globalThis.__fmModelRuntimeError) throw new Error(globalThis.__fmModelRuntimeError);
86
+ const runtime = new ModelRuntime();
87
+ (globalThis.__fmModelRuntimes ??= []).push(runtime);
88
+ return runtime;
89
+ }
90
+ getModel(provider, id) {
91
+ return this.models.find((model) => model.provider === provider && model.id === id);
92
+ }
93
+ hasConfiguredAuth(provider) {
94
+ return this.authenticated.has(provider);
95
+ }
96
+ }
97
+ export class DefaultResourceLoader {
98
+ constructor(options) {
99
+ this.options = options;
100
+ (globalThis.__fmLoaders ??= []).push(this);
101
+ }
102
+ async reload() {
103
+ this.reloaded = true;
104
+ }
105
+ }
106
+
107
+ export class SessionManager {
108
+ constructor(file) {
109
+ this.file = file;
110
+ }
111
+ static create(cwd, dir) {
112
+ globalThis.__fmCreateCount = (globalThis.__fmCreateCount ?? 0) + 1;
113
+ const sm = new SessionManager(`${dir}/created-${globalThis.__fmCreateCount}.jsonl`);
114
+ sm.created = true;
115
+ writeFileSync(sm.file, "");
116
+ (globalThis.__fmSessionManagers ??= []).push(sm);
117
+ return sm;
118
+ }
119
+ static open(path) {
120
+ const sm = new SessionManager(path);
121
+ sm.opened = true;
122
+ (globalThis.__fmSessionManagers ??= []).push(sm);
123
+ return sm;
124
+ }
125
+ getSessionFile() {
126
+ return this.file;
127
+ }
128
+ buildSessionContext() {
129
+ const model = globalThis.__fmRecordedModels?.get(this.file) ?? null;
130
+ return { messages: model ? [{ role: "assistant", content: [], provider: model.provider, model: model.modelId }] : [], thinkingLevel: "medium", model };
131
+ }
132
+ }
133
+
134
+ export function createBashToolDefinition(cwd, options) {
135
+ return {
136
+ name: "bash",
137
+ label: "stub bash",
138
+ description: "stub bash",
139
+ parameters: { type: "object" },
140
+ __cwd: cwd,
141
+ __options: options,
142
+ execute: async (_toolCallId, params) => {
143
+ if (!globalThis.__fmExecuteBranchBash) return { content: [], details: undefined };
144
+ const initial = { command: String(params.command ?? ""), cwd, env: { ...process.env } };
145
+ const context = options.spawnHook ? options.spawnHook(initial) : initial;
146
+ return globalThis.__fmExecuteBranchBash(context);
147
+ },
148
+ };
149
+ }
150
+
151
+ export async function createAgentSession(options) {
152
+ if (globalThis.__fmCreateSessionError) throw new Error(globalThis.__fmCreateSessionError);
153
+ globalThis.__fmCreateStarted = (globalThis.__fmCreateStarted ?? 0) + 1;
154
+ if (globalThis.__fmCreateGate) await globalThis.__fmCreateGate;
155
+ if (options.model && (!options.modelRuntime || !options.modelRuntime.getModel(options.model.provider, options.model.id))) {
156
+ throw new Error(`branch runtime cannot use ${options.model.provider}/${options.model.id}`);
157
+ }
158
+ const session = {
159
+ options,
160
+ ops: [],
161
+ disposed: false,
162
+ async prompt(text) {
163
+ if (globalThis.__fmPromptGate) {
164
+ globalThis.__fmPromptStarted = true;
165
+ await globalThis.__fmPromptGate;
166
+ }
167
+ session.ops.push({ kind: "prompt", text });
168
+ (globalThis.__fmPrompts ??= []).push(text);
169
+ await globalThis.__fmOnBranchPrompt?.({ session, text });
170
+ },
171
+ async sendCustomMessage(message, opts) {
172
+ if (globalThis.__fmMirrorGate) {
173
+ globalThis.__fmMirrorStarted = true;
174
+ await globalThis.__fmMirrorGate;
175
+ }
176
+ session.ops.push({ kind: "custom", message, opts });
177
+ (globalThis.__fmMirrors ??= []).push(message);
178
+ },
179
+ dispose() {
180
+ session.disposed = true;
181
+ },
182
+ };
183
+ const restoredModel = options.model
184
+ ? { provider: options.model.provider, modelId: options.model.id }
185
+ : globalThis.__fmRecordedModels?.get(options.sessionManager.getSessionFile());
186
+ if (restoredModel) (globalThis.__fmRecordedModels ??= new Map()).set(options.sessionManager.getSessionFile(), restoredModel);
187
+ (globalThis.__fmSessions ??= []).push(session);
188
+ return { session, extensionsResult: {} };
189
+ }
190
+ JS
191
+ cat > "$repo/node_modules/@earendil-works/pi-ai/package.json" <<'JSON'
192
+ {"name":"@earendil-works/pi-ai","type":"module","exports":"./index.js"}
193
+ JSON
194
+ cat > "$repo/node_modules/@earendil-works/pi-ai/index.js" <<'JS'
195
+ // Pi's own thinking-level rules, reproduced from the installed package so the
196
+ // portable regression can drive models with different effort ceilings. The
197
+ // live guard (tests/fm-pi-branch-live-e2e.test.sh) is what proves the real
198
+ // vendor surface still behaves this way.
199
+ const EXTENDED_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
200
+
201
+ export function getSupportedThinkingLevels(model) {
202
+ if (!model.reasoning) return ["off"];
203
+ return EXTENDED_THINKING_LEVELS.filter((level) => {
204
+ const mapped = model.thinkingLevelMap?.[level];
205
+ if (mapped === null) return false;
206
+ if (level === "xhigh" || level === "max") return mapped !== undefined;
207
+ return true;
208
+ });
209
+ }
210
+
211
+ export function clampThinkingLevel(model, level) {
212
+ const available = getSupportedThinkingLevels(model);
213
+ if (available.includes(level)) return level;
214
+ const requested = EXTENDED_THINKING_LEVELS.indexOf(level);
215
+ if (requested === -1) return available[0] ?? "off";
216
+ for (let i = requested; i < EXTENDED_THINKING_LEVELS.length; i += 1) {
217
+ if (available.includes(EXTENDED_THINKING_LEVELS[i])) return EXTENDED_THINKING_LEVELS[i];
218
+ }
219
+ for (let i = requested - 1; i >= 0; i -= 1) {
220
+ if (available.includes(EXTENDED_THINKING_LEVELS[i])) return EXTENDED_THINKING_LEVELS[i];
221
+ }
222
+ return available[0] ?? "off";
223
+ }
224
+ JS
225
+ cat > "$repo/node_modules/@earendil-works/pi-tui/package.json" <<'JSON'
226
+ {"name":"@earendil-works/pi-tui","type":"module","exports":"./index.js"}
227
+ JSON
228
+ cat > "$repo/node_modules/@earendil-works/pi-tui/index.js" <<'JS'
229
+ export class Text {
230
+ constructor(text, paddingX, paddingY) {
231
+ this.text = text;
232
+ this.paddingX = paddingX;
233
+ this.paddingY = paddingY;
234
+ }
235
+ }
236
+
237
+ export class Container {
238
+ constructor() {
239
+ this.children = [];
240
+ }
241
+ addChild(child) {
242
+ this.children.push(child);
243
+ }
244
+ clear() {
245
+ this.children = [];
246
+ }
247
+ render() {
248
+ return this.children.flatMap((child) => child.render?.() ?? []);
249
+ }
250
+ }
251
+
252
+ export class Box extends Container {
253
+ constructor(paddingX, paddingY, bgFn) {
254
+ super();
255
+ this.paddingX = paddingX;
256
+ this.paddingY = paddingY;
257
+ this.bgFn = bgFn;
258
+ }
259
+ setBgFn(bgFn) {
260
+ this.bgFn = bgFn;
261
+ }
262
+ }
263
+
264
+ export class Input {
265
+ constructor() {
266
+ this.value = "";
267
+ this.focused = false;
268
+ }
269
+ getValue() {
270
+ return this.value;
271
+ }
272
+ setValue(value) {
273
+ this.value = value;
274
+ }
275
+ handleInput(data) {
276
+ this.value = data === "\u007f" ? this.value.slice(0, -1) : this.value + data;
277
+ }
278
+ invalidate() {}
279
+ render() {
280
+ return [this.value];
281
+ }
282
+ }
283
+
284
+ // Records every construction so a driver can assert the rows and the visible
285
+ // bound the extension asked Pi's real SelectList for. Navigation keys arrive
286
+ // as their keybinding ids because the driver's fake keybindings manager
287
+ // matches an id against the raw key data.
288
+ export class SelectList {
289
+ constructor(items, maxVisible, theme) {
290
+ this.items = items;
291
+ this.maxVisible = maxVisible;
292
+ this.theme = theme;
293
+ this.selectedIndex = 0;
294
+ (globalThis.__fmPickerLists ??= []).push({ items: items.map((item) => ({ ...item })), maxVisible });
295
+ }
296
+ handleInput(data) {
297
+ if (data === "tui.select.down") {
298
+ this.selectedIndex = Math.min(this.selectedIndex + 1, this.items.length - 1);
299
+ } else if (data === "tui.select.up") {
300
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
301
+ } else if (data === "tui.select.confirm") {
302
+ const item = this.items[this.selectedIndex];
303
+ if (item) this.onSelect?.(item);
304
+ } else if (data === "tui.select.cancel") {
305
+ this.onCancel?.();
306
+ }
307
+ }
308
+ getSelectedItem() {
309
+ return this.items[this.selectedIndex] ?? null;
310
+ }
311
+ invalidate() {}
312
+ render() {
313
+ return this.items.slice(0, this.maxVisible).map((item) => item.label);
314
+ }
315
+ }
316
+
317
+ export function fuzzyFilter(items, query, getText) {
318
+ const needle = query.toLowerCase();
319
+ return items.filter((item) => getText(item).toLowerCase().includes(needle));
320
+ }
321
+ JS
322
+ cat > "$repo/node_modules/typebox/package.json" <<'JSON'
323
+ {"name":"typebox","type":"module","exports":"./index.js"}
324
+ JSON
325
+ cat > "$repo/node_modules/typebox/index.js" <<'JS'
326
+ export const Type = {
327
+ Object(properties, options) {
328
+ return { type: "object", properties, ...(options ?? {}) };
329
+ },
330
+ String(options) {
331
+ return { type: "string", ...(options ?? {}) };
332
+ },
333
+ Number(options) {
334
+ return { type: "number", ...(options ?? {}) };
335
+ },
336
+ Boolean(options) {
337
+ return { type: "boolean", ...(options ?? {}) };
338
+ },
339
+ Optional(schema) {
340
+ return { ...schema, optional: true };
341
+ },
342
+ Literal(value) {
343
+ return { const: value };
344
+ },
345
+ Union(schemas, options) {
346
+ return { anyOf: schemas, ...(options ?? {}) };
347
+ },
348
+ };
349
+ JS
350
+ }
351
+
352
+ # Shared driver preamble: a fake main-session ExtensionAPI with a synchronous
353
+ # event bus (mirrors pi's EventEmitter-backed bus), captured handlers, and
354
+ # captured main-bound messages.
355
+ DRIVER_PRELUDE_FILE="$TMP_ROOT/driver-prelude.js"
356
+ cat > "$DRIVER_PRELUDE_FILE" <<'JS'
357
+ const { spawnSync } = await import("node:child_process");
358
+ const { mkdirSync, writeFileSync } = await import("node:fs");
359
+ const { pathToFileURL } = await import("node:url");
360
+
361
+ const home = process.env.FM_HOME;
362
+ const realRoot = process.env.FM_ROOT_OVERRIDE;
363
+ const approvedProject = `${home}/projects/approved`;
364
+ mkdirSync(`${home}/state`, { recursive: true });
365
+ mkdirSync(`${home}/config`, { recursive: true });
366
+ mkdirSync(approvedProject, { recursive: true });
367
+ writeFileSync(`${home}/state/branch-driver.meta`, `project=${approvedProject}\nwindow=fm-branch-driver\n`);
368
+ // Supervision is default-on for every task: no captain grant file gates it
369
+ // any more.
370
+ // The branch acts only for the session that owns the fleet lock; drivers own
371
+ // it by default, while cold-start and secondary-session scenarios opt out.
372
+ if (!process.env.FM_TEST_SKIP_LOCK) {
373
+ writeFileSync(`${home}/state/.lock`, `${process.pid}\n`);
374
+ }
375
+
376
+ // Stubbed model surface: Pi's own catalog and selector dialog, scripted.
377
+ // registryModels is what ctx.modelRegistry serves, uiSelections queues the
378
+ // captain's answers to ctx.ui.select, and notices records what the command
379
+ // told the captain.
380
+ const registryModels = [];
381
+ const uiSelections = [];
382
+ const uiPrompts = [];
383
+ const notices = [];
384
+ const commands = new Map();
385
+ let mainModel = { provider: "anthropic", id: "main-model" };
386
+ // Main's own effort, which Pi answers through pi.getThinkingLevel(). Drivers
387
+ // change it through setMainThinkingLevel and then fire Pi's own
388
+ // thinking_level_select event, exactly as Pi does for a real /settings pick.
389
+ let mainThinkingLevel = "medium";
390
+ function setMainThinkingLevel(level) {
391
+ mainThinkingLevel = level;
392
+ }
393
+ globalThis.__fmBranchStaticModels = () => registryModels
394
+ .filter((model) => model.branchAvailable !== false)
395
+ .map((model) => ({ ...model }));
396
+ const modelRegistry = {
397
+ getAvailable: () => registryModels.filter((model) => model.mainAvailable !== false).slice(),
398
+ find: (provider, id) => registryModels.find((model) => model.provider === provider && model.id === id),
399
+ hasConfiguredAuth: (model) => model.mainAvailable !== false,
400
+ };
401
+ function makeCtx(extra) {
402
+ return {
403
+ modelRegistry,
404
+ get model() {
405
+ return mainModel;
406
+ },
407
+ ui: {
408
+ select(title, options) {
409
+ uiPrompts.push({ title, options });
410
+ return Promise.resolve(uiSelections.shift());
411
+ },
412
+ notify(message, type) {
413
+ notices.push({ message, type });
414
+ },
415
+ },
416
+ ...(extra ?? {}),
417
+ };
418
+ }
419
+
420
+ // A TUI-mode context whose ui.custom runs the extension's real picker
421
+ // component headlessly: the smidja receives a fake renderer, a pass-through
422
+ // theme, and a keybindings manager that matches a keybinding id against the
423
+ // raw key data, and then the next queued keystroke script is fed to the
424
+ // component's own handleInput. Keystrokes are either a keybinding id
425
+ // (navigation) or literal characters (search). mainModelWrites records every
426
+ // attempt to move the captain's own model, which pinning the branch must
427
+ // never do.
428
+ const uiKeystrokes = [];
429
+ const mainModelWrites = [];
430
+ function makeTuiCtx(extra) {
431
+ const base = makeCtx(extra);
432
+ return {
433
+ ...base,
434
+ get model() {
435
+ return mainModel;
436
+ },
437
+ mode: "tui",
438
+ settingsManager: {
439
+ setDefaultModelAndProvider(provider, id) {
440
+ mainModelWrites.push({ provider, id });
441
+ },
442
+ },
443
+ setModel(provider, id) {
444
+ mainModelWrites.push({ provider, id });
445
+ },
446
+ ui: {
447
+ ...base.ui,
448
+ async custom(smidja) {
449
+ let result;
450
+ let settled = false;
451
+ const component = await smidja(
452
+ {
453
+ requestRender() {},
454
+ },
455
+ { fg: (_color, text) => text, bold: (text) => text },
456
+ { matches: (data, id) => data === id },
457
+ (value) => {
458
+ result = value;
459
+ settled = true;
460
+ },
461
+ );
462
+ component.render(80);
463
+ for (const key of uiKeystrokes.shift() ?? ["tui.select.confirm"]) {
464
+ if (settled) break;
465
+ component.handleInput(key);
466
+ }
467
+ if (!settled) throw new Error("the picker script ended without a selection or a cancellation");
468
+ return result;
469
+ },
470
+ },
471
+ };
472
+ }
473
+
474
+ const busHandlers = new Map();
475
+ const bus = {
476
+ on(channel, handler) {
477
+ busHandlers.set(channel, [...(busHandlers.get(channel) ?? []), handler]);
478
+ return () => {};
479
+ },
480
+ emit(channel, data) {
481
+ for (const handler of busHandlers.get(channel) ?? []) handler(data);
482
+ },
483
+ };
484
+ const piHandlers = new Map();
485
+ const sentToMain = [];
486
+ const mainUserMessages = [];
487
+ const mainTools = [];
488
+ const renderers = new Map();
489
+ const pi = {
490
+ events: bus,
491
+ on(event, handler) {
492
+ piHandlers.set(event, [...(piHandlers.get(event) ?? []), handler]);
493
+ },
494
+ registerTool(tool) {
495
+ mainTools.push(tool);
496
+ },
497
+ registerCommand(name, options) {
498
+ commands.set(name, options);
499
+ },
500
+ registerMessageRenderer(customType, renderer) {
501
+ renderers.set(customType, renderer);
502
+ },
503
+ sendMessage(message, options) {
504
+ sentToMain.push({ message, options: options ?? {} });
505
+ },
506
+ sendUserMessage(content, options) {
507
+ mainUserMessages.push({ content, options: options ?? {} });
508
+ },
509
+ getThinkingLevel() {
510
+ if (globalThis.__fmThinkingLevelError) throw new Error(globalThis.__fmThinkingLevelError);
511
+ return mainThinkingLevel;
512
+ },
513
+ };
514
+ function fire(event, payload, ctx) {
515
+ for (const handler of piHandlers.get(event) ?? []) handler(payload, ctx);
516
+ }
517
+ function makeOffer(message, projects = [approvedProject], heartbeat = false, eligible = projects.length > 0 || heartbeat) {
518
+ const offer = {
519
+ message,
520
+ projects,
521
+ heartbeat,
522
+ eligible,
523
+ accepted: false,
524
+ accept() {
525
+ offer.accepted = true;
526
+ },
527
+ };
528
+ return offer;
529
+ }
530
+ function dispatch(message, projects, heartbeat, eligible) {
531
+ const offer = makeOffer(message, projects, heartbeat, eligible);
532
+ if (offer.eligible) {
533
+ const row = offer.heartbeat
534
+ ? "1\t1\theartbeat\theartbeat\theartbeat\n"
535
+ : `1\t1\tsignal\tbranch-driver.status\t${message}\n`;
536
+ writeFileSync(`${home}/state/.wake-queue`, row);
537
+ }
538
+ bus.emit("fm-branch-supervision:dispatch", offer);
539
+ return offer;
540
+ }
541
+ async function settle(predicate, label) {
542
+ for (let i = 0; i < 250; i += 1) {
543
+ if (predicate()) return;
544
+ await new Promise((resolve) => setTimeout(resolve, 10));
545
+ }
546
+ throw new Error(`timed out waiting for ${label}`);
547
+ }
548
+ function outcomeScript(args) {
549
+ const result = spawnSync("bash", [`${realRoot}/bin/fm-branch-outcome.sh`, ...args], {
550
+ encoding: "utf8",
551
+ env: { ...process.env, FM_HOME: home, FM_STATE_OVERRIDE: `${home}/state` },
552
+ });
553
+ if (result.status !== 0) throw new Error(`fm-branch-outcome.sh ${args.join(" ")} failed: ${result.stderr}`);
554
+ return (result.stdout || "").trim();
555
+ }
556
+ const mod = await import(pathToFileURL(process.env.PLUGIN).href);
557
+ mod.default(pi);
558
+ JS
559
+ DRIVER_PRELUDE=$(cat "$DRIVER_PRELUDE_FILE")
560
+
561
+ test_branch_dispatch_two_stage_filter_and_prefix_contract() {
562
+ local repo home out status
563
+ repo="$TMP_ROOT/dispatch-root"
564
+ home="$TMP_ROOT/dispatch-home"
565
+ mkdir -p "$home/state" "$home/config"
566
+ install_pi_branch_extension_fixture "$repo"
567
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
568
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
569
+ const prelude = process.env.DRIVER_PRELUDE;
570
+ await eval(`(async () => { ${prelude}; globalThis.__t = { pi, fire, dispatch, settle, outcomeScript, sentToMain, mainUserMessages, mainTools, renderers, home, realRoot }; })()`);
571
+ const { pi, fire, dispatch, settle, outcomeScript, sentToMain, mainUserMessages, mainTools, renderers, home, realRoot } = globalThis.__t;
572
+ import { readFileSync, writeFileSync } from "node:fs";
573
+
574
+ writeFileSync(`${home}/state/.lock`, `${process.ppid}\n`);
575
+
576
+ // 1. An accepted wake reaches the branch session, never main.
577
+ const offer = dispatch("signal: task-9 done: PR https://example.com/pr/9 checks green");
578
+ if (!offer.accepted) throw new Error("branch did not accept the wake offer");
579
+ await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "branch wake prompt");
580
+ const wakePrompt = globalThis.__fmPrompts[0];
581
+ if (!wakePrompt.includes("FIRSTMATE SUPERVISION WAKE: signal: task-9 done")) {
582
+ throw new Error(`branch prompt lost the wake reason: ${wakePrompt}`);
583
+ }
584
+ if (mainUserMessages.length !== 0) throw new Error("accepted wake leaked to main as a user message");
585
+
586
+ // 2. Byte-stable prefix contract: same tool names in the same order, a
587
+ // generator-produced system prompt, no project resources, and the branch bash
588
+ // carries the deterministic actor identity.
589
+ const session = globalThis.__fmSessions[0];
590
+ if (JSON.stringify(session.options.tools) !== JSON.stringify(["read", "bash", "fm_branch_report"])) {
591
+ throw new Error(`unexpected tool order: ${JSON.stringify(session.options.tools)}`);
592
+ }
593
+ const loader = globalThis.__fmLoaders[0];
594
+ for (const key of ["noExtensions", "noSkills", "noPromptTemplates", "noThemes", "noContextFiles"]) {
595
+ if (loader.options[key] !== true) throw new Error(`branch loader must set ${key}`);
596
+ }
597
+ if (!loader.options.systemPrompt || !loader.options.systemPrompt.startsWith("You are the SUPERVISION BRANCH")) {
598
+ throw new Error("branch system prompt is not the generator's output");
599
+ }
600
+ if (loader.options.systemPrompt.length < 4096) throw new Error("branch prompt is below the provider caching minimum");
601
+ const bashTool = session.options.customTools.find((tool) => tool.name === "bash");
602
+ const hooked = bashTool.__options.spawnHook({ command: "true", cwd: "/x", env: { PATH: "/bin" } });
603
+ if (hooked.env.FM_SUPERVISION_ACTOR !== "branch") throw new Error("branch bash does not inject the branch actor");
604
+ if (hooked.env.FM_LEASE_HOLDER_PID !== String(process.ppid)) throw new Error("branch bash does not pin the verified session-lock holder pid");
605
+
606
+ // 3. Shared per-home prompt_cache_key: overrides only payloads that already
607
+ // carry one, stable within the home.
608
+ let cacheHandler = null;
609
+ const smidjaEntry = loader.options.extensionFactories[0];
610
+ const smidja = typeof smidjaEntry === "function" ? smidjaEntry : smidjaEntry.smidja;
611
+ smidja({ on: (event, handler) => { if (event === "before_provider_request") cacheHandler = handler; } });
612
+ if (!cacheHandler) throw new Error("branch cache-key hook not registered");
613
+ const rewriteA = cacheHandler({ type: "before_provider_request", payload: { prompt_cache_key: "session-a", model: "m" } });
614
+ const rewriteB = cacheHandler({ type: "before_provider_request", payload: { prompt_cache_key: "session-b", model: "m" } });
615
+ if (!rewriteA.prompt_cache_key.startsWith("fm-branch-")) throw new Error(`unexpected cache key: ${rewriteA.prompt_cache_key}`);
616
+ if (rewriteA.prompt_cache_key !== rewriteB.prompt_cache_key) throw new Error("branch cache key varies within one home");
617
+ if (rewriteA.model !== "m") throw new Error("cache-key hook dropped payload fields");
618
+ const untouched = cacheHandler({ type: "before_provider_request", payload: { model: "m" } });
619
+ if (untouched !== undefined) throw new Error("cache-key hook rewrote a provider payload with no prompt_cache_key");
620
+ console.log(`CACHE_KEY=${rewriteA.prompt_cache_key}`);
621
+
622
+ // 4. Two-stage filter, stage 2: routine while main is idle appends with no
623
+ // turn; routine while main is busy defers to after the captain's next prompt;
624
+ // captain-relevant appends and triggers exactly one turn. Store rows are
625
+ // written BEFORE the merge note and marked read after it.
626
+ const report = session.options.customTools.find((tool) => tool.name === "fm_branch_report");
627
+ const r1 = await report.execute("call-1", { task: "task-9", verdict: "routine", summary: "worker healthy, no action needed", wake: "signal: working" }, undefined, undefined, {});
628
+ if (r1.isError) throw new Error(`routine report failed: ${JSON.stringify(r1)}`);
629
+ if (sentToMain.length !== 1) throw new Error("routine report did not merge exactly one note");
630
+ if (sentToMain[0].message.customType !== "fm-branch-merge") throw new Error("merge note has the wrong custom type");
631
+ if (sentToMain[0].options.triggerTurn) throw new Error("routine idle merge must not trigger a turn");
632
+ if (sentToMain[0].options.deliverAs) throw new Error("routine idle merge must append immediately");
633
+ fire("agent_start", {});
634
+ await report.execute("call-2", { task: "task-9", verdict: "routine", summary: "still healthy" }, undefined, undefined, {});
635
+ if (sentToMain[1].options.deliverAs !== "nextTurn" || sentToMain[1].options.triggerTurn) {
636
+ throw new Error(`routine busy merge must defer to nextTurn without a turn: ${JSON.stringify(sentToMain[1].options)}`);
637
+ }
638
+ fire("agent_end", {});
639
+ await report.execute("call-3", { task: "task-9", verdict: "captain", summary: "PR https://example.com/pr/9 checks green, ready for review" }, undefined, undefined, {});
640
+ if (sentToMain[2].options.triggerTurn !== true || sentToMain[2].options.deliverAs !== "followUp") {
641
+ throw new Error(`captain merge must trigger exactly one follow-up turn: ${JSON.stringify(sentToMain[2].options)}`);
642
+ }
643
+ if (typeof sentToMain[0].message.content !== "string" || !sentToMain[0].message.content.startsWith("⛵ ")) {
644
+ throw new Error(`routine note missing sailboat prefix: ${sentToMain[0].message.content}`);
645
+ }
646
+ if (/branch merged|\[routine\]|\[captain\]/.test(sentToMain[0].message.content)) {
647
+ throw new Error(`routine note still has boilerplate: ${sentToMain[0].message.content}`);
648
+ }
649
+ // A routine note is rendered (display: true); a captain-facing note must
650
+ // never be printed or rendered at all - the follow-up turn triggered above
651
+ // is itself the captain-visible outcome. display: false is the exact flag
652
+ // Pi's own chat renderer and HTML export both gate on before ever calling a
653
+ // customType renderer, so this is the authoritative "never printed" proof.
654
+ if (sentToMain[0].message.display !== true) {
655
+ throw new Error(`routine note must render: display=${sentToMain[0].message.display}`);
656
+ }
657
+ if (sentToMain[2].message.display !== false) {
658
+ throw new Error(`captain note must never be printed or rendered: display=${sentToMain[2].message.display}`);
659
+ }
660
+ if (typeof sentToMain[2].message.content !== "string" || sentToMain[2].message.content.includes("⚓")) {
661
+ throw new Error(`captain note must carry no anchor glyph now that it is never rendered: ${sentToMain[2].message.content}`);
662
+ }
663
+ if (!sentToMain[2].message.content.includes("task-9: PR https://example.com/pr/9")) {
664
+ throw new Error(`captain note lost its outcome: ${sentToMain[2].message.content}`);
665
+ }
666
+ if (/branch merged|\[routine\]|\[captain\]/.test(sentToMain[2].message.content)) {
667
+ throw new Error(`captain note still has boilerplate: ${sentToMain[2].message.content}`);
668
+ }
669
+ // What main's model actually receives. Pi keeps only `content` when it turns a
670
+ // custom message into a provider message - customType, display, and details are
671
+ // all dropped - so `content` IS the delivered payload, and these two files are
672
+ // the exact bytes main's model would read. The bash side classifies them with
673
+ // the REAL bin/fm-operational-input.sh so the protocol's own executable, not a
674
+ // pattern in this test, decides what was delivered. Pi's half of that contract
675
+ // is proven separately against the real SDK in fm-pi-branch-live-e2e.test.sh.
676
+ writeFileSync(`${home}/state/delivered-captain-note`, sentToMain[2].message.content);
677
+ writeFileSync(`${home}/state/delivered-routine-note`, sentToMain[0].message.content);
678
+ if (sentToMain.filter((sent) => sent.options.triggerTurn).length !== 1) {
679
+ throw new Error("one captain outcome must open exactly one turn on main");
680
+ }
681
+
682
+ // The store (the owned durable contract) holds all three outcomes in order,
683
+ // and each merged note advanced the read cursor.
684
+ const rows = readFileSync(`${home}/state/branch-outcomes.jsonl`, "utf8").trim().split("\n").map((line) => JSON.parse(line));
685
+ if (rows.length !== 3) throw new Error(`expected 3 store rows, got ${rows.length}`);
686
+ if (rows[0].verdict !== "routine" || rows[2].verdict !== "captain") throw new Error("store verdicts out of order");
687
+ if (rows[0].wake !== "signal: working") throw new Error("store lost the wake reason");
688
+ if (outcomeScript(["unread"]) !== "") throw new Error("merged outcomes were not marked read");
689
+
690
+ // 5. Main-side surfaces: the on-demand store reader tool and the merge-note
691
+ // renderer.
692
+ const outcomesTool = mainTools.find((tool) => tool.name === "fm_branch_outcomes");
693
+ if (!outcomesTool) throw new Error("fm_branch_outcomes was not registered on main");
694
+ const renderTheme = {
695
+ fg(_color, text) { return text; },
696
+ bg(_color, text) { return text; },
697
+ bold(text) { return text; },
698
+ };
699
+ const renderContext = { state: {}, isError: false, isPartial: false };
700
+ const stockResult = { content: [{ type: "text", text: "OUTCOME_DUMP" }] };
701
+ const calmOffCall = outcomesTool.renderCall({}, renderTheme, renderContext);
702
+ const calmOffResult = outcomesTool.renderResult(stockResult, { expanded: false, isPartial: false }, renderTheme, renderContext);
703
+ if (calmOffCall.constructor.name !== "Box" || calmOffCall.paddingX !== 1 || calmOffCall.paddingY !== 1) {
704
+ throw new Error("fm_branch_outcomes changed its ordinary shell rendering");
705
+ }
706
+ if (calmOffResult.constructor.name !== "Container" || calmOffCall.children[0]?.text !== "fm_branch_outcomes" || calmOffCall.children[1]?.text !== "OUTCOME_DUMP") {
707
+ throw new Error("fm_branch_outcomes changed its ordinary call or result rendering");
708
+ }
709
+ const legacyStockResult = {
710
+ content: [{
711
+ type: "text",
712
+ text: Array.from({ length: 12 }, (_, index) => `LEGACY_OUTCOME_${String(index + 1).padStart(2, "0")}`).join("\n"),
713
+ }],
714
+ };
715
+ const legacyRenderContext = { state: {}, isError: false, isPartial: false };
716
+ const legacyCall = outcomesTool.renderCall({}, renderTheme, legacyRenderContext);
717
+ outcomesTool.renderResult(legacyStockResult, { expanded: false, isPartial: false }, renderTheme, legacyRenderContext);
718
+ const collapsedLegacyText = legacyCall.children[1]?.text;
719
+ if (!collapsedLegacyText?.includes("LEGACY_OUTCOME_12") || collapsedLegacyText.includes("more lines")) {
720
+ throw new Error("legacy all-line stock capability did not preserve collapsed Calm-off output");
721
+ }
722
+ outcomesTool.renderResult(legacyStockResult, { expanded: true, isPartial: false }, renderTheme, legacyRenderContext);
723
+ if (legacyCall.children[1]?.text !== collapsedLegacyText) {
724
+ throw new Error("legacy all-line stock capability changed expanded Calm-off output");
725
+ }
726
+ pi.events.emit("firstmate:calm-presentation", { active: true, stockExportRendering: false });
727
+ const calmOnCall = outcomesTool.renderCall({}, renderTheme, renderContext);
728
+ const calmOnResult = outcomesTool.renderResult(stockResult, { expanded: false, isPartial: false }, renderTheme, renderContext);
729
+ if (calmOnCall.constructor.name !== "Container" || calmOnCall.render(100).length !== 0 || calmOnResult.constructor.name !== "Container" || calmOnResult.render(100).length !== 0) {
730
+ throw new Error("fm_branch_outcomes remained visible while Calm was on");
731
+ }
732
+ pi.events.emit("firstmate:calm-presentation", { active: false, stockExportRendering: false });
733
+ if (outcomesTool.renderCall({}, renderTheme, renderContext).constructor.name !== "Box" || outcomesTool.renderResult(stockResult, { expanded: false, isPartial: false }, renderTheme, renderContext).constructor.name !== "Container") {
734
+ throw new Error("fm_branch_outcomes did not restore ordinary rendering when Calm was turned off");
735
+ }
736
+ pi.events.emit("firstmate:calm-presentation", { active: true, stockExportRendering: true });
737
+ let exportCallFellBack = false;
738
+ let exportResultFellBack = false;
739
+ try {
740
+ outcomesTool.renderCall({}, renderTheme, renderContext);
741
+ } catch {
742
+ exportCallFellBack = true;
743
+ }
744
+ try {
745
+ outcomesTool.renderResult(stockResult, { expanded: false, isPartial: false }, renderTheme, renderContext);
746
+ } catch {
747
+ exportResultFellBack = true;
748
+ }
749
+ if (!exportCallFellBack || !exportResultFellBack) {
750
+ throw new Error("fm_branch_outcomes replaced Pi stock export rendering");
751
+ }
752
+ const listed = await outcomesTool.execute("call-4", { recent: 2 }, undefined, undefined, {});
753
+ const listedText = listed.content[0].text;
754
+ if (listedText.split("\n").length !== 2 || !listedText.includes("checks green")) {
755
+ throw new Error(`fm_branch_outcomes did not read the store: ${listedText}`);
756
+ }
757
+ if (!renderers.has("fm-branch-merge")) throw new Error("merge-note renderer missing");
758
+ const assertRenderedNote = (note, glyph) => {
759
+ const fgCalls = [];
760
+ const rendered = renderers.get("fm-branch-merge")(
761
+ { content: note },
762
+ { expanded: false },
763
+ {
764
+ fg(color, text) {
765
+ fgCalls.push({ color, text });
766
+ return text;
767
+ },
768
+ },
769
+ );
770
+ if (!String(rendered.text).includes(glyph)) throw new Error(`renderer dropped ${glyph}: ${rendered.text}`);
771
+ if (String(rendered.text).includes("branch merged")) throw new Error(`renderer kept boilerplate: ${rendered.text}`);
772
+ if (rendered.paddingX === 0 && rendered.paddingY === 0) {
773
+ throw new Error("renderer still pads with 0,0 instead of outputPad");
774
+ }
775
+ if (rendered.paddingX !== 1 || rendered.paddingY !== 0) {
776
+ throw new Error(
777
+ `renderer padding should match real Pi messages (outputPad, 0), got ${rendered.paddingX},${rendered.paddingY}`,
778
+ );
779
+ }
780
+ const glyphCalls = fgCalls.filter((call) => call.text === glyph);
781
+ if (glyphCalls.length !== 1 || glyphCalls[0].color === "dim") {
782
+ throw new Error(`icon ${glyph} must carry color, not dim: ${JSON.stringify(fgCalls)}`);
783
+ }
784
+ const restCalls = fgCalls.filter((call) => call.text !== glyph);
785
+ if (restCalls.length === 0 || restCalls.some((call) => call.color !== "dim")) {
786
+ throw new Error(`note remainder must be dim: ${JSON.stringify(fgCalls)}`);
787
+ }
788
+ };
789
+ assertRenderedNote(sentToMain[0].message.content, "⛵");
790
+ process.exit(0);
791
+ EOF
792
+ status=$?
793
+ out=$(cat "$TMP_ROOT/node-output")
794
+ expect_code 0 "$status" "branch dispatch, prefix contract, and two-stage filter must hold: $out"
795
+ case "$out" in
796
+ CACHE_KEY=fm-branch-*) ;;
797
+ *) fail "cache key line missing from driver output: $out" ;;
798
+ esac
799
+ pass "branch owns accepted wakes with a stable prefix contract and verdict-driven merge delivery"
800
+
801
+ # The delivered captain payload must identify itself to main's model. When it
802
+ # did not, main could not tell an incoming outcome from its own earlier answer
803
+ # and re-emitted that answer instead of relaying the outcome, silently losing
804
+ # it. The real protocol executable is the oracle here: it decides the kind and
805
+ # extracts the body, so this asserts delivered behavior rather than a shape
806
+ # this test already knows.
807
+ local kind body
808
+ kind=$(./bin/fm-operational-input.sh kind < "$home/state/delivered-captain-note") \
809
+ || fail "captain outcome reaches main's model as unattributed text the model cannot tell from its own answer"
810
+ [ "$kind" = branch-outcome ] \
811
+ || fail "captain outcome delivered as kind '$kind', not branch-outcome"
812
+ body=$(./bin/fm-operational-input.sh body < "$home/state/delivered-captain-note") \
813
+ || fail "captain outcome envelope carries no readable body"
814
+ case "$body" in
815
+ *"This is a supervision outcome delivered automatically by the supervision branch."*"It was not typed by the captain."*"task-9: PR https://example.com/pr/9"*) ;;
816
+ *) fail "captain outcome body lost its self-description or the outcome itself: $body" ;;
817
+ esac
818
+ # Event ownership and conversational judgment are separate contracts. The
819
+ # delivered instruction forbids reprocessing the fleet event but leaves main
820
+ # free to decide how the outcome belongs in the captain conversation.
821
+ case "$body" in
822
+ *"The fleet event is already handled: do not re-drain, re-run, or acknowledge it."*) ;;
823
+ *) fail "captain outcome body lost the event-ownership boundary: $body" ;;
824
+ esac
825
+ case "$body" in
826
+ *"This outcome is captain-facing: give the captain a visible response now."*"Use your judgment over the wording and how to incorporate it, not whether to surface it."*) ;;
827
+ *) fail "captain outcome body made visibility optional or removed wording judgment: $body" ;;
828
+ esac
829
+ case "$body" in
830
+ *"An outcome that directly answers an explicit captain request is captain-facing"*"regardless of whether it is healthy, routine, measured, actionable, or requires a decision."*) ;;
831
+ *) fail "captain outcome body lost the unconditional explicit-request rule: $body" ;;
832
+ esac
833
+ # The routine note is rendered in the TUI, and its renderer reads the glyph off
834
+ # the front of this same string, so it must stay plain text.
835
+ if ./bin/fm-operational-input.sh kind < "$home/state/delivered-routine-note" >/dev/null 2>&1; then
836
+ fail "routine note must stay plain rendered text, not typed operational input"
837
+ fi
838
+ pass "a captain outcome reaches main's model as typed, self-describing input while routine notes stay plain"
839
+ }
840
+
841
+ test_requested_healthy_outcome_and_unsolicited_routine_outcome_delivery() {
842
+ local repo home out status
843
+ repo="$TMP_ROOT/requested-outcome-root"
844
+ home="$TMP_ROOT/requested-outcome-home"
845
+ mkdir -p "$home/state" "$home/config"
846
+ install_pi_branch_extension_fixture "$repo"
847
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
848
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
849
+ const prelude = process.env.DRIVER_PRELUDE;
850
+ await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, sentToMain, outcomeScript, mainTools, home, realRoot }; })()`);
851
+ const { fire, dispatch, settle, sentToMain, outcomeScript, mainTools, home, realRoot } = globalThis.__t;
852
+ import { existsSync, readFileSync } from "node:fs";
853
+ import { spawnSync } from "node:child_process";
854
+
855
+ const fleetOperations = [];
856
+ globalThis.__fmExecuteBranchBash = async (context) => {
857
+ const actor = spawnSync(
858
+ "bash",
859
+ ["-c", '. "$1"; fm_lease_actor', "_", `${realRoot}/bin/fm-lease-lib.sh`],
860
+ { encoding: "utf8", cwd: context.cwd, env: context.env },
861
+ );
862
+ if (actor.status !== 0) throw new Error(`branch bash actor resolution failed: ${actor.stderr}`);
863
+ const result = spawnSync("bash", ["-c", context.command], {
864
+ encoding: "utf8",
865
+ cwd: context.cwd,
866
+ env: context.env,
867
+ });
868
+ fleetOperations.push({ command: context.command, actor: actor.stdout.trim(), status: result.status });
869
+ return {
870
+ content: [{ type: "text", text: `${result.stdout}${result.stderr}` }],
871
+ details: { stdout: result.stdout, stderr: result.stderr, exitCode: result.status, actor: actor.stdout.trim() },
872
+ isError: result.status !== 0,
873
+ };
874
+ };
875
+
876
+ async function runFleetCommand(session, args) {
877
+ const bash = session.options.customTools.find((tool) => tool.name === "bash");
878
+ const command = ["bin/fm-wake-drain.sh", ...args].join(" ");
879
+ const result = await bash.execute(`fleet-${fleetOperations.length}`, { command }, undefined, undefined, {});
880
+ if (result.isError) throw new Error(`fleet command failed: ${JSON.stringify(result)}`);
881
+ return result.details;
882
+ }
883
+
884
+ function directlyRequestsResourceReport(mirror) {
885
+ const latestCaptain = mirror.at(-1) ?? "";
886
+ const words = new Set(latestCaptain.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
887
+ const requestsDelivery = ["give", "provide", "send", "show"].some((word) => words.has(word));
888
+ const namesReport = ["report", "status", "measurement"].some((word) => words.has(word));
889
+ const namesResources = ["resource", "resources", "cpu", "memory"].some((word) => words.has(word));
890
+ return requestsDelivery && namesReport && namesResources;
891
+ }
892
+
893
+ globalThis.__fmOnBranchPrompt = async ({ session }) => {
894
+ const mirror = session.ops
895
+ .filter((op) => op.kind === "custom" && op.message.customType === "fm-main-mirror")
896
+ .map((op) => op.message.content);
897
+ const directlyRequested = directlyRequestsResourceReport(mirror);
898
+ const drained = await runFleetCommand(session, []);
899
+ const ack = drained.stderr.match(/--ack-through ([0-9]+) --recovery-generation ([A-Za-z0-9._-]+)/);
900
+ if (!ack) throw new Error(`drain did not return its acknowledgement command: ${drained.stderr}`);
901
+ const report = session.options.customTools.find((tool) => tool.name === "fm_branch_report");
902
+ const verdictDescription = report.parameters.properties.verdict.description;
903
+ if (!verdictDescription.includes("unconditionally") ||
904
+ !verdictDescription.includes("directly answers an explicit captain request") ||
905
+ !verdictDescription.includes("regardless of whether it is healthy, routine, measured, actionable, or requires a decision")) {
906
+ throw new Error(`branch provider received conflicting verdict semantics: ${verdictDescription}`);
907
+ }
908
+ const result = await report.execute(
909
+ `resource-result-${fleetOperations.length}`,
910
+ {
911
+ task: "task-resource",
912
+ verdict: directlyRequested ? "captain" : "routine",
913
+ summary: "healthy resource report: CPU 12%, memory 41%",
914
+ wake: "signal: healthy resource result",
915
+ },
916
+ undefined,
917
+ undefined,
918
+ {},
919
+ );
920
+ if (result.isError) throw new Error(`branch report failed: ${JSON.stringify(result)}`);
921
+ await runFleetCommand(session, ["--ack-through", ack[1], "--recovery-generation", ack[2]]);
922
+ };
923
+
924
+ const explicitRequest = "Please give me a fresh mini system-resource report.";
925
+ const longRequests = [
926
+ `${explicitRequest}${" head context".repeat(500)}`,
927
+ `${"middle context ".repeat(250)}${explicitRequest}${" middle context".repeat(250)}`,
928
+ `${"tail context ".repeat(500)}${explicitRequest}`,
929
+ ];
930
+ const requestedPrompts = [...longRequests, "FIRSTMATE give me a fresh system-resource report."];
931
+ // Match Pi's real AgentSession.prompt ordering: before_agent_start receives
932
+ // the expanded prompt before _runAgentPrompt appends its user message to the
933
+ // SessionManager. Keeping entries stale at the hook boundary is the regression.
934
+ const entries = [];
935
+ const mainCtx = {
936
+ model: { provider: "anthropic", id: "main-model" },
937
+ sessionManager: {
938
+ getSessionFile: () => `${home}/main.jsonl`,
939
+ getEntries: () => entries,
940
+ },
941
+ };
942
+ const operational = spawnSync(
943
+ "bash",
944
+ [`${realRoot}/bin/fm-operational-input.sh`, "encode", "watcher"],
945
+ { encoding: "utf8", input: "operational watcher injection" },
946
+ );
947
+ if (operational.status !== 0) throw new Error(`could not create operational input: ${operational.stderr}`);
948
+ fire("before_agent_start", { prompt: operational.stdout }, mainCtx);
949
+ entries.push({ type: "message", message: { role: "user", content: operational.stdout } });
950
+ const unsolicitedPrompt = "Please keep responses concise while monitoring the fleet.";
951
+ fire("before_agent_start", { prompt: unsolicitedPrompt }, mainCtx);
952
+ entries.push({ type: "message", message: { role: "user", content: unsolicitedPrompt } });
953
+ fire("agent_start", {}, mainCtx);
954
+ fire("agent_end", {}, mainCtx);
955
+ const legacyOperational = "⁣FIRSTMATE_OP: give me a fresh system-resource report.";
956
+ fire("before_agent_start", { prompt: legacyOperational }, mainCtx);
957
+ entries.push({ type: "message", message: { role: "user", content: legacyOperational } });
958
+ fire("agent_start", {}, mainCtx);
959
+ const unsolicited = dispatch("signal: healthy resource result");
960
+ if (!unsolicited.accepted) throw new Error("branch did not accept the unsolicited result");
961
+ await settle(() => fleetOperations.length === 2, "unsolicited result acknowledgement");
962
+ if (sentToMain.length !== 1 || sentToMain[0].options.triggerTurn) {
963
+ throw new Error(`unsolicited healthy result opened a main turn: ${JSON.stringify(sentToMain)}`);
964
+ }
965
+ const sailboat = sentToMain[0];
966
+ if (sailboat.message.display !== true || !sailboat.message.content.startsWith("⛵ task-resource:")) {
967
+ throw new Error(`unsolicited healthy result was not a rendered sailboat note: ${JSON.stringify(sailboat)}`);
968
+ }
969
+
970
+ const outcomes = mainTools.find((tool) => tool.name === "fm_branch_outcomes");
971
+ if (!outcomes) throw new Error("main did not receive its outcome-reading permission surface");
972
+ const visibleToMain = await outcomes.execute("main-reads-sailboat", { recent: 1 }, undefined, undefined, {});
973
+ const mainOutcomeText = visibleToMain.content.map((item) => item.text ?? "").join("\n");
974
+ if (visibleToMain.isError || !mainOutcomeText.includes("healthy resource report: CPU 12%, memory 41%")) {
975
+ throw new Error(`main could not use the sailboat content through its existing permission path: ${JSON.stringify(visibleToMain)}`);
976
+ }
977
+ if (fleetOperations.length !== 2) throw new Error("main's outcome read reprocessed the fleet event");
978
+
979
+ for (let index = 0; index < requestedPrompts.length; index += 1) {
980
+ const content = requestedPrompts[index];
981
+ if (index < longRequests.length && content.length <= 4000) {
982
+ throw new Error(`request fixture ${index} did not exceed the mirror bound`);
983
+ }
984
+ fire("before_agent_start", { prompt: content }, mainCtx);
985
+ // Pi persists this only after every before_agent_start handler has returned.
986
+ entries.push({ type: "message", message: { role: "user", content } });
987
+ fire("agent_start", {}, mainCtx);
988
+ const requested = dispatch("signal: healthy resource result");
989
+ if (!requested.accepted) throw new Error(`branch did not accept requested result ${index}`);
990
+ await settle(() => fleetOperations.length === 4 + (index * 2), `requested result ${index} acknowledgement`);
991
+ const deliveredRequestMirror = globalThis.__fmSessions[0].ops
992
+ .filter((op) => op.kind === "custom" && op.message.customType === "fm-main-mirror")
993
+ .at(-1)?.message.content;
994
+ if (deliveredRequestMirror !== `[captain] ${content}`) {
995
+ throw new Error(`pre-turn-end mirror changed long captain request ${index}`);
996
+ }
997
+ const turns = sentToMain.filter((sent) => sent.options.triggerTurn === true);
998
+ if (turns.length !== index + 1 || turns.at(-1).options.deliverAs !== "followUp") {
999
+ throw new Error(`requested result ${index} did not open exactly one main turn: ${JSON.stringify(sentToMain)}`);
1000
+ }
1001
+ }
1002
+ const mirroredCaptainText = globalThis.__fmSessions[0].ops
1003
+ .filter((op) => op.kind === "custom" && op.message.customType === "fm-main-mirror")
1004
+ .map((op) => op.message.content);
1005
+ for (const content of [unsolicitedPrompt, ...requestedPrompts]) {
1006
+ const copies = mirroredCaptainText.filter((text) => text === `[captain] ${content}`).length;
1007
+ if (copies !== 1) throw new Error(`current captain prompt was mirrored ${copies} times instead of once`);
1008
+ }
1009
+ if (mirroredCaptainText.some((text) =>
1010
+ text.includes("operational watcher injection") || text.includes("FIRSTMATE_OP: give me a fresh system-resource report")
1011
+ )) {
1012
+ throw new Error("canonical current or legacy operational input entered captain mirror context");
1013
+ }
1014
+ if ((globalThis.__fmPrompts ?? []).length !== 5) throw new Error("a handled fleet wake was rerun");
1015
+ if (sentToMain.length !== 5) throw new Error(`one result was reprocessed into ${sentToMain.length} main messages`);
1016
+ if (fleetOperations.length !== 10 || fleetOperations.some((operation) => operation.status !== 0)) {
1017
+ throw new Error(`fleet event ownership repeated or failed work: ${JSON.stringify(fleetOperations)}`);
1018
+ }
1019
+ if (fleetOperations.some((operation) => operation.actor !== "branch")) {
1020
+ throw new Error(`main took fleet-event ownership: ${JSON.stringify(fleetOperations)}`);
1021
+ }
1022
+ if (existsSync(`${home}/state/.wake-queue`) && readFileSync(`${home}/state/.wake-queue`, "utf8") !== "") {
1023
+ throw new Error("acknowledged fleet wake remained queued for another owner");
1024
+ }
1025
+ const rows = readFileSync(`${home}/state/branch-outcomes.jsonl`, "utf8").trim().split("\n").map((line) => JSON.parse(line));
1026
+ if (rows.length !== 5 || rows[0].verdict !== "routine" || rows.slice(1).some((row) => row.verdict !== "captain")) {
1027
+ throw new Error(`provider classifications were not recorded once in order: ${JSON.stringify(rows)}`);
1028
+ }
1029
+ if (outcomeScript(["unread"]) !== "") throw new Error("merged outcomes remained unread for redelivery");
1030
+ process.exit(0);
1031
+ EOF
1032
+ status=$?
1033
+ out=$(cat "$TMP_ROOT/node-output")
1034
+ expect_code 0 "$status" "requested and unsolicited healthy outcomes must follow their distinct public delivery paths: $out"
1035
+ pass "requested and unsolicited healthy outcomes keep distinct delivery and event ownership"
1036
+ }
1037
+
1038
+ test_captain_outcome_encoding_failure_delivers_plain_instruction() {
1039
+ local repo home out status
1040
+ repo="$TMP_ROOT/encoding-fallback-root"
1041
+ home="$TMP_ROOT/encoding-fallback-home"
1042
+ mkdir -p "$home/state" "$home/config"
1043
+ install_pi_branch_extension_fixture "$repo"
1044
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
1045
+ FM_OPERATIONAL_INPUT_SCRIPT="$repo/bin/missing-operational-input" \
1046
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
1047
+ const prelude = process.env.DRIVER_PRELUDE;
1048
+ await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, settle, sentToMain }; })()`);
1049
+ const { dispatch, settle, sentToMain } = globalThis.__t;
1050
+
1051
+ if (!dispatch("signal: encoding fallback probe").accepted) {
1052
+ throw new Error("branch did not accept the encoding-fallback wake");
1053
+ }
1054
+ await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "encoding-fallback branch prompt");
1055
+ const session = globalThis.__fmSessions[0];
1056
+ const report = session.options.customTools.find((tool) => tool.name === "fm_branch_report");
1057
+ const result = await report.execute(
1058
+ "encoding-fallback",
1059
+ { task: "task-fallback", verdict: "captain", summary: "PR https://example.com/pr/fallback is ready" },
1060
+ undefined,
1061
+ undefined,
1062
+ {},
1063
+ );
1064
+ if (result.isError) throw new Error(`fallback report failed: ${JSON.stringify(result)}`);
1065
+ if (sentToMain.length !== 1) throw new Error(`fallback delivered ${sentToMain.length} notes instead of one`);
1066
+ const delivered = sentToMain[0];
1067
+ if (delivered.message.display !== false) throw new Error("fallback captain note became visible");
1068
+ if (delivered.options.triggerTurn !== true || delivered.options.deliverAs !== "followUp") {
1069
+ throw new Error(`fallback changed turn delivery: ${JSON.stringify(delivered.options)}`);
1070
+ }
1071
+ if (delivered.message.content.includes("FIRSTMATE_OP:")) {
1072
+ throw new Error(`fallback unexpectedly carried an envelope: ${delivered.message.content}`);
1073
+ }
1074
+ if (!delivered.message.content.includes("The fleet event is already handled: do not re-drain, re-run, or acknowledge it.") ||
1075
+ !delivered.message.content.includes("This outcome is captain-facing: give the captain a visible response now.") ||
1076
+ !delivered.message.content.includes("Use your judgment over the wording and how to incorporate it, not whether to surface it.") ||
1077
+ !delivered.message.content.includes("task-fallback: PR https://example.com/pr/fallback is ready")) {
1078
+ throw new Error(`fallback lost its instruction or outcome: ${delivered.message.content}`);
1079
+ }
1080
+ process.exit(0);
1081
+ EOF
1082
+ status=$?
1083
+ out=$(cat "$TMP_ROOT/node-output")
1084
+ expect_code 0 "$status" "captain outcome encoding failure must degrade to plain instructed delivery: $out"
1085
+ pass "a broken operational encoder still delivers one invisible instructed captain outcome as a follow-up"
1086
+ }
1087
+
1088
+ test_branch_cache_key_is_per_home_stable() {
1089
+ local repo home_a home_b key_a1 key_a2 key_b
1090
+ repo="$TMP_ROOT/cache-key-root"
1091
+ home_a="$TMP_ROOT/cache-key-home-a"
1092
+ home_b="$TMP_ROOT/cache-key-home-b"
1093
+ mkdir -p "$home_a/state" "$home_a/config" "$home_b/state" "$home_b/config"
1094
+ install_pi_branch_extension_fixture "$repo"
1095
+ probe() {
1096
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$1" FM_ROOT_OVERRIDE="$ROOT" \
1097
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module 2>&1 <<'EOF'
1098
+ const prelude = process.env.DRIVER_PRELUDE;
1099
+ await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, settle }; })()`);
1100
+ const { dispatch, settle } = globalThis.__t;
1101
+ dispatch("signal: cache probe");
1102
+ await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "branch wake prompt");
1103
+ const loader = globalThis.__fmLoaders[0];
1104
+ const entry = loader.options.extensionFactories[0];
1105
+ let handler = null;
1106
+ (typeof entry === "function" ? entry : entry.smidja)({ on: (e, h) => { if (e === "before_provider_request") handler = h; } });
1107
+ const rewritten = handler({ type: "before_provider_request", payload: { prompt_cache_key: "x" } });
1108
+ console.log(rewritten.prompt_cache_key);
1109
+ process.exit(0);
1110
+ EOF
1111
+ }
1112
+ key_a1=$(probe "$home_a") || fail "cache-key probe A1 failed: $key_a1"
1113
+ key_a2=$(probe "$home_a") || fail "cache-key probe A2 failed: $key_a2"
1114
+ key_b=$(probe "$home_b") || fail "cache-key probe B failed: $key_b"
1115
+ [ -n "$key_a1" ] || fail "empty cache key from probe A1"
1116
+ [ "$key_a1" = "$key_a2" ] || fail "cache key not stable across branch sessions in one home: $key_a1 vs $key_a2"
1117
+ [ "$key_a1" != "$key_b" ] || fail "cache key does not separate homes: $key_a1"
1118
+ pass "branch prompt_cache_key is stable per home across sessions and distinct between homes"
1119
+ }
1120
+
1121
+ test_branch_default_on_heartbeat_afk_and_fallback() {
1122
+ local repo broken home out status
1123
+ repo="$TMP_ROOT/gating-root"
1124
+ broken="$TMP_ROOT/gating-broken-root"
1125
+ home="$TMP_ROOT/gating-home"
1126
+ mkdir -p "$home/state" "$home/config" "$broken/bin"
1127
+ install_pi_branch_extension_fixture "$repo"
1128
+ cp "$ROOT/bin/fm-lease.sh" "$ROOT/bin/fm-lease-lib.sh" "$ROOT/bin/fm-wake-lib.sh" "$ROOT/bin/fm-wake-grant.sh" "$broken/bin/"
1129
+ cat > "$broken/bin/fm-branch-prompt.sh" <<'SH'
1130
+ #!/usr/bin/env bash
1131
+ echo "synthetic generator failure" >&2
1132
+ exit 1
1133
+ SH
1134
+ chmod +x "$broken/bin/fm-branch-prompt.sh"
1135
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
1136
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
1137
+ const prelude = process.env.DRIVER_PRELUDE;
1138
+ await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, fire, settle, home, sentToMain }; })()`);
1139
+ const { dispatch, fire, settle, home, sentToMain } = globalThis.__t;
1140
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1141
+
1142
+ // Default-on: with no config/pi-supervision-branch grant file present at
1143
+ // all (this driver never writes one), a task-scoped wake is still accepted
1144
+ // and activation still runs.
1145
+ if (existsSync(`${home}/config/pi-supervision-branch`)) {
1146
+ throw new Error("test fixture unexpectedly wrote a grant file");
1147
+ }
1148
+ fire("session_start", {});
1149
+ if (!dispatch("signal: default-on task wake").accepted) {
1150
+ throw new Error("a task-scoped wake was refused with no grant file present");
1151
+ }
1152
+ if (!existsSync(`${home}/state/.pi-branch-extension-loaded`)) {
1153
+ throw new Error("default-on activation did not write the diagnostic marker");
1154
+ }
1155
+ await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "default-on task wake prompt");
1156
+
1157
+ // The real watcher emits a bare heartbeat only after its cheap bash scan has
1158
+ // flagged a fleet pass as possibly captain-relevant. The branch accepts that
1159
+ // wake without a resolved project and performs the deeper review.
1160
+ if (!dispatch("heartbeat", [], true, true).accepted) {
1161
+ throw new Error("eligible heartbeat offer was refused");
1162
+ }
1163
+ if (dispatch("heartbeat", [], true, false).accepted) {
1164
+ throw new Error("heartbeat with a main-owned queue row was accepted");
1165
+ }
1166
+ await settle(() => (globalThis.__fmPrompts ?? []).length === 2, "heartbeat wake prompt");
1167
+
1168
+ // The branch can downgrade its deeper review to a silent routine outcome or
1169
+ // escalate a captain-worthy finding into one main turn.
1170
+ const heartbeatSession = globalThis.__fmSessions[globalThis.__fmSessions.length - 1];
1171
+ const heartbeatReport = heartbeatSession.options.customTools.find((tool) => tool.name === "fm_branch_report");
1172
+ await heartbeatReport.execute(
1173
+ "heartbeat-noop",
1174
+ { task: "fleet", verdict: "routine", summary: "fleet reviewed, nothing changed", silent: true },
1175
+ undefined,
1176
+ undefined,
1177
+ {},
1178
+ );
1179
+ const noopMerge = sentToMain[sentToMain.length - 1];
1180
+ if (noopMerge.options.triggerTurn) throw new Error("a no-op heartbeat pass must not open a main turn");
1181
+ if (noopMerge.message.display !== false) throw new Error("a no-op heartbeat pass must not render a merge note");
1182
+ const storedNoop = readFileSync(`${home}/state/branch-outcomes.jsonl`, "utf8")
1183
+ .trim()
1184
+ .split("\n")
1185
+ .map((line) => JSON.parse(line))
1186
+ .find((row) => row.task === "fleet" && row.summary === "fleet reviewed, nothing changed");
1187
+ if (!storedNoop || storedNoop.verdict !== "routine" || storedNoop.silent !== true) {
1188
+ throw new Error("the silent no-op heartbeat disposition was not stored durably");
1189
+ }
1190
+ await heartbeatReport.execute(
1191
+ "fleet-routine-action",
1192
+ { task: "fleet", verdict: "routine", summary: "reconciled the backlog after completed work" },
1193
+ undefined,
1194
+ undefined,
1195
+ {},
1196
+ );
1197
+ const fleetRoutineMerge = sentToMain[sentToMain.length - 1];
1198
+ if (fleetRoutineMerge.message.display !== true) throw new Error("a fleet routine action must render");
1199
+ if (!fleetRoutineMerge.message.content.startsWith("⛵ fleet: reconciled the backlog after completed work")) {
1200
+ throw new Error(`fleet routine action note changed: ${fleetRoutineMerge.message.content}`);
1201
+ }
1202
+ await heartbeatReport.execute(
1203
+ "task-routine",
1204
+ { task: "task-9", verdict: "routine", summary: "worker healthy, no action needed" },
1205
+ undefined,
1206
+ undefined,
1207
+ {},
1208
+ );
1209
+ const taskRoutineMerge = sentToMain[sentToMain.length - 1];
1210
+ if (taskRoutineMerge.message.display !== true) throw new Error("a task-scoped routine outcome must render");
1211
+ if (!taskRoutineMerge.message.content.startsWith("⛵ task-9: worker healthy, no action needed")) {
1212
+ throw new Error(`task-scoped routine note changed: ${taskRoutineMerge.message.content}`);
1213
+ }
1214
+ await heartbeatReport.execute(
1215
+ "heartbeat-finding",
1216
+ { task: "fleet", verdict: "captain", summary: "task-2 has been stuck for an hour" },
1217
+ undefined,
1218
+ undefined,
1219
+ {},
1220
+ );
1221
+ const captainMerge = sentToMain[sentToMain.length - 1];
1222
+ if (captainMerge.options.triggerTurn !== true) throw new Error("a captain-worthy heartbeat finding must open a main turn");
1223
+ if (captainMerge.message.display !== false) throw new Error("the heartbeat captain-facing note must not be printed");
1224
+
1225
+ // Every other fleet-wide or unresolvable wake (empty projects, not a
1226
+ // heartbeat) still keeps the wake-to-main path.
1227
+ if (dispatch("check: unresolved fleet event", []).accepted) {
1228
+ throw new Error("branch accepted an unscoped, non-heartbeat fleet wake");
1229
+ }
1230
+
1231
+ // Away mode still owns supervision regardless of default-on eligibility.
1232
+ writeFileSync(`${home}/state/.afk`, "");
1233
+ if (dispatch("signal: while afk").accepted) throw new Error("branch accepted a wake during away mode");
1234
+ rmSync(`${home}/state/.afk`);
1235
+ if (!dispatch("signal: gates cleared").accepted) throw new Error("branch refused a wake with gates cleared");
1236
+ await settle(() => (globalThis.__fmPrompts ?? []).length === 3, "branch wake prompts");
1237
+ process.exit(0);
1238
+ EOF
1239
+ status=$?
1240
+ out=$(cat "$TMP_ROOT/node-output")
1241
+ expect_code 0 "$status" "default-on eligibility, heartbeat routing, and afk gating must bind: $out"
1242
+
1243
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$TMP_ROOT/gating-home-2" FM_ROOT_OVERRIDE="$broken" \
1244
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
1245
+ const prelude = process.env.DRIVER_PRELUDE;
1246
+ await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, settle, mainUserMessages }; })()`);
1247
+ const { dispatch, settle, mainUserMessages } = globalThis.__t;
1248
+
1249
+ // A branch that cannot come up must degrade to today's behavior: the accepted
1250
+ // wake falls back to main with the failure named, and later wakes are no
1251
+ // longer accepted (no wake is ever lost).
1252
+ if (!dispatch("signal: first wake").accepted) throw new Error("first offer was not accepted");
1253
+ await settle(() => mainUserMessages.length === 1, "fallback delivery to main");
1254
+ const fallback = mainUserMessages[0].content;
1255
+ if (!fallback.includes("FIRSTMATE WATCHER WAKE: signal: first wake")) throw new Error(`fallback lost the wake: ${fallback}`);
1256
+ if (!fallback.includes("Supervision branch unavailable")) throw new Error(`fallback did not name the branch failure: ${fallback}`);
1257
+ if (mainUserMessages[0].options.deliverAs !== "followUp") throw new Error("fallback must deliver as a follow-up");
1258
+ if (dispatch("signal: second wake").accepted) throw new Error("broken branch kept accepting wakes");
1259
+ process.exit(0);
1260
+ EOF
1261
+ status=$?
1262
+ out=$(cat "$TMP_ROOT/node-output")
1263
+ expect_code 0 "$status" "broken-branch fallback must return wakes to main: $out"
1264
+ pass "branch default-on eligibility (task-scoped, heartbeat, afk) binds and a broken branch falls back to main"
1265
+ }
1266
+
1267
+ test_branch_predrain_recheck_keeps_a_heartbeat_a_co_present_check_arrives_under() {
1268
+ local repo home out status
1269
+ repo="$TMP_ROOT/predrain-recheck-root"
1270
+ home="$TMP_ROOT/predrain-recheck-home"
1271
+ mkdir -p "$home/state" "$home/config"
1272
+ install_pi_branch_extension_fixture "$repo"
1273
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
1274
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
1275
+ const prelude = process.env.DRIVER_PRELUDE;
1276
+ await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, fire, home, mainUserMessages }; })()`);
1277
+ const { dispatch, fire, home, mainUserMessages } = globalThis.__t;
1278
+ import { appendFileSync, readFileSync } from "node:fs";
1279
+
1280
+ fire("session_start", {});
1281
+ let releasePrompt;
1282
+ globalThis.__fmPromptGate = new Promise((resolve) => { releasePrompt = resolve; });
1283
+ const offer = dispatch("heartbeat", [], true, true);
1284
+ if (!offer.accepted) throw new Error("eligible heartbeat offer was not accepted");
1285
+ // A main-only notice arrives between offer acceptance and the branch's own
1286
+ // drain. It must not carry the fleet review into the captain's chat.
1287
+ appendFileSync(`${home}/state/.wake-queue`, "2\t2\tcheck\tx-inbox\tcheck: pending x mention\n");
1288
+ for (let i = 0; i < 250 && !globalThis.__fmPromptStarted && mainUserMessages.length === 0; i += 1) {
1289
+ await new Promise((resolve) => setTimeout(resolve, 10));
1290
+ }
1291
+ if (mainUserMessages.length !== 0) {
1292
+ throw new Error(`a co-present check row rode the heartbeat into main: ${JSON.stringify(mainUserMessages)}`);
1293
+ }
1294
+ if (!globalThis.__fmPromptStarted) {
1295
+ throw new Error("the branch was never prompted even though the heartbeat stayed eligible");
1296
+ }
1297
+ const snapshot = readFileSync(`${home}/state/.branch-eligible-rows`, "utf8").trim().split("\n");
1298
+ if (!snapshot.includes("1")) throw new Error(`eligible-row snapshot omitted the heartbeat row: ${snapshot}`);
1299
+ if (snapshot.includes("2")) throw new Error(`eligible-row snapshot granted the main-owned row: ${snapshot}`);
1300
+ releasePrompt();
1301
+ const queue = readFileSync(`${home}/state/.wake-queue`, "utf8");
1302
+ if (!queue.includes("\theartbeat\t") || !queue.includes("\tcheck\t")) {
1303
+ throw new Error(`the pre-drain recheck mutated the queued set: ${queue}`);
1304
+ }
1305
+ process.exit(0);
1306
+ EOF
1307
+ status=$?
1308
+ out=$(cat "$TMP_ROOT/node-output")
1309
+ expect_code 0 "$status" "a co-present check row must not carry a heartbeat review into main: $out"
1310
+ pass "a heartbeat review survives a check row arriving before its drain"
1311
+ }
1312
+
1313
+ # The non-heartbeat half of the same recheck: a check-kind row that arrives
1314
+ # after a signal/stale offer is accepted must stay main-owned WITHOUT bouncing
1315
+ # the branch's own eligible row back to main
1316
+ # (docs/watcher-continuity.md "Per-actor acknowledgement"). A check row is
1317
+ # main-owned in every mode, so the heartbeat case proven above and this one
1318
+ # resolve the same way.
1319
+ test_branch_predrain_recheck_excludes_new_main_owned_row_without_deferring_eligible_work() {
1320
+ local repo home out status
1321
+ repo="$TMP_ROOT/predrain-partial-root"
1322
+ home="$TMP_ROOT/predrain-partial-home"
1323
+ mkdir -p "$home/state" "$home/config"
1324
+ install_pi_branch_extension_fixture "$repo"
1325
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
1326
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
1327
+ const prelude = process.env.DRIVER_PRELUDE;
1328
+ await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, fire, home, mainUserMessages }; })()`);
1329
+ const { dispatch, fire, home, mainUserMessages } = globalThis.__t;
1330
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
1331
+
1332
+ fire("session_start", {});
1333
+ let releasePrompt;
1334
+ globalThis.__fmPromptGate = new Promise((resolve) => { releasePrompt = resolve; });
1335
+ const offer = dispatch("signal: task-local wake");
1336
+ if (!offer.accepted) throw new Error("eligible task-local offer was not accepted");
1337
+ // A main-only notice arrives while main is still finishing its own earlier
1338
+ // turn - unacked, still sitting in the queue - between offer acceptance and
1339
+ // the branch's own drain.
1340
+ appendFileSync(`${home}/state/.wake-queue`, "2\t2\tcheck\tx-inbox\tcheck: pending x mention\n");
1341
+ for (let i = 0; i < 250 && !globalThis.__fmPromptStarted && mainUserMessages.length === 0; i += 1) {
1342
+ await new Promise((resolve) => setTimeout(resolve, 10));
1343
+ }
1344
+ if (mainUserMessages.length !== 0) {
1345
+ throw new Error(`a co-present main-owned row bounced the whole mixed queue to main: ${JSON.stringify(mainUserMessages)}`);
1346
+ }
1347
+ if (!globalThis.__fmPromptStarted) {
1348
+ throw new Error("the branch was never prompted even though its own row stayed eligible");
1349
+ }
1350
+ const snapshot = readFileSync(`${home}/state/.branch-eligible-rows`, "utf8").trim().split("\n");
1351
+ if (!snapshot.includes("1")) throw new Error(`eligible-row snapshot omitted the task-local row: ${snapshot}`);
1352
+ if (snapshot.includes("2")) throw new Error(`eligible-row snapshot granted the main-owned row: ${snapshot}`);
1353
+ const queue = readFileSync(`${home}/state/.wake-queue`, "utf8");
1354
+ if (!queue.includes("\tcheck\tx-inbox\t")) {
1355
+ throw new Error(`the main-owned row must remain queued for main, untouched: ${queue}`);
1356
+ }
1357
+ releasePrompt();
1358
+ for (let i = 0; i < 250 && (globalThis.__fmPrompts ?? []).length === 0; i += 1) {
1359
+ await new Promise((resolve) => setTimeout(resolve, 10));
1360
+ }
1361
+ for (let i = 0; i < 250 && existsSync(`${home}/state/.branch-eligible-rows`); i += 1) {
1362
+ await new Promise((resolve) => setTimeout(resolve, 10));
1363
+ }
1364
+ if (existsSync(`${home}/state/.branch-eligible-rows`)) {
1365
+ throw new Error("settled branch prompt retained its row grant");
1366
+ }
1367
+ process.exit(0);
1368
+ EOF
1369
+ status=$?
1370
+ out=$(cat "$TMP_ROOT/node-output")
1371
+ expect_code 0 "$status" "pre-drain eligibility re-check must exclude only the new main-owned row: $out"
1372
+ pass "pre-drain eligibility re-check excludes a newly main-owned row without deferring eligible work"
1373
+ }
1374
+
1375
+ test_settled_branch_prompt_releases_unacknowledged_grant() {
1376
+ local repo home out status
1377
+ repo="$TMP_ROOT/settled-grant-root"
1378
+ home="$TMP_ROOT/settled-grant-home"
1379
+ mkdir -p "$home/state" "$home/config"
1380
+ install_pi_branch_extension_fixture "$repo"
1381
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
1382
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
1383
+ const prelude = process.env.DRIVER_PRELUDE;
1384
+ await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, fire, home, realRoot }; })()`);
1385
+ const { dispatch, fire, home, realRoot } = globalThis.__t;
1386
+ const { spawnSync } = await import("node:child_process");
1387
+ const { existsSync } = await import("node:fs");
1388
+
1389
+ fire("session_start", {});
1390
+ if (!dispatch("signal: unacknowledged branch wake").accepted) {
1391
+ throw new Error("eligible wake was not accepted");
1392
+ }
1393
+ for (let i = 0; i < 250 && (globalThis.__fmPrompts ?? []).length === 0; i += 1) {
1394
+ await new Promise((resolve) => setTimeout(resolve, 10));
1395
+ }
1396
+ if ((globalThis.__fmPrompts ?? []).length !== 1) throw new Error("branch prompt did not settle");
1397
+ for (let i = 0; i < 250 && existsSync(`${home}/state/.branch-eligible-rows`); i += 1) {
1398
+ await new Promise((resolve) => setTimeout(resolve, 10));
1399
+ }
1400
+ if (existsSync(`${home}/state/.branch-eligible-rows`)) {
1401
+ throw new Error("settled prompt left its unacknowledged grant active");
1402
+ }
1403
+ const drain = spawnSync("bash", [`${realRoot}/bin/fm-wake-drain.sh`], {
1404
+ encoding: "utf8",
1405
+ env: { ...process.env, FM_HOME: home, FM_STATE_OVERRIDE: `${home}/state`, FM_ROOT_OVERRIDE: realRoot },
1406
+ });
1407
+ if (drain.status !== 0) throw new Error(`main drain failed after grant release: ${drain.stderr}`);
1408
+ if (!drain.stdout.includes("\tsignal\tbranch-driver.status\t")) {
1409
+ throw new Error(`main could not replay the unacknowledged branch row: ${drain.stdout}`);
1410
+ }
1411
+ process.exit(0);
1412
+ EOF
1413
+ status=$?
1414
+ out=$(cat "$TMP_ROOT/node-output")
1415
+ expect_code 0 "$status" "settled branch turns must release residual grants for main replay: $out"
1416
+ pass "a settled branch turn releases an unacknowledged grant for main replay"
1417
+ }
1418
+
1419
+ test_main_owned_grant_result_falls_back_to_main() {
1420
+ local repo home out status
1421
+ repo="$TMP_ROOT/main-owned-fallback-root"
1422
+ home="$TMP_ROOT/main-owned-fallback-home"
1423
+ mkdir -p "$home/state" "$home/config"
1424
+ install_pi_branch_extension_fixture "$repo"
1425
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
1426
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
1427
+ const prelude = process.env.DRIVER_PRELUDE;
1428
+ await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, fire, home, mainUserMessages }; })()`);
1429
+ const { dispatch, fire, home, mainUserMessages } = globalThis.__t;
1430
+ import { writeFileSync } from "node:fs";
1431
+
1432
+ fire("session_start", {});
1433
+ const offer = dispatch("signal: interrupted main claim");
1434
+ if (!offer.accepted) throw new Error("eligible wake was not accepted before the ownership recheck");
1435
+ writeFileSync(`${home}/state/.main-eligible-rows`, "1\n");
1436
+ for (let i = 0; i < 250 && mainUserMessages.length === 0; i += 1) {
1437
+ await new Promise((resolve) => setTimeout(resolve, 10));
1438
+ }
1439
+ if ((globalThis.__fmPrompts ?? []).length !== 0) {
1440
+ throw new Error("branch prompted for a row already claimed by main");
1441
+ }
1442
+ if (mainUserMessages.length !== 1) {
1443
+ throw new Error(`main-owned row was silently absorbed: ${JSON.stringify(mainUserMessages)}`);
1444
+ }
1445
+ if (!String(mainUserMessages[0].content).includes("FIRSTMATE WATCHER WAKE: signal: interrupted main claim")) {
1446
+ throw new Error(`fallback lost the durable wake: ${mainUserMessages[0].content}`);
1447
+ }
1448
+ if (mainUserMessages[0].options.deliverAs !== "followUp") {
1449
+ throw new Error("main-owned fallback was not delivered as a follow-up");
1450
+ }
1451
+ process.exit(0);
1452
+ EOF
1453
+ status=$?
1454
+ out=$(cat "$TMP_ROOT/node-output")
1455
+ expect_code 0 "$status" "a main-owned grant result must still deliver the wake to main: $out"
1456
+ pass "a stale main claim cannot silently suppress later wake delivery"
1457
+ }
1458
+
1459
+ test_branch_predrain_recheck_noops_already_drained_wake() {
1460
+ local repo home out status
1461
+ repo="$TMP_ROOT/predrain-empty-root"
1462
+ home="$TMP_ROOT/predrain-empty-home"
1463
+ mkdir -p "$home/state" "$home/config"
1464
+ install_pi_branch_extension_fixture "$repo"
1465
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
1466
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
1467
+ const prelude = process.env.DRIVER_PRELUDE;
1468
+ await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, fire, home, mainUserMessages }; })()`);
1469
+ const { dispatch, fire, home, mainUserMessages } = globalThis.__t;
1470
+ import { writeFileSync } from "node:fs";
1471
+
1472
+ fire("session_start", {});
1473
+ let releaseFirst;
1474
+ globalThis.__fmPromptGate = new Promise((resolve) => {
1475
+ releaseFirst = resolve;
1476
+ });
1477
+ if (!dispatch("signal: first queued wake").accepted) throw new Error("first wake was not accepted");
1478
+ for (let i = 0; i < 250 && !globalThis.__fmPromptStarted; i += 1) {
1479
+ await new Promise((resolve) => setTimeout(resolve, 10));
1480
+ }
1481
+ if (!globalThis.__fmPromptStarted) throw new Error("first branch prompt did not start");
1482
+ if (!dispatch("heartbeat", [], true, true).accepted) throw new Error("queued heartbeat was not accepted");
1483
+ writeFileSync(`${home}/state/.wake-queue`, "");
1484
+ releaseFirst();
1485
+ for (let i = 0; i < 250 && (globalThis.__fmPrompts ?? []).length < 1; i += 1) {
1486
+ await new Promise((resolve) => setTimeout(resolve, 10));
1487
+ }
1488
+ await new Promise((resolve) => setTimeout(resolve, 50));
1489
+ if ((globalThis.__fmPrompts ?? []).length !== 1) {
1490
+ throw new Error(`already-drained wake prompted again: ${JSON.stringify(globalThis.__fmPrompts ?? [])}`);
1491
+ }
1492
+ if (mainUserMessages.length !== 0) {
1493
+ throw new Error(`already-drained wake fell back to main: ${JSON.stringify(mainUserMessages)}`);
1494
+ }
1495
+ process.exit(0);
1496
+ EOF
1497
+ status=$?
1498
+ out=$(cat "$TMP_ROOT/node-output")
1499
+ expect_code 0 "$status" "an already-drained serialized wake must no-op without main fallback: $out"
1500
+ pass "pre-drain eligibility re-check no-ops an already-drained wake"
1501
+ }
1502
+
1503
+ test_branch_mirror_filters_order_and_cursor() {
1504
+ local repo home out status
1505
+ repo="$TMP_ROOT/mirror-root"
1506
+ home="$TMP_ROOT/mirror-home"
1507
+ mkdir -p "$home/state" "$home/config"
1508
+ install_pi_branch_extension_fixture "$repo"
1509
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
1510
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
1511
+ const prelude = process.env.DRIVER_PRELUDE;
1512
+ await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, home }; })()`);
1513
+ const { fire, dispatch, settle, home } = globalThis.__t;
1514
+ import { existsSync, readFileSync } from "node:fs";
1515
+
1516
+ const entries = [
1517
+ { type: "message", message: { role: "user", content: `never merge task-7 without my word ${"h".repeat(5000)} old-history-tail` } },
1518
+ { type: "message", message: { role: "assistant", content: [{ type: "text", text: "aye, holding task-7" }, { type: "toolCall", id: "t1" }] } },
1519
+ { type: "message", message: { role: "user", content: "⁣FIRSTMATE_OP: v1 watcher: operational injection" } },
1520
+ { type: "message", message: { role: "toolResult", content: "tool output stays in main" } },
1521
+ { type: "custom", message: { role: "custom", customType: "fm-branch-merge", content: "merged note" } },
1522
+ { type: "compaction", summary: "compacted" },
1523
+ { type: "message", message: { role: "user", content: `pad ${"x".repeat(5000)}\ntail: retain this request` } },
1524
+ ];
1525
+ const ctx = {
1526
+ sessionManager: {
1527
+ getSessionFile: () => `${home}/main-1.jsonl`,
1528
+ getEntries: () => entries,
1529
+ },
1530
+ };
1531
+
1532
+ // Dialog collected at main's turn_end, delivered into the branch BEFORE the
1533
+ // next wake, tagged and filtered: no tool traffic, no operational injections,
1534
+ // no merge notes, long messages capped.
1535
+ fire("turn_end", {}, ctx);
1536
+ dispatch("signal: after mirror");
1537
+ await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "branch wake prompt");
1538
+ const session = globalThis.__fmSessions[0];
1539
+ const kinds = session.ops.map((op) => op.kind);
1540
+ if (JSON.stringify(kinds) !== JSON.stringify(["custom", "custom", "custom", "prompt"])) {
1541
+ throw new Error(`mirror must land before the wake: ${JSON.stringify(kinds)}`);
1542
+ }
1543
+ const mirrored = session.ops.filter((op) => op.kind === "custom").map((op) => op.message);
1544
+ if (mirrored.some((m) => m.customType !== "fm-main-mirror")) throw new Error("mirror used the wrong custom type");
1545
+ if (mirrored.some((m) => m.display !== false)) throw new Error("mirrored context must be silent");
1546
+ if (!mirrored[0].content.startsWith("[captain] never merge task-7 without my word") ||
1547
+ !mirrored[0].content.includes("[mirror truncated:") ||
1548
+ !mirrored[0].content.endsWith("old-history-tail")) {
1549
+ throw new Error(`older captain history was not bounded: ${mirrored[0].content}`);
1550
+ }
1551
+ if (mirrored[1].content !== "[main] aye, holding task-7") throw new Error(`bad main mirror: ${mirrored[1].content}`);
1552
+ if (mirrored[2].content !== `[captain] ${entries[6].message.content}`) {
1553
+ throw new Error("current captain dialog was not preserved completely");
1554
+ }
1555
+ if (mirrored.some((m) => m.content.includes("operational injection") || m.content.includes("tool output") || m.content.includes("merged note"))) {
1556
+ throw new Error("mirror leaked operational, tool, or merge-note traffic");
1557
+ }
1558
+
1559
+ // The durable cursor advances: a second turn_end mirrors only NEW dialog.
1560
+ entries.push({ type: "message", message: { role: "user", content: "actually, task-7 may merge when green" } });
1561
+ fire("turn_end", {}, ctx);
1562
+ await settle(() => session.ops.filter((op) => op.kind === "custom").length === 4, "incremental mirror");
1563
+ const latest = session.ops[session.ops.length - 1];
1564
+ if (latest.message.content !== "[captain] actually, task-7 may merge when green") {
1565
+ throw new Error(`incremental mirror re-sent old dialog or lost the new line: ${latest.message.content}`);
1566
+ }
1567
+ if (!existsSync(`${home}/state/.branch-mirror-cursor`)) throw new Error("mirror cursor is not durable");
1568
+ const cursor = JSON.parse(readFileSync(`${home}/state/.branch-mirror-cursor`, "utf8"));
1569
+ if (cursor.file !== `${home}/main-1.jsonl` || cursor.index !== entries.length) {
1570
+ throw new Error(`cursor did not advance with the session file: ${JSON.stringify(cursor)}`);
1571
+ }
1572
+
1573
+ // A replacement main session re-anchors: dialog mirrors from its start.
1574
+ const ctx2 = {
1575
+ sessionManager: {
1576
+ getSessionFile: () => `${home}/main-2.jsonl`,
1577
+ getEntries: () => [{ type: "message", message: { role: "user", content: "fresh session standing order" } }],
1578
+ },
1579
+ };
1580
+ fire("turn_end", {}, ctx2);
1581
+ await settle(() => session.ops.filter((op) => op.kind === "custom").length === 5, "replacement-session mirror");
1582
+ const fresh = session.ops[session.ops.length - 1];
1583
+ if (fresh.message.content !== "[captain] fresh session standing order") {
1584
+ throw new Error(`replacement session did not re-anchor the mirror: ${fresh.message.content}`);
1585
+ }
1586
+ process.exit(0);
1587
+ EOF
1588
+ status=$?
1589
+ out=$(cat "$TMP_ROOT/node-output")
1590
+ expect_code 0 "$status" "mirror filtering, ordering, and cursor must hold: $out"
1591
+ pass "dialog mirror filters tool and operational traffic, lands before wakes, and keeps a durable cursor"
1592
+ }
1593
+
1594
+ test_branch_session_persists_across_process_restarts() {
1595
+ local repo home out status
1596
+ repo="$TMP_ROOT/persist-root"
1597
+ home="$TMP_ROOT/persist-home"
1598
+ mkdir -p "$home/state" "$home/config"
1599
+ install_pi_branch_extension_fixture "$repo"
1600
+ run_once() {
1601
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
1602
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module 2>&1 <<'EOF'
1603
+ const prelude = process.env.DRIVER_PRELUDE;
1604
+ await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, settle }; })()`);
1605
+ const { dispatch, settle } = globalThis.__t;
1606
+ dispatch("signal: persistence probe");
1607
+ await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "branch wake prompt");
1608
+ const sm = globalThis.__fmSessionManagers[0];
1609
+ console.log(`${sm.opened ? "opened" : "created"} ${sm.getSessionFile()}`);
1610
+ process.exit(0);
1611
+ EOF
1612
+ }
1613
+ out=$(run_once) || fail "first branch session run failed: $out"
1614
+ # Path.join normalizes the doubled slash macOS TMPDIR introduces, so match
1615
+ # on the home-relative tail rather than the raw $home prefix.
1616
+ case "$out" in
1617
+ "created "*"/persist-home/state/branch-session/"*.jsonl) ;;
1618
+ *) fail "first run did not create a session under state/branch-session: $out" ;;
1619
+ esac
1620
+ first_file=${out#created }
1621
+ [ -f "$home/state/.branch-session" ] || fail "branch session pointer was not recorded"
1622
+ out=$(run_once) || fail "second branch session run failed: $out"
1623
+ [ "$out" = "opened $first_file" ] \
1624
+ || fail "restart did not reopen the persistent branch session (got: $out; want: opened $first_file)"
1625
+ pass "branch session persists across process restarts through the recorded pointer"
1626
+ }
1627
+
1628
+ test_branch_model_pin_applies_and_absent_pin_keeps_the_default() {
1629
+ local repo home out status
1630
+ repo="$TMP_ROOT/modelpin-root"
1631
+ home="$TMP_ROOT/modelpin-home"
1632
+ mkdir -p "$home/state" "$home/config"
1633
+ install_pi_branch_extension_fixture "$repo"
1634
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
1635
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
1636
+ const prelude = process.env.DRIVER_PRELUDE;
1637
+ await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, makeCtx, registryModels, home }; })()`);
1638
+ const { fire, dispatch, settle, makeCtx, registryModels, home } = globalThis.__t;
1639
+ import { rmSync, writeFileSync } from "node:fs";
1640
+
1641
+ registryModels.push({ provider: "anthropic", id: "main-model" }, { provider: "openai", id: "cheap-1" });
1642
+
1643
+ // 1. No pin and main's model not known yet: the build falls back to the
1644
+ // pre-feature path of passing no override rather than refusing to build, so
1645
+ // a wake is never lost over model choice.
1646
+ fire("session_start", {});
1647
+ dispatch("signal: main model unknown");
1648
+ await settle(() => (globalThis.__fmSessions ?? []).length === 1, "unknown-main-model branch build");
1649
+ if ("model" in globalThis.__fmSessions[0].options) {
1650
+ throw new Error("an unknown main model must fall back to passing no model override");
1651
+ }
1652
+
1653
+ // 2. No pin, main's model known: the branch follows MAIN's own model,
1654
+ // applied explicitly.
1655
+ fire("session_shutdown", {});
1656
+ fire("session_start", {}, makeCtx());
1657
+ dispatch("signal: unpinned probe");
1658
+ await settle(() => (globalThis.__fmSessions ?? []).length === 2, "unpinned branch build");
1659
+ const unpinned = globalThis.__fmSessions[1].options.model;
1660
+ if (!unpinned || unpinned.provider !== "anthropic" || unpinned.id !== "main-model") {
1661
+ throw new Error(`an absent pin must follow main's own model: ${JSON.stringify(unpinned)}`);
1662
+ }
1663
+
1664
+ // 3. Pin present: the very next build carries exactly that model, resolved
1665
+ // out of Pi's own catalog.
1666
+ writeFileSync(`${home}/config/supervision-branch-model`, "openai/cheap-1\n");
1667
+ fire("session_shutdown", {});
1668
+ fire("session_start", {}, makeCtx());
1669
+ dispatch("signal: pinned probe");
1670
+ await settle(() => (globalThis.__fmSessions ?? []).length === 3, "pinned branch build");
1671
+ const pinned = globalThis.__fmSessions[2].options.model;
1672
+ if (!pinned || pinned.provider !== "openai" || pinned.id !== "cheap-1") {
1673
+ throw new Error(`pinned build did not use the pinned model: ${JSON.stringify(pinned)}`);
1674
+ }
1675
+
1676
+ // 4. The reopen path (/new, /resume, /fork, reload all replace the session
1677
+ // in-process) reopens the SAME persistent branch conversation and still
1678
+ // applies the pin.
1679
+ fire("session_shutdown", {});
1680
+ fire("session_start", {}, makeCtx());
1681
+ dispatch("signal: reopened probe");
1682
+ await settle(() => (globalThis.__fmSessions ?? []).length === 4, "reopened branch build");
1683
+ const reopened = globalThis.__fmSessions[3].options.model;
1684
+ if (!reopened || reopened.provider !== "openai" || reopened.id !== "cheap-1") {
1685
+ throw new Error(`reopened build did not use the pinned model: ${JSON.stringify(reopened)}`);
1686
+ }
1687
+ const manager = globalThis.__fmSessions[3].options.sessionManager;
1688
+ if (!manager.opened) throw new Error("reopen did not continue the persistent branch conversation");
1689
+
1690
+ // 5. Clearing the pin makes the REOPENED branch follow main again. This is
1691
+ // the case Pi's own session restore would otherwise get wrong: the branch
1692
+ // conversation still records the pinned model, so only an explicit override
1693
+ // keeps "follow main" honest.
1694
+ rmSync(`${home}/config/supervision-branch-model`);
1695
+ fire("session_shutdown", {});
1696
+ fire("session_start", {}, makeCtx());
1697
+ dispatch("signal: unpinned again");
1698
+ await settle(() => (globalThis.__fmSessions ?? []).length === 5, "post-clear branch build");
1699
+ const cleared = globalThis.__fmSessions[4];
1700
+ if (!cleared.options.sessionManager.opened) {
1701
+ throw new Error("the post-clear build must still reopen the persistent branch conversation");
1702
+ }
1703
+ if (cleared.options.model?.id === "cheap-1") {
1704
+ throw new Error("clearing the pin left the branch on the previously pinned model");
1705
+ }
1706
+ if (cleared.options.model?.provider !== "anthropic" || cleared.options.model?.id !== "main-model") {
1707
+ throw new Error(`clearing the pin did not return the branch to main's model: ${JSON.stringify(cleared.options.model)}`);
1708
+ }
1709
+ process.exit(0);
1710
+ EOF
1711
+ status=$?
1712
+ out=$(cat "$TMP_ROOT/node-output")
1713
+ expect_code 0 "$status" "the current pin state must decide the model on every branch build: $out"
1714
+ pass "the current pin state binds every branch create and reopen, and clearing it returns the branch to main's model"
1715
+ }
1716
+
1717
+ test_unpinned_branch_follows_main_model_changes_live() {
1718
+ local repo home out status
1719
+ repo="$TMP_ROOT/model-live-root"
1720
+ home="$TMP_ROOT/model-live-home"
1721
+ mkdir -p "$home/state" "$home/config"
1722
+ install_pi_branch_extension_fixture "$repo"
1723
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
1724
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
1725
+ const prelude = process.env.DRIVER_PRELUDE;
1726
+ await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, makeCtx, registryModels, home }; })()`);
1727
+ const { fire, dispatch, settle, makeCtx, registryModels, home } = globalThis.__t;
1728
+ import { readFileSync, writeFileSync } from "node:fs";
1729
+
1730
+ registryModels.push(
1731
+ { provider: "anthropic", id: "main-model" },
1732
+ { provider: "anthropic", id: "replacement-model" },
1733
+ { provider: "openai", id: "cheap-1" },
1734
+ );
1735
+ fire("session_start", {}, makeCtx());
1736
+ dispatch("signal: before main model change");
1737
+ await settle(() => (globalThis.__fmSessions ?? []).length === 1, "initial unpinned branch build");
1738
+ const original = globalThis.__fmSessions[0];
1739
+ if (original.options.model?.id !== "main-model") throw new Error("the unpinned branch did not start on main's model");
1740
+
1741
+ fire("model_select", { model: { provider: "anthropic", id: "replacement-model" } });
1742
+ await settle(() => original.disposed, "live unpinned branch release");
1743
+ dispatch("signal: after main model change");
1744
+ await settle(() => (globalThis.__fmSessions ?? []).length === 2, "replacement unpinned branch build");
1745
+ const following = globalThis.__fmSessions[1];
1746
+ if (following.options.model?.id !== "replacement-model") {
1747
+ throw new Error(`the unpinned branch did not follow main's model change: ${JSON.stringify(following.options.model)}`);
1748
+ }
1749
+
1750
+ writeFileSync(`${home}/config/supervision-branch-model`, "openai/cheap-1\n");
1751
+ fire("session_shutdown", {});
1752
+ fire("session_start", {}, makeCtx());
1753
+ dispatch("signal: establish pinned branch");
1754
+ await settle(() => (globalThis.__fmSessions ?? []).length === 3, "pinned branch build");
1755
+ const pinned = globalThis.__fmSessions[2];
1756
+ if (pinned.options.model?.id !== "cheap-1") throw new Error("the pinned branch did not use its pin");
1757
+ const promptsBefore = pinned.ops.filter((op) => op.kind === "prompt").length;
1758
+
1759
+ fire("model_select", { model: { provider: "anthropic", id: "replacement-model" } });
1760
+ dispatch("signal: pinned after main model change");
1761
+ await settle(() => pinned.ops.filter((op) => op.kind === "prompt").length === promptsBefore + 1, "pinned branch wake");
1762
+ if (pinned.disposed || globalThis.__fmSessions.length !== 3) {
1763
+ throw new Error("a main model change replaced the pinned branch");
1764
+ }
1765
+ if (readFileSync(`${home}/config/supervision-branch-model`, "utf8") !== "openai/cheap-1\n") {
1766
+ throw new Error("a main model change disturbed the supervision pin");
1767
+ }
1768
+ process.exit(0);
1769
+ EOF
1770
+ status=$?
1771
+ out=$(cat "$TMP_ROOT/node-output")
1772
+ expect_code 0 "$status" "unpinned branches must follow main model changes while pins remain authoritative: $out"
1773
+ pass "unpinned branches follow main model changes live while pinned branches stay fixed"
1774
+ }
1775
+
1776
+ test_supervision_model_command_persists_and_rebinds_the_live_branch() {
1777
+ local repo home out status
1778
+ repo="$TMP_ROOT/modelcmd-root"
1779
+ home="$TMP_ROOT/modelcmd-home"
1780
+ mkdir -p "$home/state" "$home/config"
1781
+ install_pi_branch_extension_fixture "$repo"
1782
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
1783
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
1784
+ const prelude = process.env.DRIVER_PRELUDE;
1785
+ await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, makeCtx, commands, registryModels, uiSelections, uiPrompts, notices, home }; })()`);
1786
+ const { fire, dispatch, settle, makeCtx, commands, registryModels, uiSelections, uiPrompts, notices, home } = globalThis.__t;
1787
+ import { existsSync, readFileSync, statSync } from "node:fs";
1788
+
1789
+ registryModels.push(
1790
+ { provider: "anthropic", id: "main-model" },
1791
+ { provider: "openai-codex", id: "cheap-oauth", authKind: "oauth" },
1792
+ { provider: "dynamic", id: "extension-only", branchAvailable: false },
1793
+ );
1794
+ const command = commands.get("supervision-model");
1795
+ if (!command) throw new Error("the supervision-model command was not registered");
1796
+
1797
+ fire("session_start", {}, makeCtx());
1798
+ dispatch("signal: before the pick");
1799
+ await settle(() => (globalThis.__fmSessions ?? []).length === 1, "pre-pick branch build");
1800
+ const firstSession = globalThis.__fmSessions[0];
1801
+ if (firstSession.options.model?.id !== "main-model") {
1802
+ throw new Error(`the branch started on something other than main's model before any pick: ${JSON.stringify(firstSession.options.model)}`);
1803
+ }
1804
+
1805
+ // The picker offers Pi's branch-runnable catalog plus following main, and the
1806
+ // captain's pick is persisted as the one-line config value.
1807
+ uiSelections.push("openai-codex/cheap-oauth");
1808
+ await command.handler("", makeCtx());
1809
+ const offered = uiPrompts[0];
1810
+ if (offered.options[0] !== "Follow main (anthropic/main-model)") {
1811
+ throw new Error(`the picker must offer following main first: ${JSON.stringify(offered.options)}`);
1812
+ }
1813
+ if (!offered.options.includes("openai-codex/cheap-oauth") || !offered.options.includes("anthropic/main-model")) {
1814
+ throw new Error(`the picker omitted a model available to the isolated branch: ${JSON.stringify(offered.options)}`);
1815
+ }
1816
+ if (offered.options.includes("dynamic/extension-only")) {
1817
+ throw new Error(`the picker offered a main-session-only provider: ${JSON.stringify(offered.options)}`);
1818
+ }
1819
+ const pinFile = `${home}/config/supervision-branch-model`;
1820
+ if (readFileSync(pinFile, "utf8") !== "openai-codex/cheap-oauth\n") {
1821
+ throw new Error(`unexpected persisted pin: ${JSON.stringify(readFileSync(pinFile, "utf8"))}`);
1822
+ }
1823
+ if ((statSync(pinFile).mode & 0o777) !== 0o600) throw new Error("the pin must be written private to the operator");
1824
+ if (!notices.some((notice) => notice.message.includes("openai-codex/cheap-oauth"))) {
1825
+ throw new Error(`the captain was not told which model the branch now uses: ${JSON.stringify(notices)}`);
1826
+ }
1827
+
1828
+ // The live branch is released, so the pick binds on the next wake instead of
1829
+ // waiting for a session replacement - and the same persistent conversation
1830
+ // comes back under the new model.
1831
+ await settle(() => firstSession.disposed, "live branch release after the pick");
1832
+ dispatch("signal: after the pick");
1833
+ await settle(() => (globalThis.__fmSessions ?? []).length === 2, "post-pick branch build");
1834
+ const repinned = globalThis.__fmSessions[1];
1835
+ if (!repinned.options.model || repinned.options.model.id !== "cheap-oauth") {
1836
+ throw new Error(`the pick did not bind the next branch build: ${JSON.stringify(repinned.options.model)}`);
1837
+ }
1838
+ if (repinned.options.model.authKind !== "oauth") {
1839
+ throw new Error(`the branch runtime changed the stored OAuth credential semantics: ${JSON.stringify(repinned.options.model)}`);
1840
+ }
1841
+ if (!repinned.options.sessionManager.opened) throw new Error("the pick must keep the branch conversation, not start a new one");
1842
+
1843
+ // Following main again clears the file and actually returns the branch to
1844
+ // main's model, rather than letting the reopened session restore the pin.
1845
+ const clearNoticeCount = notices.length;
1846
+ uiSelections.push("Follow main (anthropic/main-model)");
1847
+ await command.handler("", makeCtx());
1848
+ if (existsSync(pinFile)) throw new Error("following main must remove the pin file");
1849
+ const clearNotices = notices.slice(clearNoticeCount);
1850
+ if (clearNotices.length !== 1 || clearNotices[0].type !== "info" || !clearNotices[0].message.includes("anthropic/main-model")) {
1851
+ throw new Error(`following main did not report the model actually applied: ${JSON.stringify(clearNotices)}`);
1852
+ }
1853
+ await settle(() => repinned.disposed, "live branch release after clearing");
1854
+ dispatch("signal: after clearing");
1855
+ await settle(() => (globalThis.__fmSessions ?? []).length === 3, "post-clear branch build");
1856
+ const followed = globalThis.__fmSessions[2].options.model;
1857
+ if (followed?.id === "cheap-oauth") throw new Error("following main left the branch on the cleared pin's model");
1858
+ if (followed?.provider !== "anthropic" || followed?.id !== "main-model") {
1859
+ throw new Error(`following main did not apply main's own model: ${JSON.stringify(followed)}`);
1860
+ }
1861
+
1862
+ // A pick made while the old-model branch build is in flight invalidates that
1863
+ // build. The accepted wake continues on a second build under the newest pin.
1864
+ uiSelections.push("Follow main (anthropic/main-model)");
1865
+ await command.handler("", makeCtx());
1866
+ let releaseCreate;
1867
+ globalThis.__fmCreateGate = new Promise((resolve) => { releaseCreate = resolve; });
1868
+ const createsBeforeRace = globalThis.__fmCreateStarted;
1869
+ dispatch("signal: model race");
1870
+ await settle(() => globalThis.__fmCreateStarted === createsBeforeRace + 1, "in-flight old-model build");
1871
+ uiSelections.push("openai-codex/cheap-oauth");
1872
+ await command.handler("", makeCtx());
1873
+ releaseCreate();
1874
+ await settle(() => (globalThis.__fmSessions ?? []).length === 5, "replacement build after in-flight pick");
1875
+ const staleBuild = globalThis.__fmSessions[3];
1876
+ const winningBuild = globalThis.__fmSessions[4];
1877
+ if (!staleBuild.disposed || staleBuild.options.model?.id === "cheap-oauth") {
1878
+ throw new Error("the in-flight old-model build was adopted after the pick");
1879
+ }
1880
+ if (winningBuild.options.model?.id !== "cheap-oauth") {
1881
+ throw new Error(`the newest pin did not win the in-flight build race: ${JSON.stringify(winningBuild.options.model)}`);
1882
+ }
1883
+ await settle(() => (globalThis.__fmPrompts ?? []).some((prompt) => prompt.includes("signal: model race")), "raced wake prompt");
1884
+
1885
+ // A cancelled picker changes nothing.
1886
+ uiSelections.push(undefined);
1887
+ await command.handler("", makeCtx());
1888
+ if (readFileSync(pinFile, "utf8") !== "openai-codex/cheap-oauth\n") throw new Error("a cancelled picker must not change the pin");
1889
+
1890
+ // If the isolated runtime cannot load, the old pin remains and no success
1891
+ // notification is emitted.
1892
+ const noticeCount = notices.length;
1893
+ globalThis.__fmModelRuntimeError = "synthetic stored-credential load failure";
1894
+ await command.handler("", makeCtx());
1895
+ delete globalThis.__fmModelRuntimeError;
1896
+ if (readFileSync(pinFile, "utf8") !== "openai-codex/cheap-oauth\n") throw new Error("an unapplied model replaced the working pin");
1897
+ const newNotices = notices.slice(noticeCount);
1898
+ if (newNotices.length !== 1 || newNotices[0].type !== "error") {
1899
+ throw new Error(`an unapplied model emitted a success notification: ${JSON.stringify(newNotices)}`);
1900
+ }
1901
+
1902
+ // If the picker loads but resolving main after Follow main fails, the pin is
1903
+ // cleared and the captain receives an honest warning rather than a rejection
1904
+ // or a false success notice.
1905
+ const clearFailureNoticeCount = notices.length;
1906
+ globalThis.__fmModelRuntimeErrors = [null, "synthetic post-clear runtime failure"];
1907
+ uiSelections.push("Follow main (anthropic/main-model)");
1908
+ await command.handler("", makeCtx());
1909
+ if (existsSync(pinFile)) throw new Error("following main did not clear the pin before its resolution warning");
1910
+ const clearFailureNotices = notices.slice(clearFailureNoticeCount);
1911
+ if (
1912
+ clearFailureNotices.length !== 1 ||
1913
+ clearFailureNotices[0].type !== "warning" ||
1914
+ !clearFailureNotices[0].message.includes("synthetic post-clear runtime failure")
1915
+ ) {
1916
+ throw new Error(`post-clear resolution failure was not reported honestly: ${JSON.stringify(clearFailureNotices)}`);
1917
+ }
1918
+ process.exit(0);
1919
+ EOF
1920
+ status=$?
1921
+ out=$(cat "$TMP_ROOT/node-output")
1922
+ expect_code 0 "$status" "the supervision-model command must persist the pick and rebind the live branch: $out"
1923
+ pass "supervision-model command persists the captain's pick and rebinds the live branch"
1924
+ }
1925
+
1926
+ test_branch_effort_pin_applies_and_absent_pin_follows_main() {
1927
+ local repo home out status
1928
+ repo="$TMP_ROOT/effortpin-root"
1929
+ home="$TMP_ROOT/effortpin-home"
1930
+ mkdir -p "$home/state" "$home/config"
1931
+ install_pi_branch_extension_fixture "$repo"
1932
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
1933
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
1934
+ const prelude = process.env.DRIVER_PRELUDE;
1935
+ await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, makeCtx, registryModels, setMainThinkingLevel, home }; })()`);
1936
+ const { fire, dispatch, settle, makeCtx, registryModels, setMainThinkingLevel, home } = globalThis.__t;
1937
+ import { rmSync, writeFileSync } from "node:fs";
1938
+
1939
+ // main-model reasons up to Pi's "high"; deep-1 also maps xhigh and max.
1940
+ registryModels.push(
1941
+ { provider: "anthropic", id: "main-model", reasoning: true },
1942
+ { provider: "openai", id: "deep-1", reasoning: true, thinkingLevelMap: { xhigh: "xhigh", max: "max" } },
1943
+ );
1944
+ const effortPin = `${home}/config/supervision-branch-effort`;
1945
+ const modelPin = `${home}/config/supervision-branch-model`;
1946
+ function rebuild(reason, expected) {
1947
+ fire("session_shutdown", {});
1948
+ fire("session_start", {}, makeCtx());
1949
+ dispatch(`signal: ${reason}`);
1950
+ return settle(() => (globalThis.__fmSessions ?? []).length === expected, `${reason} branch build`);
1951
+ }
1952
+
1953
+ // 1. No effort pin: the branch applies MAIN's own current effort explicitly,
1954
+ // so a reopened conversation cannot restore the level it last ran under.
1955
+ fire("session_start", {}, makeCtx());
1956
+ dispatch("signal: unpinned effort");
1957
+ await settle(() => (globalThis.__fmSessions ?? []).length === 1, "unpinned-effort branch build");
1958
+ if (globalThis.__fmSessions[0].options.thinkingLevel !== "medium") {
1959
+ throw new Error(`an absent effort pin must follow main's own effort: ${globalThis.__fmSessions[0].options.thinkingLevel}`);
1960
+ }
1961
+
1962
+ // 2. An effort-only pin binds the next build and leaves the model following
1963
+ // main, so the two pins are independently expressible.
1964
+ writeFileSync(effortPin, "low\n");
1965
+ await rebuild("effort-only pin", 2);
1966
+ const effortOnly = globalThis.__fmSessions[1].options;
1967
+ if (effortOnly.thinkingLevel !== "low") {
1968
+ throw new Error(`the effort pin did not bind the build: ${effortOnly.thinkingLevel}`);
1969
+ }
1970
+ if (effortOnly.model?.id !== "main-model") {
1971
+ throw new Error(`an effort-only pin must leave the model following main: ${JSON.stringify(effortOnly.model)}`);
1972
+ }
1973
+
1974
+ // 3. The reopen path applies the effort pin too, over whatever the restored
1975
+ // branch conversation recorded.
1976
+ await rebuild("effort pin reopen", 3);
1977
+ if (globalThis.__fmSessions[2].options.thinkingLevel !== "low") {
1978
+ throw new Error(`the reopened build dropped the effort pin: ${globalThis.__fmSessions[2].options.thinkingLevel}`);
1979
+ }
1980
+ if (!globalThis.__fmSessions[2].options.sessionManager.opened) {
1981
+ throw new Error("the effort-pinned reopen must continue the persistent branch conversation");
1982
+ }
1983
+
1984
+ // 4. A model-only pin leaves the effort following main, the mirror image of
1985
+ // case 2.
1986
+ rmSync(effortPin);
1987
+ writeFileSync(modelPin, "openai/deep-1\n");
1988
+ setMainThinkingLevel("high");
1989
+ await rebuild("model-only pin", 4);
1990
+ const modelOnly = globalThis.__fmSessions[3].options;
1991
+ if (modelOnly.model?.id !== "deep-1") throw new Error("the model-only pin did not bind the build");
1992
+ if (modelOnly.thinkingLevel !== "high") {
1993
+ throw new Error(`a model-only pin must leave the effort following main: ${modelOnly.thinkingLevel}`);
1994
+ }
1995
+
1996
+ // 5. Clearing the effort pin is the case Pi's own restore would get wrong:
1997
+ // the branch conversation still records the pinned level, so main's current
1998
+ // effort has to be applied explicitly rather than merely omitted.
1999
+ writeFileSync(effortPin, "max\n");
2000
+ await rebuild("effort repinned", 5);
2001
+ if (globalThis.__fmSessions[4].options.thinkingLevel !== "max") {
2002
+ throw new Error(`deep-1 must accept Pi's max level: ${globalThis.__fmSessions[4].options.thinkingLevel}`);
2003
+ }
2004
+ rmSync(effortPin);
2005
+ setMainThinkingLevel("minimal");
2006
+ await rebuild("effort cleared", 6);
2007
+ if (globalThis.__fmSessions[5].options.thinkingLevel === "max") {
2008
+ throw new Error("clearing the effort pin left the branch on the previously pinned level");
2009
+ }
2010
+ if (globalThis.__fmSessions[5].options.thinkingLevel !== "minimal") {
2011
+ throw new Error(`clearing the effort pin did not return the branch to main's effort: ${globalThis.__fmSessions[5].options.thinkingLevel}`);
2012
+ }
2013
+
2014
+ // 6. Pi owns the clamp: a pinned level the branch's model cannot run becomes
2015
+ // that model's nearest supported level instead of refusing the branch.
2016
+ rmSync(modelPin);
2017
+ writeFileSync(effortPin, "max\n");
2018
+ await rebuild("clamped effort pin", 7);
2019
+ const clamped = globalThis.__fmSessions[6].options;
2020
+ if (clamped.model?.id !== "main-model") throw new Error("the clamp probe must run on main's own model");
2021
+ if (clamped.thinkingLevel !== "high") {
2022
+ throw new Error(`an unsupported pinned level must clamp to the model's nearest level: ${clamped.thinkingLevel}`);
2023
+ }
2024
+
2025
+ // 7. A token Pi does not recognize is no pin at all, exactly like an
2026
+ // unparseable model pin, and never a silent downgrade to no reasoning.
2027
+ writeFileSync(effortPin, "deep-thought\n");
2028
+ setMainThinkingLevel("medium");
2029
+ await rebuild("unrecognized effort pin", 8);
2030
+ if (globalThis.__fmSessions[7].options.thinkingLevel !== "medium") {
2031
+ throw new Error(`an unrecognized effort token must behave as no pin: ${globalThis.__fmSessions[7].options.thinkingLevel}`);
2032
+ }
2033
+
2034
+ // 8. When Pi cannot report main's own effort either, the build falls back to
2035
+ // passing no effort override at all rather than losing the wake.
2036
+ rmSync(effortPin);
2037
+ globalThis.__fmThinkingLevelError = "synthetic unbound extension runtime";
2038
+ await rebuild("unknown main effort", 9);
2039
+ delete globalThis.__fmThinkingLevelError;
2040
+ if ("thinkingLevel" in globalThis.__fmSessions[8].options) {
2041
+ throw new Error("an unknowable main effort must fall back to passing no effort override");
2042
+ }
2043
+ process.exit(0);
2044
+ EOF
2045
+ status=$?
2046
+ out=$(cat "$TMP_ROOT/node-output")
2047
+ expect_code 0 "$status" "the current effort pin state must decide the branch effort on every build: $out"
2048
+ pass "the effort pin binds every branch create and reopen, and clearing it returns the branch to main's effort"
2049
+ }
2050
+
2051
+ test_unpinned_branch_follows_main_effort_changes_live() {
2052
+ local repo home out status
2053
+ repo="$TMP_ROOT/effort-live-root"
2054
+ home="$TMP_ROOT/effort-live-home"
2055
+ mkdir -p "$home/state" "$home/config"
2056
+ install_pi_branch_extension_fixture "$repo"
2057
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
2058
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
2059
+ const prelude = process.env.DRIVER_PRELUDE;
2060
+ await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, makeCtx, registryModels, setMainThinkingLevel, home }; })()`);
2061
+ const { fire, dispatch, settle, makeCtx, registryModels, setMainThinkingLevel, home } = globalThis.__t;
2062
+ import { readFileSync, writeFileSync } from "node:fs";
2063
+
2064
+ registryModels.push({ provider: "anthropic", id: "main-model", reasoning: true });
2065
+ const effortPin = `${home}/config/supervision-branch-effort`;
2066
+
2067
+ fire("session_start", {}, makeCtx());
2068
+ dispatch("signal: before main effort change");
2069
+ await settle(() => (globalThis.__fmSessions ?? []).length === 1, "initial branch build");
2070
+ const original = globalThis.__fmSessions[0];
2071
+ if (original.options.thinkingLevel !== "medium") throw new Error("the unpinned branch did not start on main's effort");
2072
+
2073
+ // A mid-session effort change releases the live branch, so the next wake
2074
+ // reopens the same conversation at main's new effort without waiting for a
2075
+ // session replacement.
2076
+ setMainThinkingLevel("high");
2077
+ fire("thinking_level_select", { level: "high", previousLevel: "medium" });
2078
+ await settle(() => original.disposed, "live branch release after main's effort change");
2079
+ dispatch("signal: after main effort change");
2080
+ await settle(() => (globalThis.__fmSessions ?? []).length === 2, "post-change branch build");
2081
+ const following = globalThis.__fmSessions[1];
2082
+ if (following.options.thinkingLevel !== "high") {
2083
+ throw new Error(`the unpinned branch did not follow main's effort change: ${following.options.thinkingLevel}`);
2084
+ }
2085
+ if (!following.options.sessionManager.opened) {
2086
+ throw new Error("following main's effort must keep the persistent branch conversation");
2087
+ }
2088
+
2089
+ // A pinned branch is authoritative: main's effort changes leave both the live
2090
+ // branch and the stored pin alone.
2091
+ writeFileSync(effortPin, "minimal\n");
2092
+ fire("session_shutdown", {});
2093
+ fire("session_start", {}, makeCtx());
2094
+ dispatch("signal: pinned effort");
2095
+ await settle(() => (globalThis.__fmSessions ?? []).length === 3, "pinned effort branch build");
2096
+ const pinned = globalThis.__fmSessions[2];
2097
+ if (pinned.options.thinkingLevel !== "minimal") throw new Error("the pinned branch did not use its effort pin");
2098
+
2099
+ setMainThinkingLevel("high");
2100
+ fire("thinking_level_select", { level: "high", previousLevel: "minimal" });
2101
+ dispatch("signal: pinned after main effort change");
2102
+ await new Promise((resolve) => setTimeout(resolve, 50));
2103
+ if (pinned.disposed) throw new Error("a main effort change replaced the effort-pinned branch");
2104
+ if (readFileSync(effortPin, "utf8") !== "minimal\n") {
2105
+ throw new Error("a main effort change disturbed the supervision effort pin");
2106
+ }
2107
+ process.exit(0);
2108
+ EOF
2109
+ status=$?
2110
+ out=$(cat "$TMP_ROOT/node-output")
2111
+ expect_code 0 "$status" "unpinned branches must follow main effort changes while pins remain authoritative: $out"
2112
+ pass "unpinned branches follow main effort changes live while pinned branches stay fixed"
2113
+ }
2114
+
2115
+ test_supervision_model_picker_is_bounded_searchable_and_branch_only() {
2116
+ local repo home out status
2117
+ repo="$TMP_ROOT/pickerux-root"
2118
+ home="$TMP_ROOT/pickerux-home"
2119
+ mkdir -p "$home/state" "$home/config"
2120
+ install_pi_branch_extension_fixture "$repo"
2121
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
2122
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
2123
+ const prelude = process.env.DRIVER_PRELUDE;
2124
+ await eval(`(async () => { ${prelude}; globalThis.__t = { fire, makeCtx, makeTuiCtx, commands, registryModels, uiSelections, uiKeystrokes, mainModelWrites, home }; })()`);
2125
+ const { fire, makeCtx, makeTuiCtx, commands, registryModels, uiSelections, uiKeystrokes, mainModelWrites, home } = globalThis.__t;
2126
+ import { readFileSync } from "node:fs";
2127
+
2128
+ // A catalog long enough that rendering it whole would run off a terminal,
2129
+ // plus one distinctively named model to search for and one model the
2130
+ // isolated branch runtime cannot run.
2131
+ registryModels.push({ provider: "anthropic", id: "main-model" });
2132
+ for (let i = 1; i <= 30; i += 1) registryModels.push({ provider: "anthropic", id: `bulk-model-${i}` });
2133
+ registryModels.push({ provider: "openai-codex", id: "cheap-oauth", authKind: "oauth" });
2134
+ registryModels.push({ provider: "dynamic", id: "extension-only", branchAvailable: false });
2135
+
2136
+ const command = commands.get("supervision-model");
2137
+ if (!command) throw new Error("the supervision-model command was not registered");
2138
+ fire("session_start", {}, makeCtx());
2139
+
2140
+ // The captain types a search query and confirms the one row it leaves.
2141
+ globalThis.__fmPickerLists = [];
2142
+ uiKeystrokes.push(["c", "h", "e", "a", "p", "tui.select.confirm"]);
2143
+ uiSelections.push(undefined); // the effort step is cancelled, leaving that choice alone
2144
+ await command.handler("", makeTuiCtx());
2145
+
2146
+ const lists = globalThis.__fmPickerLists;
2147
+ if (lists.length < 2) throw new Error(`typing a query must rebuild the list: ${JSON.stringify(lists.map((l) => l.items.length))}`);
2148
+ const opened = lists[0];
2149
+ if (opened.maxVisible !== 10) {
2150
+ throw new Error(`the model list must stay bounded rather than rendering every row: maxVisible=${opened.maxVisible}`);
2151
+ }
2152
+ if (opened.items[0].label !== "Follow main (anthropic/main-model)") {
2153
+ throw new Error(`following main must be the first row: ${JSON.stringify(opened.items.slice(0, 2))}`);
2154
+ }
2155
+ if (opened.items.length !== 33) {
2156
+ throw new Error(`the opened list must offer following main plus every branch-runnable model: ${opened.items.length}`);
2157
+ }
2158
+ if (opened.items.some((item) => item.label.includes("extension-only"))) {
2159
+ throw new Error("the picker widened past the branch runtime's eligibility filter");
2160
+ }
2161
+ const filtered = lists[lists.length - 1];
2162
+ if (filtered.items.length !== 1 || filtered.items[0].value !== "openai-codex/cheap-oauth") {
2163
+ throw new Error(`the search query did not narrow the list: ${JSON.stringify(filtered.items)}`);
2164
+ }
2165
+
2166
+ // The pick lands on the supervision branch alone.
2167
+ if (readFileSync(`${home}/config/supervision-branch-model`, "utf8") !== "openai-codex/cheap-oauth\n") {
2168
+ throw new Error("the searched-for pick was not persisted as the supervision branch model");
2169
+ }
2170
+ if (mainModelWrites.length !== 0) {
2171
+ throw new Error(`pinning the branch moved the captain's own model: ${JSON.stringify(mainModelWrites)}`);
2172
+ }
2173
+ if (makeCtx().model.id !== "main-model") throw new Error("the captain's own conversation model changed");
2174
+
2175
+ // A query that matches following main keeps that row first, and escape
2176
+ // leaves every choice standing.
2177
+ globalThis.__fmPickerLists = [];
2178
+ uiKeystrokes.push(["m", "a", "i", "n", "tui.select.cancel"]);
2179
+ await command.handler("", makeTuiCtx());
2180
+ const mainQuery = globalThis.__fmPickerLists[globalThis.__fmPickerLists.length - 1];
2181
+ if (mainQuery.items[0].label !== "Follow main (anthropic/main-model)") {
2182
+ throw new Error(`a matching query must keep following main first: ${JSON.stringify(mainQuery.items.slice(0, 2))}`);
2183
+ }
2184
+ if (mainQuery.items.length < 2) throw new Error("a matching query dropped the models it also matched");
2185
+ if (readFileSync(`${home}/config/supervision-branch-model`, "utf8") !== "openai-codex/cheap-oauth\n") {
2186
+ throw new Error("cancelling the picker changed the standing pin");
2187
+ }
2188
+ process.exit(0);
2189
+ EOF
2190
+ status=$?
2191
+ out=$(cat "$TMP_ROOT/node-output")
2192
+ expect_code 0 "$status" "the model picker must be bounded, searchable, and branch-only: $out"
2193
+ pass "supervision-model opens a bounded searchable list, follow main first, and pins the branch alone"
2194
+ }
2195
+
2196
+ test_branch_model_picker_keeps_follow_main_first_under_ranking() {
2197
+ local repo out status
2198
+ repo="$TMP_ROOT/pickerlib-root"
2199
+ mkdir -p "$repo/.pi/extensions/lib"
2200
+ cp "$ROOT/.pi/extensions/lib/fm-branch-model-picker.ts" "$repo/.pi/extensions/lib/fm-branch-model-picker.ts"
2201
+ LIB="$repo/.pi/extensions/lib/fm-branch-model-picker.ts" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
2202
+ import { pathToFileURL } from "node:url";
2203
+
2204
+ const { buildBranchModelItems, filterBranchPickerItems, BRANCH_PICKER_MAX_VISIBLE, FOLLOW_MAIN_VALUE } = await import(
2205
+ pathToFileURL(process.env.LIB).href
2206
+ );
2207
+
2208
+ const items = buildBranchModelItems("Follow main (anthropic/main-model)", ["anthropic/main-model", "openai/mainly-cheap"], null);
2209
+ if (items[0].value !== FOLLOW_MAIN_VALUE) throw new Error("following main must be built as the first row");
2210
+ if (items[0].description !== "current") throw new Error("an absent pin must mark following main as the current choice");
2211
+ if (items[2].description !== undefined) throw new Error("a model that is not pinned must not be marked current");
2212
+
2213
+ const pinned = buildBranchModelItems("Follow main (anthropic/main-model)", ["anthropic/main-model"], "anthropic/main-model");
2214
+ if (pinned[0].description !== undefined) throw new Error("a pinned branch must not mark following main as current");
2215
+ if (pinned[1].description !== "current") throw new Error("the pinned model must be marked as the current choice");
2216
+
2217
+ // A ranking filter is free to sort a better match ahead of following main;
2218
+ // the picker must still show following main first whenever it matches.
2219
+ const rankReversing = (list, query, getText) =>
2220
+ list.filter((item) => getText(item).toLowerCase().includes(query.toLowerCase())).reverse();
2221
+ const matched = filterBranchPickerItems(items, "main", rankReversing);
2222
+ if (matched[0].value !== FOLLOW_MAIN_VALUE) {
2223
+ throw new Error(`ranking moved following main out of first place: ${JSON.stringify(matched)}`);
2224
+ }
2225
+ if (matched.length !== 3) throw new Error(`a matching query dropped rows it should keep: ${JSON.stringify(matched)}`);
2226
+
2227
+ const narrowed = filterBranchPickerItems(items, "openai", rankReversing);
2228
+ if (narrowed.length !== 1 || narrowed[0].value !== "openai/mainly-cheap") {
2229
+ throw new Error(`a query that excludes following main must drop it: ${JSON.stringify(narrowed)}`);
2230
+ }
2231
+ const unfiltered = filterBranchPickerItems(items, " ", rankReversing);
2232
+ if (unfiltered.length !== items.length || unfiltered[0].value !== FOLLOW_MAIN_VALUE) {
2233
+ throw new Error("an empty query must keep the built order");
2234
+ }
2235
+ if (BRANCH_PICKER_MAX_VISIBLE !== 10) throw new Error("the picker must keep a bounded visible row count");
2236
+ process.exit(0);
2237
+ EOF
2238
+ status=$?
2239
+ out=$(cat "$TMP_ROOT/node-output")
2240
+ expect_code 0 "$status" "the picker's ordering and filtering must hold: $out"
2241
+ pass "branch model picker keeps follow main first and filters the eligible catalog"
2242
+ }
2243
+
2244
+ test_supervision_model_command_picks_effort_after_the_model() {
2245
+ local repo home out status
2246
+ repo="$TMP_ROOT/effortcmd-root"
2247
+ home="$TMP_ROOT/effortcmd-home"
2248
+ mkdir -p "$home/state" "$home/config"
2249
+ install_pi_branch_extension_fixture "$repo"
2250
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
2251
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
2252
+ const prelude = process.env.DRIVER_PRELUDE;
2253
+ await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, makeCtx, commands, registryModels, uiSelections, uiPrompts, notices, setMainThinkingLevel, home }; })()`);
2254
+ const { fire, dispatch, settle, makeCtx, commands, registryModels, uiSelections, uiPrompts, notices, setMainThinkingLevel, home } = globalThis.__t;
2255
+ import { existsSync, readFileSync, statSync } from "node:fs";
2256
+
2257
+ // main-model reasons up to Pi's "high"; deep-1 also maps xhigh and max, so
2258
+ // the two models genuinely offer different menus.
2259
+ registryModels.push(
2260
+ { provider: "anthropic", id: "main-model", reasoning: true },
2261
+ { provider: "openai", id: "deep-1", reasoning: true, thinkingLevelMap: { xhigh: "xhigh", max: "max" } },
2262
+ { provider: "openai", id: "shallow-1", reasoning: true },
2263
+ );
2264
+ const command = commands.get("supervision-model");
2265
+ const effortPin = `${home}/config/supervision-branch-effort`;
2266
+ const modelPin = `${home}/config/supervision-branch-model`;
2267
+ fire("session_start", {}, makeCtx());
2268
+
2269
+ // One command, two steps in order: the model picker, then an effort picker
2270
+ // built from Pi's own supported levels for the model just chosen.
2271
+ uiSelections.push("openai/deep-1", "xhigh");
2272
+ await command.handler("", makeCtx());
2273
+ if (uiPrompts.length !== 2) throw new Error(`the command must show the model picker then the effort picker: ${uiPrompts.length}`);
2274
+ if (!uiPrompts[0].title.startsWith("Supervision branch model")) {
2275
+ throw new Error(`the first step must be the model picker: ${uiPrompts[0].title}`);
2276
+ }
2277
+ const effortStep = uiPrompts[1];
2278
+ if (!effortStep.title.startsWith("Supervision branch effort")) {
2279
+ throw new Error(`the second step must be the effort picker: ${effortStep.title}`);
2280
+ }
2281
+ if (effortStep.options[0] !== "Follow main (medium)") {
2282
+ throw new Error(`the effort picker must offer following main first: ${JSON.stringify(effortStep.options)}`);
2283
+ }
2284
+ if (JSON.stringify(effortStep.options.slice(1)) !== JSON.stringify(["off", "minimal", "low", "medium", "high", "xhigh", "max"])) {
2285
+ throw new Error(`the effort picker must offer Pi's own levels for the chosen model: ${JSON.stringify(effortStep.options)}`);
2286
+ }
2287
+ if (readFileSync(effortPin, "utf8") !== "xhigh\n") {
2288
+ throw new Error(`unexpected persisted effort pin: ${JSON.stringify(readFileSync(effortPin, "utf8"))}`);
2289
+ }
2290
+ if ((statSync(effortPin).mode & 0o777) !== 0o600) throw new Error("the effort pin must be written private to the operator");
2291
+ if (readFileSync(modelPin, "utf8") !== "openai/deep-1\n") throw new Error("the model pick was not persisted alongside the effort pick");
2292
+ const bothNotice = notices[notices.length - 1];
2293
+ if (!bothNotice.message.includes("openai/deep-1") || !bothNotice.message.includes("xhigh")) {
2294
+ throw new Error(`the captain was not told both applied choices: ${JSON.stringify(bothNotice)}`);
2295
+ }
2296
+
2297
+ // Both choices bind the next branch build together.
2298
+ dispatch("signal: after both picks");
2299
+ await settle(() => (globalThis.__fmSessions ?? []).length === 1, "branch build after both picks");
2300
+ const both = globalThis.__fmSessions[0].options;
2301
+ if (both.model?.id !== "deep-1" || both.thinkingLevel !== "xhigh") {
2302
+ throw new Error(`the picks did not bind the branch build: ${JSON.stringify({ model: both.model?.id, effort: both.thinkingLevel })}`);
2303
+ }
2304
+
2305
+ // The effort picker's menu follows the model chosen in the SAME invocation,
2306
+ // not the model the branch was running a moment ago: main-model stops at
2307
+ // Pi's "high", so xhigh and max disappear.
2308
+ uiSelections.push("Follow main (anthropic/main-model)", "high");
2309
+ await command.handler("", makeCtx());
2310
+ const narrowed = uiPrompts[uiPrompts.length - 1];
2311
+ if (narrowed.options.includes("xhigh") || narrowed.options.includes("max")) {
2312
+ throw new Error(`the effort menu did not follow the model just chosen: ${JSON.stringify(narrowed.options)}`);
2313
+ }
2314
+ if (!narrowed.title.includes("now: xhigh")) {
2315
+ throw new Error(`the effort picker must show the standing pin: ${narrowed.title}`);
2316
+ }
2317
+ if (readFileSync(effortPin, "utf8") !== "high\n") throw new Error("the second effort pick was not persisted");
2318
+ if (existsSync(modelPin)) throw new Error("following main did not clear the model pin");
2319
+
2320
+ // Cancelling the effort step leaves the standing effort choice alone while
2321
+ // the model pick made in the same invocation still applies, and the captain
2322
+ // is told what the branch will actually run at.
2323
+ const cancelNoticeCount = notices.length;
2324
+ uiSelections.push("openai/deep-1");
2325
+ await command.handler("", makeCtx());
2326
+ if (readFileSync(effortPin, "utf8") !== "high\n") throw new Error("a cancelled effort step changed the standing effort pin");
2327
+ if (readFileSync(modelPin, "utf8") !== "openai/deep-1\n") throw new Error("a cancelled effort step discarded the model pick");
2328
+ const cancelNotices = notices.slice(cancelNoticeCount);
2329
+ if (cancelNotices.length !== 1 || cancelNotices[0].type !== "info" || !cancelNotices[0].message.includes("high")) {
2330
+ throw new Error(`a cancelled effort step must still report the standing effort: ${JSON.stringify(cancelNotices)}`);
2331
+ }
2332
+
2333
+ // Following main for effort clears the pin and reports main's own level.
2334
+ const followNoticeCount = notices.length;
2335
+ uiSelections.push("openai/deep-1", "Follow main (medium)");
2336
+ await command.handler("", makeCtx());
2337
+ if (existsSync(effortPin)) throw new Error("following main must remove the effort pin file");
2338
+ const followNotices = notices.slice(followNoticeCount);
2339
+ if (followNotices.length !== 1 || !followNotices[0].message.includes("Effort follows main (medium)")) {
2340
+ throw new Error(`following main for effort was not reported honestly: ${JSON.stringify(followNotices)}`);
2341
+ }
2342
+
2343
+ // Honest reporting when Pi's own clamp will lower a standing pin: "max" is
2344
+ // offered on deep-1, but moving the branch back to main-model, which tops out
2345
+ // at Pi's "high", must be reported as the level the branch will really run at
2346
+ // rather than as the raw pin - and the captain's raw pick is kept, so
2347
+ // returning to deep-1 restores it.
2348
+ uiSelections.push("openai/deep-1", "max");
2349
+ await command.handler("", makeCtx());
2350
+ if (readFileSync(effortPin, "utf8") !== "max\n") throw new Error("the max effort pick was not persisted");
2351
+ const clampNoticeCount = notices.length;
2352
+ uiSelections.push("Follow main (anthropic/main-model)");
2353
+ await command.handler("", makeCtx());
2354
+ const clampNotices = notices.slice(clampNoticeCount);
2355
+ if (clampNotices.length !== 1 || !clampNotices[0].message.includes("Effort: max, which this model runs at high.")) {
2356
+ throw new Error(`a clamped standing effort pin was not reported honestly: ${JSON.stringify(clampNotices)}`);
2357
+ }
2358
+ if (readFileSync(effortPin, "utf8") !== "max\n") throw new Error("reporting a clamp rewrote the captain's raw effort pick");
2359
+ dispatch("signal: clamped build");
2360
+ await settle(() => (globalThis.__fmSessions ?? []).length === 2, "clamped branch build");
2361
+ if (globalThis.__fmSessions[1].options.thinkingLevel !== "high") {
2362
+ throw new Error(`the clamped build did not run at the reported level: ${globalThis.__fmSessions[1].options.thinkingLevel}`);
2363
+ }
2364
+
2365
+ // When main's model cannot be resolved, the effort step derives Pi's level
2366
+ // set from the model recorded by the persistent branch conversation.
2367
+ uiSelections.push("openai/shallow-1", "max");
2368
+ await command.handler("", makeCtx());
2369
+ dispatch("signal: record shallow branch model");
2370
+ await settle(() => (globalThis.__fmSessions ?? []).length === 3, "shallow branch build");
2371
+ registryModels.find((model) => model.id === "main-model").branchAvailable = false;
2372
+ const restoredNoticeCount = notices.length;
2373
+ uiSelections.push("Follow main (anthropic/main-model)");
2374
+ await command.handler("", makeCtx());
2375
+ const restoredPicker = uiPrompts[uiPrompts.length - 1];
2376
+ if (JSON.stringify(restoredPicker.options.slice(1)) !== JSON.stringify(["off", "minimal", "low", "medium", "high"])) {
2377
+ throw new Error(`the unresolved-main picker did not use Pi's levels for the recorded branch model: ${JSON.stringify(restoredPicker.options)}`);
2378
+ }
2379
+ const restoredNotices = notices.slice(restoredNoticeCount);
2380
+ if (restoredNotices.length !== 1 || !restoredNotices[0].message.includes("Effort: max, which this model runs at high.")) {
2381
+ throw new Error(`the recorded branch model did not make clamp reporting honest: ${JSON.stringify(restoredNotices)}`);
2382
+ }
2383
+
2384
+ // If neither main nor the recorded branch model can be resolved, no invented
2385
+ // catalog is offered and the report says the applied level is unknown.
2386
+ registryModels.find((model) => model.id === "shallow-1").branchAvailable = false;
2387
+ const unknownNoticeCount = notices.length;
2388
+ uiSelections.push("Follow main (anthropic/main-model)");
2389
+ await command.handler("", makeCtx());
2390
+ const unknownPicker = uiPrompts[uiPrompts.length - 1];
2391
+ if (JSON.stringify(unknownPicker.options) !== JSON.stringify(["Follow main (medium)"])) {
2392
+ throw new Error(`the unknown-model picker invented effort levels: ${JSON.stringify(unknownPicker.options)}`);
2393
+ }
2394
+ const unknownNotices = notices.slice(unknownNoticeCount);
2395
+ if (unknownNotices.length !== 1 || !unknownNotices[0].message.includes("cannot be determined")) {
2396
+ throw new Error(`the unknown effective effort was reported as applied: ${JSON.stringify(unknownNotices)}`);
2397
+ }
2398
+ process.exit(0);
2399
+ EOF
2400
+ status=$?
2401
+ out=$(cat "$TMP_ROOT/node-output")
2402
+ expect_code 0 "$status" "the effort step must follow the model step and persist independently: $out"
2403
+ pass "supervision-model runs an effort picker after the model picker and persists both independently"
2404
+ }
2405
+
2406
+ test_unusable_model_pin_falls_back_to_main() {
2407
+ local repo home out status
2408
+ repo="$TMP_ROOT/modelbad-root"
2409
+ home="$TMP_ROOT/modelbad-home"
2410
+ mkdir -p "$home/state" "$home/config"
2411
+ install_pi_branch_extension_fixture "$repo"
2412
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
2413
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
2414
+ const prelude = process.env.DRIVER_PRELUDE;
2415
+ await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, makeCtx, registryModels, mainUserMessages, home }; })()`);
2416
+ const { fire, dispatch, settle, makeCtx, registryModels, mainUserMessages, home } = globalThis.__t;
2417
+ import { writeFileSync } from "node:fs";
2418
+
2419
+ registryModels.push(
2420
+ { provider: "anthropic", id: "main-model" },
2421
+ { provider: "dynamic", id: "extension-only", branchAvailable: false },
2422
+ );
2423
+
2424
+ // A pin the isolated branch runtime cannot hand back is never a silent
2425
+ // downgrade onto main's model, even when main's session knows that model.
2426
+ writeFileSync(`${home}/config/supervision-branch-model`, "dynamic/extension-only\n");
2427
+ fire("session_start", {}, makeCtx());
2428
+ dispatch("signal: unusable pin probe");
2429
+ await settle(() => mainUserMessages.length === 1, "fallback to main");
2430
+ const delivered = mainUserMessages[0].content;
2431
+ if (!delivered.includes("dynamic/extension-only") || !delivered.includes("supervision model pin")) {
2432
+ throw new Error(`the fallback did not name the unusable pin: ${delivered}`);
2433
+ }
2434
+ if ((globalThis.__fmSessions ?? []).length !== 0) throw new Error("an unusable pin must not build a branch session");
2435
+
2436
+ // An unparseable file is simply no pin, so supervision keeps working and the
2437
+ // branch follows main's own model.
2438
+ writeFileSync(`${home}/config/supervision-branch-model`, "not-a-model-reference\n");
2439
+ fire("session_shutdown", {});
2440
+ fire("session_start", {}, makeCtx());
2441
+ dispatch("signal: unparseable pin probe");
2442
+ await settle(() => (globalThis.__fmSessions ?? []).length === 1, "unparseable-pin branch build");
2443
+ const unparseable = globalThis.__fmSessions[0].options.model;
2444
+ if (unparseable?.provider !== "anthropic" || unparseable?.id !== "main-model") {
2445
+ throw new Error(`an unparseable pin must be treated as no pin and follow main: ${JSON.stringify(unparseable)}`);
2446
+ }
2447
+ process.exit(0);
2448
+ EOF
2449
+ status=$?
2450
+ out=$(cat "$TMP_ROOT/node-output")
2451
+ expect_code 0 "$status" "an unusable model pin must fall back to main and an unparseable one must be no pin: $out"
2452
+ pass "an unusable model pin falls back to main and an unparseable one is treated as no pin"
2453
+ }
2454
+
2455
+ test_replacement_activation_cleans_leases_and_retries_failure() {
2456
+ local repo home fakebin out status real_bash
2457
+ repo="$TMP_ROOT/activation-root"
2458
+ home="$TMP_ROOT/activation-home"
2459
+ fakebin="$home/fakebin"
2460
+ real_bash=$(command -v bash)
2461
+ mkdir -p "$home/state" "$home/config" "$fakebin"
2462
+ install_pi_branch_extension_fixture "$repo"
2463
+ cat > "$fakebin/bash" <<'SH'
2464
+ #!/bin/sh
2465
+ if [ "$1" = "$FM_TEST_LEASE_SCRIPT" ] && [ ! -e "$FM_TEST_FAIL_MARKER" ]; then
2466
+ : > "$FM_TEST_FAIL_MARKER"
2467
+ exit 7
2468
+ fi
2469
+ exec "$FM_TEST_REAL_BASH" "$@"
2470
+ SH
2471
+ chmod +x "$fakebin/bash"
2472
+ PATH="$fakebin:$PATH" PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
2473
+ FM_TEST_REAL_BASH="$real_bash" FM_TEST_LEASE_SCRIPT="$ROOT/bin/fm-lease.sh" \
2474
+ FM_TEST_FAIL_MARKER="$home/state/release-failed-once" DRIVER_PRELUDE="$DRIVER_PRELUDE" \
2475
+ node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
2476
+ const prelude = process.env.DRIVER_PRELUDE;
2477
+ await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, home, realRoot }; })()`);
2478
+ const { fire, dispatch, settle, home } = globalThis.__t;
2479
+ import { existsSync, writeFileSync } from "node:fs";
2480
+
2481
+ writeFileSync(`${home}/state/.lease-task-old`, `branch\t${process.pid}\t123\n`);
2482
+
2483
+ fire("session_start", {});
2484
+ if (!existsSync(`${home}/state/.lease-task-old`)) throw new Error("failed activation incorrectly committed lease cleanup");
2485
+ const offer = dispatch("signal: retry activation");
2486
+ if (!offer.accepted) throw new Error("later boundary did not retry failed activation");
2487
+ if (existsSync(`${home}/state/.lease-task-old`)) throw new Error("replacement activation did not clean the prior branch lease");
2488
+ await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "post-retry wake prompt");
2489
+ process.exit(0);
2490
+ EOF
2491
+ status=$?
2492
+ out=$(cat "$TMP_ROOT/node-output")
2493
+ expect_code 0 "$status" "replacement activation must clean leases and retry failures: $out"
2494
+ pass "replacement activation cleans old branch leases and retries failed cleanup"
2495
+ }
2496
+
2497
+ test_cold_start_activates_after_lock_acquisition() {
2498
+ local repo home out status
2499
+ repo="$TMP_ROOT/coldstart-root"
2500
+ home="$TMP_ROOT/coldstart-home"
2501
+ mkdir -p "$home/state" "$home/config"
2502
+ install_pi_branch_extension_fixture "$repo"
2503
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
2504
+ FM_TEST_SKIP_LOCK=1 DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
2505
+ const prelude = process.env.DRIVER_PRELUDE;
2506
+ await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, settle, home }; })()`);
2507
+ const { dispatch, settle, home } = globalThis.__t;
2508
+ import { existsSync, writeFileSync } from "node:fs";
2509
+
2510
+ // An ordinary cold Pi start: session_start fires BEFORE the session acquires
2511
+ // the fleet lock (fm-sessionstart-run.sh acquires it later). Ownership must
2512
+ // be evaluated lazily per action, never latched at session_start.
2513
+ if (dispatch("signal: before lock").accepted) throw new Error("branch accepted a wake before the lock existed");
2514
+ if (existsSync(`${home}/state/.pi-branch-extension-loaded`)) {
2515
+ throw new Error("branch wrote its marker before owning the lock");
2516
+ }
2517
+ writeFileSync(`${home}/state/.lock`, `${process.pid}\n`);
2518
+ if (!dispatch("signal: after lock").accepted) throw new Error("branch refused a wake after the lock was acquired");
2519
+ await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "post-lock branch wake prompt");
2520
+ if (!existsSync(`${home}/state/.pi-branch-extension-loaded`)) {
2521
+ throw new Error("owned activation did not write the diagnostic marker");
2522
+ }
2523
+ process.exit(0);
2524
+ EOF
2525
+ status=$?
2526
+ out=$(cat "$TMP_ROOT/node-output")
2527
+ expect_code 0 "$status" "cold-start lazy lock-ownership activation must hold: $out"
2528
+ pass "branch activates on a cold start once the lock is acquired, never before"
2529
+ }
2530
+
2531
+ test_queued_actions_recheck_lock_ownership() {
2532
+ local repo home out status
2533
+ repo="$TMP_ROOT/queued-ownership-root"
2534
+ home="$TMP_ROOT/queued-ownership-home"
2535
+ mkdir -p "$home/state" "$home/config"
2536
+ install_pi_branch_extension_fixture "$repo"
2537
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
2538
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
2539
+ const prelude = process.env.DRIVER_PRELUDE;
2540
+ await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, home, mainUserMessages }; })()`);
2541
+ const { fire, dispatch, settle, home, mainUserMessages } = globalThis.__t;
2542
+ import { existsSync, unlinkSync } from "node:fs";
2543
+
2544
+ let releasePrompt;
2545
+ globalThis.__fmPromptGate = new Promise((resolve) => { releasePrompt = resolve; });
2546
+ if (!dispatch("signal: active wake").accepted) throw new Error("first wake was not accepted");
2547
+ await settle(() => globalThis.__fmPromptStarted === true, "blocked first prompt");
2548
+ if (!dispatch("signal: queued wake").accepted) throw new Error("queued wake was not accepted");
2549
+ const entries = [{ type: "message", message: { role: "user", content: "queued mirror must stay undelivered" } }];
2550
+ fire("turn_end", {}, {
2551
+ sessionManager: { getSessionFile: () => `${home}/main.jsonl`, getEntries: () => entries },
2552
+ });
2553
+ unlinkSync(`${home}/state/.lock`);
2554
+ releasePrompt();
2555
+ await settle(() => mainUserMessages.length === 1, "lost-ownership fallback");
2556
+ if (!mainUserMessages[0].content.includes("FIRSTMATE WATCHER WAKE: signal: queued wake")) {
2557
+ throw new Error(`queued wake did not fall back to main: ${mainUserMessages[0].content}`);
2558
+ }
2559
+ await new Promise((resolve) => setTimeout(resolve, 25));
2560
+ const session = globalThis.__fmSessions[0];
2561
+ if (session.ops.some((op) => op.kind === "custom")) throw new Error("queued mirror appended after lock ownership was lost");
2562
+ if (existsSync(`${home}/state/.branch-mirror-cursor`)) throw new Error("queued mirror advanced its cursor after lock ownership was lost");
2563
+ process.exit(0);
2564
+ EOF
2565
+ status=$?
2566
+ out=$(cat "$TMP_ROOT/node-output")
2567
+ expect_code 0 "$status" "queued branch actions must recheck lock ownership: $out"
2568
+ pass "queued wakes and mirrors stop mutating branch state after lock ownership is lost"
2569
+ }
2570
+
2571
+ test_stale_generation_boundaries_are_side_effect_free() {
2572
+ local repo home out status
2573
+ repo="$TMP_ROOT/stale-boundaries-root"
2574
+ home="$TMP_ROOT/stale-boundaries-home"
2575
+ mkdir -p "$home/state" "$home/config"
2576
+ install_pi_branch_extension_fixture "$repo"
2577
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
2578
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
2579
+ const prelude = process.env.DRIVER_PRELUDE;
2580
+ await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, home, sentToMain }; })()`);
2581
+ const { fire, dispatch, settle, home, sentToMain } = globalThis.__t;
2582
+ import { existsSync, readFileSync } from "node:fs";
2583
+
2584
+ if (!dispatch("signal: establish old branch").accepted) throw new Error("old branch wake was not accepted");
2585
+ await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "old branch prompt");
2586
+ const oldSession = globalThis.__fmSessions[0];
2587
+ const oldReport = oldSession.options.customTools.find((tool) => tool.name === "fm_branch_report");
2588
+ const oldBash = oldSession.options.customTools.find((tool) => tool.name === "bash");
2589
+
2590
+ let releaseMirror;
2591
+ globalThis.__fmMirrorGate = new Promise((resolve) => { releaseMirror = resolve; });
2592
+ const oldEntries = [{ type: "message", message: { role: "user", content: "old generation mirror" } }];
2593
+ fire("turn_end", {}, {
2594
+ sessionManager: { getSessionFile: () => `${home}/old-main.jsonl`, getEntries: () => oldEntries },
2595
+ });
2596
+ await settle(() => globalThis.__fmMirrorStarted === true, "blocked old mirror delivery");
2597
+ fire("session_shutdown", {});
2598
+ fire("session_start", {});
2599
+ const newEntries = [{ type: "message", message: { role: "user", content: "new generation mirror" } }];
2600
+ fire("turn_end", {}, {
2601
+ sessionManager: { getSessionFile: () => `${home}/new-main.jsonl`, getEntries: () => newEntries },
2602
+ });
2603
+
2604
+ const reportResult = await oldReport.execute(
2605
+ "stale-report",
2606
+ { task: "task-stale", verdict: "captain", summary: "must not append or merge" },
2607
+ undefined,
2608
+ undefined,
2609
+ {},
2610
+ );
2611
+ if (!reportResult.isError) throw new Error("stale report tool was not refused");
2612
+ let bashRefused = false;
2613
+ try {
2614
+ oldBash.__options.spawnHook({
2615
+ command: "bin/fm-lease.sh claim task-stale --actor branch",
2616
+ cwd: home,
2617
+ env: {},
2618
+ });
2619
+ } catch {
2620
+ bashRefused = true;
2621
+ }
2622
+ if (!bashRefused) throw new Error("stale bash tool was not refused");
2623
+ if (existsSync(`${home}/state/branch-outcomes.jsonl`)) throw new Error("stale report appended an outcome");
2624
+ if (existsSync(`${home}/state/.lease-task-stale`)) throw new Error("stale bash claimed a lease");
2625
+ if (sentToMain.length !== 0) throw new Error("stale report merged a note into main");
2626
+
2627
+ releaseMirror();
2628
+ await new Promise((resolve) => setTimeout(resolve, 25));
2629
+ if (!dispatch("signal: establish replacement branch").accepted) throw new Error("replacement wake was not accepted");
2630
+ await settle(() => (globalThis.__fmPrompts ?? []).length === 2, "replacement branch prompt");
2631
+ await settle(
2632
+ () => (globalThis.__fmMirrors ?? []).some((message) => message.content === "[captain] new generation mirror"),
2633
+ "replacement mirror delivery",
2634
+ );
2635
+ const cursor = JSON.parse(readFileSync(`${home}/state/.branch-mirror-cursor`, "utf8"));
2636
+ if (cursor.file !== `${home}/new-main.jsonl` || cursor.index !== 1) {
2637
+ throw new Error(`stale mirror continuation changed the replacement cursor: ${JSON.stringify(cursor)}`);
2638
+ }
2639
+ process.exit(0);
2640
+ EOF
2641
+ status=$?
2642
+ out=$(cat "$TMP_ROOT/node-output")
2643
+ expect_code 0 "$status" "stale branch boundaries must perform no side effects: $out"
2644
+ pass "stale reports, shells, mirrors, cursors, leases, and prompts perform no side effects"
2645
+ }
2646
+
2647
+ test_secondary_session_stays_inert() {
2648
+ local repo home out status foreign_pid
2649
+ repo="$TMP_ROOT/secondary-root"
2650
+ home="$TMP_ROOT/secondary-home"
2651
+ mkdir -p "$home/state" "$home/config"
2652
+ install_pi_branch_extension_fixture "$repo"
2653
+ # The fleet lock is owned by ANOTHER live process that is NOT in the
2654
+ # driver's ancestry (a sibling sleeper), so the driver is a secondary
2655
+ # session: it must accept nothing, write no marker, and release no leases.
2656
+ sleep 60 &
2657
+ foreign_pid=$!
2658
+ printf 'branch\t%s\t123\n' "$foreign_pid" > "$home/state/.lease-task-x"
2659
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
2660
+ FM_TEST_SKIP_LOCK=1 FM_TEST_LOCK_PID=$foreign_pid DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
2661
+ const prelude = process.env.DRIVER_PRELUDE;
2662
+ await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, home }; })()`);
2663
+ const { dispatch, home } = globalThis.__t;
2664
+ import { existsSync, writeFileSync } from "node:fs";
2665
+ writeFileSync(`${home}/state/.lock`, `${process.env.FM_TEST_LOCK_PID}\n`);
2666
+ if (dispatch("signal: secondary probe").accepted) throw new Error("secondary session accepted a wake it does not own");
2667
+ if (existsSync(`${home}/state/.pi-branch-extension-loaded`)) {
2668
+ throw new Error("secondary session wrote the primary's marker");
2669
+ }
2670
+ if (!existsSync(`${home}/state/.lease-task-x`)) {
2671
+ throw new Error("secondary session released the primary's branch lease");
2672
+ }
2673
+ process.exit(0);
2674
+ EOF
2675
+ status=$?
2676
+ out=$(cat "$TMP_ROOT/node-output")
2677
+ kill "$foreign_pid" 2>/dev/null || true
2678
+ expect_code 0 "$status" "a secondary session must stay inert: $out"
2679
+ pass "a Pi session that does not own the lock accepts nothing and mutates no branch state"
2680
+ }
2681
+
2682
+ test_rebind_remirrors_undelivered_dialog_from_durable_cursor() {
2683
+ local repo home out status
2684
+ repo="$TMP_ROOT/rebind-root"
2685
+ home="$TMP_ROOT/rebind-home"
2686
+ mkdir -p "$home/state" "$home/config"
2687
+ install_pi_branch_extension_fixture "$repo"
2688
+ PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
2689
+ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
2690
+ const prelude = process.env.DRIVER_PRELUDE;
2691
+ await eval(`(async () => { ${prelude}; globalThis.__t = { fire, home }; })()`);
2692
+ const { fire, home } = globalThis.__t;
2693
+ import { writeFileSync } from "node:fs";
2694
+ import { pathToFileURL } from "node:url";
2695
+
2696
+ // Instance A collects dialog at turn_end while no branch exists yet (nothing
2697
+ // delivered, durable cursor unmoved), then the extension instance is replaced
2698
+ // (/new, /resume, reload). The replacement must reconstruct exclusively from
2699
+ // the durable cursor and re-mirror the undelivered dialog - never drop it.
2700
+ const entries = [
2701
+ { type: "message", message: { role: "user", content: "standing order: never merge task-7" } },
2702
+ ];
2703
+ const ctx = {
2704
+ sessionManager: { getSessionFile: () => `${home}/main-1.jsonl`, getEntries: () => entries },
2705
+ };
2706
+ fire("turn_end", {}, ctx);
2707
+ fire("session_shutdown", {});
2708
+
2709
+ // Replacement instance: fresh import simulates Pi rebinding the extension.
2710
+ const replacementHandlers = new Map();
2711
+ const replacementBus = {
2712
+ on(channel, handler) {
2713
+ replacementHandlers.set(channel, [...(replacementHandlers.get(channel) ?? []), handler]);
2714
+ return () => {};
2715
+ },
2716
+ emit(channel, data) {
2717
+ for (const handler of replacementHandlers.get(channel) ?? []) handler(data);
2718
+ },
2719
+ };
2720
+ const replacementPiHandlers = new Map();
2721
+ const replacementPi = {
2722
+ events: replacementBus,
2723
+ on(event, handler) {
2724
+ replacementPiHandlers.set(event, [...(replacementPiHandlers.get(event) ?? []), handler]);
2725
+ },
2726
+ registerTool() {},
2727
+ registerCommand() {},
2728
+ registerMessageRenderer() {},
2729
+ sendMessage() {},
2730
+ sendUserMessage() {},
2731
+ };
2732
+ const replacement = await import(`${pathToFileURL(process.env.PLUGIN).href}?rebind=1`);
2733
+ replacement.default(replacementPi);
2734
+ for (const handler of replacementPiHandlers.get("session_start") ?? []) handler({}, ctx);
2735
+ for (const handler of replacementPiHandlers.get("turn_end") ?? []) handler({}, ctx);
2736
+ writeFileSync(`${home}/state/.wake-queue`, "1\t1\tsignal\tbranch-driver.status\tsignal: after rebind\n");
2737
+ const offer = {
2738
+ message: "signal: after rebind",
2739
+ projects: [`${home}/projects/approved`],
2740
+ heartbeat: false,
2741
+ eligible: true,
2742
+ accepted: false,
2743
+ accept() {
2744
+ offer.accepted = true;
2745
+ },
2746
+ };
2747
+ replacementBus.emit("fm-branch-supervision:dispatch", offer);
2748
+ if (!offer.accepted) throw new Error("replacement instance refused the wake");
2749
+ for (let i = 0; i < 250; i += 1) {
2750
+ const mirrors = (globalThis.__fmMirrors ?? []).map((m) => m.content);
2751
+ if (mirrors.includes("[captain] standing order: never merge task-7")) break;
2752
+ await new Promise((resolve) => setTimeout(resolve, 10));
2753
+ }
2754
+ const mirrors = (globalThis.__fmMirrors ?? []).map((m) => m.content);
2755
+ if (!mirrors.includes("[captain] standing order: never merge task-7")) {
2756
+ throw new Error(`replacement dropped undelivered dialog: ${JSON.stringify(mirrors)}`);
2757
+ }
2758
+ process.exit(0);
2759
+ EOF
2760
+ status=$?
2761
+ out=$(cat "$TMP_ROOT/node-output")
2762
+ expect_code 0 "$status" "rebind must re-mirror undelivered dialog from the durable cursor: $out"
2763
+ pass "an extension rebind re-mirrors undelivered dialog instead of dropping it"
2764
+ }
2765
+
2766
+ # Direct unit coverage of fm-branch-dispatch.ts's classification, independent
2767
+ # of the Pi SDK stub: every legitimately main-only class (docs/pi-supervision-
2768
+ # branch.md) stays excluded from eligibleSeqs no matter its check-kind key,
2769
+ # a mixed queue keeps its task-local rows eligible without those main-only
2770
+ # rows vetoing the scan, and the eligible-row snapshot writer names exactly
2771
+ # the eligible set.
2772
+ test_branch_dispatch_classifies_main_only_rows_and_writes_the_eligible_snapshot() {
2773
+ local repo home out status
2774
+ repo="$TMP_ROOT/dispatch-classify-root"
2775
+ home="$TMP_ROOT/dispatch-classify-home"
2776
+ mkdir -p "$repo/.pi/extensions/lib" "$home/state" "$home/projects/approved"
2777
+ cp "$ROOT/.pi/extensions/lib/fm-branch-dispatch.ts" "$repo/.pi/extensions/lib/fm-branch-dispatch.ts"
2778
+ cp "$ROOT/.pi/extensions/lib/fm-branch-model-picker.ts" "$repo/.pi/extensions/lib/fm-branch-model-picker.ts"
2779
+ printf 'project=%s/projects/approved\nwindow=fm-window\n' "$home" > "$home/state/task-a.meta"
2780
+ LIB="$repo/.pi/extensions/lib/fm-branch-dispatch.ts" FM_HOME="$home" GRANT="$ROOT/bin/fm-wake-grant.sh" \
2781
+ node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
2782
+ import { pathToFileURL } from "node:url";
2783
+ import { readFileSync, writeFileSync } from "node:fs";
2784
+
2785
+ const { activateEligibleRowsOwner, scopeForUnreadWake, writeEligibleRowsSnapshot, releaseEligibleRowsSnapshot, BRANCH_ELIGIBLE_ROWS_FILE } =
2786
+ await import(pathToFileURL(process.env.LIB).href);
2787
+ const state = `${process.env.FM_HOME}/state`;
2788
+ const project = `${process.env.FM_HOME}/projects/approved`;
2789
+
2790
+ // Every legitimately main-only class is a check-kind row under a different
2791
+ // key; classification never looks at the key, only the kind, so one
2792
+ // representative per named class is sufficient coverage.
2793
+ const mainOnlyRows = [
2794
+ "1\t1\tcheck\tx-inbox\tcheck: pending x mention",
2795
+ "1\t1\tcheck\tsome-poll.check.sh\tcheck: some-poll.check.sh: merged",
2796
+ "1\t1\tcheck\tunauthenticated-state-checks\tcheck: rejected unauthenticated state checks",
2797
+ ];
2798
+ for (const row of mainOnlyRows) {
2799
+ writeFileSync(`${state}/.wake-queue`, row);
2800
+ const scope = scopeForUnreadWake(state, false);
2801
+ if (scope.eligible || scope.eligibleSeqs.length !== 0) {
2802
+ throw new Error(`a main-only class was offered to the branch: ${row} -> ${JSON.stringify(scope)}`);
2803
+ }
2804
+ if (scope.corrupted) throw new Error(`an ordinary main-only row must not read as corrupted: ${row}`);
2805
+ }
2806
+
2807
+ writeFileSync(
2808
+ `${state}/.wake-queue`,
2809
+ [
2810
+ "1\t1\tcheck\tx-inbox",
2811
+ "1\t2\tsignal\ttask-a.status\tsignal: task-a.status",
2812
+ ].join("\n"),
2813
+ );
2814
+ const truncated = scopeForUnreadWake(state, false);
2815
+ if (!truncated.corrupted || truncated.eligible || truncated.eligibleSeqs.length !== 0) {
2816
+ throw new Error(`a four-field queue row was not classified as corruption: ${JSON.stringify(truncated)}`);
2817
+ }
2818
+
2819
+ // A mixed queue: the main-only row (seq 1) never vetoes the task-local rows
2820
+ // (seq 2, 3) - the reproduction from the task.
2821
+ writeFileSync(
2822
+ `${state}/.wake-queue`,
2823
+ [
2824
+ "1\t1\tcheck\tx-inbox\tcheck: pending x mention",
2825
+ "1\t2\tsignal\ttask-a.status\tsignal: task-a.status",
2826
+ "1\t3\tstale\tfm-window\tstale: fm-window",
2827
+ ].join("\n"),
2828
+ );
2829
+ const mixed = scopeForUnreadWake(state, false);
2830
+ if (!mixed.eligible) throw new Error(`mixed queue with eligible task-local rows must stay eligible: ${JSON.stringify(mixed)}`);
2831
+ if (mixed.eligibleSeqs.slice().sort().join(",") !== "2,3") {
2832
+ throw new Error(`eligibleSeqs must name exactly the task-local rows: ${JSON.stringify(mixed)}`);
2833
+ }
2834
+ if (!mixed.projects.includes(project)) {
2835
+ throw new Error(`eligible project context lost: ${JSON.stringify(mixed.projects)}`);
2836
+ }
2837
+
2838
+ if (!activateEligibleRowsOwner(state, process.env.GRANT, process.pid, "fixture")) {
2839
+ throw new Error("branch owner activation failed");
2840
+ }
2841
+ if (writeEligibleRowsSnapshot(state, mixed.eligibleSeqs, process.env.GRANT, "fixture") !== "published") {
2842
+ throw new Error("snapshot write reported failure");
2843
+ }
2844
+ const snapshot = readFileSync(`${state}/${BRANCH_ELIGIBLE_ROWS_FILE}`, "utf8").trim().split("\n");
2845
+ if (snapshot.join(",") !== "2,3") throw new Error(`snapshot did not name exactly the eligible rows: ${snapshot}`);
2846
+
2847
+ // An empty eligible set is refused rather than clearing the snapshot to
2848
+ // nothing - a caller must never overwrite a live snapshot with an empty one.
2849
+ if (writeEligibleRowsSnapshot(state, [], process.env.GRANT, "fixture") !== "error") {
2850
+ throw new Error("an empty eligible set must not be written");
2851
+ }
2852
+ if (!releaseEligibleRowsSnapshot(state, process.env.GRANT, "fixture")) throw new Error("snapshot release failed");
2853
+ writeFileSync(`${state}/.main-eligible-rows`, "2\n");
2854
+ if (writeEligibleRowsSnapshot(state, ["2"], process.env.GRANT, "fixture") !== "main-owned") {
2855
+ throw new Error("a row already claimed by main was not reported as main-owned");
2856
+ }
2857
+
2858
+ // A heartbeat is not vetoed or ridden into main by a co-present check row.
2859
+ // The check row is permanently main-owned in every mode, so it is excluded
2860
+ // from the claim rather than deferring a fleet review that has nothing to do
2861
+ // with it - the captain's reproduction.
2862
+ writeFileSync(
2863
+ `${state}/.wake-queue`,
2864
+ [
2865
+ "1\t1\tcheck\tx-inbox\tcheck: pending x mention",
2866
+ "1\t2\theartbeat\theartbeat\theartbeat",
2867
+ "1\t3\tsignal\ttask-a.status\tsignal: task-a.status",
2868
+ ].join("\n"),
2869
+ );
2870
+ const heartbeatMixed = scopeForUnreadWake(state, true);
2871
+ if (!heartbeatMixed.eligible || heartbeatMixed.corrupted) {
2872
+ throw new Error(`a co-present check row rode a heartbeat into main: ${JSON.stringify(heartbeatMixed)}`);
2873
+ }
2874
+ if (heartbeatMixed.eligibleSeqs.slice().sort().join(",") !== "2,3") {
2875
+ throw new Error(`a heartbeat claim must cover every branch-ownable row and no check row: ${JSON.stringify(heartbeatMixed)}`);
2876
+ }
2877
+
2878
+ // All-or-nothing is unchanged in what it actually guarantees: a heartbeat
2879
+ // review takes every branch-ownable row or none of them, so a row this scan
2880
+ // cannot resolve still defers the whole review to main.
2881
+ writeFileSync(
2882
+ `${state}/.wake-queue`,
2883
+ [
2884
+ "1\t1\theartbeat\theartbeat\theartbeat",
2885
+ "1\t2\tsignal\tno-such-task.status\tsignal: no-such-task.status",
2886
+ ].join("\n"),
2887
+ );
2888
+ const heartbeatUnresolvable = scopeForUnreadWake(state, true);
2889
+ if (heartbeatUnresolvable.eligible || !heartbeatUnresolvable.corrupted) {
2890
+ throw new Error(`an unresolvable row must still defer a heartbeat review: ${JSON.stringify(heartbeatUnresolvable)}`);
2891
+ }
2892
+ writeFileSync(
2893
+ `${state}/.wake-queue`,
2894
+ [
2895
+ "1\t1\theartbeat\theartbeat\theartbeat",
2896
+ "1\t2\tinvented\tsomething\tinvented: not a kind fm_wake_append emits",
2897
+ ].join("\n"),
2898
+ );
2899
+ const heartbeatUnknownKind = scopeForUnreadWake(state, true);
2900
+ if (heartbeatUnknownKind.eligible || !heartbeatUnknownKind.corrupted) {
2901
+ throw new Error(`an unknown row kind must still defer a heartbeat review: ${JSON.stringify(heartbeatUnknownKind)}`);
2902
+ }
2903
+
2904
+ // A queue holding nothing but main-only rows leaves a heartbeat with nothing
2905
+ // to hand over, so it is not offered rather than granted an empty claim.
2906
+ writeFileSync(`${state}/.wake-queue`, "1\t1\tcheck\tx-inbox\tcheck: pending x mention");
2907
+ const heartbeatNothingOwnable = scopeForUnreadWake(state, true);
2908
+ if (heartbeatNothingOwnable.eligible || heartbeatNothingOwnable.eligibleSeqs.length !== 0) {
2909
+ throw new Error(`a heartbeat was offered with no branch-ownable row: ${JSON.stringify(heartbeatNothingOwnable)}`);
2910
+ }
2911
+ if (heartbeatNothingOwnable.corrupted) {
2912
+ throw new Error(`a purely main-only queue is ordinary absence, not a fault: ${JSON.stringify(heartbeatNothingOwnable)}`);
2913
+ }
2914
+ process.exit(0);
2915
+ EOF
2916
+ status=$?
2917
+ out=$(cat "$TMP_ROOT/node-output")
2918
+ expect_code 0 "$status" "main-only classification and eligible-row snapshot contract must hold: $out"
2919
+ pass "scopeForUnreadWake excludes every main-only class without vetoing eligible task-local rows, and writes the eligible snapshot"
2920
+ }
2921
+
2922
+ # The model picker's bounded scrolling and its search ranking are Pi's own
2923
+ # SelectList and fuzzyFilter, so the guarantee only holds while the installed
2924
+ # Pi still exports them and still bounds what it renders. Stubs cannot answer
2925
+ # that, so this runs against the real package and skips when it is absent.
2926
+ test_real_pi_picker_primitives_stay_bounded_and_searchable() {
2927
+ if ! command -v node >/dev/null 2>&1; then
2928
+ echo "skip: node not found for the Pi picker primitives test"
2929
+ return
2930
+ fi
2931
+ local package_dir fixture original_dir out status
2932
+ package_dir=${FM_PI_PACKAGE_DIR:-"$(npm root -g 2>/dev/null)/@earendil-works/pi-coding-agent"}
2933
+ if [ ! -f "$package_dir/package.json" ]; then
2934
+ echo "skip: installed @earendil-works/pi-coding-agent package not found"
2935
+ return
2936
+ fi
2937
+ fixture="$TMP_ROOT/real-picker-primitives"
2938
+ mkdir -p "$fixture/lib" "$fixture/node_modules/@earendil-works"
2939
+ cp "$ROOT/.pi/extensions/lib/fm-branch-model-picker.ts" "$fixture/lib/fm-branch-model-picker.ts"
2940
+ ln -s "$package_dir" "$fixture/node_modules/@earendil-works/pi-coding-agent"
2941
+ ln -s "$package_dir/node_modules/@earendil-works/pi-tui" "$fixture/node_modules/@earendil-works/pi-tui"
2942
+ original_dir=$PWD
2943
+ cd "$fixture" || fail "could not enter the Pi picker primitives fixture"
2944
+ LIB="$fixture/lib/fm-branch-model-picker.ts" PI_VERSION_FILE="$package_dir/package.json" \
2945
+ node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'JS'
2946
+ import { pathToFileURL } from "node:url";
2947
+ import { readFileSync } from "node:fs";
2948
+
2949
+ const version = JSON.parse(readFileSync(process.env.PI_VERSION_FILE, "utf8")).version;
2950
+ const { Input, SelectList, fuzzyFilter } = await import("@earendil-works/pi-tui");
2951
+ const { DynamicBorder } = await import("@earendil-works/pi-coding-agent");
2952
+ for (const [name, value] of [
2953
+ ["Input", Input],
2954
+ ["SelectList", SelectList],
2955
+ ["fuzzyFilter", fuzzyFilter],
2956
+ ["DynamicBorder", DynamicBorder],
2957
+ ]) {
2958
+ if (typeof value !== "function") {
2959
+ throw new Error(`installed pi ${version} no longer exports ${name}, which the supervision model picker renders through`);
2960
+ }
2961
+ }
2962
+
2963
+ const { buildBranchModelItems, filterBranchPickerItems, BRANCH_PICKER_MAX_VISIBLE } = await import(
2964
+ pathToFileURL(process.env.LIB).href
2965
+ );
2966
+ const labels = Array.from({ length: 40 }, (_, i) => `anthropic/bulk-model-${i + 1}`);
2967
+ labels.push("openai-codex/cheap-oauth");
2968
+ const items = buildBranchModelItems("Follow main (anthropic/main-model)", labels, null);
2969
+
2970
+ // Pi's own list renders a bounded window plus at most one scroll indicator,
2971
+ // which is what keeps a long catalog inside the dialog.
2972
+ const passthrough = (text) => text;
2973
+ const list = new SelectList(items, BRANCH_PICKER_MAX_VISIBLE, {
2974
+ selectedPrefix: passthrough,
2975
+ selectedText: passthrough,
2976
+ description: passthrough,
2977
+ scrollInfo: passthrough,
2978
+ noMatch: passthrough,
2979
+ });
2980
+ const lines = list.render(80);
2981
+ if (lines.length > BRANCH_PICKER_MAX_VISIBLE + 1) {
2982
+ throw new Error(`installed pi ${version} rendered ${lines.length} rows for ${items.length} models instead of a bounded window`);
2983
+ }
2984
+ if (!lines[0].includes("Follow main")) {
2985
+ throw new Error(`installed pi ${version} did not render the first row the picker opens on`);
2986
+ }
2987
+
2988
+ // Pi's own fuzzy ranking drives the search box, and following main stays first.
2989
+ const searched = filterBranchPickerItems(items, "cheap", fuzzyFilter);
2990
+ if (searched.length !== 1 || searched[0].value !== "openai-codex/cheap-oauth") {
2991
+ throw new Error(`installed pi ${version} fuzzy search did not narrow the catalog: ${JSON.stringify(searched)}`);
2992
+ }
2993
+ const mainSearch = filterBranchPickerItems(items, "main", fuzzyFilter);
2994
+ if (mainSearch.length === 0 || mainSearch[0].label !== "Follow main (anthropic/main-model)") {
2995
+ throw new Error(`installed pi ${version} fuzzy ranking moved following main out of first place`);
2996
+ }
2997
+
2998
+ // The search box is Pi's own single-line input.
2999
+ const input = new Input();
3000
+ input.handleInput("c");
3001
+ input.handleInput("h");
3002
+ if (input.getValue() !== "ch") {
3003
+ throw new Error(`installed pi ${version} Input no longer accumulates typed characters for the picker's search box`);
3004
+ }
3005
+ JS
3006
+ status=$?
3007
+ cd "$original_dir" || fail "could not leave the Pi picker primitives fixture"
3008
+ out=$(cat "$TMP_ROOT/node-output")
3009
+ expect_code 0 "$status" "the installed Pi must still provide the picker's bounded searchable primitives: $out"
3010
+ [ -z "$out" ] || fail "Pi picker primitives test printed output: $out"
3011
+ pass "the installed Pi still bounds the picker's list and ranks its search"
3012
+ }
3013
+
3014
+ test_outcomes_tool_uses_stock_execution_and_export_consumers() {
3015
+ if ! command -v node >/dev/null 2>&1; then
3016
+ echo "skip: node not found for Pi outcomes rendering test"
3017
+ return
3018
+ fi
3019
+ local package_dir fixture out status
3020
+ package_dir=${FM_PI_PACKAGE_DIR:-"$(npm root -g 2>/dev/null)/@earendil-works/pi-coding-agent"}
3021
+ if [ ! -f "$package_dir/package.json" ]; then
3022
+ echo "skip: installed @earendil-works/pi-coding-agent package not found"
3023
+ return
3024
+ fi
3025
+ fixture="$TMP_ROOT/stock-render-consumers"
3026
+ mkdir -p "$fixture/.pi/extensions/lib" "$fixture/node_modules/@earendil-works"
3027
+ cp "$EXT" "$fixture/.pi/extensions/fm-branch-supervision.ts"
3028
+ cp "$ROOT/.pi/extensions/lib/fm-branch-dispatch.ts" "$fixture/.pi/extensions/lib/fm-branch-dispatch.ts"
3029
+ cp "$ROOT/.pi/extensions/lib/fm-branch-model-picker.ts" "$fixture/.pi/extensions/lib/fm-branch-model-picker.ts"
3030
+ cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$fixture/.pi/extensions/lib/fm-calm-visibility.ts"
3031
+ cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$fixture/.pi/extensions/lib/fm-operational-input.ts"
3032
+ ln -s "$package_dir" "$fixture/node_modules/@earendil-works/pi-coding-agent"
3033
+ ln -s "$package_dir/node_modules/@earendil-works/pi-tui" "$fixture/node_modules/@earendil-works/pi-tui"
3034
+ ln -s "$package_dir/node_modules/@earendil-works/pi-ai" "$fixture/node_modules/@earendil-works/pi-ai"
3035
+ ln -s "$package_dir/node_modules/typebox" "$fixture/node_modules/typebox"
3036
+
3037
+ out=$(cd "$fixture" && EXT="$fixture/.pi/extensions/fm-branch-supervision.ts" PI_PACKAGE_DIR="$package_dir" node --input-type=module 2>&1 <<'JS'
3038
+ import { pathToFileURL } from "node:url";
3039
+
3040
+ const packageRoot = process.env.PI_PACKAGE_DIR;
3041
+ const [{ ToolExecutionComponent }, { createToolHtmlRenderer }, { initTheme, theme }] = await Promise.all([
3042
+ import(pathToFileURL(`${packageRoot}/dist/modes/interactive/components/tool-execution.js`).href),
3043
+ import(pathToFileURL(`${packageRoot}/dist/core/export-html/tool-renderer.js`).href),
3044
+ import(pathToFileURL(`${packageRoot}/dist/modes/interactive/theme/theme.js`).href),
3045
+ ]);
3046
+ initTheme("dark");
3047
+
3048
+ const listeners = new Map();
3049
+ const tools = [];
3050
+ const pi = {
3051
+ events: {
3052
+ on(name, listener) {
3053
+ listeners.set(name, [...(listeners.get(name) ?? []), listener]);
3054
+ },
3055
+ emit(name, data) {
3056
+ for (const listener of listeners.get(name) ?? []) listener(data);
3057
+ },
3058
+ },
3059
+ on() {},
3060
+ registerCommand() {},
3061
+ registerMessageRenderer() {},
3062
+ registerTool(tool) { tools.push(tool); },
3063
+ sendMessage() {},
3064
+ sendUserMessage() {},
3065
+ };
3066
+ const extension = await import(`${pathToFileURL(process.env.EXT).href}?consumer=${Date.now()}`);
3067
+ extension.default(pi);
3068
+ const actualDefinition = tools.find((tool) => tool.name === "fm_branch_outcomes");
3069
+ if (!actualDefinition) throw new Error("fm_branch_outcomes was not registered");
3070
+ const stockDefinition = { ...actualDefinition };
3071
+ delete stockDefinition.renderShell;
3072
+ delete stockDefinition.renderCall;
3073
+ delete stockDefinition.renderResult;
3074
+
3075
+ const args = { recent: 2 };
3076
+ const result = {
3077
+ content: [{
3078
+ type: "text",
3079
+ text: [
3080
+ "\x1b[31mOUTCOME_ONE\x1b[0m",
3081
+ "OUT\u0000COME_TWO\uFFF9",
3082
+ "OUTCOME_THREE",
3083
+ "OUTCOME_FOUR",
3084
+ "OUTCOME_FIVE",
3085
+ "OUTCOME_SIX",
3086
+ "OUTCOME_SEVEN",
3087
+ "OUTCOME_EIGHT",
3088
+ "OUTCOME_NINE",
3089
+ "OUTCOME_TEN",
3090
+ "OUTCOME_ELEVEN",
3091
+ "OUTCOME_TWELVE",
3092
+ ].join("\r\n"),
3093
+ }],
3094
+ details: { ok: true },
3095
+ isError: false,
3096
+ };
3097
+ const ui = { requestRender() {} };
3098
+ const stockRow = new ToolExecutionComponent("fm_branch_outcomes", "stock", args, { showImages: false }, stockDefinition, ui, process.cwd());
3099
+ const actualRow = new ToolExecutionComponent("fm_branch_outcomes", "actual", args, { showImages: false }, actualDefinition, ui, process.cwd());
3100
+ for (const row of [stockRow, actualRow]) {
3101
+ row.markExecutionStarted();
3102
+ row.setArgsComplete();
3103
+ row.updateResult(result);
3104
+ }
3105
+ const collapsedStock = stockRow.render(100);
3106
+ const collapsedActual = actualRow.render(100);
3107
+ if (JSON.stringify(collapsedActual) !== JSON.stringify(collapsedStock)) {
3108
+ throw new Error("Calm-off ToolExecutionComponent rendering differs from Pi stock");
3109
+ }
3110
+ const collapsedText = collapsedStock.join("\n");
3111
+ if (collapsedText.includes("OUTCOME_TWELVE") || !collapsedText.includes("more lines") || !collapsedText.includes("to expand")) {
3112
+ throw new Error("stock rendering fixture did not exercise its collapsed preview and expansion hint");
3113
+ }
3114
+ stockRow.setExpanded(true);
3115
+ actualRow.setExpanded(true);
3116
+ const expandedStock = stockRow.render(100);
3117
+ const expandedActual = actualRow.render(100);
3118
+ if (JSON.stringify(expandedActual) !== JSON.stringify(expandedStock)) {
3119
+ throw new Error("expanded Calm-off ToolExecutionComponent rendering differs from Pi stock");
3120
+ }
3121
+ if (!expandedStock.join("\n").includes("OUTCOME_TWELVE") || JSON.stringify(expandedStock) === JSON.stringify(collapsedStock)) {
3122
+ throw new Error("stock rendering fixture did not exercise expanded output");
3123
+ }
3124
+ pi.events.emit("firstmate:calm-presentation", { active: true, stockExportRendering: false });
3125
+ actualRow.invalidate();
3126
+ if (actualRow.render(100).length !== 0) {
3127
+ throw new Error("Calm-on ToolExecutionComponent row remained visible");
3128
+ }
3129
+ pi.events.emit("firstmate:calm-presentation", { active: false, stockExportRendering: false });
3130
+ actualRow.invalidate();
3131
+ if (JSON.stringify(actualRow.render(100)) !== JSON.stringify(stockRow.render(100))) {
3132
+ throw new Error("ToolExecutionComponent rendering did not restore after live toggle");
3133
+ }
3134
+
3135
+ pi.events.emit("firstmate:calm-presentation", { active: true, stockExportRendering: true });
3136
+ const stockHtml = createToolHtmlRenderer({ getToolDefinition: () => stockDefinition, theme, cwd: process.cwd() });
3137
+ const actualHtml = createToolHtmlRenderer({ getToolDefinition: () => actualDefinition, theme, cwd: process.cwd() });
3138
+ const stockCall = stockHtml.renderCall("stock-html", "fm_branch_outcomes", args);
3139
+ const actualCall = actualHtml.renderCall("actual-html", "fm_branch_outcomes", args);
3140
+ const stockResult = stockHtml.renderResult("stock-html", "fm_branch_outcomes", result.content, result.details, false);
3141
+ const actualResult = actualHtml.renderResult("actual-html", "fm_branch_outcomes", result.content, result.details, false);
3142
+ if (actualCall !== undefined || actualResult !== undefined || stockCall !== undefined || stockResult !== undefined) {
3143
+ throw new Error("stock export rendering did not delegate to Pi's structured fallback");
3144
+ }
3145
+ JS
3146
+ )
3147
+ status=$?
3148
+ expect_code 0 "$status" "Pi outcomes rendering consumers must preserve stock behavior: $out"
3149
+ [ -z "$out" ] || fail "Pi outcomes rendering consumer test printed output: $out"
3150
+ pass "fm_branch_outcomes hides through ToolExecutionComponent while Calm-off and HTML export stay stock"
3151
+ }
3152
+
3153
+ test_outcomes_tool_uses_stock_execution_and_export_consumers
3154
+ test_real_pi_picker_primitives_stay_bounded_and_searchable
3155
+ test_branch_dispatch_two_stage_filter_and_prefix_contract
3156
+ test_requested_healthy_outcome_and_unsolicited_routine_outcome_delivery
3157
+ test_captain_outcome_encoding_failure_delivers_plain_instruction
3158
+ test_branch_dispatch_classifies_main_only_rows_and_writes_the_eligible_snapshot
3159
+ test_branch_cache_key_is_per_home_stable
3160
+ test_branch_default_on_heartbeat_afk_and_fallback
3161
+ test_branch_predrain_recheck_keeps_a_heartbeat_a_co_present_check_arrives_under
3162
+ test_branch_predrain_recheck_excludes_new_main_owned_row_without_deferring_eligible_work
3163
+ test_settled_branch_prompt_releases_unacknowledged_grant
3164
+ test_main_owned_grant_result_falls_back_to_main
3165
+ test_branch_predrain_recheck_noops_already_drained_wake
3166
+ test_branch_mirror_filters_order_and_cursor
3167
+ test_branch_session_persists_across_process_restarts
3168
+ test_branch_model_pin_applies_and_absent_pin_keeps_the_default
3169
+ test_unpinned_branch_follows_main_model_changes_live
3170
+ test_supervision_model_command_persists_and_rebinds_the_live_branch
3171
+ test_supervision_model_picker_is_bounded_searchable_and_branch_only
3172
+ test_branch_model_picker_keeps_follow_main_first_under_ranking
3173
+ test_branch_effort_pin_applies_and_absent_pin_follows_main
3174
+ test_unpinned_branch_follows_main_effort_changes_live
3175
+ test_supervision_model_command_picks_effort_after_the_model
3176
+ test_unusable_model_pin_falls_back_to_main
3177
+ test_replacement_activation_cleans_leases_and_retries_failure
3178
+ test_cold_start_activates_after_lock_acquisition
3179
+ test_queued_actions_recheck_lock_ownership
3180
+ test_stale_generation_boundaries_are_side_effect_free
3181
+ test_secondary_session_stays_inert
3182
+ test_rebind_remirrors_undelivered_dialog_from_durable_cursor