Files
portal/tools/night/stenogramma.test.mjs
T

401 lines
26 KiB
JavaScript

// Сторожа поиска стенограммы работника. Беда, ради которой они заведены, описана в шапке
// `stenogramma.mjs`: прошлая смена искала причину смерти работника ВЕСЬ ДЕНЬ, перебирая
// каталоги домашней папки руками, — а лежала она в стенограмме с самого начала.
//
// 🔴 ГЛАВНЫЙ ВОПРОС К ЭТИМ СТОРОЖАМ, ЗАДАННЫЙ ВСЛУХ: «а вчерашнюю беду — ту самую, что
// искали руками весь день, — они бы поймали?» ОТВЕТ: ДА, и именно тот сторож, что читает
// НАСТОЯЩИЕ файлы обоих живых работников с диска (блок «живые улики» ниже). Он падает,
// если поиск промахнётся на `zhivoe-5` — на работнике, у которого заказанный номер сессии
// с именем файла НЕ СОВПАДАЕТ. Синтетические сторожа ниже стерегут края (ноль находок,
// несколько находок, хвостовая косая, регистр буквы диска) — вчерашнюю беду они бы одни
// НЕ поймали, потому что вчерашняя беда была не в краю, а в самом правиле поиска.
import { describe, it, expect } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, utimesSync, existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir, homedir } from 'node:os';
import {
imyaKatalogaStenogramm, imenaKatalogovStenogramm, katalogiStenogramm,
naytiStenogrammu, stenogrammaPoKartochke, strokaVladeltsu, pohozhaLiNaRabotnika,
} from './stenogramma.mjs';
import { REPO_ROOT } from './paths.mjs';
import { isRealSuccess } from './spawn.mjs';
// ═══════════════════════════════════════════════════════════════════════════════
// ПРАВИЛО ИМЕНИ КАТАЛОГА — сверено на настоящих именах из домашней папки
// ═══════════════════════════════════════════════════════════════════════════════
describe('имя каталога стенограмм по пути рабочего угла', () => {
// 🔴 Это НЕ выдуманные пары: справа — имена каталогов, которые вправду лежат в
// `~/.claude/projects` на этой машине (сверено командой `ls`). Слева — пути углов
// из карточек работников `.night/runs/*/workers/*/worker.json`.
const ZHIVYE_PARY = [
['C:/моя/проекты/портал crm/Документация/.claude/worktrees/night-workspaces/rabota-1/w-1',
'C---------------------crm---------------claude-worktrees-night-workspaces-rabota-1-w-1'],
['C:/моя/проекты/портал crm/Документация/.claude/worktrees/night-workspaces/zhivoe-5/w-1',
'C---------------------crm---------------claude-worktrees-night-workspaces-zhivoe-5-w-1'],
['C:/моя/проекты/портал crm/Документация/.claude/worktrees/night1',
'C---------------------crm---------------claude-worktrees-night1'],
['c:/моя/проекты/портал crm/Документация',
'c---------------------crm-------------'],
];
it('🔴 сходится на всех четырёх НАСТОЯЩИХ каталогах домашней папки', () => {
for (const [put, imya] of ZHIVYE_PARY) {
expect(imyaKatalogaStenogramm(put)).toBe(imya);
}
});
it('обратные косые Windows дают то же имя, что прямые', () => {
const [put, imya] = ZHIVYE_PARY[0];
expect(imyaKatalogaStenogramm(put.replace(/\//g, '\\'))).toBe(imya);
});
it('🪤 хвостовая косая НЕ добавляет лишнего дефиса — иначе каталог не нашёлся бы никогда', () => {
const [put, imya] = ZHIVYE_PARY[0];
expect(imyaKatalogaStenogramm(`${put}/`)).toBe(imya);
expect(imyaKatalogaStenogramm(`${put}\\`)).toBe(imya);
expect(imyaKatalogaStenogramm(`${put}\\\\`)).toBe(imya);
});
it('угла не назвали — отвечаем пусто, а не падаем и не выдумываем', () => {
expect(imyaKatalogaStenogramm(null)).toBeNull();
expect(imyaKatalogaStenogramm('')).toBeNull();
expect(imyaKatalogaStenogramm(' ')).toBeNull();
expect(imenaKatalogovStenogramm(null)).toEqual([]);
});
it('🪤 буква диска берётся В ОБОИХ написаниях: замерено, что в домашней папке живут разом «C---» и «c---»', () => {
const oba = imenaKatalogovStenogramm('C:/моя/проекты/портал crm/Документация');
expect(oba).toHaveLength(2);
expect(oba[0].startsWith('C')).toBe(true);
expect(oba[1].startsWith('c')).toBe(true);
// и наоборот — начали со строчной, верхнее написание всё равно предложено
expect(imenaKatalogovStenogramm('c:/x')).toEqual(['c--x', 'C--x']);
});
it('каталоги строятся внутри домашней папки, в .claude/projects', () => {
const puti = katalogiStenogramm('C:/x/y', { home: join('ДОМ') });
expect(puti[0]).toBe(join('ДОМ', '.claude', 'projects', 'C--x-y'));
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// 🔴🔴 ЖИВЫЕ УЛИКИ: настоящие файлы обоих работников с диска
// ═══════════════════════════════════════════════════════════════════════════════
describe('🔴🔴 живые улики — ВЧЕРАШНЯЯ БЕДА: стенограмма, которую искали руками весь день', () => {
const kartochka = r => join(REPO_ROOT, '.night', 'runs', r, 'workers', 'w-1', 'worker.json');
// 🔴🔴 УСЛОВИЕ ПРОПУСКА ВЫБРАНО НАРОЧНО И ЭТО САМОЕ ВАЖНОЕ МЕСТО ВО ВСЁМ ФАЙЛЕ.
// Сперва здесь стояло «пропустить, если нет КАТАЛОГА стенограмм» — и это был зелёный
// сторож, которого нельзя увидеть красным: поменяй завтра программа правило имени
// каталога, каталог перестал бы находиться, сторож молча пропустился бы, а поиск
// навсегда отвечал бы владельцу «стенограммы нет». Ровно та беда, ради которой всё
// и затевалось, вернулась бы под зелёным.
// Теперь пропуск зависит ТОЛЬКО от того, целы ли исходные улики (карточки работников
// и сама домашняя папка помощника) — то есть от того, что от правила имени не зависит.
// Изменись правило имени — сторож ПОКРАСНЕЕТ, а не пропустится.
const ulikiNaMeste = existsSync(kartochka('zhivoe-5')) && existsSync(kartochka('rabota-1'))
&& existsSync(join(homedir(), '.claude', 'projects'));
it.skipIf(!ulikiNaMeste)(
'🔴🔴 работник zhivoe-5 УМЕР МОЛЧА, заказанный номер сессии с именем файла НЕ СОВПАДАЕТ — '
+ 'и стенограмма всё равно найдена',
() => {
const n = stenogrammaPoKartochke(kartochka('zhivoe-5'));
// Заказанный затеей номер — 3568d1be…, а файл назван 3494b548… . Поиск «по номеру»
// здесь промахнулся бы молча, и владелец опять пошёл бы перебирать каталоги руками.
expect(n.naydeno).toBe(1);
expect(n.put).toMatch(/3494b548-8456-4e06-8e7f-0fc5c176b38c\.jsonl$/);
expect(n.put).not.toMatch(/3568d1be/);
expect(n.sposob).not.toMatch(/номеру/);
expect(n.stroka).toContain(n.put);
},
);
it.skipIf(!ulikiNaMeste)(
'работник rabota-1 закончил сам — там номер совпал, и быстрая дорожка по номеру сработала',
() => {
const n = stenogrammaPoKartochke(kartochka('rabota-1'));
expect(n.naydeno).toBe(1);
expect(n.put).toMatch(/c837496c-5b8d-4b89-b4c8-3cd78511e78a\.jsonl$/);
expect(n.sposob).toMatch(/номеру/);
},
);
// ━━━ СВЕРКА «ЧЬЯ ЭТО СТЕНОГРАММА» НА ЖИВЫХ ФАЙЛАХ ━━━
it.skipIf(!ulikiNaMeste)(
'🔴🔴 стенограмма УМЕРШЕГО опознаётся по словам, хотя поручение дошло до него ОБРЕЗАННЫМ до «Ты»',
() => {
const card = JSON.parse(readFileSync(kartochka('zhivoe-5'), 'utf8'));
const n = stenogrammaPoKartochke(kartochka('zhivoe-5'));
// Строгое равенство «текст = поручение» здесь промахнулось бы: в стенограмме лежит
// одно слово «Ты». Приставочная сверка опознаёт и его.
expect(pohozhaLiNaRabotnika(n.put, card.prompt)).toBe(true);
expect(n.sposob).not.toMatch(/НЕ ЕГО/);
},
);
it.skipIf(!ulikiNaMeste)('стенограмма ЖИВОГО тоже опознаётся — там поручение лежит целиком', () => {
const card = JSON.parse(readFileSync(kartochka('rabota-1'), 'utf8'));
const n = stenogrammaPoKartochke(kartochka('rabota-1'));
expect(pohozhaLiNaRabotnika(n.put, card.prompt)).toBe(true);
});
it.skipIf(!ulikiNaMeste)('🔴 ЧУЖОЕ поручение теми же файлами НЕ опознаётся — иначе сверка ничего не стерегла бы', () => {
const n = stenogrammaPoKartochke(kartochka('zhivoe-5'));
expect(pohozhaLiNaRabotnika(n.put, 'Посмотри, пожалуйста, что тут наделал работник')).toBe(false);
});
it('карточку прочесть не вышло — отвечаем честно и НЕ падаем', () => {
const n = stenogrammaPoKartochke(join(REPO_ROOT, '.night', 'runs', 'такого-прогона-нет', 'worker.json'));
expect(n.naydeno).toBe(0);
expect(n.put).toBeNull();
expect(n.sposob).toMatch(/карточк/i);
expect(n.stroka).toMatch(/прочесть не вышло/i);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// КРАЯ: ноль находок, несколько находок, отбор по времени
// ═══════════════════════════════════════════════════════════════════════════════
describe('края поиска — там, где допущение «один работник = один каталог» ломается', () => {
// Своя домашняя папка на каждый сторож: в настоящую не пишем ни знака (стенограммы —
// улики, оплаченные живыми деньгами, и трогать их нельзя).
function svoyDom() {
return mkdtempSync(join(tmpdir(), 'sten-'));
}
function polozhit(home, ugol, imena) {
const dir = join(home, '.claude', 'projects', imyaKatalogaStenogramm(ugol));
mkdirSync(dir, { recursive: true });
for (const [imya, kogda] of imena) {
const p = join(dir, imya);
writeFileSync(p, '{}\n');
if (kogda) utimesSync(p, new Date(kogda), new Date(kogda));
}
return dir;
}
const UGOL = 'C:/дом/угол/w-1';
it('🔴 стенограмм НОЛЬ, каталога нет вовсе — говорим прямо и всё равно называем, куда смотреть', () => {
const home = svoyDom();
const n = naytiStenogrammu({ rabochiyUgol: UGOL, home });
expect(n.naydeno).toBe(0);
expect(n.put).toBeNull();
expect(n.sposob).toMatch(/каталога стенограмм нет/);
expect(n.stroka).toContain(imyaKatalogaStenogramm(UGOL));
// 🔴 Слова, ради которых это написано: владельцу сказано, что ноль — не поломка поиска.
expect(n.stroka).toMatch(/умер раньше/i);
});
it('🔴 каталог есть, а стенограмм в нём нет — это ДРУГОЙ ответ, чем «каталога нет»', () => {
const home = svoyDom();
const dir = polozhit(home, UGOL, []);
const n = naytiStenogrammu({ rabochiyUgol: UGOL, home });
expect(n.naydeno).toBe(0);
expect(n.sposob).toMatch(/каталог есть/);
expect(n.stroka).toContain(dir);
});
it('🔴🔴 стенограмм НЕСКОЛЬКО (в углу работал ещё и человек) — выбирать за владельца НЕЛЬЗЯ', () => {
const home = svoyDom();
polozhit(home, UGOL, [['aaa.jsonl', '2026-08-03T10:00:00Z'], ['bbb.jsonl', '2026-08-03T12:00:00Z']]);
const n = naytiStenogrammu({ rabochiyUgol: UGOL, home });
expect(n.naydeno).toBe(2);
// 🔴 Вот ради чего сторож: «свежая» — это могла бы быть стенограмма ЧЕЛОВЕКА,
// заглянувшего в угол работника. Молча отдать её значило бы соврать владельцу.
expect(n.put).toBeNull();
expect(n.sposob).toMatch(/выбрать нельзя/);
expect(n.kandidaty.map(k => k.imya)).toEqual(['bbb.jsonl', 'aaa.jsonl']); // свежая первой
expect(n.stroka).toContain('aaa.jsonl');
expect(n.stroka).toContain('bbb.jsonl');
});
it('время прогона сужает несколько до одной — и способ называется честно', () => {
const home = svoyDom();
polozhit(home, UGOL, [
['staraya.jsonl', '2026-08-01T10:00:00Z'],
['nasha.jsonl', '2026-08-03T12:00:00Z'],
]);
const n = naytiStenogrammu({
rabochiyUgol: UGOL, home,
ot: Date.parse('2026-08-03T11:50:00Z'),
do_: Date.parse('2026-08-03T12:10:00Z'),
});
expect(n.naydeno).toBe(1);
expect(n.put).toMatch(/nasha\.jsonl$/);
expect(n.sposob).toMatch(/за время прогона/);
});
it('🪤 отбор по времени НЕ оставил ничего — отбор отменяется, а не отдаётся пустота', () => {
const home = svoyDom();
polozhit(home, UGOL, [['odna.jsonl', '2026-08-01T10:00:00Z']]);
const n = naytiStenogrammu({
rabochiyUgol: UGOL, home,
ot: Date.parse('2026-08-03T11:50:00Z'),
do_: Date.parse('2026-08-03T12:10:00Z'),
});
// Файл со странным временем — всё равно улика. Молча ответить «нет ничего» значило бы
// отправить владельца спать, когда причина смерти лежит у него под рукой.
expect(n.naydeno).toBe(1);
expect(n.put).toMatch(/odna\.jsonl$/);
});
it('заказанный номер сессии совпал — берём его, даже когда рядом лежит файл посвежее', () => {
const home = svoyDom();
polozhit(home, UGOL, [
['nash-nomer.jsonl', '2026-08-03T10:00:00Z'],
['chuzhaya.jsonl', '2026-08-03T23:00:00Z'],
]);
const n = naytiStenogrammu({ rabochiyUgol: UGOL, home, sessionId: 'nash-nomer' });
expect(n.naydeno).toBe(1);
expect(n.put).toMatch(/nash-nomer\.jsonl$/);
expect(n.sposob).toMatch(/номеру/);
});
it('🔴 заказанный номер ПРОМАХНУЛСЯ — поиск по каталогу продолжается, а не сдаётся', () => {
const home = svoyDom();
polozhit(home, UGOL, [['sovsem-drugoy.jsonl', '2026-08-03T10:00:00Z']]);
const n = naytiStenogrammu({ rabochiyUgol: UGOL, home, sessionId: 'заказанный-которого-нет' });
expect(n.naydeno).toBe(1);
expect(n.put).toMatch(/sovsem-drugoy\.jsonl$/);
expect(n.sposob).not.toMatch(/номеру/);
});
it('чужие файлы в каталоге (не .jsonl) за стенограммы не считаются', () => {
const home = svoyDom();
polozhit(home, UGOL, [['zametka.txt', null], ['nastroyki.json', null], ['ona.jsonl', null]]);
const n = naytiStenogrammu({ rabochiyUgol: UGOL, home });
expect(n.naydeno).toBe(1);
expect(n.put).toMatch(/ona\.jsonl$/);
});
it('🪤 угол записан со СТРОЧНОЙ буквой диска, а каталог заведён с ПРОПИСНОЙ — всё равно находим', () => {
const home = svoyDom();
polozhit(home, 'C:/дом/угол/w-1', [['ona.jsonl', null]]);
const n = naytiStenogrammu({ rabochiyUgol: 'c:/дом/угол/w-1', home });
expect(n.naydeno).toBe(1);
expect(n.put).toMatch(/ona\.jsonl$/);
});
// ━━━ СВЕРКА «ЧЬЯ СТЕНОГРАММА» НА СВОИХ ФАЙЛАХ ━━━
function polozhitSoSlovami(home, ugol, nabor) {
const dir = join(home, '.claude', 'projects', imyaKatalogaStenogramm(ugol));
mkdirSync(dir, { recursive: true });
for (const [imya, pervoeSoobshchenie, kogda] of nabor) {
const stroki = [
JSON.stringify({ type: 'system', subtype: 'init' }),
JSON.stringify({ type: 'user', message: { content: pervoeSoobshchenie } }),
].join('\n');
const p = join(dir, imya);
writeFileSync(p, `${stroki}\n`);
if (kogda) utimesSync(p, new Date(kogda), new Date(kogda));
}
return dir;
}
const PORUCHENIE = 'Ты пробный работник. Дело у тебя одно и безобидное.';
it('🔴🔴 в углу работал ещё и ЧЕЛОВЕК — из двух стенограмм по словам опознана работникова', () => {
const home = svoyDom();
polozhitSoSlovami(home, UGOL, [
['chelovek.jsonl', 'посмотри, что он тут наделал', '2026-08-03T23:00:00Z'],
['rabotnik.jsonl', 'Ты', '2026-08-03T10:00:00Z'],
]);
const n = naytiStenogrammu({ rabochiyUgol: UGOL, home, poruchenie: PORUCHENIE });
// 🔴 Без сверки по словам ответ был бы «несколько, выбрать нельзя», а «взять свежую»
// подсунуло бы владельцу запись ЧЕЛОВЕКА.
expect(n.naydeno).toBe(1);
expect(n.put).toMatch(/rabotnik\.jsonl$/);
expect(n.sposob).toMatch(/по словам/);
});
it('🔴🔴 стенограмма ровно одна, но слова в ней ЧУЖИЕ — говорим об этом, а не выдаём за его', () => {
const home = svoyDom();
polozhitSoSlovami(home, UGOL, [['chelovek.jsonl', 'посмотри, что он тут наделал', null]]);
const n = naytiStenogrammu({ rabochiyUgol: UGOL, home, poruchenie: PORUCHENIE });
expect(n.naydeno).toBe(1);
expect(n.put).toMatch(/chelovek\.jsonl$/); // улику не прячем
expect(n.sposob).toMatch(/НЕ ЕГО/);
expect(n.stroka).toMatch(/НЕ\s*$|НЕ /);
expect(n.stroka).toMatch(/запись человека/i);
});
it('поручения не дали — сверка отвечает «сказать нечем», а не «нет»', () => {
const home = svoyDom();
polozhitSoSlovami(home, UGOL, [['odna.jsonl', 'что угодно', null]]);
const n = naytiStenogrammu({ rabochiyUgol: UGOL, home });
expect(n.naydeno).toBe(1);
expect(n.sposob).not.toMatch(/НЕ ЕГО/);
expect(n.kandidaty[0].pohozhe).toBeNull();
});
it('🪤 сообщения пользователя в стенограмме нет вовсе — «сказать нечем», и улику НЕ хороним', () => {
const home = svoyDom();
const dir = join(home, '.claude', 'projects', imyaKatalogaStenogramm(UGOL));
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'nemaya.jsonl'), `${JSON.stringify({ type: 'system', subtype: 'init' })}\n`);
const n = naytiStenogrammu({ rabochiyUgol: UGOL, home, poruchenie: PORUCHENIE });
expect(n.naydeno).toBe(1);
expect(n.sposob).not.toMatch(/НЕ ЕГО/); // «нечем сказать» ≠ «не его»
expect(pohozhaLiNaRabotnika(join(dir, 'nemaya.jsonl'), PORUCHENIE)).toBeNull();
});
it('угла не назвали — честный отказ, без падения', () => {
const n = naytiStenogrammu({ rabochiyUgol: null, home: svoyDom() });
expect(n.naydeno).toBe(0);
expect(n.put).toBeNull();
expect(strokaVladeltsu(n)).toMatch(/не назван/i);
expect(strokaVladeltsu(null)).toMatch(/не искали/i);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// СВЯЗКА С ПРИГОВОРОМ О РАБОТНИКЕ
// ═══════════════════════════════════════════════════════════════════════════════
describe('приговор о работнике умеет назвать, куда смотреть', () => {
function domSoStenogrammoy(ugol) {
const home = mkdtempSync(join(tmpdir(), 'sten-prigovor-'));
const dir = join(home, '.claude', 'projects', imyaKatalogaStenogramm(ugol));
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'nasha.jsonl'), '{}\n');
return home;
}
it('🔴 второй довод НЕ подан — приговор ровно тот же, что был: боевое поведение не тронуто', () => {
expect(isRealSuccess(null).reason).toBe('Ответ работника не разобран — считаем, что запуск не удался.');
expect(isRealSuccess({ is_error: true, result: 'ENOENT' }).reason).toBe('Работник вернул ошибку: ENOENT');
expect(isRealSuccess({ is_error: false, result: '' }).reason)
.toBe('Работник сообщил об успехе, но вернул пустой ответ — успехом это не считается.');
expect(isRealSuccess({ is_error: false, result: 'сделал' })).toEqual({ ok: true, reason: null });
});
it('🔴 второй довод подан и приговор плохой — к причине дописан путь к стенограмме', () => {
// 🪤 ЦЕНА ПЕРВОЙ ПОПЫТКИ, СКАЗАННАЯ ВСЛУХ: сперва этот сторож подменял на время зова
// переменные среды `USERPROFILE`/`HOME`, чтобы поиск сходил в свою папку. По `HOME`
// ищет свои настройки git — и у СОСЕДЕЙ по общему прогону посыпалось «not a git
// repository»: `supervisor.test.mjs` в одиночку давал 187 из 187, а в общем прогоне 5
// красных. Домашняя папка теперь идёт ДОВОДОМ и ничего в среде не трогает.
const ugol = 'C:/дом/угол-приговора/w-1';
const home = domSoStenogrammoy(ugol);
const r = isRealSuccess(null, { rabochiyUgol: ugol, home });
expect(r.ok).toBe(false);
expect(r.reason).toContain('Ответ работника не разобран');
expect(r.reason).toContain('nasha.jsonl');
});
it('🔴 добрый исход стенограммой не засоряется — читать её незачем', () => {
const ugol = 'C:/дом/угол-dobryy/w-1';
const r = isRealSuccess({ is_error: false, result: 'сделал' }, { rabochiyUgol: ugol, home: domSoStenogrammoy(ugol) });
expect(r).toEqual({ ok: true, reason: null });
});
it('🔴🔴 поиск улики НЕ ИМЕЕТ ПРАВА уронить приговор о работнике', () => {
// 🪤 ПОЙМАНО ВЫРЕЗАНИЕМ: сперва здесь стоял угол `{ не: 'строка' }` — и он НЕ БРОСАЛ
// ничего вовсе (`String({})` даёт «[object Object]», поиск честно отвечает «ноль»).
// То есть сторож стерёг пустоту: убери из `isRealSuccess` перехват — он бы не покраснел.
// Теперь угол устроен так, что бросок НЕИЗБЕЖЕН: у него ломается сам перевод в строку.
const podlyyUgol = { toString() { throw new Error('поиск улики споткнулся'); } };
// Сперва убеждаемся, что подлость вправду подлая, — иначе сторож снова стерёг бы пустоту.
expect(() => `${podlyyUgol}`).toThrow(/споткнулся/);
const r = isRealSuccess(null, { rabochiyUgol: podlyyUgol });
expect(r.ok).toBe(false);
expect(r.reason).toBe('Ответ работника не разобран — считаем, что запуск не удался.');
});
});