ghostrail
v0.16.0
Published
Open source software factory for coding agents. Config-driven loops, pluggable isolation backends, human merge gate.
Maintainers
Readme
Ghostrail — open source software factory for coding agents
Config-driven loops, pluggable isolation backends (worktree, container, Firecracker, E2B), and a human merge gate. One light left on, on rails that hold.
Ghostrail turns a repo into a factory by dropping in one config file and one prompt. It finds eligible work in your tracker, isolates it, runs a sandboxed coding agent that only writes files, gates the result against a mirror of your CI, and opens a pull request. It never merges. That stays your call.
Status: pre-alpha. Phase 0 (the MVP that self-hosts and replaces a bespoke bash factory) is complete: the config-driven loop, the Linear source, the claude-code agent, the worktree and container backends, the gate, the git publisher, the respond pass, the run store, and the board all ship. See
SPEC.mdfor the design.
Why
A software factory is thin glue over three primitive layers: where work comes from, what agent does the work, and where the work runs in isolation. Most people rebuild that glue per repo. Ghostrail extracts it once, so the tracker, the agent job, the gate, and the isolation backend are all configuration:
- Config-driven. A
ghostrail.tomlplus a prompt file is the whole factory. - Pluggable backends.
local-worktreeandcontainer-per-runship first-class; Firecracker, E2B, and remote sandboxes are adapters behind the same five-verb interface. - Pluggable agents. Claude Code headless is the day-one agent, behind an
[agent]seam so other agents can slot in. - Local and simple by default. The quickstart runs on your own hardware with zero infrastructure and no additional cost. Cloud backends and a hosted board are opt-in.
- Human merge gate.
auto_mergedefaults to false. A person owns every merge.
Quickstart
npx ghostrail install # CLI + skill, then offers to set up your credentials
ghostrail init code # or: content
ghostrail run # one tick: claim, isolate, run, gate, open PRs
ghostrail respond # fold new human PR comments back into branches
ghostrail board # watch runs at http://127.0.0.1:5050/ghostrail run does one tick and exits; ghostrail watch --interval <dur> and
ghostrail start --interval <dur> run ticks on a loop. For exactly what a
tick does end to end, and how the [guardrails] levers interact (including
combinations that quietly do nothing), see how a tick works.
Configuration reference
ghostrail.toml is the whole factory: one file, one table per adapter seam.
Every key below is read and validated in
src/config/load.ts; the defaults come from
src/config/schema.ts — this table is checked
against that code, not against the illustrative templates under
templates/ or examples/, which drift.
A few things apply across every table, worth knowing before you read them:
- Durations are strings.
timeoutandclaim_ttlare parsed by a small parser that accepts a whole positive number plus one unit:"30m","2h","90s". A bare number (30) is a validation error, not minutes. - An invalid value does not stop the rest of the file from validating.
Every key is checked independently: a missing or malformed value is
recorded as an error and that one key falls back to its default, so one
typo doesn't hide problems elsewhere in the file. That said,
ghostrail run/watchstill refuse to start if any error was recorded — look forconfig error at <path>: ...lines on stderr. A couple of lookups made before a run starts (like the factory name in early log output) instead treat a config that fails to validate as absent and quietly fall back to their own hardcoded default, without printing anything. - An omitted section takes every default in it.
[guardrails],[backend],[gate], and[output]can all be left out of the file entirely.
[factory]
| Key | Type | Values | Default | Required |
| --- | --- | --- | --- | --- |
| name | string | any non-empty string | none | yes |
| base_branch | string | any non-empty string | "main" | no |
| source | string | a git URL or local path | none — operates on --repo in place | no |
[source]
| Key | Type | Values | Default | Required |
| --- | --- | --- | --- | --- |
| kind | string | "linear" | "github-issues" | "beads" | "linear"* | yes |
| team | string | tracker team key | "" | yes for linear, no otherwise |
| project | string | project name within the team | none | no |
* A missing or invalid kind is recorded as an error, then falls back to
"linear" rather than blocking the rest of the file — see the note above.
[source.eligible]
Filters which tracker items are eligible work. The same shape is reused,
verbatim, by [triage.eligible].
| Key | Type | Values | Default | Required |
| --- | --- | --- | --- | --- |
| state | string | a tracker workflow state name | none — not filtered | no |
| label | string or array of strings | one or more label names (AND'd) | [] — not filtered | no |
| assignee | string | "unassigned" | "me" | a user email or id | none — not filtered | no |
[agent]
| Key | Type | Values | Default | Required |
| --- | --- | --- | --- | --- |
| kind | string | "claude-code" today | "claude-code"* | yes |
| prompt | string | path to a prompt file, relative to the config | none | yes |
| allowed_tools | array of strings | tool names the agent may call | ["Read", "Edit", "Write", "Bash", "Grep", "Glob"] | no |
* Same fallback as [source].kind: missing or invalid is an error, and the
field still resolves to "claude-code".
[backend]
| Key | Type | Values | Default | Required |
| --- | --- | --- | --- | --- |
| kind | string | "local-worktree" | "container" | "firecracker" | "e2b" | "remote" | "local-worktree" | no |
| image | string | a container image reference | none | no — needed once kind = "container" |
| managed_checkout | boolean | true | false | true | no |
managed_checkout only affects local-worktree: true makes worktrees from
a bare clone ghostrail owns under its own state dir; false borrows your
working checkout instead. See choosing a backend.
[gate]
| Key | Type | Values | Default | Required |
| --- | --- | --- | --- | --- |
| commands | array of strings | shell commands, run in order in the workspace | [] | no |
[guardrails]
| Key | Type | Values | Default | Required |
| --- | --- | --- | --- | --- |
| max_items_per_run | number | > 0 | 3 | no |
| budget_usd_per_item | number or string | > 0, or "unlimited" | 3 | no |
| max_cost_usd_per_run | number or string | > 0, or "unlimited" | 5 | no |
| timeout | duration string | e.g. "30m" | "30m" | no |
| claim_ttl | duration string | e.g. "2h" | "2h" | no |
| concurrency | number | > 0 | 1 | no |
budget_usd_per_itemdoes two jobs: it's the hard per-item cost ceiling, and it's the cost ghostrail assumes for an item that finishes without reporting any cost at all. Raising it to give one item more headroom also raises what an unreported-cost item gets charged.- Either budget key also accepts the string
"unlimited"to disable its cap entirely. An item that reports no cost is then charged$0rather thanbudget_usd_per_item, and the tick logs which cap is uncapped. - This table says what each key accepts. What each one bounds inside a tick,
and how they interact with
concurrency, is covered in how a tick works.
[output]
| Key | Type | Values | Default | Required |
| --- | --- | --- | --- | --- |
| open_pr | boolean | true | false | true | no |
| draft | boolean | true | false | false | no |
| auto_merge | boolean | true | false | false | no |
| branch_prefix | string | any non-empty string | "ghostrail" | no |
| commit_type | string | a conventional-commit type, e.g. "feat", "fix", "chore" | "chore" | no |
auto_merge is policy, not just a default: it hard-defaults to false
because a human owns every merge. No combination of keys makes ghostrail
merge its own PR.
Top level
| Key | Type | Values | Default | Required |
| --- | --- | --- | --- | --- |
| artifacts | array of strings | names of global artifacts under ~/.config/ghostrail/artifacts/ | [] | no |
[respond] (optional)
Enables ghostrail respond. Omit the whole section to leave it disabled.
| Key | Type | Values | Default | Required |
| --- | --- | --- | --- | --- |
| prompt | string | path to a prompt file, relative to the config | none | yes, if [respond] is present |
[triage] (optional)
Required by ghostrail triage; without it, that command has nothing to run.
| Key | Type | Values | Default | Required |
| --- | --- | --- | --- | --- |
| prompt | string | path to a prompt file, relative to the config | none | yes, if [triage] is present |
| eligible | inline table | same shape as [source.eligible] | { labels: [] } | no |
A complete example
[factory]
name = "fix-bugs"
base_branch = "main" # default; shown for clarity
[source]
kind = "linear" # linear | github-issues | beads
team = "TJ" # required for linear: its team key
project = "myproject" # optional: scope to one project
eligible = { state = "Todo", label = "factory", assignee = "unassigned" }
[agent]
kind = "claude-code"
prompt = "prompts/fix-bug.md"
# allowed_tools defaults to ["Read", "Edit", "Write", "Bash", "Grep", "Glob"]
[backend]
kind = "local-worktree" # default; container | firecracker | e2b | remote also declared
# managed_checkout = true # default: local-worktree owns its clone
[gate]
commands = [
"pnpm install --frozen-lockfile",
"pnpm lint",
"pnpm typecheck",
"pnpm test:unit",
]
[guardrails]
max_items_per_run = 3 # default
budget_usd_per_item = 3 # default; also the assumed cost of an unreported-cost item
max_cost_usd_per_run = 5 # default
timeout = "30m" # default
claim_ttl = "2h" # default
concurrency = 1 # default: one item at a time
[output]
open_pr = true # default
draft = false # default
auto_merge = false # hard default: a human owns every merge
branch_prefix = "ghostrail" # default
commit_type = "chore" # default
artifacts = ["style-guide"] # optional: shared references staged into every run
[respond] # optional: enables `ghostrail respond`
prompt = "prompts/respond.md"
[triage] # optional: required by `ghostrail triage`
prompt = "prompts/triage.md"
eligible = { state = "Todo", label = "triage", assignee = "unassigned" }Credentials
Ghostrail reads its credentials from two dotenv-style files before every run,
watch, respond, and triage:
| File | Scope |
| --- | --- |
| ~/.config/ghostrail/secrets.env | every factory on your account |
| .ghostrail.env in the repo you point -C at | that one factory |
Precedence is repo, then global, then the ambient environment, so a value in a file beats an exported variable of the same name. Nothing has to go in your shell profile.
ghostrail install offers to create this file for you at the end of a fresh
install. You can also do it any time:
ghostrail secrets-setup # report what it would add, change nothing
ghostrail secrets-setup --write # create it (mode 600), then open your $EDITORIt is append-only and safe to re-run: existing values are never rewritten, keys
you added yourself are left alone, and a second run adds nothing. Run it again
after upgrading and it tops the file up with placeholders for any credential a
newer ghostrail has learned to read. Add --no-edit to skip the editor.
Uncomment only the lines you need. Leave the rest commented, because an uncommented empty value overrides, and therefore blanks out, a working credential from your environment.
LINEAR_API_KEY (required)
The tracker credential. Ghostrail acts as whoever owns this key: it reads
eligible issues, assigns them, moves their state, comments, and creates
sub-issues during triage.
- Go to linear.app/settings/account/security (Settings → Account → Security & access).
- Under Personal API keys, click New API key.
- Name it something you will recognize later, like
ghostrail. - Grant Write. Read alone is not enough: ghostrail assigns issues, moves them between states, and comments. You do not need Admin.
- Restrict it to the team your factory works in, if your workspace has several.
- Copy the key (it starts with
lin_api_) intoLINEAR_API_KEY.
Linear shows the key once. If you lose it, delete that key and make a new one.
GH_TOKEN (required for a code factory)
Used to clone the repo, push factory branches, open pull requests, label them,
and read and post PR comments during respond. GITHUB_TOKEN is read as a
fallback when GH_TOKEN is unset; set one, not both.
The simple option, a classic token. Go to
github.com/settings/tokens → Generate
new token (classic), set an expiration (90 days is a reasonable default), and
tick the single repo scope. That covers everything ghostrail does. Add
workflow only if you expect the agent to edit files under
.github/workflows/, since GitHub rejects those pushes otherwise.
The tighter option, a fine-grained token. Go to github.com/settings/personal-access-tokens → Generate new token, set an expiration, select Only select repositories and pick the repo your factory targets, then grant these repository permissions:
| Permission | Access | Why |
| --- | --- | --- |
| Contents | Read and write | clone the repo, push the factory branch |
| Pull requests | Read and write | open the PR, read its comments in respond |
| Issues | Read and write | apply the ghostrail label, post PR comments |
| Metadata | Read-only | mandatory, GitHub enables it for you |
Issues is the surprising one. PR labels and PR comments both go through GitHub's issues API, so a token with only Pull requests write cannot label a PR or comment on it.
One caveat before you choose fine-grained: gh has a
known issue where some PR reads fail
with Resource not accessible by personal access token, because the CLI asks
for project-card fields no fine-grained permission covers. If respond fails
that way, a classic repo token is the fix.
For a repo owned by an organization, an org owner may need to approve the token before it works, and fine-grained tokens can be blocked by org policy entirely.
Agent auth: pick one rail
Ghostrail runs Claude Code as the coding agent, and there are two ways to pay
for it. ANTHROPIC_API_KEY wins whenever both are set, so if you mean to
bill a subscription, leave the API key commented out and make sure it is not
exported in your shell either.
CLAUDE_CODE_OAUTH_TOKEN, to bill a Pro or Max subscription. Run:
claude setup-tokenIt opens the same browser authorization flow as /login and prints a one-year
token to the terminal. It does not save it anywhere, so copy it straight into
CLAUDE_CODE_OAUTH_TOKEN. Requires a Pro, Max, Team, or Enterprise plan.
Confirm that automated use fits your plan's terms before running it unattended,
and note that your plan's rolling limits are shared with your claude.ai usage.
ANTHROPIC_API_KEY, to bill metered API usage. Go to
platform.claude.com → Settings → API keys →
Create key, and copy it into ANTHROPIC_API_KEY. Straightforward billing
and no shared subscription limits, but every run costs tokens.
Keeping them safe
secrets-setup writes the file mode 600 inside a 700 directory, so it is
readable only by you. A few things worth knowing:
- Never commit one. A repo-local
.ghostrail.envbelongs in.gitignore; the scaffolded templates do not add that line for you. - Ghostrail redacts values from its CLI output. Run logs and the JSON a tick
prints replace any known credential value, and
secrets-setupprints key names only. The run store behindghostrail boardkeeps metadata rather than command output (gate step names, exit codes, PR links), so it has little to leak, but it is not passed through the redactor. Treat redaction as a safety net, not a reason to paste a key into a terminal. - Rotate on the provider, not in the file. Delete the old key at the source so a leaked copy stops working, then paste the new one in.
The factory image
Only the container backend needs one. Both scaffold templates default to
local-worktree, which runs in a git worktree on your host with no image and no
Docker, so a first setup usually needs nothing here.
When you do switch to kind = "container", build the toolchain image once per
machine. The agent and the gate run inside the workspace, so the container
needs node and pnpm for the gate, git and gh to publish, and the Claude CLI for
the agent. The default alpine:3 has none of them.
ghostrail image build # -> ghostrail-factory:local
ghostrail image build --tag mine:v2 # or your own tagThe Dockerfile ships inside the npm package, so this needs no checkout. From a
repo checkout, scripts/build-factory-image.sh does the same thing. ghostrail
install offers to run the build for you, defaulting to no since most people
start on local-worktree.
Point the config at whatever you built:
[backend]
kind = "container"
image = "ghostrail-factory:local"No credentials are baked into the image. They are forwarded per run from your environment after the secrets files are applied.
Drive it from your coding agent
ghostrail skill installs a /ghostrail skill into whichever coding agents you
have configured, so you can work from inside them instead of the shell. The one
worth knowing: /ghostrail customize reads the repo (stack, CI workflow,
test conventions) and proposes the edits that turn the generic scaffold into a
factory that fits this codebase. Also doctor, status, init, run,
respond, triage.
It is idempotent — re-run it to install or update, whichever applies:
ghostrail skill # install/update for detected agents
ghostrail skill --project # commit it with this repo instead of your user config
ghostrail skill --check # is my copy out of date?
ghostrail skill uninstall # remove it (never touches another product's skill)
ghostrail self-update # update the CLI, then its skillKeeping up with template changes
ghostrail init records what it scaffolded in ghostrail.template.json, so a
later release can tell a file you edited apart from one the template changed.
Commit that file with the repo.
ghostrail init code --diff # read-only: how do my files differ?Each scaffolded file reports as up to date, locally customized, safe to take
(the template moved, yours is untouched), a conflict (both moved), or missing.
For anything that needs a look it prints the exact diff command against the
shipped template. It never writes, so it is safe to run at any time.
Scaffolded before this existed? Re-run ghostrail init once. It adopts a
baseline for every file that still matches the template exactly, which leaves
only your genuinely edited files unknown.
ghostrail artifacts lists the shared references in your global store
(~/.config/ghostrail/artifacts/), staged into each run so the same voice and
rules apply across every factory instance.
Self-host and cutover
Ghostrail builds itself: ghostrail.toml +
prompts/resolve-issue.md point the factory at its own repo.
To replace the bespoke bash factories in echotime, tjwrite, and research,
see docs/cutover.md and the configs in
examples/.
Development
Requires Node 22+ and pnpm (via corepack: corepack enable).
pnpm install
pnpm test:unit # run the unit tests
pnpm typecheck # type-check src + tests
pnpm lint # lint + format check (Biome)
pnpm build # compile to dist/Releasing
Releases publish from CI on a tag, so main and npm cannot drift.
- Bump
versioninpackage.jsonand add the release toCHANGELOG.md. - Merge that to
main. - Tag and push:
git tag v0.1.0 && git push origin v0.1.0.
.github/workflows/release.yml then verifies the
tag matches package.json, runs lint/typecheck/tests/build, checks the package
contents (the bin shebang, both templates and their prompts, the Dockerfile, and
that every advertised command appears in help), and publishes with provenance.
Auth is npm Trusted Publishing (OIDC): the workflow exchanges its GitHub
Actions OIDC token for a short-lived registry token at publish time, so there is
no long-lived NPM_TOKEN secret to leak or rotate.
One-time setup on npmjs.com: open the ghostrail package → Settings →
Trusted Publisher → GitHub Actions, and point it at this repository with
workflow filename release.yml. Allowed action: npm publish.
Provenance (the Sigstore attestation binding a tarball to its source commit and build) requires a public repository, so the workflow gates that flag on visibility: releases cut while the repo is private publish unattested, and the first release after it goes public attests automatically. Provenance is per-version, so earlier versions stay unattested — it cannot be backfilled.
