@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.
- package/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- 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
|
+
}
|