@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,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
+ });