@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
package/src/types.ts CHANGED
@@ -129,7 +129,7 @@ export interface AgentSession {
129
129
  capability: string; // Which agent definition
130
130
  worktreePath: string;
131
131
  branchName: string;
132
- beadId: string; // Task being worked
132
+ taskId: string; // Task being worked
133
133
  tmuxSession: string; // Tmux session name
134
134
  state: AgentState;
135
135
  pid: number | null; // Claude Code PID
@@ -151,7 +151,7 @@ export interface AgentIdentity {
151
151
  sessionsCompleted: number;
152
152
  expertiseDomains: string[];
153
153
  recentTasks: Array<{
154
- beadId: string;
154
+ taskId: string;
155
155
  summary: string;
156
156
  completedAt: string;
157
157
  }>;
@@ -210,7 +210,7 @@ export interface MailMessage {
210
210
 
211
211
  /** Worker signals task completion to supervisor. */
212
212
  export interface WorkerDonePayload {
213
- beadId: string;
213
+ taskId: string;
214
214
  branch: string;
215
215
  exitCode: number;
216
216
  filesModified: string[];
@@ -219,7 +219,7 @@ export interface WorkerDonePayload {
219
219
  /** Supervisor signals branch is verified and ready for merge. */
220
220
  export interface MergeReadyPayload {
221
221
  branch: string;
222
- beadId: string;
222
+ taskId: string;
223
223
  agentName: string;
224
224
  filesModified: string[];
225
225
  }
@@ -227,14 +227,14 @@ export interface MergeReadyPayload {
227
227
  /** Merger signals branch was merged successfully. */
228
228
  export interface MergedPayload {
229
229
  branch: string;
230
- beadId: string;
230
+ taskId: string;
231
231
  tier: ResolutionTier;
232
232
  }
233
233
 
234
234
  /** Merger signals merge failed, needs rework. */
235
235
  export interface MergeFailedPayload {
236
236
  branch: string;
237
- beadId: string;
237
+ taskId: string;
238
238
  conflictFiles: string[];
239
239
  errorMessage: string;
240
240
  }
@@ -242,7 +242,7 @@ export interface MergeFailedPayload {
242
242
  /** Any agent escalates an issue to a higher-level decision-maker. */
243
243
  export interface EscalationPayload {
244
244
  severity: "warning" | "error" | "critical";
245
- beadId: string | null;
245
+ taskId: string | null;
246
246
  context: string;
247
247
  }
248
248
 
@@ -254,7 +254,7 @@ export interface HealthCheckPayload {
254
254
 
255
255
  /** Coordinator dispatches work to a supervisor. */
256
256
  export interface DispatchPayload {
257
- beadId: string;
257
+ taskId: string;
258
258
  specPath: string;
259
259
  capability: Capability;
260
260
  fileScope: string[];
@@ -262,7 +262,7 @@ export interface DispatchPayload {
262
262
 
263
263
  /** Supervisor assigns work to a specific worker. */
264
264
  export interface AssignPayload {
265
- beadId: string;
265
+ taskId: string;
266
266
  specPath: string;
267
267
  workerName: string;
268
268
  branch: string;
@@ -284,7 +284,7 @@ export interface MailPayloadMap {
284
284
 
285
285
  export interface OverlayConfig {
286
286
  agentName: string;
287
- beadId: string;
287
+ taskId: string;
288
288
  specPath: string | null;
289
289
  branchName: string;
290
290
  worktreePath: string;
@@ -312,7 +312,7 @@ export type ResolutionTier = "clean-merge" | "auto-resolve" | "ai-resolve" | "re
312
312
 
313
313
  export interface MergeEntry {
314
314
  branchName: string;
315
- beadId: string;
315
+ taskId: string;
316
316
  agentName: string;
317
317
  filesModified: string[];
318
318
  enqueuedAt: string;
@@ -376,7 +376,7 @@ export interface LogEvent {
376
376
 
377
377
  export interface SessionMetrics {
378
378
  agentName: string;
379
- beadId: string;
379
+ taskId: string;
380
380
  capability: string;
381
381
  startedAt: string;
382
382
  completedAt: string | null;
@@ -645,7 +645,7 @@ export interface MulchCompactResult {
645
645
  */
646
646
  export interface SessionCheckpoint {
647
647
  agentName: string;
648
- beadId: string;
648
+ taskId: string;
649
649
  sessionId: string; // The AgentSession.id that created this checkpoint
650
650
  timestamp: string; // ISO
651
651
  progressSummary: string; // Human-readable summary of work done so far
@@ -677,7 +677,7 @@ export interface AgentLayers {
677
677
  sandbox: {
678
678
  worktreePath: string;
679
679
  branchName: string;
680
- beadId: string;
680
+ taskId: string;
681
681
  };
682
682
  session: {
683
683
  id: string;
@@ -66,7 +66,7 @@ function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
66
66
  capability: "builder",
67
67
  worktreePath: "/tmp/test",
68
68
  branchName: "overstory/test-agent/test-task",
69
- beadId: "test-task",
69
+ taskId: "test-task",
70
70
  tmuxSession: "overstory-test-agent",
71
71
  state: "working",
72
72
  pid: process.pid, // Use our own PID so isProcessRunning returns true
@@ -798,7 +798,7 @@ describe("daemon tick", () => {
798
798
  agentName: "old-agent",
799
799
  worktreePath: "/tmp/test",
800
800
  branchName: "overstory/old-agent/task",
801
- beadId: "task",
801
+ taskId: "task",
802
802
  tmuxSession: "overstory-old-agent",
803
803
  state: "working",
804
804
  pid: process.pid,
@@ -1133,7 +1133,7 @@ describe("daemon mulch failure recording", () => {
1133
1133
  const session = makeSession({
1134
1134
  agentName: "dying-agent",
1135
1135
  capability: "builder",
1136
- beadId: "task-123",
1136
+ taskId: "task-123",
1137
1137
  tmuxSession: "overstory-dying-agent",
1138
1138
  state: "working",
1139
1139
  lastActivity: new Date().toISOString(),
@@ -1158,7 +1158,7 @@ describe("daemon mulch failure recording", () => {
1158
1158
  expect(failureMock.calls[0]?.tier).toBe(0);
1159
1159
  expect(failureMock.calls[0]?.session.agentName).toBe("dying-agent");
1160
1160
  expect(failureMock.calls[0]?.session.capability).toBe("builder");
1161
- expect(failureMock.calls[0]?.session.beadId).toBe("task-123");
1161
+ expect(failureMock.calls[0]?.session.taskId).toBe("task-123");
1162
1162
  // Reason should be either the reconciliationNote or default "Process terminated"
1163
1163
  expect(failureMock.calls[0]?.reason).toBeDefined();
1164
1164
  });
@@ -1169,7 +1169,7 @@ describe("daemon mulch failure recording", () => {
1169
1169
  const session = makeSession({
1170
1170
  agentName: "triaged-agent",
1171
1171
  capability: "scout",
1172
- beadId: "task-456",
1172
+ taskId: "task-456",
1173
1173
  tmuxSession: "overstory-triaged-agent",
1174
1174
  state: "stalled",
1175
1175
  lastActivity: staleActivity,
@@ -1198,7 +1198,7 @@ describe("daemon mulch failure recording", () => {
1198
1198
  expect(failureMock.calls[0]?.tier).toBe(1);
1199
1199
  expect(failureMock.calls[0]?.session.agentName).toBe("triaged-agent");
1200
1200
  expect(failureMock.calls[0]?.session.capability).toBe("scout");
1201
- expect(failureMock.calls[0]?.session.beadId).toBe("task-456");
1201
+ expect(failureMock.calls[0]?.session.taskId).toBe("task-456");
1202
1202
  expect(failureMock.calls[0]?.triageSuggestion).toBe("terminate");
1203
1203
  expect(failureMock.calls[0]?.reason).toContain("AI triage");
1204
1204
  });
@@ -1267,11 +1267,11 @@ describe("daemon mulch failure recording", () => {
1267
1267
  expect(failureMock.calls).toHaveLength(0);
1268
1268
  });
1269
1269
 
1270
- test("recordFailure includes evidenceBead when beadId is present", async () => {
1270
+ test("recordFailure includes evidenceBead when taskId is present", async () => {
1271
1271
  const session = makeSession({
1272
1272
  agentName: "beaded-agent",
1273
1273
  capability: "builder",
1274
- beadId: "task-789",
1274
+ taskId: "task-789",
1275
1275
  tmuxSession: "overstory-beaded-agent",
1276
1276
  state: "working",
1277
1277
  lastActivity: new Date().toISOString(),
@@ -1292,7 +1292,7 @@ describe("daemon mulch failure recording", () => {
1292
1292
  });
1293
1293
 
1294
1294
  expect(failureMock.calls).toHaveLength(1);
1295
- expect(failureMock.calls[0]?.session.beadId).toBe("task-789");
1295
+ expect(failureMock.calls[0]?.session.taskId).toBe("task-789");
1296
1296
  });
1297
1297
 
1298
1298
  test("Tier 0: recordFailure called at escalation level 3+ (progressive termination)", async () => {
@@ -1301,7 +1301,7 @@ describe("daemon mulch failure recording", () => {
1301
1301
  const session = makeSession({
1302
1302
  agentName: "doomed-agent",
1303
1303
  capability: "builder",
1304
- beadId: "task-999",
1304
+ taskId: "task-999",
1305
1305
  tmuxSession: "overstory-doomed-agent",
1306
1306
  state: "stalled",
1307
1307
  lastActivity: staleActivity,
@@ -73,7 +73,7 @@ async function recordFailure(
73
73
  type: "failure",
74
74
  description,
75
75
  tags: ["watchdog", "auto-recorded"],
76
- evidenceBead: session.beadId || undefined,
76
+ evidenceBead: session.taskId || undefined,
77
77
  });
78
78
  } catch {
79
79
  // Fire-and-forget: recording failures must not break the watchdog
@@ -36,7 +36,7 @@ function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
36
36
  capability: "builder",
37
37
  worktreePath: "/tmp/test",
38
38
  branchName: "overstory/test-agent/test-task",
39
- beadId: "test-task",
39
+ taskId: "test-task",
40
40
  tmuxSession: "overstory-test-agent",
41
41
  state: "booting",
42
42
  pid: ALIVE_PID,
@@ -58,7 +58,7 @@ describe("createWorktree", () => {
58
58
  baseDir: worktreesDir,
59
59
  agentName: "auth-login",
60
60
  baseBranch: defaultBranch,
61
- beadId: "bead-abc123",
61
+ taskId: "bead-abc123",
62
62
  });
63
63
 
64
64
  expect(result.path).toBe(join(worktreesDir, "auth-login"));
@@ -71,7 +71,7 @@ describe("createWorktree", () => {
71
71
  baseDir: worktreesDir,
72
72
  agentName: "auth-login",
73
73
  baseBranch: defaultBranch,
74
- beadId: "bead-abc123",
74
+ taskId: "bead-abc123",
75
75
  });
76
76
 
77
77
  expect(existsSync(result.path)).toBe(true);
@@ -85,7 +85,7 @@ describe("createWorktree", () => {
85
85
  baseDir: worktreesDir,
86
86
  agentName: "auth-login",
87
87
  baseBranch: defaultBranch,
88
- beadId: "bead-abc123",
88
+ taskId: "bead-abc123",
89
89
  });
90
90
 
91
91
  const branchList = await git(repoDir, ["branch", "--list"]);
@@ -98,7 +98,7 @@ describe("createWorktree", () => {
98
98
  baseDir: worktreesDir,
99
99
  agentName: "auth-login",
100
100
  baseBranch: defaultBranch,
101
- beadId: "bead-abc123",
101
+ taskId: "bead-abc123",
102
102
  });
103
103
 
104
104
  await expect(
@@ -107,7 +107,7 @@ describe("createWorktree", () => {
107
107
  baseDir: worktreesDir,
108
108
  agentName: "auth-login",
109
109
  baseBranch: defaultBranch,
110
- beadId: "bead-abc123",
110
+ taskId: "bead-abc123",
111
111
  }),
112
112
  ).rejects.toThrow(WorktreeError);
113
113
  });
@@ -119,7 +119,7 @@ describe("createWorktree", () => {
119
119
  baseDir: worktreesDir,
120
120
  agentName: "auth-login",
121
121
  baseBranch: defaultBranch,
122
- beadId: "bead-abc123",
122
+ taskId: "bead-abc123",
123
123
  });
124
124
 
125
125
  try {
@@ -128,7 +128,7 @@ describe("createWorktree", () => {
128
128
  baseDir: worktreesDir,
129
129
  agentName: "auth-login",
130
130
  baseBranch: defaultBranch,
131
- beadId: "bead-abc123",
131
+ taskId: "bead-abc123",
132
132
  });
133
133
  // Should not reach here
134
134
  expect(true).toBe(false);
@@ -174,7 +174,7 @@ describe("listWorktrees", () => {
174
174
  baseDir: worktreesDir,
175
175
  agentName: "auth-login",
176
176
  baseBranch: defaultBranch,
177
- beadId: "bead-abc",
177
+ taskId: "bead-abc",
178
178
  });
179
179
 
180
180
  await createWorktree({
@@ -182,7 +182,7 @@ describe("listWorktrees", () => {
182
182
  baseDir: worktreesDir,
183
183
  agentName: "data-sync",
184
184
  baseBranch: defaultBranch,
185
- beadId: "bead-xyz",
185
+ taskId: "bead-xyz",
186
186
  });
187
187
 
188
188
  const entries = await listWorktrees(repoDir);
@@ -206,7 +206,7 @@ describe("listWorktrees", () => {
206
206
  baseDir: worktreesDir,
207
207
  agentName: "feature-worker",
208
208
  baseBranch: defaultBranch,
209
- beadId: "bead-123",
209
+ taskId: "bead-123",
210
210
  });
211
211
 
212
212
  const entries = await listWorktrees(repoDir);
@@ -223,7 +223,7 @@ describe("listWorktrees", () => {
223
223
  baseDir: worktreesDir,
224
224
  agentName: "auth-login",
225
225
  baseBranch: defaultBranch,
226
- beadId: "bead-abc",
226
+ taskId: "bead-abc",
227
227
  });
228
228
 
229
229
  const entries = await listWorktrees(repoDir);
@@ -266,7 +266,7 @@ describe("isBranchMerged", () => {
266
266
  baseDir: worktreesDir,
267
267
  agentName: "feature-agent",
268
268
  baseBranch: defaultBranch,
269
- beadId: "bead-merged",
269
+ taskId: "bead-merged",
270
270
  });
271
271
 
272
272
  // Add a commit to the feature branch
@@ -285,7 +285,7 @@ describe("isBranchMerged", () => {
285
285
  baseDir: worktreesDir,
286
286
  agentName: "feature-agent",
287
287
  baseBranch: defaultBranch,
288
- beadId: "bead-unmerged",
288
+ taskId: "bead-unmerged",
289
289
  });
290
290
 
291
291
  // Add a commit to the feature branch (not merged)
@@ -302,7 +302,7 @@ describe("isBranchMerged", () => {
302
302
  baseDir: worktreesDir,
303
303
  agentName: "feature-agent",
304
304
  baseBranch: defaultBranch,
305
- beadId: "bead-same",
305
+ taskId: "bead-same",
306
306
  });
307
307
 
308
308
  // The branch was created from defaultBranch with no additional commits,
@@ -334,7 +334,7 @@ describe("removeWorktree", () => {
334
334
  baseDir: worktreesDir,
335
335
  agentName: "auth-login",
336
336
  baseBranch: defaultBranch,
337
- beadId: "bead-abc",
337
+ taskId: "bead-abc",
338
338
  });
339
339
 
340
340
  expect(existsSync(wtPath)).toBe(true);
@@ -350,7 +350,7 @@ describe("removeWorktree", () => {
350
350
  baseDir: worktreesDir,
351
351
  agentName: "auth-login",
352
352
  baseBranch: defaultBranch,
353
- beadId: "bead-abc",
353
+ taskId: "bead-abc",
354
354
  });
355
355
 
356
356
  await removeWorktree(repoDir, wtPath);
@@ -365,7 +365,7 @@ describe("removeWorktree", () => {
365
365
  baseDir: worktreesDir,
366
366
  agentName: "auth-login",
367
367
  baseBranch: defaultBranch,
368
- beadId: "bead-abc",
368
+ taskId: "bead-abc",
369
369
  });
370
370
 
371
371
  await removeWorktree(repoDir, wtPath);
@@ -381,7 +381,7 @@ describe("removeWorktree", () => {
381
381
  baseDir: worktreesDir,
382
382
  agentName: "auth-login",
383
383
  baseBranch: defaultBranch,
384
- beadId: "bead-abc",
384
+ taskId: "bead-abc",
385
385
  });
386
386
 
387
387
  // Create an untracked file in the worktree
@@ -400,7 +400,7 @@ describe("removeWorktree", () => {
400
400
  baseDir: worktreesDir,
401
401
  agentName: "auth-login",
402
402
  baseBranch: defaultBranch,
403
- beadId: "bead-abc",
403
+ taskId: "bead-abc",
404
404
  });
405
405
 
406
406
  // Add a commit in the worktree so the branch diverges (making it "unmerged")
@@ -419,7 +419,7 @@ describe("removeWorktree", () => {
419
419
  baseDir: worktreesDir,
420
420
  agentName: "auth-login",
421
421
  baseBranch: defaultBranch,
422
- beadId: "bead-abc",
422
+ taskId: "bead-abc",
423
423
  });
424
424
 
425
425
  // Add a commit to make the branch unmerged
@@ -1,3 +1,4 @@
1
+ import { unlink } from "node:fs/promises";
1
2
  import { join } from "node:path";
2
3
  import { WorktreeError } from "../errors.ts";
3
4
 
@@ -38,7 +39,7 @@ async function runGit(
38
39
  * Create a new git worktree for an agent.
39
40
  *
40
41
  * Creates a worktree at `{baseDir}/{agentName}` with a new branch
41
- * named `overstory/{agentName}/{beadId}` based on `baseBranch`.
42
+ * named `overstory/{agentName}/{taskId}` based on `baseBranch`.
42
43
  *
43
44
  * @returns The absolute worktree path and branch name.
44
45
  */
@@ -47,12 +48,12 @@ export async function createWorktree(options: {
47
48
  baseDir: string;
48
49
  agentName: string;
49
50
  baseBranch: string;
50
- beadId: string;
51
+ taskId: string;
51
52
  }): Promise<{ path: string; branch: string }> {
52
- const { repoRoot, baseDir, agentName, baseBranch, beadId } = options;
53
+ const { repoRoot, baseDir, agentName, baseBranch, taskId } = options;
53
54
 
54
55
  const worktreePath = join(baseDir, agentName);
55
- const branchName = `overstory/${agentName}/${beadId}`;
56
+ const branchName = `overstory/${agentName}/${taskId}`;
56
57
 
57
58
  await runGit(repoRoot, ["worktree", "add", "-b", branchName, worktreePath, baseBranch], {
58
59
  worktreePath,
@@ -196,3 +197,118 @@ export async function removeWorktree(
196
197
  }
197
198
  }
198
199
  }
200
+
201
+ /**
202
+ * Preserve .seeds/ changes from a branch into the canonical branch.
203
+ *
204
+ * Lead agent branches are never merged via the normal merge pipeline, so
205
+ * any .seeds/ issue files they create would be lost when the worktree is
206
+ * cleaned. This function extracts only the .seeds/ diff from the branch
207
+ * and applies it to the canonical branch via a patch.
208
+ *
209
+ * @returns `{ preserved: true }` if changes were found and committed,
210
+ * `{ preserved: false }` if there were no .seeds/ changes,
211
+ * `{ preserved: false, error: "..." }` if something went wrong.
212
+ */
213
+ export async function preserveSeedsChanges(
214
+ repoRoot: string,
215
+ branch: string,
216
+ canonicalBranch: string,
217
+ agentName: string,
218
+ ): Promise<{ preserved: boolean; error?: string }> {
219
+ // Step 1: Get the .seeds/ diff between canonical and the branch (three-dot diff).
220
+ // Three-dot diff shows changes introduced on branch since it diverged from canonicalBranch.
221
+ let diff: string;
222
+ try {
223
+ diff = await runGit(repoRoot, ["diff", `${canonicalBranch}...${branch}`, "--", ".seeds/"]);
224
+ } catch (err) {
225
+ const msg = err instanceof Error ? err.message : String(err);
226
+ return { preserved: false, error: `Failed to compute .seeds/ diff: ${msg}` };
227
+ }
228
+
229
+ if (diff.trim() === "") {
230
+ // No .seeds/ changes on this branch
231
+ return { preserved: false };
232
+ }
233
+
234
+ // Step 2: Verify the repo root is currently on canonicalBranch.
235
+ let currentBranch: string;
236
+ try {
237
+ currentBranch = (await runGit(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
238
+ } catch (err) {
239
+ const msg = err instanceof Error ? err.message : String(err);
240
+ return { preserved: false, error: `Failed to determine current branch: ${msg}` };
241
+ }
242
+
243
+ if (currentBranch !== canonicalBranch) {
244
+ return {
245
+ preserved: false,
246
+ error: `Repo root is on '${currentBranch}', expected '${canonicalBranch}'. Cannot apply patch.`,
247
+ };
248
+ }
249
+
250
+ // Step 3: Check that .seeds/ is clean in the canonical branch.
251
+ let statusOutput: string;
252
+ try {
253
+ statusOutput = await runGit(repoRoot, ["status", "--porcelain", "--", ".seeds/"]);
254
+ } catch (err) {
255
+ const msg = err instanceof Error ? err.message : String(err);
256
+ return { preserved: false, error: `Failed to check .seeds/ status: ${msg}` };
257
+ }
258
+
259
+ if (statusOutput.trim() !== "") {
260
+ return {
261
+ preserved: false,
262
+ error: `.seeds/ has uncommitted changes in canonical branch. Cannot apply patch safely.`,
263
+ };
264
+ }
265
+
266
+ // Step 4: Write diff to a temp file.
267
+ const tmpFile = join(repoRoot, ".overstory", `_seeds-patch-${Date.now()}.diff`);
268
+ try {
269
+ await Bun.write(tmpFile, diff);
270
+
271
+ // Step 5: Apply the patch with --index (stages changes).
272
+ try {
273
+ await runGit(repoRoot, ["apply", "--index", tmpFile]);
274
+ } catch (err) {
275
+ const msg = err instanceof Error ? err.message : String(err);
276
+ // Revert any partial changes
277
+ try {
278
+ await runGit(repoRoot, ["reset", "HEAD", "--", ".seeds/"]);
279
+ await runGit(repoRoot, ["checkout", "--", ".seeds/"]);
280
+ } catch {
281
+ // Best-effort revert
282
+ }
283
+ return { preserved: false, error: `Failed to apply .seeds/ patch: ${msg}` };
284
+ }
285
+
286
+ // Step 6: Commit the changes.
287
+ try {
288
+ await runGit(repoRoot, [
289
+ "commit",
290
+ "-m",
291
+ `chore: preserve .seeds/ changes from lead ${agentName}`,
292
+ ]);
293
+ } catch (err) {
294
+ const msg = err instanceof Error ? err.message : String(err);
295
+ // Revert any staged changes
296
+ try {
297
+ await runGit(repoRoot, ["reset", "HEAD", "--", ".seeds/"]);
298
+ await runGit(repoRoot, ["checkout", "--", ".seeds/"]);
299
+ } catch {
300
+ // Best-effort revert
301
+ }
302
+ return { preserved: false, error: `Failed to commit .seeds/ changes: ${msg}` };
303
+ }
304
+
305
+ return { preserved: true };
306
+ } finally {
307
+ // Step 8: Always clean up the temp file.
308
+ try {
309
+ await unlink(tmpFile);
310
+ } catch {
311
+ // Ignore cleanup errors
312
+ }
313
+ }
314
+ }
@@ -3,6 +3,7 @@ import { AgentError } from "../errors.ts";
3
3
  import {
4
4
  capturePaneContent,
5
5
  createSession,
6
+ ensureTmuxAvailable,
6
7
  getDescendantPids,
7
8
  getPanePid,
8
9
  isProcessAlive,
@@ -961,21 +962,26 @@ describe("waitForTuiReady", () => {
961
962
  });
962
963
 
963
964
  test("returns true after content appears on later poll", async () => {
964
- let callCount = 0;
965
- spawnSpy.mockImplementation(() => {
966
- callCount++;
967
- if (callCount <= 3) {
968
- // First 3 polls: empty pane (TUI still loading)
969
- return mockSpawnResult("", "", 0);
965
+ let captureCallCount = 0;
966
+ spawnSpy.mockImplementation((...args: unknown[]) => {
967
+ const cmd = args[0] as string[];
968
+ if (cmd[1] === "capture-pane") {
969
+ captureCallCount++;
970
+ if (captureCallCount <= 3) {
971
+ // First 3 capture-pane polls: empty pane (TUI still loading)
972
+ return mockSpawnResult("", "", 0);
973
+ }
974
+ // 4th poll: content appears
975
+ return mockSpawnResult("Welcome to Claude Code!", "", 0);
970
976
  }
971
- // 4th poll: content appears
972
- return mockSpawnResult("Welcome to Claude Code!", "", 0);
977
+ // has-session: session is alive throughout
978
+ return mockSpawnResult("", "", 0);
973
979
  });
974
980
 
975
981
  const ready = await waitForTuiReady("overstory-agent", 10_000, 500);
976
982
 
977
983
  expect(ready).toBe(true);
978
- // Should have slept 3 times (3 empty polls before content appeared)
984
+ // Should have slept 3 times (3 empty capture-pane polls before content appeared)
979
985
  expect(sleepSpy).toHaveBeenCalledTimes(3);
980
986
  });
981
987
 
@@ -1006,4 +1012,87 @@ describe("waitForTuiReady", () => {
1006
1012
 
1007
1013
  expect(ready).toBe(true);
1008
1014
  });
1015
+
1016
+ test("returns false immediately when session is dead", async () => {
1017
+ // capture-pane fails (session dead), has-session also fails (session dead)
1018
+ spawnSpy.mockImplementation((...args: unknown[]) => {
1019
+ const cmd = args[0] as string[];
1020
+ if (cmd[1] === "capture-pane") {
1021
+ return mockSpawnResult("", "can't find session", 1);
1022
+ }
1023
+ // has-session: session is dead
1024
+ return mockSpawnResult("", "can't find session", 1);
1025
+ });
1026
+
1027
+ const ready = await waitForTuiReady("dead-session", 15_000, 500);
1028
+
1029
+ expect(ready).toBe(false);
1030
+ // Should NOT have polled the full timeout (no sleeps — returned immediately)
1031
+ expect(sleepSpy).not.toHaveBeenCalled();
1032
+ });
1033
+
1034
+ test("continues polling when session is alive but pane is empty", async () => {
1035
+ let captureCallCount = 0;
1036
+ spawnSpy.mockImplementation((...args: unknown[]) => {
1037
+ const cmd = args[0] as string[];
1038
+ if (cmd[1] === "capture-pane") {
1039
+ captureCallCount++;
1040
+ // Pane stays empty for all polls (session alive but TUI not rendered yet)
1041
+ return mockSpawnResult("", "", 0);
1042
+ }
1043
+ // has-session: session is alive
1044
+ return mockSpawnResult("", "", 0);
1045
+ });
1046
+
1047
+ // Use a short timeout so the test doesn't take long
1048
+ const ready = await waitForTuiReady("loading-session", 1_000, 500);
1049
+
1050
+ expect(ready).toBe(false);
1051
+ // Should have polled multiple times (not returned early)
1052
+ expect(captureCallCount).toBeGreaterThan(1);
1053
+ expect(sleepSpy).toHaveBeenCalled();
1054
+ });
1055
+ });
1056
+
1057
+ describe("ensureTmuxAvailable", () => {
1058
+ let spawnSpy: ReturnType<typeof spyOn>;
1059
+
1060
+ beforeEach(() => {
1061
+ spawnSpy = spyOn(Bun, "spawn");
1062
+ });
1063
+
1064
+ afterEach(() => {
1065
+ spawnSpy.mockRestore();
1066
+ });
1067
+
1068
+ test("succeeds when tmux is available", async () => {
1069
+ spawnSpy.mockImplementation(() => mockSpawnResult("tmux 3.3a\n", "", 0));
1070
+
1071
+ // Should not throw
1072
+ await ensureTmuxAvailable();
1073
+
1074
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
1075
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
1076
+ const cmd = callArgs[0] as string[];
1077
+ expect(cmd).toEqual(["tmux", "-V"]);
1078
+ });
1079
+
1080
+ test("throws AgentError when tmux is not installed", async () => {
1081
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "tmux: command not found", 1));
1082
+
1083
+ await expect(ensureTmuxAvailable()).rejects.toThrow(AgentError);
1084
+ });
1085
+
1086
+ test("AgentError message mentions tmux not installed", async () => {
1087
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 127));
1088
+
1089
+ try {
1090
+ await ensureTmuxAvailable();
1091
+ expect(true).toBe(false); // Should have thrown
1092
+ } catch (err: unknown) {
1093
+ expect(err).toBeInstanceOf(AgentError);
1094
+ const agentErr = err as AgentError;
1095
+ expect(agentErr.message).toContain("tmux is not installed");
1096
+ }
1097
+ });
1009
1098
  });