@yemi33/minions 0.1.2306 → 0.1.2308

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.
@@ -437,6 +437,8 @@ function renderProjectSkillsBlock(entries) {
437
437
  }
438
438
  lines.push('');
439
439
  lines.push("Record the skill outcome in your completion report's `meta.skill` block (`invoked` or `skipped` — see `docs/completion-reports.md`) so the engine can later measure skill-vs-first-principles signal.");
440
+ lines.push('');
441
+ lines.push("**Before you record a `skipped` outcome for an in-scope skill, this is a hard constraint, not a suggestion:** re-read that skill's own SKILL.md review criteria / checklist (the content injected above) and confirm your skip reason does not contradict any explicit \"Flag if…\" / \"Verify that…\" / \"Requirements\" item it documents. If the diff trips ANY explicit checklist item the skill covers, skipping is NOT permitted — apply the skill (or at minimum that specific checklist item). A skip reason that waves away a gate the skill explicitly documents (e.g. \"no CHANGELOG needed here\" when the skill's CHANGELOG Requirements section says otherwise) is invalid. When you do skip, the SAME `{name, reason}` you record in `meta.skill.skipped` must be the reason surfaced in the PR comment — no drift between the two.");
440
442
  return lines.join('\n');
441
443
  }
442
444
 
@@ -469,6 +471,8 @@ function renderReviewSkillsBlock(entries) {
469
471
  }
470
472
  lines.push('');
471
473
  lines.push("Record the skill outcome in your completion report's `meta.review` block (`skillInvoked` or `skillSkipped` — see `docs/completion-reports.md`) so the engine can later measure skill-vs-first-principles signal.");
474
+ lines.push('');
475
+ lines.push("**Before you record a `skillSkipped` outcome for an in-scope review skill, this is a hard constraint, not a suggestion:** re-read that skill's own SKILL.md review criteria / checklist (the content injected above) and confirm your skip reason does not contradict any explicit \"Flag if…\" / \"Verify that…\" / \"Requirements\" item it documents. If the diff under review trips ANY explicit checklist item the skill covers, skipping is NOT permitted — apply the skill (or at minimum that specific checklist item) and let it inform your verdict. A skip reason that waves away a gate the skill explicitly documents (e.g. \"no CHANGELOG needed for this diff\" when the skill's CHANGELOG Requirements section says otherwise) is invalid. When you do skip, the SAME `{name, reason}` you record in `meta.review.skillSkipped` must be the reason surfaced in the PR comment — no drift between the two.");
472
476
  return lines.join('\n');
473
477
  }
474
478
 
@@ -444,7 +444,10 @@ async function addToDispatchWithValidation(item, opts = {}) {
444
444
 
445
445
  let evaluation;
446
446
  try {
447
- evaluation = await validate(wi, { engineConfig: config?.engine });
447
+ evaluation = await validate(wi, {
448
+ engineConfig: config?.engine,
449
+ project: _resolveEvalProjectContext(item, config),
450
+ });
448
451
  } catch (e) {
449
452
  log('warn', `pre-dispatch-eval: validator threw — failing open: ${e.message}`);
450
453
  return addToDispatch(item);
@@ -458,6 +461,37 @@ async function addToDispatchWithValidation(item, opts = {}) {
458
461
  return null;
459
462
  }
460
463
 
464
+ /**
465
+ * Build the target-project identity passed to the pre-dispatch validator so the
466
+ * LLM knows which repo the work item targets. Without this the model has no way
467
+ * to know which project it is reasoning about and defaults to describing the
468
+ * Minions engine checkout it executes inside (cwd is hardcoded to MINIONS_DIR),
469
+ * producing false-invalid verdicts backed by fabricated file-existence claims
470
+ * about the wrong repo (W-mr25eg2400078d4b; repro WI W-mr2401ga0007830d
471
+ * targeting constellation). Best-effort — resolves the full project config to
472
+ * derive a canonical repo slug, but degrades gracefully to name/localPath only.
473
+ * @returns {{name?: string, slug?: string, localPath?: string}|null}
474
+ */
475
+ function _resolveEvalProjectContext(item, config) {
476
+ const metaProject = item?.meta?.project;
477
+ if (!metaProject) return null;
478
+ const name = typeof metaProject === 'string' ? metaProject : metaProject.name;
479
+ const ctx = {};
480
+ if (name) ctx.name = String(name);
481
+ if (metaProject && typeof metaProject === 'object' && metaProject.localPath) {
482
+ ctx.localPath = String(metaProject.localPath);
483
+ }
484
+ try {
485
+ const full = _resolveDispatchProject(name || metaProject, config);
486
+ if (full && typeof full === 'object') {
487
+ if (!ctx.localPath && full.localPath) ctx.localPath = String(full.localPath);
488
+ const slug = shared.getProjectPrScope(full);
489
+ if (slug) ctx.slug = slug;
490
+ }
491
+ } catch { /* best-effort — name/localPath alone still help */ }
492
+ return Object.keys(ctx).length > 0 ? ctx : null;
493
+ }
494
+
461
495
 
462
496
  function _resolveDispatchProject(projectRef, config) {
463
497
  if (!projectRef) return null;
@@ -580,6 +614,7 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
580
614
  FAILURE_CLASS.LIVE_CHECKOUT_DIRTY, // P-a3f9b204 — live-checkout refused to spawn because operator localPath is dirty; mechanical retry won't fix it (operator must commit/stash/discard). NOTE (W-mqzmkoqt000hbca2): engine.js#spawnAgent ALWAYS passes an explicit `agentRetryable` for this class, so this Set entry is never the actual gate — when auto-cleanup (liveCheckoutAutoReset/AutoStash) is enabled the engine overrides to retryable. Kept as a defensive safety net for any caller that omits the explicit override.
581
615
  FAILURE_CLASS.LIVE_CHECKOUT_MID_OPERATION, // P-a7f3c1d9 — live-checkout refused to spawn because the operator tree is mid-operation (in-progress merge/rebase/cherry-pick/bisect or detached HEAD); mechanical retry won't fix it (operator must finish/abort the op or checkout a branch)
582
616
  FAILURE_CLASS.LIVE_CHECKOUT_BLOB_FETCH, // PL-live-checkout-reliability-hardening — live-checkout `git checkout <existing-branch>` failed hydrating the tree through the auth-less GVFS cache server (blobless partial clone, headless); deterministic, so mechanical retry just reproduces it (operator must hydrate the branch with their own creds, then re-dispatch)
617
+ FAILURE_CLASS.LIVE_CHECKOUT_WORKTREE_CONFLICT, // W-mr28h2j2000y0de1 — live-checkout `git checkout <existing-branch>` failed because that branch is already checked out in another worktree; structural conflict, so mechanical retry just reproduces it (operator must `git worktree remove` the other tree or finish its WIP, then re-dispatch)
583
618
  FAILURE_CLASS.OUTPUT_TRUNCATED, // P-8e4c2a17 — agent stdout exceeded the hard capture cap before the terminal result event; mechanical retry just reproduces the overflow (agent must reduce output volume or the task must be split)
584
619
  ]);
585
620
  if (neverRetry.has(failureClass)) return false;
@@ -43,6 +43,7 @@ const { execFileSync: _execFileSync } = require('child_process');
43
43
  const { resolveTokenForSlug: _defaultResolveTokenForSlug } = require('./gh-token');
44
44
  const {
45
45
  buildHarnessUsedSection,
46
+ buildSkippedSkillSection,
46
47
  linkifyBrandTrailer,
47
48
  MINIONS_BRAND_URL,
48
49
  // Neutral marker + body builder now lives in comment-format.js so the GitHub
@@ -155,13 +156,14 @@ function postPrComment({
155
156
  kind,
156
157
  workItemId,
157
158
  harnessUsed,
159
+ skillSkipped,
158
160
  timeoutMs = 30000,
159
161
  execFileSync = _execFileSync,
160
162
  resolveTokenForSlug,
161
163
  } = {}) {
162
164
  _validateRepo(repo);
163
165
  _validatePrNumber(prNumber);
164
- const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body, harnessUsed });
166
+ const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body, harnessUsed, skillSkipped });
165
167
  const file = _writeTempBodyFile(finalBody);
166
168
  const env = _resolveTokenEnvForRepo(repo, resolveTokenForSlug);
167
169
  try {
@@ -185,13 +187,14 @@ function postPrReviewComment({
185
187
  kind,
186
188
  workItemId,
187
189
  harnessUsed,
190
+ skillSkipped,
188
191
  timeoutMs = 30000,
189
192
  execFileSync = _execFileSync,
190
193
  resolveTokenForSlug,
191
194
  } = {}) {
192
195
  _validateRepo(repo);
193
196
  _validatePrNumber(prNumber);
194
- const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body, harnessUsed });
197
+ const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body, harnessUsed, skillSkipped });
195
198
  const file = _writeTempBodyFile(finalBody);
196
199
  const env = _resolveTokenEnvForRepo(repo, resolveTokenForSlug);
197
200
  try {
@@ -222,6 +225,7 @@ function postPrReview({
222
225
  kind,
223
226
  workItemId,
224
227
  harnessUsed,
228
+ skillSkipped,
225
229
  timeoutMs = 30000,
226
230
  execFileSync = _execFileSync,
227
231
  resolveTokenForSlug,
@@ -234,7 +238,7 @@ function postPrReview({
234
238
  }
235
239
  _validateRepo(repo);
236
240
  _validatePrNumber(prNumber);
237
- const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body, harnessUsed });
241
+ const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body, harnessUsed, skillSkipped });
238
242
  const file = _writeTempBodyFile(finalBody);
239
243
  const env = _resolveTokenEnvForRepo(repo, resolveTokenForSlug);
240
244
  try {
@@ -254,6 +258,7 @@ module.exports = {
254
258
  // Builders / parsers (pure functions — usable from anywhere)
255
259
  buildMinionsCommentBody,
256
260
  buildHarnessUsedSection, // re-export of engine/comment-format.js for comment callers
261
+ buildSkippedSkillSection, // re-export of engine/comment-format.js for comment callers
257
262
  linkifyBrandTrailer, // re-export of engine/comment-format.js for comment callers
258
263
  MINIONS_BRAND_URL, // re-export of engine/comment-format.js for comment callers
259
264
  parseMinionsMarker,
@@ -6308,18 +6308,34 @@ function diagnoseEmptyOutput(failureClass, code, elapsedMs) {
6308
6308
  return `[empty-output: process exited in ${elapsedMs}ms \u2014 possible causes: machine sleep, network unavailability, auth failure]`;
6309
6309
  }
6310
6310
 
6311
- // W-mqba5ulq000nd255 — Reconciliation sweep: delete PR records flagged with
6312
- // `_invalidProjectScope: { reason: "pr_scope_mismatch" }` IFF a sibling
6313
- // record for the same canonical pr.id exists in another project whose scope
6314
- // matches the PR URL (i.e. the correctly-scoped project owns the canonical
6315
- // record). Sibling-less mismatches are preserved as tracking. Runs once per
6316
- // tick from engine.js after the ADO/GitHub reconcile polls finish.
6311
+ // W-mqba5ulq000nd255 / W-mr2cqq12000b04f8 — Reconciliation sweep with two
6312
+ // defenses against mis-scoped `_invalidProjectScope: { reason: "pr_scope_mismatch" }`
6313
+ // records:
6317
6314
  //
6318
- // Returns { pruned, scanned } so the engine tick can log a summary line.
6315
+ // 1. PRUNE — a sibling record for the same canonical pr.id already exists in
6316
+ // the correctly-scoped project (e.g. a fresh dispatch there re-synced it).
6317
+ // The mis-scoped duplicate is deleted; the sibling is the surviving record.
6318
+ //
6319
+ // 2. RELOCATE — no sibling exists yet, but a project IS configured whose
6320
+ // scope matches the PR's real repo (e.g. work dispatched under project
6321
+ // "minions-opg" opened a PR against `yemi33/minions`, and a "minions"
6322
+ // project mapping to that repo is configured). Without this, the record
6323
+ // would sit mis-scoped forever — the correct project's tracker (read by
6324
+ // repo-scoped schedules such as daily-merge-approved-minions-prs) would
6325
+ // never see it. The record is moved: upserted into the correct project's
6326
+ // file (which clears `_invalidProjectScope` since it's now correctly
6327
+ // scoped) and removed from the mis-scoped project's file.
6328
+ //
6329
+ // When no configured project matches the PR's real scope at all, the record
6330
+ // is left in place with its `_invalidProjectScope` stamp so operators can
6331
+ // still find it (still surfaced via GET /api/pull-requests aggregation).
6332
+ // Runs once per tick from engine.js after the ADO/GitHub reconcile polls finish.
6333
+ //
6334
+ // Returns { pruned, relocated, scanned } so the engine tick can log a summary line.
6319
6335
  function pruneScopeMismatchDuplicatePrs(config) {
6320
6336
  config = config || getConfig();
6321
6337
  const projects = shared.getProjects(config) || [];
6322
- if (projects.length === 0) return { pruned: 0, scanned: 0 };
6338
+ if (projects.length === 0) return { pruned: 0, relocated: 0, scanned: 0 };
6323
6339
 
6324
6340
  // Map project name -> canonical scope so we can find which project IS the
6325
6341
  // correctly-scoped owner for a given mismatch record.
@@ -6344,13 +6360,17 @@ function pruneScopeMismatchDuplicatePrs(config) {
6344
6360
  }
6345
6361
 
6346
6362
  // Build per-project delete sets keyed by id, so we batch one mutation per
6347
- // affected project file.
6348
- const deletesByProject = new Map(); // projectName -> Set(prId)
6363
+ // affected project file. Track prune-deletes and relocate-deletes in
6364
+ // separate id sets (per project) so `pruned` vs `relocated` counts stay
6365
+ // accurate even though both end up removing rows from the same file.
6366
+ const pruneDeletesByProject = new Map(); // projectName -> Set(prId)
6367
+ const relocateDeletesByProject = new Map(); // projectName -> Set(prId)
6368
+ // Records to insert into their correctly-scoped project's file.
6369
+ const relocationsByTargetProject = new Map(); // projectName -> [{ rec, owningScope }]
6349
6370
  let scanned = 0;
6350
6371
  let pruned = 0;
6351
6372
 
6352
6373
  for (const records of byId.values()) {
6353
- if (records.length < 2) continue;
6354
6374
  for (const rec of records) {
6355
6375
  scanned++;
6356
6376
  if (!rec._invalidProjectScope || rec._invalidProjectScope.reason !== 'pr_scope_mismatch') continue;
@@ -6358,32 +6378,79 @@ function pruneScopeMismatchDuplicatePrs(config) {
6358
6378
  if (!correctScope) continue;
6359
6379
  const correctProject = projectByScope.get(correctScope);
6360
6380
  if (!correctProject) continue; // no configured project owns the URL — keep as tracking
6361
- // Sibling check: is there a record under the correct scope for the same id?
6362
- const sibling = records.find(r => r !== rec && r._scope === correctProject.name);
6363
- if (!sibling) continue;
6364
- // Safe to prune.
6365
6381
  const owningScope = rec._scope;
6366
6382
  if (!owningScope || owningScope === 'central') continue;
6367
- if (!deletesByProject.has(owningScope)) deletesByProject.set(owningScope, new Set());
6368
- deletesByProject.get(owningScope).add(rec.id);
6383
+ if (owningScope === correctProject.name) continue; // already correctly scoped
6384
+ // Sibling check: is there a record under the correct scope for the same id?
6385
+ const sibling = records.find(r => r !== rec && r._scope === correctProject.name);
6386
+ if (sibling) {
6387
+ // Safe to prune — the sibling already owns the canonical record.
6388
+ if (!pruneDeletesByProject.has(owningScope)) pruneDeletesByProject.set(owningScope, new Set());
6389
+ pruneDeletesByProject.get(owningScope).add(rec.id);
6390
+ } else {
6391
+ // No sibling anywhere: relocate the record into the correctly-scoped
6392
+ // project instead of leaving it stuck mis-scoped indefinitely.
6393
+ if (!relocationsByTargetProject.has(correctProject.name)) relocationsByTargetProject.set(correctProject.name, []);
6394
+ relocationsByTargetProject.get(correctProject.name).push({ rec, owningScope });
6395
+ }
6396
+ }
6397
+ }
6398
+
6399
+ // Relocate first (insert into target), only marking the source record for
6400
+ // deletion once the insert into the correct project succeeds — this avoids
6401
+ // losing a record if the insert throws mid-way.
6402
+ let relocated = 0;
6403
+ for (const [targetProjectName, entries] of relocationsByTargetProject) {
6404
+ const targetProject = projects.find(p => p.name === targetProjectName);
6405
+ if (!targetProject) continue;
6406
+ const targetPrPath = projectPrPath(targetProject);
6407
+ for (const { rec, owningScope } of entries) {
6408
+ const entry = { ...rec };
6409
+ delete entry._scope;
6410
+ delete entry._invalidProjectScope;
6411
+ delete entry.project; // let upsertPullRequestRecord backfill from targetProject
6412
+ try {
6413
+ const result = shared.upsertPullRequestRecord(targetPrPath, entry, {
6414
+ project: targetProject,
6415
+ itemIds: Array.isArray(rec.prdItems) ? rec.prdItems : null,
6416
+ });
6417
+ if (result.created || result.linked) {
6418
+ relocated++;
6419
+ if (!relocateDeletesByProject.has(owningScope)) relocateDeletesByProject.set(owningScope, new Set());
6420
+ relocateDeletesByProject.get(owningScope).add(rec.id);
6421
+ log('info', `[pull-requests] relocated mis-scoped PR ${rec.id} from project=${owningScope} to correctly-scoped project=${targetProjectName}`);
6422
+ }
6423
+ } catch (err) {
6424
+ log('warn', `pruneScopeMismatchDuplicatePrs: failed to relocate ${rec.id} to ${targetProjectName}: ${err?.message || err}`);
6425
+ }
6369
6426
  }
6370
6427
  }
6371
6428
 
6372
- if (deletesByProject.size === 0) return { pruned: 0, scanned };
6429
+ // Merge prune + relocate delete sets per project so each file is mutated once.
6430
+ const deletesByProject = new Map();
6431
+ for (const [projectName, ids] of pruneDeletesByProject) {
6432
+ deletesByProject.set(projectName, new Set(ids));
6433
+ }
6434
+ for (const [projectName, ids] of relocateDeletesByProject) {
6435
+ if (!deletesByProject.has(projectName)) deletesByProject.set(projectName, new Set());
6436
+ for (const id of ids) deletesByProject.get(projectName).add(id);
6437
+ }
6438
+
6439
+ if (deletesByProject.size === 0) return { pruned: 0, relocated, scanned };
6373
6440
 
6374
6441
  for (const [projectName, idsToDelete] of deletesByProject) {
6375
6442
  const project = projects.find(p => p.name === projectName);
6376
6443
  if (!project) continue;
6377
6444
  const prPath = projectPrPath(project);
6445
+ const pruneIds = pruneDeletesByProject.get(projectName) || new Set();
6378
6446
  try {
6379
6447
  shared.mutatePullRequests(prPath, (prs) => {
6380
- const before = prs.length;
6448
+ const deletedIds = prs.filter(p => idsToDelete.has(p?.id)).map(p => p.id);
6381
6449
  const next = prs.filter(p => !idsToDelete.has(p?.id));
6382
- const deleted = before - next.length;
6383
- if (deleted > 0) {
6384
- pruned += deleted;
6385
- for (const id of idsToDelete) {
6386
- log('info', `[pull-requests] pruned scope-mismatch duplicate ${id} from project=${projectName} (sibling exists in correctly-scoped project)`);
6450
+ for (const id of deletedIds) {
6451
+ if (pruneIds.has(id)) {
6452
+ pruned++;
6453
+ log('info', `[pull-requests] removed mis-scoped duplicate ${id} from project=${projectName}`);
6387
6454
  }
6388
6455
  }
6389
6456
  return next;
@@ -6393,7 +6460,7 @@ function pruneScopeMismatchDuplicatePrs(config) {
6393
6460
  }
6394
6461
  }
6395
6462
 
6396
- return { pruned, scanned };
6463
+ return { pruned, relocated, scanned };
6397
6464
  }
6398
6465
 
6399
6466
  // Repair helper: collapse prNumber duplicates across all project-scoped and
@@ -53,6 +53,11 @@
53
53
  * { ok:false, reason:'dirty', dirtyFiles:[…] }
54
54
  * { ok:false, reason:'mid-operation', op:'merge'|'rebase'|'cherry-pick'|'revert', details }
55
55
  * { ok:false, reason:'detached-head', sha }
56
+ * { ok:false, reason:'blob-fetch', op, branch, message, originalRef, originalRefType }
57
+ * { ok:false, reason:'worktree-conflict', op, branch, conflictingWorktreePath, message, originalRef, originalRefType }
58
+ * (W-mr28h2j2000y0de1) — the target branch is already checked out in another
59
+ * worktree; `git checkout <branch>` refuses deterministically. Non-retryable
60
+ * at the caller — a human/engine must remove or reassign the other worktree.
56
61
  *
57
62
  * NOTE: `mainRef` is still accepted (and validated) for caller-contract
58
63
  * stability, but it is NOT used to seed the new branch in live mode — HEAD
@@ -132,6 +137,33 @@ function _isPartialCloneBlobError(message = '') {
132
137
  );
133
138
  }
134
139
 
140
+ // W-mr28h2j2000y0de1 — worktree-conflict signature match. When the target
141
+ // branch is ALSO checked out in a SECOND worktree elsewhere (a leftover from a
142
+ // prior isolated-worktree dispatch, a manually-created worktree, or a stale
143
+ // worktree left behind by a checkoutMode change), a plain `git checkout
144
+ // <branch>` inside the operator checkout (project.localPath) refuses
145
+ // DETERMINISTICALLY with git's own literal phrasing:
146
+ // fatal: '<branch>' is already used by worktree at '<path>'
147
+ // This is a STRUCTURAL conflict — it does NOT clear on retry, ever, until a
148
+ // human or the engine removes/reassigns the other worktree. So (like the
149
+ // blob-fetch case) it must be surfaced ONCE as an operator-actionable refusal
150
+ // instead of retry-storming an identical LIVE_CHECKOUT_FAILED to the cap. The
151
+ // matcher keys on git's stable literal wording (case-insensitive substring)
152
+ // and, when present, captures the conflicting worktree path so the caller's
153
+ // inbox alert can name the exact tree to remove or finish work in.
154
+ function _isWorktreeConflictError(message = '') {
155
+ return /is already used by worktree at/i.test(String(message || ''));
156
+ }
157
+
158
+ // Extract the conflicting worktree path from git's "is already used by worktree
159
+ // at '<path>'" message. Returns the captured path, or null when the phrasing
160
+ // didn't include an extractable path (defensive — the caller degrades to an
161
+ // "unknown location" alert).
162
+ function _extractConflictingWorktreePath(message = '') {
163
+ const m = String(message || '').match(/is already used by worktree at '([^']+)'/i);
164
+ return m ? m[1] : null;
165
+ }
166
+
135
167
  // W-mqva907q — auto-clean safe untracked build artifacts before a live-checkout
136
168
  // dirty bail. A `git status --porcelain` line for a stray `__pycache__/` (or
137
169
  // other regenerable cache) should NOT block dispatch the way a real edit does.
@@ -664,6 +696,18 @@ async function prepareLiveCheckout(opts = {}) {
664
696
  originalRefType,
665
697
  };
666
698
  }
699
+ if (_isWorktreeConflictError(msg)) {
700
+ return {
701
+ ok: false,
702
+ reason: 'worktree-conflict',
703
+ op: creating ? 'create' : 'checkout',
704
+ branch: branchName,
705
+ conflictingWorktreePath: _extractConflictingWorktreePath(msg),
706
+ message: msg.slice(0, 500),
707
+ originalRef,
708
+ originalRefType,
709
+ };
710
+ }
667
711
  throw checkoutErr; // transient → caller retries (LIVE_CHECKOUT_FAILED)
668
712
  }
669
713
  };
@@ -954,6 +998,164 @@ async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
954
998
  }
955
999
  }
956
1000
 
1001
+ /**
1002
+ * resolveLiveCheckoutAutoStash — W-mqtvnnj1000357fa
1003
+ *
1004
+ * Decides whether a dirty live-checkout tree should be auto-stashed before
1005
+ * dispatch instead of failing with FAILURE_CLASS.LIVE_CHECKOUT_DIRTY.
1006
+ *
1007
+ * Resolution order (per-project overrides fleet, matching resolveCheckoutMode
1008
+ * and the per-agent cli/model override pattern):
1009
+ * 1. If `project.liveCheckoutAutoStash` is an explicit boolean, use it.
1010
+ * 2. Else if `engine.liveCheckoutAutoStash` is an explicit boolean, use it.
1011
+ * 3. Else default false.
1012
+ *
1013
+ * Pure — no I/O. Tested in test/unit/live-checkout-auto-stash.test.js.
1014
+ *
1015
+ * @param {{ project?:object|null, engine?:object|null }} opts
1016
+ * @returns {boolean}
1017
+ */
1018
+ function resolveLiveCheckoutAutoStash(opts = {}) {
1019
+ const { project, engine } = opts;
1020
+ if (project && typeof project.liveCheckoutAutoStash === 'boolean') {
1021
+ return project.liveCheckoutAutoStash;
1022
+ }
1023
+ if (engine && typeof engine.liveCheckoutAutoStash === 'boolean') {
1024
+ return engine.liveCheckoutAutoStash;
1025
+ }
1026
+ return false;
1027
+ }
1028
+
1029
+ /**
1030
+ * performLiveCheckoutAutoStash — W-mqtvnnj1000357fa
1031
+ *
1032
+ * Runs `git stash push --include-untracked -m "<message>"` in the operator's
1033
+ * live checkout so a dirty tree can be parked and dispatch can proceed. The
1034
+ * engine NEVER pops the stash automatically — that is the operator's choice
1035
+ * (step 5 of the WI). The stash name is logged + surfaced in an inbox note so
1036
+ * the operator can `git stash pop` manually.
1037
+ *
1038
+ * `--include-untracked` is intentional (the WI text shows the bare form): the
1039
+ * dirty preflight uses `git status --porcelain`, which counts untracked `??`
1040
+ * files as dirty, so a tracked-only stash would leave the tree dirty and the
1041
+ * re-run preflight would still bail. Including untracked makes the tree clean.
1042
+ *
1043
+ * Never throws on a stash failure and never silently swallows it — returns
1044
+ * `{ ok:false, error }` so the caller can fall through to the existing
1045
+ * retry-once-then-fail behavior (step 4 of the WI).
1046
+ *
1047
+ * MUST be called OUTSIDE any file lock — it shells out to git and the lock
1048
+ * callbacks must stay synchronous/fast.
1049
+ *
1050
+ * @param {{ localPath:string, dispatchId?:string, gitOpts?:object,
1051
+ * log?:(msg:string, lvl?:string)=>void, _git?:Function }} opts
1052
+ * @returns {Promise<{ ok:boolean, stashMessage:string, error?:string }>}
1053
+ */
1054
+ async function performLiveCheckoutAutoStash(opts = {}) {
1055
+ const { localPath, dispatchId, gitOpts, log, _git } = opts;
1056
+ if (!localPath || typeof localPath !== 'string') {
1057
+ throw new Error('performLiveCheckoutAutoStash: localPath is required (got ' + JSON.stringify(localPath) + ')');
1058
+ }
1059
+ const git = (typeof _git === 'function') ? _git : shared.shellSafeGit;
1060
+ const logFn = (typeof log === 'function') ? log : () => {};
1061
+ const baseOpts = { cwd: localPath, ...(gitOpts || {}) };
1062
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
1063
+ const stashMessage = `minions-auto-stash-${dispatchId || 'nodispatch'}-${stamp}`;
1064
+ try {
1065
+ await git(['stash', 'push', '--include-untracked', '-m', stashMessage], baseOpts);
1066
+ logFn(
1067
+ `live-checkout: auto-stashed dirty tree as "${stashMessage}" in ${localPath} — ` +
1068
+ `the engine will NOT pop it automatically; run \`git stash pop\` in ${localPath} to restore.`,
1069
+ 'info',
1070
+ );
1071
+ return { ok: true, stashMessage };
1072
+ } catch (e) {
1073
+ const error = (e && e.message) ? e.message : String(e);
1074
+ logFn(`live-checkout: auto-stash failed in ${localPath}: ${error}`, 'warn');
1075
+ return { ok: false, stashMessage, error };
1076
+ }
1077
+ }
1078
+
1079
+ /**
1080
+ * applyLiveCheckoutAutoStash — W-mqtvnnj1000357fa
1081
+ *
1082
+ * Orchestrates the dirty-tree auto-stash flow so spawnAgent stays lean. When the
1083
+ * preflight `liveResult` is a confirmed-dirty refusal AND auto-stash is enabled
1084
+ * (per-project field wins, else engine fleet-wide fallback), this:
1085
+ * 1. `git stash push --include-untracked` the operator's changes
1086
+ * (performLiveCheckoutAutoStash — runs OUTSIDE any file lock).
1087
+ * 2. On stash success, re-runs `prepareLiveCheckout` (the tree is now clean).
1088
+ * 3. Clears the WI's `_pendingReason === 'live_checkout_dirty'` stamp via the
1089
+ * injected `clearDirtyStamp` callback so a future dirty tree is not treated
1090
+ * as "already failed" (resets the retry-once state).
1091
+ * 4. Writes an operator inbox note via the injected `writeStashNote(key, body)`
1092
+ * callback (the engine NEVER pops the stash automatically).
1093
+ *
1094
+ * Returns a discriminated outcome the caller acts on:
1095
+ * - `{ outcome:'unchanged', liveResult }` — not dirty, disabled, or stash failed
1096
+ * (caller falls through to the existing retry-once-then-fail dirty handling).
1097
+ * - `{ outcome:'stashed', liveResult }` — stashed + re-preflighted; proceed.
1098
+ * - `{ outcome:'threw', error }` — the re-preflight threw; caller must
1099
+ * fail the dispatch as LIVE_CHECKOUT_FAILED (engine-specific completion).
1100
+ *
1101
+ * @param {object} opts
1102
+ * @returns {Promise<{outcome:'unchanged'|'stashed'|'threw', liveResult?:object, error?:Error}>}
1103
+ */
1104
+ async function applyLiveCheckoutAutoStash(opts = {}) {
1105
+ const {
1106
+ liveResult, project, engine, localPath, branchName, mainRef,
1107
+ gitOpts, dispatchId, wiId, log,
1108
+ clearDirtyStamp, writeStashNote,
1109
+ } = opts;
1110
+ const logFn = (typeof log === 'function') ? log : () => {};
1111
+ const reason = (liveResult && liveResult.ok === false) ? liveResult.reason : null;
1112
+ if (reason !== 'dirty' || !resolveLiveCheckoutAutoStash({ project, engine })) {
1113
+ return { outcome: 'unchanged', liveResult };
1114
+ }
1115
+ let stashResult;
1116
+ try {
1117
+ stashResult = await performLiveCheckoutAutoStash({ localPath, dispatchId, gitOpts, log });
1118
+ } catch (stashErr) {
1119
+ stashResult = { ok: false, error: stashErr && stashErr.message };
1120
+ }
1121
+ if (!stashResult || !stashResult.ok) {
1122
+ logFn(`spawnAgent: live-checkout auto-stash failed for ${dispatchId} (${(stashResult && stashResult.error) || 'unknown'}); falling through to dirty-fail handling`, 'warn');
1123
+ return { outcome: 'unchanged', liveResult };
1124
+ }
1125
+ let newLive;
1126
+ try {
1127
+ newLive = await prepareLiveCheckout({ localPath, branchName, mainRef, gitOpts, dispatchId, wiId, log });
1128
+ } catch (stashLiveErr) {
1129
+ return { outcome: 'threw', error: stashLiveErr };
1130
+ }
1131
+ if (typeof clearDirtyStamp === 'function') {
1132
+ try { clearDirtyStamp(); } catch (e) { logFn(`live-checkout: failed to clear _pendingReason after auto-stash: ${e.message}`, 'warn'); }
1133
+ }
1134
+ if (typeof writeStashNote === 'function') {
1135
+ try {
1136
+ const body = [
1137
+ '# Live-checkout dirty tree auto-stashed',
1138
+ '',
1139
+ `**Project:** ${(project && project.name) || '(unknown)'}`,
1140
+ `**Local path:** ${localPath}`,
1141
+ `**Branch:** ${branchName}`,
1142
+ `**Work item:** ${wiId}`,
1143
+ `**Dispatch:** ${dispatchId}`,
1144
+ `**Stash:** \`${stashResult.stashMessage}\``,
1145
+ '',
1146
+ `The live checkout at \`${localPath}\` had uncommitted changes. Because live-checkout auto-stash is enabled, the engine ran \`git stash push --include-untracked\` so this dispatch could proceed. The engine never \`git reset\` or \`git clean\` your tree.`,
1147
+ '',
1148
+ '## Recovery',
1149
+ '',
1150
+ `The engine does NOT pop the stash automatically. To restore your changes, run \`git stash pop\` (or \`git stash apply\`) in \`${localPath}\`. Locate it with \`git stash list\` — look for \`${stashResult.stashMessage}\`.`,
1151
+ ].join('\n');
1152
+ writeStashNote(`live-checkout-autostash-${wiId}`, body);
1153
+ } catch (e) { logFn(`live-checkout: writeInboxAlert (auto-stash) failed: ${e.message}`, 'warn'); }
1154
+ }
1155
+ logFn(`spawnAgent: live-checkout auto-stashed dirty tree for ${dispatchId} (${stashResult.stashMessage}); proceeding with dispatch`, 'info');
1156
+ return { outcome: 'stashed', liveResult: newLive };
1157
+ }
1158
+
957
1159
  /**
958
1160
  * maybeRestoreLiveCheckoutFromRecord — PL-live-checkout-reliability-hardening
959
1161
  *
@@ -1020,8 +1222,13 @@ async function maybeRestoreLiveCheckoutFromRecord(opts = {}) {
1020
1222
  module.exports = {
1021
1223
  prepareLiveCheckout,
1022
1224
  restoreLiveCheckoutAtDispatchEnd,
1225
+ resolveLiveCheckoutAutoStash,
1226
+ performLiveCheckoutAutoStash,
1227
+ applyLiveCheckoutAutoStash,
1023
1228
  maybeRestoreLiveCheckoutFromRecord,
1024
1229
  _isPartialCloneBlobError,
1230
+ _isWorktreeConflictError,
1231
+ _extractConflictingWorktreePath,
1025
1232
  _isAutoCleanableArtifact,
1026
1233
  _collectAutoCleanablePaths,
1027
1234
  _looksLikeAgentBranch,
@@ -45,6 +45,44 @@ const DEFAULT_TIMEOUT_MS = 60000;
45
45
  // descriptions don't carry enough signal for the gate to add value.
46
46
  const DESCRIPTION_MIN_CHARS = 80;
47
47
 
48
+ // Guardrail appended to every prompt. The validator runs a pure text-reasoning
49
+ // LLM call (direct: true, cwd hardcoded to MINIONS_DIR per engine/llm.js
50
+ // _spawnProcess) with NO filesystem tools, so the model cannot see the target
51
+ // project's code. P-b5e2a481 removed the read tools that were making the model
52
+ // search MINIONS_DIR, but the model kept *asserting* unverified file-existence
53
+ // claims in its reasoning text (e.g. "this repo contains no TypeScript files")
54
+ // while actually describing the Minions engine checkout it runs inside rather
55
+ // than the WI's real target repo (W-mr25eg2400078d4b, repro WI
56
+ // W-mr2401ga0007830d targeting constellation). Forbidding empirical repo claims
57
+ // in the prompt makes an invalid verdict that hinges on a fabricated
58
+ // file-existence claim structurally impossible.
59
+ const NO_FS_GUARDRAIL = [
60
+ 'IMPORTANT — you have NO filesystem or repository access and CANNOT see the',
61
+ "target project's code, files, or directory structure. Judge ONLY whether the",
62
+ 'work item is well-specified: clear, internally consistent, actionable, and',
63
+ 'testable in the abstract as a software task. Do NOT assert or assume whether',
64
+ 'any specific file, directory, path, module, package, or code symbol exists or',
65
+ 'does not exist, and do NOT cite file paths or file contents as evidence. A',
66
+ 'verdict of "valid": false must NEVER rest on a claim about what the repository',
67
+ 'does or does not contain — you have not seen it. Only mark invalid when the',
68
+ 'description itself is too vague, contradictory, or underspecified to act on.',
69
+ ].join('\n');
70
+
71
+ function _describeProject(project) {
72
+ if (!project || typeof project !== 'object') return '';
73
+ const name = project.name ? String(project.name).trim() : '';
74
+ const slug = project.slug ? String(project.slug).trim() : '';
75
+ const localPath = project.localPath ? String(project.localPath).trim() : '';
76
+ const parts = [];
77
+ if (name) parts.push(`name "${name}"`);
78
+ if (slug) parts.push(`repo ${slug}`);
79
+ if (localPath) parts.push(`local checkout at ${localPath}`);
80
+ if (parts.length === 0) return '';
81
+ return `Target project: ${parts.join(', ')}. This work item targets that `
82
+ + 'project, which is a DIFFERENT repository from the Minions engine codebase '
83
+ + 'you are running inside.';
84
+ }
85
+
48
86
  function _extractCriteria(workItem) {
49
87
  if (!workItem || typeof workItem !== 'object') return [];
50
88
  const candidates = [workItem.acceptance_criteria, workItem.acceptanceCriteria];
@@ -59,17 +97,21 @@ function _extractDescription(workItem) {
59
97
  return (workItem.description || '').trim();
60
98
  }
61
99
 
62
- function _buildPrompt(workItem, criteria) {
100
+ function _buildPrompt(workItem, criteria, project) {
63
101
  const title = workItem.title || workItem.name || workItem.id || 'untitled';
64
102
  const description = _extractDescription(workItem);
65
- const lines = [
66
- `Work item: ${title}`,
67
- ];
103
+ const lines = [];
104
+ const projectLine = _describeProject(project);
105
+ if (projectLine) lines.push(projectLine, '');
106
+ lines.push(`Work item: ${title}`);
68
107
  const hasCriteria = Array.isArray(criteria) && criteria.length > 0;
69
108
  if (description) lines.push('', 'Description:', description);
70
109
  if (hasCriteria) {
71
110
  lines.push('', 'Acceptance criteria:');
72
111
  for (const c of criteria) lines.push(`- ${c}`);
112
+ }
113
+ lines.push('', NO_FS_GUARDRAIL);
114
+ if (hasCriteria) {
73
115
  lines.push('',
74
116
  'Are these acceptance criteria clear, actionable, and testable?',
75
117
  'Reply with JSON: {"valid": true|false, "reason": "..."}.');
@@ -146,6 +188,12 @@ function _resolveModel(opts) {
146
188
  * @param {string} [opts.model] - explicit model override; otherwise the
147
189
  * engine's CC model resolution applies, falling back to `undefined`
148
190
  * (adapter default) when nothing is configured.
191
+ * @param {object} [opts.project] - target-project identity for prompt context
192
+ * (`{ name, slug, localPath }`). Threaded by
193
+ * `engine/dispatch.js addToDispatchWithValidation` so the model knows which
194
+ * repo the work item targets (it CANNOT see any repo) and does not default to
195
+ * describing the Minions engine checkout it runs inside
196
+ * (W-mr25eg2400078d4b).
149
197
  * @param {number} [opts.timeout] - LLM timeout in ms.
150
198
  * @returns {Promise<{valid: boolean, reason: string}>}
151
199
  */
@@ -159,7 +207,7 @@ async function validateAcceptanceCriteria(workItem, opts = {}) {
159
207
  return { valid: true, reason: 'no acceptance criteria to validate' };
160
208
  }
161
209
 
162
- const prompt = _buildPrompt(workItem, criteria);
210
+ const prompt = _buildPrompt(workItem, criteria, opts.project);
163
211
  let result;
164
212
  try {
165
213
  result = await callLLM(prompt, SYSTEM_PROMPT, {
@@ -219,6 +267,7 @@ module.exports = {
219
267
  // Exposed for unit testing — engine code MUST go through validateAcceptanceCriteria.
220
268
  _extractCriteria,
221
269
  _extractDescription,
270
+ _describeProject,
222
271
  _buildPrompt,
223
272
  _parseResponse,
224
273
  _resolveModel,