Fix what the agent-instruction overlay actually loads - #114
Draft
alycda wants to merge 12 commits into
Draft
Conversation
Claude Code reads CLAUDE.md and has no AGENTS.md fallback. The overlay used none of the three documented bridges (an @import inside CLAUDE.md, a symlink, or /import), so tools/agents/AGENTS.md - the identity, communication and working-agreement sections, the densest behavioural part of the whole overlay - reached Codex and nothing else. tools/agents/README.md has been calling it the "canonical entrypoint" for both surfaces; for Claude that was aspirational. It stayed invisible because the work machine's hand-edited ~/.claude/CLAUDE.md duplicates the AGENTS.md body inline. The content was there, via a copy that has since drifted ahead of the tracked file. The fix isn't a one-line append, because appending can't express precedence: a new layer always lands last, and the entrypoint has to be *first* - it carries the composition contract that says later layers refine earlier ones and the private overlay wins on conflict. Appending also needs a bespoke grep -vxF migration for every line a past generation wrote, which is what the removed loop had accumulated. So activation now rewrites a marker-delimited block at the top of CLAUDE.md wholesale. Everything outside the markers is preserved verbatim, so the file stays hand-edited; adding, removing or reordering a layer is a one-line edit with no migration, and running the switch twice is byte-identical. Block-level HTML comments are stripped before injection, so the markers cost no context. agents-company-values.md is deliberately not in the block: AGENTS.md imports ~/.agents/company-values.md by absolute path already, and listing it here would expand the layer twice. The include symlink stays for the capsule. Kept at entryAfter linkGeneration for the reason the append had it - the block must not name includes that haven't been linked yet.
Two changes to the same file, both about whether a line has any effect. @ imports resolve relative to the file that contains them, not the working directory. From ~/.agents/AGENTS.md, `@includes/agents-instructions.private.md` and `@rules/outbound-comment-gate.md` resolve to ~/.agents/includes/ and ~/.agents/rules/, neither of which exists - both lines were written in the ~/.claude/ frame. They're deleted rather than repointed: after the managed block both layers already load at the CLAUDE.md level, so nothing is lost. Unresolved: whether Codex expands @ imports in AGENTS.md at all. Its documented mechanism is project_doc_fallback_filenames, which is filename discovery, not import expansion. If Codex does expand them, the swap is to keep these two as absolute @~/.agents/... paths and drop the matching lines from the managed block instead. Testable in five minutes - start Codex at a repo root and ask it to quote a line from preferred-tooling.md. Separately, two lines stated facts about me instead of instructing behaviour, which is the difference between something a model can act on and decoration: "my clarifying questions are for understanding" becomes an instruction to answer the question and not revise an unargued position, and "I process bottom-up" gets paired with what it implies, including the runnable-experiment instruction that already existed on the work machine only. Also folds in three bullets that had drifted onto the work machine's copy (conflict-naming, no "soon"/"ASAP", state-the-decision-behind-the-question) and fixes SDKs team -> SDK team.
claude-code.nix installs the gate at ~/.claude/rules/, and ~/.claude/rules/ is user-scope: Claude Code discovers every .md in it and loads it into every session with no import line. The activation entry that also appended "@rules/outbound-comment-gate.md" to CLAUDE.md therefore pulled the same file in a second time, and both copies entered context every session. The append predates user-level rules support - it was the only mechanism available when it was written. Removing the entry changes nothing but the duplication: rules without `paths:` frontmatter load at launch with the same priority as CLAUDE.md itself. The stale line is stripped from existing CLAUDE.md files by the sync script added in the managed-block change, along with the other legacy bare imports.
docker/entrypoint.sh copied the per-arch container doc over
/root/.claude/CLAUDE.md unconditionally, and it ran *before* the home-manager
activation below it - which is conditional on the baked generation differing
from the volume's. So:
start 1, fresh volumes: cp clobbers, activation runs -> imports present
start 2, warm devhome: cp clobbers, activation skipped -> imports gone
start N: same as 2
Every session after the first ran with CLAUDE.md reset to the plain container
doc: no agent-instruction layers, no rules import. Silently - nothing logs it,
and /context inside a live session was the only place it showed.
The copy was guarding a real problem: /root/.claude is the claude-home volume,
which shadows anything baked into the image at that path, and Docker seeds a
named volume from the image only once. But the guard introduced a worse
failure than the one it prevented.
Ship the doc inside the generation instead, as ~/.claude/rules/container-env.md
from the dev profile. ~/.claude/rules/ is user-scope - Claude Code discovers
every .md there and loads it into every session with no import line - which is
exactly what the doc is for ("applies regardless of which project is mounted at
/work"). It arrives with the generation, updates when the generation updates,
and nothing overwrites it. Both the Dockerfile copy and the entrypoint copy go
away, along with the shadowing problem they existed to solve.
Arch selection moves from `uname -m` to pkgs.stdenv.hostPlatform.isAarch64:
both alyssa@dev (aarch64-linux) and alyssa@dev-x86 (x86_64-linux) use this
profile, so it's known at eval time.
One migration wrinkle, not handled here: on a container whose claude-home
volume already has the doc copied into CLAUDE.md, that body is hand-edited
content as far as the sync script is concerned, so it stays - and the same text
now also loads as rules/container-env.md. Delete the stale body from
/root/.claude/CLAUDE.md once per existing volume.
Every problem in this series was invisible for the same reason: the only way to
inspect the loaded set is /context inside a live session. For a setup this
declarative that's the wrong shape - a wiped import block or a dangling include
looked exactly like a working one from outside.
The InstructionsLoaded event fires when a CLAUDE.md or .claude/rules/*.md file
is loaded, at session start and again on lazy loads, with a matcher on the
reason (session_start, nested_traversal, path_glob_match, include, compact). It
has no decision control, which makes it purely an audit surface.
The handler is 20 lines of POSIX sh appending the whole event object as JSONL
to $XDG_STATE_HOME/claude/instructions-loaded.jsonl. No jq dependency: JSON
escapes real newlines inside strings as \n, so `tr -d '\n'` can only strip
pretty-printing whitespace and cannot corrupt a value. async: true keeps it off
the startup path. Every path exits 0 - Claude Code ignores the exit code, so
the only failure worth guarding is one that writes garbage into the log.
Verifying the whole overlay then becomes:
jq -r '.event.file_path // .event' \
~/.local/state/claude/instructions-loaded.jsonl | sort -u
The file is linked executable on purpose: home.file links store copies 0444 by
default, and a non-executable hook fails with a shell 127 that Claude Code
reports as a non-blocking error - easy to miss.
Worth knowing before more hooks are added this way: the managed-settings merge
uses jq's `*`, which recurses into objects but *replaces* every non-object,
arrays included. Naming an event in tools/claude/settings.json therefore claims
that event outright - a handler added under the same key through the UI is
dropped on the next activation. Other events are untouched. Right behaviour for
managed config, but it's ownership, not addition. Noted in a comment there too.
Contributor
⊕ Entity-level changesDockerfile
docker/CLAUDE-arm64.md
docker/entrypoint.sh
home-manager/modules/tools/agents.nix
home-manager/modules/tools/claude-code.nix
home-manager/profiles/dev.nix
secrets/personal/agent-instructions.age
tools/agents/AGENTS.md
tools/agents/README.md
tools/claude/hooks/log-instructions-loaded.sh
tools/claude/settings.json
- "cleanupPeriodDays": 90
+ "cleanupPeriodDays": 90,Summary: 8 added, 18 modified, 1 deleted, 1 renamed across 11 files (3 added orphans, 4 modified orphans) functions and classes, not lines · sem |
The awk strip treats everything after BEGIN as managed until it reaches END, so a file with a BEGIN and no END lost its entire hand-edited remainder on the next activation. Reproduced against the real script: a six-line CLAUDE.md with the END marker deleted came back as the block alone, with `mv` making it atomic and nothing backed up. That is not a hypothetical hand-edit accident. The markers are invisible HTML comments inside a file whose whole point is that you edit it by hand, and tools/agents/README.md now displays both of them in a fenced block - copying the documented shape out of the README and pasting half of it is enough. Skip rather than abort: a mangled marker isn't a reason to fail an otherwise good activation, and the imports already in the file keep working until the markers are repaired. The refusal prints what it found, since the failure it replaces was silent. Verified the guard doesn't cost anything: a normal file still syncs, is still byte-identical on a second run, and still keeps its hand-edited body; a missing file is still created with the block.
The EXIT trap covered $tmp but not $md.new. If the write that assembles the new file fails part way - ENOSPC is the realistic one - set -e aborts before the mv, so CLAUDE.md itself is untouched (the right failure direction) but a truncated CLAUDE.md.new is left in ~/.claude permanently, with nothing to ever clean it up.
With empty stdin the hook appended {"ts":"...","event":} - and one malformed
record makes the whole log unreadable to `jq -c .`, not just that line. The
script's own comment says the failure worth guarding is one that writes
garbage into the log; exit 0 on every path is what permitted it.
Read the event first, and write nothing unless it looks like an object. That
also closes a second hole: the three separate writes shared one O_APPEND fd,
and InstructionsLoaded fires repeatedly per session with async:true, so
overlapping invocations could interleave their fragments. One printf of a
string composed in memory instead.
Verified: empty stdin and junk stdin now leave no log file at all; a
pretty-printed multi-line event logs as one valid JSON line with an embedded
\n inside a string value surviving intact - which is the property the no-jq
approach rests on.
It appends several records per session and lives on the persistent devhome volume, with nothing to bound it: cleanupPeriodDays sweeps session transcripts, not this file. One generation back is enough. The log answers "what loaded recently", and anything older than the last activation has been superseded by it. The size check needs `[ -f ]` before the redirect, not `2>/dev/null` on wc: a redirect from a missing file is the *shell* reporting the error, so the suppression on wc never applied and it printed a diagnostic on every fresh log - which for a hook is noise in exactly the place noise is hard to trace.
It still described being re-copied over ~/.claude/CLAUDE.md on every container start, which is precisely the mechanism this branch removed. Now that the file ships as ~/.claude/rules/container-env.md it is always-loaded, so the stale sentence isn't a stale comment in a repo - it's a false statement about the environment, asserted into every session on the machine it describes.
Edited on top of main's current ciphertext, after merging #113 in — the copy on this branch was still the pre-#113 blob, and editing that would have silently reverted the overlay update that landed with it. Recipients in secrets/secrets.nix are unchanged, so this is an edit, not a rekey. Verified before committing that it still decrypts with the personal identity and kept its armored encoding. Contents deliberately not described: the overlay exists so that layer is only ever ciphertext in this repo.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Audit of the issue #40 overlay against Claude Code's documented loading behaviour, and the five fixes it turned up. Five commits, one per finding.
AGENTS.mdis never loaded into Claude Code on any machine19fe2acAGENTS.md's three@imports resolve to paths that don't exist86ea6ea2876791outbound-comment-gate.mdis loaded twice per session5bbbfaf733d486The two high-severity ones compound:
AGENTS.mdnever reached Claude Code at all (it readsCLAUDE.mdand has noAGENTS.mdfallback), and in the container every session after the first ran withCLAUDE.mdreset to the plain container doc — the entrypoint's unconditionalcpran before the conditional home-manager activation, so the copy clobbered the import block and the skipped activation never put it back.Both were invisible from outside a live session, which is F5's point:
/contextwas the only inspection surface. TheInstructionsLoadedhook now logs what loads, when, and why.Verification
The Nix in this series was written in an environment with no
nix, so none of it had been evaluated. It has been now, onaarch64-linux:statixanddeadnixclean..github/scripts/eval-configurations.sh— all seven configurations evaluate, both Darwin ones included.nix build .#homeConfigurations."alyssa@dev".activationPackagebuilds.rules/container-env.mdresolves to the arm64 doc, the hook links0555, andincludes/agents-entrypoint.mdis an out-of-store symlink to~/.agents/AGENTS.md.CLAUDE.mdsync script, run against a copy of a real hand-editedCLAUDE.md: block lands at line 1, the hand-edited body is preserved verbatim, legacy bare imports are stripped, and a second run is byte-identical. That's the idempotence the append loop never had.InstructionsLoadedis confirmed against the hooks documentation: the event exists, the five matcher values are exactly as used, it has no decision control (exit code ignored), andasync/timeoutare valid config fields. The event has no documented input schema, so the.event.file_pathfield in the audit's suggestedjqone-liner is unverified — the handler logs the whole object, so the first logged line shows the real shape.Open — why this is a draft
@-import behaviour (F2) is untested. Codex's documented mechanism isproject_doc_fallback_filenames, which is filename discovery, not import expansion. If Codex does expand imports, the right shape is to keep those two lines inAGENTS.mdas absolute@~/.agents/…paths and drop the matching lines from the managed block instead. Test: start Codex at a repo root, ask it to quote a distinctive line frompreferred-tooling.md.CLAUDE.md, the sync script treats that body as hand-edited and keeps it — so the same text also loads asrules/container-env.md. Delete the stale body once per existingclaude-homevolume.~/.agents, and Cowork sessions skip user-scope imports that resolve outside the session working directory. The overlay does not load there. Nothing in this series changes that; flagging it because it's otherwise invisible.