@os-eco/overstory-cli 0.6.1 → 0.6.4
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 +7 -6
- package/package.json +12 -4
- package/src/agents/hooks-deployer.test.ts +94 -16
- package/src/agents/hooks-deployer.ts +18 -0
- package/src/agents/manifest.test.ts +86 -0
- package/src/commands/agents.test.ts +3 -3
- package/src/commands/agents.ts +59 -88
- package/src/commands/clean.test.ts +31 -46
- package/src/commands/clean.ts +28 -49
- package/src/commands/completions.ts +14 -0
- package/src/commands/coordinator.test.ts +131 -24
- package/src/commands/coordinator.ts +100 -63
- package/src/commands/costs.test.ts +2 -2
- package/src/commands/costs.ts +96 -75
- package/src/commands/dashboard.test.ts +2 -2
- package/src/commands/dashboard.ts +73 -93
- 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 +2 -2
- package/src/commands/inspect.ts +54 -57
- package/src/commands/log.test.ts +5 -10
- package/src/commands/log.ts +90 -84
- package/src/commands/logs.test.ts +1 -1
- package/src/commands/logs.ts +101 -104
- package/src/commands/mail.ts +157 -169
- package/src/commands/merge.test.ts +20 -58
- package/src/commands/merge.ts +13 -43
- package/src/commands/metrics.test.ts +2 -2
- package/src/commands/metrics.ts +33 -34
- package/src/commands/monitor.test.ts +3 -3
- package/src/commands/monitor.ts +56 -61
- package/src/commands/nudge.ts +41 -89
- package/src/commands/prime.test.ts +15 -47
- package/src/commands/prime.ts +7 -44
- package/src/commands/replay.test.ts +2 -2
- package/src/commands/replay.ts +79 -86
- package/src/commands/run.ts +97 -77
- package/src/commands/sling.test.ts +196 -0
- package/src/commands/sling.ts +24 -54
- package/src/commands/spec.test.ts +13 -39
- package/src/commands/spec.ts +30 -99
- package/src/commands/status.ts +46 -42
- package/src/commands/stop.test.ts +21 -39
- package/src/commands/stop.ts +18 -33
- package/src/commands/supervisor.test.ts +3 -5
- package/src/commands/supervisor.ts +136 -157
- package/src/commands/trace.test.ts +9 -9
- package/src/commands/trace.ts +54 -77
- package/src/commands/watch.test.ts +2 -2
- package/src/commands/watch.ts +38 -45
- package/src/commands/worktree.test.ts +8 -8
- package/src/commands/worktree.ts +63 -46
- package/src/config.test.ts +96 -0
- 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/e2e/init-sling-lifecycle.test.ts +6 -6
- 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/merge/queue.test.ts +66 -0
- package/src/merge/queue.ts +15 -0
- package/src/schema-consistency.test.ts +239 -0
- package/src/sessions/compat.ts +1 -1
- package/src/sessions/store.test.ts +37 -0
- package/src/sessions/store.ts +11 -0
- package/src/worktree/tmux.test.ts +98 -9
- package/src/worktree/tmux.ts +18 -0
package/src/commands/trace.ts
CHANGED
|
@@ -6,15 +6,17 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
+
import { Command } from "commander";
|
|
9
10
|
import { loadConfig } from "../config.ts";
|
|
10
11
|
import { ValidationError } from "../errors.ts";
|
|
11
12
|
import { createEventStore } from "../events/store.ts";
|
|
13
|
+
import type { ColorFn } from "../logging/color.ts";
|
|
12
14
|
import { color } from "../logging/color.ts";
|
|
13
15
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
14
16
|
import type { EventType, StoredEvent } from "../types.ts";
|
|
15
17
|
|
|
16
18
|
/** Labels and colors for each event type. */
|
|
17
|
-
const EVENT_LABELS: Record<EventType, { label: string; color:
|
|
19
|
+
const EVENT_LABELS: Record<EventType, { label: string; color: ColorFn }> = {
|
|
18
20
|
tool_start: { label: "TOOL START", color: color.blue },
|
|
19
21
|
tool_end: { label: "TOOL END ", color: color.blue },
|
|
20
22
|
session_start: { label: "SESSION +", color: color.green },
|
|
@@ -26,21 +28,6 @@ const EVENT_LABELS: Record<EventType, { label: string; color: string }> = {
|
|
|
26
28
|
custom: { label: "CUSTOM ", color: color.gray },
|
|
27
29
|
};
|
|
28
30
|
|
|
29
|
-
/**
|
|
30
|
-
* Parse a named flag value from args.
|
|
31
|
-
*/
|
|
32
|
-
function getFlag(args: string[], flag: string): string | undefined {
|
|
33
|
-
const idx = args.indexOf(flag);
|
|
34
|
-
if (idx === -1 || idx + 1 >= args.length) {
|
|
35
|
-
return undefined;
|
|
36
|
-
}
|
|
37
|
-
return args[idx + 1];
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function hasFlag(args: string[], flag: string): boolean {
|
|
41
|
-
return args.includes(flag);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
31
|
/**
|
|
45
32
|
* Detect whether a target string looks like a bead ID.
|
|
46
33
|
* Bead IDs follow the pattern: word-alphanumeric (e.g., "overstory-rj1k", "myproject-abc1").
|
|
@@ -142,15 +129,15 @@ function buildEventDetail(event: StoredEvent): string {
|
|
|
142
129
|
function printTimeline(events: StoredEvent[], agentName: string, useAbsoluteTime: boolean): void {
|
|
143
130
|
const w = process.stdout.write.bind(process.stdout);
|
|
144
131
|
|
|
145
|
-
w(`${color.bold
|
|
132
|
+
w(`${color.bold(`Timeline for ${agentName}`)}\n`);
|
|
146
133
|
w(`${"=".repeat(70)}\n`);
|
|
147
134
|
|
|
148
135
|
if (events.length === 0) {
|
|
149
|
-
w(`${color.dim
|
|
136
|
+
w(`${color.dim("No events found.")}\n`);
|
|
150
137
|
return;
|
|
151
138
|
}
|
|
152
139
|
|
|
153
|
-
w(`${color.dim
|
|
140
|
+
w(`${color.dim(`${events.length} event${events.length === 1 ? "" : "s"}`)}\n\n`);
|
|
154
141
|
|
|
155
142
|
let lastDate = "";
|
|
156
143
|
|
|
@@ -161,7 +148,7 @@ function printTimeline(events: StoredEvent[], agentName: string, useAbsoluteTime
|
|
|
161
148
|
if (lastDate !== "") {
|
|
162
149
|
w("\n");
|
|
163
150
|
}
|
|
164
|
-
w(`${color.dim
|
|
151
|
+
w(`${color.dim(`--- ${date} ---`)}\n`);
|
|
165
152
|
lastDate = date;
|
|
166
153
|
}
|
|
167
154
|
|
|
@@ -174,74 +161,35 @@ function printTimeline(events: StoredEvent[], agentName: string, useAbsoluteTime
|
|
|
174
161
|
color: color.gray,
|
|
175
162
|
};
|
|
176
163
|
|
|
177
|
-
const
|
|
178
|
-
event.level === "error" ? color.red : event.level === "warn" ? color.yellow :
|
|
179
|
-
const
|
|
164
|
+
const levelColorFn =
|
|
165
|
+
event.level === "error" ? color.red : event.level === "warn" ? color.yellow : null;
|
|
166
|
+
const applyLevel = (text: string) => (levelColorFn ? levelColorFn(text) : text);
|
|
180
167
|
|
|
181
168
|
const detail = buildEventDetail(event);
|
|
182
|
-
const detailSuffix = detail ? ` ${color.dim
|
|
169
|
+
const detailSuffix = detail ? ` ${color.dim(detail)}` : "";
|
|
183
170
|
|
|
184
|
-
const agentLabel =
|
|
185
|
-
event.agentName !== agentName ? ` ${color.dim}[${event.agentName}]${color.reset}` : "";
|
|
171
|
+
const agentLabel = event.agentName !== agentName ? ` ${color.dim(`[${event.agentName}]`)}` : "";
|
|
186
172
|
|
|
187
173
|
w(
|
|
188
|
-
`${color.dim
|
|
189
|
-
`${
|
|
174
|
+
`${color.dim(timeStr.padStart(10))} ` +
|
|
175
|
+
`${applyLevel(eventInfo.color(color.bold(eventInfo.label)))}` +
|
|
190
176
|
`${agentLabel}${detailSuffix}\n`,
|
|
191
177
|
);
|
|
192
178
|
}
|
|
193
179
|
}
|
|
194
180
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
Options:
|
|
203
|
-
--json Output as JSON array of StoredEvent objects
|
|
204
|
-
--since <timestamp> Start time filter (ISO 8601)
|
|
205
|
-
--until <timestamp> End time filter (ISO 8601)
|
|
206
|
-
--limit <n> Max events to show (default: 100)
|
|
207
|
-
--help, -h Show this help`;
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Entry point for `overstory trace <target> [--json] [--since] [--until] [--limit]`.
|
|
211
|
-
*/
|
|
212
|
-
export async function traceCommand(args: string[]): Promise<void> {
|
|
213
|
-
if (args.includes("--help") || args.includes("-h")) {
|
|
214
|
-
process.stdout.write(`${TRACE_HELP}\n`);
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Extract positional target: first arg that is not a flag or flag value
|
|
219
|
-
const flagsWithValues = new Set(["--since", "--until", "--limit"]);
|
|
220
|
-
const booleanFlags = new Set(["--json", "--help", "-h"]);
|
|
221
|
-
let target: string | undefined;
|
|
222
|
-
for (let i = 0; i < args.length; i++) {
|
|
223
|
-
const arg = args[i];
|
|
224
|
-
if (arg === undefined) continue;
|
|
225
|
-
if (booleanFlags.has(arg)) continue;
|
|
226
|
-
if (flagsWithValues.has(arg)) {
|
|
227
|
-
i++; // skip the value
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
if (arg.startsWith("-")) continue;
|
|
231
|
-
target = arg;
|
|
232
|
-
break;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (!target) {
|
|
236
|
-
throw new ValidationError("Missing target. Usage: overstory trace <agent-name|bead-id>", {
|
|
237
|
-
field: "target",
|
|
238
|
-
});
|
|
239
|
-
}
|
|
181
|
+
interface TraceOpts {
|
|
182
|
+
json?: boolean;
|
|
183
|
+
since?: string;
|
|
184
|
+
until?: string;
|
|
185
|
+
limit?: string;
|
|
186
|
+
}
|
|
240
187
|
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
const
|
|
188
|
+
async function executeTrace(target: string, opts: TraceOpts): Promise<void> {
|
|
189
|
+
const json = opts.json ?? false;
|
|
190
|
+
const sinceStr = opts.since;
|
|
191
|
+
const untilStr = opts.until;
|
|
192
|
+
const limitStr = opts.limit;
|
|
245
193
|
const limit = limitStr ? Number.parseInt(limitStr, 10) : 100;
|
|
246
194
|
|
|
247
195
|
if (Number.isNaN(limit) || limit < 1) {
|
|
@@ -323,3 +271,32 @@ export async function traceCommand(args: string[]): Promise<void> {
|
|
|
323
271
|
eventStore.close();
|
|
324
272
|
}
|
|
325
273
|
}
|
|
274
|
+
|
|
275
|
+
export function createTraceCommand(): Command {
|
|
276
|
+
return new Command("trace")
|
|
277
|
+
.description("Chronological event timeline for agent/bead")
|
|
278
|
+
.argument("<target>", "Agent name or bead ID")
|
|
279
|
+
.option("--json", "Output as JSON array of StoredEvent objects")
|
|
280
|
+
.option("--since <timestamp>", "Start time filter (ISO 8601)")
|
|
281
|
+
.option("--until <timestamp>", "End time filter (ISO 8601)")
|
|
282
|
+
.option("--limit <n>", "Max events to show (default: 100)")
|
|
283
|
+
.action(async (target: string, opts: TraceOpts) => {
|
|
284
|
+
await executeTrace(target, opts);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export async function traceCommand(args: string[]): Promise<void> {
|
|
289
|
+
const cmd = createTraceCommand();
|
|
290
|
+
cmd.exitOverride();
|
|
291
|
+
try {
|
|
292
|
+
await cmd.parseAsync(args, { from: "user" });
|
|
293
|
+
} catch (err: unknown) {
|
|
294
|
+
if (err && typeof err === "object" && "code" in err) {
|
|
295
|
+
const code = (err as { code: string }).code;
|
|
296
|
+
if (code === "commander.helpDisplayed" || code === "commander.version") {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
throw err;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -81,7 +81,7 @@ describe("watchCommand", () => {
|
|
|
81
81
|
await watchCommand(["--help"]);
|
|
82
82
|
const out = output();
|
|
83
83
|
|
|
84
|
-
expect(out).toContain("
|
|
84
|
+
expect(out).toContain("watch");
|
|
85
85
|
expect(out).toContain("--interval");
|
|
86
86
|
expect(out).toContain("--background");
|
|
87
87
|
expect(out).toContain("Tier 0");
|
|
@@ -91,7 +91,7 @@ describe("watchCommand", () => {
|
|
|
91
91
|
await watchCommand(["-h"]);
|
|
92
92
|
const out = output();
|
|
93
93
|
|
|
94
|
-
expect(out).toContain("
|
|
94
|
+
expect(out).toContain("watch");
|
|
95
95
|
expect(out).toContain("Tier 0");
|
|
96
96
|
});
|
|
97
97
|
|
package/src/commands/watch.ts
CHANGED
|
@@ -7,27 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
+
import { Command } from "commander";
|
|
10
11
|
import { loadConfig } from "../config.ts";
|
|
11
12
|
import { OverstoryError } from "../errors.ts";
|
|
12
13
|
import type { HealthCheck } from "../types.ts";
|
|
13
14
|
import { startDaemon } from "../watchdog/daemon.ts";
|
|
14
15
|
import { isProcessRunning } from "../watchdog/health.ts";
|
|
15
16
|
|
|
16
|
-
/**
|
|
17
|
-
* Parse a named flag value from args.
|
|
18
|
-
*/
|
|
19
|
-
function getFlag(args: string[], flag: string): string | undefined {
|
|
20
|
-
const idx = args.indexOf(flag);
|
|
21
|
-
if (idx === -1 || idx + 1 >= args.length) {
|
|
22
|
-
return undefined;
|
|
23
|
-
}
|
|
24
|
-
return args[idx + 1];
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function hasFlag(args: string[], flag: string): boolean {
|
|
28
|
-
return args.includes(flag);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
17
|
/**
|
|
32
18
|
* Format a health check for display.
|
|
33
19
|
*/
|
|
@@ -126,44 +112,21 @@ async function resolveOverstoryBin(): Promise<string> {
|
|
|
126
112
|
}
|
|
127
113
|
|
|
128
114
|
/**
|
|
129
|
-
*
|
|
115
|
+
* Core implementation for the watch command.
|
|
130
116
|
*/
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
Usage: overstory watch [--interval <ms>] [--background]
|
|
134
|
-
|
|
135
|
-
Tier numbering:
|
|
136
|
-
Tier 0 Mechanical daemon (heartbeat, tmux/pid liveness) — this command
|
|
137
|
-
Tier 1 Triage agent (ephemeral AI analysis of stalled agents)
|
|
138
|
-
Tier 2 Monitor agent (continuous patrol — not yet implemented)
|
|
139
|
-
Tier 3 Supervisor monitors (per-project)
|
|
140
|
-
|
|
141
|
-
Options:
|
|
142
|
-
--interval <ms> Health check interval in milliseconds (default: from config)
|
|
143
|
-
--background Daemonize (run in background)
|
|
144
|
-
--help, -h Show this help`;
|
|
145
|
-
|
|
146
|
-
export async function watchCommand(args: string[]): Promise<void> {
|
|
147
|
-
if (args.includes("--help") || args.includes("-h")) {
|
|
148
|
-
process.stdout.write(`${WATCH_HELP}\n`);
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const intervalStr = getFlag(args, "--interval");
|
|
153
|
-
const background = hasFlag(args, "--background");
|
|
154
|
-
|
|
117
|
+
async function runWatch(opts: { interval?: string; background?: boolean }): Promise<void> {
|
|
155
118
|
const cwd = process.cwd();
|
|
156
119
|
const config = await loadConfig(cwd);
|
|
157
120
|
|
|
158
|
-
const intervalMs =
|
|
159
|
-
? Number.parseInt(
|
|
121
|
+
const intervalMs = opts.interval
|
|
122
|
+
? Number.parseInt(opts.interval, 10)
|
|
160
123
|
: config.watchdog.tier0IntervalMs;
|
|
161
124
|
|
|
162
125
|
const staleThresholdMs = config.watchdog.staleThresholdMs;
|
|
163
126
|
const zombieThresholdMs = config.watchdog.zombieThresholdMs;
|
|
164
127
|
const pidFilePath = join(config.project.root, ".overstory", "watchdog.pid");
|
|
165
128
|
|
|
166
|
-
if (background) {
|
|
129
|
+
if (opts.background) {
|
|
167
130
|
// Check if a watchdog is already running
|
|
168
131
|
const existingPid = await readPidFile(pidFilePath);
|
|
169
132
|
if (existingPid !== null && isProcessRunning(existingPid)) {
|
|
@@ -182,8 +145,8 @@ export async function watchCommand(args: string[]): Promise<void> {
|
|
|
182
145
|
|
|
183
146
|
// Build the args for the child process, forwarding --interval but not --background
|
|
184
147
|
const childArgs: string[] = ["watch"];
|
|
185
|
-
if (
|
|
186
|
-
childArgs.push("--interval",
|
|
148
|
+
if (opts.interval) {
|
|
149
|
+
childArgs.push("--interval", opts.interval);
|
|
187
150
|
}
|
|
188
151
|
|
|
189
152
|
// Resolve the overstory binary path
|
|
@@ -245,3 +208,33 @@ export async function watchCommand(args: string[]): Promise<void> {
|
|
|
245
208
|
// Block forever
|
|
246
209
|
await new Promise(() => {});
|
|
247
210
|
}
|
|
211
|
+
|
|
212
|
+
export function createWatchCommand(): Command {
|
|
213
|
+
return new Command("watch")
|
|
214
|
+
.description("Start Tier 0 mechanical watchdog daemon")
|
|
215
|
+
.option("--interval <ms>", "Health check interval in milliseconds")
|
|
216
|
+
.option("--background", "Daemonize (run in background)")
|
|
217
|
+
.action(async (opts: { interval?: string; background?: boolean }) => {
|
|
218
|
+
await runWatch(opts);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Entry point for `overstory watch [--interval <ms>] [--background]`.
|
|
224
|
+
*/
|
|
225
|
+
export async function watchCommand(args: string[]): Promise<void> {
|
|
226
|
+
const cmd = createWatchCommand();
|
|
227
|
+
cmd.exitOverride();
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
await cmd.parseAsync(args, { from: "user" });
|
|
231
|
+
} catch (err: unknown) {
|
|
232
|
+
if (err && typeof err === "object" && "code" in err) {
|
|
233
|
+
const code = (err as { code: string }).code;
|
|
234
|
+
if (code === "commander.helpDisplayed" || code === "commander.version") {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { existsSync, realpathSync } from "node:fs";
|
|
3
3
|
import { mkdir } from "node:fs/promises";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { ValidationError } from "../errors.ts";
|
|
5
6
|
import { createSessionStore } from "../sessions/store.ts";
|
|
6
7
|
import { cleanupTempDir, commitFile, createTempGitRepo, runGitInDir } from "../test-helpers.ts";
|
|
7
8
|
import type { AgentSession } from "../types.ts";
|
|
@@ -99,30 +100,29 @@ describe("worktreeCommand", () => {
|
|
|
99
100
|
await worktreeCommand(["--help"]);
|
|
100
101
|
const out = output();
|
|
101
102
|
|
|
102
|
-
expect(out).toContain("
|
|
103
|
+
expect(out).toContain("worktree");
|
|
103
104
|
expect(out).toContain("list");
|
|
104
105
|
expect(out).toContain("clean");
|
|
105
|
-
expect(out).toContain("--json");
|
|
106
106
|
});
|
|
107
107
|
|
|
108
108
|
test("-h shows help text", async () => {
|
|
109
109
|
await worktreeCommand(["-h"]);
|
|
110
110
|
const out = output();
|
|
111
111
|
|
|
112
|
-
expect(out).toContain("
|
|
112
|
+
expect(out).toContain("worktree");
|
|
113
113
|
expect(out).toContain("list");
|
|
114
114
|
});
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
describe("validation", () => {
|
|
118
118
|
test("unknown subcommand throws ValidationError", async () => {
|
|
119
|
-
await expect(worktreeCommand(["unknown"])).rejects.toThrow(
|
|
120
|
-
"Unknown worktree subcommand: unknown",
|
|
121
|
-
);
|
|
119
|
+
await expect(worktreeCommand(["unknown"])).rejects.toThrow(ValidationError);
|
|
122
120
|
});
|
|
123
121
|
|
|
124
|
-
test("
|
|
125
|
-
await
|
|
122
|
+
test("empty args shows help text", async () => {
|
|
123
|
+
await worktreeCommand([]);
|
|
124
|
+
const out = output();
|
|
125
|
+
expect(out).toContain("worktree");
|
|
126
126
|
});
|
|
127
127
|
});
|
|
128
128
|
|
package/src/commands/worktree.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
+
import { Command } from "commander";
|
|
10
11
|
import { loadConfig } from "../config.ts";
|
|
11
12
|
import { ValidationError } from "../errors.ts";
|
|
12
13
|
import { createMailStore } from "../mail/store.ts";
|
|
@@ -15,10 +16,6 @@ import type { AgentSession } from "../types.ts";
|
|
|
15
16
|
import { isBranchMerged, listWorktrees, removeWorktree } from "../worktree/manager.ts";
|
|
16
17
|
import { isSessionAlive, killSession } from "../worktree/tmux.ts";
|
|
17
18
|
|
|
18
|
-
function hasFlag(args: string[], flag: string): boolean {
|
|
19
|
-
return args.includes(flag);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
19
|
/**
|
|
23
20
|
* Handle `overstory worktree list`.
|
|
24
21
|
*/
|
|
@@ -72,14 +69,12 @@ async function handleList(root: string, json: boolean): Promise<void> {
|
|
|
72
69
|
* Handle `overstory worktree clean [--completed] [--all] [--force]`.
|
|
73
70
|
*/
|
|
74
71
|
async function handleClean(
|
|
75
|
-
|
|
72
|
+
opts: { all: boolean; force: boolean; completedOnly: boolean },
|
|
76
73
|
root: string,
|
|
77
74
|
json: boolean,
|
|
78
75
|
canonicalBranch: string,
|
|
79
76
|
): Promise<void> {
|
|
80
|
-
const
|
|
81
|
-
const force = hasFlag(args, "--force");
|
|
82
|
-
const completedOnly = hasFlag(args, "--completed") || !all;
|
|
77
|
+
const { force, completedOnly } = opts;
|
|
83
78
|
|
|
84
79
|
const worktrees = await listWorktrees(root);
|
|
85
80
|
const overstoryDir = join(root, ".overstory");
|
|
@@ -260,52 +255,74 @@ async function handleClean(
|
|
|
260
255
|
}
|
|
261
256
|
}
|
|
262
257
|
|
|
258
|
+
export function createWorktreeCommand(): Command {
|
|
259
|
+
const cmd = new Command("worktree").description("Manage agent worktrees");
|
|
260
|
+
|
|
261
|
+
cmd
|
|
262
|
+
.command("list")
|
|
263
|
+
.description("List worktrees with agent status")
|
|
264
|
+
.option("--json", "Output as JSON")
|
|
265
|
+
.action(async (opts: { json?: boolean }) => {
|
|
266
|
+
const cwd = process.cwd();
|
|
267
|
+
const config = await loadConfig(cwd);
|
|
268
|
+
await handleList(config.project.root, opts.json ?? false);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
cmd
|
|
272
|
+
.command("clean")
|
|
273
|
+
.description("Remove completed worktrees")
|
|
274
|
+
.option("--completed", "Only finished agents (default)")
|
|
275
|
+
.option("--all", "Force remove all worktrees")
|
|
276
|
+
.option("--force", "Delete even if branches are unmerged")
|
|
277
|
+
.option("--json", "Output as JSON")
|
|
278
|
+
.action(
|
|
279
|
+
async (opts: { completed?: boolean; all?: boolean; force?: boolean; json?: boolean }) => {
|
|
280
|
+
const cwd = process.cwd();
|
|
281
|
+
const config = await loadConfig(cwd);
|
|
282
|
+
const all = opts.all ?? false;
|
|
283
|
+
await handleClean(
|
|
284
|
+
{
|
|
285
|
+
all,
|
|
286
|
+
force: opts.force ?? false,
|
|
287
|
+
completedOnly: opts.completed ?? !all,
|
|
288
|
+
},
|
|
289
|
+
config.project.root,
|
|
290
|
+
opts.json ?? false,
|
|
291
|
+
config.project.canonicalBranch,
|
|
292
|
+
);
|
|
293
|
+
},
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
return cmd;
|
|
297
|
+
}
|
|
298
|
+
|
|
263
299
|
/**
|
|
264
300
|
* Entry point for `overstory worktree <subcommand> [flags]`.
|
|
265
301
|
*
|
|
266
302
|
* Subcommands: list, clean.
|
|
267
303
|
*/
|
|
268
|
-
const WORKTREE_HELP = `overstory worktree — Manage agent worktrees
|
|
269
|
-
|
|
270
|
-
Usage: overstory worktree <subcommand> [flags]
|
|
271
|
-
|
|
272
|
-
Subcommands:
|
|
273
|
-
list List worktrees with agent status
|
|
274
|
-
clean Remove completed worktrees
|
|
275
|
-
[--completed] Only finished agents (default)
|
|
276
|
-
[--all] Force remove all
|
|
277
|
-
[--force] Delete even if branches are unmerged
|
|
278
|
-
|
|
279
|
-
Options:
|
|
280
|
-
--json Output as JSON
|
|
281
|
-
--help, -h Show this help`;
|
|
282
|
-
|
|
283
304
|
export async function worktreeCommand(args: string[]): Promise<void> {
|
|
284
|
-
|
|
285
|
-
|
|
305
|
+
const cmd = createWorktreeCommand();
|
|
306
|
+
cmd.exitOverride();
|
|
307
|
+
|
|
308
|
+
if (args.length === 0) {
|
|
309
|
+
process.stdout.write(cmd.helpInformation());
|
|
286
310
|
return;
|
|
287
311
|
}
|
|
288
312
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
await handleClean(subArgs, root, jsonFlag, canonicalBranch);
|
|
304
|
-
break;
|
|
305
|
-
default:
|
|
306
|
-
throw new ValidationError(
|
|
307
|
-
`Unknown worktree subcommand: ${subcommand ?? "(none)"}. Use: list, clean`,
|
|
308
|
-
{ field: "subcommand" },
|
|
309
|
-
);
|
|
313
|
+
try {
|
|
314
|
+
await cmd.parseAsync(args, { from: "user" });
|
|
315
|
+
} catch (err: unknown) {
|
|
316
|
+
if (err && typeof err === "object" && "code" in err) {
|
|
317
|
+
const code = (err as { code: string }).code;
|
|
318
|
+
if (code === "commander.helpDisplayed" || code === "commander.version") {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (code === "commander.unknownCommand") {
|
|
322
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
323
|
+
throw new ValidationError(message, { field: "subcommand" });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
throw err;
|
|
310
327
|
}
|
|
311
328
|
}
|
package/src/config.test.ts
CHANGED
|
@@ -265,6 +265,80 @@ providers:
|
|
|
265
265
|
expect(config.providers.anthropic).toEqual({ type: "native" });
|
|
266
266
|
});
|
|
267
267
|
|
|
268
|
+
test("multiple providers parsed correctly", async () => {
|
|
269
|
+
await ensureOverstoryDir();
|
|
270
|
+
await writeConfig(`
|
|
271
|
+
providers:
|
|
272
|
+
anthropic:
|
|
273
|
+
type: native
|
|
274
|
+
openrouter:
|
|
275
|
+
type: gateway
|
|
276
|
+
baseUrl: https://openrouter.ai/api/v1
|
|
277
|
+
authTokenEnv: OPENROUTER_API_KEY
|
|
278
|
+
litellm:
|
|
279
|
+
type: gateway
|
|
280
|
+
baseUrl: http://localhost:4000
|
|
281
|
+
authTokenEnv: LITELLM_API_KEY
|
|
282
|
+
`);
|
|
283
|
+
const config = await loadConfig(tempDir);
|
|
284
|
+
expect(Object.keys(config.providers).length).toBe(3);
|
|
285
|
+
expect(config.providers.anthropic).toEqual({ type: "native" });
|
|
286
|
+
expect(config.providers.openrouter).toEqual({
|
|
287
|
+
type: "gateway",
|
|
288
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
289
|
+
authTokenEnv: "OPENROUTER_API_KEY",
|
|
290
|
+
});
|
|
291
|
+
expect(config.providers.litellm).toEqual({
|
|
292
|
+
type: "gateway",
|
|
293
|
+
baseUrl: "http://localhost:4000",
|
|
294
|
+
authTokenEnv: "LITELLM_API_KEY",
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("config.local.yaml adds new provider alongside config.yaml providers", async () => {
|
|
299
|
+
await ensureOverstoryDir();
|
|
300
|
+
await writeConfig(`
|
|
301
|
+
providers:
|
|
302
|
+
openrouter:
|
|
303
|
+
type: gateway
|
|
304
|
+
baseUrl: https://openrouter.ai/api/v1
|
|
305
|
+
authTokenEnv: OPENROUTER_API_KEY
|
|
306
|
+
`);
|
|
307
|
+
await Bun.write(
|
|
308
|
+
join(tempDir, ".overstory", "config.local.yaml"),
|
|
309
|
+
`providers:\n litellm:\n type: gateway\n baseUrl: http://localhost:4000\n authTokenEnv: LITELLM_API_KEY\n`,
|
|
310
|
+
);
|
|
311
|
+
const config = await loadConfig(tempDir);
|
|
312
|
+
// All three providers present: default anthropic + openrouter from config.yaml + litellm from config.local.yaml
|
|
313
|
+
expect(config.providers.anthropic).toEqual({ type: "native" });
|
|
314
|
+
expect(config.providers.openrouter).toEqual({
|
|
315
|
+
type: "gateway",
|
|
316
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
317
|
+
authTokenEnv: "OPENROUTER_API_KEY",
|
|
318
|
+
});
|
|
319
|
+
expect(config.providers.litellm).toEqual({
|
|
320
|
+
type: "gateway",
|
|
321
|
+
baseUrl: "http://localhost:4000",
|
|
322
|
+
authTokenEnv: "LITELLM_API_KEY",
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("simple model strings still work without providers section", async () => {
|
|
327
|
+
await ensureOverstoryDir();
|
|
328
|
+
await writeConfig(`
|
|
329
|
+
models:
|
|
330
|
+
coordinator: sonnet
|
|
331
|
+
builder: opus
|
|
332
|
+
monitor: haiku
|
|
333
|
+
`);
|
|
334
|
+
const config = await loadConfig(tempDir);
|
|
335
|
+
expect(config.models.coordinator).toBe("sonnet");
|
|
336
|
+
expect(config.models.builder).toBe("opus");
|
|
337
|
+
expect(config.models.monitor).toBe("haiku");
|
|
338
|
+
// Default anthropic provider still present even without explicit providers section
|
|
339
|
+
expect(config.providers.anthropic).toEqual({ type: "native" });
|
|
340
|
+
});
|
|
341
|
+
|
|
268
342
|
test("migrates deprecated watchdog tier1/tier2 keys to tier0/tier1", async () => {
|
|
269
343
|
await ensureOverstoryDir();
|
|
270
344
|
await writeConfig(`
|
|
@@ -556,6 +630,28 @@ models:
|
|
|
556
630
|
await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
|
|
557
631
|
});
|
|
558
632
|
|
|
633
|
+
test("rejects model ref with deeply nested slashes when provider unknown", async () => {
|
|
634
|
+
await writeConfig(`
|
|
635
|
+
models:
|
|
636
|
+
coordinator: unknown/openai/gpt-5.3/latest
|
|
637
|
+
`);
|
|
638
|
+
await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("accepts model ref with deeply nested slashes when provider exists", async () => {
|
|
642
|
+
await writeConfig(`
|
|
643
|
+
providers:
|
|
644
|
+
openrouter:
|
|
645
|
+
type: gateway
|
|
646
|
+
baseUrl: https://openrouter.ai/api/v1
|
|
647
|
+
authTokenEnv: OPENROUTER_API_KEY
|
|
648
|
+
models:
|
|
649
|
+
coordinator: openrouter/openai/gpt-5.3/variant
|
|
650
|
+
`);
|
|
651
|
+
const config = await loadConfig(tempDir);
|
|
652
|
+
expect(config.models.coordinator).toBe("openrouter/openai/gpt-5.3/variant");
|
|
653
|
+
});
|
|
654
|
+
|
|
559
655
|
test("rejects bare invalid model name", async () => {
|
|
560
656
|
await writeConfig(`
|
|
561
657
|
models:
|