@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.
Files changed (110) hide show
  1. package/README.md +8 -7
  2. package/package.json +12 -4
  3. package/src/agents/checkpoint.test.ts +2 -2
  4. package/src/agents/hooks-deployer.test.ts +131 -16
  5. package/src/agents/hooks-deployer.ts +33 -1
  6. package/src/agents/identity.test.ts +27 -27
  7. package/src/agents/identity.ts +10 -10
  8. package/src/agents/lifecycle.test.ts +6 -6
  9. package/src/agents/lifecycle.ts +2 -2
  10. package/src/agents/manifest.test.ts +86 -0
  11. package/src/agents/overlay.test.ts +9 -9
  12. package/src/agents/overlay.ts +4 -4
  13. package/src/commands/agents.test.ts +8 -8
  14. package/src/commands/agents.ts +62 -91
  15. package/src/commands/clean.test.ts +36 -51
  16. package/src/commands/clean.ts +28 -49
  17. package/src/commands/completions.ts +14 -0
  18. package/src/commands/coordinator.test.ts +133 -26
  19. package/src/commands/coordinator.ts +101 -64
  20. package/src/commands/costs.test.ts +47 -47
  21. package/src/commands/costs.ts +96 -75
  22. package/src/commands/dashboard.test.ts +2 -2
  23. package/src/commands/dashboard.ts +75 -95
  24. package/src/commands/doctor.test.ts +2 -2
  25. package/src/commands/doctor.ts +92 -79
  26. package/src/commands/errors.test.ts +2 -2
  27. package/src/commands/errors.ts +56 -50
  28. package/src/commands/feed.test.ts +2 -2
  29. package/src/commands/feed.ts +86 -83
  30. package/src/commands/group.ts +167 -177
  31. package/src/commands/hooks.test.ts +2 -2
  32. package/src/commands/hooks.ts +52 -42
  33. package/src/commands/init.test.ts +19 -19
  34. package/src/commands/init.ts +7 -16
  35. package/src/commands/inspect.test.ts +18 -18
  36. package/src/commands/inspect.ts +55 -58
  37. package/src/commands/log.test.ts +26 -31
  38. package/src/commands/log.ts +97 -91
  39. package/src/commands/logs.test.ts +1 -1
  40. package/src/commands/logs.ts +101 -104
  41. package/src/commands/mail.test.ts +5 -5
  42. package/src/commands/mail.ts +157 -169
  43. package/src/commands/merge.test.ts +28 -66
  44. package/src/commands/merge.ts +21 -51
  45. package/src/commands/metrics.test.ts +8 -8
  46. package/src/commands/metrics.ts +34 -35
  47. package/src/commands/monitor.test.ts +3 -3
  48. package/src/commands/monitor.ts +57 -62
  49. package/src/commands/nudge.test.ts +1 -1
  50. package/src/commands/nudge.ts +41 -89
  51. package/src/commands/prime.test.ts +19 -51
  52. package/src/commands/prime.ts +13 -50
  53. package/src/commands/replay.test.ts +2 -2
  54. package/src/commands/replay.ts +79 -86
  55. package/src/commands/run.test.ts +1 -1
  56. package/src/commands/run.ts +97 -77
  57. package/src/commands/sling.test.ts +201 -5
  58. package/src/commands/sling.ts +37 -64
  59. package/src/commands/spec.test.ts +14 -40
  60. package/src/commands/spec.ts +32 -101
  61. package/src/commands/status.test.ts +97 -1
  62. package/src/commands/status.ts +63 -58
  63. package/src/commands/stop.test.ts +22 -40
  64. package/src/commands/stop.ts +18 -33
  65. package/src/commands/supervisor.test.ts +12 -14
  66. package/src/commands/supervisor.ts +144 -165
  67. package/src/commands/trace.test.ts +15 -15
  68. package/src/commands/trace.ts +59 -82
  69. package/src/commands/watch.test.ts +2 -2
  70. package/src/commands/watch.ts +38 -45
  71. package/src/commands/worktree.test.ts +213 -37
  72. package/src/commands/worktree.ts +110 -55
  73. package/src/config.test.ts +96 -0
  74. package/src/doctor/consistency.test.ts +14 -14
  75. package/src/doctor/databases.test.ts +22 -2
  76. package/src/doctor/databases.ts +16 -0
  77. package/src/doctor/dependencies.test.ts +55 -1
  78. package/src/doctor/dependencies.ts +113 -18
  79. package/src/doctor/merge-queue.test.ts +4 -4
  80. package/src/e2e/init-sling-lifecycle.test.ts +8 -8
  81. package/src/errors.ts +1 -1
  82. package/src/index.ts +223 -213
  83. package/src/logging/color.test.ts +74 -91
  84. package/src/logging/color.ts +52 -46
  85. package/src/logging/reporter.test.ts +10 -10
  86. package/src/logging/reporter.ts +6 -5
  87. package/src/mail/broadcast.test.ts +1 -1
  88. package/src/mail/client.test.ts +6 -6
  89. package/src/mail/store.test.ts +3 -3
  90. package/src/merge/queue.test.ts +73 -7
  91. package/src/merge/queue.ts +17 -2
  92. package/src/merge/resolver.test.ts +159 -7
  93. package/src/merge/resolver.ts +46 -2
  94. package/src/metrics/store.test.ts +44 -44
  95. package/src/metrics/store.ts +2 -2
  96. package/src/metrics/summary.test.ts +35 -35
  97. package/src/mulch/client.test.ts +1 -1
  98. package/src/schema-consistency.test.ts +239 -0
  99. package/src/sessions/compat.test.ts +3 -3
  100. package/src/sessions/compat.ts +2 -2
  101. package/src/sessions/store.test.ts +41 -4
  102. package/src/sessions/store.ts +13 -2
  103. package/src/types.ts +14 -14
  104. package/src/watchdog/daemon.test.ts +10 -10
  105. package/src/watchdog/daemon.ts +1 -1
  106. package/src/watchdog/health.test.ts +1 -1
  107. package/src/worktree/manager.test.ts +20 -20
  108. package/src/worktree/manager.ts +120 -4
  109. package/src/worktree/tmux.test.ts +98 -9
  110. package/src/worktree/tmux.ts +18 -0
@@ -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
- * 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];
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 bead ID.
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 beadId - The bead task ID to check for concurrent work
168
+ * @param taskId - The bead task ID to check for concurrent work
167
169
  */
168
170
  export function checkBeadLock(
169
- activeSessions: ReadonlyArray<{ agentName: string; beadId: string }>,
170
- beadId: string,
171
+ activeSessions: ReadonlyArray<{ agentName: string; taskId: string }>,
172
+ taskId: string,
171
173
  ): string | null {
172
- const existing = activeSessions.find((s) => s.beadId === beadId);
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
- * 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)
230
+ * @param taskId - The task ID to assign to the agent
231
+ * @param opts - Command options
236
232
  */
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("--"));
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 = 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");
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 = args.includes("--force-hierarchy");
278
- const skipScout = args.includes("--skip-scout");
279
- const skipTaskCheck = args.includes("--skip-task-check");
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 bead ID.
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
- beadId: taskId,
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
- beadId: taskId,
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
- beadId: taskId,
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 (args.includes("--json")) {
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 { specCommand, writeSpec } from "./spec.ts";
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("unknown subcommand throws ValidationError", async () => {
84
- await expect(specCommand(["unknown"])).rejects.toThrow("Unknown spec subcommand");
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(specCommand(["write", "task-abc", "--agent", "scout-1"])).rejects.toThrow(
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(specCommand(["write", "task-abc", "--body", " "])).rejects.toThrow(
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
- // === specCommand (CLI integration) ===
142
+ // === specWriteCommand (CLI integration) ===
169
143
 
170
- describe("specCommand write", () => {
144
+ describe("specWriteCommand (integration)", () => {
171
145
  test("writes spec and prints path", async () => {
172
- await specCommand(["write", "task-cmd", "--body", "# CLI Spec"]);
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 specCommand(["write", "task-attr", "--body", "# Attributed", "--agent", "scout-2"]);
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("flags can appear in any order", async () => {
194
- await specCommand(["write", "--agent", "scout-3", "--body", "# Content", "task-order"]);
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-order.md");
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("<!-- written-by: scout-3 -->");
201
- expect(content).toContain("# Content");
174
+ expect(content).not.toContain("written-by");
175
+ expect(content).toBe("# No Agent\n");
202
176
  });
203
177
  });
@@ -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
- /** Boolean flags that do NOT consume the next arg. */
16
- const BOOLEAN_FLAGS = new Set(["--help", "-h"]);
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
- beadId: string,
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, `${beadId}.md`);
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 <subcommand>`.
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 specCommand(args: string[]): Promise<void> {
118
- if (args.includes("--help") || args.includes("-h") || args.length === 0) {
119
- process.stdout.write(`${SPEC_HELP}\n`);
120
- return;
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
- const subcommand = args[0];
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
- // If no --body flag, try reading from stdin
141
- if (body === undefined) {
142
- const stdinContent = await readStdin();
143
- if (stdinContent.trim().length > 0) {
144
- body = stdinContent;
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
- default:
163
- throw new ValidationError(
164
- `Unknown spec subcommand: ${subcommand}. Run 'overstory spec --help' for usage.`,
165
- { field: "subcommand", value: subcommand },
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
- beadId: "task-1",
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();