@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 +1 -1
- package/package.json +1 -1
- package/src/commands/agents.ts +8 -9
- package/src/commands/coordinator.ts +1 -1
- package/src/commands/costs.ts +7 -25
- package/src/commands/log.ts +2 -1
- package/src/commands/monitor.ts +1 -1
- package/src/commands/sling.ts +1 -1
- package/src/commands/supervisor.ts +1 -1
- package/src/config.ts +11 -0
- package/src/index.ts +1 -1
- package/src/merge/resolver.test.ts +99 -0
- package/src/merge/resolver.ts +31 -0
- package/src/metrics/transcript.test.ts +5 -17
- package/src/metrics/transcript.ts +0 -2
- package/src/runtimes/claude.ts +18 -1
- package/src/runtimes/codex.ts +5 -0
- package/src/runtimes/copilot.ts +5 -0
- package/src/runtimes/gemini.ts +5 -0
- package/src/runtimes/pi.ts +5 -0
- package/src/runtimes/registry.test.ts +36 -0
- package/src/runtimes/registry.ts +34 -4
- package/src/runtimes/sapling.ts +5 -0
- package/src/runtimes/types.ts +9 -0
- package/src/types.ts +5 -0
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/
|
|
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
|
+
"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",
|
package/src/commands/agents.ts
CHANGED
|
@@ -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
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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, ...
|
|
57
|
-
:
|
|
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.
|
package/src/commands/costs.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
59
|
+
runtime: AgentRuntime,
|
|
78
60
|
projectRoot: string,
|
|
79
61
|
): Promise<string | null> {
|
|
80
|
-
const transcriptDir = getTranscriptDir(
|
|
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
|
|
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}'`);
|
package/src/commands/log.ts
CHANGED
|
@@ -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 {
|
|
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";
|
package/src/commands/monitor.ts
CHANGED
|
@@ -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, {
|
package/src/commands/sling.ts
CHANGED
|
@@ -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.
|
|
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();
|
package/src/merge/resolver.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
19
|
-
import {
|
|
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
|
-
//
|
|
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.
|
package/src/runtimes/claude.ts
CHANGED
|
@@ -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
|
|
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. */
|
package/src/runtimes/codex.ts
CHANGED
|
@@ -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
|
}
|
package/src/runtimes/copilot.ts
CHANGED
|
@@ -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
|
}
|
package/src/runtimes/gemini.ts
CHANGED
|
@@ -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
|
}
|
package/src/runtimes/pi.ts
CHANGED
|
@@ -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
|
});
|
package/src/runtimes/registry.ts
CHANGED
|
@@ -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.
|
|
29
|
-
* 3. `
|
|
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(
|
|
40
|
-
|
|
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") {
|
package/src/runtimes/sapling.ts
CHANGED
package/src/runtimes/types.ts
CHANGED
|
@@ -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.
|