@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.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. package/templates/overlay.md.tmpl +81 -0
@@ -0,0 +1,384 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { AgentError } from "../errors.ts";
4
+ import type { AgentIdentity } from "../types.ts";
5
+
6
+ const IDENTITY_FILENAME = "identity.yaml";
7
+ const MAX_RECENT_TASKS = 20;
8
+
9
+ // === YAML Serialization ===
10
+
11
+ /**
12
+ * Serialize an AgentIdentity to a YAML string.
13
+ *
14
+ * Produces simple key-value pairs with proper indentation.
15
+ * Arrays of scalars use `- item` syntax.
16
+ * Arrays of objects use `- key: value` with indented continuation lines.
17
+ */
18
+ function serializeIdentityYaml(identity: AgentIdentity): string {
19
+ const lines: string[] = [];
20
+
21
+ lines.push(`name: ${quoteIfNeeded(identity.name)}`);
22
+ lines.push(`capability: ${quoteIfNeeded(identity.capability)}`);
23
+ lines.push(`created: ${quoteIfNeeded(identity.created)}`);
24
+ lines.push(`sessionsCompleted: ${identity.sessionsCompleted}`);
25
+
26
+ // expertiseDomains
27
+ if (identity.expertiseDomains.length === 0) {
28
+ lines.push("expertiseDomains: []");
29
+ } else {
30
+ lines.push("expertiseDomains:");
31
+ for (const domain of identity.expertiseDomains) {
32
+ lines.push(`\t- ${quoteIfNeeded(domain)}`);
33
+ }
34
+ }
35
+
36
+ // recentTasks (array of objects)
37
+ if (identity.recentTasks.length === 0) {
38
+ lines.push("recentTasks: []");
39
+ } else {
40
+ lines.push("recentTasks:");
41
+ for (const task of identity.recentTasks) {
42
+ lines.push(`\t- beadId: ${quoteIfNeeded(task.beadId)}`);
43
+ lines.push(`\t\tsummary: ${quoteIfNeeded(task.summary)}`);
44
+ lines.push(`\t\tcompletedAt: ${quoteIfNeeded(task.completedAt)}`);
45
+ }
46
+ }
47
+
48
+ return `${lines.join("\n")}\n`;
49
+ }
50
+
51
+ /**
52
+ * Quote a string value if it contains characters that could be misinterpreted
53
+ * by a YAML parser (colons, hashes, leading/trailing whitespace, etc.).
54
+ */
55
+ function quoteIfNeeded(value: string): string {
56
+ if (
57
+ value === "" ||
58
+ value.includes(": ") ||
59
+ value.includes("#") ||
60
+ value.startsWith(" ") ||
61
+ value.endsWith(" ") ||
62
+ value.startsWith('"') ||
63
+ value.startsWith("'") ||
64
+ value === "true" ||
65
+ value === "false" ||
66
+ value === "null" ||
67
+ value === "~" ||
68
+ /^\d/.test(value)
69
+ ) {
70
+ // Use double quotes, escaping internal double quotes
71
+ const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
72
+ return `"${escaped}"`;
73
+ }
74
+ return value;
75
+ }
76
+
77
+ // === YAML Deserialization ===
78
+
79
+ /**
80
+ * Parse an AgentIdentity YAML file into a structured object.
81
+ *
82
+ * This is a purpose-built parser for the identity YAML format. It handles:
83
+ * - Simple key: value pairs (strings, numbers)
84
+ * - Arrays of scalars (expertiseDomains)
85
+ * - Arrays of objects (recentTasks with beadId, summary, completedAt)
86
+ * - Empty arrays (`[]`)
87
+ * - Quoted strings
88
+ * - Tab indentation
89
+ */
90
+ function parseIdentityYaml(text: string): AgentIdentity {
91
+ const lines = text.split("\n");
92
+
93
+ let name = "";
94
+ let capability = "";
95
+ let created = "";
96
+ let sessionsCompleted = 0;
97
+ const expertiseDomains: string[] = [];
98
+ const recentTasks: Array<{ beadId: string; summary: string; completedAt: string }> = [];
99
+
100
+ let currentSection: "none" | "expertiseDomains" | "recentTasks" = "none";
101
+ let currentTask: { beadId: string; summary: string; completedAt: string } | null = null;
102
+
103
+ for (const rawLine of lines) {
104
+ const trimmed = rawLine.trim();
105
+
106
+ // Skip empty lines and comments
107
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
108
+
109
+ // Top-level key: value (no leading whitespace)
110
+ if (!rawLine.startsWith("\t") && !rawLine.startsWith(" ")) {
111
+ // Flush any pending task
112
+ if (currentTask !== null) {
113
+ recentTasks.push(currentTask);
114
+ currentTask = null;
115
+ }
116
+
117
+ const colonIndex = trimmed.indexOf(":");
118
+ if (colonIndex === -1) continue;
119
+
120
+ const key = trimmed.slice(0, colonIndex).trim();
121
+ const rawValue = trimmed.slice(colonIndex + 1).trim();
122
+
123
+ switch (key) {
124
+ case "name":
125
+ name = parseScalar(rawValue);
126
+ currentSection = "none";
127
+ break;
128
+ case "capability":
129
+ capability = parseScalar(rawValue);
130
+ currentSection = "none";
131
+ break;
132
+ case "created":
133
+ created = parseScalar(rawValue);
134
+ currentSection = "none";
135
+ break;
136
+ case "sessionsCompleted":
137
+ sessionsCompleted = Number.parseInt(parseScalar(rawValue), 10) || 0;
138
+ currentSection = "none";
139
+ break;
140
+ case "expertiseDomains":
141
+ if (rawValue === "[]") {
142
+ currentSection = "none";
143
+ } else {
144
+ currentSection = "expertiseDomains";
145
+ }
146
+ break;
147
+ case "recentTasks":
148
+ if (rawValue === "[]") {
149
+ currentSection = "none";
150
+ } else {
151
+ currentSection = "recentTasks";
152
+ }
153
+ break;
154
+ }
155
+ continue;
156
+ }
157
+
158
+ // Indented line: array items or nested object properties
159
+ if (currentSection === "expertiseDomains") {
160
+ if (trimmed.startsWith("- ")) {
161
+ expertiseDomains.push(parseScalar(trimmed.slice(2).trim()));
162
+ }
163
+ continue;
164
+ }
165
+
166
+ if (currentSection === "recentTasks") {
167
+ if (trimmed.startsWith("- ")) {
168
+ // New array item — flush previous task
169
+ if (currentTask !== null) {
170
+ recentTasks.push(currentTask);
171
+ }
172
+ currentTask = { beadId: "", summary: "", completedAt: "" };
173
+
174
+ // Parse the key-value on the same line as the dash
175
+ const itemContent = trimmed.slice(2).trim();
176
+ const itemColonIdx = itemContent.indexOf(":");
177
+ if (itemColonIdx !== -1) {
178
+ const itemKey = itemContent.slice(0, itemColonIdx).trim();
179
+ const itemValue = parseScalar(itemContent.slice(itemColonIdx + 1).trim());
180
+ assignTaskField(currentTask, itemKey, itemValue);
181
+ }
182
+ } else if (currentTask !== null) {
183
+ // Continuation line for current task object
184
+ const colonIdx = trimmed.indexOf(":");
185
+ if (colonIdx !== -1) {
186
+ const fieldKey = trimmed.slice(0, colonIdx).trim();
187
+ const fieldValue = parseScalar(trimmed.slice(colonIdx + 1).trim());
188
+ assignTaskField(currentTask, fieldKey, fieldValue);
189
+ }
190
+ }
191
+ }
192
+ }
193
+
194
+ // Flush final pending task
195
+ if (currentTask !== null) {
196
+ recentTasks.push(currentTask);
197
+ }
198
+
199
+ return {
200
+ name,
201
+ capability,
202
+ created,
203
+ sessionsCompleted,
204
+ expertiseDomains,
205
+ recentTasks,
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Assign a parsed field value to a task object by key name.
211
+ */
212
+ function assignTaskField(
213
+ task: { beadId: string; summary: string; completedAt: string },
214
+ key: string,
215
+ value: string,
216
+ ): void {
217
+ switch (key) {
218
+ case "beadId":
219
+ task.beadId = value;
220
+ break;
221
+ case "summary":
222
+ task.summary = value;
223
+ break;
224
+ case "completedAt":
225
+ task.completedAt = value;
226
+ break;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Parse a scalar YAML value, stripping quotes if present.
232
+ */
233
+ function parseScalar(raw: string): string {
234
+ if (raw.length >= 2) {
235
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
236
+ return raw.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
237
+ }
238
+ }
239
+ return raw;
240
+ }
241
+
242
+ // === Public API ===
243
+
244
+ /**
245
+ * Create a new agent identity file.
246
+ *
247
+ * Writes the identity to `{baseDir}/{identity.name}/identity.yaml`,
248
+ * creating the directory if it doesn't exist.
249
+ *
250
+ * @param baseDir - Absolute path to the agents base directory (e.g., `.overstory/agents`)
251
+ * @param identity - The AgentIdentity to persist
252
+ */
253
+ export async function createIdentity(baseDir: string, identity: AgentIdentity): Promise<void> {
254
+ const filePath = join(baseDir, identity.name, IDENTITY_FILENAME);
255
+ const dir = dirname(filePath);
256
+
257
+ try {
258
+ await mkdir(dir, { recursive: true });
259
+ } catch (err) {
260
+ throw new AgentError(`Failed to create identity directory: ${dir}`, {
261
+ agentName: identity.name,
262
+ cause: err instanceof Error ? err : undefined,
263
+ });
264
+ }
265
+
266
+ const yaml = serializeIdentityYaml(identity);
267
+
268
+ try {
269
+ await Bun.write(filePath, yaml);
270
+ } catch (err) {
271
+ throw new AgentError(`Failed to write identity file: ${filePath}`, {
272
+ agentName: identity.name,
273
+ cause: err instanceof Error ? err : undefined,
274
+ });
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Load an existing agent identity from disk.
280
+ *
281
+ * Reads from `{baseDir}/{name}/identity.yaml`. Returns null if the file
282
+ * does not exist.
283
+ *
284
+ * @param baseDir - Absolute path to the agents base directory
285
+ * @param name - Agent name (used as subdirectory)
286
+ * @returns The loaded AgentIdentity, or null if not found
287
+ */
288
+ export async function loadIdentity(baseDir: string, name: string): Promise<AgentIdentity | null> {
289
+ const filePath = join(baseDir, name, IDENTITY_FILENAME);
290
+ const file = Bun.file(filePath);
291
+ const exists = await file.exists();
292
+
293
+ if (!exists) {
294
+ return null;
295
+ }
296
+
297
+ let text: string;
298
+ try {
299
+ text = await file.text();
300
+ } catch (err) {
301
+ throw new AgentError(`Failed to read identity file: ${filePath}`, {
302
+ agentName: name,
303
+ cause: err instanceof Error ? err : undefined,
304
+ });
305
+ }
306
+
307
+ try {
308
+ return parseIdentityYaml(text);
309
+ } catch (err) {
310
+ throw new AgentError(`Failed to parse identity YAML: ${filePath}`, {
311
+ agentName: name,
312
+ cause: err instanceof Error ? err : undefined,
313
+ });
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Update an existing agent identity.
319
+ *
320
+ * Loads the identity, applies updates, writes back, and returns the result.
321
+ *
322
+ * Supported updates:
323
+ * - `sessionsCompleted`: Incremented by the given value (additive)
324
+ * - `expertiseDomains`: Merged with existing (deduplicating)
325
+ * - `completedTask`: Appended to `recentTasks` with a current ISO timestamp
326
+ *
327
+ * The `recentTasks` list is capped at 20 entries; oldest entries are dropped.
328
+ *
329
+ * @param baseDir - Absolute path to the agents base directory
330
+ * @param name - Agent name
331
+ * @param update - Partial update to apply
332
+ * @returns The updated AgentIdentity
333
+ * @throws AgentError if the identity does not exist
334
+ */
335
+ export async function updateIdentity(
336
+ baseDir: string,
337
+ name: string,
338
+ update: Partial<Pick<AgentIdentity, "sessionsCompleted" | "expertiseDomains">> & {
339
+ completedTask?: { beadId: string; summary: string };
340
+ },
341
+ ): Promise<AgentIdentity> {
342
+ const identity = await loadIdentity(baseDir, name);
343
+
344
+ if (identity === null) {
345
+ throw new AgentError(`Agent identity not found: ${name}`, {
346
+ agentName: name,
347
+ });
348
+ }
349
+
350
+ // Increment sessionsCompleted
351
+ if (update.sessionsCompleted !== undefined) {
352
+ identity.sessionsCompleted += update.sessionsCompleted;
353
+ }
354
+
355
+ // Merge expertiseDomains (deduplicate)
356
+ if (update.expertiseDomains !== undefined) {
357
+ const existing = new Set(identity.expertiseDomains);
358
+ for (const domain of update.expertiseDomains) {
359
+ existing.add(domain);
360
+ }
361
+ identity.expertiseDomains = [...existing];
362
+ }
363
+
364
+ // Append completed task
365
+ if (update.completedTask !== undefined) {
366
+ identity.recentTasks.push({
367
+ beadId: update.completedTask.beadId,
368
+ summary: update.completedTask.summary,
369
+ completedAt: new Date().toISOString(),
370
+ });
371
+
372
+ // Cap at MAX_RECENT_TASKS, dropping oldest
373
+ if (identity.recentTasks.length > MAX_RECENT_TASKS) {
374
+ identity.recentTasks = identity.recentTasks.slice(
375
+ identity.recentTasks.length - MAX_RECENT_TASKS,
376
+ );
377
+ }
378
+ }
379
+
380
+ // Write back
381
+ await createIdentity(baseDir, identity);
382
+
383
+ return identity;
384
+ }
@@ -0,0 +1,196 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { cleanupTempDir } from "../test-helpers.ts";
6
+ import type { SessionHandoff } from "../types.ts";
7
+ import { loadCheckpoint } from "./checkpoint.ts";
8
+ import { completeHandoff, initiateHandoff, resumeFromHandoff } from "./lifecycle.ts";
9
+
10
+ describe("lifecycle", () => {
11
+ let agentsDir: string;
12
+
13
+ beforeEach(async () => {
14
+ agentsDir = await mkdtemp(join(tmpdir(), "overstory-lifecycle-test-"));
15
+ });
16
+
17
+ afterEach(async () => {
18
+ await cleanupTempDir(agentsDir);
19
+ });
20
+
21
+ test("initiateHandoff creates checkpoint and handoff record", async () => {
22
+ const handoff = await initiateHandoff({
23
+ agentsDir,
24
+ agentName: "builder-1",
25
+ sessionId: "session-100",
26
+ beadId: "overstory-xyz1",
27
+ reason: "compaction",
28
+ progressSummary: "Built the widget",
29
+ pendingWork: "Tests remain",
30
+ currentBranch: "overstory/builder-1/overstory-xyz1",
31
+ filesModified: ["src/widget.ts"],
32
+ mulchDomains: ["agents"],
33
+ });
34
+
35
+ // Handoff record is correct
36
+ expect(handoff.fromSessionId).toBe("session-100");
37
+ expect(handoff.toSessionId).toBeNull();
38
+ expect(handoff.reason).toBe("compaction");
39
+ expect(handoff.checkpoint.agentName).toBe("builder-1");
40
+ expect(handoff.checkpoint.progressSummary).toBe("Built the widget");
41
+
42
+ // Checkpoint was saved to disk
43
+ const checkpoint = await loadCheckpoint(agentsDir, "builder-1");
44
+ expect(checkpoint).not.toBeNull();
45
+ expect(checkpoint?.sessionId).toBe("session-100");
46
+
47
+ // Handoffs file was created
48
+ const handoffsFile = Bun.file(join(agentsDir, "builder-1", "handoffs.json"));
49
+ expect(await handoffsFile.exists()).toBe(true);
50
+ const handoffs = JSON.parse(await handoffsFile.text()) as SessionHandoff[];
51
+ expect(handoffs).toHaveLength(1);
52
+ });
53
+
54
+ test("resumeFromHandoff returns pending handoff", async () => {
55
+ await initiateHandoff({
56
+ agentsDir,
57
+ agentName: "builder-2",
58
+ sessionId: "session-200",
59
+ beadId: "overstory-abc2",
60
+ reason: "crash",
61
+ progressSummary: "Halfway done",
62
+ pendingWork: "Finish implementation",
63
+ currentBranch: "overstory/builder-2/overstory-abc2",
64
+ filesModified: ["src/foo.ts"],
65
+ mulchDomains: [],
66
+ });
67
+
68
+ const result = await resumeFromHandoff({
69
+ agentsDir,
70
+ agentName: "builder-2",
71
+ });
72
+
73
+ expect(result).not.toBeNull();
74
+ expect(result?.checkpoint.sessionId).toBe("session-200");
75
+ expect(result?.checkpoint.progressSummary).toBe("Halfway done");
76
+ expect(result?.handoff.reason).toBe("crash");
77
+ expect(result?.handoff.toSessionId).toBeNull();
78
+ });
79
+
80
+ test("completeHandoff updates toSessionId and clears checkpoint", async () => {
81
+ await initiateHandoff({
82
+ agentsDir,
83
+ agentName: "builder-3",
84
+ sessionId: "session-300",
85
+ beadId: "overstory-def3",
86
+ reason: "manual",
87
+ progressSummary: "Done with phase 1",
88
+ pendingWork: "Phase 2",
89
+ currentBranch: "overstory/builder-3/overstory-def3",
90
+ filesModified: [],
91
+ mulchDomains: [],
92
+ });
93
+
94
+ await completeHandoff({
95
+ agentsDir,
96
+ agentName: "builder-3",
97
+ newSessionId: "session-301",
98
+ });
99
+
100
+ // Checkpoint should be cleared
101
+ const checkpoint = await loadCheckpoint(agentsDir, "builder-3");
102
+ expect(checkpoint).toBeNull();
103
+
104
+ // Handoff should have toSessionId set
105
+ const handoffsFile = Bun.file(join(agentsDir, "builder-3", "handoffs.json"));
106
+ const handoffs = JSON.parse(await handoffsFile.text()) as SessionHandoff[];
107
+ expect(handoffs).toHaveLength(1);
108
+ const first = handoffs[0];
109
+ expect(first).toBeDefined();
110
+ expect(first?.toSessionId).toBe("session-301");
111
+ });
112
+
113
+ test("multiple handoffs accumulate in handoffs.json", async () => {
114
+ // First handoff
115
+ await initiateHandoff({
116
+ agentsDir,
117
+ agentName: "builder-4",
118
+ sessionId: "session-400",
119
+ beadId: "overstory-ghi4",
120
+ reason: "compaction",
121
+ progressSummary: "First session work",
122
+ pendingWork: "Continue",
123
+ currentBranch: "overstory/builder-4/overstory-ghi4",
124
+ filesModified: ["a.ts"],
125
+ mulchDomains: [],
126
+ });
127
+
128
+ // Complete the first handoff
129
+ await completeHandoff({
130
+ agentsDir,
131
+ agentName: "builder-4",
132
+ newSessionId: "session-401",
133
+ });
134
+
135
+ // Second handoff
136
+ await initiateHandoff({
137
+ agentsDir,
138
+ agentName: "builder-4",
139
+ sessionId: "session-401",
140
+ beadId: "overstory-ghi4",
141
+ reason: "timeout",
142
+ progressSummary: "Second session work",
143
+ pendingWork: "Finish up",
144
+ currentBranch: "overstory/builder-4/overstory-ghi4",
145
+ filesModified: ["a.ts", "b.ts"],
146
+ mulchDomains: [],
147
+ });
148
+
149
+ const handoffsFile = Bun.file(join(agentsDir, "builder-4", "handoffs.json"));
150
+ const handoffs = JSON.parse(await handoffsFile.text()) as SessionHandoff[];
151
+ expect(handoffs).toHaveLength(2);
152
+
153
+ const first = handoffs[0];
154
+ expect(first).toBeDefined();
155
+ expect(first?.toSessionId).toBe("session-401");
156
+
157
+ const second = handoffs[1];
158
+ expect(second).toBeDefined();
159
+ expect(second?.toSessionId).toBeNull();
160
+ });
161
+
162
+ test("resumeFromHandoff returns null when no pending handoff exists", async () => {
163
+ const result = await resumeFromHandoff({
164
+ agentsDir,
165
+ agentName: "nonexistent-agent",
166
+ });
167
+ expect(result).toBeNull();
168
+ });
169
+
170
+ test("resumeFromHandoff returns null when all handoffs are completed", async () => {
171
+ await initiateHandoff({
172
+ agentsDir,
173
+ agentName: "builder-5",
174
+ sessionId: "session-500",
175
+ beadId: "overstory-jkl5",
176
+ reason: "compaction",
177
+ progressSummary: "Done",
178
+ pendingWork: "Nothing",
179
+ currentBranch: "overstory/builder-5/overstory-jkl5",
180
+ filesModified: [],
181
+ mulchDomains: [],
182
+ });
183
+
184
+ await completeHandoff({
185
+ agentsDir,
186
+ agentName: "builder-5",
187
+ newSessionId: "session-501",
188
+ });
189
+
190
+ const result = await resumeFromHandoff({
191
+ agentsDir,
192
+ agentName: "builder-5",
193
+ });
194
+ expect(result).toBeNull();
195
+ });
196
+ });