@os-eco/overstory-cli 0.9.3 → 0.10.3

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 (116) hide show
  1. package/README.md +49 -18
  2. package/agents/builder.md +9 -8
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +98 -82
  5. package/agents/merger.md +25 -14
  6. package/agents/reviewer.md +22 -16
  7. package/agents/scout.md +17 -12
  8. package/package.json +6 -3
  9. package/src/agents/capabilities.test.ts +85 -0
  10. package/src/agents/capabilities.ts +125 -0
  11. package/src/agents/headless-mail-injector.test.ts +448 -0
  12. package/src/agents/headless-mail-injector.ts +211 -0
  13. package/src/agents/headless-prompt.test.ts +102 -0
  14. package/src/agents/headless-prompt.ts +68 -0
  15. package/src/agents/hooks-deployer.test.ts +514 -14
  16. package/src/agents/hooks-deployer.ts +141 -0
  17. package/src/agents/overlay.test.ts +4 -4
  18. package/src/agents/overlay.ts +30 -8
  19. package/src/agents/turn-lock.test.ts +181 -0
  20. package/src/agents/turn-lock.ts +235 -0
  21. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  22. package/src/agents/turn-runner-dispatch.ts +105 -0
  23. package/src/agents/turn-runner.test.ts +1450 -0
  24. package/src/agents/turn-runner.ts +1166 -0
  25. package/src/commands/clean.ts +56 -1
  26. package/src/commands/completions.test.ts +4 -1
  27. package/src/commands/coordinator.test.ts +127 -0
  28. package/src/commands/coordinator.ts +205 -6
  29. package/src/commands/dashboard.test.ts +188 -0
  30. package/src/commands/dashboard.ts +13 -3
  31. package/src/commands/doctor.ts +94 -77
  32. package/src/commands/group.test.ts +94 -0
  33. package/src/commands/group.ts +49 -20
  34. package/src/commands/init.test.ts +8 -0
  35. package/src/commands/init.ts +8 -1
  36. package/src/commands/log.test.ts +56 -11
  37. package/src/commands/log.ts +134 -69
  38. package/src/commands/mail.test.ts +162 -0
  39. package/src/commands/mail.ts +64 -9
  40. package/src/commands/merge.test.ts +112 -1
  41. package/src/commands/merge.ts +17 -4
  42. package/src/commands/monitor.ts +2 -1
  43. package/src/commands/nudge.test.ts +351 -4
  44. package/src/commands/nudge.ts +356 -34
  45. package/src/commands/run.test.ts +43 -7
  46. package/src/commands/serve/build.test.ts +202 -0
  47. package/src/commands/serve/build.ts +206 -0
  48. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  49. package/src/commands/serve/coordinator-actions.ts +408 -0
  50. package/src/commands/serve/dev.test.ts +168 -0
  51. package/src/commands/serve/dev.ts +117 -0
  52. package/src/commands/serve/mail-actions.test.ts +312 -0
  53. package/src/commands/serve/mail-actions.ts +167 -0
  54. package/src/commands/serve/rest.test.ts +1323 -0
  55. package/src/commands/serve/rest.ts +708 -0
  56. package/src/commands/serve/static.ts +51 -0
  57. package/src/commands/serve/ws.test.ts +361 -0
  58. package/src/commands/serve/ws.ts +332 -0
  59. package/src/commands/serve.test.ts +459 -0
  60. package/src/commands/serve.ts +565 -0
  61. package/src/commands/sling.test.ts +85 -1
  62. package/src/commands/sling.ts +153 -64
  63. package/src/commands/status.test.ts +9 -0
  64. package/src/commands/status.ts +12 -4
  65. package/src/commands/stop.test.ts +174 -1
  66. package/src/commands/stop.ts +107 -8
  67. package/src/commands/supervisor.ts +2 -1
  68. package/src/commands/watch.test.ts +49 -4
  69. package/src/commands/watch.ts +153 -28
  70. package/src/commands/worktree.test.ts +319 -3
  71. package/src/commands/worktree.ts +86 -0
  72. package/src/config.test.ts +78 -0
  73. package/src/config.ts +43 -1
  74. package/src/doctor/consistency.test.ts +106 -0
  75. package/src/doctor/consistency.ts +50 -3
  76. package/src/doctor/serve.test.ts +95 -0
  77. package/src/doctor/serve.ts +86 -0
  78. package/src/doctor/types.ts +2 -1
  79. package/src/doctor/watchdog.ts +57 -1
  80. package/src/events/tailer.test.ts +234 -1
  81. package/src/events/tailer.ts +90 -0
  82. package/src/index.ts +53 -6
  83. package/src/json.ts +29 -0
  84. package/src/mail/client.ts +15 -2
  85. package/src/mail/store.test.ts +82 -0
  86. package/src/mail/store.ts +41 -4
  87. package/src/merge/lock.test.ts +149 -0
  88. package/src/merge/lock.ts +140 -0
  89. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  90. package/src/runtimes/claude.test.ts +791 -1
  91. package/src/runtimes/claude.ts +323 -1
  92. package/src/runtimes/connections.test.ts +141 -1
  93. package/src/runtimes/connections.ts +73 -4
  94. package/src/runtimes/headless-connection.test.ts +264 -0
  95. package/src/runtimes/headless-connection.ts +158 -0
  96. package/src/runtimes/types.ts +10 -0
  97. package/src/schema-consistency.test.ts +1 -0
  98. package/src/sessions/store.test.ts +390 -24
  99. package/src/sessions/store.ts +184 -19
  100. package/src/test-setup.test.ts +31 -0
  101. package/src/test-setup.ts +28 -0
  102. package/src/types.ts +56 -1
  103. package/src/utils/pid.test.ts +85 -1
  104. package/src/utils/pid.ts +86 -1
  105. package/src/utils/process-scan.test.ts +53 -0
  106. package/src/utils/process-scan.ts +76 -0
  107. package/src/watchdog/daemon.test.ts +1520 -411
  108. package/src/watchdog/daemon.ts +442 -83
  109. package/src/watchdog/health.test.ts +157 -0
  110. package/src/watchdog/health.ts +92 -25
  111. package/src/worktree/process.test.ts +71 -0
  112. package/src/worktree/process.ts +25 -5
  113. package/src/worktree/tmux.test.ts +39 -0
  114. package/src/worktree/tmux.ts +23 -3
  115. package/templates/CLAUDE.md.tmpl +19 -8
  116. package/templates/overlay.md.tmpl +3 -2
@@ -1,12 +1,21 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdtemp } from "node:fs/promises";
2
+ import { chmod, mkdir, mkdtemp, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
- import { join } from "node:path";
4
+ import { join, resolve } from "node:path";
5
5
  import { AgentError } from "../errors.ts";
6
- import { cleanupTempDir } from "../test-helpers.ts";
6
+ import { createMailClient } from "../mail/client.ts";
7
+ import { createMailStore } from "../mail/store.ts";
8
+ import {
9
+ cleanupTempDir,
10
+ commitFile,
11
+ createTempGitRepo,
12
+ getDefaultBranch,
13
+ runGitInDir,
14
+ } from "../test-helpers.ts";
7
15
  import {
8
16
  buildBashFileGuardScript,
9
17
  buildBashPathBoundaryScript,
18
+ buildLeadCloseGateScript,
10
19
  buildPathBoundaryGuardScript,
11
20
  buildTrackerCloseGuardScript,
12
21
  deployHooks,
@@ -15,6 +24,7 @@ import {
15
24
  getBashPathBoundaryGuards,
16
25
  getCapabilityGuards,
17
26
  getDangerGuards,
27
+ getLeadCloseGateGuards,
18
28
  getPathBoundaryGuards,
19
29
  getTrackerCloseGuards,
20
30
  isOverstoryHookEntry,
@@ -514,9 +524,15 @@ describe("deployHooks", () => {
514
524
  expect(guardMatchers).toContain("NotebookEdit");
515
525
  expect(guardMatchers).toContain("Bash");
516
526
 
517
- // Should have 4 Bash guards: danger guard + file guard + tracker close guard + universal push guard
527
+ // Should have 5 Bash guards: danger guard + file guard + tracker close guard + universal push guard + lead close-gate
518
528
  const bashGuards = preToolUse.filter((h: { matcher: string }) => h.matcher === "Bash");
519
- expect(bashGuards.length).toBe(4);
529
+ expect(bashGuards.length).toBe(5);
530
+
531
+ // One Bash guard is the lead close-gate (overstory-3899)
532
+ const closeGate = bashGuards.find((h: { hooks: Array<{ command: string }> }) =>
533
+ h.hooks[0]?.command?.includes("merge_ready gate"),
534
+ );
535
+ expect(closeGate).toBeDefined();
520
536
  });
521
537
 
522
538
  test("builder capability gets path boundary + Bash danger + Bash path boundary guards + native team tool blocks", async () => {
@@ -963,9 +979,9 @@ describe("getCapabilityGuards", () => {
963
979
  expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + INTERACTIVE_TOOL_COUNT + 4);
964
980
  });
965
981
 
966
- test("returns 17 guards for lead (10 team + 3 interactive + 3 tool blocks + 1 bash file guard)", () => {
982
+ test("returns 18 guards for lead (10 team + 3 interactive + 3 tool blocks + 1 bash file guard + 1 close-gate)", () => {
967
983
  const guards = getCapabilityGuards("lead");
968
- expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + INTERACTIVE_TOOL_COUNT + 4);
984
+ expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + INTERACTIVE_TOOL_COUNT + 5);
969
985
  });
970
986
 
971
987
  test("returns 14 guards for builder (10 team + 3 interactive + 1 bash path boundary)", () => {
@@ -1531,7 +1547,7 @@ describe("structural enforcement integration", () => {
1531
1547
  expect(scoutMatchers).toEqual(reviewerMatchers);
1532
1548
  });
1533
1549
 
1534
- test("lead has same guard structure as scout/reviewer", async () => {
1550
+ test("lead has scout/reviewer guards plus the merge_ready close-gate", async () => {
1535
1551
  const leadPath = join(tempDir, "lead-wt");
1536
1552
  const scoutPath = join(tempDir, "scout-wt");
1537
1553
 
@@ -1544,13 +1560,15 @@ describe("structural enforcement integration", () => {
1544
1560
  const leadPreToolUse = JSON.parse(leadContent).hooks.PreToolUse;
1545
1561
  const scoutPreToolUse = JSON.parse(scoutContent).hooks.PreToolUse;
1546
1562
 
1547
- // Same number of guards
1548
- expect(leadPreToolUse.length).toBe(scoutPreToolUse.length);
1563
+ // Lead has exactly one extra guard: the merge_ready close-gate (overstory-3899)
1564
+ expect(leadPreToolUse.length).toBe(scoutPreToolUse.length + 1);
1549
1565
 
1550
- // Same matchers
1551
- const leadMatchers = leadPreToolUse.map((h: { matcher: string }) => h.matcher);
1552
- const scoutMatchers = scoutPreToolUse.map((h: { matcher: string }) => h.matcher);
1553
- expect(leadMatchers).toEqual(scoutMatchers);
1566
+ // The extra guard is a Bash matcher referencing the close-gate
1567
+ const closeGate = leadPreToolUse.find(
1568
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
1569
+ h.matcher === "Bash" && h.hooks[0]?.command?.includes("merge_ready gate"),
1570
+ );
1571
+ expect(closeGate).toBeDefined();
1554
1572
  });
1555
1573
 
1556
1574
  test("builder and merger have identical guard structures", async () => {
@@ -2582,6 +2600,488 @@ describe("deployHooks tracker close guard integration", () => {
2582
2600
  });
2583
2601
  });
2584
2602
 
2603
+ describe("buildLeadCloseGateScript (overstory-3899, overstory-da9b)", () => {
2604
+ test("returns a string containing key patterns", () => {
2605
+ const script = buildLeadCloseGateScript();
2606
+ expect(typeof script).toBe("string");
2607
+ expect(script.length).toBeGreaterThan(0);
2608
+ expect(script).toContain("merge_ready");
2609
+ expect(script).toContain("worker_done");
2610
+ expect(script).toContain("OVERSTORY_TASK_ID");
2611
+ expect(script).toContain("OVERSTORY_AGENT_NAME");
2612
+ expect(script).toContain("(sd|bd)");
2613
+ expect(script).toContain("close");
2614
+ });
2615
+
2616
+ test("contains ENV_GUARD prefix", () => {
2617
+ const script = buildLeadCloseGateScript();
2618
+ expect(script).toContain('[ -z "$OVERSTORY_AGENT_NAME" ] && exit 0;');
2619
+ });
2620
+
2621
+ test("contains OVERSTORY_TASK_ID early-exit check", () => {
2622
+ const script = buildLeadCloseGateScript();
2623
+ expect(script).toContain('[ -z "$OVERSTORY_TASK_ID" ] && exit 0;');
2624
+ });
2625
+
2626
+ test("contains merge-ancestor verification (overstory-da9b)", () => {
2627
+ const script = buildLeadCloseGateScript();
2628
+ expect(script).toContain("OVERSTORY_WORKTREE_PATH");
2629
+ expect(script).toContain("session-branch.txt");
2630
+ expect(script).toContain("merge-base --is-ancestor");
2631
+ expect(script).toContain("not yet merged");
2632
+ });
2633
+ });
2634
+
2635
+ describe("getLeadCloseGateGuards", () => {
2636
+ test("returns exactly 1 Bash guard entry", () => {
2637
+ const guards = getLeadCloseGateGuards();
2638
+ expect(guards).toHaveLength(1);
2639
+ expect(guards[0]?.matcher).toBe("Bash");
2640
+ });
2641
+
2642
+ test("guard hook type is command", () => {
2643
+ const guards = getLeadCloseGateGuards();
2644
+ expect(guards[0]?.hooks[0]?.type).toBe("command");
2645
+ });
2646
+
2647
+ test("guard command includes PATH_PREFIX (so ov resolves)", () => {
2648
+ const guards = getLeadCloseGateGuards();
2649
+ const command = guards[0]?.hooks[0]?.command ?? "";
2650
+ expect(command).toContain("$HOME/.bun/bin");
2651
+ });
2652
+
2653
+ test("guard command references merge_ready and worker_done", () => {
2654
+ const guards = getLeadCloseGateGuards();
2655
+ const command = guards[0]?.hooks[0]?.command ?? "";
2656
+ expect(command).toContain("merge_ready");
2657
+ expect(command).toContain("worker_done");
2658
+ });
2659
+ });
2660
+
2661
+ // CI runners don't have a global `ov` binary on PATH, so the lead close-gate
2662
+ // script (which calls `ov mail list ...`) can't count seeded mails. Build a
2663
+ // shim that execs the project's own src/index.ts via bun, and return its dir
2664
+ // to prepend to PATH inside spawned shells.
2665
+ async function createOvShim(parentDir: string): Promise<string> {
2666
+ const binDir = join(parentDir, ".bin");
2667
+ await mkdir(binDir, { recursive: true });
2668
+ const ovSrc = resolve(import.meta.dir, "..", "index.ts");
2669
+ const shim = `#!/bin/sh\nexec ${process.execPath} ${JSON.stringify(ovSrc)} "$@"\n`;
2670
+ const ovShim = join(binDir, "ov");
2671
+ await writeFile(ovShim, shim);
2672
+ await chmod(ovShim, 0o755);
2673
+ return binDir;
2674
+ }
2675
+
2676
+ describe("lead close-gate behavioral tests", () => {
2677
+ let tempDir: string;
2678
+ let dbPath: string;
2679
+ let ovBinDir: string;
2680
+
2681
+ beforeEach(async () => {
2682
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-lead-gate-test-"));
2683
+ await mkdir(join(tempDir, ".overstory"), { recursive: true });
2684
+ dbPath = join(tempDir, ".overstory", "mail.db");
2685
+ ovBinDir = await createOvShim(tempDir);
2686
+ });
2687
+
2688
+ afterEach(async () => {
2689
+ await cleanupTempDir(tempDir);
2690
+ });
2691
+
2692
+ function seedMail(messages: Array<{ from: string; to: string; type: string }>): void {
2693
+ const store = createMailStore(dbPath);
2694
+ const client = createMailClient(store);
2695
+ try {
2696
+ for (const m of messages) {
2697
+ client.send({
2698
+ from: m.from,
2699
+ to: m.to,
2700
+ subject: `${m.type}: test`,
2701
+ body: "test",
2702
+ type: m.type as Parameters<typeof client.send>[0]["type"],
2703
+ });
2704
+ }
2705
+ } finally {
2706
+ client.close();
2707
+ }
2708
+ }
2709
+
2710
+ async function runGate(opts: {
2711
+ command: string;
2712
+ agentName?: string | null;
2713
+ taskId?: string | null;
2714
+ }): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
2715
+ const guards = getLeadCloseGateGuards();
2716
+ const script = guards[0]?.hooks[0]?.command ?? "";
2717
+ const input = JSON.stringify({ command: opts.command });
2718
+ const env = { ...process.env } as Record<string, string>;
2719
+ env.PATH = `${ovBinDir}:${env.PATH ?? ""}`;
2720
+ if (opts.agentName === null) {
2721
+ delete env.OVERSTORY_AGENT_NAME;
2722
+ } else if (opts.agentName !== undefined) {
2723
+ env.OVERSTORY_AGENT_NAME = opts.agentName;
2724
+ }
2725
+ if (opts.taskId === null) {
2726
+ delete env.OVERSTORY_TASK_ID;
2727
+ } else if (opts.taskId !== undefined) {
2728
+ env.OVERSTORY_TASK_ID = opts.taskId;
2729
+ }
2730
+ const proc = Bun.spawn(["sh", "-c", script], {
2731
+ cwd: tempDir,
2732
+ stdin: new TextEncoder().encode(input),
2733
+ stdout: "pipe",
2734
+ stderr: "pipe",
2735
+ env,
2736
+ });
2737
+ const stdout = await new Response(proc.stdout).text();
2738
+ const stderr = await new Response(proc.stderr).text();
2739
+ const exitCode = await proc.exited;
2740
+ return { stdout, stderr, exitCode };
2741
+ }
2742
+
2743
+ test("exits silently when OVERSTORY_AGENT_NAME is unset", async () => {
2744
+ const r = await runGate({
2745
+ command: "sd close my-task",
2746
+ agentName: null,
2747
+ taskId: "my-task",
2748
+ });
2749
+ expect(r.stdout.trim()).toBe("");
2750
+ });
2751
+
2752
+ test("exits silently when OVERSTORY_TASK_ID is unset (coordinator/monitor)", async () => {
2753
+ const r = await runGate({
2754
+ command: "sd close my-task",
2755
+ agentName: "coordinator",
2756
+ taskId: null,
2757
+ });
2758
+ expect(r.stdout.trim()).toBe("");
2759
+ });
2760
+
2761
+ test("exits silently for non-close commands", async () => {
2762
+ const r = await runGate({
2763
+ command: "git status",
2764
+ agentName: "lead-x",
2765
+ taskId: "my-task",
2766
+ });
2767
+ expect(r.stdout.trim()).toBe("");
2768
+ });
2769
+
2770
+ test("exits silently when closing a foreign task (handled by tracker-close guard)", async () => {
2771
+ const r = await runGate({
2772
+ command: "sd close some-other-task",
2773
+ agentName: "lead-x",
2774
+ taskId: "my-task",
2775
+ });
2776
+ expect(r.stdout.trim()).toBe("");
2777
+ });
2778
+
2779
+ test("blocks sd close of own task when zero merge_ready sent", async () => {
2780
+ const r = await runGate({
2781
+ command: "sd close my-task",
2782
+ agentName: "lead-x",
2783
+ taskId: "my-task",
2784
+ });
2785
+ const parsed = JSON.parse(r.stdout.trim());
2786
+ expect(parsed.decision).toBe("block");
2787
+ expect(parsed.reason).toContain("merge_ready");
2788
+ expect(parsed.reason).toContain("not sent");
2789
+ });
2790
+
2791
+ test("blocks bd close of own task when zero merge_ready sent (beads tracker)", async () => {
2792
+ const r = await runGate({
2793
+ command: "bd close my-task",
2794
+ agentName: "lead-x",
2795
+ taskId: "my-task",
2796
+ });
2797
+ const parsed = JSON.parse(r.stdout.trim());
2798
+ expect(parsed.decision).toBe("block");
2799
+ expect(parsed.reason).toContain("merge_ready");
2800
+ });
2801
+
2802
+ test("allows sd close when one merge_ready sent and zero worker_done received", async () => {
2803
+ seedMail([{ from: "lead-x", to: "coordinator", type: "merge_ready" }]);
2804
+ const r = await runGate({
2805
+ command: "sd close my-task",
2806
+ agentName: "lead-x",
2807
+ taskId: "my-task",
2808
+ });
2809
+ expect(r.stdout.trim()).toBe("");
2810
+ });
2811
+
2812
+ test("blocks sd close when merge_ready count < worker_done count", async () => {
2813
+ seedMail([
2814
+ { from: "lead-x", to: "coordinator", type: "merge_ready" },
2815
+ { from: "builder-a", to: "lead-x", type: "worker_done" },
2816
+ { from: "builder-b", to: "lead-x", type: "worker_done" },
2817
+ ]);
2818
+ const r = await runGate({
2819
+ command: "sd close my-task",
2820
+ agentName: "lead-x",
2821
+ taskId: "my-task",
2822
+ });
2823
+ const parsed = JSON.parse(r.stdout.trim());
2824
+ expect(parsed.decision).toBe("block");
2825
+ expect(parsed.reason).toContain("less than worker_done");
2826
+ });
2827
+
2828
+ test("allows sd close when merge_ready count equals worker_done count", async () => {
2829
+ seedMail([
2830
+ { from: "lead-x", to: "coordinator", type: "merge_ready" },
2831
+ { from: "lead-x", to: "coordinator", type: "merge_ready" },
2832
+ { from: "builder-a", to: "lead-x", type: "worker_done" },
2833
+ { from: "builder-b", to: "lead-x", type: "worker_done" },
2834
+ ]);
2835
+ const r = await runGate({
2836
+ command: "sd close my-task",
2837
+ agentName: "lead-x",
2838
+ taskId: "my-task",
2839
+ });
2840
+ expect(r.stdout.trim()).toBe("");
2841
+ });
2842
+
2843
+ test("only counts merge_ready sent BY this agent, not by other leads", async () => {
2844
+ seedMail([
2845
+ { from: "lead-y", to: "coordinator", type: "merge_ready" },
2846
+ { from: "lead-z", to: "coordinator", type: "merge_ready" },
2847
+ ]);
2848
+ const r = await runGate({
2849
+ command: "sd close my-task",
2850
+ agentName: "lead-x",
2851
+ taskId: "my-task",
2852
+ });
2853
+ const parsed = JSON.parse(r.stdout.trim());
2854
+ expect(parsed.decision).toBe("block");
2855
+ expect(parsed.reason).toContain("not sent");
2856
+ });
2857
+ });
2858
+
2859
+ describe("lead close-gate merge-ancestor check (overstory-da9b)", () => {
2860
+ let repoDir: string;
2861
+ let defaultBranch: string;
2862
+ let ovBinDir: string;
2863
+
2864
+ beforeEach(async () => {
2865
+ repoDir = await createTempGitRepo();
2866
+ defaultBranch = await getDefaultBranch(repoDir);
2867
+ await mkdir(join(repoDir, ".overstory"), { recursive: true });
2868
+ ovBinDir = await createOvShim(repoDir);
2869
+ // Pre-seed merge_ready so the lead clears the count checks and the script
2870
+ // reaches the new merge-ancestor logic.
2871
+ const dbPath = join(repoDir, ".overstory", "mail.db");
2872
+ const store = createMailStore(dbPath);
2873
+ const client = createMailClient(store);
2874
+ try {
2875
+ client.send({
2876
+ from: "lead-x",
2877
+ to: "coordinator",
2878
+ subject: "merge_ready: my-task",
2879
+ body: "test",
2880
+ type: "merge_ready",
2881
+ });
2882
+ } finally {
2883
+ client.close();
2884
+ }
2885
+ });
2886
+
2887
+ afterEach(async () => {
2888
+ await cleanupTempDir(repoDir);
2889
+ });
2890
+
2891
+ async function runMergeGate(opts: {
2892
+ worktreePath?: string | null;
2893
+ projectRoot?: string | null;
2894
+ }): Promise<{ stdout: string; exitCode: number | null }> {
2895
+ const guards = getLeadCloseGateGuards();
2896
+ const script = guards[0]?.hooks[0]?.command ?? "";
2897
+ const input = JSON.stringify({ command: "sd close my-task" });
2898
+ const env = { ...process.env } as Record<string, string>;
2899
+ env.PATH = `${ovBinDir}:${env.PATH ?? ""}`;
2900
+ env.OVERSTORY_AGENT_NAME = "lead-x";
2901
+ env.OVERSTORY_TASK_ID = "my-task";
2902
+ if (opts.worktreePath === null) {
2903
+ delete env.OVERSTORY_WORKTREE_PATH;
2904
+ } else if (opts.worktreePath !== undefined) {
2905
+ env.OVERSTORY_WORKTREE_PATH = opts.worktreePath;
2906
+ }
2907
+ if (opts.projectRoot === null) {
2908
+ delete env.OVERSTORY_PROJECT_ROOT;
2909
+ } else if (opts.projectRoot !== undefined) {
2910
+ env.OVERSTORY_PROJECT_ROOT = opts.projectRoot;
2911
+ }
2912
+ const proc = Bun.spawn(["sh", "-c", script], {
2913
+ cwd: repoDir,
2914
+ stdin: new TextEncoder().encode(input),
2915
+ stdout: "pipe",
2916
+ stderr: "pipe",
2917
+ env,
2918
+ });
2919
+ const stdout = await new Response(proc.stdout).text();
2920
+ const exitCode = await proc.exited;
2921
+ return { stdout, exitCode };
2922
+ }
2923
+
2924
+ test("blocks sd close when lead's branch is not yet merged into default target", async () => {
2925
+ await runGitInDir(repoDir, ["checkout", "-b", "overstory/lead-x/my-task"]);
2926
+ await commitFile(repoDir, "lead-work.txt", "lead's work");
2927
+ // HEAD has a commit not present on `main` — lead is unmerged.
2928
+ const r = await runMergeGate({
2929
+ worktreePath: repoDir,
2930
+ projectRoot: repoDir,
2931
+ });
2932
+ const parsed = JSON.parse(r.stdout.trim());
2933
+ expect(parsed.decision).toBe("block");
2934
+ expect(parsed.reason).toContain("not yet merged");
2935
+ });
2936
+
2937
+ test("allows sd close once the lead's branch has been merged into the default target", async () => {
2938
+ await runGitInDir(repoDir, ["checkout", "-b", "overstory/lead-x/my-task"]);
2939
+ await commitFile(repoDir, "lead-work.txt", "lead's work");
2940
+ // Coordinator merges lead's branch into main.
2941
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
2942
+ await runGitInDir(repoDir, [
2943
+ "merge",
2944
+ "--no-ff",
2945
+ "overstory/lead-x/my-task",
2946
+ "-m",
2947
+ "merge lead-x",
2948
+ ]);
2949
+ // Lead's worktree HEAD remains on its branch tip; target now contains it.
2950
+ await runGitInDir(repoDir, ["checkout", "overstory/lead-x/my-task"]);
2951
+ const r = await runMergeGate({
2952
+ worktreePath: repoDir,
2953
+ projectRoot: repoDir,
2954
+ });
2955
+ expect(r.stdout.trim()).toBe("");
2956
+ });
2957
+
2958
+ test("uses session-branch.txt as the merge target when present", async () => {
2959
+ // Create a non-default branch as the merge target.
2960
+ await runGitInDir(repoDir, ["checkout", "-b", "release/v1"]);
2961
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
2962
+ await Bun.write(join(repoDir, ".overstory", "session-branch.txt"), "release/v1\n");
2963
+ // Lead works on its own branch with new commits; release/v1 has none of them.
2964
+ await runGitInDir(repoDir, ["checkout", "-b", "overstory/lead-x/my-task"]);
2965
+ await commitFile(repoDir, "lead-work.txt", "lead's work");
2966
+ const r = await runMergeGate({
2967
+ worktreePath: repoDir,
2968
+ projectRoot: repoDir,
2969
+ });
2970
+ const parsed = JSON.parse(r.stdout.trim());
2971
+ expect(parsed.decision).toBe("block");
2972
+ expect(parsed.reason).toContain("not yet merged");
2973
+
2974
+ // Now merge into release/v1; close should be allowed.
2975
+ await runGitInDir(repoDir, ["checkout", "release/v1"]);
2976
+ await runGitInDir(repoDir, [
2977
+ "merge",
2978
+ "--no-ff",
2979
+ "overstory/lead-x/my-task",
2980
+ "-m",
2981
+ "merge lead-x to v1",
2982
+ ]);
2983
+ await runGitInDir(repoDir, ["checkout", "overstory/lead-x/my-task"]);
2984
+ const r2 = await runMergeGate({
2985
+ worktreePath: repoDir,
2986
+ projectRoot: repoDir,
2987
+ });
2988
+ expect(r2.stdout.trim()).toBe("");
2989
+ });
2990
+
2991
+ test("fails open (allows close) when OVERSTORY_WORKTREE_PATH is unset", async () => {
2992
+ await runGitInDir(repoDir, ["checkout", "-b", "overstory/lead-x/my-task"]);
2993
+ await commitFile(repoDir, "lead-work.txt", "lead's work");
2994
+ // HEAD is unmerged, but no worktree path means we can't verify — fail open.
2995
+ const r = await runMergeGate({
2996
+ worktreePath: null,
2997
+ projectRoot: repoDir,
2998
+ });
2999
+ expect(r.stdout.trim()).toBe("");
3000
+ });
3001
+
3002
+ test("fails open when the resolved target ref does not exist locally", async () => {
3003
+ await Bun.write(
3004
+ join(repoDir, ".overstory", "session-branch.txt"),
3005
+ "branch-that-does-not-exist\n",
3006
+ );
3007
+ await runGitInDir(repoDir, ["checkout", "-b", "overstory/lead-x/my-task"]);
3008
+ await commitFile(repoDir, "lead-work.txt", "lead's work");
3009
+ const r = await runMergeGate({
3010
+ worktreePath: repoDir,
3011
+ projectRoot: repoDir,
3012
+ });
3013
+ // Target ref unresolvable → can't make a definitive claim → don't block.
3014
+ expect(r.stdout.trim()).toBe("");
3015
+ });
3016
+
3017
+ test("falls back to 'main' when session-branch.txt is missing", async () => {
3018
+ // No session-branch.txt — must fall back to "main".
3019
+ await runGitInDir(repoDir, ["checkout", "-b", "overstory/lead-x/my-task"]);
3020
+ await commitFile(repoDir, "lead-work.txt", "lead's work");
3021
+ // Merge into main.
3022
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
3023
+ await runGitInDir(repoDir, [
3024
+ "merge",
3025
+ "--no-ff",
3026
+ "overstory/lead-x/my-task",
3027
+ "-m",
3028
+ "merge lead-x",
3029
+ ]);
3030
+ await runGitInDir(repoDir, ["checkout", "overstory/lead-x/my-task"]);
3031
+ const r = await runMergeGate({
3032
+ worktreePath: repoDir,
3033
+ projectRoot: repoDir,
3034
+ });
3035
+ expect(r.stdout.trim()).toBe("");
3036
+ });
3037
+ });
3038
+
3039
+ describe("deployHooks lead close-gate integration", () => {
3040
+ let tempDir: string;
3041
+
3042
+ beforeEach(async () => {
3043
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-lead-gate-deploy-"));
3044
+ });
3045
+
3046
+ afterEach(async () => {
3047
+ await cleanupTempDir(tempDir);
3048
+ });
3049
+
3050
+ test("lead capability gets the close-gate guard in PreToolUse", async () => {
3051
+ const worktreePath = join(tempDir, "lead-wt");
3052
+ await deployHooks(worktreePath, "lead-agent", "lead");
3053
+
3054
+ const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
3055
+ const parsed = JSON.parse(content);
3056
+ const preToolUse = parsed.hooks.PreToolUse;
3057
+
3058
+ const closeGate = preToolUse.find(
3059
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
3060
+ h.matcher === "Bash" && h.hooks[0]?.command?.includes("merge_ready gate"),
3061
+ );
3062
+ expect(closeGate).toBeDefined();
3063
+ });
3064
+
3065
+ test("non-lead capabilities do NOT get the close-gate guard", async () => {
3066
+ const capabilities = ["builder", "scout", "reviewer", "merger", "coordinator", "orchestrator"];
3067
+
3068
+ for (const cap of capabilities) {
3069
+ const wt = join(tempDir, `${cap}-wt`);
3070
+ await deployHooks(wt, `${cap}-agent`, cap);
3071
+
3072
+ const content = await Bun.file(join(wt, ".claude", "settings.local.json")).text();
3073
+ const parsed = JSON.parse(content);
3074
+ const preToolUse = parsed.hooks.PreToolUse;
3075
+
3076
+ const closeGate = preToolUse.find(
3077
+ (h: { matcher: string; hooks: Array<{ command: string }> }) =>
3078
+ h.matcher === "Bash" && h.hooks[0]?.command?.includes("merge_ready gate"),
3079
+ );
3080
+ expect(closeGate).toBeUndefined();
3081
+ }
3082
+ });
3083
+ });
3084
+
2585
3085
  describe("escapeForSingleQuotedShell", () => {
2586
3086
  test("no single quotes: string passes through unchanged", () => {
2587
3087
  expect(escapeForSingleQuotedShell("hello world")).toBe("hello world");