@os-eco/overstory-cli 0.6.8 → 0.6.10

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 (69) hide show
  1. package/README.md +19 -5
  2. package/agents/builder.md +6 -15
  3. package/agents/lead.md +4 -6
  4. package/agents/merger.md +5 -13
  5. package/agents/reviewer.md +2 -9
  6. package/package.json +1 -1
  7. package/src/agents/hooks-deployer.test.ts +232 -0
  8. package/src/agents/hooks-deployer.ts +54 -8
  9. package/src/agents/overlay.test.ts +156 -1
  10. package/src/agents/overlay.ts +67 -7
  11. package/src/commands/agents.ts +9 -6
  12. package/src/commands/clean.ts +2 -1
  13. package/src/commands/completions.test.ts +8 -20
  14. package/src/commands/completions.ts +7 -6
  15. package/src/commands/coordinator.test.ts +8 -0
  16. package/src/commands/coordinator.ts +11 -8
  17. package/src/commands/costs.test.ts +48 -38
  18. package/src/commands/costs.ts +48 -38
  19. package/src/commands/dashboard.ts +7 -7
  20. package/src/commands/doctor.test.ts +8 -0
  21. package/src/commands/doctor.ts +96 -51
  22. package/src/commands/ecosystem.ts +291 -0
  23. package/src/commands/errors.test.ts +47 -40
  24. package/src/commands/errors.ts +5 -4
  25. package/src/commands/feed.test.ts +40 -33
  26. package/src/commands/feed.ts +5 -4
  27. package/src/commands/group.ts +23 -14
  28. package/src/commands/hooks.ts +2 -1
  29. package/src/commands/init.test.ts +104 -0
  30. package/src/commands/init.ts +11 -7
  31. package/src/commands/inspect.test.ts +2 -0
  32. package/src/commands/inspect.ts +9 -8
  33. package/src/commands/logs.test.ts +5 -6
  34. package/src/commands/logs.ts +2 -1
  35. package/src/commands/mail.test.ts +11 -10
  36. package/src/commands/mail.ts +11 -12
  37. package/src/commands/merge.ts +11 -12
  38. package/src/commands/metrics.test.ts +15 -2
  39. package/src/commands/metrics.ts +3 -2
  40. package/src/commands/monitor.ts +5 -4
  41. package/src/commands/nudge.ts +2 -3
  42. package/src/commands/prime.test.ts +1 -6
  43. package/src/commands/prime.ts +2 -3
  44. package/src/commands/replay.test.ts +62 -55
  45. package/src/commands/replay.ts +3 -2
  46. package/src/commands/run.ts +17 -20
  47. package/src/commands/sling.ts +3 -2
  48. package/src/commands/status.test.ts +2 -1
  49. package/src/commands/status.ts +7 -6
  50. package/src/commands/stop.test.ts +2 -0
  51. package/src/commands/stop.ts +10 -11
  52. package/src/commands/supervisor.ts +7 -6
  53. package/src/commands/trace.test.ts +52 -44
  54. package/src/commands/trace.ts +5 -4
  55. package/src/commands/upgrade.test.ts +46 -0
  56. package/src/commands/upgrade.ts +259 -0
  57. package/src/commands/watch.ts +8 -10
  58. package/src/commands/worktree.test.ts +21 -15
  59. package/src/commands/worktree.ts +10 -4
  60. package/src/doctor/databases.test.ts +38 -0
  61. package/src/doctor/databases.ts +7 -10
  62. package/src/doctor/ecosystem.test.ts +307 -0
  63. package/src/doctor/ecosystem.ts +155 -0
  64. package/src/doctor/merge-queue.test.ts +98 -0
  65. package/src/doctor/merge-queue.ts +23 -0
  66. package/src/doctor/structure.test.ts +130 -1
  67. package/src/doctor/structure.ts +87 -1
  68. package/src/doctor/types.ts +5 -2
  69. package/src/index.ts +25 -1
@@ -4,7 +4,15 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { AgentError } from "../errors.ts";
6
6
  import type { OverlayConfig, QualityGate } from "../types.ts";
7
- import { generateOverlay, isCanonicalRoot, writeOverlay } from "./overlay.ts";
7
+ import {
8
+ formatQualityGatesBash,
9
+ formatQualityGatesCapabilities,
10
+ formatQualityGatesInline,
11
+ formatQualityGatesSteps,
12
+ generateOverlay,
13
+ isCanonicalRoot,
14
+ writeOverlay,
15
+ } from "./overlay.ts";
8
16
 
9
17
  const SAMPLE_BASE_DEFINITION = `# Builder Agent
10
18
 
@@ -674,3 +682,150 @@ describe("isCanonicalRoot", () => {
674
682
  expect(isCanonicalRoot(worktreePath, canonicalRoot)).toBe(false);
675
683
  });
676
684
  });
685
+
686
+ describe("formatQualityGatesInline", () => {
687
+ test("formats default gates as inline backtick list", () => {
688
+ const result = formatQualityGatesInline(undefined);
689
+ expect(result).toBe("`bun test`, `bun run lint`, `bun run typecheck`");
690
+ });
691
+
692
+ test("formats custom gates as inline backtick list", () => {
693
+ const gates: QualityGate[] = [
694
+ { name: "Test", command: "pytest", description: "all tests pass" },
695
+ { name: "Lint", command: "ruff check .", description: "no lint errors" },
696
+ ];
697
+ const result = formatQualityGatesInline(gates);
698
+ expect(result).toBe("`pytest`, `ruff check .`");
699
+ });
700
+
701
+ test("falls back to defaults for empty array", () => {
702
+ const result = formatQualityGatesInline([]);
703
+ expect(result).toContain("`bun test`");
704
+ });
705
+ });
706
+
707
+ describe("formatQualityGatesSteps", () => {
708
+ test("formats default gates as numbered steps", () => {
709
+ const result = formatQualityGatesSteps(undefined);
710
+ expect(result).toContain("1. Run `bun test`");
711
+ expect(result).toContain("2. Run `bun run lint`");
712
+ expect(result).toContain("3. Run `bun run typecheck`");
713
+ });
714
+
715
+ test("formats custom gates as numbered steps", () => {
716
+ const gates: QualityGate[] = [
717
+ { name: "Build", command: "cargo build", description: "compilation succeeds" },
718
+ { name: "Test", command: "cargo test", description: "all tests pass" },
719
+ ];
720
+ const result = formatQualityGatesSteps(gates);
721
+ expect(result).toBe(
722
+ "1. Run `cargo build` -- compilation succeeds.\n2. Run `cargo test` -- all tests pass.",
723
+ );
724
+ });
725
+ });
726
+
727
+ describe("formatQualityGatesBash", () => {
728
+ test("formats as fenced bash block with aligned comments", () => {
729
+ const result = formatQualityGatesBash(undefined);
730
+ expect(result).toContain("```bash");
731
+ expect(result).toContain("bun test");
732
+ expect(result).toContain("bun run lint");
733
+ expect(result).toContain("bun run typecheck");
734
+ expect(result).toContain("```");
735
+ });
736
+
737
+ test("capitalizes first letter of description in comments", () => {
738
+ const gates: QualityGate[] = [
739
+ { name: "Test", command: "pytest", description: "all tests pass" },
740
+ ];
741
+ const result = formatQualityGatesBash(gates);
742
+ expect(result).toContain("# All tests pass");
743
+ });
744
+
745
+ test("custom gates produce correct bash block", () => {
746
+ const gates: QualityGate[] = [
747
+ { name: "Test", command: "npm test", description: "tests pass" },
748
+ { name: "Lint", command: "npm run lint", description: "lint clean" },
749
+ ];
750
+ const result = formatQualityGatesBash(gates);
751
+ expect(result).toContain("npm test");
752
+ expect(result).toContain("npm run lint");
753
+ expect(result).not.toContain("bun");
754
+ });
755
+ });
756
+
757
+ describe("formatQualityGatesCapabilities", () => {
758
+ test("formats as indented bullet list", () => {
759
+ const result = formatQualityGatesCapabilities(undefined);
760
+ expect(result).toContain(" - `bun test`");
761
+ expect(result).toContain(" - `bun run lint`");
762
+ expect(result).toContain(" - `bun run typecheck`");
763
+ });
764
+
765
+ test("custom gates produce correct capability bullets", () => {
766
+ const gates: QualityGate[] = [
767
+ { name: "Test", command: "pytest", description: "run tests" },
768
+ { name: "Type", command: "mypy .", description: "type check" },
769
+ ];
770
+ const result = formatQualityGatesCapabilities(gates);
771
+ expect(result).toBe(" - `pytest` (run tests)\n - `mypy .` (type check)");
772
+ });
773
+ });
774
+
775
+ describe("quality gate placeholders in base definitions", () => {
776
+ test("QUALITY_GATE_INLINE in base definition gets replaced", async () => {
777
+ const config = makeConfig({
778
+ baseDefinition: "Run {{QUALITY_GATE_INLINE}} before closing.",
779
+ });
780
+ const output = await generateOverlay(config);
781
+ expect(output).toContain("`bun test`, `bun run lint`, `bun run typecheck`");
782
+ expect(output).not.toContain("{{QUALITY_GATE_INLINE}}");
783
+ });
784
+
785
+ test("QUALITY_GATE_STEPS in base definition gets replaced", async () => {
786
+ const config = makeConfig({
787
+ baseDefinition: "## Steps\n{{QUALITY_GATE_STEPS}}",
788
+ });
789
+ const output = await generateOverlay(config);
790
+ expect(output).toContain("1. Run `bun test`");
791
+ expect(output).not.toContain("{{QUALITY_GATE_STEPS}}");
792
+ });
793
+
794
+ test("QUALITY_GATE_BASH in base definition gets replaced", async () => {
795
+ const config = makeConfig({
796
+ baseDefinition: "## Workflow\n{{QUALITY_GATE_BASH}}",
797
+ });
798
+ const output = await generateOverlay(config);
799
+ expect(output).toContain("```bash");
800
+ expect(output).toContain("bun test");
801
+ expect(output).not.toContain("{{QUALITY_GATE_BASH}}");
802
+ });
803
+
804
+ test("QUALITY_GATE_CAPABILITIES in base definition gets replaced", async () => {
805
+ const config = makeConfig({
806
+ baseDefinition: "## Caps\n{{QUALITY_GATE_CAPABILITIES}}",
807
+ });
808
+ const output = await generateOverlay(config);
809
+ expect(output).toContain(" - `bun test`");
810
+ expect(output).not.toContain("{{QUALITY_GATE_CAPABILITIES}}");
811
+ });
812
+
813
+ test("custom quality gates in base definition get custom commands", async () => {
814
+ const gates: QualityGate[] = [
815
+ { name: "Test", command: "pytest", description: "all tests pass" },
816
+ { name: "Lint", command: "ruff check .", description: "no lint errors" },
817
+ ];
818
+ const config = makeConfig({
819
+ capability: "builder",
820
+ qualityGates: gates,
821
+ baseDefinition:
822
+ "Run {{QUALITY_GATE_INLINE}} before closing.\n{{QUALITY_GATE_BASH}}\n{{QUALITY_GATE_STEPS}}",
823
+ });
824
+ const output = await generateOverlay(config);
825
+ expect(output).toContain("`pytest`, `ruff check .`");
826
+ expect(output).toContain("pytest");
827
+ expect(output).toContain("ruff check .");
828
+ expect(output).not.toContain("bun test");
829
+ expect(output).not.toContain("{{QUALITY_GATE");
830
+ });
831
+ });
@@ -1,5 +1,6 @@
1
1
  import { mkdir } from "node:fs/promises";
2
2
  import { dirname, join, resolve } from "node:path";
3
+ import { DEFAULT_QUALITY_GATES } from "../config.ts";
3
4
  import { AgentError } from "../errors.ts";
4
5
  import type { OverlayConfig, QualityGate } from "../types.ts";
5
6
 
@@ -76,12 +77,65 @@ Your parent has already gathered the context you need.
76
77
  * a lightweight section that only tells them to close the issue and report.
77
78
  * Writable agents get the full quality gates (tests, lint, build, commit).
78
79
  */
79
- /** Default quality gates used when none are configured. */
80
- const DEFAULT_GATES: QualityGate[] = [
81
- { name: "Tests", command: "bun test", description: "all tests must pass" },
82
- { name: "Lint", command: "bun run lint", description: "zero errors" },
83
- { name: "Typecheck", command: "bun run typecheck", description: "no TypeScript errors" },
84
- ];
80
+ /**
81
+ * Resolve quality gates: use provided gates if non-empty, otherwise fall back to defaults.
82
+ */
83
+ function resolveGates(gates: QualityGate[] | undefined): QualityGate[] {
84
+ return gates && gates.length > 0 ? gates : DEFAULT_QUALITY_GATES;
85
+ }
86
+
87
+ /**
88
+ * Format quality gates as inline backtick-delimited commands for prose sections.
89
+ * Example: `bun test`, `bun run lint`, `bun run typecheck`
90
+ */
91
+ export function formatQualityGatesInline(gates: QualityGate[] | undefined): string {
92
+ return resolveGates(gates)
93
+ .map((g) => `\`${g.command}\``)
94
+ .join(", ");
95
+ }
96
+
97
+ /**
98
+ * Format quality gates as a numbered step list for completion-protocol sections.
99
+ * Example:
100
+ * 1. Run `bun test` -- all tests must pass.
101
+ * 2. Run `bun run lint` -- lint and formatting must be clean.
102
+ */
103
+ export function formatQualityGatesSteps(gates: QualityGate[] | undefined): string {
104
+ return resolveGates(gates)
105
+ .map((g, i) => `${i + 1}. Run \`${g.command}\` -- ${g.description}.`)
106
+ .join("\n");
107
+ }
108
+
109
+ /**
110
+ * Format quality gates as a bash code block for workflow sections.
111
+ * Example:
112
+ * ```bash
113
+ * bun test # All tests must pass
114
+ * bun run lint # Lint and format must be clean
115
+ * ```
116
+ */
117
+ export function formatQualityGatesBash(gates: QualityGate[] | undefined): string {
118
+ const resolved = resolveGates(gates);
119
+ // Pad commands to align comments
120
+ const maxLen = Math.max(...resolved.map((g) => g.command.length));
121
+ const lines = resolved.map((g) => {
122
+ const padded = g.command.padEnd(maxLen + 2);
123
+ return `${padded}# ${g.description[0]?.toUpperCase() ?? ""}${g.description.slice(1)}`;
124
+ });
125
+ return ["```bash", ...lines, "```"].join("\n");
126
+ }
127
+
128
+ /**
129
+ * Format quality gates as a bullet list for capabilities sections.
130
+ * Example:
131
+ * - `bun test` (run tests)
132
+ * - `bun run lint` (lint and format check via biome)
133
+ */
134
+ export function formatQualityGatesCapabilities(gates: QualityGate[] | undefined): string {
135
+ return resolveGates(gates)
136
+ .map((g) => ` - \`${g.command}\` (${g.description})`)
137
+ .join("\n");
138
+ }
85
139
 
86
140
  function formatQualityGates(config: OverlayConfig): string {
87
141
  if (READ_ONLY_CAPABILITIES.has(config.capability)) {
@@ -99,7 +153,9 @@ function formatQualityGates(config: OverlayConfig): string {
99
153
  }
100
154
 
101
155
  const gates =
102
- config.qualityGates && config.qualityGates.length > 0 ? config.qualityGates : DEFAULT_GATES;
156
+ config.qualityGates && config.qualityGates.length > 0
157
+ ? config.qualityGates
158
+ : DEFAULT_QUALITY_GATES;
103
159
 
104
160
  const gateLines = gates.map(
105
161
  (gate, i) => `${i + 1}. **${gate.name}:** \`${gate.command}\` — ${gate.description}`,
@@ -220,6 +276,10 @@ export async function generateOverlay(config: OverlayConfig): Promise<string> {
220
276
  "{{SPEC_INSTRUCTION}}": specInstruction,
221
277
  "{{SKIP_SCOUT}}": config.skipScout ? SKIP_SCOUT_SECTION : "",
222
278
  "{{BASE_DEFINITION}}": config.baseDefinition,
279
+ "{{QUALITY_GATE_INLINE}}": formatQualityGatesInline(config.qualityGates),
280
+ "{{QUALITY_GATE_STEPS}}": formatQualityGatesSteps(config.qualityGates),
281
+ "{{QUALITY_GATE_BASH}}": formatQualityGatesBash(config.qualityGates),
282
+ "{{QUALITY_GATE_CAPABILITIES}}": formatQualityGatesCapabilities(config.qualityGates),
223
283
  "{{TRACKER_CLI}}": config.trackerCli ?? "bd",
224
284
  "{{TRACKER_NAME}}": config.trackerName ?? "beads",
225
285
  };
@@ -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
 
@@ -203,54 +203,42 @@ describe("completionsCommand", () => {
203
203
  });
204
204
 
205
205
  it("should exit with error for missing shell argument", () => {
206
- const originalExit = process.exit;
206
+ const originalExitCode = process.exitCode;
207
207
  const originalStderr = process.stderr.write;
208
- let exitCode: number | undefined;
209
208
  let stderrOutput = "";
210
209
 
211
- process.exit = mock((code?: string | number | null | undefined) => {
212
- exitCode = typeof code === "number" ? code : 1;
213
- throw new Error("process.exit called");
214
- }) as never;
215
-
216
210
  process.stderr.write = mock((chunk: unknown) => {
217
211
  stderrOutput += String(chunk);
218
212
  return true;
219
213
  });
220
214
 
221
215
  try {
222
- expect(() => completionsCommand([])).toThrow("process.exit called");
223
- expect(exitCode).toBe(1);
216
+ completionsCommand([]);
217
+ expect(process.exitCode).toBe(1);
224
218
  expect(stderrOutput).toContain("missing shell argument");
225
219
  } finally {
226
- process.exit = originalExit;
220
+ process.exitCode = originalExitCode;
227
221
  process.stderr.write = originalStderr;
228
222
  }
229
223
  });
230
224
 
231
225
  it("should exit with error for unknown shell", () => {
232
- const originalExit = process.exit;
226
+ const originalExitCode = process.exitCode;
233
227
  const originalStderr = process.stderr.write;
234
- let exitCode: number | undefined;
235
228
  let stderrOutput = "";
236
229
 
237
- process.exit = mock((code?: string | number | null | undefined) => {
238
- exitCode = typeof code === "number" ? code : 1;
239
- throw new Error("process.exit called");
240
- }) as never;
241
-
242
230
  process.stderr.write = mock((chunk: unknown) => {
243
231
  stderrOutput += String(chunk);
244
232
  return true;
245
233
  });
246
234
 
247
235
  try {
248
- expect(() => completionsCommand(["powershell"])).toThrow("process.exit called");
249
- expect(exitCode).toBe(1);
236
+ completionsCommand(["powershell"]);
237
+ expect(process.exitCode).toBe(1);
250
238
  expect(stderrOutput).toContain("unknown shell");
251
239
  expect(stderrOutput).toContain("powershell");
252
240
  } finally {
253
- process.exit = originalExit;
241
+ process.exitCode = originalExitCode;
254
242
  process.stderr.write = originalStderr;
255
243
  }
256
244
  });
@@ -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,9 +873,9 @@ 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");
877
- process.exit(1);
876
+ printError("missing shell argument", "Usage: ov --completions <bash|zsh|fish>");
877
+ process.exitCode = 1;
878
+ return;
878
879
  }
879
880
 
880
881
  let script: string;
@@ -889,9 +890,9 @@ export function completionsCommand(args: string[]): void {
889
890
  script = generateFish();
890
891
  break;
891
892
  default:
892
- process.stderr.write(`Error: unknown shell '${shell}'\n`);
893
- process.stderr.write("Supported shells: bash, zsh, fish\n");
894
- process.exit(1);
893
+ printError(`unknown shell '${shell}'`, "Supported shells: bash, zsh, fish");
894
+ process.exitCode = 1;
895
+ return;
895
896
  }
896
897
 
897
898
  process.stdout.write(script);
@@ -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`);