@os-eco/overstory-cli 0.6.4 → 0.6.6

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 (117) hide show
  1. package/README.md +61 -61
  2. package/agents/builder.md +16 -16
  3. package/agents/coordinator.md +57 -57
  4. package/agents/issue-reviews.md +71 -0
  5. package/agents/lead.md +43 -42
  6. package/agents/merger.md +15 -15
  7. package/agents/monitor.md +37 -37
  8. package/agents/pr-reviews.md +60 -0
  9. package/agents/prioritize.md +110 -0
  10. package/agents/release.md +56 -0
  11. package/agents/reviewer.md +15 -15
  12. package/agents/scout.md +18 -18
  13. package/agents/supervisor.md +78 -78
  14. package/package.json +1 -1
  15. package/src/agents/checkpoint.test.ts +2 -2
  16. package/src/agents/hooks-deployer.test.ts +59 -25
  17. package/src/agents/hooks-deployer.ts +24 -6
  18. package/src/agents/identity.test.ts +27 -27
  19. package/src/agents/identity.ts +10 -10
  20. package/src/agents/lifecycle.test.ts +6 -6
  21. package/src/agents/lifecycle.ts +2 -2
  22. package/src/agents/overlay.test.ts +14 -14
  23. package/src/agents/overlay.ts +14 -14
  24. package/src/commands/agents.test.ts +5 -5
  25. package/src/commands/agents.ts +10 -9
  26. package/src/commands/clean.test.ts +5 -5
  27. package/src/commands/clean.ts +5 -5
  28. package/src/commands/completions.test.ts +10 -10
  29. package/src/commands/completions.ts +26 -28
  30. package/src/commands/coordinator.test.ts +4 -4
  31. package/src/commands/coordinator.ts +13 -13
  32. package/src/commands/costs.test.ts +45 -45
  33. package/src/commands/costs.ts +1 -1
  34. package/src/commands/dashboard.ts +11 -11
  35. package/src/commands/doctor.ts +4 -4
  36. package/src/commands/errors.ts +1 -1
  37. package/src/commands/feed.ts +1 -1
  38. package/src/commands/group.ts +3 -3
  39. package/src/commands/hooks.test.ts +7 -7
  40. package/src/commands/hooks.ts +7 -7
  41. package/src/commands/init.test.ts +6 -2
  42. package/src/commands/init.ts +19 -19
  43. package/src/commands/inspect.test.ts +16 -16
  44. package/src/commands/inspect.ts +19 -19
  45. package/src/commands/log.test.ts +21 -21
  46. package/src/commands/log.ts +10 -10
  47. package/src/commands/logs.ts +1 -1
  48. package/src/commands/mail.test.ts +7 -7
  49. package/src/commands/mail.ts +28 -11
  50. package/src/commands/merge.test.ts +8 -8
  51. package/src/commands/merge.ts +15 -15
  52. package/src/commands/metrics.test.ts +7 -7
  53. package/src/commands/metrics.ts +3 -3
  54. package/src/commands/monitor.test.ts +5 -5
  55. package/src/commands/monitor.ts +5 -5
  56. package/src/commands/nudge.test.ts +1 -1
  57. package/src/commands/nudge.ts +1 -1
  58. package/src/commands/prime.test.ts +5 -5
  59. package/src/commands/prime.ts +8 -8
  60. package/src/commands/replay.ts +1 -1
  61. package/src/commands/run.test.ts +1 -1
  62. package/src/commands/run.ts +2 -2
  63. package/src/commands/sling.test.ts +89 -7
  64. package/src/commands/sling.ts +109 -18
  65. package/src/commands/spec.test.ts +2 -2
  66. package/src/commands/spec.ts +13 -14
  67. package/src/commands/status.test.ts +99 -3
  68. package/src/commands/status.ts +19 -20
  69. package/src/commands/stop.test.ts +1 -1
  70. package/src/commands/stop.ts +2 -2
  71. package/src/commands/supervisor.test.ts +10 -10
  72. package/src/commands/supervisor.ts +14 -14
  73. package/src/commands/trace.test.ts +7 -7
  74. package/src/commands/trace.ts +10 -10
  75. package/src/commands/watch.ts +5 -5
  76. package/src/commands/worktree.test.ts +208 -32
  77. package/src/commands/worktree.ts +56 -18
  78. package/src/doctor/consistency.test.ts +14 -14
  79. package/src/doctor/dependencies.test.ts +5 -5
  80. package/src/doctor/dependencies.ts +2 -2
  81. package/src/doctor/logs.ts +1 -1
  82. package/src/doctor/merge-queue.test.ts +4 -4
  83. package/src/doctor/structure.test.ts +1 -1
  84. package/src/doctor/structure.ts +1 -1
  85. package/src/doctor/version.test.ts +3 -3
  86. package/src/doctor/version.ts +1 -1
  87. package/src/e2e/init-sling-lifecycle.test.ts +8 -4
  88. package/src/errors.ts +1 -1
  89. package/src/index.ts +13 -11
  90. package/src/mail/broadcast.test.ts +1 -1
  91. package/src/mail/client.test.ts +7 -7
  92. package/src/mail/client.ts +2 -2
  93. package/src/mail/store.test.ts +3 -3
  94. package/src/merge/queue.test.ts +12 -12
  95. package/src/merge/queue.ts +2 -2
  96. package/src/merge/resolver.test.ts +159 -7
  97. package/src/merge/resolver.ts +46 -2
  98. package/src/metrics/store.test.ts +44 -44
  99. package/src/metrics/store.ts +2 -2
  100. package/src/metrics/summary.test.ts +35 -35
  101. package/src/mulch/client.test.ts +1 -1
  102. package/src/mulch/client.ts +1 -1
  103. package/src/sessions/compat.test.ts +3 -3
  104. package/src/sessions/compat.ts +1 -1
  105. package/src/sessions/store.test.ts +4 -4
  106. package/src/sessions/store.ts +2 -2
  107. package/src/types.ts +14 -14
  108. package/src/watchdog/daemon.test.ts +10 -10
  109. package/src/watchdog/daemon.ts +1 -1
  110. package/src/watchdog/health.test.ts +1 -1
  111. package/src/worktree/manager.test.ts +20 -20
  112. package/src/worktree/manager.ts +120 -4
  113. package/src/worktree/tmux.test.ts +8 -3
  114. package/src/worktree/tmux.ts +19 -18
  115. package/templates/CLAUDE.md.tmpl +27 -27
  116. package/templates/hooks.json.tmpl +15 -11
  117. package/templates/overlay.md.tmpl +7 -7
@@ -3,7 +3,9 @@ import { resolveModel, resolveProviderEnv } from "../agents/manifest.ts";
3
3
  import { HierarchyError } from "../errors.ts";
4
4
  import type { AgentManifest, OverstoryConfig } from "../types.ts";
5
5
  import {
6
+ type AutoDispatchOptions,
6
7
  type BeaconOptions,
8
+ buildAutoDispatch,
7
9
  buildBeacon,
8
10
  calculateStaggerDelay,
9
11
  checkBeadLock,
@@ -405,14 +407,14 @@ describe("buildBeacon", () => {
405
407
 
406
408
  expect(beacon).toContain("read .claude/CLAUDE.md");
407
409
  expect(beacon).toContain("mulch prime");
408
- expect(beacon).toContain("overstory mail check --agent scout-1");
410
+ expect(beacon).toContain("ov mail check --agent scout-1");
409
411
  expect(beacon).toContain("begin task overstory-xyz");
410
412
  });
411
413
 
412
414
  test("uses agent name in mail check command", () => {
413
415
  const beacon = buildBeacon(makeBeaconOpts({ agentName: "reviewer-beta" }));
414
416
 
415
- expect(beacon).toContain("overstory mail check --agent reviewer-beta");
417
+ expect(beacon).toContain("ov mail check --agent reviewer-beta");
416
418
  });
417
419
 
418
420
  test("reflects capability in header", () => {
@@ -554,13 +556,13 @@ describe("isRunningAsRoot", () => {
554
556
  /**
555
557
  * Tests for checkBeadLock.
556
558
  *
557
- * checkBeadLock prevents concurrent agents from working the same bead ID.
559
+ * checkBeadLock prevents concurrent agents from working the same task ID.
558
560
  * It checks the active session list and returns the agent name that holds
559
561
  * the lock (i.e., is already working on the bead), or null if the bead is free.
560
562
  */
561
563
 
562
- function makeBeadSession(agentName: string, beadId: string): { agentName: string; beadId: string } {
563
- return { agentName, beadId };
564
+ function makeBeadSession(agentName: string, taskId: string): { agentName: string; taskId: string } {
565
+ return { agentName, taskId };
564
566
  }
565
567
 
566
568
  describe("checkBeadLock", () => {
@@ -568,7 +570,7 @@ describe("checkBeadLock", () => {
568
570
  expect(checkBeadLock([], "overstory-abc")).toBeNull();
569
571
  });
570
572
 
571
- test("returns null when no session matches the bead ID", () => {
573
+ test("returns null when no session matches the task ID", () => {
572
574
  const sessions = [
573
575
  makeBeadSession("builder-1", "overstory-xyz"),
574
576
  makeBeadSession("builder-2", "overstory-def"),
@@ -587,7 +589,7 @@ describe("checkBeadLock", () => {
587
589
  });
588
590
 
589
591
  test("returns the first matching agent when multiple sessions match", () => {
590
- // Multiple sessions can have the same beadId (e.g., retried agent)
592
+ // Multiple sessions can have the same taskId (e.g., retried agent)
591
593
  // checkBeadLock returns the first match
592
594
  const sessions = [
593
595
  makeBeadSession("builder-1", "overstory-abc"),
@@ -851,3 +853,83 @@ describe("sling provider env injection building blocks", () => {
851
853
  expect(builderResult.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("anthropic/claude-3-5-haiku");
852
854
  });
853
855
  });
856
+
857
+ /**
858
+ * Tests for buildAutoDispatch.
859
+ *
860
+ * buildAutoDispatch constructs a pre-spawn dispatch mail message that is
861
+ * inserted into the mail DB before the tmux session is created. This ensures
862
+ * the agent's SessionStart hook can immediately deliver context without
863
+ * waiting for the coordinator to send a separate dispatch message.
864
+ */
865
+
866
+ function makeAutoDispatchOpts(overrides?: Partial<AutoDispatchOptions>): AutoDispatchOptions {
867
+ return {
868
+ agentName: "builder-1",
869
+ taskId: "overstory-abc",
870
+ capability: "builder",
871
+ specPath: "/path/to/spec.md",
872
+ parentAgent: "lead-alpha",
873
+ ...overrides,
874
+ };
875
+ }
876
+
877
+ describe("buildAutoDispatch", () => {
878
+ test("uses parent agent as sender when provided", () => {
879
+ const dispatch = buildAutoDispatch({
880
+ agentName: "builder-1",
881
+ taskId: "overstory-abc",
882
+ capability: "builder",
883
+ specPath: "/path/to/spec.md",
884
+ parentAgent: "lead-alpha",
885
+ });
886
+ expect(dispatch.from).toBe("lead-alpha");
887
+ expect(dispatch.to).toBe("builder-1");
888
+ expect(dispatch.subject).toContain("overstory-abc");
889
+ expect(dispatch.body).toContain("spec.md");
890
+ });
891
+
892
+ test("uses orchestrator as sender when no parent", () => {
893
+ const dispatch = buildAutoDispatch({
894
+ agentName: "lead-1",
895
+ taskId: "overstory-xyz",
896
+ capability: "lead",
897
+ specPath: null,
898
+ parentAgent: null,
899
+ });
900
+ expect(dispatch.from).toBe("orchestrator");
901
+ expect(dispatch.body).toContain("No spec file");
902
+ });
903
+
904
+ test("includes capability in body", () => {
905
+ const dispatch = buildAutoDispatch({
906
+ agentName: "scout-1",
907
+ taskId: "overstory-abc",
908
+ capability: "scout",
909
+ specPath: null,
910
+ parentAgent: "lead-alpha",
911
+ });
912
+ expect(dispatch.body).toContain("scout");
913
+ });
914
+
915
+ test("includes spec path when provided", () => {
916
+ const dispatch = buildAutoDispatch({
917
+ agentName: "builder-1",
918
+ taskId: "overstory-abc",
919
+ capability: "builder",
920
+ specPath: "/abs/path/to/spec.md",
921
+ parentAgent: "lead-alpha",
922
+ });
923
+ expect(dispatch.body).toContain("/abs/path/to/spec.md");
924
+ });
925
+
926
+ test("subject contains task ID", () => {
927
+ const dispatch = buildAutoDispatch(makeAutoDispatchOpts({ taskId: "overstory-zz99" }));
928
+ expect(dispatch.subject).toContain("overstory-zz99");
929
+ });
930
+
931
+ test("to is the agent name", () => {
932
+ const dispatch = buildAutoDispatch(makeAutoDispatchOpts({ agentName: "my-builder" }));
933
+ expect(dispatch.to).toBe("my-builder");
934
+ });
935
+ });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * CLI command: overstory sling <task-id>
2
+ * CLI command: ov sling <task-id>
3
3
  *
4
4
  * CRITICAL PATH. Orchestrates a full agent spawn:
5
5
  * 1. Load config + manifest
@@ -27,6 +27,8 @@ import { writeOverlay } from "../agents/overlay.ts";
27
27
  import { loadConfig } from "../config.ts";
28
28
  import { AgentError, HierarchyError, ValidationError } from "../errors.ts";
29
29
  import { inferDomain } from "../insights/analyzer.ts";
30
+ import { createMailClient } from "../mail/client.ts";
31
+ import { createMailStore } from "../mail/store.ts";
30
32
  import { createMulchClient } from "../mulch/client.ts";
31
33
  import { openSessionStore } from "../sessions/compat.ts";
32
34
  import { createRunStore } from "../sessions/store.ts";
@@ -34,7 +36,13 @@ import type { TrackerIssue } from "../tracker/factory.ts";
34
36
  import { createTrackerClient, resolveBackend, trackerCliName } from "../tracker/factory.ts";
35
37
  import type { AgentSession, OverlayConfig } from "../types.ts";
36
38
  import { createWorktree } from "../worktree/manager.ts";
37
- import { createSession, sendKeys, waitForTuiReady } from "../worktree/tmux.ts";
39
+ import {
40
+ capturePaneContent,
41
+ createSession,
42
+ ensureTmuxAvailable,
43
+ sendKeys,
44
+ waitForTuiReady,
45
+ } from "../worktree/tmux.ts";
38
46
 
39
47
  /**
40
48
  * Calculate how many milliseconds to sleep before spawning a new agent,
@@ -111,6 +119,45 @@ export interface SlingOptions {
111
119
  json?: boolean;
112
120
  }
113
121
 
122
+ export interface AutoDispatchOptions {
123
+ agentName: string;
124
+ taskId: string;
125
+ capability: string;
126
+ specPath: string | null;
127
+ parentAgent: string | null;
128
+ }
129
+
130
+ /**
131
+ * Build a structured auto-dispatch mail message for a newly slung agent.
132
+ *
133
+ * Sending this mail before creating the tmux session ensures it exists
134
+ * in the DB when SessionStart fires, eliminating the race where dispatch
135
+ * mail arrives after the agent boots and sits idle forever.
136
+ */
137
+ export function buildAutoDispatch(opts: AutoDispatchOptions): {
138
+ from: string;
139
+ to: string;
140
+ subject: string;
141
+ body: string;
142
+ } {
143
+ const from = opts.parentAgent ?? "orchestrator";
144
+ const specLine = opts.specPath
145
+ ? `Spec file: ${opts.specPath}`
146
+ : "No spec file provided. Check your overlay for task details.";
147
+ const body = [
148
+ `You have been assigned task ${opts.taskId} as a ${opts.capability} agent.`,
149
+ specLine,
150
+ `Read your overlay at .claude/CLAUDE.md and begin immediately.`,
151
+ ].join(" ");
152
+
153
+ return {
154
+ from,
155
+ to: opts.agentName,
156
+ subject: `Dispatch: ${opts.taskId}`,
157
+ body,
158
+ };
159
+ }
160
+
114
161
  /**
115
162
  * Options for building the structured startup beacon.
116
163
  */
@@ -135,7 +182,7 @@ export interface BeaconOptions {
135
182
  * Startup protocol:
136
183
  * 1. Read your assignment in .claude/CLAUDE.md
137
184
  * 2. Load expertise: mulch prime
138
- * 3. Check mail: overstory mail check --agent <name>
185
+ * 3. Check mail: ov mail check --agent <name>
139
186
  * 4. Begin working on task <bead-id>
140
187
  */
141
188
  export function buildBeacon(opts: BeaconOptions): string {
@@ -144,7 +191,7 @@ export function buildBeacon(opts: BeaconOptions): string {
144
191
  const parts = [
145
192
  `[OVERSTORY] ${opts.agentName} (${opts.capability}) ${timestamp} task:${opts.taskId}`,
146
193
  `Depth: ${opts.depth} | Parent: ${parent}`,
147
- `Startup: read .claude/CLAUDE.md, run mulch prime, check mail (overstory mail check --agent ${opts.agentName}), then begin task ${opts.taskId}`,
194
+ `Startup: read .claude/CLAUDE.md, run mulch prime, check mail (ov mail check --agent ${opts.agentName}), then begin task ${opts.taskId}`,
148
195
  ];
149
196
  return parts.join(" — ");
150
197
  }
@@ -161,17 +208,17 @@ export function parentHasScouts(
161
208
  }
162
209
 
163
210
  /**
164
- * Check if any active agent is already working on the given bead ID.
211
+ * Check if any active agent is already working on the given task ID.
165
212
  * Returns the agent name if locked, or null if the bead is free.
166
213
  *
167
214
  * @param activeSessions - Currently active (non-zombie) sessions
168
- * @param beadId - The bead task ID to check for concurrent work
215
+ * @param taskId - The bead task ID to check for concurrent work
169
216
  */
170
217
  export function checkBeadLock(
171
- activeSessions: ReadonlyArray<{ agentName: string; beadId: string }>,
172
- beadId: string,
218
+ activeSessions: ReadonlyArray<{ agentName: string; taskId: string }>,
219
+ taskId: string,
173
220
  ): string | null {
174
- const existing = activeSessions.find((s) => s.beadId === beadId);
221
+ const existing = activeSessions.find((s) => s.taskId === taskId);
175
222
  return existing?.agentName ?? null;
176
223
  }
177
224
 
@@ -225,14 +272,14 @@ export function validateHierarchy(
225
272
  }
226
273
 
227
274
  /**
228
- * Entry point for `overstory sling <task-id> [flags]`.
275
+ * Entry point for `ov sling <task-id> [flags]`.
229
276
  *
230
277
  * @param taskId - The task ID to assign to the agent
231
278
  * @param opts - Command options
232
279
  */
233
280
  export async function slingCommand(taskId: string, opts: SlingOptions): Promise<void> {
234
281
  if (!taskId) {
235
- throw new ValidationError("Task ID is required: overstory sling <task-id>", {
282
+ throw new ValidationError("Task ID is required: ov sling <task-id>", {
236
283
  field: "taskId",
237
284
  });
238
285
  }
@@ -269,7 +316,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
269
316
  // Warn if --skip-scout is used for a non-lead capability (harmless but confusing)
270
317
  if (skipScout && capability !== "lead") {
271
318
  process.stderr.write(
272
- `⚠️ Warning: --skip-scout is only meaningful for leads. Ignoring for "${capability}" agent "${name}".\n`,
319
+ `Warning: --skip-scout is only meaningful for leads. Ignoring for "${capability}" agent "${name}".\n`,
273
320
  );
274
321
  }
275
322
 
@@ -393,7 +440,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
393
440
  });
394
441
  }
395
442
 
396
- // 5d. Bead-level locking: prevent concurrent agents on the same bead ID.
443
+ // 5d. Bead-level locking: prevent concurrent agents on the same task ID.
397
444
  // Exception: the parent agent may delegate its own task to a child.
398
445
  const lockHolder = checkBeadLock(activeSessions, taskId);
399
446
  if (lockHolder !== null && lockHolder !== parentAgent) {
@@ -415,7 +462,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
415
462
  // the scout-skip pattern so agents and operators can see it happening.
416
463
  if (capability === "builder" && parentAgent && !parentHasScouts(store.getAll(), parentAgent)) {
417
464
  process.stderr.write(
418
- `⚠️ Warning: "${parentAgent}" is spawning builder "${name}" without having spawned any scouts.\n`,
465
+ `Warning: "${parentAgent}" is spawning builder "${name}" without having spawned any scouts.\n`,
419
466
  );
420
467
  process.stderr.write(
421
468
  " Leads should spawn scouts in Phase 1 before building. See agents/lead.md.\n",
@@ -453,7 +500,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
453
500
  baseDir: worktreeBaseDir,
454
501
  agentName: name,
455
502
  baseBranch: config.project.canonicalBranch,
456
- beadId: taskId,
503
+ taskId: taskId,
457
504
  });
458
505
 
459
506
  // 8. Generate + write overlay CLAUDE.md
@@ -474,7 +521,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
474
521
 
475
522
  const overlayConfig: OverlayConfig = {
476
523
  agentName: name,
477
- beadId: taskId,
524
+ taskId: taskId,
478
525
  specPath: absoluteSpecPath,
479
526
  branchName,
480
527
  worktreePath,
@@ -514,6 +561,30 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
514
561
  // 9. Deploy hooks config (capability-specific guards)
515
562
  await deployHooks(worktreePath, name, capability);
516
563
 
564
+ // 9b. Send auto-dispatch mail so it exists when SessionStart hook fires.
565
+ // This eliminates the race where coordinator sends dispatch AFTER agent boots.
566
+ const dispatch = buildAutoDispatch({
567
+ agentName: name,
568
+ taskId,
569
+ capability,
570
+ specPath: absoluteSpecPath,
571
+ parentAgent,
572
+ });
573
+ const mailStore = createMailStore(join(overstoryDir, "mail.db"));
574
+ try {
575
+ const mailClient = createMailClient(mailStore);
576
+ mailClient.send({
577
+ from: dispatch.from,
578
+ to: dispatch.to,
579
+ subject: dispatch.subject,
580
+ body: dispatch.body,
581
+ type: "dispatch",
582
+ priority: "normal",
583
+ });
584
+ } finally {
585
+ mailStore.close();
586
+ }
587
+
517
588
  // 10. Claim tracker issue
518
589
  if (config.taskTracker.enabled && !skipTaskCheck) {
519
590
  try {
@@ -537,6 +608,9 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
537
608
  });
538
609
  }
539
610
 
611
+ // 11b. Preflight: verify tmux is available before attempting session creation
612
+ await ensureTmuxAvailable();
613
+
540
614
  // 12. Create tmux session running claude in interactive mode
541
615
  const tmuxSessionName = `overstory-${config.project.name}-${name}`;
542
616
  const { model, env } = resolveModel(config, manifest, capability, agentDef.model);
@@ -557,7 +631,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
557
631
  capability,
558
632
  worktreePath,
559
633
  branchName,
560
- beadId: taskId,
634
+ taskId: taskId,
561
635
  tmuxSession: tmuxSessionName,
562
636
  state: "booting",
563
637
  pid,
@@ -604,6 +678,23 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
604
678
  await sendKeys(tmuxSessionName, "");
605
679
  }
606
680
 
681
+ // 13d. Verify beacon was received — if pane still shows the welcome
682
+ // screen ("Try "), resend the beacon. Claude Code's TUI sometimes
683
+ // consumes the Enter keystroke during late initialization, swallowing
684
+ // the beacon text entirely (overstory-3271).
685
+ const verifyAttempts = 5;
686
+ for (let v = 0; v < verifyAttempts; v++) {
687
+ await Bun.sleep(2_000);
688
+ const paneContent = await capturePaneContent(tmuxSessionName);
689
+ if (paneContent && !paneContent.includes('Try "')) {
690
+ break; // Agent is processing — beacon was received
691
+ }
692
+ // Still at welcome screen — resend beacon
693
+ await sendKeys(tmuxSessionName, beacon);
694
+ await Bun.sleep(1_000);
695
+ await sendKeys(tmuxSessionName, ""); // Follow-up Enter
696
+ }
697
+
607
698
  // 14. Output result
608
699
  const output = {
609
700
  agentName: name,
@@ -618,7 +709,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
618
709
  if (opts.json ?? false) {
619
710
  process.stdout.write(`${JSON.stringify(output)}\n`);
620
711
  } else {
621
- process.stdout.write(`🚀 Agent "${name}" launched!\n`);
712
+ process.stdout.write(`Agent "${name}" launched!\n`);
622
713
  process.stdout.write(` Task: ${taskId}\n`);
623
714
  process.stdout.write(` Branch: ${branchName}\n`);
624
715
  process.stdout.write(` Worktree: ${worktreePath}\n`);
@@ -58,8 +58,8 @@ afterEach(async () => {
58
58
  // === validation ===
59
59
 
60
60
  describe("validation", () => {
61
- test("write without bead-id throws ValidationError", async () => {
62
- await expect(specWriteCommand("", {})).rejects.toThrow("Bead ID is required");
61
+ test("write without task-id throws ValidationError", async () => {
62
+ await expect(specWriteCommand("", {})).rejects.toThrow("Task ID is required");
63
63
  });
64
64
 
65
65
  test("write without body throws ValidationError", async () => {
@@ -1,7 +1,7 @@
1
1
  /**
2
- * CLI command: overstory spec write <bead-id> --body <content>
2
+ * CLI command: ov spec write <bead-id> --body <content>
3
3
  *
4
- * Writes a task specification to `.overstory/specs/<bead-id>.md`.
4
+ * Writes a task specification to `.overstory/specs/<task-id>.md`.
5
5
  * Scouts use this to persist spec documents as files instead of
6
6
  * sending entire specs via mail messages.
7
7
  *
@@ -30,13 +30,13 @@ async function readStdin(): Promise<string> {
30
30
  }
31
31
 
32
32
  /**
33
- * Write a spec file to .overstory/specs/<bead-id>.md.
33
+ * Write a spec file to .overstory/specs/<task-id>.md.
34
34
  *
35
35
  * Exported for direct use in tests.
36
36
  */
37
37
  export async function writeSpec(
38
38
  projectRoot: string,
39
- beadId: string,
39
+ taskId: string,
40
40
  body: string,
41
41
  agent?: string,
42
42
  ): Promise<string> {
@@ -55,24 +55,23 @@ export async function writeSpec(
55
55
  content += "\n";
56
56
  }
57
57
 
58
- const specPath = join(specsDir, `${beadId}.md`);
58
+ const specPath = join(specsDir, `${taskId}.md`);
59
59
  await Bun.write(specPath, content);
60
60
 
61
61
  return specPath;
62
62
  }
63
63
 
64
64
  /**
65
- * Entry point for `overstory spec write <bead-id> [flags]`.
65
+ * Entry point for `ov spec write <bead-id> [flags]`.
66
66
  *
67
- * @param beadId - The bead/task ID for the spec file
67
+ * @param taskId - The task ID for the spec file
68
68
  * @param opts - Command options
69
69
  */
70
- export async function specWriteCommand(beadId: string, opts: SpecWriteOptions): Promise<void> {
71
- if (!beadId || beadId.trim().length === 0) {
72
- throw new ValidationError(
73
- "Bead ID is required: overstory spec write <bead-id> --body <content>",
74
- { field: "beadId" },
75
- );
70
+ export async function specWriteCommand(taskId: string, opts: SpecWriteOptions): Promise<void> {
71
+ if (!taskId || taskId.trim().length === 0) {
72
+ throw new ValidationError("Task ID is required: ov spec write <task-id> --body <content>", {
73
+ field: "taskId",
74
+ });
76
75
  }
77
76
 
78
77
  let body = opts.body;
@@ -94,6 +93,6 @@ export async function specWriteCommand(beadId: string, opts: SpecWriteOptions):
94
93
  const { resolveProjectRoot } = await import("../config.ts");
95
94
  const projectRoot = await resolveProjectRoot(process.cwd());
96
95
 
97
- const specPath = await writeSpec(projectRoot, beadId, body, opts.agent);
96
+ const specPath = await writeSpec(projectRoot, taskId, body, opts.agent);
98
97
  process.stdout.write(`${specPath}\n`);
99
98
  }
@@ -28,7 +28,7 @@ function makeAgent(overrides: Partial<AgentSession> = {}): AgentSession {
28
28
  capability: "builder",
29
29
  worktreePath: "/tmp/worktrees/test-builder",
30
30
  branchName: "overstory/test-builder/task-1",
31
- beadId: "task-1",
31
+ taskId: "task-1",
32
32
  tmuxSession: "overstory-test-builder",
33
33
  state: "working",
34
34
  pid: 12345,
@@ -364,7 +364,7 @@ describe("--watch deprecation", () => {
364
364
 
365
365
  const out = chunks.join("");
366
366
  expect(out).toContain("deprecated");
367
- expect(out).toContain("overstory dashboard");
367
+ expect(out).toContain("ov dashboard");
368
368
  });
369
369
 
370
370
  test("--watch writes deprecation notice to stderr", async () => {
@@ -394,7 +394,103 @@ describe("--watch deprecation", () => {
394
394
 
395
395
  const err = stderrChunks.join("");
396
396
  expect(err).toContain("--watch is deprecated");
397
- expect(err).toContain("overstory dashboard");
397
+ expect(err).toContain("ov dashboard");
398
+ });
399
+ });
400
+
401
+ describe("gatherStatus reconciliation", () => {
402
+ afterEach(() => {
403
+ invalidateStatusCache();
404
+ });
405
+
406
+ test("booting agent with dead tmux becomes zombie", async () => {
407
+ const tempDir = await createTempGitRepo();
408
+ const overstoryDir = join(tempDir, ".overstory");
409
+ await mkdir(overstoryDir, { recursive: true });
410
+
411
+ const store = createSessionStore(join(overstoryDir, "sessions.db"));
412
+ const now = new Date().toISOString();
413
+ const session = makeAgent({
414
+ agentName: "boot-builder",
415
+ capability: "builder",
416
+ state: "booting",
417
+ tmuxSession: "overstory-boot-builder",
418
+ runId: null,
419
+ });
420
+ session.startedAt = now;
421
+ session.lastActivity = now;
422
+ store.upsert(session);
423
+ store.close();
424
+
425
+ try {
426
+ // No real tmux running, so getCachedTmuxSessions() returns empty array
427
+ // evaluateHealth ZFC Rule 1: tmux dead → zombie
428
+ const result = await gatherStatus(tempDir, "orchestrator", false, undefined);
429
+ const agent = result.agents.find((a) => a.agentName === "boot-builder");
430
+ expect(agent).toBeDefined();
431
+ expect(agent?.state).toBe("zombie");
432
+ } finally {
433
+ await rm(tempDir, { recursive: true, force: true });
434
+ }
435
+ });
436
+
437
+ test("completed agents skip reconciliation", async () => {
438
+ const tempDir = await createTempGitRepo();
439
+ const overstoryDir = join(tempDir, ".overstory");
440
+ await mkdir(overstoryDir, { recursive: true });
441
+
442
+ const store = createSessionStore(join(overstoryDir, "sessions.db"));
443
+ const now = new Date().toISOString();
444
+ const session = makeAgent({
445
+ agentName: "done-builder",
446
+ capability: "builder",
447
+ state: "completed",
448
+ tmuxSession: "overstory-done-builder",
449
+ runId: null,
450
+ });
451
+ session.startedAt = now;
452
+ session.lastActivity = now;
453
+ store.upsert(session);
454
+ store.close();
455
+
456
+ try {
457
+ const result = await gatherStatus(tempDir, "orchestrator", false, undefined);
458
+ const agent = result.agents.find((a) => a.agentName === "done-builder");
459
+ expect(agent).toBeDefined();
460
+ expect(agent?.state).toBe("completed");
461
+ } finally {
462
+ await rm(tempDir, { recursive: true, force: true });
463
+ }
464
+ });
465
+
466
+ test("working agent with dead tmux becomes zombie", async () => {
467
+ const tempDir = await createTempGitRepo();
468
+ const overstoryDir = join(tempDir, ".overstory");
469
+ await mkdir(overstoryDir, { recursive: true });
470
+
471
+ const store = createSessionStore(join(overstoryDir, "sessions.db"));
472
+ const now = new Date().toISOString();
473
+ const session = makeAgent({
474
+ agentName: "working-builder",
475
+ capability: "builder",
476
+ state: "working",
477
+ tmuxSession: "overstory-working-builder",
478
+ runId: null,
479
+ });
480
+ session.startedAt = now;
481
+ session.lastActivity = now;
482
+ store.upsert(session);
483
+ store.close();
484
+
485
+ try {
486
+ // No real tmux session → tmux dead → zombie via evaluateHealth ZFC Rule 1
487
+ const result = await gatherStatus(tempDir, "orchestrator", false, undefined);
488
+ const agent = result.agents.find((a) => a.agentName === "working-builder");
489
+ expect(agent).toBeDefined();
490
+ expect(agent?.state).toBe("zombie");
491
+ } finally {
492
+ await rm(tempDir, { recursive: true, force: true });
493
+ }
398
494
  });
399
495
  });
400
496
 
@@ -14,6 +14,7 @@ import { createMergeQueue } from "../merge/queue.ts";
14
14
  import { createMetricsStore } from "../metrics/store.ts";
15
15
  import { openSessionStore } from "../sessions/compat.ts";
16
16
  import type { AgentSession } from "../types.ts";
17
+ import { evaluateHealth } from "../watchdog/health.ts";
17
18
  import { listWorktrees } from "../worktree/manager.ts";
18
19
  import { listSessions } from "../worktree/tmux.ts";
19
20
 
@@ -136,23 +137,23 @@ export async function gatherStatus(
136
137
 
137
138
  const tmuxSessions = await getCachedTmuxSessions();
138
139
 
139
- // Reconcile agent states: if tmux session is dead but agent state
140
- // indicates it should be alive, mark it as zombie
140
+ // Reconcile agent states using the same health evaluation as the
141
+ // dashboard and watchdog. This handles:
142
+ // 1. tmux dead -> zombie (regardless of recorded state)
143
+ // 2. persistent capabilities (coordinator, monitor) booting -> working when tmux alive
144
+ // 3. time-based stale/zombie detection for non-persistent agents
141
145
  const tmuxSessionNames = new Set(tmuxSessions.map((s) => s.name));
146
+ const healthThresholds = { staleMs: 300_000, zombieMs: 600_000 };
142
147
  for (const session of sessions) {
143
- if (
144
- session.state === "booting" ||
145
- session.state === "working" ||
146
- session.state === "stalled"
147
- ) {
148
- const tmuxAlive = tmuxSessionNames.has(session.tmuxSession);
149
- if (!tmuxAlive) {
150
- try {
151
- store.updateState(session.agentName, "zombie");
152
- session.state = "zombie";
153
- } catch {
154
- // Best effort: don't fail status display if update fails
155
- }
148
+ if (session.state === "completed") continue;
149
+ const tmuxAlive = tmuxSessionNames.has(session.tmuxSession);
150
+ const check = evaluateHealth(session, tmuxAlive, healthThresholds);
151
+ if (check.state !== session.state) {
152
+ try {
153
+ store.updateState(session.agentName, check.state);
154
+ session.state = check.state;
155
+ } catch {
156
+ // Best effort: don't fail status display if update fails
156
157
  }
157
158
  }
158
159
  }
@@ -273,7 +274,7 @@ export function printStatus(data: StatusData): void {
273
274
  const tmuxAlive = tmuxSessionNames.has(agent.tmuxSession);
274
275
  const aliveMarker = tmuxAlive ? "●" : "○";
275
276
  w(` ${aliveMarker} ${agent.agentName} [${agent.capability}] `);
276
- w(`${agent.state} | ${agent.beadId} | ${duration}\n`);
277
+ w(`${agent.state} | ${agent.taskId} | ${duration}\n`);
277
278
 
278
279
  const detail = data.verboseDetails?.[agent.agentName];
279
280
  if (detail) {
@@ -346,9 +347,7 @@ async function executeStatus(opts: StatusOpts): Promise<void> {
346
347
  }
347
348
 
348
349
  if (watch) {
349
- process.stderr.write(
350
- "⚠️ --watch is deprecated. Use 'overstory dashboard' for live monitoring.\n\n",
351
- );
350
+ process.stderr.write("⚠️ --watch is deprecated. Use 'ov dashboard' for live monitoring.\n\n");
352
351
  // Polling loop (kept for one release cycle)
353
352
  while (true) {
354
353
  // Clear screen
@@ -378,7 +377,7 @@ export function createStatusCommand(): Command {
378
377
  .option("--verbose", "Show extra detail per agent (worktree, logs, mail timestamps)")
379
378
  .option("--agent <name>", "Show unread mail for this agent (default: orchestrator)")
380
379
  .option("--all", "Show sessions from all runs (default: current run only)")
381
- .option("--watch", "(deprecated) Use 'overstory dashboard' for live monitoring")
380
+ .option("--watch", "(deprecated) Use 'ov dashboard' for live monitoring")
382
381
  .option("--interval <ms>", "Poll interval for --watch in milliseconds (default: 3000)")
383
382
  .action(async (opts: StatusOpts) => {
384
383
  await executeStatus(opts);