@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,607 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { AgentError } from "../errors.ts";
4
+
5
+ /**
6
+ * Capabilities that must never modify project files.
7
+ * Includes read-only roles (scout, reviewer) and coordination roles (lead).
8
+ * Only "builder" and "merger" are allowed to modify files.
9
+ */
10
+ const NON_IMPLEMENTATION_CAPABILITIES = new Set([
11
+ "scout",
12
+ "reviewer",
13
+ "lead",
14
+ "coordinator",
15
+ "supervisor",
16
+ "monitor",
17
+ ]);
18
+
19
+ /**
20
+ * Capabilities that coordinate work and need git add/commit for syncing
21
+ * beads, mulch, and other metadata — but must NOT git push.
22
+ */
23
+ const COORDINATION_CAPABILITIES = new Set(["coordinator", "supervisor", "monitor"]);
24
+
25
+ /**
26
+ * Additional safe Bash prefixes for coordination capabilities.
27
+ * Allows git add/commit for beads sync, mulch records, etc.
28
+ * git push remains blocked via DANGEROUS_BASH_PATTERNS.
29
+ */
30
+ const COORDINATION_SAFE_PREFIXES = ["git add", "git commit"];
31
+
32
+ /**
33
+ * Claude Code native team/task tools that bypass overstory orchestration.
34
+ * All overstory agents must use `overstory sling` for delegation, not these.
35
+ */
36
+ const NATIVE_TEAM_TOOLS = [
37
+ "Task",
38
+ "TeamCreate",
39
+ "TeamDelete",
40
+ "SendMessage",
41
+ "TaskCreate",
42
+ "TaskUpdate",
43
+ "TaskList",
44
+ "TaskGet",
45
+ "TaskOutput",
46
+ "TaskStop",
47
+ ];
48
+
49
+ /** Tools that non-implementation agents must not use. */
50
+ const WRITE_TOOLS = ["Write", "Edit", "NotebookEdit"];
51
+
52
+ /**
53
+ * Bash commands that modify files and must be blocked for non-implementation agents.
54
+ * Each pattern is a regex fragment used inside a grep -qE check.
55
+ */
56
+ const DANGEROUS_BASH_PATTERNS = [
57
+ "sed\\s+-i",
58
+ "sed\\s+--in-place",
59
+ "echo\\s+.*>",
60
+ "printf\\s+.*>",
61
+ "cat\\s+.*>",
62
+ "tee\\s",
63
+ "\\bvim\\b",
64
+ "\\bnano\\b",
65
+ "\\bvi\\b",
66
+ "\\bmv\\s",
67
+ "\\bcp\\s",
68
+ "\\brm\\s",
69
+ "\\bmkdir\\s",
70
+ "\\btouch\\s",
71
+ "\\bchmod\\s",
72
+ "\\bchown\\s",
73
+ ">>",
74
+ "\\bgit\\s+add\\b",
75
+ "\\bgit\\s+commit\\b",
76
+ "\\bgit\\s+merge\\b",
77
+ "\\bgit\\s+push\\b",
78
+ "\\bgit\\s+reset\\b",
79
+ "\\bgit\\s+checkout\\b",
80
+ "\\bgit\\s+rebase\\b",
81
+ "\\bgit\\s+stash\\b",
82
+ "\\bnpm\\s+install\\b",
83
+ "\\bbun\\s+install\\b",
84
+ "\\bbun\\s+add\\b",
85
+ // Runtime eval flags — bypass shell pattern guards by executing JS/Python directly
86
+ "\\bbun\\s+-e\\b",
87
+ "\\bbun\\s+--eval\\b",
88
+ "\\bnode\\s+-e\\b",
89
+ "\\bnode\\s+--eval\\b",
90
+ "\\bdeno\\s+eval\\b",
91
+ "\\bpython3?\\s+-c\\b",
92
+ "\\bperl\\s+-e\\b",
93
+ "\\bruby\\s+-e\\b",
94
+ ];
95
+
96
+ /**
97
+ * Bash commands that are always safe for non-implementation agents.
98
+ * If a command starts with any of these prefixes, it bypasses the dangerous command check.
99
+ * This whitelist is checked BEFORE the blocklist.
100
+ */
101
+ const SAFE_BASH_PREFIXES = [
102
+ "overstory ",
103
+ "bd ",
104
+ "sd ",
105
+ "git status",
106
+ "git log",
107
+ "git diff",
108
+ "git show",
109
+ "git blame",
110
+ "git branch",
111
+ "mulch ",
112
+ "bun test",
113
+ "bun run lint",
114
+ "bun run typecheck",
115
+ "bun run biome",
116
+ ];
117
+
118
+ /** Hook entry shape matching Claude Code's settings.local.json format. */
119
+ interface HookEntry {
120
+ matcher: string;
121
+ hooks: Array<{ type: string; command: string }>;
122
+ }
123
+
124
+ /**
125
+ * Resolve the path to the hooks template file.
126
+ * The template lives at `templates/hooks.json.tmpl` relative to the repo root.
127
+ */
128
+ function getTemplatePath(): string {
129
+ // src/agents/hooks-deployer.ts -> repo root is ../../
130
+ return join(dirname(import.meta.dir), "..", "templates", "hooks.json.tmpl");
131
+ }
132
+
133
+ /**
134
+ * Env var guard prefix for hook commands.
135
+ *
136
+ * When hooks are deployed to the project root (e.g. for the coordinator),
137
+ * they affect ALL Claude Code sessions in that directory. This prefix
138
+ * ensures hooks only activate for overstory-managed agent sessions
139
+ * (which have OVERSTORY_AGENT_NAME set in their environment) and are
140
+ * no-ops for the user's own Claude Code session.
141
+ */
142
+ const ENV_GUARD = '[ -z "$OVERSTORY_AGENT_NAME" ] && exit 0;';
143
+
144
+ /**
145
+ * Build a PreToolUse guard script that validates file paths are within
146
+ * the agent's worktree boundary.
147
+ *
148
+ * Applied to Write, Edit, and NotebookEdit tools. Uses the
149
+ * OVERSTORY_WORKTREE_PATH env var set during tmux session creation
150
+ * to determine the allowed path boundary.
151
+ *
152
+ * @param filePathField - The JSON field name containing the file path
153
+ * ("file_path" for Write/Edit, "notebook_path" for NotebookEdit)
154
+ */
155
+ export function buildPathBoundaryGuardScript(filePathField: string): string {
156
+ const script = [
157
+ // Only enforce for overstory agent sessions
158
+ ENV_GUARD,
159
+ // Skip if worktree path is not set (e.g., orchestrator)
160
+ '[ -z "$OVERSTORY_WORKTREE_PATH" ] && exit 0;',
161
+ "read -r INPUT;",
162
+ // Extract file path from JSON (sed -n + p = empty if no match)
163
+ `FILE_PATH=$(echo "$INPUT" | sed -n 's/.*"${filePathField}": *"\\([^"]*\\)".*/\\1/p');`,
164
+ // No path extracted — fail open (tool may be called differently)
165
+ '[ -z "$FILE_PATH" ] && exit 0;',
166
+ // Resolve relative paths against cwd
167
+ 'case "$FILE_PATH" in /*) ;; *) FILE_PATH="$(pwd)/$FILE_PATH" ;; esac;',
168
+ // Allow if path is inside the worktree (exact match or subpath)
169
+ 'case "$FILE_PATH" in "$OVERSTORY_WORKTREE_PATH"/*) exit 0 ;; "$OVERSTORY_WORKTREE_PATH") exit 0 ;; esac;',
170
+ // Block: path is outside the worktree boundary
171
+ 'echo \'{"decision":"block","reason":"Path boundary violation: file is outside your assigned worktree. All writes must target files within your worktree."}\';',
172
+ ].join(" ");
173
+ return script;
174
+ }
175
+
176
+ /**
177
+ * Generate PreToolUse guards that enforce worktree path boundaries.
178
+ *
179
+ * Returns guards for Write (file_path), Edit (file_path), and
180
+ * NotebookEdit (notebook_path). Applied to ALL agent capabilities
181
+ * as defense-in-depth (non-implementation agents already have these
182
+ * tools blocked, but the path guard catches any bypass).
183
+ */
184
+ export function getPathBoundaryGuards(): HookEntry[] {
185
+ return [
186
+ {
187
+ matcher: "Write",
188
+ hooks: [{ type: "command", command: buildPathBoundaryGuardScript("file_path") }],
189
+ },
190
+ {
191
+ matcher: "Edit",
192
+ hooks: [{ type: "command", command: buildPathBoundaryGuardScript("file_path") }],
193
+ },
194
+ {
195
+ matcher: "NotebookEdit",
196
+ hooks: [{ type: "command", command: buildPathBoundaryGuardScript("notebook_path") }],
197
+ },
198
+ ];
199
+ }
200
+
201
+ /**
202
+ * Build a PreToolUse guard that blocks a specific tool.
203
+ *
204
+ * Returns a JSON response with decision=block so Claude Code rejects
205
+ * the tool call before execution.
206
+ */
207
+ function blockGuard(toolName: string, reason: string): HookEntry {
208
+ const response = JSON.stringify({ decision: "block", reason });
209
+ return {
210
+ matcher: toolName,
211
+ hooks: [
212
+ {
213
+ type: "command",
214
+ command: `${ENV_GUARD} echo '${response}'`,
215
+ },
216
+ ],
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Build a Bash guard script that inspects the command from stdin JSON.
222
+ *
223
+ * Claude Code PreToolUse hooks receive `{"tool_name": "Bash", "tool_input": {"command": "..."}, ...}` on stdin.
224
+ * This builds a bash script that reads stdin, extracts the command, and checks for
225
+ * dangerous patterns (push to canonical branch, hard reset, wrong branch naming).
226
+ */
227
+ function buildBashGuardScript(agentName: string): string {
228
+ // The script reads JSON from stdin, extracts the command field, then checks patterns.
229
+ // Uses parameter expansion to avoid requiring jq (zero runtime deps).
230
+ const script = [
231
+ // Only enforce for overstory agent sessions (skip for user's own Claude Code)
232
+ ENV_GUARD,
233
+ "read -r INPUT;",
234
+ // Extract command value from JSON — grab everything after "command": (with optional space)
235
+ 'CMD=$(echo "$INPUT" | sed \'s/.*"command": *"\\([^"]*\\)".*/\\1/\');',
236
+ // Check 1: Block all git push — agents must never push to remote
237
+ "if echo \"$CMD\" | grep -qE '\\bgit\\s+push\\b'; then",
238
+ ' echo \'{"decision":"block","reason":"git push is blocked — use overstory merge to integrate changes, push manually when ready"}\';',
239
+ " exit 0;",
240
+ "fi;",
241
+ // Check 2: Block git reset --hard
242
+ "if echo \"$CMD\" | grep -qE 'git\\s+reset\\s+--hard'; then",
243
+ ' echo \'{"decision":"block","reason":"git reset --hard is not allowed — it destroys uncommitted work"}\';',
244
+ " exit 0;",
245
+ "fi;",
246
+ // Check 3: Warn on git checkout -b with wrong naming convention
247
+ "if echo \"$CMD\" | grep -qE 'git\\s+checkout\\s+-b\\s'; then",
248
+ ` BRANCH=$(echo "$CMD" | sed 's/.*git\\s*checkout\\s*-b\\s*\\([^ ]*\\).*/\\1/');`,
249
+ ` if ! echo "$BRANCH" | grep -qE '^overstory/${agentName}/'; then`,
250
+ ` echo '{"decision":"block","reason":"Branch must follow overstory/${agentName}/{bead-id} convention"}';`,
251
+ " exit 0;",
252
+ " fi;",
253
+ "fi;",
254
+ ].join(" ");
255
+ return script;
256
+ }
257
+
258
+ /**
259
+ * Generate Bash-level PreToolUse guards for dangerous operations.
260
+ *
261
+ * Applied to ALL agent capabilities. Inspects Bash tool commands for:
262
+ * - `git push` to canonical branches (main/master) — blocked
263
+ * - `git reset --hard` — blocked
264
+ * - `git checkout -b` with non-standard branch naming — blocked
265
+ *
266
+ * @param agentName - The agent name, used for branch naming validation
267
+ */
268
+ export function getDangerGuards(agentName: string): HookEntry[] {
269
+ return [
270
+ {
271
+ matcher: "Bash",
272
+ hooks: [
273
+ {
274
+ type: "command",
275
+ command: buildBashGuardScript(agentName),
276
+ },
277
+ ],
278
+ },
279
+ ];
280
+ }
281
+
282
+ /**
283
+ * Build a Bash guard script that blocks file-modifying commands for non-implementation agents.
284
+ *
285
+ * Uses a whitelist-first approach: if the command matches a known-safe prefix, it passes.
286
+ * Otherwise, it checks against dangerous patterns and blocks if any match.
287
+ *
288
+ * @param capability - The agent capability, included in block reason messages
289
+ * @param extraSafePrefixes - Additional safe prefixes for this capability (e.g. git add/commit for coordinators)
290
+ */
291
+ export function buildBashFileGuardScript(
292
+ capability: string,
293
+ extraSafePrefixes: string[] = [],
294
+ ): string {
295
+ // Build the safe prefix check: if command starts with any safe prefix, allow it
296
+ const allSafePrefixes = [...SAFE_BASH_PREFIXES, ...extraSafePrefixes];
297
+ const safePrefixChecks = allSafePrefixes
298
+ .map((prefix) => `if echo "$CMD" | grep -qE '^\\s*${prefix}'; then exit 0; fi;`)
299
+ .join(" ");
300
+
301
+ // Build the dangerous pattern check
302
+ const dangerPattern = DANGEROUS_BASH_PATTERNS.join("|");
303
+
304
+ const script = [
305
+ // Only enforce for overstory agent sessions (skip for user's own Claude Code)
306
+ ENV_GUARD,
307
+ "read -r INPUT;",
308
+ // Extract command value from JSON (with optional space after colon)
309
+ 'CMD=$(echo "$INPUT" | sed \'s/.*"command": *"\\([^"]*\\)".*/\\1/\');',
310
+ // First: whitelist safe commands
311
+ safePrefixChecks,
312
+ // Then: check for dangerous patterns
313
+ `if echo "$CMD" | grep -qE '${dangerPattern}'; then`,
314
+ ` echo '{"decision":"block","reason":"${capability} agents cannot modify files — this command is not allowed"}';`,
315
+ " exit 0;",
316
+ "fi;",
317
+ ].join(" ");
318
+ return script;
319
+ }
320
+
321
+ /**
322
+ * Capabilities that are allowed to modify files via Bash commands.
323
+ * These get the Bash path boundary guard instead of a blanket file-modification block.
324
+ */
325
+ const IMPLEMENTATION_CAPABILITIES = new Set(["builder", "merger"]);
326
+
327
+ /**
328
+ * Bash patterns that modify files and require path boundary validation.
329
+ * Each entry is a regex fragment matched against the extracted command.
330
+ * When matched, all absolute paths in the command are checked against the worktree boundary.
331
+ */
332
+ const FILE_MODIFYING_BASH_PATTERNS = [
333
+ "sed\\s+-i",
334
+ "sed\\s+--in-place",
335
+ "echo\\s+.*>",
336
+ "printf\\s+.*>",
337
+ "cat\\s+.*>",
338
+ "tee\\s",
339
+ "\\bmv\\s",
340
+ "\\bcp\\s",
341
+ "\\brm\\s",
342
+ "\\bmkdir\\s",
343
+ "\\btouch\\s",
344
+ "\\bchmod\\s",
345
+ "\\bchown\\s",
346
+ ">>",
347
+ "\\binstall\\s",
348
+ "\\brsync\\s",
349
+ ];
350
+
351
+ /**
352
+ * Build a Bash PreToolUse guard script that validates file-modifying commands
353
+ * keep their target paths within the agent's worktree boundary.
354
+ *
355
+ * Applied to builder/merger agents. For file-modifying Bash commands (sed -i,
356
+ * echo >, cp, mv, tee, install, rsync, etc.), extracts all absolute paths
357
+ * from the command and verifies they resolve within the worktree.
358
+ *
359
+ * Limitations (documented by design):
360
+ * - Cannot detect paths constructed via variable expansion ($VAR/file)
361
+ * - Cannot detect paths reached via cd + relative path
362
+ * - Cannot detect paths inside subshells or backtick evaluation
363
+ * - Relative paths are assumed safe (tmux cwd IS the worktree)
364
+ *
365
+ * Uses OVERSTORY_WORKTREE_PATH env var set during tmux session creation.
366
+ */
367
+ export function buildBashPathBoundaryScript(): string {
368
+ const fileModifyPattern = FILE_MODIFYING_BASH_PATTERNS.join("|");
369
+
370
+ const script = [
371
+ // Only enforce for overstory agent sessions
372
+ ENV_GUARD,
373
+ // Skip if worktree path is not set (e.g., orchestrator)
374
+ '[ -z "$OVERSTORY_WORKTREE_PATH" ] && exit 0;',
375
+ "read -r INPUT;",
376
+ // Extract command value from JSON (with optional space after colon)
377
+ 'CMD=$(echo "$INPUT" | sed \'s/.*"command": *"\\([^"]*\\)".*/\\1/\');',
378
+ // Only check file-modifying commands — non-modifying commands pass through
379
+ `if ! echo "$CMD" | grep -qE '${fileModifyPattern}'; then exit 0; fi;`,
380
+ // Extract all absolute paths (tokens starting with /) from the command.
381
+ // Uses tr to split on whitespace, grep to find /paths, sed to strip trailing quotes/semicolons.
382
+ "PATHS=$(echo \"$CMD\" | tr ' \\t' '\\n\\n' | grep '^/' | sed 's/[\";>]*$//');",
383
+ // If no absolute paths found, allow (relative paths resolve from worktree cwd)
384
+ '[ -z "$PATHS" ] && exit 0;',
385
+ // Check each absolute path against the worktree boundary
386
+ 'echo "$PATHS" | while IFS= read -r P; do',
387
+ ' case "$P" in',
388
+ ' "$OVERSTORY_WORKTREE_PATH"/*) ;;',
389
+ ' "$OVERSTORY_WORKTREE_PATH") ;;',
390
+ " /dev/*) ;;",
391
+ " /tmp/*) ;;",
392
+ ' *) echo \'{"decision":"block","reason":"Bash path boundary violation: command targets a path outside your worktree. All file modifications must stay within your assigned worktree."}\'; exit 0; ;;',
393
+ " esac;",
394
+ "done;",
395
+ ].join(" ");
396
+ return script;
397
+ }
398
+
399
+ /**
400
+ * Generate Bash path boundary guards for implementation capabilities.
401
+ *
402
+ * Returns a single Bash PreToolUse guard that checks file-modifying commands
403
+ * for absolute paths outside the worktree boundary.
404
+ *
405
+ * Only applied to builder/merger agents (implementation capabilities).
406
+ * Non-implementation agents already have all file-modifying Bash commands
407
+ * blocked via buildBashFileGuardScript().
408
+ */
409
+ export function getBashPathBoundaryGuards(): HookEntry[] {
410
+ return [
411
+ {
412
+ matcher: "Bash",
413
+ hooks: [{ type: "command", command: buildBashPathBoundaryScript() }],
414
+ },
415
+ ];
416
+ }
417
+
418
+ /**
419
+ * Generate capability-specific PreToolUse guards.
420
+ *
421
+ * Non-implementation capabilities (scout, reviewer, lead, coordinator, supervisor, monitor) get:
422
+ * - Write, Edit, NotebookEdit tool blocks
423
+ * - Bash file-modification command guards (sed -i, echo >, mv, rm, etc.)
424
+ * - Coordination capabilities (coordinator, supervisor) get git add/commit whitelisted
425
+ *
426
+ * Implementation capabilities (builder, merger) get:
427
+ * - Bash path boundary guards (validates absolute paths stay in worktree)
428
+ *
429
+ * All overstory-managed agents get:
430
+ * - Claude Code native team/task tool blocks (Task, TeamCreate, SendMessage, etc.)
431
+ * to ensure delegation goes through overstory sling
432
+ *
433
+ * Note: All capabilities also receive Bash danger guards via getDangerGuards().
434
+ */
435
+ export function getCapabilityGuards(capability: string): HookEntry[] {
436
+ const guards: HookEntry[] = [];
437
+
438
+ // Block Claude Code native team/task tools for ALL overstory agents.
439
+ // Agents must use `overstory sling` for delegation, not native Task/Team tools.
440
+ const teamToolGuards = NATIVE_TEAM_TOOLS.map((tool) =>
441
+ blockGuard(
442
+ tool,
443
+ `Overstory agents must use 'overstory sling' for delegation — ${tool} is not allowed`,
444
+ ),
445
+ );
446
+ guards.push(...teamToolGuards);
447
+
448
+ if (NON_IMPLEMENTATION_CAPABILITIES.has(capability)) {
449
+ const toolGuards = WRITE_TOOLS.map((tool) =>
450
+ blockGuard(tool, `${capability} agents cannot modify files — ${tool} is not allowed`),
451
+ );
452
+ guards.push(...toolGuards);
453
+
454
+ // Coordination capabilities get git add/commit whitelisted for beads/mulch sync
455
+ const extraSafe = COORDINATION_CAPABILITIES.has(capability) ? COORDINATION_SAFE_PREFIXES : [];
456
+ const bashFileGuard: HookEntry = {
457
+ matcher: "Bash",
458
+ hooks: [
459
+ {
460
+ type: "command",
461
+ command: buildBashFileGuardScript(capability, extraSafe),
462
+ },
463
+ ],
464
+ };
465
+ guards.push(bashFileGuard);
466
+ }
467
+
468
+ // Implementation capabilities get Bash path boundary validation
469
+ // (non-implementation agents already block all file-modifying Bash commands)
470
+ if (IMPLEMENTATION_CAPABILITIES.has(capability)) {
471
+ guards.push(...getBashPathBoundaryGuards());
472
+ }
473
+
474
+ return guards;
475
+ }
476
+
477
+ /**
478
+ * Check whether a hook entry is overstory-managed.
479
+ *
480
+ * Overstory hook commands always reference either `overstory` (CLI commands)
481
+ * or `OVERSTORY_` (env var guards like OVERSTORY_AGENT_NAME, OVERSTORY_WORKTREE_PATH).
482
+ * User hooks will not contain these patterns.
483
+ */
484
+ export function isOverstoryHookEntry(entry: HookEntry): boolean {
485
+ return entry.hooks.some(
486
+ (h) => h.command.includes("overstory") || h.command.includes("OVERSTORY_"),
487
+ );
488
+ }
489
+
490
+ /**
491
+ * Deploy hooks config to an agent's worktree as `.claude/settings.local.json`.
492
+ *
493
+ * Reads `templates/hooks.json.tmpl`, replaces `{{AGENT_NAME}}`, then merges
494
+ * capability-specific PreToolUse guards into the resulting config.
495
+ *
496
+ * When the target file already exists (e.g. at the project root for coordinator/
497
+ * supervisor/monitor), preserves non-hooks keys and user-defined hook entries.
498
+ * Stale overstory hook entries are stripped and replaced with the new set.
499
+ * Overstory hooks are placed before user hooks per event type so security
500
+ * guards run first.
501
+ *
502
+ * @param worktreePath - Absolute path to the agent's git worktree (or project root)
503
+ * @param agentName - The unique name of the agent
504
+ * @param capability - Agent capability (builder, scout, reviewer, lead, merger)
505
+ * @throws {AgentError} If the template is not found or the write fails
506
+ */
507
+ export async function deployHooks(
508
+ worktreePath: string,
509
+ agentName: string,
510
+ capability = "builder",
511
+ ): Promise<void> {
512
+ const templatePath = getTemplatePath();
513
+ const file = Bun.file(templatePath);
514
+ const exists = await file.exists();
515
+
516
+ if (!exists) {
517
+ throw new AgentError(`Hooks template not found: ${templatePath}`, {
518
+ agentName,
519
+ });
520
+ }
521
+
522
+ let template: string;
523
+ try {
524
+ template = await file.text();
525
+ } catch (err) {
526
+ throw new AgentError(`Failed to read hooks template: ${templatePath}`, {
527
+ agentName,
528
+ cause: err instanceof Error ? err : undefined,
529
+ });
530
+ }
531
+
532
+ // Replace all occurrences of {{AGENT_NAME}}
533
+ let content = template;
534
+ while (content.includes("{{AGENT_NAME}}")) {
535
+ content = content.replace("{{AGENT_NAME}}", agentName);
536
+ }
537
+
538
+ // Parse the base config and merge guards into PreToolUse
539
+ const config = JSON.parse(content) as { hooks: Record<string, HookEntry[]> };
540
+ const pathGuards = getPathBoundaryGuards();
541
+ const dangerGuards = getDangerGuards(agentName);
542
+ const capabilityGuards = getCapabilityGuards(capability);
543
+ const allGuards = [...pathGuards, ...dangerGuards, ...capabilityGuards];
544
+
545
+ if (allGuards.length > 0) {
546
+ const preToolUse = config.hooks.PreToolUse ?? [];
547
+ config.hooks.PreToolUse = [...allGuards, ...preToolUse];
548
+ }
549
+
550
+ const claudeDir = join(worktreePath, ".claude");
551
+ const outputPath = join(claudeDir, "settings.local.json");
552
+
553
+ try {
554
+ await mkdir(claudeDir, { recursive: true });
555
+ } catch (err) {
556
+ throw new AgentError(`Failed to create .claude/ directory at: ${claudeDir}`, {
557
+ agentName,
558
+ cause: err instanceof Error ? err : undefined,
559
+ });
560
+ }
561
+
562
+ // Read existing settings.local.json to preserve user hooks and non-hooks keys
563
+ let existingConfig: Record<string, unknown> = {};
564
+ const existingFile = Bun.file(outputPath);
565
+ if (await existingFile.exists()) {
566
+ try {
567
+ const existingContent = await existingFile.text();
568
+ existingConfig = JSON.parse(existingContent) as Record<string, unknown>;
569
+ } catch {
570
+ // Malformed existing file — start fresh
571
+ }
572
+ }
573
+
574
+ // Separate non-hooks keys (permissions, env, $schema, etc.) from hooks
575
+ const { hooks: existingHooksRaw, ...nonHooksKeys } = existingConfig;
576
+
577
+ // Partition existing hooks: keep user entries, discard stale overstory entries
578
+ const existingHooks = (existingHooksRaw ?? {}) as Record<string, HookEntry[]>;
579
+ const userHooks: Record<string, HookEntry[]> = {};
580
+ for (const [eventType, entries] of Object.entries(existingHooks)) {
581
+ const userEntries = entries.filter((e) => !isOverstoryHookEntry(e));
582
+ if (userEntries.length > 0) {
583
+ userHooks[eventType] = userEntries;
584
+ }
585
+ }
586
+
587
+ // Merge: overstory hooks first (security guards must run first), then user hooks
588
+ const mergedHooks: Record<string, HookEntry[]> = {};
589
+ const allEventTypes = new Set([...Object.keys(config.hooks), ...Object.keys(userHooks)]);
590
+ for (const eventType of allEventTypes) {
591
+ const overstoryEntries = config.hooks[eventType] ?? [];
592
+ const userEntries = userHooks[eventType] ?? [];
593
+ mergedHooks[eventType] = [...overstoryEntries, ...userEntries];
594
+ }
595
+
596
+ const finalConfig = { ...nonHooksKeys, hooks: mergedHooks };
597
+ const finalContent = `${JSON.stringify(finalConfig, null, "\t")}\n`;
598
+
599
+ try {
600
+ await Bun.write(outputPath, finalContent);
601
+ } catch (err) {
602
+ throw new AgentError(`Failed to write hooks config to: ${outputPath}`, {
603
+ agentName,
604
+ cause: err instanceof Error ? err : undefined,
605
+ });
606
+ }
607
+ }