@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,661 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: overstory sling <task-id>
|
|
3
|
+
*
|
|
4
|
+
* CRITICAL PATH. Orchestrates a full agent spawn:
|
|
5
|
+
* 1. Load config + manifest
|
|
6
|
+
* 2. Validate (depth limit, hierarchy)
|
|
7
|
+
* 3. Load manifest + validate capability
|
|
8
|
+
* 4. Resolve or create run_id (current-run.txt)
|
|
9
|
+
* 5. Check name uniqueness + concurrency limit
|
|
10
|
+
* 6. Validate bead exists
|
|
11
|
+
* 7. Create worktree
|
|
12
|
+
* 8. Generate + write overlay CLAUDE.md
|
|
13
|
+
* 9. Deploy hooks config
|
|
14
|
+
* 10. Claim beads issue
|
|
15
|
+
* 11. Create agent identity
|
|
16
|
+
* 12. Create tmux session running claude
|
|
17
|
+
* 13. Record session in SessionStore + increment run agent count
|
|
18
|
+
* 14. Return AgentSession
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { mkdir } from "node:fs/promises";
|
|
22
|
+
import { join, resolve } from "node:path";
|
|
23
|
+
import { deployHooks } from "../agents/hooks-deployer.ts";
|
|
24
|
+
import { createIdentity, loadIdentity } from "../agents/identity.ts";
|
|
25
|
+
import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
|
|
26
|
+
import { writeOverlay } from "../agents/overlay.ts";
|
|
27
|
+
import { loadConfig } from "../config.ts";
|
|
28
|
+
import { AgentError, HierarchyError, ValidationError } from "../errors.ts";
|
|
29
|
+
import { inferDomain } from "../insights/analyzer.ts";
|
|
30
|
+
import { createMulchClient } from "../mulch/client.ts";
|
|
31
|
+
import { openSessionStore } from "../sessions/compat.ts";
|
|
32
|
+
import { createRunStore } from "../sessions/store.ts";
|
|
33
|
+
import type { TrackerIssue } from "../tracker/factory.ts";
|
|
34
|
+
import { createTrackerClient, resolveBackend, trackerCliName } from "../tracker/factory.ts";
|
|
35
|
+
import type { AgentSession, OverlayConfig } from "../types.ts";
|
|
36
|
+
import { createWorktree } from "../worktree/manager.ts";
|
|
37
|
+
import { createSession, sendKeys, waitForTuiReady } from "../worktree/tmux.ts";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Calculate how many milliseconds to sleep before spawning a new agent,
|
|
41
|
+
* based on the configured stagger delay and when the most recent active
|
|
42
|
+
* session was started.
|
|
43
|
+
*
|
|
44
|
+
* Returns 0 if no sleep is needed (no active sessions, delay is 0, or
|
|
45
|
+
* enough time has already elapsed).
|
|
46
|
+
*
|
|
47
|
+
* @param staggerDelayMs - The configured minimum delay between spawns
|
|
48
|
+
* @param activeSessions - Currently active (non-zombie) sessions
|
|
49
|
+
* @param now - Current timestamp in ms (defaults to Date.now(), injectable for testing)
|
|
50
|
+
*/
|
|
51
|
+
export function calculateStaggerDelay(
|
|
52
|
+
staggerDelayMs: number,
|
|
53
|
+
activeSessions: ReadonlyArray<{ startedAt: string }>,
|
|
54
|
+
now: number = Date.now(),
|
|
55
|
+
): number {
|
|
56
|
+
if (staggerDelayMs <= 0 || activeSessions.length === 0) {
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const mostRecent = activeSessions.reduce((latest, s) => {
|
|
61
|
+
return new Date(s.startedAt).getTime() > new Date(latest.startedAt).getTime() ? s : latest;
|
|
62
|
+
});
|
|
63
|
+
const elapsed = now - new Date(mostRecent.startedAt).getTime();
|
|
64
|
+
const remaining = staggerDelayMs - elapsed;
|
|
65
|
+
return remaining > 0 ? remaining : 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check if the current process is running as root (UID 0).
|
|
70
|
+
* Returns true if running as root, false otherwise.
|
|
71
|
+
* Returns false on platforms that don't support getuid (e.g., Windows).
|
|
72
|
+
*
|
|
73
|
+
* The getuid parameter is injectable for testability without mocking process.getuid.
|
|
74
|
+
*/
|
|
75
|
+
export function isRunningAsRoot(getuid: (() => number) | undefined = process.getuid): boolean {
|
|
76
|
+
return getuid?.() === 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Infer mulch domains from a list of file paths.
|
|
81
|
+
* Returns unique domains sorted alphabetically, falling back to
|
|
82
|
+
* configured defaults if no domains could be inferred.
|
|
83
|
+
*/
|
|
84
|
+
export function inferDomainsFromFiles(
|
|
85
|
+
files: readonly string[],
|
|
86
|
+
configDomains: readonly string[],
|
|
87
|
+
): string[] {
|
|
88
|
+
const inferred = new Set<string>();
|
|
89
|
+
for (const file of files) {
|
|
90
|
+
const domain = inferDomain(file);
|
|
91
|
+
if (domain !== null) {
|
|
92
|
+
inferred.add(domain);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (inferred.size === 0) {
|
|
96
|
+
return [...configDomains];
|
|
97
|
+
}
|
|
98
|
+
return [...inferred].sort();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Parse a named flag value from an args array.
|
|
103
|
+
*/
|
|
104
|
+
function getFlag(args: string[], flag: string): string | undefined {
|
|
105
|
+
const idx = args.indexOf(flag);
|
|
106
|
+
if (idx === -1 || idx + 1 >= args.length) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
return args[idx + 1];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Options for building the structured startup beacon.
|
|
114
|
+
*/
|
|
115
|
+
export interface BeaconOptions {
|
|
116
|
+
agentName: string;
|
|
117
|
+
capability: string;
|
|
118
|
+
taskId: string;
|
|
119
|
+
parentAgent: string | null;
|
|
120
|
+
depth: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Build a structured startup beacon for an agent.
|
|
125
|
+
*
|
|
126
|
+
* The beacon is the first user message sent to a Claude Code agent via
|
|
127
|
+
* tmux send-keys. It provides identity context and a numbered startup
|
|
128
|
+
* protocol so the agent knows exactly what to do on boot.
|
|
129
|
+
*
|
|
130
|
+
* Format:
|
|
131
|
+
* [OVERSTORY] <agent-name> (<capability>) <ISO timestamp> task:<bead-id>
|
|
132
|
+
* Depth: <n> | Parent: <parent-name|none>
|
|
133
|
+
* Startup protocol:
|
|
134
|
+
* 1. Read your assignment in .claude/CLAUDE.md
|
|
135
|
+
* 2. Load expertise: mulch prime
|
|
136
|
+
* 3. Check mail: overstory mail check --agent <name>
|
|
137
|
+
* 4. Begin working on task <bead-id>
|
|
138
|
+
*/
|
|
139
|
+
export function buildBeacon(opts: BeaconOptions): string {
|
|
140
|
+
const timestamp = new Date().toISOString();
|
|
141
|
+
const parent = opts.parentAgent ?? "none";
|
|
142
|
+
const parts = [
|
|
143
|
+
`[OVERSTORY] ${opts.agentName} (${opts.capability}) ${timestamp} task:${opts.taskId}`,
|
|
144
|
+
`Depth: ${opts.depth} | Parent: ${parent}`,
|
|
145
|
+
`Startup: read .claude/CLAUDE.md, run mulch prime, check mail (overstory mail check --agent ${opts.agentName}), then begin task ${opts.taskId}`,
|
|
146
|
+
];
|
|
147
|
+
return parts.join(" — ");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Check if a parent agent has spawned any scouts.
|
|
152
|
+
* Returns true if the parent has at least one scout child in the session history.
|
|
153
|
+
*/
|
|
154
|
+
export function parentHasScouts(
|
|
155
|
+
sessions: ReadonlyArray<{ parentAgent: string | null; capability: string }>,
|
|
156
|
+
parentAgent: string,
|
|
157
|
+
): boolean {
|
|
158
|
+
return sessions.some((s) => s.parentAgent === parentAgent && s.capability === "scout");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Check if any active agent is already working on the given bead ID.
|
|
163
|
+
* Returns the agent name if locked, or null if the bead is free.
|
|
164
|
+
*
|
|
165
|
+
* @param activeSessions - Currently active (non-zombie) sessions
|
|
166
|
+
* @param beadId - The bead task ID to check for concurrent work
|
|
167
|
+
*/
|
|
168
|
+
export function checkBeadLock(
|
|
169
|
+
activeSessions: ReadonlyArray<{ agentName: string; beadId: string }>,
|
|
170
|
+
beadId: string,
|
|
171
|
+
): string | null {
|
|
172
|
+
const existing = activeSessions.find((s) => s.beadId === beadId);
|
|
173
|
+
return existing?.agentName ?? null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Check if spawning another agent would exceed the per-run session limit.
|
|
178
|
+
* Returns true if the limit is reached. A limit of 0 means unlimited.
|
|
179
|
+
*
|
|
180
|
+
* @param maxSessionsPerRun - Config limit (0 = unlimited)
|
|
181
|
+
* @param currentRunAgentCount - Number of agents already spawned in this run
|
|
182
|
+
*/
|
|
183
|
+
export function checkRunSessionLimit(
|
|
184
|
+
maxSessionsPerRun: number,
|
|
185
|
+
currentRunAgentCount: number,
|
|
186
|
+
): boolean {
|
|
187
|
+
if (maxSessionsPerRun <= 0) return false;
|
|
188
|
+
return currentRunAgentCount >= maxSessionsPerRun;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Validate hierarchy constraints: the coordinator (no parent) may only spawn leads.
|
|
193
|
+
*
|
|
194
|
+
* When parentAgent is null, the caller is the coordinator or a human.
|
|
195
|
+
* Only "lead" capability is allowed in that case. All other capabilities
|
|
196
|
+
* (builder, scout, reviewer, merger) must be spawned by a lead or supervisor
|
|
197
|
+
* that passes --parent.
|
|
198
|
+
*
|
|
199
|
+
* @param parentAgent - The --parent flag value (null = coordinator/human)
|
|
200
|
+
* @param capability - The requested agent capability
|
|
201
|
+
* @param name - The agent name (for error context)
|
|
202
|
+
* @param depth - The requested hierarchy depth
|
|
203
|
+
* @param forceHierarchy - If true, bypass the check (for debugging)
|
|
204
|
+
* @throws HierarchyError if the constraint is violated
|
|
205
|
+
*/
|
|
206
|
+
export function validateHierarchy(
|
|
207
|
+
parentAgent: string | null,
|
|
208
|
+
capability: string,
|
|
209
|
+
name: string,
|
|
210
|
+
_depth: number,
|
|
211
|
+
forceHierarchy: boolean,
|
|
212
|
+
): void {
|
|
213
|
+
if (forceHierarchy) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (parentAgent === null && capability !== "lead") {
|
|
218
|
+
throw new HierarchyError(
|
|
219
|
+
`Coordinator cannot spawn "${capability}" directly. Only "lead" is allowed without --parent. Use a lead as intermediary, or pass --force-hierarchy to bypass.`,
|
|
220
|
+
{ agentName: name, requestedCapability: capability },
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Entry point for `overstory sling <task-id> [flags]`.
|
|
227
|
+
*
|
|
228
|
+
* Flags:
|
|
229
|
+
* --capability <type> builder | scout | reviewer | lead | merger
|
|
230
|
+
* --name <name> Unique agent name
|
|
231
|
+
* --spec <path> Path to task spec file
|
|
232
|
+
* --files <f1,f2,...> Exclusive file scope
|
|
233
|
+
* --parent <agent-name> Parent agent (for hierarchy tracking)
|
|
234
|
+
* --depth <n> Current hierarchy depth (default 0)
|
|
235
|
+
* --force-hierarchy Bypass hierarchy validation (debugging only)
|
|
236
|
+
*/
|
|
237
|
+
const SLING_HELP = `overstory sling — Spawn a worker agent
|
|
238
|
+
|
|
239
|
+
Usage: overstory sling <task-id> [flags]
|
|
240
|
+
|
|
241
|
+
Arguments:
|
|
242
|
+
<task-id> Beads task ID to assign
|
|
243
|
+
|
|
244
|
+
Options:
|
|
245
|
+
--capability <type> Agent type: builder | scout | reviewer | lead | merger (default: builder)
|
|
246
|
+
--name <name> Unique agent name (required)
|
|
247
|
+
--spec <path> Path to task spec file
|
|
248
|
+
--files <f1,f2,...> Exclusive file scope (comma-separated)
|
|
249
|
+
--parent <agent-name> Parent agent for hierarchy tracking
|
|
250
|
+
--depth <n> Current hierarchy depth (default: 0)
|
|
251
|
+
--skip-scout Skip scout phase for lead agents (jump to build)
|
|
252
|
+
--skip-task-check Skip task existence validation (for worktree-created issues)
|
|
253
|
+
--force-hierarchy Bypass hierarchy validation (debugging only)
|
|
254
|
+
--json Output result as JSON
|
|
255
|
+
--help, -h Show this help`;
|
|
256
|
+
|
|
257
|
+
export async function slingCommand(args: string[]): Promise<void> {
|
|
258
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
259
|
+
process.stdout.write(`${SLING_HELP}\n`);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const taskId = args.find((a) => !a.startsWith("--"));
|
|
264
|
+
if (!taskId) {
|
|
265
|
+
throw new ValidationError("Task ID is required: overstory sling <task-id>", {
|
|
266
|
+
field: "taskId",
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const capability = getFlag(args, "--capability") ?? "builder";
|
|
271
|
+
const name = getFlag(args, "--name");
|
|
272
|
+
const specPath = getFlag(args, "--spec") ?? null;
|
|
273
|
+
const filesRaw = getFlag(args, "--files");
|
|
274
|
+
const parentAgent = getFlag(args, "--parent") ?? null;
|
|
275
|
+
const depthStr = getFlag(args, "--depth");
|
|
276
|
+
const depth = depthStr !== undefined ? Number.parseInt(depthStr, 10) : 0;
|
|
277
|
+
const forceHierarchy = args.includes("--force-hierarchy");
|
|
278
|
+
const skipScout = args.includes("--skip-scout");
|
|
279
|
+
const skipTaskCheck = args.includes("--skip-task-check");
|
|
280
|
+
|
|
281
|
+
if (!name || name.trim().length === 0) {
|
|
282
|
+
throw new ValidationError("--name is required for sling", { field: "name" });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (Number.isNaN(depth) || depth < 0) {
|
|
286
|
+
throw new ValidationError("--depth must be a non-negative integer", {
|
|
287
|
+
field: "depth",
|
|
288
|
+
value: depthStr,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (isRunningAsRoot()) {
|
|
293
|
+
throw new AgentError(
|
|
294
|
+
"Cannot spawn agents as root (UID 0). The claude CLI rejects --dangerously-skip-permissions when run as root, causing the tmux session to die immediately. Run overstory as a non-root user.",
|
|
295
|
+
{ agentName: name },
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Warn if --skip-scout is used for a non-lead capability (harmless but confusing)
|
|
300
|
+
if (skipScout && capability !== "lead") {
|
|
301
|
+
process.stderr.write(
|
|
302
|
+
`⚠️ Warning: --skip-scout is only meaningful for leads. Ignoring for "${capability}" agent "${name}".\n`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (skipTaskCheck && !parentAgent) {
|
|
307
|
+
process.stderr.write(
|
|
308
|
+
`Warning: --skip-task-check without --parent is unusual. This flag is designed for leads spawning builders with worktree-created issues.\n`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Validate that spec file exists if provided, and resolve to absolute path
|
|
313
|
+
// so agents in worktrees can access it (worktrees don't have .overstory/)
|
|
314
|
+
let absoluteSpecPath: string | null = null;
|
|
315
|
+
if (specPath !== null) {
|
|
316
|
+
absoluteSpecPath = resolve(specPath);
|
|
317
|
+
const specFile = Bun.file(absoluteSpecPath);
|
|
318
|
+
const specExists = await specFile.exists();
|
|
319
|
+
if (!specExists) {
|
|
320
|
+
throw new ValidationError(`Spec file not found: ${specPath}`, {
|
|
321
|
+
field: "spec",
|
|
322
|
+
value: specPath,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const fileScope = filesRaw
|
|
328
|
+
? filesRaw
|
|
329
|
+
.split(",")
|
|
330
|
+
.map((f) => f.trim())
|
|
331
|
+
.filter((f) => f.length > 0)
|
|
332
|
+
: [];
|
|
333
|
+
|
|
334
|
+
// 1. Load config
|
|
335
|
+
const cwd = process.cwd();
|
|
336
|
+
const config = await loadConfig(cwd);
|
|
337
|
+
const resolvedBackend = await resolveBackend(config.taskTracker.backend, config.project.root);
|
|
338
|
+
|
|
339
|
+
// 2. Validate depth limit
|
|
340
|
+
// Hierarchy: orchestrator(0) -> lead(1) -> specialist(2)
|
|
341
|
+
// With maxDepth=2, depth=2 is the deepest allowed leaf, so reject only depth > maxDepth
|
|
342
|
+
if (depth > config.agents.maxDepth) {
|
|
343
|
+
throw new AgentError(
|
|
344
|
+
`Depth limit exceeded: depth ${depth} > maxDepth ${config.agents.maxDepth}`,
|
|
345
|
+
{ agentName: name },
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 2b. Validate hierarchy: coordinator (no --parent) can only spawn leads
|
|
350
|
+
validateHierarchy(parentAgent, capability, name, depth, forceHierarchy);
|
|
351
|
+
|
|
352
|
+
// 3. Load manifest and validate capability
|
|
353
|
+
const manifestLoader = createManifestLoader(
|
|
354
|
+
join(config.project.root, config.agents.manifestPath),
|
|
355
|
+
join(config.project.root, config.agents.baseDir),
|
|
356
|
+
);
|
|
357
|
+
const manifest = await manifestLoader.load();
|
|
358
|
+
|
|
359
|
+
const agentDef = manifest.agents[capability];
|
|
360
|
+
if (!agentDef) {
|
|
361
|
+
throw new AgentError(
|
|
362
|
+
`Unknown capability "${capability}". Available: ${Object.keys(manifest.agents).join(", ")}`,
|
|
363
|
+
{ agentName: name, capability },
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// 4. Resolve or create run_id for this spawn
|
|
368
|
+
const overstoryDir = join(config.project.root, ".overstory");
|
|
369
|
+
const currentRunPath = join(overstoryDir, "current-run.txt");
|
|
370
|
+
let runId: string;
|
|
371
|
+
|
|
372
|
+
const currentRunFile = Bun.file(currentRunPath);
|
|
373
|
+
if (await currentRunFile.exists()) {
|
|
374
|
+
runId = (await currentRunFile.text()).trim();
|
|
375
|
+
} else {
|
|
376
|
+
runId = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
377
|
+
const runStore = createRunStore(join(overstoryDir, "sessions.db"));
|
|
378
|
+
try {
|
|
379
|
+
runStore.createRun({
|
|
380
|
+
id: runId,
|
|
381
|
+
startedAt: new Date().toISOString(),
|
|
382
|
+
coordinatorSessionId: null,
|
|
383
|
+
status: "active",
|
|
384
|
+
});
|
|
385
|
+
} finally {
|
|
386
|
+
runStore.close();
|
|
387
|
+
}
|
|
388
|
+
await Bun.write(currentRunPath, runId);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// 4b. Check per-run session limit
|
|
392
|
+
if (config.agents.maxSessionsPerRun > 0) {
|
|
393
|
+
const runCheckStore = createRunStore(join(overstoryDir, "sessions.db"));
|
|
394
|
+
try {
|
|
395
|
+
const run = runCheckStore.getRun(runId);
|
|
396
|
+
if (run && checkRunSessionLimit(config.agents.maxSessionsPerRun, run.agentCount)) {
|
|
397
|
+
throw new AgentError(
|
|
398
|
+
`Run session limit reached: ${run.agentCount}/${config.agents.maxSessionsPerRun} agents spawned in run "${runId}". ` +
|
|
399
|
+
`Increase agents.maxSessionsPerRun in config.yaml or start a new run.`,
|
|
400
|
+
{ agentName: name },
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
} finally {
|
|
404
|
+
runCheckStore.close();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 5. Check name uniqueness and concurrency limit against active sessions
|
|
409
|
+
const { store } = openSessionStore(overstoryDir);
|
|
410
|
+
try {
|
|
411
|
+
const activeSessions = store.getActive();
|
|
412
|
+
if (activeSessions.length >= config.agents.maxConcurrent) {
|
|
413
|
+
throw new AgentError(
|
|
414
|
+
`Max concurrent agent limit reached: ${activeSessions.length}/${config.agents.maxConcurrent} active agents`,
|
|
415
|
+
{ agentName: name },
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const existing = store.getByName(name);
|
|
420
|
+
if (existing && existing.state !== "zombie" && existing.state !== "completed") {
|
|
421
|
+
throw new AgentError(`Agent name "${name}" is already in use (state: ${existing.state})`, {
|
|
422
|
+
agentName: name,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// 5d. Bead-level locking: prevent concurrent agents on the same bead ID.
|
|
427
|
+
// Exception: the parent agent may delegate its own task to a child.
|
|
428
|
+
const lockHolder = checkBeadLock(activeSessions, taskId);
|
|
429
|
+
if (lockHolder !== null && lockHolder !== parentAgent) {
|
|
430
|
+
throw new AgentError(
|
|
431
|
+
`Bead "${taskId}" is already being worked by agent "${lockHolder}". ` +
|
|
432
|
+
`Concurrent work on the same bead causes duplicate issues and wasted tokens.`,
|
|
433
|
+
{ agentName: name },
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// 5b. Enforce stagger delay between agent spawns
|
|
438
|
+
const staggerMs = calculateStaggerDelay(config.agents.staggerDelayMs, activeSessions);
|
|
439
|
+
if (staggerMs > 0) {
|
|
440
|
+
await Bun.sleep(staggerMs);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// 5c. Structural enforcement: warn when a lead spawns a builder without prior scouts.
|
|
444
|
+
// This is a non-blocking warning — it does not prevent the spawn, but surfaces
|
|
445
|
+
// the scout-skip pattern so agents and operators can see it happening.
|
|
446
|
+
if (capability === "builder" && parentAgent && !parentHasScouts(store.getAll(), parentAgent)) {
|
|
447
|
+
process.stderr.write(
|
|
448
|
+
`⚠️ Warning: "${parentAgent}" is spawning builder "${name}" without having spawned any scouts.\n`,
|
|
449
|
+
);
|
|
450
|
+
process.stderr.write(
|
|
451
|
+
" Leads should spawn scouts in Phase 1 before building. See agents/lead.md.\n",
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// 6. Validate task exists and is in a workable state (if tracker enabled)
|
|
456
|
+
const tracker = createTrackerClient(resolvedBackend, config.project.root);
|
|
457
|
+
if (config.taskTracker.enabled && !skipTaskCheck) {
|
|
458
|
+
let issue: TrackerIssue;
|
|
459
|
+
try {
|
|
460
|
+
issue = await tracker.show(taskId);
|
|
461
|
+
} catch (err) {
|
|
462
|
+
throw new AgentError(`Task "${taskId}" not found or inaccessible`, {
|
|
463
|
+
agentName: name,
|
|
464
|
+
cause: err instanceof Error ? err : undefined,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const workableStatuses = ["open", "in_progress"];
|
|
469
|
+
if (!workableStatuses.includes(issue.status)) {
|
|
470
|
+
throw new ValidationError(
|
|
471
|
+
`Task "${taskId}" is not workable (status: ${issue.status}). Only open or in_progress issues can be assigned.`,
|
|
472
|
+
{ field: "taskId", value: taskId },
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// 7. Create worktree
|
|
478
|
+
const worktreeBaseDir = join(config.project.root, config.worktrees.baseDir);
|
|
479
|
+
await mkdir(worktreeBaseDir, { recursive: true });
|
|
480
|
+
|
|
481
|
+
const { path: worktreePath, branch: branchName } = await createWorktree({
|
|
482
|
+
repoRoot: config.project.root,
|
|
483
|
+
baseDir: worktreeBaseDir,
|
|
484
|
+
agentName: name,
|
|
485
|
+
baseBranch: config.project.canonicalBranch,
|
|
486
|
+
beadId: taskId,
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// 8. Generate + write overlay CLAUDE.md
|
|
490
|
+
const agentDefPath = join(config.project.root, config.agents.baseDir, agentDef.file);
|
|
491
|
+
const baseDefinition = await Bun.file(agentDefPath).text();
|
|
492
|
+
|
|
493
|
+
// 8a. Fetch file-scoped mulch expertise if mulch is enabled and files are provided
|
|
494
|
+
let mulchExpertise: string | undefined;
|
|
495
|
+
if (config.mulch.enabled && fileScope.length > 0) {
|
|
496
|
+
try {
|
|
497
|
+
const mulch = createMulchClient(config.project.root);
|
|
498
|
+
mulchExpertise = await mulch.prime(undefined, undefined, { files: fileScope });
|
|
499
|
+
} catch {
|
|
500
|
+
// Non-fatal: mulch expertise is supplementary context
|
|
501
|
+
mulchExpertise = undefined;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const overlayConfig: OverlayConfig = {
|
|
506
|
+
agentName: name,
|
|
507
|
+
beadId: taskId,
|
|
508
|
+
specPath: absoluteSpecPath,
|
|
509
|
+
branchName,
|
|
510
|
+
worktreePath,
|
|
511
|
+
fileScope,
|
|
512
|
+
mulchDomains: config.mulch.enabled
|
|
513
|
+
? inferDomainsFromFiles(fileScope, config.mulch.domains)
|
|
514
|
+
: [],
|
|
515
|
+
parentAgent: parentAgent,
|
|
516
|
+
depth,
|
|
517
|
+
canSpawn: agentDef.canSpawn,
|
|
518
|
+
capability,
|
|
519
|
+
baseDefinition,
|
|
520
|
+
mulchExpertise,
|
|
521
|
+
skipScout: skipScout && capability === "lead",
|
|
522
|
+
qualityGates: config.project.qualityGates,
|
|
523
|
+
trackerCli: trackerCliName(resolvedBackend),
|
|
524
|
+
trackerName: resolvedBackend,
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
try {
|
|
528
|
+
await writeOverlay(worktreePath, overlayConfig, config.project.root);
|
|
529
|
+
} catch (err) {
|
|
530
|
+
// Clean up the orphaned worktree created in step 7 (overstory-p4st)
|
|
531
|
+
try {
|
|
532
|
+
const cleanupProc = Bun.spawn(["git", "worktree", "remove", "--force", worktreePath], {
|
|
533
|
+
cwd: config.project.root,
|
|
534
|
+
stdout: "pipe",
|
|
535
|
+
stderr: "pipe",
|
|
536
|
+
});
|
|
537
|
+
await cleanupProc.exited;
|
|
538
|
+
} catch {
|
|
539
|
+
// Best-effort cleanup; the original error is more important
|
|
540
|
+
}
|
|
541
|
+
throw err;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// 9. Deploy hooks config (capability-specific guards)
|
|
545
|
+
await deployHooks(worktreePath, name, capability);
|
|
546
|
+
|
|
547
|
+
// 10. Claim tracker issue
|
|
548
|
+
if (config.taskTracker.enabled && !skipTaskCheck) {
|
|
549
|
+
try {
|
|
550
|
+
await tracker.claim(taskId);
|
|
551
|
+
} catch {
|
|
552
|
+
// Non-fatal: issue may already be claimed
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// 11. Create agent identity (if new)
|
|
557
|
+
const identityBaseDir = join(config.project.root, ".overstory", "agents");
|
|
558
|
+
const existingIdentity = await loadIdentity(identityBaseDir, name);
|
|
559
|
+
if (!existingIdentity) {
|
|
560
|
+
await createIdentity(identityBaseDir, {
|
|
561
|
+
name,
|
|
562
|
+
capability,
|
|
563
|
+
created: new Date().toISOString(),
|
|
564
|
+
sessionsCompleted: 0,
|
|
565
|
+
expertiseDomains: config.mulch.enabled ? config.mulch.domains : [],
|
|
566
|
+
recentTasks: [],
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// 12. Create tmux session running claude in interactive mode
|
|
571
|
+
const tmuxSessionName = `overstory-${config.project.name}-${name}`;
|
|
572
|
+
const { model, env } = resolveModel(config, manifest, capability, agentDef.model);
|
|
573
|
+
const claudeCmd = `claude --model ${model} --dangerously-skip-permissions`;
|
|
574
|
+
const pid = await createSession(tmuxSessionName, worktreePath, claudeCmd, {
|
|
575
|
+
...env,
|
|
576
|
+
OVERSTORY_AGENT_NAME: name,
|
|
577
|
+
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// 13. Record session BEFORE sending the beacon so that hook-triggered
|
|
581
|
+
// updateLastActivity() can find the entry and transition booting->working.
|
|
582
|
+
// Without this, a race exists: hooks fire before the session is persisted,
|
|
583
|
+
// leaving the agent stuck in "booting" (overstory-036f).
|
|
584
|
+
const session: AgentSession = {
|
|
585
|
+
id: `session-${Date.now()}-${name}`,
|
|
586
|
+
agentName: name,
|
|
587
|
+
capability,
|
|
588
|
+
worktreePath,
|
|
589
|
+
branchName,
|
|
590
|
+
beadId: taskId,
|
|
591
|
+
tmuxSession: tmuxSessionName,
|
|
592
|
+
state: "booting",
|
|
593
|
+
pid,
|
|
594
|
+
parentAgent: parentAgent,
|
|
595
|
+
depth,
|
|
596
|
+
runId,
|
|
597
|
+
startedAt: new Date().toISOString(),
|
|
598
|
+
lastActivity: new Date().toISOString(),
|
|
599
|
+
escalationLevel: 0,
|
|
600
|
+
stalledSince: null,
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
store.upsert(session);
|
|
604
|
+
|
|
605
|
+
// Increment agent count for the run
|
|
606
|
+
const runStore = createRunStore(join(overstoryDir, "sessions.db"));
|
|
607
|
+
try {
|
|
608
|
+
runStore.incrementAgentCount(runId);
|
|
609
|
+
} finally {
|
|
610
|
+
runStore.close();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// 13b. Wait for Claude Code TUI to render before sending input.
|
|
614
|
+
// Polling capture-pane is more reliable than a fixed sleep because
|
|
615
|
+
// TUI init time varies by machine load and model state.
|
|
616
|
+
await waitForTuiReady(tmuxSessionName);
|
|
617
|
+
// Buffer for the input handler to attach after initial render
|
|
618
|
+
await Bun.sleep(1_000);
|
|
619
|
+
|
|
620
|
+
const beacon = buildBeacon({
|
|
621
|
+
agentName: name,
|
|
622
|
+
capability,
|
|
623
|
+
taskId,
|
|
624
|
+
parentAgent,
|
|
625
|
+
depth,
|
|
626
|
+
});
|
|
627
|
+
await sendKeys(tmuxSessionName, beacon);
|
|
628
|
+
|
|
629
|
+
// 13c. Follow-up Enters with increasing delays to ensure submission.
|
|
630
|
+
// Claude Code's TUI may consume early Enters during late initialization
|
|
631
|
+
// (overstory-yhv6). An Enter on an empty input line is harmless.
|
|
632
|
+
for (const delay of [1_000, 2_000]) {
|
|
633
|
+
await Bun.sleep(delay);
|
|
634
|
+
await sendKeys(tmuxSessionName, "");
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// 14. Output result
|
|
638
|
+
const output = {
|
|
639
|
+
agentName: name,
|
|
640
|
+
capability,
|
|
641
|
+
taskId,
|
|
642
|
+
branch: branchName,
|
|
643
|
+
worktree: worktreePath,
|
|
644
|
+
tmuxSession: tmuxSessionName,
|
|
645
|
+
pid,
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
if (args.includes("--json")) {
|
|
649
|
+
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
650
|
+
} else {
|
|
651
|
+
process.stdout.write(`🚀 Agent "${name}" launched!\n`);
|
|
652
|
+
process.stdout.write(` Task: ${taskId}\n`);
|
|
653
|
+
process.stdout.write(` Branch: ${branchName}\n`);
|
|
654
|
+
process.stdout.write(` Worktree: ${worktreePath}\n`);
|
|
655
|
+
process.stdout.write(` Tmux: ${tmuxSessionName}\n`);
|
|
656
|
+
process.stdout.write(` PID: ${pid}\n`);
|
|
657
|
+
}
|
|
658
|
+
} finally {
|
|
659
|
+
store.close();
|
|
660
|
+
}
|
|
661
|
+
}
|