AutoHook
August 27, 2026 · View on GitHub
Claude Code forgets your standing rules every time it compacts the conversation. This puts them back. For Windows, where the obvious fix silently destroys every accented character before Claude ever reads it.
Install
Copy this and paste it into Claude Code. Nothing to download, nothing to run.
Claude Code will stop and ask your permission partway through, because both
files it needs to touch live outside your project folder. That prompt is the
install working, not failing. Approve it: it edits exactly one file, settings.json,
and backs it up first.
Install a post-compaction rules reminder hook for Claude Code on this Windows machine.
STEP 1 - Create %USERPROFILE%\.claude\hooks\post-compact-reminder.ps1 with exactly this content:
param([string]$RulesFile = (Join-Path $env:USERPROFILE ".claude\CLAUDE.md"))
# Required. Reading [Console]::OutputEncoding is NOT a valid check - it can
# report utf-8 while the stream is still mangled. It must be ASSIGNED.
[Console]::OutputEncoding = [Text.UTF8Encoding]::new(0)
if (-not (Test-Path -LiteralPath $RulesFile)) {
Write-Output "Context was compacted, but no rules file was found at: $RulesFile"
exit 0
}
Write-Output "Context was just compacted. Your standing rules are restored below - they apply to everything that follows."
Write-Output ""
Write-Output (Get-Content -Raw -Encoding UTF8 -LiteralPath $RulesFile)
exit 0
Save it as pure ASCII with no BOM.
STEP 2 - Edit %USERPROFILE%\.claude\settings.json. This is my GLOBAL Claude Code
settings file, outside any project, so tell me plainly that the hook will then fire
in every Claude Code session on this machine - and ask for permission in one go,
naming that one file and nothing else.
If the file already exists, copy it to settings.json.bak-autohook first.
If it does NOT exist, create it, and also create an empty marker file next to it
named settings.json.absent-before-autohook, so the uninstall knows the file was not
there before and can remove it cleanly.
Then add this entry. Merge carefully - do NOT overwrite anything:
- if there is no "hooks" object, create it;
- if "hooks" exists but has no "SessionStart", add that key;
- if "SessionStart" already exists, APPEND this object to its array and leave
every existing entry untouched. Other hooks with other matchers are normal;
- if an entry with matcher "compact" pointing at post-compact-reminder.ps1 is
already present, this is a re-install: replace only that one entry.
Keep everything else in the file byte-for-byte identical:
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -ExecutionPolicy Bypass -File \"%USERPROFILE%\\.claude\\hooks\\post-compact-reminder.ps1\"",
"timeout": 15,
"statusMessage": "Restoring standing rules after compaction"
}
]
}
]
Expand %USERPROFILE% to the real absolute path before writing it - a relative path
breaks the hook. Validate that the file is still valid JSON after the edit.
STEP 3 - Verify, then show me the output. Two things make a naive check useless
here, so run exactly this and nothing else: my own rules file may be pure ASCII,
and this bug does not occur at all when the console codepage is already 65001.
The test therefore supplies its own characters AND forces a codepage that fails.
$hook = "$env:USERPROFILE\.claude\hooks\post-compact-reminder.ps1"
$probe = Join-Path $env:TEMP "autohook-probe.md"
$out = Join-Path $env:TEMP "autohook-out.txt"
$want = "accents " + [char]0xE9 + [char]0xE8 + " arrow " + [char]0x2192
[IO.File]::WriteAllText($probe, $want, (New-Object Text.UTF8Encoding $false))
$cl = 'chcp 1252 >nul && powershell -NoProfile -ExecutionPolicy Bypass -File "' +
$hook + '" -RulesFile "' + $probe + '" > "' + $out + '"'
cmd /c $cl
([IO.File]::ReadAllBytes($out) | Where-Object { $_ -gt 127 } |
Select-Object -First 10 | ForEach-Object { "{0:X2}" -f $_ }) -join " "
Report the byte list verbatim.
PASS C3 A9 C3 A8 E2 86 92 multi-byte UTF-8, the fix is working
FAIL E9 E8 or 82 8A lone high bytes, and the arrow vanished
On FAIL the script is missing the [Console]::OutputEncoding assignment, or it
was saved with a BOM. Stop and tell me - do not make it pass by dropping the
chcp, which is what makes the test meaningful.
Finally, tell me whether %USERPROFILE%\.claude\CLAUDE.md exists and its size in
KB, since its whole content is re-injected at every compaction. If it does not
exist, say so: the hook has nothing to inject until I create it.
That is the whole install. Nothing visible happens until your next compaction - then your rules come back on their own. Ask Claude what your standing rules are, right after a compaction, and you will know it worked.
Uninstall
One command in PowerShell. It puts settings.json back the way it was - restoring
the backup if the file already existed, removing it if the install created it -
and deletes the hook script.
$c = "$env:USERPROFILE\.claude"
if (Test-Path "$c\settings.json.bak-autohook") {
Copy-Item "$c\settings.json.bak-autohook" "$c\settings.json" -Force
Remove-Item "$c\settings.json.bak-autohook" -Force
"settings.json restored from backup"
} elseif (Test-Path "$c\settings.json.absent-before-autohook") {
Remove-Item "$c\settings.json" -Force -ErrorAction SilentlyContinue
Remove-Item "$c\settings.json.absent-before-autohook" -Force
"settings.json removed - it did not exist before the install"
} else { "no backup and no marker - remove the SessionStart compact entry by hand" }
Remove-Item "$c\hooks\post-compact-reminder.ps1" -Force -ErrorAction SilentlyContinue
"hook removed"
The first branch restores a backup, so anything else you changed in
settings.json after installing goes back too. Minutes after an install that is
nothing; months later it might not be, and then the paste below is safer.
Or paste this into Claude Code instead:
Remove the AutoHook post-compaction hook from this machine: delete the
"SessionStart" entry whose matcher is "compact" and whose command points at
post-compact-reminder.ps1 from %USERPROFILE%\.claude\settings.json, keeping every
other setting byte-for-byte. If that entry was the only thing in the file and
a settings.json.absent-before-autohook marker is present, delete both instead.
Then delete %USERPROFILE%\.claude\hooks\post-compact-reminder.ps1.
Why a prompt, and not an installer
Because a downloaded installer does not run on a default Windows machine. Measured, on Windows 11 with factory settings:
> powershell -File install.ps1
install.ps1 cannot be loaded. The file is not digitally signed.
You cannot run this script on the current system. exit 1
That is the installer this repo deliberately does not ship. The blocker is
ExecutionPolicy = RemoteSigned, the default for the current user, and
it applies to any .ps1 that carries the Mark of the Web — which every file
downloaded from GitHub does. Getting past it takes a paid code-signing
certificate, or teaching users to disable their own security. Neither is
shipping.
A prompt removes that surface. Claude writes the file locally, and a locally
created file carries no Mark of the Web - which is the whole of what
RemoteSigned refuses. Measured both ways on the same script: unmarked it runs,
marked it is rejected. Everyone installing a Claude Code hook already has the
installer: Claude.
Execution policy does not stop existing, of course, and you will notice the hook
command passes -ExecutionPolicy Bypass. That is there for anyone on AllSigned
or Restricted, where even a local script is refused. Two things about that flag:
it lasts exactly as long as that one process and changes nothing on the machine,
and it is the tool applying it to itself rather than you being told to loosen a
setting of your own. That distinction is the entire point of this repo. Nobody
should have to switch a protection off to install a reminder.
(Smart App Control, for the record, is not the blocker here. It was enforcing on the test machine and PowerShell still ran in FullLanguage.)
The encoding trap this exists for
Claude Code injects a hook's stdout directly into the model's context. On Windows, PowerShell re-encodes that stdout in the console's ANSI codepage on the way out. Claude Code then decodes it as UTF-8. Everything non-ASCII is destroyed — silently, with no error and nothing in the logs.
Measured on the same string, same machine, with and without the one-line fix:
console codepage with the fix without it
cp1252 C3 A9 C3 A8 E2 86 92 E9 E8 arrow gone
cp850 C3 A9 C3 A8 E2 86 92 82 8A arrow gone
cp65001 C3 A9 C3 A8 E2 86 92 C3 A9 C3 A8 E2 86 92
Read the last row carefully: on a console already set to UTF-8 the bug does
not happen at all. That is why it is invisible to some people and destructive
for others, why it will not reproduce on demand, and why a check run from the
wrong shell will happily report success on a broken hook. Characters that the
codepage cannot represent at all - the arrow above, or an emoji - do not become
a wrong byte; they are replaced by a literal 3F question mark and are gone.
The check that looks like proof, and is not:
[Console]::OutputEncoding.WebName # => utf-8
[Console]::OutputEncoding.CodePage # => 65001
Both already read UTF-8 while the output above was still being mangled. Reading the property proves nothing. The encoding has to be assigned, which is what rebuilds the cached stdout writer:
[Console]::OutputEncoding = [Text.UTF8Encoding]::new(0) # 0 = no BOM
One statement, prepended to the hook. With it, the round-trip is byte-identical.
Note that Get-Content -Encoding UTF8 does not cover this. That fixes
reading the file. The corruption happens on the way out.
What it costs you
The hook injects the whole rules file, every time the conversation is compacted. That is a recurring token cost, so keep the file short: a page of standing rules is the right size, a 76 KB architecture document is not.
If your rules file is large, point the hook at a short summary instead and let Claude read the full document on demand:
-RulesFile "C:\Users\YOU\.claude\RULES-SHORT.md"
The upstream Bash project takes the other approach - it injects a one-line reminder telling Claude to re-read the file. That is cheaper per compaction but depends on Claude actually performing the read. Injecting the content is more reliable and costs more. Pick per file size.
Reading it before you run it
You should not paste a prompt that writes a script onto your machine without knowing what the script does. It is twenty lines, and both pieces are here:
- hooks/post-compact-reminder.ps1 - the hook
- settings.example.json - the settings entry
They are here to be read, not downloaded. Save that .ps1 from GitHub and run
it and Windows will refuse it - unsigned, Mark of the Web - which is the exact
error at the top of this page. That is not a flaw in the file; it is the reason
the install is a prompt. The prompt contains the same script verbatim, and
Claude writes it locally where the policy does not apply.
If you would rather type it out yourself, two rules:
- Use an absolute path in the hook command. A relative path breaks it.
- Save the
.ps1as pure ASCII, no BOM. PowerShell 5.1 reads a BOM-less file as ANSI, so any non-ASCII character in the script itself is a second instance of the same bug.
Notes for anyone writing Claude Code hooks on Windows
Collected the hard way, each one field-tested:
Get-Contentwithout-Encoding UTF8reads UTF-8 files as ANSI. Silent.- Python writing non-ASCII to a Windows pipe raises
UnicodeEncodeError. Force it:sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8'). - Setting anything through an API from the shell can corrupt it. Send a
pure-ASCII JSON body with
\uXXXXescapes instead — no encoding layer can damage ASCII. $argsis a PowerShell automatic variable. Naming aparam()entry$argsfails in ways that look like a logic bug.ConvertFrom-Jsonin PowerShell 5.1 merges multi-line input into one object whose properties are arrays. You get 1 result where there were 15. Join first:($output -join "`n") | ConvertFrom-Json.- A hook that exits non-zero on every session start is worse than no hook. Fail quietly and say why in stdout.
Tested on
- Windows 11 Pro 26100, Windows PowerShell 5.1.26100
- Smart App Control enforcing; PowerShell 7 not tested
Independently verified end to end on a second Windows machine, by someone who had not seen the project, working from this page alone:
- the install prompt ran through, including the permission stop
- the check returned
C3 A9 C3 A8 E2 86 92- correct UTF-8 under a forced cp1252 console, the condition an unfixed hook fails - a real
/compactfired the hook, and their own accented rules came back intact:çasC3 A7, an em dash asE2 80 94, nothing replaced
Every link is measured: disk, PowerShell, hook stdout, model context. Nothing in this README is inferred.
Prior art
This is the Windows-native complement to work that already exists, not a replacement for it:
- Dicklesworthstone/post_compact_reminder
— the original, and where the
SessionStart+matcher: "compact"approach comes from. Bash; Linux, macOS and WSL. If you are on any of those, use it. - hagigi0405/claude-code-hooks-windows-traps — field-tested Windows hook pitfalls on a Japanese (cp932) system. Covers the stdin side: exit-code semantics, JSON payload corruption on input, relative-path breakage, hot reload. This repo covers the stdout side.
No code from either is used here.
MIT - see LICENSE.