diff --git a/hooks/gsd-context-monitor.js b/hooks/gsd-context-monitor.js index ed36374b2..5b0ae89b9 100644 --- a/hooks/gsd-context-monitor.js +++ b/hooks/gsd-context-monitor.js @@ -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 /hooks/ + // and gsd-tools.cjs lives at /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 */ } } diff --git a/tests/bug-1974-context-exhaustion-record.test.cjs b/tests/bug-1974-context-exhaustion-record.test.cjs new file mode 100644 index 000000000..2cebdd82b --- /dev/null +++ b/tests/bug-1974-context-exhaustion-record.test.cjs @@ -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' + ); + }); +});