@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,458 @@
1
+ /**
2
+ * Tests for overstory hooks install/uninstall/status command.
3
+ *
4
+ * Uses real temp directories and real filesystem (no mocks needed).
5
+ * Each test gets an isolated temp directory with minimal .overstory/
6
+ * and .claude/ scaffolding.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
+ import { mkdir, realpath } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import { ValidationError } from "../errors.ts";
13
+ import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
14
+ import { hooksCommand, mergeHooksByEventType } from "./hooks.ts";
15
+
16
+ let tempDir: string;
17
+ const originalCwd = process.cwd();
18
+
19
+ /** Orchestrator hooks content for .overstory/hooks.json. */
20
+ const SAMPLE_HOOKS = {
21
+ hooks: {
22
+ SessionStart: [
23
+ {
24
+ matcher: "",
25
+ hooks: [{ type: "command", command: "overstory prime --agent orchestrator" }],
26
+ },
27
+ ],
28
+ Stop: [
29
+ {
30
+ matcher: "",
31
+ hooks: [{ type: "command", command: "overstory log session-end --agent orchestrator" }],
32
+ },
33
+ ],
34
+ },
35
+ };
36
+
37
+ /** Capture stdout.write output during a function call. */
38
+ async function captureStdout(fn: () => Promise<void>): Promise<string> {
39
+ const chunks: string[] = [];
40
+ const originalWrite = process.stdout.write;
41
+ process.stdout.write = ((chunk: string) => {
42
+ chunks.push(chunk);
43
+ return true;
44
+ }) as typeof process.stdout.write;
45
+ try {
46
+ await fn();
47
+ } finally {
48
+ process.stdout.write = originalWrite;
49
+ }
50
+ return chunks.join("");
51
+ }
52
+
53
+ beforeEach(async () => {
54
+ process.chdir(originalCwd);
55
+ tempDir = await realpath(await createTempGitRepo());
56
+
57
+ // Create minimal .overstory/ with config.yaml
58
+ const overstoryDir = join(tempDir, ".overstory");
59
+ await mkdir(overstoryDir, { recursive: true });
60
+ await Bun.write(
61
+ join(overstoryDir, "config.yaml"),
62
+ ["project:", " name: test-project", ` root: ${tempDir}`, " canonicalBranch: main"].join(
63
+ "\n",
64
+ ),
65
+ );
66
+
67
+ process.chdir(tempDir);
68
+ });
69
+
70
+ afterEach(async () => {
71
+ process.chdir(originalCwd);
72
+ await cleanupTempDir(tempDir);
73
+ });
74
+
75
+ describe("hooksCommand help", () => {
76
+ test("--help outputs help text", async () => {
77
+ const output = await captureStdout(() => hooksCommand(["--help"]));
78
+ expect(output).toContain("overstory hooks");
79
+ expect(output).toContain("install");
80
+ expect(output).toContain("uninstall");
81
+ expect(output).toContain("status");
82
+ });
83
+
84
+ test("empty args outputs help text", async () => {
85
+ const output = await captureStdout(() => hooksCommand([]));
86
+ expect(output).toContain("overstory hooks");
87
+ });
88
+
89
+ test("unknown subcommand throws ValidationError", async () => {
90
+ await expect(hooksCommand(["frobnicate"])).rejects.toThrow(ValidationError);
91
+ });
92
+ });
93
+
94
+ describe("hooks install", () => {
95
+ test("installs hooks from .overstory/hooks.json to .claude/settings.local.json", async () => {
96
+ // Write source hooks
97
+ await Bun.write(
98
+ join(tempDir, ".overstory", "hooks.json"),
99
+ `${JSON.stringify(SAMPLE_HOOKS, null, "\t")}\n`,
100
+ );
101
+
102
+ await captureStdout(() => hooksCommand(["install"]));
103
+
104
+ // Verify target file was created
105
+ const targetPath = join(tempDir, ".claude", "settings.local.json");
106
+ const content = await Bun.file(targetPath).text();
107
+ const parsed = JSON.parse(content) as Record<string, unknown>;
108
+ expect(parsed.hooks).toBeDefined();
109
+ expect(content).toContain("overstory prime");
110
+ });
111
+
112
+ test("preserves existing non-hooks keys in settings.local.json", async () => {
113
+ await Bun.write(
114
+ join(tempDir, ".overstory", "hooks.json"),
115
+ `${JSON.stringify(SAMPLE_HOOKS, null, "\t")}\n`,
116
+ );
117
+
118
+ // Write existing settings.local.json with non-hooks content
119
+ const claudeDir = join(tempDir, ".claude");
120
+ await mkdir(claudeDir, { recursive: true });
121
+ await Bun.write(
122
+ join(claudeDir, "settings.local.json"),
123
+ `${JSON.stringify({ env: { SOME_VAR: "1" } }, null, "\t")}\n`,
124
+ );
125
+
126
+ await captureStdout(() => hooksCommand(["install"]));
127
+
128
+ const content = await Bun.file(join(claudeDir, "settings.local.json")).text();
129
+ const parsed = JSON.parse(content) as Record<string, unknown>;
130
+ expect(parsed.hooks).toBeDefined();
131
+ expect(parsed.env).toEqual({ SOME_VAR: "1" });
132
+ });
133
+
134
+ test("warns when hooks already exist without --force", async () => {
135
+ await Bun.write(
136
+ join(tempDir, ".overstory", "hooks.json"),
137
+ `${JSON.stringify(SAMPLE_HOOKS, null, "\t")}\n`,
138
+ );
139
+
140
+ const claudeDir = join(tempDir, ".claude");
141
+ await mkdir(claudeDir, { recursive: true });
142
+ await Bun.write(
143
+ join(claudeDir, "settings.local.json"),
144
+ `${JSON.stringify({ hooks: { old: "hooks" } }, null, "\t")}\n`,
145
+ );
146
+
147
+ const output = await captureStdout(() => hooksCommand(["install"]));
148
+ expect(output).toContain("already present");
149
+ expect(output).toContain("--force");
150
+
151
+ // Verify hooks were NOT overwritten
152
+ const content = await Bun.file(join(claudeDir, "settings.local.json")).text();
153
+ expect(content).toContain("old");
154
+ });
155
+
156
+ test("--force merges existing hooks (not overwrites)", async () => {
157
+ await Bun.write(
158
+ join(tempDir, ".overstory", "hooks.json"),
159
+ `${JSON.stringify(SAMPLE_HOOKS, null, "\t")}\n`,
160
+ );
161
+
162
+ const claudeDir = join(tempDir, ".claude");
163
+ await mkdir(claudeDir, { recursive: true });
164
+ const existingSettings = {
165
+ hooks: {
166
+ UserInput: [
167
+ {
168
+ matcher: "",
169
+ hooks: [{ type: "command", command: "echo user-hook" }],
170
+ },
171
+ ],
172
+ },
173
+ };
174
+ await Bun.write(
175
+ join(claudeDir, "settings.local.json"),
176
+ `${JSON.stringify(existingSettings, null, "\t")}\n`,
177
+ );
178
+
179
+ await captureStdout(() => hooksCommand(["install", "--force"]));
180
+
181
+ const content = await Bun.file(join(claudeDir, "settings.local.json")).text();
182
+ // Existing user hook is preserved
183
+ expect(content).toContain("user-hook");
184
+ // Overstory hooks are added
185
+ expect(content).toContain("overstory prime");
186
+ });
187
+
188
+ test("throws when .overstory/hooks.json does not exist", async () => {
189
+ await expect(hooksCommand(["install"])).rejects.toThrow(ValidationError);
190
+ });
191
+
192
+ test("writes JSON with trailing newline", async () => {
193
+ await Bun.write(
194
+ join(tempDir, ".overstory", "hooks.json"),
195
+ `${JSON.stringify(SAMPLE_HOOKS, null, "\t")}\n`,
196
+ );
197
+
198
+ await captureStdout(() => hooksCommand(["install"]));
199
+
200
+ const content = await Bun.file(join(tempDir, ".claude", "settings.local.json")).text();
201
+ expect(content.endsWith("\n")).toBe(true);
202
+ });
203
+ });
204
+
205
+ describe("hooks uninstall", () => {
206
+ test("removes hooks-only settings.local.json file entirely", async () => {
207
+ const claudeDir = join(tempDir, ".claude");
208
+ await mkdir(claudeDir, { recursive: true });
209
+ await Bun.write(
210
+ join(claudeDir, "settings.local.json"),
211
+ `${JSON.stringify({ hooks: { some: "hooks" } }, null, "\t")}\n`,
212
+ );
213
+
214
+ const output = await captureStdout(() => hooksCommand(["uninstall"]));
215
+ expect(output).toContain("Removed");
216
+
217
+ const exists = await Bun.file(join(claudeDir, "settings.local.json")).exists();
218
+ expect(exists).toBe(false);
219
+ });
220
+
221
+ test("preserves non-hooks keys when uninstalling", async () => {
222
+ const claudeDir = join(tempDir, ".claude");
223
+ await mkdir(claudeDir, { recursive: true });
224
+ await Bun.write(
225
+ join(claudeDir, "settings.local.json"),
226
+ `${JSON.stringify({ hooks: { some: "hooks" }, env: { KEY: "val" } }, null, "\t")}\n`,
227
+ );
228
+
229
+ const output = await captureStdout(() => hooksCommand(["uninstall"]));
230
+ expect(output).toContain("preserved other settings");
231
+
232
+ const content = await Bun.file(join(claudeDir, "settings.local.json")).text();
233
+ const parsed = JSON.parse(content) as Record<string, unknown>;
234
+ expect(parsed.hooks).toBeUndefined();
235
+ expect(parsed.env).toEqual({ KEY: "val" });
236
+ });
237
+
238
+ test("handles missing settings.local.json gracefully", async () => {
239
+ const output = await captureStdout(() => hooksCommand(["uninstall"]));
240
+ expect(output).toContain("nothing to uninstall");
241
+ });
242
+
243
+ test("handles settings.local.json with no hooks key", async () => {
244
+ const claudeDir = join(tempDir, ".claude");
245
+ await mkdir(claudeDir, { recursive: true });
246
+ await Bun.write(
247
+ join(claudeDir, "settings.local.json"),
248
+ `${JSON.stringify({ env: { KEY: "val" } }, null, "\t")}\n`,
249
+ );
250
+
251
+ const output = await captureStdout(() => hooksCommand(["uninstall"]));
252
+ expect(output).toContain("No hooks found");
253
+ });
254
+ });
255
+
256
+ describe("hooks install merge behavior", () => {
257
+ test("--force merges overstory hooks into existing user hooks", async () => {
258
+ await Bun.write(
259
+ join(tempDir, ".overstory", "hooks.json"),
260
+ `${JSON.stringify(SAMPLE_HOOKS, null, "\t")}\n`,
261
+ );
262
+
263
+ const claudeDir = join(tempDir, ".claude");
264
+ await mkdir(claudeDir, { recursive: true });
265
+ const existingSettings = {
266
+ hooks: {
267
+ PreToolUse: [
268
+ {
269
+ matcher: "Write",
270
+ hooks: [{ type: "command", command: "echo user-write-hook" }],
271
+ },
272
+ ],
273
+ },
274
+ };
275
+ await Bun.write(
276
+ join(claudeDir, "settings.local.json"),
277
+ `${JSON.stringify(existingSettings, null, "\t")}\n`,
278
+ );
279
+
280
+ await captureStdout(() => hooksCommand(["install", "--force"]));
281
+
282
+ const content = await Bun.file(join(claudeDir, "settings.local.json")).text();
283
+ const parsed = JSON.parse(content) as { hooks: Record<string, unknown[]> };
284
+ // User's PreToolUse hook preserved
285
+ expect(content).toContain("user-write-hook");
286
+ // Overstory's SessionStart hook added
287
+ expect(content).toContain("overstory prime");
288
+ // Both event types present
289
+ expect(parsed.hooks.PreToolUse).toBeDefined();
290
+ expect(parsed.hooks.SessionStart).toBeDefined();
291
+ });
292
+
293
+ test("--force deduplicates identical entries", async () => {
294
+ await Bun.write(
295
+ join(tempDir, ".overstory", "hooks.json"),
296
+ `${JSON.stringify(SAMPLE_HOOKS, null, "\t")}\n`,
297
+ );
298
+
299
+ const claudeDir = join(tempDir, ".claude");
300
+ await mkdir(claudeDir, { recursive: true });
301
+
302
+ // First install
303
+ await captureStdout(() => hooksCommand(["install"]));
304
+
305
+ // Second install with --force (same hooks again)
306
+ await captureStdout(() => hooksCommand(["install", "--force"]));
307
+
308
+ const content = await Bun.file(join(claudeDir, "settings.local.json")).text();
309
+ const parsed = JSON.parse(content) as { hooks: Record<string, unknown[]> };
310
+
311
+ // SessionStart should have exactly 1 entry (no duplicate)
312
+ expect(parsed.hooks.SessionStart?.length).toBe(1);
313
+ // Stop should have exactly 1 entry (no duplicate)
314
+ expect(parsed.hooks.Stop?.length).toBe(1);
315
+ });
316
+
317
+ test("--force preserves existing event types not in source", async () => {
318
+ await Bun.write(
319
+ join(tempDir, ".overstory", "hooks.json"),
320
+ `${JSON.stringify(SAMPLE_HOOKS, null, "\t")}\n`,
321
+ );
322
+
323
+ const claudeDir = join(tempDir, ".claude");
324
+ await mkdir(claudeDir, { recursive: true });
325
+ const existingSettings = {
326
+ hooks: {
327
+ Notification: [
328
+ {
329
+ matcher: "",
330
+ hooks: [{ type: "command", command: "echo notification-hook" }],
331
+ },
332
+ ],
333
+ },
334
+ };
335
+ await Bun.write(
336
+ join(claudeDir, "settings.local.json"),
337
+ `${JSON.stringify(existingSettings, null, "\t")}\n`,
338
+ );
339
+
340
+ await captureStdout(() => hooksCommand(["install", "--force"]));
341
+
342
+ const content = await Bun.file(join(claudeDir, "settings.local.json")).text();
343
+ const parsed = JSON.parse(content) as { hooks: Record<string, unknown[]> };
344
+ // Custom event type preserved
345
+ expect(parsed.hooks.Notification).toBeDefined();
346
+ expect(content).toContain("notification-hook");
347
+ // Overstory hooks also present
348
+ expect(parsed.hooks.SessionStart).toBeDefined();
349
+ expect(parsed.hooks.Stop).toBeDefined();
350
+ });
351
+
352
+ test("first install without existing hooks works unchanged", async () => {
353
+ await Bun.write(
354
+ join(tempDir, ".overstory", "hooks.json"),
355
+ `${JSON.stringify(SAMPLE_HOOKS, null, "\t")}\n`,
356
+ );
357
+
358
+ await captureStdout(() => hooksCommand(["install"]));
359
+
360
+ const content = await Bun.file(join(tempDir, ".claude", "settings.local.json")).text();
361
+ const parsed = JSON.parse(content) as { hooks: Record<string, unknown[]> };
362
+ expect(parsed.hooks.SessionStart).toBeDefined();
363
+ expect(parsed.hooks.Stop).toBeDefined();
364
+ expect(content).toContain("overstory prime");
365
+ });
366
+
367
+ describe("mergeHooksByEventType unit tests", () => {
368
+ test("copies existing event types not in incoming", () => {
369
+ const existing = {
370
+ UserInput: [{ matcher: "", hooks: [{ type: "command", command: "echo a" }] }],
371
+ };
372
+ const incoming = {
373
+ SessionStart: [{ matcher: "", hooks: [{ type: "command", command: "echo b" }] }],
374
+ };
375
+ const result = mergeHooksByEventType(existing, incoming);
376
+ expect(result.UserInput).toBeDefined();
377
+ expect(result.SessionStart).toBeDefined();
378
+ });
379
+
380
+ test("appends non-duplicate incoming entries to existing event type", () => {
381
+ const existing = {
382
+ PreToolUse: [{ matcher: "Read", hooks: [{ type: "command", command: "echo read" }] }],
383
+ };
384
+ const incoming = {
385
+ PreToolUse: [{ matcher: "Write", hooks: [{ type: "command", command: "echo write" }] }],
386
+ };
387
+ const result = mergeHooksByEventType(existing, incoming);
388
+ expect(result.PreToolUse?.length).toBe(2);
389
+ });
390
+
391
+ test("does not add duplicate entries (same matcher + same commands)", () => {
392
+ const entry = { matcher: "", hooks: [{ type: "command", command: "echo dupe" }] };
393
+ const existing = { PreToolUse: [entry] };
394
+ const incoming = { PreToolUse: [entry] };
395
+ const result = mergeHooksByEventType(existing, incoming);
396
+ expect(result.PreToolUse?.length).toBe(1);
397
+ });
398
+
399
+ test("adds entry with same matcher but different commands", () => {
400
+ const existing = {
401
+ PreToolUse: [{ matcher: "", hooks: [{ type: "command", command: "echo a" }] }],
402
+ };
403
+ const incoming = {
404
+ PreToolUse: [{ matcher: "", hooks: [{ type: "command", command: "echo b" }] }],
405
+ };
406
+ const result = mergeHooksByEventType(existing, incoming);
407
+ expect(result.PreToolUse?.length).toBe(2);
408
+ });
409
+ });
410
+ });
411
+
412
+ describe("hooks status", () => {
413
+ test("reports source missing when .overstory/hooks.json does not exist", async () => {
414
+ const output = await captureStdout(() => hooksCommand(["status"]));
415
+ expect(output).toContain("missing");
416
+ });
417
+
418
+ test("reports installed:false when no hooks in .claude/", async () => {
419
+ await Bun.write(
420
+ join(tempDir, ".overstory", "hooks.json"),
421
+ `${JSON.stringify(SAMPLE_HOOKS, null, "\t")}\n`,
422
+ );
423
+
424
+ const output = await captureStdout(() => hooksCommand(["status"]));
425
+ expect(output).toContain("present");
426
+ expect(output).toContain("no");
427
+ expect(output).toContain("overstory hooks install");
428
+ });
429
+
430
+ test("reports installed:true when hooks present in .claude/", async () => {
431
+ await Bun.write(
432
+ join(tempDir, ".overstory", "hooks.json"),
433
+ `${JSON.stringify(SAMPLE_HOOKS, null, "\t")}\n`,
434
+ );
435
+
436
+ const claudeDir = join(tempDir, ".claude");
437
+ await mkdir(claudeDir, { recursive: true });
438
+ await Bun.write(
439
+ join(claudeDir, "settings.local.json"),
440
+ `${JSON.stringify({ hooks: {} }, null, "\t")}\n`,
441
+ );
442
+
443
+ const output = await captureStdout(() => hooksCommand(["status"]));
444
+ expect(output).toContain("yes");
445
+ });
446
+
447
+ test("--json outputs correct fields", async () => {
448
+ await Bun.write(
449
+ join(tempDir, ".overstory", "hooks.json"),
450
+ `${JSON.stringify(SAMPLE_HOOKS, null, "\t")}\n`,
451
+ );
452
+
453
+ const output = await captureStdout(() => hooksCommand(["status", "--json"]));
454
+ const parsed = JSON.parse(output) as Record<string, unknown>;
455
+ expect(parsed.sourceExists).toBe(true);
456
+ expect(parsed.installed).toBe(false);
457
+ });
458
+ });
@@ -0,0 +1,253 @@
1
+ /**
2
+ * CLI command: overstory hooks install|uninstall|status
3
+ *
4
+ * Manages orchestrator hooks in .claude/settings.local.json.
5
+ * Hooks are sourced from .overstory/hooks.json (generated by overstory init).
6
+ *
7
+ * This keeps the canonical hook configuration in .overstory/ while placing
8
+ * a minimal copy in .claude/ only when the user explicitly opts in.
9
+ * Running `overstory init` alone does NOT modify .claude/ — the user must
10
+ * run `overstory hooks install` as a separate step.
11
+ */
12
+
13
+ import { mkdir, unlink } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+ import { loadConfig } from "../config.ts";
16
+ import { ValidationError } from "../errors.ts";
17
+
18
+ interface HookEntry {
19
+ matcher: string;
20
+ hooks: ReadonlyArray<{ type: string; command: string }>;
21
+ }
22
+
23
+ function isDuplicateEntry(a: HookEntry, b: HookEntry): boolean {
24
+ if (a.matcher !== b.matcher) return false;
25
+ if (a.hooks.length !== b.hooks.length) return false;
26
+ return a.hooks.every((cmd, i) => {
27
+ const bCmd = b.hooks[i];
28
+ return bCmd !== undefined && bCmd.type === cmd.type && bCmd.command === cmd.command;
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Merge two hook maps by event type, deduplicating entries with identical
34
+ * matcher + command list pairs. Preserves all existing entries and appends
35
+ * only non-duplicate incoming entries per event type.
36
+ */
37
+ export function mergeHooksByEventType(
38
+ existing: Record<string, unknown[]>,
39
+ incoming: Record<string, unknown[]>,
40
+ ): Record<string, unknown[]> {
41
+ const merged: Record<string, unknown[]> = { ...existing };
42
+
43
+ for (const [eventType, incomingEntries] of Object.entries(incoming)) {
44
+ if (!(eventType in merged)) {
45
+ merged[eventType] = incomingEntries;
46
+ continue;
47
+ }
48
+
49
+ const existingEntries = merged[eventType] ?? [];
50
+ const toAdd: unknown[] = [];
51
+
52
+ for (const entry of incomingEntries) {
53
+ const incomingEntry = entry as HookEntry;
54
+ const isDupe = existingEntries.some((e) => isDuplicateEntry(e as HookEntry, incomingEntry));
55
+ if (!isDupe) {
56
+ toAdd.push(entry);
57
+ }
58
+ }
59
+
60
+ merged[eventType] = [...existingEntries, ...toAdd];
61
+ }
62
+
63
+ return merged;
64
+ }
65
+
66
+ const HOOKS_HELP = `overstory hooks — Manage orchestrator hooks
67
+
68
+ Usage: overstory hooks <subcommand>
69
+
70
+ Subcommands:
71
+ install Install orchestrator hooks to .claude/settings.local.json
72
+ uninstall Remove orchestrator hooks from .claude/settings.local.json
73
+ status Check if hooks are installed
74
+
75
+ Options:
76
+ --force Overwrite existing hooks in .claude/settings.local.json
77
+ --json Output as JSON
78
+ --help, -h Show this help
79
+
80
+ Hooks source: .overstory/hooks.json (generated by overstory init)
81
+ Hooks target: .claude/settings.local.json (read by Claude Code)`;
82
+
83
+ /**
84
+ * Install orchestrator hooks from .overstory/hooks.json to .claude/settings.local.json.
85
+ *
86
+ * Reads the canonical hook config from .overstory/hooks.json and writes it to
87
+ * .claude/settings.local.json where Claude Code discovers it. Preserves any
88
+ * existing non-hooks keys in the target file.
89
+ */
90
+ async function installHooks(args: string[]): Promise<void> {
91
+ const force = args.includes("--force");
92
+ const cwd = process.cwd();
93
+ const config = await loadConfig(cwd);
94
+ const projectRoot = config.project.root;
95
+
96
+ // Read source hooks from .overstory/hooks.json
97
+ const sourcePath = join(projectRoot, ".overstory", "hooks.json");
98
+ const sourceFile = Bun.file(sourcePath);
99
+ if (!(await sourceFile.exists())) {
100
+ throw new ValidationError("No hooks.json found in .overstory/. Run 'overstory init' first.", {
101
+ field: "source",
102
+ });
103
+ }
104
+
105
+ const sourceContent = await sourceFile.text();
106
+ const sourceHooks = JSON.parse(sourceContent) as Record<string, unknown>;
107
+
108
+ // Check target .claude/settings.local.json
109
+ const targetDir = join(projectRoot, ".claude");
110
+ const targetPath = join(targetDir, "settings.local.json");
111
+ const targetFile = Bun.file(targetPath);
112
+
113
+ let targetConfig: Record<string, unknown> = {};
114
+ if (await targetFile.exists()) {
115
+ const existingContent = await targetFile.text();
116
+ const existing = JSON.parse(existingContent) as Record<string, unknown>;
117
+
118
+ if (existing.hooks && !force) {
119
+ process.stdout.write(
120
+ "Hooks already present in .claude/settings.local.json\nUse --force to overwrite.\n",
121
+ );
122
+ return;
123
+ }
124
+
125
+ // Preserve non-hooks keys (e.g., env settings)
126
+ targetConfig = existing;
127
+ }
128
+
129
+ // Merge: set hooks from source, preserve other keys
130
+ const existingHooks = targetConfig.hooks as Record<string, unknown[]> | undefined;
131
+ const incomingHooks = sourceHooks.hooks as Record<string, unknown[]>;
132
+ targetConfig.hooks = existingHooks
133
+ ? mergeHooksByEventType(existingHooks, incomingHooks)
134
+ : incomingHooks;
135
+
136
+ // Write
137
+ await mkdir(targetDir, { recursive: true });
138
+ await Bun.write(targetPath, `${JSON.stringify(targetConfig, null, "\t")}\n`);
139
+
140
+ process.stdout.write("\u2713 Installed orchestrator hooks to .claude/settings.local.json\n");
141
+ process.stdout.write(" Source: .overstory/hooks.json\n");
142
+ }
143
+
144
+ /**
145
+ * Remove orchestrator hooks from .claude/settings.local.json.
146
+ *
147
+ * If hooks were the only content, removes the file entirely.
148
+ * Otherwise, preserves other keys and only removes the hooks key.
149
+ */
150
+ async function uninstallHooks(_args: string[]): Promise<void> {
151
+ const cwd = process.cwd();
152
+ const config = await loadConfig(cwd);
153
+ const projectRoot = config.project.root;
154
+
155
+ const targetPath = join(projectRoot, ".claude", "settings.local.json");
156
+ const targetFile = Bun.file(targetPath);
157
+
158
+ if (!(await targetFile.exists())) {
159
+ process.stdout.write("No .claude/settings.local.json found \u2014 nothing to uninstall.\n");
160
+ return;
161
+ }
162
+
163
+ const content = await targetFile.text();
164
+ const parsed = JSON.parse(content) as Record<string, unknown>;
165
+
166
+ if (!parsed.hooks) {
167
+ process.stdout.write(
168
+ "No hooks found in .claude/settings.local.json \u2014 nothing to uninstall.\n",
169
+ );
170
+ return;
171
+ }
172
+
173
+ // Separate hooks from other settings
174
+ const { hooks: _hooks, ...rest } = parsed;
175
+
176
+ const remainingKeys = Object.keys(rest);
177
+ if (remainingKeys.length === 0) {
178
+ await unlink(targetPath);
179
+ process.stdout.write("\u2713 Removed .claude/settings.local.json (was hooks-only)\n");
180
+ } else {
181
+ await Bun.write(targetPath, `${JSON.stringify(rest, null, "\t")}\n`);
182
+ process.stdout.write(
183
+ "\u2713 Removed hooks from .claude/settings.local.json (preserved other settings)\n",
184
+ );
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Show hooks installation status.
190
+ */
191
+ async function statusHooks(args: string[]): Promise<void> {
192
+ const json = args.includes("--json");
193
+ const cwd = process.cwd();
194
+ const config = await loadConfig(cwd);
195
+ const projectRoot = config.project.root;
196
+
197
+ const sourcePath = join(projectRoot, ".overstory", "hooks.json");
198
+ const targetPath = join(projectRoot, ".claude", "settings.local.json");
199
+
200
+ const sourceExists = await Bun.file(sourcePath).exists();
201
+ const targetExists = await Bun.file(targetPath).exists();
202
+
203
+ let installed = false;
204
+ if (targetExists) {
205
+ const content = await Bun.file(targetPath).text();
206
+ const parsed = JSON.parse(content) as Record<string, unknown>;
207
+ installed = !!parsed.hooks;
208
+ }
209
+
210
+ if (json) {
211
+ process.stdout.write(`${JSON.stringify({ sourceExists, installed })}\n`);
212
+ } else {
213
+ process.stdout.write(
214
+ `Hooks source (.overstory/hooks.json): ${sourceExists ? "present" : "missing"}\n`,
215
+ );
216
+ process.stdout.write(
217
+ `Hooks installed (.claude/settings.local.json): ${installed ? "yes" : "no"}\n`,
218
+ );
219
+ if (!installed && sourceExists) {
220
+ process.stdout.write(`\nRun 'overstory hooks install' to install.\n`);
221
+ }
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Entry point for `overstory hooks <subcommand>`.
227
+ */
228
+ export async function hooksCommand(args: string[]): Promise<void> {
229
+ if (args.includes("--help") || args.includes("-h") || args.length === 0) {
230
+ process.stdout.write(`${HOOKS_HELP}\n`);
231
+ return;
232
+ }
233
+
234
+ const subcommand = args[0];
235
+ const subArgs = args.slice(1);
236
+
237
+ switch (subcommand) {
238
+ case "install":
239
+ await installHooks(subArgs);
240
+ break;
241
+ case "uninstall":
242
+ await uninstallHooks(subArgs);
243
+ break;
244
+ case "status":
245
+ await statusHooks(subArgs);
246
+ break;
247
+ default:
248
+ throw new ValidationError(
249
+ `Unknown hooks subcommand: ${subcommand}. Run 'overstory hooks --help' for usage.`,
250
+ { field: "subcommand", value: subcommand },
251
+ );
252
+ }
253
+ }