@os-eco/overstory-cli 0.7.8 → 0.8.0
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 +17 -8
- package/agents/coordinator.md +41 -0
- package/agents/orchestrator.md +239 -0
- package/package.json +1 -1
- package/src/agents/guard-rules.test.ts +372 -0
- package/src/agents/manifest.test.ts +168 -1
- package/src/agents/manifest.ts +23 -2
- package/src/commands/agents.ts +1 -0
- package/src/commands/coordinator.test.ts +334 -0
- package/src/commands/coordinator.ts +366 -0
- package/src/commands/init.test.ts +3 -1
- package/src/commands/init.ts +3 -2
- package/src/commands/prime.test.ts +1 -0
- package/src/commands/update.test.ts +465 -0
- package/src/commands/update.ts +263 -0
- package/src/config.test.ts +65 -1
- package/src/config.ts +23 -0
- package/src/doctor/structure.test.ts +1 -0
- package/src/doctor/structure.ts +1 -0
- package/src/e2e/init-sling-lifecycle.test.ts +3 -2
- package/src/index.ts +21 -2
- package/src/runtimes/gemini.test.ts +537 -0
- package/src/runtimes/gemini.ts +235 -0
- package/src/runtimes/registry.test.ts +15 -1
- package/src/runtimes/registry.ts +2 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Gemini CLI runtime adapter for overstory's AgentRuntime interface.
|
|
2
|
+
// Implements the AgentRuntime contract for Google's `gemini` CLI.
|
|
3
|
+
//
|
|
4
|
+
// Key characteristics:
|
|
5
|
+
// - TUI: `gemini` maintains an interactive Ink-based TUI in tmux
|
|
6
|
+
// - Instruction file: GEMINI.md (read automatically from workspace root)
|
|
7
|
+
// - No hooks: Gemini CLI has no hook/guard mechanism (like Copilot)
|
|
8
|
+
// - Sandbox: `--sandbox` flag + `--approval-mode yolo` for bypass
|
|
9
|
+
// - Headless: `gemini -p "prompt"` for one-shot calls
|
|
10
|
+
// - Transcripts: `--output-format stream-json` produces NDJSON events
|
|
11
|
+
|
|
12
|
+
import { mkdir } from "node:fs/promises";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import type { ResolvedModel } from "../types.ts";
|
|
15
|
+
import type {
|
|
16
|
+
AgentRuntime,
|
|
17
|
+
HooksDef,
|
|
18
|
+
OverlayContent,
|
|
19
|
+
ReadyState,
|
|
20
|
+
SpawnOpts,
|
|
21
|
+
TranscriptSummary,
|
|
22
|
+
} from "./types.ts";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Gemini CLI runtime adapter.
|
|
26
|
+
*
|
|
27
|
+
* Implements AgentRuntime for Google's `gemini` CLI (Gemini coding agent).
|
|
28
|
+
* Gemini maintains an interactive Ink-based TUI, similar to Copilot.
|
|
29
|
+
*
|
|
30
|
+
* Security: Gemini CLI supports `--sandbox` for filesystem isolation
|
|
31
|
+
* (Seatbelt on macOS, container-based on Linux) but has no hook/guard
|
|
32
|
+
* mechanism for per-tool interception. The `_hooks` parameter in
|
|
33
|
+
* `deployConfig` is unused.
|
|
34
|
+
*
|
|
35
|
+
* Instructions are delivered via `GEMINI.md` (Gemini's native context
|
|
36
|
+
* file convention), which the CLI reads automatically from the workspace.
|
|
37
|
+
*/
|
|
38
|
+
export class GeminiRuntime implements AgentRuntime {
|
|
39
|
+
/** Unique identifier for this runtime. */
|
|
40
|
+
readonly id = "gemini";
|
|
41
|
+
|
|
42
|
+
/** Relative path to the instruction file within a worktree. */
|
|
43
|
+
readonly instructionPath = "GEMINI.md";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the shell command string to spawn an interactive Gemini agent.
|
|
47
|
+
*
|
|
48
|
+
* Maps SpawnOpts to `gemini` CLI flags:
|
|
49
|
+
* - `model` → `-m <model>`
|
|
50
|
+
* - `permissionMode === "bypass"` → `--approval-mode yolo`
|
|
51
|
+
* - `permissionMode === "ask"` → no approval flag
|
|
52
|
+
* - `appendSystemPrompt` and `appendSystemPromptFile` are IGNORED —
|
|
53
|
+
* the `gemini` CLI has no `--append-system-prompt` equivalent.
|
|
54
|
+
* Role definitions are deployed via GEMINI.md instead.
|
|
55
|
+
*
|
|
56
|
+
* @param opts - Spawn options (model, permissionMode; appendSystemPrompt ignored)
|
|
57
|
+
* @returns Shell command string suitable for tmux new-session -c
|
|
58
|
+
*/
|
|
59
|
+
buildSpawnCommand(opts: SpawnOpts): string {
|
|
60
|
+
let cmd = `gemini -m ${opts.model}`;
|
|
61
|
+
|
|
62
|
+
if (opts.permissionMode === "bypass") {
|
|
63
|
+
cmd += " --approval-mode yolo";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// appendSystemPrompt and appendSystemPromptFile are intentionally ignored.
|
|
67
|
+
// The gemini CLI has no --append-system-prompt equivalent. Role definitions
|
|
68
|
+
// are deployed via GEMINI.md (the instruction file) by deployConfig().
|
|
69
|
+
|
|
70
|
+
return cmd;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build the argv array for a headless one-shot Gemini invocation.
|
|
75
|
+
*
|
|
76
|
+
* Returns an argv array suitable for `Bun.spawn()`. The `-p` flag
|
|
77
|
+
* triggers headless mode — the CLI processes the prompt (including tool
|
|
78
|
+
* invocations) and exits. `--yolo` auto-approves tool calls; without it,
|
|
79
|
+
* unapproved tool calls fail rather than hang.
|
|
80
|
+
*
|
|
81
|
+
* Used by merge/resolver.ts and watchdog/triage.ts for AI-assisted operations.
|
|
82
|
+
*
|
|
83
|
+
* @param prompt - The prompt to pass via `-p`
|
|
84
|
+
* @param model - Optional model override
|
|
85
|
+
* @returns Argv array for Bun.spawn
|
|
86
|
+
*/
|
|
87
|
+
buildPrintCommand(prompt: string, model?: string): string[] {
|
|
88
|
+
const cmd = ["gemini", "-p", prompt, "--yolo"];
|
|
89
|
+
if (model !== undefined) {
|
|
90
|
+
cmd.push("-m", model);
|
|
91
|
+
}
|
|
92
|
+
return cmd;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Deploy per-agent instructions to a worktree.
|
|
97
|
+
*
|
|
98
|
+
* Writes the overlay to `GEMINI.md` in the worktree root (Gemini's
|
|
99
|
+
* native context file convention). The CLI reads GEMINI.md automatically
|
|
100
|
+
* when starting in a directory that contains one.
|
|
101
|
+
*
|
|
102
|
+
* The `hooks` parameter is unused — Gemini CLI has no hook mechanism
|
|
103
|
+
* for per-tool interception. Security depends on `--sandbox` and
|
|
104
|
+
* `--approval-mode` flags instead.
|
|
105
|
+
*
|
|
106
|
+
* @param worktreePath - Absolute path to the agent's git worktree
|
|
107
|
+
* @param overlay - Overlay content to write as GEMINI.md, or undefined to skip
|
|
108
|
+
* @param _hooks - Unused for Gemini runtime
|
|
109
|
+
*/
|
|
110
|
+
async deployConfig(
|
|
111
|
+
worktreePath: string,
|
|
112
|
+
overlay: OverlayContent | undefined,
|
|
113
|
+
_hooks: HooksDef,
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
if (!overlay) return;
|
|
116
|
+
|
|
117
|
+
const geminiPath = join(worktreePath, this.instructionPath);
|
|
118
|
+
await mkdir(dirname(geminiPath), { recursive: true });
|
|
119
|
+
await Bun.write(geminiPath, overlay.content);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Detect Gemini TUI readiness from a tmux pane content snapshot.
|
|
124
|
+
*
|
|
125
|
+
* Gemini uses an Ink-based React TUI. Detection requires both a
|
|
126
|
+
* prompt indicator AND a Gemini branding indicator:
|
|
127
|
+
*
|
|
128
|
+
* - Prompt: "> " prefix, placeholder "type your message", or U+276F (❯)
|
|
129
|
+
* - Branding: "gemini" visible in the TUI header or status area
|
|
130
|
+
*
|
|
131
|
+
* No trust dialog phase exists for Gemini (unlike Claude Code).
|
|
132
|
+
*
|
|
133
|
+
* @param paneContent - Captured tmux pane content to analyze
|
|
134
|
+
* @returns Current readiness phase (never "dialog" for Gemini)
|
|
135
|
+
*/
|
|
136
|
+
detectReady(paneContent: string): ReadyState {
|
|
137
|
+
const lower = paneContent.toLowerCase();
|
|
138
|
+
|
|
139
|
+
// Prompt indicator: placeholder text, "> " at line start, or ❯ character.
|
|
140
|
+
const hasPrompt =
|
|
141
|
+
lower.includes("type your message") ||
|
|
142
|
+
/^> /m.test(paneContent) ||
|
|
143
|
+
paneContent.includes("\u276f");
|
|
144
|
+
|
|
145
|
+
// Branding indicator: "gemini" visible in TUI header/status.
|
|
146
|
+
const hasGemini = lower.includes("gemini");
|
|
147
|
+
|
|
148
|
+
if (hasPrompt && hasGemini) {
|
|
149
|
+
return { phase: "ready" };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { phase: "loading" };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Parse a Gemini NDJSON transcript file into normalized token usage.
|
|
157
|
+
*
|
|
158
|
+
* Gemini's `--output-format stream-json` produces NDJSON with these events:
|
|
159
|
+
* - `init`: carries `model` and `session_id`
|
|
160
|
+
* - `message`: user/assistant messages (content chunks when delta=true)
|
|
161
|
+
* - `tool_use` / `tool_result`: tool call lifecycle
|
|
162
|
+
* - `result`: final event with `stats.input_tokens` and `stats.output_tokens`
|
|
163
|
+
*
|
|
164
|
+
* Returns null if the file does not exist or cannot be parsed.
|
|
165
|
+
*
|
|
166
|
+
* @param path - Absolute path to the Gemini NDJSON transcript file
|
|
167
|
+
* @returns Aggregated token usage, or null if unavailable
|
|
168
|
+
*/
|
|
169
|
+
async parseTranscript(path: string): Promise<TranscriptSummary | null> {
|
|
170
|
+
const file = Bun.file(path);
|
|
171
|
+
if (!(await file.exists())) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const text = await file.text();
|
|
177
|
+
const lines = text.split("\n").filter((l) => l.trim().length > 0);
|
|
178
|
+
|
|
179
|
+
let inputTokens = 0;
|
|
180
|
+
let outputTokens = 0;
|
|
181
|
+
let model = "";
|
|
182
|
+
|
|
183
|
+
for (const line of lines) {
|
|
184
|
+
let event: Record<string, unknown>;
|
|
185
|
+
try {
|
|
186
|
+
event = JSON.parse(line) as Record<string, unknown>;
|
|
187
|
+
} catch {
|
|
188
|
+
// Skip malformed lines — partial writes during capture.
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Model from init event.
|
|
193
|
+
if (event.type === "init" && typeof event.model === "string") {
|
|
194
|
+
model = event.model;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Token usage from result event's stats field.
|
|
198
|
+
if (event.type === "result" && typeof event.stats === "object" && event.stats !== null) {
|
|
199
|
+
const stats = event.stats as Record<string, unknown>;
|
|
200
|
+
const inp = stats.input_tokens;
|
|
201
|
+
const out = stats.output_tokens;
|
|
202
|
+
if (typeof inp === "number") {
|
|
203
|
+
inputTokens += inp;
|
|
204
|
+
}
|
|
205
|
+
if (typeof out === "number") {
|
|
206
|
+
outputTokens += out;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Fallback: capture model from any event that carries it.
|
|
211
|
+
if (typeof event.model === "string" && event.model && !model) {
|
|
212
|
+
model = event.model;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { inputTokens, outputTokens, model };
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Build runtime-specific environment variables for model/provider routing.
|
|
224
|
+
*
|
|
225
|
+
* Returns the provider environment variables from the resolved model.
|
|
226
|
+
* For Google native: may include GEMINI_API_KEY.
|
|
227
|
+
* For gateway providers: may include gateway-specific auth and routing vars.
|
|
228
|
+
*
|
|
229
|
+
* @param model - Resolved model with optional provider env vars
|
|
230
|
+
* @returns Environment variable map (may be empty)
|
|
231
|
+
*/
|
|
232
|
+
buildEnv(model: ResolvedModel): Record<string, string> {
|
|
233
|
+
return model.env ?? {};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { OverstoryConfig } from "../types.ts";
|
|
|
3
3
|
import { ClaudeRuntime } from "./claude.ts";
|
|
4
4
|
import { CodexRuntime } from "./codex.ts";
|
|
5
5
|
import { CopilotRuntime } from "./copilot.ts";
|
|
6
|
+
import { GeminiRuntime } from "./gemini.ts";
|
|
6
7
|
import { PiRuntime } from "./pi.ts";
|
|
7
8
|
import { getRuntime } from "./registry.ts";
|
|
8
9
|
|
|
@@ -21,7 +22,7 @@ describe("getRuntime", () => {
|
|
|
21
22
|
|
|
22
23
|
it("throws with a helpful message for an unknown runtime", () => {
|
|
23
24
|
expect(() => getRuntime("unknown-runtime")).toThrow(
|
|
24
|
-
'Unknown runtime: "unknown-runtime". Available: claude, codex, pi, copilot',
|
|
25
|
+
'Unknown runtime: "unknown-runtime". Available: claude, codex, pi, copilot, gemini',
|
|
25
26
|
);
|
|
26
27
|
});
|
|
27
28
|
|
|
@@ -103,4 +104,17 @@ describe("getRuntime", () => {
|
|
|
103
104
|
const b = getRuntime("copilot");
|
|
104
105
|
expect(a).not.toBe(b);
|
|
105
106
|
});
|
|
107
|
+
|
|
108
|
+
it("returns GeminiRuntime when name is 'gemini'", () => {
|
|
109
|
+
const runtime = getRuntime("gemini");
|
|
110
|
+
expect(runtime).toBeInstanceOf(GeminiRuntime);
|
|
111
|
+
expect(runtime.id).toBe("gemini");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("uses config.runtime.default 'gemini' when name is omitted", () => {
|
|
115
|
+
const config = { runtime: { default: "gemini" } } as OverstoryConfig;
|
|
116
|
+
const runtime = getRuntime(undefined, config);
|
|
117
|
+
expect(runtime).toBeInstanceOf(GeminiRuntime);
|
|
118
|
+
expect(runtime.id).toBe("gemini");
|
|
119
|
+
});
|
|
106
120
|
});
|
package/src/runtimes/registry.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { OverstoryConfig } from "../types.ts";
|
|
|
5
5
|
import { ClaudeRuntime } from "./claude.ts";
|
|
6
6
|
import { CodexRuntime } from "./codex.ts";
|
|
7
7
|
import { CopilotRuntime } from "./copilot.ts";
|
|
8
|
+
import { GeminiRuntime } from "./gemini.ts";
|
|
8
9
|
import { PiRuntime } from "./pi.ts";
|
|
9
10
|
import type { AgentRuntime } from "./types.ts";
|
|
10
11
|
|
|
@@ -14,6 +15,7 @@ const runtimes = new Map<string, () => AgentRuntime>([
|
|
|
14
15
|
["codex", () => new CodexRuntime()],
|
|
15
16
|
["pi", () => new PiRuntime()],
|
|
16
17
|
["copilot", () => new CopilotRuntime()],
|
|
18
|
+
["gemini", () => new GeminiRuntime()],
|
|
17
19
|
]);
|
|
18
20
|
|
|
19
21
|
/**
|