@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
@@ -15,9 +15,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
15
15
  import { mkdir, realpath } from "node:fs/promises";
16
16
  import { join } from "node:path";
17
17
  import { AgentError, ValidationError } from "../errors.ts";
18
+ import { createMailClient } from "../mail/client.ts";
19
+ import { createMailStore } from "../mail/store.ts";
18
20
  import { openSessionStore } from "../sessions/compat.ts";
19
21
  import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
20
- import type { AgentSession } from "../types.ts";
22
+ import type { AgentSession, MergeReadyPayload } from "../types.ts";
21
23
  import { type StopDeps, stopCommand } from "./stop.ts";
22
24
 
23
25
  // --- Fake Git (for branch deletion) ---
@@ -472,6 +474,258 @@ describe("stopCommand stop behavior", () => {
472
474
  store.close();
473
475
  expect(updated?.state).toBe("completed");
474
476
  });
477
+
478
+ test("stopping a lead writes lead_completed pending-nudge for coordinator", async () => {
479
+ // Regression test for overstory-49a7:
480
+ // The lead_completed nudge now fires from `ov stop` (real completion signal),
481
+ // not from the per-turn Stop hook, which was spamming the coordinator.
482
+ const session = makeAgentSession({
483
+ agentName: "lead-alpha",
484
+ capability: "lead",
485
+ state: "working",
486
+ tmuxSession: "overstory-lead-alpha",
487
+ });
488
+ saveSessionsToDb([session]);
489
+
490
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
491
+ await stopCommand("lead-alpha", {}, deps);
492
+
493
+ const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
494
+ const markerFile = Bun.file(markerPath);
495
+ expect(await markerFile.exists()).toBe(true);
496
+
497
+ const marker = JSON.parse(await markerFile.text());
498
+ expect(marker.from).toBe("lead-alpha");
499
+ expect(marker.reason).toBe("lead_completed");
500
+ expect(marker.subject).toContain("lead-alpha");
501
+ expect(marker.messageId).toContain("auto-nudge-lead-alpha-");
502
+ expect(marker.createdAt).toBeDefined();
503
+ });
504
+
505
+ test("lead exiting without merge_ready gets 'no merge_ready sent' subject (overstory-41fe)", async () => {
506
+ const session = makeAgentSession({
507
+ agentName: "lead-beta",
508
+ capability: "lead",
509
+ state: "working",
510
+ tmuxSession: "overstory-lead-beta",
511
+ });
512
+ saveSessionsToDb([session]);
513
+
514
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
515
+ await stopCommand("lead-beta", {}, deps);
516
+
517
+ const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
518
+ const marker = JSON.parse(await Bun.file(markerPath).text());
519
+ expect(marker.subject).toBe(
520
+ "Lead lead-beta exited — no merge_ready sent, needs coordinator follow-up",
521
+ );
522
+ });
523
+
524
+ test("lead with one merge_ready gets branch-specific subject (overstory-41fe)", async () => {
525
+ const session = makeAgentSession({
526
+ agentName: "lead-gamma",
527
+ capability: "lead",
528
+ state: "working",
529
+ tmuxSession: "overstory-lead-gamma",
530
+ });
531
+ saveSessionsToDb([session]);
532
+
533
+ // Seed mail.db with a merge_ready message from this lead
534
+ const mailStore = createMailStore(join(overstoryDir, "mail.db"));
535
+ const mailClient = createMailClient(mailStore);
536
+ const payload: MergeReadyPayload = {
537
+ branch: "overstory/lead-gamma/bead-42",
538
+ taskId: "bead-42",
539
+ agentName: "lead-gamma",
540
+ filesModified: ["src/foo.ts"],
541
+ };
542
+ mailClient.sendProtocol({
543
+ from: "lead-gamma",
544
+ to: "coordinator",
545
+ subject: "merge_ready: bead-42",
546
+ body: "ready",
547
+ type: "merge_ready",
548
+ payload,
549
+ });
550
+ mailClient.close();
551
+
552
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
553
+ await stopCommand("lead-gamma", {}, deps);
554
+
555
+ const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
556
+ const marker = JSON.parse(await Bun.file(markerPath).text());
557
+ expect(marker.subject).toBe(
558
+ "Lead lead-gamma sent merge_ready for branch overstory/lead-gamma/bead-42",
559
+ );
560
+ });
561
+
562
+ test("lead with multiple merge_ready messages lists all unique branches (overstory-41fe)", async () => {
563
+ const session = makeAgentSession({
564
+ agentName: "lead-delta",
565
+ capability: "lead",
566
+ state: "working",
567
+ tmuxSession: "overstory-lead-delta",
568
+ });
569
+ saveSessionsToDb([session]);
570
+
571
+ const mailStore = createMailStore(join(overstoryDir, "mail.db"));
572
+ const mailClient = createMailClient(mailStore);
573
+ for (const branch of ["overstory/worker-a/t1", "overstory/worker-b/t2"]) {
574
+ mailClient.sendProtocol({
575
+ from: "lead-delta",
576
+ to: "coordinator",
577
+ subject: `merge_ready: ${branch}`,
578
+ body: "ready",
579
+ type: "merge_ready",
580
+ payload: {
581
+ branch,
582
+ taskId: branch.split("/")[2] ?? "unknown",
583
+ agentName: "lead-delta",
584
+ filesModified: [],
585
+ },
586
+ });
587
+ }
588
+ mailClient.close();
589
+
590
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
591
+ await stopCommand("lead-delta", {}, deps);
592
+
593
+ const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
594
+ const marker = JSON.parse(await Bun.file(markerPath).text());
595
+ expect(marker.subject).toContain("Lead lead-delta sent 2 merge_ready");
596
+ expect(marker.subject).toContain("overstory/worker-a/t1");
597
+ expect(marker.subject).toContain("overstory/worker-b/t2");
598
+ });
599
+
600
+ test("merge_ready messages from other agents do not influence the subject (overstory-41fe)", async () => {
601
+ const session = makeAgentSession({
602
+ agentName: "lead-eps",
603
+ capability: "lead",
604
+ state: "working",
605
+ tmuxSession: "overstory-lead-eps",
606
+ });
607
+ saveSessionsToDb([session]);
608
+
609
+ // A *different* lead has merge_ready in the same mail.db — should be ignored
610
+ const mailStore = createMailStore(join(overstoryDir, "mail.db"));
611
+ const mailClient = createMailClient(mailStore);
612
+ mailClient.sendProtocol({
613
+ from: "some-other-lead",
614
+ to: "coordinator",
615
+ subject: "merge_ready: x",
616
+ body: "ready",
617
+ type: "merge_ready",
618
+ payload: {
619
+ branch: "overstory/other/x",
620
+ taskId: "x",
621
+ agentName: "some-other-lead",
622
+ filesModified: [],
623
+ },
624
+ });
625
+ mailClient.close();
626
+
627
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
628
+ await stopCommand("lead-eps", {}, deps);
629
+
630
+ const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
631
+ const marker = JSON.parse(await Bun.file(markerPath).text());
632
+ expect(marker.subject).toBe(
633
+ "Lead lead-eps exited — no merge_ready sent, needs coordinator follow-up",
634
+ );
635
+ });
636
+
637
+ test("lead falls back to historical subject when mail store cannot be opened (overstory-7291)", async () => {
638
+ const session = makeAgentSession({
639
+ agentName: "lead-zeta",
640
+ capability: "lead",
641
+ state: "working",
642
+ tmuxSession: "overstory-lead-zeta",
643
+ });
644
+ saveSessionsToDb([session]);
645
+
646
+ // Make mail.db un-openable by creating a directory at that path. SQLite
647
+ // cannot open a directory as a database, so createMailStore() throws and
648
+ // buildLeadCompletedSubject hits its outer-catch fallback.
649
+ const mailDbPath = join(overstoryDir, "mail.db");
650
+ await mkdir(mailDbPath, { recursive: true });
651
+
652
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
653
+ await stopCommand("lead-zeta", {}, deps);
654
+
655
+ const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
656
+ const marker = JSON.parse(await Bun.file(markerPath).text());
657
+ expect(marker.subject).toBe(
658
+ "Lead lead-zeta completed — check mail for merge_ready/worker_done",
659
+ );
660
+ });
661
+
662
+ test("lead with malformed merge_ready payload skips that message (overstory-7291)", async () => {
663
+ const session = makeAgentSession({
664
+ agentName: "lead-eta",
665
+ capability: "lead",
666
+ state: "working",
667
+ tmuxSession: "overstory-lead-eta",
668
+ });
669
+ saveSessionsToDb([session]);
670
+
671
+ // Insert two merge_ready rows directly via the store: one with a valid
672
+ // MergeReadyPayload, one with a non-JSON payload string. sendProtocol
673
+ // would JSON.stringify any payload, so it cannot produce a malformed
674
+ // row — the low-level store accepts the payload column verbatim. The
675
+ // loop must skip the malformed one (inner catch + continue) and use
676
+ // the valid one, yielding the single-branch subject variant.
677
+ const mailStore = createMailStore(join(overstoryDir, "mail.db"));
678
+ const validPayload: MergeReadyPayload = {
679
+ branch: "overstory/lead-eta/bead-99",
680
+ taskId: "bead-99",
681
+ agentName: "lead-eta",
682
+ filesModified: ["src/x.ts"],
683
+ };
684
+ mailStore.insert({
685
+ id: "msg-valid",
686
+ from: "lead-eta",
687
+ to: "coordinator",
688
+ subject: "merge_ready: bead-99",
689
+ body: "ready",
690
+ type: "merge_ready",
691
+ priority: "normal",
692
+ threadId: null,
693
+ payload: JSON.stringify(validPayload),
694
+ });
695
+ mailStore.insert({
696
+ id: "msg-malformed",
697
+ from: "lead-eta",
698
+ to: "coordinator",
699
+ subject: "merge_ready: broken",
700
+ body: "ready",
701
+ type: "merge_ready",
702
+ priority: "normal",
703
+ threadId: null,
704
+ payload: "not-json{",
705
+ });
706
+ mailStore.close();
707
+
708
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
709
+ await stopCommand("lead-eta", {}, deps);
710
+
711
+ const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
712
+ const marker = JSON.parse(await Bun.file(markerPath).text());
713
+ expect(marker.subject).toBe(
714
+ "Lead lead-eta sent merge_ready for branch overstory/lead-eta/bead-99",
715
+ );
716
+ });
717
+
718
+ test("stopping a non-lead agent does NOT write lead_completed pending-nudge", async () => {
719
+ const session = makeAgentSession({ state: "working", capability: "builder" });
720
+ saveSessionsToDb([session]);
721
+
722
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
723
+ await stopCommand("my-builder", {}, deps);
724
+
725
+ const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
726
+ const markerFile = Bun.file(markerPath);
727
+ expect(await markerFile.exists()).toBe(false);
728
+ });
475
729
  });
476
730
 
477
731
  describe("stopCommand --json output", () => {
@@ -12,12 +12,16 @@
12
12
  * With --clean-worktree, completed agents skip the kill step and proceed to cleanup.
13
13
  */
14
14
 
15
+ import { unlink } from "node:fs/promises";
15
16
  import { join } from "node:path";
16
17
  import { loadConfig } from "../config.ts";
17
18
  import { AgentError, ValidationError } from "../errors.ts";
18
19
  import { jsonOutput } from "../json.ts";
19
20
  import { printSuccess, printWarning } from "../logging/color.ts";
21
+ import { createMailStore } from "../mail/store.ts";
20
22
  import { openSessionStore } from "../sessions/compat.ts";
23
+ import type { MergeReadyPayload } from "../types.ts";
24
+ import { readPidFile } from "../utils/pid.ts";
21
25
  import { removeWorktree } from "../worktree/manager.ts";
22
26
  import { isProcessAlive, isSessionAlive, killProcessTree, killSession } from "../worktree/tmux.ts";
23
27
 
@@ -49,6 +53,56 @@ export interface StopDeps {
49
53
  };
50
54
  }
51
55
 
56
+ /**
57
+ * Build the lead_completed nudge subject based on whether the lead actually sent
58
+ * merge_ready before exiting (overstory-41fe). The merge_ready close-gate
59
+ * (commit 3e21338) prevents leads from running `sd close` without it, but a
60
+ * lead can still exit (process termination, watchdog kill, manual `ov stop`)
61
+ * without ever having sent one. The coordinator's surfacing of this nudge
62
+ * needs to distinguish those two cases.
63
+ */
64
+ function buildLeadCompletedSubject(agentName: string, mailDbPath: string): string {
65
+ let mergeReadyBranches: string[] = [];
66
+ let mergeReadyCount = 0;
67
+ try {
68
+ const store = createMailStore(mailDbPath);
69
+ try {
70
+ const messages = store.getAll({ from: agentName, type: "merge_ready" });
71
+ mergeReadyCount = messages.length;
72
+ for (const msg of messages) {
73
+ if (msg.payload === null) continue;
74
+ try {
75
+ const parsed = JSON.parse(msg.payload) as Partial<MergeReadyPayload>;
76
+ if (typeof parsed.branch === "string" && parsed.branch.length > 0) {
77
+ mergeReadyBranches.push(parsed.branch);
78
+ }
79
+ } catch {
80
+ // Skip messages with unparseable payloads
81
+ }
82
+ }
83
+ } finally {
84
+ store.close();
85
+ }
86
+ } catch {
87
+ // If the mail store can't be opened (corrupt db, permissions), fall back
88
+ // to the historical ambiguous phrasing rather than blocking the stop.
89
+ return `Lead ${agentName} completed — check mail for merge_ready/worker_done`;
90
+ }
91
+
92
+ if (mergeReadyCount === 0) {
93
+ return `Lead ${agentName} exited — no merge_ready sent, needs coordinator follow-up`;
94
+ }
95
+ // Dedupe in case a lead resent merge_ready for the same branch
96
+ mergeReadyBranches = Array.from(new Set(mergeReadyBranches));
97
+ if (mergeReadyBranches.length === 0) {
98
+ return `Lead ${agentName} sent ${mergeReadyCount} merge_ready (branch unknown)`;
99
+ }
100
+ if (mergeReadyBranches.length === 1) {
101
+ return `Lead ${agentName} sent merge_ready for branch ${mergeReadyBranches[0]}`;
102
+ }
103
+ return `Lead ${agentName} sent ${mergeReadyBranches.length} merge_ready (branches: ${mergeReadyBranches.join(", ")})`;
104
+ }
105
+
52
106
  /** Delete a git branch (best-effort, non-fatal). */
53
107
  async function deleteBranchBestEffort(repoRoot: string, branch: string): Promise<boolean> {
54
108
  try {
@@ -115,20 +169,35 @@ export async function stopCommand(
115
169
  }
116
170
 
117
171
  const isZombie = session.state === "zombie";
118
- const isHeadless = session.tmuxSession === "" && session.pid !== null;
172
+ // Headless task-scoped agents (Phase 3 spawn-per-turn): tmuxSession is ""
173
+ // and session.pid is null between turns. The live PID for an in-flight
174
+ // turn is published at .overstory/agents/<name>/turn.pid. Sapling RPC
175
+ // agents still use session.pid for their long-lived process.
176
+ const isHeadless = session.tmuxSession === "";
177
+ const turnPidPath = join(overstoryDir, "agents", agentName, "turn.pid");
119
178
 
120
179
  let tmuxKilled = false;
121
180
  let pidKilled = false;
122
181
 
123
182
  // Skip kill operations for already-completed agents (process/tmux already gone)
124
183
  if (!isAlreadyCompleted) {
125
- if (isHeadless && session.pid !== null) {
126
- // Headless agent: kill via process tree instead of tmux
127
- const alive = proc.isAlive(session.pid);
128
- if (alive) {
129
- await proc.killTree(session.pid);
184
+ if (isHeadless) {
185
+ // Prefer the per-turn PID file (Phase 3) this catches an in-flight
186
+ // claude turn for any task-scoped capability. Fall back to the
187
+ // session row's pid for legacy/long-lived headless runtimes (Sapling).
188
+ const turnPid = await readPidFile(turnPidPath);
189
+ const targetPid = turnPid ?? session.pid;
190
+ if (targetPid !== null && proc.isAlive(targetPid)) {
191
+ await proc.killTree(targetPid);
130
192
  pidKilled = true;
131
193
  }
194
+ // Reap the turn.pid file so a subsequent ov stop / mail injector
195
+ // doesn't see a stale entry. Idempotent.
196
+ try {
197
+ await unlink(turnPidPath);
198
+ } catch {
199
+ // already gone — non-fatal
200
+ }
132
201
  } else {
133
202
  // TUI agent: kill via tmux session
134
203
  const alive = await tmux.isSessionAlive(session.tmuxSession);
@@ -138,9 +207,39 @@ export async function stopCommand(
138
207
  }
139
208
  }
140
209
 
141
- // Mark session as completed
142
- store.updateState(agentName, "completed");
210
+ // Mark session as completed via the guarded transition. `completed` is
211
+ // reachable from every non-completed state (including zombie, so `ov
212
+ // stop` can promote a watchdog-flagged zombie to a clean completion),
213
+ // so the only way this rejects is if state is already `completed` —
214
+ // which is the no-op we want anyway. Race-safe under overstory-a993.
215
+ store.tryTransitionState(agentName, "completed");
143
216
  store.updateLastActivity(agentName);
217
+
218
+ // Auto-nudge coordinator when a lead truly completes so it wakes up
219
+ // to process merge_ready / worker_done messages without waiting for
220
+ // user input. Fires from `ov stop` (real completion signal) rather
221
+ // than the per-turn Stop hook, which was spamming the coordinator
222
+ // (overstory-49a7).
223
+ if (session.capability === "lead") {
224
+ try {
225
+ const mailDbPath = join(overstoryDir, "mail.db");
226
+ const subject = buildLeadCompletedSubject(agentName, mailDbPath);
227
+ const nudgesDir = join(overstoryDir, "pending-nudges");
228
+ const { mkdir } = await import("node:fs/promises");
229
+ await mkdir(nudgesDir, { recursive: true });
230
+ const markerPath = join(nudgesDir, "coordinator.json");
231
+ const marker = {
232
+ from: agentName,
233
+ reason: "lead_completed",
234
+ subject,
235
+ messageId: `auto-nudge-${agentName}-${Date.now()}`,
236
+ createdAt: new Date().toISOString(),
237
+ };
238
+ await Bun.write(markerPath, `${JSON.stringify(marker, null, "\t")}\n`);
239
+ } catch {
240
+ // Non-fatal: nudge failure should not break stop
241
+ }
242
+ }
144
243
  }
145
244
 
146
245
  // Optionally remove worktree and branch (best-effort, non-fatal)
@@ -88,6 +88,7 @@ describe("watchCommand", () => {
88
88
  expect(out).toContain("watch");
89
89
  expect(out).toContain("--interval");
90
90
  expect(out).toContain("--background");
91
+ expect(out).toContain("--kill-others");
91
92
  expect(out).toContain("Tier 0");
92
93
  });
93
94
 
@@ -113,6 +114,48 @@ describe("watchCommand", () => {
113
114
  expect(process.exitCode).toBe(1);
114
115
  });
115
116
 
117
+ test("foreground mode: refuses when a live foreign PID owns the lock", async () => {
118
+ // Spawn a long-running child to act as the "foreign live process". Its
119
+ // PID will not match our own, so acquirePidLock should refuse rather
120
+ // than treat the existing PID as idempotent self-ownership. The
121
+ // foreground path used to overwrite this file unconditionally — the
122
+ // overstory-8ef6 fix forces it to refuse.
123
+ const sleeper = Bun.spawn(["sleep", "30"], {
124
+ stdout: "ignore",
125
+ stderr: "ignore",
126
+ });
127
+ try {
128
+ const pidFilePath = join(tempDir, ".overstory", "watchdog.pid");
129
+ await Bun.write(pidFilePath, `${sleeper.pid}\n`);
130
+
131
+ // --json for structured output. No --background, so this exercises
132
+ // the foreground exclusion path. A correctly contested lock returns
133
+ // immediately (exit 1) without starting the daemon loop.
134
+ await watchCommand(["--json"]);
135
+
136
+ const out = output();
137
+ const jsonLine = out
138
+ .split("\n")
139
+ .map((l) => l.trim())
140
+ .find((l) => l.startsWith("{"));
141
+ expect(jsonLine).toBeDefined();
142
+ if (jsonLine) {
143
+ const parsed = JSON.parse(jsonLine);
144
+ expect(parsed.running).toBe(true);
145
+ expect(parsed.pid).toBe(sleeper.pid);
146
+ expect(parsed.error).toContain("already running");
147
+ }
148
+ expect(process.exitCode).toBe(1);
149
+
150
+ // PID file untouched — still the foreign owner's PID.
151
+ const fileContent = await Bun.file(pidFilePath).text();
152
+ expect(fileContent.trim()).toBe(`${sleeper.pid}`);
153
+ } finally {
154
+ sleeper.kill("SIGTERM");
155
+ await sleeper.exited.catch(() => {});
156
+ }
157
+ });
158
+
116
159
  test("background mode: stale PID cleanup", async () => {
117
160
  // Write a PID file with a non-running process (999999 is very unlikely to exist)
118
161
  const pidFilePath = join(tempDir, ".overstory", "watchdog.pid");