#!/usr/bin/env node // deepwork — цикл с инструментами поверх моста ollama_orchestro/ask.mjs. // // DeepSeek через веб-чат видит только то, что ему положили в вопрос. Здесь он // получает обратное направление: возвращает блок-запрос (NEED/LIST/FIND), // программа его исполняет и досылает результат следующим сообщением в тот же // чат. Получается обычный агентский цикл, только транспорт — текст. // // Мост НЕ импортируется и НЕ копируется: ask.mjs разбирает process.argv и // исполняет команду на верхнем уровне, поэтому import немедленно запустил бы // её. Вызываем его подпроцессом теми флагами, которые у него уже есть. // ollama_orchestro при этом не меняется ни на строку. import { readFile, writeFile, mkdir, copyFile, readdir, stat, rm } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { spawn } from 'node:child_process'; import { resolve, join, dirname, relative, isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; import { tmpdir } from 'node:os'; const HERE = dirname(fileURLToPath(import.meta.url)); // ------------------------------------------------------------------ аргументы const rawArgs = process.argv.slice(2); const positional = []; const flags = {}; for (let i = 0; i < rawArgs.length; i++) { const a = rawArgs[i]; if (a.startsWith('--')) { const eq = a.indexOf('='); if (eq !== -1) { flags[a.slice(2, eq)] = a.slice(eq + 1); continue; } const next = rawArgs[i + 1]; if (next !== undefined && !next.startsWith('--')) { flags[a.slice(2)] = next; i++; } else flags[a.slice(2)] = true; } else positional.push(a); } const USAGE = ` deepwork — задать DeepSeek вопрос и дать ему дочитать недостающее самому node deepwork.mjs "вопрос" [--files a.js,b.js] [--steps 4] [--apply] node deepwork.mjs ui веб-пульт: файлы галочками, ход работы виден Работает в ТЕКУЩЕЙ папке: её файлы показывает, в неё же пишет правки. --files a.js,b.js что положить в вопрос сразу (через запятую) --steps N сколько кругов позволить (по умолчанию 4) --apply применить пришедшие блоки === FILE: === на диск (прежняя версия уезжает в backup/) --chat роль think (по умолчанию) | search --timeout сек максимум бездействия страницы, передаётся мосту --keep-temp не удалять временные файлы шагов --check проверить, что мост на месте и вкладка жива --selftest прогнать внутренние проверки, ничего не отправляя Инструменты, которые может вернуть DeepSeek (только чтение, только внутри текущей папки): === NEED: путь, путь === прислать файлы целиком === LIST: **/*.mjs === список файлов по маске === FIND: текст === где встречается текст Мост берётся из config.json (ключ bridge), по умолчанию — ../ollama_orchestro/ask.mjs рядом с этой папкой. `.trim(); // ------------------------------------------------------------------ настройки const CONFIG_DEFAULTS = { bridge: resolve(HERE, '..', 'ollama_orchestro', 'ask.mjs'), chat: 'think', steps: 4, timeout: 240, cdpPort: 9333, // отладочный порт браузера — только чтобы показать, жив ли мост uiPort: 8787, // не 8777: там живёт пульт orchestro, оба могут работать разом }; // Нет файла — работаем на умолчаниях, это норма. А вот битый JSON молчать не // должен: иначе правка в config.json не действует, и понять почему нельзя — // программа просто идёт по умолчанию к соседнему мосту. async function readConfig() { const file = join(HERE, 'config.json'); let text; try { text = await readFile(file, 'utf8'); } catch { return { ...CONFIG_DEFAULTS }; } try { return { ...CONFIG_DEFAULTS, ...JSON.parse(text) }; } catch (e) { throw new Error(`config.json не разбирается: ${e.message}\nФайл: ${file}`); } } // ------------------------------------------------------- работа с файлами const SKIP_DIRS = new Set(['.git', 'node_modules', '.claude', 'dist', 'build', '__pycache__', '.venv', 'vendor', 'backup']); const SKIP_EXT = /\.(png|jpe?g|gif|webp|ico|pdf|zip|rar|7z|exe|dll|mp[34]|mov|woff2?|ttf|eot|lock|bak)$/i; // Лимиты на результат инструмента: он уезжает в сообщение чата, а там предел // длины. Лучше отдать обрезанное с пометкой, чем не отдать ничего. const NEED_BUDGET = 200_000; // символов суммарно за шаг const FIND_HITS = 100; const LIST_PATHS = 300; async function listFiles(root, rel = '', out = []) { for (const name of await readdir(join(root, rel))) { if (name.startsWith('.') && name !== '.gitignore') continue; if (SKIP_DIRS.has(name)) continue; const r = rel ? `${rel}/${name}` : name; const st = await stat(join(root, r)); if (st.isDirectory()) { await listFiles(root, r, out); continue; } if (SKIP_EXT.test(name)) continue; out.push({ path: r, size: st.size }); } return out; } // Путь обязан остаться внутри рабочей папки: ответ приходит из интернета, // доверять ему нельзя. Это касается и чтения, и записи. function resolveInside(root, rel) { if (!rel || isAbsolute(rel)) return null; const full = resolve(join(root, rel)); if (relative(resolve(root), full).startsWith('..')) return null; return full; } // Маска из LIST — не полноценный glob, а три подстановки: ** — любые папки, // * — часть имени, ? — один символ. Полный glob здесь не нужен, а зависимость // ради него — тем более. function maskToRe(mask) { const esc = mask.replace(/[.+^${}()|[\]\\]/g, '\\$&'); const body = esc .replace(/\*\*\//g, '\u0000SLASHSTAR\u0000') .replace(/\*\*/g, '\u0000STARSTAR\u0000') .replace(/\*/g, '[^/]*') .replace(/\?/g, '[^/]') .replace(/\u0000SLASHSTAR\u0000/g, '(?:.*/)?') .replace(/\u0000STARSTAR\u0000/g, '.*'); return new RegExp(`^${body}$`, 'i'); } // ------------------------------------------------------------------ разбор const FILE_CLOSE = '=== END FILE ==='; const FILE_BLOCK_RE = new RegExp(`^=== FILE:\\s*(.+?)\\s*===\\s*$([\\s\\S]*?)^${FILE_CLOSE}\\s*$`, 'gm'); const LANG_RE = /^(text|plain|javascript|js|jsx|typescript|ts|tsx|json|html|css|scss|python|py|bash|sh|shell|cmd|bat|powershell|ps1|diff|patch|xml|yaml|yml|sql|java|kotlin|swift|c|cpp|cs|go|rust|rs|php|ruby|rb|ini|toml|markdown|md)$/i; // Блоки === FILE: путь === … === END FILE ===. Содержимое обязано лежать в // тройных кавычках (иначе разметка страницы съедает отступы), а страница // показывает блок без самих кавычек, но с подписью языка отдельной строкой — // её тоже снимаем. function parsePatches(answer) { const out = []; const re = new RegExp(FILE_BLOCK_RE.source, 'gm'); let m; while ((m = re.exec(String(answer || '')))) { const lines = m[2].replace(/^\n/, '').replace(/\n$/, '').split('\n'); if (/^\s*```/.test(lines[0] ?? '')) { lines.shift(); if (/^\s*```\s*$/.test(lines[lines.length - 1] ?? '')) lines.pop(); } if (LANG_RE.test((lines[0] ?? '').trim())) lines.shift(); out.push({ path: m[1].trim(), content: lines.join('\n') }); } return out; } // Запросы-однострочники. Тела и закрывающего блока у них нет — этим они и // отличаются от FILE. Блоки FILE вырезаются ПЕРЕД поиском: если DeepSeek // вернул файл, внутри которого написаны такие же строки (а этот проект как раз // про них), они не должны сойти за запрос. function parseRequests(answer) { const text = String(answer || '').replace(FILE_BLOCK_RE, ''); const out = []; const re = /^=== (NEED|LIST|FIND):\s*(.+?)\s*===\s*$/gm; let m; while ((m = re.exec(text))) out.push({ kind: m[1], arg: m[2] }); return out; } // ------------------------------------------------------------- исполнители function fence(body) { return '```\n' + body + '\n```'; } async function doNeed(root, arg, budget) { const parts = []; for (const rel of arg.split(',').map(s => s.trim().replace(/^["'`]|["'`]$/g, '')).filter(Boolean)) { const full = resolveInside(root, rel); if (!full) { parts.push(`=== RESULT: NEED ${rel} ===\n${fence('отказано: путь ведёт за пределы рабочей папки')}`); continue; } let text; try { text = await readFile(full, 'utf8'); } catch { parts.push(`=== RESULT: NEED ${rel} ===\n${fence('файла нет по такому пути')}`); continue; } if (text.length > budget.left) { text = text.slice(0, Math.max(budget.left, 0)) + `\n…обрезано, всего ${text.length} символов`; } budget.left -= text.length; parts.push(`=== FILE: ${rel} ===\n${fence(text)}\n${FILE_CLOSE}`); } return parts.join('\n\n'); } async function doList(root, arg) { const re = maskToRe(arg.trim() || '**/*'); const all = await listFiles(root); const hit = all.filter(f => re.test(f.path)); const shown = hit.slice(0, LIST_PATHS); const body = shown.map(f => `${f.path} ${f.size} б`).join('\n') + (hit.length > shown.length ? `\n…обрезано, всего ${hit.length}` : '') + (hit.length ? '' : 'ничего не подошло под маску'); return `=== RESULT: LIST ${arg} ===\n${fence(body)}`; } async function doFind(root, arg) { const needle = arg.trim().toLowerCase(); const hits = []; for (const f of await listFiles(root)) { if (f.size > 2_000_000) continue; let text; try { text = await readFile(join(root, f.path), 'utf8'); } catch { continue; } if (!text.toLowerCase().includes(needle)) continue; text.split('\n').forEach((line, i) => { if (hits.length >= FIND_HITS + 1) return; if (line.toLowerCase().includes(needle)) hits.push(`${f.path}:${i + 1}: ${line.trim().slice(0, 200)}`); }); } const shown = hits.slice(0, FIND_HITS); const body = shown.join('\n') + (hits.length > shown.length ? `\n…обрезано, показаны первые ${FIND_HITS}` : '') + (hits.length ? '' : 'нигде не встречается'); return `=== RESULT: FIND ${arg} ===\n${fence(body)}`; } async function runRequests(root, requests, log) { const budget = { left: NEED_BUDGET }; const parts = []; for (const r of requests) { log(` ${r.kind}: ${r.arg}`); if (r.kind === 'NEED') parts.push(await doNeed(root, r.arg, budget)); else if (r.kind === 'LIST') parts.push(await doList(root, r.arg)); else if (r.kind === 'FIND') parts.push(await doFind(root, r.arg)); } return parts.filter(Boolean).join('\n\n'); } // ------------------------------------------------------------------ мост // Один шаг: текст уходит через --file, ответ забирается через --out. Оба пути // абсолютные, поэтому рабочая папка подпроцесса роли не играет. // fresh=true — первое сообщение (мост правит его и чат остаётся из одного // обмена); fresh=false — обычная отправка, только так DeepSeek помнит шаг. async function callBridge(cfg, text, { fresh, temp, n, log }) { const qFile = join(temp, `step${n}-q.txt`); const aFile = join(temp, `step${n}-a.txt`); await writeFile(qFile, text, 'utf8'); const args = ['ask', '--chat', String(cfg.chat), '--file', qFile, '--out', aFile, '--timeout', String(cfg.timeout), '--quiet']; if (!fresh) args.push('--new'); log(`шаг ${n}: ${text.length} символов${fresh ? '' : ' (--new)'}, жду мост…`); const started = Date.now(); const code = await new Promise((done, fail) => { const p = spawn(process.execPath, [cfg.bridge, ...args], { cwd: dirname(cfg.bridge) }); let err = ''; p.stdout.on('data', () => {}); p.stderr.on('data', d => { err += d.toString(); }); p.on('error', fail); p.on('close', c => { if (c !== 0) fail(new Error(err.trim() || `мост вышел с кодом ${c}`)); else done(c); }); }); void code; const answer = await readFile(aFile, 'utf8'); log(` ответ ${answer.length} символов за ${Math.round((Date.now() - started) / 1000)} с`); // Мост сам жмёт Retry, когда DeepSeek отвечает «Server is busy», но попытки // там не бесконечны. Если такой текст всё-таки доехал — это не ответ, и // разбирать его как ответ нельзя: в блоках-запросах и правках его нет, // а цикл решил бы, что запросов не поступило, и молча закончил работу. if (isServerBusy(answer)) throw new Error( 'DeepSeek занят: «Server is busy». Мост исчерпал повторы — попробуйте позже.'); return answer; } // Ответ, которого на самом деле нет: сервер занят и просит повторить. function isServerBusy(text) { const t = String(text || '').trim(); return t.length < 400 && /server is busy|сервер занят|try again later/i.test(t); } // ------------------------------------------------------------------ цикл async function buildFirst(root, files, question) { let head = ''; try { const raw = await readFile(join(HERE, 'agent-prompt.md'), 'utf8'); head = (raw.split(/^---$/m)[1] ?? '').trim(); } catch { /* нет файла — поедет без приставки, инструменты просто не всплывут */ } // Дерево папки едет сразу. Первый живой прогон без него: DeepSeek угадывал // имена (server.js, bridge.js — таких нет), потом просил LIST *.js, потом // LIST **/* — два круга из четырёх ушли на выяснение, что вообще лежит в // папке. Дерево этого проекта — 600 символов против двух минут ожидания. const tree = (await listFiles(root)).slice(0, LIST_PATHS); const parts = [head, '', '=== RESULT: LIST **/* ===', fence(tree.map(f => `${f.path} ${f.size} б`).join('\n')), '', ]; for (const rel of files) { const full = resolveInside(root, rel); if (!full) throw new Error(`файл вне рабочей папки: ${rel}`); parts.push(`=== FILE: ${rel} ===`, fence(await readFile(full, 'utf8')), FILE_CLOSE, ''); } // Напоминание повторяется ВПЛОТНУЮ к вопросу. Первый живой прогон: приставка // ушла в начало сообщения, между ней и вопросом легли 22 КБ файла, и DeepSeek // инструменты проигнорировал — сам написал «точный код в другом файле» и // выдал три догадки вместо запроса. Инструкция в хвосте весит больше. parts.push(TAIL_REMINDER, '', 'ВОПРОС:', question.trim()); return parts.join('\n'); } const TAIL_REMINDER = [ 'НАПОМИНАНИЕ ПЕРЕД ОТВЕТОМ. Присланными файлами дело не ограничено: рабочая', 'папка доступна тебе целиком. Если для ответа нужен код, которого ты не', 'видел, — НЕ строй догадок и не пиши «точный код в другом файле». Верни', 'блок-запрос, он будет исполнен, и следующим сообщением ты получишь данные:', '', '=== NEED: путь/файл.js === прислать файл целиком', '=== LIST: **/*.mjs === список файлов по маске', '=== FIND: имяФункции === где встречается текст, с номерами строк', '', 'Не знаешь пути — начни с LIST или FIND. Запрашивай всё нужное сразу, одним', 'сообщением. Догадка вместо запроса считается неверным ответом.', ].join('\n'); const FOLLOW_HEAD = [ 'РЕЗУЛЬТАТ ЗАПРОСА. Данные ниже. Отвечай по существу вопроса из первого', 'сообщения; не благодари и не пересказывай присланное. Если данных всё ещё', 'не хватает — верни новый блок-запрос, иначе отвечай. Правки файлов —', 'блоками === FILE: путь === с содержимым в тройных кавычках.', '', ].join('\n'); async function runAgent(cfg, root, question, files, log) { const temp = join(tmpdir(), `deepwork-${process.pid}`); await mkdir(temp, { recursive: true }); let text = await buildFirst(root, files, question); let answer = ''; let lastSig = ''; for (let n = 1; n <= cfg.steps; n++) { answer = await callBridge(cfg, text, { fresh: n === 1, temp, n, log }); const requests = parseRequests(answer); if (!requests.length) { log(`шаг ${n}: запросов нет, готово`); break; } // Один и тот же запрос второй раз подряд — петля: данные мы уже присылали, // значит DeepSeek их не принял и повторит это бесконечно. const sig = requests.map(r => `${r.kind}:${r.arg}`).join('|'); if (sig === lastSig) { log(`шаг ${n}: тот же запрос повторно — петля, выхожу`); break; } lastSig = sig; if (n === cfg.steps) { log(`шаг ${n}: шаги кончились, а запросы ещё идут`); break; } log(`шаг ${n}: запрошено ${requests.length}`); const result = await runRequests(root, requests, log); text = FOLLOW_HEAD + result; } if (!flags['keep-temp']) await rm(temp, { recursive: true, force: true }).catch(() => {}); else log(`временные файлы: ${temp}`); return answer; } // ------------------------------------------------------------- запись правок async function applyPatch(root, rel, content) { const full = resolveInside(root, rel); if (!full) return { ok: false, reason: 'путь ведёт за пределы рабочей папки' }; let backup = null; try { await readFile(full, 'utf8'); const stamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15); backup = join('backup', `${rel}.bak-${stamp}`); await mkdir(dirname(join(root, backup)), { recursive: true }); await copyFile(full, join(root, backup)); } catch { /* новый файл, копировать нечего */ } await mkdir(dirname(full), { recursive: true }); await writeFile(full, content, 'utf8'); return { ok: true, backup }; } // ------------------------------------------------------------------ пульт // Ход работы копится здесь, а страница забирает его опросом раз в 2 секунды. // Стримить нечего: строк за прогон десяток, а опрос — тот же приём, которым // пульт orchestro следит за мостом. const progress = { steps: [], busy: false, started: 0 }; async function serve(cfg) { const http = await import('node:http'); const root = process.cwd(); const port = Number.parseInt(flags.port, 10) || cfg.uiPort; const json = (res, code, data) => { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(data)); }; // Куски копим Buffer'ами и склеиваем один раз: складывание строками рвёт // многобайтовый символ на границе, и русский вопрос уезжает крякозябрами. const readBody = req => new Promise((ok, bad) => { const chunks = []; req.on('data', c => chunks.push(c)); req.on('error', bad); req.on('end', () => { try { ok(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); } catch (e) { bad(e); } }); }); const server = http.createServer(async (req, res) => { try { const url = new URL(req.url, `http://127.0.0.1:${port}`); if (req.method === 'GET' && url.pathname === '/') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(await readFile(join(HERE, 'ui.html'), 'utf8')); } if (req.method === 'GET' && url.pathname === '/api/tree') return json(res, 200, { root, files: await listFiles(root) }); if (req.method === 'GET' && url.pathname === '/api/status') { try { const r = await fetch(`http://127.0.0.1:${cfg.cdpPort}/json/list`, { signal: AbortSignal.timeout(1500) }); const pages = (await r.json()).filter(t => t.type === 'page'); return json(res, 200, { browser: true, deepseek: pages.some(t => (t.url || '').includes('chat.deepseek.com')) }); } catch { return json(res, 200, { browser: false, deepseek: false }); } } if (req.method === 'GET' && url.pathname === '/api/progress') return json(res, 200, { ...progress, seconds: progress.busy ? Math.round((Date.now() - progress.started) / 1000) : 0 }); if (req.method === 'POST' && url.pathname === '/api/ask') { if (progress.busy) return json(res, 409, { error: 'предыдущий вопрос ещё в работе' }); const body = await readBody(req); if (!String(body.question || '').trim()) return json(res, 400, { error: 'пустой вопрос' }); progress.busy = true; progress.steps = []; progress.started = Date.now(); try { const run = { ...cfg, steps: Number.parseInt(body.steps, 10) || cfg.steps }; const answer = await runAgent(run, root, String(body.question), body.files || [], m => progress.steps.push(m)); return json(res, 200, { answer, steps: progress.steps, patches: parsePatches(answer) }); } finally { progress.busy = false; } } if (req.method === 'POST' && url.pathname === '/api/apply') { const { path: rel, content } = await readBody(req); return json(res, 200, await applyPatch(root, rel, content)); } res.writeHead(404); res.end('нет такой страницы'); } catch (e) { json(res, 500, { error: String(e && e.message || e) }); } }); // Занятый порт не должен ронять запуск: прошлый пульт мог остаться висеть. const listen = p => new Promise((ok, bad) => { const onErr = e => (e.code === 'EADDRINUSE' ? ok(null) : bad(e)); server.once('error', onErr); server.listen(p, '127.0.0.1', () => { server.off('error', onErr); ok(p); }); }); let actual = null; for (let p = port; p < port + 20 && actual === null; p++) actual = await listen(p); if (actual === null) throw new Error(`свободный порт не найден в диапазоне ${port}..${port + 19}`); console.log(`пульт: http://127.0.0.1:${actual}`); console.log(`папка: ${root}`); console.log('остановить — Ctrl+C'); // Открываем СТРОГО в браузере по умолчанию. В отладочный браузер нельзя: // пульт займёт там вкладку, и мост перестанет находить chat.deepseek.com. if (!flags['no-open']) { const { exec } = await import('node:child_process'); exec(`start "" "http://127.0.0.1:${actual}"`, { shell: 'cmd.exe' }, () => {}); } await new Promise(() => {}); } // ------------------------------------------------------------- самопроверка async function selftest() { const { strict: assert } = await import('node:assert'); const root = process.cwd(); // Запросы разбираются, а такие же строки ВНУТРИ блока файла — нет. const sample = [ 'Не хватает данных.', '=== NEED: ask.mjs, ui.html ===', '=== LIST: **/*.mjs ===', '=== FIND: waitForAnswer ===', '=== FILE: agent-prompt.md ===', '```', 'text', '=== NEED: это строка внутри файла ===', ' отступ сохраняется', '```', FILE_CLOSE, ].join('\n'); const req = parseRequests(sample); assert.deepEqual(req.map(r => r.kind), ['NEED', 'LIST', 'FIND']); assert.equal(req[0].arg, 'ask.mjs, ui.html'); const pat = parsePatches(sample); assert.equal(pat.length, 1); assert.equal(pat[0].path, 'agent-prompt.md'); // Тройные кавычки и подпись языка сняты, отступ и строка-обманка целы. assert.equal(pat[0].content, '=== NEED: это строка внутри файла ===\n отступ сохраняется'); // Выход за пределы папки отвергается, свои пути — нет. assert.equal(resolveInside(root, '../../windows/system32/drivers/etc/hosts'), null); assert.equal(resolveInside(root, 'C:\\windows\\win.ini'), null); assert.ok(resolveInside(root, 'sub/dir/file.js')); // Маска: * не перепрыгивает через папку, ** перепрыгивает. assert.ok(!maskToRe('*.mjs').test('sub/x.mjs')); assert.ok(maskToRe('*.mjs').test('x.mjs')); assert.ok(maskToRe('**/*.mjs').test('sub/dir/x.mjs')); assert.ok(maskToRe('**/*.mjs').test('x.mjs')); assert.ok(!maskToRe('**/*.mjs').test('x.md')); console.log('самопроверка пройдена'); } // ------------------------------------------------------------------ запуск const cfg = await readConfig(); cfg.steps = Number.parseInt(flags.steps, 10) || cfg.steps; cfg.timeout = Number.parseInt(flags.timeout, 10) || cfg.timeout; if (flags.chat) cfg.chat = String(flags.chat); if (flags.selftest) { await selftest(); process.exit(0); } if (!existsSync(cfg.bridge)) { console.error(`Мост не найден: ${cfg.bridge}`); console.error('Укажите путь к ask.mjs в config.json (образец — config.example.json).'); process.exit(1); } if (flags.ui || positional[0] === 'ui') { await serve(cfg); } else if (flags.check) { console.log(`мост: ${cfg.bridge}`); const p = spawn(process.execPath, [cfg.bridge, 'probe'], { cwd: dirname(cfg.bridge), stdio: 'inherit' }); p.on('close', c => process.exit(c)); } else { const question = positional.join(' '); if (!question.trim()) { console.log(USAGE); process.exit(question ? 1 : 0); } const root = process.cwd(); const files = String(flags.files || '').split(',').map(s => s.trim()).filter(Boolean); const log = (...a) => console.log(...a); try { console.log(`папка: ${root}`); const answer = await runAgent(cfg, root, question, files, log); console.log('\n----- ответ DeepSeek -----'); console.log(answer); const patches = parsePatches(answer); if (patches.length) { console.log(`\n----- файлы в ответе: ${patches.length} -----`); for (const p of patches) console.log(` ${p.path} ${p.content.split('\n').length} строк`); if (flags.apply) { for (const p of patches) { const r = await applyPatch(root, p.path, p.content); console.log(r.ok ? `записан ${p.path}${r.backup ? ` (копия: ${r.backup})` : ' (новый)'}` : `пропущен ${p.path}: ${r.reason}`); } } else console.log(' не записаны: нужен флаг --apply'); } } catch (e) { console.error(`Ошибка: ${e.message}`); process.exit(1); } }