@os-eco/overstory-cli 0.8.6 → 0.9.1

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 (49) hide show
  1. package/README.md +18 -11
  2. package/agents/ov-co-creation.md +90 -0
  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/agents/overlay.ts +13 -0
  7. package/src/canopy/client.test.ts +107 -0
  8. package/src/canopy/client.ts +179 -0
  9. package/src/commands/coordinator.test.ts +74 -5
  10. package/src/commands/coordinator.ts +105 -25
  11. package/src/commands/dashboard.ts +85 -19
  12. package/src/commands/discover.test.ts +288 -0
  13. package/src/commands/discover.ts +202 -0
  14. package/src/commands/ecosystem.test.ts +101 -0
  15. package/src/commands/init.test.ts +76 -1
  16. package/src/commands/init.ts +36 -14
  17. package/src/commands/sling.test.ts +33 -0
  18. package/src/commands/sling.ts +126 -38
  19. package/src/commands/supervisor.ts +2 -0
  20. package/src/commands/update.test.ts +1 -0
  21. package/src/commands/watch.ts +9 -9
  22. package/src/e2e/init-sling-lifecycle.test.ts +2 -1
  23. package/src/index.ts +6 -1
  24. package/src/mail/store.ts +2 -1
  25. package/src/merge/resolver.test.ts +141 -7
  26. package/src/merge/resolver.ts +61 -8
  27. package/src/runtimes/claude.test.ts +32 -7
  28. package/src/runtimes/claude.ts +19 -4
  29. package/src/runtimes/codex.test.ts +13 -0
  30. package/src/runtimes/codex.ts +18 -2
  31. package/src/runtimes/copilot.ts +3 -0
  32. package/src/runtimes/cursor.test.ts +497 -0
  33. package/src/runtimes/cursor.ts +205 -0
  34. package/src/runtimes/gemini.ts +3 -0
  35. package/src/runtimes/opencode.ts +3 -0
  36. package/src/runtimes/pi.test.ts +1 -1
  37. package/src/runtimes/pi.ts +3 -0
  38. package/src/runtimes/registry.test.ts +21 -1
  39. package/src/runtimes/registry.ts +3 -0
  40. package/src/runtimes/sapling.ts +3 -0
  41. package/src/runtimes/types.ts +5 -0
  42. package/src/schema-consistency.test.ts +2 -0
  43. package/src/sessions/store.test.ts +178 -0
  44. package/src/sessions/store.ts +69 -12
  45. package/src/types.ts +69 -2
  46. package/src/watchdog/daemon.ts +41 -0
  47. package/src/worktree/tmux.test.ts +150 -0
  48. package/src/worktree/tmux.ts +126 -23
  49. package/templates/overlay.md.tmpl +2 -0
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ import { createCompletionsCommand } from "./commands/completions.ts";
16
16
  import { createCoordinatorCommand } from "./commands/coordinator.ts";
17
17
  import { createCostsCommand } from "./commands/costs.ts";
18
18
  import { createDashboardCommand } from "./commands/dashboard.ts";
19
+ import { createDiscoverCommand } from "./commands/discover.ts";
19
20
  import { createDoctorCommand } from "./commands/doctor.ts";
20
21
  import { createEcosystemCommand } from "./commands/ecosystem.ts";
21
22
  import { createErrorsCommand } from "./commands/errors.ts";
@@ -49,7 +50,7 @@ import { ConfigError, OverstoryError, WorktreeError } from "./errors.ts";
49
50
  import { jsonError } from "./json.ts";
50
51
  import { brand, chalk, muted, setQuiet } from "./logging/color.ts";
51
52
 
52
- export const VERSION = "0.8.6";
53
+ export const VERSION = "0.9.1";
53
54
 
54
55
  const rawArgs = process.argv.slice(2);
55
56
 
@@ -76,6 +77,7 @@ const COMMANDS = [
76
77
  "stop",
77
78
  "status",
78
79
  "dashboard",
80
+ "discover",
79
81
  "inspect",
80
82
  "clean",
81
83
  "doctor",
@@ -286,6 +288,7 @@ program
286
288
  .option("--dispatch-max-agents <n>", "Per-lead max agents ceiling (injected into overlay)")
287
289
  .option("--runtime <name>", "Runtime adapter (default: config or claude)")
288
290
  .option("--base-branch <branch>", "Base branch for worktree creation (default: current HEAD)")
291
+ .option("--profile <name>", "Canopy profile to apply to agent overlay")
289
292
  .option("--json", "Output result as JSON")
290
293
  .action(async (taskId, opts) => {
291
294
  await slingCommand(taskId, opts);
@@ -329,6 +332,8 @@ program.addCommand(createStatusCommand());
329
332
 
330
333
  program.addCommand(createDashboardCommand());
331
334
 
335
+ program.addCommand(createDiscoverCommand());
336
+
332
337
  program.addCommand(createInspectCommand());
333
338
 
334
339
  program
package/src/mail/store.ts CHANGED
@@ -83,9 +83,10 @@ function migrateSchema(db: Database): void {
83
83
  const hasCheckConstraints = row.sql.includes("CHECK");
84
84
  const hasPayloadColumn = row.sql.includes("payload");
85
85
  const hasProtocolTypes = row.sql.includes("worker_done");
86
+ const hasDecisionGate = row.sql.includes("decision_gate");
86
87
 
87
88
  // If schema is fully up to date, nothing to do
88
- if (hasCheckConstraints && hasPayloadColumn && hasProtocolTypes) {
89
+ if (hasCheckConstraints && hasPayloadColumn && hasProtocolTypes && hasDecisionGate) {
89
90
  return;
90
91
  }
91
92
 
@@ -22,6 +22,7 @@ import type { MergeEntry, ParsedConflictPattern } from "../types.ts";
22
22
  import {
23
23
  buildConflictHistory,
24
24
  createMergeResolver,
25
+ hasContentfulCanonical,
25
26
  looksLikeProse,
26
27
  parseConflictPatterns,
27
28
  resolveConflictsUnion,
@@ -86,6 +87,20 @@ async function setupContentConflict(dir: string, baseBranch: string): Promise<vo
86
87
  await commitFile(dir, "src/test.ts", "main modified content\n");
87
88
  }
88
89
 
90
+ /**
91
+ * Set up a conflict where the canonical (HEAD) side is empty in the conflict marker.
92
+ * Main deletes a shared line; feature replaces it. Git produces a conflict with an
93
+ * empty HEAD side, so hasContentfulCanonical returns false and auto-resolve can safely
94
+ * keep the incoming content.
95
+ */
96
+ async function setupEmptyCanonicalConflict(dir: string, baseBranch: string): Promise<void> {
97
+ await commitFile(dir, "src/test.ts", "line1\nshared line\nline3\n");
98
+ await runGitInDir(dir, ["checkout", "-b", "feature-branch"]);
99
+ await commitFile(dir, "src/test.ts", "line1\nnew content\nline3\n");
100
+ await runGitInDir(dir, ["checkout", baseBranch]);
101
+ await commitFile(dir, "src/test.ts", "line1\nline3\n"); // main deletes "shared line"
102
+ }
103
+
89
104
  /**
90
105
  * Create a delete/modify conflict: file is deleted on main but modified on
91
106
  * the feature branch. This produces a conflict with NO conflict markers in
@@ -431,11 +446,13 @@ describe("createMergeResolver", () => {
431
446
  });
432
447
 
433
448
  describe("Tier 1 fail -> Tier 2: Auto-resolve", () => {
434
- test("auto-resolves conflicts keeping incoming changes with correct content", async () => {
449
+ test("auto-resolves conflicts when canonical side is empty (keeps incoming)", async () => {
435
450
  const repoDir = await createTempGitRepo();
436
451
  try {
437
452
  const defaultBranch = await getDefaultBranch(repoDir);
438
- await setupContentConflict(repoDir, defaultBranch);
453
+ // Use empty-canonical setup: main deletes a line, feature replaces it.
454
+ // The conflict marker has an empty HEAD side, so auto-resolve is safe.
455
+ await setupEmptyCanonicalConflict(repoDir, defaultBranch);
439
456
 
440
457
  const entry = makeTestEntry({
441
458
  branchName: "feature-branch",
@@ -453,11 +470,12 @@ describe("createMergeResolver", () => {
453
470
  expect(result.tier).toBe("auto-resolve");
454
471
  expect(result.entry.status).toBe("merged");
455
472
  expect(result.entry.resolvedTier).toBe("auto-resolve");
473
+ expect(result.warnings).toEqual([]);
456
474
 
457
475
  // The resolved file should contain the incoming (feature branch) content
458
476
  const file = Bun.file(join(repoDir, "src/test.ts"));
459
477
  const content = await file.text();
460
- expect(content).toBe("feature content\n");
478
+ expect(content).toContain("new content");
461
479
  } finally {
462
480
  await cleanupTempDir(repoDir);
463
481
  }
@@ -688,6 +706,7 @@ describe("createMergeResolver", () => {
688
706
  expect(result).toHaveProperty("tier");
689
707
  expect(result).toHaveProperty("conflictFiles");
690
708
  expect(result).toHaveProperty("errorMessage");
709
+ expect(result).toHaveProperty("warnings");
691
710
  });
692
711
 
693
712
  test("failed result preserves original entry fields", async () => {
@@ -806,6 +825,117 @@ describe("createMergeResolver", () => {
806
825
  });
807
826
  });
808
827
 
828
+ describe("hasContentfulCanonical", () => {
829
+ test("returns true when canonical side has content", () => {
830
+ const content = [
831
+ "<<<<<<< HEAD\n",
832
+ "canonical content\n",
833
+ "=======\n",
834
+ "incoming content\n",
835
+ ">>>>>>> feature-branch\n",
836
+ ].join("");
837
+ expect(hasContentfulCanonical(content)).toBe(true);
838
+ });
839
+
840
+ test("returns false when canonical side is empty", () => {
841
+ const content = [
842
+ "<<<<<<< HEAD\n",
843
+ "=======\n",
844
+ "incoming content\n",
845
+ ">>>>>>> feature-branch\n",
846
+ ].join("");
847
+ expect(hasContentfulCanonical(content)).toBe(false);
848
+ });
849
+
850
+ test("returns false when canonical is whitespace only", () => {
851
+ const content = [
852
+ "<<<<<<< HEAD\n",
853
+ " \n",
854
+ "\t\n",
855
+ "=======\n",
856
+ "incoming content\n",
857
+ ">>>>>>> feature-branch\n",
858
+ ].join("");
859
+ expect(hasContentfulCanonical(content)).toBe(false);
860
+ });
861
+
862
+ test("returns false when no conflict markers", () => {
863
+ expect(hasContentfulCanonical("no conflicts here\n")).toBe(false);
864
+ expect(hasContentfulCanonical("")).toBe(false);
865
+ });
866
+
867
+ test("returns true if ANY block has canonical content (multiple blocks)", () => {
868
+ const block1 = "<<<<<<< HEAD\n=======\nonly incoming\n>>>>>>> branch\n";
869
+ const block2 = "<<<<<<< HEAD\ncanonical content\n=======\nincoming\n>>>>>>> branch\n";
870
+ const content = `${block1}middle\n${block2}`;
871
+ expect(hasContentfulCanonical(content)).toBe(true);
872
+ });
873
+ });
874
+
875
+ describe("auto-resolve: content protection", () => {
876
+ test("auto-resolve skips files with contentful canonical, result includes warning", async () => {
877
+ const repoDir = await createTempGitRepo();
878
+ try {
879
+ const defaultBranch = await getDefaultBranch(repoDir);
880
+ // setupContentConflict: both canonical and incoming have content
881
+ await setupContentConflict(repoDir, defaultBranch);
882
+
883
+ const entry = makeTestEntry({
884
+ branchName: "feature-branch",
885
+ filesModified: ["src/test.ts"],
886
+ });
887
+
888
+ // AI and reimagine disabled — should FAIL because auto-resolve correctly refuses
889
+ const resolver = createMergeResolver({
890
+ aiResolveEnabled: false,
891
+ reimagineEnabled: false,
892
+ });
893
+
894
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
895
+
896
+ expect(result.success).toBe(false);
897
+ expect(result.warnings.length).toBeGreaterThan(0);
898
+ expect(result.warnings[0]).toContain("src/test.ts");
899
+ } finally {
900
+ await cleanupTempDir(repoDir);
901
+ }
902
+ });
903
+ });
904
+
905
+ describe("untracked files: no silent commit", () => {
906
+ test("untracked overlapping files are deleted, not committed", async () => {
907
+ const repoDir = await createTempGitRepo();
908
+ try {
909
+ const defaultBranch = await getDefaultBranch(repoDir);
910
+ await setupCleanMerge(repoDir, defaultBranch);
911
+
912
+ // Place an untracked file at the path the feature branch will bring in
913
+ await Bun.write(`${repoDir}/src/feature-file.ts`, "local untracked content\n");
914
+
915
+ const entry = makeTestEntry({
916
+ branchName: "feature-branch",
917
+ filesModified: ["src/feature-file.ts"],
918
+ });
919
+
920
+ const resolver = createMergeResolver({
921
+ aiResolveEnabled: false,
922
+ reimagineEnabled: false,
923
+ });
924
+
925
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
926
+
927
+ expect(result.success).toBe(true);
928
+ expect(result.warnings.some((w) => w.includes("src/feature-file.ts"))).toBe(true);
929
+
930
+ // Verify git log does NOT contain the "commit untracked files before merge" commit
931
+ const log = await runGitInDir(repoDir, ["log", "--oneline"]);
932
+ expect(log).not.toContain("commit untracked files before merge");
933
+ } finally {
934
+ await cleanupTempDir(repoDir);
935
+ }
936
+ });
937
+ });
938
+
809
939
  describe("Tier 3: AI-resolve prose rejection", () => {
810
940
  test("rejects prose output and falls through to failure", async () => {
811
941
  const repoDir = await createTempGitRepo();
@@ -858,7 +988,8 @@ describe("createMergeResolver", () => {
858
988
  const repoDir = await createTempGitRepo();
859
989
  try {
860
990
  const defaultBranch = await getDefaultBranch(repoDir);
861
- await setupContentConflict(repoDir, defaultBranch);
991
+ // Use empty-canonical setup so auto-resolve completes successfully
992
+ await setupEmptyCanonicalConflict(repoDir, defaultBranch);
862
993
 
863
994
  const entry = makeTestEntry({
864
995
  branchName: "feature-branch",
@@ -884,7 +1015,8 @@ describe("createMergeResolver", () => {
884
1015
  const repoDir = await createTempGitRepo();
885
1016
  try {
886
1017
  const defaultBranch = await getDefaultBranch(repoDir);
887
- await setupContentConflict(repoDir, defaultBranch);
1018
+ // Use empty-canonical setup so auto-resolve completes successfully
1019
+ await setupEmptyCanonicalConflict(repoDir, defaultBranch);
888
1020
 
889
1021
  const entry = makeTestEntry({
890
1022
  branchName: "feature-branch",
@@ -1014,7 +1146,8 @@ describe("createMergeResolver", () => {
1014
1146
  const repoDir = await createTempGitRepo();
1015
1147
  try {
1016
1148
  const defaultBranch = await getDefaultBranch(repoDir);
1017
- await setupContentConflict(repoDir, defaultBranch);
1149
+ // Use empty-canonical setup so auto-resolve completes successfully
1150
+ await setupEmptyCanonicalConflict(repoDir, defaultBranch);
1018
1151
 
1019
1152
  const entry = makeTestEntry({
1020
1153
  branchName: "feature-branch",
@@ -1709,7 +1842,8 @@ describe("createMergeResolver", () => {
1709
1842
  const repoDir = await createTempGitRepo();
1710
1843
  try {
1711
1844
  const defaultBranch = await getDefaultBranch(repoDir);
1712
- await setupContentConflict(repoDir, defaultBranch);
1845
+ // Use empty-canonical setup so auto-resolve completes successfully
1846
+ await setupEmptyCanonicalConflict(repoDir, defaultBranch);
1713
1847
 
1714
1848
  const entry = makeTestEntry({
1715
1849
  branchName: "feature-branch",
@@ -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";
@@ -184,6 +185,24 @@ export function resolveConflictsUnion(content: string): string | null {
184
185
  });
185
186
  }
186
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
+
187
206
  /**
188
207
  * Check if a file has the `merge=union` gitattribute set.
189
208
  * Returns true if `git check-attr merge -- <file>` ends with ": merge: union".
@@ -231,12 +250,15 @@ async function tryCleanMerge(
231
250
  /**
232
251
  * Tier 2: Auto-resolve conflicts by keeping incoming (agent) changes.
233
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.
234
255
  */
235
256
  async function tryAutoResolve(
236
257
  conflictFiles: string[],
237
258
  repoRoot: string,
238
- ): Promise<{ success: boolean; remainingConflicts: string[] }> {
259
+ ): Promise<{ success: boolean; remainingConflicts: string[]; contentDropWarnings: string[] }> {
239
260
  const remainingConflicts: string[] = [];
261
+ const contentDropWarnings: string[] = [];
240
262
 
241
263
  for (const file of conflictFiles) {
242
264
  const filePath = `${repoRoot}/${file}`;
@@ -244,6 +266,18 @@ async function tryAutoResolve(
244
266
  try {
245
267
  const content = await readFile(filePath);
246
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
+
247
281
  const resolved = isUnion
248
282
  ? resolveConflictsUnion(content)
249
283
  : resolveConflictsKeepIncoming(content);
@@ -265,12 +299,12 @@ async function tryAutoResolve(
265
299
  }
266
300
 
267
301
  if (remainingConflicts.length > 0) {
268
- return { success: false, remainingConflicts };
302
+ return { success: false, remainingConflicts, contentDropWarnings };
269
303
  }
270
304
 
271
305
  // All files resolved — commit
272
306
  const { exitCode } = await runGit(repoRoot, ["commit", "--no-edit"]);
273
- return { success: exitCode === 0, remainingConflicts };
307
+ return { success: exitCode === 0, remainingConflicts, contentDropWarnings };
274
308
  }
275
309
 
276
310
  /**
@@ -689,13 +723,15 @@ export function createMergeResolver(options: {
689
723
  didStash = true;
690
724
  }
691
725
 
726
+ const warnings: string[] = [];
692
727
  let lastTier: ResolutionTier = "clean-merge";
693
728
  let conflictFiles: string[] = [];
694
729
 
695
730
  try {
696
- // Commit untracked files overlapping entry.filesModified before merging.
731
+ // Delete untracked files overlapping entry.filesModified before merging.
697
732
  // git merge refuses to run if untracked files in the working tree would
698
- // be overwritten by the incoming branch.
733
+ // be overwritten by the incoming branch. Deleting them lets the merge
734
+ // proceed and bring in the branch version.
699
735
  const { stdout: untrackedOut } = await runGit(repoRoot, [
700
736
  "ls-files",
701
737
  "--others",
@@ -707,9 +743,18 @@ export function createMergeResolver(options: {
707
743
  .filter((f) => f.length > 0);
708
744
  const entryFileSet = new Set(entry.filesModified);
709
745
  const overlappingUntracked = untrackedFiles.filter((f) => entryFileSet.has(f));
710
- if (overlappingUntracked.length > 0) {
711
- await runGit(repoRoot, ["add", ...overlappingUntracked]);
712
- await runGit(repoRoot, ["commit", "-m", "chore: commit untracked files before merge"]);
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
+ }
713
758
  }
714
759
 
715
760
  // Tier 1: Clean merge
@@ -732,6 +777,7 @@ export function createMergeResolver(options: {
732
777
  tier: "clean-merge",
733
778
  conflictFiles: [],
734
779
  errorMessage: null,
780
+ warnings,
735
781
  };
736
782
  }
737
783
  conflictFiles = cleanResult.conflictFiles;
@@ -750,6 +796,9 @@ export function createMergeResolver(options: {
750
796
  if (!history.skipTiers.includes("auto-resolve")) {
751
797
  lastTier = "auto-resolve";
752
798
  const autoResult = await tryAutoResolve(conflictFiles, repoRoot);
799
+ if (autoResult.contentDropWarnings.length > 0) {
800
+ warnings.push(...autoResult.contentDropWarnings);
801
+ }
753
802
  if (autoResult.success) {
754
803
  if (options.mulchClient) {
755
804
  recordConflictPattern(
@@ -777,6 +826,7 @@ export function createMergeResolver(options: {
777
826
  tier: "auto-resolve",
778
827
  conflictFiles,
779
828
  errorMessage: null,
829
+ warnings,
780
830
  };
781
831
  }
782
832
  conflictFiles = autoResult.remainingConflicts;
@@ -812,6 +862,7 @@ export function createMergeResolver(options: {
812
862
  tier: "ai-resolve",
813
863
  conflictFiles,
814
864
  errorMessage: null,
865
+ warnings,
815
866
  };
816
867
  }
817
868
  conflictFiles = aiResult.remainingConflicts;
@@ -847,6 +898,7 @@ export function createMergeResolver(options: {
847
898
  tier: "reimagine",
848
899
  conflictFiles: [],
849
900
  errorMessage: null,
901
+ warnings,
850
902
  };
851
903
  }
852
904
  }
@@ -868,6 +920,7 @@ export function createMergeResolver(options: {
868
920
  tier: lastTier,
869
921
  conflictFiles,
870
922
  errorMessage: `All enabled resolution tiers failed (last attempted: ${lastTier})`,
923
+ warnings,
871
924
  };
872
925
  } finally {
873
926
  if (didStash) {
@@ -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",
@@ -37,6 +37,9 @@ export class CodexRuntime implements AgentRuntime {
37
37
  /** Unique identifier for this runtime. */
38
38
  readonly id = "codex";
39
39
 
40
+ /** Stability level. Codex adapter is experimental — not fully validated. */
41
+ readonly stability = "experimental" as const;
42
+
40
43
  /** Relative path to the instruction file within a worktree. */
41
44
  readonly instructionPath = "AGENTS.md";
42
45
 
@@ -46,6 +49,16 @@ export class CodexRuntime implements AgentRuntime {
46
49
  */
47
50
  private static readonly MANIFEST_ALIASES = new Set(["sonnet", "opus", "haiku"]);
48
51
 
52
+ /**
53
+ * Escape a directory path for use in a single-quoted shell argument.
54
+ *
55
+ * @param path - Absolute directory path
56
+ * @returns POSIX shell-safe path string
57
+ */
58
+ private static shellEscape(path: string): string {
59
+ return path.replace(/'/g, "'\\''");
60
+ }
61
+
49
62
  /**
50
63
  * Build the shell command string to spawn a Codex agent in a tmux pane.
51
64
  *
@@ -68,16 +81,19 @@ export class CodexRuntime implements AgentRuntime {
68
81
  if (!CodexRuntime.MANIFEST_ALIASES.has(opts.model)) {
69
82
  cmd += ` --model ${opts.model}`;
70
83
  }
84
+ for (const dir of opts.sharedWritableDirs ?? []) {
85
+ cmd += ` --add-dir '${CodexRuntime.shellEscape(dir)}'`;
86
+ }
71
87
 
72
88
  if (opts.appendSystemPromptFile) {
73
89
  // Read role definition from file at shell expansion time — avoids tmux
74
90
  // IPC message size limits. Append the "read AGENTS.md" instruction.
75
- const escaped = opts.appendSystemPromptFile.replace(/'/g, "'\\''");
91
+ const escaped = CodexRuntime.shellEscape(opts.appendSystemPromptFile);
76
92
  cmd += ` "$(cat '${escaped}')"' Read AGENTS.md for your task assignment and begin immediately.'`;
77
93
  } else if (opts.appendSystemPrompt) {
78
94
  // Inline role definition + instruction to read AGENTS.md.
79
95
  const prompt = `${opts.appendSystemPrompt}\n\nRead AGENTS.md for your task assignment and begin immediately.`;
80
- const escaped = prompt.replace(/'/g, "'\\''");
96
+ const escaped = CodexRuntime.shellEscape(prompt);
81
97
  cmd += ` '${escaped}'`;
82
98
  } else {
83
99
  cmd += ` 'Read AGENTS.md for your task assignment and begin immediately.'`;
@@ -28,6 +28,9 @@ export class CopilotRuntime implements AgentRuntime {
28
28
  /** Unique identifier for this runtime. */
29
29
  readonly id = "copilot";
30
30
 
31
+ /** Stability level. Copilot adapter is experimental — not fully validated. */
32
+ readonly stability = "experimental" as const;
33
+
31
34
  /** Relative path to the instruction file within a worktree. */
32
35
  readonly instructionPath = ".github/copilot-instructions.md";
33
36