@cohortapp/agent-sdk 2.17.0 → 2.18.4

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 (526) 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.mjs +8 -6
  23. package/lib/collective/global-config.mjs +63 -1
  24. package/lib/collective/presence.mjs +142 -5
  25. package/lib/comms/send-gate.mjs +559 -1
  26. package/lib/diagnostics/alerts.mjs +49 -0
  27. package/lib/diagnostics/cadence-output-freshness.mjs +288 -0
  28. package/lib/engine/agents/definitions.mjs +343 -0
  29. package/lib/engine/agents/persist.mjs +275 -0
  30. package/lib/engine/agents/runtime.mjs +748 -0
  31. package/lib/engine/agents/usage.mjs +95 -0
  32. package/lib/engine/auth-status.mjs +139 -0
  33. package/lib/engine/budget.mjs +194 -0
  34. package/lib/engine/cli.mjs +1204 -0
  35. package/lib/engine/commands/index.mjs +269 -0
  36. package/lib/engine/context/budget.mjs +219 -0
  37. package/lib/engine/context/cache.mjs +125 -0
  38. package/lib/engine/context/child-env.mjs +215 -0
  39. package/lib/engine/context/compaction.mjs +342 -0
  40. package/lib/engine/context/images.mjs +90 -0
  41. package/lib/engine/context/instructions.mjs +327 -0
  42. package/lib/engine/context/lazy-instructions.mjs +169 -0
  43. package/lib/engine/context/manager.mjs +182 -0
  44. package/lib/engine/context/real-path.mjs +91 -0
  45. package/lib/engine/context/secret-values.mjs +163 -0
  46. package/lib/engine/context/settings.mjs +274 -0
  47. package/lib/engine/context/stream-input.mjs +159 -0
  48. package/lib/engine/guard.mjs +152 -0
  49. package/lib/engine/hooks.mjs +713 -0
  50. package/lib/engine/loop.mjs +560 -0
  51. package/lib/engine/mcp/client.mjs +254 -0
  52. package/lib/engine/mcp/config.mjs +301 -0
  53. package/lib/engine/mcp/http.mjs +201 -0
  54. package/lib/engine/mcp/index.mjs +146 -0
  55. package/lib/engine/mcp/jsonrpc.mjs +147 -0
  56. package/lib/engine/mcp/naming.mjs +66 -0
  57. package/lib/engine/mcp/resources.mjs +89 -0
  58. package/lib/engine/mcp/results.mjs +133 -0
  59. package/lib/engine/mcp/stdio.mjs +137 -0
  60. package/lib/engine/mcp/supervisor.mjs +116 -0
  61. package/lib/engine/messages.mjs +104 -0
  62. package/lib/engine/output/json.mjs +164 -0
  63. package/lib/engine/output/stream-json.mjs +266 -0
  64. package/lib/engine/permissions.mjs +845 -0
  65. package/lib/engine/process-identity.mjs +164 -0
  66. package/lib/engine/process-tree.mjs +551 -0
  67. package/lib/engine/prompt.mjs +60 -0
  68. package/lib/engine/session/store.mjs +299 -0
  69. package/lib/engine/session-runtime/args.mjs +97 -0
  70. package/lib/engine/session-runtime/host.mjs +143 -0
  71. package/lib/engine/session-runtime/inbox.mjs +122 -0
  72. package/lib/engine/session-runtime/notifications.mjs +129 -0
  73. package/lib/engine/session-runtime/registry.mjs +328 -0
  74. package/lib/engine/session-runtime/runner.mjs +344 -0
  75. package/lib/engine/session-runtime/socket.mjs +212 -0
  76. package/lib/engine/session-runtime/wakeup.mjs +115 -0
  77. package/lib/engine/skills/index.mjs +321 -0
  78. package/lib/engine/tools/bash-background.mjs +533 -0
  79. package/lib/engine/tools/bash.mjs +216 -0
  80. package/lib/engine/tools/edit.mjs +97 -0
  81. package/lib/engine/tools/glob.mjs +81 -0
  82. package/lib/engine/tools/grep.mjs +224 -0
  83. package/lib/engine/tools/index.mjs +84 -0
  84. package/lib/engine/tools/list-agents.mjs +32 -0
  85. package/lib/engine/tools/ls.mjs +127 -0
  86. package/lib/engine/tools/monitor.mjs +82 -0
  87. package/lib/engine/tools/notebook-edit.mjs +218 -0
  88. package/lib/engine/tools/read.mjs +103 -0
  89. package/lib/engine/tools/schedule-wakeup.mjs +45 -0
  90. package/lib/engine/tools/schema.mjs +144 -0
  91. package/lib/engine/tools/send-message.mjs +77 -0
  92. package/lib/engine/tools/session.mjs +70 -0
  93. package/lib/engine/tools/todo.mjs +144 -0
  94. package/lib/engine/tools/toolsearch.mjs +217 -0
  95. package/lib/engine/tools/walk.mjs +193 -0
  96. package/lib/engine/tools/web-switch.mjs +31 -0
  97. package/lib/engine/tools/webfetch-html.mjs +387 -0
  98. package/lib/engine/tools/webfetch-net.mjs +340 -0
  99. package/lib/engine/tools/webfetch.mjs +198 -0
  100. package/lib/engine/tools/websearch.mjs +91 -0
  101. package/lib/engine/tools/workflow.mjs +95 -0
  102. package/lib/engine/tools/write.mjs +76 -0
  103. package/lib/engine/tui/line-editor.mjs +137 -0
  104. package/lib/engine/tui/render.mjs +86 -0
  105. package/lib/engine/tui/tui.mjs +274 -0
  106. package/lib/engine/wire/anthropic-messages.mjs +263 -0
  107. package/lib/engine/wire/effort.mjs +36 -0
  108. package/lib/engine/wire/errors.mjs +496 -0
  109. package/lib/engine/wire/http.mjs +441 -0
  110. package/lib/engine/wire/index.mjs +76 -0
  111. package/lib/engine/wire/openai-chat.mjs +332 -0
  112. package/lib/engine/wire/prompt-cache.mjs +79 -0
  113. package/lib/engine/wire/search.mjs +140 -0
  114. package/lib/engine/wire/sse.mjs +114 -0
  115. package/lib/engine/wire/stall.mjs +349 -0
  116. package/lib/engine/wire/token-provider.mjs +175 -0
  117. package/lib/engine/wire/usage.mjs +192 -0
  118. package/lib/engine/workflow/host.mjs +524 -0
  119. package/lib/engine/workflow/journal.mjs +188 -0
  120. package/lib/engine/workflow/json-schema.mjs +171 -0
  121. package/lib/engine/workflow/meta.mjs +329 -0
  122. package/lib/engine/workflow/notifications.mjs +52 -0
  123. package/lib/engine/workflow/runtime.mjs +447 -0
  124. package/lib/engine/workflow/sandbox.mjs +534 -0
  125. package/lib/engine/workflow/worker.mjs +141 -0
  126. package/lib/engine/workflow/worktree.mjs +74 -0
  127. package/lib/execution/disposition.mjs +1 -1
  128. package/lib/execution/intake.mjs +10 -0
  129. package/lib/execution/surface-policy.mjs +15 -0
  130. package/lib/learning/curator.mjs +8 -6
  131. package/lib/learning/reflect.mjs +8 -6
  132. package/lib/model-router/catalog/cohort.yaml +137 -0
  133. package/lib/model-router/catalog.mjs +118 -1
  134. package/lib/model-router/failover.mjs +67 -16
  135. package/lib/model-router/llm-task.mjs +39 -3
  136. package/lib/model-router/resolve.mjs +89 -3
  137. package/lib/model-router/spawn.mjs +46 -47
  138. package/lib/model-router/taxonomy.mjs +126 -4
  139. package/lib/org/cost-sync.mjs +141 -11
  140. package/lib/org/inbound/broadcast.mjs +289 -0
  141. package/lib/org/inbound/collective.mjs +375 -0
  142. package/lib/org/inbound/directedness.mjs +96 -8
  143. package/lib/org/inbound/facts.mjs +78 -2
  144. package/lib/org/inbound/project.mjs +22 -0
  145. package/lib/org/inbound/surfaces.mjs +14 -0
  146. package/lib/org/llm-token.mjs +879 -0
  147. package/lib/org/mesh.mjs +61 -0
  148. package/lib/org/messaging.mjs +3 -1
  149. package/lib/org/protocol.checksum +1 -1
  150. package/lib/org/protocol.mjs +15 -0
  151. package/lib/org/quota.mjs +520 -0
  152. package/lib/org/tool-surface.mjs +104 -16
  153. package/lib/org/ui-parity.mjs +16 -1
  154. package/lib/org/work-ledger.mjs +37 -6
  155. package/lib/rate-guard.mjs +114 -1
  156. package/lib/resource-governor.mjs +41 -6
  157. package/lib/runtime/adapter.mjs +823 -0
  158. package/lib/runtime/child-env.mjs +191 -0
  159. package/lib/runtime/legacy-shell-guard.mjs +97 -0
  160. package/lib/runtime/seat-engine.mjs +162 -0
  161. package/lib/session/ask-ledger.mjs +271 -0
  162. package/lib/session/current-work.mjs +676 -0
  163. package/lib/session/feed-core.mjs +40 -3
  164. package/lib/session/launch-args.mjs +56 -4
  165. package/lib/session/status-summary.mjs +26 -9
  166. package/lib/session/upgrade-notice.mjs +42 -0
  167. package/lib/setup/claude-probe.mjs +117 -13
  168. package/lib/setup/enrich.mjs +13 -10
  169. package/lib/setup/sections/model.mjs +39 -13
  170. package/lib/telemetry/collect.mjs +208 -9
  171. package/lib/upgrade/ignored-drift.mjs +105 -0
  172. package/lib/voice/post-call-brief.mjs +30 -17
  173. package/package.json +13 -3
  174. package/plugins/maestro-skills/skills/board-work.md +5 -0
  175. package/plugins/maestro-skills/skills/inbound-triage.md +56 -15
  176. package/plugins/maestro-skills/skills/main-session.md +18 -7
  177. package/scripts/ci/check-tarball-fidelity.mjs +126 -2
  178. package/scripts/ci/run-tests.mjs +47 -19
  179. package/scripts/cohort-llm/api-key-helper.mjs +92 -0
  180. package/scripts/collective/hook-runner.mjs +29 -2
  181. package/scripts/continuous-monitor.sh +13 -0
  182. package/scripts/cost/track-claude-usage.mjs +15 -0
  183. package/scripts/daemon/agent-daemon.mjs +408 -20
  184. package/scripts/daemon/assurance.mjs +48 -12
  185. package/scripts/daemon/cadence-consumer.mjs +218 -68
  186. package/scripts/daemon/cadence-handlers.mjs +73 -4
  187. package/scripts/daemon/classifier.mjs +75 -26
  188. package/scripts/daemon/context-compiler.mjs +51 -37
  189. package/scripts/daemon/deliver.mjs +30 -1
  190. package/scripts/daemon/dispatcher.mjs +595 -149
  191. package/scripts/daemon/health.mjs +14 -1
  192. package/scripts/daemon/maestro-daemon.mjs +11 -0
  193. package/scripts/daemon/prompt-builder.mjs +24 -0
  194. package/scripts/daemon/responder.mjs +246 -79
  195. package/scripts/daemon/sdk-version.mjs +98 -16
  196. package/scripts/eval/probe-gateway.mjs +635 -0
  197. package/scripts/eval/replay/extract.mjs +270 -0
  198. package/scripts/eval/replay/grade.mjs +260 -0
  199. package/scripts/eval/replay/lib/config.mjs +50 -0
  200. package/scripts/eval/replay/lib/effects.mjs +65 -0
  201. package/scripts/eval/replay/lib/fixture.mjs +188 -0
  202. package/scripts/eval/replay/lib/judge.mjs +72 -0
  203. package/scripts/eval/replay/lib/redact.mjs +136 -0
  204. package/scripts/eval/replay/lib/sandbox.mjs +170 -0
  205. package/scripts/eval/replay/lib/schema-check.mjs +63 -0
  206. package/scripts/eval/replay/lib/transcript.mjs +76 -0
  207. package/scripts/eval/replay/mcp-replay-stub.mjs +101 -0
  208. package/scripts/eval/replay/report.mjs +185 -0
  209. package/scripts/eval/replay/run.mjs +404 -0
  210. package/scripts/fleet/rollout.mjs +1094 -0
  211. package/scripts/hooks/pre-send-audit.sh +36 -245
  212. package/scripts/hooks/pre-write-yaml-validate.mjs +275 -0
  213. package/scripts/hooks/validate-state-yaml.sh +190 -0
  214. package/scripts/huddle/huddle-llm.mjs +361 -0
  215. package/scripts/huddle/huddle-server.mjs +46 -121
  216. package/scripts/local-triggers/autoupdate.sh +448 -78
  217. package/scripts/local-triggers/run-trigger.sh +13 -0
  218. package/scripts/maintenance/pin-integrity.mjs +364 -0
  219. package/scripts/poll-slack-events.sh +41 -9
  220. package/scripts/poller/slack-socket-mode.mjs +28 -3
  221. package/scripts/session/supervisor.mjs +80 -13
  222. package/scripts/spawn-session.sh +13 -0
  223. package/bin/maestro.test.mjs +0 -1574
  224. package/lib/action-executor.test.mjs +0 -871
  225. package/lib/archetype.test.mjs +0 -132
  226. package/lib/assurance/plan-note.test.mjs +0 -234
  227. package/lib/assurance/room-budget.test.mjs +0 -486
  228. package/lib/assurance/tier.test.mjs +0 -174
  229. package/lib/autonomy.test.mjs +0 -66
  230. package/lib/backlog.test.mjs +0 -302
  231. package/lib/backup/policy.test.mjs +0 -305
  232. package/lib/budget-escalate.test.mjs +0 -232
  233. package/lib/budget-guard.envelope.test.mjs +0 -476
  234. package/lib/budget-guard.test.mjs +0 -427
  235. package/lib/cadence-bus-requeue.test.mjs +0 -83
  236. package/lib/cadence-bus-schedule.test.mjs +0 -194
  237. package/lib/cadence-bus.test.mjs +0 -720
  238. package/lib/cadences.test.mjs +0 -230
  239. package/lib/capability/inventory.test.mjs +0 -232
  240. package/lib/capability.test.mjs +0 -78
  241. package/lib/channels/base-adapter.test.mjs +0 -590
  242. package/lib/channels/channels.test.mjs +0 -371
  243. package/lib/channels/contract.test.mjs +0 -162
  244. package/lib/channels/inbox-item.test.mjs +0 -368
  245. package/lib/channels/orgmail/adapter.test.mjs +0 -448
  246. package/lib/channels/pairing.test.mjs +0 -270
  247. package/lib/channels/repeat-suppressor.test.mjs +0 -134
  248. package/lib/channels/slack-adapter.test.mjs +0 -212
  249. package/lib/channels/telegram-adapter.test.mjs +0 -306
  250. package/lib/channels/voice/adapter.test.mjs +0 -278
  251. package/lib/channels/whatsapp/adapter-baileys.test.mjs +0 -359
  252. package/lib/channels/whatsapp/baileys-typing.test.mjs +0 -154
  253. package/lib/charter.test.mjs +0 -89
  254. package/lib/claude-bin.test.mjs +0 -131
  255. package/lib/cli/board.test.mjs +0 -227
  256. package/lib/cli/design.test.mjs +0 -270
  257. package/lib/cli/doctor-checks.test.mjs +0 -336
  258. package/lib/cli/global-setup-extras.test.mjs +0 -462
  259. package/lib/cli/inbox.test.mjs +0 -230
  260. package/lib/cli/session-ack.test.mjs +0 -63
  261. package/lib/cli/session.test.mjs +0 -613
  262. package/lib/collective/capture.test.mjs +0 -121
  263. package/lib/collective/cards.test.mjs +0 -114
  264. package/lib/collective/config.test.mjs +0 -123
  265. package/lib/collective/global-config.test.mjs +0 -220
  266. package/lib/collective/global-skills.test.mjs +0 -126
  267. package/lib/collective/presence.test.mjs +0 -95
  268. package/lib/collective/recall.test.mjs +0 -116
  269. package/lib/collective/vendor-skills.test.mjs +0 -306
  270. package/lib/comms/send-gate.test.mjs +0 -770
  271. package/lib/comms.test.mjs +0 -41
  272. package/lib/context/budget.test.mjs +0 -252
  273. package/lib/context/history-scope.test.mjs +0 -79
  274. package/lib/cost/ledger-row.test.mjs +0 -183
  275. package/lib/design/design-md.test.mjs +0 -318
  276. package/lib/design/fixtures/DESIGN.golden.md +0 -238
  277. package/lib/design/fixtures/PRODUCT.golden.md +0 -67
  278. package/lib/design/fixtures/foundation.json +0 -133
  279. package/lib/design/refresh-gate.test.mjs +0 -144
  280. package/lib/design/write.test.mjs +0 -241
  281. package/lib/diagnostics/alerts.test.mjs +0 -318
  282. package/lib/diagnostics/backup-freshness.test.mjs +0 -185
  283. package/lib/diagnostics/counters.test.mjs +0 -206
  284. package/lib/diagnostics/events.test.mjs +0 -290
  285. package/lib/diagnostics/otel.test.mjs +0 -196
  286. package/lib/diagnostics/trace.test.mjs +0 -251
  287. package/lib/env-compat.test.mjs +0 -104
  288. package/lib/execution/disposition.test.mjs +0 -553
  289. package/lib/execution/drive.test.mjs +0 -270
  290. package/lib/execution/effects.test.mjs +0 -344
  291. package/lib/execution/intake.test.mjs +0 -389
  292. package/lib/execution/journal.test.mjs +0 -261
  293. package/lib/execution/match.test.mjs +0 -235
  294. package/lib/execution/pipeline.test.mjs +0 -392
  295. package/lib/execution/route.test.mjs +0 -186
  296. package/lib/execution/surface-policy.test.mjs +0 -162
  297. package/lib/fs-atomic.test.mjs +0 -72
  298. package/lib/fs-ownership.test.mjs +0 -158
  299. package/lib/goals/admission.test.mjs +0 -164
  300. package/lib/goals/classify.test.mjs +0 -167
  301. package/lib/goals/collaborate.test.mjs +0 -336
  302. package/lib/goals/gaps.test.mjs +0 -284
  303. package/lib/goals/loop.test.mjs +0 -845
  304. package/lib/hooks/bus.test.mjs +0 -387
  305. package/lib/identity/persona.test.mjs +0 -142
  306. package/lib/kpi-sensors.test.mjs +0 -278
  307. package/lib/kpi.test.mjs +0 -244
  308. package/lib/learning/config.test.mjs +0 -75
  309. package/lib/learning/counters.test.mjs +0 -69
  310. package/lib/learning/curator-consolidate.test.mjs +0 -238
  311. package/lib/learning/curator.test.mjs +0 -106
  312. package/lib/learning/reflect.test.mjs +0 -0
  313. package/lib/learning/session-index.test.mjs +0 -125
  314. package/lib/learning/skill-writer.test.mjs +0 -210
  315. package/lib/mandate/audit.test.mjs +0 -195
  316. package/lib/mandate/contract.test.mjs +0 -185
  317. package/lib/mandate/derive.test.mjs +0 -274
  318. package/lib/mandate/model.test.mjs +0 -164
  319. package/lib/mandate/refresh.test.mjs +0 -389
  320. package/lib/mcp/server.test.mjs +0 -426
  321. package/lib/model-router/auth-profiles.test.mjs +0 -580
  322. package/lib/model-router/catalog.test.mjs +0 -385
  323. package/lib/model-router/economics.test.mjs +0 -438
  324. package/lib/model-router/failover.test.mjs +0 -439
  325. package/lib/model-router/health.test.mjs +0 -338
  326. package/lib/model-router/integration-coverage.test.mjs +0 -831
  327. package/lib/model-router/integration.test.mjs +0 -564
  328. package/lib/model-router/ledger.test.mjs +0 -415
  329. package/lib/model-router/llm-task.test.mjs +0 -392
  330. package/lib/model-router/org-credentials.test.mjs +0 -265
  331. package/lib/model-router/pricing-refresh.test.mjs +0 -286
  332. package/lib/model-router/reconcile.test.mjs +0 -316
  333. package/lib/model-router/repair.test.mjs +0 -180
  334. package/lib/model-router/spawn.test.mjs +0 -446
  335. package/lib/model-router/taxonomy.test.mjs +0 -410
  336. package/lib/model-router.test.mjs +0 -1207
  337. package/lib/org/activity.test.mjs +0 -134
  338. package/lib/org/approvals.test.mjs +0 -216
  339. package/lib/org/awareness.test.mjs +0 -159
  340. package/lib/org/board-mine-cache.test.mjs +0 -53
  341. package/lib/org/board.test.mjs +0 -187
  342. package/lib/org/bootstrap-context.test.mjs +0 -153
  343. package/lib/org/client.test.mjs +0 -1206
  344. package/lib/org/cohort-client.test.mjs +0 -126
  345. package/lib/org/cost-sync.test.mjs +0 -153
  346. package/lib/org/doctor.test.mjs +0 -346
  347. package/lib/org/engagement-ledger.test.mjs +0 -112
  348. package/lib/org/engagement.test.mjs +0 -739
  349. package/lib/org/handoff.test.mjs +0 -269
  350. package/lib/org/inbound/directedness.test.mjs +0 -668
  351. package/lib/org/inbound/facts.test.mjs +0 -471
  352. package/lib/org/inbound/hydrate.test.mjs +0 -908
  353. package/lib/org/inbound/index.test.mjs +0 -429
  354. package/lib/org/inbound/project.test.mjs +0 -287
  355. package/lib/org/integration-tools.test.mjs +0 -160
  356. package/lib/org/keys.test.mjs +0 -92
  357. package/lib/org/knowledge.test.mjs +0 -326
  358. package/lib/org/leases.test.mjs +0 -235
  359. package/lib/org/mesh-directives.test.mjs +0 -110
  360. package/lib/org/mesh-integration.test.mjs +0 -127
  361. package/lib/org/mesh.test.mjs +0 -400
  362. package/lib/org/messaging.test.mjs +0 -471
  363. package/lib/org/param-contract.test.mjs +0 -477
  364. package/lib/org/policy.test.mjs +0 -237
  365. package/lib/org/protocol.checksum.test.mjs +0 -90
  366. package/lib/org/protocol.test.mjs +0 -323
  367. package/lib/org/push.test.mjs +0 -792
  368. package/lib/org/registry.test.mjs +0 -100
  369. package/lib/org/resource-tools.test.mjs +0 -361
  370. package/lib/org/tool-access.test.mjs +0 -144
  371. package/lib/org/tool-surface-integration.test.mjs +0 -120
  372. package/lib/org/tool-surface.test.mjs +0 -1268
  373. package/lib/org/typing.test.mjs +0 -291
  374. package/lib/org/ui-parity.test.mjs +0 -560
  375. package/lib/org/verify.test.mjs +0 -194
  376. package/lib/org/work-ledger.test.mjs +0 -273
  377. package/lib/plan/adoption-e2e.test.mjs +0 -366
  378. package/lib/plan/budget-enforcement.test.mjs +0 -400
  379. package/lib/plan/compile.test.mjs +0 -382
  380. package/lib/plan/emit.test.mjs +0 -269
  381. package/lib/plan/explain.test.mjs +0 -188
  382. package/lib/prompts/parallelism.test.mjs +0 -177
  383. package/lib/rag/rag.test.mjs +0 -505
  384. package/lib/rate-guard.test.mjs +0 -272
  385. package/lib/reactive-gate.test.mjs +0 -57
  386. package/lib/render.test.mjs +0 -68
  387. package/lib/resource-governor.test.mjs +0 -488
  388. package/lib/scheduling/dynamic-jobs.test.mjs +0 -344
  389. package/lib/scheduling/jitter.test.mjs +0 -140
  390. package/lib/secrets/broker.test.mjs +0 -280
  391. package/lib/secrets/providers.test.mjs +0 -274
  392. package/lib/security/audit-engine.test.mjs +0 -424
  393. package/lib/security/coerce-args.test.mjs +0 -281
  394. package/lib/security/dangerous-tools.test.mjs +0 -68
  395. package/lib/security/external-content.test.mjs +0 -84
  396. package/lib/security/redact.test.mjs +0 -441
  397. package/lib/security/secret-equal.test.mjs +0 -55
  398. package/lib/session/config.test.mjs +0 -92
  399. package/lib/session/feed-core.test.mjs +0 -198
  400. package/lib/session/first-run.test.mjs +0 -121
  401. package/lib/session/frontdoor.test.mjs +0 -205
  402. package/lib/session/handoffs.test.mjs +0 -183
  403. package/lib/session/identity.test.mjs +0 -180
  404. package/lib/session/inbox-claims.test.mjs +0 -286
  405. package/lib/session/launch-args.test.mjs +0 -157
  406. package/lib/session/liveness.test.mjs +0 -100
  407. package/lib/session/status-summary.test.mjs +0 -118
  408. package/lib/session-permissions.test.mjs +0 -120
  409. package/lib/setup/claude-probe.test.mjs +0 -187
  410. package/lib/setup/completeness.test.mjs +0 -110
  411. package/lib/setup/context-pack.test.mjs +0 -89
  412. package/lib/setup/enrich.test.mjs +0 -115
  413. package/lib/setup/enroll-from-cohort.test.mjs +0 -300
  414. package/lib/setup/integration.test.mjs +0 -162
  415. package/lib/setup/io.test.mjs +0 -77
  416. package/lib/setup/runner.test.mjs +0 -132
  417. package/lib/setup/sections/identity.test.mjs +0 -234
  418. package/lib/setup/sections/inventory.test.mjs +0 -198
  419. package/lib/setup/sections/learning.test.mjs +0 -81
  420. package/lib/setup/sections/mandate.test.mjs +0 -388
  421. package/lib/setup/sections/messaging.test.mjs +0 -127
  422. package/lib/setup/sections/model.test.mjs +0 -240
  423. package/lib/setup/sections/org.test.mjs +0 -346
  424. package/lib/setup/sections/orgmail.test.mjs +0 -118
  425. package/lib/setup/sections/recovery.test.mjs +0 -98
  426. package/lib/setup/sections/subagents.test.mjs +0 -429
  427. package/lib/setup/sections/verify.test.mjs +0 -175
  428. package/lib/setup/sot.test.mjs +0 -81
  429. package/lib/setup/state.test.mjs +0 -115
  430. package/lib/singleton.test.mjs +0 -151
  431. package/lib/subagents/cli.test.mjs +0 -389
  432. package/lib/subagents/client.test.mjs +0 -309
  433. package/lib/subagents/gap.test.mjs +0 -234
  434. package/lib/subagents/lock.test.mjs +0 -248
  435. package/lib/subagents/manifest.test.mjs +0 -175
  436. package/lib/subagents/refs.test.mjs +0 -204
  437. package/lib/subagents/resolve.test.mjs +0 -422
  438. package/lib/subagents/schema.test.mjs +0 -328
  439. package/lib/telemetry/alerts.test.mjs +0 -109
  440. package/lib/telemetry/collect.test.mjs +0 -1274
  441. package/lib/tool-definitions-integration.test.mjs +0 -83
  442. package/lib/tool-definitions.test.mjs +0 -437
  443. package/lib/upgrade/global-refresh.test.mjs +0 -65
  444. package/lib/upgrade/launchd-reconcile.test.mjs +0 -272
  445. package/lib/upgrade/post-steps.test.mjs +0 -200
  446. package/lib/upgrade/verify.test.mjs +0 -164
  447. package/lib/util/fetch-timeout.test.mjs +0 -202
  448. package/lib/util/reconnect.test.mjs +0 -369
  449. package/lib/util/unhandled.test.mjs +0 -216
  450. package/lib/voice/outbound.test.mjs +0 -69
  451. package/lib/voice/session-rotation.test.mjs +0 -114
  452. package/lib/voice/stt.test.mjs +0 -226
  453. package/lib/voice/voice.test.mjs +0 -990
  454. package/scripts/cadence/enqueue-cadence-tick.test.mjs +0 -187
  455. package/scripts/ci/check-docs-accuracy.test.mjs +0 -409
  456. package/scripts/ci/check-durable-write-seam.test.mjs +0 -90
  457. package/scripts/ci/check-no-build-artifacts.test.mjs +0 -71
  458. package/scripts/ci/check-no-residual-identity.test.mjs +0 -202
  459. package/scripts/ci/check-skill-packs.test.mjs +0 -495
  460. package/scripts/ci/check-subagent-frontmatter.test.mjs +0 -124
  461. package/scripts/ci/check.test.mjs +0 -194
  462. package/scripts/ci/conformance-org-api.test.mjs +0 -425
  463. package/scripts/cloud-relay/voice/relay-identity.test.mjs +0 -96
  464. package/scripts/collective/hook-runner.test.mjs +0 -173
  465. package/scripts/cost/fleet-digest.test.mjs +0 -207
  466. package/scripts/cost/track-claude-usage-pricing.test.mjs +0 -183
  467. package/scripts/cost/track-claude-usage.test.mjs +0 -148
  468. package/scripts/daemon/agent-daemon-board-mine.test.mjs +0 -96
  469. package/scripts/daemon/agent-daemon-design.test.mjs +0 -238
  470. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +0 -60
  471. package/scripts/daemon/agent-daemon.test.mjs +0 -995
  472. package/scripts/daemon/assurance-e2e.test.mjs +0 -613
  473. package/scripts/daemon/assurance.test.mjs +0 -1791
  474. package/scripts/daemon/board-mirror.test.mjs +0 -165
  475. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +0 -393
  476. package/scripts/daemon/cadence-consumer-governance.test.mjs +0 -276
  477. package/scripts/daemon/cadence-consumer.test.mjs +0 -776
  478. package/scripts/daemon/cadence-handlers.test.mjs +0 -837
  479. package/scripts/daemon/classifier-identity.test.mjs +0 -137
  480. package/scripts/daemon/classifier.test.mjs +0 -266
  481. package/scripts/daemon/classify-kind.test.mjs +0 -40
  482. package/scripts/daemon/context-compiler.test.mjs +0 -406
  483. package/scripts/daemon/deliver.test.mjs +0 -564
  484. package/scripts/daemon/dispatcher-cooldown.test.mjs +0 -122
  485. package/scripts/daemon/dispatcher-governance.test.mjs +0 -1013
  486. package/scripts/daemon/dispatcher-resume.test.mjs +0 -166
  487. package/scripts/daemon/dispatcher-session-continuity.test.mjs +0 -365
  488. package/scripts/daemon/execution-ladder.test.mjs +0 -470
  489. package/scripts/daemon/goal-steward-cadence.test.mjs +0 -312
  490. package/scripts/daemon/inbox-deferral-session.test.mjs +0 -49
  491. package/scripts/daemon/inbox-deferral.test.mjs +0 -336
  492. package/scripts/daemon/inbox-wake.test.mjs +0 -199
  493. package/scripts/daemon/integration.test.mjs +0 -149
  494. package/scripts/daemon/lib/self-echo.test.mjs +0 -153
  495. package/scripts/daemon/lib/session-router.test.mjs +0 -554
  496. package/scripts/daemon/prompt-builder-preamble.test.mjs +0 -210
  497. package/scripts/daemon/prompt-builder.test.mjs +0 -556
  498. package/scripts/daemon/responder-cost.test.mjs +0 -68
  499. package/scripts/daemon/responder-history.test.mjs +0 -221
  500. package/scripts/daemon/sdk-version.test.mjs +0 -31
  501. package/scripts/daemon/session-lock.test.mjs +0 -252
  502. package/scripts/daemon/session-outcomes.test.mjs +0 -533
  503. package/scripts/daemon/typing-registry.test.mjs +0 -102
  504. package/scripts/hooks/pre-send-audit.test.mjs +0 -354
  505. package/scripts/huddle/huddle-prompt.test.mjs +0 -176
  506. package/scripts/local-triggers/autoupdate.test.mjs +0 -518
  507. package/scripts/local-triggers/generate-plists.test.mjs +0 -456
  508. package/scripts/media-generation/brand-clause.test.mjs +0 -135
  509. package/scripts/org/send-orgmail.first-contact.test.mjs +0 -102
  510. package/scripts/poller/inbox-privilege-injection.test.mjs +0 -167
  511. package/scripts/poller/inbox-scan-poller.test.mjs +0 -295
  512. package/scripts/poller/lib/cloud-relay-dedup.test.mjs +0 -133
  513. package/scripts/poller/slack-socket-mode.test.mjs +0 -805
  514. package/scripts/poller-launchd/install.test.mjs +0 -243
  515. package/scripts/restore-from-backup.test.mjs +0 -181
  516. package/scripts/session/feed.test.mjs +0 -196
  517. package/scripts/session/supervisor-sh.test.mjs +0 -218
  518. package/scripts/session/supervisor.test.mjs +0 -482
  519. package/scripts/setup/configure-macos.test.mjs +0 -306
  520. package/scripts/setup/gen-subagent-manifest.test.mjs +0 -124
  521. package/scripts/setup/generate-agent-package-json.test.mjs +0 -143
  522. package/scripts/setup/generate-capability.test.mjs +0 -134
  523. package/scripts/setup/init-agent.test.mjs +0 -370
  524. package/scripts/setup/init-skill-marketplace.test.mjs +0 -193
  525. package/scripts/vendor/sync-skill-packs.test.mjs +0 -103
  526. package/scripts/watchdog/memory-watchdog.test.mjs +0 -64
@@ -0,0 +1,1094 @@
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 { readFileSync } 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
+ /** `npm view <pkg> version` → {ok,version,error?}. Needs no auth. */
716
+ export function npmLatest(pkg, deps) {
717
+ const r = deps.exec("npm", ["view", pkg, "version"]);
718
+ if (r.ok && r.stdout) return { ok: true, version: r.stdout.split("\n").pop().trim() };
719
+ return { ok: false, version: null, error: ((r.stderr || r.stdout || `exit ${r.code}`).split("\n")[0] || "").trim() };
720
+ }
721
+
722
+ /** Working-tree cleanliness from `git status --porcelain`. */
723
+ export function gitWorktree(deps) {
724
+ const r = deps.exec("git", ["status", "--porcelain"]);
725
+ if (!r.ok) return { clean: false, dirty: [`git status failed: ${r.stderr || r.code}`] };
726
+ const dirty = r.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
727
+ return { clean: dirty.length === 0, dirty };
728
+ }
729
+
730
+ /** Whether `v<version>` exists locally and on the remote. */
731
+ export function gitTag(version, deps) {
732
+ const name = `v${version}`;
733
+ const local = deps.exec("git", ["tag", "--list", name]);
734
+ const remote = deps.exec("git", ["ls-remote", "--tags", "origin", name]);
735
+ return {
736
+ name,
737
+ local: local.ok && local.stdout.includes(name),
738
+ remote: remote.ok && remote.stdout.includes(`refs/tags/${name}`),
739
+ };
740
+ }
741
+
742
+ /**
743
+ * Read the fleet from the org directory — hq's server-stamped presence
744
+ * derivation, i.e. the same beats the human /fleet page paints. Fail-soft:
745
+ * a failed read yields `ok:false` and the caller keeps polling rather than
746
+ * declaring the fleet dark.
747
+ *
748
+ * @param {object} o - { agentRoot, client?, seatWindowMs? }
749
+ * @returns {Promise<{ok:boolean, error?:string, seats:object[], nonSeats:object[]}>}
750
+ */
751
+ export async function readFleet(o = {}) {
752
+ const client = o.client || (await import("../../lib/org/client.mjs"));
753
+ let cfg;
754
+ try {
755
+ cfg = client.loadOrgConfig(o.agentRoot);
756
+ } catch (err) {
757
+ return { ok: false, error: `org config unreadable (${err && err.message})`, seats: [], nonSeats: [] };
758
+ }
759
+ if (!client.isEnabled(cfg)) {
760
+ return { ok: false, error: "org integration is not enabled for this seat — cannot read the fleet", seats: [], nonSeats: [] };
761
+ }
762
+ let r;
763
+ try {
764
+ r = await client.read("directory", client.configFromAgent(cfg));
765
+ } catch (err) {
766
+ return { ok: false, error: `directory read threw (${err && err.message})`, seats: [], nonSeats: [] };
767
+ }
768
+ if (!r || !r.ok || !r.payload) {
769
+ return { ok: false, error: `directory read failed (${(r && r.error) || "no payload"})`, seats: [], nonSeats: [] };
770
+ }
771
+ const members = Array.isArray(r.payload) ? r.payload : r.payload.members || [];
772
+ return { ok: true, ...toSeats(members, { seatWindowMs: o.seatWindowMs }) };
773
+ }
774
+
775
+ // ---------------------------------------------------------------------------
776
+ // STAGES
777
+ // ---------------------------------------------------------------------------
778
+
779
+ /**
780
+ * Stage 1 — gather the facts (running the gates for real) and apply
781
+ * {@link evaluatePreflight}.
782
+ */
783
+ export function preflight(o) {
784
+ const deps = { ...defaultDeps, ...(o.deps || {}) };
785
+ const { name, version } = o.pkg;
786
+ deps.log(`\n── pre-flight ──────────────────────────────────────────────`);
787
+
788
+ const whoami = npmWhoami(deps);
789
+ deps.log(`npm whoami ${whoami.ok ? `ok (${whoami.user})` : `FAILED — ${whoami.error}`}`);
790
+
791
+ const registryLatest = o.registryLatest || npmLatest(name, deps);
792
+ deps.log(`registry @latest ${registryLatest.ok ? registryLatest.version : `unreadable — ${registryLatest.error}`} (local ${version})`);
793
+
794
+ const worktree = gitWorktree(deps);
795
+ deps.log(`working tree ${worktree.clean ? "clean" : `${worktree.dirty.length} uncommitted path(s)`}`);
796
+
797
+ const tag = gitTag(version, deps);
798
+ deps.log(`tag ${tag.name}${" ".repeat(Math.max(1, 16 - tag.name.length))}${tag.local ? (tag.remote ? "local + pushed" : "local only — NOT pushed") : "missing"}`);
799
+
800
+ // The cheap facts first. If any of them already blocks the publish, the two
801
+ // multi-minute gates are NOT run — there is nothing they could unblock, and
802
+ // an operator staring at a 401 should not wait three minutes to be told so.
803
+ // They are never SKIPPED on a run that could publish: see below.
804
+ const cheap = {
805
+ whoami,
806
+ localVersion: version,
807
+ registryLatest,
808
+ worktree,
809
+ tag,
810
+ check: { ok: true, code: 0 },
811
+ test: { ok: true, code: 0 },
812
+ };
813
+ const cheapVerdict = evaluatePreflight(cheap);
814
+ if (!cheapVerdict.ok) {
815
+ deps.log(`npm run check not run (publish already blocked)`);
816
+ deps.log(`npm test not run (publish already blocked)`);
817
+ return {
818
+ ok: false,
819
+ refusals: cheapVerdict.refusals,
820
+ 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."],
821
+ facts: { whoami, registryLatest, worktree, tag, check: null, test: null },
822
+ };
823
+ }
824
+
825
+ // The gates are re-run, never trusted from a log: they must pass on the bytes
826
+ // about to be published, not on whatever was in the tree an hour ago.
827
+ deps.log(`npm run check running…`);
828
+ const check = deps.exec("npm", ["run", "check"], { inherit: Boolean(o.inheritGates) });
829
+ deps.log(`npm run check ${gateLine(check)}`);
830
+ deps.log(`npm test running…`);
831
+ const test = deps.exec("npm", ["test"], { inherit: Boolean(o.inheritGates) });
832
+ deps.log(`npm test ${gateLine(test)}`);
833
+
834
+ const verdict = evaluatePreflight({
835
+ ...cheap,
836
+ check: { ok: check.ok, ran: check.ran, failure: check.failure, code: check.code },
837
+ test: { ok: test.ok, ran: test.ran, failure: test.failure, code: test.code },
838
+ });
839
+ const notes = [];
840
+ const dirt = gateDirtNote(worktree.dirty, gitWorktree(deps).dirty);
841
+ if (dirt) {
842
+ notes.push(dirt);
843
+ deps.log(`note: ${dirt}`);
844
+ }
845
+ return { ...verdict, notes, facts: { whoami, registryLatest, worktree, tag, check, test } };
846
+ }
847
+
848
+ /**
849
+ * Stage 2 — publish, then poll the registry through the read lag.
850
+ * @returns {Promise<{ok:boolean, published:boolean, reason:string}>}
851
+ */
852
+ export async function publish(o) {
853
+ const deps = { ...defaultDeps, ...(o.deps || {}) };
854
+ const { name, version } = o.pkg;
855
+ deps.log(`\n── publish ─────────────────────────────────────────────────`);
856
+ if (o.dryRun) {
857
+ deps.log(`--dry-run: NOT publishing ${name}@${version}. Everything else ran for real.`);
858
+ return { ok: true, published: false, reason: "dry run" };
859
+ }
860
+ const r = deps.exec("npm", ["publish"], { inherit: true });
861
+ if (!r.ok) return { ok: false, published: false, reason: `npm publish failed (exit ${r.code})` };
862
+ deps.log(`published ${name}@${version} — waiting for the registry to serve it`);
863
+
864
+ const maxAttempts = Number.isFinite(o.registryAttempts) ? o.registryAttempts : 10;
865
+ const waitMs = Number.isFinite(o.registryWaitMs) ? o.registryWaitMs : 6000;
866
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
867
+ const read = npmLatest(name, deps);
868
+ const d = readLagDecision({ attempt, maxAttempts, target: version, read });
869
+ deps.log(` registry check ${attempt}/${maxAttempts}: ${d.reason}`);
870
+ if (d.action === "done") return { ok: true, published: true, reason: d.reason };
871
+ if (d.action === "fail") return { ok: false, published: true, reason: d.reason };
872
+ await deps.sleep(waitMs);
873
+ }
874
+ return { ok: false, published: true, reason: "registry never served the new version" };
875
+ }
876
+
877
+ /**
878
+ * Stage 3 — poll the org until every seat reports `target`, or the deadline
879
+ * passes. Prints the table every round.
880
+ * @returns {Promise<{ok:boolean, summary:object|null, rounds:number}>}
881
+ */
882
+ export async function verifyPropagation(o) {
883
+ const deps = { ...defaultDeps, ...(o.deps || {}) };
884
+ const target = o.target;
885
+ const started = deps.now();
886
+ const deadlineMs = Number.isFinite(o.deadlineMs) ? o.deadlineMs : 90 * 60 * 1000;
887
+ const intervalMs = Number.isFinite(o.intervalMs) ? o.intervalMs : 60 * 1000;
888
+ deps.log(`\n── verify propagation ──────────────────────────────────────`);
889
+ deps.log(`target ${target} · deadline ${ageLabel(deadlineMs)} · poll every ${ageLabel(intervalMs)}`);
890
+ deps.log(`seats update themselves from npm on their own hourly autoupdater; nothing is pushed from here.`);
891
+
892
+ let round = 0;
893
+ let summary = null;
894
+ let saidWhyUnknown = false;
895
+ for (;;) {
896
+ round += 1;
897
+ const fleet = await (o.readFleet || readFleet)({
898
+ agentRoot: o.agentRoot,
899
+ client: o.client,
900
+ seatWindowMs: o.seatWindowMs,
901
+ });
902
+ if (!fleet.ok) {
903
+ deps.log(`round ${round}: fleet read failed — ${fleet.error}`);
904
+ } else {
905
+ summary = summarise(fleet, target);
906
+ deps.log("");
907
+ deps.log(renderSeatTable(summary, { round, elapsedMs: deps.now() - started }));
908
+ if (!saidWhyUnknown && summary.counts.unverifiable > 0) {
909
+ saidWhyUnknown = true;
910
+ deps.log(
911
+ `\n ${propagationOutcome(summary).note}\n`
912
+ );
913
+ }
914
+ if (summary.done) {
915
+ deps.log(`\n${renderVerdict(summary)}`);
916
+ return { ok: true, summary, rounds: round };
917
+ }
918
+ }
919
+ const elapsed = deps.now() - started;
920
+ if (elapsed + intervalMs > deadlineMs) {
921
+ deps.log(`\ndeadline (${ageLabel(deadlineMs)}) reached.`);
922
+ if (summary) deps.log(renderVerdict(summary));
923
+ else deps.log("no successful fleet read before the deadline.");
924
+ return { ok: false, summary, rounds: round };
925
+ }
926
+ await deps.sleep(intervalMs);
927
+ }
928
+ }
929
+
930
+ // ---------------------------------------------------------------------------
931
+ // CLI
932
+ // ---------------------------------------------------------------------------
933
+
934
+ /**
935
+ * Flag names that must never be accepted — and whose VALUE must never be
936
+ * echoed. Deliberately wider than the flags this tool knows about: the point is
937
+ * not to enumerate our own options, it is that an operator who types a secret
938
+ * at this CLI must not find it in their scrollback (or in a tee'd log) after the
939
+ * refusal. `--token` and `--registry-token` and `--npmAuthToken` are all the
940
+ * same mistake.
941
+ */
942
+ const CREDENTIAL_FLAG = /(token|auth|pass|secret|otp|key|credential|cookie|session|bearer|api[-_]?k)/i;
943
+
944
+ /**
945
+ * The flag name alone. Everything from the first `=` onwards is dropped
946
+ * unread, so no code path downstream of parsing can echo a `--flag=value`
947
+ * value — not the credential branch, not the unknown-argument branch.
948
+ * @param {string} a
949
+ * @returns {string}
950
+ */
951
+ function flagName(a) {
952
+ const eq = a.indexOf("=");
953
+ return eq === -1 ? a : a.slice(0, eq);
954
+ }
955
+
956
+ /**
957
+ * Parse argv. Pure. Rejects anything that smells like a credential, and never
958
+ * puts an argument's VALUE into an error string — see {@link CREDENTIAL_FLAG}.
959
+ * A bare (non-flag) argument is reported by shape and length only, because a
960
+ * pasted secret most often arrives as one.
961
+ */
962
+ export function parseArgs(argv) {
963
+ const o = {
964
+ dryRun: false,
965
+ verifyOnly: false,
966
+ deadlineMs: 90 * 60 * 1000,
967
+ intervalMs: 60 * 1000,
968
+ seatWindowMs: DEFAULT_SEAT_WINDOW_MS,
969
+ agentRoot: process.env.AGENT_ROOT || process.env.AGENT_DIR || undefined,
970
+ json: false,
971
+ help: false,
972
+ errors: [],
973
+ };
974
+ for (let i = 0; i < argv.length; i++) {
975
+ const a = argv[i];
976
+ if (a === "--dry-run") o.dryRun = true;
977
+ else if (a === "--verify-only") o.verifyOnly = true;
978
+ else if (a === "--json") o.json = true;
979
+ else if (a === "--help" || a === "-h") o.help = true;
980
+ else if (a === "--deadline") o.deadlineMs = Math.max(0, Number(argv[++i]) * 60 * 1000);
981
+ else if (a === "--interval") o.intervalMs = Math.max(1000, Number(argv[++i]) * 1000);
982
+ else if (a === "--seat-window") o.seatWindowMs = Math.max(0, Number(argv[++i]) * 60 * 1000);
983
+ else if (a === "--agent-root") o.agentRoot = argv[++i];
984
+ else if (a.startsWith("-")) {
985
+ const name = flagName(a);
986
+ if (CREDENTIAL_FLAG.test(name)) {
987
+ o.errors.push(`${name}: this tool never takes a credential as an argument — run \`npm login\` instead.`);
988
+ // `--token secret` (space-separated) would otherwise reach the next
989
+ // iteration as a bare argument. Swallow it so it is never echoed.
990
+ if (a.indexOf("=") === -1 && i + 1 < argv.length && !argv[i + 1].startsWith("-")) i++;
991
+ } else {
992
+ o.errors.push(`unknown argument ${name}`);
993
+ }
994
+ } else {
995
+ o.errors.push(`unknown argument: a bare value of ${a.length} character(s) — every option here starts with \`--\``);
996
+ }
997
+ }
998
+ return o;
999
+ }
1000
+
1001
+ const USAGE = `rollout — publish one SDK version and prove the fleet took it
1002
+
1003
+ node scripts/fleet/rollout.mjs [--dry-run] [--deadline <min>] [--interval <s>]
1004
+ [--seat-window <min>] [--agent-root <path>] [--json]
1005
+
1006
+ --dry-run run every gate for real, skip only the publish itself
1007
+ --verify-only skip pre-flight and publish; just watch the fleet take a version
1008
+ --deadline min how long to wait for the fleet (default 90)
1009
+ --interval s seconds between fleet polls (default 60)
1010
+ --seat-window m a member counts as a seat if it beat within this window (default 30)
1011
+ --json print the verdict as JSON: {code, reason, note, summary}
1012
+
1013
+ Exit: 0 verified · 1 pre-flight refusal · 2 publish/registry failure ·
1014
+ 3 deadline passed with seats behind or unverifiable.
1015
+
1016
+ UNVERIFIED IS NOT DONE. A seat that reports no version is never counted as
1017
+ current, so a run can exit 3 with nothing known to be behind. Branch on the
1018
+ --json 'reason' field ("verified" | "behind" | "unverifiable" | "empty-fleet" |
1019
+ "no-fleet-read"), not on the exit code: 'behind' is fixed by waiting,
1020
+ 'unverifiable' is a seat whose copied tree predates SDK 2.12 and is not.
1021
+
1022
+ Never ssh's anywhere and never accepts a credential as an argument — a
1023
+ credential-shaped flag is refused and its value is never echoed back.`;
1024
+
1025
+ /** @returns {Promise<number>} process exit code */
1026
+ export async function main(argv = process.argv.slice(2), injected = {}) {
1027
+ const deps = { ...defaultDeps, ...(injected.deps || {}) };
1028
+ const args = parseArgs(argv);
1029
+ if (args.help) {
1030
+ deps.log(USAGE);
1031
+ return 0;
1032
+ }
1033
+ if (args.errors.length) {
1034
+ for (const e of args.errors) deps.log(`rollout: ${e}`);
1035
+ return 1;
1036
+ }
1037
+ const pkg = injected.pkg || readLocalPackage();
1038
+ deps.log(`rollout ${pkg.name}@${pkg.version}${args.dryRun ? " [dry run]" : ""}`);
1039
+
1040
+ const latest = npmLatest(pkg.name, deps);
1041
+ const already = latest.ok && latest.version && compareVersions(latest.version, pkg.version) === 0;
1042
+
1043
+ if (args.verifyOnly) {
1044
+ deps.log(
1045
+ `--verify-only: not publishing. Registry @latest is ${latest.ok ? latest.version : `unreadable (${latest.error})`}; ` +
1046
+ `checking which seats are running ${pkg.version}.`
1047
+ );
1048
+ } else if (already) {
1049
+ deps.log(`already published — registry @latest is ${latest.version}. Verifying propagation.`);
1050
+ } else {
1051
+ const pre = preflight({ pkg, deps, registryLatest: latest, ...injected.preflight });
1052
+ if (!pre.ok) {
1053
+ deps.log(`\nREFUSING TO PUBLISH — ${pre.refusals.length} blocker(s):`);
1054
+ for (const r of pre.refusals) deps.log(` · ${r}`);
1055
+ for (const n of pre.notes || []) deps.log(` (${n})`);
1056
+ deps.log(`\nNothing was published and nothing was changed. Fix the blockers and re-run.`);
1057
+ return 1;
1058
+ }
1059
+ deps.log(`\npre-flight clear.`);
1060
+ const pub = await publish({ pkg, deps, dryRun: args.dryRun, ...injected.publishOpts });
1061
+ if (!pub.ok) {
1062
+ deps.log(`publish stage failed: ${pub.reason}`);
1063
+ return 2;
1064
+ }
1065
+ }
1066
+
1067
+ const v = await verifyPropagation({
1068
+ target: pkg.version,
1069
+ deps,
1070
+ agentRoot: args.agentRoot,
1071
+ deadlineMs: args.deadlineMs,
1072
+ intervalMs: args.intervalMs,
1073
+ seatWindowMs: args.seatWindowMs,
1074
+ readFleet: injected.readFleet,
1075
+ client: injected.client,
1076
+ });
1077
+ const outcome = propagationOutcome(v.summary);
1078
+ if (outcome.note) deps.log(`\n${outcome.note}`);
1079
+ if (args.json) deps.log(JSON.stringify({ ...outcome, summary: v.summary ?? null }, null, 2));
1080
+ // A failed verify never exits 0, whatever the outcome says.
1081
+ return v.ok ? 0 : outcome.code || 3;
1082
+ }
1083
+
1084
+ if (process.argv[1] && resolvePath(process.argv[1]) === resolvePath(__filename)) {
1085
+ main().then(
1086
+ (code) => { process.exitCode = code; },
1087
+ (err) => {
1088
+ process.stderr.write(`rollout failed: ${(err && err.stack) || err}\n`);
1089
+ process.exitCode = 2;
1090
+ }
1091
+ );
1092
+ }
1093
+
1094
+ export default { main, preflight, publish, verifyPropagation, readFleet, propagationOutcome, commandRefusal };