@yemi33/minions 0.1.2381 → 0.1.2383

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 (54) hide show
  1. package/bin/minions.js +42 -2
  2. package/dashboard/js/refresh.js +8 -2
  3. package/dashboard/js/render-other.js +38 -0
  4. package/dashboard/js/render-work-items.js +61 -20
  5. package/dashboard/js/settings.js +7 -8
  6. package/dashboard/pages/engine.html +6 -0
  7. package/dashboard.js +110 -27
  8. package/docs/README.md +1 -0
  9. package/docs/claude-md-propagation.md +118 -0
  10. package/docs/completion-reports.md +20 -1
  11. package/docs/engine-restart.md +10 -0
  12. package/docs/runtime-adapters.md +54 -22
  13. package/docs/skills.md +2 -0
  14. package/docs/workspace-manifests.md +1 -1
  15. package/docs/worktree-lifecycle.md +23 -0
  16. package/engine/acp-transport.js +63 -35
  17. package/engine/ado-comment.js +3 -1
  18. package/engine/agent-worker-pool.js +11 -4
  19. package/engine/cc-worker-pool.js +4 -2
  20. package/engine/claude-md-context.js +195 -0
  21. package/engine/cli.js +9 -1
  22. package/engine/comment-format.js +51 -14
  23. package/engine/consolidation.js +47 -3
  24. package/engine/db/migrations/026-pre-dispatch-rejections.js +55 -0
  25. package/engine/dispatch.js +80 -23
  26. package/engine/execution-model.js +68 -0
  27. package/engine/gh-comment.js +6 -3
  28. package/engine/github.js +6 -7
  29. package/engine/lifecycle.js +195 -56
  30. package/engine/llm.js +59 -83
  31. package/engine/memory-store.js +3 -1
  32. package/engine/pipeline.js +147 -20
  33. package/engine/playbook.js +39 -6
  34. package/engine/pooled-agent-process.js +28 -26
  35. package/engine/prd-store.js +14 -6
  36. package/engine/quarantine-refs.js +33 -0
  37. package/engine/queries.js +8 -6
  38. package/engine/restart-health.js +69 -4
  39. package/engine/runtimes/claude.js +100 -25
  40. package/engine/runtimes/codex.js +19 -0
  41. package/engine/runtimes/contract.js +239 -0
  42. package/engine/runtimes/copilot.js +85 -8
  43. package/engine/runtimes/index.js +37 -9
  44. package/engine/shared.js +157 -64
  45. package/engine/spawn-agent.js +79 -113
  46. package/engine/spawn-phase-watchdog.js +8 -37
  47. package/engine/supervisor.js +72 -16
  48. package/engine/timeout.js +87 -16
  49. package/engine/untrusted-fence.js +2 -1
  50. package/engine.js +434 -125
  51. package/package.json +1 -1
  52. package/playbooks/fix.md +20 -0
  53. package/playbooks/review.md +2 -0
  54. package/skills/check-self-authored-review-comment/SKILL.md +34 -0
@@ -37,6 +37,14 @@ const { FAILURE_CLASS, safeWrite, ts, resolveEngineCacheDir } = require('../shar
37
37
  const ENGINE_DIR = __dirname.replace(/[\\/]runtimes$/, '');
38
38
  const _CACHE_DIR = resolveEngineCacheDir(ENGINE_DIR);
39
39
  const isWin = process.platform === 'win32';
40
+ const RUNTIME_NAME = 'copilot';
41
+ const SPAWN_STARTUP_TYPES = new Set([
42
+ 'session.mcp_server_status_changed',
43
+ 'session.mcp_servers_loaded',
44
+ 'session.skills_loaded',
45
+ 'session.tools_updated',
46
+ 'session.info',
47
+ ]);
40
48
 
41
49
  // ── Binary Resolution ───────────────────────────────────────────────────────
42
50
  //
@@ -529,7 +537,7 @@ function buildArgs(opts = {}) {
529
537
  }
530
538
 
531
539
  function buildSpawnFlags(opts = {}) {
532
- const flags = ['--runtime', 'copilot'];
540
+ const flags = ['--runtime', RUNTIME_NAME];
533
541
  if (opts.maxTurns != null) flags.push('--max-turns', String(opts.maxTurns));
534
542
  if (opts.model) flags.push('--model', String(opts.model));
535
543
  if (opts.allowedTools) flags.push('--allowedTools', String(opts.allowedTools));
@@ -552,12 +560,66 @@ function buildSpawnFlags(opts = {}) {
552
560
  return flags;
553
561
  }
554
562
 
555
- // Stamped into every session.json this adapter writes so the pre-spawn resume
556
- // path can detect "session was produced by a different runtime" — Copilot
557
- // rejects Claude session IDs (and vice versa) with "No conversation found",
558
- // which would otherwise burn a retry slot before the post-failure cleanup at
559
- // engine.js:1195 fires. See W-mot9fwya000d09cb.
560
- const RUNTIME_NAME = 'copilot';
563
+ function resolveInvocationOptions({
564
+ options = {},
565
+ engineConfig = {},
566
+ failureClass = null,
567
+ } = {}) {
568
+ const resolved = { ...options };
569
+ if (resolved.stream == null) resolved.stream = engineConfig.copilotStreamMode;
570
+ if (resolved.disableBuiltinMcps == null) {
571
+ resolved.disableBuiltinMcps = engineConfig.copilotDisableBuiltinMcps;
572
+ }
573
+ if (resolved.reasoningSummaries == null) {
574
+ resolved.reasoningSummaries = engineConfig.copilotReasoningSummaries;
575
+ }
576
+ if (failureClass === FAILURE_CLASS.MODEL_UNAVAILABLE && engineConfig.copilotFallbackModel) {
577
+ resolved.model = engineConfig.copilotFallbackModel;
578
+ }
579
+ return resolved;
580
+ }
581
+
582
+ function isSpawnStartupEvent(event) {
583
+ return !!event && SPAWN_STARTUP_TYPES.has(event.type);
584
+ }
585
+
586
+ function shouldSuppressPostMutationError({ mutationApplied, error } = {}) {
587
+ if (mutationApplied !== true || !error) return false;
588
+ return error.errorClass === 'unknown-model' || error.errorClass === 'model-unavailable';
589
+ }
590
+
591
+ function buildWorkerArgs() {
592
+ return ['--acp', '--allow-all', '--max-autopilot-continues', '1'];
593
+ }
594
+
595
+ function encodePooledOutput(event = {}) {
596
+ if (event.kind === 'model') {
597
+ return JSON.stringify({
598
+ type: 'session.tools_updated',
599
+ data: { model: event.model || null },
600
+ }) + '\n';
601
+ }
602
+ if (event.kind === 'chunk') {
603
+ return JSON.stringify({
604
+ type: 'assistant.message_delta',
605
+ data: { deltaContent: event.text || '' },
606
+ }) + '\n';
607
+ }
608
+ if (event.kind === 'result') {
609
+ return JSON.stringify({
610
+ type: 'result',
611
+ sessionId: event.sessionId || null,
612
+ usage: {
613
+ totalApiDurationMs: event.durationMs,
614
+ stopReason: event.stopReason || null,
615
+ },
616
+ }) + '\n';
617
+ }
618
+ throw new Error(`Unsupported pooled output event kind: ${event.kind || '<missing>'}`);
619
+ }
620
+
621
+ // RUNTIME_NAME is stamped into every session.json so the pre-spawn resume path
622
+ // can reject session IDs produced by another adapter before invoking the CLI.
561
623
 
562
624
  function getResumeSessionId({ agentId, branchName, agentsDir, maxAgeMs = 2 * 60 * 60 * 1000, logger = console } = {}) {
563
625
  if (!agentId || agentId.startsWith('temp-') || !agentsDir) return null;
@@ -1273,6 +1335,13 @@ const capabilities = {
1273
1335
  budgetCap: false,
1274
1336
  // No --bare equivalent.
1275
1337
  bareMode: false,
1338
+ // Copilot has NO native CLAUDE.md auto-load — it only reads its own AGENTS.md
1339
+ // (native custom-instruction discovery owned by the CLI itself). FALSE
1340
+ // here opts this runtime INTO the engine's CLAUDE.md-propagation layer
1341
+ // (engine/claude-md-context.js) so repo-authored CLAUDE.md guidance is
1342
+ // surfaced in the prompt. This is orthogonal to AGENTS.md discovery —
1343
+ // CLAUDE.md carries no split-brain risk because Copilot never reads it itself.
1344
+ claudeMdNativeDiscovery: false,
1276
1345
  // No --fallback-model
1277
1346
  fallbackModel: false,
1278
1347
  // Copilot manages session state internally in ~/.copilot/session-state/
@@ -1363,7 +1432,8 @@ function getMcpConfigPaths({ homeDir = os.homedir(), project = null } = {}) {
1363
1432
  }
1364
1433
 
1365
1434
  module.exports = {
1366
- name: 'copilot',
1435
+ apiVersion: 1,
1436
+ name: RUNTIME_NAME,
1367
1437
  capabilities,
1368
1438
  resolveBinary,
1369
1439
  capsFile: CAPS_FILE,
@@ -1372,7 +1442,11 @@ module.exports = {
1372
1442
  // Use the same wrapper as Claude — spawn-agent.js is runtime-agnostic per P-9c4f2d6a
1373
1443
  spawnScript: path.join(ENGINE_DIR, 'spawn-agent.js'),
1374
1444
  installHint: INSTALL_HINT,
1445
+ workerErrorHint: 'copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete',
1446
+ resolveInvocationOptions,
1375
1447
  buildSpawnFlags,
1448
+ buildWorkerArgs,
1449
+ encodePooledOutput,
1376
1450
  buildArgs,
1377
1451
  buildPrompt,
1378
1452
  getUserAssetDirs,
@@ -1392,6 +1466,8 @@ module.exports = {
1392
1466
  parseStreamChunk,
1393
1467
  parseError,
1394
1468
  createStreamConsumer,
1469
+ isSpawnStartupEvent,
1470
+ shouldSuppressPostMutationError,
1395
1471
  permissionBypassFlags: PERMISSION_BYPASS_FLAGS,
1396
1472
  // Exposed for unit tests — engine code MUST go through resolveRuntime + the
1397
1473
  // adapter contract; never reach into these helpers directly.
@@ -1412,4 +1488,5 @@ module.exports = {
1412
1488
  _extractInvalidModelName,
1413
1489
  CAPS_SCHEMA_VERSION,
1414
1490
  KNOWN_EVENT_TYPES,
1491
+ SPAWN_STARTUP_TYPES,
1415
1492
  };
@@ -7,18 +7,16 @@
7
7
  * runtime name fails loudly with a clear list of registered options.
8
8
  *
9
9
  * Adding a new runtime:
10
- * 1. Implement the full adapter contract documented in claude.js.
11
- * 2. `registry.set('<name>', require('./<name>'));` below.
10
+ * 1. Implement API version 1 from contract.js.
11
+ * 2. `registerRuntime('<name>', require('./<name>'), { strict: true });`.
12
12
  * 3. Expose its capabilities via the `/api/runtimes` endpoint (free).
13
13
  *
14
14
  * Engine code MUST gate behavior on `runtime.capabilities.*` flags, not on
15
15
  * `runtime.name === 'claude'` comparisons. The whole point of this layer.
16
16
  */
17
17
 
18
+ const contract = require('./contract');
18
19
  const registry = new Map();
19
- registry.set('claude', require('./claude'));
20
- registry.set('codex', require('./codex'));
21
- registry.set('copilot', require('./copilot'));
22
20
 
23
21
  /**
24
22
  * Look up a runtime adapter by name. Throws when the name is unknown so
@@ -47,16 +45,46 @@ function listRuntimes() {
47
45
  * Register a runtime adapter. Exposed for tests and for downstream tooling
48
46
  * that wants to register a custom runtime without editing this file.
49
47
  */
50
- function registerRuntime(name, adapter) {
51
- if (!name || typeof name !== 'string') throw new Error('registerRuntime: name must be a non-empty string');
52
- if (!adapter || typeof adapter !== 'object') throw new Error('registerRuntime: adapter must be an object');
53
- registry.set(name, adapter);
48
+ function registerRuntime(name, adapter, options) {
49
+ const normalized = contract.normalizeRuntimeAdapter(name, adapter, options);
50
+ registry.set(name, normalized);
51
+ return normalized;
54
52
  }
55
53
 
54
+ function resolveRuntimeByCapability(capability) {
55
+ const matches = listRuntimes()
56
+ .map(name => registry.get(name))
57
+ .filter(adapter => adapter?.capabilities?.[capability] === true);
58
+ if (matches.length === 0) {
59
+ throw new Error(
60
+ `No registered runtime declares capability "${capability}"`
61
+ );
62
+ }
63
+ return matches[0];
64
+ }
65
+
66
+ registerRuntime('claude', require('./claude'), { strict: true });
67
+ registerRuntime('codex', require('./codex'), { strict: true });
68
+ registerRuntime('copilot', require('./copilot'), { strict: true });
69
+
56
70
  module.exports = {
71
+ ADAPTER_API_VERSION: contract.ADAPTER_API_VERSION,
72
+ RUNTIME_OPTIONS_FLAG: contract.RUNTIME_OPTIONS_FLAG,
73
+ RUNTIME_IMAGES_FILE_OPTION: contract.RUNTIME_IMAGES_FILE_OPTION,
57
74
  resolveRuntime,
75
+ resolveRuntimeByCapability,
58
76
  listRuntimes,
59
77
  registerRuntime,
78
+ sanitizeInvocationOptions: contract.sanitizeInvocationOptions,
79
+ resolveInvocationOptions: contract.resolveInvocationOptions,
80
+ decodeRuntimeOptions: contract.decodeRuntimeOptions,
81
+ buildSpawnFlags: contract.buildSpawnFlags,
82
+ prepareWorkspace: contract.prepareWorkspace,
83
+ isSpawnStartupEvent: contract.isSpawnStartupEvent,
84
+ shouldSuppressPostMutationError: contract.shouldSuppressPostMutationError,
85
+ buildWorkerArgs: contract.buildWorkerArgs,
86
+ encodePooledOutput: contract.encodePooledOutput,
87
+ validateRuntimeAdapter: contract.normalizeRuntimeAdapter,
60
88
  // Exposed for tests — engine code MUST go through resolveRuntime/listRuntimes
61
89
  _registry: registry,
62
90
  };
package/engine/shared.js CHANGED
@@ -1037,8 +1037,16 @@ function validateDispatchTmpDir(dirPath) {
1037
1037
 
1038
1038
  function removeDispatchTmpDir(dirPath) {
1039
1039
  if (!validateDispatchTmpDir(dirPath)) return false;
1040
- try { fs.rmSync(dirPath, { recursive: true, force: true }); return true; }
1041
- catch { return false; }
1040
+ try {
1041
+ _retryFsOp(
1042
+ () => fs.rmSync(dirPath, { recursive: true, force: true }),
1043
+ `removeDispatchTmpDir(${dirPath})`
1044
+ );
1045
+ return true;
1046
+ } catch (e) {
1047
+ log('warn', `removeDispatchTmpDir failed after retries: ${e.message}`, { path: dirPath, error: e.code });
1048
+ return false;
1049
+ }
1042
1050
  }
1043
1051
 
1044
1052
  // Read a file using O_NOFOLLOW where the platform supports it. On Windows
@@ -1439,48 +1447,37 @@ function withFileLock(lockPath, fn, {
1439
1447
  const isLockRaceErr = err.code === 'EEXIST' ||
1440
1448
  (process.platform === 'win32' && err.code === 'EPERM');
1441
1449
  if (!isLockRaceErr) throw err;
1442
- // P-b7d4e8f2 Stale-lock check combines mtime age with PID liveness:
1443
- // 1. If mtime <= LOCK_STALE_MS → never reap (recently active).
1444
- // 2. If JSON-parsable {pid, ts}:
1445
- // - dead PID → reap.
1446
- // - alive PID, mtime <= 5× → don't reap (legitimate slow holder).
1447
- // - alive PID, mtime > 5× → reap (last-resort guard against
1448
- // stuck holders that never released).
1449
- // 3. Legacy / empty / non-JSON lockfile → mtime-only path (reap).
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.
1450
1453
  try {
1451
1454
  const stat = fs.statSync(lockPath);
1452
1455
  const mtimeAge = Date.now() - stat.mtimeMs;
1453
- if (mtimeAge > LOCK_STALE_MS) {
1454
- let holderPid = null;
1455
- try {
1456
- const raw = fs.readFileSync(lockPath, 'utf8');
1457
- const parsed = JSON.parse(raw);
1458
- if (parsed && Number.isFinite(parsed.pid) && parsed.pid > 0) {
1459
- holderPid = parsed.pid;
1460
- }
1461
- } catch { /* legacy/empty/corrupt lock → fall through to mtime-only */ }
1462
-
1463
- let shouldReap;
1464
- if (holderPid !== null) {
1465
- shouldReap = !processUtils.isPidAlive(holderPid) || mtimeAge > LOCK_STALE_MS * 5;
1466
- } else {
1467
- // Legacy empty/non-JSON lockfile: trust mtime alone
1468
- shouldReap = true;
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;
1469
1462
  }
1463
+ } catch { /* legacy/empty/corrupt lock → mtime-only fallback */ }
1470
1464
 
1471
- if (shouldReap) {
1472
- try {
1473
- fs.unlinkSync(lockPath);
1474
- } catch (unlinkErr) {
1475
- // ENOENT: another process deleted the lock between stat and unlink — safe to retry.
1476
- // EPERM on Windows: file is in 'pending delete' state from a concurrent
1477
- // reaper — the OS will finalize the delete shortly; retry will succeed.
1478
- const tolerable = unlinkErr.code === 'ENOENT' ||
1479
- (process.platform === 'win32' && unlinkErr.code === 'EPERM');
1480
- if (!tolerable) throw unlinkErr;
1481
- }
1482
- continue; // lock just removed retry immediately
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;
1483
1479
  }
1480
+ continue; // lock just removed — retry immediately
1484
1481
  }
1485
1482
  } catch (staleErr) {
1486
1483
  // ENOENT from statSync: lock file disappeared between EEXIST and stat — retry will succeed.
@@ -3120,9 +3117,8 @@ const ENGINE_DEFAULTS = {
3120
3117
  // description the validator will never accept (see stage-output
3121
3118
  // substitution bug above) got re-evaluated forever, once per tick,
3122
3119
  // invisible to the dashboard. Once the SAME (unchanged) description is
3123
- // rejected this many consecutive times, the WI is flipped to `failed`
3124
- // with FAILURE_CLASS.PRE_DISPATCH_EVAL_STUCK instead of being silently
3125
- // re-queued into review again.
3120
+ // rejected this many consecutive times, the WI stays pending but is marked
3121
+ // exhausted so discovery pauses until an operator edits it.
3126
3122
  preDispatchEvalMaxRejections: 5,
3127
3123
  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".
3128
3124
  autoApplyReviewVote: false, // Master gate for platform vote mutations; local verdicts remain informational when false.
@@ -3255,6 +3251,7 @@ const ENGINE_DEFAULTS = {
3255
3251
  maxMeetingPromptBytes: 16 * 1024, // cap meeting findings/debate context injected into prompts
3256
3252
  maxMeetingHumanNotesBytes: 2 * 1024, // cap human note bullet lists injected into meeting prompts
3257
3253
  maxPipelineMeetingContextBytes: 16 * 1024, // cap aggregated meeting/dependency context for pipeline plan generation
3254
+ maxPipelineStageHandoffBytes: 32 * 1024, // cap current-run note content exposed through {{stages.<id>.handoff}}
3258
3255
  notesArchiveMaxFiles: 2000, // keep notes/archive bounded during periodic cleanup
3259
3256
  // ── Worktree pool (W-mp73ya3e000me6c5) ─────────────────────────────────────
3260
3257
  // Per-project warm pool: completed worktrees are parked detached at
@@ -3324,7 +3321,7 @@ const ENGINE_DEFAULTS = {
3324
3321
  'node', 'bun', 'npm', 'npx', 'pnpm', 'yarn',
3325
3322
  'python', 'python3', 'pip', 'pip3',
3326
3323
  'docker', 'podman',
3327
- 'adb', 'emulator',
3324
+ 'adb', 'emulator', 'maestro',
3328
3325
  'gradle', 'gradlew', 'mvn',
3329
3326
  'pwsh', 'powershell', 'bash', 'sh',
3330
3327
  'curl', 'wget',
@@ -3431,9 +3428,9 @@ function resolvePollFlag(engineCfg, granularKey, legacyMacroKey) {
3431
3428
 
3432
3429
  // ─── Runtime Fleet Resolution (P-3b8e5f1d) ──────────────────────────────────
3433
3430
  //
3434
- // Six helpers that are the single source of truth for "which CLI runtime + model
3435
- // + budget + bare-mode applies to this spawn?". Engine code MUST go through
3436
- // these — never read `agent.cli`, `engine.defaultCli`, etc. directly. Future
3431
+ // Runtime helpers are the single source of truth for "which CLI runtime + model
3432
+ // + tool ceiling + budget + bare-mode applies to this spawn?". Engine code MUST
3433
+ // go through these — never read `agent.cli`, `engine.defaultCli`, etc. directly. Future
3437
3434
  // agents adding new resolution rules should extend these helpers, not bypass
3438
3435
  // them.
3439
3436
  //
@@ -3479,28 +3476,27 @@ function resolveCcCli(engine) {
3479
3476
  /**
3480
3477
  * Resolve whether the Command Center / doc-chat path should route through the
3481
3478
  * persistent ACP worker pool. Priority:
3482
- * 1. CC runtime guard pool spawns `copilot --acp` (Agent Client Protocol).
3483
- * Claude Code does not implement ACP, and `_invokeCcStream` would silently
3484
- * switch a Claude operator's CC turns to copilot if the pool ran for them.
3485
- * Refuse the pool when CC runtime is not copilot, even if explicit-true is
3486
- * set. Future runtimes that don't implement ACP fall into the same bucket.
3479
+ * 1. Runtime capability guard. Refuse the pool unless the selected adapter
3480
+ * declares `acpWorkerPool`, even if explicit-true is set, so pooling can
3481
+ * never switch the configured harness.
3487
3482
  * (W-mphlriic00095f69)
3488
3483
  * 2. `engine.ccUseWorkerPool` explicit true/false — operator override wins
3489
- * *within* the copilot runtime
3490
- * 3. Runtime-aware default: ON when CC runtime resolves to `copilot`
3491
- * (Copilot's cold-spawn is 18-21s on Windows — pool turns subsequent turns
3492
- * from 47-140s back into a few seconds).
3484
+ * within an ACP-capable runtime.
3485
+ * 3. Runtime-aware default: ON for any ACP-capable adapter.
3493
3486
  * 4. ENGINE_DEFAULTS.ccUseWorkerPool — final fallback
3494
3487
  *
3495
3488
  * Strict boolean check on the override so a literal `false` opts out even on
3496
- * Copilot, matching the "treat empty/null/undefined as unset" semantics used
3489
+ * ACP runtimes, matching the "treat empty/null/undefined as unset" semantics used
3497
3490
  * throughout the resolve* helpers for boolean flags.
3498
3491
  */
3499
3492
  function resolveCcUseWorkerPool(engine) {
3500
- // Guard 1 (W-mphlriic00095f69): pool transport is ACP-only. If CC runtime
3501
- // isn't copilot, the pool cannot run regardless of operator opt-in — don't
3502
- // silently switch their runtime to copilot.
3503
- if (resolveCcCli(engine) !== 'copilot') return false;
3493
+ let runtime;
3494
+ try {
3495
+ runtime = require('./runtimes').resolveRuntime(resolveCcCli(engine));
3496
+ } catch {
3497
+ return false;
3498
+ }
3499
+ if (runtime?.capabilities?.acpWorkerPool !== true) return false;
3504
3500
  if (engine && (engine.ccUseWorkerPool === true || engine.ccUseWorkerPool === false)) {
3505
3501
  return engine.ccUseWorkerPool;
3506
3502
  }
@@ -3514,7 +3510,7 @@ function resolveCcUseWorkerPool(engine) {
3514
3510
  * adapter (as returned by `resolveRuntime(resolveAgentCli(agent, engine))`)
3515
3511
  * rather than re-deriving it, since callers on the agent-spawn path already
3516
3512
  * have it in hand. Priority:
3517
- * 1. Capability guard — pool transport is ACP-only (`copilot --acp`). Hard
3513
+ * 1. Capability guard — pool transport is ACP-only. Hard
3518
3514
  * false whenever `runtime.capabilities.acpWorkerPool` isn't `true`,
3519
3515
  * regardless of operator override, so a future non-ACP runtime can never
3520
3516
  * be silently routed through the pool (same rationale as
@@ -3588,6 +3584,17 @@ function resolveCcModel(engine) {
3588
3584
  return undefined;
3589
3585
  }
3590
3586
 
3587
+ /**
3588
+ * Resolve the fleet tool ceiling for an agent spawn.
3589
+ *
3590
+ * `config.claude.allowedTools` predates runtime adapters but has always applied
3591
+ * to every selected runtime. Keep that cross-runtime contract centralized here
3592
+ * until the field is migrated to a runtime-neutral config namespace.
3593
+ */
3594
+ function resolveAgentAllowedTools(config) {
3595
+ return config?.claude?.allowedTools;
3596
+ }
3597
+
3591
3598
  /**
3592
3599
  * Resolve the per-spawn USD budget cap. Priority:
3593
3600
  * 1. `agent.maxBudgetUsd` — per-agent override
@@ -3629,6 +3636,28 @@ function resolveAgentBareMode(agent, engine) {
3629
3636
  return false;
3630
3637
  }
3631
3638
 
3639
+ // W-mrdon0pe000l045a — resolve whether repo-authored CLAUDE.md instructions
3640
+ // should be propagated into the prompt for runtimes that don't auto-discover
3641
+ // CLAUDE.md natively (Copilot/Codex). Precedence mirrors
3642
+ // `resolveLiveCheckoutAutoReset`:
3643
+ // 1. per-project boolean `project.propagateClaudeMdForNonClaudeRuntimes`
3644
+ // 2. fleet-wide boolean `engine.propagateClaudeMdForNonClaudeRuntimes`
3645
+ // 3. hardcoded default TRUE (additive, on by default)
3646
+ // Only an explicit boolean counts as "set" at either level — undefined/null/
3647
+ // string are treated as unset so the next tier (or the TRUE default) decides.
3648
+ // This gate is orthogonal to each runtime's native AGENTS.md / repo-instruction
3649
+ // discovery by design (see ENGINE_DEFAULTS.propagateClaudeMdForNonClaudeRuntimes).
3650
+ // Pure, no I/O.
3651
+ function resolvePropagateClaudeMdForNonClaudeRuntimes(project, engine) {
3652
+ if (project && typeof project === 'object' && typeof project.propagateClaudeMdForNonClaudeRuntimes === 'boolean') {
3653
+ return project.propagateClaudeMdForNonClaudeRuntimes;
3654
+ }
3655
+ if (engine && typeof engine === 'object' && typeof engine.propagateClaudeMdForNonClaudeRuntimes === 'boolean') {
3656
+ return engine.propagateClaudeMdForNonClaudeRuntimes;
3657
+ }
3658
+ return true;
3659
+ }
3660
+
3632
3661
  // ─── Legacy ccModel → defaultModel Migration ─────────────────────────────────
3633
3662
  //
3634
3663
  // Pre-P-3b8e5f1d, `engine.ccModel` was the single fleet-wide model knob (it
@@ -4500,7 +4529,7 @@ const FAILURE_CLASS = {
4500
4529
  OUTPUT_TRUNCATED: 'output-truncated', // P-8e4c2a17: the agent streamed more stdout than the engine's hard capture cap (engine.js AGENT_OUTPUT_CAP_BYTES, 1MB) BEFORE the terminal `result` event arrived. The result (session id, completion block, final text) lives at the END of the stream, so it fell outside the captured window and parseOutput found nothing — the dispatch would otherwise fail as an opaque UNKNOWN and retry to death. Surfaced loudly with an actionable message; non-retryable (mechanical retry just reproduces the overflow — the agent must reduce output volume or the task must be split).
4501
4530
  VERIFY_MISSING_PR: 'verify-missing-pr', // Verify completed without an attached or tracked PR.
4502
4531
  PROJECT_NOT_FOUND: 'project_not_found', // W-mqv2wsy30002e090: a work item's `project` field names a project that is not configured (resolveConfiguredProject / formatUnknownProjectError → `Project "<name>" not found. Known projects: …`). Stamped at discovery time so the dashboard PR-column renderer surfaces the red failReason snippet like other non-retryable failures instead of burying it in the Agent column. Non-retryable — operator must fix the WI's project field or configure the project.
4503
- PRE_DISPATCH_EVAL_STUCK: 'pre-dispatch-eval-stuck', // W-mrcrh5hj0017d22f: pre-dispatch-eval rejected the SAME (unchanged) description ENGINE_DEFAULTS.preDispatchEvalMaxRejections times in a row without the WI status ever leaving pending — previously this burned an LLM validation call every discovery tick forever with nothing surfaced to the dashboard (constant-bug-bash's file-fixes stage wedged 16+ consecutive rejections, one/minute, until a human manually cancelled it). Engine now flips the WI to failed with this class so the stalled item is visible and stops silently re-evaluating; non-retryable until the description changes.
4532
+ PRE_DISPATCH_EVAL_STUCK: 'pre-dispatch-eval-stuck', // Legacy persisted classification normalized by migration 025. New exhausted evaluations remain pending with _preDispatchEval.exhausted so they are not counted as agent execution failures.
4504
4533
  UNKNOWN: 'unknown', // Unclassified failure
4505
4534
  };
4506
4535
 
@@ -6024,7 +6053,7 @@ function validateProjectPath(pathStr, options = {}) {
6024
6053
 
6025
6054
  function parseSkillFrontmatter(content, filename) {
6026
6055
  let name = filename.replace('.md', '');
6027
- let trigger = '', description = '', project = 'any', author = '', created = '', allowedTools = '';
6056
+ let trigger = '', description = '', project = 'any', author = '', created = '', allowedTools = '', scope = 'minions';
6028
6057
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
6029
6058
  if (fmMatch) {
6030
6059
  const fm = fmMatch[1];
@@ -6036,8 +6065,52 @@ function parseSkillFrontmatter(content, filename) {
6036
6065
  author = m('author');
6037
6066
  created = m('created');
6038
6067
  allowedTools = m('allowed-tools');
6068
+ scope = m('scope') || scope;
6069
+ }
6070
+ return { name, trigger, description, project, author, created, allowedTools, scope };
6071
+ }
6072
+
6073
+ function syncBundledPersonalSkills(skillsDir, opts = {}) {
6074
+ const result = { created: [], updated: [], unchanged: [] };
6075
+ if (!skillsDir || !fs.existsSync(skillsDir)) return result;
6076
+
6077
+ const homeDir = opts.homeDir || os.homedir();
6078
+ const { listRuntimes, resolveRuntime } = require('./runtimes');
6079
+ const personalRoots = new Set();
6080
+ for (const runtimeName of listRuntimes()) {
6081
+ const runtime = resolveRuntime(runtimeName);
6082
+ if (typeof runtime.getSkillWriteTargets !== 'function') continue;
6083
+ const personal = runtime.getSkillWriteTargets({ homeDir }).personal;
6084
+ if (typeof personal === 'string' && personal.trim()) {
6085
+ personalRoots.add(path.resolve(personal));
6086
+ }
6087
+ }
6088
+
6089
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true })
6090
+ .filter(entry => entry.isDirectory())
6091
+ .sort((a, b) => a.name.localeCompare(b.name));
6092
+ for (const entry of entries) {
6093
+ const sourcePath = path.join(skillsDir, entry.name, 'SKILL.md');
6094
+ if (!fs.existsSync(sourcePath)) continue;
6095
+ const content = fs.readFileSync(sourcePath, 'utf8');
6096
+ const skill = parseSkillFrontmatter(content, `${entry.name}.md`);
6097
+ if (skill.scope !== 'minions') continue;
6098
+ const skillName = String(skill.name || entry.name).replace(/[^a-z0-9-]/g, '-');
6099
+ if (!skillName) throw new Error(`Bundled skill has an invalid name: ${sourcePath}`);
6100
+
6101
+ for (const personalRoot of personalRoots) {
6102
+ const targetPath = path.join(personalRoot, skillName, 'SKILL.md');
6103
+ const exists = fs.existsSync(targetPath);
6104
+ if (exists && fs.readFileSync(targetPath, 'utf8') === content) {
6105
+ result.unchanged.push(targetPath);
6106
+ continue;
6107
+ }
6108
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
6109
+ fs.copyFileSync(sourcePath, targetPath);
6110
+ result[exists ? 'updated' : 'created'].push(targetPath);
6111
+ }
6039
6112
  }
6040
- return { name, trigger, description, project, author, created, allowedTools };
6113
+ return result;
6041
6114
  }
6042
6115
 
6043
6116
  // ── PR → PRD Links ────────────────────────────────────────────────────────────
@@ -7721,6 +7794,7 @@ function prFixEvidenceFingerprint(pr, cause = PR_FIX_CAUSE.UNKNOWN) {
7721
7794
  evidence.lastReviewedAt = pr?.lastReviewedAt || '';
7722
7795
  evidence.reviewedAt = review.reviewedAt || '';
7723
7796
  evidence.reviewNote = review.note || pr?.reviewNote || '';
7797
+ evidence.findingId = prReviewFindingIdentity(pr);
7724
7798
  // #2979 — same rationale as BUILD_FAILURE: review feedback fingerprints
7725
7799
  // were sticky across force-push because reviewStatus / reviewedAt /
7726
7800
  // reviewNote don't change when the author rebases. Adding the head SHA
@@ -7732,6 +7806,23 @@ function prFixEvidenceFingerprint(pr, cause = PR_FIX_CAUSE.UNKNOWN) {
7732
7806
  return crypto.createHash('sha1').update(JSON.stringify(evidence)).digest('hex').slice(0, 16);
7733
7807
  }
7734
7808
 
7809
+ function prReviewFindingIdentity(pr) {
7810
+ const review = pr?.minionsReview || {};
7811
+ const threads = Array.isArray(review.threads)
7812
+ ? review.threads.map(value => String(value)).filter(Boolean).sort()
7813
+ : [];
7814
+ const evidence = {
7815
+ pr: pr?.id || pr?.url || '',
7816
+ sourceItem: review.sourceItem || '',
7817
+ dispatchId: review.dispatchId || '',
7818
+ reviewer: review.reviewer || '',
7819
+ reviewedAt: review.reviewedAt || pr?.lastReviewedAt || '',
7820
+ threads,
7821
+ note: review.note || pr?.reviewNote || '',
7822
+ };
7823
+ return `review-${crypto.createHash('sha1').update(JSON.stringify(evidence)).digest('hex').slice(0, 16)}`;
7824
+ }
7825
+
7735
7826
  function getPrNoOpFixRecord(pr, cause) {
7736
7827
  if (!pr || !cause || !pr._noOpFixes || typeof pr._noOpFixes !== 'object') return null;
7737
7828
  const record = pr._noOpFixes[cause];
@@ -8491,7 +8582,7 @@ module.exports = {
8491
8582
  ENGINE_DEFAULTS,
8492
8583
  resolvePollFlag, // P-c4d8e1a3 — granular per-poller flag resolution
8493
8584
  resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentUseWorkerPool, resolveAgentAcpPoolSize, resolveAgentModel, resolveCcModel,
8494
- resolveAgentMaxBudget, resolveAgentBareMode,
8585
+ resolveAgentAllowedTools, resolveAgentMaxBudget, resolveAgentBareMode, resolvePropagateClaudeMdForNonClaudeRuntimes,
8495
8586
  applyLegacyCcModelMigration, _resetLegacyCcModelMigrationFlag,
8496
8587
  runtimeConfigWarnings,
8497
8588
  projectWorkSourceWarnings,
@@ -8629,6 +8720,7 @@ module.exports = {
8629
8720
  PR_FIX_CAUSE,
8630
8721
  getPrFixAutomationCause,
8631
8722
  prFixEvidenceFingerprint,
8723
+ prReviewFindingIdentity,
8632
8724
  prMergeConflictGuardKey,
8633
8725
  resetMergeConflictStateOnRetarget,
8634
8726
  getPrNoOpFixRecord,
@@ -8636,6 +8728,7 @@ module.exports = {
8636
8728
  getPrPausedCauses,
8637
8729
  isBuildFixIneffectivePaused,
8638
8730
  parseSkillFrontmatter,
8731
+ syncBundledPersonalSkills,
8639
8732
  sleepMs,
8640
8733
  clearWorktreeFailureCache,
8641
8734
  removeWorktree,