@os-eco/overstory-cli 0.7.9 → 0.8.2

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 (42) hide show
  1. package/README.md +16 -7
  2. package/agents/coordinator.md +41 -0
  3. package/agents/orchestrator.md +239 -0
  4. package/package.json +1 -1
  5. package/src/agents/guard-rules.test.ts +372 -0
  6. package/src/commands/coordinator.test.ts +334 -0
  7. package/src/commands/coordinator.ts +366 -0
  8. package/src/commands/dashboard.test.ts +86 -0
  9. package/src/commands/dashboard.ts +8 -4
  10. package/src/commands/feed.test.ts +8 -0
  11. package/src/commands/init.test.ts +2 -1
  12. package/src/commands/init.ts +2 -2
  13. package/src/commands/inspect.test.ts +156 -1
  14. package/src/commands/inspect.ts +19 -4
  15. package/src/commands/replay.test.ts +8 -0
  16. package/src/commands/sling.ts +218 -121
  17. package/src/commands/status.test.ts +77 -0
  18. package/src/commands/status.ts +6 -3
  19. package/src/commands/stop.test.ts +134 -0
  20. package/src/commands/stop.ts +41 -11
  21. package/src/commands/trace.test.ts +8 -0
  22. package/src/commands/update.test.ts +465 -0
  23. package/src/commands/update.ts +263 -0
  24. package/src/config.test.ts +65 -1
  25. package/src/config.ts +23 -0
  26. package/src/e2e/init-sling-lifecycle.test.ts +3 -2
  27. package/src/index.ts +21 -2
  28. package/src/logging/theme.ts +4 -0
  29. package/src/runtimes/connections.test.ts +74 -0
  30. package/src/runtimes/connections.ts +34 -0
  31. package/src/runtimes/registry.test.ts +1 -1
  32. package/src/runtimes/registry.ts +2 -0
  33. package/src/runtimes/sapling.test.ts +1237 -0
  34. package/src/runtimes/sapling.ts +698 -0
  35. package/src/runtimes/types.ts +45 -0
  36. package/src/types.ts +5 -1
  37. package/src/watchdog/daemon.ts +34 -0
  38. package/src/watchdog/health.test.ts +102 -0
  39. package/src/watchdog/health.ts +140 -69
  40. package/src/worktree/process.test.ts +101 -0
  41. package/src/worktree/process.ts +111 -0
  42. package/src/worktree/tmux.ts +5 -0
@@ -15,11 +15,13 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
15
15
  import { mkdir, realpath } from "node:fs/promises";
16
16
  import { join } from "node:path";
17
17
  import { AgentError, ValidationError } from "../errors.ts";
18
+ import { createMailStore } from "../mail/store.ts";
18
19
  import { openSessionStore } from "../sessions/compat.ts";
19
20
  import { createRunStore } from "../sessions/store.ts";
20
21
  import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
21
22
  import type { AgentSession } from "../types.ts";
22
23
  import {
24
+ askCoordinator,
23
25
  buildCoordinatorBeacon,
24
26
  type CoordinatorDeps,
25
27
  coordinatorCommand,
@@ -1770,3 +1772,335 @@ describe("SessionStore round-trip", () => {
1770
1772
  expect(exists).toBe(true);
1771
1773
  });
1772
1774
  });
1775
+
1776
+ // --- Helpers for send/output tests ---
1777
+
1778
+ /** Read all messages from the mail store at mail.db for assertions. */
1779
+ function loadMailMessages() {
1780
+ const mailDbPath = join(overstoryDir, "mail.db");
1781
+ const mailStore = createMailStore(mailDbPath);
1782
+ try {
1783
+ return mailStore.getAll();
1784
+ } finally {
1785
+ mailStore.close();
1786
+ }
1787
+ }
1788
+
1789
+ describe("sendCoordinator", () => {
1790
+ test("send succeeds with running coordinator — mail is in DB", async () => {
1791
+ const session = makeCoordinatorSession({ state: "working" });
1792
+ saveSessionsToDb([session]);
1793
+
1794
+ let nudgeCalled = false;
1795
+ const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
1796
+ deps._nudge = async () => {
1797
+ nudgeCalled = true;
1798
+ return { delivered: true };
1799
+ };
1800
+
1801
+ await captureStdout(() => coordinatorCommand(["send", "--body", "hello world"], deps));
1802
+
1803
+ const messages = loadMailMessages();
1804
+ expect(messages).toHaveLength(1);
1805
+ expect(messages[0]?.from).toBe("operator");
1806
+ expect(messages[0]?.to).toBe("coordinator");
1807
+ expect(messages[0]?.body).toBe("hello world");
1808
+ expect(messages[0]?.type).toBe("dispatch");
1809
+ expect(nudgeCalled).toBe(true);
1810
+ });
1811
+
1812
+ test("send fails when no coordinator running", async () => {
1813
+ const { deps } = makeDeps();
1814
+
1815
+ await expect(coordinatorCommand(["send", "--body", "hello"], deps)).rejects.toThrow(AgentError);
1816
+ });
1817
+
1818
+ test("send fails when coordinator tmux is dead — state updated to zombie", async () => {
1819
+ const session = makeCoordinatorSession({ state: "working" });
1820
+ saveSessionsToDb([session]);
1821
+
1822
+ const { deps } = makeDeps({ "overstory-test-project-coordinator": false });
1823
+
1824
+ await expect(coordinatorCommand(["send", "--body", "hello"], deps)).rejects.toThrow(AgentError);
1825
+
1826
+ const sessions = loadSessionsFromDb();
1827
+ expect(sessions[0]?.state).toBe("zombie");
1828
+ });
1829
+
1830
+ test("send --json outputs JSON with id and nudged fields", async () => {
1831
+ const session = makeCoordinatorSession({ state: "working" });
1832
+ saveSessionsToDb([session]);
1833
+
1834
+ const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
1835
+ deps._nudge = async () => ({ delivered: true });
1836
+
1837
+ const output = await captureStdout(() =>
1838
+ coordinatorCommand(["send", "--body", "hello", "--json"], deps),
1839
+ );
1840
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1841
+ expect(typeof parsed.id).toBe("string");
1842
+ expect(parsed.nudged).toBe(true);
1843
+ });
1844
+
1845
+ test("send with custom --subject uses subject in mail", async () => {
1846
+ const session = makeCoordinatorSession({ state: "working" });
1847
+ saveSessionsToDb([session]);
1848
+
1849
+ const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
1850
+ deps._nudge = async () => ({ delivered: false });
1851
+
1852
+ await captureStdout(() =>
1853
+ coordinatorCommand(
1854
+ ["send", "--body", "build feature X", "--subject", "Deploy feature X"],
1855
+ deps,
1856
+ ),
1857
+ );
1858
+
1859
+ const messages = loadMailMessages();
1860
+ expect(messages[0]?.subject).toBe("Deploy feature X");
1861
+ });
1862
+ });
1863
+
1864
+ describe("outputCoordinator", () => {
1865
+ test("output shows pane content", async () => {
1866
+ const session = makeCoordinatorSession({ state: "working" });
1867
+ saveSessionsToDb([session]);
1868
+
1869
+ const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
1870
+ deps._capturePaneContent = async () => "Hello from coordinator pane\n";
1871
+
1872
+ const output = await captureStdout(() => coordinatorCommand(["output"], deps));
1873
+ expect(output).toContain("Hello from coordinator pane");
1874
+ });
1875
+
1876
+ test("output fails when no coordinator running", async () => {
1877
+ const { deps } = makeDeps();
1878
+
1879
+ await expect(coordinatorCommand(["output"], deps)).rejects.toThrow(AgentError);
1880
+ });
1881
+
1882
+ test("output fails when coordinator tmux is dead — state updated to zombie", async () => {
1883
+ const session = makeCoordinatorSession({ state: "working" });
1884
+ saveSessionsToDb([session]);
1885
+
1886
+ const { deps } = makeDeps({ "overstory-test-project-coordinator": false });
1887
+
1888
+ await expect(coordinatorCommand(["output"], deps)).rejects.toThrow(AgentError);
1889
+
1890
+ const sessions = loadSessionsFromDb();
1891
+ expect(sessions[0]?.state).toBe("zombie");
1892
+ });
1893
+
1894
+ test("output --json wraps content in JSON", async () => {
1895
+ const session = makeCoordinatorSession({ state: "working" });
1896
+ saveSessionsToDb([session]);
1897
+
1898
+ const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
1899
+ deps._capturePaneContent = async () => "some output";
1900
+
1901
+ const output = await captureStdout(() => coordinatorCommand(["output", "--json"], deps));
1902
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1903
+ expect(parsed.content).toBe("some output");
1904
+ expect(typeof parsed.lines).toBe("number");
1905
+ });
1906
+
1907
+ test("output --lines passes lines parameter to capturePaneContent", async () => {
1908
+ const session = makeCoordinatorSession({ state: "working" });
1909
+ saveSessionsToDb([session]);
1910
+
1911
+ let capturedLines: number | undefined;
1912
+ const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
1913
+ deps._capturePaneContent = async (_name: string, lines?: number) => {
1914
+ capturedLines = lines;
1915
+ return "output";
1916
+ };
1917
+
1918
+ await captureStdout(() => coordinatorCommand(["output", "--lines", "100"], deps));
1919
+ expect(capturedLines).toBe(100);
1920
+ });
1921
+ });
1922
+
1923
+ describe("askCoordinator", () => {
1924
+ test("sends mail and returns reply body on stdout", async () => {
1925
+ const session = makeCoordinatorSession({ state: "working" });
1926
+ saveSessionsToDb([session]);
1927
+
1928
+ const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
1929
+ deps._nudge = async () => ({ delivered: true });
1930
+ deps._pollIntervalMs = 50; // Fast polling for test
1931
+
1932
+ const mailDbPath = join(overstoryDir, "mail.db");
1933
+ const outputChunks: string[] = [];
1934
+ const originalWrite = process.stdout.write;
1935
+ process.stdout.write = ((chunk: string) => {
1936
+ outputChunks.push(chunk);
1937
+ return true;
1938
+ }) as typeof process.stdout.write;
1939
+
1940
+ try {
1941
+ // Start ask without awaiting — lets us insert the reply concurrently
1942
+ const askPromise = askCoordinator(
1943
+ "what is the status",
1944
+ { subject: "status check", timeout: 10, json: false },
1945
+ deps,
1946
+ );
1947
+
1948
+ // Wait for the ask to complete setup and send mail, then insert a reply
1949
+ await Bun.sleep(300);
1950
+ const replyStore = createMailStore(mailDbPath);
1951
+ try {
1952
+ const messages = replyStore.getAll({ from: "operator", to: "coordinator" });
1953
+ const sent = messages[0];
1954
+ if (sent) {
1955
+ replyStore.insert({
1956
+ id: "",
1957
+ from: "coordinator",
1958
+ to: "operator",
1959
+ subject: `Re: ${sent.subject}`,
1960
+ body: "Here is your answer",
1961
+ type: "status",
1962
+ priority: "normal",
1963
+ threadId: sent.id,
1964
+ payload: JSON.stringify({
1965
+ correlationId: JSON.parse(sent.payload ?? "{}").correlationId,
1966
+ }),
1967
+ });
1968
+ }
1969
+ } finally {
1970
+ replyStore.close();
1971
+ }
1972
+
1973
+ await askPromise;
1974
+ } finally {
1975
+ process.stdout.write = originalWrite;
1976
+ }
1977
+
1978
+ expect(outputChunks.join("")).toBe("Here is your answer\n");
1979
+ });
1980
+
1981
+ test("times out when no reply arrives", async () => {
1982
+ const session = makeCoordinatorSession({ state: "working" });
1983
+ saveSessionsToDb([session]);
1984
+
1985
+ const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
1986
+ deps._nudge = async () => ({ delivered: false });
1987
+ deps._pollIntervalMs = 50; // Fast polling so the 1s timeout exhausts quickly
1988
+
1989
+ let caughtError: unknown;
1990
+ try {
1991
+ await askCoordinator(
1992
+ "will you answer?",
1993
+ { subject: "timeout test", timeout: 1, json: false },
1994
+ deps,
1995
+ );
1996
+ } catch (err) {
1997
+ caughtError = err;
1998
+ }
1999
+
2000
+ expect(caughtError).toBeInstanceOf(AgentError);
2001
+ const ae = caughtError as AgentError;
2002
+ expect(ae.message).toContain("Timed out");
2003
+ });
2004
+
2005
+ test("throws when coordinator is not running", async () => {
2006
+ // No session in DB
2007
+ const { deps } = makeDeps();
2008
+
2009
+ let caughtError: unknown;
2010
+ try {
2011
+ await askCoordinator("hello", { subject: "test", timeout: 5, json: false }, deps);
2012
+ } catch (err) {
2013
+ caughtError = err;
2014
+ }
2015
+
2016
+ expect(caughtError).toBeInstanceOf(AgentError);
2017
+ const ae = caughtError as AgentError;
2018
+ expect(ae.message).toContain("No active coordinator");
2019
+ });
2020
+
2021
+ test("throws when coordinator tmux session is dead", async () => {
2022
+ const session = makeCoordinatorSession({ state: "working" });
2023
+ saveSessionsToDb([session]);
2024
+
2025
+ // Tmux reports session as dead
2026
+ const { deps } = makeDeps({ "overstory-test-project-coordinator": false });
2027
+
2028
+ let caughtError: unknown;
2029
+ try {
2030
+ await askCoordinator("hello", { subject: "test", timeout: 5, json: false }, deps);
2031
+ } catch (err) {
2032
+ caughtError = err;
2033
+ }
2034
+
2035
+ expect(caughtError).toBeInstanceOf(AgentError);
2036
+ const ae = caughtError as AgentError;
2037
+ expect(ae.message).toContain("not alive");
2038
+
2039
+ // Session state should be updated to zombie
2040
+ const sessions = loadSessionsFromDb();
2041
+ expect(sessions[0]?.state).toBe("zombie");
2042
+ });
2043
+
2044
+ test("JSON output includes correlationId and reply details", async () => {
2045
+ const session = makeCoordinatorSession({ state: "working" });
2046
+ saveSessionsToDb([session]);
2047
+
2048
+ const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
2049
+ deps._nudge = async () => ({ delivered: true });
2050
+ deps._pollIntervalMs = 50;
2051
+
2052
+ const mailDbPath = join(overstoryDir, "mail.db");
2053
+ let output = "";
2054
+
2055
+ const askPromise = captureStdout(async () => {
2056
+ const innerAskPromise = askCoordinator(
2057
+ "report status",
2058
+ { subject: "status", timeout: 10, json: true },
2059
+ deps,
2060
+ );
2061
+
2062
+ // Insert reply while ask is polling
2063
+ await Bun.sleep(300);
2064
+ const replyStore = createMailStore(mailDbPath);
2065
+ try {
2066
+ const messages = replyStore.getAll({ from: "operator", to: "coordinator" });
2067
+ const sent = messages[0];
2068
+ if (sent) {
2069
+ replyStore.insert({
2070
+ id: "",
2071
+ from: "coordinator",
2072
+ to: "operator",
2073
+ subject: `Re: ${sent.subject}`,
2074
+ body: "Status: all good",
2075
+ type: "status",
2076
+ priority: "normal",
2077
+ threadId: sent.id,
2078
+ payload: JSON.stringify({
2079
+ correlationId: JSON.parse(sent.payload ?? "{}").correlationId,
2080
+ }),
2081
+ });
2082
+ }
2083
+ } finally {
2084
+ replyStore.close();
2085
+ }
2086
+
2087
+ await innerAskPromise;
2088
+ });
2089
+
2090
+ output = await askPromise;
2091
+
2092
+ const parsed = JSON.parse(output) as Record<string, unknown>;
2093
+ expect(parsed.success).toBe(true);
2094
+ expect(parsed.command).toBe("coordinator ask");
2095
+ expect(typeof parsed.correlationId).toBe("string");
2096
+ expect(typeof parsed.sentId).toBe("string");
2097
+ expect(typeof parsed.replyId).toBe("string");
2098
+ expect(parsed.body).toBe("Status: all good");
2099
+ });
2100
+
2101
+ test("command registration — createCoordinatorCommand has ask subcommand", () => {
2102
+ const cmd = createCoordinatorCommand({});
2103
+ const subcommandNames = cmd.commands.map((c) => c.name());
2104
+ expect(subcommandNames).toContain("ask");
2105
+ });
2106
+ });