@os-eco/overstory-cli 0.8.5 → 0.8.7
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/README.md +13 -9
- package/agents/coordinator.md +52 -4
- package/package.json +1 -1
- package/src/agents/hooks-deployer.test.ts +185 -12
- package/src/agents/hooks-deployer.ts +57 -1
- package/src/commands/clean.test.ts +136 -0
- package/src/commands/clean.ts +198 -4
- package/src/commands/coordinator.test.ts +494 -6
- package/src/commands/coordinator.ts +200 -4
- package/src/commands/dashboard.ts +84 -18
- package/src/commands/ecosystem.test.ts +101 -0
- package/src/commands/init.test.ts +211 -0
- package/src/commands/init.ts +93 -15
- package/src/commands/log.test.ts +10 -11
- package/src/commands/log.ts +31 -32
- package/src/commands/prime.ts +30 -5
- package/src/commands/sling.test.ts +33 -0
- package/src/commands/sling.ts +416 -358
- package/src/commands/spec.ts +8 -2
- package/src/commands/stop.test.ts +127 -6
- package/src/commands/stop.ts +95 -43
- package/src/commands/supervisor.ts +2 -0
- package/src/commands/watch.ts +29 -9
- package/src/config.test.ts +72 -0
- package/src/config.ts +26 -1
- package/src/index.ts +4 -1
- package/src/merge/resolver.test.ts +383 -25
- package/src/merge/resolver.ts +291 -98
- package/src/runtimes/claude.test.ts +32 -7
- package/src/runtimes/claude.ts +19 -4
- package/src/runtimes/codex.test.ts +13 -0
- package/src/runtimes/codex.ts +18 -2
- package/src/runtimes/copilot.ts +3 -0
- package/src/runtimes/cursor.test.ts +497 -0
- package/src/runtimes/cursor.ts +205 -0
- package/src/runtimes/gemini.ts +3 -0
- package/src/runtimes/opencode.ts +3 -0
- package/src/runtimes/pi.test.ts +119 -2
- package/src/runtimes/pi.ts +64 -12
- package/src/runtimes/registry.test.ts +21 -1
- package/src/runtimes/registry.ts +3 -0
- package/src/runtimes/sapling.ts +3 -0
- package/src/runtimes/types.ts +5 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +178 -0
- package/src/sessions/store.ts +44 -8
- package/src/types.ts +25 -1
- package/src/watchdog/daemon.test.ts +257 -0
- package/src/watchdog/daemon.ts +66 -23
- package/src/worktree/manager.test.ts +65 -1
- package/src/worktree/manager.ts +36 -0
- package/src/worktree/tmux.test.ts +150 -0
- package/src/worktree/tmux.ts +126 -23
package/src/merge/resolver.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* Disabled tiers are skipped. Uses Bun.spawn for all subprocess calls.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { unlinkSync } from "node:fs";
|
|
14
15
|
import { MergeError } from "../errors.ts";
|
|
15
16
|
import type { MulchClient } from "../mulch/client.ts";
|
|
16
17
|
import { getRuntime } from "../runtimes/registry.ts";
|
|
@@ -50,6 +51,30 @@ async function runGit(
|
|
|
50
51
|
return { stdout, stderr, exitCode };
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* os-eco runtime state path prefixes and exact filenames.
|
|
56
|
+
* Files matching these are bookkeeping artifacts that change during normal
|
|
57
|
+
* orchestration and should be auto-committed rather than blocking merges.
|
|
58
|
+
*/
|
|
59
|
+
const OS_ECO_STATE_PREFIXES = [
|
|
60
|
+
".seeds/",
|
|
61
|
+
".overstory/",
|
|
62
|
+
".greenhouse/",
|
|
63
|
+
".mulch/",
|
|
64
|
+
".canopy/",
|
|
65
|
+
".claude/",
|
|
66
|
+
];
|
|
67
|
+
const OS_ECO_STATE_FILES = ["CLAUDE.md"];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Returns true if a file path is an os-eco runtime state file
|
|
71
|
+
* (issue tracker, groups, expertise, prompts, etc.).
|
|
72
|
+
*/
|
|
73
|
+
function isOsEcoStateFile(filePath: string): boolean {
|
|
74
|
+
if (OS_ECO_STATE_FILES.includes(filePath)) return true;
|
|
75
|
+
return OS_ECO_STATE_PREFIXES.some((prefix) => filePath.startsWith(prefix));
|
|
76
|
+
}
|
|
77
|
+
|
|
53
78
|
/**
|
|
54
79
|
* Get the list of tracked files with uncommitted changes (unstaged or staged).
|
|
55
80
|
* Returns deduplicated list of file paths. An empty list means the working tree is clean.
|
|
@@ -70,6 +95,24 @@ async function checkDirtyWorkingTree(repoRoot: string): Promise<string[]> {
|
|
|
70
95
|
return [...new Set(files)];
|
|
71
96
|
}
|
|
72
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Auto-commit os-eco runtime state files so they don't block merges.
|
|
100
|
+
* Returns true if a commit was made, false if there was nothing to commit.
|
|
101
|
+
*/
|
|
102
|
+
async function autoCommitStateFiles(repoRoot: string, stateFiles: string[]): Promise<boolean> {
|
|
103
|
+
if (stateFiles.length === 0) return false;
|
|
104
|
+
|
|
105
|
+
const { exitCode: addCode } = await runGit(repoRoot, ["add", ...stateFiles]);
|
|
106
|
+
if (addCode !== 0) return false;
|
|
107
|
+
|
|
108
|
+
const { exitCode: commitCode } = await runGit(repoRoot, [
|
|
109
|
+
"commit",
|
|
110
|
+
"-m",
|
|
111
|
+
"chore: sync os-eco runtime state",
|
|
112
|
+
]);
|
|
113
|
+
return commitCode === 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
73
116
|
/**
|
|
74
117
|
* Get the list of conflicted files from `git diff --name-only --diff-filter=U`.
|
|
75
118
|
*/
|
|
@@ -142,6 +185,24 @@ export function resolveConflictsUnion(content: string): string | null {
|
|
|
142
185
|
});
|
|
143
186
|
}
|
|
144
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Detect if any conflict block has non-whitespace content on the canonical (HEAD) side.
|
|
190
|
+
* Returns true if auto-resolving with keep-incoming would silently discard canonical content.
|
|
191
|
+
* Use this before calling resolveConflictsKeepIncoming to prevent data loss.
|
|
192
|
+
*/
|
|
193
|
+
export function hasContentfulCanonical(content: string): boolean {
|
|
194
|
+
const conflictPattern = /^<{7} .+\n([\s\S]*?)^={7}\n([\s\S]*?)^>{7} .+\n?/gm;
|
|
195
|
+
let match = conflictPattern.exec(content);
|
|
196
|
+
while (match !== null) {
|
|
197
|
+
const canonical = match[1] ?? "";
|
|
198
|
+
if (canonical.trim().length > 0) {
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
match = conflictPattern.exec(content);
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
145
206
|
/**
|
|
146
207
|
* Check if a file has the `merge=union` gitattribute set.
|
|
147
208
|
* Returns true if `git check-attr merge -- <file>` ends with ": merge: union".
|
|
@@ -189,12 +250,15 @@ async function tryCleanMerge(
|
|
|
189
250
|
/**
|
|
190
251
|
* Tier 2: Auto-resolve conflicts by keeping incoming (agent) changes.
|
|
191
252
|
* Parses conflict markers and keeps the content between ======= and >>>>>>>.
|
|
253
|
+
* Skips files where the canonical side has non-whitespace content to prevent
|
|
254
|
+
* silent data loss — those files are escalated to higher tiers.
|
|
192
255
|
*/
|
|
193
256
|
async function tryAutoResolve(
|
|
194
257
|
conflictFiles: string[],
|
|
195
258
|
repoRoot: string,
|
|
196
|
-
): Promise<{ success: boolean; remainingConflicts: string[] }> {
|
|
259
|
+
): Promise<{ success: boolean; remainingConflicts: string[]; contentDropWarnings: string[] }> {
|
|
197
260
|
const remainingConflicts: string[] = [];
|
|
261
|
+
const contentDropWarnings: string[] = [];
|
|
198
262
|
|
|
199
263
|
for (const file of conflictFiles) {
|
|
200
264
|
const filePath = `${repoRoot}/${file}`;
|
|
@@ -202,6 +266,18 @@ async function tryAutoResolve(
|
|
|
202
266
|
try {
|
|
203
267
|
const content = await readFile(filePath);
|
|
204
268
|
const isUnion = await checkMergeUnion(repoRoot, file);
|
|
269
|
+
|
|
270
|
+
// For non-union files, check if the canonical side has content.
|
|
271
|
+
// If it does, auto-resolving would silently discard that content.
|
|
272
|
+
// Escalate to a higher tier instead.
|
|
273
|
+
if (!isUnion && hasContentfulCanonical(content)) {
|
|
274
|
+
contentDropWarnings.push(
|
|
275
|
+
`auto-resolve skipped for ${file}: canonical side has content that would be discarded`,
|
|
276
|
+
);
|
|
277
|
+
remainingConflicts.push(file);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
205
281
|
const resolved = isUnion
|
|
206
282
|
? resolveConflictsUnion(content)
|
|
207
283
|
: resolveConflictsKeepIncoming(content);
|
|
@@ -223,12 +299,12 @@ async function tryAutoResolve(
|
|
|
223
299
|
}
|
|
224
300
|
|
|
225
301
|
if (remainingConflicts.length > 0) {
|
|
226
|
-
return { success: false, remainingConflicts };
|
|
302
|
+
return { success: false, remainingConflicts, contentDropWarnings };
|
|
227
303
|
}
|
|
228
304
|
|
|
229
305
|
// All files resolved — commit
|
|
230
306
|
const { exitCode } = await runGit(repoRoot, ["commit", "--no-edit"]);
|
|
231
|
-
return { success: exitCode === 0, remainingConflicts };
|
|
307
|
+
return { success: exitCode === 0, remainingConflicts, contentDropWarnings };
|
|
232
308
|
}
|
|
233
309
|
|
|
234
310
|
/**
|
|
@@ -585,6 +661,7 @@ export function createMergeResolver(options: {
|
|
|
585
661
|
reimagineEnabled: boolean;
|
|
586
662
|
mulchClient?: MulchClient;
|
|
587
663
|
config?: OverstoryConfig;
|
|
664
|
+
onMergeSuccess?: (entry: MergeEntry) => Promise<void>;
|
|
588
665
|
}): MergeResolver {
|
|
589
666
|
return {
|
|
590
667
|
async resolve(
|
|
@@ -613,127 +690,243 @@ export function createMergeResolver(options: {
|
|
|
613
690
|
}
|
|
614
691
|
}
|
|
615
692
|
|
|
616
|
-
// Pre-check:
|
|
693
|
+
// Pre-check: auto-commit os-eco state files, stash any remaining dirty tracked files.
|
|
617
694
|
// When dirty tracked files exist, git merge refuses to start (exit 1, no conflict markers),
|
|
618
695
|
// causing all tiers to cascade with empty conflict lists and a misleading final error.
|
|
619
696
|
const dirtyFiles = await checkDirtyWorkingTree(repoRoot);
|
|
620
697
|
if (dirtyFiles.length > 0) {
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
)
|
|
698
|
+
const stateFiles = dirtyFiles.filter(isOsEcoStateFile);
|
|
699
|
+
|
|
700
|
+
// Auto-commit os-eco runtime state files so they don't block merges
|
|
701
|
+
if (stateFiles.length > 0) {
|
|
702
|
+
await autoCommitStateFiles(repoRoot, stateFiles);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Re-check after auto-commit: any remaining dirty tracked files get stashed
|
|
707
|
+
// so clean-merge-eligible branches can proceed without manual intervention.
|
|
708
|
+
let didStash = false;
|
|
709
|
+
const remainingDirty = await checkDirtyWorkingTree(repoRoot);
|
|
710
|
+
if (remainingDirty.length > 0) {
|
|
711
|
+
const { exitCode: stashCode } = await runGit(repoRoot, [
|
|
712
|
+
"stash",
|
|
713
|
+
"push",
|
|
714
|
+
"-m",
|
|
715
|
+
"ov-merge: auto-stash dirty files",
|
|
716
|
+
]);
|
|
717
|
+
if (stashCode !== 0) {
|
|
718
|
+
throw new MergeError(
|
|
719
|
+
`Working tree has uncommitted changes to tracked files: ${remainingDirty.join(", ")}. Commit or stash changes before running ov merge.`,
|
|
720
|
+
{ branchName: entry.branchName },
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
didStash = true;
|
|
625
724
|
}
|
|
626
725
|
|
|
726
|
+
const warnings: string[] = [];
|
|
627
727
|
let lastTier: ResolutionTier = "clean-merge";
|
|
628
728
|
let conflictFiles: string[] = [];
|
|
629
729
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
730
|
+
try {
|
|
731
|
+
// Delete untracked files overlapping entry.filesModified before merging.
|
|
732
|
+
// git merge refuses to run if untracked files in the working tree would
|
|
733
|
+
// be overwritten by the incoming branch. Deleting them lets the merge
|
|
734
|
+
// proceed and bring in the branch version.
|
|
735
|
+
const { stdout: untrackedOut } = await runGit(repoRoot, [
|
|
736
|
+
"ls-files",
|
|
737
|
+
"--others",
|
|
738
|
+
"--exclude-standard",
|
|
739
|
+
]);
|
|
740
|
+
const untrackedFiles = untrackedOut
|
|
741
|
+
.trim()
|
|
742
|
+
.split("\n")
|
|
743
|
+
.filter((f) => f.length > 0);
|
|
744
|
+
const entryFileSet = new Set(entry.filesModified);
|
|
745
|
+
const overlappingUntracked = untrackedFiles.filter((f) => entryFileSet.has(f));
|
|
746
|
+
for (const file of overlappingUntracked) {
|
|
747
|
+
const filePath = `${repoRoot}/${file}`;
|
|
748
|
+
try {
|
|
749
|
+
if (await Bun.file(filePath).exists()) {
|
|
750
|
+
unlinkSync(filePath);
|
|
751
|
+
}
|
|
752
|
+
warnings.push(
|
|
753
|
+
`untracked file deleted before merge: ${file} (branch version will be used)`,
|
|
754
|
+
);
|
|
755
|
+
} catch {
|
|
756
|
+
// Ignore errors removing untracked files
|
|
757
|
+
}
|
|
758
|
+
}
|
|
652
759
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
760
|
+
// Tier 1: Clean merge
|
|
761
|
+
const cleanResult = await tryCleanMerge(entry, repoRoot);
|
|
762
|
+
if (cleanResult.success) {
|
|
763
|
+
if (options.onMergeSuccess) {
|
|
764
|
+
try {
|
|
765
|
+
await options.onMergeSuccess({
|
|
766
|
+
...entry,
|
|
767
|
+
status: "merged",
|
|
768
|
+
resolvedTier: "clean-merge",
|
|
769
|
+
});
|
|
770
|
+
} catch {
|
|
771
|
+
// callback failures must not fail the merge
|
|
772
|
+
}
|
|
660
773
|
}
|
|
661
774
|
return {
|
|
662
|
-
entry: { ...entry, status: "merged", resolvedTier: "
|
|
775
|
+
entry: { ...entry, status: "merged", resolvedTier: "clean-merge" },
|
|
663
776
|
success: true,
|
|
664
|
-
tier: "
|
|
665
|
-
conflictFiles,
|
|
777
|
+
tier: "clean-merge",
|
|
778
|
+
conflictFiles: [],
|
|
666
779
|
errorMessage: null,
|
|
780
|
+
warnings,
|
|
667
781
|
};
|
|
668
782
|
}
|
|
669
|
-
conflictFiles =
|
|
670
|
-
} // If skipped, fall through to next tier
|
|
783
|
+
conflictFiles = cleanResult.conflictFiles;
|
|
671
784
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
options.
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
785
|
+
// Query conflict history (if mulchClient available)
|
|
786
|
+
let history: ConflictHistory = {
|
|
787
|
+
skipTiers: [],
|
|
788
|
+
pastResolutions: [],
|
|
789
|
+
predictedConflictFiles: [],
|
|
790
|
+
};
|
|
791
|
+
if (options.mulchClient) {
|
|
792
|
+
history = await queryConflictHistory(options.mulchClient, entry);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Tier 2: Auto-resolve (keep incoming)
|
|
796
|
+
if (!history.skipTiers.includes("auto-resolve")) {
|
|
797
|
+
lastTier = "auto-resolve";
|
|
798
|
+
const autoResult = await tryAutoResolve(conflictFiles, repoRoot);
|
|
799
|
+
if (autoResult.contentDropWarnings.length > 0) {
|
|
800
|
+
warnings.push(...autoResult.contentDropWarnings);
|
|
684
801
|
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
802
|
+
if (autoResult.success) {
|
|
803
|
+
if (options.mulchClient) {
|
|
804
|
+
recordConflictPattern(
|
|
805
|
+
options.mulchClient,
|
|
806
|
+
entry,
|
|
807
|
+
"auto-resolve",
|
|
808
|
+
conflictFiles,
|
|
809
|
+
true,
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
if (options.onMergeSuccess) {
|
|
813
|
+
try {
|
|
814
|
+
await options.onMergeSuccess({
|
|
815
|
+
...entry,
|
|
816
|
+
status: "merged",
|
|
817
|
+
resolvedTier: "auto-resolve",
|
|
818
|
+
});
|
|
819
|
+
} catch {
|
|
820
|
+
// callback failures must not fail the merge
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return {
|
|
824
|
+
entry: { ...entry, status: "merged", resolvedTier: "auto-resolve" },
|
|
825
|
+
success: true,
|
|
826
|
+
tier: "auto-resolve",
|
|
827
|
+
conflictFiles,
|
|
828
|
+
errorMessage: null,
|
|
829
|
+
warnings,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
conflictFiles = autoResult.remainingConflicts;
|
|
833
|
+
} // If skipped, fall through to next tier
|
|
834
|
+
|
|
835
|
+
// Tier 3: AI-resolve
|
|
836
|
+
if (options.aiResolveEnabled && !history.skipTiers.includes("ai-resolve")) {
|
|
837
|
+
lastTier = "ai-resolve";
|
|
838
|
+
const aiResult = await tryAiResolve(
|
|
689
839
|
conflictFiles,
|
|
690
|
-
|
|
691
|
-
|
|
840
|
+
repoRoot,
|
|
841
|
+
history.pastResolutions,
|
|
842
|
+
options.config,
|
|
843
|
+
);
|
|
844
|
+
if (aiResult.success) {
|
|
845
|
+
if (options.mulchClient) {
|
|
846
|
+
recordConflictPattern(options.mulchClient, entry, "ai-resolve", conflictFiles, true);
|
|
847
|
+
}
|
|
848
|
+
if (options.onMergeSuccess) {
|
|
849
|
+
try {
|
|
850
|
+
await options.onMergeSuccess({
|
|
851
|
+
...entry,
|
|
852
|
+
status: "merged",
|
|
853
|
+
resolvedTier: "ai-resolve",
|
|
854
|
+
});
|
|
855
|
+
} catch {
|
|
856
|
+
// callback failures must not fail the merge
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
return {
|
|
860
|
+
entry: { ...entry, status: "merged", resolvedTier: "ai-resolve" },
|
|
861
|
+
success: true,
|
|
862
|
+
tier: "ai-resolve",
|
|
863
|
+
conflictFiles,
|
|
864
|
+
errorMessage: null,
|
|
865
|
+
warnings,
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
conflictFiles = aiResult.remainingConflicts;
|
|
692
869
|
}
|
|
693
|
-
conflictFiles = aiResult.remainingConflicts;
|
|
694
|
-
}
|
|
695
870
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
871
|
+
// Tier 4: Re-imagine
|
|
872
|
+
if (options.reimagineEnabled && !history.skipTiers.includes("reimagine")) {
|
|
873
|
+
lastTier = "reimagine";
|
|
874
|
+
const reimagineResult = await tryReimagine(
|
|
875
|
+
entry,
|
|
876
|
+
canonicalBranch,
|
|
877
|
+
repoRoot,
|
|
878
|
+
options.config,
|
|
879
|
+
);
|
|
880
|
+
if (reimagineResult.success) {
|
|
881
|
+
if (options.mulchClient) {
|
|
882
|
+
recordConflictPattern(options.mulchClient, entry, "reimagine", conflictFiles, true);
|
|
883
|
+
}
|
|
884
|
+
if (options.onMergeSuccess) {
|
|
885
|
+
try {
|
|
886
|
+
await options.onMergeSuccess({
|
|
887
|
+
...entry,
|
|
888
|
+
status: "merged",
|
|
889
|
+
resolvedTier: "reimagine",
|
|
890
|
+
});
|
|
891
|
+
} catch {
|
|
892
|
+
// callback failures must not fail the merge
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return {
|
|
896
|
+
entry: { ...entry, status: "merged", resolvedTier: "reimagine" },
|
|
897
|
+
success: true,
|
|
898
|
+
tier: "reimagine",
|
|
899
|
+
conflictFiles: [],
|
|
900
|
+
errorMessage: null,
|
|
901
|
+
warnings,
|
|
902
|
+
};
|
|
708
903
|
}
|
|
709
|
-
return {
|
|
710
|
-
entry: { ...entry, status: "merged", resolvedTier: "reimagine" },
|
|
711
|
-
success: true,
|
|
712
|
-
tier: "reimagine",
|
|
713
|
-
conflictFiles: [],
|
|
714
|
-
errorMessage: null,
|
|
715
|
-
};
|
|
716
904
|
}
|
|
717
|
-
}
|
|
718
905
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
906
|
+
// All enabled tiers failed — abort any in-progress merge
|
|
907
|
+
try {
|
|
908
|
+
await runGit(repoRoot, ["merge", "--abort"]);
|
|
909
|
+
} catch {
|
|
910
|
+
// merge --abort may fail if there's no merge in progress (e.g., after reimagine)
|
|
911
|
+
}
|
|
725
912
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
913
|
+
if (options.mulchClient) {
|
|
914
|
+
recordConflictPattern(options.mulchClient, entry, lastTier, conflictFiles, false);
|
|
915
|
+
}
|
|
729
916
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
917
|
+
return {
|
|
918
|
+
entry: { ...entry, status: "failed", resolvedTier: null },
|
|
919
|
+
success: false,
|
|
920
|
+
tier: lastTier,
|
|
921
|
+
conflictFiles,
|
|
922
|
+
errorMessage: `All enabled resolution tiers failed (last attempted: ${lastTier})`,
|
|
923
|
+
warnings,
|
|
924
|
+
};
|
|
925
|
+
} finally {
|
|
926
|
+
if (didStash) {
|
|
927
|
+
await runGit(repoRoot, ["stash", "pop"]);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
737
930
|
},
|
|
738
931
|
};
|
|
739
932
|
}
|
|
@@ -220,14 +220,16 @@ describe("ClaudeRuntime", () => {
|
|
|
220
220
|
expect(state).toEqual({ phase: "ready" });
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
-
test("returns
|
|
223
|
+
test("returns loading for prompt indicator + shift+tab (no bypass permissions)", () => {
|
|
224
|
+
// shift+tab appears in ALL Claude Code sessions — it must NOT trigger ready
|
|
224
225
|
const state = runtime.detectReady("Claude Code\n\u276f\nshift+tab to chat");
|
|
225
|
-
expect(state).toEqual({ phase: "
|
|
226
|
+
expect(state).toEqual({ phase: "loading" });
|
|
226
227
|
});
|
|
227
228
|
|
|
228
|
-
test('returns
|
|
229
|
+
test('returns loading for Try " + shift+tab (no bypass permissions)', () => {
|
|
230
|
+
// False-positive scenario: shift+tab alone is not a reliable readiness signal
|
|
229
231
|
const state = runtime.detectReady('Try "help"\nshift+tab');
|
|
230
|
-
expect(state).toEqual({ phase: "
|
|
232
|
+
expect(state).toEqual({ phase: "loading" });
|
|
231
233
|
});
|
|
232
234
|
|
|
233
235
|
test("returns dialog for trust dialog", () => {
|
|
@@ -235,6 +237,20 @@ describe("ClaudeRuntime", () => {
|
|
|
235
237
|
expect(state).toEqual({ phase: "dialog", action: "Enter" });
|
|
236
238
|
});
|
|
237
239
|
|
|
240
|
+
test("returns dialog for bypass permissions confirmation", () => {
|
|
241
|
+
const state = runtime.detectReady(
|
|
242
|
+
"WARNING: Claude Code running in Bypass Permissions mode\n❯ 1. No, exit\n2. Yes, I accept",
|
|
243
|
+
);
|
|
244
|
+
expect(state).toEqual({ phase: "dialog", action: "type:2" });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("bypass permissions confirmation takes precedence over ready indicators", () => {
|
|
248
|
+
const state = runtime.detectReady(
|
|
249
|
+
"WARNING: Claude Code running in Bypass Permissions mode\n❯ 1. No, exit\n2. Yes, I accept\nbypass permissions",
|
|
250
|
+
);
|
|
251
|
+
expect(state).toEqual({ phase: "dialog", action: "type:2" });
|
|
252
|
+
});
|
|
253
|
+
|
|
238
254
|
test("trust dialog takes precedence over ready indicators", () => {
|
|
239
255
|
const state = runtime.detectReady("trust this folder\n\u276f\nbypass permissions");
|
|
240
256
|
expect(state).toEqual({ phase: "dialog", action: "Enter" });
|
|
@@ -577,9 +593,10 @@ describe("ClaudeRuntime integration: detectReady matches pre-refactor tmux behav
|
|
|
577
593
|
expect(state.phase).toBe("ready");
|
|
578
594
|
});
|
|
579
595
|
|
|
580
|
-
test("
|
|
596
|
+
test("loading: 'Try \"help\"' + 'shift+tab' (no bypass permissions — false-positive fix)", () => {
|
|
597
|
+
// shift+tab appears in all Claude Code sessions, must not trigger ready without bypass permissions
|
|
581
598
|
const state = runtime.detectReady('Try "help"\nshift+tab');
|
|
582
|
-
expect(state.phase).toBe("
|
|
599
|
+
expect(state.phase).toBe("loading");
|
|
583
600
|
});
|
|
584
601
|
|
|
585
602
|
test("not ready: only prompt (no status bar)", () => {
|
|
@@ -597,6 +614,14 @@ describe("ClaudeRuntime integration: detectReady matches pre-refactor tmux behav
|
|
|
597
614
|
expect(state.phase).toBe("dialog");
|
|
598
615
|
expect((state as { phase: "dialog"; action: string }).action).toBe("Enter");
|
|
599
616
|
});
|
|
617
|
+
|
|
618
|
+
test("dialog: bypass permissions confirmation", () => {
|
|
619
|
+
const state = runtime.detectReady(
|
|
620
|
+
"WARNING: Claude Code running in Bypass Permissions mode\n❯ 1. No, exit\n2. Yes, I accept",
|
|
621
|
+
);
|
|
622
|
+
expect(state.phase).toBe("dialog");
|
|
623
|
+
expect((state as { phase: "dialog"; action: string }).action).toBe("type:2");
|
|
624
|
+
});
|
|
600
625
|
});
|
|
601
626
|
|
|
602
627
|
describe("ClaudeRuntime integration: buildEnv matches pre-refactor env injection", () => {
|
|
@@ -652,6 +677,6 @@ describe("ClaudeRuntime integration: registry resolves 'claude' as default", ()
|
|
|
652
677
|
test("getRuntime rejects unknown runtimes", async () => {
|
|
653
678
|
const { getRuntime } = await import("./registry.ts");
|
|
654
679
|
expect(() => getRuntime("aider")).toThrow('Unknown runtime: "aider"');
|
|
655
|
-
expect(() => getRuntime("
|
|
680
|
+
expect(() => getRuntime("nonexistent")).toThrow('Unknown runtime: "nonexistent"');
|
|
656
681
|
});
|
|
657
682
|
});
|
package/src/runtimes/claude.ts
CHANGED
|
@@ -31,6 +31,9 @@ export class ClaudeRuntime implements AgentRuntime {
|
|
|
31
31
|
/** Unique identifier for this runtime. */
|
|
32
32
|
readonly id = "claude";
|
|
33
33
|
|
|
34
|
+
/** Stability level. Claude Code is the primary runtime. */
|
|
35
|
+
readonly stability = "stable" as const;
|
|
36
|
+
|
|
34
37
|
/** Relative path to the instruction file within a worktree. */
|
|
35
38
|
readonly instructionPath = ".claude/CLAUDE.md";
|
|
36
39
|
|
|
@@ -136,14 +139,25 @@ export class ClaudeRuntime implements AgentRuntime {
|
|
|
136
139
|
*
|
|
137
140
|
* Detection phases:
|
|
138
141
|
* - Trust dialog: "trust this folder" detected → `{ phase: "dialog", action: "Enter" }`
|
|
139
|
-
* - Ready: prompt indicator (❯ or 'Try "') AND status bar ("bypass permissions"
|
|
140
|
-
*
|
|
142
|
+
* - Ready: prompt indicator (❯ or 'Try "') AND status bar ("bypass permissions")
|
|
143
|
+
* both present → `{ phase: "ready" }`
|
|
141
144
|
* - Otherwise → `{ phase: "loading" }`
|
|
142
145
|
*
|
|
143
146
|
* @param paneContent - Captured tmux pane content to analyze
|
|
144
147
|
* @returns Current readiness phase
|
|
145
148
|
*/
|
|
146
149
|
detectReady(paneContent: string): ReadyState {
|
|
150
|
+
// Claude Code v2.1.71+ shows a dedicated bypass confirmation screen.
|
|
151
|
+
// It already contains both a prompt marker and the phrase "bypass permissions",
|
|
152
|
+
// so it must be detected before the normal ready heuristics.
|
|
153
|
+
if (
|
|
154
|
+
paneContent.includes("WARNING: Claude Code running in Bypass Permissions mode") &&
|
|
155
|
+
paneContent.includes("1. No, exit") &&
|
|
156
|
+
paneContent.includes("2. Yes, I accept")
|
|
157
|
+
) {
|
|
158
|
+
return { phase: "dialog", action: "type:2" };
|
|
159
|
+
}
|
|
160
|
+
|
|
147
161
|
// Trust dialog takes precedence — it replaces the normal TUI temporarily.
|
|
148
162
|
// The caller should send the action key to dismiss it.
|
|
149
163
|
if (paneContent.includes("trust this folder")) {
|
|
@@ -155,8 +169,9 @@ export class ClaudeRuntime implements AgentRuntime {
|
|
|
155
169
|
const hasPrompt = paneContent.includes("\u276f") || paneContent.includes('Try "');
|
|
156
170
|
|
|
157
171
|
// Phase 2: status bar text confirms full TUI render.
|
|
158
|
-
|
|
159
|
-
|
|
172
|
+
// Only match 'bypass permissions' — 'shift+tab' appears in ALL Claude Code sessions
|
|
173
|
+
// regardless of permission mode and would cause false-positive ready detection.
|
|
174
|
+
const hasStatusBar = paneContent.includes("bypass permissions");
|
|
160
175
|
|
|
161
176
|
if (hasPrompt && hasStatusBar) {
|
|
162
177
|
return { phase: "ready" };
|
|
@@ -151,6 +151,19 @@ describe("CodexRuntime", () => {
|
|
|
151
151
|
expect(cmd).not.toContain("This inline content should be ignored");
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
test("sharedWritableDirs are exposed with --add-dir", () => {
|
|
155
|
+
const opts: SpawnOpts = {
|
|
156
|
+
model: "gpt-5-codex",
|
|
157
|
+
permissionMode: "bypass",
|
|
158
|
+
cwd: "/tmp/worktree",
|
|
159
|
+
sharedWritableDirs: ["/project/.overstory", "/project/.git"],
|
|
160
|
+
env: {},
|
|
161
|
+
};
|
|
162
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
163
|
+
expect(cmd).toContain("--add-dir '/project/.overstory'");
|
|
164
|
+
expect(cmd).toContain("--add-dir '/project/.git'");
|
|
165
|
+
});
|
|
166
|
+
|
|
154
167
|
test("without appendSystemPrompt uses default AGENTS.md prompt", () => {
|
|
155
168
|
const opts: SpawnOpts = {
|
|
156
169
|
model: "gpt-5-codex",
|