@os-eco/overstory-cli 0.7.9 → 0.8.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 (42) hide show
  1. package/README.md +16 -7
  2. package/agents/coordinator.md +41 -0
  3. package/agents/orchestrator.md +239 -0
  4. package/package.json +1 -1
  5. package/src/agents/guard-rules.test.ts +372 -0
  6. package/src/commands/coordinator.test.ts +334 -0
  7. package/src/commands/coordinator.ts +366 -0
  8. package/src/commands/dashboard.test.ts +86 -0
  9. package/src/commands/dashboard.ts +8 -4
  10. package/src/commands/feed.test.ts +8 -0
  11. package/src/commands/init.test.ts +2 -1
  12. package/src/commands/init.ts +2 -2
  13. package/src/commands/inspect.test.ts +156 -1
  14. package/src/commands/inspect.ts +19 -4
  15. package/src/commands/replay.test.ts +8 -0
  16. package/src/commands/sling.ts +218 -121
  17. package/src/commands/status.test.ts +77 -0
  18. package/src/commands/status.ts +6 -3
  19. package/src/commands/stop.test.ts +134 -0
  20. package/src/commands/stop.ts +41 -11
  21. package/src/commands/trace.test.ts +8 -0
  22. package/src/commands/update.test.ts +465 -0
  23. package/src/commands/update.ts +263 -0
  24. package/src/config.test.ts +65 -1
  25. package/src/config.ts +23 -0
  26. package/src/e2e/init-sling-lifecycle.test.ts +3 -2
  27. package/src/index.ts +21 -2
  28. package/src/logging/theme.ts +4 -0
  29. package/src/runtimes/connections.test.ts +74 -0
  30. package/src/runtimes/connections.ts +34 -0
  31. package/src/runtimes/registry.test.ts +1 -1
  32. package/src/runtimes/registry.ts +2 -0
  33. package/src/runtimes/sapling.test.ts +1237 -0
  34. package/src/runtimes/sapling.ts +698 -0
  35. package/src/runtimes/types.ts +45 -0
  36. package/src/types.ts +5 -1
  37. package/src/watchdog/daemon.ts +34 -0
  38. package/src/watchdog/health.test.ts +102 -0
  39. package/src/watchdog/health.ts +140 -69
  40. package/src/worktree/process.test.ts +101 -0
  41. package/src/worktree/process.ts +111 -0
  42. package/src/worktree/tmux.ts +5 -0
@@ -21,6 +21,8 @@ import { loadConfig } from "../config.ts";
21
21
  import { AgentError, ValidationError } from "../errors.ts";
22
22
  import { jsonOutput } from "../json.ts";
23
23
  import { printHint, printSuccess, printWarning } from "../logging/color.ts";
24
+ import { createMailClient } from "../mail/client.ts";
25
+ import { createMailStore } from "../mail/store.ts";
24
26
  import { getRuntime } from "../runtimes/registry.ts";
25
27
  import { openSessionStore } from "../sessions/compat.ts";
26
28
  import { createRunStore } from "../sessions/store.ts";
@@ -29,6 +31,7 @@ import type { AgentSession } from "../types.ts";
29
31
  import { isProcessRunning } from "../watchdog/health.ts";
30
32
  import type { SessionState } from "../worktree/tmux.ts";
31
33
  import {
34
+ capturePaneContent,
32
35
  checkSessionState,
33
36
  createSession,
34
37
  ensureTmuxAvailable,
@@ -37,11 +40,18 @@ import {
37
40
  sendKeys,
38
41
  waitForTuiReady,
39
42
  } from "../worktree/tmux.ts";
43
+ import { nudgeAgent } from "./nudge.ts";
40
44
  import { isRunningAsRoot } from "./sling.ts";
41
45
 
42
46
  /** Default coordinator agent name. */
43
47
  const COORDINATOR_NAME = "coordinator";
44
48
 
49
+ /** Poll interval for the ask subcommand reply loop. */
50
+ const ASK_POLL_INTERVAL_MS = 2_000;
51
+
52
+ /** Default timeout in seconds for the ask subcommand. */
53
+ const ASK_DEFAULT_TIMEOUT_S = 120;
54
+
45
55
  /**
46
56
  * Build the tmux session name for the coordinator.
47
57
  * Includes the project name to prevent cross-project collisions (overstory-pcef).
@@ -81,6 +91,15 @@ export interface CoordinatorDeps {
81
91
  stop: () => Promise<boolean>;
82
92
  isRunning: () => Promise<boolean>;
83
93
  };
94
+ _nudge?: (
95
+ projectRoot: string,
96
+ agentName: string,
97
+ message: string,
98
+ force: boolean,
99
+ ) => Promise<{ delivered: boolean; reason?: string }>;
100
+ _capturePaneContent?: (name: string, lines?: number) => Promise<string | null>;
101
+ /** Override poll interval for ask subcommand (default: ASK_POLL_INTERVAL_MS). Used in tests. */
102
+ _pollIntervalMs?: number;
84
103
  }
85
104
 
86
105
  /**
@@ -738,6 +757,303 @@ async function statusCoordinator(
738
757
  }
739
758
  }
740
759
 
760
+ /**
761
+ * Send a fire-and-forget message to the running coordinator.
762
+ *
763
+ * Sends a mail message (from: operator, type: dispatch) and auto-nudges the
764
+ * coordinator via tmux sendKeys. Replaces the two-step `ov mail send + ov nudge` pattern.
765
+ */
766
+ async function sendToCoordinator(
767
+ body: string,
768
+ opts: { subject: string; json: boolean },
769
+ deps: CoordinatorDeps = {},
770
+ ): Promise<void> {
771
+ const tmux = deps._tmux ?? {
772
+ createSession,
773
+ isSessionAlive,
774
+ checkSessionState,
775
+ killSession,
776
+ sendKeys,
777
+ waitForTuiReady,
778
+ ensureTmuxAvailable,
779
+ };
780
+ const nudge = deps._nudge ?? nudgeAgent;
781
+
782
+ const { subject, json } = opts;
783
+ const cwd = process.cwd();
784
+ const config = await loadConfig(cwd);
785
+ const projectRoot = config.project.root;
786
+
787
+ const overstoryDir = join(projectRoot, ".overstory");
788
+ const { store } = openSessionStore(overstoryDir);
789
+ try {
790
+ const session = store.getByName(COORDINATOR_NAME);
791
+
792
+ if (
793
+ !session ||
794
+ session.capability !== "coordinator" ||
795
+ session.state === "completed" ||
796
+ session.state === "zombie"
797
+ ) {
798
+ throw new AgentError("No active coordinator session found", {
799
+ agentName: COORDINATOR_NAME,
800
+ });
801
+ }
802
+
803
+ const alive = await tmux.isSessionAlive(session.tmuxSession);
804
+ if (!alive) {
805
+ store.updateState(COORDINATOR_NAME, "zombie");
806
+ store.updateLastActivity(COORDINATOR_NAME);
807
+ throw new AgentError(`Coordinator tmux session "${session.tmuxSession}" is not alive`, {
808
+ agentName: COORDINATOR_NAME,
809
+ });
810
+ }
811
+
812
+ // Send mail
813
+ const mailDbPath = join(overstoryDir, "mail.db");
814
+ const mailStore = createMailStore(mailDbPath);
815
+ const mailClient = createMailClient(mailStore);
816
+ let id: string;
817
+ try {
818
+ id = mailClient.send({
819
+ from: "operator",
820
+ to: COORDINATOR_NAME,
821
+ subject,
822
+ body,
823
+ type: "dispatch",
824
+ priority: "normal",
825
+ });
826
+ } finally {
827
+ mailClient.close();
828
+ }
829
+
830
+ // Auto-nudge (fire-and-forget)
831
+ const nudgeMessage = `[DISPATCH] ${subject}: ${body.slice(0, 500)}`;
832
+ let nudged = false;
833
+ try {
834
+ const nudgeResult = await nudge(projectRoot, COORDINATOR_NAME, nudgeMessage, true);
835
+ nudged = nudgeResult.delivered;
836
+ } catch {
837
+ // Nudge is fire-and-forget — silently ignore errors
838
+ }
839
+
840
+ if (json) {
841
+ jsonOutput("coordinator send", { id, nudged });
842
+ } else {
843
+ printSuccess("Sent to coordinator", id);
844
+ }
845
+ } finally {
846
+ store.close();
847
+ }
848
+ }
849
+
850
+ /**
851
+ * Send a synchronous request to the coordinator and wait for a reply.
852
+ *
853
+ * Sends a mail message (from: operator, type: dispatch) with a correlationId,
854
+ * auto-nudges the coordinator via tmux, then polls mail.db for a reply in the
855
+ * same thread. Prints the reply body (or structured JSON) and exits.
856
+ * Throws AgentError if no reply arrives before the timeout.
857
+ */
858
+ export async function askCoordinator(
859
+ body: string,
860
+ opts: { subject: string; timeout: number; json: boolean },
861
+ deps: CoordinatorDeps = {},
862
+ ): Promise<void> {
863
+ const tmux = deps._tmux ?? {
864
+ createSession,
865
+ isSessionAlive,
866
+ checkSessionState,
867
+ killSession,
868
+ sendKeys,
869
+ waitForTuiReady,
870
+ ensureTmuxAvailable,
871
+ };
872
+ const nudge = deps._nudge ?? nudgeAgent;
873
+ const pollIntervalMs = deps._pollIntervalMs ?? ASK_POLL_INTERVAL_MS;
874
+
875
+ const { subject, timeout, json } = opts;
876
+ const cwd = process.cwd();
877
+ const config = await loadConfig(cwd);
878
+ const projectRoot = config.project.root;
879
+
880
+ const overstoryDir = join(projectRoot, ".overstory");
881
+ const { store } = openSessionStore(overstoryDir);
882
+ try {
883
+ const session = store.getByName(COORDINATOR_NAME);
884
+
885
+ if (
886
+ !session ||
887
+ session.capability !== "coordinator" ||
888
+ session.state === "completed" ||
889
+ session.state === "zombie"
890
+ ) {
891
+ throw new AgentError("No active coordinator session found", {
892
+ agentName: COORDINATOR_NAME,
893
+ });
894
+ }
895
+
896
+ const alive = await tmux.isSessionAlive(session.tmuxSession);
897
+ if (!alive) {
898
+ store.updateState(COORDINATOR_NAME, "zombie");
899
+ store.updateLastActivity(COORDINATOR_NAME);
900
+ throw new AgentError(`Coordinator tmux session "${session.tmuxSession}" is not alive`, {
901
+ agentName: COORDINATOR_NAME,
902
+ });
903
+ }
904
+
905
+ // Generate correlation ID for tracking this request/response pair
906
+ const correlationId = crypto.randomUUID();
907
+
908
+ // Send mail with correlationId in payload
909
+ const mailDbPath = join(overstoryDir, "mail.db");
910
+ const mailStore = createMailStore(mailDbPath);
911
+ const mailClient = createMailClient(mailStore);
912
+ let sentId: string;
913
+ try {
914
+ sentId = mailClient.send({
915
+ from: "operator",
916
+ to: COORDINATOR_NAME,
917
+ subject,
918
+ body,
919
+ type: "dispatch",
920
+ priority: "normal",
921
+ payload: JSON.stringify({ correlationId }),
922
+ });
923
+ } finally {
924
+ mailClient.close();
925
+ }
926
+
927
+ // Auto-nudge (fire-and-forget)
928
+ const nudgeMessage = `[ASK] ${subject}: ${body.slice(0, 500)}`;
929
+ try {
930
+ await nudge(projectRoot, COORDINATOR_NAME, nudgeMessage, true);
931
+ } catch {
932
+ // Nudge is fire-and-forget — silently ignore errors
933
+ }
934
+
935
+ // Poll for a reply in the same thread
936
+ const deadline = Date.now() + timeout * 1000;
937
+ while (Date.now() < deadline) {
938
+ await Bun.sleep(pollIntervalMs);
939
+ // Open a fresh store connection each cycle so we see the latest committed writes
940
+ const pollStore = createMailStore(mailDbPath);
941
+ let reply: import("../types.ts").MailMessage | undefined;
942
+ try {
943
+ const replies = pollStore.getByThread(sentId);
944
+ reply = replies.find((m) => m.from === COORDINATOR_NAME && m.to === "operator");
945
+ } finally {
946
+ pollStore.close();
947
+ }
948
+ if (reply) {
949
+ if (json) {
950
+ jsonOutput("coordinator ask", {
951
+ correlationId,
952
+ sentId,
953
+ replyId: reply.id,
954
+ subject: reply.subject,
955
+ body: reply.body,
956
+ payload: reply.payload,
957
+ });
958
+ } else {
959
+ process.stdout.write(`${reply.body}\n`);
960
+ }
961
+ return;
962
+ }
963
+ }
964
+
965
+ throw new AgentError(
966
+ `Timed out after ${timeout}s waiting for coordinator reply (correlationId: ${correlationId})`,
967
+ { agentName: COORDINATOR_NAME },
968
+ );
969
+ } finally {
970
+ store.close();
971
+ }
972
+ }
973
+
974
+ /**
975
+ * Show recent coordinator tmux pane content without attaching.
976
+ *
977
+ * Wraps capturePaneContent() from tmux.ts. Supports --follow for continuous polling.
978
+ */
979
+ async function outputCoordinator(
980
+ opts: { follow: boolean; lines: number; interval: number; json: boolean },
981
+ deps: CoordinatorDeps = {},
982
+ ): Promise<void> {
983
+ const tmux = deps._tmux ?? {
984
+ createSession,
985
+ isSessionAlive,
986
+ checkSessionState,
987
+ killSession,
988
+ sendKeys,
989
+ waitForTuiReady,
990
+ ensureTmuxAvailable,
991
+ };
992
+ const capturePane = deps._capturePaneContent ?? capturePaneContent;
993
+
994
+ const { follow, lines, interval, json } = opts;
995
+ const cwd = process.cwd();
996
+ const config = await loadConfig(cwd);
997
+ const projectRoot = config.project.root;
998
+
999
+ const overstoryDir = join(projectRoot, ".overstory");
1000
+ const { store } = openSessionStore(overstoryDir);
1001
+ try {
1002
+ const session = store.getByName(COORDINATOR_NAME);
1003
+
1004
+ if (
1005
+ !session ||
1006
+ session.capability !== "coordinator" ||
1007
+ session.state === "completed" ||
1008
+ session.state === "zombie"
1009
+ ) {
1010
+ throw new AgentError("No active coordinator session found", {
1011
+ agentName: COORDINATOR_NAME,
1012
+ });
1013
+ }
1014
+
1015
+ const alive = await tmux.isSessionAlive(session.tmuxSession);
1016
+ if (!alive) {
1017
+ store.updateState(COORDINATOR_NAME, "zombie");
1018
+ store.updateLastActivity(COORDINATOR_NAME);
1019
+ throw new AgentError(`Coordinator tmux session "${session.tmuxSession}" is not alive`, {
1020
+ agentName: COORDINATOR_NAME,
1021
+ });
1022
+ }
1023
+
1024
+ const tmuxSession = session.tmuxSession;
1025
+
1026
+ if (follow) {
1027
+ // Set up SIGINT handler for clean exit
1028
+ let running = true;
1029
+ process.once("SIGINT", () => {
1030
+ running = false;
1031
+ });
1032
+
1033
+ while (running) {
1034
+ const content = await capturePane(tmuxSession, lines);
1035
+ if (json) {
1036
+ jsonOutput("coordinator output", { content, lines });
1037
+ } else {
1038
+ process.stdout.write(content ?? "");
1039
+ }
1040
+ if (running) {
1041
+ await Bun.sleep(interval);
1042
+ }
1043
+ }
1044
+ } else {
1045
+ const content = await capturePane(tmuxSession, lines);
1046
+ if (json) {
1047
+ jsonOutput("coordinator output", { content, lines });
1048
+ } else {
1049
+ process.stdout.write(content ?? "");
1050
+ }
1051
+ }
1052
+ } finally {
1053
+ store.close();
1054
+ }
1055
+ }
1056
+
741
1057
  /**
742
1058
  * Create the Commander command for `ov coordinator`.
743
1059
  */
@@ -784,6 +1100,56 @@ export function createCoordinatorCommand(deps: CoordinatorDeps = {}): Command {
784
1100
  await statusCoordinator({ json: opts.json ?? false }, deps);
785
1101
  });
786
1102
 
1103
+ cmd
1104
+ .command("send")
1105
+ .description("Send a message to the coordinator (fire-and-forget)")
1106
+ .requiredOption("--body <text>", "Message body")
1107
+ .option("--subject <text>", "Message subject", "operator dispatch")
1108
+ .option("--json", "Output as JSON")
1109
+ .action(async (opts: { body: string; subject: string; json?: boolean }) => {
1110
+ await sendToCoordinator(opts.body, { subject: opts.subject, json: opts.json ?? false }, deps);
1111
+ });
1112
+
1113
+ cmd
1114
+ .command("ask")
1115
+ .description("Send a request to the coordinator and wait for a reply")
1116
+ .requiredOption("--body <text>", "Message body")
1117
+ .option("--subject <text>", "Message subject", "operator request")
1118
+ .option("--timeout <seconds>", "Timeout in seconds", String(ASK_DEFAULT_TIMEOUT_S))
1119
+ .option("--json", "Output as JSON")
1120
+ .action(async (opts: { body: string; subject: string; timeout?: string; json?: boolean }) => {
1121
+ await askCoordinator(
1122
+ opts.body,
1123
+ {
1124
+ subject: opts.subject,
1125
+ timeout: Number.parseInt(opts.timeout ?? String(ASK_DEFAULT_TIMEOUT_S), 10),
1126
+ json: opts.json ?? false,
1127
+ },
1128
+ deps,
1129
+ );
1130
+ });
1131
+
1132
+ cmd
1133
+ .command("output")
1134
+ .description("Show recent coordinator output (tmux pane content)")
1135
+ .option("--follow, -f", "Continuously poll for new output")
1136
+ .option("--lines <n>", "Number of lines to capture", "50")
1137
+ .option("--interval <ms>", "Poll interval in milliseconds (with --follow)", "2000")
1138
+ .option("--json", "Output as JSON")
1139
+ .action(
1140
+ async (opts: { follow?: boolean; lines?: string; interval?: string; json?: boolean }) => {
1141
+ await outputCoordinator(
1142
+ {
1143
+ follow: opts.follow ?? false,
1144
+ lines: Number.parseInt(opts.lines ?? "50", 10),
1145
+ interval: Number.parseInt(opts.interval ?? "2000", 10),
1146
+ json: opts.json ?? false,
1147
+ },
1148
+ deps,
1149
+ );
1150
+ },
1151
+ );
1152
+
787
1153
  return cmd;
788
1154
  }
789
1155
 
@@ -409,6 +409,92 @@ describe("renderAgentPanel", () => {
409
409
  // dimBox.vertical is a dimmed ANSI string — present in output
410
410
  expect(out).toContain(dimBox.vertical);
411
411
  });
412
+
413
+ test("renders Live column header (not Tmux)", () => {
414
+ const data = makeDashboardData({});
415
+ const out = renderAgentPanel(data, 100, 12, 3);
416
+ expect(out).toContain("Live");
417
+ expect(out).not.toContain("Tmux");
418
+ });
419
+
420
+ test("shows green dot for headless agent with alive PID", () => {
421
+ const alivePid = process.pid; // own PID — guaranteed alive
422
+ const data = {
423
+ ...makeDashboardData({}),
424
+ status: {
425
+ currentRunId: null,
426
+ agents: [
427
+ {
428
+ id: "sess-h1",
429
+ agentName: "headless-worker",
430
+ capability: "builder",
431
+ worktreePath: "/tmp/wt/headless",
432
+ branchName: "overstory/headless/task-1",
433
+ taskId: "task-h1",
434
+ tmuxSession: "", // headless
435
+ state: "working" as const,
436
+ pid: alivePid,
437
+ parentAgent: null,
438
+ depth: 0,
439
+ runId: null,
440
+ startedAt: new Date(Date.now() - 10_000).toISOString(),
441
+ lastActivity: new Date().toISOString(),
442
+ escalationLevel: 0,
443
+ stalledSince: null,
444
+ transcriptPath: null,
445
+ },
446
+ ],
447
+ worktrees: [],
448
+ tmuxSessions: [], // no tmux sessions
449
+ unreadMailCount: 0,
450
+ mergeQueueCount: 0,
451
+ recentMetricsCount: 0,
452
+ },
453
+ };
454
+ const out = renderAgentPanel(data, 100, 12, 3);
455
+ // Green ">" for alive headless agent
456
+ expect(out).toContain(">");
457
+ expect(out).toContain("headless-worker");
458
+ });
459
+
460
+ test("shows red dot for headless agent with dead PID", () => {
461
+ const deadPid = 2_147_483_647;
462
+ const data = {
463
+ ...makeDashboardData({}),
464
+ status: {
465
+ currentRunId: null,
466
+ agents: [
467
+ {
468
+ id: "sess-h2",
469
+ agentName: "dead-headless", // short enough to not be truncated
470
+ capability: "builder",
471
+ worktreePath: "/tmp/wt/dead-headless",
472
+ branchName: "overstory/dead-headless/task-2",
473
+ taskId: "task-h2",
474
+ tmuxSession: "", // headless
475
+ state: "working" as const,
476
+ pid: deadPid,
477
+ parentAgent: null,
478
+ depth: 0,
479
+ runId: null,
480
+ startedAt: new Date(Date.now() - 10_000).toISOString(),
481
+ lastActivity: new Date().toISOString(),
482
+ escalationLevel: 0,
483
+ stalledSince: null,
484
+ transcriptPath: null,
485
+ },
486
+ ],
487
+ worktrees: [],
488
+ tmuxSessions: [],
489
+ unreadMailCount: 0,
490
+ mergeQueueCount: 0,
491
+ recentMetricsCount: 0,
492
+ },
493
+ };
494
+ const out = renderAgentPanel(data, 100, 12, 3);
495
+ expect(out).toContain("x");
496
+ expect(out).toContain("dead-headless");
497
+ });
412
498
  });
413
499
 
414
500
  describe("openDashboardStores", () => {
@@ -42,6 +42,7 @@ import { createTrackerClient, resolveBackend } from "../tracker/factory.ts";
42
42
  import type { TrackerIssue } from "../tracker/types.ts";
43
43
  import type { EventStore, MailMessage, StoredEvent } from "../types.ts";
44
44
  import { evaluateHealth } from "../watchdog/health.ts";
45
+ import { isProcessAlive } from "../worktree/tmux.ts";
45
46
  import { getCachedTmuxSessions, getCachedWorktrees, type StatusData } from "./status.ts";
46
47
 
47
48
  const pkgPath = resolve(import.meta.dir, "../../package.json");
@@ -555,7 +556,7 @@ export function renderAgentPanel(
555
556
  output += `${CURSOR.cursorTo(startRow, 1)}${headerLine}${headerPadding}${dimBox.vertical}\n`;
556
557
 
557
558
  // Column headers
558
- const colStr = `${dimBox.vertical} St Name Capability State Task ID Duration Tmux `;
559
+ const colStr = `${dimBox.vertical} St Name Capability State Task ID Duration Live `;
559
560
  const colPadding = " ".repeat(
560
561
  Math.max(0, leftWidth - visibleLength(colStr) - visibleLength(dimBox.vertical)),
561
562
  );
@@ -595,10 +596,13 @@ export function renderAgentPanel(
595
596
  : now;
596
597
  const duration = formatDuration(endTime - new Date(agent.startedAt).getTime());
597
598
  const durationPadded = pad(duration, 9);
598
- const tmuxAlive = data.status.tmuxSessions.some((s) => s.name === agent.tmuxSession);
599
- const tmuxDot = tmuxAlive ? color.green(">") : color.red("x");
599
+ const isHeadless = agent.tmuxSession === "" && agent.pid !== null;
600
+ const alive = isHeadless
601
+ ? agent.pid !== null && isProcessAlive(agent.pid)
602
+ : data.status.tmuxSessions.some((s) => s.name === agent.tmuxSession);
603
+ const aliveDot = alive ? color.green(">") : color.red("x");
600
604
 
601
- const lineContent = `${dimBox.vertical} ${stateColorFn(icon)} ${name} ${capability} ${stateColorFn(state)} ${taskId} ${durationPadded} ${tmuxDot} `;
605
+ const lineContent = `${dimBox.vertical} ${stateColorFn(icon)} ${name} ${capability} ${stateColorFn(state)} ${taskId} ${durationPadded} ${aliveDot} `;
602
606
  const linePadding = " ".repeat(
603
607
  Math.max(0, leftWidth - visibleLength(lineContent) - visibleLength(dimBox.vertical)),
604
608
  );
@@ -469,6 +469,10 @@ describe("feedCommand", () => {
469
469
  "spawn",
470
470
  "error",
471
471
  "custom",
472
+ "turn_start",
473
+ "turn_end",
474
+ "progress",
475
+ "result",
472
476
  ] as const;
473
477
  for (const eventType of eventTypes) {
474
478
  store.insert(
@@ -494,6 +498,10 @@ describe("feedCommand", () => {
494
498
  expect(out).toContain("SPAWN");
495
499
  expect(out).toContain("ERROR");
496
500
  expect(out).toContain("CUSTM");
501
+ expect(out).toContain("TURN+");
502
+ expect(out).toContain("TURN-");
503
+ expect(out).toContain("PROG ");
504
+ expect(out).toContain("RSULT");
497
505
  });
498
506
  });
499
507
 
@@ -26,6 +26,7 @@ const AGENT_DEF_FILES = [
26
26
  "merger.md",
27
27
  "coordinator.md",
28
28
  "monitor.md",
29
+ "orchestrator.md",
29
30
  ];
30
31
 
31
32
  /** Resolve the source agents directory (same logic as init.ts). */
@@ -52,7 +53,7 @@ describe("initCommand: agent-defs deployment", () => {
52
53
  await cleanupTempDir(tempDir);
53
54
  });
54
55
 
55
- test("creates .overstory/agent-defs/ with all 7 agent definition files (supervisor deprecated)", async () => {
56
+ test("creates .overstory/agent-defs/ with all 8 agent definition files (supervisor deprecated)", async () => {
56
57
  await initCommand({ _spawner: noopSpawner });
57
58
 
58
59
  const agentDefsDir = join(tempDir, ".overstory", "agent-defs");
@@ -324,7 +324,7 @@ function formatYamlValue(value: unknown): string {
324
324
  /**
325
325
  * Build the starter agent manifest.
326
326
  */
327
- function buildAgentManifest(): AgentManifest {
327
+ export function buildAgentManifest(): AgentManifest {
328
328
  const agents: AgentManifest["agents"] = {
329
329
  scout: {
330
330
  file: "scout.md",
@@ -407,7 +407,7 @@ function buildAgentManifest(): AgentManifest {
407
407
  * {{AGENT_NAME}} placeholders and space indentation). Uses tab indentation
408
408
  * to match Biome formatting rules.
409
409
  */
410
- function buildHooksJson(): string {
410
+ export function buildHooksJson(): string {
411
411
  // Tool name extraction: reads hook stdin JSON and extracts tool_name field.
412
412
  // Claude Code sends {"tool_name":"Bash","tool_input":{...}} on stdin for
413
413
  // PreToolUse/PostToolUse hooks.