Files
get-shit-done/hooks/gsd-check-update-worker.js
Tom Boucher c35997fb0b feat(hooks): add gsd-read-injection-scanner PostToolUse hook (#2201) (#2328)
* feat: add /gsd-spec-phase — Socratic spec refinement with ambiguity scoring (#2213)

Introduces `/gsd-spec-phase <phase>` as an optional pre-step before discuss-phase.
Clarifies WHAT a phase delivers (requirements, boundaries, acceptance criteria) with
quantitative ambiguity scoring before discuss-phase handles HOW to implement.

- `commands/gsd/spec-phase.md` — slash command routing to workflow
- `get-shit-done/workflows/spec-phase.md` — full Socratic interview loop (up to 6
  rounds, 5 rotating perspectives: Researcher, Simplifier, Boundary Keeper, Failure
  Analyst, Seed Closer) with weighted 4-dimension ambiguity gate (≤ 0.20 to write SPEC.md)
- `get-shit-done/templates/spec.md` — SPEC.md template with falsifiable requirements
  (Current/Target/Acceptance per requirement), Boundaries, Acceptance Criteria,
  Ambiguity Report, and Interview Log; includes two full worked examples
- `get-shit-done/workflows/discuss-phase.md` — new `check_spec` step detects
  `{padded_phase}-SPEC.md` at startup; displays "Found SPEC.md — N requirements
  locked. Focusing on implementation decisions."; `analyze_phase` respects `spec_loaded`
  flag to skip "what/why" gray areas; `write_context` emits `<spec_lock>` section
  with boundary summary and canonical ref to SPEC.md
- `docs/ARCHITECTURE.md` — update command/workflow counts (74→75, 71→72)

Closes #2213

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(hooks): add gsd-read-injection-scanner PostToolUse hook (#2201)

Adds a new PostToolUse hook that scans content returned by the Read tool
for prompt injection patterns, including four summarisation-specific patterns
(retention-directive, permanence-claim, etc.) that survive context compression.

Defense-in-depth for long GSD sessions where the context summariser cannot
distinguish user instructions from content read from external files.

- Advisory-only (warns without blocking), consistent with gsd-prompt-guard.js
- LOW severity for 1-2 patterns, HIGH for 3+
- Inlined pattern library (hook independence)
- Exclusion list: .planning/, REVIEW.md, CHECKPOINT, security docs, hook sources
- Wired in install.js as PostToolUse matcher: Read, timeout: 5s
- Added to MANAGED_HOOKS for staleness detection
- 19 tests covering all 13 acceptance criteria (SCAN-01–07, EXCL-01–06, EDGE-01–06)

Closes #2201

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ci): add read-injection-scanner files to prompt-injection-scan allowlist

Test payloads in tests/read-injection-scanner.test.cjs and inlined patterns
in hooks/gsd-read-injection-scanner.js legitimately contain injection strings.
Add both to the CI script allowlist to prevent false-positive failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): assert exitCode, stdout, and signal explicitly in EDGE-05

Addresses CodeRabbit feedback: the success path discarded the return
value so a malformed-JSON input that produced stdout would still pass.
Now captures and asserts all three observable properties.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 17:22:31 -04:00

109 lines
3.8 KiB
JavaScript

#!/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-read-injection-scanner.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) {}
}