@os-eco/overstory-cli 0.6.4 → 0.6.6
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/README.md +61 -61
- package/agents/builder.md +16 -16
- package/agents/coordinator.md +57 -57
- package/agents/issue-reviews.md +71 -0
- package/agents/lead.md +43 -42
- package/agents/merger.md +15 -15
- package/agents/monitor.md +37 -37
- package/agents/pr-reviews.md +60 -0
- package/agents/prioritize.md +110 -0
- package/agents/release.md +56 -0
- package/agents/reviewer.md +15 -15
- package/agents/scout.md +18 -18
- package/agents/supervisor.md +78 -78
- package/package.json +1 -1
- package/src/agents/checkpoint.test.ts +2 -2
- package/src/agents/hooks-deployer.test.ts +59 -25
- package/src/agents/hooks-deployer.ts +24 -6
- package/src/agents/identity.test.ts +27 -27
- package/src/agents/identity.ts +10 -10
- package/src/agents/lifecycle.test.ts +6 -6
- package/src/agents/lifecycle.ts +2 -2
- package/src/agents/overlay.test.ts +14 -14
- package/src/agents/overlay.ts +14 -14
- package/src/commands/agents.test.ts +5 -5
- package/src/commands/agents.ts +10 -9
- package/src/commands/clean.test.ts +5 -5
- package/src/commands/clean.ts +5 -5
- package/src/commands/completions.test.ts +10 -10
- package/src/commands/completions.ts +26 -28
- package/src/commands/coordinator.test.ts +4 -4
- package/src/commands/coordinator.ts +13 -13
- package/src/commands/costs.test.ts +45 -45
- package/src/commands/costs.ts +1 -1
- package/src/commands/dashboard.ts +11 -11
- package/src/commands/doctor.ts +4 -4
- package/src/commands/errors.ts +1 -1
- package/src/commands/feed.ts +1 -1
- package/src/commands/group.ts +3 -3
- package/src/commands/hooks.test.ts +7 -7
- package/src/commands/hooks.ts +7 -7
- package/src/commands/init.test.ts +6 -2
- package/src/commands/init.ts +19 -19
- package/src/commands/inspect.test.ts +16 -16
- package/src/commands/inspect.ts +19 -19
- package/src/commands/log.test.ts +21 -21
- package/src/commands/log.ts +10 -10
- package/src/commands/logs.ts +1 -1
- package/src/commands/mail.test.ts +7 -7
- package/src/commands/mail.ts +28 -11
- package/src/commands/merge.test.ts +8 -8
- package/src/commands/merge.ts +15 -15
- package/src/commands/metrics.test.ts +7 -7
- package/src/commands/metrics.ts +3 -3
- package/src/commands/monitor.test.ts +5 -5
- package/src/commands/monitor.ts +5 -5
- package/src/commands/nudge.test.ts +1 -1
- package/src/commands/nudge.ts +1 -1
- package/src/commands/prime.test.ts +5 -5
- package/src/commands/prime.ts +8 -8
- package/src/commands/replay.ts +1 -1
- package/src/commands/run.test.ts +1 -1
- package/src/commands/run.ts +2 -2
- package/src/commands/sling.test.ts +89 -7
- package/src/commands/sling.ts +109 -18
- package/src/commands/spec.test.ts +2 -2
- package/src/commands/spec.ts +13 -14
- package/src/commands/status.test.ts +99 -3
- package/src/commands/status.ts +19 -20
- package/src/commands/stop.test.ts +1 -1
- package/src/commands/stop.ts +2 -2
- package/src/commands/supervisor.test.ts +10 -10
- package/src/commands/supervisor.ts +14 -14
- package/src/commands/trace.test.ts +7 -7
- package/src/commands/trace.ts +10 -10
- package/src/commands/watch.ts +5 -5
- package/src/commands/worktree.test.ts +208 -32
- package/src/commands/worktree.ts +56 -18
- package/src/doctor/consistency.test.ts +14 -14
- package/src/doctor/dependencies.test.ts +5 -5
- package/src/doctor/dependencies.ts +2 -2
- package/src/doctor/logs.ts +1 -1
- package/src/doctor/merge-queue.test.ts +4 -4
- package/src/doctor/structure.test.ts +1 -1
- package/src/doctor/structure.ts +1 -1
- package/src/doctor/version.test.ts +3 -3
- package/src/doctor/version.ts +1 -1
- package/src/e2e/init-sling-lifecycle.test.ts +8 -4
- package/src/errors.ts +1 -1
- package/src/index.ts +13 -11
- package/src/mail/broadcast.test.ts +1 -1
- package/src/mail/client.test.ts +7 -7
- package/src/mail/client.ts +2 -2
- package/src/mail/store.test.ts +3 -3
- package/src/merge/queue.test.ts +12 -12
- package/src/merge/queue.ts +2 -2
- package/src/merge/resolver.test.ts +159 -7
- package/src/merge/resolver.ts +46 -2
- package/src/metrics/store.test.ts +44 -44
- package/src/metrics/store.ts +2 -2
- package/src/metrics/summary.test.ts +35 -35
- package/src/mulch/client.test.ts +1 -1
- package/src/mulch/client.ts +1 -1
- package/src/sessions/compat.test.ts +3 -3
- package/src/sessions/compat.ts +1 -1
- package/src/sessions/store.test.ts +4 -4
- package/src/sessions/store.ts +2 -2
- package/src/types.ts +14 -14
- package/src/watchdog/daemon.test.ts +10 -10
- package/src/watchdog/daemon.ts +1 -1
- package/src/watchdog/health.test.ts +1 -1
- package/src/worktree/manager.test.ts +20 -20
- package/src/worktree/manager.ts +120 -4
- package/src/worktree/tmux.test.ts +8 -3
- package/src/worktree/tmux.ts +19 -18
- package/templates/CLAUDE.md.tmpl +27 -27
- package/templates/hooks.json.tmpl +15 -11
- package/templates/overlay.md.tmpl +7 -7
|
@@ -3,7 +3,9 @@ import { resolveModel, resolveProviderEnv } from "../agents/manifest.ts";
|
|
|
3
3
|
import { HierarchyError } from "../errors.ts";
|
|
4
4
|
import type { AgentManifest, OverstoryConfig } from "../types.ts";
|
|
5
5
|
import {
|
|
6
|
+
type AutoDispatchOptions,
|
|
6
7
|
type BeaconOptions,
|
|
8
|
+
buildAutoDispatch,
|
|
7
9
|
buildBeacon,
|
|
8
10
|
calculateStaggerDelay,
|
|
9
11
|
checkBeadLock,
|
|
@@ -405,14 +407,14 @@ describe("buildBeacon", () => {
|
|
|
405
407
|
|
|
406
408
|
expect(beacon).toContain("read .claude/CLAUDE.md");
|
|
407
409
|
expect(beacon).toContain("mulch prime");
|
|
408
|
-
expect(beacon).toContain("
|
|
410
|
+
expect(beacon).toContain("ov mail check --agent scout-1");
|
|
409
411
|
expect(beacon).toContain("begin task overstory-xyz");
|
|
410
412
|
});
|
|
411
413
|
|
|
412
414
|
test("uses agent name in mail check command", () => {
|
|
413
415
|
const beacon = buildBeacon(makeBeaconOpts({ agentName: "reviewer-beta" }));
|
|
414
416
|
|
|
415
|
-
expect(beacon).toContain("
|
|
417
|
+
expect(beacon).toContain("ov mail check --agent reviewer-beta");
|
|
416
418
|
});
|
|
417
419
|
|
|
418
420
|
test("reflects capability in header", () => {
|
|
@@ -554,13 +556,13 @@ describe("isRunningAsRoot", () => {
|
|
|
554
556
|
/**
|
|
555
557
|
* Tests for checkBeadLock.
|
|
556
558
|
*
|
|
557
|
-
* checkBeadLock prevents concurrent agents from working the same
|
|
559
|
+
* checkBeadLock prevents concurrent agents from working the same task ID.
|
|
558
560
|
* It checks the active session list and returns the agent name that holds
|
|
559
561
|
* the lock (i.e., is already working on the bead), or null if the bead is free.
|
|
560
562
|
*/
|
|
561
563
|
|
|
562
|
-
function makeBeadSession(agentName: string,
|
|
563
|
-
return { agentName,
|
|
564
|
+
function makeBeadSession(agentName: string, taskId: string): { agentName: string; taskId: string } {
|
|
565
|
+
return { agentName, taskId };
|
|
564
566
|
}
|
|
565
567
|
|
|
566
568
|
describe("checkBeadLock", () => {
|
|
@@ -568,7 +570,7 @@ describe("checkBeadLock", () => {
|
|
|
568
570
|
expect(checkBeadLock([], "overstory-abc")).toBeNull();
|
|
569
571
|
});
|
|
570
572
|
|
|
571
|
-
test("returns null when no session matches the
|
|
573
|
+
test("returns null when no session matches the task ID", () => {
|
|
572
574
|
const sessions = [
|
|
573
575
|
makeBeadSession("builder-1", "overstory-xyz"),
|
|
574
576
|
makeBeadSession("builder-2", "overstory-def"),
|
|
@@ -587,7 +589,7 @@ describe("checkBeadLock", () => {
|
|
|
587
589
|
});
|
|
588
590
|
|
|
589
591
|
test("returns the first matching agent when multiple sessions match", () => {
|
|
590
|
-
// Multiple sessions can have the same
|
|
592
|
+
// Multiple sessions can have the same taskId (e.g., retried agent)
|
|
591
593
|
// checkBeadLock returns the first match
|
|
592
594
|
const sessions = [
|
|
593
595
|
makeBeadSession("builder-1", "overstory-abc"),
|
|
@@ -851,3 +853,83 @@ describe("sling provider env injection building blocks", () => {
|
|
|
851
853
|
expect(builderResult.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("anthropic/claude-3-5-haiku");
|
|
852
854
|
});
|
|
853
855
|
});
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Tests for buildAutoDispatch.
|
|
859
|
+
*
|
|
860
|
+
* buildAutoDispatch constructs a pre-spawn dispatch mail message that is
|
|
861
|
+
* inserted into the mail DB before the tmux session is created. This ensures
|
|
862
|
+
* the agent's SessionStart hook can immediately deliver context without
|
|
863
|
+
* waiting for the coordinator to send a separate dispatch message.
|
|
864
|
+
*/
|
|
865
|
+
|
|
866
|
+
function makeAutoDispatchOpts(overrides?: Partial<AutoDispatchOptions>): AutoDispatchOptions {
|
|
867
|
+
return {
|
|
868
|
+
agentName: "builder-1",
|
|
869
|
+
taskId: "overstory-abc",
|
|
870
|
+
capability: "builder",
|
|
871
|
+
specPath: "/path/to/spec.md",
|
|
872
|
+
parentAgent: "lead-alpha",
|
|
873
|
+
...overrides,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
describe("buildAutoDispatch", () => {
|
|
878
|
+
test("uses parent agent as sender when provided", () => {
|
|
879
|
+
const dispatch = buildAutoDispatch({
|
|
880
|
+
agentName: "builder-1",
|
|
881
|
+
taskId: "overstory-abc",
|
|
882
|
+
capability: "builder",
|
|
883
|
+
specPath: "/path/to/spec.md",
|
|
884
|
+
parentAgent: "lead-alpha",
|
|
885
|
+
});
|
|
886
|
+
expect(dispatch.from).toBe("lead-alpha");
|
|
887
|
+
expect(dispatch.to).toBe("builder-1");
|
|
888
|
+
expect(dispatch.subject).toContain("overstory-abc");
|
|
889
|
+
expect(dispatch.body).toContain("spec.md");
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test("uses orchestrator as sender when no parent", () => {
|
|
893
|
+
const dispatch = buildAutoDispatch({
|
|
894
|
+
agentName: "lead-1",
|
|
895
|
+
taskId: "overstory-xyz",
|
|
896
|
+
capability: "lead",
|
|
897
|
+
specPath: null,
|
|
898
|
+
parentAgent: null,
|
|
899
|
+
});
|
|
900
|
+
expect(dispatch.from).toBe("orchestrator");
|
|
901
|
+
expect(dispatch.body).toContain("No spec file");
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test("includes capability in body", () => {
|
|
905
|
+
const dispatch = buildAutoDispatch({
|
|
906
|
+
agentName: "scout-1",
|
|
907
|
+
taskId: "overstory-abc",
|
|
908
|
+
capability: "scout",
|
|
909
|
+
specPath: null,
|
|
910
|
+
parentAgent: "lead-alpha",
|
|
911
|
+
});
|
|
912
|
+
expect(dispatch.body).toContain("scout");
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
test("includes spec path when provided", () => {
|
|
916
|
+
const dispatch = buildAutoDispatch({
|
|
917
|
+
agentName: "builder-1",
|
|
918
|
+
taskId: "overstory-abc",
|
|
919
|
+
capability: "builder",
|
|
920
|
+
specPath: "/abs/path/to/spec.md",
|
|
921
|
+
parentAgent: "lead-alpha",
|
|
922
|
+
});
|
|
923
|
+
expect(dispatch.body).toContain("/abs/path/to/spec.md");
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
test("subject contains task ID", () => {
|
|
927
|
+
const dispatch = buildAutoDispatch(makeAutoDispatchOpts({ taskId: "overstory-zz99" }));
|
|
928
|
+
expect(dispatch.subject).toContain("overstory-zz99");
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
test("to is the agent name", () => {
|
|
932
|
+
const dispatch = buildAutoDispatch(makeAutoDispatchOpts({ agentName: "my-builder" }));
|
|
933
|
+
expect(dispatch.to).toBe("my-builder");
|
|
934
|
+
});
|
|
935
|
+
});
|
package/src/commands/sling.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI command:
|
|
2
|
+
* CLI command: ov sling <task-id>
|
|
3
3
|
*
|
|
4
4
|
* CRITICAL PATH. Orchestrates a full agent spawn:
|
|
5
5
|
* 1. Load config + manifest
|
|
@@ -27,6 +27,8 @@ import { writeOverlay } from "../agents/overlay.ts";
|
|
|
27
27
|
import { loadConfig } from "../config.ts";
|
|
28
28
|
import { AgentError, HierarchyError, ValidationError } from "../errors.ts";
|
|
29
29
|
import { inferDomain } from "../insights/analyzer.ts";
|
|
30
|
+
import { createMailClient } from "../mail/client.ts";
|
|
31
|
+
import { createMailStore } from "../mail/store.ts";
|
|
30
32
|
import { createMulchClient } from "../mulch/client.ts";
|
|
31
33
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
32
34
|
import { createRunStore } from "../sessions/store.ts";
|
|
@@ -34,7 +36,13 @@ import type { TrackerIssue } from "../tracker/factory.ts";
|
|
|
34
36
|
import { createTrackerClient, resolveBackend, trackerCliName } from "../tracker/factory.ts";
|
|
35
37
|
import type { AgentSession, OverlayConfig } from "../types.ts";
|
|
36
38
|
import { createWorktree } from "../worktree/manager.ts";
|
|
37
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
capturePaneContent,
|
|
41
|
+
createSession,
|
|
42
|
+
ensureTmuxAvailable,
|
|
43
|
+
sendKeys,
|
|
44
|
+
waitForTuiReady,
|
|
45
|
+
} from "../worktree/tmux.ts";
|
|
38
46
|
|
|
39
47
|
/**
|
|
40
48
|
* Calculate how many milliseconds to sleep before spawning a new agent,
|
|
@@ -111,6 +119,45 @@ export interface SlingOptions {
|
|
|
111
119
|
json?: boolean;
|
|
112
120
|
}
|
|
113
121
|
|
|
122
|
+
export interface AutoDispatchOptions {
|
|
123
|
+
agentName: string;
|
|
124
|
+
taskId: string;
|
|
125
|
+
capability: string;
|
|
126
|
+
specPath: string | null;
|
|
127
|
+
parentAgent: string | null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build a structured auto-dispatch mail message for a newly slung agent.
|
|
132
|
+
*
|
|
133
|
+
* Sending this mail before creating the tmux session ensures it exists
|
|
134
|
+
* in the DB when SessionStart fires, eliminating the race where dispatch
|
|
135
|
+
* mail arrives after the agent boots and sits idle forever.
|
|
136
|
+
*/
|
|
137
|
+
export function buildAutoDispatch(opts: AutoDispatchOptions): {
|
|
138
|
+
from: string;
|
|
139
|
+
to: string;
|
|
140
|
+
subject: string;
|
|
141
|
+
body: string;
|
|
142
|
+
} {
|
|
143
|
+
const from = opts.parentAgent ?? "orchestrator";
|
|
144
|
+
const specLine = opts.specPath
|
|
145
|
+
? `Spec file: ${opts.specPath}`
|
|
146
|
+
: "No spec file provided. Check your overlay for task details.";
|
|
147
|
+
const body = [
|
|
148
|
+
`You have been assigned task ${opts.taskId} as a ${opts.capability} agent.`,
|
|
149
|
+
specLine,
|
|
150
|
+
`Read your overlay at .claude/CLAUDE.md and begin immediately.`,
|
|
151
|
+
].join(" ");
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
from,
|
|
155
|
+
to: opts.agentName,
|
|
156
|
+
subject: `Dispatch: ${opts.taskId}`,
|
|
157
|
+
body,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
114
161
|
/**
|
|
115
162
|
* Options for building the structured startup beacon.
|
|
116
163
|
*/
|
|
@@ -135,7 +182,7 @@ export interface BeaconOptions {
|
|
|
135
182
|
* Startup protocol:
|
|
136
183
|
* 1. Read your assignment in .claude/CLAUDE.md
|
|
137
184
|
* 2. Load expertise: mulch prime
|
|
138
|
-
* 3. Check mail:
|
|
185
|
+
* 3. Check mail: ov mail check --agent <name>
|
|
139
186
|
* 4. Begin working on task <bead-id>
|
|
140
187
|
*/
|
|
141
188
|
export function buildBeacon(opts: BeaconOptions): string {
|
|
@@ -144,7 +191,7 @@ export function buildBeacon(opts: BeaconOptions): string {
|
|
|
144
191
|
const parts = [
|
|
145
192
|
`[OVERSTORY] ${opts.agentName} (${opts.capability}) ${timestamp} task:${opts.taskId}`,
|
|
146
193
|
`Depth: ${opts.depth} | Parent: ${parent}`,
|
|
147
|
-
`Startup: read .claude/CLAUDE.md, run mulch prime, check mail (
|
|
194
|
+
`Startup: read .claude/CLAUDE.md, run mulch prime, check mail (ov mail check --agent ${opts.agentName}), then begin task ${opts.taskId}`,
|
|
148
195
|
];
|
|
149
196
|
return parts.join(" — ");
|
|
150
197
|
}
|
|
@@ -161,17 +208,17 @@ export function parentHasScouts(
|
|
|
161
208
|
}
|
|
162
209
|
|
|
163
210
|
/**
|
|
164
|
-
* Check if any active agent is already working on the given
|
|
211
|
+
* Check if any active agent is already working on the given task ID.
|
|
165
212
|
* Returns the agent name if locked, or null if the bead is free.
|
|
166
213
|
*
|
|
167
214
|
* @param activeSessions - Currently active (non-zombie) sessions
|
|
168
|
-
* @param
|
|
215
|
+
* @param taskId - The bead task ID to check for concurrent work
|
|
169
216
|
*/
|
|
170
217
|
export function checkBeadLock(
|
|
171
|
-
activeSessions: ReadonlyArray<{ agentName: string;
|
|
172
|
-
|
|
218
|
+
activeSessions: ReadonlyArray<{ agentName: string; taskId: string }>,
|
|
219
|
+
taskId: string,
|
|
173
220
|
): string | null {
|
|
174
|
-
const existing = activeSessions.find((s) => s.
|
|
221
|
+
const existing = activeSessions.find((s) => s.taskId === taskId);
|
|
175
222
|
return existing?.agentName ?? null;
|
|
176
223
|
}
|
|
177
224
|
|
|
@@ -225,14 +272,14 @@ export function validateHierarchy(
|
|
|
225
272
|
}
|
|
226
273
|
|
|
227
274
|
/**
|
|
228
|
-
* Entry point for `
|
|
275
|
+
* Entry point for `ov sling <task-id> [flags]`.
|
|
229
276
|
*
|
|
230
277
|
* @param taskId - The task ID to assign to the agent
|
|
231
278
|
* @param opts - Command options
|
|
232
279
|
*/
|
|
233
280
|
export async function slingCommand(taskId: string, opts: SlingOptions): Promise<void> {
|
|
234
281
|
if (!taskId) {
|
|
235
|
-
throw new ValidationError("Task ID is required:
|
|
282
|
+
throw new ValidationError("Task ID is required: ov sling <task-id>", {
|
|
236
283
|
field: "taskId",
|
|
237
284
|
});
|
|
238
285
|
}
|
|
@@ -269,7 +316,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
269
316
|
// Warn if --skip-scout is used for a non-lead capability (harmless but confusing)
|
|
270
317
|
if (skipScout && capability !== "lead") {
|
|
271
318
|
process.stderr.write(
|
|
272
|
-
|
|
319
|
+
`Warning: --skip-scout is only meaningful for leads. Ignoring for "${capability}" agent "${name}".\n`,
|
|
273
320
|
);
|
|
274
321
|
}
|
|
275
322
|
|
|
@@ -393,7 +440,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
393
440
|
});
|
|
394
441
|
}
|
|
395
442
|
|
|
396
|
-
// 5d. Bead-level locking: prevent concurrent agents on the same
|
|
443
|
+
// 5d. Bead-level locking: prevent concurrent agents on the same task ID.
|
|
397
444
|
// Exception: the parent agent may delegate its own task to a child.
|
|
398
445
|
const lockHolder = checkBeadLock(activeSessions, taskId);
|
|
399
446
|
if (lockHolder !== null && lockHolder !== parentAgent) {
|
|
@@ -415,7 +462,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
415
462
|
// the scout-skip pattern so agents and operators can see it happening.
|
|
416
463
|
if (capability === "builder" && parentAgent && !parentHasScouts(store.getAll(), parentAgent)) {
|
|
417
464
|
process.stderr.write(
|
|
418
|
-
|
|
465
|
+
`Warning: "${parentAgent}" is spawning builder "${name}" without having spawned any scouts.\n`,
|
|
419
466
|
);
|
|
420
467
|
process.stderr.write(
|
|
421
468
|
" Leads should spawn scouts in Phase 1 before building. See agents/lead.md.\n",
|
|
@@ -453,7 +500,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
453
500
|
baseDir: worktreeBaseDir,
|
|
454
501
|
agentName: name,
|
|
455
502
|
baseBranch: config.project.canonicalBranch,
|
|
456
|
-
|
|
503
|
+
taskId: taskId,
|
|
457
504
|
});
|
|
458
505
|
|
|
459
506
|
// 8. Generate + write overlay CLAUDE.md
|
|
@@ -474,7 +521,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
474
521
|
|
|
475
522
|
const overlayConfig: OverlayConfig = {
|
|
476
523
|
agentName: name,
|
|
477
|
-
|
|
524
|
+
taskId: taskId,
|
|
478
525
|
specPath: absoluteSpecPath,
|
|
479
526
|
branchName,
|
|
480
527
|
worktreePath,
|
|
@@ -514,6 +561,30 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
514
561
|
// 9. Deploy hooks config (capability-specific guards)
|
|
515
562
|
await deployHooks(worktreePath, name, capability);
|
|
516
563
|
|
|
564
|
+
// 9b. Send auto-dispatch mail so it exists when SessionStart hook fires.
|
|
565
|
+
// This eliminates the race where coordinator sends dispatch AFTER agent boots.
|
|
566
|
+
const dispatch = buildAutoDispatch({
|
|
567
|
+
agentName: name,
|
|
568
|
+
taskId,
|
|
569
|
+
capability,
|
|
570
|
+
specPath: absoluteSpecPath,
|
|
571
|
+
parentAgent,
|
|
572
|
+
});
|
|
573
|
+
const mailStore = createMailStore(join(overstoryDir, "mail.db"));
|
|
574
|
+
try {
|
|
575
|
+
const mailClient = createMailClient(mailStore);
|
|
576
|
+
mailClient.send({
|
|
577
|
+
from: dispatch.from,
|
|
578
|
+
to: dispatch.to,
|
|
579
|
+
subject: dispatch.subject,
|
|
580
|
+
body: dispatch.body,
|
|
581
|
+
type: "dispatch",
|
|
582
|
+
priority: "normal",
|
|
583
|
+
});
|
|
584
|
+
} finally {
|
|
585
|
+
mailStore.close();
|
|
586
|
+
}
|
|
587
|
+
|
|
517
588
|
// 10. Claim tracker issue
|
|
518
589
|
if (config.taskTracker.enabled && !skipTaskCheck) {
|
|
519
590
|
try {
|
|
@@ -537,6 +608,9 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
537
608
|
});
|
|
538
609
|
}
|
|
539
610
|
|
|
611
|
+
// 11b. Preflight: verify tmux is available before attempting session creation
|
|
612
|
+
await ensureTmuxAvailable();
|
|
613
|
+
|
|
540
614
|
// 12. Create tmux session running claude in interactive mode
|
|
541
615
|
const tmuxSessionName = `overstory-${config.project.name}-${name}`;
|
|
542
616
|
const { model, env } = resolveModel(config, manifest, capability, agentDef.model);
|
|
@@ -557,7 +631,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
557
631
|
capability,
|
|
558
632
|
worktreePath,
|
|
559
633
|
branchName,
|
|
560
|
-
|
|
634
|
+
taskId: taskId,
|
|
561
635
|
tmuxSession: tmuxSessionName,
|
|
562
636
|
state: "booting",
|
|
563
637
|
pid,
|
|
@@ -604,6 +678,23 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
604
678
|
await sendKeys(tmuxSessionName, "");
|
|
605
679
|
}
|
|
606
680
|
|
|
681
|
+
// 13d. Verify beacon was received — if pane still shows the welcome
|
|
682
|
+
// screen ("Try "), resend the beacon. Claude Code's TUI sometimes
|
|
683
|
+
// consumes the Enter keystroke during late initialization, swallowing
|
|
684
|
+
// the beacon text entirely (overstory-3271).
|
|
685
|
+
const verifyAttempts = 5;
|
|
686
|
+
for (let v = 0; v < verifyAttempts; v++) {
|
|
687
|
+
await Bun.sleep(2_000);
|
|
688
|
+
const paneContent = await capturePaneContent(tmuxSessionName);
|
|
689
|
+
if (paneContent && !paneContent.includes('Try "')) {
|
|
690
|
+
break; // Agent is processing — beacon was received
|
|
691
|
+
}
|
|
692
|
+
// Still at welcome screen — resend beacon
|
|
693
|
+
await sendKeys(tmuxSessionName, beacon);
|
|
694
|
+
await Bun.sleep(1_000);
|
|
695
|
+
await sendKeys(tmuxSessionName, ""); // Follow-up Enter
|
|
696
|
+
}
|
|
697
|
+
|
|
607
698
|
// 14. Output result
|
|
608
699
|
const output = {
|
|
609
700
|
agentName: name,
|
|
@@ -618,7 +709,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
618
709
|
if (opts.json ?? false) {
|
|
619
710
|
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
620
711
|
} else {
|
|
621
|
-
process.stdout.write(
|
|
712
|
+
process.stdout.write(`Agent "${name}" launched!\n`);
|
|
622
713
|
process.stdout.write(` Task: ${taskId}\n`);
|
|
623
714
|
process.stdout.write(` Branch: ${branchName}\n`);
|
|
624
715
|
process.stdout.write(` Worktree: ${worktreePath}\n`);
|
|
@@ -58,8 +58,8 @@ afterEach(async () => {
|
|
|
58
58
|
// === validation ===
|
|
59
59
|
|
|
60
60
|
describe("validation", () => {
|
|
61
|
-
test("write without
|
|
62
|
-
await expect(specWriteCommand("", {})).rejects.toThrow("
|
|
61
|
+
test("write without task-id throws ValidationError", async () => {
|
|
62
|
+
await expect(specWriteCommand("", {})).rejects.toThrow("Task ID is required");
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
test("write without body throws ValidationError", async () => {
|
package/src/commands/spec.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI command:
|
|
2
|
+
* CLI command: ov spec write <bead-id> --body <content>
|
|
3
3
|
*
|
|
4
|
-
* Writes a task specification to `.overstory/specs/<
|
|
4
|
+
* Writes a task specification to `.overstory/specs/<task-id>.md`.
|
|
5
5
|
* Scouts use this to persist spec documents as files instead of
|
|
6
6
|
* sending entire specs via mail messages.
|
|
7
7
|
*
|
|
@@ -30,13 +30,13 @@ async function readStdin(): Promise<string> {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Write a spec file to .overstory/specs/<
|
|
33
|
+
* Write a spec file to .overstory/specs/<task-id>.md.
|
|
34
34
|
*
|
|
35
35
|
* Exported for direct use in tests.
|
|
36
36
|
*/
|
|
37
37
|
export async function writeSpec(
|
|
38
38
|
projectRoot: string,
|
|
39
|
-
|
|
39
|
+
taskId: string,
|
|
40
40
|
body: string,
|
|
41
41
|
agent?: string,
|
|
42
42
|
): Promise<string> {
|
|
@@ -55,24 +55,23 @@ export async function writeSpec(
|
|
|
55
55
|
content += "\n";
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
const specPath = join(specsDir, `${
|
|
58
|
+
const specPath = join(specsDir, `${taskId}.md`);
|
|
59
59
|
await Bun.write(specPath, content);
|
|
60
60
|
|
|
61
61
|
return specPath;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
|
-
* Entry point for `
|
|
65
|
+
* Entry point for `ov spec write <bead-id> [flags]`.
|
|
66
66
|
*
|
|
67
|
-
* @param
|
|
67
|
+
* @param taskId - The task ID for the spec file
|
|
68
68
|
* @param opts - Command options
|
|
69
69
|
*/
|
|
70
|
-
export async function specWriteCommand(
|
|
71
|
-
if (!
|
|
72
|
-
throw new ValidationError(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
);
|
|
70
|
+
export async function specWriteCommand(taskId: string, opts: SpecWriteOptions): Promise<void> {
|
|
71
|
+
if (!taskId || taskId.trim().length === 0) {
|
|
72
|
+
throw new ValidationError("Task ID is required: ov spec write <task-id> --body <content>", {
|
|
73
|
+
field: "taskId",
|
|
74
|
+
});
|
|
76
75
|
}
|
|
77
76
|
|
|
78
77
|
let body = opts.body;
|
|
@@ -94,6 +93,6 @@ export async function specWriteCommand(beadId: string, opts: SpecWriteOptions):
|
|
|
94
93
|
const { resolveProjectRoot } = await import("../config.ts");
|
|
95
94
|
const projectRoot = await resolveProjectRoot(process.cwd());
|
|
96
95
|
|
|
97
|
-
const specPath = await writeSpec(projectRoot,
|
|
96
|
+
const specPath = await writeSpec(projectRoot, taskId, body, opts.agent);
|
|
98
97
|
process.stdout.write(`${specPath}\n`);
|
|
99
98
|
}
|
|
@@ -28,7 +28,7 @@ function makeAgent(overrides: Partial<AgentSession> = {}): AgentSession {
|
|
|
28
28
|
capability: "builder",
|
|
29
29
|
worktreePath: "/tmp/worktrees/test-builder",
|
|
30
30
|
branchName: "overstory/test-builder/task-1",
|
|
31
|
-
|
|
31
|
+
taskId: "task-1",
|
|
32
32
|
tmuxSession: "overstory-test-builder",
|
|
33
33
|
state: "working",
|
|
34
34
|
pid: 12345,
|
|
@@ -364,7 +364,7 @@ describe("--watch deprecation", () => {
|
|
|
364
364
|
|
|
365
365
|
const out = chunks.join("");
|
|
366
366
|
expect(out).toContain("deprecated");
|
|
367
|
-
expect(out).toContain("
|
|
367
|
+
expect(out).toContain("ov dashboard");
|
|
368
368
|
});
|
|
369
369
|
|
|
370
370
|
test("--watch writes deprecation notice to stderr", async () => {
|
|
@@ -394,7 +394,103 @@ describe("--watch deprecation", () => {
|
|
|
394
394
|
|
|
395
395
|
const err = stderrChunks.join("");
|
|
396
396
|
expect(err).toContain("--watch is deprecated");
|
|
397
|
-
expect(err).toContain("
|
|
397
|
+
expect(err).toContain("ov dashboard");
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe("gatherStatus reconciliation", () => {
|
|
402
|
+
afterEach(() => {
|
|
403
|
+
invalidateStatusCache();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("booting agent with dead tmux becomes zombie", async () => {
|
|
407
|
+
const tempDir = await createTempGitRepo();
|
|
408
|
+
const overstoryDir = join(tempDir, ".overstory");
|
|
409
|
+
await mkdir(overstoryDir, { recursive: true });
|
|
410
|
+
|
|
411
|
+
const store = createSessionStore(join(overstoryDir, "sessions.db"));
|
|
412
|
+
const now = new Date().toISOString();
|
|
413
|
+
const session = makeAgent({
|
|
414
|
+
agentName: "boot-builder",
|
|
415
|
+
capability: "builder",
|
|
416
|
+
state: "booting",
|
|
417
|
+
tmuxSession: "overstory-boot-builder",
|
|
418
|
+
runId: null,
|
|
419
|
+
});
|
|
420
|
+
session.startedAt = now;
|
|
421
|
+
session.lastActivity = now;
|
|
422
|
+
store.upsert(session);
|
|
423
|
+
store.close();
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
// No real tmux running, so getCachedTmuxSessions() returns empty array
|
|
427
|
+
// evaluateHealth ZFC Rule 1: tmux dead → zombie
|
|
428
|
+
const result = await gatherStatus(tempDir, "orchestrator", false, undefined);
|
|
429
|
+
const agent = result.agents.find((a) => a.agentName === "boot-builder");
|
|
430
|
+
expect(agent).toBeDefined();
|
|
431
|
+
expect(agent?.state).toBe("zombie");
|
|
432
|
+
} finally {
|
|
433
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("completed agents skip reconciliation", async () => {
|
|
438
|
+
const tempDir = await createTempGitRepo();
|
|
439
|
+
const overstoryDir = join(tempDir, ".overstory");
|
|
440
|
+
await mkdir(overstoryDir, { recursive: true });
|
|
441
|
+
|
|
442
|
+
const store = createSessionStore(join(overstoryDir, "sessions.db"));
|
|
443
|
+
const now = new Date().toISOString();
|
|
444
|
+
const session = makeAgent({
|
|
445
|
+
agentName: "done-builder",
|
|
446
|
+
capability: "builder",
|
|
447
|
+
state: "completed",
|
|
448
|
+
tmuxSession: "overstory-done-builder",
|
|
449
|
+
runId: null,
|
|
450
|
+
});
|
|
451
|
+
session.startedAt = now;
|
|
452
|
+
session.lastActivity = now;
|
|
453
|
+
store.upsert(session);
|
|
454
|
+
store.close();
|
|
455
|
+
|
|
456
|
+
try {
|
|
457
|
+
const result = await gatherStatus(tempDir, "orchestrator", false, undefined);
|
|
458
|
+
const agent = result.agents.find((a) => a.agentName === "done-builder");
|
|
459
|
+
expect(agent).toBeDefined();
|
|
460
|
+
expect(agent?.state).toBe("completed");
|
|
461
|
+
} finally {
|
|
462
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("working agent with dead tmux becomes zombie", async () => {
|
|
467
|
+
const tempDir = await createTempGitRepo();
|
|
468
|
+
const overstoryDir = join(tempDir, ".overstory");
|
|
469
|
+
await mkdir(overstoryDir, { recursive: true });
|
|
470
|
+
|
|
471
|
+
const store = createSessionStore(join(overstoryDir, "sessions.db"));
|
|
472
|
+
const now = new Date().toISOString();
|
|
473
|
+
const session = makeAgent({
|
|
474
|
+
agentName: "working-builder",
|
|
475
|
+
capability: "builder",
|
|
476
|
+
state: "working",
|
|
477
|
+
tmuxSession: "overstory-working-builder",
|
|
478
|
+
runId: null,
|
|
479
|
+
});
|
|
480
|
+
session.startedAt = now;
|
|
481
|
+
session.lastActivity = now;
|
|
482
|
+
store.upsert(session);
|
|
483
|
+
store.close();
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
// No real tmux session → tmux dead → zombie via evaluateHealth ZFC Rule 1
|
|
487
|
+
const result = await gatherStatus(tempDir, "orchestrator", false, undefined);
|
|
488
|
+
const agent = result.agents.find((a) => a.agentName === "working-builder");
|
|
489
|
+
expect(agent).toBeDefined();
|
|
490
|
+
expect(agent?.state).toBe("zombie");
|
|
491
|
+
} finally {
|
|
492
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
493
|
+
}
|
|
398
494
|
});
|
|
399
495
|
});
|
|
400
496
|
|
package/src/commands/status.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { createMergeQueue } from "../merge/queue.ts";
|
|
|
14
14
|
import { createMetricsStore } from "../metrics/store.ts";
|
|
15
15
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
16
16
|
import type { AgentSession } from "../types.ts";
|
|
17
|
+
import { evaluateHealth } from "../watchdog/health.ts";
|
|
17
18
|
import { listWorktrees } from "../worktree/manager.ts";
|
|
18
19
|
import { listSessions } from "../worktree/tmux.ts";
|
|
19
20
|
|
|
@@ -136,23 +137,23 @@ export async function gatherStatus(
|
|
|
136
137
|
|
|
137
138
|
const tmuxSessions = await getCachedTmuxSessions();
|
|
138
139
|
|
|
139
|
-
// Reconcile agent states
|
|
140
|
-
//
|
|
140
|
+
// Reconcile agent states using the same health evaluation as the
|
|
141
|
+
// dashboard and watchdog. This handles:
|
|
142
|
+
// 1. tmux dead -> zombie (regardless of recorded state)
|
|
143
|
+
// 2. persistent capabilities (coordinator, monitor) booting -> working when tmux alive
|
|
144
|
+
// 3. time-based stale/zombie detection for non-persistent agents
|
|
141
145
|
const tmuxSessionNames = new Set(tmuxSessions.map((s) => s.name));
|
|
146
|
+
const healthThresholds = { staleMs: 300_000, zombieMs: 600_000 };
|
|
142
147
|
for (const session of sessions) {
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
session.state = "zombie";
|
|
153
|
-
} catch {
|
|
154
|
-
// Best effort: don't fail status display if update fails
|
|
155
|
-
}
|
|
148
|
+
if (session.state === "completed") continue;
|
|
149
|
+
const tmuxAlive = tmuxSessionNames.has(session.tmuxSession);
|
|
150
|
+
const check = evaluateHealth(session, tmuxAlive, healthThresholds);
|
|
151
|
+
if (check.state !== session.state) {
|
|
152
|
+
try {
|
|
153
|
+
store.updateState(session.agentName, check.state);
|
|
154
|
+
session.state = check.state;
|
|
155
|
+
} catch {
|
|
156
|
+
// Best effort: don't fail status display if update fails
|
|
156
157
|
}
|
|
157
158
|
}
|
|
158
159
|
}
|
|
@@ -273,7 +274,7 @@ export function printStatus(data: StatusData): void {
|
|
|
273
274
|
const tmuxAlive = tmuxSessionNames.has(agent.tmuxSession);
|
|
274
275
|
const aliveMarker = tmuxAlive ? "●" : "○";
|
|
275
276
|
w(` ${aliveMarker} ${agent.agentName} [${agent.capability}] `);
|
|
276
|
-
w(`${agent.state} | ${agent.
|
|
277
|
+
w(`${agent.state} | ${agent.taskId} | ${duration}\n`);
|
|
277
278
|
|
|
278
279
|
const detail = data.verboseDetails?.[agent.agentName];
|
|
279
280
|
if (detail) {
|
|
@@ -346,9 +347,7 @@ async function executeStatus(opts: StatusOpts): Promise<void> {
|
|
|
346
347
|
}
|
|
347
348
|
|
|
348
349
|
if (watch) {
|
|
349
|
-
process.stderr.write(
|
|
350
|
-
"⚠️ --watch is deprecated. Use 'overstory dashboard' for live monitoring.\n\n",
|
|
351
|
-
);
|
|
350
|
+
process.stderr.write("⚠️ --watch is deprecated. Use 'ov dashboard' for live monitoring.\n\n");
|
|
352
351
|
// Polling loop (kept for one release cycle)
|
|
353
352
|
while (true) {
|
|
354
353
|
// Clear screen
|
|
@@ -378,7 +377,7 @@ export function createStatusCommand(): Command {
|
|
|
378
377
|
.option("--verbose", "Show extra detail per agent (worktree, logs, mail timestamps)")
|
|
379
378
|
.option("--agent <name>", "Show unread mail for this agent (default: orchestrator)")
|
|
380
379
|
.option("--all", "Show sessions from all runs (default: current run only)")
|
|
381
|
-
.option("--watch", "(deprecated) Use '
|
|
380
|
+
.option("--watch", "(deprecated) Use 'ov dashboard' for live monitoring")
|
|
382
381
|
.option("--interval <ms>", "Poll interval for --watch in milliseconds (default: 3000)")
|
|
383
382
|
.action(async (opts: StatusOpts) => {
|
|
384
383
|
await executeStatus(opts);
|