From 3fa773968914b86ee5bd45fb3065cf485fcf6b3c Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 14 Aug 2026 15:42:09 -0700 Subject: [PATCH] fix(investigate): anchor the scope-lock freeze hook on $HOME, not CLAUDE_SKILL_DIR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The investigate skill's PreToolUse hooks and Scope Lock probe resolved check-freeze.sh via ${CLAUDE_SKILL_DIR}, which does not exist when frontmatter hooks run — the || exit 0 tail then failed open, so the debug scope boundary silently never engaged (#1871 follow-up). Anchor all four sites on $HOME/.claude/skills/gstack/ like careful/freeze, and add a static test asserting no frontmatter command: line in the guard-family skills ever references CLAUDE_SKILL_DIR again. Fixes #2469; closes the last live half of #1459 together with the freeze/careful hookSpecificOutput fix. The broader portable-install-root rewrite stays #1882 (its own focused PR per the TODOS.md decision). Reported with a fix by @maxpetrusenkoagent (PR #1873; absorbed narrowly — the cwd-walk rewrite belongs to #1882). Co-Authored-By: Claude Fable 5 --- investigate/SKILL.md | 10 ++++++---- investigate/SKILL.md.tmpl | 10 ++++++---- test/hook-scripts.test.ts | 21 +++++++++++++++++++++ test/investigate-freeze-path.test.ts | 25 +++++++++++++++++-------- 4 files changed, 50 insertions(+), 16 deletions(-) diff --git a/investigate/SKILL.md b/investigate/SKILL.md index 918bd95f0..f17426d42 100644 --- a/investigate/SKILL.md +++ b/investigate/SKILL.md @@ -23,12 +23,12 @@ hooks: - matcher: "Edit" hooks: - type: command - command: 'bash -c ''S="${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh"; [ -x "$S" ] || S="${CLAUDE_SKILL_DIR}/../gstack-freeze/bin/check-freeze.sh"; [ -x "$S" ] && bash "$S" || exit 0''' + command: 'bash -c ''S="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; exit 0''' statusMessage: "Checking debug scope boundary..." - matcher: "Write" hooks: - type: command - command: 'bash -c ''S="${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh"; [ -x "$S" ] || S="${CLAUDE_SKILL_DIR}/../gstack-freeze/bin/check-freeze.sh"; [ -x "$S" ] && bash "$S" || exit 0''' + command: 'bash -c ''S="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; exit 0''' statusMessage: "Checking debug scope boundary..." gbrain: schema: 1 @@ -910,8 +910,10 @@ If any learnings come back, name which one applies to your investigation in one After forming your root cause hypothesis, lock edits to the affected module to prevent scope creep. ```bash -_FREEZE_SCRIPT="${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh" -[ -x "$_FREEZE_SCRIPT" ] || _FREEZE_SCRIPT="${CLAUDE_SKILL_DIR}/../gstack-freeze/bin/check-freeze.sh" +# $HOME-anchored like the careful/freeze frontmatter hooks (#1871): frontmatter +# hooks and early skill bash run before any runtime var like CLAUDE_SKILL_DIR +# exists, so a ${CLAUDE_SKILL_DIR}-relative path silently never resolves (#2469). +_FREEZE_SCRIPT="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh" [ -x "$_FREEZE_SCRIPT" ] && echo "FREEZE_AVAILABLE" || echo "FREEZE_UNAVAILABLE" ``` diff --git a/investigate/SKILL.md.tmpl b/investigate/SKILL.md.tmpl index 67e254d74..c7cd9a99a 100644 --- a/investigate/SKILL.md.tmpl +++ b/investigate/SKILL.md.tmpl @@ -30,12 +30,12 @@ hooks: - matcher: "Edit" hooks: - type: command - command: 'bash -c ''S="${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh"; [ -x "$S" ] || S="${CLAUDE_SKILL_DIR}/../gstack-freeze/bin/check-freeze.sh"; [ -x "$S" ] && bash "$S" || exit 0''' + command: 'bash -c ''S="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; exit 0''' statusMessage: "Checking debug scope boundary..." - matcher: "Write" hooks: - type: command - command: 'bash -c ''S="${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh"; [ -x "$S" ] || S="${CLAUDE_SKILL_DIR}/../gstack-freeze/bin/check-freeze.sh"; [ -x "$S" ] && bash "$S" || exit 0''' + command: 'bash -c ''S="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"; [ -x "$S" ] && exec bash "$S"; exit 0''' statusMessage: "Checking debug scope boundary..." gbrain: schema: 1 @@ -118,8 +118,10 @@ If any learnings come back, name which one applies to your investigation in one After forming your root cause hypothesis, lock edits to the affected module to prevent scope creep. ```bash -_FREEZE_SCRIPT="${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh" -[ -x "$_FREEZE_SCRIPT" ] || _FREEZE_SCRIPT="${CLAUDE_SKILL_DIR}/../gstack-freeze/bin/check-freeze.sh" +# $HOME-anchored like the careful/freeze frontmatter hooks (#1871): frontmatter +# hooks and early skill bash run before any runtime var like CLAUDE_SKILL_DIR +# exists, so a ${CLAUDE_SKILL_DIR}-relative path silently never resolves (#2469). +_FREEZE_SCRIPT="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh" [ -x "$_FREEZE_SCRIPT" ] && echo "FREEZE_AVAILABLE" || echo "FREEZE_UNAVAILABLE" ``` diff --git a/test/hook-scripts.test.ts b/test/hook-scripts.test.ts index 60c727a6c..96db60e5a 100644 --- a/test/hook-scripts.test.ts +++ b/test/hook-scripts.test.ts @@ -56,6 +56,27 @@ function withFreezeDir(freezePath: string, fn: (stateDir: string) => void) { } } +// ============================================================ +// Frontmatter hook wiring (#2469 / #1871) +// ============================================================ +// Frontmatter hooks run before any runtime variable exists, so a +// ${CLAUDE_SKILL_DIR}-relative command silently never resolves and the guard +// never fires. Every command: line must anchor on $HOME like careful/freeze. +describe('frontmatter hook command paths', () => { + test.each(['investigate/SKILL.md', 'careful/SKILL.md', 'freeze/SKILL.md', 'guard/SKILL.md'])( + '%s hook commands are $HOME-anchored, never CLAUDE_SKILL_DIR', + (rel) => { + const content = fs.readFileSync(path.join(ROOT, rel), 'utf-8'); + const commandLines = content.split('\n').filter((l) => l.trim().startsWith('command:')); + expect(commandLines.length).toBeGreaterThan(0); + for (const line of commandLines) { + expect(line).not.toContain('CLAUDE_SKILL_DIR'); + expect(line).toContain('$HOME/.claude/skills/gstack/'); + } + }, + ); +}); + // ============================================================ // check-careful.sh tests // ============================================================ diff --git a/test/investigate-freeze-path.test.ts b/test/investigate-freeze-path.test.ts index 2ef4a72b7..7b9ef0cae 100644 --- a/test/investigate-freeze-path.test.ts +++ b/test/investigate-freeze-path.test.ts @@ -5,20 +5,29 @@ import * as path from 'path'; const ROOT = path.resolve(import.meta.dir, '..'); const FILES = ['investigate/SKILL.md.tmpl', 'investigate/SKILL.md']; +// #2469: frontmatter hooks (and early skill bash) run before any runtime +// variable exists, so a ${CLAUDE_SKILL_DIR}-relative path silently never +// resolved and the scope-lock guard failed open via `|| exit 0`. Both the +// hook commands and the Scope Lock probe must anchor on $HOME like the +// careful/freeze skills (#1871). The old standalone `gstack-freeze` sibling +// fallback was part of the never-resolving path — prefix installs keep the +// payload at ~/.claude/skills/gstack/, so the $HOME anchor covers them. describe('investigate freeze path resolution', () => { for (const rel of FILES) { const content = fs.readFileSync(path.join(ROOT, rel), 'utf-8'); - test(`${rel} hook falls back to standalone gstack-freeze install`, () => { - expect(content).toContain('${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh'); - expect(content).toContain('${CLAUDE_SKILL_DIR}/../gstack-freeze/bin/check-freeze.sh'); - expect(content).toContain('[ -x "$S" ] && bash "$S" || exit 0'); - expect(content).toContain("command: 'bash -c ''"); + test(`${rel} hook resolves check-freeze via the $HOME anchor`, () => { + expect(content).toContain('S="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"'); + expect(content).toContain('[ -x "$S" ] && exec bash "$S"; exit 0'); + const commandLines = content.split('\n').filter((l) => l.trim().startsWith('command:')); + expect(commandLines.length).toBeGreaterThan(0); + for (const line of commandLines) { + expect(line).not.toContain('CLAUDE_SKILL_DIR'); + } }); - test(`${rel} scope lock availability check supports standalone install`, () => { - expect(content).toContain('_FREEZE_SCRIPT="${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh"'); - expect(content).toContain('[ -x "$_FREEZE_SCRIPT" ] || _FREEZE_SCRIPT="${CLAUDE_SKILL_DIR}/../gstack-freeze/bin/check-freeze.sh"'); + test(`${rel} scope lock availability probe uses the $HOME anchor`, () => { + expect(content).toContain('_FREEZE_SCRIPT="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"'); expect(content).toContain('[ -x "$_FREEZE_SCRIPT" ] && echo "FREEZE_AVAILABLE" || echo "FREEZE_UNAVAILABLE"'); }); }