Skip to content

Fix what the agent-instruction overlay actually loads - #114

Draft
alycda wants to merge 12 commits into
mainfrom
agent-instruction-loading
Draft

Fix what the agent-instruction overlay actually loads#114
alycda wants to merge 12 commits into
mainfrom
agent-instruction-loading

Conversation

@alycda

@alycda alycda commented Aug 22, 2026

Copy link
Copy Markdown
Owner

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.

# Finding Severity Commit
F1 AGENTS.md is never loaded into Claude Code on any machine high 19fe2ac
F2 Two of AGENTS.md's three @ imports resolve to paths that don't exist medium 86ea6ea
F3 The container wipes the managed imports on every restart after the first high 2876791
F4 outbound-comment-gate.md is loaded twice per session low 5bbbfaf
F5 No way to verify what loaded without opening a session low 733d486

The two high-severity ones compound: AGENTS.md never reached Claude Code at all (it reads CLAUDE.md and has no AGENTS.md fallback), and in the container every session after the first ran with CLAUDE.md reset to the plain container doc — the entrypoint's unconditional cp ran 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: /context was the only inspection surface. The InstructionsLoaded hook 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, on aarch64-linux:

  • statix and deadnix clean.
  • .github/scripts/eval-configurations.sh — all seven configurations evaluate, both Darwin ones included.
  • nix build .#homeConfigurations."alyssa@dev".activationPackage builds.
  • In the built generation: rules/container-env.md resolves to the arm64 doc, the hook links 0555, and includes/agents-entrypoint.md is an out-of-store symlink to ~/.agents/AGENTS.md.
  • The CLAUDE.md sync script, run against a copy of a real hand-edited CLAUDE.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.

InstructionsLoaded is confirmed against the hooks documentation: the event exists, the five matcher values are exactly as used, it has no decision control (exit code ignored), and async/timeout are valid config fields. The event has no documented input schema, so the .event.file_path field in the audit's suggested jq one-liner is unverified — the handler logs the whole object, so the first logged line shows the real shape.

Open — why this is a draft

  • Codex @-import behaviour (F2) is untested. Codex's documented mechanism is project_doc_fallback_filenames, which is filename discovery, not import expansion. If Codex does expand imports, the right shape is to keep those two lines in AGENTS.md as 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 from preferred-tooling.md.
  • Existing container volumes need a one-time cleanup. Where the entrypoint has already copied the container doc into CLAUDE.md, the sync script treats that body as hand-edited and keeps it — so the same text also loads as rules/container-env.md. Delete the stale body once per existing claude-home volume.
  • Cowork. Every include here is an out-of-store symlink into ~/.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.

alycda added 5 commits August 22, 2026 19:36
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.
@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

⊕ Entity-level changes

Dockerfile

Status Type Name
Δ chunk lines 101-120
↻ Δ chunk lines 121-138 -> lines 121-135

docker/CLAUDE-arm64.md

Status Type Name
Δ heading Layout and persistence

docker/entrypoint.sh

Status Type Name
Δ orphan module-level

home-manager/modules/tools/agents.nix

Status Type Name
Δ orphan module-level
+ orphan module-level
+ binding claudeImportBlock
+ orphan module-level
+ binding claudeMdSync
Δ binding home.file
~ orphan module-level
Δ binding home.activation.claudeAgentsImports

home-manager/modules/tools/claude-code.nix

Status Type Name
~ orphan module-level
Δ binding home

home-manager/profiles/dev.nix

Status Type Name
Δ binding home

secrets/personal/agent-instructions.age

Status Type Name
Δ chunk lines 1-20
- chunk lines 21-24
+ chunk lines 21-40
+ chunk lines 41-46

tools/agents/AGENTS.md

Status Type Name
Δ heading Who I Am
Δ heading Communication
Δ heading Working Agreement

tools/agents/README.md

Status Type Name
Δ heading How it deploys (Home Manager / Nix)
Δ heading Surface integration

tools/claude/hooks/log-instructions-loaded.sh

Status Type Name
+ orphan module-level

tools/claude/settings.json

Status Type Name
~ property cleanupPeriodDays
+ array InstructionsLoaded

cleanupPeriodDays

- "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

alycda added 7 commits August 22, 2026 21:28
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant