@cohortapp/agent-sdk 2.16.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 (529) hide show
  1. package/.claude/settings.json +18 -0
  2. package/.env.example +23 -7
  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/poller-daemon-setup.md +4 -1
  9. package/docs/guides/setup-wizard.md +1 -1
  10. package/docs/runbooks/fleet-rollout.md +156 -0
  11. package/docs/runbooks/mac-mini-bootstrap.md +12 -14
  12. package/lib/action-executor.js +19 -3
  13. package/lib/budget-guard.mjs +279 -3
  14. package/lib/channels/base-adapter.mjs +3 -1
  15. package/lib/channels/contract.mjs +2 -1
  16. package/lib/channels/inbox-item.mjs +8 -0
  17. package/lib/claude-bin.mjs +5 -6
  18. package/lib/cli/doctor-checks.mjs +141 -10
  19. package/lib/cli/global-setup-extras.mjs +5 -1
  20. package/lib/cli/inbox.mjs +100 -15
  21. package/lib/cli/seat-auth.mjs +463 -0
  22. package/lib/cli/session.mjs +80 -12
  23. package/lib/collective/capture.mjs +8 -6
  24. package/lib/collective/global-config.mjs +63 -1
  25. package/lib/collective/presence.mjs +142 -5
  26. package/lib/comms/send-gate.mjs +559 -1
  27. package/lib/context/budget.mjs +327 -0
  28. package/lib/context/history-scope.mjs +138 -0
  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/economics.mjs +9 -0
  138. package/lib/model-router/failover.mjs +67 -16
  139. package/lib/model-router/llm-task.mjs +39 -3
  140. package/lib/model-router/resolve.mjs +95 -3
  141. package/lib/model-router/spawn.mjs +46 -47
  142. package/lib/model-router/taxonomy.mjs +126 -4
  143. package/lib/org/cost-sync.mjs +141 -11
  144. package/lib/org/inbound/broadcast.mjs +289 -0
  145. package/lib/org/inbound/collective.mjs +375 -0
  146. package/lib/org/inbound/directedness.mjs +96 -8
  147. package/lib/org/inbound/facts.mjs +82 -4
  148. package/lib/org/inbound/hydrate.mjs +555 -51
  149. package/lib/org/inbound/project.mjs +22 -0
  150. package/lib/org/inbound/surfaces.mjs +14 -0
  151. package/lib/org/llm-token.mjs +879 -0
  152. package/lib/org/mesh.mjs +61 -0
  153. package/lib/org/messaging.mjs +3 -1
  154. package/lib/org/protocol.checksum +1 -1
  155. package/lib/org/protocol.mjs +15 -0
  156. package/lib/org/quota.mjs +520 -0
  157. package/lib/org/tool-surface.mjs +104 -16
  158. package/lib/org/ui-parity.mjs +16 -1
  159. package/lib/org/work-ledger.mjs +37 -6
  160. package/lib/rate-guard.mjs +114 -1
  161. package/lib/resource-governor.mjs +41 -6
  162. package/lib/runtime/adapter.mjs +823 -0
  163. package/lib/runtime/child-env.mjs +191 -0
  164. package/lib/runtime/legacy-shell-guard.mjs +97 -0
  165. package/lib/runtime/seat-engine.mjs +162 -0
  166. package/lib/session/ask-ledger.mjs +271 -0
  167. package/lib/session/current-work.mjs +676 -0
  168. package/lib/session/feed-core.mjs +40 -3
  169. package/lib/session/launch-args.mjs +56 -4
  170. package/lib/session/status-summary.mjs +26 -9
  171. package/lib/session/upgrade-notice.mjs +42 -0
  172. package/lib/setup/claude-probe.mjs +117 -13
  173. package/lib/setup/enrich.mjs +13 -10
  174. package/lib/setup/sections/model.mjs +39 -13
  175. package/lib/telemetry/collect.mjs +208 -9
  176. package/lib/upgrade/ignored-drift.mjs +105 -0
  177. package/lib/voice/post-call-brief.mjs +30 -17
  178. package/package.json +15 -3
  179. package/plugins/maestro-skills/skills/board-work.md +5 -0
  180. package/plugins/maestro-skills/skills/inbound-triage.md +56 -15
  181. package/plugins/maestro-skills/skills/main-session.md +18 -7
  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 +29 -2
  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 +104 -59
  194. package/scripts/daemon/deliver.mjs +30 -1
  195. package/scripts/daemon/dispatcher.mjs +804 -157
  196. package/scripts/daemon/health.mjs +14 -1
  197. package/scripts/daemon/lib/session-router.mjs +310 -42
  198. package/scripts/daemon/maestro-daemon.mjs +11 -0
  199. package/scripts/daemon/prompt-builder.mjs +121 -12
  200. package/scripts/daemon/responder.mjs +315 -146
  201. package/scripts/daemon/sdk-version.mjs +98 -16
  202. package/scripts/eval/probe-gateway.mjs +635 -0
  203. package/scripts/eval/replay/extract.mjs +270 -0
  204. package/scripts/eval/replay/grade.mjs +260 -0
  205. package/scripts/eval/replay/lib/config.mjs +50 -0
  206. package/scripts/eval/replay/lib/effects.mjs +65 -0
  207. package/scripts/eval/replay/lib/fixture.mjs +188 -0
  208. package/scripts/eval/replay/lib/judge.mjs +72 -0
  209. package/scripts/eval/replay/lib/redact.mjs +136 -0
  210. package/scripts/eval/replay/lib/sandbox.mjs +170 -0
  211. package/scripts/eval/replay/lib/schema-check.mjs +63 -0
  212. package/scripts/eval/replay/lib/transcript.mjs +76 -0
  213. package/scripts/eval/replay/mcp-replay-stub.mjs +101 -0
  214. package/scripts/eval/replay/report.mjs +185 -0
  215. package/scripts/eval/replay/run.mjs +404 -0
  216. package/scripts/fleet/rollout.mjs +1094 -0
  217. package/scripts/hooks/pre-send-audit.sh +36 -245
  218. package/scripts/hooks/pre-write-yaml-validate.mjs +275 -0
  219. package/scripts/hooks/validate-state-yaml.sh +190 -0
  220. package/scripts/huddle/huddle-llm.mjs +361 -0
  221. package/scripts/huddle/huddle-server.mjs +46 -121
  222. package/scripts/local-triggers/autoupdate.sh +448 -78
  223. package/scripts/local-triggers/run-trigger.sh +13 -0
  224. package/scripts/maintenance/pin-integrity.mjs +364 -0
  225. package/scripts/poll-slack-events.sh +41 -9
  226. package/scripts/poller/slack-socket-mode.mjs +28 -3
  227. package/scripts/session/supervisor.mjs +80 -13
  228. package/scripts/spawn-session.sh +13 -0
  229. package/bin/maestro.test.mjs +0 -1574
  230. package/lib/action-executor.test.mjs +0 -871
  231. package/lib/archetype.test.mjs +0 -132
  232. package/lib/assurance/plan-note.test.mjs +0 -234
  233. package/lib/assurance/room-budget.test.mjs +0 -486
  234. package/lib/assurance/tier.test.mjs +0 -174
  235. package/lib/autonomy.test.mjs +0 -66
  236. package/lib/backlog.test.mjs +0 -302
  237. package/lib/backup/policy.test.mjs +0 -305
  238. package/lib/budget-escalate.test.mjs +0 -232
  239. package/lib/budget-guard.envelope.test.mjs +0 -476
  240. package/lib/budget-guard.test.mjs +0 -427
  241. package/lib/cadence-bus-requeue.test.mjs +0 -83
  242. package/lib/cadence-bus-schedule.test.mjs +0 -194
  243. package/lib/cadence-bus.test.mjs +0 -720
  244. package/lib/cadences.test.mjs +0 -230
  245. package/lib/capability/inventory.test.mjs +0 -232
  246. package/lib/capability.test.mjs +0 -78
  247. package/lib/channels/base-adapter.test.mjs +0 -590
  248. package/lib/channels/channels.test.mjs +0 -371
  249. package/lib/channels/contract.test.mjs +0 -162
  250. package/lib/channels/inbox-item.test.mjs +0 -368
  251. package/lib/channels/orgmail/adapter.test.mjs +0 -448
  252. package/lib/channels/pairing.test.mjs +0 -270
  253. package/lib/channels/repeat-suppressor.test.mjs +0 -134
  254. package/lib/channels/slack-adapter.test.mjs +0 -212
  255. package/lib/channels/telegram-adapter.test.mjs +0 -306
  256. package/lib/channels/voice/adapter.test.mjs +0 -278
  257. package/lib/channels/whatsapp/adapter-baileys.test.mjs +0 -359
  258. package/lib/channels/whatsapp/baileys-typing.test.mjs +0 -154
  259. package/lib/charter.test.mjs +0 -89
  260. package/lib/claude-bin.test.mjs +0 -131
  261. package/lib/cli/board.test.mjs +0 -227
  262. package/lib/cli/design.test.mjs +0 -270
  263. package/lib/cli/doctor-checks.test.mjs +0 -336
  264. package/lib/cli/global-setup-extras.test.mjs +0 -462
  265. package/lib/cli/inbox.test.mjs +0 -230
  266. package/lib/cli/session-ack.test.mjs +0 -63
  267. package/lib/cli/session.test.mjs +0 -613
  268. package/lib/collective/capture.test.mjs +0 -121
  269. package/lib/collective/cards.test.mjs +0 -114
  270. package/lib/collective/config.test.mjs +0 -123
  271. package/lib/collective/global-config.test.mjs +0 -220
  272. package/lib/collective/global-skills.test.mjs +0 -126
  273. package/lib/collective/presence.test.mjs +0 -95
  274. package/lib/collective/recall.test.mjs +0 -116
  275. package/lib/collective/vendor-skills.test.mjs +0 -306
  276. package/lib/comms/send-gate.test.mjs +0 -770
  277. package/lib/comms.test.mjs +0 -41
  278. package/lib/cost/ledger-row.test.mjs +0 -183
  279. package/lib/design/design-md.test.mjs +0 -318
  280. package/lib/design/fixtures/DESIGN.golden.md +0 -238
  281. package/lib/design/fixtures/PRODUCT.golden.md +0 -67
  282. package/lib/design/fixtures/foundation.json +0 -133
  283. package/lib/design/refresh-gate.test.mjs +0 -144
  284. package/lib/design/write.test.mjs +0 -241
  285. package/lib/diagnostics/alerts.test.mjs +0 -318
  286. package/lib/diagnostics/backup-freshness.test.mjs +0 -185
  287. package/lib/diagnostics/counters.test.mjs +0 -206
  288. package/lib/diagnostics/events.test.mjs +0 -290
  289. package/lib/diagnostics/otel.test.mjs +0 -196
  290. package/lib/diagnostics/trace.test.mjs +0 -251
  291. package/lib/env-compat.test.mjs +0 -104
  292. package/lib/execution/disposition.test.mjs +0 -553
  293. package/lib/execution/drive.test.mjs +0 -270
  294. package/lib/execution/effects.test.mjs +0 -344
  295. package/lib/execution/intake.test.mjs +0 -389
  296. package/lib/execution/journal.test.mjs +0 -261
  297. package/lib/execution/match.test.mjs +0 -235
  298. package/lib/execution/pipeline.test.mjs +0 -392
  299. package/lib/execution/route.test.mjs +0 -186
  300. package/lib/execution/surface-policy.test.mjs +0 -162
  301. package/lib/fs-atomic.test.mjs +0 -72
  302. package/lib/fs-ownership.test.mjs +0 -158
  303. package/lib/goals/admission.test.mjs +0 -164
  304. package/lib/goals/classify.test.mjs +0 -167
  305. package/lib/goals/collaborate.test.mjs +0 -336
  306. package/lib/goals/gaps.test.mjs +0 -284
  307. package/lib/goals/loop.test.mjs +0 -845
  308. package/lib/hooks/bus.test.mjs +0 -387
  309. package/lib/identity/persona.test.mjs +0 -142
  310. package/lib/kpi-sensors.test.mjs +0 -278
  311. package/lib/kpi.test.mjs +0 -244
  312. package/lib/learning/config.test.mjs +0 -75
  313. package/lib/learning/counters.test.mjs +0 -69
  314. package/lib/learning/curator-consolidate.test.mjs +0 -238
  315. package/lib/learning/curator.test.mjs +0 -106
  316. package/lib/learning/reflect.test.mjs +0 -0
  317. package/lib/learning/session-index.test.mjs +0 -125
  318. package/lib/learning/skill-writer.test.mjs +0 -210
  319. package/lib/mandate/audit.test.mjs +0 -195
  320. package/lib/mandate/contract.test.mjs +0 -185
  321. package/lib/mandate/derive.test.mjs +0 -274
  322. package/lib/mandate/model.test.mjs +0 -164
  323. package/lib/mandate/refresh.test.mjs +0 -389
  324. package/lib/mcp/server.test.mjs +0 -426
  325. package/lib/model-router/auth-profiles.test.mjs +0 -580
  326. package/lib/model-router/catalog.test.mjs +0 -385
  327. package/lib/model-router/economics.test.mjs +0 -438
  328. package/lib/model-router/failover.test.mjs +0 -439
  329. package/lib/model-router/health.test.mjs +0 -338
  330. package/lib/model-router/integration-coverage.test.mjs +0 -831
  331. package/lib/model-router/integration.test.mjs +0 -564
  332. package/lib/model-router/ledger.test.mjs +0 -415
  333. package/lib/model-router/llm-task.test.mjs +0 -392
  334. package/lib/model-router/org-credentials.test.mjs +0 -265
  335. package/lib/model-router/pricing-refresh.test.mjs +0 -286
  336. package/lib/model-router/reconcile.test.mjs +0 -316
  337. package/lib/model-router/repair.test.mjs +0 -180
  338. package/lib/model-router/spawn.test.mjs +0 -446
  339. package/lib/model-router/taxonomy.test.mjs +0 -410
  340. package/lib/model-router.test.mjs +0 -1207
  341. package/lib/org/activity.test.mjs +0 -134
  342. package/lib/org/approvals.test.mjs +0 -216
  343. package/lib/org/awareness.test.mjs +0 -159
  344. package/lib/org/board-mine-cache.test.mjs +0 -53
  345. package/lib/org/board.test.mjs +0 -187
  346. package/lib/org/bootstrap-context.test.mjs +0 -153
  347. package/lib/org/client.test.mjs +0 -1206
  348. package/lib/org/cohort-client.test.mjs +0 -126
  349. package/lib/org/cost-sync.test.mjs +0 -153
  350. package/lib/org/doctor.test.mjs +0 -346
  351. package/lib/org/engagement-ledger.test.mjs +0 -112
  352. package/lib/org/engagement.test.mjs +0 -739
  353. package/lib/org/handoff.test.mjs +0 -269
  354. package/lib/org/inbound/directedness.test.mjs +0 -668
  355. package/lib/org/inbound/facts.test.mjs +0 -471
  356. package/lib/org/inbound/hydrate.test.mjs +0 -453
  357. package/lib/org/inbound/index.test.mjs +0 -429
  358. package/lib/org/inbound/project.test.mjs +0 -287
  359. package/lib/org/integration-tools.test.mjs +0 -160
  360. package/lib/org/keys.test.mjs +0 -92
  361. package/lib/org/knowledge.test.mjs +0 -326
  362. package/lib/org/leases.test.mjs +0 -235
  363. package/lib/org/mesh-directives.test.mjs +0 -110
  364. package/lib/org/mesh-integration.test.mjs +0 -127
  365. package/lib/org/mesh.test.mjs +0 -400
  366. package/lib/org/messaging.test.mjs +0 -471
  367. package/lib/org/param-contract.test.mjs +0 -477
  368. package/lib/org/policy.test.mjs +0 -237
  369. package/lib/org/protocol.checksum.test.mjs +0 -90
  370. package/lib/org/protocol.test.mjs +0 -323
  371. package/lib/org/push.test.mjs +0 -792
  372. package/lib/org/registry.test.mjs +0 -100
  373. package/lib/org/resource-tools.test.mjs +0 -361
  374. package/lib/org/tool-access.test.mjs +0 -144
  375. package/lib/org/tool-surface-integration.test.mjs +0 -120
  376. package/lib/org/tool-surface.test.mjs +0 -1268
  377. package/lib/org/typing.test.mjs +0 -291
  378. package/lib/org/ui-parity.test.mjs +0 -560
  379. package/lib/org/verify.test.mjs +0 -194
  380. package/lib/org/work-ledger.test.mjs +0 -273
  381. package/lib/plan/adoption-e2e.test.mjs +0 -366
  382. package/lib/plan/budget-enforcement.test.mjs +0 -400
  383. package/lib/plan/compile.test.mjs +0 -382
  384. package/lib/plan/emit.test.mjs +0 -269
  385. package/lib/plan/explain.test.mjs +0 -188
  386. package/lib/prompts/parallelism.test.mjs +0 -177
  387. package/lib/rag/rag.test.mjs +0 -505
  388. package/lib/rate-guard.test.mjs +0 -272
  389. package/lib/reactive-gate.test.mjs +0 -57
  390. package/lib/render.test.mjs +0 -68
  391. package/lib/resource-governor.test.mjs +0 -488
  392. package/lib/scheduling/dynamic-jobs.test.mjs +0 -344
  393. package/lib/scheduling/jitter.test.mjs +0 -140
  394. package/lib/secrets/broker.test.mjs +0 -280
  395. package/lib/secrets/providers.test.mjs +0 -274
  396. package/lib/security/audit-engine.test.mjs +0 -424
  397. package/lib/security/coerce-args.test.mjs +0 -281
  398. package/lib/security/dangerous-tools.test.mjs +0 -68
  399. package/lib/security/external-content.test.mjs +0 -84
  400. package/lib/security/redact.test.mjs +0 -441
  401. package/lib/security/secret-equal.test.mjs +0 -55
  402. package/lib/session/config.test.mjs +0 -92
  403. package/lib/session/feed-core.test.mjs +0 -198
  404. package/lib/session/first-run.test.mjs +0 -121
  405. package/lib/session/frontdoor.test.mjs +0 -205
  406. package/lib/session/handoffs.test.mjs +0 -183
  407. package/lib/session/identity.test.mjs +0 -180
  408. package/lib/session/inbox-claims.test.mjs +0 -286
  409. package/lib/session/launch-args.test.mjs +0 -157
  410. package/lib/session/liveness.test.mjs +0 -100
  411. package/lib/session/status-summary.test.mjs +0 -118
  412. package/lib/session-permissions.test.mjs +0 -120
  413. package/lib/setup/claude-probe.test.mjs +0 -187
  414. package/lib/setup/completeness.test.mjs +0 -110
  415. package/lib/setup/context-pack.test.mjs +0 -89
  416. package/lib/setup/enrich.test.mjs +0 -115
  417. package/lib/setup/enroll-from-cohort.test.mjs +0 -300
  418. package/lib/setup/integration.test.mjs +0 -162
  419. package/lib/setup/io.test.mjs +0 -77
  420. package/lib/setup/runner.test.mjs +0 -132
  421. package/lib/setup/sections/identity.test.mjs +0 -234
  422. package/lib/setup/sections/inventory.test.mjs +0 -198
  423. package/lib/setup/sections/learning.test.mjs +0 -81
  424. package/lib/setup/sections/mandate.test.mjs +0 -388
  425. package/lib/setup/sections/messaging.test.mjs +0 -127
  426. package/lib/setup/sections/model.test.mjs +0 -240
  427. package/lib/setup/sections/org.test.mjs +0 -346
  428. package/lib/setup/sections/orgmail.test.mjs +0 -118
  429. package/lib/setup/sections/recovery.test.mjs +0 -98
  430. package/lib/setup/sections/subagents.test.mjs +0 -429
  431. package/lib/setup/sections/verify.test.mjs +0 -175
  432. package/lib/setup/sot.test.mjs +0 -81
  433. package/lib/setup/state.test.mjs +0 -115
  434. package/lib/singleton.test.mjs +0 -151
  435. package/lib/subagents/cli.test.mjs +0 -389
  436. package/lib/subagents/client.test.mjs +0 -309
  437. package/lib/subagents/gap.test.mjs +0 -234
  438. package/lib/subagents/lock.test.mjs +0 -248
  439. package/lib/subagents/manifest.test.mjs +0 -175
  440. package/lib/subagents/refs.test.mjs +0 -204
  441. package/lib/subagents/resolve.test.mjs +0 -422
  442. package/lib/subagents/schema.test.mjs +0 -328
  443. package/lib/telemetry/alerts.test.mjs +0 -109
  444. package/lib/telemetry/collect.test.mjs +0 -1274
  445. package/lib/tool-definitions-integration.test.mjs +0 -83
  446. package/lib/tool-definitions.test.mjs +0 -437
  447. package/lib/upgrade/global-refresh.test.mjs +0 -65
  448. package/lib/upgrade/launchd-reconcile.test.mjs +0 -272
  449. package/lib/upgrade/post-steps.test.mjs +0 -200
  450. package/lib/upgrade/verify.test.mjs +0 -164
  451. package/lib/util/fetch-timeout.test.mjs +0 -202
  452. package/lib/util/reconnect.test.mjs +0 -369
  453. package/lib/util/unhandled.test.mjs +0 -216
  454. package/lib/voice/outbound.test.mjs +0 -69
  455. package/lib/voice/session-rotation.test.mjs +0 -114
  456. package/lib/voice/stt.test.mjs +0 -226
  457. package/lib/voice/voice.test.mjs +0 -990
  458. package/scripts/cadence/enqueue-cadence-tick.test.mjs +0 -187
  459. package/scripts/ci/check-docs-accuracy.test.mjs +0 -409
  460. package/scripts/ci/check-durable-write-seam.test.mjs +0 -90
  461. package/scripts/ci/check-no-build-artifacts.test.mjs +0 -71
  462. package/scripts/ci/check-no-residual-identity.test.mjs +0 -202
  463. package/scripts/ci/check-skill-packs.test.mjs +0 -495
  464. package/scripts/ci/check-subagent-frontmatter.test.mjs +0 -124
  465. package/scripts/ci/check.test.mjs +0 -194
  466. package/scripts/ci/conformance-org-api.test.mjs +0 -425
  467. package/scripts/cloud-relay/voice/relay-identity.test.mjs +0 -96
  468. package/scripts/collective/hook-runner.test.mjs +0 -173
  469. package/scripts/cost/fleet-digest.test.mjs +0 -207
  470. package/scripts/cost/track-claude-usage-pricing.test.mjs +0 -183
  471. package/scripts/cost/track-claude-usage.test.mjs +0 -148
  472. package/scripts/daemon/agent-daemon-board-mine.test.mjs +0 -96
  473. package/scripts/daemon/agent-daemon-design.test.mjs +0 -238
  474. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +0 -60
  475. package/scripts/daemon/agent-daemon.test.mjs +0 -995
  476. package/scripts/daemon/assurance-e2e.test.mjs +0 -613
  477. package/scripts/daemon/assurance.test.mjs +0 -1791
  478. package/scripts/daemon/board-mirror.test.mjs +0 -165
  479. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +0 -393
  480. package/scripts/daemon/cadence-consumer-governance.test.mjs +0 -276
  481. package/scripts/daemon/cadence-consumer.test.mjs +0 -776
  482. package/scripts/daemon/cadence-handlers.test.mjs +0 -837
  483. package/scripts/daemon/classifier-identity.test.mjs +0 -137
  484. package/scripts/daemon/classifier.test.mjs +0 -266
  485. package/scripts/daemon/classify-kind.test.mjs +0 -40
  486. package/scripts/daemon/context-compiler.test.mjs +0 -300
  487. package/scripts/daemon/deliver.test.mjs +0 -564
  488. package/scripts/daemon/dispatcher-cooldown.test.mjs +0 -122
  489. package/scripts/daemon/dispatcher-governance.test.mjs +0 -1013
  490. package/scripts/daemon/dispatcher-resume.test.mjs +0 -166
  491. package/scripts/daemon/execution-ladder.test.mjs +0 -470
  492. package/scripts/daemon/goal-steward-cadence.test.mjs +0 -312
  493. package/scripts/daemon/inbox-deferral-session.test.mjs +0 -49
  494. package/scripts/daemon/inbox-deferral.test.mjs +0 -336
  495. package/scripts/daemon/inbox-wake.test.mjs +0 -199
  496. package/scripts/daemon/integration.test.mjs +0 -149
  497. package/scripts/daemon/lib/self-echo.test.mjs +0 -153
  498. package/scripts/daemon/lib/session-router.test.mjs +0 -295
  499. package/scripts/daemon/prompt-builder-preamble.test.mjs +0 -210
  500. package/scripts/daemon/prompt-builder.test.mjs +0 -344
  501. package/scripts/daemon/responder-cost.test.mjs +0 -68
  502. package/scripts/daemon/responder-history.test.mjs +0 -185
  503. package/scripts/daemon/sdk-version.test.mjs +0 -31
  504. package/scripts/daemon/session-lock.test.mjs +0 -252
  505. package/scripts/daemon/session-outcomes.test.mjs +0 -533
  506. package/scripts/daemon/typing-registry.test.mjs +0 -102
  507. package/scripts/hooks/pre-send-audit.test.mjs +0 -354
  508. package/scripts/huddle/huddle-prompt.test.mjs +0 -176
  509. package/scripts/local-triggers/autoupdate.test.mjs +0 -518
  510. package/scripts/local-triggers/generate-plists.test.mjs +0 -456
  511. package/scripts/media-generation/brand-clause.test.mjs +0 -135
  512. package/scripts/org/send-orgmail.first-contact.test.mjs +0 -102
  513. package/scripts/poller/inbox-privilege-injection.test.mjs +0 -167
  514. package/scripts/poller/inbox-scan-poller.test.mjs +0 -295
  515. package/scripts/poller/lib/cloud-relay-dedup.test.mjs +0 -133
  516. package/scripts/poller/slack-socket-mode.test.mjs +0 -805
  517. package/scripts/poller-launchd/install.test.mjs +0 -243
  518. package/scripts/restore-from-backup.test.mjs +0 -181
  519. package/scripts/session/feed.test.mjs +0 -196
  520. package/scripts/session/supervisor-sh.test.mjs +0 -218
  521. package/scripts/session/supervisor.test.mjs +0 -482
  522. package/scripts/setup/configure-macos.test.mjs +0 -306
  523. package/scripts/setup/gen-subagent-manifest.test.mjs +0 -124
  524. package/scripts/setup/generate-agent-package-json.test.mjs +0 -143
  525. package/scripts/setup/generate-capability.test.mjs +0 -134
  526. package/scripts/setup/init-agent.test.mjs +0 -370
  527. package/scripts/setup/init-skill-marketplace.test.mjs +0 -193
  528. package/scripts/vendor/sync-skill-packs.test.mjs +0 -103
  529. 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 };