@os-eco/overstory-cli 0.9.4 → 0.11.0

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 (124) hide show
  1. package/README.md +50 -19
  2. package/agents/builder.md +19 -9
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +204 -87
  5. package/agents/merger.md +25 -14
  6. package/agents/reviewer.md +22 -16
  7. package/agents/scout.md +17 -12
  8. package/package.json +6 -3
  9. package/src/agents/capabilities.test.ts +85 -0
  10. package/src/agents/capabilities.ts +125 -0
  11. package/src/agents/headless-mail-injector.test.ts +448 -0
  12. package/src/agents/headless-mail-injector.ts +219 -0
  13. package/src/agents/headless-prompt.test.ts +102 -0
  14. package/src/agents/headless-prompt.ts +68 -0
  15. package/src/agents/hooks-deployer.test.ts +514 -14
  16. package/src/agents/hooks-deployer.ts +141 -0
  17. package/src/agents/mail-poll-detect.test.ts +153 -0
  18. package/src/agents/mail-poll-detect.ts +73 -0
  19. package/src/agents/overlay.test.ts +60 -4
  20. package/src/agents/overlay.ts +63 -8
  21. package/src/agents/scope-detect.test.ts +190 -0
  22. package/src/agents/scope-detect.ts +146 -0
  23. package/src/agents/turn-lock.test.ts +181 -0
  24. package/src/agents/turn-lock.ts +235 -0
  25. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  26. package/src/agents/turn-runner-dispatch.ts +105 -0
  27. package/src/agents/turn-runner.test.ts +2312 -0
  28. package/src/agents/turn-runner.ts +1383 -0
  29. package/src/commands/agents.ts +9 -0
  30. package/src/commands/clean.ts +54 -0
  31. package/src/commands/coordinator.test.ts +254 -0
  32. package/src/commands/coordinator.ts +273 -8
  33. package/src/commands/dashboard.test.ts +188 -0
  34. package/src/commands/dashboard.ts +14 -4
  35. package/src/commands/doctor.ts +3 -1
  36. package/src/commands/group.test.ts +94 -0
  37. package/src/commands/group.ts +49 -20
  38. package/src/commands/init.test.ts +8 -0
  39. package/src/commands/init.ts +8 -1
  40. package/src/commands/log.test.ts +187 -11
  41. package/src/commands/log.ts +171 -71
  42. package/src/commands/mail.test.ts +162 -0
  43. package/src/commands/mail.ts +64 -9
  44. package/src/commands/merge.test.ts +230 -1
  45. package/src/commands/merge.ts +68 -12
  46. package/src/commands/nudge.test.ts +351 -4
  47. package/src/commands/nudge.ts +356 -34
  48. package/src/commands/run.test.ts +43 -7
  49. package/src/commands/serve/build.test.ts +202 -0
  50. package/src/commands/serve/build.ts +206 -0
  51. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  52. package/src/commands/serve/coordinator-actions.ts +408 -0
  53. package/src/commands/serve/dev.test.ts +168 -0
  54. package/src/commands/serve/dev.ts +117 -0
  55. package/src/commands/serve/mail-actions.test.ts +312 -0
  56. package/src/commands/serve/mail-actions.ts +167 -0
  57. package/src/commands/serve/rest.test.ts +1323 -0
  58. package/src/commands/serve/rest.ts +708 -0
  59. package/src/commands/serve/static.ts +51 -0
  60. package/src/commands/serve/ws.test.ts +361 -0
  61. package/src/commands/serve/ws.ts +332 -0
  62. package/src/commands/serve.test.ts +459 -0
  63. package/src/commands/serve.ts +565 -0
  64. package/src/commands/sling.test.ts +177 -1
  65. package/src/commands/sling.ts +243 -71
  66. package/src/commands/status.test.ts +9 -0
  67. package/src/commands/status.ts +12 -4
  68. package/src/commands/stop.test.ts +255 -1
  69. package/src/commands/stop.ts +107 -8
  70. package/src/commands/watch.test.ts +43 -0
  71. package/src/commands/watch.ts +153 -28
  72. package/src/config.ts +23 -0
  73. package/src/doctor/consistency.test.ts +106 -0
  74. package/src/doctor/consistency.ts +48 -1
  75. package/src/doctor/serve.test.ts +95 -0
  76. package/src/doctor/serve.ts +86 -0
  77. package/src/doctor/types.ts +2 -1
  78. package/src/doctor/watchdog.ts +57 -1
  79. package/src/events/tailer.test.ts +234 -1
  80. package/src/events/tailer.ts +90 -0
  81. package/src/index.ts +57 -6
  82. package/src/insights/quality-gates.test.ts +141 -0
  83. package/src/insights/quality-gates.ts +156 -0
  84. package/src/json.ts +29 -0
  85. package/src/logging/theme.ts +4 -0
  86. package/src/mail/client.ts +15 -2
  87. package/src/mail/store.test.ts +82 -0
  88. package/src/mail/store.ts +41 -4
  89. package/src/merge/lock.test.ts +149 -0
  90. package/src/merge/lock.ts +140 -0
  91. package/src/merge/predict.test.ts +387 -0
  92. package/src/merge/predict.ts +249 -0
  93. package/src/merge/resolver.ts +1 -1
  94. package/src/mulch/client.ts +3 -3
  95. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  96. package/src/runtimes/claude.test.ts +791 -1
  97. package/src/runtimes/claude.ts +323 -1
  98. package/src/runtimes/connections.test.ts +141 -1
  99. package/src/runtimes/connections.ts +73 -4
  100. package/src/runtimes/headless-connection.test.ts +264 -0
  101. package/src/runtimes/headless-connection.ts +158 -0
  102. package/src/runtimes/types.ts +10 -0
  103. package/src/schema-consistency.test.ts +1 -0
  104. package/src/sessions/store.test.ts +657 -29
  105. package/src/sessions/store.ts +286 -23
  106. package/src/test-setup.test.ts +31 -0
  107. package/src/test-setup.ts +28 -0
  108. package/src/types.ts +107 -2
  109. package/src/utils/pid.test.ts +85 -1
  110. package/src/utils/pid.ts +86 -1
  111. package/src/utils/process-scan.test.ts +53 -0
  112. package/src/utils/process-scan.ts +76 -0
  113. package/src/watchdog/daemon.test.ts +1607 -376
  114. package/src/watchdog/daemon.ts +462 -88
  115. package/src/watchdog/health.test.ts +282 -0
  116. package/src/watchdog/health.ts +126 -27
  117. package/src/worktree/manager.test.ts +218 -1
  118. package/src/worktree/manager.ts +55 -0
  119. package/src/worktree/process.test.ts +71 -0
  120. package/src/worktree/process.ts +25 -5
  121. package/src/worktree/tmux.test.ts +28 -0
  122. package/src/worktree/tmux.ts +27 -3
  123. package/templates/CLAUDE.md.tmpl +19 -8
  124. package/templates/overlay.md.tmpl +5 -2
@@ -12,12 +12,14 @@
12
12
 
13
13
  import { join } from "node:path";
14
14
  import { Command } from "commander";
15
+ import { isStopHookPersistentCapability } from "../agents/capabilities.ts";
15
16
  import { updateIdentity } from "../agents/identity.ts";
16
17
  import { loadConfig } from "../config.ts";
17
18
  import { ValidationError } from "../errors.ts";
18
19
  import { createEventStore } from "../events/store.ts";
19
20
  import { filterToolArgs } from "../events/tool-filter.ts";
20
21
  import { analyzeSessionInsights } from "../insights/analyzer.ts";
22
+ import { hasWorkToVerify, runQualityGates } from "../insights/quality-gates.ts";
21
23
  import { createLogger } from "../logging/logger.ts";
22
24
  import { createMailClient } from "../mail/client.ts";
23
25
  import { createMailStore } from "../mail/store.ts";
@@ -66,8 +68,12 @@ function updateLastActivity(projectRoot: string, agentName: string): void {
66
68
  const session = store.getByName(agentName);
67
69
  if (session) {
68
70
  store.updateLastActivity(agentName);
69
- if (session.state === "booting" || session.state === "zombie") {
70
- store.updateState(agentName, "working");
71
+ // Tool-use observed: try booting working. Matrix-guarded so a
72
+ // zombie classification (set by watchdog) is NOT silently revived
73
+ // here — that revival was a contributor to the schizophrenic
74
+ // state=zombie + tool-use-active symptom in overstory-a993.
75
+ if (session.state === "booting") {
76
+ store.tryTransitionState(agentName, "working");
71
77
  }
72
78
  }
73
79
  } finally {
@@ -79,63 +85,144 @@ function updateLastActivity(projectRoot: string, agentName: string): void {
79
85
  }
80
86
 
81
87
  /**
82
- * Agent capabilities that run as persistent interactive sessions.
83
- * The Stop hook fires every turn for these agents (not just at session end),
84
- * so they must NOT auto-transition to 'completed' on session-end events.
88
+ * Maximum retry attempts for the session-end transition.
89
+ *
90
+ * The Stop hook is the only signal that turns sessions.db state from
91
+ * "working" to "completed" for headless legacy paths and tmux sessions.
92
+ * If it loses that signal due to a transient SQLite contention error
93
+ * (e.g. "database is locked" while the watchdog ticks against the same
94
+ * file), the row stays in "working" forever and the watchdog later
95
+ * promotes it to "zombie". Retrying with exponential backoff lets brief
96
+ * lock contention resolve before we give up. (overstory-e74b)
85
97
  */
86
- const PERSISTENT_CAPABILITIES = new Set(["coordinator", "orchestrator", "monitor"]);
98
+ const TRANSITION_MAX_ATTEMPTS = 5;
99
+ const TRANSITION_BACKOFF_BASE_MS = 50;
87
100
 
88
101
  /**
89
- * Transition agent state to 'completed' in the SessionStore.
90
- * Called when session-end event fires.
91
- *
92
- * Skips the transition for persistent agent types (coordinator, orchestrator, monitor)
93
- * whose Stop hook fires every turn, not just at true session end.
102
+ * One attempt at the session-end state transition.
94
103
  *
95
- * Non-fatal: silently ignores errors to avoid breaking hook execution.
104
+ * Throws on transient failures (e.g. SQLite "database is locked") so the
105
+ * caller can retry. The body is the original logic from
106
+ * `transitionToCompleted`.
96
107
  */
97
- function transitionToCompleted(projectRoot: string, agentName: string): void {
108
+ function transitionToCompletedOnce(projectRoot: string, agentName: string): void {
109
+ const overstoryDir = join(projectRoot, ".overstory");
110
+ const { store } = openSessionStore(overstoryDir);
98
111
  try {
99
- const overstoryDir = join(projectRoot, ".overstory");
100
- const { store } = openSessionStore(overstoryDir);
101
- try {
102
- const session = store.getByName(agentName);
103
- if (session && PERSISTENT_CAPABILITIES.has(session.capability)) {
104
- // Check if a persistent top-level agent self-exited by verifying the run
105
- // is already completed.
106
- // If `ov run complete` was called before session-end, the run status is 'completed'
107
- // and we should transition the persistent session to completed too.
108
- if (
109
- (session.capability === "coordinator" || session.capability === "orchestrator") &&
110
- session.runId
111
- ) {
112
- const runStore = createRunStore(join(overstoryDir, "sessions.db"));
113
- try {
114
- const run = runStore.getRun(session.runId);
115
- if (run && run.status === "completed") {
116
- // Self-exit: the persistent agent called ov run complete before session ended
117
- store.updateState(agentName, "completed");
118
- store.updateLastActivity(agentName);
119
- return;
120
- }
121
- } finally {
122
- runStore.close();
112
+ const session = store.getByName(agentName);
113
+ if (session && isStopHookPersistentCapability(session.capability)) {
114
+ // Check if a persistent top-level agent self-exited by verifying the run
115
+ // is already completed.
116
+ // If `ov run complete` was called before session-end, the run status is 'completed'
117
+ // and we should transition the persistent session to completed too.
118
+ if (
119
+ (session.capability === "coordinator" || session.capability === "orchestrator") &&
120
+ session.runId
121
+ ) {
122
+ const runStore = createRunStore(join(overstoryDir, "sessions.db"));
123
+ try {
124
+ const run = runStore.getRun(session.runId);
125
+ if (run && run.status === "completed") {
126
+ // Self-exit: the persistent agent called ov run complete before session ended
127
+ store.updateState(agentName, "completed");
128
+ store.updateLastActivity(agentName);
129
+ return;
123
130
  }
131
+ } finally {
132
+ runStore.close();
124
133
  }
125
- // Normal persistent agent: only update activity, don't mark completed
126
- store.updateLastActivity(agentName);
127
- return;
128
134
  }
129
- store.updateState(agentName, "completed");
135
+ // Normal persistent agent: only update activity, don't mark completed
130
136
  store.updateLastActivity(agentName);
137
+ return;
138
+ }
139
+ store.updateState(agentName, "completed");
140
+ store.updateLastActivity(agentName);
141
+ } finally {
142
+ store.close();
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Best-effort: log a session-end hook failure to events.db so it surfaces in
148
+ * `ov errors` and trace timelines. Swallows secondary errors (events.db may
149
+ * also be locked when the primary write failed).
150
+ */
151
+ async function logHookFailure(
152
+ projectRoot: string,
153
+ agentName: string,
154
+ hookName: string,
155
+ error: unknown,
156
+ attempts: number,
157
+ ): Promise<void> {
158
+ try {
159
+ const eventsDbPath = join(projectRoot, ".overstory", "events.db");
160
+ const eventStore = createEventStore(eventsDbPath);
161
+ try {
162
+ eventStore.insert({
163
+ runId: null,
164
+ agentName,
165
+ sessionId: null,
166
+ eventType: "error",
167
+ toolName: null,
168
+ toolArgs: null,
169
+ toolDurationMs: null,
170
+ level: "error",
171
+ data: JSON.stringify({
172
+ hook: hookName,
173
+ attempts,
174
+ message: error instanceof Error ? error.message : String(error),
175
+ }),
176
+ });
131
177
  } finally {
132
- store.close();
178
+ eventStore.close();
133
179
  }
134
180
  } catch {
135
- // Non-fatal: don't break logging if session update fails
181
+ // Non-fatal: events.db may also be unavailable when the primary write failed.
136
182
  }
137
183
  }
138
184
 
185
+ /**
186
+ * Transition agent state to 'completed' in the SessionStore.
187
+ * Called when session-end event fires.
188
+ *
189
+ * Retries on transient SQLite contention with exponential backoff
190
+ * (50/100/200/400/800ms). On persistent failure, records an `error` event
191
+ * to events.db so the missed signal shows up in observability tooling and
192
+ * the watchdog's stale-but-tmux-dead fallback can recognize it.
193
+ * (overstory-e74b)
194
+ *
195
+ * Skips the transition for capabilities in `STOP_HOOK_PERSISTENT_CAPABILITIES`
196
+ * (coordinator, orchestrator, monitor, lead) whose Stop hook fires every model
197
+ * turn rather than once at true session end. See
198
+ * `src/agents/capabilities.ts` for the full rationale and consumer list.
199
+ *
200
+ * Non-fatal: silently ignores errors to avoid breaking hook execution.
201
+ */
202
+ async function transitionToCompleted(projectRoot: string, agentName: string): Promise<void> {
203
+ let lastError: unknown;
204
+ for (let attempt = 0; attempt < TRANSITION_MAX_ATTEMPTS; attempt++) {
205
+ try {
206
+ transitionToCompletedOnce(projectRoot, agentName);
207
+ return;
208
+ } catch (err) {
209
+ lastError = err;
210
+ if (attempt < TRANSITION_MAX_ATTEMPTS - 1) {
211
+ await Bun.sleep(TRANSITION_BACKOFF_BASE_MS * 2 ** attempt);
212
+ }
213
+ }
214
+ }
215
+
216
+ // All retries failed — surface the missed signal via events.db.
217
+ await logHookFailure(
218
+ projectRoot,
219
+ agentName,
220
+ "session-end:transitionToCompleted",
221
+ lastError,
222
+ TRANSITION_MAX_ATTEMPTS,
223
+ );
224
+ }
225
+
139
226
  /**
140
227
  * Look up an agent's session record.
141
228
  * Returns null if not found.
@@ -293,6 +380,7 @@ export async function autoRecordExpertise(params: {
293
380
  parentAgent: string | null;
294
381
  projectRoot: string;
295
382
  sessionStartedAt: string;
383
+ outcomeStatus?: "success" | "partial" | "failure";
296
384
  }): Promise<string[]> {
297
385
  const learnResult = await params.mulchClient.learn({ since: "HEAD~1" });
298
386
  if (learnResult.suggestedDomains.length === 0) {
@@ -309,6 +397,8 @@ export async function autoRecordExpertise(params: {
309
397
  description: `${params.capability} agent ${params.agentName} completed work in this domain. Files: ${filesList}`,
310
398
  tags: ["auto-session-end", params.capability],
311
399
  evidenceBead: params.taskId ?? undefined,
400
+ outcomeStatus: params.outcomeStatus,
401
+ outcomeAgent: params.agentName,
312
402
  });
313
403
  recordedDomains.push(domain);
314
404
  } catch {
@@ -348,6 +438,8 @@ export async function autoRecordExpertise(params: {
348
438
  description: insight.description,
349
439
  tags: insight.tags,
350
440
  evidenceBead: params.taskId ?? undefined,
441
+ outcomeStatus: params.outcomeStatus,
442
+ outcomeAgent: params.agentName,
351
443
  });
352
444
  if (!recordedDomains.includes(insight.domain)) {
353
445
  recordedDomains.push(insight.domain);
@@ -414,6 +506,7 @@ export async function appendOutcomeToAppliedRecords(params: {
414
506
  capability: string;
415
507
  taskId: string | null;
416
508
  projectRoot: string;
509
+ outcomeStatus?: "success" | "partial" | "failure";
417
510
  }): Promise<number> {
418
511
  const appliedRecordsPath = join(
419
512
  params.projectRoot,
@@ -436,10 +529,12 @@ export async function appendOutcomeToAppliedRecords(params: {
436
529
  if (!records || records.length === 0) return 0;
437
530
 
438
531
  const taskSuffix = params.taskId ? ` for task ${params.taskId}` : "";
532
+ const status: "success" | "partial" | "failure" = params.outcomeStatus ?? "success";
533
+ const gateNote = params.outcomeStatus ? ` Quality gates: ${params.outcomeStatus}.` : "";
439
534
  const outcome = {
440
- status: "success" as const,
535
+ status,
441
536
  agent: params.agentName,
442
- notes: `Applied by ${params.capability} agent ${params.agentName}${taskSuffix}. Session completed.`,
537
+ notes: `Applied by ${params.capability} agent ${params.agentName}${taskSuffix}. Session completed.${gateNote}`,
443
538
  };
444
539
 
445
540
  let appended = 0;
@@ -629,8 +724,9 @@ async function runLog(opts: {
629
724
  }
630
725
  case "session-end":
631
726
  logger.info("session.end", { agentName: opts.agent });
632
- // Transition agent state to completed
633
- transitionToCompleted(config.project.root, opts.agent);
727
+ // Transition agent state to completed (with retry/backoff and
728
+ // events.db fallback on persistent failure — overstory-e74b).
729
+ await transitionToCompleted(config.project.root, opts.agent);
634
730
  // Look up agent session for identity update and metrics recording
635
731
  {
636
732
  const agentSession = getAgentSession(config.project.root, opts.agent);
@@ -647,28 +743,6 @@ async function runLog(opts: {
647
743
  // Non-fatal: identity may not exist for this agent
648
744
  }
649
745
 
650
- // Auto-nudge coordinator when a lead completes so it wakes up
651
- // to process merge_ready / worker_done messages without waiting
652
- // for user input (see decision mx-728f8d).
653
- if (agentSession?.capability === "lead") {
654
- try {
655
- const nudgesDir = join(config.project.root, ".overstory", "pending-nudges");
656
- const { mkdir } = await import("node:fs/promises");
657
- await mkdir(nudgesDir, { recursive: true });
658
- const markerPath = join(nudgesDir, "coordinator.json");
659
- const marker = {
660
- from: opts.agent,
661
- reason: "lead_completed",
662
- subject: `Lead ${opts.agent} completed — check mail for merge_ready/worker_done`,
663
- messageId: `auto-nudge-${opts.agent}-${Date.now()}`,
664
- createdAt: new Date().toISOString(),
665
- };
666
- await Bun.write(markerPath, `${JSON.stringify(marker, null, "\t")}\n`);
667
- } catch {
668
- // Non-fatal: nudge failure should not break session-end
669
- }
670
- }
671
-
672
746
  // Record session metrics (with optional token data from transcript)
673
747
  if (agentSession) {
674
748
  // NOTE: We intentionally do NOT auto-complete the run here for coordinator agents.
@@ -728,9 +802,33 @@ async function runLog(opts: {
728
802
  // Non-fatal: metrics recording should not break session-end handling
729
803
  }
730
804
 
805
+ // Resolve outcome status from quality-gate results, threaded into
806
+ // every session-end mulch record write so confirmation scoring
807
+ // reflects whether tests/lint/typecheck actually passed.
808
+ let outcomeStatus: "success" | "partial" | "failure" | undefined;
809
+ if (!isStopHookPersistentCapability(agentSession.capability)) {
810
+ try {
811
+ let baseRef = "main";
812
+ const baseBranchPath = join(config.project.root, ".overstory", "session-branch.txt");
813
+ const baseFile = Bun.file(baseBranchPath);
814
+ if (await baseFile.exists()) {
815
+ const txt = (await baseFile.text()).trim();
816
+ if (txt.length > 0) baseRef = txt;
817
+ }
818
+ const hasWork = await hasWorkToVerify(agentSession.worktreePath, baseRef);
819
+ if (hasWork) {
820
+ const gates = config.project.qualityGates ?? [];
821
+ const outcome = await runQualityGates(gates, agentSession.worktreePath);
822
+ if (outcome) outcomeStatus = outcome.status;
823
+ }
824
+ } catch {
825
+ // Non-fatal: outcome status is optional
826
+ }
827
+ }
828
+
731
829
  // Auto-record expertise via mulch learn + record (post-session).
732
830
  // Skip persistent agents whose Stop hook fires every turn.
733
- if (!PERSISTENT_CAPABILITIES.has(agentSession.capability)) {
831
+ if (!isStopHookPersistentCapability(agentSession.capability)) {
734
832
  try {
735
833
  const mulchClient = createMulchClient(config.project.root);
736
834
  const mailDbPath = join(config.project.root, ".overstory", "mail.db");
@@ -743,6 +841,7 @@ async function runLog(opts: {
743
841
  parentAgent: agentSession.parentAgent,
744
842
  projectRoot: config.project.root,
745
843
  sessionStartedAt: agentSession.startedAt,
844
+ outcomeStatus,
746
845
  });
747
846
  } catch {
748
847
  // Non-fatal: mulch learn/record should not break session-end handling
@@ -751,7 +850,7 @@ async function runLog(opts: {
751
850
 
752
851
  // Append outcomes to applied mulch records (outcome feedback loop).
753
852
  // Reads applied-records.json written by sling.ts at spawn time.
754
- if (!PERSISTENT_CAPABILITIES.has(agentSession.capability)) {
853
+ if (!isStopHookPersistentCapability(agentSession.capability)) {
755
854
  try {
756
855
  const mulchClient = createMulchClient(config.project.root);
757
856
  await appendOutcomeToAppliedRecords({
@@ -760,6 +859,7 @@ async function runLog(opts: {
760
859
  capability: agentSession.capability,
761
860
  taskId,
762
861
  projectRoot: config.project.root,
862
+ outcomeStatus,
763
863
  });
764
864
  } catch {
765
865
  // Non-fatal
@@ -118,6 +118,54 @@ describe("mailCommand", () => {
118
118
  expect(output).toContain("Explore API");
119
119
  expect(output).toContain("Total: 2 messages");
120
120
  });
121
+
122
+ test("--type filters by message type", async () => {
123
+ // Add a typed message to the seeded inbox
124
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
125
+ const client = createMailClient(store);
126
+ client.send({
127
+ from: "lead-x",
128
+ to: "coordinator",
129
+ subject: "merge_ready: t1",
130
+ body: "ready to merge",
131
+ type: "merge_ready",
132
+ });
133
+ client.close();
134
+
135
+ await mailCommand(["list", "--type", "merge_ready"]);
136
+ expect(output).toContain("merge_ready: t1");
137
+ expect(output).not.toContain("Build task");
138
+ expect(output).not.toContain("Explore API");
139
+ expect(output).toContain("Total: 1 message");
140
+ });
141
+
142
+ test("--type combined with --from filters by both", async () => {
143
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
144
+ const client = createMailClient(store);
145
+ client.send({
146
+ from: "lead-x",
147
+ to: "coordinator",
148
+ subject: "merge_ready: t1",
149
+ body: "ready",
150
+ type: "merge_ready",
151
+ });
152
+ client.send({
153
+ from: "lead-y",
154
+ to: "coordinator",
155
+ subject: "merge_ready: t2",
156
+ body: "ready",
157
+ type: "merge_ready",
158
+ });
159
+ client.close();
160
+
161
+ await mailCommand(["list", "--from", "lead-x", "--type", "merge_ready"]);
162
+ expect(output).toContain("merge_ready: t1");
163
+ expect(output).not.toContain("merge_ready: t2");
164
+ });
165
+
166
+ test("--type rejects invalid type with ValidationError", async () => {
167
+ await expect(mailCommand(["list", "--type", "bogus"])).rejects.toThrow(/Invalid --type/);
168
+ });
121
169
  });
122
170
 
123
171
  describe("reply", () => {
@@ -1274,6 +1322,120 @@ describe("mailCommand", () => {
1274
1322
  expect(stderrOutput).toBe("");
1275
1323
  });
1276
1324
  });
1325
+
1326
+ describe("terminal-state recipient rejection (overstory-f5be)", () => {
1327
+ async function seedRecipient(name: string, state: "working" | "completed" | "zombie") {
1328
+ const { createSessionStore } = await import("../sessions/store.ts");
1329
+ const sessionsDbPath = join(tempDir, ".overstory", "sessions.db");
1330
+ const sessionStore = createSessionStore(sessionsDbPath);
1331
+ sessionStore.upsert({
1332
+ id: `session-${name}`,
1333
+ agentName: name,
1334
+ capability: "builder",
1335
+ worktreePath: `/worktrees/${name}`,
1336
+ branchName: name,
1337
+ taskId: "bead-x",
1338
+ tmuxSession: `overstory-test-${name}`,
1339
+ state,
1340
+ pid: 99999,
1341
+ parentAgent: "orchestrator",
1342
+ depth: 1,
1343
+ runId: "run-001",
1344
+ startedAt: new Date().toISOString(),
1345
+ lastActivity: new Date().toISOString(),
1346
+ escalationLevel: 0,
1347
+ stalledSince: null,
1348
+ transcriptPath: null,
1349
+ });
1350
+ sessionStore.close();
1351
+ }
1352
+
1353
+ test("rejects send to recipient in completed state", async () => {
1354
+ await seedRecipient("dead-builder", "completed");
1355
+
1356
+ let caught: unknown;
1357
+ try {
1358
+ await mailCommand([
1359
+ "send",
1360
+ "--to",
1361
+ "dead-builder",
1362
+ "--subject",
1363
+ "Hello",
1364
+ "--body",
1365
+ "Are you there?",
1366
+ ]);
1367
+ } catch (err) {
1368
+ caught = err;
1369
+ }
1370
+
1371
+ expect(caught).toBeDefined();
1372
+ expect((caught as Error).name).toBe("MailError");
1373
+ expect((caught as Error).message).toContain("dead-builder");
1374
+ expect((caught as Error).message).toContain("completed");
1375
+
1376
+ // Confirm no message was inserted
1377
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
1378
+ const client = createMailClient(store);
1379
+ const messages = client.list({ to: "dead-builder" });
1380
+ expect(messages.length).toBe(0);
1381
+ client.close();
1382
+ });
1383
+
1384
+ test("rejects send to recipient in zombie state", async () => {
1385
+ await seedRecipient("crashed-builder", "zombie");
1386
+
1387
+ let caught: unknown;
1388
+ try {
1389
+ await mailCommand([
1390
+ "send",
1391
+ "--to",
1392
+ "crashed-builder",
1393
+ "--subject",
1394
+ "Status?",
1395
+ "--body",
1396
+ "Ping",
1397
+ ]);
1398
+ } catch (err) {
1399
+ caught = err;
1400
+ }
1401
+
1402
+ expect(caught).toBeDefined();
1403
+ expect((caught as Error).name).toBe("MailError");
1404
+ expect((caught as Error).message).toContain("zombie");
1405
+ });
1406
+
1407
+ test("allows send when recipient has no session row (e.g. orchestrator)", async () => {
1408
+ // No session seeded for "orchestrator" — the existing beforeEach
1409
+ // only inserts mail rows, not session rows.
1410
+ await mailCommand([
1411
+ "send",
1412
+ "--to",
1413
+ "orchestrator",
1414
+ "--subject",
1415
+ "Hello",
1416
+ "--body",
1417
+ "Top-level role",
1418
+ ]);
1419
+
1420
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
1421
+ const client = createMailClient(store);
1422
+ const messages = client.list({ to: "orchestrator" });
1423
+ expect(messages.length).toBeGreaterThanOrEqual(1);
1424
+ client.close();
1425
+ });
1426
+
1427
+ test("allows send to active (working) recipient", async () => {
1428
+ await seedRecipient("live-builder", "working");
1429
+
1430
+ await mailCommand(["send", "--to", "live-builder", "--subject", "Hello", "--body", "Active"]);
1431
+
1432
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
1433
+ const client = createMailClient(store);
1434
+ const messages = client.list({ to: "live-builder" });
1435
+ expect(messages.length).toBe(1);
1436
+ client.close();
1437
+ });
1438
+ });
1277
1439
  });
1278
1440
 
1279
1441
  describe("shouldAutoNudge", () => {
@@ -7,9 +7,9 @@
7
7
  */
8
8
 
9
9
  import { join } from "node:path";
10
- import { Command } from "commander";
10
+ import { Command, CommanderError } from "commander";
11
11
  import { resolveProjectRoot } from "../config.ts";
12
- import { ValidationError } from "../errors.ts";
12
+ import { MailError, ValidationError } from "../errors.ts";
13
13
  import { createEventStore } from "../events/store.ts";
14
14
  import { jsonOutput } from "../json.ts";
15
15
  import { accent, printHint, printSuccess } from "../logging/color.ts";
@@ -253,6 +253,7 @@ interface ListOpts {
253
253
  to?: string;
254
254
  agent?: string;
255
255
  unread?: boolean;
256
+ type?: string;
256
257
  json?: boolean;
257
258
  }
258
259
 
@@ -405,6 +406,30 @@ async function handleSend(opts: SendOpts, cwd: string): Promise<void> {
405
406
  }
406
407
  }
407
408
 
409
+ // Reject sends to agents in a terminal state (completed/zombie).
410
+ // `installMailInjectors` reaps the per-agent dispatch loop the moment a
411
+ // session lands in a terminal state (serve.ts:378), so any mail addressed
412
+ // after that point would sit unread forever with no way to surface it.
413
+ // Sessions with no row at all (orchestrator, coordinator, operator roles)
414
+ // fall through — we only know about agents tracked in SessionStore.
415
+ // Group addresses already skip terminal agents via `getActive()`.
416
+ {
417
+ const overstoryDir = join(cwd, ".overstory");
418
+ const { store: sessionStore } = openSessionStore(overstoryDir);
419
+ try {
420
+ const recipient = sessionStore.getByName(to);
421
+ if (recipient && (recipient.state === "completed" || recipient.state === "zombie")) {
422
+ throw new MailError(
423
+ `Recipient "${to}" is in terminal state (${recipient.state}); message not sent. ` +
424
+ `The agent is no longer running, so this message would never be delivered.`,
425
+ { agentName: to },
426
+ );
427
+ }
428
+ } finally {
429
+ sessionStore.close();
430
+ }
431
+ }
432
+
408
433
  // Single-recipient message (existing logic)
409
434
  const client = openClient(cwd);
410
435
  try {
@@ -603,9 +628,20 @@ function handleList(opts: ListOpts, cwd: string): void {
603
628
  const unread = opts.unread ? true : undefined;
604
629
  const json = opts.json ?? false;
605
630
 
631
+ let type: MailMessageType | undefined;
632
+ if (opts.type !== undefined) {
633
+ if (!MAIL_MESSAGE_TYPES.includes(opts.type as MailMessageType)) {
634
+ throw new ValidationError(
635
+ `Invalid --type "${opts.type}". Must be one of: ${MAIL_MESSAGE_TYPES.join(", ")}`,
636
+ { field: "type", value: opts.type },
637
+ );
638
+ }
639
+ type = opts.type as MailMessageType;
640
+ }
641
+
606
642
  const client = openClient(cwd);
607
643
  try {
608
- const messages = client.list({ from, to, unread });
644
+ const messages = client.list({ from, to, unread, type });
609
645
 
610
646
  if (json) {
611
647
  jsonOutput("mail list", { messages });
@@ -732,8 +768,8 @@ export async function mailCommand(args: string[]): Promise<void> {
732
768
 
733
769
  program
734
770
  .command("check")
735
- .description("Check inbox (unread messages)")
736
- .option("--agent <name>", "Agent name")
771
+ .description("Check inbox for one agent and mark unread as read (per-agent scope)")
772
+ .option("--agent <name>", "Agent name (default: orchestrator)")
737
773
  .option("--inject", "Inject format for hook context")
738
774
  .option("--json", "Output as JSON")
739
775
  .option("--debounce <ms>", "Debounce interval in milliseconds")
@@ -744,11 +780,12 @@ export async function mailCommand(args: string[]): Promise<void> {
744
780
 
745
781
  program
746
782
  .command("list")
747
- .description("List messages with filters")
783
+ .description("List messages with filters (system-wide unless --to/--agent given)")
748
784
  .option("--from <name>", "Filter by sender")
749
- .option("--to <name>", "Filter by recipient")
785
+ .option("--to <name>", "Filter by recipient (scopes to one agent)")
750
786
  .option("--agent <name>", "Alias for --to (filter by recipient)")
751
- .option("--unread", "Show only unread messages")
787
+ .option("--unread", "Show only unread messages (does NOT mark them read)")
788
+ .option("--type <type>", "Filter by message type")
752
789
  .option("--json", "Output as JSON")
753
790
  .exitOverride()
754
791
  .action((opts: ListOpts) => {
@@ -789,5 +826,23 @@ export async function mailCommand(args: string[]): Promise<void> {
789
826
  handlePurge(opts, root);
790
827
  });
791
828
 
792
- await program.parseAsync(["node", "overstory-mail", ...args]);
829
+ try {
830
+ await program.parseAsync(["node", "overstory-mail", ...args]);
831
+ } catch (err) {
832
+ // `exitOverride()` turns Commander's help paths into thrown
833
+ // CommanderErrors after the help text was already written to stdout.
834
+ // Swallow both the explicit `--help` path (commander.helpDisplayed,
835
+ // exitCode 0) and the missing-subcommand path (commander.help,
836
+ // exitCode 1) — the user got what they asked for.
837
+ if (
838
+ err instanceof CommanderError &&
839
+ (err.code === "commander.helpDisplayed" || err.code === "commander.help")
840
+ ) {
841
+ if (err.exitCode !== 0) {
842
+ process.exitCode = err.exitCode;
843
+ }
844
+ return;
845
+ }
846
+ throw err;
847
+ }
793
848
  }