Files
portal/tools/night/cli.mjs
T
Дмитрий 6689e110d8 круг замкнулся: владельцу есть чем ответить, и ответ доезжает до работника
Задачи 4 и 8 куска 5. Вместе с прежним коммитом (задачи 2 и 3) мост работает по кругу:
работник спросил → вопрос вышел в общий список → владелец ответил командой → ответ лёг
работнику в ящик, туда, куда ему велено смотреть его же поручением.

ЗАДАЧА 4 — ОТВЕТ ДОЕЗЖАЕТ (`deliverAnswers`):
· чужой ответ не берётся НИ ПРИ КАКОМ СЛУЧАЕ — «да» владельца про деньги, ушедшее не тому
  работнику, самое дорогое, что тут может случиться;
· уже доставленный второй раз не кладётся, но сверяется САМ ОТВЕТ, а не номер: владелец
  может ответить второй раз, и это новость. Иначе работник бросал бы работу каждый круг;
· прежние ответы остаются на месте — работник читает ящик целиком;
· 🪤 `repliesPath` из договора о стыках НЕ принят: правило хозяйства прямее договора —
  «параметр принимается только вместе с зовом», журнал ведёт задача 8. Названо вслух.

ЗАДАЧА 8 — ВЛАДЕЛЬЦУ ЕСТЬ ЧЕМ ОТВЕТИТЬ (`cli.mjs answer`):
· без доводов ПОКАЗЫВАЕТ ждущие вопросы с номерами, работником, пунктом и временем.
  🔑 Владелец не программист: без этого списка ему пришлось бы искать номер в файле руками,
  а номер обязателен — иначе ответ уедет не туда;
· ответ правит строку НА МЕСТЕ и под тем же замком, что подъём вопросов: сквозной номер
  считается по числу строк, лишняя строка увела бы следующий вопрос на чужой номер;
· повторный ответ на тот же вопрос не запрещён, но назван вслух словом «ПЕРЕПИСАН»;
· текст берётся всем хвостом доводов — человек пишет «нет, не сноси», оболочка разложит
  это на три слова.

🔴 ЧТО ЧЕСТНО ОСТАЛОСЬ НЕДОДЕЛАННЫМ В ЗАДАЧЕ 8: команды `ack` нет, и журнал
`owner-replies.jsonl` этой командой не ведётся — `recordOwnerAck` по-прежнему «зовут только
проверки». Записано прямо в шапке `mailer.mjs`, чтобы следующая смена не сочла сделанным.

🟢 ТРИ ЧУЖИХ СТОРОЖА ПОКРАСНЕЛИ ПО ДЕЛУ И БЫЛИ ПРАВЫ:
1. `primechanie-ne-rashoditsya-s-pravdoy` дважды: шапки `questions.mjs` («ЭТОТ ФАЙЛ
   НЕ ПОДКЛЮЧЁН НИ К ЧЕМУ») и `mailer.mjs` («ПИСАТЬ ИХ НЕЧЕМ») два месяца говорили правду,
   а сегодня стали ложью. Обе переписаны — с честным перечнем, что подключено, а что нет.
   ⇒ Ровно правило 263: у изменившегося утверждения спроси, ГДЕ ОНО ЕЩЁ ЗАПИСАНО.
2. `cli.test.mjs` пересчитал команды: было семь, стало восемь. Обновлён состав и названо,
   что `answer` НЕ платная — работника не заводит, денег не тратит.

🪤 Красное по занятой машине встретилось снова: `paths.test.mjs` упал в полном прогоне
и дал 118 зелёных при одиночном перемере. Сначала перемерь, потом чини.

Прогон: 3009 проверок в 114 файлах, код 0 — ровно предсказанное (было 2998, +11 моих).

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

2076 lines
186 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.
#!/usr/bin/env node
// Точки входа. `mail` зовёт планировщик Windows в 8:00, остальное — помощник по слову
// владельца. Кусок 1 знал четыре команды из одиннадцати; кусок 2 добавил ещё три
// (`budget`, `seal`, `approve-spec`) — итого семь. Остальные заводят куски 3–7.
//
// 🆕 Правка К1 (О-2): логика вынесена в экспортируемые функции (`main`, `runMailCommand`,
// `collectSummary`, `sweepNow`) с внедряемыми зависимостями — иначе проверить кусок 1
// нечем: раньше это был голый скрипт, который на одном чтении настроек уже дёргал
// `process.exit`. Точка входа (низ файла) зовёт `main()` только когда файл запущен
// САМ, а не подключён проверкой как модуль — `import.meta.url` совпал бы с путём
// запуска только в первом случае.
import { join, resolve, dirname } from 'node:path';
import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { readJson, readLines, appendLine, writeJsonAtomic } from './state.mjs';
import {
REPO_ROOT, configPath, mailFailuresPath, summaryLinesPath as summaryLinesPathOf,
lastRunPath, summarySentPath, priyomkaDir, runsDir, runDir,
budgetMeasuresPath, probeAttemptsPath, planSealPath, specApprovalPath,
NIGHT_ROOT, samePath, klyuchSravneniyaPuti, questionsPath,
} from './paths.mjs';
// 🔴 Кусок 5, задача 8 — вход владельца. Правило правки строки живёт в `questions.mjs`,
// здесь только зов и разговор с человеком: своей второй копии счёта номеров тут нет.
import { answerQuestion } from './questions.mjs';
import { podZamkom } from './file-claims.mjs';
import { buildSummary } from './summary.mjs';
// 🔴 Утренняя сводка ВЛАДЕЛЬЦУ — второй слой поверх `summary.mjs`, а не замена ему.
// `buildSummary` строит текст МАСТЕРУ (улики, пути, машинные отказы), `utro.mjs` — текст
// человеку, который не знает ни одного программистского слова. Почта отменена, значит файл
// остался единственной дорогой до владельца, и класть его должен тот, кто просыпается сам.
// 🪤 Ввозится ОДНОЙ строкой и ОДИН раз: три дня назад второй ввоз того же имени валил `node`
// ещё до первой строки работы, и ночь не запускалась трое суток при зелёных проверках.
import { sobratDovody, zapisatUtro, utroPath, zhivyePutiPoter } from './utro.mjs';
import { makeTransport as makeTransportDefault, sendSummary as sendSummaryDefault } from './mailer.mjs';
import { planRun, startAll, smokeLongRun } from './run.mjs';
import { sweepStaleWorkspaces } from './workspace.mjs';
// 🔴 `SVETOFOR_RU` — единственное место, где три цвета названы по-русски. Ввозится, а не
// переписывается: свой второй перевод разъехался бы с первым молча.
// 🔴 `chuzhoeOknoZelenoe`, `imyaOkna`, `vidPredela` и имя разового ключа ввозятся В ТУ ЖЕ
// строку: правило «когда разовый ключ владельца действует» живёт ОДНИМ куском в budget.mjs
// и здесь только зовётся. Своей копии условия в точке входа нет намеренно — вторая такая же
// проверка разъехалась бы с первой молча, и одна денежная дверь начала бы открываться там,
// где вторая закрыта.
import {
budgetState, recordMeasure, zapisatOplachennoe, SVETOFOR_RU, potolokIzNastroek,
chuzhoeOknoZelenoe, imyaOkna, vidPredela, KLYUCH_RAZRESHENIYA_VLADELTSA,
} from './budget.mjs';
// 🔴 К платному замеру остатка точка входа ходит ТОЛЬКО через эти ворота: они считают
// сделанное за смену (владелец разрешил до 30) и отказывают на пределе. Прямой зов
// `probeUtilization` отсюда убран намеренно — сторож `platnyy-zamer-tolko-cherez-schyot`
// покраснеет, если его вернут.
import { zamerSoSchyotom } from './schyot-zamerov.mjs';
import { freeGb, diskState, porogMesta } from './disk.mjs';
import { sealPlan, checkSeal, assertPlanEditable } from './plan-fingerprint.mjs';
// 🔴 Разбор каталога прогонов «пустота против порванного» в затее ОДИН, и живёт он у замка
// на правку плана (`readRuns`, plan-lock-hook.mjs) — там этот класс беды закрыли первым.
// Своего второго обхода каталога здесь не заводим: две копии одного правила расходятся молча,
// а расходятся они ровно в ту ночь, когда запись порвалась.
import { readRuns } from './plan-lock-hook.mjs';
// 🔴 Слово `"status": "running"` в карточке — это НАМЕРЕНИЕ, а не жизнь. Кто вправду идёт,
// спрашивается у прибора: он ищет надзирателя прогона и спрашивает о нём саму систему.
// Допущение прибора и три случая, где он ошибётся, названы в шапке его файла.
import { sostoyanieProgona, schitatZanyatym, zhivLiNomer } from './zhiv-li-progon.mjs';
import { specApproved, papersCommitted, paperPathsFor } from './paper-gates.mjs';
// 🔴 Три тихие части точки входа живут отдельным файлом и проверены двадцатью семью
// проверками: разбор доводов, раскладка ответов правил по кормушкам ворот и строки отказа.
// Здесь их только зовут — иначе проверки сторожили бы код, которого программа не исполняет.
// 🪤 `zamerDovody` отсюда убран: доводы замера собирают теперь ворота счёта
// (`schyot-zamerov.mjs`) — там же, где зовётся сам замер. Оставленный тут мёртвый ввоз
// выглядел бы как «точка входа всё ещё умеет ходить к деньгам сама».
import {
razborDovodov, vhodVorot, strokiOtkaza, vyborProdolzhaemogo, strokiPriborov,
} from './cli-parts.mjs';
// ━━━ КНОПКА «ПРОДОЛЖИТЬ»: десять правил, у которых до этой минуты не было ни одного зовущего.
// 🔴 Ни одного из них здесь не переписано и не продублировано — только позвано.
import { resumePoint, resumePlan, rabotaPosleZachtennogo, sledOtkata } from './resume.mjs';
import { resumeChecks } from './limits.mjs';
// 🔴 `zavestiSudyuNaPustuyuNoch` ввозится, а не пишется здесь: живое рождение головы
// разрешено поимённо трём файлам, и `cli.mjs` среди них нет. Заведи мы дорогу тут —
// ворота счёта рождений владельца считали бы не все рождения за ночь.
import { otkatDoKommita, closedItemsOfRun, zavestiSudyuNaPustuyuNoch } from './supervisor.mjs';
import { gryazenLiUgol } from './item-audit.mjs';
import { zavestiProdolzhenie } from './run.mjs';
import { readJsonOrBroken } from './state.mjs';
// 🔴 Замки на файлы — ТЕ ЖЕ, что у обычного прогона. Своего второго способа занимать файлы
// кусок 6 не заводит: две дороги к одному замку разъезжаются молча, и за смену это находили
// трижды. `claimFiles`/`releaseClaims` — внутри прогона, `filesDisjoint` — МЕЖДУ прогонами.
import { claimFiles, releaseClaims } from './file-claims.mjs';
// 🔴 `startGates` и `GATE_ORDER` — ЕДИНСТВЕННОЕ место, где записан состав, порядок и правила
// девяти ворот. Ввозятся сюда ради бесплатного предпрохода ПЕРЕД деньгами (см. ниже
// `besplatnyeVorotaDoDeneg`): второго списка ворот в этом файле не заводится ни в каком виде —
// две копии одного состава разъезжаются молча, и разъедутся ровно в ту ночь, когда добавят
// девятые ворота и забудут дописать их сюда.
import { filesDisjoint, startGates, GATE_ORDER } from './gates.mjs';
// 🔴🔴🔴 ЗДЕСЬ СТОЯЛ ВТОРОЙ ВВОЗ `mkdirSync`, И ИЗ-ЗА НЕГО ТОЧКА ВХОДА НОЧИ НЕ ЗАПУСКАЛАСЬ
// ВООБЩЕ — с 03.08.2026 10:34 (коммит `215c2258a`) по 04.08.2026. `mkdirSync` уже ввезён
// строкой 13, а два ввоза одного имени в одном файле — это отказ разбора: `node` падает
// на нём ещё до первой строки работы.
//
// 🔴 ПОЧЕМУ ЭТОГО НЕ ЗАМЕТИЛИ ЧЕТЫРЕ СМЕНЫ. Прибор проверок разбирает файлы СВОИМ
// разборщиком, и тот двойной ввоз проглатывает молча. Поэтому 2139 проверок были зелёными,
// а `node tools/night/cli.mjs` отвечал `SyntaxError: Identifier 'mkdirSync' has already
// been declared`. Зелёные проверки и живая программа расходятся ровно на этом: они читают
// один и тот же файл РАЗНЫМИ разборщиками.
//
// ⇒ Сторож на это — `tochka-vhoda-zapuskaetsya.test.mjs`: он зовёт точку входа НАСТОЯЩИМ
// `node`, тем же способом, каким её позовёт ночь. Ни одна проверка внутри прибора этого
// класса поймать не может — только живой запуск.
import { randomUUID } from 'node:crypto';
// 🔴 ВОРОТА ПЛАНА И ПРАВИЛО «ЧЕМ МЕРИТЬ ПУНКТ» — оба из `plan-model.mjs`, и оба нужны ДВОИМ:
// командной строке (здесь) и надзирателю (задача 5). Второй дороги к ним не заводить.
// 🔴🔴 Долг, закрытый здесь: до этой строки `validatePlan` не звал в рабочем коде НИКТО —
// правило было написано, проверено 44 проверками и не подключено к делу ни одним зовом.
// План с повторяющимися номерами пунктов, план без единого пункта, план с мерным прибором
// в `files=` — все уходили в ночь молча.
import { validatePlan, komandaProverokPunkta } from './plan-model.mjs';
// Ворота возврата отложенной задачи (проверки 28, 29, 30).
import { deferredTaskGate } from './items.mjs';
// 🔴 Путь к следу знает ОДИН файл на всю затею. Своего второго каталога следов здесь
// не заводить ни в каком виде: разойдись они хоть на знак — след лёг бы в одно место,
// а искался в другом, и отложенная задача подхватилась бы назавтра САМА.
import { readDeferred, clearDeferred, deferredDir } from './deferred.mjs';
// 🔴🔴 СТОРОЖ, У КОТОРОГО ДО ЭТОЙ СТРОКИ НЕ БЫЛО НИ ОДНОГО ЗОВУЩЕГО (найдено датчиком
// `schyot-zovushchih.mjs`, задача О-3, 03.08.2026). Сверяет `night.config.json` с решениями
// владельца из протокола `docs/grilling/2026-08-02-nastroyki-nochi-i-ramka-deneg.md`. Он готов,
// проверен 23 своими проверками и живёт нетронутым — здесь его только зовут, самого не чиним.
import { sverkaIliBrosok } from './sverka-nastroek-s-resheniyami.mjs';
function flag(argv, name) {
const i = argv.indexOf(name);
return i > -1 && i + 1 < argv.length ? argv[i + 1] : null;
}
// 🔴 ДВОЕ ВОРОТ ПОКА НЕ СТОРОЖАТ. Было четверо; двоих (потолки задач и работников,
// пересечение задач по файлам) кусок 3 дописал, и они сторожат по-настоящему.
// Оставшимся двоим правило пишет кусок 7; до тех пор они пропускают всё — иначе живую ночь
// нельзя было бы запустить, и владелец не увидел бы работающую затею до самого конца.
// Решение владельца: пропускать — но говорить об этом вслух, каждый раз, поимённо.
// Молчаливо пропускающие ворота — это самообман: человек видит «все ворота пройдены»
// и думает, что его сторожат девятеро.
const VOROTA_POKA_NE_STOROZHAT = [
'отклик владельца',
'от прошлого прогона никого',
];
// 🔴🔴 ДОВЕДЕНО ДО ПОСЛЕДНЕЙ МИЛИ (задача О-5, 03.08.2026). Раньше эта строка печаталась
// ТОЛЬКО на удаче («Прогон заведён…») — ниже по файлу, у команды `run`. На отказе (ворот,
// плана, отложенной задачи и так далее) владелец её не видел НИ РАЗУ, а отказов на пути
// к рабочему плану куда больше, чем удач. Значит про недостроенные двери он узнавал
// только в ту ночь, когда всё наконец завелось, — и ни разу раньше. Экспортирую функцию,
// чтобы её слова проверялись напрямую, а не только чтением исходника.
export function stroka_pro_nestorozhashchie() {
return `Эти ворота пока НЕ сторожат (их правило пишет кусок 7, сейчас они пропускают всё): ${VOROTA_POKA_NE_STOROZHAT.join(' · ')}.`;
}
// ━━━ 🔴🔴 ВОРОТА СВЕРКИ НАСТРОЕК С РЕШЕНИЯМИ ВЛАДЕЛЬЦА — ЗОВУЩИЙ ДЛЯ
// `sverka-nastroek-s-resheniyami.mjs` (задача О-3, 03.08.2026).
//
// 🔴 ДО ЭТОЙ СТРОКИ У СТОРОЖА НЕ БЫЛО НИ ОДНОГО ЗОВУЩЕГО: построен, проверен 23 своими
// проверками, зелёный — и ночь его не спрашивала НИ РАЗУ (датчик `schyot-zovushchih.mjs`).
// Правило метода 2: защита, которую никто не зовёт, не бывает проверенной; затея заплатила
// за это шесть раз.
//
// 🔴 ЧИТАЕТ НАСТОЯЩИЙ ФАЙЛ ЗАНОВО, а не довод `cfg`, которым живёт остальной `main()`.
// Причина не лень, а сознательный выбор: `cfg` в проверках команды `run` — подставной
// у ДЕСЯТКОВ проверок (испытывают другое, воротам счёта и ёмкости этого достаточно),
// и требовать спавн- и проб-бюджет владельца от каждой из них означало бы менять правку,
// которая испытывает совсем другое. В бою разницы нет: `readConfig()` по умолчанию читает
// тот же `configPath()`, что и здесь.
export function proverkaNastroekVladeltsa({
sverka = sverkaIliBrosok,
chitatNastoyashchiyFayl = () => readJson(configPath()),
// ━━━ 🔴🔴 «СЕЙЧАС РОДЯТСЯ ПЛАТНЫЕ РАБОТНИКИ» — ДОВОД, КОТОРОГО ЗДЕСЬ НЕ БЫЛО ВОВСЕ ━━━
//
// Правило разбора ветки (`razborVetkiNochi`) разводит два случая нарочно: лежащий долг
// говорится СЛОВАМИ (держать смену в вечном красном за долг, который ей не закрыть, —
// наказание не по адресу), а вот-вот заводимые платные работники — КРАСНЫМ (решение
// владельца 25: отставшая ветка означает, что семеро получат код, который не запускается).
//
// 🔴 ЗАМЕРЕНО НАДЗИРАТЕЛЕМ 05.08.2026, И ЭТО БЫЛА ДЫРА ЦЕЛИКОМ. Обёртка звала сверку
// БЕЗ доводов, а обе боевые дороги звали обёртку тоже без доводов — значит признак живого
// запуска не ставился НИКОГДА, и красная половина правила не срабатывала ни разу. Сторож
// умел останавливать ночь и не останавливал её ни при каком отставании.
//
// 🪤 И слова, в которые он падал взамен, уходили в никуда: их печатали в окно помощника,
// а ночь приходит из задания Планировщика, где окна нет. Тот же класс, что и три беды
// утренней бумаги этой же смены, — правило работает, а до человека не доезжает.
//
// 🔴 УМОЛЧАНИЕ `false` — НАРОЧНО. Сверку зовут и те, кто живых работников не заводит
// (разбор настроек глазами, проверки). Поставь умолчанием `true` — и всякий, кто просто
// посмотрел настройки, вставал бы в красное за чужой лежащий долг. Признак ставит тот,
// кто вправду сейчас платит.
zhivoyZapusk = false,
} = {}) {
// Файла нет или он порван — `readJson` отдаёт `null` в обоих случаях (state.mjs).
// Пустой объект здесь — это НЕ «пропускаем»: сверка сама увидит все решения владельца
// отсутствующими и упадёт вслух по каждому. «Сторож сам сломался» тут не молчит,
// а становится громким и понятным отказом тем же приёмом, каким сверка уже умеет
// отвечать на пропавшую настройку.
return sverka(chitatNastoyashchiyFayl() ?? {}, { zhivoyZapusk });
}
// ━━━ 🔴🔴 ВОРОТА ПРИБОРА ЗАМЕРА — ИСПРАВЕН ЛИ ПРИБОР. ПРО ДЕНЬГИ ЗДЕСЬ НИ СЛОВА.
//
// 🔴🔴 ПЕРЕИМЕНОВАНО 03.08.2026, И ЭТО НЕ КОСМЕТИКА. Дверь звалась «ворота денег»
// (`vorotaDeneg`, довод `dengiNaZapusk`) — а спрашивает она ровно две вещи: удался ли замер
// и пришло ли в нём число. ХВАТАЕТ ЛИ ЗАПАСА она не спрашивает и никогда не спрашивала.
// Из-за имени прошлая смена врезала её пробе, записала в передачу «денежные ворота у пробы
// стоят» и закрыла задачу — а проба запустилась при израсходованных 0,76 против рамки
// владельца 0,714 и завела платного работника. Имя обещало деньги, делало прибор, и разницы
// не видел никто, включая сторожа. Теперь дверей ДВЕ и зовутся они по-разному: эта — про
// прибор, `vorotaZapasa` ниже — про деньги владельца. Одинаково назвать их больше нельзя.
//
// ━━━ ОДНА ДВЕРЬ ДЛЯ ВСЕХ, КТО ЗАВОДИТ ЖИВОГО ПЛАТНОГО РАБОТНИКА
// (задача О-4, 03.08.2026, находка третьего помощника со стороны).
//
// ДО ЭТОЙ ПРАВКИ разбор ответа замера — что считать отказом, что успехом, какими словами
// это сказать владельцу — лежал ГОТОВЫМ КУСКОМ КОДА ТОЛЬКО внутри `run`. У `smoke-longrun`
// (проба «работник живёт и через три часа», того же класса трат, что и обычный прогон) такой
// двери не было вовсе: единственная команда затеи БЕЗ денежных ворот оказалась ровно той,
// которой собирались делать первое живое рождение работника. Завести `smoke-longrun` своей
// копией этого куска значило бы построить ВТОРОЕ правило одного смысла — а два правила
// одного смысла расходятся молча, тем же классом беды, что уже нашёлся у `readRuns`
// и у замка на файлы (см. заметки в шапке файла, «две копии одного правила»). Поэтому кусок
// вынесен СЮДА, и `run` теперь тоже зовёт его вместо своей прежней копии — дверь одна
// на обоих зовущих, а не по одной на каждого.
//
// 🪤 `otkaz` — только слова владельцу («Прогон НЕ запущен» / «Проба НЕ запущена»): само
// правило (что считать отказом, что — успехом) от этих слов не зависит и не разветвляется.
// ━━━ 🔴🔴 ЗАПИСЬ ЗАМЕРА ПЕРЕЕХАЛА СЮДА (круг Б, 04.08.2026). ЗАКРЫТ КЛАСС, А НЕ СЛУЧАЙ.
//
// 🪤 ПРЕЖДЕ ЗДЕСЬ БЫЛО НАПИСАНО ОБРАТНОЕ: «запись успешного замера в общий журнал сюда
// не входит намеренно — у каждого зовущего свой `phase`». Довод был верный, а последствие
// дорогое: между тем мигом, когда деньги уже потрачены, и тем, когда зовущий доберётся
// до `recordMeasure`, лежало окно. Любой обрыв в нём (падение, убитый процесс, брошенное
// исключение по дороге) терял ОПЛАЧЕННЫЙ замер навсегда — и терял молча.
//
// Замерено в оплаченных уликах: следов в `.night/probe-raw/` — 16, строк попыток
// в `~/.night-shared/probe-attempts.jsonl` — 19, строк в журнале замеров — 23. Три журнала,
// и ни один не сходится с другими. Двум оплаченным следам (0,14828 $ и 0,03422 $) строки
// в журнале замеров нет вовсе.
//
// 🔴🔴 ЭТО НЕ ПОЧИНКА СЛУЧАЯ 15:39. Механизм той конкретной потери НЕ УСТАНОВЛЕН: попытка
// записана, след записан, замер в следе целый, код того часа записывал безусловно, а соседний
// замер через две минуты при тех же данных записался. Здесь закрыт КЛАСС «обрыв между оплатой
// и записью» — не более того. Кто будет читать это следующим: 15:39 всё ещё ищут, не считайте
// вопрос закрытым.
//
// 🔴 `phase` по-прежнему СПРАШИВАЕТСЯ У ЗОВУЩЕГО, а не угадывается — прежний довод в силе
// и здесь: под каким именем прирост ляжет в общую историю, решает зовущий. Не назвал —
// падаем вслух, до всякой траты.
// 🪤 РЕШЕНИЕ Р2 (наследуется от прежнего места в `run`, не переизобретается заново): причина
// неудачи называется ТА САМАЯ, что пришла от прибора (`z.problem`), а не одна общая строка
// на все случаи — здесь уже однажды платили десятью днями работы за придуманную причину.
export async function vorotaPribora({
cfg, komanda, phase, otkaz = 'Прогон НЕ запущен',
zamer = zamerSoSchyotom,
attemptsPath = probeAttemptsPath(),
// 🪤 Точки впрыска для проверок: без них проверка дороги писала бы в НАСТОЯЩИЙ общий
// журнал замеров `.night/budget-measures.jsonl`, а он один на всех — своя строка там
// сдвинула бы чужой прирост и погасила бы соседний прогон ни за что.
zapisatZamer = recordMeasure,
zhurnalZamerov = budgetMeasuresPath,
// 🔴🔴 РАЗОВЫЙ КЛЮЧ ВЛАДЕЛЬЦА (`KLYUCH_RAZRESHENIYA_VLADELTSA`). Умолчание — ВЫКЛЮЧЕН,
// и это главное его свойство: без ключа эта дверь ведёт себя ровно как прежде, до последнего
// слова отказа. Подаётся он только из `run` и только когда владелец набрал ключ рукой.
// 🪤 Проба (`smoke-longrun`) ключа НЕ получает вовсе: разрешение владельца дано на ночь,
// а не на диагностический инструмент. Расширять его молча нельзя.
resheniyeVladeltsa = false,
err = console.error,
log = console.log,
} = {}) {
// 🔴 Падаем ДО траты: замер, которому некуда лечь под своим именем, платить не должен.
if (!phase) throw new Error('Платный замер зовут, не назвав `phase` («before-run» / «before-smoke» / …). Записать оплаченный замер будет некуда — а платить вслепую нельзя.');
let z = null;
try {
z = await zamer({ cfg, attemptsPath, komanda });
} catch (e) {
err(`${otkaz}: замер остатка не сделан. ${e?.message ?? e}`);
err('Это настройка в вашем night.config.json. Пример строки есть в night.config.example.json.');
return { ok: false };
}
if (!z.ok) {
err(`${otkaz}: замеров остатка за смену больше не осталось.`);
err(z.reason);
return { ok: false };
}
// 🔴🔴 ДЕНЬГИ УЖЕ ПОТРАЧЕНЫ — ЗНАЧИТ ПРИГОВОРЫ ДАТЧИКОВ ГОВОРЯТСЯ ВСЛУХ. Место выбрано
// ровно здесь, ПОСЛЕ отказа по счёту и ДО разбора удачи замера, и оба края намеренны:
// • выше (`!z.ok`) прибор не запускался вовсе — денег не потрачено, приговаривать нечего;
// • ниже замер мог не удаться (`!m`) — но платный запуск ВСЁ РАВНО был, и именно
// неудачный замер интереснее всех: за него заплачено, а показать было нечего.
// Печатай мы только на удаче — датчик молчал бы ровно там, где дороже всего.
for (const line of strokiPriborov(z)) log(line);
// 🔴🔴 ЗАПИСЬ СТОИТ ВЫШЕ ЛЮБОГО РАЗБОРА УДАЧИ (круг B, 05.08.2026). Прежде она стояла НИЖЕ
// и под условием «показание своего вида есть» — и оплаченное показание чужого окна
// выбрасывалось целиком вместе с деньгами. Замерено по оплаченной улике: 03.08.2026 в 15:39
// так сгорели 0,14828025 $, самый дорогой замер тех суток; показание в потоке было целым.
// Правило «заплачено — значит записано» живёт одним куском в `zapisatOplachennoe`
// (budget.mjs); здесь оно только зовётся, второй копии правила тут нет.
// 🪤 Ворота от этого НЕ смягчаются: отказ по несовпавшему виду ниже остался слово в слово.
zapisatOplachennoe({
zamery: z.zamery, zakazan: z.zakazan_vid, path: zhurnalZamerov(), at: Date.now(), phase, zapisat: zapisatZamer,
});
const m = z.measure;
if (!m) {
// ━━━ 🔴🔴 РАЗОВЫЙ КЛЮЧ ВЛАДЕЛЬЦА — ЕДИНСТВЕННАЯ ЩЕЛЬ В ЭТОЙ ДВЕРИ (05.08.2026) ━━━
//
// Правило «когда ключ вообще действует» живёт ОДНИМ куском в `chuzhoeOknoZelenoe`
// (budget.mjs) и зовётся отсюда, а не переписывается здесь заново: тот же кусок судит
// вторую денежную дверь (`budgetState`), и две копии одного условия разъехались бы молча —
// одна дверь начала бы открываться там, где вторая закрыта.
//
// 🔴 ЧТО КЛЮЧ ЗДЕСЬ НЕ СНИМАЕТ. Оба отказа ВЫШЕ (замер не сделан вовсе; замеров за смену
// не осталось) до этой строки не доходят — они уже вернулись. А из того, что доходит,
// ключ берёт ровно один случай: система прислала ДРУГОЕ окно знакомого вида с ЗЕЛЁНЫМ
// светом. Прибор промолчал совсем (`z.zamery` пуст) — дверь закрыта, как и была: пропусти
// мы это, ночь пошла бы при сломанном приборе, не зная ни про одно окно вообще.
//
// 🪤 Показание заказанного вида отсюда НЕ ВЫДУМЫВАЕТСЯ: наверх уходит `measure: null`.
// Подставь мы сюда чужое показание вместо своего — все, кто ниже читает `measure`,
// приняли бы чужой запас за свой, и вранья стало бы больше, а не меньше.
const chuzhoyZelenyy = resheniyeVladeltsa
? (z.zamery ?? []).find(r => chuzhoeOknoZelenoe(r, vidPredela(cfg))) ?? null
: null;
if (chuzhoyZelenyy) {
// 🪤 Слова НЕ собираются из `otkaz`: он про отказ («Прогон НЕ запущен»), а здесь отказа
// нет. Вывернутый наизнанку чужой текст читался бы как отказ ровно там, где дверь открыли.
log(`🔴 Показания заказанного окна нет, и дверь прибора пропущена РАЗОВЫМ КЛЮЧОМ ВЛАДЕЛЬЦА (${KLYUCH_RAZRESHENIYA_VLADELTSA}).`);
log(` · Система прислала ${imyaOkna(chuzhoyZelenyy.kind)} окно, светофор «${chuzhoyZelenyy.status}». Про заказанное окно оно не говорит ничего.`);
log(' · Полные слова о том, чем это грозит, скажет счёт запаса ниже — он же положит их в утреннюю бумагу.');
return { ok: true, measure: null, po_resheniyu_vladeltsa: true, prishlo: chuzhoyZelenyy };
}
err(`${otkaz}: остаток запаса посчитать не удалось.`);
err(` · ${z.problem ?? 'Причина не названа.'}`);
err('Решать о деньгах вслепую нельзя, поэтому запуск не начинается вовсе. Прошлые замеры в журнале запуск не разрешают: они могли устареть за один день.');
err('Что делать: повторить `npm run night:budget` и посмотреть, отвечает ли прибор.');
// 🔴 Ключ набрали, а он не подошёл — говорим об этом прямо. Молчание здесь читалось бы
// как «ключ сломан», и владелец набирал бы его снова и снова, платя за каждый замер.
if (resheniyeVladeltsa) {
err(` · Разовый ключ ${KLYUCH_RAZRESHENIYA_VLADELTSA} здесь НЕ подошёл: он снимает ровно один случай — когда система прислала ДРУГОЕ окно знакомого вида с ЗЕЛЁНЫМ светом. Сейчас это не тот случай.`);
}
return { ok: false };
}
// 🔴 ПЕРВОЕ, ЧТО ДЕЛАЕТСЯ С ОПЛАЧЕННЫМ ПОКАЗАНИЕМ, — ОНО ЗАПИСЫВАЕТСЯ. Ни одной строки кода
// между «деньги потрачены, число в руках» и «строка в журнале» быть не должно: каждая
// такая строка — это ещё один способ потерять оплаченное. 🔴 Сама запись переехала ВЫШЕ
// (см. `zapisatOplachennoe` перед разбором удачи): условие «показание своего вида есть»
// держало её, и на нём сгорели 0,14828025 $. Второй записи здесь нет нарочно — удавшееся
// показание уже легло тем же вызовом, что и любое другое.
return { ok: true, measure: m };
}
// ━━━ 🔴🔴 ВОРОТА ЗАПАСА — ВТОРАЯ ДВЕРЬ, И ОНА ПРО ДЕНЬГИ ВЛАДЕЛЬЦА, А НЕ ПРО ПРИБОР
// (найдено 03.08.2026 живым замером: доля израсходованного 0,76 при светофоре
// `allowed_warning` — правило владельца «запускаем, пока израсходовано меньше порога»
// перейдено, а проба ЗАПУСТИЛАСЬ и завела платного работника).
//
// 🔴 ПОЧЕМУ ОТДЕЛЬНАЯ ДВЕРЬ, А НЕ ДОПИСКА В `vorotaPribora`. `vorotaPribora` спрашивает прибор:
// «замер сделан? число пришло?» — это исправность ПРИБОРА. Хватает ли запаса — вопрос
// к ЖУРНАЛУ замеров, и ответить на него можно только ПОСЛЕ того, как свежий замер в журнал
// лёг (`recordMeasure`). Слить их в одну дверь значило бы считать запас по вчерашней строке
// журнала: `budgetState` читает последний ЗАПИСАННЫЙ замер, а не тот, что держит в руках
// зовущий. Порядок «прибор → запись → запас» у `run` уже такой, здесь он тот же.
//
// 🔴 ПРАВИЛО НЕ ПЕРЕПИСЫВАЕТСЯ. Порог (настройка `start_floor_share`) и весь разбор светофоров живут
// в ОДНОМ месте — `budgetState` в budget.mjs; здесь только читается его приговор
// (`may_start`) и его же словами говорится отказ (`reason`). Второго правила одного смысла
// не заводится — тем же приёмом, каким `vorotaPribora` не копировалась вниз.
//
// 🪤 Р5, СУЖЕНИЕ ЗАЩИТЫ. Когда система прислала светофор без доли, `budgetState` пускает
// (`may_start: true`) и говорит владельцу вслух, что его рамка сейчас не меряется ничем
// (`slova_vladeltsu`). Эти слова печатаются и здесь: у `run` они печатаются, и молчать
// о том же сужении у пробы значило бы, что одно и то же место сторожит по-разному.
export function vorotaZapasa({
budget, otkaz = 'Прогон НЕ запущен',
err = console.error, log = console.log,
} = {}) {
// 🪤 Ответа может не быть вовсе (некому было спросить, зовущий ошибся) — это «неизвестно»,
// а не «можно». Неизвестный ответ зелёным не считается, тем же приёмом, каким незнакомый
// светофор не считается зелёным внутри самого `budgetState`.
if (!budget) {
err(`${otkaz}: запас владельца не посчитан вовсе — ответа от счёта запаса не пришло.`);
err('Решать о деньгах вслепую нельзя, поэтому запуск не начинается.');
return { ok: false };
}
if (!budget.may_start) {
err(`${otkaz}: ${budget.reason ?? 'запаса не хватает, а причина не названа — это тоже отказ.'}`);
return { ok: false };
}
if (budget.dolya_ne_prishla && budget.slova_vladeltsu) log(budget.slova_vladeltsu);
return { ok: true };
}
// ═══════════════════════════════════════════════════════════════════════════════
// 🔴🔴 БЕСПЛАТНЫЙ ПРЕДПРОХОД ВОРОТ — ГЛАВНАЯ ДЕНЕЖНАЯ ТРАТА ЗАТЕИ (круг Б, 04.08.2026).
//
// ━━━ ЗАМЕРЕНО ПО СЛЕДУ ОПЛАТЫ, А НЕ ПОСЧИТАНО «СОБЫТИЙ × СРЕДНЮЮ ЦЕНУ» ━━━
// В `.night/probe-raw/` лежит по файлу на КАЖДЫЙ платный запуск прибора, и в каждом —
// строка `total_cost_usd`. Три замера «перед прогоном», после которых старта не случилось:
// 19:31:36 → 0,03505 $ · 21:54:07 → 0,03409 $ · 22:53:59 → 0,03768 $ = 0,10682 $
// Против 0,00767 $ на удвоении запуска, которое чинили кругом Б до этого (два дешёвых
// замера в тёплом кэше: 0,00408 и 0,00360). То есть эта беда дороже той в ЧЕТЫРНАДЦАТЬ раз.
// 🪤 Цена замера НЕ постоянна: те же 15 запусков стоили от 0,0033 до 0,1483 $ за штуку —
// умножать среднюю на число событий здесь запрещено, счёт только по следу оплаты.
//
// ━━━ ЧТО ИМЕННО СЛОМАНО ━━━
// Ворот девять, и ТОЛЬКО ОДНИ из них («запас») вообще нуждаются в платном замере. Восемь
// остальных — место на диске, замысел, бумаги, отпечаток плана, потолки задач и работников,
// пересечение по файлам и двое пока не сторожащих — стоят НОЛЬ и отвечают из файлов.
// А в `run` они все девять судились ПОСЛЕ платного шага. Отказ восьмерых бесплатных при
// таком порядке всегда стоил владельцу денег: заплатили и только потом узнали, что нельзя.
//
// ━━━ ПОЧЕМУ ЭТО ОТДЕЛЬНАЯ ДВЕРЬ, А НЕ ПЕРЕПИСАННЫЕ ВОРОТА ━━━
// Правила ворот не переписываются и не копируются: зовётся ТОТ ЖЕ `startGates`, тем же
// входом, только без ответа про деньги — его в эту минуту ещё физически нет. Из приговора
// выкидывается ровно один пункт — денежные ворота: их отказ здесь означал бы не «нельзя»,
// а «ещё не мерили». Все остальные отказы — настоящие, и стоят они ноль.
//
// 🔴 ПОЧЕМУ НЕ ПОДСТАВИТЬ ВОРОТАМ ЗАПАСА ЗЕЛЁНЫЙ ОТВЕТ. Это был бы выдуманный замер —
// ровно тот приём, который затея запрещает себе везде («не спросили значит не знаем»).
// Мы не говорим воротам запаса «всё хорошо»; мы НЕ СПРАШИВАЕМ их вовсе и честно называем
// их единственными, чей приговор здесь не считается.
export const VOROTA_DENEG = 'запас';
export function besplatnyeVorotaDoDeneg({ vhod = {}, vorota = startGates } = {}) {
// 🔴 Довод `budget` затирается в `null` НАРОЧНО, даже если зовущий что-то положил:
// до платного шага никакого ответа про деньги быть не может, а положенный сюда старый
// ответ из вчерашнего журнала — это решение о деньгах по протухшему числу.
const otvet = vorota({ ...vhod, budget: null });
const failed = (otvet.failed ?? []).filter(f => f?.gate !== VOROTA_DENEG);
return {
ok: failed.length === 0,
failed,
unguarded: otvet.unguarded ?? [],
reason: failed.length ? failed[0].reason : null,
};
}
// ━━━ 🔴🔴 ПРАВДА ПРО ЦВЕТ СВЕТОФОРА ЗАПАСА (решение владельца 04.08.2026, дословно:
// «тормозить только на красный, жёлтый — со словами в сводке»).
//
// ━━━ ЧТО БЫЛО СЛОМАНО, ЗАМЕРЕНО В ЖУРНАЛЕ ━━━
// Все замеры недельного запаса приходили ЖЁЛТЫМИ («тревога по запасу»); зелёного затея
// не видела ни разу. А решение о старте, когда число пришло, смотрит ТОЛЬКО на число
// (`utilization < floor`) и цвета не касается вовсе. Значит про тревогу системы владельцу
// не говорил НИКТО. Тормозить на жёлтом он запретил — значит взамен остаётся отчёт,
// и молчать нельзя: молчаливое сужение защиты — тот же самообман, что и молча пропускающие
// ворота.
//
// 🔴 ОТДЕЛЬНОЙ ФУНКЦИЕЙ, А НЕ КУСКОМ ВНУТРИ `run`, — чтобы её слова проверялись прямо,
// а не через живой запуск, который писал бы в настоящее хозяйство владельца.
//
// 🔴 СЛОВА ЦВЕТА БЕРУТСЯ У ЕДИНСТВЕННОГО ИХ ХОЗЯИНА (`SVETOFOR_RU` в budget.mjs), а не
// набираются здесь заново: свой второй перевод трёх цветов разъехался бы с первым молча.
//
// 🪤 Незнакомый цвет — НЕ повод промолчать. Молчание читается владельцем как «всё зелено»,
// а это ровно тот случай, когда система изменилась и об этом надо знать первым делом.
export function strokaProSvet(status) {
// Зелёный — единственный, о котором говорить нечего: он и есть «всё в порядке».
if (!status || status === 'allowed') return null;
const slovami = SVETOFOR_RU[status] ?? `система прислала цвет «${status}», которого затея не знает`;
return `Светофор запаса был НЕ зелёный: ${slovami}. Прогон всё равно пошёл — так решили вы: тормозим только на красном.`;
}
// Что владелец увидит при отказе бесплатных ворот. Отдельные слова, а не `strokiOtkaza`:
// там последней строкой говорится, когда обновится недельный запас, — а здесь про запас
// не спрашивали вовсе, и такая строка была бы враньём о причине отказа.
export function strokiOtkazaDoDeneg(prigovor) {
const out = ['Прогон НЕ запущен. Не пройдено (проверено бесплатно, ДО замера остатка):'];
for (const g of prigovor?.failed ?? []) out.push(` · ${g?.gate ?? 'ворота'}: ${g?.reason ?? 'Причина не названа.'}`);
out.push('Денег владельца это не стоило ни копейки: платный замер остатка не запускался.');
return out;
}
// 🔴 Место мерится по каталогу ХОЗЯЙСТВА, а не по текущему каталогу: у задачи планировщика
// рабочий каталог системный, и она мерила бы не тот диск.
async function diskNow(cfg = {}) {
// 🔴 Порог больше не плоское число: он считается от числа работников (`porogMesta`).
// Прежнее `cfg.disk_free_min_gb ?? 5` было слепо к тому, что углов собирается семь.
return diskState(await freeGb(REPO_ROOT), porogMesta({
rabotnikov: cfg.max_workers_per_task,
zapasGb: cfg.disk_free_min_gb,
}));
}
// Какие прогоны идут прямо сейчас. Нужно дважды: чтобы не дать перепечатать план идущего
// прогона (проверка 43) и чтобы честно сказать воротам ёмкости, сколько задач в работе.
//
// 🔴🔴 ПОПРАВЛЕНО ПО ЖИВОЙ БЕДЕ. Прежде здесь стоял плоский `readJson`, а он на порванном
// файле отдаёт `null` — и `.filter(Boolean)` убирал такой прогон из счёта МОЛЧА. Ворота
// порвали одну запись и тут же завели третью задачу при потолке в две, а следом задачу
// на файл, который порванный прогон держит: оба отказа исчезли без единого слова.
// Порванная запись — это «неизвестно, идёт он или кончился», а не «такого прогона нет».
//
// 🔴 Второго способа читать карточки прогонов НЕ ЗАВОДИМ. Прибор «пустота против порванного»
// в затее один — `readJsonOrBroken` в state.mjs, — и разбор каталога прогонов этим прибором
// уже написан: `readRuns` в plan-lock-hook.mjs (замок на правку плана, тот же класс беды
// и то же решение — «не смогли прочесть, значит замок ЗАКРЫТ»). Зовём его. Свой третий
// обход `readdirSync` рядом разъехался бы с ним молча.
//
// Каталог берётся доводом только ради проверок: живые зовущие зовут без довода и работают
// по настоящему `runsDir()`.
export function runningRuns(dir = runsDir()) {
const vse = readRuns(dir);
const porvannye = vse.filter(r => r?.broken);
if (porvannye.length) {
throw new Error(
`Карточку прогона прочесть не удалось: ${porvannye.map(r => r.card_path ?? r.run_id).join(', ')}. `
+ 'Файл на месте, но разобрать его нельзя. Считать это «такого прогона нет» нельзя: так потолок '
+ 'задач и замок на файлы откроются всем именно в ту ночь, когда запись порвалась (а рвётся она '
+ 'чаще всего оттого, что на сервере кончилось место посреди записи). Посмотрите этот файл глазами: '
+ 'прогон кончился — поправьте или удалите карточку, прогон идёт — остановите его командой «стоп всё».',
);
}
return vse.filter(r => r && r.status === 'running');
}
// 🔴 ОДНА ДВЕРЬ НА ОБОИХ ЗОВУЩИХ. Счёт идущих прогонов спрашивают двое — `seal` и `run`, —
// и обоим нужен ОДИН ответ на порванную запись: сказать владельцу строку и не запускаться,
// а не обвалиться следом стека. Два `try` по месту разъехались бы молча: поправят один,
// забудут другой. Довод `chitat` — точка впрыска для проверок, живые зовущие зовут без него.
// 🔴🔴 ЗДЕСЬ ЖЕ — ВТОРАЯ ПОЛОВИНА ОТВЕТА, ДОБЫТАЯ ЖИВЫМ ЗАМЕРОМ 03.08.2026. Карточка говорит
// `"status": "running"` — и до этой минуты ей верили на слово. А слово это ставится один раз,
// при заведении прогона, и снимать его некому, если работа оборвалась: убитый процесс своей
// карточки не правит. Замерено: два прогона висели `running` восемнадцать часов, их процессов
// давно не было, — и затея отказывала владельцу словами «сейчас идёт задач: 2, а потолок — 2»
// и «новая задача задевает те же файлы, что уже идущая». Обе строки — правда про БУМАГИ и
// ложь про хозяйство: не шло ни одной. Любой обрыв (за прошлую смену их было три) занимал
// место в потолке и держал замок на своих файлах НАВСЕГДА.
//
// Поэтому ответ теперь РАЗЛОЖЕН, а не слит в одно число:
// running — те, кем место вправду занято (идут, только заведены, либо не прочлись);
// mertvye — помечены идущими, а никого нет: места не занимают и файлов не держат;
// neyasnye — прочесть не удалось: считаются занятыми, но названы отдельно.
// 🔴 Сваливать «мёртв» и «не прочесть» в одну строку нельзя: прошлым сменам такой слив стоил
// двух дней поиска не в ту сторону. Мёртвого лечат уборкой карточки, непрочитанного — глазами.
//
// 🔴 ОДНА ДВЕРЬ НА ОБОИХ ЗОВУЩИХ. Счёт идущих прогонов спрашивают двое — `seal` и `run`, —
// и обоим нужен ОДИН ответ на порванную запись: сказать владельцу строку и не запускаться,
// а не обвалиться следом стека. Два `try` по месту разъехались бы молча: поправят один,
// забудут другой. Довод `chitat` — точка впрыска для проверок, живые зовущие зовут без него.
// ═══════════════════════════════════════════════════════════════════════════════
// 🔴🔴 ДВЕ ДВЕРИ ПЕРЕД ДЕНЬГАМИ (04.08.2026). ВТОРОЙ ЗАПУСК ПЛАТИЛ.
//
// ━━━ ЗАМЕРЕНО В ЖИВОМ ЖУРНАЛЕ, А НЕ ПРЕДПОЛОЖЕНО ━━━
// `.night/budget-measures.jsonl`, время московское: дважды замер «перед прогоном» лёг
// ПОСЛЕ старта — на 8 секунд (22:55:37 при старте 22:55:29) и на 5 (00:29:40 при 00:29:35).
// Одному процессу это недоступно: он этот замер уже сделал и ушёл дальше. Значит запусков
// было несколько, и опоздавший ДОХОДИЛ ДО ПЛАТНОГО ЗАМЕРА, а до старта не доходил.
// Цена этих двоих по следам `.night/probe-raw/`: 0,0041 $ и 0,0036 $.
//
// ━━━ 🔴 ГЛАВНОЕ ЗДЕСЬ — ПОРЯДОК, А НЕ САМ ЗАМОК ━━━
// Всё, что могло бы узнать про идущий прогон (потолок задач, пересечение по файлам), стоит
// в `run` ПОСЛЕ платного замера. Защита, стоящая после платного шага, — не защита, а отчёт
// о потере. Поэтому обе двери ниже зовутся ДО `priborZamera`, и ничего платного они не делают.
//
// ━━━ ПОЧЕМУ ДВЕРЕЙ ДВЕ, А НЕ ОДНА ━━━
// Первая (`progonPoTomuZhePlanu`) читает карточки прогонов — но в самый опасный миг карточки
// ещё НЕТ НИ У КОГО: двое запустились в одну секунду, оба видят пустое хозяйство, оба платят.
// Этот миг закрывает вторая — неделимый замок на файле.
// Живой прогон по ТОМУ ЖЕ плану. Не «любой второй запуск»: владелец разрешил две задачи
// разом (`max_tasks_at_once`), и дверь, отбивающая всё подряд, отняла бы у него половину ночи.
//
// 🔴 Пути сравниваются `samePath` — тем же прибором, что и везде в затее. Своего сравнения
// строк здесь нет намеренно: `docs\плана.md` и `docs/Плана.md` — один файл, а строки разные,
// и замок обходился бы опечаткой.
export function progonPoTomuZhePlanu({ running = [], planPath = null } = {}) {
if (!planPath) return null;
for (const r of running ?? []) {
if (samePath(r?.plan_path, planPath)) return r;
}
return null;
}
// ━━━ 🔴🔴 СРОК ЗАМКА — ЭТО НЕ ОСТОРОЖНОСТЬ, А ЛЕЧЕНИЕ ХУДШЕГО ИСХОДА ━━━
// Замок, переживший падение хозяина, ХУЖЕ отсутствия замка: ночь просто не начнётся, и
// владелец узнает об этом утром. Признака жизни номера одного мало — номера система выдаёт
// по кругу (та же оговорка, что названа в шапке `zhiv-li-progon.mjs`), и достанься номер
// чему-нибудь долгоживущему, хозяйство заперлось бы навсегда. Поэтому у замка есть ещё и срок.
//
// Пятнадцать минут взяты не наугад: замок держится РОВНО на пути от замера до старта (замер
// живьём — секунды, ворота — мгновения), и четверти часа хватает с большим запасом. Всё, что
// дольше, — это не работа, а брошенный след.
export const PREDEL_ZAMKA_MS = 15 * 60_000;
// Путь замка — СВОЙ на каждый план: два разных плана запускать разом законно.
// 🪤 Имя собрано из читаемого хвоста И отпечатка. Только хвост (после вычистки знаков) дал бы
// двум разным планам одно имя — и один молча запирал бы другой; только отпечаток нельзя
// прочесть глазом, разбирая хозяйство руками.
// 🔴 ПОЙМАНО ЧУЖИМ СТОРОЖЕМ (`paths.test.mjs`, «приведение пути к одному виду написано ровно
// в одном файле»), и поймано СПРАВЕДЛИВО: сперва я привёл путь к одному виду прямо здесь,
// своей строкой. Это второй текст про одно правило — класс, на котором в этой затее уже
// обжигались дважды (`samePlanPath` у печати и `samePath` у замка). Приведение берётся
// у единственного прибора — `klyuchSravneniyaPuti` в paths.mjs, того же, каким затея
// сравнивает файлы пунктов.
export function putZamkaZapuska(planPath, koren = NIGHT_ROOT) {
const sveden = klyuchSravneniyaPuti(String(planPath ?? ''));
const otpechatok = createHash('sha1').update(sveden).digest('hex').slice(0, 8);
const hvost = sveden.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(-60).toLowerCase() || 'bez-imeni';
return join(koren, 'zamki-zapuska', `${hvost}-${otpechatok}.json`);
}
// Взять замок. Отдаёт `{ vzyal, perehvachen, reason }` и НЕ бросает: упасть здесь значило бы
// не пустить ночь из-за самой защиты.
//
// 🔴 ЧТО ЭТА ДВЕРЬ СМОТРИТ — И ЧЕГО ОНА НАРОЧНО НЕ СМОТРИТ. Решение принимается по двум
// вещам, и ни одну не подаёт тот, кого дверь отбивает: (1) существование файла — его создаёт
// САМА СИСТЕМА неделимо (флаг `wx` — «создать, а если такой уже есть, отказаться»), двое
// разом получить его не могут физически; (2) номер хозяина — его записал ПОБЕДИТЕЛЬ.
// Отбитый до отказа не пишет ни байта.
export function vzyatZamokZapuska({
path, pid = process.pid, now = Date.now(), zhiv = zhivLiNomer, predelMs = PREDEL_ZAMKA_MS,
} = {}) {
const zapisat = () => {
// 🪤 ЗАВЕДЕНИЕ КАТАЛОГА — В СВОЁМ КОШЕЛЬКЕ, И ЭТО НЕ ПРИДИРКА, А ПОЙМАННАЯ ВЫРЕЗАНИЕМ
// ОШИБКА. Сперва оба действия стояли под одним `try`, и обе беды опознавались по одному
// слову `EEXIST`. А `mkdirSync` отвечает тем же словом, когда на месте будущего каталога
// лежит ФАЙЛ, — то есть «замок завести физически негде» читалось как «замок уже занят»,
// и запуск отбивался наглухо, навсегда, без единого живого соперника. Ровно тот исход,
// от которого предостерегает шапка выше: ночь не начнётся, а владелец узнает утром.
try {
mkdirSync(dirname(path), { recursive: true });
} catch (err) {
return { beda: `каталог для замка не заводится: ${err?.message ?? err}` };
}
try {
writeFileSync(path, `${JSON.stringify({ pid, at: now })}\n`, { encoding: 'utf8', flag: 'wx' });
return true;
} catch (err) {
// 🔴 И ЗДЕСЬ `EEXIST` означает РОВНО ОДНО: файл замка уже есть, то есть занято.
if (err?.code === 'EEXIST') return false;
// Замок не заводится вовсе (нет места, нет прав). Пускаем и говорим вслух: остановить
// ночь из-за неработающего замка — это ровно тот вред, ради которого он и не заводится.
return { beda: err?.message ?? String(err) };
}
};
const pervaya = zapisat();
if (pervaya === true) return { vzyal: true, perehvachen: false, reason: null };
if (pervaya !== false) {
return { vzyal: true, perehvachen: false, reason: `Замок запуска завести не вышло (${path}): ${pervaya.beda}. Запуск идёт БЕЗ него — второй запуск сейчас ничем не отбит.` };
}
// Занято. Разбираем, кем именно.
const ustarel = prichinaUstareniyaZamka({ path, pid, now, zhiv, predelMs });
if (!ustarel) {
let chuzhoy = null;
try { chuzhoy = JSON.parse(readFileSync(path, 'utf8')); } catch { /* уже разобрано выше */ }
return {
vzyal: false,
perehvachen: false,
reason: `Прогон по этому плану уже запускают прямо сейчас (процесс ${chuzhoy?.pid ?? '?'}, замок ${path}). `
+ 'Второй запуск остановлен ДО платного замера остатка — денег владельца он не тратит. '
+ 'Дождитесь первого; если он вправду брошен, замок сам протухнет через четверть часа.',
};
}
// Замок протух — перехватываем. 🪤 Перехват сделан НЕ «записать поверх», а «снять и взять
// заново тем же неделимым способом»: реши двое разом, что замок протух, поверх записали бы
// оба и оба пошли бы платить. Так же, как и в первый раз, победит ровно один.
try { unlinkSync(path); } catch { /* кто-то успел снять раньше — тем лучше */ }
const vtoraya = zapisat();
if (vtoraya === true) return { vzyal: true, perehvachen: true, reason: ustarel };
if (vtoraya !== false) {
return { vzyal: true, perehvachen: true, reason: `${ustarel} Перезавести замок при этом не вышло: ${vtoraya.beda}.` };
}
return {
vzyal: false,
perehvachen: false,
reason: `Замок запуска был протухшим (${ustarel}), но его перехватил кто-то другой в ту же секунду. `
+ 'Второй запуск остановлен ДО платного замера — денег владельца он не тратит.',
};
}
// Почему замок считается протухшим. Отдаёт СЛОВА (их читает владелец) либо `null` — «не протух».
// 🔴 Четыре дороги, и каждая поодиночке заперла бы хозяйство навсегда, промолчи мы о ней.
function prichinaUstareniyaZamka({ path, pid, now, zhiv, predelMs }) {
let syroe;
try {
syroe = readFileSync(path, 'utf8');
} catch (err) {
return `замок есть, а прочесть его не вышло (${err?.message ?? err}) — верить такому нельзя, забираем.`;
}
let zamok;
try {
zamok = JSON.parse(syroe);
} catch {
return 'замок порван и не разбирается — забираем: неразбираемый замок иначе запер бы ночь навсегда.';
}
const chuzhoyPid = zamok?.pid;
// Мой же номер в замке — это не «я сам себя запер», а старый след: номер достался мне после
// смерти прежнего хозяина. Без этой ветки запуск отказал бы САМ СЕБЕ, и навсегда.
if (chuzhoyPid === pid) return `в замке стоит мой же номер процесса (${pid}) — это старый след, а не живой хозяин.`;
if (typeof chuzhoyPid !== 'number' || !Number.isInteger(chuzhoyPid) || chuzhoyPid <= 0) {
return `в замке не записан годный номер процесса (${JSON.stringify(chuzhoyPid)}) — спросить у системы не о ком, забираем.`;
}
if (!zhiv(chuzhoyPid)) return `хозяин замка (процесс ${chuzhoyPid}) уже НЕ ЖИВ — забираем.`;
const vzyat = zamok?.at;
if (!Number.isFinite(vzyat)) return 'в замке не записано, когда его взяли, — судить о сроке нечем, забираем.';
const vozrast = now - vzyat;
if (vozrast > predelMs) {
return `замку ${Math.round(vozrast / 60_000)} мин, а срок его — ${Math.round(predelMs / 60_000)}: он давно просрочен, забираем `
+ '(номер процесса система выдаёт по кругу, и один только признак жизни запер бы ночь навсегда).';
}
return null;
}
// Отдать замок. 🔴 ЧУЖОЙ НЕ ТРОГАЕМ: открыть дверь тому, кто её прямо сейчас держит, —
// это ровно та беда, что дверь и лечит.
//
// 🪤 Пропущенная отдача здесь НЕ смертельна, и это решение названо вслух: команда `run`
// кончается вместе со своим процессом, а дверь выше спрашивает у системы, жив ли хозяин, —
// то есть брошенный замок мёртвого запуска перехватывается СЛЕДУЮЩИМ ЖЕ запуском сразу,
// не дожидаясь срока. Отдача нужна для случая, когда `run` зовут дважды в ОДНОМ процессе.
export function otdatZamokZapuska({ path, pid = process.pid } = {}) {
try {
const zamok = JSON.parse(readFileSync(path, 'utf8'));
if (zamok?.pid !== pid) return false;
unlinkSync(path);
return true;
} catch {
return false;
}
}
export function idushchieProgony(chitat = runningRuns, { sudit = sostoyanieProgona, koren = null, now = Date.now() } = {}) {
let vse;
try {
vse = chitat();
} catch (err) {
return {
ok: false,
running: [],
mertvye: [],
neyasnye: [],
reason: `Сколько прогонов идёт сейчас — сосчитать не удалось. ${err?.message ?? err}`,
};
}
// Каталог тот же, из которого читались карточки. Проверки подают свой; живые зовущие — нет.
const papkaProgonov = koren ?? runsDir();
const running = [];
const mertvye = [];
const neyasnye = [];
for (const r of vse) {
const sud = sudit(r, { dir: join(papkaProgonov, String(r?.run_id ?? '')), now });
if (sud.vid === 'mertv') { mertvye.push(sud); continue; }
if (sud.vid === 'ne_prochest') neyasnye.push(sud);
// 🔴 Занятым считается ВСЁ, кроме доказанно мёртвого, — правило одно и живёт в приборе.
// Своей второй копии этого решения здесь нет намеренно.
if (schitatZanyatym(sud.vid)) running.push(r);
}
return { ok: true, running, mertvye, neyasnye, reason: null };
}
// Что сказать владельцу про хозяйство — простым русским и тремя РАЗНЫМИ строками.
// 🔴 Строки нужны не только при отказе: мёртвые карточки владелец обязан увидеть и тогда,
// когда запуск прошёл, — иначе мусор копится молча до ночи, в которую он всё перекроет.
const bolshoyBukvoy = s => (typeof s === 'string' && s.length ? s[0].toUpperCase() + s.slice(1) : String(s ?? ''));
export function strokiProProgony(schyot) {
const out = [];
if (!schyot) return out;
const idut = (schyot.running ?? []).length - (schyot.neyasnye ?? []).length;
if (idut > 0) out.push(`Вправду идёт прогонов: ${idut}.`);
// 🪤 Причину прибор уже написал целой фразой и с номером прогона внутри. Не приписывай
// сюда своей второй половины той же мысли: владелец получал строку с заиканием
// («Прогон X помечен идущим, но НЕ ИДЁТ: прогон X помечен идущим, но…») — замерено глазами
// на живом хозяйстве 03.08.2026. Здесь добавляется ТОЛЬКО последствие, которого прибор
// знать не может: занимает место или нет.
for (const s of schyot.mertvye ?? []) {
out.push(`${bolshoyBukvoy(s.pochemu)}. Место в потолке задач он больше не занимает и файлы не держит.`);
}
if ((schyot.mertvye ?? []).length) {
out.push(`Карточки таких прогонов стоит убрать рукой (${runsDir()}) — сама затея чужие улики не трогает.`);
}
for (const s of schyot.neyasnye ?? []) {
out.push(`Про прогон ${s.run_id} не удалось узнать, идёт он или нет. ${bolshoyBukvoy(s.pochemu)}. Пока не ясно — считаем его идущим и место за ним держим.`);
}
return out;
}
// ━━━ 🆕 СБОРЩИК КАРТОЧЕК РАБОТНИКОВ ПОДНИМАЕМОГО ПРОГОНА ━━━
//
// 🟡 ПОСТРОЕН СВЕРХ ЗАДАЧИ, называю вслух. `vyborProdolzhaemogo` принимает карточки готовым
// списком, а собрать их не поручено ни одному куску (долг М-181, я же его и назвал).
// Без сборщика дорога продолжения не заводится вовсе, поэтому строю здесь.
//
// 🔴🔴 ТРЕТИЙ ОТВЕТ, А НЕ ПУСТОТА. Плоский `readJson` + `.filter(Boolean)` выбросил бы
// порванную карточку МОЛЧА — и работник, которого нельзя поднимать (скажем, уже продолженный,
// у которого порвалось поле `resumed_into`), просто не попал бы в список. Продолжаемым
// оказался бы другой, а отказы «его уже продолжили» и «продолжаемых несколько» тихо
// перестали бы срабатывать, оставаясь зелёными. Это тот же класс и то же лечение, что
// у `readRuns` для карточек прогонов: «не смогли прочесть» — отдельный ответ.
export function kartochkiRabotnikov(runId, dir = runDir(runId)) {
const papka = join(dir, 'workers');
if (!existsSync(papka)) {
return { ok: false, cards: [], reason: `У прогона ${runId} нет каталога работников (${papka}). Проверьте номер прогона.` };
}
const cards = [];
const porvannye = [];
for (const wid of readdirSync(papka)) {
const put = join(papka, wid, 'worker.json');
const otvet = readJsonOrBroken(put);
if (!otvet.found) continue; // карточки нет — работника не заводили
if (otvet.broken) { porvannye.push(wid); continue; }
cards.push(otvet.value);
}
if (porvannye.length) {
return {
ok: false,
cards,
reason: `Карточки работников ${porvannye.join(', ')} прочесть не удалось — файлы на месте, но разобрать их нельзя. `
+ 'Считать это «таких работников нет» нельзя: тогда продолжаемым окажется кто-то другой, а отказы «его уже '
+ `продолжили» и «продолжаемых несколько» промолчат. Посмотрите эти файлы глазами: ${papka}`,
};
}
return { ok: true, cards, reason: null };
}
// Коммит УЖЕ ЗАСЧИТАННОГО пункта. Нужен откату: до какого места возвращать угол.
//
// 🔴 ПРАВИЛО «ЧТО СЧИТАЕТСЯ ЗАСЧИТАННЫМ» ЗДЕСЬ НЕ ПОВТОРЯЕТСЯ. Оно живёт в `closedItemsOfRun`
// (кусок 4: засчитано только то, у чего проверки вправду зелены), и зовущий спрашивает
// СНАЧАЛА его. Сюда приходит уже благословлённый номер пункта, и это место отвечает ровно
// на один вопрос — «каким коммитом он закрыт». Своей проверки `tests_green` тут нет намеренно:
// две копии одного правила разъезжаются молча, и тогда откат поехал бы до пункта, который
// на самом деле не засчитан.
export function kommitZachtennogoPunkta(runDirPath, itemId) {
if (!itemId) return null;
const dir = join(runDirPath, 'workers');
if (!existsSync(dir)) return null;
let nayden = null;
for (const wid of readdirSync(dir)) {
for (const row of readLines(join(dir, wid, 'items-verified.jsonl'))) {
if (String(row?.item) !== String(itemId)) continue;
// Самая поздняя запись про этот пункт: пункт могли пересдавать.
if (!nayden || (row?.at ?? 0) > (nayden.at ?? 0)) nayden = row;
}
}
return nayden?.commit_sha ?? null;
}
// 🔴 Принудительная уборка старых углов случается ЗДЕСЬ — вместе с утренней сводкой,
// потому что это единственное, что просыпается каждый день само. Про каждый убранный угол
// в сводку идёт отдельная строка: тихо уносить чужую работу нельзя.
//
// 🆕 Долг перед куском 6 (решение владельца 126 от 01.08.2026): угол работника со статусом
// `stalled` или `extinguished` и БЕЗ отметки `resumed_at` эта уборка не тронет, сколько бы
// дней ни прошло, — правило и его причина названы у `isWorkspaceStale` (задача 7). Здесь,
// в `night:mail`/`night:summary`, ничего звать по-другому не нужно: защита стоит внутри
// `sweepStaleWorkspaces`, а не в этой обёртке, и обходить её отсюда нельзя.
// 🔴 УТРЕННЯЯ СВОДКА ВЛАДЕЛЬЦУ ЛОЖИТСЯ ФАЙЛОМ. Зовётся из ОБОИХ утренних дел (`summary`
// и `mail`): по расписанию просыпается то одно, то другое, а владелец должен получить
// бумагу в любом случае. Класть дважды не страшно — файл один и тот же, он переписывается.
//
// 🪤 НИКОГДА НЕ БРОСАЕТ. Утренняя бумага не имеет права уронить утреннее дело: упади она —
// не ушло бы и письмо, и владелец остался бы вообще без всего. `zapisatUtro` своё падение
// уже съедает сам; здесь накрыт и сбор доводов из улик.
export function polozhitUtroFaylom({ disk_free_gb = null } = {}) {
try {
// 🔴 Потолок израсходованного — у ЕДИНСТВЕННОГО ХОЗЯИНА (`potolokIzNastroek` в budget.mjs),
// того же, по которому решают ворота. Своего числа у утренней бумаги нет намеренно:
// оно там однажды стояло и молча разъехалось с настройкой владельца (круг C, 05.08.2026).
// 🔴 Пути к уликам о деньгах подаёт точка входа: без них прибор потерь молчит,
// и владелец про сгоревшие деньги не узнаёт (так и было с 03.08 по 06.08.2026).
const dovody = sobratDovody({
koren: NIGHT_ROOT,
predel: potolokIzNastroek(),
poteriPuti: zhivyePutiPoter(NIGHT_ROOT),
});
return zapisatUtro({ ...dovody, disk_free_gb }, { put: utroPath(NIGHT_ROOT) });
} catch (err) {
const error = String(err?.message ?? err);
console.error(`Утреннюю сводку владельцу собрать не удалось: ${error}`);
return { ok: false, error };
}
}
export function sweepNow(cfg) {
return sweepStaleWorkspaces({
repoRoot: REPO_ROOT,
maxAgeDays: cfg.workspace_max_age_days ?? 7,
}).map(s => s.line);
}
// 🆕 Чтение последнего прогона и строк сводки вынесено во внедряемые зависимости
// (`readLastRun`/`readSummaryLines`) — иначе проверить «сводка на пустом месте не молчит»
// нечем без похода в НАСТОЯЩИЙ `.night/` этого рабочего угла, а там уже живут файлы
// второго правщика (виден `.night/mail-failures.jsonl`, `.night/runs/`) — трогать их нельзя.
export function collectSummary(cfg, extraLines = [], {
readLastRun = () => readJson(lastRunPath()),
readSummaryLines = runId => readLines(summaryLinesPathOf(runId)).map(r => r.line).filter(Boolean),
// Замер места делает зовущий и передаёт сюда готовым: сама сводка никуда не ходит.
disk = null,
} = {}) {
const last = readLastRun();
const lines = last?.run_id ? readSummaryLines(last.run_id) : [];
return buildSummary({
run_id: last?.run_id ?? null,
workers: last?.workers ?? [],
lines: [...lines, ...extraLines],
// Кусок 2 умеет мерить место — выдуманного числа здесь по-прежнему нет: не замерили,
// значит `null`, и сводка скажет об этом словами.
disk_free_gb: typeof disk?.free_gb === 'number' ? Number(disk.free_gb.toFixed(1)) : null,
server_rebooted_at: last?.server_rebooted_at ?? null,
});
}
// О-1: самое дорогое во всей затее. Раньше между «собрать сводку» и «отдать отправлялке»
// не было НИКАКОЙ защиты — сбой сборки сводки или транспорта (до самого sendMail) вылетал
// наружу необработанным исключением, `mail-failures.jsonl` не получал строку, и владелец
// утром видел ТИШИНУ — а тишину он не отличит от «всё хорошо». Отправлялка (`mailer.mjs`
// `sendSummary`) уже умеет записывать СВОЮ неудачу — тем же способом (`appendLine` в тот
// же файл) здесь записывается неудача на подступах к ней, не только внутри неё.
// Настоящих писем эта функция никогда не шлёт САМА — она только собирает и передаёт дальше;
// шлёт (или не шлёт) внедрённый `transport`.
export async function runMailCommand({
cfg,
failPath = mailFailuresPath(),
sentPath = summarySentPath(),
sweep = () => sweepNow(cfg),
measureDisk = diskNow,
buildSummaryLines = async extra => collectSummary(cfg, extra, { disk: await measureDisk(cfg) }),
makeTransport = makeTransportDefault,
sendSummary = sendSummaryDefault,
now = () => Date.now(),
} = {}) {
try {
// 🔴 Строка про несторожащие ворота идёт и в письмо тоже, а не только в окно помощника.
// Убрать её из письма значило бы прятать правду именно от того, кому она нужна.
const summary = await buildSummaryLines([...sweep(), stroka_pro_nestorozhashchie()]);
const transport = await makeTransport(cfg);
return await sendSummary({
...summary,
failPath,
sentPath,
transport,
from: cfg.mail.from,
to: cfg.mail.to,
});
} catch (err) {
// Тем же способом, что уже умеет отправлялка: пишем СВОЮ неудачу в тот же файл.
// Не бросаем дальше — обещание «никогда не бросает» держится и на этом шаге тоже.
const error = String(err?.message ?? err);
const at = now();
try {
appendLine(failPath, { at, subject: null, error, stage: 'before-send' });
} catch (writeErr) {
// 🔴 Запись собственной неудачи — тоже запись на диск, и она тоже умеет упасть.
// Последнее, что можно сделать, — крикнуть в свой журнал и всё равно вернуть отказ,
// а не сделать вид, что письмо ушло.
console.error(`Сводка не ушла (${error}), и записать эту неудачу тоже не вышло: ${writeErr?.message ?? writeErr}`);
return { ok: false, error, at, unrecorded: `не записано: ${writeErr?.message ?? writeErr}` };
}
return { ok: false, error, at };
}
}
// 🔴 «Записать замер и спросить остаток» — ОДНО дело и ОДНА точка впрыска, а не две.
// Иначе проверке дороги пришлось бы писать в НАСТОЯЩИЙ журнал замеров общего хозяйства
// (`.night/budget-measures.jsonl`), а он один на всех: своя строка там сдвинула бы чужой
// прирост и погасила бы соседний прогон ни за что. Поймано живьём при первом же прогоне
// этих проверок — они вправду написали в общий журнал.
//
// 🪤 Живёт отдельным именем, а не громоздким умолчанием прямо в подписи: сторож «точка входа
// не зовёт имён, которых у неё нет» разбирает список доводов приметой по тексту, и вложенные
// скобки внутри умолчания сбивают его — он назвал невиновными пятерых. Сторож при этом прав
// по сути: длинное тело в списке доводов не читается человеком.
// 🔴 `phase` СПРАШИВАЕТСЯ У ЗОВУЩЕГО, А НЕ УГАДЫВАЕТСЯ. Дверь одна, зовущих двое
// (продолжение и проба), и под каким именем прирост ляжет в общую историю — решает зовущий:
// у продолжения это «before-resume», у пробы «before-smoke». Умолчания нет нарочно: молчаливое
// «наверное, продолжение» подписало бы чужую строку чужим именем, а разбирают эту историю
// потом глазами. Не назвали — падаем вслух.
//
// 🪤 Номер прогона и работника есть только у продолжения (у пробы своего прогона нет вовсе).
// Личная доля работника без них не считается — `workersCount` подаётся только когда есть
// кому её отмерить. Подставить единицу «на всякий случай» значило бы придумать работника.
// 🔴🔴 ПРИНИМАЕТ ТЕПЕРЬ ВСЕ ОПЛАЧЕННЫЕ ПОКАЗАНИЯ, А НЕ ОДНО СВОЁ (круг B, 05.08.2026).
// Прежде сюда подавалось `measure` — показание ЗАКАЗАННОГО вида, — и потому эта дверь
// зовётся ПОСЛЕ проверки «показание своего вида есть». То есть на несовпавшем виде она
// не звалась вовсе, и оплаченное показание пропадало молча. Правило записи одно на все
// три платные дороги и живёт в `zapisatOplachennoe` (budget.mjs).
function zapisatZamerIVzyatOstatok({ zamery = [], zakazan = null, runId = null, workerId = null, cfg, at, phase }) {
if (!phase) throw new Error('Замер записывать некуда: зовущий не назвал `phase` («before-resume» / «before-smoke»).');
zapisatOplachennoe({ zamery, zakazan, path: budgetMeasuresPath(), at, runId, phase });
return budgetState(budgetMeasuresPath(), cfg, at, { runId, workerId, workersCount: runId ? 1 : null });
}
// ═══════════════════════════════════════════════════════════════════════════════
// 🔴🔴 КНОПКА «ПРОДОЛЖИТЬ» — ЖИВАЯ ДОРОГА ИЗ ДВЕНАДЦАТИ ШАГОВ (решение 10).
//
// ЗАЧЕМ ЭТО ГЛАВНОЕ. Датчик зовущих показывал у ДЕСЯТИ правил куска 6 ноль: построены,
// проверены, зелены — и не позваны ни разу. Правило метода 2: защита, которую никто
// не зовёт, не бывает проверенной; затея заплатила за это шесть раз. Вот зовущий.
//
// 🔴 ДОРОГА ИДЁТ МИМО ДЕВЯТИ ВОРОТ обычного запуска (решение 8). Ни `planRun`, ни `vhodVorot`
// здесь не зовутся НИ РАЗУ, и это не небрежность: проверка 9 говорит дословно «больше кнопка
// не проверяет ничего: живой работник того же прогона и молчание владельца её НЕ
// останавливают — иначе она не сработала бы никогда». Через `planRun` её держали бы ещё семь
// ворот. Сторож на это — счёт зовов подставного `planRun` (П-24).
//
// 🔴 ВЛАДЕЛЕЦ УЗНАЁТ ОБО ВСЁМ ИЗ ОКНА (решение 125). Отдельного письма нет и не строится:
// кнопку он жмёт рукой и в эту минуту смотрит в окно. Поэтому КАЖДАЯ причина отказа
// и каждая из четырёх строк печатается немедленно, а не только ложится в сводку.
export async function komandaProdolzheniya({
cfg = {},
planPath,
resumeRunId,
resumeWorkerId = null,
// Точки впрыска — только для проверок; живой зовущий подаёт лишь первые четыре довода.
readCards = kartochkiRabotnikov,
// 🪤 Отпечаток подаётся точкой впрыска по той же причине, что и место с замером: живая
// печать лежит в общем `.night/seals/`, и проверке пришлось бы либо печатать туда
// по-настоящему (портя чужое хозяйство), либо не проверять дорогу дальше первых ворот.
// Умолчание — НАСТОЯЩИЙ `checkSeal`; подставка подаётся явно и только в проверках.
pechat = () => checkSeal(planSealPath(planPath), planPath),
measureDisk = diskNow,
zamer = zamerSoSchyotom,
ostatok = zapisatZamerIVzyatOstatok,
podnyat = zavestiProdolzhenie,
otkatchik = otkatDoKommita,
gryaz = gryazenLiUgol,
// Кто идёт прямо сейчас — нужно, чтобы развести файлы МЕЖДУ прогонами. Отдельная точка
// впрыска: живой зов ходит в настоящий `.night/runs/`, а проверке нужен свой мир.
idushchie = idushchieProgony,
now = () => Date.now(),
out = console.log,
err = console.error,
} = {}) {
// 🔴 Строка идёт СРАЗУ В ДВА МЕСТА: в окно владельцу (единственная дорога до него)
// и в сводку ПОДНИМАЕМОГО прогона (для разбора командой `cli.mjs summary <номер>`).
// Порознь их писать нельзя: забудешь одно — либо владелец не узнает, либо разбирать нечем.
const skazat = (line, { plohо = false, workerId = null } = {}) => {
(plohо ? err : out)(line);
try {
appendLine(summaryLinesPathOf(resumeRunId), { at: now(), worker_id: workerId, line });
} catch { /* сводка не должна мешать владельцу увидеть строку в окне */ }
};
const otkaz = (line, workerId = null) => { skazat(line, { plohо: true, workerId }); return 1; };
// ━━━ ШАГ 2. КОГО ПОДНИМАЕМ ━━━ (шаг 1 — разбор доводов — сделан зовущим)
const sobrano = readCards(resumeRunId);
if (!sobrano.ok) return otkaz(`Продолжение не начато: ${sobrano.reason}`);
const vybor = vyborProdolzhaemogo({ workers: sobrano.cards, workerId: resumeWorkerId });
if (!vybor.worker) return otkaz(`Продолжение не начато: ${vybor.reason}`);
const worker = vybor.worker;
const wid = worker.worker_id;
// 🔴 ПРИЧИНА 10 (решение 13): УГЛА РАБОТНИКА НЕТ ВОВСЕ. После решения владельца 126 уборка
// углы вставших и погашенных не трогает, так что случай краевой — угол может пропасть только
// рукой владельца или бедой на диске. Но отказ обязан быть ПОНЯТНЫМ: без этой проверки
// дорога упала бы глубже, в чужом правиле, строкой про несуществующий путь.
const ugol = worker.workspace;
if (!ugol || !existsSync(ugol)) {
return otkaz(`Продолжение не начато: угла работника ${wid} нет вовсе (${ugol ?? 'путь не записан'}). `
+ 'Продолжать нечего и негде: вся наработка жила в нём. Уборка углы вставших и погашенных не трогает — '
+ 'значит его унесли рукой или случилась беда с диском.', wid);
}
// ━━━ ШАГИ 3 и 4: ДВА ДЕШЁВЫХ ОТВЕТА ━━━
const seal = pechat();
const disk = await measureDisk(cfg);
// ━━━ ШАГ 5: ПЛАТНЫЙ ЗАМЕР — ТОЛЬКО ЕСЛИ ОБА ДЕШЁВЫХ СОШЛИСЬ ━━━
// 🔴 Замер идёт платной программой. Жечь его на попытку, которая заведомо провалится
// по отпечатку плана или по месту на сервере, нельзя — это деньги владельца (П-41).
// Не сошлись — остаток подаётся ответом «не мерили», и ворота назовут ПЕРВУЮ по порядку
// причину, а не невнятное «запас неизвестен».
const novyjProgon = `resume-${randomUUID().slice(0, 8)}`;
let budget = null;
if (seal?.ok && disk?.ok) {
let z = null;
try {
z = await zamer({ cfg, attemptsPath: probeAttemptsPath(), komanda: 'resume' });
} catch (e) {
return otkaz(`Продолжение не начато: замер остатка не сделан. ${e?.message ?? e}`, wid);
}
if (!z.ok) return otkaz(`Продолжение не начато: ${z.reason}`, wid);
// 🔴 Те же два приговора, что и у `vorotaPribora`, и той же дверью (`strokiPriborov`):
// копии слов здесь нет. Идут через `skazat` — то есть и в окно владельцу, и в сводку
// поднимаемого прогона; иначе разбирать ночью было бы нечем.
for (const line of strokiPriborov(z)) skazat(line, { workerId: wid });
// 🔴🔴 ЗАПИСЬ ОПЛАЧЕННОГО — ВЫШЕ ОТКАЗА (круг B, 05.08.2026). Прежде эта строка стояла
// НИЖЕ отказа по несовпавшему виду, и на нём оплаченное показание пропадало молча
// (замерено по оплаченной улике: 03.08.2026 в 15:39 так сгорели 0,14828025 $).
// 🔴 Номер прогона — НОВЫЙ и выданный ЗДЕСЬ, до заведения карточки: доля прогона
// у продолжения своя, чистая (проверка 6). Работник один, потому `workersCount: 1`.
const zapas = ostatok({
zamery: z.zamery, zakazan: z.zakazan_vid,
runId: novyjProgon, workerId: wid, cfg, at: now(), phase: 'before-resume',
});
// 🪤 Ворота от этого не смягчаются: своего показания нет — продолжения нет, слово в слово
// как было. Записана только правда о том, за что уже заплачено.
if (!z.measure) {
return otkaz(`Продолжение не начато: остаток запаса посчитать не удалось. ${z.problem ?? 'причина не названа.'}`, wid);
}
budget = zapas;
}
// ━━━ ШАГ 6: ТРИ ВОРОТ. Список ЗАКРЫТ ━━━
// 🔴🔴 ШОВ МЕЖДУ КУСКАМИ 2 И 6 — НАЙДЕН ЖИВЬЁМ 03.08.2026 И ПЕРЕВОДИТСЯ ЗДЕСЬ.
// `budgetState` (кусок 2) отвечает полем `may_start`; `otvetVorot` внутри `resumeChecks`
// (кусок 6) смотрит ТОЛЬКО на `ok` и на голые `true`/`false`, а всё прочее считает
// «не мерили», то есть «нельзя». ЗАМЕРЕНО: на заведомо хорошем запасе (израсходовано 5%)
// `resumeChecks` отвечает `ok: false` со словами «остаток запаса не позволяет продолжать
// (не мерили — остаток неизвестен)». То есть кнопка не сработала бы НИ РАЗУ — и оба куска
// при этом зелены, потому что ни один не видит другого.
// 🟡 Перевожу здесь, у зовущего, а не правлю `limits.mjs`: это чужой предмет, и правило
// «не мерили — значит нельзя» само по себе верное. Настоящее лечение — свести имена
// в договоре; названо вслух в отчёте.
const zapasDlyaVorot = budget ? { ok: budget.may_start, reason: budget.reason } : null;
const vorota = resumeChecks({ seal, budget: zapasDlyaVorot, disk });
if (!vorota.ok) return otkaz(`Продолжение не начато: ${vorota.summary_line}`, wid);
skazat(`${wid}: ${vorota.summary_line}`, { workerId: wid });
if (budget?.dolya_ne_prishla && budget?.slova_vladeltsu) out(budget.slova_vladeltsu);
// ━━━ ШАГ 7: ТОЧКА ПРОДОЛЖЕНИЯ. НЕ ОБРЫВАЕТ НИКОГДА ━━━
// 🔴 «Что засчитано» спрашивается у ЧУЖОГО правила (кусок 4), а не решается здесь:
// засчитано только то, у чего проверки вправду зелены. Оно отдаёт НОМЕРА пунктов.
const dirProgona = runDir(resumeRunId);
const zachteno = closedItemsOfRun(dirProgona);
const posledniyZachtennyy = zachteno.length ? zachteno[zachteno.length - 1] : null;
const kommitZachtennogo = kommitZachtennogoPunkta(dirProgona, posledniyZachtennyy);
const punkty = (readJson(join(dirProgona, 'run.json'))?.items ?? []).map(i => i?.id).filter(Boolean);
const tochka = resumePoint(ugol, { last_closed_item: posledniyZachtennyy, known_items: punkty });
// 🔴 ВТОРАЯ из четырёх строк: запись состояния порвана или неполна. Дорога идёт ДАЛЬШЕ —
// это не отказ, — но молчать нельзя: владелец обязан знать, что продолжаем не с записи.
if (tochka.sboy) skazat(`${wid}: запись состояния не пригодилась — ${tochka.note}`, { workerId: wid });
// ━━━ ШАГ 8: ЧТО НАРАБОТАНО ПОСЛЕ ЗАСЧИТАННОГО ━━━
const rabotaPosle = rabotaPosleZachtennogo({ worktreeRoot: ugol, commit_sha: kommitZachtennogo });
// ━━━ ШАГ 9: ПРИГОВОР О ПРОДОЛЖЕНИИ ━━━
const prigovor = resumePlan({
status: worker.status,
last_closed_item: posledniyZachtennyy,
work_after: rabotaPosle,
paused_at_item: worker.paused_at_item ?? null,
known_items: punkty,
});
if (!prigovor.auto_resume) {
// 🔴 ЧЕТВЁРТАЯ из четырёх строк: «продолжать не с чего» — с описью отложенного,
// чтобы владелец решал сам, а не гадал, куда делась работа.
return otkaz(`Продолжение не начато: ${prigovor.reason}. Отложено: ${(prigovor.kept_aside ?? []).join(', ') || '(ничего)'}`, wid);
}
// ━━━ 🔴🔴 ШАГ 10а: ЗАНЯТЬ ФАЙЛЫ. ДО ВСЯКОГО ИЗМЕНЕНИЯ И ТОЧНО ДО ОТКАТА ━━━
//
// САМОЕ ОПАСНОЕ МЕСТО ЗАТЕИ, найдено 03.08.2026 четвёртым вопросом («а что если этого же
// самого будет ДВА одновременно?»). Продолжение не занимало файлов ВОВСЕ — а оно не просто
// пишет, оно ОТКАТЫВАЕТ: `read-tree --reset -u` переписывает рабочее дерево целиком.
// Идёт в это время обычный прогон, чей работник законно занял тот же файл (замок свободен —
// продолжение его не брало), и откат сносит ЧУЖУЮ правку в общей ветке и общей базе.
// Владелец обнаружит это не утром, а когда чего-то недосчитается.
//
// 🔴 ЗАНИМАЕМ НЕ ТОЛЬКО ФАЙЛЫ ПУНКТА. Откат трогает всё, что наработано после засчитанного
// пункта, а это шире пункта: работник мог задеть соседние файлы. Список берётся у того же
// прибора, что уже спросили на шаге 8 (`rabotaPosleZachtennogo`), — второго зова нет.
const moiFayly = [...new Set([
...(worker.item?.files ?? []),
...(rabotaPosle?.files ?? []),
])].filter(Boolean);
// 🔴🔴 ДВА РАЗНЫХ ЗАМКА, И ОДНОГО МАЛО — это и есть суть находки.
// Занятия живут в каталоге ПРОГОНА (`.night/runs/<прогон>/file-claims.json`). Продолжение
// заводит НОВЫЙ прогон, то есть новый ПУСТОЙ список занятий: занять в нём что угодно можно
// всегда, и от соседнего прогона это не защищает НИ ОТ ЧЕГО. Между прогонами файлы разводит
// другое правило — `filesDisjoint` (кусок 2), и обычный запуск зовёт его девятыми воротами.
// Продолжение идёт мимо девяти ворот (решение 8) — значит зовёт его САМО.
// 🪤 Своего способа занимать файлы не заводим: оба прибора уже есть и оба чужие.
const schyot = idushchie();
if (!schyot.ok) return otkaz(`Продолжение не начато: ${schyot.reason}`, wid);
const chuzhieFayly = schyot.running
.filter(r => r?.run_id !== resumeRunId) // поднимаемый прогон себе не соперник: он кончился
.map(r => (r?.items ?? []).flatMap(i => i?.files ?? []));
const razvedeny = filesDisjoint(moiFayly, chuzhieFayly);
if (!razvedeny.ok) {
// 🔴 ТРЕТИЙ ЗОВУЩИЙ СЧЁТА ИДУЩИХ, НАЙДЕННЫЙ ГЛАЗАМИ, А НЕ КОМАНДОЙ. Поиск по тексту
// `idushchieProgony(` его не видит: сюда он приходит ПЕРЕИМЕНОВАННЫМ на входе
// (`idushchie = idushchieProgony`) и зовётся как `idushchie()`. А кнопка «Продолжить» —
// ровно то место, куда владелец жмёт после оборванной ночи, то есть когда покойники
// в хозяйстве наиболее вероятны. Без этих строк он читал бы «файл занят другим прогоном»
// и не знал бы, что «другой прогон» — покойник, которого некому хоронить.
for (const s of strokiProProgony(schyot)) skazat(s, { plohо: true, workerId: wid });
return otkaz(`Продолжение не начато: ${razvedeny.reason} Откат переписал бы рабочее дерево целиком и снёс бы чужую правку молча.`, wid);
}
// Занятие внутри своего прогона: им держится освобождение (его делает надзиратель
// при гашении) и на него смотрит замок «двое не правят один файл» внутри задачи.
mkdirSync(runDir(novyjProgon), { recursive: true });
const zanyatie = claimFiles(runDir(novyjProgon), wid, moiFayly);
if (!zanyatie.ok) {
return otkaz(`Продолжение не начато: занять файлы за работником ${wid} не вышло — ${zanyatie.reason}`, wid);
}
// 🔴 Замок, переживший неудачную попытку, держал бы файлы за работником, которого нет.
// Поэтому КАЖДЫЙ отказ ниже освобождает занятое.
const otkazOsvobodiv = (line) => { releaseClaims(runDir(novyjProgon), wid); return otkaz(line, wid); };
// ━━━ ШАГ 10: ГРЯЗЕН ЛИ УГОЛ ━━━
// 🪤 Тот же прибор спрашивает у себя внутри и общий откатчик — это ОДИН прибор, спрошенный
// дважды, а не два прибора. Мой зов нужен, чтобы отказать ДО отката понятной строкой.
// 🪤 ПРО ГРЯЗЬ СПРАШИВАЮТ ТРИЖДЫ, И ТРЁХ НЕ ИЗБЕЖАТЬ — называю вслух, как обещал.
// Спрашивают: этот шаг, `sledOtkata` (кусок 6) и `otkatDoKommita` (кусок 5). Свести
// к одному нельзя, и вот почему: у обоих нижних есть ВТОРОЙ зовущий помимо меня
// (`sledOtkata` зовут и другие дороги куска 6, откатчик зовёт ещё и блок 2в куска 5),
// и правило «грязный угол отката не получает» обязано держаться у каждого зовущего,
// а не только у меня. Убери я свой вопрос — потеряю понятную строку владельцу ДО отката
// и оставлю без присмотра случай, когда откат не нужен вовсе (коммита засчитанного нет).
// 🔴 Щель во времени между тремя вопросами остаётся: работник может дописать файл между
// ними. Она СУЖЕНА тем, что файлы теперь заняты выше — сосед в них уже не войдёт;
// остаётся только сам работник этого угла, а он погашен. Это названо, а не закрыто.
const gryazno = gryaz(ugol);
if (gryazno === null) return otkazOsvobodiv(`Продолжение не начато: спросить про несохранённые правки в углу ${wid} не удалось — вслепую не откатываем.`);
if (gryazno === true) return otkazOsvobodiv(`Продолжение не начато: в углу работника ${wid} лежат несохранённые правки. Откат потерял бы их. Посмотрите угол глазами: ${ugol}`);
// ━━━ ШАГ 11: ОТКАТ ДО ЗАСЧИТАННОГО ━━━
if (kommitZachtennogo) {
const sled = sledOtkata({ worktreeRoot: ugol, commit_sha: kommitZachtennogo, otkat: otkatchik });
if (!sled.ok) return otkazOsvobodiv(`Продолжение не начато: откат не удался — ${sled.reason}`);
// 🔴 ТРЕТЬЯ из четырёх строк: откат прошёл — называем ветку и коммит, чтобы владелец
// мог достать откаченное.
skazat(`${wid}: ${sled.reason}${sled.otlozheno?.branch ? ` (ветка ${sled.otlozheno.branch})` : ''}`, { workerId: wid });
}
// ━━━ ШАГ 12: ПОДЪЁМ ━━━
let podnyato;
try {
podnyato = podnyat({
runId: novyjProgon,
worker,
planPath,
taskId: worker.task_id ?? null,
workerModel: cfg.worker_model,
});
} catch (e) {
return otkazOsvobodiv(`Продолжение не начато: ${e?.message ?? e}`);
}
// 🔴 Работник не поднялся — замок обязан упасть вместе с ним. Надзирателя, который освободил
// бы файлы при гашении, в этом случае нет вовсе: его как раз и не завели.
if (!podnyato?.ok) return otkazOsvobodiv(`Продолжение не начато: ${podnyato?.reason ?? 'подъём не удался, причина не названа'}`);
skazat(`Работник ${wid} продолжен прогоном ${podnyato.run_id}: начинает с пункта ${prigovor.start_from ?? '(пункт не назван)'}, надзиратель ${podnyato.supervisor_pid}.`, { workerId: wid });
out('Окно можно закрывать: надзиратель переживёт конец сессии.');
return 0;
}
// 🆕 Разбор команды вынесен в `main(argv, deps)` — точка входа внизу файла зовёт его,
// когда файл запущен как программа, а не подключён как модуль (проверки подключают
// именно так). Возвращает КОД, а не зовёт `process.exit` сам — иначе подключить
// функцию в проверку означало бы уронить процесс проверки вместе с ней.
export async function main(argv = process.argv, deps = {}) {
const {
readConfig = () => readJson(configPath()),
runMail = runMailCommand,
summaryOf = collectSummary,
sweep = sweepNow,
measureDisk = diskNow,
// 🔴 Ворота сверки настроек с решениями владельца (задача О-3) — умолчание НАСТОЯЩЕЕ,
// подставка подаётся явно и только в проверках.
proverkaVladeltsa = proverkaNastroekVladeltsa,
// 🔴 Общий список вопросов владельцу (кусок 5, задача 8). Умолчание настоящее;
// подставка подаётся явно и только в проверках — иначе они правили бы живой список,
// за которым стоят настоящие вопросы настоящих работников.
qPath = questionsPath(),
// 🔴 Судья на ПУСТУЮ ночь (заведено 07.08.2026 по слову владельца). Умолчание
// НАСТОЯЩЕЕ — живое рождение судьи; подставка подаётся явно и только в проверках,
// иначе проверять было бы нечем без настоящих денег владельца.
// 🔴 Сама дорога живёт в `supervisor.mjs`: живое рождение головы разрешено поимённо
// трём файлам, и `cli.mjs` среди них нет. Здесь только зов, и это не мелочь —
// впусти мы сюда рождение, ворота счёта рождений перестали бы быть короткими.
sudyaPustoyNochiFn = zavestiSudyuNaPustuyuNoch,
// 🔴 Ворота ПРИБОРА замера (задача О-4) — та же дверь у `run` и у `smoke-longrun`.
// 🪤 Это НЕ ворота денег: хватает ли запаса, спрашивает `vorotaZapasa`, отдельная дверь
// и отдельное имя. Слить их обратно в одно имя — вернуть беду 03.08.2026. Умолчание
// НАСТОЯЩЕЕ (`vorotaPribora`, платный зов); подставка подаётся явно и только в проверках —
// иначе проверить дорогу нечем без живого платного замера.
priborZamera = vorotaPribora,
// 🔴 Утренняя бумага владельцу. Умолчание НАСТОЯЩЕЕ — иначе она не легла бы сама
// ни разу; подставка подаётся только в проверках, чтобы не писать на диск зря.
polozhitUtro = polozhitUtroFaylom,
// 🔴 «Записать замер и спросить остаток» — ОДНА точка впрыска, а не две (тот же довод,
// что у продолжения: без неё проверка дороги писала бы в НАСТОЯЩИЙ общий журнал замеров
// `.night/budget-measures.jsonl` и сдвигала бы чужой прирост). Умолчание НАСТОЯЩЕЕ —
// та же самая дверь, что зовёт продолжение, а не вторая её копия.
zapisatIZapas = zapisatZamerIVzyatOstatok,
// 🔴 Счёт запаса у пробы — точкой впрыска по той же причине, что и всё остальное:
// живой `budgetState` читал бы НАСТОЯЩИЙ общий журнал замеров, а он один на всех.
// Умолчание НАСТОЯЩЕЕ — та же самая машинка, что у `run`, а не вторая её копия.
sostoyanieZapasa = budgetState,
// 🔴 САМО ЗАВЕДЕНИЕ ПРОБЫ — тоже точка впрыска, и не ради удобства. Без неё проверку
// «ворота отказали — проба НЕ заведена» пришлось бы доказывать одним кодом возврата,
// а код возврата этого НЕ доказывает: сломанные ворота могли бы завести живого платного
// работника И вернуть единицу. Со спаем доказывается то самое: зовущего не было ни разу.
// 🪤 И проверка при этом физически не может завести платного работника, даже когда
// ворота сломаны нарочно (вырезание) — а вырезать их обязаны, иначе они не сторожат.
probaProgon = smokeLongRun,
// 🔴 ДВЕ ДВЕРИ ПРОТИВ ВТОРОГО ЗАПУСКА — тоже точками впрыска, и не ради удобства.
// Без них проверка дороги трогала бы НАСТОЯЩЕЕ хозяйство: `idushchie` читала бы живые
// карточки прогонов (ответ непредсказуем — проверка врала бы через раз), а `zamokPut`
// указывал бы в живой `.night/`, куда проверкам писать запрещено. Умолчания НАСТОЯЩИЕ.
idushchie = idushchieProgony,
zamokPut = putZamkaZapuska,
// 🔴🔴 БЕСПЛАТНЫЙ ПРЕДПРОХОД ВОРОТ — точка впрыска, и она нужна ровно для одного:
// доказать проверкой, что при отказе бесплатных ворот платного зова НЕ БЫЛО НИ РАЗУ.
// Кодом возврата это не доказывается (единица приходит и оттуда, и отсюда), а живой
// платный зов в проверке недопустим. Умолчание НАСТОЯЩЕЕ.
besplatnyeVorota = besplatnyeVorotaDoDeneg,
} = deps;
const cmd = argv[2];
const cfg = readConfig();
if (!cfg) {
console.error(`Настроек нет: ${configPath()}. Скопируйте night.config.example.json и заполните.`);
return 2;
}
if (cmd === 'summary') {
// Та же сводка, что уходит письмом, но напечатанная в окно: помощнику — посмотреть глазами.
// 🔴 Строка про несторожащие ворота — в оба зова, и в окно, и в письмо.
const disk = await measureDisk(cfg);
const summary = summaryOf(cfg, [...sweep(cfg), stroka_pro_nestorozhashchie()], { disk });
console.log(summary.text);
// 🔴 Владельцу — своя бумага, и она ложится ФАЙЛОМ, а не печатается в это окно.
// Окно видит помощник; владелец ночью спит, а утром открывает файл.
const utro = polozhitUtro({ disk_free_gb: typeof disk?.free_gb === 'number' ? Number(disk.free_gb.toFixed(1)) : null });
console.log(utro.ok ? `Сводка владельцу записана: ${utro.put}` : `Сводку владельцу записать НЕ удалось: ${utro.error}`);
return 0;
}
// ━━━ ОТВЕТ ВЛАДЕЛЬЦА РАБОТНИКУ (кусок 5, задача 8) ━━━
//
// 🔴 До 07.08.2026 этой команды не было вовсе, и мост был односторонним: работник мог
// спросить (а точнее — думал, что может), а положить ответ владельцу было НЕЧЕМ.
// 🔴 Владелец не программист. Поэтому без доводов команда не ругается, а ПОКАЗЫВАЕТ,
// на что он ещё не ответил, вместе с номерами: номер обязателен, иначе ответ уедет
// не туда, а искать его в файле руками — не работа для человека.
if (cmd === 'answer') {
const nomer = argv[3];
// Текст берётся ВСЕМ ХВОСТОМ, а не одним словом: человек пишет «нет, не сноси»,
// и оболочка разложит это на три довода.
const tekst = argv.slice(4).join(' ').trim();
if (nomer === undefined) {
const zhdut = readLines(qPath).filter(v => v && (v.answer === null || v.answer === undefined));
if (zhdut.length === 0) {
console.log('Ждущих вопросов нет — никто из работников ни о чём не спрашивал.');
return 0;
}
console.log(`Вопросов ждёт ответа: ${zhdut.length}.\n`);
for (const v of zhdut) {
const kogda = Number.isFinite(v.asked_at) ? new Date(v.asked_at).toLocaleString('ru-RU') : 'время не записано';
console.log(`${v.number} · работник ${v.worker_id ?? '—'} · пункт ${v.item ?? '—'} · спросил ${kogda}`);
console.log(` ${v.text ?? '(текст вопроса не записан)'}\n`);
}
console.log('Ответить так: node tools/night/cli.mjs answer <номер> <ваш ответ словами>');
return 0;
}
const nomerChislom = Number(nomer);
if (!Number.isInteger(nomerChislom) || nomerChislom < 1) {
console.error(`Номер вопроса — целое число, а названо «${nomer}». Посмотреть ждущие: node tools/night/cli.mjs answer`);
return 1;
}
if (!tekst) {
console.error('Ответ пуст. Напишите его словами: node tools/night/cli.mjs answer <номер> <ваш ответ>');
return 1;
}
// 🔴 ПОД ЗАМКОМ, и это не осторожность впрок: правку строки на месте нельзя вести
// одновременно с записью нового вопроса семью надзирателями — один из них пропал бы,
// и пропал бы молча. Замок берётся тот же, что у подъёма вопросов (кусок 3).
const itog = podZamkom(dirname(qPath), () => answerQuestion(qPath, nomerChislom, tekst, Date.now()));
if (!itog?.ok) {
console.error(`${itog?.reason ?? `вопроса № ${nomerChislom} записать не удалось`}. Посмотреть ждущие: node tools/night/cli.mjs answer`);
return 1;
}
const komu = itog.question?.worker_id ?? '—';
console.log(itog.replaced
? `Ответ на вопрос № ${nomerChislom} ПЕРЕПИСАН (прежний ответ там уже был). Работник ${komu} получит новый на ближайшем круге надзирателя.`
: `Ответ на вопрос № ${nomerChislom} записан. Работник ${komu} получит его на ближайшем круге надзирателя.`);
// 🪤 Названо вслух: если ночь уже кончилась, ответ ляжет в список и будет ждать —
// его подхватит продолженный работник (кусок 6), а не сегодняшний.
return 0;
}
if (cmd === 'mail') {
const r = await runMail({ cfg });
console.log(r.ok ? 'Сводка отправлена.' : `Сводка НЕ отправлена: ${r.error}`);
// 🔴 Кладётся ДАЖЕ КОГДА ПИСЬМО НЕ УШЛО, и это главное. Почта отменена решением
// владельца, и если бумага зависела бы от успеха письма, дороги до него не было бы вовсе.
// 🪤 Замер места делается ОДИН раз: он ходит к железу, и второй зов — лишняя работа
// на ровном месте (написал было дважды — поймал на перечитывании).
const diskM = await measureDisk(cfg);
const utro = polozhitUtro({ disk_free_gb: typeof diskM?.free_gb === 'number' ? Number(diskM.free_gb.toFixed(1)) : null });
console.log(utro.ok ? `Сводка владельцу записана: ${utro.put}` : `Сводку владельцу записать НЕ удалось: ${utro.error}`);
return r.ok ? 0 : 1;
}
if (cmd === 'budget') {
// 💳 Замер стоит копейки, но не ноль: прибор системы отдаёт остаток только в потоке
// живого запуска. Своего счёта в долларах у затеи нет вовсе — мы спрашиваем, а не считаем.
//
// 🔴 Обёртка обязательна. Нет настройки `probe_model` — ворота падают понятной
// строкой С ИМЕНЕМ НАСТРОЙКИ (задача 2), и эту строку надо ПОКАЗАТЬ владельцу. Без обёртки
// он получил бы обвал программы со следом стека — то есть строку не для человека.
let z = null;
try {
z = await zamerSoSchyotom({ cfg, attemptsPath: probeAttemptsPath(), komanda: 'budget' });
} catch (err) {
console.error(`Замер не сделан: ${err?.message ?? err}`);
console.error('Это настройка в вашем night.config.json. Пример строки есть в night.config.example.json.');
return 1;
}
// Замеров за смену уже сделано столько, сколько разрешил владелец. Печатаем ЕГО слова
// и всё равно показываем, что известно из журнала: остаток по прошлым замерам он видеть
// вправе, деньги на это не тратятся.
if (!z.ok) {
console.error(z.reason);
console.log(JSON.stringify(budgetState(budgetMeasuresPath(), cfg, Date.now()), null, 2));
return 1;
}
// 🔴 Третья и последняя дорога платного замера. Приговоры печатаются ДО всего прочего:
// владелец жмёт эту команду рукой и смотрит в окно, а числа остатка ниже длинные —
// строку про чужую голову в их хвосте он бы попросту не увидел.
for (const line of strokiPriborov(z)) console.log(line);
// 🔴🔴 ЗАПИСЬ — ДО РАЗБОРА УДАЧИ И БЕЗ ВСЯКОГО УСЛОВИЯ (круг B, 05.08.2026). Прежде она
// стояла внутри `if (m)`, то есть оплаченное показание чужого окна выбрасывалось молча.
// Правило одно на все три платные дороги и живёт в `zapisatOplachennoe` (budget.mjs).
zapisatOplachennoe({
zamery: z.zamery, zakazan: z.zakazan_vid, path: budgetMeasuresPath(), at: Date.now(), phase: 'probe',
});
const m = z.measure;
if (m) {
if (m.utilization === null) {
// 🔴 Молчать об этом нельзя: владелец, глядя на `"utilization": null` в ответе, решил бы,
// что прибор сломан. Он не сломан — доля приходит только вместе с тревогой.
console.log('Доли израсходованного система не прислала — она кладёт её в событие только вместе с тревогой («allowed_warning»). Светофор системы при этом получен, и решение принимается по нему.');
}
} else {
// 🔴 РЕШЕНИЕ Р2: причина называется ТА САМАЯ, а не одна на все случаи. Прежде здесь
// стояла строка «события rate_limit_event НЕДЕЛЬНОГО вида от системы не пришло» — и она
// была враньём ровно в том случае, который случался живьём каждый раз: событие приходило,
// не совпадал ВИД предела.
console.error(`Замер не удался: ${z.problem ?? 'причина не названа.'}`);
console.error('Ноль сюда не подставляется — это был бы выдуманный ответ.');
}
console.log(JSON.stringify(budgetState(budgetMeasuresPath(), cfg, Date.now()), null, 2));
return 0;
}
if (cmd === 'seal') {
// Печать плана ставит ПОМОЩНИК по слову владельца — ни работник, ни надзиратель.
// Печать значит «этот текст владелец видел и разрешил»; поставленная автоматом,
// она значила бы только «файл существует».
const d = razborDovodov(argv);
if (d.error) {
console.error(d.error);
return 2;
}
if (!existsSync(d.planPath)) {
console.error(`Плана нет: ${d.planPath}`);
return 2;
}
// 🔴 Проверка 43, первая половина: пока идёт прогон, план не перепечатывают. Правило
// (`assertPlanEditable`) было написано задачей 3 — а звать его было НЕКОМУ. Печать нового
// текста посреди ночи означала бы, что работники доделывают одно, а разрешено уже другое.
// 🔴 Порванную карточку прогона считать за «никого нет» нельзя: печать нового текста
// посреди чужой ночи — ровно то, от чего эти ворота и стоят. Отказ идёт СТРОКОЙ.
const schyot = idushchieProgony();
if (!schyot.ok) {
console.error(`План НЕ опечатан. ${schyot.reason}`);
return 1;
}
const going = schyot.running;
const editable = assertPlanEditable(going.length ? { status: 'running', run_id: going[0].run_id } : null);
if (!editable.ok) {
console.error(editable.reason);
// 🔴 Отказ обязан сказать, ЧЕМ именно занято. Раньше здесь хватило бы слова из карточки,
// и владелец печатал бы план заново, упираясь в покойника снова и снова.
for (const s of strokiProProgony(schyot)) console.error(s);
return 1;
}
// Запрет не сработал — но про мёртвые карточки владельцу сказать всё равно надо.
for (const s of strokiProProgony(schyot)) if (!s.startsWith('Вправду идёт')) console.log(s);
const seal = sealPlan(planSealPath(d.planPath), d.planPath, { approved_by: 'owner', at: Date.now() });
console.log(`План опечатан: ${d.planPath}`);
console.log(`Отпечаток: ${seal.fingerprint}`);
console.log('Поменяете в нём хоть строчку — запуска по нему не будет, пока не опечатаете заново.');
return 0;
}
if (cmd === 'approve-spec') {
// Отметка «замысел проверен» — это ДВА рецензента И слово владельца. Ставит её помощник.
const d = razborDovodov(argv);
if (d.error) {
console.error(d.error);
return 2;
}
if (!existsSync(d.specPath)) {
console.error(`Бумаги замысла нет: ${d.specPath}`);
console.error('Надо так: node tools/night/cli.mjs approve-spec <путь-к-бумаге> --reviewer <имя> --reviewer <имя>');
return 2;
}
// 🪤 Путь в отметку кладём в том же виде, в каком его потом спросит команда `run`:
// приводит его к одному виду сам разбор доводов. Отметка `docs\...\замысел.md` против
// запроса `docs/.../замысел.md` — это два разных текста, и ворота замысла отказывали бы
// всегда, а причина была бы непонятной.
const reviewers = d.reviewers ?? [];
const mark = { spec_path: d.specPath, reviewed_by: reviewers, owner_said_yes_at: Date.now() };
// 🔴 Вторым доводом — та же бумага: `specApproved` без неё отказывает всегда
// («не сказано, по какой бумаге запускают»), и отметку нельзя было бы поставить вовсе.
const verdict = specApproved(mark, d.specPath);
if (!verdict.ok) {
console.error(verdict.reason);
return 1;
}
writeJsonAtomic(specApprovalPath(), mark);
console.log(`Замысел ${d.specPath} отмечен проверенным: рецензенты ${reviewers.join(', ')} и слово владельца.`);
return 0;
}
if (cmd === 'run') {
// ━━━ 🔴🔴 ВОРОТА СВЕРКИ НАСТРОЕК — САМЫЕ ПЕРВЫЕ В КОМАНДЕ `run`, ДО РАЗБОРА ДОВОДОВ.
// Здесь дорога ЕЩЁ ОДНА: обычный запуск и продолжение (`--resume`) расходятся НИЖЕ,
// у проверки `razbor.resumeRunId`. Сверка стоит ДО развилки нарочно — так, чтобы разойтись
// эти дороги ни разу не смогли МИМО неё. Проверь я только один рукав — второй утёк бы
// мимо сторожа, и это была бы ровно та беда, что чинится этой правкой (датчик зовущих
// нашёл у сторожа ноль зовущих; см. задачу О-3).
try {
// 🔴 `zhivoyZapusk: true` — НЕ украшение зова. Через несколько шагов отсюда родятся
// платные работники, и ровно этот признак включает красную половину разбора ветки
// (решение владельца 25). Без него сторож отставшей ветки печатал слова в окно —
// а ночь приходит из задания Планировщика, где окна нет, и слова уходили в никуда.
const slova = proverkaVladeltsa({ zhivoyZapusk: true });
for (const s of slova) console.log(s.tekst);
} catch (err) {
console.error('Прогон НЕ запущен: настройка ночи разошлась с решением владельца дороже обещанного (либо сверку сделать не вышло — это тоже не повод молча пускать ночь).');
console.error(err?.message ?? String(err));
return 1;
}
// 🔴 Разбор доводов — один на все три команды, и он проверен двенадцатью проверками.
// Разбирай `run` по-своему рядом — проверки сторожили бы не тот код, что работает.
const razbor = razborDovodov(argv);
if (razbor.error) {
console.error(razbor.error);
return 2;
}
const { planPath, priyomkaPath, specPath } = razbor;
if (!existsSync(planPath)) {
console.error(`Плана нет: ${planPath}`);
return 2;
}
// 🔴🔴 РАЗВИЛКА КНОПКИ «ПРОДОЛЖИТЬ» — В САМОМ НАЧАЛЕ КОМАНДЫ (решение 8, столкновение С-3).
// Дальше идёт СВОЯ дорога из двенадцати шагов; чужой порядок обычного запуска
// («бумаги → разбор плана → замер → девять ворот → строки отказа») не тронут ни строкой.
// 🔴 Ключи `--priyomka` и `--spec` кнопке не нужны — бумаг она не проверяет; но разбор
// доводов у команды `run` один на оба пути, и он их всё равно требует. Это разница
// названа вслух: продолжение запускают тем же `run`, просто с ключом `--resume`.
if (razbor.resumeRunId) {
return await komandaProdolzheniya({
cfg,
planPath,
resumeRunId: razbor.resumeRunId,
resumeWorkerId: razbor.resumeWorkerId,
});
}
if (!existsSync(priyomkaPath)) {
console.error(`Приёмочного листа нет: ${priyomkaPath}. Без него закрывать пункт нечем.`);
return 2;
}
// Бумагу замысла кусок 1 не читает, но её существование проверяет здесь же: названный
// и несуществующий путь — это опечатка, которая всплыла бы только в куске 2, ночью.
if (!existsSync(specPath)) {
console.error(`Бумаги замысла нет: ${specPath}. Ворота на неё заводит кусок 2, но путь должен быть настоящим уже сейчас.`);
return 2;
}
// 🔴 РАЗБОР ПЛАНА — это ВХОД ворот, а не ворота. Без разобранного плана воротам
// питаться нечем вовсе, поэтому он идёт первым, до всего.
// 🔴 Из этого зова берутся ТОЛЬКО пункты: план читается и разбирается ровно ОДИН раз
// за запуск. Приговор ворот здесь НЕ смотрится и смотреться не может — запаса
// и места на диске в эту минуту ещё не мерили, бумаги и замысел сюда не поданы.
// Судят ворота ниже, одним полным зовом, и туда идут уже разобранные пункты.
// Второй раз файл плана не читается: между двумя чтениями он мог измениться,
// и ворота судили бы не тот план, который пошёл в работу.
// 🪤 Кусок 1 лепил здесь ОДИН пункт «весь-план» из первой строки файла, а файлы к нему
// называл владелец рукой ключом `--files`. Больше одного работника за раз так завести
// было нельзя. Этот путь заменён разбором целиком.
let punkty;
try {
punkty = vhodVorot({ planPath }).items;
} catch (err) {
// 🔴 Разбор плана падает вслух и только на настоящей беде плана (незакрытая
// рамка-образец). Показываем СЛОВА, а не след стека: их писали владельцу.
console.error('Прогон НЕ запущен: план прочитать не удалось.');
console.error(` · ${err?.message ?? err}`);
return 1;
}
// 🟡 СВЕРХ ПЛАНА, называю вслух. Ключ `--files` кусок 4 обессмыслил: файлы теперь
// берутся из плана. Промолчи мы — владелец набрал бы `--files a.php`, ничего
// бы не произошло, и он искал бы беду в своём плане, а не в лишнем ключе.
if ((razbor.files ?? []).length) {
console.log('Ключ --files больше не нужен и не учитывается: файлы каждого пункта теперь берутся из самого плана (строка «<!-- night: ... files=... -->» под заголовком пункта).');
}
// 🔴 ОТКАЗ ПО ПЛАНУ (проверки 39, 41 и прочие). Печатается ДО замера остатка: замер
// идёт платной программой `claude` из подписки, и жечь его на заведомо негодный
// план нельзя — каждая ошибочная попытка стоила бы владельцу денег.
// Список девяти ворот при этом не тронут: новых ворот туда не вставляется.
const planOk = validatePlan(punkty);
if (!planOk.ok) {
console.error('Прогон НЕ запущен: план проверку не прошёл.');
console.error(` · ${planOk.reason}`);
console.error(` · пунктов в плане: ${planOk.items_count}, из них помечено длинными: ${planOk.long_count}.`);
console.error(` · план: ${planPath}`);
console.error('Это правится в самом плане — ночь по такому плану не запускается. Ни один работник не заведён, деньги не потрачены.');
return 1;
}
// 🔴 ВОРОТА ОТЛОЖЕННОЙ ЗАДАЧИ (проверки 28, 29, 30). След кладёт надзиратель последнего
// погасшего работника (задача 5) либо рука владельца. Отложенная задача сама
// НЕ подхватывается ни следующим прогоном, ни через неделю.
const zadacha = razbor.taskId ?? null;
let sled = null;
if (zadacha) {
try {
sled = readDeferred(deferredDir(), zadacha);
} catch (err) {
// 🔴 След есть, но порван. Молчать нельзя: «следа нет» пустило бы отложенную
// задачу в работу само, без слова владельца.
console.error(`Прогон НЕ запущен: запись об отложенной задаче ${zadacha} испорчена и не читается.`);
console.error(` · ${err?.message ?? err}`);
return 1;
}
}
if (sled) {
const vorota = deferredTaskGate({
reason: sled.reason,
owner_said_continue: razbor.ownerSaidContinue,
// 🔴 Перепроверка плана — из ЭТОГО запуска, а не «когда-то была» (проверка 30):
// пока задача лежала, продукт изменился, и план мог разойтись с ним.
plan_revalidated: planOk.ok,
defect_list: razbor.defectList,
// 🔴🔴 ТОТ ЖЕ ПЛАН (проверка 29). Слева — путь из следа, справа — план этого запуска.
// Не подай оба — правило внутри ворот стояло бы и молчало, а задачу можно было бы
// «доделать» по совсем другому плану.
plan_path: sled.plan_path,
plan_path_now: razbor.planPath,
});
if (!vorota.may_run) {
console.error(`Прогон НЕ запущен: задача ${zadacha} отложена (${sled.reason === 'rejected' ? 'не прошла приёмку владельца' : 'не была доделана в прошлый раз'}).`);
console.error(` · ${vorota.reason}`);
console.error(` · осталось незакрытым: ${(sled.left_items ?? []).join(', ') || '(не записано)'}`);
console.error('Продолжить: тот же запуск плюс ключ --continue. Если задача вернулась с приёмки — ещё и --defects <файл со списком брака>.');
console.error('Список брака пока подаётся файлом с руки — по строке на замечание. Ни один работник не заведён, деньги не потрачены.');
return 1;
}
console.log(`Задача ${zadacha} возвращается в работу по слову владельца. План перепроверен заново. Делать её будет НОВЫЙ работник.`);
}
// ━━━ 🔴🔴 ДВЕ ДВЕРИ ПРОТИВ ВТОРОГО ЗАПУСКА — ПОСЛЕДНЕЕ, ЧТО СТОИТ ПЕРЕД ДЕНЬГАМИ ━━━
// Замерено живьём: дважды замер «перед прогоном» лёг ПОСЛЕ старта (на 8 и на 5 секунд),
// то есть опоздавший запуск ДОХОДИЛ СЮДА и платил. Все ворота, которые могли бы его
// отбить (потолок задач, пересечение по файлам), стоят НИЖЕ платного замера — то есть
// были не защитой, а отчётом о потере. Обе двери здесь бесплатны: одна читает карточки,
// вторая трогает один файл.
//
// 🪤 Порядок дверей не случаен: дешёвая и точная — первой. Она же единственная умеет
// отличить законную вторую задачу (другой план) от удвоения одной и той же.
const kartochki = idushchie();
if (kartochki.ok) {
const dvoynik = progonPoTomuZhePlanu({ running: kartochki.running, planPath });
if (dvoynik) {
console.error(`Прогон НЕ запущен: по этому же плану уже идёт прогон ${dvoynik.run_id}.`);
console.error(` · план: ${planPath}`);
console.error(' · это второй запуск одного и того же — остановлен ДО платного замера остатка, денег владельца он не потратил.');
console.error(' · вправду брошен? Тогда его карточку увидит прибор живости, и следующий запуск пройдёт сам.');
return 1;
}
}
// 🪤 Сосчитать идущих не вышло — это НЕ повод пускать: но и не повод отказывать здесь.
// Ниже стоит своя, полная проверка того же (`schyotIdushchih`), она и откажет словами.
// Второго правила об одном я тут не завожу — оно молча разъехалось бы с первым.
const putZamka = zamokPut(planPath);
const zamok = vzyatZamokZapuska({ path: putZamka });
if (!zamok.vzyal) {
console.error('Прогон НЕ запущен: этот же план прямо сейчас запускает кто-то ещё.');
console.error(` · ${zamok.reason}`);
return 1;
}
// Перехват брошенного замка — не пустяк и молчать о нём нельзя: владелец обязан знать,
// что здесь остался след оборванного запуска.
if (zamok.reason) console.log(`Замок запуска: ${zamok.reason}`);
// 🔴 ОДНА дверь наружу с этой минуты, а не пять `return` вразнобой. Забудь я отдать замок
// хоть на одном из выходов — следующий запуск того же плана уткнулся бы в него. Смертельным
// это не было бы (дверь выше спрашивает систему, жив ли хозяин, а этот процесс к тому
// времени мёртв), но в ОДНОМ процессе — а так зовут проверки — заперло бы наглухо.
const konec = kod => { otdatZamokZapuska({ path: putZamka }); return kod; };
// ═════════════════════════════════════════════════════════════════════════
// 🔴🔴 ВСЁ БЕСПЛАТНОЕ — ВЫШЕ ПЛАТНОГО. ГЛАВНАЯ ДЕНЕЖНАЯ ТРАТА ЗАТЕИ (круг Б, 04.08.2026).
//
// Раньше эти пять замеров (место, отпечаток, хозяйство, бумаги, замысел) стояли НИЖЕ
// платного замера, и ворота судили по ним уже после того, как деньги ушли. По следу
// оплаты в `.night/probe-raw/`: 0,10682 $ сожжено тремя замерами, после которых старта
// не случилось, — против 0,00767 $ на удвоении запуска, которое чинили до этого.
// Всё, что стоит ниже платного шага, — не защита, а отчёт о потере.
//
// 🔴 Каждая строка ниже стоит НОЛЬ: чтение файлов, вызов `git` и замер свободного места.
// Платного здесь нет ничего, и появиться ему тут нельзя.
const disk = await measureDisk(cfg);
const seal = checkSeal(planSealPath(planPath), planPath);
// 🔴 Тот же счёт и та же одна дверь, что у `seal`. Порванная карточка — это «неизвестно,
// идёт прогон или нет»: с таким ответом ворота потолков и ворота пересечения по файлам
// пропустили бы что угодно, и оба отказа исчезли бы молча. Значит прогона нет, и сказано
// почему.
// 🔴 СЧЁТ ИДУЩИХ БЕРЁТСЯ ТЕМ ЖЕ ЗОВОМ, ЧТО И У ДВЕРИ ДВОЙНИКА ВЫШЕ (`idushchie`), а не
// вторым прямым походом в каталог. Прежде здесь стоял прямой `idushchieProgony()`: один
// запуск читал карточки ДВАЖДЫ, и два чтения могли разойтись между собой (за секунды
// между ними прогон успевает завестись или умереть) — а проверки, подставлявшие первый
// зов, второго не видели вовсе и сторожили не то, что работает.
const schyotIdushchih = kartochki;
if (!schyotIdushchih.ok) {
console.error(`Прогон НЕ запущен. ${schyotIdushchih.reason}`);
return konec(1);
}
const running = schyotIdushchih.running;
// 🔴 РАЗБОР ХОЗЯЙСТВА ГОВОРИТСЯ ВСЛУХ ДО ВОРОТ. Ворота дальше скажут сухое «идёт задач: N,
// а потолок — M», и это число само по себе владельца обманывает: он читает его как
// «хозяйство занято работой», а бывало, что все N — покойники с прошлой ночи. Три
// состояния разложены тремя РАЗНЫМИ строками; сваливать их в одну запрещено (§ отказа).
for (const s of strokiProProgony(schyotIdushchih)) console.log(s);
const papers = papersCommitted(REPO_ROOT, paperPathsFor({ planPath, priyomkaPath, specPath }));
// 🔴 Вторым доводом — путь бумаги замысла, по которой запускают. Без него отметка
// «замысел проверен» открывала бы запуск по ЛЮБОЙ другой бумаге, в том числе никем
// не читанной (задача 4 сверяет `spec_path` в отметке с этим путём).
const spec = specApproved(readJson(specApprovalPath()), specPath);
// 🔴 ВХОД ВОРОТ СОБИРАЕТСЯ ОДИН РАЗ И СЛУЖИТ ОБОИМ ПРОХОДАМ — бесплатному (сейчас)
// и полному (после замера). Собери его дважды — бесплатный проход судил бы одно
// хозяйство, а платный другое, и владелец получил бы «до денег было можно, после
// денег нельзя» без единого слова о том, что изменилось.
// 🔴 ПОПРАВЛЕНО ПРОТИВ ПЛАНА, вслух: план предлагал разобрать доводы здесь ещё раз
// (`const dovody = razborDovodov(argv)`), описывая `cli.mjs` таким, каким тот был
// ДО куска 1. Разбор уже сделан выше и лежит в `razbor` — второй разбор рядом означал бы
// два места, знающих доводы по-своему, и проверки сторожили бы не то, что работает.
const vhod = vhodVorot({
// 🔴 Пункты уже разобраны выше и сюда подаются готовыми. `planPath` здесь НЕ подаётся
// намеренно: второе чтение файла плана — это второй план в одном запуске.
cfg, items: punkty, budget: null, disk, planSeal: seal, papers, spec, running,
// Номер задачи доходит и до ворот: продолжение начатого третьей задачей не считается,
// и своими же файлами задача сама с собой не пересекается.
taskId: razbor.taskId ?? null,
});
// 🔴🔴 БЕСПЛАТНЫЙ ПРЕДПРОХОД — ПОСЛЕДНЕЕ, ЧТО СТОИТ ПЕРЕД ДЕНЬГАМИ. Восемь ворот из
// девяти судят прямо здесь и стоят ноль. Отказал хоть один — платного замера не будет
// вовсе, и владельцу сказано это прямым текстом.
const doDeneg = besplatnyeVorota({ vhod });
if (!doDeneg.ok) {
for (const s of strokiOtkazaDoDeneg(doDeneg)) console.error(s);
console.error(stroka_pro_nestorozhashchie());
return konec(1);
}
// 💳 Один крошечный замер остатка ПЕРЕД воротами запаса. Без него воротам «запас» нечем
// питаться, и они пропустили бы что угодно. Замер идёт программой `claude` из подписки —
// ANTHROPIC_API_KEY не выставляется никогда, оплата ушла бы мимо подписки.
//
// 🔴 РЕШЕНИЕ ВЛАДЕЛЬЦА, дословно: замер делается СВЕЖИЙ перед КАЖДЫМ запуском, и срока
// годности у него нет вовсе. Не вышел замер — прогона нет, и владельцу сказано почему.
// Никакой «ещё не протухшей» строки из журнала: между тем замером и этой минутой могла
// пройти дневная работа и съесть весь запас, а решать о деньгах вслепую нельзя (проверка 17).
//
// 🪤 ПРЕЖНЯЯ ОГОВОРКА СНЯТА ЭТОЙ ЖЕ ПРАВКОЙ. Здесь стояло: «замер делается ДО ворот,
// значит КАЖДЫЙ отказ ворот тоже стоит замера» — и это была правда, стоившая 0,10682 $.
// Теперь до замера доходит только тот запуск, которому все восемь бесплатных ворот
// уже сказали «можно», и платит он ровно за то, чего иначе узнать нельзя, — за деньги.
//
// 🔴 РАЗБОР ОТВЕТА — ЧЕРЕЗ ОБЩУЮ ДВЕРЬ `vorotaPribora` (задача О-4, 03.08.2026), НЕ СВОИМ
// КУСКОМ КОДА. Раньше здесь стоял готовый код (try/catch, `z.ok`, `z.measure`) — и когда
// `smoke-longrun` понадобилась та же дверь, копия вниз завела бы ВТОРОЕ правило одного
// смысла. Дверь вынесена выше по файлу, `run` и `smoke-longrun` зовут ОДНУ и ту же.
// 🔴 Запись замера теперь делает САМА дверь, сразу как деньги потрачены (круг Б). Своей
// второй записи здесь нет намеренно: две записи одного замера дали бы двойную строку
// в журнале, а по нему считаются оба денежных предела ночи.
// 🔴 РАЗОВЫЙ КЛЮЧ ВЛАДЕЛЬЦА подаётся ОБЕИМ денежным дверям одним и тем же значением,
// взятым из разбора доводов. Собери его тут заново (вторым `argv.includes`) — и однажды
// одна дверь считала бы ключ набранным, а вторая нет.
const klyuchVladeltsa = Boolean(razbor.resheniyeVladeltsa);
const dengi = await priborZamera({ cfg, komanda: 'run', phase: 'before-run', resheniyeVladeltsa: klyuchVladeltsa });
if (!dengi.ok) return konec(1);
const m = dengi.measure;
const budget = budgetState(budgetMeasuresPath(), cfg, Date.now(), { resheniyeVladeltsa: klyuchVladeltsa });
// 🔴 ПОЛНЫЙ ПРОХОД ВСЕХ ДЕВЯТИ ВОРОТ — тем же входом, что судился бесплатно, плюс ответ
// про деньги. Восемь из них здесь заведомо зелены (их только что спросили даром), но
// спрашиваются они заново намеренно: правило «прогон заводит только полный проход ворот»
// одно, и обходить его коротким путём «раз бесплатные прошли, судим один запас» нельзя —
// так в затее уже однажды появилась дверь, которая обещала деньги, а проверяла прибор.
const plan = planRun({ ...vhod, budget });
if (!plan.ok) {
for (const s of strokiOtkaza(plan, budget)) console.error(s);
// 🔴🔴 БЫЛО МОЛЧАНИЕ ИМЕННО ЗДЕСЬ (задача О-5). Строка про недостроенные ворота печаталась
// ниже, только на удаче — а на отказе владелец её не видел вовсе. Читал «не пройдено:
// такие-то ворота» и мог решить, что всё остальное (включая двоих незапертых) держит.
// Правда должна звучать в обоих исходах, а не только в том, что случается реже.
console.error(stroka_pro_nestorozhashchie());
return konec(1);
}
// 🔴🔴 РЕШЕНИЕ Р5, ВИДИМАЯ ЧАСТЬ. Ворота запаса пропустили ПО СВЕТОФОРУ СИСТЕМЫ, а не
// по числу, — то есть прежняя рамка владельца (остаток над порогом неприкосновенен) сейчас
// не меряется ничем. Это СУЖЕНИЕ ЕГО ЗАЩИТЫ, и оно обязано быть видно ему прямым текстом,
// а не жить примечанием к коду. Печатается ДО слов «прогон заведён»: строка, спрятанная
// под списком работников, до глаз не доходит.
if (budget?.dolya_ne_prishla && budget?.slova_vladeltsu) {
console.log(budget.slova_vladeltsu);
}
// 🔴 ТОЧКА ОТСЧЁТА ПРОГОНА. Крошечный замер выше лёг в журнал БЕЗ номера прогона —
// номера в ту минуту ещё не существовало, его выдал `planRun`. А прирост прогона
// считается от первой строки ЭТОГО прогона (задача 2, `utilizationDelta`).
// Не дописать её — точкой отсчёта станет первый тик первого работника, всё съеденное
// до него в прирост не войдёт, и предел одной седьмой сработает позже обещанного.
// Число берём то же самое, что и в замере перед воротами: второй раз прибор не спрашиваем.
// 🔴🔴 ПОКАЗАНИЯ СВОЕГО ВИДА МОЖЕТ НЕ БЫТЬ ВОВСЕ — и только по разовому ключу владельца
// (без ключа дверь прибора сюда не пустила бы). Записывать тогда НЕЧЕГО, и выдумывать
// точку отсчёта нельзя: подставленное чужое показание сделало бы прирост прогона разницей
// чисел из разных запасов, то есть числом, которого не бывает. Молчать об этом тоже
// нельзя — говорим прямо, чего именно у ночи не будет.
// 🪤 Оплаченное показание при этом НЕ ПОТЕРЯНО: его уже положила в журнал сама дверь
// прибора (`zapisatOplachennoe`), с пометкой, какого вида ждала настройка.
// 🔴🔴 УСЛОВИЕ НАПИСАНО «ОТ ОБРАТНОГО» — СНАЧАЛА СЛУЧАЙ «ПОКАЗАНИЯ НЕТ». ЭТО НЕ ПРИЧУДА,
// И «ПРИЧЁСЫВАТЬ» ЕГО НЕЛЬЗЯ. На прямую связку «показание есть, значит пишем замер»
// в этом файле стоит сторож (`oplachennoe-ne-propadaet.test.mjs`, правило «старого условия
// „пиши, только если вид совпал“ в cli.mjs больше нет»): ровно в такой форме здесь однажды
// сгорели 0,14828025 $ — оплаченное показание чужого окна выбрасывалось вместе с деньгами.
// 🪤 Сторож ищет ФОРМУ по всему файлу, а не место, — значит его будит ЛЮБАЯ запись под
// условием по показанию, даже безобидная вроде этой (она пишет уже записанное второй раз,
// ради точки отсчёта прогона, и денег стоить не может). Разверни условие обратно — прогон
// проверок покраснеет, и покраснеет ПРАВИЛЬНО: две записи одной формы текстом не различить.
// 🔴 Само правило при этом не ослаблено ни на волос: платную запись по-прежнему делает
// дверь прибора БЕЗУСЛОВНО (`zapisatOplachennoe`), и её сторожат четыре правила того файла.
if (!m) {
console.log('🔴 Точки отсчёта расхода у этой ночи НЕТ: показания заказанного окна система не прислала. Значит и прирост за ночь посчитать будет не из чего.');
} else {
recordMeasure(budgetMeasuresPath(), {
utilization: m.utilization,
status: m.status,
kind: m.kind,
resets_at: m.resets_at,
at: Date.now(),
run_id: plan.run_id,
phase: 'start',
// 🔴 Второй светофор («дадут ли работать сверх запаса») — из того же показания.
sverh_zapasa: m.sverh_zapasa ?? null,
sverh_pochemu: m.sverh_pochemu ?? null,
});
}
// ━━━ 🔴🔴 «ЗАПУСКАЛИСЬ ВСЛЕПУЮ» ИДЁТ В УТРЕННЮЮ СВОДКУ, А НЕ ТОЛЬКО В ОКНО
// (решения владельца 04.08.2026: порог `start_floor_share` 0,99 и тумблер
// `bez_doli_ne_zapuskat` ВЫКЛ — оба оставлены сознательно).
//
// 🔴 ПОЧЕМУ ЭТОГО МАЛО — ПЕЧАТАТЬ В ОКНО. Строка выше (`console.log`) видна тому, кто
// жмёт запуск и в эту минуту смотрит в экран. А ночь запускает планировщик, и владелец
// узнаёт о ней утром из письма. Значит про сужение своей защиты он не узнавал НИ РАЗУ
// именно в том случае, ради которого затея и построена, — в автоматическом ночном пуске.
//
// 🔴 Раз тормоза по решению владельца практически нет, остаётся ОТЧЁТ. Молчаливое
// сужение защиты — это самообман того же рода, что молчаливо пропускающие ворота.
// Слова берутся у запаса (`slova_vladeltsu`), своих здесь не сочиняется: два текста
// об одном разъезжаются молча.
// 🔴🔴 ПОПРАВЛЕНО 05.08.2026: «доли нет» и «запускались ВСЛЕПУЮ» — РАЗНЫЕ вещи. Система
// зажигает тревогу на 75 % и только тогда прикладывает число; значит зелёный светофор
// без доли ДОКАЗЫВАЕТ расход ниже 75 %, и при пороге старта 0,99 старт доказан. Писать
// владельцу «запускались вслепую» в этом случае — враньё в его же сводке. Решает не эта
// строка, а сам запас (`vslepuyu` из `budgetState`): двух правил об одном тут не заводим.
if (budget?.dolya_ne_prishla && budget?.slova_vladeltsu) {
// 🔴🔴 У НОЧИ ПО РАЗОВОМУ КЛЮЧУ — СВОЙ ЗАГОЛОВОК, И ЭТО НЕ УКРАШЕНИЕ, А ЕДИНСТВЕННЫЙ
// СПОСОБ ДОВЕЗТИ ЭТИ СЛОВА ДО ВЛАДЕЛЬЦА. Утренняя бумага выбрасывает строки, начинающиеся
// словами «Запускались ВСЛЕПУЮ», когда про слепоту она уже сказала своими словами
// (`CHUZHIE_FAKTY` в utro.mjs — свёртка «один факт дважды»). Начнись строка про ключ
// теми же словами — весь громкий текст о том, на каком основании пошла ночь, пропал бы
// из бумаги МОЛЧА. Замерено проверкой «строка про ключ доезжает до бумаги».
// 🪤 `vslepuyu` при этом остаётся ИСТИННЫМ и не подкручивается: про заказанное окно мы
// вправду ничего не знаем. Меняется заголовок строки, а не правда о ночи.
const golova = budget.po_resheniyu_vladeltsa
? 'Ночь запущена РАЗОВЫМ КЛЮЧОМ ВЛАДЕЛЬЦА, без числа недельного остатка. '
: (budget.vslepuyu
? 'Запускались ВСЛЕПУЮ: доли израсходованного система не прислала. '
: 'Старт был ДОКАЗАН, а не слеп: доли система не прислала, но и тревоги не зажигала. ');
appendLine(summaryLinesPathOf(plan.run_id), {
at: Date.now(),
line: `${golova}${budget.slova_vladeltsu}`,
});
}
// ━━━ 🔴🔴 ЖЁЛТЫЙ СВЕТ ГОВОРИТСЯ ВСЛУХ (решение владельца 04.08.2026, дословно:
// «тормозить только на красный, жёлтый — со словами в сводке»).
//
// 🔴 ЧТО БЫЛО СЛОМАНО. Все замеры недельного запаса приходили ЖЁЛТЫМИ («тревога по
// запасу»), зелёного затея не видела ни разу — а решение о старте, когда число пришло,
// смотрит ТОЛЬКО на число (`utilization < floor`) и цвета не касается вовсе. Значит про
// тревогу системы владельцу не говорил никто. Останавливать на жёлтом он запретил —
// значит взамен остаётся отчёт, и молчать нельзя.
//
// 🔴 СЛОВА ЦВЕТА БЕРУТСЯ У ЕДИНСТВЕННОГО ИХ ХОЗЯИНА (`SVETOFOR_RU`), а не набираются
// здесь заново. Свой второй перевод трёх цветов разъехался бы с первым молча.
const svetStroka = strokaProSvet(budget?.status ?? null);
if (svetStroka) {
console.log(svetStroka);
appendLine(summaryLinesPathOf(plan.run_id), { at: Date.now(), line: svetStroka });
}
const started = startAll({
runId: plan.run_id,
workers: plan.workers,
planPath,
baseBranch: plan.base_branch,
// Голова работника — из настроек владельца. Здесь она только передаётся дальше.
workerModel: cfg.worker_model,
// Продолжаем названную владельцем задачу; не назвал — прогон сам себе задача.
taskId: razbor.taskId ?? plan.run_id,
// 🔴 След решения в карточке прогона: на каком основании эта ночь вообще пошла.
klyuchVladeltsa: klyuchVladeltsa ? KLYUCH_RAZRESHENIYA_VLADELTSA : null,
// 🔴 Команда проверок — своя у каждого пункта, из его же `tests`. Общий набор здесь
// превратил бы «зелень по этому пункту» в «зелень вообще».
testCommandFor: w => komandaProverokPunkta(w.item),
// 🔴 В карточку прогона идут ВСЕ пункты плана, а не только пункты семерых заведённых:
// это очередь, из неё надзиратель берёт работнику следующий пункт.
items: punkty,
});
// 🔴🔴 «ЗАВЕДЁН» — ЭТО ПРО ЖИВЫХ РАБОТНИКОВ, А НЕ ПРО ЗАПИСЬ НА ДИСКЕ. Найдено воротами
// живьём: при нуле заведённых владельцу печаталось «Прогон заведён. Работников: 0» и следом
// «надзиратели переживут конец сессии» — при нуле надзирателей. Это прямое враньё тому,
// кто после этого закроет окно и уйдёт спать. Сам прогон при нуле работников закрывается
// в `startAll` (иначе он навсегда держал бы место в потолке задач) — здесь только правда
// словами и код возврата, отличный от успеха: ночь не началась.
if (!started.length) {
console.error(`Прогон ${plan.run_id} НЕ начался: не завёлся НИ ОДИН работник.`);
console.error(`Почему — написано в карточках работников (поле finish_reason): ${runsDir()}`);
console.error('Чаще всего это два пункта плана на один и тот же файл либо несобравшийся composer/npm в углу.');
console.error('Прогон закрыт сразу — места в потолке задач он не занимает, и следующая попытка ничем не заперта.');
// 🔴 БУМАГА ВЛАДЕЛЬЦУ ЗДЕСЬ НЕ КЛАДЁТСЯ, И ЭТО НЕ ЗАБЫВЧИВОСТЬ. Ночь тут вправду
// кончилась, но закрывает её `writeRunOutcome` внутри `startAll` — то же самое место,
// что закрывает ночь после последнего погасшего работника. Бумагу кладёт ОНО.
// Положи её ещё и здесь — в затее завелось бы второе правило «прогон кончился»,
// ровно то, чего `run.mjs` избегает вслух. Одна дверь, один зов.
//
// 🔴🔴 А ВОТ СУДЬЮ ЗОВЁМ — И ИМЕННО ЗДЕСЬ (заведено 07.08.2026 по слову владельца).
// Такую ночь закрывает `startAll`, минуя надзирателя, а судью зовёт только надзиратель —
// значит на самую тревожную ночь из всех взгляда со стороны не было ВООБЩЕ.
// 🪤 Зов стоит ЗДЕСЬ, а не в `startAll`, по замеренной причине: рождение судьи
// возвращает обещание, а `startAll` синхронная — ждать там было бы нечем, и вышли бы
// бумаги о судье без судьи. Здесь `await` возможен.
// 🔴 Порядок: судья идёт ПОСЛЕ того, как `startAll` закрыл прогон. Позови раньше —
// он читал бы незакрытую ночь и судил недописанное.
// 🔴 Сама дорога живёт в `supervisor.mjs`: живое рождение разрешено поимённо трём
// файлам, и этот среди них, а `cli.mjs` — нет. Здесь только зов.
await sudyaPustoyNochiFn({ runDirPath: runDir(plan.run_id), runId: plan.run_id, cfg });
return konec(1);
}
// След снят: задача пошла в работу. Не сними — следующий запуск отказал бы по нему снова,
// и владельцу пришлось бы каждый раз говорить «продолжай» заново. Раньше снимать нельзя:
// до этой строки любой отказ обязан застать след на месте, иначе вторая попытка прошла бы
// мимо ворот вовсе.
if (sled && zadacha) clearDeferred(deferredDir(), zadacha);
console.log(`Прогон ${plan.run_id} заведён. Работников: ${started.length}.`);
for (const s of started) {
console.log(` · ${s.worker_id} — угол ${s.workspace}, база ${s.db_name}, надзиратель ${s.supervisor_pid}`);
}
console.log('Окно можно закрывать: надзиратели переживут конец сессии.');
console.log(`Замысел ${specPath} проверен, бумаги сохранены, отпечаток плана сошёлся.`);
// 🔴 Честность про пустые ворота. «Все ворота пройдены» при четырёх ненаписанных правилах —
// это самообман; владелец обязан знать, что его сторожат пятеро, а не девятеро.
console.log(stroka_pro_nestorozhashchie());
return konec(0);
}
if (cmd === 'smoke-longrun') {
// ━━━ 🔴🔴 ВОРОТА, НАЙДЕННЫЕ ПРОПУЩЕННЫМИ 03.08.2026 (задача О-4, находка третьего
// помощника со стороны). Проба заводит ТОГО ЖЕ платного работника, что и `run`, а ворот
// у неё не было НИ ОДНИХ: сверка настроек и денежные ворота стояли только в куске `run`,
// ДО развилки на `--resume`, — а `smoke-longrun` сравнивает `cmd` ЗАНОВО, отдельной веткой,
// и внутрь ветки `run` не заходит вовсе. Единственная команда затеи без денежных ворот
// оказалась ровно той, которой собирались делать первое живое рождение работника.
//
// 🔴 ОБЕ ДВЕРИ — ТЕ ЖЕ САМЫЕ, а не вторые копии: `proverkaVladeltsa` — тот же довод главной
// программы, что уже стоит у `run` чуть выше по файлу; `priborZamera` — дверь, вынесенную
// ИЗ `run` специально ради этого (раньше разбор ответа замера был готовым куском кода
// только внутри `run`, и своя копия здесь завела бы второе правило одного смысла).
//
// 🟡 РЕШЕНО НЕ ВЕСТИ ПРОБУ ЧЕРЕЗ ВСЕ ДЕВЯТЬ ВОРОТ обычного прогона (план, приёмка, замысел,
// отпечаток, место на диске, потолки задач/работников, пересечение файлов, отложенная
// задача) — только через эти ТРИ (сверка настроек · прибор замера · запас владельца;
// третья врезана 03.08.2026, до неё их было две и денег не сторожила ни одна).
// Причина сужения: проба не план и не задачу заводит, а один
// работник на N минут вручную, руками, обычно когда что-то чинят; своего плана/приёмки/
// замысла у неё нет и не будет, и требовать их означало бы придумывать бумаги под
// диагностический инструмент. Сужение названо, а не спрятано: см. отчёт задачи.
try {
// 🔴 Тот же признак, что и у обычного запуска: проба заводит ОДНОГО живого платного
// работника, и отставшая ветка стоит ей ровно столько же — он получит код, который
// не запускается. Разной строгости у двух дорог к одним и тем же деньгам быть не должно.
const slova = proverkaVladeltsa({ zhivoyZapusk: true });
for (const s of slova) console.log(s.tekst);
} catch (err) {
console.error('Проба НЕ запущена: настройка ночи разошлась с решением владельца дороже обещанного (либо сверку сделать не вышло — это тоже не повод молча пускать платного работника).');
console.error(err?.message ?? String(err));
return 1;
}
const dengi = await priborZamera({ cfg, komanda: 'smoke-longrun', phase: 'before-smoke', otkaz: 'Проба НЕ запущена' });
if (!dengi.ok) return 1;
// 🔴🔴 ВОРОТА ЗАПАСА — ровно то, чего тут не было. `priborZamera` выше спрашивает прибор
// («замер сделан? число пришло?»), и на этом останавливалась. Хватает ли запаса
// по правилу владельца — спрашивается здесь, ПОСЛЕ записи свежего замера в журнал:
// счёт запаса читает последний ЗАПИСАННЫЙ замер, а не тот, что держит в руках зовущий.
// 🔄 ПОПРАВЛЕНО КРУГОМ Б: запись свежего замера сделала САМА дверь прибора, сразу как
// деньги ушли. Здесь остался только вопрос о запасе — он читает последний ЗАПИСАННЫЙ
// замер, и записан он уже строкой выше. Прежняя пара «записать и спросить» (`zapisatIZapas`)
// отсюда убрана: писала бы второй раз, а по этому журналу считаются денежные пределы ночи.
//
// 🟡 Из девяти ворот обычного прогона проба по-прежнему не идёт ни через одни (план,
// приёмка, замысел, отпечаток, диск, потолки, пересечение файлов, отложенная задача) —
// довод прежний и не меняется: это бумаги под план, а у пробы плана нет. Но ворота
// запаса НЕ ИЗ ТОГО РЯДА: они не про бумаги, а про деньги владельца, и трата у пробы
// ровно того же класса, что у прогона (тот же платный работник, те же часы).
const zapas = vorotaZapasa({
budget: sostoyanieZapasa(budgetMeasuresPath(), cfg, Date.now()),
otkaz: 'Проба НЕ запущена',
});
if (!zapas.ok) return 1;
// Доказательство проверки 1: движение должно быть и через три часа после конца сессии
// помощника. Проверками кода это не доказывается — только живым прогоном.
const minutes = Number(flag(argv, '--minutes') ?? 200);
// 🔴 Имя прогона можно назвать своё (`--run`). Без этого вторая попытка невозможна:
// ветка и угол от первой уже заняты, а каталог прошлого прогона сносить нельзя —
// из него живёт сводка. Короткая проба перед ночью зовётся как раз со своим именем.
const runId = flag(argv, '--run') ?? 'smoke';
const r = await probaProgon({ cfg, minutes, runId });
console.log(`Пробный долгий прогон заведён: прогон ${runId}, работник ${r.worker_id}, надзиратель ${r.supervisor_pid}.`);
console.log(`Признаки жизни: ${r.heartbeat_path}`);
console.log(`След работника: ${r.notes_path}`);
console.log('Закройте окно помощника целиком и вернитесь через три часа.');
console.log(`Бумагу по итогам класть сюда: ${join(priyomkaDir(), '1')}`);
return 0;
}
console.error(`Неизвестная команда: ${cmd ?? '(команду не назвали)'}. Написаны восемь: run, mail, summary, smoke-longrun, budget, seal, approve-spec, answer.`);
return 2;
}
// Точка входа звонит только когда файл запущен САМ (`node tools/night/cli.mjs …»),
// а не подключён проверкой через import — иначе проверка сама себе позвала бы
// `process.exit` при подключении файла, и ни один тест этого файла не дожил бы до конца.
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
main().then(code => process.exit(code));
}