#!/usr/bin/env node /** * router-learning-queue — очередь обучения роутера (3.4 примеры / 3.5 долгая память). * НЕСУЩИЙ HARD-RULE (решение владельца 2026-06-03): наполнение фонда ТОЛЬКО по явному * «да» владельца, ИНАЧЕ НИКАК. Система только ПРЕДЛАГАЕТ (enqueue → pending); в фонд * переводит ТОЛЬКО applyApprovalBatch с явным id в approve. Без авто-обучения. */ import fsDefault from 'node:fs'; /** Запись очереди — ВСЕГДА pending (нет авто-одобрения). */ export function makeQueueEntry(candidate) { return { id: candidate.id, kind: candidate.kind, summary: candidate.summary || '', why_proposed: candidate.why_proposed || '', status: 'pending' }; } /** Добавить предложение (pending). Никогда не одобряет. */ export function enqueue(queue, candidate) { return [...(queue || []), makeQueueEntry(candidate)]; } /** Сколько ждёт одобрения. */ export function pendingCount(queue) { return (queue || []).filter((e) => e.status === 'pending').length; } /** * Одобрение пачкой — ЕДИНСТВЕННЫЙ путь в фонд. approve → 'approved' + в fund; * reject → 'rejected' (помечен, не предлагать повторно); defer → остаётся 'pending'. * Без явного решения id остаётся как был. Возвращает {queue, fund, rejected}. * * Приоритет reject > approve: конфликт «и да, и нет» по одному id — двусмысленный * сигнал → по hard-rule «без явного да НИКАК» в фонд НЕ пускаем (безопасная сторона). */ export function applyApprovalBatch(queue, { approve = [], reject = [], defer = [] } = {}) { const aSet = new Set(approve), rSet = new Set(reject), dSet = new Set(defer); const fund = [], rejected = []; const newQueue = (queue || []).map((e) => { // G (аудит M1-M4): только pending-запись переходит. Повторное approve/reject уже // решённого id (или reuse id) НЕ перепроводит её снова → нет дублей в фонде. if (e.status !== 'pending') return e; if (rSet.has(e.id)) { const ne = { ...e, status: 'rejected' }; rejected.push(ne); return ne; } if (aSet.has(e.id)) { const ne = { ...e, status: 'approved' }; fund.push(ne); return ne; } if (dSet.has(e.id)) return { ...e, status: 'pending' }; return e; }); return { queue: newQueue, fund, rejected }; } /** Рендер раздела «На одобрение» для /brain-retro (только pending). */ export function renderApprovalSection(queue) { const pend = (queue || []).filter((e) => e.status === 'pending'); if (pend.length === 0) return '## На одобрение\n\n_нет кандидатов_'; const lines = pend.map((e) => `- [${e.id}] (${e.kind}) ${e.summary} — почему: ${e.why_proposed}`); return `## На одобрение (${pend.length})\n\n${lines.join('\n')}\n\nОтвет: «одобряю 1,3 / отклоняю 2 / отложить 4».`; } /** Сигнал для STATUS.md: «ждут одобрения: N». */ export function statusSignal(queue) { return `ждут одобрения: ${pendingCount(queue)}`; } /** Персист очереди (fs инъектируется). */ export function saveQueue({ queue, path, fsImpl = fsDefault }) { fsImpl.writeFileSync(path, JSON.stringify(queue)); } export function loadQueue({ path, fsImpl = fsDefault }) { try { return JSON.parse(fsImpl.readFileSync(path, 'utf8')); } catch (e) { if (e && e.code === 'ENOENT') return []; throw e; } }