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.
- package/README.md +77 -8
- package/bootstrap/bin.mjs +42 -0
- package/bootstrap/boot.ts +383 -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 +113 -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 +11 -5
- package/templates/container/Dockerfile +41 -19
- package/templates/container/compose.local.yml +37 -0
- package/templates/container/compose.yml +34 -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,61 @@
|
|
|
1
|
+
# Context
|
|
2
|
+
|
|
3
|
+
## Assigned research ticket
|
|
4
|
+
|
|
5
|
+
You are resolving **exactly** wayfinder research ticket **#{{ISSUE_NUMBER}}** — the orchestrator selected it before this run started. Do not pick a different ticket. It is a child of a `wayfinder:map` issue and carries the `{{RESEARCH_LABEL}}` label.
|
|
6
|
+
|
|
7
|
+
!`gh issue view {{ISSUE_NUMBER}} --json number,title,body,labels,comments --jq '{number, title, body, labels: [.labels[].name], comments: [.comments[].body]}'`
|
|
8
|
+
|
|
9
|
+
## Candidate parent maps
|
|
10
|
+
|
|
11
|
+
!`gh issue list --label "wayfinder:map" --state open --json number,title --jq '[.[] | {number, title}]'`
|
|
12
|
+
|
|
13
|
+
# Task
|
|
14
|
+
|
|
15
|
+
You are Phoebe — resolving a **wayfinder research ticket** (an AFK ticket type: reading primary sources and recording what you found). Your job is to answer the ticket's Question from authoritative sources, produce a Markdown summary, and run wayfinder's resolution protocol.
|
|
16
|
+
|
|
17
|
+
**Before anything else, read `AGENTS.md` at the repo root, if present**, and — if it exists — the wayfinder skill at `.agents/skills/wayfinder/SKILL.md`. AGENTS.md is the single source of project guidance and overrides your defaults; the wayfinder skill defines the map/ticket protocol you are resolving against.
|
|
18
|
+
|
|
19
|
+
## Workflow
|
|
20
|
+
|
|
21
|
+
1. **Read the Question.** The ticket body holds a `## Question` — the decision or investigation this ticket resolves, sized to one session. Resolve _that_, nothing broader.
|
|
22
|
+
|
|
23
|
+
2. **Find the parent map.** This ticket is a child of one `wayfinder:map` issue. Identify it from a reference in the ticket body, its sub-issue relationship, or by reading the candidate maps listed above (their bodies point at their tickets). Load the map body — you need its `## Decisions so far` and `## Notes` (the Notes name skills every session should consult). If you genuinely cannot identify the map, say so in the resolution comment and continue without the map update.
|
|
24
|
+
|
|
25
|
+
3. **Investigate primary sources.** Read official documentation, third-party API references, and local resources (knowledge bases, source in this repo). Prefer authoritative **primary** sources over summaries; record exactly where each fact came from. Never invent findings — an honest "the docs don't say" is a valid answer.
|
|
26
|
+
|
|
27
|
+
4. **Produce the Markdown summary — pick the output shape the finding needs:**
|
|
28
|
+
- **Issue-level artifact (default).** Most research resolves this way: the summary lives as a comment (or a linked asset) on the ticket, with no code change. Leave the worktree without commits.
|
|
29
|
+
- **Committed doc (PR).** _Only_ when the finding is naturally a document that belongs in the repo (a reference note others will read from the tree). Write it where the repo already keeps such notes — match the existing convention; do not invent a new location. Commit it with a `Phoebe:` prefix. The host will push the branch and open a PR whose body closes this ticket.
|
|
30
|
+
|
|
31
|
+
5. **Verify (committed-doc case only).** If you committed a doc and the repo has gates, run `{{READY_COMMAND}}` (or `{{CHECK_COMMAND}}` and `{{TEST_COMMAND}}`) and fix any failure before finishing. A pure issue-level artifact skips this.
|
|
32
|
+
|
|
33
|
+
6. **Resolve per wayfinder protocol:**
|
|
34
|
+
1. **Post the resolution comment** on ticket #{{ISSUE_NUMBER}} — the answer itself, or the summary with a link to the committed doc/asset:
|
|
35
|
+
```
|
|
36
|
+
gh issue comment {{ISSUE_NUMBER}} --body "<answer + sources>"
|
|
37
|
+
```
|
|
38
|
+
2. **Close the ticket — but only in the issue-level-artifact case:**
|
|
39
|
+
```
|
|
40
|
+
gh issue close {{ISSUE_NUMBER}}
|
|
41
|
+
```
|
|
42
|
+
If you committed a doc, **do not close it here** — the PR the host opens carries `Closes #{{ISSUE_NUMBER}}` and closes it on merge.
|
|
43
|
+
3. **Append a pointer to the map's `## Decisions so far`** — one line, referring to the ticket by name (never a bare number), gisting the answer so a future session can judge relevance and zoom the link for detail. Fetch the map body, add the line under that heading, and write it back:
|
|
44
|
+
```
|
|
45
|
+
- [<ticket title>](<ticket url>) — <one-line gist of the answer>
|
|
46
|
+
```
|
|
47
|
+
Use `gh issue edit <map-number> --body-file -` with the amended body. Do not restate the full answer on the map — it is an index, not a store.
|
|
48
|
+
|
|
49
|
+
## Rules
|
|
50
|
+
|
|
51
|
+
- Work on **this ticket only** (#{{ISSUE_NUMBER}}). Do not resolve other tickets or edit unrelated parts of the map.
|
|
52
|
+
- Default to an **issue-level artifact**; commit a doc only when it genuinely belongs in the repo.
|
|
53
|
+
- **Close the ticket yourself only when you did not commit a doc.** A committed-doc PR closes it on merge — closing twice or closing before merge is wrong.
|
|
54
|
+
- Cite sources for every finding; do not present guesses as facts.
|
|
55
|
+
- If you are blocked (the Question needs a human decision, a source is inaccessible, the map is unfindable), post what you have as a comment explaining the blocker and leave the ticket **open** — do not close it.
|
|
56
|
+
|
|
57
|
+
# Done
|
|
58
|
+
|
|
59
|
+
When ticket #{{ISSUE_NUMBER}} is resolved (or you are blocked and have commented), output the completion signal:
|
|
60
|
+
|
|
61
|
+
<promise>COMPLETE</promise>
|
package/src/agent-env.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
|
|
6
|
+
import type { PhoebeConfig, ProviderName } from "./config-schema.ts";
|
|
7
|
+
|
|
8
|
+
const BASE_ALLOWLIST = [
|
|
9
|
+
"PATH",
|
|
10
|
+
"HOME",
|
|
11
|
+
"GH_TOKEN",
|
|
12
|
+
"GIT_AUTHOR_NAME",
|
|
13
|
+
"GIT_AUTHOR_EMAIL",
|
|
14
|
+
"GIT_COMMITTER_NAME",
|
|
15
|
+
"GIT_COMMITTER_EMAIL",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export function buildAgentEnv(opts: {
|
|
19
|
+
parentEnv: Record<string, string | undefined>;
|
|
20
|
+
provider: ProviderName;
|
|
21
|
+
providerEnv: PhoebeConfig["providerEnv"];
|
|
22
|
+
}): Record<string, string> {
|
|
23
|
+
const { parentEnv, provider, providerEnv } = opts;
|
|
24
|
+
const env: Record<string, string> = { CI: "true" };
|
|
25
|
+
for (const key of [...BASE_ALLOWLIST, providerEnv[provider]]) {
|
|
26
|
+
const value = parentEnv[key];
|
|
27
|
+
if (value !== undefined && value !== "") {
|
|
28
|
+
env[key] = value;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return env;
|
|
32
|
+
}
|
package/src/branded.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Nominal (branded) types for the three primitive slots that flow through the
|
|
2
|
+
// engine and were previously bare `string` / `number`: git commit SHAs, git
|
|
3
|
+
// branch refs, and PR numbers. Before branding, any two of these passed each
|
|
4
|
+
// other's parameter slot silently — a `headRefName` (branch) could be handed to
|
|
5
|
+
// a function expecting a `headSha`, and tsc said nothing.
|
|
6
|
+
//
|
|
7
|
+
// The brand is a compile-time-only phantom field; it has no runtime footprint.
|
|
8
|
+
// `asSha` / `asBranchRef` / `asPrNumber` are identity casts applied at the trust
|
|
9
|
+
// boundary — where `gh` output is parsed and where config values are loaded —
|
|
10
|
+
// so interior code carries the distinction that tsc then enforces end-to-end.
|
|
11
|
+
// See issue #14.
|
|
12
|
+
|
|
13
|
+
export type Sha = string & { readonly __brand: "Sha" };
|
|
14
|
+
export type BranchRef = string & { readonly __brand: "BranchRef" };
|
|
15
|
+
export type PrNumber = number & { readonly __brand: "PrNumber" };
|
|
16
|
+
|
|
17
|
+
export const asSha = (value: string): Sha => value as Sha;
|
|
18
|
+
export const asBranchRef = (value: string): BranchRef => value as BranchRef;
|
|
19
|
+
export const asPrNumber = (value: number): PrNumber => value as PrNumber;
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// `phoebe` bin — the packaged CLI consumers invoke via
|
|
4
|
+
// `npx phoebe-agent [flags]` (or a pinned `phoebe` script). Recognises two
|
|
5
|
+
// modes:
|
|
6
|
+
//
|
|
7
|
+
// phoebe init [dir] Scaffold a consumer-owned runtime (config, prompts,
|
|
8
|
+
// .env.example, container templates, gitignore).
|
|
9
|
+
// Skips existing files — safe to re-run.
|
|
10
|
+
// phoebe [flags] Run the engine. Loads the consumer's
|
|
11
|
+
// `phoebe.config.ts`, overlays `PHOEBE_*` env vars,
|
|
12
|
+
// installs the resolved config, then hands off to main.
|
|
13
|
+
//
|
|
14
|
+
// This is the only supported v1 programmatic surface: there is no exported
|
|
15
|
+
// `run(config)` — CLI-only. That keeps every consumer on the same load/resolve/
|
|
16
|
+
// install pipeline and leaves the door open to CLI-only concerns (init/pin
|
|
17
|
+
// scaffolding, log formatting) without breaking a library API.
|
|
18
|
+
|
|
19
|
+
import { realpathSync } from "node:fs";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
21
|
+
import { resolveConfig } from "./config-schema.ts";
|
|
22
|
+
import { formatInitReport, runInit } from "./init.ts";
|
|
23
|
+
import { applyEnvOverlay, loadUserConfig, resolveConfigPath } from "./load-config.ts";
|
|
24
|
+
import { setResolvedConfig } from "./resolved-config.ts";
|
|
25
|
+
|
|
26
|
+
type ParsedArgs = { configPath: string | undefined; help: boolean; forward: string[] };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract `--config <path>` / `--config=<path>` / `-c <path>` and `--help`/`-h`
|
|
30
|
+
* from argv, forwarding everything else to `runEngine`. A minimal parser is
|
|
31
|
+
* enough — the engine handles its own boolean flags (`--run-once`, `--dry-run`)
|
|
32
|
+
* from the forwarded array.
|
|
33
|
+
*/
|
|
34
|
+
export function parseCliArgs(argv: readonly string[]): ParsedArgs {
|
|
35
|
+
const forward: string[] = [];
|
|
36
|
+
let configPath: string | undefined;
|
|
37
|
+
let help = false;
|
|
38
|
+
for (let i = 0; i < argv.length; i++) {
|
|
39
|
+
const arg = argv[i];
|
|
40
|
+
if (arg === "--help" || arg === "-h") {
|
|
41
|
+
help = true;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (arg === "--config" || arg === "-c") {
|
|
45
|
+
const next = argv[i + 1];
|
|
46
|
+
if (next === undefined) {
|
|
47
|
+
throw new Error(`${arg} requires a path argument (e.g. --config phoebe.config.ts).`);
|
|
48
|
+
}
|
|
49
|
+
configPath = next;
|
|
50
|
+
i += 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (arg !== undefined && arg.startsWith("--config=")) {
|
|
54
|
+
configPath = arg.slice("--config=".length);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (arg !== undefined) {
|
|
58
|
+
forward.push(arg);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { configPath, help, forward };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type ParsedInitArgs = { targetDir: string; help: boolean };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse argv left after the leading `init` token has been consumed. Supports
|
|
68
|
+
* an optional positional target directory (`phoebe init ./my-agent`) and
|
|
69
|
+
* `--help`. Extra flags are rejected loudly so a typo like `--forcee` fails
|
|
70
|
+
* fast instead of being silently ignored.
|
|
71
|
+
*/
|
|
72
|
+
export function parseInitArgs(argv: readonly string[]): ParsedInitArgs {
|
|
73
|
+
let targetDir: string | undefined;
|
|
74
|
+
let help = false;
|
|
75
|
+
for (const arg of argv) {
|
|
76
|
+
if (arg === "--help" || arg === "-h") {
|
|
77
|
+
help = true;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (arg.startsWith("-")) {
|
|
81
|
+
throw new Error(`Unknown flag \`${arg}\` for \`phoebe init\`. See \`phoebe init --help\`.`);
|
|
82
|
+
}
|
|
83
|
+
if (targetDir !== undefined) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`\`phoebe init\` takes at most one target directory (got \`${targetDir}\` and \`${arg}\`).`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
targetDir = arg;
|
|
89
|
+
}
|
|
90
|
+
return { targetDir: targetDir ?? ".", help };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const HELP_TEXT = `phoebe — AFK coding agent
|
|
94
|
+
|
|
95
|
+
Usage:
|
|
96
|
+
phoebe init [dir] Scaffold a consumer-owned runtime
|
|
97
|
+
phoebe [--config <path>] [flags] Run the engine
|
|
98
|
+
|
|
99
|
+
Options (engine mode):
|
|
100
|
+
--config, -c <path> Path to phoebe.config.ts (default: ./phoebe.config.ts)
|
|
101
|
+
--run-once Work one unit of the first one-shot-eligible kind, then exit
|
|
102
|
+
--dry-run Print the selected unit without executing it
|
|
103
|
+
--help, -h Show this message
|
|
104
|
+
|
|
105
|
+
Environment overlays (each replaces the corresponding config field):
|
|
106
|
+
PHOEBE_REPO_SLUG, PHOEBE_REPO_URL, PHOEBE_DEFAULT_BRANCH, PHOEBE_BRANCH_PREFIX,
|
|
107
|
+
PHOEBE_READY_LABEL, PHOEBE_PROCESSING_LABEL, PHOEBE_PR_OPT_OUT_LABEL,
|
|
108
|
+
PHOEBE_INSTALL_COMMAND, PHOEBE_CHECK_COMMAND, PHOEBE_TEST_COMMAND,
|
|
109
|
+
PHOEBE_READY_COMMAND, PHOEBE_BLOCKED_BY_PATTERN, PHOEBE_REVIEWS_SUCCESS_HEADING,
|
|
110
|
+
PHOEBE_PR_SCOPE, PHOEBE_DRAFT_PRS, PHOEBE_DEFAULT_PROVIDER
|
|
111
|
+
|
|
112
|
+
Runtime toggles (read directly by the engine, not overlaid onto the config):
|
|
113
|
+
PHOEBE_AGENT Provider name to use for this run (cursor|claude|codex)
|
|
114
|
+
PHOEBE_MODEL Model to use for this run
|
|
115
|
+
PHOEBE_POLL_INTERVAL_MS Persistent-mode poll interval (default 300000)
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
const INIT_HELP_TEXT = `phoebe init — scaffold a consumer-owned runtime
|
|
119
|
+
|
|
120
|
+
Usage:
|
|
121
|
+
phoebe init [dir]
|
|
122
|
+
|
|
123
|
+
Writes into [dir] (default: current directory):
|
|
124
|
+
phoebe.config.ts Consumer config starter (edit the five required fields)
|
|
125
|
+
prompts/ Copies of the shipped agent prompts (edit to override)
|
|
126
|
+
.env.example Documented environment variables to copy to .env
|
|
127
|
+
.gitignore Additive — appends Phoebe entries only
|
|
128
|
+
container/Dockerfile Runtime image (Node 24 + git + gh, entrypoint: phoebe boot)
|
|
129
|
+
container/compose.yml Compose config for the long-lived boot container
|
|
130
|
+
container/compose.local.yml Dev overlay to run an engine checkout from your host
|
|
131
|
+
|
|
132
|
+
Existing files are left untouched, so re-running is safe. To regenerate a
|
|
133
|
+
scaffolded file, delete it first and re-run \`phoebe init\`.
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Engine-CLI entry point. Loads the consumer's config, overlays env, installs
|
|
138
|
+
* the resolved config, then runs the engine (or scaffolds via `init`). The
|
|
139
|
+
* bootstrapper (bootstrap/cli.ts) delegates here so the engine keeps a single
|
|
140
|
+
* load/resolve/install pipeline and stays directly runnable. The published bin
|
|
141
|
+
* is a JS launcher (bootstrap/bin.mjs) that materializes the package outside
|
|
142
|
+
* node_modules and execs bootstrap/cli.ts there.
|
|
143
|
+
*/
|
|
144
|
+
export async function runCli(): Promise<void> {
|
|
145
|
+
const args = process.argv.slice(2);
|
|
146
|
+
|
|
147
|
+
if (args[0] === "init") {
|
|
148
|
+
const parsed = parseInitArgs(args.slice(1));
|
|
149
|
+
if (parsed.help) {
|
|
150
|
+
process.stdout.write(INIT_HELP_TEXT);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const report = runInit({ targetDir: parsed.targetDir });
|
|
154
|
+
process.stdout.write(formatInitReport(report, parsed.targetDir));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const parsed = parseCliArgs(args);
|
|
159
|
+
if (parsed.help) {
|
|
160
|
+
process.stdout.write(HELP_TEXT);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const configPath = resolveConfigPath(parsed.configPath, process.cwd());
|
|
165
|
+
const userConfig = await loadUserConfig(configPath);
|
|
166
|
+
const overlaid = applyEnvOverlay(userConfig, process.env);
|
|
167
|
+
setResolvedConfig(resolveConfig(overlaid));
|
|
168
|
+
|
|
169
|
+
// Import after the config is installed — main.ts's module-level constants
|
|
170
|
+
// read `config` at import time via the Proxy in resolved-config.ts.
|
|
171
|
+
const { runEngine } = await import("./main.ts");
|
|
172
|
+
await runEngine(parsed.forward);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Run the engine only when this module is invoked directly (`node …/src/cli.ts`)
|
|
176
|
+
// — how the engine runs standalone. The bootstrapper reaches it by importing
|
|
177
|
+
// `runCli` (bootstrap/cli.ts), so this guard stays dormant there; tests import
|
|
178
|
+
// `parseCliArgs` without triggering the pipeline for the same reason. `argv[1]`
|
|
179
|
+
// is realpath'd so a symlinked entry still matches `import.meta.url`, which Node
|
|
180
|
+
// resolves through symlinks.
|
|
181
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
182
|
+
runCli().catch((error: unknown) => {
|
|
183
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
184
|
+
console.error(`[phoebe] ${message}`);
|
|
185
|
+
process.exit(1);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
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
|
+
|
|
12
|
+
export const PROVIDER_NAMES = ["cursor", "claude", "codex"] as const;
|
|
13
|
+
export type ProviderName = (typeof PROVIDER_NAMES)[number];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Selects where the thin `phoebe boot` bootstrapper materializes the engine
|
|
17
|
+
* from — a GitHub ref (branch/tag/SHA, defaulting to `main` on the shipped
|
|
18
|
+
* engine repo) or a local mount. The engine itself never reads this: it is a
|
|
19
|
+
* bootstrapper concern, resolved by `bootstrap/engine-source.ts`. It lives on
|
|
20
|
+
* `PhoebeUserConfig` only so a consumer config that sets it still type-checks,
|
|
21
|
+
* and `resolveConfig` deliberately drops it — it never reaches `PhoebeConfig`.
|
|
22
|
+
*/
|
|
23
|
+
export type EngineSourceField =
|
|
24
|
+
| { source: "github"; ref?: string; repo?: string }
|
|
25
|
+
| { source: "local" };
|
|
26
|
+
|
|
27
|
+
export type PromptFilesConfig = {
|
|
28
|
+
issue: string;
|
|
29
|
+
conflict: string;
|
|
30
|
+
checks: string;
|
|
31
|
+
reviews: string;
|
|
32
|
+
research: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type PathsConfig = {
|
|
36
|
+
/** The private clone (origin hub). */
|
|
37
|
+
repoDir: string;
|
|
38
|
+
/** Per-unit git worktrees. */
|
|
39
|
+
worktreesDir: string;
|
|
40
|
+
/** Lock, markers, logs. */
|
|
41
|
+
stateDir: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type PhoebeConfig = {
|
|
45
|
+
/** GitHub `owner/repo` slug, passed to every `gh -R` call. */
|
|
46
|
+
repoSlug: string;
|
|
47
|
+
/** HTTPS clone URL for the container's private clone. */
|
|
48
|
+
repoUrl: string;
|
|
49
|
+
/** Branch PRs target and worktrees base off (usually `main`). */
|
|
50
|
+
defaultBranch: string;
|
|
51
|
+
/** Prefix for agent branches; issue branches are `<prefix>issue-<n>`. */
|
|
52
|
+
branchPrefix: string;
|
|
53
|
+
/** Label marking issues Phoebe may pick up. */
|
|
54
|
+
readyLabel: string;
|
|
55
|
+
/** Label marking wayfinder research tickets the `research` work kind picks up. */
|
|
56
|
+
researchLabel: string;
|
|
57
|
+
/** Label the agent applies to an issue it has claimed and is working. */
|
|
58
|
+
processingLabel: string;
|
|
59
|
+
/** Which open PRs the conflicts/checks/reviews work-kinds scan.
|
|
60
|
+
* "phoebe" = only branchPrefix branches. "all" = any same-repo PR. */
|
|
61
|
+
prScope: "phoebe" | "all";
|
|
62
|
+
/** Draft PR handling: "skip-non-phoebe" = drafts on non-Phoebe branches are
|
|
63
|
+
* off-limits; "skip-all" = never touch drafts; "include" = drafts are fair game. */
|
|
64
|
+
draftPrs: "skip-non-phoebe" | "skip-all" | "include";
|
|
65
|
+
/** PRs carrying this label are excluded from the PR scan in every mode. */
|
|
66
|
+
prOptOutLabel: string;
|
|
67
|
+
/** Shell command strings — toolchains differ per repo, so these are data. */
|
|
68
|
+
installCommand: string;
|
|
69
|
+
checkCommand: string;
|
|
70
|
+
testCommand: string;
|
|
71
|
+
/** The all-in-one gate the agent runs before pushing (e.g. `npm run ready`).
|
|
72
|
+
* Substituted into default prompts as `{{READY_COMMAND}}`. */
|
|
73
|
+
readyCommand: string;
|
|
74
|
+
/**
|
|
75
|
+
* JavaScript-compatible regex source that matches an issue-blocker reference
|
|
76
|
+
* in issue body text. Must expose the blocker issue number as capture group 1.
|
|
77
|
+
* Compiled with the `gi` flags.
|
|
78
|
+
*/
|
|
79
|
+
blockedByPattern: string;
|
|
80
|
+
/**
|
|
81
|
+
* Markdown heading the reviews agent must include when it posts its summary
|
|
82
|
+
* comment. The orchestrator detects the summary by substring match on this
|
|
83
|
+
* exact string, so it must be unique enough not to collide with other
|
|
84
|
+
* comments. Substituted into the default reviews prompt as
|
|
85
|
+
* `{{REVIEWS_SUCCESS_HEADING}}`.
|
|
86
|
+
*/
|
|
87
|
+
reviewsSuccessHeading: string;
|
|
88
|
+
/**
|
|
89
|
+
* Prompt template paths, relative to the runtime root (process cwd —
|
|
90
|
+
* consumer checkout on the host; `/etc/phoebe` in the container where
|
|
91
|
+
* compose mounts config + `prompts/`). Absolute paths are accepted as-is.
|
|
92
|
+
*/
|
|
93
|
+
promptFiles: PromptFilesConfig;
|
|
94
|
+
/** Ordered work kinds, validated by the orchestrator at startup. */
|
|
95
|
+
workOrder: readonly string[];
|
|
96
|
+
defaultProvider: ProviderName;
|
|
97
|
+
defaultModels: Record<ProviderName, string>;
|
|
98
|
+
/** Env var holding each provider's API key — the only key the agent child inherits. */
|
|
99
|
+
providerEnv: Record<ProviderName, string>;
|
|
100
|
+
/** Container filesystem layout (named volumes). */
|
|
101
|
+
paths: PathsConfig;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* User-facing shape of `phoebe.config.ts`. Only the five fields with no sane
|
|
106
|
+
* cross-repo default are required; everything else is optional and filled from
|
|
107
|
+
* `CONFIG_DEFAULTS` by `resolveConfig()`. Nested objects (`promptFiles`,
|
|
108
|
+
* `paths`, `defaultModels`, `providerEnv`) are merged key-by-key, so overriding
|
|
109
|
+
* one provider's model or one prompt file does not force the caller to supply
|
|
110
|
+
* the rest.
|
|
111
|
+
*/
|
|
112
|
+
export type PhoebeUserConfig = {
|
|
113
|
+
repoSlug: string;
|
|
114
|
+
repoUrl: string;
|
|
115
|
+
installCommand: string;
|
|
116
|
+
checkCommand: string;
|
|
117
|
+
testCommand: string;
|
|
118
|
+
/** Bootstrapper-only engine source (see {@link EngineSourceField}). The
|
|
119
|
+
* engine ignores it; `resolveConfig` drops it. Omitted ⇒ github/main. */
|
|
120
|
+
engine?: EngineSourceField;
|
|
121
|
+
defaultBranch?: string;
|
|
122
|
+
branchPrefix?: string;
|
|
123
|
+
readyLabel?: string;
|
|
124
|
+
researchLabel?: string;
|
|
125
|
+
processingLabel?: string;
|
|
126
|
+
prScope?: PhoebeConfig["prScope"];
|
|
127
|
+
draftPrs?: PhoebeConfig["draftPrs"];
|
|
128
|
+
prOptOutLabel?: string;
|
|
129
|
+
readyCommand?: string;
|
|
130
|
+
blockedByPattern?: string;
|
|
131
|
+
reviewsSuccessHeading?: string;
|
|
132
|
+
promptFiles?: Partial<PromptFilesConfig>;
|
|
133
|
+
workOrder?: readonly string[];
|
|
134
|
+
defaultProvider?: ProviderName;
|
|
135
|
+
defaultModels?: Partial<Record<ProviderName, string>>;
|
|
136
|
+
providerEnv?: Partial<Record<ProviderName, string>>;
|
|
137
|
+
paths?: Partial<PathsConfig>;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Engine defaults for every optional user field. These land in the resolved
|
|
142
|
+
* config whenever the consumer's `phoebe.config.ts` omits them, so a minimal
|
|
143
|
+
* consumer config only has to name the repo and its three toolchain commands.
|
|
144
|
+
*/
|
|
145
|
+
export const CONFIG_DEFAULTS = {
|
|
146
|
+
defaultBranch: "main",
|
|
147
|
+
branchPrefix: "phoebe/",
|
|
148
|
+
readyLabel: "ready-for-agent",
|
|
149
|
+
researchLabel: "wayfinder:research",
|
|
150
|
+
processingLabel: "processing",
|
|
151
|
+
prScope: "phoebe" as const,
|
|
152
|
+
draftPrs: "skip-non-phoebe" as const,
|
|
153
|
+
prOptOutLabel: "ready-for-human",
|
|
154
|
+
readyCommand: "npm run ready",
|
|
155
|
+
blockedByPattern: String.raw`Blocked by\s+#(\d+)`,
|
|
156
|
+
reviewsSuccessHeading: "## Review feedback addressed",
|
|
157
|
+
promptFiles: {
|
|
158
|
+
issue: "prompts/issues-prompt.md",
|
|
159
|
+
conflict: "prompts/conflict-prompt.md",
|
|
160
|
+
checks: "prompts/checks-prompt.md",
|
|
161
|
+
reviews: "prompts/reviews-prompt.md",
|
|
162
|
+
research: "prompts/research-prompt.md",
|
|
163
|
+
} satisfies PromptFilesConfig,
|
|
164
|
+
workOrder: ["conflicts", "checks", "reviews", "issues", "research"] as readonly string[],
|
|
165
|
+
defaultProvider: "cursor" as ProviderName,
|
|
166
|
+
defaultModels: {
|
|
167
|
+
cursor: "composer-2.5",
|
|
168
|
+
claude: "claude-sonnet-4-6",
|
|
169
|
+
codex: "gpt-5.4-mini",
|
|
170
|
+
} satisfies Record<ProviderName, string>,
|
|
171
|
+
providerEnv: {
|
|
172
|
+
cursor: "CURSOR_API_KEY",
|
|
173
|
+
claude: "ANTHROPIC_API_KEY",
|
|
174
|
+
codex: "OPENAI_KEY",
|
|
175
|
+
} satisfies Record<ProviderName, string>,
|
|
176
|
+
paths: {
|
|
177
|
+
repoDir: "/data/repo",
|
|
178
|
+
worktreesDir: "/data/worktrees",
|
|
179
|
+
stateDir: "/data/state",
|
|
180
|
+
} satisfies PathsConfig,
|
|
181
|
+
} as const;
|
|
182
|
+
|
|
183
|
+
const REQUIRED_USER_FIELDS = [
|
|
184
|
+
"repoSlug",
|
|
185
|
+
"repoUrl",
|
|
186
|
+
"installCommand",
|
|
187
|
+
"checkCommand",
|
|
188
|
+
"testCommand",
|
|
189
|
+
] as const satisfies readonly (keyof PhoebeUserConfig)[];
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Count the numbered capture groups defined by a regex source. We compile it
|
|
193
|
+
* with an added empty alternative (`|`) so the resulting regex always matches
|
|
194
|
+
* the empty string; the match array's length minus one then equals the number
|
|
195
|
+
* of capture groups, regardless of whether the original pattern would have
|
|
196
|
+
* matched anything on its own. Escaped parens, non-capturing groups (`(?:…)`),
|
|
197
|
+
* lookarounds, and named groups are handled correctly because we're asking
|
|
198
|
+
* the engine's own group count, not parsing the source ourselves.
|
|
199
|
+
*/
|
|
200
|
+
function countCaptureGroups(source: string): number {
|
|
201
|
+
const compiled = new RegExp(`${source}|`);
|
|
202
|
+
const match = compiled.exec("");
|
|
203
|
+
// The extra `|` guarantees a match against ""; TS still narrows to nullable.
|
|
204
|
+
if (!match) {
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
return match.length - 1;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Throw when a required field is missing or blank, or when `blockedByPattern`
|
|
212
|
+
* is not a valid regex or fails to expose the blocker issue number as capture
|
|
213
|
+
* group 1. `parseBlockedBy` reads `match[1]`, so a pattern without a capture
|
|
214
|
+
* group would silently break the entire blocker-detection path — reject it up
|
|
215
|
+
* front. Kept separate from `resolveConfig` so consumers or tests can validate
|
|
216
|
+
* a config independent of the defaults merge.
|
|
217
|
+
*/
|
|
218
|
+
export function validateUserConfig(user: PhoebeUserConfig): void {
|
|
219
|
+
const missing = REQUIRED_USER_FIELDS.filter((key) => {
|
|
220
|
+
const value = user[key];
|
|
221
|
+
return typeof value !== "string" || value.trim().length === 0;
|
|
222
|
+
});
|
|
223
|
+
if (missing.length > 0) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
`phoebe.config.ts is missing required field(s): ${missing.join(", ")}. ` +
|
|
226
|
+
`Only these five fields are required — the engine fills the rest from its defaults.`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (user.blockedByPattern !== undefined) {
|
|
230
|
+
try {
|
|
231
|
+
new RegExp(user.blockedByPattern, "gi");
|
|
232
|
+
} catch (err) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`phoebe.config.ts blockedByPattern is not a valid regex: ${(err as Error).message}`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
if (countCaptureGroups(user.blockedByPattern) < 1) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`phoebe.config.ts blockedByPattern must define capture group 1 for the ` +
|
|
240
|
+
`blocker issue number (parseBlockedBy reads match[1]). Wrap the number ` +
|
|
241
|
+
`portion in parentheses, e.g. String.raw\`Blocked by\\s+#(\\d+)\`.`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Merge a user config with `CONFIG_DEFAULTS` and return the fully-populated
|
|
249
|
+
* shape the engine runs against. Nested records are shallow-merged so partial
|
|
250
|
+
* overrides (one prompt file, one provider's env var, etc.) work as expected.
|
|
251
|
+
*/
|
|
252
|
+
export function resolveConfig(user: PhoebeUserConfig): PhoebeConfig {
|
|
253
|
+
validateUserConfig(user);
|
|
254
|
+
return {
|
|
255
|
+
repoSlug: user.repoSlug,
|
|
256
|
+
repoUrl: user.repoUrl,
|
|
257
|
+
installCommand: user.installCommand,
|
|
258
|
+
checkCommand: user.checkCommand,
|
|
259
|
+
testCommand: user.testCommand,
|
|
260
|
+
defaultBranch: user.defaultBranch ?? CONFIG_DEFAULTS.defaultBranch,
|
|
261
|
+
branchPrefix: user.branchPrefix ?? CONFIG_DEFAULTS.branchPrefix,
|
|
262
|
+
readyLabel: user.readyLabel ?? CONFIG_DEFAULTS.readyLabel,
|
|
263
|
+
researchLabel: user.researchLabel ?? CONFIG_DEFAULTS.researchLabel,
|
|
264
|
+
processingLabel: user.processingLabel ?? CONFIG_DEFAULTS.processingLabel,
|
|
265
|
+
prScope: user.prScope ?? CONFIG_DEFAULTS.prScope,
|
|
266
|
+
draftPrs: user.draftPrs ?? CONFIG_DEFAULTS.draftPrs,
|
|
267
|
+
prOptOutLabel: user.prOptOutLabel ?? CONFIG_DEFAULTS.prOptOutLabel,
|
|
268
|
+
readyCommand: user.readyCommand ?? CONFIG_DEFAULTS.readyCommand,
|
|
269
|
+
blockedByPattern: user.blockedByPattern ?? CONFIG_DEFAULTS.blockedByPattern,
|
|
270
|
+
reviewsSuccessHeading: user.reviewsSuccessHeading ?? CONFIG_DEFAULTS.reviewsSuccessHeading,
|
|
271
|
+
promptFiles: { ...CONFIG_DEFAULTS.promptFiles, ...user.promptFiles },
|
|
272
|
+
workOrder: user.workOrder ?? CONFIG_DEFAULTS.workOrder,
|
|
273
|
+
defaultProvider: user.defaultProvider ?? CONFIG_DEFAULTS.defaultProvider,
|
|
274
|
+
defaultModels: { ...CONFIG_DEFAULTS.defaultModels, ...user.defaultModels },
|
|
275
|
+
providerEnv: { ...CONFIG_DEFAULTS.providerEnv, ...user.providerEnv },
|
|
276
|
+
paths: { ...CONFIG_DEFAULTS.paths, ...user.paths },
|
|
277
|
+
};
|
|
278
|
+
}
|
package/src/drain.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Graceful-drain coordination for the persistent engine loop.
|
|
2
|
+
//
|
|
3
|
+
// `phoebe boot` (the bootstrapper) runs the engine as a long-lived child and
|
|
4
|
+
// stops it with SIGTERM — on container shutdown, and on a config/ref change the
|
|
5
|
+
// reconcile watch relaunches for (bootstrap/reconcile.ts). A hard SIGTERM would
|
|
6
|
+
// kill the engine mid-work-unit, abandoning a half-finished agent run (a dirty
|
|
7
|
+
// worktree, maybe a partially pushed branch). Instead the engine treats SIGTERM
|
|
8
|
+
// as a *drain* request: finish the unit in flight, start no new one, exit 0.
|
|
9
|
+
//
|
|
10
|
+
// This module is that one-way latch — a boolean that flips on the first signal,
|
|
11
|
+
// plus an interruptible wait so an idle poll-sleep wakes immediately instead of
|
|
12
|
+
// stalling shutdown for a whole poll interval. It takes the emitter and signal
|
|
13
|
+
// names as arguments (defaulting to `process` / `SIGTERM`) so the latch logic is
|
|
14
|
+
// unit-tested without sending real process signals.
|
|
15
|
+
//
|
|
16
|
+
// Both poll loops use it, for the same reason: the engine's work loop drains on
|
|
17
|
+
// it (src/main.ts), and the bootstrapper's reconcile watch takes it as the
|
|
18
|
+
// container's stop request (bootstrap/reconcile.ts) so a shutdown wakes the
|
|
19
|
+
// watch instead of waiting out a poll interval — and so boot survives the moment
|
|
20
|
+
// between draining one engine and spawning its replacement.
|
|
21
|
+
|
|
22
|
+
export interface DrainSignal {
|
|
23
|
+
/** True once a drain has been requested; never flips back. */
|
|
24
|
+
readonly requested: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Resolve after `ms`, or immediately once a drain is requested — whichever
|
|
27
|
+
* comes first. A drain arriving mid-wait wakes it so the loop stops promptly
|
|
28
|
+
* rather than sleeping out the full poll interval. Single-waiter: the loop
|
|
29
|
+
* awaits one wait() at a time.
|
|
30
|
+
*/
|
|
31
|
+
wait(ms: number): Promise<void>;
|
|
32
|
+
/** Remove the signal listeners and cancel any pending wait. Idempotent. */
|
|
33
|
+
dispose(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function installDrainSignal(
|
|
37
|
+
emitter: NodeJS.EventEmitter = process,
|
|
38
|
+
signals: readonly NodeJS.Signals[] = ["SIGTERM"],
|
|
39
|
+
): DrainSignal {
|
|
40
|
+
let requested = false;
|
|
41
|
+
// Resolver for an in-flight wait(), so a drain can wake it early. Cleared once
|
|
42
|
+
// it fires (or the wait times out) so we never resolve a stale promise.
|
|
43
|
+
let wake: (() => void) | undefined;
|
|
44
|
+
|
|
45
|
+
const onSignal = () => {
|
|
46
|
+
requested = true;
|
|
47
|
+
wake?.();
|
|
48
|
+
};
|
|
49
|
+
for (const signal of signals) emitter.on(signal, onSignal);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
get requested() {
|
|
53
|
+
return requested;
|
|
54
|
+
},
|
|
55
|
+
wait(ms: number): Promise<void> {
|
|
56
|
+
if (requested) return Promise.resolve();
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
wake = undefined;
|
|
60
|
+
resolve();
|
|
61
|
+
}, ms);
|
|
62
|
+
wake = () => {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
wake = undefined;
|
|
65
|
+
resolve();
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
dispose() {
|
|
70
|
+
for (const signal of signals) emitter.off(signal, onSignal);
|
|
71
|
+
wake?.();
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|