@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.
- package/README.md +18 -11
- package/agents/ov-co-creation.md +90 -0
- package/package.json +1 -1
- package/src/agents/hooks-deployer.test.ts +185 -12
- package/src/agents/hooks-deployer.ts +57 -1
- package/src/agents/overlay.ts +13 -0
- package/src/canopy/client.test.ts +107 -0
- package/src/canopy/client.ts +179 -0
- package/src/commands/coordinator.test.ts +74 -5
- package/src/commands/coordinator.ts +105 -25
- package/src/commands/dashboard.ts +85 -19
- package/src/commands/discover.test.ts +288 -0
- package/src/commands/discover.ts +202 -0
- package/src/commands/ecosystem.test.ts +101 -0
- package/src/commands/init.test.ts +76 -1
- package/src/commands/init.ts +36 -14
- package/src/commands/sling.test.ts +33 -0
- package/src/commands/sling.ts +126 -38
- package/src/commands/supervisor.ts +2 -0
- package/src/commands/update.test.ts +1 -0
- package/src/commands/watch.ts +9 -9
- package/src/e2e/init-sling-lifecycle.test.ts +2 -1
- package/src/index.ts +6 -1
- package/src/mail/store.ts +2 -1
- package/src/merge/resolver.test.ts +141 -7
- package/src/merge/resolver.ts +61 -8
- 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 +1 -1
- package/src/runtimes/pi.ts +3 -0
- 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 +2 -0
- package/src/sessions/store.test.ts +178 -0
- package/src/sessions/store.ts +69 -12
- package/src/types.ts +69 -2
- package/src/watchdog/daemon.ts +41 -0
- package/src/worktree/tmux.test.ts +150 -0
- package/src/worktree/tmux.ts +126 -23
- 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.
|
|
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
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
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";
|
|
@@ -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
|
-
//
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
|
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",
|
package/src/runtimes/codex.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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.'`;
|
package/src/runtimes/copilot.ts
CHANGED
|
@@ -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
|
|