fix(hooks): address three blocking defects in context exhaustion record (#1974)

Review feedback from @trek-e — three blocking fixes:

1. **Sentinel prevents repeated firing**
   Added warnData.criticalRecorded flag persisted to the warn state file.
   Previously the subprocess fired on every DEBOUNCE_CALLS cycle (5 tool
   uses) for the rest of the session, overwriting the "crash moment"
   record with a new timestamp each time. Now fires exactly once per
   CRITICAL session.

2. **Runtime-agnostic path via __dirname**
   Replaced hardcoded `path.join(process.env.HOME, '.claude', ...)` with
   `path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs')`.
   The hook lives at <runtime-config>/hooks/ and gsd-tools.cjs at
   <runtime-config>/get-shit-done/bin/ — __dirname resolves correctly on
   all runtimes (Claude Code, OpenCode, Gemini, Kilo) without assuming
   ~/.claude/.

3. **Correct subcommand: state record-session**
   Switched from `state update "Stopped At" ...` to
   `state record-session --stopped-at ...`. The dedicated command
   updates Last session, Last Date, Stopped At, and Resume File
   atomically under the state lock.

Also:
- Hoisted `const { spawn } = require('child_process')` to top of file
  to match existing require() style.
- Coerced usedPct to Number(usedPct) || 0 to sanitize the bridge file
  in case it's malformed or adversarially crafted.

Tests (tests/bug-1974-context-exhaustion-record.test.cjs, 4 cases):
- Subprocess spawns and writes "context exhaustion" on CRITICAL
- Subprocess does NOT spawn when .planning/STATE.md is absent
- Sentinel guard prevents second fire within same session
- Hook source uses __dirname-based path (not hardcoded ~/.claude/)
This commit is contained in:
Tibsfox
2026-04-11 03:37:34 -07:00
parent 42ad3fe853
commit 7be9affea2
2 changed files with 197 additions and 6 deletions

View File

@@ -21,6 +21,7 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const WARNING_THRESHOLD = 35; // remaining_percentage <= 35%
const CRITICAL_THRESHOLD = 25; // remaining_percentage <= 25%
@@ -130,16 +131,27 @@ process.stdin.on('end', () => {
// On CRITICAL with active GSD project, auto-record session state as a
// breadcrumb for /gsd-resume-work (#1974). Fire-and-forget subprocess —
// doesn't block the hook or the agent.
if (isCritical && isGsdActive) {
// doesn't block the hook or the agent. Fires ONCE per CRITICAL session,
// guarded by warnData.criticalRecorded to prevent repeated overwrites
// of the "crash moment" record on every debounce cycle.
if (isCritical && isGsdActive && !warnData.criticalRecorded) {
try {
const gsdTools = path.join(process.env.HOME || '', '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs');
const timestamp = new Date().toISOString().split('T')[0];
require('child_process').spawn(
// Runtime-agnostic path: this hook lives at <runtime-config>/hooks/
// and gsd-tools.cjs lives at <runtime-config>/get-shit-done/bin/.
// Using __dirname makes this work on Claude Code, OpenCode, Gemini,
// Kilo, etc. without hardcoding ~/.claude/.
const gsdTools = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs');
// Coerce usedPct to a safe number in case bridge file is malformed
const safeUsedPct = Number(usedPct) || 0;
const stoppedAt = `context exhaustion at ${safeUsedPct}% (${new Date().toISOString().split('T')[0]})`;
spawn(
process.execPath,
[gsdTools, 'state', 'update', 'Stopped At', `context exhaustion at ${usedPct}% (${timestamp})`],
[gsdTools, 'state', 'record-session', '--stopped-at', stoppedAt],
{ cwd, detached: true, stdio: 'ignore' }
).unref();
warnData.criticalRecorded = true;
// Persist the sentinel so subsequent debounce cycles don't re-fire
fs.writeFileSync(warnPath, JSON.stringify(warnData));
} catch { /* non-critical — don't let state recording break the hook */ }
}