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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JesusFilm
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # Phoebe
2
+
3
+ **Phoebe is an AFK coding agent.** It polls a GitHub repository for ready-to-work
4
+ issues, works each one on its own branch in an isolated git worktree, runs your
5
+ project's gates, and opens a pull request. Between new issues it sweeps open PRs
6
+ for merge conflicts, failing CI, and unresolved review feedback — so work keeps
7
+ moving without a human babysitting every branch.
8
+
9
+ Phoebe runs as a **single Docker container** that is both orchestrator and
10
+ execution environment. Your host checkout is never touched: the container owns a
11
+ private clone and pushes branches directly to origin. Every repo-specific value
12
+ lives behind one config file, so the same engine drives any repository.
13
+
14
+ > ⚠️ **Early scaffold.** This repository is being stood up as the public home of
15
+ > Phoebe's engine, extracted from [`JesusFilm/youtube-studio`](https://github.com/JesusFilm/youtube-studio).
16
+ > The engine, CLI packaging, `phoebe init` scaffolder, CI, and first npm release
17
+ > land as the tracked execution issues on this repo. Until `phoebe-agent@0.1.0`
18
+ > is published, treat everything here as work in progress.
19
+
20
+ ## Distribution
21
+
22
+ The engine is published to npm as **`phoebe-agent`** (unscoped) and consumed as a
23
+ pinned CLI — you never vendor the engine source into your repo, only a small
24
+ config file, your prompt overrides, and the container files `phoebe init`
25
+ scaffolds for you.
26
+
27
+ ## Documentation
28
+
29
+ Docs live under [`docs/`](docs/) and grow as the engine lands:
30
+
31
+ - `docs/architecture.md` — topology, worktree isolation, supervisor self-update.
32
+ - `docs/configuration.md` — full config-field reference.
33
+ - `docs/work-kinds.md` — issues / conflicts / checks / reviews mechanics.
34
+ - `docs/operating.md` — controlling Phoebe as a human (labels, drafts, watermarks).
35
+ - `docs/upgrading.md` — the init / pin / upgrade contract.
36
+ - `docs/ai-install.md` — a deterministic, agent-followable install runbook.
37
+ - `docs/releasing.md` — the Changesets + npm trusted-publishing release flow.
38
+
39
+ Agents landing in this repo should start at [`AGENTS.md`](AGENTS.md).
40
+
41
+ ## History & attribution
42
+
43
+ Phoebe was designed, built, and dogfooded inside
44
+ [`JesusFilm/youtube-studio`](https://github.com/JesusFilm/youtube-studio), which
45
+ remains its reference consumer. Its execution loop was first prototyped on
46
+ [Sandcastle](https://github.com/mattpocock/sandcastle) (`@ai-hero/sandcastle`, by
47
+ Matt Pocock) — the sandbox-per-run design proved the loop end-to-end and its
48
+ provider wrappers are the design ancestor of `src/providers/`. The dependency was
49
+ removed when the host-spawns-sandboxes topology was replaced by the single
50
+ persistable container. This repository starts with fresh history; the full design
51
+ record lives in the youtube-studio issue tracker.
52
+
53
+ ## License
54
+
55
+ [MIT](LICENSE) © JesusFilm
@@ -0,0 +1,2 @@
1
+ import type { PhoebeConfig } from "./src/config-schema.ts";
2
+ export declare const config: PhoebeConfig;
@@ -0,0 +1,43 @@
1
+ // All repo-specific configuration for the Phoebe engine lives here — repo slug,
2
+ // labels, branch prefix, toolchain commands, prompts, work order, provider keys.
3
+ // Engine code under src/ never mentions any concrete repository (enforced by
4
+ // src/config-seam.test.ts), so pointing Phoebe at a repo is a matter of editing
5
+ // this one file. These are the engine's shipped defaults; consumers replace the
6
+ // repo/toolchain values with their own.
7
+ export const config = {
8
+ repoSlug: "your-org/your-repo",
9
+ repoUrl: "https://github.com/your-org/your-repo.git",
10
+ defaultBranch: "main",
11
+ branchPrefix: "phoebe/",
12
+ readyLabel: "ready-for-agent",
13
+ prScope: "phoebe",
14
+ draftPrs: "skip-non-phoebe",
15
+ prOptOutLabel: "ready-for-human",
16
+ installCommand: "npm ci",
17
+ checkCommand: "npm run check",
18
+ testCommand: "npm test",
19
+ promptFiles: {
20
+ issue: "prompts/prompt.md",
21
+ conflict: "prompts/conflict-prompt.md",
22
+ checks: "prompts/checks-prompt.md",
23
+ reviews: "prompts/reviews-prompt.md",
24
+ },
25
+ workOrder: ["conflicts", "checks", "reviews", "issues"],
26
+ defaultProvider: "cursor",
27
+ defaultModels: {
28
+ cursor: "composer-2.5",
29
+ claude: "claude-sonnet-4-6",
30
+ codex: "gpt-5.4-mini",
31
+ },
32
+ providerEnv: {
33
+ cursor: "CURSOR_API_KEY",
34
+ claude: "ANTHROPIC_API_KEY",
35
+ codex: "OPENAI_KEY",
36
+ },
37
+ selfUpdatePaths: ["package.json", "package-lock.json"],
38
+ paths: {
39
+ repoDir: "/data/repo",
40
+ worktreesDir: "/data/worktrees",
41
+ stateDir: "/data/state",
42
+ },
43
+ };
@@ -0,0 +1,6 @@
1
+ import type { PhoebeConfig, ProviderName } from "./config-schema.ts";
2
+ export declare function buildAgentEnv(opts: {
3
+ parentEnv: Record<string, string | undefined>;
4
+ provider: ProviderName;
5
+ providerEnv: PhoebeConfig["providerEnv"];
6
+ }): Record<string, string>;
@@ -0,0 +1,24 @@
1
+ // Explicit env allowlist for agent child processes. The agent sees PATH, HOME,
2
+ // git identity, the GitHub token, and the *active* provider's API key — never
3
+ // the other providers' keys, so a prompt-injected agent can't exfiltrate the
4
+ // whole keyring.
5
+ const BASE_ALLOWLIST = [
6
+ "PATH",
7
+ "HOME",
8
+ "GH_TOKEN",
9
+ "GIT_AUTHOR_NAME",
10
+ "GIT_AUTHOR_EMAIL",
11
+ "GIT_COMMITTER_NAME",
12
+ "GIT_COMMITTER_EMAIL",
13
+ ];
14
+ export function buildAgentEnv(opts) {
15
+ const { parentEnv, provider, providerEnv } = opts;
16
+ const env = { CI: "true" };
17
+ for (const key of [...BASE_ALLOWLIST, providerEnv[provider]]) {
18
+ const value = parentEnv[key];
19
+ if (value !== undefined && value !== "") {
20
+ env[key] = value;
21
+ }
22
+ }
23
+ return env;
24
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ type ParsedArgs = {
3
+ configPath: string | undefined;
4
+ help: boolean;
5
+ forward: string[];
6
+ };
7
+ /**
8
+ * Extract `--config <path>` / `--config=<path>` / `-c <path>` and `--help`/`-h`
9
+ * from argv, forwarding everything else to `runEngine`. A minimal parser is
10
+ * enough — the engine handles its own boolean flags (`--run-once`, `--dry-run`)
11
+ * from the forwarded array.
12
+ */
13
+ export declare function parseCliArgs(argv: readonly string[]): ParsedArgs;
14
+ export type ParsedInitArgs = {
15
+ targetDir: string;
16
+ help: boolean;
17
+ };
18
+ /**
19
+ * Parse argv left after the leading `init` token has been consumed. Supports
20
+ * an optional positional target directory (`phoebe init ./my-agent`) and
21
+ * `--help`. Extra flags are rejected loudly so a typo like `--forcee` fails
22
+ * fast instead of being silently ignored.
23
+ */
24
+ export declare function parseInitArgs(argv: readonly string[]): ParsedInitArgs;
25
+ export {};
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ // `phoebe` bin — the packaged CLI consumers invoke via
3
+ // `npx phoebe-agent [flags]` (or a pinned `phoebe` script). Recognises two
4
+ // modes:
5
+ //
6
+ // phoebe init [dir] Scaffold a consumer-owned runtime (config, prompts,
7
+ // .env.example, container templates, gitignore).
8
+ // Skips existing files — safe to re-run.
9
+ // phoebe [flags] Run the engine. Loads the consumer's
10
+ // `phoebe.config.ts`, overlays `PHOEBE_*` env vars,
11
+ // installs the resolved config, then hands off to main.
12
+ //
13
+ // This is the only supported v1 programmatic surface: there is no exported
14
+ // `run(config)` — CLI-only. That keeps every consumer on the same load/resolve/
15
+ // install pipeline and leaves the door open to CLI-only concerns (init/pin
16
+ // scaffolding, log formatting) without breaking a library API.
17
+ import { realpathSync } from "node:fs";
18
+ import { pathToFileURL } from "node:url";
19
+ import { resolveConfig } from "./config-schema.js";
20
+ import { formatInitReport, runInit } from "./init.js";
21
+ import { applyEnvOverlay, loadUserConfig, resolveConfigPath } from "./load-config.js";
22
+ import { setResolvedConfig } from "./resolved-config.js";
23
+ /**
24
+ * Extract `--config <path>` / `--config=<path>` / `-c <path>` and `--help`/`-h`
25
+ * from argv, forwarding everything else to `runEngine`. A minimal parser is
26
+ * enough — the engine handles its own boolean flags (`--run-once`, `--dry-run`)
27
+ * from the forwarded array.
28
+ */
29
+ export function parseCliArgs(argv) {
30
+ const forward = [];
31
+ let configPath;
32
+ let help = false;
33
+ for (let i = 0; i < argv.length; i++) {
34
+ const arg = argv[i];
35
+ if (arg === "--help" || arg === "-h") {
36
+ help = true;
37
+ continue;
38
+ }
39
+ if (arg === "--config" || arg === "-c") {
40
+ const next = argv[i + 1];
41
+ if (next === undefined) {
42
+ throw new Error(`${arg} requires a path argument (e.g. --config phoebe.config.ts).`);
43
+ }
44
+ configPath = next;
45
+ i += 1;
46
+ continue;
47
+ }
48
+ if (arg !== undefined && arg.startsWith("--config=")) {
49
+ configPath = arg.slice("--config=".length);
50
+ continue;
51
+ }
52
+ if (arg !== undefined) {
53
+ forward.push(arg);
54
+ }
55
+ }
56
+ return { configPath, help, forward };
57
+ }
58
+ /**
59
+ * Parse argv left after the leading `init` token has been consumed. Supports
60
+ * an optional positional target directory (`phoebe init ./my-agent`) and
61
+ * `--help`. Extra flags are rejected loudly so a typo like `--forcee` fails
62
+ * fast instead of being silently ignored.
63
+ */
64
+ export function parseInitArgs(argv) {
65
+ let targetDir;
66
+ let help = false;
67
+ for (const arg of argv) {
68
+ if (arg === "--help" || arg === "-h") {
69
+ help = true;
70
+ continue;
71
+ }
72
+ if (arg.startsWith("-")) {
73
+ throw new Error(`Unknown flag \`${arg}\` for \`phoebe init\`. See \`phoebe init --help\`.`);
74
+ }
75
+ if (targetDir !== undefined) {
76
+ throw new Error(`\`phoebe init\` takes at most one target directory (got \`${targetDir}\` and \`${arg}\`).`);
77
+ }
78
+ targetDir = arg;
79
+ }
80
+ return { targetDir: targetDir ?? ".", help };
81
+ }
82
+ const HELP_TEXT = `phoebe — AFK coding agent
83
+
84
+ Usage:
85
+ phoebe init [dir] Scaffold a consumer-owned runtime
86
+ phoebe [--config <path>] [flags] Run the engine
87
+
88
+ Options (engine mode):
89
+ --config, -c <path> Path to phoebe.config.ts (default: ./phoebe.config.ts)
90
+ --run-once Work one unit of the first one-shot-eligible kind, then exit
91
+ --dry-run Print the selected unit without executing it
92
+ --help, -h Show this message
93
+
94
+ Environment overlays (each replaces the corresponding config field):
95
+ PHOEBE_REPO_SLUG, PHOEBE_REPO_URL, PHOEBE_DEFAULT_BRANCH, PHOEBE_BRANCH_PREFIX,
96
+ PHOEBE_READY_LABEL, PHOEBE_PROCESSING_LABEL, PHOEBE_PR_OPT_OUT_LABEL,
97
+ PHOEBE_INSTALL_COMMAND, PHOEBE_CHECK_COMMAND, PHOEBE_TEST_COMMAND,
98
+ PHOEBE_READY_COMMAND, PHOEBE_BLOCKED_BY_PATTERN, PHOEBE_REVIEWS_SUCCESS_HEADING,
99
+ PHOEBE_PR_SCOPE, PHOEBE_DRAFT_PRS, PHOEBE_DEFAULT_PROVIDER
100
+
101
+ Runtime toggles (read directly by the engine, not overlaid onto the config):
102
+ PHOEBE_AGENT Provider name to use for this run (cursor|claude|codex)
103
+ PHOEBE_MODEL Model to use for this run
104
+ PHOEBE_POLL_INTERVAL_MS Persistent-mode poll interval (default 300000)
105
+ PHOEBE_DEFAULT_BRANCH Branch the supervisor keeps the clone on (overrides tracked branch only)
106
+ `;
107
+ const INIT_HELP_TEXT = `phoebe init — scaffold a consumer-owned runtime
108
+
109
+ Usage:
110
+ phoebe init [dir]
111
+
112
+ Writes into [dir] (default: current directory):
113
+ phoebe.config.ts Consumer config starter (edit the five required fields)
114
+ prompts/ Copies of the shipped agent prompts (edit to override)
115
+ .env.example Documented environment variables to copy to .env
116
+ .gitignore Additive — appends Phoebe entries only
117
+ container/Dockerfile Runtime image (Node + git + gh + pinned phoebe-agent)
118
+ container/compose.yml Base one-shot compose config
119
+ container/compose.daemon.yml Overlay to run Phoebe as a persistent daemon
120
+ container/supervisor.sh Warm-install + engine-restart loop (chmod +x)
121
+
122
+ Existing files are left untouched, so re-running is safe. To regenerate a
123
+ scaffolded file, delete it first and re-run \`phoebe init\`.
124
+ `;
125
+ async function main() {
126
+ const args = process.argv.slice(2);
127
+ if (args[0] === "init") {
128
+ const parsed = parseInitArgs(args.slice(1));
129
+ if (parsed.help) {
130
+ process.stdout.write(INIT_HELP_TEXT);
131
+ return;
132
+ }
133
+ const report = runInit({ targetDir: parsed.targetDir });
134
+ process.stdout.write(formatInitReport(report, parsed.targetDir));
135
+ return;
136
+ }
137
+ const parsed = parseCliArgs(args);
138
+ if (parsed.help) {
139
+ process.stdout.write(HELP_TEXT);
140
+ return;
141
+ }
142
+ const configPath = resolveConfigPath(parsed.configPath, process.cwd());
143
+ const userConfig = await loadUserConfig(configPath);
144
+ const overlaid = applyEnvOverlay(userConfig, process.env);
145
+ setResolvedConfig(resolveConfig(overlaid));
146
+ // Import after the config is installed — main.ts's module-level constants
147
+ // read `config` at import time via the Proxy in resolved-config.ts.
148
+ const { runEngine } = await import("./main.js");
149
+ await runEngine(parsed.forward);
150
+ }
151
+ // Only run when invoked as the entry point — tests import `parseCliArgs` from
152
+ // this module without triggering the whole CLI pipeline. `argv[1]` is realpath'd
153
+ // so a bin symlink (`node_modules/.bin/phoebe -> ../phoebe-agent/dist/src/cli.js`)
154
+ // still matches `import.meta.url`, which Node resolves through symlinks.
155
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
156
+ main().catch((error) => {
157
+ const message = error instanceof Error ? error.message : String(error);
158
+ console.error(`[phoebe] ${message}`);
159
+ process.exit(1);
160
+ });
161
+ }
@@ -0,0 +1,163 @@
1
+ export declare const PROVIDER_NAMES: readonly ["cursor", "claude", "codex"];
2
+ export type ProviderName = (typeof PROVIDER_NAMES)[number];
3
+ export type PromptFilesConfig = {
4
+ issue: string;
5
+ conflict: string;
6
+ checks: string;
7
+ reviews: string;
8
+ };
9
+ export type PathsConfig = {
10
+ /** The private clone (origin hub). */
11
+ repoDir: string;
12
+ /** Per-unit git worktrees. */
13
+ worktreesDir: string;
14
+ /** Lock, markers, logs. */
15
+ stateDir: string;
16
+ };
17
+ export type PhoebeConfig = {
18
+ /** GitHub `owner/repo` slug, passed to every `gh -R` call. */
19
+ repoSlug: string;
20
+ /** HTTPS clone URL for the container's private clone. */
21
+ repoUrl: string;
22
+ /** Branch PRs target and worktrees base off (usually `main`). */
23
+ defaultBranch: string;
24
+ /** Prefix for agent branches; issue branches are `<prefix>issue-<n>`. */
25
+ branchPrefix: string;
26
+ /** Label marking issues Phoebe may pick up. */
27
+ readyLabel: string;
28
+ /** Label the agent applies to an issue it has claimed and is working. */
29
+ processingLabel: string;
30
+ /** Which open PRs the conflicts/checks/reviews work-kinds scan.
31
+ * "phoebe" = only branchPrefix branches. "all" = any same-repo PR. */
32
+ prScope: "phoebe" | "all";
33
+ /** Draft PR handling: "skip-non-phoebe" = drafts on non-Phoebe branches are
34
+ * off-limits; "skip-all" = never touch drafts; "include" = drafts are fair game. */
35
+ draftPrs: "skip-non-phoebe" | "skip-all" | "include";
36
+ /** PRs carrying this label are excluded from the PR scan in every mode. */
37
+ prOptOutLabel: string;
38
+ /** Shell command strings — toolchains differ per repo, so these are data. */
39
+ installCommand: string;
40
+ checkCommand: string;
41
+ testCommand: string;
42
+ /** The all-in-one gate the agent runs before pushing (e.g. `npm run ready`).
43
+ * Substituted into default prompts as `{{READY_COMMAND}}`. */
44
+ readyCommand: string;
45
+ /**
46
+ * JavaScript-compatible regex source that matches an issue-blocker reference
47
+ * in issue body text. Must expose the blocker issue number as capture group 1.
48
+ * Compiled with the `gi` flags.
49
+ */
50
+ blockedByPattern: string;
51
+ /**
52
+ * Markdown heading the reviews agent must include when it posts its summary
53
+ * comment. The orchestrator detects the summary by substring match on this
54
+ * exact string, so it must be unique enough not to collide with other
55
+ * comments. Substituted into the default reviews prompt as
56
+ * `{{REVIEWS_SUCCESS_HEADING}}`.
57
+ */
58
+ reviewsSuccessHeading: string;
59
+ /** Prompt template paths, relative to the package root (apps/phoebe). */
60
+ promptFiles: PromptFilesConfig;
61
+ /** Ordered work kinds, validated by the orchestrator at startup. */
62
+ workOrder: readonly string[];
63
+ defaultProvider: ProviderName;
64
+ defaultModels: Record<ProviderName, string>;
65
+ /** Env var holding each provider's API key — the only key the agent child inherits. */
66
+ providerEnv: Record<ProviderName, string>;
67
+ /**
68
+ * Repo paths that mean "Phoebe's own code changed" — a fetch of the default
69
+ * branch touching these makes the orchestrator exit for a supervisor
70
+ * reinstall + re-exec. Directory entries must end with `/`.
71
+ */
72
+ selfUpdatePaths: readonly string[];
73
+ /** Container filesystem layout (named volumes). */
74
+ paths: PathsConfig;
75
+ };
76
+ /**
77
+ * User-facing shape of `phoebe.config.ts`. Only the five fields with no sane
78
+ * cross-repo default are required; everything else is optional and filled from
79
+ * `CONFIG_DEFAULTS` by `resolveConfig()`. Nested objects (`promptFiles`,
80
+ * `paths`, `defaultModels`, `providerEnv`) are merged key-by-key, so overriding
81
+ * one provider's model or one prompt file does not force the caller to supply
82
+ * the rest.
83
+ */
84
+ export type PhoebeUserConfig = {
85
+ repoSlug: string;
86
+ repoUrl: string;
87
+ installCommand: string;
88
+ checkCommand: string;
89
+ testCommand: string;
90
+ defaultBranch?: string;
91
+ branchPrefix?: string;
92
+ readyLabel?: string;
93
+ processingLabel?: string;
94
+ prScope?: PhoebeConfig["prScope"];
95
+ draftPrs?: PhoebeConfig["draftPrs"];
96
+ prOptOutLabel?: string;
97
+ readyCommand?: string;
98
+ blockedByPattern?: string;
99
+ reviewsSuccessHeading?: string;
100
+ promptFiles?: Partial<PromptFilesConfig>;
101
+ workOrder?: readonly string[];
102
+ defaultProvider?: ProviderName;
103
+ defaultModels?: Partial<Record<ProviderName, string>>;
104
+ providerEnv?: Partial<Record<ProviderName, string>>;
105
+ selfUpdatePaths?: readonly string[];
106
+ paths?: Partial<PathsConfig>;
107
+ };
108
+ /**
109
+ * Engine defaults for every optional user field. These land in the resolved
110
+ * config whenever the consumer's `phoebe.config.ts` omits them, so a minimal
111
+ * consumer config only has to name the repo and its three toolchain commands.
112
+ */
113
+ export declare const CONFIG_DEFAULTS: {
114
+ readonly defaultBranch: "main";
115
+ readonly branchPrefix: "phoebe/";
116
+ readonly readyLabel: "ready-for-agent";
117
+ readonly processingLabel: "processing";
118
+ readonly prScope: "phoebe";
119
+ readonly draftPrs: "skip-non-phoebe";
120
+ readonly prOptOutLabel: "ready-for-human";
121
+ readonly readyCommand: "npm run ready";
122
+ readonly blockedByPattern: string;
123
+ readonly reviewsSuccessHeading: "## Review feedback addressed";
124
+ readonly promptFiles: {
125
+ issue: string;
126
+ conflict: string;
127
+ checks: string;
128
+ reviews: string;
129
+ };
130
+ readonly workOrder: readonly string[];
131
+ readonly defaultProvider: ProviderName;
132
+ readonly defaultModels: {
133
+ cursor: string;
134
+ claude: string;
135
+ codex: string;
136
+ };
137
+ readonly providerEnv: {
138
+ cursor: string;
139
+ claude: string;
140
+ codex: string;
141
+ };
142
+ readonly selfUpdatePaths: readonly string[];
143
+ readonly paths: {
144
+ repoDir: string;
145
+ worktreesDir: string;
146
+ stateDir: string;
147
+ };
148
+ };
149
+ /**
150
+ * Throw when a required field is missing or blank, or when `blockedByPattern`
151
+ * is not a valid regex or fails to expose the blocker issue number as capture
152
+ * group 1. `parseBlockedBy` reads `match[1]`, so a pattern without a capture
153
+ * group would silently break the entire blocker-detection path — reject it up
154
+ * front. Kept separate from `resolveConfig` so consumers or tests can validate
155
+ * a config independent of the defaults merge.
156
+ */
157
+ export declare function validateUserConfig(user: PhoebeUserConfig): void;
158
+ /**
159
+ * Merge a user config with `CONFIG_DEFAULTS` and return the fully-populated
160
+ * shape the engine runs against. Nested records are shallow-merged so partial
161
+ * overrides (one prompt file, one provider's env var, etc.) work as expected.
162
+ */
163
+ export declare function resolveConfig(user: PhoebeUserConfig): PhoebeConfig;
@@ -0,0 +1,140 @@
1
+ // Shape of the repo-specific configuration the Phoebe engine runs against.
2
+ // The values live in ../phoebe.config.ts — the single file allowed to mention
3
+ // this repository. Engine modules (everything under src/) import the resolved
4
+ // config from ./resolved-config.ts and stay repo-agnostic;
5
+ // src/config-seam.test.ts enforces it.
6
+ //
7
+ // Two shapes live here. `PhoebeUserConfig` is what a consumer writes: only the
8
+ // unavoidable repo/toolchain fields are required; everything else is optional
9
+ // and filled from `CONFIG_DEFAULTS` by `resolveConfig()`. `PhoebeConfig` is the
10
+ // fully-resolved shape the engine sees at runtime — every field populated.
11
+ export const PROVIDER_NAMES = ["cursor", "claude", "codex"];
12
+ /**
13
+ * Engine defaults for every optional user field. These land in the resolved
14
+ * config whenever the consumer's `phoebe.config.ts` omits them, so a minimal
15
+ * consumer config only has to name the repo and its three toolchain commands.
16
+ */
17
+ export const CONFIG_DEFAULTS = {
18
+ defaultBranch: "main",
19
+ branchPrefix: "phoebe/",
20
+ readyLabel: "ready-for-agent",
21
+ processingLabel: "processing",
22
+ prScope: "phoebe",
23
+ draftPrs: "skip-non-phoebe",
24
+ prOptOutLabel: "ready-for-human",
25
+ readyCommand: "npm run ready",
26
+ blockedByPattern: String.raw `Blocked by\s+#(\d+)`,
27
+ reviewsSuccessHeading: "## Review feedback addressed",
28
+ promptFiles: {
29
+ issue: "prompts/prompt.md",
30
+ conflict: "prompts/conflict-prompt.md",
31
+ checks: "prompts/checks-prompt.md",
32
+ reviews: "prompts/reviews-prompt.md",
33
+ },
34
+ workOrder: ["conflicts", "checks", "reviews", "issues"],
35
+ defaultProvider: "cursor",
36
+ defaultModels: {
37
+ cursor: "composer-2.5",
38
+ claude: "claude-sonnet-4-6",
39
+ codex: "gpt-5.4-mini",
40
+ },
41
+ providerEnv: {
42
+ cursor: "CURSOR_API_KEY",
43
+ claude: "ANTHROPIC_API_KEY",
44
+ codex: "OPENAI_KEY",
45
+ },
46
+ selfUpdatePaths: ["package.json", "package-lock.json"],
47
+ paths: {
48
+ repoDir: "/data/repo",
49
+ worktreesDir: "/data/worktrees",
50
+ stateDir: "/data/state",
51
+ },
52
+ };
53
+ const REQUIRED_USER_FIELDS = [
54
+ "repoSlug",
55
+ "repoUrl",
56
+ "installCommand",
57
+ "checkCommand",
58
+ "testCommand",
59
+ ];
60
+ /**
61
+ * Count the numbered capture groups defined by a regex source. We compile it
62
+ * with an added empty alternative (`|`) so the resulting regex always matches
63
+ * the empty string; the match array's length minus one then equals the number
64
+ * of capture groups, regardless of whether the original pattern would have
65
+ * matched anything on its own. Escaped parens, non-capturing groups (`(?:…)`),
66
+ * lookarounds, and named groups are handled correctly because we're asking
67
+ * the engine's own group count, not parsing the source ourselves.
68
+ */
69
+ function countCaptureGroups(source) {
70
+ const compiled = new RegExp(`${source}|`);
71
+ const match = compiled.exec("");
72
+ // The extra `|` guarantees a match against ""; TS still narrows to nullable.
73
+ if (!match) {
74
+ return 0;
75
+ }
76
+ return match.length - 1;
77
+ }
78
+ /**
79
+ * Throw when a required field is missing or blank, or when `blockedByPattern`
80
+ * is not a valid regex or fails to expose the blocker issue number as capture
81
+ * group 1. `parseBlockedBy` reads `match[1]`, so a pattern without a capture
82
+ * group would silently break the entire blocker-detection path — reject it up
83
+ * front. Kept separate from `resolveConfig` so consumers or tests can validate
84
+ * a config independent of the defaults merge.
85
+ */
86
+ export function validateUserConfig(user) {
87
+ const missing = REQUIRED_USER_FIELDS.filter((key) => {
88
+ const value = user[key];
89
+ return typeof value !== "string" || value.trim().length === 0;
90
+ });
91
+ if (missing.length > 0) {
92
+ throw new Error(`phoebe.config.ts is missing required field(s): ${missing.join(", ")}. ` +
93
+ `Only these five fields are required — the engine fills the rest from its defaults.`);
94
+ }
95
+ if (user.blockedByPattern !== undefined) {
96
+ try {
97
+ new RegExp(user.blockedByPattern, "gi");
98
+ }
99
+ catch (err) {
100
+ throw new Error(`phoebe.config.ts blockedByPattern is not a valid regex: ${err.message}`);
101
+ }
102
+ if (countCaptureGroups(user.blockedByPattern) < 1) {
103
+ throw new Error(`phoebe.config.ts blockedByPattern must define capture group 1 for the ` +
104
+ `blocker issue number (parseBlockedBy reads match[1]). Wrap the number ` +
105
+ `portion in parentheses, e.g. String.raw\`Blocked by\\s+#(\\d+)\`.`);
106
+ }
107
+ }
108
+ }
109
+ /**
110
+ * Merge a user config with `CONFIG_DEFAULTS` and return the fully-populated
111
+ * shape the engine runs against. Nested records are shallow-merged so partial
112
+ * overrides (one prompt file, one provider's env var, etc.) work as expected.
113
+ */
114
+ export function resolveConfig(user) {
115
+ validateUserConfig(user);
116
+ return {
117
+ repoSlug: user.repoSlug,
118
+ repoUrl: user.repoUrl,
119
+ installCommand: user.installCommand,
120
+ checkCommand: user.checkCommand,
121
+ testCommand: user.testCommand,
122
+ defaultBranch: user.defaultBranch ?? CONFIG_DEFAULTS.defaultBranch,
123
+ branchPrefix: user.branchPrefix ?? CONFIG_DEFAULTS.branchPrefix,
124
+ readyLabel: user.readyLabel ?? CONFIG_DEFAULTS.readyLabel,
125
+ processingLabel: user.processingLabel ?? CONFIG_DEFAULTS.processingLabel,
126
+ prScope: user.prScope ?? CONFIG_DEFAULTS.prScope,
127
+ draftPrs: user.draftPrs ?? CONFIG_DEFAULTS.draftPrs,
128
+ prOptOutLabel: user.prOptOutLabel ?? CONFIG_DEFAULTS.prOptOutLabel,
129
+ readyCommand: user.readyCommand ?? CONFIG_DEFAULTS.readyCommand,
130
+ blockedByPattern: user.blockedByPattern ?? CONFIG_DEFAULTS.blockedByPattern,
131
+ reviewsSuccessHeading: user.reviewsSuccessHeading ?? CONFIG_DEFAULTS.reviewsSuccessHeading,
132
+ promptFiles: { ...CONFIG_DEFAULTS.promptFiles, ...user.promptFiles },
133
+ workOrder: user.workOrder ?? CONFIG_DEFAULTS.workOrder,
134
+ defaultProvider: user.defaultProvider ?? CONFIG_DEFAULTS.defaultProvider,
135
+ defaultModels: { ...CONFIG_DEFAULTS.defaultModels, ...user.defaultModels },
136
+ providerEnv: { ...CONFIG_DEFAULTS.providerEnv, ...user.providerEnv },
137
+ selfUpdatePaths: user.selfUpdatePaths ?? CONFIG_DEFAULTS.selfUpdatePaths,
138
+ paths: { ...CONFIG_DEFAULTS.paths, ...user.paths },
139
+ };
140
+ }
@@ -0,0 +1,9 @@
1
+ export declare const CONTAINER_MARKER_PATH = "/.phoebe-container";
2
+ export declare function isInsideContainer(exists?: (path: string) => boolean): boolean;
3
+ export type ExecutionDecision = "execute" | "dry-run" | "refuse";
4
+ /** Pure decision: may this process execute the selected work unit? */
5
+ export declare function executionDecision(opts: {
6
+ dryRun: boolean;
7
+ inContainer: boolean;
8
+ }): ExecutionDecision;
9
+ export declare const EXECUTION_REFUSED_MESSAGE: string;