#!/usr/bin/env node // stop-context-monitor.mjs — graceful-handoff v2.0 (Hybrid Option C from research/06) // // Stop hook fires after each model response. Estimates context usage from // transcript size; at ≥70% (estimated), spawns handoff-pipeline.mjs --auto // --no-push to write artifact + commit. Push remains user-triggered. // // Reconciliation with disable-model-invocation: the spawn calls the script // DIRECTLY, not the skill. The skill stays manual-only. // // Lock file at /.handoff-lock- prevents repeat // firing in the same session. // // Context resolution (4-step fallback, v2.1): // 1. payload.context_window.used_percentage → authoritative, model-agnostic // 2. payload.context_window.context_window_size + transcript estimate // 3. MODEL_WINDOWS[payload.model.id] + transcript estimate // 4. FALLBACK_WINDOW (1M, 2026 default) + transcript estimate // Token estimation (steps 2-4): char_count / 3.5. Approximation drifts ±10%; // 70% threshold is conservative buffer. import { readFileSync, statSync, writeFileSync, existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { spawnSync } from 'node:child_process'; const THRESHOLD = 0.70; const FALLBACK_WINDOW = 1_000_000; const CHARS_PER_TOKEN = 3.5; // Model → context window mapping. Sonnet 4.6 has an opt-in 1M tier that is // not always active and not exposed in payload — use the safer 200k default. const MODEL_WINDOWS = { 'claude-opus-4-7': 1_000_000, 'claude-sonnet-4-6': 200_000, 'claude-haiku-4-5-20251001': 200_000, }; // Test injection: tests can override these by setting on the export. export const __testHooks = { spawn: spawnSync, fsRead: readFileSync, fsStat: statSync, fsWrite: writeFileSync, fsExists: existsSync, }; function readStdin() { try { return readFileSync(0, 'utf-8'); } catch { return ''; } } function estimateUsedPct(transcriptPath, windowSize) { try { const stat = __testHooks.fsStat(transcriptPath); const tokens = stat.size / CHARS_PER_TOKEN; return tokens / windowSize; } catch { return null; } } // Resolve context usage via the 4-step fallback chain documented above. // Returns { pct, source } or null if pct cannot be computed. export function resolveContextSource(payload, transcriptPath) { const ctx = payload?.context_window; const direct = ctx?.used_percentage; if (typeof direct === 'number' && !isNaN(direct) && direct > 0) { return { pct: direct / 100, source: 'direct' }; } const payloadSize = ctx?.context_window_size; if (typeof payloadSize === 'number' && payloadSize > 0) { const pct = estimateUsedPct(transcriptPath, payloadSize); return pct == null ? null : { pct, source: 'payload-size' }; } const modelId = payload?.model?.id; const mapped = modelId ? MODEL_WINDOWS[modelId] : undefined; if (mapped) { const pct = estimateUsedPct(transcriptPath, mapped); return pct == null ? null : { pct, source: 'model-map' }; } const pct = estimateUsedPct(transcriptPath, FALLBACK_WINDOW); return pct == null ? null : { pct, source: 'default-1m' }; } function emit(output) { process.stdout.write(JSON.stringify(output)); } function main() { const raw = readStdin(); if (!raw.trim()) { process.exit(0); } let payload; try { payload = JSON.parse(raw); } catch { process.exit(0); } const transcriptPath = payload?.transcript_path; const sessionId = payload?.session_id || 'unknown'; if (!transcriptPath) { process.exit(0); } // 4-step resolution: used_percentage → payload-size → model-map → 1M fallback const resolved = resolveContextSource(payload, transcriptPath); if (resolved == null) { process.exit(0); } const { pct: pctRaw, source } = resolved; const pct = Math.round(pctRaw * 100); if (pctRaw < THRESHOLD) { process.exit(0); } // Lock file path: based on transcript directory (session-stable), // NOT cwd (which can change). See plan revisions #6. const lockPath = join(dirname(transcriptPath), `.handoff-lock-${sessionId}`); if (__testHooks.fsExists(lockPath)) { process.exit(0); // already triggered this session } // Touch lock first to prevent races on rapid Stop hook firing try { __testHooks.fsWrite(lockPath, `${sessionId}\n${new Date().toISOString()}\n`, 'utf-8'); } catch { process.exit(0); // can't lock, give up silently } // Spawn pipeline synchronously (NOT detached) so we can capture output. // 25s timeout fits within Stop hook 30s timeout budget. const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT; if (!pluginRoot) { emit({ hookSpecificOutput: { hookEventName: 'Stop', additionalContext: `⚠️ Auto-handoff aborted at est. ${pct}% [kilde: ${source}]: CLAUDE_PLUGIN_ROOT not set, cannot locate handoff-pipeline.mjs.`, }, }); process.exit(0); } const pipelineScript = join(pluginRoot, 'scripts', 'handoff-pipeline.mjs'); const result = __testHooks.spawn( 'node', [pipelineScript, '--auto', '--no-push', '--non-interactive'], { encoding: 'utf-8', timeout: 25_000 } ); if (result.error) { emit({ hookSpecificOutput: { hookEventName: 'Stop', additionalContext: `⚠️ Auto-handoff FAILED at est. ${pct}% [kilde: ${source}]: ${result.error.message}. Run /graceful-handoff manually.`, }, }); process.exit(0); } if (result.status !== 0) { emit({ hookSpecificOutput: { hookEventName: 'Stop', additionalContext: `⚠️ Auto-handoff pipeline exited ${result.status} at est. ${pct}% [kilde: ${source}]. stderr: ${(result.stderr || '').slice(0, 300)}. Run /graceful-handoff manually.`, }, }); process.exit(0); } // Parse pipeline JSON; report status to user via additionalContext let pipelineResult; try { pipelineResult = JSON.parse(result.stdout); } catch { emit({ hookSpecificOutput: { hookEventName: 'Stop', additionalContext: `⚠️ Auto-handoff at est. ${pct}% [kilde: ${source}]: pipeline output unparseable. Run /graceful-handoff manually.`, }, }); process.exit(0); } const errors = pipelineResult.errors || []; if (errors.length > 0) { emit({ hookSpecificOutput: { hookEventName: 'Stop', additionalContext: `⚠️ Auto-handoff at est. ${pct}% [kilde: ${source}] partially completed with errors: ${errors.join('; ')}. Artifact: ${pipelineResult.artifact_path || 'not written'}. Run git push manually.`, }, }); process.exit(0); } emit({ hookSpecificOutput: { hookEventName: 'Stop', additionalContext: `⚠️ Auto-handoff utført ved estimert ${pct}% [kilde: ${source}]: artefakt ${pipelineResult.artifact_path}. Push gjenstår — kjør \`git push\` når du er klar.`, }, }); process.exit(0); } // Only run main() when invoked as script, not when imported by tests if (import.meta.url === `file://${process.argv[1]}`) { main(); }