@yemi33/minions 0.1.2285 → 0.1.2287

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.
@@ -433,6 +433,17 @@ function _mapEffort(level) {
433
433
  // `fallbackModel` opts are silently ignored (their capability flags are false
434
434
  // so engine code shouldn't pass them, but we tolerate them gracefully).
435
435
 
436
+ // Map MIME type to file extension for --attachment temp file naming.
437
+ const _MIME_EXT = {
438
+ 'image/png': 'png',
439
+ 'image/jpeg': 'jpg',
440
+ 'image/gif': 'gif',
441
+ 'image/webp': 'webp',
442
+ };
443
+ function _mimeToExt(mimeType) {
444
+ return _MIME_EXT[String(mimeType || '').toLowerCase()] || 'bin';
445
+ }
446
+
436
447
  function buildArgs(opts = {}) {
437
448
  const {
438
449
  model,
@@ -445,6 +456,8 @@ function buildArgs(opts = {}) {
445
456
  suppressAgentsMd,
446
457
  reasoningSummaries,
447
458
  disabledMcpServers,
459
+ images,
460
+ tmpDir,
448
461
  } = opts;
449
462
 
450
463
  const args = [
@@ -498,6 +511,22 @@ function buildArgs(opts = {}) {
498
511
  // the `=`, commander.js treats the next token as a positional, not the value.
499
512
  if (sessionId) args.push(`--resume=${sessionId}`);
500
513
 
514
+ // W-mqv7324u0021db5d — image attachments. Write each base64 payload to a
515
+ // temp file in tmpDir and emit --attachment <path> (flag is repeatable).
516
+ // tmpDir is required; skip silently when absent so callers without image
517
+ // support don't crash (defense-in-depth on top of the caps.imageInput gate).
518
+ if (Array.isArray(images) && images.length && tmpDir) {
519
+ for (let i = 0; i < images.length; i++) {
520
+ const img = images[i];
521
+ if (!img || !img.dataBase64) continue;
522
+ const ext = _mimeToExt(img.mimeType);
523
+ const filename = `img-${i}-${Date.now().toString(36)}.${ext}`;
524
+ const filePath = path.join(tmpDir, filename);
525
+ fs.writeFileSync(filePath, Buffer.from(img.dataBase64, 'base64'));
526
+ args.push('--attachment', filePath);
527
+ }
528
+ }
529
+
501
530
  return args;
502
531
  }
503
532
 
@@ -1214,11 +1243,9 @@ const capabilities = {
1214
1243
  resumePromptCarryover: true,
1215
1244
  // Adapter implements createStreamConsumer(ctx) — required by llm.js accumulator
1216
1245
  streamConsumer: true,
1217
- // P-7b2e4d01: `--attachment <path>` is documented for non-interactive mode
1218
- // but the headless smoke test was inconclusive (spike P-3f8a1c92). Stub false
1219
- // until a clean re-test passes; enabling it also needs temp-file materializing
1220
- // of the base64 payload (a different adapter-local path than Claude's inline).
1221
- imageInput: false,
1246
+ // `--attachment <path>` works in non-interactive mode (live-tested; see W-mqv7324u0021db5d).
1247
+ // Base64 payloads are materialized to tmp files inside llmTmpDir before spawn.
1248
+ imageInput: true,
1222
1249
  };
1223
1250
 
1224
1251
  // Install hint surfaced when `resolveBinary()` returns null. Covers all
package/engine/shared.js CHANGED
@@ -1866,6 +1866,17 @@ function mutateJsonFileLocked(filePath, mutateFn, {
1866
1866
  try { if (fileExists) fs.copyFileSync(filePath, backupPath); } catch { /* backup is best-effort */ }
1867
1867
  }
1868
1868
  safeWrite(filePath, finalData);
1869
+ // Phase 10 step 2 — dual-write PRD content to SQL. The single chokepoint:
1870
+ // every prd/*.json mutation through mutateJsonFileLocked mirrors into the
1871
+ // prds/prd_items/prd_verify_prs tables. JSON stays canonical (nothing
1872
+ // reads SQL yet); this keeps the mirror trustworthy for the read-flip.
1873
+ // BEST-EFFORT: parsePrdPath fast-rejects non-PRD paths, and a SQLite
1874
+ // failure must never break the JSON write. mirrorPrdToSql swallows
1875
+ // internally; the require is guarded for the (engine-agnostic) load order.
1876
+ try {
1877
+ const prdStore = require('./prd-store');
1878
+ if (prdStore.parsePrdPath(filePath)) prdStore.mirrorPrdToSql(filePath, finalData);
1879
+ } catch { /* mirror is best-effort — never break the canonical JSON write */ }
1869
1880
  // Side-effect hook fired only when an actual write happened. Callers
1870
1881
  // use this to emit cache-invalidation signals (events table row) so
1871
1882
  // skip-write-unchanged paths (dedup, idempotent no-op POSTs) don't
@@ -2635,12 +2646,21 @@ const CHECKOUT_MODES = Object.freeze({ WORKTREE: 'worktree', LIVE: 'live' });
2635
2646
  function resolveCheckoutMode(project, workItemType) {
2636
2647
  if (!project || typeof project !== 'object') return CHECKOUT_MODES.WORKTREE;
2637
2648
  const canonical = project.checkoutMode;
2638
- // liveValidation without checkoutMode: 'live' is a misconfiguration warn and ignore.
2639
- if (project.liveValidation && canonical !== CHECKOUT_MODES.LIVE) {
2649
+ // Resolve the effective mode: canonical field first, then legacy worktreeMode fallback.
2650
+ let effectiveMode;
2651
+ if (canonical === CHECKOUT_MODES.LIVE || canonical === CHECKOUT_MODES.WORKTREE) {
2652
+ effectiveMode = canonical;
2653
+ } else if (project.worktreeMode === 'live') {
2654
+ effectiveMode = CHECKOUT_MODES.LIVE;
2655
+ } else {
2656
+ effectiveMode = CHECKOUT_MODES.WORKTREE;
2657
+ }
2658
+ // liveValidation without an effective live mode is a misconfiguration — warn and ignore.
2659
+ if (project.liveValidation && effectiveMode !== CHECKOUT_MODES.LIVE) {
2640
2660
  log('warn', 'resolveCheckoutMode: liveValidation is set but checkoutMode is not "live" — liveValidation ignored',
2641
2661
  { projectName: project.name });
2642
2662
  }
2643
- if (canonical === CHECKOUT_MODES.LIVE) {
2663
+ if (effectiveMode === CHECKOUT_MODES.LIVE) {
2644
2664
  // Apply liveValidation routing when the block is present and workItemType is provided.
2645
2665
  if (project.liveValidation && workItemType !== undefined) {
2646
2666
  return workItemType === project.liveValidation.type
@@ -2649,11 +2669,6 @@ function resolveCheckoutMode(project, workItemType) {
2649
2669
  }
2650
2670
  return CHECKOUT_MODES.LIVE;
2651
2671
  }
2652
- if (canonical === CHECKOUT_MODES.WORKTREE) return CHECKOUT_MODES.WORKTREE;
2653
- // Legacy field fallback (only consulted when checkoutMode is absent/unknown).
2654
- const legacy = project.worktreeMode;
2655
- if (legacy === 'live') return CHECKOUT_MODES.LIVE;
2656
- // legacy 'isolated' (and anything else) → the default worktree behavior.
2657
2672
  return CHECKOUT_MODES.WORKTREE;
2658
2673
  }
2659
2674
 
@@ -4387,6 +4402,7 @@ const FAILURE_CLASS = {
4387
4402
  SPAWN_PHASE_STALL: 'spawn-phase-stall', // W-mq0e2dae000a003d: process spawned and ran startup-only events (MCP init / hooks) but never emitted real task progress; CPU usage stayed below threshold past the grace window. Engine kills the wedged child and treats this as retryable (fresh-session) so a re-spawn can clear a transient MCP wedge.
4388
4403
  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).
4389
4404
  VERIFY_MISSING_PR: 'verify-missing-pr', // W-mqsk1ip00006cbae: a verify WI exited done but no PR was attached (neither _prUrl/_pr on the item nor a matching pull-requests.json entry for the plan). Flipped to failed so the plan doesn't silently advance without an E2E PR. Retryable — agent may have phantom-crashed before pushing the branch.
4405
+ 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.
4390
4406
  UNKNOWN: 'unknown', // Unclassified failure
4391
4407
  };
4392
4408
 
@@ -562,7 +562,10 @@ registerActionType(WATCH_ACTION_TYPE.WEBHOOK, {
562
562
  // minions-api — first-class loopback API caller for the in-process dashboard.
563
563
  // Restricts `endpoint` to paths starting with `/api/` so a watch can't reach
564
564
  // external hosts (use `webhook` for that). Always targets
565
- // `http://127.0.0.1:${MINIONS_PORT||7331}${endpoint}` and sets
565
+ // `http://127.0.0.1:${shared.readDashboardPortFile(MINIONS_DIR)?.port||7331}${endpoint}`
566
+ // — the *actual* bound dashboard port from the dashboard-port.json beacon, not
567
+ // the frozen MINIONS_PORT env (which stays at the requested value when the
568
+ // dashboard falls back to 7332+ on EADDRINUSE) — and sets
566
569
  // `X-Minions-Internal: 1` so dashboard handlers can recognize that the
567
570
  // request originated from the engine's own action surface (Origin/Referer
568
571
  // are intentionally absent — the dashboard's origin gate already allows
package/engine.js CHANGED
@@ -849,6 +849,7 @@ async function findExistingWorktree(repoDir, branchName) {
849
849
  }
850
850
 
851
851
  function isWorktreeRetryableError(err) {
852
+ if (err?._incompleteCheckout) return true;
852
853
  const msg = String(err?.message || '');
853
854
  return msg.includes('ETIMEDOUT')
854
855
  || msg.includes('index.lock')
@@ -957,6 +958,29 @@ async function runWorktreeAdd(rootDir, worktreePath, addArgs, gitOpts, worktreeC
957
958
  log('warn', `Retrying git worktree add (attempt ${attempt + 1}/${retries + 1}) for ${path.basename(worktreePath)}`);
958
959
  }
959
960
  await shared.shellSafeGit(['worktree', 'add', worktreePath, ...addArgs], { ...gitOpts, cwd: rootDir });
961
+ // W-mqvaxv65000m76f2 (issue #430, Layer 1) — GVFS partial-checkout guard.
962
+ // On large GVFS repos (e.g. 1JS with 296K files), `git worktree add`
963
+ // exits cleanly but only writes a .git file — no source files are checked
964
+ // out. The engine would otherwise treat the empty worktree as valid for
965
+ // reuse; the subsequent `git status` preflight fails (nothing to stat) and
966
+ // the dispatch loop recurs. Detect the empty checkout and throw a retriable
967
+ // error so the retry loop re-creates the worktree on the next attempt.
968
+ let worktreeEntries;
969
+ try { worktreeEntries = fs.readdirSync(worktreePath).filter(e => e !== '.git'); }
970
+ catch { worktreeEntries = null; } // If the read fails, skip the check — don't block normal cases.
971
+ if (worktreeEntries !== null && worktreeEntries.length === 0) {
972
+ log('warn', `runWorktreeAdd: GVFS partial checkout detected at ${worktreePath} (only .git written) — removing and retrying`);
973
+ try {
974
+ await shared.shellSafeGit(['worktree', 'remove', '--force', worktreePath], { ...gitOpts, cwd: rootDir, timeout: 30000 });
975
+ } catch (rmErr) {
976
+ log('warn', `runWorktreeAdd: worktree remove after partial checkout failed (${rmErr.message}) — falling back to fs-level cleanup`);
977
+ try { fs.rmSync(worktreePath, { recursive: true, force: true }); } catch {}
978
+ try { await shared.shellSafeGit(['worktree', 'prune'], { ...gitOpts, cwd: rootDir, timeout: 15000 }); } catch {}
979
+ }
980
+ const incompleteErr = new Error(`GVFS incomplete checkout at ${worktreePath}: worktree add exited cleanly but no source files were checked out (only .git was written). Retriable.`);
981
+ incompleteErr._incompleteCheckout = true;
982
+ throw incompleteErr;
983
+ }
960
984
  // W-mqecdoot — stamp the engine ownership marker so the out-of-root
961
985
  // worktree GC can distinguish a worktree we created from one a human
962
986
  // developer made by hand. Best-effort; never blocks the spawn.
@@ -5992,8 +6016,10 @@ function materializePlansAsWorkItems(config) {
5992
6016
  let planFiles;
5993
6017
  try { planFiles = fs.readdirSync(PRD_DIR).filter(f => f.endsWith('.json')); } catch { return; }
5994
6018
 
5995
- // Regex for detecting sequential PRD item IDs (P-001, P-002) — hoisted outside loop
5996
- const SEQUENTIAL_ID_RE = /^P-?\d+$/;
6019
+ // Regex for detecting sequential PRD item IDs (P-001, M001, M-001, WI-001, …) — hoisted outside loop.
6020
+ // Covers all patterns where an alphabetic prefix is followed by an optional dash and pure digits,
6021
+ // e.g. P001, P-001, M001, M-001, WI-001. Does NOT match hex UUIDs like P-a3f9b2c1.
6022
+ const SEQUENTIAL_ID_RE = /^[A-Za-z]+-?\d+$/;
5997
6023
 
5998
6024
  for (let file of planFiles) {
5999
6025
  // safeJsonNoRestore — if a PRD was archived between readdir and this
@@ -6211,7 +6237,9 @@ function materializePlansAsWorkItems(config) {
6211
6237
  mutateWorkItems(wiPath, existingItems => {
6212
6238
  for (const item of projItems) {
6213
6239
  // Re-open: 'updated' or 'missing' re-opens a done work item (#906)
6214
- const existingWi = existingItems.find(w => w.id === item.id);
6240
+ // Use composite (sourcePlan, id) key so a WI from another PRD with the
6241
+ // same id is not treated as already-materialized for this PRD.
6242
+ const existingWi = existingItems.find(w => w.id === item.id && w.sourcePlan === file);
6215
6243
  const shouldReopen = item.status === PRD_ITEM_STATUS.UPDATED || item.status === PRD_ITEM_STATUS.MISSING;
6216
6244
  if (existingWi && DONE_STATUSES.has(existingWi.status) && shouldReopen) {
6217
6245
  shared.reopenWorkItem(existingWi);
@@ -6222,13 +6250,14 @@ function materializePlansAsWorkItems(config) {
6222
6250
  continue;
6223
6251
  }
6224
6252
 
6225
- // Skip if already materialized — work item ID = PRD item ID, check all projects
6253
+ // Skip if already materialized — use composite (sourcePlan, id) key to prevent
6254
+ // cross-PRD collision when two plans share the same item id (W-mqv6ql5w001w792f).
6226
6255
  let alreadyExists = !!existingWi;
6227
6256
  if (!alreadyExists) {
6228
6257
  for (const p of allProjects) {
6229
6258
  if (String(p.name || '').toLowerCase() === String(projName || '').toLowerCase()) continue;
6230
6259
  const otherItems = safeJsonArr(projectWorkItemsPath(p));
6231
- const otherWi = otherItems.find(w => w.id === item.id);
6260
+ const otherWi = otherItems.find(w => w.id === item.id && w.sourcePlan === file);
6232
6261
  if (otherWi) {
6233
6262
  if (DONE_STATUSES.has(otherWi.status) && shouldReopen) {
6234
6263
  deferredReopens.push({ itemId: item.id, projectName: p.name, item });
@@ -8510,7 +8539,7 @@ function discoverCentralWorkItems(config) {
8510
8539
  const itemProjectResolution = shared.resolveConfiguredProject(item.project, projects);
8511
8540
  if (itemProjectResolution.error) {
8512
8541
  const error = itemProjectResolution.error;
8513
- mutations.set(item.id, { status: WI_STATUS.FAILED, failReason: error, failedAt: ts() });
8542
+ mutations.set(item.id, { status: WI_STATUS.FAILED, failReason: error, failedAt: ts(), _failureClass: FAILURE_CLASS.PROJECT_NOT_FOUND });
8514
8543
  log('warn', `central work item ${item.id}: ${error}`);
8515
8544
  continue;
8516
8545
  }
@@ -8648,6 +8677,7 @@ function discoverCentralWorkItems(config) {
8648
8677
  status: WI_STATUS.FAILED,
8649
8678
  failReason: error,
8650
8679
  failedAt: ts(),
8680
+ _failureClass: FAILURE_CLASS.PROJECT_NOT_FOUND,
8651
8681
  ...(declaredPlanProject ? { _declaredPlanProject: declaredPlanProject, _declaredPlanProjectMissing: true } : {}),
8652
8682
  });
8653
8683
  log('warn', `central work item ${item.id}: ${error}`);
@@ -8661,6 +8691,7 @@ function discoverCentralWorkItems(config) {
8661
8691
  status: WI_STATUS.FAILED,
8662
8692
  failReason: crossRepoFallbackError,
8663
8693
  failedAt: ts(),
8694
+ _failureClass: FAILURE_CLASS.PROJECT_NOT_FOUND,
8664
8695
  _crossRepoTargetProjects: planTargetProjects.slice(),
8665
8696
  });
8666
8697
  log('warn', `central work item ${item.id} (cross-repo plan-to-prd): ${crossRepoFallbackError}`);
@@ -10288,12 +10319,15 @@ async function tickInner() {
10288
10319
  } else {
10289
10320
  // P-a3f9b205: surface the per-project live-mode gate. Order matters:
10290
10321
  // max_concurrency / agent_busy / branch_locked are all more specific
10291
- // and win when both apply.
10322
+ // and win when both apply. Also check the pending item's own resolved
10323
+ // checkout mode so worktree-mode items on hybrid projects are not
10324
+ // falsely annotated live_checkout_busy (mirrors seeding guard above).
10292
10325
  const itemProjName = item.project || item.meta?.project?.name || null;
10293
10326
  if (
10294
10327
  itemProjName
10295
10328
  && !READ_ONLY_ROOT_TASK_TYPES.has(item.type)
10296
10329
  && postLiveProjectsInUse.has(itemProjName)
10330
+ && shared.resolveCheckoutMode(shared.findProjectByName(shared.getProjects(config), itemProjName), item.type) === 'live'
10297
10331
  ) {
10298
10332
  reason = 'live_checkout_busy';
10299
10333
  }
@@ -10490,6 +10524,7 @@ module.exports = {
10490
10524
  resolveDependencyBranches, buildCrossRepoDepsSection, // exported for testing (P-faea3206)
10491
10525
  gitOutputToString, gitErrorOutput, classifyDepMergeFailureOutput, listUnmergedFiles, // exported for testing
10492
10526
  buildDepConflictFixItem, deriveConflictFixKey, // exported for testing (W-mpcwojgr000a0244)
10527
+ runWorktreeAdd, // exported for testing (W-mqvaxv65000m76f2 — GVFS partial-checkout behavioral test)
10493
10528
  isWorktreeRetryableError, removeStaleIndexLock, syncReusedWorktree, assertCleanSharedWorktree, _quarantineDirtyWorktree, // exported for testing
10494
10529
  _statusPorcelainCmd, _killGitDescendantsForWorktree, _bumpQuarantineOutcome, // exported for testing (W-mq5n1zx5)
10495
10530
  _reapWorktreeHolders, _findTerminalWorktreeOwners, // exported for testing (W-mqila0t5 — CWD-pinned holder reap)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2285",
3
+ "version": "0.1.2287",
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"