@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.
Files changed (101) hide show
  1. package/README.md +21 -6
  2. package/agents/coordinator.md +34 -10
  3. package/agents/lead.md +11 -1
  4. package/package.json +1 -1
  5. package/src/agents/copilot-hooks-deployer.test.ts +162 -0
  6. package/src/agents/copilot-hooks-deployer.ts +93 -0
  7. package/src/agents/hooks-deployer.test.ts +9 -1
  8. package/src/agents/hooks-deployer.ts +2 -1
  9. package/src/agents/overlay.test.ts +26 -0
  10. package/src/agents/overlay.ts +18 -4
  11. package/src/beads/client.ts +31 -3
  12. package/src/commands/agents.ts +1 -1
  13. package/src/commands/clean.test.ts +3 -0
  14. package/src/commands/clean.ts +1 -58
  15. package/src/commands/completions.test.ts +18 -6
  16. package/src/commands/completions.ts +40 -1
  17. package/src/commands/coordinator.test.ts +77 -4
  18. package/src/commands/coordinator.ts +228 -125
  19. package/src/commands/dashboard.ts +50 -10
  20. package/src/commands/doctor.ts +3 -1
  21. package/src/commands/ecosystem.test.ts +126 -1
  22. package/src/commands/ecosystem.ts +7 -53
  23. package/src/commands/feed.test.ts +117 -2
  24. package/src/commands/feed.ts +46 -30
  25. package/src/commands/group.test.ts +274 -155
  26. package/src/commands/group.ts +11 -5
  27. package/src/commands/init.ts +50 -0
  28. package/src/commands/inspect.ts +8 -4
  29. package/src/commands/log.test.ts +35 -0
  30. package/src/commands/log.ts +10 -6
  31. package/src/commands/logs.test.ts +423 -1
  32. package/src/commands/logs.ts +99 -104
  33. package/src/commands/monitor.ts +8 -2
  34. package/src/commands/orchestrator.ts +42 -0
  35. package/src/commands/prime.test.ts +177 -2
  36. package/src/commands/prime.ts +4 -2
  37. package/src/commands/sling.ts +8 -3
  38. package/src/commands/upgrade.test.ts +2 -0
  39. package/src/commands/upgrade.ts +1 -17
  40. package/src/commands/watch.test.ts +67 -1
  41. package/src/commands/watch.ts +4 -79
  42. package/src/config.test.ts +250 -0
  43. package/src/config.ts +43 -0
  44. package/src/doctor/agents.test.ts +72 -5
  45. package/src/doctor/agents.ts +10 -10
  46. package/src/doctor/consistency.test.ts +35 -0
  47. package/src/doctor/consistency.ts +7 -3
  48. package/src/doctor/dependencies.test.ts +58 -1
  49. package/src/doctor/dependencies.ts +4 -2
  50. package/src/doctor/providers.test.ts +41 -5
  51. package/src/doctor/types.ts +2 -1
  52. package/src/doctor/version.test.ts +106 -2
  53. package/src/doctor/version.ts +4 -2
  54. package/src/doctor/watchdog.test.ts +167 -0
  55. package/src/doctor/watchdog.ts +158 -0
  56. package/src/e2e/init-sling-lifecycle.test.ts +2 -1
  57. package/src/errors.test.ts +350 -0
  58. package/src/events/tailer.test.ts +25 -0
  59. package/src/events/tailer.ts +8 -1
  60. package/src/index.ts +4 -1
  61. package/src/mail/store.test.ts +110 -0
  62. package/src/runtimes/aider.test.ts +124 -0
  63. package/src/runtimes/aider.ts +147 -0
  64. package/src/runtimes/amp.test.ts +164 -0
  65. package/src/runtimes/amp.ts +154 -0
  66. package/src/runtimes/claude.test.ts +4 -2
  67. package/src/runtimes/codex.test.ts +38 -1
  68. package/src/runtimes/codex.ts +22 -3
  69. package/src/runtimes/copilot.test.ts +213 -13
  70. package/src/runtimes/copilot.ts +93 -11
  71. package/src/runtimes/goose.test.ts +133 -0
  72. package/src/runtimes/goose.ts +157 -0
  73. package/src/runtimes/pi-guards.ts +2 -1
  74. package/src/runtimes/pi.test.ts +33 -9
  75. package/src/runtimes/pi.ts +10 -10
  76. package/src/runtimes/registry.test.ts +1 -1
  77. package/src/runtimes/registry.ts +13 -4
  78. package/src/runtimes/sapling.ts +2 -1
  79. package/src/runtimes/types.ts +9 -2
  80. package/src/tracker/factory.test.ts +10 -0
  81. package/src/tracker/factory.ts +3 -2
  82. package/src/types.ts +4 -0
  83. package/src/utils/bin.test.ts +10 -0
  84. package/src/utils/bin.ts +37 -0
  85. package/src/utils/fs.test.ts +119 -0
  86. package/src/utils/fs.ts +62 -0
  87. package/src/utils/pid.test.ts +68 -0
  88. package/src/utils/pid.ts +45 -0
  89. package/src/utils/time.test.ts +43 -0
  90. package/src/utils/time.ts +37 -0
  91. package/src/utils/version.test.ts +33 -0
  92. package/src/utils/version.ts +70 -0
  93. package/src/watchdog/daemon.test.ts +255 -1
  94. package/src/watchdog/daemon.ts +46 -9
  95. package/src/watchdog/health.test.ts +15 -1
  96. package/src/watchdog/health.ts +1 -1
  97. package/src/watchdog/triage.test.ts +49 -9
  98. package/src/watchdog/triage.ts +21 -5
  99. package/src/worktree/tmux.test.ts +166 -49
  100. package/src/worktree/tmux.ts +36 -37
  101. 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.
@@ -14,8 +14,8 @@ describe("PiRuntime", () => {
14
14
  expect(runtime.id).toBe("pi");
15
15
  });
16
16
 
17
- test("instructionPath is .claude/CLAUDE.md", () => {
18
- expect(runtime.instructionPath).toBe(".claude/CLAUDE.md");
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 .claude/CLAUDE.md when overlay is provided", async () => {
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, ".claude", "CLAUDE.md");
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 CLAUDE.md when overlay is undefined", async () => {
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, ".claude", "CLAUDE.md");
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 claudeMdExists = await Bun.file(join(worktreePath, ".claude", "CLAUDE.md")).exists();
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(claudeMdExists).toBe(true);
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(".claude/CLAUDE.md");
781
+ expect(rt.instructionPath).toBe("AGENTS.md");
758
782
  });
759
783
 
760
784
  test("getRuntime rejects truly unknown runtimes", async () => {
@@ -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 .claude/CLAUDE.md natively. */
42
- readonly instructionPath = ".claude/CLAUDE.md";
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. `.claude/CLAUDE.md` — agent's task-specific overlay. Skipped when overlay is undefined.
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 CLAUDE.md, or undefined for guard-only deployment
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
- const claudeDir = join(worktreePath, ".claude");
133
- await mkdir(claudeDir, { recursive: true });
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
- // Earlier detection checked for "model:" which Pi's TUI never contains.
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+%\/\d+k/.test(paneContent);
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, pi, copilot, cursor, gemini, sapling, opencode',
27
+ 'Unknown runtime: "unknown-runtime". Available: aider, amp, claude, codex, copilot, cursor, gemini, goose, opencode, pi, sapling',
28
28
  );
29
29
  });
30
30
 
@@ -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
- ["sapling", () => new SaplingRuntime()],
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 SaplingRuntime(),
51
+ new GooseRuntime(),
45
52
  new OpenCodeRuntime(),
53
+ new PiRuntime(),
54
+ new SaplingRuntime(),
46
55
  ];
47
56
  }
48
57
 
@@ -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.
@@ -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. ".claude/CLAUDE.md"). */
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 .claude/CLAUDE.md + a guard extension in .pi/extensions/.
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", () => {
@@ -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 .seeds/ directory first (newer tool), then .beads/
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
+ });
@@ -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
+ });