Files
portal/tools/night/spawn.mjs
T
Дмитрий 951b15fb85 fix(ночь): «свободных пунктов нет» больше не значит «задача сделана»; сторож был зелен случайно
Находка М-406, правила 244-246.

Смена 27 передала список «пять доступных настоящих долгов». Правило 243 велит проверять
такой список замером, каждую строку отдельно. Проверил — ДОСТУПНЫМ ОКАЗАЛСЯ ОДИН.
Третья смена подряд с той же долей: 14 ложных из 33 долгов, 3 из 4 разъездов, 4 из 5.

 - `zadachaKonchena` —  настоящий, закрыт этим коммитом;
 - `reshyonnyeSNevypolnimymi` — 🔒 заперт словом владельца: единственный источник
   невыполнимых пунктов — ответ владельца «нет» в `questions.mjs`, а он мёртв целиком;
 - `mailFailedSince` — 🔒 заперт им же: замысел строки 750/1073 называет его дословно
   («затея видит, что сводка не ушла») частью правила ворот «отклик владельца»;
 - `readResult` —  не долг, а МЁРТВЫЙ ОСТАТОК. Дороги «запусти, дождись конца, возьми
   итог» в затее нет: `startWorker` отдаёт живого ребёнка. Четверо читающих потоки делают
   ДРУГОЕ (надзиратель — живьём по строке; `budget` — ищет замеры предела; судья — берёт
   последнее событие `result` и текст приговора; `dengi-bez-zapisi` — первую цену), а расход
   затея нарочно не считает, а спрашивает. Не снесён намеренно: это решение владельца.

ЧТО ПОЧИНЕНО. В блоке 5а надзирателя стояло упрощение `otlozhennyePunkty.size === 0` —
«отложенных нет, значит всё засчитано». Сюда приходят по ДВУМ причинам, и вторая — не
«пунктов не осталось», а «пункт ЕСТЬ, но его файлы держит живой сосед и занять их из-под
руки не вышло». Отложенных при этом нет — и на НЕДОДЕЛАННОЙ задаче поднимался флаг
приёмщику, а владельцу уходило «Пункты задачи кончились и все засчитаны».

Доказано вырезанием: сторож написан первым и покраснел (`items_all_verified` = true на
недоделанной задаче), после починки зелёный, при возврате упрощения — снова красный, и
ровно один, посчитанный ДО прогона. Прибор №6 подтвердил независимо: имя ушло из приговора.

🔴 ГЛАВНОЕ, ЧТО КУПИЛА СМЕНА, И КУПЛЕНО ЧУЖИМ КРАСНЫМ. Полный прогон после починки дал
красное у чужого сторожа `primechanie-ne-rashoditsya-s-pravdoy.test.mjs`. Правило 242 велит
сперва спросить, что он бережёт. Он бережёт согласие примечания с правдой и по замыслу ПРАВ.
Шапку я поправил — он всё равно краснел.

Причина: сторож брал `items.mjs` СЫРЫМ файлом и искал фразу-маркер «НЕТ НИ ОДНОГО», а она
стоит там в ЖИВОМ КОДЕ — в тексте ошибки «в плане задачи нет ни одного пункта». Пока шапка
врала тем же словом, обе половины совпадали и сторож молчал: он был зелёным ПО ДВУМ ПРИЧИНАМ
РАЗОМ, и одна из них случайная.

⇒ Заведён третий рез `tolkoPrimechaniya`, обратный `bezPrimechaniy`, в тот же дом и к той же
развилке «какой брать». Обратность стережётся отдельной проверкой (что вырезал один — то
оставил второй). Все три места сторожа переведены на него, хотя слепота задевала одно:
у двух других фразы-маркера в коде сегодня нет — но это удача, а не защита.

244. У СТОРОЖА, ЧИТАЮЩЕГО ФАЙЛ СЫРЫМ, СПРОСИ: НЕ ОТВЕЧАЕТ ЛИ ЗА ПРИМЕЧАНИЕ ТО, ЧТО
ПРИМЕЧАНИЕМ НЕ ЯВЛЯЕТСЯ. Обратная сторона 234: тот про «рез срезал зерно», этот про
«отсутствие реза подсунуло шелуху».
245. У КРАСНОГО ЧУЖОГО СТОРОЖА ТРИ ИСХОДА, А НЕ ДВА. Третий: прав он по замыслу, но слеп
по способу — тогда чинят САМОГО СТОРОЖА. Спроси, что он измерил, и то ли это, что он
берётся охранять.
246. `grep -i` НА РУССКИХ БУКВАХ РЕГИСТР НЕ ПРИВОДИТ: «НЕТ НИ ОДНОГО» → 0, «нет ни одного»
→ 1, node тем же выражением → нашёл. Мерь тем же телом, каким меряет проверяемый.

ПОПУТНО три примечания приведены к правде (правило 235): у `zadachaKonchena` живой зовущий
теперь ЕСТЬ И ОН ОДИН (второго не будет, пока нет куска 7 — писать «зовут двое» значило бы
чинить враньё половиной правды); `readResult` назван мёртвым остатком; `reshyonnyeSNevypolnimymi`
помечен запертым, а не «некому сделать».

Проверок 2929 в 108 файлах (было 2927), ноль красных, код 0. Прибор №6: вывозится 613,
точных долгов 29 — число то же, состав другой: `zadachaKonchena` ушёл, на его место встал
новый рез (класс правила 231, прибор говорит это сам).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 06:16:51 +03:00

770 lines
68 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.
// Запуск работника. Работник — ребёнок НАДЗИРАТЕЛЯ (supervisor.mjs), а не сессии помощника:
// отвязанным (detached) живёт надзиратель, он и переживает конец сессии.
// Учёта расхода через onExit родителя здесь НЕТ: родитель умирает вместе с сессией,
// и его onExit не сработает никогда. Расход пишет надзиратель.
// 🪤 334 прежних фоновых прогона в этом хозяйстве упали с "spawn claude ENOENT":
// на Windows `claude` — скрипт без .cmd, и по голому имени его не находят.
// Путь к любой внешней программе строит ОДНО место — resolveBin в paths.mjs.
import { spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { resolveBin, needsShell, configPath, PODMENA_HOZYAYSTVA, PRIMETY_PRIBORA } from './paths.mjs';
import { readJson } from './state.mjs';
import { rozhdenieSoSchyotom, spawnAttemptsPath } from './schyot-zamerov.mjs';
// 🔴 Где лежит стенограмма работника — считает ОДНО место на всю затею (`stenogramma.mjs`),
// а не каждый по-своему. Второй такой счёт разошёлся бы с первым в первый же вечер.
import { naytiStenogrammu, strokaVladeltsu } from './stenogramma.mjs';
export function resolveClaudeBin({ lookIn = null, name = 'claude' } = {}) {
return resolveBin(name, lookIn ? { lookIn } : {});
}
// 🔴 Найти файл — половина дела. Вторая половина: Node с 18.20/20.12 (у нас 24) ОТКАЗЫВАЕТСЯ
// запускать `.cmd`/`.bat` без оболочки (закрытая дыра CVE-2024-27980, ответ EINVAL),
// а `claude` на этой машине — ровно `.cmd`.
//
// 🪤 И просто включить оболочку тут нельзя: оболочка разбирает командную строку ЗАНОВО,
// а мы передаём длинное русское поручение с переводами строк и кавычками — она его изуродует
// или разорвёт. Поэтому нутро программы ищется и зовётся НАПРЯМУЮ, без оболочки в цепочке.
//
// ━━━ 🔴🔴 ПЕРЕЕЗД ПРОГРАММЫ — замерено живьём 03.08.2026 ━━━
// Прежде здесь стояло ЖЁСТКОЕ имя нутра — `node_modules/@anthropic-ai/claude-code/cli.js`.
// Такого файла больше НЕТ ВОВСЕ: пакет 2.1.142 кладёт рядом `cli-wrapper.cjs` и каталог
// `bin`, а настоящий запускаемый файл — `bin/claude.exe`. Не найдя старого имени, запуск
// молча сваливался на оболочку, и там умирало ВСЁ, что не переживает склейку: многострочное
// поручение работника рвалось, пути с пробелом разваливались, ключ со значением «пустая
// строка» исчезал бесследно. Живой платный замер обеих дорог: через оболочку — код 1 и
// ни байта вывода, напрямую — код 0, поручение целиком, и вдвое дешевле.
//
// 🔑 ЧЕМ ЛЕЧИМ КЛАСС, А НЕ СЛУЧАЙ: имя нутра НЕ ЗАШИТО НИ ОДНО. Его называет сам пакет
// в своём `package.json` (поле `bin` — это ЗАКАЗ пакета: «зови вот это»). Переедет нутро
// снова — переедет и запись в `package.json`, и здесь чинить будет нечего. Жёсткое
// `bin/claude.exe` было бы тем же капканом, что `cli.js`, только с новым именем.
export function claudeNutro(target, { pkgDir = null } = {}) {
const dir = pkgDir ?? join(dirname(target), 'node_modules', '@anthropic-ai', 'claude-code');
const manifestPath = join(dir, 'package.json');
if (!existsSync(manifestPath)) return null;
let manifest;
try {
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
} catch {
return null; // бумага пакета испорчена — это «не нашли», а не «нашли пустоту»
}
// 🪤 Поле `bin` бывает ТРЁХ видов, и третий — не «да» и не «нет»: строкой (одно имя),
// мелочью с именами (`{"claude": "bin/claude.exe"}`) и вовсе отсутствующим. Свести
// первые два в один вид обязаны мы, а не звать наугад.
const bin = manifest?.bin;
let rel = null;
if (typeof bin === 'string') rel = bin;
else if (bin && typeof bin === 'object' && !Array.isArray(bin)) {
rel = typeof bin.claude === 'string' ? bin.claude
: (Object.values(bin).find(v => typeof v === 'string') ?? null);
}
if (!rel) return null;
const file = join(dir, rel);
if (!existsSync(file)) return null;
// 🔴 Нутро, которое САМО требует оболочки (`.cmd`/`.bat`), нутром не считается: звать его
// без оболочки — EINVAL, с оболочкой — та самая склейка, от которой мы и уходим.
// «Нашли негодное» честнее слить с «не нашли», чем молча отдать заведомо битый запуск.
if (needsShell(file)) return null;
return file;
}
export function claudeLaunch(bin) {
const target = bin ?? resolveClaudeBin();
if (!needsShell(target)) return { command: target, prefixArgs: [], shell: false };
const nutro = claudeNutro(target);
if (nutro) {
// Нутро на JS зовётся через `node`, настоящий запускаемый файл — сам собой.
return /\.(c|m)?js$/i.test(nutro)
? { command: resolveBin('node'), prefixArgs: [nutro], shell: false }
: { command: nutro, prefixArgs: [], shell: false };
}
// 🔴🔴 ЗАПАСНОЙ ДОРОГИ ЧЕРЕЗ ОБОЛОЧКУ ЗДЕСЬ БОЛЬШЕ НЕТ — И ЭТО РЕШЕНИЕ, А НЕ ПРОПУСК.
// Она выглядела безобидной («ну хоть как-то запустим»), а на деле молча уродовала всё,
// что через неё шло: работник рождался и умирал через минуту, не отдав итога, а причину
// две смены искали не там. Тихая запасная дорога, которая ЛОМАЕТ, хуже громкого отказа:
// отказ виден сразу и стоит ноль денег, а поломка видна утром и стоит ночь работы.
throw new Error(
`Нутро программы claude не найдено рядом с «${target}». Ждали запись «bin» в ` +
`${join(dirname(target), 'node_modules', '@anthropic-ai', 'claude-code', 'package.json')} ` +
`и файл по ней. Через оболочку не зовём НАМЕРЕННО: она склеивает доводы в одну строку ` +
`без кавычек — многострочное поручение работника рвётся, пути с пробелом разваливаются, ` +
`ключ со значением «пустая строка» исчезает. Проверить: цел ли пакет @anthropic-ai/` +
`claude-code (переустановить npm i -g @anthropic-ai/claude-code) и не переехал ли он.`,
);
}
// ━━━ 🔴🔴 СТОРОЖ КЛАССА «ДОВОД, КОТОРЫЙ НЕ ПЕРЕЖИВЁТ СКЛЕЙКУ ОБОЛОЧКОЙ» ━━━
// Беда не в имени ключа и не в одном месте кода. Беда в СОЮЗЕ двух вещей: запуск идёт через
// оболочку И среди доводов есть такой, который склейка в одну строку без кавычек уродует.
// Порознь обе половины законны: без оболочки многострочное поручение и пути с пробелом —
// совершенно нормальные доводы, и краснеть на них было бы враньём.
//
// 🔑 ЛОВИМ ПО ФОРМЕ ДОВОДА, А НЕ ПО СПИСКУ ЗНАКОМЫХ ИМЁН. Список имён («вот эти три ключа
// опасны») устарел бы к следующей правке — ровно как жёсткое имя `cli.js` устарело к переезду.
// Смотрим на сам довод: пустой, с пробельным знаком, со знаком, который оболочка толкует
// по-своему. Затея заведомо подаёт такие доводы: поручение работника многострочное, путь
// к его настройкам лежит в каталоге с пробелом («портал crm»), а ключи с пустым значением
// у программы есть.
const ZNAKI_OBOLOCHKI = /["'`^&|<>()%!]/;
export function dovodNePerezhivyotSkleyku(arg) {
const s = String(arg ?? '');
if (s === '') return 'пустая строка — при склейке исчезает бесследно, и ключ молча съедает следующий довод';
if (/\s/.test(s)) return 'пробельный знак (пробел, табуляция или перевод строки) — склейка разорвёт один довод на несколько';
if (ZNAKI_OBOLOCHKI.test(s)) return 'знак, который оболочка разбирает по-своему (кавычка, &, |, <, >, %, !, ^, скобка)';
return null;
}
// Отдаёт СПИСОК бед, а не «да/нет»: чинить придётся поимённо, а не вообще.
// 🔴 `shell` смотрится на ПРАВДИВОСТЬ, а не на равенство `true`: у Node это ещё и строка
// («какой оболочкой звать») — третье состояние, которое равенством `=== true` слилось бы
// с «оболочки нет» и прошло бы сторожа насквозь.
export function proverkaSkleyki({ shell, args }) {
if (!shell) return []; // без оболочки такие доводы законны — это не беда, а обычная работа
const bedy = [];
const spisok = Array.isArray(args) ? args : [];
for (let i = 0; i < spisok.length; i += 1) {
const prichina = dovodNePerezhivyotSkleyku(spisok[i]);
if (prichina) bedy.push({ nomer: i, dovod: String(spisok[i] ?? ''), prichina });
}
return bedy;
}
export function zapretSkleyki({ shell, args, gde = 'запуск' }) {
const bedy = proverkaSkleyki({ shell, args });
if (bedy.length === 0) return;
const perechen = bedy
.map(b => ` · довод №${b.nomer} «${b.dovod.replace(/\r?\n/g, '⏎').slice(0, 60)}» — ${b.prichina}`)
.join('\n');
throw new Error(
`${gde}: доводы поданы ЧЕРЕЗ ОБОЛОЧКУ, а ${bedy.length} из них её не переживут.\n${perechen}\n` +
`Оболочка склеивает все доводы в одну строку без кавычек — программа получит НЕ ТО, ` +
`что ей отдали, и умрёт молча. Лечение одно: звать без оболочки (claudeLaunch ищет нутро ` +
`программы по записи «bin» в её package.json и отдаёт shell: false).`,
);
}
export function buildArgs({ prompt, settingsPath, sessionId, model, addDirs = [] }) {
// 🔴 Голова работника приходит из настроек владельца (`worker_model` в night.config.json)
// и НИГДЕ в коде словом не зашита. Причина денежная: разные головы едят недельный запас
// по-разному, а запас у владельца один на всё. Значения по умолчанию тут нет намеренно —
// молча подставить голову значит потратить чужой запас головой, которую не выбирали,
// и узнать об этом по счёту.
if (!model) {
throw new Error('Не сказано, какой головой запускать работника: в night.config.json нет worker_model (образец — night.config.example.json).');
}
// Предела в долларах в запуске НЕТ: своего счёта в деньгах затея не ведёт вовсе.
// Долю прогона держит надзиратель по приросту utilization у прибора системы (кусок 2).
//
// ━━━ 🔴🔴 ЛЕЧЕНИЕ 03.08.2026, МЕСТО 2 — ОДИН КЛЮЧ ИЗ ТРЁХ, А НЕ ВСЕ ━━━
// Живой платный замер (см. `sostavMasterskoyIzPotoka` выше) доказал: загрузка всей
// мастерской (50 инструментов, 51 внешняя служба, 129 умений, 156 команд) стоит платных
// токенов на КАЖДОМ запуске, а не только в крошечном замере (`probeArgs` в budget.mjs).
// Но работник — не крошечный замер: он правит код, гоняет проверки, коммитит, и ему
// мастерская НУЖНА для самой работы. Решение по каждому ключу — раздельное, не пакетом:
//
// · `--tools ''` — НЕ ставим. Без Read/Edit/Bash/Grep работник не работник вовсе:
// срезать инструменты значило бы сломать его работу, а не удешевить её.
// · `--strict-mcp-config` — СТАВИМ. Замерено по двум источникам: `worker-prompt.mjs`
// (весь протокол работника — файлы почтового ящика `.night-worker/` + `git` + команда
// проверок из плана, ни слова про MCP) и по грепу всего `tools/night/*.mjs` (ни одного
// обращения к MCP-инструменту нигде в коде затеи). 51 внешняя служба из `.mcp.json`
// (playwright, github, laravel-boost, sentry, redis, research-паки…) работнику ночью
// не нужна НИ РАЗУ — а флаг режет только их, встроенных Read/Edit/Bash/Grep/Glob
// не касаясь вовсе.
// · `--setting-sources ''` — НЕ ставим, и это НЕ забывчивость, а отдельное решение.
// Этот ключ обрубает источники настроек целиком — а из них же приходит и предупреждение
// «БОЕВОЙ ПРОД» в CLAUDE.md, и хук `prod-db-guard`, блокирующий снос боевой базы, —
// тому самому работнику, который всю ночь работает без человека рядом. Свои настройки
// (запреты, замки) работник и так получает СВОИМ, уже стоящим здесь ключом `--settings`
// (единственное число, вот он, ниже) — `--setting-sources` (множественное число) тут
// ни при чём, и трогать его ради экономии в центы значило бы менять по ошибке
// предохранитель. Если сомневаешься — не ставь; здесь не поставлен НАМЕРЕННО.
const args = [
'-p', prompt,
// Поток событий, а не один кусок в конце: надзирателю нужен ХОД дела —
// граница шага для «паузы», замеры недельного запаса по ходу и признаки жизни,
// а не только итог над трупом.
'--output-format', 'stream-json',
'--verbose', // без него Claude Code откажется отдавать поток в паре с -p
'--settings', settingsPath,
'--session-id', sessionId,
'--model', model,
'--permission-mode', 'acceptEdits',
'--strict-mcp-config',
];
for (const d of addDirs) args.push('--add-dir', d);
return args;
}
// Что работник наследует из окружения. Список ЗАКРЫТ и намеренно короток: по умолчанию
// запуск отдаёт ребёнку ВСЁ окружение запускающего, включая боевые ключи в переменных среды,
// а замок «боевых ключей нет» смотрит только на файлы и такого не поймал бы.
// ANTHROPIC_API_KEY тут нет намеренно: с ним оплата ушла бы мимо подписки владельца.
//
// ━━━ 🔴🔴 ВТОРАЯ ПОЛОВИНА СПИСКА: ОТВЕДЕНИЕ ХОЗЯЙСТВА УЛИК ПЕРЕЕЗЖАЕТ К РЕБЁНКУ ━━━
// Найдено и замерено 04.08.2026 (круг E). Пол в `paths.mjs` («под прибором проверок корень
// хозяйства не бывает боевым») узнаёт прибор ТОЛЬКО по приметам в окружении. А это окружение
// РЕЖЕТСЯ здесь — и до 04.08.2026 резалось вместе с приметами. Значит ребёнок, рождённый
// из проверки, приходил в мир без единой приметы, честно решал, что он боевой, и получал
// корнем ОПЛАЧЕННЫЕ УЛИКИ ВЛАДЕЛЬЦА.
// 🔴 Замерено командой, а не выведено: `env -i PATH=… node -e "import('./paths.mjs')"` отдаёт
// боевой `.night` рядом с хранилищем; та же команда с `VITEST=true` — временный каталог.
//
// 🪤 ПРЕЖНИЙ ДОВОД БЫЛ ЛОЖНЫМ, И ЭТО ГЛАВНОЕ. В стороже улик стояло «сегодня так никто
// не делает, оба живых зова окружение переносят целиком». Делают: `spawn.test.mjs` рождает
// живого ребёнка ровно этим урезанным списком. Спасало только то, что тот ребёнок печатает
// одну строку и модулей затеи не грузит, — то есть дверь держалась на том, что в неё пока
// не толкались. Допиши кто-нибудь в него один `import` — и пол перестал бы накрывать МОЛЧА.
//
// 🔴 ПОЧЕМУ ЭТО НЕ ТРОГАЕТ БОЕВУЮ НОЧЬ НИ НА ЗНАК. Перенос идёт по правилу «есть у родителя —
// отдай ребёнку» (ниже `if (from[key] !== undefined)`). В настоящей ночи ни одной из этих
// переменных в окружении НЕТ: приметы ставит vitest, а отведение — прибор проверок. Значит
// в бою список остаётся прежним, семь переменных, и ни одна лишняя не появляется.
// 🪤 Секретов здесь не прибавляется: это путь к временному каталогу и признак «идёт проверка».
// Ключей и паролей среди них нет — замок «боевых ключей нет» не ослаблен ни на волос.
export const WORKER_ENV_KEYS = [
'PATH', 'SystemRoot', 'TEMP', 'TMP', 'HOME', 'USERPROFILE', 'APPDATA',
// Куда отведено хозяйство улик, и признак того, что отведение вообще законно.
// 🔴 Имена ввозятся из `paths.mjs`, а не набраны здесь строками: набранная копия разъедется
// с оригиналом молча, и ловить это было бы нечем.
PODMENA_HOZYAYSTVA,
...PRIMETY_PRIBORA,
];
export function workerEnv({ from = process.env, dbName }) {
if (!dbName) {
throw new Error('Работнику не дали имя своей базы — запуска нет: общая база на семерых даёт ЗЕЛЁНЫЕ ВРУЩИЕ проверки, а это худший вид беды.');
}
const env = {};
for (const key of WORKER_ENV_KEYS) {
if (from[key] !== undefined) env[key] = from[key];
}
env.NIGHT_WORKER = '1';
// 🪤 app/phpunit.xml:33 жёстко задаёт DB_DATABASE=liderra_testing, и без этой переменной
// своя база работника до прогона проверок НЕ доходит: семеро затирают друг другу базу
// при зелёных тестах. Сам phpunit.xml не правим — он общий для всего продукта.
// Работает это потому, что у того объявления нет пометки «перебивать»: переменную,
// которая в окружении уже есть, проверки не трогают, и Laravel свой .env поверх
// окружения тоже не кладёт.
env.DB_DATABASE = dbName;
return env;
}
export function parseStreamLine(line) {
const s = String(line ?? '').trim();
if (!s) return null;
try { return JSON.parse(s); } catch { return null; } // порванная строка потока не валит надзирателя
}
const STEP_BOUNDARIES = new Set(['assistant', 'user', 'result']);
export function isStepBoundary(event) {
// Граница шага — то место, где «паузу» можно применить чисто: работник ответил
// или запуск кончился. Служебное начало сессии (`system`) границей не считается.
return Boolean(event) && STEP_BOUNDARIES.has(event.type);
}
// ━━━ ЗАМЕР ПРИБОРА СИСТЕМЫ ИЗ ПОТОКА ━━━
//
// 🔴 ЗАМЕРЕНО ЖИВЬЁМ 03.08.2026, сырой поток лежит в `.night/probe-raw-2026-08-03.jsonl`.
// Живое событие целиком, знак в знак:
// {"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1785712200,
// "rateLimitType":"five_hour","overageStatus":"rejected",
// "overageDisabledReason":"org_level_disabled","isUsingOverage":false}}
//
// 🔴🔴 ПОЛЯ `utilization` В НЁМ НЕТ ВОВСЕ — И ЭТО НЕ ПОЛОМКА. Программа собирает событие
// двумя дорогами: при спокойном расходе — сборкой БЕЗ доли, а с долей — только когда перейдён
// порог тревоги, и тогда `status` становится `allowed_warning`. То есть прежнее правило
// «нет доли — замера нет» ждало ЛАМПОЧКУ ТРЕВОГИ и принимало её за бензомер: пока владелец
// тратит не быстрее часов, числа не будет никогда.
//
// 🔴 Прежнее правило отвергало живое событие ДВАЖДЫ, и каждой причины хватало поодиночке:
// ждало НЕДЕЛЬНЫЙ вид предела (а приходит `five_hour`) и ждало долю (её нет). Ворота запаса
// не пропустили ни разу за всю жизнь затеи, а владелец читал «события не пришло» — враньё.
//
// Ноль вместо неизвестного — по-прежнему враньё о запасе, поэтому доли нет = `null`, не 0.
// Сырой разбор события БЕЗ оглядки на то, какой вид предела нам нужен. Нужен затем, чтобы
// уметь сказать владельцу правду по решению Р2: «система шлёт вид such-and-such, а в настройках
// ждём такой-то». Без этой дороги несовпадение вида было бы неотличимо от «события не пришло».
export function razborPredela(event) {
if (!event || event.type !== 'rate_limit_event') return null;
const info = event.rate_limit_info;
if (!info || typeof info !== 'object') return null;
// 🔴 Замер без вида предела — ничей: приписать его нашему пределу значило бы решать
// о деньгах по числу, про которое неизвестно, из какого оно окна.
const kind = typeof info.rateLimitType === 'string' && info.rateLimitType ? info.rateLimitType : null;
if (!kind) return null;
return {
kind,
status: typeof info.status === 'string' && info.status ? info.status : null,
utilization: Number.isFinite(info.utilization) ? info.utilization : null,
resets_at: Number.isFinite(info.resetsAt) ? info.resetsAt * 1000 : null,
// 🔴 ВТОРОЙ СВЕТОФОР. Светофоров в событии ДВА, а затея читала один и второй молча
// выбрасывала. Первый (`status`) говорит, как дела с запасом СЕЙЧАС. Второй
// (`overageStatus`) отвечает на другой вопрос: что будет, КОГДА запас кончится —
// дадут ли работать сверх за доплату или откажут. Замерено на пяти оплаченных следах:
// всегда `rejected`, причина `org_level_disabled`. Значит ночь, упёршись в предел,
// встанет намертво, и ждать доплаты бесполезно.
// 🪤 Нет поля — `null`, а НЕ «разрешено»: подставленное умолчание обещало бы владельцу
// продолжение работы, которого может не быть. Ноль и умолчание вместо неизвестного
// в этой затее запрещены везде, где речь о деньгах.
sverh_zapasa: typeof info.overageStatus === 'string' && info.overageStatus ? info.overageStatus : null,
sverh_pochemu: typeof info.overageDisabledReason === 'string' && info.overageDisabledReason
? info.overageDisabledReason : null,
sverh_idyot: typeof info.isUsingOverage === 'boolean' ? info.isUsingOverage : null,
};
}
/**
* Второй светофор — словами владельца.
*
* 🔴 Отвечает ровно на один вопрос: что будет, когда запас кончится. Это не то же самое,
* что первый светофор («как дела сейчас»), и путать их нельзя: при зелёном первом второй
* вполне может быть «откажут».
*/
export function slovaProSverh(status, pochemu) {
if (status === 'rejected') {
const hvost = pochemu === 'org_level_disabled'
? ' Так настроено на стороне владельца подписки, а не сломалось.'
: '';
return `Когда запас кончится, работать сверх не дадут — система откажет, и ночь встанет.${hvost}`;
}
if (status === 'allowed') {
return 'Когда запас кончится, работать сверх дадут — значит расход может продолжиться за доплату.';
}
return 'Дадут ли работать сверх запаса — неизвестно: система об этом не сказала.';
}
// Тот же разбор, но отсеивающий чужой вид предела.
//
// 🔴 РЕШЕНИЕ Р1: вид предела — ОБЯЗАТЕЛЬНЫЙ довод, зашитого умолчания здесь нет и быть
// не должно. Прежнее умолчание НЕДЕЛЬНЫМ видом было ровно тем умолчанием, из-за которого
// ворота молчали: зовущий, забывший назвать вид, молча получал не тот запас. Теперь такой
// зовущий падает громко. Единственное место, где вид берётся, — настройка `rate_limit_type`
// (правило `vidPredela` в budget.mjs).
export function rateLimitFromEvent(event, { kind = null } = {}) {
if (!kind) {
throw new Error('Не сказано, какой вид предела читать из события (довод `kind`). Своего умолчания здесь нет намеренно: подставленный молча вид — это чужой запас, и решать по нему о деньгах владельца нельзя.');
}
const info = razborPredela(event);
if (!info || info.kind !== kind) return null;
return info;
}
// ━━━ ГОЛОВА, КОТОРАЯ ВПРАВДУ ОТВЕЧАЛА ━━━
//
// 🔴🔴 ЗАМЕРЕНО ЖИВЬЁМ 03.08.2026 (сырой поток — `.night/probe-raw-2026-08-03.jsonl`, 11 строк,
// голое имя головы намеренно НЕ повторяем здесь словом — см. правило-сторож в spawn.test.mjs,
// «слова головы в коде затеи нет нигде»): заказали крошечный замер дешёвой головой, а в СЧЕТЕ
// оказалась (и была ОПЛАЧЕНА) ДРУГАЯ, В РАЗЫ ДОРОЖЕ ГОЛОВА — цена сошлась с её прейскурантом
// знак в знак (`total_cost_usd: 0.15743` на 41 954 токенах создания кэша, у дешёвой головы
// вышло бы вчетверо меньше). 🔴 ОТЧЕГО ТАК — НЕ ВЫЯСНЕНО. Отдельной живой проверкой (другим
// прогоном, не этим сырым файлом) подтверждено, что заказанная голова САМА ПО СЕБЕ работает —
// то есть это НЕ отказ ключа `--model`. Точная причина появления второй головы в счёте —
// открытый вопрос, датчик её не расследует и не утверждает; его дело — заметить и назвать
// вслух факт, а не поставить диагноз.
//
// 🪤 ЛОВУШКА, в которую эта проверка чуть не попала: событие `system`/`init` в начале потока
// ТОЖЕ несёт поле `model` — но там лежит ЗАКАЗ (то же самое, что попросили ключом `--model`),
// а не ИСПОЛНЕНИЕ. На живом потоке `system`/`init` честно назвал ЗАКАЗАННУЮ дешёвую голову
// (дословно — в сыром файле), а вовсе не ту, что стоит в счёте. Взять голову оттуда значило бы
// сверять заказ с самим собой — проверка сходилась бы ВСЕГДА, и беда осталась бы невидимой
// навсегда.
//
// Надёжное место — событие `assistant`: `message.model` — голова, которая СГЕНЕРИРОВАЛА
// ответ. Она же (или те же головы, если их несколько за прогон) стоит ключами в `modelUsage`
// итогового события `result`, откуда система считает `total_cost_usd` — то есть это ровно то,
// за что СПИСАНЫ деньги. Читаем по `assistant`, а не по `result`, по двум причинам: он приходит
// раньше (виден даже в потоке, оборванном по времени, где до `result` дело не дошло), и он же
// используется у события за событием — так, как читает поток надзиратель (см. `eatLine`
// в supervisor.mjs), а не только целиком после конца прогона.
export function golovaIzSobytiya(event) {
if (!event || event.type !== 'assistant') return null;
const model = event.message?.model;
return typeof model === 'string' && model ? model : null;
}
// ━━━ ГОЛОВЫ, КОТОРЫМИ ВПРАВДУ СЧИТАНЫ ДЕНЬГИ (поправка 03.08.2026) ━━━
//
// 🔴🔴 НЕ ЕДИНСТВЕННЫЙ ИСТОЧНИК ИСПОЛНЕНИЯ. Замерено командой прямо на живом потоке
// (`grep -o '"model":"[^"]*"' … | sort | uniq -c`): имя головы стоит в потоке ДВАЖДЫ — в
// `system`/`init` (это ЗАКАЗ, ловушка, описанная у `golovaIzSobytiya` выше) и в ответе
// помощника (`assistant`). А деньги (`total_cost_usd`) система считает по ТРЕТЬЕМУ полю —
// `modelUsage` итогового события `result`, ключи которого и есть головы, за которые
// СПИСАНЫ деньги. На живом потоке `modelUsage` совпадает с ответом помощника — но это
// не повод читать только один из двух источников исполнения: они не обязаны совпадать
// молча вечно, а датчик обязан ловить расхождение, в каком бы из двух оно ни показалось
// первым (например, поток мог оборваться сразу после `result`, не дойдя до чего-то ещё).
export function golovyIzModelUsage(event) {
if (!event || event.type !== 'result') return [];
const usage = event.modelUsage;
if (!usage || typeof usage !== 'object') return [];
return Object.keys(usage).filter(k => typeof k === 'string' && k);
}
// ━━━ СРАВНЕНИЕ ЗАКАЗАННОЙ ГОЛОВЫ С ТОЙ, ЧТО ВПРАВДУ ОТВЕТИЛА ━━━
//
// 🪤 Заказывают КОРОТКИМ именем (то, что кладут в night.config.json как `probe_model`/
// `worker_model` — короткие имена перечислены в night.config.example.json), а отвечает
// ПОЛНЫМ (`claude-haiku-4-5-20251001` и подобные). Строгое равенство строк сравнивало бы
// разные алфавиты и падало бы КАЖДЫЙ раз, даже когда голова верна. Сравнение здесь —
// «полное имя содержит короткое», без учёта регистра.
//
// 🔴 ЧТО ЭТО СРАВНЕНИЕ ПРОПУСТИТ (сказано вслух, а не спрятано в умолчание): если у семейства
// голов однажды заведут короткое имя, которое является подстрокой ДРУГОГО короткого имени
// (например, короткое имя новой головы содержало бы буквы, из которых полностью состоит
// нынешнее короткое имя другой головы), это сравнение ложно сочло бы их совпавшими —
// оно смотрит только «есть ли заказанное имя ВНУТРИ полного», а не «это она и есть, а не
// однофамилец». На сегодняшних трёх головах (короткие имена в `night.config.example.json`)
// подстрок друг в друге нет — беда, ради которой строился датчик (заказали одну голову,
// ответила голова из СОВСЕМ ДРУГОГО семейства), этим сравнением ловится полностью. Если
// семейство голов расширится похожими именами, сравнение придётся ужесточить.
export function golovySovpadayut(zakazana, otvetila) {
if (typeof zakazana !== 'string' || !zakazana) return null;
if (typeof otvetila !== 'string' || !otvetila) return null;
return otvetila.toLowerCase().includes(zakazana.toLowerCase());
}
// ━━━ ГЛАВНЫЙ ДАТЧИК: заказанная голова против того, что вправду стоит в счёте ━━━
//
// 🔴🔴 ПРЕЖНИЙ ВЫВОД ЗДЕСЬ ОПРОВЕРГНУТ ЗАМЕРОМ — ИСПРАВЛЕНО 03.08.2026 (вечер).
// Здесь было написано: «беда — не замена, а ДОБАВКА (заказанная голова в счёте есть, рядом
// появляется лишняя), причина открыта». Это оказалось НЕВЕРНО, и владельцу это уходило в
// утреннюю сводку как «открытый вопрос». Бесплатный замер (прибор
// `.night/probe-golova-2026-08-03.mjs`: программе подставляется свой сервер, и в теле
// настоящего запроса читается поле `model` — исполнение, а не заказ; живого обращения нет,
// цена ровно ноль) показал обратное:
// • это была именно ЗАМЕНА — заказанной головы в запросе не было вовсе;
// • причина НАЙДЕНА и названа: программа подменяет голову при СОЮЗЕ двух вещей —
// голова названа коротким именем-прозвищем И заказан режим `--permission-mode plan`.
// Порознь обе половины безобидны, оттого две смены и искали не там: событие
// `system`/`init` при подмене продолжает честно показывать ЗАКАЗАННУЮ дешёвую голову.
// • лечение уже врезано в `budget.mjs`: режим замера — `default`, а сам союз закрыт
// сторожем `zapretPodmenyGolovy` (он падает ДО запуска, а не после счёта).
// Подробный разбор — `docs/night/razbor-dorogaya-golova-2026-08-03.md`.
//
// 🔴 ЧТО ЭТО ЗНАЧИТ ДЛЯ САМОГО ДАТЧИКА. Он остаётся нужен и не переписывается под одну
// причину: его дело — заметить и назвать ФАКТ расхождения, а не поставить диагноз. Но если
// он всё-таки заговорит СЕЙЧАС, когда лечение стоит, — это уже не «открытый вопрос», а одно
// из двух: либо лечение откатили (тогда сторож `zapretPodmenyGolovy` покраснеет первым),
// либо это НОВАЯ причина, которой замер 03.08.2026 не касался. Ровно так и сказано владельцу
// в `soobshchenie` ниже — вместо прежнего «не выяснено».
//
// 🔴🔴 РЕШЕНИЕ — ПРЕДУПРЕЖДЕНИЕ (говорить), А НЕ ОТКАЗ (падать). Обоснование:
// • Найденная причина закрыта РАНЬШЕ по дороге — сторожем `zapretPodmenyGolovy` в
// `budget.mjs`, который падает ещё до запуска. Здесь же ловится всё, что этим сторожем
// не покрыто, то есть причина СЕГОДНЯ неизвестная, — а падать на неизвестной причине
// значило бы останавливать ВСЮ ночь из-за того, на что затея повлиять не может. Хуже того:
// `probeUtilization`/`startWorker` вызываются ДО того, как известно, что стоит в счёте, —
// бросок в момент сверки убил бы уже идущий, наполовину оплаченный запуск, не добавив
// владельцу ничего, кроме дыры в ночи.
// • Молчать при этом нельзя категорически — владелец платит больше, чем рассчитывал, и не
// знает — поэтому это ГРОМКОЕ предупреждение, а не тихая пометка: слова в `soobshchenie`
// названы так, чтобы их можно было положить в утреннюю сводку владельцу без перевода.
// • Тот же класс решения уже есть в затее: Р5 в `budget.mjs` (`strogoBezDoli`) отвечает
// ровно так же на похожую вилку «система не подтвердила ожидаемое одним словом» — говорит,
// а не падает, и оставляет владельцу отдельный переключатель на СВОЁ решение стать строже.
// Если лишняя голова в счёте станет систематической, владельцу нужен ровно такой
// переключатель (например, в надзирателе) — но заводить его молча внутри датчика нельзя:
// это решение владельца, а не программиста.
export function sverkaGolovy({ zakazana, potok }) {
if (typeof zakazana !== 'string' || !zakazana) {
throw new Error('Сверка головы позвана без заказанной головы (довод `zakazana`) — сверять её не с чем.');
}
const otvetivshie = [];
const dobavit = golova => { if (golova && !otvetivshie.includes(golova)) otvetivshie.push(golova); };
for (const line of String(potok ?? '').split('\n')) {
const event = parseStreamLine(line);
dobavit(golovaIzSobytiya(event));
// 🔴 Оба источника ИСПОЛНЕНИЯ, не один: ответ помощника и то, чем считаны деньги
// (см. комментарий у `golovyIzModelUsage` выше). `system`/`init` сюда не подмешивается
// никогда — там лежит заказ, а не исполнение.
for (const g of golovyIzModelUsage(event)) dobavit(g);
}
// ━━━ ТРЕТИЙ ОТВЕТ: имени головы в потоке не было ВООБЩЕ ━━━
// Это НЕ «сходится» — совпадение доказать нечем, поток мог оборваться раньше первого хода.
if (!otvetivshie.length) {
return {
sovpalo: null,
zakazana,
otvetila: [],
zakazannaya_prisutstvuet: null,
postoronnie: [],
soobshchenie: `Заказана голова «${zakazana}», а в потоке нет ни одной головы — ни в ответе помощника `
+ '(события «assistant»), ни в счёте (modelUsage у события «result») — узнать, что вправду в счёте, '
+ 'не удалось. Это НЕ "сходится": имени головы просто не пришло.',
};
}
// Совпадающие с заказом (обычно ровно она сама) — и посторонние (не заказывали, а в счёте есть).
const sovpadayushchie = otvetivshie.filter(h => golovySovpadayut(zakazana, h));
const postoronnie = otvetivshie.filter(h => !golovySovpadayut(zakazana, h));
if (!postoronnie.length) {
return {
sovpalo: true, zakazana, otvetila: otvetivshie,
zakazannaya_prisutstvuet: true, postoronnie: [], soobshchenie: null,
};
}
const zakazannayaEst = sovpadayushchie.length > 0;
// Деньги: дешёвая и дорогая голова расходятся в цене в разы (замерено — см. комментарий
// выше у `golovaIzSobytiya`). Это утверждение о ЦЕНЕ, а не о ПРИЧИНЕ появления — причину
// не называем, потому что не знаем.
// 🔴 Слова про причину — общие для обеих веток: одна известная причина НАЙДЕНА и вылечена
// (короткое имя головы + режим «plan»), поэтому «не выяснено» владельцу больше не пишем.
const proPrichinu = 'Одна причина такого расхождения найдена и уже вылечена 03.08.2026: программа молча '
+ 'меняла голову, когда её заказывали коротким именем-прозвищем вместе с режимом «plan» (разбор — '
+ 'docs/night/razbor-dorogaya-golova-2026-08-03.md). Раз эта строка всё-таки появилась, значит либо '
+ 'лечение откатили, либо причина ДРУГАЯ, ещё не разобранная.';
const soobshchenie = zakazannayaEst
? `🔴 Кроме заказанной головы «${zakazana}», в счёте появилась ЕЩЁ голова, которую не заказывали: `
+ `«${postoronnie.join(', ')}» — и она тоже оплачена. ${proPrichinu} `
+ 'Дешёвая и дорогая голова расходятся в цене в разы — если это '
+ 'повторяется каждую ночь, лишняя оплата складывается. Покажите эту строку тому, кто правит затею.'
: `🔴 Заказанная голова «${zakazana}» среди того, что в счёте, не видна вовсе — вместо неё там голова, `
+ `которую не заказывали: «${postoronnie.join(', ')}». ${proPrichinu} `
+ 'Дешёвая и дорогая голова расходятся в цене в разы — могло быть заплачено намного больше, чем '
+ 'рассчитывали. Покажите эту строку тому, кто правит затею.';
return {
sovpalo: false, zakazana, otvetila: otvetivshie,
zakazannaya_prisutstvuet: zakazannayaEst, postoronnie, soobshchenie,
};
}
// ━━━ 🔴🔴 ДАТЧИК МАСТЕРСКОЙ (03.08.2026): «КЛЮЧ ПРИНЯТ» ≠ «КЛЮЧ ПРИМЕНЁН» ━━━
//
// 🔴🔴 НАХОДКА, ЗАМЕРЕННАЯ ЖИВЬЁМ ЧАСОМ РАНЬШЕ: ключ `--tools ''` стоял в коде затеи
// с примечанием «работать этот запуск не должен вовсе» — и МОЛЧА НЕ ПРИМЕНЯЛСЯ. Крошечный
// замер (вопрос из трёх токенов, ответ из шести) платил за 41 954 токена мастерской, которую
// заказали отключить. Тот же класс беды, что уже дважды выстрелил в этом хозяйстве в тот же
// день (Inworld вернул подтверждение потолка токенов и не применил его вовсе): настройка,
// которую ПРИНЯЛИ, — не то же самое, что настройка, которую ПРИМЕНИЛИ. Верить аргументу
// командной строки нельзя; верить можно только честному ответу самой системы.
//
// 🔴 Тот честный ответ — событие `system`/`init` в начале потока. Оно называет ЧЕТЫРЕ списка
// того, что вправду загружено: `tools`, `mcp_servers`, `skills`, `slash_commands`. Датчик ниже
// читает ИХ, а не то, что было заказано ключами, — ровно так же, как `sverkaGolovy` выше
// сверяет не заказанную голову, а ту, что вправду стоит в счёте.
//
// 🔴 ДОКАЗАНО НА ДВУХ НАСТОЯЩИХ ОПЛАЧЕННЫХ ЖИВЫХ ПОТОКАХ (лежат в `.night/`, не выдумка):
// без лечения (`probe-raw-2026-08-03.jsonl`) — tools 50, mcp_servers 51, skills 129,
// slash_commands 156; с лечением, три ключа разом (`probe-lechenie-2026-08-03.jsonl`) —
// tools 0, mcp_servers 0, skills 8, slash_commands 20. Восемь умений и двадцать команд —
// ВСТРОЕННЫЙ КОСТЯК программы: до нуля они не падают даже со всеми тремя ключами разом,
// и это не недолеченный обвес, а нижний предел того, что программа умеет отдать без
// пользовательских/проектных источников настроек.
export function sostavMasterskoyIzPotoka(potok) {
for (const line of String(potok ?? '').split('\n')) {
const event = parseStreamLine(line);
if (event?.type === 'system' && event.subtype === 'init') {
const dlina = v => (Array.isArray(v) ? v.length : null);
return {
naydeno: true,
tools: dlina(event.tools),
mcp_servers: dlina(event.mcp_servers),
skills: dlina(event.skills),
slash_commands: dlina(event.slash_commands),
};
}
}
// 🔴 Событие не пришло — это ЧЕСТНОЕ «неизвестно», а не ноль. Ноль означал бы «мастерская
// пуста», а на самом деле мы её просто не видели: поток мог оборваться раньше первого хода.
return {
naydeno: false, tools: null, mcp_servers: null, skills: null, slash_commands: null,
};
}
// Сверка ЗАКАЗАННЫХ граней урезания с тем, что вправду показал поток. Заказывают ПООДИНОЧКЕ
// (`zakazanTools` за `--tools ''`, `zakazanMcp` за `--strict-mcp-config`), потому что два
// платных места затеи заказывают РАЗНОЕ: крошечный замер (budget.mjs) режет всё, рождение
// работника (этот файл, `buildArgs` выше) — только внешние службы, инструменты работнику
// нужны для самой работы. Грань, которую не заказывали, не сверяется вовсе — иначе датчик
// врал бы «не отсечено» про обвес, который никто и не просил резать.
//
// 🔴 `skills`/`slash_commands` здесь НЕ сверяются числом: у них нет чужого нуля, до которого
// падать, — есть только встроенный костяк (см. комментарий выше), а его размер не сторожили
// живым замером отдельно от `tools`/`mcp_servers`. Сверяем то, для чего есть доказанный ноль.
export function masterskayaOtsechena({ potok, zakazanTools = false, zakazanMcp = false }) {
const sostav = sostavMasterskoyIzPotoka(potok);
if (!sostav.naydeno) {
return {
otsecheno: null,
sostav,
soobshchenie: 'В потоке не было события system/init — сверить состав мастерской нечем: '
+ 'поток мог оборваться раньше первого хода. Это НЕ "сошлось": сравнивать было не с чем.',
};
}
const problemy = [];
if (zakazanTools && sostav.tools !== 0) {
problemy.push(`заказан --tools '' (инструментов быть не должно), а поток честно называет ${sostav.tools}`);
}
if (zakazanMcp && sostav.mcp_servers !== 0) {
problemy.push(`заказан --strict-mcp-config (внешних служб быть не должно), а поток честно называет ${sostav.mcp_servers}`);
}
if (!problemy.length) {
return { otsecheno: true, sostav, soobshchenie: null };
}
return {
otsecheno: false,
sostav,
soobshchenie: `🔴 Настройка ПРИНЯТА, но НЕ ПРИМЕНЕНА: ${problemy.join('; ')}. Верить ключу `
+ 'командной строки нельзя — верить можно только честному ответу самого потока '
+ '(событие system/init).',
};
}
// 🔴🔴 ЖИВЫХ ЗОВУЩИХ НЕТ, И ЭТО НЕ ДОЛГ, А МЁРТВЫЙ ОСТАТОК. ЗАМЕРЕНО 07.08.2026, СМЕНА 28.
//
// Правило берёт ГОТОВЫЙ вывод целиком и разбирает его ПОСЛЕДНЮЮ строку. Дороги, которой
// оно писано — «запусти работника, дождись конца, возьми итог», — в затее больше нет:
// `startWorker` отдаёт ЖИВОГО ребёнка, а не текст. Все четверо, кто читает потоки, названы
// поимённо, и каждый делает ДРУГУЮ работу — сводить их сюда было бы не починкой, а поломкой:
//
// · `supervisor.mjs` — разбирает поток ЖИВЬЁМ, по строке, по мере прихода: попутно кладёт
// кусок на диск до всякого разбора и считает замеры запаса. Ждать конца ему нельзя;
// · `budget.mjs` (`probeUtilization`) — перебирает ВСЕ строки, ища замеры предела,
// а не итог. Последняя строка ему не нужна вовсе;
// · `sudya-nochi.mjs` (`itogIzPotoka`) — берёт ПОСЛЕДНЕЕ событие вида `result` (а не
// последнюю строку: после итога в потоке бывает хвост), понимает `is_error` и считает
// пустой приговор за «не судил». Отдаёт ТЕКСТ приговора, а не объект события;
// · `dengi-bez-zapisi.mjs` (`razborSleda`) — берёт ПЕРВУЮ цену из следа-улики.
//
// 🪤 Расход (`total_cost_usd`), ради которого правило писалось, затея НАРОЧНО не ведёт:
// остаток запаса она не считает, а СПРАШИВАЕТ у прибора системы (`supervisor.mjs`, разбор
// потока). Подключать отсюда второй счёт денег значило бы завести вторую дорогу к тому же
// смыслу — ровно то, от чего хозяйство предупреждает в пяти местах.
//
// 🔴 Не снесено НАМЕРЕННО, и причина названа: снос — необратимее записи, слова владельца
// на него нет, а вместе с правилом ушли бы три его проверки. Сносить это — отдельное
// решение владельца, не побочный ход чужой работы.
export function readResult(stdout) {
const text = String(stdout ?? '').trim();
if (!text) return null;
const last = text.split('\n').filter(Boolean).pop();
try { return JSON.parse(last); } catch { return null; }
}
// ━━━ 🔴 ПРИГОВОР О РАБОТНИКЕ + КУДА СМОТРЕТЬ, ЕСЛИ ПРИГОВОР ПЛОХОЙ (03.08.2026) ━━━
//
// Второй довод `gde` — НЕОБЯЗАТЕЛЬНЫЙ, и это не лень, а нарочно: надзиратель зовёт
// `isRealSuccess(result)` одним доводом, как звал, и боевое поведение без второго довода
// не меняется НИ НА ЗНАК. Подашь второй — к причине неудачи допишется путь к стенограмме
// работника, то есть к полной записи всего, что с ним было (см. `stenogramma.mjs`: у молча
// умершего работника это ЕДИНСТВЕННОЕ место, где вообще написано, отчего он умер, — своего
// следа потока у затеи в этом случае нет ни байта).
//
// 🔴🔴 ПОСЛЕДНЯЯ МИЛЯ НЕ ПОДКЛЮЧЕНА, И Я ГОВОРЮ ЭТО ВСЛУХ, А НЕ ПРЯЧУ. Сегодня второй довод
// не подаёт НИКТО: единственный зовущий — `supervisor.mjs` (строка ~1794), а он в этой смене
// правится другими и мне запрещён. Хуже того, самый нужный случай — молча умерший работник —
// до `isRealSuccess` вообще НЕ ДОХОДИТ: приговор «Работник кончился, не отдав итога» собран
// в `supervisor.mjs` строками ~1786–1790 прямо на месте, отдельной веткой выше. То есть
// чтобы улика дошла до утреннего письма владельца, нужны ДВЕ строки в чужом файле:
// 1) в ветке «не отдал итога» дописать к причине `strokaVladeltsu(stenogrammaPoKartochke(…))`;
// 2) сюда, в зов `isRealSuccess`, передать вторым доводом `{ rabochiyUgol: card.workspace,
// sessionId: card.session_id }`.
// Пока их нет, улика достижима руками: `node tools/night/stenogramma.mjs <путь к worker.json>`.
export function isRealSuccess(result, gde = null) {
// Хвост «куда смотреть» считается ЛЕНИВО и только для плохого приговора: на добром
// исходе владельцу читать стенограмму незачем, а лазить по диску на каждом успехе — зря.
const kuda = () => {
if (!gde || !gde.rabochiyUgol) return '';
try {
const n = naytiStenogrammu({
rabochiyUgol: gde.rabochiyUgol,
sessionId: gde.sessionId ?? null,
ot: Number.isFinite(gde.ot) ? gde.ot : null,
do_: Number.isFinite(gde.do_) ? gde.do_ : null,
// 🪤 Домашняя папка — доводом, а не через переменную среды. Первая попытка подменяла
// проверке `USERPROFILE`/`HOME` — и это ЗАДЕЛО СОСЕДЕЙ ПО ПРОГОНУ: по `HOME` ищет
// свои настройки git, и у соседних проверок посыпалось «not a git repository».
// Замерено: `supervisor.test.mjs` в одиночку 187 из 187, а в общем прогоне 5 красных.
...(gde.home ? { home: gde.home } : {}),
});
return ` ${strokaVladeltsu(n)}`;
} catch {
// 🔴 Поиск улики НЕ ИМЕЕТ ПРАВА уронить приговор о работнике: приговор — главное,
// подсказка «куда смотреть» — добавка. Сломается диск — владелец получит приговор
// без подсказки, а не молчание вместо приговора.
return '';
}
};
if (!result) {
return { ok: false, reason: `Ответ работника не разобран — считаем, что запуск не удался.${kuda()}` };
}
if (result.is_error) {
return { ok: false, reason: `Работник вернул ошибку: ${result.result ?? result.api_error_status ?? 'причина не названа'}${kuda()}` };
}
if (typeof result.result !== 'string' || result.result.trim() === '') {
return { ok: false, reason: `Работник сообщил об успехе, но вернул пустой ответ — успехом это не считается.${kuda()}` };
}
return { ok: true, reason: null };
}
// 🔴 `model` — обязательное поле, без значения по умолчанию. Голова работника приходит
// из настроек владельца и проносится сюда насквозь: run.mjs (задача 14) читает `worker_model`
// из night.config.json → кладёт в карточку работника → supervisor.mjs (задача 12) берёт
// из карточки → сюда. Ни одно звено цепочки своего слова не подставляет.
//
// ━━━ 🔴🔴 ЗДЕСЬ И ЕСТЬ РОЖДЕНИЕ РАБОТНИКА — ЕДИНСТВЕННОЕ В ЗАТЕЕ ━━━
// Замерено командой 02.08.2026: живых запусков головы в затее ровно ДВА — крошечный платный
// замер остатка (`budget.mjs`) и вот этот. Замер был сосчитан и ограничен числом 30,
// а рождение работника — расход в разы больший, на ЧАСЫ работы — не считалось ничем.
// То есть предел владельца сторожил копейки и не сторожил рубли. Теперь и здесь стоят
// ворота счёта (`rozhdenieSoSchyotom`), и они те же самые, что у замера: второго устройства
// для этого нарочно не заведено.
//
// 🔴 ПОРЯДОК ШАГОВ — НЕ СЛУЧАЙНЫЙ. Сперва идут все отказы, которые НЕ СТОЯТ ДЕНЕГ (программу
// не нашли, голову не сказали, имя базы не дали), и только потом счёт. Иначе описка
// в настройках владельца воровала бы у него рождение, которого не было.
export function startWorker({
cwd, prompt, settingsPath, sessionId, dbName, model, addDirs = [],
// 🔴 Настройки и счёт — доводы с НАСТОЯЩИМИ умолчаниями, то есть боевое поведение
// не изменилось ни на строку: надзиратель зовёт `startWorker` как звал, без новых слов.
// Подавать их своими нужно ровно одному — проверкам: писать в настоящее хозяйство
// `.night` им нельзя, оно одно на весь рабочий угол. Второй дороги к рождению эти два
// довода не открывают: счёт идёт ВСЕГДА, они лишь говорят, в какой тетради он ведётся.
cfg = readJson(configPath()) ?? {},
attemptsPath = spawnAttemptsPath(),
}) {
// Не голое имя и не голый путь: `claudeLaunch` отвечает ещё и на вопрос «а запустится ли
// найденное». Без него `.cmd` даёт EINVAL, и работник не рождается вовсе.
const launch = claudeLaunch();
const args = [...launch.prefixArgs, ...buildArgs({ prompt, settingsPath, sessionId, model, addDirs })];
// Окружение — урезанным списком, а не наследованием. Иначе работник получил бы все
// боевые ключи из переменных среды запускающего, а файловый замок этого не увидел бы.
// 🔴 Считается ЗАРАНЕЕ, до счёта: без имени своей базы `workerEnv` падает, и этот отказ
// денег не стоит — записывать его в счёт значило бы украсть у владельца рождение.
const env = workerEnv({ dbName });
// 🔴 СТОРОЖ СКЛЕЙКИ — ЗДЕСЬ, ДО СЧЁТА: этот отказ денег не стоит, а рождение, которое
// всё равно умрёт молча через минуту, стоит ночи работы. Поручение работника многострочное,
// а путь к его настройкам лежит в каталоге с пробелом — оба довода склейку не переживут.
zapretSkleyki({ shell: launch.shell, args, gde: 'Рождение работника' });
// 🔴🔴 ВОРОТА СЧЁТА. Пропустят — родим, откажут — бросят человеческой строкой, которую
// надзиратель уже умеет ловить (`worker_spawn_failed`) и кладёт словами в утреннюю сводку.
return rozhdenieSoSchyotom({
cfg,
attemptsPath,
kto: sessionId ?? null,
rodit: () => {
// detached: false — работник обязан быть ребёнком НАДЗИРАТЕЛЯ. Отвяжи его,
// и надзиратель перестанет видеть и его поток, и его смерть, а значит перестанет
// исполнять пределы. Отвязанным живёт надзиратель, не работник.
const child = spawn(launch.command, args, {
cwd,
detached: false,
windowsHide: true,
shell: launch.shell,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
return child; // сырой ребёнок: поток читает надзиратель, он же пишет замеры и итог
},
});
}