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
@@ -1,140 +0,0 @@
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
- }
@@ -1,9 +0,0 @@
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;
@@ -1,17 +0,0 @@
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
- import { existsSync } from "node:fs";
6
- export const CONTAINER_MARKER_PATH = "/.phoebe-container";
7
- export function isInsideContainer(exists = existsSync) {
8
- return exists(CONTAINER_MARKER_PATH);
9
- }
10
- /** Pure decision: may this process execute the selected work unit? */
11
- export function executionDecision(opts) {
12
- if (opts.dryRun)
13
- return "dry-run";
14
- return opts.inContainer ? "execute" : "refuse";
15
- }
16
- export const EXECUTION_REFUSED_MESSAGE = "[phoebe] Refusing to execute a work unit outside the Phoebe container. " +
17
- "Use --dry-run to preview selection on the host, or start the container loop.";
@@ -1,30 +0,0 @@
1
- export type GitRunner = (args: string[], opts?: {
2
- cwd?: string;
3
- stdio?: "inherit" | "ignore" | "pipe";
4
- }) => string;
5
- export declare const defaultGit: GitRunner;
6
- /** Clone the repo into `repoDir` unless a clone already exists there. */
7
- export declare function ensureClone(opts: {
8
- repoUrl: string;
9
- repoDir: string;
10
- }, git?: GitRunner): void;
11
- export declare function fetchOrigin(repoDir: string, git?: GitRunner): void;
12
- export declare function originBranchSha(repoDir: string, branch: string, git?: GitRunner): string;
13
- /** Filesystem-safe worktree directory name for a branch. */
14
- export declare function worktreeDirForBranch(worktreesDir: string, branch: string): string;
15
- /** Create a worktree on a (possibly new) branch reset to `baseRef`. */
16
- export declare function addWorktreeForNewBranch(opts: {
17
- repoDir: string;
18
- worktreeDir: string;
19
- branch: string;
20
- baseRef: string;
21
- }, git?: GitRunner): void;
22
- /** Create a worktree on an existing branch (local first, then origin/<branch>). */
23
- export declare function addWorktreeForExistingBranch(opts: {
24
- repoDir: string;
25
- worktreeDir: string;
26
- branch: string;
27
- }, git?: GitRunner): void;
28
- export declare function removeWorktree(repoDir: string, worktreeDir: string, git?: GitRunner): void;
29
- export declare function commitCount(worktreeDir: string, range: string, git?: GitRunner): number;
30
- export declare function pushBranch(worktreeDir: string, branch: string, git?: GitRunner): void;
@@ -1,71 +0,0 @@
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
- import { execFileSync } from "node:child_process";
6
- import { existsSync, mkdirSync, rmSync } from "node:fs";
7
- import { join } from "node:path";
8
- export const defaultGit = (args, opts) => execFileSync("git", args, {
9
- encoding: "utf8",
10
- ...(opts?.cwd ? { cwd: opts.cwd } : {}),
11
- ...(opts?.stdio ? { stdio: opts.stdio } : {}),
12
- });
13
- /** Clone the repo into `repoDir` unless a clone already exists there. */
14
- export function ensureClone(opts, git = defaultGit) {
15
- if (existsSync(join(opts.repoDir, ".git")))
16
- return;
17
- mkdirSync(opts.repoDir, { recursive: true });
18
- git(["clone", opts.repoUrl, opts.repoDir], { stdio: "inherit" });
19
- }
20
- export function fetchOrigin(repoDir, git = defaultGit) {
21
- git(["fetch", "origin"], { cwd: repoDir, stdio: "inherit" });
22
- }
23
- export function originBranchSha(repoDir, branch, git = defaultGit) {
24
- return git(["rev-parse", `origin/${branch}`], { cwd: repoDir }).trim();
25
- }
26
- /** Filesystem-safe worktree directory name for a branch. */
27
- export function worktreeDirForBranch(worktreesDir, branch) {
28
- return join(worktreesDir, branch.toLowerCase().replace(/[^a-z0-9]/g, "-"));
29
- }
30
- /** Create a worktree on a (possibly new) branch reset to `baseRef`. */
31
- export function addWorktreeForNewBranch(opts, git = defaultGit) {
32
- git(["worktree", "add", "-B", opts.branch, opts.worktreeDir, opts.baseRef], {
33
- cwd: opts.repoDir,
34
- stdio: "inherit",
35
- });
36
- }
37
- /** Create a worktree on an existing branch (local first, then origin/<branch>). */
38
- export function addWorktreeForExistingBranch(opts, git = defaultGit) {
39
- try {
40
- git(["worktree", "add", opts.worktreeDir, opts.branch], {
41
- cwd: opts.repoDir,
42
- stdio: "inherit",
43
- });
44
- }
45
- catch {
46
- git(["worktree", "add", "-B", opts.branch, opts.worktreeDir, `origin/${opts.branch}`], {
47
- cwd: opts.repoDir,
48
- stdio: "inherit",
49
- });
50
- }
51
- }
52
- export function removeWorktree(repoDir, worktreeDir, git = defaultGit) {
53
- try {
54
- git(["worktree", "remove", "--force", worktreeDir], { cwd: repoDir, stdio: "ignore" });
55
- }
56
- catch {
57
- rmSync(worktreeDir, { recursive: true, force: true });
58
- }
59
- try {
60
- git(["worktree", "prune"], { cwd: repoDir, stdio: "ignore" });
61
- }
62
- catch {
63
- // Best-effort.
64
- }
65
- }
66
- export function commitCount(worktreeDir, range, git = defaultGit) {
67
- return Number(git(["rev-list", "--count", range], { cwd: worktreeDir }).trim());
68
- }
69
- export function pushBranch(worktreeDir, branch, git = defaultGit) {
70
- git(["push", "origin", branch], { cwd: worktreeDir, stdio: "inherit" });
71
- }
@@ -1,2 +0,0 @@
1
- export { defineConfig } from "./load-config.ts";
2
- export type { PhoebeConfig, PhoebeUserConfig, PathsConfig, PromptFilesConfig, ProviderName, } from "./config-schema.ts";
package/dist/src/index.js DELETED
@@ -1,12 +0,0 @@
1
- // Public consumer surface for `phoebe-agent`. Deliberately minimal — the
2
- // packaged v1 has no programmatic runtime API, only the `phoebe` bin. Import
3
- // this module for type-checked config authoring:
4
- //
5
- // ```ts
6
- // import { defineConfig, type PhoebeUserConfig } from "phoebe-agent";
7
- // export default defineConfig({ repoSlug: "...", ... });
8
- // ```
9
- //
10
- // Types are structurally identical to their source in ./config-schema.ts;
11
- // `defineConfig` is the identity typing helper from ./load-config.ts.
12
- export { defineConfig } from "./load-config.js";
@@ -1,82 +0,0 @@
1
- /** Placeholder tokens rendered into every scaffolded file. */
2
- export type TemplateParams = {
3
- /** The consumer's install command (also written into the config). */
4
- installCommand: string;
5
- /** The npm package name of the CLI — normally `phoebe-agent`. */
6
- cliBin: string;
7
- };
8
- export declare const DEFAULT_TEMPLATE_PARAMS: TemplateParams;
9
- /**
10
- * One consumer-owned output produced by init. Sources are one of:
11
- * - `template`: a file under the shipped `templates/` tree, rendered with
12
- * placeholder substitution.
13
- * - `shipped-prompt`: a file under the shipped `prompts/` tree, copied
14
- * verbatim (prompts already carry their own `{{…}}` markers that the
15
- * engine expands at run time — do NOT substitute those here).
16
- * - `gitignore`: an additive-merge marker; `entries` are appended to any
17
- * existing `.gitignore`, deduped line-wise.
18
- */
19
- export type PlannedOutput = {
20
- destRelPath: string;
21
- source: {
22
- kind: "template";
23
- templateRelPath: string;
24
- executable?: boolean;
25
- } | {
26
- kind: "shipped-prompt";
27
- promptRelPath: string;
28
- } | {
29
- kind: "gitignore";
30
- entries: readonly string[];
31
- };
32
- };
33
- /**
34
- * Enumerate every file init will produce. The prompt list is derived from
35
- * `CONFIG_DEFAULTS.promptFiles` so adding a new prompt kind to the engine
36
- * automatically gets scaffolded — no drift between the two lists.
37
- */
38
- export declare function planInitOutputs(): PlannedOutput[];
39
- /**
40
- * Substitute `{{KEY}}` tokens with the matching value. Unknown tokens throw
41
- * so a typo in a template surfaces during tests rather than silently landing
42
- * in a consumer's repo. Iteration is over the params (not a regex over the
43
- * source) so a value that happens to contain a `{{…}}`-shaped substring is
44
- * never re-scanned.
45
- */
46
- export declare function renderTemplate(source: string, params: TemplateParams): string;
47
- /**
48
- * Additive `.gitignore` merge — append any missing entries under a `# Phoebe`
49
- * header, leaving existing lines untouched. An empty file becomes a bare list
50
- * (no header) because there's nothing else to distinguish it from.
51
- */
52
- export declare function mergeGitignore(existing: string, entries: readonly string[]): string;
53
- export type InitReport = {
54
- /** New files written (destination paths, relative to the target dir). */
55
- created: string[];
56
- /** `.gitignore` entries appended in-place (destination paths). */
57
- updated: string[];
58
- /** Existing files left alone (destination paths). */
59
- skipped: string[];
60
- };
61
- export type RunInitOptions = {
62
- /** Directory the scaffolded files land under. Created if missing. */
63
- targetDir: string;
64
- /** Override template params (`installCommand`, `cliBin`). */
65
- params?: Partial<TemplateParams>;
66
- /** Root for shipped `templates/` and `prompts/` (test seam). Defaults to
67
- * the walk-up from this module. */
68
- packageRoot?: string;
69
- };
70
- /**
71
- * Execute the plan: create missing files, additively update `.gitignore`, and
72
- * leave every existing file alone. Returns a report so the CLI (and tests)
73
- * can render a summary without re-walking the filesystem.
74
- *
75
- * Not idempotent in the "produces the same output twice" sense — running init
76
- * twice on a directory the consumer has edited must not change their files.
77
- * That's the entire guarded-re-run contract. A second run into an empty
78
- * directory *does* reproduce the first-run output.
79
- */
80
- export declare function runInit(opts: RunInitOptions): InitReport;
81
- /** Human-readable summary suitable for the CLI to stdout after init runs. */
82
- export declare function formatInitReport(report: InitReport, targetDir: string): string;
package/dist/src/init.js DELETED
@@ -1,207 +0,0 @@
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, base compose, daemon overlay,
5
- // supervisor script). Re-runs are guarded — an existing file is skipped, not
6
- // 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
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
- import { basename, dirname, join, resolve as resolvePath } from "node:path";
12
- import { fileURLToPath } from "node:url";
13
- import { CONFIG_DEFAULTS } from "./config-schema.js";
14
- export const DEFAULT_TEMPLATE_PARAMS = {
15
- installCommand: "npm ci",
16
- cliBin: "phoebe-agent",
17
- };
18
- const GITIGNORE_ENTRIES = [".env", "node_modules/"];
19
- /**
20
- * Enumerate every file init will produce. The prompt list is derived from
21
- * `CONFIG_DEFAULTS.promptFiles` so adding a new prompt kind to the engine
22
- * automatically gets scaffolded — no drift between the two lists.
23
- */
24
- export function planInitOutputs() {
25
- const promptOutputs = Object.values(CONFIG_DEFAULTS.promptFiles).map((relPath) => ({
26
- destRelPath: relPath,
27
- source: { kind: "shipped-prompt", promptRelPath: relPath },
28
- }));
29
- return [
30
- {
31
- destRelPath: "phoebe.config.ts",
32
- source: { kind: "template", templateRelPath: "phoebe.config.ts" },
33
- },
34
- {
35
- destRelPath: ".env.example",
36
- source: { kind: "template", templateRelPath: ".env.example" },
37
- },
38
- {
39
- destRelPath: "container/Dockerfile",
40
- source: { kind: "template", templateRelPath: "container/Dockerfile" },
41
- },
42
- {
43
- destRelPath: "container/compose.yml",
44
- source: { kind: "template", templateRelPath: "container/compose.yml" },
45
- },
46
- {
47
- destRelPath: "container/compose.daemon.yml",
48
- source: { kind: "template", templateRelPath: "container/compose.daemon.yml" },
49
- },
50
- {
51
- destRelPath: "container/supervisor.sh",
52
- source: { kind: "template", templateRelPath: "container/supervisor.sh", executable: true },
53
- },
54
- ...promptOutputs,
55
- {
56
- destRelPath: ".gitignore",
57
- source: { kind: "gitignore", entries: GITIGNORE_ENTRIES },
58
- },
59
- ];
60
- }
61
- /**
62
- * Substitute `{{KEY}}` tokens with the matching value. Unknown tokens throw
63
- * so a typo in a template surfaces during tests rather than silently landing
64
- * in a consumer's repo. Iteration is over the params (not a regex over the
65
- * source) so a value that happens to contain a `{{…}}`-shaped substring is
66
- * never re-scanned.
67
- */
68
- export function renderTemplate(source, params) {
69
- let rendered = source;
70
- for (const [key, value] of Object.entries(params)) {
71
- const token = `{{${keyToToken(key)}}}`;
72
- rendered = rendered.split(token).join(value);
73
- }
74
- const leftover = /\{\{([A-Z_]+)\}\}/.exec(rendered);
75
- if (leftover) {
76
- throw new Error(`Template contained an unrenderable placeholder \`{{${leftover[1]}}}\` — ` +
77
- `every {{TOKEN}} in a scaffolded file must map to a TemplateParams field.`);
78
- }
79
- return rendered;
80
- }
81
- function keyToToken(key) {
82
- // installCommand -> INSTALL_COMMAND, cliBin -> CLI_BIN
83
- return key.replace(/([A-Z])/g, "_$1").toUpperCase();
84
- }
85
- /**
86
- * Additive `.gitignore` merge — append any missing entries under a `# Phoebe`
87
- * header, leaving existing lines untouched. An empty file becomes a bare list
88
- * (no header) because there's nothing else to distinguish it from.
89
- */
90
- export function mergeGitignore(existing, entries) {
91
- const existingLines = new Set(existing
92
- .split("\n")
93
- .map((line) => line.trim())
94
- .filter((line) => line.length > 0 && !line.startsWith("#")));
95
- const missing = entries.filter((entry) => !existingLines.has(entry));
96
- if (missing.length === 0) {
97
- return existing;
98
- }
99
- if (existing.trim().length === 0) {
100
- return `${missing.join("\n")}\n`;
101
- }
102
- const separator = existing.endsWith("\n") ? "" : "\n";
103
- return `${existing}${separator}\n# Phoebe\n${missing.join("\n")}\n`;
104
- }
105
- /**
106
- * Walk up from this module's directory to find the shipped resource root. We
107
- * emit to `dist/src/init.js` and read `templates/…` + `prompts/…` from the
108
- * package root, so the walk-up mirrors `resolvePackageFile` in main.ts. Stops
109
- * at a `node_modules` boundary so an installed dep never resolves resources
110
- * from the consuming repo.
111
- */
112
- function resolvePackageResource(relativePath, moduleDir) {
113
- let dir = moduleDir;
114
- while (true) {
115
- const candidate = join(dir, relativePath);
116
- if (existsSync(candidate)) {
117
- return candidate;
118
- }
119
- const parent = dirname(dir);
120
- if (parent === dir || basename(parent) === "node_modules") {
121
- throw new Error(`Could not find ${relativePath} within the Phoebe package (searched from ${moduleDir})`);
122
- }
123
- dir = parent;
124
- }
125
- }
126
- function readShippedFile(relPath, packageRoot, moduleDir) {
127
- const absolute = packageRoot
128
- ? resolvePath(packageRoot, relPath)
129
- : resolvePackageResource(relPath, moduleDir);
130
- return readFileSync(absolute, "utf8");
131
- }
132
- /**
133
- * Execute the plan: create missing files, additively update `.gitignore`, and
134
- * leave every existing file alone. Returns a report so the CLI (and tests)
135
- * can render a summary without re-walking the filesystem.
136
- *
137
- * Not idempotent in the "produces the same output twice" sense — running init
138
- * twice on a directory the consumer has edited must not change their files.
139
- * That's the entire guarded-re-run contract. A second run into an empty
140
- * directory *does* reproduce the first-run output.
141
- */
142
- export function runInit(opts) {
143
- const targetDir = resolvePath(opts.targetDir);
144
- const params = { ...DEFAULT_TEMPLATE_PARAMS, ...opts.params };
145
- const moduleDir = dirname(fileURLToPath(import.meta.url));
146
- mkdirSync(targetDir, { recursive: true });
147
- const report = { created: [], updated: [], skipped: [] };
148
- for (const output of planInitOutputs()) {
149
- const destAbs = join(targetDir, output.destRelPath);
150
- mkdirSync(dirname(destAbs), { recursive: true });
151
- if (output.source.kind === "gitignore") {
152
- const existing = existsSync(destAbs) ? readFileSync(destAbs, "utf8") : "";
153
- const merged = mergeGitignore(existing, output.source.entries);
154
- if (merged === existing) {
155
- report.skipped.push(output.destRelPath);
156
- }
157
- else if (existing.length === 0) {
158
- writeFileSync(destAbs, merged);
159
- report.created.push(output.destRelPath);
160
- }
161
- else {
162
- writeFileSync(destAbs, merged);
163
- report.updated.push(output.destRelPath);
164
- }
165
- continue;
166
- }
167
- if (existsSync(destAbs)) {
168
- report.skipped.push(output.destRelPath);
169
- continue;
170
- }
171
- if (output.source.kind === "template") {
172
- const rawTemplate = readShippedFile(join("templates", output.source.templateRelPath), opts.packageRoot, moduleDir);
173
- const rendered = renderTemplate(rawTemplate, params);
174
- writeFileSync(destAbs, rendered, {
175
- mode: output.source.executable ? 0o755 : 0o644,
176
- });
177
- }
178
- else {
179
- // Shipped prompts ship verbatim — the engine's own render step handles
180
- // their `{{PLACEHOLDER}}` tokens at run time.
181
- const prompt = readShippedFile(output.source.promptRelPath, opts.packageRoot, moduleDir);
182
- writeFileSync(destAbs, prompt);
183
- }
184
- report.created.push(output.destRelPath);
185
- }
186
- return report;
187
- }
188
- /** Human-readable summary suitable for the CLI to stdout after init runs. */
189
- export function formatInitReport(report, targetDir) {
190
- const lines = [`[phoebe] init → ${targetDir}`];
191
- const emit = (label, paths) => {
192
- if (paths.length === 0)
193
- return;
194
- lines.push(` ${label}:`);
195
- for (const path of paths) {
196
- lines.push(` ${path}`);
197
- }
198
- };
199
- emit("created", report.created);
200
- emit("updated", report.updated);
201
- emit("skipped (already present)", report.skipped);
202
- if (report.skipped.length > 0) {
203
- lines.push("");
204
- lines.push("Existing files were left untouched. Delete them and re-run init to regenerate.");
205
- }
206
- return `${lines.join("\n")}\n`;
207
- }
@@ -1,88 +0,0 @@
1
- import type { PhoebeUserConfig } from "./config-schema.ts";
2
- /**
3
- * Identity function that types a consumer's `phoebe.config.ts` export as
4
- * `PhoebeUserConfig`. Consumers write:
5
- *
6
- * ```ts
7
- * import { defineConfig } from "phoebe-agent";
8
- * export default defineConfig({ repoSlug: "...", ... });
9
- * ```
10
- *
11
- * The engine never reads this at runtime beyond forwarding the value; the
12
- * whole benefit is editor autocomplete and a compile-time check that only
13
- * known fields appear.
14
- */
15
- export declare function defineConfig(config: PhoebeUserConfig): PhoebeUserConfig;
16
- /**
17
- * Scalar-only overlay: each `PHOEBE_*` env var, when set to a non-empty
18
- * string, replaces the corresponding user-config field. Nested records
19
- * (`promptFiles`, `paths`, `defaultModels`, `providerEnv`, `workOrder`) stay
20
- * config-file territory — env vars are for one-off run overrides where the
21
- * consumer doesn't want to edit `phoebe.config.ts`, and expanding structured
22
- * shapes into env keys defeats the point.
23
- *
24
- * A field-scoped list (rather than magic name-mangling) keeps the surface
25
- * documented and predictable: users can grep for `PHOEBE_` here and see the
26
- * complete overlay contract.
27
- */
28
- export declare const ENV_OVERLAY_KEYS: readonly [{
29
- readonly env: "PHOEBE_REPO_SLUG";
30
- readonly key: "repoSlug";
31
- }, {
32
- readonly env: "PHOEBE_REPO_URL";
33
- readonly key: "repoUrl";
34
- }, {
35
- readonly env: "PHOEBE_DEFAULT_BRANCH";
36
- readonly key: "defaultBranch";
37
- }, {
38
- readonly env: "PHOEBE_BRANCH_PREFIX";
39
- readonly key: "branchPrefix";
40
- }, {
41
- readonly env: "PHOEBE_READY_LABEL";
42
- readonly key: "readyLabel";
43
- }, {
44
- readonly env: "PHOEBE_PROCESSING_LABEL";
45
- readonly key: "processingLabel";
46
- }, {
47
- readonly env: "PHOEBE_PR_OPT_OUT_LABEL";
48
- readonly key: "prOptOutLabel";
49
- }, {
50
- readonly env: "PHOEBE_INSTALL_COMMAND";
51
- readonly key: "installCommand";
52
- }, {
53
- readonly env: "PHOEBE_CHECK_COMMAND";
54
- readonly key: "checkCommand";
55
- }, {
56
- readonly env: "PHOEBE_TEST_COMMAND";
57
- readonly key: "testCommand";
58
- }, {
59
- readonly env: "PHOEBE_READY_COMMAND";
60
- readonly key: "readyCommand";
61
- }, {
62
- readonly env: "PHOEBE_BLOCKED_BY_PATTERN";
63
- readonly key: "blockedByPattern";
64
- }, {
65
- readonly env: "PHOEBE_REVIEWS_SUCCESS_HEADING";
66
- readonly key: "reviewsSuccessHeading";
67
- }];
68
- /**
69
- * Apply the `PHOEBE_*` overlay onto a user config and return a new object.
70
- * The overlay is additive over what the config file declared — an unset env
71
- * var leaves the field untouched (so `resolveConfig` can still fall back to
72
- * `CONFIG_DEFAULTS` if the field was also absent from the config file).
73
- */
74
- export declare function applyEnvOverlay(user: PhoebeUserConfig, env: NodeJS.ProcessEnv): PhoebeUserConfig;
75
- /**
76
- * Resolve a `--config` argument (or the default) to an absolute path and
77
- * assert the file exists. Split from `loadUserConfig` so the CLI can print
78
- * a precise "file not found" message before attempting the dynamic import.
79
- */
80
- export declare function resolveConfigPath(argPath: string | undefined, cwd: string): string;
81
- /**
82
- * Dynamically import a `phoebe.config.ts` and return the user shape. Native
83
- * Node type-stripping (Node ≥ 22.7 with `--experimental-strip-types`, or
84
- * ≥ 23 by default) handles the TS syntax — no bundler needed on the consumer
85
- * side. Accepts either a default export or a named `config` export so the
86
- * pre-`defineConfig` scaffold still loads.
87
- */
88
- export declare function loadUserConfig(configPath: string): Promise<PhoebeUserConfig>;