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
|
@@ -1,20 +1,32 @@
|
|
|
1
1
|
# Phoebe runtime image — scaffolded by `phoebe init`.
|
|
2
2
|
#
|
|
3
3
|
# Consumer-owned: commit this file, tweak it as you need. Phoebe treats the
|
|
4
|
-
# resulting image as both orchestrator and execution environment.
|
|
5
|
-
#
|
|
4
|
+
# resulting image as both orchestrator and execution environment.
|
|
5
|
+
#
|
|
6
|
+
# The image carries the *bootstrapper* (`{{CLI_BIN}}` from npm), not the engine.
|
|
7
|
+
# `phoebe boot` is the container's long-lived main process: it reads the mounted
|
|
8
|
+
# `phoebe.config.ts`, materializes the engine from the source named there
|
|
9
|
+
# (`engine: { source: "github", ref }` by default), execs it, and keeps
|
|
10
|
+
# supervising it — relaunching on a config or ref change, falling back to the
|
|
11
|
+
# last engine commit that ran healthily if a new one will not boot, and draining
|
|
12
|
+
# it gracefully on `SIGTERM`. So changing engine version is a config edit, not
|
|
13
|
+
# an image rebuild; this image only changes when the toolchain around it does.
|
|
6
14
|
#
|
|
7
15
|
# What ships in this image:
|
|
8
|
-
# * Node.js
|
|
9
|
-
# * A globally installed `{{CLI_BIN}}`
|
|
10
|
-
# *
|
|
16
|
+
# * Node.js 24 + git + GitHub CLI (`gh`) — the runtime the engine drives.
|
|
17
|
+
# * A globally installed `{{CLI_BIN}}` (the bootstrapper).
|
|
18
|
+
# * Your agent provider's CLI.
|
|
11
19
|
#
|
|
12
20
|
# What lives on named volumes (see compose.yml):
|
|
13
21
|
# * /data/repo — Phoebe's private clone of the target repo.
|
|
14
22
|
# * /data/worktrees — Per-work-unit git worktrees.
|
|
15
|
-
# * /data/state — Lock, watermarks, logs.
|
|
23
|
+
# * /data/state — Lock, watermarks, logs, crash-loop record.
|
|
24
|
+
# * /data/engine — Engine checkouts, so a restart fetches instead of cloning.
|
|
25
|
+
#
|
|
26
|
+
# The workload runs as the unprivileged `phoebe` user, not root — see the
|
|
27
|
+
# privilege-drop block below for the two things that constrains.
|
|
16
28
|
|
|
17
|
-
FROM node:
|
|
29
|
+
FROM node:24-bookworm-slim
|
|
18
30
|
|
|
19
31
|
# gh CLI + git + tini for signal handling; keep the layer small.
|
|
20
32
|
RUN apt-get update \
|
|
@@ -30,11 +42,89 @@ RUN apt-get update \
|
|
|
30
42
|
&& apt-get install -y --no-install-recommends gh \
|
|
31
43
|
&& rm -rf /var/lib/apt/lists/*
|
|
32
44
|
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
|
|
37
|
-
|
|
45
|
+
# The unprivileged user everything below the privilege drop runs as, and the
|
|
46
|
+
# four volume mount points, created *now* while we are still root.
|
|
47
|
+
#
|
|
48
|
+
# The ordering matters and is not cosmetic: Docker seeds a **fresh named volume
|
|
49
|
+
# from the image's contents at that path, ownership included**. If /data/repo
|
|
50
|
+
# does not exist in the image, Docker creates the mount point as root:root and
|
|
51
|
+
# the first `git clone` fails with EACCES. Creating and chowning them here is
|
|
52
|
+
# what makes the volumes writable after the drop.
|
|
53
|
+
#
|
|
54
|
+
# (`node:24` already ships a uid-1000 `node` user. We add our own so the name
|
|
55
|
+
# in `ps`, in log output, and on the volumes says what it is, and so the uid is
|
|
56
|
+
# stable if the base image ever renumbers `node`. The uid is a literal rather
|
|
57
|
+
# than a build ARG because nothing needs to vary it — everything Phoebe writes
|
|
58
|
+
# lives on named volumes, which take their ownership from this image, not from
|
|
59
|
+
# any host uid. Change it here if you have a reason to.)
|
|
60
|
+
RUN useradd --create-home --shell /bin/bash --uid 10001 --user-group phoebe \
|
|
61
|
+
&& mkdir -p /data/repo /data/worktrees /data/state /data/engine \
|
|
62
|
+
&& chown -R phoebe:phoebe /data
|
|
63
|
+
|
|
64
|
+
# The bootstrapper. Unpinned on purpose: which *engine* you run is `engine.ref`
|
|
65
|
+
# in phoebe.config.ts, and this package is only the thin launcher around it.
|
|
66
|
+
# Pin it (`{{CLI_BIN}}@<version>`) if you want the launcher itself frozen too.
|
|
67
|
+
#
|
|
68
|
+
# Installed as root into /usr/local, which `phoebe` only ever reads: at boot it
|
|
69
|
+
# copies itself out to PHOEBE_ENGINE_DIR (/data/engine, owned by us) before
|
|
70
|
+
# running anything, because Node 24 refuses to type-strip TypeScript under a
|
|
71
|
+
# `node_modules/` path segment.
|
|
72
|
+
RUN npm install -g {{CLI_BIN}}
|
|
73
|
+
|
|
74
|
+
# Provider agent CLI. The engine spawns the selected provider's CLI as a child
|
|
75
|
+
# (`agent` for cursor, `claude`, or `codex`), so the one matching your
|
|
76
|
+
# `defaultProvider` / `PHOEBE_AGENT` must be on PATH — otherwise the run dies
|
|
77
|
+
# with `spawn <cli> ENOENT`. Default below installs Cursor. Swap or add the
|
|
78
|
+
# commented lines for the provider you actually run.
|
|
79
|
+
#
|
|
80
|
+
# This is deliberately NOT the `curl https://cursor.com/install | bash` one-liner
|
|
81
|
+
# Cursor documents. That pipes whatever the vendor serves at build time straight
|
|
82
|
+
# into a shell, so two builds of the same Dockerfile can produce different
|
|
83
|
+
# images and neither records which agent it got. Cursor publishes no checksums
|
|
84
|
+
# and no `latest` indirection, but its installer does resolve to a plain
|
|
85
|
+
# versioned artifact, which is what we pin to. The digests below are ours,
|
|
86
|
+
# computed on 2026-07-28 by fetching both architectures' tarballs from the URL
|
|
87
|
+
# below — they pin the build, they are not a vendor attestation.
|
|
88
|
+
#
|
|
89
|
+
# To bump: run the vendor installer once and read the version out of the
|
|
90
|
+
# DOWNLOAD_URL it echoes, then `sha256sum` the linux/x64 *and* linux/arm64
|
|
91
|
+
# tarballs. Both, even if you only build one — an unchecked digest for the other
|
|
92
|
+
# arch turns into a hard build failure the day someone builds on it.
|
|
93
|
+
ARG CURSOR_AGENT_VERSION=2026.07.23-e383d2b
|
|
94
|
+
ARG CURSOR_AGENT_SHA256_X64=702ad595213bee5df0268be9f80a19f29fcceaa2a42fc55e39f2b5199051f0c4
|
|
95
|
+
ARG CURSOR_AGENT_SHA256_ARM64=f40b99647cb24e0da885e97620a2048034f1fe8961910d573d827d77c4d26dcb
|
|
96
|
+
# Set by BuildKit; the fallback keeps the classic builder working.
|
|
97
|
+
ARG TARGETARCH
|
|
98
|
+
RUN set -eux; \
|
|
99
|
+
case "${TARGETARCH:-$(dpkg --print-architecture)}" in \
|
|
100
|
+
amd64) cursor_arch=x64; cursor_sha="${CURSOR_AGENT_SHA256_X64}" ;; \
|
|
101
|
+
arm64) cursor_arch=arm64; cursor_sha="${CURSOR_AGENT_SHA256_ARM64}" ;; \
|
|
102
|
+
*) echo "cursor-agent publishes no build for ${TARGETARCH}" >&2; exit 1 ;; \
|
|
103
|
+
esac; \
|
|
104
|
+
curl -fsSL -o /tmp/cursor-agent.tar.gz \
|
|
105
|
+
"https://downloads.cursor.com/lab/${CURSOR_AGENT_VERSION}/linux/${cursor_arch}/agent-cli-package.tar.gz"; \
|
|
106
|
+
echo "${cursor_sha} /tmp/cursor-agent.tar.gz" | sha256sum -c -; \
|
|
107
|
+
mkdir -p /opt/cursor-agent; \
|
|
108
|
+
tar --strip-components=1 -xzf /tmp/cursor-agent.tar.gz -C /opt/cursor-agent; \
|
|
109
|
+
rm /tmp/cursor-agent.tar.gz; \
|
|
110
|
+
ln -s /opt/cursor-agent/cursor-agent /usr/local/bin/agent; \
|
|
111
|
+
ln -s /opt/cursor-agent/cursor-agent /usr/local/bin/cursor-agent
|
|
112
|
+
# Both symlink names on purpose: the engine spawns `agent`, but the vendor
|
|
113
|
+
# installer we replaced also created `cursor-agent`, and Cursor's own docs use
|
|
114
|
+
# that name. Dropping it would make this a silent behaviour regression for
|
|
115
|
+
# anyone whose scripts follow those docs.
|
|
116
|
+
#
|
|
117
|
+
# The installer would have put these in ~/.local/bin and needed a PATH entry
|
|
118
|
+
# pointing into a home directory. /usr/local/bin is already on PATH for every
|
|
119
|
+
# user, so the agent child resolves `agent` after the privilege drop with no
|
|
120
|
+
# PATH edit — and no root-owned home directory leaks into its env
|
|
121
|
+
# (src/agent-env.ts forwards PATH and HOME to the agent verbatim).
|
|
122
|
+
#
|
|
123
|
+
# Swapping providers? Pin these too — an unpinned `npm install -g` has the same
|
|
124
|
+
# problem as the installer pipe above, just with a lockfile-shaped hole instead
|
|
125
|
+
# of a shell-shaped one. Check the registry for the current version:
|
|
126
|
+
# Claude Code: RUN npm install -g @anthropic-ai/claude-code@<version>
|
|
127
|
+
# Codex: RUN npm install -g @openai/codex@<version>
|
|
38
128
|
|
|
39
129
|
# Consumer install command, kept as an ARG so it lands in the image metadata
|
|
40
130
|
# and can be inspected with `docker image inspect`. The engine re-runs the
|
|
@@ -42,13 +132,32 @@ RUN npm install -g {{CLI_BIN}}@${PHOEBE_VERSION}
|
|
|
42
132
|
ARG PHOEBE_INSTALL_COMMAND="{{INSTALL_COMMAND}}"
|
|
43
133
|
LABEL phoebe.install-command="${PHOEBE_INSTALL_COMMAND}"
|
|
44
134
|
|
|
45
|
-
|
|
46
|
-
|
|
135
|
+
# Execution-gate marker: the engine refuses to mutate a clone, launch an agent,
|
|
136
|
+
# or push unless `/.phoebe-container` exists (src/execution-gate.ts). Without it
|
|
137
|
+
# the container refuses every non-dry-run unit. Root-owned and read-only to us
|
|
138
|
+
# on purpose — the gate is only ever tested for existence, never written.
|
|
139
|
+
RUN touch /.phoebe-container
|
|
140
|
+
|
|
141
|
+
# The privilege drop. Everything past this line — `boot`, the engine, the agent
|
|
142
|
+
# child, and your repo's install/check/test commands — runs unprivileged.
|
|
143
|
+
#
|
|
144
|
+
# Two things this constrains:
|
|
145
|
+
# * Anything the workload writes must be under /data (the volumes above) or
|
|
146
|
+
# $HOME. That covers the package manager and provider CLI caches, which is
|
|
147
|
+
# why HOME is set explicitly rather than left to the runtime: the engine
|
|
148
|
+
# forwards HOME to the agent child, and an unwritable one breaks the
|
|
149
|
+
# provider CLI in ways that surface as unrelated errors.
|
|
150
|
+
# * Files you bind-mount in (phoebe.config.ts, prompts/) must be readable by
|
|
151
|
+
# *other* — the default 0644 from a git checkout is, but a 0600 file is not
|
|
152
|
+
# and boot will fail to read its config.
|
|
153
|
+
ENV HOME=/home/phoebe
|
|
154
|
+
USER phoebe
|
|
47
155
|
|
|
48
156
|
# Compose's `command:` fully replaces `CMD` (it does not append to
|
|
49
|
-
# `ENTRYPOINT`), so the
|
|
50
|
-
# compose
|
|
51
|
-
#
|
|
52
|
-
# `CMD []` here means the compose files only ever contribute engine
|
|
53
|
-
|
|
157
|
+
# `ENTRYPOINT`), so the whole `phoebe boot` invocation must live in
|
|
158
|
+
# `ENTRYPOINT` — otherwise a compose `command: ["--run-once"]` would strip the
|
|
159
|
+
# bootstrapper away and tini would try to exec `--run-once` as a program.
|
|
160
|
+
# Keeping `CMD []` here means the compose files only ever contribute engine
|
|
161
|
+
# flags, which `boot` forwards to the engine it launches.
|
|
162
|
+
ENTRYPOINT ["/usr/bin/tini", "--", "phoebe", "boot"]
|
|
54
163
|
CMD []
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Local-engine overlay — scaffolded by `phoebe init`, consumer-owned.
|
|
2
|
+
#
|
|
3
|
+
# Development only. Instead of `boot` checking the engine out from GitHub, this
|
|
4
|
+
# runs it straight from a checkout on your host, so an edit to the engine source
|
|
5
|
+
# takes effect on the next launch with no rebuild and no push:
|
|
6
|
+
#
|
|
7
|
+
# PHOEBE_ENGINE_MOUNT=/path/to/phoebe \
|
|
8
|
+
# docker compose -f compose.yml -f compose.local.yml run --rm phoebe
|
|
9
|
+
#
|
|
10
|
+
# It only takes effect if the mounted phoebe.config.ts also says so:
|
|
11
|
+
#
|
|
12
|
+
# engine: { source: "local" }
|
|
13
|
+
#
|
|
14
|
+
# `boot` fails loudly when that field says "local" and nothing is mounted at
|
|
15
|
+
# /opt/phoebe-engine — a missing mount is a misconfigured container, not a
|
|
16
|
+
# reason to quietly fall back to GitHub.
|
|
17
|
+
#
|
|
18
|
+
# Two behaviours differ from the deployed topology, both by design:
|
|
19
|
+
# * No crash-loop fallback. It guards a moving github ref by pinning back to
|
|
20
|
+
# the last engine commit that ran healthily; a mount has no commit to pin,
|
|
21
|
+
# so a crashing engine here just exits.
|
|
22
|
+
# * The ref watch is inert — there is no remote to poll. `boot` still watches
|
|
23
|
+
# the config, so a config edit relaunches the engine as usual.
|
|
24
|
+
|
|
25
|
+
services:
|
|
26
|
+
phoebe:
|
|
27
|
+
environment:
|
|
28
|
+
# A minute is a long time to wait when you are iterating; the reconcile
|
|
29
|
+
# poll is one stat plus (for a github source) one ls-remote, so a tight
|
|
30
|
+
# interval is cheap here.
|
|
31
|
+
PHOEBE_RECONCILE_INTERVAL_MS: "${PHOEBE_RECONCILE_INTERVAL_MS:-10000}"
|
|
32
|
+
volumes:
|
|
33
|
+
# The engine source `boot` execs for `engine: { source: "local" }`.
|
|
34
|
+
# Read-only: the engine works its target clone under /data, never its own
|
|
35
|
+
# source. Mount the repo *root* — package.json's `"type": "module"` has to
|
|
36
|
+
# sit alongside the `.ts` the engine runs under Node's type-stripping.
|
|
37
|
+
- "${PHOEBE_ENGINE_MOUNT:?PHOEBE_ENGINE_MOUNT must point at a local engine checkout}:/opt/phoebe-engine:ro"
|
|
@@ -1,41 +1,70 @@
|
|
|
1
1
|
# Phoebe compose file — scaffolded by `phoebe init`, consumer-owned.
|
|
2
2
|
#
|
|
3
|
-
#
|
|
4
|
-
# the engine
|
|
5
|
-
# runs
|
|
6
|
-
#
|
|
3
|
+
# One long-lived container. Its main process is `phoebe boot` (pinned into the
|
|
4
|
+
# image's ENTRYPOINT), which materializes the engine named by `engine` in
|
|
5
|
+
# phoebe.config.ts, runs it, and keeps supervising it for the life of the
|
|
6
|
+
# container — so this file describes a daemon, not a one-shot:
|
|
7
7
|
#
|
|
8
|
-
# docker compose
|
|
8
|
+
# docker compose up -d # run it
|
|
9
|
+
# docker compose logs -f # watch it
|
|
10
|
+
# docker compose stop # SIGTERM → the engine drains, finishing its
|
|
11
|
+
# # current work unit and starting no new one
|
|
9
12
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
13
|
+
# For a scoped, non-daemon invocation, pass engine flags — the image's `CMD []`
|
|
14
|
+
# means anything here is forwarded straight through `boot` to the engine:
|
|
15
|
+
#
|
|
16
|
+
# docker compose run --rm phoebe --dry-run --run-once # selection preview
|
|
17
|
+
# docker compose run --rm phoebe --run-once # work exactly one unit
|
|
18
|
+
#
|
|
19
|
+
# To upgrade the engine, edit `engine.ref` in phoebe.config.ts — `boot` notices
|
|
20
|
+
# within a poll interval and relaunches at the next work-unit boundary. No
|
|
21
|
+
# rebuild and no restart; rebuild only when this image's toolchain changes.
|
|
22
|
+
|
|
23
|
+
# Project name — namespaces this instance's containers and named volumes. It
|
|
24
|
+
# defaults to `phoebe`, but if you run Phoebe for more than one repo on the same
|
|
25
|
+
# host you MUST make it unique per repo (set COMPOSE_PROJECT_NAME in `.env`).
|
|
26
|
+
# Compose otherwise derives the project from this file's parent directory —
|
|
27
|
+
# `container` — which is identical for every consumer, so the "private"
|
|
28
|
+
# /data/repo, /data/state, … volumes would be shared across repos. Set it and
|
|
29
|
+
# each repo gets its own `phoebe-<repo>_phoebe-repo` volume set.
|
|
30
|
+
name: ${COMPOSE_PROJECT_NAME:-phoebe}
|
|
12
31
|
|
|
13
32
|
services:
|
|
14
33
|
phoebe:
|
|
15
34
|
build:
|
|
16
35
|
context: .
|
|
17
36
|
args:
|
|
18
|
-
PHOEBE_VERSION: "${PHOEBE_VERSION:-latest}"
|
|
19
37
|
PHOEBE_INSTALL_COMMAND: "{{INSTALL_COMMAND}}"
|
|
20
|
-
image: phoebe-runtime
|
|
21
|
-
|
|
38
|
+
image: phoebe-runtime:latest
|
|
39
|
+
restart: unless-stopped
|
|
22
40
|
environment:
|
|
23
|
-
|
|
24
|
-
#
|
|
41
|
+
# Secrets — populate via `.env` (see `.env.example`). GH_TOKEN also
|
|
42
|
+
# authenticates the engine checkout when `engine.source` is "github".
|
|
25
43
|
GH_TOKEN: "${GH_TOKEN:?GH_TOKEN is required}"
|
|
26
44
|
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
|
27
45
|
CURSOR_API_KEY: "${CURSOR_API_KEY:-}"
|
|
28
46
|
OPENAI_KEY: "${OPENAI_KEY:-}"
|
|
47
|
+
# Where `boot` keeps engine checkouts. Pointed at a named volume below so
|
|
48
|
+
# a restart fetches into an existing clone instead of cloning again.
|
|
49
|
+
PHOEBE_ENGINE_DIR: /data/engine
|
|
29
50
|
# Optional runtime toggles.
|
|
30
51
|
PHOEBE_AGENT: "${PHOEBE_AGENT:-}"
|
|
31
52
|
PHOEBE_MODEL: "${PHOEBE_MODEL:-}"
|
|
32
53
|
PHOEBE_POLL_INTERVAL_MS: "${PHOEBE_POLL_INTERVAL_MS:-}"
|
|
54
|
+
PHOEBE_RECONCILE_INTERVAL_MS: "${PHOEBE_RECONCILE_INTERVAL_MS:-}"
|
|
33
55
|
volumes:
|
|
34
56
|
- phoebe-repo:/data/repo
|
|
35
57
|
- phoebe-worktrees:/data/worktrees
|
|
36
58
|
- phoebe-state:/data/state
|
|
59
|
+
- phoebe-engine:/data/engine
|
|
37
60
|
# Consumer config + prompt overrides live in-repo; mount them read-only
|
|
38
|
-
# so
|
|
61
|
+
# so `boot` can re-read the config without a rebuild.
|
|
62
|
+
#
|
|
63
|
+
# Caveat worth knowing: a single-file bind mount pins the host *inode*, so
|
|
64
|
+
# a save that writes a new file and renames it over the old one — most
|
|
65
|
+
# editors' atomic save, and what `git pull` does — is invisible in here,
|
|
66
|
+
# and `boot`'s config watch will never fire for it. Either edit in place,
|
|
67
|
+
# or follow the write with `docker compose up -d --force-recreate`.
|
|
39
68
|
- ../phoebe.config.ts:/etc/phoebe/phoebe.config.ts:ro
|
|
40
69
|
- ../prompts:/etc/phoebe/prompts:ro
|
|
41
70
|
working_dir: /etc/phoebe
|
|
@@ -44,3 +73,4 @@ volumes:
|
|
|
44
73
|
phoebe-repo:
|
|
45
74
|
phoebe-worktrees:
|
|
46
75
|
phoebe-state:
|
|
76
|
+
phoebe-engine:
|
|
@@ -1,15 +1,39 @@
|
|
|
1
1
|
// Phoebe consumer config — scaffolded by `phoebe init`.
|
|
2
2
|
//
|
|
3
|
-
// Only
|
|
4
|
-
// (see the `PhoebeUserConfig` type)
|
|
5
|
-
// them
|
|
3
|
+
// Only the five fields at the top are required. Everything else has a shipped
|
|
4
|
+
// default (see the `PhoebeUserConfig` type); add overrides here only when you
|
|
5
|
+
// need them, and engine upgrades pick up new defaults automatically.
|
|
6
|
+
//
|
|
7
|
+
// This is a *type-only* import on purpose. The container mounts this file at
|
|
8
|
+
// /etc/phoebe and `phoebe boot` imports it before the engine exists, from a
|
|
9
|
+
// directory with no reachable `node_modules` — a value import of `{{CLI_BIN}}`
|
|
10
|
+
// (the `defineConfig` helper) could not resolve there under ESM. A type-only
|
|
11
|
+
// import is erased at runtime, so it type-checks in your editor and costs
|
|
12
|
+
// nothing in the container.
|
|
6
13
|
|
|
7
|
-
import {
|
|
14
|
+
import type { PhoebeUserConfig } from "{{CLI_BIN}}";
|
|
8
15
|
|
|
9
|
-
|
|
16
|
+
const config: PhoebeUserConfig = {
|
|
10
17
|
repoSlug: "your-org/your-repo",
|
|
11
18
|
repoUrl: "https://github.com/your-org/your-repo.git",
|
|
12
19
|
installCommand: "{{INSTALL_COMMAND}}",
|
|
13
20
|
checkCommand: "npm run check",
|
|
14
21
|
testCommand: "npm test",
|
|
15
|
-
|
|
22
|
+
|
|
23
|
+
// Which engine `phoebe boot` runs. This is the upgrade knob: edit it and the
|
|
24
|
+
// running container drains the engine and relaunches on the new code at the
|
|
25
|
+
// next work-unit boundary — no rebuild, no restart.
|
|
26
|
+
//
|
|
27
|
+
// ref: "main" follow the tip — every push lands here. Guarded: a
|
|
28
|
+
// commit that will not boot is quarantined after a few
|
|
29
|
+
// fast crashes and `boot` pins back to the last one that
|
|
30
|
+
// ran healthily, until the branch moves past it.
|
|
31
|
+
// ref: "v1.2.3" pin a tag (or a full SHA). Exactly this commit, always.
|
|
32
|
+
// No fallback — pinning means pinning.
|
|
33
|
+
//
|
|
34
|
+
// `repo` defaults to the upstream engine repo; set it to run a fork.
|
|
35
|
+
// For a checkout on your own machine, see container/compose.local.yml.
|
|
36
|
+
engine: { source: "github", ref: "main" },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export default config;
|
package/dist/phoebe.config.d.ts
DELETED
package/dist/phoebe.config.js
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
// All repo-specific configuration for the Phoebe engine lives here — repo slug,
|
|
2
|
-
// labels, branch prefix, toolchain commands, prompts, work order, provider keys.
|
|
3
|
-
// Engine code under src/ never mentions any concrete repository (enforced by
|
|
4
|
-
// src/config-seam.test.ts), so pointing Phoebe at a repo is a matter of editing
|
|
5
|
-
// this one file. These are the engine's shipped defaults; consumers replace the
|
|
6
|
-
// repo/toolchain values with their own.
|
|
7
|
-
export const config = {
|
|
8
|
-
repoSlug: "your-org/your-repo",
|
|
9
|
-
repoUrl: "https://github.com/your-org/your-repo.git",
|
|
10
|
-
defaultBranch: "main",
|
|
11
|
-
branchPrefix: "phoebe/",
|
|
12
|
-
readyLabel: "ready-for-agent",
|
|
13
|
-
prScope: "phoebe",
|
|
14
|
-
draftPrs: "skip-non-phoebe",
|
|
15
|
-
prOptOutLabel: "ready-for-human",
|
|
16
|
-
installCommand: "npm ci",
|
|
17
|
-
checkCommand: "npm run check",
|
|
18
|
-
testCommand: "npm test",
|
|
19
|
-
promptFiles: {
|
|
20
|
-
issue: "prompts/prompt.md",
|
|
21
|
-
conflict: "prompts/conflict-prompt.md",
|
|
22
|
-
checks: "prompts/checks-prompt.md",
|
|
23
|
-
reviews: "prompts/reviews-prompt.md",
|
|
24
|
-
},
|
|
25
|
-
workOrder: ["conflicts", "checks", "reviews", "issues"],
|
|
26
|
-
defaultProvider: "cursor",
|
|
27
|
-
defaultModels: {
|
|
28
|
-
cursor: "composer-2.5",
|
|
29
|
-
claude: "claude-sonnet-4-6",
|
|
30
|
-
codex: "gpt-5.4-mini",
|
|
31
|
-
},
|
|
32
|
-
providerEnv: {
|
|
33
|
-
cursor: "CURSOR_API_KEY",
|
|
34
|
-
claude: "ANTHROPIC_API_KEY",
|
|
35
|
-
codex: "OPENAI_KEY",
|
|
36
|
-
},
|
|
37
|
-
selfUpdatePaths: ["package.json", "package-lock.json"],
|
|
38
|
-
paths: {
|
|
39
|
-
repoDir: "/data/repo",
|
|
40
|
-
worktreesDir: "/data/worktrees",
|
|
41
|
-
stateDir: "/data/state",
|
|
42
|
-
},
|
|
43
|
-
};
|
package/dist/src/agent-env.d.ts
DELETED
package/dist/src/agent-env.js
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
// Explicit env allowlist for agent child processes. The agent sees PATH, HOME,
|
|
2
|
-
// git identity, the GitHub token, and the *active* provider's API key — never
|
|
3
|
-
// the other providers' keys, so a prompt-injected agent can't exfiltrate the
|
|
4
|
-
// whole keyring.
|
|
5
|
-
const BASE_ALLOWLIST = [
|
|
6
|
-
"PATH",
|
|
7
|
-
"HOME",
|
|
8
|
-
"GH_TOKEN",
|
|
9
|
-
"GIT_AUTHOR_NAME",
|
|
10
|
-
"GIT_AUTHOR_EMAIL",
|
|
11
|
-
"GIT_COMMITTER_NAME",
|
|
12
|
-
"GIT_COMMITTER_EMAIL",
|
|
13
|
-
];
|
|
14
|
-
export function buildAgentEnv(opts) {
|
|
15
|
-
const { parentEnv, provider, providerEnv } = opts;
|
|
16
|
-
const env = { CI: "true" };
|
|
17
|
-
for (const key of [...BASE_ALLOWLIST, providerEnv[provider]]) {
|
|
18
|
-
const value = parentEnv[key];
|
|
19
|
-
if (value !== undefined && value !== "") {
|
|
20
|
-
env[key] = value;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return env;
|
|
24
|
-
}
|
package/dist/src/cli.d.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
type ParsedArgs = {
|
|
3
|
-
configPath: string | undefined;
|
|
4
|
-
help: boolean;
|
|
5
|
-
forward: string[];
|
|
6
|
-
};
|
|
7
|
-
/**
|
|
8
|
-
* Extract `--config <path>` / `--config=<path>` / `-c <path>` and `--help`/`-h`
|
|
9
|
-
* from argv, forwarding everything else to `runEngine`. A minimal parser is
|
|
10
|
-
* enough — the engine handles its own boolean flags (`--run-once`, `--dry-run`)
|
|
11
|
-
* from the forwarded array.
|
|
12
|
-
*/
|
|
13
|
-
export declare function parseCliArgs(argv: readonly string[]): ParsedArgs;
|
|
14
|
-
export type ParsedInitArgs = {
|
|
15
|
-
targetDir: string;
|
|
16
|
-
help: boolean;
|
|
17
|
-
};
|
|
18
|
-
/**
|
|
19
|
-
* Parse argv left after the leading `init` token has been consumed. Supports
|
|
20
|
-
* an optional positional target directory (`phoebe init ./my-agent`) and
|
|
21
|
-
* `--help`. Extra flags are rejected loudly so a typo like `--forcee` fails
|
|
22
|
-
* fast instead of being silently ignored.
|
|
23
|
-
*/
|
|
24
|
-
export declare function parseInitArgs(argv: readonly string[]): ParsedInitArgs;
|
|
25
|
-
export {};
|
package/dist/src/cli.js
DELETED
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// `phoebe` bin — the packaged CLI consumers invoke via
|
|
3
|
-
// `npx phoebe-agent [flags]` (or a pinned `phoebe` script). Recognises two
|
|
4
|
-
// modes:
|
|
5
|
-
//
|
|
6
|
-
// phoebe init [dir] Scaffold a consumer-owned runtime (config, prompts,
|
|
7
|
-
// .env.example, container templates, gitignore).
|
|
8
|
-
// Skips existing files — safe to re-run.
|
|
9
|
-
// phoebe [flags] Run the engine. Loads the consumer's
|
|
10
|
-
// `phoebe.config.ts`, overlays `PHOEBE_*` env vars,
|
|
11
|
-
// installs the resolved config, then hands off to main.
|
|
12
|
-
//
|
|
13
|
-
// This is the only supported v1 programmatic surface: there is no exported
|
|
14
|
-
// `run(config)` — CLI-only. That keeps every consumer on the same load/resolve/
|
|
15
|
-
// install pipeline and leaves the door open to CLI-only concerns (init/pin
|
|
16
|
-
// scaffolding, log formatting) without breaking a library API.
|
|
17
|
-
import { realpathSync } from "node:fs";
|
|
18
|
-
import { pathToFileURL } from "node:url";
|
|
19
|
-
import { resolveConfig } from "./config-schema.js";
|
|
20
|
-
import { formatInitReport, runInit } from "./init.js";
|
|
21
|
-
import { applyEnvOverlay, loadUserConfig, resolveConfigPath } from "./load-config.js";
|
|
22
|
-
import { setResolvedConfig } from "./resolved-config.js";
|
|
23
|
-
/**
|
|
24
|
-
* Extract `--config <path>` / `--config=<path>` / `-c <path>` and `--help`/`-h`
|
|
25
|
-
* from argv, forwarding everything else to `runEngine`. A minimal parser is
|
|
26
|
-
* enough — the engine handles its own boolean flags (`--run-once`, `--dry-run`)
|
|
27
|
-
* from the forwarded array.
|
|
28
|
-
*/
|
|
29
|
-
export function parseCliArgs(argv) {
|
|
30
|
-
const forward = [];
|
|
31
|
-
let configPath;
|
|
32
|
-
let help = false;
|
|
33
|
-
for (let i = 0; i < argv.length; i++) {
|
|
34
|
-
const arg = argv[i];
|
|
35
|
-
if (arg === "--help" || arg === "-h") {
|
|
36
|
-
help = true;
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
if (arg === "--config" || arg === "-c") {
|
|
40
|
-
const next = argv[i + 1];
|
|
41
|
-
if (next === undefined) {
|
|
42
|
-
throw new Error(`${arg} requires a path argument (e.g. --config phoebe.config.ts).`);
|
|
43
|
-
}
|
|
44
|
-
configPath = next;
|
|
45
|
-
i += 1;
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
if (arg !== undefined && arg.startsWith("--config=")) {
|
|
49
|
-
configPath = arg.slice("--config=".length);
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
if (arg !== undefined) {
|
|
53
|
-
forward.push(arg);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return { configPath, help, forward };
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* Parse argv left after the leading `init` token has been consumed. Supports
|
|
60
|
-
* an optional positional target directory (`phoebe init ./my-agent`) and
|
|
61
|
-
* `--help`. Extra flags are rejected loudly so a typo like `--forcee` fails
|
|
62
|
-
* fast instead of being silently ignored.
|
|
63
|
-
*/
|
|
64
|
-
export function parseInitArgs(argv) {
|
|
65
|
-
let targetDir;
|
|
66
|
-
let help = false;
|
|
67
|
-
for (const arg of argv) {
|
|
68
|
-
if (arg === "--help" || arg === "-h") {
|
|
69
|
-
help = true;
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
if (arg.startsWith("-")) {
|
|
73
|
-
throw new Error(`Unknown flag \`${arg}\` for \`phoebe init\`. See \`phoebe init --help\`.`);
|
|
74
|
-
}
|
|
75
|
-
if (targetDir !== undefined) {
|
|
76
|
-
throw new Error(`\`phoebe init\` takes at most one target directory (got \`${targetDir}\` and \`${arg}\`).`);
|
|
77
|
-
}
|
|
78
|
-
targetDir = arg;
|
|
79
|
-
}
|
|
80
|
-
return { targetDir: targetDir ?? ".", help };
|
|
81
|
-
}
|
|
82
|
-
const HELP_TEXT = `phoebe — AFK coding agent
|
|
83
|
-
|
|
84
|
-
Usage:
|
|
85
|
-
phoebe init [dir] Scaffold a consumer-owned runtime
|
|
86
|
-
phoebe [--config <path>] [flags] Run the engine
|
|
87
|
-
|
|
88
|
-
Options (engine mode):
|
|
89
|
-
--config, -c <path> Path to phoebe.config.ts (default: ./phoebe.config.ts)
|
|
90
|
-
--run-once Work one unit of the first one-shot-eligible kind, then exit
|
|
91
|
-
--dry-run Print the selected unit without executing it
|
|
92
|
-
--help, -h Show this message
|
|
93
|
-
|
|
94
|
-
Environment overlays (each replaces the corresponding config field):
|
|
95
|
-
PHOEBE_REPO_SLUG, PHOEBE_REPO_URL, PHOEBE_DEFAULT_BRANCH, PHOEBE_BRANCH_PREFIX,
|
|
96
|
-
PHOEBE_READY_LABEL, PHOEBE_PROCESSING_LABEL, PHOEBE_PR_OPT_OUT_LABEL,
|
|
97
|
-
PHOEBE_INSTALL_COMMAND, PHOEBE_CHECK_COMMAND, PHOEBE_TEST_COMMAND,
|
|
98
|
-
PHOEBE_READY_COMMAND, PHOEBE_BLOCKED_BY_PATTERN, PHOEBE_REVIEWS_SUCCESS_HEADING,
|
|
99
|
-
PHOEBE_PR_SCOPE, PHOEBE_DRAFT_PRS, PHOEBE_DEFAULT_PROVIDER
|
|
100
|
-
|
|
101
|
-
Runtime toggles (read directly by the engine, not overlaid onto the config):
|
|
102
|
-
PHOEBE_AGENT Provider name to use for this run (cursor|claude|codex)
|
|
103
|
-
PHOEBE_MODEL Model to use for this run
|
|
104
|
-
PHOEBE_POLL_INTERVAL_MS Persistent-mode poll interval (default 300000)
|
|
105
|
-
PHOEBE_DEFAULT_BRANCH Branch the supervisor keeps the clone on (overrides tracked branch only)
|
|
106
|
-
`;
|
|
107
|
-
const INIT_HELP_TEXT = `phoebe init — scaffold a consumer-owned runtime
|
|
108
|
-
|
|
109
|
-
Usage:
|
|
110
|
-
phoebe init [dir]
|
|
111
|
-
|
|
112
|
-
Writes into [dir] (default: current directory):
|
|
113
|
-
phoebe.config.ts Consumer config starter (edit the five required fields)
|
|
114
|
-
prompts/ Copies of the shipped agent prompts (edit to override)
|
|
115
|
-
.env.example Documented environment variables to copy to .env
|
|
116
|
-
.gitignore Additive — appends Phoebe entries only
|
|
117
|
-
container/Dockerfile Runtime image (Node + git + gh + pinned phoebe-agent)
|
|
118
|
-
container/compose.yml Base one-shot compose config
|
|
119
|
-
container/compose.daemon.yml Overlay to run Phoebe as a persistent daemon
|
|
120
|
-
container/supervisor.sh Warm-install + engine-restart loop (chmod +x)
|
|
121
|
-
|
|
122
|
-
Existing files are left untouched, so re-running is safe. To regenerate a
|
|
123
|
-
scaffolded file, delete it first and re-run \`phoebe init\`.
|
|
124
|
-
`;
|
|
125
|
-
async function main() {
|
|
126
|
-
const args = process.argv.slice(2);
|
|
127
|
-
if (args[0] === "init") {
|
|
128
|
-
const parsed = parseInitArgs(args.slice(1));
|
|
129
|
-
if (parsed.help) {
|
|
130
|
-
process.stdout.write(INIT_HELP_TEXT);
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
const report = runInit({ targetDir: parsed.targetDir });
|
|
134
|
-
process.stdout.write(formatInitReport(report, parsed.targetDir));
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
const parsed = parseCliArgs(args);
|
|
138
|
-
if (parsed.help) {
|
|
139
|
-
process.stdout.write(HELP_TEXT);
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
const configPath = resolveConfigPath(parsed.configPath, process.cwd());
|
|
143
|
-
const userConfig = await loadUserConfig(configPath);
|
|
144
|
-
const overlaid = applyEnvOverlay(userConfig, process.env);
|
|
145
|
-
setResolvedConfig(resolveConfig(overlaid));
|
|
146
|
-
// Import after the config is installed — main.ts's module-level constants
|
|
147
|
-
// read `config` at import time via the Proxy in resolved-config.ts.
|
|
148
|
-
const { runEngine } = await import("./main.js");
|
|
149
|
-
await runEngine(parsed.forward);
|
|
150
|
-
}
|
|
151
|
-
// Only run when invoked as the entry point — tests import `parseCliArgs` from
|
|
152
|
-
// this module without triggering the whole CLI pipeline. `argv[1]` is realpath'd
|
|
153
|
-
// so a bin symlink (`node_modules/.bin/phoebe -> ../phoebe-agent/dist/src/cli.js`)
|
|
154
|
-
// still matches `import.meta.url`, which Node resolves through symlinks.
|
|
155
|
-
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
156
|
-
main().catch((error) => {
|
|
157
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
158
|
-
console.error(`[phoebe] ${message}`);
|
|
159
|
-
process.exit(1);
|
|
160
|
-
});
|
|
161
|
-
}
|