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.
# 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.
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 scripts/embed.ts (prepare hook)
bun test
bun run typecheck
bun run quay -- task list # invoke the CLI from TS sourcescripts/embed.ts regenerates src/build/embedded.generated.ts (the
in-binary copy of migrations + shipped schema + version stamp). The
generated file is gitignored; bun install and bun run build both
regenerate it.
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, head_sha, review_id, review_attempt_id, and
branch_name. 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
[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.
Resolution order (first match wins): --schema-file <path>,
$QUAY_CONFIG_DIR/ticket_schema.toml, $HOME/.quay/ticket_schema.toml,
shipped default at 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 |
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/
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.