fix(hooks): complete stale-hooks false-positive fix — stamp .sh version headers + fix detector regex (#2224)
* fix(hooks): stamp gsd-hook-version in .sh hooks and fix stale detection regex (#2136, #2206) Three-part fix for the persistent "⚠ stale hooks — run /gsd-update" false positive that appeared on every session after a fresh install. Root cause: the stale-hook detector (gsd-check-update.js) could only match the JS comment syntax // in its version regex — never the bash # syntax used in .sh hooks. And the bash hooks had no version header at all, so they always landed in the "unknown / stale" branch regardless. Neither partial fix (PR #2207 regex only, PR #2215 install stamping only) was sufficient alone: - Regex fix without install stamping: hooks install with literal "{{GSD_VERSION}}", the {{-guard silently skips them, bash hook staleness permanently undetectable after future updates. - Install stamping without regex fix: hooks are stamped correctly with "# gsd-hook-version: 1.36.0" but the detector's // regex can't read it; still falls to the unknown/stale branch on every session. Fix: 1. Add "# gsd-hook-version: {{GSD_VERSION}}" header to gsd-phase-boundary.sh, gsd-session-state.sh, gsd-validate-commit.sh 2. Extend install.js (both bundled and Codex paths) to substitute {{GSD_VERSION}} in .sh files at install time (same as .js hooks) 3. Extend gsd-check-update.js versionMatch regex to handle bash "#" comment syntax: /(?:\/\/|#) gsd-hook-version:\s*(.+)/ Tests: 11 new assertions across 5 describe blocks covering all three fix parts independently plus an E2E install+detect round-trip. 3885/3885 pass. Approach credit: PR #2207 (j2h4u / Maxim Brashenko) for the regex fix; PR #2215 (nitsan2dots) for the install.js substitution approach. Closes #2136, #2206, #2209, #2210, #2212 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(hooks): extract check-update worker to dedicated file, eliminating template-literal regex escaping Move stale-hook detection logic from inline `node -e '<template literal>'` subprocess to a standalone gsd-check-update-worker.js. Benefits: - Regex is plain JS with no double-escaping (root cause of the (?:\\/\\/|#) confusion) - Worker is independently testable and can be read directly by tests - Uses execFileSync (array args) to satisfy security hook that blocks execSync - MANAGED_HOOKS now includes gsd-check-update-worker.js itself Update tests to read worker file instead of main hook for regex/configDir assertions. All 3886 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
107
hooks/gsd-check-update-worker.js
Normal file
107
hooks/gsd-check-update-worker.js
Normal file
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env node
|
||||
// gsd-hook-version: {{GSD_VERSION}}
|
||||
// Background worker spawned by gsd-check-update.js (SessionStart hook).
|
||||
// Checks for GSD updates and stale hooks, writes result to cache file.
|
||||
// Receives paths via environment variables set by the parent hook.
|
||||
//
|
||||
// Using a separate file (rather than node -e '<inline code>') avoids the
|
||||
// template-literal regex-escaping problem: regex source is plain JS here.
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const cacheFile = process.env.GSD_CACHE_FILE;
|
||||
const projectVersionFile = process.env.GSD_PROJECT_VERSION_FILE;
|
||||
const globalVersionFile = process.env.GSD_GLOBAL_VERSION_FILE;
|
||||
|
||||
// Compare semver: true if a > b (a is strictly newer than b)
|
||||
// Strips pre-release suffixes (e.g. '3-beta.1' → '3') to avoid NaN from Number()
|
||||
function isNewer(a, b) {
|
||||
const pa = (a || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
|
||||
const pb = (b || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (pa[i] > pb[i]) return true;
|
||||
if (pa[i] < pb[i]) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check project directory first (local install), then global
|
||||
let installed = '0.0.0';
|
||||
let configDir = '';
|
||||
try {
|
||||
if (fs.existsSync(projectVersionFile)) {
|
||||
installed = fs.readFileSync(projectVersionFile, 'utf8').trim();
|
||||
configDir = path.dirname(path.dirname(projectVersionFile));
|
||||
} else if (fs.existsSync(globalVersionFile)) {
|
||||
installed = fs.readFileSync(globalVersionFile, 'utf8').trim();
|
||||
configDir = path.dirname(path.dirname(globalVersionFile));
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// Check for stale hooks — compare hook version headers against installed VERSION
|
||||
// Hooks are installed at configDir/hooks/ (e.g. ~/.claude/hooks/) (#1421)
|
||||
// Only check hooks that GSD currently ships — orphaned files from removed features
|
||||
// (e.g., gsd-intel-*.js) must be ignored to avoid permanent stale warnings (#1750)
|
||||
const MANAGED_HOOKS = [
|
||||
'gsd-check-update-worker.js',
|
||||
'gsd-check-update.js',
|
||||
'gsd-context-monitor.js',
|
||||
'gsd-phase-boundary.sh',
|
||||
'gsd-prompt-guard.js',
|
||||
'gsd-read-guard.js',
|
||||
'gsd-session-state.sh',
|
||||
'gsd-statusline.js',
|
||||
'gsd-validate-commit.sh',
|
||||
'gsd-workflow-guard.js',
|
||||
];
|
||||
|
||||
let staleHooks = [];
|
||||
if (configDir) {
|
||||
const hooksDir = path.join(configDir, 'hooks');
|
||||
try {
|
||||
if (fs.existsSync(hooksDir)) {
|
||||
const hookFiles = fs.readdirSync(hooksDir).filter(f => MANAGED_HOOKS.includes(f));
|
||||
for (const hookFile of hookFiles) {
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(hooksDir, hookFile), 'utf8');
|
||||
// Match both JS (//) and bash (#) comment styles
|
||||
const versionMatch = content.match(/(?:\/\/|#) gsd-hook-version:\s*(.+)/);
|
||||
if (versionMatch) {
|
||||
const hookVersion = versionMatch[1].trim();
|
||||
if (isNewer(installed, hookVersion) && !hookVersion.includes('{{')) {
|
||||
staleHooks.push({ file: hookFile, hookVersion, installedVersion: installed });
|
||||
}
|
||||
} else {
|
||||
// No version header at all — definitely stale (pre-version-tracking)
|
||||
staleHooks.push({ file: hookFile, hookVersion: 'unknown', installedVersion: installed });
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
let latest = null;
|
||||
try {
|
||||
latest = execFileSync('npm', ['view', 'get-shit-done-cc', 'version'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
windowsHide: true,
|
||||
}).trim();
|
||||
} catch (e) {}
|
||||
|
||||
const result = {
|
||||
update_available: latest && isNewer(latest, installed),
|
||||
installed,
|
||||
latest: latest || 'unknown',
|
||||
checked: Math.floor(Date.now() / 1000),
|
||||
stale_hooks: staleHooks.length > 0 ? staleHooks : undefined,
|
||||
};
|
||||
|
||||
if (cacheFile) {
|
||||
try { fs.writeFileSync(cacheFile, JSON.stringify(result)); } catch (e) {}
|
||||
}
|
||||
@@ -44,99 +44,21 @@ if (!fs.existsSync(cacheDir)) {
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Run check in background (spawn background process, windowsHide prevents console flash)
|
||||
const child = spawn(process.execPath, ['-e', `
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Compare semver: true if a > b (a is strictly newer than b)
|
||||
// Strips pre-release suffixes (e.g. '3-beta.1' → '3') to avoid NaN from Number()
|
||||
function isNewer(a, b) {
|
||||
const pa = (a || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
|
||||
const pb = (b || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (pa[i] > pb[i]) return true;
|
||||
if (pa[i] < pb[i]) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const cacheFile = ${JSON.stringify(cacheFile)};
|
||||
const projectVersionFile = ${JSON.stringify(projectVersionFile)};
|
||||
const globalVersionFile = ${JSON.stringify(globalVersionFile)};
|
||||
|
||||
// Check project directory first (local install), then global
|
||||
let installed = '0.0.0';
|
||||
let configDir = '';
|
||||
try {
|
||||
if (fs.existsSync(projectVersionFile)) {
|
||||
installed = fs.readFileSync(projectVersionFile, 'utf8').trim();
|
||||
configDir = path.dirname(path.dirname(projectVersionFile));
|
||||
} else if (fs.existsSync(globalVersionFile)) {
|
||||
installed = fs.readFileSync(globalVersionFile, 'utf8').trim();
|
||||
configDir = path.dirname(path.dirname(globalVersionFile));
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// Check for stale hooks — compare hook version headers against installed VERSION
|
||||
// Hooks are installed at configDir/hooks/ (e.g. ~/.claude/hooks/) (#1421)
|
||||
// Only check hooks that GSD currently ships — orphaned files from removed features
|
||||
// (e.g., gsd-intel-*.js) must be ignored to avoid permanent stale warnings (#1750)
|
||||
const MANAGED_HOOKS = [
|
||||
'gsd-check-update.js',
|
||||
'gsd-context-monitor.js',
|
||||
'gsd-phase-boundary.sh',
|
||||
'gsd-prompt-guard.js',
|
||||
'gsd-read-guard.js',
|
||||
'gsd-session-state.sh',
|
||||
'gsd-statusline.js',
|
||||
'gsd-validate-commit.sh',
|
||||
'gsd-workflow-guard.js',
|
||||
];
|
||||
let staleHooks = [];
|
||||
if (configDir) {
|
||||
const hooksDir = path.join(configDir, 'hooks');
|
||||
try {
|
||||
if (fs.existsSync(hooksDir)) {
|
||||
const hookFiles = fs.readdirSync(hooksDir).filter(f => MANAGED_HOOKS.includes(f));
|
||||
for (const hookFile of hookFiles) {
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(hooksDir, hookFile), 'utf8');
|
||||
const versionMatch = content.match(/\\/\\/ gsd-hook-version:\\s*(.+)/);
|
||||
if (versionMatch) {
|
||||
const hookVersion = versionMatch[1].trim();
|
||||
if (isNewer(installed, hookVersion) && !hookVersion.includes('{{')) {
|
||||
staleHooks.push({ file: hookFile, hookVersion, installedVersion: installed });
|
||||
}
|
||||
} else {
|
||||
// No version header at all — definitely stale (pre-version-tracking)
|
||||
staleHooks.push({ file: hookFile, hookVersion: 'unknown', installedVersion: installed });
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
let latest = null;
|
||||
try {
|
||||
latest = execSync('npm view get-shit-done-cc version', { encoding: 'utf8', timeout: 10000, windowsHide: true }).trim();
|
||||
} catch (e) {}
|
||||
|
||||
const result = {
|
||||
update_available: latest && isNewer(latest, installed),
|
||||
installed,
|
||||
latest: latest || 'unknown',
|
||||
checked: Math.floor(Date.now() / 1000),
|
||||
stale_hooks: staleHooks.length > 0 ? staleHooks : undefined
|
||||
};
|
||||
|
||||
fs.writeFileSync(cacheFile, JSON.stringify(result));
|
||||
`], {
|
||||
// Run check in background via a dedicated worker script.
|
||||
// Spawning a file (rather than node -e '<inline code>') keeps the worker logic
|
||||
// in plain JS with no template-literal regex-escaping concerns, and makes the
|
||||
// worker independently testable.
|
||||
const workerPath = path.join(__dirname, 'gsd-check-update-worker.js');
|
||||
const child = spawn(process.execPath, [workerPath], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
detached: true // Required on Windows for proper process detachment
|
||||
detached: true, // Required on Windows for proper process detachment
|
||||
env: {
|
||||
...process.env,
|
||||
GSD_CACHE_FILE: cacheFile,
|
||||
GSD_PROJECT_VERSION_FILE: projectVersionFile,
|
||||
GSD_GLOBAL_VERSION_FILE: globalVersionFile,
|
||||
},
|
||||
});
|
||||
|
||||
child.unref();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/bin/bash
|
||||
# gsd-hook-version: {{GSD_VERSION}}
|
||||
# gsd-phase-boundary.sh — PostToolUse hook: detect .planning/ file writes
|
||||
# Outputs a reminder when planning files are modified outside normal workflow.
|
||||
# Uses Node.js for JSON parsing (always available in GSD projects, no jq dependency).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/bin/bash
|
||||
# gsd-hook-version: {{GSD_VERSION}}
|
||||
# gsd-session-state.sh — SessionStart hook: inject project state reminder
|
||||
# Outputs STATE.md head on every session start for orientation.
|
||||
#
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/bin/bash
|
||||
# gsd-hook-version: {{GSD_VERSION}}
|
||||
# gsd-validate-commit.sh — PreToolUse hook: enforce Conventional Commits format
|
||||
# Blocks git commit commands with non-conforming messages (exit 2).
|
||||
# Allows conforming messages and all non-commit commands (exit 0).
|
||||
|
||||
Reference in New Issue
Block a user