@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,1820 @@
1
+ import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
5
+ import { AgentError } from "../errors.ts";
6
+ import {
7
+ buildBashFileGuardScript,
8
+ buildBashPathBoundaryScript,
9
+ buildPathBoundaryGuardScript,
10
+ deployHooks,
11
+ getBashPathBoundaryGuards,
12
+ getCapabilityGuards,
13
+ getDangerGuards,
14
+ getPathBoundaryGuards,
15
+ } from "./hooks-deployer.ts";
16
+
17
+ describe("deployHooks", () => {
18
+ let tempDir: string;
19
+
20
+ beforeEach(async () => {
21
+ tempDir = await mkdtemp(join(tmpdir(), "legio-hooks-test-"));
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await rm(tempDir, { recursive: true, force: true });
26
+ });
27
+
28
+ test("creates .claude/settings.local.json in worktree directory", async () => {
29
+ const worktreePath = join(tempDir, "worktree");
30
+
31
+ await deployHooks(worktreePath, "test-agent");
32
+
33
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
34
+ const exists = await access(outputPath)
35
+ .then(() => true)
36
+ .catch(() => false);
37
+ expect(exists).toBe(true);
38
+ });
39
+
40
+ test("replaces {{AGENT_NAME}} with the actual agent name", async () => {
41
+ const worktreePath = join(tempDir, "worktree");
42
+
43
+ await deployHooks(worktreePath, "my-builder");
44
+
45
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
46
+ const content = await readFile(outputPath, "utf-8");
47
+ expect(content).toContain("my-builder");
48
+ expect(content).not.toContain("{{AGENT_NAME}}");
49
+ });
50
+
51
+ test("replaces all occurrences of {{AGENT_NAME}}", async () => {
52
+ const worktreePath = join(tempDir, "worktree");
53
+
54
+ await deployHooks(worktreePath, "scout-alpha");
55
+
56
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
57
+ const content = await readFile(outputPath, "utf-8");
58
+
59
+ // The template has {{AGENT_NAME}} in multiple hook commands
60
+ const occurrences = content.split("scout-alpha").length - 1;
61
+ expect(occurrences).toBeGreaterThanOrEqual(6);
62
+ expect(content).not.toContain("{{AGENT_NAME}}");
63
+ });
64
+
65
+ test("output is valid JSON", async () => {
66
+ const worktreePath = join(tempDir, "worktree");
67
+
68
+ await deployHooks(worktreePath, "json-test-agent");
69
+
70
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
71
+ const content = await readFile(outputPath, "utf-8");
72
+ const parsed = JSON.parse(content);
73
+ expect(parsed).toBeDefined();
74
+ expect(parsed.hooks).toBeDefined();
75
+ });
76
+
77
+ test("output contains SessionStart hook", async () => {
78
+ const worktreePath = join(tempDir, "worktree");
79
+
80
+ await deployHooks(worktreePath, "hook-check");
81
+
82
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
83
+ const content = await readFile(outputPath, "utf-8");
84
+ const parsed = JSON.parse(content);
85
+ expect(parsed.hooks.SessionStart).toBeDefined();
86
+ expect(parsed.hooks.SessionStart).toBeInstanceOf(Array);
87
+ expect(parsed.hooks.SessionStart.length).toBeGreaterThan(0);
88
+ });
89
+
90
+ test("output contains UserPromptSubmit hook", async () => {
91
+ const worktreePath = join(tempDir, "worktree");
92
+
93
+ await deployHooks(worktreePath, "hook-check");
94
+
95
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
96
+ const content = await readFile(outputPath, "utf-8");
97
+ const parsed = JSON.parse(content);
98
+ expect(parsed.hooks.UserPromptSubmit).toBeDefined();
99
+ expect(parsed.hooks.UserPromptSubmit).toBeInstanceOf(Array);
100
+ });
101
+
102
+ test("output contains PreToolUse hook", async () => {
103
+ const worktreePath = join(tempDir, "worktree");
104
+
105
+ await deployHooks(worktreePath, "hook-check");
106
+
107
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
108
+ const content = await readFile(outputPath, "utf-8");
109
+ const parsed = JSON.parse(content);
110
+ expect(parsed.hooks.PreToolUse).toBeDefined();
111
+ expect(parsed.hooks.PreToolUse).toBeInstanceOf(Array);
112
+ });
113
+
114
+ test("output contains PostToolUse hook", async () => {
115
+ const worktreePath = join(tempDir, "worktree");
116
+
117
+ await deployHooks(worktreePath, "hook-check");
118
+
119
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
120
+ const content = await readFile(outputPath, "utf-8");
121
+ const parsed = JSON.parse(content);
122
+ expect(parsed.hooks.PostToolUse).toBeDefined();
123
+ expect(parsed.hooks.PostToolUse).toBeInstanceOf(Array);
124
+ });
125
+
126
+ test("output contains Stop hook", async () => {
127
+ const worktreePath = join(tempDir, "worktree");
128
+
129
+ await deployHooks(worktreePath, "hook-check");
130
+
131
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
132
+ const content = await readFile(outputPath, "utf-8");
133
+ const parsed = JSON.parse(content);
134
+ expect(parsed.hooks.Stop).toBeDefined();
135
+ expect(parsed.hooks.Stop).toBeInstanceOf(Array);
136
+ });
137
+
138
+ test("PostToolUse hook includes debounced mail check entry", async () => {
139
+ const worktreePath = join(tempDir, "worktree");
140
+
141
+ await deployHooks(worktreePath, "mail-check-agent");
142
+
143
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
144
+ const content = await readFile(outputPath, "utf-8");
145
+ const parsed = JSON.parse(content);
146
+ const postToolUse = parsed.hooks.PostToolUse;
147
+ // PostToolUse should have 3 entries: logger, mail check, mulch diff
148
+ expect(postToolUse).toHaveLength(3);
149
+ // First entry is the logging hook
150
+ expect(postToolUse[0].hooks[0].command).toContain("legio log tool-end");
151
+ // Second entry is the debounced mail check
152
+ expect(postToolUse[1].hooks[0].command).toContain("legio mail check --inject");
153
+ expect(postToolUse[1].hooks[0].command).toContain("mail-check-agent");
154
+ expect(postToolUse[1].hooks[0].command).toContain("--debounce 30000");
155
+ expect(postToolUse[1].hooks[0].command).toContain("LEGIO_AGENT_NAME");
156
+ // Third entry is mulch diff after commit
157
+ expect(postToolUse[2].matcher).toBe("Bash");
158
+ expect(postToolUse[2].hooks[0].command).toContain("mulch diff HEAD~1");
159
+ });
160
+
161
+ test("output contains PreCompact hook", async () => {
162
+ const worktreePath = join(tempDir, "worktree");
163
+
164
+ await deployHooks(worktreePath, "hook-check");
165
+
166
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
167
+ const content = await readFile(outputPath, "utf-8");
168
+ const parsed = JSON.parse(content);
169
+ expect(parsed.hooks.PreCompact).toBeDefined();
170
+ expect(parsed.hooks.PreCompact).toBeInstanceOf(Array);
171
+ });
172
+
173
+ test("all six hook types are present", async () => {
174
+ const worktreePath = join(tempDir, "worktree");
175
+
176
+ await deployHooks(worktreePath, "all-hooks");
177
+
178
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
179
+ const content = await readFile(outputPath, "utf-8");
180
+ const parsed = JSON.parse(content);
181
+ const hookTypes = Object.keys(parsed.hooks);
182
+ expect(hookTypes).toContain("SessionStart");
183
+ expect(hookTypes).toContain("UserPromptSubmit");
184
+ expect(hookTypes).toContain("PreToolUse");
185
+ expect(hookTypes).toContain("PostToolUse");
186
+ expect(hookTypes).toContain("Stop");
187
+ expect(hookTypes).toContain("PreCompact");
188
+ expect(hookTypes).toHaveLength(6);
189
+ });
190
+
191
+ test("SessionStart hook runs legio prime with agent name", async () => {
192
+ const worktreePath = join(tempDir, "worktree");
193
+
194
+ await deployHooks(worktreePath, "prime-agent");
195
+
196
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
197
+ const content = await readFile(outputPath, "utf-8");
198
+ const parsed = JSON.parse(content);
199
+ const sessionStart = parsed.hooks.SessionStart[0];
200
+ expect(sessionStart.hooks[0].type).toBe("command");
201
+ expect(sessionStart.hooks[0].command).toContain("legio prime --agent prime-agent");
202
+ expect(sessionStart.hooks[0].command).toContain("LEGIO_AGENT_NAME");
203
+ });
204
+
205
+ test("UserPromptSubmit hook runs mail check with agent name", async () => {
206
+ const worktreePath = join(tempDir, "worktree");
207
+
208
+ await deployHooks(worktreePath, "mail-agent");
209
+
210
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
211
+ const content = await readFile(outputPath, "utf-8");
212
+ const parsed = JSON.parse(content);
213
+ const userPrompt = parsed.hooks.UserPromptSubmit[0];
214
+ expect(userPrompt.hooks[0].command).toContain("legio mail check --inject --agent mail-agent");
215
+ expect(userPrompt.hooks[0].command).toContain("LEGIO_AGENT_NAME");
216
+ });
217
+
218
+ test("PreCompact hook runs legio prime with --compact flag", async () => {
219
+ const worktreePath = join(tempDir, "worktree");
220
+
221
+ await deployHooks(worktreePath, "compact-agent");
222
+
223
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
224
+ const content = await readFile(outputPath, "utf-8");
225
+ const parsed = JSON.parse(content);
226
+ const preCompact = parsed.hooks.PreCompact[0];
227
+ expect(preCompact.hooks[0].type).toBe("command");
228
+ expect(preCompact.hooks[0].command).toContain("legio prime --agent compact-agent --compact");
229
+ expect(preCompact.hooks[0].command).toContain("LEGIO_AGENT_NAME");
230
+ });
231
+
232
+ test("PreToolUse hook pipes stdin to legio log with --stdin flag", async () => {
233
+ const worktreePath = join(tempDir, "worktree");
234
+
235
+ await deployHooks(worktreePath, "stdin-agent");
236
+
237
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
238
+ const content = await readFile(outputPath, "utf-8");
239
+ const parsed = JSON.parse(content);
240
+
241
+ // Find the base PreToolUse hook (matcher == "")
242
+ const preToolUse = parsed.hooks.PreToolUse;
243
+ const baseHook = preToolUse.find((h: { matcher: string }) => h.matcher === "");
244
+ expect(baseHook).toBeDefined();
245
+ expect(baseHook.hooks[0].command).toContain("--stdin");
246
+ expect(baseHook.hooks[0].command).toContain("legio log tool-start");
247
+ expect(baseHook.hooks[0].command).toContain("stdin-agent");
248
+ expect(baseHook.hooks[0].command).not.toContain("read -r INPUT");
249
+ });
250
+
251
+ test("PostToolUse hook pipes stdin to legio log with --stdin flag", async () => {
252
+ const worktreePath = join(tempDir, "worktree");
253
+
254
+ await deployHooks(worktreePath, "stdin-agent");
255
+
256
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
257
+ const content = await readFile(outputPath, "utf-8");
258
+ const parsed = JSON.parse(content);
259
+ const postToolUse = parsed.hooks.PostToolUse[0];
260
+ expect(postToolUse.hooks[0].command).toContain("--stdin");
261
+ expect(postToolUse.hooks[0].command).toContain("legio log tool-end");
262
+ expect(postToolUse.hooks[0].command).toContain("stdin-agent");
263
+ expect(postToolUse.hooks[0].command).not.toContain("read -r INPUT");
264
+ });
265
+
266
+ test("PostToolUse hook includes mail check with debounce", async () => {
267
+ const worktreePath = join(tempDir, "mail-debounce-wt");
268
+
269
+ await deployHooks(worktreePath, "mail-debounce-agent");
270
+
271
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
272
+ const content = await readFile(outputPath, "utf-8");
273
+ const parsed = JSON.parse(content);
274
+ const postToolUse = parsed.hooks.PostToolUse[0];
275
+
276
+ // Should have 2 hooks: tool-end logging + mail check
277
+ expect(postToolUse.hooks).toHaveLength(2);
278
+
279
+ // Second hook should be mail check with debounce
280
+ expect(postToolUse.hooks[1].command).toContain("legio mail check");
281
+ expect(postToolUse.hooks[1].command).toContain("--inject");
282
+ expect(postToolUse.hooks[1].command).toContain("--agent mail-debounce-agent");
283
+ expect(postToolUse.hooks[1].command).toContain("--debounce 500");
284
+ expect(postToolUse.hooks[1].command).toContain("LEGIO_AGENT_NAME");
285
+ });
286
+
287
+ test("PostToolUse contains mulch diff hook triggered after git commits", async () => {
288
+ const worktreePath = join(tempDir, "mulch-diff-wt");
289
+
290
+ await deployHooks(worktreePath, "mulch-diff-agent");
291
+
292
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
293
+ const content = await readFile(outputPath, "utf-8");
294
+ const parsed = JSON.parse(content);
295
+ const postToolUse = parsed.hooks.PostToolUse;
296
+
297
+ // Third entry should be the mulch diff hook (Bash matcher)
298
+ const mulchDiffEntry = postToolUse.find((h: { matcher: string }) => h.matcher === "Bash");
299
+ expect(mulchDiffEntry).toBeDefined();
300
+ expect(mulchDiffEntry.hooks[0].command).toContain("mulch diff HEAD~1");
301
+ expect(mulchDiffEntry.hooks[0].command).toContain("git\\s+commit");
302
+ expect(mulchDiffEntry.hooks[0].command).toContain("LEGIO_AGENT_NAME");
303
+ });
304
+
305
+ test("PreToolUse template contains push-guard blocking git push", async () => {
306
+ const worktreePath = join(tempDir, "push-guard-wt");
307
+
308
+ await deployHooks(worktreePath, "push-guard-agent");
309
+
310
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
311
+ const content = await readFile(outputPath, "utf-8");
312
+ const parsed = JSON.parse(content);
313
+ const preToolUse = parsed.hooks.PreToolUse;
314
+
315
+ // There should be at least one Bash hook that blocks git push
316
+ const pushGuards = preToolUse.filter(
317
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
318
+ h.matcher === "Bash" && h.hooks[0]?.command?.includes("git push is blocked"),
319
+ );
320
+ expect(pushGuards.length).toBeGreaterThanOrEqual(1);
321
+ expect(pushGuards[0].hooks[0].command).toContain('"decision":"block"');
322
+ expect(pushGuards[0].hooks[0].command).toContain("LEGIO_AGENT_NAME");
323
+ });
324
+
325
+ test("Stop hook pipes stdin to legio log with --stdin flag", async () => {
326
+ const worktreePath = join(tempDir, "worktree");
327
+
328
+ await deployHooks(worktreePath, "stdin-agent");
329
+
330
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
331
+ const content = await readFile(outputPath, "utf-8");
332
+ const parsed = JSON.parse(content);
333
+ const stop = parsed.hooks.Stop[0];
334
+ expect(stop.hooks[0].command).toContain("--stdin");
335
+ expect(stop.hooks[0].command).toContain("legio log session-end");
336
+ expect(stop.hooks[0].command).toContain("stdin-agent");
337
+ expect(stop.hooks[0].command).not.toContain("read -r INPUT");
338
+ });
339
+
340
+ test("Stop hook includes mulch learn command", async () => {
341
+ const worktreePath = join(tempDir, "worktree");
342
+
343
+ await deployHooks(worktreePath, "learn-agent");
344
+
345
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
346
+ const content = await readFile(outputPath, "utf-8");
347
+ const parsed = JSON.parse(content);
348
+ const stop = parsed.hooks.Stop[0];
349
+ expect(stop.hooks.length).toBe(2);
350
+ expect(stop.hooks[1].command).toContain("mulch learn");
351
+ expect(stop.hooks[1].command).toContain("LEGIO_AGENT_NAME");
352
+ });
353
+
354
+ test("hook commands no longer use sed-based extraction", async () => {
355
+ const worktreePath = join(tempDir, "worktree");
356
+
357
+ await deployHooks(worktreePath, "no-sed-agent");
358
+
359
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
360
+ const content = await readFile(outputPath, "utf-8");
361
+ const parsed = JSON.parse(content);
362
+
363
+ // PreToolUse base hook should not contain sed or --tool-name
364
+ const preToolUse = parsed.hooks.PreToolUse;
365
+ const basePreHook = preToolUse.find((h: { matcher: string }) => h.matcher === "");
366
+ expect(basePreHook.hooks[0].command).not.toContain("--tool-name");
367
+ expect(basePreHook.hooks[0].command).not.toContain("TOOL_NAME=$(");
368
+
369
+ // PostToolUse should not contain sed or --tool-name
370
+ const postToolUse = parsed.hooks.PostToolUse[0];
371
+ expect(postToolUse.hooks[0].command).not.toContain("--tool-name");
372
+ expect(postToolUse.hooks[0].command).not.toContain("TOOL_NAME=$(");
373
+ });
374
+
375
+ test("creates .claude directory even if worktree already exists", async () => {
376
+ const worktreePath = join(tempDir, "existing-worktree");
377
+ const { mkdir } = await import("node:fs/promises");
378
+ await mkdir(worktreePath, { recursive: true });
379
+
380
+ await deployHooks(worktreePath, "test-agent");
381
+
382
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
383
+ const exists = await access(outputPath)
384
+ .then(() => true)
385
+ .catch(() => false);
386
+ expect(exists).toBe(true);
387
+ });
388
+
389
+ test("overwrites existing settings.local.json", async () => {
390
+ const worktreePath = join(tempDir, "worktree");
391
+ const claudeDir = join(worktreePath, ".claude");
392
+ const { mkdir } = await import("node:fs/promises");
393
+ await mkdir(claudeDir, { recursive: true });
394
+ await writeFile(join(claudeDir, "settings.local.json"), '{"old": true}');
395
+
396
+ await deployHooks(worktreePath, "new-agent");
397
+
398
+ const content = await readFile(join(claudeDir, "settings.local.json"), "utf-8");
399
+ expect(content).toContain("new-agent");
400
+ expect(content).not.toContain('"old"');
401
+ });
402
+
403
+ test("handles agent names with special characters", async () => {
404
+ const worktreePath = join(tempDir, "worktree");
405
+
406
+ await deployHooks(worktreePath, "agent-with-dashes-123");
407
+
408
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
409
+ const content = await readFile(outputPath, "utf-8");
410
+ expect(content).toContain("agent-with-dashes-123");
411
+ // Should still be valid JSON
412
+ const parsed = JSON.parse(content);
413
+ expect(parsed.hooks).toBeDefined();
414
+ });
415
+
416
+ test("throws AgentError when template is missing", async () => {
417
+ // We can't easily remove the template without affecting the repo,
418
+ // but we can verify the error type by testing the module's behavior.
419
+ // The function uses getTemplatePath() internally which is not exported,
420
+ // so we test indirectly: verify that a successful call works, confirming
421
+ // the template exists. The error path is tested via the error type assertion.
422
+ const worktreePath = join(tempDir, "worktree");
423
+
424
+ // Successful deployment proves the template exists
425
+ await deployHooks(worktreePath, "template-exists");
426
+ const exists = await access(join(worktreePath, ".claude", "settings.local.json"))
427
+ .then(() => true)
428
+ .catch(() => false);
429
+ expect(exists).toBe(true);
430
+ });
431
+
432
+ test("AgentError includes agent name in context", async () => {
433
+ // Verify AgentError shape by constructing one (as the function does internally)
434
+ const error = new AgentError("test error", { agentName: "failing-agent" });
435
+ expect(error.agentName).toBe("failing-agent");
436
+ expect(error.code).toBe("AGENT_ERROR");
437
+ expect(error.name).toBe("AgentError");
438
+ expect(error.message).toBe("test error");
439
+ });
440
+
441
+ test("write failure throws AgentError", async () => {
442
+ // Use a path that will fail to write (read-only parent)
443
+ const invalidPath = "/dev/null/impossible-path";
444
+
445
+ try {
446
+ await deployHooks(invalidPath, "fail-agent");
447
+ // Should not reach here
448
+ expect(true).toBe(false);
449
+ } catch (err) {
450
+ expect(err).toBeInstanceOf(AgentError);
451
+ if (err instanceof AgentError) {
452
+ expect(err.agentName).toBe("fail-agent");
453
+ expect(err.code).toBe("AGENT_ERROR");
454
+ }
455
+ }
456
+ });
457
+
458
+ test("scout capability adds path boundary + block guards for Write/Edit/NotebookEdit and Bash file guards", async () => {
459
+ const worktreePath = join(tempDir, "scout-wt");
460
+
461
+ await deployHooks(worktreePath, "scout-agent", "scout");
462
+
463
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
464
+ const content = await readFile(outputPath, "utf-8");
465
+ const parsed = JSON.parse(content);
466
+ const preToolUse = parsed.hooks.PreToolUse;
467
+
468
+ // Scout gets both path boundary guards AND block guards for Write/Edit/NotebookEdit
469
+ const writeGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Write");
470
+ const editGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Edit");
471
+ const notebookGuards = preToolUse.filter(
472
+ (h: { matcher: string }) => h.matcher === "NotebookEdit",
473
+ );
474
+
475
+ // 2 each: path boundary + capability block
476
+ expect(writeGuards.length).toBe(2);
477
+ expect(editGuards.length).toBe(2);
478
+ expect(notebookGuards.length).toBe(2);
479
+
480
+ // Find the capability block guard (contains "cannot modify files")
481
+ const writeBlockGuard = writeGuards.find((h: { hooks: Array<{ command: string }> }) =>
482
+ h.hooks[0]?.command?.includes("cannot modify files"),
483
+ );
484
+ expect(writeBlockGuard).toBeDefined();
485
+ expect(writeBlockGuard.hooks[0].command).toContain('"decision":"block"');
486
+
487
+ // Should have multiple Bash guards: danger guard + file guard + template push-guard
488
+ const bashGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Bash");
489
+ expect(bashGuards.length).toBe(3); // danger guard + file guard + template push-guard
490
+ });
491
+
492
+ test("reviewer capability adds same guards as scout", async () => {
493
+ const worktreePath = join(tempDir, "reviewer-wt");
494
+
495
+ await deployHooks(worktreePath, "reviewer-agent", "reviewer");
496
+
497
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
498
+ const content = await readFile(outputPath, "utf-8");
499
+ const parsed = JSON.parse(content);
500
+ const preToolUse = parsed.hooks.PreToolUse;
501
+
502
+ const guardMatchers = preToolUse
503
+ .filter((h: { matcher: string }) => h.matcher !== "")
504
+ .map((h: { matcher: string }) => h.matcher);
505
+
506
+ expect(guardMatchers).toContain("Bash");
507
+ expect(guardMatchers).toContain("Write");
508
+ expect(guardMatchers).toContain("Edit");
509
+ expect(guardMatchers).toContain("NotebookEdit");
510
+ });
511
+
512
+ test("lead capability gets Write/Edit/NotebookEdit guards and Bash file guards", async () => {
513
+ const worktreePath = join(tempDir, "lead-wt");
514
+
515
+ await deployHooks(worktreePath, "lead-agent", "lead");
516
+
517
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
518
+ const content = await readFile(outputPath, "utf-8");
519
+ const parsed = JSON.parse(content);
520
+ const preToolUse = parsed.hooks.PreToolUse;
521
+
522
+ const guardMatchers = preToolUse
523
+ .filter((h: { matcher: string }) => h.matcher !== "")
524
+ .map((h: { matcher: string }) => h.matcher);
525
+
526
+ expect(guardMatchers).toContain("Write");
527
+ expect(guardMatchers).toContain("Edit");
528
+ expect(guardMatchers).toContain("NotebookEdit");
529
+ expect(guardMatchers).toContain("Bash");
530
+
531
+ // Should have 3 Bash guards: danger guard + file guard + template push-guard
532
+ const bashGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Bash");
533
+ expect(bashGuards.length).toBe(3);
534
+ });
535
+
536
+ test("builder capability gets path boundary + Bash danger + Bash path boundary guards + native team tool blocks", async () => {
537
+ const worktreePath = join(tempDir, "builder-wt");
538
+
539
+ await deployHooks(worktreePath, "builder-agent", "builder");
540
+
541
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
542
+ const content = await readFile(outputPath, "utf-8");
543
+ const parsed = JSON.parse(content);
544
+ const preToolUse = parsed.hooks.PreToolUse;
545
+
546
+ const guardMatchers = preToolUse
547
+ .filter((h: { matcher: string }) => h.matcher !== "")
548
+ .map((h: { matcher: string }) => h.matcher);
549
+
550
+ // Path boundary guards + Bash danger guard + Bash path boundary guard + 10 native team tool blocks
551
+ expect(guardMatchers).toContain("Bash");
552
+ expect(guardMatchers).toContain("Task");
553
+ expect(guardMatchers).toContain("TeamCreate");
554
+ // Builder has Write guards for path boundary (not block guards)
555
+ expect(guardMatchers).toContain("Write");
556
+ const writeGuards = preToolUse.filter(
557
+ (h: { matcher: string; hooks: Array<{ command: string }> }) => h.matcher === "Write",
558
+ );
559
+ // Path boundary guard, not a full block
560
+ expect(writeGuards[0].hooks[0].command).toContain("LEGIO_WORKTREE_PATH");
561
+ expect(writeGuards[0].hooks[0].command).not.toContain("cannot modify files");
562
+
563
+ // Builder should have 3 Bash guards: danger guard + path boundary guard + template push-guard
564
+ const bashGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Bash");
565
+ expect(bashGuards.length).toBe(3);
566
+ // One should be the danger guard (checks git push)
567
+ const dangerGuard = bashGuards.find(
568
+ (h: { hooks: Array<{ command: string }> }) =>
569
+ h.hooks[0]?.command?.includes("git") && h.hooks[0]?.command?.includes("push"),
570
+ );
571
+ expect(dangerGuard).toBeDefined();
572
+ // One should be the path boundary guard
573
+ const pathBoundaryGuard = bashGuards.find((h: { hooks: Array<{ command: string }> }) =>
574
+ h.hooks[0]?.command?.includes("Bash path boundary violation"),
575
+ );
576
+ expect(pathBoundaryGuard).toBeDefined();
577
+ });
578
+
579
+ test("merger capability gets path boundary + Bash danger guards + native team tool blocks", async () => {
580
+ const worktreePath = join(tempDir, "merger-wt");
581
+
582
+ await deployHooks(worktreePath, "merger-agent", "merger");
583
+
584
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
585
+ const content = await readFile(outputPath, "utf-8");
586
+ const parsed = JSON.parse(content);
587
+ const preToolUse = parsed.hooks.PreToolUse;
588
+
589
+ const guardMatchers = preToolUse
590
+ .filter((h: { matcher: string }) => h.matcher !== "")
591
+ .map((h: { matcher: string }) => h.matcher);
592
+
593
+ expect(guardMatchers).toContain("Bash");
594
+ expect(guardMatchers).toContain("Task");
595
+ // Merger has Write path boundary guards
596
+ expect(guardMatchers).toContain("Write");
597
+ });
598
+
599
+ test("default capability (no arg) gets path boundary + Bash danger guards + native team tool blocks", async () => {
600
+ const worktreePath = join(tempDir, "default-wt");
601
+
602
+ await deployHooks(worktreePath, "default-agent");
603
+
604
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
605
+ const content = await readFile(outputPath, "utf-8");
606
+ const parsed = JSON.parse(content);
607
+ const preToolUse = parsed.hooks.PreToolUse;
608
+
609
+ const guardMatchers = preToolUse
610
+ .filter((h: { matcher: string }) => h.matcher !== "")
611
+ .map((h: { matcher: string }) => h.matcher);
612
+
613
+ expect(guardMatchers).toContain("Bash");
614
+ expect(guardMatchers).toContain("Task");
615
+ // Default (builder) has Write path boundary guards
616
+ expect(guardMatchers).toContain("Write");
617
+ });
618
+
619
+ test("guards are prepended before base logging hook", async () => {
620
+ const worktreePath = join(tempDir, "order-wt");
621
+
622
+ await deployHooks(worktreePath, "order-agent", "scout");
623
+
624
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
625
+ const content = await readFile(outputPath, "utf-8");
626
+ const parsed = JSON.parse(content);
627
+ const preToolUse = parsed.hooks.PreToolUse;
628
+
629
+ // Guards (matcher != "") should come before base (matcher == "")
630
+ const baseIdx = preToolUse.findIndex((h: { matcher: string }) => h.matcher === "");
631
+ const writeIdx = preToolUse.findIndex((h: { matcher: string }) => h.matcher === "Write");
632
+
633
+ expect(writeIdx).toBeLessThan(baseIdx);
634
+ });
635
+ });
636
+
637
+ describe("getCapabilityGuards", () => {
638
+ // 10 native team tool blocks + 3 interactive tool blocks apply to ALL capabilities
639
+ const NATIVE_TEAM_TOOL_COUNT = 10;
640
+ const INTERACTIVE_TOOL_COUNT = 3;
641
+ const BASE_GUARD_COUNT = NATIVE_TEAM_TOOL_COUNT + INTERACTIVE_TOOL_COUNT;
642
+
643
+ test("returns 17 guards for scout (10 team + 3 interactive + 3 tool blocks + 1 bash file guard)", () => {
644
+ const guards = getCapabilityGuards("scout");
645
+ expect(guards.length).toBe(BASE_GUARD_COUNT + 4);
646
+ });
647
+
648
+ test("returns 17 guards for reviewer (10 team + 3 interactive + 3 tool blocks + 1 bash file guard)", () => {
649
+ const guards = getCapabilityGuards("reviewer");
650
+ expect(guards.length).toBe(BASE_GUARD_COUNT + 4);
651
+ });
652
+
653
+ test("returns 17 guards for lead (10 team + 3 interactive + 3 tool blocks + 1 bash file guard)", () => {
654
+ const guards = getCapabilityGuards("lead");
655
+ expect(guards.length).toBe(BASE_GUARD_COUNT + 4);
656
+ });
657
+
658
+ test("returns 14 guards for builder (10 team + 3 interactive + 1 bash path boundary)", () => {
659
+ const guards = getCapabilityGuards("builder");
660
+ expect(guards.length).toBe(BASE_GUARD_COUNT + 1);
661
+ });
662
+
663
+ test("returns 14 guards for merger (10 team + 3 interactive + 1 bash path boundary)", () => {
664
+ const guards = getCapabilityGuards("merger");
665
+ expect(guards.length).toBe(BASE_GUARD_COUNT + 1);
666
+ });
667
+
668
+ test("returns 13 guards for unknown capability (10 team + 3 interactive tool blocks only)", () => {
669
+ const guards = getCapabilityGuards("unknown");
670
+ expect(guards.length).toBe(BASE_GUARD_COUNT);
671
+ });
672
+
673
+ test("builder gets Bash path boundary guard", () => {
674
+ const guards = getCapabilityGuards("builder");
675
+ const bashGuard = guards.find((g) => g.matcher === "Bash");
676
+ expect(bashGuard).toBeDefined();
677
+ expect(bashGuard?.hooks[0]?.command).toContain("LEGIO_WORKTREE_PATH");
678
+ expect(bashGuard?.hooks[0]?.command).toContain("Bash path boundary violation");
679
+ });
680
+
681
+ test("merger gets Bash path boundary guard", () => {
682
+ const guards = getCapabilityGuards("merger");
683
+ const bashGuard = guards.find((g) => g.matcher === "Bash");
684
+ expect(bashGuard).toBeDefined();
685
+ expect(bashGuard?.hooks[0]?.command).toContain("LEGIO_WORKTREE_PATH");
686
+ expect(bashGuard?.hooks[0]?.command).toContain("Bash path boundary violation");
687
+ });
688
+
689
+ test("scout guards include Write, Edit, NotebookEdit, and Bash matchers", () => {
690
+ const guards = getCapabilityGuards("scout");
691
+ const matchers = guards.map((g) => g.matcher);
692
+ expect(matchers).toContain("Write");
693
+ expect(matchers).toContain("Edit");
694
+ expect(matchers).toContain("NotebookEdit");
695
+ expect(matchers).toContain("Bash");
696
+ });
697
+
698
+ test("lead guards include Write, Edit, NotebookEdit, and Bash matchers", () => {
699
+ const guards = getCapabilityGuards("lead");
700
+ const matchers = guards.map((g) => g.matcher);
701
+ expect(matchers).toContain("Write");
702
+ expect(matchers).toContain("Edit");
703
+ expect(matchers).toContain("NotebookEdit");
704
+ expect(matchers).toContain("Bash");
705
+ });
706
+
707
+ test("tool block guards include capability name in reason", () => {
708
+ const guards = getCapabilityGuards("scout");
709
+ const writeGuard = guards.find((g) => g.matcher === "Write");
710
+ expect(writeGuard).toBeDefined();
711
+ expect(writeGuard?.hooks[0]?.command).toContain("scout");
712
+ expect(writeGuard?.hooks[0]?.command).toContain("cannot modify files");
713
+ });
714
+
715
+ test("lead tool block guards include lead in reason", () => {
716
+ const guards = getCapabilityGuards("lead");
717
+ const editGuard = guards.find((g) => g.matcher === "Edit");
718
+ expect(editGuard).toBeDefined();
719
+ expect(editGuard?.hooks[0]?.command).toContain("lead");
720
+ expect(editGuard?.hooks[0]?.command).toContain("cannot modify files");
721
+ });
722
+
723
+ test("bash file guard for scout includes capability in block message", () => {
724
+ const guards = getCapabilityGuards("scout");
725
+ const bashGuard = guards.find((g) => g.matcher === "Bash");
726
+ expect(bashGuard).toBeDefined();
727
+ expect(bashGuard?.hooks[0]?.command).toContain("scout agents cannot modify files");
728
+ });
729
+
730
+ test("bash file guard for lead includes capability in block message", () => {
731
+ const guards = getCapabilityGuards("lead");
732
+ const bashGuard = guards.find((g) => g.matcher === "Bash");
733
+ expect(bashGuard).toBeDefined();
734
+ expect(bashGuard?.hooks[0]?.command).toContain("lead agents cannot modify files");
735
+ });
736
+
737
+ test("all capabilities get Task tool blocked", () => {
738
+ for (const cap of [
739
+ "scout",
740
+ "reviewer",
741
+ "lead",
742
+ "coordinator",
743
+ "supervisor",
744
+ "builder",
745
+ "merger",
746
+ ]) {
747
+ const guards = getCapabilityGuards(cap);
748
+ const taskGuard = guards.find((g) => g.matcher === "Task");
749
+ expect(taskGuard).toBeDefined();
750
+ expect(taskGuard?.hooks[0]?.command).toContain("legio sling");
751
+ }
752
+ });
753
+
754
+ test("all capabilities get TeamCreate and SendMessage blocked", () => {
755
+ for (const cap of [
756
+ "scout",
757
+ "reviewer",
758
+ "lead",
759
+ "coordinator",
760
+ "supervisor",
761
+ "builder",
762
+ "merger",
763
+ ]) {
764
+ const guards = getCapabilityGuards(cap);
765
+ const matchers = guards.map((g) => g.matcher);
766
+ expect(matchers).toContain("TeamCreate");
767
+ expect(matchers).toContain("SendMessage");
768
+ }
769
+ });
770
+
771
+ test("block guard commands include env var guard prefix", () => {
772
+ const guards = getCapabilityGuards("scout");
773
+ for (const tool of ["Write", "Edit", "NotebookEdit"]) {
774
+ const guard = guards.find((g) => g.matcher === tool);
775
+ expect(guard).toBeDefined();
776
+ expect(guard?.hooks[0]?.command).toContain('[ -z "$LEGIO_AGENT_NAME" ] && exit 0;');
777
+ }
778
+ });
779
+
780
+ test("native team tool block guards include env var guard prefix", () => {
781
+ const guards = getCapabilityGuards("builder");
782
+ const taskGuard = guards.find((g) => g.matcher === "Task");
783
+ expect(taskGuard).toBeDefined();
784
+ expect(taskGuard?.hooks[0]?.command).toContain('[ -z "$LEGIO_AGENT_NAME" ] && exit 0;');
785
+ });
786
+
787
+ test("coordinator gets 17 guards (10 team + 3 interactive + 3 tool blocks + 1 bash file guard)", () => {
788
+ const guards = getCapabilityGuards("coordinator");
789
+ expect(guards.length).toBe(BASE_GUARD_COUNT + 4);
790
+ });
791
+
792
+ test("supervisor gets 17 guards (10 team + 3 interactive + 3 tool blocks + 1 bash file guard)", () => {
793
+ const guards = getCapabilityGuards("supervisor");
794
+ expect(guards.length).toBe(BASE_GUARD_COUNT + 4);
795
+ });
796
+
797
+ test("builder gets AskUserQuestion, EnterPlanMode, EnterWorktree blocked", () => {
798
+ const guards = getCapabilityGuards("builder");
799
+ const matchers = guards.map((g) => g.matcher);
800
+ expect(matchers).toContain("AskUserQuestion");
801
+ expect(matchers).toContain("EnterPlanMode");
802
+ expect(matchers).toContain("EnterWorktree");
803
+ });
804
+
805
+ test("scout gets AskUserQuestion, EnterPlanMode, EnterWorktree blocked", () => {
806
+ const guards = getCapabilityGuards("scout");
807
+ const matchers = guards.map((g) => g.matcher);
808
+ expect(matchers).toContain("AskUserQuestion");
809
+ expect(matchers).toContain("EnterPlanMode");
810
+ expect(matchers).toContain("EnterWorktree");
811
+ });
812
+
813
+ test("AskUserQuestion block reason instructs using legio mail", () => {
814
+ const guards = getCapabilityGuards("builder");
815
+ const guard = guards.find((g) => g.matcher === "AskUserQuestion");
816
+ expect(guard).toBeDefined();
817
+ expect(guard?.hooks[0]?.command).toContain("legio mail send --type question");
818
+ });
819
+
820
+ test("EnterPlanMode block reason references propulsion principle", () => {
821
+ const guards = getCapabilityGuards("builder");
822
+ const guard = guards.find((g) => g.matcher === "EnterPlanMode");
823
+ expect(guard).toBeDefined();
824
+ expect(guard?.hooks[0]?.command).toContain("propulsion principle");
825
+ });
826
+
827
+ test("EnterWorktree block reason explains assigned worktrees", () => {
828
+ const guards = getCapabilityGuards("builder");
829
+ const guard = guards.find((g) => g.matcher === "EnterWorktree");
830
+ expect(guard).toBeDefined();
831
+ expect(guard?.hooks[0]?.command).toContain("assigned worktrees");
832
+ });
833
+
834
+ test("interactive tool block guards include env var guard prefix", () => {
835
+ const guards = getCapabilityGuards("builder");
836
+ for (const tool of ["AskUserQuestion", "EnterPlanMode", "EnterWorktree"]) {
837
+ const guard = guards.find((g) => g.matcher === tool);
838
+ expect(guard).toBeDefined();
839
+ expect(guard?.hooks[0]?.command).toContain('[ -z "$LEGIO_AGENT_NAME" ] && exit 0;');
840
+ }
841
+ });
842
+
843
+ test("interactive tool block guards produce decision:block", () => {
844
+ const guards = getCapabilityGuards("scout");
845
+ for (const tool of ["AskUserQuestion", "EnterPlanMode", "EnterWorktree"]) {
846
+ const guard = guards.find((g) => g.matcher === tool);
847
+ expect(guard).toBeDefined();
848
+ expect(guard?.hooks[0]?.command).toContain('"decision":"block"');
849
+ }
850
+ });
851
+ });
852
+
853
+ describe("getDangerGuards", () => {
854
+ test("returns exactly one Bash guard entry", () => {
855
+ const guards = getDangerGuards("test-agent");
856
+ expect(guards).toHaveLength(1);
857
+ expect(guards[0]?.matcher).toBe("Bash");
858
+ });
859
+
860
+ test("guard command includes agent name for branch validation", () => {
861
+ const guards = getDangerGuards("my-builder");
862
+ const command = guards[0]?.hooks[0]?.command ?? "";
863
+ expect(command).toContain("legio/my-builder/");
864
+ });
865
+
866
+ test("guard command blocks all git push", () => {
867
+ const guards = getDangerGuards("test-agent");
868
+ const command = guards[0]?.hooks[0]?.command ?? "";
869
+ expect(command).toContain("git");
870
+ expect(command).toContain("push");
871
+ expect(command).toContain("block");
872
+ });
873
+
874
+ test("guard command checks for git reset --hard", () => {
875
+ const guards = getDangerGuards("test-agent");
876
+ const command = guards[0]?.hooks[0]?.command ?? "";
877
+ expect(command).toContain("reset");
878
+ expect(command).toContain("--hard");
879
+ });
880
+
881
+ test("guard command checks for git checkout -b", () => {
882
+ const guards = getDangerGuards("test-agent");
883
+ const command = guards[0]?.hooks[0]?.command ?? "";
884
+ expect(command).toContain("checkout");
885
+ expect(command).toContain("-b");
886
+ });
887
+
888
+ test("guard hook type is command", () => {
889
+ const guards = getDangerGuards("test-agent");
890
+ expect(guards[0]?.hooks[0]?.type).toBe("command");
891
+ });
892
+
893
+ test("guard command includes env var guard prefix", () => {
894
+ const guards = getDangerGuards("test-agent");
895
+ const command = guards[0]?.hooks[0]?.command ?? "";
896
+ expect(command).toContain('[ -z "$LEGIO_AGENT_NAME" ] && exit 0;');
897
+ });
898
+
899
+ test("all capabilities get Bash danger guards in deployed hooks", async () => {
900
+ const capabilities = ["builder", "scout", "reviewer", "lead", "merger"];
901
+ const tempDir = await mkdtemp(join(tmpdir(), "legio-danger-test-"));
902
+
903
+ try {
904
+ for (const cap of capabilities) {
905
+ const worktreePath = join(tempDir, `${cap}-wt`);
906
+ await deployHooks(worktreePath, `${cap}-agent`, cap);
907
+
908
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
909
+ const content = await readFile(outputPath, "utf-8");
910
+ const parsed = JSON.parse(content);
911
+ const preToolUse = parsed.hooks.PreToolUse;
912
+
913
+ const bashGuard = preToolUse.find((h: { matcher: string }) => h.matcher === "Bash");
914
+ expect(bashGuard).toBeDefined();
915
+ expect(bashGuard.hooks[0].command).toContain(`legio/${cap}-agent/`);
916
+ }
917
+ } finally {
918
+ await rm(tempDir, { recursive: true, force: true });
919
+ }
920
+ });
921
+
922
+ test("guard ordering: path boundary → danger → capability in scout", async () => {
923
+ const tempDir = await mkdtemp(join(tmpdir(), "legio-order-test-"));
924
+
925
+ try {
926
+ const worktreePath = join(tempDir, "scout-order-wt");
927
+ await deployHooks(worktreePath, "scout-order", "scout");
928
+
929
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
930
+ const content = await readFile(outputPath, "utf-8");
931
+ const parsed = JSON.parse(content);
932
+ const preToolUse = parsed.hooks.PreToolUse;
933
+
934
+ // Path boundary Write guard (first) should come before Bash danger guard
935
+ const pathBoundaryWriteIdx = preToolUse.findIndex(
936
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
937
+ h.matcher === "Write" && h.hooks[0]?.command?.includes("LEGIO_WORKTREE_PATH"),
938
+ );
939
+ const bashDangerIdx = preToolUse.findIndex((h: { matcher: string }) => h.matcher === "Bash");
940
+ // Capability block Write guard should come after Bash danger guard
941
+ const writeBlockIdx = preToolUse.findIndex(
942
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
943
+ h.matcher === "Write" && h.hooks[0]?.command?.includes("cannot modify files"),
944
+ );
945
+
946
+ expect(pathBoundaryWriteIdx).toBeLessThan(bashDangerIdx);
947
+ expect(bashDangerIdx).toBeLessThan(writeBlockIdx);
948
+ } finally {
949
+ await rm(tempDir, { recursive: true, force: true });
950
+ }
951
+ });
952
+ });
953
+
954
+ describe("buildBashFileGuardScript", () => {
955
+ test("returns a string containing the capability name", () => {
956
+ const script = buildBashFileGuardScript("scout");
957
+ expect(script).toContain("scout agents cannot modify files");
958
+ });
959
+
960
+ test("reads stdin input", () => {
961
+ const script = buildBashFileGuardScript("scout");
962
+ expect(script).toContain("read -r INPUT");
963
+ });
964
+
965
+ test("extracts command from JSON input", () => {
966
+ const script = buildBashFileGuardScript("reviewer");
967
+ expect(script).toContain("CMD=$(");
968
+ });
969
+
970
+ test("includes safe prefix whitelist checks", () => {
971
+ const script = buildBashFileGuardScript("scout");
972
+ expect(script).toContain("legio ");
973
+ expect(script).toContain("bd ");
974
+ expect(script).toContain("git status");
975
+ expect(script).toContain("git log");
976
+ expect(script).toContain("git diff");
977
+ expect(script).toContain("mulch ");
978
+ expect(script).toContain("npm test");
979
+ expect(script).toContain("npm run lint");
980
+ });
981
+
982
+ test("includes dangerous command pattern checks", () => {
983
+ const script = buildBashFileGuardScript("lead");
984
+ // File modification commands
985
+ expect(script).toContain("sed");
986
+ expect(script).toContain("tee");
987
+ expect(script).toContain("vim");
988
+ expect(script).toContain("nano");
989
+ expect(script).toContain("mv");
990
+ expect(script).toContain("cp");
991
+ expect(script).toContain("rm");
992
+ expect(script).toContain("mkdir");
993
+ expect(script).toContain("touch");
994
+ // Git modification commands
995
+ expect(script).toContain("git\\s+add");
996
+ expect(script).toContain("git\\s+commit");
997
+ expect(script).toContain("git\\s+push");
998
+ });
999
+
1000
+ test("blocks sed -i for all non-implementation capabilities", () => {
1001
+ for (const cap of ["scout", "reviewer", "lead"]) {
1002
+ const script = buildBashFileGuardScript(cap);
1003
+ expect(script).toContain("sed\\s+-i");
1004
+ }
1005
+ });
1006
+
1007
+ test("blocks npm install", () => {
1008
+ const script = buildBashFileGuardScript("scout");
1009
+ expect(script).toContain("npm\\s+install");
1010
+ });
1011
+
1012
+ test("blocks file permission commands", () => {
1013
+ const script = buildBashFileGuardScript("reviewer");
1014
+ expect(script).toContain("chmod");
1015
+ expect(script).toContain("chown");
1016
+ });
1017
+
1018
+ test("blocks append redirect operator", () => {
1019
+ const script = buildBashFileGuardScript("lead");
1020
+ expect(script).toContain(">>");
1021
+ });
1022
+
1023
+ test("blocks node -e eval execution", () => {
1024
+ const script = buildBashFileGuardScript("scout");
1025
+ expect(script).toContain("node\\s+-e");
1026
+ });
1027
+
1028
+ test("blocks runtime eval flags (node --eval, deno eval, python -c, perl -e, ruby -e)", () => {
1029
+ const script = buildBashFileGuardScript("scout");
1030
+ expect(script).toContain("node\\s+--eval");
1031
+ expect(script).toContain("deno\\s+eval");
1032
+ expect(script).toContain("python3?\\s+-c");
1033
+ expect(script).toContain("perl\\s+-e");
1034
+ expect(script).toContain("ruby\\s+-e");
1035
+ });
1036
+
1037
+ test("includes env var guard prefix", () => {
1038
+ const script = buildBashFileGuardScript("scout");
1039
+ expect(script).toMatch(/^\[ -z "\$LEGIO_AGENT_NAME" \] && exit 0;/);
1040
+ });
1041
+
1042
+ test("accepts extra safe prefixes for coordinator", () => {
1043
+ const script = buildBashFileGuardScript("coordinator", ["git add", "git commit"]);
1044
+ expect(script).toContain("git add");
1045
+ expect(script).toContain("git commit");
1046
+ });
1047
+
1048
+ test("default script does not whitelist git add/commit", () => {
1049
+ const script = buildBashFileGuardScript("scout");
1050
+ // git add/commit should NOT be in the safe prefix checks (only in danger patterns)
1051
+ // The safe prefixes use exit 0, danger patterns use decision:block
1052
+ const safeSection = script.split("grep -qE '")[0] ?? "";
1053
+ expect(safeSection).not.toContain("'^\\s*git add'");
1054
+ expect(safeSection).not.toContain("'^\\s*git commit'");
1055
+ });
1056
+
1057
+ test("safe prefix checks use exit 0 to allow", () => {
1058
+ const script = buildBashFileGuardScript("scout");
1059
+ // Each safe prefix should have an exit 0 to allow the command
1060
+ expect(script).toContain("exit 0; fi;");
1061
+ });
1062
+
1063
+ test("dangerous pattern check outputs block decision JSON", () => {
1064
+ const script = buildBashFileGuardScript("reviewer");
1065
+ expect(script).toContain('"decision":"block"');
1066
+ expect(script).toContain("reviewer agents cannot modify files");
1067
+ });
1068
+ });
1069
+
1070
+ describe("structural enforcement integration", () => {
1071
+ let tempDir: string;
1072
+
1073
+ beforeEach(async () => {
1074
+ tempDir = await mkdtemp(join(tmpdir(), "legio-structural-test-"));
1075
+ });
1076
+
1077
+ afterEach(async () => {
1078
+ await rm(tempDir, { recursive: true, force: true });
1079
+ });
1080
+
1081
+ test("non-implementation agents have more guards than implementation agents", async () => {
1082
+ const scoutPath = join(tempDir, "scout-wt");
1083
+ const builderPath = join(tempDir, "builder-wt");
1084
+
1085
+ await deployHooks(scoutPath, "scout-1", "scout");
1086
+ await deployHooks(builderPath, "builder-1", "builder");
1087
+
1088
+ const scoutContent = await readFile(join(scoutPath, ".claude", "settings.local.json"), "utf-8");
1089
+ const builderContent = await readFile(
1090
+ join(builderPath, ".claude", "settings.local.json"),
1091
+ "utf-8",
1092
+ );
1093
+
1094
+ const scoutPreToolUse = JSON.parse(scoutContent).hooks.PreToolUse;
1095
+ const builderPreToolUse = JSON.parse(builderContent).hooks.PreToolUse;
1096
+
1097
+ // Scout should have more PreToolUse entries than builder
1098
+ expect(scoutPreToolUse.length).toBeGreaterThan(builderPreToolUse.length);
1099
+ });
1100
+
1101
+ test("scout and reviewer have identical guard structures", async () => {
1102
+ const scoutPath = join(tempDir, "scout-wt");
1103
+ const reviewerPath = join(tempDir, "reviewer-wt");
1104
+
1105
+ await deployHooks(scoutPath, "scout-1", "scout");
1106
+ await deployHooks(reviewerPath, "reviewer-1", "reviewer");
1107
+
1108
+ const scoutContent = await readFile(join(scoutPath, ".claude", "settings.local.json"), "utf-8");
1109
+ const reviewerContent = await readFile(
1110
+ join(reviewerPath, ".claude", "settings.local.json"),
1111
+ "utf-8",
1112
+ );
1113
+
1114
+ const scoutPreToolUse = JSON.parse(scoutContent).hooks.PreToolUse;
1115
+ const reviewerPreToolUse = JSON.parse(reviewerContent).hooks.PreToolUse;
1116
+
1117
+ // Same number of guards
1118
+ expect(scoutPreToolUse.length).toBe(reviewerPreToolUse.length);
1119
+
1120
+ // Same matchers (just different agent names in commands)
1121
+ const scoutMatchers = scoutPreToolUse.map((h: { matcher: string }) => h.matcher);
1122
+ const reviewerMatchers = reviewerPreToolUse.map((h: { matcher: string }) => h.matcher);
1123
+ expect(scoutMatchers).toEqual(reviewerMatchers);
1124
+ });
1125
+
1126
+ test("lead has same guard structure as scout/reviewer", async () => {
1127
+ const leadPath = join(tempDir, "lead-wt");
1128
+ const scoutPath = join(tempDir, "scout-wt");
1129
+
1130
+ await deployHooks(leadPath, "lead-1", "lead");
1131
+ await deployHooks(scoutPath, "scout-1", "scout");
1132
+
1133
+ const leadContent = await readFile(join(leadPath, ".claude", "settings.local.json"), "utf-8");
1134
+ const scoutContent = await readFile(join(scoutPath, ".claude", "settings.local.json"), "utf-8");
1135
+
1136
+ const leadPreToolUse = JSON.parse(leadContent).hooks.PreToolUse;
1137
+ const scoutPreToolUse = JSON.parse(scoutContent).hooks.PreToolUse;
1138
+
1139
+ // Same number of guards
1140
+ expect(leadPreToolUse.length).toBe(scoutPreToolUse.length);
1141
+
1142
+ // Same matchers
1143
+ const leadMatchers = leadPreToolUse.map((h: { matcher: string }) => h.matcher);
1144
+ const scoutMatchers = scoutPreToolUse.map((h: { matcher: string }) => h.matcher);
1145
+ expect(leadMatchers).toEqual(scoutMatchers);
1146
+ });
1147
+
1148
+ test("builder and merger have identical guard structures", async () => {
1149
+ const builderPath = join(tempDir, "builder-wt");
1150
+ const mergerPath = join(tempDir, "merger-wt");
1151
+
1152
+ await deployHooks(builderPath, "builder-1", "builder");
1153
+ await deployHooks(mergerPath, "merger-1", "merger");
1154
+
1155
+ const builderContent = await readFile(
1156
+ join(builderPath, ".claude", "settings.local.json"),
1157
+ "utf-8",
1158
+ );
1159
+ const mergerContent = await readFile(
1160
+ join(mergerPath, ".claude", "settings.local.json"),
1161
+ "utf-8",
1162
+ );
1163
+
1164
+ const builderPreToolUse = JSON.parse(builderContent).hooks.PreToolUse;
1165
+ const mergerPreToolUse = JSON.parse(mergerContent).hooks.PreToolUse;
1166
+
1167
+ // Same number of guards
1168
+ expect(builderPreToolUse.length).toBe(mergerPreToolUse.length);
1169
+
1170
+ // Same matchers
1171
+ const builderMatchers = builderPreToolUse.map((h: { matcher: string }) => h.matcher);
1172
+ const mergerMatchers = mergerPreToolUse.map((h: { matcher: string }) => h.matcher);
1173
+ expect(builderMatchers).toEqual(mergerMatchers);
1174
+ });
1175
+
1176
+ test("all deployed configs produce valid JSON", async () => {
1177
+ const capabilities = [
1178
+ "scout",
1179
+ "reviewer",
1180
+ "lead",
1181
+ "builder",
1182
+ "merger",
1183
+ "coordinator",
1184
+ "supervisor",
1185
+ ];
1186
+
1187
+ for (const cap of capabilities) {
1188
+ const wt = join(tempDir, `${cap}-wt`);
1189
+ await deployHooks(wt, `${cap}-agent`, cap);
1190
+
1191
+ const content = await readFile(join(wt, ".claude", "settings.local.json"), "utf-8");
1192
+ expect(() => JSON.parse(content)).not.toThrow();
1193
+ }
1194
+ });
1195
+
1196
+ test("coordinator bash guard whitelists git add and git commit", async () => {
1197
+ const worktreePath = join(tempDir, "coord-wt");
1198
+
1199
+ await deployHooks(worktreePath, "coordinator-agent", "coordinator");
1200
+
1201
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
1202
+ const content = await readFile(outputPath, "utf-8");
1203
+ const parsed = JSON.parse(content);
1204
+ const preToolUse = parsed.hooks.PreToolUse;
1205
+
1206
+ // Find the bash file guard (the second Bash entry, after the danger guard)
1207
+ const bashGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Bash");
1208
+ expect(bashGuards.length).toBe(3); // danger guard + file guard + template push-guard
1209
+
1210
+ // The file guard (second Bash guard) should whitelist git add/commit
1211
+ const fileGuard = bashGuards[1];
1212
+ expect(fileGuard.hooks[0].command).toContain("git add");
1213
+ expect(fileGuard.hooks[0].command).toContain("git commit");
1214
+ });
1215
+
1216
+ test("scout bash guard does NOT whitelist git add/commit", async () => {
1217
+ const worktreePath = join(tempDir, "scout-git-wt");
1218
+
1219
+ await deployHooks(worktreePath, "scout-git", "scout");
1220
+
1221
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
1222
+ const content = await readFile(outputPath, "utf-8");
1223
+ const parsed = JSON.parse(content);
1224
+ const preToolUse = parsed.hooks.PreToolUse;
1225
+
1226
+ const bashGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Bash");
1227
+ const fileGuard = bashGuards[1];
1228
+
1229
+ // The safe prefix section should not include git add or git commit for scouts
1230
+ const command = fileGuard.hooks[0].command;
1231
+ const safePrefixSection = command.split("grep -qE '")[0] ?? "";
1232
+ expect(safePrefixSection).not.toContain("'^\\s*git add'");
1233
+ expect(safePrefixSection).not.toContain("'^\\s*git commit'");
1234
+ });
1235
+
1236
+ test("coordinator and supervisor have same guard structure", async () => {
1237
+ const coordPath = join(tempDir, "coord-wt");
1238
+ const supPath = join(tempDir, "sup-wt");
1239
+
1240
+ await deployHooks(coordPath, "coord-1", "coordinator");
1241
+ await deployHooks(supPath, "sup-1", "supervisor");
1242
+
1243
+ const coordContent = await readFile(join(coordPath, ".claude", "settings.local.json"), "utf-8");
1244
+ const supContent = await readFile(join(supPath, ".claude", "settings.local.json"), "utf-8");
1245
+
1246
+ const coordPreToolUse = JSON.parse(coordContent).hooks.PreToolUse;
1247
+ const supPreToolUse = JSON.parse(supContent).hooks.PreToolUse;
1248
+
1249
+ // Same number of guards
1250
+ expect(coordPreToolUse.length).toBe(supPreToolUse.length);
1251
+
1252
+ // Same matchers
1253
+ const coordMatchers = coordPreToolUse.map((h: { matcher: string }) => h.matcher);
1254
+ const supMatchers = supPreToolUse.map((h: { matcher: string }) => h.matcher);
1255
+ expect(coordMatchers).toEqual(supMatchers);
1256
+ });
1257
+
1258
+ test("all template hooks include ENV_GUARD for project root isolation", async () => {
1259
+ const worktreePath = join(tempDir, "env-guard-tmpl-wt");
1260
+
1261
+ await deployHooks(worktreePath, "env-guard-agent", "builder");
1262
+
1263
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
1264
+ const content = await readFile(outputPath, "utf-8");
1265
+ const parsed = JSON.parse(content);
1266
+
1267
+ // All hook types from the template should include ENV_GUARD
1268
+ for (const hookType of [
1269
+ "SessionStart",
1270
+ "UserPromptSubmit",
1271
+ "PreCompact",
1272
+ "PostToolUse",
1273
+ "Stop",
1274
+ ]) {
1275
+ const hooks = parsed.hooks[hookType] as Array<{
1276
+ matcher: string;
1277
+ hooks: Array<{ command: string }>;
1278
+ }>;
1279
+ expect(hooks.length).toBeGreaterThan(0);
1280
+ const baseHook = hooks.find((h) => h.matcher === "");
1281
+ expect(baseHook).toBeDefined();
1282
+ expect(baseHook?.hooks[0]?.command).toContain("LEGIO_AGENT_NAME");
1283
+ }
1284
+
1285
+ // PreToolUse base hook (matcher == "") should also have ENV_GUARD
1286
+ const preToolUse = parsed.hooks.PreToolUse as Array<{
1287
+ matcher: string;
1288
+ hooks: Array<{ command: string }>;
1289
+ }>;
1290
+ const basePreToolUse = preToolUse.find((h) => h.matcher === "");
1291
+ expect(basePreToolUse).toBeDefined();
1292
+ expect(basePreToolUse?.hooks[0]?.command).toContain("LEGIO_AGENT_NAME");
1293
+ });
1294
+
1295
+ test("all deployed hook commands include env var guard for project root isolation", async () => {
1296
+ const worktreePath = join(tempDir, "coord-env-wt");
1297
+
1298
+ await deployHooks(worktreePath, "coordinator-env", "coordinator");
1299
+
1300
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
1301
+ const content = await readFile(outputPath, "utf-8");
1302
+ const parsed = JSON.parse(content);
1303
+ const preToolUse = parsed.hooks.PreToolUse as Array<{
1304
+ matcher: string;
1305
+ hooks: Array<{ type: string; command: string }>;
1306
+ }>;
1307
+
1308
+ // All guard entries (non-empty matchers, i.e. generated by getDangerGuards/getCapabilityGuards)
1309
+ // must include the env var guard so hooks deployed to project root only activate for agents
1310
+ const guardEntries = preToolUse.filter((entry) => entry.matcher !== "");
1311
+ expect(guardEntries.length).toBeGreaterThan(0);
1312
+
1313
+ for (const entry of guardEntries) {
1314
+ for (const hook of entry.hooks) {
1315
+ if (hook.type === "command") {
1316
+ expect(hook.command).toContain("LEGIO_AGENT_NAME");
1317
+ }
1318
+ }
1319
+ }
1320
+ });
1321
+
1322
+ test("all capabilities block Task tool for legio sling enforcement", async () => {
1323
+ const capabilities = [
1324
+ "scout",
1325
+ "reviewer",
1326
+ "lead",
1327
+ "builder",
1328
+ "merger",
1329
+ "coordinator",
1330
+ "supervisor",
1331
+ ];
1332
+
1333
+ for (const cap of capabilities) {
1334
+ const wt = join(tempDir, `${cap}-task-wt`);
1335
+ await deployHooks(wt, `${cap}-agent`, cap);
1336
+
1337
+ const content = await readFile(join(wt, ".claude", "settings.local.json"), "utf-8");
1338
+ const parsed = JSON.parse(content);
1339
+ const preToolUse = parsed.hooks.PreToolUse;
1340
+
1341
+ const taskGuard = preToolUse.find((h: { matcher: string }) => h.matcher === "Task");
1342
+ expect(taskGuard).toBeDefined();
1343
+ expect(taskGuard.hooks[0].command).toContain("legio sling");
1344
+ }
1345
+ });
1346
+
1347
+ test("all capabilities get path boundary guards in deployed hooks", async () => {
1348
+ const capabilities = [
1349
+ "scout",
1350
+ "reviewer",
1351
+ "lead",
1352
+ "builder",
1353
+ "merger",
1354
+ "coordinator",
1355
+ "supervisor",
1356
+ ];
1357
+
1358
+ for (const cap of capabilities) {
1359
+ const wt = join(tempDir, `${cap}-path-wt`);
1360
+ await deployHooks(wt, `${cap}-agent`, cap);
1361
+
1362
+ const content = await readFile(join(wt, ".claude", "settings.local.json"), "utf-8");
1363
+ const parsed = JSON.parse(content);
1364
+ const preToolUse = parsed.hooks.PreToolUse;
1365
+
1366
+ // Path boundary guards should be present for Write, Edit, NotebookEdit
1367
+ // They use LEGIO_WORKTREE_PATH env var
1368
+ const writeGuards = preToolUse.filter(
1369
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
1370
+ h.matcher === "Write" && h.hooks[0]?.command?.includes("LEGIO_WORKTREE_PATH"),
1371
+ );
1372
+ const editGuards = preToolUse.filter(
1373
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
1374
+ h.matcher === "Edit" && h.hooks[0]?.command?.includes("LEGIO_WORKTREE_PATH"),
1375
+ );
1376
+ const notebookGuards = preToolUse.filter(
1377
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
1378
+ h.matcher === "NotebookEdit" && h.hooks[0]?.command?.includes("LEGIO_WORKTREE_PATH"),
1379
+ );
1380
+
1381
+ expect(writeGuards.length).toBeGreaterThanOrEqual(1);
1382
+ expect(editGuards.length).toBeGreaterThanOrEqual(1);
1383
+ expect(notebookGuards.length).toBeGreaterThanOrEqual(1);
1384
+ }
1385
+ });
1386
+
1387
+ test("path boundary guards appear before danger guards in deployed hooks", async () => {
1388
+ const worktreePath = join(tempDir, "order-path-wt");
1389
+
1390
+ await deployHooks(worktreePath, "order-path-agent", "builder");
1391
+
1392
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
1393
+ const content = await readFile(outputPath, "utf-8");
1394
+ const parsed = JSON.parse(content);
1395
+ const preToolUse = parsed.hooks.PreToolUse;
1396
+
1397
+ // Path boundary Write guard should come before Bash danger guard
1398
+ const pathWriteIdx = preToolUse.findIndex(
1399
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
1400
+ h.matcher === "Write" && h.hooks[0]?.command?.includes("LEGIO_WORKTREE_PATH"),
1401
+ );
1402
+ const bashDangerIdx = preToolUse.findIndex(
1403
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
1404
+ h.matcher === "Bash" && h.hooks[0]?.command?.includes("git"),
1405
+ );
1406
+
1407
+ expect(pathWriteIdx).toBeGreaterThanOrEqual(0);
1408
+ expect(bashDangerIdx).toBeGreaterThanOrEqual(0);
1409
+ expect(pathWriteIdx).toBeLessThan(bashDangerIdx);
1410
+ });
1411
+ });
1412
+
1413
+ describe("buildPathBoundaryGuardScript", () => {
1414
+ test("returns a string containing env var guard", () => {
1415
+ const script = buildPathBoundaryGuardScript("file_path");
1416
+ expect(script).toContain('[ -z "$LEGIO_AGENT_NAME" ] && exit 0;');
1417
+ });
1418
+
1419
+ test("returns a string checking LEGIO_WORKTREE_PATH", () => {
1420
+ const script = buildPathBoundaryGuardScript("file_path");
1421
+ expect(script).toContain('[ -z "$LEGIO_WORKTREE_PATH" ] && exit 0;');
1422
+ });
1423
+
1424
+ test("reads stdin input", () => {
1425
+ const script = buildPathBoundaryGuardScript("file_path");
1426
+ expect(script).toContain("read -r INPUT");
1427
+ });
1428
+
1429
+ test("extracts the specified field name from JSON", () => {
1430
+ const script = buildPathBoundaryGuardScript("file_path");
1431
+ expect(script).toContain('"file_path"');
1432
+
1433
+ const notebookScript = buildPathBoundaryGuardScript("notebook_path");
1434
+ expect(notebookScript).toContain('"notebook_path"');
1435
+ });
1436
+
1437
+ test("resolves relative paths against cwd", () => {
1438
+ const script = buildPathBoundaryGuardScript("file_path");
1439
+ expect(script).toContain("$(pwd)");
1440
+ });
1441
+
1442
+ test("allows paths inside the worktree", () => {
1443
+ const script = buildPathBoundaryGuardScript("file_path");
1444
+ expect(script).toContain('"$LEGIO_WORKTREE_PATH"/*) exit 0');
1445
+ });
1446
+
1447
+ test("blocks paths outside the worktree with decision:block", () => {
1448
+ const script = buildPathBoundaryGuardScript("file_path");
1449
+ expect(script).toContain('"decision":"block"');
1450
+ expect(script).toContain("Path boundary violation");
1451
+ });
1452
+
1453
+ test("fails open when path field is empty", () => {
1454
+ const script = buildPathBoundaryGuardScript("file_path");
1455
+ expect(script).toContain('[ -z "$FILE_PATH" ] && exit 0;');
1456
+ });
1457
+
1458
+ test("allows paths in the agent memory directory", () => {
1459
+ const script = buildPathBoundaryGuardScript("file_path");
1460
+ expect(script).toContain('"$HOME"/.claude/projects/*/memory/*) exit 0');
1461
+ });
1462
+
1463
+ test("memory dir exception appears before the block decision", () => {
1464
+ const script = buildPathBoundaryGuardScript("file_path");
1465
+ const memoryIdx = script.indexOf('"$HOME"/.claude/projects/*/memory/*) exit 0');
1466
+ const blockIdx = script.indexOf('"decision":"block"');
1467
+ expect(memoryIdx).toBeGreaterThanOrEqual(0);
1468
+ expect(blockIdx).toBeGreaterThanOrEqual(0);
1469
+ expect(memoryIdx).toBeLessThan(blockIdx);
1470
+ });
1471
+ });
1472
+
1473
+ describe("getPathBoundaryGuards", () => {
1474
+ test("returns exactly 3 guards (Write, Edit, NotebookEdit)", () => {
1475
+ const guards = getPathBoundaryGuards();
1476
+ expect(guards).toHaveLength(3);
1477
+ });
1478
+
1479
+ test("guards match Write, Edit, and NotebookEdit tools", () => {
1480
+ const guards = getPathBoundaryGuards();
1481
+ const matchers = guards.map((g) => g.matcher);
1482
+ expect(matchers).toContain("Write");
1483
+ expect(matchers).toContain("Edit");
1484
+ expect(matchers).toContain("NotebookEdit");
1485
+ });
1486
+
1487
+ test("Write and Edit guards extract file_path field", () => {
1488
+ const guards = getPathBoundaryGuards();
1489
+ const writeGuard = guards.find((g) => g.matcher === "Write");
1490
+ const editGuard = guards.find((g) => g.matcher === "Edit");
1491
+ expect(writeGuard?.hooks[0]?.command).toContain('"file_path"');
1492
+ expect(editGuard?.hooks[0]?.command).toContain('"file_path"');
1493
+ });
1494
+
1495
+ test("NotebookEdit guard extracts notebook_path field", () => {
1496
+ const guards = getPathBoundaryGuards();
1497
+ const notebookGuard = guards.find((g) => g.matcher === "NotebookEdit");
1498
+ expect(notebookGuard?.hooks[0]?.command).toContain('"notebook_path"');
1499
+ });
1500
+
1501
+ test("all guards include LEGIO_WORKTREE_PATH check", () => {
1502
+ const guards = getPathBoundaryGuards();
1503
+ for (const guard of guards) {
1504
+ expect(guard.hooks[0]?.command).toContain("LEGIO_WORKTREE_PATH");
1505
+ }
1506
+ });
1507
+
1508
+ test("all guards have command type hooks", () => {
1509
+ const guards = getPathBoundaryGuards();
1510
+ for (const guard of guards) {
1511
+ expect(guard.hooks[0]?.type).toBe("command");
1512
+ }
1513
+ });
1514
+ });
1515
+
1516
+ describe("buildBashPathBoundaryScript", () => {
1517
+ test("returns a string containing env var guard", () => {
1518
+ const script = buildBashPathBoundaryScript();
1519
+ expect(script).toContain('[ -z "$LEGIO_AGENT_NAME" ] && exit 0;');
1520
+ });
1521
+
1522
+ test("checks LEGIO_WORKTREE_PATH env var", () => {
1523
+ const script = buildBashPathBoundaryScript();
1524
+ expect(script).toContain('[ -z "$LEGIO_WORKTREE_PATH" ] && exit 0;');
1525
+ });
1526
+
1527
+ test("reads stdin input", () => {
1528
+ const script = buildBashPathBoundaryScript();
1529
+ expect(script).toContain("read -r INPUT");
1530
+ });
1531
+
1532
+ test("extracts command from JSON input", () => {
1533
+ const script = buildBashPathBoundaryScript();
1534
+ expect(script).toContain("CMD=$(");
1535
+ expect(script).toContain('"command"');
1536
+ });
1537
+
1538
+ test("checks for file-modifying patterns before path extraction", () => {
1539
+ const script = buildBashPathBoundaryScript();
1540
+ // Should check for file-modifying patterns first
1541
+ expect(script).toContain("grep -qE");
1542
+ expect(script).toContain("sed\\s+-i");
1543
+ expect(script).toContain("\\bmv\\s");
1544
+ expect(script).toContain("\\bcp\\s");
1545
+ expect(script).toContain("\\brm\\s");
1546
+ expect(script).toContain("tee\\s");
1547
+ expect(script).toContain("\\brsync\\s");
1548
+ expect(script).toContain("\\binstall\\s");
1549
+ });
1550
+
1551
+ test("includes common file-modifying patterns", () => {
1552
+ const script = buildBashPathBoundaryScript();
1553
+ expect(script).toContain("sed\\s+-i");
1554
+ expect(script).toContain("sed\\s+--in-place");
1555
+ expect(script).toContain("echo\\s+.*>");
1556
+ expect(script).toContain("printf\\s+.*>");
1557
+ expect(script).toContain("cat\\s+.*>");
1558
+ expect(script).toContain("tee\\s");
1559
+ expect(script).toContain("\\bmv\\s");
1560
+ expect(script).toContain("\\bcp\\s");
1561
+ expect(script).toContain("\\brm\\s");
1562
+ expect(script).toContain("\\bmkdir\\s");
1563
+ expect(script).toContain("\\btouch\\s");
1564
+ expect(script).toContain("\\bchmod\\s");
1565
+ expect(script).toContain("\\bchown\\s");
1566
+ expect(script).toContain(">>");
1567
+ expect(script).toContain("\\binstall\\s");
1568
+ expect(script).toContain("\\brsync\\s");
1569
+ });
1570
+
1571
+ test("passes through non-file-modifying commands", () => {
1572
+ const script = buildBashPathBoundaryScript();
1573
+ // Non-modifying commands should hit the early exit
1574
+ expect(script).toContain("exit 0; fi;");
1575
+ });
1576
+
1577
+ test("extracts absolute paths from command", () => {
1578
+ const script = buildBashPathBoundaryScript();
1579
+ // Should extract tokens starting with /
1580
+ expect(script).toContain("grep '^/'");
1581
+ });
1582
+
1583
+ test("allows commands with no absolute paths (relative paths OK)", () => {
1584
+ const script = buildBashPathBoundaryScript();
1585
+ expect(script).toContain('[ -z "$PATHS" ] && exit 0;');
1586
+ });
1587
+
1588
+ test("validates paths against worktree boundary", () => {
1589
+ const script = buildBashPathBoundaryScript();
1590
+ expect(script).toContain('"$LEGIO_WORKTREE_PATH"/*');
1591
+ expect(script).toContain('"$LEGIO_WORKTREE_PATH")');
1592
+ });
1593
+
1594
+ test("allows /dev/* paths as safe exceptions", () => {
1595
+ const script = buildBashPathBoundaryScript();
1596
+ expect(script).toContain("/dev/*");
1597
+ });
1598
+
1599
+ test("allows /tmp/* paths as safe exceptions", () => {
1600
+ const script = buildBashPathBoundaryScript();
1601
+ expect(script).toContain("/tmp/*");
1602
+ });
1603
+
1604
+ test("allows $HOME/.claude/projects/*/memory/* paths as safe exceptions", () => {
1605
+ const script = buildBashPathBoundaryScript();
1606
+ expect(script).toContain('"$HOME"/.claude/projects/*/memory/*');
1607
+ });
1608
+
1609
+ test("memory dir exception appears before the block decision in bash path script", () => {
1610
+ const script = buildBashPathBoundaryScript();
1611
+ const memoryIdx = script.indexOf('"$HOME"/.claude/projects/*/memory/*');
1612
+ const blockIdx = script.indexOf('"decision":"block"');
1613
+ expect(memoryIdx).toBeGreaterThanOrEqual(0);
1614
+ expect(blockIdx).toBeGreaterThanOrEqual(0);
1615
+ expect(memoryIdx).toBeLessThan(blockIdx);
1616
+ });
1617
+
1618
+ test("blocks paths outside worktree with decision:block", () => {
1619
+ const script = buildBashPathBoundaryScript();
1620
+ expect(script).toContain('"decision":"block"');
1621
+ expect(script).toContain("Bash path boundary violation");
1622
+ expect(script).toContain("outside your worktree");
1623
+ });
1624
+
1625
+ test("iterates over extracted paths with while loop", () => {
1626
+ const script = buildBashPathBoundaryScript();
1627
+ expect(script).toContain("while IFS= read -r P; do");
1628
+ expect(script).toContain("done;");
1629
+ });
1630
+
1631
+ test("strips trailing quotes and semicolons from extracted paths", () => {
1632
+ const script = buildBashPathBoundaryScript();
1633
+ // sed should strip trailing junk from path tokens
1634
+ expect(script).toContain("sed 's/[\";>]*$//'");
1635
+ });
1636
+ });
1637
+
1638
+ describe("getBashPathBoundaryGuards", () => {
1639
+ test("returns exactly 1 Bash guard entry", () => {
1640
+ const guards = getBashPathBoundaryGuards();
1641
+ expect(guards).toHaveLength(1);
1642
+ expect(guards[0]?.matcher).toBe("Bash");
1643
+ });
1644
+
1645
+ test("guard hook type is command", () => {
1646
+ const guards = getBashPathBoundaryGuards();
1647
+ expect(guards[0]?.hooks[0]?.type).toBe("command");
1648
+ });
1649
+
1650
+ test("guard command checks LEGIO_WORKTREE_PATH", () => {
1651
+ const guards = getBashPathBoundaryGuards();
1652
+ const command = guards[0]?.hooks[0]?.command ?? "";
1653
+ expect(command).toContain("LEGIO_WORKTREE_PATH");
1654
+ });
1655
+
1656
+ test("guard command includes env var guard prefix", () => {
1657
+ const guards = getBashPathBoundaryGuards();
1658
+ const command = guards[0]?.hooks[0]?.command ?? "";
1659
+ expect(command).toContain('[ -z "$LEGIO_AGENT_NAME" ] && exit 0;');
1660
+ });
1661
+
1662
+ test("guard blocks paths outside worktree", () => {
1663
+ const guards = getBashPathBoundaryGuards();
1664
+ const command = guards[0]?.hooks[0]?.command ?? "";
1665
+ expect(command).toContain("Bash path boundary violation");
1666
+ });
1667
+ });
1668
+
1669
+ describe("bash path boundary integration", () => {
1670
+ let tempDir: string;
1671
+
1672
+ beforeEach(async () => {
1673
+ tempDir = await mkdtemp(join(tmpdir(), "legio-bash-path-test-"));
1674
+ });
1675
+
1676
+ afterEach(async () => {
1677
+ await rm(tempDir, { recursive: true, force: true });
1678
+ });
1679
+
1680
+ test("builder gets Bash path boundary guard in deployed hooks", async () => {
1681
+ const worktreePath = join(tempDir, "builder-bp-wt");
1682
+
1683
+ await deployHooks(worktreePath, "builder-bp", "builder");
1684
+
1685
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
1686
+ const content = await readFile(outputPath, "utf-8");
1687
+ const parsed = JSON.parse(content);
1688
+ const preToolUse = parsed.hooks.PreToolUse;
1689
+
1690
+ const bashGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Bash");
1691
+ // Should have 3 Bash guards: danger guard + path boundary guard + template push-guard
1692
+ expect(bashGuards.length).toBe(3);
1693
+
1694
+ // Find the path boundary guard
1695
+ const pathGuard = bashGuards.find((h: { hooks: Array<{ command: string }> }) =>
1696
+ h.hooks[0]?.command?.includes("Bash path boundary violation"),
1697
+ );
1698
+ expect(pathGuard).toBeDefined();
1699
+ expect(pathGuard.hooks[0].command).toContain("LEGIO_WORKTREE_PATH");
1700
+ });
1701
+
1702
+ test("merger gets Bash path boundary guard in deployed hooks", async () => {
1703
+ const worktreePath = join(tempDir, "merger-bp-wt");
1704
+
1705
+ await deployHooks(worktreePath, "merger-bp", "merger");
1706
+
1707
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
1708
+ const content = await readFile(outputPath, "utf-8");
1709
+ const parsed = JSON.parse(content);
1710
+ const preToolUse = parsed.hooks.PreToolUse;
1711
+
1712
+ const bashGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Bash");
1713
+ expect(bashGuards.length).toBe(3); // danger guard + path boundary guard + template push-guard
1714
+
1715
+ const pathGuard = bashGuards.find((h: { hooks: Array<{ command: string }> }) =>
1716
+ h.hooks[0]?.command?.includes("Bash path boundary violation"),
1717
+ );
1718
+ expect(pathGuard).toBeDefined();
1719
+ });
1720
+
1721
+ test("scout does NOT get Bash path boundary guard", async () => {
1722
+ const worktreePath = join(tempDir, "scout-bp-wt");
1723
+
1724
+ await deployHooks(worktreePath, "scout-bp", "scout");
1725
+
1726
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
1727
+ const content = await readFile(outputPath, "utf-8");
1728
+ const parsed = JSON.parse(content);
1729
+ const preToolUse = parsed.hooks.PreToolUse;
1730
+
1731
+ // Scout gets danger guard + file guard + template push-guard (3 Bash guards), but NOT path boundary
1732
+ const bashGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Bash");
1733
+ expect(bashGuards.length).toBe(3);
1734
+
1735
+ const pathGuard = bashGuards.find((h: { hooks: Array<{ command: string }> }) =>
1736
+ h.hooks[0]?.command?.includes("Bash path boundary violation"),
1737
+ );
1738
+ expect(pathGuard).toBeUndefined();
1739
+ });
1740
+
1741
+ test("reviewer does NOT get Bash path boundary guard", async () => {
1742
+ const worktreePath = join(tempDir, "reviewer-bp-wt");
1743
+
1744
+ await deployHooks(worktreePath, "reviewer-bp", "reviewer");
1745
+
1746
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
1747
+ const content = await readFile(outputPath, "utf-8");
1748
+ const parsed = JSON.parse(content);
1749
+ const preToolUse = parsed.hooks.PreToolUse;
1750
+
1751
+ const pathGuard = preToolUse.find(
1752
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
1753
+ h.matcher === "Bash" && h.hooks[0]?.command?.includes("Bash path boundary violation"),
1754
+ );
1755
+ expect(pathGuard).toBeUndefined();
1756
+ });
1757
+
1758
+ test("lead does NOT get Bash path boundary guard", async () => {
1759
+ const worktreePath = join(tempDir, "lead-bp-wt");
1760
+
1761
+ await deployHooks(worktreePath, "lead-bp", "lead");
1762
+
1763
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
1764
+ const content = await readFile(outputPath, "utf-8");
1765
+ const parsed = JSON.parse(content);
1766
+ const preToolUse = parsed.hooks.PreToolUse;
1767
+
1768
+ const pathGuard = preToolUse.find(
1769
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
1770
+ h.matcher === "Bash" && h.hooks[0]?.command?.includes("Bash path boundary violation"),
1771
+ );
1772
+ expect(pathGuard).toBeUndefined();
1773
+ });
1774
+
1775
+ test("Bash path boundary guard appears after danger guard in builder", async () => {
1776
+ const worktreePath = join(tempDir, "builder-order-wt");
1777
+
1778
+ await deployHooks(worktreePath, "builder-order", "builder");
1779
+
1780
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
1781
+ const content = await readFile(outputPath, "utf-8");
1782
+ const parsed = JSON.parse(content);
1783
+ const preToolUse = parsed.hooks.PreToolUse;
1784
+
1785
+ const dangerIdx = preToolUse.findIndex(
1786
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
1787
+ h.matcher === "Bash" &&
1788
+ h.hooks[0]?.command?.includes("git") &&
1789
+ h.hooks[0]?.command?.includes("push"),
1790
+ );
1791
+ const pathBoundaryIdx = preToolUse.findIndex(
1792
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
1793
+ h.matcher === "Bash" && h.hooks[0]?.command?.includes("Bash path boundary violation"),
1794
+ );
1795
+
1796
+ expect(dangerIdx).toBeGreaterThanOrEqual(0);
1797
+ expect(pathBoundaryIdx).toBeGreaterThanOrEqual(0);
1798
+ // Danger guard comes from getDangerGuards, path boundary from getCapabilityGuards
1799
+ // In deployHooks: allGuards = [...pathGuards, ...dangerGuards, ...capabilityGuards]
1800
+ // So danger guard comes before path boundary guard
1801
+ expect(dangerIdx).toBeLessThan(pathBoundaryIdx);
1802
+ });
1803
+
1804
+ test("default capability (builder) gets Bash path boundary guard", async () => {
1805
+ const worktreePath = join(tempDir, "default-bp-wt");
1806
+
1807
+ await deployHooks(worktreePath, "default-bp");
1808
+
1809
+ const outputPath = join(worktreePath, ".claude", "settings.local.json");
1810
+ const content = await readFile(outputPath, "utf-8");
1811
+ const parsed = JSON.parse(content);
1812
+ const preToolUse = parsed.hooks.PreToolUse;
1813
+
1814
+ const pathGuard = preToolUse.find(
1815
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
1816
+ h.matcher === "Bash" && h.hooks[0]?.command?.includes("Bash path boundary violation"),
1817
+ );
1818
+ expect(pathGuard).toBeDefined();
1819
+ });
1820
+ });