@os-eco/overstory-cli 0.8.6 → 0.8.7
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 +11 -8
- package/package.json +1 -1
- package/src/agents/hooks-deployer.test.ts +185 -12
- package/src/agents/hooks-deployer.ts +57 -1
- package/src/commands/coordinator.test.ts +74 -5
- package/src/commands/coordinator.ts +27 -3
- package/src/commands/dashboard.ts +84 -18
- package/src/commands/ecosystem.test.ts +101 -0
- package/src/commands/init.test.ts +74 -0
- package/src/commands/init.ts +36 -14
- package/src/commands/sling.test.ts +33 -0
- package/src/commands/sling.ts +106 -38
- package/src/commands/supervisor.ts +2 -0
- package/src/index.ts +1 -1
- package/src/merge/resolver.test.ts +141 -7
- package/src/merge/resolver.ts +61 -8
- package/src/runtimes/claude.test.ts +32 -7
- package/src/runtimes/claude.ts +19 -4
- package/src/runtimes/codex.test.ts +13 -0
- package/src/runtimes/codex.ts +18 -2
- package/src/runtimes/copilot.ts +3 -0
- package/src/runtimes/cursor.test.ts +497 -0
- package/src/runtimes/cursor.ts +205 -0
- package/src/runtimes/gemini.ts +3 -0
- package/src/runtimes/opencode.ts +3 -0
- package/src/runtimes/pi.test.ts +1 -1
- package/src/runtimes/pi.ts +3 -0
- package/src/runtimes/registry.test.ts +21 -1
- package/src/runtimes/registry.ts +3 -0
- package/src/runtimes/sapling.ts +3 -0
- package/src/runtimes/types.ts +5 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +178 -0
- package/src/sessions/store.ts +44 -8
- package/src/types.ts +8 -1
- package/src/worktree/tmux.test.ts +150 -0
- package/src/worktree/tmux.ts +126 -23
|
@@ -40,7 +40,13 @@ import { openSessionStore } from "../sessions/compat.ts";
|
|
|
40
40
|
import type { SessionStore } from "../sessions/store.ts";
|
|
41
41
|
import { createTrackerClient, resolveBackend } from "../tracker/factory.ts";
|
|
42
42
|
import type { TrackerIssue } from "../tracker/types.ts";
|
|
43
|
-
import type {
|
|
43
|
+
import type {
|
|
44
|
+
AgentSession,
|
|
45
|
+
EventStore,
|
|
46
|
+
MailMessage,
|
|
47
|
+
OverstoryConfig,
|
|
48
|
+
StoredEvent,
|
|
49
|
+
} from "../types.ts";
|
|
44
50
|
import { evaluateHealth } from "../watchdog/health.ts";
|
|
45
51
|
import { isProcessAlive } from "../worktree/tmux.ts";
|
|
46
52
|
import { getCachedTmuxSessions, getCachedWorktrees, type StatusData } from "./status.ts";
|
|
@@ -296,6 +302,14 @@ interface TrackerCache {
|
|
|
296
302
|
let trackerCache: TrackerCache | null = null;
|
|
297
303
|
const TRACKER_CACHE_TTL_MS = 10_000; // 10 seconds
|
|
298
304
|
|
|
305
|
+
/** Session data cached between ticks — stale-on-error fallback. */
|
|
306
|
+
interface SessionDataCache {
|
|
307
|
+
sessions: AgentSession[];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Module-level session cache (persists across poll ticks, used as fallback on SQLite errors). */
|
|
311
|
+
let sessionDataCache: SessionDataCache | null = null;
|
|
312
|
+
|
|
299
313
|
interface DashboardData {
|
|
300
314
|
currentRunId?: string | null;
|
|
301
315
|
status: StatusData;
|
|
@@ -309,6 +323,8 @@ interface DashboardData {
|
|
|
309
323
|
tasks: TrackerIssue[];
|
|
310
324
|
recentEvents: StoredEvent[];
|
|
311
325
|
feedColorMap: Map<string, (s: string) => string>;
|
|
326
|
+
/** Runtime config for resolving per-capability runtime names in the agent panel. */
|
|
327
|
+
runtimeConfig?: OverstoryConfig["runtime"];
|
|
312
328
|
}
|
|
313
329
|
|
|
314
330
|
/**
|
|
@@ -336,9 +352,17 @@ async function loadDashboardData(
|
|
|
336
352
|
runId?: string | null,
|
|
337
353
|
thresholds?: { staleMs: number; zombieMs: number },
|
|
338
354
|
eventBuffer?: EventBuffer,
|
|
355
|
+
runtimeConfig?: OverstoryConfig["runtime"],
|
|
339
356
|
): Promise<DashboardData> {
|
|
340
|
-
// Get all sessions from the pre-opened session store
|
|
341
|
-
|
|
357
|
+
// Get all sessions from the pre-opened session store — fall back to cache on SQLite errors.
|
|
358
|
+
let allSessions: AgentSession[];
|
|
359
|
+
try {
|
|
360
|
+
allSessions = stores.sessionStore.getAll();
|
|
361
|
+
sessionDataCache = { sessions: allSessions };
|
|
362
|
+
} catch {
|
|
363
|
+
// SQLite lock contention or I/O error — use last known sessions
|
|
364
|
+
allSessions = sessionDataCache?.sessions ?? [];
|
|
365
|
+
}
|
|
342
366
|
|
|
343
367
|
// Get worktrees and tmux sessions via cached subprocess helpers
|
|
344
368
|
const worktrees = await getCachedWorktrees(root);
|
|
@@ -347,18 +371,22 @@ async function loadDashboardData(
|
|
|
347
371
|
// Evaluate health for active agents using the same logic as the watchdog.
|
|
348
372
|
const tmuxSessionNames = new Set(tmuxSessions.map((s) => s.name));
|
|
349
373
|
const healthThresholds = thresholds ?? { staleMs: 300_000, zombieMs: 600_000 };
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
374
|
+
try {
|
|
375
|
+
for (const session of allSessions) {
|
|
376
|
+
if (session.state === "completed") continue;
|
|
377
|
+
const tmuxAlive = tmuxSessionNames.has(session.tmuxSession);
|
|
378
|
+
const check = evaluateHealth(session, tmuxAlive, healthThresholds);
|
|
379
|
+
if (check.state !== session.state) {
|
|
380
|
+
try {
|
|
381
|
+
stores.sessionStore.updateState(session.agentName, check.state);
|
|
382
|
+
session.state = check.state;
|
|
383
|
+
} catch {
|
|
384
|
+
// Best effort: don't fail dashboard if update fails
|
|
385
|
+
}
|
|
360
386
|
}
|
|
361
387
|
}
|
|
388
|
+
} catch {
|
|
389
|
+
// Best effort: evaluateHealth loop should not crash the dashboard
|
|
362
390
|
}
|
|
363
391
|
|
|
364
392
|
// If run-scoped, filter agents to only those belonging to the current run.
|
|
@@ -519,6 +547,7 @@ async function loadDashboardData(
|
|
|
519
547
|
tasks,
|
|
520
548
|
recentEvents,
|
|
521
549
|
feedColorMap,
|
|
550
|
+
runtimeConfig,
|
|
522
551
|
};
|
|
523
552
|
}
|
|
524
553
|
|
|
@@ -536,6 +565,18 @@ function renderHeader(width: number, interval: number, currentRunId?: string | n
|
|
|
536
565
|
return `${line}\n${separator}`;
|
|
537
566
|
}
|
|
538
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Resolve the runtime name for a given capability from config.
|
|
570
|
+
* Mirrors the lookup chain in runtimes/registry.ts getRuntime():
|
|
571
|
+
* capabilities[cap] > runtime.default > "claude"
|
|
572
|
+
*/
|
|
573
|
+
function resolveRuntimeName(
|
|
574
|
+
capability: string,
|
|
575
|
+
runtimeConfig?: OverstoryConfig["runtime"],
|
|
576
|
+
): string {
|
|
577
|
+
return runtimeConfig?.capabilities?.[capability] ?? runtimeConfig?.default ?? "claude";
|
|
578
|
+
}
|
|
579
|
+
|
|
539
580
|
/**
|
|
540
581
|
* Render the agent panel (left 60%, dynamic height).
|
|
541
582
|
*/
|
|
@@ -556,7 +597,7 @@ export function renderAgentPanel(
|
|
|
556
597
|
output += `${CURSOR.cursorTo(startRow, 1)}${headerLine}${headerPadding}${dimBox.vertical}\n`;
|
|
557
598
|
|
|
558
599
|
// Column headers
|
|
559
|
-
const colStr = `${dimBox.vertical} St Name Capability State Task ID Duration Live `;
|
|
600
|
+
const colStr = `${dimBox.vertical} St Name Capability Runtime State Task ID Duration Live `;
|
|
560
601
|
const colPadding = " ".repeat(
|
|
561
602
|
Math.max(0, leftWidth - visibleLength(colStr) - visibleLength(dimBox.vertical)),
|
|
562
603
|
);
|
|
@@ -588,6 +629,8 @@ export function renderAgentPanel(
|
|
|
588
629
|
const stateColorFn = stateColor(agent.state);
|
|
589
630
|
const name = accent(pad(truncate(agent.agentName, 15), 15));
|
|
590
631
|
const capability = pad(truncate(agent.capability, 12), 12);
|
|
632
|
+
const runtimeName = resolveRuntimeName(agent.capability, data.runtimeConfig);
|
|
633
|
+
const runtime = pad(truncate(runtimeName, 8), 8);
|
|
591
634
|
const state = pad(agent.state, 10);
|
|
592
635
|
const taskId = accent(pad(truncate(agent.taskId, 16), 16));
|
|
593
636
|
const endTime =
|
|
@@ -602,7 +645,7 @@ export function renderAgentPanel(
|
|
|
602
645
|
: data.status.tmuxSessions.some((s) => s.name === agent.tmuxSession);
|
|
603
646
|
const aliveDot = alive ? color.green(">") : color.red("x");
|
|
604
647
|
|
|
605
|
-
const lineContent = `${dimBox.vertical} ${stateColorFn(icon)} ${name} ${capability} ${stateColorFn(state)} ${taskId} ${durationPadded} ${aliveDot} `;
|
|
648
|
+
const lineContent = `${dimBox.vertical} ${stateColorFn(icon)} ${name} ${capability} ${color.dim(runtime)} ${stateColorFn(state)} ${taskId} ${durationPadded} ${aliveDot} `;
|
|
606
649
|
const linePadding = " ".repeat(
|
|
607
650
|
Math.max(0, leftWidth - visibleLength(lineContent) - visibleLength(dimBox.vertical)),
|
|
608
651
|
);
|
|
@@ -1020,10 +1063,33 @@ async function executeDashboard(opts: DashboardOpts): Promise<void> {
|
|
|
1020
1063
|
process.exit(0);
|
|
1021
1064
|
});
|
|
1022
1065
|
|
|
1023
|
-
// Poll loop
|
|
1066
|
+
// Poll loop — errors are caught per-tick so transient DB failures never crash the dashboard.
|
|
1067
|
+
let lastGoodData: DashboardData | null = null;
|
|
1068
|
+
let lastErrorMsg: string | null = null;
|
|
1024
1069
|
while (running) {
|
|
1025
|
-
|
|
1026
|
-
|
|
1070
|
+
try {
|
|
1071
|
+
const data = await loadDashboardData(
|
|
1072
|
+
root,
|
|
1073
|
+
stores,
|
|
1074
|
+
runId,
|
|
1075
|
+
thresholds,
|
|
1076
|
+
eventBuffer,
|
|
1077
|
+
config.runtime,
|
|
1078
|
+
);
|
|
1079
|
+
lastGoodData = data;
|
|
1080
|
+
lastErrorMsg = null;
|
|
1081
|
+
renderDashboard(data, interval);
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
// Render last good frame so the TUI stays alive, then show the error inline.
|
|
1084
|
+
if (lastGoodData) {
|
|
1085
|
+
renderDashboard(lastGoodData, interval);
|
|
1086
|
+
}
|
|
1087
|
+
lastErrorMsg = err instanceof Error ? err.message : String(err);
|
|
1088
|
+
const w = process.stdout.columns ?? 100;
|
|
1089
|
+
const h = process.stdout.rows ?? 30;
|
|
1090
|
+
const errLine = `${CURSOR.cursorTo(h, 1)}\x1b[31m⚠ DB error (retrying):\x1b[0m ${truncate(lastErrorMsg, w - 30)}`;
|
|
1091
|
+
process.stdout.write(errLine);
|
|
1092
|
+
}
|
|
1027
1093
|
await Bun.sleep(interval);
|
|
1028
1094
|
}
|
|
1029
1095
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the ov ecosystem command.
|
|
3
|
+
*
|
|
4
|
+
* Structural tests for CLI registration, plus a smoke test that runs the
|
|
5
|
+
* actual command and verifies the JSON output shape. The smoke test hits
|
|
6
|
+
* real CLIs and the npm registry, so it requires network access.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { createEcosystemCommand, executeEcosystem } from "./ecosystem.ts";
|
|
11
|
+
|
|
12
|
+
describe("createEcosystemCommand — CLI structure", () => {
|
|
13
|
+
test("command has correct name", () => {
|
|
14
|
+
const cmd = createEcosystemCommand();
|
|
15
|
+
expect(cmd.name()).toBe("ecosystem");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("description mentions os-eco", () => {
|
|
19
|
+
const cmd = createEcosystemCommand();
|
|
20
|
+
expect(cmd.description().toLowerCase()).toContain("os-eco");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("has --json option", () => {
|
|
24
|
+
const cmd = createEcosystemCommand();
|
|
25
|
+
const optionNames = cmd.options.map((o) => o.long);
|
|
26
|
+
expect(optionNames).toContain("--json");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("returns a Command instance", () => {
|
|
30
|
+
const cmd = createEcosystemCommand();
|
|
31
|
+
expect(typeof cmd.parse).toBe("function");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("executeEcosystem — JSON output shape", () => {
|
|
36
|
+
test("--json produces valid JSON with expected structure", async () => {
|
|
37
|
+
// Capture stdout
|
|
38
|
+
const chunks: string[] = [];
|
|
39
|
+
const originalWrite = process.stdout.write;
|
|
40
|
+
process.stdout.write = (chunk: string | Uint8Array) => {
|
|
41
|
+
chunks.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk));
|
|
42
|
+
return true;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await executeEcosystem({ json: true });
|
|
47
|
+
} finally {
|
|
48
|
+
process.stdout.write = originalWrite;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const output = chunks.join("");
|
|
52
|
+
const parsed = JSON.parse(output.trim());
|
|
53
|
+
|
|
54
|
+
// Envelope
|
|
55
|
+
expect(parsed.success).toBe(true);
|
|
56
|
+
expect(parsed.command).toBe("ecosystem");
|
|
57
|
+
|
|
58
|
+
// Tools array
|
|
59
|
+
expect(Array.isArray(parsed.tools)).toBe(true);
|
|
60
|
+
expect(parsed.tools.length).toBeGreaterThan(0);
|
|
61
|
+
|
|
62
|
+
// Each tool has required fields
|
|
63
|
+
for (const tool of parsed.tools) {
|
|
64
|
+
expect(typeof tool.name).toBe("string");
|
|
65
|
+
expect(typeof tool.cli).toBe("string");
|
|
66
|
+
expect(typeof tool.npm).toBe("string");
|
|
67
|
+
expect(typeof tool.installed).toBe("boolean");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Summary
|
|
71
|
+
expect(typeof parsed.summary).toBe("object");
|
|
72
|
+
expect(typeof parsed.summary.total).toBe("number");
|
|
73
|
+
expect(typeof parsed.summary.installed).toBe("number");
|
|
74
|
+
expect(typeof parsed.summary.missing).toBe("number");
|
|
75
|
+
expect(typeof parsed.summary.outdated).toBe("number");
|
|
76
|
+
expect(parsed.summary.total).toBe(parsed.tools.length);
|
|
77
|
+
}, 30_000); // Network calls may be slow
|
|
78
|
+
|
|
79
|
+
test("includes overstory in tool list", async () => {
|
|
80
|
+
const chunks: string[] = [];
|
|
81
|
+
const originalWrite = process.stdout.write;
|
|
82
|
+
process.stdout.write = (chunk: string | Uint8Array) => {
|
|
83
|
+
chunks.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk));
|
|
84
|
+
return true;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
await executeEcosystem({ json: true });
|
|
89
|
+
} finally {
|
|
90
|
+
process.stdout.write = originalWrite;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const parsed = JSON.parse(chunks.join("").trim());
|
|
94
|
+
const overstory = parsed.tools.find((t: { name: string }) => t.name === "overstory");
|
|
95
|
+
expect(overstory).toBeDefined();
|
|
96
|
+
// In CI, `ov` may not be globally installed — only assert version when installed
|
|
97
|
+
if (overstory.installed) {
|
|
98
|
+
expect(overstory.version).toBeDefined();
|
|
99
|
+
}
|
|
100
|
+
}, 30_000);
|
|
101
|
+
});
|
|
@@ -839,6 +839,80 @@ describe("initCommand: scaffold commit", () => {
|
|
|
839
839
|
});
|
|
840
840
|
});
|
|
841
841
|
|
|
842
|
+
describe("initCommand: spawner error resilience", () => {
|
|
843
|
+
let tempDir: string;
|
|
844
|
+
let originalCwd: string;
|
|
845
|
+
let originalWrite: typeof process.stdout.write;
|
|
846
|
+
|
|
847
|
+
beforeEach(async () => {
|
|
848
|
+
tempDir = await createTempGitRepo();
|
|
849
|
+
originalCwd = process.cwd();
|
|
850
|
+
process.chdir(tempDir);
|
|
851
|
+
originalWrite = process.stdout.write;
|
|
852
|
+
process.stdout.write = (() => true) as typeof process.stdout.write;
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
afterEach(async () => {
|
|
856
|
+
process.chdir(originalCwd);
|
|
857
|
+
process.stdout.write = originalWrite;
|
|
858
|
+
await cleanupTempDir(tempDir);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
test("spawner that throws ENOENT does not crash init — degrades gracefully", async () => {
|
|
862
|
+
const throwingSpawner: Spawner = async (args) => {
|
|
863
|
+
const key = args.join(" ");
|
|
864
|
+
// Allow git operations through (git add, git diff, git commit)
|
|
865
|
+
if (key.startsWith("git")) return { exitCode: 0, stdout: "", stderr: "" };
|
|
866
|
+
// Simulate ecosystem tool binary not found (ENOENT)
|
|
867
|
+
throw new Error(`spawn ENOENT: ${args[0]}: not found`);
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
// Should not throw — graceful degradation
|
|
871
|
+
await expect(initCommand({ _spawner: throwingSpawner })).resolves.toBeUndefined();
|
|
872
|
+
|
|
873
|
+
// Core .overstory files should still be created
|
|
874
|
+
const configPath = join(tempDir, ".overstory", "config.yaml");
|
|
875
|
+
expect(await Bun.file(configPath).exists()).toBe(true);
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
test("throwing spawner causes all ecosystem tools to be skipped", async () => {
|
|
879
|
+
const calls: string[][] = [];
|
|
880
|
+
const throwingSpawner: Spawner = async (args) => {
|
|
881
|
+
calls.push(args);
|
|
882
|
+
const key = args.join(" ");
|
|
883
|
+
if (key.startsWith("git")) return { exitCode: 0, stdout: "", stderr: "" };
|
|
884
|
+
throw new Error("spawn ENOENT");
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
await initCommand({ _spawner: throwingSpawner });
|
|
888
|
+
|
|
889
|
+
// init and onboard should NOT be called when --version throws
|
|
890
|
+
expect(calls).not.toContainEqual(["ml", "init"]);
|
|
891
|
+
expect(calls).not.toContainEqual(["sd", "init"]);
|
|
892
|
+
expect(calls).not.toContainEqual(["cn", "init"]);
|
|
893
|
+
expect(calls).not.toContainEqual(["ml", "onboard"]);
|
|
894
|
+
expect(calls).not.toContainEqual(["sd", "onboard"]);
|
|
895
|
+
expect(calls).not.toContainEqual(["cn", "onboard"]);
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
test("spawner that throws only on init (not --version) still skips gracefully", async () => {
|
|
899
|
+
// --version succeeds (tool appears installed), but init itself throws
|
|
900
|
+
const throwingInitSpawner: Spawner = async (args) => {
|
|
901
|
+
const key = args.join(" ");
|
|
902
|
+
if (key.startsWith("git")) return { exitCode: 0, stdout: "", stderr: "" };
|
|
903
|
+
if (key.endsWith("--version")) return { exitCode: 0, stdout: "1.0.0", stderr: "" };
|
|
904
|
+
if (key.endsWith("onboard")) return { exitCode: 0, stdout: "", stderr: "" };
|
|
905
|
+
// init itself throws
|
|
906
|
+
throw new Error("spawn ENOENT on init");
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
await expect(initCommand({ _spawner: throwingInitSpawner })).resolves.toBeUndefined();
|
|
910
|
+
|
|
911
|
+
const configPath = join(tempDir, ".overstory", "config.yaml");
|
|
912
|
+
expect(await Bun.file(configPath).exists()).toBe(true);
|
|
913
|
+
});
|
|
914
|
+
});
|
|
915
|
+
|
|
842
916
|
describe("initCommand: .gitattributes setup", () => {
|
|
843
917
|
let tempDir: string;
|
|
844
918
|
let originalCwd: string;
|
package/src/commands/init.ts
CHANGED
|
@@ -32,15 +32,21 @@ export type Spawner = (
|
|
|
32
32
|
) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
|
|
33
33
|
|
|
34
34
|
const defaultSpawner: Spawner = async (args, opts) => {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
35
|
+
try {
|
|
36
|
+
const proc = Bun.spawn(args, {
|
|
37
|
+
cwd: opts?.cwd,
|
|
38
|
+
stdout: "pipe",
|
|
39
|
+
stderr: "pipe",
|
|
40
|
+
});
|
|
41
|
+
const exitCode = await proc.exited;
|
|
42
|
+
const stdout = await new Response(proc.stdout).text();
|
|
43
|
+
const stderr = await new Response(proc.stderr).text();
|
|
44
|
+
return { exitCode, stdout, stderr };
|
|
45
|
+
} catch (err) {
|
|
46
|
+
// Binary not found (ENOENT) or other spawn failure — treat as non-zero exit
|
|
47
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
48
|
+
return { exitCode: 1, stdout: "", stderr: message };
|
|
49
|
+
}
|
|
44
50
|
};
|
|
45
51
|
|
|
46
52
|
interface SiblingTool {
|
|
@@ -80,8 +86,12 @@ export function resolveToolSet(opts: InitOptions): SiblingTool[] {
|
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
async function isToolInstalled(cli: string, spawner: Spawner): Promise<boolean> {
|
|
83
|
-
|
|
84
|
-
|
|
89
|
+
try {
|
|
90
|
+
const result = await spawner([cli, "--version"]);
|
|
91
|
+
return result.exitCode === 0;
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
85
95
|
}
|
|
86
96
|
|
|
87
97
|
async function initSiblingTool(
|
|
@@ -98,7 +108,15 @@ async function initSiblingTool(
|
|
|
98
108
|
return "skipped";
|
|
99
109
|
}
|
|
100
110
|
|
|
101
|
-
|
|
111
|
+
let result: { exitCode: number; stdout: string; stderr: string };
|
|
112
|
+
try {
|
|
113
|
+
result = await spawner([tool.cli, ...tool.initCmd], { cwd: projectRoot });
|
|
114
|
+
} catch (err) {
|
|
115
|
+
// Spawn failure (e.g. ENOENT) — treat as not installed
|
|
116
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
117
|
+
printWarning(`${tool.name} init failed`, message);
|
|
118
|
+
return "skipped";
|
|
119
|
+
}
|
|
102
120
|
if (result.exitCode !== 0) {
|
|
103
121
|
// Check if dot directory already exists (already initialized)
|
|
104
122
|
try {
|
|
@@ -123,8 +141,12 @@ async function onboardTool(
|
|
|
123
141
|
const installed = await isToolInstalled(tool.cli, spawner);
|
|
124
142
|
if (!installed) return "current";
|
|
125
143
|
|
|
126
|
-
|
|
127
|
-
|
|
144
|
+
try {
|
|
145
|
+
const result = await spawner([tool.cli, ...tool.onboardCmd], { cwd: projectRoot });
|
|
146
|
+
return result.exitCode === 0 ? "appended" : "current";
|
|
147
|
+
} catch {
|
|
148
|
+
return "current";
|
|
149
|
+
}
|
|
128
150
|
}
|
|
129
151
|
|
|
130
152
|
/**
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
extractMulchRecordIds,
|
|
23
23
|
generateAgentName,
|
|
24
24
|
getCurrentBranch,
|
|
25
|
+
getSharedWritableDirs,
|
|
25
26
|
inferDomainsFromFiles,
|
|
26
27
|
isRunningAsRoot,
|
|
27
28
|
parentHasScouts,
|
|
@@ -343,6 +344,17 @@ describe("shouldShowScoutWarning", () => {
|
|
|
343
344
|
});
|
|
344
345
|
});
|
|
345
346
|
|
|
347
|
+
describe("getSharedWritableDirs", () => {
|
|
348
|
+
test("returns only .overstory for non-lead agents", () => {
|
|
349
|
+
expect(getSharedWritableDirs("/repo", "builder")).toEqual(["/repo/.overstory"]);
|
|
350
|
+
expect(getSharedWritableDirs("/repo", "scout")).toEqual(["/repo/.overstory"]);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("includes canonical .git for lead agents", () => {
|
|
354
|
+
expect(getSharedWritableDirs("/repo", "lead")).toEqual(["/repo/.overstory", "/repo/.git"]);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
346
358
|
describe("generateAgentName", () => {
|
|
347
359
|
test("returns capability-taskId when no collision", () => {
|
|
348
360
|
expect(generateAgentName("builder", "overstory-2f10", [])).toBe("builder-overstory-2f10");
|
|
@@ -1005,6 +1017,27 @@ describe("sling provider env injection building blocks", () => {
|
|
|
1005
1017
|
expect(combined.OVERSTORY_WORKTREE_PATH).toBe("/tmp/wt");
|
|
1006
1018
|
});
|
|
1007
1019
|
|
|
1020
|
+
test("env dict from resolveModel can be spread with OVERSTORY_TASK_ID", () => {
|
|
1021
|
+
const config = makeConfig(
|
|
1022
|
+
{ builder: "openrouter/anthropic/claude-3-5-sonnet" },
|
|
1023
|
+
{ openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" } },
|
|
1024
|
+
);
|
|
1025
|
+
const manifest = makeManifest();
|
|
1026
|
+
|
|
1027
|
+
const { env } = resolveModel(config, manifest, "builder", "sonnet");
|
|
1028
|
+
// Simulates the spread in slingCommand: { ...env, OVERSTORY_AGENT_NAME: name, OVERSTORY_WORKTREE_PATH: wt, OVERSTORY_TASK_ID: taskId }
|
|
1029
|
+
const combined: Record<string, string> = {
|
|
1030
|
+
...(env ?? {}),
|
|
1031
|
+
OVERSTORY_AGENT_NAME: "test-builder",
|
|
1032
|
+
OVERSTORY_WORKTREE_PATH: "/tmp/wt",
|
|
1033
|
+
OVERSTORY_TASK_ID: "overstory-1234",
|
|
1034
|
+
};
|
|
1035
|
+
|
|
1036
|
+
expect(combined.OVERSTORY_AGENT_NAME).toBe("test-builder");
|
|
1037
|
+
expect(combined.OVERSTORY_WORKTREE_PATH).toBe("/tmp/wt");
|
|
1038
|
+
expect(combined.OVERSTORY_TASK_ID).toBe("overstory-1234");
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1008
1041
|
test("resolveModel returns no env for native anthropic provider", () => {
|
|
1009
1042
|
const config = makeConfig({ builder: "sonnet" }, { anthropic: { type: "native" } });
|
|
1010
1043
|
const manifest = makeManifest();
|