@os-eco/overstory-cli 0.6.1 → 0.6.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.
Files changed (80) hide show
  1. package/README.md +7 -6
  2. package/package.json +12 -4
  3. package/src/agents/hooks-deployer.test.ts +94 -16
  4. package/src/agents/hooks-deployer.ts +18 -0
  5. package/src/agents/manifest.test.ts +86 -0
  6. package/src/commands/agents.test.ts +3 -3
  7. package/src/commands/agents.ts +59 -88
  8. package/src/commands/clean.test.ts +31 -46
  9. package/src/commands/clean.ts +28 -49
  10. package/src/commands/completions.ts +14 -0
  11. package/src/commands/coordinator.test.ts +131 -24
  12. package/src/commands/coordinator.ts +100 -63
  13. package/src/commands/costs.test.ts +2 -2
  14. package/src/commands/costs.ts +96 -75
  15. package/src/commands/dashboard.test.ts +2 -2
  16. package/src/commands/dashboard.ts +73 -93
  17. package/src/commands/doctor.test.ts +2 -2
  18. package/src/commands/doctor.ts +92 -79
  19. package/src/commands/errors.test.ts +2 -2
  20. package/src/commands/errors.ts +56 -50
  21. package/src/commands/feed.test.ts +2 -2
  22. package/src/commands/feed.ts +86 -83
  23. package/src/commands/group.ts +167 -177
  24. package/src/commands/hooks.test.ts +2 -2
  25. package/src/commands/hooks.ts +52 -42
  26. package/src/commands/init.test.ts +19 -19
  27. package/src/commands/init.ts +7 -16
  28. package/src/commands/inspect.test.ts +2 -2
  29. package/src/commands/inspect.ts +54 -57
  30. package/src/commands/log.test.ts +5 -10
  31. package/src/commands/log.ts +90 -84
  32. package/src/commands/logs.test.ts +1 -1
  33. package/src/commands/logs.ts +101 -104
  34. package/src/commands/mail.ts +157 -169
  35. package/src/commands/merge.test.ts +20 -58
  36. package/src/commands/merge.ts +13 -43
  37. package/src/commands/metrics.test.ts +2 -2
  38. package/src/commands/metrics.ts +33 -34
  39. package/src/commands/monitor.test.ts +3 -3
  40. package/src/commands/monitor.ts +56 -61
  41. package/src/commands/nudge.ts +41 -89
  42. package/src/commands/prime.test.ts +15 -47
  43. package/src/commands/prime.ts +7 -44
  44. package/src/commands/replay.test.ts +2 -2
  45. package/src/commands/replay.ts +79 -86
  46. package/src/commands/run.ts +97 -77
  47. package/src/commands/sling.test.ts +196 -0
  48. package/src/commands/sling.ts +24 -54
  49. package/src/commands/spec.test.ts +13 -39
  50. package/src/commands/spec.ts +30 -99
  51. package/src/commands/status.ts +46 -42
  52. package/src/commands/stop.test.ts +21 -39
  53. package/src/commands/stop.ts +18 -33
  54. package/src/commands/supervisor.test.ts +3 -5
  55. package/src/commands/supervisor.ts +136 -157
  56. package/src/commands/trace.test.ts +9 -9
  57. package/src/commands/trace.ts +54 -77
  58. package/src/commands/watch.test.ts +2 -2
  59. package/src/commands/watch.ts +38 -45
  60. package/src/commands/worktree.test.ts +8 -8
  61. package/src/commands/worktree.ts +63 -46
  62. package/src/config.test.ts +96 -0
  63. package/src/doctor/databases.test.ts +22 -2
  64. package/src/doctor/databases.ts +16 -0
  65. package/src/doctor/dependencies.test.ts +55 -1
  66. package/src/doctor/dependencies.ts +113 -18
  67. package/src/e2e/init-sling-lifecycle.test.ts +6 -6
  68. package/src/index.ts +223 -213
  69. package/src/logging/color.test.ts +74 -91
  70. package/src/logging/color.ts +52 -46
  71. package/src/logging/reporter.test.ts +10 -10
  72. package/src/logging/reporter.ts +6 -5
  73. package/src/merge/queue.test.ts +66 -0
  74. package/src/merge/queue.ts +15 -0
  75. package/src/schema-consistency.test.ts +239 -0
  76. package/src/sessions/compat.ts +1 -1
  77. package/src/sessions/store.test.ts +37 -0
  78. package/src/sessions/store.ts +11 -0
  79. package/src/worktree/tmux.test.ts +98 -9
  80. package/src/worktree/tmux.ts +18 -0
@@ -12,39 +12,12 @@
12
12
  */
13
13
 
14
14
  import { join } from "node:path";
15
+ import { Command, CommanderError } from "commander";
15
16
  import { loadConfig } from "../config.ts";
17
+ import { ValidationError } from "../errors.ts";
16
18
  import { createRunStore, createSessionStore } from "../sessions/store.ts";
17
19
  import type { AgentSession, Run } from "../types.ts";
18
20
 
19
- const RUN_HELP = `overstory run -- Manage runs (coordinator session groupings)
20
-
21
- Usage: overstory run [subcommand] [options]
22
-
23
- Subcommands:
24
- (default) Show current run status
25
- list [--last <n>] List recent runs (default last 10)
26
- complete Mark current run as completed
27
- show <id> Show run details (agents, duration)
28
-
29
- Options:
30
- --json Output as JSON
31
- --help, -h Show this help`;
32
-
33
- /**
34
- * Parse a named flag value from args.
35
- */
36
- function getFlag(args: string[], flag: string): string | undefined {
37
- const idx = args.indexOf(flag);
38
- if (idx === -1 || idx + 1 >= args.length) {
39
- return undefined;
40
- }
41
- return args[idx + 1];
42
- }
43
-
44
- function hasFlag(args: string[], flag: string): boolean {
45
- return args.includes(flag);
46
- }
47
-
48
21
  /**
49
22
  * Format milliseconds as human-readable duration.
50
23
  */
@@ -90,6 +63,18 @@ function runDuration(run: Run): string {
90
63
  return formatDuration(end - start);
91
64
  }
92
65
 
66
+ /**
67
+ * Format an agent's duration from startedAt to now (or completion).
68
+ */
69
+ function formatAgentDuration(agent: AgentSession): string {
70
+ const start = new Date(agent.startedAt).getTime();
71
+ const end =
72
+ agent.state === "completed" || agent.state === "zombie"
73
+ ? new Date(agent.lastActivity).getTime()
74
+ : Date.now();
75
+ return formatDuration(end - start);
76
+ }
77
+
93
78
  /**
94
79
  * Show current run status (default subcommand).
95
80
  */
@@ -291,61 +276,96 @@ async function showRun(overstoryDir: string, runId: string, json: boolean): Prom
291
276
  }
292
277
  }
293
278
 
294
- /**
295
- * Format an agent's duration from startedAt to now (or completion).
296
- */
297
- function formatAgentDuration(agent: AgentSession): string {
298
- const start = new Date(agent.startedAt).getTime();
299
- const end =
300
- agent.state === "completed" || agent.state === "zombie"
301
- ? new Date(agent.lastActivity).getTime()
302
- : Date.now();
303
- return formatDuration(end - start);
279
+ interface RunDefaultOpts {
280
+ json?: boolean;
304
281
  }
305
282
 
306
- /**
307
- * Entry point for `overstory run [subcommand] [options]`.
308
- */
309
- export async function runCommand(args: string[]): Promise<void> {
310
- if (args.includes("--help") || args.includes("-h")) {
311
- process.stdout.write(`${RUN_HELP}\n`);
312
- return;
313
- }
283
+ interface RunListOpts {
284
+ last?: string;
285
+ json?: boolean;
286
+ }
314
287
 
315
- const json = hasFlag(args, "--json");
316
- const cwd = process.cwd();
317
- const config = await loadConfig(cwd);
318
- const overstoryDir = join(config.project.root, ".overstory");
288
+ interface RunShowOpts {
289
+ json?: boolean;
290
+ }
319
291
 
320
- const subcommand = args[0];
292
+ interface RunCompleteOpts {
293
+ json?: boolean;
294
+ }
321
295
 
322
- switch (subcommand) {
323
- case "list": {
324
- const lastStr = getFlag(args, "--last");
296
+ export function createRunCommand(): Command {
297
+ const cmd = new Command("run").description("Manage runs (coordinator session groupings)");
298
+
299
+ // Default action (bare `overstory run`)
300
+ cmd.option("--json", "Output as JSON").action(async (opts: RunDefaultOpts) => {
301
+ const cwd = process.cwd();
302
+ const config = await loadConfig(cwd);
303
+ const overstoryDir = join(config.project.root, ".overstory");
304
+ await showCurrentRun(overstoryDir, opts.json ?? false);
305
+ });
306
+
307
+ // `overstory run list`
308
+ cmd
309
+ .command("list")
310
+ .description("List recent runs")
311
+ .option("--last <n>", "Number of recent runs to show (default: 10)")
312
+ .option("--json", "Output as JSON")
313
+ .action(async (opts: RunListOpts) => {
314
+ const lastStr = opts.last;
325
315
  const limit = lastStr ? Number.parseInt(lastStr, 10) : 10;
326
- await listRuns(overstoryDir, limit, json);
327
- break;
328
- }
329
- case "complete":
330
- await completeCurrentRun(overstoryDir, json);
331
- break;
332
- case "show": {
333
- const runId = args[1];
334
- if (!runId || runId.startsWith("--")) {
335
- if (json) {
336
- process.stdout.write('{"error":"Missing run ID. Usage: overstory run show <id>"}\n');
337
- } else {
338
- process.stderr.write("Missing run ID. Usage: overstory run show <id>\n");
339
- }
340
- process.exitCode = 1;
341
- return;
316
+ if (Number.isNaN(limit) || limit < 1) {
317
+ throw new ValidationError("--last must be a positive integer", {
318
+ field: "last",
319
+ value: lastStr,
320
+ });
342
321
  }
343
- await showRun(overstoryDir, runId, json);
344
- break;
322
+ const cwd = process.cwd();
323
+ const config = await loadConfig(cwd);
324
+ const overstoryDir = join(config.project.root, ".overstory");
325
+ await listRuns(overstoryDir, limit, opts.json ?? false);
326
+ });
327
+
328
+ // `overstory run show <id>`
329
+ cmd
330
+ .command("show")
331
+ .description("Show run details (agents, duration)")
332
+ .argument("<id>", "Run ID")
333
+ .option("--json", "Output as JSON")
334
+ .action(async (id: string, opts: RunShowOpts) => {
335
+ const cwd = process.cwd();
336
+ const config = await loadConfig(cwd);
337
+ const overstoryDir = join(config.project.root, ".overstory");
338
+ await showRun(overstoryDir, id, opts.json ?? false);
339
+ });
340
+
341
+ // `overstory run complete`
342
+ cmd
343
+ .command("complete")
344
+ .description("Mark current run as completed")
345
+ .option("--json", "Output as JSON")
346
+ .action(async (opts: RunCompleteOpts) => {
347
+ const cwd = process.cwd();
348
+ const config = await loadConfig(cwd);
349
+ const overstoryDir = join(config.project.root, ".overstory");
350
+ await completeCurrentRun(overstoryDir, opts.json ?? false);
351
+ });
352
+
353
+ return cmd;
354
+ }
355
+
356
+ export async function runCommand(args: string[]): Promise<void> {
357
+ const program = new Command("overstory").exitOverride().configureOutput({
358
+ writeOut: (str) => process.stdout.write(str),
359
+ writeErr: (str) => process.stderr.write(str),
360
+ });
361
+ program.addCommand(createRunCommand());
362
+ try {
363
+ await program.parseAsync(["node", "overstory", "run", ...args]);
364
+ } catch (err: unknown) {
365
+ if (err instanceof CommanderError) {
366
+ if (err.code === "commander.helpDisplayed" || err.code === "commander.version") return;
367
+ throw new ValidationError(err.message, { field: "args" });
345
368
  }
346
- default:
347
- // Default: show current run status
348
- await showCurrentRun(overstoryDir, json);
349
- break;
369
+ throw err;
350
370
  }
351
371
  }
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { resolveModel, resolveProviderEnv } from "../agents/manifest.ts";
2
3
  import { HierarchyError } from "../errors.ts";
4
+ import type { AgentManifest, OverstoryConfig } from "../types.ts";
3
5
  import {
4
6
  type BeaconOptions,
5
7
  buildBeacon,
@@ -655,3 +657,197 @@ describe("checkRunSessionLimit", () => {
655
657
  expect(checkRunSessionLimit(-1, 100)).toBe(false);
656
658
  });
657
659
  });
660
+
661
+ /**
662
+ * Tests for sling provider env injection building blocks.
663
+ *
664
+ * In slingCommand, resolveModel() is called to get the { model, env } for the
665
+ * spawned agent. The env dict is then spread into createSession's env parameter
666
+ * alongside OVERSTORY_AGENT_NAME and OVERSTORY_WORKTREE_PATH:
667
+ *
668
+ * const { model, env } = resolveModel(config, manifest, capability, agentDef.model);
669
+ * const pid = await createSession(tmuxSessionName, worktreePath, claudeCmd, {
670
+ * ...env,
671
+ * OVERSTORY_AGENT_NAME: name,
672
+ * OVERSTORY_WORKTREE_PATH: worktreePath,
673
+ * });
674
+ *
675
+ * These tests verify the building blocks: that resolveModel and resolveProviderEnv
676
+ * produce the correct env dicts for the provider scenarios sling will encounter.
677
+ */
678
+
679
+ function makeConfig(
680
+ models: OverstoryConfig["models"] = {},
681
+ providers: OverstoryConfig["providers"] = { anthropic: { type: "native" } },
682
+ ): OverstoryConfig {
683
+ return {
684
+ project: { name: "test", root: "/tmp/test", canonicalBranch: "main" },
685
+ agents: {
686
+ manifestPath: ".overstory/agent-manifest.json",
687
+ baseDir: ".overstory/agent-defs",
688
+ maxConcurrent: 5,
689
+ staggerDelayMs: 0,
690
+ maxDepth: 2,
691
+ maxSessionsPerRun: 0,
692
+ },
693
+ worktrees: { baseDir: ".overstory/worktrees" },
694
+ taskTracker: { backend: "auto", enabled: false },
695
+ mulch: { enabled: false, domains: [], primeFormat: "markdown" },
696
+ merge: { aiResolveEnabled: false, reimagineEnabled: false },
697
+ providers,
698
+ watchdog: {
699
+ tier0Enabled: false,
700
+ tier0IntervalMs: 30_000,
701
+ tier1Enabled: false,
702
+ tier2Enabled: false,
703
+ staleThresholdMs: 300_000,
704
+ zombieThresholdMs: 600_000,
705
+ nudgeIntervalMs: 60_000,
706
+ },
707
+ models,
708
+ logging: { verbose: false, redactSecrets: true },
709
+ };
710
+ }
711
+
712
+ function makeManifest(): AgentManifest {
713
+ return {
714
+ version: "1.0",
715
+ agents: {
716
+ builder: {
717
+ file: "builder.md",
718
+ model: "opus",
719
+ tools: ["Read", "Write", "Edit", "Bash"],
720
+ capabilities: ["implement"],
721
+ canSpawn: false,
722
+ constraints: [],
723
+ },
724
+ coordinator: {
725
+ file: "coordinator.md",
726
+ model: "sonnet",
727
+ tools: ["Read", "Bash"],
728
+ capabilities: ["coordinate"],
729
+ canSpawn: true,
730
+ constraints: [],
731
+ },
732
+ },
733
+ capabilityIndex: { implement: ["builder"], coordinate: ["coordinator"] },
734
+ };
735
+ }
736
+
737
+ describe("sling provider env injection building blocks", () => {
738
+ test("resolveModel produces env for gateway provider in config override scenario", () => {
739
+ const config = makeConfig(
740
+ { builder: "openrouter/anthropic/claude-3-5-sonnet" },
741
+ { openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" } },
742
+ );
743
+ const manifest = makeManifest();
744
+
745
+ const result = resolveModel(config, manifest, "builder", "sonnet");
746
+
747
+ expect(result.model).toBe("sonnet");
748
+ expect(result.env).toBeDefined();
749
+ expect(result.env?.ANTHROPIC_BASE_URL).toBe("https://openrouter.ai/api/v1");
750
+ expect(result.env?.ANTHROPIC_API_KEY).toBe("");
751
+ expect(result.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("anthropic/claude-3-5-sonnet");
752
+ });
753
+
754
+ test("env dict from resolveModel can be spread with OVERSTORY_AGENT_NAME and OVERSTORY_WORKTREE_PATH", () => {
755
+ const config = makeConfig(
756
+ { builder: "openrouter/anthropic/claude-3-5-sonnet" },
757
+ { openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" } },
758
+ );
759
+ const manifest = makeManifest();
760
+
761
+ const { env } = resolveModel(config, manifest, "builder", "sonnet");
762
+ // Simulates the spread in slingCommand: { ...env, OVERSTORY_AGENT_NAME: name, OVERSTORY_WORKTREE_PATH: wt }
763
+ const combined: Record<string, string> = {
764
+ ...(env ?? {}),
765
+ OVERSTORY_AGENT_NAME: "test-builder",
766
+ OVERSTORY_WORKTREE_PATH: "/tmp/wt",
767
+ };
768
+
769
+ expect(combined.ANTHROPIC_BASE_URL).toBe("https://openrouter.ai/api/v1");
770
+ expect(combined.ANTHROPIC_API_KEY).toBe("");
771
+ expect(combined.OVERSTORY_AGENT_NAME).toBe("test-builder");
772
+ expect(combined.OVERSTORY_WORKTREE_PATH).toBe("/tmp/wt");
773
+ });
774
+
775
+ test("resolveModel returns no env for native anthropic provider", () => {
776
+ const config = makeConfig({ builder: "sonnet" }, { anthropic: { type: "native" } });
777
+ const manifest = makeManifest();
778
+
779
+ const result = resolveModel(config, manifest, "builder", "sonnet");
780
+
781
+ expect(result.model).toBe("sonnet");
782
+ expect(result.env).toBeUndefined();
783
+ });
784
+
785
+ test("resolveModel returns no env when model is a simple alias from manifest default", () => {
786
+ // No models override: manifest builder model "opus" is a simple alias
787
+ const config = makeConfig({}, {});
788
+ const manifest = makeManifest();
789
+
790
+ const result = resolveModel(config, manifest, "builder", "sonnet");
791
+
792
+ expect(result.model).toBe("opus");
793
+ expect(result.env).toBeUndefined();
794
+ });
795
+
796
+ test("resolveProviderEnv includes ANTHROPIC_AUTH_TOKEN when authTokenEnv var is set", () => {
797
+ const providers = {
798
+ openrouter: {
799
+ type: "gateway" as const,
800
+ baseUrl: "https://openrouter.ai/api/v1",
801
+ authTokenEnv: "MY_API_KEY",
802
+ },
803
+ };
804
+ const env = { MY_API_KEY: "sk-test-123" };
805
+
806
+ const result = resolveProviderEnv("openrouter", "anthropic/claude-3-5-sonnet", providers, env);
807
+
808
+ expect(result).not.toBeNull();
809
+ expect(result?.ANTHROPIC_AUTH_TOKEN).toBe("sk-test-123");
810
+ });
811
+
812
+ test("resolveProviderEnv omits ANTHROPIC_AUTH_TOKEN when authTokenEnv var is absent", () => {
813
+ const providers = {
814
+ openrouter: {
815
+ type: "gateway" as const,
816
+ baseUrl: "https://openrouter.ai/api/v1",
817
+ authTokenEnv: "MY_API_KEY",
818
+ },
819
+ };
820
+ const env: Record<string, string | undefined> = {};
821
+
822
+ const result = resolveProviderEnv("openrouter", "anthropic/claude-3-5-sonnet", providers, env);
823
+
824
+ expect(result).not.toBeNull();
825
+ expect(result?.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
826
+ });
827
+
828
+ test("resolveModel produces different env dicts for coordinator and builder with different gateway providers", () => {
829
+ const config = makeConfig(
830
+ {
831
+ coordinator: "openrouter/anthropic/claude-3-5-sonnet",
832
+ builder: "litellm/anthropic/claude-3-5-haiku",
833
+ },
834
+ {
835
+ openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" },
836
+ litellm: { type: "gateway", baseUrl: "https://litellm.example.com/v1" },
837
+ },
838
+ );
839
+ const manifest = makeManifest();
840
+
841
+ const coordinatorResult = resolveModel(config, manifest, "coordinator", "sonnet");
842
+ const builderResult = resolveModel(config, manifest, "builder", "sonnet");
843
+
844
+ expect(coordinatorResult.model).toBe("sonnet");
845
+ expect(builderResult.model).toBe("sonnet");
846
+ expect(coordinatorResult.env?.ANTHROPIC_BASE_URL).toBe("https://openrouter.ai/api/v1");
847
+ expect(builderResult.env?.ANTHROPIC_BASE_URL).toBe("https://litellm.example.com/v1");
848
+ expect(coordinatorResult.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe(
849
+ "anthropic/claude-3-5-sonnet",
850
+ );
851
+ expect(builderResult.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("anthropic/claude-3-5-haiku");
852
+ });
853
+ });
@@ -98,15 +98,17 @@ export function inferDomainsFromFiles(
98
98
  return [...inferred].sort();
99
99
  }
100
100
 
101
- /**
102
- * Parse a named flag value from an args array.
103
- */
104
- function getFlag(args: string[], flag: string): string | undefined {
105
- const idx = args.indexOf(flag);
106
- if (idx === -1 || idx + 1 >= args.length) {
107
- return undefined;
108
- }
109
- return args[idx + 1];
101
+ export interface SlingOptions {
102
+ capability?: string;
103
+ name?: string;
104
+ spec?: string;
105
+ files?: string;
106
+ parent?: string;
107
+ depth?: string;
108
+ skipScout?: boolean;
109
+ skipTaskCheck?: boolean;
110
+ forceHierarchy?: boolean;
111
+ json?: boolean;
110
112
  }
111
113
 
112
114
  /**
@@ -225,58 +227,26 @@ export function validateHierarchy(
225
227
  /**
226
228
  * Entry point for `overstory sling <task-id> [flags]`.
227
229
  *
228
- * Flags:
229
- * --capability <type> builder | scout | reviewer | lead | merger
230
- * --name <name> Unique agent name
231
- * --spec <path> Path to task spec file
232
- * --files <f1,f2,...> Exclusive file scope
233
- * --parent <agent-name> Parent agent (for hierarchy tracking)
234
- * --depth <n> Current hierarchy depth (default 0)
235
- * --force-hierarchy Bypass hierarchy validation (debugging only)
230
+ * @param taskId - The task ID to assign to the agent
231
+ * @param opts - Command options
236
232
  */
237
- const SLING_HELP = `overstory sling Spawn a worker agent
238
-
239
- Usage: overstory sling <task-id> [flags]
240
-
241
- Arguments:
242
- <task-id> Beads task ID to assign
243
-
244
- Options:
245
- --capability <type> Agent type: builder | scout | reviewer | lead | merger (default: builder)
246
- --name <name> Unique agent name (required)
247
- --spec <path> Path to task spec file
248
- --files <f1,f2,...> Exclusive file scope (comma-separated)
249
- --parent <agent-name> Parent agent for hierarchy tracking
250
- --depth <n> Current hierarchy depth (default: 0)
251
- --skip-scout Skip scout phase for lead agents (jump to build)
252
- --skip-task-check Skip task existence validation (for worktree-created issues)
253
- --force-hierarchy Bypass hierarchy validation (debugging only)
254
- --json Output result as JSON
255
- --help, -h Show this help`;
256
-
257
- export async function slingCommand(args: string[]): Promise<void> {
258
- if (args.includes("--help") || args.includes("-h")) {
259
- process.stdout.write(`${SLING_HELP}\n`);
260
- return;
261
- }
262
-
263
- const taskId = args.find((a) => !a.startsWith("--"));
233
+ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<void> {
264
234
  if (!taskId) {
265
235
  throw new ValidationError("Task ID is required: overstory sling <task-id>", {
266
236
  field: "taskId",
267
237
  });
268
238
  }
269
239
 
270
- const capability = getFlag(args, "--capability") ?? "builder";
271
- const name = getFlag(args, "--name");
272
- const specPath = getFlag(args, "--spec") ?? null;
273
- const filesRaw = getFlag(args, "--files");
274
- const parentAgent = getFlag(args, "--parent") ?? null;
275
- const depthStr = getFlag(args, "--depth");
240
+ const capability = opts.capability ?? "builder";
241
+ const name = opts.name;
242
+ const specPath = opts.spec ?? null;
243
+ const filesRaw = opts.files;
244
+ const parentAgent = opts.parent ?? null;
245
+ const depthStr = opts.depth;
276
246
  const depth = depthStr !== undefined ? Number.parseInt(depthStr, 10) : 0;
277
- const forceHierarchy = args.includes("--force-hierarchy");
278
- const skipScout = args.includes("--skip-scout");
279
- const skipTaskCheck = args.includes("--skip-task-check");
247
+ const forceHierarchy = opts.forceHierarchy ?? false;
248
+ const skipScout = opts.skipScout ?? false;
249
+ const skipTaskCheck = opts.skipTaskCheck ?? false;
280
250
 
281
251
  if (!name || name.trim().length === 0) {
282
252
  throw new ValidationError("--name is required for sling", { field: "name" });
@@ -645,7 +615,7 @@ export async function slingCommand(args: string[]): Promise<void> {
645
615
  pid,
646
616
  };
647
617
 
648
- if (args.includes("--json")) {
618
+ if (opts.json ?? false) {
649
619
  process.stdout.write(`${JSON.stringify(output)}\n`);
650
620
  } else {
651
621
  process.stdout.write(`🚀 Agent "${name}" launched!\n`);
@@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
9
  import { mkdir } from "node:fs/promises";
10
10
  import { join } from "node:path";
11
11
  import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
12
- import { specCommand, writeSpec } from "./spec.ts";
12
+ import { specWriteCommand, writeSpec } from "./spec.ts";
13
13
 
14
14
  let tempDir: string;
15
15
  let overstoryDir: string;
@@ -55,47 +55,21 @@ afterEach(async () => {
55
55
  await cleanupTempDir(tempDir);
56
56
  });
57
57
 
58
- // === help ===
59
-
60
- describe("help", () => {
61
- test("--help shows usage", async () => {
62
- await specCommand(["--help"]);
63
- expect(stdoutOutput).toContain("overstory spec");
64
- expect(stdoutOutput).toContain("write");
65
- expect(stdoutOutput).toContain("--body");
66
- expect(stdoutOutput).toContain("--agent");
67
- });
68
-
69
- test("-h shows usage", async () => {
70
- await specCommand(["-h"]);
71
- expect(stdoutOutput).toContain("overstory spec");
72
- });
73
-
74
- test("no args shows help", async () => {
75
- await specCommand([]);
76
- expect(stdoutOutput).toContain("overstory spec");
77
- });
78
- });
79
-
80
58
  // === validation ===
81
59
 
82
60
  describe("validation", () => {
83
- test("unknown subcommand throws ValidationError", async () => {
84
- await expect(specCommand(["unknown"])).rejects.toThrow("Unknown spec subcommand");
85
- });
86
-
87
61
  test("write without bead-id throws ValidationError", async () => {
88
- await expect(specCommand(["write"])).rejects.toThrow("Bead ID is required");
62
+ await expect(specWriteCommand("", {})).rejects.toThrow("Bead ID is required");
89
63
  });
90
64
 
91
65
  test("write without body throws ValidationError", async () => {
92
- await expect(specCommand(["write", "task-abc", "--agent", "scout-1"])).rejects.toThrow(
66
+ await expect(specWriteCommand("task-abc", { agent: "scout-1" })).rejects.toThrow(
93
67
  "Spec body is required",
94
68
  );
95
69
  });
96
70
 
97
71
  test("write with empty body throws ValidationError", async () => {
98
- await expect(specCommand(["write", "task-abc", "--body", " "])).rejects.toThrow(
72
+ await expect(specWriteCommand("task-abc", { body: " " })).rejects.toThrow(
99
73
  "Spec body is required",
100
74
  );
101
75
  });
@@ -165,11 +139,11 @@ describe("writeSpec", () => {
165
139
  });
166
140
  });
167
141
 
168
- // === specCommand (CLI integration) ===
142
+ // === specWriteCommand (CLI integration) ===
169
143
 
170
- describe("specCommand write", () => {
144
+ describe("specWriteCommand (integration)", () => {
171
145
  test("writes spec and prints path", async () => {
172
- await specCommand(["write", "task-cmd", "--body", "# CLI Spec"]);
146
+ await specWriteCommand("task-cmd", { body: "# CLI Spec" });
173
147
 
174
148
  // Path may differ due to macOS /var -> /private/var symlink resolution
175
149
  expect(stdoutOutput.trim()).toContain(".overstory/specs/task-cmd.md");
@@ -180,7 +154,7 @@ describe("specCommand write", () => {
180
154
  });
181
155
 
182
156
  test("writes spec with agent attribution", async () => {
183
- await specCommand(["write", "task-attr", "--body", "# Attributed", "--agent", "scout-2"]);
157
+ await specWriteCommand("task-attr", { body: "# Attributed", agent: "scout-2" });
184
158
 
185
159
  expect(stdoutOutput.trim()).toContain(".overstory/specs/task-attr.md");
186
160
 
@@ -190,14 +164,14 @@ describe("specCommand write", () => {
190
164
  expect(content).toContain("# Attributed");
191
165
  });
192
166
 
193
- test("flags can appear in any order", async () => {
194
- await specCommand(["write", "--agent", "scout-3", "--body", "# Content", "task-order"]);
167
+ test("writes spec without agent when agent is omitted", async () => {
168
+ await specWriteCommand("task-noagent", { body: "# No Agent" });
195
169
 
196
- expect(stdoutOutput.trim()).toContain(".overstory/specs/task-order.md");
170
+ expect(stdoutOutput.trim()).toContain(".overstory/specs/task-noagent.md");
197
171
 
198
172
  const specPath = stdoutOutput.trim();
199
173
  const content = await Bun.file(specPath).text();
200
- expect(content).toContain("<!-- written-by: scout-3 -->");
201
- expect(content).toContain("# Content");
174
+ expect(content).not.toContain("written-by");
175
+ expect(content).toBe("# No Agent\n");
202
176
  });
203
177
  });