fix(investigate): anchor the scope-lock freeze hook on $HOME, not CLAUDE_SKILL_DIR

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 <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-08-14 15:42:09 -07:00
parent e684948efe
commit 3fa7739689
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
4 changed files with 50 additions and 16 deletions

View File

@ -23,12 +23,12 @@ hooks:
- matcher: "Edit" - matcher: "Edit"
hooks: hooks:
- type: command - 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..." statusMessage: "Checking debug scope boundary..."
- matcher: "Write" - matcher: "Write"
hooks: hooks:
- type: command - 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..." statusMessage: "Checking debug scope boundary..."
gbrain: gbrain:
schema: 1 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. After forming your root cause hypothesis, lock edits to the affected module to prevent scope creep.
```bash ```bash
_FREEZE_SCRIPT="${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh" # $HOME-anchored like the careful/freeze frontmatter hooks (#1871): frontmatter
[ -x "$_FREEZE_SCRIPT" ] || _FREEZE_SCRIPT="${CLAUDE_SKILL_DIR}/../gstack-freeze/bin/check-freeze.sh" # 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" [ -x "$_FREEZE_SCRIPT" ] && echo "FREEZE_AVAILABLE" || echo "FREEZE_UNAVAILABLE"
``` ```

View File

@ -30,12 +30,12 @@ hooks:
- matcher: "Edit" - matcher: "Edit"
hooks: hooks:
- type: command - 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..." statusMessage: "Checking debug scope boundary..."
- matcher: "Write" - matcher: "Write"
hooks: hooks:
- type: command - 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..." statusMessage: "Checking debug scope boundary..."
gbrain: gbrain:
schema: 1 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. After forming your root cause hypothesis, lock edits to the affected module to prevent scope creep.
```bash ```bash
_FREEZE_SCRIPT="${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh" # $HOME-anchored like the careful/freeze frontmatter hooks (#1871): frontmatter
[ -x "$_FREEZE_SCRIPT" ] || _FREEZE_SCRIPT="${CLAUDE_SKILL_DIR}/../gstack-freeze/bin/check-freeze.sh" # 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" [ -x "$_FREEZE_SCRIPT" ] && echo "FREEZE_AVAILABLE" || echo "FREEZE_UNAVAILABLE"
``` ```

View File

@ -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 // check-careful.sh tests
// ============================================================ // ============================================================

View File

@ -5,20 +5,29 @@ import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..'); const ROOT = path.resolve(import.meta.dir, '..');
const FILES = ['investigate/SKILL.md.tmpl', 'investigate/SKILL.md']; 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', () => { describe('investigate freeze path resolution', () => {
for (const rel of FILES) { for (const rel of FILES) {
const content = fs.readFileSync(path.join(ROOT, rel), 'utf-8'); const content = fs.readFileSync(path.join(ROOT, rel), 'utf-8');
test(`${rel} hook falls back to standalone gstack-freeze install`, () => { test(`${rel} hook resolves check-freeze via the $HOME anchor`, () => {
expect(content).toContain('${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh'); expect(content).toContain('S="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"');
expect(content).toContain('${CLAUDE_SKILL_DIR}/../gstack-freeze/bin/check-freeze.sh'); expect(content).toContain('[ -x "$S" ] && exec bash "$S"; exit 0');
expect(content).toContain('[ -x "$S" ] && bash "$S" || exit 0'); const commandLines = content.split('\n').filter((l) => l.trim().startsWith('command:'));
expect(content).toContain("command: 'bash -c ''"); 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`, () => { test(`${rel} scope lock availability probe uses the $HOME anchor`, () => {
expect(content).toContain('_FREEZE_SCRIPT="${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh"'); expect(content).toContain('_FREEZE_SCRIPT="$HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"');
expect(content).toContain('[ -x "$_FREEZE_SCRIPT" ] || _FREEZE_SCRIPT="${CLAUDE_SKILL_DIR}/../gstack-freeze/bin/check-freeze.sh"');
expect(content).toContain('[ -x "$_FREEZE_SCRIPT" ] && echo "FREEZE_AVAILABLE" || echo "FREEZE_UNAVAILABLE"'); expect(content).toContain('[ -x "$_FREEZE_SCRIPT" ] && echo "FREEZE_AVAILABLE" || echo "FREEZE_UNAVAILABLE"');
}); });
} }