afk.sh
CLAUDE.md3 starsChanged 44 days agoMITView on GitHub
Three bash scripts, no dependencies, no LLM in the orchestrator. afk.sh chains claude -p "/implement GitHub ticket #N" sessions over the ready-for-agent tickets of a GitHub repo configured by /setup-matt-pocock-skills. The README describes the user-facing behaviour; this file describes the internal invariants. skills/ holds two families, and they do not have the same rights. Framing a run — afk-setup (write the .afk.env), afk-preflight (reread the batch before launching), afk-debrief (go through the run on waking up). They propose, they do…
- Reads credentials
- Commits and pushes
In this repository6 other files it gives its agents.
- afk-debriefSkill
- afk-mergeSkill
- afk-preflightSkill
- afk-setupSkill
- afk-specSkill
- afk-waveSkill
The file
We split this CLAUDE.md into the rules it gives an agent
1 rule. About 0 are common, 0 are distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 67
No per-test runner: harness.sh is monolithic, it runs in full.
≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
Three bash scripts, no dependencies, no LLM in the orchestrator. afk.sh chains
claude -p "/implement GitHub ticket #N" sessions over the ready-for-agent tickets of a
GitHub repo configured by /setup-matt-pocock-skills. The README describes the user-facing
behaviour; this file describes the internal invariants.
skills/ holds two families, and they do not have the same rights.
Framing a run — afk-setup (write the .afk.env), afk-preflight (reread the batch
before launching), afk-debrief (go through the run on waking up). They propose, they do
not act — a skill launching afk.sh or relabelling a ticket would put an LLM back into the
loop through the back door. One exception, and it is explicit: /afk-preflight apply,
which only afk-app.sh's loop invokes. There nobody is awake to approve a table, so it
applies its mechanical fixes itself — and still refuses the one thing that would break the
guard below, rewriting an acceptance criterion; such a ticket leaves the batch instead.
Building an app autonomously — afk-spec (idea → docs/spec.md + skeleton + gate,
once), afk-wave (open the next wave of tickets), afk-merge (land the wave on the landing branch — BASE_BRANCH, dev by default —
check the boxes, report which stop holds). afk-app.sh chains them around afk.sh, one
wave after another, with /afk-preflight apply between the opening and the run. Those
ones act: they run in a loop when nobody is awake, so waiting for approval makes no
sense. The invariant holds all the same: they stay above
afk.sh, which still has no LLM inside — it is the loop that judges, not the orchestrator.
Four of the six are user-invoked (disable-model-invocation: true): only a human or
afk-app.sh launches them by name, so their description has no business in the context
of every session of every project. afk-setup and afk-debrief stay model-invoked
because another skill runs them — afk-spec step 6 and afk-merge step 5; a
user-invoked skill cannot be reached from another one.
What keeps them honest is not their good will, it is a mechanical guard: docs/spec.md is
written once by afk-spec and tagged afk-spec. The other two can only check boxes, and
they verify it at every turn:
diff <(git show afk-spec:docs/spec.md | sed 's/\[x\]/[ ]/g') <(sed 's/\[x\]/[ ]/g' docs/spec.md)Non-empty → the loop stops. Without it, whoever writes the criteria and whoever fills them
are the same model. The boxes are neutralised on both sides of the diff: the tagged
version already contains a checked one (the skeleton makes A0 pass), and a guard
neutralising only one side refuses to run from the very first wave.
And a box is only checked after the criterion's command has passed on the landing branch: the
counter is mechanical, never a reading. afk-app.sh leans on the same principle — it counts
the boxes and the open tickets, it never reads what a session says to decide whether to
continue.
The code and the comments are in English — stick to it.
Commands
./afk-app.sh -w 4 -j 3 # the loop: /afk-wave → /afk-preflight apply → afk.sh → /afk-merge, ×4
./check.sh # pure parsers, ~1 s, no side effects
./harness.sh # the whole orchestrator, claude and gh stubbed, local bare remote (~1 min)
bash -n afk.sh # syntax only (check.sh does it first anyway)
./afk.sh -n 43 48 # the plan, without launching anything — useful to validate the scheduler by handNo per-test runner: harness.sh is monolithic, it runs in full. To isolate, comment out
the unrelated want/want2/want4 calls, or read the traces it leaves in its temporary
$T (run.log, run2.log, gh.log).
Run ./harness.sh after any change to the loop (scheduler, worker, worktrees, CI /
integration phases). It found three bugs on its first execution.
Architecture
One file, two halves
afk.sh splits in two at the line [[ -n "${AFK_LIB:-}" ]] && return 0:
- above: the pure parsers (
label_for,blocked_refs,meta_line,deepest_branch,peak_context,clashing_numbers,jval,jmodels,jspawned).check.shdoesAFK_LIB=1 source ./afk.shto test them alone. They must read no global and write nothing. The override validation patterns (RE_TIMEOUT,RE_MODEL,RE_EFFORT,RE_VERIFY) are there too, socheck.shtests the ones that actually serve rather than a copy. All four fields pass a pattern:Verifywas the only one that did not, and the only one whose value is executed. - below: config, guard rails, and the loop. None of that is testable by
check.sh— it goes throughharness.sh.
Adding a parser ⇒ put it above the guard and give it a case in check.sh.
The pipeline
plan_run → schedule (→ launch → worker) → ci_phase → integration_check → write_summary
plan_runreads each ticket once throughghand caches everything in.afk/<n>.{body,title,labels,verify,timeout}. The worker never calls the API back for metadata. It also sorts the blockers intoDEPS(in the run, or outside the run but with an open PR) andEXT(outside the run, freezing).scheduleloops overdeps_state, whose return code is a tri-state:0ready,1wait,2frozen. Up toJOBSlive workers.workerruns in a subshell, cwd = its worktree.
The parent/child protocol
A worker is a subshell: it cannot write anything into the parent's arrays. It drops
key=value lines into .afk/<n>.status, the parent reads them back with sget. Any new
information a worker surfaces goes through there.
Keys: result (ok | ko | absorbed | frozen), branch, base, base_ref,
attempt, pr, draft, draft_why, reason, dur, session, resume, cost, model,
subagents. draft is a flag set on an ok, not a result.
The file is append-only and sget reads the last line. So the cautious value written at
startup is called result_initial, not result: a human running cat or grep result= on
a green ticket used to read result=ko at the top. A missing result counts as red (reap
handles it in its default branch).
reap translates these statuses into the parent's arrays (OK KO SKIP DRAFT
ABSORBED PUSH_KO BRANCH_OF FIRST_TRY), which then feed ci_phase,
integration_check and the summary. GREEN and UNPROVEN are computed at summary time
only: OK stays the raw list of tickets that opened a PR, GREEN removes from it anything
no complete gate has seen (draft, or reduced gate + inconclusive CI). It is GREEN that is
displayed.
Isolation
One ticket = one worktree .afk/wt/<n> on feat/<n>, created from origin/<base>.
The main tree is never touched — no checkout, pull or reset. Every git operation
of a worker must stay inside its worktree (git -C "$wt" from the parent).
A green worktree is dropped, a red one is kept: it is the debugging artefact.
set -uo pipefail, without -e: a worker failing is a result, not a reason to stop the
run.
The session
claude -p … --output-format json, never --resume: the output is an object, not a log.
jval reads subtype from it (the failure is NAMED instead of returning a code) — except
a refusal, which completes the turn and therefore reads success: stop_reason is the only
field that says it, grepped directly because it comes AFTER the free-text result and
jval takes the first match (defect 46) —
session_id (the summary turns it into a claude --resume for the reds, whose worktree is
kept), total_cost_usd (cumulated over the ticket's attempts); jmodels reads the models
actually used, the only way to see a FALLBACK_MODEL fallback. But modelUsage and the
cost aggregate the session and its subagents, which carry the model of their definition and
not the ticket's: jspawned reads their number, without which a second model reads as a
fallback that never happened (defect 41). Adding a session flag means passing it through
copts — and checking that the harness's fake claude still returns a readable object,
otherwise every session looks mute.
The prompt
build_prompt receives head0 — the HEAD from BEFORE the session — and not HEAD:
inherited_note uses it to list what the blockers delivered
(git diff --name-only "$BASE_REF...$head0"). On attempt 2, HEAD already carries the
agent's work, which it has nothing to learn from. The harness's fake claude copies its
prompt into $T/prompt-<n>.txt: the prompt is testable like the rest.
It names the plugin's skills in full (mattpocock-skills:tdd,
mattpocock-skills:code-review): /implement says /tdd and /code-review, and the bare
names resolved to Claude Code's built-in review or to nothing — 173 sessions, tdd called
0 times (#3). It also makes the ticket the written confirmation of the seams /tdd wants
agreed (#4), and puts the review after the commit, with head0 as its fixed point.
It also asks the session for a decision journal at $AFK_DIR/<ticket>-work.tsv. This is
the only thing the prompt asks for that neither the gate nor the diff can check, so
nothing enforces it: a missing journal is an afk defect to record, not a red. It exists
because /afk-debrief was rebuilding the session's reasoning from the traces, which do
not contain it — the alternative was claude --resume on every red. Its whole contract
(path, columns, what deserves a line, append only) lives in build_prompt, and nowhere
else: no skill is needed, so a config dir without one (CLAUDE_CONFIG_DIR) still gets
a journal.
AFK_BUILD_CMD
Replaces only the build session, where the worker would run claude -p. Everything
after it (safety net, HEAD check, gate, push, PR, labels) is the same code, so the
contract is minimal: exit 0 = commits in the worktree on feat/<n>; nonzero = result=ko,
reason=build, written directly (no second attempt). Inputs are AFK_* env variables
(list in the README); AFK_STATUS_FILE lets the command add keys, and resume=<text> is
the only one the summary reads. afk knows nothing of what the command does — no vendor
name belongs here. The afk-base/<n> publication for absorbed blockers is the command's
job (HEAD ahead of AFK_BASE), not afk's. The harness covers it with a fake builder.
Deliberate duplication
The DRY_RUN block redoes launch()'s base/absorption computation (the run's branches do
not exist yet, so deepest_branch falls back on its fallback). The two must stay in
agreement — changing one without the other makes -n lie.
Git auth
setup_git_auth rewrites github.com to HTTPS and wires gh's credential helper, only through
exported GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n. Never write into .git/config: the
token must not touch the disk and the host repo's config must not move. The indices are
contiguous — adding a key means incrementing COUNT.
Touching the harness
harness.sh stubs claude and gh with two scripts in a temporary $PATH.
Adding a gh call in afk.sh means handling it in the fake gh — otherwise it falls
into the final exit 0 and returns an empty string, which shows up very far from the cause.
Same for a new agent behaviour: it is simulated by a case in the fake claude, keyed on the
ticket number extracted from the prompt.
The harness's runs are independent and ordered: parallel (diamond DAG, safety net,
crash, freeze), the red #7 relaunched alone (its old tip kept), series (absorbed, frozen by
its own session, refused before and after its first edit, Timeout:, in-review, an
outsider's ticket),
interruption, stacking on a PR open outside the run (plus the same batch replayed in -n,
which must say the same thing), two independent direct blockers (base + absorption, double
inheritance), push refused by the remote + the same path created twice + a stale reference +
a dependant of both whose stacking conflicts, repo without CI, and reduced gate with CI that
does not conclude. The ticket numbers carry their scenario (see the file header) — reusing an
existing number for something else breaks the assertions.
The bare remote carries an update hook refusing feat/17: that is how a rejected
git push is simulated without a network. And the fake gh pr checks obeys NO_CHECKS
(the repo has no CI), NO_CHECKS_ONCE (CI exists but is not registered yet) and HANG_CI
(it is still running) — three situations --watch rendered with two sentences.
The logs
Written by hand, one entry per event, never one per commit — git log already does that,
and better:
CHANGELOG.md— a change in observable behaviour. To fill in at the same time as the change, not after.docs/proposals.md— an idea that was proposed, with its verdict and the reasoning, including the refusals: an accepted idea leaves a comment in the code, a refused idea leaves nothing and comes back. Dismissing a proposal without writing it there means agreeing to redo the reasoning.docs/defects.md— an afk defect observed for real during a run, numbered. A code comment can point at it (defect 17). Only what would have broken the same way on any repo goes in: the worked-on project's problems get fixed over there. Written by/afk-debriefor by hand.docs/defects-fixed.md— the same ones once fixed, same numbering. The live file only keeps the open and mitigated ones: a debrief reads it end to end, and thirty closed entries cost as much to read as the four that still need a decision. So a reference is looked up indocs/defects*.md.
Written by the sessions, not by the script:
.afk/<ticket>-work.tsv— the ticket's decision journal, appended by the session as it goes, six tab-separated columns (ts,phase,decision,why,evidence,result). Asked for bybuild_prompt, read by/afk-debriefat step 4. Gitignored with the rest of.afk/, and overwritten by the next run on the same ticket like every other file there — a journal worth keeping gets copied out before relaunching.
Written by the machine:
RUNS.md— one line per run, appended byappend_run_logat the very end. The facts, not a judgement. It lives in$AFK_HOME(the script's repo, not the worked-on project) because it is the only place mounted in every project, and because.afk/summary.mdis overwritten on the next run.harness.shredirectsAFK_HOME: without it, its test runs would append to it. The project column is a stable digest of the repo directory name, not the name itself — this log travels with afk's repo and gets read out of context.
The why of a choice already implemented stays in the comment next to the code — these files do not duplicate it, they point at it.
Trust surface
A ticket's Verify: and Timeout: lines are executed / passed as-is. Tickets are part of
the trust surface, just like the sessions' --permission-mode bypassPermissions.
Agent skills
Issue tracker
GitHub Issues on this repo, through the gh CLI. See docs/agents/issue-tracker.md.
Triage labels
The five default labels, unchanged (needs-triage, needs-info, ready-for-agent,
ready-for-human, wontfix) — afk.sh already reads ready-for-agent and
ready-for-human. See docs/agents/triage-labels.md.
Domain docs
Single-context: one CONTEXT.md and docs/adr/ at the root, neither created yet. See
docs/agents/domain.md.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

