@os-eco/overstory-cli 0.8.4 → 0.8.6
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 +4 -2
- package/agents/coordinator.md +52 -4
- package/package.json +1 -1
- package/src/agents/manifest.test.ts +33 -8
- package/src/agents/manifest.ts +4 -3
- package/src/commands/clean.test.ts +136 -0
- package/src/commands/clean.ts +198 -4
- package/src/commands/coordinator.test.ts +420 -1
- package/src/commands/coordinator.ts +173 -1
- package/src/commands/init.test.ts +137 -0
- package/src/commands/init.ts +57 -1
- package/src/commands/inspect.test.ts +398 -1
- package/src/commands/inspect.ts +234 -0
- package/src/commands/log.test.ts +10 -11
- package/src/commands/log.ts +31 -32
- package/src/commands/prime.ts +30 -5
- package/src/commands/sling.ts +312 -322
- package/src/commands/spec.ts +8 -2
- package/src/commands/stop.test.ts +127 -6
- package/src/commands/stop.ts +95 -43
- package/src/commands/watch.ts +29 -9
- package/src/config.test.ts +72 -0
- package/src/config.ts +26 -1
- package/src/events/tailer.test.ts +461 -0
- package/src/events/tailer.ts +235 -0
- package/src/index.ts +4 -1
- package/src/merge/resolver.test.ts +243 -19
- package/src/merge/resolver.ts +235 -95
- package/src/runtimes/claude.test.ts +1 -1
- package/src/runtimes/opencode.test.ts +325 -0
- package/src/runtimes/opencode.ts +185 -0
- package/src/runtimes/pi.test.ts +119 -2
- package/src/runtimes/pi.ts +61 -12
- package/src/runtimes/registry.test.ts +21 -1
- package/src/runtimes/registry.ts +3 -0
- package/src/runtimes/sapling.test.ts +30 -0
- package/src/runtimes/sapling.ts +27 -24
- package/src/runtimes/types.ts +2 -2
- package/src/types.ts +19 -0
- package/src/watchdog/daemon.test.ts +257 -0
- package/src/watchdog/daemon.ts +123 -23
- package/src/worktree/manager.test.ts +65 -1
- package/src/worktree/manager.ts +36 -0
package/src/index.ts
CHANGED
|
@@ -49,7 +49,7 @@ import { ConfigError, OverstoryError, WorktreeError } from "./errors.ts";
|
|
|
49
49
|
import { jsonError } from "./json.ts";
|
|
50
50
|
import { brand, chalk, muted, setQuiet } from "./logging/color.ts";
|
|
51
51
|
|
|
52
|
-
export const VERSION = "0.8.
|
|
52
|
+
export const VERSION = "0.8.6";
|
|
53
53
|
|
|
54
54
|
const rawArgs = process.argv.slice(2);
|
|
55
55
|
|
|
@@ -145,6 +145,7 @@ program
|
|
|
145
145
|
.name("ov")
|
|
146
146
|
.description("Multi-agent orchestration for Claude Code")
|
|
147
147
|
.version(VERSION, "-v, --version", "Print version")
|
|
148
|
+
.enablePositionalOptions()
|
|
148
149
|
.option("-q, --quiet", "Suppress non-error output")
|
|
149
150
|
.option("--json", "JSON output")
|
|
150
151
|
.option("--verbose", "Verbose output")
|
|
@@ -298,6 +299,7 @@ specCmd
|
|
|
298
299
|
.argument("<task-id>", "Task ID for the spec file")
|
|
299
300
|
.option("--body <content>", "Spec content (or pipe via stdin)")
|
|
300
301
|
.option("--agent <name>", "Agent writing the spec (for attribution)")
|
|
302
|
+
.option("--json", "Output as JSON")
|
|
301
303
|
.action(async (taskId, opts) => {
|
|
302
304
|
await specWriteCommand(taskId, opts);
|
|
303
305
|
});
|
|
@@ -307,6 +309,7 @@ program
|
|
|
307
309
|
.description("Load context for orchestrator/agent")
|
|
308
310
|
.option("--agent <name>", "Prime for a specific agent")
|
|
309
311
|
.option("--compact", "Output reduced context (for PreCompact hook)")
|
|
312
|
+
.option("--json", "Output as JSON")
|
|
310
313
|
.action(async (opts) => {
|
|
311
314
|
await primeCommand(opts);
|
|
312
315
|
});
|
|
@@ -291,7 +291,7 @@ describe("createMergeResolver", () => {
|
|
|
291
291
|
});
|
|
292
292
|
|
|
293
293
|
describe("Dirty working tree pre-check", () => {
|
|
294
|
-
test("
|
|
294
|
+
test("stashes unstaged changes and proceeds with clean merge", async () => {
|
|
295
295
|
const repoDir = await createTempGitRepo();
|
|
296
296
|
try {
|
|
297
297
|
const defaultBranch = await getDefaultBranch(repoDir);
|
|
@@ -300,7 +300,7 @@ describe("createMergeResolver", () => {
|
|
|
300
300
|
await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
|
|
301
301
|
await commitFile(repoDir, "src/feature.ts", "feature content\n");
|
|
302
302
|
await runGitInDir(repoDir, ["checkout", defaultBranch]);
|
|
303
|
-
// Modify a tracked file without staging
|
|
303
|
+
// Modify a tracked file without staging — should be stashed, not rejected
|
|
304
304
|
await Bun.write(`${repoDir}/src/main.ts`, "modified content\n");
|
|
305
305
|
|
|
306
306
|
const entry = makeTestEntry({
|
|
@@ -313,13 +313,15 @@ describe("createMergeResolver", () => {
|
|
|
313
313
|
reimagineEnabled: false,
|
|
314
314
|
});
|
|
315
315
|
|
|
316
|
-
await
|
|
316
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
317
|
+
expect(result.success).toBe(true);
|
|
318
|
+
expect(result.tier).toBe("clean-merge");
|
|
317
319
|
} finally {
|
|
318
320
|
await cleanupTempDir(repoDir);
|
|
319
321
|
}
|
|
320
322
|
});
|
|
321
323
|
|
|
322
|
-
test("
|
|
324
|
+
test("dirty non-state files are stashed, merge succeeds, files restored after", async () => {
|
|
323
325
|
const repoDir = await createTempGitRepo();
|
|
324
326
|
try {
|
|
325
327
|
const defaultBranch = await getDefaultBranch(repoDir);
|
|
@@ -327,26 +329,59 @@ describe("createMergeResolver", () => {
|
|
|
327
329
|
await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
|
|
328
330
|
await commitFile(repoDir, "src/feature.ts", "feature content\n");
|
|
329
331
|
await runGitInDir(repoDir, ["checkout", defaultBranch]);
|
|
330
|
-
|
|
332
|
+
// Dirty file unrelated to the merge
|
|
333
|
+
await Bun.write(`${repoDir}/src/main.ts`, "in-progress work\n");
|
|
334
|
+
|
|
335
|
+
const entry = makeTestEntry({
|
|
336
|
+
branchName: "feature-branch",
|
|
337
|
+
filesModified: ["src/feature.ts"],
|
|
338
|
+
});
|
|
331
339
|
|
|
332
|
-
const entry = makeTestEntry({ branchName: "feature-branch" });
|
|
333
340
|
const resolver = createMergeResolver({ aiResolveEnabled: false, reimagineEnabled: false });
|
|
341
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
334
342
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
343
|
+
expect(result.success).toBe(true);
|
|
344
|
+
expect(result.tier).toBe("clean-merge");
|
|
345
|
+
|
|
346
|
+
// After merge, the stashed modification should be restored
|
|
347
|
+
const mainContent = await Bun.file(`${repoDir}/src/main.ts`).text();
|
|
348
|
+
expect(mainContent).toBe("in-progress work\n");
|
|
349
|
+
} finally {
|
|
350
|
+
await cleanupTempDir(repoDir);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("stash pop happens after failed merge too", async () => {
|
|
355
|
+
const repoDir = await createTempGitRepo();
|
|
356
|
+
try {
|
|
357
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
358
|
+
// Set up a conflict that cannot be resolved (delete/modify)
|
|
359
|
+
await setupDeleteModifyConflict(repoDir, defaultBranch);
|
|
360
|
+
// Also leave an unrelated file dirty
|
|
361
|
+
await Bun.write(`${repoDir}/src/other.ts`, "work in progress\n");
|
|
362
|
+
await runGitInDir(repoDir, ["add", "src/other.ts"]);
|
|
363
|
+
await runGitInDir(repoDir, ["commit", "-m", "add other.ts"]);
|
|
364
|
+
await Bun.write(`${repoDir}/src/other.ts`, "modified but not committed\n");
|
|
365
|
+
|
|
366
|
+
const entry = makeTestEntry({
|
|
367
|
+
branchName: "feature-branch",
|
|
368
|
+
filesModified: ["src/test.ts"],
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const resolver = createMergeResolver({ aiResolveEnabled: false, reimagineEnabled: false });
|
|
372
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
373
|
+
|
|
374
|
+
expect(result.success).toBe(false);
|
|
375
|
+
|
|
376
|
+
// After failed merge, the stashed modification should be restored
|
|
377
|
+
const otherContent = await Bun.file(`${repoDir}/src/other.ts`).text();
|
|
378
|
+
expect(otherContent).toBe("modified but not committed\n");
|
|
344
379
|
} finally {
|
|
345
380
|
await cleanupTempDir(repoDir);
|
|
346
381
|
}
|
|
347
382
|
});
|
|
348
383
|
|
|
349
|
-
test("
|
|
384
|
+
test("staged but uncommitted changes are stashed and merge proceeds", async () => {
|
|
350
385
|
const repoDir = await createTempGitRepo();
|
|
351
386
|
try {
|
|
352
387
|
const defaultBranch = await getDefaultBranch(repoDir);
|
|
@@ -354,14 +389,20 @@ describe("createMergeResolver", () => {
|
|
|
354
389
|
await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
|
|
355
390
|
await commitFile(repoDir, "src/feature.ts", "feature content\n");
|
|
356
391
|
await runGitInDir(repoDir, ["checkout", defaultBranch]);
|
|
357
|
-
// Modify and stage (but don't commit)
|
|
392
|
+
// Modify and stage (but don't commit) — should be stashed, not rejected
|
|
358
393
|
await Bun.write(`${repoDir}/src/main.ts`, "staged but not committed\n");
|
|
359
394
|
await runGitInDir(repoDir, ["add", "src/main.ts"]);
|
|
360
395
|
|
|
361
|
-
const entry = makeTestEntry({
|
|
396
|
+
const entry = makeTestEntry({
|
|
397
|
+
branchName: "feature-branch",
|
|
398
|
+
filesModified: ["src/feature.ts"],
|
|
399
|
+
});
|
|
400
|
+
|
|
362
401
|
const resolver = createMergeResolver({ aiResolveEnabled: false, reimagineEnabled: false });
|
|
402
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
363
403
|
|
|
364
|
-
|
|
404
|
+
expect(result.success).toBe(true);
|
|
405
|
+
expect(result.tier).toBe("clean-merge");
|
|
365
406
|
} finally {
|
|
366
407
|
await cleanupTempDir(repoDir);
|
|
367
408
|
}
|
|
@@ -1632,4 +1673,187 @@ describe("createMergeResolver", () => {
|
|
|
1632
1673
|
}
|
|
1633
1674
|
});
|
|
1634
1675
|
});
|
|
1676
|
+
|
|
1677
|
+
describe("onMergeSuccess callback", () => {
|
|
1678
|
+
test("callback is invoked on successful clean merge", async () => {
|
|
1679
|
+
const repoDir = await createTempGitRepo();
|
|
1680
|
+
try {
|
|
1681
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
1682
|
+
await setupCleanMerge(repoDir, defaultBranch);
|
|
1683
|
+
|
|
1684
|
+
const entry = makeTestEntry({
|
|
1685
|
+
branchName: "feature-branch",
|
|
1686
|
+
filesModified: ["src/feature-file.ts"],
|
|
1687
|
+
});
|
|
1688
|
+
|
|
1689
|
+
let callbackCalled = false;
|
|
1690
|
+
const resolver = createMergeResolver({
|
|
1691
|
+
aiResolveEnabled: false,
|
|
1692
|
+
reimagineEnabled: false,
|
|
1693
|
+
onMergeSuccess: async (mergedEntry) => {
|
|
1694
|
+
callbackCalled = true;
|
|
1695
|
+
expect(mergedEntry.status).toBe("merged");
|
|
1696
|
+
expect(mergedEntry.resolvedTier).toBe("clean-merge");
|
|
1697
|
+
},
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
1701
|
+
expect(result.success).toBe(true);
|
|
1702
|
+
expect(callbackCalled).toBe(true);
|
|
1703
|
+
} finally {
|
|
1704
|
+
await cleanupTempDir(repoDir);
|
|
1705
|
+
}
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
test("callback is invoked on auto-resolve success", async () => {
|
|
1709
|
+
const repoDir = await createTempGitRepo();
|
|
1710
|
+
try {
|
|
1711
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
1712
|
+
await setupContentConflict(repoDir, defaultBranch);
|
|
1713
|
+
|
|
1714
|
+
const entry = makeTestEntry({
|
|
1715
|
+
branchName: "feature-branch",
|
|
1716
|
+
filesModified: ["src/test.ts"],
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
let callbackCalled = false;
|
|
1720
|
+
const resolver = createMergeResolver({
|
|
1721
|
+
aiResolveEnabled: false,
|
|
1722
|
+
reimagineEnabled: false,
|
|
1723
|
+
onMergeSuccess: async (mergedEntry) => {
|
|
1724
|
+
callbackCalled = true;
|
|
1725
|
+
expect(mergedEntry.resolvedTier).toBe("auto-resolve");
|
|
1726
|
+
},
|
|
1727
|
+
});
|
|
1728
|
+
|
|
1729
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
1730
|
+
expect(result.success).toBe(true);
|
|
1731
|
+
expect(callbackCalled).toBe(true);
|
|
1732
|
+
} finally {
|
|
1733
|
+
await cleanupTempDir(repoDir);
|
|
1734
|
+
}
|
|
1735
|
+
});
|
|
1736
|
+
|
|
1737
|
+
test("callback is NOT invoked on merge failure", async () => {
|
|
1738
|
+
const repoDir = await createTempGitRepo();
|
|
1739
|
+
try {
|
|
1740
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
1741
|
+
await setupDeleteModifyConflict(repoDir, defaultBranch);
|
|
1742
|
+
|
|
1743
|
+
const entry = makeTestEntry({
|
|
1744
|
+
branchName: "feature-branch",
|
|
1745
|
+
filesModified: ["src/test.ts"],
|
|
1746
|
+
});
|
|
1747
|
+
|
|
1748
|
+
let callbackCalled = false;
|
|
1749
|
+
const resolver = createMergeResolver({
|
|
1750
|
+
aiResolveEnabled: false,
|
|
1751
|
+
reimagineEnabled: false,
|
|
1752
|
+
onMergeSuccess: async () => {
|
|
1753
|
+
callbackCalled = true;
|
|
1754
|
+
},
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1757
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
1758
|
+
expect(result.success).toBe(false);
|
|
1759
|
+
expect(callbackCalled).toBe(false);
|
|
1760
|
+
} finally {
|
|
1761
|
+
await cleanupTempDir(repoDir);
|
|
1762
|
+
}
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
test("callback errors are swallowed and merge result is unaffected", async () => {
|
|
1766
|
+
const repoDir = await createTempGitRepo();
|
|
1767
|
+
try {
|
|
1768
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
1769
|
+
await setupCleanMerge(repoDir, defaultBranch);
|
|
1770
|
+
|
|
1771
|
+
const entry = makeTestEntry({
|
|
1772
|
+
branchName: "feature-branch",
|
|
1773
|
+
filesModified: ["src/feature-file.ts"],
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
const resolver = createMergeResolver({
|
|
1777
|
+
aiResolveEnabled: false,
|
|
1778
|
+
reimagineEnabled: false,
|
|
1779
|
+
onMergeSuccess: async () => {
|
|
1780
|
+
throw new Error("callback failure");
|
|
1781
|
+
},
|
|
1782
|
+
});
|
|
1783
|
+
|
|
1784
|
+
// Should succeed despite the callback throwing
|
|
1785
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
1786
|
+
expect(result.success).toBe(true);
|
|
1787
|
+
expect(result.tier).toBe("clean-merge");
|
|
1788
|
+
} finally {
|
|
1789
|
+
await cleanupTempDir(repoDir);
|
|
1790
|
+
}
|
|
1791
|
+
});
|
|
1792
|
+
});
|
|
1793
|
+
|
|
1794
|
+
describe("Untracked files blocking merge", () => {
|
|
1795
|
+
test("untracked files in merge path are auto-committed and merge succeeds", async () => {
|
|
1796
|
+
const repoDir = await createTempGitRepo();
|
|
1797
|
+
try {
|
|
1798
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
1799
|
+
|
|
1800
|
+
// Feature branch adds src/new-feature.ts
|
|
1801
|
+
await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
|
|
1802
|
+
await commitFile(repoDir, "src/new-feature.ts", "feature content\n");
|
|
1803
|
+
await runGitInDir(repoDir, ["checkout", defaultBranch]);
|
|
1804
|
+
|
|
1805
|
+
// Create the same file as untracked in the working directory —
|
|
1806
|
+
// without our fix, git merge would fail: "untracked file would be overwritten"
|
|
1807
|
+
await Bun.write(`${repoDir}/src/new-feature.ts`, "feature content\n");
|
|
1808
|
+
|
|
1809
|
+
const entry = makeTestEntry({
|
|
1810
|
+
branchName: "feature-branch",
|
|
1811
|
+
filesModified: ["src/new-feature.ts"],
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
const resolver = createMergeResolver({
|
|
1815
|
+
aiResolveEnabled: false,
|
|
1816
|
+
reimagineEnabled: false,
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
1820
|
+
expect(result.success).toBe(true);
|
|
1821
|
+
expect(result.tier).toBe("clean-merge");
|
|
1822
|
+
} finally {
|
|
1823
|
+
await cleanupTempDir(repoDir);
|
|
1824
|
+
}
|
|
1825
|
+
});
|
|
1826
|
+
|
|
1827
|
+
test("untracked files NOT in entry.filesModified are left alone", async () => {
|
|
1828
|
+
const repoDir = await createTempGitRepo();
|
|
1829
|
+
try {
|
|
1830
|
+
const defaultBranch = await getDefaultBranch(repoDir);
|
|
1831
|
+
await setupCleanMerge(repoDir, defaultBranch);
|
|
1832
|
+
|
|
1833
|
+
// Untracked file NOT in filesModified — should not be touched
|
|
1834
|
+
await Bun.write(`${repoDir}/src/scratch.ts`, "scratch work\n");
|
|
1835
|
+
|
|
1836
|
+
const entry = makeTestEntry({
|
|
1837
|
+
branchName: "feature-branch",
|
|
1838
|
+
filesModified: ["src/feature-file.ts"],
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
const resolver = createMergeResolver({
|
|
1842
|
+
aiResolveEnabled: false,
|
|
1843
|
+
reimagineEnabled: false,
|
|
1844
|
+
});
|
|
1845
|
+
|
|
1846
|
+
const result = await resolver.resolve(entry, defaultBranch, repoDir);
|
|
1847
|
+
expect(result.success).toBe(true);
|
|
1848
|
+
|
|
1849
|
+
// Untracked file should still exist and be untracked
|
|
1850
|
+
const scratchContent = await Bun.file(`${repoDir}/src/scratch.ts`).text();
|
|
1851
|
+
expect(scratchContent).toBe("scratch work\n");
|
|
1852
|
+
const status = await runGitInDir(repoDir, ["status", "--porcelain"]);
|
|
1853
|
+
expect(status).toContain("src/scratch.ts");
|
|
1854
|
+
} finally {
|
|
1855
|
+
await cleanupTempDir(repoDir);
|
|
1856
|
+
}
|
|
1857
|
+
});
|
|
1858
|
+
});
|
|
1635
1859
|
});
|