@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,645 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
6
+ import { createSessionStore } from "../sessions/store.ts";
7
+ import type { LegioConfig } from "../types.ts";
8
+ import type { ConsistencyCheckDeps } from "./consistency.ts";
9
+ import { checkConsistency } from "./consistency.ts";
10
+
11
+ /**
12
+ * Mock tmux functions using dependency injection instead of mock.module().
13
+ * This avoids test isolation issues from module-level mocking.
14
+ */
15
+ const mockListSessions = vi.fn(() => Promise.resolve([] as Array<{ name: string; pid: number }>));
16
+ const mockIsProcessAlive = vi.fn((_pid: number) => true);
17
+
18
+ /**
19
+ * Create a minimal temp git repo for worktree tests.
20
+ */
21
+ function createTempGitRepo(): string {
22
+ const dir = mkdtempSync(join(tmpdir(), "legio-test-"));
23
+ const git = (args: string[]) => {
24
+ const result = spawnSync("git", args, { cwd: dir, stdio: ["ignore", "ignore", "pipe"] });
25
+ if (result.status !== 0) {
26
+ throw new Error(`git ${args.join(" ")} failed: ${result.stderr?.toString() ?? ""}`);
27
+ }
28
+ };
29
+
30
+ git(["init"]);
31
+ git(["config", "user.email", "test@test.com"]);
32
+ git(["config", "user.name", "Test User"]);
33
+ git(["config", "commit.gpgsign", "false"]);
34
+ writeFileSync(join(dir, "README.md"), "# Test Repo\n");
35
+ git(["add", "."]);
36
+ git(["commit", "-m", "Initial commit"]);
37
+
38
+ return dir;
39
+ }
40
+
41
+ /**
42
+ * Create a git worktree at the given path.
43
+ */
44
+ function createWorktree(repoRoot: string, worktreePath: string, branchName: string): void {
45
+ const result = spawnSync("git", ["worktree", "add", "-b", branchName, worktreePath, "HEAD"], {
46
+ cwd: repoRoot,
47
+ stdio: ["ignore", "ignore", "pipe"],
48
+ });
49
+ if (result.status !== 0) {
50
+ throw new Error(`Failed to create worktree: ${result.stderr?.toString() ?? ""}`);
51
+ }
52
+ }
53
+
54
+ describe("checkConsistency", () => {
55
+ let repoRoot: string;
56
+ let legioDir: string;
57
+ let config: LegioConfig;
58
+ let mockDeps: ConsistencyCheckDeps;
59
+
60
+ beforeEach(() => {
61
+ repoRoot = createTempGitRepo();
62
+ legioDir = join(repoRoot, ".legio");
63
+ mkdirSync(legioDir, { recursive: true });
64
+ mkdirSync(join(legioDir, "worktrees"), { recursive: true });
65
+
66
+ config = {
67
+ project: {
68
+ name: "testproject",
69
+ root: repoRoot,
70
+ canonicalBranch: "main",
71
+ },
72
+ agents: {
73
+ manifestPath: join(legioDir, "agent-manifest.json"),
74
+ baseDir: join(repoRoot, "agents"),
75
+ maxConcurrent: 5,
76
+ staggerDelayMs: 100,
77
+ maxDepth: 2,
78
+ },
79
+ worktrees: {
80
+ baseDir: join(legioDir, "worktrees"),
81
+ },
82
+ beads: {
83
+ enabled: false,
84
+ },
85
+ mulch: {
86
+ enabled: false,
87
+ domains: [],
88
+ primeFormat: "markdown",
89
+ },
90
+ merge: {
91
+ aiResolveEnabled: false,
92
+ reimagineEnabled: false,
93
+ },
94
+ watchdog: {
95
+ tier0Enabled: false,
96
+ tier0IntervalMs: 30000,
97
+ tier1Enabled: false,
98
+ tier2Enabled: false,
99
+ zombieThresholdMs: 300000,
100
+ nudgeIntervalMs: 30000,
101
+ },
102
+ models: {},
103
+ logging: {
104
+ verbose: false,
105
+ redactSecrets: true,
106
+ },
107
+ };
108
+
109
+ // Reset mocks and create deps object
110
+ mockListSessions.mockReset();
111
+ mockIsProcessAlive.mockReset();
112
+ mockListSessions.mockResolvedValue([]);
113
+ mockIsProcessAlive.mockReturnValue(true);
114
+
115
+ mockDeps = {
116
+ listSessions: mockListSessions,
117
+ isProcessAlive: mockIsProcessAlive,
118
+ };
119
+ });
120
+
121
+ afterEach(() => {
122
+ if (existsSync(repoRoot)) {
123
+ rmSync(repoRoot, { recursive: true, force: true });
124
+ }
125
+ });
126
+
127
+ test("returns all pass when no sessions exist", async () => {
128
+ const checks = await checkConsistency(config, legioDir, mockDeps);
129
+
130
+ expect(checks.length).toBeGreaterThan(0);
131
+ const passChecks = checks.filter((c) => c.status === "pass");
132
+ expect(passChecks.length).toBeGreaterThan(0);
133
+
134
+ const failChecks = checks.filter((c) => c.status === "fail");
135
+ expect(failChecks.length).toBe(0);
136
+ });
137
+
138
+ test("detects orphaned worktrees", async () => {
139
+ // Create a worktree but don't add it to SessionStore
140
+ const worktreePath = join(legioDir, "worktrees", "orphan-agent");
141
+ createWorktree(repoRoot, worktreePath, "legio/orphan-agent/test-123");
142
+
143
+ const checks = await checkConsistency(config, legioDir, mockDeps);
144
+
145
+ const orphanCheck = checks.find((c) => c.name === "orphaned-worktrees");
146
+ expect(orphanCheck).toBeDefined();
147
+ expect(orphanCheck?.status).toBe("warn");
148
+ expect(orphanCheck?.message).toContain("1 orphaned worktree");
149
+ expect(orphanCheck?.details?.length).toBe(1);
150
+ expect(orphanCheck?.fixable).toBe(true);
151
+ });
152
+
153
+ test("detects orphaned tmux sessions", async () => {
154
+ // Mock a tmux session that isn't in SessionStore
155
+ mockListSessions.mockResolvedValue([{ name: "legio-testproject-orphan", pid: 9999 }]);
156
+
157
+ const checks = await checkConsistency(config, legioDir, mockDeps);
158
+
159
+ const orphanCheck = checks.find((c) => c.name === "orphaned-tmux");
160
+ expect(orphanCheck).toBeDefined();
161
+ expect(orphanCheck?.status).toBe("warn");
162
+ expect(orphanCheck?.message).toContain("1 orphaned tmux session");
163
+ expect(orphanCheck?.fixable).toBe(true);
164
+ });
165
+
166
+ test("ignores tmux sessions from other projects", async () => {
167
+ // Mock tmux sessions from different projects
168
+ mockListSessions.mockResolvedValue([
169
+ { name: "legio-otherproject-agent1", pid: 9999 },
170
+ { name: "my-custom-session", pid: 8888 },
171
+ ]);
172
+
173
+ const checks = await checkConsistency(config, legioDir, mockDeps);
174
+
175
+ const orphanCheck = checks.find((c) => c.name === "orphaned-tmux");
176
+ expect(orphanCheck).toBeDefined();
177
+ expect(orphanCheck?.status).toBe("pass");
178
+ expect(orphanCheck?.message).toContain("No orphaned tmux sessions");
179
+ });
180
+
181
+ test("detects dead PIDs in SessionStore", async () => {
182
+ // Create a session with a PID that's marked as dead
183
+ const dbPath = join(legioDir, "sessions.db");
184
+ const store = createSessionStore(dbPath);
185
+
186
+ store.upsert({
187
+ id: "session-1",
188
+ agentName: "dead-agent",
189
+ capability: "builder",
190
+ worktreePath: join(legioDir, "worktrees", "dead-agent"),
191
+ branchName: "legio/dead-agent/test-123",
192
+ beadId: "test-123",
193
+ tmuxSession: "legio-testproject-dead-agent",
194
+ state: "working",
195
+ pid: 99999, // Non-existent PID
196
+ parentAgent: null,
197
+ depth: 0,
198
+ runId: null,
199
+ startedAt: new Date().toISOString(),
200
+ lastActivity: new Date().toISOString(),
201
+ escalationLevel: 0,
202
+ stalledSince: null,
203
+ });
204
+ store.close();
205
+
206
+ // Mock that this PID is not alive
207
+ mockIsProcessAlive.mockReturnValue(false);
208
+
209
+ const checks = await checkConsistency(config, legioDir, mockDeps);
210
+
211
+ const deadPidCheck = checks.find((c) => c.name === "dead-pids");
212
+ expect(deadPidCheck).toBeDefined();
213
+ expect(deadPidCheck?.status).toBe("warn");
214
+ expect(deadPidCheck?.message).toContain("1 session(s) with dead PIDs");
215
+ expect(deadPidCheck?.fixable).toBe(true);
216
+ });
217
+
218
+ test("passes when all PIDs are alive", async () => {
219
+ const dbPath = join(legioDir, "sessions.db");
220
+ const store = createSessionStore(dbPath);
221
+
222
+ store.upsert({
223
+ id: "session-1",
224
+ agentName: "live-agent",
225
+ capability: "builder",
226
+ worktreePath: join(legioDir, "worktrees", "live-agent"),
227
+ branchName: "legio/live-agent/test-123",
228
+ beadId: "test-123",
229
+ tmuxSession: "legio-testproject-live-agent",
230
+ state: "working",
231
+ pid: 12345,
232
+ parentAgent: null,
233
+ depth: 0,
234
+ runId: null,
235
+ startedAt: new Date().toISOString(),
236
+ lastActivity: new Date().toISOString(),
237
+ escalationLevel: 0,
238
+ stalledSince: null,
239
+ });
240
+ store.close();
241
+
242
+ // Mock that this PID is alive
243
+ mockIsProcessAlive.mockReturnValue(true);
244
+
245
+ const checks = await checkConsistency(config, legioDir, mockDeps);
246
+
247
+ const deadPidCheck = checks.find((c) => c.name === "dead-pids");
248
+ expect(deadPidCheck).toBeDefined();
249
+ expect(deadPidCheck?.status).toBe("pass");
250
+ });
251
+
252
+ test("detects missing worktrees for SessionStore entries", async () => {
253
+ const dbPath = join(legioDir, "sessions.db");
254
+ const store = createSessionStore(dbPath);
255
+
256
+ const missingWorktreePath = join(legioDir, "worktrees", "missing-agent");
257
+ store.upsert({
258
+ id: "session-1",
259
+ agentName: "missing-agent",
260
+ capability: "builder",
261
+ worktreePath: missingWorktreePath,
262
+ branchName: "legio/missing-agent/test-123",
263
+ beadId: "test-123",
264
+ tmuxSession: "legio-testproject-missing-agent",
265
+ state: "working",
266
+ pid: null,
267
+ parentAgent: null,
268
+ depth: 0,
269
+ runId: null,
270
+ startedAt: new Date().toISOString(),
271
+ lastActivity: new Date().toISOString(),
272
+ escalationLevel: 0,
273
+ stalledSince: null,
274
+ });
275
+ store.close();
276
+
277
+ const checks = await checkConsistency(config, legioDir, mockDeps);
278
+
279
+ const missingCheck = checks.find((c) => c.name === "missing-worktrees");
280
+ expect(missingCheck).toBeDefined();
281
+ expect(missingCheck?.status).toBe("warn");
282
+ expect(missingCheck?.message).toContain("1 session(s) with missing worktrees");
283
+ expect(missingCheck?.fixable).toBe(true);
284
+ });
285
+
286
+ test("detects missing tmux sessions for SessionStore entries", async () => {
287
+ const dbPath = join(legioDir, "sessions.db");
288
+ const store = createSessionStore(dbPath);
289
+
290
+ const worktreePath = join(legioDir, "worktrees", "agent-without-tmux");
291
+ createWorktree(repoRoot, worktreePath, "legio/agent-without-tmux/test-123");
292
+
293
+ store.upsert({
294
+ id: "session-1",
295
+ agentName: "agent-without-tmux",
296
+ capability: "builder",
297
+ worktreePath,
298
+ branchName: "legio/agent-without-tmux/test-123",
299
+ beadId: "test-123",
300
+ tmuxSession: "legio-testproject-agent-without-tmux",
301
+ state: "working",
302
+ pid: null,
303
+ parentAgent: null,
304
+ depth: 0,
305
+ runId: null,
306
+ startedAt: new Date().toISOString(),
307
+ lastActivity: new Date().toISOString(),
308
+ escalationLevel: 0,
309
+ stalledSince: null,
310
+ });
311
+ store.close();
312
+
313
+ // Mock empty tmux sessions list
314
+ mockListSessions.mockResolvedValue([]);
315
+
316
+ const checks = await checkConsistency(config, legioDir, mockDeps);
317
+
318
+ const missingCheck = checks.find((c) => c.name === "missing-tmux");
319
+ expect(missingCheck).toBeDefined();
320
+ expect(missingCheck?.status).toBe("warn");
321
+ expect(missingCheck?.message).toContain("1 session(s) with missing tmux sessions");
322
+ expect(missingCheck?.fixable).toBe(true);
323
+ });
324
+
325
+ test("passes when everything is consistent", async () => {
326
+ const dbPath = join(legioDir, "sessions.db");
327
+ const store = createSessionStore(dbPath);
328
+
329
+ const worktreePath = join(legioDir, "worktrees", "consistent-agent");
330
+ createWorktree(repoRoot, worktreePath, "legio/consistent-agent/test-123");
331
+
332
+ store.upsert({
333
+ id: "session-1",
334
+ agentName: "consistent-agent",
335
+ capability: "builder",
336
+ worktreePath,
337
+ branchName: "legio/consistent-agent/test-123",
338
+ beadId: "test-123",
339
+ tmuxSession: "legio-testproject-consistent-agent",
340
+ state: "working",
341
+ pid: 12345,
342
+ parentAgent: null,
343
+ depth: 0,
344
+ runId: null,
345
+ startedAt: new Date().toISOString(),
346
+ lastActivity: new Date().toISOString(),
347
+ escalationLevel: 0,
348
+ stalledSince: null,
349
+ });
350
+ store.close();
351
+
352
+ // Mock matching tmux session
353
+ mockListSessions.mockResolvedValue([
354
+ { name: "legio-testproject-consistent-agent", pid: 12345 },
355
+ ]);
356
+
357
+ // Mock PID as alive
358
+ mockIsProcessAlive.mockReturnValue(true);
359
+
360
+ const checks = await checkConsistency(config, legioDir, mockDeps);
361
+
362
+ const warnOrFail = checks.filter((c) => c.status === "warn" || c.status === "fail");
363
+ expect(warnOrFail.length).toBe(0);
364
+ });
365
+
366
+ test("handles tmux not installed gracefully", async () => {
367
+ // Mock tmux listing to throw an error
368
+ mockListSessions.mockRejectedValue(new Error("tmux: command not found"));
369
+
370
+ const checks = await checkConsistency(config, legioDir, mockDeps);
371
+
372
+ const tmuxCheck = checks.find((c) => c.name === "tmux-listing");
373
+ expect(tmuxCheck).toBeDefined();
374
+ expect(tmuxCheck?.status).toBe("warn");
375
+ expect(tmuxCheck?.message).toContain("Failed to list tmux sessions");
376
+ });
377
+
378
+ test("fails early if git worktree list fails", async () => {
379
+ // Use a non-existent repo root to trigger worktree listing failure
380
+ const badConfig = { ...config, project: { ...config.project, root: "/nonexistent" } };
381
+
382
+ const checks = await checkConsistency(badConfig, legioDir, mockDeps);
383
+
384
+ expect(checks.length).toBe(1);
385
+ expect(checks[0]?.name).toBe("worktree-listing");
386
+ expect(checks[0]?.status).toBe("fail");
387
+ });
388
+
389
+ test("fails early if SessionStore cannot be opened", async () => {
390
+ // Use a bad legio directory path
391
+ const badLegioDir = "/nonexistent/.legio";
392
+
393
+ const checks = await checkConsistency(config, badLegioDir, mockDeps);
394
+
395
+ const storeCheck = checks.find((c) => c.name === "sessionstore-open");
396
+ expect(storeCheck).toBeDefined();
397
+ expect(storeCheck?.status).toBe("fail");
398
+ });
399
+
400
+ test("reviewer-coverage: leads without reviewers emits warn", async () => {
401
+ const dbPath = join(legioDir, "sessions.db");
402
+ const store = createSessionStore(dbPath);
403
+
404
+ // Add 2 builder sessions under lead-1, no reviewers
405
+ store.upsert({
406
+ id: "session-1",
407
+ agentName: "builder-1",
408
+ capability: "builder",
409
+ worktreePath: join(legioDir, "worktrees", "builder-1"),
410
+ branchName: "legio/builder-1/test-123",
411
+ beadId: "test-123",
412
+ tmuxSession: "legio-testproject-builder-1",
413
+ state: "working",
414
+ pid: null,
415
+ parentAgent: "lead-1",
416
+ depth: 1,
417
+ runId: null,
418
+ startedAt: new Date().toISOString(),
419
+ lastActivity: new Date().toISOString(),
420
+ escalationLevel: 0,
421
+ stalledSince: null,
422
+ });
423
+
424
+ store.upsert({
425
+ id: "session-2",
426
+ agentName: "builder-2",
427
+ capability: "builder",
428
+ worktreePath: join(legioDir, "worktrees", "builder-2"),
429
+ branchName: "legio/builder-2/test-456",
430
+ beadId: "test-456",
431
+ tmuxSession: "legio-testproject-builder-2",
432
+ state: "working",
433
+ pid: null,
434
+ parentAgent: "lead-1",
435
+ depth: 1,
436
+ runId: null,
437
+ startedAt: new Date().toISOString(),
438
+ lastActivity: new Date().toISOString(),
439
+ escalationLevel: 0,
440
+ stalledSince: null,
441
+ });
442
+ store.close();
443
+
444
+ const checks = await checkConsistency(config, legioDir, mockDeps);
445
+
446
+ const reviewerCheck = checks.find((c) => c.name === "reviewer-coverage");
447
+ expect(reviewerCheck).toBeDefined();
448
+ expect(reviewerCheck?.status).toBe("warn");
449
+ expect(reviewerCheck?.message).toContain("without any reviewers");
450
+ expect(reviewerCheck?.details).toBeDefined();
451
+ expect(reviewerCheck?.details?.length).toBeGreaterThan(0);
452
+ });
453
+
454
+ test("reviewer-coverage: partial reviewer coverage emits warn", async () => {
455
+ const dbPath = join(legioDir, "sessions.db");
456
+ const store = createSessionStore(dbPath);
457
+
458
+ // Add 3 builders and 1 reviewer under same parent
459
+ for (let i = 1; i <= 3; i++) {
460
+ store.upsert({
461
+ id: `session-builder-${i}`,
462
+ agentName: `builder-${i}`,
463
+ capability: "builder",
464
+ worktreePath: join(legioDir, "worktrees", `builder-${i}`),
465
+ branchName: `legio/builder-${i}/test-${i}`,
466
+ beadId: `test-${i}`,
467
+ tmuxSession: `legio-testproject-builder-${i}`,
468
+ state: "working",
469
+ pid: null,
470
+ parentAgent: "lead-1",
471
+ depth: 1,
472
+ runId: null,
473
+ startedAt: new Date().toISOString(),
474
+ lastActivity: new Date().toISOString(),
475
+ escalationLevel: 0,
476
+ stalledSince: null,
477
+ });
478
+ }
479
+
480
+ store.upsert({
481
+ id: "session-reviewer-1",
482
+ agentName: "reviewer-1",
483
+ capability: "reviewer",
484
+ worktreePath: join(legioDir, "worktrees", "reviewer-1"),
485
+ branchName: "legio/reviewer-1/test-r1",
486
+ beadId: "test-r1",
487
+ tmuxSession: "legio-testproject-reviewer-1",
488
+ state: "working",
489
+ pid: null,
490
+ parentAgent: "lead-1",
491
+ depth: 1,
492
+ runId: null,
493
+ startedAt: new Date().toISOString(),
494
+ lastActivity: new Date().toISOString(),
495
+ escalationLevel: 0,
496
+ stalledSince: null,
497
+ });
498
+ store.close();
499
+
500
+ const checks = await checkConsistency(config, legioDir, mockDeps);
501
+
502
+ const reviewerCheck = checks.find((c) => c.name === "reviewer-coverage");
503
+ expect(reviewerCheck).toBeDefined();
504
+ expect(reviewerCheck?.status).toBe("warn");
505
+ expect(reviewerCheck?.message).toContain("partial reviewer coverage");
506
+ });
507
+
508
+ test("reviewer-coverage: full reviewer coverage emits pass", async () => {
509
+ const dbPath = join(legioDir, "sessions.db");
510
+ const store = createSessionStore(dbPath);
511
+
512
+ // Add 2 builders and 2 reviewers under same parent
513
+ for (let i = 1; i <= 2; i++) {
514
+ store.upsert({
515
+ id: `session-builder-${i}`,
516
+ agentName: `builder-${i}`,
517
+ capability: "builder",
518
+ worktreePath: join(legioDir, "worktrees", `builder-${i}`),
519
+ branchName: `legio/builder-${i}/test-${i}`,
520
+ beadId: `test-${i}`,
521
+ tmuxSession: `legio-testproject-builder-${i}`,
522
+ state: "working",
523
+ pid: null,
524
+ parentAgent: "lead-1",
525
+ depth: 1,
526
+ runId: null,
527
+ startedAt: new Date().toISOString(),
528
+ lastActivity: new Date().toISOString(),
529
+ escalationLevel: 0,
530
+ stalledSince: null,
531
+ });
532
+
533
+ store.upsert({
534
+ id: `session-reviewer-${i}`,
535
+ agentName: `reviewer-${i}`,
536
+ capability: "reviewer",
537
+ worktreePath: join(legioDir, "worktrees", `reviewer-${i}`),
538
+ branchName: `legio/reviewer-${i}/test-r${i}`,
539
+ beadId: `test-r${i}`,
540
+ tmuxSession: `legio-testproject-reviewer-${i}`,
541
+ state: "working",
542
+ pid: null,
543
+ parentAgent: "lead-1",
544
+ depth: 1,
545
+ runId: null,
546
+ startedAt: new Date().toISOString(),
547
+ lastActivity: new Date().toISOString(),
548
+ escalationLevel: 0,
549
+ stalledSince: null,
550
+ });
551
+ }
552
+ store.close();
553
+
554
+ const checks = await checkConsistency(config, legioDir, mockDeps);
555
+
556
+ const reviewerCheck = checks.find((c) => c.name === "reviewer-coverage");
557
+ expect(reviewerCheck).toBeDefined();
558
+ expect(reviewerCheck?.status).toBe("pass");
559
+ });
560
+
561
+ test("reviewer-coverage: no builder sessions emits pass", async () => {
562
+ // Don't create any sessions at all
563
+ const checks = await checkConsistency(config, legioDir, mockDeps);
564
+
565
+ const reviewerCheck = checks.find((c) => c.name === "reviewer-coverage");
566
+ expect(reviewerCheck).toBeDefined();
567
+ expect(reviewerCheck?.status).toBe("pass");
568
+ expect(reviewerCheck?.message).toContain("No builder sessions found");
569
+ });
570
+
571
+ test("reviewer-coverage: multiple leads mixed coverage", async () => {
572
+ const dbPath = join(legioDir, "sessions.db");
573
+ const store = createSessionStore(dbPath);
574
+
575
+ // Lead-1 has builders + reviewers (good)
576
+ store.upsert({
577
+ id: "session-builder-1",
578
+ agentName: "builder-1",
579
+ capability: "builder",
580
+ worktreePath: join(legioDir, "worktrees", "builder-1"),
581
+ branchName: "legio/builder-1/test-1",
582
+ beadId: "test-1",
583
+ tmuxSession: "legio-testproject-builder-1",
584
+ state: "working",
585
+ pid: null,
586
+ parentAgent: "lead-1",
587
+ depth: 1,
588
+ runId: null,
589
+ startedAt: new Date().toISOString(),
590
+ lastActivity: new Date().toISOString(),
591
+ escalationLevel: 0,
592
+ stalledSince: null,
593
+ });
594
+
595
+ store.upsert({
596
+ id: "session-reviewer-1",
597
+ agentName: "reviewer-1",
598
+ capability: "reviewer",
599
+ worktreePath: join(legioDir, "worktrees", "reviewer-1"),
600
+ branchName: "legio/reviewer-1/test-r1",
601
+ beadId: "test-r1",
602
+ tmuxSession: "legio-testproject-reviewer-1",
603
+ state: "working",
604
+ pid: null,
605
+ parentAgent: "lead-1",
606
+ depth: 1,
607
+ runId: null,
608
+ startedAt: new Date().toISOString(),
609
+ lastActivity: new Date().toISOString(),
610
+ escalationLevel: 0,
611
+ stalledSince: null,
612
+ });
613
+
614
+ // Lead-2 has builders only (bad)
615
+ store.upsert({
616
+ id: "session-builder-2",
617
+ agentName: "builder-2",
618
+ capability: "builder",
619
+ worktreePath: join(legioDir, "worktrees", "builder-2"),
620
+ branchName: "legio/builder-2/test-2",
621
+ beadId: "test-2",
622
+ tmuxSession: "legio-testproject-builder-2",
623
+ state: "working",
624
+ pid: null,
625
+ parentAgent: "lead-2",
626
+ depth: 1,
627
+ runId: null,
628
+ startedAt: new Date().toISOString(),
629
+ lastActivity: new Date().toISOString(),
630
+ escalationLevel: 0,
631
+ stalledSince: null,
632
+ });
633
+ store.close();
634
+
635
+ const checks = await checkConsistency(config, legioDir, mockDeps);
636
+
637
+ const reviewerCheck = checks.find((c) => c.name === "reviewer-coverage");
638
+ expect(reviewerCheck).toBeDefined();
639
+ expect(reviewerCheck?.status).toBe("warn");
640
+ expect(reviewerCheck?.details).toBeDefined();
641
+ // Should contain lead-2 in the details
642
+ const detailsStr = reviewerCheck?.details?.join(" ");
643
+ expect(detailsStr).toContain("lead-2");
644
+ });
645
+ });