@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,346 @@
1
+ /**
2
+ * E2E tests for the chat flow: POST and GET routes for coordinator and agent chat.
3
+ *
4
+ * Tests:
5
+ * 1. POST /api/coordinator/chat stores message with from=human, to=coordinator, audience=human
6
+ * 2. POST /api/agents/:name/chat stores message in mail.db
7
+ * 3. GET /api/coordinator/chat/history returns bidirectional messages
8
+ * 4. GET /api/agents/:name/chat/history returns bidirectional messages
9
+ * 5. History excludes audience=agent messages
10
+ * 6. Limit param works
11
+ *
12
+ * Uses real SQLite databases in temp directories. No mocking of store logic.
13
+ * Mock setup mirrors routes.test.ts (see header there for rationale).
14
+ *
15
+ */
16
+
17
+ import { accessSync, constants as fsConstants, readFileSync } from "node:fs";
18
+ import { vi } from "vitest";
19
+
20
+ // Stub the global Bun object because production modules (e.g., config.ts) still use Bun APIs
21
+ // and have not yet been migrated to Node.js equivalents. This shim provides only the subset
22
+ // of the Bun API surface required by the code paths exercised by these chat tests.
23
+ vi.stubGlobal("Bun", {
24
+ file: (path: string) => ({
25
+ exists: async () => {
26
+ try {
27
+ accessSync(path, fsConstants.F_OK);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ },
33
+ text: async () => readFileSync(path, "utf-8"),
34
+ json: async () => JSON.parse(readFileSync(path, "utf-8")),
35
+ }),
36
+ spawn: () => {
37
+ throw new Error("Bun.spawn not available in Node.js/vitest environment");
38
+ },
39
+ sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
40
+ write: async (path: string, content: string) => {
41
+ const { writeFileSync } = await import("node:fs");
42
+ writeFileSync(path, content, "utf-8");
43
+ },
44
+ });
45
+
46
+ // Mock the beads client so tests can run without `bd` on PATH.
47
+ vi.mock("../beads/client.ts", () => ({
48
+ createBeadsClient: () => ({
49
+ ready: async () => [],
50
+ list: async () => [],
51
+ show: async (id: string) => {
52
+ throw new Error(`bd not available: ${id}`);
53
+ },
54
+ create: async () => "bead-test-001",
55
+ claim: async () => {},
56
+ close: async () => {},
57
+ }),
58
+ }));
59
+
60
+ // Mock tmux so tests don't interfere with real developer tmux sessions.
61
+ const { mockIsSessionAlive, mockSendKeys } = vi.hoisted(() => ({
62
+ mockIsSessionAlive: vi.fn().mockResolvedValue(true),
63
+ mockSendKeys: vi.fn().mockResolvedValue(undefined),
64
+ }));
65
+ vi.mock("../worktree/tmux.ts", () => ({
66
+ isSessionAlive: mockIsSessionAlive,
67
+ sendKeys: mockSendKeys,
68
+ }));
69
+
70
+ import { mkdir, rm, writeFile } from "node:fs/promises";
71
+ import { tmpdir } from "node:os";
72
+ import { join } from "node:path";
73
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
74
+ import { createMailStore } from "../mail/store.ts";
75
+ import { handleApiRequest } from "../server/routes.ts";
76
+ import { createSessionStore } from "../sessions/store.ts";
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Helpers
80
+ // ---------------------------------------------------------------------------
81
+
82
+ function makeRequest(path: string, query?: Record<string, string>): Request {
83
+ const url = new URL(`http://localhost${path}`);
84
+ if (query) {
85
+ for (const [k, v] of Object.entries(query)) {
86
+ url.searchParams.set(k, v);
87
+ }
88
+ }
89
+ return new Request(url.toString(), { method: "GET" });
90
+ }
91
+
92
+ function makePostRequest(path: string, body: unknown): Request {
93
+ return new Request(`http://localhost${path}`, {
94
+ method: "POST",
95
+ headers: { "Content-Type": "application/json" },
96
+ body: JSON.stringify(body),
97
+ });
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Test setup
102
+ // ---------------------------------------------------------------------------
103
+
104
+ let tempDir: string;
105
+ let legioDir: string;
106
+ let projectRoot: string;
107
+
108
+ beforeEach(async () => {
109
+ tempDir = await (async () => {
110
+ const base = join(tmpdir(), `chat-flow-test-${Date.now()}`);
111
+ await mkdir(base, { recursive: true });
112
+ return base;
113
+ })();
114
+ legioDir = join(tempDir, ".legio");
115
+ projectRoot = tempDir;
116
+ await mkdir(legioDir, { recursive: true });
117
+
118
+ // Write minimal config.yaml for loadConfig
119
+ await writeFile(
120
+ join(legioDir, "config.yaml"),
121
+ [
122
+ "project:",
123
+ " name: test",
124
+ " canonicalBranch: main",
125
+ "agents:",
126
+ " maxDepth: 2",
127
+ "coordinator:",
128
+ " model: claude-sonnet-4-6",
129
+ ].join("\n"),
130
+ );
131
+ });
132
+
133
+ afterEach(async () => {
134
+ await rm(tempDir, { recursive: true, force: true });
135
+ });
136
+
137
+ async function dispatch(path: string, query?: Record<string, string>): Promise<Response> {
138
+ return handleApiRequest(makeRequest(path, query), legioDir, projectRoot);
139
+ }
140
+
141
+ async function dispatchPost(path: string, body: unknown): Promise<Response> {
142
+ return handleApiRequest(makePostRequest(path, body), legioDir, projectRoot);
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Tests
147
+ // ---------------------------------------------------------------------------
148
+
149
+ describe("E2E: chat flow", () => {
150
+ it("POST /api/coordinator/chat stores message with from=human, to=coordinator, audience=human", async () => {
151
+ // Seed orchestrator-tmux.json so resolveTerminalSession returns a session name
152
+ await writeFile(
153
+ join(legioDir, "orchestrator-tmux.json"),
154
+ JSON.stringify({ tmuxSession: "legio-fake-orchestrator" }),
155
+ );
156
+
157
+ const res = await dispatchPost("/api/coordinator/chat", { text: "hello coordinator" });
158
+ expect(res.status).toBe(201);
159
+
160
+ const body = (await res.json()) as { from: string; to: string; audience: string; body: string };
161
+ expect(body.from).toBe("human");
162
+ expect(body.to).toBe("coordinator");
163
+ expect(body.audience).toBe("human");
164
+ expect(body.body).toBe("hello coordinator");
165
+
166
+ // Verify the message was persisted in mail.db
167
+ const mailStore = createMailStore(join(legioDir, "mail.db"));
168
+ const messages = mailStore.getAll({ from: "human", to: "coordinator" });
169
+ mailStore.close();
170
+ expect(messages.length).toBe(1);
171
+ expect(messages[0]?.audience).toBe("human");
172
+ expect(messages[0]?.body).toBe("hello coordinator");
173
+ });
174
+
175
+ it("POST /api/agents/:name/chat stores message in mail.db", async () => {
176
+ // Seed sessions.db with test-agent in working state
177
+ const sessStore = createSessionStore(join(legioDir, "sessions.db"));
178
+ const now = new Date().toISOString();
179
+ sessStore.upsert({
180
+ id: "sess-chat-001",
181
+ agentName: "test-agent",
182
+ capability: "builder",
183
+ worktreePath: "/tmp/wt/test-agent",
184
+ branchName: "legio/test-agent/task-1",
185
+ beadId: "task-1",
186
+ tmuxSession: "legio-fake-test-agent",
187
+ state: "working",
188
+ pid: 12345,
189
+ parentAgent: null,
190
+ depth: 1,
191
+ runId: "run-001",
192
+ startedAt: now,
193
+ lastActivity: now,
194
+ escalationLevel: 0,
195
+ stalledSince: null,
196
+ });
197
+ sessStore.close();
198
+
199
+ const res = await dispatchPost("/api/agents/test-agent/chat", { text: "hello agent" });
200
+ expect(res.status).toBe(201);
201
+
202
+ const body = (await res.json()) as { from: string; to: string; audience: string };
203
+ expect(body.from).toBe("human");
204
+ expect(body.to).toBe("test-agent");
205
+ expect(body.audience).toBe("human");
206
+
207
+ // Verify persisted in mail.db
208
+ const mailStore = createMailStore(join(legioDir, "mail.db"));
209
+ const messages = mailStore.getAll({ from: "human", to: "test-agent" });
210
+ mailStore.close();
211
+ expect(messages.length).toBe(1);
212
+ expect(messages[0]?.body).toBe("hello agent");
213
+ });
214
+
215
+ it("GET /api/coordinator/chat/history returns bidirectional messages", async () => {
216
+ // Seed both directions: human→coordinator and coordinator→human
217
+ const mailStore = createMailStore(join(legioDir, "mail.db"));
218
+ mailStore.insert({
219
+ id: "msg-human-to-coord",
220
+ from: "human",
221
+ to: "coordinator",
222
+ subject: "chat",
223
+ body: "user message",
224
+ type: "status",
225
+ priority: "normal",
226
+ threadId: null,
227
+ audience: "human",
228
+ });
229
+ mailStore.insert({
230
+ id: "msg-coord-to-human",
231
+ from: "coordinator",
232
+ to: "human",
233
+ subject: "response",
234
+ body: "coordinator reply",
235
+ type: "result",
236
+ priority: "normal",
237
+ threadId: null,
238
+ audience: "human",
239
+ });
240
+ mailStore.close();
241
+
242
+ const res = await dispatch("/api/coordinator/chat/history");
243
+ expect(res.status).toBe(200);
244
+
245
+ const messages = (await res.json()) as Array<{ from: string; to: string }>;
246
+ expect(messages.length).toBe(2);
247
+ expect(messages.some((m) => m.from === "human" && m.to === "coordinator")).toBe(true);
248
+ expect(messages.some((m) => m.from === "coordinator" && m.to === "human")).toBe(true);
249
+ });
250
+
251
+ it("GET /api/agents/:name/chat/history returns bidirectional messages", async () => {
252
+ // Seed both directions: human→agent and agent→human
253
+ const mailStore = createMailStore(join(legioDir, "mail.db"));
254
+ mailStore.insert({
255
+ id: "msg-human-to-agent",
256
+ from: "human",
257
+ to: "my-agent",
258
+ subject: "chat",
259
+ body: "user asks",
260
+ type: "status",
261
+ priority: "normal",
262
+ threadId: null,
263
+ audience: "human",
264
+ });
265
+ mailStore.insert({
266
+ id: "msg-agent-to-human",
267
+ from: "my-agent",
268
+ to: "human",
269
+ subject: "response",
270
+ body: "agent responds",
271
+ type: "result",
272
+ priority: "normal",
273
+ threadId: null,
274
+ audience: "human",
275
+ });
276
+ mailStore.close();
277
+
278
+ const res = await dispatch("/api/agents/my-agent/chat/history");
279
+ expect(res.status).toBe(200);
280
+
281
+ const messages = (await res.json()) as Array<{ from: string; to: string }>;
282
+ expect(messages.length).toBe(2);
283
+ expect(messages.some((m) => m.from === "human" && m.to === "my-agent")).toBe(true);
284
+ expect(messages.some((m) => m.from === "my-agent" && m.to === "human")).toBe(true);
285
+ });
286
+
287
+ it("GET /api/coordinator/chat/history excludes audience=agent messages", async () => {
288
+ const mailStore = createMailStore(join(legioDir, "mail.db"));
289
+ mailStore.insert({
290
+ id: "msg-human-visible",
291
+ from: "human",
292
+ to: "coordinator",
293
+ subject: "chat",
294
+ body: "visible message",
295
+ type: "status",
296
+ priority: "normal",
297
+ threadId: null,
298
+ audience: "human",
299
+ });
300
+ mailStore.insert({
301
+ id: "msg-agent-hidden",
302
+ from: "human",
303
+ to: "coordinator",
304
+ subject: "internal",
305
+ body: "agent-only message",
306
+ type: "status",
307
+ priority: "normal",
308
+ threadId: null,
309
+ audience: "agent",
310
+ });
311
+ mailStore.close();
312
+
313
+ const res = await dispatch("/api/coordinator/chat/history");
314
+ expect(res.status).toBe(200);
315
+
316
+ const messages = (await res.json()) as Array<{ id: string; audience: string }>;
317
+ // Only the human-audience message should appear
318
+ expect(messages.length).toBe(1);
319
+ expect(messages[0]?.id).toBe("msg-human-visible");
320
+ expect(messages.some((m) => m.id === "msg-agent-hidden")).toBe(false);
321
+ });
322
+
323
+ it("GET /api/coordinator/chat/history respects limit param", async () => {
324
+ const mailStore = createMailStore(join(legioDir, "mail.db"));
325
+ for (let i = 0; i < 5; i++) {
326
+ mailStore.insert({
327
+ id: `msg-limit-${i}`,
328
+ from: "human",
329
+ to: "coordinator",
330
+ subject: "chat",
331
+ body: `message ${i}`,
332
+ type: "status",
333
+ priority: "normal",
334
+ threadId: null,
335
+ audience: "human",
336
+ });
337
+ }
338
+ mailStore.close();
339
+
340
+ const res = await dispatch("/api/coordinator/chat/history", { limit: "2" });
341
+ expect(res.status).toBe(200);
342
+
343
+ const messages = (await res.json()) as unknown[];
344
+ expect(messages.length).toBe(2);
345
+ });
346
+ });
@@ -0,0 +1,288 @@
1
+ import { access, readdir, readFile, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
4
+
5
+ /** Test helper: check whether a file exists using Node.js fs/promises. */
6
+ async function fileExists(path: string): Promise<boolean> {
7
+ return access(path).then(
8
+ () => true,
9
+ () => false,
10
+ );
11
+ }
12
+
13
+ /** Test helper: read a file as UTF-8 text using Node.js fs/promises. */
14
+ function fileText(path: string): Promise<string> {
15
+ return readFile(path, "utf-8");
16
+ }
17
+
18
+ import { createManifestLoader } from "../agents/manifest.ts";
19
+ import { writeOverlay } from "../agents/overlay.ts";
20
+ import { initCommand } from "../commands/init.ts";
21
+ import { loadConfig } from "../config.ts";
22
+ import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
23
+ import type { OverlayConfig } from "../types.ts";
24
+
25
+ /**
26
+ * E2E test: init→sling lifecycle on a throwaway external project.
27
+ *
28
+ * Validates the "project-agnostic" promise by running legio init on a
29
+ * fresh temp git repo (NOT the legio repo itself), then verifying all
30
+ * artifacts, loading config + manifest via real APIs, and generating an overlay.
31
+ *
32
+ * Uses real filesystem and real git repos. No mocks.
33
+ * Suppresses stdout because initCommand prints status lines.
34
+ */
35
+
36
+ const EXPECTED_AGENT_DEFS = [
37
+ "builder.md",
38
+ "coordinator.md",
39
+ "cto.md",
40
+ "gateway.md",
41
+ "lead.md",
42
+ "merger.md",
43
+ "monitor.md",
44
+ "reviewer.md",
45
+ "scout.md",
46
+ "supervisor.md",
47
+ ];
48
+
49
+ describe("E2E: init→sling lifecycle on external project", () => {
50
+ let tempDir: string;
51
+ let originalCwd: string;
52
+ let originalWrite: typeof process.stdout.write;
53
+
54
+ beforeEach(async () => {
55
+ tempDir = await createTempGitRepo();
56
+ originalCwd = process.cwd();
57
+ process.chdir(tempDir);
58
+
59
+ // Suppress stdout noise from initCommand
60
+ originalWrite = process.stdout.write;
61
+ process.stdout.write = (() => true) as typeof process.stdout.write;
62
+ });
63
+
64
+ afterEach(async () => {
65
+ process.chdir(originalCwd);
66
+ process.stdout.write = originalWrite;
67
+ await cleanupTempDir(tempDir);
68
+ });
69
+
70
+ test("init creates all expected artifacts", async () => {
71
+ await initCommand([]);
72
+
73
+ const legioDir = join(tempDir, ".legio");
74
+
75
+ // config.yaml exists
76
+ expect(await fileExists(join(legioDir, "config.yaml"))).toBe(true);
77
+
78
+ // agent-manifest.json exists and is valid JSON
79
+ expect(await fileExists(join(legioDir, "agent-manifest.json"))).toBe(true);
80
+ const manifestText = await fileText(join(legioDir, "agent-manifest.json"));
81
+ const manifestJson = JSON.parse(manifestText);
82
+ expect(manifestJson).toBeDefined();
83
+ expect(manifestJson.version).toBe("1.0");
84
+ expect(typeof manifestJson.agents).toBe("object");
85
+
86
+ // hooks.json exists
87
+ expect(await fileExists(join(legioDir, "hooks.json"))).toBe(true);
88
+
89
+ // .gitignore exists
90
+ expect(await fileExists(join(legioDir, ".gitignore"))).toBe(true);
91
+
92
+ // agent-defs/ contains all 9 agent definition files
93
+ const agentDefsDir = join(legioDir, "agent-defs");
94
+ const agentDefFiles = (await readdir(agentDefsDir)).filter((f) => f.endsWith(".md")).sort();
95
+ expect(agentDefFiles).toEqual(EXPECTED_AGENT_DEFS);
96
+
97
+ // Required subdirectories exist
98
+ const expectedDirs = ["agents", "worktrees", "specs", "logs"];
99
+ for (const dirName of expectedDirs) {
100
+ const dirPath = join(legioDir, dirName);
101
+ const dirStat = await stat(dirPath);
102
+ expect(dirStat.isDirectory()).toBe(true);
103
+ }
104
+ });
105
+
106
+ test("loadConfig returns valid config pointing to temp dir", async () => {
107
+ await initCommand([]);
108
+
109
+ const config = await loadConfig(tempDir);
110
+
111
+ // project.root should point to the temp directory
112
+ expect(config.project.root).toBe(tempDir);
113
+
114
+ // agents.baseDir should be the relative path to agent-defs
115
+ expect(config.agents.baseDir).toBe(".legio/agent-defs");
116
+
117
+ // canonicalBranch should be detected (main for our test repos)
118
+ expect(config.project.canonicalBranch).toBeTruthy();
119
+
120
+ // name should be set (from dir basename or git remote)
121
+ expect(config.project.name).toBeTruthy();
122
+ });
123
+
124
+ test("manifest loads successfully with all 9 agents", async () => {
125
+ await initCommand([]);
126
+
127
+ const manifestPath = join(tempDir, ".legio", "agent-manifest.json");
128
+ const agentDefsDir = join(tempDir, ".legio", "agent-defs");
129
+ const loader = createManifestLoader(manifestPath, agentDefsDir);
130
+
131
+ const manifest = await loader.load();
132
+
133
+ // All 9 agents present
134
+ const agentNames = Object.keys(manifest.agents).sort();
135
+ expect(agentNames).toEqual([
136
+ "builder",
137
+ "coordinator",
138
+ "cto",
139
+ "lead",
140
+ "merger",
141
+ "monitor",
142
+ "reviewer",
143
+ "scout",
144
+ "supervisor",
145
+ ]);
146
+
147
+ // Each agent has a valid file reference
148
+ for (const [_name, def] of Object.entries(manifest.agents)) {
149
+ expect(def.file).toMatch(/\.md$/);
150
+ // Verify the referenced .md file actually exists
151
+ expect(await fileExists(join(agentDefsDir, def.file))).toBe(true);
152
+ }
153
+
154
+ // Validation returns no errors
155
+ const errors = loader.validate();
156
+ expect(errors).toEqual([]);
157
+ });
158
+
159
+ test("manifest capability index is consistent", async () => {
160
+ await initCommand([]);
161
+
162
+ const manifestPath = join(tempDir, ".legio", "agent-manifest.json");
163
+ const agentDefsDir = join(tempDir, ".legio", "agent-defs");
164
+ const loader = createManifestLoader(manifestPath, agentDefsDir);
165
+
166
+ const manifest = await loader.load();
167
+
168
+ // capabilityIndex should map capabilities to agent names
169
+ expect(Object.keys(manifest.capabilityIndex).length).toBeGreaterThan(0);
170
+
171
+ // Each capability in the index should reference agents that declare it
172
+ for (const [cap, names] of Object.entries(manifest.capabilityIndex)) {
173
+ for (const name of names) {
174
+ const agent = manifest.agents[name];
175
+ expect(agent).toBeDefined();
176
+ expect(agent?.capabilities).toContain(cap);
177
+ }
178
+ }
179
+ });
180
+
181
+ test("overlay generation works for external project", async () => {
182
+ await initCommand([]);
183
+
184
+ const agentDefsDir = join(tempDir, ".legio", "agent-defs");
185
+ const baseDefinition = await fileText(join(agentDefsDir, "builder.md"));
186
+
187
+ const overlayConfig: OverlayConfig = {
188
+ agentName: "test-agent",
189
+ beadId: "test-bead-001",
190
+ specPath: null,
191
+ branchName: "legio/test-agent/test-bead-001",
192
+ worktreePath: join(tempDir, ".legio", "worktrees", "test-agent"),
193
+ fileScope: [],
194
+ mulchDomains: [],
195
+ parentAgent: null,
196
+ depth: 0,
197
+ canSpawn: false,
198
+ capability: "builder",
199
+ baseDefinition,
200
+ };
201
+
202
+ // Write the overlay into a subdirectory of the temp dir (simulating a worktree)
203
+ const worktreePath = join(tempDir, ".legio", "worktrees", "test-agent");
204
+ const { mkdir } = await import("node:fs/promises");
205
+ await mkdir(worktreePath, { recursive: true });
206
+
207
+ await writeOverlay(worktreePath, overlayConfig, tempDir);
208
+
209
+ // Verify the overlay was written
210
+ const overlayPath = join(worktreePath, ".claude", "CLAUDE.md");
211
+ expect(await fileExists(overlayPath)).toBe(true);
212
+
213
+ const content = await fileText(overlayPath);
214
+
215
+ // Verify template placeholders were replaced
216
+ expect(content).toContain("test-agent");
217
+ expect(content).toContain("test-bead-001");
218
+ expect(content).toContain("legio/test-agent/test-bead-001");
219
+ expect(content).not.toContain("{{AGENT_NAME}}");
220
+ expect(content).not.toContain("{{BEAD_ID}}");
221
+ expect(content).not.toContain("{{BRANCH_NAME}}");
222
+ });
223
+
224
+ test("full init→config→manifest→overlay pipeline succeeds", async () => {
225
+ // This test validates the entire lifecycle in sequence:
226
+ // init → load config → load manifest → generate overlay
227
+
228
+ // Step 1: Init
229
+ await initCommand([]);
230
+
231
+ // Step 2: Load config
232
+ const config = await loadConfig(tempDir);
233
+ expect(config.project.root).toBe(tempDir);
234
+
235
+ // Step 3: Load manifest using config paths
236
+ const manifestPath = join(config.project.root, config.agents.manifestPath);
237
+ const agentDefsDir = join(config.project.root, config.agents.baseDir);
238
+ const loader = createManifestLoader(manifestPath, agentDefsDir);
239
+ await loader.load();
240
+
241
+ // Verify builder agent exists (the one we'll use for overlay)
242
+ const builder = loader.getAgent("builder");
243
+ expect(builder).toBeDefined();
244
+ expect(builder?.canSpawn).toBe(false);
245
+
246
+ // Verify lead agent can spawn
247
+ const lead = loader.getAgent("lead");
248
+ expect(lead).toBeDefined();
249
+ expect(lead?.canSpawn).toBe(true);
250
+
251
+ // Step 4: Generate overlay using a realistic config
252
+ const builderDef = await fileText(join(agentDefsDir, "builder.md"));
253
+ const overlayConfig: OverlayConfig = {
254
+ agentName: "lifecycle-builder",
255
+ beadId: "lifecycle-001",
256
+ specPath: join(tempDir, ".legio", "specs", "lifecycle-001.md"),
257
+ branchName: "legio/lifecycle-builder/lifecycle-001",
258
+ worktreePath: join(tempDir, ".legio", "worktrees", "lifecycle-builder"),
259
+ fileScope: ["src/main.ts", "src/utils.ts"],
260
+ mulchDomains: ["typescript"],
261
+ parentAgent: "orchestrator",
262
+ depth: 0,
263
+ canSpawn: false,
264
+ capability: "builder",
265
+ baseDefinition: builderDef,
266
+ };
267
+
268
+ const worktreePath = join(tempDir, ".legio", "worktrees", "lifecycle-builder");
269
+ const { mkdir } = await import("node:fs/promises");
270
+ await mkdir(worktreePath, { recursive: true });
271
+
272
+ await writeOverlay(worktreePath, overlayConfig, tempDir);
273
+
274
+ const overlayContent = await fileText(join(worktreePath, ".claude", "CLAUDE.md"));
275
+
276
+ // Verify all overlay fields rendered correctly
277
+ expect(overlayContent).toContain("lifecycle-builder");
278
+ expect(overlayContent).toContain("lifecycle-001");
279
+ expect(overlayContent).toContain("legio/lifecycle-builder/lifecycle-001");
280
+ expect(overlayContent).toContain("orchestrator");
281
+ expect(overlayContent).toContain("`src/main.ts`");
282
+ expect(overlayContent).toContain("`src/utils.ts`");
283
+ expect(overlayContent).toContain("mulch prime typescript");
284
+
285
+ // No unresolved placeholders
286
+ expect(overlayContent).not.toMatch(/\{\{[A-Z_]+\}\}/);
287
+ });
288
+ });
@@ -0,0 +1,21 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { isRunningAsRoot } from "./errors.ts";
3
+
4
+ describe("isRunningAsRoot", () => {
5
+ test("returns false in normal test environment (not root)", () => {
6
+ // This test runs as a regular user during development and CI.
7
+ // process.getuid() should return a non-zero UID.
8
+ if (typeof process.getuid === "function") {
9
+ expect(process.getuid()).not.toBe(0);
10
+ }
11
+ expect(isRunningAsRoot()).toBe(false);
12
+ });
13
+
14
+ test("returns false when process.getuid is not available (Windows)", () => {
15
+ // Simulate a platform without process.getuid by overriding it to undefined
16
+ const original = process.getuid;
17
+ (process as unknown as Record<string, unknown>).getuid = undefined;
18
+ expect(isRunningAsRoot()).toBe(false);
19
+ process.getuid = original;
20
+ });
21
+ });