Files
portal/tools/night/stenogramma.mjs
T

424 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ━━━ ГДЕ ЛЕЖИТ СТЕНОГРАММА РАБОТНИКА ━━━
//
// ━━━ БЕДА, РАДИ КОТОРОЙ ЭТОТ ФАЙЛ ЗАВЕДЁН (03.08.2026) ━━━
// Когда работник умирает молча, затея кладёт владельцу в утреннюю сводку строку вида
// «Работник кончился, не отдав итога. Что он сказал напоследок: ничего» — и это ВСЁ, что
// она знает. А полная запись всего, что с работником было, в эту минуту уже лежит на диске:
// система сама ведёт стенограмму каждого живого работника в домашнем каталоге. Прошлая
// смена искала в ней причину смерти ВЕСЬ ДЕНЬ, перебирая каталоги руками, — и нашла только
// вечером. Лежала она там с самого начала.
//
// 🔴🔴 ЗАМЕРЕНО НА ДВУХ НАСТОЯЩИХ ПОГИБШИХ/ЗАКОНЧИВШИХ РАБОТНИКАХ (не выдумка, файлы на диске):
//
// работник `zhivoe-5/w-1` — УМЕР МОЛЧА (`status: "stalled"`):
// · своего следа потока у затеи НЕТ ВОВСЕ — `stream-raw.jsonl` не создан,
// `exit.json` → `result: null`, `supervisor.log` содержит одно ругательство узла
// про оболочку и ни слова о работнике;
// · а в стенограмме — причина смерти ПРЯМЫМ ТЕКСТОМ: работнику дошло от поручения
// одно первое слово («кажется, сообщение оборвалось — пришло только „Ты“»), то есть
// поручение съела склейка через оболочку. Это ровно тот корень, который затея потом
// закрыла сторожем `zapretSkleyki` в spawn.mjs.
//
// работник `rabota-1/w-1` — ЗАКОНЧИЛ САМ (`status: "extinguished"`):
// · след потока у затеи ЕСТЬ (`stream-raw.jsonl`, 59 046 байт), `exit.json` несёт
// полный итог с причиной остановки.
//
// ⇒ ОТВЕТ НА ВОПРОС «А НЕ ПИШЕТ ЛИ ЭТУ ЖЕ УЛИКУ КТО-ТО ЕЩЁ, ПОМИМО СТЕНОГРАММЫ?» —
// ДА, ПИШЕТ, НО РОВНО ТАМ, ГДЕ ОНА НЕ НУЖНА. Затея ведёт свой собственный след потока
// (`.night/runs/<прогон>/workers/<работник>/stream-raw.jsonl` + `exit.json`), и на живом
// замере он полон. Но он появляется только тогда, когда работник вправду заговорил: у
// молча умершего его НЕТ НИ БАЙТА. То есть свой след затеи и стенограмма — не дубликаты,
// а РОВНО ДОПОЛНЯЮЩИЕ ДРУГ ДРУГА улики, и стенограмма — единственная в том самом случае,
// ради которого владелец утром и лезет в улики. Дублирования здесь нет, и заводить свой
// третий след не нужно — нужно уметь НАЙТИ уже существующий.
//
// ━━━ 🔴🔴 ГЛАВНОЕ, ЧТО ЗАМЕР ОПРОВЕРГ В САМОЙ ПОСТАНОВКЕ ЗАДАЧИ ━━━
// Мне было сказано: «номер сессии, который затея записывает у себя, — НЕ ТОТ, каким система
// называет файл стенограммы». Замерено на обоих живых примерах — правило НЕ ОДНО, их два,
// и они противоположны:
//
// `rabota-1/w-1` : затея записала `c837496c-…`, файл назван `c837496c-….jsonl` → СОВПАЛО
// `zhivoe-5/w-1` : затея записала `3568d1be-…`, файл назван `3494b548-….jsonl` → РАЗОШЛОСЬ,
// и заказанного номера внутри файла НЕТ НИ В ОДНОЙ строке (искал — 0 из 15).
//
// То есть поиск «по номеру, который затея у себя записала» работает ИНОГДА. И это худший
// из возможных раскладов: на живом работнике, который дошёл до конца, такой поиск СХОДИТСЯ
// и тем доказывает сам себе, что он исправен, — а на молча умершем молча промахивается.
// Ровно на умершем он и нужен.
//
// 🔴 ОТЧЕГО ТАК — сказано как замер, а не как диагноз: у `zhivoe-5` поручение до работника
// не дошло вовсе (склейка через оболочку), а вместе с поручением, судя по всему, не дошёл
// и ключ `--session-id`, которым затея заказывает номер, — и программа завела свой. Прямым
// замером ключа я это НЕ проверял, поэтому утверждаю только то, что видно: заказанный номер
// в стенограмме отсутствует целиком.
//
// ⇒ ПОЭТОМУ НОМЕР СЕССИИ ЗДЕСЬ — НЕ ГЛАВНЫЙ КЛЮЧ, А ЛИШЬ БЫСТРАЯ ДОРОЖКА. Главный ключ —
// РАБОЧИЙ УГОЛ работника: у каждого работника он свой собственный (`night-workspaces/
// <прогон>/<работник>`), и система заводит под него ОТДЕЛЬНЫЙ каталог стенограмм.
//
// ━━━ 🔴 ДОПУЩЕНИЕ, НА КОТОРОМ ВСЁ ЭТО СТОИТ (названо вслух, а не спрятано) ━━━
// «Один работник — один свой рабочий угол, значит каталог стенограмм этого угла
// принадлежит ему одному».
//
// 🪤 ГДЕ ЭТО ДОПУЩЕНИЕ ОШИБЁТСЯ — тоже названо вслух, и датчик об этом ГОВОРИТ, а не молчит:
// · Человек (или соседняя смена) открыл своего помощника В ТОМ ЖЕ углу работника — чтобы
// посмотреть, что тот наделал. В каталоге появится ВТОРАЯ стенограмма, и «взять свежую»
// дало бы стенограмму ЧЕЛОВЕКА вместо работниковой. Поэтому «свежую» здесь не берут
// молча: при двух и более находках датчик отдаёт СПИСОК и говорит владельцу, что выбрать
// за него не может.
// · Стенограмм НОЛЬ — так бывает, если работник умер раньше, чем программа успела завести
// файл. Это НЕ «ошибка поиска», и врать про неё нельзя: датчик отвечает «ноль» и всё
// равно называет каталог, куда смотреть.
// · Работника перезапустили в том же углу (`--resume`) — стенограмм станет две-три, и
// это тот же случай «несколько».
// · Угол переименовали/удалили после прогона — каталог стенограмм останется под СТАРЫМ
// именем, а по новому пути не найдётся ничего.
//
// ━━━ ПРАВИЛО ИМЕНИ КАТАЛОГА — ПРОВЕРЕНО САМ, А НЕ ПРИНЯТО НА ВЕРУ ━━━
// «Путь угла, где каждый знак, кроме латинских букв и цифр, заменён на дефис». Сверено на
// четырёх настоящих каталогах из домашней папки (два угла работников + этот рабочий угол +
// корень хранилища) — сошлось на всех четырёх, включая кириллицу (каждая буква даёт свой
// дефис), пробел, двоеточие диска, точку в `.claude` и обе косые.
//
// 🪤 БУКВА ДИСКА НЕ ПРИВОДИТСЯ К ОДНОМУ РЕГИСТРУ — замерено: в домашней папке живут разом
// `C---…` и `c---…`, то есть один и тот же каталог, открытый как `C:\` и как `c:\`, даёт
// ДВА РАЗНЫХ каталога стенограмм. Поэтому ищем по обоим написаниям буквы диска.
import { readdirSync, statSync, existsSync, readFileSync, openSync, readSync, closeSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
// Запас времени по краям окна прогона: стенограмма дописывается не мгновенно, и её время
// правки может уехать за конец прогона. Пятнадцать минут — с запасом и без риска захватить
// соседний прогон (между прогонами этой ночью было больше двух часов).
export const ZAPAS_MS = 15 * 60 * 1000;
// ━━━ ИМЯ КАТАЛОГА ПО ПУТИ УГЛА ━━━
// 🪤 Хвостовые косые срезаются ДО замены: `…\w-1\` иначе дал бы лишний дефис в конце и
// каталог не нашёлся бы никогда — а путь с хвостовой косой владелец набирает запросто.
export function imyaKatalogaStenogramm(rabochiyUgol) {
const s = String(rabochiyUgol ?? '').trim().replace(/[\\/]+$/, '');
if (!s) return null;
return s.replace(/[^a-zA-Z0-9]/g, '-');
}
// Оба написания буквы диска — см. ловушку про регистр в шапке. Если путь не начинается
// с буквы диска (например, это путь Линукса), вариант ровно один.
export function imenaKatalogovStenogramm(rabochiyUgol) {
const imya = imyaKatalogaStenogramm(rabochiyUgol);
if (!imya) return [];
const pervaya = imya[0];
if (!/[a-zA-Z]/.test(pervaya)) return [imya];
const drugoy = pervaya === pervaya.toUpperCase() ? pervaya.toLowerCase() : pervaya.toUpperCase();
const vtoroy = drugoy + imya.slice(1);
return vtoroy === imya ? [imya] : [imya, vtoroy];
}
export function katalogiStenogramm(rabochiyUgol, { home = homedir() } = {}) {
return imenaKatalogovStenogramm(rabochiyUgol).map(imya => join(home, '.claude', 'projects', imya));
}
function spisokStenogramm(katalog) {
let imena;
try {
imena = readdirSync(katalog);
} catch {
return null; // каталога нет вовсе — это НЕ то же самое, что «каталог есть и пуст»
}
const out = [];
for (const imya of imena) {
if (!imya.toLowerCase().endsWith('.jsonl')) continue;
const put = join(katalog, imya);
let st;
try {
st = statSync(put);
} catch {
continue;
}
if (!st.isFile()) continue;
out.push({ put, imya, nomer: imya.slice(0, -'.jsonl'.length), bayt: st.size, izmenen: st.mtimeMs });
}
// Свежая первой — но выбор по свежести НИКОГДА не делается молча (см. шапку).
out.sort((a, b) => b.izmenen - a.izmenen);
return out;
}
// ━━━ 🔴🔴 А ЭТА СТЕНОГРАММА ВООБЩЕ ЕГО? ━━━
//
// Беда, которую эта мелочь закрывает, — самая опасная во всём файле, потому что она врёт
// НЕ пустотой, а уверенным ответом: если человек заглянул своим помощником в угол умершего
// работника (посмотреть, что тот наделал), в каталоге окажется ЕГО стенограмма. А если
// работниковой при этом не завелось вовсе, стенограмма будет ровно одна — и поиск бодро
// доложит владельцу «Стенограмма работника: …», подсунув запись человека.
//
// Сверяем не номера, а СЛОВА: первое сообщение пользователя в стенограмме против поручения
// из карточки работника. Совпадением считается ПРИСТАВКА в любую сторону — и вот почему
// именно так, а не «текст равен поручению»:
// `rabota-1` (дошёл) — в стенограмме лежит поручение ЦЕЛИКОМ;
// `zhivoe-5` (умер) — в стенограмме лежит «Ты», то есть ПЕРВОЕ СЛОВО поручения и всё
// (поручение съела склейка через оболочку — это и была причина смерти).
// Строгое равенство опознало бы только живого и промахнулось бы ровно на мёртвом.
//
// 🪤 ЧЕГО ЭТА СВЕРКА НЕ УМЕЕТ, сказано вслух: коротким приставкам верить нельзя — человек
// тоже мог начать со слова «Ты». Поэтому ответ у неё ТРИ, а не два: «да» · «нет» · «сказать
// нечем» (сообщения пользователя в стенограмме не нашлось, файл не прочёлся, поручения
// не дали). «Сказать нечем» НЕ выдаётся за «да» ни при каких условиях.
// 🪤 Читаем НЕ ВЕСЬ файл, а начало: стенограмма долгого работника вырастает в сотни
// мегабайт, а первое сообщение пользователя лежит в первых строках. Обрезанная посередине
// последняя строка не страшна — неразобранные строки и так пропускаются.
export const CHITAEM_BAYT = 512 * 1024;
function pervoePorucheniePolzovatelya(put) {
let text;
try {
const fd = openSync(put, 'r');
try {
const buf = Buffer.alloc(CHITAEM_BAYT);
const skolko = readSync(fd, buf, 0, CHITAEM_BAYT, 0);
text = buf.slice(0, skolko).toString('utf8');
} finally {
closeSync(fd);
}
} catch {
return null;
}
for (const line of text.split('\n')) {
if (!line.trim()) continue;
let e;
try { e = JSON.parse(line); } catch { continue; }
if (e?.type !== 'user') continue;
const c = e.message?.content;
const s = typeof c === 'string'
? c
: (Array.isArray(c) ? c.map(b => (typeof b?.text === 'string' ? b.text : '')).join('') : '');
const chisto = String(s).replace(/\s+/g, ' ').trim();
if (chisto) return chisto;
}
return null;
}
export function pohozhaLiNaRabotnika(put, poruchenie) {
const zhdyom = String(poruchenie ?? '').replace(/\s+/g, ' ').trim();
if (!zhdyom) return null; // поручения не дали — сказать нечем
const bylo = pervoePorucheniePolzovatelya(put);
if (!bylo) return null; // сообщения пользователя нет — сказать нечем, но НЕ «нет»
return zhdyom.startsWith(bylo) || bylo.startsWith(zhdyom);
}
// ━━━ ГЛАВНЫЙ ПОИСК ━━━
//
// Ответ намеренно НЕ строка с путём, а разбор: сколько нашлось, каким способом, где искали
// и что из этого следует. Одна строка «вот путь» врала бы в двух из четырёх случаев (ноль
// находок и несколько находок), а именно в них владельцу и нужна правда.
export function naytiStenogrammu({
rabochiyUgol,
sessionId = null,
home = homedir(),
ot = null, // начало прогона, мс — необязательно
do_ = null, // конец прогона, мс — необязательно
poruchenie = null, // текст поручения из карточки — им сверяют, ЕГО ли это стенограмма
} = {}) {
const katalogi = katalogiStenogramm(rabochiyUgol, { home });
const pusto = {
naydeno: 0,
put: null,
kandidaty: [],
katalog: null,
katalogi_probovali: katalogi,
sposob: 'угол не назван',
stroka: 'Стенограмму искать не по чему: рабочий угол работника не назван.',
};
if (!katalogi.length) return pusto;
// 🪤 ПОЙМАНО СВОИМ ЖЕ СТОРОЖЕМ: сперва здесь стояло «последний найденный побеждает», и на
// ПУСТОМ каталоге ответ уезжал на второе написание буквы диска — владельцу называли путь,
// которого он у себя не набирал. Правило теперь: непустой каталог побеждает любой, а среди
// равных побеждает ПЕРВЫЙ, то есть написанный ровно так, как записан угол работника.
// 🔴 На Windows файловая система сама не различает регистр, и второе написание почти всегда
// ведёт в тот же каталог; список из двух заведён ради Линукса, где это два разных каталога.
let katalog = null;
let spisok = null;
for (const k of katalogi) {
const s = spisokStenogramm(k);
if (s === null) continue; // такого каталога нет — пробуем второе написание буквы диска
if (s.length) { katalog = k; spisok = s; break; } // непустой побеждает сразу
if (spisok === null) { katalog = k; spisok = s; } // пустой запоминаем, но поиск продолжаем
}
if (spisok === null) {
return {
naydeno: 0,
put: null,
kandidaty: [],
katalog: null,
katalogi_probovali: katalogi,
sposob: 'каталога стенограмм нет',
stroka: `Стенограммы работника нет: каталога под неё не заведено вовсе. Искали здесь: ${katalogi.join(' и ')}. `
+ 'Так бывает, когда работник умер раньше, чем программа успела завести файл.',
};
}
// ━━━ БЫСТРАЯ ДОРОЖКА: файл прямо по заказанному номеру ━━━
// Она сходится не всегда (замер в шапке: у одного из двух работников — мимо), поэтому
// она первая, но НЕ единственная: промах по номеру не отменяет поиска по каталогу.
if (sessionId) {
const tochno = spisok.find(f => f.nomer === String(sessionId));
if (tochno) {
return {
naydeno: 1,
put: tochno.put,
kandidaty: [tochno],
katalog,
katalogi_probovali: katalogi,
sposob: 'по заказанному номеру сессии',
stroka: `Стенограмма работника: ${tochno.put}`,
};
}
}
// ━━━ ОТБОР ПО ВРЕМЕНИ ПРОГОНА ━━━
// 🔴 Если отбор не оставил НИ ОДНОЙ — он не применяется вовсе, и об этом говорится вслух.
// Молча отдать ноль там, где файл есть, но со странным временем, — худшее, что можно
// сделать: владелец решит, что улики нет, и не пойдёт смотреть.
let otobrannye = spisok;
let poVremeni = false;
if (Number.isFinite(ot) || Number.isFinite(do_)) {
const nizh = Number.isFinite(ot) ? ot - ZAPAS_MS : -Infinity;
const verh = Number.isFinite(do_) ? do_ + ZAPAS_MS : Infinity;
const v_okne = spisok.filter(f => f.izmenen >= nizh && f.izmenen <= verh);
if (v_okne.length) {
otobrannye = v_okne;
poVremeni = v_okne.length !== spisok.length;
}
}
if (!otobrannye.length) {
return {
naydeno: 0,
put: null,
kandidaty: [],
katalog,
katalogi_probovali: katalogi,
sposob: 'каталог есть, стенограмм в нём нет',
stroka: `Стенограммы работника нет: каталог под неё заведён, но пуст. Смотреть здесь: ${katalog}`,
};
}
// ━━━ СВЕРКА ПО СЛОВАМ: чья это стенограмма ━━━
// Не отсев, а разметка: «нет» никого не выбрасывает молча, оно ГОВОРИТСЯ владельцу.
// Молча выбросить единственную улику по своей же догадке было бы хуже, чем показать
// её с оговоркой.
for (const f of otobrannye) f.pohozhe = poruchenie ? pohozhaLiNaRabotnika(f.put, poruchenie) : null;
const svoi = otobrannye.filter(f => f.pohozhe === true);
if (svoi.length === 1 && otobrannye.length > 1) {
// 🔴 Ровно ради этого сверка и заведена: было несколько, по словам сошлась одна.
return {
naydeno: 1,
put: svoi[0].put,
kandidaty: otobrannye,
katalog,
katalogi_probovali: katalogi,
sposob: 'опознана по словам поручения',
stroka: `Стенограмма работника: ${svoi[0].put} (опознана по первым словам его поручения; `
+ `рядом в том же каталоге лежит ещё ${otobrannye.length - 1} — чужих)`,
};
}
if (otobrannye.length === 1) {
const odna = otobrannye[0];
const sposob = poVremeni ? 'единственная за время прогона' : 'единственная в каталоге работника';
if (odna.pohozhe === false) {
// 🔴🔴 САМОЕ ВРЕДНОЕ, ЧТО ЗДЕСЬ МОЖНО СДЕЛАТЬ, — промолчать. Файл ровно один, и
// соблазн доложить «вот она» велик, но слова в ней не работниковы. Говорим прямо.
return {
naydeno: 1,
put: odna.put,
kandidaty: otobrannye,
katalog,
katalogi_probovali: katalogi,
sposob: `${sposob}, но по словам НЕ ЕГО`,
stroka: `🔴 В каталоге работника лежит одна стенограмма — ${odna.put} — но начинается она НЕ `
+ 'с его поручения. Похоже, это запись человека, заглянувшего в тот же угол, а не работника. '
+ 'Смотреть можно, но искать в ней причину смерти работника — скорее всего впустую.',
};
}
return {
naydeno: 1,
put: odna.put,
kandidaty: otobrannye,
katalog,
katalogi_probovali: katalogi,
sposob: odna.pohozhe === true ? `${sposob}, слова сошлись` : sposob,
stroka: `Стенограмма работника: ${odna.put}`,
};
}
return {
naydeno: otobrannye.length,
put: null, // 🔴 выбрать за владельца нельзя — см. ловушку «в углу работал ещё и человек»
kandidaty: otobrannye,
katalog,
katalogi_probovali: katalogi,
sposob: 'несколько — выбрать нельзя',
stroka: `Стенограмм у этого работника ${otobrannye.length}, и какая его — отсюда не видно `
+ '(в том же углу мог работать человек или работника перезапускали). Смотреть все, свежая первой: '
+ otobrannye.map(f => f.put).join(' · '),
};
}
// ━━━ ВХОД ДЛЯ ЖИВОГО ХОЗЯЙСТВА: по карточке работника ━━━
// Карточка `.night/runs/<прогон>/workers/<работник>/worker.json` уже несёт ОБА нужных
// слова — `workspace` и `session_id`, — и время прогона в придачу. То есть заводить своего
// нового следа под это не нужно ни одного: всё уже записано, просто никто не складывал
// одно с другим.
export function stenogrammaPoKartochke(putKartochki, { home = homedir() } = {}) {
let card;
try {
card = JSON.parse(readFileSync(putKartochki, 'utf8'));
} catch (e) {
return {
naydeno: 0,
put: null,
kandidaty: [],
katalog: null,
katalogi_probovali: [],
sposob: 'карточка работника не прочлась',
stroka: `Карточку работника прочесть не вышло (${putKartochki}): ${e.message}`,
};
}
return naytiStenogrammu({
rabochiyUgol: card.workspace,
sessionId: card.session_id ?? null,
home,
ot: Number.isFinite(card.started_at) ? card.started_at : null,
do_: Number.isFinite(card.finished_at) ? card.finished_at : null,
// 🔴 Поручение — то, чем стенограмма опознаётся как ЕГО, а не человека, заглянувшего
// в тот же угол. В карточке оно уже лежит; своего нового следа под это не заводим.
poruchenie: typeof card.prompt === 'string' ? card.prompt : null,
});
}
// Строка для утренней сводки. Отдельной мелочью — чтобы тот, кто соберёт письмо владельцу,
// не сочинял свои слова заново и не потерял по дороге случаи «ноль» и «несколько».
export function strokaVladeltsu(nakhodka) {
if (!nakhodka) return 'Стенограмму работника не искали.';
return nakhodka.stroka;
}
// ━━━ РУЧНОЙ ВХОД: `node tools/night/stenogramma.mjs <путь>` ━━━
// Путь — либо карточка работника `…/worker.json`, либо прямо рабочий угол.
// Заведён затем, чтобы улика была достижима ПРЯМО СЕЙЧАС, не дожидаясь, пока её приладят
// в утреннее письмо (см. про последнюю милю в примечании у `isRealSuccess` в spawn.mjs).
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1].replace(/\\/g, '/')}`).href) {
const arg = process.argv[2];
if (!arg) {
console.log('Скажите путь: либо карточку работника (…/worker.json), либо его рабочий угол.');
process.exit(2);
}
const nakhodka = arg.toLowerCase().endsWith('.json') && existsSync(arg)
? stenogrammaPoKartochke(arg)
: naytiStenogrammu({ rabochiyUgol: arg });
console.log(nakhodka.stroka);
console.log(`(способ: ${nakhodka.sposob}; каталог: ${nakhodka.katalog ?? 'не найден'})`);
process.exit(nakhodka.naydeno === 1 ? 0 : 1);
}