@os-eco/overstory-cli 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,8 @@ Overstory turns a single coding session into a multi-agent team by spawning work
10
10
 
11
11
  > **Warning: Agent swarms are not a universal solution.** Do not deploy Overstory without understanding the risks of multi-agent orchestration — compounding error rates, cost amplification, debugging complexity, and merge conflicts are the normal case, not edge cases. Read [STEELMAN.md](STEELMAN.md) for a full risk analysis and the [Agentic Engineering Book](https://github.com/jayminwest/agentic-engineering-book) ([web version](https://jayminwest.com/agentic-engineering-book)) before using this tool in production.
12
12
 
13
+ > **Maintenance status.** Overstory is maintained part-time. PRs are reviewed in roughly 2-week batches; PRs inactive for 30+ days are closed (reopen anytime). For features larger than ~200 lines, open an issue or discussion first. See [CONTRIBUTING.md](CONTRIBUTING.md#review-cadence).
14
+
13
15
  ## Install
14
16
 
15
17
  Requires [Bun](https://bun.sh) v1.0+, git, and tmux. At least one supported agent runtime must be installed:
@@ -295,6 +297,7 @@ overstory/
295
297
  checkpoint.ts Session checkpoint save/restore
296
298
  lifecycle.ts Handoff orchestration
297
299
  hooks-deployer.ts Deploy hooks + tool enforcement
300
+ copilot-hooks-deployer.ts Deploy hooks config to Copilot worktrees
298
301
  guard-rules.ts Shared guard constants (tool lists, bash patterns)
299
302
  worktree/ Git worktree + tmux management
300
303
  mail/ SQLite mail system (typed protocol, broadcast)
@@ -319,16 +319,16 @@ When a batch is complete (task group auto-closed, all issues resolved):
319
319
  4. **Only then** close the issue: `{{TRACKER_CLI}} close <id> --reason "Merged branch <branch-name>"`.
320
320
 
321
321
  1. Verify all issues are closed: run `{{TRACKER_CLI}} show <id>` for each issue in the group.
322
- 2. Verify all branches are merged: check `ov status` for unmerged branches. If any branch is unmerged, do NOT proceed — wait for the lead's `merge_ready` signal.
323
- 3. Clean up worktrees: `ov worktree clean --completed`.
324
- 4. Record orchestration insights: `ml record <domain> --type <type> --classification <foundational|tactical|observational> --description "<insight>"`.
325
- 5. Commit and sync state files: after all work is merged and issues are closed, commit any outstanding state changes so runtime state is not left uncommitted when the coordinator goes idle:
322
+ 2. Verify all branches are merged: check `ov status` for unmerged branches. If any branch is unmerged, do NOT proceed — wait for the lead's `merge_ready` signal. **Note:** merged branches carry each worker's committed `.mulch/` changes into the canonical branch — this is how discovery scout findings reach the main repo.
323
+ 3. Record orchestration insights: `ml record <domain> --type <type> --classification <foundational|tactical|observational> --description "<insight>"`.
324
+ 4. Commit and sync state files: after all work is merged and issues are closed, commit any outstanding state changes so runtime state is not left uncommitted when the coordinator goes idle:
326
325
  ```bash
327
326
  {{TRACKER_CLI}} sync
328
327
  git add .overstory/ .mulch/
329
328
  git diff --cached --quiet || git commit -m "chore: sync runtime state"
330
329
  git push
331
330
  ```
331
+ 5. Clean up worktrees: `ov worktree clean --completed`. **Only run this after branches are merged and .mulch/ state is committed** — cleaning worktrees before merging destroys any uncommitted scout findings.
332
332
  6. Report to the human operator: summarize what was accomplished, what was merged, any issues encountered.
333
333
  7. Check for follow-up work: `{{TRACKER_CLI}} ready` to see if new issues surfaced during the batch.
334
334
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@os-eco/overstory-cli",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "Multi-agent orchestration for AI coding agents — spawn workers in git worktrees via tmux, coordinate through SQLite mail, merge with tiered conflict resolution. Pluggable runtime adapters for Claude Code, Pi, and more.",
5
5
  "author": "Jaymin West",
6
6
  "license": "MIT",
@@ -0,0 +1,162 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { cleanupTempDir } from "../test-helpers.ts";
6
+ import { deployCopilotHooks } from "./copilot-hooks-deployer.ts";
7
+ import { PATH_PREFIX } from "./hooks-deployer.ts";
8
+
9
+ describe("deployCopilotHooks", () => {
10
+ let tempDir: string;
11
+
12
+ beforeEach(async () => {
13
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-copilot-hooks-test-"));
14
+ });
15
+
16
+ afterEach(async () => {
17
+ await cleanupTempDir(tempDir);
18
+ });
19
+
20
+ test("writes hooks.json to .github/hooks/ directory", async () => {
21
+ const worktreePath = join(tempDir, "worktree");
22
+ await deployCopilotHooks(worktreePath, "my-builder");
23
+
24
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
25
+ const exists = await Bun.file(hooksPath).exists();
26
+ expect(exists).toBe(true);
27
+ });
28
+
29
+ test("creates .github/hooks/ directory if it does not exist", async () => {
30
+ const worktreePath = join(tempDir, "new-worktree");
31
+ // Directory does not exist before the call
32
+ await deployCopilotHooks(worktreePath, "builder-1");
33
+
34
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
35
+ expect(await Bun.file(hooksPath).exists()).toBe(true);
36
+ });
37
+
38
+ test("output file is valid JSON", async () => {
39
+ const worktreePath = join(tempDir, "worktree");
40
+ await deployCopilotHooks(worktreePath, "test-agent");
41
+
42
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
43
+ const raw = await Bun.file(hooksPath).text();
44
+ expect(() => JSON.parse(raw)).not.toThrow();
45
+ });
46
+
47
+ test("output has Copilot schema structure (top-level hooks with onSessionStart)", async () => {
48
+ const worktreePath = join(tempDir, "worktree");
49
+ await deployCopilotHooks(worktreePath, "test-agent");
50
+
51
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
52
+ const config = JSON.parse(await Bun.file(hooksPath).text()) as Record<string, unknown>;
53
+
54
+ expect(config).toHaveProperty("hooks");
55
+ const hooks = config.hooks as Record<string, unknown>;
56
+ expect(hooks).toHaveProperty("onSessionStart");
57
+ expect(Array.isArray(hooks.onSessionStart)).toBe(true);
58
+ });
59
+
60
+ test("replaces {{AGENT_NAME}} with agentName in all commands", async () => {
61
+ const worktreePath = join(tempDir, "worktree");
62
+ await deployCopilotHooks(worktreePath, "scout-agent-42");
63
+
64
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
65
+ const raw = await Bun.file(hooksPath).text();
66
+
67
+ expect(raw).toContain("scout-agent-42");
68
+ expect(raw).not.toContain("{{AGENT_NAME}}");
69
+ });
70
+
71
+ test("prepends PATH_PREFIX to all hook commands", async () => {
72
+ const worktreePath = join(tempDir, "worktree");
73
+ await deployCopilotHooks(worktreePath, "builder-1");
74
+
75
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
76
+ const config = JSON.parse(await Bun.file(hooksPath).text()) as {
77
+ hooks: Record<string, Array<{ command: string }>>;
78
+ };
79
+
80
+ const allCommands = Object.values(config.hooks)
81
+ .flat()
82
+ .map((e) => e.command);
83
+ expect(allCommands.length).toBeGreaterThan(0);
84
+ for (const cmd of allCommands) {
85
+ expect(cmd).toStartWith(PATH_PREFIX);
86
+ }
87
+ });
88
+
89
+ test("onSessionStart entries are objects with command field only (no matcher, no type)", async () => {
90
+ const worktreePath = join(tempDir, "worktree");
91
+ await deployCopilotHooks(worktreePath, "builder-1");
92
+
93
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
94
+ const config = JSON.parse(await Bun.file(hooksPath).text()) as {
95
+ hooks: { onSessionStart: Array<Record<string, unknown>> };
96
+ };
97
+
98
+ for (const entry of config.hooks.onSessionStart) {
99
+ expect(typeof entry.command).toBe("string");
100
+ // Copilot schema has no matcher or type fields
101
+ expect(entry).not.toHaveProperty("matcher");
102
+ expect(entry).not.toHaveProperty("type");
103
+ }
104
+ });
105
+
106
+ test("onSessionStart includes ov prime command", async () => {
107
+ const worktreePath = join(tempDir, "worktree");
108
+ await deployCopilotHooks(worktreePath, "prime-test-agent");
109
+
110
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
111
+ const config = JSON.parse(await Bun.file(hooksPath).text()) as {
112
+ hooks: { onSessionStart: Array<{ command: string }> };
113
+ };
114
+
115
+ const commands = config.hooks.onSessionStart.map((e) => e.command);
116
+ expect(commands.some((c) => c.includes("ov prime") && c.includes("prime-test-agent"))).toBe(
117
+ true,
118
+ );
119
+ });
120
+
121
+ test("onSessionStart includes ov mail check --inject command", async () => {
122
+ const worktreePath = join(tempDir, "worktree");
123
+ await deployCopilotHooks(worktreePath, "mail-test-agent");
124
+
125
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
126
+ const config = JSON.parse(await Bun.file(hooksPath).text()) as {
127
+ hooks: { onSessionStart: Array<{ command: string }> };
128
+ };
129
+
130
+ const commands = config.hooks.onSessionStart.map((e) => e.command);
131
+ expect(
132
+ commands.some((c) => c.includes("ov mail check --inject") && c.includes("mail-test-agent")),
133
+ ).toBe(true);
134
+ });
135
+
136
+ test("all hook commands include ENV_GUARD pattern", async () => {
137
+ const worktreePath = join(tempDir, "worktree");
138
+ await deployCopilotHooks(worktreePath, "guard-test-agent");
139
+
140
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
141
+ const config = JSON.parse(await Bun.file(hooksPath).text()) as {
142
+ hooks: Record<string, Array<{ command: string }>>;
143
+ };
144
+
145
+ const allCommands = Object.values(config.hooks)
146
+ .flat()
147
+ .map((e) => e.command);
148
+ for (const cmd of allCommands) {
149
+ expect(cmd).toContain("OVERSTORY_AGENT_NAME");
150
+ }
151
+ });
152
+
153
+ test("template file exists and is valid JSON after substitution", async () => {
154
+ // Verify template file is present and parseable (basic template health check).
155
+ const templatePath = join(import.meta.dir, "..", "..", "templates", "copilot-hooks.json.tmpl");
156
+ const exists = await Bun.file(templatePath).exists();
157
+ expect(exists).toBe(true);
158
+
159
+ const raw = (await Bun.file(templatePath).text()).replace(/\{\{AGENT_NAME\}\}/g, "test");
160
+ expect(() => JSON.parse(raw)).not.toThrow();
161
+ });
162
+ });
@@ -0,0 +1,93 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { AgentError } from "../errors.ts";
4
+ import { PATH_PREFIX } from "./hooks-deployer.ts";
5
+
6
+ /** Copilot hook entry shape — simpler than Claude Code (no matcher, no type field). */
7
+ interface CopilotHookEntry {
8
+ command: string;
9
+ }
10
+
11
+ /**
12
+ * Resolve the path to the Copilot hooks template file.
13
+ * The template lives at `templates/copilot-hooks.json.tmpl` relative to the repo root.
14
+ */
15
+ function getTemplatePath(): string {
16
+ // src/agents/copilot-hooks-deployer.ts -> repo root is ../../
17
+ return join(dirname(import.meta.dir), "..", "templates", "copilot-hooks.json.tmpl");
18
+ }
19
+
20
+ /**
21
+ * Deploy Copilot lifecycle hooks to an agent's worktree.
22
+ *
23
+ * Reads `templates/copilot-hooks.json.tmpl`, replaces all `{{AGENT_NAME}}` tokens,
24
+ * prepends PATH_PREFIX to every hook command so CLIs (ov, ml, sd) resolve correctly
25
+ * under Copilot's minimal PATH, then writes the result to
26
+ * `<worktreePath>/.github/hooks/hooks.json`.
27
+ *
28
+ * Phase 1: lifecycle hooks only (onSessionStart). No security guards.
29
+ *
30
+ * @param worktreePath - Absolute path to the agent's git worktree
31
+ * @param agentName - The unique name of the agent (replaces {{AGENT_NAME}} in template)
32
+ * @throws {AgentError} If the template is missing or the write fails
33
+ */
34
+ export async function deployCopilotHooks(worktreePath: string, agentName: string): Promise<void> {
35
+ const templatePath = getTemplatePath();
36
+ const file = Bun.file(templatePath);
37
+ const exists = await file.exists();
38
+
39
+ if (!exists) {
40
+ throw new AgentError(`Copilot hooks template not found: ${templatePath}`, {
41
+ agentName,
42
+ });
43
+ }
44
+
45
+ let template: string;
46
+ try {
47
+ template = await file.text();
48
+ } catch (err) {
49
+ throw new AgentError(`Failed to read Copilot hooks template: ${templatePath}`, {
50
+ agentName,
51
+ cause: err instanceof Error ? err : undefined,
52
+ });
53
+ }
54
+
55
+ // Replace all occurrences of {{AGENT_NAME}}
56
+ let content = template;
57
+ while (content.includes("{{AGENT_NAME}}")) {
58
+ content = content.replace("{{AGENT_NAME}}", agentName);
59
+ }
60
+
61
+ // Parse the base config from the template
62
+ const config = JSON.parse(content) as { hooks: Record<string, CopilotHookEntry[]> };
63
+
64
+ // Extend PATH in all hook commands.
65
+ // Copilot CLI executes hooks with a minimal PATH — ~/.bun/bin (where ov, ml, sd live)
66
+ // is not included. Prepend PATH_PREFIX so CLIs resolve correctly.
67
+ for (const entries of Object.values(config.hooks)) {
68
+ for (const entry of entries) {
69
+ entry.command = `${PATH_PREFIX} ${entry.command}`;
70
+ }
71
+ }
72
+
73
+ const hooksDir = join(worktreePath, ".github", "hooks");
74
+ const outputPath = join(hooksDir, "hooks.json");
75
+
76
+ try {
77
+ await mkdir(hooksDir, { recursive: true });
78
+ } catch (err) {
79
+ throw new AgentError(`Failed to create .github/hooks/ directory at: ${hooksDir}`, {
80
+ agentName,
81
+ cause: err instanceof Error ? err : undefined,
82
+ });
83
+ }
84
+
85
+ try {
86
+ await Bun.write(outputPath, `${JSON.stringify(config, null, "\t")}\n`);
87
+ } catch (err) {
88
+ throw new AgentError(`Failed to write Copilot hooks config to: ${outputPath}`, {
89
+ agentName,
90
+ cause: err instanceof Error ? err : undefined,
91
+ });
92
+ }
93
+ }
@@ -194,9 +194,37 @@ export function createBeadsClient(cwd: string): BeadsClient {
194
194
  if (options?.limit !== undefined) {
195
195
  args.push("--limit", String(options.limit));
196
196
  }
197
- const { stdout } = await runBd(args, "list");
198
- const raw = parseJsonOutput<RawBeadIssue[]>(stdout, "list");
199
- return raw.map(normalizeIssue);
197
+ try {
198
+ const { stdout } = await runBd(args, "list");
199
+ const trimmed = stdout.trim();
200
+ if (trimmed === "") return [];
201
+ const parsed: unknown = JSON.parse(trimmed);
202
+ if (Array.isArray(parsed)) {
203
+ // Flat format: RawBeadIssue[]
204
+ return (parsed as RawBeadIssue[]).map(normalizeIssue);
205
+ }
206
+ if (typeof parsed === "object" && parsed !== null) {
207
+ // Tree format: { mol: RawBeadIssue[] } — flatten all groups
208
+ const tree = parsed as Record<string, unknown>;
209
+ const issues: BeadIssue[] = [];
210
+ for (const group of Object.values(tree)) {
211
+ if (Array.isArray(group)) {
212
+ for (const item of group) {
213
+ issues.push(normalizeIssue(item as RawBeadIssue));
214
+ }
215
+ }
216
+ }
217
+ if (issues.length > 0) return issues;
218
+ }
219
+ } catch {
220
+ // fall through to ready fallback
221
+ }
222
+ // Fallback: bd ready --json always returns a flat array
223
+ const { stdout: readyStdout } = await runBd(["ready", "--json"], "ready (list fallback)");
224
+ const readyTrimmed = readyStdout.trim();
225
+ if (readyTrimmed === "") return [];
226
+ const readyRaw = parseJsonOutput<RawBeadIssue[]>(readyTrimmed, "ready (list fallback)");
227
+ return readyRaw.map(normalizeIssue);
200
228
  },
201
229
  };
202
230
  }
@@ -37,6 +37,7 @@ import {
37
37
  killProcessTree,
38
38
  killSession,
39
39
  listSessions,
40
+ sanitizeTmuxName,
40
41
  } from "../worktree/tmux.ts";
41
42
 
42
43
  export interface CleanOptions {
@@ -155,7 +156,7 @@ interface CleanResult {
155
156
  */
156
157
  async function killAllTmuxSessions(overstoryDir: string, projectName: string): Promise<number> {
157
158
  let killed = 0;
158
- const projectPrefix = `overstory-${projectName}-`;
159
+ const projectPrefix = `overstory-${sanitizeTmuxName(projectName)}-`;
159
160
  try {
160
161
  const tmuxSessions = await listSessions();
161
162
  const overStorySessions = tmuxSessions.filter((s) => s.name.startsWith(projectPrefix));
@@ -12,7 +12,10 @@ import {
12
12
  } from "./completions.ts";
13
13
 
14
14
  afterEach(() => {
15
- process.exitCode = undefined;
15
+ // Use 0 not undefined — Bun doesn't reliably clear a nonzero exitCode when
16
+ // reassigned to undefined (see prior fix f3fde1a). If the 1 from completion
17
+ // tests leaks to bun test's shutdown, the suite exits 1 with 0 test failures.
18
+ process.exitCode = 0;
16
19
  });
17
20
 
18
21
  describe("COMMANDS array", () => {
@@ -37,7 +37,9 @@ import {
37
37
  ensureTmuxAvailable,
38
38
  isSessionAlive,
39
39
  killSession,
40
+ sanitizeTmuxName,
40
41
  sendKeys,
42
+ TMUX_SOCKET,
41
43
  waitForTuiReady,
42
44
  } from "../worktree/tmux.ts";
43
45
  import { nudgeAgent } from "./nudge.ts";
@@ -75,7 +77,7 @@ const ASK_DEFAULT_TIMEOUT_S = 120;
75
77
  * Includes the project name to prevent cross-project collisions (overstory-pcef).
76
78
  */
77
79
  function coordinatorTmuxSession(projectName: string, name: string = COORDINATOR_NAME): string {
78
- return `overstory-${projectName}-${name}`;
80
+ return `overstory-${sanitizeTmuxName(projectName)}-${name}`;
79
81
  }
80
82
 
81
83
  /** Dependency injection for testing. Uses real implementations when omitted. */
@@ -630,7 +632,7 @@ export async function startCoordinatorSession(
630
632
  }
631
633
 
632
634
  if (shouldAttach) {
633
- Bun.spawnSync(["tmux", "attach-session", "-t", tmuxSession], {
635
+ Bun.spawnSync(["tmux", "-L", TMUX_SOCKET, "attach-session", "-t", tmuxSession], {
634
636
  stdio: ["inherit", "inherit", "inherit"],
635
637
  });
636
638
  }
@@ -46,6 +46,7 @@ import type {
46
46
  MailMessage,
47
47
  OverstoryConfig,
48
48
  StoredEvent,
49
+ TaskTrackerBackend,
49
50
  } from "../types.ts";
50
51
  import { evaluateHealth } from "../watchdog/health.ts";
51
52
  import { isProcessAlive } from "../worktree/tmux.ts";
@@ -356,6 +357,7 @@ async function loadDashboardData(
356
357
  thresholds?: { staleMs: number; zombieMs: number },
357
358
  eventBuffer?: EventBuffer,
358
359
  runtimeConfig?: OverstoryConfig["runtime"],
360
+ taskTrackerBackend?: TaskTrackerBackend,
359
361
  ): Promise<DashboardData> {
360
362
  // Get all sessions from the pre-opened session store — fall back to cache on SQLite errors.
361
363
  let allSessions: AgentSession[];
@@ -516,7 +518,7 @@ async function loadDashboardData(
516
518
  const now2 = Date.now();
517
519
  if (!trackerCache || now2 - trackerCache.fetchedAt > TRACKER_CACHE_TTL_MS) {
518
520
  try {
519
- const backend = await resolveBackend("auto", root);
521
+ const backend = await resolveBackend(taskTrackerBackend ?? "auto", root);
520
522
  const tracker = createTrackerClient(backend, root);
521
523
  tasks = await tracker.list({ limit: 10 });
522
524
  trackerCache = { tasks, fetchedAt: now2 };
@@ -1104,6 +1106,7 @@ async function executeDashboard(opts: DashboardOpts): Promise<void> {
1104
1106
  thresholds,
1105
1107
  eventBuffer,
1106
1108
  config.runtime,
1109
+ config.taskTracker.backend,
1107
1110
  );
1108
1111
  lastGoodData = data;
1109
1112
  // If recovering from an error, clear the stale error line at the bottom
@@ -158,10 +158,81 @@ export interface DoctorCommandOptions {
158
158
  checkRunners?: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>;
159
159
  }
160
160
 
161
+ interface DoctorActionOpts {
162
+ json?: boolean;
163
+ verbose?: boolean;
164
+ category?: string;
165
+ fix?: boolean;
166
+ }
167
+
161
168
  /**
162
- * Create the Commander command for `overstory doctor`.
169
+ * Run the doctor checks. Returns true if any check failed.
170
+ * Shared by both the Commander action and the programmatic entry point so the
171
+ * exit-code signal never has to travel through `process.exitCode` (which is
172
+ * global mutable state and races with other tests in parallel bun test runs).
163
173
  */
164
- export function createDoctorCommand(options?: DoctorCommandOptions): Command {
174
+ async function runDoctorChecks(
175
+ opts: DoctorActionOpts,
176
+ checkRunners: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>,
177
+ ): Promise<boolean> {
178
+ const json = opts.json ?? false;
179
+ const verbose = opts.verbose ?? false;
180
+ const categoryFilter = opts.category;
181
+ const fix = opts.fix ?? false;
182
+
183
+ if (categoryFilter !== undefined) {
184
+ const validCategories = ALL_CHECKS.map((c) => c.category);
185
+ if (!validCategories.includes(categoryFilter as DoctorCategory)) {
186
+ throw new ValidationError(
187
+ `Invalid category: ${categoryFilter}. Valid categories: ${validCategories.join(", ")}`,
188
+ {
189
+ field: "category",
190
+ value: categoryFilter,
191
+ },
192
+ );
193
+ }
194
+ }
195
+
196
+ const cwd = process.cwd();
197
+ const config = await loadConfig(cwd);
198
+ const overstoryDir = join(config.project.root, ".overstory");
199
+
200
+ const checksToRun = categoryFilter
201
+ ? checkRunners.filter((c) => c.category === categoryFilter)
202
+ : checkRunners;
203
+
204
+ let results: DoctorCheck[] = [];
205
+ for (const { fn } of checksToRun) {
206
+ const checkResults = await fn(config, overstoryDir);
207
+ results.push(...checkResults);
208
+ }
209
+
210
+ let fixedItems: string[] | undefined;
211
+ if (fix) {
212
+ const applied = await applyFixes(results);
213
+ if (applied.length > 0) {
214
+ fixedItems = applied;
215
+ results = [];
216
+ for (const { fn } of checksToRun) {
217
+ const checkResults = await fn(config, overstoryDir);
218
+ results.push(...checkResults);
219
+ }
220
+ }
221
+ }
222
+
223
+ if (json) {
224
+ printJSON(results, fixedItems);
225
+ } else {
226
+ printHumanReadable(results, verbose, checkRunners, fixedItems);
227
+ }
228
+
229
+ return results.some((c) => c.status === "fail");
230
+ }
231
+
232
+ function buildDoctorCommand(
233
+ onResult: (hasFailures: boolean) => void,
234
+ checkRunners: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>,
235
+ ): Command {
165
236
  return new Command("doctor")
166
237
  .description("Run health checks on overstory setup")
167
238
  .option("--json", "Output as JSON")
@@ -172,73 +243,20 @@ export function createDoctorCommand(options?: DoctorCommandOptions): Command {
172
243
  "after",
173
244
  "\nCategories: dependencies, structure, config, databases, consistency, agents, merge, logs, version, ecosystem, providers, watchdog",
174
245
  )
175
- .action(
176
- async (opts: { json?: boolean; verbose?: boolean; category?: string; fix?: boolean }) => {
177
- const json = opts.json ?? false;
178
- const verbose = opts.verbose ?? false;
179
- const categoryFilter = opts.category;
180
- const fix = opts.fix ?? false;
181
-
182
- // Validate category filter if provided
183
- if (categoryFilter !== undefined) {
184
- const validCategories = ALL_CHECKS.map((c) => c.category);
185
- if (!validCategories.includes(categoryFilter as DoctorCategory)) {
186
- throw new ValidationError(
187
- `Invalid category: ${categoryFilter}. Valid categories: ${validCategories.join(", ")}`,
188
- {
189
- field: "category",
190
- value: categoryFilter,
191
- },
192
- );
193
- }
194
- }
195
-
196
- const cwd = process.cwd();
197
- const config = await loadConfig(cwd);
198
- const overstoryDir = join(config.project.root, ".overstory");
199
-
200
- // Filter checks by category if specified
201
- const allChecks = options?.checkRunners ?? ALL_CHECKS;
202
- const checksToRun = categoryFilter
203
- ? allChecks.filter((c) => c.category === categoryFilter)
204
- : allChecks;
205
-
206
- // Run all checks sequentially
207
- let results: DoctorCheck[] = [];
208
- for (const { fn } of checksToRun) {
209
- const checkResults = await fn(config, overstoryDir);
210
- results.push(...checkResults);
211
- }
212
-
213
- // Apply fixes if requested
214
- let fixedItems: string[] | undefined;
215
- if (fix) {
216
- const applied = await applyFixes(results);
217
- if (applied.length > 0) {
218
- fixedItems = applied;
219
- // Re-run all checks to get fresh results after fixes
220
- results = [];
221
- for (const { fn } of checksToRun) {
222
- const checkResults = await fn(config, overstoryDir);
223
- results.push(...checkResults);
224
- }
225
- }
226
- }
227
-
228
- // Output results
229
- if (json) {
230
- printJSON(results, fixedItems);
231
- } else {
232
- printHumanReadable(results, verbose, allChecks, fixedItems);
233
- }
246
+ .action(async (opts: DoctorActionOpts) => {
247
+ onResult(await runDoctorChecks(opts, checkRunners));
248
+ });
249
+ }
234
250
 
235
- // Set exit code if any check failed
236
- const hasFailures = results.some((c) => c.status === "fail");
237
- if (hasFailures) {
238
- process.exitCode = 1;
239
- }
240
- },
241
- );
251
+ /**
252
+ * Create the Commander command for `overstory doctor`.
253
+ */
254
+ export function createDoctorCommand(options?: DoctorCommandOptions): Command {
255
+ return buildDoctorCommand((hasFailures) => {
256
+ if (hasFailures) {
257
+ process.exitCode = 1;
258
+ }
259
+ }, options?.checkRunners ?? ALL_CHECKS);
242
260
  }
243
261
 
244
262
  /**
@@ -250,16 +268,15 @@ export async function doctorCommand(
250
268
  args: string[],
251
269
  options?: DoctorCommandOptions,
252
270
  ): Promise<number | undefined> {
253
- const cmd = createDoctorCommand(options);
271
+ let hasFailures = false;
272
+ const cmd = buildDoctorCommand((result) => {
273
+ hasFailures = result;
274
+ }, options?.checkRunners ?? ALL_CHECKS);
254
275
  cmd.exitOverride();
255
276
 
256
- const prevExitCode = process.exitCode as number | undefined;
257
- process.exitCode = undefined;
258
-
259
277
  try {
260
278
  await cmd.parseAsync(args, { from: "user" });
261
279
  } catch (err: unknown) {
262
- process.exitCode = prevExitCode;
263
280
  if (err && typeof err === "object" && "code" in err) {
264
281
  const code = (err as { code: string }).code;
265
282
  if (code === "commander.helpDisplayed" || code === "commander.version") {
@@ -269,7 +286,5 @@ export async function doctorCommand(
269
286
  throw err;
270
287
  }
271
288
 
272
- const exitCode = process.exitCode === 1 ? 1 : undefined;
273
- process.exitCode = prevExitCode;
274
- return exitCode;
289
+ return hasFailures ? 1 : undefined;
275
290
  }