Stop hook fallback antok 200K-vindu. På Opus 4.7 (faktisk 1M) kunne auto-handoff fyre 5–7x for tidlig — estimert 70% når reell bruk var ~14%. Erstatter enkel fallback med 4-stegs resolution-kjede: 1. payload.context_window.used_percentage (autoritativ) 2. payload.context_window.context_window_size + transcript-estimat 3. MODEL_WINDOWS[payload.model.id] + estimat 4. FALLBACK_WINDOW=1_000_000 + estimat (2026-default) additionalContext-meldinger inkluderer nå [kilde: <source>] for innsyn. Brief som kilde-artefakt i docs/brief-context-window-detection.md. 6 nye tester (57 totalt). Ingen regresjoner.
216 lines
6.9 KiB
JavaScript
216 lines
6.9 KiB
JavaScript
#!/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 <transcript_dir>/.handoff-lock-<session_id> 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();
|
|
}
|