@yemi33/minions 0.1.2178 → 0.1.2180

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 (39) hide show
  1. package/README.md +7 -5
  2. package/bin/minions.js +39 -17
  3. package/dashboard/js/command-parser.js +1 -1
  4. package/dashboard/js/memory-panel.js +324 -0
  5. package/dashboard/js/qa.js +2 -2
  6. package/dashboard/js/refresh.js +19 -1
  7. package/dashboard/js/render-other.js +143 -2
  8. package/dashboard/js/render-prs.js +2 -1
  9. package/dashboard/js/render-schedules.js +1 -1
  10. package/dashboard/js/render-watches.js +1 -1
  11. package/dashboard/js/render-work-items.js +18 -1
  12. package/dashboard/js/settings.js +23 -0
  13. package/dashboard/pages/engine-memory-panel.html +56 -0
  14. package/dashboard/pages/engine.html +1 -0
  15. package/dashboard/pages/tools.html +8 -0
  16. package/dashboard/slim/js/link-pr.js +5 -5
  17. package/dashboard/slim/js/modals-tiles.js +44 -3
  18. package/dashboard/slim/js/projects.js +8 -6
  19. package/dashboard/slim/styles.css +20 -0
  20. package/dashboard-build.js +17 -2
  21. package/dashboard.js +693 -19
  22. package/docs/branch-derivation.md +13 -1
  23. package/docs/diagnostics-memory.md +446 -0
  24. package/docs/harness-propagation.md +273 -0
  25. package/docs/human-vs-automated.md +1 -1
  26. package/docs/runtime-adapters.md +5 -0
  27. package/engine/cli.js +24 -5
  28. package/engine/diagnostics-memory.js +190 -0
  29. package/engine/lifecycle.js +111 -1
  30. package/engine/preflight.js +265 -0
  31. package/engine/queries.js +331 -19
  32. package/engine/runtimes/claude.js +36 -0
  33. package/engine/runtimes/codex.js +19 -0
  34. package/engine/runtimes/copilot.js +27 -36
  35. package/engine/shared.js +390 -15
  36. package/engine/spawn-agent.js +178 -12
  37. package/engine/watchdog.js +6 -0
  38. package/engine.js +277 -4
  39. package/package.json +2 -2
@@ -55,6 +55,15 @@ function parseSpawnArgs(argv) {
55
55
  let runtimeName = 'claude';
56
56
  const opts = {};
57
57
  const passthrough = [];
58
+ // P-08b62d49 — accumulating array of project-local-on-main harness dirs that
59
+ // engine.js wants `computeAddDirs` to surface as additional `--add-dir`
60
+ // entries. Multiple `--project-harness-dir <path>` flags accumulate in
61
+ // declaration order; the dedup/exists filter happens inside `computeAddDirs`.
62
+ const projectHarnessDirs = [];
63
+ // P-49e1c8b7 — hermetic harness opt-out (boolean). Set by engine.js when
64
+ // shared.resolveAgentHermeticHarness(agent, engine) is true; propagates to
65
+ // computeAddDirs which then returns [minionsDir] only.
66
+ let hermetic = false;
58
67
 
59
68
  for (let i = 2; i < args.length; i++) {
60
69
  const a = args[i];
@@ -77,6 +86,17 @@ function parseSpawnArgs(argv) {
77
86
  case '--disable-builtin-mcps': opts.disableBuiltinMcps = true; break;
78
87
  case '--no-custom-instructions': opts.suppressAgentsMd = true; break;
79
88
  case '--enable-reasoning-summaries': opts.reasoningSummaries = true; break;
89
+ case '--project-harness-dir': {
90
+ const v = peek();
91
+ if (typeof v === 'string' && v) projectHarnessDirs.push(v);
92
+ break;
93
+ }
94
+ // P-49e1c8b7 — hermetic harness opt-out. When set, computeAddDirs in
95
+ // main() returns [minionsDir] only (user-scope asset dirs from
96
+ // runtime.getUserAssetDirs AND any forwarded --project-harness-dir
97
+ // entries are dropped). Engine.js gates this flag via
98
+ // shared.resolveAgentHermeticHarness(agent, engine).
99
+ case '--hermetic-harness': hermetic = true; break;
80
100
  // LEGACY: dropped — the runtime adapter emits its own permission flag.
81
101
  // Pre-P-2a6d9c4f engine.js still passes `--permission-mode bypassPermissions`;
82
102
  // letting it through would duplicate the permission flag for Claude.
@@ -85,7 +105,7 @@ function parseSpawnArgs(argv) {
85
105
  }
86
106
  }
87
107
 
88
- return { promptFile, sysPromptFile, runtimeName, opts, passthrough };
108
+ return { promptFile, sysPromptFile, runtimeName, opts, passthrough, projectHarnessDirs, hermetic };
89
109
  }
90
110
 
91
111
  /**
@@ -299,18 +319,39 @@ function createParentPipeForwarder(stream, outputPath, prefix = '') {
299
319
 
300
320
  /**
301
321
  * Build the `--add-dir` list passed to the runtime CLI. Pure: takes
302
- * `{ runtime, minionsDir, homeDir, exists }` and returns an ordered, deduped
303
- * array of dirs the agent should be able to read from outside its worktree.
322
+ * `{ runtime, minionsDir, homeDir, projectHarnessDirs, hermetic, exists }`
323
+ * and returns an ordered, deduped array of dirs the agent should be able to
324
+ * read from outside its worktree.
325
+ *
326
+ * Order:
327
+ * 1. minionsDir (always first, so playbooks/system-prompt are reachable).
328
+ * 2. Every existing dir from `runtime.getUserAssetDirs({ homeDir })` —
329
+ * user-scope skill/command/MCP roots (`~/.claude`, `~/.copilot`,
330
+ * `~/.agents`, …) so worktree-bound agents inherit user-installed
331
+ * harnesses.
332
+ * 3. P-08b62d49 — Every existing dir in `projectHarnessDirs` (engine.js
333
+ * passes the union of `queries.getProjectHarnesses(project).{skills,
334
+ * commands}` dirs that live in `project.localPath` but NOT under the
335
+ * worktree, so uncommitted `<repo>/.claude/skills/…` etc. on the main
336
+ * checkout are reachable from worktree dispatches). See
337
+ * `docs/harness-propagation.md` → "The worktree-uncommitted footgun".
304
338
  *
305
- * Order: minionsDir first (so playbooks/system-prompt are always reachable),
306
- * followed by every existing dir from `runtime.getUserAssetDirs({ homeDir })`.
307
- * Non-existent asset dirs are dropped Claude CLI rejects unknown `--add-dir`
308
- * entries. The dedup compares resolved paths so we never emit minionsDir twice
309
- * (e.g. when runtime asset dir IS the minions repo in unusual setups).
339
+ * Non-existent dirs are dropped at every layer — Claude CLI rejects unknown
340
+ * `--add-dir` entries. The dedup compares resolved paths so we never emit a
341
+ * dir twice (e.g. when a runtime asset dir IS the minions repo in unusual
342
+ * setups, or when a project-harness dir duplicates a user-asset dir).
343
+ *
344
+ * P-49e1c8b7 — When `hermetic === true`, the result is exactly `[minionsDir]`:
345
+ * both the runtime adapter's user-asset dirs AND `projectHarnessDirs` are
346
+ * dropped so the spawned agent runs with a known-empty harness surface.
347
+ * Engine.js sets this via `shared.resolveAgentHermeticHarness(agent, engine)`
348
+ * and forwards `--hermetic-harness` to spawn-agent so the flag survives the
349
+ * subprocess hop.
310
350
  *
311
351
  * `exists` is injectable for tests; defaults to `fs.existsSync`.
312
352
  */
313
- function computeAddDirs({ runtime, minionsDir, homeDir, exists = fs.existsSync } = {}) {
353
+ function computeAddDirs({ runtime, minionsDir, homeDir, projectHarnessDirs, hermetic, exists = fs.existsSync } = {}) {
354
+ if (hermetic) return [minionsDir];
314
355
  const out = [minionsDir];
315
356
  const seen = new Set([path.resolve(minionsDir)]);
316
357
  const assetDirs = typeof runtime?.getUserAssetDirs === 'function'
@@ -324,9 +365,121 @@ function computeAddDirs({ runtime, minionsDir, homeDir, exists = fs.existsSync }
324
365
  out.push(d);
325
366
  seen.add(resolved);
326
367
  }
368
+ if (Array.isArray(projectHarnessDirs)) {
369
+ for (const d of projectHarnessDirs) {
370
+ if (!d) continue;
371
+ const resolved = path.resolve(d);
372
+ if (seen.has(resolved)) continue;
373
+ if (!exists(d)) continue;
374
+ out.push(d);
375
+ seen.add(resolved);
376
+ }
377
+ }
327
378
  return out;
328
379
  }
329
380
 
381
+ /**
382
+ * P-7d31a06b — Pre-approve a worktree's `.mcp.json` servers in `~/.claude.json`
383
+ * so Claude Code's first invocation in a fresh worktree cwd doesn't show the
384
+ * project-scoped "trust this MCP server?" prompt (which `--dangerously-skip-permissions`
385
+ * silently suppresses → the agent runs without the workspace MCPs).
386
+ *
387
+ * Writes to `projects[worktreePath].enabledMcpjsonServers` (the field Claude
388
+ * Code actually reads — confirmed against the live `~/.claude.json` schema:
389
+ * `enabledMcpjsonServers` / `disabledMcpjsonServers` are siblings of
390
+ * `mcpServers`, indexed by absolute project path). Notes on the PRD wording
391
+ * "mcpServers.approved": that's the intent label; `enabledMcpjsonServers` is
392
+ * the on-disk reality.
393
+ *
394
+ * Pure-ish: all I/O (fs reads, mutator) is injected for tests. Best-effort —
395
+ * callers MUST swallow errors and never block dispatch on this.
396
+ *
397
+ * Returns `{ wrote: boolean, reason: string, servers: string[] }`:
398
+ * - reason ∈ { 'ok', 'flag-off', 'not-claude', 'no-worktree',
399
+ * 'no-workspace-mcp', 'invalid-workspace-mcp', 'no-servers' }
400
+ * - servers: keys discovered in the workspace `.mcp.json` (regardless of write)
401
+ *
402
+ * Args:
403
+ * runtimeName — `runtime.name` from `resolveRuntime(...)`. Helper no-ops if !== 'claude'.
404
+ * worktreePath — absolute path to the agent's cwd (the worktree). Used as both the file location
405
+ * (`<worktreePath>/.mcp.json`) and the `projects[...]` key in `~/.claude.json`.
406
+ * homeDir — `os.homedir()`. Injected for test isolation; helper does NOT call os.homedir directly.
407
+ * engineConfig — config.engine object; reads `claudePreApproveWorkspaceMcps` (default true via ENGINE_DEFAULTS).
408
+ * mutateJsonFileLocked — injected file-locked mutator (engine/shared.js#mutateJsonFileLocked).
409
+ * existsSync, readFileSync — injectable fs primitives (default to node:fs).
410
+ */
411
+ function preApproveWorkspaceMcps({
412
+ runtimeName,
413
+ worktreePath,
414
+ homeDir,
415
+ engineConfig,
416
+ mutateJsonFileLocked,
417
+ existsSync = fs.existsSync,
418
+ readFileSync = fs.readFileSync,
419
+ } = {}) {
420
+ if (engineConfig && engineConfig.claudePreApproveWorkspaceMcps === false) {
421
+ return { wrote: false, reason: 'flag-off', servers: [] };
422
+ }
423
+ if (runtimeName !== 'claude') {
424
+ return { wrote: false, reason: 'not-claude', servers: [] };
425
+ }
426
+ if (!worktreePath || typeof worktreePath !== 'string') {
427
+ return { wrote: false, reason: 'no-worktree', servers: [] };
428
+ }
429
+ const workspaceMcpPath = path.join(worktreePath, '.mcp.json');
430
+ if (!existsSync(workspaceMcpPath)) {
431
+ return { wrote: false, reason: 'no-workspace-mcp', servers: [] };
432
+ }
433
+ let workspaceMcp;
434
+ try {
435
+ workspaceMcp = JSON.parse(readFileSync(workspaceMcpPath, 'utf8'));
436
+ } catch {
437
+ return { wrote: false, reason: 'invalid-workspace-mcp', servers: [] };
438
+ }
439
+ const serversObj = workspaceMcp && typeof workspaceMcp === 'object' ? workspaceMcp.mcpServers : null;
440
+ const servers = serversObj && typeof serversObj === 'object' && !Array.isArray(serversObj)
441
+ ? Object.keys(serversObj).filter((k) => typeof k === 'string' && k.length > 0)
442
+ : [];
443
+ if (servers.length === 0) {
444
+ return { wrote: false, reason: 'no-servers', servers: [] };
445
+ }
446
+ if (!homeDir || typeof mutateJsonFileLocked !== 'function') {
447
+ return { wrote: false, reason: 'no-home-or-mutator', servers };
448
+ }
449
+ const claudeJsonPath = path.join(homeDir, '.claude.json');
450
+ let didWrite = false;
451
+ mutateJsonFileLocked(claudeJsonPath, (data) => {
452
+ const root = data && typeof data === 'object' ? data : {};
453
+ if (!root.projects || typeof root.projects !== 'object') root.projects = {};
454
+ const projectKey = worktreePath;
455
+ const proj = root.projects[projectKey] && typeof root.projects[projectKey] === 'object'
456
+ ? root.projects[projectKey]
457
+ : {};
458
+ // enabledMcpjsonServers is Claude's actual on-disk approval field
459
+ const enabled = Array.isArray(proj.enabledMcpjsonServers) ? proj.enabledMcpjsonServers.slice() : [];
460
+ const disabled = Array.isArray(proj.disabledMcpjsonServers) ? proj.disabledMcpjsonServers.slice() : [];
461
+ const enabledSet = new Set(enabled.filter((s) => typeof s === 'string'));
462
+ let mutated = false;
463
+ for (const name of servers) {
464
+ if (!enabledSet.has(name)) {
465
+ enabledSet.add(name);
466
+ mutated = true;
467
+ }
468
+ }
469
+ // If a server was previously explicitly disabled, lift the disabled mark when we
470
+ // re-approve it — otherwise Claude treats the disabled entry as authoritative.
471
+ const nextDisabled = disabled.filter((s) => typeof s === 'string' && !servers.includes(s));
472
+ if (nextDisabled.length !== disabled.length) mutated = true;
473
+ if (!mutated) return data;
474
+ proj.enabledMcpjsonServers = [...enabledSet];
475
+ proj.disabledMcpjsonServers = nextDisabled;
476
+ root.projects[projectKey] = proj;
477
+ didWrite = true;
478
+ return root;
479
+ }, { defaultValue: {}, skipWriteIfUnchanged: true });
480
+ return { wrote: didWrite, reason: 'ok', servers };
481
+ }
482
+
330
483
  // ─── Main script execution ──────────────────────────────────────────────────
331
484
 
332
485
  function _installHint(name, runtime) {
@@ -345,7 +498,7 @@ function main() {
345
498
  console.error('Usage: node spawn-agent.js <prompt-file> <sysprompt-file> [--runtime <name>] [args...]');
346
499
  process.exit(1);
347
500
  }
348
- const { promptFile, sysPromptFile, runtimeName, opts, passthrough } = parsed;
501
+ const { promptFile, sysPromptFile, runtimeName, opts, passthrough, projectHarnessDirs, hermetic } = parsed;
349
502
 
350
503
  const env = cleanChildEnv();
351
504
  injectAdoTokenEnvForRepoHost(env);
@@ -382,8 +535,21 @@ function main() {
382
535
  // Skill discovery dirs — agents run with CWD set to an external repo
383
536
  // worktree, so runtime-native global assets would otherwise be invisible.
384
537
  // The adapter owns both where those assets live and how to surface them.
538
+ // P-08b62d49: engine.js also forwards uncommitted project-local harness
539
+ // dirs from the operator's main checkout via `--project-harness-dir <path>`
540
+ // (one per dir, accumulated in `projectHarnessDirs`) so worktree-bound
541
+ // agents inherit them too. See docs/harness-propagation.md.
542
+ // P-49e1c8b7: when engine.js passes `--hermetic-harness`, computeAddDirs
543
+ // returns [minionsDir] only — both user-asset dirs and project harness
544
+ // dirs are dropped for a known-empty harness surface.
385
545
  const minionsDir = path.resolve(__dirname, '..');
386
- const addDirs = computeAddDirs({ runtime, minionsDir, homeDir: os.homedir() });
546
+ const addDirs = computeAddDirs({
547
+ runtime,
548
+ minionsDir,
549
+ homeDir: os.homedir(),
550
+ projectHarnessDirs,
551
+ hermetic,
552
+ });
387
553
 
388
554
  let resolved;
389
555
  try { resolved = runtime.resolveBinary({ env }); }
@@ -639,6 +805,6 @@ function main() {
639
805
  });
640
806
  }
641
807
 
642
- module.exports = { parseSpawnArgs, buildSpawnInvocation, normalizeRuntimeExit, shouldInjectAdoTokenEnv, injectAdoTokenEnv, injectAdoTokenEnvForRepoHost, writeProcessExitSentinel, computeAddDirs, createParentPipeForwarder, assertStaleHeadOk };
808
+ module.exports = { parseSpawnArgs, buildSpawnInvocation, normalizeRuntimeExit, shouldInjectAdoTokenEnv, injectAdoTokenEnv, injectAdoTokenEnvForRepoHost, writeProcessExitSentinel, computeAddDirs, preApproveWorkspaceMcps, createParentPipeForwarder, assertStaleHeadOk };
643
809
 
644
810
  if (require.main === module) main();
@@ -146,6 +146,12 @@ async function tick(opts) {
146
146
  detached: true,
147
147
  stdio: 'ignore',
148
148
  windowsHide: true,
149
+ // W-mqb9y83o — suppress auto-open in the respawned dashboard. The
150
+ // watchdog has no user-intent signal (it fires on health failure, not
151
+ // user action), so the post-restart hook MUST NOT pop a browser tab.
152
+ // The CLI's spawnFullStackAndVerify reads this env var as a hard
153
+ // kill-switch via MINIONS_NO_AUTO_OPEN=1.
154
+ env: { ...process.env, MINIONS_NO_AUTO_OPEN: '1' },
149
155
  });
150
156
  if (child && typeof child.unref === 'function') child.unref();
151
157
  logLine(minionsHome, `spawned minions ${action} pid=${child && child.pid} (detached)`);
package/engine.js CHANGED
@@ -168,7 +168,19 @@ const { runPostCompletionHooks, updateWorkItemStatus, syncPrdItemStatus, reconci
168
168
  syncPrsFromOutput, updatePrAfterReview, updatePrAfterFix, checkForLearnings, extractSkillsFromOutput,
169
169
  updateAgentHistory, updateMetrics, createReviewFeedbackForAuthor, parseAgentOutput, syncPrdFromPrs,
170
170
  isItemCompleted, classifyFailure: classifyFailureFallback, diagnoseEmptyOutput, processPendingRebases, resolveWorkItemPath,
171
- mergeArtifactNotes, promoteCompletionArtifacts } = require('./engine/lifecycle');
171
+ mergeArtifactNotes, promoteCompletionArtifacts, pruneScopeMismatchDuplicatePrs } = require('./engine/lifecycle');
172
+
173
+ // ─── Diagnostics: memory + event-loop + GC sampler (P-a1b2c3d4 / P-b2c3d4e5) ─
174
+
175
+ const diagnosticsMemory = require('./engine/diagnostics-memory');
176
+ const DIAGNOSTICS_MEMORY_PATH = path.join(ENGINE_DIR, 'diagnostics-memory.json');
177
+
178
+ // P-e5f6a7b8 — sentinel consumed each tick; dashboard.js writes it after
179
+ // validating the operator confirm token. We pick up the iso the dashboard
180
+ // chose so the engine snapshot file lands at a predictable path the
181
+ // dashboard can directly poll for.
182
+ const DIAGNOSTICS_DIR = path.join(ENGINE_DIR, 'diagnostics');
183
+ const HEAP_SNAPSHOT_REQUEST_PATH = path.join(DIAGNOSTICS_DIR, 'heap-snapshot-request.json');
172
184
 
173
185
  // ─── Agent Spawner ──────────────────────────────────────────────────────────
174
186
 
@@ -1696,9 +1708,54 @@ async function spawnAgent(dispatchItem, config) {
1696
1708
  // stages now short-circuit alongside any other read-only WI (see the gate at
1697
1709
  // `if (branchName && READ_ONLY_ROOT_TASK_TYPES.has(type))` below).
1698
1710
  const _preBranchName = meta?.branch ? sanitizeBranch(meta.branch) : null;
1711
+
1712
+ // ── Per-WI meta.workdir override (P-714ef144) ─────────────────────────
1713
+ // Optional relative POSIX subpath on the work item that lands the agent
1714
+ // at <base>/<workdir> instead of <base>. Used for monorepo subpackage
1715
+ // dispatches so the runtime CLI's cwd-rooted skill discovery surfaces
1716
+ // only the target package's `.claude/skills/<name>/SKILL.md` files.
1717
+ // Validation runs at dispatch time (the dashboard also validates on
1718
+ // create/update — this is defense-in-depth for ad-hoc dispatches and
1719
+ // upgrades that re-dispatch pre-validator WIs). Containment escapes are
1720
+ // non-retryable: the operator must fix the WI's meta.workdir or remove
1721
+ // it before a retry would succeed.
1722
+ const _rawWorkdir = meta?.item?.meta?.workdir;
1723
+ const _wdValidation = shared.validateWorkItemWorkdir(_rawWorkdir);
1724
+ if (!_wdValidation.valid) {
1725
+ const _wiId = meta?.item?.id || meta?.workItemId || id;
1726
+ log('warn', `spawnAgent: meta.workdir validation rejected dispatch ${id} (WI ${_wiId}): ${_wdValidation.error}`);
1727
+ try {
1728
+ writeInboxAlert(`invalid-workdir-${_wiId}`, [
1729
+ `# Invalid meta.workdir on ${_wiId}`,
1730
+ ``,
1731
+ `Dispatch \`${id}\` for agent \`${agentId}\` was rejected before spawn.`,
1732
+ ``,
1733
+ `**Reason:** ${_wdValidation.error}`,
1734
+ ``,
1735
+ `**Submitted value:** \`${typeof _rawWorkdir === 'string' ? _rawWorkdir : JSON.stringify(_rawWorkdir)}\``,
1736
+ ``,
1737
+ `**Project:** \`${project?.name || '(unknown)'}\` (localPath: \`${project?.localPath || '(none)'}\`)`,
1738
+ ``,
1739
+ `meta.workdir must be a relative POSIX subpath under the project root (or worktree, for code-mutating types). Examples: \`packages/foo\`, \`apps/dashboard\`. Absolute paths, drive-letter prefixes, \`..\` segments, and null bytes are rejected.`,
1740
+ ``,
1741
+ `Fix the WI's \`meta.workdir\` field (or remove it to dispatch at the project root) and re-dispatch.`,
1742
+ ].join('\n'));
1743
+ } catch (e) { log('warn', `invalid-workdir inbox alert write failed: ${e.message}`); }
1744
+ completeDispatch(
1745
+ id,
1746
+ DISPATCH_RESULT.ERROR,
1747
+ _wdValidation.error.slice(0, 800),
1748
+ 'meta.workdir validation rejected this dispatch — fix the WI subpath or remove it before re-dispatch.',
1749
+ { failureClass: FAILURE_CLASS.INVALID_WORKDIR, agentRetryable: false },
1750
+ );
1751
+ cleanupTempAgent(agentId);
1752
+ return null;
1753
+ }
1754
+ const validatedWorkdir = _wdValidation.value; // null when unset / empty
1755
+
1699
1756
  let cwd, worktreeRootDir, liveMode = false;
1700
1757
  try {
1701
- ({ cwd, worktreeRootDir, liveMode = false } = shared.resolveSpawnPaths(project, type, MINIONS_DIR));
1758
+ ({ cwd, worktreeRootDir, liveMode = false } = shared.resolveSpawnPaths(project, type, MINIONS_DIR, { workdir: validatedWorkdir }));
1702
1759
  } catch (rootErr) {
1703
1760
  if (rootErr?.code === 'WORKTREE_ROOTDIR_COLLAPSED_TO_DRIVE_ROOT' || rootErr?.code === 'WORKTREE_ROOTDIR_MISSING_BASE') {
1704
1761
  log('error', `spawnAgent: project rootDir resolution failed for ${id}: ${rootErr.message}`);
@@ -1713,6 +1770,38 @@ async function spawnAgent(dispatchItem, config) {
1713
1770
  cleanupTempAgent(agentId);
1714
1771
  return null;
1715
1772
  }
1773
+ if (rootErr?.code === 'INVALID_WORKDIR') {
1774
+ // Post-resolve containment escape from live or read-only branch (the
1775
+ // pre-resolve validator above already caught the common cases; this
1776
+ // catches symlink-style attacks where the literal subpath looks fine
1777
+ // but path.resolve lands outside the base).
1778
+ const _wiId = meta?.item?.id || meta?.workItemId || id;
1779
+ log('warn', `spawnAgent: workdir post-resolve escape for ${id} (WI ${_wiId}): ${rootErr.message}`);
1780
+ try {
1781
+ writeInboxAlert(`invalid-workdir-${_wiId}`, [
1782
+ `# Invalid meta.workdir on ${_wiId} (post-resolve escape)`,
1783
+ ``,
1784
+ `Dispatch \`${id}\` for agent \`${agentId}\` was rejected by the resolver containment guard.`,
1785
+ ``,
1786
+ `**Reason:** ${rootErr.message}`,
1787
+ ``,
1788
+ `**Submitted value:** \`${validatedWorkdir || ''}\``,
1789
+ ``,
1790
+ `**Project:** \`${project?.name || '(unknown)'}\` (localPath: \`${project?.localPath || '(none)'}\`)`,
1791
+ ``,
1792
+ `The literal subpath passed shape validation but path.resolve landed outside the base directory — usually a symlink in the project root pointing elsewhere. Fix the WI's meta.workdir or remove the offending symlink before re-dispatch.`,
1793
+ ].join('\n'));
1794
+ } catch (e) { log('warn', `invalid-workdir inbox alert write failed: ${e.message}`); }
1795
+ completeDispatch(
1796
+ id,
1797
+ DISPATCH_RESULT.ERROR,
1798
+ rootErr.message.slice(0, 800),
1799
+ 'meta.workdir resolved outside the project/worktree base — fix the subpath or remove it.',
1800
+ { failureClass: FAILURE_CLASS.INVALID_WORKDIR, agentRetryable: false },
1801
+ );
1802
+ cleanupTempAgent(agentId);
1803
+ return null;
1804
+ }
1716
1805
  throw rootErr;
1717
1806
  }
1718
1807
  // Legacy local alias: downstream git ops (worktree add, prune, fetch) and
@@ -3081,6 +3170,15 @@ async function spawnAgent(dispatchItem, config) {
3081
3170
  // Other runtimes ignore the opt (their buildSpawnFlags don't read it), so no
3082
3171
  // runtime.name branch here.
3083
3172
  const resolvedDisabledMcpServers = shared.resolveCopilotAgentDisabledMcpServers(agentConfig, engineConfig);
3173
+ // P-49e1c8b7 — hermetic harness opt-out (per-agent override allowed).
3174
+ // When true, this dispatch:
3175
+ // - skips Claude workspace .mcp.json pre-approval (preApproveWorkspaceMcps),
3176
+ // - skips project-local-on-main `--project-harness-dir` propagation,
3177
+ // - forwards `--hermetic-harness` to spawn-agent.js so computeAddDirs
3178
+ // returns [minionsDir] only (user-asset dirs stripped from --add-dir).
3179
+ // Independent of copilotDisableBuiltinMcps and copilotSuppressAgentsMd,
3180
+ // which retain their existing semantics.
3181
+ const resolvedHermetic = shared.resolveAgentHermeticHarness(agentConfig, engineConfig);
3084
3182
 
3085
3183
  // W-mpg6isvy000xca4d — On retry after FAILURE_CLASS.MODEL_UNAVAILABLE, swap
3086
3184
  // to the runtime-appropriate fallback model. Two paths gated on
@@ -3140,6 +3238,39 @@ async function spawnAgent(dispatchItem, config) {
3140
3238
 
3141
3239
  // MCP servers: agents inherit from ~/.claude.json directly as Claude Code processes.
3142
3240
  // No --mcp-config needed — avoids redundant config and ensures agents always have latest servers.
3241
+ //
3242
+ // P-7d31a06b — When the runtime is Claude AND the worktree has a `.mcp.json`,
3243
+ // pre-warm `~/.claude.json` projects.<worktreePath>.enabledMcpjsonServers so
3244
+ // Claude's first call doesn't show the project-MCP trust prompt (which
3245
+ // --dangerously-skip-permissions silently suppresses → agent runs without the
3246
+ // workspace MCPs connected). Helper internally no-ops for non-Claude runtimes
3247
+ // and when engine.claudePreApproveWorkspaceMcps is false (no runtime.name
3248
+ // check at this call site, per CLAUDE.md rule). Best-effort: NEVER block dispatch.
3249
+ //
3250
+ // P-49e1c8b7 — Skipped entirely when resolvedHermetic is true: a hermetic
3251
+ // dispatch wants a known-empty harness surface, so the workspace .mcp.json
3252
+ // approval must not be written.
3253
+ if (resolvedHermetic) {
3254
+ log('debug', `Hermetic harness: skipping workspace MCP pre-approval for ${id}`);
3255
+ } else {
3256
+ try {
3257
+ const result = preApproveWorkspaceMcps({
3258
+ runtimeName,
3259
+ worktreePath,
3260
+ homeDir: os.homedir(),
3261
+ engineConfig,
3262
+ mutateJsonFileLocked: shared.mutateJsonFileLocked,
3263
+ });
3264
+ if (result.wrote) {
3265
+ log('info', `Pre-approved ${result.servers.length} workspace MCP server(s) in ~/.claude.json for ${worktreePath}: ${result.servers.join(', ')}`);
3266
+ } else if (result.reason !== 'no-workspace-mcp' && result.reason !== 'not-claude' && result.reason !== 'no-worktree') {
3267
+ log('debug', `Skipped workspace MCP pre-approval for ${id} (reason=${result.reason})`);
3268
+ }
3269
+ } catch (err) {
3270
+ log('warn', `Workspace MCP pre-approval failed for ${id} (non-fatal): ${err.message}`);
3271
+ }
3272
+ }
3273
+
3143
3274
  _phaseT.afterRuntime = Date.now();
3144
3275
 
3145
3276
  log('info', `Spawning agent: ${agentId} (${id}) in ${cwd}`);
@@ -3197,7 +3328,70 @@ async function spawnAgent(dispatchItem, config) {
3197
3328
  // Spawn via wrapper script — node directly (no bash intermediary)
3198
3329
  // spawn-agent.js handles CLAUDECODE env cleanup and claude binary resolution
3199
3330
  const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
3200
- const spawnArgs = [spawnScript, promptPath, sysPromptPath, ...args];
3331
+
3332
+ // ── P-08b62d49 — project-local-on-main harness propagation ──────────────
3333
+ // The worktree-uncommitted footgun (docs/harness-propagation.md): when a
3334
+ // mutating dispatch runs in a fresh `git worktree add` (worktreePath !==
3335
+ // project.localPath), uncommitted `<repo>/.claude/skills/foo/SKILL.md` etc.
3336
+ // on the operator's main checkout are invisible to the runtime CLI because
3337
+ // its native discovery is rooted at cwd. Union the project-scope harness
3338
+ // dirs (skills + commands) that exist under `project.localPath` but NOT
3339
+ // under `worktreePath`, and forward them to spawn-agent as
3340
+ // `--project-harness-dir <dir>` so `computeAddDirs` surfaces them via
3341
+ // `--add-dir`. Gated by engine.harnessPropagateProjectLocal (default true).
3342
+ // No-op for live-checkout mode (worktreePath === null) and read-only types
3343
+ // (also worktreePath === null per resolveSpawnPaths).
3344
+ //
3345
+ // P-49e1c8b7 — Also skipped entirely when resolvedHermetic is true: hermetic
3346
+ // dispatches want a known-empty harness surface so neither user-asset dirs
3347
+ // (filtered inside computeAddDirs) nor project-local-on-main dirs (filtered
3348
+ // here) reach the agent. We skip the queries.getProjectHarnesses(...) call
3349
+ // too — saves the fs scan when the result will be discarded.
3350
+ //
3351
+ // P-714ef144 — when meta.workdir is set, the filter helper clips both the
3352
+ // project-side and worktree-side anchors to <base>/<workdir>, so a
3353
+ // `packages/foo` dispatch never sees harness dirs from sibling packages.
3354
+ // The clipping is purely additive on top of the existing project-vs-worktree
3355
+ // disjoint filter; when workdir is null the helper produces the same result
3356
+ // as the legacy inline loop.
3357
+ const projectHarnessArgs = [];
3358
+ if (!resolvedHermetic
3359
+ && engineConfig.harnessPropagateProjectLocal !== false
3360
+ && worktreePath
3361
+ && project?.localPath
3362
+ && path.resolve(worktreePath) !== path.resolve(project.localPath)) {
3363
+ try {
3364
+ const harnesses = queries.getProjectHarnesses(project);
3365
+ const candidateDirs = [
3366
+ ...(harnesses.skills || []),
3367
+ ...(harnesses.commands || []),
3368
+ ]
3369
+ .map((entry) => entry?.dir)
3370
+ .filter((d) => typeof d === 'string' && d);
3371
+ const filteredDirs = shared.filterProjectHarnessDirsForWorkdir(candidateDirs, {
3372
+ projectLocalPath: project.localPath,
3373
+ worktreePath,
3374
+ workdir: validatedWorkdir,
3375
+ });
3376
+ for (const abs of filteredDirs) {
3377
+ if (!fs.existsSync(abs)) continue;
3378
+ projectHarnessArgs.push('--project-harness-dir', abs);
3379
+ }
3380
+ if (projectHarnessArgs.length) {
3381
+ log('debug', `Project-local harness propagation: ${projectHarnessArgs.length / 2} dir(s) for ${id} (${worktreePath}${validatedWorkdir ? ', workdir=' + validatedWorkdir : ''})`);
3382
+ }
3383
+ } catch (err) {
3384
+ log('warn', `Project-local harness propagation failed for ${id} (non-fatal): ${err.message}`);
3385
+ }
3386
+ }
3387
+
3388
+ // P-49e1c8b7 — hermetic harness opt-out forwarded to spawn-agent.js so
3389
+ // computeAddDirs returns [minionsDir] only (user-asset dirs stripped).
3390
+ // Append BEFORE projectHarnessArgs (which will be empty when hermetic, but
3391
+ // ordering keeps the intent explicit in process listings).
3392
+ const hermeticArgs = resolvedHermetic ? ['--hermetic-harness'] : [];
3393
+
3394
+ const spawnArgs = [spawnScript, promptPath, sysPromptPath, ...args, ...hermeticArgs, ...projectHarnessArgs];
3201
3395
 
3202
3396
  // Live output file — stamped BEFORE child process is spawned (#W-mo248lkjwgsu).
3203
3397
  // Writing the stub pre-spawn lets the orphan detector distinguish three failure modes
@@ -3584,7 +3778,12 @@ async function spawnAgent(dispatchItem, config) {
3584
3778
  let resumeProc;
3585
3779
  try {
3586
3780
  // detached so the resumed steering session also survives engine death (matches initial spawn)
3587
- resumeProc = runFile(process.execPath, [spawnScript, steerPromptPath, sysPromptPath, ...resumeArgs], {
3781
+ // P-08b62d49 also include projectHarnessArgs so the resumed runtime
3782
+ // can still reach uncommitted project-local harness dirs (the runtime
3783
+ // CLI re-indexes asset dirs on every spawn, including this resume).
3784
+ // P-49e1c8b7 — same for --hermetic-harness so the resumed dispatch
3785
+ // keeps the known-empty harness contract.
3786
+ resumeProc = runFile(process.execPath, [spawnScript, steerPromptPath, sysPromptPath, ...resumeArgs, ...hermeticArgs, ...projectHarnessArgs], {
3588
3787
  cwd,
3589
3788
  stdio: ['pipe', 'pipe', 'pipe'],
3590
3789
  env: childEnv,
@@ -8670,6 +8869,20 @@ async function tickInner() {
8670
8869
  }
8671
8870
  if (reconcilePolls.length) await Promise.allSettled(reconcilePolls);
8672
8871
  if (_isTickStale(myGeneration)) return;
8872
+
8873
+ // W-mqba5ulq000nd255 — Cross-project PR scope-mismatch sweep. Cleans up
8874
+ // stale records that were stamped with `_invalidProjectScope` and have a
8875
+ // sibling in the correctly-scoped project. Cheap (a single read of all
8876
+ // PRs + a per-affected-project mutation). Runs once per reconcile tick;
8877
+ // sibling-less stamps are preserved as tracking signals.
8878
+ try {
8879
+ const result = pruneScopeMismatchDuplicatePrs(config);
8880
+ if (result?.pruned > 0) {
8881
+ log('info', `[pull-requests] scope-mismatch sweep pruned ${result.pruned} duplicate record(s) across ${result.scanned} scanned`);
8882
+ }
8883
+ } catch (err) {
8884
+ log('warn', `[pull-requests] scope-mismatch sweep error: ${err?.message || err}`);
8885
+ }
8673
8886
  }
8674
8887
 
8675
8888
  // 2.9. Stalled dispatch detection — auto-retry failed items blocking the graph (~20 min — cadence in ENGINE_DEFAULTS.stalledDispatchSweepEvery)
@@ -9209,6 +9422,64 @@ async function tickInner() {
9209
9422
  if (!discoveryOk) {
9210
9423
  log('warn', 'Discovery failed after pending dispatch pass — new work may be stale until next tick');
9211
9424
  }
9425
+
9426
+ // 6. Periodic memory baseline (P-b2c3d4e5).
9427
+ // Sample once every ENGINE_DEFAULTS.memoryBaselineEveryTicks ticks (default
9428
+ // 6 ≈ 60s at the default 10s tickInterval). When the cadence is <= 0,
9429
+ // sampling is disabled cleanly — no log emission, no sidecar write
9430
+ // (operator opt-out). The diagnostics-memory.json sidecar is a passive
9431
+ // cache (single-object latest sample) — gitignored and exempt from the
9432
+ // SQL-first state rule, same as engine/dashboard-port.json.
9433
+ safe('memoryBaseline', () => emitMemoryBaseline(tickCount));
9434
+
9435
+ // 7. Operator-driven heap snapshot capture (P-e5f6a7b8).
9436
+ // Dashboard's POST /api/diagnostics/heap-snapshot drops a sentinel in
9437
+ // engine/diagnostics/heap-snapshot-request.json after capturing its own
9438
+ // heap. We pick it up on the next tick, write the engine-side
9439
+ // .heapsnapshot, then remove the sentinel so the dashboard handler can
9440
+ // detect completion. The v8.writeHeapSnapshot call stalls THIS process
9441
+ // for several seconds — the dashboard polls with a 30s timeout.
9442
+ safe('heapSnapshotRequest', () => processHeapSnapshotRequest());
9443
+ }
9444
+
9445
+ function processHeapSnapshotRequest() {
9446
+ if (!fs.existsSync(HEAP_SNAPSHOT_REQUEST_PATH)) return;
9447
+ let req = null;
9448
+ try {
9449
+ req = JSON.parse(fs.readFileSync(HEAP_SNAPSHOT_REQUEST_PATH, 'utf8'));
9450
+ } catch (e) {
9451
+ log('warn', `heap-snapshot-request parse failed: ${e.message}`);
9452
+ try { safeUnlink(HEAP_SNAPSHOT_REQUEST_PATH); } catch { /* best effort */ }
9453
+ return;
9454
+ }
9455
+ const iso = (req && typeof req.requestedAt === 'string') ? req.requestedAt : new Date().toISOString();
9456
+ const safeIso = String(iso).replace(/[:.]/g, '-');
9457
+ const outPath = path.join(DIAGNOSTICS_DIR, `heap-engine-${safeIso}.heapsnapshot`);
9458
+ const v8 = require('v8');
9459
+ try {
9460
+ fs.mkdirSync(DIAGNOSTICS_DIR, { recursive: true });
9461
+ log('info', `HEAP_SNAPSHOT engine capturing -> ${outPath} (this stalls the engine for several seconds)`);
9462
+ v8.writeHeapSnapshot(outPath);
9463
+ log('info', `HEAP_SNAPSHOT engine wrote ${outPath}`);
9464
+ } catch (e) {
9465
+ log('warn', `heap-snapshot writeHeapSnapshot failed: ${e.message}`);
9466
+ } finally {
9467
+ try { safeUnlink(HEAP_SNAPSHOT_REQUEST_PATH); } catch { /* best effort */ }
9468
+ }
9469
+ }
9470
+
9471
+ function emitMemoryBaseline(tickN) {
9472
+ const every = Number(ENGINE_DEFAULTS.memoryBaselineEveryTicks);
9473
+ if (!Number.isFinite(every) || every <= 0) return;
9474
+ if ((tickN % every) !== 0) return;
9475
+ const sample = diagnosticsMemory.sampleSelf({ label: 'engine' });
9476
+ diagnosticsMemory.recordSample(sample);
9477
+ try { safeWrite(DIAGNOSTICS_MEMORY_PATH, sample); }
9478
+ catch (e) { log('warn', `memoryBaseline sidecar write: ${e.message}`); }
9479
+ log('info',
9480
+ `MEMORY_BASELINE engine rss=${sample.rss} heapUsed=${sample.heapUsed} ` +
9481
+ `eventLoopLagP99=${sample.eventLoopLagP99.toFixed(2)}ms ` +
9482
+ `gcPauses=${sample.gcCount}/${sample.gcPausesTotalMs.toFixed(2)}ms tickN=${tickN}`);
9212
9483
  }
9213
9484
 
9214
9485
  // ─── Exports (for engine/cli.js and other modules) ──────────────────────────
@@ -9287,6 +9558,8 @@ module.exports = {
9287
9558
  resolvePreDispatchEvalConcurrency,
9288
9559
  // P-c2e5a1d9-a — exported for testing the tick-generation force-release path
9289
9560
  _isTickStale,
9561
+ // P-b2c3d4e5 — exported for testing the memory baseline emitter + sidecar path
9562
+ emitMemoryBaseline, DIAGNOSTICS_MEMORY_PATH,
9290
9563
  get tickGeneration() { return tickGeneration; },
9291
9564
  set tickGeneration(v) { tickGeneration = v; },
9292
9565
  get tickRunning() { return tickRunning; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2178",
3
+ "version": "0.1.2180",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"
@@ -25,7 +25,7 @@
25
25
  "test:e2e:report": "npx playwright show-report test/playwright/report",
26
26
  "test:e2e:video": "npx playwright test --video=on --headed",
27
27
  "test:all": "node test/run-parallel.js && node test/minions-tests.js && node test/integration/run.js",
28
- "test:perf": "node test/perf/managed-spawn-load.test.js",
28
+ "test:perf": "node test/perf/run.js",
29
29
  "test:e2e:accept": "node test/playwright/accept-baseline.js",
30
30
  "test:e2e:accept-force": "node test/playwright/accept-baseline.js --force",
31
31
  "test:setup": "npx playwright install chromium",