@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.
- package/README.md +50 -19
- package/agents/builder.md +19 -9
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +204 -87
- package/agents/merger.md +25 -14
- package/agents/reviewer.md +22 -16
- package/agents/scout.md +17 -12
- package/package.json +6 -3
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/headless-mail-injector.test.ts +448 -0
- package/src/agents/headless-mail-injector.ts +219 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +514 -14
- package/src/agents/hooks-deployer.ts +141 -0
- package/src/agents/mail-poll-detect.test.ts +153 -0
- package/src/agents/mail-poll-detect.ts +73 -0
- package/src/agents/overlay.test.ts +60 -4
- package/src/agents/overlay.ts +63 -8
- package/src/agents/scope-detect.test.ts +190 -0
- package/src/agents/scope-detect.ts +146 -0
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +2312 -0
- package/src/agents/turn-runner.ts +1383 -0
- package/src/commands/agents.ts +9 -0
- package/src/commands/clean.ts +54 -0
- package/src/commands/coordinator.test.ts +254 -0
- package/src/commands/coordinator.ts +273 -8
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +14 -4
- package/src/commands/doctor.ts +3 -1
- package/src/commands/group.test.ts +94 -0
- package/src/commands/group.ts +49 -20
- package/src/commands/init.test.ts +8 -0
- package/src/commands/init.ts +8 -1
- package/src/commands/log.test.ts +187 -11
- package/src/commands/log.ts +171 -71
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +230 -1
- package/src/commands/merge.ts +68 -12
- package/src/commands/nudge.test.ts +351 -4
- package/src/commands/nudge.ts +356 -34
- package/src/commands/run.test.ts +43 -7
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +408 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1323 -0
- package/src/commands/serve/rest.ts +708 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +565 -0
- package/src/commands/sling.test.ts +177 -1
- package/src/commands/sling.ts +243 -71
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +255 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/watch.test.ts +43 -0
- package/src/commands/watch.ts +153 -28
- package/src/config.ts +23 -0
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +48 -1
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/types.ts +2 -1
- package/src/doctor/watchdog.ts +57 -1
- package/src/events/tailer.test.ts +234 -1
- package/src/events/tailer.ts +90 -0
- package/src/index.ts +57 -6
- package/src/insights/quality-gates.test.ts +141 -0
- package/src/insights/quality-gates.ts +156 -0
- package/src/json.ts +29 -0
- package/src/logging/theme.ts +4 -0
- package/src/mail/client.ts +15 -2
- package/src/mail/store.test.ts +82 -0
- package/src/mail/store.ts +41 -4
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/merge/predict.test.ts +387 -0
- package/src/merge/predict.ts +249 -0
- package/src/merge/resolver.ts +1 -1
- package/src/mulch/client.ts +3 -3
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/claude.test.ts +791 -1
- package/src/runtimes/claude.ts +323 -1
- package/src/runtimes/connections.test.ts +141 -1
- package/src/runtimes/connections.ts +73 -4
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/types.ts +10 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +657 -29
- package/src/sessions/store.ts +286 -23
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +107 -2
- package/src/utils/pid.test.ts +85 -1
- package/src/utils/pid.ts +86 -1
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/watchdog/daemon.test.ts +1607 -376
- package/src/watchdog/daemon.ts +462 -88
- package/src/watchdog/health.test.ts +282 -0
- package/src/watchdog/health.ts +126 -27
- package/src/worktree/manager.test.ts +218 -1
- package/src/worktree/manager.ts +55 -0
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +28 -0
- package/src/worktree/tmux.ts +27 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +5 -2
package/src/commands/log.ts
CHANGED
|
@@ -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
|
-
|
|
70
|
-
|
|
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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
|
98
|
+
const TRANSITION_MAX_ATTEMPTS = 5;
|
|
99
|
+
const TRANSITION_BACKOFF_BASE_MS = 50;
|
|
87
100
|
|
|
88
101
|
/**
|
|
89
|
-
*
|
|
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
|
-
*
|
|
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
|
|
108
|
+
function transitionToCompletedOnce(projectRoot: string, agentName: string): void {
|
|
109
|
+
const overstoryDir = join(projectRoot, ".overstory");
|
|
110
|
+
const { store } = openSessionStore(overstoryDir);
|
|
98
111
|
try {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
+
eventStore.close();
|
|
133
179
|
}
|
|
134
180
|
} catch {
|
|
135
|
-
// Non-fatal:
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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", () => {
|
package/src/commands/mail.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|