phoebe-agent 0.0.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/LICENSE +21 -0
- package/README.md +55 -0
- package/dist/phoebe.config.d.ts +2 -0
- package/dist/phoebe.config.js +43 -0
- package/dist/src/agent-env.d.ts +6 -0
- package/dist/src/agent-env.js +24 -0
- package/dist/src/cli.d.ts +25 -0
- package/dist/src/cli.js +161 -0
- package/dist/src/config-schema.d.ts +163 -0
- package/dist/src/config-schema.js +140 -0
- package/dist/src/execution-gate.d.ts +9 -0
- package/dist/src/execution-gate.js +17 -0
- package/dist/src/git-model.d.ts +30 -0
- package/dist/src/git-model.js +71 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +12 -0
- package/dist/src/init.d.ts +82 -0
- package/dist/src/init.js +207 -0
- package/dist/src/load-config.d.ts +88 -0
- package/dist/src/load-config.js +153 -0
- package/dist/src/main.d.ts +7 -0
- package/dist/src/main.js +1180 -0
- package/dist/src/orchestrator.d.ts +275 -0
- package/dist/src/orchestrator.js +579 -0
- package/dist/src/prompt.d.ts +13 -0
- package/dist/src/prompt.js +55 -0
- package/dist/src/providers/providers.d.ts +3 -0
- package/dist/src/providers/providers.js +210 -0
- package/dist/src/providers/run-agent.d.ts +34 -0
- package/dist/src/providers/run-agent.js +57 -0
- package/dist/src/providers/types.d.ts +27 -0
- package/dist/src/providers/types.js +6 -0
- package/dist/src/resolved-config.d.ts +8 -0
- package/dist/src/resolved-config.js +49 -0
- package/dist/src/supervisor-decision.d.ts +70 -0
- package/dist/src/supervisor-decision.js +94 -0
- package/package.json +53 -0
- package/prompts/checks-prompt.md +53 -0
- package/prompts/conflict-prompt.md +53 -0
- package/prompts/prompt.md +55 -0
- package/prompts/reviews-prompt.md +106 -0
- package/templates/.env.example +21 -0
- package/templates/container/Dockerfile +54 -0
- package/templates/container/compose.daemon.yml +16 -0
- package/templates/container/compose.yml +46 -0
- package/templates/container/supervisor.sh +50 -0
- package/templates/phoebe.config.ts +15 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// The three agent CLIs Phoebe can drive. Invocation flags and stream-JSON
|
|
2
|
+
// schemas were authored from Sandcastle 0.8.0's AgentProvider.ts (the
|
|
3
|
+
// `@ai-hero/sandcastle` package sources) and carry its field-level
|
|
4
|
+
// knowledge of each CLI's output format.
|
|
5
|
+
/** Maps allowlisted tool names to the input field carrying the display arg. */
|
|
6
|
+
const TOOL_ARG_FIELDS = {
|
|
7
|
+
Bash: "command",
|
|
8
|
+
WebSearch: "query",
|
|
9
|
+
WebFetch: "url",
|
|
10
|
+
Agent: "description",
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Claude Code and Cursor share the Claude stream-json line schema:
|
|
14
|
+
* `assistant` messages with text/tool_use content blocks, plus a terminal
|
|
15
|
+
* `result` event.
|
|
16
|
+
*/
|
|
17
|
+
const parseClaudeStreamLine = (line) => {
|
|
18
|
+
if (!line.startsWith("{"))
|
|
19
|
+
return [];
|
|
20
|
+
try {
|
|
21
|
+
const obj = JSON.parse(line);
|
|
22
|
+
const message = obj["message"];
|
|
23
|
+
if (obj["type"] === "assistant" && Array.isArray(message?.content)) {
|
|
24
|
+
const events = [];
|
|
25
|
+
const texts = [];
|
|
26
|
+
for (const block of message.content) {
|
|
27
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
28
|
+
texts.push(block.text);
|
|
29
|
+
}
|
|
30
|
+
else if (block.type === "tool_use" &&
|
|
31
|
+
typeof block.name === "string" &&
|
|
32
|
+
block.input !== undefined) {
|
|
33
|
+
const argField = TOOL_ARG_FIELDS[block.name];
|
|
34
|
+
if (argField === undefined)
|
|
35
|
+
continue;
|
|
36
|
+
const argValue = block.input[argField];
|
|
37
|
+
if (typeof argValue !== "string")
|
|
38
|
+
continue;
|
|
39
|
+
if (texts.length > 0) {
|
|
40
|
+
events.push({ type: "text", text: texts.join("") });
|
|
41
|
+
texts.length = 0;
|
|
42
|
+
}
|
|
43
|
+
events.push({ type: "tool_call", name: block.name, args: argValue });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (texts.length > 0) {
|
|
47
|
+
events.push({ type: "text", text: texts.join("") });
|
|
48
|
+
}
|
|
49
|
+
return events;
|
|
50
|
+
}
|
|
51
|
+
if (obj["type"] === "result" && typeof obj["result"] === "string") {
|
|
52
|
+
return [{ type: "result", result: obj["result"] }];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Not valid JSON — skip.
|
|
57
|
+
}
|
|
58
|
+
return [];
|
|
59
|
+
};
|
|
60
|
+
/** Cursor additionally emits top-level `tool_call` events with per-tool shapes. */
|
|
61
|
+
const parseCursorStreamLine = (line) => {
|
|
62
|
+
if (!line.startsWith("{"))
|
|
63
|
+
return [];
|
|
64
|
+
let obj;
|
|
65
|
+
try {
|
|
66
|
+
obj = JSON.parse(line);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
if (obj["type"] !== "tool_call" || obj["subtype"] !== "started") {
|
|
72
|
+
return parseClaudeStreamLine(line);
|
|
73
|
+
}
|
|
74
|
+
const toolCall = obj["tool_call"];
|
|
75
|
+
if (!toolCall || typeof toolCall !== "object")
|
|
76
|
+
return [];
|
|
77
|
+
const tc = toolCall;
|
|
78
|
+
const readToolCall = tc["readToolCall"];
|
|
79
|
+
if (readToolCall?.args && typeof readToolCall.args.path === "string") {
|
|
80
|
+
return [{ type: "tool_call", name: "Read", args: readToolCall.args.path }];
|
|
81
|
+
}
|
|
82
|
+
const writeToolCall = tc["writeToolCall"];
|
|
83
|
+
if (writeToolCall?.args && typeof writeToolCall.args.path === "string") {
|
|
84
|
+
return [{ type: "tool_call", name: "Write", args: writeToolCall.args.path }];
|
|
85
|
+
}
|
|
86
|
+
const fn = tc["function"];
|
|
87
|
+
if (fn && typeof fn.name === "string") {
|
|
88
|
+
const rawArgs = typeof fn.arguments === "string" ? fn.arguments : "";
|
|
89
|
+
if (rawArgs) {
|
|
90
|
+
try {
|
|
91
|
+
const parsedArgs = JSON.parse(rawArgs);
|
|
92
|
+
if (typeof parsedArgs["command"] === "string") {
|
|
93
|
+
return [{ type: "tool_call", name: "Bash", args: parsedArgs["command"] }];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Fall through to the raw arguments string.
|
|
98
|
+
}
|
|
99
|
+
return [{ type: "tool_call", name: fn.name, args: rawArgs }];
|
|
100
|
+
}
|
|
101
|
+
return [{ type: "tool_call", name: fn.name, args: "" }];
|
|
102
|
+
}
|
|
103
|
+
return [];
|
|
104
|
+
};
|
|
105
|
+
const extractErrorMessage = (obj) => {
|
|
106
|
+
const err = obj["error"];
|
|
107
|
+
if (typeof err === "string")
|
|
108
|
+
return err;
|
|
109
|
+
if (typeof err === "object" && err !== null) {
|
|
110
|
+
const e = err;
|
|
111
|
+
if (typeof e.message === "string")
|
|
112
|
+
return e.message;
|
|
113
|
+
if (typeof e.data?.message === "string")
|
|
114
|
+
return e.data.message;
|
|
115
|
+
}
|
|
116
|
+
if (typeof obj["message"] === "string")
|
|
117
|
+
return obj["message"];
|
|
118
|
+
return undefined;
|
|
119
|
+
};
|
|
120
|
+
/** Codex `exec --json` emits item/turn events rather than message blocks. */
|
|
121
|
+
const parseCodexStreamLine = (line) => {
|
|
122
|
+
if (!line.startsWith("{"))
|
|
123
|
+
return [];
|
|
124
|
+
try {
|
|
125
|
+
const obj = JSON.parse(line);
|
|
126
|
+
const item = obj["item"];
|
|
127
|
+
if (obj["type"] === "item.completed" &&
|
|
128
|
+
item?.type === "agent_message" &&
|
|
129
|
+
typeof item.text === "string") {
|
|
130
|
+
return [
|
|
131
|
+
{ type: "text", text: item.text },
|
|
132
|
+
{ type: "result", result: item.text },
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
if (obj["type"] === "item.started" &&
|
|
136
|
+
item?.type === "command_execution" &&
|
|
137
|
+
typeof item.command === "string") {
|
|
138
|
+
return [{ type: "tool_call", name: "Bash", args: item.command }];
|
|
139
|
+
}
|
|
140
|
+
// Codex reports auth/rate-limit/API errors on stdout, not stderr.
|
|
141
|
+
if (obj["type"] === "error") {
|
|
142
|
+
const msg = extractErrorMessage(obj);
|
|
143
|
+
return msg ? [{ type: "result", result: msg }] : [];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Not valid JSON — skip.
|
|
148
|
+
}
|
|
149
|
+
return [];
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* The Cursor CLI takes the prompt as a positional argv argument (stdin is not
|
|
153
|
+
* documented for prompt delivery). Linux caps a single argument at ~128 KiB;
|
|
154
|
+
* stay under it so users get a clear error instead of spawn E2BIG.
|
|
155
|
+
*/
|
|
156
|
+
const CURSOR_PROMPT_MAX_BYTES = 120 * 1024;
|
|
157
|
+
const cursor = {
|
|
158
|
+
name: "cursor",
|
|
159
|
+
buildCommand({ prompt, model }) {
|
|
160
|
+
const bytes = Buffer.byteLength(prompt, "utf8");
|
|
161
|
+
if (bytes > CURSOR_PROMPT_MAX_BYTES) {
|
|
162
|
+
throw new Error(`Cursor prompt is ${bytes} bytes (max ${CURSOR_PROMPT_MAX_BYTES}). The Cursor CLI only accepts the prompt as an argv argument; shorten the prompt or use another provider.`);
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
argv: [
|
|
166
|
+
"agent",
|
|
167
|
+
"--print",
|
|
168
|
+
"--output-format",
|
|
169
|
+
"stream-json",
|
|
170
|
+
"--model",
|
|
171
|
+
model,
|
|
172
|
+
"--force",
|
|
173
|
+
prompt,
|
|
174
|
+
],
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
parseStreamLine: parseCursorStreamLine,
|
|
178
|
+
};
|
|
179
|
+
const claude = {
|
|
180
|
+
name: "claude",
|
|
181
|
+
buildCommand({ prompt, model }) {
|
|
182
|
+
return {
|
|
183
|
+
argv: [
|
|
184
|
+
"claude",
|
|
185
|
+
"--print",
|
|
186
|
+
"--verbose",
|
|
187
|
+
"--dangerously-skip-permissions",
|
|
188
|
+
"--output-format",
|
|
189
|
+
"stream-json",
|
|
190
|
+
"--model",
|
|
191
|
+
model,
|
|
192
|
+
"-p",
|
|
193
|
+
"-",
|
|
194
|
+
],
|
|
195
|
+
stdin: prompt,
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
parseStreamLine: parseClaudeStreamLine,
|
|
199
|
+
};
|
|
200
|
+
const codex = {
|
|
201
|
+
name: "codex",
|
|
202
|
+
buildCommand({ prompt, model }) {
|
|
203
|
+
return {
|
|
204
|
+
argv: ["codex", "exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "-m", model],
|
|
205
|
+
stdin: prompt,
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
parseStreamLine: parseCodexStreamLine,
|
|
209
|
+
};
|
|
210
|
+
export const PROVIDERS = { cursor, claude, codex };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Provider } from "./types.ts";
|
|
2
|
+
export type AgentRunResult = {
|
|
3
|
+
exitCode: number;
|
|
4
|
+
/** Last `result` event the provider emitted, if any. */
|
|
5
|
+
resultText: string;
|
|
6
|
+
};
|
|
7
|
+
/** Minimal child-process surface so tests can inject a fake spawn. */
|
|
8
|
+
export type AgentChild = {
|
|
9
|
+
stdout: {
|
|
10
|
+
on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
|
|
11
|
+
};
|
|
12
|
+
stderr: {
|
|
13
|
+
on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
|
|
14
|
+
};
|
|
15
|
+
stdin: {
|
|
16
|
+
write(data: string): unknown;
|
|
17
|
+
end(): unknown;
|
|
18
|
+
};
|
|
19
|
+
on(event: "error", listener: (err: Error) => void): unknown;
|
|
20
|
+
on(event: "close", listener: (code: number | null) => void): unknown;
|
|
21
|
+
};
|
|
22
|
+
export type SpawnAgent = (file: string, args: readonly string[], opts: {
|
|
23
|
+
cwd: string;
|
|
24
|
+
env: Record<string, string>;
|
|
25
|
+
}) => AgentChild;
|
|
26
|
+
export declare function runAgent(opts: {
|
|
27
|
+
provider: Provider;
|
|
28
|
+
model: string;
|
|
29
|
+
prompt: string;
|
|
30
|
+
cwd: string;
|
|
31
|
+
env: Record<string, string>;
|
|
32
|
+
spawn?: SpawnAgent;
|
|
33
|
+
log?: (line: string) => void;
|
|
34
|
+
}): Promise<AgentRunResult>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Spawn an agent CLI for one work unit and stream its output. The child's
|
|
2
|
+
// argv/stdin come from the provider; the env is the caller-built allowlist
|
|
3
|
+
// (see ../agent-env.ts) — never the orchestrator's full process.env.
|
|
4
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
5
|
+
const defaultSpawn = (file, args, opts) => nodeSpawn(file, args, { ...opts, stdio: ["pipe", "pipe", "pipe"] });
|
|
6
|
+
export async function runAgent(opts) {
|
|
7
|
+
const { provider, model, prompt, cwd, env } = opts;
|
|
8
|
+
const spawn = opts.spawn ?? defaultSpawn;
|
|
9
|
+
const log = opts.log ?? ((line) => console.log(line));
|
|
10
|
+
const command = provider.buildCommand({ prompt, model });
|
|
11
|
+
const [file, ...args] = command.argv;
|
|
12
|
+
if (!file) {
|
|
13
|
+
throw new Error(`Provider "${provider.name}" built an empty command.`);
|
|
14
|
+
}
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const child = spawn(file, args, { cwd, env });
|
|
17
|
+
let resultText = "";
|
|
18
|
+
let stdoutBuffer = "";
|
|
19
|
+
const handleLine = (line) => {
|
|
20
|
+
for (const event of provider.parseStreamLine(line)) {
|
|
21
|
+
if (event.type === "text") {
|
|
22
|
+
const text = event.text.trim();
|
|
23
|
+
if (text)
|
|
24
|
+
log(`[${provider.name}] ${text}`);
|
|
25
|
+
}
|
|
26
|
+
else if (event.type === "tool_call") {
|
|
27
|
+
log(`[${provider.name}] ${event.name}: ${event.args}`);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
resultText = event.result;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
child.stdout.on("data", (chunk) => {
|
|
35
|
+
stdoutBuffer += chunk.toString();
|
|
36
|
+
const lines = stdoutBuffer.split("\n");
|
|
37
|
+
stdoutBuffer = lines.pop() ?? "";
|
|
38
|
+
for (const line of lines)
|
|
39
|
+
handleLine(line);
|
|
40
|
+
});
|
|
41
|
+
child.stderr.on("data", (chunk) => {
|
|
42
|
+
const text = chunk.toString().trim();
|
|
43
|
+
if (text)
|
|
44
|
+
log(`[${provider.name}:stderr] ${text}`);
|
|
45
|
+
});
|
|
46
|
+
child.on("error", reject);
|
|
47
|
+
child.on("close", (code) => {
|
|
48
|
+
if (stdoutBuffer)
|
|
49
|
+
handleLine(stdoutBuffer);
|
|
50
|
+
resolve({ exitCode: code ?? 1, resultText });
|
|
51
|
+
});
|
|
52
|
+
if (command.stdin !== undefined) {
|
|
53
|
+
child.stdin.write(command.stdin);
|
|
54
|
+
}
|
|
55
|
+
child.stdin.end();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ProviderName } from "../config-schema.ts";
|
|
2
|
+
/** A ready-to-spawn CLI invocation. `argv[0]` is the binary; no shell involved. */
|
|
3
|
+
export type AgentCommand = {
|
|
4
|
+
readonly argv: readonly string[];
|
|
5
|
+
/** When set, piped to the child's stdin (large prompts don't fit argv). */
|
|
6
|
+
readonly stdin?: string;
|
|
7
|
+
};
|
|
8
|
+
/** Events parsed from one line of the agent's JSONL output stream. */
|
|
9
|
+
export type AgentEvent = {
|
|
10
|
+
type: "text";
|
|
11
|
+
text: string;
|
|
12
|
+
} | {
|
|
13
|
+
type: "result";
|
|
14
|
+
result: string;
|
|
15
|
+
} | {
|
|
16
|
+
type: "tool_call";
|
|
17
|
+
name: string;
|
|
18
|
+
args: string;
|
|
19
|
+
};
|
|
20
|
+
export type Provider = {
|
|
21
|
+
readonly name: ProviderName;
|
|
22
|
+
buildCommand(opts: {
|
|
23
|
+
prompt: string;
|
|
24
|
+
model: string;
|
|
25
|
+
}): AgentCommand;
|
|
26
|
+
parseStreamLine(line: string): AgentEvent[];
|
|
27
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Agent provider contract — how Phoebe invokes a coding-agent CLI and reads
|
|
2
|
+
// its output stream. Ported from Sandcastle's AgentProvider design (Matt
|
|
3
|
+
// Pocock, https://github.com/mattpocock/sandcastle — the design ancestor of
|
|
4
|
+
// this engine), trimmed to what Phoebe uses: one print-mode run per work unit,
|
|
5
|
+
// no session capture, no interactive mode.
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PhoebeConfig } from "./config-schema.ts";
|
|
2
|
+
/** Install the resolved config. Idempotent — later calls replace the prior value. */
|
|
3
|
+
export declare function setResolvedConfig(next: PhoebeConfig): void;
|
|
4
|
+
/** For test isolation: clear the installed config back to the "not installed" state. */
|
|
5
|
+
export declare function clearResolvedConfig(): void;
|
|
6
|
+
/** Whether a resolved config has been installed. */
|
|
7
|
+
export declare function hasResolvedConfig(): boolean;
|
|
8
|
+
export declare const config: PhoebeConfig;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Runtime handle to the resolved, defaults-filled config the engine reads.
|
|
2
|
+
//
|
|
3
|
+
// The engine (everything else under src/) imports `config` from here and stays
|
|
4
|
+
// repo-agnostic; the CLI (src/cli.ts) loads the consumer's `phoebe.config.ts`,
|
|
5
|
+
// applies the `PHOEBE_*` env overlay, resolves defaults, and installs the
|
|
6
|
+
// result via `setResolvedConfig` before importing the engine entry point.
|
|
7
|
+
// Tests install a sample config via the setup file wired in `vite.config.ts`.
|
|
8
|
+
//
|
|
9
|
+
// A `Proxy` gates every field read: reading before install throws with a
|
|
10
|
+
// pointer at the CLI/setup path, so an accidentally repo-coupled engine import
|
|
11
|
+
// fails loudly at the callsite instead of silently reading `undefined`.
|
|
12
|
+
let resolved = null;
|
|
13
|
+
/** Install the resolved config. Idempotent — later calls replace the prior value. */
|
|
14
|
+
export function setResolvedConfig(next) {
|
|
15
|
+
resolved = next;
|
|
16
|
+
}
|
|
17
|
+
/** For test isolation: clear the installed config back to the "not installed" state. */
|
|
18
|
+
export function clearResolvedConfig() {
|
|
19
|
+
resolved = null;
|
|
20
|
+
}
|
|
21
|
+
/** Whether a resolved config has been installed. */
|
|
22
|
+
export function hasResolvedConfig() {
|
|
23
|
+
return resolved !== null;
|
|
24
|
+
}
|
|
25
|
+
export const config = new Proxy({}, {
|
|
26
|
+
get(_target, prop) {
|
|
27
|
+
if (resolved === null) {
|
|
28
|
+
throw new Error(`Attempted to read config.${String(prop)} before the resolved config was installed. ` +
|
|
29
|
+
`The Phoebe CLI (src/cli.ts) installs it after loading phoebe.config.ts; ` +
|
|
30
|
+
`tests install a sample via src/test-setup.ts (wired from vite.config.ts).`);
|
|
31
|
+
}
|
|
32
|
+
return resolved[prop];
|
|
33
|
+
},
|
|
34
|
+
ownKeys() {
|
|
35
|
+
if (resolved === null)
|
|
36
|
+
return [];
|
|
37
|
+
return Reflect.ownKeys(resolved);
|
|
38
|
+
},
|
|
39
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
40
|
+
if (resolved === null)
|
|
41
|
+
return undefined;
|
|
42
|
+
return Reflect.getOwnPropertyDescriptor(resolved, prop);
|
|
43
|
+
},
|
|
44
|
+
has(_target, prop) {
|
|
45
|
+
if (resolved === null)
|
|
46
|
+
return false;
|
|
47
|
+
return prop in resolved;
|
|
48
|
+
},
|
|
49
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Deliberate "I need a reinstall + re-exec" exit code, watched by container/supervisor.sh. */
|
|
2
|
+
export declare const SELF_UPDATE_EXIT_CODE = 42;
|
|
3
|
+
/** Consecutive fast crashes of a freshly-pulled SHA before the daemon falls
|
|
4
|
+
* back to the last SHA that ran healthily. Mirrored in container/supervisor.sh. */
|
|
5
|
+
export declare const CRASH_LOOP_THRESHOLD = 3;
|
|
6
|
+
/** Seconds an orchestrator run must survive to count as healthy: a startup
|
|
7
|
+
* crash from a bad pull dies well within this, a transient runtime error does
|
|
8
|
+
* not. Mirrored in container/supervisor.sh. */
|
|
9
|
+
export declare const HEALTHY_RUN_SECONDS = 60;
|
|
10
|
+
/**
|
|
11
|
+
* Whether any changed file means Phoebe's own code (or the lockfile behind its
|
|
12
|
+
* dependencies) moved. `selfUpdatePaths` entries ending in `/` match as
|
|
13
|
+
* directory prefixes; other entries match exactly.
|
|
14
|
+
*/
|
|
15
|
+
export declare function shouldSelfUpdate(changedFiles: readonly string[], selfUpdatePaths: readonly string[]): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Whether the orchestrator should exit for a supervisor self-update. Same as
|
|
18
|
+
* {@link shouldSelfUpdate}, except a run pinned to the last-good SHA (the
|
|
19
|
+
* supervisor passes the crash-looping SHA it is quarantining) must not
|
|
20
|
+
* self-update back into that quarantined commit: while `origin/<branch>` still
|
|
21
|
+
* points at it, stay on the good code.
|
|
22
|
+
*/
|
|
23
|
+
export declare function shouldExitForSelfUpdate(opts: {
|
|
24
|
+
changedFiles: readonly string[];
|
|
25
|
+
selfUpdatePaths: readonly string[];
|
|
26
|
+
/** Current `origin/<defaultBranch>` SHA. */
|
|
27
|
+
originSha: string;
|
|
28
|
+
/** The crash-looping SHA the supervisor is avoiding, if any. */
|
|
29
|
+
quarantinedSha?: string | null;
|
|
30
|
+
}): boolean;
|
|
31
|
+
/** Crash-loop bookkeeping, persisted across container restarts on /data/state. */
|
|
32
|
+
export type CrashLoopState = {
|
|
33
|
+
/** SHA that last ran healthily — the fallback target. */
|
|
34
|
+
lastGoodSha: string | null;
|
|
35
|
+
/** SHA currently accumulating fast-crash counts (or quarantined as bad). */
|
|
36
|
+
failingSha: string | null;
|
|
37
|
+
/** Consecutive fast crashes recorded for `failingSha`. */
|
|
38
|
+
failureCount: number;
|
|
39
|
+
};
|
|
40
|
+
export declare const INITIAL_CRASH_LOOP_STATE: CrashLoopState;
|
|
41
|
+
/**
|
|
42
|
+
* Whether the supervisor should abandon `target` and re-run the last known-good
|
|
43
|
+
* SHA — true only once `target` has fast-crashed `threshold` times and a
|
|
44
|
+
* *different* good SHA exists to fall back to (if the good SHA is `target`
|
|
45
|
+
* itself, falling back changes nothing, so we keep retrying `target`).
|
|
46
|
+
*/
|
|
47
|
+
export declare function shouldFallBack(target: string, state: CrashLoopState, threshold?: number): boolean;
|
|
48
|
+
/** The SHA the supervisor should check out and run this iteration. */
|
|
49
|
+
export declare function chooseRunSha(target: string, state: CrashLoopState, threshold?: number): string;
|
|
50
|
+
export type RunOutcome = {
|
|
51
|
+
/** SHA that was run. */
|
|
52
|
+
sha: string;
|
|
53
|
+
/** Orchestrator exit code. */
|
|
54
|
+
exitCode: number;
|
|
55
|
+
/** Seconds the run survived before exiting. */
|
|
56
|
+
elapsedSeconds: number;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* A run is healthy — its code booted and worked — if it self-updated, exited
|
|
60
|
+
* cleanly, or survived the healthy window before exiting. Only a fast non-zero,
|
|
61
|
+
* non-self-update exit is a crash that counts toward the fallback threshold.
|
|
62
|
+
*/
|
|
63
|
+
export declare function isHealthyRun(outcome: RunOutcome, healthySeconds?: number): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Fold a completed run into the crash-loop state. A healthy run becomes the new
|
|
66
|
+
* last-good, but a quarantine of a *different* crash-looping SHA is preserved
|
|
67
|
+
* (a fallback run of the good SHA must not clear the bad SHA's record). A fast
|
|
68
|
+
* crash increments the count for its SHA, resetting when the failing SHA moves.
|
|
69
|
+
*/
|
|
70
|
+
export declare function recordRun(state: CrashLoopState, outcome: RunOutcome, healthySeconds?: number): CrashLoopState;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Pure self-update + crash-loop decisions. The container's shell supervisor
|
|
2
|
+
// stays dumb; these functions are the tested specification it mirrors.
|
|
3
|
+
//
|
|
4
|
+
// - shouldSelfUpdate / shouldExitForSelfUpdate — after each cycle's fetch,
|
|
5
|
+
// did Phoebe's own code move on the default branch? If so the orchestrator
|
|
6
|
+
// exits with SELF_UPDATE_EXIT_CODE and the supervisor reinstalls + re-execs.
|
|
7
|
+
// - chooseRunSha / recordRun — the daemon's last-good crash-loop fallback:
|
|
8
|
+
// if a freshly-pulled SHA keeps dying on startup, pin to the last SHA that
|
|
9
|
+
// ran healthily until the default branch advances past the bad one.
|
|
10
|
+
//
|
|
11
|
+
// The supervisor mirrors this logic in POSIX sh (container/supervisor.sh)
|
|
12
|
+
// rather than calling it, so the fallback survives the very failure it guards
|
|
13
|
+
// against — a bad pull that makes this TypeScript itself fail to run. Keep the
|
|
14
|
+
// two in sync; the tests here are the reference semantics.
|
|
15
|
+
/** Deliberate "I need a reinstall + re-exec" exit code, watched by container/supervisor.sh. */
|
|
16
|
+
export const SELF_UPDATE_EXIT_CODE = 42;
|
|
17
|
+
/** Consecutive fast crashes of a freshly-pulled SHA before the daemon falls
|
|
18
|
+
* back to the last SHA that ran healthily. Mirrored in container/supervisor.sh. */
|
|
19
|
+
export const CRASH_LOOP_THRESHOLD = 3;
|
|
20
|
+
/** Seconds an orchestrator run must survive to count as healthy: a startup
|
|
21
|
+
* crash from a bad pull dies well within this, a transient runtime error does
|
|
22
|
+
* not. Mirrored in container/supervisor.sh. */
|
|
23
|
+
export const HEALTHY_RUN_SECONDS = 60;
|
|
24
|
+
/**
|
|
25
|
+
* Whether any changed file means Phoebe's own code (or the lockfile behind its
|
|
26
|
+
* dependencies) moved. `selfUpdatePaths` entries ending in `/` match as
|
|
27
|
+
* directory prefixes; other entries match exactly.
|
|
28
|
+
*/
|
|
29
|
+
export function shouldSelfUpdate(changedFiles, selfUpdatePaths) {
|
|
30
|
+
return changedFiles.some((file) => selfUpdatePaths.some((path) => (path.endsWith("/") ? file.startsWith(path) : file === path)));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Whether the orchestrator should exit for a supervisor self-update. Same as
|
|
34
|
+
* {@link shouldSelfUpdate}, except a run pinned to the last-good SHA (the
|
|
35
|
+
* supervisor passes the crash-looping SHA it is quarantining) must not
|
|
36
|
+
* self-update back into that quarantined commit: while `origin/<branch>` still
|
|
37
|
+
* points at it, stay on the good code.
|
|
38
|
+
*/
|
|
39
|
+
export function shouldExitForSelfUpdate(opts) {
|
|
40
|
+
if (opts.quarantinedSha && opts.originSha === opts.quarantinedSha)
|
|
41
|
+
return false;
|
|
42
|
+
return shouldSelfUpdate(opts.changedFiles, opts.selfUpdatePaths);
|
|
43
|
+
}
|
|
44
|
+
export const INITIAL_CRASH_LOOP_STATE = {
|
|
45
|
+
lastGoodSha: null,
|
|
46
|
+
failingSha: null,
|
|
47
|
+
failureCount: 0,
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Whether the supervisor should abandon `target` and re-run the last known-good
|
|
51
|
+
* SHA — true only once `target` has fast-crashed `threshold` times and a
|
|
52
|
+
* *different* good SHA exists to fall back to (if the good SHA is `target`
|
|
53
|
+
* itself, falling back changes nothing, so we keep retrying `target`).
|
|
54
|
+
*/
|
|
55
|
+
export function shouldFallBack(target, state, threshold = CRASH_LOOP_THRESHOLD) {
|
|
56
|
+
return (state.failingSha === target &&
|
|
57
|
+
state.failureCount >= threshold &&
|
|
58
|
+
state.lastGoodSha !== null &&
|
|
59
|
+
state.lastGoodSha !== target);
|
|
60
|
+
}
|
|
61
|
+
/** The SHA the supervisor should check out and run this iteration. */
|
|
62
|
+
export function chooseRunSha(target, state, threshold = CRASH_LOOP_THRESHOLD) {
|
|
63
|
+
return shouldFallBack(target, state, threshold) ? state.lastGoodSha : target;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A run is healthy — its code booted and worked — if it self-updated, exited
|
|
67
|
+
* cleanly, or survived the healthy window before exiting. Only a fast non-zero,
|
|
68
|
+
* non-self-update exit is a crash that counts toward the fallback threshold.
|
|
69
|
+
*/
|
|
70
|
+
export function isHealthyRun(outcome, healthySeconds = HEALTHY_RUN_SECONDS) {
|
|
71
|
+
return (outcome.exitCode === SELF_UPDATE_EXIT_CODE ||
|
|
72
|
+
outcome.exitCode === 0 ||
|
|
73
|
+
outcome.elapsedSeconds >= healthySeconds);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Fold a completed run into the crash-loop state. A healthy run becomes the new
|
|
77
|
+
* last-good, but a quarantine of a *different* crash-looping SHA is preserved
|
|
78
|
+
* (a fallback run of the good SHA must not clear the bad SHA's record). A fast
|
|
79
|
+
* crash increments the count for its SHA, resetting when the failing SHA moves.
|
|
80
|
+
*/
|
|
81
|
+
export function recordRun(state, outcome, healthySeconds = HEALTHY_RUN_SECONDS) {
|
|
82
|
+
if (isHealthyRun(outcome, healthySeconds)) {
|
|
83
|
+
const stillQuarantining = state.failingSha !== null && state.failingSha !== outcome.sha;
|
|
84
|
+
return {
|
|
85
|
+
lastGoodSha: outcome.sha,
|
|
86
|
+
failingSha: stillQuarantining ? state.failingSha : null,
|
|
87
|
+
failureCount: stillQuarantining ? state.failureCount : 0,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (state.failingSha === outcome.sha) {
|
|
91
|
+
return { ...state, failureCount: state.failureCount + 1 };
|
|
92
|
+
}
|
|
93
|
+
return { lastGoodSha: state.lastGoodSha, failingSha: outcome.sha, failureCount: 1 };
|
|
94
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "phoebe-agent",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Phoebe — an AFK coding agent: a configurable engine that works a GitHub issue tracker one ticket at a time, distributed as a pinned CLI.",
|
|
6
|
+
"homepage": "https://github.com/JesusFilm/phoebe#readme",
|
|
7
|
+
"bugs": "https://github.com/JesusFilm/phoebe/issues",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/JesusFilm/phoebe.git"
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"phoebe": "./dist/src/cli.js",
|
|
15
|
+
"phoebe-agent": "./dist/src/cli.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"prompts",
|
|
20
|
+
"templates"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "./dist/src/index.js",
|
|
24
|
+
"types": "./dist/src/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/src/index.d.ts",
|
|
28
|
+
"import": "./dist/src/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"ready": "vp check && vp run -r lint && vp run -r typecheck && vp run -r test && vp run -r build",
|
|
34
|
+
"check": "vp check",
|
|
35
|
+
"lint": "vp lint",
|
|
36
|
+
"typecheck": "vp exec tsc --noEmit",
|
|
37
|
+
"test": "vp test",
|
|
38
|
+
"build": "vp exec tsc -p tsconfig.build.json",
|
|
39
|
+
"changeset": "changeset",
|
|
40
|
+
"version-packages": "changeset version",
|
|
41
|
+
"release": "vp run build && changeset publish"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@changesets/cli": "^2.31.1",
|
|
45
|
+
"@types/node": "^22.13.0",
|
|
46
|
+
"typescript": "^5.7.0",
|
|
47
|
+
"vite-plus": "^0.2.0"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=22.13.0"
|
|
51
|
+
},
|
|
52
|
+
"packageManager": "pnpm@11.2.2"
|
|
53
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Context
|
|
2
|
+
|
|
3
|
+
## Assigned task
|
|
4
|
+
|
|
5
|
+
Phoebe's host detected that **PR #{{PR_NUMBER}}** (`{{PR_BRANCH}}`) has failing CI on its current head. Your job is to fix the failures, verify locally, and push — or leave a PR comment if you cannot fix them.
|
|
6
|
+
|
|
7
|
+
!`gh pr view {{PR_NUMBER}} --json number,title,body,headRefName,baseRefName`
|
|
8
|
+
|
|
9
|
+
## Failing checks
|
|
10
|
+
|
|
11
|
+
{{FAILING_CHECKS}}
|
|
12
|
+
|
|
13
|
+
# Task
|
|
14
|
+
|
|
15
|
+
You are Phoebe — fixing **failing CI** on an existing PR branch in this repository.
|
|
16
|
+
|
|
17
|
+
**Before anything else, read `AGENTS.md` at the repo root, if present.** It is the single source of project guidance — toolchain, conventions, and any compliance requirements — and it overrides your defaults.
|
|
18
|
+
|
|
19
|
+
## Workflow
|
|
20
|
+
|
|
21
|
+
1. **Investigate** — pull failure logs for each failing check:
|
|
22
|
+
```
|
|
23
|
+
gh run list --branch {{PR_BRANCH}} --limit 5
|
|
24
|
+
gh run view <run-id> --log-failed
|
|
25
|
+
```
|
|
26
|
+
2. **Reproduce** — run the same gates locally: `{{CHECK_COMMAND}}` and `{{TEST_COMMAND}}` (or `{{READY_COMMAND}}` if the project ships an all-in-one gate).
|
|
27
|
+
3. **Fix** — make the smallest correct change that resolves the failures.
|
|
28
|
+
4. **Verify** — re-run `{{CHECK_COMMAND}}` and `{{TEST_COMMAND}}` and fix any remaining failures.
|
|
29
|
+
5. **Commit** — one or more commits with the `Phoebe:` prefix. Do **not** force-push.
|
|
30
|
+
6. **Push** — `git push origin {{PR_BRANCH}}`.
|
|
31
|
+
7. **Flaky escape hatch** — if the failure does not reproduce locally and looks environmental or flaky, you may instead:
|
|
32
|
+
- `gh run rerun --failed` (once)
|
|
33
|
+
- Comment on the PR explaining why no code change was made:
|
|
34
|
+
```
|
|
35
|
+
gh pr comment {{PR_NUMBER}} --body "<explanation>"
|
|
36
|
+
```
|
|
37
|
+
8. **Give up** — only if you cannot fix or rerun, leave a PR comment explaining what you tried:
|
|
38
|
+
```
|
|
39
|
+
gh pr comment {{PR_NUMBER}} --body "<explanation>"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Rules
|
|
43
|
+
|
|
44
|
+
- Work on **this PR only** (#{{PR_NUMBER}}). Do not pick up issues or other PRs.
|
|
45
|
+
- Do not leave commented-out code or TODO comments in committed code.
|
|
46
|
+
- Never force-push (`git push --force`).
|
|
47
|
+
- If you are blocked, comment on the PR and finish — do not push a broken fix.
|
|
48
|
+
|
|
49
|
+
# Done
|
|
50
|
+
|
|
51
|
+
When CI is fixed and pushed, or you have commented (rerun or give-up), output:
|
|
52
|
+
|
|
53
|
+
<promise>COMPLETE</promise>
|