Claude Learner

Safety

What a fill exercise does to your files, what stops it leaving them broken, and how to remove Learner entirely.

The guardrail

A fill exercise deliberately breaks a file you care about, and a session can end at any moment: a crash, a closed terminal, /clear, a meeting. So the Stop hook carries a second job. While a leftover marker survives, it re-blocks the end of the session and asks for the file to be put back before anything else happens:

the guardrail, blocking
🎓 Learner — an unfinished fill-in exercise left // LEARNER-TODO markers in: hooks/learner-config.sh
Before anything else: restore the correct implementation, remove every // LEARNER-TODO it left, and verify it compiles/lints/tests. Do not finish while a marker remains.

Five properties, as they are actually implemented in hooks/learner-quiz.sh:

Leftovers only
A file counts when the working tree has a marker in it and HEAD does not. The unit is the file rather than the individual marker: the hook lists the files matching in the working tree, lists the files matching in HEAD, and takes the difference. A marker you committed on purpose is therefore repo content and never triggers it — which is what lets this project write the string in its README, its tests and this very page. In a repository with no commits yet there is no HEAD to subtract from, so every marker in the tree counts, which is the right answer there.
Moved files count as committed
The difference above is taken by path, and a file you rename lands at a path HEAD has never carried — so on paths alone every marker inside it would read as a fresh hole and block a session that cut nothing. The hook therefore also compares content: a working-tree file whose exact bytes are already committed somewhere is a rename or a copy, not an exercise. A renamed file that was also cut has bytes HEAD has never seen, and still counts.
Prose that names the marker is not a hole
A page explaining this feature writes the marker inside an inline code span, the way this very sentence does; an exercise cuts it into code as a bare comment line. So in a .md file the hook strips the inline spans and looks again — what survives is what counts. The relaxation is that narrow on purpose: it applies to markdown only, and only to inline spans. A fenced block is left intact, because that is where a hole cut into a documented snippet would sit, and backticks never acquit a marker in a source file. Without this, a new documentation page would hold the session hostage to its own text until it was committed, since an untracked file matches neither a HEAD path nor a HEAD blob.
Untracked files count
The search runs with --untracked. A file the session created a moment ago is the commonest place for a hole to be left behind, and it is precisely the file git would otherwise say nothing about.
It fires even when the quiz is off
The guardrail runs before the activation check, so neither "enabled": false nor a missing or invalid level silences it: an abandoned exercise still has to be cleaned up. Exactly four things stop it — no jq, no session id in the hook payload, not being inside a git repository, or the repository sitting under disabledPaths. That last one is the deliberate hole: a repository you told Learner to leave alone is left alone, holes included, so if you start an exercise there by hand nothing will chase you about it.
Bounded, so a session can always end
At most two blocks per outstanding exercise, counted in a per-session file under $TMPDIR. After the second, the hook goes quiet rather than hang a session on a marker Claude cannot or will not clear — the tree is still broken, but that is your call to make and not a loop you have to escape. Cleaning the tree deletes the counter, so a later exercise starts again with a fresh budget of two. When it does block, it names up to twenty files and says how many more there are.

Worth stating plainly what all of this is not: the guardrail is a check that runs when a session ends, not a sandbox. It is the reason an interrupted exercise gets noticed instead of shipped, and the reason fill exercises are confined to files inside the repository — an edit outside it is never recorded as quiz material, so a hole can never be cut where a repo-scoped check could not see it. Committing before you start an exercise still costs you nothing, and git will show you every hole.

What counts as quiz material

An exclusion list, not an include list. Every file Claude edits inside the repository is material — an edit outside it, a dotfile in $HOME or a file in another project, is never recorded — minus a built-in floor you cannot configure away:

Then minus your own untrackGlobs on top of that floor. The globs are matched with shell pattern matching and split on whitespace internally, so a glob containing a space is not supported.

Pilot's data

Pilot is opt-in and off by default (pilotEnabled: false), because on it reads every prompt you type, in every repository. disabledPaths is honoured for it exactly as it is for the quiz above: a repository listed there is never read for Pilot either, and nothing it writes — a queue line, a scored row, a kept quote — is ever produced from inside one.

Everything it keeps lives under $CFG/learner/, alongside memory.md and recap.md: pilot-queue (sessions not yet scored), pilot.md (the dashboard: sessions, index, manoeuvres), pilot-evidence.md (the quotes behind each score, capped at 200 characters each), pilot-stamps and pilot-devlines (internal pacing and tallying state). pilot forget [--all|--before <date>] purges the kept quotes at any time, without touching the scores in pilot.md. ./uninstall.sh --purge (or learner-uninstall --purge) deletes all of it, along with the rest of $CFG/learner/; the plain form leaves it in place, same as it leaves your quiz progress.

What leaves the machine

Everything above — the quiz, the record it keeps, the coach — stays on this machine. learner sync is the one exception: it is the only path that ever sends your learning record anywhere else, and it does so only when you type learner sync push or learner sync pull yourself. No hook ever calls it; nothing leaves on its own, on any cadence, from any regime.

A push snapshots memory.md, recap.md, libs.md, events.jsonl and your global learner.json into one GitHub gist, created --secret the first time, after asking. A secret gist is unlisted, not access-controlled: GitHub does not check who is asking, only whether they have the URL. Anyone with that URL can read the gist without a GitHub account of their own. Since the snapshot carries your repo names, file names, the wording of your weak spots and the libraries you've been coached on — plus pushedFrom (the machine_name plugin option, if set), learner.json's disabledPaths (absolute local paths) and events.jsonl (the text of every question you were asked and each repo's absolute path) — treat the gist URL the way you would treat a password, and expect anyone it reaches to be able to read everything in it.

Uninstall

./uninstall.sh                            # hooks, skills and wiring; data stays
./uninstall.sh --purge                    # also delete learner.json and learner/
./uninstall.sh --project /path/to/repo    # clean up a legacy per-project install

The plain form is the exact reverse of the install: the fifteen hook files, the skills, and every learner entry in settings.json, dropping any hook event left empty. Your config and your progress survive, so a reinstall picks up where you left off. --purge deletes those too.

None of this touches a gist learner sync may have created: it lives on GitHub, not under ~/.claude, so --purge does not delete it either. If you no longer want the record there, delete the gist yourself from your GitHub account.

--project is for anyone who installed an earlier, per-repository version of Learner: it removes the hooks, the skill, the .claude/settings.json entries and the .gitignore lines from inside one specific repository. Per-repo overrides are not centrally enumerable, so a plain ./uninstall.sh cannot find them for you — delete a stray learner.local.json by hand, or point --project at the repo.

Installed via Homebrew or apt? Run learner-uninstall instead of ./uninstall.sh — same flags, learner-uninstall --purge included. sudo apt remove learner / brew uninstall learner only remove learner-install and learner-uninstall themselves; neither touches ~/.claude. Run learner-uninstall first if you want that cleaned up too — do it before the package step if you can, since afterward the binary that does it is gone.

Both forms refuse to start if settings.json cannot be parsed, and say so, rather than getting half way and leaving the wiring pointing at hooks that no longer exist.

Next: what Learner is, if you came in sideways.