@cohortapp/agent-sdk 2.17.0 → 2.18.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (531) hide show
  1. package/.claude/settings.json +18 -0
  2. package/.env.example +18 -5
  3. package/README.md +1 -0
  4. package/bin/maestro.mjs +62 -0
  5. package/docs/guides/billing-console-keys.md +60 -0
  6. package/docs/guides/front-door-session.md +54 -9
  7. package/docs/guides/mac-mini.md +20 -25
  8. package/docs/guides/setup-wizard.md +1 -1
  9. package/docs/runbooks/fleet-rollout.md +156 -0
  10. package/docs/runbooks/mac-mini-bootstrap.md +12 -14
  11. package/lib/action-executor.js +19 -3
  12. package/lib/budget-guard.mjs +279 -3
  13. package/lib/channels/base-adapter.mjs +3 -1
  14. package/lib/channels/contract.mjs +2 -1
  15. package/lib/channels/inbox-item.mjs +8 -0
  16. package/lib/claude-bin.mjs +5 -6
  17. package/lib/cli/doctor-checks.mjs +141 -10
  18. package/lib/cli/global-setup-extras.mjs +5 -1
  19. package/lib/cli/inbox.mjs +100 -15
  20. package/lib/cli/seat-auth.mjs +463 -0
  21. package/lib/cli/session.mjs +80 -12
  22. package/lib/collective/capture-slots.mjs +234 -0
  23. package/lib/collective/capture.mjs +8 -6
  24. package/lib/collective/config.mjs +2 -0
  25. package/lib/collective/global-config.mjs +63 -1
  26. package/lib/collective/loop-guard.mjs +155 -0
  27. package/lib/collective/presence.mjs +142 -5
  28. package/lib/comms/send-gate.mjs +559 -1
  29. package/lib/diagnostics/alerts.mjs +49 -0
  30. package/lib/diagnostics/cadence-output-freshness.mjs +288 -0
  31. package/lib/engine/agents/definitions.mjs +343 -0
  32. package/lib/engine/agents/persist.mjs +275 -0
  33. package/lib/engine/agents/runtime.mjs +748 -0
  34. package/lib/engine/agents/usage.mjs +95 -0
  35. package/lib/engine/auth-status.mjs +139 -0
  36. package/lib/engine/budget.mjs +194 -0
  37. package/lib/engine/cli.mjs +1204 -0
  38. package/lib/engine/commands/index.mjs +269 -0
  39. package/lib/engine/context/budget.mjs +219 -0
  40. package/lib/engine/context/cache.mjs +125 -0
  41. package/lib/engine/context/child-env.mjs +215 -0
  42. package/lib/engine/context/compaction.mjs +342 -0
  43. package/lib/engine/context/images.mjs +90 -0
  44. package/lib/engine/context/instructions.mjs +327 -0
  45. package/lib/engine/context/lazy-instructions.mjs +169 -0
  46. package/lib/engine/context/manager.mjs +182 -0
  47. package/lib/engine/context/real-path.mjs +91 -0
  48. package/lib/engine/context/secret-values.mjs +163 -0
  49. package/lib/engine/context/settings.mjs +274 -0
  50. package/lib/engine/context/stream-input.mjs +159 -0
  51. package/lib/engine/guard.mjs +152 -0
  52. package/lib/engine/hooks.mjs +713 -0
  53. package/lib/engine/loop.mjs +560 -0
  54. package/lib/engine/mcp/client.mjs +254 -0
  55. package/lib/engine/mcp/config.mjs +301 -0
  56. package/lib/engine/mcp/http.mjs +201 -0
  57. package/lib/engine/mcp/index.mjs +146 -0
  58. package/lib/engine/mcp/jsonrpc.mjs +147 -0
  59. package/lib/engine/mcp/naming.mjs +66 -0
  60. package/lib/engine/mcp/resources.mjs +89 -0
  61. package/lib/engine/mcp/results.mjs +133 -0
  62. package/lib/engine/mcp/stdio.mjs +137 -0
  63. package/lib/engine/mcp/supervisor.mjs +116 -0
  64. package/lib/engine/messages.mjs +104 -0
  65. package/lib/engine/output/json.mjs +164 -0
  66. package/lib/engine/output/stream-json.mjs +266 -0
  67. package/lib/engine/permissions.mjs +845 -0
  68. package/lib/engine/process-identity.mjs +164 -0
  69. package/lib/engine/process-tree.mjs +551 -0
  70. package/lib/engine/prompt.mjs +60 -0
  71. package/lib/engine/session/store.mjs +299 -0
  72. package/lib/engine/session-runtime/args.mjs +97 -0
  73. package/lib/engine/session-runtime/host.mjs +143 -0
  74. package/lib/engine/session-runtime/inbox.mjs +122 -0
  75. package/lib/engine/session-runtime/notifications.mjs +129 -0
  76. package/lib/engine/session-runtime/registry.mjs +328 -0
  77. package/lib/engine/session-runtime/runner.mjs +344 -0
  78. package/lib/engine/session-runtime/socket.mjs +212 -0
  79. package/lib/engine/session-runtime/wakeup.mjs +115 -0
  80. package/lib/engine/skills/index.mjs +321 -0
  81. package/lib/engine/tools/bash-background.mjs +533 -0
  82. package/lib/engine/tools/bash.mjs +216 -0
  83. package/lib/engine/tools/edit.mjs +97 -0
  84. package/lib/engine/tools/glob.mjs +81 -0
  85. package/lib/engine/tools/grep.mjs +224 -0
  86. package/lib/engine/tools/index.mjs +84 -0
  87. package/lib/engine/tools/list-agents.mjs +32 -0
  88. package/lib/engine/tools/ls.mjs +127 -0
  89. package/lib/engine/tools/monitor.mjs +82 -0
  90. package/lib/engine/tools/notebook-edit.mjs +218 -0
  91. package/lib/engine/tools/read.mjs +103 -0
  92. package/lib/engine/tools/schedule-wakeup.mjs +45 -0
  93. package/lib/engine/tools/schema.mjs +144 -0
  94. package/lib/engine/tools/send-message.mjs +77 -0
  95. package/lib/engine/tools/session.mjs +70 -0
  96. package/lib/engine/tools/todo.mjs +144 -0
  97. package/lib/engine/tools/toolsearch.mjs +217 -0
  98. package/lib/engine/tools/walk.mjs +193 -0
  99. package/lib/engine/tools/web-switch.mjs +31 -0
  100. package/lib/engine/tools/webfetch-html.mjs +387 -0
  101. package/lib/engine/tools/webfetch-net.mjs +340 -0
  102. package/lib/engine/tools/webfetch.mjs +198 -0
  103. package/lib/engine/tools/websearch.mjs +91 -0
  104. package/lib/engine/tools/workflow.mjs +95 -0
  105. package/lib/engine/tools/write.mjs +76 -0
  106. package/lib/engine/tui/line-editor.mjs +137 -0
  107. package/lib/engine/tui/render.mjs +86 -0
  108. package/lib/engine/tui/tui.mjs +274 -0
  109. package/lib/engine/wire/anthropic-messages.mjs +263 -0
  110. package/lib/engine/wire/effort.mjs +36 -0
  111. package/lib/engine/wire/errors.mjs +496 -0
  112. package/lib/engine/wire/http.mjs +441 -0
  113. package/lib/engine/wire/index.mjs +76 -0
  114. package/lib/engine/wire/openai-chat.mjs +332 -0
  115. package/lib/engine/wire/prompt-cache.mjs +79 -0
  116. package/lib/engine/wire/search.mjs +140 -0
  117. package/lib/engine/wire/sse.mjs +114 -0
  118. package/lib/engine/wire/stall.mjs +349 -0
  119. package/lib/engine/wire/token-provider.mjs +175 -0
  120. package/lib/engine/wire/usage.mjs +192 -0
  121. package/lib/engine/workflow/host.mjs +524 -0
  122. package/lib/engine/workflow/journal.mjs +188 -0
  123. package/lib/engine/workflow/json-schema.mjs +171 -0
  124. package/lib/engine/workflow/meta.mjs +329 -0
  125. package/lib/engine/workflow/notifications.mjs +52 -0
  126. package/lib/engine/workflow/runtime.mjs +447 -0
  127. package/lib/engine/workflow/sandbox.mjs +534 -0
  128. package/lib/engine/workflow/worker.mjs +141 -0
  129. package/lib/engine/workflow/worktree.mjs +74 -0
  130. package/lib/execution/disposition.mjs +1 -1
  131. package/lib/execution/intake.mjs +10 -0
  132. package/lib/execution/surface-policy.mjs +15 -0
  133. package/lib/learning/curator.mjs +8 -6
  134. package/lib/learning/reflect.mjs +8 -6
  135. package/lib/model-router/catalog/cohort.yaml +137 -0
  136. package/lib/model-router/catalog.mjs +118 -1
  137. package/lib/model-router/failover.mjs +67 -16
  138. package/lib/model-router/llm-task.mjs +39 -3
  139. package/lib/model-router/resolve.mjs +89 -3
  140. package/lib/model-router/spawn.mjs +46 -47
  141. package/lib/model-router/taxonomy.mjs +126 -4
  142. package/lib/org/cost-sync.mjs +141 -11
  143. package/lib/org/inbound/broadcast.mjs +289 -0
  144. package/lib/org/inbound/collective.mjs +375 -0
  145. package/lib/org/inbound/directedness.mjs +96 -8
  146. package/lib/org/inbound/facts.mjs +78 -2
  147. package/lib/org/inbound/project.mjs +22 -0
  148. package/lib/org/inbound/surfaces.mjs +14 -0
  149. package/lib/org/llm-token.mjs +879 -0
  150. package/lib/org/mesh.mjs +61 -0
  151. package/lib/org/messaging.mjs +3 -1
  152. package/lib/org/protocol.checksum +1 -1
  153. package/lib/org/protocol.mjs +15 -0
  154. package/lib/org/quota.mjs +520 -0
  155. package/lib/org/tool-surface.mjs +104 -16
  156. package/lib/org/ui-parity.mjs +16 -1
  157. package/lib/org/work-ledger.mjs +37 -6
  158. package/lib/rate-guard.mjs +114 -1
  159. package/lib/resource-governor.mjs +41 -6
  160. package/lib/runtime/adapter.mjs +833 -0
  161. package/lib/runtime/child-env.mjs +191 -0
  162. package/lib/runtime/legacy-shell-guard.mjs +97 -0
  163. package/lib/runtime/seat-engine.mjs +162 -0
  164. package/lib/session/ask-ledger.mjs +271 -0
  165. package/lib/session/current-work.mjs +676 -0
  166. package/lib/session/feed-core.mjs +40 -3
  167. package/lib/session/launch-args.mjs +56 -4
  168. package/lib/session/status-summary.mjs +26 -9
  169. package/lib/session/upgrade-notice.mjs +42 -0
  170. package/lib/setup/claude-probe.mjs +117 -13
  171. package/lib/setup/enrich.mjs +13 -10
  172. package/lib/setup/sections/model.mjs +39 -13
  173. package/lib/telemetry/collect.mjs +229 -11
  174. package/lib/upgrade/ignored-drift.mjs +105 -0
  175. package/lib/voice/post-call-brief.mjs +30 -17
  176. package/package.json +13 -3
  177. package/plugins/maestro-skills/skills/board-work.md +5 -0
  178. package/plugins/maestro-skills/skills/inbound-triage.md +56 -15
  179. package/plugins/maestro-skills/skills/main-session.md +18 -7
  180. package/scaffold/config/collective.yaml +7 -0
  181. package/scripts/ci/check-durable-write-seam.mjs +3 -1
  182. package/scripts/ci/check-tarball-fidelity.mjs +126 -2
  183. package/scripts/ci/run-tests.mjs +47 -19
  184. package/scripts/cohort-llm/api-key-helper.mjs +92 -0
  185. package/scripts/collective/hook-runner.mjs +142 -19
  186. package/scripts/continuous-monitor.sh +13 -0
  187. package/scripts/cost/track-claude-usage.mjs +15 -0
  188. package/scripts/daemon/agent-daemon.mjs +408 -20
  189. package/scripts/daemon/assurance.mjs +48 -12
  190. package/scripts/daemon/cadence-consumer.mjs +218 -68
  191. package/scripts/daemon/cadence-handlers.mjs +73 -4
  192. package/scripts/daemon/classifier.mjs +75 -26
  193. package/scripts/daemon/context-compiler.mjs +51 -37
  194. package/scripts/daemon/deliver.mjs +30 -1
  195. package/scripts/daemon/dispatcher.mjs +595 -149
  196. package/scripts/daemon/health.mjs +14 -1
  197. package/scripts/daemon/maestro-daemon.mjs +11 -0
  198. package/scripts/daemon/prompt-builder.mjs +24 -0
  199. package/scripts/daemon/responder.mjs +246 -79
  200. package/scripts/daemon/sdk-version.mjs +98 -16
  201. package/scripts/eval/probe-gateway.mjs +635 -0
  202. package/scripts/eval/replay/extract.mjs +270 -0
  203. package/scripts/eval/replay/grade.mjs +260 -0
  204. package/scripts/eval/replay/lib/config.mjs +50 -0
  205. package/scripts/eval/replay/lib/effects.mjs +65 -0
  206. package/scripts/eval/replay/lib/fixture.mjs +188 -0
  207. package/scripts/eval/replay/lib/judge.mjs +72 -0
  208. package/scripts/eval/replay/lib/redact.mjs +136 -0
  209. package/scripts/eval/replay/lib/sandbox.mjs +170 -0
  210. package/scripts/eval/replay/lib/schema-check.mjs +63 -0
  211. package/scripts/eval/replay/lib/transcript.mjs +76 -0
  212. package/scripts/eval/replay/mcp-replay-stub.mjs +101 -0
  213. package/scripts/eval/replay/report.mjs +185 -0
  214. package/scripts/eval/replay/run.mjs +404 -0
  215. package/scripts/fleet/rollout.mjs +1151 -0
  216. package/scripts/hooks/pre-send-audit.sh +36 -245
  217. package/scripts/hooks/pre-write-yaml-validate.mjs +275 -0
  218. package/scripts/hooks/validate-state-yaml.sh +190 -0
  219. package/scripts/huddle/huddle-llm.mjs +361 -0
  220. package/scripts/huddle/huddle-server.mjs +46 -121
  221. package/scripts/local-triggers/autoupdate.sh +465 -81
  222. package/scripts/local-triggers/run-trigger.sh +13 -0
  223. package/scripts/maintenance/pin-integrity.mjs +364 -0
  224. package/scripts/poll-slack-events.sh +41 -9
  225. package/scripts/poller/slack-socket-mode.mjs +28 -3
  226. package/scripts/session/supervisor.mjs +80 -13
  227. package/scripts/spawn-session.sh +13 -0
  228. package/bin/maestro.test.mjs +0 -1574
  229. package/lib/action-executor.test.mjs +0 -871
  230. package/lib/archetype.test.mjs +0 -132
  231. package/lib/assurance/plan-note.test.mjs +0 -234
  232. package/lib/assurance/room-budget.test.mjs +0 -486
  233. package/lib/assurance/tier.test.mjs +0 -174
  234. package/lib/autonomy.test.mjs +0 -66
  235. package/lib/backlog.test.mjs +0 -302
  236. package/lib/backup/policy.test.mjs +0 -305
  237. package/lib/budget-escalate.test.mjs +0 -232
  238. package/lib/budget-guard.envelope.test.mjs +0 -476
  239. package/lib/budget-guard.test.mjs +0 -427
  240. package/lib/cadence-bus-requeue.test.mjs +0 -83
  241. package/lib/cadence-bus-schedule.test.mjs +0 -194
  242. package/lib/cadence-bus.test.mjs +0 -720
  243. package/lib/cadences.test.mjs +0 -230
  244. package/lib/capability/inventory.test.mjs +0 -232
  245. package/lib/capability.test.mjs +0 -78
  246. package/lib/channels/base-adapter.test.mjs +0 -590
  247. package/lib/channels/channels.test.mjs +0 -371
  248. package/lib/channels/contract.test.mjs +0 -162
  249. package/lib/channels/inbox-item.test.mjs +0 -368
  250. package/lib/channels/orgmail/adapter.test.mjs +0 -448
  251. package/lib/channels/pairing.test.mjs +0 -270
  252. package/lib/channels/repeat-suppressor.test.mjs +0 -134
  253. package/lib/channels/slack-adapter.test.mjs +0 -212
  254. package/lib/channels/telegram-adapter.test.mjs +0 -306
  255. package/lib/channels/voice/adapter.test.mjs +0 -278
  256. package/lib/channels/whatsapp/adapter-baileys.test.mjs +0 -359
  257. package/lib/channels/whatsapp/baileys-typing.test.mjs +0 -154
  258. package/lib/charter.test.mjs +0 -89
  259. package/lib/claude-bin.test.mjs +0 -131
  260. package/lib/cli/board.test.mjs +0 -227
  261. package/lib/cli/design.test.mjs +0 -270
  262. package/lib/cli/doctor-checks.test.mjs +0 -336
  263. package/lib/cli/global-setup-extras.test.mjs +0 -462
  264. package/lib/cli/inbox.test.mjs +0 -230
  265. package/lib/cli/session-ack.test.mjs +0 -63
  266. package/lib/cli/session.test.mjs +0 -613
  267. package/lib/collective/capture.test.mjs +0 -121
  268. package/lib/collective/cards.test.mjs +0 -114
  269. package/lib/collective/config.test.mjs +0 -123
  270. package/lib/collective/global-config.test.mjs +0 -220
  271. package/lib/collective/global-skills.test.mjs +0 -126
  272. package/lib/collective/presence.test.mjs +0 -95
  273. package/lib/collective/recall.test.mjs +0 -116
  274. package/lib/collective/vendor-skills.test.mjs +0 -306
  275. package/lib/comms/send-gate.test.mjs +0 -770
  276. package/lib/comms.test.mjs +0 -41
  277. package/lib/context/budget.test.mjs +0 -252
  278. package/lib/context/history-scope.test.mjs +0 -79
  279. package/lib/cost/ledger-row.test.mjs +0 -183
  280. package/lib/design/design-md.test.mjs +0 -318
  281. package/lib/design/fixtures/DESIGN.golden.md +0 -238
  282. package/lib/design/fixtures/PRODUCT.golden.md +0 -67
  283. package/lib/design/fixtures/foundation.json +0 -133
  284. package/lib/design/refresh-gate.test.mjs +0 -144
  285. package/lib/design/write.test.mjs +0 -241
  286. package/lib/diagnostics/alerts.test.mjs +0 -318
  287. package/lib/diagnostics/backup-freshness.test.mjs +0 -185
  288. package/lib/diagnostics/counters.test.mjs +0 -206
  289. package/lib/diagnostics/events.test.mjs +0 -290
  290. package/lib/diagnostics/otel.test.mjs +0 -196
  291. package/lib/diagnostics/trace.test.mjs +0 -251
  292. package/lib/env-compat.test.mjs +0 -104
  293. package/lib/execution/disposition.test.mjs +0 -553
  294. package/lib/execution/drive.test.mjs +0 -270
  295. package/lib/execution/effects.test.mjs +0 -344
  296. package/lib/execution/intake.test.mjs +0 -389
  297. package/lib/execution/journal.test.mjs +0 -261
  298. package/lib/execution/match.test.mjs +0 -235
  299. package/lib/execution/pipeline.test.mjs +0 -392
  300. package/lib/execution/route.test.mjs +0 -186
  301. package/lib/execution/surface-policy.test.mjs +0 -162
  302. package/lib/fs-atomic.test.mjs +0 -72
  303. package/lib/fs-ownership.test.mjs +0 -158
  304. package/lib/goals/admission.test.mjs +0 -164
  305. package/lib/goals/classify.test.mjs +0 -167
  306. package/lib/goals/collaborate.test.mjs +0 -336
  307. package/lib/goals/gaps.test.mjs +0 -284
  308. package/lib/goals/loop.test.mjs +0 -845
  309. package/lib/hooks/bus.test.mjs +0 -387
  310. package/lib/identity/persona.test.mjs +0 -142
  311. package/lib/kpi-sensors.test.mjs +0 -278
  312. package/lib/kpi.test.mjs +0 -244
  313. package/lib/learning/config.test.mjs +0 -75
  314. package/lib/learning/counters.test.mjs +0 -69
  315. package/lib/learning/curator-consolidate.test.mjs +0 -238
  316. package/lib/learning/curator.test.mjs +0 -106
  317. package/lib/learning/reflect.test.mjs +0 -0
  318. package/lib/learning/session-index.test.mjs +0 -125
  319. package/lib/learning/skill-writer.test.mjs +0 -210
  320. package/lib/mandate/audit.test.mjs +0 -195
  321. package/lib/mandate/contract.test.mjs +0 -185
  322. package/lib/mandate/derive.test.mjs +0 -274
  323. package/lib/mandate/model.test.mjs +0 -164
  324. package/lib/mandate/refresh.test.mjs +0 -389
  325. package/lib/mcp/server.test.mjs +0 -426
  326. package/lib/model-router/auth-profiles.test.mjs +0 -580
  327. package/lib/model-router/catalog.test.mjs +0 -385
  328. package/lib/model-router/economics.test.mjs +0 -438
  329. package/lib/model-router/failover.test.mjs +0 -439
  330. package/lib/model-router/health.test.mjs +0 -338
  331. package/lib/model-router/integration-coverage.test.mjs +0 -831
  332. package/lib/model-router/integration.test.mjs +0 -564
  333. package/lib/model-router/ledger.test.mjs +0 -415
  334. package/lib/model-router/llm-task.test.mjs +0 -392
  335. package/lib/model-router/org-credentials.test.mjs +0 -265
  336. package/lib/model-router/pricing-refresh.test.mjs +0 -286
  337. package/lib/model-router/reconcile.test.mjs +0 -316
  338. package/lib/model-router/repair.test.mjs +0 -180
  339. package/lib/model-router/spawn.test.mjs +0 -446
  340. package/lib/model-router/taxonomy.test.mjs +0 -410
  341. package/lib/model-router.test.mjs +0 -1207
  342. package/lib/org/activity.test.mjs +0 -134
  343. package/lib/org/approvals.test.mjs +0 -216
  344. package/lib/org/awareness.test.mjs +0 -159
  345. package/lib/org/board-mine-cache.test.mjs +0 -53
  346. package/lib/org/board.test.mjs +0 -187
  347. package/lib/org/bootstrap-context.test.mjs +0 -153
  348. package/lib/org/client.test.mjs +0 -1206
  349. package/lib/org/cohort-client.test.mjs +0 -126
  350. package/lib/org/cost-sync.test.mjs +0 -153
  351. package/lib/org/doctor.test.mjs +0 -346
  352. package/lib/org/engagement-ledger.test.mjs +0 -112
  353. package/lib/org/engagement.test.mjs +0 -739
  354. package/lib/org/handoff.test.mjs +0 -269
  355. package/lib/org/inbound/directedness.test.mjs +0 -668
  356. package/lib/org/inbound/facts.test.mjs +0 -471
  357. package/lib/org/inbound/hydrate.test.mjs +0 -908
  358. package/lib/org/inbound/index.test.mjs +0 -429
  359. package/lib/org/inbound/project.test.mjs +0 -287
  360. package/lib/org/integration-tools.test.mjs +0 -160
  361. package/lib/org/keys.test.mjs +0 -92
  362. package/lib/org/knowledge.test.mjs +0 -326
  363. package/lib/org/leases.test.mjs +0 -235
  364. package/lib/org/mesh-directives.test.mjs +0 -110
  365. package/lib/org/mesh-integration.test.mjs +0 -127
  366. package/lib/org/mesh.test.mjs +0 -400
  367. package/lib/org/messaging.test.mjs +0 -471
  368. package/lib/org/param-contract.test.mjs +0 -477
  369. package/lib/org/policy.test.mjs +0 -237
  370. package/lib/org/protocol.checksum.test.mjs +0 -90
  371. package/lib/org/protocol.test.mjs +0 -323
  372. package/lib/org/push.test.mjs +0 -792
  373. package/lib/org/registry.test.mjs +0 -100
  374. package/lib/org/resource-tools.test.mjs +0 -361
  375. package/lib/org/tool-access.test.mjs +0 -144
  376. package/lib/org/tool-surface-integration.test.mjs +0 -120
  377. package/lib/org/tool-surface.test.mjs +0 -1268
  378. package/lib/org/typing.test.mjs +0 -291
  379. package/lib/org/ui-parity.test.mjs +0 -560
  380. package/lib/org/verify.test.mjs +0 -194
  381. package/lib/org/work-ledger.test.mjs +0 -273
  382. package/lib/plan/adoption-e2e.test.mjs +0 -366
  383. package/lib/plan/budget-enforcement.test.mjs +0 -400
  384. package/lib/plan/compile.test.mjs +0 -382
  385. package/lib/plan/emit.test.mjs +0 -269
  386. package/lib/plan/explain.test.mjs +0 -188
  387. package/lib/prompts/parallelism.test.mjs +0 -177
  388. package/lib/rag/rag.test.mjs +0 -505
  389. package/lib/rate-guard.test.mjs +0 -272
  390. package/lib/reactive-gate.test.mjs +0 -57
  391. package/lib/render.test.mjs +0 -68
  392. package/lib/resource-governor.test.mjs +0 -488
  393. package/lib/scheduling/dynamic-jobs.test.mjs +0 -344
  394. package/lib/scheduling/jitter.test.mjs +0 -140
  395. package/lib/secrets/broker.test.mjs +0 -280
  396. package/lib/secrets/providers.test.mjs +0 -274
  397. package/lib/security/audit-engine.test.mjs +0 -424
  398. package/lib/security/coerce-args.test.mjs +0 -281
  399. package/lib/security/dangerous-tools.test.mjs +0 -68
  400. package/lib/security/external-content.test.mjs +0 -84
  401. package/lib/security/redact.test.mjs +0 -441
  402. package/lib/security/secret-equal.test.mjs +0 -55
  403. package/lib/session/config.test.mjs +0 -92
  404. package/lib/session/feed-core.test.mjs +0 -198
  405. package/lib/session/first-run.test.mjs +0 -121
  406. package/lib/session/frontdoor.test.mjs +0 -205
  407. package/lib/session/handoffs.test.mjs +0 -183
  408. package/lib/session/identity.test.mjs +0 -180
  409. package/lib/session/inbox-claims.test.mjs +0 -286
  410. package/lib/session/launch-args.test.mjs +0 -157
  411. package/lib/session/liveness.test.mjs +0 -100
  412. package/lib/session/status-summary.test.mjs +0 -118
  413. package/lib/session-permissions.test.mjs +0 -120
  414. package/lib/setup/claude-probe.test.mjs +0 -187
  415. package/lib/setup/completeness.test.mjs +0 -110
  416. package/lib/setup/context-pack.test.mjs +0 -89
  417. package/lib/setup/enrich.test.mjs +0 -115
  418. package/lib/setup/enroll-from-cohort.test.mjs +0 -300
  419. package/lib/setup/integration.test.mjs +0 -162
  420. package/lib/setup/io.test.mjs +0 -77
  421. package/lib/setup/runner.test.mjs +0 -132
  422. package/lib/setup/sections/identity.test.mjs +0 -234
  423. package/lib/setup/sections/inventory.test.mjs +0 -198
  424. package/lib/setup/sections/learning.test.mjs +0 -81
  425. package/lib/setup/sections/mandate.test.mjs +0 -388
  426. package/lib/setup/sections/messaging.test.mjs +0 -127
  427. package/lib/setup/sections/model.test.mjs +0 -240
  428. package/lib/setup/sections/org.test.mjs +0 -346
  429. package/lib/setup/sections/orgmail.test.mjs +0 -118
  430. package/lib/setup/sections/recovery.test.mjs +0 -98
  431. package/lib/setup/sections/subagents.test.mjs +0 -429
  432. package/lib/setup/sections/verify.test.mjs +0 -175
  433. package/lib/setup/sot.test.mjs +0 -81
  434. package/lib/setup/state.test.mjs +0 -115
  435. package/lib/singleton.test.mjs +0 -151
  436. package/lib/subagents/cli.test.mjs +0 -389
  437. package/lib/subagents/client.test.mjs +0 -309
  438. package/lib/subagents/gap.test.mjs +0 -234
  439. package/lib/subagents/lock.test.mjs +0 -248
  440. package/lib/subagents/manifest.test.mjs +0 -175
  441. package/lib/subagents/refs.test.mjs +0 -204
  442. package/lib/subagents/resolve.test.mjs +0 -422
  443. package/lib/subagents/schema.test.mjs +0 -328
  444. package/lib/telemetry/alerts.test.mjs +0 -109
  445. package/lib/telemetry/collect.test.mjs +0 -1274
  446. package/lib/tool-definitions-integration.test.mjs +0 -83
  447. package/lib/tool-definitions.test.mjs +0 -437
  448. package/lib/upgrade/global-refresh.test.mjs +0 -65
  449. package/lib/upgrade/launchd-reconcile.test.mjs +0 -272
  450. package/lib/upgrade/post-steps.test.mjs +0 -200
  451. package/lib/upgrade/verify.test.mjs +0 -164
  452. package/lib/util/fetch-timeout.test.mjs +0 -202
  453. package/lib/util/reconnect.test.mjs +0 -369
  454. package/lib/util/unhandled.test.mjs +0 -216
  455. package/lib/voice/outbound.test.mjs +0 -69
  456. package/lib/voice/session-rotation.test.mjs +0 -114
  457. package/lib/voice/stt.test.mjs +0 -226
  458. package/lib/voice/voice.test.mjs +0 -990
  459. package/scripts/cadence/enqueue-cadence-tick.test.mjs +0 -187
  460. package/scripts/ci/check-docs-accuracy.test.mjs +0 -409
  461. package/scripts/ci/check-durable-write-seam.test.mjs +0 -90
  462. package/scripts/ci/check-no-build-artifacts.test.mjs +0 -71
  463. package/scripts/ci/check-no-residual-identity.test.mjs +0 -202
  464. package/scripts/ci/check-skill-packs.test.mjs +0 -495
  465. package/scripts/ci/check-subagent-frontmatter.test.mjs +0 -124
  466. package/scripts/ci/check.test.mjs +0 -194
  467. package/scripts/ci/conformance-org-api.test.mjs +0 -425
  468. package/scripts/cloud-relay/voice/relay-identity.test.mjs +0 -96
  469. package/scripts/collective/hook-runner.test.mjs +0 -173
  470. package/scripts/cost/fleet-digest.test.mjs +0 -207
  471. package/scripts/cost/track-claude-usage-pricing.test.mjs +0 -183
  472. package/scripts/cost/track-claude-usage.test.mjs +0 -148
  473. package/scripts/daemon/agent-daemon-board-mine.test.mjs +0 -96
  474. package/scripts/daemon/agent-daemon-design.test.mjs +0 -238
  475. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +0 -60
  476. package/scripts/daemon/agent-daemon.test.mjs +0 -995
  477. package/scripts/daemon/assurance-e2e.test.mjs +0 -613
  478. package/scripts/daemon/assurance.test.mjs +0 -1791
  479. package/scripts/daemon/board-mirror.test.mjs +0 -165
  480. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +0 -393
  481. package/scripts/daemon/cadence-consumer-governance.test.mjs +0 -276
  482. package/scripts/daemon/cadence-consumer.test.mjs +0 -776
  483. package/scripts/daemon/cadence-handlers.test.mjs +0 -837
  484. package/scripts/daemon/classifier-identity.test.mjs +0 -137
  485. package/scripts/daemon/classifier.test.mjs +0 -266
  486. package/scripts/daemon/classify-kind.test.mjs +0 -40
  487. package/scripts/daemon/context-compiler.test.mjs +0 -406
  488. package/scripts/daemon/deliver.test.mjs +0 -564
  489. package/scripts/daemon/dispatcher-cooldown.test.mjs +0 -122
  490. package/scripts/daemon/dispatcher-governance.test.mjs +0 -1013
  491. package/scripts/daemon/dispatcher-resume.test.mjs +0 -166
  492. package/scripts/daemon/dispatcher-session-continuity.test.mjs +0 -365
  493. package/scripts/daemon/execution-ladder.test.mjs +0 -470
  494. package/scripts/daemon/goal-steward-cadence.test.mjs +0 -312
  495. package/scripts/daemon/inbox-deferral-session.test.mjs +0 -49
  496. package/scripts/daemon/inbox-deferral.test.mjs +0 -336
  497. package/scripts/daemon/inbox-wake.test.mjs +0 -199
  498. package/scripts/daemon/integration.test.mjs +0 -149
  499. package/scripts/daemon/lib/self-echo.test.mjs +0 -153
  500. package/scripts/daemon/lib/session-router.test.mjs +0 -554
  501. package/scripts/daemon/prompt-builder-preamble.test.mjs +0 -210
  502. package/scripts/daemon/prompt-builder.test.mjs +0 -556
  503. package/scripts/daemon/responder-cost.test.mjs +0 -68
  504. package/scripts/daemon/responder-history.test.mjs +0 -221
  505. package/scripts/daemon/sdk-version.test.mjs +0 -31
  506. package/scripts/daemon/session-lock.test.mjs +0 -252
  507. package/scripts/daemon/session-outcomes.test.mjs +0 -533
  508. package/scripts/daemon/typing-registry.test.mjs +0 -102
  509. package/scripts/hooks/pre-send-audit.test.mjs +0 -354
  510. package/scripts/huddle/huddle-prompt.test.mjs +0 -176
  511. package/scripts/local-triggers/autoupdate.test.mjs +0 -518
  512. package/scripts/local-triggers/generate-plists.test.mjs +0 -456
  513. package/scripts/media-generation/brand-clause.test.mjs +0 -135
  514. package/scripts/org/send-orgmail.first-contact.test.mjs +0 -102
  515. package/scripts/poller/inbox-privilege-injection.test.mjs +0 -167
  516. package/scripts/poller/inbox-scan-poller.test.mjs +0 -295
  517. package/scripts/poller/lib/cloud-relay-dedup.test.mjs +0 -133
  518. package/scripts/poller/slack-socket-mode.test.mjs +0 -805
  519. package/scripts/poller-launchd/install.test.mjs +0 -243
  520. package/scripts/restore-from-backup.test.mjs +0 -181
  521. package/scripts/session/feed.test.mjs +0 -196
  522. package/scripts/session/supervisor-sh.test.mjs +0 -218
  523. package/scripts/session/supervisor.test.mjs +0 -482
  524. package/scripts/setup/configure-macos.test.mjs +0 -306
  525. package/scripts/setup/gen-subagent-manifest.test.mjs +0 -124
  526. package/scripts/setup/generate-agent-package-json.test.mjs +0 -143
  527. package/scripts/setup/generate-capability.test.mjs +0 -134
  528. package/scripts/setup/init-agent.test.mjs +0 -370
  529. package/scripts/setup/init-skill-marketplace.test.mjs +0 -193
  530. package/scripts/vendor/sync-skill-packs.test.mjs +0 -103
  531. package/scripts/watchdog/memory-watchdog.test.mjs +0 -64
@@ -0,0 +1,1151 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/fleet/rollout.mjs — publish one SDK version to the fleet, then PROVE
4
+ * every seat took it.
5
+ *
6
+ * ── WHY THIS EXISTS ───────────────────────────────────────────────────────────
7
+ * Propagation across the fleet has exactly one live path: publish to npm, and
8
+ * each seat's hourly `scripts/local-triggers/autoupdate.sh` installs @latest.
9
+ * There is no fan-out, no ssh, no push. That path is fine — what was missing is
10
+ * the OPERATOR half of it:
11
+ *
12
+ * · the publish is gated on facts nobody re-checked at the moment of publish
13
+ * (auth, a clean tree, a pushed tag, green check+test), so "I ran the tests
14
+ * an hour ago" stood in for "the tests pass on the bytes I am about to
15
+ * publish";
16
+ * · and nothing ever ASKED whether the fleet took it. One seat ran six days
17
+ * behind (2026-09-15..21) while its own log said "up to date" every hour;
18
+ * the org noticed nothing, because nobody was looking at the fleet's
19
+ * versions as a set.
20
+ *
21
+ * So: one command, three stages, and a non-zero exit while any seat is behind.
22
+ *
23
+ * 1. PRE-FLIGHT — refuse unless `npm whoami` succeeds, the local version is
24
+ * strictly greater than the registry's @latest, the working tree is clean,
25
+ * the version's tag exists locally AND on the remote, and `npm run check`
26
+ * + `npm test` pass. The gates are RE-RUN here; a log from an earlier run
27
+ * is not evidence about the bytes being published.
28
+ * 2. PUBLISH — `npm publish`, then poll the registry until @latest IS the
29
+ * new version. A 404/stale read in the first seconds after a publish is
30
+ * REGISTRY READ LAG, not failure (this has been mistaken for a failed
31
+ * publish before), so the poll retries on a stale answer and only fails
32
+ * when the deadline passes.
33
+ * 3. VERIFY — poll the org directory (hq's server-stamped presence-beat
34
+ * derivation) for every fleet seat until each reports the new version or
35
+ * the deadline passes. A per-seat table prints every round; the run exits
36
+ * non-zero naming exactly which seats are behind.
37
+ *
38
+ * ── WHAT IT WILL NEVER DO ─────────────────────────────────────────────────────
39
+ * NO SSH. Not as a fallback, not with `--force`, not "just to check". Seats are
40
+ * updated by their own autoupdater; this tool publishes and then OBSERVES. It
41
+ * also takes NO credential as an argument or environment variable of its own:
42
+ * npm auth comes from the operator's own `npm login`, org auth from the agent
43
+ * config the rest of lib/org reads.
44
+ *
45
+ * Both promises are enforced twice, because a source-reading test can only see
46
+ * the shapes it was taught: {@link commandRefusal} refuses any command but npm
47
+ * and git at RUNTIME, on the value, and {@link parseArgs} drops everything
48
+ * after the first `=` before any error string is built, so no branch can echo a
49
+ * credential back. The structural tests in scripts/fleet/rollout.test.mjs sit
50
+ * on top of those, pinning the import surface of node:child_process so nothing
51
+ * can spawn a process without going through the one guarded seam.
52
+ *
53
+ * ── `unverifiable` NOW MEANS THE SEAT IS SILENT, NOT THE READ ─────────────────
54
+ * Stage 3 wants each seat's RUNNING version. This used to be unanswerable from
55
+ * outside the machines: the beat carried `machine.sdkVersion` and hq persisted
56
+ * it, but `directory` (src/server/rpc/reads.ts#directory) selected five
57
+ * AgentStatus columns and `machine` was not among them — so every seat in a
58
+ * perfect fleet read `unverifiable` and the run exited 3. That read now
59
+ * projects the identity fields (`sdkVersion`, `sdkVersionRunning`, `hostname`),
60
+ * narrowed deliberately: the rest of `machine` carries per-seat spend and a
61
+ * private-network address, which stay behind `fleet.view`.
62
+ *
63
+ * So `unverifiable` has changed meaning, and the new meaning is a finding. A
64
+ * seat reads unverifiable when IT reports no version — which, in this fleet,
65
+ * means its copied tree predates SDK 2.12, the release that first put identity
66
+ * on the beat. The field that would announce the staleness is the field the
67
+ * staleness removes, so such a seat cannot be told from a healthy one by its
68
+ * version alone; what gives it away is that its beat also carries no
69
+ * `frontDoor`/`sessionLive`, which every 2.12+ collector emits unconditionally.
70
+ * Either way it still exits non-zero: an unverified seat is not a verified one,
71
+ * and "it beat, so it must be current" is exactly the substitution that hid a
72
+ * six-day-stale seat.
73
+ *
74
+ * INSTALLED IS NOT RUNNING. {@link seatVersions} keeps the two apart and judges
75
+ * on `running` whenever the seat reports it (`machine.daemon.sdkVersion`, SDK
76
+ * 2.18+), because autoupdate installs into node_modules while the old daemon
77
+ * keeps executing the code it booted on. A seat whose two disagree is `stale`.
78
+ *
79
+ * ── USAGE ─────────────────────────────────────────────────────────────────────
80
+ * node scripts/fleet/rollout.mjs [--dry-run] [--deadline <min>]
81
+ * [--interval <s>] [--seat-window <min>]
82
+ * [--agent-root <path>] [--json] [--help]
83
+ *
84
+ * `--dry-run` does everything except the publish itself (pre-flight gates run
85
+ * for real, including check+test) and then verifies propagation against the
86
+ * version in package.json — which is how you see, today, exactly which seats
87
+ * would still be behind.
88
+ *
89
+ * Idempotent: when the registry's @latest already equals the local version the
90
+ * run prints "already published, verifying propagation" and goes straight to
91
+ * stage 3. Re-running it is the supported way to watch a rollout land.
92
+ *
93
+ * Exit codes: 0 verified · 1 pre-flight refusal · 2 publish/registry failure ·
94
+ * 3 propagation deadline passed with seats behind or unverifiable.
95
+ *
96
+ * @module scripts/fleet/rollout
97
+ */
98
+
99
+ "use strict";
100
+
101
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
102
+ import { spawnSync } from "node:child_process";
103
+ import { dirname, join, resolve as resolvePath } from "node:path";
104
+ import { fileURLToPath } from "node:url";
105
+
106
+ const __filename = fileURLToPath(import.meta.url);
107
+ const REPO_ROOT = resolvePath(dirname(__filename), "..", "..");
108
+
109
+ /** Package this tool publishes. Read from package.json, never hardcoded. */
110
+ export function readLocalPackage(repoRoot = REPO_ROOT) {
111
+ const doc = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf-8"));
112
+ return { name: String(doc.name || ""), version: String(doc.version || "") };
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // PURE: version comparison
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Parse a semver-ish string into comparable parts. Tolerant: a leading `v`, and
121
+ * missing minor/patch, are accepted; anything unparseable yields null so the
122
+ * caller can refuse rather than guess.
123
+ * @param {string} v
124
+ * @returns {{major:number,minor:number,patch:number,pre:string[]}|null}
125
+ */
126
+ export function parseVersion(v) {
127
+ const s = String(v == null ? "" : v).trim().replace(/^v/i, "");
128
+ const m = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(s);
129
+ if (!m) return null;
130
+ return {
131
+ major: Number(m[1]),
132
+ minor: Number(m[2] || 0),
133
+ patch: Number(m[3] || 0),
134
+ pre: m[4] ? m[4].split(".") : [],
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Compare two versions: -1 (a<b), 0 (equal), 1 (a>b). An unparseable version
140
+ * sorts BELOW a parseable one (and two unparseable ones compare equal), so a
141
+ * garbage registry answer can never read as "newer than mine" and wave a
142
+ * publish through.
143
+ * @param {string} a
144
+ * @param {string} b
145
+ * @returns {-1|0|1}
146
+ */
147
+ export function compareVersions(a, b) {
148
+ const pa = parseVersion(a);
149
+ const pb = parseVersion(b);
150
+ if (!pa && !pb) return 0;
151
+ if (!pa) return -1;
152
+ if (!pb) return 1;
153
+ for (const k of ["major", "minor", "patch"]) {
154
+ if (pa[k] !== pb[k]) return pa[k] < pb[k] ? -1 : 1;
155
+ }
156
+ // 1.0.0-rc.1 < 1.0.0 : a release with no prerelease outranks one with.
157
+ if (pa.pre.length === 0 && pb.pre.length === 0) return 0;
158
+ if (pa.pre.length === 0) return 1;
159
+ if (pb.pre.length === 0) return -1;
160
+ const n = Math.max(pa.pre.length, pb.pre.length);
161
+ for (let i = 0; i < n; i++) {
162
+ const x = pa.pre[i];
163
+ const y = pb.pre[i];
164
+ if (x === undefined) return -1;
165
+ if (y === undefined) return 1;
166
+ const xn = /^\d+$/.test(x);
167
+ const yn = /^\d+$/.test(y);
168
+ if (xn && yn) {
169
+ if (Number(x) !== Number(y)) return Number(x) < Number(y) ? -1 : 1;
170
+ } else if (x !== y) {
171
+ return x < y ? -1 : 1;
172
+ }
173
+ }
174
+ return 0;
175
+ }
176
+
177
+ /** True when `a` is a strictly greater version than `b`. */
178
+ export function isNewer(a, b) {
179
+ return compareVersions(a, b) === 1;
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // PURE: pre-flight verdict
184
+ // ---------------------------------------------------------------------------
185
+
186
+ /**
187
+ * Decide whether a publish may proceed, from already-gathered FACTS. Pure: it
188
+ * runs nothing, so every refusal is unit-testable and the order of refusals is
189
+ * stable (cheapest, most-likely-human-fixable first).
190
+ *
191
+ * @param {object} f
192
+ * @param {{ok:boolean, user?:string, error?:string}} f.whoami
193
+ * @param {string} f.localVersion
194
+ * @param {{ok:boolean, version:string|null, error?:string}} f.registryLatest
195
+ * @param {{clean:boolean, dirty:string[]}} f.worktree
196
+ * @param {{local:boolean, remote:boolean, name:string}} f.tag
197
+ * @param {{ok:boolean, code:number}} f.check
198
+ * @param {{ok:boolean, code:number}} f.test
199
+ * @returns {{ok:boolean, refusals:string[]}}
200
+ */
201
+ export function evaluatePreflight(f) {
202
+ const refusals = [];
203
+ const local = String(f.localVersion || "");
204
+
205
+ if (!f.whoami || !f.whoami.ok) {
206
+ refusals.push(
207
+ `npm auth: \`npm whoami\` failed${f.whoami && f.whoami.error ? ` (${f.whoami.error})` : ""} — run \`npm login\` as the publisher. This tool never accepts a token as an argument.`
208
+ );
209
+ }
210
+
211
+ if (!parseVersion(local)) {
212
+ refusals.push(`local version: package.json version ${JSON.stringify(local)} is not a version this tool can compare.`);
213
+ } else if (!f.registryLatest || !f.registryLatest.ok) {
214
+ refusals.push(
215
+ `registry read: could not read the published @latest${f.registryLatest && f.registryLatest.error ? ` (${f.registryLatest.error})` : ""} — refusing to publish blind.`
216
+ );
217
+ } else if (!isNewer(local, f.registryLatest.version)) {
218
+ refusals.push(
219
+ `version: local ${local} is not greater than the registry's @latest ${f.registryLatest.version || "(none)"} — bump package.json first.`
220
+ );
221
+ }
222
+
223
+ if (!f.worktree || !f.worktree.clean) {
224
+ const dirty = (f.worktree && f.worktree.dirty) || [];
225
+ const shown = dirty.slice(0, 5).join(", ");
226
+ refusals.push(
227
+ `working tree: ${dirty.length} uncommitted path(s)${shown ? ` (${shown}${dirty.length > 5 ? ", …" : ""})` : ""} — publish only what is committed.`
228
+ );
229
+ }
230
+
231
+ if (!f.tag || !f.tag.local) {
232
+ refusals.push(`tag: ${f.tag && f.tag.name ? f.tag.name : `v${local}`} does not exist locally — tag the release commit.`);
233
+ } else if (!f.tag.remote) {
234
+ refusals.push(`tag: ${f.tag.name} is not on the remote — \`git push origin ${f.tag.name}\` so the published bytes stay findable.`);
235
+ }
236
+
237
+ for (const g of [{ k: "check", cmd: "npm run check" }, { k: "test", cmd: "npm test" }]) {
238
+ const r = f[g.k];
239
+ if (!r) { refusals.push(`gate: \`${g.cmd}\` was not run.`); continue; }
240
+ if (r.ok) continue;
241
+ // Could not run vs ran and failed: the operator's next move differs, so the
242
+ // refusal must not blur them (ENOBUFS once made a passing suite read as a
243
+ // failing one).
244
+ if (r.ran === false) refusals.push(`gate: \`${g.cmd}\` could not be run (${r.failure || "unknown"}) — this is NOT a test failure; the harness could not capture its output.`);
245
+ else refusals.push(`gate: \`${g.cmd}\` failed (exit ${r.code}).`);
246
+ }
247
+
248
+ return { ok: refusals.length === 0, refusals };
249
+ }
250
+
251
+ /**
252
+ * One gate's line in the pre-flight table. Pure.
253
+ *
254
+ * Three outcomes, not two: passed, ran and failed, or never got to answer.
255
+ * The third used to print as the second.
256
+ */
257
+ export function gateLine(r) {
258
+ if (!r) return "not run";
259
+ if (r.ok) return "pass";
260
+ if (r.ran === false) return `COULD NOT RUN (${r.failure || "unknown"}) — not a test failure`;
261
+ return `FAILED (exit ${r.code})`;
262
+ }
263
+
264
+ /**
265
+ * A note about paths the GATES themselves dirtied. `npm test` appends to a
266
+ * tracked runtime ledger (.claude-flow/policy/state.json), so a second run in a
267
+ * row would refuse on a file the first run wrote. The clean-tree gate is NOT
268
+ * weakened for this — publishing a tree you cannot describe stays a refusal —
269
+ * the run just says which paths are the gates' own leavings so the operator
270
+ * reverts them instead of hunting them. Pure.
271
+ *
272
+ * @param {string[]} before dirty paths before the gates ran
273
+ * @param {string[]} after dirty paths after
274
+ * @returns {string|null}
275
+ */
276
+ export function gateDirtNote(before, after) {
277
+ const was = new Set(before || []);
278
+ const added = (after || []).filter((p) => !was.has(p));
279
+ if (added.length === 0) return null;
280
+ return (
281
+ `the gates just dirtied ${added.length} tracked path(s): ${added.join(", ")}. ` +
282
+ `They are runtime artefacts, not your change — \`git checkout --\` them before re-running, ` +
283
+ `or the clean-tree gate will refuse the next run.`
284
+ );
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // PURE: registry read-lag retry decision
289
+ // ---------------------------------------------------------------------------
290
+
291
+ /**
292
+ * Decide what to do with one post-publish registry read. The registry is
293
+ * eventually consistent: for a few seconds after a successful publish a read
294
+ * can 404 the version or still answer with the PREVIOUS @latest. That is READ
295
+ * LAG, not a failed publish — treating it as failure has previously sent people
296
+ * hunting a publish that had in fact worked.
297
+ *
298
+ * @param {object} o
299
+ * @param {number} o.attempt 1-based attempt number
300
+ * @param {number} o.maxAttempts
301
+ * @param {string} o.target version just published
302
+ * @param {{ok:boolean, version:string|null, error?:string}} o.read
303
+ * @returns {{action:"done"|"retry"|"fail", reason:string}}
304
+ */
305
+ export function readLagDecision(o) {
306
+ const { attempt, maxAttempts, target, read } = o;
307
+ if (read && read.ok && read.version && compareVersions(read.version, target) === 0) {
308
+ return { action: "done", reason: `registry @latest is ${target}` };
309
+ }
310
+ const why = !read || !read.ok
311
+ ? `registry read failed (${(read && read.error) || "unknown"})`
312
+ : `registry @latest is ${read.version || "(none)"}, not ${target}`;
313
+ if (attempt < maxAttempts) return { action: "retry", reason: `${why} — read lag, retrying` };
314
+ return { action: "fail", reason: `${why} after ${maxAttempts} attempt(s)` };
315
+ }
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // PURE: seats, versions, staleness table
319
+ // ---------------------------------------------------------------------------
320
+
321
+ /** Default window in which a beat makes a member a FLEET SEAT (ms). */
322
+ export const DEFAULT_SEAT_WINDOW_MS = 30 * 60 * 1000;
323
+
324
+ /** First non-blank string among the candidates, trimmed; null when none is. */
325
+ function firstString(candidates) {
326
+ for (const c of candidates) {
327
+ if (typeof c === "string" && c.trim()) return c.trim();
328
+ }
329
+ return null;
330
+ }
331
+
332
+ /**
333
+ * The two versions a directory entry reports, kept APART.
334
+ *
335
+ * installed — what `npm install` put on the seat (`machine.sdkVersion`)
336
+ * running — what the beating process actually executes
337
+ * (`machine.daemon.sdkVersion`, SDK 2.18+)
338
+ *
339
+ * They are different facts and a rollout is about the second one. autoupdate
340
+ * installs into node_modules while the old daemon keeps executing the code it
341
+ * booted on, and a hand-run `maestro upgrade` never restarts the daemon at all
342
+ * — so "installed" is where a propagation check goes wrong quietly: the seat is
343
+ * carrying the new package and running the old one, and a checker that only
344
+ * reads `installed` calls that done.
345
+ *
346
+ * `version` is therefore RUNNING when the seat reports it, and falls back to
347
+ * installed only when it does not — with `basis` naming which, so no reader has
348
+ * to infer it. A seat whose two disagree is `stale`, correctly: it has the fix
349
+ * on disk and is not running it.
350
+ *
351
+ * Several shapes are accepted because the field has travelled: the directory
352
+ * read now projects it as `status.sdkVersion` / `status.sdkVersionRunning`,
353
+ * `presence.fleetVersions` returns `installed` / `running`, and the raw
354
+ * `machine` record is what both are read from.
355
+ *
356
+ * EXPECT EVERY ROW TO SAY `(installed)` UNTIL 2.18.x IS ON THE SEATS. `running`
357
+ * comes from `machine.daemon`, which only an SDK 2.18+ daemon emits, and no
358
+ * seat in the fleet writes that block today — so `basis` is `"installed"` for
359
+ * every row and the table prints the suffix everywhere. That is the check
360
+ * reporting honestly about what it can see, NOT a defect and NOT a reason to
361
+ * drop the suffix: the suffix disappears seat by seat as the daemons restart
362
+ * onto a version that reports what it is running, and a table that went quiet
363
+ * about the difference would be back to the failure this file exists to catch.
364
+ *
365
+ * @param {object} entry
366
+ * @returns {{installed:string|null, running:string|null, version:string|null, basis:"running"|"installed"|null}}
367
+ */
368
+ export function seatVersions(entry) {
369
+ const e = entry && typeof entry === "object" ? entry : {};
370
+ const st = (e.status && typeof e.status === "object" && e.status) || {};
371
+ const mach = (st.machine && typeof st.machine === "object" && st.machine) || (e.machine && typeof e.machine === "object" && e.machine) || {};
372
+ const daemon = (mach.daemon && typeof mach.daemon === "object" && mach.daemon) || {};
373
+
374
+ const installed = firstString([
375
+ mach.sdkVersion,
376
+ st.sdkVersion,
377
+ e.presence && e.presence.sdkVersion,
378
+ e.installed,
379
+ e.sdkVersion,
380
+ ]);
381
+ const running = firstString([
382
+ daemon.sdkVersion,
383
+ st.sdkVersionRunning,
384
+ e.running,
385
+ ]);
386
+ const version = running || installed;
387
+ return { installed, running, version, basis: version === null ? null : running ? "running" : "installed" };
388
+ }
389
+
390
+ /**
391
+ * The single version a seat is judged on, or null when the read carries none.
392
+ * Kept as its own export because it is the whole of what `classifySeat` needs;
393
+ * {@link seatVersions} is what a caller reaches for when it wants to SAY which
394
+ * of the two it got.
395
+ * @param {object} entry
396
+ * @returns {string|null}
397
+ */
398
+ export function seatVersion(entry) {
399
+ return seatVersions(entry).version;
400
+ }
401
+
402
+ /**
403
+ * Turn a directory `members` array into the rollout's seat rows. A member is a
404
+ * SEAT when it is an AI colleague whose server-stamped beat is inside
405
+ * `seatWindowMs`: an agent with no machine (LLM-responder mode) never takes an
406
+ * SDK version and must not be counted as behind. Pure.
407
+ *
408
+ * `readCarriesVersionField` is the tool's answer to "am I blind, or is the
409
+ * fleet silent?" — the two produce an identical table and want opposite
410
+ * responses. It is true when ANY member's status object OWNS an sdkVersion key,
411
+ * even set to null: a read that projects the field says so by carrying it,
412
+ * whatever the value. Without this the tool would have to assert a cause it
413
+ * cannot see, and it would be wrong on whichever side of the hq deploy it was
414
+ * not written for.
415
+ *
416
+ * @param {object[]} members
417
+ * @param {{seatWindowMs?:number}} [o]
418
+ * @returns {{seats:object[], nonSeats:object[], readCarriesVersionField:boolean}}
419
+ */
420
+ export function toSeats(members, o = {}) {
421
+ const windowMs = Number.isFinite(o.seatWindowMs) ? o.seatWindowMs : DEFAULT_SEAT_WINDOW_MS;
422
+ const seats = [];
423
+ const nonSeats = [];
424
+ let readCarriesVersionField = false;
425
+ for (const m of Array.isArray(members) ? members : []) {
426
+ if (!m || typeof m !== "object") continue;
427
+ if (m.kind && m.kind !== "AI_AGENT") continue;
428
+ const st = m.status;
429
+ if (st && typeof st === "object" && ("sdkVersion" in st || "sdkVersionRunning" in st || (st.machine && typeof st.machine === "object"))) {
430
+ readCarriesVersionField = true;
431
+ }
432
+ const p = m.presence && typeof m.presence === "object" ? m.presence : null;
433
+ const beatMs = p && Number.isFinite(p.lastBeatMs) ? p.lastBeatMs : null;
434
+ const row = {
435
+ id: String(m.id || ""),
436
+ slug: String(m.slug || ""),
437
+ name: String(m.displayName || m.name || m.slug || m.id || ""),
438
+ lane: (p && p.lane) || "unknown",
439
+ detail: (p && p.detail) || "",
440
+ lastBeatMs: beatMs,
441
+ ...seatVersions(m),
442
+ };
443
+ if (beatMs !== null && beatMs <= windowMs) seats.push(row);
444
+ else nonSeats.push(row);
445
+ }
446
+ const bySlug = (a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0);
447
+ return { seats: seats.sort(bySlug), nonSeats: nonSeats.sort(bySlug), readCarriesVersionField };
448
+ }
449
+
450
+ /**
451
+ * Verdict for one seat against the target version. Pure.
452
+ * current — reported version equals the target
453
+ * stale — reported a different (older or newer) version
454
+ * unverifiable — beating, but the read carries no version at all
455
+ * @param {object} seat
456
+ * @param {string} target
457
+ * @returns {"current"|"stale"|"unverifiable"}
458
+ */
459
+ export function classifySeat(seat, target) {
460
+ const v = seat && seat.version;
461
+ if (!v) return "unverifiable";
462
+ return compareVersions(v, target) === 0 ? "current" : "stale";
463
+ }
464
+
465
+ /**
466
+ * Summarise a fleet read against the target. `done` is true only when every
467
+ * seat is `current` — an unverifiable seat is never counted as done. Pure.
468
+ * @param {{seats:object[], nonSeats:object[]}} fleet
469
+ * @param {string} target
470
+ * @returns {object}
471
+ */
472
+ export function summarise(fleet, target) {
473
+ const rows = (fleet.seats || []).map((s) => ({ ...s, verdict: classifySeat(s, target) }));
474
+ const counts = { current: 0, stale: 0, unverifiable: 0 };
475
+ for (const r of rows) counts[r.verdict] += 1;
476
+ return {
477
+ target,
478
+ rows,
479
+ counts,
480
+ nonSeats: fleet.nonSeats || [],
481
+ stale: rows.filter((r) => r.verdict === "stale"),
482
+ unverifiable: rows.filter((r) => r.verdict === "unverifiable"),
483
+ readCarriesVersionField: fleet.readCarriesVersionField === true,
484
+ done: rows.length > 0 && counts.current === rows.length,
485
+ };
486
+ }
487
+
488
+ /** Human "27ms"/"18s"/"4m"/"2h" age. */
489
+ export function ageLabel(ms) {
490
+ if (!Number.isFinite(ms)) return "never";
491
+ if (ms < 1000) return `${Math.round(ms)}ms`;
492
+ const s = Math.round(ms / 1000);
493
+ if (s < 90) return `${s}s`;
494
+ const m = Math.round(s / 60);
495
+ if (m < 90) return `${m}m`;
496
+ const h = Math.round(m / 60);
497
+ if (h < 48) return `${h}h`;
498
+ return `${Math.round(h / 24)}d`;
499
+ }
500
+
501
+ /**
502
+ * Render the per-seat table. Pure (returns a string; prints nothing).
503
+ * @param {object} summary from {@link summarise}
504
+ * @param {{round?:number, elapsedMs?:number}} [o]
505
+ * @returns {string}
506
+ */
507
+ export function renderSeatTable(summary, o = {}) {
508
+ const head = ["SEAT", "ID", "BEAT", "REPORTS", "VERDICT"];
509
+ const body = summary.rows.map((r) => [
510
+ r.name,
511
+ r.slug,
512
+ ageLabel(r.lastBeatMs),
513
+ r.version ? (r.basis === "installed" ? `${r.version} (installed)` : r.version) : "unknown",
514
+ r.verdict,
515
+ ]);
516
+ const widths = head.map((h, i) => Math.max(h.length, ...body.map((row) => row[i].length), 0));
517
+ const line = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
518
+ const out = [];
519
+ const stamp = [
520
+ o.round ? `round ${o.round}` : null,
521
+ Number.isFinite(o.elapsedMs) ? `${ageLabel(o.elapsedMs)} elapsed` : null,
522
+ ].filter(Boolean).join(" · ");
523
+ out.push(`Fleet vs ${summary.target}${stamp ? ` (${stamp})` : ""}`);
524
+ out.push(line(head));
525
+ out.push(widths.map((w) => "-".repeat(w)).join(" "));
526
+ for (const row of body) out.push(line(row));
527
+ out.push(
528
+ `${summary.rows.length} seat(s): ${summary.counts.current} current · ${summary.counts.stale} stale · ${summary.counts.unverifiable} unverifiable` +
529
+ (summary.nonSeats.length ? ` (+${summary.nonSeats.length} agent(s) with no beating machine — not rollout targets)` : "")
530
+ );
531
+ return out.join("\n");
532
+ }
533
+
534
+ /** The closing sentence naming exactly which seats are behind. Pure. */
535
+ export function renderVerdict(summary) {
536
+ if (summary.done) return `All ${summary.rows.length} seat(s) report ${summary.target}.`;
537
+ const parts = [];
538
+ if (summary.stale.length) {
539
+ parts.push(
540
+ `${summary.stale.length} seat(s) still behind ${summary.target}: ` +
541
+ summary.stale.map((r) => `${r.name} (${r.slug}, reports ${r.version})`).join(", ")
542
+ );
543
+ }
544
+ if (summary.unverifiable.length) {
545
+ parts.push(
546
+ `${summary.unverifiable.length} seat(s) could not be verified — the org read carries no sdkVersion: ` +
547
+ summary.unverifiable.map((r) => `${r.name} (${r.slug})`).join(", ")
548
+ );
549
+ }
550
+ if (!summary.rows.length) parts.push("no beating seats found in the org directory — nothing to verify.");
551
+ return parts.join("\n");
552
+ }
553
+
554
+ /**
555
+ * What stage 3's exit code will be, and WHY — as data, so a caller can tell the
556
+ * three very different things a `3` means apart.
557
+ *
558
+ * This exists because of an asymmetry that is easy to mistake for a bug. An
559
+ * unverified seat is deliberately NOT counted as done, so a run can exit 3 with
560
+ * no seat known to be behind. That is the honest call and it is not going to
561
+ * change: "it beat, so it must be current" is exactly the substitution that hid
562
+ * a six-day-stale seat.
563
+ *
564
+ * What changed is why a seat goes unverified. It used to be every seat, because
565
+ * hq's directory read did not project the column; that read carries the
566
+ * identity fields now, so `unverifiable` is a statement about THE SEAT — it
567
+ * reports no version, which means a copied tree older than SDK 2.12. A caller
568
+ * that wants to branch on "is anything behind" still reads `reason`, not the
569
+ * exit code: `behind` and `unverifiable` are different problems with different
570
+ * fixes, and only the first is answered by waiting.
571
+ *
572
+ * Pure.
573
+ *
574
+ * @param {ReturnType<typeof summarise>|null|undefined} summary
575
+ * @returns {{code:number, reason:"verified"|"behind"|"unverifiable"|"empty-fleet"|"no-fleet-read", note:string|null}}
576
+ */
577
+ export function propagationOutcome(summary) {
578
+ if (!summary || !Array.isArray(summary.rows)) {
579
+ return {
580
+ code: 3,
581
+ reason: "no-fleet-read",
582
+ note: "No successful org read before the deadline — this says nothing about the fleet's versions.",
583
+ };
584
+ }
585
+ if (summary.done) return { code: 0, reason: "verified", note: null };
586
+ if (!summary.rows.length) {
587
+ return {
588
+ code: 3,
589
+ reason: "empty-fleet",
590
+ note: "No beating seats were found, so nothing was verified. An empty fleet is never 'done'.",
591
+ };
592
+ }
593
+ if (summary.counts.stale === 0 && summary.counts.unverifiable > 0) {
594
+ const head = "Exit 3 here does NOT mean a seat is behind — no seat is known to be. ";
595
+ const seatIsSilent =
596
+ "The directory read DOES project sdkVersion and these seats carry none, so the silence is theirs: a " +
597
+ "copied tree older than SDK 2.12, the release that first put the field on the beat. Waiting will not fix " +
598
+ "that — the code that would report the version is the code that is missing. Confirm it by checking " +
599
+ "whether those seats' beats also lack frontDoor/sessionLive (every 2.12+ collector emits those " +
600
+ "unconditionally); if so, the seat's autoupdater has not merged a release in weeks and someone has to " +
601
+ "reach the machine.";
602
+ const readIsBlind =
603
+ "No member in this read carried an sdkVersion field AT ALL — not even null — so the blindness is the " +
604
+ "READ's, not the fleet's, and this run says nothing about any seat's version. The projection lives in " +
605
+ "hq's src/server/rpc/reads.ts#directory; an hq old enough to predate it, or one deployed behind the " +
606
+ "branch that added it, answers exactly like this. Check what hq is actually serving before reading " +
607
+ "anything into the table above.";
608
+ return {
609
+ code: 3,
610
+ reason: "unverifiable",
611
+ note: head + (summary.readCarriesVersionField ? seatIsSilent : readIsBlind),
612
+ };
613
+ }
614
+ return {
615
+ code: 3,
616
+ reason: "behind",
617
+ note: `${summary.counts.stale} seat(s) are provably behind ${summary.target}.`,
618
+ };
619
+ }
620
+
621
+ // ---------------------------------------------------------------------------
622
+ // EFFECTS: shelling out, reading the org, sleeping
623
+ // ---------------------------------------------------------------------------
624
+
625
+ /**
626
+ * The only commands this tool may ever run. An allowlist, not a denylist: a
627
+ * denylist has to predict the name of the thing it is trying to stop, and
628
+ * `ssh` has plenty of spellings (`/usr/bin/ssh`, `sshpass`, a wrapper on PATH).
629
+ */
630
+ export const ROLLOUT_ALLOWED_COMMANDS = ["npm", "git"];
631
+
632
+ /**
633
+ * Commands this tool must never run. Remote execution is not a fallback. Kept
634
+ * as an explicit list because the refusal message should be able to say "this
635
+ * looks like remote execution" rather than only "not on the allowlist".
636
+ */
637
+ export const ROLLOUT_FORBIDDEN_COMMANDS = ["ssh", "scp", "rsync", "sshpass", "expect"];
638
+
639
+ /**
640
+ * Refuse any command that is not on {@link ROLLOUT_ALLOWED_COMMANDS}. Pure —
641
+ * returns the refusal rather than throwing it, so it can be tested without a
642
+ * process.
643
+ *
644
+ * This is the guard that actually holds. The structural test over this file's
645
+ * source can only see call sites it recognises, and a call site can be written
646
+ * not to look like one (`execSync("ssh …")`, `deps.exec(cmd, [])` where `cmd`
647
+ * is a variable). Everything still has to come through here to reach a process,
648
+ * so the check lives here, at runtime, on the value.
649
+ *
650
+ * @param {unknown} cmd
651
+ * @returns {string|null} the refusal, or null when the command is allowed
652
+ */
653
+ export function commandRefusal(cmd) {
654
+ if (typeof cmd !== "string" || !cmd.length) return `refusing to run a non-string command (${typeof cmd})`;
655
+ if (ROLLOUT_ALLOWED_COMMANDS.includes(cmd)) return null;
656
+ const base = cmd.split("/").pop() || cmd;
657
+ if (ROLLOUT_FORBIDDEN_COMMANDS.includes(base) || /\bssh\b/.test(cmd)) {
658
+ return `refusing to run "${cmd}": this tool never executes anything remotely — seats update themselves.`;
659
+ }
660
+ return `refusing to run "${cmd}": only ${ROLLOUT_ALLOWED_COMMANDS.join(" and ")} may be run from here.`;
661
+ }
662
+
663
+ /**
664
+ * Run a command, capturing output. The only process-spawning seam in this file;
665
+ * {@link commandRefusal} is applied to every command before it can reach a
666
+ * process, and a test asserts no call site violates it either.
667
+ *
668
+ * A refused command THROWS rather than returning `{ok:false}`: a call site
669
+ * asking for `ssh` is a defect in this file, not an expected failure of a
670
+ * rollout, and must not be swallowed by a caller's error handling.
671
+ */
672
+ export function runCommand(cmd, args, o = {}) {
673
+ const refusal = commandRefusal(cmd);
674
+ if (refusal) throw new Error(`rollout: ${refusal}`);
675
+ const r = spawnSync(cmd, args, {
676
+ cwd: o.cwd || REPO_ROOT,
677
+ encoding: "utf-8",
678
+ stdio: o.inherit ? "inherit" : "pipe",
679
+ env: process.env,
680
+ // Node's default maxBuffer is 1 MiB and `npm test` prints ~1.04 MiB of TAP.
681
+ // Exceeding it makes spawnSync KILL the child and report status null — the
682
+ // green suite arrived here as "npm test FAILED (exit -1)" and refused a
683
+ // sound release. A gate that cannot be run must never read as a gate that
684
+ // failed, so this is raised well past any plausible gate and the two
685
+ // outcomes are reported separately below.
686
+ maxBuffer: o.maxBuffer ?? 256 * 1024 * 1024,
687
+ });
688
+ // `error` set (ENOBUFS, ENOENT, a signal) means the command did not get to
689
+ // answer. `ran:false` says so; callers must not read it as a failing gate.
690
+ return {
691
+ ok: !r.error && r.status === 0,
692
+ ran: !r.error,
693
+ failure: r.error ? r.error.code || r.error.message : null,
694
+ code: r.status == null ? -1 : r.status,
695
+ stdout: (r.stdout || "").trim(),
696
+ stderr: (r.stderr || "").trim(),
697
+ };
698
+ }
699
+
700
+ const defaultDeps = {
701
+ exec: runCommand,
702
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
703
+ now: () => Date.now(),
704
+ log: (s) => process.stdout.write(`${s}\n`),
705
+ };
706
+
707
+ /** `npm whoami` → {ok,user?,error?}. Never prints or returns a token. */
708
+ export function npmWhoami(deps) {
709
+ const r = deps.exec("npm", ["whoami"]);
710
+ if (r.ok && r.stdout) return { ok: true, user: r.stdout.split("\n").pop().trim() };
711
+ const err = (r.stderr || r.stdout || "").split("\n").find((l) => /E401|401|ENEEDAUTH|error/i.test(l));
712
+ return { ok: false, error: (err || `exit ${r.code}`).trim() };
713
+ }
714
+
715
+ /**
716
+ * `npm view <pkg> version` → {ok,version,error?}. Needs no auth.
717
+ *
718
+ * `--prefer-online` is load-bearing, not tidiness. npm caches registry metadata
719
+ * and revalidates it lazily, so straight after a successful publish this read
720
+ * kept answering with the PREVIOUS version from the local cache: 2.18.4 went to
721
+ * the registry, and all ten read-lag retries reported 2.17.0 from ~/.npm while
722
+ * a fresh shell already saw 2.18.4. The run then declared the publish stage
723
+ * failed on a package it had just published correctly. The retry loop cannot
724
+ * out-wait a cache that is never re-fetched.
725
+ */
726
+ export function npmLatest(pkg, deps) {
727
+ const r = deps.exec("npm", ["view", "--prefer-online", pkg, "version"]);
728
+ if (r.ok && r.stdout) return { ok: true, version: r.stdout.split("\n").pop().trim() };
729
+ return { ok: false, version: null, error: ((r.stderr || r.stdout || `exit ${r.code}`).split("\n")[0] || "").trim() };
730
+ }
731
+
732
+ /** Working-tree cleanliness from `git status --porcelain`. */
733
+ export function gitWorktree(deps) {
734
+ const r = deps.exec("git", ["status", "--porcelain"]);
735
+ if (!r.ok) return { clean: false, dirty: [`git status failed: ${r.stderr || r.code}`] };
736
+ const dirty = r.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
737
+ return { clean: dirty.length === 0, dirty };
738
+ }
739
+
740
+ /** Whether `v<version>` exists locally and on the remote. */
741
+ export function gitTag(version, deps) {
742
+ const name = `v${version}`;
743
+ const local = deps.exec("git", ["tag", "--list", name]);
744
+ const remote = deps.exec("git", ["ls-remote", "--tags", "origin", name]);
745
+ return {
746
+ name,
747
+ local: local.ok && local.stdout.includes(name),
748
+ remote: remote.ok && remote.stdout.includes(`refs/tags/${name}`),
749
+ };
750
+ }
751
+
752
+ /**
753
+ * Read the fleet from the org directory — hq's server-stamped presence
754
+ * derivation, i.e. the same beats the human /fleet page paints. Fail-soft:
755
+ * a failed read yields `ok:false` and the caller keeps polling rather than
756
+ * declaring the fleet dark.
757
+ *
758
+ * @param {object} o - { agentRoot, client?, seatWindowMs? }
759
+ * @returns {Promise<{ok:boolean, error?:string, seats:object[], nonSeats:object[]}>}
760
+ */
761
+ /**
762
+ * Which enrolled agent directory this run reads the org through.
763
+ *
764
+ * This script lives in the SDK repo, which is NOT an agent seat: it has no
765
+ * config/org.yaml. Run from ~/maestro with nothing set, `loadOrgConfig(undefined)`
766
+ * threw `The "path" argument must be of type string`, the verify loop treated
767
+ * that as a transient read failure, and it retried ninety times over two hours
768
+ * before reporting nothing — about a fleet that had in fact taken the release.
769
+ *
770
+ * Order: an explicit --agent-root / AGENT_ROOT / AGENT_DIR, then the cwd if it
771
+ * is itself a seat, then the one enrolled directory under $HOME. Several
772
+ * candidates is ambiguity, not a default: it says so and asks for the flag.
773
+ *
774
+ * @returns {{ok:true, root:string, how:string} | {ok:false, error:string}}
775
+ */
776
+ export function resolveAgentRoot(explicit, deps = {}) {
777
+ const exists = deps.exists || ((p) => existsSync(p));
778
+ const home = deps.home || process.env.HOME || "";
779
+ const cwd = deps.cwd || process.cwd();
780
+ const isSeat = (dir) => Boolean(dir) && exists(join(dir, "config", "org.yaml"));
781
+
782
+ if (explicit) {
783
+ if (isSeat(explicit)) return { ok: true, root: explicit, how: "given" };
784
+ return { ok: false, error: `--agent-root ${explicit} has no config/org.yaml — that is not an enrolled agent directory.` };
785
+ }
786
+ if (isSeat(cwd)) return { ok: true, root: cwd, how: "cwd" };
787
+
788
+ const found = (deps.listHome || (() => { try { return readdirSync(home); } catch { return []; } }))()
789
+ .map((name) => join(home, name))
790
+ .filter(isSeat);
791
+ if (found.length === 1) return { ok: true, root: found[0], how: "discovered under $HOME" };
792
+ if (found.length === 0) {
793
+ return { ok: false, error: `no enrolled agent directory found (looked at ${cwd} and $HOME/*/config/org.yaml) — pass --agent-root <dir>.` };
794
+ }
795
+ return { ok: false, error: `several enrolled agent directories (${found.join(", ")}) — pass --agent-root <dir> to say which one reads the fleet.` };
796
+ }
797
+
798
+ export async function readFleet(o = {}) {
799
+ const client = o.client || (await import("../../lib/org/client.mjs"));
800
+ const root = resolveAgentRoot(o.agentRoot);
801
+ // `fatal` means polling cannot help: the fault is in how this run was invoked,
802
+ // not in a momentarily unreachable server. The loop stops on it.
803
+ if (!root.ok) return { ok: false, fatal: true, error: root.error, seats: [], nonSeats: [] };
804
+ let cfg;
805
+ try {
806
+ cfg = client.loadOrgConfig(root.root);
807
+ } catch (err) {
808
+ return { ok: false, fatal: true, error: `org config unreadable at ${root.root} (${err && err.message})`, seats: [], nonSeats: [] };
809
+ }
810
+ if (!client.isEnabled(cfg)) {
811
+ return { ok: false, fatal: true, error: `org integration is not enabled for ${root.root} — cannot read the fleet`, seats: [], nonSeats: [] };
812
+ }
813
+ let r;
814
+ try {
815
+ r = await client.read("directory", client.configFromAgent(cfg));
816
+ } catch (err) {
817
+ return { ok: false, error: `directory read threw (${err && err.message})`, seats: [], nonSeats: [] };
818
+ }
819
+ if (!r || !r.ok || !r.payload) {
820
+ return { ok: false, error: `directory read failed (${(r && r.error) || "no payload"})`, seats: [], nonSeats: [] };
821
+ }
822
+ const members = Array.isArray(r.payload) ? r.payload : r.payload.members || [];
823
+ return { ok: true, ...toSeats(members, { seatWindowMs: o.seatWindowMs }) };
824
+ }
825
+
826
+ // ---------------------------------------------------------------------------
827
+ // STAGES
828
+ // ---------------------------------------------------------------------------
829
+
830
+ /**
831
+ * Stage 1 — gather the facts (running the gates for real) and apply
832
+ * {@link evaluatePreflight}.
833
+ */
834
+ export function preflight(o) {
835
+ const deps = { ...defaultDeps, ...(o.deps || {}) };
836
+ const { name, version } = o.pkg;
837
+ deps.log(`\n── pre-flight ──────────────────────────────────────────────`);
838
+
839
+ const whoami = npmWhoami(deps);
840
+ deps.log(`npm whoami ${whoami.ok ? `ok (${whoami.user})` : `FAILED — ${whoami.error}`}`);
841
+
842
+ const registryLatest = o.registryLatest || npmLatest(name, deps);
843
+ deps.log(`registry @latest ${registryLatest.ok ? registryLatest.version : `unreadable — ${registryLatest.error}`} (local ${version})`);
844
+
845
+ const worktree = gitWorktree(deps);
846
+ deps.log(`working tree ${worktree.clean ? "clean" : `${worktree.dirty.length} uncommitted path(s)`}`);
847
+
848
+ const tag = gitTag(version, deps);
849
+ deps.log(`tag ${tag.name}${" ".repeat(Math.max(1, 16 - tag.name.length))}${tag.local ? (tag.remote ? "local + pushed" : "local only — NOT pushed") : "missing"}`);
850
+
851
+ // The cheap facts first. If any of them already blocks the publish, the two
852
+ // multi-minute gates are NOT run — there is nothing they could unblock, and
853
+ // an operator staring at a 401 should not wait three minutes to be told so.
854
+ // They are never SKIPPED on a run that could publish: see below.
855
+ const cheap = {
856
+ whoami,
857
+ localVersion: version,
858
+ registryLatest,
859
+ worktree,
860
+ tag,
861
+ check: { ok: true, code: 0 },
862
+ test: { ok: true, code: 0 },
863
+ };
864
+ const cheapVerdict = evaluatePreflight(cheap);
865
+ if (!cheapVerdict.ok) {
866
+ deps.log(`npm run check not run (publish already blocked)`);
867
+ deps.log(`npm test not run (publish already blocked)`);
868
+ return {
869
+ ok: false,
870
+ refusals: cheapVerdict.refusals,
871
+ notes: ["`npm run check` and `npm test` were not run: the blockers above stop the publish regardless. They are re-run in full on any run that could publish."],
872
+ facts: { whoami, registryLatest, worktree, tag, check: null, test: null },
873
+ };
874
+ }
875
+
876
+ // The gates are re-run, never trusted from a log: they must pass on the bytes
877
+ // about to be published, not on whatever was in the tree an hour ago.
878
+ deps.log(`npm run check running…`);
879
+ const check = deps.exec("npm", ["run", "check"], { inherit: Boolean(o.inheritGates) });
880
+ deps.log(`npm run check ${gateLine(check)}`);
881
+ deps.log(`npm test running…`);
882
+ const test = deps.exec("npm", ["test"], { inherit: Boolean(o.inheritGates) });
883
+ deps.log(`npm test ${gateLine(test)}`);
884
+
885
+ const verdict = evaluatePreflight({
886
+ ...cheap,
887
+ check: { ok: check.ok, ran: check.ran, failure: check.failure, code: check.code },
888
+ test: { ok: test.ok, ran: test.ran, failure: test.failure, code: test.code },
889
+ });
890
+ const notes = [];
891
+ const dirt = gateDirtNote(worktree.dirty, gitWorktree(deps).dirty);
892
+ if (dirt) {
893
+ notes.push(dirt);
894
+ deps.log(`note: ${dirt}`);
895
+ }
896
+ return { ...verdict, notes, facts: { whoami, registryLatest, worktree, tag, check, test } };
897
+ }
898
+
899
+ /**
900
+ * Stage 2 — publish, then poll the registry through the read lag.
901
+ * @returns {Promise<{ok:boolean, published:boolean, reason:string}>}
902
+ */
903
+ export async function publish(o) {
904
+ const deps = { ...defaultDeps, ...(o.deps || {}) };
905
+ const { name, version } = o.pkg;
906
+ deps.log(`\n── publish ─────────────────────────────────────────────────`);
907
+ if (o.dryRun) {
908
+ deps.log(`--dry-run: NOT publishing ${name}@${version}. Everything else ran for real.`);
909
+ return { ok: true, published: false, reason: "dry run" };
910
+ }
911
+ const r = deps.exec("npm", ["publish"], { inherit: true });
912
+ if (!r.ok) return { ok: false, published: false, reason: `npm publish failed (exit ${r.code})` };
913
+ deps.log(`published ${name}@${version} — waiting for the registry to serve it`);
914
+
915
+ const maxAttempts = Number.isFinite(o.registryAttempts) ? o.registryAttempts : 10;
916
+ const waitMs = Number.isFinite(o.registryWaitMs) ? o.registryWaitMs : 6000;
917
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
918
+ const read = npmLatest(name, deps);
919
+ const d = readLagDecision({ attempt, maxAttempts, target: version, read });
920
+ deps.log(` registry check ${attempt}/${maxAttempts}: ${d.reason}`);
921
+ if (d.action === "done") return { ok: true, published: true, reason: d.reason };
922
+ if (d.action === "fail") return { ok: false, published: true, reason: d.reason };
923
+ await deps.sleep(waitMs);
924
+ }
925
+ return { ok: false, published: true, reason: "registry never served the new version" };
926
+ }
927
+
928
+ /**
929
+ * Stage 3 — poll the org until every seat reports `target`, or the deadline
930
+ * passes. Prints the table every round.
931
+ * @returns {Promise<{ok:boolean, summary:object|null, rounds:number}>}
932
+ */
933
+ export async function verifyPropagation(o) {
934
+ const deps = { ...defaultDeps, ...(o.deps || {}) };
935
+ const target = o.target;
936
+ const started = deps.now();
937
+ const deadlineMs = Number.isFinite(o.deadlineMs) ? o.deadlineMs : 90 * 60 * 1000;
938
+ const intervalMs = Number.isFinite(o.intervalMs) ? o.intervalMs : 60 * 1000;
939
+ deps.log(`\n── verify propagation ──────────────────────────────────────`);
940
+ deps.log(`target ${target} · deadline ${ageLabel(deadlineMs)} · poll every ${ageLabel(intervalMs)}`);
941
+ deps.log(`seats update themselves from npm on their own hourly autoupdater; nothing is pushed from here.`);
942
+
943
+ let round = 0;
944
+ let summary = null;
945
+ let saidWhyUnknown = false;
946
+ for (;;) {
947
+ round += 1;
948
+ const fleet = await (o.readFleet || readFleet)({
949
+ agentRoot: o.agentRoot,
950
+ client: o.client,
951
+ seatWindowMs: o.seatWindowMs,
952
+ });
953
+ if (!fleet.ok && fleet.fatal) {
954
+ // Not transient: retrying changes nothing and the deadline would only
955
+ // delay the answer. Say what to do and stop.
956
+ deps.log(`\nfleet read cannot succeed as invoked — ${fleet.error}`);
957
+ return { ok: false, summary: null, rounds: round, fatal: true, error: fleet.error };
958
+ }
959
+ if (!fleet.ok) {
960
+ deps.log(`round ${round}: fleet read failed — ${fleet.error}`);
961
+ } else {
962
+ summary = summarise(fleet, target);
963
+ deps.log("");
964
+ deps.log(renderSeatTable(summary, { round, elapsedMs: deps.now() - started }));
965
+ if (!saidWhyUnknown && summary.counts.unverifiable > 0) {
966
+ saidWhyUnknown = true;
967
+ deps.log(
968
+ `\n ${propagationOutcome(summary).note}\n`
969
+ );
970
+ }
971
+ if (summary.done) {
972
+ deps.log(`\n${renderVerdict(summary)}`);
973
+ return { ok: true, summary, rounds: round };
974
+ }
975
+ }
976
+ const elapsed = deps.now() - started;
977
+ if (elapsed + intervalMs > deadlineMs) {
978
+ deps.log(`\ndeadline (${ageLabel(deadlineMs)}) reached.`);
979
+ if (summary) deps.log(renderVerdict(summary));
980
+ else deps.log("no successful fleet read before the deadline.");
981
+ return { ok: false, summary, rounds: round };
982
+ }
983
+ await deps.sleep(intervalMs);
984
+ }
985
+ }
986
+
987
+ // ---------------------------------------------------------------------------
988
+ // CLI
989
+ // ---------------------------------------------------------------------------
990
+
991
+ /**
992
+ * Flag names that must never be accepted — and whose VALUE must never be
993
+ * echoed. Deliberately wider than the flags this tool knows about: the point is
994
+ * not to enumerate our own options, it is that an operator who types a secret
995
+ * at this CLI must not find it in their scrollback (or in a tee'd log) after the
996
+ * refusal. `--token` and `--registry-token` and `--npmAuthToken` are all the
997
+ * same mistake.
998
+ */
999
+ const CREDENTIAL_FLAG = /(token|auth|pass|secret|otp|key|credential|cookie|session|bearer|api[-_]?k)/i;
1000
+
1001
+ /**
1002
+ * The flag name alone. Everything from the first `=` onwards is dropped
1003
+ * unread, so no code path downstream of parsing can echo a `--flag=value`
1004
+ * value — not the credential branch, not the unknown-argument branch.
1005
+ * @param {string} a
1006
+ * @returns {string}
1007
+ */
1008
+ function flagName(a) {
1009
+ const eq = a.indexOf("=");
1010
+ return eq === -1 ? a : a.slice(0, eq);
1011
+ }
1012
+
1013
+ /**
1014
+ * Parse argv. Pure. Rejects anything that smells like a credential, and never
1015
+ * puts an argument's VALUE into an error string — see {@link CREDENTIAL_FLAG}.
1016
+ * A bare (non-flag) argument is reported by shape and length only, because a
1017
+ * pasted secret most often arrives as one.
1018
+ */
1019
+ export function parseArgs(argv) {
1020
+ const o = {
1021
+ dryRun: false,
1022
+ verifyOnly: false,
1023
+ deadlineMs: 90 * 60 * 1000,
1024
+ intervalMs: 60 * 1000,
1025
+ seatWindowMs: DEFAULT_SEAT_WINDOW_MS,
1026
+ agentRoot: process.env.AGENT_ROOT || process.env.AGENT_DIR || undefined,
1027
+ json: false,
1028
+ help: false,
1029
+ errors: [],
1030
+ };
1031
+ for (let i = 0; i < argv.length; i++) {
1032
+ const a = argv[i];
1033
+ if (a === "--dry-run") o.dryRun = true;
1034
+ else if (a === "--verify-only") o.verifyOnly = true;
1035
+ else if (a === "--json") o.json = true;
1036
+ else if (a === "--help" || a === "-h") o.help = true;
1037
+ else if (a === "--deadline") o.deadlineMs = Math.max(0, Number(argv[++i]) * 60 * 1000);
1038
+ else if (a === "--interval") o.intervalMs = Math.max(1000, Number(argv[++i]) * 1000);
1039
+ else if (a === "--seat-window") o.seatWindowMs = Math.max(0, Number(argv[++i]) * 60 * 1000);
1040
+ else if (a === "--agent-root") o.agentRoot = argv[++i];
1041
+ else if (a.startsWith("-")) {
1042
+ const name = flagName(a);
1043
+ if (CREDENTIAL_FLAG.test(name)) {
1044
+ o.errors.push(`${name}: this tool never takes a credential as an argument — run \`npm login\` instead.`);
1045
+ // `--token secret` (space-separated) would otherwise reach the next
1046
+ // iteration as a bare argument. Swallow it so it is never echoed.
1047
+ if (a.indexOf("=") === -1 && i + 1 < argv.length && !argv[i + 1].startsWith("-")) i++;
1048
+ } else {
1049
+ o.errors.push(`unknown argument ${name}`);
1050
+ }
1051
+ } else {
1052
+ o.errors.push(`unknown argument: a bare value of ${a.length} character(s) — every option here starts with \`--\``);
1053
+ }
1054
+ }
1055
+ return o;
1056
+ }
1057
+
1058
+ const USAGE = `rollout — publish one SDK version and prove the fleet took it
1059
+
1060
+ node scripts/fleet/rollout.mjs [--dry-run] [--deadline <min>] [--interval <s>]
1061
+ [--seat-window <min>] [--agent-root <path>] [--json]
1062
+
1063
+ --dry-run run every gate for real, skip only the publish itself
1064
+ --verify-only skip pre-flight and publish; just watch the fleet take a version
1065
+ --deadline min how long to wait for the fleet (default 90)
1066
+ --interval s seconds between fleet polls (default 60)
1067
+ --seat-window m a member counts as a seat if it beat within this window (default 30)
1068
+ --json print the verdict as JSON: {code, reason, note, summary}
1069
+
1070
+ Exit: 0 verified · 1 pre-flight refusal · 2 publish/registry failure ·
1071
+ 3 deadline passed with seats behind or unverifiable.
1072
+
1073
+ UNVERIFIED IS NOT DONE. A seat that reports no version is never counted as
1074
+ current, so a run can exit 3 with nothing known to be behind. Branch on the
1075
+ --json 'reason' field ("verified" | "behind" | "unverifiable" | "empty-fleet" |
1076
+ "no-fleet-read"), not on the exit code: 'behind' is fixed by waiting,
1077
+ 'unverifiable' is a seat whose copied tree predates SDK 2.12 and is not.
1078
+
1079
+ Never ssh's anywhere and never accepts a credential as an argument — a
1080
+ credential-shaped flag is refused and its value is never echoed back.`;
1081
+
1082
+ /** @returns {Promise<number>} process exit code */
1083
+ export async function main(argv = process.argv.slice(2), injected = {}) {
1084
+ const deps = { ...defaultDeps, ...(injected.deps || {}) };
1085
+ const args = parseArgs(argv);
1086
+ if (args.help) {
1087
+ deps.log(USAGE);
1088
+ return 0;
1089
+ }
1090
+ if (args.errors.length) {
1091
+ for (const e of args.errors) deps.log(`rollout: ${e}`);
1092
+ return 1;
1093
+ }
1094
+ const pkg = injected.pkg || readLocalPackage();
1095
+ deps.log(`rollout ${pkg.name}@${pkg.version}${args.dryRun ? " [dry run]" : ""}`);
1096
+
1097
+ const latest = npmLatest(pkg.name, deps);
1098
+ const already = latest.ok && latest.version && compareVersions(latest.version, pkg.version) === 0;
1099
+
1100
+ if (args.verifyOnly) {
1101
+ deps.log(
1102
+ `--verify-only: not publishing. Registry @latest is ${latest.ok ? latest.version : `unreadable (${latest.error})`}; ` +
1103
+ `checking which seats are running ${pkg.version}.`
1104
+ );
1105
+ } else if (already) {
1106
+ deps.log(`already published — registry @latest is ${latest.version}. Verifying propagation.`);
1107
+ } else {
1108
+ const pre = preflight({ pkg, deps, registryLatest: latest, ...injected.preflight });
1109
+ if (!pre.ok) {
1110
+ deps.log(`\nREFUSING TO PUBLISH — ${pre.refusals.length} blocker(s):`);
1111
+ for (const r of pre.refusals) deps.log(` · ${r}`);
1112
+ for (const n of pre.notes || []) deps.log(` (${n})`);
1113
+ deps.log(`\nNothing was published and nothing was changed. Fix the blockers and re-run.`);
1114
+ return 1;
1115
+ }
1116
+ deps.log(`\npre-flight clear.`);
1117
+ const pub = await publish({ pkg, deps, dryRun: args.dryRun, ...injected.publishOpts });
1118
+ if (!pub.ok) {
1119
+ deps.log(`publish stage failed: ${pub.reason}`);
1120
+ return 2;
1121
+ }
1122
+ }
1123
+
1124
+ const v = await verifyPropagation({
1125
+ target: pkg.version,
1126
+ deps,
1127
+ agentRoot: args.agentRoot,
1128
+ deadlineMs: args.deadlineMs,
1129
+ intervalMs: args.intervalMs,
1130
+ seatWindowMs: args.seatWindowMs,
1131
+ readFleet: injected.readFleet,
1132
+ client: injected.client,
1133
+ });
1134
+ const outcome = propagationOutcome(v.summary);
1135
+ if (outcome.note) deps.log(`\n${outcome.note}`);
1136
+ if (args.json) deps.log(JSON.stringify({ ...outcome, summary: v.summary ?? null }, null, 2));
1137
+ // A failed verify never exits 0, whatever the outcome says.
1138
+ return v.ok ? 0 : outcome.code || 3;
1139
+ }
1140
+
1141
+ if (process.argv[1] && resolvePath(process.argv[1]) === resolvePath(__filename)) {
1142
+ main().then(
1143
+ (code) => { process.exitCode = code; },
1144
+ (err) => {
1145
+ process.stderr.write(`rollout failed: ${(err && err.stack) || err}\n`);
1146
+ process.exitCode = 2;
1147
+ }
1148
+ );
1149
+ }
1150
+
1151
+ export default { main, preflight, publish, verifyPropagation, readFleet, propagationOutcome, commandRefusal };