phoebe-agent 0.0.0 → 0.1.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 (75) hide show
  1. package/README.md +77 -8
  2. package/bootstrap/bin.mjs +42 -0
  3. package/bootstrap/boot.ts +383 -0
  4. package/bootstrap/cli.ts +29 -0
  5. package/bootstrap/crash-loop.ts +391 -0
  6. package/bootstrap/define-config.ts +20 -0
  7. package/bootstrap/engine-source.ts +83 -0
  8. package/bootstrap/github-engine.ts +169 -0
  9. package/bootstrap/index.mjs +9 -0
  10. package/bootstrap/index.ts +24 -0
  11. package/bootstrap/materialize.mjs +53 -0
  12. package/bootstrap/reconcile.ts +314 -0
  13. package/bootstrap/spawn-engine.mjs +78 -0
  14. package/package.json +26 -24
  15. package/prompts/checks-prompt.md +21 -6
  16. package/prompts/conflict-prompt.md +12 -1
  17. package/prompts/research-prompt.md +61 -0
  18. package/src/agent-env.ts +32 -0
  19. package/src/branded.ts +19 -0
  20. package/src/cli.ts +187 -0
  21. package/src/config-schema.ts +278 -0
  22. package/src/drain.ts +74 -0
  23. package/src/execution-gate.ts +27 -0
  24. package/src/git-model.ts +113 -0
  25. package/src/init.ts +277 -0
  26. package/src/load-config.ts +168 -0
  27. package/src/main.ts +1636 -0
  28. package/src/orchestrator.ts +953 -0
  29. package/src/prompt.ts +98 -0
  30. package/src/providers/providers.ts +222 -0
  31. package/src/providers/run-agent.ts +90 -0
  32. package/src/providers/types.ts +26 -0
  33. package/src/resolved-config.ts +55 -0
  34. package/templates/.env.example +11 -5
  35. package/templates/container/Dockerfile +41 -19
  36. package/templates/container/compose.local.yml +37 -0
  37. package/templates/container/compose.yml +34 -13
  38. package/templates/phoebe.config.ts +30 -6
  39. package/dist/phoebe.config.d.ts +0 -2
  40. package/dist/phoebe.config.js +0 -43
  41. package/dist/src/agent-env.d.ts +0 -6
  42. package/dist/src/agent-env.js +0 -24
  43. package/dist/src/cli.d.ts +0 -25
  44. package/dist/src/cli.js +0 -161
  45. package/dist/src/config-schema.d.ts +0 -163
  46. package/dist/src/config-schema.js +0 -140
  47. package/dist/src/execution-gate.d.ts +0 -9
  48. package/dist/src/execution-gate.js +0 -17
  49. package/dist/src/git-model.d.ts +0 -30
  50. package/dist/src/git-model.js +0 -71
  51. package/dist/src/index.d.ts +0 -2
  52. package/dist/src/index.js +0 -12
  53. package/dist/src/init.d.ts +0 -82
  54. package/dist/src/init.js +0 -207
  55. package/dist/src/load-config.d.ts +0 -88
  56. package/dist/src/load-config.js +0 -153
  57. package/dist/src/main.d.ts +0 -7
  58. package/dist/src/main.js +0 -1180
  59. package/dist/src/orchestrator.d.ts +0 -275
  60. package/dist/src/orchestrator.js +0 -579
  61. package/dist/src/prompt.d.ts +0 -13
  62. package/dist/src/prompt.js +0 -55
  63. package/dist/src/providers/providers.d.ts +0 -3
  64. package/dist/src/providers/providers.js +0 -210
  65. package/dist/src/providers/run-agent.d.ts +0 -34
  66. package/dist/src/providers/run-agent.js +0 -57
  67. package/dist/src/providers/types.d.ts +0 -27
  68. package/dist/src/providers/types.js +0 -6
  69. package/dist/src/resolved-config.d.ts +0 -8
  70. package/dist/src/resolved-config.js +0 -49
  71. package/dist/src/supervisor-decision.d.ts +0 -70
  72. package/dist/src/supervisor-decision.js +0 -94
  73. package/templates/container/compose.daemon.yml +0 -16
  74. package/templates/container/supervisor.sh +0 -50
  75. /package/prompts/{prompt.md → issues-prompt.md} +0 -0
package/src/prompt.ts ADDED
@@ -0,0 +1,98 @@
1
+ // Prompt template rendering: {{KEY}} argument substitution plus !`command`
2
+ // shell expansion, executed in the work unit's worktree. The marker trick is
3
+ // ported from Sandcastle's PromptPreprocessor: shell blocks present in the raw
4
+ // template are marked *before* argument substitution, so `!`...`` patterns
5
+ // arriving via substituted values are treated as data, never executed.
6
+ //
7
+ // Prompt file paths (`config.promptFiles.*`) resolve against the runtime root
8
+ // (process cwd) — the consumer checkout on the host, `/etc/phoebe` in the
9
+ // container where compose mounts `phoebe.config.ts` and `prompts/`. They do
10
+ // not walk the installed package; `phoebe init` copies shipped prompts into
11
+ // the runtime root for that reason.
12
+
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { isAbsolute, resolve } from "node:path";
15
+ import type { PhoebeConfig } from "./config-schema.ts";
16
+
17
+ export type PromptArgs = Record<string, string>;
18
+
19
+ /**
20
+ * Resolve a `promptFiles.*` path against the runtime root. Absolute paths are
21
+ * used as-is; relative paths join to `runtimeRoot`. Throws when the file is
22
+ * missing — never falls back into the installed package tree.
23
+ */
24
+ export function resolvePromptFile(promptPath: string, runtimeRoot: string): string {
25
+ const absolute = isAbsolute(promptPath) ? promptPath : resolve(runtimeRoot, promptPath);
26
+ if (!existsSync(absolute)) {
27
+ throw new Error(
28
+ `Could not find prompt file ${promptPath} (resolved to ${absolute} from runtime root ${runtimeRoot})`,
29
+ );
30
+ }
31
+ return absolute;
32
+ }
33
+
34
+ /** Read a prompt template from a path relative to (or absolute under) the runtime root. */
35
+ export function loadPromptTemplate(promptPath: string, runtimeRoot: string): string {
36
+ return readFileSync(resolvePromptFile(promptPath, runtimeRoot), "utf8");
37
+ }
38
+
39
+ /**
40
+ * The standard placeholder set every default prompt template can reference —
41
+ * derived once per run from the resolved config so callers can retarget the
42
+ * toolchain by editing `phoebe.config.ts` alone. Per-callsite args
43
+ * (`ISSUE_NUMBER`, `PR_NUMBER`, …) are merged on top by `runAgentInWorktree`.
44
+ */
45
+ export function buildDefaultPromptArgs(config: PhoebeConfig): PromptArgs {
46
+ return {
47
+ INSTALL_COMMAND: config.installCommand,
48
+ CHECK_COMMAND: config.checkCommand,
49
+ TEST_COMMAND: config.testCommand,
50
+ READY_COMMAND: config.readyCommand,
51
+ DEFAULT_BRANCH: config.defaultBranch,
52
+ BRANCH_PREFIX: config.branchPrefix,
53
+ READY_LABEL: config.readyLabel,
54
+ RESEARCH_LABEL: config.researchLabel,
55
+ PROCESSING_LABEL: config.processingLabel,
56
+ REVIEWS_SUCCESS_HEADING: config.reviewsSuccessHeading,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Marker inserted between `!` and the opening backtick for shell blocks that
62
+ * appear in the raw template. Only marked blocks are executed.
63
+ */
64
+ const SHELL_BLOCK_MARKER = "\x01";
65
+
66
+ const SHELL_BLOCK_PATTERN = /!`([^`]+)`/g;
67
+ const MARKED_SHELL_BLOCK_PATTERN = new RegExp(`!${SHELL_BLOCK_MARKER}\`([^\`]+)\``, "g");
68
+ const PLACEHOLDER_PATTERN = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
69
+
70
+ export function substitutePromptArgs(template: string, args: PromptArgs): string {
71
+ const marked = template.replace(SHELL_BLOCK_PATTERN, (_m, cmd: string) => {
72
+ return `!${SHELL_BLOCK_MARKER}\`${cmd}\``;
73
+ });
74
+ return marked.replace(PLACEHOLDER_PATTERN, (match, key: string) => {
75
+ const value = args[key];
76
+ if (value === undefined) {
77
+ throw new Error(`Prompt placeholder {{${key}}} has no value.`);
78
+ }
79
+ return value;
80
+ });
81
+ }
82
+
83
+ /** Execute marked shell blocks and splice their trimmed stdout into the prompt. */
84
+ export function expandShellBlocks(prompt: string, execShell: (command: string) => string): string {
85
+ return prompt
86
+ .replace(MARKED_SHELL_BLOCK_PATTERN, (_m, command: string) => {
87
+ return execShell(command).trimEnd();
88
+ })
89
+ .replaceAll(SHELL_BLOCK_MARKER, "");
90
+ }
91
+
92
+ export function renderPrompt(
93
+ template: string,
94
+ args: PromptArgs,
95
+ execShell: (command: string) => string,
96
+ ): string {
97
+ return expandShellBlocks(substitutePromptArgs(template, args), execShell);
98
+ }
@@ -0,0 +1,222 @@
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
+
6
+ import type { AgentCommand, AgentEvent, Provider } from "./types.ts";
7
+ import type { ProviderName } from "../config-schema.ts";
8
+
9
+ /** Maps allowlisted tool names to the input field carrying the display arg. */
10
+ const TOOL_ARG_FIELDS: Record<string, string> = {
11
+ Bash: "command",
12
+ WebSearch: "query",
13
+ WebFetch: "url",
14
+ Agent: "description",
15
+ };
16
+
17
+ /**
18
+ * Claude Code and Cursor share the Claude stream-json line schema:
19
+ * `assistant` messages with text/tool_use content blocks, plus a terminal
20
+ * `result` event.
21
+ */
22
+ const parseClaudeStreamLine = (line: string): AgentEvent[] => {
23
+ if (!line.startsWith("{")) return [];
24
+ try {
25
+ const obj = JSON.parse(line) as Record<string, unknown>;
26
+ const message = obj["message"] as { content?: unknown } | undefined;
27
+ if (obj["type"] === "assistant" && Array.isArray(message?.content)) {
28
+ const events: AgentEvent[] = [];
29
+ const texts: string[] = [];
30
+ for (const block of message.content as Array<{
31
+ type: string;
32
+ text?: string;
33
+ name?: string;
34
+ input?: Record<string, unknown>;
35
+ }>) {
36
+ if (block.type === "text" && typeof block.text === "string") {
37
+ texts.push(block.text);
38
+ } else if (
39
+ block.type === "tool_use" &&
40
+ typeof block.name === "string" &&
41
+ block.input !== undefined
42
+ ) {
43
+ const argField = TOOL_ARG_FIELDS[block.name];
44
+ if (argField === undefined) continue;
45
+ const argValue = block.input[argField];
46
+ if (typeof argValue !== "string") continue;
47
+ if (texts.length > 0) {
48
+ events.push({ type: "text", text: texts.join("") });
49
+ texts.length = 0;
50
+ }
51
+ events.push({ type: "tool_call", name: block.name, args: argValue });
52
+ }
53
+ }
54
+ if (texts.length > 0) {
55
+ events.push({ type: "text", text: texts.join("") });
56
+ }
57
+ return events;
58
+ }
59
+ if (obj["type"] === "result" && typeof obj["result"] === "string") {
60
+ return [{ type: "result", result: obj["result"] }];
61
+ }
62
+ } catch {
63
+ // Not valid JSON — skip.
64
+ }
65
+ return [];
66
+ };
67
+
68
+ /** Cursor additionally emits top-level `tool_call` events with per-tool shapes. */
69
+ const parseCursorStreamLine = (line: string): AgentEvent[] => {
70
+ if (!line.startsWith("{")) return [];
71
+ let obj: Record<string, unknown>;
72
+ try {
73
+ obj = JSON.parse(line) as Record<string, unknown>;
74
+ } catch {
75
+ return [];
76
+ }
77
+ if (obj["type"] !== "tool_call" || obj["subtype"] !== "started") {
78
+ return parseClaudeStreamLine(line);
79
+ }
80
+ const toolCall = obj["tool_call"];
81
+ if (!toolCall || typeof toolCall !== "object") return [];
82
+ const tc = toolCall as Record<string, unknown>;
83
+
84
+ const readToolCall = tc["readToolCall"] as { args?: { path?: unknown } } | undefined;
85
+ if (readToolCall?.args && typeof readToolCall.args.path === "string") {
86
+ return [{ type: "tool_call", name: "Read", args: readToolCall.args.path }];
87
+ }
88
+ const writeToolCall = tc["writeToolCall"] as { args?: { path?: unknown } } | undefined;
89
+ if (writeToolCall?.args && typeof writeToolCall.args.path === "string") {
90
+ return [{ type: "tool_call", name: "Write", args: writeToolCall.args.path }];
91
+ }
92
+ const fn = tc["function"] as { name?: unknown; arguments?: unknown } | undefined;
93
+ if (fn && typeof fn.name === "string") {
94
+ const rawArgs = typeof fn.arguments === "string" ? fn.arguments : "";
95
+ if (rawArgs) {
96
+ try {
97
+ const parsedArgs = JSON.parse(rawArgs) as Record<string, unknown>;
98
+ if (typeof parsedArgs["command"] === "string") {
99
+ return [{ type: "tool_call", name: "Bash", args: parsedArgs["command"] }];
100
+ }
101
+ } catch {
102
+ // Fall through to the raw arguments string.
103
+ }
104
+ return [{ type: "tool_call", name: fn.name, args: rawArgs }];
105
+ }
106
+ return [{ type: "tool_call", name: fn.name, args: "" }];
107
+ }
108
+ return [];
109
+ };
110
+
111
+ const extractErrorMessage = (obj: Record<string, unknown>): string | undefined => {
112
+ const err = obj["error"];
113
+ if (typeof err === "string") return err;
114
+ if (typeof err === "object" && err !== null) {
115
+ const e = err as { message?: unknown; data?: { message?: unknown } };
116
+ if (typeof e.message === "string") return e.message;
117
+ if (typeof e.data?.message === "string") return e.data.message;
118
+ }
119
+ if (typeof obj["message"] === "string") return obj["message"];
120
+ return undefined;
121
+ };
122
+
123
+ /** Codex `exec --json` emits item/turn events rather than message blocks. */
124
+ const parseCodexStreamLine = (line: string): AgentEvent[] => {
125
+ if (!line.startsWith("{")) return [];
126
+ try {
127
+ const obj = JSON.parse(line) as Record<string, unknown>;
128
+ const item = obj["item"] as { type?: string; text?: unknown; command?: unknown } | undefined;
129
+ if (
130
+ obj["type"] === "item.completed" &&
131
+ item?.type === "agent_message" &&
132
+ typeof item.text === "string"
133
+ ) {
134
+ return [
135
+ { type: "text", text: item.text },
136
+ { type: "result", result: item.text },
137
+ ];
138
+ }
139
+ if (
140
+ obj["type"] === "item.started" &&
141
+ item?.type === "command_execution" &&
142
+ typeof item.command === "string"
143
+ ) {
144
+ return [{ type: "tool_call", name: "Bash", args: item.command }];
145
+ }
146
+ // Codex reports auth/rate-limit/API errors on stdout, not stderr.
147
+ if (obj["type"] === "error") {
148
+ const msg = extractErrorMessage(obj);
149
+ return msg ? [{ type: "result", result: msg }] : [];
150
+ }
151
+ } catch {
152
+ // Not valid JSON — skip.
153
+ }
154
+ return [];
155
+ };
156
+
157
+ /**
158
+ * The Cursor CLI takes the prompt as a positional argv argument (stdin is not
159
+ * documented for prompt delivery). Linux caps a single argument at ~128 KiB;
160
+ * stay under it so users get a clear error instead of spawn E2BIG.
161
+ */
162
+ const CURSOR_PROMPT_MAX_BYTES = 120 * 1024;
163
+
164
+ const cursor: Provider = {
165
+ name: "cursor",
166
+ buildCommand({ prompt, model }): AgentCommand {
167
+ const bytes = Buffer.byteLength(prompt, "utf8");
168
+ if (bytes > CURSOR_PROMPT_MAX_BYTES) {
169
+ throw new Error(
170
+ `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.`,
171
+ );
172
+ }
173
+ return {
174
+ argv: [
175
+ "agent",
176
+ "--print",
177
+ "--output-format",
178
+ "stream-json",
179
+ "--model",
180
+ model,
181
+ "--force",
182
+ prompt,
183
+ ],
184
+ };
185
+ },
186
+ parseStreamLine: parseCursorStreamLine,
187
+ };
188
+
189
+ const claude: Provider = {
190
+ name: "claude",
191
+ buildCommand({ prompt, model }): AgentCommand {
192
+ return {
193
+ argv: [
194
+ "claude",
195
+ "--print",
196
+ "--verbose",
197
+ "--dangerously-skip-permissions",
198
+ "--output-format",
199
+ "stream-json",
200
+ "--model",
201
+ model,
202
+ "-p",
203
+ "-",
204
+ ],
205
+ stdin: prompt,
206
+ };
207
+ },
208
+ parseStreamLine: parseClaudeStreamLine,
209
+ };
210
+
211
+ const codex: Provider = {
212
+ name: "codex",
213
+ buildCommand({ prompt, model }): AgentCommand {
214
+ return {
215
+ argv: ["codex", "exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "-m", model],
216
+ stdin: prompt,
217
+ };
218
+ },
219
+ parseStreamLine: parseCodexStreamLine,
220
+ };
221
+
222
+ export const PROVIDERS: Record<ProviderName, Provider> = { cursor, claude, codex };
@@ -0,0 +1,90 @@
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
+
5
+ import { spawn as nodeSpawn } from "node:child_process";
6
+ import type { Provider } from "./types.ts";
7
+
8
+ export type AgentRunResult = {
9
+ exitCode: number;
10
+ /** Last `result` event the provider emitted, if any. */
11
+ resultText: string;
12
+ };
13
+
14
+ /** Minimal child-process surface so tests can inject a fake spawn. */
15
+ export type AgentChild = {
16
+ stdout: { on(event: "data", listener: (chunk: Buffer | string) => void): unknown };
17
+ stderr: { on(event: "data", listener: (chunk: Buffer | string) => void): unknown };
18
+ stdin: { write(data: string): unknown; end(): unknown };
19
+ on(event: "error", listener: (err: Error) => void): unknown;
20
+ on(event: "close", listener: (code: number | null) => void): unknown;
21
+ };
22
+
23
+ export type SpawnAgent = (
24
+ file: string,
25
+ args: readonly string[],
26
+ opts: { cwd: string; env: Record<string, string> },
27
+ ) => AgentChild;
28
+
29
+ const defaultSpawn: SpawnAgent = (file, args, opts) =>
30
+ nodeSpawn(file, args, { ...opts, stdio: ["pipe", "pipe", "pipe"] });
31
+
32
+ export async function runAgent(opts: {
33
+ provider: Provider;
34
+ model: string;
35
+ prompt: string;
36
+ cwd: string;
37
+ env: Record<string, string>;
38
+ spawn?: SpawnAgent;
39
+ log?: (line: string) => void;
40
+ }): Promise<AgentRunResult> {
41
+ const { provider, model, prompt, cwd, env } = opts;
42
+ const spawn = opts.spawn ?? defaultSpawn;
43
+ const log = opts.log ?? ((line: string) => console.log(line));
44
+
45
+ const command = provider.buildCommand({ prompt, model });
46
+ const [file, ...args] = command.argv;
47
+ if (!file) {
48
+ throw new Error(`Provider "${provider.name}" built an empty command.`);
49
+ }
50
+
51
+ return new Promise<AgentRunResult>((resolve, reject) => {
52
+ const child = spawn(file, args, { cwd, env });
53
+ let resultText = "";
54
+ let stdoutBuffer = "";
55
+
56
+ const handleLine = (line: string): void => {
57
+ for (const event of provider.parseStreamLine(line)) {
58
+ if (event.type === "text") {
59
+ const text = event.text.trim();
60
+ if (text) log(`[${provider.name}] ${text}`);
61
+ } else if (event.type === "tool_call") {
62
+ log(`[${provider.name}] ${event.name}: ${event.args}`);
63
+ } else {
64
+ resultText = event.result;
65
+ }
66
+ }
67
+ };
68
+
69
+ child.stdout.on("data", (chunk) => {
70
+ stdoutBuffer += chunk.toString();
71
+ const lines = stdoutBuffer.split("\n");
72
+ stdoutBuffer = lines.pop() ?? "";
73
+ for (const line of lines) handleLine(line);
74
+ });
75
+ child.stderr.on("data", (chunk) => {
76
+ const text = chunk.toString().trim();
77
+ if (text) log(`[${provider.name}:stderr] ${text}`);
78
+ });
79
+ child.on("error", reject);
80
+ child.on("close", (code) => {
81
+ if (stdoutBuffer) handleLine(stdoutBuffer);
82
+ resolve({ exitCode: code ?? 1, resultText });
83
+ });
84
+
85
+ if (command.stdin !== undefined) {
86
+ child.stdin.write(command.stdin);
87
+ }
88
+ child.stdin.end();
89
+ });
90
+ }
@@ -0,0 +1,26 @@
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
+
7
+ import type { ProviderName } from "../config-schema.ts";
8
+
9
+ /** A ready-to-spawn CLI invocation. `argv[0]` is the binary; no shell involved. */
10
+ export type AgentCommand = {
11
+ readonly argv: readonly string[];
12
+ /** When set, piped to the child's stdin (large prompts don't fit argv). */
13
+ readonly stdin?: string;
14
+ };
15
+
16
+ /** Events parsed from one line of the agent's JSONL output stream. */
17
+ export type AgentEvent =
18
+ | { type: "text"; text: string }
19
+ | { type: "result"; result: string }
20
+ | { type: "tool_call"; name: string; args: string };
21
+
22
+ export type Provider = {
23
+ readonly name: ProviderName;
24
+ buildCommand(opts: { prompt: string; model: string }): AgentCommand;
25
+ parseStreamLine(line: string): AgentEvent[];
26
+ };
@@ -0,0 +1,55 @@
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
+
13
+ import type { PhoebeConfig } from "./config-schema.ts";
14
+
15
+ let resolved: PhoebeConfig | null = null;
16
+
17
+ /** Install the resolved config. Idempotent — later calls replace the prior value. */
18
+ export function setResolvedConfig(next: PhoebeConfig): void {
19
+ resolved = next;
20
+ }
21
+
22
+ /** For test isolation: clear the installed config back to the "not installed" state. */
23
+ export function clearResolvedConfig(): void {
24
+ resolved = null;
25
+ }
26
+
27
+ /** Whether a resolved config has been installed. */
28
+ export function hasResolvedConfig(): boolean {
29
+ return resolved !== null;
30
+ }
31
+
32
+ export const config: PhoebeConfig = new Proxy({} as PhoebeConfig, {
33
+ get(_target, prop) {
34
+ if (resolved === null) {
35
+ throw new Error(
36
+ `Attempted to read config.${String(prop)} before the resolved config was installed. ` +
37
+ `The Phoebe CLI (src/cli.ts) installs it after loading phoebe.config.ts; ` +
38
+ `tests install a sample via src/test-setup.ts (wired from vite.config.ts).`,
39
+ );
40
+ }
41
+ return resolved[prop as keyof PhoebeConfig];
42
+ },
43
+ ownKeys() {
44
+ if (resolved === null) return [];
45
+ return Reflect.ownKeys(resolved);
46
+ },
47
+ getOwnPropertyDescriptor(_target, prop) {
48
+ if (resolved === null) return undefined;
49
+ return Reflect.getOwnPropertyDescriptor(resolved, prop);
50
+ },
51
+ has(_target, prop) {
52
+ if (resolved === null) return false;
53
+ return prop in resolved;
54
+ },
55
+ });
@@ -1,8 +1,12 @@
1
1
  # Phoebe environment — scaffolded by `phoebe init`. Copy to `.env` and fill in.
2
2
  #
3
- # GH_TOKEN is required; every `gh` call in the engine reads it. The provider
4
- # key is required only for whichever agent you actually run set the one that
5
- # matches your `defaultProvider` (or `PHOEBE_AGENT` at runtime).
3
+ # GH_TOKEN is required; every `gh` call in the engine reads it, and `phoebe
4
+ # boot` uses it to check the engine out from GitHub. The provider key is
5
+ # required only for whichever agent you actually run — set the one that matches
6
+ # your `defaultProvider` (or `PHOEBE_AGENT` at runtime).
7
+ #
8
+ # Which engine version runs is NOT set here: it is `engine.ref` in
9
+ # phoebe.config.ts, which `boot` re-reads while the container runs.
6
10
 
7
11
  # --- Required ---
8
12
  GH_TOKEN=
@@ -16,6 +20,8 @@ OPENAI_KEY=
16
20
  # PHOEBE_AGENT=claude
17
21
  # PHOEBE_MODEL=claude-sonnet-4-6
18
22
  # PHOEBE_POLL_INTERVAL_MS=300000
23
+ # How often `boot` checks the config and the tracked engine ref (default 60000).
24
+ # PHOEBE_RECONCILE_INTERVAL_MS=60000
19
25
 
20
- # --- Engine version pin (compose.yml reads this) ---
21
- PHOEBE_VERSION=latest
26
+ # --- Local engine checkout (compose.local.yml only, development) ---
27
+ # PHOEBE_ENGINE_MOUNT=/path/to/phoebe
@@ -1,20 +1,29 @@
1
1
  # Phoebe runtime image — scaffolded by `phoebe init`.
2
2
  #
3
3
  # Consumer-owned: commit this file, tweak it as you need. Phoebe treats the
4
- # resulting image as both orchestrator and execution environment. The engine
5
- # itself is installed from npm (`{{CLI_BIN}}`) — the source is not vendored.
4
+ # resulting image as both orchestrator and execution environment.
5
+ #
6
+ # The image carries the *bootstrapper* (`{{CLI_BIN}}` from npm), not the engine.
7
+ # `phoebe boot` is the container's long-lived main process: it reads the mounted
8
+ # `phoebe.config.ts`, materializes the engine from the source named there
9
+ # (`engine: { source: "github", ref }` by default), execs it, and keeps
10
+ # supervising it — relaunching on a config or ref change, falling back to the
11
+ # last engine commit that ran healthily if a new one will not boot, and draining
12
+ # it gracefully on `SIGTERM`. So changing engine version is a config edit, not
13
+ # an image rebuild; this image only changes when the toolchain around it does.
6
14
  #
7
15
  # What ships in this image:
8
- # * Node.js (LTS) + git + GitHub CLI (`gh`) — the runtime the engine drives.
9
- # * A globally installed `{{CLI_BIN}}` pinned by the compose file.
10
- # * A `supervisor.sh` that keeps the engine restarted and self-updating.
16
+ # * Node.js 24 + git + GitHub CLI (`gh`) — the runtime the engine drives.
17
+ # * A globally installed `{{CLI_BIN}}` (the bootstrapper).
18
+ # * Your agent provider's CLI.
11
19
  #
12
20
  # What lives on named volumes (see compose.yml):
13
21
  # * /data/repo — Phoebe's private clone of the target repo.
14
22
  # * /data/worktrees — Per-work-unit git worktrees.
15
- # * /data/state — Lock, watermarks, logs.
23
+ # * /data/state — Lock, watermarks, logs, crash-loop record.
24
+ # * /data/engine — Engine checkouts, so a restart fetches instead of cloning.
16
25
 
17
- FROM node:22-bookworm-slim
26
+ FROM node:24-bookworm-slim
18
27
 
19
28
  # gh CLI + git + tini for signal handling; keep the layer small.
20
29
  RUN apt-get update \
@@ -30,11 +39,21 @@ RUN apt-get update \
30
39
  && apt-get install -y --no-install-recommends gh \
31
40
  && rm -rf /var/lib/apt/lists/*
32
41
 
33
- # PHOEBE_VERSION is set by the compose file so consumers can pin the engine
34
- # without editing the Dockerfile. `latest` is only a build-time default; the
35
- # compose files should always pass an explicit version.
36
- ARG PHOEBE_VERSION=latest
37
- RUN npm install -g {{CLI_BIN}}@${PHOEBE_VERSION}
42
+ # The bootstrapper. Unpinned on purpose: which *engine* you run is `engine.ref`
43
+ # in phoebe.config.ts, and this package is only the thin launcher around it.
44
+ # Pin it (`{{CLI_BIN}}@<version>`) if you want the launcher itself frozen too.
45
+ RUN npm install -g {{CLI_BIN}}
46
+
47
+ # Provider agent CLI. The engine spawns the selected provider's CLI as a child
48
+ # (`agent` for cursor, `claude`, or `codex`), so the one matching your
49
+ # `defaultProvider` / `PHOEBE_AGENT` must be on PATH — otherwise the run dies
50
+ # with `spawn <cli> ENOENT`. Default below installs Cursor: its executable is
51
+ # symlinked as `agent` into ~/.local/bin, which we add to PATH. Swap or add the
52
+ # commented lines for the provider you actually run.
53
+ RUN curl -fsSL https://cursor.com/install | bash
54
+ ENV PATH="/root/.local/bin:${PATH}"
55
+ # Claude Code: RUN npm install -g @anthropic-ai/claude-code
56
+ # Codex: RUN npm install -g @openai/codex
38
57
 
39
58
  # Consumer install command, kept as an ARG so it lands in the image metadata
40
59
  # and can be inspected with `docker image inspect`. The engine re-runs the
@@ -42,13 +61,16 @@ RUN npm install -g {{CLI_BIN}}@${PHOEBE_VERSION}
42
61
  ARG PHOEBE_INSTALL_COMMAND="{{INSTALL_COMMAND}}"
43
62
  LABEL phoebe.install-command="${PHOEBE_INSTALL_COMMAND}"
44
63
 
45
- COPY supervisor.sh /usr/local/bin/phoebe-supervisor
46
- RUN chmod +x /usr/local/bin/phoebe-supervisor
64
+ # Execution-gate marker: the engine refuses to mutate a clone, launch an agent,
65
+ # or push unless `/.phoebe-container` exists (src/execution-gate.ts). Without it
66
+ # the container refuses every non-dry-run unit.
67
+ RUN touch /.phoebe-container
47
68
 
48
69
  # Compose's `command:` fully replaces `CMD` (it does not append to
49
- # `ENTRYPOINT`), so the supervisor path must live in `ENTRYPOINT` — otherwise
50
- # compose.yml's `command: ["--run-once"]` (or the daemon overlay's `command: []`)
51
- # strips the supervisor away and tini exits with no child to exec. Keeping
52
- # `CMD []` here means the compose files only ever contribute engine flags.
53
- ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/phoebe-supervisor"]
70
+ # `ENTRYPOINT`), so the whole `phoebe boot` invocation must live in
71
+ # `ENTRYPOINT` — otherwise a compose `command: ["--run-once"]` would strip the
72
+ # bootstrapper away and tini would try to exec `--run-once` as a program.
73
+ # Keeping `CMD []` here means the compose files only ever contribute engine
74
+ # flags, which `boot` forwards to the engine it launches.
75
+ ENTRYPOINT ["/usr/bin/tini", "--", "phoebe", "boot"]
54
76
  CMD []
@@ -0,0 +1,37 @@
1
+ # Local-engine overlay — scaffolded by `phoebe init`, consumer-owned.
2
+ #
3
+ # Development only. Instead of `boot` checking the engine out from GitHub, this
4
+ # runs it straight from a checkout on your host, so an edit to the engine source
5
+ # takes effect on the next launch with no rebuild and no push:
6
+ #
7
+ # PHOEBE_ENGINE_MOUNT=/path/to/phoebe \
8
+ # docker compose -f compose.yml -f compose.local.yml run --rm phoebe
9
+ #
10
+ # It only takes effect if the mounted phoebe.config.ts also says so:
11
+ #
12
+ # engine: { source: "local" }
13
+ #
14
+ # `boot` fails loudly when that field says "local" and nothing is mounted at
15
+ # /opt/phoebe-engine — a missing mount is a misconfigured container, not a
16
+ # reason to quietly fall back to GitHub.
17
+ #
18
+ # Two behaviours differ from the deployed topology, both by design:
19
+ # * No crash-loop fallback. It guards a moving github ref by pinning back to
20
+ # the last engine commit that ran healthily; a mount has no commit to pin,
21
+ # so a crashing engine here just exits.
22
+ # * The ref watch is inert — there is no remote to poll. `boot` still watches
23
+ # the config, so a config edit relaunches the engine as usual.
24
+
25
+ services:
26
+ phoebe:
27
+ environment:
28
+ # A minute is a long time to wait when you are iterating; the reconcile
29
+ # poll is one stat plus (for a github source) one ls-remote, so a tight
30
+ # interval is cheap here.
31
+ PHOEBE_RECONCILE_INTERVAL_MS: "${PHOEBE_RECONCILE_INTERVAL_MS:-10000}"
32
+ volumes:
33
+ # The engine source `boot` execs for `engine: { source: "local" }`.
34
+ # Read-only: the engine works its target clone under /data, never its own
35
+ # source. Mount the repo *root* — package.json's `"type": "module"` has to
36
+ # sit alongside the `.ts` the engine runs under Node's type-stripping.
37
+ - "${PHOEBE_ENGINE_MOUNT:?PHOEBE_ENGINE_MOUNT must point at a local engine checkout}:/opt/phoebe-engine:ro"