@os-eco/overstory-cli 0.6.8 → 0.6.9
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 +4 -2
- package/package.json +1 -1
- package/src/agents/hooks-deployer.test.ts +180 -0
- package/src/agents/hooks-deployer.ts +32 -1
- package/src/commands/agents.ts +9 -6
- package/src/commands/clean.ts +2 -1
- package/src/commands/completions.ts +3 -4
- package/src/commands/coordinator.test.ts +8 -0
- package/src/commands/coordinator.ts +11 -8
- package/src/commands/costs.test.ts +48 -38
- package/src/commands/costs.ts +48 -38
- package/src/commands/dashboard.ts +7 -7
- package/src/commands/doctor.test.ts +8 -0
- package/src/commands/doctor.ts +2 -6
- package/src/commands/errors.test.ts +47 -40
- package/src/commands/errors.ts +5 -4
- package/src/commands/feed.test.ts +40 -33
- package/src/commands/feed.ts +3 -2
- package/src/commands/group.ts +23 -14
- package/src/commands/hooks.ts +2 -1
- package/src/commands/init.test.ts +104 -0
- package/src/commands/init.ts +11 -7
- package/src/commands/inspect.test.ts +2 -0
- package/src/commands/inspect.ts +9 -8
- package/src/commands/logs.test.ts +5 -6
- package/src/commands/logs.ts +2 -1
- package/src/commands/mail.test.ts +11 -10
- package/src/commands/mail.ts +11 -12
- package/src/commands/merge.ts +11 -12
- package/src/commands/metrics.test.ts +15 -2
- package/src/commands/metrics.ts +3 -2
- package/src/commands/monitor.ts +5 -4
- package/src/commands/nudge.ts +2 -3
- package/src/commands/prime.test.ts +1 -6
- package/src/commands/prime.ts +2 -3
- package/src/commands/replay.test.ts +62 -55
- package/src/commands/replay.ts +3 -2
- package/src/commands/run.ts +17 -20
- package/src/commands/sling.ts +2 -1
- package/src/commands/status.test.ts +2 -1
- package/src/commands/status.ts +7 -6
- package/src/commands/stop.test.ts +2 -0
- package/src/commands/stop.ts +10 -11
- package/src/commands/supervisor.ts +7 -6
- package/src/commands/trace.test.ts +52 -44
- package/src/commands/trace.ts +5 -4
- package/src/commands/watch.ts +8 -10
- package/src/commands/worktree.test.ts +21 -15
- package/src/commands/worktree.ts +10 -4
- package/src/index.ts +3 -1
package/README.md
CHANGED
|
@@ -105,6 +105,8 @@ ov agents discover Discover agents by capability/state/parent
|
|
|
105
105
|
|
|
106
106
|
ov init Initialize .overstory/ in current project
|
|
107
107
|
(deploys agent definitions automatically)
|
|
108
|
+
--yes, -y Skip interactive prompts
|
|
109
|
+
--name <name> Set project name (default: auto-detect)
|
|
108
110
|
|
|
109
111
|
ov coordinator start Start persistent coordinator agent
|
|
110
112
|
--attach / --no-attach TTY-aware tmux attach (default: auto)
|
|
@@ -273,13 +275,13 @@ Global Flags:
|
|
|
273
275
|
- **Dependencies**: Minimal runtime — `chalk` (color output), `commander` (CLI framework), core I/O via Bun built-in APIs
|
|
274
276
|
- **Database**: SQLite via `bun:sqlite` (WAL mode for concurrent access)
|
|
275
277
|
- **Linting**: Biome (formatter + linter)
|
|
276
|
-
- **Testing**: `bun test` (
|
|
278
|
+
- **Testing**: `bun test` (2186 tests across 77 files, colocated with source)
|
|
277
279
|
- **External CLIs**: `bd` (beads) or `sd` (seeds), `mulch`, `git`, `tmux` — invoked as subprocesses
|
|
278
280
|
|
|
279
281
|
## Development
|
|
280
282
|
|
|
281
283
|
```bash
|
|
282
|
-
# Run tests (
|
|
284
|
+
# Run tests (2186 tests across 77 files)
|
|
283
285
|
bun test
|
|
284
286
|
|
|
285
287
|
# Run a single test
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@os-eco/overstory-cli",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.9",
|
|
4
4
|
"description": "Multi-agent orchestration for Claude Code — spawn worker agents in git worktrees via tmux, coordinate through SQLite mail, merge with tiered conflict resolution",
|
|
5
5
|
"author": "Jaymin West",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
getDangerGuards,
|
|
15
15
|
getPathBoundaryGuards,
|
|
16
16
|
isOverstoryHookEntry,
|
|
17
|
+
PATH_PREFIX,
|
|
17
18
|
} from "./hooks-deployer.ts";
|
|
18
19
|
|
|
19
20
|
describe("deployHooks", () => {
|
|
@@ -2115,6 +2116,185 @@ describe("bash path boundary integration", () => {
|
|
|
2115
2116
|
});
|
|
2116
2117
|
});
|
|
2117
2118
|
|
|
2119
|
+
describe("PATH_PREFIX", () => {
|
|
2120
|
+
test("PATH_PREFIX is exported and is a non-empty string", () => {
|
|
2121
|
+
expect(typeof PATH_PREFIX).toBe("string");
|
|
2122
|
+
expect(PATH_PREFIX.length).toBeGreaterThan(0);
|
|
2123
|
+
});
|
|
2124
|
+
|
|
2125
|
+
test("PATH_PREFIX contains ~/.bun/bin for bun-installed CLIs", () => {
|
|
2126
|
+
expect(PATH_PREFIX).toContain(".bun/bin");
|
|
2127
|
+
});
|
|
2128
|
+
|
|
2129
|
+
test("PATH_PREFIX extends PATH (not replaces it)", () => {
|
|
2130
|
+
// Must preserve original PATH via :$PATH
|
|
2131
|
+
expect(PATH_PREFIX).toContain(":$PATH");
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
test("PATH_PREFIX sets PATH via export", () => {
|
|
2135
|
+
expect(PATH_PREFIX).toMatch(/^export PATH=/);
|
|
2136
|
+
});
|
|
2137
|
+
});
|
|
2138
|
+
|
|
2139
|
+
describe("PATH prefix in deployed hooks", () => {
|
|
2140
|
+
let tempDir: string;
|
|
2141
|
+
|
|
2142
|
+
beforeEach(async () => {
|
|
2143
|
+
tempDir = await mkdtemp(join(tmpdir(), "overstory-path-prefix-test-"));
|
|
2144
|
+
});
|
|
2145
|
+
|
|
2146
|
+
afterEach(async () => {
|
|
2147
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
2148
|
+
});
|
|
2149
|
+
|
|
2150
|
+
test("SessionStart hook commands include PATH prefix", async () => {
|
|
2151
|
+
const worktreePath = join(tempDir, "path-ss-wt");
|
|
2152
|
+
await deployHooks(worktreePath, "path-agent");
|
|
2153
|
+
|
|
2154
|
+
const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
|
|
2155
|
+
const parsed = JSON.parse(content);
|
|
2156
|
+
for (const entry of parsed.hooks.SessionStart) {
|
|
2157
|
+
for (const hook of entry.hooks) {
|
|
2158
|
+
expect(hook.command).toContain("export PATH=");
|
|
2159
|
+
expect(hook.command).toContain(".bun/bin");
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
});
|
|
2163
|
+
|
|
2164
|
+
test("UserPromptSubmit hook commands include PATH prefix", async () => {
|
|
2165
|
+
const worktreePath = join(tempDir, "path-ups-wt");
|
|
2166
|
+
await deployHooks(worktreePath, "path-agent");
|
|
2167
|
+
|
|
2168
|
+
const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
|
|
2169
|
+
const parsed = JSON.parse(content);
|
|
2170
|
+
for (const entry of parsed.hooks.UserPromptSubmit) {
|
|
2171
|
+
for (const hook of entry.hooks) {
|
|
2172
|
+
expect(hook.command).toContain("export PATH=");
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
});
|
|
2176
|
+
|
|
2177
|
+
test("PostToolUse hook commands include PATH prefix", async () => {
|
|
2178
|
+
const worktreePath = join(tempDir, "path-ptu-wt");
|
|
2179
|
+
await deployHooks(worktreePath, "path-agent");
|
|
2180
|
+
|
|
2181
|
+
const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
|
|
2182
|
+
const parsed = JSON.parse(content);
|
|
2183
|
+
for (const entry of parsed.hooks.PostToolUse) {
|
|
2184
|
+
for (const hook of entry.hooks) {
|
|
2185
|
+
expect(hook.command).toContain("export PATH=");
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
test("Stop hook commands include PATH prefix", async () => {
|
|
2191
|
+
const worktreePath = join(tempDir, "path-stop-wt");
|
|
2192
|
+
await deployHooks(worktreePath, "path-agent");
|
|
2193
|
+
|
|
2194
|
+
const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
|
|
2195
|
+
const parsed = JSON.parse(content);
|
|
2196
|
+
for (const entry of parsed.hooks.Stop) {
|
|
2197
|
+
for (const hook of entry.hooks) {
|
|
2198
|
+
expect(hook.command).toContain("export PATH=");
|
|
2199
|
+
expect(hook.command).toContain(".bun/bin");
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
});
|
|
2203
|
+
|
|
2204
|
+
test("PreCompact hook commands include PATH prefix", async () => {
|
|
2205
|
+
const worktreePath = join(tempDir, "path-pc-wt");
|
|
2206
|
+
await deployHooks(worktreePath, "path-agent");
|
|
2207
|
+
|
|
2208
|
+
const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
|
|
2209
|
+
const parsed = JSON.parse(content);
|
|
2210
|
+
for (const entry of parsed.hooks.PreCompact) {
|
|
2211
|
+
for (const hook of entry.hooks) {
|
|
2212
|
+
expect(hook.command).toContain("export PATH=");
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
});
|
|
2216
|
+
|
|
2217
|
+
test("PATH prefix appears before CLI command in SessionStart", async () => {
|
|
2218
|
+
const worktreePath = join(tempDir, "path-order-wt");
|
|
2219
|
+
await deployHooks(worktreePath, "path-order-agent");
|
|
2220
|
+
|
|
2221
|
+
const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
|
|
2222
|
+
const parsed = JSON.parse(content);
|
|
2223
|
+
const cmd = parsed.hooks.SessionStart[0].hooks[0].command as string;
|
|
2224
|
+
// PATH export must come before the CLI invocation
|
|
2225
|
+
const pathIdx = cmd.indexOf("export PATH=");
|
|
2226
|
+
const ovIdx = cmd.indexOf("ov prime");
|
|
2227
|
+
expect(pathIdx).toBeGreaterThanOrEqual(0);
|
|
2228
|
+
expect(ovIdx).toBeGreaterThan(pathIdx);
|
|
2229
|
+
});
|
|
2230
|
+
|
|
2231
|
+
test("PATH prefix appears before ml learn in Stop hook", async () => {
|
|
2232
|
+
const worktreePath = join(tempDir, "path-ml-wt");
|
|
2233
|
+
await deployHooks(worktreePath, "path-ml-agent");
|
|
2234
|
+
|
|
2235
|
+
const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
|
|
2236
|
+
const parsed = JSON.parse(content);
|
|
2237
|
+
const stopHooks = parsed.hooks.Stop[0].hooks;
|
|
2238
|
+
// Second Stop hook is "ml learn"
|
|
2239
|
+
const mlCmd = stopHooks[1].command as string;
|
|
2240
|
+
const pathIdx = mlCmd.indexOf("export PATH=");
|
|
2241
|
+
const mlIdx = mlCmd.indexOf("ml learn");
|
|
2242
|
+
expect(pathIdx).toBeGreaterThanOrEqual(0);
|
|
2243
|
+
expect(mlIdx).toBeGreaterThan(pathIdx);
|
|
2244
|
+
});
|
|
2245
|
+
|
|
2246
|
+
test("generated guard commands do NOT have PATH prefix (they use only built-ins)", async () => {
|
|
2247
|
+
const worktreePath = join(tempDir, "path-guards-wt");
|
|
2248
|
+
await deployHooks(worktreePath, "path-guards-agent", "builder");
|
|
2249
|
+
|
|
2250
|
+
const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
|
|
2251
|
+
const parsed = JSON.parse(content);
|
|
2252
|
+
const preToolUse = parsed.hooks.PreToolUse;
|
|
2253
|
+
|
|
2254
|
+
// Path boundary guards (Write/Edit/NotebookEdit) are generated — no PATH prefix
|
|
2255
|
+
const writeGuard = preToolUse.find(
|
|
2256
|
+
(h: { matcher: string; hooks: Array<{ command: string }> }) =>
|
|
2257
|
+
h.matcher === "Write" && h.hooks[0]?.command?.includes("OVERSTORY_WORKTREE_PATH"),
|
|
2258
|
+
);
|
|
2259
|
+
expect(writeGuard).toBeDefined();
|
|
2260
|
+
expect(writeGuard.hooks[0].command).not.toContain("export PATH=");
|
|
2261
|
+
|
|
2262
|
+
// Danger guard (generated) — no PATH prefix
|
|
2263
|
+
const dangerGuard = preToolUse.find(
|
|
2264
|
+
(h: { matcher: string; hooks: Array<{ command: string }> }) =>
|
|
2265
|
+
h.matcher === "Bash" && h.hooks[0]?.command?.includes("git reset --hard"),
|
|
2266
|
+
);
|
|
2267
|
+
expect(dangerGuard).toBeDefined();
|
|
2268
|
+
expect(dangerGuard.hooks[0].command).not.toContain("export PATH=");
|
|
2269
|
+
});
|
|
2270
|
+
|
|
2271
|
+
test("re-deployment is idempotent: PATH prefix not duplicated", async () => {
|
|
2272
|
+
const worktreePath = join(tempDir, "path-idem-wt");
|
|
2273
|
+
|
|
2274
|
+
await deployHooks(worktreePath, "path-idem-agent");
|
|
2275
|
+
await deployHooks(worktreePath, "path-idem-agent");
|
|
2276
|
+
|
|
2277
|
+
const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
|
|
2278
|
+
const parsed = JSON.parse(content);
|
|
2279
|
+
const cmd = parsed.hooks.SessionStart[0].hooks[0].command as string;
|
|
2280
|
+
|
|
2281
|
+
// PATH prefix should appear exactly once, not doubled
|
|
2282
|
+
const occurrences = cmd.split("export PATH=").length - 1;
|
|
2283
|
+
expect(occurrences).toBe(1);
|
|
2284
|
+
});
|
|
2285
|
+
|
|
2286
|
+
test("PATH prefix uses $HOME expansion (not hardcoded path)", async () => {
|
|
2287
|
+
const worktreePath = join(tempDir, "path-home-wt");
|
|
2288
|
+
await deployHooks(worktreePath, "home-agent");
|
|
2289
|
+
|
|
2290
|
+
const content = await Bun.file(join(worktreePath, ".claude", "settings.local.json")).text();
|
|
2291
|
+
const parsed = JSON.parse(content);
|
|
2292
|
+
const cmd = parsed.hooks.SessionStart[0].hooks[0].command as string;
|
|
2293
|
+
// Should use $HOME not a hardcoded path like /Users/...
|
|
2294
|
+
expect(cmd).toContain("$HOME");
|
|
2295
|
+
});
|
|
2296
|
+
});
|
|
2297
|
+
|
|
2118
2298
|
describe("escapeForSingleQuotedShell", () => {
|
|
2119
2299
|
test("no single quotes: string passes through unchanged", () => {
|
|
2120
2300
|
expect(escapeForSingleQuotedShell("hello world")).toBe("hello world");
|
|
@@ -149,6 +149,22 @@ function getTemplatePath(): string {
|
|
|
149
149
|
*/
|
|
150
150
|
const ENV_GUARD = '[ -z "$OVERSTORY_AGENT_NAME" ] && exit 0;';
|
|
151
151
|
|
|
152
|
+
/**
|
|
153
|
+
* PATH setup prefix for hook commands.
|
|
154
|
+
*
|
|
155
|
+
* Claude Code executes hook commands via /bin/sh with a minimal PATH
|
|
156
|
+
* (/usr/bin:/bin:/usr/sbin:/sbin). Bun-installed CLIs — ov, ml, sd, cn, bd —
|
|
157
|
+
* live in ~/.bun/bin which is absent from that PATH, causing hooks like
|
|
158
|
+
* `ov prime` (SessionStart) and `ml learn` (Stop) to fail with
|
|
159
|
+
* "command not found".
|
|
160
|
+
*
|
|
161
|
+
* Prepend this to any hook command that invokes one of those CLIs so they
|
|
162
|
+
* resolve correctly regardless of how Claude Code was launched.
|
|
163
|
+
*
|
|
164
|
+
* Exported so tests can verify the exact prefix value.
|
|
165
|
+
*/
|
|
166
|
+
export const PATH_PREFIX = 'export PATH="$HOME/.bun/bin:/usr/local/bin:/opt/homebrew/bin:$PATH";';
|
|
167
|
+
|
|
152
168
|
/**
|
|
153
169
|
* Build a PreToolUse guard script that validates file paths are within
|
|
154
170
|
* the agent's worktree boundary.
|
|
@@ -571,8 +587,23 @@ export async function deployHooks(
|
|
|
571
587
|
content = content.replace("{{AGENT_NAME}}", agentName);
|
|
572
588
|
}
|
|
573
589
|
|
|
574
|
-
// Parse the base config
|
|
590
|
+
// Parse the base config from the template
|
|
575
591
|
const config = JSON.parse(content) as { hooks: Record<string, HookEntry[]> };
|
|
592
|
+
|
|
593
|
+
// Extend PATH in all template hook commands.
|
|
594
|
+
// Claude Code invokes hooks with PATH=/usr/bin:/bin:/usr/sbin:/sbin — ~/.bun/bin
|
|
595
|
+
// (where ov, ml, sd, etc. live) is not included. Prepend PATH_PREFIX so CLIs resolve.
|
|
596
|
+
for (const entries of Object.values(config.hooks)) {
|
|
597
|
+
for (const entry of entries) {
|
|
598
|
+
for (const hook of entry.hooks) {
|
|
599
|
+
hook.command = `${PATH_PREFIX} ${hook.command}`;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Merge capability-specific PreToolUse guards into the config.
|
|
605
|
+
// Guards are generated scripts using only shell built-ins (grep, sed, echo, exit)
|
|
606
|
+
// and do not require PATH extension.
|
|
576
607
|
const pathGuards = getPathBoundaryGuards();
|
|
577
608
|
const dangerGuards = getDangerGuards(agentName);
|
|
578
609
|
const capabilityGuards = getCapabilityGuards(capability);
|
package/src/commands/agents.ts
CHANGED
|
@@ -8,7 +8,8 @@ import { join } from "node:path";
|
|
|
8
8
|
import { Command } from "commander";
|
|
9
9
|
import { loadConfig } from "../config.ts";
|
|
10
10
|
import { ValidationError } from "../errors.ts";
|
|
11
|
-
import {
|
|
11
|
+
import { jsonOutput } from "../json.ts";
|
|
12
|
+
import { accent, color } from "../logging/color.ts";
|
|
12
13
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
13
14
|
import { type AgentSession, SUPPORTED_CAPABILITIES } from "../types.ts";
|
|
14
15
|
|
|
@@ -166,10 +167,12 @@ function printAgents(agents: DiscoveredAgent[]): void {
|
|
|
166
167
|
|
|
167
168
|
for (const agent of agents) {
|
|
168
169
|
const icon = getStateIcon(agent.state);
|
|
169
|
-
w(` ${icon} ${agent.agentName} [${agent.capability}]\n`);
|
|
170
|
-
w(` State: ${agent.state} | Task: ${agent.taskId}\n`);
|
|
171
|
-
w(` Branch: ${agent.branchName}\n`);
|
|
172
|
-
w(
|
|
170
|
+
w(` ${icon} ${accent(agent.agentName)} [${agent.capability}]\n`);
|
|
171
|
+
w(` State: ${agent.state} | Task: ${accent(agent.taskId)}\n`);
|
|
172
|
+
w(` Branch: ${accent(agent.branchName)}\n`);
|
|
173
|
+
w(
|
|
174
|
+
` Parent: ${agent.parentAgent ? accent(agent.parentAgent) : "none"} | Depth: ${agent.depth}\n`,
|
|
175
|
+
);
|
|
173
176
|
|
|
174
177
|
if (agent.fileScope.length === 0) {
|
|
175
178
|
w(" Files: (unrestricted)\n");
|
|
@@ -220,7 +223,7 @@ export function createAgentsCommand(): Command {
|
|
|
220
223
|
});
|
|
221
224
|
|
|
222
225
|
if (opts.json) {
|
|
223
|
-
|
|
226
|
+
jsonOutput("agents discover", { agents });
|
|
224
227
|
} else {
|
|
225
228
|
printAgents(agents);
|
|
226
229
|
}
|
package/src/commands/clean.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { join } from "node:path";
|
|
|
25
25
|
import { loadConfig } from "../config.ts";
|
|
26
26
|
import { ValidationError } from "../errors.ts";
|
|
27
27
|
import { createEventStore } from "../events/store.ts";
|
|
28
|
+
import { jsonOutput } from "../json.ts";
|
|
28
29
|
import { printHint, printSuccess } from "../logging/color.ts";
|
|
29
30
|
import { createMulchClient } from "../mulch/client.ts";
|
|
30
31
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
@@ -518,7 +519,7 @@ export async function cleanCommand(opts: CleanOptions): Promise<void> {
|
|
|
518
519
|
|
|
519
520
|
// Output
|
|
520
521
|
if (json) {
|
|
521
|
-
|
|
522
|
+
jsonOutput("clean", { ...result });
|
|
522
523
|
return;
|
|
523
524
|
}
|
|
524
525
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { Command } from "commander";
|
|
8
|
+
import { printError } from "../logging/color.ts";
|
|
8
9
|
|
|
9
10
|
interface FlagDef {
|
|
10
11
|
name: string;
|
|
@@ -872,8 +873,7 @@ export function completionsCommand(args: string[]): void {
|
|
|
872
873
|
const shell = args[0];
|
|
873
874
|
|
|
874
875
|
if (!shell) {
|
|
875
|
-
|
|
876
|
-
process.stderr.write("Usage: ov --completions <bash|zsh|fish>\n");
|
|
876
|
+
printError("missing shell argument", "Usage: ov --completions <bash|zsh|fish>");
|
|
877
877
|
process.exit(1);
|
|
878
878
|
}
|
|
879
879
|
|
|
@@ -889,8 +889,7 @@ export function completionsCommand(args: string[]): void {
|
|
|
889
889
|
script = generateFish();
|
|
890
890
|
break;
|
|
891
891
|
default:
|
|
892
|
-
|
|
893
|
-
process.stderr.write("Supported shells: bash, zsh, fish\n");
|
|
892
|
+
printError(`unknown shell '${shell}'`, "Supported shells: bash, zsh, fish");
|
|
894
893
|
process.exit(1);
|
|
895
894
|
}
|
|
896
895
|
|
|
@@ -592,6 +592,8 @@ describe("startCoordinator", () => {
|
|
|
592
592
|
}
|
|
593
593
|
|
|
594
594
|
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
595
|
+
expect(parsed.success).toBe(true);
|
|
596
|
+
expect(parsed.command).toBe("coordinator start");
|
|
595
597
|
expect(parsed.agentName).toBe("coordinator");
|
|
596
598
|
expect(parsed.capability).toBe("coordinator");
|
|
597
599
|
expect(parsed.tmuxSession).toBe("overstory-test-project-coordinator");
|
|
@@ -761,6 +763,8 @@ describe("stopCoordinator", () => {
|
|
|
761
763
|
|
|
762
764
|
const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
|
|
763
765
|
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
766
|
+
expect(parsed.success).toBe(true);
|
|
767
|
+
expect(parsed.command).toBe("coordinator stop");
|
|
764
768
|
expect(parsed.stopped).toBe(true);
|
|
765
769
|
expect(parsed.sessionId).toBe(session.id);
|
|
766
770
|
});
|
|
@@ -930,6 +934,8 @@ describe("statusCoordinator", () => {
|
|
|
930
934
|
const { deps } = makeDeps();
|
|
931
935
|
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
932
936
|
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
937
|
+
expect(parsed.success).toBe(true);
|
|
938
|
+
expect(parsed.command).toBe("coordinator status");
|
|
933
939
|
expect(parsed.running).toBe(false);
|
|
934
940
|
});
|
|
935
941
|
|
|
@@ -951,6 +957,8 @@ describe("statusCoordinator", () => {
|
|
|
951
957
|
|
|
952
958
|
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
953
959
|
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
960
|
+
expect(parsed.success).toBe(true);
|
|
961
|
+
expect(parsed.command).toBe("coordinator status");
|
|
954
962
|
expect(parsed.running).toBe(true);
|
|
955
963
|
expect(parsed.sessionId).toBe(session.id);
|
|
956
964
|
expect(parsed.state).toBe("working");
|
|
@@ -20,6 +20,7 @@ import { createIdentity, loadIdentity } from "../agents/identity.ts";
|
|
|
20
20
|
import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
|
|
21
21
|
import { loadConfig } from "../config.ts";
|
|
22
22
|
import { AgentError, ValidationError } from "../errors.ts";
|
|
23
|
+
import { jsonOutput } from "../json.ts";
|
|
23
24
|
import { printHint, printSuccess, printWarning } from "../logging/color.ts";
|
|
24
25
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
25
26
|
import { createRunStore } from "../sessions/store.ts";
|
|
@@ -462,7 +463,7 @@ async function startCoordinator(
|
|
|
462
463
|
};
|
|
463
464
|
|
|
464
465
|
if (json) {
|
|
465
|
-
|
|
466
|
+
jsonOutput("coordinator start", output);
|
|
466
467
|
} else {
|
|
467
468
|
printSuccess("Coordinator started");
|
|
468
469
|
process.stdout.write(` Tmux: ${tmuxSession}\n`);
|
|
@@ -564,9 +565,13 @@ async function stopCoordinator(opts: { json: boolean }, deps: CoordinatorDeps =
|
|
|
564
565
|
}
|
|
565
566
|
|
|
566
567
|
if (json) {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
568
|
+
jsonOutput("coordinator stop", {
|
|
569
|
+
stopped: true,
|
|
570
|
+
sessionId: session.id,
|
|
571
|
+
watchdogStopped,
|
|
572
|
+
monitorStopped,
|
|
573
|
+
runCompleted,
|
|
574
|
+
});
|
|
570
575
|
} else {
|
|
571
576
|
printSuccess("Coordinator stopped", session.id);
|
|
572
577
|
if (watchdogStopped) {
|
|
@@ -629,9 +634,7 @@ async function statusCoordinator(
|
|
|
629
634
|
session.state === "zombie"
|
|
630
635
|
) {
|
|
631
636
|
if (json) {
|
|
632
|
-
|
|
633
|
-
`${JSON.stringify({ running: false, watchdogRunning, monitorRunning })}\n`,
|
|
634
|
-
);
|
|
637
|
+
jsonOutput("coordinator status", { running: false, watchdogRunning, monitorRunning });
|
|
635
638
|
} else {
|
|
636
639
|
printHint("Coordinator is not running");
|
|
637
640
|
if (watchdogRunning) {
|
|
@@ -668,7 +671,7 @@ async function statusCoordinator(
|
|
|
668
671
|
};
|
|
669
672
|
|
|
670
673
|
if (json) {
|
|
671
|
-
|
|
674
|
+
jsonOutput("coordinator status", status);
|
|
672
675
|
} else {
|
|
673
676
|
const stateLabel = alive ? "running" : session.state;
|
|
674
677
|
process.stdout.write(`Coordinator: ${stateLabel}\n`);
|
|
@@ -116,7 +116,14 @@ describe("costsCommand", () => {
|
|
|
116
116
|
await costsCommand(["--json"]);
|
|
117
117
|
const out = output();
|
|
118
118
|
|
|
119
|
-
|
|
119
|
+
const parsed = JSON.parse(out.trim()) as {
|
|
120
|
+
success: boolean;
|
|
121
|
+
command: string;
|
|
122
|
+
sessions: unknown[];
|
|
123
|
+
};
|
|
124
|
+
expect(parsed.success).toBe(true);
|
|
125
|
+
expect(parsed.command).toBe("costs");
|
|
126
|
+
expect(parsed.sessions).toEqual([]);
|
|
120
127
|
});
|
|
121
128
|
});
|
|
122
129
|
|
|
@@ -149,9 +156,10 @@ describe("costsCommand", () => {
|
|
|
149
156
|
await costsCommand(["--json"]);
|
|
150
157
|
const out = output();
|
|
151
158
|
|
|
152
|
-
const parsed = JSON.parse(out.trim()) as unknown[];
|
|
153
|
-
expect(
|
|
154
|
-
expect(parsed).
|
|
159
|
+
const parsed = JSON.parse(out.trim()) as { success: boolean; sessions: unknown[] };
|
|
160
|
+
expect(parsed.success).toBe(true);
|
|
161
|
+
expect(Array.isArray(parsed.sessions)).toBe(true);
|
|
162
|
+
expect(parsed.sessions).toHaveLength(2);
|
|
155
163
|
});
|
|
156
164
|
|
|
157
165
|
test("JSON output includes expected token fields", async () => {
|
|
@@ -173,9 +181,12 @@ describe("costsCommand", () => {
|
|
|
173
181
|
await costsCommand(["--json"]);
|
|
174
182
|
const out = output();
|
|
175
183
|
|
|
176
|
-
const parsed = JSON.parse(out.trim()) as
|
|
177
|
-
|
|
178
|
-
|
|
184
|
+
const parsed = JSON.parse(out.trim()) as {
|
|
185
|
+
success: boolean;
|
|
186
|
+
sessions: Record<string, unknown>[];
|
|
187
|
+
};
|
|
188
|
+
expect(parsed.sessions).toHaveLength(1);
|
|
189
|
+
const session = parsed.sessions[0];
|
|
179
190
|
expect(session).toBeDefined();
|
|
180
191
|
expect(session?.inputTokens).toBe(100);
|
|
181
192
|
expect(session?.outputTokens).toBe(50);
|
|
@@ -193,8 +204,8 @@ describe("costsCommand", () => {
|
|
|
193
204
|
await costsCommand(["--json", "--agent", "nonexistent"]);
|
|
194
205
|
const out = output();
|
|
195
206
|
|
|
196
|
-
const parsed = JSON.parse(out.trim()) as unknown[];
|
|
197
|
-
expect(parsed).toEqual([]);
|
|
207
|
+
const parsed = JSON.parse(out.trim()) as { success: boolean; sessions: unknown[] };
|
|
208
|
+
expect(parsed.sessions).toEqual([]);
|
|
198
209
|
});
|
|
199
210
|
|
|
200
211
|
test("JSON --by-capability outputs grouped object", async () => {
|
|
@@ -221,12 +232,12 @@ describe("costsCommand", () => {
|
|
|
221
232
|
await costsCommand(["--json", "--by-capability"]);
|
|
222
233
|
const out = output();
|
|
223
234
|
|
|
224
|
-
const parsed = JSON.parse(out.trim()) as Record<string, unknown
|
|
225
|
-
expect(parsed).toBeDefined();
|
|
226
|
-
expect(parsed.builder).toBeDefined();
|
|
227
|
-
expect(parsed.scout).toBeDefined();
|
|
235
|
+
const parsed = JSON.parse(out.trim()) as { grouped: Record<string, unknown> };
|
|
236
|
+
expect(parsed.grouped).toBeDefined();
|
|
237
|
+
expect(parsed.grouped.builder).toBeDefined();
|
|
238
|
+
expect(parsed.grouped.scout).toBeDefined();
|
|
228
239
|
|
|
229
|
-
const builderGroup = parsed.builder as Record<string, unknown>;
|
|
240
|
+
const builderGroup = parsed.grouped.builder as Record<string, unknown>;
|
|
230
241
|
expect(builderGroup.sessions).toBeDefined();
|
|
231
242
|
expect(builderGroup.totals).toBeDefined();
|
|
232
243
|
});
|
|
@@ -415,9 +426,9 @@ describe("costsCommand", () => {
|
|
|
415
426
|
await costsCommand(["--json", "--agent", "builder-1"]);
|
|
416
427
|
const out = output();
|
|
417
428
|
|
|
418
|
-
const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
|
|
419
|
-
expect(parsed).toHaveLength(1);
|
|
420
|
-
expect(parsed[0]?.agentName).toBe("builder-1");
|
|
429
|
+
const parsed = JSON.parse(out.trim()) as { sessions: Record<string, unknown>[] };
|
|
430
|
+
expect(parsed.sessions).toHaveLength(1);
|
|
431
|
+
expect(parsed.sessions[0]?.agentName).toBe("builder-1");
|
|
421
432
|
});
|
|
422
433
|
|
|
423
434
|
test("returns empty for non-existent agent", async () => {
|
|
@@ -429,8 +440,8 @@ describe("costsCommand", () => {
|
|
|
429
440
|
await costsCommand(["--json", "--agent", "nonexistent"]);
|
|
430
441
|
const out = output();
|
|
431
442
|
|
|
432
|
-
const parsed = JSON.parse(out.trim()) as unknown[];
|
|
433
|
-
expect(parsed).toEqual([]);
|
|
443
|
+
const parsed = JSON.parse(out.trim()) as { sessions: unknown[] };
|
|
444
|
+
expect(parsed.sessions).toEqual([]);
|
|
434
445
|
});
|
|
435
446
|
});
|
|
436
447
|
|
|
@@ -456,9 +467,9 @@ describe("costsCommand", () => {
|
|
|
456
467
|
await costsCommand(["--json", "--run", "run-2026-01-01"]);
|
|
457
468
|
const out = output();
|
|
458
469
|
|
|
459
|
-
const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
|
|
460
|
-
expect(parsed).toHaveLength(1);
|
|
461
|
-
expect(parsed[0]?.agentName).toBe("builder-1");
|
|
470
|
+
const parsed = JSON.parse(out.trim()) as { sessions: Record<string, unknown>[] };
|
|
471
|
+
expect(parsed.sessions).toHaveLength(1);
|
|
472
|
+
expect(parsed.sessions[0]?.agentName).toBe("builder-1");
|
|
462
473
|
});
|
|
463
474
|
|
|
464
475
|
test("returns empty when no sessions match run ID", async () => {
|
|
@@ -472,8 +483,8 @@ describe("costsCommand", () => {
|
|
|
472
483
|
await costsCommand(["--json", "--run", "run-nonexistent"]);
|
|
473
484
|
const out = output();
|
|
474
485
|
|
|
475
|
-
const parsed = JSON.parse(out.trim()) as unknown[];
|
|
476
|
-
expect(parsed).toEqual([]);
|
|
486
|
+
const parsed = JSON.parse(out.trim()) as { sessions: unknown[] };
|
|
487
|
+
expect(parsed.sessions).toEqual([]);
|
|
477
488
|
});
|
|
478
489
|
});
|
|
479
490
|
|
|
@@ -557,12 +568,11 @@ describe("costsCommand", () => {
|
|
|
557
568
|
await costsCommand(["--json", "--by-capability"]);
|
|
558
569
|
const out = output();
|
|
559
570
|
|
|
560
|
-
const parsed = JSON.parse(out.trim()) as
|
|
561
|
-
string,
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
expect(parsed.
|
|
565
|
-
expect(parsed.scout?.sessions).toHaveLength(1);
|
|
571
|
+
const parsed = JSON.parse(out.trim()) as {
|
|
572
|
+
grouped: Record<string, { sessions: unknown[]; totals: Record<string, unknown> }>;
|
|
573
|
+
};
|
|
574
|
+
expect(parsed.grouped.builder?.sessions).toHaveLength(3);
|
|
575
|
+
expect(parsed.grouped.scout?.sessions).toHaveLength(1);
|
|
566
576
|
});
|
|
567
577
|
|
|
568
578
|
test("empty data shows no session data message", async () => {
|
|
@@ -591,8 +601,8 @@ describe("costsCommand", () => {
|
|
|
591
601
|
await costsCommand(["--json", "--last", "3"]);
|
|
592
602
|
const out = output();
|
|
593
603
|
|
|
594
|
-
const parsed = JSON.parse(out.trim()) as unknown[];
|
|
595
|
-
expect(parsed).toHaveLength(3);
|
|
604
|
+
const parsed = JSON.parse(out.trim()) as { sessions: unknown[] };
|
|
605
|
+
expect(parsed.sessions).toHaveLength(3);
|
|
596
606
|
});
|
|
597
607
|
|
|
598
608
|
test("default limit is 20", async () => {
|
|
@@ -606,8 +616,8 @@ describe("costsCommand", () => {
|
|
|
606
616
|
await costsCommand(["--json"]);
|
|
607
617
|
const out = output();
|
|
608
618
|
|
|
609
|
-
const parsed = JSON.parse(out.trim()) as unknown[];
|
|
610
|
-
expect(parsed).toHaveLength(20);
|
|
619
|
+
const parsed = JSON.parse(out.trim()) as { sessions: unknown[] };
|
|
620
|
+
expect(parsed.sessions).toHaveLength(20);
|
|
611
621
|
});
|
|
612
622
|
});
|
|
613
623
|
|
|
@@ -705,9 +715,9 @@ describe("costsCommand", () => {
|
|
|
705
715
|
await costsCommand(["--json"]);
|
|
706
716
|
const out = output();
|
|
707
717
|
|
|
708
|
-
const parsed = JSON.parse(out.trim()) as SessionMetrics[];
|
|
709
|
-
const totalInput = parsed.reduce((sum, s) => sum + s.inputTokens, 0);
|
|
710
|
-
const totalOutput = parsed.reduce((sum, s) => sum + s.outputTokens, 0);
|
|
718
|
+
const parsed = JSON.parse(out.trim()) as { sessions: SessionMetrics[] };
|
|
719
|
+
const totalInput = parsed.sessions.reduce((sum, s) => sum + s.inputTokens, 0);
|
|
720
|
+
const totalOutput = parsed.sessions.reduce((sum, s) => sum + s.outputTokens, 0);
|
|
711
721
|
expect(totalInput).toBe(300);
|
|
712
722
|
expect(totalOutput).toBe(150);
|
|
713
723
|
});
|
|
@@ -1106,7 +1116,7 @@ describe("costsCommand", () => {
|
|
|
1106
1116
|
const out = output();
|
|
1107
1117
|
|
|
1108
1118
|
const parsed = JSON.parse(out.trim()) as Record<string, unknown>;
|
|
1109
|
-
expect(parsed.error).toBe("
|
|
1119
|
+
expect(parsed.error).toBe("No orchestrator transcript found");
|
|
1110
1120
|
});
|
|
1111
1121
|
|
|
1112
1122
|
test("--self in help text", async () => {
|