@os-eco/overstory-cli 0.8.5 → 0.8.7
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 +13 -9
- package/agents/coordinator.md +52 -4
- package/package.json +1 -1
- package/src/agents/hooks-deployer.test.ts +185 -12
- package/src/agents/hooks-deployer.ts +57 -1
- package/src/commands/clean.test.ts +136 -0
- package/src/commands/clean.ts +198 -4
- package/src/commands/coordinator.test.ts +494 -6
- package/src/commands/coordinator.ts +200 -4
- package/src/commands/dashboard.ts +84 -18
- package/src/commands/ecosystem.test.ts +101 -0
- package/src/commands/init.test.ts +211 -0
- package/src/commands/init.ts +93 -15
- package/src/commands/log.test.ts +10 -11
- package/src/commands/log.ts +31 -32
- package/src/commands/prime.ts +30 -5
- package/src/commands/sling.test.ts +33 -0
- package/src/commands/sling.ts +416 -358
- package/src/commands/spec.ts +8 -2
- package/src/commands/stop.test.ts +127 -6
- package/src/commands/stop.ts +95 -43
- package/src/commands/supervisor.ts +2 -0
- package/src/commands/watch.ts +29 -9
- package/src/config.test.ts +72 -0
- package/src/config.ts +26 -1
- package/src/index.ts +4 -1
- package/src/merge/resolver.test.ts +383 -25
- package/src/merge/resolver.ts +291 -98
- package/src/runtimes/claude.test.ts +32 -7
- package/src/runtimes/claude.ts +19 -4
- package/src/runtimes/codex.test.ts +13 -0
- package/src/runtimes/codex.ts +18 -2
- package/src/runtimes/copilot.ts +3 -0
- package/src/runtimes/cursor.test.ts +497 -0
- package/src/runtimes/cursor.ts +205 -0
- package/src/runtimes/gemini.ts +3 -0
- package/src/runtimes/opencode.ts +3 -0
- package/src/runtimes/pi.test.ts +119 -2
- package/src/runtimes/pi.ts +64 -12
- package/src/runtimes/registry.test.ts +21 -1
- package/src/runtimes/registry.ts +3 -0
- package/src/runtimes/sapling.ts +3 -0
- package/src/runtimes/types.ts +5 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +178 -0
- package/src/sessions/store.ts +44 -8
- package/src/types.ts +25 -1
- package/src/watchdog/daemon.test.ts +257 -0
- package/src/watchdog/daemon.ts +66 -23
- package/src/worktree/manager.test.ts +65 -1
- package/src/worktree/manager.ts +36 -0
- package/src/worktree/tmux.test.ts +150 -0
- package/src/worktree/tmux.ts +126 -23
package/src/runtimes/codex.ts
CHANGED
|
@@ -37,6 +37,9 @@ export class CodexRuntime implements AgentRuntime {
|
|
|
37
37
|
/** Unique identifier for this runtime. */
|
|
38
38
|
readonly id = "codex";
|
|
39
39
|
|
|
40
|
+
/** Stability level. Codex adapter is experimental — not fully validated. */
|
|
41
|
+
readonly stability = "experimental" as const;
|
|
42
|
+
|
|
40
43
|
/** Relative path to the instruction file within a worktree. */
|
|
41
44
|
readonly instructionPath = "AGENTS.md";
|
|
42
45
|
|
|
@@ -46,6 +49,16 @@ export class CodexRuntime implements AgentRuntime {
|
|
|
46
49
|
*/
|
|
47
50
|
private static readonly MANIFEST_ALIASES = new Set(["sonnet", "opus", "haiku"]);
|
|
48
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Escape a directory path for use in a single-quoted shell argument.
|
|
54
|
+
*
|
|
55
|
+
* @param path - Absolute directory path
|
|
56
|
+
* @returns POSIX shell-safe path string
|
|
57
|
+
*/
|
|
58
|
+
private static shellEscape(path: string): string {
|
|
59
|
+
return path.replace(/'/g, "'\\''");
|
|
60
|
+
}
|
|
61
|
+
|
|
49
62
|
/**
|
|
50
63
|
* Build the shell command string to spawn a Codex agent in a tmux pane.
|
|
51
64
|
*
|
|
@@ -68,16 +81,19 @@ export class CodexRuntime implements AgentRuntime {
|
|
|
68
81
|
if (!CodexRuntime.MANIFEST_ALIASES.has(opts.model)) {
|
|
69
82
|
cmd += ` --model ${opts.model}`;
|
|
70
83
|
}
|
|
84
|
+
for (const dir of opts.sharedWritableDirs ?? []) {
|
|
85
|
+
cmd += ` --add-dir '${CodexRuntime.shellEscape(dir)}'`;
|
|
86
|
+
}
|
|
71
87
|
|
|
72
88
|
if (opts.appendSystemPromptFile) {
|
|
73
89
|
// Read role definition from file at shell expansion time — avoids tmux
|
|
74
90
|
// IPC message size limits. Append the "read AGENTS.md" instruction.
|
|
75
|
-
const escaped = opts.appendSystemPromptFile
|
|
91
|
+
const escaped = CodexRuntime.shellEscape(opts.appendSystemPromptFile);
|
|
76
92
|
cmd += ` "$(cat '${escaped}')"' Read AGENTS.md for your task assignment and begin immediately.'`;
|
|
77
93
|
} else if (opts.appendSystemPrompt) {
|
|
78
94
|
// Inline role definition + instruction to read AGENTS.md.
|
|
79
95
|
const prompt = `${opts.appendSystemPrompt}\n\nRead AGENTS.md for your task assignment and begin immediately.`;
|
|
80
|
-
const escaped =
|
|
96
|
+
const escaped = CodexRuntime.shellEscape(prompt);
|
|
81
97
|
cmd += ` '${escaped}'`;
|
|
82
98
|
} else {
|
|
83
99
|
cmd += ` 'Read AGENTS.md for your task assignment and begin immediately.'`;
|
package/src/runtimes/copilot.ts
CHANGED
|
@@ -28,6 +28,9 @@ export class CopilotRuntime implements AgentRuntime {
|
|
|
28
28
|
/** Unique identifier for this runtime. */
|
|
29
29
|
readonly id = "copilot";
|
|
30
30
|
|
|
31
|
+
/** Stability level. Copilot adapter is experimental — not fully validated. */
|
|
32
|
+
readonly stability = "experimental" as const;
|
|
33
|
+
|
|
31
34
|
/** Relative path to the instruction file within a worktree. */
|
|
32
35
|
readonly instructionPath = ".github/copilot-instructions.md";
|
|
33
36
|
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { cleanupTempDir } from "../test-helpers.ts";
|
|
6
|
+
import type { ResolvedModel } from "../types.ts";
|
|
7
|
+
import { CursorRuntime } from "./cursor.ts";
|
|
8
|
+
import type { SpawnOpts } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
describe("CursorRuntime", () => {
|
|
11
|
+
const runtime = new CursorRuntime();
|
|
12
|
+
|
|
13
|
+
describe("id and instructionPath", () => {
|
|
14
|
+
test("id is 'cursor'", () => {
|
|
15
|
+
expect(runtime.id).toBe("cursor");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("instructionPath is .cursor/rules/overstory.md", () => {
|
|
19
|
+
expect(runtime.instructionPath).toBe(".cursor/rules/overstory.md");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("buildSpawnCommand", () => {
|
|
24
|
+
test("bypass permission mode includes --yolo", () => {
|
|
25
|
+
const opts: SpawnOpts = {
|
|
26
|
+
model: "sonnet",
|
|
27
|
+
permissionMode: "bypass",
|
|
28
|
+
cwd: "/tmp/worktree",
|
|
29
|
+
env: {},
|
|
30
|
+
};
|
|
31
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
32
|
+
expect(cmd).toBe("agent --model sonnet --yolo");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("ask permission mode omits permission flag", () => {
|
|
36
|
+
const opts: SpawnOpts = {
|
|
37
|
+
model: "opus",
|
|
38
|
+
permissionMode: "ask",
|
|
39
|
+
cwd: "/tmp/worktree",
|
|
40
|
+
env: {},
|
|
41
|
+
};
|
|
42
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
43
|
+
expect(cmd).toBe("agent --model opus");
|
|
44
|
+
expect(cmd).not.toContain("--yolo");
|
|
45
|
+
expect(cmd).not.toContain("--permission-mode");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("appendSystemPrompt is ignored (agent CLI has no such flag)", () => {
|
|
49
|
+
const opts: SpawnOpts = {
|
|
50
|
+
model: "sonnet",
|
|
51
|
+
permissionMode: "bypass",
|
|
52
|
+
cwd: "/tmp/worktree",
|
|
53
|
+
env: {},
|
|
54
|
+
appendSystemPrompt: "You are a builder agent.",
|
|
55
|
+
};
|
|
56
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
57
|
+
expect(cmd).toBe("agent --model sonnet --yolo");
|
|
58
|
+
expect(cmd).not.toContain("append-system-prompt");
|
|
59
|
+
expect(cmd).not.toContain("You are a builder agent");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("appendSystemPromptFile is ignored (agent CLI has no such flag)", () => {
|
|
63
|
+
const opts: SpawnOpts = {
|
|
64
|
+
model: "opus",
|
|
65
|
+
permissionMode: "bypass",
|
|
66
|
+
cwd: "/project",
|
|
67
|
+
env: {},
|
|
68
|
+
appendSystemPromptFile: "/project/.overstory/agent-defs/coordinator.md",
|
|
69
|
+
};
|
|
70
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
71
|
+
expect(cmd).toBe("agent --model opus --yolo");
|
|
72
|
+
expect(cmd).not.toContain("cat");
|
|
73
|
+
expect(cmd).not.toContain("coordinator.md");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("cwd and env are not embedded in command string", () => {
|
|
77
|
+
const opts: SpawnOpts = {
|
|
78
|
+
model: "sonnet",
|
|
79
|
+
permissionMode: "bypass",
|
|
80
|
+
cwd: "/some/specific/path",
|
|
81
|
+
env: { CURSOR_API_KEY: "test-key-123" },
|
|
82
|
+
};
|
|
83
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
84
|
+
expect(cmd).not.toContain("/some/specific/path");
|
|
85
|
+
expect(cmd).not.toContain("test-key-123");
|
|
86
|
+
expect(cmd).not.toContain("CURSOR_API_KEY");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("all model names pass through unchanged", () => {
|
|
90
|
+
for (const model of ["sonnet", "opus", "haiku", "gpt-4o", "openrouter/gpt-5"]) {
|
|
91
|
+
const opts: SpawnOpts = {
|
|
92
|
+
model,
|
|
93
|
+
permissionMode: "bypass",
|
|
94
|
+
cwd: "/tmp",
|
|
95
|
+
env: {},
|
|
96
|
+
};
|
|
97
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
98
|
+
expect(cmd).toContain(`--model ${model}`);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("produces identical output for same inputs (deterministic)", () => {
|
|
103
|
+
const opts: SpawnOpts = {
|
|
104
|
+
model: "sonnet",
|
|
105
|
+
permissionMode: "bypass",
|
|
106
|
+
cwd: "/tmp/worktree",
|
|
107
|
+
env: {},
|
|
108
|
+
};
|
|
109
|
+
const cmd1 = runtime.buildSpawnCommand(opts);
|
|
110
|
+
const cmd2 = runtime.buildSpawnCommand(opts);
|
|
111
|
+
expect(cmd1).toBe(cmd2);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("buildPrintCommand", () => {
|
|
116
|
+
test("basic prompt includes -p and --yolo", () => {
|
|
117
|
+
const argv = runtime.buildPrintCommand("Summarize this diff");
|
|
118
|
+
expect(argv).toEqual(["agent", "-p", "Summarize this diff", "--yolo"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("with model override appends --model flag", () => {
|
|
122
|
+
const argv = runtime.buildPrintCommand("Classify this error", "sonnet");
|
|
123
|
+
expect(argv).toEqual(["agent", "-p", "Classify this error", "--yolo", "--model", "sonnet"]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("without model omits --model flag", () => {
|
|
127
|
+
const argv = runtime.buildPrintCommand("Hello");
|
|
128
|
+
expect(argv).not.toContain("--model");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("model undefined omits --model flag", () => {
|
|
132
|
+
const argv = runtime.buildPrintCommand("Hello", undefined);
|
|
133
|
+
expect(argv).not.toContain("--model");
|
|
134
|
+
expect(argv).toContain("--yolo");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("--yolo always present regardless of model", () => {
|
|
138
|
+
const withModel = runtime.buildPrintCommand("prompt", "opus");
|
|
139
|
+
const withoutModel = runtime.buildPrintCommand("prompt");
|
|
140
|
+
expect(withModel).toContain("--yolo");
|
|
141
|
+
expect(withoutModel).toContain("--yolo");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("prompt with special characters is preserved", () => {
|
|
145
|
+
const prompt = 'Fix the "bug" in file\'s path & run tests';
|
|
146
|
+
const argv = runtime.buildPrintCommand(prompt);
|
|
147
|
+
expect(argv[2]).toBe(prompt);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("empty prompt is passed through", () => {
|
|
151
|
+
const argv = runtime.buildPrintCommand("");
|
|
152
|
+
expect(argv).toEqual(["agent", "-p", "", "--yolo"]);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("detectReady", () => {
|
|
157
|
+
test("returns loading for empty pane", () => {
|
|
158
|
+
expect(runtime.detectReady("")).toEqual({ phase: "loading" });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("returns loading for partial content (prompt only, no status bar)", () => {
|
|
162
|
+
const state = runtime.detectReady("Welcome!\n\u276f");
|
|
163
|
+
expect(state).toEqual({ phase: "loading" });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("returns loading for partial content (status bar only, no prompt)", () => {
|
|
167
|
+
const state = runtime.detectReady("shift+tab to toggle");
|
|
168
|
+
expect(state).toEqual({ phase: "loading" });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("returns ready for ❯ + shift+tab", () => {
|
|
172
|
+
const state = runtime.detectReady("Cursor Agent\n\u276f\nshift+tab to chat");
|
|
173
|
+
expect(state).toEqual({ phase: "ready" });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("returns ready for ❯ + esc", () => {
|
|
177
|
+
const state = runtime.detectReady("Cursor Agent\n\u276f\nesc to cancel");
|
|
178
|
+
expect(state).toEqual({ phase: "ready" });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("returns ready for ❯ + agent keyword", () => {
|
|
182
|
+
const state = runtime.detectReady("Agent Ready\n\u276f\ntype here");
|
|
183
|
+
expect(state).toEqual({ phase: "ready" });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("returns ready for > prefix + shift+tab", () => {
|
|
187
|
+
const pane = "Cursor\n> \nshift+tab";
|
|
188
|
+
expect(runtime.detectReady(pane)).toEqual({ phase: "ready" });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("returns ready for > prefix + esc", () => {
|
|
192
|
+
const pane = "Some content\n> type here\npress esc to exit";
|
|
193
|
+
expect(runtime.detectReady(pane)).toEqual({ phase: "ready" });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("returns ready for > prefix + agent keyword", () => {
|
|
197
|
+
const pane = "Agent v1.0\n> ";
|
|
198
|
+
expect(runtime.detectReady(pane)).toEqual({ phase: "ready" });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("case-insensitive match for status bar keywords", () => {
|
|
202
|
+
expect(runtime.detectReady("\u276f\nSHIFT+TAB")).toEqual({ phase: "ready" });
|
|
203
|
+
expect(runtime.detectReady("\u276f\nESC to exit")).toEqual({ phase: "ready" });
|
|
204
|
+
expect(runtime.detectReady("\u276f\nAGENT mode")).toEqual({ phase: "ready" });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("returns loading for random pane content", () => {
|
|
208
|
+
expect(runtime.detectReady("Loading...\nPlease wait")).toEqual({ phase: "loading" });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("never returns dialog phase", () => {
|
|
212
|
+
const panes = [
|
|
213
|
+
"",
|
|
214
|
+
"Cursor Agent",
|
|
215
|
+
"> ready",
|
|
216
|
+
"\u276f\nshift+tab",
|
|
217
|
+
"Loading...",
|
|
218
|
+
"trust this folder",
|
|
219
|
+
];
|
|
220
|
+
for (const pane of panes) {
|
|
221
|
+
const result = runtime.detectReady(pane);
|
|
222
|
+
expect(result.phase).not.toBe("dialog");
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("Shift+Tab (capital) is matched case-insensitively", () => {
|
|
227
|
+
const state = runtime.detectReady("\u276f\nShift+Tab to toggle");
|
|
228
|
+
expect(state).toEqual({ phase: "ready" });
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("deployConfig", () => {
|
|
233
|
+
let tempDir: string;
|
|
234
|
+
|
|
235
|
+
beforeEach(async () => {
|
|
236
|
+
tempDir = await mkdtemp(join(tmpdir(), "ov-cursor-test-"));
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
afterEach(async () => {
|
|
240
|
+
await cleanupTempDir(tempDir);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("writes overlay to .cursor/rules/overstory.md when provided", async () => {
|
|
244
|
+
const worktreePath = join(tempDir, "worktree");
|
|
245
|
+
|
|
246
|
+
await runtime.deployConfig(
|
|
247
|
+
worktreePath,
|
|
248
|
+
{ content: "# Cursor Instructions\nYou are a builder." },
|
|
249
|
+
{ agentName: "test-builder", capability: "builder", worktreePath },
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const overlayPath = join(worktreePath, ".cursor", "rules", "overstory.md");
|
|
253
|
+
const content = await Bun.file(overlayPath).text();
|
|
254
|
+
expect(content).toBe("# Cursor Instructions\nYou are a builder.");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("creates .cursor/rules/ directory if it does not exist", async () => {
|
|
258
|
+
const worktreePath = join(tempDir, "new-worktree");
|
|
259
|
+
|
|
260
|
+
await runtime.deployConfig(
|
|
261
|
+
worktreePath,
|
|
262
|
+
{ content: "# Instructions" },
|
|
263
|
+
{ agentName: "test", capability: "builder", worktreePath },
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const fileExists = await Bun.file(
|
|
267
|
+
join(worktreePath, ".cursor", "rules", "overstory.md"),
|
|
268
|
+
).exists();
|
|
269
|
+
expect(fileExists).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("skips overlay write when overlay is undefined", async () => {
|
|
273
|
+
const worktreePath = join(tempDir, "worktree");
|
|
274
|
+
|
|
275
|
+
await runtime.deployConfig(worktreePath, undefined, {
|
|
276
|
+
agentName: "coordinator",
|
|
277
|
+
capability: "coordinator",
|
|
278
|
+
worktreePath,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const overlayExists = await Bun.file(
|
|
282
|
+
join(worktreePath, ".cursor", "rules", "overstory.md"),
|
|
283
|
+
).exists();
|
|
284
|
+
expect(overlayExists).toBe(false);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("does not write guard files (no hook deployment)", async () => {
|
|
288
|
+
const worktreePath = join(tempDir, "worktree");
|
|
289
|
+
|
|
290
|
+
await runtime.deployConfig(
|
|
291
|
+
worktreePath,
|
|
292
|
+
{ content: "# Instructions" },
|
|
293
|
+
{
|
|
294
|
+
agentName: "test-builder",
|
|
295
|
+
capability: "builder",
|
|
296
|
+
worktreePath,
|
|
297
|
+
qualityGates: [
|
|
298
|
+
{ command: "bun test", name: "tests", description: "all tests must pass" },
|
|
299
|
+
],
|
|
300
|
+
},
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const overlayFile = Bun.file(join(worktreePath, ".cursor", "rules", "overstory.md"));
|
|
304
|
+
expect(await overlayFile.exists()).toBe(true);
|
|
305
|
+
|
|
306
|
+
const settingsFile = Bun.file(join(worktreePath, ".claude", "settings.local.json"));
|
|
307
|
+
expect(await settingsFile.exists()).toBe(false);
|
|
308
|
+
|
|
309
|
+
const piGuardFile = Bun.file(join(worktreePath, ".pi", "extensions", "overstory-guard.ts"));
|
|
310
|
+
expect(await piGuardFile.exists()).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("overwrites existing overlay file", async () => {
|
|
314
|
+
const overlayPath = join(tempDir, ".cursor", "rules", "overstory.md");
|
|
315
|
+
const { mkdir: mkdirFS } = await import("node:fs/promises");
|
|
316
|
+
await mkdirFS(join(tempDir, ".cursor", "rules"), { recursive: true });
|
|
317
|
+
await Bun.write(overlayPath, "# Old content");
|
|
318
|
+
|
|
319
|
+
await runtime.deployConfig(
|
|
320
|
+
tempDir,
|
|
321
|
+
{ content: "# New content" },
|
|
322
|
+
{ agentName: "test-agent", capability: "builder", worktreePath: tempDir },
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const content = await Bun.file(overlayPath).text();
|
|
326
|
+
expect(content).toBe("# New content");
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe("parseTranscript", () => {
|
|
331
|
+
let tempDir: string;
|
|
332
|
+
|
|
333
|
+
beforeEach(async () => {
|
|
334
|
+
tempDir = await mkdtemp(join(tmpdir(), "ov-cursor-transcript-"));
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
afterEach(async () => {
|
|
338
|
+
await cleanupTempDir(tempDir);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("returns null for non-existent file", async () => {
|
|
342
|
+
const result = await runtime.parseTranscript(join(tempDir, "does-not-exist.jsonl"));
|
|
343
|
+
expect(result).toBeNull();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("extracts model from system/init event", async () => {
|
|
347
|
+
const transcript = [
|
|
348
|
+
JSON.stringify({
|
|
349
|
+
type: "system",
|
|
350
|
+
subtype: "init",
|
|
351
|
+
model: "claude-sonnet-4-6",
|
|
352
|
+
}),
|
|
353
|
+
].join("\n");
|
|
354
|
+
|
|
355
|
+
const path = join(tempDir, "transcript.jsonl");
|
|
356
|
+
await Bun.write(path, transcript);
|
|
357
|
+
|
|
358
|
+
const result = await runtime.parseTranscript(path);
|
|
359
|
+
expect(result).not.toBeNull();
|
|
360
|
+
expect(result?.model).toBe("claude-sonnet-4-6");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("always returns zero tokens (not available in Cursor format)", async () => {
|
|
364
|
+
const transcript = [
|
|
365
|
+
JSON.stringify({ type: "system", subtype: "init", model: "sonnet" }),
|
|
366
|
+
JSON.stringify({ type: "assistant", content: "Hello world" }),
|
|
367
|
+
].join("\n");
|
|
368
|
+
|
|
369
|
+
const path = join(tempDir, "transcript.jsonl");
|
|
370
|
+
await Bun.write(path, transcript);
|
|
371
|
+
|
|
372
|
+
const result = await runtime.parseTranscript(path);
|
|
373
|
+
expect(result?.inputTokens).toBe(0);
|
|
374
|
+
expect(result?.outputTokens).toBe(0);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("skips malformed JSON lines and continues parsing", async () => {
|
|
378
|
+
const goodEntry = JSON.stringify({
|
|
379
|
+
type: "system",
|
|
380
|
+
subtype: "init",
|
|
381
|
+
model: "opus",
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const path = join(tempDir, "transcript.jsonl");
|
|
385
|
+
await Bun.write(path, `not json at all\n${goodEntry}\n{broken`);
|
|
386
|
+
|
|
387
|
+
const result = await runtime.parseTranscript(path);
|
|
388
|
+
expect(result).not.toBeNull();
|
|
389
|
+
expect(result?.model).toBe("opus");
|
|
390
|
+
expect(result?.inputTokens).toBe(0);
|
|
391
|
+
expect(result?.outputTokens).toBe(0);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("returns empty model for empty file", async () => {
|
|
395
|
+
const path = join(tempDir, "empty.jsonl");
|
|
396
|
+
await Bun.write(path, "");
|
|
397
|
+
|
|
398
|
+
const result = await runtime.parseTranscript(path);
|
|
399
|
+
expect(result).not.toBeNull();
|
|
400
|
+
expect(result?.inputTokens).toBe(0);
|
|
401
|
+
expect(result?.outputTokens).toBe(0);
|
|
402
|
+
expect(result?.model).toBe("");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("handles trailing newlines", async () => {
|
|
406
|
+
const transcript = `${JSON.stringify({ type: "system", subtype: "init", model: "sonnet" })}\n\n\n`;
|
|
407
|
+
|
|
408
|
+
const path = join(tempDir, "transcript.jsonl");
|
|
409
|
+
await Bun.write(path, transcript);
|
|
410
|
+
|
|
411
|
+
const result = await runtime.parseTranscript(path);
|
|
412
|
+
expect(result?.model).toBe("sonnet");
|
|
413
|
+
expect(result?.inputTokens).toBe(0);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("ignores non-init system events", async () => {
|
|
417
|
+
const transcript = [
|
|
418
|
+
JSON.stringify({ type: "system", subtype: "heartbeat", model: "should-not-match" }),
|
|
419
|
+
JSON.stringify({ type: "system", subtype: "init", model: "correct-model" }),
|
|
420
|
+
].join("\n");
|
|
421
|
+
|
|
422
|
+
const path = join(tempDir, "transcript.jsonl");
|
|
423
|
+
await Bun.write(path, transcript);
|
|
424
|
+
|
|
425
|
+
const result = await runtime.parseTranscript(path);
|
|
426
|
+
expect(result?.model).toBe("correct-model");
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("last init event wins when multiple are present", async () => {
|
|
430
|
+
const transcript = [
|
|
431
|
+
JSON.stringify({ type: "system", subtype: "init", model: "first-model" }),
|
|
432
|
+
JSON.stringify({ type: "system", subtype: "init", model: "second-model" }),
|
|
433
|
+
].join("\n");
|
|
434
|
+
|
|
435
|
+
const path = join(tempDir, "transcript.jsonl");
|
|
436
|
+
await Bun.write(path, transcript);
|
|
437
|
+
|
|
438
|
+
const result = await runtime.parseTranscript(path);
|
|
439
|
+
expect(result?.model).toBe("second-model");
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
describe("buildEnv", () => {
|
|
444
|
+
test("returns empty object when model has no env", () => {
|
|
445
|
+
const model: ResolvedModel = { model: "sonnet" };
|
|
446
|
+
const env = runtime.buildEnv(model);
|
|
447
|
+
expect(env).toEqual({});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("returns model.env when present", () => {
|
|
451
|
+
const model: ResolvedModel = {
|
|
452
|
+
model: "gpt-4o",
|
|
453
|
+
env: { CURSOR_API_KEY: "test-key", CURSOR_HOST: "https://cursor.sh" },
|
|
454
|
+
};
|
|
455
|
+
const env = runtime.buildEnv(model);
|
|
456
|
+
expect(env).toEqual({
|
|
457
|
+
CURSOR_API_KEY: "test-key",
|
|
458
|
+
CURSOR_HOST: "https://cursor.sh",
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("returns empty object when model.env is undefined", () => {
|
|
463
|
+
const model: ResolvedModel = { model: "opus", env: undefined };
|
|
464
|
+
const env = runtime.buildEnv(model);
|
|
465
|
+
expect(env).toEqual({});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("env is safe to spread into session env", () => {
|
|
469
|
+
const model: ResolvedModel = { model: "sonnet" };
|
|
470
|
+
const env = runtime.buildEnv(model);
|
|
471
|
+
const combined = { ...env, OVERSTORY_AGENT_NAME: "builder-1" };
|
|
472
|
+
expect(combined).toEqual({ OVERSTORY_AGENT_NAME: "builder-1" });
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
describe("getTranscriptDir", () => {
|
|
477
|
+
test("returns null (transcript location not yet verified)", () => {
|
|
478
|
+
expect(runtime.getTranscriptDir("/some/project")).toBeNull();
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
describe("requiresBeaconVerification", () => {
|
|
483
|
+
test("not defined — defaults to true (gets resend loop)", () => {
|
|
484
|
+
expect("requiresBeaconVerification" in runtime).toBe(false);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
describe("CursorRuntime integration: registry resolves 'cursor'", () => {
|
|
490
|
+
test("getRuntime('cursor') returns CursorRuntime", async () => {
|
|
491
|
+
const { getRuntime } = await import("./registry.ts");
|
|
492
|
+
const rt = getRuntime("cursor");
|
|
493
|
+
expect(rt).toBeInstanceOf(CursorRuntime);
|
|
494
|
+
expect(rt.id).toBe("cursor");
|
|
495
|
+
expect(rt.instructionPath).toBe(".cursor/rules/overstory.md");
|
|
496
|
+
});
|
|
497
|
+
});
|