@os-eco/overstory-cli 0.8.7 → 0.9.2

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 (98) hide show
  1. package/README.md +26 -8
  2. package/agents/coordinator.md +30 -6
  3. package/agents/lead.md +11 -1
  4. package/agents/ov-co-creation.md +90 -0
  5. package/package.json +1 -1
  6. package/src/agents/hooks-deployer.test.ts +9 -1
  7. package/src/agents/hooks-deployer.ts +2 -1
  8. package/src/agents/overlay.test.ts +26 -0
  9. package/src/agents/overlay.ts +31 -4
  10. package/src/canopy/client.test.ts +107 -0
  11. package/src/canopy/client.ts +179 -0
  12. package/src/commands/agents.ts +1 -1
  13. package/src/commands/clean.test.ts +3 -0
  14. package/src/commands/clean.ts +1 -58
  15. package/src/commands/completions.test.ts +18 -6
  16. package/src/commands/completions.ts +40 -1
  17. package/src/commands/coordinator.test.ts +77 -4
  18. package/src/commands/coordinator.ts +304 -146
  19. package/src/commands/dashboard.ts +47 -10
  20. package/src/commands/discover.test.ts +288 -0
  21. package/src/commands/discover.ts +202 -0
  22. package/src/commands/doctor.ts +3 -1
  23. package/src/commands/ecosystem.test.ts +126 -1
  24. package/src/commands/ecosystem.ts +7 -53
  25. package/src/commands/feed.test.ts +117 -2
  26. package/src/commands/feed.ts +46 -30
  27. package/src/commands/group.test.ts +274 -155
  28. package/src/commands/group.ts +11 -5
  29. package/src/commands/init.test.ts +2 -1
  30. package/src/commands/init.ts +8 -0
  31. package/src/commands/log.test.ts +35 -0
  32. package/src/commands/log.ts +10 -6
  33. package/src/commands/logs.test.ts +423 -1
  34. package/src/commands/logs.ts +99 -104
  35. package/src/commands/orchestrator.ts +42 -0
  36. package/src/commands/prime.test.ts +177 -2
  37. package/src/commands/prime.ts +4 -2
  38. package/src/commands/sling.ts +23 -3
  39. package/src/commands/update.test.ts +1 -0
  40. package/src/commands/upgrade.test.ts +2 -0
  41. package/src/commands/upgrade.ts +1 -17
  42. package/src/commands/watch.test.ts +67 -1
  43. package/src/commands/watch.ts +13 -88
  44. package/src/config.test.ts +250 -0
  45. package/src/config.ts +43 -0
  46. package/src/doctor/agents.test.ts +72 -5
  47. package/src/doctor/agents.ts +10 -10
  48. package/src/doctor/consistency.test.ts +35 -0
  49. package/src/doctor/consistency.ts +7 -3
  50. package/src/doctor/dependencies.test.ts +58 -1
  51. package/src/doctor/dependencies.ts +4 -2
  52. package/src/doctor/providers.test.ts +41 -5
  53. package/src/doctor/types.ts +2 -1
  54. package/src/doctor/version.test.ts +106 -2
  55. package/src/doctor/version.ts +4 -2
  56. package/src/doctor/watchdog.test.ts +167 -0
  57. package/src/doctor/watchdog.ts +158 -0
  58. package/src/e2e/init-sling-lifecycle.test.ts +4 -2
  59. package/src/errors.test.ts +350 -0
  60. package/src/events/tailer.test.ts +25 -0
  61. package/src/events/tailer.ts +8 -1
  62. package/src/index.ts +9 -1
  63. package/src/mail/store.test.ts +110 -0
  64. package/src/mail/store.ts +2 -1
  65. package/src/runtimes/aider.test.ts +124 -0
  66. package/src/runtimes/aider.ts +147 -0
  67. package/src/runtimes/amp.test.ts +164 -0
  68. package/src/runtimes/amp.ts +154 -0
  69. package/src/runtimes/claude.test.ts +4 -2
  70. package/src/runtimes/goose.test.ts +133 -0
  71. package/src/runtimes/goose.ts +157 -0
  72. package/src/runtimes/pi-guards.ts +2 -1
  73. package/src/runtimes/pi.test.ts +9 -9
  74. package/src/runtimes/pi.ts +6 -7
  75. package/src/runtimes/registry.test.ts +1 -1
  76. package/src/runtimes/registry.ts +13 -4
  77. package/src/runtimes/sapling.ts +2 -1
  78. package/src/runtimes/types.ts +2 -2
  79. package/src/schema-consistency.test.ts +1 -0
  80. package/src/sessions/store.ts +25 -4
  81. package/src/types.ts +65 -1
  82. package/src/utils/bin.test.ts +10 -0
  83. package/src/utils/bin.ts +37 -0
  84. package/src/utils/fs.test.ts +119 -0
  85. package/src/utils/fs.ts +62 -0
  86. package/src/utils/pid.test.ts +68 -0
  87. package/src/utils/pid.ts +45 -0
  88. package/src/utils/time.test.ts +43 -0
  89. package/src/utils/time.ts +37 -0
  90. package/src/utils/version.test.ts +33 -0
  91. package/src/utils/version.ts +70 -0
  92. package/src/watchdog/daemon.test.ts +255 -1
  93. package/src/watchdog/daemon.ts +87 -9
  94. package/src/watchdog/health.test.ts +15 -1
  95. package/src/watchdog/health.ts +1 -1
  96. package/src/watchdog/triage.test.ts +49 -9
  97. package/src/watchdog/triage.ts +21 -5
  98. package/templates/overlay.md.tmpl +2 -0
@@ -59,10 +59,13 @@ const PKG_VERSION: string = JSON.parse(await Bun.file(pkgPath).text()).version ?
59
59
  * These are not colors, so they stay separate from the color module.
60
60
  */
61
61
  const CURSOR = {
62
- clear: "\x1b[2J\x1b[H", // Clear screen and home cursor
62
+ clear: "\x1b[H\x1b[J", // Home cursor then clear from cursor to end
63
+ home: "\x1b[H", // Home cursor only (for redraw without full clear)
63
64
  cursorTo: (row: number, col: number) => `\x1b[${row};${col}H`,
64
65
  hideCursor: "\x1b[?25l",
65
66
  showCursor: "\x1b[?25h",
67
+ enterAltScreen: "\x1b[?1049h", // Enter alternate screen buffer
68
+ leaveAltScreen: "\x1b[?1049l", // Leave alternate screen buffer
66
69
  } as const;
67
70
 
68
71
  /**
@@ -960,11 +963,13 @@ function renderMetricsPanel(
960
963
  /**
961
964
  * Render the full dashboard.
962
965
  */
963
- function renderDashboard(data: DashboardData, interval: number): void {
966
+ function renderDashboard(data: DashboardData, interval: number, isFirstRender: boolean): void {
964
967
  const width = process.stdout.columns ?? 100;
965
968
  const height = process.stdout.rows ?? 30;
966
969
 
967
- let output = CURSOR.clear;
970
+ // First render: clear entire alt screen. Subsequent: just home cursor
971
+ // and overwrite in-place (avoids Warp's block-per-clear issue).
972
+ let output = isFirstRender ? CURSOR.clear : CURSOR.home;
968
973
 
969
974
  // Header (rows 1-2)
970
975
  output += renderHeader(width, interval, data.currentRunId);
@@ -1050,20 +1055,44 @@ async function executeDashboard(opts: DashboardOpts): Promise<void> {
1050
1055
  zombieMs: config.watchdog.zombieThresholdMs,
1051
1056
  };
1052
1057
 
1053
- // Hide cursor
1058
+ // Enter alternate screen buffer (like vim/htop) + hide cursor + raw stdin
1059
+ process.stdout.write(CURSOR.enterAltScreen);
1054
1060
  process.stdout.write(CURSOR.hideCursor);
1061
+ if (process.stdin.isTTY) {
1062
+ process.stdin.setRawMode(true);
1063
+ process.stdin.resume();
1064
+ }
1055
1065
 
1056
- // Clean exit on Ctrl+C
1066
+ // Clean exit on Ctrl+C or 'q': restore original screen
1057
1067
  let running = true;
1058
- process.on("SIGINT", () => {
1068
+ const cleanup = () => {
1059
1069
  running = false;
1070
+ if (process.stdin.isTTY) {
1071
+ process.stdin.setRawMode(false);
1072
+ process.stdin.pause();
1073
+ }
1060
1074
  closeDashboardStores(stores);
1061
1075
  process.stdout.write(CURSOR.showCursor);
1062
- process.stdout.write(CURSOR.clear);
1063
- process.exit(0);
1076
+ process.stdout.write(CURSOR.leaveAltScreen);
1077
+ };
1078
+
1079
+ process.on("SIGINT", () => {
1080
+ cleanup();
1081
+ process.exitCode = 0;
1082
+ });
1083
+
1084
+ // Allow 'q' to quit the dashboard
1085
+ process.stdin.on("data", (data: Buffer) => {
1086
+ const key = data.toString();
1087
+ if (key === "q" || key === "\x03") {
1088
+ // 'q' or Ctrl+C
1089
+ cleanup();
1090
+ process.exitCode = 0;
1091
+ }
1064
1092
  });
1065
1093
 
1066
1094
  // Poll loop — errors are caught per-tick so transient DB failures never crash the dashboard.
1095
+ let isFirstRender = true;
1067
1096
  let lastGoodData: DashboardData | null = null;
1068
1097
  let lastErrorMsg: string | null = null;
1069
1098
  while (running) {
@@ -1077,12 +1106,20 @@ async function executeDashboard(opts: DashboardOpts): Promise<void> {
1077
1106
  config.runtime,
1078
1107
  );
1079
1108
  lastGoodData = data;
1109
+ // If recovering from an error, clear the stale error line at the bottom
1110
+ if (lastErrorMsg !== null) {
1111
+ const w = process.stdout.columns ?? 100;
1112
+ const h = process.stdout.rows ?? 30;
1113
+ process.stdout.write(`${CURSOR.cursorTo(h, 1)}${" ".repeat(w)}`);
1114
+ }
1080
1115
  lastErrorMsg = null;
1081
- renderDashboard(data, interval);
1116
+ renderDashboard(data, interval, isFirstRender);
1117
+ isFirstRender = false;
1082
1118
  } catch (err) {
1083
1119
  // Render last good frame so the TUI stays alive, then show the error inline.
1084
1120
  if (lastGoodData) {
1085
- renderDashboard(lastGoodData, interval);
1121
+ renderDashboard(lastGoodData, interval, isFirstRender);
1122
+ isFirstRender = false;
1086
1123
  }
1087
1124
  lastErrorMsg = err instanceof Error ? err.message : String(err);
1088
1125
  const w = process.stdout.columns ?? 100;
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Tests for ov discover command.
3
+ *
4
+ * Tests cover the pure functions and command structure.
5
+ * The coordinator session startup is not tested here (requires tmux and
6
+ * external processes). Use dependency injection via DiscoverDeps to test
7
+ * that discoverCommand() delegates correctly.
8
+ */
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import { ValidationError } from "../errors.ts";
12
+ import type { CoordinatorSessionOptions } from "./coordinator.ts";
13
+ import {
14
+ buildDiscoveryBeacon,
15
+ buildScoutArgs,
16
+ createDiscoverCommand,
17
+ DISCOVERY_CATEGORIES,
18
+ type DiscoverDeps,
19
+ discoverCommand,
20
+ VALID_CATEGORY_NAMES,
21
+ } from "./discover.ts";
22
+
23
+ describe("DISCOVERY_CATEGORIES", () => {
24
+ test("has exactly 6 categories", () => {
25
+ expect(DISCOVERY_CATEGORIES).toHaveLength(6);
26
+ });
27
+
28
+ test("each category has name, subject, and body", () => {
29
+ for (const category of DISCOVERY_CATEGORIES) {
30
+ expect(category.name).toBeTruthy();
31
+ expect(category.subject).toBeTruthy();
32
+ expect(category.body).toBeTruthy();
33
+ }
34
+ });
35
+
36
+ test("contains all expected category names", () => {
37
+ const names = DISCOVERY_CATEGORIES.map((c) => c.name);
38
+ expect(names).toContain("architecture");
39
+ expect(names).toContain("dependencies");
40
+ expect(names).toContain("testing");
41
+ expect(names).toContain("apis");
42
+ expect(names).toContain("config");
43
+ expect(names).toContain("implicit");
44
+ });
45
+ });
46
+
47
+ describe("VALID_CATEGORY_NAMES", () => {
48
+ test("contains all 6 category names", () => {
49
+ expect(VALID_CATEGORY_NAMES.size).toBe(6);
50
+ expect(VALID_CATEGORY_NAMES.has("architecture")).toBe(true);
51
+ expect(VALID_CATEGORY_NAMES.has("dependencies")).toBe(true);
52
+ expect(VALID_CATEGORY_NAMES.has("testing")).toBe(true);
53
+ expect(VALID_CATEGORY_NAMES.has("apis")).toBe(true);
54
+ expect(VALID_CATEGORY_NAMES.has("config")).toBe(true);
55
+ expect(VALID_CATEGORY_NAMES.has("implicit")).toBe(true);
56
+ });
57
+
58
+ test("does not contain invalid category names", () => {
59
+ expect(VALID_CATEGORY_NAMES.has("unknown")).toBe(false);
60
+ expect(VALID_CATEGORY_NAMES.has("")).toBe(false);
61
+ });
62
+ });
63
+
64
+ describe("buildScoutArgs()", () => {
65
+ test("returns correct args array for a category", () => {
66
+ const category = DISCOVERY_CATEGORIES[0];
67
+ if (!category) throw new Error("DISCOVERY_CATEGORIES is empty");
68
+ const args = buildScoutArgs(category, "task-123", "discover-coordinator");
69
+ expect(args).toContain("ov");
70
+ expect(args).toContain("sling");
71
+ expect(args).toContain("task-123");
72
+ expect(args).toContain("--capability");
73
+ expect(args).toContain("scout");
74
+ expect(args).toContain("--name");
75
+ expect(args).toContain(`discover-${category.name}`);
76
+ expect(args).toContain("--profile");
77
+ expect(args).toContain("ov-discovery");
78
+ expect(args).toContain("--parent");
79
+ expect(args).toContain("discover-coordinator");
80
+ expect(args).toContain("--skip-task-check");
81
+ });
82
+ });
83
+
84
+ describe("buildDiscoveryBeacon()", () => {
85
+ test("includes coordinator name", () => {
86
+ const beacon = buildDiscoveryBeacon(DISCOVERY_CATEGORIES, "discover-coordinator");
87
+ expect(beacon).toContain("discover-coordinator");
88
+ });
89
+
90
+ test("includes all active category names", () => {
91
+ const beacon = buildDiscoveryBeacon(DISCOVERY_CATEGORIES, "discover-coordinator");
92
+ for (const cat of DISCOVERY_CATEGORIES) {
93
+ expect(beacon).toContain(cat.name);
94
+ }
95
+ });
96
+
97
+ test("includes timestamp marker", () => {
98
+ const beacon = buildDiscoveryBeacon(DISCOVERY_CATEGORIES, "discover-coordinator");
99
+ expect(beacon).toContain("[OVERSTORY]");
100
+ });
101
+
102
+ test("excludes skipped categories", () => {
103
+ const active = DISCOVERY_CATEGORIES.filter((c) => c.name !== "testing");
104
+ const beacon = buildDiscoveryBeacon(active, "discover-coordinator");
105
+ // All active categories present
106
+ for (const cat of active) {
107
+ expect(beacon).toContain(cat.name);
108
+ }
109
+ // The skipped category body text should not appear as a standalone discovery target
110
+ // (the name "testing" may appear inside other category descriptions, so check body)
111
+ const testingCat = DISCOVERY_CATEGORIES.find((c) => c.name === "testing");
112
+ if (!testingCat) throw new Error("testing category not found");
113
+ expect(beacon).not.toContain(testingCat.body);
114
+ });
115
+
116
+ test("includes startup instructions", () => {
117
+ const beacon = buildDiscoveryBeacon(DISCOVERY_CATEGORIES, "discover-coordinator");
118
+ expect(beacon).toContain("mulch prime");
119
+ expect(beacon).toContain("spawn one lead per");
120
+ });
121
+ });
122
+
123
+ describe("createDiscoverCommand()", () => {
124
+ test("returns a Command with name 'discover'", () => {
125
+ const cmd = createDiscoverCommand();
126
+ expect(cmd.name()).toBe("discover");
127
+ });
128
+
129
+ test("has --skip option", () => {
130
+ const cmd = createDiscoverCommand();
131
+ const option = cmd.options.find((o) => o.long === "--skip");
132
+ expect(option).toBeDefined();
133
+ });
134
+
135
+ test("has --name option", () => {
136
+ const cmd = createDiscoverCommand();
137
+ const option = cmd.options.find((o) => o.long === "--name");
138
+ expect(option).toBeDefined();
139
+ });
140
+
141
+ test("has --task-id option", () => {
142
+ const cmd = createDiscoverCommand();
143
+ const option = cmd.options.find((o) => o.long === "--task-id");
144
+ expect(option).toBeDefined();
145
+ });
146
+
147
+ test("has --json option", () => {
148
+ const cmd = createDiscoverCommand();
149
+ const option = cmd.options.find((o) => o.long === "--json");
150
+ expect(option).toBeDefined();
151
+ });
152
+
153
+ test("has --attach option", () => {
154
+ const cmd = createDiscoverCommand();
155
+ const option = cmd.options.find((o) => o.long === "--attach");
156
+ expect(option).toBeDefined();
157
+ });
158
+
159
+ test("has --watchdog option", () => {
160
+ const cmd = createDiscoverCommand();
161
+ const option = cmd.options.find((o) => o.long === "--watchdog");
162
+ expect(option).toBeDefined();
163
+ });
164
+
165
+ test("has a description", () => {
166
+ const cmd = createDiscoverCommand();
167
+ expect(cmd.description()).toBeTruthy();
168
+ });
169
+ });
170
+
171
+ describe("discoverCommand() skip validation", () => {
172
+ test("throws ValidationError for invalid category name", async () => {
173
+ await expect(discoverCommand({ skip: "notacategory" })).rejects.toThrow(ValidationError);
174
+ });
175
+
176
+ test("throws ValidationError for mixed valid and invalid categories", async () => {
177
+ await expect(discoverCommand({ skip: "architecture,notacategory" })).rejects.toThrow(
178
+ ValidationError,
179
+ );
180
+ });
181
+
182
+ test("throws ValidationError when all categories are skipped", async () => {
183
+ const allCategories = DISCOVERY_CATEGORIES.map((c) => c.name).join(",");
184
+ await expect(discoverCommand({ skip: allCategories })).rejects.toThrow(ValidationError);
185
+ });
186
+
187
+ test("throws ValidationError with helpful message for invalid category", async () => {
188
+ let thrownError: unknown;
189
+ try {
190
+ await discoverCommand({ skip: "badcategory" });
191
+ } catch (err) {
192
+ thrownError = err;
193
+ }
194
+ expect(thrownError).toBeInstanceOf(ValidationError);
195
+ const ve = thrownError as ValidationError;
196
+ expect(ve.message).toContain("badcategory");
197
+ expect(ve.message).toContain("Valid categories");
198
+ });
199
+ });
200
+
201
+ describe("discoverCommand() delegation", () => {
202
+ test("calls startCoordinatorSession with ov-discovery profile", async () => {
203
+ let capturedOpts: CoordinatorSessionOptions | undefined;
204
+ const deps: DiscoverDeps = {
205
+ _startCoordinatorSession: async (opts) => {
206
+ capturedOpts = opts;
207
+ },
208
+ };
209
+
210
+ await discoverCommand({ attach: false }, deps);
211
+
212
+ expect(capturedOpts).toBeDefined();
213
+ expect(capturedOpts?.profile).toBe("ov-discovery");
214
+ });
215
+
216
+ test("uses default coordinator name 'discover-coordinator'", async () => {
217
+ let capturedOpts: CoordinatorSessionOptions | undefined;
218
+ const deps: DiscoverDeps = {
219
+ _startCoordinatorSession: async (opts) => {
220
+ capturedOpts = opts;
221
+ },
222
+ };
223
+
224
+ await discoverCommand({ attach: false }, deps);
225
+
226
+ expect(capturedOpts?.coordinatorName).toBe("discover-coordinator");
227
+ });
228
+
229
+ test("uses custom name when provided", async () => {
230
+ let capturedOpts: CoordinatorSessionOptions | undefined;
231
+ const deps: DiscoverDeps = {
232
+ _startCoordinatorSession: async (opts) => {
233
+ capturedOpts = opts;
234
+ },
235
+ };
236
+
237
+ await discoverCommand({ name: "my-discover", attach: false }, deps);
238
+
239
+ expect(capturedOpts?.coordinatorName).toBe("my-discover");
240
+ });
241
+
242
+ test("beacon builder returns string containing active category names", async () => {
243
+ let capturedOpts: CoordinatorSessionOptions | undefined;
244
+ const deps: DiscoverDeps = {
245
+ _startCoordinatorSession: async (opts) => {
246
+ capturedOpts = opts;
247
+ },
248
+ };
249
+
250
+ await discoverCommand({ skip: "testing,config,implicit", attach: false }, deps);
251
+
252
+ expect(capturedOpts?.beaconBuilder).toBeDefined();
253
+ const beacon = capturedOpts?.beaconBuilder?.("bd") ?? "";
254
+ expect(beacon).toContain("architecture");
255
+ expect(beacon).toContain("dependencies");
256
+ expect(beacon).toContain("apis");
257
+ // Skipped categories should not appear as category targets in the beacon
258
+ const testingCat = DISCOVERY_CATEGORIES.find((c) => c.name === "testing");
259
+ if (!testingCat) throw new Error("testing category not found");
260
+ expect(beacon).not.toContain(testingCat.body);
261
+ });
262
+
263
+ test("sets monitor: false", async () => {
264
+ let capturedOpts: CoordinatorSessionOptions | undefined;
265
+ const deps: DiscoverDeps = {
266
+ _startCoordinatorSession: async (opts) => {
267
+ capturedOpts = opts;
268
+ },
269
+ };
270
+
271
+ await discoverCommand({ attach: false }, deps);
272
+
273
+ expect(capturedOpts?.monitor).toBe(false);
274
+ });
275
+
276
+ test("forwards watchdog option", async () => {
277
+ let capturedOpts: CoordinatorSessionOptions | undefined;
278
+ const deps: DiscoverDeps = {
279
+ _startCoordinatorSession: async (opts) => {
280
+ capturedOpts = opts;
281
+ },
282
+ };
283
+
284
+ await discoverCommand({ watchdog: true, attach: false }, deps);
285
+
286
+ expect(capturedOpts?.watchdog).toBe(true);
287
+ });
288
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * CLI command: ov discover
3
+ *
4
+ * Launches a coordinator session with the ov-discovery profile to explore a
5
+ * brownfield codebase and produce structured mulch records. The coordinator
6
+ * autonomously spawns leads, which spawn scouts per category, synthesizes
7
+ * results, and writes mulch records.
8
+ */
9
+
10
+ import { Command } from "commander";
11
+ import { ValidationError } from "../errors.ts";
12
+ import type { CoordinatorDeps, CoordinatorSessionOptions } from "./coordinator.ts";
13
+ import { startCoordinatorSession } from "./coordinator.ts";
14
+
15
+ /** A single discovery category with its research focus. */
16
+ export interface DiscoveryCategory {
17
+ name: string;
18
+ subject: string;
19
+ body: string;
20
+ }
21
+
22
+ /** All discovery categories that scouts will explore. */
23
+ export const DISCOVERY_CATEGORIES: DiscoveryCategory[] = [
24
+ {
25
+ name: "architecture",
26
+ subject: "Discover: architecture",
27
+ body: "Explore directory structure, module boundaries, layering conventions, and design patterns. Identify the core architectural style (monolith, layered, hexagonal, etc.), note major subsystems and their relationships, and document any implicit layering rules or boundary conventions.",
28
+ },
29
+ {
30
+ name: "dependencies",
31
+ subject: "Discover: dependencies",
32
+ body: "Catalog all npm packages, CLI tool dependencies, and version constraints. Identify runtime vs dev dependencies, note any unusual or pinned versions, and flag deprecated or potentially problematic packages. Document any external CLIs invoked as subprocesses.",
33
+ },
34
+ {
35
+ name: "testing",
36
+ subject: "Discover: testing",
37
+ body: "Map the test framework, file locations, mock strategy, and coverage gaps. Identify what test runner is used, where tests live relative to source, what mocking patterns are used (and why), and which subsystems lack adequate test coverage.",
38
+ },
39
+ {
40
+ name: "apis",
41
+ subject: "Discover: apis",
42
+ body: "Document exported functions and types, interfaces, error handling patterns, and CLI structure. Identify the public API surface, note how errors are typed and propagated, and document any conventions around return types or async patterns.",
43
+ },
44
+ {
45
+ name: "config",
46
+ subject: "Discover: config",
47
+ body: "Catalog config file formats, environment variables, loading and validation patterns, and default values. Note how configuration is structured (YAML, JSON, env), how it's validated at runtime, and what the expected defaults are.",
48
+ },
49
+ {
50
+ name: "implicit",
51
+ subject: "Discover: implicit",
52
+ body: "Surface naming conventions, error handling style, TODOs, and unwritten rules. Look for patterns in variable naming, file naming, comment style, and any informal conventions that aren't documented. Note recurring TODOs or FIXMEs that indicate known debt.",
53
+ },
54
+ ];
55
+
56
+ /** Set of valid category names for validation. */
57
+ export const VALID_CATEGORY_NAMES: ReadonlySet<string> = new Set(
58
+ DISCOVERY_CATEGORIES.map((c) => c.name),
59
+ );
60
+
61
+ export interface DiscoverOptions {
62
+ skip?: string;
63
+ name?: string;
64
+ taskId?: string;
65
+ attach?: boolean;
66
+ watchdog?: boolean;
67
+ json?: boolean;
68
+ }
69
+
70
+ /** Dependency injection for discoverCommand. Used in tests. */
71
+ export interface DiscoverDeps {
72
+ _startCoordinatorSession?: (
73
+ opts: CoordinatorSessionOptions,
74
+ deps: CoordinatorDeps,
75
+ ) => Promise<void>;
76
+ }
77
+
78
+ /** Parse and validate the --skip option, returning a set of category names to skip. */
79
+ function parseSkipCategories(skip: string | undefined): Set<string> {
80
+ if (!skip) return new Set();
81
+ const names = skip
82
+ .split(",")
83
+ .map((s) => s.trim())
84
+ .filter((s) => s.length > 0);
85
+ const invalid = names.filter((n) => !VALID_CATEGORY_NAMES.has(n));
86
+ if (invalid.length > 0) {
87
+ throw new ValidationError(
88
+ `Invalid category name(s): ${invalid.join(", ")}. Valid categories: ${[...VALID_CATEGORY_NAMES].join(", ")}`,
89
+ );
90
+ }
91
+ return new Set(names);
92
+ }
93
+
94
+ /**
95
+ * Build the discovery beacon — the initial prompt sent to the discover coordinator
96
+ * after Claude Code initializes. Instructs it to spawn one lead per category.
97
+ */
98
+ export function buildDiscoveryBeacon(
99
+ categories: DiscoveryCategory[],
100
+ coordinatorName: string,
101
+ ): string {
102
+ const timestamp = new Date().toISOString();
103
+ const categoryNames = categories.map((c) => c.name).join(", ");
104
+ const categoryDetails = categories.map((c) => `${c.name}: ${c.body}`).join(" | ");
105
+ const parts = [
106
+ `[OVERSTORY] ${coordinatorName} (coordinator) ${timestamp}`,
107
+ `Role: discovery coordinator | Categories: ${categoryNames}`,
108
+ `Startup: run mulch prime, then spawn one lead per active category. Each lead spawns a scout to explore its category area. After all scouts report back, synthesize findings into mulch records.`,
109
+ `Categories: ${categoryDetails}`,
110
+ ];
111
+ return parts.join(" — ");
112
+ }
113
+
114
+ /**
115
+ * Build the scout args for a given discovery category and task ID.
116
+ * Kept for reference and for callers that need per-category sling arguments.
117
+ */
118
+ export function buildScoutArgs(
119
+ category: DiscoveryCategory,
120
+ taskId: string,
121
+ parentName: string,
122
+ ): string[] {
123
+ return [
124
+ "ov",
125
+ "sling",
126
+ taskId,
127
+ "--capability",
128
+ "scout",
129
+ "--name",
130
+ `discover-${category.name}`,
131
+ "--profile",
132
+ "ov-discovery",
133
+ "--parent",
134
+ parentName,
135
+ "--depth",
136
+ "1",
137
+ "--skip-task-check",
138
+ ];
139
+ }
140
+
141
+ /** Main handler for ov discover. */
142
+ export async function discoverCommand(
143
+ opts: DiscoverOptions,
144
+ deps: DiscoverDeps = {},
145
+ ): Promise<void> {
146
+ const json = opts.json ?? false;
147
+ const coordinatorName = opts.name ?? "discover-coordinator";
148
+
149
+ // Validate and parse skip list
150
+ const skipSet = parseSkipCategories(opts.skip);
151
+ const categories = DISCOVERY_CATEGORIES.filter((c) => !skipSet.has(c.name));
152
+
153
+ if (categories.length === 0) {
154
+ throw new ValidationError("All categories skipped — nothing to discover.");
155
+ }
156
+
157
+ const attach = opts.attach !== undefined ? opts.attach : !!process.stdout.isTTY;
158
+
159
+ const startSession = deps._startCoordinatorSession ?? startCoordinatorSession;
160
+
161
+ await startSession(
162
+ {
163
+ json,
164
+ attach,
165
+ watchdog: opts.watchdog ?? false,
166
+ monitor: false,
167
+ profile: "ov-discovery",
168
+ coordinatorName,
169
+ beaconBuilder: (_trackerCli) => buildDiscoveryBeacon(categories, coordinatorName),
170
+ },
171
+ {},
172
+ );
173
+ }
174
+
175
+ /** Commander command factory. */
176
+ export function createDiscoverCommand(): Command {
177
+ return new Command("discover")
178
+ .description("Discover a brownfield codebase via coordinator-driven scout swarm")
179
+ .option(
180
+ "--skip <categories>",
181
+ "Skip specific categories (comma-separated: architecture,dependencies,testing,apis,config,implicit)",
182
+ )
183
+ .option("--name <name>", "Coordinator agent name (default: discover-coordinator)")
184
+ .option("--task-id <id>", "Task ID (unused — kept for backward compatibility)")
185
+ .option("--attach", "Always attach to tmux session after start")
186
+ .option("--no-attach", "Never attach to tmux session after start")
187
+ .option("--watchdog", "Auto-start watchdog daemon with coordinator")
188
+ .option("--json", "Output as JSON")
189
+ .action(
190
+ async (opts: {
191
+ skip?: string;
192
+ name?: string;
193
+ taskId?: string;
194
+ attach?: boolean;
195
+ watchdog?: boolean;
196
+ json?: boolean;
197
+ }) => {
198
+ const attach = opts.attach !== undefined ? opts.attach : !!process.stdout.isTTY;
199
+ await discoverCommand({ ...opts, attach });
200
+ },
201
+ );
202
+ }
@@ -19,6 +19,7 @@ import { checkProviders } from "../doctor/providers.ts";
19
19
  import { checkStructure } from "../doctor/structure.ts";
20
20
  import type { DoctorCategory, DoctorCheck, DoctorCheckFn } from "../doctor/types.ts";
21
21
  import { checkVersion } from "../doctor/version.ts";
22
+ import { checkWatchdog } from "../doctor/watchdog.ts";
22
23
  import { ValidationError } from "../errors.ts";
23
24
  import { jsonOutput } from "../json.ts";
24
25
  import { color } from "../logging/color.ts";
@@ -37,6 +38,7 @@ const ALL_CHECKS: Array<{ category: DoctorCategory; fn: DoctorCheckFn }> = [
37
38
  { category: "version", fn: checkVersion },
38
39
  { category: "ecosystem", fn: checkEcosystem },
39
40
  { category: "providers", fn: checkProviders },
41
+ { category: "watchdog", fn: checkWatchdog },
40
42
  ];
41
43
 
42
44
  /**
@@ -168,7 +170,7 @@ export function createDoctorCommand(options?: DoctorCommandOptions): Command {
168
170
  .option("--fix", "Attempt to auto-fix issues")
169
171
  .addHelpText(
170
172
  "after",
171
- "\nCategories: dependencies, structure, config, databases, consistency, agents, merge, logs, version, ecosystem, providers",
173
+ "\nCategories: dependencies, structure, config, databases, consistency, agents, merge, logs, version, ecosystem, providers, watchdog",
172
174
  )
173
175
  .action(
174
176
  async (opts: { json?: boolean; verbose?: boolean; category?: string; fix?: boolean }) => {