Files
portal/tools/night/items.mjs
T
Дмитрий 951b15fb85 fix(ночь): «свободных пунктов нет» больше не значит «задача сделана»; сторож был зелен случайно
Находка М-406, правила 244-246.

Смена 27 передала список «пять доступных настоящих долгов». Правило 243 велит проверять
такой список замером, каждую строку отдельно. Проверил — ДОСТУПНЫМ ОКАЗАЛСЯ ОДИН.
Третья смена подряд с той же долей: 14 ложных из 33 долгов, 3 из 4 разъездов, 4 из 5.

 - `zadachaKonchena` —  настоящий, закрыт этим коммитом;
 - `reshyonnyeSNevypolnimymi` — 🔒 заперт словом владельца: единственный источник
   невыполнимых пунктов — ответ владельца «нет» в `questions.mjs`, а он мёртв целиком;
 - `mailFailedSince` — 🔒 заперт им же: замысел строки 750/1073 называет его дословно
   («затея видит, что сводка не ушла») частью правила ворот «отклик владельца»;
 - `readResult` —  не долг, а МЁРТВЫЙ ОСТАТОК. Дороги «запусти, дождись конца, возьми
   итог» в затее нет: `startWorker` отдаёт живого ребёнка. Четверо читающих потоки делают
   ДРУГОЕ (надзиратель — живьём по строке; `budget` — ищет замеры предела; судья — берёт
   последнее событие `result` и текст приговора; `dengi-bez-zapisi` — первую цену), а расход
   затея нарочно не считает, а спрашивает. Не снесён намеренно: это решение владельца.

ЧТО ПОЧИНЕНО. В блоке 5а надзирателя стояло упрощение `otlozhennyePunkty.size === 0` —
«отложенных нет, значит всё засчитано». Сюда приходят по ДВУМ причинам, и вторая — не
«пунктов не осталось», а «пункт ЕСТЬ, но его файлы держит живой сосед и занять их из-под
руки не вышло». Отложенных при этом нет — и на НЕДОДЕЛАННОЙ задаче поднимался флаг
приёмщику, а владельцу уходило «Пункты задачи кончились и все засчитаны».

Доказано вырезанием: сторож написан первым и покраснел (`items_all_verified` = true на
недоделанной задаче), после починки зелёный, при возврате упрощения — снова красный, и
ровно один, посчитанный ДО прогона. Прибор №6 подтвердил независимо: имя ушло из приговора.

🔴 ГЛАВНОЕ, ЧТО КУПИЛА СМЕНА, И КУПЛЕНО ЧУЖИМ КРАСНЫМ. Полный прогон после починки дал
красное у чужого сторожа `primechanie-ne-rashoditsya-s-pravdoy.test.mjs`. Правило 242 велит
сперва спросить, что он бережёт. Он бережёт согласие примечания с правдой и по замыслу ПРАВ.
Шапку я поправил — он всё равно краснел.

Причина: сторож брал `items.mjs` СЫРЫМ файлом и искал фразу-маркер «НЕТ НИ ОДНОГО», а она
стоит там в ЖИВОМ КОДЕ — в тексте ошибки «в плане задачи нет ни одного пункта». Пока шапка
врала тем же словом, обе половины совпадали и сторож молчал: он был зелёным ПО ДВУМ ПРИЧИНАМ
РАЗОМ, и одна из них случайная.

⇒ Заведён третий рез `tolkoPrimechaniya`, обратный `bezPrimechaniy`, в тот же дом и к той же
развилке «какой брать». Обратность стережётся отдельной проверкой (что вырезал один — то
оставил второй). Все три места сторожа переведены на него, хотя слепота задевала одно:
у двух других фразы-маркера в коде сегодня нет — но это удача, а не защита.

244. У СТОРОЖА, ЧИТАЮЩЕГО ФАЙЛ СЫРЫМ, СПРОСИ: НЕ ОТВЕЧАЕТ ЛИ ЗА ПРИМЕЧАНИЕ ТО, ЧТО
ПРИМЕЧАНИЕМ НЕ ЯВЛЯЕТСЯ. Обратная сторона 234: тот про «рез срезал зерно», этот про
«отсутствие реза подсунуло шелуху».
245. У КРАСНОГО ЧУЖОГО СТОРОЖА ТРИ ИСХОДА, А НЕ ДВА. Третий: прав он по замыслу, но слеп
по способу — тогда чинят САМОГО СТОРОЖА. Спроси, что он измерил, и то ли это, что он
берётся охранять.
246. `grep -i` НА РУССКИХ БУКВАХ РЕГИСТР НЕ ПРИВОДИТ: «НЕТ НИ ОДНОГО» → 0, «нет ни одного»
→ 1, node тем же выражением → нашёл. Мерь тем же телом, каким меряет проверяемый.

ПОПУТНО три примечания приведены к правде (правило 235): у `zadachaKonchena` живой зовущий
теперь ЕСТЬ И ОН ОДИН (второго не будет, пока нет куска 7 — писать «зовут двое» значило бы
чинить враньё половиной правды); `readResult` назван мёртвым остатком; `reshyonnyeSNevypolnimymi`
помечен запертым, а не «некому сделать».

Проверок 2929 в 108 файлах (было 2927), ноль красных, код 0. Прибор №6: вывозится 613,
точных долгов 29 — число то же, состав другой: `zadachaKonchena` ушёл, на его место встал
новый рез (класс правила 231, прибор говорит это сам).

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

435 lines
36 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.
// Команды снаружи. Панель, бот или сама рука владельца кладут файл в `.night/commands/`,
// а читает его НАДЗИРАТЕЛЬ — работник этого модуля не видит вовсе: он другой процесс
// в другом каталоге и разговаривает только файлами своего почтового ящика.
//
// 🔴 Файл заведён ЧАСТЬЮ. Здесь только приём команд; `closeItem`, `afterItemDone`,
// `afterTaskDone` и `deferredTaskGate` дописывает в этот же файл кусок 4 — заводить
// его заново ему не надо.
import { existsSync, readdirSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import { readJson, writeJsonAtomic } from './state.mjs';
// Закрытый список, и порядок в нём значимый: «стоп всё» главнее «стопа», «стоп» главнее
// «паузы». Слово не из списка не читается вовсе — выдумывать, что оно значит, нельзя.
// 🔴 Список вывезен наружу нарочно: надзиратель называет его владельцу в сводке, когда
// команду не понял. Второй такой же список рядом разошёлся бы с этим в первый же месяц.
export const COMMAND_WORDS = ['stopall', 'stop', 'pause', 'resume'];
const CMDS = COMMAND_WORDS;
export function readCommand(commandsDirPath, workerId) {
// 🪤 Каталог команд заводит рука владельца, и в первую ночь его может не быть.
// Брошенная тут ошибка убила бы надзирателя молча — тот самый класс беды, из-за
// которого 334 прогона умирали три месяца незамеченными.
if (!commandsDirPath || !existsSync(commandsDirPath)) return null;
let files;
try {
files = readdirSync(commandsDirPath);
} catch {
return null; // каталог недоступен — это отсутствие команды, а не повод падать
}
for (const cmd of CMDS) {
const all = `all.${cmd}.json`;
const mine = `${workerId}.${cmd}.json`;
// Общая идёт первой: она касается и того, кому лично ничего не давали.
for (const name of [all, mine]) {
if (!files.includes(name)) continue;
// Порванный файл — отсутствие сведений, а не мусор: слово команды стоит
// в самом имени файла, и его достаточно.
const body = readJson(join(commandsDirPath, name)) ?? {};
// 🪤 Общую команду, которую ЭТОТ работник уже исполнил, второй раз не отдаём:
// иначе он гасился бы по ней снова и снова, пока файл ждёт остальных.
if (name === all && (body.done ?? []).includes(workerId)) continue;
// 🔴🔴 `cmd` стоит ПОСЛЕ тела, а не до него, и это не вкус. Слово команды берётся
// из ИМЕНИ файла — оно и есть закрытый список. Поставь мы `cmd` перед `...body`,
// и тело файла перебило бы его: положи кто-нибудь в `w-1.pause.json` тело
// {"cmd":"stopall"} — работник был бы погашен, хотя имя файла говорит «пауза».
// Тем же способом обходился бы и весь закрытый список `CMDS`. Тело команды несёт
// сведения (кто, когда, почему), а не слово; слово — только из имени.
return { ...body, cmd, file: name, common: name === all };
}
}
return null;
}
// 🔴🔴 Владелец узнаёт, что его команду НЕ ПОНЯЛИ (решение диспетчера). Слово не из
// закрытого списка исполнять нельзя — выдумывать его смысл мы не станем. Но и молчать
// нельзя: владелец положил файл, ушёл спать и думает, что «пауза» нажата, а её никто
// не понял. Молчание в ответ на команду хозяина недопустимо.
// 🪤 Эта работа НИЧЕГО не удаляет и ничего не исполняет — только называет. Файл владельца
// остаётся лежать: он и есть доказательство, и утром хозяин увидит его на месте, а не
// будет гадать, куда тот делся.
export function unknownCommands(commandsDirPath, workerId) {
if (!commandsDirPath || !existsSync(commandsDirPath)) return [];
let files;
try {
files = readdirSync(commandsDirPath);
} catch {
return []; // каталог недоступен — сказать нечего, и падать не о чем
}
const out = [];
for (const name of files) {
// Команда — это ровно `<кому>.<слово>.json`. Всё прочее в каталоге не наше дело:
// объявлять чужой файл «непонятой командой» — значит пугать владельца на пустом месте.
const razbor = /^(.+)\.([^.]+)\.json$/.exec(name);
if (!razbor) continue;
const [, komu, slovo] = razbor;
// Чужая личная команда — не наша забота: о ней скажет надзиратель того работника,
// иначе одну и ту же строку написали бы семеро разом.
if (komu !== 'all' && komu !== workerId) continue;
if (CMDS.includes(slovo)) continue; // понятную здесь не называем
out.push({ file: name, word: slovo, common: komu === 'all' });
}
return out;
}
// 🪤 Прежде эта работа стирала И общий, И личный файл разом. Из-за этого «стоп всё»
// съедал первый же прочитавший, а остальные шестеро продолжали работать при отданной
// команде — и владелец видел «остановлено», когда ночь шла дальше. Теперь личная команда
// убирается сразу, а общая — только когда её исполнили ВСЕ, кого она касается; до тех пор
// в её файле копится список исполнивших.
export function consumeCommand(commandsDirPath, workerId, cmd, { concerns = [] } = {}) {
const result = { personal_removed: false, common_removed: false, done: [] };
if (!commandsDirPath || !existsSync(commandsDirPath)) return result;
const mine = join(commandsDirPath, `${workerId}.${cmd}.json`);
if (existsSync(mine)) {
unlinkSync(mine);
result.personal_removed = true;
}
const all = join(commandsDirPath, `all.${cmd}.json`);
if (!existsSync(all)) return result;
// 🔴 Список исполнивших живёт В ФАЙЛЕ, а не в памяти надзирателя: надзиратели — разные
// процессы, и каждый видел бы свой собственный список. Перечитываем прямо перед записью,
// чтобы не затереть отметку соседа, поставленную секунду назад.
const body = readJson(all) ?? {};
const done = Array.from(new Set([...(body.done ?? []), workerId]));
result.done = done;
// Кого команда касается, знает зовущий: надзиратель берёт состав прогона из `run.json`.
// Списка нет — файл НЕ удаляем: лучше лишний раз промолчать, чем снять команду с тех,
// до кого она не дошла.
if (concerns.length && concerns.every(w => done.includes(w))) {
unlinkSync(all);
result.common_removed = true;
return result;
}
writeJsonAtomic(all, { ...body, done });
return result;
}
// ── Кусок 4: закрытие пункта, очередь пунктов, конец задачи ────────────────────
//
// 🔴 «Пункт закрыт» — не слово работника, а доказательство: сохранённый коммит ПЛЮС
// зелёные проверки ИМЕННО ЭТОГО пункта. Зелень меряет надзиратель сам (`item-audit.mjs`,
// задача 4), сюда она приходит уже замеренной; слово работника доказательством не считается.
//
// 🔴 Порядок отказов значим. Владелец и работник читают ПЕРВУЮ названную причину и чинят
// ЕЁ. Поэтому сперва «в плане нет проверок», потом «в расчёте на ответ», потом «нет
// коммита», и только потом «проверки красные»: скажи мы про красный код там, где на деле
// не пришёл ответ хозяина, — работник полночи чинил бы верное.
// Номера пунктов владелец пишет то `2`, то `"2"`. Сравнение знак в знак вернуло бы
// работнику пункт, который он только что закрыл, и он крутился бы на нём до утра.
function odinNomer(id) {
return String(id ?? '').trim();
}
export function closeItem({ item, commit_sha, tests_green, on_assumption = false, answer_received = false }) {
if (!item || typeof item !== 'object') {
throw new Error('closeItem: пункт не передан. Это сбой зова, а не свойство пункта: тихий отказ «не засчитан» выглядел бы как честная работа замка, хотя замок ничего не смотрел.');
}
// Проверка 39б: у пункта в плане не записано ни одной проверки — закрывать нечем.
if (!Array.isArray(item.tests) || item.tests.length === 0) {
return { closed: false, reason: `Для пункта ${odinNomer(item.id) || '?'} в плане не записано ни одной проверки — закрыть его нечем. Проверки пункта ставит тот, кто пишет план, строкой-пометой под заголовком.` };
}
// Проверка 40: сделанное «в расчёте на ответ» не закрывается, пока ответа нет,
// сколько бы зелёных проверок по нему ни прошло.
if (on_assumption && !answer_received) {
return { closed: false, reason: `Пункт ${odinNomer(item.id)} сделан «в расчёте на ответ» владельца, а ответа ещё нет. Закрывать такой пункт нельзя, сколько бы зелёных проверок по нему ни прошло: ответ может отменить сделанное.` };
}
if (!commit_sha) {
return { closed: false, reason: `Сделанное по пункту ${odinNomer(item.id)} не сохранено — коммита в ветке задачи нет. Несохранённое не переживёт ночь.` };
}
// Проверка 38: нет зелени по ЭТОМУ пункту — не засчитан. `tests_green` приходит
// от надзирателя замеренным (задача 4); не переданный вовсе — это «замерить
// не удалось», а не «наверное зелено».
if (tests_green !== true) {
return { closed: false, reason: `Проверки пункта ${odinNomer(item.id)} (${item.tests.join(', ')}) не прогнаны или не зелёные — пункт не засчитан.` };
}
return { closed: true, reason: null };
}
// 🔴🔴 ГРАНИЦА ЭТОЙ РАБОТЫ — ЧИТАТЬ ДО ТОГО, КАК ВЗЯТЬ ЕЁ ОТВЕТ ЗА «ВОТ ТВОЙ ПУНКТ».
//
// На вопрос «какой пункт работнику брать следующим» в затее отвечают ДВЕ работы, и они
// отвечают РАЗНОЕ на одном и том же хозяйстве. Это не поломка, это разделение ролей:
//
// · `nextFreeItem` (`file-claims.mjs`) — ВЫДАЁТ пункт. Ей передают каталог прогона,
// она читает список занятых файлов и пропускает пункты, чьи файлы держит чужой.
// · `afterItemDone` (здесь) — говорит, ЕСТЬ ЛИ ВООБЩЕ ЧТО БРАТЬ и не пора ли гаснуть.
//
// ЧЕГО ЭТА РАБОТА НЕ ЗНАЕТ: занятых файлов. Ей не передают ни каталога прогона, ни списка
// занятий — и передать нечем, в её подписи такого входа нет. Она смотрит только на план
// и на список закрытых пунктов.
//
// ОТ ЧЕГО ОНА НЕ ЗАЩИЩАЕТ: от того, что названный ею пункт прямо сейчас правит сосед.
// Замер 02.08.2026: w-1 занял `app/a.php` (пункт 1) и сидит на нём, w-2 спрашивает —
// `nextFreeItem` отвечает «пункт 2», `afterItemDone` отвечает «пункт 1», то есть тот,
// на котором сидит сосед. Возьми кто-нибудь этот ответ как выдачу пункта — двое молча
// правили бы одни файлы всю ночь при зелёных проверках, а замок занятий не сработал бы
// ни разу: его просто не спросили.
//
// 🔴 КАК ЗВАТЬ ПРАВИЛЬНО (так зовёт план задачи 5, проверено 02.08.2026): у этой работы
// спрашивают «пусто или нет» и «гаснуть ли», а САМ ПУНКТ берут через `nextFreeItem`.
// Ответ `next_item` тут — это «работа ещё есть», а не «вот твой пункт».
//
// 🪤 Собрался передать сюда каталог прогона или занятия — ОСТАНОВИСЬ и иди к владельцу.
// Подписи обеих работ ждёт задача 5, и менять разделение ролей — его решение, не твоё.
// Границу сторожит проверка «две двери на один вопрос» в `items.test.mjs`: она покраснеет
// и на попытку научить эту работу занятиям, и на попытку отучить от них `nextFreeItem`.
export function afterItemDone({ items, done, answered_during_day = false }) {
if (!Array.isArray(items)) {
throw new Error('afterItemDone: подан не список пунктов. Тихий ответ «пункты кончились» погасил бы всех работников задачи на ровном месте, и владелец прочёл бы утром «работа сделана», когда её никто не делал.');
}
if (!Array.isArray(done)) {
throw new Error('afterItemDone: не подан список закрытых пунктов. Тихое «ничего не закрыто» вернуло бы работнику тот самый пункт, который он только что закрыл.');
}
if (answered_during_day) {
return {
next_item: null,
extinguish_reason: 'Работник дождался ответа владельца днём — доделал свой пункт и гаснет, нового не берёт: иначе он жёг бы недельный запас вместе с владельцем.',
task_note: 'Остаток плана сам не подхватывается — задача продолжится только по слову владельца «продолжай».',
};
}
const zakryto = done.map(odinNomer);
const next = items.find(i => !zakryto.includes(odinNomer(i?.id))) ?? null;
return {
next_item: next,
extinguish_reason: next ? null : 'Пункты плана кончились — брать больше нечего.',
task_note: null,
};
}
export function afterTaskDone({ task_id, workers }) {
if (!Array.isArray(workers) || workers.length === 0) {
throw new Error('afterTaskDone: не сказано, каких работников гасить. Тихий пустой список прочёлся бы как «все погашены», и задача считалась бы законченной при живых работниках.');
}
return {
task_id,
// Список копируется: правка снаружи не должна менять то, что уже решено.
extinguish_workers: [...workers],
// Проверка 25: следующая задача в том же прогоне не начинается — задача упирается
// в приёмку владельца.
start_next_task: false,
// 🟡 Флаг поднят и ляжет в `run.json` (задача 5). САМОГО приёмщика по нему заводит
// кусок 7 (`startReviewer`) — здесь заводить его нечем и незачем. Пока по флагу
// никто ничего не запускает, и это названо вслух, чтобы правило не сочли работающим.
reviewer_starts: true,
note: 'Задача закончена: все её работники погашены, следующая задача в этом прогоне не начинается. Дальше — приёмка владельца, приёмщика по этому флагу заводит кусок 7.',
};
}
// ── Кусок 4, задача 3: ворота возврата отложенной задачи ───────────────────────
//
// 🔴 Проверка 28: задача не влезла в прогон — следующим прогоном САМА не подхватывается.
// 🔴 Проверка 29: не прошла приёмку — возвращается только по слову владельца, с тем же
// планом и со списком брака, и делает её НОВЫЙ работник.
// 🔴 Проверка 30: план отложенной задачи сначала заново проходит проверку — и в случае
// «не влезла», и в случае «не прошла приёмку». Пока задача лежала, продукт менялся.
//
// 🪤 Слово `reason` тут значит две разные вещи, и договор о стыках их не развёл: НА ВХОДЕ
// это вид случая (закрытый список), НА ВЫХОДЕ — текст отказа владельцу. Имена заданы
// договором дословно и менять их нельзя; столкновение названо вслух.
import { DEFER_REASONS, spisokPrichinSlovami } from './deferred.mjs';
// 🔴🔴 СРАВНИВАТЕЛЬ ПУТИ ЗДЕСЬ НЕ ЗАВОДИТСЯ СВОЙ, и это отступление от плана задачи 3,
// названное вслух. План (01.08) велел завести в этом файле свою мелочь `odinVidPuti`,
// сводящую только косые черты. К 02.08 в `paths.mjs` уже живут ДВЕ работы с этим же
// вопросом — `samePath` («это один и тот же путь?») и `odinVidPuti` (свой вид имени
// файла), и на второй копии сравнивателя там стоят два сторожа: «сравниватель пути —
// ОДИН на всю затею». Третья копия под тем же именем `odinVidPuti`, но с другим
// поведением — ровно тот класс, на котором уже обжигались.
//
// 🔴 И дело не только в чистоте: своя мелочь плана ВРАЛА БЫ ЖИВЬЁМ. Замерено 02.08.2026
// (`node` на трёх сравнивателях): владелец запускает прогон ПОЛНЫМ путём
// (`night run C:\…\план.md` — так сказано в примечании к `samePath`), а в след ложится
// путь ОТ КОРНЯ хранилища. Сведение одних косых даёт «план не тот» на ОДНОМ И ТОМ ЖЕ
// плане: ворота отказали бы владельцу на его же работе, отложенная задача не вернулась
// бы никогда, и починить это он не смог бы ничем. То же и с `./план.md`.
// `samePath` оба случая проходит верно (замерено), а разные планы по-прежнему разводит.
import { samePath } from './paths.mjs';
export function deferredTaskGate({
reason, owner_said_continue, plan_revalidated, defect_list = [],
// 🔴 «ТОТ ЖЕ ПЛАН» (проверка 29 дословно). `plan_path` — путь плана ИЗ СЛЕДА, тот,
// по которому задачу откладывали. `plan_path_now` — путь плана в ЭТОМ запуске.
// Не сравни их — задачу можно было бы «доделать» по совсем другому плану, и доделано
// было бы НЕ то, что не доделали. Оба умолчания пустые: не дали — не сравниваем
// и молчим, а не выдумываем совпадение.
plan_path = null,
plan_path_now = null,
}) {
if (!DEFER_REASONS.includes(reason)) {
// 🟡 Отступление от плана, названное вслух: план велел ЭТУ строку не трогать, потому
// что её проверяют только словами `not_finished|rejected`. Но после третьего слова
// (`impossible`, шаг 9ж) она стала ВРАТЬ владельцу: говорила «одним из двух», а список
// принимает три. Врущее сообщение об отказе — ровно тот класс, от которого вся затея
// и строится, поэтому счёт исправлен. Сами слова в строке не тронуты.
// 🔴 04.08.2026: строка была ГОЛОЙ тройкой без единого русского слова — рядом
// (`deferred.mjs`) те же три слова уже объяснены по-русски, а тут владелец видел бы
// латиницу без перевода. Список слов теперь один на оба файла — `spisokPrichinSlovami()`.
throw new Error(`Ворота возврата: вид отложенной задачи должен быть одним из трёх — ${spisokPrichinSlovami()}. Дано: ${JSON.stringify(reason)}. Молчаливое «это не rejected» отменило бы требование списка брака, и задача вернулась бы в работу без него.`);
}
// 🔴 ТОТ ЖЕ ПЛАН (проверка 29). Стоит ПЕРВЫМ из отказов, и это нарочно: слово владельца
// «продолжай» относится к ТОЙ задаче, которую отложили. Подмени план — и «продолжай»
// сказано уже про другую работу, а владелец об этом не знает.
if (plan_path && plan_path_now && !samePath(plan_path, plan_path_now)) {
return {
may_run: false,
reason: `План не тот. Задачу откладывали по плану ${plan_path}, а запускают её по плану ${plan_path_now} — это разные планы, и доделано будет не то, что не доделали. Запустите с тем же планом; если план вправду сменился, это новая задача, а старый след снимите руками.`,
fresh_worker_required: false,
defect_list: [...defect_list],
};
}
// Проверка 28/29а: без слова владельца — никуда. Молчание не согласие.
if (!owner_said_continue) {
return {
may_run: false,
reason: 'Отложенная задача сама не подхватывается. Нужно слово владельца «продолжай» — ключ --continue в строке запуска.',
fresh_worker_required: false,
defect_list: [...defect_list],
};
}
// Проверка 30: план перепроверяется заново — в ОБОИХ случаях, и «не влезла»,
// и «не прошла приёмку».
if (!plan_revalidated) {
return {
may_run: false,
reason: 'План отложенной задачи должен заново пройти проверку: пока задача лежала, продукт менялся, и вчера годный план сегодня может не годиться вовсе.',
fresh_worker_required: false,
defect_list: [...defect_list],
};
}
// Проверка 29в: у вернувшейся с приёмки список брака обязателен. У той, что просто
// не успела, брака не было — и спрашивать его не с кого.
if (reason === 'rejected' && defect_list.length === 0) {
return {
may_run: false,
reason: 'Задача возвращена с приёмки владельца, но список брака пуст — работать не с чем. Подайте его ключом --defects <файл>. (До куска 7 список подаётся с руки: настоящую приёмку, откуда он возьмётся сам, строит кусок 7.)',
fresh_worker_required: false,
defect_list: [],
};
}
return {
may_run: true,
reason: null,
// Проверка 29г: делает её НОВЫЙ работник — прежний погас вместе с концом задачи.
fresh_worker_required: true,
// Список брака идёт ДАЛЬШЕ тем же составом: иначе ворота его прочли, кивнули
// и потеряли, а работник получил бы тот же план без единого слова о том, что в нём
// не так.
defect_list: [...defect_list],
};
}
// ── Долг куска 5 (Р14) и куска 7 (блок 6а), внесён 01.08 ───────────────────────
//
// 🔴 Пункт, который владелец ответом «нет» велел признать НЕВЫПОЛНИМЫМ (кусок 5, Р14),
// не проходит через `closeItem`: у него никогда не будет ни коммита, ни зелёных проверок —
// он и не должен их иметь. Без этого правила такой пункт НАВСЕГДА остаётся «не среди
// засчитанных», задача не закрывается никогда, и приёмщик (кусок 7, блок 6а) не заводится
// вовсе — молча (находка куска 7 СВ-6).
//
// 🪤 Списки `verified` и `impossible` по смыслу не пересекаются (пункт либо закрыт зелёными
// проверками, либо признан невыполнимым — не оба сразу), но эта функция того не проверяет:
// она не судья, а чистое сложение множеств. Судят в разных местах: замок (эта же задача,
// `closeItem`) — про «закрыт»; надзиратель круга (кусок 5, блок 2в) — про «невыполним».
//
// 🔴🔴 Откуда взять живой список невыполнимых — здесь НЕ решено. `item-verdict.json` кусок 5
// кладёт в ящик РАБОТНИКА одним файлом, и следующий вердикт затирает предыдущий; копящего
// ленджера невыполнимых пунктов затея на 02.08 не заводит нигде. Значит правило готово,
// а живой провод к нему — нет: кто будет его делать, обязан сперва завести такой ленджер,
// иначе звать это правило будет нечем.
//
// 🔒 ЗАМЕРЕНО 07.08.2026, СМЕНА 28 — И ЭТО НЕ «НЕКОМУ СДЕЛАТЬ», А «НЕЛЬЗЯ НАЧАТЬ».
// Единственное место затеи, откуда пункт вообще становится невыполнимым, — ответ владельца
// «нет» в `questions.mjs` (`item_marked_impossible`). Механизм вопросов владельцу мёртв
// С ОБЕИХ СТОРОН и ждёт его слова: подключение меняет ЕГО ночь. Значит и это правило
// заперто тем же решением — заводить ленджер невыполнимых, пока их неоткуда взять,
// значит строить провод к источнику, которого нет.
export function reshyonnyeSNevypolnimymi(verified = [], impossible = []) {
return [...new Set([...verified, ...impossible].map(String))];
}
// ── кусок 7, задача 6 ────────────────────────────────────────────────────────────────
// 🟢 ЖИВОЙ ЗОВУЩИЙ ЕСТЬ, И ОН ОДИН. ЗАМЕРЕНО 07.08.2026, СМЕНА 28.
//
// 🔴 ЧИСЛО ЗДЕСЬ НАЗВАНО ТОЧНО, И ЭТО НЕ ПРИДИРКА. Замысел велит звать правило ДВОИМ:
// блок 5а куска 4 (вслед за засчитанной заявкой) и блок 6а куска 7 (каждый круг).
// Подключён ПЕРВЫЙ — `supervisor.mjs`, блок 5а. Второго нет: куска 7 в затее нет вовсе.
// 🪤 Напиши здесь «зовут его двое» — и это снова стало бы правилом 235: задуманное
// в настоящем времени читается как доказательство подключения, будучи его заменой.
// Прежняя смена честно пометила ту же строку как враньё; чинить враньё половиной правды
// нельзя. Пока куска 7 нет — здесь стоит ОДИН.
//
// 🔴 ЕДИНСТВЕННОЕ место затеи, где записано, что значит «задача кончена». Второй дороги
// к этому правилу не заводить: разъедутся молча, и приёмщик пойдёт снимать недоделанную
// задачу — а владелец утром прочтёт «сделано». Ровно эту беду и держал прежний зов:
// вместо правила там стояло упрощение `otlozhennyePunkty.size === 0`, и на пункте,
// чьи файлы держит живой сосед, оно отвечало «всё засчитано» (замерено вырезанием).
//
// 🔴 Третий довод `otlozhennye` обязателен по существу (находка Н8). Отложенный пункт лежит
// в списке `done` очереди — чтобы она не выдала его снова, — но в `items-verified.jsonl`
// НЕ попадает. Значит по паре `{ items, verified }` его не видно вовсе, и правило объявило бы
// задачу кончённой там, где кусок 4 её кончённой не считает.
export function zadachaKonchena({ items, verified, otlozhennye }) {
const plan = (Array.isArray(items) ? items : []).map(String);
// 🔴 Сравниваем ИМЕНАМИ, а не числами: пункты зовутся «4.11», «10», «2»,
// и числовое сравнение здесь врёт молча.
const zaschitany = new Set((Array.isArray(verified) ? verified : []).map(String));
// 🔴 «Про отложенные не спросили» — это ТРЕТЬЕ состояние, и слипаться с «отложенных нет»
// ему нельзя: старый зовущий, не знающий про новый довод, молча получал бы «кончена»,
// то есть правка тихо вернула бы ровно ту беду, ради которой довод и заведён.
if (otlozhennye === null || otlozhennye === undefined) {
return {
konchena: false,
left: plan.filter(p => !zaschitany.has(p)),
reason: 'про отложенные пункты не спросили — пока не спросят, задача кончённой не считается',
};
}
const otlozheny = new Set([...otlozhennye].map(String));
// Пустой план — это не победа, а сбой разбора: пунктов не нашлось вовсе.
if (plan.length === 0) {
return { konchena: false, left: [], reason: 'в плане задачи нет ни одного пункта — считать нечего' };
}
const left = plan.filter(p => !zaschitany.has(p));
if (left.length) {
return { konchena: false, left, reason: `не засчитаны пункты: ${left.join(', ')}` };
}
const otlozhennyeIzPlana = plan.filter(p => otlozheny.has(p));
if (otlozhennyeIzPlana.length) {
return {
konchena: false,
left: otlozhennyeIzPlana,
reason: `отложены пункты: ${otlozhennyeIzPlana.join(', ')} — задача не кончена, пока они висят`,
};
}
return { konchena: true, left: [], reason: 'все пункты плана засчитаны, отложенных нет' };
}