phoebe-agent 0.0.0 → 0.1.1
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.
- package/README.md +77 -8
- package/bootstrap/bin.mjs +42 -0
- package/bootstrap/boot.ts +431 -0
- package/bootstrap/cli.ts +29 -0
- package/bootstrap/crash-loop.ts +391 -0
- package/bootstrap/define-config.ts +20 -0
- package/bootstrap/engine-source.ts +83 -0
- package/bootstrap/github-engine.ts +169 -0
- package/bootstrap/index.mjs +9 -0
- package/bootstrap/index.ts +24 -0
- package/bootstrap/materialize.mjs +53 -0
- package/bootstrap/reconcile.ts +314 -0
- package/bootstrap/spawn-engine.mjs +78 -0
- package/package.json +26 -24
- package/prompts/checks-prompt.md +21 -6
- package/prompts/conflict-prompt.md +12 -1
- package/prompts/research-prompt.md +61 -0
- package/src/agent-env.ts +32 -0
- package/src/branded.ts +19 -0
- package/src/cli.ts +187 -0
- package/src/config-schema.ts +278 -0
- package/src/drain.ts +74 -0
- package/src/execution-gate.ts +27 -0
- package/src/git-model.ts +145 -0
- package/src/init.ts +277 -0
- package/src/load-config.ts +168 -0
- package/src/main.ts +1636 -0
- package/src/orchestrator.ts +953 -0
- package/src/prompt.ts +98 -0
- package/src/providers/providers.ts +222 -0
- package/src/providers/run-agent.ts +90 -0
- package/src/providers/types.ts +26 -0
- package/src/resolved-config.ts +55 -0
- package/templates/.env.example +17 -5
- package/templates/container/Dockerfile +128 -19
- package/templates/container/compose.local.yml +37 -0
- package/templates/container/compose.yml +43 -13
- package/templates/phoebe.config.ts +30 -6
- package/dist/phoebe.config.d.ts +0 -2
- package/dist/phoebe.config.js +0 -43
- package/dist/src/agent-env.d.ts +0 -6
- package/dist/src/agent-env.js +0 -24
- package/dist/src/cli.d.ts +0 -25
- package/dist/src/cli.js +0 -161
- package/dist/src/config-schema.d.ts +0 -163
- package/dist/src/config-schema.js +0 -140
- package/dist/src/execution-gate.d.ts +0 -9
- package/dist/src/execution-gate.js +0 -17
- package/dist/src/git-model.d.ts +0 -30
- package/dist/src/git-model.js +0 -71
- package/dist/src/index.d.ts +0 -2
- package/dist/src/index.js +0 -12
- package/dist/src/init.d.ts +0 -82
- package/dist/src/init.js +0 -207
- package/dist/src/load-config.d.ts +0 -88
- package/dist/src/load-config.js +0 -153
- package/dist/src/main.d.ts +0 -7
- package/dist/src/main.js +0 -1180
- package/dist/src/orchestrator.d.ts +0 -275
- package/dist/src/orchestrator.js +0 -579
- package/dist/src/prompt.d.ts +0 -13
- package/dist/src/prompt.js +0 -55
- package/dist/src/providers/providers.d.ts +0 -3
- package/dist/src/providers/providers.js +0 -210
- package/dist/src/providers/run-agent.d.ts +0 -34
- package/dist/src/providers/run-agent.js +0 -57
- package/dist/src/providers/types.d.ts +0 -27
- package/dist/src/providers/types.js +0 -6
- package/dist/src/resolved-config.d.ts +0 -8
- package/dist/src/resolved-config.js +0 -49
- package/dist/src/supervisor-decision.d.ts +0 -70
- package/dist/src/supervisor-decision.js +0 -94
- package/templates/container/compose.daemon.yml +0 -16
- package/templates/container/supervisor.sh +0 -50
- /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.";
|
package/src/git-model.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
/**
|
|
27
|
+
* Clone the repo into `repoDir` unless a clone already exists there.
|
|
28
|
+
*
|
|
29
|
+
* An existing clone is only adopted if its `origin` actually points at
|
|
30
|
+
* `repoUrl`. A `/data/repo` volume is supposed to be this instance's private
|
|
31
|
+
* clone, but two Phoebe instances on one host can end up sharing it (a compose
|
|
32
|
+
* project-name collision namespaces the "private" volumes identically). Adopting
|
|
33
|
+
* a foreign clone by mere presence of `.git` would silently run every worktree,
|
|
34
|
+
* branch, and push against the wrong repo while `gh` calls still used this
|
|
35
|
+
* instance's `repoSlug` — reading one repo's issues and doing the work on
|
|
36
|
+
* another's tree. So a mismatch fails loudly instead: isolate the instances (see
|
|
37
|
+
* `COMPOSE_PROJECT_NAME`) or wipe the shared volume.
|
|
38
|
+
*/
|
|
39
|
+
export function ensureClone(
|
|
40
|
+
opts: { repoUrl: string; repoDir: string },
|
|
41
|
+
git: GitRunner = defaultGit,
|
|
42
|
+
): void {
|
|
43
|
+
if (existsSync(join(opts.repoDir, ".git"))) {
|
|
44
|
+
// `git config --get` exits non-zero when the key is unset, which `defaultGit`
|
|
45
|
+
// surfaces as a throw. An unreadable origin is not the configured `repoUrl`,
|
|
46
|
+
// so treat it as an absent origin and fall through to the refusal below —
|
|
47
|
+
// reaching the explicit `<none>` message rather than a raw `Command failed`.
|
|
48
|
+
let origin = "";
|
|
49
|
+
try {
|
|
50
|
+
origin = git(["config", "--get", "remote.origin.url"], { cwd: opts.repoDir }).trim();
|
|
51
|
+
} catch {
|
|
52
|
+
origin = "";
|
|
53
|
+
}
|
|
54
|
+
if (origin !== opts.repoUrl) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Existing clone at ${opts.repoDir} has origin \`${origin || "<none>"}\`, but this ` +
|
|
57
|
+
`Phoebe is configured for \`${opts.repoUrl}\`. Refusing to work a foreign clone — the ` +
|
|
58
|
+
`state volume is shared with another instance. Give each instance a distinct compose ` +
|
|
59
|
+
`project (COMPOSE_PROJECT_NAME), or wipe this volume, then retry.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
mkdirSync(opts.repoDir, { recursive: true });
|
|
65
|
+
git(["clone", opts.repoUrl, opts.repoDir], { stdio: "inherit" });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function fetchOrigin(repoDir: string, git: GitRunner = defaultGit): void {
|
|
69
|
+
git(["fetch", "origin"], { cwd: repoDir, stdio: "inherit" });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function originBranchSha(
|
|
73
|
+
repoDir: string,
|
|
74
|
+
branch: BranchRef,
|
|
75
|
+
git: GitRunner = defaultGit,
|
|
76
|
+
): Sha {
|
|
77
|
+
return asSha(git(["rev-parse", `origin/${branch}`], { cwd: repoDir }).trim());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Filesystem-safe worktree directory name for a branch. */
|
|
81
|
+
export function worktreeDirForBranch(worktreesDir: string, branch: BranchRef): string {
|
|
82
|
+
return join(worktreesDir, branch.toLowerCase().replace(/[^a-z0-9]/g, "-"));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Create a worktree on a (possibly new) branch reset to `baseRef`. */
|
|
86
|
+
export function addWorktreeForNewBranch(
|
|
87
|
+
opts: { repoDir: string; worktreeDir: string; branch: BranchRef; baseRef: string },
|
|
88
|
+
git: GitRunner = defaultGit,
|
|
89
|
+
): void {
|
|
90
|
+
git(["worktree", "add", "-B", opts.branch, opts.worktreeDir, opts.baseRef], {
|
|
91
|
+
cwd: opts.repoDir,
|
|
92
|
+
stdio: "inherit",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Create a worktree on an existing branch (local first, then origin/<branch>). */
|
|
97
|
+
export function addWorktreeForExistingBranch(
|
|
98
|
+
opts: { repoDir: string; worktreeDir: string; branch: BranchRef },
|
|
99
|
+
git: GitRunner = defaultGit,
|
|
100
|
+
): void {
|
|
101
|
+
try {
|
|
102
|
+
git(["worktree", "add", opts.worktreeDir, opts.branch], {
|
|
103
|
+
cwd: opts.repoDir,
|
|
104
|
+
stdio: "inherit",
|
|
105
|
+
});
|
|
106
|
+
} catch {
|
|
107
|
+
git(["worktree", "add", "-B", opts.branch, opts.worktreeDir, `origin/${opts.branch}`], {
|
|
108
|
+
cwd: opts.repoDir,
|
|
109
|
+
stdio: "inherit",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function removeWorktree(
|
|
115
|
+
repoDir: string,
|
|
116
|
+
worktreeDir: string,
|
|
117
|
+
git: GitRunner = defaultGit,
|
|
118
|
+
): void {
|
|
119
|
+
try {
|
|
120
|
+
git(["worktree", "remove", "--force", worktreeDir], { cwd: repoDir, stdio: "ignore" });
|
|
121
|
+
} catch {
|
|
122
|
+
rmSync(worktreeDir, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
git(["worktree", "prune"], { cwd: repoDir, stdio: "ignore" });
|
|
126
|
+
} catch {
|
|
127
|
+
// Best-effort.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function commitCount(
|
|
132
|
+
worktreeDir: string,
|
|
133
|
+
range: string,
|
|
134
|
+
git: GitRunner = defaultGit,
|
|
135
|
+
): number {
|
|
136
|
+
return Number(git(["rev-list", "--count", range], { cwd: worktreeDir }).trim());
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function pushBranch(
|
|
140
|
+
worktreeDir: string,
|
|
141
|
+
branch: BranchRef,
|
|
142
|
+
git: GitRunner = defaultGit,
|
|
143
|
+
): void {
|
|
144
|
+
git(["push", "origin", branch], { cwd: worktreeDir, stdio: "inherit" });
|
|
145
|
+
}
|
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
|
+
}
|