@os-eco/overstory-cli 0.6.1

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. package/templates/overlay.md.tmpl +81 -0
@@ -0,0 +1,325 @@
1
+ /**
2
+ * CLI command: overstory trace <target> [--json] [--since <ts>] [--until <ts>] [--limit <n>]
3
+ *
4
+ * Shows a chronological timeline of events for an agent or bead task.
5
+ * Target can be an agent name or a bead ID (resolved to agent name via SessionStore).
6
+ */
7
+
8
+ import { join } from "node:path";
9
+ import { loadConfig } from "../config.ts";
10
+ import { ValidationError } from "../errors.ts";
11
+ import { createEventStore } from "../events/store.ts";
12
+ import { color } from "../logging/color.ts";
13
+ import { openSessionStore } from "../sessions/compat.ts";
14
+ import type { EventType, StoredEvent } from "../types.ts";
15
+
16
+ /** Labels and colors for each event type. */
17
+ const EVENT_LABELS: Record<EventType, { label: string; color: string }> = {
18
+ tool_start: { label: "TOOL START", color: color.blue },
19
+ tool_end: { label: "TOOL END ", color: color.blue },
20
+ session_start: { label: "SESSION +", color: color.green },
21
+ session_end: { label: "SESSION -", color: color.yellow },
22
+ mail_sent: { label: "MAIL SENT ", color: color.cyan },
23
+ mail_received: { label: "MAIL RECV ", color: color.cyan },
24
+ spawn: { label: "SPAWN ", color: color.magenta },
25
+ error: { label: "ERROR ", color: color.red },
26
+ custom: { label: "CUSTOM ", color: color.gray },
27
+ };
28
+
29
+ /**
30
+ * Parse a named flag value from args.
31
+ */
32
+ function getFlag(args: string[], flag: string): string | undefined {
33
+ const idx = args.indexOf(flag);
34
+ if (idx === -1 || idx + 1 >= args.length) {
35
+ return undefined;
36
+ }
37
+ return args[idx + 1];
38
+ }
39
+
40
+ function hasFlag(args: string[], flag: string): boolean {
41
+ return args.includes(flag);
42
+ }
43
+
44
+ /**
45
+ * Detect whether a target string looks like a bead ID.
46
+ * Bead IDs follow the pattern: word-alphanumeric (e.g., "overstory-rj1k", "myproject-abc1").
47
+ */
48
+ function looksLikeBeadId(target: string): boolean {
49
+ return /^[a-z][a-z0-9]*-[a-z0-9]{3,}$/i.test(target);
50
+ }
51
+
52
+ /**
53
+ * Format a relative time string from a timestamp.
54
+ * Returns strings like "2m ago", "1h ago", "3d ago".
55
+ */
56
+ function formatRelativeTime(timestamp: string): string {
57
+ const eventTime = new Date(timestamp).getTime();
58
+ const now = Date.now();
59
+ const diffMs = now - eventTime;
60
+
61
+ if (diffMs < 0) return "just now";
62
+
63
+ const seconds = Math.floor(diffMs / 1000);
64
+ if (seconds < 60) return `${seconds}s ago`;
65
+
66
+ const minutes = Math.floor(seconds / 60);
67
+ if (minutes < 60) return `${minutes}m ago`;
68
+
69
+ const hours = Math.floor(minutes / 60);
70
+ if (hours < 24) return `${hours}h ago`;
71
+
72
+ const days = Math.floor(hours / 24);
73
+ return `${days}d ago`;
74
+ }
75
+
76
+ /**
77
+ * Format an absolute time from an ISO timestamp.
78
+ * Returns "HH:MM:SS" portion.
79
+ */
80
+ function formatAbsoluteTime(timestamp: string): string {
81
+ const match = /T(\d{2}:\d{2}:\d{2})/.exec(timestamp);
82
+ if (match?.[1]) {
83
+ return match[1];
84
+ }
85
+ return timestamp;
86
+ }
87
+
88
+ /**
89
+ * Format the date portion of an ISO timestamp.
90
+ * Returns "YYYY-MM-DD".
91
+ */
92
+ function formatDate(timestamp: string): string {
93
+ const match = /^(\d{4}-\d{2}-\d{2})/.exec(timestamp);
94
+ if (match?.[1]) {
95
+ return match[1];
96
+ }
97
+ return "";
98
+ }
99
+
100
+ /**
101
+ * Build a detail string for a timeline event based on its type and fields.
102
+ */
103
+ function buildEventDetail(event: StoredEvent): string {
104
+ const parts: string[] = [];
105
+
106
+ if (event.toolName) {
107
+ parts.push(`tool=${event.toolName}`);
108
+ }
109
+
110
+ if (event.toolDurationMs !== null) {
111
+ parts.push(`duration=${event.toolDurationMs}ms`);
112
+ }
113
+
114
+ if (event.data) {
115
+ try {
116
+ const parsed: unknown = JSON.parse(event.data);
117
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
118
+ const data = parsed as Record<string, unknown>;
119
+ for (const [key, value] of Object.entries(data)) {
120
+ if (value !== null && value !== undefined) {
121
+ const strValue = typeof value === "string" ? value : JSON.stringify(value);
122
+ // Truncate long values
123
+ const truncated = strValue.length > 80 ? `${strValue.slice(0, 77)}...` : strValue;
124
+ parts.push(`${key}=${truncated}`);
125
+ }
126
+ }
127
+ }
128
+ } catch {
129
+ // data is not valid JSON; show it raw if short enough
130
+ if (event.data.length <= 80) {
131
+ parts.push(event.data);
132
+ }
133
+ }
134
+ }
135
+
136
+ return parts.join(" ");
137
+ }
138
+
139
+ /**
140
+ * Print events as a formatted timeline with ANSI colors.
141
+ */
142
+ function printTimeline(events: StoredEvent[], agentName: string, useAbsoluteTime: boolean): void {
143
+ const w = process.stdout.write.bind(process.stdout);
144
+
145
+ w(`${color.bold}Timeline for ${agentName}${color.reset}\n`);
146
+ w(`${"=".repeat(70)}\n`);
147
+
148
+ if (events.length === 0) {
149
+ w(`${color.dim}No events found.${color.reset}\n`);
150
+ return;
151
+ }
152
+
153
+ w(`${color.dim}${events.length} event${events.length === 1 ? "" : "s"}${color.reset}\n\n`);
154
+
155
+ let lastDate = "";
156
+
157
+ for (const event of events) {
158
+ // Print date separator when the date changes
159
+ const date = formatDate(event.createdAt);
160
+ if (date && date !== lastDate) {
161
+ if (lastDate !== "") {
162
+ w("\n");
163
+ }
164
+ w(`${color.dim}--- ${date} ---${color.reset}\n`);
165
+ lastDate = date;
166
+ }
167
+
168
+ const timeStr = useAbsoluteTime
169
+ ? formatAbsoluteTime(event.createdAt)
170
+ : formatRelativeTime(event.createdAt);
171
+
172
+ const eventInfo = EVENT_LABELS[event.eventType] ?? {
173
+ label: event.eventType.padEnd(10),
174
+ color: color.gray,
175
+ };
176
+
177
+ const levelColor =
178
+ event.level === "error" ? color.red : event.level === "warn" ? color.yellow : "";
179
+ const levelReset = levelColor ? color.reset : "";
180
+
181
+ const detail = buildEventDetail(event);
182
+ const detailSuffix = detail ? ` ${color.dim}${detail}${color.reset}` : "";
183
+
184
+ const agentLabel =
185
+ event.agentName !== agentName ? ` ${color.dim}[${event.agentName}]${color.reset}` : "";
186
+
187
+ w(
188
+ `${color.dim}${timeStr.padStart(10)}${color.reset} ` +
189
+ `${levelColor}${eventInfo.color}${color.bold}${eventInfo.label}${color.reset}${levelReset}` +
190
+ `${agentLabel}${detailSuffix}\n`,
191
+ );
192
+ }
193
+ }
194
+
195
+ const TRACE_HELP = `overstory trace -- Show chronological timeline for an agent or bead
196
+
197
+ Usage: overstory trace <target> [options]
198
+
199
+ Arguments:
200
+ <target> Agent name or bead ID
201
+
202
+ Options:
203
+ --json Output as JSON array of StoredEvent objects
204
+ --since <timestamp> Start time filter (ISO 8601)
205
+ --until <timestamp> End time filter (ISO 8601)
206
+ --limit <n> Max events to show (default: 100)
207
+ --help, -h Show this help`;
208
+
209
+ /**
210
+ * Entry point for `overstory trace <target> [--json] [--since] [--until] [--limit]`.
211
+ */
212
+ export async function traceCommand(args: string[]): Promise<void> {
213
+ if (args.includes("--help") || args.includes("-h")) {
214
+ process.stdout.write(`${TRACE_HELP}\n`);
215
+ return;
216
+ }
217
+
218
+ // Extract positional target: first arg that is not a flag or flag value
219
+ const flagsWithValues = new Set(["--since", "--until", "--limit"]);
220
+ const booleanFlags = new Set(["--json", "--help", "-h"]);
221
+ let target: string | undefined;
222
+ for (let i = 0; i < args.length; i++) {
223
+ const arg = args[i];
224
+ if (arg === undefined) continue;
225
+ if (booleanFlags.has(arg)) continue;
226
+ if (flagsWithValues.has(arg)) {
227
+ i++; // skip the value
228
+ continue;
229
+ }
230
+ if (arg.startsWith("-")) continue;
231
+ target = arg;
232
+ break;
233
+ }
234
+
235
+ if (!target) {
236
+ throw new ValidationError("Missing target. Usage: overstory trace <agent-name|bead-id>", {
237
+ field: "target",
238
+ });
239
+ }
240
+
241
+ const json = hasFlag(args, "--json");
242
+ const sinceStr = getFlag(args, "--since");
243
+ const untilStr = getFlag(args, "--until");
244
+ const limitStr = getFlag(args, "--limit");
245
+ const limit = limitStr ? Number.parseInt(limitStr, 10) : 100;
246
+
247
+ if (Number.isNaN(limit) || limit < 1) {
248
+ throw new ValidationError("--limit must be a positive integer", {
249
+ field: "limit",
250
+ value: limitStr,
251
+ });
252
+ }
253
+
254
+ // Validate timestamps if provided
255
+ if (sinceStr !== undefined && Number.isNaN(new Date(sinceStr).getTime())) {
256
+ throw new ValidationError("--since must be a valid ISO 8601 timestamp", {
257
+ field: "since",
258
+ value: sinceStr,
259
+ });
260
+ }
261
+ if (untilStr !== undefined && Number.isNaN(new Date(untilStr).getTime())) {
262
+ throw new ValidationError("--until must be a valid ISO 8601 timestamp", {
263
+ field: "until",
264
+ value: untilStr,
265
+ });
266
+ }
267
+
268
+ const cwd = process.cwd();
269
+ const config = await loadConfig(cwd);
270
+ const overstoryDir = join(config.project.root, ".overstory");
271
+
272
+ // Resolve target to agent name
273
+ let agentName = target;
274
+
275
+ if (looksLikeBeadId(target)) {
276
+ // Try to resolve bead ID to agent name via SessionStore
277
+ const { store: sessionStore } = openSessionStore(overstoryDir);
278
+ try {
279
+ const allSessions = sessionStore.getAll();
280
+ const matchingSession = allSessions.find((s) => s.beadId === target);
281
+ if (matchingSession) {
282
+ agentName = matchingSession.agentName;
283
+ } else {
284
+ // No session found for this bead ID; treat it as an agent name anyway
285
+ // (the event query will return empty results if no events match)
286
+ agentName = target;
287
+ }
288
+ } finally {
289
+ sessionStore.close();
290
+ }
291
+ }
292
+
293
+ // Open event store and query events
294
+ const eventsDbPath = join(overstoryDir, "events.db");
295
+ const eventsFile = Bun.file(eventsDbPath);
296
+ if (!(await eventsFile.exists())) {
297
+ if (json) {
298
+ process.stdout.write("[]\n");
299
+ } else {
300
+ process.stdout.write("No events data yet.\n");
301
+ }
302
+ return;
303
+ }
304
+
305
+ const eventStore = createEventStore(eventsDbPath);
306
+
307
+ try {
308
+ const events = eventStore.getByAgent(agentName, {
309
+ since: sinceStr,
310
+ until: untilStr,
311
+ limit,
312
+ });
313
+
314
+ if (json) {
315
+ process.stdout.write(`${JSON.stringify(events)}\n`);
316
+ return;
317
+ }
318
+
319
+ // Use absolute time if --since is specified, relative otherwise
320
+ const useAbsoluteTime = sinceStr !== undefined;
321
+ printTimeline(events, agentName, useAbsoluteTime);
322
+ } finally {
323
+ eventStore.close();
324
+ }
325
+ }
@@ -0,0 +1,145 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { watchCommand } from "./watch.ts";
6
+
7
+ /**
8
+ * Tests for `overstory watch` command.
9
+ *
10
+ * IMPORTANT: We CANNOT test the actual daemon loop (it would hang the test).
11
+ * Focus on:
12
+ * - Help output (safe, returns immediately)
13
+ * - Background mode: already-running detection
14
+ * - Background mode: stale PID cleanup
15
+ *
16
+ * We do NOT test:
17
+ * - Foreground mode (blocks forever with await new Promise(() => {}))
18
+ * - Actual health check loop behavior
19
+ */
20
+
21
+ describe("watchCommand", () => {
22
+ let chunks: string[];
23
+ let stderrChunks: string[];
24
+ let originalWrite: typeof process.stdout.write;
25
+ let originalStderrWrite: typeof process.stderr.write;
26
+ let tempDir: string;
27
+ let originalCwd: string;
28
+ let originalExitCode: string | number | null | undefined;
29
+
30
+ beforeEach(async () => {
31
+ // Spy on stdout
32
+ chunks = [];
33
+ originalWrite = process.stdout.write;
34
+ process.stdout.write = ((chunk: string) => {
35
+ chunks.push(chunk);
36
+ return true;
37
+ }) as typeof process.stdout.write;
38
+
39
+ // Spy on stderr
40
+ stderrChunks = [];
41
+ originalStderrWrite = process.stderr.write;
42
+ process.stderr.write = ((chunk: string) => {
43
+ stderrChunks.push(chunk);
44
+ return true;
45
+ }) as typeof process.stderr.write;
46
+
47
+ // Save original exitCode
48
+ originalExitCode = process.exitCode;
49
+ process.exitCode = 0;
50
+
51
+ // Create temp dir with .overstory/config.yaml structure
52
+ tempDir = await mkdtemp(join(tmpdir(), "watch-test-"));
53
+ const overstoryDir = join(tempDir, ".overstory");
54
+ await Bun.write(
55
+ join(overstoryDir, "config.yaml"),
56
+ `project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
57
+ );
58
+
59
+ // Change to temp dir so loadConfig() works
60
+ originalCwd = process.cwd();
61
+ process.chdir(tempDir);
62
+ });
63
+
64
+ afterEach(async () => {
65
+ process.stdout.write = originalWrite;
66
+ process.stderr.write = originalStderrWrite;
67
+ process.exitCode = originalExitCode;
68
+ process.chdir(originalCwd);
69
+ await rm(tempDir, { recursive: true, force: true });
70
+ });
71
+
72
+ function output(): string {
73
+ return chunks.join("");
74
+ }
75
+
76
+ function stderr(): string {
77
+ return stderrChunks.join("");
78
+ }
79
+
80
+ test("--help flag shows help text with key info", async () => {
81
+ await watchCommand(["--help"]);
82
+ const out = output();
83
+
84
+ expect(out).toContain("overstory watch");
85
+ expect(out).toContain("--interval");
86
+ expect(out).toContain("--background");
87
+ expect(out).toContain("Tier 0");
88
+ });
89
+
90
+ test("-h flag shows help text", async () => {
91
+ await watchCommand(["-h"]);
92
+ const out = output();
93
+
94
+ expect(out).toContain("overstory watch");
95
+ expect(out).toContain("Tier 0");
96
+ });
97
+
98
+ test("background mode: already running detection", async () => {
99
+ // Write a PID file with a running process (use our own PID)
100
+ const pidFilePath = join(tempDir, ".overstory", "watchdog.pid");
101
+ await Bun.write(pidFilePath, `${process.pid}\n`);
102
+
103
+ // Try to start in background mode — should fail with "already running"
104
+ await watchCommand(["--background"]);
105
+
106
+ const err = stderr();
107
+ expect(err).toContain("already running");
108
+ expect(err).toContain(`${process.pid}`);
109
+ expect(process.exitCode).toBe(1);
110
+ });
111
+
112
+ test("background mode: stale PID cleanup", async () => {
113
+ // Write a PID file with a non-running process (999999 is very unlikely to exist)
114
+ const pidFilePath = join(tempDir, ".overstory", "watchdog.pid");
115
+ await Bun.write(pidFilePath, "999999\n");
116
+
117
+ // Verify the stale PID file exists before the test
118
+ const fileBeforeExists = await Bun.file(pidFilePath).exists();
119
+ expect(fileBeforeExists).toBe(true);
120
+
121
+ // Try to start in background mode
122
+ // This will clean up the stale PID file, then attempt to spawn.
123
+ // The spawn will fail because there's no real overstory binary in test env,
124
+ // but the important part is that the stale PID file gets removed.
125
+ try {
126
+ await watchCommand(["--background"]);
127
+ } catch {
128
+ // Expected to fail when trying to spawn — that's OK
129
+ }
130
+
131
+ // The stale PID file should have been removed during the check
132
+ // (Even if the spawn itself failed, the cleanup happens before spawn)
133
+ // Actually, looking at the code: if existingPid is not null but not running,
134
+ // it removes the PID file. Then it tries to spawn. So the file should be gone
135
+ // OR replaced with a new PID.
136
+
137
+ // Let's check: the file should either not exist, OR contain a different PID
138
+ const fileAfterExists = await Bun.file(pidFilePath).exists();
139
+ if (fileAfterExists) {
140
+ const content = await Bun.file(pidFilePath).text();
141
+ expect(content.trim()).not.toBe("999999");
142
+ }
143
+ // If it doesn't exist, that's also valid (spawn failed before writing new PID)
144
+ });
145
+ });
@@ -0,0 +1,247 @@
1
+ /**
2
+ * CLI command: overstory watch [--interval <ms>] [--background]
3
+ *
4
+ * Starts the Tier 0 mechanical watchdog daemon. Foreground mode shows real-time status.
5
+ * Background mode spawns a detached process via Bun.spawn and writes a PID file.
6
+ * Interval configurable, default 30000ms.
7
+ */
8
+
9
+ import { join } from "node:path";
10
+ import { loadConfig } from "../config.ts";
11
+ import { OverstoryError } from "../errors.ts";
12
+ import type { HealthCheck } from "../types.ts";
13
+ import { startDaemon } from "../watchdog/daemon.ts";
14
+ import { isProcessRunning } from "../watchdog/health.ts";
15
+
16
+ /**
17
+ * Parse a named flag value from args.
18
+ */
19
+ function getFlag(args: string[], flag: string): string | undefined {
20
+ const idx = args.indexOf(flag);
21
+ if (idx === -1 || idx + 1 >= args.length) {
22
+ return undefined;
23
+ }
24
+ return args[idx + 1];
25
+ }
26
+
27
+ function hasFlag(args: string[], flag: string): boolean {
28
+ return args.includes(flag);
29
+ }
30
+
31
+ /**
32
+ * Format a health check for display.
33
+ */
34
+ function formatCheck(check: HealthCheck): string {
35
+ const actionIcon =
36
+ check.action === "terminate"
37
+ ? "💀"
38
+ : check.action === "escalate"
39
+ ? "⚠️"
40
+ : check.action === "investigate"
41
+ ? "🔍"
42
+ : "✅";
43
+ const pidLabel = check.pidAlive === null ? "n/a" : check.pidAlive ? "up" : "down";
44
+ let line = `${actionIcon} ${check.agentName}: ${check.state} (tmux=${check.tmuxAlive ? "up" : "down"}, pid=${pidLabel})`;
45
+ if (check.reconciliationNote) {
46
+ line += ` [${check.reconciliationNote}]`;
47
+ }
48
+ return line;
49
+ }
50
+
51
+ // isProcessRunning is imported from ../watchdog/health.ts (ZFC shared utility)
52
+
53
+ /**
54
+ * Read the PID from the watchdog PID file.
55
+ * Returns null if the file doesn't exist or can't be parsed.
56
+ */
57
+ async function readPidFile(pidFilePath: string): Promise<number | null> {
58
+ const file = Bun.file(pidFilePath);
59
+ const exists = await file.exists();
60
+ if (!exists) {
61
+ return null;
62
+ }
63
+
64
+ try {
65
+ const text = await file.text();
66
+ const pid = Number.parseInt(text.trim(), 10);
67
+ if (Number.isNaN(pid) || pid <= 0) {
68
+ return null;
69
+ }
70
+ return pid;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Write a PID to the watchdog PID file.
78
+ */
79
+ async function writePidFile(pidFilePath: string, pid: number): Promise<void> {
80
+ await Bun.write(pidFilePath, `${pid}\n`);
81
+ }
82
+
83
+ /**
84
+ * Remove the watchdog PID file.
85
+ */
86
+ async function removePidFile(pidFilePath: string): Promise<void> {
87
+ const { unlink } = await import("node:fs/promises");
88
+ try {
89
+ await unlink(pidFilePath);
90
+ } catch {
91
+ // File may already be gone — not an error
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Resolve the path to the overstory binary for re-launching.
97
+ * Uses `which overstory` first, then falls back to process.argv.
98
+ */
99
+ async function resolveOverstoryBin(): Promise<string> {
100
+ try {
101
+ const proc = Bun.spawn(["which", "overstory"], {
102
+ stdout: "pipe",
103
+ stderr: "pipe",
104
+ });
105
+ const exitCode = await proc.exited;
106
+ if (exitCode === 0) {
107
+ const binPath = (await new Response(proc.stdout).text()).trim();
108
+ if (binPath.length > 0) {
109
+ return binPath;
110
+ }
111
+ }
112
+ } catch {
113
+ // which not available or overstory not on PATH
114
+ }
115
+
116
+ // Fallback: use the script that's currently running (process.argv[1])
117
+ const scriptPath = process.argv[1];
118
+ if (scriptPath) {
119
+ return scriptPath;
120
+ }
121
+
122
+ throw new OverstoryError(
123
+ "Cannot resolve overstory binary path for background launch",
124
+ "WATCH_ERROR",
125
+ );
126
+ }
127
+
128
+ /**
129
+ * Entry point for `overstory watch [--interval <ms>] [--background]`.
130
+ */
131
+ const WATCH_HELP = `overstory watch — Start Tier 0 mechanical watchdog daemon
132
+
133
+ Usage: overstory watch [--interval <ms>] [--background]
134
+
135
+ Tier numbering:
136
+ Tier 0 Mechanical daemon (heartbeat, tmux/pid liveness) — this command
137
+ Tier 1 Triage agent (ephemeral AI analysis of stalled agents)
138
+ Tier 2 Monitor agent (continuous patrol — not yet implemented)
139
+ Tier 3 Supervisor monitors (per-project)
140
+
141
+ Options:
142
+ --interval <ms> Health check interval in milliseconds (default: from config)
143
+ --background Daemonize (run in background)
144
+ --help, -h Show this help`;
145
+
146
+ export async function watchCommand(args: string[]): Promise<void> {
147
+ if (args.includes("--help") || args.includes("-h")) {
148
+ process.stdout.write(`${WATCH_HELP}\n`);
149
+ return;
150
+ }
151
+
152
+ const intervalStr = getFlag(args, "--interval");
153
+ const background = hasFlag(args, "--background");
154
+
155
+ const cwd = process.cwd();
156
+ const config = await loadConfig(cwd);
157
+
158
+ const intervalMs = intervalStr
159
+ ? Number.parseInt(intervalStr, 10)
160
+ : config.watchdog.tier0IntervalMs;
161
+
162
+ const staleThresholdMs = config.watchdog.staleThresholdMs;
163
+ const zombieThresholdMs = config.watchdog.zombieThresholdMs;
164
+ const pidFilePath = join(config.project.root, ".overstory", "watchdog.pid");
165
+
166
+ if (background) {
167
+ // Check if a watchdog is already running
168
+ const existingPid = await readPidFile(pidFilePath);
169
+ if (existingPid !== null && isProcessRunning(existingPid)) {
170
+ process.stderr.write(
171
+ `Error: Watchdog already running (PID: ${existingPid}). ` +
172
+ `Kill it first or remove ${pidFilePath}\n`,
173
+ );
174
+ process.exitCode = 1;
175
+ return;
176
+ }
177
+
178
+ // Clean up stale PID file if process is no longer running
179
+ if (existingPid !== null) {
180
+ await removePidFile(pidFilePath);
181
+ }
182
+
183
+ // Build the args for the child process, forwarding --interval but not --background
184
+ const childArgs: string[] = ["watch"];
185
+ if (intervalStr) {
186
+ childArgs.push("--interval", intervalStr);
187
+ }
188
+
189
+ // Resolve the overstory binary path
190
+ const overstoryBin = await resolveOverstoryBin();
191
+
192
+ // Spawn a detached background process running `overstory watch` (without --background)
193
+ const child = Bun.spawn(["bun", "run", overstoryBin, ...childArgs], {
194
+ cwd,
195
+ stdout: "ignore",
196
+ stderr: "ignore",
197
+ stdin: "ignore",
198
+ });
199
+
200
+ // Unref the child so the parent can exit without waiting for it
201
+ child.unref();
202
+
203
+ const childPid = child.pid;
204
+
205
+ // Write PID file for later cleanup
206
+ await writePidFile(pidFilePath, childPid);
207
+
208
+ process.stdout.write(
209
+ `Watchdog started in background (PID: ${childPid}, interval: ${intervalMs}ms)\n`,
210
+ );
211
+ process.stdout.write(`PID file: ${pidFilePath}\n`);
212
+ return;
213
+ }
214
+
215
+ // Foreground mode: show real-time health checks
216
+ process.stdout.write(`Watchdog running (interval: ${intervalMs}ms)\n`);
217
+ process.stdout.write("Press Ctrl+C to stop.\n\n");
218
+
219
+ // Write PID file so `--background` check and external tools can find us
220
+ await writePidFile(pidFilePath, process.pid);
221
+
222
+ const { stop } = startDaemon({
223
+ root: config.project.root,
224
+ intervalMs,
225
+ staleThresholdMs,
226
+ zombieThresholdMs,
227
+ nudgeIntervalMs: config.watchdog.nudgeIntervalMs,
228
+ tier1Enabled: config.watchdog.tier1Enabled,
229
+ onHealthCheck(check) {
230
+ const timestamp = new Date().toISOString().slice(11, 19);
231
+ process.stdout.write(`[${timestamp}] ${formatCheck(check)}\n`);
232
+ },
233
+ });
234
+
235
+ // Keep running until interrupted
236
+ process.on("SIGINT", () => {
237
+ stop();
238
+ // Clean up PID file on graceful shutdown
239
+ removePidFile(pidFilePath).finally(() => {
240
+ process.stdout.write("\nWatchdog stopped.\n");
241
+ process.exit(0);
242
+ });
243
+ });
244
+
245
+ // Block forever
246
+ await new Promise(() => {});
247
+ }