@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
@@ -0,0 +1,465 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
5
+ import type { Spawner } from "./init.ts";
6
+ import {
7
+ buildAgentManifest,
8
+ buildHooksJson,
9
+ initCommand,
10
+ OVERSTORY_GITIGNORE,
11
+ OVERSTORY_README,
12
+ } from "./init.ts";
13
+ import { executeUpdate } from "./update.ts";
14
+
15
+ /**
16
+ * Tests for `ov update` — refresh .overstory/ managed files.
17
+ *
18
+ * Uses real temp git repos. Suppresses stdout to keep test output clean.
19
+ * Requires a pre-initialized .overstory/ directory (via initCommand).
20
+ */
21
+
22
+ /** No-op spawner that treats all ecosystem tools as "not installed". */
23
+ const noopSpawner: Spawner = async () => ({ exitCode: 1, stdout: "", stderr: "not found" });
24
+
25
+ const AGENT_DEF_FILES = [
26
+ "builder.md",
27
+ "coordinator.md",
28
+ "lead.md",
29
+ "merger.md",
30
+ "monitor.md",
31
+ "orchestrator.md",
32
+ "reviewer.md",
33
+ "scout.md",
34
+ ];
35
+
36
+ /** Resolve the source agents directory (same logic as init.ts). */
37
+ const SOURCE_AGENTS_DIR = join(import.meta.dir, "..", "..", "agents");
38
+
39
+ describe("executeUpdate: not initialized", () => {
40
+ let tempDir: string;
41
+ let originalCwd: string;
42
+ let originalWrite: typeof process.stdout.write;
43
+
44
+ beforeEach(async () => {
45
+ tempDir = await createTempGitRepo();
46
+ originalCwd = process.cwd();
47
+ process.chdir(tempDir);
48
+
49
+ originalWrite = process.stdout.write;
50
+ process.stdout.write = (() => true) as typeof process.stdout.write;
51
+ });
52
+
53
+ afterEach(async () => {
54
+ process.chdir(originalCwd);
55
+ process.stdout.write = originalWrite;
56
+ await cleanupTempDir(tempDir);
57
+ });
58
+
59
+ test("errors when .overstory/config.yaml does not exist", async () => {
60
+ await expect(executeUpdate({})).rejects.toThrow("Not initialized");
61
+ });
62
+
63
+ test("error message hints to run ov init", async () => {
64
+ try {
65
+ await executeUpdate({});
66
+ expect.unreachable("Should have thrown");
67
+ } catch (err) {
68
+ expect((err as Error).message).toContain("ov init");
69
+ }
70
+ });
71
+ });
72
+
73
+ describe("executeUpdate: refresh all (no flags)", () => {
74
+ let tempDir: string;
75
+ let originalCwd: string;
76
+ let originalWrite: typeof process.stdout.write;
77
+
78
+ beforeEach(async () => {
79
+ tempDir = await createTempGitRepo();
80
+ originalCwd = process.cwd();
81
+ process.chdir(tempDir);
82
+
83
+ originalWrite = process.stdout.write;
84
+ process.stdout.write = (() => true) as typeof process.stdout.write;
85
+
86
+ // Initialize .overstory/
87
+ await initCommand({ _spawner: noopSpawner });
88
+ });
89
+
90
+ afterEach(async () => {
91
+ process.chdir(originalCwd);
92
+ process.stdout.write = originalWrite;
93
+ await cleanupTempDir(tempDir);
94
+ });
95
+
96
+ test("refreshes all managed files when no flags given", async () => {
97
+ // Tamper with agent defs
98
+ const scoutPath = join(tempDir, ".overstory", "agent-defs", "scout.md");
99
+ await Bun.write(scoutPath, "# tampered\n");
100
+
101
+ // Tamper with manifest
102
+ await Bun.write(join(tempDir, ".overstory", "agent-manifest.json"), "{}");
103
+
104
+ // Tamper with hooks
105
+ await Bun.write(join(tempDir, ".overstory", "hooks.json"), "{}");
106
+
107
+ // Tamper with gitignore
108
+ await Bun.write(join(tempDir, ".overstory", ".gitignore"), "# old\n");
109
+
110
+ // Tamper with readme
111
+ await Bun.write(join(tempDir, ".overstory", "README.md"), "# old\n");
112
+
113
+ await executeUpdate({});
114
+
115
+ // Verify all files restored
116
+ const scoutContent = await Bun.file(scoutPath).text();
117
+ const sourceScout = await Bun.file(join(SOURCE_AGENTS_DIR, "scout.md")).text();
118
+ expect(scoutContent).toBe(sourceScout);
119
+
120
+ const manifestContent = await Bun.file(
121
+ join(tempDir, ".overstory", "agent-manifest.json"),
122
+ ).text();
123
+ const expectedManifest = `${JSON.stringify(buildAgentManifest(), null, "\t")}\n`;
124
+ expect(manifestContent).toBe(expectedManifest);
125
+
126
+ const hooksContent = await Bun.file(join(tempDir, ".overstory", "hooks.json")).text();
127
+ expect(hooksContent).toBe(buildHooksJson());
128
+
129
+ const gitignoreContent = await Bun.file(join(tempDir, ".overstory", ".gitignore")).text();
130
+ expect(gitignoreContent).toBe(OVERSTORY_GITIGNORE);
131
+
132
+ const readmeContent = await Bun.file(join(tempDir, ".overstory", "README.md")).text();
133
+ expect(readmeContent).toBe(OVERSTORY_README);
134
+ });
135
+
136
+ test("does not touch config.yaml", async () => {
137
+ const configPath = join(tempDir, ".overstory", "config.yaml");
138
+ const originalConfig = await Bun.file(configPath).text();
139
+
140
+ await executeUpdate({});
141
+
142
+ const afterUpdate = await Bun.file(configPath).text();
143
+ expect(afterUpdate).toBe(originalConfig);
144
+ });
145
+
146
+ test("does not touch databases", async () => {
147
+ // Create fake database files
148
+ const mailDbPath = join(tempDir, ".overstory", "mail.db");
149
+ const sessionsDbPath = join(tempDir, ".overstory", "sessions.db");
150
+ await Bun.write(mailDbPath, "fake-mail-db");
151
+ await Bun.write(sessionsDbPath, "fake-sessions-db");
152
+
153
+ await executeUpdate({});
154
+
155
+ const mailDb = await Bun.file(mailDbPath).text();
156
+ expect(mailDb).toBe("fake-mail-db");
157
+
158
+ const sessionsDb = await Bun.file(sessionsDbPath).text();
159
+ expect(sessionsDb).toBe("fake-sessions-db");
160
+ });
161
+
162
+ test("handles already-up-to-date files gracefully (idempotent)", async () => {
163
+ // Run update twice — second should report nothing changed
164
+ await executeUpdate({});
165
+
166
+ // Capture JSON output of second run
167
+ let captured = "";
168
+ const restoreWrite = process.stdout.write;
169
+ process.stdout.write = ((chunk: unknown) => {
170
+ captured += String(chunk);
171
+ return true;
172
+ }) as typeof process.stdout.write;
173
+
174
+ await executeUpdate({ json: true });
175
+
176
+ process.stdout.write = restoreWrite;
177
+
178
+ const parsed = JSON.parse(captured.trim()) as Record<string, unknown>;
179
+ expect(parsed.success).toBe(true);
180
+
181
+ const agentDefs = parsed.agentDefs as { updated: string[]; unchanged: string[] };
182
+ expect(agentDefs.updated).toHaveLength(0);
183
+ expect(agentDefs.unchanged.length).toBeGreaterThan(0);
184
+
185
+ expect(parsed.manifest).toEqual({ updated: false });
186
+ expect(parsed.hooks).toEqual({ updated: false });
187
+ expect(parsed.gitignore).toEqual({ updated: false });
188
+ expect(parsed.readme).toEqual({ updated: false });
189
+ });
190
+ });
191
+
192
+ describe("executeUpdate: granular flags", () => {
193
+ let tempDir: string;
194
+ let originalCwd: string;
195
+ let originalWrite: typeof process.stdout.write;
196
+
197
+ beforeEach(async () => {
198
+ tempDir = await createTempGitRepo();
199
+ originalCwd = process.cwd();
200
+ process.chdir(tempDir);
201
+
202
+ originalWrite = process.stdout.write;
203
+ process.stdout.write = (() => true) as typeof process.stdout.write;
204
+
205
+ await initCommand({ _spawner: noopSpawner });
206
+ });
207
+
208
+ afterEach(async () => {
209
+ process.chdir(originalCwd);
210
+ process.stdout.write = originalWrite;
211
+ await cleanupTempDir(tempDir);
212
+ });
213
+
214
+ test("--agents only refreshes agent-defs", async () => {
215
+ // Tamper with agent def and manifest
216
+ await Bun.write(join(tempDir, ".overstory", "agent-defs", "scout.md"), "# tampered\n");
217
+ await Bun.write(join(tempDir, ".overstory", "agent-manifest.json"), "{}");
218
+ await Bun.write(join(tempDir, ".overstory", "hooks.json"), "{}");
219
+
220
+ await executeUpdate({ agents: true });
221
+
222
+ // Agent def should be restored
223
+ const scoutContent = await Bun.file(
224
+ join(tempDir, ".overstory", "agent-defs", "scout.md"),
225
+ ).text();
226
+ const sourceScout = await Bun.file(join(SOURCE_AGENTS_DIR, "scout.md")).text();
227
+ expect(scoutContent).toBe(sourceScout);
228
+
229
+ // Manifest should NOT be restored (--agents only)
230
+ const manifestContent = await Bun.file(
231
+ join(tempDir, ".overstory", "agent-manifest.json"),
232
+ ).text();
233
+ expect(manifestContent).toBe("{}");
234
+
235
+ // Hooks should NOT be restored (--agents only)
236
+ const hooksContent = await Bun.file(join(tempDir, ".overstory", "hooks.json")).text();
237
+ expect(hooksContent).toBe("{}");
238
+ });
239
+
240
+ test("--manifest only refreshes agent-manifest.json", async () => {
241
+ await Bun.write(join(tempDir, ".overstory", "agent-manifest.json"), "{}");
242
+ await Bun.write(join(tempDir, ".overstory", "agent-defs", "scout.md"), "# tampered\n");
243
+
244
+ await executeUpdate({ manifest: true });
245
+
246
+ // Manifest should be restored
247
+ const manifestContent = await Bun.file(
248
+ join(tempDir, ".overstory", "agent-manifest.json"),
249
+ ).text();
250
+ const expectedManifest = `${JSON.stringify(buildAgentManifest(), null, "\t")}\n`;
251
+ expect(manifestContent).toBe(expectedManifest);
252
+
253
+ // Agent def should NOT be restored
254
+ const scoutContent = await Bun.file(
255
+ join(tempDir, ".overstory", "agent-defs", "scout.md"),
256
+ ).text();
257
+ expect(scoutContent).toBe("# tampered\n");
258
+ });
259
+
260
+ test("--hooks only refreshes hooks.json", async () => {
261
+ await Bun.write(join(tempDir, ".overstory", "hooks.json"), "{}");
262
+ await Bun.write(join(tempDir, ".overstory", "agent-manifest.json"), "{}");
263
+
264
+ await executeUpdate({ hooks: true });
265
+
266
+ // Hooks should be restored
267
+ const hooksContent = await Bun.file(join(tempDir, ".overstory", "hooks.json")).text();
268
+ expect(hooksContent).toBe(buildHooksJson());
269
+
270
+ // Manifest should NOT be restored
271
+ const manifestContent = await Bun.file(
272
+ join(tempDir, ".overstory", "agent-manifest.json"),
273
+ ).text();
274
+ expect(manifestContent).toBe("{}");
275
+ });
276
+
277
+ test("granular flags do not refresh gitignore or readme", async () => {
278
+ await Bun.write(join(tempDir, ".overstory", ".gitignore"), "# old\n");
279
+ await Bun.write(join(tempDir, ".overstory", "README.md"), "# old\n");
280
+
281
+ await executeUpdate({ agents: true });
282
+
283
+ const gitignoreContent = await Bun.file(join(tempDir, ".overstory", ".gitignore")).text();
284
+ expect(gitignoreContent).toBe("# old\n");
285
+
286
+ const readmeContent = await Bun.file(join(tempDir, ".overstory", "README.md")).text();
287
+ expect(readmeContent).toBe("# old\n");
288
+ });
289
+ });
290
+
291
+ describe("executeUpdate: --dry-run", () => {
292
+ let tempDir: string;
293
+ let originalCwd: string;
294
+ let originalWrite: typeof process.stdout.write;
295
+
296
+ beforeEach(async () => {
297
+ tempDir = await createTempGitRepo();
298
+ originalCwd = process.cwd();
299
+ process.chdir(tempDir);
300
+
301
+ originalWrite = process.stdout.write;
302
+ process.stdout.write = (() => true) as typeof process.stdout.write;
303
+
304
+ await initCommand({ _spawner: noopSpawner });
305
+ });
306
+
307
+ afterEach(async () => {
308
+ process.chdir(originalCwd);
309
+ process.stdout.write = originalWrite;
310
+ await cleanupTempDir(tempDir);
311
+ });
312
+
313
+ test("reports changes without writing files", async () => {
314
+ // Tamper with files
315
+ await Bun.write(join(tempDir, ".overstory", "agent-defs", "scout.md"), "# tampered\n");
316
+ await Bun.write(join(tempDir, ".overstory", "agent-manifest.json"), "{}");
317
+
318
+ let captured = "";
319
+ const restoreWrite = process.stdout.write;
320
+ process.stdout.write = ((chunk: unknown) => {
321
+ captured += String(chunk);
322
+ return true;
323
+ }) as typeof process.stdout.write;
324
+
325
+ await executeUpdate({ dryRun: true, json: true });
326
+
327
+ process.stdout.write = restoreWrite;
328
+
329
+ const parsed = JSON.parse(captured.trim()) as Record<string, unknown>;
330
+ expect(parsed.success).toBe(true);
331
+ expect(parsed.dryRun).toBe(true);
332
+
333
+ const agentDefs = parsed.agentDefs as { updated: string[]; unchanged: string[] };
334
+ expect(agentDefs.updated).toContain("scout.md");
335
+
336
+ expect(parsed.manifest).toEqual({ updated: true });
337
+
338
+ // Verify files were NOT actually modified
339
+ const scoutContent = await Bun.file(
340
+ join(tempDir, ".overstory", "agent-defs", "scout.md"),
341
+ ).text();
342
+ expect(scoutContent).toBe("# tampered\n");
343
+
344
+ const manifestContent = await Bun.file(
345
+ join(tempDir, ".overstory", "agent-manifest.json"),
346
+ ).text();
347
+ expect(manifestContent).toBe("{}");
348
+ });
349
+ });
350
+
351
+ describe("executeUpdate: --json output", () => {
352
+ let tempDir: string;
353
+ let originalCwd: string;
354
+ let originalWrite: typeof process.stdout.write;
355
+
356
+ beforeEach(async () => {
357
+ tempDir = await createTempGitRepo();
358
+ originalCwd = process.cwd();
359
+ process.chdir(tempDir);
360
+
361
+ originalWrite = process.stdout.write;
362
+ process.stdout.write = (() => true) as typeof process.stdout.write;
363
+
364
+ await initCommand({ _spawner: noopSpawner });
365
+ });
366
+
367
+ afterEach(async () => {
368
+ process.chdir(originalCwd);
369
+ process.stdout.write = originalWrite;
370
+ await cleanupTempDir(tempDir);
371
+ });
372
+
373
+ test("outputs correct JSON envelope", async () => {
374
+ let captured = "";
375
+ const restoreWrite = process.stdout.write;
376
+ process.stdout.write = ((chunk: unknown) => {
377
+ captured += String(chunk);
378
+ return true;
379
+ }) as typeof process.stdout.write;
380
+
381
+ await executeUpdate({ json: true });
382
+
383
+ process.stdout.write = restoreWrite;
384
+
385
+ const parsed = JSON.parse(captured.trim()) as Record<string, unknown>;
386
+ expect(parsed.success).toBe(true);
387
+ expect(parsed.command).toBe("update");
388
+ expect(parsed.dryRun).toBe(false);
389
+ expect(parsed.agentDefs).toBeDefined();
390
+ expect(parsed.manifest).toBeDefined();
391
+ expect(parsed.hooks).toBeDefined();
392
+ expect(parsed.gitignore).toBeDefined();
393
+ expect(parsed.readme).toBeDefined();
394
+ });
395
+
396
+ test("JSON envelope includes updated file lists", async () => {
397
+ // Tamper with scout
398
+ await Bun.write(join(tempDir, ".overstory", "agent-defs", "scout.md"), "# tampered\n");
399
+
400
+ let captured = "";
401
+ const restoreWrite = process.stdout.write;
402
+ process.stdout.write = ((chunk: unknown) => {
403
+ captured += String(chunk);
404
+ return true;
405
+ }) as typeof process.stdout.write;
406
+
407
+ await executeUpdate({ json: true });
408
+
409
+ process.stdout.write = restoreWrite;
410
+
411
+ const parsed = JSON.parse(captured.trim()) as Record<string, unknown>;
412
+ const agentDefs = parsed.agentDefs as { updated: string[]; unchanged: string[] };
413
+ expect(agentDefs.updated).toContain("scout.md");
414
+ expect(agentDefs.unchanged.length).toBeGreaterThan(0);
415
+ });
416
+ });
417
+
418
+ describe("executeUpdate: agent def exclusions", () => {
419
+ let tempDir: string;
420
+ let originalCwd: string;
421
+ let originalWrite: typeof process.stdout.write;
422
+
423
+ beforeEach(async () => {
424
+ tempDir = await createTempGitRepo();
425
+ originalCwd = process.cwd();
426
+ process.chdir(tempDir);
427
+
428
+ originalWrite = process.stdout.write;
429
+ process.stdout.write = (() => true) as typeof process.stdout.write;
430
+
431
+ await initCommand({ _spawner: noopSpawner });
432
+ });
433
+
434
+ afterEach(async () => {
435
+ process.chdir(originalCwd);
436
+ process.stdout.write = originalWrite;
437
+ await cleanupTempDir(tempDir);
438
+ });
439
+
440
+ test("does not deploy supervisor.md (deprecated)", async () => {
441
+ await executeUpdate({ agents: true });
442
+
443
+ const agentDefsDir = join(tempDir, ".overstory", "agent-defs");
444
+ const files = await readdir(agentDefsDir);
445
+ expect(files).not.toContain("supervisor.md");
446
+ });
447
+
448
+ test("deploys all non-deprecated agent defs", async () => {
449
+ // Delete all agent defs first
450
+ for (const f of AGENT_DEF_FILES) {
451
+ try {
452
+ const { unlink } = await import("node:fs/promises");
453
+ await unlink(join(tempDir, ".overstory", "agent-defs", f));
454
+ } catch {
455
+ // May not exist
456
+ }
457
+ }
458
+
459
+ await executeUpdate({ agents: true });
460
+
461
+ const agentDefsDir = join(tempDir, ".overstory", "agent-defs");
462
+ const files = (await readdir(agentDefsDir)).filter((f) => f.endsWith(".md")).sort();
463
+ expect(files).toEqual(AGENT_DEF_FILES);
464
+ });
465
+ });