@os-eco/overstory-cli 0.8.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@os-eco/overstory-cli",
3
- "version": "0.8.3",
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",
@@ -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, {
@@ -744,7 +744,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
744
744
  }
745
745
 
746
746
  // Resolve runtime before overlayConfig so we can pass runtime.instructionPath
747
- const runtime = getRuntime(opts.runtime, config);
747
+ const runtime = getRuntime(opts.runtime, config, capability);
748
748
 
749
749
  const overlayConfig: OverlayConfig = {
750
750
  agentName: name,
@@ -143,7 +143,7 @@ async function startSupervisor(opts: {
143
143
  );
144
144
  const manifest = await manifestLoader.load();
145
145
  const resolvedModel = resolveModel(config, manifest, "supervisor", "opus");
146
- const runtime = getRuntime(undefined, config);
146
+ const runtime = getRuntime(undefined, config, "supervisor");
147
147
 
148
148
  // Deploy supervisor-specific hooks to the project root's .claude/ directory.
149
149
  await runtime.deployConfig(projectRoot, undefined, {
package/src/config.ts CHANGED
@@ -706,6 +706,17 @@ function validateConfig(config: OverstoryConfig): void {
706
706
  }
707
707
  }
708
708
 
709
+ if (config.runtime?.capabilities) {
710
+ for (const [cap, runtimeName] of Object.entries(config.runtime.capabilities)) {
711
+ if (runtimeName !== undefined && (typeof runtimeName !== "string" || runtimeName === "")) {
712
+ throw new ValidationError(`runtime.capabilities.${cap} must be a non-empty string`, {
713
+ field: `runtime.capabilities.${cap}`,
714
+ value: runtimeName,
715
+ });
716
+ }
717
+ }
718
+ }
719
+
709
720
  // models: validate each value.
710
721
  // - Standard runtimes: aliases (sonnet/opus/haiku) or provider-prefixed refs.
711
722
  // - Codex runtime: also allow bare model refs (e.g. gpt-5.3-codex).
package/src/index.ts CHANGED
@@ -49,7 +49,7 @@ import { ConfigError, OverstoryError, WorktreeError } from "./errors.ts";
49
49
  import { jsonError } from "./json.ts";
50
50
  import { brand, chalk, muted, setQuiet } from "./logging/color.ts";
51
51
 
52
- export const VERSION = "0.8.3";
52
+ export const VERSION = "0.8.4";
53
53
 
54
54
  const rawArgs = process.argv.slice(2);
55
55
 
@@ -290,6 +290,105 @@ describe("createMergeResolver", () => {
290
290
  });
291
291
  });
292
292
 
293
+ describe("Dirty working tree pre-check", () => {
294
+ test("throws MergeError when unstaged changes exist on tracked files", async () => {
295
+ const repoDir = await createTempGitRepo();
296
+ try {
297
+ const defaultBranch = await getDefaultBranch(repoDir);
298
+ // Create a tracked file and then leave it modified (unstaged)
299
+ await commitFile(repoDir, "src/main.ts", "original content\n");
300
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
301
+ await commitFile(repoDir, "src/feature.ts", "feature content\n");
302
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
303
+ // Modify a tracked file without staging
304
+ await Bun.write(`${repoDir}/src/main.ts`, "modified content\n");
305
+
306
+ const entry = makeTestEntry({
307
+ branchName: "feature-branch",
308
+ filesModified: ["src/feature.ts"],
309
+ });
310
+
311
+ const resolver = createMergeResolver({
312
+ aiResolveEnabled: false,
313
+ reimagineEnabled: false,
314
+ });
315
+
316
+ await expect(resolver.resolve(entry, defaultBranch, repoDir)).rejects.toThrow(MergeError);
317
+ } finally {
318
+ await cleanupTempDir(repoDir);
319
+ }
320
+ });
321
+
322
+ test("throws MergeError with message listing dirty files", async () => {
323
+ const repoDir = await createTempGitRepo();
324
+ try {
325
+ const defaultBranch = await getDefaultBranch(repoDir);
326
+ await commitFile(repoDir, "src/main.ts", "original content\n");
327
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
328
+ await commitFile(repoDir, "src/feature.ts", "feature content\n");
329
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
330
+ await Bun.write(`${repoDir}/src/main.ts`, "modified content\n");
331
+
332
+ const entry = makeTestEntry({ branchName: "feature-branch" });
333
+ const resolver = createMergeResolver({ aiResolveEnabled: false, reimagineEnabled: false });
334
+
335
+ try {
336
+ await resolver.resolve(entry, defaultBranch, repoDir);
337
+ expect(true).toBe(false); // should not reach
338
+ } catch (err: unknown) {
339
+ expect(err).toBeInstanceOf(MergeError);
340
+ const mergeErr = err as MergeError;
341
+ expect(mergeErr.message).toContain("src/main.ts");
342
+ expect(mergeErr.message).toContain("Commit or stash");
343
+ }
344
+ } finally {
345
+ await cleanupTempDir(repoDir);
346
+ }
347
+ });
348
+
349
+ test("throws MergeError when staged but uncommitted changes exist", async () => {
350
+ const repoDir = await createTempGitRepo();
351
+ try {
352
+ const defaultBranch = await getDefaultBranch(repoDir);
353
+ await commitFile(repoDir, "src/main.ts", "original content\n");
354
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
355
+ await commitFile(repoDir, "src/feature.ts", "feature content\n");
356
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
357
+ // Modify and stage (but don't commit)
358
+ await Bun.write(`${repoDir}/src/main.ts`, "staged but not committed\n");
359
+ await runGitInDir(repoDir, ["add", "src/main.ts"]);
360
+
361
+ const entry = makeTestEntry({ branchName: "feature-branch" });
362
+ const resolver = createMergeResolver({ aiResolveEnabled: false, reimagineEnabled: false });
363
+
364
+ await expect(resolver.resolve(entry, defaultBranch, repoDir)).rejects.toThrow(MergeError);
365
+ } finally {
366
+ await cleanupTempDir(repoDir);
367
+ }
368
+ });
369
+
370
+ test("clean working tree proceeds normally to Tier 1", async () => {
371
+ const repoDir = await createTempGitRepo();
372
+ try {
373
+ const defaultBranch = await getDefaultBranch(repoDir);
374
+ await setupCleanMerge(repoDir, defaultBranch);
375
+
376
+ const entry = makeTestEntry({
377
+ branchName: "feature-branch",
378
+ filesModified: ["src/feature-file.ts"],
379
+ });
380
+
381
+ const resolver = createMergeResolver({ aiResolveEnabled: false, reimagineEnabled: false });
382
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
383
+
384
+ expect(result.success).toBe(true);
385
+ expect(result.tier).toBe("clean-merge");
386
+ } finally {
387
+ await cleanupTempDir(repoDir);
388
+ }
389
+ });
390
+ });
391
+
293
392
  describe("Tier 1 fail -> Tier 2: Auto-resolve", () => {
294
393
  test("auto-resolves conflicts keeping incoming changes with correct content", async () => {
295
394
  const repoDir = await createTempGitRepo();
@@ -50,6 +50,26 @@ async function runGit(
50
50
  return { stdout, stderr, exitCode };
51
51
  }
52
52
 
53
+ /**
54
+ * Get the list of tracked files with uncommitted changes (unstaged or staged).
55
+ * Returns deduplicated list of file paths. An empty list means the working tree is clean.
56
+ */
57
+ async function checkDirtyWorkingTree(repoRoot: string): Promise<string[]> {
58
+ const { stdout: unstaged } = await runGit(repoRoot, ["diff", "--name-only"]);
59
+ const { stdout: staged } = await runGit(repoRoot, ["diff", "--name-only", "--cached"]);
60
+ const files = [
61
+ ...unstaged
62
+ .trim()
63
+ .split("\n")
64
+ .filter((l) => l.length > 0),
65
+ ...staged
66
+ .trim()
67
+ .split("\n")
68
+ .filter((l) => l.length > 0),
69
+ ];
70
+ return [...new Set(files)];
71
+ }
72
+
53
73
  /**
54
74
  * Get the list of conflicted files from `git diff --name-only --diff-filter=U`.
55
75
  */
@@ -593,6 +613,17 @@ export function createMergeResolver(options: {
593
613
  }
594
614
  }
595
615
 
616
+ // Pre-check: abort early if working tree has uncommitted changes.
617
+ // When dirty tracked files exist, git merge refuses to start (exit 1, no conflict markers),
618
+ // causing all tiers to cascade with empty conflict lists and a misleading final error.
619
+ const dirtyFiles = await checkDirtyWorkingTree(repoRoot);
620
+ if (dirtyFiles.length > 0) {
621
+ throw new MergeError(
622
+ `Working tree has uncommitted changes to tracked files: ${dirtyFiles.join(", ")}. Commit or stash changes before running ov merge.`,
623
+ { branchName: entry.branchName },
624
+ );
625
+ }
626
+
596
627
  let lastTier: ResolutionTier = "clean-merge";
597
628
  let conflictFiles: string[] = [];
598
629
 
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Coverage:
8
8
  * - parseTranscriptUsage (transcript.ts)
9
- * - estimateCost re-export (transcript.ts -> pricing.ts)
9
+ * - estimateCost (pricing.ts, imported directly)
10
10
  * - getPricingForModel (pricing.ts)
11
11
  */
12
12
 
@@ -15,8 +15,8 @@ import { mkdtemp } from "node:fs/promises";
15
15
  import { tmpdir } from "node:os";
16
16
  import { join } from "node:path";
17
17
  import { cleanupTempDir } from "../test-helpers.ts";
18
- import { getPricingForModel, estimateCost as pricingEstimateCost } from "./pricing.ts";
19
- import { estimateCost, parseTranscriptUsage } from "./transcript.ts";
18
+ import { estimateCost, getPricingForModel } from "./pricing.ts";
19
+ import { parseTranscriptUsage } from "./transcript.ts";
20
20
 
21
21
  let tempDir: string;
22
22
 
@@ -479,17 +479,5 @@ describe("getPricingForModel", () => {
479
479
  });
480
480
  });
481
481
 
482
- // === re-export parity ===
483
-
484
- describe("estimateCost re-export parity", () => {
485
- test("transcript.estimateCost and pricing.estimateCost produce same result", () => {
486
- const usage = {
487
- inputTokens: 1_000_000,
488
- outputTokens: 1_000_000,
489
- cacheReadTokens: 1_000_000,
490
- cacheCreationTokens: 1_000_000,
491
- modelUsed: "claude-opus-4-6",
492
- };
493
- expect(estimateCost(usage)).toBe(pricingEstimateCost(usage));
494
- });
495
- });
482
+ // estimateCost re-export removed from transcript.ts (overstory-aa00).
483
+ // estimateCost is now imported directly from pricing.ts everywhere.
@@ -27,8 +27,6 @@ import type { TokenUsage } from "./pricing.ts";
27
27
 
28
28
  export type TranscriptUsage = TokenUsage;
29
29
 
30
- export { estimateCost } from "./pricing.ts";
31
-
32
30
  /**
33
31
  * Narrow an unknown value to determine if it looks like a transcript assistant entry.
34
32
  * Returns the usage fields if valid, or null otherwise.
@@ -5,7 +5,8 @@
5
5
  import { mkdir } from "node:fs/promises";
6
6
  import { join } from "node:path";
7
7
  import { deployHooks } from "../agents/hooks-deployer.ts";
8
- import { estimateCost, parseTranscriptUsage } from "../metrics/transcript.ts";
8
+ import { estimateCost } from "../metrics/pricing.ts";
9
+ import { parseTranscriptUsage } from "../metrics/transcript.ts";
9
10
  import type { ResolvedModel } from "../types.ts";
10
11
  import type {
11
12
  AgentRuntime,
@@ -219,6 +220,22 @@ export class ClaudeRuntime implements AgentRuntime {
219
220
  buildEnv(model: ResolvedModel): Record<string, string> {
220
221
  return model.env ?? {};
221
222
  }
223
+
224
+ /**
225
+ * Return the Claude Code transcript directory for a given project root.
226
+ *
227
+ * Claude Code stores session transcripts at ~/.claude/projects/<projectKey>/
228
+ * where <projectKey> is the project root path with "/" replaced by "-".
229
+ *
230
+ * @param projectRoot - Absolute path to the project root
231
+ * @returns Absolute path to the transcript directory, or null if HOME is unavailable
232
+ */
233
+ getTranscriptDir(projectRoot: string): string | null {
234
+ const home = process.env.HOME ?? "";
235
+ if (home.length === 0) return null;
236
+ const projectKey = projectRoot.replace(/\//g, "-");
237
+ return join(home, ".claude", "projects", projectKey);
238
+ }
222
239
  }
223
240
 
224
241
  /** Singleton instance for use in callers that do not need DI. */
@@ -230,4 +230,9 @@ export class CodexRuntime implements AgentRuntime {
230
230
  buildEnv(model: ResolvedModel): Record<string, string> {
231
231
  return model.env ?? {};
232
232
  }
233
+
234
+ /** Codex does not produce transcript files. */
235
+ getTranscriptDir(_projectRoot: string): string | null {
236
+ return null;
237
+ }
233
238
  }
@@ -223,4 +223,9 @@ export class CopilotRuntime implements AgentRuntime {
223
223
  buildEnv(model: ResolvedModel): Record<string, string> {
224
224
  return model.env ?? {};
225
225
  }
226
+
227
+ /** Copilot does not produce transcript files. */
228
+ getTranscriptDir(_projectRoot: string): string | null {
229
+ return null;
230
+ }
226
231
  }
@@ -232,4 +232,9 @@ export class GeminiRuntime implements AgentRuntime {
232
232
  buildEnv(model: ResolvedModel): Record<string, string> {
233
233
  return model.env ?? {};
234
234
  }
235
+
236
+ /** Gemini does not produce transcript files. */
237
+ getTranscriptDir(_projectRoot: string): string | null {
238
+ return null;
239
+ }
235
240
  }
@@ -245,4 +245,9 @@ export class PiRuntime implements AgentRuntime {
245
245
  buildEnv(model: ResolvedModel): Record<string, string> {
246
246
  return model.env ?? {};
247
247
  }
248
+
249
+ /** Pi uses RPC — no transcript files. */
250
+ getTranscriptDir(_projectRoot: string): string | null {
251
+ return null;
252
+ }
248
253
  }
@@ -117,4 +117,40 @@ describe("getRuntime", () => {
117
117
  expect(runtime).toBeInstanceOf(GeminiRuntime);
118
118
  expect(runtime.id).toBe("gemini");
119
119
  });
120
+
121
+ describe("capability routing", () => {
122
+ it("resolves capability-specific runtime from config", () => {
123
+ const config = {
124
+ runtime: { default: "claude", capabilities: { builder: "gemini" } },
125
+ } as unknown as OverstoryConfig;
126
+ const runtime = getRuntime(undefined, config, "builder");
127
+ expect(runtime).toBeInstanceOf(GeminiRuntime);
128
+ expect(runtime.id).toBe("gemini");
129
+ });
130
+
131
+ it("falls back to default when capability has no override", () => {
132
+ const config = {
133
+ runtime: { default: "codex", capabilities: { builder: "gemini" } },
134
+ } as unknown as OverstoryConfig;
135
+ const runtime = getRuntime(undefined, config, "scout");
136
+ expect(runtime).toBeInstanceOf(CodexRuntime);
137
+ expect(runtime.id).toBe("codex");
138
+ });
139
+
140
+ it("explicit name overrides capability routing", () => {
141
+ const config = {
142
+ runtime: { default: "claude", capabilities: { builder: "gemini" } },
143
+ } as unknown as OverstoryConfig;
144
+ const runtime = getRuntime("copilot", config, "builder");
145
+ expect(runtime).toBeInstanceOf(CopilotRuntime);
146
+ expect(runtime.id).toBe("copilot");
147
+ });
148
+
149
+ it("works when capabilities is undefined", () => {
150
+ const config = { runtime: { default: "claude" } } as OverstoryConfig;
151
+ const runtime = getRuntime(undefined, config, "coordinator");
152
+ expect(runtime).toBeInstanceOf(ClaudeRuntime);
153
+ expect(runtime.id).toBe("claude");
154
+ });
155
+ });
120
156
  });
@@ -20,24 +20,54 @@ const runtimes = new Map<string, () => AgentRuntime>([
20
20
  ["sapling", () => new SaplingRuntime()],
21
21
  ]);
22
22
 
23
+ /**
24
+ * Return all registered runtime adapter instances.
25
+ *
26
+ * Used by callers that need to enumerate all runtimes (e.g. to build a
27
+ * dynamic list of known instruction file paths from each runtime's
28
+ * `instructionPath` property).
29
+ *
30
+ * @returns Array of one fresh instance per registered runtime.
31
+ */
32
+ export function getAllRuntimes(): AgentRuntime[] {
33
+ return [
34
+ new ClaudeRuntime(),
35
+ new CodexRuntime(),
36
+ new PiRuntime(),
37
+ new CopilotRuntime(),
38
+ new GeminiRuntime(),
39
+ new SaplingRuntime(),
40
+ ];
41
+ }
42
+
23
43
  /**
24
44
  * Resolve a runtime adapter by name.
25
45
  *
26
46
  * Lookup order:
27
47
  * 1. Explicit `name` argument (if provided)
28
- * 2. `config.runtime.default` (if config is provided)
29
- * 3. `"claude"` (hardcoded fallback)
48
+ * 2. `config.runtime.capabilities[capability]` (if capability provided)
49
+ * 3. `config.runtime.default` (if config is provided)
50
+ * 4. `"claude"` (hardcoded fallback)
30
51
  *
31
52
  * Special cases:
32
53
  * - Pi runtime receives `config.runtime.pi` for model alias expansion.
33
54
  *
34
55
  * @param name - Runtime name to resolve (e.g. "claude"). Omit to use config default.
35
56
  * @param config - Overstory config for reading the default runtime.
57
+ * @param capability - Agent capability (e.g. "coordinator", "builder") for per-capability routing.
36
58
  * @throws {Error} If the resolved runtime name is not registered.
37
59
  * @returns A fresh AgentRuntime instance.
38
60
  */
39
- export function getRuntime(name?: string, config?: OverstoryConfig): AgentRuntime {
40
- const runtimeName = name ?? config?.runtime?.default ?? "claude";
61
+ export function getRuntime(
62
+ name?: string,
63
+ config?: OverstoryConfig,
64
+ capability?: string,
65
+ ): AgentRuntime {
66
+ const capabilityRuntime =
67
+ capability && config?.runtime?.capabilities
68
+ ? config.runtime.capabilities[capability]
69
+ : undefined;
70
+ const runtimeName = name ?? capabilityRuntime ?? config?.runtime?.default ?? "claude";
41
71
 
42
72
  // Pi runtime needs config for model alias expansion.
43
73
  if (runtimeName === "pi") {
@@ -695,4 +695,9 @@ export class SaplingRuntime implements AgentRuntime {
695
695
 
696
696
  return env;
697
697
  }
698
+
699
+ /** Sapling uses NDJSON event streaming — no transcript files. */
700
+ getTranscriptDir(_projectRoot: string): string | null {
701
+ return null;
702
+ }
698
703
  }
@@ -184,6 +184,15 @@ export interface AgentRuntime {
184
184
  */
185
185
  parseTranscript(path: string): Promise<TranscriptSummary | null>;
186
186
 
187
+ /**
188
+ * Return the directory containing session transcript files for this runtime,
189
+ * or null if transcript discovery is not supported.
190
+ *
191
+ * @param projectRoot - Absolute path to the project root
192
+ * @returns Absolute path to the transcript directory, or null
193
+ */
194
+ getTranscriptDir(projectRoot: string): string | null;
195
+
187
196
  /**
188
197
  * Build runtime-specific environment variables for model/provider routing.
189
198
  * Claude Code uses ANTHROPIC_API_KEY; Codex uses OPENAI_API_KEY; Pi passes
package/src/types.ts CHANGED
@@ -97,6 +97,11 @@ export interface OverstoryConfig {
97
97
  runtime?: {
98
98
  /** Default runtime adapter name (default: "claude"). */
99
99
  default: string;
100
+ /**
101
+ * Per-capability runtime overrides. Maps capability names (e.g. "coordinator", "builder")
102
+ * to runtime adapter names. Lookup chain: explicit --runtime flag > capabilities[cap] > default > "claude".
103
+ */
104
+ capabilities?: Partial<Record<string, string>>;
100
105
  /**
101
106
  * Runtime adapter for headless one-shot AI calls (--print mode).
102
107
  * Used by merge/resolver.ts and watchdog/triage.ts.