@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,657 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { HierarchyError } from "../errors.ts";
|
|
3
|
+
import {
|
|
4
|
+
type BeaconOptions,
|
|
5
|
+
buildBeacon,
|
|
6
|
+
calculateStaggerDelay,
|
|
7
|
+
checkBeadLock,
|
|
8
|
+
checkRunSessionLimit,
|
|
9
|
+
inferDomainsFromFiles,
|
|
10
|
+
isRunningAsRoot,
|
|
11
|
+
parentHasScouts,
|
|
12
|
+
validateHierarchy,
|
|
13
|
+
} from "./sling.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Tests for the stagger delay enforcement in the sling command (step 4b).
|
|
17
|
+
*
|
|
18
|
+
* The stagger delay logic prevents rapid-fire agent spawning by requiring
|
|
19
|
+
* a minimum delay between consecutive spawns. If the most recently started
|
|
20
|
+
* active session was spawned less than staggerDelayMs ago, the sling command
|
|
21
|
+
* sleeps for the remaining time.
|
|
22
|
+
*
|
|
23
|
+
* calculateStaggerDelay is a pure function that returns the number of
|
|
24
|
+
* milliseconds to sleep (0 if no delay is needed). The sling command calls
|
|
25
|
+
* Bun.sleep with the returned value if it's greater than 0.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// --- Helpers ---
|
|
29
|
+
|
|
30
|
+
function makeSession(startedAt: string): { startedAt: string } {
|
|
31
|
+
return { startedAt };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("calculateStaggerDelay", () => {
|
|
35
|
+
test("returns remaining delay when a recent session exists", () => {
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
// Session started 500ms ago, stagger delay is 2000ms -> should return ~1500ms
|
|
38
|
+
const sessions = [makeSession(new Date(now - 500).toISOString())];
|
|
39
|
+
|
|
40
|
+
const delay = calculateStaggerDelay(2_000, sessions, now);
|
|
41
|
+
|
|
42
|
+
expect(delay).toBe(1_500);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("returns 0 when staggerDelayMs is 0", () => {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
// Even with a very recent session, delay of 0 means no stagger
|
|
48
|
+
const sessions = [makeSession(new Date(now - 100).toISOString())];
|
|
49
|
+
|
|
50
|
+
const delay = calculateStaggerDelay(0, sessions, now);
|
|
51
|
+
|
|
52
|
+
expect(delay).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("returns 0 when no active sessions exist", () => {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
|
|
58
|
+
const delay = calculateStaggerDelay(5_000, [], now);
|
|
59
|
+
|
|
60
|
+
expect(delay).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("returns 0 when enough time has already elapsed", () => {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
// Session started 10 seconds ago, stagger delay is 2 seconds -> no delay
|
|
66
|
+
const sessions = [makeSession(new Date(now - 10_000).toISOString())];
|
|
67
|
+
|
|
68
|
+
const delay = calculateStaggerDelay(2_000, sessions, now);
|
|
69
|
+
|
|
70
|
+
expect(delay).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("returns 0 when elapsed time exactly equals stagger delay", () => {
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
// Session started exactly 2000ms ago, stagger delay is 2000ms -> remaining = 0
|
|
76
|
+
const sessions = [makeSession(new Date(now - 2_000).toISOString())];
|
|
77
|
+
|
|
78
|
+
const delay = calculateStaggerDelay(2_000, sessions, now);
|
|
79
|
+
|
|
80
|
+
expect(delay).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("uses the most recent session for calculation with multiple sessions", () => {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
// Two sessions: one old (5s ago), one recent (200ms ago)
|
|
86
|
+
// With staggerDelayMs=2000, delay should be based on the 200ms-old session
|
|
87
|
+
const sessions = [
|
|
88
|
+
makeSession(new Date(now - 5_000).toISOString()),
|
|
89
|
+
makeSession(new Date(now - 200).toISOString()),
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const delay = calculateStaggerDelay(2_000, sessions, now);
|
|
93
|
+
|
|
94
|
+
expect(delay).toBe(1_800);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("handles sessions in any order (most recent is not last)", () => {
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
// Most recent session is first in the array
|
|
100
|
+
const sessions = [
|
|
101
|
+
makeSession(new Date(now - 300).toISOString()),
|
|
102
|
+
makeSession(new Date(now - 5_000).toISOString()),
|
|
103
|
+
makeSession(new Date(now - 10_000).toISOString()),
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
const delay = calculateStaggerDelay(2_000, sessions, now);
|
|
107
|
+
|
|
108
|
+
expect(delay).toBe(1_700);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("returns 0 when staggerDelayMs is negative", () => {
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
const sessions = [makeSession(new Date(now - 100).toISOString())];
|
|
114
|
+
|
|
115
|
+
const delay = calculateStaggerDelay(-1_000, sessions, now);
|
|
116
|
+
|
|
117
|
+
expect(delay).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("returns full delay when session was just started (elapsed ~0)", () => {
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
// Session started at exactly now
|
|
123
|
+
const sessions = [makeSession(new Date(now).toISOString())];
|
|
124
|
+
|
|
125
|
+
const delay = calculateStaggerDelay(3_000, sessions, now);
|
|
126
|
+
|
|
127
|
+
expect(delay).toBe(3_000);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("handles a single session correctly", () => {
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
const sessions = [makeSession(new Date(now - 1_000).toISOString())];
|
|
133
|
+
|
|
134
|
+
const delay = calculateStaggerDelay(5_000, sessions, now);
|
|
135
|
+
|
|
136
|
+
expect(delay).toBe(4_000);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("handles large stagger delay values", () => {
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
const sessions = [makeSession(new Date(now - 1_000).toISOString())];
|
|
142
|
+
|
|
143
|
+
const delay = calculateStaggerDelay(60_000, sessions, now);
|
|
144
|
+
|
|
145
|
+
expect(delay).toBe(59_000);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("all sessions old enough means no delay, regardless of count", () => {
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
// Many sessions, but all started well before the stagger window
|
|
151
|
+
const sessions = [
|
|
152
|
+
makeSession(new Date(now - 30_000).toISOString()),
|
|
153
|
+
makeSession(new Date(now - 25_000).toISOString()),
|
|
154
|
+
makeSession(new Date(now - 20_000).toISOString()),
|
|
155
|
+
makeSession(new Date(now - 15_000).toISOString()),
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
const delay = calculateStaggerDelay(5_000, sessions, now);
|
|
159
|
+
|
|
160
|
+
expect(delay).toBe(0);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Tests for parentHasScouts check.
|
|
166
|
+
*
|
|
167
|
+
* parentHasScouts is used during sling to detect when a lead agent spawns a
|
|
168
|
+
* builder without having previously spawned any scouts. This provides structural
|
|
169
|
+
* enforcement of the scout-first workflow (Phase 1: explore, Phase 2: build).
|
|
170
|
+
*
|
|
171
|
+
* The function is non-blocking — it only emits a warning to stderr, but does
|
|
172
|
+
* not prevent the spawn. This allows valid edge cases where scout-skip is
|
|
173
|
+
* justified, while surfacing the pattern so agents and operators can see it.
|
|
174
|
+
*/
|
|
175
|
+
|
|
176
|
+
function makeAgentSession(
|
|
177
|
+
parentAgent: string | null,
|
|
178
|
+
capability: string,
|
|
179
|
+
): { parentAgent: string | null; capability: string } {
|
|
180
|
+
return { parentAgent, capability };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
describe("parentHasScouts", () => {
|
|
184
|
+
test("returns false when sessions is empty", () => {
|
|
185
|
+
expect(parentHasScouts([], "lead-alpha")).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("returns false when parent has only builder children", () => {
|
|
189
|
+
const sessions = [
|
|
190
|
+
makeAgentSession("lead-alpha", "builder"),
|
|
191
|
+
makeAgentSession("lead-alpha", "builder"),
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
expect(parentHasScouts(sessions, "lead-alpha")).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("returns true when parent has a scout child", () => {
|
|
198
|
+
const sessions = [makeAgentSession("lead-alpha", "scout")];
|
|
199
|
+
|
|
200
|
+
expect(parentHasScouts(sessions, "lead-alpha")).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("returns true when parent has scout + builder children", () => {
|
|
204
|
+
const sessions = [
|
|
205
|
+
makeAgentSession("lead-alpha", "scout"),
|
|
206
|
+
makeAgentSession("lead-alpha", "builder"),
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
expect(parentHasScouts(sessions, "lead-alpha")).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("ignores scouts from other parents", () => {
|
|
213
|
+
const sessions = [
|
|
214
|
+
makeAgentSession("lead-beta", "scout"),
|
|
215
|
+
makeAgentSession("lead-gamma", "scout"),
|
|
216
|
+
makeAgentSession("lead-alpha", "builder"),
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
expect(parentHasScouts(sessions, "lead-alpha")).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("returns false when parent has only reviewer children", () => {
|
|
223
|
+
const sessions = [
|
|
224
|
+
makeAgentSession("lead-alpha", "reviewer"),
|
|
225
|
+
makeAgentSession("lead-alpha", "reviewer"),
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
expect(parentHasScouts(sessions, "lead-alpha")).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("returns true when parent has multiple scouts", () => {
|
|
232
|
+
const sessions = [
|
|
233
|
+
makeAgentSession("lead-alpha", "scout"),
|
|
234
|
+
makeAgentSession("lead-alpha", "scout"),
|
|
235
|
+
makeAgentSession("lead-alpha", "scout"),
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
expect(parentHasScouts(sessions, "lead-alpha")).toBe(true);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("returns false when sessions contain null parents only", () => {
|
|
242
|
+
const sessions = [makeAgentSession(null, "scout"), makeAgentSession(null, "builder")];
|
|
243
|
+
|
|
244
|
+
expect(parentHasScouts(sessions, "lead-alpha")).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("differentiates between parent names (case-sensitive)", () => {
|
|
248
|
+
const sessions = [
|
|
249
|
+
makeAgentSession("lead-alpha", "scout"),
|
|
250
|
+
makeAgentSession("Lead-Alpha", "scout"),
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
// Should only find the exact match
|
|
254
|
+
expect(parentHasScouts(sessions, "lead-alpha")).toBe(true);
|
|
255
|
+
expect(parentHasScouts(sessions, "Lead-Alpha")).toBe(true);
|
|
256
|
+
expect(parentHasScouts(sessions, "lead-beta")).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("works with mixed capability types", () => {
|
|
260
|
+
const sessions = [
|
|
261
|
+
makeAgentSession("lead-alpha", "builder"),
|
|
262
|
+
makeAgentSession("lead-alpha", "reviewer"),
|
|
263
|
+
makeAgentSession("lead-alpha", "merger"),
|
|
264
|
+
];
|
|
265
|
+
|
|
266
|
+
expect(parentHasScouts(sessions, "lead-alpha")).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Tests for hierarchy validation in sling.
|
|
272
|
+
*
|
|
273
|
+
* validateHierarchy enforces that the coordinator (no --parent flag) can only
|
|
274
|
+
* spawn lead agents. All other capabilities must be spawned by a lead or
|
|
275
|
+
* supervisor that passes --parent. This prevents the flat delegation anti-pattern
|
|
276
|
+
* where the coordinator short-circuits the hierarchy.
|
|
277
|
+
*/
|
|
278
|
+
|
|
279
|
+
describe("validateHierarchy", () => {
|
|
280
|
+
test("rejects builder when parentAgent is null", () => {
|
|
281
|
+
expect(() => validateHierarchy(null, "builder", "test-builder", 0, false)).toThrow(
|
|
282
|
+
HierarchyError,
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("rejects scout when parentAgent is null", () => {
|
|
287
|
+
expect(() => validateHierarchy(null, "scout", "test-scout", 0, false)).toThrow(HierarchyError);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("rejects reviewer when parentAgent is null", () => {
|
|
291
|
+
expect(() => validateHierarchy(null, "reviewer", "test-reviewer", 0, false)).toThrow(
|
|
292
|
+
HierarchyError,
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("rejects merger when parentAgent is null", () => {
|
|
297
|
+
expect(() => validateHierarchy(null, "merger", "test-merger", 0, false)).toThrow(
|
|
298
|
+
HierarchyError,
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("allows lead when parentAgent is null", () => {
|
|
303
|
+
expect(() => validateHierarchy(null, "lead", "test-lead", 0, false)).not.toThrow();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("allows builder when parentAgent is provided", () => {
|
|
307
|
+
expect(() =>
|
|
308
|
+
validateHierarchy("lead-alpha", "builder", "test-builder", 1, false),
|
|
309
|
+
).not.toThrow();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("allows scout when parentAgent is provided", () => {
|
|
313
|
+
expect(() => validateHierarchy("lead-alpha", "scout", "test-scout", 1, false)).not.toThrow();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("allows reviewer when parentAgent is provided", () => {
|
|
317
|
+
expect(() =>
|
|
318
|
+
validateHierarchy("lead-alpha", "reviewer", "test-reviewer", 1, false),
|
|
319
|
+
).not.toThrow();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("--force-hierarchy bypasses the check for builder", () => {
|
|
323
|
+
expect(() => validateHierarchy(null, "builder", "test-builder", 0, true)).not.toThrow();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("--force-hierarchy bypasses the check for scout", () => {
|
|
327
|
+
expect(() => validateHierarchy(null, "scout", "test-scout", 0, true)).not.toThrow();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("error has correct fields and code", () => {
|
|
331
|
+
try {
|
|
332
|
+
validateHierarchy(null, "builder", "my-builder", 0, false);
|
|
333
|
+
expect.unreachable("should have thrown");
|
|
334
|
+
} catch (err) {
|
|
335
|
+
expect(err).toBeInstanceOf(HierarchyError);
|
|
336
|
+
const he = err as HierarchyError;
|
|
337
|
+
expect(he.code).toBe("HIERARCHY_VIOLATION");
|
|
338
|
+
expect(he.agentName).toBe("my-builder");
|
|
339
|
+
expect(he.requestedCapability).toBe("builder");
|
|
340
|
+
expect(he.message).toContain("builder");
|
|
341
|
+
expect(he.message).toContain("lead");
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Tests for the structured startup beacon sent to agents via tmux send-keys.
|
|
348
|
+
*
|
|
349
|
+
* buildBeacon is a pure function that constructs the first user message an
|
|
350
|
+
* agent sees. It includes identity context (name, capability, task ID),
|
|
351
|
+
* hierarchy info (depth, parent), and startup instructions.
|
|
352
|
+
*
|
|
353
|
+
* The beacon is a single-line string (parts joined by " — ") to prevent
|
|
354
|
+
* multiline tmux send-keys issues (overstory-y2ob, overstory-cczf).
|
|
355
|
+
*/
|
|
356
|
+
|
|
357
|
+
function makeBeaconOpts(overrides?: Partial<BeaconOptions>): BeaconOptions {
|
|
358
|
+
return {
|
|
359
|
+
agentName: "test-builder",
|
|
360
|
+
capability: "builder",
|
|
361
|
+
taskId: "overstory-abc",
|
|
362
|
+
parentAgent: null,
|
|
363
|
+
depth: 0,
|
|
364
|
+
...overrides,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
describe("buildBeacon", () => {
|
|
369
|
+
test("is a single line (no newlines)", () => {
|
|
370
|
+
const beacon = buildBeacon(makeBeaconOpts());
|
|
371
|
+
|
|
372
|
+
expect(beacon).not.toContain("\n");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("includes agent identity and task ID in header", () => {
|
|
376
|
+
const beacon = buildBeacon(makeBeaconOpts());
|
|
377
|
+
|
|
378
|
+
expect(beacon).toContain("[OVERSTORY] test-builder (builder) ");
|
|
379
|
+
expect(beacon).toContain("task:overstory-abc");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("includes ISO timestamp", () => {
|
|
383
|
+
const beacon = buildBeacon(makeBeaconOpts());
|
|
384
|
+
|
|
385
|
+
expect(beacon).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("includes depth and parent info", () => {
|
|
389
|
+
const beacon = buildBeacon(makeBeaconOpts({ depth: 1, parentAgent: "lead-alpha" }));
|
|
390
|
+
|
|
391
|
+
expect(beacon).toContain("Depth: 1 | Parent: lead-alpha");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("shows 'none' for parent when no parent agent", () => {
|
|
395
|
+
const beacon = buildBeacon(makeBeaconOpts({ parentAgent: null }));
|
|
396
|
+
|
|
397
|
+
expect(beacon).toContain("Depth: 0 | Parent: none");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("includes startup instructions with agent name and task ID", () => {
|
|
401
|
+
const opts = makeBeaconOpts({ agentName: "scout-1", taskId: "overstory-xyz" });
|
|
402
|
+
const beacon = buildBeacon(opts);
|
|
403
|
+
|
|
404
|
+
expect(beacon).toContain("read .claude/CLAUDE.md");
|
|
405
|
+
expect(beacon).toContain("mulch prime");
|
|
406
|
+
expect(beacon).toContain("overstory mail check --agent scout-1");
|
|
407
|
+
expect(beacon).toContain("begin task overstory-xyz");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("uses agent name in mail check command", () => {
|
|
411
|
+
const beacon = buildBeacon(makeBeaconOpts({ agentName: "reviewer-beta" }));
|
|
412
|
+
|
|
413
|
+
expect(beacon).toContain("overstory mail check --agent reviewer-beta");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("reflects capability in header", () => {
|
|
417
|
+
const beacon = buildBeacon(makeBeaconOpts({ capability: "scout" }));
|
|
418
|
+
|
|
419
|
+
expect(beacon).toContain("(scout)");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("works with hierarchy depth > 0 and parent", () => {
|
|
423
|
+
const beacon = buildBeacon(
|
|
424
|
+
makeBeaconOpts({
|
|
425
|
+
agentName: "worker-3",
|
|
426
|
+
capability: "builder",
|
|
427
|
+
taskId: "overstory-deep",
|
|
428
|
+
parentAgent: "lead-main",
|
|
429
|
+
depth: 2,
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
expect(beacon).toContain("[OVERSTORY] worker-3 (builder)");
|
|
434
|
+
expect(beacon).toContain("task:overstory-deep");
|
|
435
|
+
expect(beacon).toContain("Depth: 2 | Parent: lead-main");
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Tests for inferDomainsFromFiles.
|
|
441
|
+
*
|
|
442
|
+
* This pure function maps file paths to mulch domains using inferDomain(),
|
|
443
|
+
* deduplicates results, sorts them alphabetically, and falls back to
|
|
444
|
+
* configDomains when no paths produce a domain mapping.
|
|
445
|
+
*/
|
|
446
|
+
|
|
447
|
+
describe("inferDomainsFromFiles", () => {
|
|
448
|
+
test("infers cli domain from src/commands/ files", () => {
|
|
449
|
+
const domains = inferDomainsFromFiles(["src/commands/sling.ts"], []);
|
|
450
|
+
|
|
451
|
+
expect(domains).toEqual(["cli"]);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("infers messaging domain from src/mail/ files", () => {
|
|
455
|
+
const domains = inferDomainsFromFiles(["src/mail/store.ts"], []);
|
|
456
|
+
|
|
457
|
+
expect(domains).toEqual(["messaging"]);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("infers typescript domain from general src/ files", () => {
|
|
461
|
+
const domains = inferDomainsFromFiles(["src/config.ts"], []);
|
|
462
|
+
|
|
463
|
+
expect(domains).toEqual(["typescript"]);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("infers cli domain from .test.ts files in src/commands/ (commands check takes priority)", () => {
|
|
467
|
+
const domains = inferDomainsFromFiles(["src/commands/sling.test.ts"], []);
|
|
468
|
+
|
|
469
|
+
// src/commands/ check runs before .test.ts check in inferDomain
|
|
470
|
+
expect(domains).toEqual(["cli"]);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("infers typescript domain from .test.ts files outside recognized directories", () => {
|
|
474
|
+
const domains = inferDomainsFromFiles(["src/config.test.ts"], []);
|
|
475
|
+
|
|
476
|
+
// src/ match triggers typescript (config.test.ts is not in a specific subdirectory)
|
|
477
|
+
expect(domains).toEqual(["typescript"]);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("deduplicates domains across multiple files", () => {
|
|
481
|
+
const files = ["src/commands/sling.ts", "src/commands/init.ts", "src/commands/merge.ts"];
|
|
482
|
+
const domains = inferDomainsFromFiles(files, []);
|
|
483
|
+
|
|
484
|
+
expect(domains).toEqual(["cli"]);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("returns multiple domains sorted alphabetically", () => {
|
|
488
|
+
const files = ["src/commands/sling.ts", "src/mail/store.ts"];
|
|
489
|
+
const domains = inferDomainsFromFiles(files, []);
|
|
490
|
+
|
|
491
|
+
expect(domains).toEqual(["cli", "messaging"]);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("falls back to configDomains when no files match", () => {
|
|
495
|
+
const domains = inferDomainsFromFiles(["docs/README.md"], ["typescript", "cli"]);
|
|
496
|
+
|
|
497
|
+
expect(domains).toEqual(["typescript", "cli"]);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("falls back to configDomains when files list is empty", () => {
|
|
501
|
+
const domains = inferDomainsFromFiles([], ["agents"]);
|
|
502
|
+
|
|
503
|
+
expect(domains).toEqual(["agents"]);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("returns empty array when no files match and configDomains is empty", () => {
|
|
507
|
+
const domains = inferDomainsFromFiles(["docs/README.md"], []);
|
|
508
|
+
|
|
509
|
+
expect(domains).toEqual([]);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("infers agents domain from src/agents/ files", () => {
|
|
513
|
+
const domains = inferDomainsFromFiles(["src/agents/manifest.ts"], []);
|
|
514
|
+
|
|
515
|
+
expect(domains).toEqual(["agents"]);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("infers architecture domain from src/merge/ files", () => {
|
|
519
|
+
const domains = inferDomainsFromFiles(["src/merge/queue.ts"], []);
|
|
520
|
+
|
|
521
|
+
expect(domains).toEqual(["architecture"]);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("infers architecture domain from src/worktree/ files", () => {
|
|
525
|
+
const domains = inferDomainsFromFiles(["src/worktree/manager.ts"], []);
|
|
526
|
+
|
|
527
|
+
expect(domains).toEqual(["architecture"]);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test("handles mixed file scopes producing multiple domains", () => {
|
|
531
|
+
const files = ["src/commands/sling.ts", "src/agents/manifest.ts", "src/mail/client.ts"];
|
|
532
|
+
const domains = inferDomainsFromFiles(files, []);
|
|
533
|
+
|
|
534
|
+
expect(domains).toEqual(["agents", "cli", "messaging"]);
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
describe("isRunningAsRoot", () => {
|
|
539
|
+
test("returns true when getuid returns 0", () => {
|
|
540
|
+
expect(isRunningAsRoot(() => 0)).toBe(true);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("returns false when getuid returns non-zero UID", () => {
|
|
544
|
+
expect(isRunningAsRoot(() => 1000)).toBe(false);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test("returns false when getuid is undefined (platform without getuid)", () => {
|
|
548
|
+
expect(isRunningAsRoot(undefined)).toBe(false);
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Tests for checkBeadLock.
|
|
554
|
+
*
|
|
555
|
+
* checkBeadLock prevents concurrent agents from working the same bead ID.
|
|
556
|
+
* It checks the active session list and returns the agent name that holds
|
|
557
|
+
* the lock (i.e., is already working on the bead), or null if the bead is free.
|
|
558
|
+
*/
|
|
559
|
+
|
|
560
|
+
function makeBeadSession(agentName: string, beadId: string): { agentName: string; beadId: string } {
|
|
561
|
+
return { agentName, beadId };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
describe("checkBeadLock", () => {
|
|
565
|
+
test("returns null when no sessions exist", () => {
|
|
566
|
+
expect(checkBeadLock([], "overstory-abc")).toBeNull();
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("returns null when no session matches the bead ID", () => {
|
|
570
|
+
const sessions = [
|
|
571
|
+
makeBeadSession("builder-1", "overstory-xyz"),
|
|
572
|
+
makeBeadSession("builder-2", "overstory-def"),
|
|
573
|
+
];
|
|
574
|
+
|
|
575
|
+
expect(checkBeadLock(sessions, "overstory-abc")).toBeNull();
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("returns the agent name when a session matches", () => {
|
|
579
|
+
const sessions = [
|
|
580
|
+
makeBeadSession("builder-1", "overstory-abc"),
|
|
581
|
+
makeBeadSession("builder-2", "overstory-xyz"),
|
|
582
|
+
];
|
|
583
|
+
|
|
584
|
+
expect(checkBeadLock(sessions, "overstory-abc")).toBe("builder-1");
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("returns the first matching agent when multiple sessions match", () => {
|
|
588
|
+
// Multiple sessions can have the same beadId (e.g., retried agent)
|
|
589
|
+
// checkBeadLock returns the first match
|
|
590
|
+
const sessions = [
|
|
591
|
+
makeBeadSession("builder-1", "overstory-abc"),
|
|
592
|
+
makeBeadSession("builder-2", "overstory-abc"),
|
|
593
|
+
];
|
|
594
|
+
|
|
595
|
+
expect(checkBeadLock(sessions, "overstory-abc")).toBe("builder-1");
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
describe("checkBeadLock parent bypass", () => {
|
|
600
|
+
test("parent matching lock holder is allowed (returns lock holder name for caller to compare)", () => {
|
|
601
|
+
// checkBeadLock is a pure function — it returns the lock holder name or null.
|
|
602
|
+
// The parent bypass logic is in slingCommand, not checkBeadLock.
|
|
603
|
+
// These tests verify the building blocks work correctly.
|
|
604
|
+
const sessions = [makeBeadSession("lead-alpha", "overstory-abc")];
|
|
605
|
+
// checkBeadLock still returns the holder — the caller (slingCommand) decides
|
|
606
|
+
// whether to allow based on parentAgent match.
|
|
607
|
+
expect(checkBeadLock(sessions, "overstory-abc")).toBe("lead-alpha");
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test("non-parent lock holder blocks spawn", () => {
|
|
611
|
+
const sessions = [makeBeadSession("other-agent", "overstory-abc")];
|
|
612
|
+
const lockHolder = checkBeadLock(sessions, "overstory-abc");
|
|
613
|
+
const parentAgent = "lead-alpha";
|
|
614
|
+
// lockHolder is 'other-agent', parentAgent is 'lead-alpha' — not equal, should block
|
|
615
|
+
expect(lockHolder).not.toBeNull();
|
|
616
|
+
expect(lockHolder).not.toBe(parentAgent);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("null parent with lock holder blocks spawn", () => {
|
|
620
|
+
const sessions = [makeBeadSession("lead-alpha", "overstory-abc")];
|
|
621
|
+
const lockHolder = checkBeadLock(sessions, "overstory-abc");
|
|
622
|
+
const parentAgent = null;
|
|
623
|
+
// lockHolder is non-null and parentAgent is null — should block
|
|
624
|
+
expect(lockHolder).not.toBeNull();
|
|
625
|
+
expect(lockHolder).not.toBe(parentAgent);
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Tests for checkRunSessionLimit.
|
|
631
|
+
*
|
|
632
|
+
* checkRunSessionLimit prevents spawning when the per-run agent cap is reached.
|
|
633
|
+
* A limit of 0 (or negative) means unlimited. Returns true if the limit
|
|
634
|
+
* is reached (spawn should be blocked), false otherwise.
|
|
635
|
+
*/
|
|
636
|
+
|
|
637
|
+
describe("checkRunSessionLimit", () => {
|
|
638
|
+
test("returns false when limit is 0 (unlimited)", () => {
|
|
639
|
+
expect(checkRunSessionLimit(0, 100)).toBe(false);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test("returns false when count is below limit", () => {
|
|
643
|
+
expect(checkRunSessionLimit(10, 5)).toBe(false);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("returns true when count equals limit", () => {
|
|
647
|
+
expect(checkRunSessionLimit(10, 10)).toBe(true);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test("returns true when count exceeds limit", () => {
|
|
651
|
+
expect(checkRunSessionLimit(10, 15)).toBe(true);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test("returns false when limit is negative (treated as unlimited)", () => {
|
|
655
|
+
expect(checkRunSessionLimit(-1, 100)).toBe(false);
|
|
656
|
+
});
|
|
657
|
+
});
|