@os-eco/overstory-cli 0.8.7 → 0.9.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 (98) hide show
  1. package/README.md +26 -8
  2. package/agents/coordinator.md +30 -6
  3. package/agents/lead.md +11 -1
  4. package/agents/ov-co-creation.md +90 -0
  5. package/package.json +1 -1
  6. package/src/agents/hooks-deployer.test.ts +9 -1
  7. package/src/agents/hooks-deployer.ts +2 -1
  8. package/src/agents/overlay.test.ts +26 -0
  9. package/src/agents/overlay.ts +31 -4
  10. package/src/canopy/client.test.ts +107 -0
  11. package/src/canopy/client.ts +179 -0
  12. package/src/commands/agents.ts +1 -1
  13. package/src/commands/clean.test.ts +3 -0
  14. package/src/commands/clean.ts +1 -58
  15. package/src/commands/completions.test.ts +18 -6
  16. package/src/commands/completions.ts +40 -1
  17. package/src/commands/coordinator.test.ts +77 -4
  18. package/src/commands/coordinator.ts +304 -146
  19. package/src/commands/dashboard.ts +47 -10
  20. package/src/commands/discover.test.ts +288 -0
  21. package/src/commands/discover.ts +202 -0
  22. package/src/commands/doctor.ts +3 -1
  23. package/src/commands/ecosystem.test.ts +126 -1
  24. package/src/commands/ecosystem.ts +7 -53
  25. package/src/commands/feed.test.ts +117 -2
  26. package/src/commands/feed.ts +46 -30
  27. package/src/commands/group.test.ts +274 -155
  28. package/src/commands/group.ts +11 -5
  29. package/src/commands/init.test.ts +2 -1
  30. package/src/commands/init.ts +8 -0
  31. package/src/commands/log.test.ts +35 -0
  32. package/src/commands/log.ts +10 -6
  33. package/src/commands/logs.test.ts +423 -1
  34. package/src/commands/logs.ts +99 -104
  35. package/src/commands/orchestrator.ts +42 -0
  36. package/src/commands/prime.test.ts +177 -2
  37. package/src/commands/prime.ts +4 -2
  38. package/src/commands/sling.ts +23 -3
  39. package/src/commands/update.test.ts +1 -0
  40. package/src/commands/upgrade.test.ts +2 -0
  41. package/src/commands/upgrade.ts +1 -17
  42. package/src/commands/watch.test.ts +67 -1
  43. package/src/commands/watch.ts +13 -88
  44. package/src/config.test.ts +250 -0
  45. package/src/config.ts +43 -0
  46. package/src/doctor/agents.test.ts +72 -5
  47. package/src/doctor/agents.ts +10 -10
  48. package/src/doctor/consistency.test.ts +35 -0
  49. package/src/doctor/consistency.ts +7 -3
  50. package/src/doctor/dependencies.test.ts +58 -1
  51. package/src/doctor/dependencies.ts +4 -2
  52. package/src/doctor/providers.test.ts +41 -5
  53. package/src/doctor/types.ts +2 -1
  54. package/src/doctor/version.test.ts +106 -2
  55. package/src/doctor/version.ts +4 -2
  56. package/src/doctor/watchdog.test.ts +167 -0
  57. package/src/doctor/watchdog.ts +158 -0
  58. package/src/e2e/init-sling-lifecycle.test.ts +4 -2
  59. package/src/errors.test.ts +350 -0
  60. package/src/events/tailer.test.ts +25 -0
  61. package/src/events/tailer.ts +8 -1
  62. package/src/index.ts +9 -1
  63. package/src/mail/store.test.ts +110 -0
  64. package/src/mail/store.ts +2 -1
  65. package/src/runtimes/aider.test.ts +124 -0
  66. package/src/runtimes/aider.ts +147 -0
  67. package/src/runtimes/amp.test.ts +164 -0
  68. package/src/runtimes/amp.ts +154 -0
  69. package/src/runtimes/claude.test.ts +4 -2
  70. package/src/runtimes/goose.test.ts +133 -0
  71. package/src/runtimes/goose.ts +157 -0
  72. package/src/runtimes/pi-guards.ts +2 -1
  73. package/src/runtimes/pi.test.ts +9 -9
  74. package/src/runtimes/pi.ts +6 -7
  75. package/src/runtimes/registry.test.ts +1 -1
  76. package/src/runtimes/registry.ts +13 -4
  77. package/src/runtimes/sapling.ts +2 -1
  78. package/src/runtimes/types.ts +2 -2
  79. package/src/schema-consistency.test.ts +1 -0
  80. package/src/sessions/store.ts +25 -4
  81. package/src/types.ts +65 -1
  82. package/src/utils/bin.test.ts +10 -0
  83. package/src/utils/bin.ts +37 -0
  84. package/src/utils/fs.test.ts +119 -0
  85. package/src/utils/fs.ts +62 -0
  86. package/src/utils/pid.test.ts +68 -0
  87. package/src/utils/pid.ts +45 -0
  88. package/src/utils/time.test.ts +43 -0
  89. package/src/utils/time.ts +37 -0
  90. package/src/utils/version.test.ts +33 -0
  91. package/src/utils/version.ts +70 -0
  92. package/src/watchdog/daemon.test.ts +255 -1
  93. package/src/watchdog/daemon.ts +87 -9
  94. package/src/watchdog/health.test.ts +15 -1
  95. package/src/watchdog/health.ts +1 -1
  96. package/src/watchdog/triage.test.ts +49 -9
  97. package/src/watchdog/triage.ts +21 -5
  98. package/templates/overlay.md.tmpl +2 -0
@@ -18,45 +18,13 @@ import { color } from "../logging/color.ts";
18
18
  import { formatAbsoluteTime, formatDate, logLevelColor, logLevelLabel } from "../logging/format.ts";
19
19
  import { renderHeader } from "../logging/theme.ts";
20
20
  import type { LogEvent } from "../types.ts";
21
-
22
- /**
23
- * Parse relative time formats like "1h", "30m", "2d", "10s" into a Date object.
24
- * Falls back to parsing as ISO 8601 if not in relative format.
25
- */
26
- function parseRelativeTime(timeStr: string): Date {
27
- const relativeMatch = /^(\d+)(s|m|h|d)$/.exec(timeStr);
28
- if (relativeMatch) {
29
- const value = Number.parseInt(relativeMatch[1] ?? "0", 10);
30
- const unit = relativeMatch[2];
31
- const now = Date.now();
32
- let offsetMs = 0;
33
-
34
- switch (unit) {
35
- case "s":
36
- offsetMs = value * 1000;
37
- break;
38
- case "m":
39
- offsetMs = value * 60 * 1000;
40
- break;
41
- case "h":
42
- offsetMs = value * 60 * 60 * 1000;
43
- break;
44
- case "d":
45
- offsetMs = value * 24 * 60 * 60 * 1000;
46
- break;
47
- }
48
-
49
- return new Date(now - offsetMs);
50
- }
51
-
52
- // Not a relative format, treat as ISO 8601
53
- return new Date(timeStr);
54
- }
21
+ import { parseRelativeTime } from "../utils/time.ts";
55
22
 
56
23
  /**
57
24
  * Build a detail string for a log event based on its data.
25
+ * @internal Exported for testing.
58
26
  */
59
- function buildLogDetail(event: LogEvent): string {
27
+ export function buildLogDetail(event: LogEvent): string {
60
28
  const parts: string[] = [];
61
29
 
62
30
  for (const [key, value] of Object.entries(event.data)) {
@@ -74,8 +42,9 @@ function buildLogDetail(event: LogEvent): string {
74
42
  /**
75
43
  * Discover all events.ndjson files in the logs directory.
76
44
  * Returns array of { agentName, sessionTimestamp, path }.
45
+ * @internal Exported for testing.
77
46
  */
78
- async function discoverLogFiles(
47
+ export async function discoverLogFiles(
79
48
  logsDir: string,
80
49
  agentFilter?: string,
81
50
  ): Promise<
@@ -145,8 +114,9 @@ async function discoverLogFiles(
145
114
  /**
146
115
  * Parse a single NDJSON file and return log events.
147
116
  * Silently skips invalid lines.
117
+ * @internal Exported for testing.
148
118
  */
149
- async function parseLogFile(path: string): Promise<LogEvent[]> {
119
+ export async function parseLogFile(path: string): Promise<LogEvent[]> {
150
120
  const events: LogEvent[] = [];
151
121
 
152
122
  try {
@@ -184,8 +154,9 @@ async function parseLogFile(path: string): Promise<LogEvent[]> {
184
154
 
185
155
  /**
186
156
  * Apply filters to log events.
157
+ * @internal Exported for testing.
187
158
  */
188
- function filterEvents(
159
+ export function filterEvents(
189
160
  events: LogEvent[],
190
161
  filters: {
191
162
  level?: string;
@@ -255,6 +226,95 @@ function printLogs(events: LogEvent[]): void {
255
226
  }
256
227
  }
257
228
 
229
+ /**
230
+ * Process one poll tick of log tailing: scan discovered log files for new data
231
+ * since lastKnownSizes, parse new lines, apply filters, and return the count
232
+ * of new events found.
233
+ * @internal Exported for testing.
234
+ *
235
+ * @param logFiles - Array of discovered log file paths
236
+ * @param lastKnownSizes - Map of file path to last-seen byte offset (mutated in place)
237
+ * @param filters - Level filter to apply
238
+ * @returns Number of new events emitted
239
+ */
240
+ export async function pollLogTick(
241
+ logFiles: Array<{ path: string }>,
242
+ lastKnownSizes: Map<string, number>,
243
+ filters: { level?: string },
244
+ ): Promise<number> {
245
+ const w = process.stdout.write.bind(process.stdout);
246
+ let emitted = 0;
247
+
248
+ for (const { path } of logFiles) {
249
+ const file = Bun.file(path);
250
+ let fileSize: number;
251
+
252
+ try {
253
+ const fileStat = await stat(path);
254
+ fileSize = fileStat.size;
255
+ } catch {
256
+ continue; // File disappeared
257
+ }
258
+
259
+ const lastPosition = lastKnownSizes.get(path) ?? 0;
260
+
261
+ if (fileSize > lastPosition) {
262
+ // New data available
263
+ try {
264
+ const fullText = await file.text();
265
+ const newText = fullText.slice(lastPosition);
266
+ const lines = newText.split("\n");
267
+
268
+ for (const line of lines) {
269
+ if (line.trim() === "") {
270
+ continue;
271
+ }
272
+
273
+ try {
274
+ const parsed: unknown = JSON.parse(line);
275
+ if (
276
+ typeof parsed === "object" &&
277
+ parsed !== null &&
278
+ "timestamp" in parsed &&
279
+ "event" in parsed
280
+ ) {
281
+ const event = parsed as LogEvent;
282
+
283
+ // Apply level filter
284
+ if (filters.level !== undefined && event.level !== filters.level) {
285
+ continue;
286
+ }
287
+
288
+ // Print immediately
289
+ const time = formatAbsoluteTime(event.timestamp);
290
+ const levelColorFn = logLevelColor(event.level);
291
+ const levelStr = logLevelLabel(event.level);
292
+
293
+ const agentLabel = event.agentName ? `[${event.agentName}]` : "[unknown]";
294
+ const detail = buildLogDetail(event);
295
+ const detailSuffix = detail ? ` ${color.dim(detail)}` : "";
296
+
297
+ w(
298
+ `${time} ${levelColorFn(levelStr)} ` +
299
+ `${event.event} ${color.dim(agentLabel)}${detailSuffix}\n`,
300
+ );
301
+ emitted++;
302
+ }
303
+ } catch {
304
+ // Invalid JSON line, skip
305
+ }
306
+ }
307
+
308
+ lastKnownSizes.set(path, fileSize);
309
+ } catch {
310
+ // File read error, skip
311
+ }
312
+ }
313
+ }
314
+
315
+ return emitted;
316
+ }
317
+
258
318
  /**
259
319
  * Follow mode: tail logs in real time.
260
320
  */
@@ -274,72 +334,7 @@ async function followLogs(
274
334
 
275
335
  while (true) {
276
336
  const discovered = await discoverLogFiles(logsDir, filters.agent);
277
-
278
- for (const { path } of discovered) {
279
- const file = Bun.file(path);
280
- let fileSize: number;
281
-
282
- try {
283
- const fileStat = await stat(path);
284
- fileSize = fileStat.size;
285
- } catch {
286
- continue; // File disappeared
287
- }
288
-
289
- const lastPosition = filePositions.get(path) ?? 0;
290
-
291
- if (fileSize > lastPosition) {
292
- // New data available
293
- try {
294
- const fullText = await file.text();
295
- const newText = fullText.slice(lastPosition);
296
- const lines = newText.split("\n");
297
-
298
- for (const line of lines) {
299
- if (line.trim() === "") {
300
- continue;
301
- }
302
-
303
- try {
304
- const parsed: unknown = JSON.parse(line);
305
- if (
306
- typeof parsed === "object" &&
307
- parsed !== null &&
308
- "timestamp" in parsed &&
309
- "event" in parsed
310
- ) {
311
- const event = parsed as LogEvent;
312
-
313
- // Apply level filter
314
- if (filters.level !== undefined && event.level !== filters.level) {
315
- continue;
316
- }
317
-
318
- // Print immediately
319
- const time = formatAbsoluteTime(event.timestamp);
320
- const levelColorFn = logLevelColor(event.level);
321
- const levelStr = logLevelLabel(event.level);
322
-
323
- const agentLabel = event.agentName ? `[${event.agentName}]` : "[unknown]";
324
- const detail = buildLogDetail(event);
325
- const detailSuffix = detail ? ` ${color.dim(detail)}` : "";
326
-
327
- w(
328
- `${time} ${levelColorFn(levelStr)} ` +
329
- `${event.event} ${color.dim(agentLabel)}${detailSuffix}\n`,
330
- );
331
- }
332
- } catch {
333
- // Invalid JSON line, skip
334
- }
335
- }
336
-
337
- filePositions.set(path, fileSize);
338
- } catch {
339
- // File read error, skip
340
- }
341
- }
342
- }
337
+ await pollLogTick(discovered, filePositions, { level: filters.level });
343
338
 
344
339
  // Sleep for 1 second before next poll
345
340
  await Bun.sleep(1000);
@@ -0,0 +1,42 @@
1
+ import type { Command } from "commander";
2
+ import {
3
+ type CoordinatorDeps,
4
+ createPersistentAgentCommand,
5
+ type PersistentAgentSpec,
6
+ persistentAgentCommand,
7
+ } from "./coordinator.ts";
8
+
9
+ const ORCHESTRATOR_SPEC: PersistentAgentSpec = {
10
+ commandName: "orchestrator",
11
+ displayName: "Orchestrator",
12
+ agentName: "orchestrator",
13
+ capability: "orchestrator",
14
+ agentDefFile: "orchestrator.md",
15
+ beaconBuilder: buildOrchestratorBeacon,
16
+ };
17
+
18
+ /**
19
+ * Build the startup beacon for the ecosystem-level orchestrator session.
20
+ */
21
+ export function buildOrchestratorBeacon(cliName = "bd"): string {
22
+ const timestamp = new Date().toISOString();
23
+ const parts = [
24
+ `[OVERSTORY] orchestrator (orchestrator) ${timestamp}`,
25
+ "Depth: 0 | Parent: none | Role: persistent ecosystem orchestrator",
26
+ "HIERARCHY: You start per-repo coordinators with ov coordinator start --project <path>. Do NOT use ov sling directly.",
27
+ "DELEGATION: Work flows through sub-repo coordinators. Dispatch objectives by mail, then monitor coordinator progress and escalate only when needed.",
28
+ `Startup: run mulch prime, check mail (ov mail check --agent orchestrator), check ${cliName} ready, inspect ecosystem status, then begin coordination`,
29
+ ];
30
+ return parts.join(" — ");
31
+ }
32
+
33
+ export function createOrchestratorCommand(deps: CoordinatorDeps = {}): Command {
34
+ return createPersistentAgentCommand(ORCHESTRATOR_SPEC, deps);
35
+ }
36
+
37
+ export async function orchestratorCommand(
38
+ args: string[],
39
+ deps: CoordinatorDeps = {},
40
+ ): Promise<void> {
41
+ await persistentAgentCommand(args, ORCHESTRATOR_SPEC, deps);
42
+ }
@@ -3,8 +3,8 @@ import { mkdir, mkdtemp } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
6
- import type { AgentSession } from "../types.ts";
7
- import { primeCommand } from "./prime.ts";
6
+ import type { AgentManifest, AgentSession, SessionMetrics } from "../types.ts";
7
+ import { formatManifest, formatMetrics, primeCommand } from "./prime.ts";
8
8
 
9
9
  /**
10
10
  * Tests for `overstory prime` command.
@@ -435,3 +435,178 @@ sessions.db
435
435
  });
436
436
  });
437
437
  });
438
+
439
+ describe("formatManifest", () => {
440
+ test("returns 'No agents registered.' for empty agents record", () => {
441
+ const manifest: AgentManifest = {
442
+ version: "1",
443
+ agents: {},
444
+ capabilityIndex: {},
445
+ };
446
+ expect(formatManifest(manifest)).toBe("No agents registered.");
447
+ });
448
+
449
+ test("formats single agent with capabilities", () => {
450
+ const manifest: AgentManifest = {
451
+ version: "1",
452
+ agents: {
453
+ scout: {
454
+ file: "agents/scout.md",
455
+ model: "sonnet",
456
+ tools: ["Read", "Glob"],
457
+ capabilities: ["explore", "analyze"],
458
+ canSpawn: false,
459
+ constraints: [],
460
+ },
461
+ },
462
+ capabilityIndex: { explore: ["scout"], analyze: ["scout"] },
463
+ };
464
+ const result = formatManifest(manifest);
465
+ expect(result).toContain("**scout**");
466
+ expect(result).toContain("[sonnet]");
467
+ expect(result).toContain("explore, analyze");
468
+ expect(result).not.toContain("(can spawn)");
469
+ });
470
+
471
+ test("marks agents that can spawn", () => {
472
+ const manifest: AgentManifest = {
473
+ version: "1",
474
+ agents: {
475
+ lead: {
476
+ file: "agents/lead.md",
477
+ model: "opus",
478
+ tools: ["Read", "Bash"],
479
+ capabilities: ["coordinate"],
480
+ canSpawn: true,
481
+ constraints: [],
482
+ },
483
+ },
484
+ capabilityIndex: { coordinate: ["lead"] },
485
+ };
486
+ const result = formatManifest(manifest);
487
+ expect(result).toContain("(can spawn)");
488
+ });
489
+
490
+ test("formats multiple agents as separate lines", () => {
491
+ const manifest: AgentManifest = {
492
+ version: "1",
493
+ agents: {
494
+ scout: {
495
+ file: "agents/scout.md",
496
+ model: "sonnet",
497
+ tools: [],
498
+ capabilities: ["explore"],
499
+ canSpawn: false,
500
+ constraints: [],
501
+ },
502
+ builder: {
503
+ file: "agents/builder.md",
504
+ model: "opus",
505
+ tools: [],
506
+ capabilities: ["implement"],
507
+ canSpawn: false,
508
+ constraints: [],
509
+ },
510
+ },
511
+ capabilityIndex: {},
512
+ };
513
+ const result = formatManifest(manifest);
514
+ const lines = result.split("\n");
515
+ expect(lines).toHaveLength(2);
516
+ expect(lines[0]).toContain("scout");
517
+ expect(lines[1]).toContain("builder");
518
+ });
519
+ });
520
+
521
+ describe("formatMetrics", () => {
522
+ test("returns 'No recent sessions.' for empty array", () => {
523
+ expect(formatMetrics([])).toBe("No recent sessions.");
524
+ });
525
+
526
+ test("formats a completed session with duration and merge result", () => {
527
+ const sessions: SessionMetrics[] = [
528
+ {
529
+ agentName: "builder-1",
530
+ taskId: "task-001",
531
+ capability: "builder",
532
+ startedAt: "2026-01-01T00:00:00Z",
533
+ completedAt: "2026-01-01T00:05:00Z",
534
+ durationMs: 300_000,
535
+ exitCode: 0,
536
+ mergeResult: "clean-merge",
537
+ parentAgent: "coordinator",
538
+ inputTokens: 0,
539
+ outputTokens: 0,
540
+ cacheReadTokens: 0,
541
+ cacheCreationTokens: 0,
542
+ estimatedCostUsd: null,
543
+ modelUsed: null,
544
+ runId: null,
545
+ },
546
+ ];
547
+ const result = formatMetrics(sessions);
548
+ expect(result).toContain("builder-1");
549
+ expect(result).toContain("(builder)");
550
+ expect(result).toContain("task-001");
551
+ expect(result).toContain("completed");
552
+ expect(result).toContain("(300s)");
553
+ expect(result).toContain("[clean-merge]");
554
+ });
555
+
556
+ test("formats an in-progress session without duration or merge result", () => {
557
+ const sessions: SessionMetrics[] = [
558
+ {
559
+ agentName: "scout-1",
560
+ taskId: "task-002",
561
+ capability: "scout",
562
+ startedAt: "2026-01-01T00:00:00Z",
563
+ completedAt: null,
564
+ durationMs: 0,
565
+ exitCode: null,
566
+ mergeResult: null,
567
+ parentAgent: null,
568
+ inputTokens: 0,
569
+ outputTokens: 0,
570
+ cacheReadTokens: 0,
571
+ cacheCreationTokens: 0,
572
+ estimatedCostUsd: null,
573
+ modelUsed: null,
574
+ runId: null,
575
+ },
576
+ ];
577
+ const result = formatMetrics(sessions);
578
+ expect(result).toContain("scout-1");
579
+ expect(result).toContain("in-progress");
580
+ expect(result).not.toContain("[");
581
+ });
582
+
583
+ test("formats multiple sessions as separate lines", () => {
584
+ const base: SessionMetrics = {
585
+ agentName: "builder-1",
586
+ taskId: "task-001",
587
+ capability: "builder",
588
+ startedAt: "2026-01-01T00:00:00Z",
589
+ completedAt: "2026-01-01T00:05:00Z",
590
+ durationMs: 300_000,
591
+ exitCode: 0,
592
+ mergeResult: null,
593
+ parentAgent: null,
594
+ inputTokens: 0,
595
+ outputTokens: 0,
596
+ cacheReadTokens: 0,
597
+ cacheCreationTokens: 0,
598
+ estimatedCostUsd: null,
599
+ modelUsed: null,
600
+ runId: null,
601
+ };
602
+ const sessions: SessionMetrics[] = [
603
+ base,
604
+ { ...base, agentName: "builder-2", taskId: "task-002" },
605
+ ];
606
+ const result = formatMetrics(sessions);
607
+ const lines = result.split("\n");
608
+ expect(lines).toHaveLength(2);
609
+ expect(lines[0]).toContain("builder-1");
610
+ expect(lines[1]).toContain("builder-2");
611
+ });
612
+ });
@@ -31,8 +31,9 @@ export interface PrimeOptions {
31
31
 
32
32
  /**
33
33
  * Format the agent manifest section for output.
34
+ * @internal Exported for testing.
34
35
  */
35
- function formatManifest(manifest: AgentManifest): string {
36
+ export function formatManifest(manifest: AgentManifest): string {
36
37
  const lines: string[] = [];
37
38
  for (const [name, def] of Object.entries(manifest.agents)) {
38
39
  const caps = def.capabilities.join(", ");
@@ -44,8 +45,9 @@ function formatManifest(manifest: AgentManifest): string {
44
45
 
45
46
  /**
46
47
  * Format recent session metrics for output.
48
+ * @internal Exported for testing.
47
49
  */
48
- function formatMetrics(sessions: SessionMetrics[]): string {
50
+ export function formatMetrics(sessions: SessionMetrics[]): string {
49
51
  if (sessions.length === 0) {
50
52
  return "No recent sessions.";
51
53
  }
@@ -24,6 +24,7 @@ import { join, resolve } from "node:path";
24
24
  import { createIdentity, loadIdentity } from "../agents/identity.ts";
25
25
  import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
26
26
  import { writeOverlay } from "../agents/overlay.ts";
27
+ import { createCanopyClient } from "../canopy/client.ts";
27
28
  import { loadConfig } from "../config.ts";
28
29
  import { AgentError, HierarchyError, ValidationError } from "../errors.ts";
29
30
  import { inferDomain } from "../insights/analyzer.ts";
@@ -153,6 +154,7 @@ export interface SlingOptions {
153
154
  runtime?: string;
154
155
  noScoutCheck?: boolean;
155
156
  baseBranch?: string;
157
+ profile?: string;
156
158
  }
157
159
 
158
160
  export interface AutoDispatchOptions {
@@ -369,11 +371,11 @@ export function checkParentAgentLimit(
369
371
  }
370
372
 
371
373
  /**
372
- * Validate hierarchy constraints: the coordinator (no parent) may only spawn leads.
374
+ * Validate hierarchy constraints for direct coordinator/human spawns.
373
375
  *
374
376
  * When parentAgent is null, the caller is the coordinator or a human.
375
- * Only "lead" capability is allowed in that case. All other capabilities
376
- * (builder, scout, reviewer, merger) must be spawned by a lead
377
+ * Direct spawns are allowed for "lead", "scout", and "builder".
378
+ * Other capabilities (reviewer, merger, etc.) must be spawned by a lead
377
379
  * that passes --parent.
378
380
  *
379
381
  * @param parentAgent - The --parent flag value (null = coordinator/human)
@@ -784,6 +786,23 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
784
786
  }
785
787
  }
786
788
 
789
+ // 8b. Resolve canopy profile if specified
790
+ const profileName =
791
+ opts.profile ?? process.env.OVERSTORY_PROFILE ?? config.project.defaultProfile;
792
+ let profileContent: string | undefined;
793
+ if (profileName) {
794
+ try {
795
+ const canopy = createCanopyClient(config.project.root);
796
+ const rendered = await canopy.render(profileName);
797
+ if (rendered.success && rendered.sections.length > 0) {
798
+ profileContent = rendered.sections.map((s) => s.body).join("\n\n");
799
+ }
800
+ } catch {
801
+ // Non-fatal: canopy may not be installed or profile may not exist
802
+ profileContent = undefined;
803
+ }
804
+ }
805
+
787
806
  // Resolve runtime before overlayConfig so we can pass runtime.instructionPath
788
807
  const runtime = getRuntime(opts.runtime, config, capability);
789
808
 
@@ -802,6 +821,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
802
821
  canSpawn: agentDef.canSpawn,
803
822
  capability,
804
823
  baseDefinition,
824
+ profileContent,
805
825
  mulchExpertise,
806
826
  skipScout: skipScout && capability === "lead",
807
827
  skipReview: opts.skipReview === true && capability === "lead",
@@ -29,6 +29,7 @@ const AGENT_DEF_FILES = [
29
29
  "merger.md",
30
30
  "monitor.md",
31
31
  "orchestrator.md",
32
+ "ov-co-creation.md",
32
33
  "reviewer.md",
33
34
  "scout.md",
34
35
  ];
@@ -44,3 +44,5 @@ describe("createUpgradeCommand — CLI structure", () => {
44
44
  expect(typeof cmd.parse).toBe("function");
45
45
  });
46
46
  });
47
+
48
+ // getCurrentVersion and fetchLatestVersion tests moved to src/utils/version.test.ts
@@ -10,6 +10,7 @@
10
10
  import { Command } from "commander";
11
11
  import { jsonError, jsonOutput } from "../json.ts";
12
12
  import { muted, printError, printHint, printSuccess, printWarning } from "../logging/color.ts";
13
+ import { fetchLatestVersion, getCurrentVersion } from "../utils/version.ts";
13
14
 
14
15
  const OVERSTORY_PACKAGE = "@os-eco/overstory-cli";
15
16
 
@@ -26,23 +27,6 @@ export interface UpgradeOptions {
26
27
  json?: boolean;
27
28
  }
28
29
 
29
- async function getCurrentVersion(): Promise<string> {
30
- const pkgPath = new URL("../../package.json", import.meta.url);
31
- const pkg = JSON.parse(await Bun.file(pkgPath).text()) as { version: string };
32
- return pkg.version;
33
- }
34
-
35
- async function fetchLatestVersion(packageName: string): Promise<string> {
36
- const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
37
- if (!res.ok) {
38
- throw new Error(
39
- `Failed to fetch npm registry for ${packageName}: ${res.status} ${res.statusText}`,
40
- );
41
- }
42
- const data = (await res.json()) as { version: string };
43
- return data.version;
44
- }
45
-
46
30
  async function runInstall(packageName: string): Promise<number> {
47
31
  const proc = Bun.spawn(["bun", "install", "-g", `${packageName}@latest`], {
48
32
  stdout: "inherit",
@@ -3,7 +3,8 @@ import { mkdtemp } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { cleanupTempDir } from "../test-helpers.ts";
6
- import { watchCommand } from "./watch.ts";
6
+ import type { HealthCheck } from "../types.ts";
7
+ import { formatCheck, watchCommand } from "./watch.ts";
7
8
 
8
9
  /**
9
10
  * Tests for `overstory watch` command.
@@ -144,3 +145,68 @@ describe("watchCommand", () => {
144
145
  // If it doesn't exist, that's also valid (spawn failed before writing new PID)
145
146
  });
146
147
  });
148
+
149
+ describe("formatCheck", () => {
150
+ function makeCheck(overrides: Partial<HealthCheck>): HealthCheck {
151
+ return {
152
+ agentName: "test-agent",
153
+ timestamp: new Date().toISOString(),
154
+ processAlive: true,
155
+ tmuxAlive: true,
156
+ pidAlive: true,
157
+ lastActivity: new Date().toISOString(),
158
+ state: "working",
159
+ action: "none",
160
+ reconciliationNote: null,
161
+ ...overrides,
162
+ };
163
+ }
164
+
165
+ test("terminate action uses x icon", () => {
166
+ const result = formatCheck(makeCheck({ action: "terminate" }));
167
+ expect(result).toMatch(/^x /);
168
+ });
169
+
170
+ test("escalate action uses ! icon", () => {
171
+ const result = formatCheck(makeCheck({ action: "escalate" }));
172
+ expect(result).toMatch(/^! /);
173
+ });
174
+
175
+ test("investigate action uses > icon", () => {
176
+ const result = formatCheck(makeCheck({ action: "investigate" }));
177
+ expect(result).toMatch(/^> /);
178
+ });
179
+
180
+ test("pidAlive true shows up", () => {
181
+ const result = formatCheck(makeCheck({ pidAlive: true }));
182
+ expect(result).toContain("pid=up");
183
+ });
184
+
185
+ test("pidAlive false shows down", () => {
186
+ const result = formatCheck(makeCheck({ pidAlive: false }));
187
+ expect(result).toContain("pid=down");
188
+ });
189
+
190
+ test("pidAlive null shows n/a", () => {
191
+ const result = formatCheck(makeCheck({ pidAlive: null }));
192
+ expect(result).toContain("pid=n/a");
193
+ });
194
+
195
+ test("includes reconciliation note when present", () => {
196
+ const result = formatCheck(makeCheck({ reconciliationNote: "stale session" }));
197
+ expect(result).toContain("[stale session]");
198
+ });
199
+
200
+ test("no reconciliation note brackets when null", () => {
201
+ const result = formatCheck(makeCheck({ reconciliationNote: null }));
202
+ expect(result).not.toContain("[");
203
+ });
204
+
205
+ test("includes agent name and state", () => {
206
+ const result = formatCheck(makeCheck({ agentName: "builder-1", state: "stalled" }));
207
+ expect(result).toContain("builder-1");
208
+ expect(result).toContain("stalled");
209
+ });
210
+ });
211
+
212
+ // PID and bin utility tests moved to src/utils/pid.test.ts and src/utils/bin.test.ts