@yemi33/minions 0.1.2237 → 0.1.2239

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.
@@ -65,6 +65,26 @@ function _capNotesPreservingOverflow(newContent) {
65
65
  return kept;
66
66
  }
67
67
 
68
+ // Read notes.md for an in-lock append, distinguishing "absent" from
69
+ // "present but unreadable". The consolidation write paths run inside the
70
+ // NOTES_PATH lock and append `current + entry`. getNotes()/safeRead swallow
71
+ // ALL errors and return '' (shared.js:691-692), so a transient EBUSY/EACCES/
72
+ // lock on a NON-EMPTY notes.md would make `'' + entry` clobber the entire
73
+ // file — a confirmed total-loss vector for accumulated team notes. The lock
74
+ // guarantees exclusion, not a correct read of its own target. safeReadOrNull
75
+ // (shared.js:700) lets us tell the two states apart:
76
+ // - string (incl. '') → readable; use it (verbatim empty file is fine)
77
+ // - null + file ABSENT → first-ever consolidation; treat as ''
78
+ // - null + file PRESENT → unreadable; return null so the caller ABORTS
79
+ // the cycle (leave inbox notes to retry next tick)
80
+ function _readNotesForAppendOrNull() {
81
+ const body = shared.safeReadOrNull(NOTES_PATH);
82
+ if (body !== null) return body;
83
+ // null: a genuinely missing file is the benign first-run case ('');
84
+ // a file that EXISTS but failed to read must abort to avoid clobbering it.
85
+ return fs.existsSync(NOTES_PATH) ? null : '';
86
+ }
87
+
68
88
  // Per-agent memory files live under knowledge/agents/<agent>.md and are
69
89
  // injected into individual agent prompts (in addition to the broadcast
70
90
  // notes.md). See knowledge/agents/README.md for the convention.
@@ -920,7 +940,7 @@ function consolidateWithLLM(items, existingNotes, files, config) {
920
940
  }, 10000);
921
941
  }, 180000);
922
942
 
923
- llmCall.then((result) => {
943
+ llmCall.then(async (result) => {
924
944
  if (_cleared) return;
925
945
  clearTimeout(timeoutHandle);
926
946
  trackEngineUsage('consolidation', result.usage);
@@ -947,15 +967,31 @@ function consolidateWithLLM(items, existingNotes, files, config) {
947
967
 
948
968
  const entry = '\n\n---\n\n' + digest;
949
969
  // Wrap read-modify-write in file lock to prevent race with concurrent consolidation or manual edits
950
- shared.withFileLock(NOTES_PATH + '.lock', () => {
951
- const current = getNotes() || '';
970
+ const wrote = shared.withFileLock(NOTES_PATH + '.lock', () => {
971
+ const current = _readNotesForAppendOrNull();
972
+ if (current === null) {
973
+ // notes.md exists but is unreadable (transient lock/EBUSY/EACCES).
974
+ // ABORT rather than rebuild the file from '' + entry (full-file
975
+ // clobber). Leave inbox notes in place to retry next cycle.
976
+ log('warn', 'LLM consolidation aborted: notes.md present but unreadable — preserving existing content, retrying next cycle');
977
+ return false;
978
+ }
952
979
  // DATA-LOSS GUARD: cap size but archive the overflow instead of
953
980
  // silently discarding it (see _capNotesPreservingOverflow).
954
981
  const newContent = _capNotesPreservingOverflow(current + entry);
955
982
  safeWrite(NOTES_PATH, newContent);
983
+ return true;
956
984
  });
957
- classifyToKnowledgeBase(items, config);
958
- archiveInboxFiles(files);
985
+ if (!wrote) {
986
+ // Unreadable notes.md: do NOT classify to KB or archive inbox — the
987
+ // notes survive on disk for the next tick to retry.
988
+ log('info', `LLM consolidation deferred: notes.md unreadable, ${files.length} notes left in inbox for retry`);
989
+ return;
990
+ }
991
+ // P-8c2b471f: archive only after per-agent memory routing resolves;
992
+ // skip notes whose eligible memory write failed (retried next pass).
993
+ const deferred = await classifyToKnowledgeBase(items, config);
994
+ archiveInboxFiles(files.filter(f => !deferred.has(f)));
959
995
  log('info', `LLM consolidation complete: ${files.length} notes processed`);
960
996
  } else {
961
997
  _fallback(`LLM consolidation failed (code=${result.code}) — falling back to regex`, result.stderr ? { message: result.stderr.slice(0, 500) } : null);
@@ -1068,20 +1104,39 @@ function consolidateWithRegex(items, files, config) {
1068
1104
  if (dupCount > 0) entry += `_Deduplication: ${dupCount} duplicate(s) removed._\n`;
1069
1105
 
1070
1106
  // Wrap read-modify-write in file lock to prevent race with concurrent consolidation or manual edits
1071
- shared.withFileLock(NOTES_PATH + '.lock', () => {
1072
- const current = getNotes() || '';
1107
+ const wrote = shared.withFileLock(NOTES_PATH + '.lock', () => {
1108
+ const current = _readNotesForAppendOrNull();
1109
+ if (current === null) {
1110
+ // notes.md exists but is unreadable (transient lock/EBUSY/EACCES).
1111
+ // ABORT rather than rebuild the file from '' + entry (full-file
1112
+ // clobber). Leave inbox notes in place to retry next cycle.
1113
+ log('warn', 'Regex consolidation aborted: notes.md present but unreadable \u2014 preserving existing content, retrying next cycle');
1114
+ return false;
1115
+ }
1073
1116
  // DATA-LOSS GUARD: cap size but archive the overflow (see LLM path).
1074
1117
  const newContent = _capNotesPreservingOverflow(current + entry);
1075
1118
  safeWrite(NOTES_PATH, newContent);
1119
+ return true;
1076
1120
  });
1077
- classifyToKnowledgeBase(items, config);
1078
- archiveInboxFiles(files);
1121
+ if (!wrote) {
1122
+ // Unreadable notes.md: do NOT classify to KB or archive inbox — the
1123
+ // notes survive on disk for the next tick to retry.
1124
+ log('info', `Regex consolidation deferred: notes.md unreadable, ${files.length} notes left in inbox for retry`);
1125
+ return;
1126
+ }
1127
+ // P-8c2b471f: keep consolidateWithRegex synchronous — the notes.md lock
1128
+ // error above must still surface as a sync throw before any archiving.
1129
+ // Gate inbox archiving on per-agent memory routing via the returned promise
1130
+ // so a transient memory-append failure defers (not loses) the inbox note.
1131
+ Promise.resolve(classifyToKnowledgeBase(items, config))
1132
+ .then((deferred) => archiveInboxFiles(files.filter(f => !deferred.has(f))))
1133
+ .catch((err) => log('warn', `agent-memory routing/archive failed: ${err?.message || err}`));
1079
1134
  log('info', `Regex fallback: consolidated ${files.length} notes \u2192 ${deduped.length} insights into notes.md`);
1080
1135
  }
1081
1136
 
1082
1137
  // ─── Knowledge Base Classification ───────────────────────────────────────────
1083
1138
 
1084
- function classifyToKnowledgeBase(items, config) {
1139
+ async function classifyToKnowledgeBase(items, config) {
1085
1140
 
1086
1141
  if (!fs.existsSync(KNOWLEDGE_DIR)) fs.mkdirSync(KNOWLEDGE_DIR, { recursive: true });
1087
1142
 
@@ -1098,6 +1153,9 @@ function classifyToKnowledgeBase(items, config) {
1098
1153
  }
1099
1154
 
1100
1155
  let classified = 0;
1156
+ // P-8c2b471f: per-item per-agent-memory routing outcomes, awaited after the
1157
+ // loop so inbox archiving can be gated on the memory write (see end of fn).
1158
+ const memoryRoutes = [];
1101
1159
  for (const item of items) {
1102
1160
  const content = item.content || '';
1103
1161
  const rawCategory = classifyInboxItem(item.name, content);
@@ -1128,30 +1186,52 @@ function classifyToKnowledgeBase(items, config) {
1128
1186
  // Appends the inbox content to knowledge/agents/<agent>.md when the
1129
1187
  // author is a configured team member (skips temp-* and unknown agents).
1130
1188
  // When the new entry has contradiction signals, the reconcile pass calls
1131
- // Haiku to identify and rewrite stale facts before the append. Reconcile
1132
- // is fire-and-forget — any failure or hang falls back to plain append
1133
- // inside reconcileAndAppendToAgentMemory; the consolidation pipeline is
1134
- // never blocked on the LLM. (W-mpbi7qus0011bf77)
1189
+ // Haiku to identify and rewrite stale facts before the append. Any LLM
1190
+ // failure or hang falls back to plain append inside
1191
+ // reconcileAndAppendToAgentMemory; the consolidation pipeline is never
1192
+ // blocked on the LLM. (W-mpbi7qus0011bf77)
1135
1193
  //
1136
1194
  // After every successful append, chain the optional sliding-window
1137
- // summary pass (W-mq07b8do000nc86a) — also fire-and-forget, disabled
1195
+ // summary pass (W-mq07b8do000nc86a) — fire-and-forget, disabled
1138
1196
  // by default (engine.agentMemorySummaryEnabled), and a strict no-op
1139
1197
  // when the entry-count and age triggers don't fire. The chain runs
1140
1198
  // for ALL writes (reconcile-edit AND plain-append paths), not just
1141
1199
  // the contradiction-signal fast path, so steady-state +1/-1 pruning
1142
- // can still build up enough evictions to trigger a fold.
1200
+ // can still build up enough evictions to trigger a fold. The summary
1201
+ // never gates archiving.
1202
+ //
1203
+ // P-8c2b471f: the memory-write outcome is tracked (not fire-and-forget)
1204
+ // so the loop's caller can archive the inbox note only AFTER the per-agent
1205
+ // append has completed. An ELIGIBLE route (known team agent, non-empty
1206
+ // body) whose write transiently fails is recorded so the note is deferred
1207
+ // — left in the inbox for the next consolidation pass — rather than
1208
+ // archived-and-forgotten. Ineligible routes (temp-*/unknown author, empty
1209
+ // body) legitimately skip the memory copy and stay archivable.
1210
+ const routeAgent = extractInboxAgent(item);
1211
+ const routeEligible = !!routeAgent
1212
+ && !routeAgent.startsWith('temp-')
1213
+ && !!knownAgents && knownAgents.has(routeAgent)
1214
+ && String(item?.content || '').trim().length > 0;
1143
1215
  try {
1144
- const agentForSummary = extractInboxAgent(item);
1145
1216
  const p = reconcileAndAppendToAgentMemory(item, knownAgents, config);
1146
- if (p && typeof p.then === 'function') {
1147
- p.then((ok) => {
1148
- if (!ok || !agentForSummary) return;
1149
- return maybeSummarizeAgentMemory(agentForSummary, config);
1150
- }).catch(err => log('warn', `agent-memory reconcile/append failed: ${err?.message || err}`));
1151
- }
1217
+ const tracked = Promise.resolve(p).then((ok) => {
1218
+ if (ok && routeAgent) {
1219
+ // Sliding-window summary follow-up (unchanged trigger) — kept
1220
+ // fire-and-forget so it never gates archiving.
1221
+ Promise.resolve(maybeSummarizeAgentMemory(routeAgent, config))
1222
+ .catch(err => log('warn', `agent-memory summary failed: ${err?.message || err}`));
1223
+ }
1224
+ return ok;
1225
+ }).catch((err) => {
1226
+ log('warn', `agent-memory reconcile/append failed: ${err?.message || err}`);
1227
+ return false;
1228
+ });
1229
+ memoryRoutes.push({ name: item.name, eligible: routeEligible, tracked });
1152
1230
  } catch (err) {
1153
1231
  log('warn', `agent-memory reconcile/append threw: ${err?.message || err}`);
1154
- appendToAgentMemory(item, knownAgents, config);
1232
+ // Synchronous plain-append fallback path (preserved).
1233
+ const ok = appendToAgentMemory(item, knownAgents, config);
1234
+ memoryRoutes.push({ name: item.name, eligible: routeEligible, tracked: Promise.resolve(ok) });
1155
1235
  }
1156
1236
  }
1157
1237
 
@@ -1168,6 +1248,21 @@ function classifyToKnowledgeBase(items, config) {
1168
1248
  }
1169
1249
  shared.mutateKbCheckpoint(() => ({ count, updatedAt: ts() }));
1170
1250
  } catch (err) { log('warn', `KB checkpoint: ${err.message}`); }
1251
+
1252
+ // P-8c2b471f: gate inbox archiving on per-agent memory routing completing.
1253
+ // Await each route's write outcome; defer archiving any ELIGIBLE note whose
1254
+ // memory append failed (transient lock/disk error) so a later consolidation
1255
+ // pass retries it. The broadcast notes.md + KB classification copies already
1256
+ // landed synchronously above — this only governs the per-agent-memory
1257
+ // durability of the archive step. Returns the set of inbox filenames the
1258
+ // caller must NOT archive this pass.
1259
+ const deferredArchive = new Set();
1260
+ for (const route of memoryRoutes) {
1261
+ let ok = false;
1262
+ try { ok = await route.tracked; } catch { ok = false; }
1263
+ if (route.eligible && !ok) deferredArchive.add(route.name);
1264
+ }
1265
+ return deferredArchive;
1171
1266
  }
1172
1267
 
1173
1268
  function archiveInboxFiles(files) {
@@ -17,6 +17,18 @@
17
17
 
18
18
  const SECTIONS = ['pending', 'active', 'completed', 'review'];
19
19
 
20
+ // First-touch guard (P-5f0e8c27). Tracks whether a dispatch mutation has
21
+ // written SQL in THIS process. Once SQL has been written, an empty read
22
+ // reflects a genuine prune-to-zero — NOT a fresh install — so we must not
23
+ // resurrect a stale dispatch.json (e.g. a crash between the SQL COMMIT and
24
+ // the post-commit mirror write leaves dispatch.json holding the pre-prune
25
+ // rows). This mirrors the `_resyncIfJsonDiverged` first-touch discipline the
26
+ // work-items / pull-requests / metrics / watches stores already use: once SQL
27
+ // is the established source of truth this process, JSON never overrides it.
28
+ // Reset only by process restart (module reload); test isolation re-requires
29
+ // the module via withFreshDb's cache-bust, so each test starts fresh.
30
+ let _sqlWrittenThisProcess = false;
31
+
20
32
  function _emptySectioned() {
21
33
  return { pending: [], active: [], completed: [], review: [] };
22
34
  }
@@ -48,15 +60,23 @@ function readDispatchSectioned() {
48
60
  ORDER BY status, created_at
49
61
  `).all();
50
62
 
51
- if (rows.length === 0) {
52
- // SQL has no live dispatches. Two scenarios:
53
- // (a) Production fresh install (or all pruned) JSON file is empty/missing,
54
- // fallback returns the same empty sectioned object. No harm.
63
+ if (rows.length === 0 && !_sqlWrittenThisProcess) {
64
+ // SQL has no live dispatches AND no mutation has written SQL this process.
65
+ // Only then is the JSON file a trustworthy fallback. Two scenarios:
66
+ // (a) Production fresh install JSON file is empty/missing, fallback
67
+ // returns the same empty sectioned object. No harm.
55
68
  // (b) Test that wrote dispatch.json directly (legacy test helpers in
56
69
  // timeout-behavioral / orphan-* / etc. — they bypass the proper
57
70
  // mutateDispatch API and seed via fs.writeFileSync). Picking up
58
71
  // the JSON keeps those tests working through the transition without
59
72
  // touching every helper. Phase 1.5 migrates them to the proper API.
73
+ //
74
+ // The `!_sqlWrittenThisProcess` gate is the first-touch guard: once a
75
+ // mutation has written SQL this process (e.g. the engine pruned the last
76
+ // dispatch to zero), an empty SQL read is the real state. Adopting a stale
77
+ // non-empty dispatch.json here would resurrect pruned records and could
78
+ // trigger a duplicate dispatch before the next mutation self-heals the
79
+ // mirror — the exact crash-window bug this guard closes.
60
80
  const fallback = _readDispatchJsonFallback();
61
81
  const hasContent = (
62
82
  (fallback.pending && fallback.pending.length > 0) ||
@@ -250,6 +270,9 @@ function applyDispatchMutation(mutator) {
250
270
  }
251
271
  const diff = _computeDispatchDiff(beforeSnapshot, after);
252
272
  const wrote = _applyDispatchDiff(db, diff);
273
+ // First-touch guard: record that SQL was written this process so a later
274
+ // empty read won't resurrect a stale dispatch.json (see readDispatchSectioned).
275
+ if (wrote) _sqlWrittenThisProcess = true;
253
276
  return { wrote, result: after };
254
277
  });
255
278
  }
@@ -518,6 +518,7 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
518
518
  FAILURE_CLASS.MANAGED_SPAWN_HEALTHCHECK_FAILED, // W-mpbhxg3b000u8411 — healthcheck timed out; agent must fix the spec or the service it spawned
519
519
  FAILURE_CLASS.INJECTION_FLAGGED, // F5 (W-mpeklod3000we69c) — agent spotted a prompt-injection attempt in spliced untrusted content; a human must review the source before re-dispatch
520
520
  FAILURE_CLASS.LIVE_CHECKOUT_DIRTY, // P-a3f9b204 — live-checkout refused to spawn because operator localPath is dirty; mechanical retry won't fix it (operator must commit/stash/discard)
521
+ 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)
521
522
  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)
522
523
  ]);
523
524
  if (neverRetry.has(failureClass)) return false;
package/engine/github.js CHANGED
@@ -154,7 +154,10 @@ function _isNonActionableComment(c, config = {}) {
154
154
  const ignoredAuthors = new Set((config.engine?.ignoredCommentAuthors || []).map(a => String(a).toLowerCase()));
155
155
  const login = String(c?.user?.login || '').toLowerCase();
156
156
  if (ignoredAuthors.has(login)) return true;
157
- if (_isGitHubBotComment(c)) return true;
157
+ // W-mqr41te300026723: removed blanket _isGitHubBotComment author-type drop.
158
+ // Bot comments are now filtered by CONTENT (isPreviewStatusBody below), not
159
+ // author type — symmetric with ADO's pollPrHumanComments which has no
160
+ // user.type==='bot' equivalent and relies solely on body-content + marker gates.
158
161
  if (_isAgentComment(c)) return true;
159
162
  // P-f23classifier (F2): body-only preview/CI-report check is now host-
160
163
  // agnostic. The legacy `_isCiReportCommentBody` body match (Coverage /
@@ -3,14 +3,37 @@
3
3
  *
4
4
  * Pure helper for the live-checkout dispatch mode (plan-w-mq5rmtt9000a42f9-2026-06-08).
5
5
  *
6
- * `prepareLiveCheckout({ localPath, branchName, mainRef, gitOpts, dispatchId, wiId, log, _git })`
6
+ * `prepareLiveCheckout({ localPath, branchName, mainRef, gitOpts, dispatchId, wiId, log, _git, _exists })`
7
7
  *
8
8
  * Lifecycle (called by spawnAgent in engine.js when resolveSpawnPaths returns liveMode:true):
9
9
  * 1. `git status --porcelain` from localPath. Non-empty output → bail with
10
10
  * { ok:false, reason:'dirty', dirtyFiles:[…] } so spawnAgent can fail the
11
11
  * dispatch non-retryably and write an inbox alert. NO mutating git calls
12
12
  * after this point if dirty.
13
- * 2. Branch resolution (NO `git fetch`issue #226):
13
+ * 2. MID-OPERATION / DETACHED-HEAD PREFLIGHT (P-b2e8d4a6runs BETWEEN the
14
+ * dirty check and branch resolution, before any checkout):
15
+ * a. Resolve the git dir robustly via `git rev-parse --git-dir` (NOT by
16
+ * assuming localPath/.git) so submodule / gitdir-file / repo-managed
17
+ * trees — the very setups that motivate live mode — are covered.
18
+ * b. Probe sentinel paths under the resolved git dir via the injectable
19
+ * `_exists` seam (defaults to fs.existsSync): MERGE_HEAD → merge,
20
+ * rebase-merge/ & rebase-apply/ → rebase, CHERRY_PICK_HEAD →
21
+ * cherry-pick, REVERT_HEAD → revert. First hit → bail with
22
+ * { ok:false, reason:'mid-operation', op, details } so spawnAgent
23
+ * refuses non-retryably rather than committing into a half-finished
24
+ * operation in the operator's tree.
25
+ * c. Detached HEAD via `git symbolic-ref -q HEAD` non-zero exit → bail
26
+ * with { ok:false, reason:'detached-head', sha } (sha from
27
+ * `git rev-parse HEAD`). Branching off a detached HEAD would strand
28
+ * the operator's anonymous commits.
29
+ * 3. ORIGINAL-REF CAPTURE (P-b2e8d4a6 — before any checkout): capture the
30
+ * operator's starting ref so the dispatch-end auto-restore (P-c5a1f3b8 /
31
+ * P-d9e6b2c4 engine wiring) can put the tree back. `git symbolic-ref
32
+ * --short HEAD` → { originalRef:<branch>, originalRefType:'branch' };
33
+ * falls back to `git rev-parse HEAD` → { originalRef:<sha>,
34
+ * originalRefType:'detached' } when symbolic-ref fails. These additive
35
+ * fields ride on the success object.
36
+ * 4. Branch resolution (NO `git fetch` — issue #226):
14
37
  * a. `git rev-parse --verify <branchName>` succeeds → branch exists
15
38
  * locally → `git checkout <branchName>` (NO --force, NO auto-pull,
16
39
  * NO fast-forward, NO reset). The operator owns the checkout
@@ -23,7 +46,13 @@
23
46
  * partial clones (Scalar/GVFS-managed ADO repos), which fails in
24
47
  * headless mode because the GVFS cache server receives no auth and
25
48
  * credential.helper is disabled. See issue #226.
26
- * 3. Returns { ok:true, branch, created:boolean }.
49
+ * 5. Returns { ok:true, branch, created:boolean, originalRef, originalRefType }.
50
+ *
51
+ * RETURN SHAPES:
52
+ * { ok:true, branch, created, originalRef, originalRefType:'branch'|'detached' }
53
+ * { ok:false, reason:'dirty', dirtyFiles:[…] }
54
+ * { ok:false, reason:'mid-operation', op:'merge'|'rebase'|'cherry-pick'|'revert', details }
55
+ * { ok:false, reason:'detached-head', sha }
27
56
  *
28
57
  * NOTE: `mainRef` is still accepted (and validated) for caller-contract
29
58
  * stability, but it is NOT used to seed the new branch in live mode — HEAD
@@ -44,13 +73,17 @@
44
73
  * crafted branch name like "--evil-flag" is rejected before reaching git.
45
74
  *
46
75
  * Tests inject a mock runner via the private `_git` option (signature
47
- * `(args:string[], opts:{cwd, gitExtraArgs?:string[]}) => Promise<string>`).
48
- * Production callers omit `_git` and the helper falls back to
49
- * `require('./shared').shellSafeGit`.
76
+ * `(args:string[], opts:{cwd, gitExtraArgs?:string[]}) => Promise<string>`)
77
+ * and a mock sentinel-existence probe via the private `_exists` option
78
+ * (signature `(absPath:string) => boolean`, defaulting to fs.existsSync).
79
+ * Production callers omit both and the helper falls back to
80
+ * `require('./shared').shellSafeGit` and `fs.existsSync` respectively.
50
81
  */
51
82
 
52
83
  'use strict';
53
84
 
85
+ const fs = require('fs');
86
+ const path = require('path');
54
87
  const shared = require('./shared');
55
88
 
56
89
  async function prepareLiveCheckout(opts = {}) {
@@ -63,6 +96,7 @@ async function prepareLiveCheckout(opts = {}) {
63
96
  wiId, // accepted for caller bookkeeping; not used by the helper
64
97
  log,
65
98
  _git, // private injection for testing — defaults to shared.shellSafeGit
99
+ _exists, // private injection for testing — defaults to fs.existsSync
66
100
  } = opts;
67
101
 
68
102
  // ── Required-arg guards. Throw rather than return {ok:false} so a
@@ -88,6 +122,7 @@ async function prepareLiveCheckout(opts = {}) {
88
122
  catch (e) { throw new Error('prepareLiveCheckout: invalid mainRef — ' + e.message); }
89
123
 
90
124
  const git = (typeof _git === 'function') ? _git : shared.shellSafeGit;
125
+ const exists = (typeof _exists === 'function') ? _exists : fs.existsSync;
91
126
  const baseOpts = { cwd: localPath, ...(gitOpts || {}) };
92
127
 
93
128
  // ── Step 1: git status --porcelain. Bail early on dirty tree. ─────────
@@ -106,7 +141,80 @@ async function prepareLiveCheckout(opts = {}) {
106
141
  return { ok: false, reason: 'dirty', dirtyFiles };
107
142
  }
108
143
 
109
- // ── Step 2: branch resolution + checkout. ─────────────────────────────
144
+ // ── Step 2: mid-operation / detached-HEAD preflight (P-b2e8d4a6). ──────
145
+ // Runs AFTER the dirty bail and BEFORE branch resolution — never mutates.
146
+ // Resolve the git dir via rev-parse (NOT localPath/.git) so submodule /
147
+ // gitdir-file / repo-managed trees resolve correctly. On failure fall back
148
+ // to <localPath>/.git so the sentinel probe still has a reasonable target.
149
+ let gitDir = path.join(localPath, '.git');
150
+ try {
151
+ const gitDirRaw = await git(['rev-parse', '--git-dir'], baseOpts);
152
+ const gitDirStr = (typeof gitDirRaw === 'string' ? gitDirRaw : '').trim();
153
+ if (gitDirStr) {
154
+ gitDir = path.isAbsolute(gitDirStr) ? gitDirStr : path.join(localPath, gitDirStr);
155
+ }
156
+ } catch {
157
+ // keep the <localPath>/.git fallback
158
+ }
159
+
160
+ // Probe sentinel paths under the resolved git dir. First hit wins; map each
161
+ // to its in-progress operation. rebase-merge/ (interactive/merge rebase) and
162
+ // rebase-apply/ (am-based rebase) both mean a rebase is underway.
163
+ const MID_OP_SENTINELS = [
164
+ { name: 'MERGE_HEAD', op: 'merge' },
165
+ { name: 'rebase-merge', op: 'rebase' },
166
+ { name: 'rebase-apply', op: 'rebase' },
167
+ { name: 'CHERRY_PICK_HEAD', op: 'cherry-pick' },
168
+ { name: 'REVERT_HEAD', op: 'revert' },
169
+ ];
170
+ for (const sentinel of MID_OP_SENTINELS) {
171
+ const sentinelPath = path.join(gitDir, sentinel.name);
172
+ if (exists(sentinelPath)) {
173
+ return { ok: false, reason: 'mid-operation', op: sentinel.op, details: sentinelPath };
174
+ }
175
+ }
176
+
177
+ // Detached HEAD: `git symbolic-ref -q HEAD` exits non-zero (→ shellSafeGit
178
+ // rejects) when HEAD does not point at a branch. Branching off a detached
179
+ // HEAD would strand the operator's anonymous commits, so refuse.
180
+ let detached = false;
181
+ try {
182
+ await git(['symbolic-ref', '-q', 'HEAD'], baseOpts);
183
+ } catch {
184
+ detached = true;
185
+ }
186
+ if (detached) {
187
+ let sha = '';
188
+ try {
189
+ const shaRaw = await git(['rev-parse', 'HEAD'], baseOpts);
190
+ sha = (typeof shaRaw === 'string' ? shaRaw : '').trim();
191
+ } catch {
192
+ // best-effort sha; leave empty if even rev-parse fails
193
+ }
194
+ return { ok: false, reason: 'detached-head', sha };
195
+ }
196
+
197
+ // ── Step 3: original-ref capture (P-b2e8d4a6). ────────────────────────
198
+ // Capture the operator's starting ref BEFORE any checkout so the
199
+ // dispatch-end auto-restore can put the tree back. Prefer the symbolic
200
+ // branch name; fall back to the raw HEAD sha when symbolic-ref fails.
201
+ let originalRef = '';
202
+ let originalRefType = 'branch';
203
+ try {
204
+ const refRaw = await git(['symbolic-ref', '--short', 'HEAD'], baseOpts);
205
+ originalRef = (typeof refRaw === 'string' ? refRaw : '').trim();
206
+ originalRefType = 'branch';
207
+ } catch {
208
+ originalRefType = 'detached';
209
+ try {
210
+ const refRaw = await git(['rev-parse', 'HEAD'], baseOpts);
211
+ originalRef = (typeof refRaw === 'string' ? refRaw : '').trim();
212
+ } catch {
213
+ originalRef = '';
214
+ }
215
+ }
216
+
217
+ // ── Step 4: branch resolution + checkout. ─────────────────────────────
110
218
  // NO `git fetch` (issue #226): the operator's existing HEAD is the
111
219
  // baseline in live mode, and a fetch against a Scalar/GVFS partial clone
112
220
  // pulls blobs through an auth-less cache server in headless mode (fails).
@@ -121,7 +229,7 @@ async function prepareLiveCheckout(opts = {}) {
121
229
  if (branchExists) {
122
230
  // Plain checkout — NO --force, NO -B, NO pull. Operator owns local commits.
123
231
  await git(['checkout', branchName], baseOpts);
124
- return { ok: true, branch: branchName, created: false };
232
+ return { ok: true, branch: branchName, created: false, originalRef, originalRefType };
125
233
  }
126
234
 
127
235
  // New branch — create from the current HEAD (issue #226). NOT
@@ -129,7 +237,181 @@ async function prepareLiveCheckout(opts = {}) {
129
237
  // seeding from a remote ref forces auth-less GVFS blob fetches that fail
130
238
  // on partial clones in headless mode.
131
239
  await git(['checkout', '-b', branchName], baseOpts);
132
- return { ok: true, branch: branchName, created: true };
240
+ return { ok: true, branch: branchName, created: true, originalRef, originalRefType };
241
+ }
242
+
243
+ /**
244
+ * restoreLiveCheckoutAtDispatchEnd — P-d9e6b2c4
245
+ *
246
+ * Dispatch-end counterpart to prepareLiveCheckout. Runs from spawnAgent's
247
+ * onAgentClose (engine.js) on EVERY terminal result AND from the engine-restart
248
+ * orphan/reattach completion path (engine/cli.js), so the operator's working
249
+ * tree is switched back to the ref it was on before the live-mode agent ran.
250
+ *
251
+ * Behaviour (locked by human steer steer-bc5861ff95, 2026-06-19):
252
+ * (A) TERMINAL-FAILURE NOTIFY: when the dispatch ended in a non-success
253
+ * terminal state (`isTerminalFailure`), write a deduped inbox alert
254
+ * `live-checkout-failed-<dispatchId>` so the operator knows a live-mode
255
+ * run failed inside their checkout (where side effects are visible).
256
+ * (B) AUTO-RESTORE (best-effort, never throws, NEVER --force):
257
+ * • No-op when there is nothing to restore — no captured originalRef,
258
+ * or the agent branch IS the original ref, or HEAD already sits on
259
+ * the original ref (branch name OR raw sha, so the detached-HEAD
260
+ * restore is recognized too).
261
+ * • Otherwise a PLAIN `git checkout <originalRef>` (no --force, no
262
+ * reset, no clean, no stash). git refuses if uncommitted agent work
263
+ * would be overwritten; that refusal is HONORED — the tree is left
264
+ * exactly as the agent left it and a `live-checkout-branch-<dispatchId>`
265
+ * notify alert is written so the operator can resolve it manually.
266
+ * • Any unexpected error is swallowed and logged; restore is strictly
267
+ * best-effort and must NEVER alter the dispatch result.
268
+ *
269
+ * SIDE-EFFECTING (unlike prepareLiveCheckout) but every effect routes through
270
+ * an injected seam: git via `_git` (defaults to shared.shellSafeGit) and the
271
+ * inbox write via the required `writeInboxAlert(slug, body)` callback. This
272
+ * keeps the engine's dispatch module the owner of inbox I/O while letting unit
273
+ * tests capture every git argv and alert with zero filesystem / process touch.
274
+ *
275
+ * @param {object} opts
276
+ * @param {string} opts.localPath operator checkout root (git cwd)
277
+ * @param {string} opts.branchName branch the agent worked on
278
+ * @param {string} opts.originalRef ref to switch back to (branch or sha)
279
+ * @param {string} [opts.originalRefType] 'branch' | 'detached' (informational)
280
+ * @param {string} opts.dispatchId dispatch id (alert dedupe key)
281
+ * @param {string} [opts.projectName] project name for log/alert text
282
+ * @param {boolean} [opts.isTerminalFailure] true on error/timeout/crash
283
+ * @param {string} [opts.resultLabel] short status word for the alert body
284
+ * @param {object} [opts.gitOpts] extra execFile opts merged into cwd
285
+ * @param {function} [opts.log] (level, msg) => void
286
+ * @param {function} opts.writeInboxAlert (slug, body) => any (false = deduped)
287
+ * @param {function} [opts._git] test seam; defaults to shellSafeGit
288
+ * @returns {Promise<{restored:boolean, reason:string|null, failureAlerted:boolean, fallbackAlerted:boolean}>}
289
+ */
290
+ async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
291
+ const {
292
+ localPath,
293
+ branchName,
294
+ originalRef,
295
+ originalRefType, // informational; kept for caller symmetry
296
+ dispatchId,
297
+ projectName,
298
+ isTerminalFailure,
299
+ resultLabel,
300
+ gitOpts,
301
+ log,
302
+ writeInboxAlert,
303
+ _git, // private injection for testing — defaults to shared.shellSafeGit
304
+ } = opts;
305
+
306
+ const logFn = (typeof log === 'function') ? log : () => {};
307
+ const git = (typeof _git === 'function') ? _git : shared.shellSafeGit;
308
+ const alert = (typeof writeInboxAlert === 'function') ? writeInboxAlert : () => {};
309
+ const proj = projectName || 'project';
310
+ const did = dispatchId || '(unknown)';
311
+ const baseOpts = { cwd: localPath, ...(gitOpts || {}) };
312
+
313
+ const result = { restored: false, reason: null, failureAlerted: false, fallbackAlerted: false };
314
+
315
+ // ── (A) Terminal-failure notify. Independent of restore; deduped per
316
+ // dispatch by the alert slug+date. Only fires on a non-success terminal
317
+ // result so operators get a heads-up that a live-mode run failed in
318
+ // their own working tree.
319
+ if (isTerminalFailure && localPath && branchName) {
320
+ try {
321
+ const body = [
322
+ `A live-checkout dispatch ended in a non-success state` +
323
+ (resultLabel ? ` (${resultLabel})` : '') + `.`,
324
+ ``,
325
+ `Project: ${proj}`,
326
+ `Dispatch: ${did}`,
327
+ `Branch: ${branchName}`,
328
+ `Working tree: ${localPath}`,
329
+ ``,
330
+ `The agent ran in-place in your checkout, so any partial work is visible there.`,
331
+ `Inspect the tree, then keep, set aside, or discard the changes as you see fit.`,
332
+ `The engine only switches branches — it never resets, cleans, or discards your work.`,
333
+ ].join('\n');
334
+ const wrote = alert(`live-checkout-failed-${did}`, body);
335
+ result.failureAlerted = (wrote !== false);
336
+ } catch (e) {
337
+ logFn('warn', `live-checkout: could not write terminal-failure alert for ${did}: ${e && e.message}`);
338
+ }
339
+ }
340
+
341
+ // ── (B) Auto-restore (best-effort). ──────────────────────────────────────
342
+ if (!localPath || !originalRef) {
343
+ result.reason = 'no-original-ref';
344
+ return result;
345
+ }
346
+ if (originalRef === branchName) {
347
+ result.reason = 'already-on-branch';
348
+ return result;
349
+ }
350
+
351
+ try {
352
+ // No-op when HEAD already sits on the original ref. Compare both the
353
+ // symbolic branch name and the raw HEAD sha so the detached-HEAD restore
354
+ // (originalRefType:'detached', originalRef = sha) is recognized too.
355
+ let alreadyThere = false;
356
+ try {
357
+ const curBranchRaw = await git(['rev-parse', '--abbrev-ref', 'HEAD'], baseOpts);
358
+ const curBranch = (typeof curBranchRaw === 'string' ? curBranchRaw : '').trim();
359
+ if (curBranch && curBranch === originalRef) alreadyThere = true;
360
+ } catch { /* best-effort — fall through to the sha probe */ }
361
+ if (!alreadyThere) {
362
+ try {
363
+ const curShaRaw = await git(['rev-parse', 'HEAD'], baseOpts);
364
+ const curSha = (typeof curShaRaw === 'string' ? curShaRaw : '').trim();
365
+ if (curSha && curSha === originalRef) alreadyThere = true;
366
+ } catch { /* best-effort */ }
367
+ }
368
+ if (alreadyThere) {
369
+ result.reason = 'already-on-original-ref';
370
+ return result;
371
+ }
372
+
373
+ // PLAIN checkout — NO --force. git refuses if uncommitted changes would be
374
+ // overwritten; that refusal is honored rather than clobbering the tree.
375
+ try {
376
+ await git(['checkout', originalRef], baseOpts);
377
+ result.restored = true;
378
+ result.reason = 'restored';
379
+ logFn('info', `live-checkout: restored ${proj} to ${originalRef} after dispatch ${did}`);
380
+ return result;
381
+ } catch (checkoutErr) {
382
+ // Refused (most likely uncommitted agent work). Do NOT force. Notify.
383
+ result.reason = 'checkout-refused';
384
+ logFn('warn',
385
+ `live-checkout: could not auto-restore ${proj} to ${originalRef} (leaving tree as-is): ` +
386
+ `${checkoutErr && checkoutErr.message}`);
387
+ try {
388
+ const body = [
389
+ `Could not automatically switch ${proj} back to "${originalRef}" after a live-checkout`,
390
+ `dispatch — git declined the switch, most likely because the working tree has`,
391
+ `uncommitted changes that a plain checkout would overwrite.`,
392
+ ``,
393
+ `Dispatch: ${did}`,
394
+ `Current branch: ${branchName}`,
395
+ `Working tree: ${localPath}`,
396
+ ``,
397
+ `Your tree was left untouched (no force, no reset, no clean, no stash). To return`,
398
+ `to your original ref, keep or set aside the pending changes, then switch manually:`,
399
+ ``,
400
+ ` git -C "${localPath}" checkout ${originalRef}`,
401
+ ].join('\n');
402
+ const wrote = alert(`live-checkout-branch-${did}`, body);
403
+ result.fallbackAlerted = (wrote !== false);
404
+ } catch (e) {
405
+ logFn('warn', `live-checkout: could not write restore-fallback alert for ${did}: ${e && e.message}`);
406
+ }
407
+ return result;
408
+ }
409
+ } catch (restoreErr) {
410
+ // Strictly best-effort — swallow so restore never alters the dispatch result.
411
+ result.reason = 'error';
412
+ logFn('warn', `live-checkout: restore hiccup for ${did}: ${restoreErr && restoreErr.message}`);
413
+ return result;
414
+ }
133
415
  }
134
416
 
135
- module.exports = { prepareLiveCheckout };
417
+ module.exports = { prepareLiveCheckout, restoreLiveCheckoutAtDispatchEnd };