@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.
Files changed (43) hide show
  1. package/README.md +4 -2
  2. package/agents/coordinator.md +52 -4
  3. package/package.json +1 -1
  4. package/src/agents/manifest.test.ts +33 -8
  5. package/src/agents/manifest.ts +4 -3
  6. package/src/commands/clean.test.ts +136 -0
  7. package/src/commands/clean.ts +198 -4
  8. package/src/commands/coordinator.test.ts +420 -1
  9. package/src/commands/coordinator.ts +173 -1
  10. package/src/commands/init.test.ts +137 -0
  11. package/src/commands/init.ts +57 -1
  12. package/src/commands/inspect.test.ts +398 -1
  13. package/src/commands/inspect.ts +234 -0
  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.ts +312 -322
  18. package/src/commands/spec.ts +8 -2
  19. package/src/commands/stop.test.ts +127 -6
  20. package/src/commands/stop.ts +95 -43
  21. package/src/commands/watch.ts +29 -9
  22. package/src/config.test.ts +72 -0
  23. package/src/config.ts +26 -1
  24. package/src/events/tailer.test.ts +461 -0
  25. package/src/events/tailer.ts +235 -0
  26. package/src/index.ts +4 -1
  27. package/src/merge/resolver.test.ts +243 -19
  28. package/src/merge/resolver.ts +235 -95
  29. package/src/runtimes/claude.test.ts +1 -1
  30. package/src/runtimes/opencode.test.ts +325 -0
  31. package/src/runtimes/opencode.ts +185 -0
  32. package/src/runtimes/pi.test.ts +119 -2
  33. package/src/runtimes/pi.ts +61 -12
  34. package/src/runtimes/registry.test.ts +21 -1
  35. package/src/runtimes/registry.ts +3 -0
  36. package/src/runtimes/sapling.test.ts +30 -0
  37. package/src/runtimes/sapling.ts +27 -24
  38. package/src/runtimes/types.ts +2 -2
  39. package/src/types.ts +19 -0
  40. package/src/watchdog/daemon.test.ts +257 -0
  41. package/src/watchdog/daemon.ts +123 -23
  42. package/src/worktree/manager.test.ts +65 -1
  43. 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.4";
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("throws MergeError when unstaged changes exist on tracked files", async () => {
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 expect(resolver.resolve(entry, defaultBranch, repoDir)).rejects.toThrow(MergeError);
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("throws MergeError with message listing dirty files", async () => {
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
- await Bun.write(`${repoDir}/src/main.ts`, "modified content\n");
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
- try {
336
- await resolver.resolve(entry, defaultBranch, repoDir);
337
- expect(true).toBe(false); // should not reach
338
- } catch (err: unknown) {
339
- expect(err).toBeInstanceOf(MergeError);
340
- const mergeErr = err as MergeError;
341
- expect(mergeErr.message).toContain("src/main.ts");
342
- expect(mergeErr.message).toContain("Commit or stash");
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("throws MergeError when staged but uncommitted changes exist", async () => {
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({ branchName: "feature-branch" });
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
- await expect(resolver.resolve(entry, defaultBranch, repoDir)).rejects.toThrow(MergeError);
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
  });