feat(graceful-handoff): 2.0 — Stop hook auto-execute + pipeline staging fix [skip-docs]
Step 5 of v2.0 plan + critical pipeline fix. Stop hook (hooks/scripts/stop-context-monitor.mjs): - Estimates context usage from transcript size (chars/3.5 / window_size) - At ≥70%, spawns handoff-pipeline.mjs --auto --no-push synchronously - Reads context_window_size from payload (supports 1M windows) - Lock file at <transcript_dir>/.handoff-lock-<session_id> - Gracefully handles missing CLAUDE_PLUGIN_ROOT, missing transcript Pipeline fix (scripts/handoff-pipeline.mjs): - REMOVED `git add -A` (CLAUDE.md anti-pattern: scoops up unrelated WIP) - Now stages ONLY artifact + REMEMBER.md/TODO.md if present - New regression test 'pipeline never stages unrelated dirty files' Tests: 7 stop-hook tests use stub pipeline (no real git operations); 11 pipeline tests including new regression for explicit staging.
This commit is contained in:
parent
1efb1b3176
commit
81aba9a5f5
4 changed files with 357 additions and 5 deletions
178
plugins/graceful-handoff/hooks/scripts/stop-context-monitor.mjs
Normal file
178
plugins/graceful-handoff/hooks/scripts/stop-context-monitor.mjs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
#!/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.
|
||||
//
|
||||
// Token estimation: char_count / 3.5 → approximate tokens. Compares against
|
||||
// context_window_size from payload (200000 fallback). Approximation is
|
||||
// known to drift ±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 = 200_000;
|
||||
const CHARS_PER_TOKEN = 3.5;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Read context_window_size from payload if available (research/03)
|
||||
const windowSize = payload?.context_window?.context_window_size || FALLBACK_WINDOW;
|
||||
const pctRaw = estimateUsedPct(transcriptPath, windowSize);
|
||||
if (pctRaw == null) {
|
||||
process.exit(0);
|
||||
}
|
||||
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}%: 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}%: ${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}%. 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}%: 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}% 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}%: 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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue