Files
brain/tools/secretary-stop-hook.mjs
T

111 lines
6.6 KiB
JavaScript

#!/usr/bin/env node
// Stop-переходник секретаря (подпроект B): ВСЕГДА пишет сырьё (Слой 1) с ярлычком служебного хода (meta=1).
// LLM НЕ зовёт и протокол НЕ пишет — это делает фон-воркер. Хук лишь ставит закрытые спаны (и хвосты
// мёртвых сессий) в очередь темы и «выстреливает» воркера (detached). Ход владельца возвращается мгновенно.
// Курсор обработки — вотчина воркера (_worker/cursor.json); хук его только читает, чтобы знать что ставить.
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { assembleExchanges, buildRawFromExchanges } from './secretary-transcript.mjs';
import { secretaryModeFileName } from './secretary-flag.mjs';
import { upsertSessionPointer } from './secretary-sessions.mjs';
import { writeFileAtomic, realBoundariesFromRaw } from './secretary-layer1.mjs';
import { sanitize } from './observer-pii-filter.mjs';
import { enqueueSpan, readCursor } from './secretary-queue.mjs';
import { spansToEnqueue } from './secretary-enqueue.mjs';
import { spawnWorker } from './secretary-spawn.mjs';
function readStdin() { try { return readFileSync(0, 'utf-8'); } catch { return ''; } }
function flagPath(session) { return join(homedir(), '.claude', 'runtime', secretaryModeFileName(session)); }
function readFlag(session) {
try { return JSON.parse(readFileSync(flagPath(session), 'utf-8')); } catch { return { mode: 'off' }; }
}
function writeFlag(session, flag) {
try { writeFileSync(flagPath(session), JSON.stringify(flag)); } catch { /* ignore */ }
}
async function main() {
let ev = {};
try { ev = JSON.parse(readStdin() || '{}'); } catch { ev = {}; }
const session = ev.session_id || ev.sessionId || 'unknown';
const tp = ev.transcript_path || ev.transcriptPath;
let transcript = '';
try { if (tp && existsSync(tp)) transcript = readFileSync(tp, 'utf-8'); } catch { transcript = ''; }
const secdir = join(process.cwd(), 'docs', 'secretary');
const rawFile = join(secdir, 'raw', `${session}.log`);
const ex = assembleExchanges(transcript);
const turn = ex.length;
// Слой 1: ВСЕГДА пересобираем сырьё из всего транскрипта (переживает обрывы; PII вырезается).
try {
const rawContent = buildRawFromExchanges(ex, { session, sanitize });
mkdirSync(join(secdir, 'raw'), { recursive: true });
writeFileAtomic(rawFile, rawContent);
} catch { /* fail-quiet */ }
// Тетрадь (Слой 2) — только если секретарь включён или закрывается.
const flag = readFlag(session);
if (flag.mode !== 'on' && flag.mode !== 'closing') { process.exit(0); }
const closing = flag.mode === 'closing';
const work = flag.work || 'general';
try {
const workDir = join(secdir, work);
let rawText = '';
try { rawText = readFileSync(rawFile, 'utf-8'); } catch { rawText = ''; }
// Границы спанов — из СЫРЬЯ структурно (meta=1; фолбэк по тексту). Курсор обработки — у воркера.
const bounds = realBoundariesFromRaw(rawText);
const cursor = readCursor(workDir);
// Догон хвостов мёртвых сессий этого дела — тоже через очередь (хук LLM не зовёт).
// Сессия мертва → её последний (открытый) спан тоже финальный, поэтому closing:true (берём все за курсором).
if (Array.isArray(flag.catchUp) && flag.catchUp.length) {
const projDir = tp ? dirname(tp) : null;
for (const prev of flag.catchUp) {
if (!projDir || !prev || !prev.session) continue;
let prevTranscript = '';
try {
const prevTp = join(projDir, `${prev.session}.jsonl`);
if (existsSync(prevTp)) prevTranscript = readFileSync(prevTp, 'utf-8');
} catch { prevTranscript = ''; }
if (!prevTranscript) continue;
const prevRaw = buildRawFromExchanges(assembleExchanges(prevTranscript), { session: prev.session, sanitize });
try { mkdirSync(join(secdir, 'raw'), { recursive: true }); writeFileAtomic(join(secdir, 'raw', `${prev.session}.log`), prevRaw); } catch { /* ignore */ }
const prevBounds = realBoundariesFromRaw(prevRaw);
const prevLast = (prevRaw.match(/=== ХОД turn=/g) || []).length;
const prevCursor = Number.isFinite(prev.cursor) ? prev.cursor : -1;
for (const job of spansToEnqueue({ rawBounds: prevBounds, lastTurn: prevLast, cursor: prevCursor, session: prev.session, closing: true }))
enqueueSpan(workDir, job);
}
writeFlag(session, { ...readFlag(session), catchUp: [] });
}
// Новые закрытые спаны живой сессии (+ последний открытый при закрытии) → в очередь темы.
for (const job of spansToEnqueue({ rawBounds: bounds, lastTurn: turn, cursor, session, closing }))
enqueueSpan(workDir, job);
// Выстрел воркера (детач). Уже крутится — упрётся в замок темы и выйдет.
try { spawnWorker(workDir); } catch { /* выстрел вторичен — следующий ход повторит */ }
if (closing) {
writeFlag(session, { mode: 'off' });
} else {
// Указатель сессии дела (для догона будущих сессий после краха). Курсор — вотчина воркера,
// читаем его текущее значение (может отставать — для догона это безопасно: дедуп + skip по курсору).
try {
const sf = join(workDir, '_sessions.json');
let arr = [];
try { if (existsSync(sf)) arr = JSON.parse(readFileSync(sf, 'utf-8')); } catch { arr = []; }
mkdirSync(workDir, { recursive: true });
writeFileAtomic(sf, JSON.stringify(upsertSessionPointer(arr, { session, cursor: readCursor(workDir) })));
} catch { /* указатель вторичен */ }
}
} catch { /* fail-quiet: сырьё уже записано */ }
process.exit(0);
}
const isCli = (process.argv[1] || '').replace(/\\/g, '/').endsWith('/secretary-stop-hook.mjs');
if (isCli) main();