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
@@ -0,0 +1,27 @@
1
+ // Work-unit execution is gated to the Phoebe container. Selection logic and
2
+ // --dry-run stay host-runnable for fast iteration; anything that mutates a
3
+ // clone, launches an agent CLI, or pushes runs only where the container
4
+ // marker exists (created by the Dockerfile).
5
+
6
+ import { existsSync } from "node:fs";
7
+
8
+ export const CONTAINER_MARKER_PATH = "/.phoebe-container";
9
+
10
+ export function isInsideContainer(exists: (path: string) => boolean = existsSync): boolean {
11
+ return exists(CONTAINER_MARKER_PATH);
12
+ }
13
+
14
+ export type ExecutionDecision = "execute" | "dry-run" | "refuse";
15
+
16
+ /** Pure decision: may this process execute the selected work unit? */
17
+ export function executionDecision(opts: {
18
+ dryRun: boolean;
19
+ inContainer: boolean;
20
+ }): ExecutionDecision {
21
+ if (opts.dryRun) return "dry-run";
22
+ return opts.inContainer ? "execute" : "refuse";
23
+ }
24
+
25
+ export const EXECUTION_REFUSED_MESSAGE =
26
+ "[phoebe] Refusing to execute a work unit outside the Phoebe container. " +
27
+ "Use --dry-run to preview selection on the host, or start the container loop.";
@@ -0,0 +1,113 @@
1
+ // Origin-hub git model: a private clone owns all local git state, work units
2
+ // run in worktrees off it, finished branches push straight to origin. Every
3
+ // function takes the clone directory explicitly so tests can run against a
4
+ // temp clone.
5
+
6
+ import { execFileSync } from "node:child_process";
7
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { asSha, type BranchRef, type Sha } from "./branded.ts";
10
+
11
+ export type GitRunner = (
12
+ args: string[],
13
+ opts?: { cwd?: string; stdio?: "inherit" | "ignore" | "pipe"; timeout?: number },
14
+ ) => string;
15
+
16
+ export const defaultGit: GitRunner = (args, opts) =>
17
+ execFileSync("git", args, {
18
+ encoding: "utf8",
19
+ ...(opts?.cwd ? { cwd: opts.cwd } : {}),
20
+ ...(opts?.stdio ? { stdio: opts.stdio } : {}),
21
+ // A network git call on the supervisor's event loop (reconcile's `ls-remote`)
22
+ // must not block it forever on a hung remote — the caller bounds it.
23
+ ...(opts?.timeout ? { timeout: opts.timeout } : {}),
24
+ }) as unknown as string;
25
+
26
+ /** Clone the repo into `repoDir` unless a clone already exists there. */
27
+ export function ensureClone(
28
+ opts: { repoUrl: string; repoDir: string },
29
+ git: GitRunner = defaultGit,
30
+ ): void {
31
+ if (existsSync(join(opts.repoDir, ".git"))) return;
32
+ mkdirSync(opts.repoDir, { recursive: true });
33
+ git(["clone", opts.repoUrl, opts.repoDir], { stdio: "inherit" });
34
+ }
35
+
36
+ export function fetchOrigin(repoDir: string, git: GitRunner = defaultGit): void {
37
+ git(["fetch", "origin"], { cwd: repoDir, stdio: "inherit" });
38
+ }
39
+
40
+ export function originBranchSha(
41
+ repoDir: string,
42
+ branch: BranchRef,
43
+ git: GitRunner = defaultGit,
44
+ ): Sha {
45
+ return asSha(git(["rev-parse", `origin/${branch}`], { cwd: repoDir }).trim());
46
+ }
47
+
48
+ /** Filesystem-safe worktree directory name for a branch. */
49
+ export function worktreeDirForBranch(worktreesDir: string, branch: BranchRef): string {
50
+ return join(worktreesDir, branch.toLowerCase().replace(/[^a-z0-9]/g, "-"));
51
+ }
52
+
53
+ /** Create a worktree on a (possibly new) branch reset to `baseRef`. */
54
+ export function addWorktreeForNewBranch(
55
+ opts: { repoDir: string; worktreeDir: string; branch: BranchRef; baseRef: string },
56
+ git: GitRunner = defaultGit,
57
+ ): void {
58
+ git(["worktree", "add", "-B", opts.branch, opts.worktreeDir, opts.baseRef], {
59
+ cwd: opts.repoDir,
60
+ stdio: "inherit",
61
+ });
62
+ }
63
+
64
+ /** Create a worktree on an existing branch (local first, then origin/<branch>). */
65
+ export function addWorktreeForExistingBranch(
66
+ opts: { repoDir: string; worktreeDir: string; branch: BranchRef },
67
+ git: GitRunner = defaultGit,
68
+ ): void {
69
+ try {
70
+ git(["worktree", "add", opts.worktreeDir, opts.branch], {
71
+ cwd: opts.repoDir,
72
+ stdio: "inherit",
73
+ });
74
+ } catch {
75
+ git(["worktree", "add", "-B", opts.branch, opts.worktreeDir, `origin/${opts.branch}`], {
76
+ cwd: opts.repoDir,
77
+ stdio: "inherit",
78
+ });
79
+ }
80
+ }
81
+
82
+ export function removeWorktree(
83
+ repoDir: string,
84
+ worktreeDir: string,
85
+ git: GitRunner = defaultGit,
86
+ ): void {
87
+ try {
88
+ git(["worktree", "remove", "--force", worktreeDir], { cwd: repoDir, stdio: "ignore" });
89
+ } catch {
90
+ rmSync(worktreeDir, { recursive: true, force: true });
91
+ }
92
+ try {
93
+ git(["worktree", "prune"], { cwd: repoDir, stdio: "ignore" });
94
+ } catch {
95
+ // Best-effort.
96
+ }
97
+ }
98
+
99
+ export function commitCount(
100
+ worktreeDir: string,
101
+ range: string,
102
+ git: GitRunner = defaultGit,
103
+ ): number {
104
+ return Number(git(["rev-list", "--count", range], { cwd: worktreeDir }).trim());
105
+ }
106
+
107
+ export function pushBranch(
108
+ worktreeDir: string,
109
+ branch: BranchRef,
110
+ git: GitRunner = defaultGit,
111
+ ): void {
112
+ git(["push", "origin", branch], { cwd: worktreeDir, stdio: "inherit" });
113
+ }
package/src/init.ts ADDED
@@ -0,0 +1,277 @@
1
+ // `phoebe init` scaffolder. Given a target directory, drops the consumer-owned
2
+ // runtime into place: `phoebe.config.ts`, a `prompts/` dir with copies of the
3
+ // shipped defaults (edit-and-commit), `.env.example`, `.gitignore` entries,
4
+ // and the `container/` templates (Dockerfile, compose, and a dev-only overlay
5
+ // for running a local engine checkout). Re-runs are guarded — an existing file
6
+ // is skipped, not silently overwritten, so consumer edits are safe.
7
+ //
8
+ // The plan/render split keeps the pure logic (what files, what placeholders)
9
+ // separately testable from the fs I/O in `runInit`.
10
+
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { basename, dirname, join, resolve as resolvePath } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { CONFIG_DEFAULTS } from "./config-schema.ts";
15
+
16
+ /** Placeholder tokens rendered into every scaffolded file. */
17
+ export type TemplateParams = {
18
+ /** The consumer's install command (also written into the config). */
19
+ installCommand: string;
20
+ /** The npm package name of the CLI — normally `phoebe-agent`. */
21
+ cliBin: string;
22
+ };
23
+
24
+ export const DEFAULT_TEMPLATE_PARAMS: TemplateParams = {
25
+ installCommand: "npm ci",
26
+ cliBin: "phoebe-agent",
27
+ };
28
+
29
+ /**
30
+ * One consumer-owned output produced by init. Sources are one of:
31
+ * - `template`: a file under the shipped `templates/` tree, rendered with
32
+ * placeholder substitution.
33
+ * - `shipped-prompt`: a file under the shipped `prompts/` tree, copied
34
+ * verbatim (prompts already carry their own `{{…}}` markers that the
35
+ * engine expands at run time — do NOT substitute those here).
36
+ * - `gitignore`: an additive-merge marker; `entries` are appended to any
37
+ * existing `.gitignore`, deduped line-wise.
38
+ */
39
+ export type PlannedOutput = {
40
+ destRelPath: string;
41
+ source:
42
+ | { kind: "template"; templateRelPath: string }
43
+ | { kind: "shipped-prompt"; promptRelPath: string }
44
+ | { kind: "gitignore"; entries: readonly string[] };
45
+ };
46
+
47
+ const GITIGNORE_ENTRIES = [".env", "node_modules/"] as const;
48
+
49
+ /**
50
+ * Enumerate every file init will produce. The prompt list is derived from
51
+ * `CONFIG_DEFAULTS.promptFiles` so adding a new prompt kind to the engine
52
+ * automatically gets scaffolded — no drift between the two lists.
53
+ */
54
+ export function planInitOutputs(): PlannedOutput[] {
55
+ const promptOutputs: PlannedOutput[] = Object.values(CONFIG_DEFAULTS.promptFiles).map(
56
+ (relPath) => ({
57
+ destRelPath: relPath,
58
+ source: { kind: "shipped-prompt", promptRelPath: relPath },
59
+ }),
60
+ );
61
+ return [
62
+ {
63
+ destRelPath: "phoebe.config.ts",
64
+ source: { kind: "template", templateRelPath: "phoebe.config.ts" },
65
+ },
66
+ {
67
+ destRelPath: ".env.example",
68
+ source: { kind: "template", templateRelPath: ".env.example" },
69
+ },
70
+ {
71
+ destRelPath: "container/Dockerfile",
72
+ source: { kind: "template", templateRelPath: "container/Dockerfile" },
73
+ },
74
+ {
75
+ destRelPath: "container/compose.yml",
76
+ source: { kind: "template", templateRelPath: "container/compose.yml" },
77
+ },
78
+ {
79
+ destRelPath: "container/compose.local.yml",
80
+ source: { kind: "template", templateRelPath: "container/compose.local.yml" },
81
+ },
82
+ ...promptOutputs,
83
+ {
84
+ destRelPath: ".gitignore",
85
+ source: { kind: "gitignore", entries: GITIGNORE_ENTRIES },
86
+ },
87
+ ];
88
+ }
89
+
90
+ /**
91
+ * Substitute `{{KEY}}` tokens with the matching value. Unknown tokens throw
92
+ * so a typo in a template surfaces during tests rather than silently landing
93
+ * in a consumer's repo. Iteration is over the params (not a regex over the
94
+ * source) so a value that happens to contain a `{{…}}`-shaped substring is
95
+ * never re-scanned.
96
+ */
97
+ export function renderTemplate(source: string, params: TemplateParams): string {
98
+ let rendered = source;
99
+ for (const [key, value] of Object.entries(params) as Array<[keyof TemplateParams, string]>) {
100
+ const token = `{{${keyToToken(key)}}}`;
101
+ rendered = rendered.split(token).join(value);
102
+ }
103
+ const leftover = /\{\{([A-Z_]+)\}\}/.exec(rendered);
104
+ if (leftover) {
105
+ throw new Error(
106
+ `Template contained an unrenderable placeholder \`{{${leftover[1]}}}\` — ` +
107
+ `every {{TOKEN}} in a scaffolded file must map to a TemplateParams field.`,
108
+ );
109
+ }
110
+ return rendered;
111
+ }
112
+
113
+ function keyToToken(key: keyof TemplateParams): string {
114
+ // installCommand -> INSTALL_COMMAND, cliBin -> CLI_BIN
115
+ return key.replace(/([A-Z])/g, "_$1").toUpperCase();
116
+ }
117
+
118
+ /**
119
+ * Additive `.gitignore` merge — append any missing entries under a `# Phoebe`
120
+ * header, leaving existing lines untouched. An empty file becomes a bare list
121
+ * (no header) because there's nothing else to distinguish it from.
122
+ */
123
+ export function mergeGitignore(existing: string, entries: readonly string[]): string {
124
+ const existingLines = new Set(
125
+ existing
126
+ .split("\n")
127
+ .map((line) => line.trim())
128
+ .filter((line) => line.length > 0 && !line.startsWith("#")),
129
+ );
130
+ const missing = entries.filter((entry) => !existingLines.has(entry));
131
+ if (missing.length === 0) {
132
+ return existing;
133
+ }
134
+ if (existing.trim().length === 0) {
135
+ return `${missing.join("\n")}\n`;
136
+ }
137
+ const separator = existing.endsWith("\n") ? "" : "\n";
138
+ return `${existing}${separator}\n# Phoebe\n${missing.join("\n")}\n`;
139
+ }
140
+
141
+ export type InitReport = {
142
+ /** New files written (destination paths, relative to the target dir). */
143
+ created: string[];
144
+ /** `.gitignore` entries appended in-place (destination paths). */
145
+ updated: string[];
146
+ /** Existing files left alone (destination paths). */
147
+ skipped: string[];
148
+ };
149
+
150
+ /**
151
+ * Walk up from this module's directory to find the shipped resource root. This
152
+ * runs from `src/init.ts` (no build step) and reads `templates/…` + `prompts/…`
153
+ * from the package root. Stops at a `node_modules` boundary so an installed dep
154
+ * never resolves scaffold sources from the consuming repo. (Runtime `promptFiles`
155
+ * loading is separate — see `resolvePromptFile` in `prompt.ts`, which reads
156
+ * from the consumer runtime root.)
157
+ */
158
+ function resolvePackageResource(relativePath: string, moduleDir: string): string {
159
+ let dir = moduleDir;
160
+ while (true) {
161
+ const candidate = join(dir, relativePath);
162
+ if (existsSync(candidate)) {
163
+ return candidate;
164
+ }
165
+ const parent = dirname(dir);
166
+ if (parent === dir || basename(parent) === "node_modules") {
167
+ throw new Error(
168
+ `Could not find ${relativePath} within the Phoebe package (searched from ${moduleDir})`,
169
+ );
170
+ }
171
+ dir = parent;
172
+ }
173
+ }
174
+
175
+ export type RunInitOptions = {
176
+ /** Directory the scaffolded files land under. Created if missing. */
177
+ targetDir: string;
178
+ /** Override template params (`installCommand`, `cliBin`). */
179
+ params?: Partial<TemplateParams>;
180
+ /** Root for shipped `templates/` and `prompts/` (test seam). Defaults to
181
+ * the walk-up from this module. */
182
+ packageRoot?: string;
183
+ };
184
+
185
+ function readShippedFile(
186
+ relPath: string,
187
+ packageRoot: string | undefined,
188
+ moduleDir: string,
189
+ ): string {
190
+ const absolute = packageRoot
191
+ ? resolvePath(packageRoot, relPath)
192
+ : resolvePackageResource(relPath, moduleDir);
193
+ return readFileSync(absolute, "utf8");
194
+ }
195
+
196
+ /**
197
+ * Execute the plan: create missing files, additively update `.gitignore`, and
198
+ * leave every existing file alone. Returns a report so the CLI (and tests)
199
+ * can render a summary without re-walking the filesystem.
200
+ *
201
+ * Not idempotent in the "produces the same output twice" sense — running init
202
+ * twice on a directory the consumer has edited must not change their files.
203
+ * That's the entire guarded-re-run contract. A second run into an empty
204
+ * directory *does* reproduce the first-run output.
205
+ */
206
+ export function runInit(opts: RunInitOptions): InitReport {
207
+ const targetDir = resolvePath(opts.targetDir);
208
+ const params: TemplateParams = { ...DEFAULT_TEMPLATE_PARAMS, ...opts.params };
209
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
210
+
211
+ mkdirSync(targetDir, { recursive: true });
212
+
213
+ const report: InitReport = { created: [], updated: [], skipped: [] };
214
+
215
+ for (const output of planInitOutputs()) {
216
+ const destAbs = join(targetDir, output.destRelPath);
217
+ mkdirSync(dirname(destAbs), { recursive: true });
218
+
219
+ if (output.source.kind === "gitignore") {
220
+ const existing = existsSync(destAbs) ? readFileSync(destAbs, "utf8") : "";
221
+ const merged = mergeGitignore(existing, output.source.entries);
222
+ if (merged === existing) {
223
+ report.skipped.push(output.destRelPath);
224
+ } else if (existing.length === 0) {
225
+ writeFileSync(destAbs, merged);
226
+ report.created.push(output.destRelPath);
227
+ } else {
228
+ writeFileSync(destAbs, merged);
229
+ report.updated.push(output.destRelPath);
230
+ }
231
+ continue;
232
+ }
233
+
234
+ if (existsSync(destAbs)) {
235
+ report.skipped.push(output.destRelPath);
236
+ continue;
237
+ }
238
+
239
+ if (output.source.kind === "template") {
240
+ const rawTemplate = readShippedFile(
241
+ join("templates", output.source.templateRelPath),
242
+ opts.packageRoot,
243
+ moduleDir,
244
+ );
245
+ const rendered = renderTemplate(rawTemplate, params);
246
+ writeFileSync(destAbs, rendered);
247
+ } else {
248
+ // Shipped prompts ship verbatim — the engine's own render step handles
249
+ // their `{{PLACEHOLDER}}` tokens at run time.
250
+ const prompt = readShippedFile(output.source.promptRelPath, opts.packageRoot, moduleDir);
251
+ writeFileSync(destAbs, prompt);
252
+ }
253
+ report.created.push(output.destRelPath);
254
+ }
255
+
256
+ return report;
257
+ }
258
+
259
+ /** Human-readable summary suitable for the CLI to stdout after init runs. */
260
+ export function formatInitReport(report: InitReport, targetDir: string): string {
261
+ const lines = [`[phoebe] init → ${targetDir}`];
262
+ const emit = (label: string, paths: readonly string[]): void => {
263
+ if (paths.length === 0) return;
264
+ lines.push(` ${label}:`);
265
+ for (const path of paths) {
266
+ lines.push(` ${path}`);
267
+ }
268
+ };
269
+ emit("created", report.created);
270
+ emit("updated", report.updated);
271
+ emit("skipped (already present)", report.skipped);
272
+ if (report.skipped.length > 0) {
273
+ lines.push("");
274
+ lines.push("Existing files were left untouched. Delete them and re-run init to regenerate.");
275
+ }
276
+ return `${lines.join("\n")}\n`;
277
+ }
@@ -0,0 +1,168 @@
1
+ // Consumer-facing config plumbing: `loadUserConfig` (dynamic TS import via
2
+ // native Node type-stripping) and `applyEnvOverlay` (`PHOEBE_*` overrides for
3
+ // scalar fields). The `defineConfig` typing helper lives in the bootstrapper
4
+ // (bootstrap/define-config.ts), the published package surface.
5
+ //
6
+ // The Phoebe CLI (src/cli.ts) chains these: load the user's config, overlay
7
+ // env vars, then `resolveConfig` fills the shipped defaults. Kept separate from
8
+ // `config-schema.ts` so the schema stays a pure data contract and only the CLI
9
+ // path pulls in Node's fs/url runtime.
10
+
11
+ import { pathToFileURL } from "node:url";
12
+ import { existsSync } from "node:fs";
13
+ import { isAbsolute, resolve as resolvePath } from "node:path";
14
+ import type { PhoebeUserConfig, ProviderName } from "./config-schema.ts";
15
+ import { PROVIDER_NAMES } from "./config-schema.ts";
16
+
17
+ /**
18
+ * Scalar-only overlay: each `PHOEBE_*` env var, when set to a non-empty
19
+ * string, replaces the corresponding user-config field. Nested records
20
+ * (`promptFiles`, `paths`, `defaultModels`, `providerEnv`, `workOrder`) stay
21
+ * config-file territory — env vars are for one-off run overrides where the
22
+ * consumer doesn't want to edit `phoebe.config.ts`, and expanding structured
23
+ * shapes into env keys defeats the point.
24
+ *
25
+ * A field-scoped list (rather than magic name-mangling) keeps the surface
26
+ * documented and predictable: users can grep for `PHOEBE_` here and see the
27
+ * complete overlay contract.
28
+ */
29
+ export const ENV_OVERLAY_KEYS = [
30
+ { env: "PHOEBE_REPO_SLUG", key: "repoSlug" },
31
+ { env: "PHOEBE_REPO_URL", key: "repoUrl" },
32
+ { env: "PHOEBE_DEFAULT_BRANCH", key: "defaultBranch" },
33
+ { env: "PHOEBE_BRANCH_PREFIX", key: "branchPrefix" },
34
+ { env: "PHOEBE_READY_LABEL", key: "readyLabel" },
35
+ { env: "PHOEBE_RESEARCH_LABEL", key: "researchLabel" },
36
+ { env: "PHOEBE_PROCESSING_LABEL", key: "processingLabel" },
37
+ { env: "PHOEBE_PR_OPT_OUT_LABEL", key: "prOptOutLabel" },
38
+ { env: "PHOEBE_INSTALL_COMMAND", key: "installCommand" },
39
+ { env: "PHOEBE_CHECK_COMMAND", key: "checkCommand" },
40
+ { env: "PHOEBE_TEST_COMMAND", key: "testCommand" },
41
+ { env: "PHOEBE_READY_COMMAND", key: "readyCommand" },
42
+ { env: "PHOEBE_BLOCKED_BY_PATTERN", key: "blockedByPattern" },
43
+ { env: "PHOEBE_REVIEWS_SUCCESS_HEADING", key: "reviewsSuccessHeading" },
44
+ ] as const satisfies ReadonlyArray<{ env: string; key: keyof PhoebeUserConfig }>;
45
+
46
+ const PR_SCOPE_VALUES = ["phoebe", "all"] as const;
47
+ const DRAFT_PRS_VALUES = ["skip-non-phoebe", "skip-all", "include"] as const;
48
+
49
+ function readNonEmpty(env: NodeJS.ProcessEnv, key: string): string | undefined {
50
+ const raw = env[key];
51
+ if (typeof raw !== "string" || raw.length === 0) return undefined;
52
+ return raw;
53
+ }
54
+
55
+ /**
56
+ * Apply the `PHOEBE_*` overlay onto a user config and return a new object.
57
+ * The overlay is additive over what the config file declared — an unset env
58
+ * var leaves the field untouched (so `resolveConfig` can still fall back to
59
+ * `CONFIG_DEFAULTS` if the field was also absent from the config file).
60
+ */
61
+ export function applyEnvOverlay(user: PhoebeUserConfig, env: NodeJS.ProcessEnv): PhoebeUserConfig {
62
+ const overlaid: PhoebeUserConfig = { ...user };
63
+ for (const { env: envKey, key } of ENV_OVERLAY_KEYS) {
64
+ const value = readNonEmpty(env, envKey);
65
+ if (value !== undefined) {
66
+ (overlaid as Record<string, unknown>)[key] = value;
67
+ }
68
+ }
69
+
70
+ const prScope = readNonEmpty(env, "PHOEBE_PR_SCOPE");
71
+ if (prScope !== undefined) {
72
+ if (!(PR_SCOPE_VALUES as readonly string[]).includes(prScope)) {
73
+ throw new Error(
74
+ `PHOEBE_PR_SCOPE must be one of ${PR_SCOPE_VALUES.join(", ")} (got "${prScope}").`,
75
+ );
76
+ }
77
+ overlaid.prScope = prScope as PhoebeUserConfig["prScope"];
78
+ }
79
+
80
+ const draftPrs = readNonEmpty(env, "PHOEBE_DRAFT_PRS");
81
+ if (draftPrs !== undefined) {
82
+ if (!(DRAFT_PRS_VALUES as readonly string[]).includes(draftPrs)) {
83
+ throw new Error(
84
+ `PHOEBE_DRAFT_PRS must be one of ${DRAFT_PRS_VALUES.join(", ")} (got "${draftPrs}").`,
85
+ );
86
+ }
87
+ overlaid.draftPrs = draftPrs as PhoebeUserConfig["draftPrs"];
88
+ }
89
+
90
+ const defaultProvider = readNonEmpty(env, "PHOEBE_DEFAULT_PROVIDER");
91
+ if (defaultProvider !== undefined) {
92
+ if (!(PROVIDER_NAMES as readonly string[]).includes(defaultProvider)) {
93
+ throw new Error(
94
+ `PHOEBE_DEFAULT_PROVIDER must be one of ${PROVIDER_NAMES.join(", ")} (got "${defaultProvider}").`,
95
+ );
96
+ }
97
+ overlaid.defaultProvider = defaultProvider as ProviderName;
98
+ }
99
+
100
+ return overlaid;
101
+ }
102
+
103
+ /**
104
+ * Resolve a `--config` argument (or the default) to an absolute path and
105
+ * assert the file exists. Split from `loadUserConfig` so the CLI can print
106
+ * a precise "file not found" message before attempting the dynamic import.
107
+ */
108
+ export function resolveConfigPath(argPath: string | undefined, cwd: string): string {
109
+ const candidate = argPath ?? "phoebe.config.ts";
110
+ const absolute = isAbsolute(candidate) ? candidate : resolvePath(cwd, candidate);
111
+ if (!existsSync(absolute)) {
112
+ throw new Error(
113
+ argPath
114
+ ? `Config file not found: ${absolute} (passed via --config).`
115
+ : `Config file not found: ${absolute}. ` +
116
+ `Create a phoebe.config.ts in the current directory or pass --config <path>.`,
117
+ );
118
+ }
119
+ return absolute;
120
+ }
121
+
122
+ /**
123
+ * Dynamically import a `phoebe.config.ts` and return the user shape. Native
124
+ * Node type-stripping (unflagged on Node ≥ 24, the version Phoebe requires)
125
+ * handles the TS syntax — no bundler needed on the consumer side. Accepts
126
+ * either a default export or a named `config` export so the pre-`defineConfig`
127
+ * scaffold still loads.
128
+ *
129
+ * `reloadKey` busts Node's ESM module cache, which is keyed by URL and would
130
+ * otherwise hand back the first import forever. The engine never needs it (each
131
+ * run is a fresh process); `phoebe boot` does, because it re-reads the mounted
132
+ * config in-process when the reconcile watch sees it change (#42). Pass the
133
+ * config's fingerprint, not a counter: an unchanged config then reuses the
134
+ * cached module instead of leaking a new registry entry per read.
135
+ */
136
+ export async function loadUserConfig(
137
+ configPath: string,
138
+ opts: { reloadKey?: string } = {},
139
+ ): Promise<PhoebeUserConfig> {
140
+ const fileUrl = pathToFileURL(configPath);
141
+ if (opts.reloadKey !== undefined) {
142
+ fileUrl.searchParams.set("phoebe-reload", opts.reloadKey);
143
+ }
144
+ const url = fileUrl.href;
145
+ let mod: unknown;
146
+ try {
147
+ mod = await import(url);
148
+ } catch (error) {
149
+ throw new Error(
150
+ `Failed to load ${configPath}: ${error instanceof Error ? error.message : String(error)}`,
151
+ );
152
+ }
153
+ const record = mod as Record<string, unknown>;
154
+ const candidate =
155
+ (typeof record["default"] === "object" && record["default"] !== null
156
+ ? record["default"]
157
+ : undefined) ??
158
+ (typeof record["config"] === "object" && record["config"] !== null
159
+ ? record["config"]
160
+ : undefined);
161
+ if (!candidate) {
162
+ throw new Error(
163
+ `${configPath} must export a Phoebe config as \`export default defineConfig({ ... })\` ` +
164
+ `or a named \`export const config = { ... }\`.`,
165
+ );
166
+ }
167
+ return candidate as PhoebeUserConfig;
168
+ }