@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,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
|
+
});
|