#!/usr/bin/env node /** * Stop hook addition — router chain progress tracking. * Stage 3 of router discipline overhaul. * * После каждого хода: обновляет state.chainProgress на основе вызванных в этом ходу скилов. * Helper для observer-stop-hook — он сам решает, вызывать ли (зависит от того, есть ли router-state). */ import { readFileSync, writeFileSync, existsSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { fileURLToPath } from 'url'; import { readStdinAsUtf8 } from './router-stdin-helper.mjs'; export function extractSkillInvocations(events) { return (events || []) .filter((e) => e && e.tool_name === 'Skill') .map((e) => { const raw = e.tool_input?.skill || ''; const stripped = raw.includes(':') ? raw.split(':').pop() : raw; return stripped; }) .filter(Boolean); } export function updateChainProgress(state, skillsInvoked, chains) { const chainId = state.classification?.recommendedChain; if (!chainId || !chains[chainId]) return { ...state }; const sequence = chains[chainId].sequence || []; const currentProgress = [...(state.chainProgress || [])]; for (const skill of skillsInvoked) { const nextExpectedIdx = currentProgress.length; if (nextExpectedIdx >= sequence.length) break; if (sequence[nextExpectedIdx] === skill) { currentProgress.push(skill); } } return { ...state, chainProgress: currentProgress, chainCompleted: currentProgress.length === sequence.length && sequence.length > 0, }; } async function main() { const input = await readStdinAsUtf8(process.stdin); const event = JSON.parse(input || '{}'); const sessionId = event.session_id || 'unknown'; const events = event.turn_events || []; const statePath = join(homedir(), '.claude', 'runtime', `router-state-${sessionId}.json`); if (!existsSync(statePath)) { process.stdout.write('{}'); process.exit(0); return; } try { const state = JSON.parse(readFileSync(statePath, 'utf-8')); const { loadRegistry } = await import('./registry-load.mjs'); const registry = loadRegistry({ useCache: false }); const skills = extractSkillInvocations(events); const updated = updateChainProgress(state, skills, registry.chains || {}); updated.skillInvokedThisTurn = skills.length > 0; writeFileSync(statePath, JSON.stringify(updated, null, 2)); process.stdout.write('{}'); process.exit(0); } catch (err) { process.stderr.write(`[router-stop-gate] ${err.message}\n`); process.stdout.write('{}'); process.exit(0); } } // CLI guard — Windows-cyrillic path quirk: compare resolved path, not raw argv[1] const isMain = (() => { try { return process.argv[1] && fileURLToPath(import.meta.url) === fileURLToPath(`file:///${process.argv[1].replace(/\\/g, '/')}`); } catch { return process.argv[1] && import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}`; } })(); if (isMain) { main(); }