@os-eco/overstory-cli 0.7.0 → 0.7.2
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 +6 -5
- package/agents/builder.md +1 -1
- package/agents/coordinator.md +12 -11
- package/agents/lead.md +6 -6
- package/agents/monitor.md +4 -4
- package/agents/reviewer.md +1 -1
- package/agents/scout.md +5 -5
- package/agents/supervisor.md +36 -32
- package/package.json +1 -1
- package/src/agents/guard-rules.ts +97 -0
- package/src/agents/hooks-deployer.ts +7 -90
- package/src/agents/overlay.test.ts +7 -7
- package/src/agents/overlay.ts +5 -5
- package/src/commands/agents.test.ts +5 -0
- package/src/commands/clean.test.ts +3 -0
- package/src/commands/completions.ts +1 -1
- package/src/commands/coordinator.test.ts +1 -0
- package/src/commands/coordinator.ts +15 -11
- package/src/commands/costs.test.ts +5 -0
- package/src/commands/init.test.ts +1 -2
- package/src/commands/init.ts +1 -8
- package/src/commands/inspect.test.ts +14 -0
- package/src/commands/log.test.ts +14 -0
- package/src/commands/log.ts +39 -0
- package/src/commands/mail.test.ts +5 -0
- package/src/commands/monitor.ts +15 -11
- package/src/commands/nudge.test.ts +1 -0
- package/src/commands/prime.test.ts +2 -0
- package/src/commands/prime.ts +6 -2
- package/src/commands/run.test.ts +1 -0
- package/src/commands/sling.test.ts +15 -1
- package/src/commands/sling.ts +44 -21
- package/src/commands/status.test.ts +1 -0
- package/src/commands/stop.test.ts +1 -0
- package/src/commands/supervisor.ts +19 -12
- package/src/commands/trace.test.ts +1 -0
- package/src/commands/worktree.test.ts +9 -0
- package/src/config.ts +29 -0
- package/src/doctor/consistency.test.ts +14 -0
- package/src/e2e/init-sling-lifecycle.test.ts +3 -5
- package/src/index.ts +1 -1
- package/src/mail/broadcast.test.ts +1 -0
- package/src/merge/resolver.ts +23 -4
- package/src/runtimes/claude.test.ts +1 -1
- package/src/runtimes/pi-guards.test.ts +433 -0
- package/src/runtimes/pi-guards.ts +349 -0
- package/src/runtimes/pi.test.ts +620 -0
- package/src/runtimes/pi.ts +244 -0
- package/src/runtimes/registry.test.ts +33 -0
- package/src/runtimes/registry.ts +15 -2
- package/src/runtimes/types.ts +63 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/compat.ts +1 -0
- package/src/sessions/store.test.ts +31 -0
- package/src/sessions/store.ts +37 -4
- package/src/types.ts +17 -0
- package/src/watchdog/daemon.test.ts +7 -4
- package/src/watchdog/daemon.ts +1 -1
- package/src/watchdog/health.test.ts +1 -0
- package/src/watchdog/triage.ts +14 -4
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { ResolvedModel } from "../types.ts";
|
|
6
|
+
import { PiRuntime } from "./pi.ts";
|
|
7
|
+
import type { SpawnOpts } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
describe("PiRuntime", () => {
|
|
10
|
+
const runtime = new PiRuntime();
|
|
11
|
+
|
|
12
|
+
describe("id and instructionPath", () => {
|
|
13
|
+
test("id is 'pi'", () => {
|
|
14
|
+
expect(runtime.id).toBe("pi");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("instructionPath is .claude/CLAUDE.md", () => {
|
|
18
|
+
expect(runtime.instructionPath).toBe(".claude/CLAUDE.md");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("expandModel", () => {
|
|
23
|
+
test("expands known alias to provider-qualified ID", () => {
|
|
24
|
+
expect(runtime.expandModel("sonnet")).toBe("anthropic/claude-sonnet-4-6");
|
|
25
|
+
expect(runtime.expandModel("opus")).toBe("anthropic/claude-opus-4-6");
|
|
26
|
+
expect(runtime.expandModel("haiku")).toBe("anthropic/claude-haiku-4-5");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("passes through already-qualified model (contains /)", () => {
|
|
30
|
+
expect(runtime.expandModel("anthropic/claude-opus-4-6")).toBe("anthropic/claude-opus-4-6");
|
|
31
|
+
expect(runtime.expandModel("openrouter/gpt-5")).toBe("openrouter/gpt-5");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("unknown alias gets provider prefix", () => {
|
|
35
|
+
expect(runtime.expandModel("gpt-4o")).toBe("anthropic/gpt-4o");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("custom config with different provider", () => {
|
|
39
|
+
const custom = new PiRuntime({
|
|
40
|
+
provider: "amazon-bedrock",
|
|
41
|
+
modelMap: {
|
|
42
|
+
opus: "amazon-bedrock/us.anthropic.claude-opus-4-6-v1",
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
expect(custom.expandModel("opus")).toBe("amazon-bedrock/us.anthropic.claude-opus-4-6-v1");
|
|
46
|
+
// Unknown alias gets the custom provider prefix
|
|
47
|
+
expect(custom.expandModel("sonnet")).toBe("amazon-bedrock/sonnet");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("custom modelMap overrides defaults", () => {
|
|
51
|
+
const custom = new PiRuntime({
|
|
52
|
+
provider: "anthropic",
|
|
53
|
+
modelMap: {
|
|
54
|
+
sonnet: "anthropic/claude-sonnet-4-5-20250514",
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
expect(custom.expandModel("sonnet")).toBe("anthropic/claude-sonnet-4-5-20250514");
|
|
58
|
+
// Aliases not in the custom map fall back to provider prefix
|
|
59
|
+
expect(custom.expandModel("opus")).toBe("anthropic/opus");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("buildSpawnCommand", () => {
|
|
64
|
+
test("expands model alias to provider-qualified ID", () => {
|
|
65
|
+
const opts: SpawnOpts = {
|
|
66
|
+
model: "sonnet",
|
|
67
|
+
permissionMode: "bypass",
|
|
68
|
+
cwd: "/tmp/worktree",
|
|
69
|
+
env: {},
|
|
70
|
+
};
|
|
71
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
72
|
+
expect(cmd).toBe("pi --model anthropic/claude-sonnet-4-6");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("permissionMode is NOT included in command (Pi has no permission-mode flag)", () => {
|
|
76
|
+
const opts: SpawnOpts = {
|
|
77
|
+
model: "opus",
|
|
78
|
+
permissionMode: "bypass",
|
|
79
|
+
cwd: "/tmp/worktree",
|
|
80
|
+
env: {},
|
|
81
|
+
};
|
|
82
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
83
|
+
expect(cmd).not.toContain("--permission-mode");
|
|
84
|
+
expect(cmd).not.toContain("bypassPermissions");
|
|
85
|
+
expect(cmd).not.toContain("default");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("ask permissionMode also excluded", () => {
|
|
89
|
+
const opts: SpawnOpts = {
|
|
90
|
+
model: "haiku",
|
|
91
|
+
permissionMode: "ask",
|
|
92
|
+
cwd: "/tmp/worktree",
|
|
93
|
+
env: {},
|
|
94
|
+
};
|
|
95
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
96
|
+
expect(cmd).not.toContain("--permission-mode");
|
|
97
|
+
expect(cmd).toBe("pi --model anthropic/claude-haiku-4-5");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("with appendSystemPrompt (no quotes in prompt)", () => {
|
|
101
|
+
const opts: SpawnOpts = {
|
|
102
|
+
model: "sonnet",
|
|
103
|
+
permissionMode: "bypass",
|
|
104
|
+
cwd: "/tmp/worktree",
|
|
105
|
+
env: {},
|
|
106
|
+
appendSystemPrompt: "You are a builder agent.",
|
|
107
|
+
};
|
|
108
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
109
|
+
expect(cmd).toBe(
|
|
110
|
+
"pi --model anthropic/claude-sonnet-4-6 --append-system-prompt 'You are a builder agent.'",
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("with appendSystemPrompt containing single quotes (POSIX escape)", () => {
|
|
115
|
+
const opts: SpawnOpts = {
|
|
116
|
+
model: "sonnet",
|
|
117
|
+
permissionMode: "bypass",
|
|
118
|
+
cwd: "/tmp/worktree",
|
|
119
|
+
env: {},
|
|
120
|
+
appendSystemPrompt: "Don't touch the user's files",
|
|
121
|
+
};
|
|
122
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
123
|
+
expect(cmd).toContain("--append-system-prompt");
|
|
124
|
+
expect(cmd).toBe(
|
|
125
|
+
"pi --model anthropic/claude-sonnet-4-6 --append-system-prompt 'Don'\\''t touch the user'\\''s files'",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("without appendSystemPrompt omits the flag", () => {
|
|
130
|
+
const opts: SpawnOpts = {
|
|
131
|
+
model: "haiku",
|
|
132
|
+
permissionMode: "bypass",
|
|
133
|
+
cwd: "/tmp/worktree",
|
|
134
|
+
env: {},
|
|
135
|
+
};
|
|
136
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
137
|
+
expect(cmd).not.toContain("--append-system-prompt");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("cwd and env are not embedded in command string", () => {
|
|
141
|
+
const opts: SpawnOpts = {
|
|
142
|
+
model: "sonnet",
|
|
143
|
+
permissionMode: "bypass",
|
|
144
|
+
cwd: "/some/specific/path",
|
|
145
|
+
env: { API_KEY: "sk-test-123" },
|
|
146
|
+
};
|
|
147
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
148
|
+
expect(cmd).not.toContain("/some/specific/path");
|
|
149
|
+
expect(cmd).not.toContain("sk-test-123");
|
|
150
|
+
expect(cmd).not.toContain("API_KEY");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("already-qualified models pass through unchanged", () => {
|
|
154
|
+
for (const model of ["openrouter/gpt-5", "amazon-bedrock/us.anthropic.claude-opus-4-6-v1"]) {
|
|
155
|
+
const opts: SpawnOpts = {
|
|
156
|
+
model,
|
|
157
|
+
permissionMode: "bypass",
|
|
158
|
+
cwd: "/tmp",
|
|
159
|
+
env: {},
|
|
160
|
+
};
|
|
161
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
162
|
+
expect(cmd).toContain(`--model ${model}`);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("aliases are expanded in spawn command", () => {
|
|
167
|
+
for (const [alias, expected] of [
|
|
168
|
+
["sonnet", "anthropic/claude-sonnet-4-6"],
|
|
169
|
+
["opus", "anthropic/claude-opus-4-6"],
|
|
170
|
+
["haiku", "anthropic/claude-haiku-4-5"],
|
|
171
|
+
] as const) {
|
|
172
|
+
const opts: SpawnOpts = {
|
|
173
|
+
model: alias,
|
|
174
|
+
permissionMode: "bypass",
|
|
175
|
+
cwd: "/tmp",
|
|
176
|
+
env: {},
|
|
177
|
+
};
|
|
178
|
+
const cmd = runtime.buildSpawnCommand(opts);
|
|
179
|
+
expect(cmd).toContain(`--model ${expected}`);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("buildPrintCommand", () => {
|
|
185
|
+
test("basic command without model — prompt is last positional arg", () => {
|
|
186
|
+
const argv = runtime.buildPrintCommand("Summarize this diff");
|
|
187
|
+
expect(argv).toEqual(["pi", "--print", "Summarize this diff"]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("command with model alias — expands to qualified ID", () => {
|
|
191
|
+
const argv = runtime.buildPrintCommand("Classify this error", "haiku");
|
|
192
|
+
expect(argv).toEqual([
|
|
193
|
+
"pi",
|
|
194
|
+
"--print",
|
|
195
|
+
"--model",
|
|
196
|
+
"anthropic/claude-haiku-4-5",
|
|
197
|
+
"Classify this error",
|
|
198
|
+
]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("command with already-qualified model — passes through", () => {
|
|
202
|
+
const argv = runtime.buildPrintCommand("Classify this error", "openrouter/gpt-5");
|
|
203
|
+
expect(argv).toEqual(["pi", "--print", "--model", "openrouter/gpt-5", "Classify this error"]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("model undefined omits --model flag", () => {
|
|
207
|
+
const argv = runtime.buildPrintCommand("Hello", undefined);
|
|
208
|
+
expect(argv).not.toContain("--model");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("prompt is the last element (positional, not -p flag)", () => {
|
|
212
|
+
const prompt = "My test prompt";
|
|
213
|
+
const argv = runtime.buildPrintCommand(prompt, "sonnet");
|
|
214
|
+
expect(argv[argv.length - 1]).toBe(prompt);
|
|
215
|
+
expect(argv).not.toContain("-p");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("without model, argv has exactly 3 elements", () => {
|
|
219
|
+
const argv = runtime.buildPrintCommand("prompt text");
|
|
220
|
+
expect(argv.length).toBe(3);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("with model, argv has exactly 5 elements", () => {
|
|
224
|
+
const argv = runtime.buildPrintCommand("prompt text", "sonnet");
|
|
225
|
+
expect(argv.length).toBe(5);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("detectReady", () => {
|
|
230
|
+
test("returns loading for empty pane", () => {
|
|
231
|
+
const state = runtime.detectReady("");
|
|
232
|
+
expect(state).toEqual({ phase: "loading" });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("returns loading when only 'pi v' header present (no status bar)", () => {
|
|
236
|
+
const state = runtime.detectReady(" pi v0.55.1\n escape to interrupt");
|
|
237
|
+
expect(state).toEqual({ phase: "loading" });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("returns loading when only status bar present (no header)", () => {
|
|
241
|
+
const state = runtime.detectReady("0.0%/200k (auto) (anthropic) claude-opus-4-6");
|
|
242
|
+
expect(state).toEqual({ phase: "loading" });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("returns ready for real Pi TUI pane content", () => {
|
|
246
|
+
const pane = [
|
|
247
|
+
" pi v0.55.1",
|
|
248
|
+
" escape to interrupt",
|
|
249
|
+
" ctrl+c to clear",
|
|
250
|
+
"",
|
|
251
|
+
"[Context]",
|
|
252
|
+
" ~/Projects/os-eco/CLAUDE.md",
|
|
253
|
+
"",
|
|
254
|
+
"[Extensions]",
|
|
255
|
+
" project",
|
|
256
|
+
" overstory-guard.ts",
|
|
257
|
+
"",
|
|
258
|
+
"────────────────────────────────",
|
|
259
|
+
"~/Projects/os-eco/overstory (main)",
|
|
260
|
+
"0.0%/200k (auto) (anthropic) claude-opus-4-6 • high",
|
|
261
|
+
].join("\n");
|
|
262
|
+
const state = runtime.detectReady(pane);
|
|
263
|
+
expect(state).toEqual({ phase: "ready" });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("returns ready for minimal header + status bar", () => {
|
|
267
|
+
const state = runtime.detectReady("pi v1.0\n\n42.5%/200k done");
|
|
268
|
+
expect(state).toEqual({ phase: "ready" });
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("returns loading for random pane content", () => {
|
|
272
|
+
const state = runtime.detectReady("Loading...\nPlease wait");
|
|
273
|
+
expect(state).toEqual({ phase: "loading" });
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("no dialog phase — Pi has no trust dialog", () => {
|
|
277
|
+
// Pi does not have a trust dialog; even 'trust this folder' should not trigger dialog
|
|
278
|
+
const state = runtime.detectReady("trust this folder");
|
|
279
|
+
expect(state.phase).not.toBe("dialog");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("handles bedrock model provider in status bar", () => {
|
|
283
|
+
const pane =
|
|
284
|
+
" pi v0.55.1\n\n0.0%/200k (auto) (amazon-bedrock) us.anthropic.claude-opus-4-6-v1 • high";
|
|
285
|
+
const state = runtime.detectReady(pane);
|
|
286
|
+
expect(state).toEqual({ phase: "ready" });
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe("buildEnv", () => {
|
|
291
|
+
test("returns empty object when model has no env", () => {
|
|
292
|
+
const model: ResolvedModel = { model: "sonnet" };
|
|
293
|
+
const env = runtime.buildEnv(model);
|
|
294
|
+
expect(env).toEqual({});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("returns model.env when present", () => {
|
|
298
|
+
const model: ResolvedModel = {
|
|
299
|
+
model: "sonnet",
|
|
300
|
+
env: { API_KEY: "sk-test-123", BASE_URL: "https://api.example.com" },
|
|
301
|
+
};
|
|
302
|
+
const env = runtime.buildEnv(model);
|
|
303
|
+
expect(env).toEqual({ API_KEY: "sk-test-123", BASE_URL: "https://api.example.com" });
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("returns empty object when model.env is undefined", () => {
|
|
307
|
+
const model: ResolvedModel = { model: "opus", env: undefined };
|
|
308
|
+
const env = runtime.buildEnv(model);
|
|
309
|
+
expect(env).toEqual({});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("result is safe to spread", () => {
|
|
313
|
+
const model: ResolvedModel = { model: "sonnet" };
|
|
314
|
+
const env = runtime.buildEnv(model);
|
|
315
|
+
const combined = { ...env, OVERSTORY_AGENT_NAME: "builder-1" };
|
|
316
|
+
expect(combined).toEqual({ OVERSTORY_AGENT_NAME: "builder-1" });
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe("deployConfig", () => {
|
|
321
|
+
let tempDir: string;
|
|
322
|
+
|
|
323
|
+
beforeEach(async () => {
|
|
324
|
+
tempDir = await mkdtemp(join(tmpdir(), "overstory-pi-test-"));
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
afterEach(async () => {
|
|
328
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("writes overlay to .claude/CLAUDE.md when overlay is provided", async () => {
|
|
332
|
+
const worktreePath = join(tempDir, "worktree");
|
|
333
|
+
|
|
334
|
+
await runtime.deployConfig(
|
|
335
|
+
worktreePath,
|
|
336
|
+
{ content: "# Pi Agent Overlay\nThis is the overlay content." },
|
|
337
|
+
{ agentName: "test-builder", capability: "builder", worktreePath },
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
const overlayPath = join(worktreePath, ".claude", "CLAUDE.md");
|
|
341
|
+
const content = await Bun.file(overlayPath).text();
|
|
342
|
+
expect(content).toBe("# Pi Agent Overlay\nThis is the overlay content.");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("deploys guard extension to .pi/extensions/overstory-guard.ts", async () => {
|
|
346
|
+
const worktreePath = join(tempDir, "worktree");
|
|
347
|
+
|
|
348
|
+
await runtime.deployConfig(
|
|
349
|
+
worktreePath,
|
|
350
|
+
{ content: "# Overlay" },
|
|
351
|
+
{ agentName: "test-builder", capability: "builder", worktreePath },
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const guardPath = join(worktreePath, ".pi", "extensions", "overstory-guard.ts");
|
|
355
|
+
const exists = await Bun.file(guardPath).exists();
|
|
356
|
+
expect(exists).toBe(true);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("guard extension contains agent name and worktree path", async () => {
|
|
360
|
+
const worktreePath = join(tempDir, "my-worktree");
|
|
361
|
+
|
|
362
|
+
await runtime.deployConfig(
|
|
363
|
+
worktreePath,
|
|
364
|
+
{ content: "# Overlay" },
|
|
365
|
+
{ agentName: "my-pi-agent", capability: "builder", worktreePath },
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const guardPath = join(worktreePath, ".pi", "extensions", "overstory-guard.ts");
|
|
369
|
+
const content = await Bun.file(guardPath).text();
|
|
370
|
+
expect(content).toContain("my-pi-agent");
|
|
371
|
+
expect(content).toContain(worktreePath);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("deploys Pi settings.json with extensions config", async () => {
|
|
375
|
+
const worktreePath = join(tempDir, "worktree");
|
|
376
|
+
|
|
377
|
+
await runtime.deployConfig(
|
|
378
|
+
worktreePath,
|
|
379
|
+
{ content: "# Overlay" },
|
|
380
|
+
{ agentName: "test-builder", capability: "builder", worktreePath },
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
const settingsPath = join(worktreePath, ".pi", "settings.json");
|
|
384
|
+
const exists = await Bun.file(settingsPath).exists();
|
|
385
|
+
expect(exists).toBe(true);
|
|
386
|
+
|
|
387
|
+
const content = await Bun.file(settingsPath).text();
|
|
388
|
+
const parsed = JSON.parse(content) as Record<string, unknown>;
|
|
389
|
+
expect(parsed.extensions).toEqual(["./extensions"]);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("settings.json has trailing newline", async () => {
|
|
393
|
+
const worktreePath = join(tempDir, "worktree");
|
|
394
|
+
|
|
395
|
+
await runtime.deployConfig(worktreePath, undefined, {
|
|
396
|
+
agentName: "test-builder",
|
|
397
|
+
capability: "builder",
|
|
398
|
+
worktreePath,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const settingsPath = join(worktreePath, ".pi", "settings.json");
|
|
402
|
+
const content = await Bun.file(settingsPath).text();
|
|
403
|
+
expect(content.endsWith("\n")).toBe(true);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("settings.json uses tab indentation", async () => {
|
|
407
|
+
const worktreePath = join(tempDir, "worktree");
|
|
408
|
+
|
|
409
|
+
await runtime.deployConfig(worktreePath, undefined, {
|
|
410
|
+
agentName: "test-builder",
|
|
411
|
+
capability: "builder",
|
|
412
|
+
worktreePath,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const settingsPath = join(worktreePath, ".pi", "settings.json");
|
|
416
|
+
const content = await Bun.file(settingsPath).text();
|
|
417
|
+
// Tab-indented JSON has \t before array entries
|
|
418
|
+
expect(content).toContain("\t");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("skips CLAUDE.md when overlay is undefined", async () => {
|
|
422
|
+
const worktreePath = join(tempDir, "worktree");
|
|
423
|
+
|
|
424
|
+
await runtime.deployConfig(worktreePath, undefined, {
|
|
425
|
+
agentName: "coordinator",
|
|
426
|
+
capability: "coordinator",
|
|
427
|
+
worktreePath,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
const overlayPath = join(worktreePath, ".claude", "CLAUDE.md");
|
|
431
|
+
const overlayExists = await Bun.file(overlayPath).exists();
|
|
432
|
+
expect(overlayExists).toBe(false);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("still deploys guard and settings when overlay is undefined", async () => {
|
|
436
|
+
const worktreePath = join(tempDir, "worktree");
|
|
437
|
+
|
|
438
|
+
await runtime.deployConfig(worktreePath, undefined, {
|
|
439
|
+
agentName: "coordinator",
|
|
440
|
+
capability: "coordinator",
|
|
441
|
+
worktreePath,
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const guardPath = join(worktreePath, ".pi", "extensions", "overstory-guard.ts");
|
|
445
|
+
const settingsPath = join(worktreePath, ".pi", "settings.json");
|
|
446
|
+
|
|
447
|
+
expect(await Bun.file(guardPath).exists()).toBe(true);
|
|
448
|
+
expect(await Bun.file(settingsPath).exists()).toBe(true);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("all three files present when overlay is provided", async () => {
|
|
452
|
+
const worktreePath = join(tempDir, "worktree");
|
|
453
|
+
|
|
454
|
+
await runtime.deployConfig(
|
|
455
|
+
worktreePath,
|
|
456
|
+
{ content: "# Overlay" },
|
|
457
|
+
{ agentName: "test-builder", capability: "builder", worktreePath },
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
const claudeMdExists = await Bun.file(join(worktreePath, ".claude", "CLAUDE.md")).exists();
|
|
461
|
+
const guardExists = await Bun.file(
|
|
462
|
+
join(worktreePath, ".pi", "extensions", "overstory-guard.ts"),
|
|
463
|
+
).exists();
|
|
464
|
+
const settingsExists = await Bun.file(join(worktreePath, ".pi", "settings.json")).exists();
|
|
465
|
+
|
|
466
|
+
expect(claudeMdExists).toBe(true);
|
|
467
|
+
expect(guardExists).toBe(true);
|
|
468
|
+
expect(settingsExists).toBe(true);
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
describe("parseTranscript", () => {
|
|
473
|
+
let tempDir: string;
|
|
474
|
+
|
|
475
|
+
beforeEach(async () => {
|
|
476
|
+
tempDir = await mkdtemp(join(tmpdir(), "overstory-pi-transcript-test-"));
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
afterEach(async () => {
|
|
480
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("returns null for non-existent file", async () => {
|
|
484
|
+
const result = await runtime.parseTranscript(join(tempDir, "does-not-exist.jsonl"));
|
|
485
|
+
expect(result).toBeNull();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("parses message_end event with top-level inputTokens/outputTokens", async () => {
|
|
489
|
+
const transcriptPath = join(tempDir, "session.jsonl");
|
|
490
|
+
const entry = JSON.stringify({
|
|
491
|
+
type: "message_end",
|
|
492
|
+
inputTokens: 100,
|
|
493
|
+
outputTokens: 50,
|
|
494
|
+
});
|
|
495
|
+
await Bun.write(transcriptPath, `${entry}\n`);
|
|
496
|
+
|
|
497
|
+
const result = await runtime.parseTranscript(transcriptPath);
|
|
498
|
+
expect(result).not.toBeNull();
|
|
499
|
+
expect(result?.inputTokens).toBe(100);
|
|
500
|
+
expect(result?.outputTokens).toBe(50);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("aggregates multiple message_end events", async () => {
|
|
504
|
+
const transcriptPath = join(tempDir, "session.jsonl");
|
|
505
|
+
const entry1 = JSON.stringify({ type: "message_end", inputTokens: 100, outputTokens: 50 });
|
|
506
|
+
const entry2 = JSON.stringify({ type: "message_end", inputTokens: 200, outputTokens: 75 });
|
|
507
|
+
await Bun.write(transcriptPath, `${entry1}\n${entry2}\n`);
|
|
508
|
+
|
|
509
|
+
const result = await runtime.parseTranscript(transcriptPath);
|
|
510
|
+
expect(result).not.toBeNull();
|
|
511
|
+
expect(result?.inputTokens).toBe(300);
|
|
512
|
+
expect(result?.outputTokens).toBe(125);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("reads model from model_change event", async () => {
|
|
516
|
+
const transcriptPath = join(tempDir, "session.jsonl");
|
|
517
|
+
const modelChange = JSON.stringify({ type: "model_change", model: "claude-sonnet-4-6" });
|
|
518
|
+
const messageEnd = JSON.stringify({ type: "message_end", inputTokens: 10, outputTokens: 5 });
|
|
519
|
+
await Bun.write(transcriptPath, `${modelChange}\n${messageEnd}\n`);
|
|
520
|
+
|
|
521
|
+
const result = await runtime.parseTranscript(transcriptPath);
|
|
522
|
+
expect(result?.model).toBe("claude-sonnet-4-6");
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("last model_change wins when multiple present", async () => {
|
|
526
|
+
const transcriptPath = join(tempDir, "session.jsonl");
|
|
527
|
+
const change1 = JSON.stringify({ type: "model_change", model: "sonnet" });
|
|
528
|
+
const change2 = JSON.stringify({ type: "model_change", model: "opus" });
|
|
529
|
+
const msgEnd = JSON.stringify({ type: "message_end", inputTokens: 10, outputTokens: 5 });
|
|
530
|
+
await Bun.write(transcriptPath, `${change1}\n${change2}\n${msgEnd}\n`);
|
|
531
|
+
|
|
532
|
+
const result = await runtime.parseTranscript(transcriptPath);
|
|
533
|
+
expect(result?.model).toBe("opus");
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("defaults model to empty string when no model_change events", async () => {
|
|
537
|
+
const transcriptPath = join(tempDir, "session.jsonl");
|
|
538
|
+
const entry = JSON.stringify({ type: "message_end", inputTokens: 10, outputTokens: 5 });
|
|
539
|
+
await Bun.write(transcriptPath, `${entry}\n`);
|
|
540
|
+
|
|
541
|
+
const result = await runtime.parseTranscript(transcriptPath);
|
|
542
|
+
expect(result?.model).toBe("");
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test("skips non-message_end events for token counting", async () => {
|
|
546
|
+
const transcriptPath = join(tempDir, "session.jsonl");
|
|
547
|
+
// Claude-style assistant events should NOT be counted (wrong format for Pi)
|
|
548
|
+
const claudeStyleEntry = JSON.stringify({
|
|
549
|
+
type: "assistant",
|
|
550
|
+
message: { usage: { input_tokens: 999, output_tokens: 999 } },
|
|
551
|
+
});
|
|
552
|
+
const piEntry = JSON.stringify({ type: "message_end", inputTokens: 10, outputTokens: 5 });
|
|
553
|
+
await Bun.write(transcriptPath, `${claudeStyleEntry}\n${piEntry}\n`);
|
|
554
|
+
|
|
555
|
+
const result = await runtime.parseTranscript(transcriptPath);
|
|
556
|
+
expect(result?.inputTokens).toBe(10);
|
|
557
|
+
expect(result?.outputTokens).toBe(5);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("returns zero counts for file with no message_end events", async () => {
|
|
561
|
+
const transcriptPath = join(tempDir, "session.jsonl");
|
|
562
|
+
const entry = JSON.stringify({ type: "tool_call", name: "Read", input: {} });
|
|
563
|
+
await Bun.write(transcriptPath, `${entry}\n`);
|
|
564
|
+
|
|
565
|
+
const result = await runtime.parseTranscript(transcriptPath);
|
|
566
|
+
expect(result).not.toBeNull();
|
|
567
|
+
expect(result?.inputTokens).toBe(0);
|
|
568
|
+
expect(result?.outputTokens).toBe(0);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test("returns null for completely malformed file (non-JSON)", async () => {
|
|
572
|
+
const transcriptPath = join(tempDir, "bad.jsonl");
|
|
573
|
+
await Bun.write(transcriptPath, "not json at all\nstill not json");
|
|
574
|
+
|
|
575
|
+
// All lines fail to parse, result has 0 tokens (not null)
|
|
576
|
+
const result = await runtime.parseTranscript(transcriptPath);
|
|
577
|
+
if (result !== null) {
|
|
578
|
+
expect(result.inputTokens).toBe(0);
|
|
579
|
+
expect(result.outputTokens).toBe(0);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("skips malformed lines and parses valid ones", async () => {
|
|
584
|
+
const transcriptPath = join(tempDir, "mixed.jsonl");
|
|
585
|
+
const bad = "not json";
|
|
586
|
+
const good = JSON.stringify({ type: "message_end", inputTokens: 42, outputTokens: 7 });
|
|
587
|
+
await Bun.write(transcriptPath, `${bad}\n${good}\n`);
|
|
588
|
+
|
|
589
|
+
const result = await runtime.parseTranscript(transcriptPath);
|
|
590
|
+
expect(result?.inputTokens).toBe(42);
|
|
591
|
+
expect(result?.outputTokens).toBe(7);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("handles empty file (returns zero counts)", async () => {
|
|
595
|
+
const transcriptPath = join(tempDir, "empty.jsonl");
|
|
596
|
+
await Bun.write(transcriptPath, "");
|
|
597
|
+
|
|
598
|
+
const result = await runtime.parseTranscript(transcriptPath);
|
|
599
|
+
expect(result).not.toBeNull();
|
|
600
|
+
expect(result?.inputTokens).toBe(0);
|
|
601
|
+
expect(result?.outputTokens).toBe(0);
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
describe("PiRuntime integration: registry resolves 'pi'", () => {
|
|
607
|
+
test("getRuntime('pi') returns PiRuntime", async () => {
|
|
608
|
+
const { getRuntime } = await import("./registry.ts");
|
|
609
|
+
const rt = getRuntime("pi");
|
|
610
|
+
expect(rt).toBeInstanceOf(PiRuntime);
|
|
611
|
+
expect(rt.id).toBe("pi");
|
|
612
|
+
expect(rt.instructionPath).toBe(".claude/CLAUDE.md");
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("getRuntime rejects truly unknown runtimes", async () => {
|
|
616
|
+
const { getRuntime } = await import("./registry.ts");
|
|
617
|
+
expect(() => getRuntime("codex")).toThrow('Unknown runtime: "codex"');
|
|
618
|
+
expect(() => getRuntime("opencode")).toThrow('Unknown runtime: "opencode"');
|
|
619
|
+
});
|
|
620
|
+
});
|