@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
@@ -83,13 +83,13 @@ function updateLastActivity(projectRoot: string, agentName: string): void {
83
83
  * The Stop hook fires every turn for these agents (not just at session end),
84
84
  * so they must NOT auto-transition to 'completed' on session-end events.
85
85
  */
86
- const PERSISTENT_CAPABILITIES = new Set(["coordinator", "monitor"]);
86
+ const PERSISTENT_CAPABILITIES = new Set(["coordinator", "orchestrator", "monitor"]);
87
87
 
88
88
  /**
89
89
  * Transition agent state to 'completed' in the SessionStore.
90
90
  * Called when session-end event fires.
91
91
  *
92
- * Skips the transition for persistent agent types (coordinator, monitor)
92
+ * Skips the transition for persistent agent types (coordinator, orchestrator, monitor)
93
93
  * whose Stop hook fires every turn, not just at true session end.
94
94
  *
95
95
  * Non-fatal: silently ignores errors to avoid breaking hook execution.
@@ -101,15 +101,19 @@ function transitionToCompleted(projectRoot: string, agentName: string): void {
101
101
  try {
102
102
  const session = store.getByName(agentName);
103
103
  if (session && PERSISTENT_CAPABILITIES.has(session.capability)) {
104
- // Check if coordinator self-exited by verifying the run is already completed.
104
+ // Check if a persistent top-level agent self-exited by verifying the run
105
+ // is already completed.
105
106
  // If `ov run complete` was called before session-end, the run status is 'completed'
106
- // and we should transition the coordinator session to completed too.
107
- if (session.capability === "coordinator" && session.runId) {
107
+ // and we should transition the persistent session to completed too.
108
+ if (
109
+ (session.capability === "coordinator" || session.capability === "orchestrator") &&
110
+ session.runId
111
+ ) {
108
112
  const runStore = createRunStore(join(overstoryDir, "sessions.db"));
109
113
  try {
110
114
  const run = runStore.getRun(session.runId);
111
115
  if (run && run.status === "completed") {
112
- // Self-exit: coordinator called ov run complete before session ended
116
+ // Self-exit: the persistent agent called ov run complete before session ended
113
117
  store.updateState(agentName, "completed");
114
118
  store.updateLastActivity(agentName);
115
119
  return;
@@ -5,7 +5,14 @@ import { join } from "node:path";
5
5
  import { ValidationError } from "../errors.ts";
6
6
  import { cleanupTempDir } from "../test-helpers.ts";
7
7
  import type { LogEvent } from "../types.ts";
8
- import { logsCommand } from "./logs.ts";
8
+ import {
9
+ buildLogDetail,
10
+ discoverLogFiles,
11
+ filterEvents,
12
+ logsCommand,
13
+ parseLogFile,
14
+ pollLogTick,
15
+ } from "./logs.ts";
9
16
 
10
17
  /**
11
18
  * Test helper: capture stdout during command execution.
@@ -377,3 +384,418 @@ this is not json
377
384
  expect(output).not.toContain("this is not json");
378
385
  });
379
386
  });
387
+
388
+ // parseRelativeTime tests moved to src/utils/time.test.ts
389
+
390
+ describe("buildLogDetail", () => {
391
+ test("builds key=value pairs from data fields", () => {
392
+ const event: LogEvent = {
393
+ timestamp: "2026-01-01T00:00:00Z",
394
+ level: "info",
395
+ event: "test",
396
+ agentName: "a",
397
+ data: { toolName: "Bash", file: "index.ts" },
398
+ };
399
+ const result = buildLogDetail(event);
400
+ expect(result).toContain("toolName=Bash");
401
+ expect(result).toContain("file=index.ts");
402
+ });
403
+
404
+ test("truncates values longer than 80 characters", () => {
405
+ const longValue = "x".repeat(100);
406
+ const event: LogEvent = {
407
+ timestamp: "2026-01-01T00:00:00Z",
408
+ level: "info",
409
+ event: "test",
410
+ agentName: "a",
411
+ data: { message: longValue },
412
+ };
413
+ const result = buildLogDetail(event);
414
+ expect(result).not.toContain(longValue);
415
+ expect(result).toContain("...");
416
+ // 77 chars + "..." = 80
417
+ expect(result).toContain("x".repeat(77));
418
+ });
419
+
420
+ test("skips null and undefined values", () => {
421
+ const event: LogEvent = {
422
+ timestamp: "2026-01-01T00:00:00Z",
423
+ level: "info",
424
+ event: "test",
425
+ agentName: "a",
426
+ data: { present: "yes", missing: null, also: undefined },
427
+ };
428
+ const result = buildLogDetail(event);
429
+ expect(result).toContain("present=yes");
430
+ expect(result).not.toContain("missing");
431
+ expect(result).not.toContain("also");
432
+ });
433
+
434
+ test("returns empty string for empty data", () => {
435
+ const event: LogEvent = {
436
+ timestamp: "2026-01-01T00:00:00Z",
437
+ level: "info",
438
+ event: "test",
439
+ agentName: "a",
440
+ data: {},
441
+ };
442
+ expect(buildLogDetail(event)).toBe("");
443
+ });
444
+
445
+ test("stringifies non-string values as JSON", () => {
446
+ const event: LogEvent = {
447
+ timestamp: "2026-01-01T00:00:00Z",
448
+ level: "info",
449
+ event: "test",
450
+ agentName: "a",
451
+ data: { count: 42, active: true },
452
+ };
453
+ const result = buildLogDetail(event);
454
+ expect(result).toContain("count=42");
455
+ expect(result).toContain("active=true");
456
+ });
457
+ });
458
+
459
+ describe("discoverLogFiles", () => {
460
+ let tmpDir: string;
461
+
462
+ beforeEach(async () => {
463
+ tmpDir = join(
464
+ tmpdir(),
465
+ `overstory-discover-test-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
466
+ );
467
+ await mkdir(tmpDir, { recursive: true });
468
+ });
469
+
470
+ afterEach(async () => {
471
+ await cleanupTempDir(tmpDir);
472
+ });
473
+
474
+ test("discovers log files in proper agent/session structure", async () => {
475
+ // Create agent-a/session-1/events.ndjson
476
+ const dir1 = join(tmpDir, "agent-a", "2026-01-01T00-00-00");
477
+ await mkdir(dir1, { recursive: true });
478
+ await writeFile(join(dir1, "events.ndjson"), "{}");
479
+
480
+ // Create agent-b/session-2/events.ndjson
481
+ const dir2 = join(tmpDir, "agent-b", "2026-01-02T00-00-00");
482
+ await mkdir(dir2, { recursive: true });
483
+ await writeFile(join(dir2, "events.ndjson"), "{}");
484
+
485
+ const result = await discoverLogFiles(tmpDir);
486
+ expect(result).toHaveLength(2);
487
+ expect(result[0]?.agentName).toBe("agent-a");
488
+ expect(result[1]?.agentName).toBe("agent-b");
489
+ });
490
+
491
+ test("filters by agent name when provided", async () => {
492
+ const dir1 = join(tmpDir, "agent-a", "2026-01-01T00-00-00");
493
+ await mkdir(dir1, { recursive: true });
494
+ await writeFile(join(dir1, "events.ndjson"), "{}");
495
+
496
+ const dir2 = join(tmpDir, "agent-b", "2026-01-02T00-00-00");
497
+ await mkdir(dir2, { recursive: true });
498
+ await writeFile(join(dir2, "events.ndjson"), "{}");
499
+
500
+ const result = await discoverLogFiles(tmpDir, "agent-a");
501
+ expect(result).toHaveLength(1);
502
+ expect(result[0]?.agentName).toBe("agent-a");
503
+ });
504
+
505
+ test("returns empty array for nonexistent directory", async () => {
506
+ const result = await discoverLogFiles(join(tmpDir, "nonexistent"));
507
+ expect(result).toEqual([]);
508
+ });
509
+
510
+ test("sorts by session timestamp", async () => {
511
+ const dir1 = join(tmpDir, "agent-a", "2026-01-02T00-00-00");
512
+ await mkdir(dir1, { recursive: true });
513
+ await writeFile(join(dir1, "events.ndjson"), "{}");
514
+
515
+ const dir2 = join(tmpDir, "agent-a", "2026-01-01T00-00-00");
516
+ await mkdir(dir2, { recursive: true });
517
+ await writeFile(join(dir2, "events.ndjson"), "{}");
518
+
519
+ const result = await discoverLogFiles(tmpDir);
520
+ expect(result[0]?.sessionTimestamp).toBe("2026-01-01T00-00-00");
521
+ expect(result[1]?.sessionTimestamp).toBe("2026-01-02T00-00-00");
522
+ });
523
+ });
524
+
525
+ describe("parseLogFile", () => {
526
+ let tmpDir: string;
527
+
528
+ beforeEach(async () => {
529
+ tmpDir = join(
530
+ tmpdir(),
531
+ `overstory-parse-test-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
532
+ );
533
+ await mkdir(tmpDir, { recursive: true });
534
+ });
535
+
536
+ afterEach(async () => {
537
+ await cleanupTempDir(tmpDir);
538
+ });
539
+
540
+ test("parses valid NDJSON lines", async () => {
541
+ const filePath = join(tmpDir, "events.ndjson");
542
+ const lines = [
543
+ JSON.stringify({
544
+ timestamp: "2026-01-01T10:00:00Z",
545
+ event: "tool.start",
546
+ level: "info",
547
+ agentName: "a",
548
+ data: {},
549
+ }),
550
+ JSON.stringify({
551
+ timestamp: "2026-01-01T10:01:00Z",
552
+ event: "tool.end",
553
+ level: "info",
554
+ agentName: "a",
555
+ data: {},
556
+ }),
557
+ ];
558
+ await writeFile(filePath, lines.join("\n"));
559
+
560
+ const events = await parseLogFile(filePath);
561
+ expect(events).toHaveLength(2);
562
+ expect(events[0]?.event).toBe("tool.start");
563
+ expect(events[1]?.event).toBe("tool.end");
564
+ });
565
+
566
+ test("skips malformed JSON lines silently", async () => {
567
+ const filePath = join(tmpDir, "events.ndjson");
568
+ const content = [
569
+ JSON.stringify({
570
+ timestamp: "2026-01-01T10:00:00Z",
571
+ event: "valid",
572
+ level: "info",
573
+ agentName: "a",
574
+ data: {},
575
+ }),
576
+ "not valid json",
577
+ '{"incomplete": true',
578
+ JSON.stringify({
579
+ timestamp: "2026-01-01T10:02:00Z",
580
+ event: "also-valid",
581
+ level: "info",
582
+ agentName: "a",
583
+ data: {},
584
+ }),
585
+ ].join("\n");
586
+ await writeFile(filePath, content);
587
+
588
+ const events = await parseLogFile(filePath);
589
+ expect(events).toHaveLength(2);
590
+ expect(events[0]?.event).toBe("valid");
591
+ expect(events[1]?.event).toBe("also-valid");
592
+ });
593
+
594
+ test("returns empty array for nonexistent file", async () => {
595
+ const events = await parseLogFile(join(tmpDir, "nonexistent.ndjson"));
596
+ expect(events).toEqual([]);
597
+ });
598
+
599
+ test("skips objects missing required fields", async () => {
600
+ const filePath = join(tmpDir, "events.ndjson");
601
+ const content = [
602
+ JSON.stringify({ timestamp: "2026-01-01T00:00:00Z" }), // missing "event"
603
+ JSON.stringify({ event: "test" }), // missing "timestamp"
604
+ JSON.stringify({
605
+ timestamp: "2026-01-01T00:00:00Z",
606
+ event: "good",
607
+ level: "info",
608
+ agentName: "a",
609
+ data: {},
610
+ }),
611
+ ].join("\n");
612
+ await writeFile(filePath, content);
613
+
614
+ const events = await parseLogFile(filePath);
615
+ expect(events).toHaveLength(1);
616
+ expect(events[0]?.event).toBe("good");
617
+ });
618
+ });
619
+
620
+ describe("filterEvents", () => {
621
+ const baseEvents: LogEvent[] = [
622
+ {
623
+ timestamp: "2026-01-01T10:00:00.000Z",
624
+ level: "info",
625
+ event: "e1",
626
+ agentName: "a",
627
+ data: {},
628
+ },
629
+ {
630
+ timestamp: "2026-01-01T11:00:00.000Z",
631
+ level: "error",
632
+ event: "e2",
633
+ agentName: "a",
634
+ data: {},
635
+ },
636
+ {
637
+ timestamp: "2026-01-01T12:00:00.000Z",
638
+ level: "warn",
639
+ event: "e3",
640
+ agentName: "a",
641
+ data: {},
642
+ },
643
+ {
644
+ timestamp: "2026-01-01T13:00:00.000Z",
645
+ level: "debug",
646
+ event: "e4",
647
+ agentName: "a",
648
+ data: {},
649
+ },
650
+ ];
651
+
652
+ test("filters by level", () => {
653
+ const result = filterEvents(baseEvents, { level: "error" });
654
+ expect(result).toHaveLength(1);
655
+ expect(result[0]?.event).toBe("e2");
656
+ });
657
+
658
+ test("filters by since", () => {
659
+ const since = new Date("2026-01-01T11:30:00.000Z");
660
+ const result = filterEvents(baseEvents, { since });
661
+ expect(result).toHaveLength(2);
662
+ expect(result[0]?.event).toBe("e3");
663
+ expect(result[1]?.event).toBe("e4");
664
+ });
665
+
666
+ test("filters by until", () => {
667
+ const until = new Date("2026-01-01T11:30:00.000Z");
668
+ const result = filterEvents(baseEvents, { until });
669
+ expect(result).toHaveLength(2);
670
+ expect(result[0]?.event).toBe("e1");
671
+ expect(result[1]?.event).toBe("e2");
672
+ });
673
+
674
+ test("combines level + since + until", () => {
675
+ const result = filterEvents(baseEvents, {
676
+ level: "info",
677
+ since: new Date("2026-01-01T09:00:00.000Z"),
678
+ until: new Date("2026-01-01T10:30:00.000Z"),
679
+ });
680
+ expect(result).toHaveLength(1);
681
+ expect(result[0]?.event).toBe("e1");
682
+ });
683
+
684
+ test("returns all events with no filters", () => {
685
+ const result = filterEvents(baseEvents, {});
686
+ expect(result).toHaveLength(4);
687
+ });
688
+ });
689
+
690
+ describe("pollLogTick", () => {
691
+ let tmpDir: string;
692
+
693
+ beforeEach(async () => {
694
+ tmpDir = join(
695
+ tmpdir(),
696
+ `overstory-poll-test-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
697
+ );
698
+ await mkdir(tmpDir, { recursive: true });
699
+ });
700
+
701
+ afterEach(async () => {
702
+ await cleanupTempDir(tmpDir);
703
+ });
704
+
705
+ test("returns 0 for empty files", async () => {
706
+ const filePath = join(tmpDir, "events.ndjson");
707
+ await writeFile(filePath, "");
708
+
709
+ const lastKnownSizes = new Map<string, number>();
710
+ const count = await pollLogTick([{ path: filePath }], lastKnownSizes, {});
711
+ expect(count).toBe(0);
712
+ });
713
+
714
+ test("returns count of new events from file with new lines", async () => {
715
+ const filePath = join(tmpDir, "events.ndjson");
716
+ const line1 = JSON.stringify({
717
+ timestamp: "2026-01-01T10:00:00Z",
718
+ event: "e1",
719
+ level: "info",
720
+ agentName: "a",
721
+ data: {},
722
+ });
723
+ const line2 = JSON.stringify({
724
+ timestamp: "2026-01-01T10:01:00Z",
725
+ event: "e2",
726
+ level: "info",
727
+ agentName: "a",
728
+ data: {},
729
+ });
730
+ await writeFile(filePath, `${line1}\n${line2}\n`);
731
+
732
+ const lastKnownSizes = new Map<string, number>();
733
+ // Capture stdout to prevent test noise
734
+ const origWrite = process.stdout.write;
735
+ process.stdout.write = (() => true) as typeof process.stdout.write;
736
+ try {
737
+ const count = await pollLogTick([{ path: filePath }], lastKnownSizes, {});
738
+ expect(count).toBe(2);
739
+ // lastKnownSizes should be updated
740
+ expect(lastKnownSizes.get(filePath)).toBeGreaterThan(0);
741
+ } finally {
742
+ process.stdout.write = origWrite;
743
+ }
744
+ });
745
+
746
+ test("returns 0 when no new data since last position", async () => {
747
+ const filePath = join(tmpDir, "events.ndjson");
748
+ const line = JSON.stringify({
749
+ timestamp: "2026-01-01T10:00:00Z",
750
+ event: "e1",
751
+ level: "info",
752
+ agentName: "a",
753
+ data: {},
754
+ });
755
+ await writeFile(filePath, `${line}\n`);
756
+
757
+ const lastKnownSizes = new Map<string, number>();
758
+ const origWrite = process.stdout.write;
759
+ process.stdout.write = (() => true) as typeof process.stdout.write;
760
+ try {
761
+ // First tick reads everything
762
+ await pollLogTick([{ path: filePath }], lastKnownSizes, {});
763
+ // Second tick should find nothing new
764
+ const count = await pollLogTick([{ path: filePath }], lastKnownSizes, {});
765
+ expect(count).toBe(0);
766
+ } finally {
767
+ process.stdout.write = origWrite;
768
+ }
769
+ });
770
+
771
+ test("applies level filter", async () => {
772
+ const filePath = join(tmpDir, "events.ndjson");
773
+ const line1 = JSON.stringify({
774
+ timestamp: "2026-01-01T10:00:00Z",
775
+ event: "e1",
776
+ level: "info",
777
+ agentName: "a",
778
+ data: {},
779
+ });
780
+ const line2 = JSON.stringify({
781
+ timestamp: "2026-01-01T10:01:00Z",
782
+ event: "e2",
783
+ level: "error",
784
+ agentName: "a",
785
+ data: {},
786
+ });
787
+ await writeFile(filePath, `${line1}\n${line2}\n`);
788
+
789
+ const lastKnownSizes = new Map<string, number>();
790
+ const origWrite = process.stdout.write;
791
+ process.stdout.write = (() => true) as typeof process.stdout.write;
792
+ try {
793
+ const count = await pollLogTick([{ path: filePath }], lastKnownSizes, {
794
+ level: "error",
795
+ });
796
+ expect(count).toBe(1);
797
+ } finally {
798
+ process.stdout.write = origWrite;
799
+ }
800
+ });
801
+ });