diff --git a/agents/gsd-debugger.md b/agents/gsd-debugger.md index 6f1f4239f..8c7109032 100644 --- a/agents/gsd-debugger.md +++ b/agents/gsd-debugger.md @@ -409,6 +409,39 @@ git bisect bad # or good, based on testing 100 commits between working and broken: ~7 tests to find exact breaking commit. +## Follow the Indirection + +**When:** Code constructs paths, URLs, keys, or references from variables — and the constructed value might not point where you expect. + +**The trap:** You read code that builds a path like `path.join(configDir, 'hooks')` and assume it's correct because it looks reasonable. But you never verified that the constructed path matches where another part of the system actually writes/reads. + +**How:** +1. Find the code that **produces** the value (writer/installer/creator) +2. Find the code that **consumes** the value (reader/checker/validator) +3. Trace the actual resolved value in both — do they agree? +4. Check every variable in the path construction — where does each come from? What's its actual value at runtime? + +**Common indirection bugs:** +- Path A writes to `dir/sub/hooks/` but Path B checks `dir/hooks/` (directory mismatch) +- Config value comes from cache/template that wasn't updated +- Variable is derived differently in two places (e.g., one adds a subdirectory, the other doesn't) +- Template placeholder (`{{VERSION}}`) not substituted in all code paths + +**Example:** Stale hook warning persists after update +``` +Check code says: hooksDir = path.join(configDir, 'hooks') + configDir = ~/.claude + → checks ~/.claude/hooks/ + +Installer says: hooksDest = path.join(targetDir, 'hooks') + targetDir = ~/.claude/get-shit-done + → writes to ~/.claude/get-shit-done/hooks/ + +MISMATCH: Checker looks in wrong directory → hooks "not found" → reported as stale +``` + +**The discipline:** Never assume a constructed path is correct. Resolve it to its actual value and verify the other side agrees. When two systems share a resource (file, directory, key), trace the full path in both. + ## Technique Selection | Situation | Technique | @@ -419,6 +452,7 @@ git bisect bad # or good, based on testing | Know the desired output | Working backwards | | Used to work, now doesn't | Differential debugging, Git bisect | | Many possible causes | Comment out everything, Binary search | +| Paths, URLs, keys constructed from variables | Follow the indirection | | Always | Observability first (before making changes) | ## Combining Techniques diff --git a/bin/install.js b/bin/install.js index 674120eb4..4f8eeced7 100755 --- a/bin/install.js +++ b/bin/install.js @@ -3900,6 +3900,11 @@ function install(isGlobal, runtime = 'claude') { } } + // Clear stale update cache so next session re-evaluates hook versions + // targetDir is e.g. ~/.claude/get-shit-done/, parent is the config dir + const updateCacheFile = path.join(path.dirname(targetDir), 'cache', 'gsd-update-check.json'); + try { fs.unlinkSync(updateCacheFile); } catch (e) { /* cache may not exist yet */ } + if (failures.length > 0) { console.error(`\n ${yellow}Installation incomplete!${reset} Failed: ${failures.join(', ')}`); process.exit(1); diff --git a/hooks/gsd-check-update.js b/hooks/gsd-check-update.js index 9076ec038..1b7b27ed3 100755 --- a/hooks/gsd-check-update.js +++ b/hooks/gsd-check-update.js @@ -65,9 +65,10 @@ const child = spawn(process.execPath, ['-e', ` } catch (e) {} // Check for stale hooks — compare hook version headers against installed VERSION + // Hooks live inside get-shit-done/hooks/, not configDir/hooks/ let staleHooks = []; if (configDir) { - const hooksDir = path.join(configDir, 'hooks'); + const hooksDir = path.join(configDir, 'get-shit-done', 'hooks'); try { if (fs.existsSync(hooksDir)) { const hookFiles = fs.readdirSync(hooksDir).filter(f => f.startsWith('gsd-') && f.endsWith('.js')); diff --git a/tests/core.test.cjs b/tests/core.test.cjs index 68b80c968..2463e30b5 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -968,6 +968,26 @@ describe('stale hook filter', () => { }); }); +// ─── stale hook path regression (#1249) ────────────────────────────────────── + +describe('stale hook path', () => { + test('gsd-check-update.js checks get-shit-done/hooks/ not configDir/hooks/', () => { + const content = fs.readFileSync( + path.join(__dirname, '..', 'hooks', 'gsd-check-update.js'), 'utf-8' + ); + assert.ok( + content.includes("path.join(configDir, 'get-shit-done', 'hooks')"), + 'stale hook check must look in configDir/get-shit-done/hooks/, not configDir/hooks/' + ); + assert.ok( + !content.includes("path.join(configDir, 'hooks')") || + content.indexOf("path.join(configDir, 'get-shit-done', 'hooks')") < + content.indexOf("path.join(configDir, 'hooks')") + 100, // allow the old pattern only if corrected version exists first + 'should not use the wrong hooks path' + ); + }); +}); + // ─── resolveWorktreeRoot ───────────────────────────────────────────────────── describe('resolveWorktreeRoot', () => {