@yemi33/minions 0.1.2305 → 0.1.2307

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/bin/minions.js +1 -0
  2. package/dashboard/js/refresh.js +17 -13
  3. package/dashboard/js/render-other.js +251 -3
  4. package/dashboard/js/render-plans.js +1 -1
  5. package/dashboard/js/render-prs.js +4 -1
  6. package/dashboard/js/render-utils.js +13 -0
  7. package/dashboard/js/render-work-items.js +11 -13
  8. package/dashboard/js/settings.js +30 -3
  9. package/dashboard/slim/body.html +6 -22
  10. package/dashboard/slim/js/modals-tiles.js +32 -22
  11. package/dashboard/slim/js/plans.js +24 -516
  12. package/dashboard/slim/styles.css +10 -7
  13. package/dashboard/styles.css +58 -0
  14. package/dashboard.js +66 -9
  15. package/docs/completion-reports.md +27 -2
  16. package/docs/copilot-cli-schema.md +31 -1
  17. package/docs/design-state-storage.md +1 -1
  18. package/docs/harness-transparency.md +18 -0
  19. package/docs/live-checkout-mode.md +57 -8
  20. package/engine/ado-comment.js +5 -2
  21. package/engine/ado.js +37 -0
  22. package/engine/cli.js +58 -3
  23. package/engine/comment-format.js +82 -2
  24. package/engine/consolidation.js +59 -2
  25. package/engine/discover-project-skills.js +4 -0
  26. package/engine/dispatch.js +36 -1
  27. package/engine/gh-comment.js +8 -3
  28. package/engine/lifecycle.js +241 -25
  29. package/engine/live-checkout.js +335 -7
  30. package/engine/playbook.js +2 -2
  31. package/engine/pre-dispatch-eval.js +54 -5
  32. package/engine/queries.js +11 -7
  33. package/engine/shared.js +96 -6
  34. package/engine/supervisor.js +144 -19
  35. package/engine/watchdog.js +10 -0
  36. package/engine.js +210 -5
  37. package/package.json +1 -1
  38. package/playbooks/review.md +2 -0
  39. package/playbooks/shared-rules.md +9 -0
@@ -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.
@@ -188,6 +220,65 @@ function _collectAutoCleanablePaths(dirtyFiles) {
188
220
  return cleanable.length > 0 ? cleanable : null;
189
221
  }
190
222
 
223
+ // issue #608 — detects a branch name that the ENGINE created for a live-mode
224
+ // dispatch (never the operator's own branch). Matches ONLY the literal
225
+ // `work/<wi-id>` convention produced by `shared.deriveWorkItemBranchName` —
226
+ // deliberately narrow so an operator's own `user/...`/`feature/...` branch is
227
+ // never mistaken for agent litter. Used by the orphaned-agent-branch self-heal
228
+ // below (prepareLiveCheckout) to recognize a stray branch left behind by an
229
+ // earlier dispatch whose dispatch-end restore did not complete.
230
+ function _looksLikeAgentBranch(name) {
231
+ return typeof name === 'string' && /^work\//.test(name.trim());
232
+ }
233
+
234
+ // issue #608 — pull the current local branch name out of the `## <branch>...`
235
+ // header line that `git status --porcelain=v1 -b` already returns, so the
236
+ // orphaned-agent-branch self-heal below needs ZERO extra git calls on the
237
+ // common (non-stray) dirty path — preserving the existing "no extra git calls
238
+ // when the tree isn't all-artifact-cleanable" contract asserted by
239
+ // test/unit/prepare-live-checkout.test.js. Returns '' for a detached HEAD
240
+ // (`## HEAD (no branch)`) or an unparsable header.
241
+ function _extractBranchFromPorcelainHeader(branchInfo) {
242
+ if (!branchInfo || typeof branchInfo !== 'string') return '';
243
+ const body = branchInfo.replace(/^##\s*/, '');
244
+ const firstToken = body.split(' ')[0] || '';
245
+ const localBranch = firstToken.split('...')[0];
246
+ return (!localBranch || localBranch === 'HEAD') ? '' : localBranch;
247
+ }
248
+
249
+ // issue #608 — commit whatever is staged/unstaged onto the CURRENT branch
250
+ // with bounded retry+backoff, specifically to survive a transient
251
+ // `.git/index.lock` collision against a sibling git process (the root cause
252
+ // of the 2026-06-30 incident: two live-checkout dispatches raced `git add -A`
253
+ // / `git commit` against the same working tree, the commit lost the lock race
254
+ // once, and the prior code gave up on the first failure with no retry). Only
255
+ // RETRIES when the failure looks like transient lock contention; any other
256
+ // failure (e.g. "nothing to commit") returns immediately so callers fall
257
+ // through to their existing best-effort handling. Never throws — callers get
258
+ // `{ ok:false, error }` on exhaustion.
259
+ async function _commitAgentWipWithRetry(git, baseOpts, message, options = {}) {
260
+ const attempts = Number.isFinite(options.attempts) ? options.attempts : 3;
261
+ const baseDelayMs = Number.isFinite(options.baseDelayMs) ? options.baseDelayMs : 300;
262
+ const sleep = typeof options.sleep === 'function'
263
+ ? options.sleep
264
+ : (ms) => new Promise((resolve) => setTimeout(resolve, ms));
265
+ let lastErr = null;
266
+ for (let attempt = 1; attempt <= attempts; attempt++) {
267
+ try {
268
+ await git(['add', '-A'], baseOpts);
269
+ await git(['commit', '--no-verify', '-m', message], baseOpts);
270
+ return { ok: true, attempts: attempt };
271
+ } catch (e) {
272
+ lastErr = e;
273
+ const msg = String((e && e.message) || '');
274
+ const transientLock = /index\.lock/i.test(msg) || /another git process/i.test(msg);
275
+ if (!transientLock || attempt === attempts) break;
276
+ await sleep(baseDelayMs * attempt);
277
+ }
278
+ }
279
+ return { ok: false, error: lastErr, attempts };
280
+ }
281
+
191
282
  // W-mqvejug6000eeb20 — production default for resolving the live-checkout
192
283
  // auto-reset decision when the caller (engine.js spawnAgent) does not pass an
193
284
  // explicit `autoReset` boolean. engine.js is intentionally not threading the
@@ -237,6 +328,7 @@ async function prepareLiveCheckout(opts = {}) {
237
328
  _rm, // private injection for testing — defaults to fs.rmSync (recursive, force)
238
329
  _resolveAutoReset, // private injection for testing — defaults to config-based resolver
239
330
  _writeInboxNote, // private injection for testing — defaults to dispatch.writeInboxAlert
331
+ _sleep, // private injection for testing — defaults to a real setTimeout-based sleep
240
332
  } = opts;
241
333
 
242
334
  // ── Required-arg guards. Throw rather than return {ok:false} so a
@@ -319,6 +411,52 @@ async function prepareLiveCheckout(opts = {}) {
319
411
  const reRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
320
412
  ({ branchInfo, dirtyFiles } = parsePorcelain(reRaw));
321
413
  }
414
+ // issue #608 — orphaned-agent-branch self-heal. If the tree is STILL dirty
415
+ // and HEAD is sitting on a DIFFERENT engine-created branch than the one
416
+ // THIS dispatch is about to use, the dirt is provably leftover from an
417
+ // earlier dispatch whose dispatch-end restore never completed (e.g. an
418
+ // `.git/index.lock` race against a sibling dispatch — see
419
+ // restoreLiveCheckoutAtDispatchEnd). Left alone, the ORIGINAL-REF CAPTURE
420
+ // below (Step 3) would adopt that stray branch as `originalRef`, and every
421
+ // future dispatch would inherit it in turn — the tree never stops
422
+ // orbiting agent branches and every dispatch fails LIVE_CHECKOUT_DIRTY
423
+ // forever (321 incidents / 12 days). Since the branch was created BY the
424
+ // engine (matched narrowly via `_looksLikeAgentBranch` — the literal
425
+ // `work/<id>` convention from shared.deriveWorkItemBranchName, never the
426
+ // operator's own branch), committing the leftover WIP there is exactly as
427
+ // safe as the existing dispatch-end self-heal: additive, fully
428
+ // recoverable from that branch's own history, and never touches the
429
+ // operator's real branch. Unconditional (not opt-in) because it is
430
+ // non-destructive, unlike the auto-reset path below. After committing,
431
+ // switch back to `mainRef` — the one ref that is always a real,
432
+ // non-agent baseline — so the tree stops drifting onto agent branches.
433
+ if (dirtyFiles.length > 0) {
434
+ const strayBranch = _extractBranchFromPorcelainHeader(branchInfo);
435
+ if (strayBranch && strayBranch !== branchName && _looksLikeAgentBranch(strayBranch)) {
436
+ const commitRes = await _commitAgentWipWithRetry(
437
+ git, baseOpts,
438
+ `minions: auto-save orphaned WIP from ${strayBranch} (dispatch ${dispatchId || '(unknown)'})`,
439
+ { sleep: _sleep },
440
+ );
441
+ if (commitRes.ok) {
442
+ logFn(`[live-checkout] self-healed orphaned agent branch '${strayBranch}' — committed WIP` +
443
+ (commitRes.attempts > 1 ? ` after ${commitRes.attempts} attempts` : '') +
444
+ `, switching back to '${mainRef}'`, 'info');
445
+ try {
446
+ await git(['checkout', mainRef], baseOpts);
447
+ } catch (backErr) {
448
+ logFn(`[live-checkout] could not switch back to '${mainRef}' after self-healing '${strayBranch}': ${backErr && backErr.message}`, 'warn');
449
+ }
450
+ // Re-run the dirty check. Either the tree is now clean (self-heal
451
+ // succeeded end-to-end) or something new appeared (rare) — either
452
+ // way the normal auto-reset / dirty-bail logic below sees fresh state.
453
+ const healRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
454
+ ({ branchInfo, dirtyFiles } = parsePorcelain(healRaw));
455
+ } else {
456
+ logFn(`[live-checkout] could not self-heal orphaned agent branch '${strayBranch}' (commit failed): ${commitRes.error && commitRes.error.message}`, 'warn');
457
+ }
458
+ }
459
+ }
322
460
  if (dirtyFiles.length > 0) {
323
461
  // W-mqvejug6000eeb20 — opt-in auto-reset. Default behavior is to bail with
324
462
  // reason:'dirty' (spawnAgent translates to non-retryable LIVE_CHECKOUT_DIRTY
@@ -558,6 +696,18 @@ async function prepareLiveCheckout(opts = {}) {
558
696
  originalRefType,
559
697
  };
560
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
+ }
561
711
  throw checkoutErr; // transient → caller retries (LIVE_CHECKOUT_FAILED)
562
712
  }
563
713
  };
@@ -641,6 +791,7 @@ async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
641
791
  log,
642
792
  writeInboxAlert,
643
793
  _git, // private injection for testing — defaults to shared.shellSafeGit
794
+ _sleep, // private injection for testing — defaults to a real setTimeout-based sleep
644
795
  } = opts;
645
796
 
646
797
  const logFn = (typeof log === 'function') ? log : () => {};
@@ -750,16 +901,27 @@ async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
750
901
  onAgentBranch = cur && cur === branchName;
751
902
  } catch { /* best-effort */ }
752
903
  if (onAgentBranch) {
753
- try {
754
- await git(['add', '-A'], baseOpts);
755
- // --no-verify: skip operator commit hooks that may fail headless.
756
- await git(['commit', '--no-verify', '-m', `minions: auto-save agent WIP (dispatch ${did})`], baseOpts);
904
+ // issue #608 — bounded retry+backoff on the commit. The 2026-06-30
905
+ // incident was caused by exactly this commit losing a transient
906
+ // `.git/index.lock` race against a sibling dispatch's git process;
907
+ // the previous single-attempt code gave up immediately and left the
908
+ // stage half-committed, which then perpetuated as a stray agent
909
+ // branch for every subsequent dispatch (see the orphaned-agent-branch
910
+ // self-heal in prepareLiveCheckout). `--no-verify` skips operator
911
+ // commit hooks that may fail headless.
912
+ const commitRes = await _commitAgentWipWithRetry(
913
+ git, baseOpts,
914
+ `minions: auto-save agent WIP (dispatch ${did})`,
915
+ { sleep: _sleep },
916
+ );
917
+ if (commitRes.ok) {
757
918
  result.autoCommitted = true;
758
- logFn('info', `live-checkout: auto-committed agent WIP onto ${branchName} before restore (dispatch ${did})`);
759
- } catch (commitErr) {
919
+ logFn('info', `live-checkout: auto-committed agent WIP onto ${branchName} before restore (dispatch ${did})` +
920
+ (commitRes.attempts > 1 ? ` after ${commitRes.attempts} attempts` : ''));
921
+ } else {
760
922
  // Leave the tree as-is; the plain checkout below will refuse and the
761
923
  // fallback alert fires. Never force, never discard.
762
- logFn('warn', `live-checkout: could not auto-save agent WIP onto ${branchName} for ${did}: ${commitErr && commitErr.message}`);
924
+ logFn('warn', `live-checkout: could not auto-save agent WIP onto ${branchName} for ${did}: ${commitRes.error && commitRes.error.message}`);
763
925
  }
764
926
  }
765
927
  }
@@ -836,6 +998,164 @@ async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
836
998
  }
837
999
  }
838
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
+
839
1159
  /**
840
1160
  * maybeRestoreLiveCheckoutFromRecord — PL-live-checkout-reliability-hardening
841
1161
  *
@@ -902,9 +1222,17 @@ async function maybeRestoreLiveCheckoutFromRecord(opts = {}) {
902
1222
  module.exports = {
903
1223
  prepareLiveCheckout,
904
1224
  restoreLiveCheckoutAtDispatchEnd,
1225
+ resolveLiveCheckoutAutoStash,
1226
+ performLiveCheckoutAutoStash,
1227
+ applyLiveCheckoutAutoStash,
905
1228
  maybeRestoreLiveCheckoutFromRecord,
906
1229
  _isPartialCloneBlobError,
1230
+ _isWorktreeConflictError,
1231
+ _extractConflictingWorktreePath,
907
1232
  _isAutoCleanableArtifact,
908
1233
  _collectAutoCleanablePaths,
1234
+ _looksLikeAgentBranch,
1235
+ _extractBranchFromPorcelainHeader,
1236
+ _commitAgentWipWithRetry,
909
1237
  LIVE_CHECKOUT_AUTO_CLEAN_PATTERNS,
910
1238
  };
@@ -13,7 +13,7 @@ const { wrapUntrusted, buildSource } = require('./untrusted-fence');
13
13
  const { MINIONS_BRAND_URL } = require('./comment-format');
14
14
 
15
15
  const { safeJson, safeRead, getProjects, log, ts, dateStamp, truncateTextBytes, ENGINE_DEFAULTS, WI_STATUS, WORK_TYPE, PR_STATUS, DISPATCH_RESULT, getProjectOrg } = shared;
16
- const { getConfig, getDispatch, getNotes, getAgentCharter, getPrs, getKnowledgeBaseIndex, AGENTS_DIR } = queries;
16
+ const { getConfig, getDispatch, getNotes, getPrs, getKnowledgeBaseIndex, AGENTS_DIR } = queries;
17
17
 
18
18
  const MINIONS_DIR = shared.MINIONS_DIR;
19
19
  const PLAYBOOKS_DIR = path.join(MINIONS_DIR, 'playbooks');
@@ -1052,7 +1052,7 @@ function _checkPlaybookSections(content, context = '') {
1052
1052
  // Lean system prompt: agent identity + rules only (~2-4KB, never grows)
1053
1053
  function buildSystemPrompt(agentId, config, project) {
1054
1054
  const agent = config.agents[agentId] || tempAgents.get(agentId) || { name: agentId, role: 'Temporary Agent', expertise: [] };
1055
- const charter = getAgentCharter(agentId); // returns '' for temp agents (no charter file)
1055
+ const charter = safeRead(path.join(AGENTS_DIR, agentId, 'charter.md')); // returns '' for temp agents (no charter file)
1056
1056
  project = project || getProjects(config)[0] || {};
1057
1057
 
1058
1058
  let prompt = '';
@@ -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,
package/engine/queries.js CHANGED
@@ -722,10 +722,6 @@ function getAgentStatus(agentId) {
722
722
  // setAgentStatus removed — agent status is derived from dispatch.json.
723
723
  // Status.json files no longer exist.
724
724
 
725
- function getAgentCharter(agentId) {
726
- return safeRead(path.join(AGENTS_DIR, agentId, 'charter.md'));
727
- }
728
-
729
725
  function getAgents(config) {
730
726
  config = config || getConfig();
731
727
  // Fall back to DEFAULT_AGENTS if config has no agents (uninitialized repo)
@@ -2063,13 +2059,21 @@ function getWorkItems(config, opts) {
2063
2059
  });
2064
2060
 
2065
2061
  const cacheMtimes = _snapshotWorkItemsMtimes(config);
2062
+ // Use Date.now() here (not the `now` captured at function entry) so the
2063
+ // 1 s TTL is measured from when the cache was BUILT, not from when the
2064
+ // call started. On a slow or loaded machine the first cold call can take
2065
+ // > 1 s while DB migrations run; if we used the entry-time `now`, the
2066
+ // very next call would see (entry_time_of_call_2 − entry_time_of_call_1)
2067
+ // >= 1 000 ms and miss the cache, defeating the cache entirely.
2068
+ // (W-mr00aooc000i1d6b — queries-work-items-cache CI failure on slow runners)
2069
+ const cacheSetAt = Date.now();
2066
2070
  if (enrich) {
2067
2071
  _workItemsCache = allItems;
2068
- _workItemsCacheAt = now;
2072
+ _workItemsCacheAt = cacheSetAt;
2069
2073
  _workItemsCacheMtimes = cacheMtimes;
2070
2074
  } else {
2071
2075
  _workItemsLeanCache = allItems;
2072
- _workItemsLeanCacheAt = now;
2076
+ _workItemsLeanCacheAt = cacheSetAt;
2073
2077
  _workItemsLeanCacheMtimes = cacheMtimes;
2074
2078
  }
2075
2079
  return allItems;
@@ -3320,7 +3324,7 @@ module.exports = {
3320
3324
  _buildNotesByWiMap, // exported for testing
3321
3325
 
3322
3326
  // Agents
3323
- getAgentStatus, getAgentCharter, getAgents, getAgentDetail,
3327
+ getAgentStatus, getAgents, getAgentDetail,
3324
3328
 
3325
3329
  // Pull requests
3326
3330
  getPrs, getPullRequests,