Skip to content

Repository files navigation

game_loop

Your AI coding agent can work while you're not watching. This is what makes that safe.

You already know it can write code. The question this answers is different: can you walk away?


What actually happens when you walk away

Not hypotheticals. These are the failures that produced every guard in this repo — each one observed in a real run, usually while somebody was asleep or in a meeting.

It stops. You come back after an hour and it has been idle for fifty-five minutes, waiting for a "continue" nobody typed. The work it promised is exactly where you left it.

It says it's finished when it isn't. Tests pass — because they tested nothing. A bug is "fixed" because the thing that used to crash no longer crashes, which is not the same as the feature working.

It states things that are confidently, plausibly false."The library handles retries internally." It doesn't. Three files are now built on that sentence, and it reads exactly like the sentences that were true.

It touches things it shouldn't. An agent running unattended with shell access, tidying up, is a sentence that should worry you. Most of the time it's fine. You don't get to see the times it isn't until afterwards.

It burns your usage window and dies mid-thought. No handoff, no notes, nothing written down. The next session starts from nothing and re-derives what the last one already knew.

It forgets what you told it. Long sessions get compacted. The rule you gave it two hours ago is gone — and it has no way of knowing it's gone.

It commits more than it changed. A formatter ran across a directory, git add -A swept it up, and the commit message describes something else entirely.


What game_loop does about each

When this happensWhat stops it
It stops earlyA watchdog notices the session went quiet while work is outstanding, and starts it again.
It quits mid-taskA gate refuses the turn-end unless it reports progress, asks a real question, or the work is genuinely done.
It asks you something it could look upA question costs it something: it must name the file it already read that failed to answer.
It asserts something falseIt cannot claim anything about the outside world without naming a real file it read. Prose cannot satisfy that check.
It says "fixed" too earlyA fix must be proved by exercising what the fix produces — re-running the thing that used to break does not count.
It edits outside the projectEverything outside the repo is read-only. The exception is one-time, spelled out by you, and logged.
It hits your usage limitIt is required to write a handoff before the window closes, then parks and wakes itself when the limit resets.
It forgets a ruleRules become artifacts — a check, a test, a gate — rather than something it has to remember.
It commits work nobody looked atThe commit names files this session never touched, so a widened diff is visible before it lands.

None of this is advice given to the model. Every one is a hook — code that runs whether or not the agent cooperates, agrees, or remembers.

The rule everything follows from: if the agent ignored every instruction you gave it, would this still hold? If no, it isn't a guardrail — it's a wish. Long sessions break wishes.


Why you'd want this

You get the hours back. The realistic alternative to an unattended agent isn't a faster agent — it's you, checking on it. game_loop is what makes "go do this, I'll read it later" a reasonable thing to say.

You can trust the report. The expensive failure isn't an agent that gets stuck; it's one that tells you it succeeded. Most of these guards exist to make "done" mean something.

It gets stricter as it learns. When something goes wrong, the fix isn't a note in a document nobody re-reads — it becomes a check that fails next time. The harness gets harder to fool the longer you run it.

It's small, and it's yours. Python standard library and bash. No services, no accounts, no telemetry, nothing phoning home. You can read the whole thing in an afternoon.

What it does not do

Stated plainly, because a tool that oversells its guarantees is worse than one that makes none.

  • It is not a sandbox. It reduces blast radius; it does not contain a determined process. Run genuinely untrusted work in a VM.
  • It cannot see everything. The write guard reads your command TEXT. It knows the Write/Edit tools by name, every redirect form in the shell grammar, and a NAMED set of write-capable verbs — and anything outside those three is invisible to it: an interpreter one-liner, a path built from a shell variable, a script that mutates once you run it, a program not on the list, a tool name it has never heard of. It names the covered set in its own SCOPE block rather than eliding it behind an ellipsis, which it did until 2026-08-26 — and that ellipsis is exactly how curl -o and thirteen other ordinary forms wrote outside the repo, unchecked, for weeks. The rule still holds in the gaps; the enforcement does not, and knowing which is which is the point of saying so.
  • It does not make the agent smarter. It makes it honest and persistent. A confused agent guarded by game_loop is a confused agent that has stopped claiming to be finished.
  • Some parts need a terminal. Usage-limit survival reads data Claude Code exposes only to a terminal status line. In editor-embedded sessions it says so out loud rather than pretending to protect you.

Requirements

  • Python 3 — standard library only, nothing to install
  • Claude Code — the guards are Claude Code hooks
  • macOS or Linux (bash + POSIX tools)

Install

One line, in the project you want guarded — no clone needed:

curl -fsSL https://raw.githubusercontent.com/SupposedlySam/game_loop/main/install.sh | bash -s -- .

Then restart Claude Code (hooks are read when a session starts) and confirm:

./.game_loop/bin/game_loop status

Installing adds a .game_loop/ directory and merges its hooks into .claude/settings.json. It never overwrites files you own.

Installing several repos on one machine? install.sh --central wires a repo to run the tool from one shared, machine-wide location instead of copying it in — see "Central install" in docs/how-it-works.md for setup and tradeoffs.

Have linked worktrees? Upgrading the main checkout moves the bar for all of them. The commit gate compares each worktree's harness against the main checkout's, so installing there changes what every other live tree is measured against. Nothing surfaces at the time — the install succeeds, status looks healthy, and the worktrees look fine because nothing has asked them anything yet. It shows up later as a refused commit on finished work, for whoever is holding that work rather than whoever caused the drift. So install.sh now counts the linked worktrees before writing anything, names each one, and prints the command to re-provision it. It warns rather than refusing: the upgrade is usually right, and a gate that blocked it would be routed around.

Your first unattended run

Three steps. This is genuinely most of what a human does.

# 1. Give it a job it isn't allowed to abandon
./.game_loop/bin/game_loop mandate --set "get the integration tests passing"# 2. Walk away. The agent works; the gates keep it moving and honest.# 3. When you're back
./.game_loop/bin/game_loop status

While a mandate is bound the agent cannot end its turn by drifting off — it has to report progress, ask you something it has earned the right to ask, or finish. Release it when the work is done:

./.game_loop/bin/game_loop mandate --clear --notes "tests green, flaky one quarantined"

Called away mid-run? mandate --park --reason "..." pauses without pretending the work is finished.

With no mandate bound every gate is inert — game_loop never sits between you and an ordinary conversation. A mandate also binds only the session that set it, so another window on the same repo is never conscripted into this one's work.

🤖 Pointing an agent at this repo? Send it to llms.txt — the operational brief: the exact commands, the gates it will hit, and what each one wants. This README is for you; that file is for it.


Page your phone, not your terminal (optional)

If the agent genuinely needs you, it can reach you on Slack rather than blocking on a terminal nobody is looking at — and if you reply from your phone, your answer is carried back into the run and it keeps going. Configure in .game_loop/notify.json; see bin/notify.py.

Surviving usage limits

As a usage window approaches its limit, the agent is required to write a handoff before doing anything else, so the run ends with its state on disk instead of mid-sentence. When the window is exhausted the watchdog parks rather than burning retries against a wall, and wakes the session when the limit resets.

The honest caveat: Claude Code exposes usage data to a terminal status line and nowhere else. In a session that renders no status line these gates cannot arm — and status tells you so in plain terms rather than staying quiet and letting you assume you are covered.

If your sessions run in an editor, the snapshot those gates read comes only from a terminal status line — so an editor-embedded session produces none, and status says the protection is INERT rather than implying cover it does not have.

It can still consume one. The snapshot is a file in the checkout (.game_loop/limits.json) and the limits are account-wide, so one terminal session in that repo arms the gate for every other session in it, editor-hosted included. Keep an ordinary claude session open in a terminal on the same repo — doing real work, not polling — and the rest inherit its readings. Nothing to configure, and no probe that spends API turns to measure how many API turns you have left.

The honest limit: a window whose reset time has passed no longer binds, so a snapshot from last week protects nothing. This helps while a terminal session is running or has run recently, and status tells you which of those is true.

Or let it fetch its own reading. If keeping a terminal open is not practical, game_loop can spawn a short session purely to read the windows and write the snapshot:

"limits": { "probe": { "enabled": true, "min_interval_sec": 900, "max_interval_sec": 3600 } }

Off by default, and it is not free: a spawn costs about 24k input tokens (measured, and it is the host's floor rather than ours). The reason to pay it is that the alternative is not zero — an unattended run that hits its limit at 1am dies mid-action and is still dead at 7am, because nothing could see the wall coming. The whole limit family already works; it was only ever missing the snapshot.

The watchdog refreshes it on an interval the reading itself implies — the fullest window decides, a window about to reset is not urgent however full it is, and with no snapshot at all it waits the longest, since the first probe has the least information and should not also be the most frequent. Run one by hand with game_loop limitprobe --force.

Optional: let bare game_loop work anywhere in the repo

game_loop is a project-local binary (./.game_loop/bin/game_loop), never a global command — one machine can hold several projects on different versions, and a global game_loop would run the wrong one. If you want to type game_loop ... from anywhere inside a guarded repo, add this to your shell profile:

game_loop() {
local d="$PWD"while [ "$d"!="/" ];doif [ -x"$d/.game_loop/bin/game_loop" ];then"$d/.game_loop/bin/game_loop""$@"return$?fi
d="$(dirname "$d")"done
{
printf'game_loop: no .game_loop/bin/game_loop found from %s upward.\n'"$PWD"printf' This is a project-local harness, not a global command. cd into a guarded repo.\n'
} >&2return 127
}

It walks up from wherever you are rather than assuming a repo root, which is what keeps it correct in a subdirectory and inside a linked git worktree — each tree carries its own harness, and a function hardcoded to one checkout would silently run a different tree's binary.

It finds the nearest harness walking up, which is what you want — and does mean a stray .game_loop/ in a parent directory wins over nothing at all. (Found while testing this: an old harness left in /tmp made every path under /tmp resolve to it. Harmless, but surprising if you have forgotten it is there.)

Optional: Claude Code skills so a session never has to guess

templates/skills/ ships six user-level skills. User-level, because none of them is knowledge about the project being installed into — they teach a session how to install this tool, how to read one of its refusals, and how to run under a mandate, which is the same everywhere:

SkillAnswers
gl-installinstall it here, or refresh what's here — reading the disk to decide local vs --central, asking only when the disk genuinely doesn't say
gl-refuseda gate just said no — what it means, and which escape hatches are the human's to open
gl-mandaterun unattended: bind, checkpoint, arm a question, park/resume, clear
gl-hardenturn a learning into something enforced instead of remembered
gl-triggersyou want a check that watches for a recurring mistake — how to test that it fires, and that it stays quiet
game_loopis this project guarded at all, and what does status actually want right now

The installer asks before installing them, because they land in ~/.claude/skills/ — your home, every project — and that is the one thing it writes outside the directory you named:

./install.sh --skills-only # just the skills, no project touched
./install.sh --skills /path/to/proj # skip the question, yes
./install.sh --no-skills /path/to/proj

With no flag you're asked, and a run with no terminal to ask at (CI, a pipe with no tty) installs none — silence isn't a yes. From a clone they're installed as symlinks, so git pull here upgrades every one of them at once; through the curl | bash one-liner they're copies, because the payload is a temp dir the installer deletes on exit. A skill of the same name that game_loop didn't install is never overwritten — it's kept, and named.

A shell function cannot reach non-interactive shells that skip your profile, so this is a convenience for you and never something the tool relies on. Everything game_loop prints names the explicit path for exactly that reason.

How much do we stand behind a given commit?

game_loop is developed in the open, so main moves while features are half-landed. A clone gives you whatever was there that morning. Three levels say how much confidence a specific commit has earned — and each one is an artifact, not a claim we typed:

levelwhat it meanswhat makes it markable
alphathe default. Nothing marks this commit. Treat it as mid-flight.nothing — it is the absence of a mark, so silence can never read as confidence
betathe full suite passed on this exact treea clean tree and every owed check already run; refused otherwise
stableour own agent was running its harness on this committhe above, plus the pin equalling it — dogfooding as a fact, not a promise
./.game_loop/bin/game_loop confidence # what is this commit, and on what evidence
git tag -l 'beta-*''stable-*'# commits we stand behind

Levels ride annotated git tags, so they arrive with an ordinary git clone and the evidence travels in the tag message. install.sh records the level it installed from, and status says so later, when nobody remembers which commit they took.

Installing the latest stable

export GAME_LOOP_CHANNEL=stable
curl -fsSL https://raw.githubusercontent.com/SupposedlySam/game_loop/main/install.sh | bash -s -- .

The export is on its own line on purpose. In VAR=x curl … | bash, the assignment applies to curl only — the bash that actually runs the installer never sees it, so you silently get an alpha install of main. Nothing can warn you, because the installer never received the variable and cannot tell that run from an ordinary one. (curl … | GAME_LOOP_CHANNEL=stable bash -s -- . works too, and survives reformatting less well.)

stable and beta are moving pointers, re-aimed at each mark by whoever marks it, so nothing on your side has to work out which tag is newest. That matters more than it sounds: the marks are annotated tags, so tag order is not commit order — --sort=-creatordate is right and --sort=-committerdate returns something much older, both look reasonable, and picking wrong pins an older commit that install.sh then correctly stamps as stable. Nothing downstream ever contradicts it. Sorting them yourself is the one step here with a silent wrong answer in it, so it happens once, at the source.

Pin an exact release instead with GAME_LOOP_REF=stable-<sha> — immutable, where the channel moves. Either way the level is recorded from the ref that was fetched, so a tarball with no .git no longer falls through to alpha.

Vendoring game_loop into another project? An extracted copy has no git tags, so the level cannot be read from it — and because alpha is the default, the failure would be silent and would look like an honest answer. Carry two files into the extraction and install.sh honours them:

.game_loop/VERSION the sha the extraction came from
.game_loop/CONFIDENCE alpha | beta | stable

Those three words are the whole vocabulary, and anything else in that file is reported as no mark at all — not as a weak one. A typo, a level a later version added, or a half-written file makes status say the value is not a level this build knows, and an empty file says the install recorded something that did not survive. Until this was fixed, every unrecognised value fell through to the stable wording and claimed the author had run their own agent on it, which is the strongest thing this scheme can say and was not true for anyone who saw it.

That is the whole contract. game_loop names no package manager and needs nothing from one at runtime; whoever produces the tree is the only party that knows which commit it holds, so they are the only party that can honestly say. Read the tags live when you write it rather than snapshotting at publish time: a commit is usually marked after it exists, so a snapshot would record it unmarked forever.

What no level means: none of them say the code is correct — only what was checked, and by whom. beta says a suite passed. stable says we were running on it. Neither is a promise about your project, and confidence tells you how to re-check rather than trust the tag.

The guardrails, briefly

Full design in docs/how-it-works.md; the guarantees as runnable checks in test/run.py.

The suite runs everything by default and takes a few minutes. It also takes --list-sections, and --section <name> (repeatable) to run a subset — which is how verify.yaml gates a change on what it actually touches rather than on the whole suite, so a one-line edit is not priced like a rewrite. A --section matching nothing exits 2 rather than passing: zero checks and exit 0 is byte-identical to a suite that ran and agreed with you, and those must never look the same.

  • Claim gate — no assertion about a dependency, a harness, or another repo without naming the real file that backs it.
  • Write guard — an allowlist: the repo, the OS temp dir, and roots you configure. Everything else is read-only, including sibling projects. The escape hatch is you — single-use, and logged.
  • MCP guard — a connected MCP server can delete or force-push with no shell command at all. Calls are classified before they run, and anything unclassifiable is refused. You can shut the plane off (mcp_writes: "disabled") or pre-authorise a narrow set (mcp_standing_writes); see Configure.
  • Policy files are not the agent's to edit — your config.json, INVARIANTS.md and verify.yaml are refused at the write once they exist, and config.local.json whether or not it does. The rule against this used to be a sentence in the docs; a blocked agent widened its own MCP permissions to unblock itself and reported it as a fix. Seeding is untouched — creating a file that is not there is provisioning, so installers and worktree provisioning still work.
  • Commit blast radius — names the staged files this session never wrote, so a widened commit is visible before it lands.
  • verify — your own map from "you changed X" to "these checks must pass". Refuses a commit when the evidence is older than the change. Ships empty; it does nothing until you add rules. Gate on the command CI runs, not a local equivalent. The ergonomic local command is the natural thing to write and inherits none of this gate's promise: one consumer ran flutter analyze lib for a year against a CI running dart analyze --fatal-infos over the whole tree — different scope, different severity, neither visible by reading the rule, same green either way. If CI's exact command cannot run locally, say so in a comment above the rule and name the two things that diverge: what it covers, and what it treats as fatal.

The verbs

status and mandate are the two you will type. The rest are the agent's, and every one is documented for it in llms.txt. The short version:

CommandWhat it's for
statusRehydrate after compaction. The agent runs this first, every session.
mandate --set / --clear / --parkBind, release, or pause a job it cannot abandon. --park is you calling a break. --wake-path "<how a signal reaches this session>" records that something OUTSIDE can reach the run, and --wake-every <minutes> records how often it should fire — with a cadence declared, status reports a path that has STOPPED delivering, which one missed wake could never show; status says so when nothing is recorded, because every other gate fires from inside the session. A woken run records its arrival with note --woke, and status then reports when the last wake landed and how long ago — the only direction visible from inside. A wake that was requested and never delivered leaves nothing here, which is why the declaration is still not a probe.
doorbellPrint the wake-up prompt for this run — what done means, where to resume, and the recovery paths recorded with note --recovery ".." — to paste into whatever pokes the session from outside. Generated, not a template. The paths that make a wake produce progress instead of re-orientation are per-run, so a static file you fill in is the thing that does not work. It says so plainly when it has none to carry, and when nothing records how the prompt is delivered — a doorbell nobody rings reads, from in here, exactly like one that never had to ring.
checkpoint --notes ".."Report progress and hand back without asking anything. If finished work is committed, pushed and unreleased, the handback refuses until you confidence --mark it or say why not with --release-deferred "<reason>" — which is recorded.
arm --question .. --read .. --predict ..Spend one interruption of you, backed by a file it already read.
claim --assert ".." --read <path>Assert something about the outside world, with the receipt.
harden --learning .. --artifact <path>Turn a lesson into something enforced instead of remembered.
authorize --path <prefix> --reason ".." [--uses N]Your one-time, logged permission for a single write outside the repo. --uses N when you authorised a run of several, rather than being interrupted once per call. A reason citing the agent's own brief rather than you is refused — see below. If the same reason has bought a hatch before, the grant still stands but says so loudly and names the allow_write_roots line that replaces it: a recurring cross-repo write is config, not a one-off.
confidence --mark beta|stable [--ref <sha>] [--recheck]Record how much this project stands behind a commit. --recheck re-runs the gate instead of trusting the tag.
mutate --prove ".." --test <cmd> --file <path> --replace ".." --with ".."Prove a test actually pins something: the tool runs the test green, applies your one-line mutation, runs it again, and restores the file. A revert-proof you report reads the same whether or not it happened. Your --test runs in the repo root, not your cwd — use --test "cd <dir> && .." if it is relative, because a command that could not start and a test that failed both exit non-zero (the verb tells them apart and says which). --timeout sets seconds per run (default 900). Before mutating it runs a positive control: it splices something fatal at your anchor and requires the test to go red. If it stays green the anchor is not on the test's path, and that is reported as your mutation point is inert — never as "the test is vacuous", which is the opposite conclusion with the same observable. The fault is built in for Python; on any other stack supply --fault '<text that aborts here, containing {marker}>' and the same probe runs.
fix --prove / effector --proveProve a fix or an action by what it produces — a before/after pair the tool compares, not a sentence.
stepbackThe retro. --show READS it without opening one — the output is longer than a screen, and re-running it to page through used to start a fresh retro each time and discard the harden that paid for the last (#112). --nothing-to-harden is the honest escape when a chapter really yielded no rule, and it requires a --reason.
successorMint the next session's id, point it at your handoff, and hand over. --handoff <path>, --task "..", --title "..", --about "..", --cwd <dir>, --session-id <id>, --dry-run. The prompt opens with a one-line subject so a human scanning a row of terminals can tell what that session is for — derived from the handoff's # heading, or from the bound mandate when the handoff was generated; --about ".." overrides it and --about "" turns it off. Set limits.successor.skip_permissions: true in config.local.json (gitignored; the tracked config.json is refused and says so) to launch it with --dangerously-skip-permissions — a config key, never a flag.
threadsList the handover chains in this checkout — which handoff belongs to which task. --json. Each chain prints under the label it was minted with (the first hop's subject, kept stable so a chain has a name rather than a status), its hops in order, whether its head session is live, and the handoff it reads. It joins edges the log already recorded rather than writing a second copy that could disagree. It states what it cannot see: a pruned predecessor, another checkout, and a takeover by hand — which records no edge at all.
guardtest --fixture <path>Run your own hook script against recorded payloads and assert what it decides, so "this guard still fires on the case that created it" is a check rather than a memory. The fixture is JSON: a script, and cases each with a name, a payload (handed to the script on stdin, as Claude Code hands a hook its own) and an expect of deny / allow / ask — plus optional expect_output for the reason, or expect_exit for a guard that only speaks exit codes. --script overrides the fixture's. Both directions are the price of admission: a file whose cases all expect allow is refused, because a script that does nothing passes it. And running a guard is not a read — a payload aimed at a path under a standing authorize grant consumes one of its uses, exactly as a real call would, so aim fixtures at paths the guard decides on its own. Refusal by exit 2 and by a JSON permissionDecision are both read as a deny — the decision is scanned for as JSON anywhere in the output, so pretty-printing it is fine. Exit 0 saying nothing is silent, which is what an allow looks like and equally what a guard that stopped running looks like; a decision that cannot be parsed is unreadable and never silent, because a guard whose verdict was lost did not allow anything.
kindsPrint the log record schema — every kind this code can write and the fields recorded beside it, extracted from the source so it cannot drift. Read it before writing a trigger that matches on kind.
contribute --reviewed --filed ".." --note-file <path>Record that you asked, of each learning hardened since your last review, whether it named a tool's behaviour or this repo's. "All project-specific" is a complete answer — recording the review is what clears the nudge. The file form is shown because an honest answer enumerates: real notes here run past the 400-char bound, at which point inline --note ".." is refused.
<any verb> --<option>-file <path>Read a prose option from a file. Required over 400 characters — a shell mangles prose that quotes code.

Configure

Everything lives in .game_loop/config.json. It ships with sane defaults and comments; see docs/how-it-works.md for what each knob changes.

KeyWhat it does
read_rootsExtra directories a claim --read may cite, beyond this repo.
allow_write_rootsExtra roots the write guard permits. An absolute home path here ships a write permission to everyone who clones you.
deploy_verbsExtra deploy/publish commands to block outright. This rail is a denylist — a verb nobody listed is not blocked.
guardsProject-local PreToolUse gates you want status to vouch for: {name, script, probe}. The probe is a command whose exit 0 means the guard would evaluate at all. No probe → reported UNKNOWN, never ACTIVE — a guard nobody can interrogate must not render as healthy.
upstream_reposRepos to watch for movement on issues that involve you (replies, closes, new ones) and for new releases, reported at checkpoint. Empty = off. Scope it tightly: an unscoped watcher surfaces unrelated side projects and gets switched off inside a day.
mcp_writes"gated" (default: a mutating MCP call is refused, and you may open it once with authorize) or "disabled" (refused outright, no hatch offered).
mcp_trusted_serversWhole servers your project OWNS. Every call to them is allowed, destructive included — the widest door here. See below.
mcp_standing_writesA narrow set of MCP writes that need no human, in either of two grains — an exact mcp__server__tool, or a whole-server mcp__server__ prefix. See below.
mcp_read_only_toolsTeaches the MCP guard which ambiguous tools of a server you trust are read-only. It can only resolve ambiguity — never silence a mutating verb or a mutating argument.
watchdogRing timing, ring cap, and waiting_probe.
upstream_review_everyHow many hardened learnings accumulate before stepback asks whether any were about the tool rather than this repo. Default 6.
retro_overdue_factorHow far past the retro interval counts as overdue (the gate, as opposed to the nudge).
limits.rate_limits, limits.threshold_tokensThe usage-limit readings the handoff gate and the park are measured against.
limitsUsage-limit thresholds for the handoff gate and the park.
limits.context.block_spawnThe fan-out brake. Once the last turn's reading passes limits.context.spawn_threshold_tokens (defaults to the handoff cap, and is meant to sit above it), a verb in limits.context.spawn_verbs — defaulting to showrunner spawn — is refused. No handoff opens this one; only a smaller session does. It matches the Bash verb and nothing else, and it never shrinks a fleet already running. false removes the brake.
update_check, update_repoWhether to check for a newer game_loop, and where from. update_api_base / update_raw_base retarget those lookups (GitHub Enterprise, a mirror, or a test).
(not config).game_loop/installed-by.jsonWritten by a packager, not by you — see below.
session_startfalse disables the status block injected at session start and after compaction.
work_nudge_every, trans_nudge_everyHow much evidence work goes by before status nudges for a retro / a phase transition.
hooks_probe_slack_secHow stale a hook probe may be before status calls the wiring into question.
project_name, flairDisplay name, and the fun lines (flair.enabled: false to opt out).

Standing MCP writes. Ask-every-time is the wrong shape when a workflow's work product lands through an MCP write — a finished review that cannot be posted buys a retry, not a safety decision.

"mcp_standing_writes": ["mcp__github__", "mcp__other__createThing"]

A whole-server prefix is the right grain when the server is your own first-party code, because enumerating its tools goes stale every time it grows one — and it goes stale toward a stuck agent. It is safe because every floor runs on the live call and returns before this policy is consulted: a prefix widens which servers are trusted, never what may be done through them. One tier a prefix does not inherit — merge, publish, deploy, release, push still need the tool named exactly. Typing it out is the deliberate act; inheriting it from which list a verb sits in is not.

When the server is yours.mcp_standing_writes deliberately stops short of the irreversible and landing tiers — right for a server somebody else ships, wrong for one your team wrote and maintains, where you already own the blast radius. For that, declare the server outright:

"mcp_trusted_servers": ["mcp__github__", "mcp__internal__"]

Every call to those servers is allowed: irreversible verbs, landing verbs, and mutating arguments included. That is the point — a half-grant refuses exactly what you most likely built the server to do, and an agent that stops to ask for the same approve on every pull request is not being made safer, it is being made useless.

Whole servers only (mcp__server__), never a single tool; a malformed entry is refused at config-read rather than silently dropped. mcp_writes: "disabled" still outranks it. Every call is logged as trusted_mcp_write, and status reports the list in capitals, because a door this wide that nobody can see is the failure this whole tool argues against.

What it cannot know: whether the server really is yours. Nothing checks authorship — only that somebody with commit access to your config said so. If that config is shared, this is shared with it.

The waiting probe, if you fan work out to subagents: watchdog.waiting_probe is a command you supply that answers "is this run waiting on work it dispatched?" Without it, a run that has correctly handed out all its work looks identical to one that fell asleep, and eventually pages you. It is config-only on purpose — a wait the agent could declare for itself would be an off switch for the watchdog. The contract is three-valued:

ExitMeaning
0Waiting. The watchdog stays quiet.
1Not waiting — there is work here. The watchdog rings.
anything else, a timeout, or an unrunnable commandCould not answer. Still rings, andstatus reports the probe as FAILING.

That third state exists because a probe that crashed and a probe reporting work produced identical output, so a broken one stayed invisible for exactly as long as there was work to do. Write yours to resolve its own dependencies explicitly: a hook's PATH is not your shell's, and a tool found by bare name is the usual way one of these silently stops running.

The probe is told which session it speaks for, which matters as soon as one checkout holds more IF YOU DISPATCH SUBAGENTS, READ THIS FIRST. An in-process subagent (Claude Code's Agent tool) inherits CLAUDE_CODE_SESSION_ID, so its state writes land in YOUR session file, not its own. That is not a bug in the subagent; it is what sharing an id means. Three verbs are worth knowing about:

verbwhat a subagent's call does to YOU
mandate --setrefused if it would replace your live mandate with different words (#88) — the loud case
checkpointbuys you a turn-end. Your next Stop gate passes on a permission you did not purchase
armprimes a T3 on your session — the most expensive rung, holding a question you did not frame

The last two fail in the PERMISSIVE direction and say nothing. Give each dispatched worker its own state and none of this arises:

GAME_LOOP_SESSION=<something unique per worker> game_loop ...

It is an environment variable, so it is inherited by that worker's own children — a worker that dispatches must set a fresh one for each of ITS workers, at every level.

than one session: GAME_LOOP_SESSION, GAME_LOOP_SESSION_DIR, and GAME_LOOP_TRANSCRIPT. Without them a probe can only look across every session sharing the checkout, so it answers "waiting" because somebody else's work is live — a false waiting, which is the one direction that fails silent. An empty GAME_LOOP_SESSION means unknown, never a session named ""; a probe that needs scoping should exit 2 rather than guess.

GAME_LOOP_SESSION is usually the one you want, and the transcript usually is not. For the common case — a parent waiting on subagents it dispatched — the parent's own transcript is quiet precisely while it waits, so it is the state you are trying to recognise rather than a signal that distinguishes anything. What is live during a fan-out is the subagents' artifacts, and those sit under the host's per-session directory, not game_loop's. The session id is what lets a probe resolve its own directory there instead of globbing across every session in the checkout. GAME_LOOP_TRANSCRIPT is supplied for probes that genuinely want the parent's own activity.

If a package manager installs game_loop for you

status tells you when a newer game_loop is on main. By default it suggests re-running the curl installer — which is right for a curl install and wrong for a vendored one, where it would replace a blessed, stamped release with whatever is on main at that instant and drop the CONFIDENCE file. A correct alert with a destructive fix attached.

So a packager should drop .game_loop/installed-by.json beside the payload:

{"name": "yourpkg", "upgrade": "yourpkg upgrade game_loop"}

install.sh also refuses rather than quietly doing the destructive thing: running it over a vendored payload is blocked and names the packager's own upgrade command, and installing an unmarked commit over a beta/stable install is blocked because it would downgrade CONFIDENCE to alpha and destroy the only signal saying anybody stands behind the code. Both name an escape flag, and both stand aside when the packager itself is the caller — so yourpkg upgrade keeps working.

The notice then names that command and stops offering the curl. The command is printed, never run — a file game_loop executed would be a code-execution vector wearing a helpful face, so the decision and the typing stay with the human. An unreadable or multi-line value falls back to the ordinary notice rather than printing something nobody can trust.

Site wiring: config.local.json

.game_loop/config.local.json is gitignored and layered on top of config.json, key by key. Put anything machine- or checkout-specific there — a waiting_probe naming your tracker, a local write root — so it never seeds into anyone else's install. You write it; the agent cannot — its keys union with the tracked config, so a session able to write here could widen its own permissions, and the write guard refuses it. A human editing the file, or a layer above writing it from its own process, never passes through that hook and is unaffected. status names how many keys are overridden and which, because a config you cannot see is a divergence nobody can explain.

One shared copy instead of one per repo — install.sh --central

Every project can dispatch to a single machine-wide install rather than carrying its own copy of the tool. Populate or refresh that copy with:

game_loop self --pin <ref> --dest ~/.claude/game_loop-central

--dest is only for the shared copy — without it, self --pin pins this repo's own harness in .game_loop_self/. Full semantics in docs/how-it-works.md.

What this harness believes about its host — .game_loop/claims.json

Load-bearing facts about Claude Code that the limit machinery rests on, each recorded with what breaks if it moved. A claim cannot be checked automatically; its subject can be, so each entry carries verified_on and verified_against — the host version it was last re-read against. null means nobody has re-read it, and status says exactly that rather than implying agreement.

Re-reading one: ask the PID, never PATH.verified_against is the host version the claim was last read against, so a re-read has to happen in the binary actually serving your session — and which claude is not reliably that. On the machine this was written on, PATH resolved to an npm install at 2.1.243 while the session was being served by a VSCode extension's bundled binary at 2.1.241: two separate installations, two different files. status gets this right (it reads the running process's execpath, not PATH), and it will keep showing ⚠ if you stamp a claim against the wrong one — which is the check working. Find the real file with lsof -p <pid> on the live process and read the schema out of that. Both builds happened to agree here, so the answer survived a wrong method; the method was still wrong, and the next divergence will not be so forgiving.

Where a fact can be provoked rather than stamped, it is: exercisable is yes / yes-not-yet / no, and exercised_by names the live evidence. An exercise checks the claim on your machine every run and cannot go stale; a stamp records that the author checked it on theirs. Some facts are unconditional absences with nothing to provoke, and those say so instead of pretending otherwise.

Is the waiting seam armed? — game_loop watchdog

An empty queue and a wedged run are indistinguishable from outside: the transcript stops growing either way. watchdog.waiting_probe is a command the PROJECT supplies that answers "is this run waiting on work it dispatched", and game_loop watchdog --porcelain reports whether one is configured, which file arms it, its last verdict, and whether it is failing — so a layer above can check the seam before it fans out instead of parsing config.

It reports a second seam alongside that one: whether this session is blocked at a turn-end gate, since when, and by which attachment. A blocked session is indistinguishable from a working one from outside — live process, exit 0, files on disk — so an orchestrator had no way to tell "refused and waiting for a nudge" from "getting on with it".

There is no verb that sets it, deliberately. A wait a session can declare for itself is an off switch for the watchdog that watches it, and this verb cannot tell a session from the orchestrator above it. Arm it in .game_loop/config.local.json, which is gitignored site wiring — not config.json, which is a rule file byte-compared between a parent checkout and each worktree, so writing it there reports as drift.

Machine-wide trust: ~/.game_loop/config.json

A third layer, read by every project's write guard and MCP guard (full install or --central) in addition to its own config.json + config.local.json — for a grant you want to make once, for this machine, rather than re-declare in every project. templates/global-config.json in this repo is a starting point; nothing installs it automatically — copy what you need to ~/.game_loop/config.json by hand.

The trust-list keys (read_roots, allow_write_roots, deploy_verbs, generated_globs, mcp_read_only_tools, mcp_standing_writes, mcp_trusted_servers) union across all three files instead of replacing — a global grant can't be silently erased by a project's own (possibly absent) same-key list, and a project's own grant survives a global file that never mentions it. Everything else uses normal later-wins override, so a project can still narrow a global default (e.g. mcp_writes).

One real limit, not silently swept under: this only reaches projects that already run some form of game_loop. A project with no .game_loop/ at all never invokes any guard script in the first place, so nothing in ~/.game_loop/config.json reaches it — there is currently no global, no-install-required layer.

Prose that quotes code goes through a file

Every option taking free prose has a --<name>-file PATH twin, and an inline value over 400 characters is refused in favour of it — checkpoint --notes-file, harden --general-file, and so on. A quoted shell argument is code to the shell first: backticks run, $NAME expands to nothing, a lone quote truncates, and the tool receives the result with no way to know anything went missing. The bound is measured against this repo's own logged prose, not chosen for roundness.

Triggers: attaching your own actions to the loop

.game_loop/triggers.json (gitignored; see templates/triggers.example.json) lets a project hang its own command on a moment in the loop. Nothing is attached by default.

EventFires whenTypically used for
hardena learning was just encoded into an artifactshare the transferable form with other agents
stepbacka retro just beganpull in what others learned; re-read the work queue
confidencea commit was just markedpublish it wherever consumers take it
session_starta session started, or was compactedact once on the session's behalf — register with a local tool, join a room, claim a name
stopthe turn is about to endrefuse it — a question left unanswered, a branch left unpushed

A fourth shape worth knowing: the question gate. Every trigger above verifies something. A question gate cannot — it detects the shape that raises a question (a path glob, a regex over the diff), prints the question with the concrete failure that earned it, and checks only that the agent NAMED the site in a ledger file. It never checks that the answer is any good, because it cannot. That ledger is gameable, and worth having anyway: it converts "nobody considered this" into "somebody wrote a line" — a lower bar than correctness and a much higher one than silence, and unlike silence it leaves something dated that a reviewer can disagree with. Use it where the answer is a judgement no command can make: is this copy right for the audience, does this migration need a backfill. templates/triggers.example.json carries the full shape and a worked example.

A trigger gets a JSON payload on stdin and its stdout comes back to the agent. It never blocks the verb, it is never silentstatus lists each one with its last run, and a failing trigger is shown as FAILING with its error rather than passing quietly. stop is the exception to the first half of that and is described below.

session_start is the odd one out and worth reading twice before attaching to it. It is the only moment that is not a verb somebody typed, so it runs on the path every session crosses: the timeout budget is per attachment and nothing caps the sum. It fires at every start and every compaction, so "once per project" is the attachment's job — the payload carries source (startup / resume / clear / compact) to tell those apart. Its stdout is appended to the status block already injected at session start, so an attachment can both act and report what it did. And because session_start: false switches the whole moment off, status reports an attachment wired to a disabled moment as SWITCHED OFF rather than letting it read as one that is merely patient.

stop is the other one to read twice, and for the opposite reason: it is the only moment whose exit code is a verdict. Non-zero blocks turn-end and its stderr goes back to the model as the reason — the Stop gate's own contract, handed to a command your project wrote. It exists because rules like "reply when a human addresses you" live in prose, and prose is followed sometimes; an agent asked a direct question through a chat bridge did the work and ended its turn without answering. Four things hold it in place:

  • it runs on every turn-end, so its timeout is a tax on every turn — the default is 10s, not 20;
  • it is bounded by consecutive count. After 3 consecutive blocks from the same attachment it stands down with a loud notice and the turn ends; one pass resets the count. Every other block this gate issues is satisfiable from inside the session — a stop attachment's condition may be satisfiable by nobody present (the room it asks about is down), and a gate no session can pass would be the harness preventing every agent from finishing. What it does not bound: a single block. A session that is refused once and never retries never increments the count again, so the stand-down is never reached — observed as a Crawler sitting inert for 44 minutes after one correct refusal. game_loop watchdog --porcelain is what makes that visible from outside;
  • an error fails open, loudly, and is not a pass. A timeout, an unrunnable command or a crash ends the turn unchecked and says so — in the log and in status — rather than blocking, because a guard must never block its own fix;
  • a block is counted like any other, so status's gate tally and the log answer "why did this not stop" without anyone reconstructing it.

Your project's own rules live in three files the installer seeds once and never overwrites: .game_loop/INVARIANTS.md (your non-negotiables), .game_loop/verify.yaml (what a change owes), and .game_loop/config.json.

Adding a command to verify.yaml owes a run, even if no file it gates has moved.verify records what it ran beside when it ran, so a rule whose command list changed is stale on the next verify --check and says which of the two reasons applies — a changed FILE and a changed RULE send you to different places. Without that, the tidy commit that adds a gate and nothing else goes green having never executed the gate, and its first real run is some later commit that happens to touch a gated file. Reported by a consumer who hit it and read it in this source before saying so.

Testing a stop trigger before you trust it to block a turn: a trigger that matches a kind nothing ever writes, or reads a payload that never arrives, fails open silently and looks exactly like one that is merely satisfied — game_loop kinds and status's dead-kind check catch the schema half of that. templates/triggers.d-examples/ and test/trigger_fixtures.py cover the other half: two worked example gates and the fixture shapes to run one in both directions (a synthetic log, a throwaway git repo, an external command stubbed onto PATH) before it ever gets the chance to block a real turn on a condition it silently never checks.

Flair 🎮 (fun, opt-out)

game_loop narrates the run like the game master of a dungeon crawl — your AI is the Crawler. When a guard helps, it hands the agent a first-person line to repeat back, like "🎮 GameLoop yanked me back onto the path before the walls closed in. Back to it." At milestones it hands out achievements and, like any decent dungeon, runs a sponsor read:

🎮🏆 GameLoop has kept your Crawler alive and moving for 4h — not one game-over!
📺 This floor of the dungeon is sponsored by SupposedlySam. GameLoop encourages
tribute → https://github.com/sponsors/SupposedlySam

Pure decoration, isolated in .game_loop/bin/flair.py, never touching gate logic, and completely disabled by flair.enabled: false — set support_name / support_url to point the sponsor link wherever you like.

Migrating from an existing .loop/-style harness

  1. ./install.sh /path/to/that/project — adds .game_loop/ and merges game_loop's hooks.
  2. Move project-specific rules into .game_loop/INVARIANTS.md, .game_loop/config.json, and .game_loop/verify.yaml.
  3. Delete the old .loop/ directory and its hook entries from .claude/settings.json. The installer adds game_loop's hooks; it does not remove yours, so old ones would run alongside.
  4. ./.game_loop/bin/game_loop status to confirm.

Installing / distribution

Distributed as this GitHub repo: clone it and run install.sh against your project, or use the one-liner above. See docs/distribution.md for the other channels under consideration and the tradeoffs.

Lineage & credit

game_loop is extracted from two harnesses that already ran unattended for real work — one where the expensive gated action was a physical device flash (a human button-press), and one where it was a real-money trade. Same arm → gate → consume primitive, same VERIFIED / RULED-OUT / OPEN ledger vocabulary, two unrelated domains. game_loop is that pattern with the domain specifics removed, so anyone can drop it into any project.

License

MIT. See LICENSE.

About

Help your AI stay on track and keep crawling the dungeons! Game loop provides hours of fun for the low-low cost of...your sanity! Please enjoy responsibly.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages