@os-eco/overstory-cli 0.8.2 → 0.8.4

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.
package/README.md CHANGED
@@ -19,7 +19,7 @@ Requires [Bun](https://bun.sh) v1.0+, git, and tmux. At least one supported agen
19
19
  - [GitHub Copilot](https://github.com/features/copilot) (`copilot` CLI)
20
20
  - [Codex](https://github.com/openai/codex) (`codex` CLI)
21
21
  - [Gemini CLI](https://github.com/google-gemini/gemini-cli) (`gemini` CLI)
22
- - [Sapling](https://github.com/nichochar/sapling) (`sp` CLI)
22
+ - [Sapling](https://github.com/jayminwest/sapling) (`sp` CLI)
23
23
 
24
24
  ```bash
25
25
  bun install -g @os-eco/overstory-cli
package/agents/builder.md CHANGED
@@ -20,7 +20,7 @@ These are named failures. If you catch yourself doing any of these, stop and cor
20
20
 
21
21
  ## overlay
22
22
 
23
- Your task-specific context (task ID, file scope, spec path, branch name, parent agent) is in `.claude/CLAUDE.md` in your worktree. That file is generated by `ov sling` and tells you WHAT to work on. This file tells you HOW to work.
23
+ Your task-specific context (task ID, file scope, spec path, branch name, parent agent) is in `{{INSTRUCTION_PATH}}` in your worktree. That file is generated by `ov sling` and tells you WHAT to work on. This file tells you HOW to work.
24
24
 
25
25
  ## constraints
26
26
 
@@ -108,7 +108,7 @@ You are an implementation specialist. Given a spec and a set of files you own, y
108
108
 
109
109
  ## workflow
110
110
 
111
- 1. **Read your overlay** at `.claude/CLAUDE.md` in your worktree. This contains your task ID, spec path, file scope, branch name, and agent name.
111
+ 1. **Read your overlay** at `{{INSTRUCTION_PATH}}` in your worktree. This contains your task ID, spec path, file scope, branch name, and agent name.
112
112
  2. **Read the task spec** at the path specified in your overlay. Understand what needs to be built.
113
113
  3. **Load expertise** via `ml prime [domain]` for domains listed in your overlay. Apply existing patterns and conventions.
114
114
  4. **Implement the changes:**
package/agents/lead.md CHANGED
@@ -43,7 +43,7 @@ These are named failures. If you catch yourself doing any of these, stop and cor
43
43
 
44
44
  ## overlay
45
45
 
46
- Your task-specific context (task ID, spec path, hierarchy depth, agent name, whether you can spawn) is in `.claude/CLAUDE.md` in your worktree. That file is generated by `ov sling` and tells you WHAT to coordinate. This file tells you HOW to coordinate.
46
+ Your task-specific context (task ID, spec path, hierarchy depth, agent name, whether you can spawn) is in `{{INSTRUCTION_PATH}}` in your worktree. That file is generated by `ov sling` and tells you WHAT to coordinate. This file tells you HOW to coordinate.
47
47
 
48
48
  ## constraints
49
49
 
@@ -160,7 +160,7 @@ Action: Full Scout → Build → Verify pipeline. Spawn scouts for exploration,
160
160
 
161
161
  Delegate exploration to scouts so you can focus on decomposition and planning.
162
162
 
163
- 1. **Read your overlay** at `.claude/CLAUDE.md` in your worktree. This contains your task ID, hierarchy depth, and agent name.
163
+ 1. **Read your overlay** at `{{INSTRUCTION_PATH}}` in your worktree. This contains your task ID, hierarchy depth, and agent name.
164
164
  2. **Load expertise** via `ml prime [domain]` for relevant domains.
165
165
  3. **Search mulch for relevant context** before decomposing. Run `ml search <task keywords>` and review failure patterns, conventions, and decisions. Factor these insights into your specs.
166
166
  4. **Load file-specific expertise** if files are known. Use `ml prime --files <file1,file2,...>` to get file-scoped context. Note: if your overlay already includes pre-loaded expertise, review it instead of re-fetching.
package/agents/merger.md CHANGED
@@ -19,7 +19,7 @@ These are named failures. If you catch yourself doing any of these, stop and cor
19
19
 
20
20
  ## overlay
21
21
 
22
- Your task-specific context (task ID, branches to merge, target branch, merge order, parent agent) is in `.claude/CLAUDE.md` in your worktree. That file is generated by `overstory sling` and tells you WHAT to merge. This file tells you HOW to merge.
22
+ Your task-specific context (task ID, branches to merge, target branch, merge order, parent agent) is in `{{INSTRUCTION_PATH}}` in your worktree. That file is generated by `overstory sling` and tells you WHAT to merge. This file tells you HOW to merge.
23
23
 
24
24
  ## constraints
25
25
 
@@ -97,7 +97,7 @@ You are a branch integration specialist. When workers complete their tasks on se
97
97
 
98
98
  ## workflow
99
99
 
100
- 1. **Read your overlay** at `.claude/CLAUDE.md` in your worktree. This contains your task ID, the branches to merge, the target branch, and your agent name.
100
+ 1. **Read your overlay** at `{{INSTRUCTION_PATH}}` in your worktree. This contains your task ID, the branches to merge, the target branch, and your agent name.
101
101
  2. **Read the task spec** at the path specified in your overlay. Understand which branches need merging and in what order.
102
102
  3. **Review the branches** before merging:
103
103
  - `git log <target>..<branch>` to see what each branch contains.
@@ -31,7 +31,7 @@ These are named failures. If you catch yourself doing any of these, stop and cor
31
31
 
32
32
  ## overlay
33
33
 
34
- Your task-specific context (task ID, file scope, spec path, branch name, parent agent) is in `.claude/CLAUDE.md` in your worktree. That file is generated by `ov sling` and tells you WHAT to work on. This file tells you HOW to work.
34
+ Your task-specific context (task ID, file scope, spec path, branch name, parent agent) is in `{{INSTRUCTION_PATH}}` in your worktree. That file is generated by `ov sling` and tells you WHAT to work on. This file tells you HOW to work.
35
35
 
36
36
  ## constraints
37
37
 
@@ -16,7 +16,7 @@ These are named failures. If you catch yourself doing any of these, stop and cor
16
16
 
17
17
  ## overlay
18
18
 
19
- Your task-specific context (task ID, code to review, branch name, parent agent) is in `.claude/CLAUDE.md` in your worktree. That file is generated by `overstory sling` and tells you WHAT to review. This file tells you HOW to review.
19
+ Your task-specific context (task ID, code to review, branch name, parent agent) is in `{{INSTRUCTION_PATH}}` in your worktree. That file is generated by `overstory sling` and tells you WHAT to review. This file tells you HOW to review.
20
20
 
21
21
  ## constraints
22
22
 
@@ -95,7 +95,7 @@ You are a validation specialist. Given code to review, you check it for correctn
95
95
 
96
96
  ## workflow
97
97
 
98
- 1. **Read your overlay** at `.claude/CLAUDE.md` in your worktree. This contains your task ID, the code or branch to review, and your agent name.
98
+ 1. **Read your overlay** at `{{INSTRUCTION_PATH}}` in your worktree. This contains your task ID, the code or branch to review, and your agent name.
99
99
  2. **Read the task spec** at the path specified in your overlay. Understand what was supposed to be built.
100
100
  3. **Load expertise** via `ml prime [domain]` to understand project conventions and standards.
101
101
  4. **Review the code changes:**
package/agents/scout.md CHANGED
@@ -16,7 +16,7 @@ These are named failures. If you catch yourself doing any of these, stop and cor
16
16
 
17
17
  ## overlay
18
18
 
19
- Your task-specific context (what to explore, who spawned you, your agent name) is in `.claude/CLAUDE.md` in your worktree. That file is generated by `overstory sling` and tells you WHAT to work on. This file tells you HOW to work.
19
+ Your task-specific context (what to explore, who spawned you, your agent name) is in `{{INSTRUCTION_PATH}}` in your worktree. That file is generated by `overstory sling` and tells you WHAT to work on. This file tells you HOW to work.
20
20
 
21
21
  ## constraints
22
22
 
@@ -97,7 +97,7 @@ You perform reconnaissance. Given a research question, exploration target, or an
97
97
 
98
98
  ## workflow
99
99
 
100
- 1. **Read your overlay** at `.claude/CLAUDE.md` in your worktree. This contains your task assignment, spec path, and agent name.
100
+ 1. **Read your overlay** at `{{INSTRUCTION_PATH}}` in your worktree. This contains your task assignment, spec path, and agent name.
101
101
  2. **Read the task spec** at the path specified in your overlay.
102
102
  3. **Load relevant expertise** via `ml prime [domain]` for domains listed in your overlay.
103
103
  4. **Explore systematically:**
@@ -31,7 +31,7 @@ These are named failures. If you catch yourself doing any of these, stop and cor
31
31
 
32
32
  ## overlay
33
33
 
34
- Unlike the coordinator (which has no overlay), you receive your task-specific context via the overlay CLAUDE.md at `.claude/CLAUDE.md` in your worktree root. This file is generated by `ov supervisor start` (or `ov sling` with `--capability supervisor`) and provides:
34
+ Unlike the coordinator (which has no overlay), you receive your task-specific context via the overlay CLAUDE.md at `{{INSTRUCTION_PATH}}` in your worktree root. This file is generated by `ov supervisor start` (or `ov sling` with `--capability supervisor`) and provides:
35
35
 
36
36
  - **Agent Name** (`$OVERSTORY_AGENT_NAME`) -- your mail address
37
37
  - **Task ID** -- the issue you are assigned to
@@ -163,7 +163,7 @@ Before spawning, check `ov status` to ensure non-overlapping file scope across a
163
163
 
164
164
  ## workflow
165
165
 
166
- 1. **Receive the dispatch.** Your overlay (`.claude/CLAUDE.md`) contains your task ID and spec path. The coordinator sends you a `dispatch` mail with task details.
166
+ 1. **Receive the dispatch.** Your overlay (`{{INSTRUCTION_PATH}}`) contains your task ID and spec path. The coordinator sends you a `dispatch` mail with task details.
167
167
  2. **Read your task spec** at the path specified in your overlay. Understand the full scope of work assigned to you.
168
168
  3. **Load expertise** via `ml prime [domain]` for each relevant domain. Check `{{TRACKER_CLI}} show <task-id>` for task details and dependencies.
169
169
  4. **Analyze scope and decompose.** Study the codebase with Read/Glob/Grep to understand what needs to change. Determine:
@@ -418,7 +418,7 @@ You are long-lived within a project. You survive across batches and can recover
418
418
  - **Checkpoints** are saved to `.overstory/agents/$OVERSTORY_AGENT_NAME/checkpoint.json` before compaction or handoff. The checkpoint contains: agent name, assigned task ID, active worker IDs, task group ID, session ID, progress summary, and files modified.
419
419
  - **On recovery**, reload context by:
420
420
  1. Reading your checkpoint: `.overstory/agents/$OVERSTORY_AGENT_NAME/checkpoint.json`
421
- 2. Reading your overlay: `.claude/CLAUDE.md` (task ID, spec path, depth, parent)
421
+ 2. Reading your overlay: `{{INSTRUCTION_PATH}}` (task ID, spec path, depth, parent)
422
422
  3. Checking active group: `ov group status <group-id>`
423
423
  4. Checking worker states: `ov status`
424
424
  5. Checking unread mail: `ov mail check --agent $OVERSTORY_AGENT_NAME`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@os-eco/overstory-cli",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "Multi-agent orchestration for AI coding agents — spawn workers in git worktrees via tmux, coordinate through SQLite mail, merge with tiered conflict resolution. Pluggable runtime adapters for Claude Code, Pi, and more.",
5
5
  "author": "Jaymin West",
6
6
  "license": "MIT",
@@ -875,6 +875,48 @@ describe("formatQualityGatesCapabilities", () => {
875
875
  });
876
876
  });
877
877
 
878
+ describe("INSTRUCTION_PATH placeholder", () => {
879
+ test("defaults to .claude/CLAUDE.md when instructionPath is not set", async () => {
880
+ const config = makeConfig({
881
+ baseDefinition: "Read your overlay at {{INSTRUCTION_PATH}} in your worktree.",
882
+ });
883
+ const output = await generateOverlay(config);
884
+
885
+ expect(output).toContain("Read your overlay at .claude/CLAUDE.md in your worktree.");
886
+ expect(output).not.toContain("{{INSTRUCTION_PATH}}");
887
+ });
888
+
889
+ test("uses custom instructionPath when set", async () => {
890
+ const config = makeConfig({
891
+ instructionPath: "SAPLING.md",
892
+ baseDefinition: "Read your overlay at {{INSTRUCTION_PATH}} in your worktree.",
893
+ });
894
+ const output = await generateOverlay(config);
895
+
896
+ expect(output).toContain("Read your overlay at SAPLING.md in your worktree.");
897
+ expect(output).not.toContain("{{INSTRUCTION_PATH}}");
898
+ expect(output).not.toContain(".claude/CLAUDE.md");
899
+ });
900
+
901
+ test("INSTRUCTION_PATH in base definition replaced throughout (multiple occurrences)", async () => {
902
+ const config = makeConfig({
903
+ instructionPath: "AGENTS.md",
904
+ baseDefinition: "Step 1: read {{INSTRUCTION_PATH}}.\nContext is in {{INSTRUCTION_PATH}}.",
905
+ });
906
+ const output = await generateOverlay(config);
907
+
908
+ expect(output).not.toContain("{{INSTRUCTION_PATH}}");
909
+ expect(output.split("AGENTS.md").length - 1).toBeGreaterThanOrEqual(2);
910
+ });
911
+
912
+ test("no unreplaced INSTRUCTION_PATH placeholders in final output", async () => {
913
+ const config = makeConfig({ instructionPath: "SAPLING.md" });
914
+ const output = await generateOverlay(config);
915
+
916
+ expect(output).not.toContain("{{INSTRUCTION_PATH}}");
917
+ });
918
+ });
919
+
878
920
  describe("quality gate placeholders in base definitions", () => {
879
921
  test("QUALITY_GATE_INLINE in base definition gets replaced", async () => {
880
922
  const config = makeConfig({
@@ -320,6 +320,7 @@ export async function generateOverlay(config: OverlayConfig): Promise<string> {
320
320
  "{{QUALITY_GATE_CAPABILITIES}}": formatQualityGatesCapabilities(config.qualityGates),
321
321
  "{{TRACKER_CLI}}": config.trackerCli ?? "sd",
322
322
  "{{TRACKER_NAME}}": config.trackerName ?? "seeds",
323
+ "{{INSTRUCTION_PATH}}": config.instructionPath ?? ".claude/CLAUDE.md",
323
324
  };
324
325
 
325
326
  let result = template;
@@ -10,7 +10,7 @@ import { loadConfig } from "../config.ts";
10
10
  import { ValidationError } from "../errors.ts";
11
11
  import { jsonOutput } from "../json.ts";
12
12
  import { accent, color } from "../logging/color.ts";
13
- import { getRuntime } from "../runtimes/registry.ts";
13
+ import { getAllRuntimes, getRuntime } from "../runtimes/registry.ts";
14
14
  import { openSessionStore } from "../sessions/compat.ts";
15
15
  import { type AgentSession, SUPPORTED_CAPABILITIES } from "../types.ts";
16
16
 
@@ -30,12 +30,10 @@ export interface DiscoveredAgent {
30
30
  lastActivity: string;
31
31
  }
32
32
 
33
- /** Known instruction file paths, tried in order until one exists. */
34
- const KNOWN_INSTRUCTION_PATHS = [
35
- join(".claude", "CLAUDE.md"), // Claude Code, Pi
36
- "AGENTS.md", // Codex (future)
37
- "GEMINI.md", // Gemini CLI
38
- ];
33
+ /** Build the list of known instruction file paths from all registered runtimes. */
34
+ function getKnownInstructionPaths(): string[] {
35
+ return [...new Set(getAllRuntimes().map((r) => r.instructionPath))];
36
+ }
39
37
 
40
38
  /**
41
39
  * Extract file scope from an agent's overlay instruction file.
@@ -52,9 +50,10 @@ export async function extractFileScope(
52
50
  ): Promise<string[]> {
53
51
  try {
54
52
  let content: string | null = null;
53
+ const knownPaths = getKnownInstructionPaths();
55
54
  const pathsToTry = runtimeInstructionPath
56
- ? [runtimeInstructionPath, ...KNOWN_INSTRUCTION_PATHS]
57
- : KNOWN_INSTRUCTION_PATHS;
55
+ ? [runtimeInstructionPath, ...knownPaths]
56
+ : knownPaths;
58
57
  for (const relPath of pathsToTry) {
59
58
  const overlayPath = join(worktreePath, relPath);
60
59
  const overlayFile = Bun.file(overlayPath);
@@ -363,7 +363,7 @@ async function startCoordinator(
363
363
  );
364
364
  const manifest = await manifestLoader.load();
365
365
  const resolvedModel = resolveModel(config, manifest, "coordinator", "opus");
366
- const runtime = getRuntime(undefined, config);
366
+ const runtime = getRuntime(undefined, config, "coordinator");
367
367
 
368
368
  // Deploy hooks to the project root so the coordinator gets event logging,
369
369
  // mail check --inject, and activity tracking via the standard hook pipeline.
@@ -14,9 +14,11 @@ import { ValidationError } from "../errors.ts";
14
14
  import { jsonError, jsonOutput } from "../json.ts";
15
15
  import { color } from "../logging/color.ts";
16
16
  import { renderHeader, separator } from "../logging/theme.ts";
17
+ import { estimateCost } from "../metrics/pricing.ts";
17
18
  import { createMetricsStore } from "../metrics/store.ts";
18
- import { estimateCost, parseTranscriptUsage } from "../metrics/transcript.ts";
19
+ import { parseTranscriptUsage } from "../metrics/transcript.ts";
19
20
  import { getRuntime } from "../runtimes/registry.ts";
21
+ import type { AgentRuntime } from "../runtimes/types.ts";
20
22
  import { openSessionStore } from "../sessions/compat.ts";
21
23
  import type { SessionMetrics } from "../types.ts";
22
24
 
@@ -43,41 +45,21 @@ function padLeft(str: string, width: number): string {
43
45
  return str.length >= width ? str : " ".repeat(width - str.length) + str;
44
46
  }
45
47
 
46
- /**
47
- * Resolve the transcript directory for a given runtime and project root.
48
- *
49
- * @param runtimeId - The runtime identifier (e.g. "claude")
50
- * @param projectRoot - Absolute path to the project root
51
- * @returns Absolute path to the transcript directory, or null if not supported
52
- */
53
- function getTranscriptDir(runtimeId: string, projectRoot: string): string | null {
54
- const homeDir = process.env.HOME ?? "";
55
- if (homeDir.length === 0) return null;
56
- switch (runtimeId) {
57
- case "claude": {
58
- const projectKey = projectRoot.replace(/\//g, "-");
59
- return join(homeDir, ".claude", "projects", projectKey);
60
- }
61
- default:
62
- return null;
63
- }
64
- }
65
-
66
48
  /**
67
49
  * Discover the orchestrator's transcript JSONL file for the given runtime.
68
50
  *
69
51
  * Scans the runtime-specific transcript directory for JSONL files and returns
70
52
  * the most recently modified one, corresponding to the current orchestrator session.
71
53
  *
72
- * @param runtimeId - The runtime identifier (e.g. "claude")
54
+ * @param runtime - The agent runtime adapter
73
55
  * @param projectRoot - Absolute path to the project root
74
56
  * @returns Absolute path to the most recent transcript, or null if none found
75
57
  */
76
58
  async function discoverOrchestratorTranscript(
77
- runtimeId: string,
59
+ runtime: AgentRuntime,
78
60
  projectRoot: string,
79
61
  ): Promise<string | null> {
80
- const transcriptDir = getTranscriptDir(runtimeId, projectRoot);
62
+ const transcriptDir = runtime.getTranscriptDir(projectRoot);
81
63
  if (transcriptDir === null) return null;
82
64
 
83
65
  let entries: string[];
@@ -292,7 +274,7 @@ async function executeCosts(opts: CostsOpts): Promise<void> {
292
274
  // Handle --self flag (early return for self-scan)
293
275
  if (self) {
294
276
  const runtime = getRuntime(undefined, config);
295
- const transcriptPath = await discoverOrchestratorTranscript(runtime.id, config.project.root);
277
+ const transcriptPath = await discoverOrchestratorTranscript(runtime, config.project.root);
296
278
  if (!transcriptPath) {
297
279
  if (json) {
298
280
  jsonError("costs", `No transcript found for runtime '${runtime.id}'`);
@@ -21,8 +21,9 @@ import { analyzeSessionInsights } from "../insights/analyzer.ts";
21
21
  import { createLogger } from "../logging/logger.ts";
22
22
  import { createMailClient } from "../mail/client.ts";
23
23
  import { createMailStore } from "../mail/store.ts";
24
+ import { estimateCost } from "../metrics/pricing.ts";
24
25
  import { createMetricsStore } from "../metrics/store.ts";
25
- import { estimateCost, parseTranscriptUsage } from "../metrics/transcript.ts";
26
+ import { parseTranscriptUsage } from "../metrics/transcript.ts";
26
27
  import { createMulchClient, type MulchClient } from "../mulch/client.ts";
27
28
  import { openSessionStore } from "../sessions/compat.ts";
28
29
  import { createRunStore } from "../sessions/store.ts";
@@ -117,7 +117,7 @@ async function startMonitor(opts: { json: boolean; attach: boolean }): Promise<v
117
117
  );
118
118
  const manifest = await manifestLoader.load();
119
119
  const resolvedModel = resolveModel(config, manifest, "monitor", "sonnet");
120
- const runtime = getRuntime(undefined, config);
120
+ const runtime = getRuntime(undefined, config, "monitor");
121
121
 
122
122
  // Deploy monitor-specific hooks to the project root's .claude/ directory.
123
123
  await runtime.deployConfig(projectRoot, undefined, {
@@ -20,6 +20,7 @@ import {
20
20
  checkRunSessionLimit,
21
21
  checkTaskLock,
22
22
  extractMulchRecordIds,
23
+ generateAgentName,
23
24
  getCurrentBranch,
24
25
  inferDomainsFromFiles,
25
26
  isRunningAsRoot,
@@ -342,6 +343,31 @@ describe("shouldShowScoutWarning", () => {
342
343
  });
343
344
  });
344
345
 
346
+ describe("generateAgentName", () => {
347
+ test("returns capability-taskId when no collision", () => {
348
+ expect(generateAgentName("builder", "overstory-2f10", [])).toBe("builder-overstory-2f10");
349
+ });
350
+
351
+ test("returns capability-taskId when takenNames is empty", () => {
352
+ expect(generateAgentName("scout", "task-123", [])).toBe("scout-task-123");
353
+ });
354
+
355
+ test("appends -2 when base name is taken", () => {
356
+ expect(generateAgentName("builder", "overstory-2f10", ["builder-overstory-2f10"])).toBe(
357
+ "builder-overstory-2f10-2",
358
+ );
359
+ });
360
+
361
+ test("skips taken suffixes and returns -3 when -2 is also taken", () => {
362
+ expect(
363
+ generateAgentName("builder", "overstory-2f10", [
364
+ "builder-overstory-2f10",
365
+ "builder-overstory-2f10-2",
366
+ ]),
367
+ ).toBe("builder-overstory-2f10-3");
368
+ });
369
+ });
370
+
345
371
  /**
346
372
  * Tests for hierarchy validation in sling.
347
373
  *
@@ -352,14 +378,12 @@ describe("shouldShowScoutWarning", () => {
352
378
  */
353
379
 
354
380
  describe("validateHierarchy", () => {
355
- test("rejects builder when parentAgent is null", () => {
356
- expect(() => validateHierarchy(null, "builder", "test-builder", 0, false)).toThrow(
357
- HierarchyError,
358
- );
381
+ test("allows builder when parentAgent is null", () => {
382
+ expect(() => validateHierarchy(null, "builder", "test-builder", 0, false)).not.toThrow();
359
383
  });
360
384
 
361
- test("rejects scout when parentAgent is null", () => {
362
- expect(() => validateHierarchy(null, "scout", "test-scout", 0, false)).toThrow(HierarchyError);
385
+ test("allows scout when parentAgent is null", () => {
386
+ expect(() => validateHierarchy(null, "scout", "test-scout", 0, false)).not.toThrow();
363
387
  });
364
388
 
365
389
  test("rejects reviewer when parentAgent is null", () => {
@@ -404,15 +428,15 @@ describe("validateHierarchy", () => {
404
428
 
405
429
  test("error has correct fields and code", () => {
406
430
  try {
407
- validateHierarchy(null, "builder", "my-builder", 0, false);
431
+ validateHierarchy(null, "reviewer", "my-reviewer", 0, false);
408
432
  expect.unreachable("should have thrown");
409
433
  } catch (err) {
410
434
  expect(err).toBeInstanceOf(HierarchyError);
411
435
  const he = err as HierarchyError;
412
436
  expect(he.code).toBe("HIERARCHY_VIOLATION");
413
- expect(he.agentName).toBe("my-builder");
414
- expect(he.requestedCapability).toBe("builder");
415
- expect(he.message).toContain("builder");
437
+ expect(he.agentName).toBe("my-reviewer");
438
+ expect(he.requestedCapability).toBe("reviewer");
439
+ expect(he.message).toContain("reviewer");
416
440
  expect(he.message).toContain("lead");
417
441
  }
418
442
  });
@@ -32,7 +32,6 @@ import { printSuccess } from "../logging/color.ts";
32
32
  import { createMailClient } from "../mail/client.ts";
33
33
  import { createMailStore } from "../mail/store.ts";
34
34
  import { createMulchClient } from "../mulch/client.ts";
35
- import { setConnection } from "../runtimes/connections.ts";
36
35
  import { getRuntime } from "../runtimes/registry.ts";
37
36
  import { openSessionStore } from "../sessions/compat.ts";
38
37
  import { createRunStore } from "../sessions/store.ts";
@@ -78,6 +77,29 @@ export function calculateStaggerDelay(
78
77
  return remaining > 0 ? remaining : 0;
79
78
  }
80
79
 
80
+ /**
81
+ * Generate a unique agent name from capability and taskId.
82
+ * Base: capability-taskId. If that collides with takenNames,
83
+ * appends -2, -3, etc. up to 100. Falls back to -Date.now() for guaranteed uniqueness.
84
+ */
85
+ export function generateAgentName(
86
+ capability: string,
87
+ taskId: string,
88
+ takenNames: readonly string[],
89
+ ): string {
90
+ const base = `${capability}-${taskId}`;
91
+ if (!takenNames.includes(base)) {
92
+ return base;
93
+ }
94
+ for (let i = 2; i <= 100; i++) {
95
+ const candidate = `${base}-${i}`;
96
+ if (!takenNames.includes(candidate)) {
97
+ return candidate;
98
+ }
99
+ }
100
+ return `${base}-${Date.now()}`;
101
+ }
102
+
81
103
  /**
82
104
  * Check if the current process is running as root (UID 0).
83
105
  * Returns true if running as root, false otherwise.
@@ -348,9 +370,10 @@ export function validateHierarchy(
348
370
  return;
349
371
  }
350
372
 
351
- if (parentAgent === null && capability !== "lead") {
373
+ const directSpawnCapabilities = ["lead", "scout", "builder"];
374
+ if (parentAgent === null && !directSpawnCapabilities.includes(capability)) {
352
375
  throw new HierarchyError(
353
- `Coordinator cannot spawn "${capability}" directly. Only "lead" is allowed without --parent. Use a lead as intermediary, or pass --force-hierarchy to bypass.`,
376
+ `Coordinator cannot spawn "${capability}" directly. Only lead, scout, and builder are allowed without --parent. Use a lead as intermediary, or pass --force-hierarchy to bypass.`,
354
377
  { agentName: name, requestedCapability: capability },
355
378
  );
356
379
  }
@@ -429,7 +452,9 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
429
452
  }
430
453
 
431
454
  const capability = opts.capability ?? "builder";
432
- const name = opts.name;
455
+ const rawName = opts.name?.trim() ?? "";
456
+ const nameWasAutoGenerated = rawName.length === 0;
457
+ let name = nameWasAutoGenerated ? `${capability}-${taskId}` : rawName;
433
458
  const specPath = opts.spec ?? null;
434
459
  const filesRaw = opts.files;
435
460
  const parentAgent = opts.parent ?? null;
@@ -439,10 +464,6 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
439
464
  const skipScout = opts.skipScout ?? false;
440
465
  const skipTaskCheck = opts.skipTaskCheck ?? false;
441
466
 
442
- if (!name || name.trim().length === 0) {
443
- throw new ValidationError("--name is required for sling", { field: "name" });
444
- }
445
-
446
467
  if (Number.isNaN(depth) || depth < 0) {
447
468
  throw new ValidationError("--depth must be a non-negative integer", {
448
469
  field: "depth",
@@ -597,11 +618,16 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
597
618
  );
598
619
  }
599
620
 
600
- const existing = store.getByName(name);
601
- if (existing && existing.state !== "zombie" && existing.state !== "completed") {
602
- throw new AgentError(`Agent name "${name}" is already in use (state: ${existing.state})`, {
603
- agentName: name,
604
- });
621
+ if (nameWasAutoGenerated) {
622
+ const takenNames = activeSessions.map((s) => s.agentName);
623
+ name = generateAgentName(capability, taskId, takenNames);
624
+ } else {
625
+ const existing = store.getByName(name);
626
+ if (existing && existing.state !== "zombie" && existing.state !== "completed") {
627
+ throw new AgentError(`Agent name "${name}" is already in use (state: ${existing.state})`, {
628
+ agentName: name,
629
+ });
630
+ }
605
631
  }
606
632
 
607
633
  // 5d. Task-level locking: prevent concurrent agents on the same task ID.
@@ -717,6 +743,9 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
717
743
  }
718
744
  }
719
745
 
746
+ // Resolve runtime before overlayConfig so we can pass runtime.instructionPath
747
+ const runtime = getRuntime(opts.runtime, config, capability);
748
+
720
749
  const overlayConfig: OverlayConfig = {
721
750
  agentName: name,
722
751
  taskId: taskId,
@@ -742,11 +771,9 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
742
771
  qualityGates: config.project.qualityGates,
743
772
  trackerCli: trackerCliName(resolvedBackend),
744
773
  trackerName: resolvedBackend,
774
+ instructionPath: runtime.instructionPath,
745
775
  };
746
776
 
747
- // Resolve runtime before writeOverlay so we can pass runtime.instructionPath
748
- const runtime = getRuntime(opts.runtime, config);
749
-
750
777
  try {
751
778
  await writeOverlay(worktreePath, overlayConfig, config.project.root, runtime.instructionPath);
752
779
  } catch (err) {
@@ -854,14 +881,14 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
854
881
  });
855
882
 
856
883
  // Create a timestamped log dir for this headless agent session.
857
- // Redirecting stdout/stderr to files prevents OS pipe buffer backpressure:
858
- // when nobody reads the pipe, the child blocks on write() after ~64 KB and
859
- // becomes a zombie. File writes have no such limit.
884
+ // Always redirect stdout to a file. This prevents SIGPIPE death:
885
+ // ov sling exits after spawning, closing the pipe's read end.
886
+ // If stdout is a pipe, the agent dies on the next write (SIGPIPE).
887
+ // File writes have no such limit, and the agent survives the CLI exit.
860
888
  //
861
- // Exception: RPC-capable runtimes need a live stdout pipe to receive
862
- // JSON-RPC 2.0 responses (getState). In that case stdoutFile is omitted
863
- // and the caller consumes the stream via the RuntimeConnection.
864
- const hasRpcConnect = typeof runtime.connect === "function";
889
+ // Note: RPC connection wiring is intentionally omitted here. The RPC pipe
890
+ // is only useful when the spawner stays alive to consume it. ov sling is
891
+ // a short-lived CLI any connection created here dies with the process.
865
892
  const logTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
866
893
  const agentLogDir = join(overstoryDir, "logs", name, logTimestamp);
867
894
  mkdirSync(agentLogDir, { recursive: true });
@@ -869,21 +896,10 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
869
896
  const headlessProc = await spawnHeadlessAgent(argv, {
870
897
  cwd: worktreePath,
871
898
  env: { ...(process.env as Record<string, string>), ...directEnv },
872
- stdoutFile: hasRpcConnect ? undefined : join(agentLogDir, "stdout.log"),
899
+ stdoutFile: join(agentLogDir, "stdout.log"),
873
900
  stderrFile: join(agentLogDir, "stderr.log"),
874
901
  });
875
902
 
876
- // Wire up RPC connection for runtimes that support it (e.g., Sapling).
877
- // The connection is stored in the module-level registry so the watchdog
878
- // and other subsystems can call getState() for health checks.
879
- if (hasRpcConnect && headlessProc.stdout && runtime.connect) {
880
- const connection = runtime.connect({
881
- stdin: headlessProc.stdin,
882
- stdout: headlessProc.stdout,
883
- });
884
- setConnection(name, connection);
885
- }
886
-
887
903
  // 13. Record session with empty tmuxSession (no tmux pane for headless agents).
888
904
  const session: AgentSession = {
889
905
  id: `session-${Date.now()}-${name}`,