@os-eco/overstory-cli 0.9.4 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +50 -19
  2. package/agents/builder.md +19 -9
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +204 -87
  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 +219 -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/mail-poll-detect.test.ts +153 -0
  18. package/src/agents/mail-poll-detect.ts +73 -0
  19. package/src/agents/overlay.test.ts +60 -4
  20. package/src/agents/overlay.ts +63 -8
  21. package/src/agents/scope-detect.test.ts +190 -0
  22. package/src/agents/scope-detect.ts +146 -0
  23. package/src/agents/turn-lock.test.ts +181 -0
  24. package/src/agents/turn-lock.ts +235 -0
  25. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  26. package/src/agents/turn-runner-dispatch.ts +105 -0
  27. package/src/agents/turn-runner.test.ts +2312 -0
  28. package/src/agents/turn-runner.ts +1383 -0
  29. package/src/commands/agents.ts +9 -0
  30. package/src/commands/clean.ts +54 -0
  31. package/src/commands/coordinator.test.ts +254 -0
  32. package/src/commands/coordinator.ts +273 -8
  33. package/src/commands/dashboard.test.ts +188 -0
  34. package/src/commands/dashboard.ts +14 -4
  35. package/src/commands/doctor.ts +3 -1
  36. package/src/commands/group.test.ts +94 -0
  37. package/src/commands/group.ts +49 -20
  38. package/src/commands/init.test.ts +8 -0
  39. package/src/commands/init.ts +8 -1
  40. package/src/commands/log.test.ts +187 -11
  41. package/src/commands/log.ts +171 -71
  42. package/src/commands/mail.test.ts +162 -0
  43. package/src/commands/mail.ts +64 -9
  44. package/src/commands/merge.test.ts +230 -1
  45. package/src/commands/merge.ts +68 -12
  46. package/src/commands/nudge.test.ts +351 -4
  47. package/src/commands/nudge.ts +356 -34
  48. package/src/commands/run.test.ts +43 -7
  49. package/src/commands/serve/build.test.ts +202 -0
  50. package/src/commands/serve/build.ts +206 -0
  51. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  52. package/src/commands/serve/coordinator-actions.ts +408 -0
  53. package/src/commands/serve/dev.test.ts +168 -0
  54. package/src/commands/serve/dev.ts +117 -0
  55. package/src/commands/serve/mail-actions.test.ts +312 -0
  56. package/src/commands/serve/mail-actions.ts +167 -0
  57. package/src/commands/serve/rest.test.ts +1323 -0
  58. package/src/commands/serve/rest.ts +708 -0
  59. package/src/commands/serve/static.ts +51 -0
  60. package/src/commands/serve/ws.test.ts +361 -0
  61. package/src/commands/serve/ws.ts +332 -0
  62. package/src/commands/serve.test.ts +459 -0
  63. package/src/commands/serve.ts +565 -0
  64. package/src/commands/sling.test.ts +177 -1
  65. package/src/commands/sling.ts +243 -71
  66. package/src/commands/status.test.ts +9 -0
  67. package/src/commands/status.ts +12 -4
  68. package/src/commands/stop.test.ts +255 -1
  69. package/src/commands/stop.ts +107 -8
  70. package/src/commands/watch.test.ts +43 -0
  71. package/src/commands/watch.ts +153 -28
  72. package/src/config.ts +23 -0
  73. package/src/doctor/consistency.test.ts +106 -0
  74. package/src/doctor/consistency.ts +48 -1
  75. package/src/doctor/serve.test.ts +95 -0
  76. package/src/doctor/serve.ts +86 -0
  77. package/src/doctor/types.ts +2 -1
  78. package/src/doctor/watchdog.ts +57 -1
  79. package/src/events/tailer.test.ts +234 -1
  80. package/src/events/tailer.ts +90 -0
  81. package/src/index.ts +57 -6
  82. package/src/insights/quality-gates.test.ts +141 -0
  83. package/src/insights/quality-gates.ts +156 -0
  84. package/src/json.ts +29 -0
  85. package/src/logging/theme.ts +4 -0
  86. package/src/mail/client.ts +15 -2
  87. package/src/mail/store.test.ts +82 -0
  88. package/src/mail/store.ts +41 -4
  89. package/src/merge/lock.test.ts +149 -0
  90. package/src/merge/lock.ts +140 -0
  91. package/src/merge/predict.test.ts +387 -0
  92. package/src/merge/predict.ts +249 -0
  93. package/src/merge/resolver.ts +1 -1
  94. package/src/mulch/client.ts +3 -3
  95. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  96. package/src/runtimes/claude.test.ts +791 -1
  97. package/src/runtimes/claude.ts +323 -1
  98. package/src/runtimes/connections.test.ts +141 -1
  99. package/src/runtimes/connections.ts +73 -4
  100. package/src/runtimes/headless-connection.test.ts +264 -0
  101. package/src/runtimes/headless-connection.ts +158 -0
  102. package/src/runtimes/types.ts +10 -0
  103. package/src/schema-consistency.test.ts +1 -0
  104. package/src/sessions/store.test.ts +657 -29
  105. package/src/sessions/store.ts +286 -23
  106. package/src/test-setup.test.ts +31 -0
  107. package/src/test-setup.ts +28 -0
  108. package/src/types.ts +107 -2
  109. package/src/utils/pid.test.ts +85 -1
  110. package/src/utils/pid.ts +86 -1
  111. package/src/utils/process-scan.test.ts +53 -0
  112. package/src/utils/process-scan.ts +76 -0
  113. package/src/watchdog/daemon.test.ts +1607 -376
  114. package/src/watchdog/daemon.ts +462 -88
  115. package/src/watchdog/health.test.ts +282 -0
  116. package/src/watchdog/health.ts +126 -27
  117. package/src/worktree/manager.test.ts +218 -1
  118. package/src/worktree/manager.ts +55 -0
  119. package/src/worktree/process.test.ts +71 -0
  120. package/src/worktree/process.ts +25 -5
  121. package/src/worktree/tmux.test.ts +28 -0
  122. package/src/worktree/tmux.ts +27 -3
  123. package/templates/CLAUDE.md.tmpl +19 -8
  124. package/templates/overlay.md.tmpl +5 -2
@@ -1,7 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { writeFileSync } from "node:fs";
2
3
  import { mkdir } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
- import { ValidationError } from "../errors.ts";
5
+ import { MergeError, ValidationError } from "../errors.ts";
6
+ import { mergeLockPath } from "../merge/lock.ts";
5
7
  import { createMergeQueue } from "../merge/queue.ts";
6
8
  import {
7
9
  cleanupTempDir,
@@ -597,6 +599,233 @@ merge:
597
599
  });
598
600
  });
599
601
 
602
+ describe("concurrent-merge lock", () => {
603
+ test("refuses to start when another live ov merge holds the lock for the same target", async () => {
604
+ await setupProject(repoDir, defaultBranch);
605
+ const branchName = "overstory/builder/bead-lock-1";
606
+ await createCleanFeatureBranch(repoDir, branchName);
607
+
608
+ // Simulate another live ov merge by writing a lock file with this
609
+ // process's own PID — it is guaranteed alive, so the lock-holder check
610
+ // must treat it as an in-flight merge.
611
+ const lockPath = mergeLockPath(join(repoDir, ".overstory"), defaultBranch);
612
+ writeFileSync(
613
+ lockPath,
614
+ JSON.stringify({
615
+ pid: process.pid,
616
+ acquiredAt: new Date().toISOString(),
617
+ targetBranch: defaultBranch,
618
+ }),
619
+ );
620
+
621
+ const originalWrite = process.stdout.write.bind(process.stdout);
622
+ process.stdout.write = (): boolean => true;
623
+
624
+ try {
625
+ await mergeCommand({ branch: branchName });
626
+ expect(true).toBe(false); // should not reach here
627
+ } catch (err: unknown) {
628
+ expect(err).toBeInstanceOf(MergeError);
629
+ const msg = (err as MergeError).message;
630
+ expect(msg).toContain("Another ov merge is already running");
631
+ expect(msg).toContain(defaultBranch);
632
+ } finally {
633
+ process.stdout.write = originalWrite;
634
+ }
635
+ });
636
+
637
+ test("releases the lock after a successful merge so a second run can proceed", async () => {
638
+ await setupProject(repoDir, defaultBranch);
639
+ const branchName = "overstory/builder/bead-lock-2";
640
+ await createCleanFeatureBranch(repoDir, branchName);
641
+
642
+ const originalWrite = process.stdout.write.bind(process.stdout);
643
+ process.stdout.write = (): boolean => true;
644
+
645
+ try {
646
+ await mergeCommand({ branch: branchName, json: true });
647
+ } finally {
648
+ process.stdout.write = originalWrite;
649
+ }
650
+
651
+ // After release, a fresh `ov merge --dry-run` for an unrelated branch
652
+ // (which doesn't take a lock) should still see no leftover lock file.
653
+ const lockPath = mergeLockPath(join(repoDir, ".overstory"), defaultBranch);
654
+ expect(await Bun.file(lockPath).exists()).toBe(false);
655
+ });
656
+
657
+ test("--dry-run does not acquire the lock", async () => {
658
+ await setupProject(repoDir, defaultBranch);
659
+ const branchName = "overstory/builder/bead-lock-dry";
660
+ await createCleanFeatureBranch(repoDir, branchName);
661
+
662
+ const originalWrite = process.stdout.write.bind(process.stdout);
663
+ process.stdout.write = (): boolean => true;
664
+
665
+ try {
666
+ await mergeCommand({ branch: branchName, dryRun: true, json: true });
667
+ } finally {
668
+ process.stdout.write = originalWrite;
669
+ }
670
+
671
+ const lockPath = mergeLockPath(join(repoDir, ".overstory"), defaultBranch);
672
+ expect(await Bun.file(lockPath).exists()).toBe(false);
673
+ });
674
+
675
+ test("steals a stale lock (dead PID) and proceeds", async () => {
676
+ await setupProject(repoDir, defaultBranch);
677
+ const branchName = "overstory/builder/bead-lock-stale";
678
+ await createCleanFeatureBranch(repoDir, branchName);
679
+
680
+ // PID 2147483647 (INT_MAX) is virtually never assigned — treat as dead.
681
+ const lockPath = mergeLockPath(join(repoDir, ".overstory"), defaultBranch);
682
+ writeFileSync(
683
+ lockPath,
684
+ JSON.stringify({
685
+ pid: 2147483647,
686
+ acquiredAt: new Date(Date.now() - 10 * 60_000).toISOString(),
687
+ targetBranch: defaultBranch,
688
+ }),
689
+ );
690
+
691
+ let output = "";
692
+ const originalWrite = process.stdout.write.bind(process.stdout);
693
+ process.stdout.write = (chunk: unknown): boolean => {
694
+ output += String(chunk);
695
+ return true;
696
+ };
697
+
698
+ try {
699
+ await mergeCommand({ branch: branchName, json: true });
700
+ } finally {
701
+ process.stdout.write = originalWrite;
702
+ }
703
+
704
+ const parsed = JSON.parse(output);
705
+ expect(parsed.success).toBe(true);
706
+ // Lock should be released after success.
707
+ expect(await Bun.file(lockPath).exists()).toBe(false);
708
+ });
709
+ });
710
+
711
+ describe("--dry-run conflict prediction", () => {
712
+ test("--dry-run --json includes prediction.wouldRequireAgent for contentful-canonical conflict", async () => {
713
+ await setupProject(repoDir, defaultBranch);
714
+
715
+ // Build a real contentful-canonical conflict on src/shared.ts.
716
+ await commitFile(repoDir, "src/shared.ts", "shared base\n");
717
+ const branchName = "overstory/builder/bead-predict-1";
718
+ await runGitInDir(repoDir, ["checkout", "-b", branchName]);
719
+ await commitFile(repoDir, "src/shared.ts", "feature side\n");
720
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
721
+ await commitFile(repoDir, "src/shared.ts", "main side\n");
722
+
723
+ let output = "";
724
+ const originalWrite = process.stdout.write.bind(process.stdout);
725
+ process.stdout.write = (chunk: unknown): boolean => {
726
+ output += String(chunk);
727
+ return true;
728
+ };
729
+
730
+ try {
731
+ await mergeCommand({ branch: branchName, dryRun: true, json: true });
732
+ } finally {
733
+ process.stdout.write = originalWrite;
734
+ }
735
+
736
+ const parsed = JSON.parse(output);
737
+ expect(parsed.prediction).toBeDefined();
738
+ expect(parsed.prediction.wouldRequireAgent).toBe(true);
739
+ expect(parsed.prediction.predictedTier).toBe("ai-resolve");
740
+ expect(parsed.prediction.conflictFiles).toContain("src/shared.ts");
741
+ expect(typeof parsed.prediction.reason).toBe("string");
742
+
743
+ // Existing dry-run fields must still be present.
744
+ expect(parsed.branchName).toBe(branchName);
745
+ expect(parsed.status).toBe("pending");
746
+ });
747
+
748
+ test("--dry-run --json reports clean-merge prediction for non-conflicting branch", async () => {
749
+ await setupProject(repoDir, defaultBranch);
750
+ const branchName = "overstory/builder/bead-predict-2";
751
+ await createCleanFeatureBranch(repoDir, branchName);
752
+
753
+ let output = "";
754
+ const originalWrite = process.stdout.write.bind(process.stdout);
755
+ process.stdout.write = (chunk: unknown): boolean => {
756
+ output += String(chunk);
757
+ return true;
758
+ };
759
+
760
+ try {
761
+ await mergeCommand({ branch: branchName, dryRun: true, json: true });
762
+ } finally {
763
+ process.stdout.write = originalWrite;
764
+ }
765
+
766
+ const parsed = JSON.parse(output);
767
+ expect(parsed.prediction.predictedTier).toBe("clean-merge");
768
+ expect(parsed.prediction.wouldRequireAgent).toBe(false);
769
+ expect(parsed.prediction.conflictFiles).toEqual([]);
770
+ });
771
+
772
+ test("--all --dry-run --json attaches prediction to each entry", async () => {
773
+ await setupProject(repoDir, defaultBranch);
774
+ const cleanBranch = "overstory/builder/bead-predict-all-clean";
775
+ await createCleanFeatureBranch(repoDir, cleanBranch);
776
+
777
+ // Add a contentful-canonical conflict branch.
778
+ await commitFile(repoDir, "src/shared.ts", "shared base\n");
779
+ const conflictBranch = "overstory/builder/bead-predict-all-conflict";
780
+ await runGitInDir(repoDir, ["checkout", "-b", conflictBranch]);
781
+ await commitFile(repoDir, "src/shared.ts", "feature side\n");
782
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
783
+ await commitFile(repoDir, "src/shared.ts", "main side\n");
784
+
785
+ const queuePath = join(repoDir, ".overstory", "merge-queue.db");
786
+ const queue = createMergeQueue(queuePath);
787
+ queue.enqueue({
788
+ branchName: cleanBranch,
789
+ taskId: "bead-predict-all-clean",
790
+ agentName: "builder",
791
+ filesModified: [`src/${cleanBranch}.ts`],
792
+ });
793
+ queue.enqueue({
794
+ branchName: conflictBranch,
795
+ taskId: "bead-predict-all-conflict",
796
+ agentName: "builder",
797
+ filesModified: ["src/shared.ts"],
798
+ });
799
+ queue.close();
800
+
801
+ let output = "";
802
+ const originalWrite = process.stdout.write.bind(process.stdout);
803
+ process.stdout.write = (chunk: unknown): boolean => {
804
+ output += String(chunk);
805
+ return true;
806
+ };
807
+
808
+ try {
809
+ await mergeCommand({ all: true, dryRun: true, json: true });
810
+ } finally {
811
+ process.stdout.write = originalWrite;
812
+ }
813
+
814
+ const parsed = JSON.parse(output);
815
+ expect(parsed.entries).toHaveLength(2);
816
+ const cleanEntry = parsed.entries.find(
817
+ (e: { branchName: string }) => e.branchName === cleanBranch,
818
+ );
819
+ const conflictEntry = parsed.entries.find(
820
+ (e: { branchName: string }) => e.branchName === conflictBranch,
821
+ );
822
+ expect(cleanEntry.prediction.predictedTier).toBe("clean-merge");
823
+ expect(cleanEntry.prediction.wouldRequireAgent).toBe(false);
824
+ expect(conflictEntry.prediction.predictedTier).toBe("ai-resolve");
825
+ expect(conflictEntry.prediction.wouldRequireAgent).toBe(true);
826
+ });
827
+ });
828
+
600
829
  describe("conflict handling", () => {
601
830
  test("content conflict auto-resolves: same file modified on both branches, verify incoming content wins", async () => {
602
831
  await setupProject(repoDir, defaultBranch);
@@ -16,10 +16,12 @@ import { loadConfig } from "../config.ts";
16
16
  import { MergeError, ValidationError } from "../errors.ts";
17
17
  import { jsonOutput } from "../json.ts";
18
18
  import { accent, printHint } from "../logging/color.ts";
19
+ import { acquireMergeLock } from "../merge/lock.ts";
20
+ import { predictConflicts } from "../merge/predict.ts";
19
21
  import { createMergeQueue } from "../merge/queue.ts";
20
22
  import { createMergeResolver } from "../merge/resolver.ts";
21
23
  import { createMulchClient } from "../mulch/client.ts";
22
- import type { MergeEntry, MergeResult } from "../types.ts";
24
+ import type { ConflictPrediction, MergeEntry, MergeResult } from "../types.ts";
23
25
 
24
26
  export interface MergeOptions {
25
27
  branch?: string;
@@ -108,7 +110,7 @@ function formatResult(result: MergeResult): string {
108
110
  }
109
111
 
110
112
  /** Format a dry-run report for a merge entry. */
111
- function formatDryRun(entry: MergeEntry): string {
113
+ function formatDryRun(entry: MergeEntry, prediction?: ConflictPrediction): string {
112
114
  const lines: string[] = [
113
115
  `[dry-run] Branch: ${accent(entry.branchName)}`,
114
116
  ` Agent: ${accent(entry.agentName)} | Task: ${accent(entry.taskId)}`,
@@ -122,9 +124,41 @@ function formatDryRun(entry: MergeEntry): string {
122
124
  }
123
125
  }
124
126
 
127
+ if (prediction) {
128
+ const agentSuffix = prediction.wouldRequireAgent ? " (would require merger agent)" : "";
129
+ lines.push(` Prediction: ${prediction.predictedTier}${agentSuffix} — ${prediction.reason}`);
130
+ if (prediction.conflictFiles.length > 0) {
131
+ lines.push(` Conflict files: ${prediction.conflictFiles.join(", ")}`);
132
+ }
133
+ }
134
+
125
135
  return lines.join("\n");
126
136
  }
127
137
 
138
+ /**
139
+ * Predict the merge tier for a single entry, swallowing errors into a
140
+ * deterministic `ai-resolve` envelope so that `--all --dry-run` can keep
141
+ * going if one branch's prediction blows up.
142
+ */
143
+ async function safePredictForEntry(
144
+ entry: MergeEntry,
145
+ canonicalBranch: string,
146
+ repoRoot: string,
147
+ mulchClient: ReturnType<typeof createMulchClient>,
148
+ ): Promise<ConflictPrediction> {
149
+ try {
150
+ return await predictConflicts(entry, canonicalBranch, repoRoot, mulchClient);
151
+ } catch (err) {
152
+ const msg = err instanceof Error ? err.message : String(err);
153
+ return {
154
+ predictedTier: "ai-resolve",
155
+ conflictFiles: [],
156
+ wouldRequireAgent: true,
157
+ reason: `prediction-failed: ${msg}`,
158
+ };
159
+ }
160
+ }
161
+
128
162
  /**
129
163
  * Entry point for `ov merge [flags]`.
130
164
  *
@@ -168,10 +202,22 @@ export async function mergeCommand(opts: MergeOptions): Promise<void> {
168
202
  mulchClient,
169
203
  });
170
204
 
171
- if (branchName) {
172
- await handleBranch(branchName, queue, resolver, config, targetBranch, dryRun, json);
173
- } else {
174
- await handleAll(queue, resolver, config, targetBranch, dryRun, json);
205
+ // Dry-run is read-only with respect to git state — no lock needed. The
206
+ // real merge path acquires a lock on the target branch so a parallel
207
+ // `ov merge` can't observe in-progress conflict markers and report a
208
+ // false failure (seeds: overstory-9610).
209
+ const lock = dryRun
210
+ ? null
211
+ : acquireMergeLock(join(config.project.root, ".overstory"), targetBranch);
212
+
213
+ try {
214
+ if (branchName) {
215
+ await handleBranch(branchName, queue, resolver, config, targetBranch, dryRun, json);
216
+ } else {
217
+ await handleAll(queue, resolver, config, targetBranch, dryRun, json);
218
+ }
219
+ } finally {
220
+ lock?.release();
175
221
  }
176
222
  }
177
223
 
@@ -225,10 +271,13 @@ async function handleBranch(
225
271
  }
226
272
 
227
273
  if (dryRun) {
274
+ const mulchClient = createMulchClient(config.project.root);
275
+ const prediction = await safePredictForEntry(entry, canonicalBranch, repoRoot, mulchClient);
276
+
228
277
  if (json) {
229
- jsonOutput("merge", { ...entry });
278
+ jsonOutput("merge", { ...entry, prediction });
230
279
  } else {
231
- process.stdout.write(`${formatDryRun(entry)}\n`);
280
+ process.stdout.write(`${formatDryRun(entry, prediction)}\n`);
232
281
  }
233
282
  return;
234
283
  }
@@ -280,14 +329,21 @@ async function handleAll(
280
329
  }
281
330
 
282
331
  if (dryRun) {
332
+ const mulchClient = createMulchClient(config.project.root);
333
+ const enrichedEntries: Array<MergeEntry & { prediction: ConflictPrediction }> = [];
334
+ for (const entry of pendingEntries) {
335
+ const prediction = await safePredictForEntry(entry, canonicalBranch, repoRoot, mulchClient);
336
+ enrichedEntries.push({ ...entry, prediction });
337
+ }
338
+
283
339
  if (json) {
284
- jsonOutput("merge", { entries: pendingEntries });
340
+ jsonOutput("merge", { entries: enrichedEntries });
285
341
  } else {
286
342
  process.stdout.write(
287
- `${pendingEntries.length} pending branch${pendingEntries.length === 1 ? "" : "es"}:\n\n`,
343
+ `${enrichedEntries.length} pending branch${enrichedEntries.length === 1 ? "" : "es"}:\n\n`,
288
344
  );
289
- for (const entry of pendingEntries) {
290
- process.stdout.write(`${formatDryRun(entry)}\n\n`);
345
+ for (const entry of enrichedEntries) {
346
+ process.stdout.write(`${formatDryRun(entry, entry.prediction)}\n\n`);
291
347
  }
292
348
  }
293
349
  return;