@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/llm.js CHANGED
@@ -18,7 +18,15 @@ const {
18
18
  ENGINE_DEFAULTS,
19
19
  resolveCcCli, resolveCcModel,
20
20
  } = shared;
21
- const { resolveRuntime } = require('./runtimes');
21
+ const {
22
+ resolveRuntime,
23
+ resolveRuntimeByCapability,
24
+ listRuntimes,
25
+ RUNTIME_IMAGES_FILE_OPTION,
26
+ sanitizeInvocationOptions,
27
+ resolveInvocationOptions,
28
+ buildSpawnFlags,
29
+ } = require('./runtimes');
22
30
 
23
31
  const MINIONS_DIR = shared.MINIONS_DIR;
24
32
  const ENGINE_DIR = path.join(MINIONS_DIR, 'engine');
@@ -217,15 +225,17 @@ function _runtimeInstallHint(runtimeName, runtime) {
217
225
  function _missingRuntimeMessage(runtimeName, runtime, reason) {
218
226
  const selected = runtime?.name || runtimeName || 'configured';
219
227
  if (!runtime) {
228
+ const installLines = listRuntimes().map((name) => {
229
+ const adapter = resolveRuntime(name);
230
+ return `- ${name}: ${_runtimeInstallHint(name, adapter)}`;
231
+ });
220
232
  return [
221
233
  `Minions can't run the configured runtime "${selected}".`,
222
234
  '',
223
235
  reason || 'The configured runtime is not registered.',
224
236
  '',
225
237
  'Choose a supported runtime in Settings -> Engine -> defaultCli/ccCli, then install its CLI:',
226
- '- Claude Code: npm install -g @anthropic-ai/claude-code or download from https://claude.ai/download',
227
- '- OpenAI Codex CLI: npm install -g @openai/codex or brew install --cask codex',
228
- '- GitHub Copilot CLI: install via GitHub CLI/gh-copilot or download from https://github.com/github/copilot-cli/releases',
238
+ ...installLines,
229
239
  '',
230
240
  'After installing, restart Minions so the dashboard and engine inherit the updated PATH.',
231
241
  ].join('\n');
@@ -239,10 +249,9 @@ function _missingRuntimeMessage(runtimeName, runtime, reason) {
239
249
  '',
240
250
  'After installing, restart Minions so the dashboard and engine inherit the updated PATH.',
241
251
  ];
242
- if (selected === 'claude') {
243
- lines.push('Or switch Settings -> Engine -> defaultCli/ccCli to "copilot" or "codex" after installing another supported runtime.');
244
- } else {
245
- lines.push('Or switch Settings -> Engine -> defaultCli/ccCli back to "claude" after installing Claude Code.');
252
+ const alternatives = listRuntimes().filter(name => name !== selected);
253
+ if (alternatives.length) {
254
+ lines.push(`Or switch Settings -> Engine -> defaultCli/ccCli to another installed runtime: ${alternatives.join(', ')}.`);
246
255
  }
247
256
  return lines.join('\n');
248
257
  }
@@ -337,37 +346,12 @@ function _runtimeUnavailableResult(callOpts = {}) {
337
346
  // ─── Spawn Helpers ───────────────────────────────────────────────────────────
338
347
 
339
348
  /**
340
- * Translate the unified opts bag into the named CLI flags consumed by
341
- * `engine/spawn-agent.js`. spawn-agent.js parses these back into an opts
342
- * object and calls `runtime.buildArgs(opts)` once keeping the adapter as
343
- * the single source of truth and avoiding double-flag emission.
344
- *
345
- * Capability gating (matches engine.js _buildAgentSpawnFlags from P-2a6d9c4f):
346
- * - effort/sessionId/maxBudget/bare/fallbackModel are dropped when the
347
- * runtime's matching capability is false.
348
- * - Copilot-specific opts (stream, disableBuiltinMcps,
349
- * reasoningSummaries) are emitted unconditionally; the Claude adapter
350
- * ignores them via the "tolerate unknown opts" rule.
349
+ * Encode the semantic invocation options through the stable runtime facade.
350
+ * Legacy named wrapper flags remain for compatibility; the opaque options bag
351
+ * is authoritative for adapter fields the wrapper does not know yet.
351
352
  */
352
353
  function _buildSpawnAgentFlags(runtime, opts = {}) {
353
- const caps = (runtime && runtime.capabilities) || {};
354
- const flags = ['--runtime', String(runtime?.name || 'claude')];
355
-
356
- if (opts.maxTurns != null) flags.push('--max-turns', String(opts.maxTurns));
357
- if (opts.model) flags.push('--model', String(opts.model));
358
- if (opts.allowedTools) flags.push('--allowedTools', String(opts.allowedTools));
359
-
360
- if (caps.effortLevels && opts.effort) flags.push('--effort', String(opts.effort));
361
- if (caps.sessionResume && opts.sessionId) flags.push('--resume', String(opts.sessionId));
362
- if (caps.budgetCap && opts.maxBudget != null) flags.push('--max-budget-usd', String(opts.maxBudget));
363
- if (caps.bareMode && opts.bare === true) flags.push('--bare');
364
- if (caps.fallbackModel && opts.fallbackModel) flags.push('--fallback-model', String(opts.fallbackModel));
365
-
366
- if (opts.stream === 'on' || opts.stream === 'off') flags.push('--stream', opts.stream);
367
- if (opts.disableBuiltinMcps === true) flags.push('--disable-builtin-mcps');
368
- if (opts.reasoningSummaries === true) flags.push('--enable-reasoning-summaries');
369
-
370
- return flags;
354
+ return buildSpawnFlags(runtime, opts);
371
355
  }
372
356
 
373
357
  /**
@@ -379,15 +363,12 @@ function _buildSpawnAgentFlags(runtime, opts = {}) {
379
363
  *
380
364
  * Indirect path: uses engine/spawn-agent.js — mostly a fallback when the
381
365
  * direct path can't resolve the binary cache. spawn-agent.js handles
382
- * adapter resolution itself; we just hand it `--runtime <name>` plus the
383
- * named flags it knows how to parse.
366
+ * adapter resolution itself; we hand it the runtime plus an opaque options
367
+ * bag, so adding a harness flag does not require wrapper changes.
384
368
  */
385
369
  function _spawnProcess(promptText, sysPromptText, callOpts) {
386
370
  const {
387
- direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
388
- maxBudget, bare, fallbackModel,
389
- stream, disableBuiltinMcps, reasoningSummaries,
390
- images,
371
+ direct, label, runtime, adapterOptions = {},
391
372
  } = callOpts;
392
373
 
393
374
  const id = uid();
@@ -399,24 +380,10 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
399
380
  const cleanupFiles = [];
400
381
  const cleanupDirs = [llmTmpDir];
401
382
  const caps = (runtime && runtime.capabilities) || {};
402
- const adapterOpts = {
403
- model, maxTurns, allowedTools, effort, sessionId,
404
- maxBudget, bare, fallbackModel,
405
- stream, disableBuiltinMcps, reasoningSummaries,
406
- images,
383
+ const adapterOpts = sanitizeInvocationOptions(runtime, {
384
+ ...adapterOptions,
407
385
  tmpDir: llmTmpDir,
408
- };
409
- // Capability-gate per-flag opts before prompt construction so adapters can
410
- // make resume-aware prompt decisions from the same opts used for argv.
411
- if (!caps.effortLevels) adapterOpts.effort = undefined;
412
- if (!caps.sessionResume) adapterOpts.sessionId = undefined;
413
- if (!caps.budgetCap) adapterOpts.maxBudget = undefined;
414
- if (!caps.bareMode) adapterOpts.bare = undefined;
415
- if (!caps.fallbackModel) adapterOpts.fallbackModel = undefined;
416
- // P-7b2e4d01 — defense in depth: never forward images to a runtime that
417
- // can't read them (callLLM/callLLMStreaming already gate + surface a typed
418
- // error upstream; this protects any other _spawnProcess caller).
419
- if (!caps.imageInput) adapterOpts.images = undefined;
386
+ });
420
387
  const finalPrompt = runtime.buildPrompt(promptText, sysPromptText, adapterOpts);
421
388
 
422
389
  // ── Direct path ──
@@ -426,7 +393,7 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
426
393
  // Only write a sys-prompt tmp file when the runtime actually consumes one
427
394
  // via --system-prompt-file (Claude) AND we're not resuming (resumed sessions
428
395
  // already have the sys prompt baked in).
429
- if (!sessionId && sysPromptText && caps.systemPromptFile) {
396
+ if (!adapterOpts.sessionId && sysPromptText && caps.systemPromptFile) {
430
397
  sysTmpPath = path.join(llmTmpDir, `direct-sys-${id}.md`);
431
398
  fs.writeFileSync(sysTmpPath, sysPromptText);
432
399
  cleanupFiles.push(sysTmpPath);
@@ -472,12 +439,16 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
472
439
  const pidPath = promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid');
473
440
  cleanupFiles.push(promptPath, sysPath, pidPath);
474
441
 
475
- const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
476
- const adapterFlags = _buildSpawnAgentFlags(runtime, {
477
- model, maxTurns, allowedTools, effort, sessionId,
478
- maxBudget, bare, fallbackModel,
479
- stream, disableBuiltinMcps, reasoningSummaries,
480
- });
442
+ const spawnScript = runtime.spawnScript || path.join(ENGINE_DIR, 'spawn-agent.js');
443
+ const indirectAdapterOpts = { ...adapterOpts };
444
+ if (Array.isArray(indirectAdapterOpts.images) && indirectAdapterOpts.images.length) {
445
+ const imagesPath = path.join(llmTmpDir, `runtime-images-${id}.json`);
446
+ safeWrite(imagesPath, JSON.stringify(indirectAdapterOpts.images));
447
+ cleanupFiles.push(imagesPath);
448
+ indirectAdapterOpts[RUNTIME_IMAGES_FILE_OPTION] = imagesPath;
449
+ delete indirectAdapterOpts.images;
450
+ }
451
+ const adapterFlags = _buildSpawnAgentFlags(runtime, indirectAdapterOpts);
481
452
  const args = [spawnScript, promptPath, sysPath, ...adapterFlags];
482
453
 
483
454
  const proc = runFile(process.execPath, args, {
@@ -692,11 +663,16 @@ function _resolveModelForRuntime(runtime, callOpts) {
692
663
  function _resolveRuntimeFeatureOpts({
693
664
  stream, disableBuiltinMcps, reasoningSummaries, engineConfig,
694
665
  } = {}) {
695
- const engine = engineConfig || {};
666
+ const runtime = resolveRuntimeByCapability('acpWorkerPool');
667
+ const resolved = resolveInvocationOptions(runtime, {
668
+ stream,
669
+ disableBuiltinMcps,
670
+ reasoningSummaries,
671
+ }, { engineConfig: engineConfig || {} });
696
672
  return {
697
- stream: stream ?? engine.copilotStreamMode,
698
- disableBuiltinMcps: disableBuiltinMcps ?? engine.copilotDisableBuiltinMcps,
699
- reasoningSummaries: reasoningSummaries ?? engine.copilotReasoningSummaries,
673
+ stream: resolved.stream,
674
+ disableBuiltinMcps: resolved.disableBuiltinMcps,
675
+ reasoningSummaries: resolved.reasoningSummaries,
700
676
  };
701
677
  }
702
678
 
@@ -770,17 +746,17 @@ function callLLM(promptText, sysPromptText, opts = {}) {
770
746
  const model = _resolveModelForRuntime(runtime, { model: modelOverride, engineConfig });
771
747
  const imageGate = _resolveImageOpts(images, runtime && runtime.capabilities);
772
748
  if (imageGate.error) return _resolvedCallResult(_imageUnsupportedResult(runtime, imageGate.error));
773
- const runtimeFeatureOpts = _resolveRuntimeFeatureOpts({
774
- stream, disableBuiltinMcps, reasoningSummaries, engineConfig,
775
- });
749
+ const runtimeOptions = resolveInvocationOptions(runtime, {
750
+ model, maxTurns, allowedTools, effort, sessionId,
751
+ maxBudget, bare, fallbackModel, images: imageGate.images,
752
+ stream, disableBuiltinMcps, reasoningSummaries,
753
+ }, { engineConfig: engineConfig || {} });
776
754
 
777
755
  let _abort = null;
778
756
  const promise = new Promise((resolve) => {
779
757
  const _startMs = Date.now();
780
758
  const { proc, cleanupFiles, cleanupDirs } = _spawnProcess(promptText, sysPromptText, {
781
- direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
782
- maxBudget, bare, fallbackModel, images: imageGate.images,
783
- ...runtimeFeatureOpts,
759
+ direct, label, runtime, adapterOptions: runtimeOptions,
784
760
  });
785
761
  let resolved = false;
786
762
  let exitFallbackTimer = null;
@@ -895,17 +871,17 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
895
871
  const model = _resolveModelForRuntime(runtime, { model: modelOverride, engineConfig });
896
872
  const imageGate = _resolveImageOpts(images, runtime && runtime.capabilities);
897
873
  if (imageGate.error) return _resolvedCallResult(_imageUnsupportedResult(runtime, imageGate.error));
898
- const runtimeFeatureOpts = _resolveRuntimeFeatureOpts({
899
- stream, disableBuiltinMcps, reasoningSummaries, engineConfig,
900
- });
874
+ const runtimeOptions = resolveInvocationOptions(runtime, {
875
+ model, maxTurns, allowedTools, effort, sessionId,
876
+ maxBudget, bare, fallbackModel, images: imageGate.images,
877
+ stream, disableBuiltinMcps, reasoningSummaries,
878
+ }, { engineConfig: engineConfig || {} });
901
879
 
902
880
  let _abort = null;
903
881
  const promise = new Promise((resolve) => {
904
882
  const _startMs = Date.now();
905
883
  const { proc, cleanupFiles, cleanupDirs } = _spawnProcess(promptText, sysPromptText, {
906
- direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
907
- maxBudget, bare, fallbackModel, images: imageGate.images,
908
- ...runtimeFeatureOpts,
884
+ direct, label, runtime, adapterOptions: runtimeOptions,
909
885
  });
910
886
  let resolved = false;
911
887
  let exitFallbackTimer = null;
@@ -302,7 +302,22 @@ function _validateSpec(spec, index, limits, opts) {
302
302
  if (typeof spec.cwd === 'string' && spec.cwd.length > 500) {
303
303
  return { ok: false, reason: 'cwd-too-long' };
304
304
  }
305
- if (_resolveRequireGitWorkdir(opts) && typeof spec.cwd === 'string' && spec.cwd.length > 0) {
305
+ if (opts.allowedCwdRoot) {
306
+ if (typeof spec.cwd !== 'string' || spec.cwd.length === 0) {
307
+ return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'cwd-required-for-dispatch-confinement' };
308
+ }
309
+ try {
310
+ const cwdReal = shared.realPathForComparison(spec.cwd);
311
+ const rootReal = shared.realPathForComparison(opts.allowedCwdRoot);
312
+ if (!shared.isPathInsideOrEqual(cwdReal, rootReal)) {
313
+ return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'cwd-outside-dispatch-worktree' };
314
+ }
315
+ } catch (err) {
316
+ return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'cwd-containment-check-failed (' + err.message + ')' };
317
+ }
318
+ }
319
+ let normalizedCwd = typeof spec.cwd === 'string' ? spec.cwd : '';
320
+ if (_resolveRequireGitWorkdir(opts) && normalizedCwd.length > 0) {
306
321
  const wt = shared.isValidGitWorktree(spec.cwd, { memo: opts._gitWorktreeMemo });
307
322
  if (!wt.ok) {
308
323
  // W-mpbpa01y000qcdc2 — enrich the reject reason so the dispatcher prompt
@@ -319,6 +334,12 @@ function _validateSpec(spec, index, limits, opts) {
319
334
  return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + detail };
320
335
  }
321
336
  }
337
+ if (normalizedCwd) {
338
+ try { normalizedCwd = shared.realPathForComparison(normalizedCwd); }
339
+ catch (err) {
340
+ return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'cwd-canonicalization-failed (' + err.message + ')' };
341
+ }
342
+ }
322
343
 
323
344
  // env (optional). Validation has three independent layers:
324
345
  // 1. Shape guard: key matches /^[A-Za-z_][A-Za-z0-9_]*$/ (argv-smuggling
@@ -339,7 +360,7 @@ function _validateSpec(spec, index, limits, opts) {
339
360
  if (envKeys.length > maxEnv) {
340
361
  return { ok: false, reason: 'env-too-many (>' + maxEnv + ')' };
341
362
  }
342
- const projectForSpec = _resolveProjectForCwd(
363
+ const projectForSpec = opts.project || _resolveProjectForCwd(
343
364
  typeof spec.cwd === 'string' ? spec.cwd : '',
344
365
  opts && Array.isArray(opts.projects) ? opts.projects : null,
345
366
  );
@@ -411,7 +432,7 @@ function _validateSpec(spec, index, limits, opts) {
411
432
  name: spec.name,
412
433
  cmd: spec.cmd,
413
434
  args: args,
414
- cwd: typeof spec.cwd === 'string' ? spec.cwd : '',
435
+ cwd: normalizedCwd,
415
436
  env: env,
416
437
  ports: ports,
417
438
  ttl_minutes: ttlMinutes,
@@ -585,7 +606,7 @@ function buildManagedSpawnHint(opts) {
585
606
  ' "name": "constellation-host",',
586
607
  ' "cmd": "bun",',
587
608
  ' "args": ["run", "dev"],',
588
- ' "cwd": "D:/repos/constellation",',
609
+ ' "cwd": "<absolute path at or below MINIONS_AGENT_CWD>",',
589
610
  ' "env": { "VITE_HOST": "127.0.0.1" },',
590
611
  ' "ports": [3001],',
591
612
  ' "ttl_minutes": ' + ttl + ',',
@@ -624,6 +645,7 @@ function buildManagedSpawnHint(opts) {
624
645
  '- Ports: 1024–65535, ≤ 20 per spec',
625
646
  '- TTL: ≤ ' + maxTtl + ' minutes (hard cap), defaults to ' + defaultTtl + ' if omitted',
626
647
  '- `attrs` serialized: ≤ 2048 bytes (opaque blob the engine surfaces to downstream agents)',
648
+ '- `cwd`: must resolve at or below this dispatch\'s `MINIONS_AGENT_CWD`. A worktree-mode project\'s operator checkout is rejected even though it is a valid Git checkout.',
627
649
  '',
628
650
  'If your file is invalid the engine marks this dispatch ERROR with `failure_class: invalid-managed-spawn` (non-retryable) — fix the file shape, don\'t retry blindly.',
629
651
  '',
@@ -875,6 +897,9 @@ function _toStateRecord(spec, runtime, ctx) {
875
897
  owner_agent: (ctx && ctx.owner_agent) || '',
876
898
  owner_wi: (ctx && ctx.owner_wi) || '',
877
899
  owner_project: (ctx && ctx.owner_project) || '',
900
+ owner_work_type: (ctx && ctx.owner_work_type) || '',
901
+ dispatch_root: (ctx && ctx.dispatch_root) || '',
902
+ checkout_mode: (ctx && ctx.checkout_mode) || '',
878
903
  cmd: spec.cmd,
879
904
  args: Array.isArray(spec.args) ? spec.args.slice() : [],
880
905
  cwd: typeof spec.cwd === 'string' ? spec.cwd : '',
@@ -1265,7 +1290,69 @@ function computeManagedSpecsEtag(opts) {
1265
1290
  return crypto.createHash('sha1').update(json).digest('hex').slice(0, 16);
1266
1291
  }
1267
1292
 
1268
- function restartManagedSpec(name) {
1293
+ function validateManagedSpecExecutionContext(record, opts) {
1294
+ opts = opts || {};
1295
+ if (!record || typeof record.cwd !== 'string' || !record.cwd) {
1296
+ return { ok: false, reason: 'cwd-missing' };
1297
+ }
1298
+ let projects;
1299
+ try {
1300
+ projects = Array.isArray(opts.projects) ? opts.projects : shared.getProjects(opts.config);
1301
+ } catch {
1302
+ return { ok: false, reason: 'project-config-unavailable' };
1303
+ }
1304
+ const project = record.owner_project
1305
+ ? shared.findProjectByName(projects, record.owner_project)
1306
+ : null;
1307
+ for (const candidate of projects) {
1308
+ if (!candidate?.localPath) continue;
1309
+ const candidateMode = shared.resolveCheckoutMode(
1310
+ candidate,
1311
+ record.owner_work_type || shared.WORK_TYPE.IMPLEMENT,
1312
+ );
1313
+ if (candidateMode !== shared.CHECKOUT_MODES.WORKTREE) continue;
1314
+ try {
1315
+ if (shared.pathsOverlap(record.cwd, candidate.localPath)) {
1316
+ return { ok: false, reason: 'cwd-overlaps-worktree-mode-operator-checkout' };
1317
+ }
1318
+ } catch {
1319
+ return { ok: false, reason: 'cwd-overlap-check-failed' };
1320
+ }
1321
+ }
1322
+ const mode = project
1323
+ ? shared.resolveCheckoutMode(project, record.owner_work_type || shared.WORK_TYPE.IMPLEMENT)
1324
+ : shared.CHECKOUT_MODES.WORKTREE;
1325
+ if (project && mode === shared.CHECKOUT_MODES.LIVE) {
1326
+ if (record.dispatch_root && !shared.sameResolvedPath(record.dispatch_root, project.localPath)) {
1327
+ return { ok: false, reason: 'live-dispatch-root-mismatch' };
1328
+ }
1329
+ try {
1330
+ if (!shared.isPathInsideOrEqual(record.cwd, project.localPath)) {
1331
+ return { ok: false, reason: 'live-cwd-outside-project' };
1332
+ }
1333
+ } catch {
1334
+ return { ok: false, reason: 'live-cwd-unavailable' };
1335
+ }
1336
+ return { ok: true, project, mode };
1337
+ }
1338
+
1339
+ if (!record.dispatch_root) {
1340
+ return record.checkout_mode
1341
+ ? { ok: false, reason: 'dispatch-root-missing' }
1342
+ : { ok: true, legacy: true, project, mode };
1343
+ }
1344
+ try {
1345
+ shared.assertWorktreeOutsideProjects(record.dispatch_root, projects);
1346
+ if (!shared.isPathInsideOrEqual(record.cwd, record.dispatch_root)) {
1347
+ return { ok: false, reason: 'cwd-outside-dispatch-root' };
1348
+ }
1349
+ } catch {
1350
+ return { ok: false, reason: 'dispatch-root-inside-operator-checkout' };
1351
+ }
1352
+ return { ok: true, project, mode };
1353
+ }
1354
+
1355
+ function restartManagedSpec(name, opts) {
1269
1356
  if (typeof name !== 'string' || name.length === 0) {
1270
1357
  throw new Error('restartManagedSpec: name required');
1271
1358
  }
@@ -1273,6 +1360,11 @@ function restartManagedSpec(name) {
1273
1360
  if (!existing) {
1274
1361
  throw new Error('restartManagedSpec: spec not found: ' + name);
1275
1362
  }
1363
+ const executionContext = validateManagedSpecExecutionContext(existing, opts);
1364
+ if (!executionContext.ok) {
1365
+ try { removeManagedSpec(name); } catch {}
1366
+ throw new Error('restartManagedSpec: unsafe persisted execution context (' + executionContext.reason + ')');
1367
+ }
1276
1368
  // Reconstruct a sidecar-shaped spec from the persisted state row. The
1277
1369
  // state shape keeps cmd/args/cwd/env/ports/attrs/healthcheck verbatim
1278
1370
  // (see _toStateRecord), so this is a direct projection.
@@ -1299,6 +1391,9 @@ function restartManagedSpec(name) {
1299
1391
  owner_agent: existing.owner_agent || '',
1300
1392
  owner_wi: existing.owner_wi || '',
1301
1393
  owner_project: existing.owner_project || '',
1394
+ owner_work_type: existing.owner_work_type || '',
1395
+ dispatch_root: existing.dispatch_root || '',
1396
+ checkout_mode: existing.checkout_mode || '',
1302
1397
  };
1303
1398
  const runtime = spawnManagedSpec(spec, ctx);
1304
1399
  // recordManagedSpec replaces by name (item 2 idempotency contract) and
@@ -1380,6 +1475,12 @@ function _runManagedReconcile(opts) {
1380
1475
  const killBatch = typeof opts.killBatch === 'function'
1381
1476
  ? opts.killBatch
1382
1477
  : shared.killByPidsImmediate;
1478
+ let projects;
1479
+ try {
1480
+ projects = Array.isArray(opts.projects) ? opts.projects : shared.getProjects(opts.config);
1481
+ } catch {
1482
+ projects = [];
1483
+ }
1383
1484
  const stats = {
1384
1485
  scanned: 0,
1385
1486
  ttlExpired: 0,
@@ -1413,6 +1514,11 @@ function _runManagedReconcile(opts) {
1413
1514
  stats.deadDropped++;
1414
1515
  continue; // dead + not expired → drop
1415
1516
  }
1517
+ const executionContext = validateManagedSpecExecutionContext(rec, { projects });
1518
+ if (!executionContext.ok) {
1519
+ ttlPidsToKill.push(rec.pid);
1520
+ continue;
1521
+ }
1416
1522
  kept.push(rec);
1417
1523
  survivors.push({ name: rec.name, log_path: rec.log_path || '', healthy: rec.healthy === true, last_health_at: rec.last_health_at || 0 });
1418
1524
  }
@@ -1550,6 +1656,7 @@ module.exports = {
1550
1656
  // Item 4 (P-4b8d2e57): discovery API (etag + state-driven respawn).
1551
1657
  computeManagedSpecsEtag: computeManagedSpecsEtag,
1552
1658
  restartManagedSpec: restartManagedSpec,
1659
+ validateManagedSpecExecutionContext: validateManagedSpecExecutionContext,
1553
1660
  // Item 7 (P-8a4d6f29): TTL sweep + boot reconcile + project cleanup.
1554
1661
  sweepManagedSpawn: sweepManagedSpawn,
1555
1662
  bootReconcileManagedSpawn: bootReconcileManagedSpawn,
@@ -269,10 +269,12 @@ function searchMemoryRecords(query, options = {}) {
269
269
  }
270
270
  const limit = Math.max(1, Math.min(200, Number(options.limit) || 50));
271
271
  args.push(limit);
272
+ // CROSS JOIN pins FTS as the outer loop; Node 22's SQLite otherwise prefers
273
+ // the scope index and evaluates MATCH once for every scoped record.
272
274
  const sql = `
273
275
  SELECT m.*, bm25(memory_records_fts, 5.0, 1.0, 2.0, 3.0) AS fts_rank
274
276
  FROM memory_records_fts
275
- JOIN memory_records m ON m.rowid=memory_records_fts.rowid
277
+ CROSS JOIN memory_records m ON m.rowid=memory_records_fts.rowid
276
278
  WHERE ${where.join(' AND ')}
277
279
  ORDER BY fts_rank
278
280
  LIMIT ?
@@ -1214,12 +1214,10 @@ function formatPrContextSuffix(pr) {
1214
1214
  // ─── Work Discovery Helpers ──────────────────────────────────────────────────
1215
1215
 
1216
1216
  function buildBaseVars(agentId, config, project) {
1217
- // W-mq15r0cx surface the LLM model id alongside the agent name so PR
1218
- // comment sign-offs can stamp `<agent> · <model>` and humans can tell
1219
- // which agent + which model authored the comment. Falls through to
1220
- // 'unknown' (defensive only) rather than crashing or omitting the
1221
- // field when neither agent.model nor engine.defaultModel resolves —
1222
- // omitting would leave a dangling `· ` in the rendered sign-off.
1217
+ // This is the provisional requested model used in prompt examples. The
1218
+ // `minions pr comment` path replaces it with runtime/session evidence from
1219
+ // the active dispatch before posting; `unknown` survives only when neither
1220
+ // runtime nor requested-model evidence exists.
1223
1221
  const resolvedModel = shared.resolveAgentModel(
1224
1222
  config?.agents?.[agentId] || null,
1225
1223
  config?.engine || null