@yemi33/minions 0.1.2289 → 0.1.2291

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.
package/dashboard.js CHANGED
@@ -6804,13 +6804,13 @@ const server = http.createServer(async (req, res) => {
6804
6804
  if (originWi) item._originWi = originWi;
6805
6805
  await copyWorkItemPrFields(item, body, null, { project: targetProject });
6806
6806
  // W-mq5wfh1v000e0da9 — Auto-enroll the PR into pull-requests.json when
6807
- // this is a `type: fix` WI carrying a structured PR pointer and the PR
6808
- // isn't tracked yet. Without this, the engine's `pr_not_found` gate
6809
- // skips the fix forever (engine.js dispatch loop). Idempotent — safe to
6807
+ // this is a `type: fix/review/test` WI carrying a structured PR pointer and
6808
+ // the PR isn't tracked yet. Without this, the engine's `pr_not_found` gate
6809
+ // skips the WI forever (engine.js dispatch loop). Idempotent — safe to
6810
6810
  // call on every WI create. Failures are swallowed (best-effort belt;
6811
6811
  // engine.js#dispatch loop also calls this as defense-in-depth).
6812
6812
  try {
6813
- shared.autoEnrollPrFromFixWorkItem(item, targetProject, MINIONS_DIR);
6813
+ shared.autoEnrollPrFromWorkItem(item, targetProject, MINIONS_DIR);
6814
6814
  } catch (e) {
6815
6815
  shared.log('warn', `auto-enroll PR for ${item.id}: ${e.message}`);
6816
6816
  }
@@ -101,7 +101,6 @@ const shared = require('./shared');
101
101
  // still bounded. Threaded through `baseOpts` so every git call inherits it; the
102
102
  // injected `_git` mock in unit tests ignores opts so the seam is unaffected.
103
103
  const LIVE_CHECKOUT_GIT_MAX_BUFFER = 50 * 1024 * 1024;
104
-
105
104
  // PL-live-checkout-reliability-hardening — partial-clone / GVFS blob-fetch
106
105
  // signature match. On a Scalar/GVFS-managed ADO repo (blobless partial clone),
107
106
  // switching the working tree onto a branch whose tree differs from HEAD must
@@ -172,6 +171,7 @@ async function prepareLiveCheckout(opts = {}) {
172
171
  gitOpts,
173
172
  dispatchId, // accepted for caller bookkeeping; not used by the helper
174
173
  wiId, // accepted for caller bookkeeping; used in the auto-reset note slug
174
+ skipDirtyCheck, // #522: opt-out for GVFS repos where git status output exceeds maxBuffer
175
175
  log,
176
176
  autoReset, // W-mqvejug6000eeb20: tri-state. `true`/`false` short-circuits the
177
177
  // config-based resolution; `undefined` (the engine.js call shape)
@@ -212,6 +212,14 @@ async function prepareLiveCheckout(opts = {}) {
212
212
  const baseOpts = { cwd: localPath, maxBuffer: LIVE_CHECKOUT_GIT_MAX_BUFFER, ...(gitOpts || {}) };
213
213
 
214
214
  // ── Step 1: git status --porcelain=v1 -b. Bail early on dirty tree. ─────
215
+ // Skipped when `skipDirtyCheck:true` (issue #522): GVFS/VFS-for-Git repos
216
+ // report all un-hydrated virtual files as modified — this is normal GVFS
217
+ // behavior, not actual WIP. On repos with ~120k virtual dirty files the
218
+ // buffered `git status` output exceeds Node's default maxBuffer (~1MB) and
219
+ // throws before any agent spawns. Setting `skipDirtyCheck:true` via the
220
+ // project-level `skipLiveCheckoutDirtyCheck` config key bypasses the probe
221
+ // entirely so agents can dispatch normally on GVFS repos.
222
+ //
215
223
  // Porcelain v1 -b adds a `## <branch>` header line as the first output line
216
224
  // so callers get branch diagnostics for free alongside the file-status lines.
217
225
  // The file-status lines still use the two-char XY status code (e.g.
@@ -220,92 +228,94 @@ async function prepareLiveCheckout(opts = {}) {
220
228
  // trailing whitespace/CR per line — outer-trimming the whole blob would eat
221
229
  // the leading XY space on the first line. The `## ` header is separated out
222
230
  // before the dirtyFiles check so callers receive it as `branchInfo`.
223
- const statusRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
224
- const statusStr = typeof statusRaw === 'string' ? statusRaw : '';
225
- const statusLines = statusStr
226
- .split(/\r?\n/)
227
- .map((line) => line.replace(/\s+$/, ''))
228
- .filter((line) => line.length > 0);
229
- const branchInfo = statusLines.find((line) => line.startsWith('## ')) || '';
230
- const dirtyFiles = statusLines.filter((line) => !line.startsWith('## '));
231
- if (dirtyFiles.length > 0) {
232
- // W-mqvejug6000eeb20 — opt-in auto-reset. Default behavior is to bail with
233
- // reason:'dirty' (spawnAgent translates to non-retryable LIVE_CHECKOUT_DIRTY
234
- // and alerts the operator). When auto-reset is enabled — either the caller
235
- // passed an explicit `autoReset` boolean, or the config-based resolver says
236
- // so we DISCARD the dirty state via `git fetch origin` + `git reset --hard
237
- // origin/<branch>` and continue. This is destructive (the operator's
238
- // uncommitted work is gone), which is why it is strictly opt-in.
239
- let wantAutoReset = false;
240
- if (typeof autoReset === 'boolean') {
241
- wantAutoReset = autoReset;
242
- } else {
243
- const resolver = (typeof _resolveAutoReset === 'function') ? _resolveAutoReset : _defaultResolveAutoReset;
244
- try { wantAutoReset = !!resolver(localPath); } catch { wantAutoReset = false; }
245
- }
231
+ if (!skipDirtyCheck) {
232
+ const statusRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
233
+ const statusStr = typeof statusRaw === 'string' ? statusRaw : '';
234
+ const statusLines = statusStr
235
+ .split(/\r?\n/)
236
+ .map((line) => line.replace(/\s+$/, ''))
237
+ .filter((line) => line.length > 0);
238
+ const branchInfo = statusLines.find((line) => line.startsWith('## ')) || '';
239
+ const dirtyFiles = statusLines.filter((line) => !line.startsWith('## '));
240
+ if (dirtyFiles.length > 0) {
241
+ // W-mqvejug6000eeb20 opt-in auto-reset. Default behavior is to bail with
242
+ // reason:'dirty' (spawnAgent translates to non-retryable LIVE_CHECKOUT_DIRTY
243
+ // and alerts the operator). When auto-reset is enabled either the caller
244
+ // passed an explicit `autoReset` boolean, or the config-based resolver says
245
+ // so we DISCARD the dirty state via `git fetch origin` + `git reset --hard
246
+ // origin/<branch>` and continue. This is destructive (the operator's
247
+ // uncommitted work is gone), which is why it is strictly opt-in.
248
+ let wantAutoReset = false;
249
+ if (typeof autoReset === 'boolean') {
250
+ wantAutoReset = autoReset;
251
+ } else {
252
+ const resolver = (typeof _resolveAutoReset === 'function') ? _resolveAutoReset : _defaultResolveAutoReset;
253
+ try { wantAutoReset = !!resolver(localPath); } catch { wantAutoReset = false; }
254
+ }
246
255
 
247
- if (!wantAutoReset) {
248
- return { ok: false, reason: 'dirty', dirtyFiles, branchInfo };
249
- }
256
+ if (!wantAutoReset) {
257
+ return { ok: false, reason: 'dirty', dirtyFiles, branchInfo };
258
+ }
250
259
 
251
- if (typeof log === 'function') {
252
- log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s) on '${branchName}' via fetch + reset --hard origin/${branchName}`);
253
- }
254
- let resetOk = true;
255
- try {
256
- await git(['fetch', 'origin'], baseOpts);
257
- await git(['reset', '--hard', `origin/${branchName}`], baseOpts);
258
- } catch (e) {
259
- resetOk = false;
260
260
  if (typeof log === 'function') {
261
- log(`live-checkout auto-reset FAILED (fetch/reset): ${e && e.message ? e.message : e}`);
261
+ log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s) on '${branchName}' via fetch + reset --hard origin/${branchName}`);
262
262
  }
263
- }
264
-
265
- // Re-run the porcelain preflight once. If the tree is now clean we proceed;
266
- // otherwise (reset failed, or something is still dirty) we fall back to the
267
- // safe dirty refusal so we never dispatch onto an unexpected tree.
268
- let stillDirty = dirtyFiles;
269
- if (resetOk) {
263
+ let resetOk = true;
270
264
  try {
271
- const recheckRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
272
- const recheckStr = typeof recheckRaw === 'string' ? recheckRaw : '';
273
- stillDirty = recheckStr
274
- .split(/\r?\n/)
275
- .map((line) => line.replace(/\s+$/, ''))
276
- .filter((line) => line.length > 0 && !line.startsWith('## '));
265
+ await git(['fetch', 'origin'], baseOpts);
266
+ await git(['reset', '--hard', `origin/${branchName}`], baseOpts);
277
267
  } catch (e) {
278
268
  resetOk = false;
279
269
  if (typeof log === 'function') {
280
- log(`live-checkout auto-reset re-check FAILED: ${e && e.message ? e.message : e}`);
270
+ log(`live-checkout auto-reset FAILED (fetch/reset): ${e && e.message ? e.message : e}`);
281
271
  }
282
272
  }
283
- }
284
273
 
285
- if (!resetOk || stillDirty.length > 0) {
286
- return { ok: false, reason: 'dirty', dirtyFiles: stillDirty, branchInfo };
287
- }
274
+ // Re-run the porcelain preflight once. If the tree is now clean we proceed;
275
+ // otherwise (reset failed, or something is still dirty) we fall back to the
276
+ // safe dirty refusal so we never dispatch onto an unexpected tree.
277
+ let stillDirty = dirtyFiles;
278
+ if (resetOk) {
279
+ try {
280
+ const recheckRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
281
+ const recheckStr = typeof recheckRaw === 'string' ? recheckRaw : '';
282
+ stillDirty = recheckStr
283
+ .split(/\r?\n/)
284
+ .map((line) => line.replace(/\s+$/, ''))
285
+ .filter((line) => line.length > 0 && !line.startsWith('## '));
286
+ } catch (e) {
287
+ resetOk = false;
288
+ if (typeof log === 'function') {
289
+ log(`live-checkout auto-reset re-check FAILED: ${e && e.message ? e.message : e}`);
290
+ }
291
+ }
292
+ }
288
293
 
289
- // Audit trail: record the discarded paths so the operator can recover from
290
- // reflog / understand why their tree changed. Best-effort, never throws.
291
- try {
292
- const writeNote = (typeof _writeInboxNote === 'function') ? _writeInboxNote : _defaultWriteInboxNote;
293
- const slug = `live-checkout-autoreset-${wiId || dispatchId || 'unknown'}`;
294
- const body = [
295
- `# Live-checkout auto-reset on '${branchName}'`,
296
- '',
297
- `⚠️ The live-checkout tree at \`${localPath}\` was dirty at dispatch time and`,
298
- '`liveCheckoutAutoReset` is enabled, so it was force-reset to',
299
- `\`origin/${branchName}\`. **The following uncommitted changes were DISCARDED**`,
300
- '(recover from `git reflog` / `git fsck --lost-found` if needed):',
301
- '',
302
- '```',
303
- ...dirtyFiles,
304
- '```',
305
- ].join('\n');
306
- writeNote(slug, body);
307
- } catch { /* best-effort audit note */ }
308
- // Fall through — tree is now clean, continue with normal preparation.
294
+ if (!resetOk || stillDirty.length > 0) {
295
+ return { ok: false, reason: 'dirty', dirtyFiles: stillDirty, branchInfo };
296
+ }
297
+
298
+ // Audit trail: record the discarded paths so the operator can recover from
299
+ // reflog / understand why their tree changed. Best-effort, never throws.
300
+ try {
301
+ const writeNote = (typeof _writeInboxNote === 'function') ? _writeInboxNote : _defaultWriteInboxNote;
302
+ const slug = `live-checkout-autoreset-${wiId || dispatchId || 'unknown'}`;
303
+ const body = [
304
+ `# Live-checkout auto-reset on '${branchName}'`,
305
+ '',
306
+ `⚠️ The live-checkout tree at \`${localPath}\` was dirty at dispatch time and`,
307
+ '`liveCheckoutAutoReset` is enabled, so it was force-reset to',
308
+ `\`origin/${branchName}\`. **The following uncommitted changes were DISCARDED**`,
309
+ '(recover from `git reflog` / `git fsck --lost-found` if needed):',
310
+ '',
311
+ '```',
312
+ ...dirtyFiles,
313
+ '```',
314
+ ].join('\n');
315
+ writeNote(slug, body);
316
+ } catch { /* best-effort audit note */ }
317
+ // Fall through — tree is now clean, continue with normal preparation.
318
+ }
309
319
  }
310
320
 
311
321
  // ── Step 2: mid-operation / detached-HEAD preflight (P-b2e8d4a6). ──────
@@ -528,6 +538,7 @@ async function prepareLiveCheckout(opts = {}) {
528
538
  * @param {boolean} [opts.isTerminalFailure] true on error/timeout/crash
529
539
  * @param {string} [opts.resultLabel] short status word for the alert body
530
540
  * @param {object} [opts.gitOpts] extra execFile opts merged into cwd
541
+ * @param {boolean} [opts.skipDirtyCheck] #522: skip the self-healing dirty probe (GVFS repos)
531
542
  * @param {function} [opts.log] (level, msg) => void
532
543
  * @param {function} opts.writeInboxAlert (slug, body) => any (false = deduped)
533
544
  * @param {function} [opts._git] test seam; defaults to shellSafeGit
@@ -544,6 +555,7 @@ async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
544
555
  isTerminalFailure,
545
556
  resultLabel,
546
557
  gitOpts,
558
+ skipDirtyCheck, // #522: opt-out for GVFS repos — same flag as prepareLiveCheckout
547
559
  log,
548
560
  writeInboxAlert,
549
561
  _git, // private injection for testing — defaults to shared.shellSafeGit
@@ -633,7 +645,16 @@ async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
633
645
  // never reaches here (git status is empty → not dirty). Best-effort: a
634
646
  // failed commit falls through to the plain checkout, which refuses and
635
647
  // writes the existing manual-recovery alert — no worse than before.
636
- if (branchName && branchName !== originalRef) {
648
+ //
649
+ // Skipped when `skipDirtyCheck:true` (#522 / GVFS repos): on GVFS/VFS-for-Git
650
+ // trees, `git status` reports ~120k un-hydrated virtual files as modified even
651
+ // when the operator's tree is effectively clean. Since prepareLiveCheckout
652
+ // also skips the dirty probe on GVFS repos, the precondition "tree was verified
653
+ // clean before the agent ran" doesn't hold — we cannot distinguish real
654
+ // agent-authored dirt from GVFS virtual noise. Skipping the self-heal here
655
+ // is safe: the plain `git checkout <originalRef>` below will succeed because
656
+ // GVFS's virtual dirty state does NOT block git checkout.
657
+ if (!skipDirtyCheck && branchName && branchName !== originalRef) {
637
658
  let dirty = false;
638
659
  try {
639
660
  const statusRaw = await git(['status', '--porcelain'], baseOpts);
package/engine/shared.js CHANGED
@@ -7245,11 +7245,12 @@ function classifyPrRefForVerification(prRef, project = null) {
7245
7245
  // per the "structured-vs-loose split" — enrollment must be intentional.
7246
7246
  //
7247
7247
  // Returns:
7248
- // { skipped: true, reason } — not a fix / no ref / no URL / upsert error
7248
+ // { skipped: true, reason } — not a pr-requiring type / no ref / no URL / upsert error
7249
7249
  // { alreadyEnrolled: true, id } — PR already in pull-requests.json
7250
7250
  // { enrolled: true, id, prPath } — newly enrolled
7251
- function autoEnrollPrFromFixWorkItem(item, project, minionsDir) {
7252
- if (!item || item.type !== WORK_TYPE.FIX) return { skipped: true, reason: 'not-fix' };
7251
+ const _PR_REQUIRING_TYPES = new Set([WORK_TYPE.FIX, WORK_TYPE.REVIEW, WORK_TYPE.TEST]);
7252
+ function autoEnrollPrFromWorkItem(item, project, minionsDir) {
7253
+ if (!item || !_PR_REQUIRING_TYPES.has(item.type)) return { skipped: true, reason: 'not-pr-requiring-type' };
7253
7254
  const prRef = extractStructuredWorkItemPrRef(item);
7254
7255
  if (!prRef) return { skipped: true, reason: 'no-structured-ref' };
7255
7256
  const url = deriveUrlForPrRef(prRef, project);
@@ -7280,10 +7281,12 @@ function autoEnrollPrFromFixWorkItem(item, project, minionsDir) {
7280
7281
  ? { enrolled: true, id: result.id, prPath }
7281
7282
  : { alreadyEnrolled: true, id: result.id };
7282
7283
  } catch (e) {
7283
- log('warn', `autoEnrollPrFromFixWorkItem ${item.id}: ${e.message}`);
7284
+ log('warn', `autoEnrollPrFromWorkItem ${item.id}: ${e.message}`);
7284
7285
  return { skipped: true, reason: 'upsert-error', error: e.message };
7285
7286
  }
7286
7287
  }
7288
+ // Backward-compat alias — external callers that reference autoEnrollPrFromFixWorkItem keep working.
7289
+ const autoEnrollPrFromFixWorkItem = autoEnrollPrFromWorkItem;
7287
7290
 
7288
7291
  // ─── Cross-Platform Process Kill Helpers ─────────────────────────────────────
7289
7292
 
@@ -9015,6 +9018,7 @@ module.exports = {
9015
9018
  isContextOnlyPrRecord,
9016
9019
  upsertPullRequestRecord,
9017
9020
  isAutoManagedPrRecord, // W-mq5s5ttx000j7ab8-a — exported for engine + watch-plugin gate consolidation
9021
+ autoEnrollPrFromWorkItem,
9018
9022
  autoEnrollPrFromFixWorkItem,
9019
9023
  deriveUrlForPrRef, // exported for testing
9020
9024
  classifyPrRefForVerification, // issue #246 — host routing for loose PR-ref verification
package/engine.js CHANGED
@@ -2380,6 +2380,7 @@ async function spawnAgent(dispatchItem, config) {
2380
2380
  gitOpts: _gitOpts,
2381
2381
  dispatchId: id,
2382
2382
  wiId: _wiIdForAlert,
2383
+ skipDirtyCheck: !!project.skipLiveCheckoutDirtyCheck,
2383
2384
  log: (msg, lvl) => log(lvl || 'info', msg),
2384
2385
  });
2385
2386
  } catch (liveErr) {
@@ -5283,6 +5284,7 @@ async function spawnAgent(dispatchItem, config) {
5283
5284
  isTerminalFailure: effectiveResult !== DISPATCH_RESULT.SUCCESS,
5284
5285
  resultLabel: errorReason || effectiveResult,
5285
5286
  gitOpts: _gitOpts,
5287
+ skipDirtyCheck: !!project?.skipLiveCheckoutDirtyCheck,
5286
5288
  log,
5287
5289
  writeInboxAlert,
5288
5290
  });
@@ -8094,16 +8096,15 @@ function discoverFromWorkItems(config, project) {
8094
8096
  skipped.noAgent++; continue;
8095
8097
  }
8096
8098
 
8097
- // W-mq5wfh1v000e0da9 — defense-in-depth: auto-enroll the PR for fix WIs
8099
+ // W-mq5wfh1v000e0da9 — defense-in-depth: auto-enroll the PR for fix/review/test WIs
8098
8100
  // carrying a structured PR pointer. Primary enrollment runs in the
8099
8101
  // dashboard `POST /api/work-items` handler, but WIs created via CLI / restored
8100
8102
  // from disk / older code paths might land here without the PR record.
8101
- // No-op if the PR is already tracked. Failures swallowed the gate below
8102
- // still trips on missing PR records and surfaces `pr_not_found`.
8103
+ // No-op if the PR is already tracked or if the type doesn't require a PR.
8104
+ // Failures swallowed — the gate below still trips on missing PR records
8105
+ // and surfaces `pr_not_found`.
8103
8106
  try {
8104
- if (item.type === WORK_TYPE.FIX) {
8105
- shared.autoEnrollPrFromFixWorkItem(item, project, MINIONS_DIR);
8106
- }
8107
+ shared.autoEnrollPrFromWorkItem(item, project, MINIONS_DIR);
8107
8108
  } catch (e) { log('warn', `auto-enroll PR for ${item.id}: ${e.message}`); }
8108
8109
 
8109
8110
  const linkedPr = resolveWorkItemPrRecord(item, project);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2289",
3
+ "version": "0.1.2291",
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"