@yemi33/minions 0.1.2380 → 0.1.2381

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.
@@ -110,9 +110,11 @@
110
110
  * MOSTLY PURE — does NOT call completeDispatch. It does NOT mutate the tree or
111
111
  * write inbox alerts on any default path. The ONE opt-in exception is auto-reset
112
112
  * (W-mqvejug6000eeb20): when `liveCheckoutAutoReset` is enabled AND the tree is
113
- * dirty, it runs `git fetch origin` + `git reset --hard origin/<branch>` and
114
- * writes a single `live-checkout-autoreset-<wiId>` inbox note. Both the reset
115
- * git ops and the note writer are injectable (`_git`, `_writeInboxNote`) and the
113
+ * dirty, it runs `git reset --hard HEAD` + `git clean -fd` and writes a single
114
+ * `live-checkout-autoreset-<wiId>` inbox note. This preserves the current branch
115
+ * and committed history while explicitly discarding tracked and untracked WIP.
116
+ * Both destructive git ops and the note writer are injectable (`_git`,
117
+ * `_writeInboxNote`) and the
116
118
  * decision is injectable (`autoReset` / `_resolveAutoReset`), so unit tests stay
117
119
  * filesystem-free. Default (auto-reset off) keeps the helper free of side
118
120
  * effects — the dirty/blocked translation belongs in spawnAgent (engine.js).
@@ -568,6 +570,63 @@ async function _maybeAutoFreshenLocalMain(opts = {}) {
568
570
  return { freshened: true, before: beforeSha, after: afterSha, behind };
569
571
  }
570
572
 
573
+ async function _inspectLiveCheckoutHead({ git, baseOpts, localPath, exists }) {
574
+ let gitDir;
575
+ try {
576
+ const gitDirRaw = await git(['rev-parse', '--git-dir'], baseOpts);
577
+ const gitDirStr = (typeof gitDirRaw === 'string' ? gitDirRaw : '').trim();
578
+ if (!gitDirStr) throw new Error('git rev-parse --git-dir returned empty output');
579
+ gitDir = path.isAbsolute(gitDirStr) ? gitDirStr : path.join(localPath, gitDirStr);
580
+ } catch (e) {
581
+ throw new Error('prepareLiveCheckout: could not resolve git dir for ' + localPath + ' — ' + (e && e.message));
582
+ }
583
+
584
+ const midOperationSentinels = [
585
+ { name: 'MERGE_HEAD', op: 'merge' },
586
+ { name: 'rebase-merge', op: 'rebase' },
587
+ { name: 'rebase-apply', op: 'rebase' },
588
+ { name: 'CHERRY_PICK_HEAD', op: 'cherry-pick' },
589
+ { name: 'REVERT_HEAD', op: 'revert' },
590
+ { name: 'BISECT_LOG', op: 'bisect' },
591
+ ];
592
+ for (const sentinel of midOperationSentinels) {
593
+ const sentinelPath = path.join(gitDir, sentinel.name);
594
+ if (exists(sentinelPath)) {
595
+ return { ok: false, reason: 'mid-operation', op: sentinel.op, details: sentinelPath };
596
+ }
597
+ }
598
+
599
+ try {
600
+ await git(['symbolic-ref', '-q', 'HEAD'], baseOpts);
601
+ } catch (symErr) {
602
+ if (!symErr || symErr.code !== 1) throw symErr;
603
+ let sha = '';
604
+ try {
605
+ const shaRaw = await git(['rev-parse', 'HEAD'], baseOpts);
606
+ sha = (typeof shaRaw === 'string' ? shaRaw : '').trim();
607
+ } catch {
608
+ // Best-effort diagnostic only.
609
+ }
610
+ return { ok: false, reason: 'detached-head', sha };
611
+ }
612
+
613
+ let originalRef = '';
614
+ let originalRefType = 'branch';
615
+ try {
616
+ const refRaw = await git(['symbolic-ref', '--short', 'HEAD'], baseOpts);
617
+ originalRef = (typeof refRaw === 'string' ? refRaw : '').trim();
618
+ } catch {
619
+ originalRefType = 'detached';
620
+ try {
621
+ const refRaw = await git(['rev-parse', 'HEAD'], baseOpts);
622
+ originalRef = (typeof refRaw === 'string' ? refRaw : '').trim();
623
+ } catch {
624
+ originalRef = '';
625
+ }
626
+ }
627
+ return { ok: true, originalRef, originalRefType };
628
+ }
629
+
571
630
  async function prepareLiveCheckout(opts = {}) {
572
631
  const {
573
632
  localPath,
@@ -655,6 +714,19 @@ async function prepareLiveCheckout(opts = {}) {
655
714
  // currently-running git process is never removed out from under it.
656
715
  shared.removeStaleIndexLock(localPath, { log: (level, msg) => logFn(msg, level) });
657
716
 
717
+ let originalRef = '';
718
+ let originalRefType = 'branch';
719
+ let headInspected = false;
720
+ const ensureHeadInspected = async () => {
721
+ if (headInspected) return null;
722
+ const state = await _inspectLiveCheckoutHead({ git, baseOpts, localPath, exists });
723
+ if (!state.ok) return state;
724
+ originalRef = state.originalRef;
725
+ originalRefType = state.originalRefType;
726
+ headInspected = true;
727
+ return null;
728
+ };
729
+
658
730
  // ── Step 1: git status --porcelain=v1 -b. Bail early on dirty tree. ─────
659
731
  // Skipped when `skipDirtyCheck:true` (issue #522): GVFS/VFS-for-Git repos
660
732
  // report all un-hydrated virtual files as modified — this is normal GVFS
@@ -726,6 +798,8 @@ async function prepareLiveCheckout(opts = {}) {
726
798
  if (dirtyFiles.length > 0) {
727
799
  const strayBranch = _extractBranchFromPorcelainHeader(branchInfo);
728
800
  if (strayBranch && strayBranch !== branchName && _looksLikeAgentBranch(strayBranch)) {
801
+ const blocked = await ensureHeadInspected();
802
+ if (blocked) return blocked;
729
803
  const commitRes = await _commitAgentWipWithRetry(
730
804
  git, baseOpts,
731
805
  `minions: auto-save orphaned WIP from ${strayBranch} (dispatch ${dispatchId || '(unknown)'})`,
@@ -737,8 +811,19 @@ async function prepareLiveCheckout(opts = {}) {
737
811
  `, switching back to '${mainRef}'`, 'info');
738
812
  try {
739
813
  await git(['checkout', mainRef], baseOpts);
814
+ originalRef = mainRef;
815
+ originalRefType = 'branch';
740
816
  } catch (backErr) {
741
817
  logFn(`[live-checkout] could not switch back to '${mainRef}' after self-healing '${strayBranch}': ${backErr && backErr.message}`, 'warn');
818
+ return {
819
+ ok: false,
820
+ reason: 'wrong-base',
821
+ headBranch: strayBranch,
822
+ expectedBase: mainRef,
823
+ switchError: String((backErr && backErr.message) || '').slice(0, 500),
824
+ originalRef,
825
+ originalRefType,
826
+ };
742
827
  }
743
828
  // Re-run the dirty check. Either the tree is now clean (self-heal
744
829
  // succeeded end-to-end) or something new appeared (rare) — either
@@ -751,13 +836,9 @@ async function prepareLiveCheckout(opts = {}) {
751
836
  }
752
837
  }
753
838
  if (dirtyFiles.length > 0) {
754
- // W-mqvejug6000eeb20 — opt-in auto-reset. Default behavior is to bail with
755
- // reason:'dirty' (spawnAgent translates to non-retryable LIVE_CHECKOUT_DIRTY
756
- // and alerts the operator). When auto-reset is enabled either the caller
757
- // passed an explicit `autoReset` boolean, or the config-based resolver says
758
- // so — we DISCARD the dirty state via `git fetch origin` + `git reset --hard
759
- // origin/<branch>` and continue. This is destructive (the operator’s
760
- // uncommitted work is gone), which is why it is strictly opt-in.
839
+ // W-mqvejug6000eeb20 — opt-in destructive recovery. It may discard
840
+ // tracked and untracked working-tree changes, but must never repoint the
841
+ // operator's current branch or discard committed history.
761
842
  let wantAutoReset = false;
762
843
  if (typeof autoReset === 'boolean') {
763
844
  wantAutoReset = autoReset;
@@ -769,176 +850,126 @@ async function prepareLiveCheckout(opts = {}) {
769
850
  return { ok: false, reason: 'dirty', dirtyFiles, branchInfo };
770
851
  }
771
852
 
772
- // W-mrav3u0c0008ce38 live-checkout WI types that never push a branch
773
- // (setup/test/verify live-validation dispatches on checkoutMode:'live'
774
- // or hybrid liveValidation projects) leave `origin/<branchName>`
775
- // unresolvable, so a bare `reset --hard origin/<branchName>` fails every
776
- // time with "ambiguous argument … unknown revision" and the tree stays
777
- // dirty forever. Probe `origin/<branchName>` first (after the fetch, so
778
- // it reflects the latest remote state); only fall back to
779
- // `origin/<mainRef>` when that branch genuinely doesn't exist upstream.
780
- // PR-continuation flows (fix/implement WIs resuming an existing pushed
781
- // branch) keep resetting to `origin/<branchName>` exactly as before.
782
- let resetOk = true;
783
- let resetTarget = `origin/${branchName}`;
853
+ const blocked = await ensureHeadInspected();
854
+ if (blocked) return blocked;
855
+ const autoResetAttempt = {
856
+ resetSucceeded: false,
857
+ cleanAttempted: false,
858
+ cleanSucceeded: false,
859
+ recheckSucceeded: false,
860
+ errors: [],
861
+ };
784
862
  try {
785
- await git(['fetch', 'origin'], baseOpts);
786
- try {
787
- await git(['rev-parse', '--verify', resetTarget], baseOpts);
788
- } catch {
789
- resetTarget = `origin/${mainRef}`;
790
- if (typeof log === 'function') {
791
- log(`live-checkout auto-reset: origin/${branchName} does not exist (never pushed) — falling back to '${resetTarget}'`);
792
- }
793
- }
794
863
  if (typeof log === 'function') {
795
- log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s) on '${branchName}' via fetch + reset --hard ${resetTarget}`);
864
+ log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s) without changing the current branch or HEAD`);
796
865
  }
797
- await git(['reset', '--hard', resetTarget], baseOpts);
866
+ await git(['reset', '--hard', 'HEAD'], baseOpts);
867
+ autoResetAttempt.resetSucceeded = true;
798
868
  } catch (e) {
799
- resetOk = false;
869
+ const error = e && e.message ? e.message : String(e);
870
+ autoResetAttempt.errors.push(`reset --hard HEAD: ${error}`);
800
871
  if (typeof log === 'function') {
801
- log(`live-checkout auto-reset FAILED (fetch/reset): ${e && e.message ? e.message : e}`);
872
+ log(`live-checkout auto-reset FAILED (reset --hard HEAD): ${error}`);
802
873
  }
803
874
  }
804
-
805
- // Re-run the porcelain preflight once. If the tree is now clean we proceed;
806
- // otherwise (reset failed, or something is still dirty) we fall back to the
807
- // safe dirty refusal so we never dispatch onto an unexpected tree.
808
- let stillDirty = dirtyFiles;
809
- if (resetOk) {
875
+ if (autoResetAttempt.resetSucceeded) {
876
+ autoResetAttempt.cleanAttempted = true;
810
877
  try {
811
- const recheckRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
812
- const recheckStr = typeof recheckRaw === 'string' ? recheckRaw : '';
813
- stillDirty = recheckStr
814
- .split(/\r?\n/)
815
- .map((line) => line.replace(/\s+$/, ''))
816
- .filter((line) => line.length > 0 && !line.startsWith('## '));
878
+ await git(['clean', '-fd'], baseOpts);
879
+ autoResetAttempt.cleanSucceeded = true;
817
880
  } catch (e) {
818
- resetOk = false;
881
+ const error = e && e.message ? e.message : String(e);
882
+ autoResetAttempt.errors.push(`clean -fd: ${error}`);
819
883
  if (typeof log === 'function') {
820
- log(`live-checkout auto-reset re-check FAILED: ${e && e.message ? e.message : e}`);
884
+ log(`live-checkout auto-reset FAILED (clean -fd): ${error}`);
821
885
  }
822
886
  }
823
887
  }
824
888
 
825
- if (!resetOk || stillDirty.length > 0) {
826
- return { ok: false, reason: 'dirty', dirtyFiles: stillDirty, branchInfo };
889
+ // A failed git command may still have changed part of the tree. Always
890
+ // re-run porcelain after the destructive attempt so both the refusal and
891
+ // audit note report what actually remains.
892
+ let stillDirty = dirtyFiles;
893
+ try {
894
+ const recheckRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
895
+ const rechecked = parsePorcelain(recheckRaw);
896
+ stillDirty = rechecked.dirtyFiles;
897
+ branchInfo = rechecked.branchInfo || branchInfo;
898
+ autoResetAttempt.recheckSucceeded = true;
899
+ } catch (e) {
900
+ const error = e && e.message ? e.message : String(e);
901
+ autoResetAttempt.errors.push(`status re-check: ${error}`);
902
+ if (typeof log === 'function') {
903
+ log(`live-checkout auto-reset re-check FAILED: ${error}`);
904
+ }
827
905
  }
828
906
 
829
- // Audit trail: record the discarded paths so the operator can recover from
830
- // reflog / understand why their tree changed. Best-effort, never throws.
907
+ const recoveryVerified = autoResetAttempt.recheckSucceeded && stillDirty.length === 0;
908
+ // Audit every destructive attempt, including partial failures. A git
909
+ // command can mutate some paths before returning an error.
831
910
  try {
832
911
  const writeNote = (typeof _writeInboxNote === 'function') ? _writeInboxNote : _defaultWriteInboxNote;
833
- const slug = `live-checkout-autoreset-${wiId || dispatchId || 'unknown'}`;
912
+ const slug = `live-checkout-autoreset-${shared.uid()}-${dispatchId || wiId || 'unknown'}`;
913
+ autoResetAttempt.auditSlug = slug;
914
+ const resetOutcome = autoResetAttempt.resetSucceeded ? 'succeeded' : 'FAILED';
915
+ let cleanOutcome = 'not run because reset failed';
916
+ if (autoResetAttempt.cleanAttempted) {
917
+ cleanOutcome = autoResetAttempt.cleanSucceeded ? 'succeeded' : 'FAILED or partially applied';
918
+ }
919
+ let verifyOutcome = 'FAILED — remaining tree state is unknown';
920
+ if (autoResetAttempt.recheckSucceeded) {
921
+ verifyOutcome = stillDirty.length === 0 ? 'clean' : `${stillDirty.length} dirty path(s) remain`;
922
+ }
834
923
  const body = [
835
- `# Live-checkout auto-reset on '${branchName}'`,
924
+ `# Live-checkout auto-reset ${recoveryVerified ? 'completed' : 'incomplete'} before '${branchName}'`,
925
+ '',
926
+ `The live-checkout tree at \`${localPath}\` was dirty and \`liveCheckoutAutoReset\``,
927
+ 'attempted destructive WIP cleanup. The current branch and committed history were preserved.',
928
+ '',
929
+ '## Outcome',
836
930
  '',
837
- `⚠️ The live-checkout tree at \`${localPath}\` was dirty at dispatch time and`,
838
- '`liveCheckoutAutoReset` is enabled, so it was force-reset to',
839
- `\`${resetTarget}\`. **The following uncommitted changes were DISCARDED**`,
840
- '(recover from `git reflog` / `git fsck --lost-found` if needed):',
931
+ `- \`git reset --hard HEAD\`: ${resetOutcome}`,
932
+ `- \`git clean -fd\`: ${cleanOutcome}`,
933
+ `- post-cleanup status check: ${verifyOutcome}`,
934
+ ...(autoResetAttempt.errors.length > 0
935
+ ? ['', 'Errors:', ...autoResetAttempt.errors.map(error => `- ${error}`)]
936
+ : []),
937
+ '',
938
+ '## Changes present before the attempt',
841
939
  '',
842
940
  '```',
843
941
  ...dirtyFiles,
844
942
  '```',
943
+ ...(autoResetAttempt.recheckSucceeded
944
+ ? [
945
+ '',
946
+ '## Changes remaining after the attempt',
947
+ '',
948
+ '```',
949
+ ...(stillDirty.length > 0 ? stillDirty : ['(clean)']),
950
+ '```',
951
+ ]
952
+ : []),
845
953
  ].join('\n');
846
954
  writeNote(slug, body);
847
955
  } catch { /* best-effort audit note */ }
956
+
957
+ if (!recoveryVerified) {
958
+ return {
959
+ ok: false,
960
+ reason: 'dirty',
961
+ dirtyFiles: stillDirty,
962
+ branchInfo,
963
+ autoResetAttempt,
964
+ };
965
+ }
848
966
  // Fall through — tree is now clean, continue with normal preparation.
849
967
  }
850
968
  }
851
969
  }
852
970
 
853
- // ── Step 2: mid-operation / detached-HEAD preflight (P-b2e8d4a6). ──────
854
- // Runs AFTER the dirty bail and BEFORE branch resolution — never mutates.
855
- // Resolve the git dir via rev-parse (NOT localPath/.git) so submodule /
856
- // gitdir-file / repo-managed trees resolve correctly. A FAILURE here is a
857
- // real git-health problem (and on the very submodule / gitdir-file /
858
- // repo-managed trees live mode targets, <localPath>/.git is NOT a directory,
859
- // so the old fabricated-path fallback made every sentinel probe miss and
860
- // silently skipped mid-operation detection). Throw so the caller classifies
861
- // it as a retryable LIVE_CHECKOUT_FAILED instead of barging into a branch
862
- // switch on top of a half-finished operation we couldn't see.
863
- let gitDir;
864
- try {
865
- const gitDirRaw = await git(['rev-parse', '--git-dir'], baseOpts);
866
- const gitDirStr = (typeof gitDirRaw === 'string' ? gitDirRaw : '').trim();
867
- if (!gitDirStr) {
868
- throw new Error('git rev-parse --git-dir returned empty output');
869
- }
870
- gitDir = path.isAbsolute(gitDirStr) ? gitDirStr : path.join(localPath, gitDirStr);
871
- } catch (e) {
872
- throw new Error('prepareLiveCheckout: could not resolve git dir for ' + localPath + ' — ' + (e && e.message));
873
- }
874
-
875
- // Probe sentinel paths under the resolved git dir. First hit wins; map each
876
- // to its in-progress operation. rebase-merge/ (interactive/merge rebase) and
877
- // rebase-apply/ (am-based rebase) both mean a rebase is underway. BISECT_LOG
878
- // covers an in-progress `git bisect` (bisect state is HEAD-keyed, so a branch
879
- // switch would silently destroy it — the doc + header imply bisect coverage).
880
- const MID_OP_SENTINELS = [
881
- { name: 'MERGE_HEAD', op: 'merge' },
882
- { name: 'rebase-merge', op: 'rebase' },
883
- { name: 'rebase-apply', op: 'rebase' },
884
- { name: 'CHERRY_PICK_HEAD', op: 'cherry-pick' },
885
- { name: 'REVERT_HEAD', op: 'revert' },
886
- { name: 'BISECT_LOG', op: 'bisect' },
887
- ];
888
- for (const sentinel of MID_OP_SENTINELS) {
889
- const sentinelPath = path.join(gitDir, sentinel.name);
890
- if (exists(sentinelPath)) {
891
- return { ok: false, reason: 'mid-operation', op: sentinel.op, details: sentinelPath };
892
- }
893
- }
894
-
895
- // Detached HEAD: `git symbolic-ref -q HEAD` exits 1 when HEAD does not point
896
- // at a branch. Branching off a detached HEAD would strand the operator's
897
- // anonymous commits, so refuse. But distinguish a REAL detachment (git ran
898
- // and returned exit code 1) from a TRANSIENT failure (spawn error / timeout /
899
- // ENOENT) — the latter is not proof of detachment and must NOT permanently
900
- // refuse an on-a-branch tree; rethrow so it becomes a retryable
901
- // LIVE_CHECKOUT_FAILED instead.
902
- let detached = false;
903
- try {
904
- await git(['symbolic-ref', '-q', 'HEAD'], baseOpts);
905
- } catch (symErr) {
906
- if (symErr && symErr.code === 1) {
907
- detached = true; // git ran, HEAD is not a symbolic ref → genuinely detached
908
- } else {
909
- throw symErr; // spawn failure / timeout / other → transient, not detachment
910
- }
911
- }
912
- if (detached) {
913
- let sha = '';
914
- try {
915
- const shaRaw = await git(['rev-parse', 'HEAD'], baseOpts);
916
- sha = (typeof shaRaw === 'string' ? shaRaw : '').trim();
917
- } catch {
918
- // best-effort sha; leave empty if even rev-parse fails
919
- }
920
- return { ok: false, reason: 'detached-head', sha };
921
- }
922
-
923
- // ── Step 3: original-ref capture (P-b2e8d4a6). ────────────────────────
924
- // Capture the operator's starting ref BEFORE any checkout so the
925
- // dispatch-end auto-restore can put the tree back. Prefer the symbolic
926
- // branch name; fall back to the raw HEAD sha when symbolic-ref fails.
927
- let originalRef = '';
928
- let originalRefType = 'branch';
929
- try {
930
- const refRaw = await git(['symbolic-ref', '--short', 'HEAD'], baseOpts);
931
- originalRef = (typeof refRaw === 'string' ? refRaw : '').trim();
932
- originalRefType = 'branch';
933
- } catch {
934
- originalRefType = 'detached';
935
- try {
936
- const refRaw = await git(['rev-parse', 'HEAD'], baseOpts);
937
- originalRef = (typeof refRaw === 'string' ? refRaw : '').trim();
938
- } catch {
939
- originalRef = '';
940
- }
941
- }
971
+ const blocked = await ensureHeadInspected();
972
+ if (blocked) return blocked;
942
973
 
943
974
  // ── Step 4: branch resolution + checkout. ─────────────────────────────
944
975
  // NO `git fetch` (issue #226): the operator's existing HEAD is the
@@ -1195,6 +1226,7 @@ async function prepareLiveCheckout(opts = {}) {
1195
1226
  }
1196
1227
  logFn('info',
1197
1228
  `live-checkout: HEAD was on '${headBranch}' (not base '${mainRef}') — switched to local '${mainRef}' before forking '${branchName}' (no fetch, issue #226 preserved)`);
1229
+ curBranch = mainRef;
1198
1230
  // ── originalRef correction (W-mrb6an8300058fd8). ─────────────────────
1199
1231
  // originalRef/originalRefType were captured at Step 3 from the STALE
1200
1232
  // headBranch, BEFORE this self-heal ran. The tree has now been switched
@@ -1883,6 +1915,8 @@ async function performLiveCheckoutAutoStash(opts = {}) {
1883
1915
  * or — W-mrawgw4q000a6bff — to an explicit `liveCheckoutAutoReset` fallback
1884
1916
  * when that policy also resolves true; see engine.js spawnAgent).
1885
1917
  * - `{ outcome:'stashed', liveResult }` — stashed + re-preflighted; proceed.
1918
+ * - `{ outcome:'blocked', liveResult }` — mutation preflight found an
1919
+ * in-progress repository operation or detached HEAD; caller must refuse.
1886
1920
  * - `{ outcome:'threw', error }` — the re-preflight threw; caller must
1887
1921
  * fail the dispatch as LIVE_CHECKOUT_FAILED (engine-specific completion).
1888
1922
  *
@@ -1903,7 +1937,7 @@ async function performLiveCheckoutAutoStash(opts = {}) {
1903
1937
  * tree if the ff fails — so it was deliberately left out.
1904
1938
  *
1905
1939
  * @param {object} opts
1906
- * @returns {Promise<{outcome:'unchanged'|'stashed'|'threw', liveResult?:object, error?:Error}>}
1940
+ * @returns {Promise<{outcome:'unchanged'|'blocked'|'stashed'|'threw', liveResult?:object, error?:Error}>}
1907
1941
  */
1908
1942
  async function applyLiveCheckoutAutoStash(opts = {}) {
1909
1943
  const {
@@ -1911,12 +1945,22 @@ async function applyLiveCheckoutAutoStash(opts = {}) {
1911
1945
  gitOpts, dispatchId, wiId, log,
1912
1946
  clearDirtyStamp, writeStashNote,
1913
1947
  _git, // private injection for testing — forwarded to performLiveCheckoutAutoStash + the re-preflight prepareLiveCheckout call
1948
+ _exists,
1914
1949
  } = opts;
1915
1950
  const logFn = (typeof log === 'function') ? log : () => {};
1916
1951
  const reason = (liveResult && liveResult.ok === false) ? liveResult.reason : null;
1917
1952
  if (reason !== 'dirty' || !resolveLiveCheckoutAutoStash({ project, engine })) {
1918
1953
  return { outcome: 'unchanged', liveResult };
1919
1954
  }
1955
+ try {
1956
+ const git = (typeof _git === 'function') ? _git : shared.shellSafeGit;
1957
+ const exists = (typeof _exists === 'function') ? _exists : fs.existsSync;
1958
+ const baseOpts = { cwd: localPath, maxBuffer: LIVE_CHECKOUT_GIT_MAX_BUFFER, ...(gitOpts || {}) };
1959
+ const state = await _inspectLiveCheckoutHead({ git, baseOpts, localPath, exists });
1960
+ if (!state.ok) return { outcome: 'blocked', liveResult: state };
1961
+ } catch (preflightErr) {
1962
+ return { outcome: 'threw', error: preflightErr };
1963
+ }
1920
1964
  let stashResult;
1921
1965
  try {
1922
1966
  stashResult = await performLiveCheckoutAutoStash({ localPath, dispatchId, gitOpts, log, _git });
@@ -402,6 +402,7 @@ const PLAYBOOK_REQUIRED_VARS = {
402
402
  'implement': ['item_id', 'item_name', 'branch_name', 'project_path'],
403
403
  'implement-shared': ['item_id', 'item_name', 'branch_name', 'worktree_path'],
404
404
  'fix': ['pr_id', 'pr_branch'],
405
+ // M005 — SHERLOC two-phase build-fix. Same PR context as fix.
405
406
  'build-fix-complex': ['pr_id', 'pr_branch'],
406
407
  'review': ['pr_id', 'pr_branch'],
407
408
  'build-and-test': ['pr_id', 'pr_branch', 'project_path'],
@@ -434,11 +435,14 @@ const PLAYBOOK_REQUIRED_VARS = {
434
435
  'meeting-investigate': ['meeting_title', 'agenda'],
435
436
  'meeting-debate': ['meeting_title', 'agenda'],
436
437
  'meeting-conclude': ['meeting_title', 'agenda'],
437
- // M005 — SHERLOC two-phase build-fix. Same required vars as fix (PR-context
438
- // dispatch), but routed to build-fix-complex.md for multi-file failures.
439
- 'build-fix-complex': ['pr_id', 'pr_branch'],
440
438
  };
441
439
 
440
+ const LIVE_VALIDATION_DEFERRED_PLAYBOOKS = new Set([
441
+ 'implement',
442
+ 'fix',
443
+ 'build-fix-complex',
444
+ ]);
445
+
442
446
  /**
443
447
  * Validate that all required template variables for a playbook type are present
444
448
  * and non-empty in the provided vars object.
@@ -848,18 +852,11 @@ function renderPlaybook(type, vars) {
848
852
  } catch (e) { log('warn', `qa-validate context render failed: ${e.message}`); }
849
853
  }
850
854
 
851
- // M005 live-validation deferred-build notice.
852
- // Inject only for coding playbooks (implement, fix, docs, decompose) when the
853
- // matched project has liveValidation.autoDispatch === true. Agents on these
854
- // projects must push code without running builds or tests; a separate dispatch
855
- // (of liveValidation.type) runs the full build on the live environment after
856
- // the PR lands. When autoDispatch is false the agent is responsible for its own
857
- // inline validation (may fail in isolated worktrees — documented limitation).
858
- const LIVE_VALIDATION_PLAYBOOKS = new Set(['implement', 'fix', 'build-fix-complex', 'docs', 'decompose']);
859
- if (LIVE_VALIDATION_PLAYBOOKS.has(type)) {
860
- const lv = matchedProject && matchedProject.liveValidation;
861
- if (lv && lv.autoDispatch === true) {
862
- const lvType = lv.type || 'live-validation';
855
+ // Only playbooks whose completion creates the lifecycle follow-up may defer
856
+ // builds. The shared resolver also requires effective live `test` routing.
857
+ if (LIVE_VALIDATION_DEFERRED_PLAYBOOKS.has(type)) {
858
+ const lvType = shared.resolveLiveValidationAutoDispatchType(matchedProject);
859
+ if (lvType) {
863
860
  inertAppendices.push(
864
861
  `\n\n---\n\n## Live Validation — Validation Deferred\n\n` +
865
862
  `**Live validation is deferred for this project.** ` +
package/engine/routing.js CHANGED
@@ -118,7 +118,7 @@ function getTempBudget() { return _tempBudget; }
118
118
  function normalizeWorkType(workType, fallback = WORK_TYPE.IMPLEMENT) {
119
119
  const type = String(workType || fallback || '').trim();
120
120
  if (!type) return fallback;
121
- return type;
121
+ return shared.normalizeLiveValidationWorkType(type);
122
122
  }
123
123
 
124
124
  function routeForWorkType(workType) {