@katyella/legio 0.1.0
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/CHANGELOG.md +422 -0
- package/LICENSE +21 -0
- package/README.md +555 -0
- package/agents/builder.md +141 -0
- package/agents/coordinator.md +351 -0
- package/agents/cto.md +196 -0
- package/agents/gateway.md +276 -0
- package/agents/lead.md +281 -0
- package/agents/merger.md +156 -0
- package/agents/monitor.md +212 -0
- package/agents/reviewer.md +142 -0
- package/agents/scout.md +131 -0
- package/agents/supervisor.md +416 -0
- package/bin/legio.mjs +38 -0
- package/package.json +77 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +102 -0
- package/src/agents/hooks-deployer.test.ts +1820 -0
- package/src/agents/hooks-deployer.ts +574 -0
- package/src/agents/identity.test.ts +614 -0
- package/src/agents/identity.ts +385 -0
- package/src/agents/lifecycle.test.ts +202 -0
- package/src/agents/lifecycle.ts +184 -0
- package/src/agents/manifest.test.ts +558 -0
- package/src/agents/manifest.ts +297 -0
- package/src/agents/overlay.test.ts +592 -0
- package/src/agents/overlay.ts +316 -0
- package/src/beads/client.test.ts +210 -0
- package/src/beads/client.ts +227 -0
- package/src/beads/molecules.test.ts +320 -0
- package/src/beads/molecules.ts +209 -0
- package/src/commands/agents.test.ts +325 -0
- package/src/commands/agents.ts +286 -0
- package/src/commands/clean.test.ts +730 -0
- package/src/commands/clean.ts +653 -0
- package/src/commands/completions.test.ts +346 -0
- package/src/commands/completions.ts +950 -0
- package/src/commands/coordinator.test.ts +1524 -0
- package/src/commands/coordinator.ts +880 -0
- package/src/commands/costs.test.ts +1015 -0
- package/src/commands/costs.ts +473 -0
- package/src/commands/dashboard.test.ts +94 -0
- package/src/commands/dashboard.ts +607 -0
- package/src/commands/doctor.test.ts +295 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/down.test.ts +308 -0
- package/src/commands/down.ts +124 -0
- package/src/commands/errors.test.ts +648 -0
- package/src/commands/errors.ts +255 -0
- package/src/commands/feed.test.ts +579 -0
- package/src/commands/feed.ts +368 -0
- package/src/commands/gateway.test.ts +698 -0
- package/src/commands/gateway.ts +419 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +539 -0
- package/src/commands/hooks.test.ts +292 -0
- package/src/commands/hooks.ts +210 -0
- package/src/commands/init.test.ts +211 -0
- package/src/commands/init.ts +622 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +455 -0
- package/src/commands/log.test.ts +1556 -0
- package/src/commands/log.ts +752 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +544 -0
- package/src/commands/mail.test.ts +1726 -0
- package/src/commands/mail.ts +926 -0
- package/src/commands/merge.test.ts +676 -0
- package/src/commands/merge.ts +374 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +150 -0
- package/src/commands/monitor.test.ts +151 -0
- package/src/commands/monitor.ts +394 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +373 -0
- package/src/commands/prime.test.ts +467 -0
- package/src/commands/prime.ts +386 -0
- package/src/commands/replay.test.ts +742 -0
- package/src/commands/replay.ts +367 -0
- package/src/commands/run.test.ts +443 -0
- package/src/commands/run.ts +365 -0
- package/src/commands/server.test.ts +626 -0
- package/src/commands/server.ts +298 -0
- package/src/commands/sling.test.ts +810 -0
- package/src/commands/sling.ts +700 -0
- package/src/commands/spec.test.ts +206 -0
- package/src/commands/spec.ts +171 -0
- package/src/commands/status.test.ts +276 -0
- package/src/commands/status.ts +339 -0
- package/src/commands/stop.test.ts +357 -0
- package/src/commands/stop.ts +119 -0
- package/src/commands/supervisor.test.ts +186 -0
- package/src/commands/supervisor.ts +544 -0
- package/src/commands/trace.test.ts +746 -0
- package/src/commands/trace.ts +332 -0
- package/src/commands/up.test.ts +597 -0
- package/src/commands/up.ts +275 -0
- package/src/commands/watch.test.ts +152 -0
- package/src/commands/watch.ts +238 -0
- package/src/commands/worktree.test.ts +648 -0
- package/src/commands/worktree.ts +266 -0
- package/src/config.test.ts +496 -0
- package/src/config.ts +616 -0
- package/src/doctor/agents.test.ts +448 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +184 -0
- package/src/doctor/config-check.ts +185 -0
- package/src/doctor/consistency.test.ts +645 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +284 -0
- package/src/doctor/databases.ts +211 -0
- package/src/doctor/dependencies.test.ts +150 -0
- package/src/doctor/dependencies.ts +179 -0
- package/src/doctor/logs.test.ts +244 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +210 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +285 -0
- package/src/doctor/structure.ts +195 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +130 -0
- package/src/doctor/version.ts +131 -0
- package/src/e2e/chat-flow.test.ts +346 -0
- package/src/e2e/init-sling-lifecycle.test.ts +288 -0
- package/src/errors.test.ts +21 -0
- package/src/errors.ts +246 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +344 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/global-setup.ts +14 -0
- package/src/index.ts +339 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +118 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +812 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +258 -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 +873 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +815 -0
- package/src/mail/store.ts +402 -0
- package/src/merge/queue.test.ts +449 -0
- package/src/merge/queue.ts +262 -0
- package/src/merge/resolver.test.ts +1453 -0
- package/src/merge/resolver.ts +759 -0
- package/src/metrics/store.test.ts +1167 -0
- package/src/metrics/store.ts +511 -0
- package/src/metrics/summary.test.ts +397 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +643 -0
- package/src/metrics/transcript.ts +351 -0
- package/src/mulch/client.test.ts +547 -0
- package/src/mulch/client.ts +416 -0
- package/src/server/audit-store.test.ts +384 -0
- package/src/server/audit-store.ts +257 -0
- package/src/server/headless.test.ts +180 -0
- package/src/server/headless.ts +151 -0
- package/src/server/index.test.ts +241 -0
- package/src/server/index.ts +317 -0
- package/src/server/public/app.js +187 -0
- package/src/server/public/apple-touch-icon.png +0 -0
- package/src/server/public/components/agent-badge.js +37 -0
- package/src/server/public/components/data-table.js +114 -0
- package/src/server/public/components/gateway-chat.js +256 -0
- package/src/server/public/components/issue-card.js +96 -0
- package/src/server/public/components/layout.js +88 -0
- package/src/server/public/components/message-bubble.js +120 -0
- package/src/server/public/components/stat-card.js +26 -0
- package/src/server/public/components/terminal-panel.js +140 -0
- package/src/server/public/favicon-16.png +0 -0
- package/src/server/public/favicon-32.png +0 -0
- package/src/server/public/favicon.ico +0 -0
- package/src/server/public/favicon.png +0 -0
- package/src/server/public/index.html +64 -0
- package/src/server/public/lib/api.js +35 -0
- package/src/server/public/lib/markdown.js +8 -0
- package/src/server/public/lib/preact-setup.js +8 -0
- package/src/server/public/lib/state.js +99 -0
- package/src/server/public/lib/utils.js +309 -0
- package/src/server/public/lib/ws.js +79 -0
- package/src/server/public/views/chat.js +983 -0
- package/src/server/public/views/costs.js +692 -0
- package/src/server/public/views/dashboard.js +781 -0
- package/src/server/public/views/gateway-chat.js +622 -0
- package/src/server/public/views/inspect.js +399 -0
- package/src/server/public/views/issues.js +470 -0
- package/src/server/public/views/setup.js +94 -0
- package/src/server/public/views/task-detail.js +422 -0
- package/src/server/routes.test.ts +3816 -0
- package/src/server/routes.ts +1964 -0
- package/src/server/websocket.test.ts +288 -0
- package/src/server/websocket.ts +196 -0
- package/src/sessions/compat.test.ts +109 -0
- package/src/sessions/compat.ts +17 -0
- package/src/sessions/store.test.ts +969 -0
- package/src/sessions/store.ts +480 -0
- package/src/test-helpers.test.ts +97 -0
- package/src/test-helpers.ts +143 -0
- package/src/types.ts +708 -0
- package/src/watchdog/daemon.test.ts +1233 -0
- package/src/watchdog/daemon.ts +533 -0
- package/src/watchdog/health.test.ts +371 -0
- package/src/watchdog/health.ts +248 -0
- package/src/watchdog/triage.test.ts +162 -0
- package/src/watchdog/triage.ts +193 -0
- package/src/worktree/manager.test.ts +444 -0
- package/src/worktree/manager.ts +224 -0
- package/src/worktree/tmux.test.ts +1238 -0
- package/src/worktree/tmux.ts +644 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +132 -0
- package/templates/overlay.md.tmpl +79 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: legio 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: `.legio/groups.json` (array of TaskGroup objects).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { access, readFile, writeFile } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { GroupError, ValidationError } from "../errors.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
|
+
* Check if a file exists using access().
|
|
21
|
+
*/
|
|
22
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
23
|
+
try {
|
|
24
|
+
await access(path);
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Extract positional arguments, skipping flag-value pairs.
|
|
33
|
+
*/
|
|
34
|
+
function getPositionalArgs(args: string[]): string[] {
|
|
35
|
+
const positional: string[] = [];
|
|
36
|
+
let i = 0;
|
|
37
|
+
while (i < args.length) {
|
|
38
|
+
const arg = args[i];
|
|
39
|
+
if (arg?.startsWith("-")) {
|
|
40
|
+
if (BOOLEAN_FLAGS.has(arg)) {
|
|
41
|
+
i += 1;
|
|
42
|
+
} else {
|
|
43
|
+
i += 2;
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
if (arg !== undefined) {
|
|
47
|
+
positional.push(arg);
|
|
48
|
+
}
|
|
49
|
+
i += 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return positional;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the groups.json path from the project root.
|
|
57
|
+
*/
|
|
58
|
+
function groupsPath(projectRoot: string): string {
|
|
59
|
+
return join(projectRoot, ".legio", "groups.json");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Load groups from .legio/groups.json.
|
|
64
|
+
*/
|
|
65
|
+
export async function loadGroups(projectRoot: string): Promise<TaskGroup[]> {
|
|
66
|
+
const path = groupsPath(projectRoot);
|
|
67
|
+
if (!(await fileExists(path))) {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const text = await readFile(path, "utf-8");
|
|
72
|
+
return JSON.parse(text) as TaskGroup[];
|
|
73
|
+
} catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Save groups to .legio/groups.json.
|
|
80
|
+
*/
|
|
81
|
+
async function saveGroups(projectRoot: string, groups: TaskGroup[]): Promise<void> {
|
|
82
|
+
const path = groupsPath(projectRoot);
|
|
83
|
+
await writeFile(path, `${JSON.stringify(groups, null, "\t")}\n`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Query a beads issue status via `bd show <id> --json`.
|
|
88
|
+
* Returns the status string, or null if the issue cannot be found.
|
|
89
|
+
*/
|
|
90
|
+
async function getIssueStatus(id: string): Promise<string | null> {
|
|
91
|
+
try {
|
|
92
|
+
const { stdout, exitCode } = await new Promise<{ stdout: string; exitCode: number }>(
|
|
93
|
+
(resolve) => {
|
|
94
|
+
const proc = spawn("bd", ["show", id, "--json"], {
|
|
95
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
96
|
+
});
|
|
97
|
+
const stdoutChunks: Buffer[] = [];
|
|
98
|
+
proc.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
|
|
99
|
+
proc.on("close", (code) => {
|
|
100
|
+
resolve({
|
|
101
|
+
stdout: Buffer.concat(stdoutChunks).toString(),
|
|
102
|
+
exitCode: code ?? 1,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
if (exitCode !== 0) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
// bd show --json returns an array with a single element
|
|
111
|
+
const arr = JSON.parse(stdout) as { status?: string }[];
|
|
112
|
+
const data = arr[0];
|
|
113
|
+
if (!data) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return data.status ?? null;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Validate that a beads issue exists.
|
|
124
|
+
*/
|
|
125
|
+
async function validateIssueExists(id: string): Promise<void> {
|
|
126
|
+
const status = await getIssueStatus(id);
|
|
127
|
+
if (status === null) {
|
|
128
|
+
throw new GroupError(`Issue "${id}" not found in beads`, { groupId: id });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Generate a group ID.
|
|
134
|
+
*/
|
|
135
|
+
function generateGroupId(): string {
|
|
136
|
+
return `group-${crypto.randomUUID().slice(0, 8)}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Create a new task group.
|
|
141
|
+
*/
|
|
142
|
+
async function createGroup(
|
|
143
|
+
projectRoot: string,
|
|
144
|
+
name: string,
|
|
145
|
+
issueIds: string[],
|
|
146
|
+
skipValidation = false,
|
|
147
|
+
): Promise<TaskGroup> {
|
|
148
|
+
if (!name || name.trim().length === 0) {
|
|
149
|
+
throw new ValidationError("Group name is required", { field: "name" });
|
|
150
|
+
}
|
|
151
|
+
if (issueIds.length === 0) {
|
|
152
|
+
throw new ValidationError("At least one issue ID is required", { field: "issueIds" });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Validate all issues exist
|
|
156
|
+
if (!skipValidation) {
|
|
157
|
+
for (const id of issueIds) {
|
|
158
|
+
await validateIssueExists(id);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check for duplicate issue IDs in the input
|
|
163
|
+
const unique = new Set(issueIds);
|
|
164
|
+
if (unique.size !== issueIds.length) {
|
|
165
|
+
throw new ValidationError("Duplicate issue IDs provided", { field: "issueIds" });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const groups = await loadGroups(projectRoot);
|
|
169
|
+
const group: TaskGroup = {
|
|
170
|
+
id: generateGroupId(),
|
|
171
|
+
name: name.trim(),
|
|
172
|
+
memberIssueIds: issueIds,
|
|
173
|
+
status: "active",
|
|
174
|
+
createdAt: new Date().toISOString(),
|
|
175
|
+
completedAt: null,
|
|
176
|
+
};
|
|
177
|
+
groups.push(group);
|
|
178
|
+
await saveGroups(projectRoot, groups);
|
|
179
|
+
return group;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Add issues to an existing group.
|
|
184
|
+
*/
|
|
185
|
+
async function addToGroup(
|
|
186
|
+
projectRoot: string,
|
|
187
|
+
groupId: string,
|
|
188
|
+
issueIds: string[],
|
|
189
|
+
skipValidation = false,
|
|
190
|
+
): Promise<TaskGroup> {
|
|
191
|
+
if (issueIds.length === 0) {
|
|
192
|
+
throw new ValidationError("At least one issue ID is required", { field: "issueIds" });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const groups = await loadGroups(projectRoot);
|
|
196
|
+
const group = groups.find((g) => g.id === groupId);
|
|
197
|
+
if (!group) {
|
|
198
|
+
throw new GroupError(`Group "${groupId}" not found`, { groupId });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Check for duplicates against existing members
|
|
202
|
+
for (const id of issueIds) {
|
|
203
|
+
if (group.memberIssueIds.includes(id)) {
|
|
204
|
+
throw new GroupError(`Issue "${id}" is already a member of group "${groupId}"`, {
|
|
205
|
+
groupId,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Validate issues exist
|
|
211
|
+
if (!skipValidation) {
|
|
212
|
+
for (const id of issueIds) {
|
|
213
|
+
await validateIssueExists(id);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
group.memberIssueIds.push(...issueIds);
|
|
218
|
+
|
|
219
|
+
// If group was completed, reopen it
|
|
220
|
+
if (group.status === "completed") {
|
|
221
|
+
group.status = "active";
|
|
222
|
+
group.completedAt = null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
await saveGroups(projectRoot, groups);
|
|
226
|
+
return group;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Remove issues from an existing group.
|
|
231
|
+
*/
|
|
232
|
+
async function removeFromGroup(
|
|
233
|
+
projectRoot: string,
|
|
234
|
+
groupId: string,
|
|
235
|
+
issueIds: string[],
|
|
236
|
+
): Promise<TaskGroup> {
|
|
237
|
+
if (issueIds.length === 0) {
|
|
238
|
+
throw new ValidationError("At least one issue ID is required", { field: "issueIds" });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const groups = await loadGroups(projectRoot);
|
|
242
|
+
const group = groups.find((g) => g.id === groupId);
|
|
243
|
+
if (!group) {
|
|
244
|
+
throw new GroupError(`Group "${groupId}" not found`, { groupId });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Validate all issues are members
|
|
248
|
+
for (const id of issueIds) {
|
|
249
|
+
if (!group.memberIssueIds.includes(id)) {
|
|
250
|
+
throw new GroupError(`Issue "${id}" is not a member of group "${groupId}"`, {
|
|
251
|
+
groupId,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Check that removal won't empty the group
|
|
257
|
+
const remaining = group.memberIssueIds.filter((id) => !issueIds.includes(id));
|
|
258
|
+
if (remaining.length === 0) {
|
|
259
|
+
throw new GroupError("Cannot remove all issues from a group", { groupId });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
group.memberIssueIds = remaining;
|
|
263
|
+
await saveGroups(projectRoot, groups);
|
|
264
|
+
return group;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Get progress for a single group. Queries beads for member issue statuses.
|
|
269
|
+
* Auto-closes the group if all members are closed.
|
|
270
|
+
*/
|
|
271
|
+
async function getGroupProgress(
|
|
272
|
+
projectRoot: string,
|
|
273
|
+
group: TaskGroup,
|
|
274
|
+
groups: TaskGroup[],
|
|
275
|
+
): Promise<TaskGroupProgress> {
|
|
276
|
+
let completed = 0;
|
|
277
|
+
let inProgress = 0;
|
|
278
|
+
let blocked = 0;
|
|
279
|
+
let open = 0;
|
|
280
|
+
|
|
281
|
+
for (const id of group.memberIssueIds) {
|
|
282
|
+
const status = await getIssueStatus(id);
|
|
283
|
+
switch (status) {
|
|
284
|
+
case "closed":
|
|
285
|
+
completed++;
|
|
286
|
+
break;
|
|
287
|
+
case "in_progress":
|
|
288
|
+
inProgress++;
|
|
289
|
+
break;
|
|
290
|
+
case "blocked":
|
|
291
|
+
blocked++;
|
|
292
|
+
break;
|
|
293
|
+
default:
|
|
294
|
+
open++;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const total = group.memberIssueIds.length;
|
|
300
|
+
|
|
301
|
+
// Auto-close: if all members are closed and group is still active
|
|
302
|
+
if (completed === total && total > 0 && group.status === "active") {
|
|
303
|
+
group.status = "completed";
|
|
304
|
+
group.completedAt = new Date().toISOString();
|
|
305
|
+
await saveGroups(projectRoot, groups);
|
|
306
|
+
process.stdout.write(`Group "${group.name}" (${group.id}) auto-closed: all issues done\n`);
|
|
307
|
+
|
|
308
|
+
// Notify coordinator via mail (best-effort)
|
|
309
|
+
try {
|
|
310
|
+
const mailDbPath = join(projectRoot, ".legio", "mail.db");
|
|
311
|
+
if (await fileExists(mailDbPath)) {
|
|
312
|
+
const { createMailStore } = await import("../mail/store.ts");
|
|
313
|
+
const mailStore = createMailStore(mailDbPath);
|
|
314
|
+
try {
|
|
315
|
+
mailStore.insert({
|
|
316
|
+
id: "",
|
|
317
|
+
from: "system",
|
|
318
|
+
to: "coordinator",
|
|
319
|
+
subject: `Group auto-closed: ${group.name}`,
|
|
320
|
+
body: `Task group ${group.id} ("${group.name}") completed. All ${total} member issues are closed.`,
|
|
321
|
+
type: "status",
|
|
322
|
+
priority: "normal",
|
|
323
|
+
threadId: null,
|
|
324
|
+
});
|
|
325
|
+
} finally {
|
|
326
|
+
mailStore.close();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
// Non-fatal: mail notification is best-effort
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return { group, total, completed, inProgress, blocked, open };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Print a group's progress in human-readable format.
|
|
339
|
+
*/
|
|
340
|
+
function printGroupProgress(progress: TaskGroupProgress): void {
|
|
341
|
+
const w = process.stdout.write.bind(process.stdout);
|
|
342
|
+
const { group, total, completed, inProgress, blocked, open } = progress;
|
|
343
|
+
const status = group.status === "completed" ? "[completed]" : "[active]";
|
|
344
|
+
w(`${group.name} (${group.id}) ${status}\n`);
|
|
345
|
+
w(` Issues: ${total} total`);
|
|
346
|
+
w(` | ${completed} completed`);
|
|
347
|
+
w(` | ${inProgress} in_progress`);
|
|
348
|
+
w(` | ${blocked} blocked`);
|
|
349
|
+
w(` | ${open} open\n`);
|
|
350
|
+
if (group.status === "completed" && group.completedAt) {
|
|
351
|
+
w(` Completed: ${group.completedAt}\n`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const GROUP_HELP = `legio group -- Manage task groups for batch coordination
|
|
356
|
+
|
|
357
|
+
Usage: legio group <subcommand> [args...]
|
|
358
|
+
|
|
359
|
+
Subcommands:
|
|
360
|
+
create '<name>' <id1> [id2...] Create a new task group
|
|
361
|
+
status [group-id] Show progress for one or all groups
|
|
362
|
+
add <group-id> <id1> [id2...] Add issues to a group
|
|
363
|
+
remove <group-id> <id1> [id2...] Remove issues from a group
|
|
364
|
+
list List all groups (summary)
|
|
365
|
+
|
|
366
|
+
Options:
|
|
367
|
+
--json Output as JSON
|
|
368
|
+
--skip-validation Skip beads issue validation (for offline use)
|
|
369
|
+
--help, -h Show this help`;
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Entry point for `legio group <subcommand>`.
|
|
373
|
+
*/
|
|
374
|
+
export async function groupCommand(args: string[]): Promise<void> {
|
|
375
|
+
if (args.includes("--help") || args.includes("-h") || args.length === 0) {
|
|
376
|
+
process.stdout.write(`${GROUP_HELP}\n`);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const subcommand = args[0];
|
|
381
|
+
const subArgs = args.slice(1);
|
|
382
|
+
const json = subArgs.includes("--json");
|
|
383
|
+
const skipValidation = subArgs.includes("--skip-validation");
|
|
384
|
+
|
|
385
|
+
const { resolveProjectRoot } = await import("../config.ts");
|
|
386
|
+
const projectRoot = await resolveProjectRoot(process.cwd());
|
|
387
|
+
|
|
388
|
+
switch (subcommand) {
|
|
389
|
+
case "create": {
|
|
390
|
+
const positional = getPositionalArgs(subArgs);
|
|
391
|
+
const name = positional[0];
|
|
392
|
+
if (!name || name.trim().length === 0) {
|
|
393
|
+
throw new ValidationError(
|
|
394
|
+
"Group name is required: legio group create '<name>' <id1> [id2...]",
|
|
395
|
+
{ field: "name" },
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
const issueIds = positional.slice(1);
|
|
399
|
+
if (issueIds.length === 0) {
|
|
400
|
+
throw new ValidationError(
|
|
401
|
+
"At least one issue ID is required: legio group create '<name>' <id1> [id2...]",
|
|
402
|
+
{ field: "issueIds" },
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
const group = await createGroup(projectRoot, name, issueIds, skipValidation);
|
|
406
|
+
if (json) {
|
|
407
|
+
process.stdout.write(`${JSON.stringify(group, null, "\t")}\n`);
|
|
408
|
+
} else {
|
|
409
|
+
process.stdout.write(`Created group "${group.name}" (${group.id})\n`);
|
|
410
|
+
process.stdout.write(` Members: ${group.memberIssueIds.join(", ")}\n`);
|
|
411
|
+
}
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
case "status": {
|
|
416
|
+
const positional = getPositionalArgs(subArgs);
|
|
417
|
+
const groupId = positional[0];
|
|
418
|
+
const groups = await loadGroups(projectRoot);
|
|
419
|
+
|
|
420
|
+
if (groupId) {
|
|
421
|
+
const group = groups.find((g) => g.id === groupId);
|
|
422
|
+
if (!group) {
|
|
423
|
+
throw new GroupError(`Group "${groupId}" not found`, { groupId });
|
|
424
|
+
}
|
|
425
|
+
const progress = await getGroupProgress(projectRoot, group, groups);
|
|
426
|
+
if (json) {
|
|
427
|
+
process.stdout.write(`${JSON.stringify(progress, null, "\t")}\n`);
|
|
428
|
+
} else {
|
|
429
|
+
printGroupProgress(progress);
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
const activeGroups = groups.filter((g) => g.status === "active");
|
|
433
|
+
if (activeGroups.length === 0) {
|
|
434
|
+
if (json) {
|
|
435
|
+
process.stdout.write("[]\n");
|
|
436
|
+
} else {
|
|
437
|
+
process.stdout.write("No active groups\n");
|
|
438
|
+
}
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
const progressList: TaskGroupProgress[] = [];
|
|
442
|
+
for (const group of activeGroups) {
|
|
443
|
+
const progress = await getGroupProgress(projectRoot, group, groups);
|
|
444
|
+
progressList.push(progress);
|
|
445
|
+
}
|
|
446
|
+
if (json) {
|
|
447
|
+
process.stdout.write(`${JSON.stringify(progressList, null, "\t")}\n`);
|
|
448
|
+
} else {
|
|
449
|
+
for (const progress of progressList) {
|
|
450
|
+
printGroupProgress(progress);
|
|
451
|
+
process.stdout.write("\n");
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
case "add": {
|
|
459
|
+
const positional = getPositionalArgs(subArgs);
|
|
460
|
+
const groupId = positional[0];
|
|
461
|
+
if (!groupId || groupId.trim().length === 0) {
|
|
462
|
+
throw new ValidationError(
|
|
463
|
+
"Group ID is required: legio group add <group-id> <id1> [id2...]",
|
|
464
|
+
{ field: "groupId" },
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
const issueIds = positional.slice(1);
|
|
468
|
+
if (issueIds.length === 0) {
|
|
469
|
+
throw new ValidationError(
|
|
470
|
+
"At least one issue ID is required: legio group add <group-id> <id1> [id2...]",
|
|
471
|
+
{ field: "issueIds" },
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
const group = await addToGroup(projectRoot, groupId, issueIds, skipValidation);
|
|
475
|
+
if (json) {
|
|
476
|
+
process.stdout.write(`${JSON.stringify(group, null, "\t")}\n`);
|
|
477
|
+
} else {
|
|
478
|
+
process.stdout.write(`Added ${issueIds.length} issue(s) to "${group.name}"\n`);
|
|
479
|
+
process.stdout.write(` Members: ${group.memberIssueIds.join(", ")}\n`);
|
|
480
|
+
}
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
case "remove": {
|
|
485
|
+
const positional = getPositionalArgs(subArgs);
|
|
486
|
+
const groupId = positional[0];
|
|
487
|
+
if (!groupId || groupId.trim().length === 0) {
|
|
488
|
+
throw new ValidationError(
|
|
489
|
+
"Group ID is required: legio group remove <group-id> <id1> [id2...]",
|
|
490
|
+
{ field: "groupId" },
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
const issueIds = positional.slice(1);
|
|
494
|
+
if (issueIds.length === 0) {
|
|
495
|
+
throw new ValidationError(
|
|
496
|
+
"At least one issue ID is required: legio group remove <group-id> <id1> [id2...]",
|
|
497
|
+
{ field: "issueIds" },
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
const group = await removeFromGroup(projectRoot, groupId, issueIds);
|
|
501
|
+
if (json) {
|
|
502
|
+
process.stdout.write(`${JSON.stringify(group, null, "\t")}\n`);
|
|
503
|
+
} else {
|
|
504
|
+
process.stdout.write(`Removed ${issueIds.length} issue(s) from "${group.name}"\n`);
|
|
505
|
+
process.stdout.write(` Members: ${group.memberIssueIds.join(", ")}\n`);
|
|
506
|
+
}
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
case "list": {
|
|
511
|
+
const groups = await loadGroups(projectRoot);
|
|
512
|
+
if (groups.length === 0) {
|
|
513
|
+
if (json) {
|
|
514
|
+
process.stdout.write("[]\n");
|
|
515
|
+
} else {
|
|
516
|
+
process.stdout.write("No groups\n");
|
|
517
|
+
}
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
if (json) {
|
|
521
|
+
process.stdout.write(`${JSON.stringify(groups, null, "\t")}\n`);
|
|
522
|
+
} else {
|
|
523
|
+
for (const group of groups) {
|
|
524
|
+
const status = group.status === "completed" ? "[completed]" : "[active]";
|
|
525
|
+
process.stdout.write(
|
|
526
|
+
`${group.id} ${status} "${group.name}" (${group.memberIssueIds.length} issues)\n`,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
default:
|
|
534
|
+
throw new ValidationError(
|
|
535
|
+
`Unknown group subcommand: ${subcommand}. Run 'legio group --help' for usage.`,
|
|
536
|
+
{ field: "subcommand", value: subcommand },
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
}
|