Troubleshooting
Read this page when a stamity run fails, or when check prints a row you do not recognise. You
leave with the meaning of that row or that error code, the command that fixes it, and the place
to report what nothing here fixes.
Start here:
npx @zomarit/stamity check
A healthy repository answers like this. Every row reads ok, plugin-runtime included: a
repository that records no plugin and has no plugin root in its environment has nothing to report
and nothing to act on, so the row passes quietly rather than parking a standing advisory on a run
that is otherwise clean.
doctor
ok node-version Node 22.22.3 satisfies >=22.22.2
ok git-available git reports a clean working tree
ok manifest .stamity/manifest.json is valid — schema 1.0.0, tools claude, 67 ledger row(s)
ok state-dirs .stamity/learnings, .stamity/handoffs are present
ok learnings 11 learning(s), all valid
ok tmp-hygiene no writer temp files left behind
ok env-mcp no MCP servers selected, so no credentials are required
ok tool-traces all 1 target tool(s) have emitted files recorded in the ledger
ok claude-hook-shell not a Windows host: the client hands hook commands to sh, where the anchored commands parse (Git Bash is a Windows-only requirement)
ok preserved-duplicate 2 managed file(s) carry their block once
ok pack-integrity no installed pack content is recorded in the ledger
ok plugin-runtime no plugin recorded and no plugin root in the environment
ok plugin-duplicates no client records a plugin, so nothing can duplicate
ok invariants invariants 1.0.0 · ratified 2026-08-31 · last amended 2026-09-13
drift: clean — every generated file matches what a sync would write
provenance (the manifest is the record): generated by 1.9.1 · updated 2026-09-23T10:08:13.476Z · schema 1.0.0
claude: 67 file(s)
all green — nothing to do
Almost every question on this page is one check already answers. It reads only. Nothing on
this page's diagnosis path writes to your repository.
What a failed run tells you
Three exit statuses, and only three.
| Exit | What it means |
|---|---|
0 | the command succeeded — --help and --version leave through here too |
1 | the command ran and failed |
2 | the command line was rejected before any command ran |
A failure is always 1. Which failure it was travels in the error's code field, not in the
exit number. Re-run with --json and read error.code. A CI script branches on that string,
because there is no second numbering to read.
On exit 2 stdout is empty. The command line never reached an action, and the diagnostic is
on stderr. Parse stdout only after checking that the status is not 2.
The CLI reference lists every error code and what it classifies, including
the two that exist only at the CLI edge. The codes this page names are VALIDATION_ERROR,
CONFIG_ERROR, INTEGRITY_ERROR, FS_ERROR and LOCK_TIMEOUT.
What check prints
Fourteen probes, then the drift gate, then a provenance rollup.
Each probe reads ok, warn or fail. A fail takes the exit code to 1. A warn is
advisory and leaves it at 0. That split is deliberate. A missing state subdirectory or an
absent git binary is a legal repository, and a run that failed on those would train you to
ignore check altogether.
Only six rows can fail: node-version, manifest, claude-hook-shell, pack-integrity,
plugin-duplicates and plugin-runtime. A probe that cannot
run at all warns instead, saying could not be checked: and why. Every other row still prints.
| Row | What a bad verdict means, and what to do |
|---|---|
node-version | Can fail. Your Node is below the range this build declares. Install a Node in that range, or switch to one with your version manager. Then re-run. |
git-available | Warns when git did not answer. Either no git is on your PATH, or this is not a repository. Nothing check, init, sync, validate or config does needs git. The worktree verbs do, and refuse with VALIDATION_ERROR without it. Sync's dirty-tree warning stays silent. |
manifest | Can fail. Absent means this repository was never set up: run init. Defective prints the engine's own field-level message, so fix the field it names. |
state-dirs | Warns when .stamity/learnings/ or .stamity/handoffs/ is missing. Nothing is lost. Both stores recreate their directory on the first write, and sync rewrites them now. |
learnings | Warns when a recorded learning is invalid, or when it sits past the file cap. Neither kind loads. Run validate for the per-file detail. |
tmp-hygiene | Warns on a live concurrent write. It also warns on .tmp.stamity-<8hex> litter left by a write interrupted between the temp file and the rename. The engine token in that name keeps another tool's .tmp.<hex> files out of the row. The row reports; it never deletes. |
env-mcp | Warns when MCP servers are selected but .env.mcp is absent, and when a credential in it is still blank. A server whose credential is empty fails at start-up. config mcp add <id> recreates the file with the names those servers need. |
tool-traces | Warns when a client the manifest targets has nothing emitted for it in the ledger. sync writes that client's files and records them. |
claude-hook-shell | Can fail, on Windows only. The Claude hook commands this engine emits are anchored on ${CLAUDE_PROJECT_DIR} with a POSIX fail-closed tail, and they parse under sh and Git Bash. On a Windows host with no Git Bash the client falls back to PowerShell, where ${NAME} is PowerShell's own variable and the tail does not parse: the pre-tool-use guard never launches and the client does not block. The row looks where the client looks and nowhere else, in the client's order (vendor's troubleshoot-install page, read 2026-09-22): CLAUDE_CODE_GIT_BASH_PATH when it names an existing file called bash.exe, sh.exe, bash or sh — any other value, a directory or git-bash.exe included, the client ignores and so does the row; then bin\bash.exe under the default install locations C:\Program Files\Git and C:\Program Files (x86)\Git; then the git.exe on PATH, reading bin\bash.exe from that installation. A bare bash.exe on PATH (MSYS2, Cygwin, WSL's C:\Windows\System32\bash.exe) is not a place the client looks and does not count; a git.exe on PATH with no bin\bash.exe beside it (a shim, a relocated Git) fails the row with the variable as the remedy, since how the client resolves such a git is unstated. It fails when this repository targets claude with repository-emitted hooks and none of the three holds Git Bash. Install Git for Windows (Git Bash), or set CLAUDE_CODE_GIT_BASH_PATH to its bin\bash.exe in the environment — a value in settings.json's env block reaches the client, not a shell that runs check outside a Claude session; re-run check. On every other host, and where Claude's hooks are carried by its plugin, it passes with a note saying which condition released it. |
preserved-duplicate | Warns when a managed file repeats its own managed block below the STAMITY:END marker. Your repository then loads that content twice. Delete the copy at the line the row names. The block itself is regenerated on every sync. |
plugin-runtime | Passes with a note when this repository records no plugin client and no plugin root is in the environment — the ordinary state for a repository that is not plugin-backed, and nothing to act on. Warns when a client IS recorded and no root is in the environment: the repository names a plugin this run could not look at. Can fail on two states, and both are plugins this repository claims: the locator refuses (no runtime found, or a Node below the plugin's floor; its own message is quoted) while a client is recorded or the mode is plugin-backed, and this repository records plugin-backed while the resolved runtime's major differs from the version its .stamity/ state was written by. Pin the plugin back to that major, or install the matching companion runtime. A refusal with no client recorded and no plugin-backed mode warns instead — the root variable came from elsewhere in your environment, and another session's broken plugin is not this repository's defect. |
plugin-duplicates | Can fail. A class an installed plugin carries is also on disk here. The row names the paths it found — three, sorted, then +N more — and three sources, each with its own remedy: ledger (this engine wrote it — clean -y, then plugin setup --client <tool>), apm (an APM dependency deploys the same classes — remove it from apm.yml and run apm install, or keep the plugin uninstalled), unmanaged (not written by this engine — remove the file, or keep it as an override under .stamity/overrides/; on Claude Code a hooks key in .claude/settings.json under a plugin install is this source too, because the client loads it beside the plugin's hooks — remove the key, or keep personal rows in .claude/settings.local.json, and sync removes a stale repository-mode rendering by itself, where clean leaves it in place). It warns while the manifest still says mode: "generated", because coexistence is the expected state before you clean, and fails once the manifest records plugin-backed. No verb deletes a duplicate. |
pack-integrity | Can fail. An installed pack's bytes no longer match what was recorded at install. Re-install that pack with clean --pack <id>, then add <id>. Do not reach for sync first: it would carry the edited bytes into your emitted setup. |
invariants | States which version of the charter's floor invariants the installed engine would write — the version, its ratification date, and its last amendment. Warns when the installed charter declares no version at all. That means an old package: upgrade, then sync. |
Below the rows, one question: would a sync change anything? That is the drift gate. It runs
the same read-only plan sync itself runs, so "check says clean" and "sync writes nothing" are
one statement rather than two implementations that must agree.
A drift line names how many files would change, how many ledgered files are missing, and how
many are queued for reclaim. It then lists them. A run that exits 1 on drift alone reports
INTEGRITY_ERROR in its --json output.
If the line reads drift: not evaluated, it names the real reason, and there are two. Either the
manifest could not be read, or the plan itself threw. For the first, the manifest row above
already says so, with the fix. A pack that bricks projection, invalid content and a malformed
override all throw. While the plan does not build, nothing is compared, so that run would not
detect tampering with a generated file.
Common failures
check reports drift after you edited a managed file
You edited inside a STAMITY:BEGIN / STAMITY:END block, and the drift gate saw it:
drift: 1 file(s) would change, 0 ledgered file(s) missing, 0 queued for reclaim
update CLAUDE.md
That is the drift gate working. Managed blocks are regenerated, so an edit inside one is an edit the next sync overwrites:
npx @zomarit/stamity sync
To keep the change, move it outside the block. Text outside a managed block is yours, and it survives every sync. Where the edit belongs inside a shipped agent, rule, command or skill, author it as an override instead. Customization is that lane. If you deleted a generated file, sync rewrites it.
sync refuses a file it did not write
A drift entry reading collision means a file already occupies a path the setup wants, with no
STAMITY:BEGIN / STAMITY:END markers in it. The engine cannot prove it wrote that file, so a
plain sync will not overwrite it, and running one changes nothing.
Two remedies, and they are not the same. Move the file aside and run sync to keep your copy
where you can read it. Or keep the generated file:
npx @zomarit/stamity sync --force
--force copies your bytes to a verified .bak before it overwrites. Which remedy is right
depends on whose content matters, and sync cannot know that.
For .claude/settings.json the collision is one key, not the file. That document is owned per
top-level key — permissions, and hooks while the repository owns hooks — and every other key
(the client's enabledPlugins, your model or env) is kept whatever you do: remove the key the
message names and re-run sync, or sync --force replaces only the engine's keys behind a
verified .bak and keeps every other key. A hand edit inside permissions or hooks of a file
the engine wrote is regenerated on the next sync — with a .bak and a warning naming the key
when the file no longer hashes to what the engine wrote, and silently when it still does — the
case of a rendering that merely moved with an engine upgrade; personal permission rows belong in
.claude/settings.local.json, which this engine never writes.
A client has no files
The setup is generated for the clients the manifest names, so a client with no files is usually a client that was never selected:
npx @zomarit/stamity config get tools
npx @zomarit/stamity config set tools claude,cursor
npx @zomarit/stamity sync
config edits state and never regenerates output. The sync is what writes the files.
Two absences are expected rather than broken. Codex has no repository-level command home, so no
command files are written for it. Copilot CLI and cloud hooks land at
.github/hooks/stamity.json, and a selected Copilot setup missing that file needs a sync.
The capability matrix states each client's supported surfaces and
enforcement limits.
Your Codex hook never runs
Three steps stand between the emitted .codex/hooks.json and a hook the client runs.
features.hooks = true. The vendor states no default for that key, and every byte inhooks.jsonis inert without it. stamity writes it into.codex/config.tomlfor you, andsyncrestores it.projects.<path>.trust_level = "trusted", in your own Codex home config.- A per-hook review through the interactive
/hookscommand. Automation that cannot take that step uses--dangerously-bypass-hook-trustinstead.
With all three in place, headless codex exec on codex-cli 0.154.0 still loaded no project hook
layer at all, measured on 2026-09-15. Treat a Codex hook as enforcement in the interactive
client, and as nothing in the headless one.
Claude Code blocks every tool call with "the pre-tool-use guard could not run"
That line is the guard failing CLOSED, and it says the guard could not LAUNCH — not that a call was refused. The two never arrive together: a refusal names the agent and the tool it denied and nothing else, because the tail below re-raises the guard's own exit 2 in silence and prints only when the status is neither 0 nor 2. If you see both, the emitted command is not the one this version writes — re-sync and compare.
The emitted command anchors the script on the client's own project root and turns a failure to launch into a block:
"command": "node \"${CLAUDE_PROJECT_DIR}/.stamity/generated/hooks/claude/stamity-pre-tool-use-guard.mjs\" || { s=$?; [ \"$s\" -eq 2 ] && exit 2; echo 'stamity: the pre-tool-use guard could not run; run stamity sync' >&2; exit 2; }"
Two things reach that branch. The generated tree is gone — clean removed it, a fresh checkout
has not synced, or the file was deleted by hand:
npx @zomarit/stamity sync
npx @zomarit/stamity check
Or the session carries no CLAUDE_PROJECT_DIR. The client sets it to the project root the session
started in, so an empty value means the hook process did not inherit the client's environment — a
wrapper, a launcher or a CI step that scrubs it. With it empty the path resolves to /.stamity/…,
where nothing is.
Why it blocks rather than warns: the command used to be repository-relative, and a hook handler
runs in the session's current directory, which a cd in the Bash tool moves. Once it left the
root the guard ran as Cannot find module and exited 1 — a status the client does not block on —
so every tool call after that cd went through ungated. A guard that cannot run now stops the
call instead of disappearing. Every non-zero path exits 2: the status is classified, never
forwarded, so a guard that exits 1 does not reach the model as a status the client ignores.
One host is a known gap. The command is POSIX — ||, $?, [ … ], a brace group — and it holds
in the two shells the client's own hooks documentation names first. On a Windows host with no Git
Bash the client falls back to PowerShell, and there nothing about the line works: PowerShell
parses none of that syntax, and ${CLAUDE_PROJECT_DIR} is its own VARIABLE syntax rather than an
environment lookup (which would be $env:CLAUDE_PROJECT_DIR), so the path expands empty. That
affects every anchored command (six entries over four scripts), not just the guard — the
session-start and tamper notices and the review gate too — so on such a host the anchoring may be
a REGRESSION: a hook that used to run while the session sat at the repository root may now never
launch at all. This is unmeasured; no run on such a host has been made. stamity check says so on
that host: its claude-hook-shell row fails when Claude is targeted with repository-emitted hooks
and no Git Bash is found where the client looks — CLAUDE_CODE_GIT_BASH_PATH, the default install
locations, or beside the git on PATH; a bare bash.exe on PATH does not count. If you are on
one, install Git Bash, and report what you see.
The other three clients need no anchor, each for a measured reason: Cursor runs a hook from the
workspace root whatever the shell's directory is, Copilot gives each hook entry a cwd relative
to the repository root, and the Codex starter walks up to the directory holding the trusted
.codex/hooks.json. The client contract evidence page carries
the citations and the dates.
CONFIG_ERROR says the bundled content was not found
A message about bundled content not being found, naming a package root and the paths it probed,
means the installed package is incomplete. The corpus that ships inside it is not where the
engine expects it. Reinstall the package. In a source checkout, run npm run build, which stages
the corpus under dist/content/.
This is an install problem, not a repository problem. Nothing in your tree can cause it, and nothing in your tree fixes it.
LOCK_TIMEOUT on a write
Another write is already in flight on the same file. It may be a second stamity process, or
another task inside the same run. Re-run once it finishes.
If nothing else is running, an interrupted run left a lockfile behind. The message names the exact path, so remove that one and re-run.
FS_ERROR on a write
FS_ERROR names the operation and the path. The usual causes are a read-only parent directory,
an exhausted quota, or a path you do not own.
Your files are safe either way. Every managed-file write goes through a temp file and an atomic rename, so a failure leaves the previous file intact rather than a half-written one. Three in-repo writes do not take that path, and none of them can damage a file that is already there:
- The state directories'
.gitkeep. It is created exclusively, so a file already at that name is kept. - The
.bakthe writer leaves before it overwrites a file it cannot regenerate. That happens to a colliding unmanaged file under--force, to a managed file whose markers a plainsynchas to repair, to.claude/settings.jsonwhen an engine-owned key — or a hooks wiring the engine recognises as its own — is replaced or removed in a file the engine cannot prove unedited, and to that file and the three MCP documents whencleanor a client's removal reclaims the engine's keys from bytes that no longer match what the ledger recorded (an untouched file is reclaimed with no backup; a backup that cannot be taken refuses the removal and leaves the file untouched). The backup takes a name no existing file holds. - The workspace root's
.stamity/workspace-sync-journal.jsonl, which is an append-only log.
init refuses on a repository that already has a setup
That is deliberate. A second init never silently replaces the first. Use sync to regenerate,
config to change a choice, or init --force to replace the setup in place.
Where to report a problem
- A bug or a question — open an issue. Include
the command you ran, its
--jsonoutput if it failed, and your Node version. - A security vulnerability — do not open a public issue. Use
the private advisory form.
What is in scope, and what the engine defends today, is in
SECURITY.md.