@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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1304
|
+
taskId: "task-999",
|
|
1305
1305
|
tmuxSession: "overstory-doomed-agent",
|
|
1306
1306
|
state: "stalled",
|
|
1307
1307
|
lastActivity: staleActivity,
|
package/src/watchdog/daemon.ts
CHANGED
|
@@ -73,7 +73,7 @@ async function recordFailure(
|
|
|
73
73
|
type: "failure",
|
|
74
74
|
description,
|
|
75
75
|
tags: ["watchdog", "auto-recorded"],
|
|
76
|
-
evidenceBead: session.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
422
|
+
taskId: "bead-abc",
|
|
423
423
|
});
|
|
424
424
|
|
|
425
425
|
// Add a commit to make the branch unmerged
|
package/src/worktree/manager.ts
CHANGED
|
@@ -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}/{
|
|
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
|
-
|
|
51
|
+
taskId: string;
|
|
51
52
|
}): Promise<{ path: string; branch: string }> {
|
|
52
|
-
const { repoRoot, baseDir, agentName, baseBranch,
|
|
53
|
+
const { repoRoot, baseDir, agentName, baseBranch, taskId } = options;
|
|
53
54
|
|
|
54
55
|
const worktreePath = join(baseDir, agentName);
|
|
55
|
-
const branchName = `overstory/${agentName}/${
|
|
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
|
|
965
|
-
spawnSpy.mockImplementation(() => {
|
|
966
|
-
|
|
967
|
-
if (
|
|
968
|
-
|
|
969
|
-
|
|
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
|
-
//
|
|
972
|
-
return mockSpawnResult("
|
|
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
|
});
|