@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.
Files changed (219) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +555 -0
  4. package/agents/builder.md +141 -0
  5. package/agents/coordinator.md +351 -0
  6. package/agents/cto.md +196 -0
  7. package/agents/gateway.md +276 -0
  8. package/agents/lead.md +281 -0
  9. package/agents/merger.md +156 -0
  10. package/agents/monitor.md +212 -0
  11. package/agents/reviewer.md +142 -0
  12. package/agents/scout.md +131 -0
  13. package/agents/supervisor.md +416 -0
  14. package/bin/legio.mjs +38 -0
  15. package/package.json +77 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +102 -0
  18. package/src/agents/hooks-deployer.test.ts +1820 -0
  19. package/src/agents/hooks-deployer.ts +574 -0
  20. package/src/agents/identity.test.ts +614 -0
  21. package/src/agents/identity.ts +385 -0
  22. package/src/agents/lifecycle.test.ts +202 -0
  23. package/src/agents/lifecycle.ts +184 -0
  24. package/src/agents/manifest.test.ts +558 -0
  25. package/src/agents/manifest.ts +297 -0
  26. package/src/agents/overlay.test.ts +592 -0
  27. package/src/agents/overlay.ts +316 -0
  28. package/src/beads/client.test.ts +210 -0
  29. package/src/beads/client.ts +227 -0
  30. package/src/beads/molecules.test.ts +320 -0
  31. package/src/beads/molecules.ts +209 -0
  32. package/src/commands/agents.test.ts +325 -0
  33. package/src/commands/agents.ts +286 -0
  34. package/src/commands/clean.test.ts +730 -0
  35. package/src/commands/clean.ts +653 -0
  36. package/src/commands/completions.test.ts +346 -0
  37. package/src/commands/completions.ts +950 -0
  38. package/src/commands/coordinator.test.ts +1524 -0
  39. package/src/commands/coordinator.ts +880 -0
  40. package/src/commands/costs.test.ts +1015 -0
  41. package/src/commands/costs.ts +473 -0
  42. package/src/commands/dashboard.test.ts +94 -0
  43. package/src/commands/dashboard.ts +607 -0
  44. package/src/commands/doctor.test.ts +295 -0
  45. package/src/commands/doctor.ts +213 -0
  46. package/src/commands/down.test.ts +308 -0
  47. package/src/commands/down.ts +124 -0
  48. package/src/commands/errors.test.ts +648 -0
  49. package/src/commands/errors.ts +255 -0
  50. package/src/commands/feed.test.ts +579 -0
  51. package/src/commands/feed.ts +368 -0
  52. package/src/commands/gateway.test.ts +698 -0
  53. package/src/commands/gateway.ts +419 -0
  54. package/src/commands/group.test.ts +262 -0
  55. package/src/commands/group.ts +539 -0
  56. package/src/commands/hooks.test.ts +292 -0
  57. package/src/commands/hooks.ts +210 -0
  58. package/src/commands/init.test.ts +211 -0
  59. package/src/commands/init.ts +622 -0
  60. package/src/commands/inspect.test.ts +670 -0
  61. package/src/commands/inspect.ts +455 -0
  62. package/src/commands/log.test.ts +1556 -0
  63. package/src/commands/log.ts +752 -0
  64. package/src/commands/logs.test.ts +379 -0
  65. package/src/commands/logs.ts +544 -0
  66. package/src/commands/mail.test.ts +1726 -0
  67. package/src/commands/mail.ts +926 -0
  68. package/src/commands/merge.test.ts +676 -0
  69. package/src/commands/merge.ts +374 -0
  70. package/src/commands/metrics.test.ts +444 -0
  71. package/src/commands/metrics.ts +150 -0
  72. package/src/commands/monitor.test.ts +151 -0
  73. package/src/commands/monitor.ts +394 -0
  74. package/src/commands/nudge.test.ts +230 -0
  75. package/src/commands/nudge.ts +373 -0
  76. package/src/commands/prime.test.ts +467 -0
  77. package/src/commands/prime.ts +386 -0
  78. package/src/commands/replay.test.ts +742 -0
  79. package/src/commands/replay.ts +367 -0
  80. package/src/commands/run.test.ts +443 -0
  81. package/src/commands/run.ts +365 -0
  82. package/src/commands/server.test.ts +626 -0
  83. package/src/commands/server.ts +298 -0
  84. package/src/commands/sling.test.ts +810 -0
  85. package/src/commands/sling.ts +700 -0
  86. package/src/commands/spec.test.ts +206 -0
  87. package/src/commands/spec.ts +171 -0
  88. package/src/commands/status.test.ts +276 -0
  89. package/src/commands/status.ts +339 -0
  90. package/src/commands/stop.test.ts +357 -0
  91. package/src/commands/stop.ts +119 -0
  92. package/src/commands/supervisor.test.ts +186 -0
  93. package/src/commands/supervisor.ts +544 -0
  94. package/src/commands/trace.test.ts +746 -0
  95. package/src/commands/trace.ts +332 -0
  96. package/src/commands/up.test.ts +597 -0
  97. package/src/commands/up.ts +275 -0
  98. package/src/commands/watch.test.ts +152 -0
  99. package/src/commands/watch.ts +238 -0
  100. package/src/commands/worktree.test.ts +648 -0
  101. package/src/commands/worktree.ts +266 -0
  102. package/src/config.test.ts +496 -0
  103. package/src/config.ts +616 -0
  104. package/src/doctor/agents.test.ts +448 -0
  105. package/src/doctor/agents.ts +396 -0
  106. package/src/doctor/config-check.test.ts +184 -0
  107. package/src/doctor/config-check.ts +185 -0
  108. package/src/doctor/consistency.test.ts +645 -0
  109. package/src/doctor/consistency.ts +294 -0
  110. package/src/doctor/databases.test.ts +284 -0
  111. package/src/doctor/databases.ts +211 -0
  112. package/src/doctor/dependencies.test.ts +150 -0
  113. package/src/doctor/dependencies.ts +179 -0
  114. package/src/doctor/logs.test.ts +244 -0
  115. package/src/doctor/logs.ts +295 -0
  116. package/src/doctor/merge-queue.test.ts +210 -0
  117. package/src/doctor/merge-queue.ts +144 -0
  118. package/src/doctor/structure.test.ts +285 -0
  119. package/src/doctor/structure.ts +195 -0
  120. package/src/doctor/types.ts +37 -0
  121. package/src/doctor/version.test.ts +130 -0
  122. package/src/doctor/version.ts +131 -0
  123. package/src/e2e/chat-flow.test.ts +346 -0
  124. package/src/e2e/init-sling-lifecycle.test.ts +288 -0
  125. package/src/errors.test.ts +21 -0
  126. package/src/errors.ts +246 -0
  127. package/src/events/store.test.ts +660 -0
  128. package/src/events/store.ts +344 -0
  129. package/src/events/tool-filter.test.ts +330 -0
  130. package/src/events/tool-filter.ts +126 -0
  131. package/src/global-setup.ts +14 -0
  132. package/src/index.ts +339 -0
  133. package/src/insights/analyzer.test.ts +466 -0
  134. package/src/insights/analyzer.ts +203 -0
  135. package/src/logging/color.test.ts +118 -0
  136. package/src/logging/color.ts +71 -0
  137. package/src/logging/logger.test.ts +812 -0
  138. package/src/logging/logger.ts +266 -0
  139. package/src/logging/reporter.test.ts +258 -0
  140. package/src/logging/reporter.ts +109 -0
  141. package/src/logging/sanitizer.test.ts +190 -0
  142. package/src/logging/sanitizer.ts +57 -0
  143. package/src/mail/broadcast.test.ts +203 -0
  144. package/src/mail/broadcast.ts +92 -0
  145. package/src/mail/client.test.ts +873 -0
  146. package/src/mail/client.ts +236 -0
  147. package/src/mail/store.test.ts +815 -0
  148. package/src/mail/store.ts +402 -0
  149. package/src/merge/queue.test.ts +449 -0
  150. package/src/merge/queue.ts +262 -0
  151. package/src/merge/resolver.test.ts +1453 -0
  152. package/src/merge/resolver.ts +759 -0
  153. package/src/metrics/store.test.ts +1167 -0
  154. package/src/metrics/store.ts +511 -0
  155. package/src/metrics/summary.test.ts +397 -0
  156. package/src/metrics/summary.ts +178 -0
  157. package/src/metrics/transcript.test.ts +643 -0
  158. package/src/metrics/transcript.ts +351 -0
  159. package/src/mulch/client.test.ts +547 -0
  160. package/src/mulch/client.ts +416 -0
  161. package/src/server/audit-store.test.ts +384 -0
  162. package/src/server/audit-store.ts +257 -0
  163. package/src/server/headless.test.ts +180 -0
  164. package/src/server/headless.ts +151 -0
  165. package/src/server/index.test.ts +241 -0
  166. package/src/server/index.ts +317 -0
  167. package/src/server/public/app.js +187 -0
  168. package/src/server/public/apple-touch-icon.png +0 -0
  169. package/src/server/public/components/agent-badge.js +37 -0
  170. package/src/server/public/components/data-table.js +114 -0
  171. package/src/server/public/components/gateway-chat.js +256 -0
  172. package/src/server/public/components/issue-card.js +96 -0
  173. package/src/server/public/components/layout.js +88 -0
  174. package/src/server/public/components/message-bubble.js +120 -0
  175. package/src/server/public/components/stat-card.js +26 -0
  176. package/src/server/public/components/terminal-panel.js +140 -0
  177. package/src/server/public/favicon-16.png +0 -0
  178. package/src/server/public/favicon-32.png +0 -0
  179. package/src/server/public/favicon.ico +0 -0
  180. package/src/server/public/favicon.png +0 -0
  181. package/src/server/public/index.html +64 -0
  182. package/src/server/public/lib/api.js +35 -0
  183. package/src/server/public/lib/markdown.js +8 -0
  184. package/src/server/public/lib/preact-setup.js +8 -0
  185. package/src/server/public/lib/state.js +99 -0
  186. package/src/server/public/lib/utils.js +309 -0
  187. package/src/server/public/lib/ws.js +79 -0
  188. package/src/server/public/views/chat.js +983 -0
  189. package/src/server/public/views/costs.js +692 -0
  190. package/src/server/public/views/dashboard.js +781 -0
  191. package/src/server/public/views/gateway-chat.js +622 -0
  192. package/src/server/public/views/inspect.js +399 -0
  193. package/src/server/public/views/issues.js +470 -0
  194. package/src/server/public/views/setup.js +94 -0
  195. package/src/server/public/views/task-detail.js +422 -0
  196. package/src/server/routes.test.ts +3816 -0
  197. package/src/server/routes.ts +1964 -0
  198. package/src/server/websocket.test.ts +288 -0
  199. package/src/server/websocket.ts +196 -0
  200. package/src/sessions/compat.test.ts +109 -0
  201. package/src/sessions/compat.ts +17 -0
  202. package/src/sessions/store.test.ts +969 -0
  203. package/src/sessions/store.ts +480 -0
  204. package/src/test-helpers.test.ts +97 -0
  205. package/src/test-helpers.ts +143 -0
  206. package/src/types.ts +708 -0
  207. package/src/watchdog/daemon.test.ts +1233 -0
  208. package/src/watchdog/daemon.ts +533 -0
  209. package/src/watchdog/health.test.ts +371 -0
  210. package/src/watchdog/health.ts +248 -0
  211. package/src/watchdog/triage.test.ts +162 -0
  212. package/src/watchdog/triage.ts +193 -0
  213. package/src/worktree/manager.test.ts +444 -0
  214. package/src/worktree/manager.ts +224 -0
  215. package/src/worktree/tmux.test.ts +1238 -0
  216. package/src/worktree/tmux.ts +644 -0
  217. package/templates/CLAUDE.md.tmpl +89 -0
  218. package/templates/hooks.json.tmpl +132 -0
  219. package/templates/overlay.md.tmpl +79 -0
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Tests for the `legio spec` command.
3
+ *
4
+ * Uses real filesystem (temp dirs) for all tests. No mocks.
5
+ * Philosophy: "never mock what you can use for real" (mx-252b16).
6
+ */
7
+
8
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
11
+ import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
12
+ import { specCommand, writeSpec } from "./spec.ts";
13
+
14
+ let tempDir: string;
15
+ let legioDir: string;
16
+ let originalCwd: string;
17
+ let stdoutOutput: string;
18
+ let _stderrOutput: string;
19
+ let originalStdoutWrite: typeof process.stdout.write;
20
+ let originalStderrWrite: typeof process.stderr.write;
21
+ let originalIsTTY: boolean | undefined;
22
+
23
+ beforeEach(async () => {
24
+ // Force stdin.isTTY so readStdin() returns immediately instead of blocking
25
+ originalIsTTY = process.stdin.isTTY;
26
+ process.stdin.isTTY = true;
27
+ tempDir = await createTempGitRepo();
28
+ legioDir = join(tempDir, ".legio");
29
+ await mkdir(legioDir, { recursive: true });
30
+
31
+ // Write minimal config.yaml so resolveProjectRoot works
32
+ await writeFile(
33
+ join(legioDir, "config.yaml"),
34
+ `project:\n name: test-project\n root: ${tempDir}\n canonicalBranch: main\n`,
35
+ );
36
+
37
+ originalCwd = process.cwd();
38
+ process.chdir(tempDir);
39
+
40
+ // Capture stdout/stderr
41
+ stdoutOutput = "";
42
+ _stderrOutput = "";
43
+ originalStdoutWrite = process.stdout.write;
44
+ originalStderrWrite = process.stderr.write;
45
+ process.stdout.write = ((chunk: string) => {
46
+ stdoutOutput += chunk;
47
+ return true;
48
+ }) as typeof process.stdout.write;
49
+ process.stderr.write = ((chunk: string) => {
50
+ _stderrOutput += chunk;
51
+ return true;
52
+ }) as typeof process.stderr.write;
53
+ });
54
+
55
+ afterEach(async () => {
56
+ process.stdin.isTTY = originalIsTTY as true;
57
+ process.chdir(originalCwd);
58
+ process.stdout.write = originalStdoutWrite;
59
+ process.stderr.write = originalStderrWrite;
60
+ await cleanupTempDir(tempDir);
61
+ });
62
+
63
+ // === help ===
64
+
65
+ describe("help", () => {
66
+ test("--help shows usage", async () => {
67
+ await specCommand(["--help"]);
68
+ expect(stdoutOutput).toContain("legio spec");
69
+ expect(stdoutOutput).toContain("write");
70
+ expect(stdoutOutput).toContain("--body");
71
+ expect(stdoutOutput).toContain("--agent");
72
+ });
73
+
74
+ test("-h shows usage", async () => {
75
+ await specCommand(["-h"]);
76
+ expect(stdoutOutput).toContain("legio spec");
77
+ });
78
+
79
+ test("no args shows help", async () => {
80
+ await specCommand([]);
81
+ expect(stdoutOutput).toContain("legio spec");
82
+ });
83
+ });
84
+
85
+ // === validation ===
86
+
87
+ describe("validation", () => {
88
+ test("unknown subcommand throws ValidationError", async () => {
89
+ await expect(specCommand(["unknown"])).rejects.toThrow("Unknown spec subcommand");
90
+ });
91
+
92
+ test("write without bead-id throws ValidationError", async () => {
93
+ await expect(specCommand(["write"])).rejects.toThrow("Bead ID is required");
94
+ });
95
+
96
+ test("write without body throws ValidationError", async () => {
97
+ await expect(specCommand(["write", "task-abc", "--agent", "scout-1"])).rejects.toThrow(
98
+ "Spec body is required",
99
+ );
100
+ });
101
+
102
+ test("write with empty body throws ValidationError", async () => {
103
+ await expect(specCommand(["write", "task-abc", "--body", " "])).rejects.toThrow(
104
+ "Spec body is required",
105
+ );
106
+ });
107
+ });
108
+
109
+ // === writeSpec (core function) ===
110
+
111
+ describe("writeSpec", () => {
112
+ test("writes spec file to .legio/specs/<bead-id>.md", async () => {
113
+ const specPath = await writeSpec(tempDir, "task-abc", "# My Spec\n\nDetails here.");
114
+
115
+ expect(specPath).toBe(join(tempDir, ".legio", "specs", "task-abc.md"));
116
+
117
+ const content = await readFile(specPath, "utf-8");
118
+ expect(content).toBe("# My Spec\n\nDetails here.\n");
119
+ });
120
+
121
+ test("creates specs directory if it does not exist", async () => {
122
+ // Verify specs dir does not exist yet (no .gitkeep check needed)
123
+ await writeSpec(tempDir, "task-xyz", "content");
124
+
125
+ const specsDir = join(legioDir, "specs");
126
+ const content = await readFile(join(specsDir, "task-xyz.md"), "utf-8");
127
+ expect(content).toBe("content\n");
128
+ });
129
+
130
+ test("adds attribution header when agent is provided", async () => {
131
+ const specPath = await writeSpec(tempDir, "task-123", "# Spec body", "scout-1");
132
+
133
+ const content = await readFile(specPath, "utf-8");
134
+ expect(content).toContain("<!-- written-by: scout-1 -->");
135
+ expect(content).toContain("# Spec body");
136
+ });
137
+
138
+ test("does not add attribution header when agent is omitted", async () => {
139
+ const specPath = await writeSpec(tempDir, "task-456", "# Spec body");
140
+
141
+ const content = await readFile(specPath, "utf-8");
142
+ expect(content).not.toContain("written-by");
143
+ expect(content).toBe("# Spec body\n");
144
+ });
145
+
146
+ test("ensures trailing newline", async () => {
147
+ const specPath = await writeSpec(tempDir, "task-nl", "no newline at end");
148
+
149
+ const content = await readFile(specPath, "utf-8");
150
+ expect(content.endsWith("\n")).toBe(true);
151
+ });
152
+
153
+ test("does not double trailing newline", async () => {
154
+ const specPath = await writeSpec(tempDir, "task-nl2", "already has newline\n");
155
+
156
+ const content = await readFile(specPath, "utf-8");
157
+ expect(content).toBe("already has newline\n");
158
+ expect(content.endsWith("\n\n")).toBe(false);
159
+ });
160
+
161
+ test("overwrites existing spec file", async () => {
162
+ await writeSpec(tempDir, "task-ow", "version 1");
163
+ await writeSpec(tempDir, "task-ow", "version 2");
164
+
165
+ const specPath = join(legioDir, "specs", "task-ow.md");
166
+ const content = await readFile(specPath, "utf-8");
167
+ expect(content).toBe("version 2\n");
168
+ });
169
+ });
170
+
171
+ // === specCommand (CLI integration) ===
172
+
173
+ describe("specCommand write", () => {
174
+ test("writes spec and prints path", async () => {
175
+ await specCommand(["write", "task-cmd", "--body", "# CLI Spec"]);
176
+
177
+ // Path may differ due to macOS /var -> /private/var symlink resolution
178
+ expect(stdoutOutput.trim()).toContain(".legio/specs/task-cmd.md");
179
+
180
+ const specPath = stdoutOutput.trim();
181
+ const content = await readFile(specPath, "utf-8");
182
+ expect(content).toBe("# CLI Spec\n");
183
+ });
184
+
185
+ test("writes spec with agent attribution", async () => {
186
+ await specCommand(["write", "task-attr", "--body", "# Attributed", "--agent", "scout-2"]);
187
+
188
+ expect(stdoutOutput.trim()).toContain(".legio/specs/task-attr.md");
189
+
190
+ const specPath = stdoutOutput.trim();
191
+ const content = await readFile(specPath, "utf-8");
192
+ expect(content).toContain("<!-- written-by: scout-2 -->");
193
+ expect(content).toContain("# Attributed");
194
+ });
195
+
196
+ test("flags can appear in any order", async () => {
197
+ await specCommand(["write", "--agent", "scout-3", "--body", "# Content", "task-order"]);
198
+
199
+ expect(stdoutOutput.trim()).toContain(".legio/specs/task-order.md");
200
+
201
+ const specPath = stdoutOutput.trim();
202
+ const content = await readFile(specPath, "utf-8");
203
+ expect(content).toContain("<!-- written-by: scout-3 -->");
204
+ expect(content).toContain("# Content");
205
+ });
206
+ });
@@ -0,0 +1,171 @@
1
+ /**
2
+ * CLI command: legio spec write <bead-id> --body <content>
3
+ *
4
+ * Writes a task specification to `.legio/specs/<bead-id>.md`.
5
+ * Scouts use this to persist spec documents as files instead of
6
+ * sending entire specs via mail messages.
7
+ *
8
+ * Supports reading body content from --body flag or stdin.
9
+ */
10
+
11
+ import { mkdir, writeFile } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { ValidationError } from "../errors.ts";
14
+
15
+ /** Boolean flags that do NOT consume the next arg. */
16
+ const BOOLEAN_FLAGS = new Set(["--help", "-h"]);
17
+
18
+ /**
19
+ * Parse a named flag value from args.
20
+ */
21
+ function getFlag(args: string[], flag: string): string | undefined {
22
+ const idx = args.indexOf(flag);
23
+ if (idx === -1 || idx + 1 >= args.length) {
24
+ return undefined;
25
+ }
26
+ return args[idx + 1];
27
+ }
28
+
29
+ /**
30
+ * Extract positional arguments, skipping flag-value pairs.
31
+ */
32
+ function getPositionalArgs(args: string[]): string[] {
33
+ const positional: string[] = [];
34
+ let i = 0;
35
+ while (i < args.length) {
36
+ const arg = args[i];
37
+ if (arg?.startsWith("-")) {
38
+ if (BOOLEAN_FLAGS.has(arg)) {
39
+ i += 1;
40
+ } else {
41
+ i += 2;
42
+ }
43
+ } else {
44
+ if (arg !== undefined) {
45
+ positional.push(arg);
46
+ }
47
+ i += 1;
48
+ }
49
+ }
50
+ return positional;
51
+ }
52
+
53
+ /**
54
+ * Read all of stdin as a string. Returns empty string if stdin is a TTY
55
+ * (no piped input).
56
+ */
57
+ async function readStdin(): Promise<string> {
58
+ if (process.stdin.isTTY) {
59
+ return "";
60
+ }
61
+ const chunks: Buffer[] = [];
62
+ for await (const chunk of process.stdin) {
63
+ chunks.push(Buffer.from(chunk as Buffer));
64
+ }
65
+ return Buffer.concat(chunks).toString("utf-8");
66
+ }
67
+
68
+ const SPEC_HELP = `legio spec -- Manage task specifications
69
+
70
+ Usage: legio spec <subcommand> [args...]
71
+
72
+ Subcommands:
73
+ write <bead-id> Write a spec file to .legio/specs/<bead-id>.md
74
+
75
+ Options for 'write':
76
+ --body <content> Spec content (or pipe via stdin)
77
+ --agent <name> Agent writing the spec (for attribution)
78
+ --help, -h Show this help
79
+
80
+ Examples:
81
+ legio spec write task-abc --body "# Spec\\nDetails here..."
82
+ echo "# Spec" | legio spec write task-abc
83
+ legio spec write task-abc --body "..." --agent scout-1`;
84
+
85
+ /**
86
+ * Write a spec file to .legio/specs/<bead-id>.md.
87
+ *
88
+ * Exported for direct use in tests.
89
+ */
90
+ export async function writeSpec(
91
+ projectRoot: string,
92
+ beadId: string,
93
+ body: string,
94
+ agent?: string,
95
+ ): Promise<string> {
96
+ const specsDir = join(projectRoot, ".legio", "specs");
97
+ await mkdir(specsDir, { recursive: true });
98
+
99
+ // Build the spec content with optional attribution header
100
+ let content = "";
101
+ if (agent) {
102
+ content += `<!-- written-by: ${agent} -->\n`;
103
+ }
104
+ content += body;
105
+
106
+ // Ensure trailing newline
107
+ if (!content.endsWith("\n")) {
108
+ content += "\n";
109
+ }
110
+
111
+ const specPath = join(specsDir, `${beadId}.md`);
112
+ await writeFile(specPath, content);
113
+
114
+ return specPath;
115
+ }
116
+
117
+ /**
118
+ * Entry point for `legio spec <subcommand>`.
119
+ */
120
+ export async function specCommand(args: string[]): Promise<void> {
121
+ if (args.includes("--help") || args.includes("-h") || args.length === 0) {
122
+ process.stdout.write(`${SPEC_HELP}\n`);
123
+ return;
124
+ }
125
+
126
+ const subcommand = args[0];
127
+ const subArgs = args.slice(1);
128
+
129
+ switch (subcommand) {
130
+ case "write": {
131
+ const positional = getPositionalArgs(subArgs);
132
+ const beadId = positional[0];
133
+ if (!beadId || beadId.trim().length === 0) {
134
+ throw new ValidationError(
135
+ "Bead ID is required: legio spec write <bead-id> --body <content>",
136
+ { field: "beadId" },
137
+ );
138
+ }
139
+
140
+ const agent = getFlag(subArgs, "--agent");
141
+ let body = getFlag(subArgs, "--body");
142
+
143
+ // If no --body flag, try reading from stdin
144
+ if (body === undefined) {
145
+ const stdinContent = await readStdin();
146
+ if (stdinContent.trim().length > 0) {
147
+ body = stdinContent;
148
+ }
149
+ }
150
+
151
+ if (body === undefined || body.trim().length === 0) {
152
+ throw new ValidationError("Spec body is required: use --body <content> or pipe via stdin", {
153
+ field: "body",
154
+ });
155
+ }
156
+
157
+ const { resolveProjectRoot } = await import("../config.ts");
158
+ const projectRoot = await resolveProjectRoot(process.cwd());
159
+
160
+ const specPath = await writeSpec(projectRoot, beadId, body, agent);
161
+ process.stdout.write(`${specPath}\n`);
162
+ break;
163
+ }
164
+
165
+ default:
166
+ throw new ValidationError(
167
+ `Unknown spec subcommand: ${subcommand}. Run 'legio spec --help' for usage.`,
168
+ { field: "subcommand", value: subcommand },
169
+ );
170
+ }
171
+ }
@@ -0,0 +1,276 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
5
+ import type { AgentSession } from "../types.ts";
6
+ import { printStatus, type StatusData, statusCommand, type VerboseAgentDetail } from "./status.ts";
7
+
8
+ /**
9
+ * Tests for the --verbose flag in legio status.
10
+ *
11
+ * printStatus is tested by capturing process.stdout.write output.
12
+ * We spy on stdout.write because printStatus uses it directly.
13
+ */
14
+
15
+ function makeAgent(overrides: Partial<AgentSession> = {}): AgentSession {
16
+ return {
17
+ id: "sess-001",
18
+ agentName: "test-builder",
19
+ capability: "builder",
20
+ worktreePath: "/tmp/worktrees/test-builder",
21
+ branchName: "legio/test-builder/task-1",
22
+ beadId: "task-1",
23
+ tmuxSession: "legio-test-builder",
24
+ state: "working",
25
+ pid: 12345,
26
+ parentAgent: null,
27
+ depth: 0,
28
+ runId: null,
29
+ startedAt: new Date(Date.now() - 60_000).toISOString(),
30
+ lastActivity: new Date().toISOString(),
31
+ escalationLevel: 0,
32
+ stalledSince: null,
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ function makeStatusData(overrides: Partial<StatusData> = {}): StatusData {
38
+ return {
39
+ agents: [makeAgent()],
40
+ worktrees: [],
41
+ tmuxSessions: [{ name: "legio-test-builder", pid: 12345 }],
42
+ unreadMailCount: 0,
43
+ mergeQueueCount: 0,
44
+ recentMetricsCount: 0,
45
+ ...overrides,
46
+ };
47
+ }
48
+
49
+ describe("printStatus", () => {
50
+ let chunks: string[];
51
+ let originalWrite: typeof process.stdout.write;
52
+
53
+ beforeEach(() => {
54
+ chunks = [];
55
+ originalWrite = process.stdout.write;
56
+ process.stdout.write = ((chunk: string) => {
57
+ chunks.push(chunk);
58
+ return true;
59
+ }) as typeof process.stdout.write;
60
+ });
61
+
62
+ afterEach(() => {
63
+ process.stdout.write = originalWrite;
64
+ });
65
+
66
+ function output(): string {
67
+ return chunks.join("");
68
+ }
69
+
70
+ test("non-verbose: does not show worktree path or logs dir", () => {
71
+ const data = makeStatusData();
72
+ printStatus(data);
73
+ const out = output();
74
+
75
+ expect(out).toContain("test-builder");
76
+ expect(out).toContain("[builder]");
77
+ expect(out).not.toContain("Worktree:");
78
+ expect(out).not.toContain("Logs:");
79
+ expect(out).not.toContain("Mail sent:");
80
+ });
81
+
82
+ test("verbose: shows worktree path, logs dir, and mail timestamps", () => {
83
+ const detail: VerboseAgentDetail = {
84
+ worktreePath: "/tmp/worktrees/test-builder",
85
+ logsDir: "/tmp/.legio/logs/test-builder",
86
+ lastMailSent: "2025-01-15T10:00:00.000Z",
87
+ lastMailReceived: "2025-01-15T10:05:00.000Z",
88
+ capability: "builder",
89
+ };
90
+
91
+ const data = makeStatusData({
92
+ verboseDetails: { "test-builder": detail },
93
+ });
94
+ printStatus(data);
95
+ const out = output();
96
+
97
+ expect(out).toContain("Worktree: /tmp/worktrees/test-builder");
98
+ expect(out).toContain("Logs: /tmp/.legio/logs/test-builder");
99
+ expect(out).toContain("Mail sent: 2025-01-15T10:00:00.000Z");
100
+ expect(out).toContain("received: 2025-01-15T10:05:00.000Z");
101
+ });
102
+
103
+ test("verbose: shows 'none' for null mail timestamps", () => {
104
+ const detail: VerboseAgentDetail = {
105
+ worktreePath: "/tmp/worktrees/test-builder",
106
+ logsDir: "/tmp/.legio/logs/test-builder",
107
+ lastMailSent: null,
108
+ lastMailReceived: null,
109
+ capability: "builder",
110
+ };
111
+
112
+ const data = makeStatusData({
113
+ verboseDetails: { "test-builder": detail },
114
+ });
115
+ printStatus(data);
116
+ const out = output();
117
+
118
+ expect(out).toContain("Mail sent: none");
119
+ expect(out).toContain("received: none");
120
+ });
121
+
122
+ test("verbose: zombie agents do not get verbose detail", () => {
123
+ const agent = makeAgent({ state: "zombie", agentName: "zombie-agent" });
124
+ const detail: VerboseAgentDetail = {
125
+ worktreePath: "/tmp/worktrees/zombie-agent",
126
+ logsDir: "/tmp/.legio/logs/zombie-agent",
127
+ lastMailSent: null,
128
+ lastMailReceived: null,
129
+ capability: "builder",
130
+ };
131
+
132
+ const data = makeStatusData({
133
+ agents: [agent],
134
+ verboseDetails: { "zombie-agent": detail },
135
+ });
136
+ printStatus(data);
137
+ const out = output();
138
+
139
+ // Zombie agents are filtered from the active list
140
+ expect(out).toContain("0 active");
141
+ expect(out).not.toContain("Worktree:");
142
+ });
143
+
144
+ test("verbose with multiple agents: each gets its own detail", () => {
145
+ const agent1 = makeAgent({ agentName: "builder-1", tmuxSession: "legio-builder-1" });
146
+ const agent2 = makeAgent({
147
+ agentName: "scout-1",
148
+ capability: "scout",
149
+ tmuxSession: "legio-scout-1",
150
+ });
151
+
152
+ const data = makeStatusData({
153
+ agents: [agent1, agent2],
154
+ tmuxSessions: [
155
+ { name: "legio-builder-1", pid: 100 },
156
+ { name: "legio-scout-1", pid: 200 },
157
+ ],
158
+ verboseDetails: {
159
+ "builder-1": {
160
+ worktreePath: "/tmp/wt/builder-1",
161
+ logsDir: "/tmp/logs/builder-1",
162
+ lastMailSent: "2025-01-15T10:00:00.000Z",
163
+ lastMailReceived: null,
164
+ capability: "builder",
165
+ },
166
+ "scout-1": {
167
+ worktreePath: "/tmp/wt/scout-1",
168
+ logsDir: "/tmp/logs/scout-1",
169
+ lastMailSent: null,
170
+ lastMailReceived: "2025-01-15T11:00:00.000Z",
171
+ capability: "scout",
172
+ },
173
+ },
174
+ });
175
+ printStatus(data);
176
+ const out = output();
177
+
178
+ expect(out).toContain("Worktree: /tmp/wt/builder-1");
179
+ expect(out).toContain("Worktree: /tmp/wt/scout-1");
180
+ expect(out).toContain("Logs: /tmp/logs/builder-1");
181
+ expect(out).toContain("Logs: /tmp/logs/scout-1");
182
+ });
183
+ });
184
+
185
+ describe("--verbose --json", () => {
186
+ test("verboseDetails is included in StatusData when present", () => {
187
+ const detail: VerboseAgentDetail = {
188
+ worktreePath: "/tmp/wt/agent",
189
+ logsDir: "/tmp/logs/agent",
190
+ lastMailSent: "2025-01-15T10:00:00.000Z",
191
+ lastMailReceived: null,
192
+ capability: "builder",
193
+ };
194
+
195
+ const data: StatusData = {
196
+ agents: [],
197
+ worktrees: [],
198
+ tmuxSessions: [],
199
+ unreadMailCount: 0,
200
+ mergeQueueCount: 0,
201
+ recentMetricsCount: 0,
202
+ verboseDetails: { agent: detail },
203
+ };
204
+
205
+ const json = JSON.parse(JSON.stringify(data)) as StatusData;
206
+ expect(json.verboseDetails).toBeDefined();
207
+ expect(json.verboseDetails?.agent?.worktreePath).toBe("/tmp/wt/agent");
208
+ expect(json.verboseDetails?.agent?.lastMailSent).toBe("2025-01-15T10:00:00.000Z");
209
+ expect(json.verboseDetails?.agent?.lastMailReceived).toBeNull();
210
+ });
211
+
212
+ test("verboseDetails is omitted from JSON when undefined", () => {
213
+ const data: StatusData = {
214
+ agents: [],
215
+ worktrees: [],
216
+ tmuxSessions: [],
217
+ unreadMailCount: 0,
218
+ mergeQueueCount: 0,
219
+ recentMetricsCount: 0,
220
+ };
221
+
222
+ const json = JSON.stringify(data);
223
+ expect(json).not.toContain("verboseDetails");
224
+ });
225
+ });
226
+
227
+ describe("--watch deprecation", () => {
228
+ test("help text marks --watch as deprecated", async () => {
229
+ const chunks: string[] = [];
230
+ const originalWrite = process.stdout.write;
231
+ process.stdout.write = ((chunk: string) => {
232
+ chunks.push(chunk);
233
+ return true;
234
+ }) as typeof process.stdout.write;
235
+
236
+ try {
237
+ await statusCommand(["--help"]);
238
+ } finally {
239
+ process.stdout.write = originalWrite;
240
+ }
241
+
242
+ const out = chunks.join("");
243
+ expect(out).toContain("deprecated");
244
+ expect(out).toContain("legio dashboard");
245
+ });
246
+
247
+ test("--watch writes deprecation notice to stderr", async () => {
248
+ const stderrChunks: string[] = [];
249
+ const originalStderr = process.stderr.write;
250
+ process.stderr.write = ((chunk: string) => {
251
+ stderrChunks.push(chunk);
252
+ return true;
253
+ }) as typeof process.stderr.write;
254
+
255
+ // statusCommand with --watch will fail at loadConfig (no .legio/)
256
+ // but the deprecation notice is written before that. We just verify
257
+ // the notice was emitted.
258
+ const tmpDir = await mkdtemp(join(tmpdir(), "status-deprecation-"));
259
+ const originalCwd = process.cwd();
260
+ process.chdir(tmpDir);
261
+
262
+ try {
263
+ await statusCommand(["--watch"]);
264
+ } catch {
265
+ // Expected: loadConfig fails without .legio/
266
+ } finally {
267
+ process.stderr.write = originalStderr;
268
+ process.chdir(originalCwd);
269
+ await rm(tmpDir, { recursive: true, force: true });
270
+ }
271
+
272
+ const err = stderrChunks.join("");
273
+ expect(err).toContain("--watch is deprecated");
274
+ expect(err).toContain("legio dashboard");
275
+ });
276
+ });