What Does a Repo Need Before You Hand It to an AI Agent?
What Does a Repo Need Before You Hand It to an AI Agent?
AI coding agents write more code in an afternoon than you can read in a week, so “I read it and it looked fine” stops working. This is a practical guide to preparing a repository so agents can work in it without drifting, lowering the bar, leaking secrets or calling work done too early. It comes from two real projects, with every file you need, and you can hand it straight to an agent.
Plain-Markdown edition for AI agents. Web version: https://frequently-asking-questions.com. Author: Merlin Duty-Knez.
The short answer, from the two projects I’ve done this on: six things, before I’d let an agent work in a repo for more than a single sitting. One spec where every rule names the test that catches it breaking. Docs that say whether they are current. A written definition of “done” that a machine can check. Guards against dangerous actions that don’t rely on the model obeying instructions. A reviewer from a different model family. And a way for the agent to see the running app.
The rest of this post is the long answer, written as a guide. It covers project-based agentic coding: AI coding agents working in your repo for weeks, across many sessions, often several at once, rather than one-off prompts in a chat window. It is written for people and for agents.
Everything here comes from two real projects:
- Votery (Lottery of Choice), a small Flutter app for group decisions. It started as a normal hobby repo with Copilot-generated docs scattered across the root. In September 2026 I restructured it for agent-driven work, and a release was then built mostly by a lead agent and a team of agent teammates. Votery is the retrofit case. Its repo is private, so the snippets below are trimmed copies.
- Tempest Fauna Trail (TFT), a weather-driven roguelike in Python/Flet. It was built spec-first with Claude Code from its first day in May 2026. TFT is the greenfield case (a project started from scratch), and its repo is public, so you can read every file I mention.
Parts that apply only to Claude Code are marked [Claude Code]. The rest works with any agent that reads files in your repo.
How do you use this guide with an agent?
There are three ways to use it, from most to least automated:
Skill
/prep-repo
A Claude Code skill that runs the whole setup in phases and writes every file from templates.
Install it once, then type /prep-repo in any repo.
Raw Markdown
GUIDE.md
This article as plain Markdown, without the page styling, for any agent that can fetch a URL:
/guides/agentic-repo-prep/GUIDE.md.
This page
Read it yourself
Every section explains the why, shows the file, and says what went wrong without it.
With the raw file, the prompt is short:
Paste into your agent, inside the repo you want to prepare:
Read https://frequently-asking-questions.com/guides/agentic-repo-prep/GUIDE.md
and prepare this repository according to it.
Follow "Step by step: preparing a repo". Ask me the step 1 interview questions first.
Work on a branch. Show command output as evidence; don't assert success.
Stop and ask whenever a permission rule or hook blocks you.
Install the prep-repo skill
The skill is one archive: SKILL.md, a copy of this guide as GUIDE.md, and the templates it
fills in (entry file, spec, format, architecture, docs README, task plan, journal, handoff,
rules, settings, guard hook, review script, and the five workflow skills).
curl -fsSL https://frequently-asking-questions.com/guides/agentic-repo-prep/prep-repo.tar.gz \
-o prep-repo.tar.gz
tar -tzf prep-repo.tar.gz # look before you unpack
mkdir -p ~/.claude/skills
tar -xzf prep-repo.tar.gz -C ~/.claude/skills/ # → ~/.claude/skills/prep-repo/
Read SKILL.md and templates/guard.py before you use them. Then, inside a repo, start
claude and type /prep-repo.
Why isn’t “the agent ran the tests” enough?
Because passing tests only prove what the tests check. Tests can miss real behavior, exercise the wrong code, or be unable to fail at all, and the agent stops as soon as the work looks done. Anthropic’s own guidance says it plainly: “Claude stops when the work looks done. Without a check it can run, ‘looks done’ is the only signal available, and you become the verification loop.”1
An agent writes more code than you can read, so the repo has to hold the rules itself, in a form the agent loads, follows and can be checked against. Without that, this is what happened in Votery:
| Failure mode | What it looked like |
|---|---|
| Docs lie | README said the app had 2 voting modes; the code had 4. The README’s weighted-vote example (A:+0.5) contradicted the code (×10, so A:+5). A compliance doc named the wrong privacy-policy URL. A “plan” doc described a feature that had already shipped. |
| Agent reports lie | A read-only research agent reported “no Navigator usage”; grep found 16. The lead agent wrote the rule “ranked tokens symmetric around 0” from reading a loop; a test showed k=2 → [1, 0]. A first rule draft said logging uses developer.log only; the code used debugPrint 56 times. |
| Docs written as if an action succeeded | The agent’s git rm --cached for the signing keystore was blocked by a permission rule, but the spec was written as if the file had been untracked. Two reviewers caught it. |
| Secret leaks | The Android upload keystore had been in git since the first release. Nobody had gitignored secret/. |
| Lowering the bar | A test that could never fail: in Dart, List == compares identity, not contents, so “different players get different orders” was always green. |
| Destructive “fixes” | The agent’s first fix for a bug in the keystore fetch script ran rm -f on the keystore before downloading the new copy. A failed download would have destroyed the only working copy. |
| Verified the wrong property | A backup round-trip was “byte-identical”, but what mattered was whether the keystore opens with the stored password. It didn’t. |
| Green tests, broken app | Widget tests passed, but on a real emulator the back button looped between two screens and a dialog got pulled away by an animation. Both were release blockers. |
| Logic in the UI, duplicated | Tally math lived in two widgets and winner selection in two more, with different algorithms and unseeded randomness. Untestable, so every refactor was blind. |
TFT’s journals, which record every agent error in their Process notes, add three more patterns:
- Invented names.
BattleResult.winnerand.total_tickswere “hallucinated from plausible conventions”; the real fields areoutcomeandduration_ticks._CHAMPION_DEFSwas assumed to be a dict (it’s a tuple), and the route export was assumed to beROUTE(it’sSTAGES). - Numbers in prose. The agent stated a combination space as “1944” twice; the real count is 648, which only came out when a script printed it. The journal’s lesson: “compute combinatorics in code, not in prose.”
- Fixing the wrong cause. A bias where one side always acted first was treated as a speed-precision problem “for many turns” before anyone looked at the tiebreak field. It surfaced because a mirror match (identical teams) didn’t come out 50/50.
None of these are exotic. They are what “plausible and confident” looks like when nobody checks. The rest of this guide gives the agent the checks and makes the repo the place where the rules live.
What goes where?
Split everything an agent reads into layers. Give each layer one job, and say plainly how current it is.
Figure: The entry file is short and mostly pointers. Four layers hold the content, each marked LIVING or FROZEN. Enforcement sits underneath, because the layers above it are only context. Even enforcement is best effort: hooks read command text, and a sandbox only covers the commands that run inside it.
| Layer | File(s) | Job | Currency |
|---|---|---|---|
| Entry point | CLAUDE.md / AGENTS.md |
Commands, hard rules, pointers. Short. | LIVING |
| Contract | SPEC.md (+ FORMAT.md) |
Goal, constraints, interfaces, invariants, tasks, bugs, open questions | LIVING, one writer |
| Map | ARCHITECTURE.md |
Where things live and how they connect | LIVING |
| Reference | docs/live/** |
“How subsystem X works now” | LIVING, audited |
| Record | docs/design/** |
Plans, proposals, write-ups at a date | FROZEN |
| History | docs/journal/** |
The “why”, including how the human + agent loop went | FROZEN, append-only |
| Scoped rules | .claude/rules/*.md |
Rules that load only when matching files are touched | LIVING |
| Procedures | .claude/skills/*/SKILL.md |
Multi-step workflows, loaded on demand | LIVING |
| Enforcement | .claude/settings.json, hooks, CI |
What must happen whatever the model decides | — |
Two principles hold this together:
Principle 1
Docs lie; code wins.
Every doc is a claim to check against the code. When SPEC and CLAUDE.md disagree, SPEC wins. When SPEC and the code disagree, that’s a bug, and it goes through backprop.
Principle 2
Advice vs enforcement.
CLAUDE.md, rules and skills are context: the model reads them and usually follows them. Permission rules, hooks and CI are enforcement. Anything that must never happen belongs in the second group.2
Here’s the full layout Votery ended up with (only the agent-relevant parts). TFT’s is almost the same, browse it on GitHub.
CLAUDE.md entry point (≈90 lines)
SPEC.md contract: §G §C §I §V §T §B §D
FORMAT.md how SPEC is written
ARCHITECTURE.md map of the code
.claude/
settings.json allow/deny rules + hook registration
hooks/guard.py deterministic guard (secrets, whole-tree staging)
rules/{domain,state,ui}.md path-scoped rules
skills/{plan,check}/SKILL.md repo-specific skills
docs/
live/ LIVING per-topic docs + README with the header convention
design/ FROZEN plans; design/tasks/tN_*_plan.md per task
journal/ FROZEN dated entries; legacy/ = old wiki, verbatim
templates/ task_plan.md, journal_entry.md
release/1.1.0/ HANDOFF.md
privacy.html served at a URL registered in the Play Console → never move
tool/
review/codex_review.sh cross-model read-only review
e2e/ emulator harness: emulator.sh, install.sh, drive.py, flows/
signing/ store/fetch the signing key from the password manager
test/ mirrors lib/: domain/ models/ pages/ providers/ services/ widgets/ support/
.github/workflows/ci.yml analyze + format + test
What goes in CLAUDE.md (or AGENTS.md)?
Keep it short and make it mostly pointers. Anthropic recommends “target under 200 lines per CLAUDE.md file. Longer files consume more context and reduce adherence,” and gives a test for every line: “Would removing this cause Claude to make mistakes? If not, cut it.”21 Votery’s is about 90 lines, in six sections:
- What this is, in one paragraph, plus the source-of-truth pointer.
- Commands the agent can’t guess.
- Definition of done, numbered (below).
- Docs map: path, role, LIVING or FROZEN.
- Workflow: one line per skill, plus the “stuck twice” rule.
- Hard rules, each saying which hook or deny rule enforces it.
The top of Votery’s file:
# Votery (Lottery of Choice)
Pass-and-play Flutter app: a group votes on options, a weighted lottery picks the winner.
> **Source of truth: [SPEC.md](SPEC.md)** (§V invariants, §T tasks, §B bugs). When this file
> and SPEC disagree, SPEC wins. Map of the code: [ARCHITECTURE.md](ARCHITECTURE.md).
## Commands
flutter analyze # must stay "No issues found!"
flutter test # all tests
dart format test/ # CI enforces format on test/ only (lib/ not yet formatted)
dart run build_runner build --delete-conflicting-outputs # after editing @HiveType models
What’s not in it: architecture prose (→ ARCHITECTURE.md), per-area conventions
(→ .claude/rules/), procedures (→ skills), history (→ journal). TFT’s
CLAUDE.md is a good
comparison: at 189 lines it’s under Anthropic’s recommended 200-line target but far past the ~100 lines
Votery aims for, and much of that length is the planning
procedure that its plan skill also carries. One copy would be enough.
CLAUDE.md or AGENTS.md? AGENTS.md is the cross-vendor convention: plain Markdown, no
required fields, and in a monorepo the nearest file wins.3 Recent Claude Code versions also read AGENTS.md
directly when there is no CLAUDE.md (check the memory docs for the minimum version; /context
shows what actually loaded). If several tools work in one repo, put the shared
content in AGENTS.md and make CLAUDE.md import it, so there’s one copy to keep
current:2
@AGENTS.md
## Claude Code
Use plan mode for changes under `src/billing/`.
Claude Code details worth knowing
@pathimports load at launch. They organise the file but don’t save context.- Block-level HTML comments in CLAUDE.md are stripped before the model sees them, so you can leave notes for human maintainers at zero token cost.
/contextshows which memory files actually loaded. In recent versions,/doctor prompt-auditchecks instruction files for stale references and contradictions.- The project-root CLAUDE.md is re-read after
/compact. Instructions you only gave in chat are not. That’s the practical reason rules belong in files.
What is SPEC.md, and why does every rule name a test?
Every rule names a test because a rule without one is only a claim: nothing tells the agent, or
you, when it stops being true. SPEC.md is where those rules live, the single source of truth
for what the project promises. The idea comes from spec-driven development: the
spec is “the center of your engineering process”, a living document that drives
implementation instead of being written once and forgotten.4 Both projects use the
same seven core sections:
| § | Section | What goes there |
|---|---|---|
| §G | Goal | 2–3 lines: what the product is for |
| §C | Context | Constraints: stack + versions, state, storage, platform, privacy, secrets, languages (i18n) |
| §I | Interfaces | Public seams that code and tests depend on: signature, file:line, one-line contract |
| §V | Invariants | Rules that must hold, each naming its guard (test or hook) or saying unguarded |
| §T | Tasks | Table: # · Task · Files · Depends · Est · Status |
| §B | Bugs / Backprop | Every bug: date, symptom, cause, fix, and which §V now guards it |
| §D | Deferred | Open design questions; resolved inline, never deleted |
(A “public seam” in §I is any function or file format that other code or tests rely on, so changing it breaks something elsewhere.)
The invariant format is what makes this more than a to-do list. In plain words, Votery’s rule
V.7 says: only options whose net tally is above zero can win, each one’s chance is its share of
the total of those positive tallies, and if exactly one option qualifies it wins without a draw. Three
test files fail if any part of that stops being true. In the spec’s compressed notation
(Σ means “sum of”):
- V.7: Nominees = options with tally **strictly > 0**; probability = score / Σ positive scores;
Σ probabilities = 1. Zero nominees → no draw; exactly one → auto-winner, no draw.
Guard: `test/providers/lottery_controller_test.dart`, `test/domain/draw_test.dart`,
`test/domain/odds_test.dart`. (T.0, T.3)
- V.11: No secrets in git: `secret/`, `*.jks`, `android/key.properties` gitignored and untracked;
agents never read them. Guard: `.claude/hooks/guard.py` PreToolUse hook +
`.claude/settings.json` deny rules (best effort, not a sandbox). (T.0)
- V.9: Inventory boxes capped at 50 items. Guard: unguarded. (T.0)
Guard:names the test or hook.unguardedis allowed, but it’s recorded debt, and a §T row should exist to fix it.- The trailing
(T.x)says which task introduced the rule.(T.0)means “distilled from the code as it was before the spec existed”.
A bug entry always links back to an invariant:
- B.2 [2026-09-25] Cancel in setup category dialog still added the option.
**Cause:** `if (result != null || mounted)` — `||` instead of `&&`.
**Fix → V.16 (T.15):** "Add" pops `''` for no category, Cancel pops `null`, check `&&`.
Touches `lib/pages/setup/category_picker_dialog.dart`.
TFT’s SPEC.md has the same
pattern at a larger scale. Its V.77, for example, exists because of bug B.34 and names two
tests, test_reward_components_are_recipe_vocabulary and test_any_two_reward_components_fuse.
Tasks are a table with an explicit status and estimate. Anything bigger than L (3–6 h)
gets split along a real seam:
| T.2 | Extract pure tally `computeTally(GameState) → Map<String,double>` into `lib/domain/`; replace both UI copies; test vs T.1 oracle | `lib/domain/tally.dart`, … | T.1 | M | ✅ |
Deferred items keep their trail, because “why didn’t we do X?” comes up again:
- D.5: Ranked tokens asymmetric for even k (includes `0`). **RESOLVED [2026-09-25] (T.0):**
intended — README "Reference patterns" documents `2 → +1, 0`, `4 → +2, +1, 0, –1`.
- D.7: Simultaneous online voting (#4). **Deferred [2026-09-25]:** needs backend + INTERNET
permission → breaks §C Privacy …
Two rules make the spec trustworthy:
- Distil the first spec from the code, not from memory. Votery’s spec header says
“Distilled from code 2026-09-25 (pre-spec behavior tagged
(T.0))”. The first version was still wrong in two places (the “symmetric” rule and the gitignore claim). Both were caught because tests and reviewers checked the spec against the code. - One writer. Only the
specskill edits SPEC.md. Thebuildskill may flip a task’s status cell and nothing else. Plans end with “SPEC changes needed”, and those are applied on your OK. Otherwise five agents each “fix” the spec to match whatever they just built.
How should the spec be written? (FORMAT.md)
A short house-style file that the skills read before touching SPEC. The rules that matter:
- Dense wording. Drop articles and filler; fragments are fine. SPEC is loaded often, so tokens count. But never compress identifiers, paths, code, numbers or enum values.
- Cite touch points as
path/file:line, or by symbol. TFT’s living docs deliberately citefile+functioninstead of line numbers, because “lines drift, symbols don’t”. - ids only go up and are never reused. Retired entries are struck through
(
~~V.7 …~~ RETIRED [date] (T.x)), never renumbered, so everyV.7in git history still means the same thing. - One entry reads on its own, without its neighbours. One rule per entry, too: TFT’s later invariants grew into paragraphs that bundle three rules, which makes them hard to guard.
- Estimates:
S(<1 h) ·M(1–3 h) ·L(3–6 h). Bigger → split.
The full template is in the prep-repo skill
(templates/FORMAT.md), and TFT’s is
here.
What does ARCHITECTURE.md need?
A map of the systems: what each one does, where it lives, how it connects to the others, and which patterns the codebase uses, with the file to open for each. Rules don’t go here (those are §V). The test of a good ARCHITECTURE.md is whether an agent that has never seen the repo can go from “I need to change how X works” to the right file without searching.
TFT’s ARCHITECTURE.md is the better of the two projects’ versions. Its opening line sets the job: “How Tempest Fauna Trail is built, how the systems fit together, and where to find each one in the code.” It has four kinds of section worth copying:
Per system
What it does, where it lives
One section per system (combat engine, weather, content, economy, save, UI). Each opens with a
table of concern → file, so “the tick loop” points straight at src/game/combat/engine.py.
Patterns
How things are done here
The recurring patterns, each with one example to copy: in TFT, “content only touches the world
through the mutator API”, “registries via @register_*”, “pure data function + separate
renderer”, and a determinism doctrine to read before touching combat.
Index
“Where do I find X?”
A task-shaped lookup table: I want to… → go to. “Add a boss” lists the three files a boss touches. It answers the question an agent actually starts with.
Walkthrough
One request, end to end
What happens in one fight (or one HTTP request, or one game round), step by step, with the function at each step. It’s how an agent learns the order things run in.
A trimmed slice of TFT’s index:
## "Where do I find X?" — quick index
| I want to… | Go to |
|---|---|
| Change how a fight resolves | `src/game/combat/engine.py` |
| Add/modify what content can do to the world | `src/game/combat/context.py` (mutator API) |
| Add a boss | `src/game/bosses/data.py` + `src/game/abilities/bosses.py` + `src/game/map_effects.py` |
| Change weather fetching / caching | `src/api/{weather,cache,refresher}.py` |
| Understand *why* a decision was made | `docs/journal/` |
| Know what rule must hold | SPEC.md §V |
Votery’s adds two habits:
- State known violations explicitly. “Known violation: domain logic lives in UI — tally in
lib/pages/lottery_page.dart:92andlib/pages/results/tallying_view.dart:153,733… SPEC T.2/T.3 move them tolib/domain/.” An agent that finds the violation then knows it’s known and planned, so it neither copies the pattern nor “fixes” it off-plan. - A table for each cross-cutting concern agents tend to get wrong: where randomness is used and whether it’s seeded, which storage keys exist, what the limits are.
Write it from the code, not from the spec. TFT’s first ARCHITECTURE.md draft documented
resolve_combat(team, enemies, weather, run_mods=None) and a determinism note about that
run_mods default, both copied from SPEC. But run_mods belonged to the augment system, which
was still a plan; the real signature had no such parameter. The same draft got the
CombatContext constructor wrong and guessed the event names (on_attack instead of the real
on_attack_landed). All four errors were caught only because a “very deep review against the
actual code” was demanded. Mark planned parts as planned, and grep every name you cite.
Keep it current. ARCHITECTURE went stale fast during Votery’s busy release. Updating it is
part of the rule in the next section: a change that adds or moves a system, module, route,
store or pattern updates ARCHITECTURE.md in the same commit, and the check skill flags drift.
How do you stop docs from going stale?
With two rules.
1. Whenever an agent touches code, it updates every LIVING doc that describes that code, in the
same commit. Not “the docs for the feature”: every doc whose claims the change makes untrue,
including SPEC, ARCHITECTURE and the docs/live/ page for each touched system. Put the rule in the
entry file and in the definition of done, because agents don’t do it on their own. In TFT, PR
reviews kept finding the same failure:
- a LIVING doc still listing the old
generate_augment_offer(..., rerolled=False)signature after the code moved toreroll_count; docs/live/systems/items.mdsayingspirit_gemwas inBASE_COMPONENTSwhen the code explicitly excluded it;- the roster doc citing a guard test by a name that no longer existed;
- a module docstring saying a feature was “not yet implemented” directly above its implementation.
Each was small. Together they are exactly how “docs lie” starts: every stale line is a confident wrong answer for the next agent.
2. Every doc is either LIVING or FROZEN, and says so in its first line. Votery’s
docs/live/README.md puts it like this:
LIVING vs FROZEN is the rule that stops drift. A reader (human or agent) trusts a LIVING doc as current and a FROZEN doc as a dated snapshot.
docs/live/is reconciled to code on every change that touches its subject, in the same commit as the code change.docs/design/is never retro-edited to match new code. It’s the record of a decision at a moment. When a task lands, distil the durable “how it works” into the matchingdocs/live/doc; leave the plan frozen.
Half of the “docs lie” problem is that nobody can tell a current doc from a stale one. A header fixes that:
> **Status: LIVING** — must match `<code path(s)>`. Audited by `/check`.
> **Scope:** <one line>. **Reconciled:** <YYYY-MM-DD>.
> **Status: FROZEN** (moved <date> from <old path>). Point-in-time record — verify against code.
TFT’s combat doc
is a real LIVING example. A FROZEN doc without its marker is a finding in the check skill.
Migrating an existing repo: move old docs with git mv (to keep their history), add the
FROZEN header, and put anything too messy to reformat into docs/journal/legacy/ verbatim.
Votery’s root-level Copilot compliance docs and its old wiki went there. Exclude legacy/ from
link checks: it’s kept verbatim, broken links included.
The journal (docs/journal/YYYY-MM-DD_topic.md) is the “why” that SPEC compresses out. It’s
append-only: if an old entry turns out wrong, you write a new one saying so. Both projects make
one section mandatory, Process notes (AI collaboration): conflicts between docs and code,
agent errors, guardrails added, drift caught, and a reflection on how the prompting went. The
template’s rule of thumb:
If a future agent reading only the code would repeat a mistake we already made, the journal’s Process notes section failed.
Some files must never move. Votery’s docs/privacy.html is served by GitHub Pages at a URL
registered in the Play Console. It’s listed in the docs map as “LIVING — never move” and in SPEC
§C. An agent tidying up docs/ would otherwise move it without a second thought.
What about plans and handoffs?
- Task plans. Every non-trivial task gets
docs/design/tasks/tN_<topic>_plan.md(Votery has 18, TFT many more). Sections: scope in/out, gap today (piece ·file:line· ✅/🔶/❌), design, decisions, open questions, test plan, machine-checkable acceptance criteria, SPEC changes needed, LIVING docs to update. Plans are FROZEN once written. - Release handoff (
docs/release/<version>/HANDOFF.md): written so the next session, human or agent, can resume cold. It’s LIVING while the release is open (its header says “WORKING DOC” to flag that it changes daily) and becomes FROZEN once the release ships:
> **Status: WORKING DOC**, release 1.1.0 handoff (2026-09-25). Read this first when resuming.
## Done (committed) table: commit → content
## Open, in order numbered, blockers first, each with the exact check to run
## Deferred bugs (GitHub issues) #5 … #14, one line each, release blockers marked
## How to work team pattern, "every UI change verified on emulator",
review script, "verify the staged set alone before committing"
How do you stop an agent from doing something dangerous?
With layered guardrails: put the rule in prose (CLAUDE.md), enforce it with a permission deny rule, and back that with a deterministic hook. Each layer has holes, but the holes are in different places.
Figure: Each layer stops something the one before it misses. Prose is ignored under pressure; deny rules miss commands you allow-listed; a hook only sees the command text. Only an OS-level sandbox, configured to deny those paths, stops a program that opens the file itself, and only for commands that run inside it.
The most important sentence in Votery’s CLAUDE.md is about what to do when a guard fires:
Both are enforced by
.claude/hooks/guard.py(PreToolUse) + deny rules in.claude/settings.json. Best effort, not a sandbox. A block is a signal to stop and ask, never to find a workaround.
Without it, an agent that hits a block treats it as a puzzle and tries another route.
That rule only works if the guard explains itself. A hook that reads command text will sometimes
fire on text that merely mentions a secret path, like a grep pattern or an echo. So the
template’s guard names the word and the rule it matched (`.env` is a secret file name) and
ends every block with the decision: if the named path is something the command really reads or
writes, stop and ask; if it only appears in text the command never opens, the block is
collateral, so rephrase the text. Never disguise a path to get past it. Literal commit messages
and PR bodies are recognised as data and not scanned at all.
Permission rules [Claude Code]
.claude/settings.json is checked in and shared with everyone who uses the repo. Allow-list
the safe, frequent commands (fewer prompts, less temptation to take shortcuts) and deny secrets
and whole-tree staging:
{
"permissions": {
"allow": [
"Bash(flutter analyze)",
"Bash(flutter analyze *)",
"Bash(flutter test)",
"Bash(flutter test *)",
"Bash(git status)",
"Bash(git diff)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(git ls-files *)"
],
"deny": [
"Read(./secret/**)",
"Read(./android/key.properties)",
"Read(**/*.jks)",
"Read(**/*.keystore)",
"Edit(./secret/**)",
"Edit(./android/key.properties)",
"Bash(git add -A *)",
"Bash(git add --all *)",
"Bash(git add -u *)",
"Bash(git add .)",
"Bash(git commit -a *)",
"Bash(git commit --all *)"
]
}
}
Facts from the docs worth building on:56
- Rules are evaluated deny → ask → allow, first match wins. An allow rule can’t carve an exception out of a deny rule.
denyandaskrules in a project file apply immediately;allowrules wait until you trust the folder.- “Bash permission patterns that try to constrain command arguments are fragile.” Read/Edit
deny rules also cover common shell file commands (
cat,head,sed, redirections), but not agrep -rfrom a parent directory or a script that opens the file itself. - Put a space before the wildcard.
Bash(git diff*)also matchesgit difftool …, which can run an external command;Bash(git diff *)doesn’t. Votery’s original rules had no space; the snippet above is corrected. - [Claude Code] The sandbox is the layer that stops a script from opening a secret itself, but only for commands that run inside it, and only for paths you tell it to deny.5 If you enable it, add the secret paths to its filesystem deny list and prove it: run a sandboxed command that tries to read one and check that it fails. Votery didn’t use it; the hook was enough for its risk.
- Path rules only apply through
Read(...)andEdit(...). AWrite(path)orGlob(path)rule is accepted but never consulted.
Votery walked straight into one of these holes. Two independent reviewers found that the secret
deny rule covered only the Read tool while git show* was allow-listed, so
git show HEAD:secret/… would have read the keystore straight out of history. That’s why the
hook exists, and why the template above no longer allow-lists git show.
The guard hook [Claude Code]
Hooks are deterministic: “Unlike CLAUDE.md instructions which are advisory, hooks are
deterministic and guarantee the action happens.”1 That guarantee holds as long
as the hook actually runs and returns a block (more on that in the design notes below). A PreToolUse hook
receives the tool name and input as JSON on stdin. Exit code 2 blocks the call and shows
stderr to the model.7 Register it by adding a top-level "hooks" key to
.claude/settings.json, next to "permissions":
"hooks": {
"PreToolUse": [{
"matcher": "Bash|Read|Edit|Write|MultiEdit|NotebookEdit|Grep|Glob",
"hooks": [{ "type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard.py\"" }]
}]
}
The core of the hook (an excerpt: the helper functions are in the full file linked below). It splits a command into segments, parses each one like a shell would (quotes, globs, braces, variables), and checks every path-like word:
def check_bash(cmd):
for body in substitutions(cmd): # $(...), `...`, <(...), bash -c '...'
check_bash(body)
for seg in segments(cmd): # split on ; & && || | outside quotes
calls = list(git_calls(seg)) # every `git` word, shlex-parsed
exempt = bool(calls) and "|" not in cmd
for sub, args, global_opts in calls:
if stages_whole_tree(sub, args): # add -A/./-u/-vA/":/", commit -a/-am
block("whole-tree staging. Stage explicit paths: git add <path>...")
if HISTORY_HAS_SECRETS and reads_history_content(sub, args): # log -p, show, diff <rev>
block("git history may contain secrets. Name non-secret paths after `--`.")
if touches_secret(" ".join(global_opts)) or not is_metadata_only(sub, args, seg):
exempt = False # only rm --cached, ls-files, commit -m "…"
if not exempt and touches_secret(seg): # any word whose path hits a secret
block("command touches a secret path. Secrets live in the password manager; ask the user.")
Design notes for the hook (read before you adapt it)
- Split compound commands before checking, including a single
&. Otherwisegit ls-files & cat secret/xslips past a check that only looks at the first command. - Check words, not substrings. The hook splits each segment on shell metacharacters (
:,>, quotes,$, parentheses) and checks each path component. That catchesgit show HEAD:.env>.env.exampleand a bare directory name likegrep -r . secret. - Allow metadata-only git commands (
rm --cached,ls-files,check-ignore), because untracking a secret means naming it. But never when the segment contains$(…), backticks or redirections, orgit commit -m "$(cat secret/token)"would pass as “just a message”. - Keep that exemption list tight. Votery’s first version exempted every
git commit. That leftgit commit secret/key.jks(a pathspec commit) andgit commit -F secret/notes.txt(which copies the file into the commit message, and so into history) open. The template only exemptsgit commit -m "a literal message", optionally with-qor-s. - Parse git like git does.
git add ".",git add -vAandgit -c key=value add -Aall stage everything. Regexes miss them; tokenizing the command and skipping git’s global options doesn’t. - History can leak too. If a secret was ever committed,
git log -porgit show <rev>prints it without naming its path. WithHISTORY_HAS_SECRETS = Truethose commands need an explicit-- <paths>. That exemption assumes the secret sat in a secret-named file. If it was pasted into an ordinary one (this repo’sCLAUDE.md), list that file inHISTORY_SECRET_PATHS, orgit log -p -- CLAUDE.mdprints the key again. Summary-only output (--stat,--name-only) shows no contents and stays allowed. - A hook only protects you if it runs. If
python3is missing, the script crashes or the hook times out, the call can go through. The template treats its own errors as a block, and after installing it you should prove the registration works: ask the agent to rungit add --dry-run -A(harmless even if it ran) and check that Claude Code reports the block. Unit tests ofguard.pyalone don’t show that. - Make the block message say what to do instead (“stage explicit paths”, “ask the user”).
- Most blocks are prose, not secrets. The hook checks Bash text, so text that merely
names a secret path trips it. I went through every block in my Claude Code transcripts
across two repos: 12 blocks in 6 sessions. 8 were prose: a heredoc writing a reviewer prompt or
a teammate brief, a
python3 - <<'EOF'edit script for a doc, agit commit -F -body that named the keystore type, an edit script that contained the textgit add .. Only 3 touched what they named (alsof the secret folder, a check that it was absent, the deliberategit add --dry-run -Atest). What helped every time: write prompts, briefs, commit messages and doc edits with the Write or Edit tool, which the hook checks by path, not by content; drop “is the secret absent?” checks altogether; never disguise the path. Say so in CLAUDE.md, or agents get stuck. - I tried exempting “data heredocs”, and took it out again. Since most blocks were prose in
heredocs, I taught the template that a quoted heredoc fed to
cat,teeorgit commit -F -is data, not code, and skipped its body. Each Codex review round then found a new way past it. Round one: a heredoc that writes a script which the same command then runs (cat > run.sh <<'EOF', thensh run.sh). Round two: a<<'EOF'inside a comment, which bash ignores but the guard didn’t, so the lines after it were skipped and ran. Round three: a shell function namedcat, a pipe in front of the sink, and a delimiter with a trailing space that bash doesn’t accept but the guard did. Every fix was correct, and every round found something else. The exemption is gone. Every heredoc body is checked again, and prose that names secret paths goes through the Write or Edit tool. The rows those reviews produced stay intest_guard.pyas cases that must keep blocking. The section below explains why I stopped patching. - Newline is a separator, a quoted newline is not.
git status, thengit add -Aon the next line, is two commands. A guard that letsshlextreat newlines as whitespace sees onegit statusand exempts it. Splitting on every raw newline instead cuts multi-line commit messages into fragments and blocks them. Parse quotes first, then split on unquoted newlines. - Match secret names by shape, not by spelling.
.envrc(direnv) and.env_prodhold the same secrets as.env.local; the template now matches all three. A baresecretin a sentence isn’t a path: match the directory as a path component, not as a word in prose. - Test it like code, and have someone else attack it. The skill ships
test_guard.pywith the cases above as a table of commands that must be blocked or allowed. Every bypass a reviewer finds becomes a new row. Prove each new row fails against the previous guard before you fix it. Run it from inside a git work tree; the history checks call git, and the script now refuses to run elsewhere instead of reporting seven false failures. Tests written by the same agent that wrote the guard share its blind spots: in video-downloader, 60 of them passed while a Codex review found three bypasses (newline chaining,.envrc,python3 -c) in its first read. - The hooks API also offers a JSON
permissionDecision(allow/deny/ask/defer) andupdatedInputfor rewriting a call instead of blocking it. - Other events useful as quality gates:
Stop(block the turn from ending until a check passes) andTaskCompleted/TeammateIdle(agent teams). Votery deliberately skipped aStophook runningflutter analyze: “too slow per turn; revisit as aTaskCompletedhook”. Measure the cost before adding a per-turn gate.
The full hook and its tests are templates/guard.py and templates/test_guard.py in the
skill. The first version of this template had several bypasses
(a command substitution in a commit message, a single &, git add -vA, git log -p), found
by the cross-model review described below. I kept that review
running for 15 rounds. Every round found new ones, each more obscure than the last
(g${X}it add -A, .en$(true)v, x+=v), and the test table grew to about 200 cases. That’s
the honest summary of a hook that reads command text: it catches the mistakes an agent makes by
accident, and a determined adversary will always find another trick. For anything that must
hold against that, use the sandbox.
Why a text guard keeps leaking, and what I’m trying instead
Experimental, field-tested in one repo: a small Python app, one PR, three review rounds. Each item below says whether I verified it (ran it and saw the result) or not. Treat it as a direction, not a finished recipe.
The hook’s two problems, blocking prose and missing tricks, have one root cause. The rule is
about effects: no process reads a secret file, and a committed secret never slips into main
unnoticed. The hook checks
command text and has to predict what bash will do with it. Bash has functions, aliases,
globs, eval, quoting rules and heredocs, so the prediction is really a second, partial bash
interpreter. Every match on text that bash never runs is a false block, and every exemption I
add to stop those blocks needs a proof that some text is never executed. Those proofs are where
review kept finding holes. Patching the parser treats symptoms.
So I’m moving each rule to the place where the effect happens:
| Rule | Text guard did | Now enforced by |
|---|---|---|
| A shell command never reads a secret | parse the command | Claude Code’s OS sandbox, sandbox.filesystem.denyRead. It applies to “all commands running inside the sandbox, including their child processes”8: scripts, globs, functions, files written earlier |
| The Read/Edit tools never open a secret | deny rules + hook | permission deny rules, plus a small hook that checks file-tool paths |
A committed secret gets caught before it reaches main |
parse git add -A |
a scan of the staged content (names and credential-shaped strings) in a pre-commit hook and in CI, with main protected by that check. This detects: the hook can be skipped and CI runs after the push, so blocking a push would take server-side push protection |
| Don’t stage everything, don’t skip the scan | parse git flags | a small nudge with no exemptions. It’s a workflow hint, not the security boundary |
Project settings (.claude/settings.json, checked in). All verified:
- Make the sandbox a boundary, not a suggestion:
"enabled": true,"allowUnsandboxedCommands": false(otherwise a failed command can be retried outside it) and"failIfUnavailable": true. Be clear what that last one means: Claude Code refuses to start on a machine withoutbubblewrapandsocat. Without it, a missing dependency silently means no sandbox at all. sandbox.filesystem.denyReadlists every secret name, at the root and at any depth:./.envand./**/.env, the same for.env.*,.envrc,secret,secretsand the SSH key names (id_rsa,id_ed25519,id_ecdsa,id_dsa),./**/*.pemand the other key-file extensions (.key,.p12,.pfx,.jks,.keystore,.kdbx), plus~/.sshand~/.aws.- Allow what your toolchain needs, and nothing else.
uv runfailed with “Read-only file system” until I addedsandbox.filesystem.allowWrite: ["~/.cache/uv"].network.allowedDomainsholds GitHub, PyPI,astral.shand whatever the app itself calls. - Permission deny rules for the file tools:
ReadandEditfor the same names, written as**/.envand so on. Also denyEdit(/.claude/settings.json),Edit(/.claude/hooks/**)andEdit(/.githooks/**). Those Edit denies also make the paths read-only for shell commands inside the sandbox:os.access(path, os.W_OK)returnedFalsethere. - Two path syntaxes in one file. In sandbox lists,
./means the project root when the setting lives in project settings, and~/means your home directory.8 In Read/Edit permission rules,./pathis relative to the current directory and/pathto the project,5 soRead(./secret/**)protects only when Claude starts at the repo root. Use/secret/**, or**/secret/**for any depth.
User settings (~/.claude/settings.json, can’t be checked in):
- Codex as the reviewer, inside the sandbox (verified):
allowWrite: ["~/.codex"], andallowedDomainschatgpt.com,*.chatgpt.com,auth.openai.comandapi.openai.com. Without the write entry, Codex fails before any network call, with “Read-only file system” when it creates its state directory. ghandgit push: from inside the sandbox both get HTTP 401 at first, because the login lives in the system keyring, which is out of reach. The documented way around it is credential masking:network.tlsTerminate, acredentials.envVarsentry forGH_TOKENwith"mode": "mask"andinjectHostsapi.github.comandgithub.com, and Claude Code started asexport GH_TOKEN=$(gh auth token) && claude.8 Masking is honoured only in user settings.ghworks through masking (verified):gh api, andgh pr view,edit,update-branch,checksandmerge. I merged three stacked PRs withghalone.git pushover HTTPS did not in my test, and neither didgit fetchorgit ls-remote: 401 “Invalid username or token”, even withgh auth git-credentialas git’s credential helper. My guess, which I haven’t verified, is that git sends the placeholder base64-encoded inside a Basic auth header, so the proxy doesn’t recognise it and never swaps in the real token.
So I push myself, or the agent sticks to
ghAPI calls.
Commits. scripts/check_secrets.py scans the staged content: full blobs, secret names
and credential-shaped strings. It reports file names, never content. It runs from
.githooks/pre-commit (git config core.hooksPath .githooks, once per clone) and in CI with
--all. CI runs on a push to every branch, and it also runs main’s copy of the scanner, so a
branch can’t quietly weaken the scanner that checks it.
The guard hook, now small. It checks file-tool paths (glob-aware, every path component,
symlinks resolved with realpath against the hook payload’s cwd) and nudges on git: bulk
staging, and skipping the pre-commit hook via --no-verify, -n, -c core.hooksPath,
GIT_CONFIG_* variables (also after env or sudo), or git config core.hooksPath pointing
elsewhere or --unset. It no longer parses shell commands for secrets.
Verify with a probe (verified). A probe script tries every bypass the text guard’s reviews
found, as real reads against fake secrets. I run setup and cleanup myself, outside the
sandbox; the agent runs check. Result: 14 of 15 blocked: cat, globs, bash -lc,
python -c, a heredoc fed to python, recursive grep, a script written and then run, a shell
function named cat, the secret/ folder, *.pem, nested secrets/ (two cases), a nested
.envrc and an SSH key name. The controls passed. The one leak is a file created and read in
the same command. On Linux, wildcard denyRead entries are expanded to concrete paths for each
command, so a file that doesn’t exist yet when the command starts isn’t covered. A file created
by an earlier command is blocked.
What I learned, beyond the recipe:
- Read deny rules merge into the sandbox’s
denyRead.8 They quietly fill gaps, but you can’t use a Read deny for a file a sandboxed tool needs: denying~/.codex/auth.jsonwould lock Codex out of its own login. A block like that has to go in the hook (file tools only). - Don’t rely on that merge for coverage. A review found my sandbox list had no nested entries; it only held because of the merge. List them in the sandbox block itself.
- The probe needs proof that setup ran. Inside the sandbox, a missing fake secret and a
hidden one look the same, so
checkpassed once without any setup. A sentinel file in.git/fixed that. Setup must also refuse to overwrite any existing file that isn’t its own fixture, not just.env. - Placeholder files show up in
git status, inside the sandbox only. Denied and protected paths appear as placeholder device files (.bashrc,.mcp.json, …). Never stage them. Reviewers can stop on them, so tell Codex to review tracked files only. - Keep the secret patterns identical in every place that lists them: the hook, the scanner,
the sandbox
denyReadand.gitignore. Mine drifted: the SSH key names were missing from both the sandbox and.gitignore. - The rest: saving the settings applies filesystem changes to the very next command, with no restart (masking needs one). A git worktree whose metadata lives in another repo can’t commit from inside. And a test that writes git objects skipped itself with a misleading message, so watch the skip count, not just the failures.
The limits, stated plainly:
- A branch can edit or delete the CI workflow that scans it.
mainis safe only because branch protection requires the CI check, and a secret on a pushed branch reaches GitHub before CI runs. Server-side push protection would be the next layer. I haven’t tried it. - The scanner matches patterns. A generic
password = valuein an ordinary file passes. - Codex’s own login stays readable. Signed in with a ChatGPT plan, Codex keeps its token in
~/.codex/auth.json, and sandboxed shell commands can read it (an API key instead bills per use at API rates).9 I accepted that risk. The hook blocks the file tools from it, and the agent’s rule is “never read or print it”. Codex can store the login in the system keyring instead (cli_auth_credentials_store = "keyring"), which I expect to be unreachable from inside the sandbox, likegh’s. Untested. - The pre-commit hook is opt-in per clone. Skipping it gets a nudge, not a block; CI is the backstop.
- Some things run outside the sandbox anyway: hooks, MCP servers and commands you type with
!.8
How the change itself went: the review loop worked as designed. Three rounds, each finding
checked against the code and answered on the PR as fixed in <sha> or dismissed: <reason>,
then the stop rule. One round-2 claim was false on inspection (“Bash can overwrite the hooks”;
they weren’t writable), but chasing it uncovered a real gap: a branch could weaken its own
scanner. With settings and hook files locked against the agent, every change to them went
through a small install script that I ran with !. One annoyance: a pasted ! command sometimes
arrived as chat text instead of running. Typing the ! by hand fixed it, and a tiny wrapper
script fixed long commands that wrapped onto two lines.
What I don’t know yet: how much friction the sandbox adds over a week of normal work, and whether
git push can work from inside at all. I’ll update this section either way.
How do you keep secrets away from the agent?
Rule: nothing secret in git, and the model never sees a secret. Secrets live in a password
manager. The repo only holds scripts that store and fetch them, and a human unlocks the vault.
Votery’s fetch script restores the release keystore before a release build. Below is a sketch
of its structure, not a runnable script: $repo is the repo root, and the download and
verification steps depend on your vault’s CLI. The parts worth copying:
{ set +x; } 2>/dev/null # never trace: would print the session token / passwords
set -euo pipefail
[ -n "${BW_SESSION:-}" ] || { echo "Set BW_SESSION first" >&2; exit 1; }
# Resolve the vault item by EXACT, case-sensitive name and require exactly one hit
# (the CLI's own search is fuzzy/case-insensitive → a duplicate item once matched).
# An explicit item-id env var overrides.
umask 077
mkdir -p "$repo/secret"; chmod 700 "$repo/secret"
# Download to a fresh temp file, verify, then atomically mv into place.
# Never delete the existing keystore before the download succeeded.
Every line there comes from a mistake:
set +x: a reviewer found that running the script withbash -xwould print the session token.- Temp file, then
mv: the agent’s first version deleted the old file first. - Verify the property that matters: after storing, the script runs
keytoolto prove the keystore opens with the stored password and alias. A byte-identical round-trip had passed while the pairing was wrong. Make the check fail whenkeytoolis missing, so a backup never counts as verified without it. - Exact-name lookup with a uniqueness check: the vault CLI’s search is case-insensitive, and a near-duplicate item existed.
- Test the restore right after the backup. That’s what surfaced both of the last two problems.
If a secret is already committed: (1) back it up to the vault and verify the backup
works; (2) only then git rm --cached it and add it to the root .gitignore; (3) decide
whether to rotate it, since it stays in history (Votery’s committed keystore turned out to be an
old, superseded key, which made that decision easy); (4) record it as a §B bug with an invariant
and a guard.
When the agent needs a script that touches secrets: Default: you run it. The agent writes and reviews the script; you execute it in your own terminal. The exception, if you choose it, is narrow: unlock the vault yourself, write the session token to a mode-600 file, and let the agent run one named script that reads it with tracing off and never prints it. The model never sees the token, but the process it starts can use it, so treat that as granting access, not hiding it. Afterwards lock the vault and delete the file. Never paste a token into chat, and never put vault identifiers or tokens in agent memory files.
Staging, branches and generated files
- Never
git add -A/./-u, nevergit commit -a. In a shared working tree (a lead plus teammates, or you plus an agent), whole-tree staging commits other people’s half-finished work. Votery enforces this three ways: CLAUDE.md, deny rules and the hook. - Branch before committing; never commit to
main. An agent’s work is disposable until someone has reviewed it. On a branch, a bad run costs onegit branch -D; onmainit costs a revert, and everyone who pulled in the meantime builds on it. A branch also keepsmainbuildable for the next session and gives parallel agents their own line of history. How the branch gets back intomainis the pull request. - Verify the staged set alone before committing mixed work:
git archive HEADplus thegit diff --cachedapplied in a scratch directory, then lint and test. That proves the commit builds without the unstaged changes lying around. - Commit messages carry the why. Bug fixes use
backprop §B.<n> + §V.<N>: <one-line cause>, with spec edit, test and fix in one commit. - Generated files and storage are contracts. “Don’t hand-edit
*.g.dart; never reuse or renumber a HivetypeId.” Storage keys, database schema ids and served URLs are contracts with installed apps or external systems: renaming one loses data or breaks a link, so add a migration instead.
Path-scoped rules [Claude Code]
Files in .claude/rules/*.md with paths: frontmatter load only when the agent reads matching
files, so area-specific rules cost nothing elsewhere.2 Votery has one per layer. Each
starts with a before/after checklist, then gives concrete rules:
---
paths:
- "lib/pages/**/*.dart"
- "lib/widgets/**/*.dart"
- "lib/router.dart"
---
# UI rules
> Before editing: read ARCHITECTURE "Game flow" + "UI conventions" and SPEC V.10.
> After editing: `flutter analyze` + `flutter test` green, then verify visually on the emulator
> (`tool/e2e/drive.py`, `docs/live/e2e_testing.md`): tap through, screenshot, look at the PNGs.
- Screen navigation only `context.go('/route')`. `Navigator.of(context).pop()` only to close dialogs.
- **No domain logic in widgets**: no tallying, scoring, or winner selection (SPEC T.2/T.3).
No `Random()` for anything outcome-relevant; cosmetic randomness (confetti) is fine.
- New code: colors from `Theme.of(context).colorScheme`, not raw `Colors.*` (legacy code is being
migrated in T.7 — don't add more debt).
- Files > 400 lines: don't grow them; extract sub-widgets (T.8).
TFT’s game-logic rule is the same idea for a Python engine: pure functions, no UI imports, seeded randomness. Two patterns worth copying: “don’t add more debt” rules for legacy code (tolerate the old violation, forbid new ones, point at the task that pays it down), and every rule cites the §V or §T it comes from.
What does “done” mean?
Put the definition in the entry file, numbered, and make every item checkable. Votery’s:
## Definition of done (every change)
1. `flutter analyze` clean **and** `flutter test` green — show the output, don't assert it.
2. Behavior change → test that fails without it. Bug fix → §B entry + §V guard (backprop skill).
3. LIVING docs updated in the same change (SPEC §T status, ARCHITECTURE, `docs/live/`).
4. Separate review pass on the diff before calling it done — green tests are not "done".
Cheap default: `tool/review/codex_review.sh <range> [paths]` (read-only);
verify each finding before acting.
5. UI change → verify in the running app on the emulator (`tool/e2e/`, see
`docs/live/e2e_testing.md`): tap through, take screenshots, look at them.
Why each line is there:
- “Show the output, don’t assert it.” Agents say “tests pass” without running them, or after running a subset. Anthropic: “Have Claude show evidence rather than asserting success.”1
- A test that fails without the change. A test that can’t fail proves nothing (remember
the
List ==test). Bugs get the backprop treatment. - Docs in the same change. Every LIVING doc that describes code the change touched (the rule above). Otherwise LIVING docs quietly become FROZEN ones that still claim to be current.
- Independent review. The model that wrote the code shouldn’t be the only one grading it.
- The running app. Tests pass in a simulated environment; your users run the real thing.
If an agent keeps “getting to green” by weakening checks, add numbers: write down the bar
(coverage, lint level, performance budget) and watch diffs for new suppressions
(// ignore:, @ts-ignore), skipped or deleted tests, removed assertions, and thresholds
edited down.
Which workflow skills do you need?
You need five: plan, spec, build, backprop and check, plus a journal habit. Each is a procedure the agent loads only when it needs it, which is what a skill is. [Claude Code] Unlike CLAUDE.md, a skill’s body loads only when it’s used.10 Other tools have their own equivalent (or you keep the procedures as Markdown files the entry file points to). Anything that’s a multi-step procedure rather than a fact belongs in a skill. Both projects use these five (some kept at user level rather than in the repo):
| Skill | Writes | Job |
|---|---|---|
| plan | only docs/design/tasks/tN_*_plan.md |
Required reading → verify every symbol against the code → ask real design forks first → plan ending in exact “SPEC changes needed” |
| spec | only SPEC.md |
The one writer. Modes: new, distil (code → spec), amend, backprop (bug: …). Shows the diff, applies on OK |
| build | code + tests; flips §T status | Implements one task. On test/build failure → backprop, never a blind retry |
| backprop | §B (+ §V, test) | Bug → trace → which invariant should have caught it (existing, or a new one)? → failing test → fix → one commit |
| check | nothing (read-only) | Drift audit: LIVING docs vs code, reports by severity, names the remedy, never applies it |
| journal | docs/journal/YYYY-MM-DD_*.md |
After milestones; Process notes mandatory |
TFT’s versions are public: plan, spec, build, check. Generic versions of all five are in the prep-repo skill.
Figure: One task, one loop. A failure during build is classified first: a plain code bug gets fixed; a wrong spec or an unspecified edge case goes through backprop, so the spec learns and a test catches the same mistake if a later agent repeats it.
Required reading, before editing code for a task (from both CLAUDE.md files):
The §T row + every §V that could apply → the ARCHITECTURE section → the touched code itself (cite
file:line). Docs lie; code wins.
The plan skill turns that into a VERIFY step:
- Grep every class/method/field/provider before citing it; confirm real name + signature.
- Duplicated logic: find **all** copies (e.g. tally exists in 2 files) — the plan must cover each.
- Randomness: any outcome-relevant RNG must be injectable (`Random` param); tests use a seed.
- Storage: touching Hive/SharedPreferences keys or typeIds → migration section required.
and an ASK step, which is where you stay in the loop cheaply:
`AskUserQuestion` only for genuine forks (scope cut, UX behavior, data migration). Investigate
first (code, `git log -S`), then ask with a recommendation + the concrete trade-off.
Rote choices: decide, flag in Open Questions, move on.
What is backprop?
It’s the one thing spec-driven development does that plain plan-then-execute doesn’t:
Plan-then-execute fixes the code and forgets. Spec-driven development fixes the code and edits the spec, adding a rule and a test, so the same mistake gets caught if it comes back. That edit is backprop.
When a test or build fails, the build skill classifies the failure: (a) my code bug → fix it;
(b) the spec is wrong; (c) an edge case the spec didn’t cover. (b) and (c) go through
spec bug: … first. A real Votery example: bug B.4 (tally overflow on small phones) produced
V.17, “No RenderFlex overflow at 360×640 with text scale 1.3”, guarded by a layout test.
Later, bug B.6 (a button label overflowing) was filed against the same V.17 instead of becoming
another one-off fix.
What does the check skill do?
It audits the LIVING docs against the code and reports what no longer matches, without changing any file. It starts mechanical, then gets semantic:
grep -rhoE '`[A-Za-z0-9_./-]+\.dart(:[0-9]+)?`' SPEC.md ARCHITECTURE.md CLAUDE.md docs/live .claude/rules \
| tr -d '`' | sort -u
Every cited path must exist. path:NN must be within ±15 lines of the named symbol (skew is a
note; missing is broken). Claims with “only”, “sole” or “never” are checked by grep first, then by reading the call paths;
where the claim is about behavior and no test covers it, the report says so, and writing that
test becomes a follow-up task. Counts
get recomputed. Each §V guard test must exist, and must test the production code. Output is one
line per finding, <doc>:<line> <emoji> <problem>. → <remedy>, ending with one suggested next
action that the skill never performs itself.
What if the agent keeps failing at setup?
Put a “stuck twice” rule in CLAUDE.md:
After the second failed attempt at the same setup problem (tooling, emulator, build, plugin, SDK, CI, platform API), stop guessing: research the live official docs. Cite the source URL in the commit or report.
Platform requirements change faster than any model’s training data. Anthropic’s version of the
same idea: “After two failed corrections, /clear and write a better initial prompt
incorporating what you learned.”1
What tests do you need before agents refactor anything?
Make the code testable and pin its current behavior first. Votery’s order was deliberate: “pin current behavior with characterization tests (T.1) → extract pure domain layer (T.2/T.3) proven against the oracle → only then UI cleanup (T.7/T.8).”
- Characterization tests first. The brief given to the test-writing agent: “Characterization tests assert what the code does, not what it should do; oddities are recorded in §D rather than ‘fixed’ in the test.” This paid off at once: it disproved the “symmetric” rule the lead had written, and the README showed the asymmetry was intended.
- A reference oracle. The tally logic was copied verbatim from the widget into
test/support/reference_tally.dart, and the new purecomputeTallywas fuzzed against it (run on thousands of random inputs, with every result compared to the oracle’s). A reviewer then noticed that before the extraction, the “guard” only tested the oracle copy, not the production code. Always check what a guard actually exercises. - Pure domain logic, out of the UI. “Pure” means plain functions with no screen, storage or
network code, so a test can call them directly.
lib/domain/(tally, odds, draw) has no Flutter imports. Widgets only present results. The duplicated algorithms became one each. - Injectable randomness and clocks. Pass the random-number generator (and the clock) in as a
parameter instead of creating it inside the function, so a test can hand in a seeded one and get
the same result every run:
drawWinner(nominees, Random). The winner is drawn once in the controller, and the wheel animation is presentation only, landing on the pre-drawn winner. A real bug came out of this: the wheel reported whichever sector was under the pointer after a rough hit-test, which wasn’t always the drawn winner. Do the same for time. - CI gates on every push and PR, toolchain pinned: install → lint → format check → tests.
Votery formats only
test/in CI because “lib/has never been formatted and a mass reformat would bury the T.2/T.3 diffs.” Tighten gates in steps. - Test layout mirrors the source layout, so agents find, and add, the right test file without being told.
Who reviews the agent’s work?
Someone other than the agent that wrote the code. At minimum that’s a fresh session, which isn’t biased toward code it just wrote: “A fresh context improves code review since Claude won’t be biased toward code it just wrote.”1 In both my projects I went one step further, with a model from a different family. I can’t prove that’s necessary, but on Votery the two families caught different bugs (below). The cheap default is a read-only Codex script:
#!/usr/bin/env bash
# Usage: tool/review/codex_review.sh <git-range> [path ...]
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
range="${1:?usage: codex_review.sh <git-range> [path ...]}"; shift || true
if [ "$#" -eq 0 ]; then set -- .; fi
paths=$(printf '%q ' "$@") # keep argument boundaries (paths with spaces)
qrange=$(printf '%q' "$range")
out="tool/review/out/$(date +%Y%m%d-%H%M%S).md"
prompt="You are a strict code reviewer for a Flutter app. Review ONLY the diff of
\`git diff ${qrange} -- ${paths}\` (run it yourself; read touched files for context).
Contract: SPEC.md §V invariants and CLAUDE.md hard rules (…).
Report correctness bugs, invariant violations, resource leaks (timers/controllers not
disposed), and tests that pass vacuously. Verify each claim against Dart semantics before
reporting (e.g. Dart's % is non-negative for positive divisors). No style nits, no praise.
Format: one line per finding: path:line: [high|med|low] problem. fix.
End with 'totals: N high, N med, N low' or 'no findings'."
mkdir -p tool/review/out
codex exec -m "${CODEX_MODEL:?set CODEX_MODEL}" -s read-only "$prompt" | tee "$out"
It needs the Codex CLI installed and signed in. Run it as
CODEX_MODEL=<model-id> tool/review/codex_review.sh main..HEAD, with the model id you use.
Review a task before it lands, while it’s still uncommitted: update the docs, stage the
task’s files by explicit path (git add -- <files>, new files included), check that
git diff --cached --stat shows exactly the task, then run the review script with --cached as
the range. In a shared working tree, have the reviewer read files from the staged snapshot (the
scratch checkout used to verify it), not the working directory, which may hold someone else’s
unfinished changes. If a finding leads to a fix, re-stage and review again. After committing, main..HEAD
reviews the whole branch. This local pass is the first layer. The second is the pull request,
below.
What to copy: a read-only sandbox; point the reviewer at the contract (§V, hard
rules); ask explicitly for vacuous tests and leaks; pre-empt known false positives (the
Dart % line exists because a reviewer once flagged it wrongly); a fixed one-line output so
findings are easy to triage; and every review saved to a file.
What two reviewers (Claude and Codex, both read-only) found on Votery’s setup commit:
Both found
The gitignore claim that never landed; the deny rule covering Read but not git show; a false
“mode 600” claim in the fetch script.
Only Codex
The always-green shuffle test; a font package fetching fonts over the network in an app that
promises “no network”; the bash -x token leak; a lax properties parser.
Only Claude
The guard test that only exercised the oracle copy.
And two of the reviewers’ claims were simply wrong (a line number, a citation).
The journal’s conclusion: “Two reviewers from different model families overlapped on the severe findings and diverged on the subtle ones — cheap insurance worth keeping for security-relevant changes.” And reviewers need verifying too: the rule is “verify each finding before acting”, not “apply all findings”. Anthropic warns about the opposite failure as well: “A reviewer prompted to find gaps will usually report some, even when the work is sound … Tell the reviewer to flag only gaps that affect correctness or the stated requirements.”1
Where should the reviewer run?
Somewhere without secrets. Votery ran reviewers in a sparse git worktree that left out
secret/. That isn’t a full guarantee: a worktree shares the repository’s history, so a secret
that was ever committed can still be read with git show <old-rev>:<path>. For a reviewer you
don’t trust, use a fresh clone with the secret purged from history, or an export of only the
files under review (git archive HEAD -- <paths>) plus the change as a patch file, in a
read-only sandbox. Build the patch with every secret path excluded
(git diff main..HEAD -- <paths> ':!secret'): a diff that deletes a secret still contains it.
Scan the patch before handing it over, and point the reviewer at it instead of asking it to run
git diff.
What do reviewers catch over a whole project?
On the 1.1.0 release, the Codex review “earned its place every round”, in the journal’s words. It found that a dialog left an unfinished game saved, so Home offered to resume straight back into the same dead end, and that a test fixture used a voter index the app can never produce. One finding didn’t apply and was dismissed after checking the code.
That’s two data points. TFT had GitHub Copilot review 49 pull requests, which left 187 inline comments (counted across all its pull requests; one more review attempt failed). My replies on those PRs almost always accepted every finding: “all legit, zero false positives”, “all were legitimate (no hallucinations) but low/trivial severity.” The one real disagreement went the other way round: Copilot said a test was stricter than the spec, and it was the spec that was wrong, so the spec changed. The findings fall into four groups, and three of them aren’t bugs in the usual sense:
Text that describes something else
A docstring saying the Spirit-Gem branch was “not yet implemented”, directly above its
implementation. A README promising “live weather drives combat” after the design decoupled them.
A PR description stating an enemy ability scales INT×1.5 while the code computed INT×2.4×0.7.
Guards that don’t guard
A drift test that only checked a function’s name appeared in the source (an import or comment
passed it). A test claiming six cases and asserting three. replay.winner == replay.winner for
two of three outcomes. A spec line claiming boss ids were CI-guarded when the test skipped bosses.
A spec that contradicts itself
V.70 still saying a node’s weather is its default, after V.73 made it the live value. V.82 citing a guard test file that doesn’t exist (the test landed under another name).
Ordinary bugs
An IndexError on recipes made from two identical components. A simulator that let you craft
items you didn’t own. A reward screen showing “✓ Recruited” when the recruit had failed.
None of this is exotic, and the tests wouldn’t have caught most of it: tests don’t read docstrings or PR descriptions, and in the second group the tests were the problem. A second model reading the diff against the code and the spec catches exactly the class of error the writing agent can’t see in its own work.
How does a change get from a branch into main?
Through a pull request, every time. The loop has five parts:
- Every change happens on a feature branch, never directly on
main. - The finished work is committed and opened as a pull request. The description names the §T row and shows the checks that were run.
- Reviewers review the PR: the other model family, Copilot, and you. Their findings arrive as PR comments.
- Fixes are pushed as new commits to the same branch, so they land in the same PR. Every
review comment gets a reply: fixed in
<commit>, or dismissed with the reason, after the finding was checked against the code. - The PR is merged only when the merge conditions hold: CI green on a branch that is up
to date with
main, every comment answered, and the reviews you require done.
Why this matters more with agents than with people:
mainstays buildable. The next session, and every other agent, starts frommain. A half-finished run that lands there breaks all of them.- An agent’s work is disposable until it’s reviewed. A branch you close costs nothing.
- The PR is the one place where everything about a change is kept together: the diff, the findings, the fixes and the decisions. Session transcripts don’t survive; the PR does.
- Reviewers get a clean diff. A PR against
maincontains exactly the task, not someone else’s unstaged work from a shared tree. - Parallel agents don’t collide. Separate sessions each work on their own branch (in their
own worktree) and meet only at merge time. If
mainmoved in the meantime, the branch is updated and CI runs again before the merge. A team in one shared tree is one branch, and only the lead commits.
The local pass from above stays. It’s the cheap first layer: it catches problems before anything is pushed, and it’s where Votery’s review actually happened. The PR is the gate and the record. Keep one PR per task. Votery’s agent-era work went in as two PRs: the setup branch (the setup commit, its review-fix commit and five follow-ups) and the 1.1.0 release (81 commits across 199 files). Neither PR has a single review comment. The reviews did happen, locally and in the journal, but at that size the PR was only a merge button. Its description lists what changed, but no comment on it records a finding or how it was resolved.
TFT used PRs as the record. A repository rule asked Copilot to review every pull request, which is where the 49 reviewed PRs and 187 inline comments above come from (all of TFT’s PRs). My replies there were mostly one summary per PR (“all legit, zero false positives”), not one reply per comment. Answer each comment instead. A per-comment reply shows which finding was fixed in which commit and why a dismissed one was wrong, so anyone reading the PR later can trace what happened to every finding.
Merge strategy is a detail. TFT squash-merges, so main gets one commit per PR; that fits when
one PR is one task.
When is the review loop done?
Everything above is one agent loop seen from the outside: the agent acts (writes code), observes the result (runs the checks, reads the review) and decides whether it’s done. That’s the shape the ReAct paper described for language models that interleave reasoning with actions,11 and Anthropic’s “evaluator-optimizer” pattern is the review half of it: one model produces, another evaluates, and the producer revises until the evaluation passes. Anthropic adds the part that’s easy to forget: “It’s also common to include stopping conditions (such as a maximum number of iterations) to maintain control.”12 A loop without a written “done” and a written stop doesn’t end when the work is good. It ends when the agent decides it’s good.
So write both down before the loop starts. In this setup “done” comes in four kinds, and the repo already has a check for each:
| Kind of check | What answers it | Example |
|---|---|---|
| Machine, yes/no | lint, the full test suite, CI on the PR | “pytest green, CI test passed” |
| Look at it | the running app, screenshots the agent opens and reads | the e2e doc in docs/live/ |
| Judgment against a rubric | a reviewer from another model family, with SPEC §V and the hard rules as its rubric | the review script’s findings |
| Only you | anything irreversible or outward-facing | merge, publish, delete, change repo settings |
The review loop on a PR, with its exit:
- Open the PR with the checks you ran.
- Run the reviewer on the whole PR diff and post its report on the PR (
gh pr comment <n> --body-file <report>), so the findings live where the change lives. - Check every finding against the code. Fix the real ones as new commits; reply “fixed in
<sha>” or “dismissed:<reason>”. - Run the reviewer again on the updated diff. Fixes introduce bugs too.
- Done when one full round has no high or medium findings and CI is green. Low findings become issues. Stop after three rounds even if it isn’t done: list what’s still open and hand it to a human. Merging stays with you either way.
Step 4 is the one I skipped before writing this section, and it’s the one that paid off. Preparing a small app with this guide, I added a rule to the guard (the “data heredoc” rule in the hook section). The first review found a hole in it: a heredoc that writes a script the same command then runs. I fixed that and reviewed again. The second round found a different hole in the same rule that the first round had missed: a heredoc operator inside a comment, which bash ignores but the guard didn’t. The third round found three more. That was the stop condition, so the loop handed the open findings to me, and instead of a fourth patch I removed the rule (see “Why a text guard keeps leaking”). In the same session, a Codex review found three ways past that app’s own guard, whose 60 tests all passed. “Every comment answered”, the merge rule above, would have let the first fix through: its one comment was answered.
Where I’m not sure yet:
- Three rounds is a guess, not a number I measured. The guard review in the hook section ran 15 rounds and kept finding things, so for a parser three is too few to reach “clean”; for a docs change it’s probably too many. I haven’t collected enough loops to pick better numbers.
- “No high or medium” trusts the reviewer’s severity labels. I haven’t checked how often those labels are wrong. Skim the lows before you file them as issues.
- Re-reviewing the whole diff costs more than reviewing only the fix commits. I do the whole diff because a fix can break something outside the lines it touched, but I haven’t compared the two approaches on cost.
- A stop condition is also a design signal. When the same piece of code fails three rounds in a row, the loop isn’t converging, and that’s worth noticing on its own. The “data heredoc” rule did exactly that. I dropped it, and the question shifted from “how do I patch this parser?” to “why does the guard parse shell at all?” I can’t yet say how often hitting the limit points to a design problem rather than just a hard bug.
Not every task needs this. A one-line typo fix gets the checks and one review, not a loop. The loop is worth it when the change is security-relevant, or when the first review found something real.
What will only you catch?
A reviewer checks the code against whatever context it’s given, usually the code and the spec. It doesn’t know what you meant unless that’s written down somewhere it reads. In TFT, the misses that mattered most were caught by me, reading results and plans rather than diffs:
- The plan said one thing, the build did another. The balance plan said not every mage needs to deal damage, and support values like healing should scale with intelligence instead. The implementation made every mage deal damage. Nothing in the diff was inconsistent; the code matched itself, just not the plan.
- A silent failure. Weather fetching failed without telling anyone. Nothing was wrong in the diff; the design just had no error flag, so a broken feed would have looked like normal weather.
- Measuring the wrong thing. A simulation meant to show weather’s effect measured win rate only. Both teams had the same tiers, but each team’s highest-tier pieces decided most fights, so a plain win/loss would have drowned out the weather signal. I caught it before the expensive run; the fix was to measure the margin of victory instead.
A reviewer given the plan might have caught the first one. That’s the argument for the ASK step in the plan skill, for handing reviewers the plan as well as the diff, and for reading results yourself: another model’s review catches a lot, but checking the work against what you intended stays your job.
How does the agent check the running app?
By driving it, taking screenshots and reading them. The standing instruction in Votery, saved as agent memory: “Verify changes in the running app: launch it, tap the buttons, take screenshots and actually look at them … Green tests alone is not ‘done’.” The reason: “widget tests missed real-device behavior (system back, lifecycle/keyboard, fonts offline, edge-to-edge).”
To make that routine, Votery has a small harness an agent can drive one command at a time:
tool/e2e/emulator.sh up # create the emulator if missing, boot headless, disable animations (~25 s)
tool/e2e/install.sh profile # build + install; never a release build (signing is off-limits)
tool/e2e/drive.py begin my_check # lease the emulator + create a run dir
tool/e2e/drive.py launch # cold start; prints startup ms
tool/e2e/drive.py dump # visible labels + bounds
tool/e2e/drive.py tap "Start New Game" # tap BY TEXT; on failure lists the visible labels
tool/e2e/drive.py screenshot setup # NN_setup.png + NN_setup.txt (label dump)
tool/e2e/drive.py end
Other commands: back, home, resume, type, wait / expect / expect-not (assertions),
keyboard shown|hidden, insets (edge-to-edge clipping), net off|on (airplane mode, checked by
ping), logcat, startup [n] (median cold start), and run <flow>. These are Votery’s own
scripts; the point is the shape, not the names. Build the equivalent for your platform.
Why it works for agents:
- Tap by text, not coordinates. Android’s UI dump turns on accessibility, so Flutter exports its semantics tree. No app changes needed. When a tap fails, the agent gets the list of visible labels and can correct itself.
- Every screenshot comes with a text dump. The agent reasons over text and still opens the PNG to check what it looks like.
- Flows are plain text files, one command per line (
smoke_full_game,nav_back,offline_fonts). On the first failure the harness savesNN_FAIL.pngand the device log. - A lock and a lease, so a second agent gets “emulator leased by run …” instead of tapping into someone else’s test.
- “Rebuild after every code change” is written down. The harness drives whatever build is
installed. When the shared working tree doesn’t compile (another agent is mid-change),
E2E_SRC=<dir>builds a clean worktree ofHEADinstead. - Measure, don’t guess. The startup task was closed with “profile cold start median 554 ms”.
It caught what tests didn’t: the back-button loop, the dialog pulled away by an animation, some
layout issues. It also confirmed bundled fonts render with the network off. For web projects the
equivalent is Playwright or a browser-automation MCP (e.g. Chrome DevTools). TFT checks its game logic
end to end with a headless playtest CLI that simulates fights and whole runs
(uv run python -m tools.playtest.sim_fight), though that doesn’t cover the UI itself.
How much should you give one agent at once?
One task. If you have several, run them as a loop that hands the agent one task at a time, each with a fresh context, instead of one long session that does them all.
The reason is the context window. Every file the agent reads, every test run and every failed
attempt stays in it. On a second and third task, the window fills with material from the first;
long sessions get compacted, and the early instructions are the first to blur. What you see is
an agent that gets worse as the session goes on: it carries assumptions from one task into the
next, “fixes” things outside its scope, and forgets the rules it followed an hour ago. Anthropic’s
advice points the same way: /clear between unrelated tasks, and after two failed corrections,
start over with a better prompt.1
A loop gives each task a clean start and keeps the state in files, not in the conversation:
Several tasks: loop, one at a time:
For each §T row in order (T.12, T.13, T.14):
1. Start a fresh session (or a new subagent) for this one row only.
2. Run the per-task loop for it: read → plan → build → verify → land.
3. Land means: SPEC §T status, LIVING docs and the commit are updated and
the PR is merged, so the next row starts from main and the files alone.
4. Stop the loop at the first row that isn't done, and report why.
TFT’s plan skill writes one plan per run, for one §T row. Its build skill commits after every
row but also has an --all mode that builds every open row in one session; that’s the mode this
rule says to avoid, and the build skill in the prep-repo templates
only takes one row. Votery’s release split the work across four teammates, each with one area and
its own files, rather than giving one agent the whole release. The per-task loop below is the inside of
each iteration.
When should you use several agents at once?
Start with research and review, not parallel implementation. Anthropic recommends the same: “start with tasks that have clear boundaries and don’t require writing code”, and “Start with 3-5 teammates.”13
Votery’s setup run: three read-only researchers (core logic, UI, docs) in parallel with a hard word cap, then two writers with separate file ownership (tests vs docs) while the lead wrote the spec. No file conflicts. The lesson, from the journal:
Research-first with read-only teammates and a hard word cap made the reports cheap to verify. Every report still had at least one wrong claim, so “verify the report before building on it” has to stay an explicit lead step.
The teammate brief that worked:
Prepend to every teammate’s spawn prompt:
You own <paths>. Don't edit anything else.
No git write commands; only the lead commits.
No SPEC.md edits. End your report with two sections:
"SPEC changes needed" and "Found, not fixed".
Cite file:line for every claim. Max <N> words.
Task context: <everything the teammate needs; it does not see the lead's chat>.
[Claude Code] Teammates don’t inherit the lead’s conversation, so the spawn prompt must carry the task context.13 They do load CLAUDE.md, rules and skills, which is one more reason to keep project knowledge in files. Anything the agents share (an emulator, a dev database, a port) needs a lock or lease. For parallel sessions without a team, git worktrees give each one its own checkout.
Agent-team launch gotchas [Claude Code], verified on v2.1.282
- Agent teams are experimental: set
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1;teammateModetmuxorautofor split panes. - Start the lead with plain
claudeinside tmux, so teammates split the lead’s window. A lead started withclaude --resume …or--bgruns under the background daemon, which drops$TMUX; teammates then land on a detachedclaude-swarm-12345-style tmux server (named after a process id). Attach to it from a separate terminal tab (nesting it inside your own tmux collides with a custom prefix key). - From the docs:
/resumedoesn’t restore in-process teammates; task status can lag; one team per session; no nested teams.13 - Teammate permission prompts bubble up to the lead, so pre-approve safe commands before spawning.
- Fire-and-forget workers (a reviewer in a
tmux split-window … claudepane) are independent sessions with no shared task list. Use them only for side jobs like a read-only review.
How does work survive the end of a session?
Sessions end, contexts get compacted, agents restart cold. Continuity has to live in files:
| Mechanism | What goes there | Votery example |
|---|---|---|
| SPEC §T / §B / §D | Status of every task, bug and open question | Task T.14 went from 🔶 “back button loops → GH #7” to ✅ “GH #7 → B.9 fixed” |
| Release HANDOFF.md | Done, open (ordered, with exact checks), deferred bugs, how to work | “Read this first when resuming.” |
| Journal | Why things are the way they are; agent errors not to repeat | 2026-09-25_agentic-setup.md |
| Agent memory | Your preferences and corrections that aren’t derivable from code | “verify in real app”, “secrets in password manager” |
| GitHub issues | Bugs found but not fixed this session | #5–#14 filed at a checkpoint; later fixed as §B entries |
Deferred bugs go to GitHub issues, not handoff notes. The rule, saved as a memory: “When a
bug is identified but not fixed in the current session, file a GitHub issue
(gh issue create --label bug) with the symptom, cause (if known), fix idea, and a guard or test
suggestion.” And at every checkpoint, sweep teammate reports for “found, not fixed” items.
Handoff notes get lost; issues show up where people look.
Memory hygiene [Claude Code]. Auto memory stores four types (user, feedback, project,
reference) and loads the first 200 lines / 25 KB of MEMORY.md each session.2 Good
memories record the why and how to apply:
---
name: verify-in-real-app
type: feedback
---
Verify changes in the running app: launch it (Android emulator), tap the buttons, take screenshots
and actually look at them … Green `flutter test` alone is not "done".
**Why:** widget tests missed real-device behavior (system back, lifecycle/keyboard, fonts offline, edge-to-edge).
**How to apply:** use the e2e harness in `tool/e2e/` … Attach screenshot evidence when reporting UI changes.
Keep secrets out of memory files: they’re plain Markdown on disk. Make checkpoint commits at
natural pauses so the next session starts from a consistent SPEC, and /clear between
unrelated tasks. A fresh session with a good handoff beats a long one full of failed attempts.
Step by step: preparing a repo
This is the order Votery used, generalised. Steps 1–9 fit on one chore/agentic-setup branch.
Votery did them as one commit with no application-code changes, followed by a review-fix
commit. The prep-repo skill runs the same phases.
- Interview, branch, baseline. Before writing anything, settle: which agent tools will
work here (→ CLAUDE.md or AGENTS.md), where secrets live, how “done” is checked in the real
app, which second model can review, which files must never move. Then
git switch -c chore/agentic-setupand record: lint clean? How many tests, passing? Does it build? Is there CI? - Secrets sweep first. Search file names only:
git ls-filesandgit log --all --name-only --format=for keystores,.env, key files. For contents, use a scanner that redacts matches (gitleaks detect --redact) and let a human inspect hits; an agent grepping file contents across history can print a live secret into its transcript. For anything found: back it up and verify the backup, then untrack and gitignore it, decide on rotation, record a §B bug. Write store/fetch scripts (no tracing, temp-then-move, verify the pairing, exact-name lookup). - Research with read-only agents: core logic, UI, docs/build, with a word cap. Verify every claim by grep before using it.
- Distil SPEC.md from the code (+ FORMAT.md). §C includes privacy/network and secrets. Every
§V has
Guard:orunguarded. Any rule about behavior you haven’t pinned with a test yet is provisional: mark it?, and let step 5’s tests confirm or correct it. §T starts with T.0 (the app as it is), then tests, extractions, debt. Surprises go to §D. - Characterization tests + CI. Assert actual behavior; record surprises, don’t fix them. Add a reference oracle for logic you’ll extract. CI runs lint + tests (+ format where it won’t bury diffs), toolchain pinned.
- ARCHITECTURE.md, written from the code: each system (what it does, where it lives, what it talks to), the patterns in use with an example of each, cross-cutting state, storage and randomness, a “Where do I find X?” index and one end-to-end walkthrough. Name known violations and link them to §T rows.
- Split the docs.
git mvold docs intodocs/design/(FROZEN) anddocs/journal/legacy/; createdocs/live/with its README and header convention; mark served files “never move”. - Agent config. CLAUDE.md (or AGENTS.md + a thin CLAUDE.md) under ~100 lines. For Claude
Code:
.claude/settings.jsonallow/deny;.claude/hooks/guard.py, tested with sample inputs;.claude/rules/<layer>.md; skills plan/spec/build/backprop/check;docs/templates/. - Review the setup with two model families, read-only, somewhere without secrets. Then push
the branch and open it as a pull request, so later findings land there as comments. Verify each
finding, fix in a follow-up commit on the same branch, reply to each comment, write the first
journal entry. Before merging, protect
main: require a pull request, require the CI check to pass on a branch that is up to date withmain, block force-push and deletion. From then on nothing reachesmainexcept through a reviewed PR. TFT’s repository rule blocked force-push and deletion and asked Copilot to review every PR, but it didn’t require a PR, and its latest documentation commits went straight tomain. - Tooling for “done”: the review script and a real-app driver (emulator harness, Playwright,
CLI smoke script). Document it in
docs/live/. - Pay down the risky structure first: extract pure logic behind the oracle, inject randomness and clocks, remove dead code. Only then UI and features.
- Set up continuity: a handoff doc per release, memory entries for standing preferences, a
buglabel for deferred issues.
The per-task loop
Once the repo is prepared, every task (one §T row, or one bug) runs the same loop. Paste it into a prompt, or let the plan and build skills carry it:
Per-task loop:
TASK: §T.<n> — <title>
0. Setup
- Fresh branch from an up-to-date main (never main, never a previous task's
branch). Shared tree → stage explicit paths only.
1. Required reading (cite file:line; docs lie, code wins)
- SPEC §T.<n> row, every §V that could apply, nearby §B, related §D
- ARCHITECTURE section for the touched layer
- docs/live/<area>.md
- the touched code itself (all copies of any duplicated logic)
2. Plan (plan skill → docs/design/tasks/t<n>_<topic>_plan.md)
- Gap today table (piece | file:line | ✅/🔶/❌)
- Ask only genuine forks, with a recommendation + trade-off
- Machine-checkable acceptance criteria (commands + expected output)
- "SPEC changes needed" → apply via spec skill on OK
3. Build (build skill)
- Failing test first (named after the §V it guards)
- Implement; no domain logic in UI; injectable randomness/clock
- Failure → backprop: my bug / spec wrong / unspecified edge case?
(b)/(c) → §B entry, link the §V that should catch it (existing or new),
add or strengthen its guard test, then continue
- Stuck twice on setup/tooling → read official docs, cite URL
4. Verify (show output, don't assert)
- lint clean + full test suite green (paste the tails)
- UI touched → run the real app: click/tap through, screenshots, READ them
5. Land
- Same change first: SPEC §T status, ARCHITECTURE, docs/live/
- Stage the task's files by explicit path; `git diff --cached --stat` shows only this task
- Shared tree → verify the staged snapshot alone (git archive HEAD + apply the cached diff
in a scratch dir, then lint + test)
- First-pass review of the staged diff (review script with --cached, other model,
read-only); verify each finding. Any fix → re-stage, re-test, re-review
- Commit exactly what was reviewed; message says why (backprop §B.x + §V.y: … for bug fixes)
- Push the branch; open a PR: §T row, what changed, checks run (paste the tails)
- PR review: other model family, Copilot, you; findings arrive as PR comments
- Post the reviewer's report on the PR (gh pr comment <n> --body-file <report>)
- Each comment: verify against the code → fix as a NEW commit on the same branch,
push, reply "fixed in <sha>"; or reply "dismissed: <reason>"
- Re-review the whole PR diff after fixes. Done: one round with no high/med findings
and CI green (lows → issues). Stop after 3 rounds: list what's open, hand to the human
- Merge only when: branch up to date with main, CI green on it, every comment
answered, required reviews done
- Found-not-fixed bugs → gh issue create --label bug; link from the PR and the handoff
6. Record (after milestones)
- journal entry incl. Process notes (AI collaboration) + prompting reflection
- check skill → no 🔴
What goes wrong most often?
Trusting the agent’s own account of what it did. Almost every failure in both projects was a confident statement nobody checked: a research report, a spec line, a “done”, a review finding. The rest were safety slips around secrets. Each item below happened at least once.
Do
- Verify every agent report by grep before building on it; keep reports short so that’s cheap
- Pin behavior with a characterization test before writing the invariant
- Check that each new test fails without the change, and that each guard calls production code
- Test a restore right after a backup, checking the property that matters
- Split files over 400 lines along real flows; agents edit giant files badly
- Write tool launch recipes down with the date and version they were verified on
Don’t
- Write up a blocked or failed action as done; re-check the real state first
- Apply reviewer findings unverified (two were wrong; one flagged Dart
%falsely) - Rely on deny rules alone (they covered Read but not
git show) - Run secret-handling scripts where
bash -xcan print tokens - Mass-reformat code in the same diff as a refactor
- Keep found-but-unfixed bugs only in handoff notes; file issues
Prompt shapes that worked well:
- “Assert actual behavior, flag surprises, don’t fix them” (characterization tests).
- “Trace the cause and decide whether an invariant should catch recurrence” instead of “just fix it”.
- Read-only researchers with a word cap, then writers with separate file ownership.
- Any agent-loop brief as goal + machine-checkable “done” + how to check + hard stop.
- Reviewer prompts that name the contract, ask for vacuous tests and leaks, and forbid style nits and praise.
So, what does a repo need before you hand it to an agent?
Less trust in the agent and more checks in the repo. What surprised me most wasn’t that the agents made mistakes. It was how confident and plausible every wrong claim looked. The agent still does most of the work; the repository decides whether it’s done.
If you want to try it, open a repo, install the prep-repo skill or point your agent at the raw guide, and start with step 1.
-
Anthropic, Best practices for Claude Code: verification, writer/reviewer pattern, hooks vs CLAUDE.md, adversarial review. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
Anthropic, How Claude remembers your project: CLAUDE.md size, AGENTS.md import, path-scoped rules, auto memory. ↩ ↩2 ↩3 ↩4 ↩5
-
AGENTS.md — a simple, open format for guiding coding agents. ↩
-
GitHub Blog, Spec-driven development with AI: Get started with a new open source toolkit. ↩
-
Anthropic, Configure permissions and Sandboxing. ↩ ↩2 ↩3
-
Anthropic, Settings files and precedence. ↩
-
Anthropic, Hooks reference: PreToolUse, exit code 2,
permissionDecision. ↩ -
Anthropic, Configure the sandboxed Bash tool:
filesystem.denyRead, child processes, protected paths,failIfUnavailable,allowUnsandboxedCommands, what runs outside the sandbox; path prefixes and Linux wildcard expansion in the settings reference. ↩ ↩2 ↩3 ↩4 ↩5 -
OpenAI, Codex authentication (formerly developers.openai.com/codex/auth): ChatGPT sign-in vs API key billing,
~/.codex/auth.json,cli_auth_credentials_store(file,keyring,auto,ephemeral). ↩ -
Anthropic, Extend Claude with skills. ↩
-
Yao, Zhao, Yu et al., ReAct: Synergizing Reasoning and Acting in Language Models (2022). ↩
-
Anthropic, Building effective agents (2024-12-19): evaluator-optimizer workflow, stopping conditions, ground truth from the environment. ↩
-
Anthropic, Orchestrate teams of Claude Code sessions. ↩ ↩2 ↩3