@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,511 @@
1
+ /**
2
+ * CLI command: overstory group create|status|add|remove|list
3
+ *
4
+ * Manages TaskGroups for batch work coordination. Groups track collections
5
+ * of beads issues and auto-close when all member issues are closed.
6
+ *
7
+ * Storage: `.overstory/groups.json` (array of TaskGroup objects).
8
+ */
9
+
10
+ import { join } from "node:path";
11
+ import { loadConfig } from "../config.ts";
12
+ import { GroupError, ValidationError } from "../errors.ts";
13
+ import { createTrackerClient, resolveBackend, type TrackerClient } from "../tracker/factory.ts";
14
+ import type { TaskGroup, TaskGroupProgress } from "../types.ts";
15
+
16
+ /** Boolean flags that do NOT consume the next arg. */
17
+ const BOOLEAN_FLAGS = new Set(["--json", "--help", "-h"]);
18
+
19
+ /**
20
+ * Extract positional arguments, skipping flag-value pairs.
21
+ */
22
+ function getPositionalArgs(args: string[]): string[] {
23
+ const positional: string[] = [];
24
+ let i = 0;
25
+ while (i < args.length) {
26
+ const arg = args[i];
27
+ if (arg?.startsWith("-")) {
28
+ if (BOOLEAN_FLAGS.has(arg)) {
29
+ i += 1;
30
+ } else {
31
+ i += 2;
32
+ }
33
+ } else {
34
+ if (arg !== undefined) {
35
+ positional.push(arg);
36
+ }
37
+ i += 1;
38
+ }
39
+ }
40
+ return positional;
41
+ }
42
+
43
+ /**
44
+ * Resolve the groups.json path from the project root.
45
+ */
46
+ function groupsPath(projectRoot: string): string {
47
+ return join(projectRoot, ".overstory", "groups.json");
48
+ }
49
+
50
+ /**
51
+ * Load groups from .overstory/groups.json.
52
+ */
53
+ export async function loadGroups(projectRoot: string): Promise<TaskGroup[]> {
54
+ const path = groupsPath(projectRoot);
55
+ const file = Bun.file(path);
56
+ if (!(await file.exists())) {
57
+ return [];
58
+ }
59
+ try {
60
+ const text = await file.text();
61
+ return JSON.parse(text) as TaskGroup[];
62
+ } catch {
63
+ return [];
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Save groups to .overstory/groups.json.
69
+ */
70
+ async function saveGroups(projectRoot: string, groups: TaskGroup[]): Promise<void> {
71
+ const path = groupsPath(projectRoot);
72
+ await Bun.write(path, `${JSON.stringify(groups, null, "\t")}\n`);
73
+ }
74
+
75
+ /**
76
+ * Query a tracker issue status via the tracker client.
77
+ * Returns the status string, or null if the issue cannot be found.
78
+ */
79
+ async function getIssueStatus(id: string, tracker: TrackerClient): Promise<string | null> {
80
+ try {
81
+ const issue = await tracker.show(id);
82
+ return issue.status ?? null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Validate that a tracker issue exists.
90
+ */
91
+ async function validateIssueExists(id: string, tracker: TrackerClient): Promise<void> {
92
+ const status = await getIssueStatus(id, tracker);
93
+ if (status === null) {
94
+ throw new GroupError(`Issue "${id}" not found in tracker`, { groupId: id });
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Generate a group ID.
100
+ */
101
+ function generateGroupId(): string {
102
+ return `group-${crypto.randomUUID().slice(0, 8)}`;
103
+ }
104
+
105
+ /**
106
+ * Create a new task group.
107
+ */
108
+ async function createGroup(
109
+ projectRoot: string,
110
+ name: string,
111
+ issueIds: string[],
112
+ skipValidation = false,
113
+ tracker?: TrackerClient,
114
+ ): Promise<TaskGroup> {
115
+ if (!name || name.trim().length === 0) {
116
+ throw new ValidationError("Group name is required", { field: "name" });
117
+ }
118
+ if (issueIds.length === 0) {
119
+ throw new ValidationError("At least one issue ID is required", { field: "issueIds" });
120
+ }
121
+
122
+ // Validate all issues exist
123
+ if (!skipValidation && tracker) {
124
+ for (const id of issueIds) {
125
+ await validateIssueExists(id, tracker);
126
+ }
127
+ }
128
+
129
+ // Check for duplicate issue IDs in the input
130
+ const unique = new Set(issueIds);
131
+ if (unique.size !== issueIds.length) {
132
+ throw new ValidationError("Duplicate issue IDs provided", { field: "issueIds" });
133
+ }
134
+
135
+ const groups = await loadGroups(projectRoot);
136
+ const group: TaskGroup = {
137
+ id: generateGroupId(),
138
+ name: name.trim(),
139
+ memberIssueIds: issueIds,
140
+ status: "active",
141
+ createdAt: new Date().toISOString(),
142
+ completedAt: null,
143
+ };
144
+ groups.push(group);
145
+ await saveGroups(projectRoot, groups);
146
+ return group;
147
+ }
148
+
149
+ /**
150
+ * Add issues to an existing group.
151
+ */
152
+ async function addToGroup(
153
+ projectRoot: string,
154
+ groupId: string,
155
+ issueIds: string[],
156
+ skipValidation = false,
157
+ tracker?: TrackerClient,
158
+ ): Promise<TaskGroup> {
159
+ if (issueIds.length === 0) {
160
+ throw new ValidationError("At least one issue ID is required", { field: "issueIds" });
161
+ }
162
+
163
+ const groups = await loadGroups(projectRoot);
164
+ const group = groups.find((g) => g.id === groupId);
165
+ if (!group) {
166
+ throw new GroupError(`Group "${groupId}" not found`, { groupId });
167
+ }
168
+
169
+ // Check for duplicates against existing members
170
+ for (const id of issueIds) {
171
+ if (group.memberIssueIds.includes(id)) {
172
+ throw new GroupError(`Issue "${id}" is already a member of group "${groupId}"`, {
173
+ groupId,
174
+ });
175
+ }
176
+ }
177
+
178
+ // Validate issues exist
179
+ if (!skipValidation && tracker) {
180
+ for (const id of issueIds) {
181
+ await validateIssueExists(id, tracker);
182
+ }
183
+ }
184
+
185
+ group.memberIssueIds.push(...issueIds);
186
+
187
+ // If group was completed, reopen it
188
+ if (group.status === "completed") {
189
+ group.status = "active";
190
+ group.completedAt = null;
191
+ }
192
+
193
+ await saveGroups(projectRoot, groups);
194
+ return group;
195
+ }
196
+
197
+ /**
198
+ * Remove issues from an existing group.
199
+ */
200
+ async function removeFromGroup(
201
+ projectRoot: string,
202
+ groupId: string,
203
+ issueIds: string[],
204
+ ): Promise<TaskGroup> {
205
+ if (issueIds.length === 0) {
206
+ throw new ValidationError("At least one issue ID is required", { field: "issueIds" });
207
+ }
208
+
209
+ const groups = await loadGroups(projectRoot);
210
+ const group = groups.find((g) => g.id === groupId);
211
+ if (!group) {
212
+ throw new GroupError(`Group "${groupId}" not found`, { groupId });
213
+ }
214
+
215
+ // Validate all issues are members
216
+ for (const id of issueIds) {
217
+ if (!group.memberIssueIds.includes(id)) {
218
+ throw new GroupError(`Issue "${id}" is not a member of group "${groupId}"`, {
219
+ groupId,
220
+ });
221
+ }
222
+ }
223
+
224
+ // Check that removal won't empty the group
225
+ const remaining = group.memberIssueIds.filter((id) => !issueIds.includes(id));
226
+ if (remaining.length === 0) {
227
+ throw new GroupError("Cannot remove all issues from a group", { groupId });
228
+ }
229
+
230
+ group.memberIssueIds = remaining;
231
+ await saveGroups(projectRoot, groups);
232
+ return group;
233
+ }
234
+
235
+ /**
236
+ * Get progress for a single group. Queries the tracker for member issue statuses.
237
+ * Auto-closes the group if all members are closed.
238
+ */
239
+ async function getGroupProgress(
240
+ projectRoot: string,
241
+ group: TaskGroup,
242
+ groups: TaskGroup[],
243
+ tracker?: TrackerClient,
244
+ ): Promise<TaskGroupProgress> {
245
+ let completed = 0;
246
+ let inProgress = 0;
247
+ let blocked = 0;
248
+ let open = 0;
249
+
250
+ for (const id of group.memberIssueIds) {
251
+ const status = tracker ? await getIssueStatus(id, tracker) : null;
252
+ switch (status) {
253
+ case "closed":
254
+ completed++;
255
+ break;
256
+ case "in_progress":
257
+ inProgress++;
258
+ break;
259
+ case "blocked":
260
+ blocked++;
261
+ break;
262
+ default:
263
+ open++;
264
+ break;
265
+ }
266
+ }
267
+
268
+ const total = group.memberIssueIds.length;
269
+
270
+ // Auto-close: if all members are closed and group is still active
271
+ if (completed === total && total > 0 && group.status === "active") {
272
+ group.status = "completed";
273
+ group.completedAt = new Date().toISOString();
274
+ await saveGroups(projectRoot, groups);
275
+ process.stdout.write(`Group "${group.name}" (${group.id}) auto-closed: all issues done\n`);
276
+
277
+ // Notify coordinator via mail (best-effort)
278
+ try {
279
+ const mailDbPath = join(projectRoot, ".overstory", "mail.db");
280
+ const mailDbFile = Bun.file(mailDbPath);
281
+ if (await mailDbFile.exists()) {
282
+ const { createMailStore } = await import("../mail/store.ts");
283
+ const mailStore = createMailStore(mailDbPath);
284
+ try {
285
+ mailStore.insert({
286
+ id: "",
287
+ from: "system",
288
+ to: "coordinator",
289
+ subject: `Group auto-closed: ${group.name}`,
290
+ body: `Task group ${group.id} ("${group.name}") completed. All ${total} member issues are closed.`,
291
+ type: "status",
292
+ priority: "normal",
293
+ threadId: null,
294
+ });
295
+ } finally {
296
+ mailStore.close();
297
+ }
298
+ }
299
+ } catch {
300
+ // Non-fatal: mail notification is best-effort
301
+ }
302
+ }
303
+
304
+ return { group, total, completed, inProgress, blocked, open };
305
+ }
306
+
307
+ /**
308
+ * Print a group's progress in human-readable format.
309
+ */
310
+ function printGroupProgress(progress: TaskGroupProgress): void {
311
+ const w = process.stdout.write.bind(process.stdout);
312
+ const { group, total, completed, inProgress, blocked, open } = progress;
313
+ const status = group.status === "completed" ? "[completed]" : "[active]";
314
+ w(`${group.name} (${group.id}) ${status}\n`);
315
+ w(` Issues: ${total} total`);
316
+ w(` | ${completed} completed`);
317
+ w(` | ${inProgress} in_progress`);
318
+ w(` | ${blocked} blocked`);
319
+ w(` | ${open} open\n`);
320
+ if (group.status === "completed" && group.completedAt) {
321
+ w(` Completed: ${group.completedAt}\n`);
322
+ }
323
+ }
324
+
325
+ const GROUP_HELP = `overstory group -- Manage task groups for batch coordination
326
+
327
+ Usage: overstory group <subcommand> [args...]
328
+
329
+ Subcommands:
330
+ create '<name>' <id1> [id2...] Create a new task group
331
+ status [group-id] Show progress for one or all groups
332
+ add <group-id> <id1> [id2...] Add issues to a group
333
+ remove <group-id> <id1> [id2...] Remove issues from a group
334
+ list List all groups (summary)
335
+
336
+ Options:
337
+ --json Output as JSON
338
+ --skip-validation Skip beads issue validation (for offline use)
339
+ --help, -h Show this help`;
340
+
341
+ /**
342
+ * Entry point for `overstory group <subcommand>`.
343
+ */
344
+ export async function groupCommand(args: string[]): Promise<void> {
345
+ if (args.includes("--help") || args.includes("-h") || args.length === 0) {
346
+ process.stdout.write(`${GROUP_HELP}\n`);
347
+ return;
348
+ }
349
+
350
+ const subcommand = args[0];
351
+ const subArgs = args.slice(1);
352
+ const json = subArgs.includes("--json");
353
+ const skipValidation = subArgs.includes("--skip-validation");
354
+
355
+ const config = await loadConfig(process.cwd());
356
+ const projectRoot = config.project.root;
357
+ const resolvedBackend = await resolveBackend(config.taskTracker.backend, projectRoot);
358
+ const tracker = createTrackerClient(resolvedBackend, projectRoot);
359
+
360
+ switch (subcommand) {
361
+ case "create": {
362
+ const positional = getPositionalArgs(subArgs);
363
+ const name = positional[0];
364
+ if (!name || name.trim().length === 0) {
365
+ throw new ValidationError(
366
+ "Group name is required: overstory group create '<name>' <id1> [id2...]",
367
+ { field: "name" },
368
+ );
369
+ }
370
+ const issueIds = positional.slice(1);
371
+ if (issueIds.length === 0) {
372
+ throw new ValidationError(
373
+ "At least one issue ID is required: overstory group create '<name>' <id1> [id2...]",
374
+ { field: "issueIds" },
375
+ );
376
+ }
377
+ const group = await createGroup(projectRoot, name, issueIds, skipValidation, tracker);
378
+ if (json) {
379
+ process.stdout.write(`${JSON.stringify(group, null, "\t")}\n`);
380
+ } else {
381
+ process.stdout.write(`Created group "${group.name}" (${group.id})\n`);
382
+ process.stdout.write(` Members: ${group.memberIssueIds.join(", ")}\n`);
383
+ }
384
+ break;
385
+ }
386
+
387
+ case "status": {
388
+ const positional = getPositionalArgs(subArgs);
389
+ const groupId = positional[0];
390
+ const groups = await loadGroups(projectRoot);
391
+
392
+ if (groupId) {
393
+ const group = groups.find((g) => g.id === groupId);
394
+ if (!group) {
395
+ throw new GroupError(`Group "${groupId}" not found`, { groupId });
396
+ }
397
+ const progress = await getGroupProgress(projectRoot, group, groups, tracker);
398
+ if (json) {
399
+ process.stdout.write(`${JSON.stringify(progress, null, "\t")}\n`);
400
+ } else {
401
+ printGroupProgress(progress);
402
+ }
403
+ } else {
404
+ const activeGroups = groups.filter((g) => g.status === "active");
405
+ if (activeGroups.length === 0) {
406
+ if (json) {
407
+ process.stdout.write("[]\n");
408
+ } else {
409
+ process.stdout.write("No active groups\n");
410
+ }
411
+ break;
412
+ }
413
+ const progressList: TaskGroupProgress[] = [];
414
+ for (const group of activeGroups) {
415
+ const progress = await getGroupProgress(projectRoot, group, groups, tracker);
416
+ progressList.push(progress);
417
+ }
418
+ if (json) {
419
+ process.stdout.write(`${JSON.stringify(progressList, null, "\t")}\n`);
420
+ } else {
421
+ for (const progress of progressList) {
422
+ printGroupProgress(progress);
423
+ process.stdout.write("\n");
424
+ }
425
+ }
426
+ }
427
+ break;
428
+ }
429
+
430
+ case "add": {
431
+ const positional = getPositionalArgs(subArgs);
432
+ const groupId = positional[0];
433
+ if (!groupId || groupId.trim().length === 0) {
434
+ throw new ValidationError(
435
+ "Group ID is required: overstory group add <group-id> <id1> [id2...]",
436
+ { field: "groupId" },
437
+ );
438
+ }
439
+ const issueIds = positional.slice(1);
440
+ if (issueIds.length === 0) {
441
+ throw new ValidationError(
442
+ "At least one issue ID is required: overstory group add <group-id> <id1> [id2...]",
443
+ { field: "issueIds" },
444
+ );
445
+ }
446
+ const group = await addToGroup(projectRoot, groupId, issueIds, skipValidation, tracker);
447
+ if (json) {
448
+ process.stdout.write(`${JSON.stringify(group, null, "\t")}\n`);
449
+ } else {
450
+ process.stdout.write(`Added ${issueIds.length} issue(s) to "${group.name}"\n`);
451
+ process.stdout.write(` Members: ${group.memberIssueIds.join(", ")}\n`);
452
+ }
453
+ break;
454
+ }
455
+
456
+ case "remove": {
457
+ const positional = getPositionalArgs(subArgs);
458
+ const groupId = positional[0];
459
+ if (!groupId || groupId.trim().length === 0) {
460
+ throw new ValidationError(
461
+ "Group ID is required: overstory group remove <group-id> <id1> [id2...]",
462
+ { field: "groupId" },
463
+ );
464
+ }
465
+ const issueIds = positional.slice(1);
466
+ if (issueIds.length === 0) {
467
+ throw new ValidationError(
468
+ "At least one issue ID is required: overstory group remove <group-id> <id1> [id2...]",
469
+ { field: "issueIds" },
470
+ );
471
+ }
472
+ const group = await removeFromGroup(projectRoot, groupId, issueIds);
473
+ if (json) {
474
+ process.stdout.write(`${JSON.stringify(group, null, "\t")}\n`);
475
+ } else {
476
+ process.stdout.write(`Removed ${issueIds.length} issue(s) from "${group.name}"\n`);
477
+ process.stdout.write(` Members: ${group.memberIssueIds.join(", ")}\n`);
478
+ }
479
+ break;
480
+ }
481
+
482
+ case "list": {
483
+ const groups = await loadGroups(projectRoot);
484
+ if (groups.length === 0) {
485
+ if (json) {
486
+ process.stdout.write("[]\n");
487
+ } else {
488
+ process.stdout.write("No groups\n");
489
+ }
490
+ break;
491
+ }
492
+ if (json) {
493
+ process.stdout.write(`${JSON.stringify(groups, null, "\t")}\n`);
494
+ } else {
495
+ for (const group of groups) {
496
+ const status = group.status === "completed" ? "[completed]" : "[active]";
497
+ process.stdout.write(
498
+ `${group.id} ${status} "${group.name}" (${group.memberIssueIds.length} issues)\n`,
499
+ );
500
+ }
501
+ }
502
+ break;
503
+ }
504
+
505
+ default:
506
+ throw new ValidationError(
507
+ `Unknown group subcommand: ${subcommand}. Run 'overstory group --help' for usage.`,
508
+ { field: "subcommand", value: subcommand },
509
+ );
510
+ }
511
+ }