Summary
Several gstack skills register PreToolUse hooks in their SKILL.md frontmatter whose commands reference ${CLAUDE_SKILL_DIR}. Claude Code does not define this environment variable for skill-registered hooks — the only variable it substitutes and exports is ${CLAUDE_PLUGIN_ROOT}. As a result, bash expands ${CLAUDE_SKILL_DIR} to the empty string at hook-execution time, producing paths rooted at / and failing every Write/Edit/Bash operation with a noisy but non-blocking error the whole time the skill is loaded.
Because the hooks are non-blocking, tool calls still succeed, so this presents as purely cosmetic noise — but any user running /investigate (or /guard) sees the error repeat on every single Write/Edit in the session. More importantly, this means the debug-scope and freeze-boundary checks these hooks are supposed to enforce have never actually run for any affected user — they fail before the real check script is reached.
Reproduction
- Install latest gstack (I reproduced on
0.16.2.0, commit dbd7aee).
- Invoke a skill that registers a hook referencing
\${CLAUDE_SKILL_DIR} — e.g. /investigate.
- Have Claude Code make any
Write or Edit tool call.
- Observe the error in the transcript:
PreToolUse:Write hook error
Failed with non-blocking status code: bash: /../freeze/bin/check-freeze.sh: No such file or directory
Every subsequent Write/Edit in the session repeats the error.
Root cause
Claude Code's skill-hook runtime (verified against the 2.1.101 native binary) only substitutes and exports ${CLAUDE_PLUGIN_ROOT} for hooks registered via a skill's frontmatter. The relevant logic validates the hook command and throws if unknown plugin-scoped variables are referenced, but does not validate or substitute ${CLAUDE_SKILL_DIR} — it passes the unexpanded string straight to bash, and since the variable is not in the inherited environment either, bash expands it to empty.
Verbatim error string from the binary confirming the whitelist:
Hook command references ${X} but only ${CLAUDE_PLUGIN_ROOT} is available for skill hooks (${CLAUDE_PLUGIN_DATA} is plugin-only).
So when investigate/SKILL.md declares:
hooks:
PreToolUse:
- matcher: "Edit"
hooks:
- type: command
command: "bash \${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh"
bash receives the literal string bash /../freeze/bin/check-freeze.sh and fails.
Affected files on main
All of these contain \${CLAUDE_SKILL_DIR} references in hook commands or in preamble scripts that run as skill instructions:
freeze/SKILL.md — 2× hook commands (\${CLAUDE_SKILL_DIR}/bin/check-freeze.sh)
careful/SKILL.md — 1× hook command (\${CLAUDE_SKILL_DIR}/bin/check-careful.sh)
guard/SKILL.md — 3× hook commands (sibling references into ../freeze/bin/ and ../careful/bin/)
investigate/SKILL.md — 2× hook commands + 1× availability probe (all sibling references into ../freeze/bin/)
ship/SKILL.md — 2× cat \${CLAUDE_SKILL_DIR}/../qa-only/SKILL.md / ../document-release/SKILL.md in skill body
All five break the same way.
Suggested fix
Replace \${CLAUDE_SKILL_DIR} with \${CLAUDE_PLUGIN_ROOT} throughout SKILL.md (and .tmpl) files. CLAUDE_PLUGIN_ROOT is the officially-supported, Claude-Code-substituted variable pointing at the skill's own directory.
Caveat for sibling references: the ../freeze/bin/... style relies on each skill being installed as a sibling directory under the same parent. That's the case for gstack's current layout (~/.agents/skills/gstack/{freeze,careful,investigate,guard}/), but it's fragile if Claude Code ever reports the skill root as a symlink location rather than the resolved target. A more robust alternative would be to ship the hook scripts inside each skill that needs them (so investigate/bin/check-freeze.sh is a copy or a repo-relative symlink of freeze/bin/check-freeze.sh), eliminating the sibling assumption. Either approach fixes the immediate bug.
Environment
- gstack:
0.16.2.0 (installed via npx skills add -g garrytan/gstack, latest on main as of today)
- Claude Code:
2.1.101 (native binary, ~/.local/share/claude/versions/2.1.101)
- OS: macOS (arm64)
Happy to submit a PR if that'd help — the fix is mechanical.
Summary
Several gstack skills register
PreToolUsehooks in their SKILL.md frontmatter whose commands reference${CLAUDE_SKILL_DIR}. Claude Code does not define this environment variable for skill-registered hooks — the only variable it substitutes and exports is${CLAUDE_PLUGIN_ROOT}. As a result, bash expands${CLAUDE_SKILL_DIR}to the empty string at hook-execution time, producing paths rooted at/and failing everyWrite/Edit/Bashoperation with a noisy but non-blocking error the whole time the skill is loaded.Because the hooks are non-blocking, tool calls still succeed, so this presents as purely cosmetic noise — but any user running
/investigate(or/guard) sees the error repeat on every singleWrite/Editin the session. More importantly, this means the debug-scope and freeze-boundary checks these hooks are supposed to enforce have never actually run for any affected user — they fail before the real check script is reached.Reproduction
0.16.2.0, commitdbd7aee).\${CLAUDE_SKILL_DIR}— e.g./investigate.WriteorEdittool call.Every subsequent
Write/Editin the session repeats the error.Root cause
Claude Code's skill-hook runtime (verified against the
2.1.101native binary) only substitutes and exports${CLAUDE_PLUGIN_ROOT}for hooks registered via a skill's frontmatter. The relevant logic validates the hook command and throws if unknown plugin-scoped variables are referenced, but does not validate or substitute${CLAUDE_SKILL_DIR}— it passes the unexpanded string straight to bash, and since the variable is not in the inherited environment either, bash expands it to empty.Verbatim error string from the binary confirming the whitelist:
So when
investigate/SKILL.mddeclares:bash receives the literal string
bash /../freeze/bin/check-freeze.shand fails.Affected files on
mainAll of these contain
\${CLAUDE_SKILL_DIR}references in hook commands or in preamble scripts that run as skill instructions:freeze/SKILL.md— 2× hook commands (\${CLAUDE_SKILL_DIR}/bin/check-freeze.sh)careful/SKILL.md— 1× hook command (\${CLAUDE_SKILL_DIR}/bin/check-careful.sh)guard/SKILL.md— 3× hook commands (sibling references into../freeze/bin/and../careful/bin/)investigate/SKILL.md— 2× hook commands + 1× availability probe (all sibling references into../freeze/bin/)ship/SKILL.md— 2×cat \${CLAUDE_SKILL_DIR}/../qa-only/SKILL.md/../document-release/SKILL.mdin skill bodyAll five break the same way.
Suggested fix
Replace
\${CLAUDE_SKILL_DIR}with\${CLAUDE_PLUGIN_ROOT}throughout SKILL.md (and .tmpl) files.CLAUDE_PLUGIN_ROOTis the officially-supported, Claude-Code-substituted variable pointing at the skill's own directory.Caveat for sibling references: the
../freeze/bin/...style relies on each skill being installed as a sibling directory under the same parent. That's the case for gstack's current layout (~/.agents/skills/gstack/{freeze,careful,investigate,guard}/), but it's fragile if Claude Code ever reports the skill root as a symlink location rather than the resolved target. A more robust alternative would be to ship the hook scripts inside each skill that needs them (soinvestigate/bin/check-freeze.shis a copy or a repo-relative symlink offreeze/bin/check-freeze.sh), eliminating the sibling assumption. Either approach fixes the immediate bug.Environment
0.16.2.0(installed vianpx skills add -g garrytan/gstack, latest onmainas of today)2.1.101(native binary,~/.local/share/claude/versions/2.1.101)Happy to submit a PR if that'd help — the fix is mechanical.