212 lines
19 KiB
JavaScript
212 lines
19 KiB
JavaScript
#!/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());
|
||
}
|