@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
@@ -1,5 +1,5 @@
1
1
  /**
2
- * engine/shared.js — Shared utilities for Minions engine, dashboard, and LLM modules.
2
+ * engine/core/shared.js — Shared utilities for Minions engine, dashboard, and LLM modules.
3
3
  * Extracted from engine.js and dashboard.js to eliminate duplication.
4
4
  */
5
5
 
@@ -11,11 +11,29 @@ const crypto = require('crypto');
11
11
  // enumeration, cwd-holder detection) live in process-utils.js
12
12
  // (P-mrb8yg9x001k1401-b). Re-exported below via `...processUtils` so no
13
13
  // caller of require('./shared') needs to change.
14
- const processUtils = require('./process-utils');
14
+ const processUtils = require('../processes/process-utils');
15
15
 
16
- const PACKAGE_ROOT = path.resolve(__dirname, '..');
16
+ const PACKAGE_ROOT = path.resolve(__dirname, '..', '..');
17
17
  const DEFAULT_MINIONS_HOME = path.join(os.homedir(), '.minions');
18
- const ROOT_POINTER_PATH = path.join(os.homedir(), '.minions-root');
18
+
19
+ function _resolveRootPointerPath() {
20
+ const processRoot = process.env.MINIONS_TEST_PROCESS_ROOT;
21
+ const requested = process.env.MINIONS_TEST_ROOT_POINTER_PATH;
22
+ if (processRoot && requested) {
23
+ try {
24
+ const root = path.resolve(processRoot);
25
+ const target = path.resolve(requested);
26
+ const relative = path.relative(root, target);
27
+ if (fs.existsSync(path.join(root, '.minions-test-process'))
28
+ && relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
29
+ return target;
30
+ }
31
+ } catch { /* fall through to the operator home */ }
32
+ }
33
+ return path.join(os.homedir(), '.minions-root');
34
+ }
35
+
36
+ const ROOT_POINTER_PATH = _resolveRootPointerPath();
19
37
 
20
38
  function isInstalledRoot(dir) {
21
39
  if (!dir) return false;
@@ -40,6 +58,11 @@ function saveMinionsRootPointer(root) {
40
58
  }
41
59
 
42
60
  function resolveMinionsHome(forInit = false, options = {}) {
61
+ const testHome = process.env.MINIONS_TEST_DIR
62
+ ? path.resolve(process.env.MINIONS_TEST_DIR)
63
+ : null;
64
+ if (testHome) return testHome;
65
+
43
66
  const envHome = process.env.MINIONS_HOME ? path.resolve(process.env.MINIONS_HOME) : null;
44
67
  if (envHome) return envHome;
45
68
 
@@ -63,6 +86,40 @@ const MINIONS_DIR = process.env.MINIONS_TEST_DIR || resolveMinionsHome(false, {
63
86
  preferSourceCheckout: true,
64
87
  });
65
88
  const ENGINE_DIR = path.join(MINIONS_DIR, 'engine');
89
+
90
+ // W-ms6wdokc00051a14 — Definition directories (playbooks/, prompts/, dashboard/)
91
+ // belong to the runtime root. The ONE exception is the process-isolation
92
+ // baseline runtime root created by test/process-isolation.js: it is seeded with
93
+ // runtime-state directories only, so it opts into a package-root fallback by
94
+ // carrying this marker. The fallback is deliberately scoped to that single
95
+ // directory — a test that swaps in its own MINIONS_TEST_DIR sandbox must stay
96
+ // sandboxed, or "this definition file is absent" assertions would silently read
97
+ // the real repository copy instead.
98
+ const TEST_PROCESS_RUNTIME_MARKER = '.minions-test-process-runtime';
99
+
100
+ function _definitionFallbackRootFor(runtimeRoot) {
101
+ const processRoot = process.env.MINIONS_TEST_PROCESS_ROOT;
102
+ if (!processRoot) return null;
103
+ try {
104
+ if (!fs.existsSync(path.join(path.resolve(processRoot), '.minions-test-process'))) return null;
105
+ if (!fs.existsSync(path.join(runtimeRoot, TEST_PROCESS_RUNTIME_MARKER))) return null;
106
+ } catch { return null; }
107
+ return PACKAGE_ROOT;
108
+ }
109
+
110
+ function resolveDefinitionDir(name) {
111
+ if (!/^[A-Za-z0-9._-]+$/.test(String(name || ''))) {
112
+ throw new Error(`Invalid definition directory: ${name}`);
113
+ }
114
+ const runtimeDir = path.join(MINIONS_DIR, name);
115
+ if (fs.existsSync(runtimeDir)) return runtimeDir;
116
+ const fallbackRoot = _definitionFallbackRootFor(MINIONS_DIR);
117
+ if (fallbackRoot) {
118
+ const packageDir = path.join(fallbackRoot, name);
119
+ if (fs.existsSync(packageDir)) return packageDir;
120
+ }
121
+ return runtimeDir;
122
+ }
66
123
  // W-mr2c4i8m0004da94: Node's built-in `--diagnostic-dir` target for the
67
124
  // engine.js process's crash-diagnostics report (see
68
125
  // getEngineCrashDiagnosticsEnv / pruneCrashDiagnosticsReports below and
@@ -150,22 +207,36 @@ function writeDashboardPortFile({ port, pid, minionsHome } = {}) {
150
207
  boundAt: new Date().toISOString(),
151
208
  };
152
209
  try {
153
- fs.mkdirSync(path.dirname(fp), { recursive: true });
154
- const tmp = fp + '.tmp';
155
- fs.writeFileSync(tmp, JSON.stringify(payload));
156
- fs.renameSync(tmp, fp);
210
+ withFileLock(`${fp}.lock`, () => {
211
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
212
+ const tmp = fp + '.tmp';
213
+ fs.writeFileSync(tmp, JSON.stringify(payload));
214
+ fs.renameSync(tmp, fp);
215
+ });
157
216
  return { ok: true, path: fp };
158
217
  } catch (err) {
159
218
  return { ok: false, error: err };
160
219
  }
161
220
  }
162
221
 
163
- /** Delete the runtime dashboard-port.json beacon. Called on graceful
164
- * dashboard shutdown so the next CLI status check doesn't probe a stale
165
- * port. Best-effort. */
166
- function clearDashboardPortFile(minionsHome) {
222
+ /** Delete the runtime dashboard-port.json beacon. An expected PID makes stale
223
+ * dashboard shutdowns preserve a newer process's beacon. Best-effort. */
224
+ function clearDashboardPortFile(minionsHome, { expectedPid = null } = {}) {
167
225
  const fp = _dashboardPortPath(minionsHome);
168
- try { fs.unlinkSync(fp); return { ok: true }; } catch { return { ok: false }; }
226
+ try {
227
+ return withFileLock(`${fp}.lock`, () => {
228
+ if (Number.isInteger(expectedPid)) {
229
+ const current = readDashboardPortFile(minionsHome);
230
+ if (!current || current.pid !== expectedPid) {
231
+ return { ok: false, reason: 'owner-changed' };
232
+ }
233
+ }
234
+ try { fs.unlinkSync(fp); return { ok: true }; }
235
+ catch { return { ok: false, reason: 'missing' }; }
236
+ });
237
+ } catch (error) {
238
+ return { ok: false, error };
239
+ }
169
240
  }
170
241
 
171
242
  /**
@@ -244,7 +315,7 @@ function nodeSqliteRemediationLine(version = process.versions.node) {
244
315
  }
245
316
 
246
317
  // ── F6 (P-f6commentedit): Comment-edit dedup helpers ────────────────────────
247
- // Shared by engine/github.js + engine/ado.js pollPrHumanComments so a comment
318
+ // Shared by engine/providers/github.js + engine/ado/index.js pollPrHumanComments so a comment
248
319
  // EDITED after first observation triggers a single re-dispatch (and only one).
249
320
  // MANDATORY TZ NORMALIZATION: GH `updated_at` is always UTC (Z-suffix), but
250
321
  // ADO `lastUpdatedDate` is inconsistent — sometimes `+00:00`, sometimes naive
@@ -465,7 +536,7 @@ function log(level, msg, meta = {}) {
465
536
  // Capture the SQL state.db path too — same write-time semantics so a
466
537
  // deferred buffer flush after the test ends still routes the entry to
467
538
  // the test's state.db (not the production one). Path resolution mirrors
468
- // engine/db.js but inlined to avoid the require cycle that loading the
539
+ // engine/db/index.js but inlined to avoid the require cycle that loading the
469
540
  // db module here would create on module init.
470
541
  Object.defineProperty(entry, '_dbPath', {
471
542
  value: _currentLogDbPath(),
@@ -496,7 +567,7 @@ function _currentLogDbPath() {
496
567
  if (process.env.MINIONS_TEST_DIR) {
497
568
  return path.join(path.resolve(process.env.MINIONS_TEST_DIR), 'engine', 'state.db');
498
569
  }
499
- const root = process.env.MINIONS_HOME ? path.resolve(process.env.MINIONS_HOME) : path.resolve(__dirname, '..');
570
+ const root = process.env.MINIONS_HOME ? path.resolve(process.env.MINIONS_HOME) : PACKAGE_ROOT;
500
571
  return path.join(root, 'engine', 'state.db');
501
572
  }
502
573
 
@@ -632,7 +703,7 @@ function _flushLogBuffer() {
632
703
  // state.db files stay isolated; logging must never crash the caller, so
633
704
  // appendLogsToDbFile already swallows its own errors.
634
705
  try {
635
- const store = require('./logs-store');
706
+ const store = require('../observability/logs-store');
636
707
  for (const [dbPath, entries] of byDbPath) {
637
708
  store.appendLogsToDbFile(dbPath, entries);
638
709
  }
@@ -1240,10 +1311,10 @@ function deleteDispatchPromptSidecar(item) {
1240
1311
 
1241
1312
  // ── Stop-intent flag (engine/stop-intent.json) ──────────────────────────────
1242
1313
  //
1243
- // File-presence signal used by the external supervisor (engine/supervisor.js)
1314
+ // File-presence signal used by the external supervisor (engine/recovery/supervisor.js)
1244
1315
  // to know when a user explicitly asked Minions to be down (`minions stop`,
1245
1316
  // `minions uninstall`, `engine.js stop`, mid-`minions restart`). Centralized
1246
- // here so dashboard.js, bin/minions.js, engine/cli.js, and supervisor.js all
1317
+ // here so dashboard.js, bin/minions.js, engine/operations/cli.js, and supervisor.js all
1247
1318
  // honor the same contract:
1248
1319
  //
1249
1320
  // - Set by every stop/kill code path BEFORE killing.
@@ -1295,7 +1366,7 @@ const LOCK_STALE_MS = 300000; // 5 minutes — force-remove locks older than thi
1295
1366
 
1296
1367
  // P-8a4d6f29 — single helper for detached-process stdio capture with
1297
1368
  // rotate-on-open. Used by bin/minions.js (engine + dashboard stdio logs) and
1298
- // engine/managed-spawn.js openManagedLog. Centralising replaces the previous
1369
+ // engine/processes/managed-spawn.js openManagedLog. Centralising replaces the previous
1299
1370
  // _openStdioLog in bin/minions.js so rotation is uniform across every long-
1300
1371
  // running log Minions writes.
1301
1372
  //
@@ -1364,7 +1435,7 @@ function openAppendLogFd(name, dir, opts) {
1364
1435
  // Windows) without installing anything (e.g. Sysinternals ProcDump).
1365
1436
  //
1366
1437
  // Callers: bin/minions.js (CLI `start`/`restart` spawn) and
1367
- // engine/supervisor.js (respawn-on-death). Both spawn engine.js as a fresh
1438
+ // engine/recovery/supervisor.js (respawn-on-death). Both spawn engine.js as a fresh
1368
1439
  // process before any config is loaded into memory, so this reads config.json
1369
1440
  // directly rather than expecting an already-parsed config object — pass the
1370
1441
  // parsed object in if the caller already has it (e.g. from its own
@@ -1392,7 +1463,7 @@ function getEngineCrashDiagnosticsEnv(config, baseEnv) {
1392
1463
  // mtime, mirroring dashboard.js's `_heapSnapshotPrune` retention pattern so
1393
1464
  // a leak-hunt session (or a genuine crash-loop) doesn't fill the disk with
1394
1465
  // reports unbounded. Best-effort: missing dir / unreadable stat / failed
1395
- // unlink are swallowed. Called from engine/cleanup.js#runCleanup (fires
1466
+ // unlink are swallowed. Called from engine/orchestration/cleanup.js#runCleanup (fires
1396
1467
  // every ENGINE_DEFAULTS.cleanupEvery ticks, ~10 min at default tick
1397
1468
  // interval) so retention happens automatically without a dedicated sweep.
1398
1469
  function pruneCrashDiagnosticsReports(config) {
@@ -1762,9 +1833,19 @@ function mutateJsonFileLocked(filePath, mutateFn, {
1762
1833
  }
1763
1834
  cleanupClaim();
1764
1835
  } else {
1836
+ // W-ms811bme000n475a — refresh the `.backup` sidecar AFTER the primary
1837
+ // write, from the content we just committed. safeWrite is atomic
1838
+ // (tmp file + rename), so a pre-write snapshot bought no torn-write
1839
+ // protection; all it did was leave the sidecar one revision stale.
1840
+ // safeJson's restore-on-missing/corrupt path then silently REVERTS
1841
+ // the last committed change and rewrites it to the primary — which is
1842
+ // how a project removed via removeProject() reappeared in config.json
1843
+ // and survived dashboard refreshes, engine ticks, and restart.
1844
+ // Copying after the write also stops a corrupt primary from being
1845
+ // copied over the last known-good backup.
1765
1846
  const backupPath = filePath + '.backup';
1766
- try { if (fileExists) fs.copyFileSync(filePath, backupPath); } catch { /* backup is best-effort */ }
1767
1847
  safeWrite(filePath, finalData);
1848
+ try { if (fileExists) fs.copyFileSync(filePath, backupPath); } catch { /* backup is best-effort */ }
1768
1849
  }
1769
1850
  // Side-effect hook fired only when an actual write happened. Callers
1770
1851
  // use this to emit cache-invalidation signals (events table row) so
@@ -1811,7 +1892,7 @@ function mutateControl(mutator) {
1811
1892
 
1812
1893
  // ── Crash-loop detection (W-mr2c46590003e3ee) ──────────────────────────────
1813
1894
  // Three independent mechanisms can respawn a dead/frozen engine process:
1814
- // engine/supervisor.js#checkEngine / #checkEngineHung, engine/watchdog.js
1895
+ // engine/recovery/supervisor.js#checkEngine / #checkEngineHung, engine/recovery/watchdog.js
1815
1896
  // (OS-scheduled external recovery), and dashboard.js's in-process 30s
1816
1897
  // watchdog. None of them counted or throttled repeated crashes, so a genuine
1817
1898
  // crash-loop (engine dying every few minutes) silently self-healed forever
@@ -1890,7 +1971,7 @@ function recordEngineRespawn(source) {
1890
1971
  }
1891
1972
 
1892
1973
  function mutateEngineState(mutator) {
1893
- const store = require('./small-state-store');
1974
+ const store = require('../persistence/small-state-store');
1894
1975
  const { result } = store.applyEngineStateMutation((data) => {
1895
1976
  if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
1896
1977
  return mutator(data) || data;
@@ -1899,19 +1980,19 @@ function mutateEngineState(mutator) {
1899
1980
  }
1900
1981
 
1901
1982
  function readEngineState() {
1902
- const data = require('./small-state-store').readEngineState();
1983
+ const data = require('../persistence/small-state-store').readEngineState();
1903
1984
  if (!data || typeof data !== 'object' || Array.isArray(data)) return {};
1904
1985
  return data;
1905
1986
  }
1906
1987
 
1907
1988
  function mutateCooldowns(mutator) {
1908
- const store = require('./small-state-store');
1989
+ const store = require('../persistence/small-state-store');
1909
1990
  const { wrote, result } = store.applyCooldownsMutation((data) => {
1910
1991
  if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
1911
1992
  return mutator(data) || data;
1912
1993
  });
1913
1994
  if (wrote) {
1914
- try { require('./db-events').emitStateEvent('cooldowns'); } catch { /* optional */ }
1995
+ try { require('../persistence/db-events').emitStateEvent('cooldowns'); } catch { /* optional */ }
1915
1996
  }
1916
1997
  return result;
1917
1998
  }
@@ -2229,7 +2310,7 @@ function validateGhSlug(slug) {
2229
2310
  }
2230
2311
 
2231
2312
  // P-f2-gh-shell (F2): validators for `gh api` endpoint paths and numeric IDs.
2232
- // Used by engine/github.js to gate argv-form shell-out calls (shellSafeGh)
2313
+ // Used by engine/providers/github.js to gate argv-form shell-out calls (shellSafeGh)
2233
2314
  // against poisoned PR JSON that could carry shell metacharacters or non-numeric
2234
2315
  // IDs through to child_process. All validators throw a structured Error on
2235
2316
  // rejection — callers must let it propagate so the shell-out never happens.
@@ -2418,7 +2499,7 @@ function shellSafeGh(args, opts = {}) {
2418
2499
 
2419
2500
  // W-mr3tayu4 — shared, age-gated stale `.git/index.lock` remover. Previously
2420
2501
  // lived only in engine.js and was called from the worktree-mode `git worktree
2421
- // add` retry paths; it is now shared so engine/live-checkout.js#prepareLiveCheckout
2502
+ // add` retry paths; it is now shared so engine/worktrees/live-checkout.js#prepareLiveCheckout
2422
2503
  // (checkoutMode:'live' projects) can self-heal the same failure without a
2423
2504
  // circular import. Removes `<rootDir>/.git/index.lock` ONLY when it is older
2424
2505
  // than STALE_INDEX_LOCK_MAX_AGE_MS (5 min) — deliberately conservative so a lock
@@ -2590,11 +2671,117 @@ function cleanChildEnv() {
2590
2671
  return { ..._cleanEnvCache };
2591
2672
  }
2592
2673
 
2674
+ // Engine-owned defaults also protect PR branches that predate runner safeguards.
2675
+ function applyAgentTestLoadSafetyEnv(env) {
2676
+ if (!env || typeof env !== 'object') return env;
2677
+ env.MINIONS_TEST_HOST_RUNTIME_PRESENT = '1';
2678
+ if (!(Number(env.MINIONS_TEST_CONCURRENCY) > 0)) {
2679
+ env.MINIONS_TEST_CONCURRENCY = '1';
2680
+ }
2681
+ if (!(Number(env.MINIONS_TEST_GLOBAL_CONCURRENCY_CAP) > 0)) {
2682
+ env.MINIONS_TEST_GLOBAL_CONCURRENCY_CAP = '3';
2683
+ }
2684
+ return env;
2685
+ }
2686
+
2593
2687
  // Environment for git commands — prevents credential manager from opening browser
2594
2688
  function gitEnv() {
2595
2689
  return { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' };
2596
2690
  }
2597
2691
 
2692
+ // ── Shared remote-ref refresh serialization (W-ms5tb6ha016fda10) ─────────────
2693
+ //
2694
+ // Two dispatches preparing FRESH worktrees in the SAME clone during the same
2695
+ // tick both run `git fetch origin <mainBranch>`, and both target the SAME
2696
+ // `refs/remotes/origin/<mainBranch>`. Git takes a `<ref>.lock` for the update,
2697
+ // so the loser dies with `cannot lock ref 'refs/remotes/origin/main': is at
2698
+ // <sha> but expected <sha>`. That is a purely LOCAL serialization defect: it
2699
+ // has nothing to do with connectivity, and laundering it into
2700
+ // FAILURE_CLASS.NETWORK_ERROR hid the real cause behind a "flaky network"
2701
+ // story while burning dispatch retries.
2702
+ //
2703
+ // Two complementary mitigations live here:
2704
+ //
2705
+ // 1. `withSerializedRemoteRefRefresh` — an in-process, per-repository async
2706
+ // mutex. The engine daemon is the process that actually issues the
2707
+ // concurrent fresh-worktree fetches, so chaining them per resolved repo
2708
+ // path removes the race at its source. It is deliberately NOT a file
2709
+ // lock: a file lock here would be held across a network round-trip,
2710
+ // which the concurrency rules in CLAUDE.md forbid. The chain is keyed on
2711
+ // the repo, so unrelated projects still fetch in parallel and no
2712
+ // unrelated work is serialized.
2713
+ //
2714
+ // 2. `isGitRefLockError` — classification for the residual cross-process
2715
+ // case (an operator's own `git fetch`, the dashboard, or the CLI racing
2716
+ // the daemon), which maps to the retryable FAILURE_CLASS.GIT_REF_LOCK.
2717
+ const GIT_REF_LOCK_PATTERNS = [
2718
+ /cannot lock ref/i,
2719
+ /unable to lock ref/i,
2720
+ /cannot lock the ref/i,
2721
+ /unable to update local ref/i,
2722
+ /unable to create '[^']*\.lock'/i,
2723
+ /ref\b[^\n]*\bis at\b[^\n]*\bbut expected\b/i,
2724
+ ];
2725
+
2726
+ /**
2727
+ * True when a git failure is a LOCAL ref-lock contention loss rather than a
2728
+ * network/auth failure. Accepts an Error, a string, or anything stringifiable;
2729
+ * inspects `stderr` and `stdout` too because `child_process.exec` rejections
2730
+ * put the actionable git text there rather than in `message`.
2731
+ */
2732
+ function isGitRefLockError(err) {
2733
+ if (!err) return false;
2734
+ const parts = [];
2735
+ if (typeof err === 'string') parts.push(err);
2736
+ else {
2737
+ if (err.message) parts.push(String(err.message));
2738
+ if (err.stderr) parts.push(String(err.stderr));
2739
+ if (err.stdout) parts.push(String(err.stdout));
2740
+ if (parts.length === 0) parts.push(String(err));
2741
+ }
2742
+ const text = parts.join('\n');
2743
+ return GIT_REF_LOCK_PATTERNS.some(re => re.test(text));
2744
+ }
2745
+
2746
+ // resolved repo key -> tail promise of the currently queued refresh chain
2747
+ const _remoteRefRefreshChains = new Map();
2748
+
2749
+ /**
2750
+ * Serialize `fn` against every other remote-ref refresh for the same
2751
+ * repository within this process. Returns whatever `fn` resolves/rejects with
2752
+ * — the queue never swallows errors, and a rejected refresh does not poison
2753
+ * the chain for the next caller.
2754
+ *
2755
+ * Keep `fn` scoped to the ref refresh itself. Do NOT wrap worktree creation,
2756
+ * checkout, or merge work in it: those are unrelated expensive operations and
2757
+ * serializing them would convert a narrow correctness fix into a throughput
2758
+ * regression.
2759
+ */
2760
+ function withSerializedRemoteRefRefresh(repoKey, fn) {
2761
+ if (typeof fn !== 'function') {
2762
+ return Promise.reject(new TypeError('withSerializedRemoteRefRefresh requires a function'));
2763
+ }
2764
+ let key;
2765
+ try { key = _normalizeWorktreePath(path.resolve(String(repoKey || ''))); }
2766
+ catch { key = null; }
2767
+ if (!key) key = '<no-repo>';
2768
+ const previous = _remoteRefRefreshChains.get(key) || Promise.resolve();
2769
+ // `previous` is already error-swallowed (see `tail`), but guard both settle
2770
+ // paths so a future refactor cannot deadlock the chain on a rejection.
2771
+ const run = previous.then(() => fn(), () => fn());
2772
+ const tail = run.then(() => {}, () => {});
2773
+ _remoteRefRefreshChains.set(key, tail);
2774
+ tail.then(() => {
2775
+ if (_remoteRefRefreshChains.get(key) === tail) _remoteRefRefreshChains.delete(key);
2776
+ });
2777
+ return run;
2778
+ }
2779
+
2780
+ /** Test-only: drop every queued refresh chain. */
2781
+ function _resetRemoteRefRefreshChainsForTesting() {
2782
+ _remoteRefRefreshChains.clear();
2783
+ }
2784
+
2598
2785
  // ── Stream-JSON Output Parsing (runtime-aware delegator) ────────────────────
2599
2786
 
2600
2787
  /**
@@ -2607,7 +2794,7 @@ function gitEnv() {
2607
2794
  *
2608
2795
  * Signatures supported:
2609
2796
  * parseStreamJsonOutput(raw)
2610
- * parseStreamJsonOutput(raw, optsObj) ← legacy form (engine/llm.js still uses this)
2797
+ * parseStreamJsonOutput(raw, optsObj) ← legacy form (engine/agents/llm.js still uses this)
2611
2798
  * parseStreamJsonOutput(raw, runtimeName)
2612
2799
  * parseStreamJsonOutput(raw, runtimeName, optsObj)
2613
2800
  *
@@ -2622,7 +2809,7 @@ function parseStreamJsonOutput(raw, runtimeName, opts) {
2622
2809
  }
2623
2810
  // Lazy require to avoid a circular dep at module init (runtimes/claude.js
2624
2811
  // doesn't import shared, but downstream adapters might).
2625
- const { resolveRuntime } = require('./runtimes');
2812
+ const { resolveRuntime } = require('../runtimes');
2626
2813
  return resolveRuntime(runtimeName).parseOutput(raw, opts || {});
2627
2814
  }
2628
2815
 
@@ -2702,7 +2889,7 @@ const KB_CATEGORIES = ['architecture', 'conventions', 'project-notes', 'build-re
2702
2889
  // authoring populate but that aren't destinations for inbox classification:
2703
2890
  //
2704
2891
  // agents — per-agent personal memory (knowledge/agents/<id>.md),
2705
- // loaded by engine/consolidation.js + engine/playbook.js.
2892
+ // loaded by engine/memory/consolidation.js + engine/agents/playbook.js.
2706
2893
  // consolidated /
2707
2894
  // consolidation /
2708
2895
  // consolidations — three historical names the consolidation pipeline has
@@ -2740,7 +2927,7 @@ const KB_READABLE_CATEGORIES = Object.freeze([
2740
2927
  * duplicate-titled knowledge/project-notes/*.md entries on a single day) and
2741
2928
  * the sibling W-mr3lokxs fix for live-checkout-dirty alerts.
2742
2929
  *
2743
- * `engine/dispatch.js#writeInboxAlert` names every file `engine-alert-<slug>-
2930
+ * `engine/orchestration/dispatch.js#writeInboxAlert` names every file `engine-alert-<slug>-
2744
2931
  * <date>.md`, so the filename prefix is one signal. `shared.js#_writeWorktree
2745
2932
  * SkipLiveInboxNote` (and any other engine-authored note) stamps frontmatter
2746
2933
  * `agent: engine`, accepted as a second signal so alerts written under a
@@ -2980,7 +3167,7 @@ function isDispatchExecutionPathSafe(item, config) {
2980
3167
  // otherwise `false`. Only an explicit boolean counts as "set" at either level —
2981
3168
  // anything else (undefined / null / string) is treated as unset so the next
2982
3169
  // tier (or the `false` default) decides. When ON and a live-checkout tree is
2983
- // dirty, `engine/live-checkout.js#prepareLiveCheckout` runs
3170
+ // dirty, `engine/worktrees/live-checkout.js#prepareLiveCheckout` runs
2984
3171
  // `git reset --hard HEAD` + `git clean -fd` instead of refusing with
2985
3172
  // LIVE_CHECKOUT_DIRTY. This discards uncommitted tracked and untracked work but
2986
3173
  // preserves the current branch and committed history, so it is opt-in. Sibling
@@ -3116,6 +3303,39 @@ function resolveCodexCcSandbox(engine) {
3116
3303
  return validateCodexSandbox(configured);
3117
3304
  }
3118
3305
 
3306
+ // W-ms87lj8j018j2572 — SINGLE SOURCE OF TRUTH for the quarantine recovery
3307
+ // window. Quarantine recovery REFS (`refs/minions/quarantine/*` and
3308
+ // `refs/minions/quarantine-wip/*`, pruned by
3309
+ // `engine/worktrees/quarantine-refs.js`) and PROTECTED quarantine DIRECTORIES
3310
+ // (swept by `engine/worktrees/gc.js` at
3311
+ // `ENGINE_DEFAULTS.worktreeProtectedQuarantineRetentionMs`) govern the SAME
3312
+ // operator recovery story, so they MUST expire together. They previously drifted
3313
+ // (10d vs 30d) because each side owned its own literal. The constant lives here —
3314
+ // the lowest dependency-free layer — because `engine/worktrees/quarantine-refs.js`
3315
+ // already `require`s this module, so shared.js cannot import from it at load time.
3316
+ // Consumers must reference these bindings; never re-derive the number.
3317
+ const QUARANTINE_REF_RETENTION_DAYS = 30;
3318
+ const QUARANTINE_REF_RETENTION_MS = QUARANTINE_REF_RETENTION_DAYS * 24 * 60 * 60 * 1000;
3319
+
3320
+ /**
3321
+ * Resolve the operator-visible quarantine recovery window in ms.
3322
+ *
3323
+ * W-ms87rbe601a66d13 — aligning the two DEFAULTS (above) is only half the
3324
+ * story: `engine.worktreeProtectedQuarantineRetentionMs` is an operator knob,
3325
+ * and until both sweeps read it the directory sweep alone honored an override
3326
+ * while the REF sweep stayed pinned to the built-in constant. That reintroduces
3327
+ * the exact divergence at runtime that the shared constant removes at build
3328
+ * time. This resolver is the single seam both sweeps go through, so lowering,
3329
+ * raising, or disabling retention always moves them together.
3330
+ *
3331
+ * A non-positive value means INDEFINITE retention on both sides (the documented
3332
+ * `0` opt-out) — never "everything is already expired".
3333
+ */
3334
+ function resolveQuarantineRetentionMs(config) {
3335
+ const configured = Number(config?.engine?.worktreeProtectedQuarantineRetentionMs);
3336
+ return Number.isFinite(configured) ? Math.max(0, configured) : QUARANTINE_REF_RETENTION_MS;
3337
+ }
3338
+
3119
3339
  const ENGINE_DEFAULTS = {
3120
3340
  // W-mpxpckey000laa4f: tick interval lowered 60s → 10s so the shipped default
3121
3341
  // matches what operators already run at (live boxes have had `tickInterval:
@@ -3165,7 +3385,7 @@ const ENGINE_DEFAULTS = {
3165
3385
  // [1000, 120000] in dashboard.js POST /api/settings.
3166
3386
  assertCleanStatusTimeoutMs: 10000,
3167
3387
  // W-mqpx0tpi — cold-start health budget for `minions restart` / post-update
3168
- // restart verification (engine/restart-health.js#waitForRestartHealth). The
3388
+ // restart verification (engine/recovery/restart-health.js#waitForRestartHealth). The
3169
3389
  // dashboard runs node:sqlite migrations + SPA HTML assembly + initial state
3170
3390
  // reads SYNCHRONOUSLY before server.listen(), so right after `npm install`
3171
3391
  // on a slow/loaded box the PID is alive but port 7331 is unbound well past
@@ -3190,6 +3410,16 @@ const ENGINE_DEFAULTS = {
3190
3410
  decomposeZeroChildrenStaleMinutes: 30,
3191
3411
  autoApprovePlans: false, // auto-approve PRDs without waiting for human approval
3192
3412
  autoArchive: false, // opt-in: auto-archive plans after verify completes (false = mark ready, user archives manually)
3413
+ // W-msal3ser01q4ccb3 — opt-in: after a PRD's verify work item(s) complete
3414
+ // SUCCESSFULLY and the canonical prd/guides/verify-<plan>.md has been
3415
+ // persisted, auto-create a PRD-driven QA Session (mode 'auto', so
3416
+ // SETUP→DRAFT→EXECUTE runs without approval) through the SAME
3417
+ // buildQaSessionSpecFromPrd + createSession + queueSetup path that backs
3418
+ // POST /api/qa/from-prd. Deliberately NOT keyed off the PRD's top-level
3419
+ // `status: completed` flip — that happens before verification finishes.
3420
+ // Resolved via shared.resolveAutoQaCompletedPrds(engine). Default false =
3421
+ // behavior-identical to today.
3422
+ autoQaCompletedPrds: false,
3193
3423
  autoFixConflicts: true, // auto-dispatch fix agents when a PR has merge conflicts
3194
3424
  autoFixBuilds: true, // auto-dispatch fix agents when a PR build fails
3195
3425
  autoReviewPrs: true, // auto-dispatch review agents for newly opened agent PRs
@@ -3222,7 +3452,7 @@ const ENGINE_DEFAULTS = {
3222
3452
  // to before this knob existed. Flip via Dashboard → Settings → Auto-fix
3223
3453
  // & Review Loop, or set `engine.autoFixPaused: true` in config.json.
3224
3454
  autoFixPaused: false, // hard-stop kill-switch / master override (see comment above)
3225
- autoConsolidateMemory: true, // default-on: periodically spawn engine/kb-sweep-runner.js from the tick loop (4h cadence). Explicit false disables it; inbox→notes consolidation is independent.
3455
+ autoConsolidateMemory: true, // default-on: periodically spawn engine/memory/kb-sweep-runner.js from the tick loop (4h cadence). Explicit false disables it; inbox→notes consolidation is independent.
3226
3456
  // W-mrktrokd — batteries-included default schedules. When true (default),
3227
3457
  // adding a project auto-creates its per-project git-fetch-freshness schedule
3228
3458
  // (`keep-branch-fresh-git-fetch-origin-<slug>`, cron `0 */12 *`, explore) and
@@ -3232,7 +3462,7 @@ const ENGINE_DEFAULTS = {
3232
3462
  // Bootstrap is idempotent + non-destructive — it never duplicates or
3233
3463
  // overwrites a schedule an operator has already created/customized (matched
3234
3464
  // by schedule id). Set false to opt every install out. See
3235
- // engine/schedule-bootstrap.js and Dashboard → Settings → Workflow Defaults.
3465
+ // engine/orchestration/schedule-bootstrap.js and Dashboard → Settings → Workflow Defaults.
3236
3466
  bootstrapDefaultSchedules: true,
3237
3467
  // W-mr48yth5 — per-category age-based TTL (in days) for the KB sweep's expiry
3238
3468
  // pass. During runKbSweep, an entry in a listed category whose authored date
@@ -3361,8 +3591,8 @@ const ENGINE_DEFAULTS = {
3361
3591
  evalMaxCost: null, // USD ceiling per work item across all eval iterations; null = no limit (gather baseline data first)
3362
3592
  maxRetries: 3, // max dispatch retries before marking work item as failed
3363
3593
  maxRetriesPerAgent: 2, // W-mpmwxn1j — per-agent retry cap. When the SAME agent fails the same WI this many times, the next retry MUST reassign to a different eligible agent (consults routing.md + agent availability). Falls back to the same agent only when no alternate is available. Counted separately from `maxRetries` (which caps total retries across all agents) and tracked on the WI as `_retriesByAgent: { agentId: count }`. Hard-pinned agents bypass reassignment (operator intent wins).
3364
- maxPhantomRetries: 3, // max retries for "phantom completion" (runtime crashed before emitting type:"result"); tracked separately from _retryCount so phantom retries don't pollute the normal PR-attachment retry budget. See engine/lifecycle.markMissingPrAttachment + detectNonTerminalResultSummary.
3365
- maxReviewVerdictMissingRetries: 2, // W-mr9sy3j700076967 — bounded retry budget for a review dispatch that completes ("success") without posting a VERDICT: comment. Below the budget, engine/lifecycle.updatePrAfterReview leaves `lastReviewedAt` unset so the normal needsReview gate re-dispatches a fresh review; at/above budget it stamps `lastReviewedAt` (stops auto-retry) but keeps `_reviewVerdictMissing: true` so the PR is dashboard-flagged for a human instead of looking like an ordinary still-pending review.
3594
+ maxPhantomRetries: 3, // max retries for "phantom completion" (runtime crashed before emitting type:"result"); tracked separately from _retryCount so phantom retries don't pollute the normal PR-attachment retry budget. See engine/orchestration/lifecycle.markMissingPrAttachment + detectNonTerminalResultSummary.
3595
+ maxReviewVerdictMissingRetries: 2, // W-mr9sy3j700076967 — bounded retry budget for a review dispatch that completes ("success") without posting a VERDICT: comment. Below the budget, engine/orchestration/lifecycle.updatePrAfterReview leaves `lastReviewedAt` unset so the normal needsReview gate re-dispatches a fresh review; at/above budget it stamps `lastReviewedAt` (stops auto-retry) but keeps `_reviewVerdictMissing: true` so the PR is dashboard-flagged for a human instead of looking like an ordinary still-pending review.
3366
3596
  minRetryGapMs: 120000, // 2min — minimum gap between retry dispatches for the same work item; prevents tight retry loops when an idempotent agent (e.g. review bailing out on a duplicate) cannot produce the expected output (#1770)
3367
3597
  pipelineApiRetries: 2, // max attempts for pipeline API calls
3368
3598
  pipelineApiRetryDelay: 2000, // ms delay between pipeline API retries
@@ -3446,7 +3676,7 @@ const ENGINE_DEFAULTS = {
3446
3676
  memoryBaselineEveryTicks: 6,
3447
3677
  // W-mr2c4i8m0004da94: proactive crash diagnostics for the engine.js
3448
3678
  // node.exe process. When enabled (default), every spawn of engine.js
3449
- // (bin/minions.js `start`/`restart` AND engine/supervisor.js respawn-on-
3679
+ // (bin/minions.js `start`/`restart` AND engine/recovery/supervisor.js respawn-on-
3450
3680
  // death) gets NODE_OPTIONS += `--report-on-fatalerror --report-on-signal
3451
3681
  // --diagnostic-dir=<CRASH_REPORTS_DIR>` so a fatal V8 error (OOM, stack
3452
3682
  // overflow, native-addon crash) or an operator-sent signal
@@ -3466,6 +3696,19 @@ const ENGINE_DEFAULTS = {
3466
3696
  // → no flip), so the constant is GitHub-only today but lives in shared defaults so a future
3467
3697
  // ADO change can adopt the same semantics.
3468
3698
  prAbandonConfirmCount: 3,
3699
+ // W-msav5edk00g81710: bounded re-probe window for `abandoned` PR records. A PR can
3700
+ // legitimately leave `abandoned` — GitHub and ADO both allow reopening a closed PR —
3701
+ // but `PR_POLLABLE_STATUSES` excludes terminal statuses, so before this constant the
3702
+ // abandoned stamp was a one-way door and the poller could never observe the reopen
3703
+ // (incident: github:opg-microsoft/minions#1154, closed 16:07:07Z and reopened
3704
+ // 16:10:07Z, stuck `abandoned` for hours while OPEN and one hourly-sweep run away
3705
+ // from a permanent `userDeleted` tombstone). `isPrPollable` keeps an abandoned record
3706
+ // in the STATUS poll for this long after it was abandoned, then drops it again. The
3707
+ // window is what keeps the cost bounded: re-probing every terminal record forever
3708
+ // would burn API quota linearly in tracker size. Records with no abandonment anchor
3709
+ // fail CLOSED (never re-probed), and a host-confirmed deletion
3710
+ // (`_reconciliation404Confirmed`) closes the window early. `0` disables re-probing.
3711
+ abandonedPrRepollWindowMs: 7 * 24 * 60 * 60 * 1000, // 7 days
3469
3712
  // W-mp60tw0u000j3931: One-shot startup reconciliation pass for `abandoned` PRs runs
3470
3713
  // exactly once per bump of this constant. The engine compares this value against
3471
3714
  // the persisted lastAbandonedReconciliationVersion at boot; if the version
@@ -3474,24 +3717,43 @@ const ENGINE_DEFAULTS = {
3474
3717
  // hardening in W-mp5trwh60008386d shipped) are flipped back to active/merged/closed
3475
3718
  // based on their live API state. Bump this when reconciliation logic itself changes
3476
3719
  // and we want it to re-run once on every install.
3477
- abandonedReconciliationVersion: 1,
3720
+ //
3721
+ // Bumped to 2 by W-msav5edk00g81710: that WI changed abandonment bookkeeping
3722
+ // (records now carry an `_abandonedAt` anchor) and added the bounded re-probe
3723
+ // window. Records abandoned BEFORE the bump have no anchor beyond `closedAt`,
3724
+ // and anything older than the window is out of the poller's reach entirely —
3725
+ // this boot pass is their one-off repair, including the #1154 incident record.
3726
+ abandonedReconciliationVersion: 2,
3478
3727
  watchesIncludeBehindBy: false, // opt-in: when true, GitHub PR poll calls /compare/{base}...{head} once per pr per pollPrStatusEvery cadence to populate pr.behindBy (powers the `behind-master` watch predicate). Off by default to avoid the extra API call. ADO PRs always get null (no commit-graph walk yet).
3479
3728
  autoCompletePrs: false, // auto-merge PRs when builds green + review approved (opt-in)
3729
+ // W-ms7kklp8002zad24 — ADO-only: proactively queue the required PR-validation
3730
+ // (branch-policy) builds for DRAFT pull requests. On Azure DevOps, branch build
3731
+ // policies do NOT run automatically while a PR is a draft, so a Minions-managed
3732
+ // draft PR never gets a build status and the build-fix loop has nothing to act
3733
+ // on. When ON (default), pollPrStatus discovers the required Build policies from
3734
+ // the target branch's policy configurations and queues a fresh build per distinct
3735
+ // definition against refs/pull/<n>/merge (rate-limited via _lastBuildRequeueAt).
3736
+ // GitHub already fires CI on drafts, so this is inert for GitHub PRs.
3737
+ adoQueueDraftPrBuilds: true,
3480
3738
  prMergeMethod: 'squash', // merge method: squash, merge, rebase
3481
3739
  ignoredCommentAuthors: [], // comments from these authors are auto-closed and never trigger fixes
3482
3740
  // P-f23classifier (F3, security note): ADO Minions identity allow-list for
3483
- // the marker spoof guard in engine/ado.js pollPrHumanComments. ADO has no
3741
+ // the marker spoof guard in engine/ado/index.js pollPrHumanComments. ADO has no
3484
3742
  // viewerDidAuthor equivalent, so a human pasting the <!-- minions:agent=... -->
3485
3743
  // marker would otherwise be misclassified as agent-authored. The poller
3486
3744
  // combines marker-presence with comment.author.{uniqueName|displayName|id|
3487
3745
  // descriptor} matching one of these (case-insensitive) before treating a
3488
- // body as agent-authored. Empty array (default) means marker-presence alone
3489
- // NEVER classifies as agent on ADO the body still has to match one of
3490
- // the legacy patterns (Minions(...), by Minions, [minions], VERDICT:).
3746
+ // body as agent-authored.
3747
+ // W-msbf1kf6033f46cb: an empty array (default) means the identity signal is
3748
+ // UNAVAILABLE, not mismatched, so the marker alone classifies as agent
3749
+ // (fail-safe) and the poller logs a one-time warning that the spoof guard is
3750
+ // inactive. Populating this list re-enables the cross-check. The previous
3751
+ // fail-OPEN reading gave the marker zero filtering power on ADO and let every
3752
+ // agent fix-summary re-trigger a fix dispatch on the same PR.
3491
3753
  minionsAdoIdentities: [],
3492
3754
  botCommentLogins: [], // P-a3f9b2c1: opt-in shared-minions GH login list — comments from these logins are suppressed ONLY when body matches positive-signal markers (Verification SUCCESS / VERDICT:APPROVE / noop:true). Narrower than ignoredCommentAuthors which suppresses all comments by login.
3493
3755
  // W-mp76pw7a001da7c1 — Per-slug GitHub PAT routing. Map of `<owner>` (or `<owner>/*`,
3494
- // or `*` for fleet default) to a `gh auth` account name. `engine/gh-token.js`
3756
+ // or `*` for fleet default) to a `gh auth` account name. `engine/providers/gh-token.js`
3495
3757
  // resolves the right token via `gh auth token --user <account> --hostname github.com`
3496
3758
  // and threads it as `GH_TOKEN` for that one shell-out, so the engine never depends
3497
3759
  // on which gh account is globally active. Empty `{}` (default) preserves legacy
@@ -3500,7 +3762,7 @@ const ENGINE_DEFAULTS = {
3500
3762
  ghAccounts: {},
3501
3763
  agentBusyReassignMs: 600000, // 10min — reassign work item to another agent if preferred agent is busy beyond this threshold
3502
3764
  ccEffort: null, // effort level for CC/doc-chat (null, 'low', 'medium', 'high')
3503
- enablePreDispatchEval: true, // P-d2a9f6e5: cheap LLM gate before queueing — on by default. See engine/pre-dispatch-eval.js (Ripley §3 recommendation, 2026-05-11 architecture review). Validates from acceptance_criteria when present, falls back to description when criteria are absent but description is rich (≥80 chars). Fail-open on any validator error.
3765
+ enablePreDispatchEval: true, // P-d2a9f6e5: cheap LLM gate before queueing — on by default. See engine/orchestration/pre-dispatch-eval.js (Ripley §3 recommendation, 2026-05-11 architecture review). Validates from acceptance_criteria when present, falls back to description when criteria are absent but description is rich (≥80 chars). Fail-open on any validator error.
3504
3766
  // W-mq9acoo800177bcb — bounded-concurrency for the pre-dispatch validator. The
3505
3767
  // discoverWork loop in engine.js used to run validateAcceptanceCriteria
3506
3768
  // sequentially, costing ~25-30s per item × N items per tick (an 11-item
@@ -3533,7 +3795,7 @@ const ENGINE_DEFAULTS = {
3533
3795
  preDispatchEvalMaxRejections: 5,
3534
3796
  completionNonceRequired: false, // P-d2a8f6c1 (agent trust boundary F8): when true, a missing `nonce` field in the completion JSON hard-fails the dispatch with failure_class:'completion-nonce-mismatch'. Default false for one release so older agents/runtime caches that haven't picked up the prompt change degrade with a warning instead of breaking. Mismatched nonces hard-fail regardless of this flag. See docs/completion-reports.md → "Trust boundary".
3535
3797
  autoApplyReviewVote: false, // Master gate for platform vote mutations; local verdicts remain informational when false.
3536
- createPrsAsDraft: false, // W-mrtdy7xv — when true, the playbook PR-creation guidance (engine/playbook.js#getPrCreateInstructions) tells agents to open normal Minions-created PRs as DRAFT (GitHub `gh pr create --draft`; ADO `az repos pr create --draft true`). Default false = behave exactly as today. Per-project-overridable via `project.createPrsAsDraft` (explicit boolean), resolved through `shared.resolveCreatePrsAsDraft(project, engine)` — a project can opt IN or OUT independent of this fleet-wide default. Aggregate/E2E verify PRs already open as draft via verify.md regardless of this flag; the engine-opened shared-branch aggregate PR is outside the playbook seam and unaffected.
3798
+ createPrsAsDraft: false, // W-mrtdy7xv — when true, the playbook PR-creation guidance (engine/agents/playbook.js#getPrCreateInstructions) tells agents to open normal Minions-created PRs as DRAFT (GitHub `gh pr create --draft`; ADO `az repos pr create --draft true`). Default false = behave exactly as today. Per-project-overridable via `project.createPrsAsDraft` (explicit boolean), resolved through `shared.resolveCreatePrsAsDraft(project, engine)` — a project can opt IN or OUT independent of this fleet-wide default. Aggregate/E2E verify PRs already open as draft via verify.md regardless of this flag; the engine-opened shared-branch aggregate PR is outside the playbook seam and unaffected.
3537
3799
 
3538
3800
  // ── Runtime fleet (P-3b8e5f1d) ──────────────────────────────────────────────
3539
3801
  // Single source of truth for which CLI runtime + model every spawn uses.
@@ -3556,13 +3818,13 @@ const ENGINE_DEFAULTS = {
3556
3818
  // projects.<worktreePath>.enabledMcpjsonServers (the field Claude Code actually reads — see live ~/.claude.json
3557
3819
  // shape) means the workspace MCPs are connected on first call. Best-effort: failures NEVER block dispatch.
3558
3820
  // Engine-scoped per the CLAUDE.md rule against `runtime.name === ...` branches at call sites — the runtime check
3559
- // lives inside the helper (engine/spawn-agent.js#preApproveWorkspaceMcps) which no-ops for non-Claude runtimes.
3821
+ // lives inside the helper (engine/agents/spawn-agent.js#preApproveWorkspaceMcps) which no-ops for non-Claude runtimes.
3560
3822
  claudePreApproveWorkspaceMcps: true,
3561
3823
  // W-mrdon0pe000l045a — propagate repo-authored CLAUDE.md instructions to
3562
3824
  // runtimes that do NOT auto-discover CLAUDE.md natively (Copilot, Codex).
3563
3825
  // When TRUE (default), the engine bounded-discovers the nearest applicable
3564
3826
  // CLAUDE.md files for a dispatch and injects them as a "Project instructions
3565
- // (CLAUDE.md)" context layer (engine/playbook.js → engine/claude-md-context.js).
3827
+ // (CLAUDE.md)" context layer (engine/agents/playbook.js → engine/agents/claude-md-context.js).
3566
3828
  // Claude is always skipped — its own CLI already reads CLAUDE.md, so injecting
3567
3829
  // it again would double the instructions. CLAUDE.md carries no split-brain
3568
3830
  // risk for Copilot/Codex because those runtimes never read it themselves, so
@@ -3573,12 +3835,12 @@ const ENGINE_DEFAULTS = {
3573
3835
  propagateClaudeMdForNonClaudeRuntimes: true,
3574
3836
  // W-mrtdmtwq000kf391 — surface the repo HARNESS assets in effect for a
3575
3837
  // dispatch (runtime + project) in the Minions-authored PR body. When TRUE
3576
- // (default), engine/playbook.js injects a PR-body appendix instructing the
3838
+ // (default), engine/agents/playbook.js injects a PR-body appendix instructing the
3577
3839
  // agent to append a fenced "## Repo harnesses used" section listing the
3578
3840
  // dispatch's runtime, injected CLAUDE.md files, AGENTS.md discovery, MCP
3579
3841
  // servers, and skills. Pure reporting via the read-only harness-diagnostics
3580
- // source (engine/harness-context.js → engine/preflight._runtimeHarnessRows +
3581
- // engine/claude-md-context.js); it never propagates or attests any asset.
3842
+ // source (engine/agents/harness-context.js → engine/agents/preflight._runtimeHarnessRows +
3843
+ // engine/agents/claude-md-context.js); it never propagates or attests any asset.
3582
3844
  includeHarnessSectionInPrBody: true,
3583
3845
  copilotStreamMode: 'on', // Copilot --stream <on|off>: 'on' streams assistant.message_delta events live; 'off' batches them
3584
3846
  copilotReasoningSummaries: false, // Copilot --enable-reasoning-summaries (Anthropic-family models only)
@@ -3586,7 +3848,7 @@ const ENGINE_DEFAULTS = {
3586
3848
  // P-9b3d5f61 (Pool copilot agent dispatches through a shared ACP worker pool):
3587
3849
  // when true AND the resolved runtime declares `capabilities.acpWorkerPool`,
3588
3850
  // engine.js spawnAgent routes per-agent dispatches through
3589
- // engine/agent-worker-pool.js (persistent `copilot --acp` workers) instead of
3851
+ // engine/agents/agent-worker-pool.js (persistent `copilot --acp` workers) instead of
3590
3852
  // cold-spawning a fresh CLI per dispatch. Opt-in while fleet-pool lifecycle
3591
3853
  // hardening remains newer than the established cold-spawn path. Structurally
3592
3854
  // Copilot-only, same as
@@ -3594,7 +3856,7 @@ const ENGINE_DEFAULTS = {
3594
3856
  // resolved runtime's capability flag isn't true, regardless of this override.
3595
3857
  agentUseWorkerPool: false,
3596
3858
  // P-9b3d5f61 — max concurrent ACP workers the per-agent pool
3597
- // (engine/agent-worker-pool.js) will keep warm. `undefined` = fall back to
3859
+ // (engine/agents/agent-worker-pool.js) will keep warm. `undefined` = fall back to
3598
3860
  // the fleet's `maxConcurrent` dispatch cap via resolveAgentAcpPoolSize, so
3599
3861
  // the pool never queues more than the engine would have dispatched anyway.
3600
3862
  agentAcpPoolSize: undefined,
@@ -3686,7 +3948,8 @@ const ENGINE_DEFAULTS = {
3686
3948
  liveCheckoutAutoFreshen: false,
3687
3949
  orphanHolderScanTimeoutMs: 5000, // 5s ceiling for the cross-platform holder scan (PowerShell / /proc walk / lsof)
3688
3950
  ccMaxTurns: 50, // max tool-use turns per CC/doc-chat call before CLI stops (per response, not per session)
3689
- ccWorkerIdleTimeoutMs: 30 * 60 * 1000, // W-mr0qs0vw: idle-reaper window for the persistent `copilot --acp` worker pool (engine/cc-worker-pool.js). After this much inactivity with no in-flight turn the warm ACP process is killed; the next message cold-spawns a fresh session with NO memory of prior turns (CC shows a "context cleared after inactivity" notice). Tradeoff: shorter = less idle memory/process footprint, longer = more context durability across gaps between messages. Wired into the pool via ccWorkerPool.setIdleTimeoutMs() on every reloadConfig(); clamped to [60000, 28800000] (1min–8h) in the settings POST handler. W-mr1qbj90000d30bd: `0` is an explicit "never idle-reap" sentinel that bypasses the clamp — cc-worker-pool.js maps it to an Infinity window so only an explicit closeTab kills the warm worker; Settings page exposes this as the "Never idle-reap CC workers" toggle.
3951
+ ccWorkerIdleTimeoutMs: 30 * 60 * 1000, // W-mr0qs0vw: idle-reaper window for the persistent `copilot --acp` worker pool (engine/agents/cc-worker-pool.js). After this much inactivity with no in-flight turn the warm ACP process is killed; the next message cold-spawns a fresh session with NO memory of prior turns (CC shows a "context cleared after inactivity" notice). Tradeoff: shorter = less idle memory/process footprint, longer = more context durability across gaps between messages. Wired into the pool via ccWorkerPool.setIdleTimeoutMs() on every reloadConfig(); clamped to [60000, 28800000] (1min–8h) in the settings POST handler. W-mr1qbj90000d30bd: `0` is an explicit "never idle-reap" sentinel that bypasses the clamp — cc-worker-pool.js maps it to an Infinity window so only an explicit closeTab kills the warm worker; Settings page exposes this as the "Never idle-reap CC workers" toggle.
3952
+ acpHandshakeTimeoutMs: 180000, // issue #1105: budget for the cold ACP worker handshake (`initialize` + `session/new` / `session/load`) in engine/agents/acp-transport.js, shared by the CC worker pool (engine/agents/cc-worker-pool.js) and the agent worker pool (engine/agents/agent-worker-pool.js). A cold `copilot --acp` start that loads the user's MCP configuration routinely exceeds the old hardcoded 60s, which surfaced as "Command Center returned no output / handshake timed out after 60000ms". Pushed into each pool via setHandshakeTimeoutMs() on config load; clamped to [10000, 900000] (10s–15min) in the settings POST handler. Timeout still raises the typed, retriable ACP_HANDSHAKE_FAILED error.
3690
3953
  ccTurnTimeoutMs: 300000, // W-mpmwxni2000c25c7-b/-d: 5min per-turn no-progress watchdog. The window resets on every liveness signal — token chunk, tool-call notification, tool-update — so an actively-streaming CC/doc-chat turn (long shell command, deep search, sub-agent loop) survives indefinitely up to the outer CC_CALL_TIMEOUT_MS (~1h) ceiling. Only true silence past this window with no progress fires the cancel: the in-flight LLM call is aborted and the handler surfaces `{code:'cc-turn-timeout', retryable:true}` via the typed error envelope so the UI can stop the spinner and offer Retry. Clamped to [10000, 3600000] in the settings POST handler. Independent of CC_CALL_TIMEOUT_MS. Non-streaming doc-chat is the lone wall-clock exception (no progress hooks); see _raceCcDocChatTimeout in dashboard.js for the dual factory/promise shape.
3691
3954
  docSessionMaxEntries: 200, // cap doc-chat session map/disk store by least-recent activity (LRU; sessions are non-expiring otherwise)
3692
3955
  ccLiveStreamMaxAgeMs: 30 * 60 * 1000, // hard cap reconnect buffers if abort/cleanup stalls
@@ -3718,7 +3981,7 @@ const ENGINE_DEFAULTS = {
3718
3981
  memoryRetrievalMaxBytes: 8 * 1024, // hard byte budget for the Relevant Memory prompt appendix
3719
3982
  memoryRetrievalCandidateLimit: 50, // bounded FTS5 candidate pool before deterministic re-ranking
3720
3983
  memoryEpisodicCapture: false, // opt-in capture of compact task outcomes; never stores transcripts or chain-of-thought
3721
- untrustedFenceMaxBytes: 64 * 1024, // F5 (W-mpeklod3000we69c): per-block cap for `<UNTRUSTED-INPUT>` fences in engine/untrusted-fence.js. 64KB is long enough for realistic PR comments / pinned notes / agent memory sections, short enough that a megabyte-bomb comment cannot blow up the prompt. Content above the cap is truncated INSIDE the fence with a `[truncated N more bytes]` marker so the agent still sees the provenance attribute.
3984
+ untrustedFenceMaxBytes: 64 * 1024, // F5 (W-mpeklod3000we69c): per-block cap for `<UNTRUSTED-INPUT>` fences in engine/core/untrusted-fence.js. 64KB is long enough for realistic PR comments / pinned notes / agent memory sections, short enough that a megabyte-bomb comment cannot blow up the prompt. Content above the cap is truncated INSIDE the fence with a `[truncated N more bytes]` marker so the agent still sees the provenance attribute.
3722
3985
  maxMeetingPromptBytes: 16 * 1024, // cap meeting findings/debate context injected into prompts
3723
3986
  maxMeetingHumanNotesBytes: 2 * 1024, // cap human note bullet lists injected into meeting prompts
3724
3987
  maxPipelineMeetingContextBytes: 16 * 1024, // cap aggregated meeting/dependency context for pipeline plan generation
@@ -3730,10 +3993,24 @@ const ENGINE_DEFAULTS = {
3730
3993
  // for the same project. Saves the cold install/build cost on heavy projects
3731
3994
  // (constellation: bun install + Vite warmup; minions: npm install + test cache).
3732
3995
  // Default off — opt-in fleet-wide via engine.worktreePoolSize or per-project
3733
- // via projects[].worktreePoolSize. See engine/worktree-pool.js + CLAUDE.md
3996
+ // via projects[].worktreePoolSize. See engine/worktrees/pool.js + CLAUDE.md
3734
3997
  // "Worktree pool" section for the lifecycle and edge cases.
3735
3998
  worktreePoolSize: 0, // 0 = disabled (default); per-project override beats this
3736
3999
  worktreePoolIdleTtlMs: 6 * 3600 * 1000, // 6h — idle entries past TTL are evicted by cleanup
4000
+ // W-ms5tb6ha016fda10 — PROTECTED quarantine directories (those whose dirty
4001
+ // WIP could NOT be snapshotted to a durable `refs/minions/quarantine-wip/*`
4002
+ // ref) were previously kept FOREVER, so a busy install accumulated
4003
+ // quarantine residue without bound (20 such dirs observed on 2026-07-29).
4004
+ // They remain the longest-lived worktree artifact, but retention is now
4005
+ // finite and is DERIVED from the single shared `QUARANTINE_REF_RETENTION_MS`
4006
+ // constant above (30 days) — the same window
4007
+ // `engine/worktrees/quarantine-refs.js` prunes recovery refs at — so the
4008
+ // operator recovery window for quarantine DIRECTORIES and quarantine REFS
4009
+ // expires together instead of one outliving the other. Do NOT replace this
4010
+ // with a literal: the two windows drifted (10d vs 30d) exactly because each
4011
+ // side owned its own number.
4012
+ // Set to 0 to restore indefinite retention.
4013
+ worktreeProtectedQuarantineRetentionMs: QUARANTINE_REF_RETENTION_MS,
3737
4014
  // ── keep_processes (W-mp68q6ke0010de68) ────────────────────────────────────
3738
4015
  // Opt-in per-WI (`meta.keep_processes: true`) feature that lets an agent
3739
4016
  // declare specific descendant PIDs the engine MUST NOT reap on close. The
@@ -3759,7 +4036,7 @@ const ENGINE_DEFAULTS = {
3759
4036
  // P-7a3b1c92 / plan W-mp7k1r760003b5dd — managed-spawn primitive: agents
3760
4037
  // describe long-running services in agents/<id>/managed-spawn.json and the
3761
4038
  // engine owns the spawn + healthcheck lifecycle. These defaults gate the
3762
- // validator (engine/managed-spawn.js) and later the engine-side spawn loop,
4039
+ // validator (engine/processes/managed-spawn.js) and later the engine-side spawn loop,
3763
4040
  // per-tick sweep, playbook hint, and dashboard endpoints. Mirrors the
3764
4041
  // `keepProcesses` block intentionally — symmetry with the keep-pids
3765
4042
  // primitive is the documented design convention.
@@ -3857,10 +4134,35 @@ const ENGINE_DEFAULTS = {
3857
4134
  constellationBridge: {
3858
4135
  enabled: false,
3859
4136
  },
4137
+ // ── Constellation companion probe (P-e93c85a1) ──────────────────────────────
4138
+ // Read-only detection of whether a COMPATIBLE Constellation companion is
4139
+ // present on this host. Reporting only: Minions never downloads, executes, or
4140
+ // installs Constellation, and companion absence is a visible degraded state,
4141
+ // never fatal. Surfaced by `minions doctor`, `minions bridge health`, and as
4142
+ // informational output from `minions init` / `minions update`.
4143
+ //
4144
+ // probeEnabled Documented opt-out for managed deployments. `false`
4145
+ // suppresses the probe ENTIRELY (no marker read, no health
4146
+ // request). `MINIONS_DISABLE_COMPANION_PROBE=1` does the
4147
+ // same from the environment and wins over config.
4148
+ // probeTimeoutMs Hard bound on the optional loopback health request, so a
4149
+ // hung companion can never stall doctor/init/update/start.
4150
+ // Clamped to [250, 15000] ms at read time.
4151
+ // healthUrl Operator override for the companion's health endpoint.
4152
+ // `null` (default) means "use whatever the companion's own
4153
+ // marker declares, if anything". LOOPBACK-ONLY: a remote
4154
+ // host is refused without being requested.
4155
+ //
4156
+ // See engine/api/companion.js and docs/constellation-bridge.md.
4157
+ constellationCompanion: {
4158
+ probeEnabled: true,
4159
+ probeTimeoutMs: 2000,
4160
+ healthUrl: null,
4161
+ },
3860
4162
  // ── Operator identity (W-mpejf0fq000e84d6) ──────────────────────────────────
3861
4163
  // Explicit override for the human operator's platform login used in branch
3862
4164
  // names (see `deriveWorkItemBranchName`). `null` (default) means auto-resolve
3863
- // via `engine/operator-identity.js` (gh → git email localpart → os user).
4165
+ // via `engine/core/operator-identity.js` (gh → git email localpart → os user).
3864
4166
  // Settings UI exposes this as a free-text input; clearing the field deletes
3865
4167
  // the override and falls back to auto-resolution.
3866
4168
  operatorLogin: null,
@@ -3963,7 +4265,7 @@ function resolveCcCli(engine) {
3963
4265
  function resolveCcUseWorkerPool(engine) {
3964
4266
  let runtime;
3965
4267
  try {
3966
- runtime = require('./runtimes').resolveRuntime(resolveCcCli(engine));
4268
+ runtime = require('../runtimes').resolveRuntime(resolveCcCli(engine));
3967
4269
  } catch {
3968
4270
  return false;
3969
4271
  }
@@ -3976,7 +4278,7 @@ function resolveCcUseWorkerPool(engine) {
3976
4278
 
3977
4279
  /**
3978
4280
  * P-9b3d5f61 — Resolve whether a per-agent spawn should route through the
3979
- * persistent ACP worker pool (engine/agent-worker-pool.js). Mirrors
4281
+ * persistent ACP worker pool (engine/agents/agent-worker-pool.js). Mirrors
3980
4282
  * resolveCcUseWorkerPool's shape but takes the already-resolved runtime
3981
4283
  * adapter (as returned by `resolveRuntime(resolveAgentCli(agent, engine))`)
3982
4284
  * rather than re-deriving it, since callers on the agent-spawn path already
@@ -3998,9 +4300,33 @@ function resolveAgentUseWorkerPool(engine, runtime) {
3998
4300
  return ENGINE_DEFAULTS.agentUseWorkerPool;
3999
4301
  }
4000
4302
 
4303
+ // Issue #1105 — clamp bounds for engine.acpHandshakeTimeoutMs. Mirrored by the
4304
+ // settings POST validator so the UI and the resolver agree on the legal range.
4305
+ const ACP_HANDSHAKE_TIMEOUT_MIN_MS = 10000;
4306
+ const ACP_HANDSHAKE_TIMEOUT_MAX_MS = 900000;
4307
+
4308
+ /**
4309
+ * Issue #1105 — Resolve the ACP worker handshake budget (`initialize` plus
4310
+ * `session/new` / `session/load`) used by both ACP worker pools. Priority:
4311
+ * 1. `engine.acpHandshakeTimeoutMs` — operator override
4312
+ * 2. `ENGINE_DEFAULTS.acpHandshakeTimeoutMs` — 180s default
4313
+ *
4314
+ * Non-numeric or non-positive overrides fall through to the default; valid
4315
+ * values are clamped to [10s, 15min] so a bad Settings/env value can neither
4316
+ * make the handshake un-cancellable nor fire before a cold start can finish.
4317
+ */
4318
+ function resolveAcpHandshakeTimeoutMs(engine) {
4319
+ const raw = engine ? engine.acpHandshakeTimeoutMs : undefined;
4320
+ const n = typeof raw === 'number' ? raw : Number(raw);
4321
+ if (raw === undefined || raw === null || raw === '' || !Number.isFinite(n) || n <= 0) {
4322
+ return ENGINE_DEFAULTS.acpHandshakeTimeoutMs;
4323
+ }
4324
+ return Math.min(ACP_HANDSHAKE_TIMEOUT_MAX_MS, Math.max(ACP_HANDSHAKE_TIMEOUT_MIN_MS, Math.round(n)));
4325
+ }
4326
+
4001
4327
  /**
4002
4328
  * P-9b3d5f61 — Resolve the max concurrent ACP workers the per-agent pool
4003
- * (engine/agent-worker-pool.js) should keep warm. Priority:
4329
+ * (engine/agents/agent-worker-pool.js) should keep warm. Priority:
4004
4330
  * 1. `engine.agentAcpPoolSize` — explicit operator override
4005
4331
  * 2. `engine.maxConcurrent` — fleet dispatch concurrency cap (so the
4006
4332
  * pool never queues more than the engine would have dispatched anyway)
@@ -4133,7 +4459,7 @@ function resolvePropagateClaudeMdForNonClaudeRuntimes(project, engine) {
4133
4459
  // "set" at either level — undefined/null/absent falls through to the next tier
4134
4460
  // so operators can enable draft PRs fleet-wide while opting a specific project
4135
4461
  // OUT (or vice-versa). Pure, no I/O. Consumed by
4136
- // engine/playbook.js#getPrCreateInstructions.
4462
+ // engine/agents/playbook.js#getPrCreateInstructions.
4137
4463
  function resolveCreatePrsAsDraft(project, engine) {
4138
4464
  if (project && typeof project === 'object' && typeof project.createPrsAsDraft === 'boolean') {
4139
4465
  return project.createPrsAsDraft;
@@ -4144,6 +4470,18 @@ function resolveCreatePrsAsDraft(project, engine) {
4144
4470
  return ENGINE_DEFAULTS.createPrsAsDraft;
4145
4471
  }
4146
4472
 
4473
+ // W-msal3ser01q4ccb3 — canonical resolution seam for the auto-QA-after-verify
4474
+ // flag. Only an explicit boolean on `engine` counts as "set"; anything else
4475
+ // (undefined/null/'true'/0) falls through to ENGINE_DEFAULTS so a malformed
4476
+ // config.json cannot silently enable an automatic dispatch. Pure, no I/O.
4477
+ // Consumed by engine/qa/auto-prd-qa.js.
4478
+ function resolveAutoQaCompletedPrds(engine) {
4479
+ if (engine && typeof engine === 'object' && typeof engine.autoQaCompletedPrds === 'boolean') {
4480
+ return engine.autoQaCompletedPrds;
4481
+ }
4482
+ return ENGINE_DEFAULTS.autoQaCompletedPrds;
4483
+ }
4484
+
4147
4485
  // ─── Legacy ccModel → defaultModel Migration ─────────────────────────────────
4148
4486
  //
4149
4487
  // Pre-P-3b8e5f1d, `engine.ccModel` was the single fleet-wide model knob (it
@@ -4194,7 +4532,7 @@ function _resetLegacyCcModelMigrationFlag() {
4194
4532
  // ─── Runtime Config Preflight Warnings ──────────────────────────────────────
4195
4533
  //
4196
4534
  // Emit non-fatal warnings about runtime/CLI configuration drift. Consumed by
4197
- // engine/preflight.js (which converts the entries to `{ name, ok: 'warn',
4535
+ // engine/agents/preflight.js (which converts the entries to `{ name, ok: 'warn',
4198
4536
  // message }` shape) and surfaced via `minions doctor`.
4199
4537
  //
4200
4538
  // The function is pure: takes the config and the list of registered runtime
@@ -4271,7 +4609,7 @@ function runtimeConfigWarnings(config, registeredRuntimes) {
4271
4609
  if (engine.claudeBareMode === true) {
4272
4610
  const ccCli = resolveCcCli(engine);
4273
4611
  let ccRuntime = null;
4274
- try { ccRuntime = require('./runtimes').resolveRuntime(ccCli); } catch { /* unknown runtime — skip */ }
4612
+ try { ccRuntime = require('../runtimes').resolveRuntime(ccCli); } catch { /* unknown runtime — skip */ }
4275
4613
  if (ccRuntime?.capabilities?.bareMode === true && !(engine.ccSystemPrompt !== undefined && engine.ccSystemPrompt !== null && engine.ccSystemPrompt !== '')) {
4276
4614
  warnings.push({
4277
4615
  id: 'bare-mode-misconfig',
@@ -4526,6 +4864,182 @@ const WORKTREE_REQUIRING_TYPES = new Set([
4526
4864
  WORK_TYPE.DOCS,
4527
4865
  ]);
4528
4866
 
4867
+ // ── Execution surface (W-ms5tb6ha016fda10) ──────────────────────────────────
4868
+ //
4869
+ // Some scheduled/maintenance work has NOTHING to do with a repository
4870
+ // checkout: "merge approved PRs", "sweep the PR tracker", "one-time review
4871
+ // external PRs", "CI health check". Its entire contract is Minions dashboard
4872
+ // API calls plus external CLIs (`gh`, `az`). Historically those still ran as
4873
+ // `setup`/`explore` dispatches, so the engine allocated a full project
4874
+ // worktree for them — pure cost, and worse: concurrent fresh-worktree
4875
+ // preparation raced on `refs/remotes/origin/<main>` and failed jobs that never
4876
+ // needed a checkout at all.
4877
+ //
4878
+ // `meta.executionSurface` makes that EXPLICIT rather than inferred. There is
4879
+ // deliberately no prose sniffing ("does the description mention curl?") — the
4880
+ // operator/schedule declares the surface as structured data and the engine
4881
+ // validates it fail-closed:
4882
+ //
4883
+ // 'repo' (default) — today's behavior: worktree / live checkout.
4884
+ // 'control-plane' — no repository checkout is allocated at all.
4885
+ // cwd is MINIONS_DIR; project context is still
4886
+ // carried in the prompt.
4887
+ // 'cloud' — the work is handed to a REMOTE coding agent
4888
+ // (Agency / Copilot cloud). No local checkout,
4889
+ // no local process: the remote agent owns its
4890
+ // own workspace and produces a PR that Minions
4891
+ // then adopts into its normal PR record and
4892
+ // review/fix loops.
4893
+ //
4894
+ // Fail-closed by construction: the opt-in is an ALLOWLIST of work types
4895
+ // (`CONTROL_PLANE_ELIGIBLE_TYPES`), not a denylist. A repo-mutating type
4896
+ // (fix/implement/review/test/verify/decompose/docs/build-fix-complex) can
4897
+ // never declare control-plane, and an unknown surface string is rejected
4898
+ // rather than defaulted. `cloud` carries its own, separate allowlist
4899
+ // (`CLOUD_ELIGIBLE_TYPES`) because its eligibility question is the inverse
4900
+ // one — not "can this run without a checkout?" but "does this produce a PR a
4901
+ // remote agent can author?".
4902
+ const EXECUTION_SURFACE = {
4903
+ REPO: 'repo',
4904
+ CONTROL_PLANE: 'control-plane',
4905
+ CLOUD: 'cloud',
4906
+ };
4907
+ const EXECUTION_SURFACE_VALUES = Object.freeze(Object.values(EXECUTION_SURFACE));
4908
+
4909
+ // Only these types may declare `control-plane`. Every one of them is a type
4910
+ // that never creates a branch, never commits, and never opens a PR, so
4911
+ // removing its checkout cannot silently change what the dispatch produces.
4912
+ // `setup` is the important entry: the built-in maintenance schedules use it.
4913
+ const CONTROL_PLANE_ELIGIBLE_TYPES = new Set([
4914
+ WORK_TYPE.SETUP,
4915
+ WORK_TYPE.ASK,
4916
+ WORK_TYPE.EXPLORE,
4917
+ WORK_TYPE.MEETING,
4918
+ ]);
4919
+
4920
+ function isControlPlaneEligibleType(type) {
4921
+ return CONTROL_PLANE_ELIGIBLE_TYPES.has(String(type || '').trim().toLowerCase());
4922
+ }
4923
+
4924
+ // Only these types may declare `cloud`. A cloud dispatch is a hand-off: Minions
4925
+ // renders the same playbook prompt it would have given a local agent, ships it
4926
+ // to a remote coding agent, and adopts the PR that comes back. That only makes
4927
+ // sense for types whose deliverable IS a pull request against a single repo.
4928
+ //
4929
+ // Deliberately excluded, and why:
4930
+ // review / verify — their job is to build, run tests, and inspect a
4931
+ // checkout; a remote agent that cannot run the local
4932
+ // validation build produces a worse review, not a
4933
+ // cheaper one.
4934
+ // build-fix-complex — needs live CI logs and the PR's own branch.
4935
+ // decompose — pure planning; it mutates Minions state, not a repo.
4936
+ // implement:large — decomposed into child items first; the CHILDREN are
4937
+ // what may go to the cloud.
4938
+ // setup — produces no PR and usually drives local processes
4939
+ // (managed_spawn), which a remote agent cannot leave
4940
+ // running on this machine.
4941
+ // ask / explore / meeting — read-only; their value is the output text, which
4942
+ // the cloud surface does not stream back.
4943
+ const CLOUD_ELIGIBLE_TYPES = new Set([
4944
+ WORK_TYPE.IMPLEMENT,
4945
+ WORK_TYPE.FIX,
4946
+ WORK_TYPE.TEST,
4947
+ WORK_TYPE.DOCS,
4948
+ ]);
4949
+
4950
+ function isCloudEligibleType(type) {
4951
+ return CLOUD_ELIGIBLE_TYPES.has(String(type || '').trim().toLowerCase());
4952
+ }
4953
+
4954
+ /**
4955
+ * Validate a declared execution surface against the work type.
4956
+ *
4957
+ * @returns {{valid: true, value: string}|{valid: false, error: string, code: string}}
4958
+ * Unset/empty normalizes to EXECUTION_SURFACE.REPO (back-compat default).
4959
+ */
4960
+ function validateExecutionSurface(value, type) {
4961
+ if (value === undefined || value === null) return { valid: true, value: EXECUTION_SURFACE.REPO };
4962
+ if (typeof value !== 'string') {
4963
+ return {
4964
+ valid: false,
4965
+ code: 'invalid-execution-surface',
4966
+ error: `meta.executionSurface must be a string (got ${typeof value})`,
4967
+ };
4968
+ }
4969
+ const normalized = value.trim().toLowerCase();
4970
+ if (!normalized) return { valid: true, value: EXECUTION_SURFACE.REPO };
4971
+ if (!EXECUTION_SURFACE_VALUES.includes(normalized)) {
4972
+ return {
4973
+ valid: false,
4974
+ code: 'invalid-execution-surface',
4975
+ error: `meta.executionSurface must be one of ${EXECUTION_SURFACE_VALUES.join(', ')} (got "${value}")`,
4976
+ };
4977
+ }
4978
+ if (normalized === EXECUTION_SURFACE.CONTROL_PLANE && !isControlPlaneEligibleType(type)) {
4979
+ return {
4980
+ valid: false,
4981
+ code: 'invalid-execution-surface',
4982
+ error: `work type "${type}" may not declare meta.executionSurface "control-plane" — `
4983
+ + `it reads or mutates repository files and requires a checkout. `
4984
+ + `Eligible types: ${[...CONTROL_PLANE_ELIGIBLE_TYPES].sort().join(', ')}.`,
4985
+ };
4986
+ }
4987
+ if (normalized === EXECUTION_SURFACE.CLOUD && !isCloudEligibleType(type)) {
4988
+ return {
4989
+ valid: false,
4990
+ code: 'invalid-execution-surface',
4991
+ error: `work type "${type}" may not declare meta.executionSurface "cloud" — `
4992
+ + `a cloud dispatch hands the task to a remote coding agent and adopts the pull `
4993
+ + `request it authors, so only PR-producing types qualify. `
4994
+ + `Eligible types: ${[...CLOUD_ELIGIBLE_TYPES].sort().join(', ')}.`,
4995
+ };
4996
+ }
4997
+ return { valid: true, value: normalized };
4998
+ }
4999
+
5000
+ /**
5001
+ * Read the declared surface off a work item (`meta.executionSurface`, with the
5002
+ * top-level `executionSurface` accepted for ad-hoc dispatch payloads). Returns
5003
+ * the RAW declared value so callers can run `validateExecutionSurface` and
5004
+ * surface a precise rejection; use `isControlPlaneExecution` when you only need
5005
+ * the fail-closed boolean.
5006
+ */
5007
+ function readDeclaredExecutionSurface(item) {
5008
+ if (!item || typeof item !== 'object') return undefined;
5009
+ const fromMeta = item.meta && typeof item.meta === 'object' ? item.meta.executionSurface : undefined;
5010
+ return fromMeta !== undefined ? fromMeta : item.executionSurface;
5011
+ }
5012
+
5013
+ /**
5014
+ * Fail-closed predicate: true ONLY when the item explicitly and validly
5015
+ * declares control-plane for an eligible type. Any invalid, unknown, or
5016
+ * ineligible declaration returns false, so a malformed opt-in degrades to a
5017
+ * normal repo dispatch rather than silently skipping the checkout.
5018
+ */
5019
+ function isControlPlaneExecution(item, type) {
5020
+ const declared = readDeclaredExecutionSurface(item);
5021
+ if (declared === undefined || declared === null) return false;
5022
+ const resolvedType = type !== undefined && type !== null ? type : item?.type;
5023
+ const result = validateExecutionSurface(declared, resolvedType);
5024
+ return result.valid && result.value === EXECUTION_SURFACE.CONTROL_PLANE;
5025
+ }
5026
+
5027
+ /**
5028
+ * Fail-closed predicate: true ONLY when the item explicitly and validly
5029
+ * declares the `cloud` surface for an eligible type. Any invalid, unknown, or
5030
+ * ineligible declaration returns false, so a malformed opt-in degrades to a
5031
+ * normal local repo dispatch rather than silently shipping the work to a
5032
+ * remote agent (the strictly safer default — local dispatch is the behavior
5033
+ * every existing work item already has).
5034
+ */
5035
+ function isCloudExecution(item, type) {
5036
+ const declared = readDeclaredExecutionSurface(item);
5037
+ if (declared === undefined || declared === null) return false;
5038
+ const resolvedType = type !== undefined && type !== null ? type : item?.type;
5039
+ const result = validateExecutionSurface(declared, resolvedType);
5040
+ return result.valid && result.value === EXECUTION_SURFACE.CLOUD;
5041
+ }
5042
+
4529
5043
  // Hybrid routing is meaningful only for work types that would otherwise need
4530
5044
  // an isolated project worktree. Read-only root tasks already run from the
4531
5045
  // project/root path and therefore gain nothing from a live-validation entry.
@@ -4613,15 +5127,266 @@ const PLAN_STATUS = {
4613
5127
  };
4614
5128
  const PRD_ITEM_STATUS = { MISSING: 'missing', UPDATED: 'updated', DONE: 'done' };
4615
5129
  const PRD_MATERIALIZABLE = new Set([PRD_ITEM_STATUS.MISSING, PRD_ITEM_STATUS.UPDATED]);
5130
+
5131
+ // ── PRD revision lifecycle (W-msa0mrus00mh466f) ──────────────────────────────
5132
+ // `revision-requested` (an agent is actively rewriting the PRD) and `planStale`
5133
+ // (the source markdown changed under an existing PRD) are two DIFFERENT recovery
5134
+ // paths that could previously be true at the same time, so the dashboard offered
5135
+ // "Regenerate PRD" / "Resume as-is" while a revision agent was already in flight.
5136
+ // These helpers are the single place that derivation happens: the dashboard
5137
+ // renders what the server returns and never re-derives it.
5138
+ const PRD_REVISION_STATE = {
5139
+ NONE: 'none',
5140
+ IN_PROGRESS: 'in-progress',
5141
+ FAILED: 'failed',
5142
+ };
5143
+ // A revision operation still owns the PRD while its work item can make progress.
5144
+ // Anything outside this set is terminal, so the PRD needs an explicit recovery
5145
+ // state rather than a permanent "revision in progress".
5146
+ const PRD_REVISION_ACTIVE_WI_STATUSES = new Set([
5147
+ WI_STATUS.PENDING, WI_STATUS.DISPATCHED, WI_STATUS.QUEUED, WI_STATUS.PAUSED,
5148
+ ]);
5149
+
5150
+ /**
5151
+ * Resolve the work item backing a PRD's revision request.
5152
+ *
5153
+ * Primary key is the `revisionWorkItemId` pointer persisted on the PRD. The
5154
+ * reverse `_revisionForPrd` stamp on the work item is the fallback so a PRD
5155
+ * whose pointer write raced (or predates the pointer) still correlates to its
5156
+ * operation — never by matching title text.
5157
+ *
5158
+ * @param {object|null} plan PRD object
5159
+ * @param {string} prdFile PRD filename (e.g. `foo-2026-08-01.json`)
5160
+ * @param {Array<object>} workItems Work items to search (central scope)
5161
+ * @returns {object|null}
5162
+ */
5163
+ function findPrdRevisionWorkItem(plan, prdFile, workItems) {
5164
+ const items = Array.isArray(workItems) ? workItems : [];
5165
+ const pinnedId = (plan && typeof plan === 'object') ? String(plan.revisionWorkItemId || '').trim() : '';
5166
+ if (pinnedId) {
5167
+ const byId = items.find(w => w && w.id === pinnedId);
5168
+ if (byId) return byId;
5169
+ }
5170
+ const file = String(prdFile || '').trim();
5171
+ if (!file) return null;
5172
+ let fallback = null;
5173
+ for (const w of items) {
5174
+ if (!w || w.type !== WORK_TYPE.PLAN_TO_PRD || w._revisionForPrd !== file) continue;
5175
+ if (PRD_REVISION_ACTIVE_WI_STATUSES.has(w.status)) return w;
5176
+ if (!fallback || String(w.created || '') > String(fallback.created || '')) fallback = w;
5177
+ }
5178
+ return fallback;
5179
+ }
5180
+
5181
+ /**
5182
+ * Canonical revision state for a PRD. Pure — callers resolve the work item with
5183
+ * `findPrdRevisionWorkItem` first.
5184
+ *
5185
+ * @param {object|null} plan
5186
+ * @param {object|null} workItem
5187
+ * @returns {{state:string, active:boolean, workItemId:string, workItemStatus:string,
5188
+ * requestedAt:string, requestedBy:string, feedback:string, failReason:string}}
5189
+ */
5190
+ function derivePrdRevisionState(plan, workItem) {
5191
+ const out = {
5192
+ state: PRD_REVISION_STATE.NONE, active: false,
5193
+ workItemId: '', workItemStatus: '',
5194
+ requestedAt: '', requestedBy: '', feedback: '', failReason: '',
5195
+ };
5196
+ if (!plan || typeof plan !== 'object') return out;
5197
+ if (plan.status !== PLAN_STATUS.REVISION_REQUESTED) return out;
5198
+ out.requestedAt = plan.revisionRequestedAt || '';
5199
+ out.requestedBy = plan.revisionRequestedBy || '';
5200
+ out.feedback = plan.revision_feedback || '';
5201
+ const wi = (workItem && typeof workItem === 'object') ? workItem : null;
5202
+ out.workItemId = String((wi && wi.id) || plan.revisionWorkItemId || '').trim();
5203
+ if (!wi) {
5204
+ // No operation backs the request. Surface it as recoverable instead of
5205
+ // pinning a permanent, misleading "in progress".
5206
+ out.state = PRD_REVISION_STATE.FAILED;
5207
+ out.failReason = 'Revision work item not found';
5208
+ return out;
5209
+ }
5210
+ out.workItemStatus = String(wi.status || '');
5211
+ if (PRD_REVISION_ACTIVE_WI_STATUSES.has(wi.status)) {
5212
+ out.state = PRD_REVISION_STATE.IN_PROGRESS;
5213
+ out.active = true;
5214
+ return out;
5215
+ }
5216
+ out.state = PRD_REVISION_STATE.FAILED;
5217
+ out.failReason = wi.failReason
5218
+ || (DONE_STATUSES.has(wi.status)
5219
+ ? 'Revision agent finished without updating this PRD'
5220
+ : `Revision work item ended as "${out.workItemStatus || 'unknown'}"`);
5221
+ return out;
5222
+ }
5223
+
5224
+ /**
5225
+ * Operator feedback to inject into a revision dispatch's prompt.
5226
+ *
5227
+ * A revision run is a plan-to-prd run whose ONLY distinguishing input is the
5228
+ * operator's feedback, so that text has to reach the agent explicitly. It used
5229
+ * to arrive by accident: the work item's `planFile` named the PRD `.json`, the
5230
+ * engine's `PLANS_DIR/<planFile>` read failed, and the description (which
5231
+ * embeds the feedback) leaked in through the `plan_content` fallback. Pointing
5232
+ * `planFile` at the real source markdown closed that accident, and
5233
+ * `plan-to-prd.md` has no `{{task_description}}` token, so the feedback needs
5234
+ * its own channel.
5235
+ *
5236
+ * The PRD is the single source of truth for the feedback (`handlePlansRevise`
5237
+ * writes it, `applyPrdRevisionCompletion` clears it), which also keeps a retry
5238
+ * that reuses the same work item from rendering superseded text.
5239
+ *
5240
+ * Gated on the structural `_revisionForPrd` ⇄ resolved-PRD-filename relation —
5241
+ * never title text — so a plain regeneration cannot pick up feedback left on a
5242
+ * PRD by an earlier failed revision.
5243
+ *
5244
+ * @param {object|null} workItem plan-to-prd work item being dispatched
5245
+ * @param {object|null} prd PRD the engine resolved for this dispatch
5246
+ * @param {string} prdFile filename of that resolved PRD
5247
+ * @returns {string} feedback text, or '' when this is not a revision dispatch
5248
+ */
5249
+ function resolvePrdRevisionFeedback(workItem, prd, prdFile) {
5250
+ if (!workItem || typeof workItem !== 'object') return '';
5251
+ if (!prd || typeof prd !== 'object' || Array.isArray(prd)) return '';
5252
+ const target = String(workItem._revisionForPrd || '').trim();
5253
+ const resolved = String(prdFile || '').trim();
5254
+ if (!target || target !== resolved) return '';
5255
+ return String(prd.revision_feedback || '').trim();
5256
+ }
5257
+
5258
+ /**
5259
+ * Normalize an imported revision PRD so the completed revision and the stale
5260
+ * recovery controls can never be observed together: status returns to
5261
+ * awaiting-approval, staleness is cleared, the source-plan sync fields are
5262
+ * re-baselined against the live markdown, and the revision pointers are dropped.
5263
+ *
5264
+ * Returns a NEW object so the caller can persist it in a single store write.
5265
+ *
5266
+ * @param {object} prd PRD produced by the revision agent
5267
+ * @param {{plansDir?:string, previous?:object}} [opts]
5268
+ * @returns {object}
5269
+ */
5270
+ function applyPrdRevisionCompletion(prd, { plansDir, previous } = {}) {
5271
+ if (!prd || typeof prd !== 'object' || Array.isArray(prd)) {
5272
+ throw new TypeError('applyPrdRevisionCompletion requires a PRD object');
5273
+ }
5274
+ const next = { ...prd };
5275
+ if (!next.source_plan && previous && previous.source_plan) next.source_plan = previous.source_plan;
5276
+ next.status = PLAN_STATUS.AWAITING_APPROVAL;
5277
+ const now = new Date().toISOString();
5278
+ next.revisionCompletedAt = now;
5279
+ next.lastSyncedFromPlan = now;
5280
+ delete next.planStale;
5281
+ delete next.revision_feedback;
5282
+ delete next.revisionRequestedAt;
5283
+ delete next.revisionRequestedBy;
5284
+ delete next.revisionWorkItemId;
5285
+ // Approval was granted for the pre-revision content — it does not carry over.
5286
+ delete next.approvedAt;
5287
+ delete next.approvedBy;
5288
+ const resolved = next.source_plan ? resolveSourcePlanPath(plansDir, next.source_plan) : null;
5289
+ if (resolved) {
5290
+ const hash = computeSourcePlanContentHash(resolved);
5291
+ if (hash) next._sourcePlanContentHash = hash;
5292
+ try {
5293
+ next.sourcePlanModifiedAt = new Date(Math.floor(fs.statSync(resolved).mtimeMs)).toISOString();
5294
+ } catch { /* markdown may have been archived/removed — keep the recorded baseline */ }
5295
+ }
5296
+ return next;
5297
+ }
5298
+
4616
5299
  const PR_STATUS = { ACTIVE: 'active', MERGED: 'merged', ABANDONED: 'abandoned', CLOSED: 'closed', LINKED: 'linked' };
4617
5300
  // PRs eligible for polling (status/build/comment checks) — excludes terminal statuses
4618
5301
  const PR_POLLABLE_STATUSES = new Set([PR_STATUS.ACTIVE, PR_STATUS.LINKED]);
5302
+
5303
+ // ─── Bounded re-probe of abandoned PRs (W-msav5edk00g81710) ─────────────────
5304
+ //
5305
+ // `abandoned` is the one terminal status a PR can legitimately leave: both
5306
+ // GitHub and ADO allow reopening a closed PR. Filtering the poll loop on
5307
+ // PR_POLLABLE_STATUSES alone made the stamp a one-way door — the reopen handler
5308
+ // in engine/providers/github.js and engine/ado/index.js (P-f5reopenreset) was
5309
+ // unreachable because the record never got polled again, and the only un-flip
5310
+ // path was a boot-only, version-gated reconciliation pass.
5311
+ //
5312
+ // The seam below is the fix: an abandoned record stays pollable for a bounded
5313
+ // window measured from when it was abandoned, so the HOST gets to un-flip it.
5314
+ // Every caller funnels through `isPrPollable` so the two providers cannot drift.
5315
+
5316
+ /** Operator override for the bounded window; honors a literal 0 (disable). */
5317
+ function resolveAbandonedPrRepollWindowMs(config) {
5318
+ const raw = config?.engine?.abandonedPrRepollWindowMs;
5319
+ const n = Number(raw);
5320
+ if (Number.isFinite(n) && n >= 0) return n;
5321
+ return ENGINE_DEFAULTS.abandonedPrRepollWindowMs;
5322
+ }
5323
+
5324
+ /**
5325
+ * Instant (ms) a record entered `abandoned`, or null when unknown.
5326
+ * `_abandonedAt` is stamped at every abandon site; `closedAt` is the fallback
5327
+ * for records stamped before this WI shipped (the 404 trapdoor never set it).
5328
+ */
5329
+ function getPrAbandonedAtMs(pr) {
5330
+ for (const raw of [pr?._abandonedAt, pr?.closedAt]) {
5331
+ if (!raw) continue;
5332
+ const parsed = Date.parse(raw);
5333
+ if (!Number.isNaN(parsed)) return parsed;
5334
+ }
5335
+ return null;
5336
+ }
5337
+
5338
+ /**
5339
+ * Single pollability predicate for both PR pollers.
5340
+ *
5341
+ * @param {object} pr
5342
+ * @param {object} [options]
5343
+ * @param {number} [options.windowMs] — bounded abandoned re-probe window. Defaults
5344
+ * to 0 (strict PR_POLLABLE_STATUSES) so widening is an explicit per-call-site
5345
+ * opt-in: only the STATUS poll needs the reopen signal, never the comment poll.
5346
+ * @param {number} [options.now]
5347
+ */
5348
+ function isPrPollable(pr, options = {}) {
5349
+ if (!pr || typeof pr !== 'object') return false;
5350
+ if (PR_POLLABLE_STATUSES.has(pr.status)) return true;
5351
+ if (pr.status !== PR_STATUS.ABANDONED) return false;
5352
+
5353
+ const windowMs = Number(options.windowMs) || 0;
5354
+ if (!(windowMs > 0)) return false;
5355
+ // Already proven gone at the host — re-probing a deleted PR is pure waste.
5356
+ if (pr._reconciliation404Confirmed) return false;
5357
+
5358
+ const abandonedAt = getPrAbandonedAtMs(pr);
5359
+ // Fail CLOSED: without an anchor the age is unbounded, and an unbounded age
5360
+ // would turn a bounded window into unbounded re-probing of every historical
5361
+ // record. Those are healed by the version-gated startup pass instead.
5362
+ if (abandonedAt === null) return false;
5363
+
5364
+ const now = Number.isFinite(options.now) ? options.now : Date.now();
5365
+ return (now - abandonedAt) <= windowMs;
5366
+ }
5367
+
5368
+ /** Stamp the anchor the re-probe window is measured from. Returns true if changed. */
5369
+ function stampPrAbandonedAt(pr, at) {
5370
+ if (!pr || typeof pr !== 'object') return false;
5371
+ const iso = at || new Date().toISOString();
5372
+ if (pr._abandonedAt === iso) return false;
5373
+ pr._abandonedAt = iso;
5374
+ return true;
5375
+ }
5376
+
5377
+ /** Drop the anchor when a record leaves `abandoned`. Returns true if changed. */
5378
+ function clearPrAbandonedAt(pr) {
5379
+ if (!pr || typeof pr !== 'object') return false;
5380
+ if (!Object.prototype.hasOwnProperty.call(pr, '_abandonedAt')) return false;
5381
+ delete pr._abandonedAt;
5382
+ return true;
5383
+ }
4619
5384
  const PR_PENDING_REASON = {
4620
5385
  MISSING_BRANCH: 'missing_pr_branch',
4621
5386
  };
4622
5387
  // PR build-status enum — single source of truth for the literal strings written to
4623
- // PR `buildStatus`. Previously drifted across engine/ado.js,
4624
- // engine/github.js, engine/lifecycle.js, engine/watches.js, engine/queries.js, engine/cli.js
5388
+ // PR `buildStatus`. Previously drifted across engine/ado/index.js,
5389
+ // engine/providers/github.js, engine/orchestration/lifecycle.js, engine/watches/index.js, engine/core/queries.js, engine/operations/cli.js
4625
5390
  // (P-bfa3d-constants-eslint, audit items #68-#82).
4626
5391
  const BUILD_STATUS = {
4627
5392
  PASSING: 'passing',
@@ -4630,8 +5395,8 @@ const BUILD_STATUS = {
4630
5395
  NONE: 'none',
4631
5396
  };
4632
5397
  // PR review-status enum — single source of truth for the literal strings written to
4633
- // PR `reviewStatus`. Previously drifted across engine/ado.js,
4634
- // engine/github.js, engine/lifecycle.js, engine/watches.js, engine/queries.js, engine/cli.js
5398
+ // PR `reviewStatus`. Previously drifted across engine/ado/index.js,
5399
+ // engine/providers/github.js, engine/orchestration/lifecycle.js, engine/watches/index.js, engine/core/queries.js, engine/operations/cli.js
4635
5400
  // (P-bfa3d-constants-eslint, audit items #68-#82).
4636
5401
  const REVIEW_STATUS = {
4637
5402
  APPROVED: 'approved',
@@ -4644,15 +5409,15 @@ const REVIEW_STATUS = {
4644
5409
  // the migrated sites read self-documentingly and drift is grep-able.
4645
5410
  // (P-bfa3d-constants-eslint, audit items #68-#82).
4646
5411
  const FETCH_TIMEOUT_MS = {
4647
- ADO_API: 4000, // engine/ado.js — ADO REST API single-shot fetches
4648
- GH_CLI: 15000, // engine/github.js, engine/lifecycle.js — `gh pr/api` shell-outs
4649
- GH_COMMENT: 30000, // engine/gh-comment.js — comment/review posts (slower endpoint)
5412
+ ADO_API: 4000, // engine/ado/index.js — ADO REST API single-shot fetches
5413
+ GH_CLI: 15000, // engine/providers/github.js, engine/orchestration/lifecycle.js — `gh pr/api` shell-outs
5414
+ GH_COMMENT: 30000, // engine/providers/gh-comment.js — comment/review posts (slower endpoint)
4650
5415
  };
4651
- // Retry delay between auto-link fallback attempts in engine/lifecycle.js#resolvePrLinkFallback.
5416
+ // Retry delay between auto-link fallback attempts in engine/orchestration/lifecycle.js#resolvePrLinkFallback.
4652
5417
  // Previously a bare `3000` magic number; named so the cadence is grep-able and consistent
4653
5418
  // if other retry paths need the same backoff. (P-bfa3d-constants-eslint).
4654
5419
  const RETRY_DELAY_MS = 3000;
4655
- // Max retries for ADO token-refresh inside engine/ado.js#adoFetch.
5420
+ // Max retries for ADO token-refresh inside engine/ado/index.js#adoFetch.
4656
5421
  // Distinct from ENGINE_DEFAULTS.maxRetries (dispatch-level cap) — this is the
4657
5422
  // per-request token-refresh ceiling and is intentionally separate.
4658
5423
  // (P-bfa3d-constants-eslint).
@@ -4664,7 +5429,7 @@ const WATCH_STATUS = { ACTIVE: 'active', PAUSED: 'paused', TRIGGERED: 'triggered
4664
5429
  // engine/watches.registerTargetType — this object lists the types shipped in
4665
5430
  // the engine for use in switch statements and constants comparisons. The
4666
5431
  // actual allowlist for createWatch/dashboard validation is the registry in
4667
- // engine/watches.js (see getTargetTypes()).
5432
+ // engine/watches/index.js (see getTargetTypes()).
4668
5433
  const WATCH_TARGET_TYPE = {
4669
5434
  PR: 'pr',
4670
5435
  WORK_ITEM: 'work-item',
@@ -4695,14 +5460,14 @@ const WATCH_CONDITION = {
4695
5460
  DISABLED: 'disabled', // schedule disabled
4696
5461
  ACTIVITY_CHANGE: 'activity-change', // agent transitioned status (e.g. idle → working)
4697
5462
  // ── P-w4e2f6a1 — Phase 2.1: PR predicate conditions ──────────────────────
4698
- // See engine/watches.js PR target type for trigger semantics.
5463
+ // See engine/watches/index.js PR target type for trigger semantics.
4699
5464
  HEAD_COMMIT_CHANGE: 'head-commit-change', // PR headRefOid advanced (new push)
4700
5465
  MERGEABLE_FLIPPED: 'mergeable-flipped', // mergeable transitioned between true↔false (NOT involving null)
4701
5466
  READY_FOR_MERGE: 'ready-for-merge', // canonical compound: active+approved+passing+mergeable+!draft
4702
5467
  BEHIND_MASTER: 'behind-master', // pr.behindBy > 0 (treats null/undefined as not-behind)
4703
5468
  DRAFT_FLIPPED: 'draft-flipped', // isDraft transitioned between true↔false
4704
5469
  // ── P-w5b8d2c9 — Phase 2.2: work-item / plan / pipeline predicates ───────
4705
- // See engine/watches.js for trigger semantics. Counters (_unchangedTicks,
5470
+ // See engine/watches/index.js for trigger semantics. Counters (_unchangedTicks,
4706
5471
  // _stuckStageTicks) are computed inside captureState by comparing the
4707
5472
  // freshly-captured snapshot against prevState — see _captureState which
4708
5473
  // now passes prevState as the 2nd arg.
@@ -4740,7 +5505,7 @@ const WATCH_ABSOLUTE_CONDITIONS = new Set([
4740
5505
  WATCH_CONDITION.ITEM_FAILED_N_TIMES,
4741
5506
  ]);
4742
5507
  // Built-in follow-up action types invoked by the engine when a watch fires.
4743
- // The action registry in engine/watch-actions.js is the source of truth; these
5508
+ // The action registry in engine/watches/actions.js is the source of truth; these
4744
5509
  // constants exist so engine/dashboard code can reference action keys without
4745
5510
  // magic strings. New action types can be registered at runtime via
4746
5511
  // registerActionType() — unknown keys here are fine if registered elsewhere.
@@ -4772,10 +5537,10 @@ const WATCH_ACTION_TYPE = {
4772
5537
  */
4773
5538
  function _smallStateMutator({ applyMutation, topic }) {
4774
5539
  return (mutator) => {
4775
- const store = require('./small-state-store');
5540
+ const store = require('../persistence/small-state-store');
4776
5541
  const { wrote, result } = store[applyMutation]((obj) => mutator(obj) || obj);
4777
5542
  if (wrote) {
4778
- try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
5543
+ try { require('../persistence/db-events').emitStateEvent(topic); } catch { /* optional */ }
4779
5544
  }
4780
5545
  return result;
4781
5546
  };
@@ -4808,13 +5573,13 @@ const mutateWorktreePool = _smallStateMutator({
4808
5573
  /** Route QA array mutations through their SQL stores. */
4809
5574
  function _qaMutator({ applyMutation, topic }) {
4810
5575
  return (mutator) => {
4811
- const store = require('./small-state-store');
5576
+ const store = require('../persistence/small-state-store');
4812
5577
  const { wrote, result } = store[applyMutation]((arr) => {
4813
5578
  if (!Array.isArray(arr)) arr = [];
4814
5579
  return mutator(arr) || arr;
4815
5580
  });
4816
5581
  if (wrote) {
4817
- try { require('./db-events').emitStateEvent(topic); } catch { /* optional */ }
5582
+ try { require('../persistence/db-events').emitStateEvent(topic); } catch { /* optional */ }
4818
5583
  }
4819
5584
  return result;
4820
5585
  };
@@ -4837,26 +5602,26 @@ const mutateQaSessions = _qaMutator({
4837
5602
  * diffs by id. SQL-canonical (Phase 9.4); SQLite failures propagate.
4838
5603
  */
4839
5604
  function mutateWatches(mutator) {
4840
- const store = require('./watches-store');
5605
+ const store = require('../watches/store');
4841
5606
  const { wrote, result } = store.applyWatchesMutation((arr) => {
4842
5607
  if (!Array.isArray(arr)) arr = [];
4843
5608
  return mutator(arr) || arr;
4844
5609
  });
4845
5610
  if (wrote) {
4846
- try { require('./db-events').emitStateEvent('watches'); } catch { /* optional */ }
5611
+ try { require('../persistence/db-events').emitStateEvent('watches'); } catch { /* optional */ }
4847
5612
  }
4848
5613
  return result;
4849
5614
  }
4850
5615
 
4851
5616
  /** Route a metrics object mutation through the SQL store. */
4852
5617
  function mutateMetrics(mutator) {
4853
- const store = require('./metrics-store');
5618
+ const store = require('../observability/metrics-store');
4854
5619
  const { wrote, result } = store.applyMetricsMutation((m) => {
4855
5620
  if (!m || typeof m !== 'object') m = {};
4856
5621
  return mutator(m) || m;
4857
5622
  });
4858
5623
  if (wrote) {
4859
- try { require('./db-events').emitStateEvent('metrics'); } catch { /* optional */ }
5624
+ try { require('../persistence/db-events').emitStateEvent('metrics'); } catch { /* optional */ }
4860
5625
  }
4861
5626
  return result;
4862
5627
  }
@@ -5004,6 +5769,7 @@ const FAILURE_CLASS = {
5004
5769
  MERGE_CONFLICT: 'merge-conflict', // Git merge conflict in worktree or dependency
5005
5770
  BUILD_FAILURE: 'build-failure', // Compilation, lint, or test failure
5006
5771
  TIMEOUT: 'timeout', // Hard runtime timeout or stale-orphan timeout
5772
+ ENGINE_RESTART: 'engine-restart', // Dispatch process ownership was lost across an engine restart; use the infrastructure retry budget
5007
5773
  EMPTY_OUTPUT: 'empty-output', // Agent produced no meaningful output
5008
5774
  SPAWN_ERROR: 'spawn-error', // Process failed to start or crashed immediately
5009
5775
  NETWORK_ERROR: 'network-error', // API rate limit, DNS, connectivity
@@ -5031,6 +5797,9 @@ const FAILURE_CLASS = {
5031
5797
  LIVE_CHECKOUT_STALE_BASE: 'live-checkout-stale-base', // W-mr98op8w000ma4ad (live-checkout dispatch mode, STALE-BASE GUARD): spawnAgent was about to fork a NEW branch off the operator's current HEAD while HEAD sits on the project base ref (mainRef), but the LOCAL mainRef tip has DIVERGED from origin/<mainRef> with unpushed commit(s) (`git rev-list --count origin/<mainRef>..<mainRef>` > 0). Forking would silently inherit that COMMITTED contamination into the new branch and its PR — the exact scope-contamination class first seen on ADO PR 5411214 and recurring on AB#12016662 / ADO PR 5419146. Survives the dirty check (contamination is committed, not uncommitted) and cannot be caught by a headBranch !== mainRef check (headBranch IS mainRef here). Detected WITHOUT a fetch (issue #226) via the existing origin/<mainRef> remote-tracking ref. Distinct from LIVE_CHECKOUT_FAILED (transient) because the contaminated local base does NOT change on retry — it reproduces identically until a human reconciles the local base branch. Non-retryable — the engine NEVER resets/cleans the operator's local mainRef; the operator must reconcile it (push/reset/rebase their own way), then re-dispatch. The guard SKIPS (proceeds normally) when HEAD is not on mainRef, when no local origin/<mainRef> ref exists (local-only/no-remote repo), or when the divergence count cannot be computed.
5032
5798
  LIVE_CHECKOUT_WRONG_BASE: 'live-checkout-wrong-base', // W-mr3lunnq000o9f41 (live-checkout dispatch mode): a NEW-branch fork was requested but the operator's checkout HEAD is on an unrelated topic/feature branch, NOT the project base (mainRef), and prepareLiveCheckout could not switch to a LOCAL base ref (no `refs/heads/<mainRef>`, or the local checkout itself failed). Forking off the stale HEAD would silently bake every commit already on that branch into the new branch AND its PR — committed-history scope contamination that survives the dirty/mid-operation preflights (the ADO PR 5411214 incident: ~22 unrelated OCM files, author-disavowed). The engine never fetches origin/<mainRef> in live mode (issue #226) so it cannot self-heal a missing local base. Non-retryable — the operator must `git checkout <mainRef>` in the operator checkout before re-dispatch. Does NOT fire for PR-targeted fixes or shared-branch continuations (those pass allowNonMainBase, intentionally continuing a non-main branch), nor when HEAD is already on mainRef, nor when a local mainRef ref exists (the guard switches to it and forks cleanly). OPT-IN AUTO-RECOVERY (`liveCheckoutAutoBaseRepair`, per-project > fleet-wide, default OFF): when enabled AND a `refs/remotes/origin/<mainRef>` remote-tracking ref exists, the guard materializes a local `<mainRef>` branch from it (a LOCAL ref op — still no `git fetch`, issue #226 preserved; origin/<mainRef> IS the base so no contamination) and forks cleanly instead of failing — eliminating the manual `git checkout <mainRef>` step. Still fails closed when neither a local base nor an origin-tracking base ref exists, or when the base checkout itself fails/hangs.
5033
5799
  INVALID_WORKDIR: 'invalid-workdir', // P-714ef144: dispatch carried a meta.workdir override that failed validation — non-string, absolute path, drive-letter prefix, null byte, ".." segment, or post-resolve containment escape against project.localPath / worktree root. Engine refuses to spawn (the subpath would either be unreachable on disk or point outside the operator's allowed surface). Non-retryable — operator must fix the WI's meta.workdir before re-dispatch. Inbox alert lists the offending value + the resolved-vs-base mismatch.
5800
+ INVALID_EXECUTION_SURFACE: 'invalid-execution-surface', // W-ms5tb6ha016fda10: the dispatch declared `meta.executionSurface` that failed fail-closed validation — an unknown surface string, a non-string value, `control-plane` on a work type that reads/mutates repository files (fix/implement/review/test/verify/decompose/docs/build-fix-complex), or `cloud` on a work type whose deliverable is not a pull request (review/verify/explore/ask/setup/decompose/build-fix-complex). Each surface's opt-in is an ALLOWLIST (shared.CONTROL_PLANE_ELIGIBLE_TYPES / shared.CLOUD_ELIGIBLE_TYPES), never inferred from prose, so a repo-mutating task can never silently lose its checkout and a validation task can never silently be shipped to a remote agent that cannot build it. Non-retryable — the operator must fix or remove the field before re-dispatch.
5801
+ CLOUD_PROVIDER_UNAVAILABLE: 'cloud-provider-unavailable', // Cloud dispatch surface: the work item validly declared `meta.executionSurface: "cloud"` but no usable cloud provider could be resolved — the id is not registered, the provider is registered but reports `isConfigured() === false` for this project (missing credentials, no repo binding, disabled in config), or provider resolution threw. Structural, NOT connectivity: retrying the same dispatch reproduces it identically until an operator configures the provider or removes the surface declaration. Non-retryable. Deliberately distinct from a provider call that reached the service and failed — that is a session-level failure classified by the provider, not this.
5802
+ GIT_REF_LOCK: 'git-ref-lock', // W-ms5tb6ha016fda10: `git fetch` lost a LOCAL ref-lock race on a shared remote-tracking ref (`cannot lock ref 'refs/remotes/origin/<main>': is at <sha> but expected <sha>`) — two dispatches preparing fresh worktrees in the SAME clone at the same time, or an operator/dashboard git command racing the daemon. This is a local serialization defect, NOT connectivity: classifying it as NETWORK_ERROR hid the real cause and burned retries. In-process races are removed at the source by shared.withSerializedRemoteRefRefresh; this class covers the residual cross-process case. Retryable — the ref lock clears in seconds.
5034
5803
  MODEL_UNAVAILABLE: 'model-unavailable', // W-mpg6isvy000xca4d: requested model returned overloaded_error / 503 / service_unavailable. Retriable — engine swaps in the runtime-appropriate fallback model on next spawn (Claude leans on --fallback-model already plumbed; Copilot overrides --model with engine.copilotFallbackModel).
5035
5804
  WORKSPACE_MANIFEST_REPO: 'workspace-manifest-repo-forbidden', // W-mq07avbk000m5543: dispatch routed an agent to a project/repo not present in its workspace_manifest.allowed_repos. Structural — never retryable until the manifest is widened or a different agent is chosen.
5036
5805
  WORKSPACE_MANIFEST_TOOL: 'workspace-manifest-tool-forbidden', // W-mq07avbk000m5543: out-of-scope tool call (manifest enforcement at the runtime gate). Non-retryable as-is.
@@ -5095,6 +5864,44 @@ function resolveTerminalWorkerState({ code, effectiveResult, errorReason } = {})
5095
5864
  };
5096
5865
  }
5097
5866
 
5867
+ // W-ms9tcs3o01y03383 — how a dispatch reached its terminal state. Persisted on
5868
+ // the completed dispatch record so "finished normally", "recovered from a
5869
+ // durable report" and "a live resume genuinely stalled" are distinguishable
5870
+ // after the fact instead of all reading as a plain success/timeout.
5871
+ const DISPATCH_COMPLETION_PATH = {
5872
+ // The tracked process closed and the normal close handler finalized it.
5873
+ PROCESS_CLOSE: 'process-close',
5874
+ // The process had provably exited and a dispatch-owned completion report was
5875
+ // already on disk, so the close handler skipped queued resume bookkeeping and
5876
+ // finalized straight away.
5877
+ REPORT_RECOVERY_IMMEDIATE: 'report-recovery-immediate',
5878
+ // The engine lost/never had the close event and recovered the outcome from
5879
+ // the [process-exit] sentinel in live-output.log (restart reattachment).
5880
+ OUTPUT_DETECTION: 'output-detection',
5881
+ // A still-live resumed runtime blew its first-output heartbeat budget, but a
5882
+ // dispatch-owned report proved the turn had already succeeded.
5883
+ RESUME_STALL_REPORT: 'resume-stall-report',
5884
+ // A still-live resumed runtime blew its heartbeat budget with no report.
5885
+ RESUME_STALL_TIMEOUT: 'resume-stall-timeout',
5886
+ };
5887
+
5888
+ // W-ms9tcs3o01y03383 — the completion-report trust boundary, expressed once.
5889
+ //
5890
+ // A completion report is only evidence about THIS dispatch when it echoes the
5891
+ // per-spawn nonce the engine minted (and carries across steering resumes). This
5892
+ // is the pure half of the check — callers supply the parsed report, so the same
5893
+ // decision is made by the close handler, the resume-stall watchdog, and the
5894
+ // output-detection recovery path without any of them re-deriving the rule.
5895
+ //
5896
+ // Deliberately fails closed: no expected nonce, no report, or a
5897
+ // missing/non-matching `nonce` field all return null.
5898
+ function acceptCompletionReportForNonce(report, expectedNonce) {
5899
+ if (!expectedNonce || typeof expectedNonce !== 'string') return null;
5900
+ if (!isPlainObject(report)) return null;
5901
+ if (typeof report.nonce !== 'string' || report.nonce !== expectedNonce) return null;
5902
+ return report;
5903
+ }
5904
+
5098
5905
  const _dispatchFinalizationOwners = new Map();
5099
5906
  const _dispatchFinalizationWaiters = new Map();
5100
5907
 
@@ -5155,7 +5962,7 @@ const DEFAULT_CLAUDE = {
5155
5962
 
5156
5963
  // Bump rolling-daily strip counter for docs/deprecated.json#prune-default-claude-config
5157
5964
  // removal gate (>=30 consecutive days of zero strips). Best-effort; mirrors
5158
- // engine/cleanup.js:1200-1213 _engine.legacyStatusMigrations.
5965
+ // engine/orchestration/cleanup.js:1200-1213 _engine.legacyStatusMigrations.
5159
5966
  function _bumpPruneDefaultClaudeStrip() {
5160
5967
  try {
5161
5968
  const dateKey = new Date().toISOString().slice(0, 10);
@@ -5662,8 +6469,8 @@ function initializeProjectState(project) {
5662
6469
  projectStateDirEnsure(project);
5663
6470
  // SQL scopes need no empty sentinel rows. Touch both stores so migrations
5664
6471
  // and schema validation still happen at project setup time.
5665
- require('./work-items-store').readWorkItemsForScope(project.name);
5666
- require('./pull-requests-store').readPullRequestsForScope(project.name);
6472
+ require('../planning/work-items-store').readWorkItemsForScope(project.name);
6473
+ require('../persistence/pull-requests-store').readPullRequestsForScope(project.name);
5667
6474
  return result;
5668
6475
  }
5669
6476
 
@@ -5691,6 +6498,62 @@ function realPathForComparison(filePath) {
5691
6498
  }
5692
6499
  }
5693
6500
 
6501
+ /**
6502
+ * Async twin of `realPathForComparison`, for callers that resolve MANY paths on
6503
+ * a latency-sensitive event loop (the dashboard's worktree inventory refresh).
6504
+ * Same missing-path walk, same error contract.
6505
+ *
6506
+ * Consistency rule: resolve every side of a comparison with the SAME twin.
6507
+ * `fs.realpathSync.native` can canonicalize case on a case-insensitive volume
6508
+ * where the promises API does not, so mixing the two could make an
6509
+ * already-resolved path miscompare on macOS.
6510
+ */
6511
+ async function realPathForComparisonAsync(filePath) {
6512
+ const resolved = path.resolve(filePath);
6513
+ try {
6514
+ return await fs.promises.realpath(resolved);
6515
+ } catch (err) {
6516
+ if (!err || (err.code !== 'ENOENT' && err.code !== 'ENOTDIR')) throw err;
6517
+ }
6518
+
6519
+ let existing = resolved;
6520
+ const missingParts = [];
6521
+ while (true) {
6522
+ const parent = path.dirname(existing);
6523
+ missingParts.unshift(path.basename(existing));
6524
+ if (parent === existing) return resolved;
6525
+ existing = parent;
6526
+ try {
6527
+ return path.join(await fs.promises.realpath(existing), ...missingParts);
6528
+ } catch (err) {
6529
+ if (!err || (err.code !== 'ENOENT' && err.code !== 'ENOTDIR')) throw err;
6530
+ }
6531
+ }
6532
+ }
6533
+
6534
+ /**
6535
+ * Run `worker` over `items` with at most `limit` in flight. The engine's
6536
+ * canonical bounded-concurrency mapper — use this instead of adding another
6537
+ * private copy of the promise-pool pattern.
6538
+ */
6539
+ async function mapWithConcurrency(items, limit, worker) {
6540
+ const list = Array.isArray(items) ? items : [];
6541
+ if (list.length === 0) return [];
6542
+ const results = new Array(list.length);
6543
+ let next = 0;
6544
+ const runners = Array.from(
6545
+ { length: Math.max(1, Math.min(Number(limit) || 1, list.length)) },
6546
+ async () => {
6547
+ while (next < list.length) {
6548
+ const index = next++;
6549
+ results[index] = await worker(list[index], index);
6550
+ }
6551
+ },
6552
+ );
6553
+ await Promise.all(runners);
6554
+ return results;
6555
+ }
6556
+
5694
6557
  // ── ID Generation ────────────────────────────────────────────────────────────
5695
6558
 
5696
6559
  function nextWorkItemId(items, prefix) {
@@ -5864,17 +6727,37 @@ function _xmlEscapeForPrompt(s) {
5864
6727
  }
5865
6728
 
5866
6729
  // Render a list of skills as an <available_skills> XML block matching the
5867
- // shape Copilot CLI's default system prompt injects. When `skills` is empty
5868
- // we still emit the block (with a sentinel comment) so the agent learns the
5869
- // path exists rather than silently inferring nothing's there.
5870
- function buildAvailableSkillsBlock(skills) {
5871
- const list = Array.isArray(skills) ? skills : [];
6730
+ // shape Copilot CLI's default system prompt injects. When both lists are empty
6731
+ // we still emit the block (with a sentinel comment naming every source that was
6732
+ // searched user, plugin, and project checkouts) so the agent learns the paths
6733
+ // exist rather than silently inferring nothing's there.
6734
+ //
6735
+ // `skills` are the runtime-native user/plugin skills (from listCopilotSkills).
6736
+ // `projectSkills` (optional) are project-embedded skills discovered under the
6737
+ // configured project checkouts (incl. deeply-nested `.claude/skills` dirs) so a
6738
+ // request like "run my weekly maintenance skill" resolves from the registry
6739
+ // instead of an ad-hoc filesystem grep. User/plugin skills win a name collision
6740
+ // (mirrors listCopilotSkills' user-wins precedence); each entry may carry a
6741
+ // `path` that is rendered as a `<path>` element so the agent can open it directly.
6742
+ function buildAvailableSkillsBlock(skills, projectSkills) {
6743
+ const userList = Array.isArray(skills) ? skills : [];
6744
+ const projList = Array.isArray(projectSkills) ? projectSkills : [];
6745
+ const byName = new Map();
6746
+ for (const s of userList) if (s && s.name && !byName.has(s.name)) byName.set(s.name, s);
6747
+ for (const s of projList) if (s && s.name && !byName.has(s.name)) byName.set(s.name, s);
6748
+ const list = Array.from(byName.values());
5872
6749
  if (list.length === 0) {
5873
- return '<available_skills>\n <!-- No skills found in ~/.copilot/skills/ or ~/.copilot/installed-plugins/<marketplace>/<plugin>/skills/ -->\n</available_skills>';
5874
- }
5875
- const items = list.map(s =>
5876
- `<skill>\n <name>${_xmlEscapeForPrompt(s.name)}</name>\n <description>${_xmlEscapeForPrompt(s.description)}</description>\n <location>${_xmlEscapeForPrompt(s.location)}</location>\n</skill>`
5877
- ).join('\n');
6750
+ return '<available_skills>\n <!-- No skills found in ~/.copilot/skills/, ~/.copilot/installed-plugins/<marketplace>/<plugin>/skills/, or any configured project checkout -->\n</available_skills>';
6751
+ }
6752
+ const items = list.map(s => {
6753
+ const parts = [
6754
+ ` <name>${_xmlEscapeForPrompt(s.name)}</name>`,
6755
+ ` <description>${_xmlEscapeForPrompt(s.description)}</description>`,
6756
+ ` <location>${_xmlEscapeForPrompt(s.location)}</location>`,
6757
+ ];
6758
+ if (s.path) parts.push(` <path>${_xmlEscapeForPrompt(s.path)}</path>`);
6759
+ return `<skill>\n${parts.join('\n')}\n</skill>`;
6760
+ }).join('\n');
5878
6761
  return `<available_skills>\n${items}\n</available_skills>`;
5879
6762
  }
5880
6763
 
@@ -5893,10 +6776,15 @@ function renderCcSystemPrompt(raw, opts) {
5893
6776
  // Available-skills enumeration walks two directory trees and reads each
5894
6777
  // SKILL.md's frontmatter — only do it when the template asks for it.
5895
6778
  // Tests pass `opts.skillsHomeDir` to redirect the scan at a fixture dir.
6779
+ // `opts.projectSkills` (optional) carries project-embedded skills discovered
6780
+ // under configured project checkouts so they appear in the CC registry
6781
+ // alongside user/plugin skills (the caller computes them from the cached
6782
+ // queries skill inventory — this stays off the /api/status hot path).
5896
6783
  if (out.includes('{{available_skills}}')) {
5897
6784
  const skillsOpts = (opts && typeof opts.skillsHomeDir === 'string')
5898
6785
  ? { homeDir: opts.skillsHomeDir } : undefined;
5899
- out = out.replace(/\{\{available_skills\}\}/g, buildAvailableSkillsBlock(listCopilotSkills(skillsOpts)));
6786
+ const projectSkills = (opts && Array.isArray(opts.projectSkills)) ? opts.projectSkills : [];
6787
+ out = out.replace(/\{\{available_skills\}\}/g, buildAvailableSkillsBlock(listCopilotSkills(skillsOpts), projectSkills));
5900
6788
  }
5901
6789
  return out
5902
6790
  .replace(/\{\{cc_turn_id\}\}/g, turnId)
@@ -5950,7 +6838,7 @@ function isLiveCommandCenterPath(absPath, opts) {
5950
6838
  if (rel === '' || rel === '.') return false; // root itself is not a "file"
5951
6839
  const relForMatch = rel.toLowerCase();
5952
6840
  if (_CC_PROTECTED_BASENAMES.includes(relForMatch)) return true;
5953
- if (/^(?:engine|bin)\/[^/]+\.js$/.test(relForMatch)) return true;
6841
+ if (/^engine\/.+\.js$/.test(relForMatch) || /^bin\/[^/]+\.js$/.test(relForMatch)) return true;
5954
6842
  for (const prefix of _CC_PROTECTED_PREFIXES) {
5955
6843
  if (relForMatch === prefix.slice(0, -1) /* exact dir */ || relForMatch.startsWith(prefix)) return true;
5956
6844
  }
@@ -6080,7 +6968,7 @@ function sanitizeBranch(name) {
6080
6968
  // gone from engine.js. PR-targeted dispatches and `shared-branch` plans bypass
6081
6969
  // this helper entirely (they reuse the existing branch).
6082
6970
  //
6083
- // `getOperatorLogin` is a thin shim around `engine/operator-identity` so other
6971
+ // `getOperatorLogin` is a thin shim around `engine/core/operator-identity` so other
6084
6972
  // modules don't need a second require. Required lazily to keep shared.js free
6085
6973
  // of side-effecting child_process imports at module load.
6086
6974
 
@@ -6098,6 +6986,18 @@ function deriveWorkItemBranchName(item, config) {
6098
6986
  // `config` are intentionally unused here but retained for callers that
6099
6987
  // may still inspect them.
6100
6988
  void config;
6989
+ // W-ms5tb6ha016fda10 — a validly-declared control-plane item owns no branch:
6990
+ // it never checks out a repository, so stamping `work/<wi-id>` on it would
6991
+ // both mislead the dashboard and put it in the branch mutex, needlessly
6992
+ // serializing maintenance jobs that share nothing. Fail-closed by
6993
+ // construction (isControlPlaneExecution rejects ineligible types), so a
6994
+ // repo-mutating item still derives its branch exactly as before.
6995
+ if (isControlPlaneExecution(item)) return null;
6996
+ // A cloud-dispatched item owns no LOCAL branch either: the remote agent
6997
+ // creates and pushes its own. Stamping `work/<wi-id>` here would put the
6998
+ // item in the local branch mutex (serializing dispatches that share no
6999
+ // checkout) and would advertise a branch that never exists locally.
7000
+ if (isCloudExecution(item)) return null;
6101
7001
  const wid = String(item?.id || '').trim();
6102
7002
  if (!wid) return null;
6103
7003
  return sanitizeBranch(`work/${wid}`);
@@ -6127,7 +7027,7 @@ function buildWorktreeDirName({
6127
7027
  // so the dir name is a single basename. Without this, `path.join(parent,
6128
7028
  // dirName)` creates `parent/work/W-…/` on Linux for a `work/W-…` branch,
6129
7029
  // and `readdirSync(parent)` returns `work` (not the full name) — breaking
6130
- // engine/worktree-gc.js's globalLiveDirNames lookup and evicting live
7030
+ // engine/worktrees/gc.js's globalLiveDirNames lookup and evicting live
6131
7031
  // worktrees on boot.
6132
7032
  const branchSlug = sanitizeBranch(branchName || 'worktree').replace(/\//g, '-');
6133
7033
  return `${projectSlug}-${branchSlug}-${suffix}`;
@@ -6153,6 +7053,40 @@ function _pathForComparison(filePath) {
6153
7053
  return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
6154
7054
  }
6155
7055
 
7056
+ /**
7057
+ * Public name for the comparison form of a path: fully symlink-resolved and
7058
+ * case-folded on Windows. Callers that must compare many paths off the event
7059
+ * loop resolve once with `pathForComparisonAsync` and then compare with
7060
+ * `isResolvedPathInsideOrEqual`, instead of paying a realpath syscall inside
7061
+ * every containment test.
7062
+ */
7063
+ function pathForComparison(filePath) {
7064
+ return _pathForComparison(filePath);
7065
+ }
7066
+
7067
+ async function pathForComparisonAsync(filePath) {
7068
+ const resolved = await realPathForComparisonAsync(String(filePath));
7069
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
7070
+ }
7071
+
7072
+ /**
7073
+ * The containment rule, over paths that are ALREADY in comparison form.
7074
+ *
7075
+ * This is the single definition of "inside or equal" — `isPathInside` and
7076
+ * `isPathInsideOrEqual` both delegate here after normalizing, so a caller that
7077
+ * pre-resolves paths asynchronously applies exactly the same rule and there is
7078
+ * no second copy to drift.
7079
+ */
7080
+ function isResolvedPathInsideOrEqual(childAbs, parentAbs) {
7081
+ if (!childAbs || !parentAbs) return false;
7082
+ if (childAbs === parentAbs) return true;
7083
+ const rel = path.relative(parentAbs, childAbs);
7084
+ if (rel === '') return false;
7085
+ if (rel.startsWith('..')) return false;
7086
+ if (path.isAbsolute(rel)) return false;
7087
+ return true;
7088
+ }
7089
+
6156
7090
  /**
6157
7091
  * True when `childPath` is strictly nested within `parentPath` (descendant,
6158
7092
  * NOT the same path). Cross-platform via `path.relative`; resilient to mixed
@@ -6163,11 +7097,8 @@ function isPathInside(childPath, parentPath) {
6163
7097
  if (!childPath || !parentPath) return false;
6164
7098
  const childAbs = _pathForComparison(childPath);
6165
7099
  const parentAbs = _pathForComparison(parentPath);
6166
- const rel = path.relative(parentAbs, childAbs);
6167
- if (rel === '') return false;
6168
- if (rel.startsWith('..')) return false;
6169
- if (path.isAbsolute(rel)) return false;
6170
- return true;
7100
+ if (childAbs === parentAbs) return false;
7101
+ return isResolvedPathInsideOrEqual(childAbs, parentAbs);
6171
7102
  }
6172
7103
 
6173
7104
  /**
@@ -6182,10 +7113,10 @@ function isPathInside(childPath, parentPath) {
6182
7113
  */
6183
7114
  function isPathInsideOrEqual(childPath, parentPath) {
6184
7115
  if (!childPath || !parentPath) return false;
6185
- const childAbs = _pathForComparison(childPath);
6186
- const parentAbs = _pathForComparison(parentPath);
6187
- if (childAbs === parentAbs) return true;
6188
- return isPathInside(childAbs, parentAbs);
7116
+ return isResolvedPathInsideOrEqual(
7117
+ _pathForComparison(childPath),
7118
+ _pathForComparison(parentPath),
7119
+ );
6189
7120
  }
6190
7121
 
6191
7122
  function pathsOverlap(firstPath, secondPath) {
@@ -6366,7 +7297,7 @@ const READ_ONLY_ROOT_TASK_TYPES = new Set(['meeting', 'ask', 'explore', 'plan-to
6366
7297
  * NOTE (W-mp8ho6w500034a58): Pipeline branches no longer override this.
6367
7298
  * Read-only pipeline stages don't commit, so a `pipeline/...` branch is a
6368
7299
  * meaningless label for them — the dispatcher short-circuits read-only WIs
6369
- * regardless of branch name, and `engine/pipeline.js` now omits the branch
7300
+ * regardless of branch name, and `engine/orchestration/pipeline.js` now omits the branch
6370
7301
  * field for read-only stages. Only code-mutating pipeline stages need a
6371
7302
  * worktree, and they take the normal code-mutating path below.
6372
7303
  *
@@ -6402,8 +7333,9 @@ const READ_ONLY_ROOT_TASK_TYPES = new Set(['meeting', 'ask', 'explore', 'plan-to
6402
7333
  * @param {{ localPath?: string|null, checkoutMode?: string|null, worktreeMode?: string|null }|null|undefined} project
6403
7334
  * @param {string} type — work type (e.g. 'fix', 'explore', 'meeting')
6404
7335
  * @param {string} minionsDir — MINIONS_DIR fallback anchor (ignored in live mode)
6405
- * @param {{ workdir?: string|null }} [options] — optional per-WI overrides
6406
- * @returns {{ cwd: string|null, worktreeRootDir: string|null, liveMode?: boolean, readOnlyWorktree?: boolean, workdir?: string|null }}
7336
+ * @param {{ workdir?: string|null, executionSurface?: string|null }} [options] — optional per-WI overrides
7337
+ * @returns {{ cwd: string|null, worktreeRootDir: string|null, liveMode?: boolean, readOnlyWorktree?: boolean, controlPlane?: boolean, workdir?: string|null }}
7338
+ * - For control-plane surface (any project): { cwd: <MINIONS_DIR[/workdir]>, worktreeRootDir: null, controlPlane: true }
6407
7339
  * - For live mode (any type): { cwd: <abs localPath[/workdir]>, worktreeRootDir: null, liveMode: true }
6408
7340
  * - For project-bound isolated read-only types:
6409
7341
  * { cwd: null, worktreeRootDir: <project root>, readOnlyWorktree: true, workdir }
@@ -6424,6 +7356,63 @@ function resolveSpawnPaths(project, type, minionsDir, options) {
6424
7356
  ? options.workdir
6425
7357
  : null;
6426
7358
 
7359
+ // ── Cloud short-circuit (Agency / Copilot cloud dispatch) ─────────────
7360
+ // A cloud dispatch allocates NO local checkout at all: the remote agent
7361
+ // owns its own workspace, its own branch, and authors its own PR. The
7362
+ // engine still needs a local cwd for the provider call itself (`az`, `gh`,
7363
+ // or an HTTPS request), so it runs from MINIONS_DIR — the same anchor
7364
+ // control-plane uses.
7365
+ //
7366
+ // `workdir` is deliberately echoed back UNAPPLIED. For a repo surface it is
7367
+ // a subpath of the checkout; here there is no checkout, so resolving it
7368
+ // against MINIONS_DIR would be meaningless (and would fail containment for
7369
+ // no reason). The caller forwards it to the provider as a repo-relative
7370
+ // scope hint for the remote agent.
7371
+ //
7372
+ // Ordered ahead of live-checkout and the read-only / mutating split for the
7373
+ // same reason control-plane is: "does this need a checkout?" must be one
7374
+ // decision in one place. As with control-plane, the caller is responsible
7375
+ // for having validated the surface fail-closed via `validateExecutionSurface`.
7376
+ if (options && typeof options === 'object'
7377
+ && options.executionSurface === EXECUTION_SURFACE.CLOUD) {
7378
+ if (!minionsDir) {
7379
+ const err = new Error('Cannot resolve cwd for cloud spawn: no MINIONS_DIR provided.');
7380
+ err.code = 'WORKTREE_ROOTDIR_MISSING_BASE';
7381
+ throw err;
7382
+ }
7383
+ return {
7384
+ cwd: path.resolve(String(minionsDir)),
7385
+ worktreeRootDir: null,
7386
+ cloud: true,
7387
+ workdir,
7388
+ };
7389
+ }
7390
+
7391
+ // ── Control-plane short-circuit (W-ms5tb6ha016fda10) ───────────────────
7392
+ // Runs BEFORE live-checkout and before the read-only / code-mutating split
7393
+ // so "this task needs no repository checkout" is a single, explicit
7394
+ // decision made in exactly one place. Deliberately ordered ahead of live
7395
+ // mode too: a control-plane job must not enter the operator's checkout, and
7396
+ // must not consume the one-mutating-dispatch-per-project live slot.
7397
+ //
7398
+ // The caller is responsible for having validated the surface (fail-closed)
7399
+ // via `validateExecutionSurface`; this resolver only honors the already-
7400
+ // validated boolean so the eligibility rule cannot fork.
7401
+ if (options && typeof options === 'object'
7402
+ && options.executionSurface === EXECUTION_SURFACE.CONTROL_PLANE) {
7403
+ if (!minionsDir) {
7404
+ const err = new Error('Cannot resolve cwd for control-plane spawn: no MINIONS_DIR provided.');
7405
+ err.code = 'WORKTREE_ROOTDIR_MISSING_BASE';
7406
+ throw err;
7407
+ }
7408
+ const cpBase = path.resolve(String(minionsDir));
7409
+ return {
7410
+ cwd: _applyWorkdirOrThrow(cpBase, workdir),
7411
+ worktreeRootDir: null,
7412
+ controlPlane: true,
7413
+ };
7414
+ }
7415
+
6427
7416
  // ── Live-checkout mode short-circuit (P-a3f9b202) ──────────────────────
6428
7417
  // Runs BEFORE the read-only / code-mutating split so live mode is the
6429
7418
  // single decision point regardless of task type — read-only tasks in
@@ -6784,7 +7773,7 @@ function syncBundledPersonalSkills(skillsDir, opts = {}) {
6784
7773
  if (!skillsDir || !fs.existsSync(skillsDir)) return result;
6785
7774
 
6786
7775
  const homeDir = opts.homeDir || os.homedir();
6787
- const { listRuntimes, resolveRuntime } = require('./runtimes');
7776
+ const { listRuntimes, resolveRuntime } = require('../runtimes');
6788
7777
  const personalRoots = new Set();
6789
7778
  for (const runtimeName of listRuntimes()) {
6790
7779
  const runtime = resolveRuntime(runtimeName);
@@ -7589,10 +8578,10 @@ function findPrRecord(prs, prRef, project = null) {
7589
8578
  // - the link-time "...(polling...)" placeholder
7590
8579
  // - serialized-JSON / agent-output leakage (contains {}"[] chars)
7591
8580
  // - bare hex/uuid-ish ids (>= 8 hex/dash chars)
7592
- // - the fallback WRITER shapes "PR created by <agent>" (engine/lifecycle.js)
7593
- // and bare "PR #<n>" (engine/ado.js central poller)
7594
- // The WRITER (engine/lifecycle.js) and the DETECTORS (engine/github.js,
7595
- // engine/ado.js) MUST agree — route every site through this helper so they
8581
+ // - the fallback WRITER shapes "PR created by <agent>" (engine/orchestration/lifecycle.js)
8582
+ // and bare "PR #<n>" (engine/ado/index.js central poller)
8583
+ // The WRITER (engine/orchestration/lifecycle.js) and the DETECTORS (engine/providers/github.js,
8584
+ // engine/ado/index.js) MUST agree — route every site through this helper so they
7596
8585
  // cannot drift apart again (the github poller previously never backfilled a
7597
8586
  // frozen "PR created by Ripley" title because its detector didn't know the shape).
7598
8587
  function isPlaceholderPrTitle(title) {
@@ -7613,6 +8602,113 @@ function snapshotPrRecord(pr) {
7613
8602
  return JSON.parse(JSON.stringify(pr));
7614
8603
  }
7615
8604
 
8605
+ // ── PR author identity (separate from `pr.agent`) ────────────────────────────
8606
+ //
8607
+ // `pr.agent` = the Minions agent that created/owns the automated work. Used for
8608
+ // routing, self-review prevention, fix ownership, and metrics. The
8609
+ // sentinel `PR_AGENT_HUMAN` ('human') means "no Minions author-agent"
8610
+ // (a human/externally created PR). `agent` is NEVER a human login.
8611
+ // `pr.author` = the real individual on whose behalf the PR exists, linked to an
8612
+ // authoritative provider identity. Structured shape:
8613
+ // { provider, id, nodeId?, descriptor?, login?, displayName?, url? }
8614
+ // Unknown/legacy records leave `author` unset and render "Unknown".
8615
+ //
8616
+ // See docs/pr-author-identity.md.
8617
+ const PR_AGENT_HUMAN = 'human';
8618
+
8619
+ const _PR_AUTHOR_FIELDS = ['provider', 'id', 'nodeId', 'descriptor', 'login', 'displayName', 'url'];
8620
+
8621
+ function _authorField(value, max) {
8622
+ if (value == null) return '';
8623
+ const s = String(value).trim();
8624
+ return s ? s.slice(0, max) : '';
8625
+ }
8626
+
8627
+ // Accept only real http(s) profile links; anything else is dropped so the
8628
+ // dashboard never renders an unvalidated href.
8629
+ function _authorProfileUrl(value) {
8630
+ const s = _authorField(value, 400);
8631
+ if (!s) return '';
8632
+ return /^https?:\/\/[^\s]+$/i.test(s) ? s : '';
8633
+ }
8634
+
8635
+ // Build a structured author identity from a GitHub PR `user` object.
8636
+ function buildGithubAuthorIdentity(user) {
8637
+ if (!user || typeof user !== 'object') return null;
8638
+ const out = { provider: 'github' };
8639
+ const id = _authorField(user.id, 64);
8640
+ const nodeId = _authorField(user.node_id, 128);
8641
+ const login = _authorField(user.login, 120);
8642
+ const displayName = _authorField(user.name, 200);
8643
+ const url = _authorProfileUrl(user.html_url);
8644
+ if (id) out.id = id;
8645
+ if (nodeId) out.nodeId = nodeId;
8646
+ if (login) out.login = login;
8647
+ if (displayName) out.displayName = displayName;
8648
+ if (url) out.url = url;
8649
+ return (out.id || out.nodeId || out.login || out.displayName) ? out : null;
8650
+ }
8651
+
8652
+ // Build a structured author identity from an ADO PR `createdBy` object. ADO's
8653
+ // createdBy.url is an API endpoint (not a browsable profile page), so no url is
8654
+ // surfaced unless a real http(s) profile link is present in _links.
8655
+ function buildAdoAuthorIdentity(createdBy) {
8656
+ if (!createdBy || typeof createdBy !== 'object') return null;
8657
+ const out = { provider: 'ado' };
8658
+ const id = _authorField(createdBy.id, 64);
8659
+ const descriptor = _authorField(createdBy.descriptor, 256);
8660
+ const login = _authorField(createdBy.uniqueName, 200);
8661
+ const displayName = _authorField(createdBy.displayName, 200);
8662
+ const url = _authorProfileUrl(createdBy._links && createdBy._links.web && createdBy._links.web.href);
8663
+ if (id) out.id = id;
8664
+ if (descriptor) out.descriptor = descriptor;
8665
+ if (login) out.login = login;
8666
+ if (displayName) out.displayName = displayName;
8667
+ if (url) out.url = url;
8668
+ return (out.id || out.descriptor || out.login || out.displayName) ? out : null;
8669
+ }
8670
+
8671
+ // Normalize an arbitrary author value (structured object or bare string) into
8672
+ // the canonical structured shape, or null when nothing usable is present.
8673
+ function normalizePrAuthorIdentity(raw) {
8674
+ if (!raw) return null;
8675
+ if (typeof raw === 'string') {
8676
+ const login = raw.trim().slice(0, 200);
8677
+ return login ? { login } : null;
8678
+ }
8679
+ if (typeof raw !== 'object') return null;
8680
+ const out = {};
8681
+ for (const key of _PR_AUTHOR_FIELDS) {
8682
+ const value = key === 'url' ? _authorProfileUrl(raw[key]) : _authorField(raw[key], 256);
8683
+ if (value) out[key] = value;
8684
+ }
8685
+ return Object.keys(out).length ? out : null;
8686
+ }
8687
+
8688
+ // Centralized merge so a partial provider refresh cannot erase a richer author
8689
+ // identity already stored. Field-by-field: a non-empty incoming value wins,
8690
+ // otherwise the existing value is preserved. A provider change trusts the newer
8691
+ // identity wholesale (the person the platform now reports authored the PR).
8692
+ function mergePrAuthorIdentity(existing, incoming) {
8693
+ const cur = normalizePrAuthorIdentity(existing);
8694
+ const inc = normalizePrAuthorIdentity(incoming);
8695
+ if (!inc) return cur;
8696
+ if (!cur) return inc;
8697
+ if (cur.provider && inc.provider && cur.provider !== inc.provider) return inc;
8698
+ const out = { ...cur };
8699
+ for (const key of _PR_AUTHOR_FIELDS) {
8700
+ if (inc[key]) out[key] = inc[key];
8701
+ }
8702
+ return out;
8703
+ }
8704
+
8705
+ // Human-readable label for a PR author (typed SQL column + filter/search text).
8706
+ function prAuthorLabel(author) {
8707
+ const a = normalizePrAuthorIdentity(author);
8708
+ if (!a) return '';
8709
+ return a.displayName || a.login || a.id || '';
8710
+ }
8711
+
7616
8712
  function _jsonEqual(a, b) {
7617
8713
  return JSON.stringify(a) === JSON.stringify(b);
7618
8714
  }
@@ -7684,17 +8780,28 @@ function normalizePrLinkItems(value) {
7684
8780
  return [...new Set(items.filter(item => typeof item === 'string' && item))];
7685
8781
  }
7686
8782
 
7687
- // Canonical `contextOnly` gate reads only the canonical field.
7688
- // All records are expected to carry `contextOnly` after the 2026-06-24
7689
- // on-disk sweep confirmed no live `_contextOnly`/`_autoObserve`/`_manual`
7690
- // keys remain (and the boot migration has been removed).
8783
+ // Canonical context-only flag retained for API/backward compatibility.
7691
8784
  function isContextOnlyPrRecord(pr) {
7692
8785
  if (!pr || typeof pr !== 'object') return false;
7693
8786
  return pr.contextOnly === true;
7694
8787
  }
7695
8788
 
8789
+ function isPrAutoFixEnabled(pr) {
8790
+ if (!pr || typeof pr !== 'object') return false;
8791
+ return typeof pr.autoFixEnabled === 'boolean'
8792
+ ? pr.autoFixEnabled
8793
+ : !isContextOnlyPrRecord(pr);
8794
+ }
8795
+
8796
+ function isPrAutoReviewEnabled(pr) {
8797
+ if (!pr || typeof pr !== 'object') return false;
8798
+ return typeof pr.autoReviewEnabled === 'boolean'
8799
+ ? pr.autoReviewEnabled
8800
+ : !isContextOnlyPrRecord(pr);
8801
+ }
8802
+
7696
8803
  function isAutoManagedPrRecord(pr) {
7697
- return !!pr && typeof pr === 'object' && !isContextOnlyPrRecord(pr);
8804
+ return isPrAutoFixEnabled(pr) || isPrAutoReviewEnabled(pr);
7698
8805
  }
7699
8806
 
7700
8807
  /**
@@ -7717,7 +8824,7 @@ const EXPLICIT_WORK_ITEM_ORIGIN_ROOTS = new Set([
7717
8824
  'dashboard', // dashboard UI + POST /api/work-items, incl. CC/agent API callers
7718
8825
  // (dashboard.js hard-codes createdBy:'dashboard'; also
7719
8826
  // dashboard:execute, dashboard:revision, dashboard:promotion:…)
7720
- 'human', // engine/meeting.js
8827
+ 'human', // engine/orchestration/meeting.js
7721
8828
  'operator',
7722
8829
  'watch', // operator-authored watch rule (watch:<watchId>)
7723
8830
  'scheduler', // operator-authored schedule (scheduler, scheduler:harness)
@@ -7730,7 +8837,7 @@ const EXPLICIT_WORK_ITEM_ORIGIN_ROOTS = new Set([
7730
8837
  * Normalize a work item's `createdBy` to its namespace root so the allowlist
7731
8838
  * above stays short. Creators namespace themselves as `<root>:<detail>`
7732
8839
  * (`dashboard:execute`, `pipeline:<id>`, `watch:<id>`, `scheduler:harness`);
7733
- * `qa-session-<phase>` (engine/qa-sessions.js) uses a hyphen instead.
8840
+ * `qa-session-<phase>` (engine/qa/sessions.js) uses a hyphen instead.
7734
8841
  */
7735
8842
  function workItemOriginRoot(createdBy) {
7736
8843
  const raw = typeof createdBy === 'string' ? createdBy.trim().toLowerCase() : '';
@@ -7798,7 +8905,7 @@ function getPrLinks() {
7798
8905
  // normalization.
7799
8906
  const projectsByName = new Map(getProjects().map(project => [project.name || path.basename(project.localPath || ''), project]));
7800
8907
  try {
7801
- const store = require('./pull-requests-store');
8908
+ const store = require('../persistence/pull-requests-store');
7802
8909
  const allPrs = store.readAllPullRequests() || [];
7803
8910
  for (const pr of allPrs) {
7804
8911
  if (!pr?.id) continue;
@@ -7813,7 +8920,7 @@ function getPrLinks() {
7813
8920
  // Static PR links are stored in SQLite.
7814
8921
  let static_ = null;
7815
8922
  try {
7816
- const store = require('./small-state-store');
8923
+ const store = require('../persistence/small-state-store');
7817
8924
  static_ = store.readPrLinks();
7818
8925
  } catch { static_ = null; }
7819
8926
  if (static_ && typeof static_ === 'object' && !Array.isArray(static_)) {
@@ -7867,9 +8974,9 @@ function addPrLink(prId, itemId, { project = null, url = '', prNumber = null } =
7867
8974
  links[effectivePrId] = [...mergedCurrent];
7868
8975
  return links;
7869
8976
  };
7870
- const { wrote } = require('./small-state-store').applyPrLinksMutation(mutator);
8977
+ const { wrote } = require('../persistence/small-state-store').applyPrLinksMutation(mutator);
7871
8978
  if (wrote) {
7872
- try { require('./db-events').emitStateEvent('pr_links'); } catch { /* optional */ }
8979
+ try { require('../persistence/db-events').emitStateEvent('pr_links'); } catch { /* optional */ }
7873
8980
  }
7874
8981
 
7875
8982
  if (!project) return;
@@ -7895,7 +9002,8 @@ function addPrLink(prId, itemId, { project = null, url = '', prNumber = null } =
7895
9002
  const _DEDUP_MERGE_SCALAR_KEYS = [
7896
9003
  'url', 'title', 'agent', 'branch', 'description', 'sourcePlan', 'itemType',
7897
9004
  'mergedAt', 'closedAt', 'created', 'reviewStatus',
7898
- 'minionsReview', '_automationFixCauses',
9005
+ 'minionsReview', 'contextOnly', 'autoFixEnabled', 'autoReviewEnabled',
9006
+ '_automationFixCauses',
7899
9007
  ];
7900
9008
 
7901
9009
  // Pick the "most complete" record from a list of duplicates.
@@ -7921,6 +9029,10 @@ function _mergeDuplicatePrInto(winner, loser) {
7921
9029
  winner[key] = loser[key];
7922
9030
  }
7923
9031
  }
9032
+ // Author is a structured identity — merge field-by-field so the winner keeps
9033
+ // the richest identity across duplicates rather than an arbitrary one.
9034
+ const mergedAuthor = mergePrAuthorIdentity(winner.author, loser.author);
9035
+ if (mergedAuthor) winner.author = mergedAuthor;
7924
9036
  winner.prdItems = normalizePrLinkItems([...(winner.prdItems || []), ...(loser.prdItems || [])]);
7925
9037
  if (winner.userDeleted === true || loser.userDeleted === true) {
7926
9038
  winner.userDeleted = true;
@@ -7985,7 +9097,7 @@ function _clearPrTombstonesForIdentity(identity) {
7985
9097
  if (!identity) return 0;
7986
9098
  const projectsByName = new Map(getProjects().map(project => [project.name, project]));
7987
9099
  const scopes = new Map();
7988
- const store = require('./pull-requests-store');
9100
+ const store = require('../persistence/pull-requests-store');
7989
9101
  for (const pr of store.readAllPullRequests() || []) {
7990
9102
  if (pr?.userDeleted !== true) continue;
7991
9103
  const project = pr._scope && pr._scope !== 'central'
@@ -8076,6 +9188,11 @@ function upsertPullRequestRecord(source, entry, { project = null, itemId = null,
8076
9188
  if (normalizedEntry.contextOnly != null) {
8077
9189
  normalizedEntry.contextOnly = normalizedEntry.contextOnly === true;
8078
9190
  }
9191
+ // Canonicalize the author identity on the create path (the update path merges
9192
+ // via mergePrAuthorIdentity). A null result is stored as-is → renders Unknown.
9193
+ if (Object.prototype.hasOwnProperty.call(normalizedEntry, 'author')) {
9194
+ normalizedEntry.author = normalizePrAuthorIdentity(normalizedEntry.author);
9195
+ }
8079
9196
 
8080
9197
  let created = false;
8081
9198
  let linked = false;
@@ -8121,7 +9238,7 @@ function upsertPullRequestRecord(source, entry, { project = null, itemId = null,
8121
9238
  // silently clearing the tombstone. Check globally before inserting.
8122
9239
  if (!target && !allowResurrect) {
8123
9240
  try {
8124
- const store = require('./pull-requests-store');
9241
+ const store = require('../persistence/pull-requests-store');
8125
9242
  const projectsByName = new Map(getProjects().map(candidate => [candidate.name, candidate]));
8126
9243
  const globalMatch = (store.readAllPullRequests() || []).find(pr => {
8127
9244
  if (!pr || pr.userDeleted !== true) return false;
@@ -8148,6 +9265,14 @@ function upsertPullRequestRecord(source, entry, { project = null, itemId = null,
8148
9265
  target.id = canonicalId;
8149
9266
  if (prNumber != null) target.prNumber = prNumber;
8150
9267
  const targetWasAutoManaged = isAutoManagedPrRecord(target);
9268
+ // Author identity merges through mergePrAuthorIdentity so a partial
9269
+ // provider refresh (which may omit author entirely) never erases a richer
9270
+ // author already stored. This is intentionally separate from the
9271
+ // fill-if-empty scalar loop below because author is a structured object.
9272
+ if (normalizedEntry.author != null) {
9273
+ const mergedAuthor = mergePrAuthorIdentity(target.author, normalizedEntry.author);
9274
+ if (mergedAuthor) target.author = mergedAuthor;
9275
+ }
8151
9276
  for (const key of ['url', 'title', 'description', 'agent', 'branch', 'reviewStatus', 'status', 'created', 'sourcePlan', 'itemType', 'prdItemId']) {
8152
9277
  if (normalizedEntry[key] != null && normalizedEntry[key] !== '' && (target[key] == null || target[key] === '')) {
8153
9278
  target[key] = normalizedEntry[key];
@@ -8159,6 +9284,14 @@ function upsertPullRequestRecord(source, entry, { project = null, itemId = null,
8159
9284
  if (normalizedEntry.contextOnly != null) {
8160
9285
  const wouldDemoteManagedPr = normalizedEntry.contextOnly === true && targetWasAutoManaged;
8161
9286
  target.contextOnly = wouldDemoteManagedPr ? false : normalizedEntry.contextOnly === true;
9287
+ if (!wouldDemoteManagedPr) {
9288
+ if (typeof normalizedEntry.autoFixEnabled === 'boolean') {
9289
+ target.autoFixEnabled = normalizedEntry.autoFixEnabled;
9290
+ }
9291
+ if (typeof normalizedEntry.autoReviewEnabled === 'boolean') {
9292
+ target.autoReviewEnabled = normalizedEntry.autoReviewEnabled;
9293
+ }
9294
+ }
8162
9295
  }
8163
9296
  }
8164
9297
  target.prdItems = normalizePrLinkItems(target.prdItems || []);
@@ -8293,7 +9426,7 @@ function autoEnrollPrFromWorkItem(item, project, minionsDir) {
8293
9426
  prNumber: prNum,
8294
9427
  title: `PR #${prNum != null ? prNum : '?'} (polling...)`,
8295
9428
  description: '',
8296
- agent: 'human',
9429
+ agent: PR_AGENT_HUMAN,
8297
9430
  branch: '',
8298
9431
  reviewStatus: 'pending',
8299
9432
  status: 'active',
@@ -8314,7 +9447,7 @@ const autoEnrollPrFromFixWorkItem = autoEnrollPrFromWorkItem;
8314
9447
 
8315
9448
  // Cross-platform process-management helpers (kill trees, PID liveness,
8316
9449
  // process-table enumeration, cwd-holder detection, descendant/reachability
8317
- // BFS) were extracted to engine/process-utils.js (P-mrb8yg9x001k1401-b) and
9450
+ // BFS) were extracted to engine/processes/process-utils.js (P-mrb8yg9x001k1401-b) and
8318
9451
  // are re-exported below via `...processUtils`.
8319
9452
 
8320
9453
  // W-mq6f2fe0000557fa — clear the per-path failure cooldown entry so the
@@ -8331,7 +9464,7 @@ function clearWorktreeFailureCache(wtPath) {
8331
9464
 
8332
9465
  // Cross-platform process-management helpers (kill trees, PID liveness,
8333
9466
  // process-table enumeration, cwd-holder detection, descendant/reachability
8334
- // BFS) were extracted to engine/process-utils.js (P-mrb8yg9x001k1401-b) and
9467
+ // BFS) were extracted to engine/processes/process-utils.js (P-mrb8yg9x001k1401-b) and
8335
9468
  // are re-exported below via `...processUtils`.
8336
9469
 
8337
9470
  // ─── Work Items & Pull Requests Mutation Helpers ────────────────────────────
@@ -8352,11 +9485,11 @@ function stateScope(source) {
8352
9485
  }
8353
9486
 
8354
9487
  function readWorkItems(source = null) {
8355
- return require('./work-items-store').readWorkItemsForScope(stateScope(source));
9488
+ return require('../planning/work-items-store').readWorkItemsForScope(stateScope(source));
8356
9489
  }
8357
9490
 
8358
9491
  function mutateWorkItems(source, mutator, opts = {}) {
8359
- const store = require('./work-items-store');
9492
+ const store = require('../planning/work-items-store');
8360
9493
  const scope = stateScope(source);
8361
9494
  const normalizedOpts = { ...opts };
8362
9495
  if (Array.isArray(opts.readScopes)) {
@@ -8367,7 +9500,7 @@ function mutateWorkItems(source, mutator, opts = {}) {
8367
9500
  return mutator(items, comparisonItems) || items;
8368
9501
  }, normalizedOpts);
8369
9502
  if (wrote) {
8370
- try { require('./db-events').emitStateEvent('work_items'); } catch { /* optional */ }
9503
+ try { require('../persistence/db-events').emitStateEvent('work_items'); } catch { /* optional */ }
8371
9504
  try { require('./queries').invalidateWorkItemsCache(); } catch { /* queries not loaded */ }
8372
9505
  }
8373
9506
  return result;
@@ -8387,17 +9520,17 @@ function reopenWorkItem(wi) {
8387
9520
  }
8388
9521
 
8389
9522
  function readPullRequests(source = null) {
8390
- return require('./pull-requests-store').readPullRequestsForScope(stateScope(source));
9523
+ return require('../persistence/pull-requests-store').readPullRequestsForScope(stateScope(source));
8391
9524
  }
8392
9525
 
8393
9526
  function mutatePullRequests(source, mutator) {
8394
- const store = require('./pull-requests-store');
9527
+ const store = require('../persistence/pull-requests-store');
8395
9528
  const { wrote, result } = store.applyPullRequestsMutation(stateScope(source), (prs) => {
8396
9529
  if (!Array.isArray(prs)) prs = [];
8397
9530
  return mutator(prs) || prs;
8398
9531
  });
8399
9532
  if (wrote) {
8400
- try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
9533
+ try { require('../persistence/db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
8401
9534
  }
8402
9535
  return result;
8403
9536
  }
@@ -8525,16 +9658,16 @@ function getPrFixAutomationCause({ dispatchKey = '', source = '', task = '' } =
8525
9658
  }
8526
9659
 
8527
9660
  // Source-branch head SHA, normalized across hosts. GitHub PRs carry
8528
- // `headSha`/`headRefOid` (engine/github.js:718-742 keeps both in sync); ADO PRs
8529
- // carry `_adoSourceCommit`/`headRefOid` (engine/ado.js:1083-1129) and a legacy
8530
- // `_adoHeadCommit`. Mirrors engine/lifecycle.js:1849 getPrFixBaselineHead.
9661
+ // `headSha`/`headRefOid` (engine/providers/github.js:718-742 keeps both in sync); ADO PRs
9662
+ // carry `_adoSourceCommit`/`headRefOid` (engine/ado/index.js:1083-1129) and a legacy
9663
+ // `_adoHeadCommit`. Mirrors engine/orchestration/lifecycle.js:1849 getPrFixBaselineHead.
8531
9664
  function _prHeadSha(pr) {
8532
9665
  return String(pr?.headRefOid || pr?.headSha || pr?._adoSourceCommit || pr?._adoHeadCommit || '').trim();
8533
9666
  }
8534
9667
 
8535
9668
  // Target-branch base SHA, normalized across hosts. GitHub PRs carry `baseSha`
8536
- // (engine/github.js:746-749); ADO PRs carry `_adoTargetCommit`
8537
- // (engine/ado.js:1168-1172, inline in pollPrStatus).
9669
+ // (engine/providers/github.js:746-749); ADO PRs carry `_adoTargetCommit`
9670
+ // (engine/ado/index.js:1168-1172, inline in pollPrStatus).
8538
9671
  function _prBaseSha(pr) {
8539
9672
  return String(pr?.baseSha || pr?._adoTargetCommit || '').trim();
8540
9673
  }
@@ -8769,7 +9902,7 @@ function isWorktreePathLive(worktreePath, opts = {}) {
8769
9902
  const excludeSpawnLeaseId = opts.excludeSpawnLeaseId ? String(opts.excludeSpawnLeaseId) : null;
8770
9903
  let db = opts.db || null;
8771
9904
  if (!db) {
8772
- try { db = require('./db').getDb(); }
9905
+ try { db = require('../db').getDb(); }
8773
9906
  catch (e) {
8774
9907
  log('warn', `isWorktreePathLive: SQL unavailable for ${worktreePath} (${e.message}) — fail-open (assume live)`);
8775
9908
  return true;
@@ -8803,6 +9936,52 @@ function isWorktreePathLive(worktreePath, opts = {}) {
8803
9936
  return false;
8804
9937
  }
8805
9938
 
9939
+ // W-ms9q3dql00vxd4a8 — batched, READ-ONLY sibling of `isWorktreePathLive` for
9940
+ // reporting paths that classify a whole worktree root at once (the worktree
9941
+ // inventory and the dashboard's lifecycle count). The per-path guard runs a
9942
+ // full pending+active scan for EVERY path, which is right for the handful of
9943
+ // destructive sites but is O(directories) queries for a 150-directory root.
9944
+ //
9945
+ // Deliberately NOT used by any wipe site: those keep the per-path guard and its
9946
+ // `excludeDispatchId` / `excludeSpawnLeaseId` semantics. The failure contract
9947
+ // mirrors the guard — `{ ok: false }` means "could not determine", and callers
9948
+ // MUST fail OPEN (treat every path as live) exactly as the guard does.
9949
+ function listLiveWorktreePaths(opts = {}) {
9950
+ let db = opts.db || null;
9951
+ if (!db) {
9952
+ try { db = require('../db').getDb(); }
9953
+ catch (e) {
9954
+ log('warn', `listLiveWorktreePaths: SQL unavailable (${e.message}) — fail-open (assume live)`);
9955
+ return { ok: false, paths: new Set() };
9956
+ }
9957
+ }
9958
+ if (!db) {
9959
+ log('warn', 'listLiveWorktreePaths: no db handle — fail-open (assume live)');
9960
+ return { ok: false, paths: new Set() };
9961
+ }
9962
+ let rows;
9963
+ try {
9964
+ rows = db.prepare(`
9965
+ SELECT json_extract(data, '$.worktreePath') AS top_wt,
9966
+ json_extract(data, '$.meta.worktreePath') AS meta_wt
9967
+ FROM dispatches
9968
+ WHERE status IN ('pending', 'active')
9969
+ `).all();
9970
+ } catch (e) {
9971
+ log('warn', `listLiveWorktreePaths: query threw (${e.message}) — fail-open (assume live)`);
9972
+ return { ok: false, paths: new Set() };
9973
+ }
9974
+ const paths = new Set();
9975
+ for (const row of rows || []) {
9976
+ for (const raw of [row?.top_wt, row?.meta_wt]) {
9977
+ if (!raw) continue;
9978
+ const normalized = _normalizeWorktreePath(raw);
9979
+ if (normalized) paths.add(normalized);
9980
+ }
9981
+ }
9982
+ return { ok: true, paths };
9983
+ }
9984
+
8806
9985
  // P-1d6b1aa7 — separate, best-effort lookup of the specific dispatch row
8807
9986
  // that is blocking a wipe, so callers can fold self-diagnosing detail (id,
8808
9987
  // status, age-in-non-terminal-state) into the skip-live inbox note instead
@@ -8822,7 +10001,7 @@ function getWorktreeBlockingDispatchInfo(worktreePath, opts = {}) {
8822
10001
  const excludeSpawnLeaseId = opts.excludeSpawnLeaseId ? String(opts.excludeSpawnLeaseId) : null;
8823
10002
  let db = opts.db || null;
8824
10003
  if (!db) {
8825
- try { db = require('./db').getDb(); }
10004
+ try { db = require('../db').getDb(); }
8826
10005
  catch { return null; }
8827
10006
  }
8828
10007
  if (!db) return null;
@@ -8906,6 +10085,36 @@ function _writeWorktreeSkipLiveInboxNote(worktreePath, callerTag, blockingInfo)
8906
10085
  } catch { /* best-effort — never throw from the skip-note writer */ }
8907
10086
  }
8908
10087
 
10088
+ // W-ms9q3dql00vxd4a8 — a removal that REPORTS success but leaves the directory
10089
+ // behind is how an install accumulates unclassifiable residue. `git worktree
10090
+ // remove --force`, `fs.rmSync` and `rd /s /q` all clear the CONTENTS first —
10091
+ // including `.git` and the `.minions-worktree` ownership marker — and can then
10092
+ // still fail to unlink the top directory when something holds it open (a
10093
+ // process CWD, AV, Explorer). The husk left behind has no marker, no `gitdir:`
10094
+ // pointer and no registration, so every safe GC classifies it as FOREIGN and
10095
+ // keeps it forever: the observed 107 empty `W-*` directories on this install.
10096
+ //
10097
+ // So verify instead of assuming. When the path survives: finish the job if it
10098
+ // is now empty, otherwise re-stamp ownership so the residue stays reclaimable
10099
+ // by the ordinary orphan sweep, and report the truth to the caller.
10100
+ // Returns true only when the path is genuinely gone.
10101
+ function _verifyWorktreeRemoved(resolved, hadOwnerMarker) {
10102
+ let exists;
10103
+ try { exists = fs.existsSync(resolved); }
10104
+ catch { return true; } // unreadable — nothing further we can safely do
10105
+ if (!exists) return true;
10106
+ try {
10107
+ if (fs.readdirSync(resolved).length === 0) fs.rmdirSync(resolved);
10108
+ } catch { /* still occupied or unreadable — fall through to the re-stamp */ }
10109
+ let survived = true;
10110
+ try { survived = fs.existsSync(resolved); } catch { survived = true; }
10111
+ if (!survived) return true;
10112
+ _restoreWorktreeOwnerMarkerAfterFailedRemoval(resolved, hadOwnerMarker);
10113
+ log('warn', `removeWorktree: removal reported success but ${resolved} still exists — ownership re-stamped so the orphan sweep can reclaim it`);
10114
+ try { bumpWorktreeGcMetric('survivedRemoval'); } catch { /* metric optional */ }
10115
+ return false;
10116
+ }
10117
+
8909
10118
  // P-c7e2b405 — INTENTIONAL divergence from worktree-gc.reapAndRemoveWorktree:
8910
10119
  // this is the low-level `git worktree remove --force` → fs.rmSync → rd /s /q
8911
10120
  // removal primitive WITH its own EBUSY/_retryFsOp backoff loop. The engine-side
@@ -8997,6 +10206,31 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
8997
10206
  const prior = _removeWorktreeFailures.get(resolved);
8998
10207
  if (prior && prior.count >= 3 && Date.now() - prior.lastAttempt < 3600000) return false;
8999
10208
 
10209
+ const _recordRemovalFailure = (message) => {
10210
+ const fail = _removeWorktreeFailures.get(resolved) || { count: 0, lastAttempt: 0 };
10211
+ fail.count++;
10212
+ fail.lastAttempt = Date.now();
10213
+ fail.lastError = message || 'unknown';
10214
+ _removeWorktreeFailures.set(resolved, fail);
10215
+ _pruneRemoveWorktreeFailures();
10216
+ bumpWorktreeGcMetric('totalFailure');
10217
+ if (fail.count <= 3) log('warn', `removeWorktree: failed for ${wtPath} (attempt ${fail.count}/3): ${fail.lastError}`);
10218
+ return false;
10219
+ };
10220
+ // W-ms9q3dql00vxd4a8 — every "success" exit funnels through here so a stage
10221
+ // that cleared the contents but could not unlink the directory is reported as
10222
+ // the failure it is, with ownership re-stamped, instead of leaking an
10223
+ // unclassifiable husk that no GC will ever be allowed to reclaim.
10224
+ const _settleRemoval = (stage, extraMetric) => {
10225
+ if (!_verifyWorktreeRemoved(resolved, hadOwnerMarker)) {
10226
+ return _recordRemovalFailure(`${stage} reported success but the directory survived`);
10227
+ }
10228
+ _removeWorktreeFailures.delete(resolved);
10229
+ bumpWorktreeGcMetric('success');
10230
+ if (extraMetric) bumpWorktreeGcMetric(extraMetric);
10231
+ return true;
10232
+ };
10233
+
9000
10234
  // Windows: purge reserved-name pseudo-files (NUL, CON, etc.) that block normal deletion
9001
10235
  if (process.platform === 'win32') {
9002
10236
  _purgeReservedFiles(resolved);
@@ -9006,9 +10240,7 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
9006
10240
  try {
9007
10241
  // P-1d7a4f80 — argv form (shell:false) so wtPath can never be re-parsed by a shell.
9008
10242
  shellSafeGitSync(['worktree', 'remove', wtPath, '--force'], { cwd: gitRoot, timeout: 15000 });
9009
- _removeWorktreeFailures.delete(resolved);
9010
- bumpWorktreeGcMetric('success');
9011
- return true;
10243
+ return _settleRemoval('git worktree remove --force');
9012
10244
  } catch (gitErr) {
9013
10245
  try {
9014
10246
  // P-b3d9a162 — re-run the real-repo refusal: a lock that released only
@@ -9026,10 +10258,7 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
9026
10258
  `fs.rmSync(${resolved})`
9027
10259
  );
9028
10260
  try { shellSafeGitSync(['worktree', 'prune'], { cwd: gitRoot, timeout: 10000 }); } catch {}
9029
- _removeWorktreeFailures.delete(resolved);
9030
- bumpWorktreeGcMetric('success');
9031
- if (attempt > 1) bumpWorktreeGcMetric('successAfterRetry');
9032
- return true;
10261
+ return _settleRemoval('fs.rmSync', attempt > 1 ? 'successAfterRetry' : null);
9033
10262
  } catch (rmErr) {
9034
10263
  // Windows: try cmd /c rd /s /q for any error — handles reserved device names,
9035
10264
  // locked files, and partially-deleted directories (not just EPERM)
@@ -9040,23 +10269,13 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
9040
10269
  // argv element rather than interpolated into a shell string.
9041
10270
  _execFileSync('cmd', ['/c', 'rd', '/s', '/q', resolved], { stdio: 'pipe', timeout: 15000, windowsHide: true });
9042
10271
  try { shellSafeGitSync(['worktree', 'prune'], { cwd: gitRoot, timeout: 10000 }); } catch {}
9043
- _removeWorktreeFailures.delete(resolved);
9044
- bumpWorktreeGcMetric('success');
9045
- return true;
10272
+ return _settleRemoval('rd /s /q');
9046
10273
  } catch (rdErr) {
9047
10274
  log('warn', `removeWorktree: rd /s /q fallback failed for ${wtPath}: ${rdErr.message}`);
9048
10275
  }
9049
10276
  }
9050
10277
  _restoreWorktreeOwnerMarkerAfterFailedRemoval(resolved, hadOwnerMarker);
9051
- const fail = _removeWorktreeFailures.get(resolved) || { count: 0, lastAttempt: 0 };
9052
- fail.count++;
9053
- fail.lastAttempt = Date.now();
9054
- fail.lastError = rmErr && rmErr.message ? rmErr.message : 'unknown';
9055
- _removeWorktreeFailures.set(resolved, fail);
9056
- _pruneRemoveWorktreeFailures();
9057
- bumpWorktreeGcMetric('totalFailure');
9058
- if (fail.count <= 3) log('warn', `removeWorktree: failed for ${wtPath} (attempt ${fail.count}/3): ${rmErr.message}`);
9059
- return false;
10278
+ return _recordRemovalFailure(rmErr && rmErr.message ? rmErr.message : 'unknown');
9060
10279
  }
9061
10280
  }
9062
10281
  }
@@ -9107,6 +10326,19 @@ function hasWorktreeOwnerMarker(worktreePath) {
9107
10326
  }
9108
10327
  }
9109
10328
 
10329
+ // Async twin of `hasWorktreeOwnerMarker`, for read-only reporting that must not
10330
+ // hold the event loop for one syscall per directory. Identical fail-closed
10331
+ // contract — keep the two in step.
10332
+ async function hasWorktreeOwnerMarkerAsync(worktreePath) {
10333
+ if (!worktreePath) return false;
10334
+ try {
10335
+ await fs.promises.access(path.join(path.resolve(worktreePath), WORKTREE_OWNER_MARKER));
10336
+ return true;
10337
+ } catch {
10338
+ return false;
10339
+ }
10340
+ }
10341
+
9110
10342
  function hasProtectedQuarantineMarker(worktreePath) {
9111
10343
  if (!worktreePath) return false;
9112
10344
  try {
@@ -9125,6 +10357,34 @@ function hasProtectedQuarantineMarker(worktreePath) {
9125
10357
  }
9126
10358
  }
9127
10359
 
10360
+ // Async twin of `hasProtectedQuarantineMarker`, including the fail-SAFE `true`
10361
+ // on error: an unreadable ancestor chain must read as "protected", never as
10362
+ // "free to delete". Keep the two in step — note the sync twin probes with
10363
+ // `fs.existsSync`, which NEVER throws, so an unreadable ancestor reads as
10364
+ // "marker absent" and the walk continues. Matching that exactly is what keeps
10365
+ // the async pre-read from classifying a directory differently than the
10366
+ // synchronous fallback would.
10367
+ async function hasProtectedQuarantineMarkerAsync(worktreePath) {
10368
+ if (!worktreePath) return false;
10369
+ try {
10370
+ let current = path.resolve(worktreePath);
10371
+ while (true) {
10372
+ if (path.basename(current).includes(WORKTREE_QUARANTINE_PROTECTED_PATH_TOKEN)) return true;
10373
+ let markerExists = false;
10374
+ try {
10375
+ await fs.promises.access(path.join(current, WORKTREE_QUARANTINE_PROTECTION_MARKER));
10376
+ markerExists = true;
10377
+ } catch { markerExists = false; }
10378
+ if (markerExists) return true;
10379
+ const parent = path.dirname(current);
10380
+ if (parent === current) return false;
10381
+ current = parent;
10382
+ }
10383
+ } catch {
10384
+ return true;
10385
+ }
10386
+ }
10387
+
9128
10388
  function _restoreWorktreeOwnerMarkerAfterFailedRemoval(worktreePath, hadOwnerMarker) {
9129
10389
  if (!hadOwnerMarker || !worktreePath) return false;
9130
10390
  let exists = false;
@@ -9326,7 +10586,7 @@ function createThrottleTracker({ label, baseBackoffMs = 60000, maxBackoffMs = 32
9326
10586
 
9327
10587
  // ── Backoff Tracker Factory ─────────────────────────────────────────────────
9328
10588
  // Generic graduated-backoff tracker for retryable *acquisition* failures (e.g.
9329
- // ADO token minting via engine/ado-token.js). Distinct from
10589
+ // ADO token minting via engine/ado/token.js). Distinct from
9330
10590
  // createThrottleTracker on three points so callers don't reach for the wrong
9331
10591
  // one: (1) the FIRST failure backs off `baseMs`, not `baseMs*2` — a single
9332
10592
  // transient blip must not blackout for the full cap; (2) recordSuccess RESETS
@@ -9370,7 +10630,7 @@ function createBackoffTracker({ baseMs = 30000, maxMs = 10 * 60 * 1000 } = {}) {
9370
10630
 
9371
10631
  // ── npm package identity (W-ms1j8m54000g4a4a) ───────────────────────────────
9372
10632
  // Minions is published to more than one npm distribution channel: the public
9373
- // `@yemi33/minions` and internal builds renamed by tools/prepare-package.js
10633
+ // `@yemi33/minions` and internal builds renamed by scripts/prepare-package.js
9374
10634
  // (`INTERNAL_PACKAGE_NAME`, default `@yemishin_microsoft/minions`). Anything
9375
10635
  // that shells out to `npm view` / `npm install -g` / `npm uninstall -g` must
9376
10636
  // target the package that is ACTUALLY installed — hardcoding the public name
@@ -9416,6 +10676,37 @@ function resolvePackageName(pkgRoot) {
9416
10676
  return PUBLIC_PACKAGE_NAME;
9417
10677
  }
9418
10678
 
10679
+ // W-ms6b7prf006tef7f-b: install-channel marker. `minions init` records the
10680
+ // runtime's version (`.minions-version`) and source commit (`.minions-commit`);
10681
+ // this marker records WHICH npm distribution produced the runtime root, so an
10682
+ // internal (`@opg-microsoft/minions`) root and a public (`@yemi33/minions`) one
10683
+ // are distinguishable on disk. Written by bin/minions.js#saveInstalledVersion.
10684
+ const RUNTIME_CHANNEL_MARKER = '.minions-package';
10685
+
10686
+ // Read the install-channel marker from a runtime root. Returns
10687
+ // `{ name, version }` or null. A MISSING marker is "unknown", never an error:
10688
+ // every runtime root initialized before this marker existed has none, and a
10689
+ // legacy root must keep working. A marker whose name we would not be willing to
10690
+ // put on an npm command line is treated as unknown for the same reason
10691
+ // resolvePackageName falls back — a channel identity is only useful if it is
10692
+ // safe to act on.
10693
+ //
10694
+ // The name is validated EXACTLY as read, never trimmed first: this value is
10695
+ // compared against a name that gets interpolated into a shell command, so
10696
+ // normalizing junk into something acceptable is the wrong direction. A marker
10697
+ // name with surrounding whitespace is a marker we do not understand.
10698
+ // The version is advisory (it only ever reaches an operator-facing message), so
10699
+ // an unparseable one degrades to null rather than voiding the channel identity.
10700
+ function readRuntimeChannel(runtimeRoot) {
10701
+ if (typeof runtimeRoot !== 'string' || !runtimeRoot) return null;
10702
+ try {
10703
+ const raw = JSON.parse(fs.readFileSync(path.join(runtimeRoot, RUNTIME_CHANNEL_MARKER), 'utf8'));
10704
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
10705
+ if (!isValidNpmPackageName(raw.name)) return null;
10706
+ return { name: raw.name, version: isValidNpmVersion(raw.version) ? raw.version : null };
10707
+ } catch { return null; }
10708
+ }
10709
+
9419
10710
  function assertNpmPackageName(pkgName) {
9420
10711
  if (!isValidNpmPackageName(pkgName)) {
9421
10712
  throw new Error(`Refusing to run npm against an invalid package name: ${JSON.stringify(pkgName)}`);
@@ -9439,9 +10730,138 @@ function buildNpmGlobalUninstallCommand(pkgName) {
9439
10730
  return `npm uninstall -g ${assertNpmPackageName(pkgName)}`;
9440
10731
  }
9441
10732
 
10733
+ // ── Update channel authority (W-ms6b7prf006tef7f-d) ─────────────────────────
10734
+ // `resolvePackageName(PKG_ROOT)` answers "which package would an update
10735
+ // install?" — it does NOT answer "which channel is this runtime on?". Those
10736
+ // diverge in exactly the case that hurts: the opg repo's own package.json is
10737
+ // named `@yemi33/minions`, and a still-public global shim can sit in front of an
10738
+ // internal runtime. Either way PKG_ROOT resolves PUBLIC while the runtime is
10739
+ // INTERNAL, and an unguarded `npm view` → `npm install -g` then drops a stale
10740
+ // public build on top of a newer internal one.
10741
+ //
10742
+ // The RUNTIME ROOT is therefore the channel authority: `minions init` records
10743
+ // the installing distribution in `<runtimeRoot>/.minions-package`, and every
10744
+ // update is gated against it. A root with no marker is a legacy install — it
10745
+ // falls back to the previous behavior with a warning, never a refusal.
10746
+
10747
+ /**
10748
+ * The supported way to move an internal runtime forward. Returned as lines
10749
+ * (never executed here) so the CLI can print them verbatim on a refusal. Every
10750
+ * form pins `--runtime-root` so the installer cannot re-resolve to a different
10751
+ * root than the one we just refused to overwrite.
10752
+ *
10753
+ * The root is always double-quoted: runtime roots routinely live under paths
10754
+ * with spaces (`C:\Users\First Last\.minions`), and an unquoted path would make
10755
+ * the printed command fail as `unknown argument`. `--runtime-root` is a real
10756
+ * flag on all three entry points (`bin/install-internal-minions.js#parseArgs`) —
10757
+ * a refusal must print something the operator can paste and run.
10758
+ *
10759
+ * Each line uses the invocation form that actually RUNS on its platform, and
10760
+ * matches the entry points documented in docs/internal-install.md: POSIX needs
10761
+ * the `./` prefix (a bare `scripts/x.sh` is not resolved from a shell), while
10762
+ * PowerShell takes the native backslash path.
10763
+ */
10764
+ function buildInternalInstallRemediation({ runtimeRoot, packageName } = {}) {
10765
+ const root = typeof runtimeRoot === 'string' && runtimeRoot ? runtimeRoot : '<runtime root>';
10766
+ const pkg = isValidNpmPackageName(packageName) ? packageName : '<internal package>';
10767
+ const args = `--runtime-root "${root}" --package ${pkg}`;
10768
+ return [
10769
+ `Windows (PowerShell): scripts\\install-internal-minions.ps1 ${args}`,
10770
+ `Linux / macOS: ./scripts/install-internal-minions.sh ${args}`,
10771
+ `Any platform: node bin/install-internal-minions.js ${args}`,
10772
+ ];
10773
+ }
10774
+
10775
+ /**
10776
+ * Decide whether `minions update` may install `resolvePackageName(pkgRoot)` on
10777
+ * top of the runtime at `runtimeRoot`. Pure: reads two files, shells out to
10778
+ * nothing, and is safe to call before any npm work.
10779
+ *
10780
+ * Returns `{ pkgName, runtimeName, runtimeVersion, allowed, reason, warning,
10781
+ * remediation }` where `reason` is one of:
10782
+ * - `match` — same channel; proceed silently
10783
+ * - `unknown-runtime-channel` — legacy root, no marker; proceed + warn
10784
+ * - `cross-channel` — channels differ but the target is not
10785
+ * the public package; proceed + warn
10786
+ * - `internal-runtime-public-package` — REFUSE: this is the public downgrade
10787
+ */
10788
+ function resolveUpdateChannel({ pkgRoot, runtimeRoot } = {}) {
10789
+ const pkgName = resolvePackageName(pkgRoot);
10790
+ const runtime = readRuntimeChannel(runtimeRoot);
10791
+ const runtimeName = runtime ? runtime.name : null;
10792
+ const base = { pkgName, runtimeName, runtimeVersion: runtime ? runtime.version : null, remediation: [] };
10793
+
10794
+ if (!runtimeName) {
10795
+ return {
10796
+ ...base,
10797
+ allowed: true,
10798
+ reason: 'unknown-runtime-channel',
10799
+ warning: `Runtime channel is unknown (no ${RUNTIME_CHANNEL_MARKER} marker) — updating as ${pkgName}; re-run \`minions init\` to record the channel.`,
10800
+ };
10801
+ }
10802
+ if (runtimeName === pkgName) {
10803
+ return { ...base, allowed: true, reason: 'match', warning: null };
10804
+ }
10805
+ // The one unrecoverable direction: an internal runtime being overwritten by
10806
+ // the public package. Never attempt it — the public build is a different,
10807
+ // and typically older, distribution of the same `minions` bin.
10808
+ if (runtimeName !== PUBLIC_PACKAGE_NAME && pkgName === PUBLIC_PACKAGE_NAME) {
10809
+ return {
10810
+ ...base,
10811
+ allowed: false,
10812
+ reason: 'internal-runtime-public-package',
10813
+ warning: null,
10814
+ remediation: buildInternalInstallRemediation({ runtimeRoot, packageName: runtimeName }),
10815
+ };
10816
+ }
10817
+ // Any other mismatch (public runtime → internal package, or internal →
10818
+ // a different internal) is a legitimate cutover. Proceed, but say so.
10819
+ return {
10820
+ ...base,
10821
+ allowed: true,
10822
+ reason: 'cross-channel',
10823
+ warning: `Runtime channel is ${runtimeName} but this update would install ${pkgName} — proceeding with a cross-channel install.`,
10824
+ };
10825
+ }
10826
+
10827
+ /**
10828
+ * In-channel downgrade guard. `npm view <pkg> version` can resolve BACKWARDS off
10829
+ * a stale local packument (same root cause as the post-install version-advance
10830
+ * check), which silently reinstalls an older build over a newer one. Refuse
10831
+ * unless the operator opts in.
10832
+ *
10833
+ * Numeric comparison via compareDottedVersions — '1.0.9' must not look newer
10834
+ * than '1.0.76'. An unknown installed version can never produce a refusal.
10835
+ */
10836
+ function evaluateUpdateDowngrade({ installedVersion, targetVersion, allowDowngrade = false } = {}) {
10837
+ const known = (v) => typeof v === 'string' && isValidNpmVersion(v);
10838
+ if (!known(installedVersion) || !known(targetVersion)) {
10839
+ return { allowed: true, isDowngrade: false, message: null };
10840
+ }
10841
+ if (compareDottedVersions(targetVersion, installedVersion) >= 0) {
10842
+ return { allowed: true, isDowngrade: false, message: null };
10843
+ }
10844
+ if (allowDowngrade) {
10845
+ return { allowed: true, isDowngrade: true, message: `Installing ${targetVersion} over ${installedVersion} (downgrade allowed by --allow-downgrade).` };
10846
+ }
10847
+ return {
10848
+ allowed: false,
10849
+ isDowngrade: true,
10850
+ message: [
10851
+ `The registry resolved ${targetVersion}, which is OLDER than the installed ${installedVersion}.`,
10852
+ 'This usually means npm resolved a stale local registry-metadata cache.',
10853
+ 'Try: npm cache clean --force',
10854
+ 'Then: minions update',
10855
+ 'To install the older version anyway: minions update --allow-downgrade',
10856
+ ].join('\n'),
10857
+ };
10858
+ }
10859
+
9442
10860
  module.exports = {
10861
+ ROOT_POINTER_PATH,
9443
10862
  MINIONS_DIR,
9444
10863
  ENGINE_DIR,
10864
+ resolveDefinitionDir,
9445
10865
  CRASH_REPORTS_DIR,
9446
10866
  getEngineCrashDiagnosticsEnv,
9447
10867
  pruneCrashDiagnosticsReports,
@@ -9476,9 +10896,16 @@ module.exports = {
9476
10896
  isValidNpmPackageName,
9477
10897
  isValidNpmVersion,
9478
10898
  resolvePackageName,
10899
+ // Install-channel marker (W-ms6b7prf006tef7f-b)
10900
+ RUNTIME_CHANNEL_MARKER,
10901
+ readRuntimeChannel,
9479
10902
  buildNpmViewVersionCommand,
9480
10903
  buildNpmGlobalInstallCommand,
9481
10904
  buildNpmGlobalUninstallCommand,
10905
+ // Update channel authority (W-ms6b7prf006tef7f-d)
10906
+ buildInternalInstallRemediation,
10907
+ resolveUpdateChannel,
10908
+ evaluateUpdateDowngrade,
9482
10909
  nodeSupportsBuiltinSqlite,
9483
10910
  nodeSqliteRemediationLine,
9484
10911
  log,
@@ -9554,6 +10981,7 @@ module.exports = {
9554
10981
  run,
9555
10982
  runFile,
9556
10983
  cleanChildEnv,
10984
+ applyAgentTestLoadSafetyEnv,
9557
10985
  gitEnv,
9558
10986
  parseStreamJsonOutput,
9559
10987
  isStaleResumeSessionSignal, // W-ms0vk9nv000z985b — stale `--resume` session/cwd detection
@@ -9563,24 +10991,36 @@ module.exports = {
9563
10991
  classifyInboxItem,
9564
10992
  isEngineSystemAlert,
9565
10993
  ENGINE_DEFAULTS,
10994
+ QUARANTINE_REF_RETENTION_DAYS, // W-ms87lj8j018j2572 — single source of truth; quarantine-refs.js re-exports these
10995
+ QUARANTINE_REF_RETENTION_MS,
10996
+ resolveQuarantineRetentionMs, // W-ms87rbe601a66d13 — the one seam both quarantine sweeps read the operator override through
9566
10997
  CODEX_SANDBOX_MODES,
9567
10998
  validateCodexSandbox,
9568
10999
  resolveCodexCcSandbox,
9569
11000
  resolvePollFlag, // P-c4d8e1a3 — granular per-poller flag resolution
9570
11001
  resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentUseWorkerPool, resolveAgentAcpPoolSize, resolveAgentModel, resolveCcModel,
11002
+ resolveAcpHandshakeTimeoutMs, ACP_HANDSHAKE_TIMEOUT_MIN_MS, ACP_HANDSHAKE_TIMEOUT_MAX_MS,
9571
11003
  resolveAgentAllowedTools, resolveAgentMaxBudget, resolveAgentBareMode, resolvePropagateClaudeMdForNonClaudeRuntimes,
9572
11004
  resolveCreatePrsAsDraft,
11005
+ resolveAutoQaCompletedPrds,
9573
11006
  applyLegacyCcModelMigration, _resetLegacyCcModelMigrationFlag,
9574
11007
  runtimeConfigWarnings,
9575
11008
  projectWorkSourceWarnings,
9576
11009
  backfillProjectWorkSourceDefaults,
9577
11010
  WORK_ITEM_PRIORITY, WORK_ITEM_PRIORITY_VALUES, WORK_ITEM_PRIORITY_ERROR_CODE, validateWorkItemPriority,
9578
- WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, isPrdArchived, isDefunctPrd, WORK_TYPE, isFixLikeWorkType, WORKTREE_REQUIRING_TYPES, LIVE_VALIDATION_WORK_TYPES, VALID_WORK_TYPES, resolveWorkItemTypeFromPrdItem, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, BUILD_STATUS, REVIEW_STATUS, FETCH_TIMEOUT_MS, RETRY_DELAY_MS, ADO_TOKEN_REFRESH_MAX_RETRIES, DISPATCH_RESULT, mutateMetrics, mutateWatches, mutateScheduleRuns, mutatePipelineRuns, mutateManagedProcesses, mutateWorktreePool, mutateQaRuns, mutateQaSessions, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject, extractPlanTargetProjects,
11011
+ WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, isPrdArchived, isDefunctPrd, WORK_TYPE, isFixLikeWorkType, WORKTREE_REQUIRING_TYPES, LIVE_VALIDATION_WORK_TYPES, VALID_WORK_TYPES, resolveWorkItemTypeFromPrdItem, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, isPrPollable, resolveAbandonedPrRepollWindowMs, getPrAbandonedAtMs, stampPrAbandonedAt, clearPrAbandonedAt, PR_PENDING_REASON, BUILD_STATUS, REVIEW_STATUS, FETCH_TIMEOUT_MS, RETRY_DELAY_MS, ADO_TOKEN_REFRESH_MAX_RETRIES, DISPATCH_RESULT, mutateMetrics, mutateWatches, mutateScheduleRuns, mutatePipelineRuns, mutateManagedProcesses, mutateWorktreePool, mutateQaRuns, mutateQaSessions, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject, extractPlanTargetProjects,
11012
+ // W-ms5tb6ha016fda10 — explicit, fail-closed control-plane execution surface
11013
+ EXECUTION_SURFACE, EXECUTION_SURFACE_VALUES, CONTROL_PLANE_ELIGIBLE_TYPES, CLOUD_ELIGIBLE_TYPES,
11014
+ isControlPlaneEligibleType, validateExecutionSurface, readDeclaredExecutionSurface, isControlPlaneExecution,
11015
+ isCloudEligibleType, isCloudExecution,
11016
+ // W-ms5tb6ha016fda10 — shared remote-ref refresh serialization + classification
11017
+ isGitRefLockError, withSerializedRemoteRefRefresh, _resetRemoteRefRefreshChainsForTesting,
9579
11018
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS, WATCH_ACTION_TYPE,
9580
11019
  WATCH_STALLED_DEFAULT_TICKS, WATCH_STUCK_STAGE_DEFAULT_TICKS,
9581
11020
  PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
9582
11021
  FAILURE_CLASS, COMPLETION_FIELDS,
9583
11022
  resolveTerminalDispatchResult, resolveTerminalWorkerState,
11023
+ DISPATCH_COMPLETION_PATH, acceptCompletionReportForNonce,
9584
11024
  claimDispatchFinalization, releaseDispatchFinalization, waitForDispatchFinalization,
9585
11025
  DEFAULT_AGENT_METRICS,
9586
11026
  DEFAULT_AGENTS,
@@ -9607,6 +11047,10 @@ module.exports = {
9607
11047
  initializeProjectState,
9608
11048
  sameResolvedPath,
9609
11049
  realPathForComparison, // exported for testing
11050
+ realPathForComparisonAsync,
11051
+ pathForComparison,
11052
+ pathForComparisonAsync,
11053
+ mapWithConcurrency,
9610
11054
  getPrLinks,
9611
11055
  addPrLink,
9612
11056
  normalizePrScopeSegment, // exported for testing
@@ -9637,12 +11081,20 @@ module.exports = {
9637
11081
  findPrRecord,
9638
11082
  isPlaceholderPrTitle,
9639
11083
  snapshotPrRecord,
11084
+ PR_AGENT_HUMAN,
11085
+ buildGithubAuthorIdentity,
11086
+ buildAdoAuthorIdentity,
11087
+ normalizePrAuthorIdentity,
11088
+ mergePrAuthorIdentity,
11089
+ prAuthorLabel,
9640
11090
  applyPrFieldDelta,
9641
11091
  normalizePrRecord,
9642
11092
  normalizePrRecords,
9643
11093
  normalizePrLinkItems, // exported for testing
9644
11094
  mergePrLinkItems, // exported for testing
9645
11095
  isContextOnlyPrRecord,
11096
+ isPrAutoFixEnabled,
11097
+ isPrAutoReviewEnabled,
9646
11098
  shouldAdoptPlatformCreated, // W-mrezh0yb0007f733 — platform creationDate authority
9647
11099
  upsertPullRequestRecord,
9648
11100
  collapseDuplicatePrRecords, // P-e9f0a2b4 — one-time repair helper
@@ -9663,6 +11115,11 @@ module.exports = {
9663
11115
 
9664
11116
  computeSourcePlanContentHash, // W-mqfevwr60018bd09 — sha256 of source plan markdown body
9665
11117
  isSourcePlanContentStale, // W-mqfevwr60018bd09 — gate destructive PRD resync on actual content change
11118
+ PRD_REVISION_STATE, // W-msa0mrus00mh466f — canonical revision-requested ⇄ planStale state machine
11119
+ findPrdRevisionWorkItem,
11120
+ derivePrdRevisionState,
11121
+ resolvePrdRevisionFeedback,
11122
+ applyPrdRevisionCompletion,
9666
11123
  sanitizeBranch,
9667
11124
  getOperatorLogin,
9668
11125
  deriveWorkItemBranchName,
@@ -9671,6 +11128,7 @@ module.exports = {
9671
11128
  isWorktreeRootInfraEntry,
9672
11129
  WORKTREE_SCRATCH_DIR_NAME,
9673
11130
  isPathInside,
11131
+ isResolvedPathInsideOrEqual,
9674
11132
  isPathInsideOrEqual,
9675
11133
  pathsOverlap,
9676
11134
  parseWorktreePorcelain,
@@ -9731,6 +11189,7 @@ module.exports = {
9731
11189
  clearWorktreeFailureCache,
9732
11190
  removeWorktree,
9733
11191
  isWorktreePathLive,
11192
+ listLiveWorktreePaths,
9734
11193
  getWorktreeBlockingDispatchInfo,
9735
11194
  WORKTREE_OWNER_MARKER,
9736
11195
  WORKTREE_RETRY_PATH_MARKER,
@@ -9738,8 +11197,11 @@ module.exports = {
9738
11197
  WORKTREE_QUARANTINE_PROTECTED_PATH_TOKEN,
9739
11198
  writeWorktreeOwnerMarker,
9740
11199
  hasWorktreeOwnerMarker,
11200
+ hasWorktreeOwnerMarkerAsync,
9741
11201
  hasProtectedQuarantineMarker,
11202
+ hasProtectedQuarantineMarkerAsync,
9742
11203
  _restoreWorktreeOwnerMarkerAfterFailedRemoval, // exported for testing
11204
+ _verifyWorktreeRemoved, // exported for testing
9743
11205
  isWorktreeOwnerMarkerStatusLine,
9744
11206
  _normalizeWorktreePath, // exported for testing
9745
11207
  _writeWorktreeSkipLiveInboxNote, // exported for testing
@@ -9765,7 +11227,7 @@ module.exports = {
9765
11227
  backfillPrPrdItems,
9766
11228
  };
9767
11229
 
9768
- // Re-export process-management helpers extracted to engine/process-utils.js
11230
+ // Re-export process-management helpers extracted to engine/processes/process-utils.js
9769
11231
  // (P-mrb8yg9x001k1401-b) so every existing require('./shared') call keeps
9770
11232
  // working unchanged.
9771
11233
  module.exports = { ...module.exports, ...processUtils };