@yemi33/minions 0.1.2341 → 0.1.2343

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.
@@ -1112,6 +1112,7 @@ async function saveSettings() {
1112
1112
  autoReapOrphanWorktreeHolders: !!document.getElementById('set-autoReapOrphanWorktreeHolders')?.checked,
1113
1113
  liveCheckoutAutoStash: !!document.getElementById('set-liveCheckoutAutoStash')?.checked,
1114
1114
  liveCheckoutAutoReset: !!document.getElementById('set-liveCheckoutAutoReset')?.checked,
1115
+ liveCheckoutAutoBaseRepair: !!document.getElementById('set-liveCheckoutAutoBaseRepair')?.checked,
1115
1116
  orphanHolderScanTimeoutMs: document.getElementById('set-orphanHolderScanTimeoutMs')?.value,
1116
1117
  statusProbeKillTimeoutMs: document.getElementById('set-statusProbeKillTimeoutMs')?.value,
1117
1118
  adoPollEnabled: document.getElementById('set-adoPollEnabled').checked,
@@ -125,6 +125,36 @@ Pool short-circuits live in `engine.js:1368` and `engine/cleanup.js`; both gate
125
125
 
126
126
  Either condition fails the dispatch non-retryably with `FAILURE_CLASS.LIVE_CHECKOUT_MID_OPERATION` (`'live-checkout-mid-operation'`; added to `engine/dispatch.js`'s neverRetry set alongside `LIVE_CHECKOUT_DIRTY`). `spawnAgent` writes a `live-checkout-blocked-<wi-id>` inbox alert and stamps the work item `_pendingReason: 'live_checkout_mid_operation'` (or `'live_checkout_detached_head'` for the detached case). The recovery guidance tells the operator to finish or abort the in-progress op with their own commands (`git <op> --continue` / `git <op> --abort`), or checkout a branch, then re-dispatch. The engine never runs `git reset`, `git clean`, `git stash`, `git rebase --abort`, or moves HEAD on the operator's behalf.
127
127
 
128
+ ### 6. Refuse on stale non-main base when forking a new branch (W-mr3lunnq000o9f41)
129
+
130
+ When a live-checkout dispatch needs a **new** branch, `git checkout -b <branch>` seeds it from the operator's **current HEAD** (issue #226 — live mode never fetches `origin/<mainRef>` to avoid auth-less GVFS blob fetches). That is safe only when HEAD is already on the project's base branch. If a human — or a prior dispatch — left the operator checkout sitting on some unrelated topic/feature branch, the new branch (and therefore its PR) would silently inherit **every commit already on that topic branch**. This is scope contamination baked into git history, not a stray uncommitted diff, so it survives the dirty-tree (Guarantee 2) and mid-operation (Guarantee 5) preflights, which only check for uncommitted changes / in-progress ops, never *"is HEAD on the expected base?"*. The motivating incident is ADO PR `!5411214`, which silently carried ~22 unrelated OCM files the PR author disavowed.
131
+
132
+ Immediately before the new-branch `git checkout -b`, `prepareLiveCheckout` compares HEAD's branch against the intended base:
133
+
134
+ - **The base is per-project, NOT a hardcoded `'main'`.** `spawnAgent` resolves `mainRef` via `shared.resolveMainBranch(cwd, project.mainBranch)` (`engine.js`), which honors the project's configured `mainBranch`, else auto-detects `refs/remotes/origin/HEAD`, else falls back to `main`. Repos whose default branch is **not** `main` — e.g. Office Android development, whose harness (`claude.md`) documents a `lkg/main/android` base — are handled by setting `project.mainBranch: "lkg/main/android"` (or by having `origin/HEAD` point at it); the guard then keys on that branch. The word "main" in this guarantee is shorthand for *"the project's resolved base branch,"* never the literal string.
135
+ - **HEAD already on `mainRef`** → forks normally; the guard never probes the base ref.
136
+ - **HEAD off-base, local `refs/heads/<mainRef>` exists** → PLAIN `git checkout <mainRef>` (no fetch, no reset, no `--force` — the tree was verified clean at Guarantee 2), then forks off it. No auth-less GVFS blob fetch is forced in the common case.
137
+ - **HEAD off-base, no usable local `mainRef`** (fresh-clone edge case) — or the local checkout itself fails — → **fail closed** with `{ ok:false, reason:'wrong-base' }` **unless auto-base-repair is enabled (see §6a).** The caller refuses **non-retryably** with `FAILURE_CLASS.LIVE_CHECKOUT_WRONG_BASE` (`'live-checkout-wrong-base'`; added to `engine/dispatch.js`'s neverRetry set), writes an inbox alert naming the off-base HEAD, the expected base, and the exact `git checkout <mainRef>` recovery command, and stamps the work item `_pendingReason`. This mirrors the dirty / mid-operation philosophy of refusing on ambiguous operator state rather than silently forking off the wrong base.
138
+
139
+ #### 6a. Opt-in auto-base-repair (`liveCheckoutAutoBaseRepair`, W-mr3lunnq000o9f41)
140
+
141
+ The fail-closed refusal above is safe but forces a manual `git checkout <mainRef>` before every re-dispatch. Enabling **`liveCheckoutAutoBaseRepair`** (per-project `project.liveCheckoutAutoBaseRepair` > fleet-wide `engine.liveCheckoutAutoBaseRepair` > **`false`**, resolved by `shared.resolveLiveCheckoutAutoBaseRepair`) lets the engine self-heal the *missing-local-base* case:
142
+
143
+ - When HEAD is off-base **and** there is no local `refs/heads/<mainRef>` **but** a `refs/remotes/origin/<mainRef>` remote-tracking ref exists, the guard materializes a local base branch from it — `git branch --no-track <mainRef> refs/remotes/origin/<mainRef>` — then proceeds exactly like the local-base path (plain `git checkout <mainRef>`, then fork).
144
+ - **Issue #226 is preserved:** `origin/<mainRef>` is consumed as a **local ref** — there is **no `git fetch`**. `origin/<mainRef>` is the *correct* project base, so there is **no scope-contamination risk** (the entire point of Guarantee 6 survives — it never forks off the stale topic HEAD).
145
+ - **Bounded, degrades safely:** the subsequent `git checkout <mainRef>` may hydrate blobs on a partial clone (the one operation issue #226 flagged as hang-prone in headless sessions). It is bounded by the git-wrapper timeout; on failure/hang it falls through to the **same** fail-closed `wrong-base` refusal — strictly no worse than the non-repair path.
146
+ - **Still fail-closed** when neither a local base nor an `origin/<mainRef>` tracking ref exists, or when the base checkout itself fails. Default **OFF**.
147
+
148
+ Enable it fleet-wide via **Settings → "Live-checkout auto-base-repair (fleet-wide)"**, or in `config.json`:
149
+
150
+ ```jsonc
151
+ { "engine": { "liveCheckoutAutoBaseRepair": true } } // fleet-wide
152
+ // or per-project (wins over fleet-wide):
153
+ { "projects": [ { "name": "office", "checkoutMode": "live", "liveCheckoutAutoBaseRepair": true } ] }
154
+ ```
155
+
156
+ **Legitimate continuations are exempt.** PR-targeted fixes and shared-branch plans intentionally continue a non-base branch. `spawnAgent` passes `allowNonMainBase` (derived from `meta.useExistingBranch || meta.branchStrategy === 'shared-branch'` — the same signal it already uses to distinguish fresh forks from continuations), which bypasses the guard entirely. The existing-branch path is also untouched: continuing existing work is still a plain checkout with no rebase/pull.
157
+
128
158
  ## Dispatch-end auto-restore
129
159
 
130
160
  Live-mode agents run **in-place** in the operator's checkout, so when a dispatch ends the engine switches the tree back to the ref it was on before the agent ran. This runs on **every** terminal result — success, failure, timeout, crash — and also on **every restart-recovery reaping path**: the engine-restart re-attach orphan-completion path (`engine/cli.js`) AND the `engine/timeout.js` paths that reap a dispatch the engine lost the process handle for (`completeFromOutput` + the orphan sweep). All three route through the shared `maybeRestoreLiveCheckoutFromRecord(item, …)` helper (which fires from the *persisted* dispatch record — `originalRef`'s presence is the live-mode signal), so a live agent that spans an engine restart and finishes afterward never strands the checkout on the agent's branch. (Previously the timeout.js reaping paths had **no** restore wiring, which is exactly how a restart-spanning live dispatch left the operator stuck on `work/W-…`.)
package/engine/ado.js CHANGED
@@ -1584,7 +1584,12 @@ async function pollPrStatus(config) {
1584
1584
  newReviewStatus = REVIEW_STATUS.APPROVED;
1585
1585
  // Re-approve: ADO resets votes when target branch (master) advances, even though
1586
1586
  // the source branch is unchanged. Re-apply the approval vote via API.
1587
- if (!votes.some(v => v >= 5) && sourceCommit && pr._adoSourceCommit === sourceCommit) {
1587
+ // Gated by autoApplyReviewVote (default OFF): re-casting a +10 vote is a
1588
+ // POSITIVE platform mutation, so when the operator has "Auto-apply review
1589
+ // vote to PR" disabled the engine must NOT touch the platform vote — the
1590
+ // local reviewStatus stays 'approved' (informational) but no +10 is PUT.
1591
+ const autoApplyReviewVote = config?.engine?.autoApplyReviewVote ?? shared.ENGINE_DEFAULTS.autoApplyReviewVote;
1592
+ if (autoApplyReviewVote && !votes.some(v => v >= 5) && sourceCommit && pr._adoSourceCommit === sourceCommit) {
1588
1593
  try {
1589
1594
  const identityData = await adoFetch(`${orgBase}/_apis/connectionData?api-version=7.1`, token).catch(() => null);
1590
1595
  const myId = identityData?.authenticatedUser?.id;
@@ -749,6 +749,7 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
749
749
  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)
750
750
  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)
751
751
  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)
752
+ FAILURE_CLASS.LIVE_CHECKOUT_WRONG_BASE, // W-mr3lunnq000o9f41 — live-checkout refused to fork a new branch because the operator HEAD is on an unrelated branch (not the project base) and no local base ref was usable; forking off it would bake that branch's commits into the new PR (scope contamination). Deterministic — mechanical retry reproduces it (operator must `git checkout <mainRef>`, then re-dispatch)
752
753
  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)
753
754
  ]);
754
755
  if (neverRetry.has(failureClass)) return false;
@@ -2410,19 +2410,20 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
2410
2410
  // never claim approved if some OTHER reviewer (human, different minion
2411
2411
  // account) still has a negative vote/review on the PR.
2412
2412
  //
2413
- // P-f7splitgate split the gate into two predicates:
2414
- // * canDismissOwnPriorNegative: dismissal of the engine's own previously-posted
2415
- // REQUEST_CHANGES on a verdict flip to APPROVE runs UNCONDITIONALLY. This is
2416
- // correctness (cleaning up our own stale state), not policy, so it must NOT
2417
- // be gated by autoApplyReviewVote.
2418
- // * autoApplyVote: continues to gate POSITIVE auto-actions mirroring the
2419
- // live platform status into local reviewStatus below (which can promote the
2420
- // PR to 'approved' based on platform-side votes).
2413
+ // P-f7splitgate / W-mpea9fyb0010febf autoApplyReviewVote gates ALL engine
2414
+ // platform vote mutations. When the operator has "Auto-apply review vote to
2415
+ // PR" disabled (default), the engine records the verdict in local reviewStatus
2416
+ // (informational) but MUST NOT touch the platform vote/review in any way
2417
+ // neither the POSITIVE mirror below (promoting local status from live votes)
2418
+ // NOR the negative-correction reconcile here (dismissing the engine's own
2419
+ // prior REQUEST_CHANGES / re-casting an ADO +10 on a verdict flip to APPROVE).
2420
+ // Re-casting/clearing a platform vote is exactly the "casting votes
2421
+ // unexpectedly" behavior the gate exists to suppress, so it is now gated too.
2421
2422
  const prevReviewStatus = reviewPr?.reviewStatus || '';
2422
2423
  const wasNegative = prevReviewStatus === REVIEW_STATUS.CHANGES_REQUESTED || prevReviewStatus === REVIEW_STATUS.WAITING
2423
2424
  || liveStatus === REVIEW_STATUS.CHANGES_REQUESTED || liveStatus === REVIEW_STATUS.WAITING;
2424
2425
  const autoApplyVote = config?.engine?.autoApplyReviewVote ?? ENGINE_DEFAULTS.autoApplyReviewVote;
2425
- const canDismissOwnPriorNegative = verdictRaw === REVIEW_STATUS.APPROVED && !isSelfReview && wasNegative && projectObjForChecks;
2426
+ const canDismissOwnPriorNegative = autoApplyVote && verdictRaw === REVIEW_STATUS.APPROVED && !isSelfReview && wasNegative && projectObjForChecks;
2426
2427
  if (canDismissOwnPriorNegative) {
2427
2428
  try {
2428
2429
  const reconcileFn = hostForChecks === 'github'
@@ -2471,8 +2472,69 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
2471
2472
  }
2472
2473
  }
2473
2474
 
2475
+ // W-mr9sy3j700076967 — a review dispatch can report success (type:"result",
2476
+ // no crash) yet never actually post a VERDICT: comment on the PR (silent
2477
+ // early-exit, budget exhaustion, model refusal, etc.). Before this guard,
2478
+ // the code below unconditionally stamped `lastReviewedAt` + `minionsReview`
2479
+ // as if the review were resolved. `lastReviewedAt` feeds the `alreadyReviewed`
2480
+ // gate in engine.js (`pr.lastPushedAt <= pr.lastReviewedAt`), so that phantom
2481
+ // stamp permanently blocked the engine from ever re-dispatching a review —
2482
+ // reviewStatus stayed "pending" forever with no path back to a real review.
2483
+ // Detect that case and (a) do NOT set lastReviewedAt so the existing
2484
+ // needsReview gate naturally re-fires a fresh review next tick (bounded by
2485
+ // maxReviewVerdictMissingRetries below), and (b) flag the PR + fire an inbox
2486
+ // alert instead of silently recording minionsReview as if it succeeded.
2487
+ const bailoutOutput = isReviewBailout(resultSummary);
2488
+ const verdictMissing = !postReviewStatus && !verdictRaw && !bailoutOutput;
2489
+
2474
2490
  let updatedTarget = null;
2475
2491
  const reviewNote = String(resultSummary || '').trim();
2492
+
2493
+ if (verdictMissing) {
2494
+ const maxMissingRetries = config?.engine?.maxReviewVerdictMissingRetries ?? ENGINE_DEFAULTS.maxReviewVerdictMissingRetries;
2495
+ let missingCount = 0;
2496
+ let retriesExhausted = false;
2497
+ shared.mutateJsonFileLocked(prPath, (prs) => {
2498
+ if (!Array.isArray(prs)) return prs;
2499
+ const target = shared.findPrRecord(prs, reviewPr, reviewProject);
2500
+ if (!target) return prs;
2501
+ missingCount = (target._reviewVerdictMissingCount || 0) + 1;
2502
+ retriesExhausted = missingCount >= maxMissingRetries;
2503
+ target._reviewVerdictMissingCount = missingCount;
2504
+ target._reviewVerdictMissing = true;
2505
+ target._reviewVerdictMissingAt = ts();
2506
+ target._lastReviewAttempt = {
2507
+ reviewer: reviewerName,
2508
+ attemptedAt: ts(),
2509
+ dispatchId: dispatchItem?.id || structuredCompletion?.dispatchId || null,
2510
+ note: reviewNote,
2511
+ };
2512
+ // Only lock the PR out of re-review (via lastReviewedAt) once the bounded
2513
+ // retry budget is exhausted — otherwise leave it unset so the engine's
2514
+ // normal needsReview gate re-dispatches a fresh review next tick.
2515
+ if (retriesExhausted) target.lastReviewedAt = ts();
2516
+ return prs;
2517
+ }, { defaultValue: [] });
2518
+
2519
+ const reason = `Review dispatch for ${reviewPr.id} completed without posting a VERDICT: comment ` +
2520
+ `(reviewer: ${reviewerName}, dispatch: ${dispatchItem?.id || structuredCompletion?.dispatchId || '(unknown)'}). ` +
2521
+ `reviewStatus left at "pending" instead of being silently recorded as resolved. ` +
2522
+ `Attempt ${missingCount}/${maxMissingRetries}` +
2523
+ (retriesExhausted
2524
+ ? ' — retry budget exhausted; PR flagged (_reviewVerdictMissing) for human follow-up.'
2525
+ : ' — the engine will re-dispatch a fresh review on the next eligible tick.');
2526
+ log('warn', reason);
2527
+ shared.writeToInbox('engine', `review-verdict-missing-${reviewPr.id}`,
2528
+ `# Review completed without a verdict: ${reviewPr.id}\n\n` +
2529
+ `**Reviewer:** ${reviewerName}\n` +
2530
+ `**PR:** ${reviewPr.id}\n\n` +
2531
+ `${reason}\n` +
2532
+ (reviewNote ? `\n## Agent summary\n${reviewNote}\n` : ''),
2533
+ null,
2534
+ { sourceItem: reviewPr.id, reason: 'review-verdict-missing' });
2535
+ return;
2536
+ }
2537
+
2476
2538
  shared.mutateJsonFileLocked(prPath, (prs) => {
2477
2539
  if (!Array.isArray(prs)) return prs;
2478
2540
  const target = shared.findPrRecord(prs, reviewPr, reviewProject);
@@ -2486,6 +2548,11 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
2486
2548
  }
2487
2549
  }
2488
2550
  target.lastReviewedAt = ts();
2551
+ // A verdict landed cleanly this time — clear any stale missing-verdict flag.
2552
+ delete target._reviewVerdictMissing;
2553
+ delete target._reviewVerdictMissingCount;
2554
+ delete target._reviewVerdictMissingAt;
2555
+ delete target._lastReviewAttempt;
2489
2556
  // W-mpg58wv3 — best-effort capture of thread ids the review cited. Used
2490
2557
  // downstream by buildPrDispatch when spawning the review-feedback fix WI
2491
2558
  // (meta.addresses_threads) so the closure-loop reaper can correlate. Accept
@@ -6691,10 +6758,26 @@ function collapseAllDuplicatePrRecords(config) {
6691
6758
  // meta.liveValidationFor === codingWiId AND meta.liveValidationType === type
6692
6759
  // blocks a second dispatch of that specific type, so multiple configured
6693
6760
  // types don't collide or skip each other.
6761
+ //
6762
+ // W-mr9u39az000db5c2 — guard against a self-referential recursive cascade.
6763
+ // When liveValidation.type overlaps with a coding WI's own type set (e.g.
6764
+ // ["test","verify","setup"] configured on a project that ALSO dispatches
6765
+ // "setup"/"test"/"fix" coding work), a completed validation WI (type
6766
+ // "setup") is itself a WI whose type is in liveValidation.type. The old
6767
+ // item.type-only filter let that validation WI's own completion re-trigger
6768
+ // this hook and spawn ANOTHER validation WI (title-prefixed "Validate:
6769
+ // Validate: ..."), repeating indefinitely. Any WI that was itself created
6770
+ // as a liveValidation follow-up (tagged via meta.liveValidationFor at
6771
+ // creation, below) must never re-trigger this hook, regardless of its type.
6694
6772
  function autoDispatchLiveValidationWi(meta, config) {
6695
6773
  const item = meta?.item;
6696
6774
  if (!item?.id || !item?.type) return;
6697
6775
 
6776
+ // Never re-trigger off the completion of a WI that was itself auto-created
6777
+ // as a live-validation follow-up — that's what causes the infinite
6778
+ // "Validate: Validate: ..." cascade (W-mr9u39az000db5c2 / issue #708).
6779
+ if (item.meta && item.meta.liveValidationFor) return;
6780
+
6698
6781
  const projectName = meta?.project?.name;
6699
6782
  if (!projectName) return;
6700
6783
 
@@ -6752,9 +6835,19 @@ function autoDispatchLiveValidationWi(meta, config) {
6752
6835
  continue;
6753
6836
  }
6754
6837
 
6838
+ const proposedTitle = 'Validate: ' + (item.title || codingWiId);
6839
+ // Defense-in-depth (belt-and-suspenders alongside the meta.liveValidationFor
6840
+ // guard above): never create a doubly-nested "Validate: Validate: ..."
6841
+ // title even if some future caller manages to invoke this function
6842
+ // directly on an already-validation-tagged item.
6843
+ if (/^Validate:\s*Validate:/.test(proposedTitle)) {
6844
+ log('warn', `liveValidation: refusing to create nested "Validate: Validate:" WI for ${codingWiId} (type=${validationType})`);
6845
+ continue;
6846
+ }
6847
+
6755
6848
  const validationWi = {
6756
6849
  id: 'W-' + shared.uid(),
6757
- title: 'Validate: ' + (item.title || codingWiId),
6850
+ title: proposedTitle,
6758
6851
  type: validationType,
6759
6852
  status: WI_STATUS.PENDING,
6760
6853
  depends_on: [codingWiId],
@@ -39,13 +39,25 @@
39
39
  * NO fast-forward, NO reset). The operator owns the checkout
40
40
  * state per the live-checkout contract; the engine never
41
41
  * overwrites local commits.
42
- * b. rev-parse fails → branch is new → `git checkout -b <branchName>`
43
- * created from the CURRENT HEAD (NOT origin/<mainRef>). In live
44
- * mode the operator's checked-out HEAD IS the baseline; branching
45
- * off origin/<mainRef> forces on-demand blob fetching on blobless
46
- * partial clones (Scalar/GVFS-managed ADO repos), which fails in
47
- * headless mode because the GVFS cache server receives no auth and
48
- * credential.helper is disabled. See issue #226.
42
+ * b. rev-parse fails → branch is new → fork it. STALE-BASE GUARD
43
+ * (W-mr3lunnq000o9f41) runs first, UNLESS `allowNonMainBase` is set
44
+ * (PR-targeted fixes / shared-branch continuations opt out they
45
+ * intend to keep building on a non-main branch): if HEAD's branch is
46
+ * NOT the project base (`mainRef`), forking off it would silently bake
47
+ * every commit already on that branch into the new branch and its PR
48
+ * (the ADO PR 5411214 scope-contamination incident — survives the
49
+ * dirty/mid-operation preflights because it is committed history, not
50
+ * an uncommitted diff). Recovery is LOCAL-ONLY (preserves issue #226's
51
+ * no-forced-fetch guarantee): if `refs/heads/<mainRef>` exists locally,
52
+ * PLAIN-checkout it (no fetch/reset/--force) and fork off it; else
53
+ * FAIL CLOSED with { ok:false, reason:'wrong-base' }. When HEAD is
54
+ * already on `mainRef` (or the guard is bypassed) →
55
+ * `git checkout -b <branchName>` from the CURRENT HEAD (NOT
56
+ * origin/<mainRef>). In live mode the operator's checked-out HEAD IS
57
+ * the baseline; branching off origin/<mainRef> forces on-demand blob
58
+ * fetching on blobless partial clones (Scalar/GVFS-managed ADO repos),
59
+ * which fails in headless mode because the GVFS cache server receives
60
+ * no auth and credential.helper is disabled. See issue #226.
49
61
  * 5. Returns { ok:true, branch, created:boolean, originalRef, originalRefType }.
50
62
  *
51
63
  * RETURN SHAPES:
@@ -53,6 +65,16 @@
53
65
  * { ok:false, reason:'dirty', dirtyFiles:[…] }
54
66
  * { ok:false, reason:'mid-operation', op:'merge'|'rebase'|'cherry-pick'|'revert', details }
55
67
  * { ok:false, reason:'detached-head', sha }
68
+ * { ok:false, reason:'wrong-base', headBranch, expectedBase, localMainExists, [switchError], originalRef, originalRefType }
69
+ * (W-mr3lunnq000o9f41) — a NEW-branch fork was requested but the operator's
70
+ * HEAD is on `headBranch`, not the project base `expectedBase` (=mainRef),
71
+ * and no local base ref could be switched to (localMainExists:false → no
72
+ * `refs/heads/<mainRef>`; or localMainExists:true + `switchError` → the local
73
+ * checkout itself failed). Non-retryable at the caller — a human must
74
+ * `git checkout <expectedBase>` before re-dispatch so the new branch forks
75
+ * off the intended baseline instead of inheriting `headBranch`'s commits.
76
+ * Suppressed entirely when the caller passes `allowNonMainBase:true`
77
+ * (PR-targeted / shared-branch continuations).
56
78
  * { ok:false, reason:'blob-fetch', op, branch, message, originalRef, originalRefType }
57
79
  * { ok:false, reason:'worktree-conflict', op, branch, conflictingWorktreePath, message, originalRef, originalRefType }
58
80
  * (W-mr28h2j2000y0de1) — the target branch is already checked out in another
@@ -320,6 +342,26 @@ function _defaultResolveAutoReset(localPath) {
320
342
  }
321
343
  }
322
344
 
345
+ // W-mr3lunnq000o9f41 — production default for resolving the live-checkout
346
+ // auto-base-repair decision when the caller (engine.js spawnAgent) does not pass
347
+ // an explicit `autoBaseRepair` boolean. Mirrors `_defaultResolveAutoReset`:
348
+ // self-resolve from config.json by matching the project on `localPath`, defer to
349
+ // `shared.resolveLiveCheckoutAutoBaseRepair`. Runs only on the wrong-base recovery
350
+ // path (cold, not hot). Fails CLOSED — any config read error returns false so a
351
+ // glitch can never silently widen the base-recovery surface.
352
+ function _defaultResolveAutoBaseRepair(localPath) {
353
+ try {
354
+ const config = shared.safeJson(path.join(shared.MINIONS_DIR, 'config.json')) || {};
355
+ const projects = shared.getProjects(config);
356
+ const norm = (p) => path.resolve(String(p || '')).replace(/\\/g, '/').toLowerCase();
357
+ const target = norm(localPath);
358
+ const project = projects.find((p) => p && p.localPath && norm(p.localPath) === target) || null;
359
+ return shared.resolveLiveCheckoutAutoBaseRepair(project, config.engine);
360
+ } catch {
361
+ return false;
362
+ }
363
+ }
364
+
323
365
  // W-mqvejug6000eeb20 — production default inbox-note writer for the auto-reset
324
366
  // audit trail. Lazily required to avoid a load-order cycle (dispatch.js does not
325
367
  // require live-checkout.js, but the lazy require keeps this module importable in
@@ -338,14 +380,25 @@ async function prepareLiveCheckout(opts = {}) {
338
380
  dispatchId, // accepted for caller bookkeeping; not used by the helper
339
381
  wiId, // accepted for caller bookkeeping; used in the auto-reset note slug
340
382
  skipDirtyCheck, // #522: opt-out for GVFS repos where git status output exceeds maxBuffer
383
+ allowNonMainBase, // W-mr3lunnq000o9f41: opt a dispatch OUT of the stale-base
384
+ // guard below. PR-targeted fixes and shared-branch
385
+ // continuations INTEND to keep building on a non-main
386
+ // branch; the caller (spawnAgent) sets this true for them.
387
+ // Undefined/false → the fresh-branch base guard is active.
341
388
  log,
342
389
  autoReset, // W-mqvejug6000eeb20: tri-state. `true`/`false` short-circuits the
343
390
  // config-based resolution; `undefined` (the engine.js call shape)
344
391
  // defers to `_resolveAutoReset`.
392
+ autoBaseRepair, // W-mr3lunnq000o9f41: tri-state. `true`/`false` short-circuits
393
+ // config resolution; `undefined` (the engine.js call shape)
394
+ // defers to `_resolveAutoBaseRepair`. When enabled, the
395
+ // wrong-base recovery may materialize a local base branch from
396
+ // `refs/remotes/origin/<mainRef>` (no fetch) instead of failing.
345
397
  _git, // private injection for testing — defaults to shared.shellSafeGit
346
398
  _exists, // private injection for testing — defaults to fs.existsSync
347
399
  _rm, // private injection for testing — defaults to fs.rmSync (recursive, force)
348
400
  _resolveAutoReset, // private injection for testing — defaults to config-based resolver
401
+ _resolveAutoBaseRepair, // private injection for testing — defaults to config-based resolver
349
402
  _writeInboxNote, // private injection for testing — defaults to dispatch.writeInboxAlert
350
403
  _sleep, // private injection for testing — defaults to a real setTimeout-based sleep
351
404
  } = opts;
@@ -755,10 +808,151 @@ async function prepareLiveCheckout(opts = {}) {
755
808
  return { ok: true, branch: branchName, created: false, originalRef, originalRefType };
756
809
  }
757
810
 
758
- // New branch — create from the current HEAD (issue #226). NOT
759
- // origin/<mainRef>: in live mode the operator's HEAD is the baseline, and
760
- // seeding from a remote ref forces auth-less GVFS blob fetches that fail
761
- // on partial clones in headless mode.
811
+ // New branch — but FIRST guard against forking off a STALE non-main HEAD.
812
+ //
813
+ // ── (4d) STALE-BASE GUARD (W-mr3lunnq000o9f41). ───────────────────────
814
+ // The branch does not exist locally, so we are about to FORK A NEW branch.
815
+ // `git checkout -b <branch>` seeds it from the CURRENT HEAD (issue #226 — we
816
+ // never fetch origin/<mainRef> in live mode). That is safe ONLY when HEAD is
817
+ // already on the project's base branch. If a human (or a prior dispatch) left
818
+ // the operator checkout sitting on some unrelated topic/feature branch, the
819
+ // new branch — and therefore its PR — would SILENTLY inherit every commit
820
+ // already on that topic branch. That is scope contamination baked into git
821
+ // history, not a stray uncommitted diff, so it survives the dirty-tree and
822
+ // mid-operation preflights above (which only check for uncommitted changes and
823
+ // in-progress operations, never "is HEAD on the expected base?"). This is a
824
+ // real, observed incident: ADO PR 5411214 silently carried ~22 unrelated OCM
825
+ // files that the PR author disavowed.
826
+ //
827
+ // `allowNonMainBase` opts a dispatch OUT of the guard: PR-targeted fixes and
828
+ // shared-branch continuations INTEND to keep building on a non-main branch, so
829
+ // the caller passes it for those legitimate continuations. For a plain
830
+ // fresh-branch dispatch the guard is active.
831
+ //
832
+ // Recovery is LOCAL-ONLY to preserve issue #226's no-forced-fetch guarantee:
833
+ // if a local `refs/heads/<mainRef>` already exists we PLAIN-checkout it (no
834
+ // fetch, no reset, no --force — the tree was verified clean at Step 1) and
835
+ // fork off it. If no local mainRef ref is resolvable we normally FAIL CLOSED
836
+ // with { ok:false, reason:'wrong-base' } — mirroring the dirty/mid-operation
837
+ // philosophy of refusing on ambiguous operator state rather than silently
838
+ // forking off the wrong base.
839
+ //
840
+ // ── (4d-repair) OPT-IN AUTO-BASE-REPAIR (W-mr3lunnq000o9f41). ──────────
841
+ // When `autoBaseRepair` is enabled AND no local base branch exists, we fall
842
+ // back to the `refs/remotes/origin/<mainRef>` remote-tracking ref — a purely
843
+ // LOCAL ref (NO `git fetch`, so issue #226 is preserved) that points at the
844
+ // CORRECT project base. We materialize a local `<mainRef>` from it (a plain
845
+ // ref op) and proceed exactly like the local-main path. origin/<mainRef> IS
846
+ // the base, so there is NO scope contamination (the whole point of this guard
847
+ // survives). The subsequent `git checkout <mainRef>` may hydrate blobs on a
848
+ // partial clone; that is bounded by the git wrapper timeout and, on failure,
849
+ // falls through to the SAME fail-closed wrong-base refusal (strictly no worse
850
+ // than the non-repair path). Opt-in because the checkout hydration is the one
851
+ // operation issue #226 flagged as hang-prone in headless sessions.
852
+ if (!allowNonMainBase) {
853
+ // HEAD's branch — captured at Step 3 as originalRef (symbolic-ref --short
854
+ // HEAD) when originalRefType is 'branch'; fall back to the abbrev-ref read
855
+ // at Step 4a. Detached HEAD was already refused at Step 2, so on this path
856
+ // originalRefType is normally 'branch'.
857
+ const headBranch = (originalRefType === 'branch' && originalRef)
858
+ ? originalRef
859
+ : (curBranch || '');
860
+ if (headBranch !== mainRef) {
861
+ let localMainExists = false;
862
+ try {
863
+ await git(['rev-parse', '--verify', '--quiet', `refs/heads/${mainRef}`], baseOpts);
864
+ localMainExists = true;
865
+ } catch {
866
+ localMainExists = false;
867
+ }
868
+
869
+ if (!localMainExists) {
870
+ // Resolve the opt-in auto-base-repair decision (explicit boolean wins;
871
+ // else config-based resolver; else fail closed as before).
872
+ let wantBaseRepair = false;
873
+ if (typeof autoBaseRepair === 'boolean') {
874
+ wantBaseRepair = autoBaseRepair;
875
+ } else {
876
+ const resolver = (typeof _resolveAutoBaseRepair === 'function') ? _resolveAutoBaseRepair : _defaultResolveAutoBaseRepair;
877
+ try { wantBaseRepair = !!resolver(localPath); } catch { wantBaseRepair = false; }
878
+ }
879
+
880
+ let originMainExists = false;
881
+ if (wantBaseRepair) {
882
+ try {
883
+ await git(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${mainRef}`], baseOpts);
884
+ originMainExists = true;
885
+ } catch {
886
+ originMainExists = false;
887
+ }
888
+ }
889
+
890
+ if (!wantBaseRepair || !originMainExists) {
891
+ return {
892
+ ok: false,
893
+ reason: 'wrong-base',
894
+ headBranch,
895
+ expectedBase: mainRef,
896
+ localMainExists: false,
897
+ autoBaseRepair: !!wantBaseRepair,
898
+ originMainExists,
899
+ originalRef,
900
+ originalRefType,
901
+ };
902
+ }
903
+
904
+ // Materialize a local <mainRef> from the remote-tracking ref. This is a
905
+ // pure ref op (NO fetch, NO checkout, NO hydration). --no-track keeps it
906
+ // a plain local branch. localMainExists then flows into the shared
907
+ // checkout below, so the hydration cost is identical to the ordinary
908
+ // local-main path.
909
+ try {
910
+ await git(['branch', '--no-track', mainRef, `refs/remotes/origin/${mainRef}`], baseOpts);
911
+ localMainExists = true;
912
+ logFn('info',
913
+ `live-checkout auto-base-repair: no local '${mainRef}' — created it from 'origin/${mainRef}' (no fetch, issue #226 preserved) to recover base for forking '${branchName}'`);
914
+ } catch (repairErr) {
915
+ return {
916
+ ok: false,
917
+ reason: 'wrong-base',
918
+ headBranch,
919
+ expectedBase: mainRef,
920
+ localMainExists: false,
921
+ autoBaseRepair: true,
922
+ originMainExists: true,
923
+ baseRepairError: String((repairErr && repairErr.message) || '').slice(0, 500),
924
+ originalRef,
925
+ originalRefType,
926
+ };
927
+ }
928
+ }
929
+
930
+ // Local mainRef ref now exists (either originally, or just materialized
931
+ // from origin/<mainRef> by auto-base-repair) — switch to it using purely
932
+ // LOCAL refs (NO fetch, NO --force), then fork the new branch off it below.
933
+ try {
934
+ await git(['checkout', mainRef], baseOpts);
935
+ } catch (switchErr) {
936
+ return {
937
+ ok: false,
938
+ reason: 'wrong-base',
939
+ headBranch,
940
+ expectedBase: mainRef,
941
+ localMainExists: true,
942
+ switchError: String((switchErr && switchErr.message) || '').slice(0, 500),
943
+ originalRef,
944
+ originalRefType,
945
+ };
946
+ }
947
+ logFn('info',
948
+ `live-checkout: HEAD was on '${headBranch}' (not base '${mainRef}') — switched to local '${mainRef}' before forking '${branchName}' (no fetch, issue #226 preserved)`);
949
+ }
950
+ }
951
+
952
+ // Create from HEAD — now guaranteed to be the project base branch unless
953
+ // allowNonMainBase bypassed the guard. NOT origin/<mainRef>: in live mode the
954
+ // operator's HEAD is the baseline, and seeding from a remote ref forces
955
+ // auth-less GVFS blob fetches that fail on partial clones in headless mode.
762
956
  const failedCreate = await runCheckout(['checkout', '-b', branchName], { creating: true });
763
957
  if (failedCreate) return failedCreate;
764
958
  return { ok: true, branch: branchName, created: true, originalRef, originalRefType };
@@ -110,7 +110,7 @@ function getPrFetchInstructions(project) {
110
110
  `If \`az\` is unavailable, fall back to the ADO REST API: get a token with \`az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv\` and \`GET .../_apis/git/repositories/<repoId>/pullrequests/<number>?api-version=7.1\` (Bearer auth). Do not use \`gh\` for Azure DevOps repositories.`;
111
111
  }
112
112
 
113
- function getPrVoteInstructions(project) {
113
+ function getPrVoteInstructions(project, config) {
114
114
  const host = getRepoHost(project);
115
115
  const repoId = project?.repositoryId || '';
116
116
  if (host === 'github') {
@@ -131,7 +131,20 @@ function getPrVoteInstructions(project) {
131
131
  `When you author the summary body, append the collapsible **"Harnesses used"** \`<details>\` section described in the shared rules (mirror of your \`harnessUsed\` completion field) so reviewers see which skills / MCPs / docs informed the change; omit it when you used none.\n\n` +
132
132
  `**Brand link:** whenever the body names "Minions" (e.g. the \`Reviewed by Minions (…)\` sign-off), render it as \`[Minions](${MINIONS_BRAND_URL})\` per the shared rules. On the GitHub path the engine also auto-links the first bare \`… by Minions\` trailer as a backstop (\`engine/comment-format.js#linkifyBrandTrailer\`), but write the link yourself so the first occurrence is correct.`;
133
133
  }
134
- // Azure DevOps — prefer `az` CLI first, ADO REST API as fallback
134
+ // Azure DevOps — prefer `az` CLI first, ADO REST API as fallback.
135
+ // The platform reviewer vote is a POSITIVE mutation gated by autoApplyReviewVote
136
+ // ("Auto-apply review vote to PR", default OFF). When disabled, agents must NOT
137
+ // cast a platform vote — they post the verdict as a comment only (parity with
138
+ // GitHub, where self-approval is blocked). This is what keeps the toggle in
139
+ // full control of whether Minions ever cast votes on PRs.
140
+ const autoApplyReviewVote = config?.engine?.autoApplyReviewVote ?? ENGINE_DEFAULTS.autoApplyReviewVote;
141
+ if (!autoApplyReviewVote) {
142
+ return `**Do NOT cast a platform reviewer vote.** The operator has "Auto-apply review vote to PR" disabled, so Minions record verdicts as comments only — the human casts the final vote. Do NOT run \`az repos pr set-vote\` or PUT a reviewer vote via the REST API.\n\n` +
143
+ `Post your verdict as a thread comment. **Preferred:** \`minions pr comment <number> --host ado --ado-org ${getProjectOrg(project)} --ado-project ${project?.adoProject || '<ado-project>'} --repo-id ${repoId || '<repo-id>'} --agent <your-agent-id> --kind review-decision [--wi <work-item-id>] --body-file <verdict.md>\` (applies the marker + harness section + brand link for you). Fallback: \`az repos pr comment create --pull-request-id <number> --content @<verdict.md>\`\n\n` +
144
+ `The verdict body file's first non-marker line MUST be \`VERDICT: APPROVE\` or \`VERDICT: REQUEST_CHANGES\` — the engine parses this to record your verdict in local state.\n\n` +
145
+ `When you author the comment body, append the collapsible **"Harnesses used"** \`<details>\` section described in the shared rules (mirror of your \`harnessUsed\` completion field); omit it when you used none.\n\n` +
146
+ `**Brand link:** whenever the body names "Minions" (e.g. the \`Reviewed by Minions (…)\` sign-off), render it as \`Reviewed by [Minions](${MINIONS_BRAND_URL}) (…)\` per the shared rules. \`minions pr comment --host ado\` auto-links the first bare \`… by Minions\` trailer for you; the raw \`az repos pr comment\` fallback does NOT, so if you use it you MUST link it yourself.`;
147
+ }
135
148
  return `For Azure DevOps, use the \`az\` CLI first to set your reviewer vote:\n` +
136
149
  `- \`az repos pr set-vote --id <number> --vote {approve | approve-with-suggestions | reject | reset | wait-for-author}\`\n` +
137
150
  `- Pair the vote with a thread comment. **Preferred:** \`minions pr comment <number> --host ado --ado-org ${getProjectOrg(project)} --ado-project ${project?.adoProject || '<ado-project>'} --repo-id ${repoId || '<repo-id>'} --agent <your-agent-id> --kind review-decision [--wi <work-item-id>] --body-file <verdict.md>\` (applies the marker + harness section + brand link for you). Fallback: \`az repos pr comment create --pull-request-id <number> --content @<verdict.md>\`\n\n` +
@@ -149,7 +162,7 @@ function getRepoHostLabel(project) {
149
162
  function getRepoHostToolRule(project) {
150
163
  const host = getRepoHost(project);
151
164
  if (host === 'github') return 'Use GitHub MCP tools or `gh` CLI for PR operations';
152
- return 'For Azure DevOps, use the `az` CLI for PR operations (e.g. `az repos pr create`, `az repos pr show`, `az repos pr comment`, `az repos pr set-vote`); if `az` is unavailable or insufficient, fall back to the ADO REST API (`_apis/git/...`) with a token from `az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798`. Do not use `gh` for Azure DevOps repositories.';
165
+ return 'For Azure DevOps, use the `az` CLI for PR operations (e.g. `az repos pr create`, `az repos pr show`, `az repos pr comment`); if `az` is unavailable or insufficient, fall back to the ADO REST API (`_apis/git/...`) with a token from `az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798`. Do not use `gh` for Azure DevOps repositories. Whether to cast a reviewer vote (`az repos pr set-vote`) is governed separately by the PR vote instructions — do not vote unless they tell you to.';
153
166
  }
154
167
 
155
168
  // ─── Task Context Resolution ────────────────────────────────────────────────
@@ -967,7 +980,7 @@ function renderPlaybook(type, vars) {
967
980
  pr_create_instructions: getPrCreateInstructions(renderProject),
968
981
  pr_comment_instructions: getPrCommentInstructions(renderProject),
969
982
  pr_fetch_instructions: getPrFetchInstructions(renderProject),
970
- pr_vote_instructions: getPrVoteInstructions(renderProject),
983
+ pr_vote_instructions: getPrVoteInstructions(renderProject, config),
971
984
  repo_host_label: getRepoHostLabel(renderProject),
972
985
  };
973
986
  const allVars = { ...projectVars, ...vars };
package/engine/shared.js CHANGED
@@ -2963,6 +2963,25 @@ function resolveLiveCheckoutAutoReset(project, engine) {
2963
2963
  return false;
2964
2964
  }
2965
2965
 
2966
+ // W-mr3lunnq000o9f41 (companion to the stale-base guard) — resolve the
2967
+ // live-checkout auto-base-repair decision. Per-project `liveCheckoutAutoBaseRepair`
2968
+ // wins, else fleet-wide `engine.liveCheckoutAutoBaseRepair`, else false (opt-in).
2969
+ // When ON, prepareLiveCheckout's wrong-base recovery may materialize a local base
2970
+ // branch from the `refs/remotes/origin/<mainRef>` remote-tracking ref (a LOCAL ref
2971
+ // — no `git fetch`, so issue #226's no-forced-fetch guarantee is preserved) when
2972
+ // no local `refs/heads/<mainRef>` exists, instead of failing closed with
2973
+ // LIVE_CHECKOUT_WRONG_BASE. origin/<mainRef> IS the project base, so there is no
2974
+ // scope-contamination risk (unlike forking off the stale topic HEAD). Default OFF.
2975
+ function resolveLiveCheckoutAutoBaseRepair(project, engine) {
2976
+ if (project && typeof project === 'object' && typeof project.liveCheckoutAutoBaseRepair === 'boolean') {
2977
+ return project.liveCheckoutAutoBaseRepair;
2978
+ }
2979
+ if (engine && typeof engine === 'object' && typeof engine.liveCheckoutAutoBaseRepair === 'boolean') {
2980
+ return engine.liveCheckoutAutoBaseRepair;
2981
+ }
2982
+ return false;
2983
+ }
2984
+
2966
2985
  function validateCheckoutMode(value) {
2967
2986
  if (value === undefined || value === null || value === '') return undefined;
2968
2987
  if (typeof value !== 'string') {
@@ -3262,6 +3281,7 @@ const ENGINE_DEFAULTS = {
3262
3281
  maxRetries: 3, // max dispatch retries before marking work item as failed
3263
3282
  maxRetriesPerAgent: 2, // W-mpmwxn1j — per-agent retry cap. When the SAME agent fails the same WI this many times, the next retry MUST reassign to a different eligible agent (consults routing.md + agent availability). Falls back to the same agent only when no alternate is available. Counted separately from `maxRetries` (which caps total retries across all agents) and tracked on the WI as `_retriesByAgent: { agentId: count }`. Hard-pinned agents bypass reassignment (operator intent wins).
3264
3283
  maxPhantomRetries: 3, // max retries for "phantom completion" (runtime crashed before emitting type:"result"); tracked separately from _retryCount so phantom retries don't pollute the normal PR-attachment retry budget. See engine/lifecycle.markMissingPrAttachment + detectNonTerminalResultSummary.
3284
+ maxReviewVerdictMissingRetries: 2, // W-mr9sy3j700076967 — bounded retry budget for a review dispatch that completes ("success") without posting a VERDICT: comment. Below the budget, engine/lifecycle.updatePrAfterReview leaves `lastReviewedAt` unset so the normal needsReview gate re-dispatches a fresh review; at/above budget it stamps `lastReviewedAt` (stops auto-retry) but keeps `_reviewVerdictMissing: true` so the PR is dashboard-flagged for a human instead of looking like an ordinary still-pending review.
3265
3285
  minRetryGapMs: 120000, // 2min — minimum gap between retry dispatches for the same work item; prevents tight retry loops when an idempotent agent (e.g. review bailing out on a duplicate) cannot produce the expected output (#1770)
3266
3286
  pipelineApiRetries: 2, // max attempts for pipeline API calls
3267
3287
  pipelineApiRetryDelay: 2000, // ms delay between pipeline API retries
@@ -3421,7 +3441,7 @@ const ENGINE_DEFAULTS = {
3421
3441
  // stale PRD reopen can't smuggle in unvetted items.
3422
3442
  preDispatchEvalSkipPrdSourced: true,
3423
3443
  completionNonceRequired: false, // P-d2a8f6c1 (agent trust boundary F8): when true, a missing `nonce` field in the completion JSON hard-fails the dispatch with failure_class:'completion-nonce-mismatch'. Default false for one release so older agents/runtime caches that haven't picked up the prompt change degrade with a warning instead of breaking. Mismatched nonces hard-fail regardless of this flag. See docs/completion-reports.md → "Trust boundary".
3424
- autoApplyReviewVote: false, // W-mpea9fyb0010febf / P-f7splitgate: gates POSITIVE auto-actions only when true, the engine mirrors live platform vote state into local pull-requests.json reviewStatus (so platform-side approves auto-promote local status). When false (default), the verdict is recorded in pull-requests.json reviewStatus from the agent's completion only — informational. NEGATIVE-correction (dismissing the engine's own prior REQUEST_CHANGES / resetting its own prior negative ADO vote on a verdict flip to APPROVE) is correctness and runs UNCONDITIONALLY regardless of this flag.
3444
+ autoApplyReviewVote: false, // W-mpea9fyb0010febf / P-f7splitgate: master gate for ALL engine platform vote mutations. When true, the engine (a) mirrors live platform vote state into local pull-requests.json reviewStatus, (b) re-applies its own ADO +10 approval when ADO resets votes on target-branch advance (pollPrStatus), (c) reconciles its own prior negative on a verdict flip to APPROVE (ADO resetReviewerNegativeVote / GitHub dismissPriorViewerChangesRequestedReviews), and (d) instructs ADO review agents to cast `az repos pr set-vote`. When false (default), the verdict is recorded in pull-requests.json reviewStatus from the agent's completion ONLY — informational and Minions never cast, re-cast, or clear a platform vote/review (ADO agents post the verdict as a comment only, parity with GitHub self-approval block).
3425
3445
 
3426
3446
  // ── Runtime fleet (P-3b8e5f1d) ──────────────────────────────────────────────
3427
3447
  // Single source of truth for which CLI runtime + model every spawn uses.
@@ -4854,6 +4874,7 @@ const FAILURE_CLASS = {
4854
4874
  LIVE_CHECKOUT_MID_OPERATION: 'live-checkout-mid-operation', // P-a7f3c1d9 (live-checkout dispatch mode): spawnAgent could not switch/create the target branch in project.localPath because the operator tree is mid-operation — an in-progress merge/rebase/cherry-pick/bisect or a detached HEAD. Distinct from LIVE_CHECKOUT_DIRTY (uncommitted changes): here the tree may be clean but the branch op cannot proceed. Engine refuses to spawn (it never runs `git reset`/`git clean`/`git rebase --abort` against the operator tree). Non-retryable — operator must finish or abort the in-progress operation, or checkout a branch, before re-dispatch.
4855
4875
  LIVE_CHECKOUT_BLOB_FETCH: 'live-checkout-blob-fetch', // PL-live-checkout-reliability-hardening (live-checkout dispatch mode): `git checkout <existing-branch>` in project.localPath failed because the tree could not be materialized — on a Scalar/GVFS-managed ADO partial (blobless) clone, switching onto a branch whose tree differs from HEAD hydrates the changed paths' blobs through the GVFS cache server (`*.gvfscache.dev.azure.com`), a different endpoint than the main git remote that receives NO auth in the headless engine shell, so the fetch fails DETERMINISTICALLY. Distinct from LIVE_CHECKOUT_FAILED (transient) because retrying reproduces it identically — surfaced once as an operator-actionable refusal instead of retry-storming to the cap. Non-retryable — the operator hydrates the branch once with their own credentials (`git checkout <branch>` interactively, or `scalar prefetch` / `git -C <repo> fetch`), then re-dispatches. The engine NEVER forces, resets, cleans, or stashes the operator tree, and best-effort switches HEAD back to the original ref so the tree is not stranded half-populated.
4856
4876
  LIVE_CHECKOUT_WORKTREE_CONFLICT: 'live-checkout-worktree-conflict', // W-mr28h2j2000y0de1 (live-checkout dispatch mode): `git checkout <existing-branch>` in project.localPath failed because that exact branch is ALSO checked out in a SECOND worktree elsewhere (a leftover from a prior isolated-worktree dispatch, a manually-created worktree, or a stale worktree left by a checkoutMode change). git refuses deterministically with `fatal: '<branch>' is already used by worktree at '<path>'`. Distinct from LIVE_CHECKOUT_FAILED (transient) because this is a STRUCTURAL conflict — it does NOT clear on retry, ever, until a human or the engine removes/reassigns the other worktree; retrying just reproduces it identically and burns maxRetries. Non-retryable — the operator either `git worktree remove <path>` (freeing the branch) if that worktree is stale, or finishes/commits/pushes from it directly if it holds real WIP. The engine NEVER forces, resets, cleans, or stashes the operator tree, and best-effort switches HEAD back to the original ref so the tree is not stranded.
4877
+ LIVE_CHECKOUT_WRONG_BASE: 'live-checkout-wrong-base', // W-mr3lunnq000o9f41 (live-checkout dispatch mode): a NEW-branch fork was requested but the operator's checkout HEAD is on an unrelated topic/feature branch, NOT the project base (mainRef), and prepareLiveCheckout could not switch to a LOCAL base ref (no `refs/heads/<mainRef>`, or the local checkout itself failed). Forking off the stale HEAD would silently bake every commit already on that branch into the new branch AND its PR — committed-history scope contamination that survives the dirty/mid-operation preflights (the ADO PR 5411214 incident: ~22 unrelated OCM files, author-disavowed). The engine never fetches origin/<mainRef> in live mode (issue #226) so it cannot self-heal a missing local base. Non-retryable — the operator must `git checkout <mainRef>` in the operator checkout before re-dispatch. Does NOT fire for PR-targeted fixes or shared-branch continuations (those pass allowNonMainBase, intentionally continuing a non-main branch), nor when HEAD is already on mainRef, nor when a local mainRef ref exists (the guard switches to it and forks cleanly). OPT-IN AUTO-RECOVERY (`liveCheckoutAutoBaseRepair`, per-project > fleet-wide, default OFF): when enabled AND a `refs/remotes/origin/<mainRef>` remote-tracking ref exists, the guard materializes a local `<mainRef>` branch from it (a LOCAL ref op — still no `git fetch`, issue #226 preserved; origin/<mainRef> IS the base so no contamination) and forks cleanly instead of failing — eliminating the manual `git checkout <mainRef>` step. Still fails closed when neither a local base nor an origin-tracking base ref exists, or when the base checkout itself fails/hangs.
4857
4878
  INVALID_WORKDIR: 'invalid-workdir', // P-714ef144: dispatch carried a meta.workdir override that failed validation — non-string, absolute path, drive-letter prefix, null byte, ".." segment, or post-resolve containment escape against project.localPath / worktree root. Engine refuses to spawn (the subpath would either be unreachable on disk or point outside the operator's allowed surface). Non-retryable — operator must fix the WI's meta.workdir before re-dispatch. Inbox alert lists the offending value + the resolved-vs-base mismatch.
4858
4879
  MODEL_UNAVAILABLE: 'model-unavailable', // W-mpg6isvy000xca4d: requested model returned overloaded_error / 503 / service_unavailable. Retriable — engine swaps in the runtime-appropriate fallback model on next spawn (Claude leans on --fallback-model already plumbed; Copilot overrides --model with engine.copilotFallbackModel).
4859
4880
  WORKSPACE_MANIFEST_REPO: 'workspace-manifest-repo-forbidden', // W-mq07avbk000m5543: dispatch routed an agent to a project/repo not present in its workspace_manifest.allowed_repos. Structural — never retryable until the manifest is widened or a different agent is chosen.
@@ -9726,6 +9747,7 @@ module.exports = {
9726
9747
  resolveCheckoutMode,
9727
9748
  isLiveCheckoutProject,
9728
9749
  resolveLiveCheckoutAutoReset,
9750
+ resolveLiveCheckoutAutoBaseRepair,
9729
9751
  validatePid,
9730
9752
  PR_FIX_CAUSE,
9731
9753
  getPrFixAutomationCause,
package/engine.js CHANGED
@@ -2451,6 +2451,13 @@ async function spawnAgent(dispatchItem, config) {
2451
2451
  }
2452
2452
  if (liveMode && branchName && !READ_ONLY_ROOT_TASK_TYPES.has(type)) {
2453
2453
  const _liveMainRef = sanitizeBranch(shared.resolveMainBranch(cwd, project.mainBranch));
2454
+ // W-mr3lunnq000o9f41: PR-targeted fixes and shared-branch continuations
2455
+ // intentionally keep building on a non-main branch, so they opt OUT of the
2456
+ // stale-base guard in prepareLiveCheckout. `meta.useExistingBranch` is set
2457
+ // true for every PR-targeted WI and for shared-branch WIs (engine.js
2458
+ // dispatch meta), so it is the exact "this is a continuation, not a fresh
2459
+ // fork off baseline" signal.
2460
+ const _liveAllowNonMainBase = !!(meta?.useExistingBranch || meta?.branchStrategy === 'shared-branch');
2454
2461
  const _wiIdForAlert = meta?.item?.id || id;
2455
2462
  const _liveCheckout = require('./engine/live-checkout');
2456
2463
  let _liveResult;
@@ -2463,6 +2470,7 @@ async function spawnAgent(dispatchItem, config) {
2463
2470
  dispatchId: id,
2464
2471
  wiId: _wiIdForAlert,
2465
2472
  skipDirtyCheck: !!project.skipLiveCheckoutDirtyCheck,
2473
+ allowNonMainBase: _liveAllowNonMainBase,
2466
2474
  log: (msg, lvl) => log(lvl || 'info', msg),
2467
2475
  });
2468
2476
  } catch (liveErr) {
@@ -2694,6 +2702,75 @@ async function spawnAgent(dispatchItem, config) {
2694
2702
  cleanupTempAgent(agentId);
2695
2703
  return null;
2696
2704
  }
2705
+ // W-mr3lunnq000o9f41: stale non-main base refusal. A NEW-branch fork was
2706
+ // requested but the operator's checkout HEAD is on an unrelated branch, not
2707
+ // the project base (mainRef), and prepareLiveCheckout could not switch to a
2708
+ // LOCAL base ref (issue #226 forbids fetching origin/<mainRef> in live mode,
2709
+ // so the engine cannot self-heal a missing local base). Forking off the
2710
+ // stale HEAD would silently inherit every commit already on that branch into
2711
+ // the new branch and its PR (committed-history scope contamination — the ADO
2712
+ // PR 5411214 incident, which survived the dirty/mid-operation preflights).
2713
+ // Fail closed with a distinct non-retryable FAILURE_CLASS and operator-
2714
+ // actionable guidance; the engine never moves the operator's HEAD for them
2715
+ // beyond the local-only base switch the helper already attempted.
2716
+ if (_liveResult && _liveResult.ok === false && _liveResult.reason === 'wrong-base') {
2717
+ const _headBranch = typeof _liveResult.headBranch === 'string' && _liveResult.headBranch
2718
+ ? _liveResult.headBranch
2719
+ : '(unknown)';
2720
+ const _expectedBase = typeof _liveResult.expectedBase === 'string' && _liveResult.expectedBase
2721
+ ? _liveResult.expectedBase
2722
+ : _liveMainRef;
2723
+ const _switchErr = typeof _liveResult.switchError === 'string' ? _liveResult.switchError : '';
2724
+ const _alertBody = [
2725
+ '# Live-checkout blocked: HEAD is on the wrong base branch',
2726
+ '',
2727
+ `**Project:** ${project.name || '(unknown)'}`,
2728
+ `**Local path:** ${cwd}`,
2729
+ `**New branch (requested):** ${branchName}`,
2730
+ `**HEAD is on:** ${_headBranch}`,
2731
+ `**Expected base:** ${_expectedBase}`,
2732
+ `**Work item:** ${_wiIdForAlert}`,
2733
+ `**Dispatch:** ${id}`,
2734
+ '',
2735
+ `The engine refused to spawn the agent because \`${cwd}\` is checked out on \`${_headBranch}\`, not the project base \`${_expectedBase}\`. Forking the new branch \`${branchName}\` off \`${_headBranch}\` would silently inherit **every commit already on that branch** into the new branch — and into its PR — as committed history (this is exactly how ADO PR 5411214 picked up ~22 unrelated files). Live-checkout never fetches \`origin/${_expectedBase}\` for you (issue #226), and no local \`${_expectedBase}\` ref was usable, so the engine cannot safely establish the base itself.`,
2736
+ ...(_switchErr ? ['', 'The local base checkout failed with:', '', '```', _switchErr, '```'] : []),
2737
+ '',
2738
+ '## Recovery',
2739
+ '',
2740
+ `Switch the operator checkout back to the base branch, then re-dispatch the work item:`,
2741
+ '',
2742
+ '```',
2743
+ `git -C "${cwd}" checkout ${_expectedBase}`,
2744
+ '```',
2745
+ '',
2746
+ `If you deliberately want to stack this work on \`${_headBranch}\`, dispatch it as a PR-targeted or shared-branch work item instead — those intentionally continue a non-main branch and are exempt from this guard.`,
2747
+ ].join('\n');
2748
+ try { writeInboxAlert(`live-checkout-blocked-${_wiIdForAlert}`, _alertBody); }
2749
+ catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
2750
+ try {
2751
+ const _wiPath = resolveWorkItemPath(dispatchItem.meta);
2752
+ if (_wiPath && dispatchItem.meta?.item?.id) {
2753
+ mutateJsonFileLocked(_wiPath, (data) => {
2754
+ if (!Array.isArray(data)) return data;
2755
+ const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
2756
+ if (wi) wi._pendingReason = 'live_checkout_wrong_base';
2757
+ return data;
2758
+ });
2759
+ }
2760
+ } catch (e) { log('warn', `live-checkout: failed to stamp _pendingReason: ${e.message}`); }
2761
+ const _shortMsg = `live-checkout blocked: HEAD on '${_headBranch}' (not base '${_expectedBase}') in ${cwd} — refusing to fork ${branchName} off a stale base`;
2762
+ log('error', `spawnAgent: ${_shortMsg}`);
2763
+ _cleanupPromptFiles();
2764
+ completeDispatch(
2765
+ id,
2766
+ DISPATCH_RESULT.ERROR,
2767
+ _shortMsg.slice(0, 800),
2768
+ `Live-checkout refused to fork a new branch off '${_headBranch}' because it is not the project base '${_expectedBase}' — doing so would bake that branch's commits into the new PR (scope contamination). The engine never fetches origin in live mode (issue #226) and found no usable local base ref. Operator must \`git checkout ${_expectedBase}\` before re-dispatch. Retrying without moving HEAD is provably useless.`,
2769
+ { failureClass: FAILURE_CLASS.LIVE_CHECKOUT_WRONG_BASE, agentRetryable: false },
2770
+ );
2771
+ cleanupTempAgent(agentId);
2772
+ return null;
2773
+ }
2697
2774
  // PL-live-checkout-reliability-hardening: partial-clone / GVFS blob-fetch
2698
2775
  // refusal. The existing-branch `git checkout` could not materialize the tree
2699
2776
  // because the operator's checkout is a Scalar/GVFS blobless partial clone and
@@ -2786,6 +2863,7 @@ async function spawnAgent(dispatchItem, config) {
2786
2863
  dispatchId: id,
2787
2864
  wiId: _wiIdForAlert,
2788
2865
  skipDirtyCheck: !!project.skipLiveCheckoutDirtyCheck,
2866
+ allowNonMainBase: _liveAllowNonMainBase,
2789
2867
  log: (msg, lvl) => log(lvl || 'info', msg),
2790
2868
  });
2791
2869
  } catch (retryErr) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2341",
3
+ "version": "0.1.2343",
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"