@os-eco/overstory-cli 0.9.1 → 0.9.3
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 +21 -6
- package/agents/coordinator.md +34 -10
- package/agents/lead.md +11 -1
- package/package.json +1 -1
- package/src/agents/copilot-hooks-deployer.test.ts +162 -0
- package/src/agents/copilot-hooks-deployer.ts +93 -0
- package/src/agents/hooks-deployer.test.ts +9 -1
- package/src/agents/hooks-deployer.ts +2 -1
- package/src/agents/overlay.test.ts +26 -0
- package/src/agents/overlay.ts +18 -4
- package/src/beads/client.ts +31 -3
- package/src/commands/agents.ts +1 -1
- package/src/commands/clean.test.ts +3 -0
- package/src/commands/clean.ts +1 -58
- package/src/commands/completions.test.ts +18 -6
- package/src/commands/completions.ts +40 -1
- package/src/commands/coordinator.test.ts +77 -4
- package/src/commands/coordinator.ts +228 -125
- package/src/commands/dashboard.ts +50 -10
- package/src/commands/doctor.ts +3 -1
- package/src/commands/ecosystem.test.ts +126 -1
- package/src/commands/ecosystem.ts +7 -53
- package/src/commands/feed.test.ts +117 -2
- package/src/commands/feed.ts +46 -30
- package/src/commands/group.test.ts +274 -155
- package/src/commands/group.ts +11 -5
- package/src/commands/init.ts +50 -0
- package/src/commands/inspect.ts +8 -4
- package/src/commands/log.test.ts +35 -0
- package/src/commands/log.ts +10 -6
- package/src/commands/logs.test.ts +423 -1
- package/src/commands/logs.ts +99 -104
- package/src/commands/monitor.ts +8 -2
- package/src/commands/orchestrator.ts +42 -0
- package/src/commands/prime.test.ts +177 -2
- package/src/commands/prime.ts +4 -2
- package/src/commands/sling.ts +8 -3
- package/src/commands/upgrade.test.ts +2 -0
- package/src/commands/upgrade.ts +1 -17
- package/src/commands/watch.test.ts +67 -1
- package/src/commands/watch.ts +4 -79
- package/src/config.test.ts +250 -0
- package/src/config.ts +43 -0
- package/src/doctor/agents.test.ts +72 -5
- package/src/doctor/agents.ts +10 -10
- package/src/doctor/consistency.test.ts +35 -0
- package/src/doctor/consistency.ts +7 -3
- package/src/doctor/dependencies.test.ts +58 -1
- package/src/doctor/dependencies.ts +4 -2
- package/src/doctor/providers.test.ts +41 -5
- package/src/doctor/types.ts +2 -1
- package/src/doctor/version.test.ts +106 -2
- package/src/doctor/version.ts +4 -2
- package/src/doctor/watchdog.test.ts +167 -0
- package/src/doctor/watchdog.ts +158 -0
- package/src/e2e/init-sling-lifecycle.test.ts +2 -1
- package/src/errors.test.ts +350 -0
- package/src/events/tailer.test.ts +25 -0
- package/src/events/tailer.ts +8 -1
- package/src/index.ts +4 -1
- package/src/mail/store.test.ts +110 -0
- package/src/runtimes/aider.test.ts +124 -0
- package/src/runtimes/aider.ts +147 -0
- package/src/runtimes/amp.test.ts +164 -0
- package/src/runtimes/amp.ts +154 -0
- package/src/runtimes/claude.test.ts +4 -2
- package/src/runtimes/codex.test.ts +38 -1
- package/src/runtimes/codex.ts +22 -3
- package/src/runtimes/copilot.test.ts +213 -13
- package/src/runtimes/copilot.ts +93 -11
- package/src/runtimes/goose.test.ts +133 -0
- package/src/runtimes/goose.ts +157 -0
- package/src/runtimes/pi-guards.ts +2 -1
- package/src/runtimes/pi.test.ts +33 -9
- package/src/runtimes/pi.ts +10 -10
- package/src/runtimes/registry.test.ts +1 -1
- package/src/runtimes/registry.ts +13 -4
- package/src/runtimes/sapling.ts +2 -1
- package/src/runtimes/types.ts +9 -2
- package/src/tracker/factory.test.ts +10 -0
- package/src/tracker/factory.ts +3 -2
- package/src/types.ts +4 -0
- package/src/utils/bin.test.ts +10 -0
- package/src/utils/bin.ts +37 -0
- package/src/utils/fs.test.ts +119 -0
- package/src/utils/fs.ts +62 -0
- package/src/utils/pid.test.ts +68 -0
- package/src/utils/pid.ts +45 -0
- package/src/utils/time.test.ts +43 -0
- package/src/utils/time.ts +37 -0
- package/src/utils/version.test.ts +33 -0
- package/src/utils/version.ts +70 -0
- package/src/watchdog/daemon.test.ts +255 -1
- package/src/watchdog/daemon.ts +46 -9
- package/src/watchdog/health.test.ts +15 -1
- package/src/watchdog/health.ts +1 -1
- package/src/watchdog/triage.test.ts +49 -9
- package/src/watchdog/triage.ts +21 -5
- package/src/worktree/tmux.test.ts +166 -49
- package/src/worktree/tmux.ts +36 -37
- package/templates/copilot-hooks.json.tmpl +13 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Goose runtime adapter for overstory's AgentRuntime interface.
|
|
2
|
+
// Implements the AgentRuntime contract for Block's `goose` CLI (AI developer agent).
|
|
3
|
+
//
|
|
4
|
+
// Key differences from Claude/Pi adapters:
|
|
5
|
+
// - Interactive: `goose` runs as a REPL session in tmux
|
|
6
|
+
// - Instruction file: .goosehints (Goose's native instruction file)
|
|
7
|
+
// - No hooks: Goose uses profile-based permissions, not PreToolUse hooks
|
|
8
|
+
// - One-shot calls use `goose run --text <prompt>`
|
|
9
|
+
// - Model is passed via `--model <model>` (or GOOSE_MODEL env var)
|
|
10
|
+
|
|
11
|
+
import { mkdir } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import type { ResolvedModel } from "../types.ts";
|
|
14
|
+
import type {
|
|
15
|
+
AgentRuntime,
|
|
16
|
+
HooksDef,
|
|
17
|
+
OverlayContent,
|
|
18
|
+
ReadyState,
|
|
19
|
+
SpawnOpts,
|
|
20
|
+
TranscriptSummary,
|
|
21
|
+
} from "./types.ts";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Goose runtime adapter.
|
|
25
|
+
*
|
|
26
|
+
* Implements AgentRuntime for Block's `goose` CLI. Goose agents run
|
|
27
|
+
* as interactive REPL sessions with configurable toolkits (developer,
|
|
28
|
+
* screen, github, etc.).
|
|
29
|
+
*
|
|
30
|
+
* Security is managed via Goose profiles which control which toolkits
|
|
31
|
+
* and permissions are available. No OS-level sandbox.
|
|
32
|
+
*/
|
|
33
|
+
export class GooseRuntime implements AgentRuntime {
|
|
34
|
+
readonly id = "goose";
|
|
35
|
+
|
|
36
|
+
/** Experimental — community-contributed adapter, not yet battle-tested in production. */
|
|
37
|
+
readonly stability = "experimental" as const;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Goose reads .goosehints from the repo root for project-level instructions.
|
|
41
|
+
*/
|
|
42
|
+
readonly instructionPath = ".goosehints";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the shell command string to spawn a Goose agent in a tmux pane.
|
|
46
|
+
*
|
|
47
|
+
* Goose starts an interactive session with `goose`. The `--model` flag
|
|
48
|
+
* sets the model. Instructions are provided via .goosehints.
|
|
49
|
+
*
|
|
50
|
+
* @param opts - Spawn options
|
|
51
|
+
* @returns Shell command string suitable for tmux new-session
|
|
52
|
+
*/
|
|
53
|
+
buildSpawnCommand(opts: SpawnOpts): string {
|
|
54
|
+
let cmd = `goose --model ${opts.model}`;
|
|
55
|
+
|
|
56
|
+
if (opts.appendSystemPromptFile) {
|
|
57
|
+
const escaped = opts.appendSystemPromptFile.replace(/'/g, "'\\''");
|
|
58
|
+
cmd += ` --instructions '${escaped}'`;
|
|
59
|
+
} else if (opts.appendSystemPrompt) {
|
|
60
|
+
// Goose doesn't have an inline system prompt flag — write to temp
|
|
61
|
+
// and use --instructions. For tmux, we pipe it in via the initial prompt.
|
|
62
|
+
const escaped =
|
|
63
|
+
`${opts.appendSystemPrompt}\n\nRead .goosehints for your task assignment.`.replace(
|
|
64
|
+
/'/g,
|
|
65
|
+
"'\\''",
|
|
66
|
+
);
|
|
67
|
+
cmd += ` --with-prompt '${escaped}'`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return cmd;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build argv for a headless one-shot Goose invocation.
|
|
75
|
+
*
|
|
76
|
+
* Uses `goose run --text <prompt>` for non-interactive execution.
|
|
77
|
+
*
|
|
78
|
+
* @param prompt - The prompt to pass
|
|
79
|
+
* @param model - Optional model override
|
|
80
|
+
* @returns Argv array for Bun.spawn
|
|
81
|
+
*/
|
|
82
|
+
buildPrintCommand(prompt: string, model?: string): string[] {
|
|
83
|
+
const cmd = ["goose", "run", "--text", prompt];
|
|
84
|
+
if (model !== undefined) {
|
|
85
|
+
cmd.push("--model", model);
|
|
86
|
+
}
|
|
87
|
+
return cmd;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Deploy per-agent instructions to a worktree.
|
|
92
|
+
*
|
|
93
|
+
* Writes the overlay to .goosehints (Goose's native instruction file).
|
|
94
|
+
* No hooks — Goose uses profile-based permissions.
|
|
95
|
+
*
|
|
96
|
+
* @param worktreePath - Absolute path to the agent's git worktree
|
|
97
|
+
* @param overlay - Overlay content, or undefined for no-op
|
|
98
|
+
* @param _hooks - Unused — Goose has no hook system
|
|
99
|
+
*/
|
|
100
|
+
async deployConfig(
|
|
101
|
+
worktreePath: string,
|
|
102
|
+
overlay: OverlayContent | undefined,
|
|
103
|
+
_hooks: HooksDef,
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
if (!overlay) return;
|
|
106
|
+
await mkdir(worktreePath, { recursive: true });
|
|
107
|
+
await Bun.write(join(worktreePath, this.instructionPath), overlay.content);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Detect Goose TUI readiness from tmux pane content.
|
|
112
|
+
*
|
|
113
|
+
* Goose shows a "( O)> " prompt (the goose emoji) when ready for input.
|
|
114
|
+
*
|
|
115
|
+
* @param paneContent - Captured tmux pane content
|
|
116
|
+
* @returns Readiness phase
|
|
117
|
+
*/
|
|
118
|
+
detectReady(paneContent: string): ReadyState {
|
|
119
|
+
const lower = paneContent.toLowerCase();
|
|
120
|
+
|
|
121
|
+
// Prompt indicator: ">" or "❯" at end of a line
|
|
122
|
+
const hasPrompt = /[>❯]\s*$/.test(paneContent);
|
|
123
|
+
|
|
124
|
+
// Branding indicator: "goose" or the goose emoji "( O)" in pane content
|
|
125
|
+
const hasBranding = lower.includes("goose") || paneContent.includes("( O)");
|
|
126
|
+
|
|
127
|
+
// Both required (AND logic) to prevent premature ready detection
|
|
128
|
+
// during startup messages like "Loading Goose..."
|
|
129
|
+
if (hasPrompt && hasBranding) {
|
|
130
|
+
return { phase: "ready" };
|
|
131
|
+
}
|
|
132
|
+
return { phase: "loading" };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Goose does not require beacon verification. */
|
|
136
|
+
requiresBeaconVerification(): boolean {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Goose does not produce machine-readable transcripts.
|
|
142
|
+
* Session logs are stored in ~/.config/goose/sessions/ but not in
|
|
143
|
+
* a format compatible with overstory's TranscriptSummary.
|
|
144
|
+
*/
|
|
145
|
+
async parseTranscript(_path: string): Promise<TranscriptSummary | null> {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
buildEnv(model: ResolvedModel): Record<string, string> {
|
|
150
|
+
return model.env ?? {};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Goose stores sessions in ~/.config/goose/sessions/ but not transcript-parseable. */
|
|
154
|
+
getTranscriptDir(_projectRoot: string): string | null {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -27,13 +27,14 @@ const NON_IMPLEMENTATION_CAPABILITIES = new Set([
|
|
|
27
27
|
"scout",
|
|
28
28
|
"reviewer",
|
|
29
29
|
"lead",
|
|
30
|
+
"orchestrator",
|
|
30
31
|
"coordinator",
|
|
31
32
|
"supervisor",
|
|
32
33
|
"monitor",
|
|
33
34
|
]);
|
|
34
35
|
|
|
35
36
|
/** Coordination capabilities that get git add/commit whitelisted for metadata sync. */
|
|
36
|
-
const COORDINATION_CAPABILITIES = new Set(["coordinator", "supervisor", "monitor"]);
|
|
37
|
+
const COORDINATION_CAPABILITIES = new Set(["coordinator", "orchestrator", "supervisor", "monitor"]);
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* Bash patterns that modify files and require path boundary validation.
|
package/src/runtimes/pi.test.ts
CHANGED
|
@@ -14,8 +14,8 @@ describe("PiRuntime", () => {
|
|
|
14
14
|
expect(runtime.id).toBe("pi");
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
-
test("instructionPath is .
|
|
18
|
-
expect(runtime.instructionPath).toBe(".
|
|
17
|
+
test("instructionPath is AGENTS.md", () => {
|
|
18
|
+
expect(runtime.instructionPath).toBe("AGENTS.md");
|
|
19
19
|
});
|
|
20
20
|
});
|
|
21
21
|
|
|
@@ -313,6 +313,30 @@ describe("PiRuntime", () => {
|
|
|
313
313
|
const state = runtime.detectReady(pane);
|
|
314
314
|
expect(state).toEqual({ phase: "ready" });
|
|
315
315
|
});
|
|
316
|
+
|
|
317
|
+
test("returns ready for 1.0M context window (Opus/Sonnet large context)", () => {
|
|
318
|
+
const pane = [
|
|
319
|
+
" pi v0.55.1",
|
|
320
|
+
" escape to interrupt",
|
|
321
|
+
"",
|
|
322
|
+
"────────────────────────────────",
|
|
323
|
+
"~/Projects/os-eco/overstory (main)",
|
|
324
|
+
"0.0%/1.0M (auto) (anthropic) claude-opus-4-6 • high",
|
|
325
|
+
].join("\n");
|
|
326
|
+
const state = runtime.detectReady(pane);
|
|
327
|
+
expect(state).toEqual({ phase: "ready" });
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("returns loading when only 1.0M status bar present (no header)", () => {
|
|
331
|
+
const state = runtime.detectReady("0.0%/1.0M (auto) (anthropic) claude-opus-4-6");
|
|
332
|
+
expect(state).toEqual({ phase: "loading" });
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("returns ready for 2.0M context window", () => {
|
|
336
|
+
const pane = " pi v1.0\n\n0.0%/2.0M done";
|
|
337
|
+
const state = runtime.detectReady(pane);
|
|
338
|
+
expect(state).toEqual({ phase: "ready" });
|
|
339
|
+
});
|
|
316
340
|
});
|
|
317
341
|
|
|
318
342
|
describe("buildEnv", () => {
|
|
@@ -356,7 +380,7 @@ describe("PiRuntime", () => {
|
|
|
356
380
|
await rm(tempDir, { recursive: true, force: true });
|
|
357
381
|
});
|
|
358
382
|
|
|
359
|
-
test("writes overlay to .
|
|
383
|
+
test("writes overlay to AGENTS.md when overlay is provided", async () => {
|
|
360
384
|
const worktreePath = join(tempDir, "worktree");
|
|
361
385
|
|
|
362
386
|
await runtime.deployConfig(
|
|
@@ -365,7 +389,7 @@ describe("PiRuntime", () => {
|
|
|
365
389
|
{ agentName: "test-builder", capability: "builder", worktreePath },
|
|
366
390
|
);
|
|
367
391
|
|
|
368
|
-
const overlayPath = join(worktreePath, ".
|
|
392
|
+
const overlayPath = join(worktreePath, "AGENTS.md");
|
|
369
393
|
const content = await Bun.file(overlayPath).text();
|
|
370
394
|
expect(content).toBe("# Pi Agent Overlay\nThis is the overlay content.");
|
|
371
395
|
});
|
|
@@ -446,7 +470,7 @@ describe("PiRuntime", () => {
|
|
|
446
470
|
expect(content).toContain("\t");
|
|
447
471
|
});
|
|
448
472
|
|
|
449
|
-
test("skips
|
|
473
|
+
test("skips AGENTS.md when overlay is undefined", async () => {
|
|
450
474
|
const worktreePath = join(tempDir, "worktree");
|
|
451
475
|
|
|
452
476
|
await runtime.deployConfig(worktreePath, undefined, {
|
|
@@ -455,7 +479,7 @@ describe("PiRuntime", () => {
|
|
|
455
479
|
worktreePath,
|
|
456
480
|
});
|
|
457
481
|
|
|
458
|
-
const overlayPath = join(worktreePath, ".
|
|
482
|
+
const overlayPath = join(worktreePath, "AGENTS.md");
|
|
459
483
|
const overlayExists = await Bun.file(overlayPath).exists();
|
|
460
484
|
expect(overlayExists).toBe(false);
|
|
461
485
|
});
|
|
@@ -485,13 +509,13 @@ describe("PiRuntime", () => {
|
|
|
485
509
|
{ agentName: "test-builder", capability: "builder", worktreePath },
|
|
486
510
|
);
|
|
487
511
|
|
|
488
|
-
const
|
|
512
|
+
const agentsMdExists = await Bun.file(join(worktreePath, "AGENTS.md")).exists();
|
|
489
513
|
const guardExists = await Bun.file(
|
|
490
514
|
join(worktreePath, ".pi", "extensions", "overstory-guard.ts"),
|
|
491
515
|
).exists();
|
|
492
516
|
const settingsExists = await Bun.file(join(worktreePath, ".pi", "settings.json")).exists();
|
|
493
517
|
|
|
494
|
-
expect(
|
|
518
|
+
expect(agentsMdExists).toBe(true);
|
|
495
519
|
expect(guardExists).toBe(true);
|
|
496
520
|
expect(settingsExists).toBe(true);
|
|
497
521
|
});
|
|
@@ -754,7 +778,7 @@ describe("PiRuntime integration: registry resolves 'pi'", () => {
|
|
|
754
778
|
const rt = getRuntime("pi");
|
|
755
779
|
expect(rt).toBeInstanceOf(PiRuntime);
|
|
756
780
|
expect(rt.id).toBe("pi");
|
|
757
|
-
expect(rt.instructionPath).toBe(".
|
|
781
|
+
expect(rt.instructionPath).toBe("AGENTS.md");
|
|
758
782
|
});
|
|
759
783
|
|
|
760
784
|
test("getRuntime rejects truly unknown runtimes", async () => {
|
package/src/runtimes/pi.ts
CHANGED
|
@@ -38,8 +38,8 @@ export class PiRuntime implements AgentRuntime {
|
|
|
38
38
|
/** Stability level. Pi adapter is experimental — not fully validated. */
|
|
39
39
|
readonly stability = "experimental" as const;
|
|
40
40
|
|
|
41
|
-
/** Relative path to the instruction file within a worktree. Pi reads .
|
|
42
|
-
readonly instructionPath = ".
|
|
41
|
+
/** Relative path to the instruction file within a worktree. Pi reads AGENTS.md at startup. */
|
|
42
|
+
readonly instructionPath = "AGENTS.md";
|
|
43
43
|
|
|
44
44
|
private readonly config: PiRuntimeConfig;
|
|
45
45
|
|
|
@@ -115,12 +115,12 @@ export class PiRuntime implements AgentRuntime {
|
|
|
115
115
|
* Deploy per-agent instructions and guards to a worktree.
|
|
116
116
|
*
|
|
117
117
|
* Writes up to three files:
|
|
118
|
-
* 1.
|
|
118
|
+
* 1. `AGENTS.md` — agent's task-specific overlay. Skipped when overlay is undefined.
|
|
119
119
|
* 2. `.pi/extensions/overstory-guard.ts` — Pi guard extension (always deployed).
|
|
120
120
|
* 3. `.pi/settings.json` — Pi settings enabling the extensions directory (always deployed).
|
|
121
121
|
*
|
|
122
122
|
* @param worktreePath - Absolute path to the agent's git worktree
|
|
123
|
-
* @param overlay - Overlay content to write as
|
|
123
|
+
* @param overlay - Overlay content to write as AGENTS.md, or undefined for guard-only deployment
|
|
124
124
|
* @param hooks - Agent identity, capability, worktree path, and optional quality gates
|
|
125
125
|
*/
|
|
126
126
|
async deployConfig(
|
|
@@ -129,9 +129,8 @@ export class PiRuntime implements AgentRuntime {
|
|
|
129
129
|
hooks: HooksDef,
|
|
130
130
|
): Promise<void> {
|
|
131
131
|
if (overlay) {
|
|
132
|
-
|
|
133
|
-
await
|
|
134
|
-
await Bun.write(join(claudeDir, "CLAUDE.md"), overlay.content);
|
|
132
|
+
await mkdir(worktreePath, { recursive: true });
|
|
133
|
+
await Bun.write(join(worktreePath, this.instructionPath), overlay.content);
|
|
135
134
|
}
|
|
136
135
|
|
|
137
136
|
// Always deploy Pi guard extension.
|
|
@@ -169,10 +168,11 @@ export class PiRuntime implements AgentRuntime {
|
|
|
169
168
|
*/
|
|
170
169
|
detectReady(paneContent: string): ReadyState {
|
|
171
170
|
// Pi's TUI shows "pi v<version>" in the header and a status bar with
|
|
172
|
-
// a token usage indicator like "0.0%/200k" when fully rendered.
|
|
173
|
-
//
|
|
171
|
+
// a token usage indicator like "0.0%/200k" or "0.0%/1.0M" when fully rendered.
|
|
172
|
+
// The context window size uses k-scale (e.g. 200k) for smaller models and
|
|
173
|
+
// M-scale (e.g. 1.0M) for Opus/Sonnet with 1M+ context windows.
|
|
174
174
|
const hasHeader = paneContent.includes("pi v");
|
|
175
|
-
const hasStatusBar = /\d+\.\d
|
|
175
|
+
const hasStatusBar = /\d+\.\d+%\/[\d.]+[kKmM]/.test(paneContent);
|
|
176
176
|
if (hasHeader && hasStatusBar) {
|
|
177
177
|
return { phase: "ready" };
|
|
178
178
|
}
|
|
@@ -24,7 +24,7 @@ describe("getRuntime", () => {
|
|
|
24
24
|
|
|
25
25
|
it("throws with a helpful message for an unknown runtime", () => {
|
|
26
26
|
expect(() => getRuntime("unknown-runtime")).toThrow(
|
|
27
|
-
'Unknown runtime: "unknown-runtime". Available: claude, codex,
|
|
27
|
+
'Unknown runtime: "unknown-runtime". Available: aider, amp, claude, codex, copilot, cursor, gemini, goose, opencode, pi, sapling',
|
|
28
28
|
);
|
|
29
29
|
});
|
|
30
30
|
|
package/src/runtimes/registry.ts
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
// This is the ONLY module that imports concrete adapter classes.
|
|
3
3
|
|
|
4
4
|
import type { OverstoryConfig } from "../types.ts";
|
|
5
|
+
import { AiderRuntime } from "./aider.ts";
|
|
6
|
+
import { AmpRuntime } from "./amp.ts";
|
|
5
7
|
import { ClaudeRuntime } from "./claude.ts";
|
|
6
8
|
import { CodexRuntime } from "./codex.ts";
|
|
7
9
|
import { CopilotRuntime } from "./copilot.ts";
|
|
8
10
|
import { CursorRuntime } from "./cursor.ts";
|
|
9
11
|
import { GeminiRuntime } from "./gemini.ts";
|
|
12
|
+
import { GooseRuntime } from "./goose.ts";
|
|
10
13
|
import { OpenCodeRuntime } from "./opencode.ts";
|
|
11
14
|
import { PiRuntime } from "./pi.ts";
|
|
12
15
|
import { SaplingRuntime } from "./sapling.ts";
|
|
@@ -14,14 +17,17 @@ import type { AgentRuntime } from "./types.ts";
|
|
|
14
17
|
|
|
15
18
|
/** Registry of config-independent runtime adapters (name → factory). */
|
|
16
19
|
const runtimes = new Map<string, () => AgentRuntime>([
|
|
20
|
+
["aider", () => new AiderRuntime()],
|
|
21
|
+
["amp", () => new AmpRuntime()],
|
|
17
22
|
["claude", () => new ClaudeRuntime()],
|
|
18
23
|
["codex", () => new CodexRuntime()],
|
|
19
|
-
["pi", () => new PiRuntime()],
|
|
20
24
|
["copilot", () => new CopilotRuntime()],
|
|
21
25
|
["cursor", () => new CursorRuntime()],
|
|
22
26
|
["gemini", () => new GeminiRuntime()],
|
|
23
|
-
["
|
|
27
|
+
["goose", () => new GooseRuntime()],
|
|
24
28
|
["opencode", () => new OpenCodeRuntime()],
|
|
29
|
+
["pi", () => new PiRuntime()],
|
|
30
|
+
["sapling", () => new SaplingRuntime()],
|
|
25
31
|
]);
|
|
26
32
|
|
|
27
33
|
/**
|
|
@@ -35,14 +41,17 @@ const runtimes = new Map<string, () => AgentRuntime>([
|
|
|
35
41
|
*/
|
|
36
42
|
export function getAllRuntimes(): AgentRuntime[] {
|
|
37
43
|
return [
|
|
44
|
+
new AiderRuntime(),
|
|
45
|
+
new AmpRuntime(),
|
|
38
46
|
new ClaudeRuntime(),
|
|
39
47
|
new CodexRuntime(),
|
|
40
|
-
new PiRuntime(),
|
|
41
48
|
new CopilotRuntime(),
|
|
42
49
|
new CursorRuntime(),
|
|
43
50
|
new GeminiRuntime(),
|
|
44
|
-
new
|
|
51
|
+
new GooseRuntime(),
|
|
45
52
|
new OpenCodeRuntime(),
|
|
53
|
+
new PiRuntime(),
|
|
54
|
+
new SaplingRuntime(),
|
|
46
55
|
];
|
|
47
56
|
}
|
|
48
57
|
|
package/src/runtimes/sapling.ts
CHANGED
|
@@ -71,13 +71,14 @@ const NON_IMPLEMENTATION_CAPABILITIES = new Set([
|
|
|
71
71
|
"scout",
|
|
72
72
|
"reviewer",
|
|
73
73
|
"lead",
|
|
74
|
+
"orchestrator",
|
|
74
75
|
"coordinator",
|
|
75
76
|
"supervisor",
|
|
76
77
|
"monitor",
|
|
77
78
|
]);
|
|
78
79
|
|
|
79
80
|
/** Coordination capabilities that get git add/commit whitelisted for metadata sync. */
|
|
80
|
-
const COORDINATION_CAPABILITIES = new Set(["coordinator", "supervisor", "monitor"]);
|
|
81
|
+
const COORDINATION_CAPABILITIES = new Set(["coordinator", "orchestrator", "supervisor", "monitor"]);
|
|
81
82
|
|
|
82
83
|
/**
|
|
83
84
|
* Build the full guards configuration object for .sapling/guards.json.
|
package/src/runtimes/types.ts
CHANGED
|
@@ -152,7 +152,7 @@ export interface AgentRuntime {
|
|
|
152
152
|
/** Stability level of this runtime adapter. */
|
|
153
153
|
readonly stability: "stable" | "beta" | "experimental";
|
|
154
154
|
|
|
155
|
-
/** Relative path to the instruction file within a worktree (e.g. ".
|
|
155
|
+
/** Relative path to the instruction file within a worktree (e.g. "AGENTS.md"). */
|
|
156
156
|
readonly instructionPath: string;
|
|
157
157
|
|
|
158
158
|
/** Build the shell command string to spawn an interactive agent in a tmux pane. */
|
|
@@ -168,7 +168,7 @@ export interface AgentRuntime {
|
|
|
168
168
|
* Deploy per-agent instructions and guards to a worktree.
|
|
169
169
|
* Claude Code writes .claude/CLAUDE.md + settings.local.json hooks.
|
|
170
170
|
* Codex writes AGENTS.md (no hook deployment needed).
|
|
171
|
-
* Pi writes .
|
|
171
|
+
* Pi writes AGENTS.md + a guard extension in .pi/extensions/.
|
|
172
172
|
* When overlay is undefined, only hooks are deployed (no instruction file written).
|
|
173
173
|
*/
|
|
174
174
|
deployConfig(
|
|
@@ -246,4 +246,11 @@ export interface AgentRuntime {
|
|
|
246
246
|
* The caller provides the raw stdout ReadableStream from Bun.spawn().
|
|
247
247
|
*/
|
|
248
248
|
parseEvents?(stream: ReadableStream<Uint8Array>): AsyncIterable<AgentEvent>;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Prepare a worktree path before spawning an agent.
|
|
252
|
+
* Called by sling.ts after worktree creation but before agent spawn.
|
|
253
|
+
* Used by runtimes that need environment setup (e.g., Copilot folder trust).
|
|
254
|
+
*/
|
|
255
|
+
prepareWorktree?(worktreePath: string): Promise<void>;
|
|
249
256
|
}
|
|
@@ -68,6 +68,16 @@ describe("resolveBackend", () => {
|
|
|
68
68
|
await rm(tempDir, { recursive: true });
|
|
69
69
|
}
|
|
70
70
|
});
|
|
71
|
+
test("returns beads for auto when both .seeds/ and .beads/ exist", async () => {
|
|
72
|
+
const tempDir = await mkdtemp(join(tmpdir(), "tracker-test-"));
|
|
73
|
+
try {
|
|
74
|
+
await mkdir(join(tempDir, ".beads"));
|
|
75
|
+
await mkdir(join(tempDir, ".seeds"));
|
|
76
|
+
expect(await resolveBackend("auto", tempDir)).toBe("beads");
|
|
77
|
+
} finally {
|
|
78
|
+
await rm(tempDir, { recursive: true });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
71
81
|
});
|
|
72
82
|
|
|
73
83
|
describe("trackerCliName", () => {
|
package/src/tracker/factory.ts
CHANGED
|
@@ -38,7 +38,8 @@ export async function resolveBackend(
|
|
|
38
38
|
): Promise<TrackerBackend> {
|
|
39
39
|
if (configBackend === "beads") return "beads";
|
|
40
40
|
if (configBackend === "seeds") return "seeds";
|
|
41
|
-
// "auto" detection: check for .
|
|
41
|
+
// "auto" detection: check for .beads/ first (never auto-scaffolded by ov init,
|
|
42
|
+
// so its presence signals explicit user setup), then .seeds/.
|
|
42
43
|
const dirExists = async (path: string): Promise<boolean> => {
|
|
43
44
|
try {
|
|
44
45
|
const s = await stat(path);
|
|
@@ -47,8 +48,8 @@ export async function resolveBackend(
|
|
|
47
48
|
return false;
|
|
48
49
|
}
|
|
49
50
|
};
|
|
50
|
-
if (await dirExists(join(cwd, ".seeds"))) return "seeds";
|
|
51
51
|
if (await dirExists(join(cwd, ".beads"))) return "beads";
|
|
52
|
+
if (await dirExists(join(cwd, ".seeds"))) return "seeds";
|
|
52
53
|
// Default fallback — seeds is the preferred tracker
|
|
53
54
|
return "seeds";
|
|
54
55
|
}
|
package/src/types.ts
CHANGED
|
@@ -105,6 +105,9 @@ export interface OverstoryConfig {
|
|
|
105
105
|
staleThresholdMs: number; // When to consider agent stale
|
|
106
106
|
zombieThresholdMs: number; // When to kill
|
|
107
107
|
nudgeIntervalMs: number; // Time between progressive nudge stages (default 60_000)
|
|
108
|
+
rpcTimeoutMs?: number; // Timeout for RPC getState() calls (default 5_000)
|
|
109
|
+
triageTimeoutMs?: number; // Timeout for Tier 1 AI triage calls (default 30_000)
|
|
110
|
+
maxEscalationLevel?: number; // Maximum escalation level before termination (default 3)
|
|
108
111
|
};
|
|
109
112
|
models: Partial<Record<string, ModelRef>>;
|
|
110
113
|
logging: {
|
|
@@ -165,6 +168,7 @@ export const SUPPORTED_CAPABILITIES = [
|
|
|
165
168
|
"reviewer",
|
|
166
169
|
"lead",
|
|
167
170
|
"merger",
|
|
171
|
+
"orchestrator",
|
|
168
172
|
"coordinator",
|
|
169
173
|
"supervisor",
|
|
170
174
|
"monitor",
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolveOverstoryBin } from "./bin.ts";
|
|
3
|
+
|
|
4
|
+
describe("resolveOverstoryBin", () => {
|
|
5
|
+
test("returns a non-empty string", async () => {
|
|
6
|
+
const bin = await resolveOverstoryBin();
|
|
7
|
+
expect(typeof bin).toBe("string");
|
|
8
|
+
expect(bin.length).toBeGreaterThan(0);
|
|
9
|
+
});
|
|
10
|
+
});
|
package/src/utils/bin.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binary resolution utilities.
|
|
3
|
+
*/
|
|
4
|
+
import { OverstoryError } from "../errors.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the path to the overstory binary for re-launching.
|
|
8
|
+
* Uses `which ov` first, then falls back to process.argv.
|
|
9
|
+
*/
|
|
10
|
+
export async function resolveOverstoryBin(): Promise<string> {
|
|
11
|
+
try {
|
|
12
|
+
const proc = Bun.spawn(["which", "ov"], {
|
|
13
|
+
stdout: "pipe",
|
|
14
|
+
stderr: "pipe",
|
|
15
|
+
});
|
|
16
|
+
const exitCode = await proc.exited;
|
|
17
|
+
if (exitCode === 0) {
|
|
18
|
+
const binPath = (await new Response(proc.stdout).text()).trim();
|
|
19
|
+
if (binPath.length > 0) {
|
|
20
|
+
return binPath;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// which not available or overstory not on PATH
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Fallback: use the script that's currently running (process.argv[1])
|
|
28
|
+
const scriptPath = process.argv[1];
|
|
29
|
+
if (scriptPath) {
|
|
30
|
+
return scriptPath;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
throw new OverstoryError(
|
|
34
|
+
"Cannot resolve overstory binary path for background launch",
|
|
35
|
+
"WATCH_ERROR",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, mkdtemp, readdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { cleanupTempDir } from "../test-helpers.ts";
|
|
7
|
+
import { clearDirectory, deleteFile, resetJsonFile, wipeSqliteDb } from "./fs.ts";
|
|
8
|
+
|
|
9
|
+
let tempDir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
tempDir = await mkdtemp(join(tmpdir(), "ov-fs-test-"));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await cleanupTempDir(tempDir);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("wipeSqliteDb", () => {
|
|
20
|
+
test("deletes main db and WAL/SHM companion files", async () => {
|
|
21
|
+
const dbPath = join(tempDir, "test-wipe.db");
|
|
22
|
+
const { Database } = await import("bun:sqlite");
|
|
23
|
+
const db = new Database(dbPath);
|
|
24
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
25
|
+
db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)");
|
|
26
|
+
db.exec("INSERT INTO t VALUES (1)");
|
|
27
|
+
db.close();
|
|
28
|
+
|
|
29
|
+
expect(existsSync(dbPath)).toBe(true);
|
|
30
|
+
|
|
31
|
+
const result = await wipeSqliteDb(dbPath);
|
|
32
|
+
expect(result).toBe(true);
|
|
33
|
+
|
|
34
|
+
expect(existsSync(dbPath)).toBe(false);
|
|
35
|
+
expect(existsSync(`${dbPath}-wal`)).toBe(false);
|
|
36
|
+
expect(existsSync(`${dbPath}-shm`)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns false when db file does not exist", async () => {
|
|
40
|
+
const dbPath = join(tempDir, "nonexistent.db");
|
|
41
|
+
const result = await wipeSqliteDb(dbPath);
|
|
42
|
+
expect(result).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("resetJsonFile", () => {
|
|
47
|
+
test("resets existing JSON file to empty array", async () => {
|
|
48
|
+
const filePath = join(tempDir, "test-reset.json");
|
|
49
|
+
await Bun.write(filePath, '[{"id":"1"},{"id":"2"}]');
|
|
50
|
+
|
|
51
|
+
const result = await resetJsonFile(filePath);
|
|
52
|
+
expect(result).toBe(true);
|
|
53
|
+
|
|
54
|
+
const content = await Bun.file(filePath).text();
|
|
55
|
+
expect(content).toBe("[]\n");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("returns false for nonexistent file", async () => {
|
|
59
|
+
const filePath = join(tempDir, "nonexistent.json");
|
|
60
|
+
const result = await resetJsonFile(filePath);
|
|
61
|
+
expect(result).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("clearDirectory", () => {
|
|
66
|
+
test("clears files from a directory", async () => {
|
|
67
|
+
const dirPath = join(tempDir, "clear-test");
|
|
68
|
+
await mkdir(dirPath, { recursive: true });
|
|
69
|
+
await writeFile(join(dirPath, "file1.txt"), "hello");
|
|
70
|
+
await writeFile(join(dirPath, "file2.txt"), "world");
|
|
71
|
+
|
|
72
|
+
const result = await clearDirectory(dirPath);
|
|
73
|
+
expect(result).toBe(true);
|
|
74
|
+
|
|
75
|
+
const entries = await readdir(dirPath);
|
|
76
|
+
expect(entries).toHaveLength(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("returns false for empty directory", async () => {
|
|
80
|
+
const dirPath = join(tempDir, "empty-dir");
|
|
81
|
+
await mkdir(dirPath, { recursive: true });
|
|
82
|
+
|
|
83
|
+
const result = await clearDirectory(dirPath);
|
|
84
|
+
expect(result).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("returns false for nonexistent directory", async () => {
|
|
88
|
+
const result = await clearDirectory(join(tempDir, "no-such-dir"));
|
|
89
|
+
expect(result).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("recursively removes subdirectories", async () => {
|
|
93
|
+
const dirPath = join(tempDir, "nested-clear");
|
|
94
|
+
await mkdir(join(dirPath, "sub", "deep"), { recursive: true });
|
|
95
|
+
await writeFile(join(dirPath, "sub", "deep", "file.txt"), "data");
|
|
96
|
+
|
|
97
|
+
const result = await clearDirectory(dirPath);
|
|
98
|
+
expect(result).toBe(true);
|
|
99
|
+
|
|
100
|
+
const entries = await readdir(dirPath);
|
|
101
|
+
expect(entries).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("deleteFile", () => {
|
|
106
|
+
test("deletes an existing file", async () => {
|
|
107
|
+
const filePath = join(tempDir, "to-delete.txt");
|
|
108
|
+
await writeFile(filePath, "delete me");
|
|
109
|
+
|
|
110
|
+
const result = await deleteFile(filePath);
|
|
111
|
+
expect(result).toBe(true);
|
|
112
|
+
expect(existsSync(filePath)).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("returns false for nonexistent file", async () => {
|
|
116
|
+
const result = await deleteFile(join(tempDir, "no-such-file.txt"));
|
|
117
|
+
expect(result).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
});
|