Files
portal/tools/night/plan-lock-hook.mjs
T

212 lines
19 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
// Замок на правку плана, по которому прямо сейчас идёт прогон (проверка 43, часть).
//
// 🔴 Зачем отдельный файл. Правило `assertPlanEditable` написано в plan-fingerprint.mjs,
// но правило без зовущего зеленеет на проверках и не срабатывает ни разу. Зовёт его
// ЭТОТ файл, а его — lefthook перед каждым коммитом.
//
// 🪤 В отдельном рабочем углу хуки не запускаются вовсе (проверено живьём 21.07.2026).
// Поэтому замок стоит в ГЛАВНОМ каталоге хозяйства, где план правит владелец или помощник.
// Работник свой план не правит — он его исполняет.
//
// Вторая половина проверки 43 — сама команда «стоп всё» — строится в куске 3. После неё
// последний погасший работник ставит прогону `finished`, и замок снимается сам.
import { existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { readJsonOrBroken } from './state.mjs';
// 🔴 Сравниватель пути — ОДИН на всю затею, и берётся он у paths.mjs. Здесь лежала его
// дословная копия (`samePath`), а вторая такая же — у печати (plan-fingerprint.mjs).
// Замок и печать ОБЯЗАНЫ отвечать одинаково: прежде здесь не было `resolve`, и печать
// считала `C:\…\docs\план.md` и `docs\план.md` одним файлом, а замок — разными; замок
// пропускал правку идущего плана тихо и зелено. Свести мешал круг по ввозу — печать
// ввозит сюда правило `assertPlanEditable`, обратный ввоз замкнул бы круг. paths.mjs
// не ввозит из затеи ничего, поэтому круга нет и копий больше не нужно.
import { runsDir, samePath } from './paths.mjs';
import { assertPlanEditable } from './plan-fingerprint.mjs';
// 🔴 ЖИВОСТЬ СПРАШИВАЕТСЯ У ГОТОВОГО ПРИБОРА, А НЕ МЕРЯЕТСЯ ЗДЕСЬ ВТОРЫМ СПОСОБОМ.
// Прибор построен и принят вырезанием в этот же день; вторая мысль о живости, написанная
// рядом, разъехалась бы с первой молча — ровно так уже разъезжались сравниватели путей
// (см. примечание к samePath выше). Своей `process.kill` в этом файле нет намеренно.
import { sostoyanieProgona, schitatZanyatym } from './zhiv-li-progon.mjs';
// ━━━ СТОРОНА ОШИБКИ У ЗАМКА ДРУГАЯ, ЧЕМ У ВОРОТ ЗАПУСКА. НАЗЫВАЮ ВСЛУХ ━━━
//
// Ворота запуска (`idushchieProgony` в cli.mjs), ошибившись в сторону «жив», всего лишь
// не пустят лишнюю ночь: владелец утром прочтёт строку и уберёт карточку. Ошибка обратимая
// и видимая.
//
// Замок, ошибившись в сторону «мёртв», ПУСТИТ ПРАВКУ ПЛАНА ПОД ЖИВЫМ РАБОТНИКОМ, который
// по этому плану прямо сейчас работает. Работник читает план кусками по ходу ночи: первые
// пункты он уже сделал по старому тексту, следующие прочтёт по новому. Получится ночь,
// собранная из двух разных замыслов, и понять это можно будет только утром по итогу,
// которого никто не заказывал. Ошибка ТИХАЯ и НЕОБРАТИМАЯ — сделанную работу назад не вернуть.
//
// ⇒ РЕШЕНИЕ: замок остаётся ЗАКРЫТЫМ на всём, кроме доказанно мёртвого, — и это ровно то же
// правило, что у ворот (`schitatZanyatym`, одно на затею, второй копии здесь нет). Разница
// не в правиле, а в цене: у ворот запас на осторожность есть, у замка его нет вовсе.
//
// 🪤 ЗДЕСЬ СТОЯЛА ССЫЛКА НА ВТОРОЙ, НЕЗАВИСИМЫЙ ТОРМОЗ `svezhLiZhurnal` — а его в файле НЕТ
// (замерено поиском по всему файлу 03.08.2026, единственное упоминание было в этой строке).
// Обещание защиты, которой не существует, опаснее честного её отсутствия: читающий верит,
// что у ответа «мёртв» есть вторая опора, и не ставит её. Второй опоры нет: замок целиком
// стоит на приборе живости. Понадобится — заводить её надо вправду, а не строкой в пояснении.
export function planLockVerdict({ staged = [], runs = [], sudit = sostoyanieProgona, now = Date.now() }) {
const pomecheny = runs.filter(r => r && !r.broken && r.status === 'running' && r.plan_path);
const going = [];
const pokoyniki = [];
for (const run of pomecheny) {
// ━━━ 🔴🔴 ЗДЕСЬ СТОЯЛА ЗАПЛАТКА ПРО ПРОДОЛЖЕНИЯ. СНЯТА 03.08.2026 ОСОЗНАННО ━━━
// 🔴 СЛЕДУЮЩЕЙ СМЕНЕ: не возвращай её, не дочитав этот кусок до конца. Со стороны она
// выглядит как защита, которую кто-то по недосмотру выбросил. Это не так.
//
// ЧТО ЗДЕСЬ БЫЛО. Строка `if (run.resumed_from) { … continue; }` — прогон-ПРОДОЛЖЕНИЕ
// («Продолжить» после оборванной ночи) замок не отдавал прибору на суд вовсе и оставался
// на нём ЗАКРЫТЫМ всегда.
//
// ПОЧЕМУ ЭТО БЫЛО ПРАВИЛЬНО ТОГДА. Прибор живости искал в журнале только строку
// `supervisor_start`, а продолжение объявляет своего надзирателя строкой `resume_start`
// (run.mjs ~688) — номер там ЕСТЬ, но под другим словом. Прибор его не видел и отвечал
// «МЁРТВ» про ЖИВОЙ прогон. Замерено живьём подложенным заведомо живым номером процесса.
// Пока прибор врал именно в эту сторону, отказ судить был меньшим злом: у замка ошибка
// «мёртв» тихая и необратимая (см. кусок выше), а запертый лишний план чинится рукой.
//
// ПОЧЕМУ ЕЁ ПРИШЛОСЬ СНЯТЬ. Прибор починен в тот же день: он читает обе записи разом
// (`VIDY_ZAPISEY_NADZIRATELYA` в zhiv-li-progon.mjs), и на продолжении отвечает правду.
// С этой минуты заплатка перестала защищать и начала ПЕРЕКРЫВАТЬ ПРАВДУ: брошенное
// продолжение держало бы свой план запертым НАВСЕГДА — ровно та беда, ради которой
// прибор и заводили. Двух мнений о живости в затее быть не должно: одно правило
// (`schitatZanyatym`) и один прибор, второе рядом разъезжается с первым молча.
//
// 🔴 ЕСЛИ ЗАХОЧЕШЬ ВЕРНУТЬ — сперва замерь, вправду ли прибор ошибается на продолжении:
// сложи каталог с одной строкой `resume_start` и своим собственным номером процесса
// и спроси `sostoyanieProgona`. Ответ `idyot` — значит прибор цел и заплатка не нужна.
// Ответ `mertv` — значит сломали прибор, и чинить надо ЕГО, а не ставить обход здесь.
// На разъезд «чем надзиратель себя объявляет / что прибор читает» стоит отдельный сторож
// `nadziratel-obyavlyaetsya-a-pribor-chitaet.test.mjs` — он покраснеет раньше тебя.
//
// 🪤 Каталог прогона берётся ИЗ КАРТОЧКИ (`card_dir`, кладёт его `readRuns`), а не
// складывается из номера прогона: номер живёт ВНУТРИ карточки и с именем папки совпадать
// не обязан. Соберись он здесь из номера — у переименованной папки прибор ответил бы
// «каталога нет», и это правильно прочлось бы как «не прочесть», но зря.
const sud = sudit(run, { dir: run.card_dir ?? null, now });
if (schitatZanyatym(sud.vid)) { going.push(run); continue; }
pokoyniki.push({ ...sud, plan_path: run.plan_path });
}
const blocked = [];
const reasons = [];
// 🔴 БИТАЯ КАРТОЧКА — НЕ «ПРОГОНА НЕТ». Прочесть `run.json` не удалось — значит неизвестно,
// идёт по нему прогон или нет. Молча выкинуть такой прогон из списка (так было раньше:
// `readJson` отдавал пустоту, а `.filter(Boolean)` её убирал) значит открыть замок ровно
// в тот момент, когда он нужнее всего: `run.json` вероятнее всего порвётся именно в ту ночь,
// когда на сервере кончилось место. Не смогли прочесть — замок ЗАКРЫТ.
//
// Какой именно план идёт по битой карточке, мы не знаем, поэтому не принимается ВСЁ, что
// подали. Сторож зовёт хук только на планы (`glob: docs/superpowers/plans/*.md`), так что
// под запрет попадают планы, а не весь коммит.
const broken = runs.filter(r => r && r.broken);
for (const run of broken) {
for (const file of staged) {
if (!blocked.includes(file)) blocked.push(file);
}
reasons.push(
`Карточку прогона ${run.run_id} прочесть не удалось: файл ${run.card_path ?? 'run.json'} на месте, `
+ 'но разобрать его нельзя. Идёт по нему прогон или он давно кончился — неизвестно, поэтому правка '
+ 'плана не принимается: открыть замок вслепую хуже, чем задержать правку. Так бывает, когда на '
+ 'сервере кончилось место посреди записи. Посмотрите этот файл: прогон кончился — поправьте или '
+ 'удалите карточку, прогон идёт — остановите его командой «стоп всё».',
);
}
for (const run of going) {
for (const file of staged) {
if (!samePath(file, run.plan_path)) continue;
// 🔴 Слова причины берутся у САМОГО правила. Напиши их здесь заново — два текста
// про одно правило разошлись бы молча.
const rule = assertPlanEditable({ status: 'running', run_id: run.run_id });
// 🔴 Один файл — одно имя в списке, сколько бы прогонов по нему ни шло. Иначе
// владелец читает одно и то же дважды. Номера прогонов при этом называются оба:
// остановить придётся каждый.
if (!blocked.includes(file)) blocked.push(file);
// 🔴 ФРАЗА ПРО «ЗАТЕЯ ПОКА НЕ УМЕЕТ» УБРАНА 03.08.2026 ВМЕСТЕ С ЗАПЛАТКОЙ. Она говорила
// владельцу, что про прогоны-продолжения проверить живость нечем, — и с починкой прибора
// стала прямой ложью. Владелец не программист и проверить такую строку не может: ложь
// в его строке дороже самой заплатки. Теперь про продолжение говорится ровно то же, что
// про любой другой идущий прогон, потому что и знаем мы про него ровно столько же.
reasons.push(
`${rule.reason} Прогон: ${run.run_id}. План: ${run.plan_path}. `
+ 'Хотите править — сначала остановите прогон командой «стоп всё»: работники погаснут, '
+ 'последний погасший закроет прогон, и замок снимется сам.',
);
}
}
// 🔴 ПОКОЙНИКИ, КОТОРЫЕ ДЕРЖАЛИ БЫ ИМЕННО ЭТИ ПРАВКИ, — НАЗЫВАЮТСЯ ВЛАДЕЛЬЦУ, а не
// проглатываются молча. Замок открылся — это хорошая новость, но за ней стоит брошенная
// карточка, которую надо убрать рукой: сама затея чужие улики не трогает. Молчание здесь
// копило бы мусор до ночи, в которую он всё перекроет.
// 🪤 Называются НЕ ВСЕ покойники, а только те, чей план в этом коммите правят: иначе
// владелец читал бы одну и ту же строку при каждом коммите чего угодно.
const pomeshali = pokoyniki.filter(p => staged.some(f => samePath(f, p.plan_path)));
if (!blocked.length) return { ok: true, reason: null, blocked: [], pokoyniki: pomeshali };
return { ok: false, reason: reasons.join('\n'), blocked, pokoyniki: pomeshali };
}
// Все прогоны, какие есть на диске. Нет каталога вовсе — значит прогонов не было.
//
// 🔴 Три ответа, а не два (общий прибор затеи — `readJsonOrBroken` в state.mjs):
// — файла нет вовсе (каталог прогона есть, карточки нет) — это НЕ прогон, пропускаем;
// — карточка читается — отдаём как есть;
// — карточка есть, но не читается — отдаём заглушку с признаком `broken`, и замок по ней
// ЗАКРЫВАЕТСЯ. Раньше такой прогон молча исчезал из списка.
//
// Каталог берётся аргументом только ради проверок: живой хук зовёт без аргумента и работает
// по настоящему `runsDir()`. Раскладку «каталог прогонов / номер / run.json» знает paths.mjs;
// что она здесь та же самая — сторожит проверка «раскладка совпадает с paths.runDir».
export function readRuns(dir = runsDir()) {
if (!existsSync(dir)) return [];
const out = [];
for (const entry of readdirSync(dir)) {
const cardPath = join(dir, entry, 'run.json');
const got = readJsonOrBroken(cardPath);
if (!got.found) continue;
if (got.broken) {
out.push({ run_id: entry, status: null, plan_path: null, broken: true, card_path: cardPath, card_dir: join(dir, entry) });
continue;
}
// 🔴 КАТАЛОГ ПРОГОНА КЛАДЁТСЯ РЯДОМ С КАРТОЧКОЙ, ОТДЕЛЬНЫМ ИМЕНЕМ `card_dir`.
// Спросить «а жив ли этот прогон на самом деле» нельзя, не зная, где лежит его журнал
// `events.jsonl`, — а карточка своего каталога не знает: номер прогона живёт ВНУТРИ неё
// и с именем папки совпадать не обязан (об этом же предупреждает zhiv-li-progon.mjs).
// 🪤 Имя нарочно не `dir`: карточку пишет чужой файл (run.mjs), и заведись у неё когда-нибудь
// своё поле `dir` — мы бы молча затёрли его записью замка. `card_dir` — имя замка, и оно
// стоит рядом с уже существующим `card_path` из битой ветки.
out.push({ ...got.value, card_dir: join(dir, entry) });
}
return out;
}
export function main(argv = process.argv) {
const staged = argv.slice(2).filter(Boolean);
const verdict = planLockVerdict({ staged, runs: readRuns() });
// 🔴 Строки про покойников печатаются и при УДАЧЕ тоже. Прежде замок молчал, когда
// пропускал, — и брошенная карточка оставалась незамеченной до следующего отказа.
for (const p of verdict.pokoyniki ?? []) {
console.error(
`Замечание: ${p.pochemu}. Правку плана он больше не держит, но карточку этого прогона `
+ `стоит убрать рукой (${runsDir()}) — сама затея чужие улики не трогает.`,
);
}
if (verdict.ok) return 0;
console.error('Правка плана НЕ принята:');
console.error(verdict.reason);
return 1;
}
// Позвали файл напрямую (так его зовёт lefthook) — работаем. Позвали ввозом (так его зовут
// проверки) — молчим.
if (process.argv[1] && process.argv[1].endsWith('plan-lock-hook.mjs')) {
process.exit(main());
}