@yemi33/minions 0.1.2382 → 0.1.2384

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 (51) hide show
  1. package/bin/minions.js +1 -0
  2. package/dashboard/js/refresh.js +2 -1
  3. package/dashboard/js/settings.js +1 -1
  4. package/dashboard.js +37 -19
  5. package/docs/completion-reports.md +20 -1
  6. package/docs/keep-processes.md +7 -2
  7. package/docs/live-checkout-mode.md +7 -7
  8. package/docs/managed-spawn.md +14 -1
  9. package/docs/runtime-adapters.md +61 -26
  10. package/docs/skills.md +2 -0
  11. package/docs/workspace-manifests.md +1 -1
  12. package/docs/worktree-lifecycle.md +36 -0
  13. package/engine/acp-transport.js +273 -58
  14. package/engine/ado-comment.js +3 -1
  15. package/engine/agent-worker-pool.js +278 -54
  16. package/engine/cc-worker-pool.js +12 -3
  17. package/engine/cleanup.js +15 -11
  18. package/engine/cli.js +56 -28
  19. package/engine/comment-format.js +51 -14
  20. package/engine/create-pr-worktree.js +57 -79
  21. package/engine/dispatch.js +7 -2
  22. package/engine/execution-model.js +68 -0
  23. package/engine/gh-comment.js +6 -3
  24. package/engine/github.js +6 -7
  25. package/engine/keep-process-sweep.js +131 -9
  26. package/engine/lifecycle.js +126 -45
  27. package/engine/live-checkout.js +4 -2
  28. package/engine/llm.js +59 -83
  29. package/engine/managed-spawn.js +112 -5
  30. package/engine/memory-store.js +3 -1
  31. package/engine/playbook.js +4 -6
  32. package/engine/pooled-agent-process.js +512 -45
  33. package/engine/pr-action.js +6 -8
  34. package/engine/process-utils.js +154 -18
  35. package/engine/runtimes/claude.js +94 -25
  36. package/engine/runtimes/codex.js +1 -0
  37. package/engine/runtimes/contract.js +239 -0
  38. package/engine/runtimes/copilot.js +97 -9
  39. package/engine/runtimes/index.js +37 -9
  40. package/engine/shared.js +349 -82
  41. package/engine/spawn-agent.js +85 -116
  42. package/engine/spawn-phase-watchdog.js +8 -37
  43. package/engine/timeout.js +14 -10
  44. package/engine/worktree-gc.js +55 -46
  45. package/engine.js +418 -241
  46. package/package.json +1 -1
  47. package/playbooks/fix.md +20 -0
  48. package/playbooks/review.md +2 -0
  49. package/playbooks/shared-rules.md +2 -2
  50. package/prompts/cc-system.md +11 -11
  51. package/skills/check-self-authored-review-comment/SKILL.md +34 -0
package/engine/shared.js CHANGED
@@ -920,6 +920,9 @@ 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;
923
926
 
924
927
  function safeWrite(p, data) {
925
928
  const dir = path.dirname(p);
@@ -929,13 +932,16 @@ function safeWrite(p, data) {
929
932
  try {
930
933
  fs.writeFileSync(tmp, content);
931
934
  // Atomic rename — retry on Windows EPERM (file locking)
932
- for (let attempt = 0; attempt < 5; attempt++) {
935
+ for (let attempt = 0; attempt < SAFE_WRITE_RENAME_ATTEMPTS; attempt++) {
933
936
  try {
934
937
  fs.renameSync(tmp, p);
935
938
  return;
936
939
  } catch (e) {
937
- if (e.code === 'EPERM' && attempt < 4) {
938
- const delay = 50 * (attempt + 1); // 50, 100, 150, 200ms
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
+ );
939
945
  sleepMs(delay);
940
946
  continue;
941
947
  }
@@ -946,7 +952,7 @@ function safeWrite(p, data) {
946
952
  }
947
953
  // All rename attempts exhausted without throw — should not happen, but clean up
948
954
  try { fs.unlinkSync(tmp); } catch { /* cleanup */ }
949
- throw new Error(`[safeWrite] All 5 rename attempts failed for ${p}`);
955
+ throw new Error(`[safeWrite] All ${SAFE_WRITE_RENAME_ATTEMPTS} rename attempts failed for ${p}`);
950
956
  } catch (err) {
951
957
  // Clean up tmp if it still exists, then re-throw — never silently swallow
952
958
  try { fs.unlinkSync(tmp); } catch { /* cleanup */ }
@@ -1406,6 +1412,104 @@ function pruneCrashDiagnosticsReports(config) {
1406
1412
  return removed;
1407
1413
  }
1408
1414
 
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
+
1409
1513
  function withFileLock(lockPath, fn, {
1410
1514
  timeoutMs = 5000,
1411
1515
  retryDelayMs = 25,
@@ -1420,9 +1524,13 @@ function withFileLock(lockPath, fn, {
1420
1524
  const backoff = retryBackoffMs * Math.pow(2, attempt - 1);
1421
1525
  sleepMs(backoff);
1422
1526
  }
1423
- const start = Date.now();
1424
1527
  let fd = null;
1425
- while (Date.now() - start < timeoutMs) {
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) {
1426
1534
  try {
1427
1535
  const dir = path.dirname(lockPath);
1428
1536
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
@@ -1432,7 +1540,11 @@ function withFileLock(lockPath, fn, {
1432
1540
  // the lock's existence (not its contents) provides mutual exclusion, so
1433
1541
  // a write failure here must NOT abort acquisition.
1434
1542
  try {
1435
- fs.writeSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
1543
+ fs.writeSync(fd, JSON.stringify({
1544
+ pid: process.pid,
1545
+ ts: Date.now(),
1546
+ owner: crypto.randomBytes(8).toString('hex'),
1547
+ }));
1436
1548
  } catch { /* payload is advisory; lock semantics unaffected */ }
1437
1549
  break;
1438
1550
  } catch (err) {
@@ -1447,47 +1559,27 @@ function withFileLock(lockPath, fn, {
1447
1559
  const isLockRaceErr = err.code === 'EEXIST' ||
1448
1560
  (process.platform === 'win32' && err.code === 'EPERM');
1449
1561
  if (!isLockRaceErr) throw err;
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
1562
+ const ownerIdentity = _readFileLockIdentity(lockPath);
1563
+ if (ownerIdentity !== null && ownerIdentity !== lastOwnerIdentity) {
1564
+ if (lastOwnerIdentity !== undefined) {
1565
+ waitedMs = 0;
1566
+ contentionCount = 0;
1481
1567
  }
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;
1568
+ lastOwnerIdentity = ownerIdentity;
1489
1569
  }
1490
- sleepMs(retryDelayMs);
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;
1491
1583
  }
1492
1584
  }
1493
1585
  if (fd === null) {
@@ -1503,16 +1595,14 @@ function withFileLock(lockPath, fn, {
1503
1595
  // completes, silently breaking mutual exclusion. Clean up our own fd /
1504
1596
  // lock first, then throw so the caller cannot ignore the failure.
1505
1597
  if (result && typeof result.then === 'function') {
1506
- try { fs.closeSync(fd); } catch { /* cleanup */ }
1507
- try { fs.unlinkSync(lockPath); } catch { /* cleanup */ }
1598
+ _releaseFileLock(lockPath, fd);
1508
1599
  fd = null; // suppress double-cleanup in `finally`
1509
1600
  throw new Error('withFileLock: fn must be synchronous; got Promise. Use synchronous operations only.');
1510
1601
  }
1511
1602
  return result;
1512
1603
  } finally {
1513
1604
  if (fd !== null) {
1514
- try { fs.closeSync(fd); } catch { /* cleanup */ }
1515
- try { fs.unlinkSync(lockPath); } catch { /* cleanup */ }
1605
+ _releaseFileLock(lockPath, fd);
1516
1606
  }
1517
1607
  }
1518
1608
  }
@@ -2602,6 +2692,62 @@ function isLiveCheckoutProject(project) {
2602
2692
  return resolveCheckoutMode(project) === CHECKOUT_MODES.LIVE;
2603
2693
  }
2604
2694
 
2695
+ // Persisted dispatch records must carry explicit checkout-mode provenance before
2696
+ // restart/orphan recovery may operate on project.localPath. Legacy live records
2697
+ // are accepted only while the current configured project still resolves live for
2698
+ // that work-item type. Any explicit worktree marker or current worktree config
2699
+ // fails closed.
2700
+ function isLiveCheckoutDispatchRecord(item, config) {
2701
+ if (!item || typeof item !== 'object') return false;
2702
+ const hasCheckoutMarker = Object.prototype.hasOwnProperty.call(item, 'checkoutMode');
2703
+ const hasLegacyMarker = Object.prototype.hasOwnProperty.call(item, 'liveMode');
2704
+ let explicitlyLive = false;
2705
+ if (hasCheckoutMarker) {
2706
+ if (item.checkoutMode !== CHECKOUT_MODES.LIVE) return false;
2707
+ explicitlyLive = true;
2708
+ } else if (hasLegacyMarker) {
2709
+ if (item.liveMode !== true) return false;
2710
+ explicitlyLive = true;
2711
+ }
2712
+
2713
+ if (config !== undefined && config !== null) {
2714
+ let projects;
2715
+ try { projects = getProjects(config); } catch { return false; }
2716
+ const projectRef = item.meta?.project;
2717
+ const resolved = resolveProjectSource(projectRef, projects, { allowCentral: false }).project;
2718
+ if (!resolved || resolveCheckoutMode(resolved, item.type) !== CHECKOUT_MODES.LIVE) return false;
2719
+ if (!projectRef?.localPath || !resolved.localPath
2720
+ || !sameResolvedPath(projectRef.localPath, resolved.localPath)) return false;
2721
+ return explicitlyLive || !!item.originalRef;
2722
+ }
2723
+
2724
+ return explicitlyLive;
2725
+ }
2726
+
2727
+ function isDispatchExecutionPathSafe(item, config) {
2728
+ if (!item || typeof item !== 'object') return false;
2729
+ let projects;
2730
+ try { projects = getProjects(config); } catch { return false; }
2731
+
2732
+ if (item.worktreePath) {
2733
+ try {
2734
+ assertWorktreeOutsideProjects(item.worktreePath, projects);
2735
+ return isValidGitWorktree(item.worktreePath).ok;
2736
+ } catch {
2737
+ return false;
2738
+ }
2739
+ }
2740
+ if (isLiveCheckoutDispatchRecord(item, config)) return true;
2741
+ if (item.meta?.project) return false;
2742
+ if (!item.executionCwd) return false;
2743
+ try {
2744
+ assertWorktreeOutsideProjects(item.executionCwd, projects);
2745
+ return fs.existsSync(item.executionCwd);
2746
+ } catch {
2747
+ return false;
2748
+ }
2749
+ }
2750
+
2605
2751
  // W-mqvejug6000eeb20 — resolve the effective live-checkout auto-reset decision.
2606
2752
  // Precedence: an explicit per-project boolean (`project.liveCheckoutAutoReset`)
2607
2753
  // wins; otherwise the fleet-wide `engine.liveCheckoutAutoReset` applies;
@@ -3428,9 +3574,9 @@ function resolvePollFlag(engineCfg, granularKey, legacyMacroKey) {
3428
3574
 
3429
3575
  // ─── Runtime Fleet Resolution (P-3b8e5f1d) ──────────────────────────────────
3430
3576
  //
3431
- // Six helpers that are the single source of truth for "which CLI runtime + model
3432
- // + budget + bare-mode applies to this spawn?". Engine code MUST go through
3433
- // these — never read `agent.cli`, `engine.defaultCli`, etc. directly. Future
3577
+ // Runtime helpers are the single source of truth for "which CLI runtime + model
3578
+ // + tool ceiling + budget + bare-mode applies to this spawn?". Engine code MUST
3579
+ // go through these — never read `agent.cli`, `engine.defaultCli`, etc. directly. Future
3434
3580
  // agents adding new resolution rules should extend these helpers, not bypass
3435
3581
  // them.
3436
3582
  //
@@ -3476,28 +3622,27 @@ function resolveCcCli(engine) {
3476
3622
  /**
3477
3623
  * Resolve whether the Command Center / doc-chat path should route through the
3478
3624
  * persistent ACP worker pool. Priority:
3479
- * 1. CC runtime guard pool spawns `copilot --acp` (Agent Client Protocol).
3480
- * Claude Code does not implement ACP, and `_invokeCcStream` would silently
3481
- * switch a Claude operator's CC turns to copilot if the pool ran for them.
3482
- * Refuse the pool when CC runtime is not copilot, even if explicit-true is
3483
- * set. Future runtimes that don't implement ACP fall into the same bucket.
3625
+ * 1. Runtime capability guard. Refuse the pool unless the selected adapter
3626
+ * declares `acpWorkerPool`, even if explicit-true is set, so pooling can
3627
+ * never switch the configured harness.
3484
3628
  * (W-mphlriic00095f69)
3485
3629
  * 2. `engine.ccUseWorkerPool` explicit true/false — operator override wins
3486
- * *within* the copilot runtime
3487
- * 3. Runtime-aware default: ON when CC runtime resolves to `copilot`
3488
- * (Copilot's cold-spawn is 18-21s on Windows — pool turns subsequent turns
3489
- * from 47-140s back into a few seconds).
3630
+ * within an ACP-capable runtime.
3631
+ * 3. Runtime-aware default: ON for any ACP-capable adapter.
3490
3632
  * 4. ENGINE_DEFAULTS.ccUseWorkerPool — final fallback
3491
3633
  *
3492
3634
  * Strict boolean check on the override so a literal `false` opts out even on
3493
- * Copilot, matching the "treat empty/null/undefined as unset" semantics used
3635
+ * ACP runtimes, matching the "treat empty/null/undefined as unset" semantics used
3494
3636
  * throughout the resolve* helpers for boolean flags.
3495
3637
  */
3496
3638
  function resolveCcUseWorkerPool(engine) {
3497
- // Guard 1 (W-mphlriic00095f69): pool transport is ACP-only. If CC runtime
3498
- // isn't copilot, the pool cannot run regardless of operator opt-in — don't
3499
- // silently switch their runtime to copilot.
3500
- if (resolveCcCli(engine) !== 'copilot') return false;
3639
+ let runtime;
3640
+ try {
3641
+ runtime = require('./runtimes').resolveRuntime(resolveCcCli(engine));
3642
+ } catch {
3643
+ return false;
3644
+ }
3645
+ if (runtime?.capabilities?.acpWorkerPool !== true) return false;
3501
3646
  if (engine && (engine.ccUseWorkerPool === true || engine.ccUseWorkerPool === false)) {
3502
3647
  return engine.ccUseWorkerPool;
3503
3648
  }
@@ -3511,7 +3656,7 @@ function resolveCcUseWorkerPool(engine) {
3511
3656
  * adapter (as returned by `resolveRuntime(resolveAgentCli(agent, engine))`)
3512
3657
  * rather than re-deriving it, since callers on the agent-spawn path already
3513
3658
  * have it in hand. Priority:
3514
- * 1. Capability guard — pool transport is ACP-only (`copilot --acp`). Hard
3659
+ * 1. Capability guard — pool transport is ACP-only. Hard
3515
3660
  * false whenever `runtime.capabilities.acpWorkerPool` isn't `true`,
3516
3661
  * regardless of operator override, so a future non-ACP runtime can never
3517
3662
  * be silently routed through the pool (same rationale as
@@ -3585,6 +3730,17 @@ function resolveCcModel(engine) {
3585
3730
  return undefined;
3586
3731
  }
3587
3732
 
3733
+ /**
3734
+ * Resolve the fleet tool ceiling for an agent spawn.
3735
+ *
3736
+ * `config.claude.allowedTools` predates runtime adapters but has always applied
3737
+ * to every selected runtime. Keep that cross-runtime contract centralized here
3738
+ * until the field is migrated to a runtime-neutral config namespace.
3739
+ */
3740
+ function resolveAgentAllowedTools(config) {
3741
+ return config?.claude?.allowedTools;
3742
+ }
3743
+
3588
3744
  /**
3589
3745
  * Resolve the per-spawn USD budget cap. Priority:
3590
3746
  * 1. `agent.maxBudgetUsd` — per-agent override
@@ -5473,6 +5629,11 @@ function isWorktreeRootInfraEntry(name) {
5473
5629
  return typeof name === 'string' && name.length > 0 && name.charCodeAt(0) === 46; // '.'
5474
5630
  }
5475
5631
 
5632
+ function _pathForComparison(filePath) {
5633
+ const resolved = realPathForComparison(String(filePath));
5634
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
5635
+ }
5636
+
5476
5637
  /**
5477
5638
  * True when `childPath` is strictly nested within `parentPath` (descendant,
5478
5639
  * NOT the same path). Cross-platform via `path.relative`; resilient to mixed
@@ -5481,8 +5642,8 @@ function isWorktreeRootInfraEntry(name) {
5481
5642
  */
5482
5643
  function isPathInside(childPath, parentPath) {
5483
5644
  if (!childPath || !parentPath) return false;
5484
- const childAbs = path.resolve(String(childPath));
5485
- const parentAbs = path.resolve(String(parentPath));
5645
+ const childAbs = _pathForComparison(childPath);
5646
+ const parentAbs = _pathForComparison(parentPath);
5486
5647
  const rel = path.relative(parentAbs, childAbs);
5487
5648
  if (rel === '') return false;
5488
5649
  if (rel.startsWith('..')) return false;
@@ -5502,12 +5663,17 @@ function isPathInside(childPath, parentPath) {
5502
5663
  */
5503
5664
  function isPathInsideOrEqual(childPath, parentPath) {
5504
5665
  if (!childPath || !parentPath) return false;
5505
- const childAbs = path.resolve(String(childPath));
5506
- const parentAbs = path.resolve(String(parentPath));
5666
+ const childAbs = _pathForComparison(childPath);
5667
+ const parentAbs = _pathForComparison(parentPath);
5507
5668
  if (childAbs === parentAbs) return true;
5508
5669
  return isPathInside(childAbs, parentAbs);
5509
5670
  }
5510
5671
 
5672
+ function pathsOverlap(firstPath, secondPath) {
5673
+ return isPathInsideOrEqual(firstPath, secondPath)
5674
+ || isPathInsideOrEqual(secondPath, firstPath);
5675
+ }
5676
+
5511
5677
  /**
5512
5678
  * Parse `git worktree list --porcelain` output. Pure — callers run the
5513
5679
  * subprocess (sync `execSilent` or async `execAsync`) and feed stdout in.
@@ -5562,6 +5728,22 @@ function assertWorktreeOutsideProject(worktreePath, projectRoot) {
5562
5728
  throw err;
5563
5729
  }
5564
5730
 
5731
+ function assertWorktreeOutsideProjects(worktreePath, projectsOrConfig) {
5732
+ const projects = Array.isArray(projectsOrConfig)
5733
+ ? projectsOrConfig
5734
+ : getProjects(projectsOrConfig);
5735
+ for (const project of projects) {
5736
+ if (!project?.localPath) continue;
5737
+ if (!pathsOverlap(worktreePath, project.localPath)) continue;
5738
+ const err = new Error(
5739
+ `Refusing worktree path "${worktreePath}" — it overlaps configured operator checkout "${project.localPath}".`
5740
+ );
5741
+ err.code = 'WORKTREE_NESTED_IN_PROJECT';
5742
+ err.protectedProject = project.name || '';
5743
+ throw err;
5744
+ }
5745
+ }
5746
+
5565
5747
  /**
5566
5748
  * Resolve the project root directory used as the parent for git worktree paths
5567
5749
  * during dispatch. Centralizes the fallback that engine spawnAgent used to do
@@ -5702,9 +5884,11 @@ const READ_ONLY_ROOT_TASK_TYPES = new Set(['meeting', 'ask', 'explore', 'plan-to
5702
5884
  * @param {string} type — work type (e.g. 'fix', 'explore', 'meeting')
5703
5885
  * @param {string} minionsDir — MINIONS_DIR fallback anchor (ignored in live mode)
5704
5886
  * @param {{ workdir?: string|null }} [options] — optional per-WI overrides
5705
- * @returns {{ cwd: string|null, worktreeRootDir: string|null, liveMode?: boolean, workdir?: string|null }}
5887
+ * @returns {{ cwd: string|null, worktreeRootDir: string|null, liveMode?: boolean, readOnlyWorktree?: boolean, workdir?: string|null }}
5706
5888
  * - For live mode (any type): { cwd: <abs localPath[/workdir]>, worktreeRootDir: null, liveMode: true }
5707
- * - For isolated read-only types: { cwd: <project dir[/workdir] or MINIONS_DIR>, worktreeRootDir: null }
5889
+ * - For project-bound isolated read-only types:
5890
+ * { cwd: null, worktreeRootDir: <project root>, readOnlyWorktree: true, workdir }
5891
+ * - For project-less read-only types: { cwd: <MINIONS_DIR[/workdir]>, worktreeRootDir: null }
5708
5892
  * - For isolated code-mutating types: { cwd: null, worktreeRootDir: <project root>, workdir: <subpath|null> }
5709
5893
  * (caller defaults cwd to worktreeRootDir before worktree creation,
5710
5894
  * then runs shared.applyWorkdir(worktreePath, result.workdir) to land
@@ -5747,14 +5931,21 @@ function resolveSpawnPaths(project, type, minionsDir, options) {
5747
5931
 
5748
5932
  const isReadOnly = READ_ONLY_ROOT_TASK_TYPES.has(type);
5749
5933
  if (isReadOnly) {
5750
- let base = null;
5751
- if (project?.localPath) base = path.resolve(String(project.localPath));
5752
- else if (minionsDir) base = path.resolve(String(minionsDir));
5753
- else {
5934
+ if (project?.localPath) {
5935
+ if (workdir) _applyWorkdirOrThrow(path.resolve(String(project.localPath)), workdir);
5936
+ return {
5937
+ cwd: null,
5938
+ worktreeRootDir: resolveProjectRootDir(project.localPath, minionsDir),
5939
+ readOnlyWorktree: true,
5940
+ workdir,
5941
+ };
5942
+ }
5943
+ if (!minionsDir) {
5754
5944
  const err = new Error('Cannot resolve cwd for read-only spawn: no project.localPath and no MINIONS_DIR provided.');
5755
5945
  err.code = 'WORKTREE_ROOTDIR_MISSING_BASE';
5756
5946
  throw err;
5757
5947
  }
5948
+ const base = path.resolve(String(minionsDir));
5758
5949
  const roCwd = _applyWorkdirOrThrow(base, workdir);
5759
5950
  return { cwd: roCwd, worktreeRootDir: null };
5760
5951
  }
@@ -5868,7 +6059,7 @@ function applyWorkdir(base, workdir) {
5868
6059
  const baseAbs = path.resolve(String(base));
5869
6060
  const joined = path.resolve(baseAbs, workdir);
5870
6061
  // Containment guard: resolved cwd must equal base or live strictly inside.
5871
- if (joined !== baseAbs && !joined.startsWith(baseAbs + path.sep)) {
6062
+ if (!isPathInsideOrEqual(joined, baseAbs)) {
5872
6063
  return {
5873
6064
  cwd: baseAbs,
5874
6065
  error: 'workdir resolved outside base (containment escape): "' + workdir + '" → "' + joined + '" not inside "' + baseAbs + '"',
@@ -6043,7 +6234,7 @@ function validateProjectPath(pathStr, options = {}) {
6043
6234
 
6044
6235
  function parseSkillFrontmatter(content, filename) {
6045
6236
  let name = filename.replace('.md', '');
6046
- let trigger = '', description = '', project = 'any', author = '', created = '', allowedTools = '';
6237
+ let trigger = '', description = '', project = 'any', author = '', created = '', allowedTools = '', scope = 'minions';
6047
6238
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
6048
6239
  if (fmMatch) {
6049
6240
  const fm = fmMatch[1];
@@ -6055,8 +6246,52 @@ function parseSkillFrontmatter(content, filename) {
6055
6246
  author = m('author');
6056
6247
  created = m('created');
6057
6248
  allowedTools = m('allowed-tools');
6249
+ scope = m('scope') || scope;
6250
+ }
6251
+ return { name, trigger, description, project, author, created, allowedTools, scope };
6252
+ }
6253
+
6254
+ function syncBundledPersonalSkills(skillsDir, opts = {}) {
6255
+ const result = { created: [], updated: [], unchanged: [] };
6256
+ if (!skillsDir || !fs.existsSync(skillsDir)) return result;
6257
+
6258
+ const homeDir = opts.homeDir || os.homedir();
6259
+ const { listRuntimes, resolveRuntime } = require('./runtimes');
6260
+ const personalRoots = new Set();
6261
+ for (const runtimeName of listRuntimes()) {
6262
+ const runtime = resolveRuntime(runtimeName);
6263
+ if (typeof runtime.getSkillWriteTargets !== 'function') continue;
6264
+ const personal = runtime.getSkillWriteTargets({ homeDir }).personal;
6265
+ if (typeof personal === 'string' && personal.trim()) {
6266
+ personalRoots.add(path.resolve(personal));
6267
+ }
6268
+ }
6269
+
6270
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true })
6271
+ .filter(entry => entry.isDirectory())
6272
+ .sort((a, b) => a.name.localeCompare(b.name));
6273
+ for (const entry of entries) {
6274
+ const sourcePath = path.join(skillsDir, entry.name, 'SKILL.md');
6275
+ if (!fs.existsSync(sourcePath)) continue;
6276
+ const content = fs.readFileSync(sourcePath, 'utf8');
6277
+ const skill = parseSkillFrontmatter(content, `${entry.name}.md`);
6278
+ if (skill.scope !== 'minions') continue;
6279
+ const skillName = String(skill.name || entry.name).replace(/[^a-z0-9-]/g, '-');
6280
+ if (!skillName) throw new Error(`Bundled skill has an invalid name: ${sourcePath}`);
6281
+
6282
+ for (const personalRoot of personalRoots) {
6283
+ const targetPath = path.join(personalRoot, skillName, 'SKILL.md');
6284
+ const exists = fs.existsSync(targetPath);
6285
+ if (exists && fs.readFileSync(targetPath, 'utf8') === content) {
6286
+ result.unchanged.push(targetPath);
6287
+ continue;
6288
+ }
6289
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
6290
+ fs.copyFileSync(sourcePath, targetPath);
6291
+ result[exists ? 'updated' : 'created'].push(targetPath);
6292
+ }
6058
6293
  }
6059
- return { name, trigger, description, project, author, created, allowedTools };
6294
+ return result;
6060
6295
  }
6061
6296
 
6062
6297
  // ── PR → PRD Links ────────────────────────────────────────────────────────────
@@ -7740,6 +7975,7 @@ function prFixEvidenceFingerprint(pr, cause = PR_FIX_CAUSE.UNKNOWN) {
7740
7975
  evidence.lastReviewedAt = pr?.lastReviewedAt || '';
7741
7976
  evidence.reviewedAt = review.reviewedAt || '';
7742
7977
  evidence.reviewNote = review.note || pr?.reviewNote || '';
7978
+ evidence.findingId = prReviewFindingIdentity(pr);
7743
7979
  // #2979 — same rationale as BUILD_FAILURE: review feedback fingerprints
7744
7980
  // were sticky across force-push because reviewStatus / reviewedAt /
7745
7981
  // reviewNote don't change when the author rebases. Adding the head SHA
@@ -7751,6 +7987,23 @@ function prFixEvidenceFingerprint(pr, cause = PR_FIX_CAUSE.UNKNOWN) {
7751
7987
  return crypto.createHash('sha1').update(JSON.stringify(evidence)).digest('hex').slice(0, 16);
7752
7988
  }
7753
7989
 
7990
+ function prReviewFindingIdentity(pr) {
7991
+ const review = pr?.minionsReview || {};
7992
+ const threads = Array.isArray(review.threads)
7993
+ ? review.threads.map(value => String(value)).filter(Boolean).sort()
7994
+ : [];
7995
+ const evidence = {
7996
+ pr: pr?.id || pr?.url || '',
7997
+ sourceItem: review.sourceItem || '',
7998
+ dispatchId: review.dispatchId || '',
7999
+ reviewer: review.reviewer || '',
8000
+ reviewedAt: review.reviewedAt || pr?.lastReviewedAt || '',
8001
+ threads,
8002
+ note: review.note || pr?.reviewNote || '',
8003
+ };
8004
+ return `review-${crypto.createHash('sha1').update(JSON.stringify(evidence)).digest('hex').slice(0, 16)}`;
8005
+ }
8006
+
7754
8007
  function getPrNoOpFixRecord(pr, cause) {
7755
8008
  if (!pr || !cause || !pr._noOpFixes || typeof pr._noOpFixes !== 'object') return null;
7756
8009
  const record = pr._noOpFixes[cause];
@@ -7993,6 +8246,14 @@ function _writeWorktreeSkipLiveInboxNote(worktreePath, callerTag, blockingInfo)
7993
8246
  function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
7994
8247
  const resolved = path.resolve(wtPath);
7995
8248
  const resolvedRoot = path.resolve(worktreeRoot) + path.sep;
8249
+ let protectedProjects;
8250
+ try {
8251
+ protectedProjects = Array.isArray(opts.projects) ? opts.projects : getProjects(opts.config);
8252
+ assertWorktreeOutsideProjects(resolved, protectedProjects);
8253
+ } catch (err) {
8254
+ log('warn', `removeWorktree: refusing to remove ${wtPath} — path is inside configured operator checkout${err?.protectedProject ? ` ${err.protectedProject}` : ''}`);
8255
+ return false;
8256
+ }
7996
8257
  if (!resolved.startsWith(resolvedRoot)) {
7997
8258
  log('warn', `removeWorktree: refusing to remove ${wtPath} — not under ${worktreeRoot}`);
7998
8259
  return false;
@@ -8510,7 +8771,7 @@ module.exports = {
8510
8771
  ENGINE_DEFAULTS,
8511
8772
  resolvePollFlag, // P-c4d8e1a3 — granular per-poller flag resolution
8512
8773
  resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentUseWorkerPool, resolveAgentAcpPoolSize, resolveAgentModel, resolveCcModel,
8513
- resolveAgentMaxBudget, resolveAgentBareMode, resolvePropagateClaudeMdForNonClaudeRuntimes,
8774
+ resolveAgentAllowedTools, resolveAgentMaxBudget, resolveAgentBareMode, resolvePropagateClaudeMdForNonClaudeRuntimes,
8514
8775
  applyLegacyCcModelMigration, _resetLegacyCcModelMigrationFlag,
8515
8776
  runtimeConfigWarnings,
8516
8777
  projectWorkSourceWarnings,
@@ -8607,14 +8868,18 @@ module.exports = {
8607
8868
  WORKTREE_SCRATCH_DIR_NAME,
8608
8869
  isPathInside,
8609
8870
  isPathInsideOrEqual,
8871
+ pathsOverlap,
8610
8872
  parseWorktreePorcelain,
8611
8873
  assertWorktreeOutsideProject,
8874
+ assertWorktreeOutsideProjects,
8612
8875
  resolveProjectRootDir,
8613
8876
  resolveAgentTempBaseDir,
8614
8877
  resolveSpawnPaths,
8615
8878
  validateWorkItemWorkdir,
8616
8879
  applyWorkdir,
8617
8880
  READ_ONLY_ROOT_TASK_TYPES,
8881
+ isLiveCheckoutDispatchRecord,
8882
+ isDispatchExecutionPathSafe,
8618
8883
  isLiveCommandCenterPath,
8619
8884
  describeCcProtectedPaths,
8620
8885
  renderCcSystemPrompt,
@@ -8648,6 +8913,7 @@ module.exports = {
8648
8913
  PR_FIX_CAUSE,
8649
8914
  getPrFixAutomationCause,
8650
8915
  prFixEvidenceFingerprint,
8916
+ prReviewFindingIdentity,
8651
8917
  prMergeConflictGuardKey,
8652
8918
  resetMergeConflictStateOnRetarget,
8653
8919
  getPrNoOpFixRecord,
@@ -8655,6 +8921,7 @@ module.exports = {
8655
8921
  getPrPausedCauses,
8656
8922
  isBuildFixIneffectivePaused,
8657
8923
  parseSkillFrontmatter,
8924
+ syncBundledPersonalSkills,
8658
8925
  sleepMs,
8659
8926
  clearWorktreeFailureCache,
8660
8927
  removeWorktree,