@os-eco/overstory-cli 0.6.10 → 0.6.11

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.
@@ -495,6 +495,85 @@ describe("generateOverlay", () => {
495
495
 
496
496
  expect(output).toContain("bd close overstory-back");
497
497
  });
498
+
499
+ test("dispatch overrides: skipReview injects SKIP REVIEW directive for leads", async () => {
500
+ const config = makeConfig({
501
+ capability: "lead",
502
+ skipReview: true,
503
+ canSpawn: true,
504
+ });
505
+ const output = await generateOverlay(config);
506
+
507
+ expect(output).toContain("Dispatch Overrides");
508
+ expect(output).toContain("SKIP REVIEW");
509
+ expect(output).toContain("Self-verify");
510
+ });
511
+
512
+ test("dispatch overrides: maxAgentsOverride injects MAX AGENTS directive for leads", async () => {
513
+ const config = makeConfig({
514
+ capability: "lead",
515
+ maxAgentsOverride: 3,
516
+ canSpawn: true,
517
+ });
518
+ const output = await generateOverlay(config);
519
+
520
+ expect(output).toContain("Dispatch Overrides");
521
+ expect(output).toContain("MAX AGENTS");
522
+ expect(output).toContain("3");
523
+ });
524
+
525
+ test("dispatch overrides: both skipReview and maxAgentsOverride together", async () => {
526
+ const config = makeConfig({
527
+ capability: "lead",
528
+ skipReview: true,
529
+ maxAgentsOverride: 4,
530
+ canSpawn: true,
531
+ });
532
+ const output = await generateOverlay(config);
533
+
534
+ expect(output).toContain("SKIP REVIEW");
535
+ expect(output).toContain("MAX AGENTS");
536
+ expect(output).toContain("4");
537
+ });
538
+
539
+ test("dispatch overrides: not injected for builder capability", async () => {
540
+ const config = makeConfig({
541
+ capability: "builder",
542
+ skipReview: true,
543
+ maxAgentsOverride: 3,
544
+ });
545
+ const output = await generateOverlay(config);
546
+
547
+ expect(output).not.toContain("Dispatch Overrides");
548
+ });
549
+
550
+ test("dispatch overrides: not injected when no overrides set", async () => {
551
+ const config = makeConfig({
552
+ capability: "lead",
553
+ canSpawn: true,
554
+ });
555
+ const output = await generateOverlay(config);
556
+
557
+ expect(output).not.toContain("Dispatch Overrides");
558
+ });
559
+
560
+ test("dispatch overrides: maxAgentsOverride of 0 is not injected", async () => {
561
+ const config = makeConfig({
562
+ capability: "lead",
563
+ maxAgentsOverride: 0,
564
+ canSpawn: true,
565
+ });
566
+ const output = await generateOverlay(config);
567
+
568
+ expect(output).not.toContain("MAX AGENTS");
569
+ });
570
+
571
+ test("no unreplaced DISPATCH_OVERRIDES placeholder", async () => {
572
+ const config = makeConfig();
573
+ const output = await generateOverlay(config);
574
+
575
+ expect(output).not.toContain("{{DISPATCH_OVERRIDES}}");
576
+ });
498
577
  });
499
578
 
500
579
  describe("writeOverlay", () => {
@@ -72,6 +72,43 @@ Do NOT spawn scout agents. Do NOT explore the codebase extensively.
72
72
  Your parent has already gathered the context you need.
73
73
  `;
74
74
 
75
+ /**
76
+ * Build the dispatch overrides section for lead overlays.
77
+ * Only generates content when overrides are actually set.
78
+ * The overlay is the source of truth -- leads read these directives, not mail.
79
+ */
80
+ function formatDispatchOverrides(config: OverlayConfig): string {
81
+ if (config.capability !== "lead") return "";
82
+
83
+ const sections: string[] = [];
84
+
85
+ if (config.skipReview) {
86
+ sections.push(
87
+ "- **SKIP REVIEW**: You have been instructed to skip the review phase. " +
88
+ "Self-verify by reading the diff and running quality gates instead of spawning a reviewer.",
89
+ );
90
+ }
91
+
92
+ if (config.maxAgentsOverride !== undefined && config.maxAgentsOverride > 0) {
93
+ sections.push(
94
+ `- **MAX AGENTS**: Your per-lead agent ceiling has been set to **${config.maxAgentsOverride}**. ` +
95
+ "Do not spawn more than this many sub-workers.",
96
+ );
97
+ }
98
+
99
+ if (sections.length === 0) return "";
100
+
101
+ return [
102
+ "## Dispatch Overrides",
103
+ "",
104
+ "Your coordinator has set the following overrides for this work stream:",
105
+ "",
106
+ ...sections,
107
+ "",
108
+ "Honor these directives. They override the default workflow described in your base definition.",
109
+ ].join("\n");
110
+ }
111
+
75
112
  /**
76
113
  * Format the quality gates section. Read-only agents (scout, reviewer) get
77
114
  * a lightweight section that only tells them to close the issue and report.
@@ -261,7 +298,7 @@ export async function generateOverlay(config: OverlayConfig): Promise<string> {
261
298
 
262
299
  const replacements: Record<string, string> = {
263
300
  "{{AGENT_NAME}}": config.agentName,
264
- "{{BEAD_ID}}": config.taskId,
301
+ "{{TASK_ID}}": config.taskId,
265
302
  "{{SPEC_PATH}}": config.specPath ?? "No spec file provided",
266
303
  "{{BRANCH_NAME}}": config.branchName,
267
304
  "{{WORKTREE_PATH}}": config.worktreePath,
@@ -275,13 +312,14 @@ export async function generateOverlay(config: OverlayConfig): Promise<string> {
275
312
  "{{CONSTRAINTS}}": formatConstraints(config),
276
313
  "{{SPEC_INSTRUCTION}}": specInstruction,
277
314
  "{{SKIP_SCOUT}}": config.skipScout ? SKIP_SCOUT_SECTION : "",
315
+ "{{DISPATCH_OVERRIDES}}": formatDispatchOverrides(config),
278
316
  "{{BASE_DEFINITION}}": config.baseDefinition,
279
317
  "{{QUALITY_GATE_INLINE}}": formatQualityGatesInline(config.qualityGates),
280
318
  "{{QUALITY_GATE_STEPS}}": formatQualityGatesSteps(config.qualityGates),
281
319
  "{{QUALITY_GATE_BASH}}": formatQualityGatesBash(config.qualityGates),
282
320
  "{{QUALITY_GATE_CAPABILITIES}}": formatQualityGatesCapabilities(config.qualityGates),
283
321
  "{{TRACKER_CLI}}": config.trackerCli ?? "bd",
284
- "{{TRACKER_NAME}}": config.trackerName ?? "beads",
322
+ "{{TRACKER_NAME}}": config.trackerName ?? "seeds",
285
323
  };
286
324
 
287
325
  let result = template;
@@ -236,7 +236,7 @@ export const COMMANDS: readonly CommandDef[] = [
236
236
  },
237
237
  {
238
238
  name: "trace",
239
- desc: "Chronological event timeline for agent/bead",
239
+ desc: "Chronological event timeline for agent or task",
240
240
  flags: [
241
241
  { name: "--json", desc: "JSON output" },
242
242
  { name: "--since", desc: "Time range filter (ISO 8601)", takesValue: true },
@@ -366,7 +366,7 @@ export const COMMANDS: readonly CommandDef[] = [
366
366
  name: "start",
367
367
  desc: "Start supervisor",
368
368
  flags: [
369
- { name: "--task", desc: "Bead task ID", takesValue: true },
369
+ { name: "--task", desc: "Task ID", takesValue: true },
370
370
  { name: "--name", desc: "Unique name", takesValue: true },
371
371
  { name: "--parent", desc: "Parent agent", takesValue: true },
372
372
  { name: "--depth", desc: "Hierarchy depth", takesValue: true },
@@ -543,7 +543,7 @@ export const COMMANDS: readonly CommandDef[] = [
543
543
  desc: "Task groups",
544
544
  flags: [
545
545
  { name: "--json", desc: "JSON output" },
546
- { name: "--skip-validation", desc: "Skip beads checks" },
546
+ { name: "--skip-validation", desc: "Skip task validation" },
547
547
  { name: "--help", desc: "Show help" },
548
548
  ],
549
549
  subcommands: [
@@ -2,13 +2,13 @@
2
2
  * CLI command: ov coordinator start|stop|status
3
3
  *
4
4
  * Manages the persistent coordinator agent lifecycle. The coordinator runs
5
- * at the project root (NOT in a worktree), receives work via mail and beads,
5
+ * at the project root (NOT in a worktree), receives work via mail and tasks,
6
6
  * and dispatches agents via ov sling.
7
7
  *
8
8
  * Unlike regular agents spawned by sling, the coordinator:
9
9
  * - Has no worktree (operates on the main working tree)
10
- * - Has no bead assignment (it creates beads, not works on them)
11
- * - Has no overlay CLAUDE.md (context comes via mail + beads + checkpoints)
10
+ * - Has no task assignment (it creates tasks, not works on them)
11
+ * - Has no overlay CLAUDE.md (context comes via mail + tasks + checkpoints)
12
12
  * - Persists across work batches
13
13
  */
14
14
 
@@ -381,7 +381,7 @@ async function startCoordinator(
381
381
  capability: "coordinator",
382
382
  worktreePath: projectRoot, // Coordinator uses project root, not a worktree
383
383
  branchName: config.project.canonicalBranch, // Operates on canonical branch
384
- taskId: "", // No specific bead assignment
384
+ taskId: "", // No specific task assignment
385
385
  tmuxSession,
386
386
  state: "booting",
387
387
  pid,
@@ -2,7 +2,7 @@
2
2
  * CLI command: ov group create|status|add|remove|list
3
3
  *
4
4
  * Manages TaskGroups for batch work coordination. Groups track collections
5
- * of beads issues and auto-close when all member issues are closed.
5
+ * of issues and auto-close when all member issues are closed.
6
6
  *
7
7
  * Storage: `.overstory/groups.json` (array of TaskGroup objects).
8
8
  */
@@ -312,7 +312,7 @@ export function createGroupCommand(): Command {
312
312
  .argument("<name>", "Group name")
313
313
  .argument("<ids...>", "Issue IDs to include")
314
314
  .option("--json", "Output as JSON")
315
- .option("--skip-validation", "Skip beads issue validation (for offline use)")
315
+ .option("--skip-validation", "Skip task validation (for offline use)")
316
316
  .action(
317
317
  async (name: string, ids: string[], opts: { json?: boolean; skipValidation?: boolean }) => {
318
318
  const config = await loadConfig(process.cwd());
@@ -343,7 +343,7 @@ export function createGroupCommand(): Command {
343
343
  .description("Show progress for one or all groups")
344
344
  .argument("[group-id]", "Group ID (optional, shows all if omitted)")
345
345
  .option("--json", "Output as JSON")
346
- .option("--skip-validation", "Skip beads issue validation (for offline use)")
346
+ .option("--skip-validation", "Skip task validation (for offline use)")
347
347
  .action(
348
348
  async (groupId: string | undefined, opts: { json?: boolean; skipValidation?: boolean }) => {
349
349
  const config = await loadConfig(process.cwd());
@@ -398,7 +398,7 @@ export function createGroupCommand(): Command {
398
398
  .argument("<group-id>", "Group ID")
399
399
  .argument("<ids...>", "Issue IDs to add")
400
400
  .option("--json", "Output as JSON")
401
- .option("--skip-validation", "Skip beads issue validation (for offline use)")
401
+ .option("--skip-validation", "Skip task validation (for offline use)")
402
402
  .action(
403
403
  async (
404
404
  groupId: string,
@@ -14,7 +14,7 @@ import { stripAnsi } from "../logging/color.ts";
14
14
  import { createMailClient } from "../mail/client.ts";
15
15
  import { createMailStore } from "../mail/store.ts";
16
16
  import type { StoredEvent } from "../types.ts";
17
- import { mailCommand } from "./mail.ts";
17
+ import { AUTO_NUDGE_TYPES, isDispatchNudge, mailCommand, shouldAutoNudge } from "./mail.ts";
18
18
 
19
19
  describe("mailCommand", () => {
20
20
  let tempDir: string;
@@ -1269,3 +1269,65 @@ describe("mailCommand", () => {
1269
1269
  });
1270
1270
  });
1271
1271
  });
1272
+
1273
+ describe("shouldAutoNudge", () => {
1274
+ test("returns true for urgent priority regardless of type", () => {
1275
+ expect(shouldAutoNudge("status", "urgent")).toBe(true);
1276
+ });
1277
+
1278
+ test("returns true for high priority regardless of type", () => {
1279
+ expect(shouldAutoNudge("status", "high")).toBe(true);
1280
+ });
1281
+
1282
+ test("returns true for worker_done type at normal priority", () => {
1283
+ expect(shouldAutoNudge("worker_done", "normal")).toBe(true);
1284
+ });
1285
+
1286
+ test("returns true for merge_ready type at normal priority", () => {
1287
+ expect(shouldAutoNudge("merge_ready", "normal")).toBe(true);
1288
+ });
1289
+
1290
+ test("returns true for error type at normal priority", () => {
1291
+ expect(shouldAutoNudge("error", "normal")).toBe(true);
1292
+ });
1293
+
1294
+ test("returns false for status type at normal priority", () => {
1295
+ expect(shouldAutoNudge("status", "normal")).toBe(false);
1296
+ });
1297
+
1298
+ test("returns false for question type at low priority", () => {
1299
+ expect(shouldAutoNudge("question", "low")).toBe(false);
1300
+ });
1301
+ });
1302
+
1303
+ describe("isDispatchNudge", () => {
1304
+ test("returns true for dispatch type", () => {
1305
+ expect(isDispatchNudge("dispatch")).toBe(true);
1306
+ });
1307
+
1308
+ test("returns false for worker_done type", () => {
1309
+ expect(isDispatchNudge("worker_done")).toBe(false);
1310
+ });
1311
+
1312
+ test("returns false for status type", () => {
1313
+ expect(isDispatchNudge("status")).toBe(false);
1314
+ });
1315
+
1316
+ test("returns false for error type", () => {
1317
+ expect(isDispatchNudge("error")).toBe(false);
1318
+ });
1319
+ });
1320
+
1321
+ describe("AUTO_NUDGE_TYPES", () => {
1322
+ test("contains worker_done, merge_ready, and error", () => {
1323
+ expect(AUTO_NUDGE_TYPES.has("worker_done")).toBe(true);
1324
+ expect(AUTO_NUDGE_TYPES.has("merge_ready")).toBe(true);
1325
+ expect(AUTO_NUDGE_TYPES.has("error")).toBe(true);
1326
+ });
1327
+
1328
+ test("does not contain regular semantic types", () => {
1329
+ expect(AUTO_NUDGE_TYPES.has("status")).toBe(false);
1330
+ expect(AUTO_NUDGE_TYPES.has("question")).toBe(false);
1331
+ expect(AUTO_NUDGE_TYPES.has("result")).toBe(false);
1332
+ });
1333
+ });
@@ -24,7 +24,7 @@ import { MAIL_MESSAGE_TYPES } from "../types.ts";
24
24
  * Protocol message types that require immediate recipient attention.
25
25
  * These trigger auto-nudge regardless of priority level.
26
26
  */
27
- const AUTO_NUDGE_TYPES: ReadonlySet<MailMessageType> = new Set([
27
+ export const AUTO_NUDGE_TYPES: ReadonlySet<MailMessageType> = new Set([
28
28
  "worker_done",
29
29
  "merge_ready",
30
30
  "error",
@@ -32,6 +32,23 @@ const AUTO_NUDGE_TYPES: ReadonlySet<MailMessageType> = new Set([
32
32
  "merge_failed",
33
33
  ]);
34
34
 
35
+ /**
36
+ * Check if a message type/priority combination should trigger a pending nudge.
37
+ * Exported for testability.
38
+ */
39
+ export function shouldAutoNudge(type: MailMessageType, priority: MailMessage["priority"]): boolean {
40
+ return priority === "urgent" || priority === "high" || AUTO_NUDGE_TYPES.has(type);
41
+ }
42
+
43
+ /**
44
+ * Check if a message type should trigger an immediate tmux dispatch nudge.
45
+ * Dispatch nudges target newly spawned agents at the welcome screen.
46
+ * Exported for testability.
47
+ */
48
+ export function isDispatchNudge(type: MailMessageType): boolean {
49
+ return type === "dispatch";
50
+ }
51
+
35
52
  /** Format a single message for human-readable output. */
36
53
  function formatMessage(msg: MailMessage): string {
37
54
  const readMarker = msg.read ? " " : "*";
@@ -47,7 +47,7 @@ function parseAgentName(branchName: string): string {
47
47
  * Pattern: overstory/{agentName}/{taskId}
48
48
  * Falls back to "unknown" if the pattern does not match.
49
49
  */
50
- function parseBeadId(branchName: string): string {
50
+ function parseTaskId(branchName: string): string {
51
51
  const parts = branchName.split("/");
52
52
  if (parts[0] === "overstory" && parts[2] !== undefined) {
53
53
  return parts[2];
@@ -213,7 +213,7 @@ async function handleBranch(
213
213
  }
214
214
 
215
215
  const agentName = parseAgentName(branchName);
216
- const taskId = parseBeadId(branchName);
216
+ const taskId = parseTaskId(branchName);
217
217
  const filesModified = await detectModifiedFiles(repoRoot, canonicalBranch, branchName);
218
218
 
219
219
  entry = queue.enqueue({
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Unlike regular agents spawned by sling, the monitor:
10
10
  * - Has no worktree (operates on the main working tree)
11
- * - Has no bead assignment (it monitors, not implements)
11
+ * - Has no task assignment (it monitors, not implements)
12
12
  * - Has no overlay CLAUDE.md (context comes via ov status + mail)
13
13
  * - Persists across patrol cycles
14
14
  */
@@ -158,7 +158,7 @@ async function startMonitor(opts: { json: boolean; attach: boolean }): Promise<v
158
158
  capability: "monitor",
159
159
  worktreePath: projectRoot, // Monitor uses project root, not a worktree
160
160
  branchName: config.project.canonicalBranch, // Operates on canonical branch
161
- taskId: "", // No specific bead assignment
161
+ taskId: "", // No specific task assignment
162
162
  tmuxSession,
163
163
  state: "booting",
164
164
  pid,
@@ -8,8 +8,10 @@ import {
8
8
  buildAutoDispatch,
9
9
  buildBeacon,
10
10
  calculateStaggerDelay,
11
- checkBeadLock,
11
+ checkDuplicateLead,
12
+ checkParentAgentLimit,
12
13
  checkRunSessionLimit,
14
+ checkTaskLock,
13
15
  inferDomainsFromFiles,
14
16
  isRunningAsRoot,
15
17
  parentHasScouts,
@@ -554,66 +556,66 @@ describe("isRunningAsRoot", () => {
554
556
  });
555
557
 
556
558
  /**
557
- * Tests for checkBeadLock.
559
+ * Tests for checkTaskLock.
558
560
  *
559
- * checkBeadLock prevents concurrent agents from working the same task ID.
561
+ * checkTaskLock prevents concurrent agents from working the same task ID.
560
562
  * It checks the active session list and returns the agent name that holds
561
- * the lock (i.e., is already working on the bead), or null if the bead is free.
563
+ * the lock (i.e., is already working on the task), or null if the task is free.
562
564
  */
563
565
 
564
- function makeBeadSession(agentName: string, taskId: string): { agentName: string; taskId: string } {
566
+ function makeTaskSession(agentName: string, taskId: string): { agentName: string; taskId: string } {
565
567
  return { agentName, taskId };
566
568
  }
567
569
 
568
- describe("checkBeadLock", () => {
570
+ describe("checkTaskLock", () => {
569
571
  test("returns null when no sessions exist", () => {
570
- expect(checkBeadLock([], "overstory-abc")).toBeNull();
572
+ expect(checkTaskLock([], "overstory-abc")).toBeNull();
571
573
  });
572
574
 
573
575
  test("returns null when no session matches the task ID", () => {
574
576
  const sessions = [
575
- makeBeadSession("builder-1", "overstory-xyz"),
576
- makeBeadSession("builder-2", "overstory-def"),
577
+ makeTaskSession("builder-1", "overstory-xyz"),
578
+ makeTaskSession("builder-2", "overstory-def"),
577
579
  ];
578
580
 
579
- expect(checkBeadLock(sessions, "overstory-abc")).toBeNull();
581
+ expect(checkTaskLock(sessions, "overstory-abc")).toBeNull();
580
582
  });
581
583
 
582
584
  test("returns the agent name when a session matches", () => {
583
585
  const sessions = [
584
- makeBeadSession("builder-1", "overstory-abc"),
585
- makeBeadSession("builder-2", "overstory-xyz"),
586
+ makeTaskSession("builder-1", "overstory-abc"),
587
+ makeTaskSession("builder-2", "overstory-xyz"),
586
588
  ];
587
589
 
588
- expect(checkBeadLock(sessions, "overstory-abc")).toBe("builder-1");
590
+ expect(checkTaskLock(sessions, "overstory-abc")).toBe("builder-1");
589
591
  });
590
592
 
591
593
  test("returns the first matching agent when multiple sessions match", () => {
592
594
  // Multiple sessions can have the same taskId (e.g., retried agent)
593
- // checkBeadLock returns the first match
595
+ // checkTaskLock returns the first match
594
596
  const sessions = [
595
- makeBeadSession("builder-1", "overstory-abc"),
596
- makeBeadSession("builder-2", "overstory-abc"),
597
+ makeTaskSession("builder-1", "overstory-abc"),
598
+ makeTaskSession("builder-2", "overstory-abc"),
597
599
  ];
598
600
 
599
- expect(checkBeadLock(sessions, "overstory-abc")).toBe("builder-1");
601
+ expect(checkTaskLock(sessions, "overstory-abc")).toBe("builder-1");
600
602
  });
601
603
  });
602
604
 
603
- describe("checkBeadLock parent bypass", () => {
605
+ describe("checkTaskLock parent bypass", () => {
604
606
  test("parent matching lock holder is allowed (returns lock holder name for caller to compare)", () => {
605
- // checkBeadLock is a pure function — it returns the lock holder name or null.
606
- // The parent bypass logic is in slingCommand, not checkBeadLock.
607
+ // checkTaskLock is a pure function — it returns the lock holder name or null.
608
+ // The parent bypass logic is in slingCommand, not checkTaskLock.
607
609
  // These tests verify the building blocks work correctly.
608
- const sessions = [makeBeadSession("lead-alpha", "overstory-abc")];
609
- // checkBeadLock still returns the holder — the caller (slingCommand) decides
610
+ const sessions = [makeTaskSession("lead-alpha", "overstory-abc")];
611
+ // checkTaskLock still returns the holder — the caller (slingCommand) decides
610
612
  // whether to allow based on parentAgent match.
611
- expect(checkBeadLock(sessions, "overstory-abc")).toBe("lead-alpha");
613
+ expect(checkTaskLock(sessions, "overstory-abc")).toBe("lead-alpha");
612
614
  });
613
615
 
614
616
  test("non-parent lock holder blocks spawn", () => {
615
- const sessions = [makeBeadSession("other-agent", "overstory-abc")];
616
- const lockHolder = checkBeadLock(sessions, "overstory-abc");
617
+ const sessions = [makeTaskSession("other-agent", "overstory-abc")];
618
+ const lockHolder = checkTaskLock(sessions, "overstory-abc");
617
619
  const parentAgent = "lead-alpha";
618
620
  // lockHolder is 'other-agent', parentAgent is 'lead-alpha' — not equal, should block
619
621
  expect(lockHolder).not.toBeNull();
@@ -621,8 +623,8 @@ describe("checkBeadLock parent bypass", () => {
621
623
  });
622
624
 
623
625
  test("null parent with lock holder blocks spawn", () => {
624
- const sessions = [makeBeadSession("lead-alpha", "overstory-abc")];
625
- const lockHolder = checkBeadLock(sessions, "overstory-abc");
626
+ const sessions = [makeTaskSession("lead-alpha", "overstory-abc")];
627
+ const lockHolder = checkTaskLock(sessions, "overstory-abc");
626
628
  const parentAgent = null;
627
629
  // lockHolder is non-null and parentAgent is null — should block
628
630
  expect(lockHolder).not.toBeNull();
@@ -630,6 +632,71 @@ describe("checkBeadLock parent bypass", () => {
630
632
  });
631
633
  });
632
634
 
635
+ /**
636
+ * Tests for checkDuplicateLead.
637
+ *
638
+ * checkDuplicateLead prevents spawning a second lead agent for the same task ID.
639
+ * It filters the active session list to only "lead" capability sessions, so
640
+ * builder/scout children working the same bead via parent delegation do not
641
+ * trigger this check.
642
+ *
643
+ * The activeSessions input is pre-filtered by store.getActive() to exclude
644
+ * completed and zombie sessions.
645
+ */
646
+
647
+ function makeLeadSession(
648
+ agentName: string,
649
+ taskId: string,
650
+ capability: string,
651
+ ): { agentName: string; taskId: string; capability: string } {
652
+ return { agentName, taskId, capability };
653
+ }
654
+
655
+ describe("checkDuplicateLead", () => {
656
+ test("returns lead agent name when an active lead exists for the task", () => {
657
+ const sessions = [
658
+ makeLeadSession("lead-alpha", "overstory-abc", "lead"),
659
+ makeLeadSession("builder-1", "overstory-xyz", "builder"),
660
+ ];
661
+ expect(checkDuplicateLead(sessions, "overstory-abc")).toBe("lead-alpha");
662
+ });
663
+
664
+ test("returns null when no lead exists for the task", () => {
665
+ const sessions = [
666
+ makeLeadSession("lead-alpha", "overstory-xyz", "lead"),
667
+ makeLeadSession("builder-1", "overstory-abc", "builder"),
668
+ ];
669
+ expect(checkDuplicateLead(sessions, "overstory-abc")).toBeNull();
670
+ });
671
+
672
+ test("returns null when no sessions exist (completed/zombie filtered out)", () => {
673
+ // activeSessions from store.getActive() already excludes completed/zombie
674
+ expect(checkDuplicateLead([], "overstory-abc")).toBeNull();
675
+ });
676
+
677
+ test("ignores non-lead agents working the same bead", () => {
678
+ const sessions = [
679
+ makeLeadSession("builder-1", "overstory-abc", "builder"),
680
+ makeLeadSession("scout-1", "overstory-abc", "scout"),
681
+ makeLeadSession("reviewer-1", "overstory-abc", "reviewer"),
682
+ ];
683
+ expect(checkDuplicateLead(sessions, "overstory-abc")).toBeNull();
684
+ });
685
+
686
+ test("returns first matching lead when multiple leads exist for the same bead", () => {
687
+ const sessions = [
688
+ makeLeadSession("lead-alpha", "overstory-abc", "lead"),
689
+ makeLeadSession("lead-beta", "overstory-abc", "lead"),
690
+ ];
691
+ expect(checkDuplicateLead(sessions, "overstory-abc")).toBe("lead-alpha");
692
+ });
693
+
694
+ test("differentiates between task IDs", () => {
695
+ const sessions = [makeLeadSession("lead-alpha", "overstory-abc", "lead")];
696
+ expect(checkDuplicateLead(sessions, "overstory-xyz")).toBeNull();
697
+ });
698
+ });
699
+
633
700
  /**
634
701
  * Tests for checkRunSessionLimit.
635
702
  *
@@ -660,6 +727,67 @@ describe("checkRunSessionLimit", () => {
660
727
  });
661
728
  });
662
729
 
730
+ describe("checkParentAgentLimit", () => {
731
+ test("returns false when limit is 0 (unlimited)", () => {
732
+ const sessions = [{ parentAgent: "lead-alpha" }, { parentAgent: "lead-alpha" }];
733
+ expect(checkParentAgentLimit(sessions, "lead-alpha", 0)).toBe(false);
734
+ });
735
+
736
+ test("returns false when count is below limit", () => {
737
+ const sessions = [{ parentAgent: "lead-alpha" }];
738
+ expect(checkParentAgentLimit(sessions, "lead-alpha", 5)).toBe(false);
739
+ });
740
+
741
+ test("returns true when count equals limit", () => {
742
+ const sessions = [
743
+ { parentAgent: "lead-alpha" },
744
+ { parentAgent: "lead-alpha" },
745
+ { parentAgent: "lead-alpha" },
746
+ ];
747
+ expect(checkParentAgentLimit(sessions, "lead-alpha", 3)).toBe(true);
748
+ });
749
+
750
+ test("returns true when count exceeds limit", () => {
751
+ const sessions = [
752
+ { parentAgent: "lead-alpha" },
753
+ { parentAgent: "lead-alpha" },
754
+ { parentAgent: "lead-alpha" },
755
+ { parentAgent: "lead-alpha" },
756
+ ];
757
+ expect(checkParentAgentLimit(sessions, "lead-alpha", 3)).toBe(true);
758
+ });
759
+
760
+ test("returns false when limit is negative (treated as unlimited)", () => {
761
+ const sessions = [{ parentAgent: "lead-alpha" }];
762
+ expect(checkParentAgentLimit(sessions, "lead-alpha", -1)).toBe(false);
763
+ });
764
+
765
+ test("only counts children of the specified parent", () => {
766
+ const sessions = [
767
+ { parentAgent: "lead-alpha" },
768
+ { parentAgent: "lead-beta" },
769
+ { parentAgent: "lead-alpha" },
770
+ { parentAgent: "lead-gamma" },
771
+ ];
772
+ expect(checkParentAgentLimit(sessions, "lead-alpha", 3)).toBe(false);
773
+ expect(checkParentAgentLimit(sessions, "lead-alpha", 2)).toBe(true);
774
+ });
775
+
776
+ test("returns false when no sessions match the parent", () => {
777
+ const sessions = [{ parentAgent: "lead-beta" }, { parentAgent: "lead-gamma" }];
778
+ expect(checkParentAgentLimit(sessions, "lead-alpha", 1)).toBe(false);
779
+ });
780
+
781
+ test("ignores sessions with null parent", () => {
782
+ const sessions = [{ parentAgent: null }, { parentAgent: "lead-alpha" }, { parentAgent: null }];
783
+ expect(checkParentAgentLimit(sessions, "lead-alpha", 2)).toBe(false);
784
+ });
785
+
786
+ test("returns false when sessions array is empty", () => {
787
+ expect(checkParentAgentLimit([], "lead-alpha", 5)).toBe(false);
788
+ });
789
+ });
790
+
663
791
  /**
664
792
  * Tests for sling provider env injection building blocks.
665
793
  *
@@ -691,6 +819,7 @@ function makeConfig(
691
819
  staggerDelayMs: 0,
692
820
  maxDepth: 2,
693
821
  maxSessionsPerRun: 0,
822
+ maxAgentsPerLead: 5,
694
823
  },
695
824
  worktrees: { baseDir: ".overstory/worktrees" },
696
825
  taskTracker: { backend: "auto", enabled: false },
@@ -933,3 +1062,21 @@ describe("buildAutoDispatch", () => {
933
1062
  expect(dispatch.to).toBe("my-builder");
934
1063
  });
935
1064
  });
1065
+
1066
+ /**
1067
+ * Beacon verification loop (sling.ts step 13d)
1068
+ *
1069
+ * NOT UNIT-TESTABLE: The beacon verification loop at sling.ts lines 687-698
1070
+ * uses capturePaneContent() to poll tmux and resend the beacon if the agent
1071
+ * is still at the welcome screen ("Try "). This involves real tmux operations
1072
+ * that cannot be reliably mocked without mock.module() (which leaks across
1073
+ * test files — see mx-56558b).
1074
+ *
1075
+ * Manual verification:
1076
+ * 1. `ov sling <task-id> --name test --capability builder`
1077
+ * 2. Watch tmux pane: `tmux capture-pane -t overstory-<project>-test -p`
1078
+ * 3. Verify the beacon text appears and the agent starts processing
1079
+ *
1080
+ * Integration coverage: The beacon loop has been validated through production
1081
+ * agent spawns. Failure mode is agents stuck at welcome screen (overstory-3271).
1082
+ */