@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
package/src/config.ts CHANGED
@@ -62,6 +62,17 @@ export const DEFAULT_CONFIG: OverstoryConfig = {
62
62
  verbose: false,
63
63
  redactSecrets: true,
64
64
  },
65
+ runtime: {
66
+ default: "claude",
67
+ pi: {
68
+ provider: "anthropic",
69
+ modelMap: {
70
+ opus: "anthropic/claude-opus-4-6",
71
+ sonnet: "anthropic/claude-sonnet-4-6",
72
+ haiku: "anthropic/claude-haiku-4-5",
73
+ },
74
+ },
75
+ },
65
76
  };
66
77
 
67
78
  const CONFIG_FILENAME = "config.yaml";
@@ -625,6 +636,34 @@ function validateConfig(config: OverstoryConfig): void {
625
636
  }
626
637
  }
627
638
 
639
+ // runtime.default must be a string if present
640
+ if (config.runtime !== undefined && typeof config.runtime.default !== "string") {
641
+ process.stderr.write(
642
+ `[overstory] WARNING: runtime.default must be a string. Got: ${typeof config.runtime.default}. Ignoring.\n`,
643
+ );
644
+ }
645
+
646
+ // runtime.pi: validate provider and modelMap if present
647
+ if (config.runtime?.pi) {
648
+ const pi = config.runtime.pi;
649
+ if (!pi.provider || typeof pi.provider !== "string") {
650
+ throw new ValidationError("runtime.pi.provider must be a non-empty string", {
651
+ field: "runtime.pi.provider",
652
+ value: pi.provider,
653
+ });
654
+ }
655
+ if (pi.modelMap && typeof pi.modelMap === "object") {
656
+ for (const [alias, qualified] of Object.entries(pi.modelMap)) {
657
+ if (!qualified || typeof qualified !== "string") {
658
+ throw new ValidationError(`runtime.pi.modelMap.${alias} must be a non-empty string`, {
659
+ field: `runtime.pi.modelMap.${alias}`,
660
+ value: qualified,
661
+ });
662
+ }
663
+ }
664
+ }
665
+ }
666
+
628
667
  // models: validate each value — accepts aliases and provider-prefixed refs
629
668
  const validAliases = ["sonnet", "opus", "haiku"];
630
669
  const toolHeavyRoles = ["builder", "scout"];
@@ -207,6 +207,7 @@ describe("checkConsistency", () => {
207
207
  lastActivity: new Date().toISOString(),
208
208
  escalationLevel: 0,
209
209
  stalledSince: null,
210
+ transcriptPath: null,
210
211
  });
211
212
  store.close();
212
213
 
@@ -243,6 +244,7 @@ describe("checkConsistency", () => {
243
244
  lastActivity: new Date().toISOString(),
244
245
  escalationLevel: 0,
245
246
  stalledSince: null,
247
+ transcriptPath: null,
246
248
  });
247
249
  store.close();
248
250
 
@@ -278,6 +280,7 @@ describe("checkConsistency", () => {
278
280
  lastActivity: new Date().toISOString(),
279
281
  escalationLevel: 0,
280
282
  stalledSince: null,
283
+ transcriptPath: null,
281
284
  });
282
285
  store.close();
283
286
 
@@ -314,6 +317,7 @@ describe("checkConsistency", () => {
314
317
  lastActivity: new Date().toISOString(),
315
318
  escalationLevel: 0,
316
319
  stalledSince: null,
320
+ transcriptPath: null,
317
321
  });
318
322
  store.close();
319
323
 
@@ -353,6 +357,7 @@ describe("checkConsistency", () => {
353
357
  lastActivity: new Date().toISOString(),
354
358
  escalationLevel: 0,
355
359
  stalledSince: null,
360
+ transcriptPath: null,
356
361
  });
357
362
  store.close();
358
363
 
@@ -426,6 +431,7 @@ describe("checkConsistency", () => {
426
431
  lastActivity: new Date().toISOString(),
427
432
  escalationLevel: 0,
428
433
  stalledSince: null,
434
+ transcriptPath: null,
429
435
  });
430
436
 
431
437
  store.upsert({
@@ -445,6 +451,7 @@ describe("checkConsistency", () => {
445
451
  lastActivity: new Date().toISOString(),
446
452
  escalationLevel: 0,
447
453
  stalledSince: null,
454
+ transcriptPath: null,
448
455
  });
449
456
  store.close();
450
457
 
@@ -481,6 +488,7 @@ describe("checkConsistency", () => {
481
488
  lastActivity: new Date().toISOString(),
482
489
  escalationLevel: 0,
483
490
  stalledSince: null,
491
+ transcriptPath: null,
484
492
  });
485
493
  }
486
494
 
@@ -501,6 +509,7 @@ describe("checkConsistency", () => {
501
509
  lastActivity: new Date().toISOString(),
502
510
  escalationLevel: 0,
503
511
  stalledSince: null,
512
+ transcriptPath: null,
504
513
  });
505
514
  store.close();
506
515
 
@@ -535,6 +544,7 @@ describe("checkConsistency", () => {
535
544
  lastActivity: new Date().toISOString(),
536
545
  escalationLevel: 0,
537
546
  stalledSince: null,
547
+ transcriptPath: null,
538
548
  });
539
549
 
540
550
  store.upsert({
@@ -554,6 +564,7 @@ describe("checkConsistency", () => {
554
564
  lastActivity: new Date().toISOString(),
555
565
  escalationLevel: 0,
556
566
  stalledSince: null,
567
+ transcriptPath: null,
557
568
  });
558
569
  }
559
570
  store.close();
@@ -597,6 +608,7 @@ describe("checkConsistency", () => {
597
608
  lastActivity: new Date().toISOString(),
598
609
  escalationLevel: 0,
599
610
  stalledSince: null,
611
+ transcriptPath: null,
600
612
  });
601
613
 
602
614
  store.upsert({
@@ -616,6 +628,7 @@ describe("checkConsistency", () => {
616
628
  lastActivity: new Date().toISOString(),
617
629
  escalationLevel: 0,
618
630
  stalledSince: null,
631
+ transcriptPath: null,
619
632
  });
620
633
 
621
634
  // Lead-2 has builders only (bad)
@@ -636,6 +649,7 @@ describe("checkConsistency", () => {
636
649
  lastActivity: new Date().toISOString(),
637
650
  escalationLevel: 0,
638
651
  stalledSince: null,
652
+ transcriptPath: null,
639
653
  });
640
654
  store.close();
641
655
 
@@ -27,7 +27,6 @@ const EXPECTED_AGENT_DEFS = [
27
27
  "monitor.md",
28
28
  "reviewer.md",
29
29
  "scout.md",
30
- "supervisor.md",
31
30
  ];
32
31
 
33
32
  describe("E2E: init→sling lifecycle on external project", () => {
@@ -77,7 +76,7 @@ describe("E2E: init→sling lifecycle on external project", () => {
77
76
  const gitignoreFile = Bun.file(join(overstoryDir, ".gitignore"));
78
77
  expect(await gitignoreFile.exists()).toBe(true);
79
78
 
80
- // agent-defs/ contains all 8 agent definition files
79
+ // agent-defs/ contains all 7 agent definition files (supervisor deprecated)
81
80
  const agentDefsDir = join(overstoryDir, "agent-defs");
82
81
  const agentDefFiles = (await readdir(agentDefsDir)).filter((f) => f.endsWith(".md")).sort();
83
82
  expect(agentDefFiles).toEqual(EXPECTED_AGENT_DEFS);
@@ -109,7 +108,7 @@ describe("E2E: init→sling lifecycle on external project", () => {
109
108
  expect(config.project.name).toBeTruthy();
110
109
  });
111
110
 
112
- test("manifest loads successfully with all 8 agents", async () => {
111
+ test("manifest loads successfully with all 7 agents (supervisor deprecated)", async () => {
113
112
  await initCommand({});
114
113
 
115
114
  const manifestPath = join(tempDir, ".overstory", "agent-manifest.json");
@@ -118,7 +117,7 @@ describe("E2E: init→sling lifecycle on external project", () => {
118
117
 
119
118
  const manifest = await loader.load();
120
119
 
121
- // All 8 agents present
120
+ // All 7 agents present (supervisor removed: deprecated, use lead instead)
122
121
  const agentNames = Object.keys(manifest.agents).sort();
123
122
  expect(agentNames).toEqual([
124
123
  "builder",
@@ -128,7 +127,6 @@ describe("E2E: init→sling lifecycle on external project", () => {
128
127
  "monitor",
129
128
  "reviewer",
130
129
  "scout",
131
- "supervisor",
132
130
  ]);
133
131
 
134
132
  // Each agent has a valid file reference
package/src/index.ts CHANGED
@@ -45,7 +45,7 @@ import { OverstoryError, WorktreeError } from "./errors.ts";
45
45
  import { jsonError } from "./json.ts";
46
46
  import { brand, chalk, muted, setQuiet } from "./logging/color.ts";
47
47
 
48
- export const VERSION = "0.6.11";
48
+ export const VERSION = "0.7.2";
49
49
 
50
50
  const rawArgs = process.argv.slice(2);
51
51
 
@@ -255,6 +255,7 @@ program
255
255
  .option("--max-agents <n>", "Max children per lead (overrides config)")
256
256
  .option("--skip-review", "Skip review phase for lead agents")
257
257
  .option("--dispatch-max-agents <n>", "Per-lead max agents ceiling (injected into overlay)")
258
+ .option("--runtime <name>", "Runtime adapter (default: config or claude)")
258
259
  .option("--json", "Output result as JSON")
259
260
  .action(async (taskId, opts) => {
260
261
  await slingCommand(taskId, opts);
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Shared formatting utilities for overstory CLI output.
3
+ *
4
+ * Duration, timestamp, event detail, agent color mapping, and status color
5
+ * helpers used across all observability commands.
6
+ */
7
+
8
+ import type { StoredEvent } from "../types.ts";
9
+ import type { ColorFn } from "./color.ts";
10
+ import { color, noColor } from "./color.ts";
11
+ import { AGENT_COLORS } from "./theme.ts";
12
+
13
+ // === Duration ===
14
+
15
+ /**
16
+ * Formats a duration in milliseconds to a human-readable string.
17
+ * Examples: "0s", "12s", "3m 45s", "2h 15m"
18
+ */
19
+ export function formatDuration(ms: number): string {
20
+ if (ms === 0) return "0s";
21
+ const totalSeconds = Math.floor(ms / 1000);
22
+ const hours = Math.floor(totalSeconds / 3600);
23
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
24
+ const seconds = totalSeconds % 60;
25
+ if (hours > 0) {
26
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
27
+ }
28
+ if (minutes > 0) {
29
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
30
+ }
31
+ return `${seconds}s`;
32
+ }
33
+
34
+ // === Timestamps ===
35
+
36
+ /**
37
+ * Extracts "HH:MM:SS" from an ISO 8601 timestamp string.
38
+ * Returns the raw substring if the timestamp is well-formed.
39
+ */
40
+ export function formatAbsoluteTime(timestamp: string): string {
41
+ // ISO format: "YYYY-MM-DDTHH:MM:SS..." or "YYYY-MM-DD HH:MM:SS..."
42
+ const match = timestamp.match(/T?(\d{2}:\d{2}:\d{2})/);
43
+ return match?.[1] ?? timestamp;
44
+ }
45
+
46
+ /**
47
+ * Extracts "YYYY-MM-DD" from an ISO 8601 timestamp string.
48
+ */
49
+ export function formatDate(timestamp: string): string {
50
+ const match = timestamp.match(/^(\d{4}-\d{2}-\d{2})/);
51
+ return match?.[1] ?? timestamp;
52
+ }
53
+
54
+ /**
55
+ * Formats a timestamp as a human-readable relative time string.
56
+ * Examples: "12s ago", "3m ago", "2h ago", "5d ago"
57
+ */
58
+ export function formatRelativeTime(timestamp: string): string {
59
+ const now = Date.now();
60
+ const then = new Date(timestamp).getTime();
61
+ const diffMs = now - then;
62
+ if (diffMs < 0) return "just now";
63
+ const diffSeconds = Math.floor(diffMs / 1000);
64
+ const diffMinutes = Math.floor(diffSeconds / 60);
65
+ const diffHours = Math.floor(diffMinutes / 60);
66
+ const diffDays = Math.floor(diffHours / 24);
67
+ if (diffDays > 0) return `${diffDays}d ago`;
68
+ if (diffHours > 0) return `${diffHours}h ago`;
69
+ if (diffMinutes > 0) return `${diffMinutes}m ago`;
70
+ return `${diffSeconds}s ago`;
71
+ }
72
+
73
+ // === Event Details ===
74
+
75
+ /**
76
+ * Builds a compact "key=value" detail string from a StoredEvent's fields.
77
+ * Values are truncated to maxValueLen (default 80) characters.
78
+ */
79
+ export function buildEventDetail(event: StoredEvent, maxValueLen = 80): string {
80
+ const parts: string[] = [];
81
+
82
+ if (event.toolName) {
83
+ parts.push(`tool=${event.toolName}`);
84
+ }
85
+ if (event.toolArgs) {
86
+ const truncated =
87
+ event.toolArgs.length > maxValueLen
88
+ ? `${event.toolArgs.slice(0, maxValueLen)}…`
89
+ : event.toolArgs;
90
+ parts.push(`args=${truncated}`);
91
+ }
92
+ if (event.toolDurationMs !== null && event.toolDurationMs !== undefined) {
93
+ parts.push(`dur=${event.toolDurationMs}ms`);
94
+ }
95
+ if (event.data) {
96
+ const truncated =
97
+ event.data.length > maxValueLen ? `${event.data.slice(0, maxValueLen)}…` : event.data;
98
+ parts.push(`data=${truncated}`);
99
+ }
100
+
101
+ return parts.join(" ");
102
+ }
103
+
104
+ // === Agent Color Mapping ===
105
+
106
+ /**
107
+ * Builds a stable color map for agents by first-appearance order in events.
108
+ * Agents are assigned colors from AGENT_COLORS cycling as needed.
109
+ */
110
+ export function buildAgentColorMap(events: StoredEvent[]): Map<string, ColorFn> {
111
+ const colorMap = new Map<string, ColorFn>();
112
+ let idx = 0;
113
+ for (const event of events) {
114
+ if (!colorMap.has(event.agentName)) {
115
+ const colorFn = AGENT_COLORS[idx % AGENT_COLORS.length] ?? noColor;
116
+ colorMap.set(event.agentName, colorFn);
117
+ idx++;
118
+ }
119
+ }
120
+ return colorMap;
121
+ }
122
+
123
+ /**
124
+ * Extends an existing agent color map with new agents from the given events.
125
+ * Used in follow mode to add agents discovered in incremental event batches.
126
+ */
127
+ export function extendAgentColorMap(colorMap: Map<string, ColorFn>, events: StoredEvent[]): void {
128
+ let idx = colorMap.size;
129
+ for (const event of events) {
130
+ if (!colorMap.has(event.agentName)) {
131
+ const colorFn = AGENT_COLORS[idx % AGENT_COLORS.length] ?? noColor;
132
+ colorMap.set(event.agentName, colorFn);
133
+ idx++;
134
+ }
135
+ }
136
+ }
137
+
138
+ // === Status Colors ===
139
+
140
+ /**
141
+ * Returns a color function for a merge status string.
142
+ * pending=yellow, merging=blue, conflict=red, merged=green
143
+ */
144
+ export function mergeStatusColor(status: string): ColorFn {
145
+ switch (status) {
146
+ case "pending":
147
+ return color.yellow;
148
+ case "merging":
149
+ return color.blue;
150
+ case "conflict":
151
+ return color.red;
152
+ case "merged":
153
+ return color.green;
154
+ default:
155
+ return (text: string) => text;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Returns a color function for a priority string.
161
+ * urgent=red, high=yellow, normal=identity, low=dim
162
+ */
163
+ export function priorityColor(priority: string): ColorFn {
164
+ switch (priority) {
165
+ case "urgent":
166
+ return color.red;
167
+ case "high":
168
+ return color.yellow;
169
+ case "normal":
170
+ return (text: string) => text;
171
+ case "low":
172
+ return color.dim;
173
+ default:
174
+ return (text: string) => text;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Returns a color function for a log level string.
180
+ * debug=gray, info=blue, warn=yellow, error=red
181
+ */
182
+ export function logLevelColor(level: string): ColorFn {
183
+ switch (level) {
184
+ case "debug":
185
+ return color.gray;
186
+ case "info":
187
+ return color.blue;
188
+ case "warn":
189
+ return color.yellow;
190
+ case "error":
191
+ return color.red;
192
+ default:
193
+ return (text: string) => text;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Returns a 3-character label for a log level string.
199
+ * debug="DBG", info="INF", warn="WRN", error="ERR"
200
+ */
201
+ export function logLevelLabel(level: string): string {
202
+ switch (level) {
203
+ case "debug":
204
+ return "DBG";
205
+ case "info":
206
+ return "INF";
207
+ case "warn":
208
+ return "WRN";
209
+ case "error":
210
+ return "ERR";
211
+ default:
212
+ return level.slice(0, 3).toUpperCase();
213
+ }
214
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Canonical visual theme for overstory CLI output.
3
+ *
4
+ * Single source of truth for state colors, event labels, agent palette,
5
+ * separators, and header rendering. All observability commands import from here.
6
+ */
7
+
8
+ import type { AgentState, EventType } from "../types.ts";
9
+ import type { ColorFn } from "./color.ts";
10
+ import { brand, color, noColor, visibleLength } from "./color.ts";
11
+
12
+ // === Agent State Theme ===
13
+
14
+ /** Maps agent states to their visual color functions. */
15
+ const STATE_COLORS: Record<AgentState, ColorFn> = {
16
+ working: color.green,
17
+ booting: color.yellow,
18
+ stalled: color.red,
19
+ zombie: color.dim,
20
+ completed: color.cyan,
21
+ };
22
+
23
+ /** Maps agent states to their icon characters. */
24
+ const STATE_ICONS: Record<AgentState, string> = {
25
+ working: ">",
26
+ booting: "~",
27
+ stalled: "!",
28
+ zombie: "x",
29
+ completed: "\u2713",
30
+ };
31
+
32
+ /** Returns the color function for a given agent state. Falls back to noColor. */
33
+ export function stateColor(state: string): ColorFn {
34
+ return STATE_COLORS[state as AgentState] ?? noColor;
35
+ }
36
+
37
+ /** Returns the raw icon character for a given agent state. Falls back to "?". */
38
+ export function stateIcon(state: string): string {
39
+ return STATE_ICONS[state as AgentState] ?? "?";
40
+ }
41
+
42
+ /** Returns a colored icon string for a given agent state. */
43
+ export function stateIconColored(state: string): string {
44
+ return stateColor(state)(stateIcon(state));
45
+ }
46
+
47
+ // === Event Label Theme ===
48
+
49
+ export interface EventLabel {
50
+ /** 5-character compact label (for feed). */
51
+ compact: string;
52
+ /** 10-character full label (for trace/replay). */
53
+ full: string;
54
+ /** Color function for this event type. */
55
+ color: ColorFn;
56
+ }
57
+
58
+ /** Maps event types to their compact (5-char) and full (10-char) labels, plus color. */
59
+ const EVENT_LABELS: Record<EventType, EventLabel> = {
60
+ tool_start: { compact: "TOOL+", full: "TOOL START", color: color.blue },
61
+ tool_end: { compact: "TOOL-", full: "TOOL END ", color: color.blue },
62
+ session_start: { compact: "SESS+", full: "SESSION +", color: color.green },
63
+ session_end: { compact: "SESS-", full: "SESSION -", color: color.yellow },
64
+ mail_sent: { compact: "MAIL>", full: "MAIL SENT ", color: color.cyan },
65
+ mail_received: { compact: "MAIL<", full: "MAIL RECV ", color: color.cyan },
66
+ spawn: { compact: "SPAWN", full: "SPAWN ", color: color.magenta },
67
+ error: { compact: "ERROR", full: "ERROR ", color: color.red },
68
+ custom: { compact: "CUSTM", full: "CUSTOM ", color: color.gray },
69
+ };
70
+
71
+ /** Returns the EventLabel for a given event type. */
72
+ export function eventLabel(eventType: EventType): EventLabel {
73
+ return EVENT_LABELS[eventType];
74
+ }
75
+
76
+ // === Agent Colors (for multi-agent displays) ===
77
+
78
+ /** Stable palette for assigning distinct colors to agents in multi-agent displays. */
79
+ export const AGENT_COLORS: readonly ColorFn[] = [
80
+ color.blue,
81
+ color.green,
82
+ color.yellow,
83
+ color.cyan,
84
+ color.magenta,
85
+ ] as const;
86
+
87
+ // === Separators ===
88
+
89
+ /** Unicode thin horizontal box-drawing character. */
90
+ export const SEPARATOR_CHAR = "\u2500";
91
+
92
+ /** Unicode double horizontal box-drawing character (thick). */
93
+ export const THICK_SEPARATOR_CHAR = "\u2550";
94
+
95
+ /** Default line width for separators and headers. */
96
+ export const DEFAULT_WIDTH = 70;
97
+
98
+ /** Returns a thin separator line of the given width (default 70). */
99
+ export function separator(width?: number): string {
100
+ return SEPARATOR_CHAR.repeat(width ?? DEFAULT_WIDTH);
101
+ }
102
+
103
+ /** Returns a thick (double-line) separator of the given width (default 70). */
104
+ export function thickSeparator(width?: number): string {
105
+ return THICK_SEPARATOR_CHAR.repeat(width ?? DEFAULT_WIDTH);
106
+ }
107
+
108
+ // === Header Rendering ===
109
+
110
+ /**
111
+ * Pads a string to the given visible width, accounting for ANSI escape codes.
112
+ * If the string is already wider than width, returns it unchanged.
113
+ */
114
+ export function padVisible(str: string, width: number): string {
115
+ const visible = visibleLength(str);
116
+ if (visible >= width) return str;
117
+ return str + " ".repeat(width - visible);
118
+ }
119
+
120
+ /**
121
+ * Renders a primary header: brand bold title + newline + thin separator.
122
+ */
123
+ export function renderHeader(title: string, width?: number): string {
124
+ return `${brand.bold(title)}\n${separator(width)}`;
125
+ }
126
+
127
+ /**
128
+ * Renders a secondary header: color bold title + newline + dim thin separator.
129
+ */
130
+ export function renderSubHeader(title: string, width?: number): string {
131
+ return `${color.bold(title)}\n${color.dim(separator(width))}`;
132
+ }
@@ -41,6 +41,7 @@ describe("resolveGroupAddress", () => {
41
41
  lastActivity: "2024-01-01T00:01:00Z",
42
42
  escalationLevel: 0,
43
43
  stalledSince: null,
44
+ transcriptPath: null,
44
45
  };
45
46
  }
46
47
 
@@ -13,10 +13,12 @@
13
13
 
14
14
  import { MergeError } from "../errors.ts";
15
15
  import type { MulchClient } from "../mulch/client.ts";
16
+ import { getRuntime } from "../runtimes/registry.ts";
16
17
  import type {
17
18
  ConflictHistory,
18
19
  MergeEntry,
19
20
  MergeResult,
21
+ OverstoryConfig,
20
22
  ParsedConflictPattern,
21
23
  ResolutionTier,
22
24
  } from "../types.ts";
@@ -243,6 +245,7 @@ async function tryAiResolve(
243
245
  conflictFiles: string[],
244
246
  repoRoot: string,
245
247
  pastResolutions?: string[],
248
+ config?: OverstoryConfig,
246
249
  ): Promise<{ success: boolean; remainingConflicts: string[] }> {
247
250
  const remainingConflicts: string[] = [];
248
251
 
@@ -265,7 +268,9 @@ async function tryAiResolve(
265
268
  content,
266
269
  ].join(" ");
267
270
 
268
- const proc = Bun.spawn(["claude", "--print", "-p", prompt], {
271
+ const runtime = getRuntime(config?.runtime?.printCommand ?? config?.runtime?.default, config);
272
+ const argv = runtime.buildPrintCommand(prompt);
273
+ const proc = Bun.spawn(argv, {
269
274
  cwd: repoRoot,
270
275
  stdout: "pipe",
271
276
  stderr: "pipe",
@@ -315,6 +320,7 @@ async function tryReimagine(
315
320
  entry: MergeEntry,
316
321
  canonicalBranch: string,
317
322
  repoRoot: string,
323
+ config?: OverstoryConfig,
318
324
  ): Promise<{ success: boolean }> {
319
325
  // Abort the current merge
320
326
  await runGit(repoRoot, ["merge", "--abort"]);
@@ -348,7 +354,9 @@ async function tryReimagine(
348
354
  branchContent,
349
355
  ].join("");
350
356
 
351
- const proc = Bun.spawn(["claude", "--print", "-p", prompt], {
357
+ const runtime = getRuntime(config?.runtime?.printCommand ?? config?.runtime?.default, config);
358
+ const argv = runtime.buildPrintCommand(prompt);
359
+ const proc = Bun.spawn(argv, {
352
360
  cwd: repoRoot,
353
361
  stdout: "pipe",
354
362
  stderr: "pipe",
@@ -556,6 +564,7 @@ export function createMergeResolver(options: {
556
564
  aiResolveEnabled: boolean;
557
565
  reimagineEnabled: boolean;
558
566
  mulchClient?: MulchClient;
567
+ config?: OverstoryConfig;
559
568
  }): MergeResolver {
560
569
  return {
561
570
  async resolve(
@@ -632,7 +641,12 @@ export function createMergeResolver(options: {
632
641
  // Tier 3: AI-resolve
633
642
  if (options.aiResolveEnabled && !history.skipTiers.includes("ai-resolve")) {
634
643
  lastTier = "ai-resolve";
635
- const aiResult = await tryAiResolve(conflictFiles, repoRoot, history.pastResolutions);
644
+ const aiResult = await tryAiResolve(
645
+ conflictFiles,
646
+ repoRoot,
647
+ history.pastResolutions,
648
+ options.config,
649
+ );
636
650
  if (aiResult.success) {
637
651
  if (options.mulchClient) {
638
652
  recordConflictPattern(options.mulchClient, entry, "ai-resolve", conflictFiles, true);
@@ -651,7 +665,12 @@ export function createMergeResolver(options: {
651
665
  // Tier 4: Re-imagine
652
666
  if (options.reimagineEnabled && !history.skipTiers.includes("reimagine")) {
653
667
  lastTier = "reimagine";
654
- const reimagineResult = await tryReimagine(entry, canonicalBranch, repoRoot);
668
+ const reimagineResult = await tryReimagine(
669
+ entry,
670
+ canonicalBranch,
671
+ repoRoot,
672
+ options.config,
673
+ );
655
674
  if (reimagineResult.success) {
656
675
  if (options.mulchClient) {
657
676
  recordConflictPattern(options.mulchClient, entry, "reimagine", conflictFiles, true);