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

118 lines
8.3 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';
export function planLockVerdict({ staged = [], runs = [] }) {
const going = runs.filter(r => r && !r.broken && r.status === 'running' && r.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);
reasons.push(
`${rule.reason} Прогон: ${run.run_id}. План: ${run.plan_path}. `
+ 'Хотите править — сначала остановите прогон командой «стоп всё»: работники погаснут, '
+ 'последний погасший закроет прогон, и замок снимется сам.',
);
}
}
if (!blocked.length) return { ok: true, reason: null, blocked: [] };
return { ok: false, reason: reasons.join('\n'), blocked };
}
// Все прогоны, какие есть на диске. Нет каталога вовсе — значит прогонов не было.
//
// 🔴 Три ответа, а не два (общий прибор затеи — `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 });
continue;
}
out.push(got.value);
}
return out;
}
export function main(argv = process.argv) {
const staged = argv.slice(2).filter(Boolean);
const verdict = planLockVerdict({ staged, runs: readRuns() });
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());
}