@os-eco/overstory-cli 0.9.3 → 0.10.3

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 (116) hide show
  1. package/README.md +49 -18
  2. package/agents/builder.md +9 -8
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +98 -82
  5. package/agents/merger.md +25 -14
  6. package/agents/reviewer.md +22 -16
  7. package/agents/scout.md +17 -12
  8. package/package.json +6 -3
  9. package/src/agents/capabilities.test.ts +85 -0
  10. package/src/agents/capabilities.ts +125 -0
  11. package/src/agents/headless-mail-injector.test.ts +448 -0
  12. package/src/agents/headless-mail-injector.ts +211 -0
  13. package/src/agents/headless-prompt.test.ts +102 -0
  14. package/src/agents/headless-prompt.ts +68 -0
  15. package/src/agents/hooks-deployer.test.ts +514 -14
  16. package/src/agents/hooks-deployer.ts +141 -0
  17. package/src/agents/overlay.test.ts +4 -4
  18. package/src/agents/overlay.ts +30 -8
  19. package/src/agents/turn-lock.test.ts +181 -0
  20. package/src/agents/turn-lock.ts +235 -0
  21. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  22. package/src/agents/turn-runner-dispatch.ts +105 -0
  23. package/src/agents/turn-runner.test.ts +1450 -0
  24. package/src/agents/turn-runner.ts +1166 -0
  25. package/src/commands/clean.ts +56 -1
  26. package/src/commands/completions.test.ts +4 -1
  27. package/src/commands/coordinator.test.ts +127 -0
  28. package/src/commands/coordinator.ts +205 -6
  29. package/src/commands/dashboard.test.ts +188 -0
  30. package/src/commands/dashboard.ts +13 -3
  31. package/src/commands/doctor.ts +94 -77
  32. package/src/commands/group.test.ts +94 -0
  33. package/src/commands/group.ts +49 -20
  34. package/src/commands/init.test.ts +8 -0
  35. package/src/commands/init.ts +8 -1
  36. package/src/commands/log.test.ts +56 -11
  37. package/src/commands/log.ts +134 -69
  38. package/src/commands/mail.test.ts +162 -0
  39. package/src/commands/mail.ts +64 -9
  40. package/src/commands/merge.test.ts +112 -1
  41. package/src/commands/merge.ts +17 -4
  42. package/src/commands/monitor.ts +2 -1
  43. package/src/commands/nudge.test.ts +351 -4
  44. package/src/commands/nudge.ts +356 -34
  45. package/src/commands/run.test.ts +43 -7
  46. package/src/commands/serve/build.test.ts +202 -0
  47. package/src/commands/serve/build.ts +206 -0
  48. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  49. package/src/commands/serve/coordinator-actions.ts +408 -0
  50. package/src/commands/serve/dev.test.ts +168 -0
  51. package/src/commands/serve/dev.ts +117 -0
  52. package/src/commands/serve/mail-actions.test.ts +312 -0
  53. package/src/commands/serve/mail-actions.ts +167 -0
  54. package/src/commands/serve/rest.test.ts +1323 -0
  55. package/src/commands/serve/rest.ts +708 -0
  56. package/src/commands/serve/static.ts +51 -0
  57. package/src/commands/serve/ws.test.ts +361 -0
  58. package/src/commands/serve/ws.ts +332 -0
  59. package/src/commands/serve.test.ts +459 -0
  60. package/src/commands/serve.ts +565 -0
  61. package/src/commands/sling.test.ts +85 -1
  62. package/src/commands/sling.ts +153 -64
  63. package/src/commands/status.test.ts +9 -0
  64. package/src/commands/status.ts +12 -4
  65. package/src/commands/stop.test.ts +174 -1
  66. package/src/commands/stop.ts +107 -8
  67. package/src/commands/supervisor.ts +2 -1
  68. package/src/commands/watch.test.ts +49 -4
  69. package/src/commands/watch.ts +153 -28
  70. package/src/commands/worktree.test.ts +319 -3
  71. package/src/commands/worktree.ts +86 -0
  72. package/src/config.test.ts +78 -0
  73. package/src/config.ts +43 -1
  74. package/src/doctor/consistency.test.ts +106 -0
  75. package/src/doctor/consistency.ts +50 -3
  76. package/src/doctor/serve.test.ts +95 -0
  77. package/src/doctor/serve.ts +86 -0
  78. package/src/doctor/types.ts +2 -1
  79. package/src/doctor/watchdog.ts +57 -1
  80. package/src/events/tailer.test.ts +234 -1
  81. package/src/events/tailer.ts +90 -0
  82. package/src/index.ts +53 -6
  83. package/src/json.ts +29 -0
  84. package/src/mail/client.ts +15 -2
  85. package/src/mail/store.test.ts +82 -0
  86. package/src/mail/store.ts +41 -4
  87. package/src/merge/lock.test.ts +149 -0
  88. package/src/merge/lock.ts +140 -0
  89. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  90. package/src/runtimes/claude.test.ts +791 -1
  91. package/src/runtimes/claude.ts +323 -1
  92. package/src/runtimes/connections.test.ts +141 -1
  93. package/src/runtimes/connections.ts +73 -4
  94. package/src/runtimes/headless-connection.test.ts +264 -0
  95. package/src/runtimes/headless-connection.ts +158 -0
  96. package/src/runtimes/types.ts +10 -0
  97. package/src/schema-consistency.test.ts +1 -0
  98. package/src/sessions/store.test.ts +390 -24
  99. package/src/sessions/store.ts +184 -19
  100. package/src/test-setup.test.ts +31 -0
  101. package/src/test-setup.ts +28 -0
  102. package/src/types.ts +56 -1
  103. package/src/utils/pid.test.ts +85 -1
  104. package/src/utils/pid.ts +86 -1
  105. package/src/utils/process-scan.test.ts +53 -0
  106. package/src/utils/process-scan.ts +76 -0
  107. package/src/watchdog/daemon.test.ts +1520 -411
  108. package/src/watchdog/daemon.ts +442 -83
  109. package/src/watchdog/health.test.ts +157 -0
  110. package/src/watchdog/health.ts +92 -25
  111. package/src/worktree/process.test.ts +71 -0
  112. package/src/worktree/process.ts +25 -5
  113. package/src/worktree/tmux.test.ts +39 -0
  114. package/src/worktree/tmux.ts +23 -3
  115. package/templates/CLAUDE.md.tmpl +19 -8
  116. package/templates/overlay.md.tmpl +3 -2
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Tests for ensureUiBuild auto-build helper.
3
+ *
4
+ * Real filesystem (temp dirs + utimesSync) drives the freshness comparison;
5
+ * the runner is always stubbed so we never invoke a real bun install/build.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { ensureUiBuild, type RunnerResult } from "./build.ts";
13
+
14
+ interface RunnerCall {
15
+ cmd: string[];
16
+ cwd: string;
17
+ }
18
+
19
+ function makeRunner(results: RunnerResult[]): {
20
+ runner: (cmd: string[], cwd: string) => Promise<RunnerResult>;
21
+ calls: RunnerCall[];
22
+ } {
23
+ const calls: RunnerCall[] = [];
24
+ let i = 0;
25
+ return {
26
+ calls,
27
+ runner: async (cmd, cwd) => {
28
+ calls.push({ cmd, cwd });
29
+ const r = results[i] ?? { exitCode: 0, stderr: "" };
30
+ i += 1;
31
+ return r;
32
+ },
33
+ };
34
+ }
35
+
36
+ function setupUiDir(root: string): { uiDir: string } {
37
+ const uiDir = join(root, "ui");
38
+ mkdirSync(join(uiDir, "src"), { recursive: true });
39
+ writeFileSync(join(uiDir, "src", "main.ts"), 'console.log("hi")');
40
+ writeFileSync(join(uiDir, "index.html"), "<html></html>");
41
+ writeFileSync(join(uiDir, "package.json"), '{"name":"ui"}');
42
+ return { uiDir };
43
+ }
44
+
45
+ describe("ensureUiBuild", () => {
46
+ let tempDir: string;
47
+
48
+ beforeEach(() => {
49
+ tempDir = mkdtempSync(join(tmpdir(), "overstory-ui-build-"));
50
+ });
51
+
52
+ afterEach(() => {
53
+ rmSync(tempDir, { recursive: true, force: true });
54
+ });
55
+
56
+ test("triggers build when dist/index.html is missing", async () => {
57
+ const { uiDir } = setupUiDir(tempDir);
58
+ // Pretend node_modules already exists so we skip install.
59
+ mkdirSync(join(uiDir, "node_modules"));
60
+
61
+ const { runner, calls } = makeRunner([{ exitCode: 0, stderr: "" }]);
62
+ await ensureUiBuild({ uiDir, _runner: runner, log: () => {} });
63
+
64
+ expect(calls.length).toBe(1);
65
+ expect(calls[0]?.cmd).toEqual(["bun", "run", "build"]);
66
+ expect(calls[0]?.cwd).toBe(uiDir);
67
+ });
68
+
69
+ test("triggers build when source mtime is newer than dist mtime", async () => {
70
+ const { uiDir } = setupUiDir(tempDir);
71
+ mkdirSync(join(uiDir, "node_modules"));
72
+ mkdirSync(join(uiDir, "dist"));
73
+ writeFileSync(join(uiDir, "dist", "index.html"), "<html>old</html>");
74
+
75
+ // Make dist older, src newer (definite mtime separation).
76
+ const past = new Date(Date.now() - 60_000);
77
+ const future = new Date(Date.now() + 60_000);
78
+ utimesSync(join(uiDir, "dist", "index.html"), past, past);
79
+ utimesSync(join(uiDir, "src", "main.ts"), future, future);
80
+
81
+ const { runner, calls } = makeRunner([{ exitCode: 0, stderr: "" }]);
82
+ await ensureUiBuild({ uiDir, _runner: runner, log: () => {} });
83
+
84
+ expect(calls.length).toBe(1);
85
+ expect(calls[0]?.cmd).toEqual(["bun", "run", "build"]);
86
+ });
87
+
88
+ test("skips build when dist is newer than every source file", async () => {
89
+ const { uiDir } = setupUiDir(tempDir);
90
+ mkdirSync(join(uiDir, "node_modules"));
91
+ mkdirSync(join(uiDir, "dist"));
92
+ writeFileSync(join(uiDir, "dist", "index.html"), "<html>fresh</html>");
93
+
94
+ // All sources older than dist.
95
+ const past = new Date(Date.now() - 60_000);
96
+ utimesSync(join(uiDir, "src", "main.ts"), past, past);
97
+ utimesSync(join(uiDir, "index.html"), past, past);
98
+ utimesSync(join(uiDir, "package.json"), past, past);
99
+ // Dist is "now" — already the newest.
100
+
101
+ const { runner, calls } = makeRunner([]);
102
+ await ensureUiBuild({ uiDir, _runner: runner, log: () => {} });
103
+
104
+ expect(calls.length).toBe(0);
105
+ });
106
+
107
+ test("runs install only when node_modules is missing", async () => {
108
+ const { uiDir } = setupUiDir(tempDir);
109
+ // node_modules deliberately missing.
110
+
111
+ const { runner, calls } = makeRunner([
112
+ { exitCode: 0, stderr: "" }, // install
113
+ { exitCode: 0, stderr: "" }, // build
114
+ ]);
115
+ await ensureUiBuild({ uiDir, _runner: runner, log: () => {} });
116
+
117
+ expect(calls.length).toBe(2);
118
+ expect(calls[0]?.cmd).toEqual(["bun", "install"]);
119
+ expect(calls[0]?.cwd).toBe(uiDir);
120
+ expect(calls[1]?.cmd).toEqual(["bun", "run", "build"]);
121
+ });
122
+
123
+ test("does NOT run install when node_modules exists", async () => {
124
+ const { uiDir } = setupUiDir(tempDir);
125
+ mkdirSync(join(uiDir, "node_modules"));
126
+
127
+ const { runner, calls } = makeRunner([{ exitCode: 0, stderr: "" }]);
128
+ await ensureUiBuild({ uiDir, _runner: runner, log: () => {} });
129
+
130
+ expect(calls.length).toBe(1);
131
+ expect(calls[0]?.cmd).toEqual(["bun", "run", "build"]);
132
+ });
133
+
134
+ test("throws on non-zero install exit; error includes stderr", async () => {
135
+ const { uiDir } = setupUiDir(tempDir);
136
+
137
+ const { runner } = makeRunner([{ exitCode: 1, stderr: "lockfile out of date" }]);
138
+
139
+ await expect(ensureUiBuild({ uiDir, _runner: runner, log: () => {} })).rejects.toThrow(
140
+ /lockfile out of date/,
141
+ );
142
+ });
143
+
144
+ test("throws on non-zero build exit; error includes stderr", async () => {
145
+ const { uiDir } = setupUiDir(tempDir);
146
+ mkdirSync(join(uiDir, "node_modules"));
147
+
148
+ const { runner } = makeRunner([{ exitCode: 2, stderr: "type error in main.ts" }]);
149
+
150
+ await expect(ensureUiBuild({ uiDir, _runner: runner, log: () => {} })).rejects.toThrow(
151
+ /type error in main\.ts/,
152
+ );
153
+ });
154
+
155
+ test("logs progress messages on build path", async () => {
156
+ const { uiDir } = setupUiDir(tempDir);
157
+ // node_modules missing → install + build → 3 log lines (install, build, built).
158
+ const messages: string[] = [];
159
+ const { runner } = makeRunner([
160
+ { exitCode: 0, stderr: "" },
161
+ { exitCode: 0, stderr: "" },
162
+ ]);
163
+ await ensureUiBuild({ uiDir, _runner: runner, log: (m) => messages.push(m) });
164
+
165
+ expect(messages).toEqual(["Installing UI dependencies…", "Building UI…", "UI built"]);
166
+ });
167
+
168
+ test("no-ops when ui/src is absent (production-install case)", async () => {
169
+ // No setupUiDir() — tempDir has no ui/ at all, mirroring a fresh `ov init`
170
+ // in a project that doesn't carry a UI workspace (overstory-916d).
171
+ const uiDir = join(tempDir, "ui");
172
+
173
+ const messages: string[] = [];
174
+ const { runner, calls } = makeRunner([]);
175
+ await ensureUiBuild({ uiDir, _runner: runner, log: (m) => messages.push(m) });
176
+
177
+ // Neither install nor build runs, and no progress messages are emitted.
178
+ expect(calls.length).toBe(0);
179
+ expect(messages.length).toBe(0);
180
+ });
181
+
182
+ test("walks ui/src/ recursively and triggers on nested-file mtime", async () => {
183
+ const { uiDir } = setupUiDir(tempDir);
184
+ mkdirSync(join(uiDir, "node_modules"));
185
+ mkdirSync(join(uiDir, "src", "components"), { recursive: true });
186
+ writeFileSync(join(uiDir, "src", "components", "App.tsx"), "export default null;");
187
+ mkdirSync(join(uiDir, "dist"));
188
+ writeFileSync(join(uiDir, "dist", "index.html"), "<html>old</html>");
189
+
190
+ // dist older than the nested file.
191
+ const past = new Date(Date.now() - 60_000);
192
+ const future = new Date(Date.now() + 60_000);
193
+ utimesSync(join(uiDir, "dist", "index.html"), past, past);
194
+ utimesSync(join(uiDir, "src", "main.ts"), past, past);
195
+ utimesSync(join(uiDir, "src", "components", "App.tsx"), future, future);
196
+
197
+ const { runner, calls } = makeRunner([{ exitCode: 0, stderr: "" }]);
198
+ await ensureUiBuild({ uiDir, _runner: runner, log: () => {} });
199
+
200
+ expect(calls.length).toBe(1);
201
+ });
202
+ });
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Auto-build helper for `ov serve` (production mode).
3
+ *
4
+ * Detects whether ui/dist/index.html is missing or older than any tracked
5
+ * source/config file in the ui/ workspace, runs `bun install` (only if
6
+ * node_modules is missing) followed by `bun run build` when so. Silent when
7
+ * the existing build is up to date.
8
+ *
9
+ * No-ops when the project has no `ui/src` directory — the production-install
10
+ * case where the user's project doesn't carry a UI workspace and is meant to
11
+ * serve the prebuilt assets shipped inside the @os-eco/overstory-cli package
12
+ * (see overstory-916d).
13
+ *
14
+ * The runner and filesystem helpers are injectable so tests can drive the
15
+ * decision logic without invoking real subprocess builds.
16
+ */
17
+
18
+ import {
19
+ existsSync as defaultExistsSync,
20
+ readdirSync as defaultReaddirSync,
21
+ statSync as defaultStatSync,
22
+ } from "node:fs";
23
+ import { join, sep } from "node:path";
24
+
25
+ export interface RunnerResult {
26
+ exitCode: number;
27
+ stderr: string;
28
+ }
29
+
30
+ export interface EnsureUiBuildOptions {
31
+ uiDir: string;
32
+ log?: (msg: string) => void;
33
+ _statSync?: typeof defaultStatSync;
34
+ _existsSync?: typeof defaultExistsSync;
35
+ _readdirSync?: typeof defaultReaddirSync;
36
+ _runner?: (cmd: string[], cwd: string) => Promise<RunnerResult>;
37
+ }
38
+
39
+ /** Files at the workspace root that affect the build output. */
40
+ const ROOT_TRACKED_FILES = [
41
+ "index.html",
42
+ "build.ts",
43
+ "package.json",
44
+ "tsconfig.app.json",
45
+ "tsconfig.json",
46
+ "components.json",
47
+ ];
48
+
49
+ /**
50
+ * Recursively walk a directory, calling visit() on each regular file path.
51
+ * Skips entries that throw on stat (broken symlinks, races during npm
52
+ * install). Uses the injected readdir/stat for testability.
53
+ */
54
+ function walkDir(
55
+ dir: string,
56
+ visit: (path: string) => void,
57
+ exists: typeof defaultExistsSync,
58
+ readdir: typeof defaultReaddirSync,
59
+ stat: typeof defaultStatSync,
60
+ ): void {
61
+ if (!exists(dir)) return;
62
+ let entries: string[];
63
+ try {
64
+ entries = readdir(dir);
65
+ } catch {
66
+ return;
67
+ }
68
+ for (const name of entries) {
69
+ const full = dir + sep + name;
70
+ let st: ReturnType<typeof defaultStatSync>;
71
+ try {
72
+ st = stat(full);
73
+ } catch {
74
+ continue;
75
+ }
76
+ if (st.isDirectory()) {
77
+ walkDir(full, visit, exists, readdir, stat);
78
+ } else if (st.isFile()) {
79
+ visit(full);
80
+ }
81
+ }
82
+ }
83
+
84
+ async function defaultRunner(cmd: string[], cwd: string): Promise<RunnerResult> {
85
+ if (cmd.length === 0) {
86
+ throw new Error("ensureUiBuild runner: empty command");
87
+ }
88
+ const proc = Bun.spawn(cmd as string[], {
89
+ cwd,
90
+ stdout: "pipe",
91
+ stderr: "pipe",
92
+ });
93
+
94
+ const stderrChunks: string[] = [];
95
+ const pipe = async (
96
+ stream: ReadableStream<Uint8Array> | null,
97
+ sink: (line: string) => void,
98
+ capture?: string[],
99
+ ): Promise<void> => {
100
+ if (stream === null) return;
101
+ const decoder = new TextDecoder();
102
+ let buf = "";
103
+ const reader = stream.getReader();
104
+ try {
105
+ while (true) {
106
+ const { done, value } = await reader.read();
107
+ if (done) break;
108
+ const chunk = decoder.decode(value, { stream: true });
109
+ if (capture) capture.push(chunk);
110
+ buf += chunk;
111
+ let idx = buf.indexOf("\n");
112
+ while (idx !== -1) {
113
+ sink(buf.slice(0, idx));
114
+ buf = buf.slice(idx + 1);
115
+ idx = buf.indexOf("\n");
116
+ }
117
+ }
118
+ if (buf.length > 0) sink(buf);
119
+ } finally {
120
+ reader.releaseLock();
121
+ }
122
+ };
123
+
124
+ const writeOut = (line: string): void => {
125
+ process.stderr.write(`[ui-build] ${line}\n`);
126
+ };
127
+
128
+ await Promise.all([pipe(proc.stdout, writeOut), pipe(proc.stderr, writeOut, stderrChunks)]);
129
+
130
+ const exitCode = await proc.exited;
131
+ return { exitCode, stderr: stderrChunks.join("") };
132
+ }
133
+
134
+ /**
135
+ * Ensure that ui/dist/ has a build that is newer than every tracked source
136
+ * file. No-op when the build is current. Throws when a required subprocess
137
+ * (install / build) fails; the thrown Error includes the captured stderr.
138
+ */
139
+ export async function ensureUiBuild(opts: EnsureUiBuildOptions): Promise<void> {
140
+ const exists = opts._existsSync ?? defaultExistsSync;
141
+ const stat = opts._statSync ?? defaultStatSync;
142
+ const readdir = opts._readdirSync ?? defaultReaddirSync;
143
+ const runner = opts._runner ?? defaultRunner;
144
+ const log =
145
+ opts.log ??
146
+ ((msg: string): void => {
147
+ process.stderr.write(`[ui-build] ${msg}\n`);
148
+ });
149
+
150
+ // Production-install case: when the project has no ui/src, there are no
151
+ // sources to build from. ov serve falls back to the prebuilt assets shipped
152
+ // inside the npm package — see resolveUiDistPath() in serve.ts.
153
+ if (!exists(join(opts.uiDir, "src"))) return;
154
+
155
+ const distIndex = join(opts.uiDir, "dist", "index.html");
156
+ let needBuild = false;
157
+ if (!exists(distIndex)) {
158
+ needBuild = true;
159
+ } else {
160
+ let distMtime = 0;
161
+ try {
162
+ distMtime = stat(distIndex).mtimeMs;
163
+ } catch {
164
+ needBuild = true;
165
+ }
166
+
167
+ if (!needBuild) {
168
+ let newest = 0;
169
+ const visit = (path: string): void => {
170
+ try {
171
+ const m = stat(path).mtimeMs;
172
+ if (m > newest) newest = m;
173
+ } catch {
174
+ // File vanished between readdir and stat — ignore.
175
+ }
176
+ };
177
+
178
+ walkDir(join(opts.uiDir, "src"), visit, exists, readdir, stat);
179
+ for (const name of ROOT_TRACKED_FILES) {
180
+ const full = join(opts.uiDir, name);
181
+ if (exists(full)) visit(full);
182
+ }
183
+
184
+ if (newest > distMtime) needBuild = true;
185
+ }
186
+ }
187
+
188
+ if (!needBuild) return;
189
+
190
+ if (!exists(join(opts.uiDir, "node_modules"))) {
191
+ log("Installing UI dependencies…");
192
+ const install = await runner(["bun", "install"], opts.uiDir);
193
+ if (install.exitCode !== 0) {
194
+ throw new Error(
195
+ `UI dependency install failed (exit ${install.exitCode}): ${install.stderr.trim()}`,
196
+ );
197
+ }
198
+ }
199
+
200
+ log("Building UI…");
201
+ const build = await runner(["bun", "run", "build"], opts.uiDir);
202
+ if (build.exitCode !== 0) {
203
+ throw new Error(`UI build failed (exit ${build.exitCode}): ${build.stderr.trim()}`);
204
+ }
205
+ log("UI built");
206
+ }