@os-eco/overstory-cli 0.6.1

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