CherryPick reviews, selects, and approves task workflow outputs.
Its repository automation is a GPT-5.6-first harness. Eight thin skills live
under .agents/skills, deterministic behavior lives in the shared FastMCP
engine under src/cherrypick/module/harness, profile configuration lives under
.codex/agents, and ephemeral work state lives under .agents/work/<work-id>.
The supported development baseline is Git, uv, a
just release that supports recipe groups and attributes, Docker with the
Compose plugin, kubectl for the retained manifest-render replay, and a host
capable of running CPython 3.14. Verify those tools before changing repository
state:
git --version
uv --version
just --version
docker version
docker compose version
kubectl version --clientThe full gate uses kubectl kustomize locally; it does not require a cluster
or API-server access. Install a platform-compatible official binary and verify
its published checksum. A user-local ~/.local/bin installation is sufficient.
Install Python and synchronize the committed lock as the workspace user:
uv python install 3.14
uv sync --python 3.14+gil --extra dev --frozen
uv run --frozen --offline --no-sync prek installThe last command installs both the declared pre-commit and pre-push hooks.
After the initial synchronization, repository gates intentionally use
--frozen --offline --no-sync.
Do not run uv, Git, or repository recipes through sudo. CherryPick does not
edit /etc/apt, choose an Ubuntu mirror, install sudo, or manage the Docker
daemon. The Kakao Ubuntu mirror is a valid host-local optimization, but any
functioning package source is supported.
If .venv/bin/python cannot be executed or the environment belongs to another
user, first confirm that the path is the exact .venv inside this checkout.
Move that directory aside, then rerun the synchronization as the workspace
user. Do not delete a broad parent directory or use sudo uv.
Bash:
# Run this check first.
.venv/bin/python --version
# Only if that check fails, preserve this checkout's exact environment
# outside the repository:
venv_backup="${TMPDIR:-/tmp}/cherrypick-venv-unusable-$(date +%Y%m%d%H%M%S)"
mv .venv "$venv_backup"
uv sync --python 3.14+gil --extra dev --frozenPowerShell:
# Run this check first.
& .\.venv\Scripts\python.exe --version
# Only if that check fails, preserve this checkout's exact environment
# outside the repository:
$venvBackup = Join-Path ([System.IO.Path]::GetTempPath()) `
("cherrypick-venv-unusable-" + (Get-Date -Format "yyyyMMddHHmmss"))
Move-Item -LiteralPath .venv -Destination $venvBackup
uv sync --python 3.14+gil --extra dev --frozenThe moved directory is a local backup outside the checkout and can be removed later after the replacement is verified.
Compose renders without a .env file. Create one only for local overrides or
feature-specific secrets:
Bash:
cp .env.example .envPowerShell:
Copy-Item .env.example .envThe complete key inventory, activation conditions, defaults, and secret
classification are in docs/configuration.md.
Values in .env.example are development defaults, not production credentials.
Build the exact PostgreSQL image used by local tests, render the Compose model, start the database, and apply Alembic migrations:
docker build --file infra/docker/Dockerfile.db --tag ghcr.io/choiec/cherrypick-db:local .
docker compose config --quiet
docker compose up --detach --build db migrate
docker compose wait migratePostgreSQL init scripts configure the database instance; Alembic owns the application schema. Ordinary startup never deletes or recreates the persistent volume.
Start the two application workloads with:
docker compose up --detach --build --wait --wait-timeout 120 api mcpBoth services run the same unified ASGI application containing REST,
/mcp/chat, and /mcp/work. Ports 8000 (api) and 8001 (mcp) are workload
aliases, not route-isolated servers. All three published ports bind only to
127.0.0.1 by default.
To run the application process on the host instead, leave db running, wait
for the preceding one-shot migrate service to exit successfully, ensure
DATABASE_URL addresses the database's published port, and run:
uv run --frozen --offline --no-sync fastapi dev src/cherrypick/main.py --host 127.0.0.1 --port 8000Smoke-test liveness, database readiness, the OpenAPI schema, and a protected route with commands for your shell.
Bash:
curl --fail http://127.0.0.1:8000/health
curl --fail http://127.0.0.1:8000/:checkHealth
curl --fail http://127.0.0.1:8000/:checkReadiness
curl --fail http://127.0.0.1:8000/openapi.json
curl --include http://127.0.0.1:8000/tasksPowerShell:
curl.exe --fail http://127.0.0.1:8000/health
curl.exe --fail http://127.0.0.1:8000/:checkHealth
curl.exe --fail http://127.0.0.1:8000/:checkReadiness
curl.exe --fail http://127.0.0.1:8000/openapi.json
curl.exe --include http://127.0.0.1:8000/tasksThe final request must fail closed with 401 Unauthorized when no Bearer token
is supplied. AUTH_PLATFORM_ENABLED=false selects direct Bearer JWT
verification; it does not disable authorization. The example issuer and JWKS
URLs are deliberately non-runnable placeholders. A successful manual protected
request requires an operator-supplied token from an approved external issuer;
never print or commit that token. Automated positive authentication tests use
static JWKS fixtures and do not require a local identity provider.
When this checkout runs inside a Codex container, the outer host may already use ports 5432, 8000, or 8001. Override the published ports without editing Compose:
Bash:
export POSTGRES_HOST_PORT=15432
export API_HOST_PORT=18000
export MCP_HOST_PORT=18001
docker compose up --detach --build db migrate
docker compose wait migrate
docker compose up --detach --build --wait --wait-timeout 120 api mcpPowerShell:
$env:POSTGRES_HOST_PORT = "15432"
$env:API_HOST_PORT = "18000"
$env:MCP_HOST_PORT = "18001"
docker compose up --detach --build db migrate
docker compose wait migrate
docker compose up --detach --build --wait --wait-timeout 120 api mcpThe outer Docker host's loopback is not the development container's
127.0.0.1. Verify the containerized path without guessing a host-gateway
address by running these shell-neutral commands from either Bash or
PowerShell:
docker compose ps api mcp
docker compose exec -T api python -c 'import http.client; c = http.client.HTTPConnection("127.0.0.1", 8000, timeout=5); c.request("GET", "/:checkReadiness"); r = c.getresponse(); assert r.status == 200, r.status'
docker compose exec -T api python -c 'import http.client; c = http.client.HTTPConnection("127.0.0.1", 8000, timeout=5); c.request("GET", "/openapi.json"); r = c.getresponse(); assert r.status == 200, r.status'
docker compose exec -T api python -c 'import http.client; c = http.client.HTTPConnection("127.0.0.1", 8000, timeout=5); c.request("GET", "/tasks"); r = c.getresponse(); assert r.status == 401, r.status'The host-process alternative from the preceding section requires an explicit
daemon-reachable database host in DATABASE_URL and forwarding for the
daemon's loopback-published port. If the controlled container runner does not
provide that forwarding, use the Compose application path above.
The development container needs access to a reachable Docker daemon, normally
through /var/run/docker.sock. Testcontainers also needs a daemon-reachable
host address and permission to start its Ryuk cleanup container. Set
TESTCONTAINERS_HOST_OVERRIDE only when the daemon cannot route back to the
container automatically. If a controlled runner forbids Ryuk, that runner owns
explicit cleanup; do not disable it as a normal workstation default. Use unique
port overrides and a disposable Compose project name when testing concurrently
with another checkout.
just ci is the complete local and CI gate:
just ciIt runs direct pytest commands with a bounded worker topology: at most eight
workers for unit/contract tests, four for PostgreSQL tests, and four for
migration tests. Focused recipes are visible through just --list.
GitHub delivery is bound to one full commit SHA. Pull requests run the
repository gate and build validation without publishing images or receiving
the dev environment. A main push, or a manual dispatch naming a full commit
that is reachable from origin/main, follows the ordered graph
verify -> publish-images -> deploy-dev.
GitHub's setup-python action requests standard CPython 3.14; the repository
continues to use uv's explicit 3.14+gil selector locally and in gate commands.
The private GHCR package names remain:
ghcr.io/choiec/cherrypick-apighcr.io/choiec/cherrypick-mcpghcr.io/choiec/cherrypick-dbghcr.io/choiec/cherrypick-tunnel-client
API and MCP are two compatibility package names for one canonical application
build and therefore publish the same manifest digest. DB and tunnel-client are
built once each. Full Git SHA tags are traceability aliases; Kubernetes always
consumes the exported sha256 digests, and the deploy job does not rebuild
images. Publication uses the ephemeral GITHUB_TOKEN; the cluster pull token
is read-only.
The deploy job is the only job that receives the dev environment and cluster
credentials. It fails closed before mutation when required split Chat/Work
configuration is absent or not distinct. The protected environment also pins
the Proxmox SSH host public key and the K3s certificate-authority digest, so an
unknown SSH endpoint or cluster is rejected before the first Kubernetes API
request. The deployer then prepares backing services, waits for them, runs
uniquely named Alembic and policy-seed jobs, applies application workloads,
waits for every affected rollout, and runs existing-cluster observation. That
smoke path does not install Helm or reconcile the steady-state overlay.
Repository implementation does not create GitHub environment values, change
package visibility, publish live packages, or mutate a cluster. Complete the
external prerequisites in
docs/configuration.md before
authorizing a live delivery.
If preflight, a backing service, migration, or policy seed fails, application workload digests remain unchanged. After a migration succeeds, recovery is an explicit operator decision using the recorded previous and new digest sets. The workflow never automatically downgrades the schema, deletes PVCs, or blindly rolls an image back.
CherryPick exposes three separately owned MCP surfaces:
chatis the short, interactive ChatGPT surface at/mcp/chat.workis the durable, approval-aware ChatGPT Work surface at/mcp/work.devis the trusted local Codex stdio surface for repository work.
The production ASGI process mounts only chat and work. The dev server is
registered by the project-local Codex configuration and is never mounted by
the production process. Public deployments require the configured OAuth
provider; production startup fails closed when it is absent.
just --list shows the thin repository recipes. The CLI and FastMCP adapters
call the same application engine and return the same stable result envelope.
The project-local Codex configuration registers that stdio server as
dev. Start it manually or list its 15 coarse operations with:
just dev-tools
just dev-mcp
just dev core_get_work request.jsonMCP request fields are top-level tool arguments; there is no nested
arguments object. CLI invoke reads the identical argument object from the
JSON file passed to --request-file. Both transports apply the same strict
Pydantic model, reject unknown fields, and return
cherrypick.harness.result.v1. Requests contain a UUIDv7 work_id, repository
root, and operation-specific fields. Target-bound operations also carry the
exact target digest and revision.
CLI process exit codes are part of that envelope: 0 means succeeded or
replayed, 2 means blocked or invalid input, 3 means execution failed, and
4 means recovery is required. CLI parsing and validation failures write one
cherrypick.harness.cli-error.v1 JSON object to stderr; they never print a
traceback or a partial receipt.
Capabilities are user-issued outside MCP. Prepare a strict issue request with
work_id, target, revision, action, scope, and an aware expires_at, then run:
PowerShell:
uv run python -m cherrypick.module.harness.adapter.cli capability-schema
uv run python -m cherrypick.module.harness.adapter.cli issue-capability `
--request-file capability-request.jsonBash:
uv run python -m cherrypick.module.harness.adapter.cli capability-schema
uv run python -m cherrypick.module.harness.adapter.cli issue-capability \
--request-file capability-request.jsonThe schema command is the authoritative, reference-free input contract. A minimal request has this shape (replace every bound value with current Core state):
{
"root": ".",
"work_id": "00000000-0000-7000-8000-000000000000",
"expected_target_digest": "sha256:<64 lowercase hex characters>",
"expected_revision": 0,
"action": "change.authorize",
"scope": ["src/**"],
"expires_at": "2099-12-31T23:59:59Z"
}The command requires an interactive TTY and exact challenge re-entry. Core
persists the issuance and returns a target-, scope-, action-, revision-, and
expiry-bound core.capability-receipt.v1. Pass that complete receipt as the
capability field of the authorized CLI or MCP request. Review tools similarly
hand off complete core.reviewer-attestation.v1 objects; each one binds a
reviewer to the target, revision, review event, phase, and findings digest. A
completion request selects only a tier; Core runs every registered check in
that tier, so callers cannot submit a verification subset. An uncertain Git
mutation must be reconciled and must not be blindly repeated.
Git recovery uses causally linked LIFO intent frames. Resolving a recovery command closes only that child frame and re-exposes its parent mutation; the parent requires its own capability and postcondition reconciliation. Abort is accepted only for the matching active Git operation, so a local recovery can never erase an unrelated or uncertain remote publish result.
The TTY challenge is a local operator-interaction boundary, not cryptographic proof of Codex UI or reviewer identity; a trusted issuer can strengthen that identity boundary later without changing the receipt shapes.
This structural proof and the bounded host command runner are development
facilities. With ENVIRONMENT=prod, the shared composition installs deny-all
providers: capability/reviewer operations require an externally signed
TrustProvider, and verification or Git execution requires an injected
isolated CommandRunner. There is no production fallback to TTY identity or
host execution. Focused verification requests accept a registered
verification_id, never raw argv; Core forces uv frozen, offline, no-sync
execution and redirects tool caches and HOME to an OS-temporary directory.
Capability action and scope are exact, not prefix grants. Use
change.authorize with the authorized path patterns and harness.improve with
the changed paths. Branch scopes start with refs/heads/<expected-branch>;
merge adds both git-ref/<source-branch> and git-oid/<expected-source-oid>,
and publish adds remote/<remote>. Merge evidence must describe a clean source
at that exact commit and tree; the command merges the immutable OID after
checking that the named source still resolves to it. Every Git recovery adds
command/<identity-without-sha256-prefix> and the current LIFO
intent/<event-digest-without-sha256-prefix>; abort, rebase, and revert also
add git-target/<target>. Their action names are
branch.finish.<action> and git.recover.<action>, respectively. Keep and
reconcile also require a capability even though they may only preserve or
resolve state.
- Durable design contracts live under
docs/specs. - The active skill roster is derived from the central harness registry and the eight skill directories; there is no separately maintained static catalog.
- Skills contain instructions and UI metadata only; Python is owned by the shared FastMCP/CLI engine.
- Work events and evidence use a work-local SQLModel/SQLite store.
- CI enters through
just ciand uses uv-managed Python with the bounded8/4/4unit/PostgreSQL/migration worker topology.