@os-eco/overstory-cli 0.6.1 → 0.6.5
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 +8 -7
- package/package.json +12 -4
- package/src/agents/checkpoint.test.ts +2 -2
- package/src/agents/hooks-deployer.test.ts +131 -16
- package/src/agents/hooks-deployer.ts +33 -1
- 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/manifest.test.ts +86 -0
- package/src/agents/overlay.test.ts +9 -9
- package/src/agents/overlay.ts +4 -4
- package/src/commands/agents.test.ts +8 -8
- package/src/commands/agents.ts +62 -91
- package/src/commands/clean.test.ts +36 -51
- package/src/commands/clean.ts +28 -49
- package/src/commands/completions.ts +14 -0
- package/src/commands/coordinator.test.ts +133 -26
- package/src/commands/coordinator.ts +101 -64
- package/src/commands/costs.test.ts +47 -47
- package/src/commands/costs.ts +96 -75
- package/src/commands/dashboard.test.ts +2 -2
- package/src/commands/dashboard.ts +75 -95
- package/src/commands/doctor.test.ts +2 -2
- package/src/commands/doctor.ts +92 -79
- package/src/commands/errors.test.ts +2 -2
- package/src/commands/errors.ts +56 -50
- package/src/commands/feed.test.ts +2 -2
- package/src/commands/feed.ts +86 -83
- package/src/commands/group.ts +167 -177
- package/src/commands/hooks.test.ts +2 -2
- package/src/commands/hooks.ts +52 -42
- package/src/commands/init.test.ts +19 -19
- package/src/commands/init.ts +7 -16
- package/src/commands/inspect.test.ts +18 -18
- package/src/commands/inspect.ts +55 -58
- package/src/commands/log.test.ts +26 -31
- package/src/commands/log.ts +97 -91
- package/src/commands/logs.test.ts +1 -1
- package/src/commands/logs.ts +101 -104
- package/src/commands/mail.test.ts +5 -5
- package/src/commands/mail.ts +157 -169
- package/src/commands/merge.test.ts +28 -66
- package/src/commands/merge.ts +21 -51
- package/src/commands/metrics.test.ts +8 -8
- package/src/commands/metrics.ts +34 -35
- package/src/commands/monitor.test.ts +3 -3
- package/src/commands/monitor.ts +57 -62
- package/src/commands/nudge.test.ts +1 -1
- package/src/commands/nudge.ts +41 -89
- package/src/commands/prime.test.ts +19 -51
- package/src/commands/prime.ts +13 -50
- package/src/commands/replay.test.ts +2 -2
- package/src/commands/replay.ts +79 -86
- package/src/commands/run.test.ts +1 -1
- package/src/commands/run.ts +97 -77
- package/src/commands/sling.test.ts +201 -5
- package/src/commands/sling.ts +37 -64
- package/src/commands/spec.test.ts +14 -40
- package/src/commands/spec.ts +32 -101
- package/src/commands/status.test.ts +97 -1
- package/src/commands/status.ts +63 -58
- package/src/commands/stop.test.ts +22 -40
- package/src/commands/stop.ts +18 -33
- package/src/commands/supervisor.test.ts +12 -14
- package/src/commands/supervisor.ts +144 -165
- package/src/commands/trace.test.ts +15 -15
- package/src/commands/trace.ts +59 -82
- package/src/commands/watch.test.ts +2 -2
- package/src/commands/watch.ts +38 -45
- package/src/commands/worktree.test.ts +213 -37
- package/src/commands/worktree.ts +110 -55
- package/src/config.test.ts +96 -0
- package/src/doctor/consistency.test.ts +14 -14
- package/src/doctor/databases.test.ts +22 -2
- package/src/doctor/databases.ts +16 -0
- package/src/doctor/dependencies.test.ts +55 -1
- package/src/doctor/dependencies.ts +113 -18
- package/src/doctor/merge-queue.test.ts +4 -4
- package/src/e2e/init-sling-lifecycle.test.ts +8 -8
- package/src/errors.ts +1 -1
- package/src/index.ts +223 -213
- package/src/logging/color.test.ts +74 -91
- package/src/logging/color.ts +52 -46
- package/src/logging/reporter.test.ts +10 -10
- package/src/logging/reporter.ts +6 -5
- package/src/mail/broadcast.test.ts +1 -1
- package/src/mail/client.test.ts +6 -6
- package/src/mail/store.test.ts +3 -3
- package/src/merge/queue.test.ts +73 -7
- package/src/merge/queue.ts +17 -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/schema-consistency.test.ts +239 -0
- package/src/sessions/compat.test.ts +3 -3
- package/src/sessions/compat.ts +2 -2
- package/src/sessions/store.test.ts +41 -4
- package/src/sessions/store.ts +13 -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 +98 -9
- package/src/worktree/tmux.ts +18 -0
package/src/commands/sling.ts
CHANGED
|
@@ -34,7 +34,7 @@ import type { TrackerIssue } from "../tracker/factory.ts";
|
|
|
34
34
|
import { createTrackerClient, resolveBackend, trackerCliName } from "../tracker/factory.ts";
|
|
35
35
|
import type { AgentSession, OverlayConfig } from "../types.ts";
|
|
36
36
|
import { createWorktree } from "../worktree/manager.ts";
|
|
37
|
-
import { createSession, sendKeys, waitForTuiReady } from "../worktree/tmux.ts";
|
|
37
|
+
import { createSession, ensureTmuxAvailable, sendKeys, waitForTuiReady } from "../worktree/tmux.ts";
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Calculate how many milliseconds to sleep before spawning a new agent,
|
|
@@ -98,15 +98,17 @@ export function inferDomainsFromFiles(
|
|
|
98
98
|
return [...inferred].sort();
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
101
|
+
export interface SlingOptions {
|
|
102
|
+
capability?: string;
|
|
103
|
+
name?: string;
|
|
104
|
+
spec?: string;
|
|
105
|
+
files?: string;
|
|
106
|
+
parent?: string;
|
|
107
|
+
depth?: string;
|
|
108
|
+
skipScout?: boolean;
|
|
109
|
+
skipTaskCheck?: boolean;
|
|
110
|
+
forceHierarchy?: boolean;
|
|
111
|
+
json?: boolean;
|
|
110
112
|
}
|
|
111
113
|
|
|
112
114
|
/**
|
|
@@ -159,17 +161,17 @@ export function parentHasScouts(
|
|
|
159
161
|
}
|
|
160
162
|
|
|
161
163
|
/**
|
|
162
|
-
* Check if any active agent is already working on the given
|
|
164
|
+
* Check if any active agent is already working on the given task ID.
|
|
163
165
|
* Returns the agent name if locked, or null if the bead is free.
|
|
164
166
|
*
|
|
165
167
|
* @param activeSessions - Currently active (non-zombie) sessions
|
|
166
|
-
* @param
|
|
168
|
+
* @param taskId - The bead task ID to check for concurrent work
|
|
167
169
|
*/
|
|
168
170
|
export function checkBeadLock(
|
|
169
|
-
activeSessions: ReadonlyArray<{ agentName: string;
|
|
170
|
-
|
|
171
|
+
activeSessions: ReadonlyArray<{ agentName: string; taskId: string }>,
|
|
172
|
+
taskId: string,
|
|
171
173
|
): string | null {
|
|
172
|
-
const existing = activeSessions.find((s) => s.
|
|
174
|
+
const existing = activeSessions.find((s) => s.taskId === taskId);
|
|
173
175
|
return existing?.agentName ?? null;
|
|
174
176
|
}
|
|
175
177
|
|
|
@@ -225,58 +227,26 @@ export function validateHierarchy(
|
|
|
225
227
|
/**
|
|
226
228
|
* Entry point for `overstory sling <task-id> [flags]`.
|
|
227
229
|
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
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)
|
|
230
|
+
* @param taskId - The task ID to assign to the agent
|
|
231
|
+
* @param opts - Command options
|
|
236
232
|
*/
|
|
237
|
-
|
|
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("--"));
|
|
233
|
+
export async function slingCommand(taskId: string, opts: SlingOptions): Promise<void> {
|
|
264
234
|
if (!taskId) {
|
|
265
235
|
throw new ValidationError("Task ID is required: overstory sling <task-id>", {
|
|
266
236
|
field: "taskId",
|
|
267
237
|
});
|
|
268
238
|
}
|
|
269
239
|
|
|
270
|
-
const capability =
|
|
271
|
-
const name =
|
|
272
|
-
const specPath =
|
|
273
|
-
const filesRaw =
|
|
274
|
-
const parentAgent =
|
|
275
|
-
const depthStr =
|
|
240
|
+
const capability = opts.capability ?? "builder";
|
|
241
|
+
const name = opts.name;
|
|
242
|
+
const specPath = opts.spec ?? null;
|
|
243
|
+
const filesRaw = opts.files;
|
|
244
|
+
const parentAgent = opts.parent ?? null;
|
|
245
|
+
const depthStr = opts.depth;
|
|
276
246
|
const depth = depthStr !== undefined ? Number.parseInt(depthStr, 10) : 0;
|
|
277
|
-
const forceHierarchy =
|
|
278
|
-
const skipScout =
|
|
279
|
-
const skipTaskCheck =
|
|
247
|
+
const forceHierarchy = opts.forceHierarchy ?? false;
|
|
248
|
+
const skipScout = opts.skipScout ?? false;
|
|
249
|
+
const skipTaskCheck = opts.skipTaskCheck ?? false;
|
|
280
250
|
|
|
281
251
|
if (!name || name.trim().length === 0) {
|
|
282
252
|
throw new ValidationError("--name is required for sling", { field: "name" });
|
|
@@ -423,7 +393,7 @@ export async function slingCommand(args: string[]): Promise<void> {
|
|
|
423
393
|
});
|
|
424
394
|
}
|
|
425
395
|
|
|
426
|
-
// 5d. Bead-level locking: prevent concurrent agents on the same
|
|
396
|
+
// 5d. Bead-level locking: prevent concurrent agents on the same task ID.
|
|
427
397
|
// Exception: the parent agent may delegate its own task to a child.
|
|
428
398
|
const lockHolder = checkBeadLock(activeSessions, taskId);
|
|
429
399
|
if (lockHolder !== null && lockHolder !== parentAgent) {
|
|
@@ -483,7 +453,7 @@ export async function slingCommand(args: string[]): Promise<void> {
|
|
|
483
453
|
baseDir: worktreeBaseDir,
|
|
484
454
|
agentName: name,
|
|
485
455
|
baseBranch: config.project.canonicalBranch,
|
|
486
|
-
|
|
456
|
+
taskId: taskId,
|
|
487
457
|
});
|
|
488
458
|
|
|
489
459
|
// 8. Generate + write overlay CLAUDE.md
|
|
@@ -504,7 +474,7 @@ export async function slingCommand(args: string[]): Promise<void> {
|
|
|
504
474
|
|
|
505
475
|
const overlayConfig: OverlayConfig = {
|
|
506
476
|
agentName: name,
|
|
507
|
-
|
|
477
|
+
taskId: taskId,
|
|
508
478
|
specPath: absoluteSpecPath,
|
|
509
479
|
branchName,
|
|
510
480
|
worktreePath,
|
|
@@ -567,6 +537,9 @@ export async function slingCommand(args: string[]): Promise<void> {
|
|
|
567
537
|
});
|
|
568
538
|
}
|
|
569
539
|
|
|
540
|
+
// 11b. Preflight: verify tmux is available before attempting session creation
|
|
541
|
+
await ensureTmuxAvailable();
|
|
542
|
+
|
|
570
543
|
// 12. Create tmux session running claude in interactive mode
|
|
571
544
|
const tmuxSessionName = `overstory-${config.project.name}-${name}`;
|
|
572
545
|
const { model, env } = resolveModel(config, manifest, capability, agentDef.model);
|
|
@@ -587,7 +560,7 @@ export async function slingCommand(args: string[]): Promise<void> {
|
|
|
587
560
|
capability,
|
|
588
561
|
worktreePath,
|
|
589
562
|
branchName,
|
|
590
|
-
|
|
563
|
+
taskId: taskId,
|
|
591
564
|
tmuxSession: tmuxSessionName,
|
|
592
565
|
state: "booting",
|
|
593
566
|
pid,
|
|
@@ -645,7 +618,7 @@ export async function slingCommand(args: string[]): Promise<void> {
|
|
|
645
618
|
pid,
|
|
646
619
|
};
|
|
647
620
|
|
|
648
|
-
if (
|
|
621
|
+
if (opts.json ?? false) {
|
|
649
622
|
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
650
623
|
} else {
|
|
651
624
|
process.stdout.write(`🚀 Agent "${name}" launched!\n`);
|
|
@@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
9
9
|
import { mkdir } from "node:fs/promises";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
12
|
-
import {
|
|
12
|
+
import { specWriteCommand, writeSpec } from "./spec.ts";
|
|
13
13
|
|
|
14
14
|
let tempDir: string;
|
|
15
15
|
let overstoryDir: string;
|
|
@@ -55,47 +55,21 @@ afterEach(async () => {
|
|
|
55
55
|
await cleanupTempDir(tempDir);
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
// === help ===
|
|
59
|
-
|
|
60
|
-
describe("help", () => {
|
|
61
|
-
test("--help shows usage", async () => {
|
|
62
|
-
await specCommand(["--help"]);
|
|
63
|
-
expect(stdoutOutput).toContain("overstory spec");
|
|
64
|
-
expect(stdoutOutput).toContain("write");
|
|
65
|
-
expect(stdoutOutput).toContain("--body");
|
|
66
|
-
expect(stdoutOutput).toContain("--agent");
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test("-h shows usage", async () => {
|
|
70
|
-
await specCommand(["-h"]);
|
|
71
|
-
expect(stdoutOutput).toContain("overstory spec");
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("no args shows help", async () => {
|
|
75
|
-
await specCommand([]);
|
|
76
|
-
expect(stdoutOutput).toContain("overstory spec");
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
58
|
// === validation ===
|
|
81
59
|
|
|
82
60
|
describe("validation", () => {
|
|
83
|
-
test("
|
|
84
|
-
await expect(
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("write without bead-id throws ValidationError", async () => {
|
|
88
|
-
await expect(specCommand(["write"])).rejects.toThrow("Bead ID is required");
|
|
61
|
+
test("write without task-id throws ValidationError", async () => {
|
|
62
|
+
await expect(specWriteCommand("", {})).rejects.toThrow("Task ID is required");
|
|
89
63
|
});
|
|
90
64
|
|
|
91
65
|
test("write without body throws ValidationError", async () => {
|
|
92
|
-
await expect(
|
|
66
|
+
await expect(specWriteCommand("task-abc", { agent: "scout-1" })).rejects.toThrow(
|
|
93
67
|
"Spec body is required",
|
|
94
68
|
);
|
|
95
69
|
});
|
|
96
70
|
|
|
97
71
|
test("write with empty body throws ValidationError", async () => {
|
|
98
|
-
await expect(
|
|
72
|
+
await expect(specWriteCommand("task-abc", { body: " " })).rejects.toThrow(
|
|
99
73
|
"Spec body is required",
|
|
100
74
|
);
|
|
101
75
|
});
|
|
@@ -165,11 +139,11 @@ describe("writeSpec", () => {
|
|
|
165
139
|
});
|
|
166
140
|
});
|
|
167
141
|
|
|
168
|
-
// ===
|
|
142
|
+
// === specWriteCommand (CLI integration) ===
|
|
169
143
|
|
|
170
|
-
describe("
|
|
144
|
+
describe("specWriteCommand (integration)", () => {
|
|
171
145
|
test("writes spec and prints path", async () => {
|
|
172
|
-
await
|
|
146
|
+
await specWriteCommand("task-cmd", { body: "# CLI Spec" });
|
|
173
147
|
|
|
174
148
|
// Path may differ due to macOS /var -> /private/var symlink resolution
|
|
175
149
|
expect(stdoutOutput.trim()).toContain(".overstory/specs/task-cmd.md");
|
|
@@ -180,7 +154,7 @@ describe("specCommand write", () => {
|
|
|
180
154
|
});
|
|
181
155
|
|
|
182
156
|
test("writes spec with agent attribution", async () => {
|
|
183
|
-
await
|
|
157
|
+
await specWriteCommand("task-attr", { body: "# Attributed", agent: "scout-2" });
|
|
184
158
|
|
|
185
159
|
expect(stdoutOutput.trim()).toContain(".overstory/specs/task-attr.md");
|
|
186
160
|
|
|
@@ -190,14 +164,14 @@ describe("specCommand write", () => {
|
|
|
190
164
|
expect(content).toContain("# Attributed");
|
|
191
165
|
});
|
|
192
166
|
|
|
193
|
-
test("
|
|
194
|
-
await
|
|
167
|
+
test("writes spec without agent when agent is omitted", async () => {
|
|
168
|
+
await specWriteCommand("task-noagent", { body: "# No Agent" });
|
|
195
169
|
|
|
196
|
-
expect(stdoutOutput.trim()).toContain(".overstory/specs/task-
|
|
170
|
+
expect(stdoutOutput.trim()).toContain(".overstory/specs/task-noagent.md");
|
|
197
171
|
|
|
198
172
|
const specPath = stdoutOutput.trim();
|
|
199
173
|
const content = await Bun.file(specPath).text();
|
|
200
|
-
expect(content).toContain("
|
|
201
|
-
expect(content).
|
|
174
|
+
expect(content).not.toContain("written-by");
|
|
175
|
+
expect(content).toBe("# No Agent\n");
|
|
202
176
|
});
|
|
203
177
|
});
|
package/src/commands/spec.ts
CHANGED
|
@@ -12,42 +12,9 @@ import { mkdir } from "node:fs/promises";
|
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { ValidationError } from "../errors.ts";
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Parse a named flag value from args.
|
|
20
|
-
*/
|
|
21
|
-
function getFlag(args: string[], flag: string): string | undefined {
|
|
22
|
-
const idx = args.indexOf(flag);
|
|
23
|
-
if (idx === -1 || idx + 1 >= args.length) {
|
|
24
|
-
return undefined;
|
|
25
|
-
}
|
|
26
|
-
return args[idx + 1];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Extract positional arguments, skipping flag-value pairs.
|
|
31
|
-
*/
|
|
32
|
-
function getPositionalArgs(args: string[]): string[] {
|
|
33
|
-
const positional: string[] = [];
|
|
34
|
-
let i = 0;
|
|
35
|
-
while (i < args.length) {
|
|
36
|
-
const arg = args[i];
|
|
37
|
-
if (arg?.startsWith("-")) {
|
|
38
|
-
if (BOOLEAN_FLAGS.has(arg)) {
|
|
39
|
-
i += 1;
|
|
40
|
-
} else {
|
|
41
|
-
i += 2;
|
|
42
|
-
}
|
|
43
|
-
} else {
|
|
44
|
-
if (arg !== undefined) {
|
|
45
|
-
positional.push(arg);
|
|
46
|
-
}
|
|
47
|
-
i += 1;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return positional;
|
|
15
|
+
export interface SpecWriteOptions {
|
|
16
|
+
body?: string;
|
|
17
|
+
agent?: string;
|
|
51
18
|
}
|
|
52
19
|
|
|
53
20
|
/**
|
|
@@ -62,23 +29,6 @@ async function readStdin(): Promise<string> {
|
|
|
62
29
|
return await new Response(Bun.stdin.stream()).text();
|
|
63
30
|
}
|
|
64
31
|
|
|
65
|
-
const SPEC_HELP = `overstory spec -- Manage task specifications
|
|
66
|
-
|
|
67
|
-
Usage: overstory spec <subcommand> [args...]
|
|
68
|
-
|
|
69
|
-
Subcommands:
|
|
70
|
-
write <bead-id> Write a spec file to .overstory/specs/<bead-id>.md
|
|
71
|
-
|
|
72
|
-
Options for 'write':
|
|
73
|
-
--body <content> Spec content (or pipe via stdin)
|
|
74
|
-
--agent <name> Agent writing the spec (for attribution)
|
|
75
|
-
--help, -h Show this help
|
|
76
|
-
|
|
77
|
-
Examples:
|
|
78
|
-
overstory spec write task-abc --body "# Spec\\nDetails here..."
|
|
79
|
-
echo "# Spec" | overstory spec write task-abc
|
|
80
|
-
overstory spec write task-abc --body "..." --agent scout-1`;
|
|
81
|
-
|
|
82
32
|
/**
|
|
83
33
|
* Write a spec file to .overstory/specs/<bead-id>.md.
|
|
84
34
|
*
|
|
@@ -86,7 +36,7 @@ Examples:
|
|
|
86
36
|
*/
|
|
87
37
|
export async function writeSpec(
|
|
88
38
|
projectRoot: string,
|
|
89
|
-
|
|
39
|
+
taskId: string,
|
|
90
40
|
body: string,
|
|
91
41
|
agent?: string,
|
|
92
42
|
): Promise<string> {
|
|
@@ -105,64 +55,45 @@ export async function writeSpec(
|
|
|
105
55
|
content += "\n";
|
|
106
56
|
}
|
|
107
57
|
|
|
108
|
-
const specPath = join(specsDir, `${
|
|
58
|
+
const specPath = join(specsDir, `${taskId}.md`);
|
|
109
59
|
await Bun.write(specPath, content);
|
|
110
60
|
|
|
111
61
|
return specPath;
|
|
112
62
|
}
|
|
113
63
|
|
|
114
64
|
/**
|
|
115
|
-
* Entry point for `overstory spec <
|
|
65
|
+
* Entry point for `overstory spec write <bead-id> [flags]`.
|
|
66
|
+
*
|
|
67
|
+
* @param taskId - The bead/task ID for the spec file
|
|
68
|
+
* @param opts - Command options
|
|
116
69
|
*/
|
|
117
|
-
export async function
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
70
|
+
export async function specWriteCommand(taskId: string, opts: SpecWriteOptions): Promise<void> {
|
|
71
|
+
if (!taskId || taskId.trim().length === 0) {
|
|
72
|
+
throw new ValidationError(
|
|
73
|
+
"Task ID is required: overstory spec write <task-id> --body <content>",
|
|
74
|
+
{ field: "taskId" },
|
|
75
|
+
);
|
|
121
76
|
}
|
|
122
77
|
|
|
123
|
-
|
|
124
|
-
const subArgs = args.slice(1);
|
|
125
|
-
|
|
126
|
-
switch (subcommand) {
|
|
127
|
-
case "write": {
|
|
128
|
-
const positional = getPositionalArgs(subArgs);
|
|
129
|
-
const beadId = positional[0];
|
|
130
|
-
if (!beadId || beadId.trim().length === 0) {
|
|
131
|
-
throw new ValidationError(
|
|
132
|
-
"Bead ID is required: overstory spec write <bead-id> --body <content>",
|
|
133
|
-
{ field: "beadId" },
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const agent = getFlag(subArgs, "--agent");
|
|
138
|
-
let body = getFlag(subArgs, "--body");
|
|
78
|
+
let body = opts.body;
|
|
139
79
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
if (body === undefined || body.trim().length === 0) {
|
|
149
|
-
throw new ValidationError("Spec body is required: use --body <content> or pipe via stdin", {
|
|
150
|
-
field: "body",
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const { resolveProjectRoot } = await import("../config.ts");
|
|
155
|
-
const projectRoot = await resolveProjectRoot(process.cwd());
|
|
156
|
-
|
|
157
|
-
const specPath = await writeSpec(projectRoot, beadId, body, agent);
|
|
158
|
-
process.stdout.write(`${specPath}\n`);
|
|
159
|
-
break;
|
|
80
|
+
// If no --body flag, try reading from stdin
|
|
81
|
+
if (body === undefined) {
|
|
82
|
+
const stdinContent = await readStdin();
|
|
83
|
+
if (stdinContent.trim().length > 0) {
|
|
84
|
+
body = stdinContent;
|
|
160
85
|
}
|
|
86
|
+
}
|
|
161
87
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
);
|
|
88
|
+
if (body === undefined || body.trim().length === 0) {
|
|
89
|
+
throw new ValidationError("Spec body is required: use --body <content> or pipe via stdin", {
|
|
90
|
+
field: "body",
|
|
91
|
+
});
|
|
167
92
|
}
|
|
93
|
+
|
|
94
|
+
const { resolveProjectRoot } = await import("../config.ts");
|
|
95
|
+
const projectRoot = await resolveProjectRoot(process.cwd());
|
|
96
|
+
|
|
97
|
+
const specPath = await writeSpec(projectRoot, taskId, body, opts.agent);
|
|
98
|
+
process.stdout.write(`${specPath}\n`);
|
|
168
99
|
}
|
|
@@ -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,
|
|
@@ -398,6 +398,102 @@ describe("--watch deprecation", () => {
|
|
|
398
398
|
});
|
|
399
399
|
});
|
|
400
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
|
+
}
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
401
497
|
describe("subprocess caching (invalidateStatusCache)", () => {
|
|
402
498
|
afterEach(() => {
|
|
403
499
|
invalidateStatusCache();
|