@yemi33/minions 0.1.2379 → 0.1.2381

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 (100) hide show
  1. package/bin/minions.js +19 -9
  2. package/dashboard/js/refresh.js +3 -4
  3. package/dashboard/js/render-other.js +43 -23
  4. package/dashboard/js/render-prd.js +1 -1
  5. package/dashboard/js/render-work-items.js +13 -6
  6. package/dashboard/js/settings.js +40 -17
  7. package/dashboard.js +435 -768
  8. package/docs/architecture-review-2026-07-09.md +2 -4
  9. package/docs/auto-discovery.md +36 -49
  10. package/docs/blog-first-successful-dispatch.md +1 -1
  11. package/docs/branch-derivation.md +2 -2
  12. package/docs/command-center.md +1 -1
  13. package/docs/completion-reports.md +14 -4
  14. package/docs/constellation-bridge.md +59 -10
  15. package/docs/constellation-style-telemetry.md +6 -6
  16. package/docs/cooldown-merge-semantics.md +4 -0
  17. package/docs/copilot-cli-schema.md +3 -3
  18. package/docs/cross-repo-plans.md +17 -17
  19. package/docs/deprecated.json +2 -2
  20. package/docs/design-state-storage.md +1 -1
  21. package/docs/documentation-audit-2026-07-09.md +2 -2
  22. package/docs/engine-restart.md +20 -8
  23. package/docs/harness-mode.md +1 -1
  24. package/docs/live-checkout-mode.md +45 -26
  25. package/docs/managed-spawn.md +4 -4
  26. package/docs/onboarding.md +1 -2
  27. package/docs/pr-comment-followup.md +3 -3
  28. package/docs/pr-review-fix-loop.md +1 -1
  29. package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
  30. package/docs/qa-runbook-lifecycle.md +4 -4
  31. package/docs/qa-runbooks.md +2 -2
  32. package/docs/rfc-completion-json.md +4 -1
  33. package/docs/runtime-adapters.md +1 -1
  34. package/docs/self-improvement.md +4 -5
  35. package/docs/shared-lifecycle-module-map.md +3 -1
  36. package/docs/slim-ux/architecture-suggestions.md +5 -6
  37. package/docs/slim-ux/concepts.md +23 -25
  38. package/docs/watches.md +7 -7
  39. package/docs/workspace-manifests.md +1 -1
  40. package/docs/worktree-lifecycle.md +1 -1
  41. package/engine/abandoned-pr-reconciliation.js +4 -5
  42. package/engine/ado-status.js +5 -5
  43. package/engine/ado.js +20 -25
  44. package/engine/agent-worker-pool.js +58 -1
  45. package/engine/bridge.js +260 -5
  46. package/engine/cleanup.js +48 -131
  47. package/engine/cli.js +125 -83
  48. package/engine/cooldown.js +9 -16
  49. package/engine/db/index.js +22 -9
  50. package/engine/db/migrations/009-qa.js +1 -1
  51. package/engine/db/migrations/020-qa-session-scopes.js +23 -0
  52. package/engine/db/migrations/021-archived-work-items.js +72 -0
  53. package/engine/db/migrations/022-global-cc-session.js +31 -0
  54. package/engine/db/migrations/023-engine-state.js +30 -0
  55. package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
  56. package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
  57. package/engine/dispatch-store.js +2 -7
  58. package/engine/dispatch.js +39 -44
  59. package/engine/github.js +20 -27
  60. package/engine/lifecycle.js +279 -354
  61. package/engine/live-checkout.js +193 -149
  62. package/engine/llm.js +1 -1
  63. package/engine/logs-store.js +2 -2
  64. package/engine/managed-spawn.js +2 -23
  65. package/engine/meeting.js +6 -6
  66. package/engine/metrics-store.js +2 -2
  67. package/engine/note-link-backfill.js +6 -11
  68. package/engine/pipeline.js +18 -36
  69. package/engine/playbook.js +13 -16
  70. package/engine/prd-store.js +73 -54
  71. package/engine/preflight.js +2 -5
  72. package/engine/projects.js +15 -62
  73. package/engine/pull-requests-store.js +0 -17
  74. package/engine/qa-runbooks.js +2 -2
  75. package/engine/qa-runs.js +1 -8
  76. package/engine/qa-sessions.js +41 -64
  77. package/engine/queries.js +120 -219
  78. package/engine/routing.js +4 -6
  79. package/engine/scheduler.js +0 -4
  80. package/engine/shared-branch-pr-reconcile.js +2 -3
  81. package/engine/shared.js +268 -699
  82. package/engine/small-state-store.js +89 -10
  83. package/engine/state-operations.js +16 -4
  84. package/engine/stdio-timestamps.js +1 -1
  85. package/engine/timeout.js +5 -12
  86. package/engine/watch-actions.js +20 -22
  87. package/engine/watches-store.js +1 -1
  88. package/engine/watches.js +6 -10
  89. package/engine/work-item-validation.js +52 -0
  90. package/engine/work-items-store.js +127 -29
  91. package/engine/worktree-gc.js +2 -2
  92. package/engine/worktree-pool.js +8 -18
  93. package/engine.js +197 -358
  94. package/minions.js +2 -2
  95. package/package.json +1 -1
  96. package/playbooks/plan-to-prd.md +2 -2
  97. package/playbooks/shared-rules.md +3 -3
  98. package/playbooks/templates/followup-dispatch.md +1 -1
  99. package/playbooks/verify.md +1 -1
  100. package/prompts/cc-system.md +9 -9
@@ -9,6 +9,7 @@ const fs = require('fs');
9
9
  const path = require('path');
10
10
  const shared = require('./shared');
11
11
  const dispatch = require('./dispatch');
12
+ const queries = require('./queries');
12
13
  const projectDiscovery = require('./project-discovery');
13
14
  const { MINIONS_DIR } = shared;
14
15
 
@@ -86,9 +87,8 @@ function _centralDispatchDefaultedToProject(d, removedProject, projects) {
86
87
  }
87
88
 
88
89
  function _collectProjectlessCentralDispatchItemIds(removedProject, projects) {
89
- const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
90
90
  const ids = new Set();
91
- const state = shared.safeJsonObj(dispatchPath);
91
+ const state = queries.getDispatch();
92
92
  for (const queue of ['pending', 'active']) {
93
93
  for (const d of Array.isArray(state?.[queue]) ? state[queue] : []) {
94
94
  if (_centralDispatchDefaultedToProject(d, removedProject, projects)) ids.add(d.meta.item.id);
@@ -99,9 +99,8 @@ function _collectProjectlessCentralDispatchItemIds(removedProject, projects) {
99
99
 
100
100
  function _requeueProjectlessCentralWorkItems(itemIds) {
101
101
  if (!itemIds || itemIds.size === 0) return 0;
102
- const wiPath = path.join(MINIONS_DIR, 'work-items.json');
103
102
  let requeued = 0;
104
- shared.mutateWorkItems(wiPath, items => {
103
+ shared.mutateWorkItems('central', items => {
105
104
  if (!Array.isArray(items)) return items;
106
105
  for (const w of items) {
107
106
  if (!itemIds.has(w?.id) || w.status !== shared.WI_STATUS.DISPATCHED) continue;
@@ -124,10 +123,7 @@ function _requeueProjectlessCentralWorkItems(itemIds) {
124
123
  * @param {object} [options]
125
124
  * @param {'archive'|'keep'|'purge'} [options.dataMode='archive']
126
125
  * archive: move projects/<name>/ to projects/.archived/<name>-YYYYMMDD/
127
- * keep: leave projects/<name>/ in place, but tombstone work-items.json /
128
- * pull-requests.json into projects/<name>/.dropped/ so dropScope()
129
- * (step 9) isn't immediately undone by the *-store.js resync-on-
130
- * read path re-hydrating the still-present JSON (W-mr96oira000g979a)
126
+ * keep: leave projects/<name>/ in place
131
127
  * purge: rm -rf projects/<name>/
132
128
  * @returns {object} summary { ok, project, cancelledItems, killedAgents,
133
129
  * drainedDispatches, cleanedWorktrees, disabledSchedules, archivedTo,
@@ -147,7 +143,6 @@ function removeProject(target, options = {}) {
147
143
  pipelineRefs: [],
148
144
  archivedTo: null,
149
145
  purgedDataDir: false,
150
- tombstonedDataFiles: false,
151
146
  // P-bfa2d-remove-project-cleanup — soft-delete tag counts for entries in
152
147
  // the three central state files that survived the hard-cancel passes
153
148
  // (steps 1, 2). See step 7.5 for the orphan-tag policy rationale.
@@ -168,15 +163,14 @@ function removeProject(target, options = {}) {
168
163
  }
169
164
  summary.project = { name: project.name, localPath: project.localPath };
170
165
 
171
- // 1. Cancel pending/queued work items linked to this project (project-local
172
- // file + central). Done items are preserved as history.
166
+ // 1. Cancel pending/queued work items linked to this project.
173
167
  summary.cancelledItems += dispatch.cancelPendingWorkItems(
174
- shared.projectWorkItemsPath(project),
168
+ project,
175
169
  () => true,
176
170
  'project-removed',
177
171
  );
178
172
  summary.cancelledItems += dispatch.cancelPendingWorkItems(
179
- path.join(MINIONS_DIR, 'work-items.json'),
173
+ 'central',
180
174
  w => _workItemMatchesProject(w, project, projects),
181
175
  'project-removed',
182
176
  );
@@ -190,7 +184,7 @@ function removeProject(target, options = {}) {
190
184
  _requeueProjectlessCentralWorkItems(projectlessCentralItemIds);
191
185
 
192
186
  // 2.5. Managed-spawn cleanup (P-8a4d6f29). Centralised in managed-spawn.js
193
- // so no other module needs to know about engine/managed-processes.json.
187
+ // so no other module needs to know the managed-process storage schema.
194
188
  // Kills + unlinks every spec owned by this project, including the .log
195
189
  // and .log.1 rotated sibling. Best-effort: failures only warn.
196
190
  try {
@@ -316,11 +310,8 @@ function removeProject(target, options = {}) {
316
310
  } catch (e) { writeError = e; }
317
311
  if (writeError) return { ...summary, error: 'Failed to write config: ' + writeError.message };
318
312
 
319
- // 7.5. P-bfa2d-remove-project-cleanup — Tag dangling refs in the three
320
- // CENTRAL state files (`pull-requests.json`, `work-items.json`,
321
- // `engine/dispatch.json`) so downstream consumers can filter or sweep
322
- // them. Project-local files in `projects/<name>/` are handled by
323
- // step 8 (archive or purge), so this step only touches central state.
313
+ // 7.5. Tag dangling refs in central state so downstream consumers can filter
314
+ // or sweep them. Project-scoped rows are removed in step 9.
324
315
  //
325
316
  // Policy is soft-delete (`_orphaned_at` + `_orphaned_project` tags)
326
317
  // rather than hard-delete: preserves history for debugging, lets users
@@ -330,17 +321,11 @@ function removeProject(target, options = {}) {
330
321
  // survivors (non-pending WIs, central PRs, defense-in-depth dispatch
331
322
  // survivors).
332
323
  //
333
- // Lock acquisition is sequential and uses the mutate-* helpers from
334
- // engine/dispatch.js and engine/shared.js so we never hold two file
335
- // locks simultaneously. Order: dispatch.json → pull-requests.json →
336
- // work-items.json (alphabetical by basename). Each helper acquires
337
- // its own lock, runs the synchronous mutator, then releases before
338
- // the next helper runs.
339
324
  try {
340
325
  const orphanedAt = new Date().toISOString();
341
326
  const orphanedProject = project.name;
342
327
 
343
- // (a) engine/dispatch.json — defense-in-depth. cleanDispatchEntries in
328
+ // (a) Dispatch state — defense-in-depth. cleanDispatchEntries in
344
329
  // step 2 should have already removed every matching entry; tag any
345
330
  // survivor so the operator can see the residue.
346
331
  try {
@@ -361,11 +346,10 @@ function removeProject(target, options = {}) {
361
346
 
362
347
  // (b) Central PR registry. PRs in this scope lack an explicit project field; match by canonical scope
363
348
  // (e.g. `github:owner/repo`) against the removed project's PR scope.
364
- const centralPrPath = shared.centralPullRequestsPath();
365
349
  try {
366
350
  const projectScope = shared.getProjectPrScope(project);
367
351
  if (projectScope) {
368
- shared.mutatePullRequests(centralPrPath, (prs) => {
352
+ shared.mutatePullRequests('central', (prs) => {
369
353
  for (const pr of prs) {
370
354
  if (!pr || pr._orphaned_at) continue;
371
355
  const canonical = shared.parseCanonicalPrId(pr.id);
@@ -383,9 +367,8 @@ function removeProject(target, options = {}) {
383
367
  // were already CANCELLED by cancelPendingWorkItems in step 1; tag
384
368
  // the remaining matchers (dispatched / done / failed / etc.) so
385
369
  // downstream consumers can filter them.
386
- const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
387
370
  try {
388
- shared.mutateWorkItems(centralWiPath, (items) => {
371
+ shared.mutateWorkItems('central', (items) => {
389
372
  for (const w of items) {
390
373
  if (!w || w._orphaned_at) continue;
391
374
  if (w.status === shared.WI_STATUS.CANCELLED) continue;
@@ -424,40 +407,10 @@ function removeProject(target, options = {}) {
424
407
  summary.archiveError = e.code ? `${e.code}: ${e.message}` : e.message;
425
408
  summary.warnings.push('archive data dir: ' + e.message);
426
409
  }
427
- } else if (dataMode === 'keep') {
428
- // W-mr96oira000g979a — 'keep' leaves projects/<name>/ (and therefore
429
- // work-items.json / pull-requests.json) at its normal, live-resolving
430
- // path. dropScope() below DELETEs the SQL rows for this scope, but the
431
- // *-store.js resync-on-read path (_resyncScopeIfJsonDiverged) treats a
432
- // scope with an empty SQL table + a still-present JSON file as a
433
- // fresh-install first-touch and re-hydrates straight from that JSON —
434
- // silently undoing the drop the very next time anything reads this
435
- // scope. Tombstone the two live-state files into a `.dropped/`
436
- // subdirectory so `_filePathForScope(scope)` (and the read-side
437
- // resync/fallback logic keyed off it) no longer resolves to them,
438
- // while keeping the historical data on disk for manual inspection —
439
- // exactly what 'keep' promises, minus the auto-resurrection.
440
- try {
441
- const droppedDir = path.join(dataDir, '.dropped');
442
- const stamp = new Date().toISOString().replace(/[:.]/g, '-');
443
- for (const fname of ['work-items.json', 'pull-requests.json']) {
444
- const src = path.join(dataDir, fname);
445
- if (!fs.existsSync(src)) continue;
446
- fs.mkdirSync(droppedDir, { recursive: true });
447
- let dest = path.join(droppedDir, `${fname}.${stamp}`);
448
- let n = 1;
449
- while (fs.existsSync(dest)) dest = path.join(droppedDir, `${fname}.${stamp}-${++n}`);
450
- _renameWithRetry(src, dest);
451
- summary.tombstonedDataFiles = true;
452
- }
453
- } catch (e) {
454
- summary.warnings.push('tombstone kept data files: ' + e.message);
455
- }
456
410
  }
457
411
  }
458
412
 
459
- // 9. Drop SQL rows for the project's scope so safeJson shim reads no longer
460
- // surface the removed project after the JSON file is archived.
413
+ // 9. Drop SQL rows for the removed project scope.
461
414
  try { require('./work-items-store').dropScope(project.name); } catch { /* best-effort */ }
462
415
  try { require('./pull-requests-store').dropScope(project.name); } catch { /* best-effort */ }
463
416
 
@@ -571,7 +524,7 @@ async function addProject(target, options = {}) {
571
524
  assertProjectLinkOk(project);
572
525
 
573
526
  // 7. Ensure project state files (projects/<name>/...).
574
- shared.ensureProjectStateFiles(project);
527
+ shared.initializeProjectState(project);
575
528
 
576
529
  // 8. Persist under config lock. Re-check inside the lock so a concurrent
577
530
  // add can't insert a duplicate between our pre-check and the write.
@@ -13,21 +13,6 @@ function _toMs(v) {
13
13
  return Number.isFinite(parsed) ? parsed : null;
14
14
  }
15
15
 
16
- function scopeForFilePath(filePath) {
17
- const norm = String(filePath).replace(/\\/g, '/');
18
- const m = norm.match(/projects\/([^/]+)\/pull-requests\.json$/);
19
- if (m) return m[1];
20
- return 'central';
21
- }
22
-
23
- // Compatibility adapter for callers that still pass a path-shaped scope token
24
- // into shared.mutatePullRequests. No file is read or written.
25
- function _filePathForScope(scope) {
26
- const shared = require('./shared');
27
- if (!scope || scope === 'central') return shared.centralPullRequestsPath(shared.MINIONS_DIR);
28
- return require('path').join(shared.MINIONS_DIR, 'projects', scope, 'pull-requests.json');
29
- }
30
-
31
16
  function _parseRow(row) {
32
17
  if (!row || !row.data) return null;
33
18
  try { return JSON.parse(row.data); }
@@ -169,8 +154,6 @@ function dropScope(scope) {
169
154
  }
170
155
 
171
156
  module.exports = {
172
- _filePathForScope,
173
- scopeForFilePath,
174
157
  readPullRequestsForScope,
175
158
  readAllPullRequests,
176
159
  applyPullRequestsMutation,
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Per-project QA runbook persistence + CRUD helpers. Runbooks are test plans
5
5
  * that travel with a project entry, mirroring the
6
- * projects/<name>/pull-requests.json precedent. Each runbook is one JSON file
6
+ * project-local definition precedent. Each runbook is one JSON file
7
7
  * at <MINIONS_DIR>/projects/<project>/runbooks/<id>.json.
8
8
  *
9
9
  * Pure persistence + validation only — this module does NOT spawn agents,
@@ -27,7 +27,7 @@ const _KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
27
27
  // keep this module self-contained for path-traversal hardening (review feedback
28
28
  // on PR #2694: id/project params previously flowed into path.join without
29
29
  // validation, so `..%2F..%2F..%2Fconfig` could read MINIONS_DIR/config.json
30
- // and DELETE could wipe dispatch.json).
30
+ // and DELETE could wipe unrelated runtime state).
31
31
  const _PROJECT_NAME_RE = /^[a-zA-Z0-9_\-]{1,64}$/;
32
32
 
33
33
  const ARTIFACT_TYPES = ['screenshot', 'video', 'log', 'other'];
package/engine/qa-runs.js CHANGED
@@ -33,7 +33,7 @@ const { mutateQaRuns, uid, ts, log } = shared;
33
33
 
34
34
  // Cap qa_runs so the table doesn't grow unboundedly
35
35
  // over months of nightly QA dispatch. Mirrors the 2500-entry cap on
36
- // engine/log.json. createRun trims oldest-by-createdAt when crossing the
36
+ // the engine log. createRun trims oldest-by-createdAt when crossing the
37
37
  // threshold; terminal-status runs that have already shipped completion
38
38
  // notifications are safe to drop.
39
39
  const QA_RUNS_MAX_RECORDS = 2000;
@@ -80,12 +80,6 @@ const ALLOWED_TRANSITIONS = {
80
80
  [QA_RUN_STATUS.ERRORED]: new Set(),
81
81
  };
82
82
 
83
- // Dynamic paths — respect MINIONS_TEST_DIR for test isolation. Kept exported
84
- // for back-compat with tests that probe the JSON sidecar on disk.
85
- function qaRunsPath() {
86
- return path.join(shared.MINIONS_DIR, 'engine', 'qa-runs.json');
87
- }
88
-
89
83
  function qaArtifactsDir() {
90
84
  return path.join(shared.MINIONS_DIR, 'engine', 'qa-artifacts');
91
85
  }
@@ -416,7 +410,6 @@ module.exports = {
416
410
  QA_RUN_STATUS,
417
411
  TERMINAL_STATUSES,
418
412
  ALLOWED_TRANSITIONS,
419
- qaRunsPath,
420
413
  qaArtifactsDir,
421
414
  qaArtifactsDirForRun,
422
415
  createRun,
@@ -164,13 +164,6 @@ function _isSafeSessionId(id) {
164
164
  return _isNonEmptyString(id) && id.length <= LIMITS.idMax && _KEBAB_RE.test(id);
165
165
  }
166
166
 
167
- // Dynamic paths — respect MINIONS_TEST_DIR for test isolation. shared.MINIONS_DIR
168
- // resolves at every call so MINIONS_TEST_DIR=foo flips the resolution
169
- // without re-requiring this module (mirrors qa-runs.js pattern).
170
- function qaSessionsPath() {
171
- return path.join(shared.MINIONS_DIR, 'engine', 'qa-sessions.json');
172
- }
173
-
174
167
  function qaTestsDir() {
175
168
  return path.join(shared.MINIONS_DIR, 'engine', 'qa-tests');
176
169
  }
@@ -332,11 +325,11 @@ function validateSpec(spec) {
332
325
  // ── CRUD ────────────────────────────────────────────────────────────────────
333
326
 
334
327
  /**
335
- * Create a session in `pending` state and persist it to qa-sessions.json.
328
+ * Create a session in `pending` state and persist it to SQL.
336
329
  *
337
330
  * The caller (POST /api/qa/session handler) is responsible for queuing the
338
331
  * SETUP work item via buildSetupWorkItem() + the standard work-items/dispatch
339
- * flow. createSession() intentionally does NOT touch dispatch.json so the
332
+ * flow. createSession() intentionally does not touch dispatch state so the
340
333
  * pure persistence layer stays unit-testable without standing up the whole
341
334
  * engine.
342
335
  *
@@ -407,10 +400,8 @@ function createSession(spec) {
407
400
  // 1 (multi-project fan-out). Single-project sessions skip this map and
408
401
  // use session.state directly.
409
402
  setupStatus: null,
410
- // primaryWiPath is stamped at queueSetup time so handleSetupComplete can
411
- // queue DRAFT on the right work-items file without re-resolving the
412
- // project through dashboard config.
413
- primaryWiPath: null,
403
+ // Stamped at queueSetup time so DRAFT stays in the primary scope.
404
+ primaryScope: null,
414
405
  // Per-phase WI links — back-filled by setSessionWorkItem when the
415
406
  // dashboard endpoint or lifecycle hook queues the next phase.
416
407
  workItems: { setup: null, draft: null, execute: null },
@@ -641,10 +632,10 @@ function transitionSession(id, toState, patch = {}) {
641
632
  if (patch && typeof patch === 'object' && !Array.isArray(patch)) {
642
633
  // Whitelist mutable fields to keep transitionSession from rewriting
643
634
  // immutable spec/createdAt fields by mistake.
644
- // W-mpq6xqzj000606d0 — added `setupStatus` and `primaryWiPath` so the
635
+ // W-mpq6xqzj000606d0 — `setupStatus` and `primaryScope` let the
645
636
  // multi-project fan-in handler can stamp per-project completion state
646
637
  // through the same locked path used by every other mutation.
647
- for (const field of ['summary', 'error', 'failureClass', 'testFile', 'qaRunId', 'managedSpawnHealth', 'setupStatus', 'primaryWiPath']) {
638
+ for (const field of ['summary', 'error', 'failureClass', 'testFile', 'qaRunId', 'managedSpawnHealth', 'setupStatus', 'primaryScope']) {
648
639
  if (Object.prototype.hasOwnProperty.call(patch, field)) {
649
640
  session[field] = patch[field];
650
641
  }
@@ -761,7 +752,7 @@ function _baseWorkItem(session, phase, { title, description, project, primary, c
761
752
  * W-mpq6xqzj000606d0 — When the session has multiple projects (fan-out),
762
753
  * `primary` distinguishes the orchestrator WI (true; carries the full
763
754
  * coServices list) from each co-service WI (false; coServices === []). The
764
- * session's DRAFT phase keys off the primary's wiPath.
755
+ * session's DRAFT phase keys off the primary scope.
765
756
  */
766
757
  function buildSetupWorkItem(session, { project, primary, coServices, primaryProject } = {}) {
767
758
  const isPrimary = primary === undefined ? true : !!primary;
@@ -881,12 +872,8 @@ function _summarizeTarget(target) {
881
872
  // dispatch + work-items are lazy-required inside _queueWorkItem to keep
882
873
  // `require('./qa-sessions')` cycle-safe at the top of lifecycle.js.
883
874
 
884
- function _queueWorkItem(wi, wiPath) {
885
- // Append the WI to the project (or central) work-items file, then queue a
886
- // dispatch entry that wraps it. Mirrors the qa-validate flow at
887
- // dashboard.js handleQaRunbookRun (line 9985+). Both writes go through their
888
- // module-internal locks so concurrent dashboard calls don't lose entries.
889
- shared.mutateWorkItems(wiPath, (items) => {
875
+ function _queueWorkItem(wi, scope) {
876
+ shared.mutateWorkItems(scope, (items) => {
890
877
  if (!Array.isArray(items)) items = [];
891
878
  if (!items.some(i => i && i.id === wi.id)) items.push(wi);
892
879
  return items;
@@ -905,35 +892,27 @@ function _queueWorkItem(wi, wiPath) {
905
892
  * Validates pending → spawning, queues the SETUP WI(s), returns the queued
906
893
  * primary WI id (back-compat).
907
894
  *
908
- * W-mpq6xqzj000606d0 — multi-project fan-out. Two calling shapes are
909
- * accepted:
910
- * 1. Legacy single-target: `{ wiPath, project }` (project may be null for
911
- * central). Builds and queues one SETUP WI exactly like before.
912
- * 2. Multi-target: `{ resolvedTargets: [{ project, wiPath }, ...] }`. The
913
- * FIRST entry is treated as the primary (drives DRAFT/EXECUTE later);
914
- * the rest are co-services. One SETUP WI is queued per entry, and
915
- * `session.setupStatus` is initialized with a `pending` entry per
916
- * project so handleSetupComplete can fan-in.
895
+ * W-mpq6xqzj000606d0 — multi-project fan-out. The first target drives
896
+ * DRAFT/EXECUTE; the rest are co-services.
917
897
  *
918
898
  * @param {string} sessionId
919
899
  * @param {object} opts
920
- * @param {string} [opts.wiPath] - legacy single-target wiPath
921
- * @param {string} [opts.project] - legacy single-target project name
922
- * @param {Array<{project:?string, wiPath:string}>} [opts.resolvedTargets]
900
+ * @param {string} [opts.scope]
901
+ * @param {string} [opts.project]
902
+ * @param {Array<{project:?string, scope:string}>} [opts.resolvedTargets]
923
903
  * @returns {string} the queued PRIMARY SETUP WI id
924
904
  */
925
905
  function queueSetup(sessionId, opts = {}) {
926
906
  let targets = Array.isArray(opts.resolvedTargets) ? opts.resolvedTargets.slice() : null;
927
907
  if (!targets || targets.length === 0) {
928
- if (!_isNonEmptyString(opts.wiPath)) {
929
- throw new Error('qa-sessions: queueSetup requires wiPath or resolvedTargets');
908
+ if (!_isNonEmptyString(opts.scope)) {
909
+ throw new Error('qa-sessions: queueSetup requires scope or resolvedTargets');
930
910
  }
931
- targets = [{ project: opts.project || null, wiPath: opts.wiPath }];
911
+ targets = [{ project: opts.project || null, scope: opts.scope }];
932
912
  }
933
- // Defensive: each target needs a wiPath. project may be null for central.
934
913
  for (const t of targets) {
935
- if (!t || !_isNonEmptyString(t.wiPath)) {
936
- throw new Error('qa-sessions: queueSetup target missing wiPath');
914
+ if (!t || !_isNonEmptyString(t.scope)) {
915
+ throw new Error('qa-sessions: queueSetup target missing scope');
937
916
  }
938
917
  }
939
918
  if (targets.length > LIMITS.projectsMax) {
@@ -947,7 +926,7 @@ function queueSetup(sessionId, opts = {}) {
947
926
  const coServiceProjects = targets.slice(1).map(t => t.project || null).filter(Boolean);
948
927
  const isMulti = targets.length > 1;
949
928
 
950
- // Initialize setupStatus + primaryWiPath BEFORE transitioning so the
929
+ // Initialize setupStatus + primaryScope BEFORE transitioning so the
951
930
  // fan-in handler sees the full project list even on a racing completion.
952
931
  // Single-project sessions skip the map to keep blast radius minimal.
953
932
  const setupStatusPatch = isMulti
@@ -964,7 +943,7 @@ function queueSetup(sessionId, opts = {}) {
964
943
  // double-queueing.
965
944
  markSpawning(sessionId, {
966
945
  setupStatus: setupStatusPatch,
967
- primaryWiPath: primaryTarget.wiPath,
946
+ primaryScope: primaryTarget.scope,
968
947
  });
969
948
 
970
949
  // Build + queue one WI per target. Primary is index 0; coServices is the
@@ -981,7 +960,7 @@ function queueSetup(sessionId, opts = {}) {
981
960
  coServices: isPrimary ? coServiceProjects : [],
982
961
  primaryProject: primaryTarget.project || null,
983
962
  });
984
- _queueWorkItem(wi, t.wiPath);
963
+ _queueWorkItem(wi, t.scope);
985
964
  builtWiIds[t.project || '__central__'] = wi.id;
986
965
  if (isPrimary) primaryWiId = wi.id;
987
966
  }
@@ -1026,7 +1005,7 @@ function queueSetup(sessionId, opts = {}) {
1026
1005
  * @param {string} sessionId
1027
1006
  * @param {object} opts
1028
1007
  * @param {boolean} opts.success
1029
- * @param {string} [opts.wiPath] - required when success=true (single-project) OR for the PRIMARY target on multi-project DRAFT queueing
1008
+ * @param {string} [opts.scope] - required when success=true
1030
1009
  * @param {string} [opts.project] - which project the completing WI was for (multi-project routing key)
1031
1010
  * @param {string} [opts.failureClass]
1032
1011
  * @param {string} [opts.reason]
@@ -1043,13 +1022,13 @@ function handleSetupComplete(sessionId, opts = {}) {
1043
1022
  // ── Single-project fast path: original behavior, untouched semantics ──
1044
1023
  if (!isMulti) {
1045
1024
  if (opts.success) {
1046
- if (!_isNonEmptyString(opts.wiPath)) {
1047
- throw new Error('qa-sessions: handleSetupComplete success requires wiPath');
1025
+ if (!_isNonEmptyString(opts.scope)) {
1026
+ throw new Error('qa-sessions: handleSetupComplete success requires scope');
1048
1027
  }
1049
1028
  markDrafting(sessionId, { managedSpawnHealth: 'healthy' });
1050
1029
  const updated = getSession(sessionId);
1051
1030
  const wi = buildDraftWorkItem(updated, { project: opts.project || updated.spec.project || null });
1052
- _queueWorkItem(wi, opts.wiPath);
1031
+ _queueWorkItem(wi, opts.scope);
1053
1032
  setSessionWorkItem(sessionId, SESSION_PHASE.DRAFT, wi.id);
1054
1033
  return wi.id;
1055
1034
  }
@@ -1112,17 +1091,16 @@ function handleSetupComplete(sessionId, opts = {}) {
1112
1091
  return null;
1113
1092
  }
1114
1093
 
1115
- // All success → advance to drafting and queue DRAFT on the PRIMARY's
1116
- // wiPath (captured at queueSetup time).
1094
+ // All success → queue DRAFT on the primary scope captured by queueSetup.
1117
1095
  const fresh = getSession(sessionId);
1118
- const draftWiPath = fresh && fresh.primaryWiPath;
1119
- if (!_isNonEmptyString(draftWiPath)) {
1120
- throw new Error('qa-sessions: handleSetupComplete multi-project missing session.primaryWiPath');
1096
+ const draftScope = fresh && fresh.primaryScope;
1097
+ if (!_isNonEmptyString(draftScope)) {
1098
+ throw new Error('qa-sessions: handleSetupComplete multi-project missing session.primaryScope');
1121
1099
  }
1122
1100
  markDrafting(sessionId, { managedSpawnHealth: 'healthy' });
1123
1101
  const updated = getSession(sessionId);
1124
1102
  const wi = buildDraftWorkItem(updated, { project: updated.primaryProject || updated.spec.project || null });
1125
- _queueWorkItem(wi, draftWiPath);
1103
+ _queueWorkItem(wi, draftScope);
1126
1104
  setSessionWorkItem(sessionId, SESSION_PHASE.DRAFT, wi.id);
1127
1105
  return wi.id;
1128
1106
  }
@@ -1136,7 +1114,7 @@ function handleSetupComplete(sessionId, opts = {}) {
1136
1114
  * @param {object} opts
1137
1115
  * @param {boolean} opts.success
1138
1116
  * @param {string} [opts.testFile] - relative path under qa-tests/<id>/, captured for EXECUTE
1139
- * @param {string} [opts.wiPath] - required when success=true and mode=auto
1117
+ * @param {string} [opts.scope] - required when success=true and mode=auto
1140
1118
  * @param {string} [opts.project]
1141
1119
  * @param {string} [opts.qaRunId] - required when success=true and mode=auto (caller creates the qa-runs record)
1142
1120
  * @param {string} [opts.reason]
@@ -1158,8 +1136,8 @@ function handleDraftComplete(sessionId, opts = {}) {
1158
1136
  }
1159
1137
  const testFilePatch = opts.testFile ? { testFile: opts.testFile } : {};
1160
1138
  if (session.spec.mode === 'auto') {
1161
- if (!_isNonEmptyString(opts.wiPath)) {
1162
- throw new Error('qa-sessions: handleDraftComplete (auto) requires wiPath');
1139
+ if (!_isNonEmptyString(opts.scope)) {
1140
+ throw new Error('qa-sessions: handleDraftComplete (auto) requires scope');
1163
1141
  }
1164
1142
  if (!_isNonEmptyString(opts.qaRunId)) {
1165
1143
  throw new Error('qa-sessions: handleDraftComplete (auto) requires qaRunId');
@@ -1170,7 +1148,7 @@ function handleDraftComplete(sessionId, opts = {}) {
1170
1148
  qaRunId: opts.qaRunId,
1171
1149
  project: opts.project || updated.spec.project || null,
1172
1150
  });
1173
- _queueWorkItem(wi, opts.wiPath);
1151
+ _queueWorkItem(wi, opts.scope);
1174
1152
  setSessionWorkItem(sessionId, SESSION_PHASE.EXECUTE, wi.id);
1175
1153
  return { nextState: QA_SESSION_STATE.EXECUTING, queuedExecuteWi: wi.id };
1176
1154
  }
@@ -1227,8 +1205,8 @@ function handleExecuteComplete(sessionId, opts = {}) {
1227
1205
  * POST /api/qa/sessions/<id>/approve — awaiting-approval → executing, queues
1228
1206
  * the EXECUTE WI. Caller creates the qa-runs record and passes its id.
1229
1207
  */
1230
- function approveDraft(sessionId, { wiPath, qaRunId, project } = {}) {
1231
- if (!_isNonEmptyString(wiPath)) throw new Error('qa-sessions: approveDraft requires wiPath');
1208
+ function approveDraft(sessionId, { scope, qaRunId, project } = {}) {
1209
+ if (!_isNonEmptyString(scope)) throw new Error('qa-sessions: approveDraft requires scope');
1232
1210
  if (!_isNonEmptyString(qaRunId)) throw new Error('qa-sessions: approveDraft requires qaRunId');
1233
1211
  const session = getSession(sessionId);
1234
1212
  if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
@@ -1241,7 +1219,7 @@ function approveDraft(sessionId, { wiPath, qaRunId, project } = {}) {
1241
1219
  qaRunId,
1242
1220
  project: project || updated.spec.project || null,
1243
1221
  });
1244
- _queueWorkItem(wi, wiPath);
1222
+ _queueWorkItem(wi, scope);
1245
1223
  setSessionWorkItem(sessionId, SESSION_PHASE.EXECUTE, wi.id);
1246
1224
  return wi.id;
1247
1225
  }
@@ -1250,8 +1228,8 @@ function approveDraft(sessionId, { wiPath, qaRunId, project } = {}) {
1250
1228
  * POST /api/qa/sessions/<id>/edit — awaiting-approval → drafting, re-queue the
1251
1229
  * DRAFT WI with the user's natural-language feedback threaded into the prompt.
1252
1230
  */
1253
- function editDraft(sessionId, { wiPath, feedback, project } = {}) {
1254
- if (!_isNonEmptyString(wiPath)) throw new Error('qa-sessions: editDraft requires wiPath');
1231
+ function editDraft(sessionId, { scope, feedback, project } = {}) {
1232
+ if (!_isNonEmptyString(scope)) throw new Error('qa-sessions: editDraft requires scope');
1255
1233
  if (!_isNonEmptyString(feedback)) throw new Error('qa-sessions: editDraft requires feedback');
1256
1234
  if (feedback.length > LIMITS.feedbackMax) {
1257
1235
  throw new Error(`qa-sessions: editDraft feedback exceeds ${LIMITS.feedbackMax} chars`);
@@ -1267,7 +1245,7 @@ function editDraft(sessionId, { wiPath, feedback, project } = {}) {
1267
1245
  project: project || updated.spec.project || null,
1268
1246
  feedback,
1269
1247
  });
1270
- _queueWorkItem(wi, wiPath);
1248
+ _queueWorkItem(wi, scope);
1271
1249
  setSessionWorkItem(sessionId, SESSION_PHASE.DRAFT, wi.id);
1272
1250
  return wi.id;
1273
1251
  }
@@ -1365,7 +1343,6 @@ module.exports = {
1365
1343
  LIMITS,
1366
1344
  QA_SESSIONS_MAX_RECORDS,
1367
1345
  // Paths
1368
- qaSessionsPath,
1369
1346
  qaTestsDir,
1370
1347
  qaTestsDirForSession,
1371
1348
  // Validation