From d44254a0e14542f233e6ee473efd62ed1a21622f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 22 Jun 2026 10:30:02 +0300 Subject: [PATCH] =?UTF-8?q?feat(secretary):=20=D0=BA=D0=B0=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B2=D0=BE=20=D0=B2=D1=8B=D0=B6=D0=B8=D0=BC=D0=BA?= =?UTF-8?q?=D0=B8=20=E2=80=94=20=D1=82=D0=B5=D0=BC=D0=B0+=D0=B2=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D1=8F,=20=D1=84=D0=BB=D0=B0=D0=B6=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B8,=20=D0=B4?= =?UTF-8?q?=D0=B5=D0=B4=D1=83=D0=BF,=20=D0=BF=D1=80=D0=BE=D0=BC=D0=BF?= =?UTF-8?q?=D1=82=20=D0=B1=D0=B5=D0=B7=20=D1=88=D1=83=D0=BC=D0=B0,=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=B1=D0=B8=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BC=D0=B0,=20=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA?= =?UTF-8?q?=D0=BE=D0=BB->=D0=A1=D0=BB=D0=BE=D0=B9=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - оглавление: реальная тема (поле «тема» в моторе) + дата со временем вместо заглушки (дело) - флажок по сессии secretary-mode-.json — параллельные сессии не смешиваются - дедуп при записи (applyExtraction) — не плодим одинаковые пункты - промпт-дисциплина: игнор служебного шума, «воля» только у [ЮЗЕР], решения не вопросы - стабильная тема (первая непустая, не уезжает на тему хода) - провенанс несёт сессию (@) -> навигация в raw/.log; steps/ убраны как дубли - мёртвый код снят: secretary-slice + computePeriod + buildStepLinks 37 тестов green. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/secretary-extract.mjs | 17 +++++++++++- tools/secretary-extract.test.mjs | 30 ++++++++++++++++++++ tools/secretary-flag.mjs | 6 ++++ tools/secretary-flag.test.mjs | 11 +++++++- tools/secretary-hookutil.mjs | 13 --------- tools/secretary-hookutil.test.mjs | 13 +-------- tools/secretary-prompt-hook.mjs | 25 ++++------------- tools/secretary-protocol.mjs | 37 ++++++++++++++++++------- tools/secretary-protocol.test.mjs | 46 +++++++++++++++++++++++++++++++ tools/secretary-slice.mjs | 14 ---------- tools/secretary-slice.test.mjs | 15 ---------- tools/secretary-stop-hook.mjs | 21 ++++++++------ 12 files changed, 154 insertions(+), 94 deletions(-) delete mode 100644 tools/secretary-slice.mjs delete mode 100644 tools/secretary-slice.test.mjs diff --git a/tools/secretary-extract.mjs b/tools/secretary-extract.mjs index 75ce6ff..653cc4e 100644 --- a/tools/secretary-extract.mjs +++ b/tools/secretary-extract.mjs @@ -5,9 +5,22 @@ export function buildExtractionPrompt({ lastExchange = {}, worksIndex = [] } = { const system = [ 'Ты — секретарь протокола работ. Извлеки СУТЬ последнего обмена по 9 пунктам.', 'Верни ТОЛЬКО JSON без markdown, поля:', - '{ "work":"", "decisions":[{"text","why","turns":[]}],', + '{ "work":"", "тема":"<одна короткая строка: о чём это дело в целом>",', + ' "decisions":[{"text","why","turns":[]}],', ' "supersede":[{"oldText","newText","turns":[]}], "will":[{"text","turns":[]}],', ' "open":[{"text","turns":[]}], "doneNext":[{"text","done":false,"turns":[]}] }', + '', + 'ПРАВИЛА (соблюдай строго):', + '1. ИГНОРИРУЙ служебный шум среды — НЕ записывай ничего про: строку coverage, экономию,', + ' подтверждения "да, штатный"/штатный режим, хуки/стену/наставника/судью, опечатки команд.', + ' Это механика инструмента, а НЕ суть дела.', + '2. "will" (воля/запреты) — ТОЛЬКО пожелания и запреты ВЛАДЕЛЬЦА из реплик [ЮЗЕР].', + ' Действия, планы и предложения ассистента [АССИСТЕНТ] сюда НЕ клади.', + '3. "decisions" — только ПРИНЯТЫЕ решения. Вопрос или ожидание выбора — это "open", не "decisions".', + '4. "why" — реальное обоснование решения, НЕ фраза про сам процесс записи.', + '5. "тема" — стабильная суть ВСЕГО дела (о чём оно), не пересказ последнего хода; одна строка.', + 'ПЛОХО: will:["Напечатать план"] — это действие ассистента.', + 'ПЛОХО: decisions:["нужна строка coverage"] — служебный шум, не писать вовсе.', 'Если сути нет — все массивы пустые.', ].join('\n'); const works = worksIndex.length @@ -36,6 +49,8 @@ export function parseExtractionResponse(llmText) { const arr = (x) => (Array.isArray(x) ? x : []); return { work: typeof parsed.work === 'string' ? parsed.work : null, + subject: typeof parsed['тема'] === 'string' ? parsed['тема'].trim() + : (typeof parsed.subject === 'string' ? parsed.subject.trim() : ''), decisions: arr(parsed.decisions), supersede: arr(parsed.supersede), will: arr(parsed.will), diff --git a/tools/secretary-extract.test.mjs b/tools/secretary-extract.test.mjs index 506392a..af1a54d 100644 --- a/tools/secretary-extract.test.mjs +++ b/tools/secretary-extract.test.mjs @@ -30,3 +30,33 @@ describe('parseExtractionResponse', () => { expect(parseExtractionResponse('')).toBeNull(); }); }); + +describe('тема (subject) для оглавления', () => { + it('buildExtractionPrompt просит поле тема', () => { + const { system } = buildExtractionPrompt({ lastExchange: {}, worksIndex: [] }); + expect(system).toContain('тема'); + }); + it('parseExtractionResponse возвращает тему из поля «тема»', () => { + const out = parseExtractionResponse('{ "work":"sec", "тема":"фоновый секретарь протокола работ", "decisions":[] }'); + expect(out.subject).toBe('фоновый секретарь протокола работ'); + }); + it('без поля «тема» — пустая строка, не падает', () => { + const out = parseExtractionResponse('{ "work":"sec", "decisions":[] }'); + expect(out.subject).toBe(''); + }); +}); + +describe('дисциплина промпта (без шума, сортировка по говорящему)', () => { + it('велит игнорировать служебный шум среды', () => { + const { system } = buildExtractionPrompt({ lastExchange: {}, worksIndex: [] }); + expect(system.toLowerCase()).toContain('служебн'); + }); + it('велит «волю» брать только у владельца [ЮЗЕР]', () => { + const { system } = buildExtractionPrompt({ lastExchange: {}, worksIndex: [] }); + expect(system).toContain('[ЮЗЕР]'); + }); + it('велит решения отличать от вопросов (open)', () => { + const { system } = buildExtractionPrompt({ lastExchange: {}, worksIndex: [] }); + expect(system.toLowerCase()).toContain('принят'); + }); +}); diff --git a/tools/secretary-flag.mjs b/tools/secretary-flag.mjs index 5ee4551..fc060fd 100644 --- a/tools/secretary-flag.mjs +++ b/tools/secretary-flag.mjs @@ -13,3 +13,9 @@ export function detectSecretaryCommand(promptText) { if (/включи\s+секретар/.test(t)) return 'on'; return null; } + +// Имя файла-флажка ПО СЕССИИ: своя записка у каждого окна, параллельные сессии не топчут +// друг друга (общий флажок раньше перетирался последним «включи»). +export function secretaryModeFileName(session) { + return `secretary-mode-${session || 'unknown'}.json`; +} diff --git a/tools/secretary-flag.test.mjs b/tools/secretary-flag.test.mjs index 91b283f..957ff1e 100644 --- a/tools/secretary-flag.test.mjs +++ b/tools/secretary-flag.test.mjs @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { detectSecretaryCommand } from './secretary-flag.mjs'; +import { detectSecretaryCommand, secretaryModeFileName } from './secretary-flag.mjs'; describe('detectSecretaryCommand', () => { it('распознаёт включение', () => { @@ -15,3 +15,12 @@ describe('detectSecretaryCommand', () => { expect(detectSecretaryCommand('фраза «включи секретаря» это команда')).toBeNull(); }); }); + +describe('secretaryModeFileName — флажок по сессии', () => { + it('имя файла флажка содержит id сессии', () => { + expect(secretaryModeFileName('abc-123')).toBe('secretary-mode-abc-123.json'); + }); + it('без сессии — unknown', () => { + expect(secretaryModeFileName()).toBe('secretary-mode-unknown.json'); + }); +}); diff --git a/tools/secretary-hookutil.mjs b/tools/secretary-hookutil.mjs index a276ab0..501b28b 100644 --- a/tools/secretary-hookutil.mjs +++ b/tools/secretary-hookutil.mjs @@ -7,19 +7,6 @@ export function verifyEncoding(content) { return { ok: true, reason: 'utf8' }; } -/** Провенанс-метка из номеров ходов: [7,12] → "[→7, →12]". */ -export function buildStepLinks(turns) { - const arr = Array.isArray(turns) ? turns.filter((t) => t != null) : []; - if (!arr.length) return ''; - return `[${arr.map((t) => `→${t}`).join(', ')}]`; -} - -/** Период нарезки из состояния флажка и текущего хода. */ -export function computePeriod(flagState = {}, currentTurn = 0) { - const from = Number.isInteger(flagState.startedAtTurn) ? flagState.startedAtTurn : 0; - return { from, to: currentTurn }; -} - /** Оглавление дел как подсказка для старта сессии. */ export function renderIndexContext(indexMd) { const body = typeof indexMd === 'string' && indexMd.trim() ? indexMd.trim() : '(дел пока нет)'; diff --git a/tools/secretary-hookutil.test.mjs b/tools/secretary-hookutil.test.mjs index 7fdd484..17feae8 100644 --- a/tools/secretary-hookutil.test.mjs +++ b/tools/secretary-hookutil.test.mjs @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { verifyEncoding, buildStepLinks, computePeriod, renderIndexContext } from './secretary-hookutil.mjs'; +import { verifyEncoding, renderIndexContext } from './secretary-hookutil.mjs'; describe('verifyEncoding', () => { it('пустое — не ок', () => { expect(verifyEncoding('').ok).toBe(false); }); @@ -7,17 +7,6 @@ describe('verifyEncoding', () => { it('нормальный UTF-8 — ок', () => { expect(verifyEncoding('текст').ok).toBe(true); }); }); -describe('buildStepLinks', () => { - it('номера → метка', () => { expect(buildStepLinks([7, 12])).toBe('[→7, →12]'); }); - it('пусто → пустая строка', () => { expect(buildStepLinks([])).toBe(''); }); -}); - -describe('computePeriod', () => { - it('из флажка и текущего хода', () => { - expect(computePeriod({ startedAtTurn: 3 }, 9)).toEqual({ from: 3, to: 9 }); - }); -}); - describe('renderIndexContext', () => { it('оборачивает оглавление', () => { const out = renderIndexContext('- [X](x/protocol.md) — цель · открыто · 2026-06-22'); diff --git a/tools/secretary-prompt-hook.mjs b/tools/secretary-prompt-hook.mjs index 8dc7787..1e9534e 100644 --- a/tools/secretary-prompt-hook.mjs +++ b/tools/secretary-prompt-hook.mjs @@ -1,14 +1,11 @@ #!/usr/bin/env node // UserPromptSubmit-переходник секретаря: ловит «включи/выключи секретаря». -// Тонкий shell над чистыми detectSecretaryCommand / sliceTurns / computePeriod. +// Тонкий shell над чистым detectSecretaryCommand. Нарезка steps/ убрана: навигация идёт +// прямо в raw/.log по провенансу с сессией (метка @ рядом с [→N]). import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; -import { detectSecretaryCommand } from './secretary-flag.mjs'; -import { sliceTurns } from './secretary-slice.mjs'; -import { computePeriod } from './secretary-hookutil.mjs'; - -const FLAG = join(homedir(), '.claude', 'runtime', 'secretary-mode.json'); +import { detectSecretaryCommand, secretaryModeFileName } from './secretary-flag.mjs'; function readStdin() { try { return readFileSync(0, 'utf-8'); } catch { return ''; } } function turnCount(rawFile) { @@ -21,6 +18,7 @@ function main() { try { ev = JSON.parse(readStdin() || '{}'); } catch { ev = {}; } const prompt = ev.prompt || ev.user_prompt || ''; const session = ev.session_id || ev.sessionId || 'unknown'; + const FLAG = join(homedir(), '.claude', 'runtime', secretaryModeFileName(session)); const cmd = detectSecretaryCommand(prompt); if (!cmd) { process.exit(0); } @@ -33,20 +31,7 @@ function main() { const work = (m && m[1]) || 'general'; try { writeFileSync(FLAG, JSON.stringify({ mode: 'on', startedAtTurn: turnCount(rawFile), work, session })); } catch { /* ignore */ } } else if (cmd === 'off') { - let flag = { mode: 'off' }; - try { flag = JSON.parse(readFileSync(FLAG, 'utf-8')); } catch { /* ignore */ } - if (flag.mode === 'on') { - const work = flag.work || 'general'; - const to = turnCount(rawFile); - const { from } = computePeriod({ startedAtTurn: flag.startedAtTurn }, to); - try { - const raw = existsSync(rawFile) ? readFileSync(rawFile, 'utf-8') : ''; - const turns = sliceTurns(raw, from + 1, to); - const stepsDir = join(secdir, work, 'steps'); - mkdirSync(stepsDir, { recursive: true }); - for (const t of turns) writeFileSync(join(stepsDir, `turn-${t.turn}.md`), t.content + '\n', 'utf-8'); - } catch { /* fail-quiet */ } - } + // Просто гасим флажок. Нарезки steps/ нет — провенанс протокола ведёт прямо в Слой 1 (raw). try { writeFileSync(FLAG, JSON.stringify({ mode: 'off' })); } catch { /* ignore */ } } process.exit(0); diff --git a/tools/secretary-protocol.mjs b/tools/secretary-protocol.mjs index 3e68c56..01e5e29 100644 --- a/tools/secretary-protocol.mjs +++ b/tools/secretary-protocol.mjs @@ -1,29 +1,46 @@ // Структура и сверка короткого протокола (§D5/§D7). Отменённое зачёркивается, не удаляется. export function EMPTY_PROTOCOL() { - return { decisions: [], will: [], open: [], doneNext: [], history: [] }; + return { subject: '', decisions: [], will: [], open: [], doneNext: [], history: [] }; } function prov(turns) { return Array.isArray(turns) && turns.length ? ` [${turns.map((t) => `→${t}`).join(', ')}]` : ''; } +// Навигация в Слой 1: метка сессии рядом с [→N] → искать raw/.log, "=== ХОД turn=N ===". +function src(entry) { + return entry && entry.session ? ` @${String(entry.session).slice(0, 8)}` : ''; +} + export function applyExtraction(protocol, extraction = {}) { const p = { + subject: protocol.subject || '', decisions: [...protocol.decisions], will: [...protocol.will], open: [...protocol.open], doneNext: [...protocol.doneNext], history: [...protocol.history], }; + // Тема дела (о чём) стабильна: ставим ОДИН раз (первая непустая), не перезатираем узкой + // темой последнего хода — иначе «тема всего дела» уезжает на тему свежего обмена (§D2). + if (!p.subject && typeof extraction.subject === 'string' && extraction.subject.trim()) { + p.subject = extraction.subject.trim(); + } + // Дедуп (§D5 «сверка, не дозапись»): нормализуем текст, не плодим одинаковые пункты. + const norm = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' '); + const hasText = (arr, text) => arr.some((e) => norm(e.text) === norm(text)); for (const d of extraction.decisions || []) { - p.decisions.push({ text: d.text, why: d.why || null, turns: d.turns || [], struck: false }); + if (p.decisions.some((x) => norm(x.text) === norm(d.text) && !x.struck)) continue; + p.decisions.push({ text: d.text, why: d.why || null, turns: d.turns || [], session: d.session || null, struck: false }); } for (const s of extraction.supersede || []) { const old = p.decisions.find((d) => d.text === s.oldText && !d.struck); if (old) old.struck = true; - p.decisions.push({ text: s.newText, why: s.why || null, turns: s.turns || [], struck: false }); + if (!hasText(p.decisions.filter((d) => !d.struck), s.newText)) { + p.decisions.push({ text: s.newText, why: s.why || null, turns: s.turns || [], session: s.session || null, struck: false }); + } p.history.push({ oldText: s.oldText, newText: s.newText, turns: s.turns || [] }); } - for (const w of extraction.will || []) p.will.push({ text: w.text, turns: w.turns || [] }); - for (const o of extraction.open || []) p.open.push({ text: o.text, turns: o.turns || [] }); - for (const s of extraction.doneNext || []) p.doneNext.push({ text: s.text, done: !!s.done, turns: s.turns || [] }); + for (const w of extraction.will || []) { if (!hasText(p.will, w.text)) p.will.push({ text: w.text, turns: w.turns || [], session: w.session || null }); } + for (const o of extraction.open || []) { if (!hasText(p.open, o.text)) p.open.push({ text: o.text, turns: o.turns || [], session: o.session || null }); } + for (const s of extraction.doneNext || []) { if (!hasText(p.doneNext, s.text)) p.doneNext.push({ text: s.text, done: !!s.done, turns: s.turns || [], session: s.session || null }); } return p; } @@ -33,14 +50,14 @@ export function renderProtocol(protocol) { for (const d of protocol.decisions) { const body = d.struck ? `~~${d.text}~~` : d.text; const why = d.why ? ` — ${d.why}` : ''; - L.push(`- ${body}${why}${prov(d.turns)}`); + L.push(`- ${body}${why}${prov(d.turns)}${src(d)}`); } L.push('', '## Твоя воля / запреты'); - for (const w of protocol.will) L.push(`- ${w.text}${prov(w.turns)}`); + for (const w of protocol.will) L.push(`- ${w.text}${prov(w.turns)}${src(w)}`); L.push('', '## Открытые вопросы'); - for (const o of protocol.open) L.push(`- ${o.text}${prov(o.turns)}`); + for (const o of protocol.open) L.push(`- ${o.text}${prov(o.turns)}${src(o)}`); L.push('', '## Сделано / дальше'); - for (const s of protocol.doneNext) L.push(`- [${s.done ? 'x' : ' '}] ${s.text}${prov(s.turns)}`); + for (const s of protocol.doneNext) L.push(`- [${s.done ? 'x' : ' '}] ${s.text}${prov(s.turns)}${src(s)}`); L.push('', '## История (заменено, не стёрто)'); for (const h of protocol.history) L.push(`- ~~${h.oldText}~~ → ${h.newText}${prov(h.turns)}`); return L.join('\n'); diff --git a/tools/secretary-protocol.test.mjs b/tools/secretary-protocol.test.mjs index 098e34e..bfe6b7c 100644 --- a/tools/secretary-protocol.test.mjs +++ b/tools/secretary-protocol.test.mjs @@ -18,3 +18,49 @@ describe('secretary-protocol', () => { expect(md).toContain('B'); }); }); + +describe('secretary-protocol — тема дела', () => { + it('сохраняет тему из выжимки', () => { + const p = applyExtraction(EMPTY_PROTOCOL(), { subject: 'о чём дело', decisions: [] }); + expect(p.subject).toBe('о чём дело'); + }); + it('пустая тема не затирает прежнюю', () => { + let p = applyExtraction(EMPTY_PROTOCOL(), { subject: 'первая', decisions: [] }); + p = applyExtraction(p, { subject: '', decisions: [] }); + expect(p.subject).toBe('первая'); + }); + it('тема стабильна: вторая (непустая) не перезатирает первую', () => { + let p = applyExtraction(EMPTY_PROTOCOL(), { subject: 'создание секретаря', decisions: [] }); + p = applyExtraction(p, { subject: 'узкая тема последнего хода', decisions: [] }); + expect(p.subject).toBe('создание секретаря'); + }); +}); + +describe('secretary-protocol — навигация в Слой 1 (провенанс с сессией)', () => { + it('applyExtraction сохраняет session в записи решения', () => { + const p = applyExtraction(EMPTY_PROTOCOL(), { decisions: [{ text: 'D', turns: [7], session: 'abc12345-zzz' }] }); + expect(p.decisions[0].session).toBe('abc12345-zzz'); + }); + it('renderProtocol показывает сессию рядом с [→N] для перехода в raw', () => { + const p = applyExtraction(EMPTY_PROTOCOL(), { decisions: [{ text: 'D', turns: [7], session: '69992620-aaaa' }] }); + const md = renderProtocol(p); + expect(md).toContain('[→7]'); + expect(md).toContain('69992620'); + }); +}); + +describe('secretary-protocol — дедуп (без хлама)', () => { + it('не дублирует решение с тем же текстом (регистр/пробелы)', () => { + let p = applyExtraction(EMPTY_PROTOCOL(), { decisions: [{ text: 'берём Postgres', turns: [1] }] }); + p = applyExtraction(p, { decisions: [{ text: ' берём postgres ', turns: [2] }] }); + expect(p.decisions.filter((d) => !d.struck).length).toBe(1); + }); + it('не дублирует пункты воли / открытых / сделано', () => { + const ext = { will: [{ text: 'не коммить без спроса' }], open: [{ text: 'какой бэкенд?' }], doneNext: [{ text: 'написать тест', done: false }] }; + let p = applyExtraction(EMPTY_PROTOCOL(), ext); + p = applyExtraction(p, ext); + expect(p.will.length).toBe(1); + expect(p.open.length).toBe(1); + expect(p.doneNext.length).toBe(1); + }); +}); diff --git a/tools/secretary-slice.mjs b/tools/secretary-slice.mjs deleted file mode 100644 index 00f6131..0000000 --- a/tools/secretary-slice.mjs +++ /dev/null @@ -1,14 +0,0 @@ -// Нарезка сырого журнала на ходы по диапазону [from,to] (§D6). Идемпотентность по turn — -// забота писателя файлов (вызывающего хука). -export function sliceTurns(rawLog, from, to) { - const out = []; - const re = /=== ХОД turn=(\d+)[^\n]*===([\s\S]*?)=== КОНЕЦ ХОДА ===/g; - let m; - while ((m = re.exec(String(rawLog || ''))) !== null) { - const turn = Number(m[1]); - if (turn >= from && turn <= to) { - out.push({ turn, content: m[0].trim() }); - } - } - return out; -} diff --git a/tools/secretary-slice.test.mjs b/tools/secretary-slice.test.mjs deleted file mode 100644 index cc1aecb..0000000 --- a/tools/secretary-slice.test.mjs +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { sliceTurns } from './secretary-slice.mjs'; - -describe('sliceTurns', () => { - it('режет журнал на ходы по диапазону', () => { - const raw = [ - '=== ХОД turn=5 · t · session=a ===\nx\n=== КОНЕЦ ХОДА ===', - '=== ХОД turn=6 · t · session=a ===\ny\n=== КОНЕЦ ХОДА ===', - '=== ХОД turn=7 · t · session=a ===\nz\n=== КОНЕЦ ХОДА ===', - ].join('\n'); - const out = sliceTurns(raw, 6, 7); - expect(out.map((o) => o.turn)).toEqual([6, 7]); - expect(out[0].content).toContain('y'); - }); -}); diff --git a/tools/secretary-stop-hook.mjs b/tools/secretary-stop-hook.mjs index ac0f8c1..5a4a47d 100644 --- a/tools/secretary-stop-hook.mjs +++ b/tools/secretary-stop-hook.mjs @@ -7,6 +7,7 @@ import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } fr import { join } from 'node:path'; import { homedir } from 'node:os'; import { parseLastExchange } from './secretary-transcript.mjs'; +import { secretaryModeFileName } from './secretary-flag.mjs'; import { buildRawRecord } from './secretary-layer1.mjs'; import { buildExtractionPrompt, parseExtractionResponse } from './secretary-extract.mjs'; import { applyExtraction, renderProtocol, EMPTY_PROTOCOL } from './secretary-protocol.mjs'; @@ -14,10 +15,11 @@ import { upsertIndexEntry } from './secretary-index.mjs'; import { sanitize } from './observer-pii-filter.mjs'; import { callAnthropicAPI } from './router-classifier.mjs'; -const FLAG = join(homedir(), '.claude', 'runtime', 'secretary-mode.json'); - function readStdin() { try { return readFileSync(0, 'utf-8'); } catch { return ''; } } -function readFlag() { try { return JSON.parse(readFileSync(FLAG, 'utf-8')); } catch { return { mode: 'off' }; } } +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; } @@ -47,7 +49,7 @@ async function main() { } catch { /* fail-quiet */ } // Онлайн-выжимка только если секретарь включён и есть НОВЫЙ ключ. - const flag = readFlag(); + const flag = readFlag(session); const apiKey = process.env.SECRETARY_LLM_KEY; if (flag.mode !== 'on' || !apiKey) { process.exit(0); } @@ -61,9 +63,10 @@ async function main() { }); const extraction = parseExtractionResponse(typeof text === 'string' ? text : ''); if (extraction) { - // Номер хода знает только хук — форсим реальный turn на все записи (Хайку его не знает). + // Номер хода и сессию знает только хук — форсим turn + session на все записи (Хайку их + // не знает; session нужна для навигации провенанс → raw/.log без коллизий ходов). for (const arr of [extraction.decisions, extraction.will, extraction.open, extraction.doneNext, extraction.supersede]) { - for (const e of (arr || [])) { e.turns = [turn]; } + for (const e of (arr || [])) { e.turns = [turn]; e.session = session; } } const workDir = join(secdir, work); const protoJson = join(workDir, 'protocol.json'); @@ -78,8 +81,10 @@ async function main() { let idxMd = ''; try { if (existsSync(idxFile)) idxMd = readFileSync(idxFile, 'utf-8'); } catch { idxMd = ''; } const updated = upsertIndexEntry(idxMd, { - slug: work, title: work, goal: '(дело)', status: 'открыто', - date: new Date().toISOString().slice(0, 10), + slug: work, title: work, + goal: (proto.subject && proto.subject.trim()) ? proto.subject.trim() : '(дело)', + status: 'открыто', + date: new Date().toISOString().slice(0, 16).replace('T', ' '), }); writeFileSync(idxFile, updated, 'utf-8'); }