@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.
Files changed (50) hide show
  1. package/README.md +4 -2
  2. package/package.json +1 -1
  3. package/src/agents/hooks-deployer.test.ts +180 -0
  4. package/src/agents/hooks-deployer.ts +32 -1
  5. package/src/commands/agents.ts +9 -6
  6. package/src/commands/clean.ts +2 -1
  7. package/src/commands/completions.ts +3 -4
  8. package/src/commands/coordinator.test.ts +8 -0
  9. package/src/commands/coordinator.ts +11 -8
  10. package/src/commands/costs.test.ts +48 -38
  11. package/src/commands/costs.ts +48 -38
  12. package/src/commands/dashboard.ts +7 -7
  13. package/src/commands/doctor.test.ts +8 -0
  14. package/src/commands/doctor.ts +2 -6
  15. package/src/commands/errors.test.ts +47 -40
  16. package/src/commands/errors.ts +5 -4
  17. package/src/commands/feed.test.ts +40 -33
  18. package/src/commands/feed.ts +3 -2
  19. package/src/commands/group.ts +23 -14
  20. package/src/commands/hooks.ts +2 -1
  21. package/src/commands/init.test.ts +104 -0
  22. package/src/commands/init.ts +11 -7
  23. package/src/commands/inspect.test.ts +2 -0
  24. package/src/commands/inspect.ts +9 -8
  25. package/src/commands/logs.test.ts +5 -6
  26. package/src/commands/logs.ts +2 -1
  27. package/src/commands/mail.test.ts +11 -10
  28. package/src/commands/mail.ts +11 -12
  29. package/src/commands/merge.ts +11 -12
  30. package/src/commands/metrics.test.ts +15 -2
  31. package/src/commands/metrics.ts +3 -2
  32. package/src/commands/monitor.ts +5 -4
  33. package/src/commands/nudge.ts +2 -3
  34. package/src/commands/prime.test.ts +1 -6
  35. package/src/commands/prime.ts +2 -3
  36. package/src/commands/replay.test.ts +62 -55
  37. package/src/commands/replay.ts +3 -2
  38. package/src/commands/run.ts +17 -20
  39. package/src/commands/sling.ts +2 -1
  40. package/src/commands/status.test.ts +2 -1
  41. package/src/commands/status.ts +7 -6
  42. package/src/commands/stop.test.ts +2 -0
  43. package/src/commands/stop.ts +10 -11
  44. package/src/commands/supervisor.ts +7 -6
  45. package/src/commands/trace.test.ts +52 -44
  46. package/src/commands/trace.ts +5 -4
  47. package/src/commands/watch.ts +8 -10
  48. package/src/commands/worktree.test.ts +21 -15
  49. package/src/commands/worktree.ts +10 -4
  50. 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` (2167 tests across 77 files, colocated with source)
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 (2167 tests across 77 files)
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.8",
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 and merge guards into PreToolUse
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);
@@ -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 { color } from "../logging/color.ts";
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(` Parent: ${agent.parentAgent ?? "none"} | Depth: ${agent.depth}\n`);
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
- process.stdout.write(`${JSON.stringify(agents, null, "\t")}\n`);
226
+ jsonOutput("agents discover", { agents });
224
227
  } else {
225
228
  printAgents(agents);
226
229
  }
@@ -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
- process.stdout.write(`${JSON.stringify(result, null, "\t")}\n`);
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
- process.stderr.write("Error: missing shell argument\n");
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
- process.stderr.write(`Error: unknown shell '${shell}'\n`);
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
- process.stdout.write(`${JSON.stringify(output)}\n`);
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
- process.stdout.write(
568
- `${JSON.stringify({ stopped: true, sessionId: session.id, watchdogStopped, monitorStopped, runCompleted })}\n`,
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
- process.stdout.write(
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
- process.stdout.write(`${JSON.stringify(status)}\n`);
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
- expect(out).toBe("[]\n");
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(Array.isArray(parsed)).toBe(true);
154
- expect(parsed).toHaveLength(2);
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 Record<string, unknown>[];
177
- expect(parsed).toHaveLength(1);
178
- const session = parsed[0];
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 Record<
561
- string,
562
- { sessions: unknown[]; totals: Record<string, unknown> }
563
- >;
564
- expect(parsed.builder?.sessions).toHaveLength(3);
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("no_transcript");
1119
+ expect(parsed.error).toBe("No orchestrator transcript found");
1110
1120
  });
1111
1121
 
1112
1122
  test("--self in help text", async () => {