Files
portal/tools/night/paths.mjs
T

398 lines
29 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.
// Единственное место, где сказано, что где лежит — включая путь к внешним программам.
import { join, resolve, basename, delimiter } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { execFileSync } from 'node:child_process';
export const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..', '..');
export const NIGHT_ROOT = join(REPO_ROOT, '.night');
// Каталог всех прогонов. Его перебирают двое — точка входа (сколько прогонов идёт)
// и уборка старых углов, — поэтому имя строится ЗДЕСЬ, а не по месту у каждого.
export function runsDir() {
return join(NIGHT_ROOT, 'runs');
}
export function runDir(runId) {
return join(runsDir(), runId);
}
// 🪤 Один и тот же файл пишут по-разному: владелец запускает прогон ПОЛНЫМ путём
// (`night run C:\…\план.md`) и с обратными косыми, а сторож (lefthook) подаёт путь
// ОТ КОРНЯ ХРАНИЛИЩА и с прямыми — ЗАМЕРЕНО на lefthook 2.1.8: `{staged_files}`
// подставляется как `docs/superpowers/plans/probe.md`, рабочим каталогом стоит корень
// хранилища. Сравнение «как набрано» не узнаёт свой же план: замок пропускал бы правку
// идущего плана — тихо и зелено.
//
// 🔴 СРАВНИВАТЕЛЬ ОДИН НА ВСЮ ЗАТЕЮ, и живёт он здесь, в единственном месте, где сказано,
// что где лежит. Прежде их было двое — `samePlanPath` у печати (plan-fingerprint.mjs)
// и `samePath` у замка (plan-lock-hook.mjs), дословными копиями друг друга; свести мешал
// круг по ввозу (печать пришлось бы ввозить в замок и обратно). paths.mjs не ввозит из
// затеи НИЧЕГО, кроме своего — круга нет. Заведут копию снова — покраснеют два сторожа
// «сравниватель пути — ОДИН на всю затею» в paths.test.mjs.
//
// 🟡 ДОЛГ, названный вслух (02.08.2026): рядом живёт вторая мелочь про имена файлов —
// `odinVidPuti`, и о регистре букв они ПО-ПРЕЖНЕМУ спорят: здесь регистр опускается,
// там — нет. Почему там нельзя — написано у самой `odinVidPuti` (её ответ идёт в приказ
// на откат и дальше в `git`, а git сверяет имя пути по знакам). Здесь можно и нужно:
// `samePath` сравнивает ДВА пути и никому ничего не отдаёт — её ответ «да/нет», а не имя.
// Сводить эти две мелочи в одну нельзя и после решения владельца: у них разные зовущие,
// разные живые проверки и разное дело — `samePath` полный путь умеет сама (через `resolve`
// от рабочего каталога), а `odinVidPuti` отдаёт ОДИН вид имени и меряет от корня хозяйства.
export function samePath(a, b) {
if (!a || !b) return false;
const norm = p => resolve(String(p)).replace(/\\/g, '/').toLowerCase();
return norm(a) === norm(b);
}
export function workerDir(runId, workerId) {
return join(runDir(runId), 'workers', workerId);
}
// Рабочий угол работника лежит РЯДОМ с хозяйством, а не внутри него: git worktree
// внутрь собственного хранилища не заводится, и первая же попытка кончилась бы отказом.
export function workspaceRootFor(runId, workerId) {
return join(REPO_ROOT, '..', 'night-workspaces', runId, workerId);
}
// Настройки запуска работника — в его же углу: там их видит и он, и надзиратель.
export function workerSettingsPath(workspaceRoot) {
return join(workspaceRoot, 'night-settings.json');
}
// Почтовый ящик работника внутри его рабочего угла — единственный способ,
// которым работник разговаривает с надзирателем. Модулей затеи он не видит:
// он другой процесс в другом каталоге.
export function workerInboxDir(workspaceRoot) {
return join(workspaceRoot, '.night-worker');
}
// Снимки приёмщика — в бумагах приёмки, у каждой задачи свой каталог. В .night им нельзя:
// каталог закрыт от хранилища, и доказательства приёмки там исчезли бы.
export function shotsDir(taskId) {
const safe = String(taskId).replace(/[^A-Za-z0-9._-]+/g, '_');
return join(priyomkaDir(), `${safe}-shots`);
}
// След пробного долгого прогона. Путь от корня хранилища: его же видит работник
// в своём поручении и по нему же делает коммиты.
export const SMOKE_NOTES_REL = 'docs/superpowers/priyomka/smoke-notes.md';
export function commandsDir() {
return join(NIGHT_ROOT, 'commands');
}
export function configPath() {
return join(REPO_ROOT, 'night.config.json');
}
// Журнал замеров ПРИБОРА СИСТЕМЫ: доля израсходованного недельного запаса (`utilization`),
// когда запас обновится (`resets_at`) и когда мерили. Своего счёта в долларах затея не ведёт.
export function budgetMeasuresPath() {
return join(NIGHT_ROOT, 'budget-measures.jsonl');
}
// Журнал ПОПЫТОК крошечного замера остатка — по одной строке на каждый ЗАПУСК платной
// головы, удачный или нет. Отдельный файл, а не поле в журнале замеров: там лежат УДАЧНЫЕ
// замеры, а деньги тратит и неудачный. Считай мы по журналу замеров — сломанный прибор жёг
// бы деньги владельца сколько угодно раз, а счёт стоял бы на нуле.
export function probeAttemptsPath() {
return join(NIGHT_ROOT, 'probe-attempts.jsonl');
}
// 🔴 Имя файла строк сводки знает ОДНО место. Его ищут двое: сводка — по номеру прогона
// (`summaryLinesPath`), надзиратель — по каталогу прогона, который ему дали на руки.
// Собери они имя каждый по-своему — один писал бы в один файл, другая читала бы другой.
export const SUMMARY_LINES_FILE = 'summary-lines.jsonl';
// Готовые строки сводки. Обещаны наружу как стык — значит имя строится здесь, а не по месту.
export function summaryLinesPath(runId) {
return join(runDir(runId), SUMMARY_LINES_FILE);
}
// Итог прогона для утренней сводки. Лежит в общем корне, а не в каталоге прогона:
// сводка читает его, когда самого прогона уже нет.
export function lastRunPath() {
return join(NIGHT_ROOT, 'last-run.json');
}
// Время последней УСПЕШНОЙ отправки сводки. На нём стоят ворота отклика владельца.
export function summarySentPath() {
return join(NIGHT_ROOT, 'last-summary.json');
}
// Печать плана: у каждого плана своя.
export function planSealPath(planPath) {
const safe = basename(String(planPath)).replace(/[^A-Za-z0-9._-]+/g, '_');
return join(NIGHT_ROOT, 'seals', `${safe}.json`);
}
// Отметка «замысел проверен»: рецензенты плюс слово владельца. Без файла воротам нечем питаться.
export function specApprovalPath() {
return join(NIGHT_ROOT, 'spec-approved.json');
}
export function mailFailuresPath() {
return join(NIGHT_ROOT, 'mail-failures.jsonl');
}
export function ownerRepliesPath() {
return join(NIGHT_ROOT, 'owner-replies.jsonl');
}
export function questionsPath() {
return join(NIGHT_ROOT, 'questions.jsonl');
}
// Бумаги приёмки живут В ХРАНИЛИЩЕ, а не под .night: .night закрыт от хранилища
// задачей 1, и доказательства приёмки там просто исчезли бы.
export function priyomkaDir() {
return join(REPO_ROOT, 'docs', 'superpowers', 'priyomka');
}
export function supervisorPath() {
return join(REPO_ROOT, 'tools', 'night', 'supervisor.mjs');
}
export function reviewerPath() {
return join(REPO_ROOT, 'tools', 'night', 'reviewer.mjs');
}
// 🪤 КЛАСС «голое имя программы». Из программы `spawn('composer')` и `spawn('npm')` падают
// с ENOENT: в путях поиска это скрипты без расширения, которые Windows сама запускать
// не умеет. Это ровно та болезнь, что убила 334 фоновых прогона на `spawn claude ENOENT`.
// Поэтому ВСЕ внешние программы затея зовёт только через resolveBin: composer, npm, npx,
// php, psql, git, node, claude.
//
// 🔴 Расширение перебирается ВНЕШНИМ кругом, каталог — внутренним. Проверено живьём:
// `C:\Users\Administrator\bin\composer` (bash-скрипт без расширения) лежит в путях поиска
// РАНЬШЕ, чем `C:\tools\composer\composer.bat`. Иди мы по каталогам снаружи — нашли бы
// негодный и получили бы ENOENT при полном пути на руках.
const EXTRA_BIN_DIRS = [
join(homedir(), 'AppData', 'Roaming', 'npm'),
join(homedir(), 'bin'),
join(homedir(), '.local', 'bin'),
'C:\\Program Files\\nodejs',
'C:\\Program Files\\Git\\cmd',
'C:\\Program Files\\PostgreSQL\\16\\bin',
'C:\\tools\\composer',
'C:\\tools\\php83',
'/usr/local/bin',
'/usr/bin',
];
export function resolveBin(name, { lookIn = null, env = process.env } = {}) {
const fromPath = String(env.PATH ?? env.Path ?? '').split(delimiter).filter(Boolean);
const dirs = lookIn ?? [...fromPath, ...EXTRA_BIN_DIRS];
const exts = process.platform === 'win32' ? ['.cmd', '.bat', '.exe', ''] : [''];
const tried = [];
let found = null;
outer: for (const ext of exts) {
for (const dir of dirs) {
const candidate = join(dir, `${name}${ext}`);
tried.push(candidate);
try {
if (existsSync(candidate) && statSync(candidate).isFile()) {
found = candidate;
break outer;
}
} catch { /* каталог недоступен — идём дальше */ }
}
}
if (!found) {
throw new Error(
`Запускаемый файл ${name} не найден. Голым именем звать нельзя — из программы это ENOENT. Искали ${tried.length} путей, первые: ${tried.slice(0, 6).join(', ')}`,
);
}
// 🔴 Вне try/catch поиска специально: если короткое имя не собралось, это должно
// упасть ГРОМКО и наружу, а не потеряться в «каталог недоступен — идём дальше».
return shortenIfShellPathHasSpace(found);
}
// 🪤 ТРЕТЬЯ, необнаруженная планом половина той же болезни, найдена живьём на этой машине.
// `npm` на ней стоит РОВНО и ТОЛЬКО под `C:\Program Files\nodejs\npm.cmd` — путь с пробелом.
// `.cmd` требует оболочку (`needsShell` выше), а Node при `shell: true` НЕ подставляет
// кавычки вокруг имени файла сам — cmd.exe видит «C:\Program» как имя команды и «Files\...»
// как её первый аргумент, и роняет EINVAL/«не распознано» ровно так же тихо, как ENOENT
// и EINVAL до него. Кавычки добавить негде: единственная точка вызова уже задана
// проверкой (`execFileSync(bin, args, { shell: needsShell(bin) })`) и путь туда идёт
// как есть. Правильный путь — не выдавать такой путь вовсе: короткое имя 8.3
// (`C:\PROGRA~1\nodejs\npm.cmd`) не содержит пробела, существует как обычный файл
// (`existsSync` видит его) и запускается оболочкой без единой кавычки.
//
// 🔴 ДЫРА, найденная надзирателем и здесь же закрытая: если короткое имя не собралось,
// молчаливый возврат исходного (заведомо нерабочего) пути — это ТОЧНО тот класс тихого
// отказа, от которого вся эта функция лечит. «Не нашли короткое имя» и «короткое имя
// не нужно» — два РАЗНЫХ исхода, и путать их нельзя: один — честный ответ, другой — сбой,
// о котором обязан узнать вызывающий, а не выяснить это ночью по молчанию работника.
// `run` — точка впрыска: по умолчанию правда зовёт PowerShell, проверки подставляют провал.
export function shortenIfShellPathHasSpace(candidate, { run = runPowerShellShortPath } = {}) {
if (process.platform !== 'win32') return candidate; // не Windows — короткого имени не бывает
if (!needsShell(candidate)) return candidate; // без оболочки пробел в пути не мешает
if (!candidate.includes(' ')) return candidate; // короткое имя НЕ ЗАПРАШИВАЕТСЯ — пробела нет
let out;
try {
out = String(run(candidate) ?? '').trim();
} catch (err) {
throw new Error(
`Короткое имя (8.3) для «${candidate}» не собралось: ${err.message}. Путь с пробелом ` +
`нельзя передать в оболочку без кавычек — их некуда вставить в точке вызова, а Node ` +
`при shell:true сам их не подставляет: команда развалится на «C:\\Program» и остаток, ` +
`и отказ будет молчаливым EINVAL/«не распознано». Проверить: включены ли короткие ` +
`имена на этом томе (fsutil 8dot3name query <буква диска>:).`,
);
}
if (!out) {
throw new Error(
`Короткое имя (8.3) для «${candidate}» не собралось: команда поиска вернула пустой ` +
`ответ. Путь с пробелом нельзя передать в оболочку без кавычек — их некуда вставить ` +
`в точке вызова, отказ будет молчаливым EINVAL/«не распознано», если отдать путь как есть.`,
);
}
return out;
}
function runPowerShellShortPath(longPath) {
const psExpr = `(New-Object -ComObject Scripting.FileSystemObject).GetFile('${longPath.replace(/'/g, "''")}').ShortPath`;
return execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', psExpr], {
encoding: 'utf8',
timeout: 5000,
});
}
// 🪤 ВТОРАЯ ПОЛОВИНА той же болезни, и без неё первая бесполезна. Найти файл мало:
// Node начиная с 18.20/20.12 (у нас 24) ОТКАЗЫВАЕТСЯ запускать `.bat` и `.cmd` через
// spawn/execFileSync без `shell` — это закрытая дыра CVE-2024-27980, ответ EINVAL.
// А `claude`, `npm` и `composer` на этой машине — ровно `.cmd`/`.bat`. То есть лечение
// «голого имени» без этой строки просто меняет ENOENT на EINVAL, и ни один работник
// за всю ночь не запустится.
export function needsShell(binPath) {
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(String(binPath));
}
// 🪤 Один вид имени файла на всю затею. Работник и владелец пишут `app\A.php` (окно
// Windows), а `git` отдаёт `app/A.php`. Несведённые — это РАЗНЫЕ файлы: ворота
// пересечения не найдут столкновения, замок пропустит двоих на один файл, и все
// проверки останутся зелёными. Заводится ровно одна такая мелочь, и её зовут все,
// кто сравнивает имена: `filesDisjoint` (задача 2), `file-claims.mjs` (задача 3),
// `claims-audit.mjs` (задача 4).
//
// 🪤 Сведения косых МАЛО, и это замерено живьём 02.08.2026 на самом замке: файл занят
// как `app/A.php`, а второй работник спокойно занимал его же как `app/./A.php`,
// `app/zzz/../A.php`, `/app/A.php` и `app/A.php/`. Отказа не было ни разу — а значит
// не было бы и отката: двое молча правили бы один файл всю ночь при зелёных проверках.
// Поэтому имя разбирается НА КУСКИ, и пустые куски (ведущая, хвостовая и двойная косая),
// куски `.` («здесь же») и куски `..` («шаг назад») убираются по смыслу, а не по образцу
// в строке. Образцом в строке это не лечится: `app/zzz/yyy/../../A.php` требует помнить,
// сколько шагов назад уже сделано, — а строчная замена не помнит ничего.
//
// 🔴 Шаг назад В НАЧАЛЕ пути НЕ съедается. `../app/A.php` показывает ВЫШЕ корня хозяйства
// и означает ДРУГОЕ место. Съешь мы его молча — получилось бы `app/A.php`, и замок объявил
// бы столкновением два разных файла: работник встал бы до утра с отказом на файл, которого
// никто не занимал. Это беда того же рода, что и дырявый замок, только с другой стороны.
// Поэтому `..` съедает предыдущий кусок только тогда, когда этот кусок ЕСТЬ и сам не `..`;
// иначе `..` сохраняется как есть.
//
// 🔴🔴 РЕГИСТР БУКВ: владелец РЕШИЛ его сводить (решение 7а от 02.08.2026, протокол
// `docs/grilling/2026-08-02-nastroyki-nochi-i-ramka-deneg.md`), но ЗДЕСЬ это решение
// НЕ ИСПОЛНЕНО — и вот почему. Прежнее примечание («на сервере хозяйства регистр значим»)
// снято: тот довод владелец выслушал и отвёл сам. Мешает ДРУГОЕ, найденное живьём 02.08.2026
// уже при попытке исполнить решение.
//
// 🪤 Эта мелочь отдаёт НЕ ТОЛЬКО ключ для сравнения. Её ответ идёт дальше как ИМЯ ДЛЯ ДЕЛА:
// `claims-audit.mjs` кладёт его в приказ на откат, а надзиратель подставляет тот же знак
// в знак в `git checkout … -- <файл>`. Замерено командой на этой самой машине:
// `core.ignorecase=true`, и всё равно `git ls-files --error-unmatch TOOLS/NIGHT/PATHS.MJS`
// отвечает «pathspec did not match any file(s) known to git» и кодом 1 — git сверяет ИМЯ
// ПУТИ по знакам, а не по-хозяйски. Опусти мы здесь регистр — приказ на откат целился бы
// в `app/a.php` там, где хранилище знает `app/A.php`, и откат чужой правки МОЛЧА промахнулся
// бы мимо. Файлов с заглавными буквами в хранилище 2400, из них 1921 — под `app/`, то есть
// ровно там, куда работники и ходят. Это ровно тот класс тихого сбоя, от которого вся затея.
//
// ⇒ Честно исполнить решение 7а можно только РАЗВЕДЯ два дела: «ключ, по которому имена
// сравнивают» (регистр опущен) и «имя, которым потом делают» (регистр как есть). Сегодня
// оба дела делает одна эта мелочь, и разводить их надо в ЧУЖИХ файлах — `file-claims.mjs`,
// `gates.mjs`, `claims-audit.mjs` плюс их проверки. Замерено: опустишь регистр только здесь —
// краснеют 18 проверок в шести чужих файлах, и краснеют по делу, а не по придирке.
// Правку чужого без слова владельца здесь не делают — решение 7а ждёт этого шага.
//
// 🔴 ПОЛНЫЙ ПУТЬ СВОДИТСЯ К ПУТИ ОТ КОРНЯ ХОЗЯЙСТВА — та же решение 7. Раньше здесь стояло
// «осталось открытым»: `C:/…/Документация/app/A.php` и `app/A.php` замок считал разными,
// а владелец полные пути набирает (см. примечание к `samePath` выше) — случай живой.
// 🪤 Общего «того самого» корня на всех НЕ БЫВАЕТ: у каждого работника свой рабочий угол
// (`workspaceRootFor` — угол лежит РЯДОМ с хозяйством), и у каждого угла свой корень.
// Поэтому меряем от корня ТОГО хозяйства, в котором идёт работа, — от `REPO_ROOT`, который
// этот же файл и считает. Работник живёт внутри своего угла и других имён всё равно не видит.
// 🪤 Начало пути отрезается ТОЛЬКО целым куском (`корень` + `/`), а не как строка: сосед
// с именем `…/night1-zapas/app/A.php` начинается так же посимвольно, но это другое место.
//
// 🔴 Шаг назад В НАЧАЛЕ пути НЕ съедается — см. примечание выше, это правило живо и после
// обеих правок: `../app/A.php` шагает ВЫШЕ корня и означает другое место.
//
// 🪤 ОСТАЛОСЬ ОТКРЫТЫМ — написания, которыми починку ОБХОДЯТ. Замерено 02.08.2026 на живом
// замке (занять `app/A.php` за одним работником и полезть вторым), пролезли все пять:
// • короткое имя 8.3 у каталога с пробелом: `…/проекты/ПОРТАЛ~1/…` вместо `портал crm`;
// • путь через устройство: `\\?\C:\…\app\A.php`;
// • имя куска с хвостовым пробелом или точкой (`app /A.php`, `app./A.php`) — Windows их
// отбрасывает сама, и это тот же файл;
// • та же кириллица, набранная разложенной (NFD): `й` двумя знаками вместо одного;
// • имя как ссылка браузера: `file:///C:/…/app/A.php`.
// Не чинится здесь НАРОЧНО, и по двум разным причинам. Первые три и пятое — выдуманные
// написания: так путь не даёт ни `git`, ни владелец, ни ключ `--files`, а каждое новое
// правило в этой мелочи стоит дороже, чем беда, которую оно закрывает. NFD — беда настоящая
// (кириллица в хозяйстве повсюду), но лечение её меняло бы САМИ ЗНАКИ ИМЕНИ, а имя отсюда
// идёт в `git checkout` — тот же запрет, что и на регистр. Всё это ждёт того же разведения
// «ключ для сравнения» / «имя для дела», что и решение 7а.
export function odinVidPuti(file) {
const svedeno = svestiKuski(file);
// 🪤 Сравниваем начало пути с корнем, опустив регистр ТОЛЬКО у буквы диска. Найдено
// вырезанием второго рода 02.08.2026 на живом замке: `c:\…` и `C:\…` — один и тот же
// диск, а замок пускал на файл двоих. Здесь это можно, а у самого имени файла нельзя:
// буква диска в ответе не остаётся вовсе (путь отмеряется от корня и начинается с `app/`),
// а имя файла остаётся и идёт дальше в `git`.
const dlyaSravneniya = bezRegistraDiska(svedeno);
// Сам корень хозяйства — это не имя файла, а «имени не назвали».
if (dlyaSravneniya === KOREN_HOZYAYSTVA) return '';
if (KOREN_HOZYAYSTVA && dlyaSravneniya.startsWith(`${KOREN_HOZYAYSTVA}/`)) {
// 🔴 Режем ИСХОДНОЕ, а не сравниваемое: длина у них одна (меняется один знак),
// и наружу должно уйти имя, как его написали, а не как его сравнивали.
return svedeno.slice(KOREN_HOZYAYSTVA.length + 1);
}
return svedeno;
}
// Буква диска — единственное место пути, где регистр на Windows не значит НИЧЕГО.
// Меняет ровно один знак и только в начале: длина строки остаётся прежней, и по ней
// можно резать исходное имя.
function bezRegistraDiska(p) {
return p.replace(/^([A-Za-z]):/, (_, bukva) => `${bukva.toLowerCase()}:`);
}
// Разбор имени на куски и сведение их по смыслу. Вынесено из `odinVidPuti` затем, что тем же
// прибором сводится и сам корень хозяйства: своди мы корень иначе — начало полного пути
// не совпало бы с ним ни разу, и лечение полного пути молча не работало бы.
function svestiKuski(imya) {
const kuski = String(imya ?? '')
.trim()
.replace(/\\/g, '/')
.split('/');
const svedeno = [];
for (const kusok of kuski) {
// Пустой кусок — это ведущая, хвостовая или двойная косая. Смысла не несёт.
if (kusok === '' || kusok === '.') continue;
if (kusok === '..') {
const est_chto_sedat = svedeno.length > 0 && svedeno[svedeno.length - 1] !== '..';
if (est_chto_sedat) svedeno.pop();
else svedeno.push('..');
continue;
}
svedeno.push(kusok);
}
return svedeno.join('/');
}
// Корень хозяйства, сведённый ТЕМ ЖЕ прибором, что и приходящие имена. Считается один раз:
// за ночь `odinVidPuti` зовут на каждое имя каждого работника.
const KOREN_HOZYAYSTVA = bezRegistraDiska(svestiKuski(REPO_ROOT));