{"id":"43717d22-89a8-4f58-8389-7d57d3496032","slug":"afk-sh","kind":"skill","name":"afk.sh","summary":"Runs Claude Code on your GitHub tickets overnight: one fresh session, one worktree, one verified PR per ticket. Bash, no LLM in the loop.","owner":"Warshoow","repoUrl":"https://github.com/Warshoow/afk.sh","license":"MIT","licenseTier":"permissive","trust":"community","stars":3,"match":null,"fewStars":true,"format":"skill","createdAt":"2026-08-22T10:45:47.000Z","addedAt":"2026-10-22T03:28:09.855Z","logoUrl":"https://avatars.githubusercontent.com/u/48083664?u=5273f396b013ced6a68460dc8d5020634c9a9aea&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["developer-tools","browser-web"],"tags":["agentic-workflow","ai-agents","anthropic","automation","bash","claude","claude-code","cli"],"runtime":"binary","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-26T00:40:16.354Z","sample":false,"url":"https://conduits.dev/tools/afk-sh","language":"Shell","transports":["stdio"],"install":{},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# afk.sh\n\n[![Watch a one-minute video tour of afk.sh](https://gitdiagram.com/video-badge.svg)](https://gitdiagram.com/warshoow/afk.sh/video)\n\nRun your GitHub tickets overnight with Claude Code. Label issues `ready-for-agent`,\nrun `./afk.sh`, wake up to one PR per ticket — each one checked by **your** test\ncommand, not by the model saying it is done.\n\n```bash\nnohup ./afk.sh -j 2 &     # every ready-for-agent ticket, two at a time\n```\n\n- **One ticket = one fresh Claude session = one PR.** No context piling up over the night.\n- **No LLM in the orchestrator.** A bash script orders the tickets by their `Blocked by`,\n  launches the sessions, runs your gate, pushes, labels. A session cannot declare itself\n  green: the gate does.\n- **Dependent tickets are stacked**, independent ones run in parallel.\n- **It merges nothing.** You review in the morning.\n\n## A real morning\n\nA 10-ticket run on a TypeScript monorepo, 2026-09-28 (titles generalised):\n\n| Ticket | Result | PR | Attempt | Model | Cost | Duration | Title |\n|---|---|---|---|---|---|---|---|\n| #245 | ok | #255 | 1 | sonnet-5 | $3.60 | 8m18s | Highest difficulty shows the right label |\n| #247 | ok | #257 | 1 | opus-5-5 | $0.84 | 3m15s | A new item opens on an empty editor |\n| #249 | ok | #259 | 1 | opus-5-5 | $0.85 | 5m20s | \"Active subscription\" as one SQL fragment |\n| #251 | ok | #262 | 1 | opus-5-5 | $1.29 | 10m17s | Publishing requires an active subscription |\n| #254 | ok | #264 | 1 | opus-5-5 | $5.36 | 13m34s | A badge next to a subscriber's name |\n| … | | | | | | | 5 more |\n\n```\n- green on 1st attempt: 10/10 — total $20.40, 61m53s\n- integration: green (partial: 7/10) — set aside at merge: feat/252 feat/253 feat/254\n  - `feat/252`: …/standards_controller.ts …/subscription.spec.ts\n  - `feat/253`: …/subscription.spec.ts\n```\n\nThat is an excerpt of `.afk/summary.md`, written at the end of every run: what is green,\nwhat is red and why, what it cost, and which branches clash once merged together (here\nthree tickets touched the same test file). Every run since the first is in\n[RUNS.md](RUNS.md), bad ones included.\n\n**Before trying it:** `afk.sh` is ~1,700 lines of bash, GitHub only, and sessions run\nwith `bypassPermissions` — use a container if the repo is not disposable. It plugs into\nthe [mattpocock/skills](https://github.com/mattpocock/skills) workflow, which it needs.\n\n## Install\n\n```bash\ngit clone https://github.com/Warshoow/afk.sh.git ~/afk\ncd ~/afk\nmkdir -p ~/.claude/skills   # or your CLAUDE_CONFIG_DIR\nfor s in skills/*/; do ln -s \"$PWD/$s\" ~/.claude/skills/; done\n```\n\nThat links the six skills (`/afk-setup`, `/afk-preflight`, `/afk-debrief`, and for the\napp mode `/afk-spec`, `/afk-wave`, `/afk-merge`). Install\n[mattpocock/skills](https://github.com/mattpocock/skills) the way its README says, in the\nsame `CLAUDE_CONFIG_DIR`: the sessions call its `/implement`.\n\nThen, in each project: `/setup-matt-pocock-skills`, `/afk-setup`, and `~/afk/afk.sh` from\nthe project's root.\n\n## Workflow\n\n**Once per project**, never again:\n\n```\n/setup-matt-pocock-skills   →   /afk-setup\n (docs/agents/, the triage        (.afk.env: the command that says\n  labels, the project              \"this ticket is done\" here)\n  memory)\n```\n\n**For every batch of work**:\n\n```\n/grill-with-docs  →  /to-tickets  →  /triage  →  /afk-preflight\n (clarify what      (issues +        (label       (reread the batch:\n  there is to        Blocked by)      ready-       what would cost\n  do)                                 for-agent)   the night)\n\n        →  ./afk.sh  →  /afk-debrief  →  you merge\n           (N headless    (sort the\n            sessions)      non-greens)\n```\n\nBetween `/afk-preflight` and `/afk-debrief`, you sleep.\n\nTo build a whole app from an idea, wave after wave: `/afk-spec` once, then `./afk-app.sh`.\n\n## Prerequisites\n\n- Repo configured by `/setup-matt-pocock-skills`, **GitHub** tracker (`docs/agents/issue-tracker.md`).\n- A `.afk.env` at the root, written by `/afk-setup` (see [Per-project config](#per-project-config)).\n  Without it, the verification gate stays a pnpm monorepo's — wrong everywhere else.\n- `claude`, `gh`, `git`, `timeout` in the PATH. Clean working tree.\n- GNU/Linux, or bash ≥ 4 with GNU coreutils: associative arrays, `nproc`, `timeout`,\n  `flock`. macOS's bash 3.2 does not start the script.\n- `gh auth login` done: the token is also used to push, without a passphrase.\n- The mattpocock skills installed in the `CLAUDE_CONFIG_DIR` the script uses\n  (default `~/.claude`) — otherwise `/implement` does not exist in the headless session.\n\n## Usage\n\n```bash\n./afk.sh                      # every ready-for-agent ticket, in series\n./afk.sh 43 48 49 50          # these ones\n./afk.sh -j 3 43 48 49 50     # in parallel wherever the DAG allows it\n./afk.sh -n -j 3 43 48 49 50  # the plan: waves, bases, stacks, frozen, effective gate\nVERIFY_CMD=\"npm test\" ./afk.sh\nCI_TIMEOUT=0 ./afk.sh         # do not wait for CI\n./check.sh                    # tests the parsers\n./harness.sh                  # tests the orchestrator (claude and gh stubbed)\n```\n\nNo human interaction by default: no passphrase (see below), no pause, no permission\nprompt. `nohup ./afk.sh -j 3 &`\nand you read it back on waking up.\n\n| Env | Default | |\n|---|---|---|\n| `VERIFY_CMD` | `pnpm typecheck && pnpm test && pnpm lint` | the definition of \"done\", overridable per ticket |\n| `INTEGRATION_VERIFY_CMD` | `$VERIFY_CMD` | the integration pass's gate — put the **uncached** form there (`turbo … --force`) |\n| `MAX_ATTEMPTS` | `2` | 1 attempt + 1 retry, in a fresh session |\n| `TIMEOUT` | `45m` | bounds a run (`--max-turns` is gone in 2.1.x), overridable per ticket |\n| `CI_TIMEOUT` | `15m` | how long we wait for CI after opening a PR; `0` = do not consult it |\n| `CI_RETRY_WAIT` | `10` | seconds before retrying a \"not registered yet\" CI |\n| `MODEL` | empty | the sessions' model; empty = `claude`'s default, overridable per ticket |\n| `EFFORT` | empty | thinking level (`low`…`max`); empty = the default, overridable per ticket |\n| `FALLBACK_MODEL` | `sonnet` | fallback model when the main one is unavailable; always on, the variable only picks the model |\n| `INTEGRATION` | `1` | integration pass over the green branches at the end of the run; `0` = skip |\n| `REUSE_GREEN` | `1` | skip the base gate when the base has the content of the last complete gate that passed (`.afk/green.tree`, `docs/spec.md` aside); `0` = always run it, for a gate that depends on more than the code |\n| `LABEL` / `LABEL_REVIEW` / `LABEL_KO` | read from `docs/agents/triage-labels.md` | |\n| `MEMORY_RE` | root + `apps/*` + `packages/*` | paths that count as \"decision captured\" |\n| `BASE_BRANCH` | the remote's default branch | |\n| `JOBS` | `1` | simultaneous sessions; `auto` = `nproc/4` capped at 4 |\n| `VERIFY_LOCK` | `1` | serialises the verifications when `JOBS > 1` |\n| `SETUP_CMD` | deduced from the lockfile | seeding a worktree (`pnpm install --frozen-lockfile`) — receives `AFK_TICKET` and `AFK_WORKTREE` |\n| `AFK_BUILD_CMD` | empty | a command that builds the ticket instead of `claude -p` here (see \"Building elsewhere\") |\n| `SEED_GLOBS` | `.env`, `apps/*/.env`, … | gitignored files copied into each worktree |\n| `KEEP_WORKTREES` | `0` | keep the green worktrees too (the red ones always are) |\n| `AFK_HOME` | the script's folder | where `RUNS.md` is written — redirect it if afk's repo is mounted read-only |\n| `STACK_ON_OPEN_PR` | `1` | stack on an out-of-run blocker with an open PR, instead of freezing |\n| `ALLOW_REVIEW` | `0` | relaunch a ticket already `in-review` (it already has an open PR) |\n\n## Per-project config\n\nThe defaults in the table above are cut for a pnpm monorepo. A Python, PHP or Rust repo\ndoes not have the same definition of \"done\" — and even on an npm repo, `npm test` often\nopens a watcher that never returns (`vitest` without `run`): the ticket then dies on\n`TIMEOUT`, for a reason that has nothing to do with it.\n\nSo a repo declares its gate in a `.afk.env` at its root, versioned next to the code:\n\n```bash\n# .afk.env — project-fb0a2365\n# `pnpm test` = vitest in watch mode: it never returns. test:run is the one to use.\nVERIFY_CMD=\"${VERIFY_CMD:-pnpm lint && pnpm test:run}\"\n```\n\nIt is sourced after the arguments: **command line > `.afk.env` > the script's default**,\nhence the `${VAR:-...}`. Only put in what differs.\n\nIt is shell from the repo, executed as-is — the same trust surface as a ticket's\n`Verify:` lines.\n\nSeveral Claude accounts on the machine? Put the repo's in it —\n`CLAUDE_CONFIG_DIR=\"$HOME/.claude-pro\"` — it wins over whatever the shell exports. The\nrun prints the one it uses (`· Claude account: …`) before anything else.\n\n### The integration gate separates from the tickets'\n\nA build cache can make a gate hollow. Turbo, for instance, hashes the files **tracked by\ngit**: a generated, gitignored file does not enter the key, so a worktree that has not\nproduced it yields **the same fingerprint** as the main tree that has → cache hit, logs\nreplayed, nothing executed. The gate prints `$ tsc --noEmit` and a ✓ without compiling a\nline, and if the cache is shared between worktrees the false green travels. On a real run,\neight tickets went green over a defect only the integration pass saw — it presented the\nfirst combination of contents ever seen, so a cache miss, so an execution.\n\n**`Cached: n cached` is a safety line, not a performance statistic.**\n\n`INTEGRATION_VERIFY_CMD` lets you pay the honest form **once**, on the combination,\nwithout imposing it on every ticket:\n\n```bash\n# .afk.env\nINTEGRATION_VERIFY_CMD=\"${INTEGRATION_VERIFY_CMD:-pnpm exec turbo typecheck lint --force && pnpm test}\"\n```\n\nThe pass announces its gate when it differs, and the summary records both.\n\n### Isolating a worktree from its neighbours\n\nTwo parallel worktrees are two copies of the code, not two copies of what lives\n**around** it: a test database, a port, a bucket. If the repo hardcodes its test database\nname in a versioned file, both suites migrate and roll it back at the same time — and the\ncurrent ticket is marked red for a neighbour's migration. It happened, and the symptom\nnever blames the real culprit (`unable to release database lock`, or a\n`Schema file \"…033_…\" is missing` coming from a file absent from THIS branch).\n\n`SETUP_CMD` already runs **in** the worktree and **under the `install` lock**, so\nserialised. It receives what it needs to tell itself apart:\n\n| variable | value |\n|---|---|\n| `AFK_TICKET` | the ticket number, or `_integration` for the final pass |\n| `AFK_WORKTREE` | the worktree's absolute path |\n\nIt is up to the project to do what it wants with them — it is the one that knows what it\nhas to isolate from:\n\n```bash\n# .afk.env\nSETUP_CMD=\"${SETUP_CMD:-scripts/afk-worktree-setup.sh && pnpm install --frozen-lockfile --prefer-offline}\"\n```\n\n```bash\n# scripts/afk-worktree-setup.sh — one test database per worktree\n[ -n \"$AFK_TICKET\" ] || exit 0          # launched outside afk: nothing to isolate\ndb=\"myapp_test_${AFK_TICKET#_}\"\necho \"DB_DATABASE=$db\" > apps/backend/.env.test.local\ndropdb --if-exists \"$db\" && createdb \"$db\"\n```\n\nNothing is destroyed on exit: one empty database per ticket number, recreated on the next\nrun of the same ticket. If that becomes annoying, it is a `dropdb` in a `TEARDOWN_CMD`\nthat does not exist yet.\n\nThe `/afk-setup` skill (in [`skills/afk-setup/`](skills/afk-setup/SKILL.md)) reads the\nrepo — scripts, CI workflows, `CLAUDE.md` —, proves the proposed command then writes that\nfile. Once per project, after `/setup-matt-pocock-skills` and before the first run\n(installed with the others, see [Install](#install)).\n\n## The run's two skills\n\n`afk.sh` has no LLM: it orders, launches, verifies, pushes, labels. The judgement is\nbefore and after, in an interactive session.\n\n[`/afk-preflight`](skills/afk-preflight/SKILL.md) — **between `/triage` and the run.**\nReads the plan (`./afk.sh -n`) and the ticket bodies, and says what is going to cost the\nnight: a ticket frozen by a blocker already merged but not closed, an acceptance criterion\nno gate can see, a rework without a `Timeout:`, two tickets of the same wave on the same\nfiles, a mechanical ticket that does not need the rework model.\nIt also re-slices the batch when needed: cut a ticket that does not fit in one session (a\nlonger `Timeout:` does not fix a lack of room), serialise with a `Blocked by` two tickets\nwriting into the same files, hand out the ADR and migration numbers before the run rather\nthan discovering them duplicated at integration.\nIt proposes the fixes, it does not apply them and it does not launch the run — except\nwhen called `/afk-preflight apply`, which only `afk-app.sh`'s loop does: there nobody is\nawake to approve a table, so it applies its mechanical fixes itself and takes out of the\nbatch anything needing a judgement on the criteria.\n\n[`/afk-debrief`](skills/afk-debrief/SKILL.md) — **on waking up, before merging.** Reads\n`.afk/summary.md`, the per-ticket decision journals and the traces, and sorts each\nnon-green by cause: wrong gate, badly seeded worktree, ticket too big, real failure. It can\ntell a red caused by the ticket from a red caused by the environment, and proposes what to\nput back to `ready-for-agent` for the next night.\n\n### The decision journal\n\nEach session appends one line per decision to `.afk/<ticket>-work.tsv`, six tab-separated\ncolumns: `ts`, `phase`, `decision`, `why`, `evidence`, `result`. The prompt asks for it,\nnothing enforces it.\n\nIt holds what the diff cannot show and what no trace contains: the hypothesis taken\nbecause nobody was there to decide, the option ruled out and what ruled it out, the red\ngate and what the session concluded from it, the premise that turned out false, anything\ndone outside the ticket's scope. Not the mechanical steps.\n\n```bash\ncolumn -t -s $'\\t' .afk/48-work.tsv      # one ticket\ngrep -H . .afk/*-work.tsv                # the whole run\n```\n\nBefore it existed, answering \"why is #48 red?\" meant `claude --resume` on the session, or\nguessing from the traces. `.afk/` is gitignored, so the journal never reaches a commit,\nand the next run on the same ticket overwrites it.\n\n## Parallelism\n\n`-j N` launches N tickets at a time. **One ticket = one git worktree** (`.afk/wt/<n>`):\ntwo agents in the same working tree trample each other, and it is also what frees the\nmain tree — the script no longer does any `checkout`, `pull` or `reset` in it. You can\nkeep working in it, on whatever branch you like, while a run is going.\nThe worktrees start from `origin/<base>`, never from the local branch.\n\nThe scheduler respects the blocker DAG: a ticket only starts when all its blockers in the\nrun are green, and a red blocker freezes its dependants (their base does not exist). `-n`\nprints the waves, so exactly where parallelism is possible and where the DAG forbids it:\n\n```\nwave 1 (parallel, 3 at a time):\n  #43   base origin/master  [mobile] The canvas logo turns into a radar…\n  #48   base origin/master  [backend][mobile] Review: a comment attached to…\nwave 2 (sequential):\n  #49   base feat/48        [backend][mobile] Reporting a standard…\nwave 3 (sequential):\n  #50   base feat/49        [admin] Moderation queue for the reports\n```\n\n**The Claude sessions run in parallel, the verifications queue up.** A session shares\nnothing; a verification holds the test Postgres, some ports and the RAM of a\n`turbo typecheck`. Two simultaneous `node ace test` on the same database destroy each\nother. Hence `VERIFY_LOCK=1`: `flock` serialises verifications and installs, the long part\nstays parallel. Measured on project-6c618d6f: seeding a worktree takes 19 s (worktree 1 s,\n`pnpm install` 4 s in hard links from the local store, `typecheck` 13 s) — negligible next\nto a session.\n\nA branch can only be checked out in one worktree: if you have `feat/48` checked out in\nyour tree, `-n` tells you before launching anything.\n\n## What it does, ticket by ticket\n\n0. **The base first.** The gate runs once on `origin/<base>`, before the first worktree.\n   Red, the whole batch is going to fail on the same test with nothing saying so: the gate\n   only ever judges \"base + ticket\". The run continues anyway — you have gone — but the\n   header, the summary and `summary.md` say the base was red, and `.afk/base-verify.txt`\n   keeps what it takes to compare with a ticket's `<n>-fail.txt`.\n1. **Boundary.** Reads the blockers (native GitHub dependencies, otherwise the\n   `## Blocked by` section written by `/to-tickets`). Blocker still open and not handled\n   in this run → ticket frozen, not launched — unless it has an open PR: its branch is\n   pushed and readable, we stack on it like on a blocker from the run\n   (`STACK_ON_OPEN_PR=0` to freeze anyway).\n2. **Stacked PRs.** Blocker delivered in this run but not merged yet → the branch starts\n   from its own, and its PR targets its branch. Several blockers → the base is the one\n   that already contains the others (`merge-base --is-ancestor`), the remaining ones are\n   merged; conflict → frozen.\n3. **Fresh session**: `claude -p \"/implement GitHub ticket #N …\"`, never `--resume`.\n   Resuming a session that just failed means restarting from the context that failed.\n   The retry receives the last 60 lines of the failure, in a pristine session.\n   `--resume` stays on offer to a human on a red ticket, at the end of the summary.\n4. **External verification.** The script grades the work, not the agent.\n   Zero commits produced → if the session says it was blocked, the ticket is **frozen**;\n   otherwise the gate is run **on the base** to decide: red, it is a failure; green, the\n   ticket is **absorbed** (see below).\n5. **Green** → push, PR `Closes #N` on the right base, ticket switched to `in-review`,\n   then waiting for CI. **Red** → `ready-for-human` + a comment with the failure output.\n   `/triage`'s state machine keeps turning while you sleep.\n6. **Worktree dropped** if green, **kept** if red: that is where we go to read what\n   happened, with the `node_modules` already in place. Relaunching the ticket starts\n   `feat/<n>` over from the base; its previous commits stay on `afk-prev/<n>`.\n7. **CI** at the end of the run, every PR watched in parallel (waiting in the worker would\n   tie up a slot for polling).\n8. **Integration** at the end of the run: every green branch merged into a throwaway\n   worktree — **in topological order**, a stacked one after its base, otherwise it\n   conflicts by construction —, then `INTEGRATION_VERIFY_CMD`. It reports; it touches no\n   PR. It also flags three things no gate can see: two files claiming the **same number**\n   (ADR, migration), the **same path created** by two branches, and the **run's tickets\n   cited in the merged docs** — a future-tense sentence about something delivered ten\n   minutes ago produces no conflict. The conflicting files are written into `summary.md`,\n   not just displayed.\n\n## Logs\n\nEverything is in `.afk/` (self-ignored), one family of files per ticket:\n\n| File | Contents |\n|---|---|\n| `<n>.out` | the orchestrator's trace for this ticket — what you read first |\n| `<n>-<attempt>.json` | what the session says about itself: failure, cost, model, `session_id` |\n| `<n>-verify.txt` / `<n>-fail.txt` | the gate's output, last failure kept |\n| `<n>-setup.log` | the worktree's install |\n| `<n>-ci.txt` | `gh pr checks`'s output |\n| `<n>-push.txt` | the remote's refusal, when the push fails |\n| `<n>-work.tsv` | the session's decision journal, one line per decision — the only file here written by the agent and not by the script |\n| `base-verify.txt` | the gate run on the base before the run — one per run, not per ticket |\n| `<n>.status` | the machine verdict (`result`, `pr`, `draft`, `draft_why`, `attempt`, `session`, `resume`, `cost`, `model`) |\n| `summary.md` | the run's table: result, PR, attempt, model, **peak context**, cost, CI, integration |\n\n**`.afk/` is overwritten on the next run.** What has to survive lives in afk's own repo —\nmounted in each of your projects, so common to all of them:\n\n| File | Contents | Written by |\n|---|---|---|\n| `RUNS.md` | one line per run: date, project, green/unproven/drafts/reds/refused pushes/frozen/absorbed, 1st attempt, model, cost, duration, integration | `afk.sh`, at the end of each run |\n| `docs/defects.md` | **afk's** still-live defects, seen for real during a run, numbered | `/afk-debrief`, or by hand |\n| `docs/defects-fixed.md` | the same, once fixed: same numbering, the archive | same, at fixing time |\n\nThe path is the script's (`AFK_HOME`), not the project's: whether you launch `afk.sh` from\na devcontainer where it is mounted or from outside, it writes to the same place.\nRepo mounted read-only → nothing is logged, and that is not a run error.\n\nIn series, the trace also comes out live on screen. In parallel it is set aside and dumped\nin one block when the ticket finishes, otherwise the outputs interleave; a\n`…  running: #48 (3m12) #50 (1m04)` line every two minutes says who is working.\n\n## Context as the thermometer of the slicing\n\nA fresh session guarantees a clean start, not a clean finish. With a 1M window, nothing\ncompacts: the session grows until the ticket is done. Measured on project-6c618d6f — same run,\nsame rules:\n\n| ticket | turns | peak context |\n|---|---|---|\n| #43 | 64 | 140k |\n| #49 | 208 | 289k |\n| #50 | 214 | 312k |\n\nSo `summary.md` carries a **context** column, read from the session's transcript. It is\nthe same information as \"green on 1st attempt\", taken upstream: a ticket brushing the\nwindow was too big, and it shows **before** quality suffers.\n\n## Per-ticket verification gate\n\n`VERIFY_CMD` is a single gate for every ticket. On a monorepo, that contradicts \"stay\nwithin the ticket's scope\": a backend ticket touching an end-to-end typed contract breaks\nthe client's typecheck, and the agent has to step outside its scope to produce green.\n\nSo a ticket can declare its own gate, with a line in its body:\n\n```\nVerify: pnpm turbo typecheck --filter=@acme/backend\n```\n\nThe script reads it and uses it instead of `VERIFY_CMD` — for that ticket only.\nThe line is executed as-is: tickets are part of the trust surface, just like the session's\n`bypassPermissions`. So a ticket whose author is not the repo's owner, a member or a\ncollaborator is skipped at planning time. The comments are not filtered: on a public\nrepo, anyone can write in them, and the session reads them — run it in a container.\n\nTwo forms are accepted, the bare one above and the command in `code`. When the value\n**starts** with a backtick span, only that span is the gate — what follows is a note for\nthe agent:\n\n```\n**Verify:** `ruff check app/ && pytest tests/ -q`, plus one fresh test per criterion\n```\n\nA value ending in `:` is an introducing sentence, not a command: it is ignored and the\nticket falls back on `VERIFY_CMD`. Same rule as `Timeout:`, `Model:` and `Effort:` — a\nbadly written ticket must not cost a run.\n\nThat makes three levels, from the most general to the most specific — **the most specific\nwins**:\n\n```\nthe script's default  →  the repo's .afk.env  →  the ticket's Verify: line\n(pnpm monorepo)          (this project)          (this ticket)\n```\n\nIt is also the answer to the symmetric risk of the green rate: **100% green means nothing\nif the gate verifies nothing.** On a cosmetic ticket, \"green\" means \"it compiles\".\n\n## Per-ticket time budget\n\n`TIMEOUT` is global, a ticket's size is not: a rework — migration, formula, guards, tests,\nfour docs — does not fit the shape of an average ticket, and gets cut in the middle. Same\nplace, same parser as `Verify:`:\n\n```\nTimeout: 90m\n```\n\nThe format is `timeout(1)`'s (`90m`, `2h`, `3600`). A value of any other shape is ignored:\npassed as-is, it would stop the session from starting.\n\n## Per-ticket model and effort\n\nSame place, same parser, for the same reason: the global setting was chosen for the\naverage ticket, and a typo fix does not need the model of a rework.\n\n```\nModel: sonnet\nEffort: high\n```\n\n`Model:` accepts an alias (`opus`, `sonnet`, `haiku`) or a full name; `Effort:` one of\n`claude`'s levels (`low`, `medium`, `high`, `xhigh`, `max`). Without these lines, `MODEL`\nand `EFFORT` apply; without them, `claude`'s defaults.\n\nThe summary gives the models that **actually** ran: `FALLBACK_MODEL` switches to a backup\nmodel when the main one is unavailable — without that, a whole night can change model\nwithout saying so. It is that fallback which stops a transient outage from burning a\nticket's two attempts in a few seconds and emptying the queue.\n\nThe column also carries the number of subagents the session spawned\n(`sonnet-5 (+2 subagents)`): they carry the model of their definition\n(`.claude/agents/*.md`) and not the ticket's, so several models are only a fallback\nwithout them — and part of the cost comes from them (defect 41).\n\n## Resuming a failed session\n\nA ticket handed back to `ready-for-human` keeps its worktree **and** its session. The\nsummary gives the command to get back in:\n\n```\n(cd .afk/wt/48 && claude --resume 42ce8dfe-…)\n```\n\nIt is the only way to ask the agent why it took that path — a log will never say.\n\n## Building elsewhere\n\n`AFK_BUILD_CMD` in `.afk.env` (empty by default: the session is `claude -p` in the\nworktree, unchanged). When set, the worker runs that command **instead of** the session.\nOrder, bases, gate, push, PR, labels and summary stay in afk: the command is only another\nway to get commits into the worktree. Typical use: the build runs on another machine.\n\nRun with `bash -c`, cwd = the ticket's worktree, within the ticket's `Timeout:`, stdin\nclosed, stdout kept in `.afk/<n>-<attempt>.json`, stderr in `.afk/<n>-<attempt>-build.err`.\n\n| variable | value |\n|---|---|\n| `AFK_PROMPT_FILE` | the prompt afk would give the session (a file) |\n| `AFK_TICKET`, `AFK_TITLE` | the ticket's number and title |\n| `AFK_ATTEMPT` | `1`, or `2` after a red gate |\n| `AFK_BRANCH` | the branch the work must end up on (`feat/<n>`) |\n| `AFK_BASE` | the base the branch starts from: `origin/<branch>`, or a blocker's branch when stacked |\n| `AFK_GATE_LOG` | attempt 2 only (empty otherwise): the failed gate's output |\n| `AFK_STATUS_FILE` | `.afk/<n>.status`; append `key=value` lines to add a key |\n\nOutput contract:\n\n- **exit 0**: the work is **committed in the worktree, on `AFK_BRANCH`**. Uncommitted\n  changes are committed by afk's safety net, as for a local session.\n- **exit nonzero**: red, no second attempt. The reason is the last non-empty stderr line\n  (or `timeout`), shown in the log and in the ticket's comment.\n- optional `resume=<text>` in `AFK_STATUS_FILE`: shown by the summary for the reds, in\n  place of the `claude --resume` line.\n\nWhen blockers were absorbed by merge, the worktree's `HEAD` is **ahead of** `AFK_BASE` and\nthat commit exists only here: a command that builds somewhere else has to publish it\nitself if it needs it as a base.\n\n## When a ticket has nothing left to do\n\nA ticket can be delivered by its predecessor — the previous ticket's agent went beyond its\nscope, which is the norm as soon as a typed contract crosses the apps. \"The agent failed\"\nand \"there was nothing left to do\" both came out as `no commit`: two attempts burned per\nticket, then `ready-for-human` for a false reason.\n\nBut the gate on the base says nothing about the ticket's content: it is green because the\nrepo compiles, not because the requested work happened. A ticket the agent judges too\nearly — a prerequisite that is not in this base — therefore came out \"absorbed\", so\ninvited to close (defect 40). The prompt now asks the session to name its case on its last\nline when it commits nothing:\n\n- `AFK: BLOCKED <what is missing>` → the ticket is **frozen**, as behind an unlifted\n  blocker: label unchanged, no PR, a comment citing what is missing, no second attempt\n  (same session, same base, same conclusion). It starts again on the next run.\n- `AFK: ALREADY DONE`, or no line at all → the gate runs **on the base**:\n  - **red** → the agent really did produce nothing, next attempt then `ready-for-human`;\n  - **green** → the ticket is **absorbed**: switched to `in-review` with a comment, no PR,\n    neither red nor \"green on 1st attempt\". Its dependants start from the base it used\n    itself, instead of freezing behind a false failure.\n\n## When a session ends badly\n\nA Claude session can die after producing complete work, or at 60%: the gate returns\nexactly the same green in both cases. The script does not throw the work away — the net\ncommits the dirty tree, with the ticket title as the message — but:\n\n- the PR comes out **as a draft**, with the failure and the session file's path in its body;\n- the ticket does not count as \"green on the first attempt\", and it leaves the summary's\n  `green` line: a draft PR does not get merged;\n- it shows up in the summary's `draft` line, **with the reason** — `cut`, `abnormal` or\n  `not committed`.\n\nSame treatment when the agent did not commit by itself: it is an anomaly, not a success.\n\nThe three are not reread the same way. A session **cut** at the `timeout` may have been cut\nin the middle of a file; a session ended **abnormally** stopped between two actions;\n**not committed** means the work is there and only the commit was missing. The gate tells\nnone of the three apart: it says that what exists compiles, not that the work is complete.\n\n## Green, and yet unproven\n\nTwo results leave the `green` column without being failures:\n\n- **unproven green** — the ticket had a `Verify:` line (local gate replaced by its own)\n  **and** its CI did not conclude. Its only complete gate is the one that returned no\n  verdict: nobody checked what its line does not cover. A repo **without** CI does not\n  count: that is not a missing verdict, it is a property of the repo, and the summary says\n  it once for the run instead of once per ticket.\n- **push refused** — the branch is complete, green and committed locally, but the remote\n  refused the `git push` (token without the `workflow` scope on a ticket touching\n  `.github/workflows/`, branch already there). No session is relaunched — the second\n  attempt would fail identically —, no label is changed, the worktree is kept, and the\n  remote's reason is carried into the summary from `<n>-push.txt`.\n\n## Ctrl-C\n\nA tool that runs for hours gets interrupted. On `INT`/`TERM`, the orchestrator kills each\nworker's **descendants** — the worker is a subshell, `claude` and `pnpm` are under it, and\nkilling the subshell alone left them orphaned and alive — then collects the worktrees of\nthe green or absorbed tickets. Those of the reds and the interrupted ones stay: that is\nwhere we go to read what happened.\n\n## Git without a keyboard\n\nThe remote is often on SSH, with a passphrase-protected key and no `ssh-agent` — every\n`pull` and every `push` then ask for the keyboard, in a tool that means *away from\nkeyboard*. Worse: detached, the push sleeps without printing anything, indistinguishable\nfrom a ticket taking its time.\n\nThe script rewrites `github.com` to HTTPS for the duration of the run and serves the `gh`\ntoken through its credential helper. Nothing is written into `.git/config`, the token never\ntouches the disk, and the repo's original configuration is not modified (everything goes\nthrough `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`).\n\n## Tests\n\n- `./check.sh` — the pure parsers (`label_for`, `blocked_refs`, `meta_line`,\n  `deepest_branch`, `peak_context`, `clashing_numbers`, `jval`, `jmodels`, `jspawned`).\n  Fast, no side effects.\n- `./harness.sh` — the whole orchestrator, without network or LLM: `claude` and `gh`\n  stubbed, local bare remote, 8 tickets covering a diamond DAG, the safety net, a crashed\n  session, an open external blocker, a cascading freeze, the CI phase and integration.\n  It found three bugs on its first run — run it after any change to the loop.\n\n## Accepted limits\n\n- It merges nothing. Human review stays the last gate.\n- Parallelism only applies to independent tickets. A chain of four stacked tickets stays a\n  chain: `-j 8` will not change that.\n- One `pnpm install` per worktree. Hard links from the store, so nearly free on disk, but a\n  remote store or a non-hoisted `nodeLinker` would change the bill.\n- The integration pass detects the clashes, it does not resolve them.\n- `--permission-mode bypassPermissions`: run it in a container if the repo is not\n  disposable.\n"}