What deploying this taught, and what each lesson became

Every entry is a defect that reached a working system, the measurement that found it, and the guard that now stops it. Nothing here is advice; each one cost something.

1. "The file is there" is not "the system runs"

Four separate bugs, one shape.

  • run.sh resolved profiles as $(dirname $0)/.., right in the repository and wrong once installed beside them. The unit exited 64 and the timer logged nothing.
  • The same for lib/backends, found only because the first fix's test started the script instead of listing files.
  • A clean install left run-instance.sh unexecutable, because chmod ran before the platform dispatch that copies it.
  • cmd_verify shipped having never been executed once.

I had "verified" the deployment by checking that files existed. Every one of these survives that check.

Guard: maintainer doctor runs each entry point rather than stat-ing it, and the test suite installs into a scratch home and asserts executability.

2. cp -r src dst copies into dst when dst exists

A second install nested profiles/profiles/ and backends/backends/. The render step then wrote the new deny wall into the nested copy, and the live agent carried 40 rules while the repository had 72. Silent, and it survived a full test run because the tests read the repository, not the deployment.

Guard: destinations are removed before copying, and a test installs twice and compares the deployed rule count against the repository's.

3. Calibrate a checker before believing it

The first tracker sweep flagged 37 of 37 open issues. That is not a finding about the tracker, it is a broken checker: it matched URLs as file paths and truncated .json to .js through a bad alternation order. After restricting to backticked paths and splitting repo-relative from prose basename from upstream-dependency, five candidates remained and one was real.

A flag rate near 0% or near 100% is a bug in the check.

Guard: sysknife-issue-audit's first section is about the auditor, not the tracker.

4. A probe can measure its own bad pattern

Investigating whether deny rules survive bypassPermissions, the first probe showed four of four commands succeeding, which reads as "containment is a fiction". It was not. Bash(touch /tmp/denied-probe:*) never matched touch /tmp/denied-probe-a, because the matcher is token-based rather than string-prefix. A differential probe with a control then showed deny working exactly as documented.

I was one step from reporting a broken security wall on the strength of a broken probe.

Guard: always run a control alongside the case, and read ground truth from the filesystem rather than from the agent's own summary.

5. A check must not match its own source

The leak check searched the repository for a home path and found one: its own source line. Same shape as the --force check that read the comments explaining why --force is absent, and the merge-gate count that read prose describing the gate as a second implementation.

Guard: assemble the needle at runtime (needle="entro""pia"), exclude the checking file, and strip comments before matching on code.

6. A deny list is spelling-specific, so it is not a boundary

Measured: /usr/bin/touch /tmp/mt-y succeeded against a rule denying touch /tmp/mt-y. Absolute paths, git -C, and compound commands all present a different string to the matcher.

Enumeration cannot be complete. A denylist reliably stops a cooperative agent reaching a destructive verb by accident or persuasion, which is the realistic failure; it does not contain a hostile one.

Guard: absolute-path forms are denied too (40 rules became 72), the README states the bound instead of implying there is none, and where a backend supports default-deny with an allowlist (opencode) that posture is used instead, because an unlisted spelling then falls through to deny rather than to allow.

7. Rootless podman can reuse a namespace it cannot establish

newuidmap is setuid, so NoNewPrivileges and RestrictSUIDSGID stop podman creating a user namespace. Measured: cold plus full hardening failed 10/10; warm plus full hardening succeeded, because podman reused the pause process.

Relying on that accidentally would mean container verification silently vanishes after a reboot, and a merge gate that quietly stops verifying still merges.

Guard: an unhardened warm-up unit running one fixed command establishes the namespace; the maintainer unit is ordered after it and stays hardened. Verified from cold 3/3 with the ordering, failing without.

8. Enabling or restarting a timer fires its missed slots at once

Persistent=true treats "never run" as a missed slot. Editing a cadence and restarting launched a run on top of an interactive session that was mid-review, and two agents posting to the same pull requests is a real duplicate-comment risk.

Guard: the installer writes ~/.local/share/systemd/timers/stamp-<unit> before enabling, and doctor reports any timer whose next run is suspiciously close to now.

9. The GitHub identity can change under a long-running session

gh's active account flipped to a different account twice in one session, rewriting user: in ~/.config/gh/hosts.yml. A 403 is the lucky outcome; a review posted under the wrong identity is the unlucky one.

Guard: run.sh switches, reads the login back, and exits with a desktop alert if it is not the configured account. Mutation-proved in three directions, including a broken gh returning nothing.

10. Never put a GitHub comment body in a double-quoted shell string

Bash executed the backticked SHA, so it vanished from a posted comment and the sentence lost its subject. The post still reported success; only stderr said 083d582: command not found.

Guard: quoted heredoc and --body-file, always. Read back the first lines of anything posted.

11. A dry run cannot see a glob that matches nothing

./install.sh --timers globbed $root/systemd/*.timer. This repository has never had a systemd/ directory; the units live under platform/linux. With nullglob off the loop ran once on the literal pattern, and systemctl --user enable --now '*.timer' aborted the install under set -e having enabled nothing.

--dry-run printed would: systemctl --user enable --now *.timer and exited 0, because a dry run prints commands instead of running them. The documented Linux install path was broken and every dry run said it was fine. It only worked here because the timers had been enabled by hand months earlier.

Guard: the suite runs the real installer against a stub systemctl and counts what it enabled against the number of .timer files in the tree. Point the glob back at $root/systemd and the suite goes red.

12. A prompt that names a renamed command gets a verdict anyway

The preamble told every unattended run, in bold, to call sysknife-maint screen <pr> before building a pull request. That command was renamed to maintainer screen and sysknife-maint no longer exists anywhere on PATH. The screen is the control that stops contributor code executing on a machine holding SSH keys.

The 2026-09-03 review report reads sysknife-maint screen 348 -> DO NOT EXECUTE. Either the agent ran the real command and copied the prompt's dead name into the report, or it ran nothing and wrote a plausible verdict. The audit trail could not distinguish those, which is the deeper defect.

Guard, two of them. profile.env declares REQUIRED_COMMANDS and KNOWN_NAMES; a test extracts every hyphenated backticked name from the assembled prompt of every task and fails on anything undeclared, and fails again on a declaration no prompt uses so the list cannot be padded quiet. maintainer-doctor checks each required command resolves. Separately, the Claude backend now runs with --output-format stream-json and writes every command the agent actually ran to a .commands file beside the log. A report is a claim; the transcript is the record.

13. local a=X b=${!a} expands before it assigns

The cadence gate opened with

local var="MIN_HOURS_$task" min="${!var:-0}"

local is a builtin, so bash expands every one of its arguments before the builtin assigns any of them. ${!var} therefore resolved against an unset name and bash printed var: invalid indirect expansion. Without set -e the function fell straight through, and every task ran regardless of its interval.

The test that caught it was the one asserting a two-hour-old task gets skipped. The two tests either side of it passed, for the wrong reason.

Guard: the gate is asserted in three directions, including the negative one, and the fix is two statements rather than one.

14. A leading slash in a Read rule points somewhere else entirely

Read(//home/you/.ssh/**) looks like a typo for Read(/home/you/.ssh/**). It is not. Claude Code resolves a single leading slash against the settings file's own directory; only // means the filesystem root, and ~/ means the home directory.

Measured with a control: Read(/tmp/x/dbl.txt) let the file through, while the // and ~/ forms both denied it. Normalising the double slash away, which is exactly what a tidy-up commit does, would have disabled all seven credential rules at once and left the count unchanged.

Guard: the wall is generated in the ~/ form, a test asserts that form survives, and the generator refuses to write a wall containing no Read(~/ rule at all.

15. Prose can claim a cadence that no code enforces

install-launchd.sh said the every-N-days cadence was "enforced by the since-last-run state". Nothing enforced it. maintainer start computes what changed since the last run and puts it in the prompt; it never declines to run. launchd cannot express a multi-day interval, so on macOS the five-day tracker audit would have run every single day, and the README repeated the claim.

Guard: MIN_HOURS_<task> in profile.env, enforced in run.sh on every platform, with the skip printed and the override named. Every scheduler now fires daily and the gate decides, which also removes cron's day-of-month stepping firing on the 31st and again on the 1st.

16. A preview is not free if it opens a file

--show-prompt was added so a maintainer can read the exact prompt before trusting the agent. It skips the identity gate, the lock and the refresh, and it writes no report. It still opened a log file, because the log path was resolved before the branch that decides whether this is a real run.

The suite calls it once per task in several places. One test run left 22 empty logs in the live audit trail, dated as if runs had happened. An audit trail that records runs which did not happen is as wrong as one that misses runs which did.

Guard: a preview logs to /dev/null, the suite exports its own MAINTAINER_STATE_DIR, and a test runs --show-prompt against an empty state directory and asserts nothing was created. Put the log back and it goes red.

17. A graceful fallback can hide the thing it falls back from

The Claude backend runs --output-format stream-json through scripts/transcript.py, and falls back to plain text when the filter is missing, so a parser problem can never cost a run its output. Sound reasoning.

install.sh copied render-settings.py by name and never copied transcript.py. The deployed agent therefore took the fallback on every run. Runs kept succeeding, reports kept appearing, and the .commands file that lesson 12 exists to produce was never written once. The only visible symptom was a file that was not there.

Found by running backend_run directly against a two-line prompt rather than by reading the code, which is lesson 1 in a new costume: the file existed in the repository, the tests read the repository, and the deployment had neither.

Guard: install.sh deploys scripts/*.py as a glob rather than by name, a test asserts every shipped script reaches the deployed tree, and the fallback now writes a line into the log saying the transcript is unavailable. A fallback that stays quiet is a fallback nobody will notice taking.

18. Scaffolding a second profile is the only test of the scaffolding

new-profile.sh passed seven tests: it substitutes every placeholder, refuses when one survives, writes one timer per task, refuses to overwrite, and produces a profile that assembles a prompt naming its own repository. Then I pointed it at this repository, and three defects fell out in five minutes.

  • maintainer status mixed two profiles. It read the profile name and the POST setting from the deployed profile.env, and took the repository path, the state directory and the task list from module-level defaults. The second profile's header sat above the first profile's repository and run history. An adopter would have read another project's runs as their own.
  • The scaffolder wrote an absolute home path into profile.env, a file meant to be committed. This repository's own leak check went red on the first scaffold. For an adopter it would have published their username.
  • The closing instructions named a command that no longer existed, telling the adopter to call run.sh by its deployed path after maintainer run had replaced it. Lesson 12 in the documentation this time rather than in a prompt.

None of the seven passing tests could see any of it, because each one asks whether the script did what it was written to do. Only using the output as an adopter asks whether what it was written to do is enough.

Guard: every setting resolves from the deployed profile with the environment taking precedence, paths under $HOME are written relative, the prompt declaration check runs for every profile rather than the first one, and a test asserts the closing instructions name a command that exists.

19. Eight defects the agent found by reviewing its own repository

Issue #8 said pointing the agent at this repository was the only honest test of the onboarding. One rehearsal run, 487 tool calls, POST=off so it published nothing. Every finding below was reproduced by hand before it was believed.

The wall enumerated three directories and missed the ones that mattered. /bin, /usr/bin, /usr/local/bin. On this machine cargo is in ~/.cargo/bin, npm under ~/.local/lib/nodejs/…/bin, maintainer-merge in ~/.local/bin. So cargo publish and npm publish had no absolute-path rule, and maintainer-merge receipt was protected by exactly one bare-name rule guarding a binary that lives where the wall does not look. Writing the full path forged a receipt, which is the one thing the merge gate exists to prevent. Measured after the fix: the agent reports the command "never ran" and no receipt file appears. On Apple Silicon the same gap would have made POST=off post, because Homebrew puts gh in /opt/homebrew/bin.

The bare list had no stated criterion, so verbs were sorted into it by guesswork and got one spelling each. gh repo delete, gh repo edit, gh secret and gh pr create were all in it, and the preamble promises the agent cannot delete anything, change repository settings, or open a pull request elsewhere. There is one list now.

A profile name reached bash -c unquoted. MAINTAINER_PROFILE set to pwn"; touch /tmp/INJECTED; :", with a matching directory, ran the touch. path.exists() guarded nothing, because whoever sets the variable can create the directory. Arbitrary shell inside a subprocess, where no deny rule is evaluated.

POST=off never reached the tools. run.sh set POST and did not export it, and maintainer-repo prune pushes branch deletions from inside a script, which no Bash deny rule can see. A rehearsal on a repository with merged branches would have deleted them remotely while reporting it reached nobody.

The eval gate was red for every profile except the first. The corpus was already per-profile; the scenario-to-rule map was global, so magent inherited an assertion about sysknife's TWiR label. The hook never saw it because git runs hooks with the ambient environment and the hook took the default profile. The map now lives in profiles/<name>/evals.json, and a scenario with no entry fails: retiring one takes an explicit "n/a" with a reason.

A second profile's containment was unguarded. Deleting a rule from profiles/magent/deny.json left all three gates green, because the suite named profiles/sysknife in seventeen places.

The helper named one repository in output every profile reads. The run header said sysknife-maint, the closing line named sysknife-maint finish after that command was renamed away, and a profile with no SKILL_<task> was told to load sysknife-review, a skill written for another project's gates. Inventing a skill name is worse than admitting there is none.

finish threw away the report that found all of this. The check for the placeholder sentinel searched the whole file, and the report quoted the sentinel while writing up a finding about it. A finished 262-line report was rejected as "no report", the baseline never promoted, and a critical alert fired. Same shape as §5, in the guard that decides whether a run is auditable. It anchors to line one now, which is where start writes it.

The through-line: code written when there was one profile generalised its corpus without generalising its assertions, and a wall written from an FHS mental model never asked where the binaries were. Neither is visible to a suite that passes.

20. A published number must be derivable the same way twice

Making the deny wall adapt to the machine fixed a real hole and broke a claim. The generator resolves each verb with which, so the rule total depends on what is installed where it runs. check_claims.sh read 998, then 1030 on the same tree an hour later, because installing the maintainer commands added a directory to spell verbs from. The commit was refused by the gate that holds the README to the tree, which is the gate working.

The fix is not a looser check. The README now pins the verb count, which is what a person writes into deny.json, and prints the rule count as an observation of this machine. A test separately asserts that every verb is denied at the directory which actually finds it in, which is the property that matters and does not move.

Guard: claim the input, observe the output, and test the invariant that connects them.

21. The second run found what the first one's fixes introduced

Running the agent against its own repository a second time, on the commit that fixed the first eight findings, produced nine more. Three came from that commit; the rest had been there longer and nothing had looked.

A rehearsal could still merge. maintainer-repo prune was taught to honour POST=off, and maintainer-merge was not: gh pr merge --squash --delete-branch runs inside the script, where no deny rule sees it. The reasoning that fixed prune was written into the commit message and not applied to the file next to it.

MAINTAINER_FORCE=1 reached the agent's environment, so every run.sh the agent invoked skipped its cadence gate. A reproduction that should have printed skipped started a real pass against another repository's checkout instead. Nothing forbade the nesting either: a rehearsal could launch a full run of a posting profile, and that it stayed a rehearsal was luck, because MAINTAINER_POST happened to be inherited too. Runs now export MAINTAINER_IN_RUN and refuse to start inside another, and the override is unset the moment it is consumed.

The suite inherited the run's environment and reported two vacuous passes. Invoked from inside a run it gave 236 passed / 6 failed, with the identity-gate cases never reaching the identity gate. Worse, a task past its minimum interval proceeds and MAINTAINER_FORCE overrides the gate both PASSED because force was on. A suite whose answer depends on who called it is not a measurement; it clears every MAINTAINER_* variable before measuring anything now.

check_claims.sh read the suite's output and not its status, so a red suite surfaced as README says 242 tests, the tree has 236. It sent a reader to the README instead of to six failures, and only noticed at all by arithmetic accident.

The "n/a" hatch could retire the whole eval suite. Omission was guarded and declaration was not: marking all seven scenarios not-applicable left the gate reporting seven passed. Five assert doctrine from lib/preamble-core.md, which every profile receives, so they are not a profile's to retire. Only 05-reserved-issue is.

The context block answered a question nobody asked. main <before> -> <after> reports what a run's own fetch pulled in, and reads (already current) when a commit was made on this machine rather than fetched. snapshot() had always written main_sha and delta() never read it, so the field that answers "what have I not reviewed" was written and unused. A run was told the tree was current while an entire unreviewed commit sat in front of it, and the prompt told it to trust that line.

Three guards had no test at all: prune's rehearsal check, the profile-name refusal, and the merge gate's. Each was mutated with all three gates left green.

The template taught the retired format, and the receipt guard in render-settings.py was keyed on which("maintainer-merge"), which returns None on a first install: the check meant to keep the merge receipt unforgeable was inert exactly when the wall is first written.

I also destroyed this work once with git checkout . and had to redo it, which is the trap the mutation-harness entry in this file already describes. Commit before running an experiment that touches the tree, including the experiments that verify the commit.

22. shellcheck found a condition the merge gate had been ignoring for weeks

Adding CI meant running shellcheck for the first time, which produced nine warnings. Seven were dead variables and fragile loops. One was not.

bin/maintainer-merge:162
state="$(gh pr view "$pr" --json mergeStateStatus --jq .mergeStateStatus)"
^---^ SC2034: state appears unused

The gate made the API call, took the answer, and never read it. So a pull request that was BEHIND its base merged happily: its checks were green against a tree that is not the one being merged, which is precisely the failure this gate exists to prevent. DIRTY and BLOCKED passed too.

An unused variable is usually tidy-up. This one was a missing condition, and a linter found it because a linter reads what the author meant to use rather than what the author remembers writing.

Guard: only CLEAN and HAS_HOOKS may merge, with five tests driving the gate through each status. Deleting the check turns four of them red.

23. The first CI run found three tests that were measuring my laptop

Adding GitHub Actions was meant to give contributors the same gates. Its first run went red on three cases that had been green here for a day:

FAIL  status does not report task
FAIL  status does not report review
FAIL  status does not show when the next run is

maintainer status was correct on the runner. It said there was no deployment and no scheduler, because there was neither. The tests asserted against whatever profile happened to be installed in my home directory and whatever timers happened to be registered, so they measured the machine rather than the code. Locally they could not fail; anywhere else they could not pass.

Guard: those cases now build a profile, a state directory, a report and a stubbed systemctl inside the suite's scratch directory, and the whole suite is expected to pass under

env -i HOME="$(mktemp -d)" PATH=/usr/bin:/bin bash ./tests/run-tests.sh

which it does. CI is the only place that check was ever going to come from, which is an argument for CI that has nothing to do with contributors.

24. The claim check read a number out of a commit SHA

scripts/check_claims.sh held the README's figures to the tree by grepping for [0-9]+ (offline )?tests and taking the first match. A new example in the README read

maintainer-merge verify 365 7c6ed388 tests/e2e/story-metadata.test.sh

and the checker found 388 tests inside the commit SHA, then refused the commit because the tree has 313. The number it complained about had never been written by anyone.

Two defects in one line, and the second is worse. Taking the first match means a second, stale copy of a figure further down the file is never checked at all: the check would have passed happily with the correct number at the top and a wrong one below it.

Guard: the number may not be the tail of a longer token, every occurrence is collected rather than the first, and figures that disagree with each other fail before either is compared to the tree. Adding a second, conflicting mention turns it red naming both.

This is the third time a check in this repository has passed or failed for a reason unrelated to what it was checking. The other two are in §5.

25. The receipt-invalidation check named one project's directories

maintainer-merge decided whether production code had moved under a receipt by diffing against a fixed list: crates/*/src/*, apps/*/src/*, packaging/*, .github/*, */Cargo.toml, Cargo.lock. Those are sysknife's directories.

On any other repository they match almost nothing. Measured on this one: of the files in the last three commits, exactly one was on the list. A pull request could earn a receipt at one head, push a rewritten bin/maintainer-merge at the next, and the gate would report

head moved …, no production diff; receipt still applies

That is a merge against a receipt describing a tree that is gone, which is the single failure the receipt exists to prevent. Same shape as the Rust-only verify in §22: a central guarantee that silently only worked for the project it was written against.

maintainer-repo release-check had the same list and therefore reported "docs and tests only" for a release containing fourteen changed production files.

Guard: PROD_GLOBS comes from the profile, there is no default, and a profile that declares none is refused a merge. Guessing which paths are production is how the original bug worked. Two tests drive a real repository: a docs-only move keeps the receipt, a rewrite of bin/ kills it.

Restoring the hardcoded list turns both red. It also turned twelve older tests red, because they had never declared production paths and the gate now refuses without them, which is the fail-closed behaviour finding under-specified tests.

26. A guard that refuses the repository's own layout

The gate refused every symlink in the extracted tree, on the grounds that sed -i follows one off the host. Run against a real pull request it refused docs/images/social-preview.png -> ../../assets/social-preview.png: a link that had been on main for months, that the pull request did not touch, and that resolves two directories away from where it sits. The message blamed the contributor for the repository's layout.

The exploit is real and was reproduced end to end, but the property that matters is whether the link leaves the tree, and the mutation step had already been narrowed to find -type f, which never matches a symlink at all. Now the scan reports only links whose target resolves outside the extracted root, and the two tests that covered it were grep -q 'ships symlink' bin/maintainer-merge, which passes for any file containing that phrase. They drive the function.

Measure the property, not the shape. A guard written against the shape of an attack refuses the shape wherever it appears, including in the tree it is supposed to protect.

27. The container runtime wrote the evidence

podman prints time="..." level=warning msg="Error validating CNI config file ..." on every run on this machine, onto the same stderr the container uses. That one line did two things to the receipt for a live pull request. It was the first line matching the failure grep, so observed_failure recorded a CNI warning where the proof belongs. And it is bytes, so the shell suite's suite_ran check, whose whole job is to refuse a receipt for a run that executed nothing, counted it as evidence that something ran.

A missing optional function did the same thing louder: suite_podman_args: command not found, ten times, inflating units_run from 2 to 10.

A log read as evidence must contain only the thing being measured. The runtime now runs at --log-level=error, the extractor drops logfmt lines, and when nothing matches the failure pattern it falls back to the last line printed rather than recording an empty string.

28. The warning that had never once printed

release-check reads the CHANGELOG's Unreleased section and warns when it is empty, because the verdict then rests on file counts alone. The extraction was sed -n '/^## \[Unreleased\]/,/^## \[[0-9]/p', and sed prints both delimiters. An empty section therefore came back as two heading lines, [ -z "$unrel" ] was false, and the warning had never printed in the life of the tool.

It was found by using it: cutting this project's own 0.2.0, the check reported removed-capability wording: 0 occurrence(s) and digit: last for a release whose Removed section has six entries, and said nothing about having read an empty section. The two tests covering this were greps for a phrase in the source of bin/maintainer-repo, so both passed throughout.

A guard whose input is never empty in the author's tests is a guard that has never run. Build the empty case as a fixture, not as an assertion about the source.

29. Run your own suite somewhere that is not your laptop

The offline suite was green here and had been for weeks. Run inside python:3.12 it reported eight failures, and only one of them was about the container:

  • Five said jq: command not found. The merge gate shelled out to jq in three places and nothing else in the project needs it. The refusal that followed read #7 has failing check(s), with a blank where the number goes: it failed closed and named the wrong thing. The counts are python3 now, which the gate already requires everywhere.
  • Two came from one bug in bin/maintainer. _next_runs called systemctl by name with no guard, so maintainer status printed three lines and died with an uncaught FileNotFoundError on any host without systemd. This project documents four schedulers and three of them are not systemd.
  • One was real: the shell verify suite needs a container runtime, and there is none inside a container.

That last one had been written as a failure, so the suite could never be green on a machine without podman. Writing it as a pass would be worse: entry 24 in this file is a missing optional dependency that turned a gate green over seven real errors. It is a third count now, SKIP, printed in the summary, and CI fails when it is not zero.

Two cases also made the suite's own size machine-dependent, which matters because check_claims.sh compares that number to the README. One reported a PASS for a PowerShell parse it had skipped, and one emitted a single failure line where the branch it replaced ran two cases. Host and container now agree on 396.

A suite that has only ever run in one place is measuring that place.

30. The health check read the other project's health

maintainer-doctor asked systemd for maintainer@* and counted what came back. Run as MAINTAINER_PROFILE=magent it counted sysknife's four timers and printed 4 systemd timer(s) registered, ok, while magent's two timer files sat disabled and had never fired once.

This is the third time this shape has appeared here. bin/maintainer defaulted MAINTAINER_PROFILE to a hardcoded "sysknife", so a second profile printed its own header above the first profile's run history. The merge gate's PROD_GLOBS were one project's directories, so on any other repository the production-diff rule compared nothing. Now the doctor.

systemctl list-timers shows loaded units, so a disabled timer is absent from it rather than listed as off. That is why the wrong glob read as healthy rather than as a contradiction: the four that answered were real, they were simply somebody else's. Counting the unit files separately is what distinguishes "no timer installed" from "installed and never enabled", and those need different fixes from the reader.

Anything that takes a profile must ask for that profile by name, every time. A per-profile tool with a wildcard query is a tool that reports on whoever answers first.

31. Three checks in one day matched their own documentation

Each of these was a text search over a file that also describes what it searches for.

  • grep -q 'secrets\.' ci.yml enforces "CI reads no repository secret". Writing the words secrets.GITHUB_TOKEN in the comment that explains the policy made the check fail.
  • grep -nE '\bjq\b' bin/maintainer-merge enforces "the gate needs no jq". The paragraph explaining why jq was removed matched it.
  • grep -q 'skipped' suite.log enforces "CI must run every case". The name of the test that proves skips are counted, and the summary line names the skipped count, matched it, so a run with zero skips failed CI for containing the word.

All three passed their own tests, because their tests fed them the failing input and never the documented input.

A grep-based check has to say what part of the file is in scope. Strip comments, anchor on a line shape, or read a structured field. A bare search over a file that talks about itself will eventually match the sentence explaining it.

32. The number was true and useless

A full review of a Rust workspace was recorded as [tokens in=48 out=17365]. Forty-eight input tokens for a run that read a repository.

input_tokens is real, and in an agent run it is nearly nothing. The volume lives in two other fields: cache_creation_input_tokens, charged above input, and cache_read_input_tokens, charged below it. The same run's real shape was in=108 cache_write=412k cache_read=9.85M. Logging one of the three and calling it "in" was worse than logging nothing, because a reader treats it as a measurement.

Three more things the usage record gets wrong if taken naively, all of them documented and none of them guessable:

  • usage excludes subagent tokens. total_cost_usd and modelUsage include them, so the per-model map is the honest total the moment anything nests.
  • total_cost_usd is a client-side estimate from a price table bundled with the CLI, not billing truth. Every surface that prints it has to say so.
  • A failed run carries usage too, and a crashed one may carry it zeroed. A run that spent four dollars and then died must not be filed as having spent nothing.

And the tempting shortcut is wrong twice over. tiktoken is OpenAI's tokenizer; it undercounts Claude by 15-20% on prose and by 1.57x to 2.08x on code, and Anthropic publishes none. But counting is the wrong move regardless: the exact billed figures are already in the stream, and re-deriving them from the text would replace a fact with an estimate.

Before summing a field, find out what the other fields are. A partial measurement wearing a total's name outlives the person who wrote it.

33. Speaking only to complain

The agent had one voice: notify-send on failure, carrying the raw shell error. What reached the desktop was a message written in the vocabulary of the thing that broke, two thirds of it an absolute path, with no next step. A successful run said nothing at all, so every popup this project ever produced was bad news.

Both halves were the same mistake. The notification was built from what was convenient to pass along rather than from what a person needs at the moment they glance at a corner of the screen: what ran, whether it worked, what to do, and what it cost.

The failure path now translates the errors it recognises into a sentence and a fix, and passes anything else through unchanged, because a wrong translation is worse than an untranslated string. The success path sends a three-line digest built only from things the run measured: its own report length, its own command record, and the usage the model reported. An earlier sketch parsed the report's prose for "3 PRs reviewed", which is the agent's claim about itself with a summary's authority.

A notification is a user interface. It gets the same scrutiny as any other, and the first question is what the reader does next.

34. Two real findings, two wrong consequences

Two sonnet subagents reviewed this project's work on the same afternoon. Both found something real. Both attached it to a conclusion that was wrong, and both conclusions were the part that would have been published.

The first reported a false pass in a release gate: strip the version field from an internal path dependency and the check reports "14 internal dependency pins checked" instead of 15, exit 0, no complaint. Reproduced exactly. Its stated consequence was that this is "the bug the check's own comment says it exists to catch", which would publish a crate depending on the wrong version. Running cargo publish --dry-run settles it:

error: all dependencies must have a version requirement specified when
publishing. dependency `sysknife-core` does not specify a version

Cargo refuses. The finding survives, the severity does not, and the real defect is a different one: the gate cannot tell "15 pins, all correct" from "14 pins, all correct, and one that vanished from view".

The second reported a merge with no entry anywhere in the audit trail, and asked whether an unlogged agent instance had done it. lib/run.sh opens its log file before it does anything else, so a run that existed left a log even if it died in the first second. There is no log within forty-five minutes of that merge. The supporting evidence it cited, "hard config failures at 15:57 and 15:58", turned out to be from the following day.

Neither agent was careless. Both were thorough enough to produce evidence I could check, which is what made checking cheap. The failure mode is narrower than "subagents are unreliable": the finding is where the work went, and the consequence is written last, quickly, in the confident register of a summary.

Verify the consequence separately from the finding, and with a different command. The write-up arrives in your own voice and reads like something you already did.

35. The index was the one document that stayed quiet

Nine runs, audited against the repository they acted on. Six of the reports were accurate on every checkable fact, and the two that also had a command record were accurate at command level. Nothing was fabricated anywhere.

The problem was the runs with no report at all.

logs/2026-09-03T07-52-review.log is twenty-two lines: the task briefing, then nothing. No report was written. In the hour after that log was opened, fifteen comments went out under the account the agent uses. The trail cannot say whether the agent posted them or a human did, because the account is shared and the transcript was empty. Reading index.md, that run does not exist.

That is the shape. logs/ knew, runs/ knew, GitHub knew, and the one document a reader actually consults said nothing, because absence was represented by absence. A gap in an append-only index is indistinguishable from a quiet week.

Two smaller versions of the same thing sat beside it. A report reading baseline promotion test was indexed like a genuine run, because the sentinel check that exists for exactly this only covered the placeholder the tooling writes, not one a developer types. And seven runs had no .commands file, so "the transcript recorded nothing" read identically to "no transcript was ever kept".

One property saved the audit, and it was undocumented: lib/run.sh opens its log before it does anything else, so a run that existed left a log even if it died in its first second. That is what established a merge with no index entry had not come from any run, because the nearest log that day was forty-five minutes away. A load-bearing invariant nobody had written down.

An audit trail has to record absence explicitly. Silence is not a value, and every reader will fill it in with the most comfortable story.

36. The agent turned off its own scheduler, and the other project's

Six hours after the self-maintenance timers went on, the magent profile reviewed this repository at POST=off and ran ./install.sh --uninstall against the real HOME. It was root-causing a genuinely failing uninstall test, its analysis was correct, and it reported what it did honestly.

It also disabled all six timers, including the four that maintain a different project entirely, and then died under set -e at the cron removal step before removing a single file. Scheduler off, files present, nothing said. Three and a half hours passed before anyone noticed, and only because someone asked an unrelated question about the next scheduled run.

Three separate things had to be true at once.

The rehearsal wall is about GitHub. POST=off blocks every posting verb and has nothing to say about systemctl, install.sh, or anything else on the machine. It reads like a general safety mode and is a specific one.

--uninstall was destructive with no confirmation. A verb that stops every profile on the host, available to anything that can run a shell, guarded by nothing.

It could not finish, and left the worst state. command -v crontab && [ -x ... ] && run ... is an && list, so a crontab that exists but cannot write killed the script under set -e after the timers were off and before any file was removed. Either finished state would have been survivable. The middle one was silent.

docs/plan.md had named the risk in the abstract when those timers were enabled: an agent that reviews its own repository can change the rules it is reviewed under. Naming a risk is not mitigating it, and the concrete form was not the one the sentence had in mind.

What worked, and is why this is a story about hours: the run left a .commands record and a usage file, so its own actions were readable in minutes. The transcript work from the same morning paid for itself the first time it was needed.

A destructive verb needs a guard that does not depend on who is calling it. An agent that can run a shell can run anything the shell can, and the deny list enumerates what someone thought of.

37. The label was a promise nothing kept

A contributor commented "I am taking this" on sysknife#355. The maintainer replied "it is yours", applied the claimed label, and said in the same comment that the label "is what the other contributors read". Ten hours later a different contributor opened a pull request closing that issue. The review approved it, the merge gate merged it, and the person who claimed it had nothing to show for a day's work.

Every part of that system worked as built. The label was applied. The reply was warm and specific. The review checked the diff, traced it to a live path, and mutation-proved both guards. The gate checked the review state, the board, the merge state, the head, and the production diff. Not one of them knew the issue had been promised to somebody.

The failure is a category error about what a label is for. claimed was treated as documentation, a note for humans to read. It was actually a commitment, and the sentence that made it one was published in the same breath: other contributors read that label and stayed off the issue. A promise that only some of your tooling can see is a promise you will break by accident.

It is in the merge gate now, beside the receipt and the production diff, because that is the last place before the irreversible act and the only one that cannot be talked past. A pull request closing an issue that carries the claim label, whose author has never posted on it, is refused with both names in the message.

Anything a project publishes as a commitment has to be enforced where the commitment can be broken. Not where it is convenient to check, and not by asking a reviewer to remember.

38. The check said no and the operation said yes

Five contributors held claimed issues with no GitHub assignee. Before assigning them I asked GitHub whether I could:

$ gh api repos/lacs-project/sysknife/assignees/Osheun
(404)

Five for five, the same answer. The conclusion was sitting there and it was wrong: that GitHub structurally forbids assigning outside contributors, that the label is the only mechanism available, and that the whole request was impossible. I was one sentence from reporting it.

Then I tried the operation instead of the question:

$ gh api -X POST repos/lacs-project/sysknife/issues/336/assignees \
    -f 'assignees[]=Osheun'
{"assignees":["Osheun"]}

It works for anyone who has commented on the issue. /assignees/{user} answers "is this a collaborator", which is a different question from "can this person be assigned here", and the endpoint name does not say so.

Two more things fell out of doing it.

maintainer claims first measured staleness from the issue's updatedAt, so assigning five claims reset all five to 0d, active — including one eleven days old. The act of recording the claim blinded the check that watches it. Idle now comes from the claimant's own last comment, because anything the maintainer touches moves updatedAt.

And cmd_claims shipped reporting "this profile declares no CLAIM_LABEL" against a profile declaring it on line 74: _profile_env sources the file and reads back a literal tuple of key names, and the new key was not in it. A key the code reads and the tuple omits comes back empty, which is indistinguishable from unset. There is a test now that cross-checks every key read against the tuple.

A capability check is a claim about the world; the operation is the world. When they are cheap and reversible, run the operation.

39. A release and an offer are the same sentence

maintainer offers shipped, and an hour later I used it on the tracker it was built for. It reported atanishka308 no: working on #272 about an issue I had released from them twenty minutes earlier, and it held two released issues out of the free pool.

The tool counted an offer as "a comment by the maintainer mentioning this person on an open issue". A release is also a comment by the maintainer mentioning that person on that open issue. Structurally they are identical: same author, same mention, same thread, and in both cases the contributor has not replied. No amount of care in the prose separates them, because the difference is intent and intent is not a field.

So the release carries a marker, <!-- maintainer: claim-released -->, invisible in the rendered comment and unambiguous to anything reading the thread. The last maintainer comment mentioning a person decides their state on that issue.

Two things this is really about.

The tool was found wrong by being used, not by being tested. Its own suite was green and its cases were reasonable; none of them had a release in it, because I had not yet made one when I wrote them.

And the fix is a convention, not a heuristic. The tempting version was to match on the wording of a release, which works until somebody phrases one differently, and fails silently when they do. A marker the writer must add is worse ergonomics and cannot be wrong.

40. The test measured the laptop that wrote it

v0.4.0 was tagged, pushed, and the release workflow failed on four cases the local suite had reported green minutes earlier:

FAIL  magent has no cursor wall
FAIL  sysknife has no cursor wall

The test read $HOME/.local/share/maintainer/profiles/<p>/cursor/cli-config.json — the deployed tree. That file exists on the machine that ran ./install.sh and on no CI runner. The suite was not measuring the repository; it was measuring this laptop, and the repository happened to be nearby.

The deny-wall cases fifty lines above it get this right: they render into a scratch directory with a fake home, because a wall read out of a deployment is a wall somebody already installed. The new test read the artifact instead of building it, which is the shorter path and the wrong one.

Two things worth keeping from it.

The check is one line. HOME="$(mktemp -d)" ./tests/run-tests.sh reproduces a fresh machine exactly, catches the entire class, and takes seconds. It is a CI step now, run before the ordinary one, so the failure names the cause rather than appearing as an unrelated test going red.

And the tag went out first. The release workflow runs the gates on the tag, which is the right design and it ran too late to stop me: I had already announced the release. A gate that runs after the irreversible-looking step protects the artifact and not the person. Nothing was published, because the publish job is gated on those gates, and that is the part of the design that worked.

41. Five warnings, one habit, nothing invented

maintainer audit on the first unattended run after it shipped:

! 2026-09-04T21-17-review    5 of 12 quoted command(s) are not in the record
      claimed, never run:  sed -n '322,324p' .github/workflows/ci.yml
      claimed, never run:  gh issue view 248 ... | grep -c 'claim-released'

Five of twelve is the flag rate of a broken checker, so the checker was the first suspect. It was right. The transcript held sed -n '318,345p' and a for n in 248 272; do ... done: the run had read a wider range and narrowed it for the reader, and unrolled a loop into its two cases. Every fact in the report came from output the run really saw. It ran a superset of what it quoted.

Nothing was fabricated, and the habit still costs something. A checker whose warnings are almost always tidying is a checker people stop reading, and the one real fabrication it would ever catch arrives in that same pile. The value of an audit is its signal-to-noise, and the noise here was generated by the thing being audited, for the reader's benefit.

The fix is in the doctrine rather than in the extractor. Loosening the matcher to accept a narrowed range would also accept a narrowed range that was never run, which is the case the tool exists for. So the rule is on the writer: quote the command that ran, character for character, then explain which part of the output matters.

Prefer the strict checker and the stricter habit. A tool taught to accept approximations cannot tell you when one is wrong.

42. The doctrine pointed at a line nobody wrote

preamble-core.md has said this since the audit trail existed:

Open the report with the version named in the first line of this prompt, so a reader six weeks from now knows which build wrote it.

No version was ever in the prompt. run.sh read VERSION, exported MAINTAINER_VERSION, wrote it into the log, and assembled the prompt out of the profile preamble, the doctrine, the prose rules and the task. The stamp reached the logfile and never reached the model.

An instruction pointing at absent data does not fail; it gets satisfied from somewhere else. The run at 2026-09-04T21-17-review opened its report with

`maintainer v0.3.0` (commit `10a7e79`).

while running v0.4.0 at 111592c. The skill tells a run to read the last few reports before starting, so the only version in reach was the previous run's, and it carried it forward. Three consecutive reports now name a build that did not write them.

A wrong stamp is worse than a missing one. A missing stamp makes you go and look; a wrong stamp sends an audit to code that never ran, and it looks like provenance while it does it.

The prompt now opens with a provenance line and one sentence naming where the version must not come from, because the failure was not ignorance of the rule. The run followed the rule. It filled the gap the rule left.

Whenever doctrine says "the X named in Y", grep Y for X. The assembled prompt is the only Y that counts, and --show-prompt prints it.

43. The check that lied and the check that repeated it

gh issue edit --add-assignee atanishka308 on #272:

failed to update https://github.com/lacs-project/sysknife/issues/272: 'atanishka308' not found

The account exists, has 236217646 for an id, and had commented on that issue eleven minutes earlier. gh issue edit resolves a login through GraphQL against the repository's assignable users first, and an outside contributor is not one. The REST call assigned them on the next line:

$ gh api -X POST repos/lacs-project/sysknife/issues/272/assignees -f 'assignees[]=atanishka308'
["atanishka308"]

Lesson 33 recorded the same shape for GET /assignees/{user}, which 404s for every outside contributor while the POST succeeds, and the doctrine carried the working call from that day on. The sysknife task prompt kept prescribing gh issue edit --add-assignee anyway. Two files disagreed, and the task prompt is assembled last, so the losing instruction was the one closest to the work.

Worse than the failure: the label went on and the assignment did not, and the comment posted alongside them said both had. A partial write with a confident sentence over it is how a promise gets broken in public.

Fixing the doctrine is half the fix. Grep every prompt for the verb you just retired, and order the calls so the reversible one goes second.

44. Thirteen runs that left no trace, and the audit walked past all of them

maintainer audit --all iterated runs/*.md. A run that dies before writing a report leaves a log and no report, so the audit could not see it: the command whose entire job is to say what happened enumerated only the runs that had already said what happened.

The real trail held thirteen. Most were development invocations, and one was not. On 2026-09-04 at 08:14 the alert fired:

ALERT sysknife-review: the run unit failed before it could report; systemd killed or refused it

Nothing downstream of that alert ever mentioned it again. index.md showed a review at 08-04 and the next at 12-56, which is a normal-looking cadence.

This is lessons 22's shape at a different scale. Absence is not self-reporting. A gap in an audit trail reads as a quiet period, and a quiet period is exactly what a suppressed failure looks like.

audit --all now walks logs/ as well and names every log with no report. Auditing one run by name still audits that one run, because a sweep's findings leaking into a single-run check is how a tool becomes noise.

45. The most important refusal had a test that never reached it

tests/run-tests.sh opened this block with a comment naming the stakes:

The gate is the single most important refusal: a write under the wrong account stamps an employer-linked identity onto personal open-source work.

It then ran lib/run.sh under a stubbed gh and asserted rc=1. The gate sits behind backend_check, and profiles/*/settings.json is generated at install time rather than committed, so every invocation died here instead:

$ PATH="$sd:$PATH" HOME="$sd" bash lib/run.sh sysknife review
backend claude unusable: missing .../profiles/sysknife/settings.json
$ echo $?
1

backend_check returns 1. The identity gate returns 1. Both cases asserted 1, so the test was green on an exit code produced eleven steps before the code it was written to guard.

The tell had been sitting in the assertion the whole time:

printf '%s' "$out" | grep -q 'refusing to run' && printf '        (refusal message present)\n'

A conditional print, with no else. It printed nothing for the life of the test, and nothing is what a passing test looks like.

Two rules come out of this, and the second is the one that generalises.

Assert the message, not only the code. An exit code is a number that any layer can produce. The gate's own sentence can only come from the gate.

A guard behind a precondition needs that precondition satisfied in the test. The wall is a build artifact, so the test has to render one. Anything the installer creates is absent in a fresh checkout, which is the environment CI and every new contributor start from.

46. One sentence for two emergencies

Both timers died on 2026-09-05, twelve hours apart in the schedule and five minutes apart in reality:

09:15:28  sysknife-review   error connecting to api.github.com
09:20:28  magent-review     error connecting to api.github.com

What reached the desktop was neither of those lines:

ALERT: gh is authenticated as 'unknown', not vladimirrott; refusing to run

gh api user --jq .login returned nothing, ${gh_login:-unknown} filled the hole, and the sentence that came out described a revoked token. During a network outage that sends you to the credential store, which is the one place the problem was not.

The gate was right to refuse. It described the refusal wrongly, and an alert nobody can act on correctly is most of an alert wasted.

The two cases also want different responses. An outage is transient, so the gate retries it and exits 75, EX_TEMPFAIL, which reads as "ask again" in the journal rather than as a refusal. A wrong account is not transient, so it is refused on the first answer and never retried into: retrying that is waiting for a different identity to appear, which is the thing this gate exists to prevent.

An empty answer is not a wrong answer. Where a default fills in for a value you failed to read, check first whether you read anything at all.

The cost was one review cycle. PR #370 arrived at 08:30 and the 09:15 pass that would have reviewed it never started, so a contributor waited for a maintainer who had already been scheduled to show up.

47. Two clocks, one run, and an audit that saw two failures

maintainer audit --all, an hour after the orphan check shipped:

  2026-09-05T10-56-review    the report quotes no executed command
! 2026-09-05T10-47-review    started and never reported: Recount matches the published prose...

One run. run.sh names its log from date +%Y-%m-%dT%H-%M at line 130, before it takes the lock. maintainer start mints the run id after the lock is acquired, at line 345. That review waited nine minutes behind an issues sweep, so the two clocks disagreed by nine minutes and the run's transcript and its report ended up under different names.

Both audit findings were false, and they failed in opposite directions. The report looked unverifiable because its .commands file was empty. The log looked like a dead run because no report carried its name. Neither was true, and a reader would have chased both.

What makes this worth writing down is that nothing was wrong until something was slow. Every run for weeks took the lock immediately, both names agreed, and the mismatch was invisible. Contention is what made the two clocks readable, and contention is rare, which is exactly why the bug survived.

A name minted before a wait is a different name from one minted after it. The log is renamed to the run id as soon as that id exists, keeping what the pre-lock log recorded, including the wait.

And the tool found it. maintainer audit --all gained the orphan check at 10:28 and reported this at 11:00, against its own sibling.

48. The gate refused correctly, and nobody could act on the refusal

lacs-project/sysknife#370 changed four files across three languages. The unattended review approved it, then hit this:

maintainer-merge: the 'docs' suite does not cover every changed path:
    CONTRIBUTING.md
    docs/introduction.md
    scripts/check_evidence_claims.py
    tests/release/public-claims.test.sh
  A receipt from a suite that does not run the changed code proves nothing.

Correct, and the run did the right thing with it: it refused to hand-write a receipt, said the refusal was a gap in the gate rather than in the PR, and handed back two options.

Getting to those options cost it a grep -l "\*\.py" verify.d/*.sh and a read of all three suite files, because the message named the changed paths and not the suites. The reviewer had to reconstruct the coverage table the gate had just computed and thrown away.

That gap is where --admin comes from. A refusal that says no and a refusal that says no, and here is the shape of yes have very different outcomes when the person reading it is tired and a contributor is waiting.

    CONTRIBUTING.md                     docs
    docs/introduction.md                docs
    scripts/check_evidence_claims.py    no suite covers this
    tests/release/public-claims.test.sh shell

  Suites that would cover the rest between them: docs shell.
  Paths no suite covers at all: 1.

Print the analysis you already did. The gate evaluated suite_covers for every path against every suite to reach its verdict, and reported one bit of it.

The underlying rule, that one suite must cover every path rather than a covering set of them, is issue #19. It is a capability the gate lacks, not a guard that failed, and the two want different fixes: this one is a message, and that one is a redesign that gives each suite its own mutation.

The test that guarded the old message was grep -q 'no suite in .* covers every changed path' bin/maintainer-merge. Rewording the refusal to be more useful turned it red while the behaviour was identical. It drives pick_suite now.

49. The agent found four blocking defects and three were still open a day later

The magent review of 2026-09-04 cost $13.83 and returned four findings rated BLOCKING, all in the merge gate and the screen:

### 3.1 A signed receipt is not bound to the pull request it names — BLOCKING
### 3.2 Under the opencode backend the receipt key is readable — BLOCKING
### 3.3 `maintainer screen` calls a hostile package.json INERT — BLOCKING
### 3.4 The `verify` mutation string executes on the host — BLOCKING

Checked a day later, after a full day of work in the same repository: 3.1, 3.2 and 3.3 were untouched, and 3.4 had its symlink vector closed while the mutation string still runs on the host.

3.1 is the sharp one. cmd_merge selects receipts/$pr.json by filename, verifies the signature over the payload, and reads back head and kind. It never reads pr. The signature covers the bytes; the filename is not part of them. So:

$ maintainer-merge receipt 7 abcdef1234 "guard mutated, went red"
$ cp state/receipts/7.json state/receipts/8.json
$ maintainer-merge merge 8
  the receipt is ASSERTED, not observed: ...

It got past the signature and the kind check on a receipt earned for a different pull request. The head check does not catch it either: two documentation pull requests touch no PROD_GLOBS, so the "head moved, no production diff" branch accepts the copy.

The lesson is not about receipts.

A finding that nobody schedules is a finding that did not happen. The agent is cheap and it is good: $13.83 for four blocking defects in its own gate is the best value anything here produces. What it cannot do is act on them, and the review's own output is a report nobody is required to read. Sixteen issues were filed on the sysknife tracker in three days and every one of them is a row on a board somebody sweeps; these four were prose in a file under ~/.local/state.

Findings from the magent profile belong on the magent tracker, the same way findings about sysknife belong on sysknife's. Until then, the closing step of reading a review is to file what it found.

50. The security boundary interpolated its own argument into an interpreter

maintainer-merge is the one file that decides whether an unattended agent may merge. It read three fields out of a receipt like this:

verified_head="$(python3 -c "import json;print(json.load(open('$receipt'))['head'])")"

$receipt is "$RECEIPTS/$pr.json", and $pr was checked only for being non-empty (${1:?pr}). So the first argument became part of a path and, through the path, part of a Python string literal running in the gate. A first argument that is a legal filename and closes the quote runs code. It was reproduced: a crafted argument put a marker on disk.

maintainer-doctor had already been hardened against this exact shape, with a comment saying so:

Validated before it reaches a python3 -c string below. bin/maintainer hardens the same variable against the same class of bug; this file did not.

The merge gate, the more dangerous of the two, had not. A hardening that lands in one file and not its sibling is half a fix, and the unfixed half is chosen by which file somebody happened to be editing that day.

Two rules, and the second is the general one.

A PR number is an integer. Say so at the boundary. GitHub never issues anything else, so validating it rejects nothing real and removes the surface entirely.

Read structured data with argv, never by interpolating a path into the program text. json.load(open(sys.argv[1])) cannot be escaped by the filename; open('$x') can. The file had one line already doing it the safe way, three lines below three that did not.

51. Repeated stuff is not always duplication

maintainer-merge and maintainer-repo each define their own two-line die() and say(). The tempting move is a shared lib/say.sh both source. It would be worse: maintainer-merge is invoked standalone by the agent and sourced whole by the test suite, and adding a runtime source dependency to the security boundary for four lines of printf trades a real property (the gate is one self-contained file) for a cosmetic one. Left as is, on purpose.

The duplication worth removing is the kind that drifts and takes a guarantee with it: two hand-copied validators that must agree (sysknife's validate.rs and its Python screen), a figure typed into four files, a rule that lives as prose in one place and as code in another. Four printfs that will never diverge because nothing depends on them agreeing are not that. Deduplicate what costs correctness when it drifts, not what merely looks the same.

52. The loop that finds a bug and the loop that fixes it were not connected

The magent review of 2026-09-04 found four defects rated BLOCKING, in the merge gate and the screen. Three were still open a day later. The reason was not that anyone judged them unimportant; it was that magent ran at POST=off, so every finding it produced was prose in ~/.local/state/magent-maint/runs/<id>.md, and nothing sweeps that file. The sysknife profile files sixteen issues in three days because each one becomes a row on a board a human clears. The agent's review of its own code produced a report nobody was required to read.

Finding a defect and fixing a defect are two loops, and they were joined only by a person who happened to open the report. That join is the weakest kind: it runs when someone remembers to.

The fix connects them mechanically. A finding becomes a tracker issue through one command, maintainer file-issue, and magent posts. Two properties make it safe to point an unattended agent at its own tracker:

  • It deduplicates. Re-finding the same defect next week files nothing, keyed to a fingerprint the agent supplies (or the normalised title, so identical titles collide even when nobody thought about dedup). That is the guard the #342/#343 pair needed, filed nine minutes apart with the same title.
  • The wall still denies everything that is not filing an issue. POST=on lifts only the rehearsal block; gh pr merge, git push, gh release and git tag are denied by the wall regardless. And a bare gh issue create is denied too, so the agent cannot route around the dedup. The most an unattended magent run can now do is open a deduplicated issue on its own repository.

A human still fixes and still merges. What changed is that the finding no longer depends on a human reading a file to become work. The board does that.

A review that files nothing is a review that runs for the record. If an agent is worth running to find defects, the defects it finds are worth putting where defects get fixed.

53. Pinning the account once is not pinning it

The run gate did the right things in the wrong tense. It ran gh auth switch to the configured account and verified gh api user matched, both once, at the top of the run. Then it handed the rest of the pass, every review and every comment, to whatever gh's active account happened to be by the time each one ran.

This machine's keyring holds two accounts, one of them a work account. The active one has flipped on its own, and a run posted to a personal open-source repository under the work identity. A start-of-run check cannot catch that: the flip happens after it.

The fix is a tense change. Instead of selecting the account and trusting it to stay selected, the run exports GH_TOKEN for its whole duration. gh uses that token for every call and ignores the active-account setting entirely: an explicit gh auth switch to the other user is ignored while GH_TOKEN is set, measured on the host. The other account is not merely deselected, it is unreachable.

Two properties made this the right shape:

  • The guarantee is structural, not vigilant. Nothing has to re-check before each post. The wrong account cannot be named because the token, not a mutable setting, decides who acts.
  • It names no account in the code. The guard is "act as the configured account, whose token this is." The forbidden account is never written down, which matters here because the repository is public and the account to keep out of it is the reason for the fix.

A run that cannot get its token refuses. An identity you cannot pin is one you cannot promise, and a maintainer that might post as the wrong person should post as no one.

54. A review bot in your working copy is a collision waiting for a dirty tree

Both profiles pointed REPO_PATH at the working copy the maintainer develops in: ~/Desktop/maintainer-agent and ~/Desktop/lacs. It worked until it didn't, in three ways, all on the same day.

run.sh's refresh refuses a dirty tree, on purpose, so it does not trample uncommitted work. So a review that fires while the maintainer has edits in flight aborts and leaves no report: a gap in the trail that reads as a quiet period, which is lesson 22 again. A review that fires while the tree is on a feature branch reviews that branch, not main. And on 2026-09-05 a fork PR was left checked out in the shared tree, so the next unattended run found itself on a stranger's branch, one cargo build away from running build.rs from an unreviewed PR as the maintainer.

With POST=on and issue-filing, the stakes went up: a run against a half-edited tree could file issues about experiments that were never committed.

A review bot owns the tree it reviews. REPO_PATH now points at a checkout under the profile's own state directory, cloned from REPO_ORIGIN on first use and kept on origin/main. It tracks what is pushed, which is exactly what a review should judge, and nothing else touches it.

The tell was there in the design all along: a refresh that refuses a dirty tree is a refresh that assumes the tree is not shared. The assumption was never written down, so nobody noticed it was false.

55. A count is not a list, and the difference cost seven issues

maintainer offers enforced one open offer per person. It measured the person side carefully, printed who was over the rule, and opened with a line like:

3 issue(s) free to offer, 7 reserved or maintainer-only

Nobody can choose from an integer. Every offering round therefore picked issues by reading the tracker by hand, and the tool that existed to prevent bad offers had nothing to say about which issue was a bad offer. On 2026-09-07 a batch of offers went out that way. Running the fixed tool against the live tracker afterwards found seven issues pointed at two people each, and #342 pointed at three, none of whom had been told about the others.

The person-side rule and the issue-side rule are the same rule seen from two ends: an offer pairs somebody with something, and either end can already be taken. Only one end was checked, because only one end was printed.

It now prints the numbers, and a DOUBLE-BOOKED line naming every issue with more than one unanswered holder. The prompt says to offer only from the named free list.

Worth noticing how it stayed hidden: the count was correct the whole time. The tool was not wrong, it was unusable for the decision it existed to inform, and a correct number is a comfortable place for that to hide.

56. The tool that reads a tracker will read a commit SHA as a person

offers extracted mentions with @([A-Za-z0-9-]+) over the comment text. An issue about pinning GitHub Actions quoted dependency-review-action@a1d282b3…, trufflehog@05a58329… and dtolnay/rust-toolchain@stable, so the tool reported three contributors who do not exist, each holding an unanswered offer, and held that real issue out of the free pool where nobody could be offered it.

A mention starts a word. In every pin and every git ref the @ follows the tool name, so the character before it decides: whitespace, a line start or a bracket is a mention, and an alphanumeric or one of -._/ is not. _gh_mentions also drops code spans, which matches GitHub, since a handle inside backticks notifies nobody and is therefore not an offer.

The agent found this in itself, during a run whose subject was a different gate entirely, and the finding cost more than the gate it was auditing.

57. The detector I built for the shape that had just burned me

Fixing the double-booking on 2026-09-07 produced a check that reports an issue offered to two people who have not answered. Hours later it missed the case that cost the most.

#252 was offered to one contributor on 1 September. They answered, which the tool scores as "working on it" rather than as an open offer. A second contributor, whom nobody had pointed anywhere, read the same public thread, implemented it, and opened a PR. I reviewed and merged that PR, closing the issue about twenty minutes before the first contributor posted their own finished implementation.

Neither condition the detector looks for was true. Both holders were invisible to it: one because they had replied, the other because the maintainer had never mentioned them.

The rule it encoded was "two unanswered offers". The rule that matters is "more than one person is spending evenings on this". An issue somebody is actively working deserves more protection than one that has been ignored for a week, and the first version gave it less.

offers now also prints CONTENDED: any issue with a live holder where somebody other than that holder and the maintainer has posted. It will fire on people asking questions, which is the right trade. A false positive costs one line of output. A false negative costs somebody their evening, and they find out by having their pull request land on a closed issue.

Worth keeping separate from the lesson about the count: that one was a tool that could not answer the question, this one was a tool answering a narrower question than the one I had just been burned by, which is harder to notice because it works.

The first live run, and why it still flags #219

One hit across roughly a hundred open issues, which is the noise level this is worth having at. It is also instructive: #219 held by @bferanmi806-sketch; @0xZKc0de also posted there is correct from the data and resolved in reality. 0xZKc0de claimed it in August and I released them on 2 September.

The release is invisible to the tool because I wrote it as "You claimed #219 on 17 August", with no @ and no marker. Nothing the maintainer says in prose reaches the machine. The same habit nearly broke a batch of eleven release comments on 2026-09-07: they addressed people as "you", so every one would have posted, read as a release to a human, and left the issue held. They were caught by running the drafts through _gh_mentions before posting rather than after.

So the rule has two halves and the second is the one that rots: a release must @-mention the person and carry <!-- maintainer: claim-released -->. The marker applies to the whole comment body, so a comment that releases one person must not mention the person keeping the issue, or it releases them too. Name that person without the @.

Retroactively marking the old ones would notify people about decisions made days ago, so the historical gap stays and reads as one line of output. Worth the trade.

58. The gate read a label; offers read the thread

maintainer-merge refused a pull request that closed an issue carrying the claim label when its author had never posted there. That guard was written after a real incident and it works. It did not fire on sysknife#252, and the reason is worth more than the guard.

Two things were wrong, and the second is the one that generalises.

The author check asked "has this contributor engaged with the issue?" rather than "is somebody else on it?". The person who opened the PR had commented, so they passed. Being interested in an issue is not the same as being the one who was promised it.

But it never got that far, because the issue had no label. The maintainer had offered it with an @mention on 1 September and never applied one. So the guard's entire precondition was false and it skipped the issue in silence.

offers derives who is on an issue from the thread: the last maintainer comment mentioning somebody, minus a release marker. The gate derived it from a label. One fact, two sources of truth, and the fragile one is the one a human has to remember at the moment of offering, which is the moment they are thinking about something else.

The gate now calls maintainer holders, which is the same derivation offers uses. Not a copy of it: reimplementing that rule in bash is how the two answers drift apart, which is the failure this lesson is about, one level up. The label check stays as a second trigger for projects that use labels and not mentions.

And it failed open on its way in

The first version of the fix read:

holders="$("$mbin" holders "$iss" 2>/dev/null || true)"
[ -n "$holders" ] || continue

maintainer holders refuses to guess which profile it is acting for when more than one is deployed. The gate did not pass one through, so the helper exited 1, 2>/dev/null || true swallowed it, holders came back empty, and empty means nobody holds this, go ahead. A guard that answers "no objection" when it could not ask the question is worse than no guard, because it reports as if it looked.

It now distinguishes the two: exit 0 with no output means no holders, and any non-zero exit refuses the merge and prints what went wrong. Found by running the gate against a stub rather than by reading the diff, which is the only way this class ever shows up.

59. Telling somebody an issue is theirs when nothing was assigned

gh api -X POST /issues/N/assignees returns 200 for a name it silently drops. GitHub will not assign a non-collaborator who has not posted on that specific issue, and it says so by doing nothing at all. gh issue edit --add-assignee 404s on the same people, which is at least honest.

On 2026-09-07 that produced a public comment telling a contributor an issue was "assigned" to them, over an issue whose assignee list was empty, and a correction had to follow on the same thread.

maintainer assign <issue> <user> now POSTs and then reads the list back, printing what is actually there and exiting non-zero when the name is missing, with the reason and the alternative: reserve it with the claim label and say that instead.

The general form: an API that returns success for a request it did not carry out is indistinguishable from one that did, unless you go and look. Every write this agent makes on somebody else's behalf should be read back before it is described to them in public.

60. Three commands, three answers to one question

offers asked the thread who was on an issue. claims asked a label. The merge gate asked a different label. Same question, three derivations, and they disagreed in ways that each cost something:

  • the gate skipped sysknife#252 entirely, because the label it keys on had never been applied to an issue that had been offered by @mention;
  • claims printed "NOT assigned, so it is invisible to their dashboard" under two issues that offers called "offered and never answered", and told the maintainer to assign both. Assigning somebody who has not replied commits them in public to work they never accepted;
  • the gate's remaining check asked whether the pull request's author had ever posted on the issue, and let anyone through who had. Being interested in an issue is not the same as being the person it was promised to. A stranger commenting "I have this implemented" cleared a claim somebody else held.

_issue_parties is now the one derivation: one fetch of the comments, returning the live holders, everyone who spoke, and the raw rows for callers that need timestamps. holders, claims and offers all read it, and the gate shells out to maintainer holders rather than carrying a fourth copy in bash.

claims now separates the two meanings the label was carrying. An issue where the named person replied is a CLAIM and gets an assignment suggestion, through maintainer assign, which reads the list back. An issue where they never replied is an OFFER and says so, with do not assign, this is not a claim.

The general shape: when one fact has several derivations, they do not drift evenly. Each one is correct for the case its author had in mind, and the gaps appear only where two of them are compared, which nothing does until somebody loses an evening.

And the gate was never actually run

Every merge that day went through gh pr merge. The holder check written that morning to prevent a repeat of #252 did not gate a single one of them. It had tests, and the derivation was verified against the live tracker by hand, and it was still not deployed in any sense that matters. A guard exercised only by its own suite is decorative.

61. The morning pass had never once run

alerts.log on 2026-09-07:

2026-09-04T08:14  ALERT sysknife-review: the run unit failed before it could report
2026-09-05T09:15  ALERT sysknife-review
2026-09-06T09:16  ALERT sysknife-review
2026-09-07T09:17  ALERT sysknife-review
2026-09-07T10:44  ALERT sysknife-issues

Four consecutive days, plus magent-review on three of them. Every one exited 75 on error connecting to api.github.com, and the preflight was right to refuse: this laptop's network is down overnight and comes back between 10:00 and 12:30, so the 09:13 slot had never fired into a working connection. The evening slot at 21:13 kept succeeding, so a review happened every day and the schedule looked healthy from the outside.

Two separate defects, and the second is the one that made the first survive four days.

The failure was recorded and never reported. run.sh writes state/failed-<task>.json and appends to alerts.log, and nothing reads either. maintainer-doctor ran clean through all four days. The predicate needed no new bookkeeping: finish promotes last-<task>.json only on success, so a failure file newer than the success file already means "this task has not completed since it broke". The doctor now says so, in red, carrying the recorded reason and the log path, because during a network outage a bare "review failed" sends you hunting for a revoked token.

A refused start lost the whole cycle. systemd will not retry a Type=oneshot unit, and five of the six timers had a single OnCalendar line. One minute of downtime at 10:43 cost the issues pass two days; ci would have cost three, audit five. The sixth timer had two slots twelve hours apart, which is a second run rather than a retry.

The fix was already built and unused. min_gate skips a task whose last-<task>.json is younger than MIN_HOURS_<task>, so a second slot inside that window costs one skipped: line on a good day and runs for real on a bad one. Both directions were proven before relying on it: a fresh stamp printed skipped: review last ran 0h ago, and a stamp aged 100 hours went straight through. Every timer now carries a catch-up slot, and the suite fails a timer whose closest two slots are further apart than its own MIN_HOURS.

The proof that proved a copy

The first version of that check re-cut the checker out of the suite with sed for its mutation cases. The end anchor never matched, sed returned 2149 lines of shell, python refused to compile it, and a non-zero exit read as the check correctly rejecting a malformed timer. One direction passed, the other failed, which is the only reason it was caught at all. The checker is now one file that the real case and both mutations invoke, and the suite compiles it first, since a checker that cannot parse rejects everything and looks strict while testing nothing.

62. The gate could not run, and reported the contributor as failing

The 2026-09-07 19:11 review approved sysknife#389, eleven checks green, mutation-proven three ways, and could not merge it:

suite: shell (docker.io/library/bash:5 under podman)
running 'tests/e2e/story-metadata.test.sh' unmutated
maintainer-merge: the test does not pass unmutated (rc=1); nothing to prove yet
    FAIL  line 184: python3: command not found

bash:5 carries no python3. sysknife#386 made that test drive check_evidence_claims.py, and from that commit onward no receipt was earnable for the shell suite. The gate did not report itself broken. It reported the pull request as failing its own test, which is the more expensive way to be wrong, and it stayed that way until somebody tried to merge.

The comment justifying bash:5 said these tests "need bash, git and nothing else". Checked rather than believed: bash:5 resolves no git either, so the guarantee it stated was never a guarantee. Nothing connected the image to what the suite runs inside it, so the drift was undetectable by construction.

Every suite now declares suite_needs(), and maintainer-doctor runs each image and checks each binary resolves. A probe that cannot run is a warning naming the image, never a pass.

An offer answered by working on it looked unanswered

The same run found claims reporting sysknife#390 as "OFFERED 0d ago and never answered; do not assign" while the person it was offered to had an open pull request whose body said Closes #390. The check that decides whether to assign somebody read only the comment thread, so the offer looked unanswered for exactly as long as the contributor did the work instead of replying.

The rule is GitHub's own closing keywords and nothing wider. A pull request saying "see #391 for context" closes nothing, and counting it would commit somebody in public to work they never took, which is worse than the bug.

Two defects in the tests written that hour

vdoc | grep -q decides from the pipeline's exit status, and the suite runs under set -o pipefail, so it reports doctor's status rather than the match. One case passed for two runs while the check it tested did not exist; its twin failed while the check worked. The rule was already a comment beside the maintainer-merge cases, and a comment did not prevent the repeat, so the suite now greps itself for the shape and names the offending line.

Leaving make_stub podman 'exit 0' behind at the end of a block put a permissive stub first on PATH for every later case, and the real shell-suite integration test two hundred cases downstream reported a receipt it had not earned. Stubs get removed, not reset to something harmless-looking.

63. The prompt told the run it was a rehearsal, and it was not

profiles/magent/prompts/common-preamble.md said "This profile is at POST=off: write the report and the drafts, publish nothing". profiles/magent/profile.env:58 says POST="${MAINTAINER_POST:-on}".

lib/run.sh injected a rehearsal notice only when POST=off, so a posting run received nothing, and the stale sentence in the preamble was the only statement about posting anywhere in the prompt. The 2026-09-07 20:09 magent review read it, called maintainer file-issue five times for its security findings, and filed issues #27 to #31 for real. It caught itself afterwards by reading MAINTAINER_POST, stopped, filed nothing further, and reported it as the first section of its own report.

The deny wall could not have caught it either, for a reason lib/run.sh already records about a different tool: the wall governs what the agent types, and file-issue reaches gh issue create through a spawn inside Python, so Bash(gh issue create:*) never applies. file-issue honoured MAINTAINER_POST, which said on.

Two changes. A prompt may describe what POST=off does; it may not assert which one is in force, because only run.sh can read that, and the suite now folds newlines and fails any prompt that claims a posting state. And run.sh declares the state in both directions, quoting the live value:

## This run POSTS. Say it once, in public, under a real name.

Writes go to vladimirrott/maintainer-agent as vladimirrott, immediately and for
real. ... `MAINTAINER_POST` is `on` in your environment, and that variable is
the answer, not any sentence you read in a prompt.

The check that was supposed to catch the sentence passed over it first: the claim wrapped between "This profile" and "is at POST=off", so a line-based grep reported the file clean. Fold the newlines before matching prose.

64. Two runs queued on one lock both passed the cadence gate

install.sh --timers on 2026-09-07 fired a Persistent=true catch-up for issues at 20:09, which is the retry from lesson 61 working on its first day. A second issues run launched at 20:23 read the cadence gate against a stamp two days old, passed, blocked on the lock for eleven minutes, took it at 20:34 one second after the first run promoted that stamp, and started a full duplicate pass. It stopped only because the backend hit a session limit.

The gate is read at run.sh:129 and the lock taken at run.sh:308. Every run that queues decides its cadence on a stamp the run ahead of it is about to replace, so the gate is now read again once the lock is held, and a superseded run exits 0 saying skipped: ... after waiting for the lock. Being superseded is not a failure and must not be recorded as one.

Giving every timer a catch-up slot makes queueing more likely rather than less, which is how a fix from four hours earlier surfaced this. MAINTAINER_FORCE is read into a variable before it is unset, or the override would be defeated by the second check for exactly the runs that need it.

The test could not reproduce it at first: $stub_dir carries make_stub flock 'exit 0', so nothing ever queued and the case passed while the code was still broken. A test that needs a real lock needs a PATH without the stub for it.

65. Five findings the agent filed while it believed it was rehearsing

The 2026-09-08 magent review filed #27 to #31 under a stale POST=off claim (lesson 63). The issues were real. Every one was verified here before being acted on, because a finding filed by a run that thought it was posting nothing has not been checked by anybody:

  • #27, the wall had no relative spelling. render-settings.py spelled the bare verb, seven FHS directories, six home directories three ways each, and whatever which resolved. It never spelled ./. That matters most for the one verb nobody types any other way: install.sh --uninstall is denied because a run once used it to turn off every timer on this machine, and install.sh sits at a repository root and is never on PATH. All 31 rendered rules for it missed ./install.sh --uninstall. An interpreter is a second way past a rule keyed on a filename, so .sh verbs now get bash and sh spellings too, and nothing else does: bash curl is noise, and a wall padded with rules that cannot fire is a wall nobody reads.
  • #28, both identity checks passed when they could not ask. pinned_login="$(gh api user ... || true)" turned "could not resolve" into "resolved to nothing", and the [ -n ... ] test then fell through to a log line announcing the identity as pinned. The log recorded a verification that had not happened, which is worse than not checking, because it reads as evidence afterwards. file-issue had the same shape and filed under a name nothing confirmed.
  • #29, the innermost guard failed closed and the five reads feeding it did not. maintainer holders was hardened and says so in its own comment. closingIssuesReferences, the author, the label, the assignee list and the comment list each swallowed an error into an empty string that the next line read as permission. holders reads REST and those read GraphQL, so a rate limit on one and not the other left the claim protection off silently.
  • #30, backend_rehearsal was tested for existence and never for its value. run.sh chose the rehearsal wall from a hardcoded list of two filenames. The cursor backend reads a third variable that nothing set, so POST=off there handed over the live wall while cursor-rehearsal sat rendered and unread. An enumeration that must be updated per backend goes stale silently, and this one had. The backend is now asked, because it is the only thing that knows which variable it reads.
  • #31, the pin verifier could not see an unpinned action. Its extraction required a line to be pinned already, so a floating tag never entered the loop and the all-clear printed over it. The first fix reproduced the defect it was fixing: anchoring to ^uses: missed the - uses: list form, so the probe line was not seen either.

66. Six critical popups a day for something that fixes itself

Reported as constant error toasts. Six unit failures on 2026-09-08 produced up to twelve, because each one alerts twice: run.sh calls alert, which sends notify-send -u critical, then exits non-zero, and systemd's OnFailure runs alert.sh, which sends a second.

Every one of the six was gh could not reach GitHub in 3 tries or a lock wait expiring. Both are recorded in failed-<task>.json, reported by maintainer-doctor, and retried by the catch-up slot every timer now carries. A critical popup six times a day for a condition that heals itself is how somebody learns to dismiss the one that matters.

The record is unchanged and the urgency drops. SuccessExitStatus=75 stops systemd calling a TEMPFAIL a unit failure, so OnFailure fires for real problems only. A lock wait that expires now exits 75 as well, because another run of the profile is still working and this one is superseded rather than broken.

Two things surfaced underneath it. alert.sh globbed every *-maint directory and wrote each alert into all of them, so magent incidents appeared in sysknife's alerts.log. And TimeoutStartSec=5400 killed a magent review that ran 11:58 to 13:28 just as it finished, which then starved the queued issues run into its lock timeout: one long run, two popups.

67. The suite was flaky, and the flake was the rule it teaches

Three consecutive runs failed on three different assertions in the same block, which is the shape of truncated output rather than missing text. The cause:

printf '%s' "$out" | grep -q 'pattern'      # 23 KB of prompt

grep -q exits on the first match, printf takes SIGPIPE, and set -o pipefail reports the match as a failure. Measured on a 23 KB payload matching early: 3 spurious failures in 300 calls, and 0 in 300 with a herestring. There were 187 such sites, every one using grep -q, and roughly two runs in five failed for no reason.

A gate that fails at random teaches everyone to re-run until green, which is exactly how a real failure gets waved through. All 187 now read grep -q 'pattern' <<<"$out".

The same runs turned up two cases that cloned lacs-project/sysknife from github.com on every invocation, in a suite whose own README calls it offline. That cost minutes per run and, on a slow link, produced two failures about things that were not under test. The suite now exports MAINTAINER_REPO_ORIGIN pointing at a bare repository it creates, so a case added later cannot reintroduce it by forgetting. Suite wall clock went from about four minutes to 29 seconds.

68. A table of who is blocked is not a list of who is free

maintainer offers printed seventeen people, each beside the issue that blocks them, under a footer saying somebody who holds nothing does not appear at all. I read that as zero eligible contributors and reported it.

@Georgefifth had five merged pull requests, the most recent merged the previous day, and held nothing. He was the warmest name on the tracker, and the only evidence of it was a name the tool never printed.

Absence carried two meanings at once. @sonalisrisivani held #371 by assignment and by open pull request #379, and she was just as absent, because offers derived every hold from maintainer @mentions alone. The same blank meant "free" for one person and "invisible" for the other. On the issue side the tool already knew better: it kept #371 out of the free list because a pull request closes it, then showed the author of that pull request as available. Half the fix had landed a week earlier and the other half was never written.

A third case sat between them. @ITSMERNB was offered #331 on 09-01 and answered it. On 09-06 the issue went to @be-student, who took the assignment and opened PR #377. offers still printed ITSMERNB no: working on #331, so somebody with five merged pull requests sat out of the pool for three days over an issue that was no longer his.

Guard: a hold now comes from three places, and the assignee wins. The maintainer pointed you at it, GitHub says it is assigned to you, or you have a pull request open against it. An assignment to somebody else retires an older mention and prints the reason, because a hold that disappears with no line explaining it reads the same as a hold the tool failed to see. The roster of available people is derived rather than inferred: everyone with a merged pull request, minus everyone holding something, minus bots and the maintainer, each with days since their last merge, because an offer to somebody quiet for five days has converted 0 for 5 here. An unreadable merged listing refuses instead of printing an empty roster. Fourteen tests, seven of which failed against the shipped version.

An issue assigned to the maintainer stopped being offered as free in the same change. sysknife #392, a release checklist with the owner's own name on it, was in the free-to-offer list every time this ran.

69. A model usage window closing is not a defect

2026-09-07T20-34-issues stopped here:

I'll start by loading the skill that governs this task.
Skill loaded. Reading required references before any action.
You've hit your session limit · resets 10:10pm (America/Mexico_City)

That produced three critical popups for one condition. lib/run.sh alerted because the backend exited non-zero, alerted again because the run wrote no report, and exited 1, so systemd's OnFailure fired alert.sh for a third. maintainer-doctor then carried a red line for 39 hours over a window that reopened ninety minutes later.

Lesson 66 had already routed the transient conditions through alert_transient and exit 75. Every one of them arrived through gh: an unreachable API, an unresolvable token, a lock wait expiring. Nothing looked at how the backend failed, so the class was half-swept. Fixing by inspection reaches the instances in front of you and stops there; the class had to be named by its definition, which is "the run stopped for a reason that waiting fixes", not by the list of gh failures that happened to be on screen that day.

Guard: backend_transient_reason in lib/profile.sh reads the run's own log and prints the line it matched. A session or usage limit, a 429, a 5xx, an overloaded_error, a dropped connection. run.sh consults it on both alert paths: a run that still wrote a report finishes normally with one low-urgency notice, and a run that wrote nothing exits 75 so the next slot retries it.

The negative twins matter more than the positives here. An empty credit balance is deliberately absent from that list, because no retry pays an invoice, and so is an unreadable log. A classifier that calls everything transient silences the alerts it exists to raise, which is a worse failure than the popups it removes. Seven tests, four of them proving it stays silent.

70. ${GH_TOKEN:-no} prints the token

The magent issues run of 2026-09-09 checked whether it had inherited a token:

echo "GH_TOKEN set? ${GH_TOKEN:+yes}${GH_TOKEN:-no}"

:+ is the one that substitutes a fixed word. :- substitutes the value when the variable is set and non-empty, so the second half printed the live personal access token. The run caught it in its own report, and the value had already reached the session transcript on disk. A sweep found it in two of them, from two separate runs, and in three VM overlay images, all local and all untracked.

The comment above the pinning block in lib/run.sh said the token "stays in this process's environment and is never written to disk". That was a claim about how the agent would behave, sitting where a property of the design should be. No deny rule can enumerate the commands that print an environment variable, and the run was doing something reasonable: asking whether a variable was set.

Guard: maintainer_scrub_secret in lib/profile.sh removes a literal secret from every text file under the paths it is given, and run.sh installs it as an EXIT trap the moment the token is pinned. Every exit path passes through it, the refusals and the transient 75 included. It scrubs the state directory and the CLI's session directory, which the Claude CLI names after the working directory with each / and . turned into -.

Three refusals in it are the part worth keeping. A secret shorter than sixteen characters is refused, because a short needle matches nearly everywhere and a scrubber that rewrites every file it walks is a worse day than the leak. A file containing a NUL byte is reported and left alone, since editing a disk image to remove a credential corrupts the image and rotating the credential does not. And the secret reaches the helper through the environment rather than argv, which anything running as this user can read out of /proc.

maintainer-doctor looks for the same thing from the other end and goes red naming the file, because a trap that stops firing is invisible otherwise. Proved both directions: planting the live token in logs/ turns the check red, the scrubber names the file it cleaned, and the next run is green.

71. A verify image chosen for what the scripts needed that day

sysknife#410 added a release test that runs git init, git add and git update-index to prove the pre-commit secret scanner fails closed. The shell suite's image was python:3.12-slim:

$ podman run --rm --network=none docker.io/library/python:3.12-slim \
    sh -c 'for c in bash python3 git; do printf "%s=%s\n" "$c" "$(command -v $c || echo MISSING)"; done'
bash=/usr/bin/bash
python3=/usr/local/bin/python3
git=MISSING

suite_needs said bash python3, so maintainer-doctor agreed the image was complete. The gate would have run the contributor's test, watched git fail, and reported the pull request as failing its own test. That is lesson 62 with a different missing binary.

Twice now. bash:5 carried no python3 when sysknife#386 made a release test drive a python script, and the fix then was to move to python:3.12-slim, which was chosen for what the scripts needed that day. A workaround applied twice means change the pattern.

Guard: the suite declares bash python3 git, the image is python:3.12, and the test suite now refuses a -slim variant for this suite by name and says why. maintainer-doctor runs the image and checks each declared binary resolves, so the declaration and the image cannot drift apart in silence.

The declaration is still hand-written, and that is the part still open. Nothing derives "what these scripts invoke" from the scripts.

72. The gate's own hardening reported the contributor as failing

sysknife#410 writes an "echoing scanner" stub into mktemp -d, makes it executable and runs it, to prove the real scanner never prints the credential it found. The verify container mounted /tmp with noexec, so the stub could not run, the assertion never fired, and the gate said:

maintainer-merge: the test does not pass unmutated (rc=1); nothing to prove yet
    FAIL: the no-echo mutation did not make the assertion fail

The same test, same image, same tree, passes with /tmp exec-capable. Third time this gate could not run and blamed the pull request. The first was an image with no python3, the second an image with no git, and this one is a mount flag.

noexec bought nothing. There is no network to fetch a payload with, and the suite command is the pull request's own code by design, so the PR can already execute whatever it likes. What the flag removed was the ordinary write-a-stub-and-run-it pattern that this repository's own suite and sysknife's both use.

Guard: nosuid stays, and so do --cap-drop=ALL, --read-only, --security-opt=no-new-privileges, --network=none and the pids, memory and CPU limits, which are the isolation that actually holds. The test suite asserts each of those is still passed and that /tmp is not noexec, naming the reason, so somebody re-hardening this later reads why before changing it.

The pattern underneath all three: a sandbox tuned against the tests that existed when it was written. Every new test is a chance for it to be wrong, and the failure always looks like the contributor's fault.

73. Say which side failed, because the gate has been wrong three times

Lessons 62, 71 and 72 are the same failure with three different causes: an image with no python3, an image with no git, and /tmp mounted noexec. Every one of them reached the contributor as:

maintainer-merge: the test does not pass unmutated (rc=1); nothing to prove yet

That sentence reads as "your test is broken" to somebody who did nothing wrong, and it is the most expensive thing this tool can say. Three times is not bad luck, it is a missing distinction: nothing in the gate could tell "your code failed" from "my container could not run it".

Guard: looks_environmental reads the clean-run log and, on a missing interpreter, an unexecutable path or a wrong-architecture binary, the refusal changes to "the unmutated test failed on my suite environment, not the pull request", points at profiles/*/verify.d/, and says in words not to report the contributor as failing.

The classifier is deliberately short. A version that called everything environmental would excuse a real failure, which is the worse error, so it lists only the shapes that have actually happened.

Proved in a container rather than by reading: a fixture whose test invokes a binary the image does not carry now produces the environment message and does not produce the old one. Both directions are asserted, because the three previous versions all contained the right words somewhere and still said the wrong thing.

74. The stub directory disarmed the lock I was testing

Two background shells nearly merged one pull request twice on 2026-09-10, so the merge took a flock. The test reported two merges of one pull request both proceeded and I spent a while reading a lock that was working perfectly.

tests/run-tests.sh line 50 is make_stub flock 'exit 0'. Every case that runs with $stub_dir on PATH gets a flock that takes nothing and returns success, so the gate's lock always looked free. The file already carried a comment saying this, on a different block, written the last time it bit.

This is the permissive-stub shape from earlier in this project: a podman stub left in the shared directory made a real test two hundred cases later report a receipt it never earned. A shared stub directory is global state, and a stub that always succeeds is a mock of the thing you are trying to prove.

Guard: the case symlinks the real flock into a directory ahead of $stub_dir, so the gh stub is still reached and the lock is real. Putting /usr/bin in front instead pulls in the real gh and the refusal becomes an identity error, which is a different test failing for a different reason and took another pass to see.

Three assertions now, not one. The holder proves it actually took the lock before the second call runs, the second call is refused and names the concurrent merge, and the lock is free again once the holder releases. Without the first, the other two pass when nothing was ever held.

The holder releases on a sentinel file rather than a timer, because killing a subshell that is sleeping leaves the sleep child holding the descriptor and the lock outlives the kill.

75. Two things that merged and then went quiet

A merge that closes nothing said nothing. Three pull requests on 2026-09-10 declared no closing keyword, so merging them landed the work and left the issue open. Nothing reported it, and #238 was closed by hand only because somebody happened to look at the tracker afterwards. The gate already reads closingIssuesReferences for its claim check; it just never said the list was empty. It does now, at merge, naming what to do about it.

A receipt outlived its merge. sysknife#399 was BEHIND and its fork forbade the branch update, so it merged outside the gate and the receipt was never consumed. It then sat in receipts/ describing shipped work while maintainer-doctor counted it as open. Telling "still in flight" from "already landed" is the only thing a receipt exists to do, so doctor now asks GitHub for each receipt's pull request state and names any that already merged.

Both are small. Both were found by using the tool for a day rather than by reading it, which is the only way either would have surfaced.

76. The test suite fired sixty critical popups at the person using the machine

Reported with a screenshot:

maintainer · sysknife review · FAILED
gh is authenticated as 'someone-else', not vladimirrott; refusing to run.
See /tmp/tmp.ZBwRBxn8fL/racehome/logs/2026-09-10T10-14-review.log

That is not a run. someone-else is a gh stub in tests/run-tests.sh and racehome is a fixture directory under $stub_dir. Every execution of the offline suite reached the real /usr/bin/notify-send and raised three critical desktop alerts. I ran the suite about twenty times that day.

The suite does stub notify-send, in $stub_dir. Two cases deliberately build a minimal PATH that excludes $stub_dir, for good reasons written in their own comments: one needs the real flock, and both want a run that reaches past the identity gate to fail locally rather than touch the network. The stub was never on their path, so the real notifier won.

Adding a stub to those two directories would have fixed those two and left the next minimal-PATH case free to do it again. This was the second toast complaint in one session, and the first one I answered by looking at timers.

Guard: MAINTAINER_NOTIFY=off is honoured by lib/run.sh and by platform/linux/alert.sh, and the suite exports it once for the whole process, where no later case can miss it. It is a real setting rather than a test backdoor: a headless host has nobody to show a popup to and wants exactly this. The alert trail is written before the switch is consulted, so turning off the desktop never turns off the record.

Three cases assert on notifications and now opt back in with MAINTAINER_NOTIFY=on, which is the honest shape: a test about notifications should have to ask for them.

Proved by mutation rather than by reading. A recorder placed ahead of the real notifier on PATH, across a whole suite run:

with the guard:     0 desktop notifications reached
guard commented out: 3, the first reading
  -u critical -a maintainer maintainer · sysknife review · FAILED
  gh is authenticated as 'someone-else', not vladimirrott ...

byte for byte the toast in the screenshot.

One of the assertions I wrote for this passed while the bug was still live, because it grepped for MAINTAINER_NOTIFY=off and matched its own test body. Lesson 5 in this file is "a check must not match its own source", and it caught me writing the check that lesson exists to prevent.

77. The gate refused to prove what it could, because it could not prove something it did not need

maintainer-merge verify earns an observed receipt by running a suite twice, clean and mutated. pick_suite decides which suite. It required one suite to cover every changed path, so a pull request touching .rs and .md got nothing: no single suite runs both.

That is most pull requests. Six of the seven merges on 2026-09-10 fell back to an asserted receipt for exactly this reason, and the unattended run on 2026-09-14 earned zero observed receipts and refused outright:

maintainer-merge: the 'rust' suite does not cover every changed path:
    CHANGELOG.md	docs
    Cargo.lock	rust
    crates/sysknife-brain/src/planning_tools/propose_plan.rs	rust
    docs/introduction.md	docs
    tests/evidence/workspace-tests.json	no suite covers this

Meanwhile cmd_merge already carried the answer. PROD_GLOBS is the profile's own declaration of which paths invalidate a receipt, and it is what cmd_merge re-checks when the head moves under an approval. A .md change cannot invalidate a mutation proof about .rs code, and one half of the tool said so while the other half refused on those grounds. The two halves now read the same declaration.

Guard: when no suite covers everything, pick_suite retries over the production paths alone and returns the suite that covers those. Three things keep that from becoming a loophole:

  • a path no suite covers at all still refuses, so an incomplete PROD_GLOBS cannot smuggle an unknown file past the gate;
  • a diff with no production path at all refuses, because there is no narrower scope to fall back to;
  • the receipt names, on stderr, every path the chosen suite does not run, so a narrower proof is stated rather than assumed.

The refusal above had a second cause worth separating. tests/evidence/workspace-tests.json was covered by no suite, and CONTRIBUTING.md requires that file to move on any added or removed Rust test, so every test-adding pull request carried an orphan path. The rust suite now claims it, with the reason written next to the case arm: that file holds the count of the tests this suite runs. It does not run vitest, so the frontend_tests field in the same file is outside what the receipt proves, and the comment says that too.

The first fix reached one caller and not the other. pick_suite has a separate branch for a suite named by hand, carrying its own copy of the coverage rule, and the unattended run passes rust explicitly. So the tests were green, the inferred path was fixed, and the real command was refused exactly as before:

$ maintainer-merge verify 426 33285c61 'ubuntu_only_descriptions_match_execution_fence' '<sed>' rust
maintainer-merge: the 'rust' suite does not cover every changed path:

Running the actual command found that; nothing else would have. The rule now lives in one function, covers_production, which both branches call.

The test I wrote for this failed the way this file keeps recording. My first three assertions grepped bin/maintainer-merge for is_production and for the wording of the new message, which passes against a function that returns the wrong answer. Rewritten to drive pick_suite against a fixture profile, one of them still passed with the fix removed: it checked that stderr names README.md, and the refusal names every path too. It now reads only the text after the narrowing line. Four of the eight assertions fail with the fix reverted; the four that pass both ways are the invariants, which is correct.

78. A fixture that expires reports a bug that does not exist

claims measures how long a claim has been idle from the claimant's own last comment, never from updatedAt, because assigning an issue moves updatedAt and would reset the staleness of exactly the claims the check exists to find. The test for it wrote a comment date of 2026-08-24 and asserted the idle column matched 1[0-9]d.

It passed for nine days. On the tenth the suite went red on every machine, with a failure message accusing the tool of the bug it was written to prevent:

FAIL  the maintainer touching an issue resets its staleness:
      #272  atanishka308  21d  stale

Nothing was wrong. The fixture had aged out of its own assertion window, and the cheap repair is to widen the pattern, which is how a test stops testing.

Guard: the fixture computes its dates from date -u -d '14 days ago - 1 hour' and asserts the exact figure, 14d. The hour of margin is the point: without it, floor(14d - ε) is 13 whenever the two timestamps land on either side of a second, and a gate that fails at random teaches everyone to re-run until green.

79. The suite image was the thing that failed, for the third time

maintainer-merge verify runs the suite twice inside a container. When the clean run fails there is nothing to prove, and the gate has to decide whose fault that is. Getting it wrong is expensive in one specific direction: it reports a contributor as failing their own test, in public, under a real name.

looks_environmental knew two shapes, both from earlier instances of this: a missing binary (python:3.12-slim had no git) and an unexecutable path (/tmp mounted noexec). The third shape is a dependency that cannot find what it links against, and the pattern missed it completely:

The system library `glib-2.0` required by crate `glib-sys` was not found.
error: failed to run custom build command for `glib-sys v0.18.1`
warning: build failed, waiting for other jobs to finish...

maintainer-merge: the test does not pass unmutated (rc=101); nothing to prove yet

Nothing in that run touched the pull request's code. cargo test --offline builds every workspace member, sysknife's workspace includes the Tauri app, and rust:1-slim carries neither pkg-config nor glib.

Guard, in two parts. looks_environmental now also matches could not find system library, pkg-config, failed to run custom build command, linker cc not found and error while loading shared libraries. Both directions are tested: four environmental shapes are caught, and a real assertion left == right failed is still the pull request's, because a gate that excuses every red run it cannot parse is worse than one that never excuses anything.

The second part is the suite itself. Adding GTK to the image to compile a paused desktop app that nobody is changing is the wrong trade, so the sysknife rust suite excludes sysknife-shell and says in the file that a receipt from it proves nothing about that crate. CI still builds it, on a runner that has the libraries.

Writing the pattern was its own small lesson. The first version wrapped the alternation across lines with a backslash inside single quotes, where a backslash-newline is literal, not a continuation. bash -n and shellcheck both passed and the regex matched nothing. It is built by concatenation now, and the test that proves it fires is what caught it.

80. Two actors for one action, and the machine got the blame

verify 426 was running by hand. At 21:14 the unattended review timer fired, its agent reached the same conclusion I had, and ran verify 426 with the same arguments. Two full cargo builds of the same workspace, against two separately extracted trees, on one twelve-core host.

What that looked like from outside was memory exhaustion. Five background tasks were killed for it. The measurement said otherwise:

MemAvailable:   21918320 kB      # 21.9 GiB of 30
SwapFree:        1967100 kB      # of 2 GiB
largest RSS:     1.19 GB, 0.77 GB (two rustc)

Nothing was short of memory. The supervisor was reading MemFree, which the build's own page cache had driven to 398 MiB, and the duplicate work was what filled it. Killing the wrapper also left the podman containers running, so the builds continued with nothing collecting their results, and 5.7 GB of abandoned target directories accumulated in /tmp.

cmd_merge took a per-pull-request lock on 2026-09-10, written up as lesson 74, for precisely this: MISTAKES rule 9, one watcher per action. cmd_verify did not get one, and verify is the half that builds the workspace twice.

Guard: cmd_verify takes .verify-$pr.lock immediately after it resolves a container runtime, before it fetches anything or extracts a tree. The second caller is refused by name.

The test for it passed for the wrong reason first. Asserting only that the second call exited non-zero was green with the lock removed, because the fixture profile refuses that changed path anyway. Two assertions folded into one, which now requires the refusal to name the concurrent verify, and that one does fail when the lock is taken out.

A lock on the cheap half and none on the expensive half is worth saying out loud: the guard went where the bug had been, not where the cost was.

81. Three suites covered three suffixes, and every refusal came from a fourth

Four pull requests were refused by the gate on one night, each with a different message, all from one cause. The suites covered *.rs and Cargo.*, *.md, and *.sh and .githooks/*. Nothing covered *.py, packaging/* or Makefile, and packaging/* is in PROD_GLOBS, which means the repository's own declaration said those paths invalidate a receipt while no suite could earn one for them.

sysknife#401 was the sharpest case. Its production change is a one-word regex in scripts/check_evidence_claims.py:

maintainer-merge: the 'shell' suite does not cover every changed path:
    scripts/check_evidence_claims.py	no suite covers this

and even with coverage it could not have been proved, because suite_mutate_glob printed a single *.sh, so the sed could never reach the line the pull request exists to change. A receipt would have measured the four story scripts beside it and said nothing about the regex.

sysknife#425 failed differently and for a related reason. Its clean run died:

fatal: not a git repository (or any parent up to mount point /)
FAIL: git could not enumerate tracked files

cmd_verify extracts the tree with git archive, which hands over the files and not the repository. #425's new arm turns a failed git ls-files into a named failure instead of an empty list quietly scanning nothing, so its own guard fired on the gate rather than on the pull request. The guard was right and the gate was wrong.

Guard, three parts.

The shell suite covers *.py, packaging/* and Makefile, with the couplings written next to the case arm and checkable one by one: scripts/check_evidence_claims.py is driven by tests/release/public-claims.test.sh, packaging/sysknife-*-edit by tests/release/*-edit.test.sh, Makefile by tests/release/install-paths.test.sh. suite_mutate_glob may now print several globs, one per line, and prints *.sh, *.py, sysknife-* and Makefile. The extensionless entry is the point: every privileged helper in packaging/ is a python script with no suffix, so the trust boundary this repository calls non-negotiable was the one place a single -name '*.py' could not reach. And cmd_verify seeds an index in the extracted tree, forced, so git ls-files answers with exactly what the archive contained.

One wide suite rather than a fourth narrow one, which is what the run report suggested. pick_suite returns one suite. Splitting shell and python into two would have recreated the union problem lesson 77 had just fixed, for every pull request touching both a .sh and a .py file, and the two would have shared a command and an image anyway. A distinction with no difference, bought at the price of the bug I had removed that morning.

A wide coverage list is safe here for a specific reason, and it is worth naming rather than trusting. If the suite claims a path the named filter does not actually exercise, the mutation lands on that file, the test passes anyway, and the gate refuses with THE GUARD DOES NOT BITE. A wrong coverage claim cannot buy a receipt; it can only waste a run.

The workflows are covered too, and I went back and forth on that one. Leaving them out keeps a receipt from ever reading as "the workflow diff was judged", which a human still has to do. Leaving them out also means every pull request touching .github/ has to be merged around the gate, and on this queue that was three of the twelve. A gate a whole class routes around is decorative, which is MISTAKES rule 7 in a different costume. Four release tests read those files and assert properties of their contents, so the mutation backstop applies there as much as anywhere. The human read stays; it was never the receipt's job.

The other half of the same refusal was PROD_GLOBS. sysknife's list named packaging/* and .github/* and not scripts/*, so #401, whose only production change is a regex in scripts/check_evidence_claims.py, had no production path at all and the narrowing had nothing to narrow to. scripts/* and Makefile are in the list now. A declaration that is missing an entry does not fail loudly; it quietly makes a different rule refuse, three steps away.

The test that broke was the grep. A case asserted the mutation touches regular files only by looking for the literal find . -type f -name in the source. Widening to several globs changed that line, the case went red, and the property it guards had not moved at all. That is the failure mode in both directions: red on a refactor, green on a regression. The mutation step is now a function, apply_mutation, and the case drives it against a tree holding a .sh, a .py, an extensionless sysknife-*, an .md that must not change, and a symlink pointing outside the tree that must be neither followed nor replaced.

82. The guard ran, the function did not, and || true said fine

lib/run.sh pinned a GitHub token into the environment for the whole run and installed an EXIT trap to scrub it out of anything the run left on disk. The trap called maintainer_scrub_secret, which lives in lib/profile.sh, and lib/run.sh never sourced lib/profile.sh. Every exit wrote

run.sh: line 441: maintainer_scrub_secret: command not found

into the log, the || true beside the call turned that into success, and the scrub had never once run. The suite was green throughout, because the case that covered it was

grep -q 'maintainer_scrub_secret' "$root/lib/run.sh" && ok "run.sh scrubs before it exits"

The name is in the file either way. A grep for an identifier in a caller proves the caller mentions it, not that the caller can call it.

Nothing leaked: maintainer-doctor looks for the token from the other end and found none on disk, which is the second guard doing its job while the first was a no-op. That is the only reason this is a lesson and not an incident.

Three changes, and the third is the one that generalises. The behavioural test drives lib/run.sh to a real refusal with a token planted in the state directory and then reads the file back. The startup check refuses with exit 64 when profile.sh cannot be found, because an exit-path guard that cannot run is worth less than no guard: the log reads as though it cleaned up. And a class guard walks every shell script in bin/ and lib/, and fails when one calls a maintainer_* helper without sourcing the file that defines it. The instance was one line. The class is checkable.

83. The audit blamed a missing shell function for three deaths it did not cause

_why_it_died returned the last non-header line of a dead run's log. Every run that aborted between 2 and 21 September ended on the command not found line from lesson 82, so maintainer audit reported a missing shell function as the reason all three died. Two were an expired backend login and one was DNS.

A wrong cause is worse than no cause, because somebody acts on it.

The shapes that decide whether a failure is retried, escalated or left unexplained were an alternation inside a shell function. They are lib/failure-shapes.json now: each shape carries an id, a class, a pattern, what it means, and for the ones a person has to fix, the command that fixes it. Both the shell and the Python read the same table, so the CLI and the audit cannot drift into disagreeing about the same log. Adding a cause is a data change, and something can finally enumerate what the agent knows how to explain.

Three outcomes, and the third is the point:

classwhat it means
transientthe next slot fixes it by running again
needs_humanno retry clears it and every later slot fails the same way
unclassifiednothing matched; say so, and carry the evidence line

unclassified is a verdict rather than a default branch. Thirty-one dead runs on the sysknife trail now carry a named cause and none are unexplained: seventeen gh-unreachable, eleven died-without-saying, two backend-unconfigured, one wrong-gh-account. Grouped like that, the list is a decision. As thirty-one identical lines it was a warning nobody read twice.

The evidence picker had its own version of the same mistake. It scanned the whole log for the most error-shaped line, and the task briefing is part of the log: it lists open pull requests and issue titles, and one of those titles contained the word errors. The audit reported a contributor's pull request title as the reason a run died. Nothing before the briefing ends is evidence of anything, so evidence selection starts after it, and the markers that end it are configuration rather than three more strings buried in a function.

84. Three critical popups, one cause, and the marker lost the cause

An unrecognised backend failure raised three desktop alerts. lib/run.sh called alert for the backend exit, called alert again because no report had been written, then exited 1 so systemd's OnFailure sent a third. Three runs aborted in five days and every one of them did this: nine critical toasts for two distinct conditions, one of which healed by itself within the hour.

The second alert is the same event described a second time. The backend died, so of course it wrote nothing. Worse, it overwrote the failure marker: alert had already stored the real cause, and the second call replaced it with

"<run> produced no report; the run is not auditable"

which is how failed-review.json came to record a failure with no reason in it. The vaguer message won because it was written last.

The second alert is gone. The marker written first stands, because it is the one that names the cause. And EAI_AGAIN is in the transient table now, which turns the 21 September abort from three criticals into one low-urgency line that says the next slot will retry.

An expired login is neither transient nor unknown, and it was being treated as unknown. It is needs_human now, with claude /login in the toast, because "Failed to authenticate" is the vocabulary of the thing that broke rather than a message to a person.

85. A scratch directory inside the tree the mutation walks

The rust suite could not verify a dependency bump: cargo test --offline runs against a cache on the host and a lockfile bump is, by definition, a version that cache does not hold. Three dependabot pull requests sat six days each because no receipt was earnable for any of them.

The fix is a fetch in its own container, and what makes it acceptable is how little it does. cargo fetch compiles nothing, so no build script from the pull request runs. --locked refuses to resolve anything the committed lockfile does not already pin. A guard reads that lockfile on the host first and refuses if it names a source other than crates.io, because a pull request adding a git dependency would otherwise point a networked container at a host of its choosing. The crates land in a podman overlay's upperdir, so ~/.cargo is untouched, and the run that executes the tests still has --network=none.

The first version put that upperdir inside the extracted tree, and the gate caught it within one run:

find: './.cargo-work/work': Permission denied
maintainer-merge: mutation failed to apply

Two things were wrong and the visible one was the smaller. podman creates the overlay workdir mode 000, so the mutation step's find could not traverse it. The one that mattered: the rust suite's mutation glob is *.rs, and an upperdir full of downloaded crates is full of other people's .rs files. A mutation could have landed in a dependency instead of in the pull request and the receipt would have recorded a proof of nothing.

Suite scratch is a separate mktemp -d now, and cmd_verify refuses if it ever resolves inside the extracted tree.

86. The image was wrong for the fourth time, so stop picking it

bash:5 had no python3. python:3.12-slim had no git. python:3.12 runs as uid 0. And python:3.12 has no cargo, which is what tests/release/release-rehearsal.test.sh needs, because it drives scripts/release_rehearsal.sh whose preflight is

for tool in cargo node npm sha256sum file; do

Three review runs in a row reported ERROR: required tool is missing: cargo and refused a receipt for #443, #435 and #446. Each time the image had been chosen for what its scripts needed the week it was written, and each time the gate described its own container as the contributor failing their own test.

The image is built from shell.Containerfile beside the suite now, so the recipe is in the tree rather than in the shell history of whoever built it first. maintainer-doctor refuses when a localhost/ image is missing and prints the podman build line, because an image that exists only on the host that built it is not a gate anybody else can reproduce. It costs the shared base docs.sh had, which was worth something, and buys a suite whose image comes from what its scripts declare they need.

87. Twelve of eighteen were already promised to somebody

Offering issues to recurring contributors, I built an allocation from merged pull request counts, matched each issue to what its contributor had shipped, and had a clean table of eighteen. Then I read the tracker.

Twelve of the eighteen already carried a live offer to a named person. Two more were covered by open pull requests that a withdrawal note on the issue had already explained. Nine issues were genuinely free, not eighteen, and posting the table would have double-booked eight of them, which is MISTAKES rule 7 repeating itself with better arithmetic.

The check that caught it is one loop: for each candidate, read the last comment carrying an @mention and look at what it says. An offer, an acceptance and a withdrawal all mention somebody, and only reading them tells you which. The count went from eighteen to nine in one command, and the honest report to the maintainer is that the pool was the binding constraint, not the arithmetic.

88. The deduplication existed, and I filed around it

maintainer file-issue hashes the normalised title, embeds the hash in the issue body as <!-- maintainer-finding: ... -->, and refuses to file a second issue carrying the same one. It exists because sysknife's #342 and #343 are identical titles filed nine minutes apart, one of them still open.

On 2026-09-21 a timer filed sysknife#464 through that tool, with its fingerprint. Twenty-five minutes later I filed #465, the same defect on the same two lines of the same file, through gh issue create. No fingerprint, nothing to compare, nothing refused. I closed it as a duplicate within the hour.

The deny wall names gh issue create in thirty-two spellings, so the unattended profile cannot make this mistake. Interactive work is outside that wall, and I was working from a drafts folder written by a run the day before, which had no way to record that a later run had already filed its contents.

Two changes came out of it, and the second is the general one. The issues I filed by hand got their fingerprints added afterwards, so a later run cannot duplicate them either. And a draft is not a task list: before acting on one, ask the tracker whether somebody already did it. The tool asks. I did not.

This is MISTAKES rule 7 at a new address. The rule already says to run the command whose job is the decision you are about to make by reading, and it already carries gh pr merge as its example. gh issue create is the same shape: a verb that works, beside a tool that works and also checks.

89. The scan measured a different set than the offers did

Offering issues to recurring contributors, I built a candidate list of eighteen, scanned every one of them for a live @mention offer, and found twelve already promised to somebody. That check was right and it caught a lot.

Then I chose the final nine from a different list.

Five of the issues I offered had never been in the scanned set, because I assembled the candidates before I finalised the allocation and then reached outside them for better matches. Two of those five were already held: sysknife#428 had been with @xianjianlf2 since 15 September, and #433 was held for @vsolano9 in a note three comments up the same thread. Both were double-booked for four hours until I re-scanned every open issue on the tracker rather than my own shortlist.

Nobody lost an evening, which is luck and not design.

The guard is to scan the set you are about to act on, at the moment you act, not a set you assembled earlier for a different purpose. A check whose input is stale reports on a world that has moved, and reports it confidently. The re-scan is one loop over every open issue and takes half a minute, which is less than the withdrawal it saves.

90. A warning the reader sees, and a guess the reader does not

offers reported @QinXi-ai OVER-OFFERED on sysknife, holding #428, #433 and #460 unanswered. Every one of the three was wrong, and the campaign running at the time was under instruction to hand half the new issues to that contributor.

Two were withdrawals. I had retracted #428 and #433 in public comments that said so plainly, and each named both the person I was withdrawing from and the person the issue went back to. The third named them only in a coordination note: "read #458 first, @QinXi-ai has an offer out on it", about a different issue entirely.

All three were AMBIGUOUS by the tool's own reading. It printed "who holds it cannot be read from the thread" and then charged the issue to every handle in the sentence it had just called unreadable. Acting on the guess was invisible; only the warning was on screen, so a reader who saw the warning still trusted the count beside it.

person      open  unans  eligible?
QinXi-ai       4      3  OVER-OFFERED: 3 unanswered (#460, #433, #428)   before
QinXi-ai       1      0  no: working on #459                             after

An ambiguous issue is now charged to nobody. It stays out of the free pool, because somebody does hold it; which body is the thing that could not be read. An open pull request settles a thread the way an assignee already did.

The shape to carry: when a check reports that it could not determine something, every number downstream of that determination has to stop too. Printing the uncertainty and then publishing a figure derived from it is worse than either one alone, because the figure looks like it survived the check.

91. The remedy the tool printed did nothing

The AMBIGUOUS line ended with "Assign whoever has it, or say so in a comment naming only them." The second half was false. crowded took the maximum number of handles across every comment on the thread, so a count that had reached two could never come back down, and the issue stayed ambiguous however many times I clarified it.

Nobody reported this, because the advice reads as reasonable and the person following it has no reason to re-run the command and check. I found it by following my own instruction and watching nothing change.

It reads the last comment that names anybody now, which is already the rule for each person's own state four lines below.

I tried to go further first and read a later single-name comment as a retraction. That broke the double-booking test, and it deserved to: two single-name comments are as likely to be two offers as a correction, and this file says two hundred lines up that intent is not parseable out of a sentence.

The guard is to test the remedy, not just the detection. Every warning that tells a human what to do is a claim about the system's behaviour, and it goes stale the same way a comment does. An assertion that follows the printed advice and re-checks the warning is three lines and it is the only thing that keeps the two honest.

92. A marker nothing writes is a marker nobody uses

RELEASE_MARKER has existed since the day an offer and a release were found to be the same shape in prose. No command ever wrote it. Every withdrawal I posted in three months was hand-typed English, so offers went on counting the person as a holder, which is lesson 90's root cause rather than its symptom.

maintainer release <issue> <user> <reason> writes it. It names one person and refuses a reason that names a second, because a retraction that also names the contributor the issue is going back to is what made sysknife#428 ambiguous in the first place. It reads the comment back before reporting success.

The shape: a convention that depends on a human remembering to type an exact string is not a convention, it is a hope. Either a command writes it or the parser should not look for it.

93. A gate a required step invalidates is a gate people step around

sysknife's branch protection requires an up-to-date branch. GitHub's "Update branch" button merges main into the pull request. cmd_merge then asked git diff <verified_head>..<new_head> -- PROD_GLOBS, and two dots include everything MAIN gained since the receipt, so the gate printed main's own merged commits as "production code changed since the verified head" and refused.

Every merge hit it. The way past it is gh pr merge --admin, which spends no receipt at all, and on 2026-09-23 that is what happened to sysknife#492 and #495. The gate did not fail; it made itself the expensive path, and the cheap path had no proof in it.

The receipt is a claim about the pull request's contribution, so that is what the check has to compare: merge the verified head with the main commit the branch actually contains, and diff the resulting tree against the tree being merged. Equal means the author changed nothing, including inside a conflict resolution, which comparing commit lists would miss. It falls back to the strict range when merge-tree reports a conflict, when git is too old, or when the verified head is already an ancestor of main, because that last case is not a branch update and folding the later commits in as "main's" is how a real push would slip through.

Two dots are the same trap MISTAKES.md 7c names for reading a PR diff. It turns out to be worse inside a gate than inside a report: a wrong reading in a report gets corrected by the next reader, and a wrong reading in a gate teaches everyone to bypass the gate.

94. A class with no guard to mutate is a class with no gate

verify proves a guard bites by breaking it and watching a test go red. A dependency bump has no guard, so the proof does not exist, so no receipt was ever earnable, so merge refused every Dependabot pull request. Three of them sat six days each, and the two on 2026-09-23 went through --admin.

"No proof is possible here" was true of the proof the gate knew how to ask for. The class does carry a claim: nothing outside dependency metadata changed, no lockfile line points at a source outside crates.io or registry.npmjs.org, no npm package newly runs an install script, and every action pin is a SHA that GitHub agrees is the tag its comment names. All of that is mechanical, and verify-deps checks it rather than being told about it, which is why the receipt it writes is one an unattended run may spend.

The shape: when a gate refuses a whole class, the question is not how to make the class fit the proof, it is what that class can actually prove.

95. An image that exists and an image built from the recipe are two claims

sysknife#471 waited through two review runs for a receipt the shell suite could not earn, because the suite's image had no yamllint and the release test it runs lints YAML. Doctor checked that the image existed and that every binary in suite_needs resolved inside it, which is exactly the right question and was being asked of the wrong image: a build from August answers podman image exists the same as a build from today's Containerfile.

The build now stamps the recipe's sha256 on the image as a label and doctor compares it, so a stale image reads as stale. The tool declaration moved with it: suite_needs names yamllint, so the live probe fails loudly if a later rebuild drops it.

That was the fourth tool this image has been missing (python3, git, an executable /tmp, now yamllint). Each one arrived as "the pull request fails its own test", which is the most expensive sentence this gate can say. The pattern underneath is not "add the tool": it is that a recipe in the tree and an image on a host drift silently, and nothing was comparing them.

96. A hold that names numbers goes stale in the direction that costs something

Three prompts carried "do not offer #345, #327 or #356" and "the reservation lifts after 2026-09-09". On 2026-09-23 all three issues were closed, and the twir-listed label was on seven OPEN issues the prompts said nothing about. The stale text looked like a working reservation while the actually-reserved work was free to be handed to an existing contributor, which is the one thing the rule exists to prevent.

The label is the record. The prompts now run gh issue list --label twir-listed and reserve whatever it returns. A dated exception in prose expires by being read, and nothing schedules the reading.

The same pass found the other half of the same shape: step 6 of the review prompt said "approve any fork workflow run sitting at action_required", and the deny wall forbids that POST on purpose. An instruction the wall refuses costs a turn and produces a report line that reads like a failure. Two rules about the same action have to be one rule.

97. The merge gate could not verify a single line of this repository's own code

Two defects, found on the same day, by trying to earn a receipt for a change to bin/maintainer-repo.

apply_mutation matches a basename. It runs find -name "<glob>", so a mutate glob is a basename pattern and never a path. magent's shell suite claimed bin/*, lib/*, scripts/* and profiles/* in suite_covers and mutated *.sh. Every command in this repository lives in bin/ with no extension. A pull request touching one passed the coverage check, mutated nothing, and cmd_verify refused with "the mutation changed nothing" - the right refusal for the wrong reason, and no receipt was reachable for the class.

maintainer-doctor died on an unset USER. podman run exports none, and under set -u the lingering check aborted the report before Container verification printed. The two image-recipe cases in the offline suite are what fails when that happens, so the suite reported 923 passed, 2 failed inside its own verify container, cmd_verify read the failing clean run, and refused every pull request on the grounds that the suite environment was broken. It was, and not for any reason the failing cases named. The same bare $USER sat in lib/run.sh's session_display, on the notification path, which runs only after something has already gone wrong.

Together they made every route to a receipt closed, which is how a maintainer learns to merge with --admin and how a gate becomes decorative.

Guard: the offline suite walks this repository's tracked files, takes the ones PROD_GLOBS matches and a suite covers, and requires each basename to match one of that suite's mutate globs, naming every file it cannot reach. It asserts the selection is non-empty first, because a filter that selects nothing passes. maintainer-doctor is driven with env -u USER and must reach the end of its report, and no shipped script may read $USER without a :- fallback.

98. A release verdict read "refuses" in a CHANGELOG and could not tell CI from the product

maintainer-repo release-check scores a break from the words a CHANGELOG uses: no longer, used to succeed, refuses. On 2026-09-28 sysknife merged five pull requests, every one of them documentation, CI scripts or end-to-end tests. Three entries said a gate now refuses something it used to accept, so the heuristic counted three breaks and the verdict came back:

production files touched:  5
removed-capability wording: 3 occurrence(s)
verdict: RELEASE DUE ...
digit:   middle. Something that used to succeed now refuses

Every number there is correct and the conclusion is wrong. PROD_GLOBS is the receipt-invalidation set, so it contains .github/* and scripts/*: a workflow change has to invalidate a receipt. A workflow change does not reach anybody who installed the last version. The mechanical answer was one command away:

$ git diff --name-only v0.22.0..origin/main -- crates apps packages
(nothing)

Cutting that release would have published byte-identical crates to crates.io, where a version can never be replaced or reused.

Guard: the profile declares SHIPPED_GLOBS alongside PROD_GLOBS, and release-check prints shipped surface touched: beside the production count. At zero it says a release would publish byte-identical artifacts and that any break named below is in CI. An unset SHIPPED_GLOBS prints a warning instead of zero, because unset and untouched are the two answers a guard must never conflate. Three tests in tests/run-tests.sh drive a repository whose only change since its tag is outside the shipped set.

99. A mutate glob is a file type, not a trust boundary

maintainer-merge verify asked four questions before writing a receipt: did the test pass clean, did the mutation change at least one file, did the test fail mutated, and was the mutated run killed. It never asked where the mutation landed.

A suite's suite_mutate_glob lists file types. sysknife's rust suite mutates *.rs, which is every test file as well as every source file; the shell suite mutates *.sh the same way. So a mutation aimed at the contributor's own test satisfied all four and earned a receipt, having proved that a test notices its own edit.

Two instances in one sysknife session on 2026-09-29, both in work the reviewer was doing at the time rather than in a contribution:

  • An MCP boundary test called the sanitiser directly instead of driving the route, so deleting the wiring from the route left all eight new tests green. Only mutating the route found it, and the fix was to move the boundary into the function the socket-backed test already drives.
  • A full-view renderer test used only short lines, so swapping an uncapped per-line render back to the capped one changed nothing it asserted.

The same review flagged the identical shape in a contributor's pull request an hour before making it twice. Reading a diff does not catch this. A test count does not catch this. The only thing that catches it is breaking the production code and watching the named test go red, which is what the gate is for.

Guard: cmd_verify compares the files the mutation actually changed against the profile's PROD_GLOBS, and refuses when the diff carries production code and the mutation reached none of it. The refusal prints what it did change. Only asked when the diff has production code at all, because a docs-only pull request has nothing to mutate and pick_suite already decides what its receipt may claim. Two tests drive it: one mutation into a test file, refused, and one into the production file beside it, which still earns a receipt, because a guard that refuses everything is not a guard.

100. The head is not the branch

maintainer screen warned "do not approve its queued workflow run" when the pull request's files included .github/workflows/. It read meta.files, which is the current head's diff.

sysknife#527 arrived as one commit touching Cargo.lock. Its branch had been force-pushed over 441f163c, which edited .github/workflows/ci.yml, and the author's description still described that commit because they had written it and then pushed only the lockfile. Screening the head reported nothing to be careful about. The dropped commit's queued runs were still sitting in the approval queue, and the author intended to restore it.

Approving a fork's workflow run is the control that stops a fork's code executing in CI with this repository's permissions. A reviewer who approves one run on a clean screen approves the next by habit.

The timeline does not answer this. A head_ref_force_pushed event carries commit_id, which is the commit the ref points at after the push, so the event never names what was dropped. The workflow runs do: every push queues runs pinned to the head it pushed, including for a fork, so a head_sha on that branch that is not among the pull request's commits is a head that existed and no longer does. It is also the more useful signal, because a queued run is exactly what pins one of those SHAs.

Guard: screen reports workflow exposure on every verdict, INERT included, and names any force-pushed head that touched a workflow. When it cannot read that history it says so and says to treat the approval as unsafe, because the whole value of this check is in the case where the head looks clean, and a check that goes quiet when it could not ask is worse there than no check. Five tests drive it, including one where the force-push history is innocent, so the guard cannot pass by crying wolf.

101. The gate had no arm for the ecosystem the repository actually builds on

sysknife pins its build images by manifest-list digest, so a moved or compromised tag cannot change the build, and Dependabot's docker ecosystem bumps them. classify_dependency_bump.py knew Cargo, npm and GitHub Actions, and answered Dockerfile is not dependency metadata for everything else.

That refusal is the right default and it was still a defect. sysknife#534 and #535 are one-line base-image digest bumps, and the only ways to land them were an asserted receipt, which an unattended run may not write, or gh pr merge --admin, which is the path this gate exists to remove. A gate that cannot speak about a whole ecosystem does not stop that ecosystem from merging. It stops the gate from being in the way while it does.

The claim a base-image bump can prove is the same three the rest of the class proves: nothing outside dependency metadata changed, no new place to download from appeared, and every pin still names what it claims to name. Only the third needs the network, and it needs a registry rather than GitHub.

Guard: a dockerfile arm that refuses any changed line that is not a FROM, any base image that stops being pinned by digest, and any added FROM whose image is not one a removed FROM already named, which is the no-new-source claim one registry over. scripts/resolve_image_digest.py then asks the registry what the tag resolves to, over the Registry v2 token dance with no new binary dependency, and verify-deps refuses a digest that is not the answer. Nine tests, four of them refusals pinned to their own reason: asserting only "verdict": "refused" passed while the classifier was still refusing every Dockerfile for being a Dockerfile, which is a negative test satisfied by a code path it is not about.

102. A verifier that inspected nothing looked like one with nothing to inspect

verify-deps counted the action pins it checked and wrote that count into the receipt. It read them with

while IFS=$'\t' read -r action pinsha tag; do ... done < <(python3 -c ...)

and process substitution reports the status of the redirection, never of the command inside it. A producer that died left the loop reading an empty stream, checked at zero, and a sealed receipt reading 0 action pin(s) verified about a diff that bumped a pin. MISTAKES rule 2b, in the gate whose whole job is telling a check that passed from a check that never ran.

The fix is two things, and the second is the one that matters. Command substitution propagates, so reading into a variable first turns a dead producer into a refusal. But a verifier also has to compare what it checked against what the diff declared: the classifier says how many pins and images it found, and a receipt is a claim about all of them. Zero checked out of one declared is not a clean bump, it is a check that did not run.

Guard: both loops read from a variable, and each is followed by an equality against the classifier's declared count. Mutation-proved in both directions: removing the || die alone leaves the suite green, because the count check catches it; removing both mints the receipt and the test goes red.