Autonomous AI Agent Loops: Use the Git Log as Memory

The loop's git-log diary is durable, not visible: pass 11 sees 5 of 10 past decisions, and not the one it needs.

Autonomous AI Agent Loops: Use the Git Log as Memory

At 3am the loop re-implements the retry logic it already wrote at 1am - same function, subtly different - because the status file it left itself said step 4: in progress, not tried exponential backoff, the test still flakes, leave it alone. The work was done. The reason it was done had evaporated.

The usual diagnosis of that scene is where the memory lived: in a file the agent overwrites every pass. So the usual fix is to move it somewhere append-only. The commit log. Run the agent headless, one task per pass, force every commit body to carry the decision, and inject the last few commits into each fresh pass so it wakes up already briefed. That recipe is right, and it is standard now. This site has already claimed that every loop-engineering building block is a context primitive in disguise; this piece works one instance of that claim all the way through, for the memory primitive, until it produces a number the recipe does not expect.

Here is the tension. The commit log fixes durability: append-only, ordered, nothing overwritten, nothing lost from the repo. But no pass reads the repo. Each pass reads the last five commits, because the recipe tells it to, and a five-commit read can lose a load-bearing decision to the window’s edge exactly as completely as a scratchpad loses it to being overwritten. Durable and visible are two different properties, and the recipe buys only the first.

Quantified, on the recipe’s own worked example: in a 12-pass loop, a plain 5-commit window shows pass 11 five of the ten decisions made before it - 50% - and specifically not the one that pass depends on, which aged out at pass 9, two full passes earlier. One line of text in the plan file, pointing at that decision’s commit hash, recovers it. Zero extra commits.

Every number in that paragraph is derived below from four stated assumptions, and checked with a script that computes the same answer two different ways.

Before any arithmetic, thirty seconds on your own runner settles whether the rest of this is for you.

Open the script that drives your unattended loop. Find the line that hands the past to each fresh pass - the git log line, the status file, whatever injects memory. Count the number after the dash. If your loop writes a status file instead of reading a log, the answer is one pass. That number is how many passes of memory your loop has.

Now the part that decides it. Open the plan file the loop works. Find an item that touches files an earlier item touched, where the earlier item’s commit body says something like “do not re-introduce this” or “the obvious approach fails here.” Count the items between the two. If more items separate them than your memory line has passes, the warning is gone by the time the second item runs.

No item in your plan depends on a decision older than your window? Close the tab; everything below is one worked example of a failure you do not have. Your loop’s memory is not the log. It is the number in that line.

An unattended loop carries two different things between passes, and they get confused constantly.

Task memory is what is left to do. Decision memory is why the finished part looks the way it does: what was tried, what passed, which path is closed and why. Task memory already has a settled home: the plan file, read fresh each pass. That pattern has its argument twice over, in the relay, not the window and treat the plan file as external memory; this piece assumes it and covers the other half, where the rationale lives. Where the queue itself comes from is settled elsewhere too - your backlog is the prompt - and does not matter here.

Decision memory is the half without a home, and the raw material is obvious. A commit happens at exactly the moment a decision is made, and its body can carry the reason. Treating the commit message as a machine-readable artifact rather than a note for humans is not new: Conventional Commits built tooling contracts on that shape, and Aider already runs the git log as an agent’s memory channel, auto-committing a descriptive message per change so a later run can diff and blame its way back to what happened. What both of those record is what changed. What the loop at 3am needed was the why - the field no auto-commit writes, and the one the toy below shows getting lost.

If you run Claude Code, a third candidate just raised its hand: the native memory layer, MEMORY.md and auto-memory, where the agent keeps facts across sessions on its own. Real, useful, and not a substitute here, for a structural reason rather than a preference. A memory write is not tied to a change. What the agent keeps, what it drops, and when it gets around to writing are all its own call. A commit is atomic: the message and the change land in one act, or neither lands. An agent-curated memory behaves like design A below rather than design C, and design A’s row in the table is that behavior on a bad night. This is also a different question from the standing-conventions choice between a hand-authored rules file and an auto-memory layer. That argument is about rules that hold on every run; this piece is about decisions from this run, which neither a static rules file nor a general memory file is built to hold reliably.

Nor is it the standing-context axis, the one covered by deterministic context, a cached explore, and hierarchical context files. Those carry what the repo is always like. Decision memory carries what happened in this run.

One more boundary, because it decides whether any of this applies to you. If your build fans out - many headless agents in parallel, each on its own item in its own clean window, an overnight build loop - then no pass depends on an earlier pass’s decision, and isolation sidesteps this problem entirely. This piece is about the sequential loop, where pass 11 has to know what pass 3 decided and nothing sidesteps it.

Split the loop’s memory in two: the plan file carries the tasks, the log carries the reasons - and only one of the two is read through a five-commit keyhole.

The whole recipe, because the arithmetic later leans on its exact lines:

#!/usr/bin/env bash
set -euo pipefail
MAX_PASSES=50 # hard cap: a stuck pass must not loop forever.
for ((i=1; i<=MAX_PASSES; i++)); do
# The agent's only memory of itself: the last few commits.
RECENT=$(git log -5 --format='%h %s%n%b' --no-merges)
BEFORE=$(git rev-parse HEAD)
PROMPT=$(cat <<EOF
$(cat ./prompt.md)
## The plan
Read ./PLAN.md for the full task list. Work the FIRST unchecked item only.
## What previous iterations did (your only memory - read it)
$RECENT
EOF
)
# Headless: fresh window, single shot, then exit.
claude -p "$PROMPT" --allowedTools "Edit,Bash(git:*),Bash(npm test:*)"
# Stop when the plan is fully checked off.
if ! grep -q '^- \[ \]' ./PLAN.md; then
echo "Plan complete after $i passes."
exit 0
fi
# Detect a stuck pass: no commit means no new memory - bail, don't spin.
if [[ "$(git rev-parse HEAD)" == "$BEFORE" ]]; then
echo "Pass $i made no commit - stopping for a human." >&2
exit 1
fi
done
echo "Hit MAX_PASSES ($MAX_PASSES) without finishing the plan." >&2
exit 1

Three things cross the boundary into each pass. The fixed prompt.md: role and contract, never changing. A reference to PLAN.md, so the plan stays a file the agent reads deliberately. And git log -5: the last five commits, decisions and all. That is the diary. Every pass opens it, re-reads what it was doing before it forgot, and gets to work.

Two brakes in that script are borrowed, not argued here. The MAX_PASSES ceiling and the no-commit HEAD check both belong to teaching the loop to stop, which argues them properly; they are here because an unattended loop needs them. Likewise the tightened --allowedTools: the diary mechanic needs git access on every pass, and the case for a small blast radius is its own piece.

One rule completes the loop, and people skip it: one task per pass. A pass that does five things writes one commit that explains none of them, and the next pass inherits fog. A pass that does one thing writes one legible diary entry. It also gives the toy its shape: one task per pass means pass i writes commit i.

Now read the memory line literally, because it carries a number and the number is the rest of this piece. git log -5 hands each pass the five most recent commits. Everything older still exists in the repo, fully durable, as promised - and is invisible to the pass holding the prompt. A decision stays visible for exactly five passes after it is made, through the fifth pass that follows it, and then it ages out. Hold that rule. The toy pays it off exactly, on the one pass where it hurts.

Five passes of the log are the loop’s entire memory. Everything older might as well not exist, as far as any fresh invocation knows.

The contract that makes the diary worth reading

Section titled “The contract that makes the diary worth reading”

A loop that re-reads the log only works if the log is worth reading, and a default commit is not. “fix tests” is written for a human skimming a PR who already has the context. The reader here is a fresh agent with none, and the message is its entire briefing. So make the message a rule - persistent context that loads every pass - and make it a contract:

## Commit contract - YOU ARE WRITING FOR THE NEXT ITERATION, NOT A HUMAN
Every commit message MUST contain, in the body:
- Decided: the key choice you made and the reason.
- Files: what you touched and why each one.
- Blocked / next: the single most important thing the next
iteration needs to know. Dead ends count - name them so nobody
retries them.
The next iteration starts with an empty memory and reads ONLY the
last few commits. If it isn't in the commit body, it did not happen.

That last line is the load-bearing one. It turns the commit from bookkeeping into the agent’s sole act of remembering. Here is the same change both ways. Under no contract:

fix auth tests

The next pass reads that, learns nothing, opens the auth module, and re-derives what the last pass already knew - including the dead end the last pass already ruled out. Under contract:

fix(auth): stop token refresh from racing the logout handler
Decided: serialized refresh and logout behind one mutex rather than
debouncing. Debounce hid the race in the test run but it still fired
under load - the mutex is uglier and correct.
Files: auth/session.ts (the mutex), auth/session.test.ts (added the
concurrent-logout case that was passing falsely before).
Blocked / next: the same race almost certainly lives in the billing
webhook handler - it shares the refresh path. Do NOT re-introduce the
debounce; that path is closed.

Keep that commit in view. It is the running example for everything below, and it is pass 3 of a 12-item PLAN.md. The diff shows the mutex. The body is the only place that says the debounce existed, looked right under test, and is closed - and the last paragraph is a prediction: item 11 on this plan is the billing webhook handler, and it shares the refresh path.

The first message is a receipt. The second is a handoff to the next pass, and its “Blocked / next” line just named pass 11’s job.

Four constants, all stated, none measured. A 12-item PLAN.md, so the loop runs 12 passes and pass i writes commit i. A 5-commit window, which is not an invented shrink: it is the literal git log -5 in the script above. A decision made at pass 3, the mutex rather than the debounce. And a need at pass 11, the billing webhook item the pass-3 commit itself predicted. The gap between decision and need is 8 passes. The window is 5. The failure falls out of the arithmetic, not out of taste.

Compute the obvious way first. At the start of pass 11, ten decisions exist, one per earlier pass. What does each design actually hand the fresh invocation?

Now the planted rule pays off. Pass 3’s decision was visible to passes 4, 5, 6, 7 and 8 - exactly five passes, exactly as promised. At pass 9 the window covers passes 4 through 8, and the decision is gone. It aged out at pass 9, two full passes before pass 11 needs it. Passes 9 and 10 run without it. Then pass 11 opens the plan, reads item 11, and its memory line hands it five commits about items 6 through 10. None of them mention the debounce.

So the pass re-derives. It finds the same refresh race in the billing webhook, and it lands where re-derivation lands: on the debounce, because the debounce is what looked right the first time - it passed the test run. That is the opening scene replayed, the 1am retry logic re-implemented at 3am, by the recipe that exists to prevent it, on the recipe’s own worked example. The arithmetic says the diary entry is gone, and the commit’s own last paragraph says where it would have been needed.

At this point the fix is one line long and should already suggest itself. Call it the anchor: a decision pinned by commit hash at the place the need lives, instead of recorded in the log and left hoping the window still covers it. One line, written into the plan item in the same pass that makes the decision:

- [ ] 11. Fix the billing webhook refresh race
(see a1f3c2e: mutex, not debounce - the debounce hid the race
under test. Follow the pattern in auth/session.ts.)

Pass 11 reads its item, the item carries a hash, and git show a1f3c2e pulls the whole decision back into view: the reasoning, the dead end, the warning. The log was durable the entire time. The anchor is what makes the one needed decision visible again, at the one pass that needs it.

The obvious objection lands here, so it gets answered here: why not just widen the window to 12, or 50? Because the window is read by every pass, every time, for the whole run. A window wide enough to cover this plan, 12 commits against the published 5, more than doubles every pass’s briefing to fix one decision at one pass; a 50-item plan needs a 50-wide window, and the briefing grows with the plan forever. Widen the window far enough and you have rebuilt the ever-growing transcript the loop was designed to avoid, one flag at a time. Widening is still a real dial, and for a plan with no long-range dependencies it is the simpler one. The anchor is the targeted version: five commits of briefing for every pass, plus one hash lookup at exactly one pass. If you would rather stop the run and hand a file to a human to eyeball before restarting, that is the handoff file’s move, for the attended case. The unattended loop has no eyeball step; the log carries the handoff alone, every pass.

Both derivations above were cross-checked with a script before this was written: a pass-by-pass simulation of what each fresh invocation receives, and a closed-form set expression, agreeing on all three designs across all twelve passes. You can run the simulation by hand in a minute. That is the point of the size.

The log was always durable. The anchor makes the one needed decision visible again - one line of plan file, zero extra commits - and that is the entire distance between repeating the debounce mistake and not repeating it.

DesignDecisions on record (of 12)Visible to pass 11 (of 10)ShareSees pass 3’s decision?Repeats the debounce mistake?Extra cost
A. Mutable status file1/121/1010%NoYes$0
B. Commit log, git log -5 (recipe as published)12/125/1050%NoYes$0
C. Commit log, hash-anchored12/126/1060%YesNo+1 line in PLAN.md, $0 extra commits

Row A is the opening scene as a design. One decision recoverable at any moment, and it is the wrong one.

Row B is the surprise, and the row this post exists for. Recording went from 1/12 to 12/12 - the append-only fix worked completely - and visibility went only from 10% to 50%. Durability bought eleven decisions of recording and four decisions of sight. The one decision with a named consumer, pass 3’s, with item 11 waiting on it since the commit body predicted it, landed outside the window both times it mattered.

Row C adds one line and gets one decision of sight back: the sixth visible decision is pass 3’s, on purpose, because the plan item named it. The share barely moves, 50% to 60%, and the share is the wrong thing to watch. The cell that flipped is the one that was load-bearing.

Row B is the recipe you run. Row C is the recipe plus one line. The gap between them is the whole post.

History rewriting kills the anchor along with the log. The whole mechanic rests on a linear, append-only history, and a squash, a rebase or an amend inside the loop erases it. The anchor dies a second death of its own: after a rewrite, the hash in PLAN.md points at a commit that no longer exists, and the next pass gets an error where it expected a decision. Run cleanup after the loop finishes, never inside it.

A convincing-but-empty commit is never re-verified. The contract forces a body; it cannot force the body to be true. An agent will write a crisp Decided / Files / Blocked over a change that does not compile, and the next pass trusts it, because the diary is read as fact. The loop’s HEAD check catches a pass that committed nothing. It says nothing about a pass that committed garbage. The test command being inside --allowedTools is not decoration: the diary records decisions, and the test gate is the only thing in the system that keeps those decisions honest.

Most work never needed any of this. An interactive session already has a memory: you. A single-pass task has nothing to hand off. The failure itself needs a specific shape - a later item that depends on an earlier item’s decision from more than a window ago. The thirty-second check at the top is the honest test, and if it came back negative, the anchor would be ceremony.

The anchor list is not free forever. One anchored decision costs one line. A project that runs long enough accumulates anchors the way it accumulates commits, and nothing in this design prunes them, budgets them, or retires one when the decision it pins is superseded.

The anchor buys back one decision’s visibility. It buys nothing about the log’s honesty, the loop’s brakes, or its own accumulation - each of those is a separate problem with a separate owner.

Keep git log -5. Add one clause to the commit contract: whenever a Blocked / next line names a future item - and it should, whenever a decision closes a path another item will walk - write the decision’s own hash into that item’s line in PLAN.md, in the same pass, in the same act. The prediction and the anchor land together, and no later pass has to be lucky.

Then row C one more time, as the closing image. Twelve decisions recorded. Six of ten visible to the pass that matters, the one it needs among them, the mistake not repeated. The entire bill is one line of plan file and zero extra commits. Row B, the recipe unchanged, recorded the same twelve decisions and lost the same mistake anyway. That distance is one hash long.

Which leaves the cost this fix creates and does not pay. One anchor is free. Forty are a list. A long-running project will eventually need many decisions pinned, not one, and nothing here says whether that list gets curated by a person, pruned by its own loop pass, or left to grow the way the commit log itself grows. That question is handed to you the same way the two-clock split hands forward what prunes a ratchet that only ever grows. One anchored decision is free. The next open question is what a project does when it needs forty.


About the numbers. The four constants are stated assumptions, not measurements of any real run: a 12-item plan, a decision made at pass 3, a need at pass 11, and a 5-commit window. The window is grounded directly in the recipe’s own git log -5 line; the other three are sized so the failure falls out of the arithmetic - an 8-pass gap against a 5-pass window - rather than being asserted. Every other number in this piece is exact arithmetic from those four: the visible sets (pass 10 only; passes 6 through 10; pass 3 plus passes 6 through 10), the 1/12, the 5/10 at 50%, the 6/10 at 60%, the age-out at pass 9, and the two-pass gap before the need. Each set was derived twice, once as a pass-by-pass simulation and once as a closed-form expression, and cross-checked with an independent script before publishing. “Repeats the debounce mistake” is the modeled outcome of not seeing the decision, given that the debounce was the option that looked right under test; the certain part is the re-derivation, and the likely part is where it lands.

For the per-tool mechanics, see Headless & CI for the non-interactive flags that make each pass a clean slate, Rules for the commit-message contract that loads every iteration, and Permissions, sandboxing & approval modes for scoping the loop’s blast radius.