b846c0c57f
Task 4/5 плана. stop-hook берёт updated.step (модельная суть хода) как essence для buildStepLine; нет step — прежний firstSentence-фолбэк. step срезается из updated перед записью (транзитное, в protocol.json не оседает). Доказано smoke-прогоном; свод секретаря зелёный. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
150 lines
8.5 KiB
JavaScript
150 lines
8.5 KiB
JavaScript
#!/usr/bin/env node
|
|
// Stop-переходник секретаря: ВСЕГДА пишет сырьё (Слой 1); если секретарь включён —
|
|
// онлайн-выжимка в протокол дела через НОВЫЙ мотор (SECRETARY_LLM_KEY).
|
|
// Тонкий shell над parseLastExchange / buildRawRecord / reconcileTurn (модель-редактор) /
|
|
// mergeTurnIntoProtocol (шаг пишется всегда) / writeFileAtomic / renderProtocol / upsertIndexEntry.
|
|
import { existsSync, readFileSync, appendFileSync, mkdirSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { homedir } from 'node:os';
|
|
import { parseLastExchange } from './secretary-transcript.mjs';
|
|
import { secretaryModeFileName } from './secretary-flag.mjs';
|
|
import { buildRawRecord, buildStepLine, writeFileAtomic } from './secretary-layer1.mjs';
|
|
import { reconcileTurn, mergeTurnIntoProtocol, formatReconcileLogLine, collapseProtocol } from './secretary-reconcile.mjs';
|
|
import { renderProtocol, EMPTY_PROTOCOL } from './secretary-protocol.mjs';
|
|
import { upsertIndexEntry } from './secretary-index.mjs';
|
|
import { sanitize } from './observer-pii-filter.mjs';
|
|
import { callAnthropicAPI } from './router-classifier.mjs';
|
|
import { buildAuditPrompt, parseAuditResponse, applyAudit, preserveRegistry } from './secretary-audit.mjs';
|
|
|
|
function readStdin() { try { return readFileSync(0, 'utf-8'); } catch { return ''; } }
|
|
function readFlag(session) {
|
|
const f = join(homedir(), '.claude', 'runtime', secretaryModeFileName(session));
|
|
try { return JSON.parse(readFileSync(f, 'utf-8')); } catch { return { mode: 'off' }; }
|
|
}
|
|
function turnCount(rawFile) {
|
|
if (!existsSync(rawFile)) return 0;
|
|
try { return (readFileSync(rawFile, 'utf-8').match(/=== ХОД turn=/g) || []).length; } catch { return 0; }
|
|
}
|
|
|
|
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 = parseLastExchange(transcript);
|
|
const turn = turnCount(rawFile) + 1;
|
|
|
|
// Слой 1: всегда пишем сырьё (PII вырезается перед записью). Append — атомарность не нужна.
|
|
try {
|
|
const rec = sanitize(buildRawRecord({
|
|
turn, time: new Date().toISOString(), session,
|
|
user: ex.user, assistant: ex.assistant, actions: ex.actions,
|
|
}));
|
|
mkdirSync(join(secdir, 'raw'), { recursive: true });
|
|
appendFileSync(rawFile, rec + '\n', 'utf-8');
|
|
} catch { /* fail-quiet */ }
|
|
|
|
// Тетрадь (Слой 2) — только если секретарь включён.
|
|
const flag = readFlag(session);
|
|
if (flag.mode !== 'on') { process.exit(0); }
|
|
|
|
const work = flag.work || 'general';
|
|
const apiKey = process.env.SECRETARY_LLM_KEY;
|
|
try {
|
|
const workDir = join(secdir, work);
|
|
const protoJson = join(workDir, 'protocol.json');
|
|
let proto = EMPTY_PROTOCOL();
|
|
try { if (existsSync(protoJson)) proto = JSON.parse(readFileSync(protoJson, 'utf-8')); } catch { proto = EMPTY_PROTOCOL(); }
|
|
|
|
// Снимок реестра СВ ДО reconcile: reconcile переписывает весь протокол и корёжит скрытые
|
|
// вопросы (перенумеровывает). Реестром владеет ТОЛЬКО аудитор — вернём снимок после merge.
|
|
const svSnapshot = JSON.parse(JSON.stringify({
|
|
hidden: proto.hidden || [], acceptance: proto.acceptance || [],
|
|
tails: proto.tails || [], nextSvId: proto.nextSvId || 1,
|
|
}));
|
|
|
|
// Видимый сигнал срыва reconcile — в лог дела (раньше тихий fail-quiet прятал причину).
|
|
const reLog = join(workDir, '_reconcile.log');
|
|
const logReason = (info) => {
|
|
try {
|
|
mkdirSync(workDir, { recursive: true });
|
|
const t = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
|
appendFileSync(reLog, formatReconcileLogLine({ turn, time: t, ...info }) + '\n', 'utf-8');
|
|
} catch { /* лог вторичен */ }
|
|
};
|
|
|
|
// Модель-редактор правит ВЕСЬ протокол; страж возвращает потерянные строки (reconcile не зависит
|
|
// от точности модели). Нет ключа — reconcile пропускаем (логируем no-key), но шаг хода ниже
|
|
// пишется ВСЁ РАВНО (целостность «Шагов»).
|
|
const callModel = apiKey
|
|
? (msgs) => callAnthropicAPI(msgs, {
|
|
apiKey,
|
|
baseUrl: process.env.SECRETARY_LLM_BASE_URL || undefined,
|
|
model: process.env.SECRETARY_LLM_MODEL || undefined,
|
|
perAttemptTimeoutMs: 300_000, // 5 минут на один ответ модели (секретарь пишет длинный протокол)
|
|
maxRetries: 0, // одна попытка, без ×5 повторов
|
|
})
|
|
: null;
|
|
|
|
let updated = null;
|
|
if (apiKey) {
|
|
updated = await reconcileTurn({ proto, ex, turn, session, callModel, diag: logReason });
|
|
} else {
|
|
logReason({ reason: 'no-key' });
|
|
}
|
|
|
|
// Шаг хода (Слой 1) ведёт хук детерминированно — пишется ВСЕГДА; протокол к записи через merge
|
|
// (при срыве reconcile категории заморожены, но перечень ходов не получает дыр).
|
|
// Модельная суть хода (если reconcile её вернул) — иначе фолбэк firstSentence в buildStepLine.
|
|
const modelStep = (updated && updated.step) || null;
|
|
if (updated && 'step' in updated) delete updated.step; // транзитное — в protocol.json не сохраняем
|
|
const step = { turn, session,
|
|
text: buildStepLine({ turn, user: ex.user, assistant: ex.assistant,
|
|
actions: (ex.actions || []).map((a) => a.tool), essence: modelStep }) };
|
|
const toWrite = mergeTurnIntoProtocol({ proto, updated, step });
|
|
|
|
// Вернуть реестр СВ из снимка (reconcile его НЕ владеет) — иначе он перенумеровывает СВ.
|
|
preserveRegistry(toWrite, svSnapshot);
|
|
|
|
// Второй проход — аудитор скрытых вопросов (9 линз). Не зависит от reconcile.
|
|
if (apiKey) {
|
|
try {
|
|
const auditMsgs = buildAuditPrompt(toWrite, ex);
|
|
const raw = await callModel(auditMsgs);
|
|
applyAudit(toWrite, parseAuditResponse(typeof raw === 'string' ? raw : (raw?.text || '')), turn);
|
|
} catch (e) { logReason({ reason: 'audit-fail', error: e && e.message }); }
|
|
}
|
|
|
|
// Самолечение дублей: финальный чокпоинт перед записью. Ловит ВСЕ исходы (reconcile-успех,
|
|
// срыв/без-ключа = прежний раздутый proto, уже накопленные дубли) — на выходе всегда чисто.
|
|
// Трогает только 6 корзин + Историю; реестр СВ (hidden/nextSvId), шаги, тема — нетронуты.
|
|
const finalProto = collapseProtocol(toWrite);
|
|
|
|
const stamp = new Date().toISOString().slice(0, 16).replace('T', ' ');
|
|
mkdirSync(workDir, { recursive: true });
|
|
// Атомарная запись (temp→rename): параллельная сессия не увидит полузаписанный файл.
|
|
writeFileAtomic(protoJson, JSON.stringify(finalProto, null, 2));
|
|
writeFileAtomic(join(workDir, 'protocol.md'), renderProtocol(finalProto, { work, date: stamp }));
|
|
|
|
const idxFile = join(secdir, 'содержание.md');
|
|
let idxMd = '';
|
|
try { if (existsSync(idxFile)) idxMd = readFileSync(idxFile, 'utf-8'); } catch { idxMd = ''; }
|
|
const upd = upsertIndexEntry(idxMd, {
|
|
slug: work, title: work,
|
|
goal: (toWrite.subject && toWrite.subject.trim()) ? toWrite.subject.trim() : '(дело)',
|
|
status: toWrite.status || 'открыто',
|
|
date: stamp,
|
|
});
|
|
writeFileAtomic(idxFile, upd);
|
|
} catch { /* fail-quiet: сырьё уже записано */ }
|
|
process.exit(0);
|
|
}
|
|
|
|
const isCli = (process.argv[1] || '').replace(/\\/g, '/').endsWith('/secretary-stop-hook.mjs');
|
|
if (isCli) main();
|