2 of 8 compactions dropped the body of a skill I had invoked earlier in the session, even though the Claude Code docs say invoked skill bodies are re-injected: both were
/compactsent throughclaude -p --resume, while the 6 compactions that ran inside a single process re-attached the skill every time. All 8 put the root CLAUDE.md and its@importback exactly as they were on disk, kept a passphrase typed in chat only inside the summary, and brought a nested CLAUDE.md back without a new Read only when it was among the files Claude Code re-reads after compacting.
In July this account published What survives compaction in Claude Code — and how to keep your rules alive. It sorted instructions into what survives compaction and what dies, and recommended a SessionStart hook with matcher: compact for the rest. It showed no measurements, and it said nothing about what happens to skills. It did end with a piece of advice: run /compact manually mid-session and check whether the agent still has the rule. Since then the Claude Code docs have gained a table that lists, mechanism by mechanism, what happens after compaction. This article is that manual check, read off transcripts, and it tests the July post and the docs table side by side.
The method is the one from Six CLAUDE.md files, six codewords, which measured when each CLAUDE.md first loads. This time the question is what is left after /compact. I put a marker string in five places: the root CLAUDE.md, a file it imports with @, a CLAUDE.md in a subdirectory (loaded by a Read there before compacting), the body of a skill invoked before compacting, and a passphrase typed in chat. After compacting, I asked the model which markers it could still see and checked the answer against the session transcript under ~/.claude/projects/, which records every attachment Claude Code sends and the token usage of every request. Later runs added an unscoped rule, a path-scoped rule and a compact hook.
Everything ran on 2026-09-30 with Claude Code 2.1.285 (claude --version) and claude-opus-5-5, in throwaway directories: 18 claude -p processes, 8 compactions, 12 probes.
Can claude -p run /compact at all?
The headless page lists how command support differs in -p mode: user-invoked skills and custom commands work, and "Built-in commands that only run in the terminal interface, such as /login, aren't available." It does not say which side /compact is on. The Agent SDK pages do. The agent loop page says "Send /compact as a prompt string to trigger compaction on demand," and the SDK skills page adds that "Commands that act on conversation history, such as /compact, need prior messages to work with", so "use this pattern in a session with prior turns, for example in streaming input mode or when resuming a session."
Both of those shapes work from the CLI. claude -p "/compact" --resume <session-id> --output-format json came back after about 20 seconds with "result": "" and num_turns: 0, and the transcript gained a compact_boundary record ("trigger": "manual", "preTokens": 23412, "postTokens": 1740) plus the local command output Compacted (ctrl+o to see full summary). A single process started with --input-format stream-json took /compact as its third user message; its output stream showed a compacting status event, a compact_boundary event, and a result with an empty string. The lab uses both shapes, and the difference between them turned out to be the main finding.
The lab
The base layout is five files and one chat message:
CLAUDE.md Root codeword: MK-ROOT-4417, then @notes/imported.md
notes/imported.md Import codeword: MK-IMPORT-2290
sub/CLAUDE.md Subdirectory codeword: MK-SUB-6621
sub/data.txt "This is a plain data file. It holds no codeword."
.claude/skills/lab-skill/SKILL.md Skill codeword: MK-SKILL-1938 (reply READY)
chat message "the passphrase is MK-PASS-8053"
The four instruction files are padded with filler lines that ask for nothing. A session sends four messages: /lab-skill; then "For the rest of this session, the passphrase is MK-PASS-8053. Now use the Read tool once on sub/data.txt and reply with only the first line of that file." (that Read is what loads sub/CLAUDE.md); then /compact; then the probe.
Between the passphrase message and /compact, a script rewrites the root CLAUDE.md, the import, the nested CLAUDE.md and the SKILL.md with new numbers (MK-ROOT-7093, MK-IMPORT-6158, MK-SUB-3846, MK-SKILL-5702). That edit is what makes the answers readable. After compaction, an old number can only have come from the conversation: the summary, or a copy Claude Code kept. A new number can only have come from disk. The passphrase is never edited, and it has no copy on disk at all.
The probe: "Do not use any tools. From what is already in your context, list every string that starts with MK- that you can see, one per line. After each one, add a few words on where it appears. Do not guess strings you cannot see." I then collected every MK- string in the transcript records that were sent to the model. In all 12 probes the model's list and the transcript's list were identical, so below the transcript is the evidence and the answers are the commentary.
Five shapes, two runs each:
-
A, resumed: five
claude -pprocesses, one per step, each after the first started with--resume. The steps are the skill call, the passphrase message, a probe with--fork-session(a control with no compaction that leaves the original session untouched),/compact, and the probe. -
B, one process: the same four messages through one
claude -p --input-format stream-jsonprocess, with the edit in between. -
BC: B without
/compact, as a control. -
C: B plus an unscoped rule in
.claude/rules/always-on.md, a rule withpaths: ["sub/**"], and aSessionStarthook with matchercompactthat runscat .claude/reinject.md. All three carry markers and get edited too. -
D: C's files with no edit and seven Reads instead of one:
sub/data.txt, thenother/f1.txttoother/f6.txt. I back-datedsub/data.txt,sub/CLAUDE.mdand the path rule by two days withtouch, so they were the least recently modified files, which is how long-lived rules files usually look next to files touched today.
A and B ran with --settings '{"disableAllHooks": true, "autoMemoryEnabled": false}' and --strict-mcp-config, so no user hook and no auto memory could add text. C and D needed hooks, so they turned off only auto memory and used --setting-sources project,local to keep user settings out. Reads were allowed with --allowedTools Read.
What the docs say comes back
The memory page:
Project-root CLAUDE.md survives compaction: after
/compact, Claude re-reads it from disk and re-injects it into the session. Nested CLAUDE.md files in subdirectories and rules withpaths:frontmatter reload as Claude reads files they apply to.
The context window page has a table under "What survives compaction". These are the rows that matter here:
| Mechanism | After compaction |
|---|---|
| Project-root CLAUDE.md and unscoped rules | Re-injected from disk |
Rules with paths: frontmatter |
Claude Code reloads them as Claude reads files they match |
| Nested CLAUDE.md in subdirectories | Claude Code reloads them as Claude reads files in that subdirectory |
| Files Claude read or edited | Claude Code re-reads up to five, most recently modified first |
| Invoked skill bodies | Re-injected, capped at 5,000 tokens per skill and 25,000 tokens total; oldest dropped first |
SessionStart hooks that match the compact source |
Claude Code runs them and adds their output to the compacted context |
Under the table: "Path-scoped rules and nested CLAUDE.md files load into message history when their trigger file is read, so compaction summarizes them away with everything else." The skills page describes the skill row in more detail: "When the conversation is summarized to free context, Claude Code re-attaches the most recent invocation of each skill after the summary, keeping the first 5,000 tokens of each." And the context window page's startup view says of the skill listing: "Unlike the rest of the startup content, this listing is not re-injected after /compact. Only skills you actually invoked get preserved."
One more sentence explains why the edit is a fair test. From the prompt caching page: "Your project-root and user-level CLAUDE.md files are read once at session start and held in memory. Editing them mid-session does not invalidate the cache, but the edit also doesn't apply." The new content "loads on the next /clear, /compact, or restart."
I fetched every page as full text with trafilatura on 2026-09-30. Trafilatura dropped the table and the startup-view text, so I took those two from the page's markdown version and checked them against its HTML; the wording matched.
What came back
| Marker | How it came back after compaction | Compactions |
|---|---|---|
| Root CLAUDE.md | Re-read from disk on the next turn | 8 of 8 |
@import file |
Re-read from disk with the root file | 8 of 8 |
| Unscoped rule (C, D) | Re-read from disk with the root file | 4 of 4 |
| Skill body | Re-attached from the stored invocation, old text after an edit | 6 of 8 (0 of 2 in A) |
| Nested CLAUDE.md | Re-read as one of the files Claude had read | 4 of 8 (B, C) |
| Path-scoped rule (C, D) | Re-read as one of the files Claude had read | 2 of 4 (C) |
| Passphrase from chat | Only as text in the summary | 8 of 8 |
| Compact hook output (C, D) | Hook ran, its stdout attached | 4 of 4 |
This is everything that carried a lab file back after the boundary in a one-process run, B1 (the filter hides a one-line listing update):
And the same session compacted through --resume, A1:
The root file comes back from disk, next to its old self
In all 8 runs, the first turn after compaction carried an instructions attachment with the root CLAUDE.md and notes/imported.md as they were on disk at that moment. In the six runs with an edit, that meant 7093 and 6158. The control without compaction, BC, confirmed the prompt caching page: after the same edit, the model still saw only 4417 and 2290. Resuming counts as the restart in that page's sentence. The fork probe in A, a fresh process with no compaction, received the new root and import in an attachment that opens "Instruction files were re-read when this session started; these differ from their earlier copies — each replaces its earlier copy".
The summary is the catch. All 8 summaries listed every old marker, so in the six edit runs the model held two root codewords after compaction: 4417 in the summary and 7093 in the file. The attachment that carried the new file said nothing about replacing anything, unlike the resume case. All six edit runs noticed. A2: "Either the files changed, or the summary is wrong. I can't tell which from here." B1 and B2 also left it open. C1 sided with the files ("The file contents above are the ones to trust."), and so did C2, which explained the gap with "the summary probably got those five values wrong." It had not: the summary was a correct record of files that had since changed. A1 reported the mismatch and noted that only the new numbers were actually shown to it.
Editing CLAUDE.md in a long session and then compacting creates exactly this state: the new text in context, the old text restated in the summary, and nothing that retires the old one.
The skill body: re-attached in one process, missing after --resume
In B, C and D, after the summary and the re-read files came an invoked_skills attachment that begins: "The following skills were invoked EARLIER in this session (before the conversation was compacted), not on the current turn. They are shown here for context only so you remain aware of their guidelines." The body followed. That is the documented behaviour, 6 of 6.
It was the old body, though. In B and C, SKILL.md on disk said MK-SKILL-5702 by then, and the re-attached body said 1938. The skills page does say "the most recent invocation", so this is a copy of what was sent when the skill ran, not a fresh read. B1 and B2 both remarked that "The skill codeword matches in both places." It did, because both places were old.
In A there was no invoked_skills attachment at all. The process that ran /compact had been started with --resume, and the skill had been invoked in the first of the five processes. After compaction, the only trace of the skill was the summary's sentence about it. A1: "I can't see the skill file or sub/CLAUDE.md directly anymore, so their codewords come only from the summary." A2 said the same in other words.
What A got instead was a skill listing. The probe ran in yet another resumed process, and that process sent the full startup listing again: 44 skills, about 19,000 characters, flagged as an initial listing. In B and C the listing after compaction was a one-entry update naming only lab-skill, and D had none, which fits the startup view's "not re-injected after /compact". In A the sentence did not hold, because a new process sends its own listing. The first request after compaction came to 22,676 and 22,776 tokens in A, against 18,748 and 18,950 in B.
I did not read Claude Code's source, so I cannot say where the record of an invocation lives. The observable rule is enough to plan around: when the compaction ran in a different process from the skill call, the body did not come back.
The nested CLAUDE.md came back as a Read nobody made
In B and C, sub/CLAUDE.md was back after the summary with no new Read, carrying the new number 3846. How it came back is the interesting part. Before compaction it had arrived as a memory attachment that starts Contents of <lab>/sub/CLAUDE.md: (paths shortened here). After compaction it arrived as a tool call, Called the Read tool with the following input: {"file_path":"<lab>/sub/CLAUDE.md"}, followed by Result of calling the Read tool: and the file with line numbers. No Read of that file had ever been called. The five-file re-read had treated the nested CLAUDE.md as a file Claude read. In C, the path-scoped rule came back the same way, with its new number 1409. The no-compaction control points the same direction: in BC the nested file's edit reached the model on the very next turn, as a note that begins Note: <lab>/sub/CLAUDE.md changed on disk since you last read it., while the root file's edit did not arrive at all.
Neither documented path covers this. The memory page says nested files reload "as Claude reads files they apply to", and no such read happened. The context window page says compaction "summarizes them away", and in B and C it did not.
D tested the limit of five. With seven files read and the sub/ files back-dated, five of the six other/ files came back and nothing from sub/ did: not sub/data.txt, not sub/CLAUDE.md, not the path rule. D1: "MK-SUB-6621: only in the compaction summary. The sub/CLAUDE.md content itself is not in my context." The six other/ files had been created within a few milliseconds of each other, and the two runs kept a different five. D1 dropped f1.txt, the oldest. D2 dropped f3.txt, which shares its modification millisecond with f1.txt and f2.txt, so the documented order holds if times are compared to the millisecond; that last part is my inference. In A, only sub/data.txt was re-read, and the nested file was not on the list at all, the same pattern as the skill.
So a nested CLAUDE.md survived in 4 of 8 compactions, each time because it was among the five most recently modified files that one process remembered reading. For a rule, that is a thin thread to hang on.
The passphrase lives only in the summary
In 8 of 8 compactions the passphrase was in the summary and nowhere else, as the memory page's "given only in conversation" case predicts. It was verbatim every time, three to five times per summary, and every summary quoted my passphrase message word for word. I would not read much into that. The lab directory was named compact-what-instructions-survive, the files called their strings codewords, and every summary identified the session as a lab about what survives compaction, by purpose or by directory name. A summarizer that knows what is being tested will keep the test's strings. Treat 8 of 8 as the best case.
The compact hook
In C and D, a hook_success attachment followed the summary: "SessionStart:compact hook success: Reinjected after compaction. Hook codeword: MK-HOOK-8316" in C, where the file had been edited, and MK-HOOK-2745 in D, where it had not. The hook runs after compaction and reads the file then, so like the root CLAUDE.md it delivers what is on disk rather than what was loaded at start.
The July explainer, claim by claim
| Claim in the July post | Result in these runs |
|---|---|
| The root CLAUDE.md is re-read, so project-level instructions "come back verbatim" | Held, 8 of 8, as the file on disk after compaction rather than the one loaded at start |
| Unscoped rules "are retained the same way" | Held, 4 of 4 |
| Scoped rules are "gone until something re-triggers them" | Held in 2 of 4 (D). In C the path rule came back with no trigger, through the file re-read |
| Mid-session instructions "exist only as whatever the summary kept of them" | Held, 8 of 8 |
| For injected context from hooks or tool output, "The raw text is gone" | Not for files read with the Read tool: between 1 and 5 of them came back in full, re-read from disk, in all 8. Output from earlier hooks was not tested |
A SessionStart hook with matcher: compact injects its stdout |
Held, 4 of 4 |
| Skills | Not covered. Re-attached in 6 of 8 as the copy from invocation time, missing in both --resume runs |
Its advice held wherever it was tested. What it missed were skills, and the fact that file reads, a kind of tool output, come back.
What I would change after this
These follow from the runs above. I have not tested whether each change fixes the problem in a long real session.
- Keep anything that must hold after compaction in the root CLAUDE.md, a file it imports, or an unscoped rule. Those came back from disk in every run.
- Do not count on nested CLAUDE.md files or path-scoped rules across a compaction. They came back only when they were among the five most recently modified files that one process had read.
- If you edit CLAUDE.md mid-session, expect the model to see the summary's version of the old text next to the new file after
/compact, with nothing saying which wins. A restart through--resumeadded an explicit "each replaces its earlier copy" note; compaction did not. - If you edit a SKILL.md mid-session, invoke the skill again after compacting. The re-attached body is the copy from the last invocation. The skills page gives the same advice for a different reason: "re-invoke it after compaction to restore the full content."
- If you script compaction as
claude -p "/compact" --resume <id>, expect invoked skills to be gone afterwards: re-invoke them, or keep the whole session in one--input-format stream-jsonprocess. A compact hook delivered its text in 4 of 4 one-process runs, but I did not run it through--resume.
What I did not measure
-
Automatic compaction. Every compaction here was a manual
/compact. The context window page says "The automatic pass works the same way as the/compactstep in the timeline." I did not check that. -
Other entry points. No interactive terminal, no
--continue, no Agent SDK code; onlyclaude -p, resumed or streaming. -
The compact hook under
--resume. A ran with hooks disabled. - The caps. One small skill and small files only, so the 5,000-token cap per skill, the 25,000-token total, and a file over 5,000 tokens coming back as a reference were never exercised.
-
Other instruction sources. No user-level CLAUDE.md, no CLAUDE.local.md, no plan file, no
/compactwith focus instructions, and auto memory was off in every run. - Obedience. The probe asks what the model can see, not whether it follows it.
- Other models and real sizes. Every recorded request went to claude-opus-5-5. The sessions held about 20,400 to 23,400 tokens before compaction, most of it system prompt and tool definitions, so compaction barely shrank them.
- The cause. Why the resumed process lost the skill invocation and the nested file is inferred from transcripts only.
- The hint. As noted above, the summarizer could tell what the test was about.
Reproduce it
The setup script writes version 1 or 2 of the four files:
#!/bin/bash
# usage: setup.sh <dir> <1|2>
set -euo pipefail
P="$1"; V="$2"
if [ "$V" = 1 ]; then ROOT=MK-ROOT-4417; IMP=MK-IMPORT-2290; SUB=MK-SUB-6621; SKL=MK-SKILL-1938
else ROOT=MK-ROOT-7093; IMP=MK-IMPORT-6158; SUB=MK-SUB-3846; SKL=MK-SKILL-5702; fi
mkdir -p "$P/notes" "$P/sub" "$P/.claude/skills/lab-skill"
filler() { for i in $(seq -w 1 "$1"); do echo "- Filler line $i for the $2 file. It asks for nothing."; done; }
{ printf '# Lab instructions (project root)\n\nRoot codeword: %s\n\n@notes/imported.md\n\n' "$ROOT"; filler 12 root; } > "$P/CLAUDE.md"
{ printf '# Imported notes\n\nImport codeword: %s\n\n' "$IMP"; filler 24 imported; } > "$P/notes/imported.md"
{ printf '# Lab instructions (sub directory)\n\nSubdirectory codeword: %s\n\n' "$SUB"; filler 36 subdirectory; } > "$P/sub/CLAUDE.md"
{ printf -- '---\nname: lab-skill\ndescription: Measurement skill for a lab. Only run it when the user types /lab-skill.\n---\n\nSkill codeword: %s\n\nWhen this skill runs, reply with exactly the word READY and nothing else.\n\n' "$SKL"; filler 48 skill; } > "$P/.claude/skills/lab-skill/SKILL.md"
[ -f "$P/sub/data.txt" ] || echo "This is a plain data file. It holds no codeword." > "$P/sub/data.txt"
Shape A. Keep setup.sh outside the lab directory so the model cannot stumble on the version 2 numbers, and use mktemp -d, which also removes the directory-name hint my runs had. $R2 and $PROBE hold the passphrase message and the probe quoted above:
SETUP="$PWD/setup.sh"; D=$(mktemp -d); SID=$(uuidgen | tr A-Z a-z)
S='{"disableAllHooks": true, "autoMemoryEnabled": false}'
F=(--output-format json --settings "$S" --strict-mcp-config)
"$SETUP" "$D" 1 && cd "$D"
claude -p "/lab-skill" --session-id "$SID" --max-turns 1 "${F[@]}"
claude -p "$R2" --resume "$SID" --allowedTools Read --max-turns 3 "${F[@]}"
"$SETUP" "$D" 2
claude -p "$PROBE" --resume "$SID" --fork-session --max-turns 1 "${F[@]}"
claude -p "/compact" --resume "$SID" --max-turns 1 "${F[@]}"
claude -p "$PROBE" --resume "$SID" --max-turns 1 "${F[@]}"
Shape B is one process fed through stdin. The core of the Node driver:
const child = spawn('claude', ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json',
'--verbose', '--session-id', sid, '--allowedTools', 'Read', '--max-turns', '3',
'--settings', settings, '--strict-mcp-config'], { cwd: dir })
// send() writes {"type":"user","message":{"role":"user","content":text}} as one line
// to stdin and resolves on the next {"type":"result"} line from stdout
await send('/lab-skill')
await send(r2)
execFileSync(setup, [dir, '2'])
await send('/compact')
const answer = await send(probe)
child.stdin.end()
And the filter behind both images, which prints each attachment after the boundary with the markers found in the text the model received (the rendered field of each record):
T=$(find ~/.claude/projects -name "$SID.jsonl" | head -1)
awk 'f; /"subtype":"compact_boundary"/{f=1}' "$T" \
| jq -r 'select(.type=="attachment") | .attachment as $a
| select(["file","invoked_skills","instructions","skill_listing","hook_success"] | index($a.type))
| [$a.type, ($a.displayPath // (if $a.type=="skill_listing" then "\($a.skillCount) skills" else "" end)),
((.rendered|tostring) | [scan("MK-[A-Z]+-[0-9]{4}")] | unique | join(" "))]
| map(select(. != "")) | join(" ")'
Numbers, for the record
| Run | preTokens | postTokens | Compaction (ms) | First request after | Summary (chars) |
|---|---|---|---|---|---|
| A1 | 23,412 | 1,740 | 18,886 | 22,676 | 3,868 |
| A2 | 23,412 | 1,793 | 21,269 | 22,776 | 4,080 |
| B1 | 23,412 | 3,466 | 19,696 | 18,748 | 4,027 |
| B2 | 23,412 | 3,608 | 21,869 | 18,950 | 4,594 |
| C1 | 20,411 | 3,737 | 12,116 | 19,986 | 3,083 |
| C2 | 20,411 | 3,756 | 13,699 | 19,981 | 3,158 |
| D1 | 21,599 | 2,889 | 13,826 | 18,506 | 2,982 |
| D2 | 21,599 | 2,895 | 10,445 | 18,496 | 3,009 |
preTokens, postTokens and the compaction time come from the compact_boundary record. "First request after" is input plus cache-read plus cache-creation tokens of the probe's first model request, and 11,431 to 11,547 of those were cache reads of the fixed prefix in every run. The last request before compaction was 23,394 tokens in A and B, 20,393 in C and 21,594 in D, so compaction shrank the next request by 618 to 718 tokens in A, 4,444 to 4,646 in B, about 410 in C and about 3,090 in D. The re-sent listing of about 19,000 characters is the largest thing A had that B did not, but I did not weigh attachments one by one. The two BC controls, with no compaction, sent 23,938 tokens with their probe. Claude Code reported 2.73 USD for all 18 processes; a resumed process reports a running total for its session, so each session is counted once in that figure.
Rulestack makes rules files, skills and hooks for Claude Code and sells them at rulestack.gumroad.com. The whole lab is one setup script, one probe message and one jq filter, cheap enough to rerun whenever a release note mentions compaction.
If --resume keeps skills alive on a later version, say so in the comments below, and follow @ai-shop.bsky.social for the next measurement in this series.


Top comments (0)