mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
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/)
193 lines
7.9 KiB
JavaScript
193 lines
7.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// gsd-hook-version: {{GSD_VERSION}}
|
|
// Context Monitor - PostToolUse/AfterTool hook (Gemini uses AfterTool)
|
|
// Reads context metrics from the statusline bridge file and injects
|
|
// warnings when context usage is high. This makes the AGENT aware of
|
|
// context limits (the statusline only shows the user).
|
|
//
|
|
// How it works:
|
|
// 1. The statusline hook writes metrics to /tmp/claude-ctx-{session_id}.json
|
|
// 2. This hook reads those metrics after each tool use
|
|
// 3. When remaining context drops below thresholds, it injects a warning
|
|
// as additionalContext, which the agent sees in its conversation
|
|
//
|
|
// Thresholds:
|
|
// WARNING (remaining <= 35%): Agent should wrap up current task
|
|
// CRITICAL (remaining <= 25%): Agent should stop immediately and save state
|
|
//
|
|
// Debounce: 5 tool uses between warnings to avoid spam
|
|
// Severity escalation bypasses debounce (WARNING -> CRITICAL fires immediately)
|
|
|
|
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%
|
|
const STALE_SECONDS = 60; // ignore metrics older than 60s
|
|
const DEBOUNCE_CALLS = 5; // min tool uses between warnings
|
|
|
|
let input = '';
|
|
// Timeout guard: if stdin doesn't close within 10s (e.g. pipe issues on
|
|
// Windows/Git Bash, or slow Claude Code piping during large outputs),
|
|
// exit silently instead of hanging until Claude Code kills the process
|
|
// and reports "hook error". See #775, #1162.
|
|
const stdinTimeout = setTimeout(() => process.exit(0), 10000);
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', chunk => input += chunk);
|
|
process.stdin.on('end', () => {
|
|
clearTimeout(stdinTimeout);
|
|
try {
|
|
const data = JSON.parse(input);
|
|
const sessionId = data.session_id;
|
|
|
|
if (!sessionId) {
|
|
process.exit(0);
|
|
}
|
|
|
|
// Reject session IDs that contain path traversal sequences or path separators.
|
|
// session_id is used to construct file paths in /tmp — an unsanitized value
|
|
// could escape the temp directory and read or write arbitrary files.
|
|
if (/[/\\]|\.\./.test(sessionId)) {
|
|
process.exit(0);
|
|
}
|
|
|
|
// Check if context warnings are disabled via config.
|
|
// Quick sentinel check: skip config read entirely for non-GSD projects (#P2.5).
|
|
const cwd = data.cwd || process.cwd();
|
|
const planningDir = path.join(cwd, '.planning');
|
|
if (fs.existsSync(planningDir)) {
|
|
try {
|
|
const configPath = path.join(planningDir, 'config.json');
|
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
if (config.hooks?.context_warnings === false) {
|
|
process.exit(0);
|
|
}
|
|
} catch (e) {
|
|
// Ignore config read/parse errors (config may not exist in .planning/)
|
|
}
|
|
}
|
|
|
|
const tmpDir = os.tmpdir();
|
|
const metricsPath = path.join(tmpDir, `claude-ctx-${sessionId}.json`);
|
|
|
|
// If no metrics file, this is a subagent or fresh session -- exit silently
|
|
if (!fs.existsSync(metricsPath)) {
|
|
process.exit(0);
|
|
}
|
|
|
|
const metrics = JSON.parse(fs.readFileSync(metricsPath, 'utf8'));
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
// Ignore stale metrics
|
|
if (metrics.timestamp && (now - metrics.timestamp) > STALE_SECONDS) {
|
|
process.exit(0);
|
|
}
|
|
|
|
const remaining = metrics.remaining_percentage;
|
|
const usedPct = metrics.used_pct;
|
|
|
|
// No warning needed
|
|
if (remaining > WARNING_THRESHOLD) {
|
|
process.exit(0);
|
|
}
|
|
|
|
// Debounce: check if we warned recently
|
|
const warnPath = path.join(tmpDir, `claude-ctx-${sessionId}-warned.json`);
|
|
let warnData = { callsSinceWarn: 0, lastLevel: null };
|
|
let firstWarn = true;
|
|
|
|
if (fs.existsSync(warnPath)) {
|
|
try {
|
|
warnData = JSON.parse(fs.readFileSync(warnPath, 'utf8'));
|
|
firstWarn = false;
|
|
} catch (e) {
|
|
// Corrupted file, reset
|
|
}
|
|
}
|
|
|
|
warnData.callsSinceWarn = (warnData.callsSinceWarn || 0) + 1;
|
|
|
|
const isCritical = remaining <= CRITICAL_THRESHOLD;
|
|
const currentLevel = isCritical ? 'critical' : 'warning';
|
|
|
|
// Emit immediately on first warning, then debounce subsequent ones
|
|
// Severity escalation (WARNING -> CRITICAL) bypasses debounce
|
|
const severityEscalated = currentLevel === 'critical' && warnData.lastLevel === 'warning';
|
|
if (!firstWarn && warnData.callsSinceWarn < DEBOUNCE_CALLS && !severityEscalated) {
|
|
// Update counter and exit without warning
|
|
fs.writeFileSync(warnPath, JSON.stringify(warnData));
|
|
process.exit(0);
|
|
}
|
|
|
|
// Reset debounce counter
|
|
warnData.callsSinceWarn = 0;
|
|
warnData.lastLevel = currentLevel;
|
|
fs.writeFileSync(warnPath, JSON.stringify(warnData));
|
|
|
|
// Detect if GSD is active (has .planning/STATE.md in working directory)
|
|
const isGsdActive = fs.existsSync(path.join(cwd, '.planning', 'STATE.md'));
|
|
|
|
// 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. 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 {
|
|
// 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', '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 */ }
|
|
}
|
|
|
|
// Build advisory warning message (never use imperative commands that
|
|
// override user preferences — see #884)
|
|
let message;
|
|
if (isCritical) {
|
|
message = isGsdActive
|
|
? `CONTEXT CRITICAL: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
|
|
'Context is nearly exhausted. Do NOT start new complex work or write handoff files — ' +
|
|
'GSD state is already tracked in STATE.md. Inform the user so they can run ' +
|
|
'/gsd-pause-work at the next natural stopping point.'
|
|
: `CONTEXT CRITICAL: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
|
|
'Context is nearly exhausted. Inform the user that context is low and ask how they ' +
|
|
'want to proceed. Do NOT autonomously save state or write handoff files unless the user asks.';
|
|
} else {
|
|
message = isGsdActive
|
|
? `CONTEXT WARNING: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
|
|
'Context is getting limited. Avoid starting new complex work. If not between ' +
|
|
'defined plan steps, inform the user so they can prepare to pause.'
|
|
: `CONTEXT WARNING: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
|
|
'Be aware that context is getting limited. Avoid unnecessary exploration or ' +
|
|
'starting new complex work.';
|
|
}
|
|
|
|
const output = {
|
|
hookSpecificOutput: {
|
|
hookEventName: process.env.GEMINI_API_KEY ? "AfterTool" : "PostToolUse",
|
|
additionalContext: message
|
|
}
|
|
};
|
|
|
|
process.stdout.write(JSON.stringify(output));
|
|
} catch (e) {
|
|
// Silent fail -- never block tool execution
|
|
process.exit(0);
|
|
}
|
|
});
|