@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.
- package/dashboard/js/refresh.js +1 -1
- package/dashboard/js/render-other.js +43 -23
- package/dashboard/js/settings.js +40 -17
- package/dashboard.js +42 -47
- package/docs/live-checkout-mode.md +45 -26
- package/engine/dispatch.js +5 -3
- package/engine/lifecycle.js +59 -72
- package/engine/live-checkout.js +193 -149
- package/engine/playbook.js +12 -15
- package/engine/routing.js +1 -1
- package/engine/shared.js +136 -62
- package/engine.js +50 -15
- package/package.json +1 -1
- package/prompts/cc-system.md +7 -7
package/engine/live-checkout.js
CHANGED
|
@@ -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
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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
|
|
755
|
-
//
|
|
756
|
-
//
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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)
|
|
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',
|
|
866
|
+
await git(['reset', '--hard', 'HEAD'], baseOpts);
|
|
867
|
+
autoResetAttempt.resetSucceeded = true;
|
|
798
868
|
} catch (e) {
|
|
799
|
-
|
|
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 (
|
|
872
|
+
log(`live-checkout auto-reset FAILED (reset --hard HEAD): ${error}`);
|
|
802
873
|
}
|
|
803
874
|
}
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
812
|
-
|
|
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
|
-
|
|
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
|
|
884
|
+
log(`live-checkout auto-reset FAILED (clean -fd): ${error}`);
|
|
821
885
|
}
|
|
822
886
|
}
|
|
823
887
|
}
|
|
824
888
|
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
|
|
830
|
-
//
|
|
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-${
|
|
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
|
|
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
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
|
|
854
|
-
|
|
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 });
|
package/engine/playbook.js
CHANGED
|
@@ -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
|
-
//
|
|
852
|
-
//
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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) {
|