@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
package/src/mail/store.ts CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { Database } from "bun:sqlite";
10
10
  import { MailError } from "../errors.ts";
11
- import type { MailMessage } from "../types.ts";
11
+ import type { MailMessage, MailMessageType } from "../types.ts";
12
12
  import { MAIL_MESSAGE_TYPES } from "../types.ts";
13
13
 
14
14
  export interface MailStore {
@@ -16,10 +16,18 @@ export interface MailStore {
16
16
  message: Omit<MailMessage, "read" | "createdAt" | "payload"> & { payload?: string | null },
17
17
  ): MailMessage;
18
18
  getUnread(agentName: string): MailMessage[];
19
- getAll(filters?: { from?: string; to?: string; unread?: boolean; limit?: number }): MailMessage[];
19
+ getAll(filters?: {
20
+ from?: string;
21
+ to?: string;
22
+ unread?: boolean;
23
+ type?: MailMessageType;
24
+ limit?: number;
25
+ }): MailMessage[];
20
26
  getById(id: string): MailMessage | null;
21
27
  getByThread(threadId: string): MailMessage[];
22
28
  markRead(id: string): void;
29
+ /** Delete a single message by id. Returns true if a row was deleted. */
30
+ deleteById(id: string): boolean;
23
31
  /** Delete messages matching the given criteria. Returns the number of messages deleted. */
24
32
  purge(options: { all?: boolean; olderThanMs?: number; agent?: string }): number;
25
33
  close(): void;
@@ -84,14 +92,21 @@ function migrateSchema(db: Database): void {
84
92
  const hasPayloadColumn = row.sql.includes("payload");
85
93
  const hasProtocolTypes = row.sql.includes("worker_done");
86
94
  const hasDecisionGate = row.sql.includes("decision_gate");
95
+ const hasWorkerDied = row.sql.includes("worker_died");
87
96
 
88
97
  // If schema is fully up to date, nothing to do
89
- if (hasCheckConstraints && hasPayloadColumn && hasProtocolTypes && hasDecisionGate) {
98
+ if (
99
+ hasCheckConstraints &&
100
+ hasPayloadColumn &&
101
+ hasProtocolTypes &&
102
+ hasDecisionGate &&
103
+ hasWorkerDied
104
+ ) {
90
105
  return;
91
106
  }
92
107
 
93
108
  // If only missing the payload column (has correct CHECK constraints), use ALTER TABLE
94
- if (hasCheckConstraints && hasProtocolTypes && !hasPayloadColumn) {
109
+ if (hasCheckConstraints && hasProtocolTypes && hasWorkerDied && !hasPayloadColumn) {
95
110
  db.exec("ALTER TABLE messages ADD COLUMN payload TEXT");
96
111
  return;
97
112
  }
@@ -232,11 +247,16 @@ export function createMailStore(dbPath: string): MailStore {
232
247
  UPDATE messages SET read = 1 WHERE id = $id
233
248
  `);
234
249
 
250
+ const deleteByIdStmt = db.prepare<void, { $id: string }>(`
251
+ DELETE FROM messages WHERE id = $id
252
+ `);
253
+
235
254
  // Dynamic filter queries are built at call time since the WHERE clause varies
236
255
  function buildFilterQuery(filters?: {
237
256
  from?: string;
238
257
  to?: string;
239
258
  unread?: boolean;
259
+ type?: MailMessageType;
240
260
  limit?: number;
241
261
  }): MailMessage[] {
242
262
  const conditions: string[] = [];
@@ -254,6 +274,10 @@ export function createMailStore(dbPath: string): MailStore {
254
274
  conditions.push("read = $read");
255
275
  params.$read = filters.unread ? 0 : 1;
256
276
  }
277
+ if (filters?.type !== undefined) {
278
+ conditions.push("type = $type");
279
+ params.$type = filters.type;
280
+ }
257
281
 
258
282
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
259
283
  const limitClause = filters?.limit !== undefined ? ` LIMIT $limit` : "";
@@ -315,6 +339,7 @@ export function createMailStore(dbPath: string): MailStore {
315
339
  from?: string;
316
340
  to?: string;
317
341
  unread?: boolean;
342
+ type?: MailMessageType;
318
343
  limit?: number;
319
344
  }): MailMessage[] {
320
345
  return buildFilterQuery(filters);
@@ -334,6 +359,18 @@ export function createMailStore(dbPath: string): MailStore {
334
359
  markReadStmt.run({ $id: id });
335
360
  },
336
361
 
362
+ deleteById(id: string): boolean {
363
+ try {
364
+ const result = deleteByIdStmt.run({ $id: id });
365
+ return result.changes > 0;
366
+ } catch (err) {
367
+ throw new MailError(`Failed to delete message: ${id}`, {
368
+ messageId: id,
369
+ cause: err instanceof Error ? err : undefined,
370
+ });
371
+ }
372
+ },
373
+
337
374
  purge(options: { all?: boolean; olderThanMs?: number; agent?: string }): number {
338
375
  // Count matching rows before deletion so we can report accurate numbers
339
376
  if (options.all) {
@@ -0,0 +1,149 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { MergeError } from "../errors.ts";
7
+ import { acquireMergeLock, mergeLockPath, sanitizeBranchForFilename } from "./lock.ts";
8
+
9
+ describe("sanitizeBranchForFilename", () => {
10
+ test("replaces forward slashes with dashes", () => {
11
+ expect(sanitizeBranchForFilename("feature/foo")).toBe("feature-foo");
12
+ expect(sanitizeBranchForFilename("a/b/c")).toBe("a-b-c");
13
+ });
14
+
15
+ test("replaces backslashes and colons", () => {
16
+ expect(sanitizeBranchForFilename("feature\\bar")).toBe("feature-bar");
17
+ expect(sanitizeBranchForFilename("ns:branch")).toBe("ns-branch");
18
+ });
19
+
20
+ test("leaves simple branch names alone", () => {
21
+ expect(sanitizeBranchForFilename("main")).toBe("main");
22
+ expect(sanitizeBranchForFilename("develop_2")).toBe("develop_2");
23
+ });
24
+ });
25
+
26
+ describe("mergeLockPath", () => {
27
+ test("composes path under .overstory/ with sanitized branch", () => {
28
+ expect(mergeLockPath("/tmp/.overstory", "feature/x")).toBe(
29
+ "/tmp/.overstory/merge-feature-x.lock",
30
+ );
31
+ });
32
+ });
33
+
34
+ describe("acquireMergeLock", () => {
35
+ let overstoryDir: string;
36
+
37
+ beforeEach(async () => {
38
+ overstoryDir = await mkdtemp(join(tmpdir(), "ov-merge-lock-"));
39
+ await mkdir(overstoryDir, { recursive: true });
40
+ });
41
+
42
+ afterEach(async () => {
43
+ await rm(overstoryDir, { recursive: true, force: true });
44
+ });
45
+
46
+ test("creates a lock file and returns a handle that removes it on release", () => {
47
+ const handle = acquireMergeLock(overstoryDir, "main");
48
+ expect(existsSync(handle.path)).toBe(true);
49
+
50
+ const payload = JSON.parse(readFileSync(handle.path, "utf8"));
51
+ expect(payload.pid).toBe(process.pid);
52
+ expect(payload.targetBranch).toBe("main");
53
+ expect(typeof payload.acquiredAt).toBe("string");
54
+
55
+ handle.release();
56
+ expect(existsSync(handle.path)).toBe(false);
57
+ });
58
+
59
+ test("release() is idempotent", () => {
60
+ const handle = acquireMergeLock(overstoryDir, "main");
61
+ handle.release();
62
+ handle.release(); // should not throw
63
+ expect(existsSync(handle.path)).toBe(false);
64
+ });
65
+
66
+ test("throws MergeError when lock is held by a live process", () => {
67
+ // Use this test process's own PID — it is guaranteed live.
68
+ const path = mergeLockPath(overstoryDir, "main");
69
+ writeFileSync(
70
+ path,
71
+ JSON.stringify({
72
+ pid: process.pid,
73
+ acquiredAt: new Date().toISOString(),
74
+ targetBranch: "main",
75
+ }),
76
+ );
77
+
78
+ try {
79
+ acquireMergeLock(overstoryDir, "main");
80
+ expect(true).toBe(false); // should not reach
81
+ } catch (err: unknown) {
82
+ expect(err).toBeInstanceOf(MergeError);
83
+ const msg = (err as MergeError).message;
84
+ expect(msg).toContain("Another ov merge is already running");
85
+ expect(msg).toContain(`pid ${process.pid}`);
86
+ expect(msg).toContain("main");
87
+ }
88
+
89
+ // Lock file is still on disk — we did not steal it.
90
+ expect(existsSync(path)).toBe(true);
91
+ });
92
+
93
+ test("steals a stale lock whose PID is not alive", () => {
94
+ const path = mergeLockPath(overstoryDir, "main");
95
+ // PID 2147483647 is INT_MAX — extremely unlikely to be in use.
96
+ writeFileSync(
97
+ path,
98
+ JSON.stringify({
99
+ pid: 2147483647,
100
+ acquiredAt: new Date(Date.now() - 60_000).toISOString(),
101
+ targetBranch: "main",
102
+ }),
103
+ );
104
+
105
+ const handle = acquireMergeLock(overstoryDir, "main");
106
+ const payload = JSON.parse(readFileSync(handle.path, "utf8"));
107
+ expect(payload.pid).toBe(process.pid);
108
+ handle.release();
109
+ });
110
+
111
+ test("steals an unparseable lock file", () => {
112
+ const path = mergeLockPath(overstoryDir, "main");
113
+ writeFileSync(path, "not json");
114
+
115
+ const handle = acquireMergeLock(overstoryDir, "main");
116
+ const payload = JSON.parse(readFileSync(handle.path, "utf8"));
117
+ expect(payload.pid).toBe(process.pid);
118
+ handle.release();
119
+ });
120
+
121
+ test("locks on different target branches are independent", () => {
122
+ const a = acquireMergeLock(overstoryDir, "main");
123
+ const b = acquireMergeLock(overstoryDir, "develop");
124
+ expect(existsSync(a.path)).toBe(true);
125
+ expect(existsSync(b.path)).toBe(true);
126
+ expect(a.path).not.toBe(b.path);
127
+ a.release();
128
+ b.release();
129
+ });
130
+
131
+ test("error message includes path so operator can manually clear", () => {
132
+ const path = mergeLockPath(overstoryDir, "main");
133
+ writeFileSync(
134
+ path,
135
+ JSON.stringify({
136
+ pid: process.pid,
137
+ acquiredAt: new Date().toISOString(),
138
+ targetBranch: "main",
139
+ }),
140
+ );
141
+
142
+ try {
143
+ acquireMergeLock(overstoryDir, "main");
144
+ expect(true).toBe(false);
145
+ } catch (err: unknown) {
146
+ expect((err as MergeError).message).toContain(path);
147
+ }
148
+ });
149
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Sentinel-file lock to prevent concurrent `ov merge` runs against the same
3
+ * canonical (target) branch.
4
+ *
5
+ * Two parallel merges into the same canonical branch can produce a misleading
6
+ * transient view: one merge runs the git operations while the second observes
7
+ * conflict markers mid-merge and reports a false failure. See seeds issue
8
+ * overstory-9610 for the original incident.
9
+ *
10
+ * The lock is a single JSON file at `.overstory/merge-{sanitized-target}.lock`
11
+ * created atomically with `writeFileSync(..., { flag: "wx" })`. If the file
12
+ * already exists, the holder PID is checked: live → fail fast, dead → take
13
+ * over. Released on exit via the returned handle.
14
+ */
15
+
16
+ import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { MergeError } from "../errors.ts";
19
+ import { isProcessAlive } from "../worktree/tmux.ts";
20
+
21
+ export interface MergeLockHandle {
22
+ /** Path to the lock file on disk (useful for diagnostics / tests). */
23
+ readonly path: string;
24
+ /** Release the lock. Idempotent — safe to call multiple times. */
25
+ release(): void;
26
+ }
27
+
28
+ interface LockPayload {
29
+ pid: number;
30
+ acquiredAt: string;
31
+ targetBranch: string;
32
+ }
33
+
34
+ /**
35
+ * Sanitize a branch name for use in a filename.
36
+ * Replaces "/", "\\", and ":" with "-" so `feature/foo` becomes `feature-foo`.
37
+ */
38
+ export function sanitizeBranchForFilename(branch: string): string {
39
+ return branch.replace(/[/\\:]/g, "-");
40
+ }
41
+
42
+ /** Compute the lock file path for a given target branch. */
43
+ export function mergeLockPath(overstoryDir: string, targetBranch: string): string {
44
+ return join(overstoryDir, `merge-${sanitizeBranchForFilename(targetBranch)}.lock`);
45
+ }
46
+
47
+ /**
48
+ * Acquire the merge lock for a given target branch. Throws `MergeError` if
49
+ * another live `ov merge` is already running against this target. Stale locks
50
+ * (PID no longer alive) are taken over automatically.
51
+ *
52
+ * The caller MUST call `release()` on the returned handle when done.
53
+ */
54
+ export function acquireMergeLock(overstoryDir: string, targetBranch: string): MergeLockHandle {
55
+ const path = mergeLockPath(overstoryDir, targetBranch);
56
+ const payload: LockPayload = {
57
+ pid: process.pid,
58
+ acquiredAt: new Date().toISOString(),
59
+ targetBranch,
60
+ };
61
+ const serialized = JSON.stringify(payload);
62
+
63
+ const tryCreate = (): boolean => {
64
+ try {
65
+ writeFileSync(path, serialized, { flag: "wx" });
66
+ return true;
67
+ } catch (err: unknown) {
68
+ const code = (err as NodeJS.ErrnoException).code;
69
+ if (code === "EEXIST") return false;
70
+ throw err;
71
+ }
72
+ };
73
+
74
+ if (tryCreate()) {
75
+ return makeHandle(path);
76
+ }
77
+
78
+ // Lock file exists. Inspect the holder before failing.
79
+ const existing = readLockPayload(path);
80
+ const holderPid = existing?.pid;
81
+ const holderAlive = typeof holderPid === "number" && isProcessAlive(holderPid);
82
+
83
+ if (holderAlive) {
84
+ const since = existing?.acquiredAt ?? "unknown time";
85
+ throw new MergeError(
86
+ `Another ov merge is already running for "${targetBranch}" (pid ${holderPid}, acquired ${since}). Wait for it to finish, or remove ${path} if you are sure it is stale.`,
87
+ { branchName: targetBranch },
88
+ );
89
+ }
90
+
91
+ // Stale or unparseable lock — remove and retry once. If a third process
92
+ // won the race in between, surface that as a clear retry-soon error.
93
+ try {
94
+ unlinkSync(path);
95
+ } catch {
96
+ // File may have just been removed by another cleanup — fine.
97
+ }
98
+ if (tryCreate()) {
99
+ return makeHandle(path);
100
+ }
101
+
102
+ throw new MergeError(
103
+ `Another ov merge raced to acquire the lock for "${targetBranch}". Retry shortly.`,
104
+ { branchName: targetBranch },
105
+ );
106
+ }
107
+
108
+ function readLockPayload(path: string): LockPayload | null {
109
+ try {
110
+ const content = readFileSync(path, "utf8");
111
+ const parsed = JSON.parse(content) as unknown;
112
+ if (
113
+ parsed !== null &&
114
+ typeof parsed === "object" &&
115
+ "pid" in parsed &&
116
+ typeof (parsed as { pid: unknown }).pid === "number"
117
+ ) {
118
+ return parsed as LockPayload;
119
+ }
120
+ return null;
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ function makeHandle(path: string): MergeLockHandle {
127
+ let released = false;
128
+ return {
129
+ path,
130
+ release(): void {
131
+ if (released) return;
132
+ released = true;
133
+ try {
134
+ unlinkSync(path);
135
+ } catch {
136
+ // File may already be gone — not an error.
137
+ }
138
+ },
139
+ };
140
+ }
@@ -0,0 +1,22 @@
1
+ // Fixture: emits a known sequence of Claude stream-json lines to stdout then exits.
2
+ // Used by the ClaudeRuntime.parseEvents() integration test.
3
+ const lines = [
4
+ JSON.stringify({ type: "system", subtype: "init", session_id: "sess-123" }),
5
+ JSON.stringify({
6
+ type: "assistant",
7
+ message: {
8
+ model: "claude-sonnet-4-6",
9
+ content: [{ type: "text", text: "hello" }],
10
+ usage: { input_tokens: 10, output_tokens: 5 },
11
+ },
12
+ }),
13
+ JSON.stringify({
14
+ type: "result",
15
+ session_id: "sess-123",
16
+ result: "done",
17
+ is_error: false,
18
+ duration_ms: 1234,
19
+ num_turns: 1,
20
+ }),
21
+ ];
22
+ for (const l of lines) process.stdout.write(`${l}\n`);