feat(secretary): качество выжимки — тема+время, флажок по сессии, дедуп, промпт без шума, стабильная тема, навигация протокол->Слой 1

- оглавление: реальная тема (поле «тема» в моторе) + дата со временем вместо заглушки (дело)
- флажок по сессии secretary-mode-<session>.json — параллельные сессии не смешиваются
- дедуп при записи (applyExtraction) — не плодим одинаковые пункты
- промпт-дисциплина: игнор служебного шума, «воля» только у [ЮЗЕР], решения не вопросы
- стабильная тема (первая непустая, не уезжает на тему хода)
- провенанс несёт сессию (@<session>) -> навигация в raw/<session>.log; steps/ убраны как дубли
- мёртвый код снят: secretary-slice + computePeriod + buildStepLinks

37 тестов green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-22 10:30:02 +03:00
parent 4253cd7114
commit d44254a0e1
12 changed files with 154 additions and 94 deletions
+16 -1
View File
@@ -5,9 +5,22 @@ export function buildExtractionPrompt({ lastExchange = {}, worksIndex = [] } = {
const system = [
'Ты — секретарь протокола работ. Извлеки СУТЬ последнего обмена по 9 пунктам.',
'Верни ТОЛЬКО JSON без markdown, поля:',
'{ "work":"<slug дела или NEW>", "decisions":[{"text","why","turns":[]}],',
'{ "work":"<slug дела или NEW>", "тема":"<одна короткая строка: о чём это дело в целом>",',
' "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),
+30
View File
@@ -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('принят');
});
});
+6
View File
@@ -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`;
}
+10 -1
View File
@@ -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');
});
});
-13
View File
@@ -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() : '(дел пока нет)';
+1 -12
View File
@@ -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');
+5 -20
View File
@@ -1,14 +1,11 @@
#!/usr/bin/env node
// UserPromptSubmit-переходник секретаря: ловит «включи/выключи секретаря».
// Тонкий shell над чистыми detectSecretaryCommand / sliceTurns / computePeriod.
// Тонкий shell над чистым detectSecretaryCommand. Нарезка steps/ убрана: навигация идёт
// прямо в raw/<session>.log по провенансу с сессией (метка @<session> рядом с [→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);
+27 -10
View File
@@ -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/<session>.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');
+46
View File
@@ -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);
});
});
-14
View File
@@ -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;
}
-15
View File
@@ -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');
});
});
+13 -8
View File
@@ -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/<session>.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');
}