@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,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: overstory feed [--follow] [--agent <name>...] [--run <id>]
|
|
3
|
+
* [--since <ts>] [--limit <n>] [--interval <ms>] [--json]
|
|
4
|
+
*
|
|
5
|
+
* Unified real-time event stream across all agents — like `tail -f` for the fleet.
|
|
6
|
+
* Shows chronological events from all agents merged into a single feed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { loadConfig } from "../config.ts";
|
|
11
|
+
import { ValidationError } from "../errors.ts";
|
|
12
|
+
import { createEventStore } from "../events/store.ts";
|
|
13
|
+
import { color } from "../logging/color.ts";
|
|
14
|
+
import type { EventType, StoredEvent } from "../types.ts";
|
|
15
|
+
|
|
16
|
+
/** Compact 5-char labels for feed output. */
|
|
17
|
+
const EVENT_LABELS: Record<EventType, { label: string; color: string }> = {
|
|
18
|
+
tool_start: { label: "TOOL+", color: color.blue },
|
|
19
|
+
tool_end: { label: "TOOL-", color: color.blue },
|
|
20
|
+
session_start: { label: "SESS+", color: color.green },
|
|
21
|
+
session_end: { label: "SESS-", color: color.yellow },
|
|
22
|
+
mail_sent: { label: "MAIL>", color: color.cyan },
|
|
23
|
+
mail_received: { label: "MAIL<", color: color.cyan },
|
|
24
|
+
spawn: { label: "SPAWN", color: color.magenta },
|
|
25
|
+
error: { label: "ERROR", color: color.red },
|
|
26
|
+
custom: { label: "CUSTM", color: color.gray },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Colors assigned to agents in order of first appearance. */
|
|
30
|
+
const AGENT_COLORS = [color.blue, color.green, color.yellow, color.cyan, color.magenta] as const;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse a named flag value from args.
|
|
34
|
+
*/
|
|
35
|
+
function getFlag(args: string[], flag: string): string | undefined {
|
|
36
|
+
const idx = args.indexOf(flag);
|
|
37
|
+
if (idx === -1 || idx + 1 >= args.length) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
return args[idx + 1];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse all occurrences of a named flag from args.
|
|
45
|
+
* Returns an array of values (e.g., --agent a --agent b => ["a", "b"]).
|
|
46
|
+
*/
|
|
47
|
+
function getAllFlags(args: string[], flag: string): string[] {
|
|
48
|
+
const values: string[] = [];
|
|
49
|
+
for (let i = 0; i < args.length; i++) {
|
|
50
|
+
if (args[i] === flag && i + 1 < args.length) {
|
|
51
|
+
const value = args[i + 1];
|
|
52
|
+
if (value !== undefined) {
|
|
53
|
+
values.push(value);
|
|
54
|
+
}
|
|
55
|
+
i++; // skip the value
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return values;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function hasFlag(args: string[], flag: string): boolean {
|
|
62
|
+
return args.includes(flag);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Format an absolute time from an ISO timestamp.
|
|
67
|
+
* Returns "HH:MM:SS" portion.
|
|
68
|
+
*/
|
|
69
|
+
function formatAbsoluteTime(timestamp: string): string {
|
|
70
|
+
const match = /T(\d{2}:\d{2}:\d{2})/.exec(timestamp);
|
|
71
|
+
if (match?.[1]) {
|
|
72
|
+
return match[1];
|
|
73
|
+
}
|
|
74
|
+
return timestamp;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build a detail string for a feed event based on its type and fields.
|
|
79
|
+
*/
|
|
80
|
+
function buildEventDetail(event: StoredEvent): string {
|
|
81
|
+
const parts: string[] = [];
|
|
82
|
+
|
|
83
|
+
if (event.toolName) {
|
|
84
|
+
parts.push(`tool=${event.toolName}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (event.toolDurationMs !== null) {
|
|
88
|
+
parts.push(`${event.toolDurationMs}ms`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (event.data) {
|
|
92
|
+
try {
|
|
93
|
+
const parsed: unknown = JSON.parse(event.data);
|
|
94
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
95
|
+
const data = parsed as Record<string, unknown>;
|
|
96
|
+
for (const [key, value] of Object.entries(data)) {
|
|
97
|
+
if (value !== null && value !== undefined) {
|
|
98
|
+
const strValue = typeof value === "string" ? value : JSON.stringify(value);
|
|
99
|
+
// Truncate long values
|
|
100
|
+
const truncated = strValue.length > 60 ? `${strValue.slice(0, 57)}...` : strValue;
|
|
101
|
+
parts.push(`${key}=${truncated}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// data is not valid JSON; show it raw if short enough
|
|
107
|
+
if (event.data.length <= 60) {
|
|
108
|
+
parts.push(event.data);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return parts.join(" ");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Assign a stable color to each agent based on order of first appearance.
|
|
118
|
+
*/
|
|
119
|
+
function buildAgentColorMap(events: StoredEvent[]): Map<string, string> {
|
|
120
|
+
const colorMap = new Map<string, string>();
|
|
121
|
+
for (const event of events) {
|
|
122
|
+
if (!colorMap.has(event.agentName)) {
|
|
123
|
+
const colorIndex = colorMap.size % AGENT_COLORS.length;
|
|
124
|
+
const agentColor = AGENT_COLORS[colorIndex];
|
|
125
|
+
if (agentColor !== undefined) {
|
|
126
|
+
colorMap.set(event.agentName, agentColor);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return colorMap;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Print a single event in compact feed format:
|
|
135
|
+
* HH:MM:SS LABEL agentname detail
|
|
136
|
+
*/
|
|
137
|
+
function printEvent(event: StoredEvent, colorMap: Map<string, string>): void {
|
|
138
|
+
const w = process.stdout.write.bind(process.stdout);
|
|
139
|
+
|
|
140
|
+
const timeStr = formatAbsoluteTime(event.createdAt);
|
|
141
|
+
|
|
142
|
+
const eventInfo = EVENT_LABELS[event.eventType] ?? {
|
|
143
|
+
label: event.eventType.padEnd(5),
|
|
144
|
+
color: color.gray,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const levelColor =
|
|
148
|
+
event.level === "error" ? color.red : event.level === "warn" ? color.yellow : "";
|
|
149
|
+
const levelReset = levelColor ? color.reset : "";
|
|
150
|
+
|
|
151
|
+
const detail = buildEventDetail(event);
|
|
152
|
+
const detailSuffix = detail ? ` ${color.dim}${detail}${color.reset}` : "";
|
|
153
|
+
|
|
154
|
+
const agentColor = colorMap.get(event.agentName) ?? color.gray;
|
|
155
|
+
const agentLabel = ` ${agentColor}${event.agentName.padEnd(15)}${color.reset}`;
|
|
156
|
+
|
|
157
|
+
w(
|
|
158
|
+
`${color.dim}${timeStr}${color.reset} ` +
|
|
159
|
+
`${levelColor}${eventInfo.color}${color.bold}${eventInfo.label}${color.reset}${levelReset}` +
|
|
160
|
+
`${agentLabel}${detailSuffix}\n`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const FEED_HELP = `overstory feed -- Unified real-time event stream across all agents
|
|
165
|
+
|
|
166
|
+
Usage: overstory feed [options]
|
|
167
|
+
|
|
168
|
+
Options:
|
|
169
|
+
--follow, -f Continuously poll for new events (like tail -f)
|
|
170
|
+
--interval <ms> Polling interval for --follow (default: 1000, min: 200)
|
|
171
|
+
--agent <name> Filter by agent name (can appear multiple times)
|
|
172
|
+
--run <id> Filter events by run ID
|
|
173
|
+
--since <timestamp> Start time (ISO 8601, default: 5 minutes ago)
|
|
174
|
+
--limit <n> Max initial events to show (default: 50)
|
|
175
|
+
--json Output events as JSON (one per line in follow mode)
|
|
176
|
+
--help, -h Show this help`;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Entry point for `overstory feed [--follow] [--agent <name>...] [--run <id>] [--json]`.
|
|
180
|
+
*/
|
|
181
|
+
export async function feedCommand(args: string[]): Promise<void> {
|
|
182
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
183
|
+
process.stdout.write(`${FEED_HELP}\n`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const json = hasFlag(args, "--json");
|
|
188
|
+
const follow = hasFlag(args, "--follow") || hasFlag(args, "-f");
|
|
189
|
+
const runId = getFlag(args, "--run");
|
|
190
|
+
const agentNames = getAllFlags(args, "--agent");
|
|
191
|
+
const sinceStr = getFlag(args, "--since");
|
|
192
|
+
const limitStr = getFlag(args, "--limit");
|
|
193
|
+
const limit = limitStr ? Number.parseInt(limitStr, 10) : 50;
|
|
194
|
+
|
|
195
|
+
const intervalStr = getFlag(args, "--interval");
|
|
196
|
+
const interval = intervalStr ? Number.parseInt(intervalStr, 10) : 1000;
|
|
197
|
+
|
|
198
|
+
if (Number.isNaN(limit) || limit < 1) {
|
|
199
|
+
throw new ValidationError("--limit must be a positive integer", {
|
|
200
|
+
field: "limit",
|
|
201
|
+
value: limitStr,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (Number.isNaN(interval) || interval < 200) {
|
|
206
|
+
throw new ValidationError("--interval must be a number >= 200 (milliseconds)", {
|
|
207
|
+
field: "interval",
|
|
208
|
+
value: intervalStr,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Validate timestamp if provided
|
|
213
|
+
if (sinceStr !== undefined && Number.isNaN(new Date(sinceStr).getTime())) {
|
|
214
|
+
throw new ValidationError("--since must be a valid ISO 8601 timestamp", {
|
|
215
|
+
field: "since",
|
|
216
|
+
value: sinceStr,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const cwd = process.cwd();
|
|
221
|
+
const config = await loadConfig(cwd);
|
|
222
|
+
const overstoryDir = join(config.project.root, ".overstory");
|
|
223
|
+
|
|
224
|
+
// Open event store
|
|
225
|
+
const eventsDbPath = join(overstoryDir, "events.db");
|
|
226
|
+
const eventsFile = Bun.file(eventsDbPath);
|
|
227
|
+
if (!(await eventsFile.exists())) {
|
|
228
|
+
if (json) {
|
|
229
|
+
process.stdout.write("[]\n");
|
|
230
|
+
} else {
|
|
231
|
+
process.stdout.write("No events data yet.\n");
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const eventStore = createEventStore(eventsDbPath);
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
// Default since: 5 minutes ago
|
|
240
|
+
const since = sinceStr ?? new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
|
241
|
+
|
|
242
|
+
// Helper to query events based on filters
|
|
243
|
+
const queryEvents = (queryOpts: { since: string; limit: number }): StoredEvent[] => {
|
|
244
|
+
if (runId) {
|
|
245
|
+
return eventStore.getByRun(runId, queryOpts);
|
|
246
|
+
}
|
|
247
|
+
if (agentNames.length > 0) {
|
|
248
|
+
const allEvents: StoredEvent[] = [];
|
|
249
|
+
for (const name of agentNames) {
|
|
250
|
+
const agentEvents = eventStore.getByAgent(name, {
|
|
251
|
+
since: queryOpts.since,
|
|
252
|
+
});
|
|
253
|
+
allEvents.push(...agentEvents);
|
|
254
|
+
}
|
|
255
|
+
// Sort by createdAt chronologically
|
|
256
|
+
allEvents.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
257
|
+
// Apply limit after merge
|
|
258
|
+
return allEvents.slice(0, queryOpts.limit);
|
|
259
|
+
}
|
|
260
|
+
return eventStore.getTimeline(queryOpts);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
if (!follow) {
|
|
264
|
+
// Non-follow mode: single snapshot
|
|
265
|
+
const events = queryEvents({ since, limit });
|
|
266
|
+
|
|
267
|
+
if (json) {
|
|
268
|
+
process.stdout.write(`${JSON.stringify(events)}\n`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (events.length === 0) {
|
|
273
|
+
process.stdout.write("No events found.\n");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const colorMap = buildAgentColorMap(events);
|
|
278
|
+
for (const event of events) {
|
|
279
|
+
printEvent(event, colorMap);
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Follow mode: continuous polling
|
|
285
|
+
// Print initial events
|
|
286
|
+
let lastSeenId = 0;
|
|
287
|
+
const initialEvents = queryEvents({ since, limit });
|
|
288
|
+
|
|
289
|
+
if (!json) {
|
|
290
|
+
const colorMap = buildAgentColorMap(initialEvents);
|
|
291
|
+
for (const event of initialEvents) {
|
|
292
|
+
printEvent(event, colorMap);
|
|
293
|
+
}
|
|
294
|
+
if (initialEvents.length > 0) {
|
|
295
|
+
const lastEvent = initialEvents[initialEvents.length - 1];
|
|
296
|
+
if (lastEvent) {
|
|
297
|
+
lastSeenId = lastEvent.id;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
// JSON mode: print each event as a line
|
|
302
|
+
for (const event of initialEvents) {
|
|
303
|
+
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
304
|
+
}
|
|
305
|
+
if (initialEvents.length > 0) {
|
|
306
|
+
const lastEvent = initialEvents[initialEvents.length - 1];
|
|
307
|
+
if (lastEvent) {
|
|
308
|
+
lastSeenId = lastEvent.id;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Maintain a color map across polling iterations (for non-JSON mode)
|
|
314
|
+
const globalColorMap = buildAgentColorMap(initialEvents);
|
|
315
|
+
|
|
316
|
+
// Poll for new events
|
|
317
|
+
while (true) {
|
|
318
|
+
await Bun.sleep(interval);
|
|
319
|
+
|
|
320
|
+
// Query events from 60s ago, then filter client-side for id > lastSeenId
|
|
321
|
+
const pollSince = new Date(Date.now() - 60 * 1000).toISOString();
|
|
322
|
+
const recentEvents = queryEvents({ since: pollSince, limit: 1000 });
|
|
323
|
+
|
|
324
|
+
// Filter to new events only
|
|
325
|
+
const newEvents = recentEvents.filter((e) => e.id > lastSeenId);
|
|
326
|
+
|
|
327
|
+
if (newEvents.length > 0) {
|
|
328
|
+
if (!json) {
|
|
329
|
+
// Update color map for any new agents
|
|
330
|
+
for (const event of newEvents) {
|
|
331
|
+
if (!globalColorMap.has(event.agentName)) {
|
|
332
|
+
const colorIndex = globalColorMap.size % AGENT_COLORS.length;
|
|
333
|
+
const agentColor = AGENT_COLORS[colorIndex];
|
|
334
|
+
if (agentColor !== undefined) {
|
|
335
|
+
globalColorMap.set(event.agentName, agentColor);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Print new events
|
|
341
|
+
for (const event of newEvents) {
|
|
342
|
+
printEvent(event, globalColorMap);
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
// JSON mode: print each event as a line
|
|
346
|
+
for (const event of newEvents) {
|
|
347
|
+
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Update lastSeenId
|
|
352
|
+
const lastNew = newEvents[newEvents.length - 1];
|
|
353
|
+
if (lastNew) {
|
|
354
|
+
lastSeenId = lastNew.id;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
} finally {
|
|
359
|
+
eventStore.close();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for overstory group command.
|
|
3
|
+
*
|
|
4
|
+
* Uses real temp directories for groups.json I/O. Does NOT mock bd CLI --
|
|
5
|
+
* tests focus on the JSON storage layer and validation logic.
|
|
6
|
+
* The beads validation is tested with --skip-validation flag since
|
|
7
|
+
* bd is an external CLI not available in unit tests.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { mkdir } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
14
|
+
import type { TaskGroup } from "../types.ts";
|
|
15
|
+
import { loadGroups } from "./group.ts";
|
|
16
|
+
|
|
17
|
+
let tempDir: string;
|
|
18
|
+
let overstoryDir: string;
|
|
19
|
+
let groupsJsonPath: string;
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
tempDir = await createTempGitRepo();
|
|
23
|
+
overstoryDir = join(tempDir, ".overstory");
|
|
24
|
+
await mkdir(overstoryDir, { recursive: true });
|
|
25
|
+
groupsJsonPath = join(overstoryDir, "groups.json");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
await cleanupTempDir(tempDir);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Helper to write groups.json directly for test setup.
|
|
34
|
+
*/
|
|
35
|
+
async function writeGroups(groups: TaskGroup[]): Promise<void> {
|
|
36
|
+
await Bun.write(groupsJsonPath, `${JSON.stringify(groups, null, "\t")}\n`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Helper to read groups.json directly for assertions.
|
|
41
|
+
*/
|
|
42
|
+
async function readGroups(): Promise<TaskGroup[]> {
|
|
43
|
+
const text = await Bun.file(groupsJsonPath).text();
|
|
44
|
+
return JSON.parse(text) as TaskGroup[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeGroup(overrides?: Partial<TaskGroup>): TaskGroup {
|
|
48
|
+
return {
|
|
49
|
+
id: `group-${crypto.randomUUID().slice(0, 8)}`,
|
|
50
|
+
name: "Test Group",
|
|
51
|
+
memberIssueIds: ["issue-1", "issue-2"],
|
|
52
|
+
status: "active",
|
|
53
|
+
createdAt: new Date().toISOString(),
|
|
54
|
+
completedAt: null,
|
|
55
|
+
...overrides,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("loadGroups", () => {
|
|
60
|
+
test("returns empty array when groups.json does not exist", async () => {
|
|
61
|
+
const groups = await loadGroups(tempDir);
|
|
62
|
+
expect(groups).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("returns empty array when groups.json is malformed", async () => {
|
|
66
|
+
await Bun.write(groupsJsonPath, "not valid json");
|
|
67
|
+
const groups = await loadGroups(tempDir);
|
|
68
|
+
expect(groups).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("loads groups from valid groups.json", async () => {
|
|
72
|
+
const group = makeGroup({ name: "My Group" });
|
|
73
|
+
await writeGroups([group]);
|
|
74
|
+
const groups = await loadGroups(tempDir);
|
|
75
|
+
expect(groups).toHaveLength(1);
|
|
76
|
+
expect(groups[0]?.name).toBe("My Group");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("group create (via JSON storage)", () => {
|
|
81
|
+
test("creates a group with correct structure", async () => {
|
|
82
|
+
const group = makeGroup({
|
|
83
|
+
name: "Feature Batch",
|
|
84
|
+
memberIssueIds: ["abc-123", "def-456"],
|
|
85
|
+
});
|
|
86
|
+
await writeGroups([group]);
|
|
87
|
+
|
|
88
|
+
const groups = await readGroups();
|
|
89
|
+
expect(groups).toHaveLength(1);
|
|
90
|
+
const saved = groups[0];
|
|
91
|
+
expect(saved?.name).toBe("Feature Batch");
|
|
92
|
+
expect(saved?.memberIssueIds).toEqual(["abc-123", "def-456"]);
|
|
93
|
+
expect(saved?.status).toBe("active");
|
|
94
|
+
expect(saved?.completedAt).toBeNull();
|
|
95
|
+
expect(saved?.id).toMatch(/^group-[a-f0-9]{8}$/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("group ID has correct format", () => {
|
|
99
|
+
const id = `group-${crypto.randomUUID().slice(0, 8)}`;
|
|
100
|
+
expect(id).toMatch(/^group-[a-f0-9]{8}$/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("groups.json has trailing newline", async () => {
|
|
104
|
+
await writeGroups([makeGroup()]);
|
|
105
|
+
const raw = await Bun.file(groupsJsonPath).text();
|
|
106
|
+
expect(raw.endsWith("\n")).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("group add (via JSON storage)", () => {
|
|
111
|
+
test("adds issues to existing group", async () => {
|
|
112
|
+
const group = makeGroup({ memberIssueIds: ["issue-1"] });
|
|
113
|
+
await writeGroups([group]);
|
|
114
|
+
|
|
115
|
+
// Simulate add
|
|
116
|
+
const groups = await readGroups();
|
|
117
|
+
const target = groups[0];
|
|
118
|
+
expect(target).toBeDefined();
|
|
119
|
+
if (target) {
|
|
120
|
+
target.memberIssueIds.push("issue-2", "issue-3");
|
|
121
|
+
await writeGroups(groups);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const updated = await readGroups();
|
|
125
|
+
expect(updated[0]?.memberIssueIds).toEqual(["issue-1", "issue-2", "issue-3"]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("reopens completed group when adding issues", async () => {
|
|
129
|
+
const group = makeGroup({
|
|
130
|
+
status: "completed",
|
|
131
|
+
completedAt: new Date().toISOString(),
|
|
132
|
+
});
|
|
133
|
+
await writeGroups([group]);
|
|
134
|
+
|
|
135
|
+
const groups = await readGroups();
|
|
136
|
+
const target = groups[0];
|
|
137
|
+
expect(target).toBeDefined();
|
|
138
|
+
if (target) {
|
|
139
|
+
target.memberIssueIds.push("new-issue");
|
|
140
|
+
target.status = "active";
|
|
141
|
+
target.completedAt = null;
|
|
142
|
+
await writeGroups(groups);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const updated = await readGroups();
|
|
146
|
+
expect(updated[0]?.status).toBe("active");
|
|
147
|
+
expect(updated[0]?.completedAt).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("detects duplicate members", () => {
|
|
151
|
+
const group = makeGroup({ memberIssueIds: ["issue-1", "issue-2"] });
|
|
152
|
+
const isDuplicate = group.memberIssueIds.includes("issue-1");
|
|
153
|
+
expect(isDuplicate).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("group remove (via JSON storage)", () => {
|
|
158
|
+
test("removes issues from group", async () => {
|
|
159
|
+
const group = makeGroup({ memberIssueIds: ["a", "b", "c"] });
|
|
160
|
+
await writeGroups([group]);
|
|
161
|
+
|
|
162
|
+
const groups = await readGroups();
|
|
163
|
+
const target = groups[0];
|
|
164
|
+
expect(target).toBeDefined();
|
|
165
|
+
if (target) {
|
|
166
|
+
target.memberIssueIds = target.memberIssueIds.filter((id) => id !== "b");
|
|
167
|
+
await writeGroups(groups);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const updated = await readGroups();
|
|
171
|
+
expect(updated[0]?.memberIssueIds).toEqual(["a", "c"]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("cannot remove all issues (would leave empty group)", () => {
|
|
175
|
+
const group = makeGroup({ memberIssueIds: ["only-one"] });
|
|
176
|
+
const toRemove = ["only-one"];
|
|
177
|
+
const remaining = group.memberIssueIds.filter((id) => !toRemove.includes(id));
|
|
178
|
+
expect(remaining.length).toBe(0);
|
|
179
|
+
// The command should throw GroupError in this case
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("detects non-member issue", () => {
|
|
183
|
+
const group = makeGroup({ memberIssueIds: ["a", "b"] });
|
|
184
|
+
const isNotMember = !group.memberIssueIds.includes("c");
|
|
185
|
+
expect(isNotMember).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("auto-close logic", () => {
|
|
190
|
+
test("marks group completed when all issues are closed", async () => {
|
|
191
|
+
const group = makeGroup({
|
|
192
|
+
status: "active",
|
|
193
|
+
memberIssueIds: ["done-1", "done-2"],
|
|
194
|
+
});
|
|
195
|
+
await writeGroups([group]);
|
|
196
|
+
|
|
197
|
+
// Simulate auto-close: all completed
|
|
198
|
+
const groups = await readGroups();
|
|
199
|
+
const target = groups[0];
|
|
200
|
+
expect(target).toBeDefined();
|
|
201
|
+
if (target && target.status === "active") {
|
|
202
|
+
// All issues closed -> auto-close
|
|
203
|
+
target.status = "completed";
|
|
204
|
+
target.completedAt = new Date().toISOString();
|
|
205
|
+
await writeGroups(groups);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const updated = await readGroups();
|
|
209
|
+
expect(updated[0]?.status).toBe("completed");
|
|
210
|
+
expect(updated[0]?.completedAt).not.toBeNull();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("does not auto-close when some issues are still open", async () => {
|
|
214
|
+
const group = makeGroup({ status: "active" });
|
|
215
|
+
await writeGroups([group]);
|
|
216
|
+
|
|
217
|
+
// No change -- some still open
|
|
218
|
+
const groups = await readGroups();
|
|
219
|
+
expect(groups[0]?.status).toBe("active");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("does not auto-close already-completed group", () => {
|
|
223
|
+
const group = makeGroup({ status: "completed", completedAt: "2025-01-01T00:00:00Z" });
|
|
224
|
+
// Already completed, should not re-trigger
|
|
225
|
+
expect(group.status).toBe("completed");
|
|
226
|
+
expect(group.completedAt).toBe("2025-01-01T00:00:00Z");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("group list (via JSON storage)", () => {
|
|
231
|
+
test("lists all groups", async () => {
|
|
232
|
+
const g1 = makeGroup({ name: "Group A" });
|
|
233
|
+
const g2 = makeGroup({ name: "Group B", status: "completed" });
|
|
234
|
+
await writeGroups([g1, g2]);
|
|
235
|
+
|
|
236
|
+
const groups = await readGroups();
|
|
237
|
+
expect(groups).toHaveLength(2);
|
|
238
|
+
expect(groups[0]?.name).toBe("Group A");
|
|
239
|
+
expect(groups[1]?.name).toBe("Group B");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("empty list when no groups exist", async () => {
|
|
243
|
+
const groups = await loadGroups(tempDir);
|
|
244
|
+
expect(groups).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("error cases", () => {
|
|
249
|
+
test("group not found by ID", async () => {
|
|
250
|
+
await writeGroups([makeGroup()]);
|
|
251
|
+
const groups = await readGroups();
|
|
252
|
+
const found = groups.find((g) => g.id === "group-nonexist");
|
|
253
|
+
expect(found).toBeUndefined();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("multiple groups can be stored", async () => {
|
|
257
|
+
const groups = [makeGroup({ name: "A" }), makeGroup({ name: "B" }), makeGroup({ name: "C" })];
|
|
258
|
+
await writeGroups(groups);
|
|
259
|
+
const loaded = await readGroups();
|
|
260
|
+
expect(loaded).toHaveLength(3);
|
|
261
|
+
});
|
|
262
|
+
});
|