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 */ }
}

View File

@@ -0,0 +1,179 @@
/**
* Integration tests for gsd-context-monitor.js auto-record on CRITICAL (#1974).
*
* Verifies:
* 1. On CRITICAL + active GSD project, subprocess is spawned and STATE.md
* receives the "Stopped At" field.
* 2. Subsequent CRITICAL firings within the same session do NOT re-fire
* the subprocess (sentinel guard prevents repeated overwrites).
* 3. When no .planning/STATE.md exists, the subprocess is not spawned.
* 4. Path resolution uses __dirname, not hardcoded ~/.claude/.
*/
'use strict';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { spawnSync } = require('node:child_process');
const HOOK_PATH = path.resolve(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
/**
* Run the hook with a given session id and context percentage.
* Writes a bridge metrics file first, then pipes the hook input via stdin.
* Returns after the hook exits.
*/
function runHook(sessionId, remainingPct, cwd) {
// Write the bridge metrics file the hook reads
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
fs.writeFileSync(bridgePath, JSON.stringify({
session_id: sessionId,
remaining_percentage: remainingPct,
used_pct: 100 - remainingPct,
timestamp: Math.floor(Date.now() / 1000),
}));
const input = JSON.stringify({
session_id: sessionId,
cwd,
});
const result = spawnSync(process.execPath, [HOOK_PATH], {
input,
encoding: 'utf-8',
timeout: 10000,
env: { ...process.env, HOME: process.env.HOME },
});
return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr };
}
/**
* Wait up to `ms` for a file to exist (the subprocess is fire-and-forget).
*/
function waitForStoppedAt(statePath, ms = 2000) {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
try {
const content = fs.readFileSync(statePath, 'utf-8');
if (/Stopped [Aa]t.*context exhaustion/.test(content)) return content;
} catch { /* file may briefly not exist during atomic write */ }
// Tight poll loop — subprocess should complete in <100ms
const start = Date.now();
while (Date.now() - start < 50) { /* spin */ }
}
return null;
}
describe('#1974 context exhaustion auto-record', () => {
let tmpDir;
let statePath;
let sessionId;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1974-'));
const planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
// Minimal STATE.md with Stopped At field
statePath = path.join(planningDir, 'STATE.md');
fs.writeFileSync(statePath, [
'# Session State',
'',
'**Current Phase:** 1',
'**Status:** executing',
'**Last session:** unset',
'**Last Date:** unset',
'**Stopped At:** None',
'**Resume File:** None',
'',
].join('\n'));
// Minimal config.json required by gsd-tools
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ project_code: 'TEST' }));
sessionId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
// Clean up bridge files
try {
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);
if (fs.existsSync(warnPath)) fs.unlinkSync(warnPath);
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
if (fs.existsSync(bridgePath)) fs.unlinkSync(bridgePath);
} catch { /* noop */ }
});
test('spawns subprocess and writes Stopped At field on CRITICAL with active GSD', () => {
// Trigger CRITICAL — remaining <= 25
const result = runHook(sessionId, 20, tmpDir);
assert.strictEqual(result.exitCode, 0, `hook should exit 0: ${result.stderr}`);
// Wait for fire-and-forget subprocess to write STATE.md
const content = waitForStoppedAt(statePath);
assert.ok(content, `STATE.md should contain "context exhaustion" after CRITICAL fire`);
assert.match(content, /context exhaustion at \d+%/);
});
test('does NOT spawn subprocess when .planning/STATE.md is absent', () => {
// Delete STATE.md to simulate non-GSD project
fs.unlinkSync(statePath);
const originalMtime = Date.now();
const result = runHook(sessionId, 20, tmpDir);
assert.strictEqual(result.exitCode, 0);
// Wait a bit then verify STATE.md was NOT recreated
const start = Date.now();
while (Date.now() - start < 500) { /* spin */ }
assert.ok(!fs.existsSync(statePath), 'STATE.md should not be recreated when absent');
});
test('sentinel prevents repeated firing within same session', () => {
// First CRITICAL fire — should record
runHook(sessionId, 20, tmpDir);
const content1 = waitForStoppedAt(statePath);
assert.ok(content1, 'first fire should record Stopped At');
// Extract the timestamp from first fire
const firstMatch = content1.match(/context exhaustion at (\d+)%/);
assert.ok(firstMatch, 'first fire should have numeric usedPct');
// Manually set Stopped At to a sentinel value to detect second fire
const modified = content1.replace(/(\*\*Stopped At:\*\*) .+/, '$1 SENTINEL_SHOULD_NOT_CHANGE');
fs.writeFileSync(statePath, modified);
// Second CRITICAL fire — should NOT re-fire the subprocess
runHook(sessionId, 18, tmpDir);
// Wait and verify the sentinel is preserved
const start = Date.now();
while (Date.now() - start < 500) { /* spin */ }
const content2 = fs.readFileSync(statePath, 'utf-8');
assert.match(
content2,
/SENTINEL_SHOULD_NOT_CHANGE/,
'second CRITICAL fire should not re-record (sentinel guard)'
);
});
test('hook uses __dirname-based path (runtime-agnostic)', () => {
// Verify the hook source references __dirname, not ~/.claude/
const hookSource = fs.readFileSync(HOOK_PATH, 'utf-8');
assert.match(
hookSource,
/path\.join\(__dirname,\s*'\.\.',\s*'get-shit-done'/,
'hook must use __dirname-based path resolution for gsd-tools.cjs'
);
assert.doesNotMatch(
hookSource,
/process\.env\.HOME.*\.claude.*get-shit-done.*gsd-tools\.cjs/,
'hook must not hardcode ~/.claude/ path'
);
});
});