@yemi33/minions 0.1.2447 → 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 +66 -7
  7. package/dashboard/js/detail-panel.js +36 -0
  8. package/dashboard/js/memory-panel.js +59 -12
  9. package/dashboard/js/qa.js +186 -45
  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 +52 -6
  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
@@ -0,0 +1,671 @@
1
+ /**
2
+ * engine/worktrees/inventory.js — worktree lifecycle observability
3
+ * (W-ms5tb6ha016fda10)
4
+ *
5
+ * The engine already has several destructive sweeps, but no read-only answer to
6
+ * the question an operator actually asks when `D:\worktrees` has 142 directories
7
+ * and only 6 registrations: *what are all these, and which ones is the engine
8
+ * ever going to clean up?*
9
+ *
10
+ * This module classifies every directory under each project's configured
11
+ * worktree root, plus every out-of-root git registration, into disjoint buckets:
12
+ *
13
+ * active — a non-terminal dispatch currently claims the path
14
+ * (`shared.isWorktreePathLive`). NEVER removable.
15
+ * pooled — a warm worktree-pool entry.
16
+ * anchored — held by a live keep_processes / managed_spawn cwd.
17
+ * registered — known to `git worktree list` but not claimed above.
18
+ * ownedOrphan — carries the engine ownership marker, unregistered, not
19
+ * live. THIS is the reclaimable backlog.
20
+ * quarantined — a `-quarantine-<ts>` recovery artifact (with the
21
+ * `protected` sub-count for the ones whose WIP has no
22
+ * durable recovery ref).
23
+ * foreignUnmanaged — no ownership marker. The engine must NEVER delete these.
24
+ *
25
+ * The disjoint buckets roll up into a `lifecycle` split (`owned` vs
26
+ * `foreignUnmanaged`) that is the ONLY supported source for any headline
27
+ * "how many worktrees does Minions have?" metric — see
28
+ * `summarizeLifecycleCounts`. A raw filesystem scan cannot answer that question
29
+ * without calling an operator's hand-made scratch checkout an active Minions
30
+ * worktree.
31
+ *
32
+ * It is strictly READ-ONLY: no git mutation, no filesystem writes, no removal.
33
+ * Every probe fails safe — a dir whose ownership cannot be determined counts as
34
+ * `foreignUnmanaged`, and a project whose git registry cannot be read reports an
35
+ * error entry rather than silently reporting zero orphans.
36
+ *
37
+ * Zero dependencies — Node built-ins + engine/core/shared + the sibling worktree
38
+ * modules. Cross-platform: all path comparison goes through the same
39
+ * normalizers the GC uses.
40
+ */
41
+
42
+ const fs = require('fs');
43
+ const path = require('path');
44
+
45
+ const shared = require('../core/shared');
46
+ const worktreePool = require('./pool');
47
+ const quarantineRefs = require('./quarantine-refs');
48
+
49
+ // Bound the async pre-read fan-out the same way the sibling quarantine-ref
50
+ // sweep does: enough parallelism to hide per-syscall latency, few enough
51
+ // handles that a 150-directory root cannot exhaust the pool.
52
+ const WORKTREE_PROBE_CONCURRENCY = 8;
53
+
54
+ function _emptyBuckets() {
55
+ return {
56
+ active: 0,
57
+ pooled: 0,
58
+ anchored: 0,
59
+ registered: 0,
60
+ ownedOrphan: 0,
61
+ quarantined: 0,
62
+ quarantinedProtected: 0,
63
+ foreignUnmanaged: 0,
64
+ total: 0,
65
+ };
66
+ }
67
+
68
+ function _addBuckets(target, source) {
69
+ for (const key of Object.keys(target)) target[key] += source[key] || 0;
70
+ return target;
71
+ }
72
+
73
+ function _isLiveFailOpen(isWorktreePathLive, candidate, liveOpts) {
74
+ // Mirrors engine/worktrees/gc.js: SQL trouble means "assume live" so a
75
+ // reporting glitch can never make a live worktree look reclaimable.
76
+ try { return !!isWorktreePathLive(candidate, liveOpts); }
77
+ catch { return true; }
78
+ }
79
+
80
+ /**
81
+ * W-ms9q3dql00vxd4a8 — the default liveness probe, resolved ONCE per inventory
82
+ * build instead of once per directory. `shared.isWorktreePathLive` re-scans the
83
+ * whole pending+active dispatch set for every path it is asked about, which is
84
+ * the right trade for the handful of destructive wipe sites but costs O(n)
85
+ * queries when classifying a 150-directory worktree root on the dashboard's
86
+ * refresh path.
87
+ *
88
+ * The fail-open contract is identical: an unreadable dispatch table means
89
+ * "assume everything is live", never "assume everything is reclaimable".
90
+ */
91
+ function _makeBatchedLiveProbe(db) {
92
+ let live;
93
+ try { live = shared.listLiveWorktreePaths(db ? { db } : undefined); }
94
+ catch { live = null; }
95
+ if (!live || live.ok !== true) return () => true;
96
+ return (candidate) => {
97
+ try { return live.paths.has(shared._normalizeWorktreePath(candidate)); }
98
+ catch { return true; }
99
+ };
100
+ }
101
+
102
+ function _collectAnchoredCwdPaths(listManagedSpecs, listKeepProcessCwds) {
103
+ const out = [];
104
+ try {
105
+ for (const rec of (listManagedSpecs() || [])) {
106
+ if (!rec || typeof rec.cwd !== 'string' || !rec.cwd) continue;
107
+ out.push(rec.cwd);
108
+ }
109
+ } catch { /* managed-spawn state unavailable */ }
110
+ try {
111
+ for (const cwd of (listKeepProcessCwds() || [])) {
112
+ if (typeof cwd !== 'string' || !cwd) continue;
113
+ out.push(cwd);
114
+ }
115
+ } catch { /* keep-process state unavailable */ }
116
+ return out;
117
+ }
118
+
119
+ function _collectAnchoredCwds(listManagedSpecs, listKeepProcessCwds) {
120
+ const out = [];
121
+ for (const cwd of _collectAnchoredCwdPaths(listManagedSpecs, listKeepProcessCwds)) {
122
+ try { out.push(shared.pathForComparison(cwd)); } catch { /* unreadable cwd */ }
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /**
128
+ * W-ms9q3dql00vxd4a8 — anchors are resolved to their COMPARISON form once per
129
+ * build, not once per candidate.
130
+ *
131
+ * `shared.isPathInsideOrEqual` symlink-resolves BOTH sides on every call, so the
132
+ * old shape cost O(directories x anchors) `realpathSync` syscalls: a single live
133
+ * managed-spawn service turned a 150-directory root into a >100 ms synchronous
134
+ * block on the dashboard's event loop. Comparing pre-resolved paths through the
135
+ * shared containment rule keeps the semantics byte-for-byte and drops the
136
+ * per-candidate cost to one resolution.
137
+ */
138
+ function _isAnchored(candidate, ctx) {
139
+ if (!ctx.anchoredCwds.length) return false;
140
+ let candidateCompare;
141
+ try { candidateCompare = ctx.comparePathFor(candidate); } catch { return false; }
142
+ for (const cwd of ctx.anchoredCwds) {
143
+ try { if (shared.isResolvedPathInsideOrEqual(cwd, candidateCompare)) return true; }
144
+ catch { /* incomparable paths are not anchors */ }
145
+ }
146
+ return false;
147
+ }
148
+
149
+ function _isQuarantineDirName(name) {
150
+ return /-quarantine-(?:protected-)?\d+(?:$|-)/.test(String(name || ''));
151
+ }
152
+
153
+ /**
154
+ * Count dispatches that ran without allocating a repository checkout because
155
+ * they declared the control-plane execution surface. Derived from the dispatch
156
+ * record itself (`executionSurface`), so there is no counter to migrate, reset,
157
+ * or drift out of sync with reality.
158
+ */
159
+ function countAvoidedAllocations(dispatchSnap) {
160
+ const surface = shared.EXECUTION_SURFACE.CONTROL_PLANE;
161
+ let active = 0;
162
+ let completed = 0;
163
+ for (const entry of (Array.isArray(dispatchSnap?.active) ? dispatchSnap.active : [])) {
164
+ if (entry?.executionSurface === surface) active++;
165
+ }
166
+ for (const entry of (Array.isArray(dispatchSnap?.completed) ? dispatchSnap.completed : [])) {
167
+ if (entry?.executionSurface === surface) completed++;
168
+ }
169
+ return { active, completed, total: active + completed };
170
+ }
171
+
172
+ function _classifyDirectory(candidate, ctx) {
173
+ const name = path.basename(candidate);
174
+ const normalized = worktreePool._normalizePath(candidate);
175
+
176
+ if (_isLiveFailOpen(ctx.isWorktreePathLive, candidate, ctx.liveOpts)) return 'active';
177
+ if (ctx.poolPaths.has(normalized)) return 'pooled';
178
+ if (_isAnchored(candidate, ctx)) return 'anchored';
179
+
180
+ let owned = false;
181
+ try { owned = !!ctx.hasWorktreeOwnerMarker(candidate); } catch { owned = false; }
182
+
183
+ if (_isQuarantineDirName(name)) {
184
+ // A quarantine dir is always engine residue: it is a rename of a worktree
185
+ // the engine itself created. Report it as quarantined regardless of whether
186
+ // the marker survived the rename, but never let that reclassify a dir the
187
+ // engine cannot prove it owns — an unmarked one is still reported under
188
+ // `foreignUnmanaged` for the removal decision.
189
+ return owned ? 'quarantined' : 'foreignUnmanaged';
190
+ }
191
+ if (!owned) return 'foreignUnmanaged';
192
+ if (ctx.registeredPaths.has(normalized)) return 'registered';
193
+ return 'ownedOrphan';
194
+ }
195
+
196
+ /**
197
+ * Build the read-only worktree inventory.
198
+ *
199
+ * @param {object} opts
200
+ * @param {Array<{name?:string, localPath?:string}>} opts.projects
201
+ * @param {object} [opts.dispatchSnap] — `{active, pending, completed}` snapshot
202
+ * @param {object} [opts.config] — for `engine.worktreeRoot`
203
+ * @param {Map} [opts.parsedTreesByProject] — pre-read `git worktree list`
204
+ * output keyed on resolved localPath, so an async caller can pay the git
205
+ * shell-out cost off the event loop (same injection shape as
206
+ * engine/orchestration/cleanup.js uses for the GC pruners).
207
+ * @param {Map} [opts.entriesByWorktreeRoot] — pre-read worktree-root
208
+ * directory listings keyed on the resolved worktree root.
209
+ * @param {Map} [opts.probesByPath] — pre-read per-directory filesystem
210
+ * probes (ownership marker, protected-quarantine marker, comparison path)
211
+ * keyed on the normalized directory path. Any path missing from the map (or
212
+ * any field missing from its record) falls back to the real synchronous
213
+ * probe, so a partial pre-read can never change a classification.
214
+ * Injection points (all optional, default to the real implementations) mirror
215
+ * engine/worktrees/gc.js so unit tests can drive this deterministically:
216
+ * fs, execSilent, parseWorktreePorcelain, isWorktreePathLive,
217
+ * hasWorktreeOwnerMarker, hasProtectedQuarantineMarker, listPoolPaths,
218
+ * listManagedSpecs, listKeepProcessCwds, db
219
+ * @returns {{ perProject: object, totals: object, avoidedAllocations: object,
220
+ * recoverableRefs: object, errors: Array }}
221
+ */
222
+ function buildWorktreeInventory(opts = {}) {
223
+ const projects = (Array.isArray(opts.projects) ? opts.projects : [])
224
+ .filter(p => p && p.localPath);
225
+ const _fs = opts.fs || fs;
226
+ const _execSilent = typeof opts.execSilent === 'function' ? opts.execSilent : shared.execSilent;
227
+ const _parsePorcelain = typeof opts.parseWorktreePorcelain === 'function'
228
+ ? opts.parseWorktreePorcelain
229
+ : shared.parseWorktreePorcelain;
230
+ const _parsedTreesByProject = opts.parsedTreesByProject instanceof Map
231
+ ? opts.parsedTreesByProject
232
+ : null;
233
+ const _entriesByWorktreeRoot = opts.entriesByWorktreeRoot instanceof Map
234
+ ? opts.entriesByWorktreeRoot
235
+ : null;
236
+ const _probesByPath = opts.probesByPath instanceof Map ? opts.probesByPath : null;
237
+ const _probeFor = (candidate) => {
238
+ if (!_probesByPath) return null;
239
+ try { return _probesByPath.get(worktreePool._normalizePath(candidate)) || null; }
240
+ catch { return null; }
241
+ };
242
+ const ctx = {
243
+ isWorktreePathLive: typeof opts.isWorktreePathLive === 'function'
244
+ ? opts.isWorktreePathLive
245
+ : _makeBatchedLiveProbe(opts.db),
246
+ // A pre-read probe is used when present; anything the async pre-read could
247
+ // not resolve falls through to the real synchronous helper, so the
248
+ // fail-safe direction of each classification is unchanged.
249
+ hasWorktreeOwnerMarker: typeof opts.hasWorktreeOwnerMarker === 'function'
250
+ ? opts.hasWorktreeOwnerMarker
251
+ : (candidate) => {
252
+ const probe = _probeFor(candidate);
253
+ return typeof probe?.ownerMarker === 'boolean'
254
+ ? probe.ownerMarker
255
+ : shared.hasWorktreeOwnerMarker(candidate);
256
+ },
257
+ comparePathFor: (candidate) => {
258
+ const probe = _probeFor(candidate);
259
+ return probe?.comparePath || shared.pathForComparison(candidate);
260
+ },
261
+ liveOpts: opts.db ? { db: opts.db } : undefined,
262
+ anchoredCwds: Array.isArray(opts.anchoredCwds)
263
+ ? opts.anchoredCwds
264
+ : _collectAnchoredCwds(
265
+ typeof opts.listManagedSpecs === 'function'
266
+ ? opts.listManagedSpecs
267
+ : () => require('../processes/managed-spawn').listManagedSpecs(),
268
+ typeof opts.listKeepProcessCwds === 'function'
269
+ ? opts.listKeepProcessCwds
270
+ : () => require('../processes/keep-process-sweep').getActiveKeepProcessCwds(),
271
+ ),
272
+ poolPaths: new Set(),
273
+ registeredPaths: new Set(),
274
+ };
275
+ const hasProtectedQuarantineMarker = typeof opts.hasProtectedQuarantineMarker === 'function'
276
+ ? opts.hasProtectedQuarantineMarker
277
+ : (candidate) => {
278
+ const probe = _probeFor(candidate);
279
+ return typeof probe?.protectedQuarantineMarker === 'boolean'
280
+ ? probe.protectedQuarantineMarker
281
+ : shared.hasProtectedQuarantineMarker(candidate);
282
+ };
283
+ const listPoolPaths = typeof opts.listPoolPaths === 'function'
284
+ ? opts.listPoolPaths
285
+ : (project) => {
286
+ try {
287
+ return [
288
+ ...(worktreePool.getActiveBorrowedPaths(project) || []),
289
+ ...(worktreePool.getIdlePaths(project) || []),
290
+ ];
291
+ } catch { return []; }
292
+ };
293
+
294
+ const worktreeRootRel = opts.config?.engine?.worktreeRoot || shared.ENGINE_DEFAULTS.worktreeRoot;
295
+ const perProject = {};
296
+ const totals = _emptyBuckets();
297
+ const errors = [];
298
+ // W-ms9q3dql00vxd4a8 — dedupe ACROSS projects, not just within one. Two
299
+ // projects whose `worktreeRoot` resolves to the same directory (the
300
+ // monorepo-style layout the GC already groups by root) otherwise classify
301
+ // every directory twice and double the reported total. Attribution goes to
302
+ // the first project that claims the root; `perProject[label].worktreeRoot`
303
+ // still identifies which root each project resolved to.
304
+ const seen = new Set();
305
+
306
+ for (const project of projects) {
307
+ const label = project.name || project.localPath;
308
+ const rootDir = path.resolve(String(project.localPath));
309
+ const buckets = _emptyBuckets();
310
+
311
+ ctx.poolPaths = new Set(
312
+ (listPoolPaths(project) || [])
313
+ .filter(p => typeof p === 'string' && p)
314
+ .map(p => worktreePool._normalizePath(p)),
315
+ );
316
+
317
+ // Registered paths come from git itself. A read failure is REPORTED, never
318
+ // treated as "nothing is registered" — that inversion would relabel every
319
+ // live worktree as an orphan.
320
+ ctx.registeredPaths = new Set();
321
+ const outOfRoot = [];
322
+ let worktreeRoot;
323
+ try { worktreeRoot = path.resolve(rootDir, worktreeRootRel); }
324
+ catch { worktreeRoot = null; }
325
+ let registryReadable = false;
326
+ try {
327
+ let trees;
328
+ if (_parsedTreesByProject && _parsedTreesByProject.has(rootDir)) {
329
+ trees = _parsedTreesByProject.get(rootDir);
330
+ if (!Array.isArray(trees)) throw new Error('pre-read worktree registry is not an array');
331
+ } else {
332
+ const raw = String(_execSilent('git --no-optional-locks worktree list --porcelain', {
333
+ cwd: rootDir, timeout: 15000, windowsHide: true,
334
+ }) || '');
335
+ trees = _parsePorcelain(raw) || [];
336
+ }
337
+ for (const tree of trees) {
338
+ if (!tree?.path) continue;
339
+ const abs = path.resolve(tree.path);
340
+ if (worktreePool._normalizePath(abs) === worktreePool._normalizePath(rootDir)) continue;
341
+ ctx.registeredPaths.add(worktreePool._normalizePath(abs));
342
+ if (worktreeRoot && !shared.isPathInsideOrEqual(worktreeRoot, abs)) outOfRoot.push(abs);
343
+ }
344
+ registryReadable = true;
345
+ } catch (e) {
346
+ errors.push({ project: label, scope: 'git-registry', error: e?.message || String(e) });
347
+ }
348
+
349
+ let entries = [];
350
+ if (worktreeRoot) {
351
+ const preRead = _entriesByWorktreeRoot ? _entriesByWorktreeRoot.get(worktreeRoot) : null;
352
+ if (Array.isArray(preRead)) {
353
+ entries = preRead;
354
+ } else {
355
+ try {
356
+ entries = _fs.readdirSync(worktreeRoot, { withFileTypes: true })
357
+ .filter(entry => entry.isDirectory() && !shared.isWorktreeRootInfraEntry(entry.name))
358
+ .map(entry => path.join(worktreeRoot, entry.name));
359
+ } catch (e) {
360
+ if (e?.code !== 'ENOENT') {
361
+ errors.push({ project: label, scope: 'worktree-root', error: e?.message || String(e) });
362
+ }
363
+ }
364
+ }
365
+ }
366
+
367
+ const seenBefore = seen.size;
368
+ for (const candidate of [...entries, ...outOfRoot]) {
369
+ const normalized = worktreePool._normalizePath(candidate);
370
+ if (seen.has(normalized)) continue;
371
+ seen.add(normalized);
372
+ // Without a readable registry we cannot distinguish `registered` from
373
+ // `ownedOrphan`, so an owned dir is reported as `registered` (the
374
+ // conservative direction — nothing gets reported as reclaimable).
375
+ let bucket = _classifyDirectory(candidate, ctx);
376
+ if (!registryReadable && bucket === 'ownedOrphan') bucket = 'registered';
377
+ buckets[bucket]++;
378
+ buckets.total++;
379
+ if (bucket === 'quarantined') {
380
+ let isProtected = true;
381
+ try { isProtected = !!hasProtectedQuarantineMarker(candidate); } catch { isProtected = true; }
382
+ if (isProtected) buckets.quarantinedProtected++;
383
+ }
384
+ }
385
+
386
+ perProject[label] = {
387
+ ...buckets,
388
+ worktreeRoot: worktreeRoot || null,
389
+ registryReadable,
390
+ // True when every directory under this project's root was already
391
+ // classified for an earlier project sharing the same root.
392
+ sharedRoot: seen.size === seenBefore && (entries.length + outOfRoot.length) > 0,
393
+ };
394
+ _addBuckets(totals, buckets);
395
+ }
396
+
397
+ return {
398
+ perProject,
399
+ totals,
400
+ lifecycle: _summarizeLifecycle(totals),
401
+ avoidedAllocations: countAvoidedAllocations(opts.dispatchSnap),
402
+ errors,
403
+ };
404
+ }
405
+
406
+ /**
407
+ * W-ms9q3dql00vxd4a8 — the lifecycle-owned split, derived from the SAME buckets
408
+ * the inventory already produces so there is exactly one classifier.
409
+ *
410
+ * `owned` is every directory the engine's own lifecycle created and is
411
+ * therefore responsible for: live dispatches, warm pool members, process
412
+ * anchors, git-registered worktrees, reclaimable owned orphans, and quarantine
413
+ * residue on its retention clock. `foreignUnmanaged` is everything the engine
414
+ * has NO ownership proof for — operator scratch worktrees and unclassifiable
415
+ * residue — which stays fail-open and must never be presented as an active
416
+ * Minions worktree.
417
+ */
418
+ function _summarizeLifecycle(totals) {
419
+ const buckets = totals || _emptyBuckets();
420
+ const total = Number(buckets.total) || 0;
421
+ const foreignUnmanaged = Number(buckets.foreignUnmanaged) || 0;
422
+ return {
423
+ owned: Math.max(0, total - foreignUnmanaged),
424
+ foreignUnmanaged,
425
+ total,
426
+ };
427
+ }
428
+
429
+ function summarizeLifecycleCounts(inventory) {
430
+ return _summarizeLifecycle(inventory?.totals);
431
+ }
432
+
433
+ /**
434
+ * Pre-read `git worktree list --porcelain` for each project WITHOUT blocking
435
+ * the event loop, keyed on the resolved localPath so `buildWorktreeInventory`
436
+ * can skip its synchronous shell-out. A per-project failure is left unset so
437
+ * the sync builder falls back to self-shelling and still reports the error.
438
+ */
439
+ async function readWorktreeRegistriesAsync(opts = {}) {
440
+ const parse = typeof opts.parseWorktreePorcelain === 'function'
441
+ ? opts.parseWorktreePorcelain
442
+ : shared.parseWorktreePorcelain;
443
+ const runGit = typeof opts.shellSafeGit === 'function' ? opts.shellSafeGit : shared.shellSafeGit;
444
+ const map = new Map();
445
+ for (const project of (Array.isArray(opts.projects) ? opts.projects : [])) {
446
+ if (!project?.localPath) continue;
447
+ let rootDir;
448
+ try { rootDir = path.resolve(String(project.localPath)); } catch { continue; }
449
+ if (map.has(rootDir)) continue;
450
+ try {
451
+ const raw = await runGit(['--no-optional-locks', 'worktree', 'list', '--porcelain'], {
452
+ cwd: rootDir, timeout: 15000,
453
+ });
454
+ const trees = parse(String(raw || ''));
455
+ if (Array.isArray(trees)) map.set(rootDir, trees);
456
+ } catch { /* leave unset — the sync builder self-shells and records the error */ }
457
+ }
458
+ return map;
459
+ }
460
+
461
+ /**
462
+ * Pre-read each project's worktree-root directory listing WITHOUT blocking the
463
+ * event loop, keyed on the resolved worktree root. A root that does not exist
464
+ * yet maps to an empty list (the sync builder's ordinary ENOENT case); any
465
+ * OTHER failure is left unset so the sync builder re-reads it and records the
466
+ * error, exactly as it does today.
467
+ */
468
+ async function readWorktreeRootEntriesAsync(opts = {}) {
469
+ // Honor an injected `fs` when it exposes the promises API, so the async
470
+ // pre-read obeys exactly the same injection contract as the sync builder
471
+ // instead of quietly reaching past it to the real filesystem.
472
+ const readdir = typeof opts.fs?.promises?.readdir === 'function'
473
+ ? opts.fs.promises.readdir
474
+ : fs.promises.readdir;
475
+ const worktreeRootRel = opts.config?.engine?.worktreeRoot || shared.ENGINE_DEFAULTS.worktreeRoot;
476
+ const map = new Map();
477
+ for (const project of (Array.isArray(opts.projects) ? opts.projects : [])) {
478
+ if (!project?.localPath) continue;
479
+ let worktreeRoot;
480
+ try { worktreeRoot = path.resolve(path.resolve(String(project.localPath)), worktreeRootRel); }
481
+ catch { continue; }
482
+ if (map.has(worktreeRoot)) continue;
483
+ try {
484
+ const dirents = await readdir(worktreeRoot, { withFileTypes: true });
485
+ map.set(worktreeRoot, dirents
486
+ .filter(entry => entry.isDirectory() && !shared.isWorktreeRootInfraEntry(entry.name))
487
+ .map(entry => path.join(worktreeRoot, entry.name)));
488
+ } catch (e) {
489
+ if (e?.code === 'ENOENT') map.set(worktreeRoot, []);
490
+ }
491
+ }
492
+ return map;
493
+ }
494
+
495
+ /**
496
+ * Every directory the classifier could ask about: a SUPERSET is deliberate and
497
+ * safe — an unused probe costs one map entry, while a missing one silently
498
+ * falls back to a synchronous syscall. Building the superset from the two
499
+ * pre-reads avoids re-deriving the builder's in-root/out-of-root split here.
500
+ */
501
+ function _probeCandidates(entriesByWorktreeRoot, parsedTreesByProject) {
502
+ const out = new Set();
503
+ for (const entries of entriesByWorktreeRoot.values()) {
504
+ for (const entry of (Array.isArray(entries) ? entries : [])) out.add(entry);
505
+ }
506
+ for (const trees of parsedTreesByProject.values()) {
507
+ for (const tree of (Array.isArray(trees) ? trees : [])) {
508
+ if (!tree?.path) continue;
509
+ try { out.add(path.resolve(tree.path)); } catch { /* unusable registry path */ }
510
+ }
511
+ }
512
+ return [...out];
513
+ }
514
+
515
+ /**
516
+ * Pre-read the per-directory filesystem probes the classifier needs — the
517
+ * ownership marker, the protected-quarantine marker, and the symlink-resolved
518
+ * comparison path — with bounded concurrency and no synchronous syscalls.
519
+ *
520
+ * A probe that fails is simply not recorded, so the synchronous classifier
521
+ * re-probes that one path and applies its own fail-safe. The pre-read can
522
+ * therefore only make the scan cheaper, never change what it reports.
523
+ */
524
+ async function readWorktreeProbesAsync(candidates, opts = {}) {
525
+ const ownerMarkerProbe = typeof opts.hasWorktreeOwnerMarkerAsync === 'function'
526
+ ? opts.hasWorktreeOwnerMarkerAsync
527
+ : shared.hasWorktreeOwnerMarkerAsync;
528
+ const protectedProbe = typeof opts.hasProtectedQuarantineMarkerAsync === 'function'
529
+ ? opts.hasProtectedQuarantineMarkerAsync
530
+ : shared.hasProtectedQuarantineMarkerAsync;
531
+ const resolveCompare = typeof opts.pathForComparisonAsync === 'function'
532
+ ? opts.pathForComparisonAsync
533
+ : shared.pathForComparisonAsync;
534
+
535
+ const list = [...new Set((Array.isArray(candidates) ? candidates : [])
536
+ .filter(candidate => typeof candidate === 'string' && candidate))];
537
+ const map = new Map();
538
+ await shared.mapWithConcurrency(list, WORKTREE_PROBE_CONCURRENCY, async (candidate) => {
539
+ const probe = {};
540
+ let usable = false;
541
+ try { probe.ownerMarker = !!(await ownerMarkerProbe(candidate)); usable = true; }
542
+ catch { /* fall back to the synchronous probe for this path */ }
543
+ try { probe.comparePath = await resolveCompare(candidate); usable = true; }
544
+ catch { /* ditto */ }
545
+ if (_isQuarantineDirName(path.basename(candidate))) {
546
+ try { probe.protectedQuarantineMarker = !!(await protectedProbe(candidate)); usable = true; }
547
+ catch { /* ditto */ }
548
+ }
549
+ if (!usable) return;
550
+ try { map.set(worktreePool._normalizePath(candidate), probe); } catch { /* unusable key */ }
551
+ });
552
+ return map;
553
+ }
554
+
555
+ /**
556
+ * Resolve the anchored `keep_processes` / `managed_spawn` cwds to their
557
+ * comparison form off the event loop. Resolved with the SAME async twin the
558
+ * candidate paths use, so both sides of every containment test agree.
559
+ *
560
+ * A cwd the async resolver cannot handle falls back to the synchronous helper
561
+ * rather than being dropped: losing an anchor silently downgrades a HELD
562
+ * worktree to `ownedOrphan`, which is the reclaimable bucket. Only a path that
563
+ * neither resolver can read is dropped — the pre-existing behaviour.
564
+ */
565
+ async function _readAnchoredCwdsAsync(opts = {}) {
566
+ const resolveCompare = typeof opts.pathForComparisonAsync === 'function'
567
+ ? opts.pathForComparisonAsync
568
+ : shared.pathForComparisonAsync;
569
+ const raw = _collectAnchoredCwdPaths(
570
+ typeof opts.listManagedSpecs === 'function'
571
+ ? opts.listManagedSpecs
572
+ : () => require('../processes/managed-spawn').listManagedSpecs(),
573
+ typeof opts.listKeepProcessCwds === 'function'
574
+ ? opts.listKeepProcessCwds
575
+ : () => require('../processes/keep-process-sweep').getActiveKeepProcessCwds(),
576
+ );
577
+ const out = [];
578
+ for (const cwd of raw) {
579
+ let resolved = null;
580
+ try { resolved = await resolveCompare(cwd); }
581
+ catch { /* fall through to the synchronous resolver */ }
582
+ if (!resolved) {
583
+ try { resolved = shared.pathForComparison(cwd); } catch { /* unreadable cwd */ }
584
+ }
585
+ if (resolved) out.push(resolved);
586
+ }
587
+ return out;
588
+ }
589
+
590
+ /**
591
+ * Async front door for callers that must not block the event loop (the
592
+ * dashboard's background worktree-count refresh and `GET
593
+ * /api/worktree-inventory`). Every filesystem and git cost is paid
594
+ * asynchronously up front; the SAME synchronous classifier then runs over the
595
+ * pre-read results, so the count and the inventory page can never disagree.
596
+ *
597
+ * W-ms9q3dql00vxd4a8 — the pre-reads are what keep the invariant honest.
598
+ * Classification itself is a tight loop over N directories, so leaving its
599
+ * probes synchronous made the whole scan one contiguous blocking block whose
600
+ * cost scales with the `foreignUnmanaged` residue the engine deliberately never
601
+ * reclaims. Measured on a 150-directory root with three live process anchors:
602
+ * >100 ms of event-loop stall every `worktreeCountCacheTtl`, against the 100 ms
603
+ * budget /api/status has to stay inside.
604
+ */
605
+ async function buildWorktreeInventoryAsync(opts = {}) {
606
+ const parsedTreesByProject = opts.parsedTreesByProject instanceof Map
607
+ ? opts.parsedTreesByProject
608
+ : await readWorktreeRegistriesAsync(opts);
609
+ const entriesByWorktreeRoot = opts.entriesByWorktreeRoot instanceof Map
610
+ ? opts.entriesByWorktreeRoot
611
+ : await readWorktreeRootEntriesAsync(opts);
612
+ const anchoredCwds = Array.isArray(opts.anchoredCwds)
613
+ ? opts.anchoredCwds
614
+ : await _readAnchoredCwdsAsync(opts);
615
+ const probesByPath = opts.probesByPath instanceof Map
616
+ ? opts.probesByPath
617
+ : await readWorktreeProbesAsync(
618
+ _probeCandidates(entriesByWorktreeRoot, parsedTreesByProject),
619
+ opts,
620
+ );
621
+ return buildWorktreeInventory({
622
+ ...opts,
623
+ parsedTreesByProject,
624
+ entriesByWorktreeRoot,
625
+ anchoredCwds,
626
+ probesByPath,
627
+ });
628
+ }
629
+
630
+ /**
631
+ * Async wrapper that also resolves the recoverable quarantine refs (a git
632
+ * shell-out per project). Kept separate so the pure classification above stays
633
+ * synchronous and directly unit-testable.
634
+ */
635
+ async function buildWorktreeInventoryWithRefs(opts = {}) {
636
+ const inventory = await buildWorktreeInventoryAsync(opts);
637
+ let refs = [];
638
+ try {
639
+ refs = await (typeof opts.listQuarantineRefs === 'function'
640
+ ? opts.listQuarantineRefs(opts.projects || [])
641
+ : quarantineRefs.listQuarantineRefs(opts.projects || []));
642
+ } catch (e) {
643
+ inventory.errors.push({ scope: 'quarantine-refs', error: e?.message || String(e) });
644
+ }
645
+ const items = Array.isArray(refs) ? refs : [];
646
+ inventory.recoverableRefs = {
647
+ total: items.filter(item => item && !item.error).length,
648
+ wip: items.filter(item => item && item.kind === 'wip').length,
649
+ commits: items.filter(item => item && item.kind === 'commits').length,
650
+ // W-ms87rbe601a66d13 — report the window actually in effect (operator
651
+ // override included), not the built-in constant, so the inventory does not
652
+ // advertise a 30-day recovery story on an install that shortened it.
653
+ retentionMs: shared.resolveQuarantineRetentionMs(opts.config),
654
+ };
655
+ for (const item of items) {
656
+ if (item && item.error) inventory.errors.push({ project: item.project, scope: 'quarantine-refs', error: item.error });
657
+ }
658
+ return inventory;
659
+ }
660
+
661
+ module.exports = {
662
+ buildWorktreeInventory,
663
+ buildWorktreeInventoryAsync,
664
+ buildWorktreeInventoryWithRefs,
665
+ readWorktreeRegistriesAsync,
666
+ readWorktreeRootEntriesAsync,
667
+ readWorktreeProbesAsync,
668
+ summarizeLifecycleCounts,
669
+ countAvoidedAllocations,
670
+ _isQuarantineDirName, // exported for testing
671
+ };