@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
@@ -31,7 +31,7 @@
31
31
  * Concurrency: every mutation goes through shared.mutateQaSessions, which
32
32
  * routes to small-state-store.applyQaSessionsMutation inside a SQLite
33
33
  * transaction (BEGIN IMMEDIATE). Callbacks are synchronous and never await. Slow filesystem work
34
- * (qa-tests/<id>/ scaffolding, dispatch enqueueing) runs OUTSIDE the lock.
34
+ * (qa-tests/<id>/ scaffolding, engine wakeup requests) runs OUTSIDE the lock.
35
35
  *
36
36
  * Path-traversal hardening: sessionId is generated by createSession() with a
37
37
  * uid suffix, but the module still treats every callsite as untrusted —
@@ -40,16 +40,18 @@
40
40
  * _isSafeId (PR #2694 review feedback).
41
41
  *
42
42
  * Persistence: SQLite (engine/state.db, `qa_sessions` table). Capped at
43
- * QA_SESSIONS_MAX_RECORDS via createSession-time rotation.
43
+ * QA_SESSIONS_MAX_RECORDS via createSession-time rotation. Records otherwise
44
+ * leave only through deleteSession(), which is terminal-only (W-msb4vgj500zbb008).
44
45
  */
45
46
 
46
47
  const fs = require('fs');
47
48
  const path = require('path');
48
- const shared = require('./shared');
49
+ const shared = require('../core/shared');
50
+ const visualJourney = require('./visual-journey');
49
51
  const { mutateQaSessions, uid, ts, log } = shared;
50
52
 
51
53
  function _readSessions() {
52
- return require('./small-state-store').readQaSessions();
54
+ return require('../persistence/small-state-store').readQaSessions();
53
55
  }
54
56
 
55
57
  // Sessions cost more than runs (3 WIs, a
@@ -247,6 +249,70 @@ function _validateCapture(capture) {
247
249
  return errors;
248
250
  }
249
251
 
252
+ /**
253
+ * The canonical project list a spec declares, in declaration order.
254
+ *
255
+ * `spec.projects` (multi) wins over the legacy single-string `spec.project`,
256
+ * matching createSession's normalization. Returns `[]` for a central session.
257
+ * Shared by validateSpec and createSession so "which projects does this spec
258
+ * name?" cannot drift between validation and persistence.
259
+ */
260
+ function _specProjects(spec) {
261
+ if (Array.isArray(spec && spec.projects) && spec.projects.length > 0) {
262
+ return spec.projects.filter(_isNonEmptyString);
263
+ }
264
+ if (_isNonEmptyString(spec && spec.project)) return [spec.project];
265
+ return [];
266
+ }
267
+
268
+ /**
269
+ * W-msb9kgs402813a97-a — resolve which project owns DRAFT/EXECUTE.
270
+ *
271
+ * Explicit `spec.primaryProject` wins; otherwise the first declared project.
272
+ * Returns null for a central (project-less) session.
273
+ */
274
+ function _resolvePrimaryProject(spec, projects) {
275
+ const list = Array.isArray(projects) ? projects : [];
276
+ if (_isNonEmptyString(spec && spec.primaryProject) && list.includes(spec.primaryProject)) {
277
+ return spec.primaryProject;
278
+ }
279
+ return list.length > 0 ? list[0] : null;
280
+ }
281
+
282
+ /**
283
+ * Keep only the projectTargets entries that name a declared project, so a
284
+ * persisted map can never route a work item to a project the session does not
285
+ * own. validateSpec already rejects unknown keys — this is the persistence-side
286
+ * belt to that suspenders.
287
+ */
288
+ function _normalizeProjectTargets(spec, projects) {
289
+ const raw = spec && spec.projectTargets;
290
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
291
+ const out = {};
292
+ for (const project of projects) {
293
+ if (Object.prototype.hasOwnProperty.call(raw, project) && raw[project]) {
294
+ out[project] = { ...raw[project] };
295
+ }
296
+ }
297
+ return Object.keys(out).length > 0 ? out : null;
298
+ }
299
+
300
+ /**
301
+ * The QA target a given project's work item should be built against.
302
+ * `spec.projectTargets[project]` when declared, else the session-wide
303
+ * `spec.target`. Used by every work-item builder so a fanned-out SETUP no
304
+ * longer stamps the primary's target onto every co-service.
305
+ */
306
+ function _targetForProject(session, project) {
307
+ const spec = (session && session.spec) || {};
308
+ const map = spec.projectTargets;
309
+ if (_isNonEmptyString(project) && map && typeof map === 'object' && !Array.isArray(map)
310
+ && map[project] && typeof map[project] === 'object') {
311
+ return map[project];
312
+ }
313
+ return spec.target;
314
+ }
315
+
250
316
  /**
251
317
  * Validate a createSession spec. Returns { ok, errors }. Never throws.
252
318
  */
@@ -320,6 +386,45 @@ function validateSpec(spec) {
320
386
  }
321
387
  }
322
388
 
389
+ // W-msb9kgs402813a97-a — explicit primary + per-project targets. Both are
390
+ // validated against the SAME project list createSession normalizes to, so a
391
+ // spec can never persist a primary or a target keyed to a project the
392
+ // session does not own. Errors name the offending value AND the known
393
+ // projects — an operator hitting POST /api/qa/session gets a fix, not a
394
+ // shrug.
395
+ const declaredProjects = _specProjects(spec);
396
+ const knownProjects = declaredProjects.length ? declaredProjects.join(', ') : '(none)';
397
+
398
+ if (spec.primaryProject !== undefined && spec.primaryProject !== null) {
399
+ if (!_isNonEmptyString(spec.primaryProject)) {
400
+ errors.push('primaryProject must be a non-empty string when present');
401
+ } else if (spec.primaryProject.length > LIMITS.projectMax) {
402
+ errors.push(`primaryProject exceeds ${LIMITS.projectMax} chars`);
403
+ } else if (!declaredProjects.includes(spec.primaryProject)) {
404
+ errors.push(`primaryProject "${spec.primaryProject}" must be one of the session projects: ${knownProjects}`);
405
+ }
406
+ }
407
+
408
+ if (spec.projectTargets !== undefined && spec.projectTargets !== null) {
409
+ if (typeof spec.projectTargets !== 'object' || Array.isArray(spec.projectTargets)) {
410
+ errors.push('projectTargets must be a plain object keyed by project name');
411
+ } else {
412
+ const keys = Object.keys(spec.projectTargets);
413
+ if (keys.length > LIMITS.projectsMax) {
414
+ errors.push(`projectTargets exceeds ${LIMITS.projectsMax} entries`);
415
+ } else {
416
+ for (const key of keys) {
417
+ if (!declaredProjects.includes(key)) {
418
+ errors.push(`projectTargets["${key}"] must be one of the session projects: ${knownProjects}`);
419
+ continue;
420
+ }
421
+ errors.push(..._validateTarget(spec.projectTargets[key])
422
+ .map(e => `projectTargets["${key}"]: ${e}`));
423
+ }
424
+ }
425
+ }
426
+ }
427
+
323
428
  return { ok: errors.length === 0, errors };
324
429
  }
325
430
 
@@ -341,6 +446,11 @@ function validateSpec(spec) {
341
446
  * @param {object} [spec.capture] - { video?, screenshots?, logs? }
342
447
  * @param {string} [spec.runner] - explicit runner name, or null to auto-detect
343
448
  * @param {string} [spec.project] - project name (used to scope artifacts)
449
+ * @param {string[]} [spec.projects] - multi-project fan-out (≤ LIMITS.projectsMax)
450
+ * @param {string} [spec.primaryProject] - which of `projects` owns DRAFT/EXECUTE
451
+ * and the unsuffixed managed-spawn name. Defaults to `projects[0]`.
452
+ * @param {object} [spec.projectTargets] - per-project `{ [projectName]: target }`
453
+ * overrides. A project without an entry falls back to `spec.target`.
344
454
  * @param {string} [spec.createdBy] - operator identity for audit
345
455
  * @param {object} [spec.provenance] - P-c5a2f8b7. Optional "where this spec
346
456
  * came from" audit slot (e.g. { source:'prd', prd, flowSource,
@@ -362,14 +472,14 @@ function createSession(spec) {
362
472
  // single canonical `projects: string[] | null` (null = central). When both
363
473
  // are present, `projects` wins. Empty string in legacy `project` is treated
364
474
  // as central (matches the dashboard input convention).
365
- let projects = null;
366
- if (Array.isArray(spec.projects) && spec.projects.length > 0) {
367
- projects = spec.projects.slice();
368
- } else if (typeof spec.project === 'string' && spec.project.length > 0) {
369
- projects = [spec.project];
370
- }
371
- const primaryProject = projects && projects.length > 0 ? projects[0] : null;
372
- const coServices = projects && projects.length > 1 ? projects.slice(1) : [];
475
+ const declaredProjects = _specProjects(spec);
476
+ const projects = declaredProjects.length > 0 ? declaredProjects.slice() : null;
477
+ // W-msb9kgs402813a97-a — the primary is DECLARED, not positional. coServices
478
+ // is therefore "every other project" rather than projects.slice(1), so a
479
+ // primary in the middle of the list still fans the rest out correctly.
480
+ const primaryProject = _resolvePrimaryProject(spec, declaredProjects);
481
+ const coServices = projects ? projects.filter(p => p !== primaryProject) : [];
482
+ const projectTargets = _normalizeProjectTargets(spec, declaredProjects);
373
483
 
374
484
  const id = 'qas-' + uid();
375
485
  const now = ts();
@@ -391,6 +501,10 @@ function createSession(spec) {
391
501
  // older lifecycle hooks); new code keys off `projects`.
392
502
  projects,
393
503
  project: primaryProject,
504
+ // Per-project target overrides. null (not {}) when the caller declared
505
+ // none, so `_targetForProject` short-circuits to spec.target and the
506
+ // record shape stays honest about "no per-project routing".
507
+ projectTargets,
394
508
  },
395
509
  // W-mpq6xqzj000606d0 — denormalized fast-path fields used by the dispatch
396
510
  // chain (queueSetup / handleSetupComplete) without re-deriving from
@@ -527,10 +641,56 @@ function getSessionTestFile(sessionId) {
527
641
  return { path: target, content, language, truncated: false, size: stat.size };
528
642
  }
529
643
 
644
+ /**
645
+ * W-msb9kgs402813a97-d — bounded read-side projection of the qa-run a session
646
+ * spawned. Returns a Map of runId → { id, status, artifacts } for the ids
647
+ * asked about, built from ONE runs read.
648
+ *
649
+ * Deliberately NOT `runs.getRun(id)` per session: getRun re-reads and re-parses
650
+ * the whole qa_runs table on every call, so a 50-session list on a 3s poll
651
+ * would be a 50× full-table scan. One read + an id index is the same answer at
652
+ * 1/50th the cost.
653
+ *
654
+ * Lazy-required and fully try/catch'd — the sessions list must survive an
655
+ * unreadable runs store (it degrades to `linkedRun: null`, never a 500).
656
+ */
657
+ function _projectLinkedRuns(runIds) {
658
+ const out = new Map();
659
+ if (!runIds || !runIds.size) return out;
660
+ let runs;
661
+ try {
662
+ const qaRuns = require('./runs');
663
+ runs = qaRuns.listRuns({});
664
+ if (!Array.isArray(runs)) return out;
665
+ for (const run of runs) {
666
+ if (!run || !runIds.has(run.id)) continue;
667
+ out.set(run.id, {
668
+ id: run.id,
669
+ status: run.status || null,
670
+ artifacts: qaRuns.selectPrimaryArtifacts(run.artifacts),
671
+ });
672
+ }
673
+ } catch (e) {
674
+ log('warn', `qa-sessions: linked-run projection unavailable: ${e.message}`);
675
+ return new Map();
676
+ }
677
+ return out;
678
+ }
679
+
530
680
  /**
531
681
  * List sessions, newest first, optionally filtered by state, capped by limit.
682
+ *
683
+ * @param {object} [opts]
684
+ * @param {number} [opts.limit]
685
+ * @param {string} [opts.state]
686
+ * @param {boolean} [opts.includeLinkedRun] - attach a bounded `linkedRun`
687
+ * projection ({ id, status, artifacts: [{type, path, label}] }, primary
688
+ * artifacts only) for sessions carrying a qaRunId, else `linkedRun: null`.
689
+ * Opt-in on purpose: the default read path (including
690
+ * runs.js#_runSessionOwnership, called on every run delete) must not pay for
691
+ * the join. The projection is attached to a COPY — it is never persisted.
532
692
  */
533
- function listSessions({ limit, state } = {}) {
693
+ function listSessions({ limit, state, includeLinkedRun } = {}) {
534
694
  let sessions = _readSessions();
535
695
  if (!Array.isArray(sessions)) return [];
536
696
  if (state) {
@@ -545,7 +705,16 @@ function listSessions({ limit, state } = {}) {
545
705
  });
546
706
  const n = Number(limit);
547
707
  if (Number.isFinite(n) && n > 0) sessions = sessions.slice(0, Math.floor(n));
548
- return sessions;
708
+ if (!includeLinkedRun) return sessions;
709
+ // Index once for the page, after limit/filter so we only join what we return.
710
+ const runIds = new Set();
711
+ for (const s of sessions) {
712
+ if (s && typeof s.qaRunId === 'string' && s.qaRunId) runIds.add(s.qaRunId);
713
+ }
714
+ const linked = _projectLinkedRuns(runIds);
715
+ return sessions.map(s => (
716
+ s ? { ...s, linkedRun: (s.qaRunId && linked.get(s.qaRunId)) || null } : s
717
+ ));
549
718
  }
550
719
 
551
720
  /**
@@ -642,7 +811,10 @@ function transitionSession(id, toState, patch = {}, options = {}) {
642
811
  // W-mpq6xqzj000606d0 — `setupStatus` and `primaryScope` let the
643
812
  // multi-project fan-in handler can stamp per-project completion state
644
813
  // through the same locked path used by every other mutation.
645
- for (const field of ['summary', 'error', 'failureClass', 'testFile', 'qaRunId', 'managedSpawnHealth', 'setupStatus', 'primaryScope']) {
814
+ // W-msb9kgs402813a97-b — `visualJourneyManifest` is the accepted DRAFT
815
+ // manifest, persisted so EXECUTE can validate captured evidence against
816
+ // what DRAFT actually promised.
817
+ for (const field of ['summary', 'error', 'failureClass', 'testFile', 'qaRunId', 'managedSpawnHealth', 'setupStatus', 'primaryScope', 'visualJourneyManifest']) {
646
818
  if (Object.prototype.hasOwnProperty.call(patch, field)) {
647
819
  session[field] = patch[field];
648
820
  }
@@ -674,7 +846,7 @@ function markKilled(id, patch) { return transitionSession(id, QA_SESSION_STATE.K
674
846
  // timeout already marked the session failed. Before this guard, that late
675
847
  // completion fell straight into `transitionSession`, which throws on the
676
848
  // illegal `failed -> drafting` (etc.) transition; the throw was swallowed by
677
- // the caller's try/catch (engine/lifecycle.js) and logged as a bare warning,
849
+ // the caller's try/catch (engine/orchestration/lifecycle.js) and logged as a bare warning,
678
850
  // silently discarding real completed work (e.g. a working managed-spawn
679
851
  // setup) with no trace for a human to act on. This guard intercepts BEFORE
680
852
  // the throw, logs loudly, and writes an inbox note so the orphaned success
@@ -693,19 +865,21 @@ function _guardAlreadyTerminal(sessionId, session, phase, opts) {
693
865
  : '');
694
866
  log('warn', detail);
695
867
  try {
696
- // Lazy require (mirrors engine/live-checkout.js#_defaultWriteInboxNote) to
868
+ // Lazy require (mirrors engine/worktrees/live-checkout.js#_defaultWriteInboxNote) to
697
869
  // sidestep any require-cycle between dispatch.js and qa-sessions.js.
698
- require('./dispatch').writeInboxAlert(`qa-session-late-completion-${sessionId}`, detail);
870
+ require('../orchestration/dispatch').writeInboxAlert(`qa-session-late-completion-${sessionId}`, detail);
699
871
  } catch (e) { log('warn', `qa-sessions: writeInboxAlert for late completion ${sessionId} failed: ${e.message}`); }
700
872
  return true;
701
873
  }
702
874
 
703
875
  // ── Work-item builders (pure) ──────────────────────────────────────────────
704
876
  //
705
- // Each builder returns a WI spec ready for mutateWorkItems().push + addToDispatch.
706
- // Keeping these pure lets the dashboard endpoints (PR4) reuse them without
707
- // pulling dispatch into the unit test path. They're also called by the
708
- // lifecycle chain helpers below to queue the next phase.
877
+ // Each builder returns a WI spec ready for mutateWorkItems().push. Keeping
878
+ // these pure lets the dashboard endpoints (PR4) reuse them without pulling
879
+ // dispatch into the unit test path. They're also called by the lifecycle chain
880
+ // helpers below to queue the next phase. `project` is a hint only —
881
+ // _persistWorkItem binds the field to the scope the item is stored in
882
+ // (W-ms9nq2pu00cd60f3).
709
883
 
710
884
  function _baseWorkItem(session, phase, { title, description, project, primary, coServices, primaryProject }) {
711
885
  const wiId = 'W-' + uid();
@@ -715,8 +889,13 @@ function _baseWorkItem(session, phase, { title, description, project, primary, c
715
889
  // coServices=[] so existing single-target prompts render unchanged.
716
890
  // DRAFT/EXECUTE always omit these (primary-only) — pass `primary:
717
891
  // undefined` to keep meta clean.
892
+ //
893
+ // W-msb9kgs402813a97-a — each WI is routed its OWN target. Before this,
894
+ // every fanned-out SETUP was stamped with the session-wide
895
+ // `spec.target`, so a co-service could only ever be checked out at the
896
+ // primary's branch/PR/commit.
718
897
  const qaMeta = {
719
- target: session.spec.target,
898
+ target: _targetForProject(session, project),
720
899
  flowsRaw: session.spec.flowsRaw,
721
900
  mode: session.spec.mode,
722
901
  capture: session.spec.capture,
@@ -764,12 +943,13 @@ function _baseWorkItem(session, phase, { title, description, project, primary, c
764
943
  function buildSetupWorkItem(session, { project, primary, coServices, primaryProject } = {}) {
765
944
  const isPrimary = primary === undefined ? true : !!primary;
766
945
  const co = Array.isArray(coServices) ? coServices.slice() : [];
946
+ const target = _targetForProject(session, project);
767
947
  return _baseWorkItem(session, SESSION_PHASE.SETUP, {
768
- title: `QA Session SETUP: ${_summarizeTarget(session.spec.target)}${isPrimary && co.length ? ` (primary +${co.length} co-services)` : (!isPrimary ? ' (co-service)' : '')}`,
948
+ title: `QA Session SETUP: ${_summarizeTarget(target)}${isPrimary && co.length ? ` (primary +${co.length} co-services)` : (!isPrimary ? ' (co-service)' : '')}`,
769
949
  description: [
770
950
  `QA Session ${session.id} — SETUP phase.`,
771
951
  '',
772
- `Target: ${JSON.stringify(session.spec.target)}`,
952
+ `Target: ${JSON.stringify(target)}`,
773
953
  `Flows: ${session.spec.flowsRaw}`,
774
954
  isPrimary
775
955
  ? (co.length ? `Role: PRIMARY (co-services: ${co.join(', ')})` : 'Role: PRIMARY (single-project session)')
@@ -814,7 +994,7 @@ function buildDraftWorkItem(session, { project, feedback } = {}) {
814
994
  lines.push('', '## Reviewer feedback on previous draft', '', String(feedback));
815
995
  }
816
996
  return _baseWorkItem(session, SESSION_PHASE.DRAFT, {
817
- title: `QA Session DRAFT: ${_summarizeTarget(session.spec.target)}`,
997
+ title: `QA Session DRAFT: ${_summarizeTarget(_targetForProject(session, project))}`,
818
998
  description: lines.join('\n'),
819
999
  project,
820
1000
  });
@@ -823,7 +1003,7 @@ function buildDraftWorkItem(session, { project, feedback } = {}) {
823
1003
  /**
824
1004
  * Build the EXECUTE work item. The agent invokes the runner against the live
825
1005
  * spawn, captures artifacts per `capture`, and writes the injected absolute
826
- * result sidecar. The qaRunId hook in engine/lifecycle.js ingests it; our own
1006
+ * result sidecar. The qaRunId hook in engine/orchestration/lifecycle.js ingests it; our own
827
1007
  * session hook below transitions done/failed based on the resulting qa-run
828
1008
  * terminal status.
829
1009
  *
@@ -837,7 +1017,7 @@ function buildExecuteWorkItem(session, { qaRunId, project } = {}) {
837
1017
  throw new Error('qa-sessions: buildExecuteWorkItem requires qaRunId');
838
1018
  }
839
1019
  const wi = _baseWorkItem(session, SESSION_PHASE.EXECUTE, {
840
- title: `QA Session EXECUTE: ${_summarizeTarget(session.spec.target)}`,
1020
+ title: `QA Session EXECUTE: ${_summarizeTarget(_targetForProject(session, project))}`,
841
1021
  description: [
842
1022
  `QA Session ${session.id} — EXECUTE phase.`,
843
1023
  '',
@@ -876,21 +1056,80 @@ function _summarizeTarget(target) {
876
1056
  // endpoints call when an agent finishes or a user takes an action. Each one
877
1057
  // validates the transition first (so an illegal call throws BEFORE side
878
1058
  // effects like queueing the next WI), then applies the state change, then
879
- // queues the next phase via _queueWorkItem when appropriate.
1059
+ // persists the next phase's work item via _queueWorkItem.
880
1060
  //
881
- // dispatch + work-items are lazy-required inside _queueWorkItem to keep
882
- // `require('./qa-sessions')` cycle-safe at the top of lifecycle.js.
883
-
884
- function _dispatchQueuedWorkItem(wi) {
885
- const dispatch = require('./dispatch');
886
- dispatch.addToDispatch({
887
- type: wi.type,
888
- agent: wi.agent || null,
889
- meta: { item: wi, playbook: wi.meta.playbook },
890
- });
1061
+ // Dispatch itself is owned by the engine's normal work-discovery pass
1062
+ // (engine.js#discoverFromWorkItems / #discoverCentralWorkItems) — see
1063
+ // _queueWorkItem. work-items is lazy-required inside shared.mutateWorkItems to
1064
+ // keep `require('./qa/sessions')` cycle-safe at the top of lifecycle.js.
1065
+
1066
+ const CENTRAL_SCOPE = 'central';
1067
+
1068
+ /**
1069
+ * The project that owns a SQL work-item scope. `shared.resolveProjectSource`
1070
+ * builds a scope as `project?.name || 'central'`, so a scope is either a
1071
+ * configured project name or the central sentinel.
1072
+ */
1073
+ function _scopeProject(scope) {
1074
+ const value = _isNonEmptyString(scope) ? String(scope).trim() : '';
1075
+ return value && value !== CENTRAL_SCOPE ? value : null;
1076
+ }
1077
+
1078
+ /**
1079
+ * W-ms9nq2pu00cd60f3 — a QA phase work item's project association is DERIVED
1080
+ * from the scope it is stored in, never merely carried alongside it.
1081
+ *
1082
+ * `WORK_TYPE.TEST` is in shared.WORKTREE_REQUIRING_TYPES: engine.js resolves a
1083
+ * worktree rootDir from the work item's project, and without one it falls back
1084
+ * to MINIONS_DIR's parent — which collapses to a drive root on installs where
1085
+ * MINIONS_DIR sits one level below it (`D:\squad-opg` → `D:\`) and fails
1086
+ * non-retryably with FAILURE_CLASS.WORKTREE_PREFLIGHT. Because the builders
1087
+ * take `project` as a separate argument with an `opts.project ||
1088
+ * spec.project || null` fallback chain, a phase queued into a project scope
1089
+ * could land with `project: null` whenever that chain came up empty (e.g. a
1090
+ * session created with `projects: ['x']`, which canonicalizes to `spec.projects`
1091
+ * and leaves `spec.project` unset). Binding to the scope makes the association
1092
+ * structural instead of incidental.
1093
+ *
1094
+ * A project scope that is handed a DIFFERENT project name is an unambiguous
1095
+ * caller bug — neither value can be trusted, so it throws rather than persist
1096
+ * an item the engine can never place. A central scope is left alone: it has no
1097
+ * owning project to bind, and a declared project there is how
1098
+ * discoverCentralWorkItems still resolves a rootDir, so neither inventing nor
1099
+ * stripping one would help.
1100
+ */
1101
+ function _assertProjectMatchesScope(scope, project, label) {
1102
+ const scopeProject = _scopeProject(scope);
1103
+ const declared = _isNonEmptyString(project) ? String(project).trim() : null;
1104
+ if (scopeProject && declared && declared !== scopeProject) {
1105
+ throw new Error(
1106
+ `qa-sessions: ${label} project "${declared}" does not match scope "${scope}" — ` +
1107
+ 'a QA phase work item must be owned by the project whose work-item scope stores it',
1108
+ );
1109
+ }
1110
+ return scopeProject;
1111
+ }
1112
+
1113
+ /**
1114
+ * Wake the engine daemon so its 1s fast poll runs a tick immediately instead of
1115
+ * waiting a full tick interval. Mirrors the dashboard's create/advance wakeups
1116
+ * (dashboard.js `mutateControl(control => ({ ...control, _wakeupAt }))`).
1117
+ * Best-effort: a missing/locked control.json must never fail the transition —
1118
+ * the next scheduled tick still discovers the item.
1119
+ */
1120
+ function _requestDispatchWakeup() {
1121
+ try {
1122
+ shared.mutateControl((control) => ({ ...control, _wakeupAt: Date.now() }));
1123
+ } catch (e) {
1124
+ log('warn', `qa-sessions: dispatch wakeup request failed: ${e.message}`);
1125
+ }
891
1126
  }
892
1127
 
893
1128
  function _persistWorkItem(wi, scope) {
1129
+ const project = _assertProjectMatchesScope(scope, wi.project, `work item ${wi.id}`);
1130
+ // Stamp BEFORE the store write: the item must never be visible to work
1131
+ // discovery without the project field engine.js reads to place a worktree.
1132
+ if (project) wi.project = project;
894
1133
  shared.mutateWorkItems(scope, (items) => {
895
1134
  if (!Array.isArray(items)) items = [];
896
1135
  if (!items.some(item => item && item.id === wi.id)) items.push(wi);
@@ -898,9 +1137,22 @@ function _persistWorkItem(wi, scope) {
898
1137
  });
899
1138
  }
900
1139
 
1140
+ /**
1141
+ * Persist a phase work item as `pending` and wake the engine.
1142
+ *
1143
+ * QA phases deliberately do NOT enqueue their own dispatch record. Dispatch is
1144
+ * built by engine.js#discoverFromWorkItems / #discoverCentralWorkItems, which
1145
+ * is the only place that resolves `meta.project`, routes an agent, derives the
1146
+ * branch, honours the branch mutex, and renders the phase playbook prompt.
1147
+ * A hand-rolled `addToDispatch({ meta: { item, playbook } })` carried none of
1148
+ * those, so it spawned project-less and forever-failed WORKTREE_PREFLIGHT while
1149
+ * also blocking the correct dispatch via the meta.item.id dedup
1150
+ * (W-ms9nq2pu00cd60f3). Matches the sibling qa-validate flow
1151
+ * (dashboard.js#handleQaRunbookRun), which has always relied on discovery.
1152
+ */
901
1153
  function _queueWorkItem(wi, scope) {
902
1154
  _persistWorkItem(wi, scope);
903
- _dispatchQueuedWorkItem(wi);
1155
+ _requestDispatchWakeup();
904
1156
  return wi.id;
905
1157
  }
906
1158
 
@@ -909,8 +1161,9 @@ function _queueWorkItem(wi, scope) {
909
1161
  * Validates pending → spawning, queues the SETUP WI(s), returns the queued
910
1162
  * primary WI id (back-compat).
911
1163
  *
912
- * W-mpq6xqzj000606d0 — multi-project fan-out. The first target drives
913
- * DRAFT/EXECUTE; the rest are co-services.
1164
+ * W-mpq6xqzj000606d0 — multi-project fan-out. W-msb9kgs402813a97-a — the
1165
+ * session's declared `primaryProject` drives DRAFT/EXECUTE (the targets are
1166
+ * reordered so it leads); every other target is a co-service.
914
1167
  *
915
1168
  * @param {string} sessionId
916
1169
  * @param {object} opts
@@ -931,6 +1184,9 @@ function queueSetup(sessionId, opts = {}) {
931
1184
  if (!t || !_isNonEmptyString(t.scope)) {
932
1185
  throw new Error('qa-sessions: queueSetup target missing scope');
933
1186
  }
1187
+ // Reject a project/scope disagreement BEFORE markSpawning, so a bad target
1188
+ // can't leave the session stranded in `spawning` with no work item.
1189
+ _assertProjectMatchesScope(t.scope, t.project, 'queueSetup target');
934
1190
  }
935
1191
  if (targets.length > LIMITS.projectsMax) {
936
1192
  throw new Error(`qa-sessions: queueSetup target count exceeds ${LIMITS.projectsMax}`);
@@ -939,6 +1195,31 @@ function queueSetup(sessionId, opts = {}) {
939
1195
  const session = getSession(sessionId);
940
1196
  if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
941
1197
 
1198
+ // W-msb9kgs402813a97-a — the primary is the target whose project matches the
1199
+ // session's DECLARED primaryProject, not whichever target the caller happened
1200
+ // to list first. Reordering (rather than tracking an index) keeps the single
1201
+ // "index 0 is primary" invariant the rest of this function and the fan-in
1202
+ // handler rely on.
1203
+ //
1204
+ // A declared primary that no project-bound target can host is a genuine
1205
+ // contract violation: the resolver and the session record disagree about what
1206
+ // the session IS. Throw BEFORE markSpawning so the caller sees a 4xx instead
1207
+ // of a session stranded in `spawning` with a mis-flagged fan-out. Callers
1208
+ // that pass only project-less targets (the legacy `{ scope }` shape) are
1209
+ // unaffected — there is nothing to contradict.
1210
+ const declaredPrimary = _isNonEmptyString(session.primaryProject) ? session.primaryProject : null;
1211
+ if (declaredPrimary) {
1212
+ const primaryIndex = targets.findIndex(t => (t.project || null) === declaredPrimary);
1213
+ if (primaryIndex > 0) {
1214
+ targets.unshift(targets.splice(primaryIndex, 1)[0]);
1215
+ } else if (primaryIndex < 0 && targets.some(t => _isNonEmptyString(t.project))) {
1216
+ throw new Error(
1217
+ `qa-sessions: queueSetup primaryProject "${declaredPrimary}" is not among the resolved `
1218
+ + `targets (${targets.map(t => t.project || '(central)').join(', ')})`,
1219
+ );
1220
+ }
1221
+ }
1222
+
942
1223
  const primaryTarget = targets[0];
943
1224
  const coServiceProjects = targets.slice(1).map(t => t.project || null).filter(Boolean);
944
1225
  const isMulti = targets.length > 1;
@@ -1153,7 +1434,7 @@ function handleSetupCompleteAfterManagedSpawn(sessionId, opts = {}) {
1153
1434
  if (!opts.success) return handleSetupComplete(sessionId, opts);
1154
1435
 
1155
1436
  const requirement = _setupManagedSpawnRequirement(session, opts.project || null);
1156
- const record = require('./managed-spawn').getManagedSpecByName(requirement.name);
1437
+ const record = require('../processes/managed-spawn').getManagedSpecByName(requirement.name);
1157
1438
  const hasExpectedOwner = _isNonEmptyString(requirement.wiId);
1158
1439
  const ownerMatches = hasExpectedOwner && record?.owner_wi === requirement.wiId;
1159
1440
  if (!record || record.alive !== true || record.healthy !== true || !ownerMatches) {
@@ -1181,10 +1462,21 @@ function handleSetupCompleteAfterManagedSpawn(sessionId, opts = {}) {
1181
1462
  * awaiting-approval (confirm mode — user must call /approve) or auto-chain
1182
1463
  * EXECUTE (auto mode). The testFile path is captured for the EXECUTE prompt.
1183
1464
  *
1465
+ * W-msb9kgs402813a97-b — a session that asked for screenshots or video must
1466
+ * also produce a VISUAL-JOURNEY MANIFEST (the agent's
1467
+ * `agents/<id>/qa-session-draft-result.json` sidecar, consumed by the
1468
+ * lifecycle hook and handed in as `opts.manifest`). The gate runs BEFORE both
1469
+ * exits — before `markAwaitingApproval` and before the auto-mode EXECUTE queue
1470
+ * — so a draft that never opened a browser, skipped a project, or ignored a
1471
+ * co-service origin fails here instead of producing a green qa-run with no
1472
+ * visual evidence.
1473
+ *
1184
1474
  * @param {string} sessionId
1185
1475
  * @param {object} opts
1186
1476
  * @param {boolean} opts.success
1187
1477
  * @param {string} [opts.testFile] - relative path under qa-tests/<id>/, captured for EXECUTE
1478
+ * @param {object} [opts.manifest] - parsed visual-journey manifest from the DRAFT sidecar
1479
+ * @param {Array} [opts.services] - managed-spawn inventory override for the coverage check
1188
1480
  * @param {string} [opts.scope] - required when success=true and mode=auto
1189
1481
  * @param {string} [opts.project]
1190
1482
  * @param {string} [opts.qaRunId] - required when success=true and mode=auto (caller creates the qa-runs record)
@@ -1205,15 +1497,46 @@ function handleDraftComplete(sessionId, opts = {}) {
1205
1497
  });
1206
1498
  return { nextState: QA_SESSION_STATE.FAILED, queuedExecuteWi: null };
1207
1499
  }
1208
- const testFilePatch = opts.testFile ? { testFile: opts.testFile } : {};
1209
- if (session.spec.mode === 'auto') {
1500
+
1501
+ // Caller-contract checks first: the chain helpers validate arguments BEFORE
1502
+ // any side effect, so a missing scope/qaRunId still throws rather than
1503
+ // half-applying a state change.
1504
+ const isAuto = session.spec.mode === 'auto';
1505
+ if (isAuto) {
1210
1506
  if (!_isNonEmptyString(opts.scope)) {
1211
1507
  throw new Error('qa-sessions: handleDraftComplete (auto) requires scope');
1212
1508
  }
1213
1509
  if (!_isNonEmptyString(opts.qaRunId)) {
1214
1510
  throw new Error('qa-sessions: handleDraftComplete (auto) requires qaRunId');
1215
1511
  }
1216
- markExecuting(sessionId, { ...testFilePatch, qaRunId: opts.qaRunId });
1512
+ }
1513
+
1514
+ // Fail-closed visual-coverage gate. Deliberately BEFORE the mode branch: a
1515
+ // rejection must not queue EXECUTE and must not park the session for human
1516
+ // approval as if the draft were usable.
1517
+ const gate = visualJourney.validateVisualJourneyManifest(
1518
+ session,
1519
+ opts.manifest === undefined ? null : opts.manifest,
1520
+ { services: opts.services },
1521
+ );
1522
+ if (!gate.ok) {
1523
+ markFailed(sessionId, {
1524
+ failureClass: visualJourney.VISUAL_COVERAGE_FAILURE_CLASS,
1525
+ error: gate.error,
1526
+ summary: `DRAFT rejected: visual-journey coverage incomplete (${gate.errors.length} issue${gate.errors.length === 1 ? '' : 's'})`,
1527
+ });
1528
+ log('warn', `qa-session ${sessionId} DRAFT rejected by the visual-journey gate: ${gate.error}`);
1529
+ return { nextState: QA_SESSION_STATE.FAILED, queuedExecuteWi: null };
1530
+ }
1531
+
1532
+ const testFilePatch = opts.testFile ? { testFile: opts.testFile } : {};
1533
+ // Only stamp a manifest when the draft actually declared one — a logs-only
1534
+ // session keeps its historical record shape.
1535
+ const draftPatch = gate.manifest
1536
+ ? { ...testFilePatch, visualJourneyManifest: gate.manifest }
1537
+ : testFilePatch;
1538
+ if (isAuto) {
1539
+ markExecuting(sessionId, { ...draftPatch, qaRunId: opts.qaRunId });
1217
1540
  const updated = getSession(sessionId);
1218
1541
  const wi = buildExecuteWorkItem(updated, {
1219
1542
  qaRunId: opts.qaRunId,
@@ -1224,20 +1547,72 @@ function handleDraftComplete(sessionId, opts = {}) {
1224
1547
  return { nextState: QA_SESSION_STATE.EXECUTING, queuedExecuteWi: wi.id };
1225
1548
  }
1226
1549
  // confirm mode (default)
1227
- markAwaitingApproval(sessionId, testFilePatch);
1550
+ markAwaitingApproval(sessionId, draftPatch);
1228
1551
  return { nextState: QA_SESSION_STATE.AWAITING_APPROVAL, queuedExecuteWi: null };
1229
1552
  }
1230
1553
 
1554
+ /**
1555
+ * Resolve the EXECUTE-side evidence verdict for a session about to be marked
1556
+ * done (W-msb9kgs402813a97-c).
1557
+ *
1558
+ * Skipped entirely — `{ ok: true, required: false }` — when the session asked
1559
+ * for neither screenshots nor video, or when no DRAFT visual-journey manifest
1560
+ * was persisted (a legacy session predating the manifest). Otherwise the run
1561
+ * record and its artifacts directory are resolved and handed to the validator.
1562
+ *
1563
+ * An internal failure FAILS CLOSED: a gate that cannot prove evidence must not
1564
+ * wave the session through.
1565
+ */
1566
+ function _evaluateExecuteEvidence(session, opts = {}) {
1567
+ const manifest = session && session.visualJourneyManifest;
1568
+ const hasManifest = !!manifest && typeof manifest === 'object' && !Array.isArray(manifest);
1569
+ if (!visualJourney.isVisualCaptureRequested(session) || !hasManifest) {
1570
+ return { ok: true, required: false };
1571
+ }
1572
+ try {
1573
+ const qaRuns = require('./runs');
1574
+ let run = opts.qaRun && typeof opts.qaRun === 'object' ? opts.qaRun : null;
1575
+ const runId = (run && run.id) || session.qaRunId || null;
1576
+ if (!run && _isNonEmptyString(runId)) {
1577
+ try { run = qaRuns.getRun(runId); } catch { run = null; }
1578
+ }
1579
+ let artifactsDir = null;
1580
+ if (_isNonEmptyString(runId)) {
1581
+ try { artifactsDir = qaRuns.qaArtifactsDirForRun(runId); } catch { artifactsDir = null; }
1582
+ }
1583
+ return visualJourney.validateEvidenceCoverage({
1584
+ session, manifest, run, artifactsDir, services: opts.services,
1585
+ });
1586
+ } catch (err) {
1587
+ return {
1588
+ ok: false,
1589
+ required: true,
1590
+ missing: { files: [], journeys: [], projects: [], services: [], evidenceTypes: [] },
1591
+ errors: [err.message],
1592
+ error: `evidence coverage could not be verified for session ${session.id}: ${err.message}`,
1593
+ };
1594
+ }
1595
+ }
1596
+
1231
1597
  /**
1232
1598
  * Lifecycle hook: EXECUTE WI completed. The qa-runs record's terminal status
1233
1599
  * is the source of truth for done vs failed — the qaRunId hook at
1234
- * engine/lifecycle.js:4340 has already written it. We just read that record
1600
+ * engine/orchestration/lifecycle.js:4340 has already written it. We just read that record
1235
1601
  * (when provided) and transition the session accordingly.
1236
1602
  *
1603
+ * W-msb9kgs402813a97-c — a green qa-run is necessary but NOT sufficient for a
1604
+ * session that asked for screenshots or video. Before choosing DONE we run the
1605
+ * evidence-coverage validator: a run whose registered artifacts do not exist
1606
+ * on disk, whose evidence is missing a requested capture type, or whose passed
1607
+ * journeys cover only one of the session's projects/services fails with
1608
+ * `qa-session-evidence-incomplete` instead.
1609
+ *
1237
1610
  * @param {string} sessionId
1238
1611
  * @param {object} opts
1239
1612
  * @param {boolean} opts.success - dispatch-level success (whether the agent exited 0)
1240
1613
  * @param {string} [opts.qaRunStatus] - 'passed' | 'failed' | 'errored' (from qa-runs record)
1614
+ * @param {object} [opts.qaRun] - the already-read qa-runs record (avoids a second lookup)
1615
+ * @param {Array} [opts.services] - managed-spawn inventory override for the coverage check
1241
1616
  * @param {string} [opts.summary]
1242
1617
  * @param {string} [opts.reason]
1243
1618
  */
@@ -1266,6 +1641,17 @@ function handleExecuteComplete(sessionId, opts = {}) {
1266
1641
  patch.failureClass = 'qa-session-execute-failed';
1267
1642
  patch.error = opts.reason || 'EXECUTE phase failed';
1268
1643
  }
1644
+ // Fail-closed evidence gate — deliberately BEFORE the transition, and only
1645
+ // on the DONE path so an already-failing run keeps its own failure class.
1646
+ if (toState === QA_SESSION_STATE.DONE) {
1647
+ const evidence = _evaluateExecuteEvidence(session, opts);
1648
+ if (evidence.required && !evidence.ok) {
1649
+ toState = QA_SESSION_STATE.FAILED;
1650
+ patch.failureClass = visualJourney.EVIDENCE_COVERAGE_FAILURE_CLASS;
1651
+ patch.error = evidence.error;
1652
+ log('warn', `qa-session ${sessionId} EXECUTE rejected by the evidence-coverage gate: ${evidence.error}`);
1653
+ }
1654
+ }
1269
1655
  transitionSession(sessionId, toState, patch);
1270
1656
  return toState;
1271
1657
  }
@@ -1307,9 +1693,10 @@ function queueApprovedDraft(sessionId, { scope, qaRunId, project } = {}) {
1307
1693
  }
1308
1694
  session.workItems[SESSION_PHASE.EXECUTE] = wi.id;
1309
1695
  session.updatedAt = ts();
1310
- _dispatchQueuedWorkItem(wi);
1311
1696
  return sessions;
1312
1697
  });
1698
+ // Outside the session lock — never take a second lock inside a mutator.
1699
+ _requestDispatchWakeup();
1313
1700
  return wi.id;
1314
1701
  }
1315
1702
 
@@ -1383,6 +1770,163 @@ function dismissSession(sessionId, { summary } = {}) {
1383
1770
  });
1384
1771
  }
1385
1772
 
1773
+ // ── Deletion (operator-facing, terminal-only) ──────────────────────────────
1774
+
1775
+ // W-msb4vgj500zbb008 — DELETE /api/qa/sessions/<id>. Sessions accumulate in
1776
+ // the dashboard's "Recent Sessions" list and, before this, the only way to
1777
+ // remove a failed record was hand-editing engine/state.db. Mirrors
1778
+ // engine/qa/runs.js#deleteQaRun: terminal-only, envelope-returning, the record
1779
+ // removed inside the SQL mutator (which emits the qa_sessions state event) and
1780
+ // every filesystem / cross-store side effect performed OUTSIDE the lock.
1781
+ //
1782
+ // Linked-resource ownership rules are deliberately conservative — a leaked
1783
+ // artifact costs nothing, a wrongly-deleted one destroys QA evidence:
1784
+ //
1785
+ // engine/qa-tests/<id>/ Session-owned by construction (createSession makes
1786
+ // it; the directory name IS the session id) → removed
1787
+ // with the same resolve + prefix sandbox deleteQaRun
1788
+ // applies to artifact directories. Never the root.
1789
+ // session.qaRunId Cascaded through qaRuns.deleteQaRun ONLY when the
1790
+ // run still exists AND carries the synthetic
1791
+ // `qa-session-<id>` runbookId stamped by the approve
1792
+ // endpoint (dashboard.js#handleQaSessionApprove) and
1793
+ // the auto-mode chain (lifecycle.js). A run owned by a
1794
+ // real runbook, a run already deleted, and a run that
1795
+ // is still pending/running are each left in place and
1796
+ // named in `qaRun.reason`. deleteQaRun's own
1797
+ // terminal-only guard is the second line of defence.
1798
+ // managed spawns NEVER touched. A QA managed-spawn can outlive its
1799
+ // session, and killing a live process as a side
1800
+ // effect of deleting a history record would be a
1801
+ // surprise. Live names are reported instead so the
1802
+ // operator can call POST /api/managed-processes/kill
1803
+ // deliberately.
1804
+ //
1805
+ // Repeat deletes are safe: the second call returns `not_found`, and a linked
1806
+ // run that a previous call (or a manual DELETE /api/qa/runs/<id>) already
1807
+ // removed reports `qaRun.reason: 'not_found'` rather than failing the request.
1808
+ const QA_SESSION_RUNBOOK_PREFIX = 'qa-session-';
1809
+
1810
+ function sessionOwnedRunbookId(sessionId) {
1811
+ return QA_SESSION_RUNBOOK_PREFIX + sessionId;
1812
+ }
1813
+
1814
+ function _removeSessionTestDir(sessionId) {
1815
+ try {
1816
+ const base = path.resolve(qaTestsDir());
1817
+ const target = path.resolve(qaTestsDirForSession(sessionId));
1818
+ if (!target.startsWith(base + path.sep)) return false;
1819
+ if (!fs.existsSync(target)) return false;
1820
+ fs.rmSync(target, { recursive: true, force: true });
1821
+ return true;
1822
+ } catch (e) {
1823
+ log('warn', `qa-sessions: rm tests dir failed for ${sessionId}: ${e.message}`);
1824
+ return false;
1825
+ }
1826
+ }
1827
+
1828
+ function _deleteLinkedQaRun(session) {
1829
+ const runId = session && session.qaRunId;
1830
+ if (!_isNonEmptyString(runId)) {
1831
+ return { linked: false, id: null, deleted: false, reason: 'no_linked_run' };
1832
+ }
1833
+ let qaRuns;
1834
+ let run;
1835
+ try {
1836
+ qaRuns = require('./runs');
1837
+ run = qaRuns.getRun(runId);
1838
+ } catch (e) {
1839
+ log('warn', `qa-sessions: qa-run lookup failed for ${runId}: ${e.message}`);
1840
+ return { linked: true, id: runId, deleted: false, reason: 'lookup_failed' };
1841
+ }
1842
+ if (!run) return { linked: true, id: runId, deleted: false, reason: 'not_found' };
1843
+ if (run.runbookId !== sessionOwnedRunbookId(session.id)) {
1844
+ return { linked: true, id: runId, deleted: false, reason: 'not_session_owned' };
1845
+ }
1846
+ let result;
1847
+ try { result = qaRuns.deleteQaRun(runId); }
1848
+ catch (e) {
1849
+ log('warn', `qa-sessions: linked qa-run delete failed for ${runId}: ${e.message}`);
1850
+ return { linked: true, id: runId, deleted: false, reason: 'delete_failed' };
1851
+ }
1852
+ if (result && result.ok) {
1853
+ return {
1854
+ linked: true,
1855
+ id: runId,
1856
+ deleted: true,
1857
+ reason: null,
1858
+ artifactsRemoved: !!result.artifactsRemoved,
1859
+ };
1860
+ }
1861
+ const out = {
1862
+ linked: true,
1863
+ id: runId,
1864
+ deleted: false,
1865
+ reason: (result && result.error) || 'delete_failed',
1866
+ };
1867
+ if (result && result.currentStatus) out.currentStatus = result.currentStatus;
1868
+ return out;
1869
+ }
1870
+
1871
+ // Report — never kill. `known: false` means the managed-spawn store could not
1872
+ // be read, so the empty list must not be read as "no live spawns".
1873
+ function _liveManagedSpawnsForSession(session) {
1874
+ try {
1875
+ const managedSpawn = require('../processes/managed-spawn');
1876
+ const live = [];
1877
+ for (const name of managedSpawnNamesForSession(session)) {
1878
+ if (managedSpawn.getManagedSpecByName(name)) live.push(name);
1879
+ }
1880
+ return { live, known: true };
1881
+ } catch (e) {
1882
+ log('warn', `qa-sessions: managed-spawn lookup failed for ${session && session.id}: ${e.message}`);
1883
+ return { live: [], known: false };
1884
+ }
1885
+ }
1886
+
1887
+ /**
1888
+ * Hard-delete a terminal QA session record and clean up the resources it
1889
+ * provably owns.
1890
+ *
1891
+ * { ok: true, id, session, testDirRemoved, qaRun, managedSpawns }
1892
+ * { ok: false, error: 'invalid_id' | 'not_found' | 'not_terminal', currentState? }
1893
+ *
1894
+ * @param {string} id - session id (must pass _isSafeSessionId)
1895
+ * @returns {object} envelope (see above)
1896
+ */
1897
+ function deleteSession(id) {
1898
+ if (!_isSafeSessionId(id)) return { ok: false, error: 'invalid_id' };
1899
+
1900
+ let captured = null;
1901
+ let notFound = false;
1902
+ let notTerminal = null;
1903
+ mutateQaSessions( (sessions) => {
1904
+ if (!Array.isArray(sessions)) return sessions;
1905
+ const idx = sessions.findIndex(s => s && s.id === id);
1906
+ if (idx < 0) { notFound = true; return sessions; }
1907
+ const session = sessions[idx];
1908
+ if (!TERMINAL_STATES.has(session.state)) {
1909
+ notTerminal = session.state;
1910
+ return sessions;
1911
+ }
1912
+ captured = session;
1913
+ sessions.splice(idx, 1);
1914
+ return sessions;
1915
+ }, { defaultValue: [] });
1916
+
1917
+ if (notFound) return { ok: false, error: 'not_found' };
1918
+ if (notTerminal !== null) return { ok: false, error: 'not_terminal', currentState: notTerminal };
1919
+
1920
+ return {
1921
+ ok: true,
1922
+ id,
1923
+ session: captured,
1924
+ testDirRemoved: _removeSessionTestDir(id),
1925
+ qaRun: _deleteLinkedQaRun(captured),
1926
+ managedSpawns: _liveManagedSpawnsForSession(captured),
1927
+ };
1928
+ }
1929
+
1386
1930
  // ── Status summary (cheap, for /api/status fast-state slice) ───────────────
1387
1931
 
1388
1932
  /**
@@ -1478,6 +2022,8 @@ module.exports = {
1478
2022
  cancelSession,
1479
2023
  killSession,
1480
2024
  dismissSession,
2025
+ deleteSession,
2026
+ sessionOwnedRunbookId,
1481
2027
  managedSpawnNamesForSession,
1482
2028
  // Status
1483
2029
  summarizeSessionsForStatus,