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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +55 -0
  3. package/dist/phoebe.config.d.ts +2 -0
  4. package/dist/phoebe.config.js +43 -0
  5. package/dist/src/agent-env.d.ts +6 -0
  6. package/dist/src/agent-env.js +24 -0
  7. package/dist/src/cli.d.ts +25 -0
  8. package/dist/src/cli.js +161 -0
  9. package/dist/src/config-schema.d.ts +163 -0
  10. package/dist/src/config-schema.js +140 -0
  11. package/dist/src/execution-gate.d.ts +9 -0
  12. package/dist/src/execution-gate.js +17 -0
  13. package/dist/src/git-model.d.ts +30 -0
  14. package/dist/src/git-model.js +71 -0
  15. package/dist/src/index.d.ts +2 -0
  16. package/dist/src/index.js +12 -0
  17. package/dist/src/init.d.ts +82 -0
  18. package/dist/src/init.js +207 -0
  19. package/dist/src/load-config.d.ts +88 -0
  20. package/dist/src/load-config.js +153 -0
  21. package/dist/src/main.d.ts +7 -0
  22. package/dist/src/main.js +1180 -0
  23. package/dist/src/orchestrator.d.ts +275 -0
  24. package/dist/src/orchestrator.js +579 -0
  25. package/dist/src/prompt.d.ts +13 -0
  26. package/dist/src/prompt.js +55 -0
  27. package/dist/src/providers/providers.d.ts +3 -0
  28. package/dist/src/providers/providers.js +210 -0
  29. package/dist/src/providers/run-agent.d.ts +34 -0
  30. package/dist/src/providers/run-agent.js +57 -0
  31. package/dist/src/providers/types.d.ts +27 -0
  32. package/dist/src/providers/types.js +6 -0
  33. package/dist/src/resolved-config.d.ts +8 -0
  34. package/dist/src/resolved-config.js +49 -0
  35. package/dist/src/supervisor-decision.d.ts +70 -0
  36. package/dist/src/supervisor-decision.js +94 -0
  37. package/package.json +53 -0
  38. package/prompts/checks-prompt.md +53 -0
  39. package/prompts/conflict-prompt.md +53 -0
  40. package/prompts/prompt.md +55 -0
  41. package/prompts/reviews-prompt.md +106 -0
  42. package/templates/.env.example +21 -0
  43. package/templates/container/Dockerfile +54 -0
  44. package/templates/container/compose.daemon.yml +16 -0
  45. package/templates/container/compose.yml +46 -0
  46. package/templates/container/supervisor.sh +50 -0
  47. 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>