@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.
Files changed (53) hide show
  1. package/README.md +13 -9
  2. package/agents/coordinator.md +52 -4
  3. package/package.json +1 -1
  4. package/src/agents/hooks-deployer.test.ts +185 -12
  5. package/src/agents/hooks-deployer.ts +57 -1
  6. package/src/commands/clean.test.ts +136 -0
  7. package/src/commands/clean.ts +198 -4
  8. package/src/commands/coordinator.test.ts +494 -6
  9. package/src/commands/coordinator.ts +200 -4
  10. package/src/commands/dashboard.ts +84 -18
  11. package/src/commands/ecosystem.test.ts +101 -0
  12. package/src/commands/init.test.ts +211 -0
  13. package/src/commands/init.ts +93 -15
  14. package/src/commands/log.test.ts +10 -11
  15. package/src/commands/log.ts +31 -32
  16. package/src/commands/prime.ts +30 -5
  17. package/src/commands/sling.test.ts +33 -0
  18. package/src/commands/sling.ts +416 -358
  19. package/src/commands/spec.ts +8 -2
  20. package/src/commands/stop.test.ts +127 -6
  21. package/src/commands/stop.ts +95 -43
  22. package/src/commands/supervisor.ts +2 -0
  23. package/src/commands/watch.ts +29 -9
  24. package/src/config.test.ts +72 -0
  25. package/src/config.ts +26 -1
  26. package/src/index.ts +4 -1
  27. package/src/merge/resolver.test.ts +383 -25
  28. package/src/merge/resolver.ts +291 -98
  29. package/src/runtimes/claude.test.ts +32 -7
  30. package/src/runtimes/claude.ts +19 -4
  31. package/src/runtimes/codex.test.ts +13 -0
  32. package/src/runtimes/codex.ts +18 -2
  33. package/src/runtimes/copilot.ts +3 -0
  34. package/src/runtimes/cursor.test.ts +497 -0
  35. package/src/runtimes/cursor.ts +205 -0
  36. package/src/runtimes/gemini.ts +3 -0
  37. package/src/runtimes/opencode.ts +3 -0
  38. package/src/runtimes/pi.test.ts +119 -2
  39. package/src/runtimes/pi.ts +64 -12
  40. package/src/runtimes/registry.test.ts +21 -1
  41. package/src/runtimes/registry.ts +3 -0
  42. package/src/runtimes/sapling.ts +3 -0
  43. package/src/runtimes/types.ts +5 -0
  44. package/src/schema-consistency.test.ts +1 -0
  45. package/src/sessions/store.test.ts +178 -0
  46. package/src/sessions/store.ts +44 -8
  47. package/src/types.ts +25 -1
  48. package/src/watchdog/daemon.test.ts +257 -0
  49. package/src/watchdog/daemon.ts +66 -23
  50. package/src/worktree/manager.test.ts +65 -1
  51. package/src/worktree/manager.ts +36 -0
  52. package/src/worktree/tmux.test.ts +150 -0
  53. package/src/worktree/tmux.ts +126 -23
@@ -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: abort early if working tree has uncommitted changes.
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
- throw new MergeError(
622
- `Working tree has uncommitted changes to tracked files: ${dirtyFiles.join(", ")}. Commit or stash changes before running ov merge.`,
623
- { branchName: entry.branchName },
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
- // Tier 1: Clean merge
631
- const cleanResult = await tryCleanMerge(entry, repoRoot);
632
- if (cleanResult.success) {
633
- return {
634
- entry: { ...entry, status: "merged", resolvedTier: "clean-merge" },
635
- success: true,
636
- tier: "clean-merge",
637
- conflictFiles: [],
638
- errorMessage: null,
639
- };
640
- }
641
- conflictFiles = cleanResult.conflictFiles;
642
-
643
- // Query conflict history (if mulchClient available)
644
- let history: ConflictHistory = {
645
- skipTiers: [],
646
- pastResolutions: [],
647
- predictedConflictFiles: [],
648
- };
649
- if (options.mulchClient) {
650
- history = await queryConflictHistory(options.mulchClient, entry);
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
- // Tier 2: Auto-resolve (keep incoming)
654
- if (!history.skipTiers.includes("auto-resolve")) {
655
- lastTier = "auto-resolve";
656
- const autoResult = await tryAutoResolve(conflictFiles, repoRoot);
657
- if (autoResult.success) {
658
- if (options.mulchClient) {
659
- recordConflictPattern(options.mulchClient, entry, "auto-resolve", conflictFiles, true);
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: "auto-resolve" },
775
+ entry: { ...entry, status: "merged", resolvedTier: "clean-merge" },
663
776
  success: true,
664
- tier: "auto-resolve",
665
- conflictFiles,
777
+ tier: "clean-merge",
778
+ conflictFiles: [],
666
779
  errorMessage: null,
780
+ warnings,
667
781
  };
668
782
  }
669
- conflictFiles = autoResult.remainingConflicts;
670
- } // If skipped, fall through to next tier
783
+ conflictFiles = cleanResult.conflictFiles;
671
784
 
672
- // Tier 3: AI-resolve
673
- if (options.aiResolveEnabled && !history.skipTiers.includes("ai-resolve")) {
674
- lastTier = "ai-resolve";
675
- const aiResult = await tryAiResolve(
676
- conflictFiles,
677
- repoRoot,
678
- history.pastResolutions,
679
- options.config,
680
- );
681
- if (aiResult.success) {
682
- if (options.mulchClient) {
683
- recordConflictPattern(options.mulchClient, entry, "ai-resolve", conflictFiles, true);
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
- return {
686
- entry: { ...entry, status: "merged", resolvedTier: "ai-resolve" },
687
- success: true,
688
- tier: "ai-resolve",
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
- errorMessage: null,
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
- // Tier 4: Re-imagine
697
- if (options.reimagineEnabled && !history.skipTiers.includes("reimagine")) {
698
- lastTier = "reimagine";
699
- const reimagineResult = await tryReimagine(
700
- entry,
701
- canonicalBranch,
702
- repoRoot,
703
- options.config,
704
- );
705
- if (reimagineResult.success) {
706
- if (options.mulchClient) {
707
- recordConflictPattern(options.mulchClient, entry, "reimagine", conflictFiles, true);
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
- // All enabled tiers failed — abort any in-progress merge
720
- try {
721
- await runGit(repoRoot, ["merge", "--abort"]);
722
- } catch {
723
- // merge --abort may fail if there's no merge in progress (e.g., after reimagine)
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
- if (options.mulchClient) {
727
- recordConflictPattern(options.mulchClient, entry, lastTier, conflictFiles, false);
728
- }
913
+ if (options.mulchClient) {
914
+ recordConflictPattern(options.mulchClient, entry, lastTier, conflictFiles, false);
915
+ }
729
916
 
730
- return {
731
- entry: { ...entry, status: "failed", resolvedTier: null },
732
- success: false,
733
- tier: lastTier,
734
- conflictFiles,
735
- errorMessage: `All enabled resolution tiers failed (last attempted: ${lastTier})`,
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 ready for prompt indicator + shift+tab", () => {
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: "ready" });
226
+ expect(state).toEqual({ phase: "loading" });
226
227
  });
227
228
 
228
- test('returns ready for Try " + shift+tab', () => {
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: "ready" });
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("ready: 'Try \"help\"' + 'shift+tab'", () => {
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("ready");
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("cursor")).toThrow('Unknown runtime: "cursor"');
680
+ expect(() => getRuntime("nonexistent")).toThrow('Unknown runtime: "nonexistent"');
656
681
  });
657
682
  });
@@ -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
- * or "shift+tab") both present → `{ phase: "ready" }`
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
- const hasStatusBar =
159
- paneContent.includes("bypass permissions") || paneContent.includes("shift+tab");
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",