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:
@@ -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 */ }
|
||||
}
|
||||
|
||||
|
||||
179
tests/bug-1974-context-exhaustion-record.test.cjs
Normal file
179
tests/bug-1974-context-exhaustion-record.test.cjs
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user