Bun + TypeScript implementation of the Quay task lifecycle service.
Start with the user-facing docs:
- User docs index:
docs/user/index.md - Quickstart:
docs/user/quickstart.md - External services setup:
docs/user/external-services.md - CLI reference:
docs/user/cli-reference.md - Troubleshooting:
docs/user/troubleshooting.md
The older specs and design notes are still useful implementation history, but they have not been maintained all the way. Treat them as internal references, not as the current user contract:
- Original lifecycle spec:
docs/quay-spec.md - Validator spec:
docs/quay-spec-ticket-validation.md - Linear/Slack adapter spec:
docs/quay-spec-deployment-adapters.md - Build order notes:
docs/quay-tdd-implementation-plan.md
Quay ships as a single, statically-compiled Bun binary. No runtime is
required on the target host — bun build --compile bundles the
TypeScript source, all migration SQL, and the shipped default
ticket_schema.toml into one executable. Release binaries also embed the
production Quay UI bundle, so quay serve can serve the Admin UI without a
separate packages/admin-ui/dist directory on the host.
# Build locally from a checkout (requires Bun ≥ 1.1):
bun install
bun run build # → dist/quay (~58 MB)
./dist/quay --version # → e.g. 0.1.0+abcdef1 (or 0.1.0+abcdef1+dirty on an unclean tree)Quay is delivered as prebuilt binaries on tagged GitHub Releases. Each
v* tag triggers .github/workflows/release.yml, which builds four
artifacts and a SHA256SUMS manifest, then attaches them to the release. The
release workflow builds the in-repo packages/admin-ui workspace first and
embeds that dist/ output into each binary. No private cross-repo checkout or
UI read token is required.
Artifacts published per release:
| File | Target |
|---|---|
quay-linux-amd64 |
Linux x86_64 |
quay-linux-arm64 |
Linux aarch64 |
quay-darwin-amd64 |
macOS Intel |
quay-darwin-arm64 |
macOS Apple Silicon |
SHA256SUMS |
sha256 of every binary above |
Stable URL pattern (used by the hermes-agent installer):
https://github.com/lafawnduh1966/quay/releases/download/<tag>/quay-<os>-<arch>
https://github.com/lafawnduh1966/quay/releases/download/<tag>/SHA256SUMS
Install on a Linux deployment box (linux-amd64 shown):
set -euo pipefail
TAG=v0.1.0
BASE=https://github.com/lafawnduh1966/quay/releases/download/${TAG}
curl -fsSL -o quay "${BASE}/quay-linux-amd64"
curl -fsSL -o SHA256SUMS "${BASE}/SHA256SUMS"
# Verify the download against the published manifest before installing.
# `set -e` above guarantees that a failing sha256sum check aborts the
# script before `install` runs, so an unverified binary never lands on
# the host.
grep " quay-linux-amd64$" SHA256SUMS | sed 's/quay-linux-amd64/quay/' \
| sha256sum -c -
install -m 0755 quay /usr/local/bin/quay
quay --version # → 0.1.0+<short-sha>The binary is single-file and statically compiled; nothing else needs to be installed on the host (no Bun, no Node).
Releases ship checksums (SHA256SUMS) but are not GPG- or cosign-signed.
Trust is anchored on (a) HTTPS to github.com and (b) the GitHub
Actions workflow being the only producer of release artifacts (the
release job has contents: write and uploads happen via
gh release create on v* tag pushes — no manual uploads). Consumers,
the hermes-agent installer in particular, MUST fetch and check
SHA256SUMS against the binary they downloaded; a release tag without
a matching SHA256SUMS entry is invalid and must be rejected.
First invocation creates ~/.quay/ (or $QUAY_DATA_DIR) and applies
the embedded migrations. Migrations are idempotent; re-running quay
is safe.
quay tick holds a supervisor lock at ${data_dir}/tick.lock for the
duration of each pass. To roll a new binary forward:
- Stop the tick driver (cron entry, systemd timer, or whatever
schedules
quay tick). - Replace the binary at its install path.
- Re-enable the driver. The next tick picks up the new version; the supervisor lock guarantees no overlap with an in-flight pass.
The binary embeds its build version (quay --version returns
<package version>+<git short SHA>, or +<sha>+dirty if built from
an unclean tree) so an operator on a remote box can confirm what's
deployed.
bun install # also runs packages/cli/scripts/embed.ts (prepare hook)
bun test
bun run typecheck
bun run admin-ui:build # optional when iterating on UI assets
bun run quay -- task list # invoke the CLI from TS sourcepackages/cli/scripts/embed.ts regenerates
packages/cli/src/build/embedded.generated.ts (the
in-binary copy of migrations + shipped schema + version stamp + optional UI
assets). The generated file is gitignored; bun install and bun run build
both regenerate it. bun run build builds packages/admin-ui and embeds that
local UI bundle into dist/quay. Set QUAY_UI_DIST_DIR=/path/to/dist to embed
a specific UI build. Without a UI build, local CLI-only builds remain API-only
unless quay serve --ui-dir <path> is used.
The old InverterNetwork/quay-ui repository has been migrated into
packages/admin-ui. Keep that repository read-only with a pointer back here,
or archive it once any remaining consumers have moved to this monorepo.
Quay is a consumer of bare clones, not a manager. Before enqueuing tasks against a repo, two things have to happen:
-
Register repo metadata with
quay repo add:quay repo add \ --id myrepo \ --url git@github.com:owner/myrepo.git \ --base-branch main \ --package-manager bun \ --install-cmd "bun install" -
Materialize the bare clone at the path quay expects. By default that's
<data_dir>/repos/<repo_id>.git(i.e.~/.quay/repos/myrepo.git). Override withrepos_rootin~/.quay/config.tomlto put the cache anywhere — e.g. a shared agent-wide directory:# ~/.quay/config.toml repos_root = "/Users/me/.acc/repos"
Then clone:
git clone --bare git@github.com:owner/myrepo.git <repos_root>/myrepo.git
That's the entire contract — vanilla
--bare, no extragit configstep. If the clone is missing when youquay enqueue, the error prints the exact expected path and command.
quay tick # one supervisor pass over the queue
quay enqueue --repo <id> --brief-file <p> [--request-pr-screenshots|--require-pr-screenshots]
# legacy enqueue (operator-composed brief)
quay enqueue --linear-issue <ENG-1234> # Linear-adapter enqueue (target repo
# comes from the ticket's `repo:` field;
# `--repo <id>` is an optional override;
# accepts screenshot request/require flags)
quay review-pr --pr <owner/repo>:<num> # enroll/poke synthetic/Quay PR review
quay validate-ticket [--ticket-json <p|->] [--schema-file <p>] [--quiet]
# standalone validator: JSON in, JSON out
quay repo set-tags <repo_id> --namespace <n> --value <v> # per-repo tag vocab CRUD
quay repo unset-tags <repo_id> --namespace <n> [--value <v>]
quay repo get-tags <repo_id>
quay repo apply-tags <repo_id> --from <path|-> # declarative replace
quay tags set-deployment --namespace <n> --value <v> # deployment-wide vocab
quay tags unset-deployment --namespace <n> [--value <v>]
quay tags get-deployment
quay tags apply-deployment --from <path|->
quay tags import --from <path> [--force] # bootstrap from TOML
quay tags list --repo <repo_id> # merged vocab + enforced flag
quay handoff list [--status <s>] [--task <id>] [--include-ineligible]
# durable awaiting-next-brief handoffs
# (JSON; default status is eligible pending)
quay outbox list [--status <s>] [--handler-class <class>] [--task <id>]
quay outbox claim <outbox_item_id> [--claim-id <id>]
quay outbox complete <outbox_item_id> --claim-id <id>
quay outbox fail <outbox_item_id> --claim-id <id> --error <message>
# shared side-effect outbox
quay task get <task_id> | task list # read commands (deterministic JSON)
quay submit-brief | escalate-human | record-human-reply | cancel
quay artifact get <task_id> <kind> # raw bytes to stdoutoutbox_items is the shared durable outbox for Quay-originated side effects
that Hermes delivers. Workflow/intervention rows are backed by the existing
handoff flow and may claim/block/resume a task; delivery rows are
notification-only work that can be claimed, completed, failed, and retried
without changing task state. quay outbox list defaults to delivery rows, and
generic outbox mutation commands reject workflow/intervention rows so delivery
workers cannot consume task-resume handoffs. Quay enforces idempotency with
idempotency_key, so Slack delivery does not need to dedupe duplicate Quay
emissions.
The first concrete delivery kind is pr_ready_approved. Quay emits it after a
Quay-owned task reaches done with the current PR head reviewed and approved,
including both orders: reviewer approval before CI pass and CI pass before
reviewer approval. Its payload contains task_id, external_ref, repo_id,
pr_number, pr_url, pr_title when available, head_sha, review_id,
review_attempt_id, branch_name, and approval_status (approved for the
first notification, reapproved when a prior ready-approved delivery exists
for an earlier head SHA). The route hint contains slack_thread_ref plus
fallback deployment_default_slack_channel; Hermes should post to the recorded
thread when present and otherwise use the deployment default Slack channel.
quay handoff list is the compatibility pull surface for workflow handoffs. It defaults
to --status pending and hides pending rows whose next_eligible_at is still
in the future; pass --include-ineligible to include cooled-down rows for
inspection. Accepted statuses are pending, claimed, completed, and
cancelled. Rows are JSON objects with handoff_id, task_id, reason,
status, claim metadata, timestamps, next_eligible_at, state_event_id,
idempotency_key, and payload_json.
quay validate-ticket skips the dispatcher's adapter wiring for fast
spawns. It opens the Quay DB lazily — and only when a ticket payload's
repo is registered with per-repo tag vocab — to enforce the layered
(deployment + per-repo) tag namespaces. A missing data dir, an
unconfigured repo, or a repo with no per-repo vocab degrades cleanly to
"no enforcement" so validation keeps working before Quay has been
initialized.
Resolution order (first match wins): $QUAY_CONFIG_FILE, $QUAY_CONFIG_DIR/config.toml,
$QUAY_DATA_DIR/config.toml, ~/.quay/config.toml. A missing file is OK
(defaults apply); an unparseable or schema-invalid file is a hard error.
# Tick / supervisor knobs (see docs/user/configuration.md for the full set)
data_dir = "/var/lib/quay"
repos_root = "/Users/me/.acc/repos" # bare-clone cache; defaults to ${data_dir}/repos
max_concurrent = 4
max_concurrent_reviewers = 2
retry_budget = 5
agent_invocation = "claude < {prompt_file}"
[reviewer]
enabled = false
gate_quay_owned_done = false
# login = "quay-bot" # gh login tick matches posted reviews against; set when the worker authenticates as a different gh identity than tick. Defaults to `gh api user`.
# Preferred reviewer auth: export QUAY_REVIEWER_GH_TOKEN in the tick environment.
# gh_token_file = "/run/hermes/reviewer-gh-token" # fallback mode 0600 file the reviewer pane reads as GH_TOKEN during migration.
# Linear/Slack adapters (see docs/user/linear-and-slack.md)
[adapters.linear]
enabled = true
api_key_env = "LINEAR_API_KEY" # name of the env var holding the bot token
# auth_mode = "bearer" # OAuth/app-actor tokens use Authorization: Bearer <token>
# bearer_token_env = "QUAY_LINEAR_APP_TOKEN"
# token_command = "hermes-agent linear-token --actor app"
[adapters.slack]
enabled = true
bot_token_env = "SLACK_TOKEN"
# max_thread_messages = 200 # fetchThreadContext cap; default 200[adapters.linear].enabled gates quay enqueue --linear-issue. Without it
the dispatcher returns adapter_not_enabled. A configured-but-missing
token (e.g. LINEAR_API_KEY unset) surfaces as adapter_not_configured.
The default Linear auth mode keeps the legacy personal API-key behavior. Set
auth_mode = "bearer" to send OAuth/app-actor tokens as
Authorization: Bearer <token>; bearer_token_env reads a current token from
the environment and token_command runs a non-interactive helper for each
request.
Resolution order (first match wins): --schema-file <path>,
$QUAY_CONFIG_DIR/ticket_schema.toml, $HOME/.quay/ticket_schema.toml,
shipped default at
packages/cli/config/ticket_schema.toml.
The default mirrors the quay-config block used by the Linear adapter, so
tag/author shape stays consistent across validation and enqueue.
| Var | Purpose |
|---|---|
LINEAR_API_KEY |
Linear bot token (rename via [adapters.linear].api_key_env) |
SLACK_TOKEN |
Slack bot token (rename via [adapters.slack].bot_token_env) |
QUAY_DATA_DIR |
Override ~/.quay data root (DB, worktrees, artifacts, lock) |
QUAY_CONFIG_DIR |
Override config + schema lookup directory |
QUAY_CONFIG_FILE |
Direct path to a config TOML; bypasses dir resolution |
QUAY_INTEGRATION_TESTS=1 |
Opt in to network-backed adapter tests |
.github/workflows/quay-review.yml calls Hermes POST /quay/review-pr after
successful pull request CI runs so Quay can enroll or refresh synthetic PR
reviews. Configure these repository secrets in GitHub Actions:
| Secret | Purpose |
|---|---|
QUAY_REVIEW_URL |
Base Hermes URL, or the full /quay/review-pr endpoint |
QUAY_REVIEW_PR_TOKEN |
Bearer token accepted by Hermes for Quay review requests |
packages/
cli/
migrations/ SQL migration files (read at runtime, applied in lex order)
config/ shipped defaults (ticket_schema.toml)
src/
cli/ entry point + dispatch; production wiring lives in cli/index.ts
core/ service API: enqueue, tick_once, claim_task, ticket_context, ...
adapters/ LinearAdapter, SlackAdapter, GitHub CLI, tmux, local git
validator/ pure validateTicket library + TOML schema loader
db/ sqlite connection + migration runner
artifacts/ artifact-store helper
ports/ interfaces: TmuxPort, GitPort, GitHubPort, SlackPort, LinearPort, Clock, ...
tests/
support/ harness, fakes, fixtures (test-only)
schema/ enqueue/ tick/ claim/ cancel/ slack/ pr/ cli/ adapters/ validator/ ticket_context/ quay_config_block/
admin-ui/ React/Vite Admin UI for the versioned /v1 Admin API
scripts/ repo-level driver scripts used by the historical slice gates
Tests are organized by domain. The full suite runs without network access;
the two adapter integration tests (linear_adapter_integration.test.ts,
slack_adapter_fetch_thread_context.test.ts) skip unless
QUAY_INTEGRATION_TESTS=1 is set.