@yemi33/minions 0.1.2448 → 0.1.2449

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 (315) hide show
  1. package/bin/cli-api-client.js +1 -1
  2. package/bin/install-internal-minions.js +1382 -44
  3. package/bin/install-layout.js +150 -0
  4. package/bin/minions.js +460 -167
  5. package/dashboard/docs/typography.md +65 -12
  6. package/dashboard/js/command-center.js +58 -4
  7. package/dashboard/js/detail-panel.js +36 -0
  8. package/dashboard/js/memory-panel.js +59 -12
  9. package/dashboard/js/qa.js +179 -20
  10. package/dashboard/js/refresh.js +148 -12
  11. package/dashboard/js/render-dispatch.js +3 -4
  12. package/dashboard/js/render-inbox.js +2 -2
  13. package/dashboard/js/render-other.js +3 -3
  14. package/dashboard/js/render-pipelines.js +14 -0
  15. package/dashboard/js/render-plans.js +57 -9
  16. package/dashboard/js/render-prd.js +132 -23
  17. package/dashboard/js/render-prs.js +195 -166
  18. package/dashboard/js/render-schedules.js +63 -3
  19. package/dashboard/js/render-utils.js +3 -3
  20. package/dashboard/js/render-watches.js +19 -3
  21. package/dashboard/js/render-work-items.js +238 -30
  22. package/dashboard/js/settings.js +205 -54
  23. package/dashboard/js/utils.js +51 -1
  24. package/dashboard/pages/home.html +1 -1
  25. package/dashboard/pages/qa.html +1 -16
  26. package/dashboard/pages/work.html +40 -0
  27. package/dashboard/shared/cc-limits.js +79 -0
  28. package/dashboard/shared/pr-filters.js +21 -38
  29. package/dashboard/shared/project-git-summary.js +1 -1
  30. package/dashboard/shared/record-filters.js +169 -0
  31. package/dashboard/shared/watches-source.js +1 -1
  32. package/dashboard/shared/welcome-popup.js +1 -1
  33. package/dashboard/shared/wi-filters.js +302 -0
  34. package/dashboard/slim/body.html +1 -0
  35. package/dashboard/slim/js/command-send.js +26 -0
  36. package/dashboard/slim/js/modals-tiles.js +380 -39
  37. package/dashboard/slim/js/status.js +13 -21
  38. package/dashboard/slim/layout.html +1 -0
  39. package/dashboard/slim/panel-bootstrap.js +6 -2
  40. package/dashboard/slim/styles.css +38 -0
  41. package/dashboard/styles.css +159 -55
  42. package/dashboard-build.js +13 -2
  43. package/dashboard.js +956 -423
  44. package/docs/README.md +11 -6
  45. package/docs/api-errors.md +2 -2
  46. package/docs/architecture-review-2026-07-09.md +1 -1
  47. package/docs/architecture.excalidraw +2 -2
  48. package/docs/auto-discovery.md +18 -9
  49. package/docs/branch-derivation.md +4 -4
  50. package/docs/capture-demos.js +39 -2
  51. package/docs/ci-runner-canary.md +123 -0
  52. package/docs/claude-md-propagation.md +2 -2
  53. package/docs/cloud-agent-dispatch.md +204 -0
  54. package/docs/command-center.md +7 -7
  55. package/docs/completion-reports.md +43 -20
  56. package/docs/constants.md +10 -3
  57. package/docs/constellation-bridge.md +134 -6
  58. package/docs/constellation-style-telemetry.md +4 -4
  59. package/docs/contracts/capability-protocol.v1.json +165 -0
  60. package/docs/cooldown-merge-semantics.md +12 -12
  61. package/docs/copilot-cli-schema.md +7 -7
  62. package/docs/cross-repo-plans.md +10 -10
  63. package/docs/dead-code-audit-retractions.md +5 -5
  64. package/docs/default-branch-ci.md +173 -0
  65. package/docs/deprecated.json +31 -31
  66. package/docs/design-inbox-entries-schema.md +3 -3
  67. package/docs/design-language.md +1051 -0
  68. package/docs/design-state-storage.md +11 -11
  69. package/docs/diagnostics-crash-reports.md +9 -9
  70. package/docs/diagnostics-memory.md +5 -5
  71. package/docs/documentation-audit-2026-07-09.md +7 -7
  72. package/docs/engine-restart.md +90 -5
  73. package/docs/harness-mode.md +1 -1
  74. package/docs/internal-install.md +338 -39
  75. package/docs/kb-dedup-duplicate-pair-investigation.md +5 -5
  76. package/docs/kb-pr3223-cascade-archiving.md +1 -1
  77. package/docs/kb-pr696-merge-conflict-docs.md +6 -6
  78. package/docs/kb-sweep.md +35 -35
  79. package/docs/keep-processes.md +1 -1
  80. package/docs/live-checkout-mode.md +30 -30
  81. package/docs/managed-spawn.md +18 -14
  82. package/docs/named-agents.md +7 -7
  83. package/docs/plan-lifecycle.md +69 -2
  84. package/docs/pr-author-identity.md +114 -0
  85. package/docs/pr-auto-fix-dispatch.md +19 -4
  86. package/docs/pr-comment-followup.md +6 -6
  87. package/docs/pr-review-fix-loop.md +59 -10
  88. package/docs/process-termination.md +40 -0
  89. package/docs/proposals/repo-pool-for-live-checkout.md +13 -13
  90. package/docs/qa-runbook-lifecycle.md +367 -17
  91. package/docs/qa-runbooks.md +3 -3
  92. package/docs/rfc-completion-json.md +18 -18
  93. package/docs/runtime-adapters.md +26 -21
  94. package/docs/security.md +6 -6
  95. package/docs/self-improvement.md +4 -4
  96. package/docs/shared-lifecycle-module-map.md +473 -472
  97. package/docs/skills.md +52 -3
  98. package/docs/slim-ux/concepts.md +121 -116
  99. package/docs/specs/agent-configurability.md +18 -18
  100. package/docs/specs/agent-rename.md +18 -18
  101. package/docs/team-memory.md +38 -21
  102. package/docs/timeouts-and-liveness.md +118 -10
  103. package/docs/tutorials/01-install-and-connect.md +1 -1
  104. package/docs/watches.md +40 -39
  105. package/docs/workspace-manifests.md +4 -4
  106. package/docs/worktree-lifecycle.md +293 -14
  107. package/engine/README.md +46 -0
  108. package/engine/{ado-comment.js → ado/comment.js} +8 -8
  109. package/engine/{ado-git-auth.js → ado/git-auth.js} +4 -4
  110. package/engine/{ado.js → ado/index.js} +417 -63
  111. package/engine/{ado-status.js → ado/status.js} +6 -8
  112. package/engine/{ado-token.js → ado/token.js} +1 -1
  113. package/engine/{acp-transport.js → agents/acp-transport.js} +62 -22
  114. package/engine/{agent-worker-pool.js → agents/agent-worker-pool.js} +17 -8
  115. package/engine/{cc-worker-pool.js → agents/cc-worker-pool.js} +16 -6
  116. package/engine/{claude-md-context.js → agents/claude-md-context.js} +5 -5
  117. package/engine/{harness-context.js → agents/harness-context.js} +5 -5
  118. package/engine/{harness.js → agents/harness.js} +3 -3
  119. package/engine/{llm.js → agents/llm.js} +18 -14
  120. package/engine/{model-discovery.js → agents/model-discovery.js} +2 -2
  121. package/engine/{playbook.js → agents/playbook.js} +155 -22
  122. package/engine/{pooled-agent-process.js → agents/pooled-agent-process.js} +14 -12
  123. package/engine/{preflight.js → agents/preflight.js} +29 -10
  124. package/engine/{spawn-agent.js → agents/spawn-agent.js} +25 -14
  125. package/engine/{spawn-phase-watchdog.js → agents/spawn-phase-watchdog.js} +16 -7
  126. package/engine/{steering.js → agents/steering.js} +5 -5
  127. package/engine/{tools-inventory.js → agents/tools-inventory.js} +2 -2
  128. package/engine/{agent-api-validation.js → api/agent-api-validation.js} +2 -2
  129. package/engine/{api-validation.js → api/api-validation.js} +1 -1
  130. package/engine/api/bridge.js +787 -0
  131. package/engine/{cc-api-validation.js → api/cc-api-validation.js} +1 -1
  132. package/engine/api/companion.js +560 -0
  133. package/engine/{content-api-validation.js → api/content-api-validation.js} +2 -2
  134. package/engine/{pr-issue-validation.js → api/pr-issue-validation.js} +33 -6
  135. package/engine/{settings-validation.js → api/settings-validation.js} +32 -4
  136. package/engine/api-contracts/agent-content.js +4 -4
  137. package/engine/api-contracts/capability-manifest.js +236 -0
  138. package/engine/api-contracts/capability-protocol.js +333 -0
  139. package/engine/api-contracts/cc-ops.js +1 -1
  140. package/engine/api-contracts/config-runtime.js +5 -0
  141. package/engine/api-contracts/core.js +28 -1
  142. package/engine/api-contracts/index.js +100 -0
  143. package/engine/api-contracts/orchestration.js +18 -5
  144. package/engine/api-contracts/pull-requests.js +37 -6
  145. package/engine/api-contracts/qa-process.js +29 -6
  146. package/engine/api-contracts/work-plan-prd.js +21 -1
  147. package/engine/cloud/contract.js +212 -0
  148. package/engine/cloud/index.js +159 -0
  149. package/engine/{execution-model.js → core/execution-model.js} +1 -1
  150. package/engine/{features.js → core/features.js} +4 -4
  151. package/engine/{operator-identity.js → core/operator-identity.js} +1 -1
  152. package/engine/{queries.js → core/queries.js} +201 -36
  153. package/engine/{safe-expr.js → core/safe-expr.js} +1 -1
  154. package/engine/{shared.js → core/shared.js} +1637 -175
  155. package/engine/{stdio-timestamps.js → core/stdio-timestamps.js} +1 -1
  156. package/engine/{untrusted-fence.js → core/untrusted-fence.js} +3 -3
  157. package/engine/db/index.js +11 -2
  158. package/engine/db/migrations/002-dispatches.js +3 -3
  159. package/engine/db/migrations/003-work-items.js +1 -1
  160. package/engine/db/migrations/004-pull-requests.js +1 -1
  161. package/engine/db/migrations/006-metrics.js +1 -1
  162. package/engine/db/migrations/007-watches.js +2 -2
  163. package/engine/db/migrations/008-small-state.js +1 -1
  164. package/engine/db/migrations/009-qa.js +1 -1
  165. package/engine/db/migrations/010-pr-links.js +1 -1
  166. package/engine/db/migrations/011-remaining-state.js +1 -1
  167. package/engine/db/migrations/012-steering-deliveries.js +2 -2
  168. package/engine/db/migrations/013-backfill-broken-note-links.js +1 -1
  169. package/engine/db/migrations/014-pr-fix-target-prefs.js +2 -2
  170. package/engine/db/migrations/015-plans-prds.js +0 -0
  171. package/engine/db/migrations/018-sql-only-cutover.js +2 -2
  172. package/engine/db/migrations/021-archived-work-items.js +1 -1
  173. package/engine/db/migrations/022-global-cc-session.js +1 -1
  174. package/engine/db/migrations/023-engine-state.js +1 -1
  175. package/engine/db/migrations/025-malformed-work-item-phantoms.js +1 -1
  176. package/engine/db/migrations/027-review-learning-lifecycle.js +1 -1
  177. package/engine/db/migrations/029-repair-reused-versions.js +20 -0
  178. package/engine/db/migrations/031-pr-author-identity.js +137 -0
  179. package/engine/{consolidation.js → memory/consolidation.js} +6 -6
  180. package/engine/{kb-sweep-runner.js → memory/kb-sweep-runner.js} +2 -2
  181. package/engine/{kb-sweep.js → memory/kb-sweep.js} +9 -7
  182. package/engine/{memory-retrieval.js → memory/memory-retrieval.js} +46 -4
  183. package/engine/{memory-store.js → memory/memory-store.js} +3 -3
  184. package/engine/{promotion.js → memory/promotion.js} +3 -3
  185. package/engine/{review-learning-backfill.js → memory/review-learning-backfill.js} +6 -6
  186. package/engine/{review-learning.js → memory/review-learning.js} +10 -5
  187. package/engine/{diagnostics-memory.js → observability/diagnostics-memory.js} +1 -1
  188. package/engine/{logs-store.js → observability/logs-store.js} +5 -5
  189. package/engine/{metrics-store.js → observability/metrics-store.js} +4 -4
  190. package/engine/{check-status.js → operations/check-status.js} +3 -3
  191. package/engine/{cli.js → operations/cli.js} +271 -113
  192. package/engine/{distribution.js → operations/distribution.js} +5 -6
  193. package/engine/{cleanup.js → orchestration/cleanup.js} +72 -45
  194. package/engine/{cooldown.js → orchestration/cooldown.js} +5 -5
  195. package/engine/{dispatch-events.js → orchestration/dispatch-events.js} +2 -2
  196. package/engine/{dispatch.js → orchestration/dispatch.js} +129 -36
  197. package/engine/orchestration/failed-scheduled-cleanup.js +274 -0
  198. package/engine/{lifecycle.js → orchestration/lifecycle.js} +198 -90
  199. package/engine/{meeting.js → orchestration/meeting.js} +6 -16
  200. package/engine/{pipeline.js → orchestration/pipeline.js} +12 -12
  201. package/engine/{pre-dispatch-eval.js → orchestration/pre-dispatch-eval.js} +10 -9
  202. package/engine/{routing.js → orchestration/routing.js} +3 -3
  203. package/engine/{schedule-bootstrap.js → orchestration/schedule-bootstrap.js} +4 -4
  204. package/engine/{scheduler.js → orchestration/scheduler.js} +38 -8
  205. package/engine/{timeout.js → orchestration/timeout.js} +158 -109
  206. package/engine/{db-events.js → persistence/db-events.js} +2 -2
  207. package/engine/{dispatch-store.js → persistence/dispatch-store.js} +7 -7
  208. package/engine/{inbox-store.js → persistence/inbox-store.js} +2 -2
  209. package/engine/{note-link-backfill.js → persistence/note-link-backfill.js} +4 -4
  210. package/engine/{pr-fix-target-store.js → persistence/pr-fix-target-store.js} +8 -8
  211. package/engine/{pull-requests-store.js → persistence/pull-requests-store.js} +21 -7
  212. package/engine/{small-state-store.js → persistence/small-state-store.js} +31 -31
  213. package/engine/persistence/state-operations.js +350 -0
  214. package/engine/{steering-store.js → persistence/steering-store.js} +6 -6
  215. package/engine/{issues.js → planning/issues.js} +2 -2
  216. package/engine/{plan-prd-validation.js → planning/plan-prd-validation.js} +8 -2
  217. package/engine/planning/prd-result-sidecar.js +190 -0
  218. package/engine/{prd-store.js → planning/prd-store.js} +17 -17
  219. package/engine/{project-discovery.js → planning/project-discovery.js} +5 -5
  220. package/engine/{projects.js → planning/projects.js} +10 -10
  221. package/engine/{resolve-area.js → planning/resolve-area.js} +1 -1
  222. package/engine/{work-item-validation.js → planning/work-item-validation.js} +39 -3
  223. package/engine/{work-items-store.js → planning/work-items-store.js} +29 -21
  224. package/engine/{keep-process-sweep.js → processes/keep-process-sweep.js} +57 -17
  225. package/engine/{managed-spawn-launcher.js → processes/managed-spawn-launcher.js} +3 -3
  226. package/engine/{managed-spawn.js → processes/managed-spawn.js} +97 -46
  227. package/engine/{process-utils.js → processes/process-utils.js} +599 -55
  228. package/engine/{abandoned-pr-reconciliation.js → providers/abandoned-pr-reconciliation.js} +17 -7
  229. package/engine/{comment-classifier.js → providers/comment-classifier.js} +85 -17
  230. package/engine/{comment-format.js → providers/comment-format.js} +5 -5
  231. package/engine/{gh-comment.js → providers/gh-comment.js} +15 -15
  232. package/engine/{gh-token.js → providers/gh-token.js} +4 -4
  233. package/engine/{github.js → providers/github.js} +131 -54
  234. package/engine/{pr-action.js → providers/pr-action.js} +13 -12
  235. package/engine/{pr-clone-keep.js → providers/pr-clone-keep.js} +7 -7
  236. package/engine/{pr-devbox.js → providers/pr-devbox.js} +6 -6
  237. package/engine/{pr-fix-target.js → providers/pr-fix-target.js} +13 -13
  238. package/engine/{pr-remote-patch.js → providers/pr-remote-patch.js} +4 -4
  239. package/engine/{pr-resolve.js → providers/pr-resolve.js} +7 -7
  240. package/engine/{pr-temp-clone.js → providers/pr-temp-clone.js} +5 -5
  241. package/engine/{pr-track.js → providers/pr-track.js} +11 -13
  242. package/engine/{shared-branch-pr-reconcile.js → providers/shared-branch-pr-reconcile.js} +4 -4
  243. package/engine/qa/auto-prd-qa.js +313 -0
  244. package/engine/{qa-from-prd.js → qa/from-prd.js} +42 -12
  245. package/engine/qa/prd-session.js +240 -0
  246. package/engine/{qa-process-validation.js → qa/process-validation.js} +14 -9
  247. package/engine/{qa-runbooks.js → qa/runbooks.js} +1 -1
  248. package/engine/{qa-runs.js → qa/runs.js} +286 -15
  249. package/engine/{qa-sessions.js → qa/sessions.js} +595 -49
  250. package/engine/qa/visual-journey.js +654 -0
  251. package/engine/{qa-runners.js → qa-runners/index.js} +7 -7
  252. package/engine/qa-runners/maestro.js +3 -3
  253. package/engine/qa-runners/playwright.js +2 -2
  254. package/engine/{restart-health.js → recovery/restart-health.js} +48 -4
  255. package/engine/recovery/stop-stack.js +607 -0
  256. package/engine/{supervisor.js → recovery/supervisor.js} +105 -175
  257. package/engine/{watchdog.js → recovery/watchdog.js} +136 -13
  258. package/engine/runtimes/claude.js +14 -12
  259. package/engine/runtimes/codex.js +8 -6
  260. package/engine/runtimes/copilot.js +17 -16
  261. package/engine/{watch-actions.js → watches/actions.js} +13 -13
  262. package/engine/{watches.js → watches/index.js} +43 -32
  263. package/engine/{watches-store.js → watches/store.js} +4 -4
  264. package/engine/{create-pr-worktree.js → worktrees/create-pr.js} +1 -1
  265. package/engine/{worktree-gc.js → worktrees/gc.js} +70 -22
  266. package/engine/worktrees/inventory.js +671 -0
  267. package/engine/{live-checkout.js → worktrees/live-checkout.js} +4 -4
  268. package/engine/{worktree-pool.js → worktrees/pool.js} +2 -2
  269. package/engine/{worktree-preflight.js → worktrees/preflight.js} +1 -0
  270. package/engine/worktrees/quarantine-refs.js +173 -0
  271. package/engine.js +1137 -208
  272. package/minions.js +147 -77
  273. package/package.json +10 -6
  274. package/playbooks/_pr-description-audit.md +110 -78
  275. package/playbooks/build-fix-complex.md +2 -0
  276. package/playbooks/fix.md +16 -12
  277. package/playbooks/implement-shared.md +2 -0
  278. package/playbooks/implement.md +19 -20
  279. package/playbooks/plan-to-prd.md +18 -3
  280. package/playbooks/qa-session-draft.md +136 -1
  281. package/playbooks/qa-session-execute.md +80 -2
  282. package/playbooks/qa-session-setup.md +17 -1
  283. package/playbooks/qa-validate.md +1 -1
  284. package/playbooks/setup.md +2 -0
  285. package/playbooks/shared-rules.md +25 -32
  286. package/playbooks/templates/followup-dispatch.md +4 -3
  287. package/playbooks/verify.md +1 -1
  288. package/prompts/cc-system.md +19 -27
  289. package/watch-plugins/README.md +92 -0
  290. package/watch-plugins/ado-author-prs.js +336 -0
  291. package/watch-plugins/gh-author-prs.js +375 -0
  292. package/watch-plugins/http.js +474 -0
  293. package/watch-plugins/teams-channel.js +869 -0
  294. package/docs/dev-composite-workflow.md +0 -101
  295. package/docs/pr-screenshots/pr-886/after-single-header.png +0 -0
  296. package/docs/pr-screenshots/pr-886/before-duplicate-header.png +0 -0
  297. package/docs/pr-screenshots/pr-895/01-cancellation-reason-detail.png +0 -0
  298. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
  299. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
  300. package/docs/pr-screenshots/pr-901/projects-tab-default.png +0 -0
  301. package/docs/pr-screenshots/pr-901/projects-tab-fmf-selected.png +0 -0
  302. package/docs/pr-screenshots/pr-916/model-picker-AFTER-crop.png +0 -0
  303. package/docs/pr-screenshots/pr-916/model-picker-AFTER.png +0 -0
  304. package/docs/pr-screenshots/pr-916/model-picker-BEFORE-crop.png +0 -0
  305. package/docs/pr-screenshots/pr-916/model-picker-BEFORE.png +0 -0
  306. package/docs/pr-screenshots/pr-916/model-picker-dropdown-AFTER.png +0 -0
  307. package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
  308. package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
  309. package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
  310. package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
  311. package/docs/visual-evidence-ci.md +0 -103
  312. package/engine/bridge.js +0 -379
  313. package/engine/quarantine-refs.js +0 -103
  314. package/engine/state-operations.js +0 -178
  315. /package/engine/{steering-constraints.js → agents/steering-constraints.js} +0 -0
@@ -14,8 +14,9 @@
14
14
  // only. State work is delegated to the *installed* CLI (`minions state backup`,
15
15
  // `minions init --force`, `minions restart`) rather than reimplemented here —
16
16
  // those are the supported entry points and already own the consistency
17
- // contracts (`engine/state-operations.js#backupState` does
18
- // `PRAGMA wal_checkpoint(TRUNCATE)` + `VACUUM INTO`).
17
+ // contracts (`engine/persistence/state-operations.js#backupState` does a
18
+ // best-effort `PRAGMA wal_checkpoint(PASSIVE)` + an authoritative
19
+ // `VACUUM INTO`, which is consistent even when the WAL cannot be quiesced).
19
20
  //
20
21
  // Token handling: the ADO access token is acquired per-run, written only into a
21
22
  // 0600 temporary npm userconfig inside the OS temp dir, and that file is removed
@@ -29,7 +30,7 @@ const { spawnSync } = require('child_process');
29
30
 
30
31
  // ─── Channel constants ──────────────────────────────────────────────────────
31
32
 
32
- /** Azure DevOps resource id used for `az account get-access-token` (mirrors engine/ado-token.js). */
33
+ /** Azure DevOps resource id used for `az account get-access-token` (mirrors engine/ado/token.js). */
33
34
  const ADO_TOKEN_RESOURCE = '499b84ac-1321-427f-aa17-267ca6975798';
34
35
 
35
36
  /** Documented default feed: the ISS ProjectFeed-ISS Azure Artifacts npm registry. */
@@ -66,6 +67,9 @@ const USAGE = `
66
67
  --package <name> Package to install (default: ${INTERNAL_PACKAGE})
67
68
  --backup-dir <dir> Where to write the pre-migration state backup
68
69
  (default: <runtime root>/backups/internal-install-<timestamp>)
70
+ --runtime-root <dir> Pin the runtime root (MINIONS_HOME) this run reads, backs up,
71
+ synchronizes, and restarts. Required to disambiguate when more
72
+ than one candidate root carries runtime state.
69
73
  --keep-public Do not uninstall an existing ${PUBLIC_PACKAGE} global install
70
74
  --no-restart Sync runtime files but skip the health-verified restart
71
75
  --force Proceed even while agents are active (see docs/internal-install.md)
@@ -90,6 +94,7 @@ const VALUE_FLAGS = new Map([
90
94
  ['--registry', 'registry'],
91
95
  ['--package', 'packageName'],
92
96
  ['--backup-dir', 'backupDir'],
97
+ ['--runtime-root', 'runtimeRoot'],
93
98
  ]);
94
99
 
95
100
  /**
@@ -102,6 +107,7 @@ function parseArgs(argv) {
102
107
  registry: DEFAULT_REGISTRY,
103
108
  packageName: INTERNAL_PACKAGE,
104
109
  backupDir: null,
110
+ runtimeRoot: null,
105
111
  keepPublic: false,
106
112
  restart: true,
107
113
  force: false,
@@ -128,7 +134,17 @@ function parseArgs(argv) {
128
134
  if (!opts.packageName.startsWith('@') || !opts.packageName.includes('/')) {
129
135
  throw new Error(`--package must be a scoped package name, got: ${opts.packageName}`);
130
136
  }
137
+ // The generic VALUE_FLAGS parse accepts an empty/whitespace string, which
138
+ // path.resolve() would turn into the cwd — silently installing into a
139
+ // different root than the caller named.
140
+ if (opts.runtimeRoot !== null && !String(opts.runtimeRoot).trim()) {
141
+ throw new Error('--runtime-root requires a value');
142
+ }
131
143
  opts.registry = normalizeRegistry(opts.registry);
144
+ // An explicitly pinned root is resolved here so every downstream comparison —
145
+ // candidate dedupe, the MINIONS_HOME handed to children, the printed plan —
146
+ // sees the same absolute path.
147
+ if (opts.runtimeRoot) opts.runtimeRoot = path.resolve(opts.runtimeRoot);
132
148
  return opts;
133
149
  }
134
150
 
@@ -154,8 +170,54 @@ function packageScope(packageName) {
154
170
  return scope;
155
171
  }
156
172
 
173
+ // ─── Private-feed channels (W-ms9bhf9k001j29cb) ─────────────────────────────
174
+ //
175
+ // Which npm distributions are served by a private feed instead of the caller's
176
+ // ordinary registry. This is the table `minions update` consults to decide
177
+ // whether an update needs feed auth at all: a package that is not listed keeps
178
+ // the plain `npm view` / `npm install -g` behavior with no token, no temporary
179
+ // config, and no registry override.
180
+ const INTERNAL_FEED_CHANNELS = Object.freeze([
181
+ Object.freeze({ packageName: INTERNAL_PACKAGE, registry: DEFAULT_REGISTRY }),
182
+ ]);
183
+
184
+ /**
185
+ * Resolve the private feed that owns `packageName`, or null when the package is
186
+ * served by whatever registry the caller's npm config already points at.
187
+ *
188
+ * Pure: no filesystem, no network, no environment. `registry` overrides the
189
+ * channel default (the `--registry` flag on both entry points) and is validated
190
+ * the same way, so an override can never silently downgrade to http.
191
+ */
192
+ function resolveInternalFeed(packageName, { registry } = {}) {
193
+ const channel = INTERNAL_FEED_CHANNELS.find(c => c.packageName === packageName);
194
+ if (!channel) return null;
195
+ return {
196
+ packageName,
197
+ registry: normalizeRegistry(registry || channel.registry),
198
+ scope: packageScope(packageName),
199
+ };
200
+ }
201
+
157
202
  // ─── Command construction (pure) ────────────────────────────────────────────
158
203
 
204
+ /**
205
+ * Pin a scoped package's registry at the ONE npm precedence layer ambient
206
+ * configuration cannot override.
207
+ *
208
+ * npm resolves a SCOPED package through `@scope:registry`, and `--registry`
209
+ * only sets the *default* registry — so a `@opg-microsoft:registry=<public
210
+ * proxy>` line in the caller's config wins over `--registry` and routes the
211
+ * request to the wrong feed (observed as a misleading E404 for a package that
212
+ * exists). npm's precedence is `cli > env > ./.npmrc > --userconfig > global`,
213
+ * which also means a project `.npmrc` in the caller's cwd beats the temporary
214
+ * userconfig this script stages. The command line is the only authoritative
215
+ * layer, and the registry URL is not a secret, so pinning it there is safe.
216
+ */
217
+ function buildScopeRegistryArg({ packageName, registry }) {
218
+ return `--${packageScope(packageName)}:registry=${normalizeRegistry(registry)}`;
219
+ }
220
+
159
221
  /**
160
222
  * `az` arguments for a short-lived ADO access token. `-o tsv` keeps the raw
161
223
  * token off the JSON path so it is never pretty-printed by az itself.
@@ -194,11 +256,13 @@ function buildNpmrc({ registry, scope, token, baseConfig = '' }) {
194
256
  }
195
257
 
196
258
  function buildNpmViewArgs({ packageName, versionSpec, registry, userconfig }) {
197
- return ['view', `${packageName}@${versionSpec}`, 'version', '--registry', registry, '--userconfig', userconfig, '--json'];
259
+ return ['view', `${packageName}@${versionSpec}`, 'version', '--registry', registry, '--userconfig', userconfig,
260
+ buildScopeRegistryArg({ packageName, registry }), '--json'];
198
261
  }
199
262
 
200
263
  function buildNpmInstallArgs({ packageName, version, registry, userconfig }) {
201
- return ['install', '-g', `${packageName}@${version}`, '--registry', registry, '--userconfig', userconfig];
264
+ return ['install', '-g', `${packageName}@${version}`, '--registry', registry, '--userconfig', userconfig,
265
+ buildScopeRegistryArg({ packageName, registry })];
202
266
  }
203
267
 
204
268
  /**
@@ -213,6 +277,7 @@ function buildNpmPackArgs({ packageName, version, registry, userconfig, destinat
213
277
  'pack', `${packageName}@${version}`,
214
278
  '--registry', registry,
215
279
  '--userconfig', userconfig,
280
+ buildScopeRegistryArg({ packageName, registry }),
216
281
  '--pack-destination', destination,
217
282
  '--json',
218
283
  ];
@@ -630,8 +695,161 @@ function redact(text, secrets) {
630
695
 
631
696
  // ─── Runtime-root inspection ────────────────────────────────────────────────
632
697
 
633
- function resolveRuntimeRoot(env = process.env) {
634
- return env.MINIONS_HOME ? path.resolve(env.MINIONS_HOME) : path.join(os.homedir(), '.minions');
698
+ /**
699
+ * The engine's root-pointer file (`engine/core/shared.js` ROOT_POINTER_PATH, written
700
+ * by `minions init`). Duplicated rather than imported: this installer must run
701
+ * on a machine with no Minions installed, so it stays Node-built-ins-only.
702
+ */
703
+ function runtimeRootPointerPath(homedir = os.homedir()) {
704
+ return path.join(homedir, '.minions-root');
705
+ }
706
+
707
+ function readRuntimeRootPointer(homedir = os.homedir()) {
708
+ try {
709
+ const pointed = fs.readFileSync(runtimeRootPointerPath(homedir), 'utf8').trim();
710
+ return pointed ? path.resolve(pointed) : null;
711
+ } catch { return null; }
712
+ }
713
+
714
+ /**
715
+ * Nearest ancestor of `cwd` (inclusive) that looks like a Minions source
716
+ * checkout — mirrors the `preferSourceCheckout` branch of the engine's
717
+ * `resolveMinionsHome()`, which returns a package root that contains `.git`.
718
+ * That branch is precisely why a repo checkout can become the engine's runtime
719
+ * root, so the installer has to see the same candidate the engine would.
720
+ *
721
+ * `engine.js` is required alongside `.git`: `.git` alone is every repository on
722
+ * the machine, and a directory the engine could never boot from is not a
723
+ * runtime root — listing it would manufacture ambiguity instead of exposing it.
724
+ */
725
+ function findCheckoutRoot(cwd, exists) {
726
+ let dir = cwd ? path.resolve(String(cwd)) : null;
727
+ for (let depth = 0; dir && depth < 64; depth++) {
728
+ if (exists(path.join(dir, '.git')) && exists(path.join(dir, 'engine.js'))) return dir;
729
+ const parent = path.dirname(dir);
730
+ if (parent === dir) break;
731
+ dir = parent;
732
+ }
733
+ return null;
734
+ }
735
+
736
+ function describeRuntimeRootSource(source, homedir = os.homedir()) {
737
+ switch (source) {
738
+ case 'flag': return '--runtime-root';
739
+ case 'env': return 'MINIONS_HOME';
740
+ case 'pointer': return `recorded in ${runtimeRootPointerPath(homedir)}`;
741
+ case 'default': return 'the default ~/.minions';
742
+ case 'checkout': return 'a source checkout at or above the current directory';
743
+ default: return String(source);
744
+ }
745
+ }
746
+
747
+ /**
748
+ * Decide, once and explicitly, which runtime root this run owns.
749
+ *
750
+ * The old resolution was `MINIONS_HOME || ~/.minions`: cwd-blind, and blind to
751
+ * the root-pointer file the ENGINE consults. The engine additionally prefers a
752
+ * package root containing `.git`. That divergence is how a repo checkout
753
+ * silently becomes the runtime root — the installer backs up and synchronizes
754
+ * one root while the engine boots out of another.
755
+ *
756
+ * Every candidate the engine could pick is enumerated here, deduplicated by
757
+ * resolved path (highest-priority source wins), and scored by whether it carries
758
+ * a NON-EMPTY `engine/state.db` — a zero-byte database is an aborted init, not a
759
+ * runtime worth protecting.
760
+ *
761
+ * Ranking:
762
+ * 1. `--runtime-root` explicit operator pin, always wins
763
+ * 2. two or more stateful roots REFUSE (`ambiguous`) — never guess
764
+ * 3. `MINIONS_HOME` explicit environment pin
765
+ * 4. the single stateful root the only way a checkout can be selected, and
766
+ * only when nothing else carries state
767
+ * 5. the root-pointer file what `minions init` last recorded
768
+ * 6. `~/.minions` the default (fresh install)
769
+ *
770
+ * Because a stateful candidate outranks every stateless one and two stateful
771
+ * candidates are a refusal, a repo checkout can never win over a root that
772
+ * already carries state.
773
+ *
774
+ * Pure apart from the injected `exists` / `size` / `readPointer` probes, so the
775
+ * contract is asserted directly rather than inferred from a live migration.
776
+ */
777
+ function resolveAuthoritativeRuntimeRoot(options = {}) {
778
+ const {
779
+ flag = null,
780
+ env = process.env,
781
+ cwd = process.cwd(),
782
+ homedir = os.homedir(),
783
+ exists = p => fs.existsSync(p),
784
+ size = (p) => { try { return fs.statSync(p).size; } catch { return 0; } },
785
+ readPointer = null,
786
+ } = options;
787
+
788
+ const pointer = readPointer ? readPointer() : readRuntimeRootPointer(homedir);
789
+ const defaultRoot = path.resolve(path.join(homedir, '.minions'));
790
+ const ordered = [
791
+ ['flag', flag],
792
+ ['env', env && env.MINIONS_HOME],
793
+ ['pointer', pointer],
794
+ ['default', defaultRoot],
795
+ ['checkout', findCheckoutRoot(cwd, exists)],
796
+ ];
797
+
798
+ const candidates = [];
799
+ const seen = new Set();
800
+ for (const [source, value] of ordered) {
801
+ if (!value) continue;
802
+ const root = path.resolve(String(value));
803
+ if (seen.has(root)) continue;
804
+ seen.add(root);
805
+ const statePath = path.join(root, 'engine', 'state.db');
806
+ candidates.push({ source, root, hasState: exists(statePath) && size(statePath) > 0 });
807
+ }
808
+
809
+ const bySource = source => candidates.find(c => c.source === source) || null;
810
+ const pick = (candidate, reason) => ({
811
+ root: candidate.root,
812
+ source: candidate.source,
813
+ candidates,
814
+ ambiguous: false,
815
+ reason,
816
+ });
817
+
818
+ const flagged = bySource('flag');
819
+ if (flagged) return pick(flagged, `pinned explicitly with --runtime-root ${flagged.root}`);
820
+
821
+ const stateful = candidates.filter(c => c.hasState);
822
+ if (stateful.length > 1) {
823
+ const describe = c => ` ${c.root} (${describeRuntimeRootSource(c.source, homedir)})`
824
+ + `${c.hasState ? ' [carries state]' : ''}`;
825
+ return {
826
+ root: null,
827
+ source: null,
828
+ candidates,
829
+ ambiguous: true,
830
+ reason: `${stateful.length} candidate runtime roots carry a non-empty engine/state.db, `
831
+ + 'and no --runtime-root was given.\n'
832
+ + ' Candidates considered:\n'
833
+ + candidates.map(describe).join('\n')
834
+ + '\n Refusing to guess which one is authoritative. Re-run pinned to the one you mean:\n'
835
+ + stateful.map(c => ` --runtime-root ${c.root}`).join('\n'),
836
+ };
837
+ }
838
+
839
+ const envRoot = bySource('env');
840
+ if (envRoot) return pick(envRoot, 'pinned by MINIONS_HOME');
841
+
842
+ if (stateful.length === 1) {
843
+ return pick(stateful[0], `the only candidate carrying runtime state (${describeRuntimeRootSource(stateful[0].source, homedir)})`);
844
+ }
845
+
846
+ const pointerRoot = bySource('pointer');
847
+ if (pointerRoot && exists(pointerRoot.root)) {
848
+ return pick(pointerRoot, `recorded in ${runtimeRootPointerPath(homedir)}`);
849
+ }
850
+
851
+ const fallback = candidates.find(c => c.root === defaultRoot);
852
+ return pick(fallback, `the default runtime root${fallback.hasState ? '' : ' (fresh install)'}`);
635
853
  }
636
854
 
637
855
  /**
@@ -679,8 +897,8 @@ function resolveBackupCli(state, exists = p => fs.existsSync(p)) {
679
897
  * Decide how to back up the existing runtime's SQLite state, preferring the
680
898
  * option that cannot go stale:
681
899
  *
682
- * 1. the runtime's OWN `engine/state-operations.js`. `minions init` copies
683
- * `engine/*.js` into the runtime root, so it is present for every installed
900
+ * 1. the runtime's OWN `engine/persistence/state-operations.js`. `minions init` copies
901
+ * packaged engine JavaScript into the runtime root, so it is present for every installed
684
902
  * runtime and is by construction version-matched to that runtime's
685
903
  * `engine/state.db`. Resolving a CLI is not sufficient on its own — the
686
904
  * published public package predates the `minions state` command, so an
@@ -690,10 +908,16 @@ function resolveBackupCli(state, exists = p => fs.existsSync(p)) {
690
908
  */
691
909
  function resolveBackupStrategy(state, exists = p => fs.existsSync(p)) {
692
910
  const runtimeRoot = (state && state.runtimeRoot) || null;
693
- const modulePath = runtimeRoot ? path.join(runtimeRoot, 'engine', 'state-operations.js') : null;
911
+ const modulePaths = runtimeRoot
912
+ ? [
913
+ path.join(runtimeRoot, 'engine', 'persistence', 'state-operations.js'),
914
+ path.join(runtimeRoot, 'engine', 'state-operations.js'),
915
+ ]
916
+ : [];
917
+ const modulePath = modulePaths.find(exists) || null;
694
918
  const cli = resolveBackupCli(state, exists);
695
- const candidates = [modulePath, ...cli.candidates].filter(Boolean);
696
- if (modulePath && exists(modulePath)) return { kind: 'runtime-module', modulePath, candidates };
919
+ const candidates = [...modulePaths, ...cli.candidates].filter(Boolean);
920
+ if (modulePath) return { kind: 'runtime-module', modulePath, candidates };
697
921
  if (cli.path) return { kind: 'cli', cliPath: cli.path, candidates };
698
922
  return { kind: 'none', candidates };
699
923
  }
@@ -701,7 +925,16 @@ function resolveBackupStrategy(state, exists = p => fs.existsSync(p)) {
701
925
  /** Run the runtime's own backupState() out-of-process against its own MINIONS_HOME. */
702
926
  const BACKUP_MODULE_SCRIPT = 'require(process.argv[1]).backupState(process.argv[2]);';
703
927
 
704
- /** Runtime paths that MUST survive the migration. */
928
+ /**
929
+ * Runtime paths that MUST survive the migration.
930
+ *
931
+ * Presence-only by construction — an emptied `engine/state.db` still passes
932
+ * this list, which is why the `verify-continuity` step exists and compares a
933
+ * row-level `summarizeState()` census instead. Schedules, pipeline RUNS,
934
+ * watches, meetings, and QA sessions live in SQL and are covered by that
935
+ * census; `pipelines/` (definitions), `prompts/`, `playbooks/`, and `agents/`
936
+ * are on disk and are covered here.
937
+ */
705
938
  const PRESERVED_PATHS = [
706
939
  'config.json',
707
940
  'engine/state.db',
@@ -711,6 +944,10 @@ const PRESERVED_PATHS = [
711
944
  'knowledge',
712
945
  'projects',
713
946
  'pinned.md',
947
+ 'pipelines',
948
+ 'prompts',
949
+ 'playbooks',
950
+ 'agents',
714
951
  ];
715
952
 
716
953
  /**
@@ -738,7 +975,7 @@ function diffPreservedPaths(before, after) {
738
975
  * operator- or agent-authored:
739
976
  *
740
977
  * - `routing.md` the operator's work-type → agent routing table
741
- * - `knowledge/agents/` per-agent memory written by engine/consolidation.js
978
+ * - `knowledge/agents/` per-agent memory written by engine/memory/consolidation.js
742
979
  *
743
980
  * A package-channel cutover must not reseed either. Their bytes are captured
744
981
  * before `sync-init` and put back after it, so the sync can still add newly
@@ -824,7 +1061,7 @@ function assessRollback({ publicUninstalled = false, internalUsable = false } =
824
1061
  /**
825
1062
  * Count live agent processes by walking the dispatch PID files the engine
826
1063
  * writes under `<runtime root>/engine/tmp/` — both the per-dispatch directory
827
- * layout and the legacy flat layout (see engine/shared.js#forEachPidFile).
1064
+ * layout and the legacy flat layout (see engine/core/shared.js#forEachPidFile).
828
1065
  * Filesystem-based on purpose: it works with the dashboard down, which is
829
1066
  * exactly when a migration is most likely to be attempted.
830
1067
  */
@@ -885,12 +1122,645 @@ function assessAgentActivity({ activeAgents, force }) {
885
1122
  };
886
1123
  }
887
1124
 
1125
+ // ─── Service quiescence ─────────────────────────────────────────────────────
1126
+
1127
+ /**
1128
+ * The three files the SQLite state actually consists of. `state.db` is the
1129
+ * database; `-wal` is the write-ahead log holding committed-but-uncheckpointed
1130
+ * transactions; `-shm` is the WAL shared-memory index.
1131
+ *
1132
+ * `-shm` is the liveness signal that matters here: SQLite creates it when the
1133
+ * FIRST connection opens the database in WAL mode and removes it when the LAST
1134
+ * one closes cleanly. A present, non-empty `-shm` therefore means a process
1135
+ * still holds the database open even when every PID we know about is gone.
1136
+ */
1137
+ const STATE_DB_FILES = ['engine/state.db', 'engine/state.db-wal', 'engine/state.db-shm'];
1138
+ const STATE_SHM_REL = 'engine/state.db-shm';
1139
+
1140
+ /** Files copied verbatim beside the backup, and put back by a state rollback. */
1141
+ const BACKUP_COPIED_FILES = ['config.json', 'routing.md', 'pinned.md'];
1142
+
1143
+ /** Subdirectory of the backup holding the ORIGINAL, byte-for-byte state files. */
1144
+ const RETAINED_ORIGINAL_DIR = 'original';
1145
+
1146
+ const QUIESCE_TIMEOUT_MS = 60000;
1147
+ const QUIESCE_POLL_MS = 500;
1148
+ const CLI_STOP_TIMEOUT_MS = 120000;
1149
+ /** Budget for the read-only capability probe: `minions help` prints and exits. */
1150
+ const CLI_HELP_TIMEOUT_MS = 20000;
1151
+
1152
+ /**
1153
+ * The stop contract to prefer, as the EXACT argv it issues.
1154
+ *
1155
+ * A bare `minions stop` is delegated to `engine.js stop`, which writes stop
1156
+ * intent and RETURNS — it asks only the engine to stand down and never touches
1157
+ * the dashboard or the supervisor. Those hold `engine/state.db` independently,
1158
+ * so the `-shm` gate below could never go green after a "successful" stop. The
1159
+ * whole-stack verb tears the supervisor, dashboard, and engine down in order and
1160
+ * `--wait` blocks until the handles are actually released.
1161
+ */
1162
+ const WHOLE_STACK_STOP_ARGS = ['stop', '--all', '--wait'];
1163
+
1164
+ /**
1165
+ * The fallback for a CLI that predates the whole-stack verb. Mid-cutover the
1166
+ * resolved CLI is frequently the OLDER PUBLIC package (see `resolveBackupCli`),
1167
+ * which would reject or ignore `--all`, so it gets the verb it does know plus
1168
+ * the wait loop that has always backed it.
1169
+ */
1170
+ const LEGACY_STOP_ARGS = ['stop'];
1171
+
1172
+ /**
1173
+ * The verb the capability probe runs.
1174
+ *
1175
+ * `help` is the FIRST branch of the CLI's command dispatch, prints usage, and
1176
+ * exits — no runtime root, no daemon, no state. `minions stop --help` would be
1177
+ * catastrophic here: `stop` is in the CLI's `engineCmds` delegation set, so an
1178
+ * older CLI would forward it to `engine.js stop` and actually stop the engine
1179
+ * as a side effect of asking whether it can.
1180
+ */
1181
+ const CLI_CAPABILITY_PROBE_ARGS = ['help'];
1182
+
1183
+ /** Operator-facing names for the three ways this gate can try to stop a runtime. */
1184
+ const STOP_PATH_LABELS = {
1185
+ 'whole-stack': 'whole-stack (`minions stop --all --wait`)',
1186
+ legacy: 'legacy fallback (`minions stop` + wait loop)',
1187
+ none: 'no stop issued (wait loop only)',
1188
+ };
1189
+
1190
+ /**
1191
+ * Read a CLI's OWN help text and report which stop flags it advertises.
1192
+ *
1193
+ * Deliberately behavioral: the installed CLI is asked what it can do, and the
1194
+ * answer comes from the binary that will actually run. Comparing version strings
1195
+ * would be a guess across two independently-versioned publish channels — the
1196
+ * internal feed and the public registry share no ordering — and the failure mode
1197
+ * of a wrong guess is an unknown flag passed to a live teardown command.
1198
+ *
1199
+ * `--wait` is required as well as `--all` because both are in the argv we issue.
1200
+ * Matching is per line for `--all` so an unrelated mention elsewhere in the help
1201
+ * cannot vouch for the stop verb; `--no-wait` (from `minions update`) cannot
1202
+ * match `--wait`, which needs two consecutive dashes.
1203
+ */
1204
+ function detectStopCapabilities(helpText) {
1205
+ const text = String(helpText || '');
1206
+ const wholeStack = text.split(/\r?\n/).some(line => /\bstop\b/.test(line) && /--all\b/.test(line));
1207
+ const wait = /--wait\b/.test(text);
1208
+ return { wholeStack, wait, supported: wholeStack && wait };
1209
+ }
1210
+
1211
+ /**
1212
+ * Ask the resolved CLI what it supports. FAILS CLOSED to the legacy verb: an
1213
+ * unreadable, empty, or unrecognized answer means we send the command every
1214
+ * published CLI has always understood, never one it might not.
1215
+ */
1216
+ function probeStopCapability({ cliPath, run, env, timeoutMs = CLI_HELP_TIMEOUT_MS } = {}) {
1217
+ if (!cliPath) {
1218
+ return { supported: false, probed: false, reason: 'no installed CLI to probe' };
1219
+ }
1220
+ let result;
1221
+ try {
1222
+ result = run(process.execPath, [cliPath, ...CLI_CAPABILITY_PROBE_ARGS], {
1223
+ timeout: timeoutMs,
1224
+ capture: true,
1225
+ allowFailure: true,
1226
+ env,
1227
+ });
1228
+ } catch (e) {
1229
+ return {
1230
+ supported: false,
1231
+ probed: false,
1232
+ reason: `the capability probe could not run (${e && e.message ? e.message : e})`,
1233
+ };
1234
+ }
1235
+ const help = `${(result && result.stdout) || ''}\n${(result && result.stderr) || ''}`;
1236
+ const capabilities = detectStopCapabilities(help);
1237
+ return {
1238
+ supported: capabilities.supported,
1239
+ probed: true,
1240
+ capabilities,
1241
+ reason: capabilities.supported
1242
+ ? 'the installed CLI advertises `minions stop --all [--wait]`'
1243
+ : 'the installed CLI does not advertise `minions stop --all [--wait]`',
1244
+ };
1245
+ }
1246
+
1247
+ /**
1248
+ * The detail lines every quiescence refusal prints, in ONE place so the cutover
1249
+ * gate and the state-restoring rollback cannot drift.
1250
+ *
1251
+ * Reports which stop contract ran and the exact argv it issued — without that,
1252
+ * "the runtime did not release the database" is indistinguishable between a
1253
+ * whole-stack teardown that genuinely could not finish and a legacy fallback
1254
+ * that only ever asked the engine to stand down.
1255
+ */
1256
+ function formatQuiescenceRefusal(quiescence = {}) {
1257
+ const stopPath = quiescence.stopPath || 'none';
1258
+ const issued = Array.isArray(quiescence.stopArgs) && quiescence.stopArgs.length
1259
+ ? `; issued: ${quiescence.stopArgs.join(' ')}`
1260
+ : '';
1261
+ const exit = Number.isInteger(quiescence.stopExitCode) && quiescence.stopExitCode !== 0
1262
+ ? ` (exit code ${quiescence.stopExitCode})`
1263
+ : '';
1264
+ const lines = [` Stop path: ${STOP_PATH_LABELS[stopPath] || STOP_PATH_LABELS.none}${issued}${exit}`];
1265
+ const holders = Array.isArray(quiescence.holders) ? quiescence.holders : [];
1266
+ if (holders.length) {
1267
+ lines.push(' Still holding the database:');
1268
+ for (const holder of holders) lines.push(` ${holder.name} PID ${holder.pid}`);
1269
+ } else {
1270
+ lines.push(` No recorded service PID is alive, but ${STATE_SHM_REL} is still non-empty —`);
1271
+ lines.push(' an unrecorded process (or a re-parented one) still has the database open.');
1272
+ }
1273
+ return lines;
1274
+ }
1275
+
1276
+ /**
1277
+ * The PIDs the runtime itself records for its long-lived services, read from
1278
+ * the files the running daemons write — so this works with the dashboard down,
1279
+ * which is exactly when a migration is most likely to be attempted:
1280
+ *
1281
+ * - `engine/control.json` `pid` the engine daemon
1282
+ * - `engine/dashboard-port.json` `pid` the dashboard
1283
+ * - `engine/supervisor.pid` the supervisor
1284
+ *
1285
+ * Agent processes are deliberately NOT included: they are covered by the
1286
+ * separate active-agent gate, and any database handle they hold is still
1287
+ * caught by the `-shm` evidence below.
1288
+ */
1289
+ function readServicePids(runtimeRoot, deps = {}) {
1290
+ const readFile = deps.readFileSync || (p => fs.readFileSync(p, 'utf8'));
1291
+ const sources = [
1292
+ ['engine', ['engine', 'control.json'], 'json'],
1293
+ ['dashboard', ['engine', 'dashboard-port.json'], 'json'],
1294
+ ['supervisor', ['engine', 'supervisor.pid'], 'text'],
1295
+ ];
1296
+ const found = [];
1297
+ for (const [name, segments, shape] of sources) {
1298
+ const file = path.join(runtimeRoot, ...segments);
1299
+ let raw;
1300
+ try { raw = String(readFile(file)); } catch { continue; }
1301
+ let pid = null;
1302
+ if (shape === 'json') {
1303
+ try { pid = Number(JSON.parse(raw).pid); } catch { pid = null; }
1304
+ } else {
1305
+ pid = Number(raw.trim());
1306
+ }
1307
+ if (Number.isInteger(pid) && pid > 0) found.push({ name, pid, file });
1308
+ }
1309
+ return found;
1310
+ }
1311
+
1312
+ /** Recorded service PIDs that are still running. */
1313
+ function detectRunningServices(runtimeRoot, deps = {}) {
1314
+ const isAlive = deps.isAlive || defaultIsAlive;
1315
+ return readServicePids(runtimeRoot, deps)
1316
+ .filter(entry => entry.pid !== process.pid && isAlive(entry.pid));
1317
+ }
1318
+
1319
+ /** `-shm` evidence for the pinned root: `{ present, bytes }`. */
1320
+ function readStateShm(runtimeRoot, deps = {}) {
1321
+ const stat = deps.statSync || (p => fs.statSync(p));
1322
+ try {
1323
+ const info = stat(path.join(runtimeRoot, ...STATE_SHM_REL.split('/')));
1324
+ return { present: true, bytes: Number(info && info.size) || 0 };
1325
+ } catch { return { present: false, bytes: 0 }; }
1326
+ }
1327
+
1328
+ /**
1329
+ * Pure verdict on whether the state database is free of live handles.
1330
+ *
1331
+ * BOTH conditions must hold: every recorded service PID is dead AND the WAL
1332
+ * shared-memory index is gone or zero-length. Either alone is a false green — a
1333
+ * PID file can be stale while a re-parented process still holds the database,
1334
+ * and `-shm` can lag a PID that has only just been signalled.
1335
+ */
1336
+ function assessDatabaseQuiescence({ running = [], shm = { present: false, bytes: 0 } } = {}) {
1337
+ const holders = running.map(entry => ({ name: entry.name, pid: entry.pid }));
1338
+ const shmHeld = !!shm.present && Number(shm.bytes) > 0;
1339
+ if (holders.length) {
1340
+ return {
1341
+ quiesced: false,
1342
+ holders,
1343
+ shmHeld,
1344
+ reason: `still running: ${holders.map(h => `${h.name} (PID ${h.pid})`).join(', ')}`,
1345
+ };
1346
+ }
1347
+ if (shmHeld) {
1348
+ return {
1349
+ quiesced: false,
1350
+ holders,
1351
+ shmHeld,
1352
+ reason: `${STATE_SHM_REL} is still present and non-empty (${shm.bytes} bytes), `
1353
+ + 'so a process still holds the database open',
1354
+ };
1355
+ }
1356
+ return { quiesced: true, holders: [], shmHeld: false, reason: 'no service holds engine/state.db' };
1357
+ }
1358
+
1359
+ /** Block the calling thread. The installer is deliberately spawnSync-only. */
1360
+ function sleepSync(ms) {
1361
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Number(ms) || 0); }
1362
+ catch { const until = Date.now() + (Number(ms) || 0); while (Date.now() < until) { /* spin */ } }
1363
+ }
1364
+
1365
+ /**
1366
+ * Stop the engine/dashboard running against the PINNED root and wait until the
1367
+ * database handles are actually released.
1368
+ *
1369
+ * Ordered before `backup-state` — and therefore before every uninstall and
1370
+ * package mutation — because a backup taken while the engine is writing is a
1371
+ * backup of a moving target, and rewriting the runtime `.js` files underneath a
1372
+ * live daemon is exactly the failure the restart contract does not cover.
1373
+ *
1374
+ * The stop verb is chosen by BEHAVIORAL probe of the resolved CLI, not by
1375
+ * version: `stop --all --wait` when that CLI advertises it, otherwise the legacy
1376
+ * `stop`. Both paths then run the same wait loop, because the CLI's own verdict
1377
+ * is never trusted — the `-shm` evidence below is.
1378
+ *
1379
+ * FAILS CLOSED: when the handles are not released inside the timeout the caller
1380
+ * gets `ok: false`, the path that was taken, and the exact PIDs still holding
1381
+ * the database.
1382
+ */
1383
+ function quiesceServices({
1384
+ runtimeRoot,
1385
+ cliPath = null,
1386
+ run,
1387
+ env,
1388
+ log = console.log,
1389
+ timeoutMs = QUIESCE_TIMEOUT_MS,
1390
+ pollMs = QUIESCE_POLL_MS,
1391
+ probeTimeoutMs = CLI_HELP_TIMEOUT_MS,
1392
+ deps = {},
1393
+ sleep = sleepSync,
1394
+ now = () => Date.now(),
1395
+ } = {}) {
1396
+ const probeState = () => assessDatabaseQuiescence({
1397
+ running: detectRunningServices(runtimeRoot, deps),
1398
+ shm: readStateShm(runtimeRoot, deps),
1399
+ });
1400
+ const startedAt = now();
1401
+ const initial = probeState();
1402
+ if (initial.quiesced) {
1403
+ return {
1404
+ ok: true,
1405
+ stopped: false,
1406
+ stopPath: 'none',
1407
+ stopArgs: null,
1408
+ stopExitCode: null,
1409
+ capabilityProbe: null,
1410
+ holders: [],
1411
+ waitedMs: 0,
1412
+ reason: initial.reason,
1413
+ };
1414
+ }
1415
+ log(` ${initial.reason}`);
1416
+ let stopped = false;
1417
+ let stopPath = 'none';
1418
+ let stopArgs = null;
1419
+ let stopExitCode = null;
1420
+ let capabilityProbe = null;
1421
+ if (cliPath) {
1422
+ capabilityProbe = probeStopCapability({ cliPath, run, env, timeoutMs: probeTimeoutMs });
1423
+ stopPath = capabilityProbe.supported ? 'whole-stack' : 'legacy';
1424
+ stopArgs = [cliPath, ...(capabilityProbe.supported ? WHOLE_STACK_STOP_ARGS : LEGACY_STOP_ARGS)];
1425
+ log(` ${capabilityProbe.reason}.`);
1426
+ log(` Stopping the runtime — ${STOP_PATH_LABELS[stopPath]} ...`);
1427
+ try {
1428
+ const result = run(process.execPath, stopArgs, {
1429
+ timeout: CLI_STOP_TIMEOUT_MS,
1430
+ capture: true,
1431
+ allowFailure: true,
1432
+ env,
1433
+ });
1434
+ stopExitCode = result && Number.isInteger(result.status) ? result.status : null;
1435
+ stopped = true;
1436
+ } catch (e) {
1437
+ log(` WARNING: \`minions ${stopArgs.slice(1).join(' ')}\` failed (${e && e.message ? e.message : e}); waiting for the handles anyway.`);
1438
+ }
1439
+ } else {
1440
+ log(' WARNING: no installed CLI was found to run `minions stop`; waiting for the handles to be released.');
1441
+ }
1442
+ const deadline = now() + timeoutMs;
1443
+ let verdict = probeState();
1444
+ while (!verdict.quiesced && now() < deadline) {
1445
+ sleep(pollMs);
1446
+ verdict = probeState();
1447
+ }
1448
+ return {
1449
+ ok: verdict.quiesced,
1450
+ stopped,
1451
+ stopPath,
1452
+ stopArgs,
1453
+ stopExitCode,
1454
+ capabilityProbe,
1455
+ holders: verdict.holders,
1456
+ waitedMs: now() - startedAt,
1457
+ reason: verdict.reason,
1458
+ };
1459
+ }
1460
+
1461
+ // ─── Original-state retention ───────────────────────────────────────────────
1462
+
1463
+ /**
1464
+ * Copy the ORIGINAL `state.db`, `-wal`, and `-shm` byte-for-byte into
1465
+ * `<backup>/original/`, beside the `VACUUM INTO` snapshot.
1466
+ *
1467
+ * The snapshot is the *consistent* artifact and is what a human should restore
1468
+ * by hand; these are the *literal* pre-migration bytes, which is what an
1469
+ * automatic state-restoring rollback needs in order to put the runtime back
1470
+ * exactly as it was — WAL included. Neither is ever deleted by this run under
1471
+ * any outcome: `cleanup` only removes the temporary npm config and the temp
1472
+ * artifact directory.
1473
+ */
1474
+ function retainOriginalStateFiles(runtimeRoot, backupDir, deps = {}) {
1475
+ const exists = deps.existsSync || (p => fs.existsSync(p));
1476
+ const mkdir = deps.mkdirSync || ((p, options) => fs.mkdirSync(p, options));
1477
+ const copy = deps.copyFileSync || ((src, dest) => fs.copyFileSync(src, dest));
1478
+ const dir = path.join(backupDir, RETAINED_ORIGINAL_DIR);
1479
+ const retained = [];
1480
+ const missing = [];
1481
+ const failed = [];
1482
+ for (const rel of STATE_DB_FILES) {
1483
+ const src = path.join(runtimeRoot, ...rel.split('/'));
1484
+ const base = path.basename(rel);
1485
+ if (!exists(src)) { missing.push(rel); continue; }
1486
+ try {
1487
+ mkdir(dir, { recursive: true });
1488
+ copy(src, path.join(dir, base));
1489
+ retained.push(`${RETAINED_ORIGINAL_DIR}/${base}`);
1490
+ } catch (e) {
1491
+ failed.push({ rel, error: e && e.message ? e.message : String(e) });
1492
+ }
1493
+ }
1494
+ return { dir, retained, missing, failed };
1495
+ }
1496
+
1497
+ /**
1498
+ * Put the retained original state files back. Idempotent: copying the same
1499
+ * bytes twice is a no-op, and a sidecar the original run did NOT have is
1500
+ * removed rather than left to pair a restored database with a foreign WAL.
1501
+ *
1502
+ * `engine/state.db` itself is never removed — only its sidecars — so a backup
1503
+ * that somehow lacks the database can never delete the live one.
1504
+ */
1505
+ function restoreOriginalStateFiles(runtimeRoot, backupDir, deps = {}) {
1506
+ const exists = deps.existsSync || (p => fs.existsSync(p));
1507
+ const mkdir = deps.mkdirSync || ((p, options) => fs.mkdirSync(p, options));
1508
+ const copy = deps.copyFileSync || ((src, dest) => fs.copyFileSync(src, dest));
1509
+ const remove = deps.rmSync || ((p, options) => fs.rmSync(p, options));
1510
+ const dir = path.join(backupDir, RETAINED_ORIGINAL_DIR);
1511
+ const restored = [];
1512
+ const removed = [];
1513
+ const failed = [];
1514
+ for (const rel of STATE_DB_FILES) {
1515
+ const src = path.join(dir, path.basename(rel));
1516
+ const dest = path.join(runtimeRoot, ...rel.split('/'));
1517
+ try {
1518
+ if (exists(src)) {
1519
+ mkdir(path.dirname(dest), { recursive: true });
1520
+ copy(src, dest);
1521
+ restored.push(rel);
1522
+ } else if (rel !== 'engine/state.db' && exists(dest)) {
1523
+ remove(dest, { force: true });
1524
+ removed.push(rel);
1525
+ }
1526
+ } catch (e) {
1527
+ failed.push({ rel, error: e && e.message ? e.message : String(e) });
1528
+ }
1529
+ }
1530
+ return { dir, restored, removed, failed };
1531
+ }
1532
+
1533
+ /** Put back the config/routing/pinned copies parked beside the backup. */
1534
+ function restoreBackupCopiedFiles(runtimeRoot, backupDir, deps = {}) {
1535
+ const exists = deps.existsSync || (p => fs.existsSync(p));
1536
+ const copy = deps.copyFileSync || ((src, dest) => fs.copyFileSync(src, dest));
1537
+ const restored = [];
1538
+ const failed = [];
1539
+ for (const rel of BACKUP_COPIED_FILES) {
1540
+ const src = path.join(backupDir, rel);
1541
+ if (!exists(src)) continue;
1542
+ try {
1543
+ copy(src, path.join(runtimeRoot, rel));
1544
+ restored.push(rel);
1545
+ } catch (e) {
1546
+ failed.push({ rel, error: e && e.message ? e.message : String(e) });
1547
+ }
1548
+ }
1549
+ return { restored, failed };
1550
+ }
1551
+
1552
+ // ─── State continuity (pre/post invariants) ─────────────────────────────────
1553
+
1554
+ /**
1555
+ * The continuity gate is an explicit ALLOWLIST of durable tables, not a denylist
1556
+ * of volatile ones. A denylist fails open: every table nobody remembered to
1557
+ * enumerate is silently gated, and most of the SQL state legitimately shrinks
1558
+ * across a package cutover —
1559
+ *
1560
+ * - `cc_sessions` / `doc_sessions` are DELETEd when a session is invalidated
1561
+ * (engine/persistence/small-state-store.js), and a changed
1562
+ * `prompts/cc-system.md` hash invalidates CC sessions — which `sync-init`
1563
+ * ships by definition,
1564
+ * - `pending_rebases` is deleted and rewritten wholesale every tick,
1565
+ * - `worktree_pool`, `managed_processes`, `schedule_runs`, `pipeline_runs`,
1566
+ * `metrics`, `cc_queued_messages`, `memory_retrieval_runs`, `steering_deliveries`
1567
+ * are all runtime bookkeeping that shrinks by design,
1568
+ * - `pr_mirror_hashes` is DROPPED outright by migration 018, which surfaces as
1569
+ * a `missingTables` entry, and
1570
+ * - `logs` / `events` / `memory_lifecycle_events` / `dispatches` / `cooldowns`
1571
+ * are capped, consumed, retention-pruned, or expire.
1572
+ *
1573
+ * So only the tables listed here are gated; everything else is passed to
1574
+ * `compareStateSummaries` as `ignoreTables`. These hold irreplaceable
1575
+ * operator- and agent-authored records that no install step may reduce.
1576
+ *
1577
+ * `repairedByMigration` names a shipped REPAIR migration that deletes rows from
1578
+ * that table by design. The gate is lifted for that table only on a run that
1579
+ * actually crosses that schema version — so a normal upgrade still fails on a
1580
+ * PRD drop, while the one migration entitled to delete PRD rows is not reported
1581
+ * as data loss.
1582
+ */
1583
+ const CONTINUITY_DURABLE_TABLES = [
1584
+ { table: 'work_items' },
1585
+ { table: 'pull_requests' },
1586
+ { table: 'plans' },
1587
+ // Migration 024 deletes duplicate live PRD echoes of archived imports.
1588
+ { table: 'prds', repairedByMigration: 24 },
1589
+ { table: 'prd_items', repairedByMigration: 24 },
1590
+ { table: 'prd_verify_prs', repairedByMigration: 24 },
1591
+ { table: 'memory_records' },
1592
+ { table: 'inbox_entries' },
1593
+ { table: 'watches' },
1594
+ { table: 'qa_runs' },
1595
+ { table: 'qa_sessions' },
1596
+ ];
1597
+
1598
+ /**
1599
+ * Turn the durable allowlist into the `ignoreTables` set `compareStateSummaries`
1600
+ * expects: every table present on either side of the census that is NOT gated.
1601
+ * Pure — the schema versions come from the two summaries themselves.
1602
+ */
1603
+ function resolveContinuityIgnoreTables(before, after, durable = CONTINUITY_DURABLE_TABLES) {
1604
+ const beforeVersion = Number(before && before.schemaVersion) || 0;
1605
+ const afterVersion = Number(after && after.schemaVersion) || 0;
1606
+ const gated = new Set();
1607
+ for (const entry of durable) {
1608
+ const { table, repairedByMigration = null } = typeof entry === 'string' ? { table: entry } : (entry || {});
1609
+ if (!table) continue;
1610
+ // A run that crosses the repair migration is entitled to the deletions it makes.
1611
+ if (repairedByMigration && beforeVersion < repairedByMigration && afterVersion >= repairedByMigration) continue;
1612
+ gated.add(table);
1613
+ }
1614
+ const seen = new Set([
1615
+ ...Object.keys((before && before.tables) || {}),
1616
+ ...Object.keys((after && after.tables) || {}),
1617
+ ]);
1618
+ return [...seen].filter(table => !gated.has(table)).sort();
1619
+ }
1620
+
1621
+ /** Markers so a summary can be recovered from stdout that also carries warnings. */
1622
+ const SUMMARY_BEGIN = '<<MINIONS-STATE-SUMMARY>>';
1623
+ const SUMMARY_END = '<</MINIONS-STATE-SUMMARY>>';
1624
+
1625
+ const EMIT = (expr) => `process.stdout.write(${JSON.stringify(SUMMARY_BEGIN)}+JSON.stringify(${expr})+${JSON.stringify(SUMMARY_END)});`;
1626
+
1627
+ /** Census the LIVE state of a runtime root, through that runtime's own module. */
1628
+ const SUMMARIZE_STATE_SCRIPT = EMIT('require(process.argv[1]).summarizeState()');
1629
+
1630
+ /** Census a backup file read-only, through the runtime's own `verifyBackup`. */
1631
+ const SUMMARIZE_BACKUP_SCRIPT = EMIT('require(process.argv[1]).verifyBackup(process.argv[2]).summary');
1632
+
1633
+ /**
1634
+ * Compare two summaries with the runtime's OWN `compareStateSummaries` rather
1635
+ * than a second implementation here. The summaries travel through files because
1636
+ * a full table census is far too large for argv on Windows.
1637
+ */
1638
+ const COMPARE_SUMMARIES_SCRIPT = 'const f=require("fs");const m=require(process.argv[1]);'
1639
+ + 'const r=(p)=>JSON.parse(f.readFileSync(p,"utf8"));'
1640
+ + EMIT('m.compareStateSummaries(r(process.argv[2]),r(process.argv[3]),{ignoreTables:r(process.argv[4])})');
1641
+
1642
+ /** Recover the emitted JSON payload from a child's stdout. Null when absent. */
1643
+ function extractEmittedJson(stdout) {
1644
+ const text = String(stdout == null ? '' : stdout);
1645
+ const start = text.indexOf(SUMMARY_BEGIN);
1646
+ if (start < 0) return null;
1647
+ const end = text.indexOf(SUMMARY_END, start + SUMMARY_BEGIN.length);
1648
+ if (end < 0) return null;
1649
+ try { return JSON.parse(text.slice(start + SUMMARY_BEGIN.length, end)); } catch { return null; }
1650
+ }
1651
+
1652
+ /**
1653
+ * Locate the state-operations module a runtime root carries. `minions init`
1654
+ * copies packaged engine JavaScript into the runtime root, so it is present for
1655
+ * every installed runtime and is by construction version-matched to that
1656
+ * runtime's `engine/state.db`.
1657
+ */
1658
+ function resolveStateOperationsModule(runtimeRoot, exists = p => fs.existsSync(p)) {
1659
+ if (!runtimeRoot) return null;
1660
+ return [
1661
+ path.join(runtimeRoot, 'engine', 'persistence', 'state-operations.js'),
1662
+ path.join(runtimeRoot, 'engine', 'state-operations.js'),
1663
+ ].find(exists) || null;
1664
+ }
1665
+
1666
+ /**
1667
+ * Run a `summarizeState()` census out-of-process, against either a backup file
1668
+ * (read-only, preferred for the pre-migration side) or the live pinned root.
1669
+ * Never throws — an unavailable census is reported, not fatal, so the caller
1670
+ * can degrade to the presence-only check instead of aborting a healthy install.
1671
+ */
1672
+ function readStateSummary({ run, modulePath, backupPath = null, env, timeout = CLI_INIT_TIMEOUT_MS } = {}) {
1673
+ if (!modulePath) {
1674
+ return { ok: false, summary: null, error: 'no engine/persistence/state-operations.js was found under the runtime root' };
1675
+ }
1676
+ const args = backupPath
1677
+ ? ['-e', SUMMARIZE_BACKUP_SCRIPT, modulePath, backupPath]
1678
+ : ['-e', SUMMARIZE_STATE_SCRIPT, modulePath];
1679
+ try {
1680
+ const result = run(process.execPath, args, { timeout, capture: true, env });
1681
+ const summary = extractEmittedJson(result.stdout);
1682
+ if (!summary || !summary.tables) throw new Error('the census returned no table counts');
1683
+ return { ok: true, summary, error: null };
1684
+ } catch (e) {
1685
+ return { ok: false, summary: null, error: e && e.message ? e.message : String(e) };
1686
+ }
1687
+ }
1688
+
1689
+ /**
1690
+ * Compare a pre- and post-migration census through the runtime's own
1691
+ * `compareStateSummaries`. Returns `{ ok, comparison, error }`; an
1692
+ * unavailable comparison is `ok: false` with `comparison: null`, which the
1693
+ * caller must treat as "could not verify", not as "verified".
1694
+ */
1695
+ function compareStateContinuity({
1696
+ run, modulePath, before, after, env,
1697
+ ignoreTables = null,
1698
+ timeout = CLI_INIT_TIMEOUT_MS,
1699
+ tmpDirFactory = () => fs.mkdtempSync(path.join(os.tmpdir(), 'minions-continuity-')),
1700
+ } = {}) {
1701
+ if (!modulePath) return { ok: false, comparison: null, error: 'no state-operations module to compare with' };
1702
+ if (!before || !after) return { ok: false, comparison: null, error: 'a state census is missing' };
1703
+ const ignore = ignoreTables || resolveContinuityIgnoreTables(before, after);
1704
+ let dir = null;
1705
+ try {
1706
+ dir = tmpDirFactory();
1707
+ const beforePath = path.join(dir, 'before.json');
1708
+ const afterPath = path.join(dir, 'after.json');
1709
+ const ignorePath = path.join(dir, 'ignore.json');
1710
+ fs.writeFileSync(beforePath, JSON.stringify(before));
1711
+ fs.writeFileSync(afterPath, JSON.stringify(after));
1712
+ fs.writeFileSync(ignorePath, JSON.stringify(ignore));
1713
+ const result = run(process.execPath, ['-e', COMPARE_SUMMARIES_SCRIPT, modulePath, beforePath, afterPath, ignorePath], {
1714
+ timeout, capture: true, env,
1715
+ });
1716
+ const comparison = extractEmittedJson(result.stdout);
1717
+ if (!comparison || typeof comparison.ok !== 'boolean') throw new Error('the comparison returned no verdict');
1718
+ return { ok: true, comparison, error: null };
1719
+ } catch (e) {
1720
+ return { ok: false, comparison: null, error: e && e.message ? e.message : String(e) };
1721
+ } finally {
1722
+ if (dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } }
1723
+ }
1724
+ }
1725
+
1726
+ /** Render a failed comparison as the operator-facing explanation. */
1727
+ function describeContinuityFailure(comparison) {
1728
+ const parts = [];
1729
+ if (comparison.missingTables && comparison.missingTables.length) {
1730
+ parts.push(`tables missing after migration: ${comparison.missingTables.join(', ')}`);
1731
+ }
1732
+ if (comparison.emptyAfterNonEmptyBefore && comparison.emptyAfterNonEmptyBefore.length) {
1733
+ parts.push(`tables emptied: ${comparison.emptyAfterNonEmptyBefore.join(', ')}`);
1734
+ }
1735
+ if (comparison.regressions && comparison.regressions.length) {
1736
+ parts.push(`row counts fell: ${comparison.regressions.map(r => `${r.table} ${r.before} → ${r.after}`).join(', ')}`);
1737
+ }
1738
+ return parts.length ? parts.join('; ') : 'the state census regressed';
1739
+ }
1740
+
1741
+ /**
1742
+ * Decide whether a state-restoring rollback may run. Package rollback is always
1743
+ * available; STATE rollback additionally requires the retained original bytes,
1744
+ * because restoring a `VACUUM INTO` snapshot over a live root would silently
1745
+ * discard the WAL the original run left behind.
1746
+ */
1747
+ function assessStateRollback({ backupDir = null, retainedOriginals = [] } = {}) {
1748
+ if (!backupDir) {
1749
+ return { allowed: false, reason: 'this run took no backup, so there is nothing to restore from' };
1750
+ }
1751
+ if (!retainedOriginals.includes('original/state.db')) {
1752
+ return { allowed: false, reason: `the original engine/state.db was not retained under ${RETAINED_ORIGINAL_DIR}/` };
1753
+ }
1754
+ return { allowed: true, reason: `restoring the original state files retained in ${backupDir}` };
1755
+ }
1756
+
888
1757
  // ─── Migration plan (pure) ──────────────────────────────────────────────────
889
1758
 
890
1759
  const STEP_ORDER = [
891
1760
  'acquire-token',
892
1761
  'validate-feed',
893
1762
  'download-artifact',
1763
+ 'quiesce-services',
894
1764
  'backup-state',
895
1765
  'uninstall-public',
896
1766
  'repair-shims',
@@ -898,6 +1768,7 @@ const STEP_ORDER = [
898
1768
  'verify-install',
899
1769
  'verify-shim',
900
1770
  'sync-init',
1771
+ 'verify-continuity',
901
1772
  'restart',
902
1773
  'cleanup',
903
1774
  ];
@@ -911,6 +1782,11 @@ const STEP_ORDER = [
911
1782
  * - `download-artifact` precedes `uninstall-public`, so the public package is
912
1783
  * only removed once the internal one is resolved AND on local disk — a feed
913
1784
  * failure during the install is then recoverable offline.
1785
+ * - `quiesce-services` precedes `backup-state`, and therefore precedes every
1786
+ * uninstall and package mutation: a backup taken while the engine is
1787
+ * writing is a backup of a moving target, and rewriting the runtime `.js`
1788
+ * files underneath a live daemon is exactly what the restart contract does
1789
+ * not cover. It fails CLOSED when the handles are not released.
914
1790
  * - `backup-state` precedes every mutation of the runtime root, and also
915
1791
  * precedes `uninstall-public`, so the cutover never starts without a
916
1792
  * restore point.
@@ -923,6 +1799,24 @@ const STEP_ORDER = [
923
1799
  * before a restore point exists.
924
1800
  * - `verify-shim` precedes `sync-init`, so the runtime is only synchronized
925
1801
  * once `minions` actually resolves to the internal package.
1802
+ * - `verify-continuity` runs while the services are still quiesced, BEFORE
1803
+ * `restart`. Two reasons, and both are load-bearing:
1804
+ * 1. A census taken against a live engine races it — rows move under the
1805
+ * count, so the verdict is not reproducible. Under quiescence the only
1806
+ * writer is the census itself, and `getDb()` applies pending migrations
1807
+ * on open (`engine/db/index.js`), so a pre-restart census still
1808
+ * exercises the migrations deterministically.
1809
+ * 2. Its failure action, `rollbackStateAndPackage()`, overwrites
1810
+ * `engine/state.db{,-wal,-shm}` with the retained pre-migration bytes.
1811
+ * After a restart that would destroy every write the restarted engine
1812
+ * and dashboard had already made — including the migrations that had
1813
+ * just succeeded. Gating before the restart means the rollback can only
1814
+ * ever discard bytes that nothing has written to since the backup.
1815
+ * It stays the last gate before `cleanup`: the preserved-path check is
1816
+ * presence-only and an emptied `engine/state.db` passes it.
1817
+ * - `restart` is therefore the final action of a run that has already proven
1818
+ * its state survived, so the machine is only brought back up on state that
1819
+ * was verified.
926
1820
  * - `cleanup` is always last and always present — it runs in a `finally`.
927
1821
  *
928
1822
  * `install-internal` is also the only step that (re-)links the `minions` bin
@@ -949,7 +1843,13 @@ function planSteps(state) {
949
1843
  && !willUninstallPublic
950
1844
  && shimOwned;
951
1845
  const skip = new Set();
952
- if (!hasExistingRuntime) skip.add('backup-state');
1846
+ if (!hasExistingRuntime) {
1847
+ // Nothing is running against a root that has no runtime, nothing to back
1848
+ // up, and no pre-migration census to compare a post-migration one against.
1849
+ skip.add('quiesce-services');
1850
+ skip.add('backup-state');
1851
+ skip.add('verify-continuity');
1852
+ }
953
1853
  if (!willUninstallPublic) skip.add('uninstall-public');
954
1854
  if (alreadyAtTarget) {
955
1855
  skip.add('install-internal');
@@ -1020,7 +1920,10 @@ function makeRunner(secrets, { log = console.log, error = console.error } = {})
1020
1920
  err.stderr = stderr;
1021
1921
  throw err;
1022
1922
  }
1023
- if (capture && !ok && stderr.trim()) log(stderr.trimEnd());
1923
+ // A tolerated failure is still a failure: its stderr is the only recorded
1924
+ // cause for the fallback, so it belongs on the diagnostic stream with every
1925
+ // other diagnostic — never interleaved into this script's stdout.
1926
+ if (capture && !ok && stderr.trim()) error(stderr.trimEnd());
1024
1927
  return { ok, stdout, stderr, status: result.status };
1025
1928
  };
1026
1929
  }
@@ -1063,6 +1966,156 @@ function resolveNpmGlobalPath(run, kind, config) {
1063
1966
  } catch { return null; }
1064
1967
  }
1065
1968
 
1969
+ // ─── Authenticated feed session (W-ms9bhf9k001j29cb) ────────────────────────
1970
+ //
1971
+ // The seam `minions update` uses to update an internal install from its own
1972
+ // feed. It exists so the in-place updater reuses THIS file's auth/config
1973
+ // handling — short-lived `az` token, 0600 temporary userconfig, redacted
1974
+ // output, guaranteed cleanup — instead of growing a second, divergent copy of
1975
+ // security-sensitive logic in bin/minions.js.
1976
+
1977
+ /** Read a version out of `npm view … version --json` (array, JSON string, or bare). */
1978
+ function parseViewedVersion(stdout) {
1979
+ const raw = String(stdout == null ? '' : stdout).trim();
1980
+ if (!raw) return '';
1981
+ try {
1982
+ const parsed = JSON.parse(raw);
1983
+ const value = Array.isArray(parsed) ? parsed[parsed.length - 1] : parsed;
1984
+ return value == null ? '' : String(value).trim();
1985
+ } catch {
1986
+ return raw.replace(/^"|"$/g, '');
1987
+ }
1988
+ }
1989
+
1990
+ /**
1991
+ * Operator-facing explanation for a failed feed operation, as printable lines.
1992
+ *
1993
+ * The failure this replaces is the misleading one: with no registry of its own,
1994
+ * an update of the internal package was routed by ambient npm config to a
1995
+ * public proxy, which answered E404 — reading as "this package does not exist"
1996
+ * for a package that does exist, in a feed that was never contacted. Every
1997
+ * message below therefore names the feed that WAS queried and the two real
1998
+ * causes (auth, or feed read permission / missing version).
1999
+ */
2000
+ function describeInternalFeedFailure({ kind, error, feed, versionSpec = DEFAULT_VERSION_SPEC } = {}) {
2001
+ const detail = error && error.message ? String(error.message) : '';
2002
+ const pkg = feed ? feed.packageName : INTERNAL_PACKAGE;
2003
+ const registry = feed ? feed.registry : DEFAULT_REGISTRY;
2004
+ if (kind === 'auth') {
2005
+ return [
2006
+ `Could not acquire an Azure DevOps token for the internal feed (${registry}).`,
2007
+ detail ? ` ${detail}` : '',
2008
+ ' Sign in with the Azure CLI, then retry:',
2009
+ ' az login',
2010
+ ' az account set --subscription <id> (only if you have more than one)',
2011
+ ].filter(Boolean);
2012
+ }
2013
+ const action = kind === 'install' ? `install ${pkg}@${versionSpec} from` : `read ${pkg}@${versionSpec} from`;
2014
+ return [
2015
+ `Could not ${action} the internal feed.`,
2016
+ ` Feed: ${registry}`,
2017
+ detail ? ` ${detail}` : '',
2018
+ ' This is the ISS ProjectFeed-ISS Azure Artifacts feed, not a public registry — a 404 here',
2019
+ ' means the version is not published to the feed or your account has no Azure Artifacts read',
2020
+ ' access to it, never that the package is unpublished.',
2021
+ ' Check: `az login` succeeds, you have read permission on ProjectFeed-ISS, and the version exists.',
2022
+ ].filter(Boolean);
2023
+ }
2024
+
2025
+ function feedError({ kind, error, feed, versionSpec }) {
2026
+ const lines = describeInternalFeedFailure({ kind, error, feed, versionSpec });
2027
+ const err = new Error(lines.join('\n'));
2028
+ err.feedFailure = kind;
2029
+ err.lines = lines;
2030
+ return err;
2031
+ }
2032
+
2033
+ /**
2034
+ * Open an authenticated npm session against a private feed.
2035
+ *
2036
+ * Acquires a short-lived ADO access token through the caller's existing Azure
2037
+ * CLI login and stages it in a 0600 temporary userconfig — the token never
2038
+ * reaches argv, the environment, stdout, or any persistent file. Every npm
2039
+ * command issued through the session carries that userconfig plus the
2040
+ * command-line scope pin, so neither the caller's npm config nor the ambient
2041
+ * default registry can redirect it.
2042
+ *
2043
+ * `close()` removes the staged credential and is idempotent. It is also wired
2044
+ * to a `process.on('exit')` hook, because the caller (`minions update`) exits
2045
+ * non-zero on npm failures and `process.exit()` skips `finally` blocks.
2046
+ */
2047
+ function openInternalFeedSession(feed, deps = {}) {
2048
+ if (!feed || !feed.registry || !feed.packageName) throw new Error('an internal feed is required');
2049
+ const log = deps.log || console.log;
2050
+ const error = deps.error || console.error;
2051
+ const secrets = [];
2052
+ const run = deps.run || makeRunner(secrets, { log, error });
2053
+
2054
+ let token;
2055
+ try {
2056
+ const acquired = deps.acquireToken
2057
+ ? deps.acquireToken()
2058
+ : run('az', buildTokenArgs(), { timeout: AZ_TOKEN_TIMEOUT_MS, capture: true }).stdout;
2059
+ token = String(acquired == null ? '' : acquired).trim();
2060
+ } catch (e) {
2061
+ throw feedError({ kind: 'auth', error: e, feed });
2062
+ }
2063
+ if (!token) throw feedError({ kind: 'auth', error: new Error('the Azure CLI returned an empty access token'), feed });
2064
+ secrets.push(token);
2065
+
2066
+ const temp = (deps.writeTempNpmrc || writeTempNpmrc)({
2067
+ registry: feed.registry,
2068
+ scope: feed.scope,
2069
+ token,
2070
+ baseConfig: (deps.readUserNpmrc || readUserNpmrc)(),
2071
+ });
2072
+
2073
+ let closed = false;
2074
+ const cleanup = () => {
2075
+ if (closed) return false;
2076
+ closed = true;
2077
+ return (deps.cleanupTempNpmrc || cleanupTempNpmrc)(temp.dir);
2078
+ };
2079
+ const onExit = () => { cleanup(); };
2080
+ process.on('exit', onExit);
2081
+
2082
+ return {
2083
+ userconfig: temp.file,
2084
+ registry: feed.registry,
2085
+ packageName: feed.packageName,
2086
+ secrets,
2087
+ /** Resolve a version spec (dist-tag or exact) to a concrete version from the feed. */
2088
+ resolveVersion(versionSpec = DEFAULT_VERSION_SPEC) {
2089
+ const args = buildNpmViewArgs({
2090
+ packageName: feed.packageName, versionSpec, registry: feed.registry, userconfig: temp.file,
2091
+ });
2092
+ let result;
2093
+ try {
2094
+ result = run('npm', args, { timeout: NPM_VIEW_TIMEOUT_MS, capture: true });
2095
+ } catch (e) {
2096
+ throw feedError({ kind: 'view', error: e, feed, versionSpec });
2097
+ }
2098
+ return parseViewedVersion(result.stdout);
2099
+ },
2100
+ /** Install an exact version globally from the feed. */
2101
+ install(version) {
2102
+ const args = buildNpmInstallArgs({
2103
+ packageName: feed.packageName, version, registry: feed.registry, userconfig: temp.file,
2104
+ });
2105
+ try {
2106
+ return run('npm', args, { timeout: NPM_INSTALL_TIMEOUT_MS });
2107
+ } catch (e) {
2108
+ throw feedError({ kind: 'install', error: e, feed, versionSpec: version });
2109
+ }
2110
+ },
2111
+ close() {
2112
+ const removed = cleanup();
2113
+ process.removeListener('exit', onExit);
2114
+ return removed;
2115
+ },
2116
+ };
2117
+ }
2118
+
1066
2119
  // ─── Main ───────────────────────────────────────────────────────────────────
1067
2120
 
1068
2121
  function main(argv) {
@@ -1078,7 +2131,6 @@ function main(argv) {
1078
2131
  console.log(USAGE);
1079
2132
  return EXIT.OK;
1080
2133
  }
1081
-
1082
2134
  const secrets = [];
1083
2135
  const log = (...args) => console.log(...args);
1084
2136
  const run = makeRunner(secrets, { log });
@@ -1087,15 +2139,26 @@ function main(argv) {
1087
2139
  // pinned onto every child process below (backup, init, restart) rather than
1088
2140
  // left to whatever MINIONS_HOME each child happens to inherit. A cutover that
1089
2141
  // backed up one root and synchronized another would look successful and lose
1090
- // the operator's state.
1091
- const runtimeRoot = resolveRuntimeRoot();
2142
+ // the operator's state — and a machine where more than one candidate carries
2143
+ // runtime state is refused here rather than resolved by guessing.
2144
+ const rootDecision = resolveAuthoritativeRuntimeRoot({ flag: opts.runtimeRoot });
2145
+ if (rootDecision.ambiguous) {
2146
+ console.error(`\n REFUSED: ${rootDecision.reason}`);
2147
+ console.error('\n Nothing was downloaded, uninstalled, or modified.\n');
2148
+ return EXIT.REFUSED;
2149
+ }
2150
+ const runtimeRoot = rootDecision.root;
1092
2151
  const runtimeEnv = { ...process.env, MINIONS_HOME: runtimeRoot };
2152
+ // MINIONS_TEST_DIR outranks MINIONS_HOME in the engine's resolveMinionsHome(),
2153
+ // so an inherited one would silently un-pin every child we just pinned.
2154
+ delete runtimeEnv.MINIONS_TEST_DIR;
1093
2155
  const hasExistingRuntime = fs.existsSync(path.join(runtimeRoot, 'engine.js'));
1094
2156
 
1095
2157
  log(`\n Internal Minions install`);
1096
2158
  log(` package: ${opts.packageName}@${opts.versionSpec}`);
1097
2159
  log(` registry: ${opts.registry}`);
1098
2160
  log(` runtime root: ${runtimeRoot}${hasExistingRuntime ? '' : ' (fresh install)'}`);
2161
+ log(` pinned via: ${rootDecision.source} — ${rootDecision.reason}`);
1099
2162
 
1100
2163
  // npm-usability probe only. The authoritative global root and prefix are
1101
2164
  // re-resolved below under the same `--userconfig` the install uses, because a
@@ -1196,12 +2259,7 @@ function main(argv) {
1196
2259
  console.error(' Nothing was uninstalled or modified.\n');
1197
2260
  return EXIT.FAILED;
1198
2261
  }
1199
- try {
1200
- const parsed = JSON.parse(viewResult.stdout.trim());
1201
- targetVersion = Array.isArray(parsed) ? parsed[parsed.length - 1] : parsed;
1202
- } catch {
1203
- targetVersion = viewResult.stdout.trim().replace(/^"|"$/g, '');
1204
- }
2262
+ targetVersion = parseViewedVersion(viewResult.stdout);
1205
2263
  if (!targetVersion) {
1206
2264
  console.error('\n ERROR: the feed returned no version for that spec. Nothing was modified.\n');
1207
2265
  return EXIT.FAILED;
@@ -1254,7 +2312,15 @@ function main(argv) {
1254
2312
  if (steps.includes('download-artifact')) {
1255
2313
  log(` npm ${redact(buildNpmPackArgs({ packageName: opts.packageName, version: targetVersion, registry: opts.registry, userconfig: temp.file, destination: '<temp>' }).join(' '), secrets)}`);
1256
2314
  }
1257
- if (steps.includes('backup-state')) log(` minions state backup <backup-dir>/state.db`);
2315
+ if (steps.includes('quiesce-services')) {
2316
+ log(` minions ${CLI_CAPABILITY_PROBE_ARGS.join(' ')} (capability probe: does this CLI know \`stop --all\`?)`);
2317
+ log(` minions ${WHOLE_STACK_STOP_ARGS.join(' ')} (or \`minions ${LEGACY_STOP_ARGS.join(' ')}\` when the probe says it does not)`);
2318
+ log(` (then wait for engine/state.db-shm to be released)`);
2319
+ }
2320
+ if (steps.includes('backup-state')) {
2321
+ log(` minions state backup <backup-dir>/state.db`);
2322
+ log(` (retain) copy engine/state.db{,-wal,-shm} → <backup-dir>/${RETAINED_ORIGINAL_DIR}/`);
2323
+ }
1258
2324
  if (steps.includes('uninstall-public')) log(` npm ${buildNpmUninstallArgs(PUBLIC_PACKAGE, npmConfig).join(' ')}`);
1259
2325
  if (steps.includes('repair-shims')) {
1260
2326
  log(orphanShims.needsRepair
@@ -1266,7 +2332,9 @@ function main(argv) {
1266
2332
  log(` (on failure) npm ${buildNpmInstallTarballArgs('<backup-dir>/<package>.tgz').join(' ')}`);
1267
2333
  }
1268
2334
  log(` minions init --force --skip-start`);
2335
+ if (steps.includes('verify-continuity')) log(` (verify) summarizeState() before vs after — any regression rolls state back`);
1269
2336
  if (steps.includes('restart')) log(` minions restart`);
2337
+ log(` MINIONS_HOME=${runtimeRoot} (pinned onto every command above; source: ${rootDecision.source})`);
1270
2338
  log('\n [dry-run] No changes made.\n');
1271
2339
  return EXIT.OK;
1272
2340
  }
@@ -1305,9 +2373,46 @@ function main(argv) {
1305
2373
  log(` Artifact downloaded: ${path.basename(artifactPath)}`);
1306
2374
  }
1307
2375
 
1308
- // 4. backup-state ──────────────────────────────────────────────────────
2376
+ // 4. quiesce-services ──────────────────────────────────────────────────
2377
+ // Everything below this line mutates the runtime root or the installed
2378
+ // package, so the engine and dashboard running against the PINNED root are
2379
+ // stopped first and their database handles are proven released. Fails
2380
+ // CLOSED: nothing is backed up, uninstalled, or installed otherwise.
2381
+ const backupCli = resolveBackupCli({
2382
+ npmRootG,
2383
+ runtimeRoot,
2384
+ packageName: opts.packageName,
2385
+ installedInternalVersion,
2386
+ installedPublic,
2387
+ });
2388
+ let servicesStopped = false;
2389
+ if (steps.includes('quiesce-services')) {
2390
+ log(`\n Quiescing services running against ${runtimeRoot} ...`);
2391
+ const quiescence = quiesceServices({
2392
+ runtimeRoot,
2393
+ cliPath: backupCli.path,
2394
+ run,
2395
+ env: runtimeEnv,
2396
+ log,
2397
+ });
2398
+ if (!quiescence.ok) {
2399
+ console.error(`\n REFUSED: the runtime did not release engine/state.db — ${quiescence.reason}.`);
2400
+ for (const line of formatQuiescenceRefusal(quiescence)) console.error(line);
2401
+ console.error(' Backing up or replacing the package while the database is open would capture a moving');
2402
+ console.error(' target and rewrite engine code a live daemon is executing.');
2403
+ console.error(' Stop those process(es) (`minions stop --all --wait`, or end the PIDs above) and re-run.');
2404
+ console.error(' Nothing was backed up, uninstalled, or modified.\n');
2405
+ return EXIT.REFUSED;
2406
+ }
2407
+ servicesStopped = quiescence.stopped;
2408
+ log(` ${quiescence.stopped ? `Services stopped via the ${STOP_PATH_LABELS[quiescence.stopPath]}; ` : ''}${quiescence.reason}.`);
2409
+ }
2410
+
2411
+ // 5. backup-state ──────────────────────────────────────────────────────
1309
2412
  let backupDir = null;
1310
2413
  let backupStatePath = null;
2414
+ let retainedOriginals = { retained: [], missing: [], failed: [] };
2415
+ let preMigrationSummary = null;
1311
2416
  if (steps.includes('backup-state')) {
1312
2417
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
1313
2418
  backupDir = opts.backupDir
@@ -1339,17 +2444,52 @@ function main(argv) {
1339
2444
  env: runtimeEnv,
1340
2445
  });
1341
2446
  if (!fs.existsSync(backupStatePath)) throw new Error('the backup command reported success but wrote no file');
1342
- log(` SQLite state checkpointed and backed up (via ${backup.kind === 'runtime-module' ? 'the runtime\'s own engine/state-operations.js' : backup.cliPath}).`);
2447
+ log(` SQLite state backed up consistently (via ${backup.kind === 'runtime-module' ? 'the runtime\'s own engine/persistence/state-operations.js' : backup.cliPath}).`);
1343
2448
  } catch (e) {
1344
2449
  backupStatePath = null;
1345
2450
  console.error(`\n ERROR: state backup failed (${e.message}).`);
1346
2451
  console.error(' Refusing to migrate without a consistent backup. Nothing was uninstalled.\n');
1347
2452
  return EXIT.FAILED;
1348
2453
  }
1349
- for (const rel of ['config.json', 'routing.md', 'pinned.md']) {
2454
+ for (const rel of BACKUP_COPIED_FILES) {
1350
2455
  const src = path.join(runtimeRoot, rel);
1351
2456
  try { if (fs.existsSync(src)) fs.copyFileSync(src, path.join(backupDir, rel)); } catch { /* best-effort */ }
1352
2457
  }
2458
+ // Retain the ORIGINAL bytes beside the VACUUM snapshot. The snapshot is
2459
+ // the consistent artifact a human restores by hand; these are what an
2460
+ // automatic state-restoring rollback needs to put the runtime back
2461
+ // exactly as it was, WAL included. Neither is ever deleted by this run.
2462
+ retainedOriginals = retainOriginalStateFiles(runtimeRoot, backupDir);
2463
+ if (!retainedOriginals.retained.includes(`${RETAINED_ORIGINAL_DIR}/state.db`)) {
2464
+ const why = retainedOriginals.failed.length
2465
+ ? retainedOriginals.failed.map(f => `${f.rel} (${f.error})`).join('; ')
2466
+ : `engine/state.db was not found under ${runtimeRoot}`;
2467
+ console.error(`\n ERROR: could not retain the original state files — ${why}.`);
2468
+ console.error(' A state-restoring rollback would be impossible. Nothing was uninstalled.\n');
2469
+ return EXIT.FAILED;
2470
+ }
2471
+ log(` Original state retained byte-for-byte: ${retainedOriginals.retained.join(', ')}`);
2472
+ // Pre-migration census, read from the BACKUP rather than the live root:
2473
+ // `verifyBackup` opens it read-only, so measuring the baseline can never
2474
+ // mutate, WAL, or lock the state it is vouching for.
2475
+ const censusModule = backup.kind === 'runtime-module'
2476
+ ? backup.modulePath
2477
+ : resolveStateOperationsModule(runtimeRoot);
2478
+ const census = readStateSummary({
2479
+ run,
2480
+ modulePath: censusModule,
2481
+ backupPath: backupStatePath,
2482
+ env: runtimeEnv,
2483
+ });
2484
+ if (census.ok) {
2485
+ preMigrationSummary = census.summary;
2486
+ const rows = Object.values(census.summary.tables).reduce((sum, n) => sum + (Number(n) || 0), 0);
2487
+ log(` Pre-migration census: schema v${census.summary.schemaVersion}, `
2488
+ + `${Object.keys(census.summary.tables).length} tables, ${rows} rows.`);
2489
+ } else {
2490
+ log(` WARNING: no pre-migration state census could be taken (${census.error}).`);
2491
+ log(' Continuity verification will be skipped; the preserved-path check still applies.');
2492
+ }
1353
2493
  // Park the artifact beside the backup so the recovery source outlives the
1354
2494
  // temp directory and stays discoverable after the process exits.
1355
2495
  if (artifactPath) {
@@ -1363,25 +2503,41 @@ function main(argv) {
1363
2503
  fs.writeFileSync(path.join(backupDir, 'manifest.json'), `${JSON.stringify({
1364
2504
  createdAt: new Date().toISOString(),
1365
2505
  runtimeRoot,
2506
+ runtimeRootSource: rootDecision.source,
2507
+ runtimeRootReason: rootDecision.reason,
1366
2508
  fromPackage: installedPublic ? PUBLIC_PACKAGE : (installedInternalVersion ? opts.packageName : null),
1367
2509
  fromVersion: installedInternalVersion,
1368
2510
  toPackage: opts.packageName,
1369
2511
  toVersion: targetVersion,
1370
2512
  artifact: artifactPath ? path.basename(artifactPath) : null,
1371
2513
  preservedPaths: preservedBefore,
2514
+ // Everything below is retained, not transient: the artifacts listed here
2515
+ // are never removed by this run under any outcome.
2516
+ retainedArtifacts: [
2517
+ 'state.db',
2518
+ ...BACKUP_COPIED_FILES.filter(rel => fs.existsSync(path.join(backupDir, rel))),
2519
+ ...retainedOriginals.retained,
2520
+ ],
2521
+ servicesStopped,
2522
+ preMigrationSummary,
1372
2523
  }, null, 2)}\n`);
1373
2524
  log(` config/routing/pinned copied; manifest written.`);
1374
2525
  if (artifactPath && backupArtifactParked) log(` Recovery artifact parked at ${artifactPath}`);
1375
2526
  }
1376
2527
 
1377
- // 5. uninstall-public ──────────────────────────────────────────────────
2528
+ // 6. uninstall-public ──────────────────────────────────────────────────
1378
2529
  let publicUninstalled = false;
2530
+ // Captured before `sync-init` and reused by the state rollback below, so
2531
+ // the restore path uses the SAME copier as the normal reseed repair.
2532
+ let reseedable = [];
1379
2533
 
1380
2534
  /**
1381
2535
  * Put the previous package back when a failed cutover would otherwise leave
1382
- * the machine with no `minions` command. Package-scoped ONLY: the state
1383
- * backup is never restored automatically, so state written before or during
1384
- * the cutover is neither rolled backward nor deleted.
2536
+ * the machine with no `minions` command. Package-scoped ONLY: on this path
2537
+ * the state backup is never restored, so state written before or during the
2538
+ * cutover is neither rolled backward nor deleted. The state-restoring
2539
+ * rollback below is a separate, narrower path used only when continuity
2540
+ * verification proves state was actually lost.
1385
2541
  */
1386
2542
  const rollbackPackageIfNeeded = () => {
1387
2543
  const decision = assessRollback({
@@ -1418,7 +2574,7 @@ function main(argv) {
1418
2574
  publicUninstalled = true;
1419
2575
  }
1420
2576
 
1421
- // 6. repair-shims ──────────────────────────────────────────────────────
2577
+ // 7. repair-shims ──────────────────────────────────────────────────────
1422
2578
  // npm has now removed whatever shims it still owned. Anything left under the
1423
2579
  // prefix that names a Minions package with no usable install behind it is an
1424
2580
  // ORPHAN — npm will not overwrite it and aborts the global install with
@@ -1450,7 +2606,7 @@ function main(argv) {
1450
2606
  }
1451
2607
  }
1452
2608
 
1453
- // 7. install-internal ──────────────────────────────────────────────────
2609
+ // 8. install-internal ──────────────────────────────────────────────────
1454
2610
  if (steps.includes('install-internal')) {
1455
2611
  log(`\n Installing ${opts.packageName}@${targetVersion} ...`);
1456
2612
  let installed = false;
@@ -1492,7 +2648,7 @@ function main(argv) {
1492
2648
  }
1493
2649
  }
1494
2650
 
1495
- // 8. verify-install ────────────────────────────────────────────────────
2651
+ // 9. verify-install ────────────────────────────────────────────────────
1496
2652
  const installedNow = readGlobalPackageVersion(npmRootG, opts.packageName);
1497
2653
  if (installedNow !== targetVersion) {
1498
2654
  console.error(`\n ERROR: expected ${opts.packageName}@${targetVersion} on disk, found ${installedNow || 'nothing'}.`);
@@ -1503,7 +2659,7 @@ function main(argv) {
1503
2659
  }
1504
2660
  log(` Verified ${opts.packageName}@${installedNow} on disk.`);
1505
2661
 
1506
- // 9. verify-shim ───────────────────────────────────────────────────────
2662
+ // 10. verify-shim ───────────────────────────────────────────────────────
1507
2663
  // Owning the package directory is not the same as owning the `minions`
1508
2664
  // command: a half-finished cutover can leave the shim wired to the public
1509
2665
  // package while the internal one sits installed and unused.
@@ -1528,13 +2684,13 @@ function main(argv) {
1528
2684
 
1529
2685
  const installedCliPath = path.join(resolveGlobalPackageRoot(npmRootG, opts.packageName), 'bin', 'minions.js');
1530
2686
 
1531
- // 10. sync-init ────────────────────────────────────────────────────────
2687
+ // 11. sync-init ────────────────────────────────────────────────────────
1532
2688
  // `minions init --force` overwrites every file the package ships, which
1533
2689
  // includes `routing.md` and `knowledge/agents/*.md`. Those are operator- and
1534
2690
  // agent-authored, so their bytes are captured here and put back below: this
1535
2691
  // is a package-channel cutover, and the sync may only ADD newly required
1536
2692
  // files and run the runtime's own idempotent startup migrations.
1537
- const reseedable = snapshotReseedableFiles(runtimeRoot);
2693
+ reseedable = snapshotReseedableFiles(runtimeRoot);
1538
2694
  log('\n Synchronizing the runtime root (minions init --force)...');
1539
2695
  try {
1540
2696
  run(process.execPath, [installedCliPath, 'init', '--force', '--skip-start'], {
@@ -1563,7 +2719,148 @@ function main(argv) {
1563
2719
  }
1564
2720
  if (preservedBefore.length) log(` Preserved: ${preservedBefore.join(', ')}`);
1565
2721
 
1566
- // 11. restart ──────────────────────────────────────────────────────────
2722
+ // 12. verify-continuity ────────────────────────────────────────────────
2723
+ // The preserved-path check above is presence-only: an emptied
2724
+ // `engine/state.db` passes it. This step re-runs the runtime's OWN
2725
+ // `summarizeState()` against the pinned root, out-of-process, and feeds
2726
+ // both censuses to its `compareStateSummaries`. Any regression, missing
2727
+ // table, or non-empty → empty transition is a hard failure that triggers a
2728
+ // state-restoring rollback.
2729
+
2730
+ /**
2731
+ * Restore the runtime to its pre-migration state and put the previous
2732
+ * package back. Unlike `rollbackPackageIfNeeded`, this IS state-scoped —
2733
+ * it is only reached when continuity verification proved state was lost, so
2734
+ * the retained original bytes are strictly better than what is on disk.
2735
+ *
2736
+ * Idempotent: every step re-copies the same retained bytes, so a re-run
2737
+ * converges on the same result rather than compounding.
2738
+ */
2739
+ const rollbackStateAndPackage = () => {
2740
+ const report = { quiesced: null, state: null, copied: null, reseeded: [], reverified: null, restarted: false };
2741
+ const gate = assessStateRollback({ backupDir, retainedOriginals: retainedOriginals.retained });
2742
+ if (!gate.allowed) {
2743
+ console.error(` State rollback is not possible: ${gate.reason}.`);
2744
+ rollbackPackageIfNeeded();
2745
+ return report;
2746
+ }
2747
+ console.error(` ${gate.reason}.`);
2748
+
2749
+ // Nothing may hold the database we are about to overwrite.
2750
+ report.quiesced = quiesceServices({
2751
+ runtimeRoot,
2752
+ cliPath: resolveBackupCli({
2753
+ npmRootG,
2754
+ runtimeRoot,
2755
+ packageName: opts.packageName,
2756
+ installedInternalVersion: readGlobalPackageVersion(npmRootG, opts.packageName),
2757
+ installedPublic: !!readGlobalPackageVersion(npmRootG, PUBLIC_PACKAGE),
2758
+ }).path,
2759
+ run,
2760
+ env: runtimeEnv,
2761
+ log: console.error,
2762
+ });
2763
+ if (!report.quiesced.ok) {
2764
+ console.error(` ABORTED: ${report.quiesced.reason}. Nothing was restored — your backup is intact at ${backupDir}.`);
2765
+ for (const line of formatQuiescenceRefusal(report.quiesced)) console.error(line);
2766
+ return report;
2767
+ }
2768
+
2769
+ report.state = restoreOriginalStateFiles(runtimeRoot, backupDir);
2770
+ for (const rel of report.state.restored) console.error(` restored ${rel}`);
2771
+ for (const rel of report.state.removed) console.error(` removed ${rel} (the original run had none)`);
2772
+ for (const failure of report.state.failed) console.error(` FAILED ${failure.rel}: ${failure.error}`);
2773
+
2774
+ report.copied = restoreBackupCopiedFiles(runtimeRoot, backupDir);
2775
+ for (const rel of report.copied.restored) console.error(` restored ${rel}`);
2776
+ for (const failure of report.copied.failed) console.error(` FAILED ${failure.rel}: ${failure.error}`);
2777
+
2778
+ // Same copier as the normal reseed repair — one implementation, not two.
2779
+ report.reseeded = restoreReseededFiles(runtimeRoot, reseedable);
2780
+ for (const rel of report.reseeded) console.error(` restored ${rel}`);
2781
+
2782
+ rollbackPackageIfNeeded();
2783
+
2784
+ const restartCli = resolveBackupCli({
2785
+ npmRootG,
2786
+ runtimeRoot,
2787
+ packageName: opts.packageName,
2788
+ installedInternalVersion: readGlobalPackageVersion(npmRootG, opts.packageName),
2789
+ installedPublic: !!readGlobalPackageVersion(npmRootG, PUBLIC_PACKAGE),
2790
+ }).path;
2791
+ if (restartCli && opts.restart) {
2792
+ try {
2793
+ run(process.execPath, [restartCli, 'restart'], { timeout: CLI_RESTART_TIMEOUT_MS, env: runtimeEnv });
2794
+ report.restarted = true;
2795
+ console.error(' engine and dashboard restarted');
2796
+ } catch (e) {
2797
+ console.error(` could not restart automatically (${e.message}); run \`minions restart\` yourself`);
2798
+ }
2799
+ }
2800
+
2801
+ // Re-verify: the restore is only credible if the census comes back.
2802
+ const after = readStateSummary({
2803
+ run,
2804
+ modulePath: resolveStateOperationsModule(runtimeRoot),
2805
+ env: runtimeEnv,
2806
+ });
2807
+ report.reverified = after.ok
2808
+ ? compareStateContinuity({
2809
+ run,
2810
+ modulePath: resolveStateOperationsModule(runtimeRoot),
2811
+ before: preMigrationSummary,
2812
+ after: after.summary,
2813
+ env: runtimeEnv,
2814
+ })
2815
+ : { ok: false, comparison: null, error: after.error };
2816
+ if (report.reverified.ok && report.reverified.comparison.ok) {
2817
+ console.error(' Rollback verified: the restored state matches the pre-migration census.');
2818
+ } else {
2819
+ console.error(` Rollback could NOT be verified (${report.reverified.comparison
2820
+ ? describeContinuityFailure(report.reverified.comparison)
2821
+ : report.reverified.error}).`);
2822
+ console.error(` Restore by hand from ${backupDir}.`);
2823
+ }
2824
+ return report;
2825
+ };
2826
+
2827
+ if (steps.includes('verify-continuity') && preMigrationSummary) {
2828
+ log('\n Verifying state continuity (row-level census, before vs after)...');
2829
+ const postModule = resolveStateOperationsModule(runtimeRoot);
2830
+ const after = readStateSummary({ run, modulePath: postModule, env: runtimeEnv });
2831
+ const continuity = after.ok
2832
+ ? compareStateContinuity({
2833
+ run,
2834
+ modulePath: postModule,
2835
+ before: preMigrationSummary,
2836
+ after: after.summary,
2837
+ env: runtimeEnv,
2838
+ })
2839
+ : { ok: false, comparison: null, error: after.error };
2840
+ if (!continuity.ok) {
2841
+ // Could not verify is NOT verified — but it is also not proof of loss,
2842
+ // so it warns loudly and leaves the retained originals in place rather
2843
+ // than rolling a healthy runtime backward on missing evidence.
2844
+ log(` WARNING: state continuity could not be verified (${continuity.error}).`);
2845
+ log(` The pre-migration state is retained at ${path.join(backupDir, RETAINED_ORIGINAL_DIR)}.`);
2846
+ } else if (!continuity.comparison.ok) {
2847
+ console.error(`\n ERROR: state was lost during the migration — ${describeContinuityFailure(continuity.comparison)}.`);
2848
+ rollbackStateAndPackage();
2849
+ console.error(`\n Your pre-migration state remains at ${backupDir} (nothing there is ever deleted).\n`);
2850
+ return EXIT.FAILED;
2851
+ } else {
2852
+ const rows = Object.values(after.summary.tables).reduce((sum, n) => sum + (Number(n) || 0), 0);
2853
+ log(` Continuity verified: schema v${after.summary.schemaVersion}, `
2854
+ + `${Object.keys(after.summary.tables).length} tables, ${rows} rows; no table regressed.`);
2855
+ }
2856
+ } else if (steps.includes('verify-continuity')) {
2857
+ log('\n Continuity verification skipped: no pre-migration census was available.');
2858
+ }
2859
+
2860
+ // 13. restart ──────────────────────────────────────────────────────────
2861
+ // Last, and only on state that verify-continuity already proved survived:
2862
+ // the machine is never brought back up on state this run cannot vouch for,
2863
+ // and no post-restart write can be destroyed by a state-restoring rollback.
1567
2864
  if (steps.includes('restart')) {
1568
2865
  log('\n Restarting engine and dashboard (health-verified)...');
1569
2866
  try {
@@ -1578,14 +2875,19 @@ function main(argv) {
1578
2875
  }
1579
2876
 
1580
2877
  log(`\n Done. ${opts.packageName}@${installedNow} is installed at ${runtimeRoot}.`);
1581
- if (backupDir) log(` Pre-migration backup: ${backupDir}`);
2878
+ if (backupDir) {
2879
+ log(` Pre-migration backup: ${backupDir}`);
2880
+ if (retainedOriginals.retained.length) {
2881
+ log(` Original state retained: ${retainedOriginals.retained.join(', ')} (never removed by this installer)`);
2882
+ }
2883
+ }
1582
2884
  log('');
1583
2885
  return EXIT.OK;
1584
2886
  } catch (e) {
1585
2887
  console.error(`\n ERROR: ${redact(e && e.message ? e.message : String(e), secrets)}\n`);
1586
2888
  return EXIT.FAILED;
1587
2889
  } finally {
1588
- // 12. cleanup — runs on success and on every failure path, including the
2890
+ // 14. cleanup — runs on success and on every failure path, including the
1589
2891
  // early `return`s above, so the token file never outlives the process. The
1590
2892
  // temp artifact copy goes too; when there was a runtime to back up, the
1591
2893
  // recovery tarball was already parked in the backup directory.
@@ -1609,12 +2911,20 @@ module.exports = {
1609
2911
  EXIT,
1610
2912
  PRESERVED_PATHS,
1611
2913
  INIT_RESEEDABLE_PATHS,
2914
+ STATE_DB_FILES,
2915
+ BACKUP_COPIED_FILES,
2916
+ RETAINED_ORIGINAL_DIR,
2917
+ CONTINUITY_DURABLE_TABLES,
2918
+ resolveContinuityIgnoreTables,
1612
2919
  SHIM_OWNER_PACKAGES,
1613
2920
  STEP_ORDER,
1614
2921
  parseArgs,
1615
2922
  normalizeRegistry,
1616
2923
  registryAuthKey,
1617
2924
  packageScope,
2925
+ INTERNAL_FEED_CHANNELS,
2926
+ resolveInternalFeed,
2927
+ buildScopeRegistryArg,
1618
2928
  buildTokenArgs,
1619
2929
  buildNpmrc,
1620
2930
  buildNpmViewArgs,
@@ -1636,7 +2946,9 @@ module.exports = {
1636
2946
  quoteWindowsArg,
1637
2947
  resolveSpawnTarget,
1638
2948
  redact,
1639
- resolveRuntimeRoot,
2949
+ resolveAuthoritativeRuntimeRoot,
2950
+ runtimeRootPointerPath,
2951
+ findCheckoutRoot,
1640
2952
  resolveBackupCliCandidates,
1641
2953
  resolveBackupCli,
1642
2954
  resolveBackupStrategy,
@@ -1644,6 +2956,27 @@ module.exports = {
1644
2956
  diffPreservedPaths,
1645
2957
  snapshotReseedableFiles,
1646
2958
  restoreReseededFiles,
2959
+ readServicePids,
2960
+ detectRunningServices,
2961
+ readStateShm,
2962
+ assessDatabaseQuiescence,
2963
+ WHOLE_STACK_STOP_ARGS,
2964
+ LEGACY_STOP_ARGS,
2965
+ CLI_CAPABILITY_PROBE_ARGS,
2966
+ STOP_PATH_LABELS,
2967
+ detectStopCapabilities,
2968
+ probeStopCapability,
2969
+ formatQuiescenceRefusal,
2970
+ quiesceServices,
2971
+ retainOriginalStateFiles,
2972
+ restoreOriginalStateFiles,
2973
+ restoreBackupCopiedFiles,
2974
+ resolveStateOperationsModule,
2975
+ extractEmittedJson,
2976
+ readStateSummary,
2977
+ compareStateContinuity,
2978
+ describeContinuityFailure,
2979
+ assessStateRollback,
1647
2980
  assessRollback,
1648
2981
  countActiveAgents,
1649
2982
  assessAgentActivity,
@@ -1651,6 +2984,11 @@ module.exports = {
1651
2984
  planSteps,
1652
2985
  writeTempNpmrc,
1653
2986
  cleanupTempNpmrc,
2987
+ makeRunner,
2988
+ readUserNpmrc,
2989
+ parseViewedVersion,
2990
+ describeInternalFeedFailure,
2991
+ openInternalFeedSession,
1654
2992
  main,
1655
2993
  };
1656
2994