@os-eco/overstory-cli 0.6.11 → 0.7.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 (87) hide show
  1. package/README.md +12 -13
  2. package/agents/builder.md +1 -1
  3. package/agents/coordinator.md +12 -11
  4. package/agents/lead.md +25 -24
  5. package/agents/monitor.md +4 -4
  6. package/agents/reviewer.md +1 -1
  7. package/agents/scout.md +5 -5
  8. package/agents/supervisor.md +36 -32
  9. package/package.json +5 -3
  10. package/src/agents/guard-rules.ts +97 -0
  11. package/src/agents/hooks-deployer.ts +7 -90
  12. package/src/agents/overlay.test.ts +30 -7
  13. package/src/agents/overlay.ts +10 -9
  14. package/src/commands/agents.test.ts +5 -0
  15. package/src/commands/clean.test.ts +3 -0
  16. package/src/commands/completions.ts +1 -1
  17. package/src/commands/coordinator.test.ts +1 -0
  18. package/src/commands/coordinator.ts +34 -18
  19. package/src/commands/costs.test.ts +6 -1
  20. package/src/commands/costs.ts +13 -20
  21. package/src/commands/dashboard.ts +38 -138
  22. package/src/commands/doctor.test.ts +1 -1
  23. package/src/commands/doctor.ts +2 -2
  24. package/src/commands/ecosystem.ts +2 -1
  25. package/src/commands/errors.test.ts +4 -5
  26. package/src/commands/errors.ts +4 -62
  27. package/src/commands/feed.test.ts +2 -2
  28. package/src/commands/feed.ts +12 -106
  29. package/src/commands/init.test.ts +1 -2
  30. package/src/commands/init.ts +1 -8
  31. package/src/commands/inspect.test.ts +14 -0
  32. package/src/commands/inspect.ts +10 -44
  33. package/src/commands/log.test.ts +14 -0
  34. package/src/commands/log.ts +39 -0
  35. package/src/commands/logs.ts +7 -63
  36. package/src/commands/mail.test.ts +5 -0
  37. package/src/commands/metrics.test.ts +2 -2
  38. package/src/commands/metrics.ts +3 -17
  39. package/src/commands/monitor.ts +30 -16
  40. package/src/commands/nudge.test.ts +1 -0
  41. package/src/commands/prime.test.ts +2 -0
  42. package/src/commands/prime.ts +6 -2
  43. package/src/commands/replay.test.ts +2 -2
  44. package/src/commands/replay.ts +12 -135
  45. package/src/commands/run.test.ts +1 -0
  46. package/src/commands/run.ts +7 -23
  47. package/src/commands/sling.test.ts +68 -1
  48. package/src/commands/sling.ts +62 -24
  49. package/src/commands/status.test.ts +1 -0
  50. package/src/commands/status.ts +4 -17
  51. package/src/commands/stop.test.ts +1 -0
  52. package/src/commands/supervisor.ts +35 -18
  53. package/src/commands/trace.test.ts +6 -6
  54. package/src/commands/trace.ts +11 -109
  55. package/src/commands/worktree.test.ts +9 -0
  56. package/src/config.ts +39 -0
  57. package/src/doctor/consistency.test.ts +14 -0
  58. package/src/e2e/init-sling-lifecycle.test.ts +3 -5
  59. package/src/index.ts +2 -1
  60. package/src/logging/format.ts +214 -0
  61. package/src/logging/theme.ts +132 -0
  62. package/src/mail/broadcast.test.ts +1 -0
  63. package/src/merge/resolver.ts +23 -4
  64. package/src/metrics/store.test.ts +46 -0
  65. package/src/metrics/store.ts +11 -0
  66. package/src/mulch/client.test.ts +20 -0
  67. package/src/mulch/client.ts +312 -45
  68. package/src/runtimes/claude.test.ts +616 -0
  69. package/src/runtimes/claude.ts +218 -0
  70. package/src/runtimes/pi-guards.test.ts +433 -0
  71. package/src/runtimes/pi-guards.ts +349 -0
  72. package/src/runtimes/pi.test.ts +620 -0
  73. package/src/runtimes/pi.ts +244 -0
  74. package/src/runtimes/registry.test.ts +86 -0
  75. package/src/runtimes/registry.ts +46 -0
  76. package/src/runtimes/types.ts +188 -0
  77. package/src/schema-consistency.test.ts +1 -0
  78. package/src/sessions/compat.ts +1 -0
  79. package/src/sessions/store.test.ts +31 -0
  80. package/src/sessions/store.ts +37 -4
  81. package/src/types.ts +21 -0
  82. package/src/watchdog/daemon.test.ts +7 -4
  83. package/src/watchdog/daemon.ts +1 -1
  84. package/src/watchdog/health.test.ts +1 -0
  85. package/src/watchdog/triage.ts +14 -4
  86. package/src/worktree/tmux.test.ts +28 -13
  87. package/src/worktree/tmux.ts +14 -28
@@ -773,6 +773,7 @@ describe("mailCommand", () => {
773
773
  lastActivity: new Date().toISOString(),
774
774
  escalationLevel: 0,
775
775
  stalledSince: null,
776
+ transcriptPath: null,
776
777
  },
777
778
  {
778
779
  id: "session-builder-1",
@@ -791,6 +792,7 @@ describe("mailCommand", () => {
791
792
  lastActivity: new Date().toISOString(),
792
793
  escalationLevel: 0,
793
794
  stalledSince: null,
795
+ transcriptPath: null,
794
796
  },
795
797
  {
796
798
  id: "session-builder-2",
@@ -809,6 +811,7 @@ describe("mailCommand", () => {
809
811
  lastActivity: new Date().toISOString(),
810
812
  escalationLevel: 0,
811
813
  stalledSince: null,
814
+ transcriptPath: null,
812
815
  },
813
816
  {
814
817
  id: "session-scout-1",
@@ -827,6 +830,7 @@ describe("mailCommand", () => {
827
830
  lastActivity: new Date().toISOString(),
828
831
  escalationLevel: 0,
829
832
  stalledSince: null,
833
+ transcriptPath: null,
830
834
  },
831
835
  ];
832
836
 
@@ -1147,6 +1151,7 @@ describe("mailCommand", () => {
1147
1151
  lastActivity: new Date().toISOString(),
1148
1152
  escalationLevel: 0,
1149
1153
  stalledSince: null,
1154
+ transcriptPath: null,
1150
1155
  });
1151
1156
  }
1152
1157
 
@@ -443,7 +443,7 @@ describe("formatDuration helper", () => {
443
443
  expect(out).toContain("1h 2m");
444
444
  });
445
445
 
446
- test("3600000ms formats as 1h 0m", async () => {
446
+ test("3600000ms formats as 1h", async () => {
447
447
  const dbPath = join(tempDir, ".overstory", "metrics.db");
448
448
  const store = createMetricsStore(dbPath);
449
449
  store.recordSession(makeSession(3_600_000));
@@ -452,6 +452,6 @@ describe("formatDuration helper", () => {
452
452
  await metricsCommand([]);
453
453
  const out = output();
454
454
 
455
- expect(out).toContain("1h 0m");
455
+ expect(out).toContain("1h");
456
456
  });
457
457
  });
@@ -9,6 +9,8 @@ import { join } from "node:path";
9
9
  import { Command } from "commander";
10
10
  import { loadConfig } from "../config.ts";
11
11
  import { jsonOutput } from "../json.ts";
12
+ import { formatDuration } from "../logging/format.ts";
13
+ import { renderHeader } from "../logging/theme.ts";
12
14
  import { createMetricsStore } from "../metrics/store.ts";
13
15
 
14
16
  interface MetricsOpts {
@@ -16,21 +18,6 @@ interface MetricsOpts {
16
18
  json?: boolean;
17
19
  }
18
20
 
19
- /**
20
- * Format milliseconds as human-readable duration.
21
- */
22
- function formatDuration(ms: number): string {
23
- if (ms === 0) return "0s";
24
- const seconds = Math.floor(ms / 1000);
25
- if (seconds < 60) return `${seconds}s`;
26
- const minutes = Math.floor(seconds / 60);
27
- const remainSec = seconds % 60;
28
- if (minutes < 60) return `${minutes}m ${remainSec}s`;
29
- const hours = Math.floor(minutes / 60);
30
- const remainMin = minutes % 60;
31
- return `${hours}h ${remainMin}m`;
32
- }
33
-
34
21
  async function executeMetrics(opts: MetricsOpts): Promise<void> {
35
22
  const limit = opts.last ? Number.parseInt(opts.last, 10) : 20;
36
23
  const json = opts.json ?? false;
@@ -64,8 +51,7 @@ async function executeMetrics(opts: MetricsOpts): Promise<void> {
64
51
  return;
65
52
  }
66
53
 
67
- process.stdout.write("Session Metrics\n");
68
- process.stdout.write(`${"═".repeat(60)}\n\n`);
54
+ process.stdout.write(`${renderHeader("Session Metrics")}\n\n`);
69
55
 
70
56
  // Summary stats
71
57
  const completed = sessions.filter((s) => s.completedAt !== null);
@@ -16,13 +16,13 @@
16
16
  import { mkdir } from "node:fs/promises";
17
17
  import { join } from "node:path";
18
18
  import { Command } from "commander";
19
- import { deployHooks } from "../agents/hooks-deployer.ts";
20
19
  import { createIdentity, loadIdentity } from "../agents/identity.ts";
21
20
  import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
22
21
  import { loadConfig } from "../config.ts";
23
22
  import { AgentError, ValidationError } from "../errors.ts";
24
23
  import { jsonOutput } from "../json.ts";
25
24
  import { printHint, printSuccess } from "../logging/color.ts";
25
+ import { getRuntime } from "../runtimes/registry.ts";
26
26
  import { openSessionStore } from "../sessions/compat.ts";
27
27
  import type { AgentSession } from "../types.ts";
28
28
  import { createSession, isSessionAlive, killSession, sendKeys } from "../worktree/tmux.ts";
@@ -110,8 +110,21 @@ async function startMonitor(opts: { json: boolean; attach: boolean }): Promise<v
110
110
  store.updateState(MONITOR_NAME, "completed");
111
111
  }
112
112
 
113
+ // Resolve model and runtime early (needed for deployConfig and spawn)
114
+ const manifestLoader = createManifestLoader(
115
+ join(projectRoot, config.agents.manifestPath),
116
+ join(projectRoot, config.agents.baseDir),
117
+ );
118
+ const manifest = await manifestLoader.load();
119
+ const resolvedModel = resolveModel(config, manifest, "monitor", "sonnet");
120
+ const runtime = getRuntime(undefined, config);
121
+
113
122
  // Deploy monitor-specific hooks to the project root's .claude/ directory.
114
- await deployHooks(projectRoot, MONITOR_NAME, "monitor");
123
+ await runtime.deployConfig(projectRoot, undefined, {
124
+ agentName: MONITOR_NAME,
125
+ capability: "monitor",
126
+ worktreePath: projectRoot,
127
+ });
115
128
 
116
129
  // Create monitor identity if first run
117
130
  const identityBaseDir = join(projectRoot, ".overstory", "agents");
@@ -128,25 +141,25 @@ async function startMonitor(opts: { json: boolean; attach: boolean }): Promise<v
128
141
  });
129
142
  }
130
143
 
131
- // Resolve model from config > manifest > fallback
132
- const manifestLoader = createManifestLoader(
133
- join(projectRoot, config.agents.manifestPath),
134
- join(projectRoot, config.agents.baseDir),
135
- );
136
- const manifest = await manifestLoader.load();
137
- const { model, env } = resolveModel(config, manifest, "monitor", "sonnet");
138
-
139
144
  // Spawn tmux session at project root with Claude Code (interactive mode).
140
145
  const agentDefPath = join(projectRoot, ".overstory", "agent-defs", "monitor.md");
141
146
  const agentDefFile = Bun.file(agentDefPath);
142
- let claudeCmd = `claude --model ${model} --permission-mode bypassPermissions`;
147
+ let appendSystemPrompt: string | undefined;
143
148
  if (await agentDefFile.exists()) {
144
- const agentDef = await agentDefFile.text();
145
- const escaped = agentDef.replace(/'/g, "'\\''");
146
- claudeCmd += ` --append-system-prompt '${escaped}'`;
149
+ appendSystemPrompt = await agentDefFile.text();
147
150
  }
148
- const pid = await createSession(tmuxSession, projectRoot, claudeCmd, {
149
- ...env,
151
+ const spawnCmd = runtime.buildSpawnCommand({
152
+ model: resolvedModel.model,
153
+ permissionMode: "bypass",
154
+ cwd: projectRoot,
155
+ appendSystemPrompt,
156
+ env: {
157
+ ...runtime.buildEnv(resolvedModel),
158
+ OVERSTORY_AGENT_NAME: MONITOR_NAME,
159
+ },
160
+ });
161
+ const pid = await createSession(tmuxSession, projectRoot, spawnCmd, {
162
+ ...runtime.buildEnv(resolvedModel),
150
163
  OVERSTORY_AGENT_NAME: MONITOR_NAME,
151
164
  });
152
165
 
@@ -169,6 +182,7 @@ async function startMonitor(opts: { json: boolean; attach: boolean }): Promise<v
169
182
  lastActivity: new Date().toISOString(),
170
183
  escalationLevel: 0,
171
184
  stalledSince: null,
185
+ transcriptPath: null,
172
186
  };
173
187
 
174
188
  store.upsert(session);
@@ -57,6 +57,7 @@ function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
57
57
  lastActivity: new Date().toISOString(),
58
58
  escalationLevel: 0,
59
59
  stalledSince: null,
60
+ transcriptPath: null,
60
61
  ...overrides,
61
62
  };
62
63
  }
@@ -167,6 +167,7 @@ recentTasks:
167
167
  lastActivity: new Date().toISOString(),
168
168
  escalationLevel: 0,
169
169
  stalledSince: null,
170
+ transcriptPath: null,
170
171
  },
171
172
  ];
172
173
 
@@ -204,6 +205,7 @@ recentTasks:
204
205
  lastActivity: new Date().toISOString(),
205
206
  escalationLevel: 0,
206
207
  stalledSince: null,
208
+ transcriptPath: null,
207
209
  },
208
210
  ];
209
211
 
@@ -38,6 +38,8 @@ const OVERSTORY_GITIGNORE = `# Wildcard+whitelist: ignore everything, whitelist
38
38
  export interface PrimeOptions {
39
39
  agent?: string;
40
40
  compact?: boolean;
41
+ /** Override the instruction path referenced in agent activation context. Defaults to ".claude/CLAUDE.md". */
42
+ instructionPath?: string;
41
43
  }
42
44
 
43
45
  /**
@@ -138,6 +140,7 @@ async function healGitignore(overstoryDir: string): Promise<void> {
138
140
  export async function primeCommand(opts: PrimeOptions): Promise<void> {
139
141
  const agentName = opts.agent ?? null;
140
142
  const compact = opts.compact ?? false;
143
+ const instructionPath = opts.instructionPath ?? ".claude/CLAUDE.md";
141
144
 
142
145
  // 1. Load config
143
146
  const config = await loadConfig(process.cwd());
@@ -161,7 +164,7 @@ export async function primeCommand(opts: PrimeOptions): Promise<void> {
161
164
  // 4. Output context (orchestrator or agent)
162
165
  if (agentName !== null) {
163
166
  // === Agent priming ===
164
- await outputAgentContext(config, agentName, compact, expertiseOutput);
167
+ await outputAgentContext(config, agentName, compact, expertiseOutput, instructionPath);
165
168
  } else {
166
169
  // === Orchestrator priming ===
167
170
  await outputOrchestratorContext(config, compact, expertiseOutput);
@@ -176,6 +179,7 @@ async function outputAgentContext(
176
179
  agentName: string,
177
180
  compact: boolean,
178
181
  expertiseOutput: string | null,
182
+ instructionPath: string,
179
183
  ): Promise<void> {
180
184
  const sections: string[] = [];
181
185
 
@@ -226,7 +230,7 @@ async function outputAgentContext(
226
230
  if (boundSession) {
227
231
  sections.push("\n## Activation");
228
232
  sections.push(`You have a bound task: **${boundSession.taskId}**`);
229
- sections.push("Read your overlay at `.claude/CLAUDE.md` and begin working immediately.");
233
+ sections.push(`Read your overlay at \`${instructionPath}\` and begin working immediately.`);
230
234
  sections.push("Do not wait for dispatch mail. Your assignment was bound at spawn time.");
231
235
  }
232
236
 
@@ -228,7 +228,7 @@ describe("replayCommand", () => {
228
228
  await replayCommand(["--run", "run-001"]);
229
229
  const out = output();
230
230
 
231
- expect(out).toContain("=".repeat(70));
231
+ expect(out).toContain("".repeat(70));
232
232
  });
233
233
 
234
234
  test("shows event count", async () => {
@@ -742,7 +742,7 @@ describe("replayCommand", () => {
742
742
  const out = output();
743
743
 
744
744
  expect(out).not.toContain(longValue);
745
- expect(out).toContain("...");
745
+ expect(out).toContain("");
746
746
  });
747
747
  });
748
748
  });
@@ -12,135 +12,16 @@ import { loadConfig } from "../config.ts";
12
12
  import { ValidationError } from "../errors.ts";
13
13
  import { createEventStore } from "../events/store.ts";
14
14
  import { jsonOutput } from "../json.ts";
15
- import type { ColorFn } from "../logging/color.ts";
16
15
  import { color } from "../logging/color.ts";
17
- import type { EventType, StoredEvent } from "../types.ts";
18
-
19
- /** Labels and colors for each event type. */
20
- const EVENT_LABELS: Record<EventType, { label: string; color: ColorFn }> = {
21
- tool_start: { label: "TOOL START", color: color.blue },
22
- tool_end: { label: "TOOL END ", color: color.blue },
23
- session_start: { label: "SESSION +", color: color.green },
24
- session_end: { label: "SESSION -", color: color.yellow },
25
- mail_sent: { label: "MAIL SENT ", color: color.cyan },
26
- mail_received: { label: "MAIL RECV ", color: color.cyan },
27
- spawn: { label: "SPAWN ", color: color.magenta },
28
- error: { label: "ERROR ", color: color.red },
29
- custom: { label: "CUSTOM ", color: color.gray },
30
- };
31
-
32
- /** Color functions assigned to agents in order of first appearance. */
33
- const AGENT_COLORS: readonly ColorFn[] = [
34
- color.blue,
35
- color.green,
36
- color.yellow,
37
- color.cyan,
38
- color.magenta,
39
- ];
40
-
41
- /**
42
- * Format a relative time string from a timestamp.
43
- * Returns strings like "2m ago", "1h ago", "3d ago".
44
- */
45
- function formatRelativeTime(timestamp: string): string {
46
- const eventTime = new Date(timestamp).getTime();
47
- const now = Date.now();
48
- const diffMs = now - eventTime;
49
-
50
- if (diffMs < 0) return "just now";
51
-
52
- const seconds = Math.floor(diffMs / 1000);
53
- if (seconds < 60) return `${seconds}s ago`;
54
-
55
- const minutes = Math.floor(seconds / 60);
56
- if (minutes < 60) return `${minutes}m ago`;
57
-
58
- const hours = Math.floor(minutes / 60);
59
- if (hours < 24) return `${hours}h ago`;
60
-
61
- const days = Math.floor(hours / 24);
62
- return `${days}d ago`;
63
- }
64
-
65
- /**
66
- * Format an absolute time from an ISO timestamp.
67
- * Returns "HH:MM:SS" portion.
68
- */
69
- function formatAbsoluteTime(timestamp: string): string {
70
- const match = /T(\d{2}:\d{2}:\d{2})/.exec(timestamp);
71
- if (match?.[1]) {
72
- return match[1];
73
- }
74
- return timestamp;
75
- }
76
-
77
- /**
78
- * Format the date portion of an ISO timestamp.
79
- * Returns "YYYY-MM-DD".
80
- */
81
- function formatDate(timestamp: string): string {
82
- const match = /^(\d{4}-\d{2}-\d{2})/.exec(timestamp);
83
- if (match?.[1]) {
84
- return match[1];
85
- }
86
- return "";
87
- }
88
-
89
- /**
90
- * Build a detail string for a timeline event based on its type and fields.
91
- */
92
- function buildEventDetail(event: StoredEvent): string {
93
- const parts: string[] = [];
94
-
95
- if (event.toolName) {
96
- parts.push(`tool=${event.toolName}`);
97
- }
98
-
99
- if (event.toolDurationMs !== null) {
100
- parts.push(`duration=${event.toolDurationMs}ms`);
101
- }
102
-
103
- if (event.data) {
104
- try {
105
- const parsed: unknown = JSON.parse(event.data);
106
- if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
107
- const data = parsed as Record<string, unknown>;
108
- for (const [key, value] of Object.entries(data)) {
109
- if (value !== null && value !== undefined) {
110
- const strValue = typeof value === "string" ? value : JSON.stringify(value);
111
- // Truncate long values
112
- const truncated = strValue.length > 80 ? `${strValue.slice(0, 77)}...` : strValue;
113
- parts.push(`${key}=${truncated}`);
114
- }
115
- }
116
- }
117
- } catch {
118
- // data is not valid JSON; show it raw if short enough
119
- if (event.data.length <= 80) {
120
- parts.push(event.data);
121
- }
122
- }
123
- }
124
-
125
- return parts.join(" ");
126
- }
127
-
128
- /**
129
- * Assign a stable color function to each agent based on order of first appearance.
130
- */
131
- function buildAgentColorMap(events: StoredEvent[]): Map<string, ColorFn> {
132
- const colorMap = new Map<string, ColorFn>();
133
- for (const event of events) {
134
- if (!colorMap.has(event.agentName)) {
135
- const colorIndex = colorMap.size % AGENT_COLORS.length;
136
- const agentColorFn = AGENT_COLORS[colorIndex];
137
- if (agentColorFn !== undefined) {
138
- colorMap.set(event.agentName, agentColorFn);
139
- }
140
- }
141
- }
142
- return colorMap;
143
- }
16
+ import {
17
+ buildAgentColorMap,
18
+ buildEventDetail,
19
+ formatAbsoluteTime,
20
+ formatDate,
21
+ formatRelativeTime,
22
+ } from "../logging/format.ts";
23
+ import { eventLabel, renderHeader } from "../logging/theme.ts";
24
+ import type { StoredEvent } from "../types.ts";
144
25
 
145
26
  /**
146
27
  * Print events as an interleaved timeline with ANSI colors and agent labels.
@@ -148,8 +29,7 @@ function buildAgentColorMap(events: StoredEvent[]): Map<string, ColorFn> {
148
29
  function printReplay(events: StoredEvent[], useAbsoluteTime: boolean): void {
149
30
  const w = process.stdout.write.bind(process.stdout);
150
31
 
151
- w(`${color.bold("Replay")}\n`);
152
- w(`${"=".repeat(70)}\n`);
32
+ w(`${renderHeader("Replay")}\n`);
153
33
 
154
34
  if (events.length === 0) {
155
35
  w(`${color.dim("No events found.")}\n`);
@@ -176,10 +56,7 @@ function printReplay(events: StoredEvent[], useAbsoluteTime: boolean): void {
176
56
  ? formatAbsoluteTime(event.createdAt)
177
57
  : formatRelativeTime(event.createdAt);
178
58
 
179
- const eventInfo = EVENT_LABELS[event.eventType] ?? {
180
- label: event.eventType.padEnd(10),
181
- color: color.gray,
182
- };
59
+ const label = eventLabel(event.eventType);
183
60
 
184
61
  const levelColorFn =
185
62
  event.level === "error" ? color.red : event.level === "warn" ? color.yellow : null;
@@ -193,7 +70,7 @@ function printReplay(events: StoredEvent[], useAbsoluteTime: boolean): void {
193
70
 
194
71
  w(
195
72
  `${color.dim(timeStr.padStart(10))} ` +
196
- `${applyLevel(eventInfo.color(color.bold(eventInfo.label)))}` +
73
+ `${applyLevel(label.color(color.bold(label.full)))}` +
197
74
  `${agentLabel}${detailSuffix}\n`,
198
75
  );
199
76
  }
@@ -79,6 +79,7 @@ function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
79
79
  lastActivity: "2026-02-13T10:30:00.000Z",
80
80
  escalationLevel: 0,
81
81
  stalledSince: null,
82
+ transcriptPath: null,
82
83
  ...overrides,
83
84
  };
84
85
  }
@@ -17,24 +17,11 @@ import { loadConfig } from "../config.ts";
17
17
  import { ValidationError } from "../errors.ts";
18
18
  import { jsonError, jsonOutput } from "../json.ts";
19
19
  import { accent, printError, printHint, printSuccess } from "../logging/color.ts";
20
+ import { formatDuration } from "../logging/format.ts";
21
+ import { renderHeader, separator } from "../logging/theme.ts";
20
22
  import { createRunStore, createSessionStore } from "../sessions/store.ts";
21
23
  import type { AgentSession, Run } from "../types.ts";
22
24
 
23
- /**
24
- * Format milliseconds as human-readable duration.
25
- */
26
- function formatDuration(ms: number): string {
27
- if (ms === 0) return "0s";
28
- const seconds = Math.floor(ms / 1000);
29
- if (seconds < 60) return `${seconds}s`;
30
- const minutes = Math.floor(seconds / 60);
31
- const remainSec = seconds % 60;
32
- if (minutes < 60) return `${minutes}m ${remainSec}s`;
33
- const hours = Math.floor(minutes / 60);
34
- const remainMin = minutes % 60;
35
- return `${hours}h ${remainMin}m`;
36
- }
37
-
38
25
  /**
39
26
  * Get the path to the current-run.txt file.
40
27
  */
@@ -109,8 +96,7 @@ async function showCurrentRun(overstoryDir: string, json: boolean): Promise<void
109
96
  return;
110
97
  }
111
98
 
112
- process.stdout.write("Current Run\n");
113
- process.stdout.write(`${"=".repeat(50)}\n`);
99
+ process.stdout.write(`${renderHeader("Current Run")}\n`);
114
100
  process.stdout.write(` ID: ${accent(run.id)}\n`);
115
101
  process.stdout.write(` Status: ${run.status}\n`);
116
102
  process.stdout.write(` Started: ${run.startedAt}\n`);
@@ -151,12 +137,11 @@ async function listRuns(overstoryDir: string, limit: number, json: boolean): Pro
151
137
  return;
152
138
  }
153
139
 
154
- process.stdout.write("Recent Runs\n");
155
- process.stdout.write(`${"=".repeat(70)}\n`);
140
+ process.stdout.write(`${renderHeader("Recent Runs")}\n`);
156
141
  process.stdout.write(
157
142
  `${"ID".padEnd(36)} ${"Status".padEnd(10)} ${"Agents".padEnd(7)} Duration\n`,
158
143
  );
159
- process.stdout.write(`${"-".repeat(70)}\n`);
144
+ process.stdout.write(`${separator()}\n`);
160
145
 
161
146
  for (const run of runs) {
162
147
  const id = accent(run.id.length > 35 ? `${run.id.slice(0, 32)}...` : run.id.padEnd(36));
@@ -245,8 +230,7 @@ async function showRun(overstoryDir: string, runId: string, json: boolean): Prom
245
230
  return;
246
231
  }
247
232
 
248
- process.stdout.write("Run Details\n");
249
- process.stdout.write(`${"=".repeat(60)}\n`);
233
+ process.stdout.write(`${renderHeader("Run Details")}\n`);
250
234
  process.stdout.write(` ID: ${accent(run.id)}\n`);
251
235
  process.stdout.write(` Status: ${run.status}\n`);
252
236
  process.stdout.write(` Started: ${run.startedAt}\n`);
@@ -258,7 +242,7 @@ async function showRun(overstoryDir: string, runId: string, json: boolean): Prom
258
242
 
259
243
  if (agents.length > 0) {
260
244
  process.stdout.write(`\nAgents (${agents.length}):\n`);
261
- process.stdout.write(`${"-".repeat(60)}\n`);
245
+ process.stdout.write(`${separator()}\n`);
262
246
  for (const agent of agents) {
263
247
  const agentDuration = formatAgentDuration(agent);
264
248
  process.stdout.write(
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { resolveModel, resolveProviderEnv } from "../agents/manifest.ts";
3
3
  import { HierarchyError } from "../errors.ts";
4
+ import { ClaudeRuntime } from "../runtimes/claude.ts";
5
+ import { getRuntime } from "../runtimes/registry.ts";
4
6
  import type { AgentManifest, OverstoryConfig } from "../types.ts";
5
7
  import {
6
8
  type AutoDispatchOptions,
@@ -367,6 +369,7 @@ function makeBeaconOpts(overrides?: Partial<BeaconOptions>): BeaconOptions {
367
369
  taskId: "overstory-abc",
368
370
  parentAgent: null,
369
371
  depth: 0,
372
+ instructionPath: ".claude/CLAUDE.md",
370
373
  ...overrides,
371
374
  };
372
375
  }
@@ -407,12 +410,20 @@ describe("buildBeacon", () => {
407
410
  const opts = makeBeaconOpts({ agentName: "scout-1", taskId: "overstory-xyz" });
408
411
  const beacon = buildBeacon(opts);
409
412
 
410
- expect(beacon).toContain("read .claude/CLAUDE.md");
413
+ expect(beacon).toContain(`read ${opts.instructionPath}`);
411
414
  expect(beacon).toContain("mulch prime");
412
415
  expect(beacon).toContain("ov mail check --agent scout-1");
413
416
  expect(beacon).toContain("begin task overstory-xyz");
414
417
  });
415
418
 
419
+ test("uses custom instructionPath in startup instructions", () => {
420
+ const opts = makeBeaconOpts({ instructionPath: "AGENTS.md" });
421
+ const beacon = buildBeacon(opts);
422
+
423
+ expect(beacon).toContain("read AGENTS.md");
424
+ expect(beacon).not.toContain(".claude/CLAUDE.md");
425
+ });
426
+
416
427
  test("uses agent name in mail check command", () => {
417
428
  const beacon = buildBeacon(makeBeaconOpts({ agentName: "reviewer-beta" }));
418
429
 
@@ -999,6 +1010,7 @@ function makeAutoDispatchOpts(overrides?: Partial<AutoDispatchOptions>): AutoDis
999
1010
  capability: "builder",
1000
1011
  specPath: "/path/to/spec.md",
1001
1012
  parentAgent: "lead-alpha",
1013
+ instructionPath: ".claude/CLAUDE.md",
1002
1014
  ...overrides,
1003
1015
  };
1004
1016
  }
@@ -1011,6 +1023,7 @@ describe("buildAutoDispatch", () => {
1011
1023
  capability: "builder",
1012
1024
  specPath: "/path/to/spec.md",
1013
1025
  parentAgent: "lead-alpha",
1026
+ instructionPath: ".claude/CLAUDE.md",
1014
1027
  });
1015
1028
  expect(dispatch.from).toBe("lead-alpha");
1016
1029
  expect(dispatch.to).toBe("builder-1");
@@ -1025,6 +1038,7 @@ describe("buildAutoDispatch", () => {
1025
1038
  capability: "lead",
1026
1039
  specPath: null,
1027
1040
  parentAgent: null,
1041
+ instructionPath: ".claude/CLAUDE.md",
1028
1042
  });
1029
1043
  expect(dispatch.from).toBe("orchestrator");
1030
1044
  expect(dispatch.body).toContain("No spec file");
@@ -1037,6 +1051,7 @@ describe("buildAutoDispatch", () => {
1037
1051
  capability: "scout",
1038
1052
  specPath: null,
1039
1053
  parentAgent: "lead-alpha",
1054
+ instructionPath: ".claude/CLAUDE.md",
1040
1055
  });
1041
1056
  expect(dispatch.body).toContain("scout");
1042
1057
  });
@@ -1048,6 +1063,7 @@ describe("buildAutoDispatch", () => {
1048
1063
  capability: "builder",
1049
1064
  specPath: "/abs/path/to/spec.md",
1050
1065
  parentAgent: "lead-alpha",
1066
+ instructionPath: ".claude/CLAUDE.md",
1051
1067
  });
1052
1068
  expect(dispatch.body).toContain("/abs/path/to/spec.md");
1053
1069
  });
@@ -1080,3 +1096,54 @@ describe("buildAutoDispatch", () => {
1080
1096
  * Integration coverage: The beacon loop has been validated through production
1081
1097
  * agent spawns. Failure mode is agents stuck at welcome screen (overstory-3271).
1082
1098
  */
1099
+
1100
+ describe("sling runtime integration", () => {
1101
+ test("runtime.buildSpawnCommand produces identical command to old hardcoded string", () => {
1102
+ const runtime = getRuntime("claude");
1103
+ const cmd = runtime.buildSpawnCommand({
1104
+ model: "sonnet",
1105
+ permissionMode: "bypass",
1106
+ cwd: "/tmp/worktree",
1107
+ env: {},
1108
+ });
1109
+ expect(cmd).toBe("claude --model sonnet --permission-mode bypassPermissions");
1110
+ });
1111
+
1112
+ test("runtime.buildSpawnCommand with opus model", () => {
1113
+ const runtime = getRuntime("claude");
1114
+ const cmd = runtime.buildSpawnCommand({
1115
+ model: "opus",
1116
+ permissionMode: "bypass",
1117
+ cwd: "/tmp/worktree",
1118
+ env: {},
1119
+ });
1120
+ expect(cmd).toBe("claude --model opus --permission-mode bypassPermissions");
1121
+ });
1122
+
1123
+ test("runtime.buildEnv returns empty object for native model", () => {
1124
+ const runtime = new ClaudeRuntime();
1125
+ const env = runtime.buildEnv({ model: "sonnet" });
1126
+ expect(env).toEqual({});
1127
+ });
1128
+
1129
+ test("runtime.buildEnv passes through provider env vars", () => {
1130
+ const runtime = new ClaudeRuntime();
1131
+ const env = runtime.buildEnv({
1132
+ model: "sonnet",
1133
+ env: { ANTHROPIC_BASE_URL: "https://example.com" },
1134
+ });
1135
+ expect(env).toEqual({ ANTHROPIC_BASE_URL: "https://example.com" });
1136
+ });
1137
+
1138
+ test("runtime.detectReady returns ready for idle Claude prompt", () => {
1139
+ const runtime = new ClaudeRuntime();
1140
+ const state = runtime.detectReady('Try "hello world"\n\nbypass permissions');
1141
+ expect(state.phase).toBe("ready");
1142
+ });
1143
+
1144
+ test("runtime.detectReady returns loading when agent is processing", () => {
1145
+ const runtime = new ClaudeRuntime();
1146
+ const state = runtime.detectReady("Running tool: Read\nbypass permissions");
1147
+ expect(state.phase).toBe("loading");
1148
+ });
1149
+ });