@os-eco/overstory-cli 0.6.1 → 0.6.5

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 (110) hide show
  1. package/README.md +8 -7
  2. package/package.json +12 -4
  3. package/src/agents/checkpoint.test.ts +2 -2
  4. package/src/agents/hooks-deployer.test.ts +131 -16
  5. package/src/agents/hooks-deployer.ts +33 -1
  6. package/src/agents/identity.test.ts +27 -27
  7. package/src/agents/identity.ts +10 -10
  8. package/src/agents/lifecycle.test.ts +6 -6
  9. package/src/agents/lifecycle.ts +2 -2
  10. package/src/agents/manifest.test.ts +86 -0
  11. package/src/agents/overlay.test.ts +9 -9
  12. package/src/agents/overlay.ts +4 -4
  13. package/src/commands/agents.test.ts +8 -8
  14. package/src/commands/agents.ts +62 -91
  15. package/src/commands/clean.test.ts +36 -51
  16. package/src/commands/clean.ts +28 -49
  17. package/src/commands/completions.ts +14 -0
  18. package/src/commands/coordinator.test.ts +133 -26
  19. package/src/commands/coordinator.ts +101 -64
  20. package/src/commands/costs.test.ts +47 -47
  21. package/src/commands/costs.ts +96 -75
  22. package/src/commands/dashboard.test.ts +2 -2
  23. package/src/commands/dashboard.ts +75 -95
  24. package/src/commands/doctor.test.ts +2 -2
  25. package/src/commands/doctor.ts +92 -79
  26. package/src/commands/errors.test.ts +2 -2
  27. package/src/commands/errors.ts +56 -50
  28. package/src/commands/feed.test.ts +2 -2
  29. package/src/commands/feed.ts +86 -83
  30. package/src/commands/group.ts +167 -177
  31. package/src/commands/hooks.test.ts +2 -2
  32. package/src/commands/hooks.ts +52 -42
  33. package/src/commands/init.test.ts +19 -19
  34. package/src/commands/init.ts +7 -16
  35. package/src/commands/inspect.test.ts +18 -18
  36. package/src/commands/inspect.ts +55 -58
  37. package/src/commands/log.test.ts +26 -31
  38. package/src/commands/log.ts +97 -91
  39. package/src/commands/logs.test.ts +1 -1
  40. package/src/commands/logs.ts +101 -104
  41. package/src/commands/mail.test.ts +5 -5
  42. package/src/commands/mail.ts +157 -169
  43. package/src/commands/merge.test.ts +28 -66
  44. package/src/commands/merge.ts +21 -51
  45. package/src/commands/metrics.test.ts +8 -8
  46. package/src/commands/metrics.ts +34 -35
  47. package/src/commands/monitor.test.ts +3 -3
  48. package/src/commands/monitor.ts +57 -62
  49. package/src/commands/nudge.test.ts +1 -1
  50. package/src/commands/nudge.ts +41 -89
  51. package/src/commands/prime.test.ts +19 -51
  52. package/src/commands/prime.ts +13 -50
  53. package/src/commands/replay.test.ts +2 -2
  54. package/src/commands/replay.ts +79 -86
  55. package/src/commands/run.test.ts +1 -1
  56. package/src/commands/run.ts +97 -77
  57. package/src/commands/sling.test.ts +201 -5
  58. package/src/commands/sling.ts +37 -64
  59. package/src/commands/spec.test.ts +14 -40
  60. package/src/commands/spec.ts +32 -101
  61. package/src/commands/status.test.ts +97 -1
  62. package/src/commands/status.ts +63 -58
  63. package/src/commands/stop.test.ts +22 -40
  64. package/src/commands/stop.ts +18 -33
  65. package/src/commands/supervisor.test.ts +12 -14
  66. package/src/commands/supervisor.ts +144 -165
  67. package/src/commands/trace.test.ts +15 -15
  68. package/src/commands/trace.ts +59 -82
  69. package/src/commands/watch.test.ts +2 -2
  70. package/src/commands/watch.ts +38 -45
  71. package/src/commands/worktree.test.ts +213 -37
  72. package/src/commands/worktree.ts +110 -55
  73. package/src/config.test.ts +96 -0
  74. package/src/doctor/consistency.test.ts +14 -14
  75. package/src/doctor/databases.test.ts +22 -2
  76. package/src/doctor/databases.ts +16 -0
  77. package/src/doctor/dependencies.test.ts +55 -1
  78. package/src/doctor/dependencies.ts +113 -18
  79. package/src/doctor/merge-queue.test.ts +4 -4
  80. package/src/e2e/init-sling-lifecycle.test.ts +8 -8
  81. package/src/errors.ts +1 -1
  82. package/src/index.ts +223 -213
  83. package/src/logging/color.test.ts +74 -91
  84. package/src/logging/color.ts +52 -46
  85. package/src/logging/reporter.test.ts +10 -10
  86. package/src/logging/reporter.ts +6 -5
  87. package/src/mail/broadcast.test.ts +1 -1
  88. package/src/mail/client.test.ts +6 -6
  89. package/src/mail/store.test.ts +3 -3
  90. package/src/merge/queue.test.ts +73 -7
  91. package/src/merge/queue.ts +17 -2
  92. package/src/merge/resolver.test.ts +159 -7
  93. package/src/merge/resolver.ts +46 -2
  94. package/src/metrics/store.test.ts +44 -44
  95. package/src/metrics/store.ts +2 -2
  96. package/src/metrics/summary.test.ts +35 -35
  97. package/src/mulch/client.test.ts +1 -1
  98. package/src/schema-consistency.test.ts +239 -0
  99. package/src/sessions/compat.test.ts +3 -3
  100. package/src/sessions/compat.ts +2 -2
  101. package/src/sessions/store.test.ts +41 -4
  102. package/src/sessions/store.ts +13 -2
  103. package/src/types.ts +14 -14
  104. package/src/watchdog/daemon.test.ts +10 -10
  105. package/src/watchdog/daemon.ts +1 -1
  106. package/src/watchdog/health.test.ts +1 -1
  107. package/src/worktree/manager.test.ts +20 -20
  108. package/src/worktree/manager.ts +120 -4
  109. package/src/worktree/tmux.test.ts +98 -9
  110. package/src/worktree/tmux.ts +18 -0
@@ -7,14 +7,16 @@
7
7
  */
8
8
 
9
9
  import { join } from "node:path";
10
+ import { Command } from "commander";
10
11
  import { loadConfig } from "../config.ts";
11
12
  import { ValidationError } from "../errors.ts";
12
13
  import { createEventStore } from "../events/store.ts";
14
+ import type { ColorFn } from "../logging/color.ts";
13
15
  import { color } from "../logging/color.ts";
14
16
  import type { EventType, StoredEvent } from "../types.ts";
15
17
 
16
18
  /** Labels and colors for each event type. */
17
- const EVENT_LABELS: Record<EventType, { label: string; color: string }> = {
19
+ const EVENT_LABELS: Record<EventType, { label: string; color: ColorFn }> = {
18
20
  tool_start: { label: "TOOL START", color: color.blue },
19
21
  tool_end: { label: "TOOL END ", color: color.blue },
20
22
  session_start: { label: "SESSION +", color: color.green },
@@ -26,41 +28,14 @@ const EVENT_LABELS: Record<EventType, { label: string; color: string }> = {
26
28
  custom: { label: "CUSTOM ", color: color.gray },
27
29
  };
28
30
 
29
- /** Colors assigned to agents in order of first appearance. */
30
- const AGENT_COLORS = [color.blue, color.green, color.yellow, color.cyan, color.magenta] as const;
31
-
32
- /**
33
- * Parse a named flag value from args.
34
- */
35
- function getFlag(args: string[], flag: string): string | undefined {
36
- const idx = args.indexOf(flag);
37
- if (idx === -1 || idx + 1 >= args.length) {
38
- return undefined;
39
- }
40
- return args[idx + 1];
41
- }
42
-
43
- /**
44
- * Parse all occurrences of a named flag from args.
45
- * Returns an array of values (e.g., --agent a --agent b => ["a", "b"]).
46
- */
47
- function getAllFlags(args: string[], flag: string): string[] {
48
- const values: string[] = [];
49
- for (let i = 0; i < args.length; i++) {
50
- if (args[i] === flag && i + 1 < args.length) {
51
- const value = args[i + 1];
52
- if (value !== undefined) {
53
- values.push(value);
54
- }
55
- i++; // skip the value
56
- }
57
- }
58
- return values;
59
- }
60
-
61
- function hasFlag(args: string[], flag: string): boolean {
62
- return args.includes(flag);
63
- }
31
+ /** Color functions assigned to agents in order of first appearance. */
32
+ const AGENT_COLORS: readonly ColorFn[] = [
33
+ color.blue,
34
+ color.green,
35
+ color.yellow,
36
+ color.cyan,
37
+ color.magenta,
38
+ ];
64
39
 
65
40
  /**
66
41
  * Format a relative time string from a timestamp.
@@ -150,16 +125,16 @@ function buildEventDetail(event: StoredEvent): string {
150
125
  }
151
126
 
152
127
  /**
153
- * Assign a stable color to each agent based on order of first appearance.
128
+ * Assign a stable color function to each agent based on order of first appearance.
154
129
  */
155
- function buildAgentColorMap(events: StoredEvent[]): Map<string, string> {
156
- const colorMap = new Map<string, string>();
130
+ function buildAgentColorMap(events: StoredEvent[]): Map<string, ColorFn> {
131
+ const colorMap = new Map<string, ColorFn>();
157
132
  for (const event of events) {
158
133
  if (!colorMap.has(event.agentName)) {
159
134
  const colorIndex = colorMap.size % AGENT_COLORS.length;
160
- const color = AGENT_COLORS[colorIndex];
161
- if (color !== undefined) {
162
- colorMap.set(event.agentName, color);
135
+ const agentColorFn = AGENT_COLORS[colorIndex];
136
+ if (agentColorFn !== undefined) {
137
+ colorMap.set(event.agentName, agentColorFn);
163
138
  }
164
139
  }
165
140
  }
@@ -172,15 +147,15 @@ function buildAgentColorMap(events: StoredEvent[]): Map<string, string> {
172
147
  function printReplay(events: StoredEvent[], useAbsoluteTime: boolean): void {
173
148
  const w = process.stdout.write.bind(process.stdout);
174
149
 
175
- w(`${color.bold}Replay${color.reset}\n`);
150
+ w(`${color.bold("Replay")}\n`);
176
151
  w(`${"=".repeat(70)}\n`);
177
152
 
178
153
  if (events.length === 0) {
179
- w(`${color.dim}No events found.${color.reset}\n`);
154
+ w(`${color.dim("No events found.")}\n`);
180
155
  return;
181
156
  }
182
157
 
183
- w(`${color.dim}${events.length} event${events.length === 1 ? "" : "s"}${color.reset}\n\n`);
158
+ w(`${color.dim(`${events.length} event${events.length === 1 ? "" : "s"}`)}\n\n`);
184
159
 
185
160
  const colorMap = buildAgentColorMap(events);
186
161
  let lastDate = "";
@@ -192,7 +167,7 @@ function printReplay(events: StoredEvent[], useAbsoluteTime: boolean): void {
192
167
  if (lastDate !== "") {
193
168
  w("\n");
194
169
  }
195
- w(`${color.dim}--- ${date} ---${color.reset}\n`);
170
+ w(`${color.dim(`--- ${date} ---`)}\n`);
196
171
  lastDate = date;
197
172
  }
198
173
 
@@ -205,57 +180,40 @@ function printReplay(events: StoredEvent[], useAbsoluteTime: boolean): void {
205
180
  color: color.gray,
206
181
  };
207
182
 
208
- const levelColor =
209
- event.level === "error" ? color.red : event.level === "warn" ? color.yellow : "";
210
- const levelReset = levelColor ? color.reset : "";
183
+ const levelColorFn =
184
+ event.level === "error" ? color.red : event.level === "warn" ? color.yellow : null;
185
+ const applyLevel = (text: string) => (levelColorFn ? levelColorFn(text) : text);
211
186
 
212
187
  const detail = buildEventDetail(event);
213
- const detailSuffix = detail ? ` ${color.dim}${detail}${color.reset}` : "";
188
+ const detailSuffix = detail ? ` ${color.dim(detail)}` : "";
214
189
 
215
- const agentColor = colorMap.get(event.agentName) ?? color.gray;
216
- const agentLabel = ` ${agentColor}[${event.agentName}]${color.reset}`;
190
+ const agentColorFn = colorMap.get(event.agentName) ?? color.gray;
191
+ const agentLabel = ` ${agentColorFn(`[${event.agentName}]`)}`;
217
192
 
218
193
  w(
219
- `${color.dim}${timeStr.padStart(10)}${color.reset} ` +
220
- `${levelColor}${eventInfo.color}${color.bold}${eventInfo.label}${color.reset}${levelReset}` +
194
+ `${color.dim(timeStr.padStart(10))} ` +
195
+ `${applyLevel(eventInfo.color(color.bold(eventInfo.label)))}` +
221
196
  `${agentLabel}${detailSuffix}\n`,
222
197
  );
223
198
  }
224
199
  }
225
200
 
226
- const REPLAY_HELP = `overstory replay -- Interleaved chronological replay across agents
227
-
228
- Usage: overstory replay [options]
229
-
230
- Options:
231
- --run <id> Filter events by run ID
232
- --agent <name> Filter by agent name (can appear multiple times)
233
- --since <timestamp> Start time filter (ISO 8601)
234
- --until <timestamp> End time filter (ISO 8601)
235
- --limit <n> Max events to show (default: 200)
236
- --json Output as JSON array of StoredEvent objects
237
- --help, -h Show this help
238
-
239
- If --run is specified, shows all events from that run.
240
- If --agent is specified, shows events from those agents merged chronologically.
241
- If neither is specified, tries to read the current run from .overstory/current-run.txt.
242
- Falls back to a 24-hour timeline of all events.`;
243
-
244
- /**
245
- * Entry point for `overstory replay [--run <id>] [--agent <name>...] [--json]`.
246
- */
247
- export async function replayCommand(args: string[]): Promise<void> {
248
- if (args.includes("--help") || args.includes("-h")) {
249
- process.stdout.write(`${REPLAY_HELP}\n`);
250
- return;
251
- }
201
+ interface ReplayOpts {
202
+ run?: string;
203
+ agent: string[]; // repeatable
204
+ since?: string;
205
+ until?: string;
206
+ limit?: string;
207
+ json?: boolean;
208
+ }
252
209
 
253
- const json = hasFlag(args, "--json");
254
- const runId = getFlag(args, "--run");
255
- const agentNames = getAllFlags(args, "--agent");
256
- const sinceStr = getFlag(args, "--since");
257
- const untilStr = getFlag(args, "--until");
258
- const limitStr = getFlag(args, "--limit");
210
+ async function executeReplay(opts: ReplayOpts): Promise<void> {
211
+ const json = opts.json ?? false;
212
+ const runId = opts.run;
213
+ const agentNames = opts.agent;
214
+ const sinceStr = opts.since;
215
+ const untilStr = opts.until;
216
+ const limitStr = opts.limit;
259
217
  const limit = limitStr ? Number.parseInt(limitStr, 10) : 200;
260
218
 
261
219
  if (Number.isNaN(limit) || limit < 1) {
@@ -358,3 +316,38 @@ export async function replayCommand(args: string[]): Promise<void> {
358
316
  eventStore.close();
359
317
  }
360
318
  }
319
+
320
+ export function createReplayCommand(): Command {
321
+ return new Command("replay")
322
+ .description("Interleaved chronological replay across agents")
323
+ .option("--run <id>", "Filter events by run ID")
324
+ .option(
325
+ "--agent <name>",
326
+ "Filter by agent name (can appear multiple times)",
327
+ (val: string, prev: string[]) => [...prev, val],
328
+ [] as string[],
329
+ )
330
+ .option("--since <timestamp>", "Start time filter (ISO 8601)")
331
+ .option("--until <timestamp>", "End time filter (ISO 8601)")
332
+ .option("--limit <n>", "Max events to show (default: 200)")
333
+ .option("--json", "Output as JSON array of StoredEvent objects")
334
+ .action(async (opts: ReplayOpts) => {
335
+ await executeReplay(opts);
336
+ });
337
+ }
338
+
339
+ export async function replayCommand(args: string[]): Promise<void> {
340
+ const cmd = createReplayCommand();
341
+ cmd.exitOverride();
342
+ try {
343
+ await cmd.parseAsync(args, { from: "user" });
344
+ } catch (err: unknown) {
345
+ if (err && typeof err === "object" && "code" in err) {
346
+ const code = (err as { code: string }).code;
347
+ if (code === "commander.helpDisplayed" || code === "commander.version") {
348
+ return;
349
+ }
350
+ }
351
+ throw err;
352
+ }
353
+ }
@@ -68,7 +68,7 @@ function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
68
68
  capability: "builder",
69
69
  worktreePath: "/tmp/worktrees/test-agent",
70
70
  branchName: "overstory/test-agent/task-1",
71
- beadId: "task-1",
71
+ taskId: "task-1",
72
72
  tmuxSession: "overstory-test-agent",
73
73
  state: "working",
74
74
  pid: 12345,
@@ -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,
@@ -552,13 +554,13 @@ describe("isRunningAsRoot", () => {
552
554
  /**
553
555
  * Tests for checkBeadLock.
554
556
  *
555
- * checkBeadLock prevents concurrent agents from working the same bead ID.
557
+ * checkBeadLock prevents concurrent agents from working the same task ID.
556
558
  * It checks the active session list and returns the agent name that holds
557
559
  * the lock (i.e., is already working on the bead), or null if the bead is free.
558
560
  */
559
561
 
560
- function makeBeadSession(agentName: string, beadId: string): { agentName: string; beadId: string } {
561
- return { agentName, beadId };
562
+ function makeBeadSession(agentName: string, taskId: string): { agentName: string; taskId: string } {
563
+ return { agentName, taskId };
562
564
  }
563
565
 
564
566
  describe("checkBeadLock", () => {
@@ -566,7 +568,7 @@ describe("checkBeadLock", () => {
566
568
  expect(checkBeadLock([], "overstory-abc")).toBeNull();
567
569
  });
568
570
 
569
- test("returns null when no session matches the bead ID", () => {
571
+ test("returns null when no session matches the task ID", () => {
570
572
  const sessions = [
571
573
  makeBeadSession("builder-1", "overstory-xyz"),
572
574
  makeBeadSession("builder-2", "overstory-def"),
@@ -585,7 +587,7 @@ describe("checkBeadLock", () => {
585
587
  });
586
588
 
587
589
  test("returns the first matching agent when multiple sessions match", () => {
588
- // Multiple sessions can have the same beadId (e.g., retried agent)
590
+ // Multiple sessions can have the same taskId (e.g., retried agent)
589
591
  // checkBeadLock returns the first match
590
592
  const sessions = [
591
593
  makeBeadSession("builder-1", "overstory-abc"),
@@ -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
+ });