@os-eco/overstory-cli 0.6.1
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/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- package/templates/overlay.md.tmpl +81 -0
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: overstory mail send/check/list/read/reply
|
|
3
|
+
*
|
|
4
|
+
* Parses CLI args and delegates to the mail client.
|
|
5
|
+
* Supports --inject for hook context injection, --json for machine output,
|
|
6
|
+
* and various filters for listing messages.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { resolveProjectRoot } from "../config.ts";
|
|
11
|
+
import { MailError, ValidationError } from "../errors.ts";
|
|
12
|
+
import { createEventStore } from "../events/store.ts";
|
|
13
|
+
import { isGroupAddress, resolveGroupAddress } from "../mail/broadcast.ts";
|
|
14
|
+
import { createMailClient } from "../mail/client.ts";
|
|
15
|
+
import { createMailStore } from "../mail/store.ts";
|
|
16
|
+
import { openSessionStore } from "../sessions/compat.ts";
|
|
17
|
+
import type { MailMessage, MailMessageType } from "../types.ts";
|
|
18
|
+
import { MAIL_MESSAGE_TYPES } from "../types.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Protocol message types that require immediate recipient attention.
|
|
22
|
+
* These trigger auto-nudge regardless of priority level.
|
|
23
|
+
*/
|
|
24
|
+
const AUTO_NUDGE_TYPES: ReadonlySet<MailMessageType> = new Set([
|
|
25
|
+
"worker_done",
|
|
26
|
+
"merge_ready",
|
|
27
|
+
"error",
|
|
28
|
+
"escalation",
|
|
29
|
+
"merge_failed",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse a named flag value from an args array.
|
|
34
|
+
* Returns the value after the flag, or undefined if not present.
|
|
35
|
+
*/
|
|
36
|
+
function getFlag(args: string[], flag: string): string | undefined {
|
|
37
|
+
const idx = args.indexOf(flag);
|
|
38
|
+
if (idx === -1 || idx + 1 >= args.length) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
return args[idx + 1];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Check if a boolean flag is present in the args. */
|
|
45
|
+
function hasFlag(args: string[], flag: string): boolean {
|
|
46
|
+
return args.includes(flag);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Boolean flags that do NOT consume the next arg as a value. */
|
|
50
|
+
const BOOLEAN_FLAGS = new Set(["--json", "--inject", "--unread", "--all", "--help", "-h"]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract positional arguments from an args array, skipping flag-value pairs.
|
|
54
|
+
*
|
|
55
|
+
* Iterates through args, skipping `--flag value` pairs for value-bearing flags
|
|
56
|
+
* and lone boolean flags. Everything else is a positional arg.
|
|
57
|
+
*/
|
|
58
|
+
function getPositionalArgs(args: string[]): string[] {
|
|
59
|
+
const positional: string[] = [];
|
|
60
|
+
let i = 0;
|
|
61
|
+
while (i < args.length) {
|
|
62
|
+
const arg = args[i];
|
|
63
|
+
if (arg?.startsWith("-")) {
|
|
64
|
+
// It's a flag. If it's boolean, skip just it; otherwise skip it + its value.
|
|
65
|
+
if (BOOLEAN_FLAGS.has(arg)) {
|
|
66
|
+
i += 1;
|
|
67
|
+
} else {
|
|
68
|
+
i += 2; // skip flag + its value
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
if (arg !== undefined) {
|
|
72
|
+
positional.push(arg);
|
|
73
|
+
}
|
|
74
|
+
i += 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return positional;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Format a single message for human-readable output. */
|
|
81
|
+
function formatMessage(msg: MailMessage): string {
|
|
82
|
+
const readMarker = msg.read ? " " : "*";
|
|
83
|
+
const priorityTag = msg.priority !== "normal" ? ` [${msg.priority.toUpperCase()}]` : "";
|
|
84
|
+
const lines: string[] = [
|
|
85
|
+
`${readMarker} ${msg.id} From: ${msg.from} → To: ${msg.to}${priorityTag}`,
|
|
86
|
+
` Subject: ${msg.subject} (${msg.type})`,
|
|
87
|
+
` ${msg.body}`,
|
|
88
|
+
];
|
|
89
|
+
if (msg.payload !== null) {
|
|
90
|
+
lines.push(` Payload: ${msg.payload}`);
|
|
91
|
+
}
|
|
92
|
+
lines.push(` ${msg.createdAt}`);
|
|
93
|
+
return lines.join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Open a mail store connected to the project's mail.db.
|
|
98
|
+
* The cwd must already be resolved to the canonical project root.
|
|
99
|
+
*/
|
|
100
|
+
function openStore(cwd: string) {
|
|
101
|
+
const dbPath = join(cwd, ".overstory", "mail.db");
|
|
102
|
+
return createMailStore(dbPath);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// === Pending Nudge Markers ===
|
|
106
|
+
//
|
|
107
|
+
// Instead of sending tmux keys (which corrupt tool I/O), auto-nudge writes
|
|
108
|
+
// a JSON marker file per agent. The `mail check --inject` flow reads and
|
|
109
|
+
// clears these markers, prepending a priority banner to the injected output.
|
|
110
|
+
|
|
111
|
+
/** Directory where pending nudge markers are stored. */
|
|
112
|
+
function pendingNudgeDir(cwd: string): string {
|
|
113
|
+
return join(cwd, ".overstory", "pending-nudges");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Shape of a pending nudge marker file. */
|
|
117
|
+
interface PendingNudge {
|
|
118
|
+
from: string;
|
|
119
|
+
reason: string;
|
|
120
|
+
subject: string;
|
|
121
|
+
messageId: string;
|
|
122
|
+
createdAt: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Write a pending nudge marker for an agent.
|
|
127
|
+
*
|
|
128
|
+
* Creates `.overstory/pending-nudges/{agent}.json` so that the next
|
|
129
|
+
* `mail check --inject` call surfaces a priority banner for this message.
|
|
130
|
+
* Overwrites any existing marker (only the latest nudge matters).
|
|
131
|
+
*/
|
|
132
|
+
async function writePendingNudge(
|
|
133
|
+
cwd: string,
|
|
134
|
+
agentName: string,
|
|
135
|
+
nudge: Omit<PendingNudge, "createdAt">,
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
const dir = pendingNudgeDir(cwd);
|
|
138
|
+
const { mkdir } = await import("node:fs/promises");
|
|
139
|
+
await mkdir(dir, { recursive: true });
|
|
140
|
+
|
|
141
|
+
const marker: PendingNudge = {
|
|
142
|
+
...nudge,
|
|
143
|
+
createdAt: new Date().toISOString(),
|
|
144
|
+
};
|
|
145
|
+
const filePath = join(dir, `${agentName}.json`);
|
|
146
|
+
await Bun.write(filePath, `${JSON.stringify(marker, null, "\t")}\n`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Read and clear pending nudge markers for an agent.
|
|
151
|
+
*
|
|
152
|
+
* Returns the pending nudge (if any) and removes the marker file.
|
|
153
|
+
* Called by `mail check --inject` to prepend a priority banner.
|
|
154
|
+
*/
|
|
155
|
+
async function readAndClearPendingNudge(
|
|
156
|
+
cwd: string,
|
|
157
|
+
agentName: string,
|
|
158
|
+
): Promise<PendingNudge | null> {
|
|
159
|
+
const filePath = join(pendingNudgeDir(cwd), `${agentName}.json`);
|
|
160
|
+
const file = Bun.file(filePath);
|
|
161
|
+
if (!(await file.exists())) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const text = await file.text();
|
|
166
|
+
const nudge = JSON.parse(text) as PendingNudge;
|
|
167
|
+
const { unlink } = await import("node:fs/promises");
|
|
168
|
+
await unlink(filePath);
|
|
169
|
+
return nudge;
|
|
170
|
+
} catch {
|
|
171
|
+
// Corrupt or race condition — clear it and move on
|
|
172
|
+
try {
|
|
173
|
+
const { unlink } = await import("node:fs/promises");
|
|
174
|
+
await unlink(filePath);
|
|
175
|
+
} catch {
|
|
176
|
+
// Already gone
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// === Mail Check Debounce ===
|
|
183
|
+
//
|
|
184
|
+
// Prevents excessive mail checking by tracking the last check timestamp per agent.
|
|
185
|
+
// When --debounce flag is provided, mail check will skip if called within the
|
|
186
|
+
// debounce window.
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Path to the mail check debounce state file.
|
|
190
|
+
*/
|
|
191
|
+
function mailCheckStatePath(cwd: string): string {
|
|
192
|
+
return join(cwd, ".overstory", "mail-check-state.json");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Check if a mail check for this agent is within the debounce window.
|
|
197
|
+
*
|
|
198
|
+
* @param cwd - Project root directory
|
|
199
|
+
* @param agentName - Agent name
|
|
200
|
+
* @param debounceMs - Debounce interval in milliseconds
|
|
201
|
+
* @returns true if the last check was within the debounce window
|
|
202
|
+
*/
|
|
203
|
+
async function isMailCheckDebounced(
|
|
204
|
+
cwd: string,
|
|
205
|
+
agentName: string,
|
|
206
|
+
debounceMs: number,
|
|
207
|
+
): Promise<boolean> {
|
|
208
|
+
const statePath = mailCheckStatePath(cwd);
|
|
209
|
+
const file = Bun.file(statePath);
|
|
210
|
+
if (!(await file.exists())) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
const text = await file.text();
|
|
215
|
+
const state = JSON.parse(text) as Record<string, number>;
|
|
216
|
+
const lastCheck = state[agentName];
|
|
217
|
+
if (lastCheck === undefined) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
return Date.now() - lastCheck < debounceMs;
|
|
221
|
+
} catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Record a mail check timestamp for debounce tracking.
|
|
228
|
+
*
|
|
229
|
+
* @param cwd - Project root directory
|
|
230
|
+
* @param agentName - Agent name
|
|
231
|
+
*/
|
|
232
|
+
async function recordMailCheck(cwd: string, agentName: string): Promise<void> {
|
|
233
|
+
const statePath = mailCheckStatePath(cwd);
|
|
234
|
+
let state: Record<string, number> = {};
|
|
235
|
+
const file = Bun.file(statePath);
|
|
236
|
+
if (await file.exists()) {
|
|
237
|
+
try {
|
|
238
|
+
const text = await file.text();
|
|
239
|
+
state = JSON.parse(text) as Record<string, number>;
|
|
240
|
+
} catch {
|
|
241
|
+
// Corrupt state file — start fresh
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
state[agentName] = Date.now();
|
|
245
|
+
await Bun.write(statePath, `${JSON.stringify(state, null, "\t")}\n`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Open a mail client connected to the project's mail.db.
|
|
250
|
+
* The cwd must already be resolved to the canonical project root.
|
|
251
|
+
*/
|
|
252
|
+
function openClient(cwd: string) {
|
|
253
|
+
const store = openStore(cwd);
|
|
254
|
+
const client = createMailClient(store);
|
|
255
|
+
return client;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** overstory mail send */
|
|
259
|
+
async function handleSend(args: string[], cwd: string): Promise<void> {
|
|
260
|
+
const to = getFlag(args, "--to");
|
|
261
|
+
const subject = getFlag(args, "--subject");
|
|
262
|
+
const body = getFlag(args, "--body");
|
|
263
|
+
const from = getFlag(args, "--agent") ?? getFlag(args, "--from") ?? "orchestrator";
|
|
264
|
+
const rawPayload = getFlag(args, "--payload");
|
|
265
|
+
const VALID_PRIORITIES = ["low", "normal", "high", "urgent"] as const;
|
|
266
|
+
|
|
267
|
+
const rawType = getFlag(args, "--type") ?? "status";
|
|
268
|
+
const rawPriority = getFlag(args, "--priority") ?? "normal";
|
|
269
|
+
|
|
270
|
+
if (!MAIL_MESSAGE_TYPES.includes(rawType as MailMessage["type"])) {
|
|
271
|
+
throw new ValidationError(
|
|
272
|
+
`Invalid --type "${rawType}". Must be one of: ${MAIL_MESSAGE_TYPES.join(", ")}`,
|
|
273
|
+
{ field: "type", value: rawType },
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
if (!VALID_PRIORITIES.includes(rawPriority as MailMessage["priority"])) {
|
|
277
|
+
throw new ValidationError(
|
|
278
|
+
`Invalid --priority "${rawPriority}". Must be one of: ${VALID_PRIORITIES.join(", ")}`,
|
|
279
|
+
{ field: "priority", value: rawPriority },
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const type = rawType as MailMessage["type"];
|
|
284
|
+
const priority = rawPriority as MailMessage["priority"];
|
|
285
|
+
|
|
286
|
+
// Validate JSON payload if provided
|
|
287
|
+
let payload: string | undefined;
|
|
288
|
+
if (rawPayload !== undefined) {
|
|
289
|
+
try {
|
|
290
|
+
JSON.parse(rawPayload);
|
|
291
|
+
payload = rawPayload;
|
|
292
|
+
} catch {
|
|
293
|
+
throw new ValidationError("--payload must be valid JSON", {
|
|
294
|
+
field: "payload",
|
|
295
|
+
value: rawPayload,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (!to) {
|
|
301
|
+
throw new ValidationError("--to is required for mail send", { field: "to" });
|
|
302
|
+
}
|
|
303
|
+
if (!subject) {
|
|
304
|
+
throw new ValidationError("--subject is required for mail send", { field: "subject" });
|
|
305
|
+
}
|
|
306
|
+
if (!body) {
|
|
307
|
+
throw new ValidationError("--body is required for mail send", { field: "body" });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Handle broadcast messages (group addresses)
|
|
311
|
+
if (isGroupAddress(to)) {
|
|
312
|
+
const overstoryDir = join(cwd, ".overstory");
|
|
313
|
+
const { store: sessionStore } = openSessionStore(overstoryDir);
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
const activeSessions = sessionStore.getActive();
|
|
317
|
+
const recipients = resolveGroupAddress(to, activeSessions, from);
|
|
318
|
+
|
|
319
|
+
const client = openClient(cwd);
|
|
320
|
+
const messageIds: string[] = [];
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
// Fan out: send individual message to each recipient
|
|
324
|
+
for (const recipient of recipients) {
|
|
325
|
+
const id = client.send({ from, to: recipient, subject, body, type, priority, payload });
|
|
326
|
+
messageIds.push(id);
|
|
327
|
+
|
|
328
|
+
// Record mail_sent event for each individual message (fire-and-forget)
|
|
329
|
+
try {
|
|
330
|
+
const eventsDbPath = join(cwd, ".overstory", "events.db");
|
|
331
|
+
const eventStore = createEventStore(eventsDbPath);
|
|
332
|
+
try {
|
|
333
|
+
let runId: string | null = null;
|
|
334
|
+
const runIdPath = join(cwd, ".overstory", "current-run.txt");
|
|
335
|
+
const runIdFile = Bun.file(runIdPath);
|
|
336
|
+
if (await runIdFile.exists()) {
|
|
337
|
+
const text = await runIdFile.text();
|
|
338
|
+
const trimmed = text.trim();
|
|
339
|
+
if (trimmed.length > 0) {
|
|
340
|
+
runId = trimmed;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
eventStore.insert({
|
|
344
|
+
runId,
|
|
345
|
+
agentName: from,
|
|
346
|
+
sessionId: null,
|
|
347
|
+
eventType: "mail_sent",
|
|
348
|
+
toolName: null,
|
|
349
|
+
toolArgs: null,
|
|
350
|
+
toolDurationMs: null,
|
|
351
|
+
level: "info",
|
|
352
|
+
data: JSON.stringify({
|
|
353
|
+
to: recipient,
|
|
354
|
+
subject,
|
|
355
|
+
type,
|
|
356
|
+
priority,
|
|
357
|
+
messageId: id,
|
|
358
|
+
broadcast: true,
|
|
359
|
+
}),
|
|
360
|
+
});
|
|
361
|
+
} finally {
|
|
362
|
+
eventStore.close();
|
|
363
|
+
}
|
|
364
|
+
} catch {
|
|
365
|
+
// Event recording failure is non-fatal
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Auto-nudge for each individual message
|
|
369
|
+
const shouldNudge =
|
|
370
|
+
priority === "urgent" || priority === "high" || AUTO_NUDGE_TYPES.has(type);
|
|
371
|
+
if (shouldNudge) {
|
|
372
|
+
const nudgeReason = AUTO_NUDGE_TYPES.has(type) ? type : `${priority} priority`;
|
|
373
|
+
await writePendingNudge(cwd, recipient, {
|
|
374
|
+
from,
|
|
375
|
+
reason: nudgeReason,
|
|
376
|
+
subject,
|
|
377
|
+
messageId: id,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
} finally {
|
|
382
|
+
client.close();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Output broadcast summary
|
|
386
|
+
if (hasFlag(args, "--json")) {
|
|
387
|
+
process.stdout.write(
|
|
388
|
+
`${JSON.stringify({ messageIds, recipientCount: recipients.length })}\n`,
|
|
389
|
+
);
|
|
390
|
+
} else {
|
|
391
|
+
process.stdout.write(
|
|
392
|
+
`📢 Broadcast sent to ${recipients.length} recipient${recipients.length === 1 ? "" : "s"} (${to})\n`,
|
|
393
|
+
);
|
|
394
|
+
for (let i = 0; i < recipients.length; i++) {
|
|
395
|
+
const recipient = recipients[i];
|
|
396
|
+
const msgId = messageIds[i];
|
|
397
|
+
process.stdout.write(` → ${recipient} (${msgId})\n`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return; // Early return — broadcast handled
|
|
402
|
+
} finally {
|
|
403
|
+
sessionStore.close();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Single-recipient message (existing logic)
|
|
408
|
+
const client = openClient(cwd);
|
|
409
|
+
try {
|
|
410
|
+
const id = client.send({ from, to, subject, body, type, priority, payload });
|
|
411
|
+
|
|
412
|
+
// Record mail_sent event to EventStore (fire-and-forget)
|
|
413
|
+
try {
|
|
414
|
+
const eventsDbPath = join(cwd, ".overstory", "events.db");
|
|
415
|
+
const eventStore = createEventStore(eventsDbPath);
|
|
416
|
+
try {
|
|
417
|
+
let runId: string | null = null;
|
|
418
|
+
const runIdPath = join(cwd, ".overstory", "current-run.txt");
|
|
419
|
+
const runIdFile = Bun.file(runIdPath);
|
|
420
|
+
if (await runIdFile.exists()) {
|
|
421
|
+
const text = await runIdFile.text();
|
|
422
|
+
const trimmed = text.trim();
|
|
423
|
+
if (trimmed.length > 0) {
|
|
424
|
+
runId = trimmed;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
eventStore.insert({
|
|
428
|
+
runId,
|
|
429
|
+
agentName: from,
|
|
430
|
+
sessionId: null,
|
|
431
|
+
eventType: "mail_sent",
|
|
432
|
+
toolName: null,
|
|
433
|
+
toolArgs: null,
|
|
434
|
+
toolDurationMs: null,
|
|
435
|
+
level: "info",
|
|
436
|
+
data: JSON.stringify({ to, subject, type, priority, messageId: id }),
|
|
437
|
+
});
|
|
438
|
+
} finally {
|
|
439
|
+
eventStore.close();
|
|
440
|
+
}
|
|
441
|
+
} catch {
|
|
442
|
+
// Event recording failure is non-fatal
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (hasFlag(args, "--json")) {
|
|
446
|
+
process.stdout.write(`${JSON.stringify({ id })}\n`);
|
|
447
|
+
} else {
|
|
448
|
+
process.stdout.write(`✉️ Sent message ${id} to ${to}\n`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Auto-nudge: write a pending nudge marker instead of sending tmux keys.
|
|
452
|
+
// Direct tmux sendKeys during tool execution corrupts the agent's I/O,
|
|
453
|
+
// causing SIGKILL (exit 137) and "request interrupted" errors (overstory-ii1o).
|
|
454
|
+
// The message is already in the DB — the UserPromptSubmit hook's
|
|
455
|
+
// `mail check --inject` will surface it on the next prompt cycle.
|
|
456
|
+
// The pending nudge marker ensures the message gets a priority banner.
|
|
457
|
+
const shouldNudge = priority === "urgent" || priority === "high" || AUTO_NUDGE_TYPES.has(type);
|
|
458
|
+
if (shouldNudge) {
|
|
459
|
+
const nudgeReason = AUTO_NUDGE_TYPES.has(type) ? type : `${priority} priority`;
|
|
460
|
+
await writePendingNudge(cwd, to, {
|
|
461
|
+
from,
|
|
462
|
+
reason: nudgeReason,
|
|
463
|
+
subject,
|
|
464
|
+
messageId: id,
|
|
465
|
+
});
|
|
466
|
+
if (!hasFlag(args, "--json")) {
|
|
467
|
+
process.stdout.write(
|
|
468
|
+
`📢 Queued nudge for "${to}" (${nudgeReason}, delivered on next prompt)\n`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Reviewer coverage check for merge_ready (advisory warning)
|
|
474
|
+
if (type === "merge_ready") {
|
|
475
|
+
try {
|
|
476
|
+
const overstoryDir = join(cwd, ".overstory");
|
|
477
|
+
const { store: sessionStore } = openSessionStore(overstoryDir);
|
|
478
|
+
try {
|
|
479
|
+
const allSessions = sessionStore.getAll();
|
|
480
|
+
const myBuilders = allSessions.filter(
|
|
481
|
+
(s) => s.parentAgent === from && s.capability === "builder",
|
|
482
|
+
);
|
|
483
|
+
const myReviewers = allSessions.filter(
|
|
484
|
+
(s) => s.parentAgent === from && s.capability === "reviewer",
|
|
485
|
+
);
|
|
486
|
+
if (myBuilders.length > 0 && myReviewers.length === 0) {
|
|
487
|
+
process.stderr.write(
|
|
488
|
+
`\n⚠️ WARNING: merge_ready sent but NO reviewer sessions found for "${from}".\n` +
|
|
489
|
+
`⚠️ ${myBuilders.length} builder(s) completed without review. This violates the review-before-merge requirement.\n` +
|
|
490
|
+
`⚠️ Spawn reviewers for each builder before merge. See REVIEW_SKIP in agents/lead.md.\n\n`,
|
|
491
|
+
);
|
|
492
|
+
} else if (myReviewers.length > 0 && myReviewers.length < myBuilders.length) {
|
|
493
|
+
process.stderr.write(
|
|
494
|
+
`\n⚠️ NOTE: Only ${myReviewers.length} reviewer(s) for ${myBuilders.length} builder(s). Ensure all builder work is review-verified.\n\n`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
} finally {
|
|
498
|
+
sessionStore.close();
|
|
499
|
+
}
|
|
500
|
+
} catch {
|
|
501
|
+
// Reviewer check failure is non-fatal — do not block mail send
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
} finally {
|
|
505
|
+
client.close();
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** overstory mail check */
|
|
510
|
+
async function handleCheck(args: string[], cwd: string): Promise<void> {
|
|
511
|
+
const agent = getFlag(args, "--agent") ?? "orchestrator";
|
|
512
|
+
const inject = hasFlag(args, "--inject");
|
|
513
|
+
const json = hasFlag(args, "--json");
|
|
514
|
+
const debounceFlag = getFlag(args, "--debounce");
|
|
515
|
+
|
|
516
|
+
// Parse debounce interval if provided
|
|
517
|
+
let debounceMs: number | undefined;
|
|
518
|
+
if (debounceFlag !== undefined) {
|
|
519
|
+
const parsed = Number.parseInt(debounceFlag, 10);
|
|
520
|
+
if (Number.isNaN(parsed) || parsed < 0) {
|
|
521
|
+
throw new ValidationError(
|
|
522
|
+
`--debounce must be a non-negative integer (milliseconds), got: ${debounceFlag}`,
|
|
523
|
+
{ field: "debounce", value: debounceFlag },
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
debounceMs = parsed;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Check debounce if enabled
|
|
530
|
+
if (debounceMs !== undefined) {
|
|
531
|
+
const debounced = await isMailCheckDebounced(cwd, agent, debounceMs);
|
|
532
|
+
if (debounced) {
|
|
533
|
+
// Silent skip — no output when debounced
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const client = openClient(cwd);
|
|
539
|
+
try {
|
|
540
|
+
if (inject) {
|
|
541
|
+
// Check for pending nudge markers (written by auto-nudge instead of tmux keys)
|
|
542
|
+
const pendingNudge = await readAndClearPendingNudge(cwd, agent);
|
|
543
|
+
const output = client.checkInject(agent);
|
|
544
|
+
|
|
545
|
+
// Prepend a priority banner if there's a pending nudge
|
|
546
|
+
if (pendingNudge) {
|
|
547
|
+
const banner = `🚨 PRIORITY: ${pendingNudge.reason} message from ${pendingNudge.from} — "${pendingNudge.subject}"\n\n`;
|
|
548
|
+
process.stdout.write(banner);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (output.length > 0) {
|
|
552
|
+
process.stdout.write(output);
|
|
553
|
+
}
|
|
554
|
+
} else {
|
|
555
|
+
const messages = client.check(agent);
|
|
556
|
+
|
|
557
|
+
if (json) {
|
|
558
|
+
process.stdout.write(`${JSON.stringify(messages)}\n`);
|
|
559
|
+
} else if (messages.length === 0) {
|
|
560
|
+
process.stdout.write("No new messages.\n");
|
|
561
|
+
} else {
|
|
562
|
+
process.stdout.write(
|
|
563
|
+
`📬 ${messages.length} new message${messages.length === 1 ? "" : "s"}:\n\n`,
|
|
564
|
+
);
|
|
565
|
+
for (const msg of messages) {
|
|
566
|
+
process.stdout.write(`${formatMessage(msg)}\n\n`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Record this check for debounce tracking (only if debounce is enabled)
|
|
572
|
+
if (debounceMs !== undefined) {
|
|
573
|
+
await recordMailCheck(cwd, agent);
|
|
574
|
+
}
|
|
575
|
+
} finally {
|
|
576
|
+
client.close();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** overstory mail list */
|
|
581
|
+
function handleList(args: string[], cwd: string): void {
|
|
582
|
+
const from = getFlag(args, "--from");
|
|
583
|
+
// --agent is an alias for --to, providing agent-scoped perspective (like mail check)
|
|
584
|
+
const to = getFlag(args, "--to") ?? getFlag(args, "--agent");
|
|
585
|
+
const unread = hasFlag(args, "--unread") ? true : undefined;
|
|
586
|
+
const json = hasFlag(args, "--json");
|
|
587
|
+
|
|
588
|
+
const client = openClient(cwd);
|
|
589
|
+
try {
|
|
590
|
+
const messages = client.list({ from, to, unread });
|
|
591
|
+
|
|
592
|
+
if (json) {
|
|
593
|
+
process.stdout.write(`${JSON.stringify(messages)}\n`);
|
|
594
|
+
} else if (messages.length === 0) {
|
|
595
|
+
process.stdout.write("No messages found.\n");
|
|
596
|
+
} else {
|
|
597
|
+
for (const msg of messages) {
|
|
598
|
+
process.stdout.write(`${formatMessage(msg)}\n\n`);
|
|
599
|
+
}
|
|
600
|
+
process.stdout.write(
|
|
601
|
+
`Total: ${messages.length} message${messages.length === 1 ? "" : "s"}\n`,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
} finally {
|
|
605
|
+
client.close();
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** overstory mail read */
|
|
610
|
+
function handleRead(args: string[], cwd: string): void {
|
|
611
|
+
const positional = getPositionalArgs(args);
|
|
612
|
+
const id = positional[0];
|
|
613
|
+
if (!id) {
|
|
614
|
+
throw new ValidationError("Message ID is required for mail read", { field: "id" });
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const client = openClient(cwd);
|
|
618
|
+
try {
|
|
619
|
+
const { alreadyRead } = client.markRead(id);
|
|
620
|
+
if (alreadyRead) {
|
|
621
|
+
process.stdout.write(`Message ${id} was already read.\n`);
|
|
622
|
+
} else {
|
|
623
|
+
process.stdout.write(`Marked ${id} as read.\n`);
|
|
624
|
+
}
|
|
625
|
+
} finally {
|
|
626
|
+
client.close();
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** overstory mail reply */
|
|
631
|
+
function handleReply(args: string[], cwd: string): void {
|
|
632
|
+
const positional = getPositionalArgs(args);
|
|
633
|
+
const id = positional[0];
|
|
634
|
+
const body = getFlag(args, "--body");
|
|
635
|
+
const from = getFlag(args, "--agent") ?? getFlag(args, "--from") ?? "orchestrator";
|
|
636
|
+
|
|
637
|
+
if (!id) {
|
|
638
|
+
throw new ValidationError("Message ID is required for mail reply", { field: "id" });
|
|
639
|
+
}
|
|
640
|
+
if (!body) {
|
|
641
|
+
throw new ValidationError("--body is required for mail reply", { field: "body" });
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const client = openClient(cwd);
|
|
645
|
+
try {
|
|
646
|
+
const replyId = client.reply(id, body, from);
|
|
647
|
+
|
|
648
|
+
if (hasFlag(args, "--json")) {
|
|
649
|
+
process.stdout.write(`${JSON.stringify({ id: replyId })}\n`);
|
|
650
|
+
} else {
|
|
651
|
+
process.stdout.write(`✉️ Reply sent: ${replyId}\n`);
|
|
652
|
+
}
|
|
653
|
+
} finally {
|
|
654
|
+
client.close();
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** overstory mail purge */
|
|
659
|
+
function handlePurge(args: string[], cwd: string): void {
|
|
660
|
+
const all = hasFlag(args, "--all");
|
|
661
|
+
const daysStr = getFlag(args, "--days");
|
|
662
|
+
const agent = getFlag(args, "--agent");
|
|
663
|
+
const json = hasFlag(args, "--json");
|
|
664
|
+
|
|
665
|
+
if (!all && daysStr === undefined && agent === undefined) {
|
|
666
|
+
throw new ValidationError(
|
|
667
|
+
"mail purge requires at least one filter: --all, --days <n>, or --agent <name>",
|
|
668
|
+
{ field: "purge" },
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
let olderThanMs: number | undefined;
|
|
673
|
+
if (daysStr !== undefined) {
|
|
674
|
+
const days = Number.parseInt(daysStr, 10);
|
|
675
|
+
if (Number.isNaN(days) || days <= 0) {
|
|
676
|
+
throw new ValidationError("--days must be a positive integer", {
|
|
677
|
+
field: "days",
|
|
678
|
+
value: daysStr,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
olderThanMs = days * 24 * 60 * 60 * 1000;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const store = openStore(cwd);
|
|
685
|
+
try {
|
|
686
|
+
const purged = store.purge({ all, olderThanMs, agent });
|
|
687
|
+
|
|
688
|
+
if (json) {
|
|
689
|
+
process.stdout.write(`${JSON.stringify({ purged })}\n`);
|
|
690
|
+
} else {
|
|
691
|
+
process.stdout.write(`Purged ${purged} message${purged === 1 ? "" : "s"}.\n`);
|
|
692
|
+
}
|
|
693
|
+
} finally {
|
|
694
|
+
store.close();
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Entry point for `overstory mail <subcommand> [args...]`.
|
|
700
|
+
*
|
|
701
|
+
* Subcommands: send, check, list, read, reply, purge.
|
|
702
|
+
*/
|
|
703
|
+
const MAIL_HELP = `overstory mail — Agent messaging system
|
|
704
|
+
|
|
705
|
+
Usage: overstory mail <subcommand> [args...]
|
|
706
|
+
|
|
707
|
+
Subcommands:
|
|
708
|
+
send Send a message
|
|
709
|
+
--to <agent> --subject <text> --body <text>
|
|
710
|
+
[--from <name>] [--agent <name> (alias for --from)]
|
|
711
|
+
[--type <type>] [--priority <low|normal|high|urgent>]
|
|
712
|
+
[--payload <json>] [--json]
|
|
713
|
+
Types: status, question, result, error (semantic)
|
|
714
|
+
worker_done, merge_ready, merged, merge_failed,
|
|
715
|
+
escalation, health_check, dispatch, assign (protocol)
|
|
716
|
+
check Check inbox (unread messages)
|
|
717
|
+
[--agent <name>] [--inject] [--json]
|
|
718
|
+
list List messages with filters
|
|
719
|
+
[--from <name>] [--to <name>] [--agent <name> (alias for --to)]
|
|
720
|
+
[--unread] [--json]
|
|
721
|
+
read Mark a message as read
|
|
722
|
+
<message-id>
|
|
723
|
+
reply Reply to a message
|
|
724
|
+
<message-id> --body <text> [--from <name>]
|
|
725
|
+
[--agent <name> (alias for --from)] [--json]
|
|
726
|
+
purge Delete old messages
|
|
727
|
+
--all | --days <n> | --agent <name>
|
|
728
|
+
[--json]
|
|
729
|
+
|
|
730
|
+
Options:
|
|
731
|
+
--help, -h Show this help`;
|
|
732
|
+
|
|
733
|
+
export async function mailCommand(args: string[]): Promise<void> {
|
|
734
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
735
|
+
process.stdout.write(`${MAIL_HELP}\n`);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const subcommand = args[0];
|
|
740
|
+
const subArgs = args.slice(1);
|
|
741
|
+
|
|
742
|
+
// Resolve the actual project root (handles git worktrees).
|
|
743
|
+
// Mail commands may run from agent worktrees via hooks, so we must
|
|
744
|
+
// resolve up to the main project root where .overstory/mail.db lives.
|
|
745
|
+
const root = await resolveProjectRoot(process.cwd());
|
|
746
|
+
|
|
747
|
+
switch (subcommand) {
|
|
748
|
+
case "send":
|
|
749
|
+
await handleSend(subArgs, root);
|
|
750
|
+
break;
|
|
751
|
+
case "check":
|
|
752
|
+
await handleCheck(subArgs, root);
|
|
753
|
+
break;
|
|
754
|
+
case "list":
|
|
755
|
+
handleList(subArgs, root);
|
|
756
|
+
break;
|
|
757
|
+
case "read":
|
|
758
|
+
handleRead(subArgs, root);
|
|
759
|
+
break;
|
|
760
|
+
case "reply":
|
|
761
|
+
handleReply(subArgs, root);
|
|
762
|
+
break;
|
|
763
|
+
case "purge":
|
|
764
|
+
handlePurge(subArgs, root);
|
|
765
|
+
break;
|
|
766
|
+
default:
|
|
767
|
+
throw new MailError(
|
|
768
|
+
`Unknown mail subcommand: ${subcommand ?? "(none)"}. Use: send, check, list, read, reply, purge`,
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
}
|