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:
@@ -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,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('принят');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user