Skip to content

fix(hooks): use ${CLAUDE_PLUGIN_ROOT} in skill-registered hook commands (narrow refile of #968/#970)#1141

Open
CermakM wants to merge 1 commit intogarrytan:mainfrom
CermakM:fix/skill-dir-to-plugin-root
Open

fix(hooks): use ${CLAUDE_PLUGIN_ROOT} in skill-registered hook commands (narrow refile of #968/#970)#1141
CermakM wants to merge 1 commit intogarrytan:mainfrom
CermakM:fix/skill-dir-to-plugin-root

Conversation

@CermakM
Copy link
Copy Markdown

@CermakM CermakM commented Apr 22, 2026

Summary

Replaces ${CLAUDE_SKILL_DIR} with ${CLAUDE_PLUGIN_ROOT} in only the 8 frontmatter hook command: strings across careful, freeze, guard, and investigate. Body-level ${CLAUDE_SKILL_DIR} uses (e.g. investigate's FREEZE_AVAILABLE probe at line 85, ship's skill-body cat commands in scripts/resolvers/review.ts) are preserved — those render inside skill content where ${CLAUDE_SKILL_DIR} is a documented, supported substitution.

This is a narrower, independently-verified refile of #968 and #970, which were closed with the rationale that gstack "consistently uses CLAUDE_SKILL_DIR". The point this PR hopes to surface cleanly: consistency within hook commands is already broken today, not preserved — Claude Code silently expands ${CLAUDE_SKILL_DIR} to empty in that specific context, and the hooks have been inoperative for every user running /careful, /freeze, /guard, or /investigate.

Scope

File Change
careful/SKILL.md.tmpl 1× hook command: line
freeze/SKILL.md.tmpl 2× hook command: lines
guard/SKILL.md.tmpl 3× hook command: lines
investigate/SKILL.md.tmpl 2× hook command: lines
+ 4 regenerated SKILL.md files via bun run gen:skill-docs --host all

16 insertions, 16 deletions across 8 files.

Deliberately unchanged (body-level, these work):

  • investigate/SKILL.md.tmpl:85FREEZE_AVAILABLE probe (inside skill body bash block)
  • scripts/resolvers/review.ts:949 — renders into ship body content
  • test/fixtures/golden/*-ship-*.md — golden fixtures for body content

Independent reproduction

Diagnosed from scratch while debugging a user-reported PreToolUse:Bash hook error with /guard active. Instrumented guard/SKILL.md with a probe logging every hook-fire's runtime environment:

# hook command, instrumented
{ echo "claude_subst=[${CLAUDE_SKILL_DIR}] shell_set=${CLAUDE_SKILL_DIR+yes} \
         shell_val=[${CLAUDE_SKILL_DIR:-__unset__}] \
         plugin_root=[${CLAUDE_PLUGIN_ROOT:-__unset__}]" >> /tmp/guard-debug.log
  bash ${CLAUDE_SKILL_DIR}/../careful/bin/check-careful.sh; }

Three hook fires, three identical log lines:

claude_subst=[]  shell_set=  shell_val=[__unset__]  plugin_root=[/Users/marek/.claude-strv/skills/guard]
  • claude_subst=[] — Claude's registration-time substitution yielded empty
  • shell_set= (empty) — bash's ${VAR+yes} test confirms the variable is unset, not empty-string
  • plugin_root=[...] — same hook, same invocation: ${CLAUDE_PLUGIN_ROOT} resolves correctly

Environment: Claude Code 2.x native binary on macOS (arm64), gstack installed via ./setup into ~/.claude/skills/gstack.

This matches #961's disassembly evidence exactly. The Skills doc (available-string-substitutions) lists CLAUDE_SKILL_DIR for skill content; the Hooks doc's environment-variable section omits it entirely and lists CLAUDE_PLUGIN_ROOT as the skill-root anchor for hook contexts.

User-visible consequence

Because hooks are non-blocking, tool calls still succeed — the bug presents as transcript noise rather than a hard failure. But the noise is real (one error per Bash/Edit/Write while any affected skill is active), and more consequentially: the destructive-command and freeze-boundary checks these hooks are meant to enforce have never run for any user. A developer running /careful believes rm -rf is gated; in reality the gate short-circuits before the check script is invoked.

Why this narrower scope than #968/#970

The owner's close comment on #968 and #970 read: "the codebase consistently uses CLAUDE_SKILL_DIR, which is the correct variable. These PRs propose switching to CLAUDE_PLUGIN_ROOT but that's not what we use."

This PR takes that seriously. ${CLAUDE_SKILL_DIR} is the correct variable for skill-content substitutions, and those uses are preserved. The change is surgically limited to frontmatter hook command: strings, where ${CLAUDE_SKILL_DIR} is not available — a distinction between two Claude Code substitution contexts, not a global rename.

Testing (per CONTRIBUTING.md)

  • Tier 1 static validationbun test against fix/skill-dir-to-plugin-root (79e67bd) shows identical pass/fail counts to upstream/main (656df0e) on the same commit base. Pre-existing environmental failures (gstack-diff-scope suite, WorktreeManager suite, detectBaseBranch) reproduce identically on both branches and are unrelated to this change.
  • Hook-script testsbun test test/hook-scripts.test.ts32 pass / 0 fail (the suite that directly covers check-careful.sh / check-freeze.sh).
  • Skill health checkbun run skill:check → all 10 host outputs (Claude Code, Codex, Factory, Kiro, OpenCode, Slate, Cursor, OpenClaw, Hermes, GBrain) report fresh.
  • Generator round-tripbun run gen:skill-docs --host all produces only the 4 expected SKILL.md regenerations; no collateral diff.
  • Live Claude Code session — with patched skills, hook-error noise disappears and check-freeze.sh / check-careful.sh receive correct paths and execute.

References

Happy to rebase if main has moved. Happy to drop the scope further or split differently if that helps.

🤖 Co-authored-by: Claude Opus 4.7

Replaces ${CLAUDE_SKILL_DIR} with ${CLAUDE_PLUGIN_ROOT} in the
PreToolUse hook commands of careful, freeze, guard, and investigate.
Only the 8 hook-command strings are changed — body-level ${CLAUDE_SKILL_DIR}
uses (e.g. investigate's FREEZE_AVAILABLE probe and ship's skill-body cat
commands) are left alone because they render inside skill content, where
CLAUDE_SKILL_DIR is a documented, supported substitution.

Why the change is needed:

Claude Code does not substitute or export ${CLAUDE_SKILL_DIR} for hooks
declared in a skill's frontmatter — only ${CLAUDE_PLUGIN_ROOT} is
available in that context (the Skills doc lists CLAUDE_SKILL_DIR under
string substitutions for skill content / bash injection, not hooks, and
the Hooks doc's environment-variable list omits it entirely). With the
variable undefined at hook-execution time, bash expands it to empty, so
every Bash/Edit/Write tool call while careful/freeze/guard/investigate
is active errors with paths like `/../freeze/bin/check-freeze.sh: No
such file or directory`. The hooks are non-blocking, so tool calls
still succeed — but the destructive-command and freeze-boundary checks
the hooks exist to enforce never actually run for any user.

This is the same fix proposed in garrytan#968 and garrytan#970, both of which were
closed without technical engagement. This PR narrows the change from
those (skill-body uses of CLAUDE_SKILL_DIR are preserved) and includes
independent verification of the root cause.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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