@os-eco/overstory-cli 0.7.4 → 0.7.6

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.
@@ -10,15 +10,148 @@
10
10
  */
11
11
 
12
12
  import { Database } from "bun:sqlite";
13
- import { mkdir, readdir } from "node:fs/promises";
13
+ import { mkdir, readdir, stat } from "node:fs/promises";
14
14
  import { basename, join } from "node:path";
15
15
  import { DEFAULT_CONFIG } from "../config.ts";
16
16
  import { ValidationError } from "../errors.ts";
17
- import { printHint, printSuccess } from "../logging/color.ts";
17
+ import { jsonOutput } from "../json.ts";
18
+ import { printHint, printSuccess, printWarning } from "../logging/color.ts";
18
19
  import type { AgentManifest, OverstoryConfig } from "../types.ts";
19
20
 
20
21
  const OVERSTORY_DIR = ".overstory";
21
22
 
23
+ // ---- Ecosystem Bootstrap ----
24
+
25
+ /**
26
+ * Spawner abstraction for testability.
27
+ * Wraps Bun.spawn for running sibling CLI tools.
28
+ */
29
+ export type Spawner = (
30
+ args: string[],
31
+ opts?: { cwd?: string },
32
+ ) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
33
+
34
+ const defaultSpawner: Spawner = async (args, opts) => {
35
+ const proc = Bun.spawn(args, {
36
+ cwd: opts?.cwd,
37
+ stdout: "pipe",
38
+ stderr: "pipe",
39
+ });
40
+ const exitCode = await proc.exited;
41
+ const stdout = await new Response(proc.stdout).text();
42
+ const stderr = await new Response(proc.stderr).text();
43
+ return { exitCode, stdout, stderr };
44
+ };
45
+
46
+ interface SiblingTool {
47
+ name: string;
48
+ cli: string;
49
+ dotDir: string;
50
+ initCmd: string[];
51
+ onboardCmd: string[];
52
+ }
53
+
54
+ const SIBLING_TOOLS: SiblingTool[] = [
55
+ { name: "mulch", cli: "ml", dotDir: ".mulch", initCmd: ["init"], onboardCmd: ["onboard"] },
56
+ { name: "seeds", cli: "sd", dotDir: ".seeds", initCmd: ["init"], onboardCmd: ["onboard"] },
57
+ { name: "canopy", cli: "cn", dotDir: ".canopy", initCmd: ["init"], onboardCmd: ["onboard"] },
58
+ ];
59
+
60
+ type ToolStatus = "initialized" | "already_initialized" | "skipped";
61
+ type OnboardStatus = "appended" | "current";
62
+
63
+ /**
64
+ * Resolve the set of sibling tools to bootstrap.
65
+ *
66
+ * If opts.tools is set (comma-separated list of names), filter to those.
67
+ * Otherwise start with all three and remove any skipped via skip flags.
68
+ */
69
+ export function resolveToolSet(opts: InitOptions): SiblingTool[] {
70
+ if (opts.tools) {
71
+ const requested = opts.tools.split(",").map((t) => t.trim());
72
+ return SIBLING_TOOLS.filter((t) => requested.includes(t.name));
73
+ }
74
+ return SIBLING_TOOLS.filter((t) => {
75
+ if (t.name === "mulch" && opts.skipMulch) return false;
76
+ if (t.name === "seeds" && opts.skipSeeds) return false;
77
+ if (t.name === "canopy" && opts.skipCanopy) return false;
78
+ return true;
79
+ });
80
+ }
81
+
82
+ async function isToolInstalled(cli: string, spawner: Spawner): Promise<boolean> {
83
+ const result = await spawner([cli, "--version"]);
84
+ return result.exitCode === 0;
85
+ }
86
+
87
+ async function initSiblingTool(
88
+ tool: SiblingTool,
89
+ projectRoot: string,
90
+ spawner: Spawner,
91
+ ): Promise<ToolStatus> {
92
+ const installed = await isToolInstalled(tool.cli, spawner);
93
+ if (!installed) {
94
+ printWarning(
95
+ `${tool.name} not installed — skipping`,
96
+ `install: npm i -g @os-eco/${tool.name}-cli`,
97
+ );
98
+ return "skipped";
99
+ }
100
+
101
+ const result = await spawner([tool.cli, ...tool.initCmd], { cwd: projectRoot });
102
+ if (result.exitCode !== 0) {
103
+ // Check if dot directory already exists (already initialized)
104
+ try {
105
+ await stat(join(projectRoot, tool.dotDir));
106
+ return "already_initialized";
107
+ } catch {
108
+ // Directory doesn't exist — real failure
109
+ printWarning(`${tool.name} init failed`, result.stderr.trim() || result.stdout.trim());
110
+ return "skipped";
111
+ }
112
+ }
113
+
114
+ printSuccess(`Bootstrapped ${tool.name}`);
115
+ return "initialized";
116
+ }
117
+
118
+ async function onboardTool(
119
+ tool: SiblingTool,
120
+ projectRoot: string,
121
+ spawner: Spawner,
122
+ ): Promise<OnboardStatus> {
123
+ const installed = await isToolInstalled(tool.cli, spawner);
124
+ if (!installed) return "current";
125
+
126
+ const result = await spawner([tool.cli, ...tool.onboardCmd], { cwd: projectRoot });
127
+ return result.exitCode === 0 ? "appended" : "current";
128
+ }
129
+
130
+ /**
131
+ * Set up .gitattributes with merge=union entries for JSONL files.
132
+ *
133
+ * Only adds entries not already present. Returns true if file was modified.
134
+ */
135
+ async function setupGitattributes(projectRoot: string): Promise<boolean> {
136
+ const entries = [".mulch/expertise/*.jsonl merge=union", ".seeds/issues.jsonl merge=union"];
137
+
138
+ const gitattrsPath = join(projectRoot, ".gitattributes");
139
+ let existing = "";
140
+
141
+ try {
142
+ existing = await Bun.file(gitattrsPath).text();
143
+ } catch {
144
+ // File doesn't exist yet — will be created
145
+ }
146
+
147
+ const missing = entries.filter((e) => !existing.includes(e));
148
+ if (missing.length === 0) return false;
149
+
150
+ const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
151
+ await Bun.write(gitattrsPath, `${existing}${separator}${missing.join("\n")}\n`);
152
+ return true;
153
+ }
154
+
22
155
  /**
23
156
  * Detect the project name from git or fall back to directory name.
24
157
  */
@@ -511,6 +644,17 @@ export interface InitOptions {
511
644
  yes?: boolean;
512
645
  name?: string;
513
646
  force?: boolean;
647
+ /** Comma-separated list of ecosystem tools to bootstrap (e.g. "mulch,seeds"). Default: all. */
648
+ tools?: string;
649
+ skipMulch?: boolean;
650
+ skipSeeds?: boolean;
651
+ skipCanopy?: boolean;
652
+ /** Skip the onboard step (injecting CLAUDE.md sections for ecosystem tools). */
653
+ skipOnboard?: boolean;
654
+ /** Output final result as JSON envelope. */
655
+ json?: boolean;
656
+ /** Injectable spawner for testability. */
657
+ _spawner?: Spawner;
514
658
  }
515
659
 
516
660
  /**
@@ -531,6 +675,7 @@ export async function initCommand(opts: InitOptions): Promise<void> {
531
675
  const force = opts.force ?? false;
532
676
  const yes = opts.yes ?? false;
533
677
  const projectRoot = process.cwd();
678
+ const spawner = opts._spawner ?? defaultSpawner;
534
679
  const overstoryPath = join(projectRoot, OVERSTORY_DIR);
535
680
 
536
681
  // 0. Verify we're inside a git repository
@@ -633,6 +778,53 @@ export async function initCommand(opts: InitOptions): Promise<void> {
633
778
  }
634
779
  }
635
780
 
781
+ // 9. Bootstrap sibling ecosystem tools
782
+ const toolSet = resolveToolSet(opts);
783
+ const toolResults: Record<string, { status: ToolStatus; path: string }> = {
784
+ overstory: { status: "initialized", path: overstoryPath },
785
+ };
786
+
787
+ if (toolSet.length > 0) {
788
+ process.stdout.write("\n");
789
+ process.stdout.write("Bootstrapping ecosystem tools...\n\n");
790
+ }
791
+
792
+ for (const tool of toolSet) {
793
+ const status = await initSiblingTool(tool, projectRoot, spawner);
794
+ toolResults[tool.name] = {
795
+ status,
796
+ path: join(projectRoot, tool.dotDir),
797
+ };
798
+ }
799
+
800
+ // 10. Set up .gitattributes with merge=union for JSONL files
801
+ const gitattrsUpdated = await setupGitattributes(projectRoot);
802
+ if (gitattrsUpdated) {
803
+ printCreated(".gitattributes");
804
+ }
805
+
806
+ // 11. Run onboard for each tool (inject CLAUDE.md sections)
807
+ const onboardResults: Record<string, OnboardStatus> = {};
808
+ if (!opts.skipOnboard) {
809
+ for (const tool of toolSet) {
810
+ if (toolResults[tool.name]?.status !== "skipped") {
811
+ const status = await onboardTool(tool, projectRoot, spawner);
812
+ onboardResults[tool.name] = status;
813
+ }
814
+ }
815
+ }
816
+
817
+ // 12. Output final result
818
+ if (opts.json) {
819
+ jsonOutput("init", {
820
+ project: projectName,
821
+ tools: toolResults,
822
+ onboard: onboardResults,
823
+ gitattributes: gitattrsUpdated,
824
+ });
825
+ return;
826
+ }
827
+
636
828
  printSuccess("Initialized");
637
829
  printHint("Next: run `ov hooks install` to enable Claude Code hooks.");
638
830
  printHint("Then: run `ov status` to see the current state.");
@@ -142,17 +142,18 @@ async function startMonitor(opts: { json: boolean; attach: boolean }): Promise<v
142
142
  }
143
143
 
144
144
  // Spawn tmux session at project root with Claude Code (interactive mode).
145
+ // Pass file path (not content) to avoid tmux "command too long" (overstory#45).
145
146
  const agentDefPath = join(projectRoot, ".overstory", "agent-defs", "monitor.md");
146
147
  const agentDefFile = Bun.file(agentDefPath);
147
- let appendSystemPrompt: string | undefined;
148
+ let appendSystemPromptFile: string | undefined;
148
149
  if (await agentDefFile.exists()) {
149
- appendSystemPrompt = await agentDefFile.text();
150
+ appendSystemPromptFile = agentDefPath;
150
151
  }
151
152
  const spawnCmd = runtime.buildSpawnCommand({
152
153
  model: resolvedModel.model,
153
154
  permissionMode: "bypass",
154
155
  cwd: projectRoot,
155
- appendSystemPrompt,
156
+ appendSystemPromptFile,
156
157
  env: {
157
158
  ...runtime.buildEnv(resolvedModel),
158
159
  OVERSTORY_AGENT_NAME: MONITOR_NAME,
@@ -169,18 +169,19 @@ async function startSupervisor(opts: {
169
169
 
170
170
  // Spawn tmux session at project root with Claude Code (interactive mode).
171
171
  // Inject the supervisor base definition via --append-system-prompt.
172
+ // Pass file path (not content) to avoid tmux "command too long" (overstory#45).
172
173
  const tmuxSession = `overstory-${config.project.name}-supervisor-${opts.name}`;
173
174
  const agentDefPath = join(projectRoot, ".overstory", "agent-defs", "supervisor.md");
174
175
  const agentDefFile = Bun.file(agentDefPath);
175
- let appendSystemPrompt: string | undefined;
176
+ let appendSystemPromptFile: string | undefined;
176
177
  if (await agentDefFile.exists()) {
177
- appendSystemPrompt = await agentDefFile.text();
178
+ appendSystemPromptFile = agentDefPath;
178
179
  }
179
180
  const spawnCmd = runtime.buildSpawnCommand({
180
181
  model: resolvedModel.model,
181
182
  permissionMode: "bypass",
182
183
  cwd: projectRoot,
183
- appendSystemPrompt,
184
+ appendSystemPromptFile,
184
185
  env: {
185
186
  ...runtime.buildEnv(resolvedModel),
186
187
  OVERSTORY_AGENT_NAME: opts.name,
@@ -0,0 +1,373 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { OverstoryConfig } from "../types.ts";
5
+ import { checkProviders } from "./providers.ts";
6
+
7
+ /** Build a minimal valid OverstoryConfig for testing. */
8
+ function makeConfig(overrides: Partial<OverstoryConfig> = {}): OverstoryConfig {
9
+ const tmp = tmpdir();
10
+ return {
11
+ project: {
12
+ name: "test-project",
13
+ root: tmp,
14
+ canonicalBranch: "main",
15
+ },
16
+ agents: {
17
+ manifestPath: join(tmp, ".overstory", "agent-manifest.json"),
18
+ baseDir: join(tmp, ".overstory", "agents"),
19
+ maxConcurrent: 5,
20
+ staggerDelayMs: 1000,
21
+ maxDepth: 2,
22
+ maxSessionsPerRun: 0,
23
+ maxAgentsPerLead: 5,
24
+ },
25
+ worktrees: {
26
+ baseDir: join(tmp, ".overstory", "worktrees"),
27
+ },
28
+ taskTracker: {
29
+ backend: "auto",
30
+ enabled: false,
31
+ },
32
+ mulch: {
33
+ enabled: false,
34
+ domains: [],
35
+ primeFormat: "markdown",
36
+ },
37
+ merge: {
38
+ aiResolveEnabled: false,
39
+ reimagineEnabled: false,
40
+ },
41
+ providers: {
42
+ anthropic: { type: "native" },
43
+ },
44
+ watchdog: {
45
+ tier0Enabled: false,
46
+ tier0IntervalMs: 30000,
47
+ tier1Enabled: false,
48
+ tier2Enabled: false,
49
+ staleThresholdMs: 300000,
50
+ zombieThresholdMs: 600000,
51
+ nudgeIntervalMs: 60000,
52
+ },
53
+ models: {},
54
+ logging: {
55
+ verbose: false,
56
+ redactSecrets: true,
57
+ },
58
+ ...overrides,
59
+ };
60
+ }
61
+
62
+ // Dummy overstoryDir — provider checks don't use the filesystem
63
+ const OVERSTORY_DIR = join(tmpdir(), ".overstory");
64
+
65
+ describe("checkProviders", () => {
66
+ test("all checks have required DoctorCheck fields", async () => {
67
+ const config = makeConfig();
68
+ const checks = await checkProviders(config, OVERSTORY_DIR);
69
+
70
+ expect(checks).toBeArray();
71
+ for (const check of checks) {
72
+ expect(typeof check.name).toBe("string");
73
+ expect(check.category).toBe("providers");
74
+ expect(["pass", "warn", "fail"]).toContain(check.status);
75
+ expect(typeof check.message).toBe("string");
76
+ if (check.details !== undefined) {
77
+ expect(check.details).toBeArray();
78
+ }
79
+ }
80
+ });
81
+
82
+ describe("providers-configured check", () => {
83
+ test("native-only config (no gateway) returns pass for providers-configured", async () => {
84
+ const config = makeConfig({
85
+ providers: { anthropic: { type: "native" } },
86
+ });
87
+ const checks = await checkProviders(config, OVERSTORY_DIR);
88
+
89
+ const check = checks.find((c) => c.name === "providers-configured");
90
+ expect(check).toBeDefined();
91
+ expect(check?.status).toBe("pass");
92
+ });
93
+
94
+ test("empty providers returns warn for providers-configured", async () => {
95
+ const config = makeConfig({ providers: {} });
96
+ const checks = await checkProviders(config, OVERSTORY_DIR);
97
+
98
+ const check = checks.find((c) => c.name === "providers-configured");
99
+ expect(check).toBeDefined();
100
+ expect(check?.status).toBe("warn");
101
+ });
102
+
103
+ test("providers-configured details list provider names and types", async () => {
104
+ const config = makeConfig({
105
+ providers: {
106
+ anthropic: { type: "native" },
107
+ openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" },
108
+ },
109
+ });
110
+ const checks = await checkProviders(config, OVERSTORY_DIR);
111
+
112
+ const check = checks.find((c) => c.name === "providers-configured");
113
+ expect(check?.status).toBe("pass");
114
+ expect(check?.details).toBeDefined();
115
+ expect(check?.details?.some((d) => d.includes("anthropic"))).toBe(true);
116
+ expect(check?.details?.some((d) => d.includes("openrouter"))).toBe(true);
117
+ });
118
+ });
119
+
120
+ describe("provider-reachable-{name} check", () => {
121
+ test("gateway config triggers reachability check (warn path — no real server)", async () => {
122
+ const config = makeConfig({
123
+ providers: {
124
+ fake: {
125
+ type: "gateway",
126
+ // Use a port that is almost certainly not listening
127
+ baseUrl: "http://127.0.0.1:19873",
128
+ },
129
+ },
130
+ });
131
+ const checks = await checkProviders(config, OVERSTORY_DIR);
132
+
133
+ const check = checks.find((c) => c.name === "provider-reachable-fake");
134
+ expect(check).toBeDefined();
135
+ expect(check?.status).toBe("warn");
136
+ expect(check?.message).toContain("fake");
137
+ });
138
+
139
+ test("reachability pass path — local HTTP server", async () => {
140
+ // Start a minimal Bun HTTP server on an ephemeral port
141
+ const server = Bun.serve({
142
+ port: 0, // OS assigns a free port
143
+ fetch() {
144
+ return new Response("ok");
145
+ },
146
+ });
147
+
148
+ try {
149
+ const config = makeConfig({
150
+ providers: {
151
+ localtest: {
152
+ type: "gateway",
153
+ baseUrl: `http://127.0.0.1:${server.port}`,
154
+ },
155
+ },
156
+ });
157
+ const checks = await checkProviders(config, OVERSTORY_DIR);
158
+
159
+ const check = checks.find((c) => c.name === "provider-reachable-localtest");
160
+ expect(check).toBeDefined();
161
+ expect(check?.status).toBe("pass");
162
+ } finally {
163
+ await server.stop();
164
+ }
165
+ });
166
+
167
+ test("gateway without baseUrl skips reachability check", async () => {
168
+ const config = makeConfig({
169
+ providers: {
170
+ nourl: { type: "gateway" }, // no baseUrl
171
+ },
172
+ });
173
+ const checks = await checkProviders(config, OVERSTORY_DIR);
174
+
175
+ const reachCheck = checks.find((c) => c.name === "provider-reachable-nourl");
176
+ expect(reachCheck).toBeUndefined();
177
+ });
178
+ });
179
+
180
+ describe("provider-auth-token-{name} check", () => {
181
+ const ENV_KEY = "OVERSTORY_TEST_FAKE_PROVIDER_TOKEN_XYZ";
182
+
183
+ beforeAll(() => {
184
+ // Ensure env var is unset before tests
185
+ delete process.env[ENV_KEY];
186
+ });
187
+
188
+ afterAll(() => {
189
+ delete process.env[ENV_KEY];
190
+ });
191
+
192
+ test("gateway with authTokenEnv warns when env var missing", async () => {
193
+ const config = makeConfig({
194
+ providers: {
195
+ testgateway: {
196
+ type: "gateway",
197
+ authTokenEnv: ENV_KEY,
198
+ },
199
+ },
200
+ });
201
+ const checks = await checkProviders(config, OVERSTORY_DIR);
202
+
203
+ const check = checks.find((c) => c.name === "provider-auth-token-testgateway");
204
+ expect(check).toBeDefined();
205
+ expect(check?.status).toBe("warn");
206
+ // Details must include the env var NAME, never a value
207
+ expect(check?.details?.some((d) => d.includes(ENV_KEY))).toBe(true);
208
+ });
209
+
210
+ test("gateway with authTokenEnv passes when env var is set", async () => {
211
+ process.env[ENV_KEY] = "test-token-value";
212
+
213
+ const config = makeConfig({
214
+ providers: {
215
+ testgateway: {
216
+ type: "gateway",
217
+ authTokenEnv: ENV_KEY,
218
+ },
219
+ },
220
+ });
221
+ const checks = await checkProviders(config, OVERSTORY_DIR);
222
+
223
+ const check = checks.find((c) => c.name === "provider-auth-token-testgateway");
224
+ expect(check).toBeDefined();
225
+ expect(check?.status).toBe("pass");
226
+ // Details include the var name, not the value
227
+ expect(check?.details?.some((d) => d.includes(ENV_KEY))).toBe(true);
228
+ // Value must NOT appear in details
229
+ expect(check?.details?.some((d) => d.includes("test-token-value"))).toBe(false);
230
+ });
231
+
232
+ test("native provider with no authTokenEnv skips auth-token check", async () => {
233
+ const config = makeConfig({
234
+ providers: { anthropic: { type: "native" } },
235
+ });
236
+ const checks = await checkProviders(config, OVERSTORY_DIR);
237
+
238
+ const authCheck = checks.find((c) => c.name?.startsWith("provider-auth-token-"));
239
+ expect(authCheck).toBeUndefined();
240
+ });
241
+ });
242
+
243
+ describe("tool-use-compat check", () => {
244
+ test("tool-heavy role with provider-prefixed model warns", async () => {
245
+ const config = makeConfig({
246
+ models: { builder: "openrouter/openai/gpt-4o" },
247
+ providers: {
248
+ openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" },
249
+ },
250
+ });
251
+ const checks = await checkProviders(config, OVERSTORY_DIR);
252
+
253
+ const check = checks.find((c) => c.name === "tool-use-compat" && c.status === "warn");
254
+ expect(check).toBeDefined();
255
+ expect(check?.message).toContain("builder");
256
+ });
257
+
258
+ test("non-tool-heavy role with provider-prefixed model does not warn", async () => {
259
+ // "lead" is not a tool-heavy role
260
+ const config = makeConfig({
261
+ models: { lead: "openrouter/openai/gpt-4o" },
262
+ providers: {
263
+ openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" },
264
+ },
265
+ });
266
+ const checks = await checkProviders(config, OVERSTORY_DIR);
267
+
268
+ const warnChecks = checks.filter((c) => c.name === "tool-use-compat" && c.status === "warn");
269
+ expect(warnChecks.length).toBe(0);
270
+ });
271
+
272
+ test("no tool-heavy roles with prefixed models emits single pass", async () => {
273
+ const config = makeConfig({
274
+ models: { builder: "sonnet" }, // alias, not provider-prefixed
275
+ });
276
+ const checks = await checkProviders(config, OVERSTORY_DIR);
277
+
278
+ const passCheck = checks.find((c) => c.name === "tool-use-compat" && c.status === "pass");
279
+ expect(passCheck).toBeDefined();
280
+ });
281
+
282
+ test("all three tool-heavy roles can trigger separate warns", async () => {
283
+ const config = makeConfig({
284
+ models: {
285
+ builder: "openrouter/openai/gpt-4o",
286
+ scout: "openrouter/openai/gpt-4o",
287
+ merger: "openrouter/openai/gpt-4o",
288
+ },
289
+ providers: {
290
+ openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" },
291
+ },
292
+ });
293
+ const checks = await checkProviders(config, OVERSTORY_DIR);
294
+
295
+ const warnChecks = checks.filter((c) => c.name === "tool-use-compat" && c.status === "warn");
296
+ expect(warnChecks.length).toBe(3);
297
+ });
298
+ });
299
+
300
+ describe("model-provider-ref(s) check", () => {
301
+ test("model referencing unknown provider fails", async () => {
302
+ const config = makeConfig({
303
+ models: { builder: "unknownprovider/some-model" },
304
+ // unknownprovider not in providers
305
+ providers: { anthropic: { type: "native" } },
306
+ });
307
+ const checks = await checkProviders(config, OVERSTORY_DIR);
308
+
309
+ const check = checks.find((c) => c.name === "model-provider-ref" && c.status === "fail");
310
+ expect(check).toBeDefined();
311
+ expect(check?.message).toContain("unknownprovider");
312
+ });
313
+
314
+ test("model referencing defined provider passes", async () => {
315
+ const config = makeConfig({
316
+ models: { builder: "openrouter/openai/gpt-4o" },
317
+ providers: {
318
+ openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" },
319
+ },
320
+ });
321
+ const checks = await checkProviders(config, OVERSTORY_DIR);
322
+
323
+ const check = checks.find((c) => c.name === "model-provider-ref" && c.status === "pass");
324
+ expect(check).toBeDefined();
325
+ });
326
+
327
+ test("no provider-prefixed models emits single pass named model-provider-refs", async () => {
328
+ const config = makeConfig({
329
+ models: { builder: "sonnet", scout: "haiku" },
330
+ });
331
+ const checks = await checkProviders(config, OVERSTORY_DIR);
332
+
333
+ const check = checks.find((c) => c.name === "model-provider-refs");
334
+ expect(check).toBeDefined();
335
+ expect(check?.status).toBe("pass");
336
+ });
337
+
338
+ test("empty models emits single pass named model-provider-refs", async () => {
339
+ const config = makeConfig({ models: {} });
340
+ const checks = await checkProviders(config, OVERSTORY_DIR);
341
+
342
+ const check = checks.find((c) => c.name === "model-provider-refs");
343
+ expect(check).toBeDefined();
344
+ expect(check?.status).toBe("pass");
345
+ });
346
+ });
347
+
348
+ describe("gateway-api-key-reminder check", () => {
349
+ test("gateway present triggers api-key reminder warn", async () => {
350
+ const config = makeConfig({
351
+ providers: {
352
+ openrouter: { type: "gateway", baseUrl: "http://127.0.0.1:19873" },
353
+ },
354
+ });
355
+ const checks = await checkProviders(config, OVERSTORY_DIR);
356
+
357
+ const check = checks.find((c) => c.name === "gateway-api-key-reminder");
358
+ expect(check).toBeDefined();
359
+ expect(check?.status).toBe("warn");
360
+ expect(check?.message).toContain("ANTHROPIC_API_KEY");
361
+ });
362
+
363
+ test("no gateway providers — reminder is absent", async () => {
364
+ const config = makeConfig({
365
+ providers: { anthropic: { type: "native" } },
366
+ });
367
+ const checks = await checkProviders(config, OVERSTORY_DIR);
368
+
369
+ const check = checks.find((c) => c.name === "gateway-api-key-reminder");
370
+ expect(check).toBeUndefined();
371
+ });
372
+ });
373
+ });