Gates
A gate checks what a unit leaves, its commits and the files its steps wrote, and fires when a human should look first. Where it fires decides what happens:
| The unit leaves | A fired gate |
|---|---|
A PR (draft_pr, pr) | Puts a decision card on top of the PR; a deliver: pr PR stays a draft |
A push onto your PR | Still pushes, the card atop the push's summary in the report |
| A plan or report | Makes the post wait for your approve, the card on top |
Gates go at three levels: top-level and per repo, which run on every chain that publishes commits, and on a rule. Each gate has a name and one of:
changed: git globs from the repo root, firing when the chain's commits touch a match;command: a script, firing when it exits non-zero, its stdout the gate's line;skill: a judge.
gates:
schema: { changed: ["**/migrations/**", "**/*.prisma"] }
routes: { command: scripts/route-diff.sh } # non-zero exit = fires, stdout = the line
deps: { changed: [package.json, pnpm-lock.yaml] }
risk: { skill: judge } # a model reads the chain's commits and may fire it
repos:
acme/backend:
gates:
schema: { changed: ["src/db/schema/**"] } # joins the top-level schema globs
queues: { changed: ["src/jobs/**"] } # only this repo has itTop-level gates apply to every repo; a repo's gates only add. A gate both define fires on either's globs, and if both have a command or skill, both run and either fires it. A repo can't turn off or loosen a top-level gate.
A command diffs from $AWAYWORK_FROM: the chain's start commit, or git's automatic merge on a sync a model resolved. Never from a branch like origin/main: the clone's refs are the agent's to move. It also gets $AWAYWORK_OUT_DIR, a read-only copy of what the steps left in .away/out/: the PR body, a screenshot a step saved, any other file.
#!/bin/sh
# scripts/route-diff.sh: fires when the chain adds a route
routes=$(git diff --text "$AWAYWORK_FROM" HEAD -- src/routes | grep -E '^\+ *router\.(get|post)')
[ -z "$routes" ] || { echo "$routes"; exit 1; }The decision card
Gates run on every chain that publishes commits, after its steps and verify, judges last, on the chain's own commits. A fired gate puts the card first in the PR body, under the title, above the agent's text and awaywork's chain notes:
## Needs a decision
### Gates (from scripts)
- schema: migrations/20261023_add_vat_mode.ts
- routes: + GET /exports/vat + POST /exports/vat/retry
### Judge
- risk: VAT rounding moved from the invoice total to each line → totals change on open invoices
---With none fired, that spot holds Gates: none fired (schema, routes, deps) above the same rule. So with gates, awaywork's section comes first and the agent's text sits below the rule, a made-up card included; that's all it guarantees. No gates, no section. A changed line lists the first three matching paths and how many more; a command line is its stdout on one line, cut at 300 characters, or why it failed if it printed nothing.
By deliver:
propens as a draft, the report sayingdraft: gate schema firedwith the card's lines under the unit.draft_prwas a draft anyway: only the card is new.pushstill pushes. Your PR's body is yours, so the card tops the push's summary in the morning report:1 commit pushed onto feat/vat-export, gate schema fired, report below.report,planandpending_reviewcommit nothing, so these gates don't run; a plan or report rule takes gates of its own.
Gates fail closed
A command that can't run, times out, is stopped or isn't run on an untrusted author's PR fires its gate with the reason: - routes: `scripts/route-diff.sh` not checked: timed out after 15 min.
The agent could rewrite the script that judges it, so right before a command runs, awaywork compares each file it names with that path at the clone's start commit, before setup or any agent (on an awaywork PR a rule took up, where that PR left the default branch). A file that differs, is new or is gone fires the gate unrun (- routes: `scripts/route-diff.sh` changed, not run), committed, uncommitted, hidden from git status or changed by verify alike.
A changed gate is the one to trust: awaywork computes it from the commits it pushes (Safety).
Details:
- The command must name its script by repo-root path, as a word of its own:
bash ./scripts/route-diff.shor(scripts/route-diff.sh) && echo okcount,cd scripts && sh route-diff.shdoesn't. - A named file missing at the chain's start always fires it, so don't name one the command writes.
- Only named files are compared, and the command runs repo code the agent may have changed: a sourced helper, code it calls, a module loaded from beside it.
python3 scripts/gate.pyimports an agent-committedscripts/json.pybefore the realjson, so run Python with-I; Node looks fornode_modulesup from the script the same way. - A gate can't change
.away/out/: once the gates ran, awaywork puts back what the steps left, as plain files, so a link or an executable bit in it is dropped. - What can't blank the diff: replace refs (the command's git ignores them); attributes like
* -diff, which make itBinary files … differ(awaywork deletes.git/info/attributesfirst, and git reads.gitattributesfiles, committed or not, from the start commit, not the worktree; older git ignores that, hence--text); the steps' git config, likediff.externalor a textconv driver (the command's git reads a fresh global config awaywork writes, with your identity and git-lfs's filter, no attributes file).
Judges
A skill gate is a judge: a model that reads the chain's commits and fires when it finds something a human must decide. After the scripts, awaywork runs that skill in the chain's clone as one more model run, routed on the rule's providers and models like a step, told which commit to diff from, where the read-only copy of the steps' .away/out/ is, and to change nothing. Writing fires to .away/out/verdict fires the gate, the first ten lines of its report.md going into the card's Judge part.
The built-in judge looks for schema and data changes, public routes and API contracts, money and rounding, auth, infra, performance-sensitive paths, dependency changes, anything irreversible. Name your own skill to judge by your rules: yours or a built-in, never the repo's. A gate's instructions, files relative to the config, reach its judge's prompt (risk: { skill: judge, instructions: [judges/money.md] }); the rule's and repo's don't, being written for the agent it judges.
Details:
- Any other verdict, or none, adds nothing. Scripts set the minimum; a judge only adds, and can't clear a script's gate, even one with its name.
- It reads what the agent wrote,
// reviewed: safecomments included, so it can be fooled, and then it just doesn't fire. - It can't change what's published: a commit, moved HEAD, uncommitted change, changed
.away/out/file or agent config it leaves (.claude/settings.local.jsontoo, though git ignores it) is put back, and the gate fires withthe judge changed the clone; awaywork dropped its changes. A worker process that outlived the watchdog could still change a file after that. - It fails closed: a judge that errors, times out, is stopped or can't run fires with
judge didn't run: <why>, so a run out of quota drafts itsdeliver: prPR. - Each judge is one more model run per published chain, on the steps' quota, logged as
acme-backend-412: gate risk, judge on claude, its own log beside what the chain published (out/judge-risk-judge.log).
Gates of a rule's own
A rule takes gates of its own, for a check only its units need. On a rule that opens a PR or pushes, a planned one included, they run with the top-level and repo gates, on top of them, and one that fires does what theirs does:
queue:
- skill: implement
where: { labels: ui }
deliver: pr
gates:
proof: { command: scripts/has-screenshot.sh } # reads $AWAYWORK_OUT_DIR/screenshot.png
copy: { changed: ["src/locales/**"] }Like the others, they run only when the chain has commits: one that left none reports, ungated. The exception is a chain that left no commits and has on_done to write, say a review that approves, so the fix after it is passed over: the rule's own gates run first, on $AWAYWORK_OUT_DIR alone (no $AWAYWORK_FROM, and changed matches nothing), and one that fires holds on_done's comment and move back, its line in the unit's report. The unit is still handled: nothing retries it and awaywork approve has nothing to approve, so the comment and the move are yours to make, or change the item to run it again. Top-level and repo gates don't run there.
A rule whose skills all report, as review does, could never run them, so its items are skipped with set deliver: report (review commits nothing, so its gates would never run), and awaywork doctor warns about the rule.
A deliver: plan or report rule's own gates are the only ones it has (top-level and repo ones never run there: no diff). They run on what the step left, and one that fires makes the post wait for your approve, the card on top:
queue:
- skill: plan
deliver: plan
wait: false # goes ahead unless a gate fires
gates:
product: { skill: judge, instructions: [judges/product.md] } # your rules, from the config only
size: { command: scripts/plan-size.sh } # reads $AWAYWORK_OUT_DIR/plan.yml- A command gets
$AWAYWORK_OUT_DIR, a read-only copy of the step's.away/out/:report.md, the post's text, a plan'splan.yml, any other file it wrote. No$AWAYWORK_FROM: nothing was committed. - A judge gets the same copy, told to judge those files against the item, not a diff. The built-in
judgelooks for product calls: acceptance criteria that can go two ways, UX or copy, scope the card doesn't settle, data kept or shown to someone new, a contract another team owns. - The post is what the step left: awaywork restores
.away/out/before posting, and a judge that changed it fires. - None fired:
Gates: none fired (product, size)tops the post, which underwait: falsegoes ahead, its mark line naming the gates that ran clear. - On a
plannedrule that reports, one that fires makes the task's result wait, and holds the card's other tasks until you answer. - A rule gate takes no name a top-level or repo gate has, and on a plan or report rule no
changed: there's no diff. Apending_reviewrule takes no gates.