@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,210 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import Database from "better-sqlite3";
5
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
6
+ import { createMergeQueue } from "../merge/queue.ts";
7
+ import type { LegioConfig } from "../types.ts";
8
+ import { checkMergeQueue } from "./merge-queue.ts";
9
+ import type { DoctorCheck } from "./types.ts";
10
+
11
+ describe("checkMergeQueue", () => {
12
+ let tempDir: string;
13
+ let mockConfig: LegioConfig;
14
+
15
+ beforeEach(() => {
16
+ tempDir = mkdtempSync(join(tmpdir(), "legio-test-"));
17
+ mockConfig = {
18
+ project: { name: "test", root: tempDir, canonicalBranch: "main" },
19
+ agents: {
20
+ manifestPath: "",
21
+ baseDir: "",
22
+ maxConcurrent: 5,
23
+ staggerDelayMs: 100,
24
+ maxDepth: 2,
25
+ },
26
+ worktrees: { baseDir: "" },
27
+ beads: { enabled: true },
28
+ mulch: { enabled: true, domains: [], primeFormat: "markdown" },
29
+ merge: { aiResolveEnabled: false, reimagineEnabled: false },
30
+ watchdog: {
31
+ tier0Enabled: true,
32
+ tier0IntervalMs: 30000,
33
+ tier1Enabled: false,
34
+ tier2Enabled: false,
35
+ zombieThresholdMs: 600000,
36
+ nudgeIntervalMs: 60000,
37
+ },
38
+ models: {},
39
+ logging: { verbose: false, redactSecrets: true },
40
+ };
41
+ });
42
+
43
+ afterEach(() => {
44
+ rmSync(tempDir, { recursive: true, force: true });
45
+ });
46
+
47
+ test("passes when merge queue db does not exist", () => {
48
+ const checks = checkMergeQueue(mockConfig, tempDir) as DoctorCheck[];
49
+
50
+ expect(checks).toHaveLength(1);
51
+ expect(checks[0]?.status).toBe("pass");
52
+ expect(checks[0]?.name).toBe("merge-queue.db exists");
53
+ expect(checks[0]?.message).toContain("normal for new installations");
54
+ });
55
+
56
+ test("passes when merge queue is empty", () => {
57
+ const dbPath = join(tempDir, "merge-queue.db");
58
+ // Create empty queue
59
+ const queue = createMergeQueue(dbPath);
60
+ queue.close();
61
+
62
+ const checks = checkMergeQueue(mockConfig, tempDir) as DoctorCheck[];
63
+
64
+ expect(checks).toHaveLength(1);
65
+ expect(checks[0]?.status).toBe("pass");
66
+ expect(checks[0]?.name).toBe("merge-queue.db schema");
67
+ expect(checks[0]?.message).toBe("Merge queue has 0 entries");
68
+ });
69
+
70
+ test("passes with valid queue entries", () => {
71
+ const dbPath = join(tempDir, "merge-queue.db");
72
+ const queue = createMergeQueue(dbPath);
73
+ queue.enqueue({
74
+ branchName: "feature/test",
75
+ beadId: "beads-abc",
76
+ agentName: "test-agent",
77
+ filesModified: ["src/test.ts"],
78
+ });
79
+ queue.enqueue({
80
+ branchName: "feature/another",
81
+ beadId: "beads-def",
82
+ agentName: "another-agent",
83
+ filesModified: ["src/another.ts"],
84
+ });
85
+ // Mark second entry as merged
86
+ const entry = queue.list()[1];
87
+ if (entry) {
88
+ queue.updateStatus(entry.branchName, "merged", "clean-merge");
89
+ }
90
+ queue.close();
91
+
92
+ const checks = checkMergeQueue(mockConfig, tempDir) as DoctorCheck[];
93
+
94
+ expect(checks).toHaveLength(1);
95
+ expect(checks[0]?.status).toBe("pass");
96
+ expect(checks[0]?.name).toBe("merge-queue.db schema");
97
+ expect(checks[0]?.message).toBe("Merge queue has 2 entries");
98
+ });
99
+
100
+ test("fails when db is corrupted", () => {
101
+ const dbPath = join(tempDir, "merge-queue.db");
102
+ // Write invalid data to db file
103
+ writeFileSync(dbPath, "not a valid sqlite database");
104
+
105
+ const checks = checkMergeQueue(mockConfig, tempDir) as DoctorCheck[];
106
+
107
+ expect(checks).toHaveLength(1);
108
+ expect(checks[0]?.status).toBe("fail");
109
+ expect(checks[0]?.name).toBe("merge-queue.db readable");
110
+ expect(checks[0]?.message).toContain("Failed to read merge-queue.db");
111
+ });
112
+
113
+ test("fails when table does not exist", () => {
114
+ const dbPath = join(tempDir, "merge-queue.db");
115
+ // Create a db but without the merge_queue table
116
+ const db = new Database(dbPath);
117
+ db.exec("CREATE TABLE other_table (id INTEGER PRIMARY KEY)");
118
+ db.close();
119
+
120
+ const checks = checkMergeQueue(mockConfig, tempDir) as DoctorCheck[];
121
+
122
+ const schemaCheck = checks.find((c) => c?.name === "merge-queue.db schema");
123
+ expect(schemaCheck?.status).toBe("fail");
124
+ expect(schemaCheck?.message).toContain("merge_queue table not found");
125
+ });
126
+
127
+ test("warns about stale pending entries", () => {
128
+ const dbPath = join(tempDir, "merge-queue.db");
129
+ // Create queue and manually insert stale entry (2 days old)
130
+ const queue = createMergeQueue(dbPath);
131
+ queue.close();
132
+
133
+ const staleDate = new Date();
134
+ staleDate.setDate(staleDate.getDate() - 2); // 2 days ago
135
+
136
+ const db = new Database(dbPath);
137
+ db.prepare(
138
+ "INSERT INTO merge_queue (branch_name, bead_id, agent_name, files_modified, status, enqueued_at) VALUES (?, ?, ?, ?, ?, ?)",
139
+ ).run(
140
+ "feature/stale",
141
+ "beads-abc",
142
+ "test-agent",
143
+ JSON.stringify(["src/test.ts"]),
144
+ "pending",
145
+ staleDate.toISOString(),
146
+ );
147
+ db.close();
148
+
149
+ const checks = checkMergeQueue(mockConfig, tempDir) as DoctorCheck[];
150
+
151
+ const staleCheck = checks.find((c) => c?.name === "merge-queue.db staleness");
152
+ expect(staleCheck?.status).toBe("warn");
153
+ expect(staleCheck?.message).toContain("potentially stale");
154
+ expect(staleCheck?.details?.[0]).toContain("feature/stale");
155
+ });
156
+
157
+ test("does not warn about old completed entries", () => {
158
+ const dbPath = join(tempDir, "merge-queue.db");
159
+ // Create queue and manually insert old merged entry (2 days ago)
160
+ const queue = createMergeQueue(dbPath);
161
+ queue.close();
162
+
163
+ const oldDate = new Date();
164
+ oldDate.setDate(oldDate.getDate() - 2); // 2 days ago
165
+
166
+ const db = new Database(dbPath);
167
+ db.prepare(
168
+ "INSERT INTO merge_queue (branch_name, bead_id, agent_name, files_modified, status, enqueued_at, resolved_tier) VALUES (?, ?, ?, ?, ?, ?, ?)",
169
+ ).run(
170
+ "feature/old-merged",
171
+ "beads-abc",
172
+ "test-agent",
173
+ JSON.stringify(["src/test.ts"]),
174
+ "merged",
175
+ oldDate.toISOString(),
176
+ "clean-merge",
177
+ );
178
+ db.close();
179
+
180
+ const checks = checkMergeQueue(mockConfig, tempDir) as DoctorCheck[];
181
+
182
+ const staleCheck = checks.find((c) => c?.name === "merge-queue.db staleness");
183
+ expect(staleCheck).toBeUndefined();
184
+ });
185
+
186
+ test("warns about duplicate branches", () => {
187
+ const dbPath = join(tempDir, "merge-queue.db");
188
+ const queue = createMergeQueue(dbPath);
189
+ queue.enqueue({
190
+ branchName: "feature/duplicate",
191
+ beadId: "beads-abc",
192
+ agentName: "test-agent",
193
+ filesModified: ["src/test.ts"],
194
+ });
195
+ queue.enqueue({
196
+ branchName: "feature/duplicate",
197
+ beadId: "beads-def",
198
+ agentName: "another-agent",
199
+ filesModified: ["src/another.ts"],
200
+ });
201
+ queue.close();
202
+
203
+ const checks = checkMergeQueue(mockConfig, tempDir) as DoctorCheck[];
204
+
205
+ const duplicateCheck = checks.find((c) => c?.name === "merge-queue.db duplicates");
206
+ expect(duplicateCheck?.status).toBe("warn");
207
+ expect(duplicateCheck?.message).toContain("duplicate branch entries");
208
+ expect(duplicateCheck?.details?.[0]).toContain("feature/duplicate");
209
+ });
210
+ });
@@ -0,0 +1,144 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import Database from "better-sqlite3";
4
+ import type { DoctorCheck, DoctorCheckFn } from "./types.ts";
5
+
6
+ /**
7
+ * Merge queue health checks.
8
+ * Validates merge-queue.db schema and detects stale entries.
9
+ */
10
+ export const checkMergeQueue: DoctorCheckFn = (_config, legioDir): DoctorCheck[] => {
11
+ const checks: DoctorCheck[] = [];
12
+ const dbPath = join(legioDir, "merge-queue.db");
13
+
14
+ if (!existsSync(dbPath)) {
15
+ checks.push({
16
+ name: "merge-queue.db exists",
17
+ category: "merge",
18
+ status: "pass",
19
+ message: "No merge queue database (normal for new installations or no merges yet)",
20
+ });
21
+ return checks;
22
+ }
23
+
24
+ let db: InstanceType<typeof Database>;
25
+ try {
26
+ db = new Database(dbPath, { readonly: true });
27
+ } catch (err) {
28
+ checks.push({
29
+ name: "merge-queue.db readable",
30
+ category: "merge",
31
+ status: "fail",
32
+ message: "Failed to open merge-queue.db",
33
+ details: [err instanceof Error ? err.message : String(err)],
34
+ fixable: true,
35
+ });
36
+ return checks;
37
+ }
38
+
39
+ try {
40
+ // Check table exists
41
+ let tableCheck: unknown;
42
+ try {
43
+ tableCheck = db
44
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='merge_queue'")
45
+ .get();
46
+ } catch (err) {
47
+ // Database is corrupted or not a valid SQLite file
48
+ checks.push({
49
+ name: "merge-queue.db readable",
50
+ category: "merge",
51
+ status: "fail",
52
+ message: "Failed to read merge-queue.db",
53
+ details: [err instanceof Error ? err.message : String(err)],
54
+ fixable: true,
55
+ });
56
+ db.close();
57
+ return checks;
58
+ }
59
+
60
+ if (!tableCheck) {
61
+ checks.push({
62
+ name: "merge-queue.db schema",
63
+ category: "merge",
64
+ status: "fail",
65
+ message: "merge_queue table not found in database",
66
+ fixable: true,
67
+ });
68
+ db.close();
69
+ return checks;
70
+ }
71
+
72
+ // Read all entries
73
+ const rows = db.prepare("SELECT * FROM merge_queue ORDER BY id ASC").all() as Array<{
74
+ branch_name: string;
75
+ agent_name: string;
76
+ status: string;
77
+ enqueued_at: string;
78
+ resolved_tier: string | null;
79
+ }>;
80
+
81
+ checks.push({
82
+ name: "merge-queue.db schema",
83
+ category: "merge",
84
+ status: "pass",
85
+ message: `Merge queue has ${rows.length} entries`,
86
+ });
87
+
88
+ // Check for stale entries (pending/merging older than 24h)
89
+ const now = new Date();
90
+ const staleThresholdMs = 24 * 60 * 60 * 1000;
91
+ const staleEntries: string[] = [];
92
+ for (const row of rows) {
93
+ if (row.status === "pending" || row.status === "merging") {
94
+ try {
95
+ const enqueuedAt = new Date(row.enqueued_at);
96
+ const ageMs = now.getTime() - enqueuedAt.getTime();
97
+ if (ageMs > staleThresholdMs) {
98
+ const ageHours = Math.floor(ageMs / (60 * 60 * 1000));
99
+ staleEntries.push(
100
+ `${row.branch_name} (${row.status}, ${ageHours}h old) - may be stuck`,
101
+ );
102
+ }
103
+ } catch {
104
+ /* invalid date */
105
+ }
106
+ }
107
+ }
108
+
109
+ if (staleEntries.length > 0) {
110
+ checks.push({
111
+ name: "merge-queue.db staleness",
112
+ category: "merge",
113
+ status: "warn",
114
+ message: `Found ${staleEntries.length} potentially stale queue entries`,
115
+ details: staleEntries,
116
+ fixable: true,
117
+ });
118
+ }
119
+
120
+ // Check for duplicate branches
121
+ const branchCounts = new Map<string, number>();
122
+ for (const row of rows) {
123
+ branchCounts.set(row.branch_name, (branchCounts.get(row.branch_name) ?? 0) + 1);
124
+ }
125
+ const duplicates: string[] = [];
126
+ for (const [branch, count] of branchCounts) {
127
+ if (count > 1) duplicates.push(`${branch} (appears ${count} times)`);
128
+ }
129
+ if (duplicates.length > 0) {
130
+ checks.push({
131
+ name: "merge-queue.db duplicates",
132
+ category: "merge",
133
+ status: "warn",
134
+ message: "Found duplicate branch entries in queue",
135
+ details: duplicates,
136
+ fixable: true,
137
+ });
138
+ }
139
+ } finally {
140
+ db.close();
141
+ }
142
+
143
+ return checks;
144
+ };
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Tests for structure doctor checks.
3
+ *
4
+ * Uses temp directories with real filesystem operations.
5
+ * No mocks needed -- all operations are cheap and local.
6
+ */
7
+
8
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
12
+ import type { LegioConfig } from "../types.ts";
13
+ import { checkStructure } from "./structure.ts";
14
+
15
+ describe("checkStructure", () => {
16
+ let tempDir: string;
17
+ let legioDir: string;
18
+ let mockConfig: LegioConfig;
19
+
20
+ beforeEach(async () => {
21
+ tempDir = await mkdtemp(join(tmpdir(), "structure-test-"));
22
+ legioDir = join(tempDir, ".legio");
23
+
24
+ mockConfig = {
25
+ project: {
26
+ name: "test-project",
27
+ root: tempDir,
28
+ canonicalBranch: "main",
29
+ },
30
+ agents: {
31
+ manifestPath: ".legio/agent-manifest.json",
32
+ baseDir: ".legio/agent-defs",
33
+ maxConcurrent: 5,
34
+ staggerDelayMs: 1000,
35
+ maxDepth: 2,
36
+ },
37
+ worktrees: {
38
+ baseDir: ".legio/worktrees",
39
+ },
40
+ beads: {
41
+ enabled: true,
42
+ },
43
+ mulch: {
44
+ enabled: true,
45
+ domains: [],
46
+ primeFormat: "markdown",
47
+ },
48
+ merge: {
49
+ aiResolveEnabled: false,
50
+ reimagineEnabled: false,
51
+ },
52
+ watchdog: {
53
+ tier0Enabled: true,
54
+ tier0IntervalMs: 30000,
55
+ tier1Enabled: false,
56
+ tier2Enabled: false,
57
+ zombieThresholdMs: 600000,
58
+ nudgeIntervalMs: 60000,
59
+ },
60
+ models: {},
61
+ logging: {
62
+ verbose: false,
63
+ redactSecrets: true,
64
+ },
65
+ };
66
+ });
67
+
68
+ afterEach(async () => {
69
+ await rm(tempDir, { recursive: true, force: true });
70
+ });
71
+
72
+ test("fails when .legio/ directory does not exist", async () => {
73
+ const checks = await checkStructure(mockConfig, legioDir);
74
+
75
+ expect(checks.length).toBeGreaterThan(0);
76
+ const dirCheck = checks.find((c) => c.name === ".legio/ directory");
77
+ expect(dirCheck).toBeDefined();
78
+ expect(dirCheck?.status).toBe("fail");
79
+ expect(dirCheck?.message).toContain("missing");
80
+ expect(dirCheck?.fixable).toBe(true);
81
+ });
82
+
83
+ test("passes when all required files and directories exist", async () => {
84
+ // Create .legio/ and all required structure
85
+ await mkdir(legioDir, { recursive: true });
86
+ await mkdir(join(legioDir, "agent-defs"), { recursive: true });
87
+ await mkdir(join(legioDir, "agents"), { recursive: true });
88
+ await mkdir(join(legioDir, "worktrees"), { recursive: true });
89
+ await mkdir(join(legioDir, "specs"), { recursive: true });
90
+ await mkdir(join(legioDir, "logs"), { recursive: true });
91
+
92
+ await writeFile(join(legioDir, "config.yaml"), "project:\n name: test\n");
93
+ await writeFile(
94
+ join(legioDir, "agent-manifest.json"),
95
+ JSON.stringify({ version: "1.0", agents: {}, capabilityIndex: {} }, null, 2),
96
+ );
97
+ await writeFile(join(legioDir, "hooks.json"), "{}");
98
+ await writeFile(
99
+ join(legioDir, ".gitignore"),
100
+ `# Wildcard+whitelist: ignore everything, whitelist tracked files
101
+ # Auto-healed by legio prime on each session start
102
+ *
103
+ !.gitignore
104
+ !config.yaml
105
+ !agent-manifest.json
106
+ !hooks.json
107
+ !groups.json
108
+ !agent-defs/
109
+ `,
110
+ );
111
+
112
+ const checks = await checkStructure(mockConfig, legioDir);
113
+
114
+ // All checks should pass
115
+ const failedChecks = checks.filter((c) => c.status === "fail");
116
+ expect(failedChecks).toHaveLength(0);
117
+
118
+ const dirCheck = checks.find((c) => c.name === ".legio/ directory");
119
+ expect(dirCheck?.status).toBe("pass");
120
+
121
+ const filesCheck = checks.find((c) => c.name === "Required files");
122
+ expect(filesCheck?.status).toBe("pass");
123
+
124
+ const dirsCheck = checks.find((c) => c.name === "Required subdirectories");
125
+ expect(dirsCheck?.status).toBe("pass");
126
+
127
+ const gitignoreCheck = checks.find((c) => c.name === ".gitignore entries");
128
+ expect(gitignoreCheck?.status).toBe("pass");
129
+ });
130
+
131
+ test("reports missing required files", async () => {
132
+ await mkdir(legioDir, { recursive: true });
133
+ await writeFile(join(legioDir, "config.yaml"), "project:\n name: test\n");
134
+ // Missing: agent-manifest.json, hooks.json, .gitignore
135
+
136
+ const checks = await checkStructure(mockConfig, legioDir);
137
+
138
+ const filesCheck = checks.find((c) => c.name === "Required files");
139
+ expect(filesCheck).toBeDefined();
140
+ expect(filesCheck?.status).toBe("fail");
141
+ expect(filesCheck?.details).toContain("agent-manifest.json");
142
+ expect(filesCheck?.details).toContain("hooks.json");
143
+ expect(filesCheck?.details).toContain(".gitignore");
144
+ expect(filesCheck?.fixable).toBe(true);
145
+ });
146
+
147
+ test("reports missing required subdirectories", async () => {
148
+ await mkdir(legioDir, { recursive: true });
149
+ await mkdir(join(legioDir, "agent-defs"), { recursive: true });
150
+ // Missing: agents/, worktrees/, specs/, logs/
151
+
152
+ const checks = await checkStructure(mockConfig, legioDir);
153
+
154
+ const dirsCheck = checks.find((c) => c.name === "Required subdirectories");
155
+ expect(dirsCheck).toBeDefined();
156
+ expect(dirsCheck?.status).toBe("fail");
157
+ expect(dirsCheck?.details).toContain("agents/");
158
+ expect(dirsCheck?.details).toContain("worktrees/");
159
+ expect(dirsCheck?.details).toContain("specs/");
160
+ expect(dirsCheck?.details).toContain("logs/");
161
+ expect(dirsCheck?.fixable).toBe(true);
162
+ });
163
+
164
+ test("warns when .gitignore is missing entries", async () => {
165
+ await mkdir(legioDir, { recursive: true });
166
+ await writeFile(
167
+ join(legioDir, ".gitignore"),
168
+ `# Incomplete gitignore
169
+ *
170
+ !.gitignore
171
+ !config.yaml
172
+ `,
173
+ );
174
+
175
+ const checks = await checkStructure(mockConfig, legioDir);
176
+
177
+ const gitignoreCheck = checks.find((c) => c.name === ".gitignore entries");
178
+ expect(gitignoreCheck).toBeDefined();
179
+ expect(gitignoreCheck?.status).toBe("warn");
180
+ expect(gitignoreCheck?.details).toBeDefined();
181
+ expect(gitignoreCheck?.details?.length).toBeGreaterThan(0);
182
+ expect(gitignoreCheck?.fixable).toBe(true);
183
+ });
184
+
185
+ test("validates agent-defs files against manifest", async () => {
186
+ await mkdir(legioDir, { recursive: true });
187
+ await mkdir(join(legioDir, "agent-defs"), { recursive: true });
188
+
189
+ const manifest = {
190
+ version: "1.0",
191
+ agents: {
192
+ scout: { file: "scout.md", model: "haiku", tools: [], capabilities: [], canSpawn: false },
193
+ builder: {
194
+ file: "builder.md",
195
+ model: "sonnet",
196
+ tools: [],
197
+ capabilities: [],
198
+ canSpawn: false,
199
+ },
200
+ },
201
+ capabilityIndex: {},
202
+ };
203
+
204
+ await writeFile(join(legioDir, "agent-manifest.json"), JSON.stringify(manifest, null, 2));
205
+ await writeFile(join(legioDir, "agent-defs", "scout.md"), "# Scout");
206
+ // Missing: builder.md
207
+
208
+ const checks = await checkStructure(mockConfig, legioDir);
209
+
210
+ const agentDefsCheck = checks.find((c) => c.name === "Agent definition files");
211
+ expect(agentDefsCheck).toBeDefined();
212
+ expect(agentDefsCheck?.status).toBe("fail");
213
+ expect(agentDefsCheck?.details).toContain("builder.md");
214
+ expect(agentDefsCheck?.fixable).toBe(true);
215
+ });
216
+
217
+ test("passes when all agent-defs files are present", async () => {
218
+ await mkdir(legioDir, { recursive: true });
219
+ await mkdir(join(legioDir, "agent-defs"), { recursive: true });
220
+
221
+ const manifest = {
222
+ version: "1.0",
223
+ agents: {
224
+ scout: { file: "scout.md", model: "haiku", tools: [], capabilities: [], canSpawn: false },
225
+ builder: {
226
+ file: "builder.md",
227
+ model: "sonnet",
228
+ tools: [],
229
+ capabilities: [],
230
+ canSpawn: false,
231
+ },
232
+ },
233
+ capabilityIndex: {},
234
+ };
235
+
236
+ await writeFile(join(legioDir, "agent-manifest.json"), JSON.stringify(manifest, null, 2));
237
+ await writeFile(join(legioDir, "agent-defs", "scout.md"), "# Scout");
238
+ await writeFile(join(legioDir, "agent-defs", "builder.md"), "# Builder");
239
+
240
+ const checks = await checkStructure(mockConfig, legioDir);
241
+
242
+ const agentDefsCheck = checks.find((c) => c.name === "Agent definition files");
243
+ expect(agentDefsCheck).toBeDefined();
244
+ expect(agentDefsCheck?.status).toBe("pass");
245
+ });
246
+
247
+ test("fails gracefully when manifest is malformed", async () => {
248
+ await mkdir(legioDir, { recursive: true });
249
+ await writeFile(join(legioDir, "agent-manifest.json"), "invalid json{");
250
+
251
+ const checks = await checkStructure(mockConfig, legioDir);
252
+
253
+ const agentDefsCheck = checks.find((c) => c.name === "Agent definition files");
254
+ expect(agentDefsCheck).toBeDefined();
255
+ expect(agentDefsCheck?.status).toBe("fail");
256
+ expect(agentDefsCheck?.message).toContain("Cannot validate");
257
+ expect(agentDefsCheck?.fixable).toBe(false);
258
+ });
259
+
260
+ test("detects leftover temp files", async () => {
261
+ await mkdir(legioDir, { recursive: true });
262
+ await writeFile(join(legioDir, "config.yaml.tmp"), "temp");
263
+ await writeFile(join(legioDir, "old-file.bak"), "backup");
264
+
265
+ const checks = await checkStructure(mockConfig, legioDir);
266
+
267
+ const tempFilesCheck = checks.find((c) => c.name === "Leftover temp files");
268
+ expect(tempFilesCheck).toBeDefined();
269
+ expect(tempFilesCheck?.status).toBe("warn");
270
+ expect(tempFilesCheck?.details).toContain("config.yaml.tmp");
271
+ expect(tempFilesCheck?.details).toContain("old-file.bak");
272
+ expect(tempFilesCheck?.fixable).toBe(true);
273
+ });
274
+
275
+ test("passes when no temp files exist", async () => {
276
+ await mkdir(legioDir, { recursive: true });
277
+ await writeFile(join(legioDir, "config.yaml"), "project:\n name: test\n");
278
+
279
+ const checks = await checkStructure(mockConfig, legioDir);
280
+
281
+ const tempFilesCheck = checks.find((c) => c.name === "Leftover temp files");
282
+ expect(tempFilesCheck).toBeDefined();
283
+ expect(tempFilesCheck?.status).toBe("pass");
284
+ });
285
+ });