@yemi33/minions 0.1.2424 → 0.1.2426

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 (185) hide show
  1. package/bin/install-internal-minions.js +1209 -0
  2. package/bin/minions.js +75 -9
  3. package/dashboard/docs/typography.md +27 -12
  4. package/dashboard/js/command-center.js +13 -11
  5. package/dashboard/js/live-stream.js +1 -1
  6. package/dashboard/js/memory-search.js +388 -43
  7. package/dashboard/js/modal-qa.js +5 -5
  8. package/dashboard/js/qa.js +39 -26
  9. package/dashboard/js/refresh.js +37 -18
  10. package/dashboard/js/render-agents.js +14 -18
  11. package/dashboard/js/render-dispatch.js +14 -74
  12. package/dashboard/js/render-inbox.js +3 -3
  13. package/dashboard/js/render-meetings.js +8 -8
  14. package/dashboard/js/render-other.js +45 -20
  15. package/dashboard/js/render-pipelines.js +6 -6
  16. package/dashboard/js/render-plans.js +14 -14
  17. package/dashboard/js/render-prd.js +46 -46
  18. package/dashboard/js/render-prs.js +9 -65
  19. package/dashboard/js/render-schedules.js +5 -5
  20. package/dashboard/js/render-skills.js +6 -6
  21. package/dashboard/js/render-utils.js +4 -4
  22. package/dashboard/js/render-watches.js +4 -16
  23. package/dashboard/js/render-work-items.js +95 -107
  24. package/dashboard/js/settings.js +175 -74
  25. package/dashboard/layout.html +3 -2
  26. package/dashboard/pages/inbox.html +1 -1
  27. package/dashboard/pages/work.html +1 -1
  28. package/dashboard/shared/model-display.js +9 -0
  29. package/dashboard/shared/watches-source.js +43 -0
  30. package/dashboard/shared/welcome-popup.js +259 -0
  31. package/dashboard/slim/body.html +6 -6
  32. package/dashboard/slim/js/chat.js +4 -2
  33. package/dashboard/slim/js/history.js +10 -3
  34. package/dashboard/slim/js/modals-tiles.js +85 -16
  35. package/dashboard/slim/js/status.js +12 -16
  36. package/dashboard/slim/styles.css +24 -4
  37. package/dashboard/styles.css +62 -27
  38. package/dashboard-build.js +8 -2
  39. package/dashboard.js +3854 -2514
  40. package/docs/README.md +7 -5
  41. package/docs/api-errors.md +144 -0
  42. package/docs/auto-discovery.md +83 -61
  43. package/docs/capture-demos.js +173 -30
  44. package/docs/command-center.md +5 -1
  45. package/docs/completion-reports.md +127 -11
  46. package/docs/constants.md +15 -3
  47. package/docs/constellation-style-telemetry.md +2 -2
  48. package/docs/copilot-cli-schema.md +32 -22
  49. package/docs/cross-repo-plans.md +30 -30
  50. package/docs/demo/memory-system.html +1 -1
  51. package/docs/deprecated.json +132 -18
  52. package/docs/diagnostics-memory.md +8 -4
  53. package/docs/human-vs-automated.md +2 -2
  54. package/docs/index.html +9 -2
  55. package/docs/internal-install.md +212 -0
  56. package/docs/kb-pr3223-cascade-archiving.md +16 -0
  57. package/docs/kb-pr696-merge-conflict-docs.md +23 -0
  58. package/docs/kb-sweep.md +2 -2
  59. package/docs/keep-processes.md +8 -1
  60. package/docs/live-checkout-mode.md +1 -1
  61. package/docs/managed-spawn.md +21 -3
  62. package/docs/named-agents.md +3 -2
  63. package/docs/onboarding.md +26 -0
  64. package/docs/plan-lifecycle.md +32 -32
  65. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
  66. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
  67. package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
  68. package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
  69. package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
  70. package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
  71. package/docs/qa-runbook-lifecycle.md +67 -51
  72. package/docs/qa-runbooks.md +2 -2
  73. package/docs/runtime-adapters.md +101 -50
  74. package/docs/security.md +28 -0
  75. package/docs/self-improvement.md +47 -13
  76. package/docs/skills.md +29 -0
  77. package/docs/slim-ux/concepts.md +2 -2
  78. package/docs/specs/agent-configurability.md +43 -41
  79. package/docs/specs/agent-rename.md +13 -13
  80. package/docs/team-memory.md +283 -14
  81. package/docs/tutorials/01-install-and-connect.md +23 -0
  82. package/docs/tutorials/03-plan-driven-feature.md +3 -3
  83. package/docs/tutorials/04-runtimes-and-harness.md +18 -21
  84. package/docs/tutorials/08-operations-and-recovery.md +3 -2
  85. package/docs/tutorials/README.md +4 -3
  86. package/docs/visual-evidence-ci.md +103 -0
  87. package/docs/watches.md +35 -30
  88. package/docs/worktree-lifecycle.md +151 -13
  89. package/engine/acp-transport.js +1 -0
  90. package/engine/ado.js +80 -33
  91. package/engine/agent-api-validation.js +571 -0
  92. package/engine/agent-worker-pool.js +7 -6
  93. package/engine/api-contracts/agent-content.js +761 -0
  94. package/engine/api-contracts/cc-ops.js +219 -0
  95. package/engine/api-contracts/config-runtime.js +419 -0
  96. package/engine/api-contracts/core.js +138 -0
  97. package/engine/api-contracts/index.js +506 -0
  98. package/engine/api-contracts/orchestration.js +602 -0
  99. package/engine/api-contracts/pull-requests.js +323 -0
  100. package/engine/api-contracts/qa-process.js +764 -0
  101. package/engine/api-contracts/work-plan-prd.js +494 -0
  102. package/engine/api-validation.js +543 -0
  103. package/engine/cc-api-validation.js +586 -0
  104. package/engine/cc-worker-pool.js +9 -1
  105. package/engine/cleanup.js +393 -271
  106. package/engine/cli.js +65 -7
  107. package/engine/comment-classifier.js +70 -5
  108. package/engine/consolidation.js +123 -5
  109. package/engine/content-api-validation.js +606 -0
  110. package/engine/create-pr-worktree.js +22 -1
  111. package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
  112. package/engine/dispatch.js +33 -0
  113. package/engine/distribution.js +189 -0
  114. package/engine/execution-model.js +59 -0
  115. package/engine/features.js +12 -0
  116. package/engine/github.js +67 -25
  117. package/engine/harness-context.js +313 -0
  118. package/engine/inbox-store.js +1 -1
  119. package/engine/kb-sweep.js +161 -38
  120. package/engine/keep-process-sweep.js +43 -3
  121. package/engine/lifecycle.js +1066 -165
  122. package/engine/llm.js +4 -0
  123. package/engine/managed-spawn.js +463 -48
  124. package/engine/meeting.js +197 -34
  125. package/engine/memory-retrieval.js +280 -0
  126. package/engine/memory-store.js +821 -38
  127. package/engine/model-discovery.js +17 -3
  128. package/engine/pipeline.js +802 -14
  129. package/engine/plan-prd-validation.js +745 -0
  130. package/engine/playbook.js +206 -17
  131. package/engine/pooled-agent-process.js +6 -1
  132. package/engine/pr-action.js +1 -1
  133. package/engine/pr-issue-validation.js +653 -0
  134. package/engine/pr-resolve.js +75 -13
  135. package/engine/prd-store.js +39 -6
  136. package/engine/preflight.js +7 -2
  137. package/engine/process-utils.js +79 -31
  138. package/engine/projects.js +198 -12
  139. package/engine/promotion.js +371 -0
  140. package/engine/qa-from-prd.js +3 -0
  141. package/engine/qa-process-validation.js +572 -0
  142. package/engine/qa-runbooks.js +41 -37
  143. package/engine/qa-runners/maestro.js +11 -7
  144. package/engine/qa-runners/playwright.js +37 -14
  145. package/engine/qa-runners.js +9 -10
  146. package/engine/qa-runs.js +124 -11
  147. package/engine/qa-sessions.js +139 -40
  148. package/engine/quarantine-refs.js +81 -11
  149. package/engine/queries.js +163 -9
  150. package/engine/review-learning-backfill.js +482 -0
  151. package/engine/review-learning.js +1236 -0
  152. package/engine/runtimes/claude.js +60 -2
  153. package/engine/runtimes/codex.js +57 -16
  154. package/engine/runtimes/contract.js +21 -0
  155. package/engine/runtimes/copilot.js +51 -3
  156. package/engine/runtimes/index.js +1 -0
  157. package/engine/scheduler.js +201 -32
  158. package/engine/settings-validation.js +909 -0
  159. package/engine/shared.js +705 -208
  160. package/engine/spawn-agent.js +48 -21
  161. package/engine/steering-constraints.js +31 -0
  162. package/engine/steering-store.js +10 -2
  163. package/engine/steering.js +14 -2
  164. package/engine/timeout.js +404 -111
  165. package/engine/untrusted-fence.js +24 -10
  166. package/engine/watch-actions.js +7 -2
  167. package/engine/watches.js +353 -51
  168. package/engine/work-item-validation.js +561 -0
  169. package/engine/work-items-store.js +61 -1
  170. package/engine/worktree-gc.js +656 -37
  171. package/engine/worktree-preflight.js +154 -0
  172. package/engine.js +2068 -342
  173. package/package.json +1 -1
  174. package/playbooks/_pr-description-audit.md +88 -41
  175. package/playbooks/build-fix-complex.md +3 -3
  176. package/playbooks/docs.md +2 -2
  177. package/playbooks/fix.md +18 -18
  178. package/playbooks/implement.md +18 -18
  179. package/playbooks/qa-session-draft.md +10 -12
  180. package/playbooks/qa-session-execute.md +16 -12
  181. package/playbooks/qa-validate.md +8 -7
  182. package/playbooks/shared-rules.md +45 -0
  183. package/playbooks/test.md +4 -0
  184. package/prompts/cc-system.md +1 -1
  185. package/skills/check-self-authored-review-comment/SKILL.md +2 -2
package/engine/shared.js CHANGED
@@ -920,9 +920,6 @@ function safeJsonNoRestore(p, defaultValue = null) {
920
920
  * avoid temp file name collisions.
921
921
  */
922
922
  let _tmpCounter = 0;
923
- const SAFE_WRITE_RENAME_ATTEMPTS = 20;
924
- const SAFE_WRITE_RENAME_RETRY_BASE_MS = 50;
925
- const SAFE_WRITE_RENAME_RETRY_MAX_MS = 250;
926
923
 
927
924
  function safeWrite(p, data) {
928
925
  const dir = path.dirname(p);
@@ -932,16 +929,13 @@ function safeWrite(p, data) {
932
929
  try {
933
930
  fs.writeFileSync(tmp, content);
934
931
  // Atomic rename — retry on Windows EPERM (file locking)
935
- for (let attempt = 0; attempt < SAFE_WRITE_RENAME_ATTEMPTS; attempt++) {
932
+ for (let attempt = 0; attempt < 5; attempt++) {
936
933
  try {
937
934
  fs.renameSync(tmp, p);
938
935
  return;
939
936
  } catch (e) {
940
- if (e.code === 'EPERM' && attempt < SAFE_WRITE_RENAME_ATTEMPTS - 1) {
941
- const delay = Math.min(
942
- SAFE_WRITE_RENAME_RETRY_BASE_MS * (attempt + 1),
943
- SAFE_WRITE_RENAME_RETRY_MAX_MS
944
- );
937
+ if (e.code === 'EPERM' && attempt < 4) {
938
+ const delay = 50 * (attempt + 1); // 50, 100, 150, 200ms
945
939
  sleepMs(delay);
946
940
  continue;
947
941
  }
@@ -952,7 +946,7 @@ function safeWrite(p, data) {
952
946
  }
953
947
  // All rename attempts exhausted without throw — should not happen, but clean up
954
948
  try { fs.unlinkSync(tmp); } catch { /* cleanup */ }
955
- throw new Error(`[safeWrite] All ${SAFE_WRITE_RENAME_ATTEMPTS} rename attempts failed for ${p}`);
949
+ throw new Error(`[safeWrite] All 5 rename attempts failed for ${p}`);
956
950
  } catch (err) {
957
951
  // Clean up tmp if it still exists, then re-throw — never silently swallow
958
952
  try { fs.unlinkSync(tmp); } catch { /* cleanup */ }
@@ -1412,104 +1406,6 @@ function pruneCrashDiagnosticsReports(config) {
1412
1406
  return removed;
1413
1407
  }
1414
1408
 
1415
- const FILE_LOCK_RELEASE_ATTEMPTS = 5;
1416
- const FILE_LOCK_RELEASE_RETRY_DELAY_MS = 25;
1417
- const FILE_LOCK_RETRY_MAX_DELAY_MS = 250;
1418
-
1419
- function _releaseFileLock(lockPath, fd) {
1420
- try { fs.closeSync(fd); } catch { /* continue to path cleanup */ }
1421
-
1422
- const attempts = process.platform === 'win32' ? FILE_LOCK_RELEASE_ATTEMPTS : 1;
1423
- for (let attempt = 0; attempt < attempts; attempt++) {
1424
- try {
1425
- fs.unlinkSync(lockPath);
1426
- return true;
1427
- } catch (err) {
1428
- if (err && err.code === 'ENOENT') return true;
1429
- const retryable = process.platform === 'win32'
1430
- && err && (err.code === 'EPERM' || err.code === 'EBUSY' || err.code === 'EACCES');
1431
- if (!retryable || attempt === attempts - 1) {
1432
- console.warn(`[withFileLock] Failed to release ${lockPath}: ${err?.message || err}`);
1433
- return false;
1434
- }
1435
- sleepMs(FILE_LOCK_RELEASE_RETRY_DELAY_MS * (attempt + 1));
1436
- }
1437
- }
1438
- return false;
1439
- }
1440
-
1441
- function _isTransientFileLockPathError(err) {
1442
- return !!err && (err.code === 'ENOENT'
1443
- || (process.platform === 'win32' && err.code === 'EPERM'));
1444
- }
1445
-
1446
- function _readFileLockState(lockPath) {
1447
- try {
1448
- const stat = fs.statSync(lockPath);
1449
- const raw = fs.readFileSync(lockPath, 'utf8');
1450
- let holderPid = null;
1451
- try {
1452
- const parsed = JSON.parse(raw);
1453
- if (parsed && Number.isFinite(parsed.pid) && parsed.pid > 0) holderPid = parsed.pid;
1454
- } catch { /* legacy/empty/corrupt lock */ }
1455
- return { raw, holderPid, mtimeAge: Date.now() - stat.mtimeMs };
1456
- } catch (err) {
1457
- if (_isTransientFileLockPathError(err)) return null;
1458
- throw err;
1459
- }
1460
- }
1461
-
1462
- function _readFileLockIdentity(lockPath) {
1463
- try {
1464
- return fs.readFileSync(lockPath, 'utf8');
1465
- } catch (err) {
1466
- if (_isTransientFileLockPathError(err)) return null;
1467
- throw err;
1468
- }
1469
- }
1470
-
1471
- function _tryReapFileLock(lockPath) {
1472
- const claimPath = `${lockPath}.reap`;
1473
- let claimFd;
1474
- try {
1475
- claimFd = fs.openSync(claimPath, 'wx');
1476
- try {
1477
- fs.writeSync(claimFd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
1478
- } catch { /* claim existence provides serialization */ }
1479
- } catch (err) {
1480
- const contended = err && (err.code === 'EEXIST'
1481
- || (process.platform === 'win32' && err.code === 'EPERM'));
1482
- if (contended) return false;
1483
- throw err;
1484
- }
1485
-
1486
- try {
1487
- const observed = _readFileLockState(lockPath);
1488
- if (!observed) return false;
1489
- const shouldReap = observed.holderPid !== null
1490
- ? !processUtils.isPidAlive(observed.holderPid) || observed.mtimeAge > LOCK_STALE_MS * 5
1491
- : observed.mtimeAge > LOCK_STALE_MS;
1492
- if (!shouldReap) return false;
1493
-
1494
- // A holder can release and a successor can acquire between the PID probe
1495
- // and deletion. The reap claim excludes competing reapers; this identity
1496
- // check prevents the winner from deleting that successor's lock.
1497
- const currentRaw = _readFileLockIdentity(lockPath);
1498
- if (currentRaw === null) return false;
1499
- if (currentRaw !== observed.raw) return false;
1500
-
1501
- try {
1502
- fs.unlinkSync(lockPath);
1503
- return true;
1504
- } catch (err) {
1505
- if (_isTransientFileLockPathError(err)) return false;
1506
- throw err;
1507
- }
1508
- } finally {
1509
- _releaseFileLock(claimPath, claimFd);
1510
- }
1511
- }
1512
-
1513
1409
  function withFileLock(lockPath, fn, {
1514
1410
  timeoutMs = 5000,
1515
1411
  retryDelayMs = 25,
@@ -1524,13 +1420,9 @@ function withFileLock(lockPath, fn, {
1524
1420
  const backoff = retryBackoffMs * Math.pow(2, attempt - 1);
1525
1421
  sleepMs(backoff);
1526
1422
  }
1423
+ const start = Date.now();
1527
1424
  let fd = null;
1528
- const pollDelayMs = Math.max(1, Number(retryDelayMs) || 1);
1529
- const waitBudgetMs = Math.max(0, Number(timeoutMs) || 0);
1530
- let waitedMs = 0;
1531
- let contentionCount = 0;
1532
- let lastOwnerIdentity;
1533
- while (true) {
1425
+ while (Date.now() - start < timeoutMs) {
1534
1426
  try {
1535
1427
  const dir = path.dirname(lockPath);
1536
1428
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
@@ -1540,11 +1432,7 @@ function withFileLock(lockPath, fn, {
1540
1432
  // the lock's existence (not its contents) provides mutual exclusion, so
1541
1433
  // a write failure here must NOT abort acquisition.
1542
1434
  try {
1543
- fs.writeSync(fd, JSON.stringify({
1544
- pid: process.pid,
1545
- ts: Date.now(),
1546
- owner: crypto.randomBytes(8).toString('hex'),
1547
- }));
1435
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
1548
1436
  } catch { /* payload is advisory; lock semantics unaffected */ }
1549
1437
  break;
1550
1438
  } catch (err) {
@@ -1559,27 +1447,47 @@ function withFileLock(lockPath, fn, {
1559
1447
  const isLockRaceErr = err.code === 'EEXIST' ||
1560
1448
  (process.platform === 'win32' && err.code === 'EPERM');
1561
1449
  if (!isLockRaceErr) throw err;
1562
- const ownerIdentity = _readFileLockIdentity(lockPath);
1563
- if (ownerIdentity !== null && ownerIdentity !== lastOwnerIdentity) {
1564
- if (lastOwnerIdentity !== undefined) {
1565
- waitedMs = 0;
1566
- contentionCount = 0;
1450
+ // PID-owned locks are safe to reap as soon as their owner is dead.
1451
+ // Alive owners retain the stale window; legacy/partial lockfiles
1452
+ // without a readable PID retain the mtime-only stale window.
1453
+ try {
1454
+ const stat = fs.statSync(lockPath);
1455
+ const mtimeAge = Date.now() - stat.mtimeMs;
1456
+ let holderPid = null;
1457
+ try {
1458
+ const raw = fs.readFileSync(lockPath, 'utf8');
1459
+ const parsed = JSON.parse(raw);
1460
+ if (parsed && Number.isFinite(parsed.pid) && parsed.pid > 0) {
1461
+ holderPid = parsed.pid;
1462
+ }
1463
+ } catch { /* legacy/empty/corrupt lock → mtime-only fallback */ }
1464
+
1465
+ const shouldReap = holderPid !== null
1466
+ ? !processUtils.isPidAlive(holderPid) || mtimeAge > LOCK_STALE_MS * 5
1467
+ : mtimeAge > LOCK_STALE_MS;
1468
+
1469
+ if (shouldReap) {
1470
+ try {
1471
+ fs.unlinkSync(lockPath);
1472
+ } catch (unlinkErr) {
1473
+ // ENOENT: another process deleted the lock between stat and unlink — safe to retry.
1474
+ // EPERM on Windows: file is in 'pending delete' state from a concurrent
1475
+ // reaper — the OS will finalize the delete shortly; retry will succeed.
1476
+ const tolerable = unlinkErr.code === 'ENOENT' ||
1477
+ (process.platform === 'win32' && unlinkErr.code === 'EPERM');
1478
+ if (!tolerable) throw unlinkErr;
1479
+ }
1480
+ continue; // lock just removed — retry immediately
1567
1481
  }
1568
- lastOwnerIdentity = ownerIdentity;
1482
+ } catch (staleErr) {
1483
+ // ENOENT from statSync: lock file disappeared between EEXIST and stat — retry will succeed.
1484
+ // EPERM on Windows: file is in 'pending delete' state — statSync itself
1485
+ // can fail; fall through to the normal retry sleep (W-mpd6lm1g000x195e).
1486
+ const tolerable = staleErr.code === 'ENOENT' ||
1487
+ (process.platform === 'win32' && staleErr.code === 'EPERM');
1488
+ if (!tolerable) throw staleErr;
1569
1489
  }
1570
- if (_tryReapFileLock(lockPath)) continue;
1571
- // Count active retry sleeps rather than wall time. Host suspension must
1572
- // not consume the whole budget without another acquisition attempt.
1573
- if (waitedMs >= waitBudgetMs) break;
1574
- const backoffMs = Math.min(
1575
- pollDelayMs * Math.pow(2, Math.min(contentionCount, 4)),
1576
- FILE_LOCK_RETRY_MAX_DELAY_MS
1577
- );
1578
- const jitterMs = contentionCount === 0 ? 0 : Math.floor(Math.random() * pollDelayMs);
1579
- const delayMs = Math.min(waitBudgetMs - waitedMs, backoffMs + jitterMs);
1580
- sleepMs(delayMs);
1581
- waitedMs += delayMs;
1582
- contentionCount += 1;
1490
+ sleepMs(retryDelayMs);
1583
1491
  }
1584
1492
  }
1585
1493
  if (fd === null) {
@@ -1595,52 +1503,143 @@ function withFileLock(lockPath, fn, {
1595
1503
  // completes, silently breaking mutual exclusion. Clean up our own fd /
1596
1504
  // lock first, then throw so the caller cannot ignore the failure.
1597
1505
  if (result && typeof result.then === 'function') {
1598
- _releaseFileLock(lockPath, fd);
1506
+ try { fs.closeSync(fd); } catch { /* cleanup */ }
1507
+ try { fs.unlinkSync(lockPath); } catch { /* cleanup */ }
1599
1508
  fd = null; // suppress double-cleanup in `finally`
1600
1509
  throw new Error('withFileLock: fn must be synchronous; got Promise. Use synchronous operations only.');
1601
1510
  }
1602
1511
  return result;
1603
1512
  } finally {
1604
1513
  if (fd !== null) {
1605
- _releaseFileLock(lockPath, fd);
1514
+ try { fs.closeSync(fd); } catch { /* cleanup */ }
1515
+ try { fs.unlinkSync(lockPath); } catch { /* cleanup */ }
1606
1516
  }
1607
1517
  }
1608
1518
  }
1609
1519
  throw lastErr;
1610
1520
  }
1611
1521
 
1522
+ const DELETE_JSON_FILE = Symbol('minions.deleteJsonFile');
1523
+
1612
1524
  function mutateJsonFileLocked(filePath, mutateFn, {
1613
1525
  defaultValue = {},
1614
1526
  lockRetries,
1615
1527
  lockRetryBackoffMs,
1616
1528
  skipWriteIfUnchanged = false,
1529
+ restoreOnRead = true,
1530
+ writeDefaultOnInvalid = true,
1531
+ exclusiveClaim = false,
1617
1532
  onWrote = null,
1618
1533
  } = {}) {
1619
1534
  const lockPath = `${filePath}.lock`;
1620
1535
  const retries = lockRetries ?? ENGINE_DEFAULTS.lockRetries;
1621
1536
  const retryBackoffMs = lockRetryBackoffMs ?? ENGINE_DEFAULTS.lockRetryBackoffMs;
1622
1537
  return withFileLock(lockPath, () => {
1623
- const fileExists = fs.existsSync(filePath);
1624
- let data = safeJson(filePath);
1625
- const parsedInvalid = fileExists && data === null;
1626
- if (data === null || typeof data !== 'object') data = Array.isArray(defaultValue) ? [...defaultValue] : { ...defaultValue };
1627
- const beforeSerialized = skipWriteIfUnchanged ? JSON.stringify(data) : null;
1628
- const next = mutateFn(data);
1629
- const finalData = next === undefined ? data : next;
1630
- const shouldWrite = !skipWriteIfUnchanged || parsedInvalid || JSON.stringify(finalData) !== beforeSerialized;
1631
- if (shouldWrite) {
1632
- const backupPath = filePath + '.backup';
1633
- try { if (fileExists) fs.copyFileSync(filePath, backupPath); } catch { /* backup is best-effort */ }
1634
- safeWrite(filePath, finalData);
1635
- // Side-effect hook fired only when an actual write happened. Callers
1636
- // use this to emit cache-invalidation signals (events table row) so
1637
- // skip-write-unchanged paths (dedup, idempotent no-op POSTs) don't
1638
- // produce spurious cache busts.
1639
- if (typeof onWrote === 'function') {
1640
- try { onWrote(finalData); } catch { /* hook errors must not break the write */ }
1538
+ let fileExists = fs.existsSync(filePath);
1539
+ let claimPath = null;
1540
+ if (exclusiveClaim && fileExists) {
1541
+ claimPath = `${filePath}.claim-${process.pid}-${uid()}`;
1542
+ try {
1543
+ fs.renameSync(filePath, claimPath);
1544
+ } catch (error) {
1545
+ if (!error || error.code !== 'ENOENT') throw error;
1546
+ fileExists = false;
1547
+ claimPath = null;
1641
1548
  }
1642
1549
  }
1643
- return finalData;
1550
+ const readPath = claimPath || filePath;
1551
+ const cleanupClaim = () => {
1552
+ if (!claimPath) return;
1553
+ for (const candidate of [claimPath, claimPath + '.backup']) {
1554
+ try { fs.unlinkSync(candidate); } catch (error) {
1555
+ if (error && error.code !== 'ENOENT') throw error;
1556
+ }
1557
+ }
1558
+ };
1559
+ const restoreClaim = () => {
1560
+ if (!claimPath || !fs.existsSync(claimPath)) return;
1561
+ try {
1562
+ fs.copyFileSync(claimPath, filePath, fs.constants.COPYFILE_EXCL);
1563
+ cleanupClaim();
1564
+ } catch (error) {
1565
+ if (error && error.code === 'EEXIST') {
1566
+ cleanupClaim();
1567
+ return;
1568
+ }
1569
+ throw error;
1570
+ }
1571
+ };
1572
+
1573
+ try {
1574
+ let data = restoreOnRead ? safeJson(readPath) : safeJsonNoRestore(readPath);
1575
+ const parsedInvalid = fileExists && data === null;
1576
+ if (data === null || typeof data !== 'object') {
1577
+ data = Array.isArray(defaultValue) ? [...defaultValue] : { ...defaultValue };
1578
+ }
1579
+ const beforeSerialized = skipWriteIfUnchanged ? JSON.stringify(data) : null;
1580
+ const next = mutateFn(data, { fileExists, readPath });
1581
+ if (next === DELETE_JSON_FILE) {
1582
+ if (claimPath) {
1583
+ if (fs.existsSync(filePath)) {
1584
+ const error = new Error(`JSON file was replaced during mutation: ${filePath}`);
1585
+ error.code = 'JSON_FILE_REPLACED';
1586
+ throw error;
1587
+ }
1588
+ cleanupClaim();
1589
+ } else {
1590
+ for (const candidate of [filePath, filePath + '.backup']) {
1591
+ try { fs.unlinkSync(candidate); } catch (error) {
1592
+ if (error && error.code !== 'ENOENT') throw error;
1593
+ }
1594
+ }
1595
+ }
1596
+ return null;
1597
+ }
1598
+ const finalData = next === undefined ? data : next;
1599
+ const shouldWrite = !skipWriteIfUnchanged
1600
+ || (parsedInvalid && writeDefaultOnInvalid)
1601
+ || JSON.stringify(finalData) !== beforeSerialized;
1602
+ if (shouldWrite) {
1603
+ if (claimPath) {
1604
+ safeWrite(claimPath, finalData);
1605
+ if (fs.existsSync(filePath)) {
1606
+ const error = new Error(`JSON file was replaced during mutation: ${filePath}`);
1607
+ error.code = 'JSON_FILE_REPLACED';
1608
+ throw error;
1609
+ }
1610
+ try {
1611
+ fs.copyFileSync(claimPath, filePath, fs.constants.COPYFILE_EXCL);
1612
+ } catch (error) {
1613
+ if (error && error.code === 'EEXIST') {
1614
+ error.code = 'JSON_FILE_REPLACED';
1615
+ }
1616
+ throw error;
1617
+ }
1618
+ cleanupClaim();
1619
+ } else {
1620
+ const backupPath = filePath + '.backup';
1621
+ try { if (fileExists) fs.copyFileSync(filePath, backupPath); } catch { /* backup is best-effort */ }
1622
+ safeWrite(filePath, finalData);
1623
+ }
1624
+ // Side-effect hook fired only when an actual write happened. Callers
1625
+ // use this to emit cache-invalidation signals (events table row) so
1626
+ // skip-write-unchanged paths (dedup, idempotent no-op POSTs) don't
1627
+ // produce spurious cache busts.
1628
+ if (typeof onWrote === 'function') {
1629
+ try { onWrote(finalData); } catch { /* hook errors must not break the write */ }
1630
+ }
1631
+ } else if (claimPath) {
1632
+ restoreClaim();
1633
+ }
1634
+ return finalData;
1635
+ } catch (error) {
1636
+ if (claimPath && fs.existsSync(claimPath)) {
1637
+ try { restoreClaim(); } catch (restoreError) {
1638
+ error.restoreError = restoreError;
1639
+ }
1640
+ }
1641
+ throw error;
1642
+ }
1644
1643
  }, { retries, retryBackoffMs });
1645
1644
  }
1646
1645
 
@@ -2468,6 +2467,73 @@ function parseStreamJsonOutput(raw, runtimeName, opts) {
2468
2467
  return resolveRuntime(runtimeName).parseOutput(raw, opts || {});
2469
2468
  }
2470
2469
 
2470
+ // ── Stale resumed-session detection (W-ms0vk9nv000z985b) ────────────────────
2471
+ //
2472
+ // A `--resume`d CLI can die instantly, before it writes a single stdout byte,
2473
+ // when its persisted session is no longer usable. Two distinct signals, both
2474
+ // runtime-agnostic in shape (each adapter's CLI phrases them slightly
2475
+ // differently, so we match on the stable substrings actually emitted):
2476
+ //
2477
+ // 1. The backing conversation was purged from the runtime's session store.
2478
+ // 2. The directory the session was created in is gone — the engine removed
2479
+ // or quarantined that worktree since the previous dispatch. Copilot
2480
+ // reports this as `resume-auto-cd: ignoring persisted cwd <path> ...
2481
+ // missing or not a directory` on stderr and then exits 1 with EMPTY
2482
+ // stdout, so the failure otherwise classifies as a meaningless
2483
+ // EMPTY_OUTPUT.
2484
+ //
2485
+ // Callers use this to (a) purge the cached session so the retry starts fresh
2486
+ // and (b) classify the failure as RUNTIME_SESSION_STALE instead of
2487
+ // EMPTY_OUTPUT. Prevention lives in each adapter's getResumeSessionId (it
2488
+ // refuses to hand back a session whose recorded cwd no longer exists); this
2489
+ // helper is the defense-in-depth for the race where the directory disappears
2490
+ // between resolution and spawn.
2491
+ const _STALE_RESUME_SESSION_PATTERNS = [
2492
+ /no conversation found/i,
2493
+ /ignoring persisted cwd/i,
2494
+ /session (?:not found|no longer exists|has expired)/i,
2495
+ ];
2496
+
2497
+ function isStaleResumeSessionSignal(text) {
2498
+ const value = text == null ? '' : String(text);
2499
+ if (!value) return false;
2500
+ return _STALE_RESUME_SESSION_PATTERNS.some(re => re.test(value));
2501
+ }
2502
+
2503
+ /**
2504
+ * PREVENTION side of the same bug (W-ms0vk9nv000z985b): decide whether a
2505
+ * persisted session may be resumed given the directory it was created in.
2506
+ *
2507
+ * Runtime CLIs anchor a resumable session to the working directory it was
2508
+ * started in. Minions gives most dispatches an engine-owned worktree that is
2509
+ * removed, recycled, or quarantined once the dispatch ends — so by the time
2510
+ * the same agent is dispatched again, the session's directory can be gone or
2511
+ * a different path entirely. Handing that session id to `--resume` makes the
2512
+ * CLI bail before it writes any stdout, which surfaced as an opaque
2513
+ * `empty-output` failure.
2514
+ *
2515
+ * Returns a human-readable reason to REFUSE the resume, or null when the
2516
+ * session is safe to resume. Sessions saved before this field existed carry no
2517
+ * `cwd` and are treated as compatible — same opt-in convention as the
2518
+ * `runtime` field, so deploying this cannot invalidate live sessions.
2519
+ *
2520
+ * Shared by every runtime adapter: the constraint is a property of how the
2521
+ * engine manages worktrees, not of any one CLI.
2522
+ */
2523
+ function staleResumeSessionCwdReason(sessionCwd, currentCwd) {
2524
+ if (!sessionCwd || typeof sessionCwd !== 'string') return null;
2525
+ let exists = false;
2526
+ try { exists = fs.existsSync(sessionCwd); } catch { exists = false; }
2527
+ if (!exists) return `session working directory no longer exists (${sessionCwd})`;
2528
+ if (!currentCwd || typeof currentCwd !== 'string') return null;
2529
+ const recorded = _normalizeWorktreePath(sessionCwd);
2530
+ const current = _normalizeWorktreePath(currentCwd);
2531
+ if (recorded && current && recorded !== current) {
2532
+ return `session was created in ${sessionCwd} but this dispatch runs in ${currentCwd}`;
2533
+ }
2534
+ return null;
2535
+ }
2536
+
2471
2537
  // ── Knowledge Base ──────────────────────────────────────────────────────────
2472
2538
 
2473
2539
  const KB_CATEGORIES = ['architecture', 'conventions', 'project-notes', 'build-reports', 'reviews'];
@@ -2502,6 +2568,7 @@ const KB_READABLE_CATEGORIES = Object.freeze([
2502
2568
  'consolidated', 'consolidation', 'consolidations',
2503
2569
  'team-memory', 'general', 'patterns',
2504
2570
  ]);
2571
+ /**
2505
2572
  /**
2506
2573
  * Detect engine-authored system alerts (dirty-tree refusals, worktree-skip-live
2507
2574
  * guard notices, blocked/conflicted live-checkout dispatches, managed-spawn
@@ -2926,6 +2993,7 @@ const ENGINE_DEFAULTS = {
2926
2993
  worktreeCreateTimeout: 300000, // 5min for git worktree add on large Windows repos
2927
2994
  worktreeCreateRetries: 1, // retry once on transient timeout/lock races
2928
2995
  worktreeRoot: '../worktrees',
2996
+ worktreeMinFreeSpaceBytes: 10 * 1024 * 1024 * 1024, // fail-closed before checkout below 10 GiB; 0 disables
2929
2997
  worktreeCountCacheTtl: 30000, // 30s — TTL for cached _countWorktrees() result in dashboard
2930
2998
  // W-mq1habhf: timeout (ms) for the `git status --porcelain` probe inside
2931
2999
  // engine.js assertCleanSharedWorktree. The default 10s is fine for ordinary
@@ -2991,7 +3059,7 @@ const ENGINE_DEFAULTS = {
2991
3059
  // to before this knob existed. Flip via Dashboard → Settings → Auto-fix
2992
3060
  // & Review Loop, or set `engine.autoFixPaused: true` in config.json.
2993
3061
  autoFixPaused: false, // hard-stop kill-switch / master override (see comment above)
2994
- autoConsolidateMemory: false, // opt-in: periodically spawn engine/kb-sweep-runner.js from the tick loop (4h cadence). Inbox→notes consolidation already runs every tick via consolidateInbox; this flag only controls the KB sweep.
3062
+ autoConsolidateMemory: true, // default-on: periodically spawn engine/kb-sweep-runner.js from the tick loop (4h cadence). Explicit false disables it; inbox→notes consolidation is independent.
2995
3063
  // W-mrktrokd — batteries-included default schedules. When true (default),
2996
3064
  // adding a project auto-creates its per-project git-fetch-freshness schedule
2997
3065
  // (`keep-branch-fresh-git-fetch-origin-<slug>`, cron `0 */12 *`, explore) and
@@ -3302,6 +3370,7 @@ const ENGINE_DEFAULTS = {
3302
3370
  preDispatchEvalMaxRejections: 5,
3303
3371
  completionNonceRequired: false, // P-d2a8f6c1 (agent trust boundary F8): when true, a missing `nonce` field in the completion JSON hard-fails the dispatch with failure_class:'completion-nonce-mismatch'. Default false for one release so older agents/runtime caches that haven't picked up the prompt change degrade with a warning instead of breaking. Mismatched nonces hard-fail regardless of this flag. See docs/completion-reports.md → "Trust boundary".
3304
3372
  autoApplyReviewVote: false, // Master gate for platform vote mutations; local verdicts remain informational when false.
3373
+ createPrsAsDraft: false, // W-mrtdy7xv — when true, the playbook PR-creation guidance (engine/playbook.js#getPrCreateInstructions) tells agents to open normal Minions-created PRs as DRAFT (GitHub `gh pr create --draft`; ADO `az repos pr create --draft true`). Default false = behave exactly as today. Per-project-overridable via `project.createPrsAsDraft` (explicit boolean), resolved through `shared.resolveCreatePrsAsDraft(project, engine)` — a project can opt IN or OUT independent of this fleet-wide default. Aggregate/E2E verify PRs already open as draft via verify.md regardless of this flag; the engine-opened shared-branch aggregate PR is outside the playbook seam and unaffected.
3305
3374
 
3306
3375
  // ── Runtime fleet (P-3b8e5f1d) ──────────────────────────────────────────────
3307
3376
  // Single source of truth for which CLI runtime + model every spawn uses.
@@ -3326,10 +3395,46 @@ const ENGINE_DEFAULTS = {
3326
3395
  // Engine-scoped per the CLAUDE.md rule against `runtime.name === ...` branches at call sites — the runtime check
3327
3396
  // lives inside the helper (engine/spawn-agent.js#preApproveWorkspaceMcps) which no-ops for non-Claude runtimes.
3328
3397
  claudePreApproveWorkspaceMcps: true,
3398
+ // W-mrdon0pe000l045a — propagate repo-authored CLAUDE.md instructions to
3399
+ // runtimes that do NOT auto-discover CLAUDE.md natively (Copilot, Codex).
3400
+ // When TRUE (default), the engine bounded-discovers the nearest applicable
3401
+ // CLAUDE.md files for a dispatch and injects them as a "Project instructions
3402
+ // (CLAUDE.md)" context layer (engine/playbook.js → engine/claude-md-context.js).
3403
+ // Claude is always skipped — its own CLI already reads CLAUDE.md, so injecting
3404
+ // it again would double the instructions. CLAUDE.md carries no split-brain
3405
+ // risk for Copilot/Codex because those runtimes never read it themselves, so
3406
+ // this is orthogonal to the native AGENTS.md / repo-instruction discovery that
3407
+ // each runtime's CLI now owns (#817). Per-project override via
3408
+ // `project.propagateClaudeMdForNonClaudeRuntimes`; resolved through
3409
+ // `shared.resolvePropagateClaudeMdForNonClaudeRuntimes(project, engine)`.
3410
+ propagateClaudeMdForNonClaudeRuntimes: true,
3411
+ // W-mrtdmtwq000kf391 — surface the repo HARNESS assets in effect for a
3412
+ // dispatch (runtime + project) in the Minions-authored PR body. When TRUE
3413
+ // (default), engine/playbook.js injects a PR-body appendix instructing the
3414
+ // agent to append a fenced "## Repo harnesses used" section listing the
3415
+ // dispatch's runtime, injected CLAUDE.md files, AGENTS.md discovery, MCP
3416
+ // servers, and skills. Pure reporting via the read-only harness-diagnostics
3417
+ // source (engine/harness-context.js → engine/preflight._runtimeHarnessRows +
3418
+ // engine/claude-md-context.js); it never propagates or attests any asset.
3419
+ includeHarnessSectionInPrBody: true,
3329
3420
  copilotStreamMode: 'on', // Copilot --stream <on|off>: 'on' streams assistant.message_delta events live; 'off' batches them
3330
3421
  copilotReasoningSummaries: false, // Copilot --enable-reasoning-summaries (Anthropic-family models only)
3331
- ccUseWorkerPool: true, // Sub-task C of W-mp2w003600196c51 (CC perf): when true AND CC runtime is copilot, _invokeCcStream routes through engine/cc-worker-pool.js (persistent `copilot --acp` per CC tab) instead of spawning a fresh CLI per turn. On by default (PR #2492 cold-spawn measurements; matches engine/features.js's `ccUseWorkerPool` registry default) — opt out via `engine.ccUseWorkerPool: false`. **Structurally copilot-only**: the pool spawns `copilot --acp` (Agent Client Protocol); Claude Code does not implement ACP, so resolveCcUseWorkerPool returns false on non-copilot CC runtimes even with explicit-true (W-mphlriic00095f69 — prevents silent runtime switch). Engine/agent dispatch path stays per-process regardless.
3332
- agentUseWorkerPool: false, // P-9b3d5f61: mirrors ccUseWorkerPool's shape but for the fleet agent-dispatch path (engine/agent-worker-pool.js). Opt-in, default OFF — see resolveAgentUseWorkerPool in engine/shared.js for the full priority chain and rationale.
3422
+ ccUseWorkerPool: true, // Route Copilot CC/doc-chat through a persistent `copilot --acp` worker per tab by default. Structurally Copilot-only: resolveCcUseWorkerPool forces false for runtimes without this transport.
3423
+ // P-9b3d5f61 (Pool copilot agent dispatches through a shared ACP worker pool):
3424
+ // when true AND the resolved runtime declares `capabilities.acpWorkerPool`,
3425
+ // engine.js spawnAgent routes per-agent dispatches through
3426
+ // engine/agent-worker-pool.js (persistent `copilot --acp` workers) instead of
3427
+ // cold-spawning a fresh CLI per dispatch. Opt-in while fleet-pool lifecycle
3428
+ // hardening remains newer than the established cold-spawn path. Structurally
3429
+ // Copilot-only, same as
3430
+ // ccUseWorkerPool: resolveAgentUseWorkerPool hard-returns false whenever the
3431
+ // resolved runtime's capability flag isn't true, regardless of this override.
3432
+ agentUseWorkerPool: false,
3433
+ // P-9b3d5f61 — max concurrent ACP workers the per-agent pool
3434
+ // (engine/agent-worker-pool.js) will keep warm. `undefined` = fall back to
3435
+ // the fleet's `maxConcurrent` dispatch cap via resolveAgentAcpPoolSize, so
3436
+ // the pool never queues more than the engine would have dispatched anyway.
3437
+ agentAcpPoolSize: undefined,
3333
3438
  maxBudgetUsd: undefined, // fleet USD ceiling for --max-budget-usd (per-agent override: agents.<id>.maxBudgetUsd). Honors 0 via ?? so a literal cap of $0 works
3334
3439
  disableModelDiscovery: false, // skip runtime.listModels() REST calls fleet-wide (settings UI falls back to free-text)
3335
3440
  // W-mpmwxkrw000872ec — dashboard global font-size scale. Drives the
@@ -3376,27 +3481,49 @@ const ENGINE_DEFAULTS = {
3376
3481
  // the kill); holder details are appended to the inbox note either way.
3377
3482
  autoReapOrphanWorktreeHolders: false,
3378
3483
  // W-mqvejug6000eeb20 — fleet-wide fallback for live-checkout auto-reset.
3379
- // When ON, a dirty/broken live-checkout tree is `git fetch origin` +
3380
- // `git reset --hard origin/<branch>`'d before dispatch instead of failing
3381
- // LIVE_CHECKOUT_DIRTY. Per-project `project.liveCheckoutAutoReset` overrides
3382
- // this. Default FLIPPED BACK to false under W-mrawgw4q000a6bff: the original
3383
- // divergence problem this was ON to solve (dirty live-checkout trees
3384
- // accumulating `minions: auto-save agent WIP` commits) is now primarily
3385
- // addressed by `liveCheckoutAutoStash` (non-destructive — `git stash push`,
3386
- // recoverable via `git stash pop` — see `resolveLiveCheckoutAutoStash` /
3387
- // `applyLiveCheckoutAutoStash` in engine/live-checkout.js), which the
3388
- // stash-before-reset ordering (W-mrawgw4q000a6bff) now always attempts FIRST
3389
- // when both flags are enabled for a dispatch. Auto-reset remains available as
3390
- // an explicit opt-in (per-project `project.liveCheckoutAutoReset: true` or
3391
- // fleet `engine.liveCheckoutAutoReset: true`) for cases that specifically
3392
- // need the branch fast-forwarded/discarded to `origin/<mainRef>`, not just
3393
- // cleaned. Resolution precedence lives in `resolveLiveCheckoutAutoReset`
3394
- // (UNCHANGED by this flip). Fires ONLY on confirmed dirty-tree detection,
3395
- // never on mid-operation / blob-fetch / tooling failures.
3484
+ // When ON, a dirty live-checkout tree is cleaned with `git reset --hard HEAD`
3485
+ // + `git clean -fd` before dispatch instead of failing LIVE_CHECKOUT_DIRTY.
3486
+ // The current branch and committed history are preserved. Per-project
3487
+ // `project.liveCheckoutAutoReset` overrides this. Default false;
3488
+ // `liveCheckoutAutoStash` gets first chance when both policies are enabled.
3489
+ // Fires only on confirmed dirt, never on mid-operation / detached-HEAD /
3490
+ // tooling failures.
3396
3491
  liveCheckoutAutoReset: false,
3492
+ // W-mr3lunnq000o9f41 — opt-in companion to the live-checkout stale-base guard.
3493
+ // When ON, a live-checkout dispatch that would fail LIVE_CHECKOUT_WRONG_BASE
3494
+ // (HEAD on an unrelated topic branch AND no local `refs/heads/<mainBranch>` to
3495
+ // fork from) is auto-recovered by materializing a local base branch from the
3496
+ // `refs/remotes/origin/<mainBranch>` remote-tracking ref (a LOCAL ref — NO
3497
+ // `git fetch`, so issue #226 is preserved) and forking cleanly off it.
3498
+ // origin/<mainBranch> IS the correct base, so there is NO scope-contamination
3499
+ // risk. Per-project `project.liveCheckoutAutoBaseRepair` overrides this.
3500
+ // Resolution precedence lives in `resolveLiveCheckoutAutoBaseRepair`. Default
3501
+ // OFF because the recovery's `git checkout <mainBranch>` may hydrate blobs on a
3502
+ // partial clone (the one op issue #226 flagged as hang-prone headless); when it
3503
+ // does hang it degrades to exactly the pre-existing fail-closed refusal.
3504
+ liveCheckoutAutoBaseRepair: false,
3505
+ // W-mrdlcud2000e630e — opt-in companion to the live-checkout stale-base guard.
3506
+ // When ON, a live-checkout (or hybrid liveValidation) dispatch that is about to
3507
+ // fork a NEW branch off the local `mainBranch` fast-forwards that local base to
3508
+ // `origin/<mainBranch>` FIRST, but ONLY in the strictly-safe case: the tree is
3509
+ // clean (the dirty-tree guard already covers this), HEAD is on `mainBranch`, the
3510
+ // local base has ZERO commits ahead of `origin/<mainBranch>` (i.e. NOT the
3511
+ // stale-base-with-unpushed-commits case — that refusal is unchanged), and the
3512
+ // local base IS behind `origin/<mainBranch>`. It runs `git fetch origin
3513
+ // <mainBranch>` then a `--ff-only` update — the ONE sanctioned live-mode fetch
3514
+ // (issue #226 otherwise forbids fetching live checkouts, to preserve operator
3515
+ // control over history rewrites); a fast-forward can never rewrite history or
3516
+ // lose local commits (there are none ahead), and it NEVER touches a dirty tree
3517
+ // or a mid-operation state. On fetch/network/auth failure it FAILS OPEN to the
3518
+ // pre-existing (un-freshened) stale-base check. Per-project
3519
+ // `project.liveCheckoutAutoFreshen` overrides this. Resolution precedence lives
3520
+ // in `resolveLiveCheckoutAutoFreshen`. Default OFF (conservative — a human opts
3521
+ // individual projects in). Only affects projects running in live/in-place
3522
+ // (or hybrid liveValidation) checkout mode.
3523
+ liveCheckoutAutoFreshen: false,
3397
3524
  orphanHolderScanTimeoutMs: 5000, // 5s ceiling for the cross-platform holder scan (PowerShell / /proc walk / lsof)
3398
3525
  ccMaxTurns: 50, // max tool-use turns per CC/doc-chat call before CLI stops (per response, not per session)
3399
- ccWorkerIdleTimeoutMs: 30 * 60 * 1000, // W-mr0qs0vw: idle-reaper window for the persistent `copilot --acp` worker pool (engine/cc-worker-pool.js). After this much inactivity with no in-flight turn the warm ACP process is killed; the next message cold-spawns a fresh session with NO memory of prior turns (CC shows a "context cleared after inactivity" notice). Tradeoff: shorter = less idle memory/process footprint, longer = more context durability across gaps between messages. Wired into the pool via ccWorkerPool.setIdleTimeoutMs() on every reloadConfig(); clamped to [60000, 28800000] (1min–8h) in the settings POST handler.
3526
+ ccWorkerIdleTimeoutMs: 30 * 60 * 1000, // W-mr0qs0vw: idle-reaper window for the persistent `copilot --acp` worker pool (engine/cc-worker-pool.js). After this much inactivity with no in-flight turn the warm ACP process is killed; the next message cold-spawns a fresh session with NO memory of prior turns (CC shows a "context cleared after inactivity" notice). Tradeoff: shorter = less idle memory/process footprint, longer = more context durability across gaps between messages. Wired into the pool via ccWorkerPool.setIdleTimeoutMs() on every reloadConfig(); clamped to [60000, 28800000] (1min–8h) in the settings POST handler. W-mr1qbj90000d30bd: `0` is an explicit "never idle-reap" sentinel that bypasses the clamp — cc-worker-pool.js maps it to an Infinity window so only an explicit closeTab kills the warm worker; Settings page exposes this as the "Never idle-reap CC workers" toggle.
3400
3527
  ccTurnTimeoutMs: 300000, // W-mpmwxni2000c25c7-b/-d: 5min per-turn no-progress watchdog. The window resets on every liveness signal — token chunk, tool-call notification, tool-update — so an actively-streaming CC/doc-chat turn (long shell command, deep search, sub-agent loop) survives indefinitely up to the outer CC_CALL_TIMEOUT_MS (~1h) ceiling. Only true silence past this window with no progress fires the cancel: the in-flight LLM call is aborted and the handler surfaces `{code:'cc-turn-timeout', retryable:true}` via the typed error envelope so the UI can stop the spinner and offer Retry. Clamped to [10000, 3600000] in the settings POST handler. Independent of CC_CALL_TIMEOUT_MS. Non-streaming doc-chat is the lone wall-clock exception (no progress hooks); see _raceCcDocChatTimeout in dashboard.js for the dual factory/promise shape.
3401
3528
  docSessionMaxEntries: 200, // cap doc-chat session map/disk store by least-recent activity (LRU; sessions are non-expiring otherwise)
3402
3529
  ccLiveStreamMaxAgeMs: 30 * 60 * 1000, // hard cap reconnect buffers if abort/cleanup stalls
@@ -3698,10 +3825,7 @@ function resolveCcUseWorkerPool(engine) {
3698
3825
  * resolveCcUseWorkerPool's CC-runtime guard, W-mphlriic00095f69).
3699
3826
  * 2. `engine.agentUseWorkerPool` explicit true/false — operator override
3700
3827
  * wins *within* an ACP-capable runtime.
3701
- * 3. `ENGINE_DEFAULTS.agentUseWorkerPool` (false) opt-in, default OFF.
3702
- * Deliberately different from CC's default-true: the fleet dispatch path
3703
- * has a much larger MCP-auth-popup / isolation surface than the single
3704
- * CC singleton, so this stays off until proven safe.
3828
+ * 3. `ENGINE_DEFAULTS.agentUseWorkerPool` — default OFF.
3705
3829
  */
3706
3830
  function resolveAgentUseWorkerPool(engine, runtime) {
3707
3831
  if (!runtime || !runtime.capabilities || runtime.capabilities.acpWorkerPool !== true) return false;
@@ -3839,6 +3963,24 @@ function resolvePropagateClaudeMdForNonClaudeRuntimes(project, engine) {
3839
3963
  return true;
3840
3964
  }
3841
3965
 
3966
+ // W-mrxv648b — resolve the effective "create PRs as draft" flag for a dispatch.
3967
+ // Resolution order: per-project `project.createPrsAsDraft` (explicit boolean)
3968
+ // wins → else `engine.createPrsAsDraft` (explicit boolean) → else
3969
+ // ENGINE_DEFAULTS.createPrsAsDraft (false). Only an explicit boolean counts as
3970
+ // "set" at either level — undefined/null/absent falls through to the next tier
3971
+ // so operators can enable draft PRs fleet-wide while opting a specific project
3972
+ // OUT (or vice-versa). Pure, no I/O. Consumed by
3973
+ // engine/playbook.js#getPrCreateInstructions.
3974
+ function resolveCreatePrsAsDraft(project, engine) {
3975
+ if (project && typeof project === 'object' && typeof project.createPrsAsDraft === 'boolean') {
3976
+ return project.createPrsAsDraft;
3977
+ }
3978
+ if (engine && typeof engine === 'object' && typeof engine.createPrsAsDraft === 'boolean') {
3979
+ return engine.createPrsAsDraft;
3980
+ }
3981
+ return ENGINE_DEFAULTS.createPrsAsDraft;
3982
+ }
3983
+
3842
3984
  // ─── Legacy ccModel → defaultModel Migration ─────────────────────────────────
3843
3985
  //
3844
3986
  // Pre-P-3b8e5f1d, `engine.ccModel` was the single fleet-wide model knob (it
@@ -4705,6 +4847,9 @@ const FAILURE_CLASS = {
4705
4847
  OUT_OF_CONTEXT: 'out-of-context', // Context window exhausted (token limit, context length)
4706
4848
  MAX_TURNS: 'max-turns', // Claude CLI error_max_turns — work in progress, retryable
4707
4849
  COMPLETION_NONCE_MISMATCH: 'completion-nonce-mismatch', // P-d2a8f6c1: completion JSON nonce did not match the per-spawn value injected via MINIONS_COMPLETION_NONCE — treat as forged/untrusted; ignore PR/noop/status fields from the report
4850
+ COMPLETION_REPORT_MISSING: 'completion-report-missing', // W-mrju48xn000a5b84: mutating agent exited without a valid required JSON completion report; process exit alone is not proof of successful completion
4851
+ RUNTIME_TRUNCATED_EXIT: 'runtime-truncated-exit', // W-ms0vk9nv000z985b: the runtime process exited 0 in the MIDDLE of a turn — it produced neither a terminal result (no final assistant text from the adapter's own parseOutput) nor the required completion report. Observed when Copilot exits 0 right after starting a long-running tool call (e.g. `npm test`): the last event is a tool start with no matching completion. This is a truncated runtime turn, NOT an agent that finished and forgot to write its report, so it gets its own explicitly-diagnosed class instead of the generic COMPLETION_REPORT_MISSING. Retryable — a fresh attempt normally runs to completion. The completion-report requirement itself is unchanged: the dispatch still fails.
4852
+ RUNTIME_SESSION_STALE: 'runtime-session-stale', // W-ms0vk9nv000z985b: a `--resume`d runtime died because its persisted session is no longer usable — the CLI reported the backing conversation is gone, or its persisted working directory no longer exists (Copilot's `resume-auto-cd: ignoring persisted cwd ... missing or not a directory`, emitted when the worktree the session was created in has since been removed). Without this class the run surfaced as an opaque EMPTY_OUTPUT because the CLI exits 1 with no stdout at all. Retryable — the engine clears the cached session so the retry starts a fresh one.
4708
4853
  WORKTREE_PREFLIGHT: 'worktree-preflight', // Pre-spawn worktree validation rejected (nested-in-project, drive-root collapse) — never retryable
4709
4854
  WORKTREE_DIRTY: 'worktree-dirty', // #2996: reused worktree had uncommitted edits and the engine could not auto-heal (or already quarantined). Non-retryable for this dispatch — next discovery cycle creates a fresh worktree.
4710
4855
  WORKTREE_DIVERGENT: 'worktree-divergent', // #2996: reused worktree's local branch was N commits ahead of origin (unsafe to reset, may contain unpushed agent work). Engine quarantined the worktree + backed up the local ref; non-retryable for this dispatch.
@@ -4735,8 +4880,92 @@ const FAILURE_CLASS = {
4735
4880
  UNKNOWN: 'unknown', // Unclassified failure
4736
4881
  };
4737
4882
 
4883
+ function resolveTerminalDispatchResult({
4884
+ code,
4885
+ agentReportedFailure = false,
4886
+ autoRecovered = false,
4887
+ benignNoop = false,
4888
+ trustedCompletionSuccess = false,
4889
+ hardContractFail = false,
4890
+ nonceFail = false,
4891
+ completionReportFail = false,
4892
+ keepProcessesAcceptanceFail = false,
4893
+ managedSpawnAcceptanceFail = false,
4894
+ managedSpawnHealthcheckFail = false,
4895
+ } = {}) {
4896
+ if (
4897
+ hardContractFail
4898
+ || nonceFail
4899
+ || completionReportFail
4900
+ || keepProcessesAcceptanceFail
4901
+ || managedSpawnAcceptanceFail
4902
+ || managedSpawnHealthcheckFail
4903
+ ) {
4904
+ return DISPATCH_RESULT.ERROR;
4905
+ }
4906
+ // A nonce-verified terminal-success completion report (trustedCompletionSuccess)
4907
+ // must win over a kill-induced nonzero exit so a startup-timer / steering /
4908
+ // watchdog kill that races a valid success report cannot persist a
4909
+ // contradictory `workerState:failed` alongside `structuredCompletion.status:
4910
+ // success` (incident ralph-fix-mrz8ka5g0015124a). It is gated by the same hard-
4911
+ // failure early-returns above, so a forged/failed/contract-violating report can
4912
+ // never ride this path to success.
4913
+ return ((code === 0 && !agentReportedFailure) || autoRecovered || benignNoop || trustedCompletionSuccess)
4914
+ ? DISPATCH_RESULT.SUCCESS
4915
+ : DISPATCH_RESULT.ERROR;
4916
+ }
4917
+
4918
+ function resolveTerminalWorkerState({ code, effectiveResult, errorReason } = {}) {
4919
+ if (effectiveResult === DISPATCH_RESULT.SUCCESS) {
4920
+ return {
4921
+ status: AGENT_STATUS.FINISHED,
4922
+ detail: code === 0
4923
+ ? 'Agent completion validated'
4924
+ : `Agent work recovered after process exit code ${code}`,
4925
+ };
4926
+ }
4927
+ return {
4928
+ status: AGENT_STATUS.FAILED,
4929
+ detail: errorReason || (code === 0
4930
+ ? 'Agent process exited but completion validation failed'
4931
+ : `Agent exited with code ${code}`),
4932
+ };
4933
+ }
4934
+
4935
+ const _dispatchFinalizationOwners = new Map();
4936
+ const _dispatchFinalizationWaiters = new Map();
4937
+
4938
+ function claimDispatchFinalization(dispatchId, owner) {
4939
+ if (!dispatchId || !owner || _dispatchFinalizationOwners.has(dispatchId)) return false;
4940
+ _dispatchFinalizationOwners.set(dispatchId, owner);
4941
+ return true;
4942
+ }
4943
+
4944
+ function releaseDispatchFinalization(dispatchId, owner) {
4945
+ if (_dispatchFinalizationOwners.get(dispatchId) !== owner) return false;
4946
+ _dispatchFinalizationOwners.delete(dispatchId);
4947
+ const waiters = _dispatchFinalizationWaiters.get(dispatchId);
4948
+ if (waiters) {
4949
+ _dispatchFinalizationWaiters.delete(dispatchId);
4950
+ for (const resolve of waiters) resolve();
4951
+ }
4952
+ return true;
4953
+ }
4954
+
4955
+ function waitForDispatchFinalization(dispatchId) {
4956
+ if (!_dispatchFinalizationOwners.has(dispatchId)) return Promise.resolve();
4957
+ return new Promise(resolve => {
4958
+ let waiters = _dispatchFinalizationWaiters.get(dispatchId);
4959
+ if (!waiters) {
4960
+ waiters = new Set();
4961
+ _dispatchFinalizationWaiters.set(dispatchId, waiters);
4962
+ }
4963
+ waiters.add(resolve);
4964
+ });
4965
+ }
4966
+
4738
4967
  // Structured completion protocol — fields agents must produce in ```completion blocks
4739
- const COMPLETION_FIELDS = ['status', 'summary', 'files_changed', 'tests', 'pr', 'not_changed', 'failure_class', 'retryable', 'needs_rerun', 'verdict', 'artifacts', 'nonce', 'securityFlags'];
4968
+ const COMPLETION_FIELDS = ['status', 'summary', 'files_changed', 'tests', 'pr', 'not_changed', 'failure_class', 'retryable', 'needs_rerun', 'verdict', 'artifacts', 'nonce', 'securityFlags', 'reviewLearnings', 'reviewLearningApplications'];
4740
4969
 
4741
4970
  const DEFAULT_AGENT_METRICS = {
4742
4971
  tasksCompleted: 0, tasksErrored: 0,
@@ -4778,13 +5007,14 @@ function _bumpPruneDefaultClaudeStrip() {
4778
5007
  } catch {}
4779
5008
  }
4780
5009
 
4781
- function pruneDefaultClaudeConfig(config) {
5010
+ function pruneDefaultClaudeConfig(config, options = {}) {
5011
+ const trackStrip = options.trackStrip !== false;
4782
5012
  if (!config || typeof config !== 'object') return false;
4783
5013
  const claude = config.claude;
4784
5014
  if (claude === undefined || claude === null) return false;
4785
5015
  if (typeof claude !== 'object' || Array.isArray(claude)) {
4786
5016
  delete config.claude;
4787
- _bumpPruneDefaultClaudeStrip();
5017
+ if (trackStrip) _bumpPruneDefaultClaudeStrip();
4788
5018
  return true;
4789
5019
  }
4790
5020
 
@@ -4811,7 +5041,7 @@ function pruneDefaultClaudeConfig(config) {
4811
5041
  delete config.claude;
4812
5042
  changed = true;
4813
5043
  }
4814
- if (changed) _bumpPruneDefaultClaudeStrip();
5044
+ if (changed && trackStrip) _bumpPruneDefaultClaudeStrip();
4815
5045
  return changed;
4816
5046
  }
4817
5047
 
@@ -5046,6 +5276,54 @@ function formatUnknownProjectError(projectName, projects = []) {
5046
5276
  return `Project "${projectName}" not found. Known projects: ${known}`;
5047
5277
  }
5048
5278
 
5279
+ function _parseProjectRepositoryIdentity(value) {
5280
+ const raw = String(value || '').trim();
5281
+ if (!raw) return null;
5282
+
5283
+ const canonicalScope = _normalizeScopeString(raw);
5284
+ if (canonicalScope) return { kind: 'scope', value: canonicalScope };
5285
+
5286
+ const remoteScope = _parseRemoteUrlToScope(raw);
5287
+ if (remoteScope) return { kind: 'scope', value: remoteScope };
5288
+
5289
+ if (path.isAbsolute(raw) || raw.startsWith('.') || raw.includes('\\') || raw.includes(':')) {
5290
+ return null;
5291
+ }
5292
+ const parts = raw
5293
+ .replace(/\.git$/i, '')
5294
+ .replace(/^\/+|\/+$/g, '')
5295
+ .split('/')
5296
+ .map(normalizePrScopeSegment)
5297
+ .filter(Boolean);
5298
+ if (parts.length === 1) return { kind: 'basename', value: parts[0] };
5299
+ if (parts.length === 2 || parts.length === 3) return { kind: 'slug', value: parts.join('/') };
5300
+ return null;
5301
+ }
5302
+
5303
+ function _findProjectsByRepositoryIdentity(value, projects) {
5304
+ const identity = _parseProjectRepositoryIdentity(value);
5305
+ if (!identity) return [];
5306
+
5307
+ const matches = [];
5308
+ for (const project of projects || []) {
5309
+ if (!project) continue;
5310
+ const scopes = getProjectAllPrScopes(project);
5311
+ const matched = scopes.some(scope => {
5312
+ if (identity.kind === 'scope') return scope === identity.value;
5313
+ const slug = scope.slice(scope.indexOf(':') + 1);
5314
+ if (identity.kind === 'slug') return slug === identity.value;
5315
+ return slug.split('/').pop() === identity.value;
5316
+ });
5317
+ if (matched && !matches.includes(project)) matches.push(project);
5318
+ }
5319
+ return matches;
5320
+ }
5321
+
5322
+ function _formatAmbiguousProjectRepositoryError(value, projects) {
5323
+ const names = [...new Set(projects.map(project => project?.name).filter(Boolean))];
5324
+ return `Repository identity "${value}" is ambiguous; it matches multiple configured projects: ${names.join(', ')}. Use an exact configured project name.`;
5325
+ }
5326
+
5049
5327
  function findProjectByName(projects, projectName) {
5050
5328
  const name = String(projectName || '').trim().toLowerCase();
5051
5329
  if (!name) return null;
@@ -5066,9 +5344,12 @@ function resolveConfiguredProject(projectName, projectsOrConfig, options = {}) {
5066
5344
  const value = String(raw || '').trim();
5067
5345
  if (value) {
5068
5346
  const project = findProjectByNameOrPath(projects, value);
5069
- return project
5070
- ? { project, explicit: true, value }
5071
- : { project: null, explicit: true, value, error: formatUnknownProjectError(value, projects) };
5347
+ if (project) return { project, explicit: true, value };
5348
+ const repositoryMatches = _findProjectsByRepositoryIdentity(value, projects);
5349
+ const error = repositoryMatches.length > 1
5350
+ ? _formatAmbiguousProjectRepositoryError(value, repositoryMatches)
5351
+ : formatUnknownProjectError(value, projects);
5352
+ return { project: null, explicit: true, value, error };
5072
5353
  }
5073
5354
  if (options.defaultWhenSingle && projects.length === 1) {
5074
5355
  return { project: projects[0], explicit: false, value: '' };
@@ -5152,6 +5433,25 @@ function resolveProjectSource(source, projectsOrConfig, options = {}) {
5152
5433
  }
5153
5434
  }
5154
5435
 
5436
+ // Repository identities are fallback aliases; exact configured names and
5437
+ // paths above remain authoritative.
5438
+ const repositoryMatches = _findProjectsByRepositoryIdentity(value, projects);
5439
+ if (repositoryMatches.length === 1) {
5440
+ return _projectSourceDescriptor(repositoryMatches[0], value, explicit, minionsDir);
5441
+ }
5442
+ if (repositoryMatches.length > 1) {
5443
+ return {
5444
+ project: null,
5445
+ explicit: true,
5446
+ value,
5447
+ sourceName: '',
5448
+ isCentral: false,
5449
+ scope: null,
5450
+ stateDir: null,
5451
+ error: _formatAmbiguousProjectRepositoryError(value, repositoryMatches),
5452
+ };
5453
+ }
5454
+
5155
5455
  return {
5156
5456
  project: null,
5157
5457
  explicit: true,
@@ -6216,6 +6516,7 @@ function buildSecurityHeaders() {
6216
6516
  // keys, and shell invocations — never bypass them for "internal" callers.
6217
6517
 
6218
6518
  const PROJECT_NAME_RE = /^[a-zA-Z0-9_\-]{1,64}$/;
6519
+ const CC_TURN_ID_MAX = 80;
6219
6520
 
6220
6521
  function _httpError(status, message, extra) {
6221
6522
  const err = new Error(message);
@@ -6271,6 +6572,14 @@ function validateProjectPath(pathStr, options = {}) {
6271
6572
  if (!fs.existsSync(resolved)) {
6272
6573
  throw _httpError(400, `Invalid project path: directory does not exist: ${resolved}`);
6273
6574
  }
6575
+ let stat;
6576
+ try { stat = fs.statSync(resolved); }
6577
+ catch (e) {
6578
+ throw _httpError(400, `Invalid project path: cannot inspect directory: ${resolved} (${e.message})`);
6579
+ }
6580
+ if (!stat.isDirectory()) {
6581
+ throw _httpError(400, `Invalid project path: path is not a directory: ${resolved}`);
6582
+ }
6274
6583
  const gitMarker = path.join(resolved, '.git');
6275
6584
  if (fs.existsSync(gitMarker)) return resolved; // .git dir OR worktree .git file
6276
6585
 
@@ -6900,20 +7209,66 @@ function getProjectAllPrScopes(project) {
6900
7209
  const _gitRemoteScopeCache = new Map();
6901
7210
  const GIT_REMOTE_SCOPE_CACHE_TTL_MS = 5 * 60 * 1000;
6902
7211
 
7212
+ function _remotePartsToScope(host, values) {
7213
+ const parts = values.map((value, index) => {
7214
+ let decoded = String(value || '');
7215
+ try { decoded = decodeURIComponent(decoded); } catch { /* retain malformed input for a non-match */ }
7216
+ if (index === values.length - 1) decoded = decoded.replace(/\.git$/i, '');
7217
+ return normalizePrScopeSegment(decoded);
7218
+ });
7219
+ if (parts.some(part => !part)) return null;
7220
+ return `${host}:${parts.join('/')}`;
7221
+ }
7222
+
6903
7223
  // Best-effort parse of a git remote fetch URL into a canonical scope string.
6904
7224
  // Handles the URL shapes git itself produces for `git remote -v` output:
6905
7225
  // SSH (`git@host:owner/repo.git`), HTTPS (`https://host/owner/repo.git`),
6906
- // and the two common ADO host forms (`dev.azure.com/org/project/_git/repo`,
6907
- // `org.visualstudio.com/project/_git/repo`). Returns null when unrecognized.
6908
- function _parseRemoteUrlToScope(url) {
6909
- const cleaned = String(url || '').trim().replace(/\.git$/i, '');
6910
- if (!cleaned) return null;
6911
- let m = cleaned.match(/github\.com[:/]+([^/]+)\/([^/]+)\/?$/i);
6912
- if (m) return `github:${normalizePrScopeSegment(m[1])}/${normalizePrScopeSegment(m[2])}`;
6913
- m = cleaned.match(/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)\/?$/i);
6914
- if (m) return `ado:${normalizePrScopeSegment(m[1])}/${normalizePrScopeSegment(m[2])}/${normalizePrScopeSegment(m[3])}`;
6915
- m = cleaned.match(/([^/@:]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\/([^/]+)\/?$/i);
6916
- if (m) return `ado:${normalizePrScopeSegment(m[1])}/${normalizePrScopeSegment(m[2])}/${normalizePrScopeSegment(m[3])}`;
7226
+ // and ADO HTTPS/SSH clone URLs. Returns null when unrecognized.
7227
+ function _parseRemoteUrlToScope(remoteUrl) {
7228
+ const raw = String(remoteUrl || '').trim();
7229
+ if (!raw) return null;
7230
+
7231
+ let normalized = raw;
7232
+ const scpMatch = raw.match(/^(?:([^@\s/:]+)@)?(github\.com|ssh\.dev\.azure\.com):(.+)$/i);
7233
+ if (scpMatch) {
7234
+ const user = scpMatch[1] ? `${scpMatch[1]}@` : '';
7235
+ normalized = `ssh://${user}${scpMatch[2]}/${scpMatch[3]}`;
7236
+ } else if (/^(?:github\.com|dev\.azure\.com|[^/\s]+\.visualstudio\.com)\//i.test(raw)) {
7237
+ normalized = `https://${raw}`;
7238
+ }
7239
+
7240
+ let parsed;
7241
+ try {
7242
+ parsed = new URL(normalized);
7243
+ } catch {
7244
+ return null;
7245
+ }
7246
+ const protocol = parsed.protocol.toLowerCase();
7247
+ if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(protocol)) return null;
7248
+
7249
+ const hostname = parsed.hostname.toLowerCase();
7250
+ const parts = parsed.pathname.split('/').filter(Boolean);
7251
+ if (hostname === 'github.com' && parts.length === 2) {
7252
+ return _remotePartsToScope('github', parts);
7253
+ }
7254
+ if (hostname === 'dev.azure.com' &&
7255
+ parts.length === 4 &&
7256
+ String(parts[2]).toLowerCase() === '_git') {
7257
+ return _remotePartsToScope('ado', [parts[0], parts[1], parts[3]]);
7258
+ }
7259
+ if (hostname.endsWith('.visualstudio.com')) {
7260
+ const org = hostname.slice(0, -'.visualstudio.com'.length);
7261
+ const offset = String(parts[0] || '').toLowerCase() === 'defaultcollection' ? 1 : 0;
7262
+ if (org && parts.length === offset + 3 && String(parts[offset + 1]).toLowerCase() === '_git') {
7263
+ return _remotePartsToScope('ado', [org, parts[offset], parts[offset + 2]]);
7264
+ }
7265
+ }
7266
+ if (hostname === 'ssh.dev.azure.com' &&
7267
+ ['ssh:', 'git+ssh:'].includes(protocol) &&
7268
+ parts.length === 4 &&
7269
+ String(parts[0]).toLowerCase() === 'v3') {
7270
+ return _remotePartsToScope('ado', parts.slice(1));
7271
+ }
6917
7272
  return null;
6918
7273
  }
6919
7274
 
@@ -8050,18 +8405,22 @@ function prFixEvidenceFingerprint(pr, cause = PR_FIX_CAUSE.UNKNOWN) {
8050
8405
 
8051
8406
  function prReviewFindingIdentity(pr) {
8052
8407
  const review = pr?.minionsReview || {};
8408
+ const persisted = String(review.findingIdentity || '').trim().toLowerCase();
8409
+ if (/^review-[0-9a-f]{16}$/.test(persisted)) return persisted;
8053
8410
  const threads = Array.isArray(review.threads)
8054
8411
  ? review.threads.map(value => String(value)).filter(Boolean).sort()
8055
8412
  : [];
8056
8413
  const evidence = {
8057
8414
  pr: pr?.id || pr?.url || '',
8058
8415
  sourceItem: review.sourceItem || '',
8059
- dispatchId: review.dispatchId || '',
8060
8416
  reviewer: review.reviewer || '',
8061
- reviewedAt: review.reviewedAt || pr?.lastReviewedAt || '',
8417
+ reviewedHeadSha: pr?.reviewedHeadSha || '',
8062
8418
  threads,
8063
8419
  note: review.note || pr?.reviewNote || '',
8064
8420
  };
8421
+ if (!evidence.sourceItem && !evidence.reviewedHeadSha) {
8422
+ evidence.dispatchId = review.dispatchId || '';
8423
+ }
8065
8424
  return `review-${crypto.createHash('sha1').update(JSON.stringify(evidence)).digest('hex').slice(0, 16)}`;
8066
8425
  }
8067
8426
 
@@ -8165,6 +8524,7 @@ function isWorktreePathLive(worktreePath, opts = {}) {
8165
8524
  const target = _normalizeWorktreePath(worktreePath);
8166
8525
  if (!target) return false;
8167
8526
  const excludeDispatchId = opts.excludeDispatchId ? String(opts.excludeDispatchId) : null;
8527
+ const excludeSpawnLeaseId = opts.excludeSpawnLeaseId ? String(opts.excludeSpawnLeaseId) : null;
8168
8528
  let db = opts.db || null;
8169
8529
  if (!db) {
8170
8530
  try { db = require('./db').getDb(); }
@@ -8182,7 +8542,8 @@ function isWorktreePathLive(worktreePath, opts = {}) {
8182
8542
  rows = db.prepare(`
8183
8543
  SELECT id,
8184
8544
  json_extract(data, '$.worktreePath') AS top_wt,
8185
- json_extract(data, '$.meta.worktreePath') AS meta_wt
8545
+ json_extract(data, '$.meta.worktreePath') AS meta_wt,
8546
+ json_extract(data, '$._spawnLeaseId') AS spawn_lease_id
8186
8547
  FROM dispatches
8187
8548
  WHERE status IN ('pending', 'active')
8188
8549
  `).all();
@@ -8191,7 +8552,9 @@ function isWorktreePathLive(worktreePath, opts = {}) {
8191
8552
  return true;
8192
8553
  }
8193
8554
  for (const row of rows || []) {
8194
- if (excludeDispatchId && String(row.id) === excludeDispatchId) continue;
8555
+ if (excludeDispatchId && String(row.id) === excludeDispatchId) {
8556
+ if (!excludeSpawnLeaseId || String(row.spawn_lease_id || '') === excludeSpawnLeaseId) continue;
8557
+ }
8195
8558
  if (row.top_wt && _normalizeWorktreePath(row.top_wt) === target) return true;
8196
8559
  if (row.meta_wt && _normalizeWorktreePath(row.meta_wt) === target) return true;
8197
8560
  }
@@ -8214,6 +8577,7 @@ function getWorktreeBlockingDispatchInfo(worktreePath, opts = {}) {
8214
8577
  const target = _normalizeWorktreePath(worktreePath);
8215
8578
  if (!target) return null;
8216
8579
  const excludeDispatchId = opts.excludeDispatchId ? String(opts.excludeDispatchId) : null;
8580
+ const excludeSpawnLeaseId = opts.excludeSpawnLeaseId ? String(opts.excludeSpawnLeaseId) : null;
8217
8581
  let db = opts.db || null;
8218
8582
  if (!db) {
8219
8583
  try { db = require('./db').getDb(); }
@@ -8223,12 +8587,15 @@ function getWorktreeBlockingDispatchInfo(worktreePath, opts = {}) {
8223
8587
  const rows = db.prepare(`
8224
8588
  SELECT id, status, updated_at,
8225
8589
  json_extract(data, '$.worktreePath') AS top_wt,
8226
- json_extract(data, '$.meta.worktreePath') AS meta_wt
8590
+ json_extract(data, '$.meta.worktreePath') AS meta_wt,
8591
+ json_extract(data, '$._spawnLeaseId') AS spawn_lease_id
8227
8592
  FROM dispatches
8228
8593
  WHERE status IN ('pending', 'active')
8229
8594
  `).all();
8230
8595
  for (const row of rows || []) {
8231
- if (excludeDispatchId && String(row.id) === excludeDispatchId) continue;
8596
+ if (excludeDispatchId && String(row.id) === excludeDispatchId) {
8597
+ if (!excludeSpawnLeaseId || String(row.spawn_lease_id || '') === excludeSpawnLeaseId) continue;
8598
+ }
8232
8599
  const topMatch = row.top_wt && _normalizeWorktreePath(row.top_wt) === target;
8233
8600
  const metaMatch = row.meta_wt && _normalizeWorktreePath(row.meta_wt) === target;
8234
8601
  if (topMatch || metaMatch) {
@@ -8319,6 +8686,10 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
8319
8686
  log('warn', `removeWorktree: refusing to remove ${wtPath} — not under ${worktreeRoot}`);
8320
8687
  return false;
8321
8688
  }
8689
+ if (hasProtectedQuarantineMarker(resolved)) {
8690
+ log('warn', `removeWorktree: refusing to remove ${wtPath} — protected quarantine contains work without complete recovery refs`);
8691
+ return false;
8692
+ }
8322
8693
  // ── Data-loss hardening (W-mqecdoot) — NEVER recursively delete a real git
8323
8694
  // main repository. ──────────────────────────────────────────────────────
8324
8695
  // The containment check above only proves `resolved` is *under* the
@@ -8378,6 +8749,7 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
8378
8749
  _writeWorktreeSkipLiveInboxNote(wtPath, 'shared.removeWorktree', blockingInfo);
8379
8750
  return false;
8380
8751
  }
8752
+ const hadOwnerMarker = hasWorktreeOwnerMarker(resolved);
8381
8753
  _pruneRemoveWorktreeFailures();
8382
8754
  // Skip paths that failed 3+ times — retry after 1 hour cooldown
8383
8755
  const prior = _removeWorktreeFailures.get(resolved);
@@ -8433,6 +8805,7 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
8433
8805
  log('warn', `removeWorktree: rd /s /q fallback failed for ${wtPath}: ${rdErr.message}`);
8434
8806
  }
8435
8807
  }
8808
+ _restoreWorktreeOwnerMarkerAfterFailedRemoval(resolved, hadOwnerMarker);
8436
8809
  const fail = _removeWorktreeFailures.get(resolved) || { count: 0, lastAttempt: 0 };
8437
8810
  fail.count++;
8438
8811
  fail.lastAttempt = Date.now();
@@ -8456,6 +8829,9 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
8456
8829
  // ownership before removal; a worktree with NO marker is treated as foreign
8457
8830
  // and left untouched (fail-open: leak a dir rather than nuke someone's work).
8458
8831
  const WORKTREE_OWNER_MARKER = '.minions-worktree';
8832
+ const WORKTREE_RETRY_PATH_MARKER = '.retry-';
8833
+ const WORKTREE_QUARANTINE_PROTECTION_MARKER = '.minions-quarantine-protected';
8834
+ const WORKTREE_QUARANTINE_PROTECTED_PATH_TOKEN = '-quarantine-protected-';
8459
8835
 
8460
8836
  // Best-effort stamp dropped right after a successful `git worktree add`. Never
8461
8837
  // throws — a missing marker only costs us the ability to auto-GC that dir.
@@ -8489,6 +8865,36 @@ function hasWorktreeOwnerMarker(worktreePath) {
8489
8865
  }
8490
8866
  }
8491
8867
 
8868
+ function hasProtectedQuarantineMarker(worktreePath) {
8869
+ if (!worktreePath) return false;
8870
+ try {
8871
+ let current = path.resolve(worktreePath);
8872
+ while (true) {
8873
+ if (path.basename(current).includes(WORKTREE_QUARANTINE_PROTECTED_PATH_TOKEN)
8874
+ || fs.existsSync(path.join(current, WORKTREE_QUARANTINE_PROTECTION_MARKER))) {
8875
+ return true;
8876
+ }
8877
+ const parent = path.dirname(current);
8878
+ if (parent === current) return false;
8879
+ current = parent;
8880
+ }
8881
+ } catch {
8882
+ return true;
8883
+ }
8884
+ }
8885
+
8886
+ function _restoreWorktreeOwnerMarkerAfterFailedRemoval(worktreePath, hadOwnerMarker) {
8887
+ if (!hadOwnerMarker || !worktreePath) return false;
8888
+ let exists = false;
8889
+ try { exists = fs.existsSync(worktreePath); } catch { return false; }
8890
+ if (!exists) return false;
8891
+ try {
8892
+ const gitStat = fs.lstatSync(path.join(path.resolve(worktreePath), '.git'));
8893
+ if (gitStat && gitStat.isDirectory()) return false;
8894
+ } catch { /* a missing .git is expected for a partially removed worktree */ }
8895
+ return writeWorktreeOwnerMarker(worktreePath, { restoredAfterFailedRemoval: true });
8896
+ }
8897
+
8492
8898
  // True when a `git status --porcelain` line refers ONLY to the engine's own
8493
8899
  // worktree-ownership marker (.minions-worktree) at the worktree root (#284).
8494
8900
  // The marker is gitignored in the repo's current tree, but a reused worktree
@@ -8720,6 +9126,77 @@ function createBackoffTracker({ baseMs = 30000, maxMs = 10 * 60 * 1000 } = {}) {
8720
9126
  return { recordFailure, recordSuccess, isBackingOff, getState, _reset, _setForTest };
8721
9127
  }
8722
9128
 
9129
+ // ── npm package identity (W-ms1j8m54000g4a4a) ───────────────────────────────
9130
+ // Minions is published to more than one npm distribution channel: the public
9131
+ // `@yemi33/minions` and internal builds renamed by tools/prepare-package.js
9132
+ // (`INTERNAL_PACKAGE_NAME`, default `@yemishin_microsoft/minions`). Anything
9133
+ // that shells out to `npm view` / `npm install -g` / `npm uninstall -g` must
9134
+ // target the package that is ACTUALLY installed — hardcoding the public name
9135
+ // makes an internal install resolve versions against the wrong channel and
9136
+ // then install a SECOND package that claims the same `minions` bin, which npm
9137
+ // rejects with EEXIST.
9138
+ //
9139
+ // The identity is read from the installed package manifest rather than a
9140
+ // build-time constant, so a single codebase behaves correctly on every channel.
9141
+ const PUBLIC_PACKAGE_NAME = '@yemi33/minions';
9142
+
9143
+ // npm's package-name rules, restricted to the subset we need: lowercase,
9144
+ // url-safe, optional `@scope/` prefix, ≤214 chars. Also the security gate for
9145
+ // the manifest-derived name — the result is interpolated into a shell command,
9146
+ // so anything outside this grammar (spaces, `;`, `&&`, path traversal) is
9147
+ // rejected rather than escaped.
9148
+ const NPM_PACKAGE_NAME_RE = /^(?:@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*$/;
9149
+ // Exact versions only (never a floating tag) — `npm view` output is echoed
9150
+ // straight back into `npm install -g <pkg>@<version>`.
9151
+ const NPM_VERSION_RE = /^[0-9][0-9a-zA-Z.+-]*$/;
9152
+
9153
+ function isValidNpmPackageName(name) {
9154
+ if (typeof name !== 'string' || !name || name.length > 214) return false;
9155
+ return NPM_PACKAGE_NAME_RE.test(name);
9156
+ }
9157
+
9158
+ function isValidNpmVersion(version) {
9159
+ if (typeof version !== 'string' || !version || version.length > 256) return false;
9160
+ return NPM_VERSION_RE.test(version);
9161
+ }
9162
+
9163
+ // Resolve the active distribution's package name from `<pkgRoot>/package.json`.
9164
+ // Read fresh from disk (not require()) so an in-place npm update is observed
9165
+ // within the same process. Falls back to the public package whenever the
9166
+ // manifest is missing, unreadable, or carries a name we would not be willing to
9167
+ // put on a command line.
9168
+ function resolvePackageName(pkgRoot) {
9169
+ if (typeof pkgRoot !== 'string' || !pkgRoot) return PUBLIC_PACKAGE_NAME;
9170
+ try {
9171
+ const name = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8')).name;
9172
+ if (isValidNpmPackageName(name)) return name;
9173
+ } catch {}
9174
+ return PUBLIC_PACKAGE_NAME;
9175
+ }
9176
+
9177
+ function assertNpmPackageName(pkgName) {
9178
+ if (!isValidNpmPackageName(pkgName)) {
9179
+ throw new Error(`Refusing to run npm against an invalid package name: ${JSON.stringify(pkgName)}`);
9180
+ }
9181
+ return pkgName;
9182
+ }
9183
+
9184
+ function buildNpmViewVersionCommand(pkgName) {
9185
+ return `npm view ${assertNpmPackageName(pkgName)} version`;
9186
+ }
9187
+
9188
+ function buildNpmGlobalInstallCommand(pkgName, version) {
9189
+ assertNpmPackageName(pkgName);
9190
+ if (!isValidNpmVersion(version)) {
9191
+ throw new Error(`Refusing to install an invalid npm version: ${JSON.stringify(version)}`);
9192
+ }
9193
+ return `npm install -g ${pkgName}@${version}`;
9194
+ }
9195
+
9196
+ function buildNpmGlobalUninstallCommand(pkgName) {
9197
+ return `npm uninstall -g ${assertNpmPackageName(pkgName)}`;
9198
+ }
9199
+
8723
9200
  module.exports = {
8724
9201
  MINIONS_DIR,
8725
9202
  ENGINE_DIR,
@@ -8752,6 +9229,14 @@ module.exports = {
8752
9229
  // Node / node:sqlite version gate (issue #244)
8753
9230
  NODE_SQLITE_MIN_VERSION,
8754
9231
  compareDottedVersions,
9232
+ // npm distribution identity (W-ms1j8m54000g4a4a)
9233
+ PUBLIC_PACKAGE_NAME,
9234
+ isValidNpmPackageName,
9235
+ isValidNpmVersion,
9236
+ resolvePackageName,
9237
+ buildNpmViewVersionCommand,
9238
+ buildNpmGlobalInstallCommand,
9239
+ buildNpmGlobalUninstallCommand,
8755
9240
  nodeSupportsBuiltinSqlite,
8756
9241
  nodeSqliteRemediationLine,
8757
9242
  log,
@@ -8761,6 +9246,7 @@ module.exports = {
8761
9246
  safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore,
8762
9247
  safeWrite,
8763
9248
  mutateTextFileLocked,
9249
+ DELETE_JSON_FILE,
8764
9250
  safeUnlink,
8765
9251
  createDispatchTmpDir,
8766
9252
  removeDispatchTmpDir,
@@ -8825,6 +9311,8 @@ module.exports = {
8825
9311
  cleanChildEnv,
8826
9312
  gitEnv,
8827
9313
  parseStreamJsonOutput,
9314
+ isStaleResumeSessionSignal, // W-ms0vk9nv000z985b — stale `--resume` session/cwd detection
9315
+ staleResumeSessionCwdReason, // W-ms0vk9nv000z985b — refuse resuming a session anchored to a dead/other worktree
8828
9316
  KB_CATEGORIES,
8829
9317
  KB_READABLE_CATEGORIES,
8830
9318
  classifyInboxItem,
@@ -8836,6 +9324,7 @@ module.exports = {
8836
9324
  resolvePollFlag, // P-c4d8e1a3 — granular per-poller flag resolution
8837
9325
  resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentUseWorkerPool, resolveAgentAcpPoolSize, resolveAgentModel, resolveCcModel,
8838
9326
  resolveAgentAllowedTools, resolveAgentMaxBudget, resolveAgentBareMode, resolvePropagateClaudeMdForNonClaudeRuntimes,
9327
+ resolveCreatePrsAsDraft,
8839
9328
  applyLegacyCcModelMigration, _resetLegacyCcModelMigrationFlag,
8840
9329
  runtimeConfigWarnings,
8841
9330
  projectWorkSourceWarnings,
@@ -8846,6 +9335,8 @@ module.exports = {
8846
9335
  WATCH_STALLED_DEFAULT_TICKS, WATCH_STUCK_STAGE_DEFAULT_TICKS,
8847
9336
  PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
8848
9337
  FAILURE_CLASS, COMPLETION_FIELDS,
9338
+ resolveTerminalDispatchResult, resolveTerminalWorkerState,
9339
+ claimDispatchFinalization, releaseDispatchFinalization, waitForDispatchFinalization,
8849
9340
  DEFAULT_AGENT_METRICS,
8850
9341
  DEFAULT_AGENTS,
8851
9342
  DEFAULT_CLAUDE,
@@ -8961,6 +9452,7 @@ module.exports = {
8961
9452
  HAS_DANGEROUS_KEY_MAX_DEPTH,
8962
9453
  HAS_DANGEROUS_KEY_MAX_NODES,
8963
9454
  validateProjectName,
9455
+ CC_TURN_ID_MAX,
8964
9456
  validateProjectPath,
8965
9457
  CHECKOUT_MODES,
8966
9458
  validateCheckoutMode,
@@ -8993,8 +9485,13 @@ module.exports = {
8993
9485
  isWorktreePathLive,
8994
9486
  getWorktreeBlockingDispatchInfo,
8995
9487
  WORKTREE_OWNER_MARKER,
9488
+ WORKTREE_RETRY_PATH_MARKER,
9489
+ WORKTREE_QUARANTINE_PROTECTION_MARKER,
9490
+ WORKTREE_QUARANTINE_PROTECTED_PATH_TOKEN,
8996
9491
  writeWorktreeOwnerMarker,
8997
9492
  hasWorktreeOwnerMarker,
9493
+ hasProtectedQuarantineMarker,
9494
+ _restoreWorktreeOwnerMarkerAfterFailedRemoval, // exported for testing
8998
9495
  isWorktreeOwnerMarkerStatusLine,
8999
9496
  _normalizeWorktreePath, // exported for testing
9000
9497
  _writeWorktreeSkipLiveInboxNote, // exported for testing