@nitra/cursor 10.1.0 → 10.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -268,18 +268,48 @@ CURSOR_MODEL="${ADR_NORMALIZE_CURSOR_MODEL:-claude-4.6-sonnet-medium}"
268
268
 
269
269
  RESPONSE_FILE="$TMP_DIR/response.txt"
270
270
 
271
- if command -v claude >/dev/null 2>&1; then
272
- log "using claude CLI (model: $CLAUDE_MODEL)"
273
- claude -p --model "$CLAUDE_MODEL" < "$FULL_PROMPT_FILE" > "$RESPONSE_FILE" 2>>"$LOG" || true
274
- elif command -v cursor-agent >/dev/null 2>&1; then
275
- log "using cursor-agent CLI (model: $CURSOR_MODEL)"
276
- FULL_PROMPT=$(cat "$FULL_PROMPT_FILE")
277
- cursor-agent -p --mode ask --output-format text --model "$CURSOR_MODEL" -- "$FULL_PROMPT" > "$RESPONSE_FILE" 2>>"$LOG" || true
278
- else
279
- log "no LLM CLI found, skipping"
280
- exit 0
271
+ # Backend selection. `local` конвеєр на малій локальній моделі (privacy + $0,
272
+ # `npm/scripts/lib/adr/normalize-pipeline.mjs`); `claude`/`cursor` single-shot
273
+ # у хмару. Auto-default: local, якщо налаштовано `N_LOCAL_MIN_MODEL`, інакше
274
+ # claude cursor. Команда local-бекенда override-иться через ADR_NORMALIZE_LOCAL_CMD
275
+ # (для тестів/in-repo: `node npm/bin/n-cursor.js adr-normalize-local`).
276
+ BACKEND="${ADR_NORMALIZE_BACKEND:-}"
277
+ if [ -z "$BACKEND" ]; then
278
+ if [ -n "${N_LOCAL_MIN_MODEL:-}" ]; then
279
+ BACKEND=local
280
+ elif command -v claude >/dev/null 2>&1; then
281
+ BACKEND=claude
282
+ elif command -v cursor-agent >/dev/null 2>&1; then
283
+ BACKEND=cursor
284
+ else
285
+ BACKEND=none
286
+ fi
281
287
  fi
282
288
 
289
+ ADR_LOCAL_CMD="${ADR_NORMALIZE_LOCAL_CMD:-npx --no @nitra/cursor adr-normalize-local}"
290
+
291
+ case "$BACKEND" in
292
+ local)
293
+ log "using local pipeline backend (model: ${N_LOCAL_MIN_MODEL:-?})"
294
+ # local-бекенд будує власні дрібні промпти з батча — FULL_PROMPT_FILE не потрібен.
295
+ # shellcheck disable=SC2086
296
+ $ADR_LOCAL_CMD --batch "$BATCH_LIST" --clean "$CLEAN_LIST" --adr-dir "$ADR_DIR" > "$RESPONSE_FILE" 2>>"$LOG" || true
297
+ ;;
298
+ claude)
299
+ log "using claude CLI (model: $CLAUDE_MODEL)"
300
+ claude -p --model "$CLAUDE_MODEL" < "$FULL_PROMPT_FILE" > "$RESPONSE_FILE" 2>>"$LOG" || true
301
+ ;;
302
+ cursor)
303
+ log "using cursor-agent CLI (model: $CURSOR_MODEL)"
304
+ FULL_PROMPT=$(cat "$FULL_PROMPT_FILE")
305
+ cursor-agent -p --mode ask --output-format text --model "$CURSOR_MODEL" -- "$FULL_PROMPT" > "$RESPONSE_FILE" 2>>"$LOG" || true
306
+ ;;
307
+ *)
308
+ log "no LLM backend available, skipping"
309
+ exit 0
310
+ ;;
311
+ esac
312
+
283
313
  if [ ! -s "$RESPONSE_FILE" ]; then
284
314
  log "empty LLM response"
285
315
  exit 0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [10.3.0] - 2026-06-15
4
+
5
+ ### Added
6
+
7
+ - adr-normalize: локальний конвеєр-оркестратор як backend (retrieval→edge-judge→cluster→gen на малій моделі); субкоманда adr-normalize-local + backend-селектор у normalize-decisions.sh (local|claude|cursor)
8
+
9
+ ## [10.2.0] - 2026-06-15
10
+
11
+ ### Added
12
+
13
+ - tool-surface rule — паритет «UI ↔ LLM ↔ оркестратор»: будь-яка дія фронтенду виконувана без UI через спільний каталог тулів (catalog → dispatch → UI/CLI/LLM-адаптери). Платформо-незалежне ядро; per-stack деталі делегуються правилам tauri/vue/capacitor. Авто-активація на фронтенд-залежностях. Додано per-stack секцію «Tool Surface у Tauri» в правило tauri (делегація в Rust-крейт/бінарник, два транспорти, схеми через schemars, дозволи плагінів).
14
+
3
15
  ## [10.1.0] - 2026-06-15
4
16
 
5
17
  ### Changed
package/bin/n-cursor.js CHANGED
@@ -4,19 +4,18 @@
4
4
  * n-cursor — CLI завантаження правил та перевірки проєкту
5
5
  *
6
6
  * Використання:
7
- * `npx \@nitra/cursor` — завантажити cursor-правила
8
- * `npx \@nitra/cursor fix` — автономний оркестратор: T0-auto + LLM (haiku→sonnet); convergence-loop до чистого стану [--max-iter N] [rules]
9
- * якщо в корені вже є `.n-cursor.json`, спочатку зчитується конфіг і за потреби дописується `$schema`
10
- * `npx \@nitra/cursor fix bun` — оркестратор лише для вказаних правил; `--json` = check-only (structured output для CI)
7
+ * `npx \@nitra/cursor` — завантажити cursor-правила (синк); якщо в корені вже є `.n-cursor.json`,
8
+ * спочатку зчитується конфіг і за потреби дописується `$schema`
11
9
  * `npx \@nitra/cursor rename-yaml-extensions` — k8s `*.yml` → `*.yaml`, `.github` `*.yaml` → `*.yml` (опції: `--dry-run`, `--root=…`; див. bin/rename-yaml-extensions.mjs)
12
10
  * `npx \@nitra/cursor post-tool-use-fix` — точка входу PostToolUse hook Claude Code: читає stdin JSON,
13
11
  * дістає `tool_input.file_path`, маршрутизує його у відповідні правила
14
12
  * (`*.mjs` → `js-lint`, `*.vue` → `js-lint style-lint vue` тощо) і викликає
15
13
  * `fix` лише з ними. Прописується автоматично в `.claude/settings.json`.
16
- * `npx \@nitra/cursor fix` автономний оркестратор (meta.json: orchestrator:true): T0-auto → LLM via pi (haiku→sonnet) → check-gate → loop; [--max-iter N] [rules]
17
- * `npx \@nitra/cursor lint` — оркестратор lint-ланцюжка з кореневого `package.json` з вимірюванням часу
18
- * кожного `lint-*` / `oxfmt` скрипта (fail-fast); канонічна заміна
19
- * раніше ручного `lint-ga && lint-js && …` агрегатора.
14
+ * `npx \@nitra/cursor lint` data-driven оркестратор lint+конформності по `rules/<id>/meta.json` (`lint: per-file|full`):
15
+ * за замовчуванням fix-by-default по дельті vs origin (лише `per-file` правила); `--full` =
16
+ * весь репо (`per-file` `full`); `--read-only` = без мутацій/LLM (CI); позиційні
17
+ * (не-флаг) аргументи фільтр правил конформності (мапить колишній `fix <rule>`).
18
+ * CI = `lint --read-only --full` (весь репо, нуль мутацій/LLM).
20
19
  * `npx \@nitra/cursor lint-ga` — канонічний lint-ga (ga.mdc): preflight на `shellcheck` →
21
20
  * `bunx github-actionlint` → `uvx zizmor --offline --collect=workflows .`
22
21
  * `npx \@nitra/cursor lint-rego` — канонічний lint-rego (conftest.mdc + rego.mdc):
@@ -27,7 +26,6 @@
27
26
  * `markdownlint-cli2 --fix` → `v8r` (json/json5/yaml/yml/toml)
28
27
  * `npx \@nitra/cursor lint-doc-files` — детермінований детектор застарілості файлових док (`stale`: `missing`|`crc-mismatch`); правило doc-files (ignore-glob у `npm/rules/doc-files/js/docgen-ignore.mjs`; тека `docs/` поряд із джерелом). Режими: повний (exit 1), `--json` (exit 0), `--missing-only`, `--hook`/`--git` (hook-протокол, exit 2), `--degraded`
29
28
  * `npx \@nitra/cursor fix-doc-files` — JS-оркестрована генерація файлових док (роутинг local/cloud) зі штампом CRC (`--limit`/`--from`/`--overwrite`/`--retry-degraded`); `--stamp` — детерміноване перештампування CRC без LLM
30
- * `npx \@nitra/cursor doc-files <sub>` — DEPRECATED-аліас (scan|check|gen|stamp) → `lint-doc-files`/`fix-doc-files`
31
29
  * `npx \@nitra/cursor doc-aggregate modules` — JSON-лістинг логічних модулів (межі за `package.json`) для Tier 2 скілу doc-aggregate
32
30
  * `npx \@nitra/cursor skill list` — скіли пакета без синку в проєкт
33
31
  * `npx \@nitra/cursor skill taze` — промпт на stdout
@@ -661,7 +659,7 @@ function buildClaudeDocFilesSectionLines() {
661
659
  '',
662
660
  '## Файлова документація (`doc-files` — обовʼязковий крок, як lint)',
663
661
  '',
664
- 'Після зміни чи додавання кодового файлу його файлова дока (`<dir>/docs/<stem>.md`) має бути **актуальною** — це **обовʼязковий крок кожної задачі**, нарівні з lint. Застарілість детермінується за **CRC** джерела у frontmatter доки. PostToolUse hook (`doc-files check --hook`) **сигналить** про дрейф після правки; Stop-hook (`doc-files check --git`) **блокує завершення** задачі за наявності застарілих док (виняток — масовий прогін понад поріг `N_CURSOR_DOC_FILES_GATE_MAX`, дефолт 50). Регенерація — `/doc-files` (JS-оркестрована, не диспатч субагентів). Агрегуюча дока (module-summary, доменні) — окремий скіл `/doc-aggregate`, за запитом.',
662
+ 'Після зміни чи додавання кодового файлу його файлова дока (`<dir>/docs/<stem>.md`) має бути **актуальною** — це **обовʼязковий крок кожної задачі**, нарівні з lint. Застарілість детермінується за **CRC** джерела у frontmatter доки. PostToolUse hook (`lint-doc-files --hook`) **сигналить** про дрейф після правки; Stop-hook (`lint-doc-files --git`) **блокує завершення** задачі за наявності застарілих док (виняток — масовий прогін понад поріг `N_CURSOR_DOC_FILES_GATE_MAX`, дефолт 50). Регенерація — `/doc-files` (JS-оркестрована, не диспатч субагентів). Агрегуюча дока (module-summary, доменні) — окремий скіл `/doc-aggregate`, за запитом.',
665
663
  ''
666
664
  ]
667
665
  }
@@ -1507,7 +1505,7 @@ try {
1507
1505
  // .n-cursor.json + bun install, а fix/lint/coverage/change/release переписують файли в CWD —
1508
1506
  // усе це ключиться на cwd(). Запуск із піддиректорії git-репо (типово прямий
1509
1507
  // `bun npm/bin/n-cursor.js` не з кореня) зачепив би не той каталог → STOP. Read-only та
1510
- // `--root`-команди (trace, graph, lint-doc-files, fix-doc-files, doc-files, doc-aggregate, rename-yaml-extensions) не зачіпаємо.
1508
+ // `--root`-команди (trace, graph, lint-doc-files, fix-doc-files, doc-aggregate, rename-yaml-extensions) не зачіпаємо.
1511
1509
  if (ROOT_GUARDED_COMMANDS.has(command)) {
1512
1510
  assertCwdIsProjectRoot(cwd(), describeRootGuardedAction(command))
1513
1511
  }
@@ -1542,12 +1540,6 @@ try {
1542
1540
 
1543
1541
  break
1544
1542
  }
1545
- case 'lint-ci': {
1546
- // CI = весь репо в read-only (нуль мутацій, нуль LLM) — еквівалент `lint --read-only --full`.
1547
- process.exitCode = await runLint({ full: true, readOnly: true })
1548
-
1549
- break
1550
- }
1551
1543
  case 'lint-ga': {
1552
1544
  // Канонічний lint-ga з preflight на shellcheck → actionlint → zizmor → check-ga (ga.mdc).
1553
1545
  // Останній крок (check-ga) async — тому await обов'язковий, інакше process.exitCode буде Promise.
@@ -1669,38 +1661,12 @@ try {
1669
1661
 
1670
1662
  break
1671
1663
  }
1672
- case 'doc-files': {
1673
- // Делегувальний аліас (deprecated): doc-files scan|check|gen|stamp →
1674
- // lint-doc-files / fix-doc-files. Зняття наступний major (спека 2026-06-12).
1675
- // Зміна exit: plain `check` 2→1 (через lint-doc-files); --hook/--git зберігають 2.
1676
- console.error('⚠ `doc-files <sub>` застаріло використовуй `lint-doc-files` / `fix-doc-files`.')
1677
- const rest = args.slice(1)
1678
- switch (args[0]) {
1679
- case 'scan': {
1680
- const { runLintDocFilesCli } = await import('../rules/doc-files/lint/lint.mjs')
1681
- process.exitCode = await runLintDocFilesCli(['--json', ...rest])
1682
- break
1683
- }
1684
- case 'check': {
1685
- const { runLintDocFilesCli } = await import('../rules/doc-files/lint/lint.mjs')
1686
- process.exitCode = await runLintDocFilesCli(rest)
1687
- break
1688
- }
1689
- case 'gen': {
1690
- const { runDocFilesGenCli } = await import('../rules/doc-files/js/docgen-files-batch.mjs')
1691
- process.exitCode = await runDocFilesGenCli(rest)
1692
- break
1693
- }
1694
- case 'stamp': {
1695
- const { runDocFilesStampCli } = await import('../rules/doc-files/js/docgen-files-batch.mjs')
1696
- process.exitCode = runDocFilesStampCli(rest)
1697
- break
1698
- }
1699
- default: {
1700
- console.error('Usage: npx @nitra/cursor lint-doc-files | fix-doc-files [--root <dir>]')
1701
- process.exitCode = 1
1702
- }
1703
- }
1664
+ case 'adr-normalize-local': {
1665
+ // Local-backend ADR-нормалізації: викликається з .claude/hooks/normalize-decisions.sh
1666
+ // як заміна single-shot LLM-виклику. Проганяє конвеєр (retrieval→edge-judge→
1667
+ // cluster→gen) на малій локальній моделі й друкує `{operations}` JSON у stdout.
1668
+ const { runAdrNormalizeLocalCli } = await import('../scripts/lib/adr/normalize-cli.mjs')
1669
+ process.exitCode = await runAdrNormalizeLocalCli(args)
1704
1670
 
1705
1671
  break
1706
1672
  }
@@ -1727,7 +1693,7 @@ try {
1727
1693
  default: {
1728
1694
  console.error(`❌ Невідома команда: ${command}`)
1729
1695
  console.error(
1730
- ` Очікується: (без аргументів) синхронізація правил, rename-yaml-extensions, post-tool-use-fix, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, lint-doc-files, fix-doc-files, coverage, coverage-fix, taze, start-check, change, release, skill, worktree, lint-ci, trace, doc-files, doc-aggregate`
1696
+ ` Очікується: (без аргументів) синхронізація правил, rename-yaml-extensions, post-tool-use-fix, adr-normalize-local, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, lint-doc-files, fix-doc-files, coverage, coverage-fix, taze, start-check, change, release, skill, worktree, trace, doc-aggregate`
1731
1697
  )
1732
1698
  process.exitCode = 1
1733
1699
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "10.1.0",
3
+ "version": "10.3.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -42,8 +42,17 @@ const IMPORT_AS_RE = /[ \t]{1,8}as[ \t]{1,8}.{0,200}/
42
42
  const WRITE_FS_RE = /\b(writeFile|mkdir|rmdir|unlink|appendFile|createWriteStream|rm\()/
43
43
  const CATCH_RE = /catch\s*\(/
44
44
  const TRY_RE = /\btry\s*\{/
45
- const FALSY_RETURN_RE = /return\s+(false|null|''|"")/
46
- const NETWORK_RE = /\bfetch\(|https?\.|axios|got\(/
45
+ // Falsy-return як «fail-safe» — лише коли воно в catch/error-гілці (інакше це
46
+ // звичайний guard `if (!x) return null`, не обробка помилки). Уникає over-claim.
47
+ const FALSY_RETURN_RE = /catch[\s\S]{0,400}?return\s+(false|null|''|"")/
48
+ // Мережа: окрім явного fetch/http, ловимо абстраговані клієнти (graphql/db/rpc/
49
+ // octokit/.request/.query). Хибний false-negative тут = небезпечна гарантія
50
+ // «без мережі», тож свідомо схиляємось до over-detection (м'якший бік помилки).
51
+ const NETWORK_RE = /\bfetch\(|https?:\/\/|\bhttps?\.|axios|\bgot\(|graphql|\.request\(|\.query\(|\.mutate\(|octokit|node-fetch|undici|\bgrpc\b|websocket/i
52
+ // Будь-який `throw` назовні → НЕ можна гарантувати «fail-safe / без винятків».
53
+ const THROW_RE = /\bthrow\s/
54
+ // Запис у БД / зовнішню мутацію → НЕ read-only (навіть якщо нема ФС-запису).
55
+ const MUTATION_RE = /\b(insert|update|delete|upsert|drop|destroy|save)[A-Za-z]*\s*[(,]|[Mm]utation\b|\bmut[A-Z]\w*|\.(save|create|update|delete|insert|destroy|mutate)\(/
47
56
  // Кеш — лише за ІМЕНОВАНИМ маркером (`cache`/`Cache`/`memoize`), не за будь-яким
48
57
  // `new Map()`: акумулятор (напр. `byPath = new Map()`) — не кеш, а хибна гарантія
49
58
  // «Кешує результати» гірша за пропуск (фабрикація > мовчання).
@@ -202,9 +211,11 @@ function extractMarkers(src) {
202
211
  if (src.includes(`'${lit}`) || src.includes(`"${lit}`) || src.includes(`/${lit}`)) skips.add(lit)
203
212
  }
204
213
  return {
205
- readOnly: !WRITE_FS_RE.test(src),
206
- catchesErrors: CATCH_RE.test(src) || TRY_RE.test(src),
207
- returnsFalsyOnFail: FALSY_RETURN_RE.test(src),
214
+ // «Фабрикація > мовчання»: прапорець true лише за high-confidence; інакше
215
+ // guaranteesFromMarkers/factsSummary його ОПУСКАЮТЬ (не стверджують протилежне).
216
+ readOnly: !WRITE_FS_RE.test(src) && !MUTATION_RE.test(src), // ні ФС-запису, ні DB-мутацій
217
+ catchesErrors: (CATCH_RE.test(src) || TRY_RE.test(src)) && !THROW_RE.test(src), // fail-safe лише якщо НЕ кидає
218
+ returnsFalsyOnFail: FALSY_RETURN_RE.test(src) && !THROW_RE.test(src),
208
219
  network: NETWORK_RE.test(src),
209
220
  caches: CACHE_RE.test(src),
210
221
  skips: [...skips]
@@ -30,12 +30,13 @@ function factsSummary(facts) {
30
30
  if (facts.header) lines.push(`Намір файлу: ${facts.header.replaceAll('\n', ' ')}`)
31
31
  if (facts.exports?.length) lines.push(`Публічні функції: ${facts.exports.map(e => e.name).join(', ')}`)
32
32
  if (m.skips?.length) lines.push(`Свідомо пропускає шляхи: ${m.skips.join(', ')}`)
33
- lines.push(`Read-only: ${m.readOnly ? 'так' : 'ні'}`)
33
+ // «Фабрикація > мовчання»: лише ПОЗИТИВНІ high-confidence сигнали; жодних дефолтних
34
+ // негативів (read-only «ні», «мережа: немає») — модель echo-їть їх як хибну гарантію.
35
+ if (m.readOnly) lines.push('Read-only: не пише (ФС/БД)')
36
+ if (m.network) lines.push('Звертається до мережі')
34
37
  if (m.catchesErrors) lines.push('Перехоплює помилки (fail-safe), не кидає винятків назовні')
35
- if (m.returnsFalsyOnFail) lines.push('За невдачі повертає значення помилки (false/null/Err) замість винятку чи паніки')
38
+ if (m.returnsFalsyOnFail) lines.push('За певних помилок повертає порожнє значення (напр. null) замість винятку')
36
39
  lines.push(m.caches ? 'Кешування: так, у межах прогону' : 'Кешування: НЕМАЄ — не згадуй кеш у гарантіях')
37
- if (m.network) lines.push('Звертається до мережі')
38
- else lines.push('Робота з мережею: немає')
39
40
  return lines.join('\n')
40
41
  }
41
42
 
@@ -193,15 +194,16 @@ export function refineMessages(sectionKey, draft, issues, facts, anchors) {
193
194
  export function guaranteesFromMarkers(facts) {
194
195
  const m = facts.markers || {}
195
196
  const lines = []
196
- if (m.readOnly) lines.push('- Read-only: файл не виконує операцій запису у файлову систему.')
197
+ // «Фабрикація > мовчання»: лише ПОЗИТИВНІ high-confidence гарантії. Жодних
198
+ // негативів/дефолтів (no-network, determinism) — їх не довести file-local аналізом.
199
+ if (m.readOnly) lines.push('- Read-only: не виконує операцій запису (ФС/БД).')
197
200
  if (m.catchesErrors) lines.push('- Перехоплює помилки і не пропускає винятків назовні (fail-safe).')
198
- if (m.returnsFalsyOnFail) lines.push('- За невдачі повертає значення помилки (`false`/`null`/`Err`) замість генерування винятку чи паніки.')
201
+ if (m.returnsFalsyOnFail) lines.push('- За певних помилок повертає порожнє значення (напр. `null`) замість винятку.')
199
202
  if (m.caches) lines.push('- Кешує результати в межах одного прогону.')
200
203
  if (m.skips?.length) {
201
204
  lines.push(`- Свідомо пропускає шляхи: ${m.skips.map(s => '`' + s + '`').join(', ')}.`)
202
205
  }
203
- if (!m.network) lines.push('- Не звертається до мережі.')
204
- if (!lines.length) return '- Поведінка детермінована: результат залежить лише від вхідних даних.'
206
+ if (!lines.length) return '- (специфічних машинно-виведених гарантій немає)'
205
207
  return lines.join('\n')
206
208
  }
207
209
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Крос-файловий ci-етап js-lint — jscpd (детектор клонів) і knip (невикористані експорти). Лише у lint-ci, по всьому репо.
2
+ description: Крос-файловий ci-етап js-lint — jscpd (детектор клонів) і knip (невикористані експорти). Лише у `lint --full`, по всьому репо.
3
3
  globs: "**/{.oxlintrc.json,eslint.config.js,.jscpd.json,knip.json,package.json},**/*.{js,mjs,cjs,jsx,ts,tsx}"
4
4
  alwaysApply: false
5
5
  version: '1.0'
@@ -8,7 +8,7 @@ version: '1.0'
8
8
  # js-lint-ci — крос-файловий ci-етап
9
9
 
10
10
  `jscpd` і `knip` аналізують увесь граф проєкту, тож мають сенс лише у повному прогоні
11
- `npx @nitra/cursor lint-ci` (не у швидкому `lint` по змінених файлах). Per-file режиму нема.
11
+ `npx @nitra/cursor lint --full` (CI: `lint --read-only --full`) не у швидкому `lint` по змінених файлах. Per-file режиму нема.
12
12
 
13
13
  Швидкий етап js-lint (oxlint/eslint) — у правилі `js-lint` (`lint: per-file`).
14
14
 
@@ -2,7 +2,7 @@
2
2
  description: Tauri
3
3
  globs: "**/src-tauri/**,**/tauri.conf.json"
4
4
  alwaysApply: false
5
- version: '1.4'
5
+ version: '1.5'
6
6
  ---
7
7
 
8
8
  У `.vscode/extensions.json` `recommendations` має містити `tauri-apps.tauri-vscode`:
@@ -66,3 +66,25 @@ exclude_globs = [
66
66
  - якщо обидва канонічні ключі (`additional_cargo_test_args`, `exclude_globs`) вже присутні — `manual cargo-mutants config preserved`, нічого не зміниться;
67
67
  - якщо якийсь канонічний ключ відсутній — додається окремим блоком у кінці файла, без зміни існуючих значень.
68
68
  - Послідовний `fix test` → `fix tauri` створює Tauri-config; повторний `fix tauri` не дублює секцій; повторний `fix test` не перетирає Tauri-tuning.
69
+
70
+ ## Tool Surface у Tauri (реалізація ядра `tool-surface`)
71
+
72
+ Це per-stack реалізація правила **`tool-surface`** (`n-tool-surface`) для Tauri+Rust. Контракт (каталог → `dispatch` → UI/оркестратор/LLM, інваріант паритету, конверт) — у тому правилі; тут — як він лягає на Tauri.
73
+
74
+ **Реальна робота живе в Rust, JS-каталог — тонкий call surface.** Handler тула не містить логіки сам — він **делегує** в native. Одна реалізація в Rust-крейті backs два споживачі:
75
+
76
+ - **Бінарник** (`src-tauri` як CLI, або окремий crate-bin) — headless-вхід для оркестратора;
77
+ - **Tauri-команда** (`#[tauri::command]`) — той самий крейт-fn, обгорнутий для UI.
78
+
79
+ **Два транспорти одного каталогу** (`src/tool/transports`):
80
+
81
+ - **UI (in-app):** `invoke(tool.tauri, input)` → Tauri-команда → крейт. Ключі `input` мапляться 1:1 на аргументи команди (camelCase, напр. `tasksDir`); поля вкладених struct лишаються snake_case (Tauri конвертить лише імена top-level аргументів).
82
+ - **Оркестратор (headless):** `bin/<app>.mjs` спавнить зібраний бінарник (`<bin> <verb> …` per-verb, або уніфікований `<bin> exec '<json>'`), парсить JSON stdout.
83
+
84
+ **Конверт:** Tauri-команда повертає `Result<T, String>`; адаптер мапить у `{ ok, output }` / `{ ok:false, error }`. Бінарник друкує конверт у stdout, exit ≠ 0 на `ok:false`.
85
+
86
+ **Єдине джерело схем:** надавай перевагу `schemars`-derive на Rust-param-структурах → бінарник віддає маніфест (`<bin> schema`); або тримай схему в JS-каталозі й валідуй до `invoke`. **Не дублюй** контракт між Rust і JS — лише деривація + спільні тест-вектори.
87
+
88
+ **Дозволи:** будь-який плагін, який смикають тули (`tauri-plugin-dialog`, `tauri-plugin-http` для локальної LLM тощо), має бути в `src-tauri/capabilities/*.json` і зареєстрований у `lib.rs` — інакше виклик тихо падає.
89
+
90
+ **LLM-раннер in-app:** chat-loop ходить до OpenAI-сумісного ендпоінта через `tauri-plugin-http` fetch (бо webview-fetch обмежений CSP/capability), а тули виконує через спільний `dispatch` (той самий, що й UI).
@@ -0,0 +1,18 @@
1
+ import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
2
+ import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
3
+
4
+ /**
5
+ * Запускає правило: applies → JS-concerns → policy → mdc-refs (через runStandardRule).
6
+ * Library mode: викликається CLI orchestration через `import + run(ctx)`.
7
+ * @param {import('../../scripts/lib/run-standard-rule.mjs').RuleContext} [ctx] контекст прогону (walkCache тощо)
8
+ * @returns {Promise<number>} 0 — OK, 1 — порушення
9
+ */
10
+ export function run(ctx) {
11
+ return runStandardRule(import.meta.dirname, ctx)
12
+ }
13
+
14
+ if (isRunAsCli(import.meta.url)) {
15
+ // Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
16
+ // (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
17
+ process.exitCode = await runRuleCli(import.meta.dirname)
18
+ }
@@ -0,0 +1 @@
1
+ { "auto": { "predicate": "depInAnyPackageJson", "arg": ["vue", "react", "svelte", "@angular/core", "preact", "solid-js", "@tauri-apps/api", "@capacitor/core"] } }
@@ -0,0 +1,72 @@
1
+ ---
2
+ description: Tool Surface — будь-яка дія фронтенду має бути виконуваною без UI (CLI + LLM) через спільний каталог тулів; UI/оркестратор/LLM — рівноправні адаптери
3
+ alwaysApply: true
4
+ version: '1.0'
5
+ ---
6
+
7
+ # Tool Surface — паритет «UI ↔ LLM ↔ оркестратор»
8
+
9
+ ## Принцип
10
+
11
+ **Будь-яка дія, яку людина виконує через фронтенд, мусить бути виконуваною як `tool` — без UI — бо вона організована як іменований виклик зі схемою, до якого однаково дотягуються UI, скриптовий оркестратор і LLM.**
12
+
13
+ Ключовий новий споживач — **LLM**, тож одиниця називається `tool`. Фронтенд — лише один з адаптерів, а не єдині двері.
14
+
15
+ ## Це НЕ про «винести логіку на бекенд»
16
+
17
+ Лінія поділу не «фронт ↔ бек», а:
18
+
19
+ > виклик, досяжний **лише через UI-взаємодію** → погано
20
+ > виклик, досяжний як **іменований tool зі схемою** → добре
21
+
22
+ JS-логіка може лишатися на фронті — її лише треба **витягнути** з обробника події в окремий tool (ім'я + параметри + схема), який однаково кличе UI, оркестратор і LLM. Tool Surface — це **call surface**, не обов'язково бекенд.
23
+
24
+ ## Три шари
25
+
26
+ 1. **Tool Catalog** (`src/tool/`) — декларативний каталог. Кожен tool: `name`, `summary`, схема входу/виходу, `handler`. Handler **може бути фронтендовою JS-функцією** (або делегувати в native/HTTP/DB). Це **єдине джерело правди**; з нього генеруються schema-маніфест (для LLM) і типи клієнта.
27
+ 2. **Dispatch** — одна функція `dispatch(name, input) → { ok, output } | { ok: false, error }`: валідує вхід за схемою, кличе handler, повертає **уніфікований конверт**. Не прив'язана до UI-рендеру/подій.
28
+ 3. **Споживачі** (усі обов'язкові):
29
+ - **UI** — компонент кличе `dispatch`, без inline-логіки дії.
30
+ - **Оркестратор** — машинний вхід `<bin> <tool> '<json>'` (+ `schema`/`list`). Кличуть скрипти, не люди — людський CLI з verb-ами не потрібен.
31
+ - **LLM** — tool-маніфест із каталогу; tool_call маршрутизується в `dispatch`.
32
+
33
+ ## Інваріант паритету (серце правила)
34
+
35
+ - **Жодної дії, досяжної лише через UI-взаємодію.** Обробник події **делегує** в `dispatch`/каталог, а не містить inline-логіку (мережа, мутація моделі, виклик бекенду).
36
+ - Дозволено: логіка у фронтенд-коді. Заборонено: логіка **зашита** в обробник кліку/сабміту так, що дотягтись можна лише кліком.
37
+ - Кожен tool каталогу має **усіх** споживачів (UI + оркестратор + LLM), інакше це не headless.
38
+
39
+ ## Єдине джерело схем
40
+
41
+ Схема входу tool-а живе в каталозі (zod / JSON Schema; для Rust — `schemars`). З неї генеруються: tool-маніфест для LLM (формат залежить від провайдера — OpenAI function-calling, Anthropic tools або MCP), CLI-довідка, типи клієнта. Тул-визначення **не повинні розходитися** з каталогом — лише деривація, не дублювання.
42
+
43
+ ## Уніфікований конверт
44
+
45
+ ```jsonc
46
+ { "ok": true, "output": { /* результат */ } }
47
+ { "ok": false, "error": { "code": "validation|not_found|io|…", "message": "…" } }
48
+ ```
49
+
50
+ Оркестратор: `ok:false` → ненульовий exit. UI/LLM: `Result` / tool_result з `is_error`.
51
+
52
+ ## Дворівнева структура (архітектура спільна, реалізація per-stack)
53
+
54
+ Архітектура **спільна**, реалізація **навмисно розходиться** за стеком. Це правило тримає платформо-незалежне **ядро**; конкретику делегують профільні правила:
55
+
56
+ | Рівень | Що тут | Де |
57
+ |---|---|---|
58
+ | **Ядро** | принцип, інваріант паритету, контракт каталог/`dispatch`/схема/конверт, 3 споживачі | це правило |
59
+ | **Tauri+Rust** | handler делегує в Rust-крейт/бінарник; машинний bin; `invoke`/spawn | `n-tauri` |
60
+ | **Capacitor / pure-web** | handler — JS напряму; bin = node/bun-скрипт, що імпортує handlers | `n-capacitor` |
61
+ | **UI-адаптер** | компонент делегує в `dispatch`, нуль inline-логіки | `n-vue` |
62
+
63
+ ## Конвенція файлів
64
+
65
+ ```
66
+ src/tool/
67
+ catalog.(js|ts) ← каталог тулів (single source): name, summary, input schema, mapping
68
+ dispatch.(js|ts) ← dispatch(name, input) + валідація + конверт
69
+ manifest.(js|ts) ← каталог → LLM tools ; CLI help
70
+ transports.(js|ts) ← UI-транспорт (invoke/fetch) ; CLI-транспорт (spawn/import)
71
+ bin/<app>.mjs ← машинний вхід: <tool> '<json>' | schema | list
72
+ ```
@@ -8,8 +8,8 @@
8
8
  "properties": {
9
9
  "lint": {
10
10
  "type": "string",
11
- "enum": ["quick", "ci"],
12
- "description": "Фаза lint-кроку: quick (по змінених, у lint і lint-ci) або ci (лише lint-ci)."
11
+ "enum": ["per-file", "full"],
12
+ "description": "Scope lint-кроку: per-file (декомпозиція по змінених файлах, дельта vs origin) або full (нероздільно крос-файловий, лише `lint --full`)."
13
13
  },
14
14
  "auto": {
15
15
  "description": "Умова автоактивації правила: \"завжди\", масив id правил-залежностей, glob, або іменований предикат.",
@@ -0,0 +1,36 @@
1
+ ---
2
+ docgen:
3
+ source: normalize-cli.mjs
4
+ crc: ce2f13af
5
+ model: omlx/gemma-4-e4b-it-OptiQ-4bit
6
+ score: 90
7
+ ---
8
+
9
+ # normalize-cli.mjs
10
+
11
+ ## Огляд
12
+
13
+ Цей файл є CLI-обгорткою для локального ADR-нормалізатора (`n-cursor adr-normalize-local`). Він використовується скриптом `.claude/hooks/normalize-decisions.sh` як локальний бекенд для обробки батчу чернеток та списку чистих ADR. Обгортка зчитує шляхи до чернеток та параметри з аргументів командного рядка та змінних середовища, а потім проганяє `normalizePipeline`. Результатом роботи є вивід JSON-контракту з операціями у stdout, який парситься зовнішнім скриптом. Прогрес відображається у stderr.
14
+
15
+ ## Поведінка
16
+
17
+ 1. `runAdrNormalizeLocalCli` парсить вхідні аргументи для визначення шляху до чернеток батчу, списку чистих ADR та каталогу ADR.
18
+ 2. Якщо не надано файл батчу, `runAdrNormalizeLocalCli` виводить повідомлення про використання та завершує роботу з кодом помилки.
19
+ 3. `runAdrNormalizeLocalCli` зчитує шляхи до чернеток батчу з вказаного файлу.
20
+ 4. Для кожної чернетки `runAdrNormalizeLocalCli` зчитує вміст файлу, резолвячи шлях відносно каталогу ADR, і створює об'єкт чернетки.
21
+ 5. `runAdrNormalizeLocalCli` зчитує список назв чистих ADR з файлу, якщо він наданий.
22
+ 6. `runAdrNormalizeLocalCli` зчитує значення змінних середовища `ADR_NORMALIZE_ALLOW_CLOUD` та `ADR_NORMALIZE_VOTES` для визначення параметрів нормалізації.
23
+ 7. `runAdrNormalizeLocalCli` викликає логіку нормалізації, передаючи чернетки, список чистих ADR та параметри, при цьому прогрес виводиться у stderr.
24
+ 8. `runAdrNormalizeLocalCli` виводить кількість операцій та статистику нормалізації у stderr.
25
+ 9. `runAdrNormalizeLocalCli` виводить деталі рішень нормалізації у stderr.
26
+ 10. `runAdrNormalizeLocalCli` друкує JSON-об'єкт, що містить операції, у stdout.
27
+ 11. `runAdrNormalizeLocalCli` завершує роботу з кодом успіху.
28
+
29
+ ## Публічний API
30
+
31
+ runAdrNormalizeLocalCli — запускає субкоманду, виводячи JSON операцій у стандартний вивід та прогрес у стандартний помилковий вивід.
32
+
33
+ ## Гарантії поведінки
34
+
35
+ - Read-only: файл не виконує операцій запису у файлову систему.
36
+ - Не звертається до мережі.
@@ -0,0 +1,40 @@
1
+ ---
2
+ docgen:
3
+ source: normalize-pipeline.mjs
4
+ crc: 6619ff48
5
+ model: omlx/gemma-4-e4b-it-OptiQ-4bit
6
+ score: 100
7
+ ---
8
+
9
+ # normalize-pipeline.mjs
10
+
11
+ ## Огляд
12
+
13
+ Файл реалізує локально-орієнтований конвеєр для нормалізації чернеток ADR. Він використовує LLM лише для вузьких, верифікованих бінарних суджень. Конвеєр працює у послідовних стадіях: JS виконує пошук кандидатів-ребер на основі лексичної схожості, LLM оцінює ці ребра (Stage 1: `same/different`) та драфти (Stage 1b: `standalone/trivial`), JS кластеризує підтверджені ребра (використовуючи `union-find`), LLM реформатує анотера (Stage 2: `gen-MADR`), а LLM генерує доповнення для злиття (Stage 3: `gen-merge`). Глобальний стан (кластери, слаги, покриття) зберігається в JS. Конвеєр повертає операції у форматі `operations[]`, сумісного з контрактом `apply-ops`.
14
+
15
+ ## Поведінка
16
+
17
+ tokenize токенізує назву або слаг у множину значущих токенів, виключаючи стоп-слова.
18
+ jaccard обчислює Jaccard-схожість між двома множинами токенів.
19
+ draftTitle витягує заголовок чернетки, надаючи пріоритет заголовку ADR.
20
+ isNoDecision визначає, чи не прийнято рішення у чернетці, аналізуючи секцію Decision Outcome.
21
+ buildEdges будує кандидати-ребра між чернетками та між чернетками і чистими ADR на основі лексичної схожості.
22
+ validateMadr перевіряє згенерований контент на відповідність канону чистого MADR.
23
+ normalizePipeline виконує повний конвеєр нормалізації, групуючи чернетки, оцінюючи їх та генеруючи операції для застосування.
24
+
25
+ ## Публічний API
26
+
27
+ tokenize — розбиває назву чи слаг на значущі частини, замінюючи пробіли та дефіси, і відсіюючи загальні слова.
28
+ jaccard — обчислює ступінь подібності між двома наборами слів.
29
+ draftTitle — витягує заголовок з чернетки, надаючи пріоритет заголовку у форматі `## ADR <title>`, а у відсутності такого — використовує перший не-ADR заголовок або ім'я файлу.
30
+ isNoDecision — визначає, чи не містить чернетка чіткого рішення, що робить її непотрібною для окремого ADR.
31
+ buildEdges — створює потенційні зв'язки між елементами на основі схожості слів.
32
+ validateMadr — перевіряє якість згенерованого документа ADR.
33
+ normalizePipeline — виконує повний процес обробки, повертаючи список виконаних кроків та статистику.
34
+
35
+ ## Гарантії поведінки
36
+
37
+ - Read-only: файл не виконує операцій запису у файлову систему.
38
+ - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
39
+ - За невдачі повертає значення помилки (`false`/`null`/`Err`) замість генерування винятку чи паніки.
40
+ - Не звертається до мережі.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * CLI-обгортка локального ADR-нормалізатора (`n-cursor adr-normalize-local`).
3
+ *
4
+ * Прод-шлях `.claude/hooks/normalize-decisions.sh` викликає цю команду як
5
+ * local-backend замість single-shot LLM-виклику: bash готує батч і clean-список,
6
+ * CLI проганяє `normalizePipeline` і друкує у stdout той самий контракт
7
+ * `{ "operations": [...] }`, що його далі парсить і застосовує bash. Прогрес —
8
+ * у stderr (потрапляє в normalize-decisions.log).
9
+ *
10
+ * Аргументи:
11
+ * --batch <file> newline-список абсолютних шляхів до чернеток батчу
12
+ * --clean <file> newline-список basename-ів clean-ADR (merge-into кандидати)
13
+ * --adr-dir <dir> тека docs/adr (для резолву basename ↔ шлях; default cwd/docs/adr)
14
+ *
15
+ * ENV:
16
+ * ADR_NORMALIZE_ALLOW_CLOUD=1 дозволити хмарну ескалацію tier-каскаду (default off)
17
+ * ADR_NORMALIZE_VOTES=N голосів self-consistency для clean-ребер (default 2)
18
+ */
19
+ import { readFileSync } from 'node:fs'
20
+ import { basename, isAbsolute, join } from 'node:path'
21
+ import { normalizePipeline } from './normalize-pipeline.mjs'
22
+
23
+ /**
24
+ * Парсить `--key value` пари у плоский об'єкт.
25
+ * @param {string[]} argv масив аргументів командного рядка
26
+ * @returns {Record<string, string>} мапа ключ→значення з `--key value` пар
27
+ */
28
+ function parseArgs(argv) {
29
+ const out = {}
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i]
32
+ if (a.startsWith('--')) { out[a.slice(2)] = argv[i + 1]; i++ }
33
+ }
34
+ return out
35
+ }
36
+
37
+ const readLines = (file) =>
38
+ readFileSync(file, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean)
39
+
40
+ /**
41
+ * Точка входу субкоманди. Друкує `{operations}` JSON у stdout, прогрес — у stderr.
42
+ * @param {string[]} argv аргументи після назви команди
43
+ * @returns {number} exit-code (0 — успіх, 1 — помилка вводу)
44
+ */
45
+ export function runAdrNormalizeLocalCli(argv) {
46
+ const args = parseArgs(argv)
47
+ const adrDir = args['adr-dir'] ?? join(process.cwd(), 'docs/adr')
48
+ if (!args.batch) {
49
+ console.error('Usage: n-cursor adr-normalize-local --batch <file> [--clean <file>] [--adr-dir <dir>]')
50
+ return 1
51
+ }
52
+
53
+ const batchPaths = readLines(args.batch)
54
+ const drafts = batchPaths.map((p) => {
55
+ const abs = isAbsolute(p) ? p : join(adrDir, p)
56
+ return { file: basename(abs), body: readFileSync(abs, 'utf8') }
57
+ })
58
+ const cleanList = args.clean ? readLines(args.clean).map((c) => basename(c)) : []
59
+
60
+ const allowCloud = process.env.ADR_NORMALIZE_ALLOW_CLOUD === '1'
61
+ const votes = Number(process.env.ADR_NORMALIZE_VOTES) || 2
62
+
63
+ const { operations, stats, trace } = normalizePipeline(drafts, cleanList, {
64
+ allowCloud,
65
+ votes,
66
+ onProgress: (m) => console.error(`adr-normalize-local: ${m}`)
67
+ })
68
+
69
+ console.error(`adr-normalize-local: ${operations.length} операцій, stats=${JSON.stringify(stats)}`)
70
+ console.error(`adr-normalize-local: decisions=${JSON.stringify(trace.decisions)}`)
71
+ process.stdout.write(JSON.stringify({ operations }))
72
+ return 0
73
+ }
@@ -0,0 +1,530 @@
1
+ /**
2
+ * ADR normalize — локально-орієнтований конвеєр (інверсія керування: JS оркеструє,
3
+ * LLM відповідає лише на вузькі verifiable-питання). Альтернатива single-shot-у
4
+ * normalize-decisions.sh, заточена під малу локальну модель (omlx/gemma-4b).
5
+ *
6
+ * Принцип: модель НІКОЛИ не приймає глобальних рішень і не повертає великих
7
+ * структур. Глобальний стан (кластери, слаги, покриття) тримає JS. Модель:
8
+ * - судить пару записів бінарно «те саме рішення? так/ні» (Stage 1),
9
+ * - для ізольованого драфта каже standalone/trivial (Stage 1b),
10
+ * - реформатить один драфт у чистий MADR (Stage 2),
11
+ * - пише short merge-additions (Stage 3).
12
+ *
13
+ * Стадії:
14
+ * 0. retrieval (JS) — лексична схожість → кандидати-ребра draft↔draft / draft↔clean
15
+ * 1. edge-judge (LLM) — бінарне same/different по кожному ребру (self-consistency)
16
+ * 1b. kind-judge(LLM) — standalone vs trivial для драфтів без ребер
17
+ * ── cluster (JS) — union-find по підтверджених ребрах, вибір anchor, призначення op
18
+ * 2. gen-MADR (LLM) — reformat anchor/standalone → чистий MADR + validation gate
19
+ * 3. gen-merge (LLM) — additions для merge-драфтів
20
+ * ── assemble (JS) — operations[] у форматі, сумісному з apply-ops
21
+ *
22
+ * Повертає той самий operations[]-контракт, що й single-shot — apply-логіка спільна.
23
+ */
24
+ import { z } from 'zod'
25
+ import { callLlm, classifyOmlxError } from '../../../lib/llm.mjs'
26
+ import { CLOUD_MIN, resolveModel } from '../../../lib/models.mjs'
27
+
28
+ // ─────────────────────────── Stage 0: retrieval (JS) ───────────────────────────
29
+
30
+ const STOP = new Set(['adr', 'та', 'для', 'через', 'на', 'в', 'у', 'з', 'із', 'до', 'і', 'й', 'the', 'a', 'of', 'md'])
31
+
32
+ // Module-scope regex (oxlint prefer-static-regex: без рекомпіляції на кожен виклик).
33
+ const RE_MD_EXT = /\.md$/
34
+ const RE_TS_PREFIX = /^\d{6,8}-\d{4,6}-/
35
+ const RE_FENCE_OPEN = /^\s*```[a-z]*\s*\n?/i
36
+ const RE_FENCE_CLOSE = /\n?```\s*$/i
37
+ const RE_SLUG_NONWORD = /[^a-zа-яіїєґ0-9]+/gi
38
+ const RE_LEAD_HYPHEN = /^-+/
39
+ const RE_TRAIL_HYPHEN = /-+$/
40
+ const RE_UPDATE_HEAD = /^##\s+Update/
41
+ const RE_DECISION_SECTION = /##\s*Decision Outcome\s*([\s\S]{0,500})/i
42
+ const RE_NO_DECISION = /(не\s+обрано|не\s+прийнят|рішення\s+не\s+прийн|не\s+зроблен|no\s+decision|undecided)/i
43
+ const RE_FENCE_LEAD = /^\s*```/
44
+ const RE_FENCE_TRAIL = /```\s*$/
45
+ const RE_FRONTMATTER = /^---\s*$/m
46
+ const RE_SESSION = /\bsession:\s/
47
+ const RE_H1 = /^#\s+\S/m
48
+ const RE_STATUS = /\*\*Status:\*\*/
49
+ const RE_DATE = /\*\*Date:\*\*\s*\d{4}-\d{2}-\d{2}/
50
+ const RE_TOKEN_SPLIT = /[^a-zа-яіїєґ0-9]+/i
51
+ const RE_DRAFT_ADR_TITLE = /^#{1,2}\s+ADR\s+(.+)$/m
52
+
53
+ /**
54
+ * Прибирає code-fence-обгортку з LLM-відповіді.
55
+ * @param {string} raw сира відповідь LLM
56
+ * @returns {string} текст без обгортки code-fence
57
+ */
58
+ const stripFence = (raw) => raw.replace(RE_FENCE_OPEN, '').replace(RE_FENCE_CLOSE, '').trim()
59
+ /**
60
+ * Назва clean-ADR → людський заголовок (без .md і timestamp-префікса).
61
+ * @param {string} s basename clean-ADR
62
+ * @returns {string} людський заголовок
63
+ */
64
+ const stripAdrName = (s) => s.replace(RE_MD_EXT, '').replace(RE_TS_PREFIX, '')
65
+
66
+ /**
67
+ * Токенізує назву/слаг у множину значущих токенів (kebab + пробіли, без стоп-слів).
68
+ * @param {string} s назва або слаг для токенізації
69
+ * @returns {Set<string>} множина значущих токенів
70
+ */
71
+ export function tokenize(s) {
72
+ return new Set(
73
+ s
74
+ .toLowerCase()
75
+ .replace(RE_MD_EXT, '')
76
+ .replace(RE_TS_PREFIX, '')
77
+ .split(RE_TOKEN_SPLIT)
78
+ .filter((t) => t.length > 2 && !STOP.has(t))
79
+ )
80
+ }
81
+
82
+ /**
83
+ * Jaccard-схожість двох множин токенів.
84
+ * @param {Set<string>} a перша множина токенів
85
+ * @param {Set<string>} b друга множина токенів
86
+ * @returns {number} коефіцієнт Jaccard у діапазоні 0..1
87
+ */
88
+ export function jaccard(a, b) {
89
+ if (!a.size || !b.size) return 0
90
+ let inter = 0
91
+ for (const t of a) if (b.has(t)) inter++
92
+ return inter / (a.size + b.size - inter)
93
+ }
94
+
95
+ const MADR_SECTION = /^(Context and Problem|Considered Options|Decision Outcome|Consequences|More Information|report|summary|Attempt|Reason|Update)\b/i
96
+
97
+ /**
98
+ * Витягує заголовок драфта. Капчер пише `## ADR <title>` — він у пріоритеті
99
+ * (чернетка може мати контент-заголовки раніше або взагалі не мати ADR-рядка).
100
+ * Fallback-и: перший h1, що не є MADR-секцією, інакше '' (caller бере імʼя файлу).
101
+ * @param {string} body тіло чернетки
102
+ * @returns {string} заголовок або ''
103
+ */
104
+ export function draftTitle(body) {
105
+ const adr = body.match(RE_DRAFT_ADR_TITLE)
106
+ if (adr) return adr[1].trim()
107
+ for (const m of body.matchAll(/^#\s+(.+)$/gm)) {
108
+ if (!MADR_SECTION.test(m[1].trim())) return m[1].trim()
109
+ }
110
+ return ''
111
+ }
112
+
113
+ /**
114
+ * Детермінований no-decision гейт (харднінг #1). Чернетка, де у `Decision Outcome`
115
+ * рішення явно НЕ прийняте (transcript обірвався) — не варта окремого ADR: gold
116
+ * (sonnet) такі видаляє. Ловимо без LLM, щоб не покладатися на kind-judge малої моделі.
117
+ * @param {string} body тіло чернетки
118
+ * @returns {boolean} true якщо рішення не прийняте
119
+ */
120
+ export function isNoDecision(body) {
121
+ const m = body.match(RE_DECISION_SECTION)
122
+ if (!m) return false
123
+ // NB: JS \b не працює з кирилицею — покладаємось на пробіл/межі фрази без \b.
124
+ return RE_NO_DECISION.test(m[1])
125
+ }
126
+
127
+ /**
128
+ * Будує кандидати-ребра за лексичною схожістю.
129
+ * @param {{file:string, body:string}[]} drafts батч чернеток
130
+ * @param {string[]} cleanList clean basename-и
131
+ * @param {{simThreshold?:number, topKClean?:number}} [opts] поріг схожості та ліміт clean-кандидатів
132
+ * @returns {{dd:[number,number][], dc:[number,string][]}} ребра draft↔draft (dd) і draft↔clean (dc)
133
+ */
134
+ export function buildEdges(drafts, cleanList, opts = {}) {
135
+ const simThreshold = opts.simThreshold ?? 0.12
136
+ const topKClean = opts.topKClean ?? 3
137
+ const draftTok = drafts.map((d) => tokenize(`${d.file} ${draftTitle(d.body)}`))
138
+ const cleanTok = new Map(cleanList.map((c) => [c, tokenize(c)]))
139
+
140
+ const dd = []
141
+ for (let i = 0; i < drafts.length; i++) {
142
+ for (let j = i + 1; j < drafts.length; j++) {
143
+ if (jaccard(draftTok[i], draftTok[j]) >= simThreshold) dd.push([i, j])
144
+ }
145
+ }
146
+ const dc = []
147
+ for (let i = 0; i < drafts.length; i++) {
148
+ const scored = []
149
+ for (const [c, tok] of cleanTok) {
150
+ const s = jaccard(draftTok[i], tok)
151
+ if (s >= simThreshold) scored.push([c, s])
152
+ }
153
+ scored.sort((a, b) => b[1] - a[1])
154
+ for (const [c] of scored.slice(0, topKClean)) dc.push([i, c])
155
+ }
156
+ return { dd, dc }
157
+ }
158
+
159
+ // ─────────────────────────── LLM helper: tier cascade ──────────────────────────
160
+
161
+ const LOCAL = () => resolveModel('min')
162
+
163
+ /**
164
+ * Виклик LLM з локальним ретраєм і (опційно) хмарною ескалацією.
165
+ * @param {Array<{role:string,content:string}>} messages чат-повідомлення для LLM
166
+ * @param {(raw:string)=>any} parse валідатор (кидає на невалідному)
167
+ * @param {{label:string, allowCloud:boolean, attempts?:number, stats:object, maxTokens?:number}} cfg конфіг каскаду (мітка, дозвіл на хмару, спроби, лічильники, ліміт токенів)
168
+ * @returns {any} результат parse
169
+ * @throws {Error} якщо всі спроби провалені
170
+ */
171
+ function callWithCascade(messages, parse, cfg) {
172
+ const attempts = cfg.attempts ?? 2
173
+ const temps = [0.1, 0.4, 0.7]
174
+ let lastErr = null
175
+ for (let a = 0; a < attempts; a++) {
176
+ try {
177
+ cfg.stats.localCalls++
178
+ const raw = callLlm(messages, LOCAL(), {
179
+ timeoutMs: 120_000,
180
+ temperature: temps[a] ?? 0.2,
181
+ maxTokens: cfg.maxTokens ?? 4096,
182
+ caller: `adr-pipe:${cfg.label}`
183
+ })
184
+ return parse(raw)
185
+ } catch (error) {
186
+ lastErr = error
187
+ if (classifyOmlxError(error.message) === 'infra') break
188
+ }
189
+ }
190
+ if (cfg.allowCloud && CLOUD_MIN) {
191
+ try {
192
+ cfg.stats.cloudCalls++
193
+ cfg.stats.escalations++
194
+ const raw = callLlm(messages, CLOUD_MIN, { timeoutMs: 120_000, temperature: 0.2, maxTokens: cfg.maxTokens ?? 4096, caller: `adr-pipe:${cfg.label}:cloud` })
195
+ return parse(raw)
196
+ } catch (error) {
197
+ lastErr = error
198
+ }
199
+ }
200
+ cfg.stats.failures++
201
+ throw lastErr ?? new Error('callWithCascade: no result')
202
+ }
203
+
204
+ /**
205
+ * Витяг першого JSON-обʼєкта з raw-тексту.
206
+ * @param {string} raw сирий текст відповіді LLM
207
+ * @returns {any} розпарсений JSON-обʼєкт
208
+ * @throws {Error} якщо у тексті немає JSON-обʼєкта
209
+ */
210
+ function extractJson(raw) {
211
+ const s = raw.indexOf('{')
212
+ const e = raw.lastIndexOf('}')
213
+ if (s === -1 || e === -1) throw new Error('no JSON object')
214
+ return JSON.parse(raw.slice(s, e + 1))
215
+ }
216
+
217
+ // ─────────────────────────── Stage 1: edge-judge (LLM) ─────────────────────────
218
+
219
+ const EdgeSchema = z.object({
220
+ same: z.boolean(),
221
+ confidence: z.number().min(0).max(1),
222
+ reason: z.string().min(3).max(400)
223
+ })
224
+
225
+ const EDGE_SYS = `Ти порівнюєш два короткі записи архітектурних рішень (ADR). Визнач, чи вони описують ОДНЕ І ТЕ САМЕ рішення (одну тему/механізм, де другий лише уточнює/доповнює/продовжує перший), чи це РІЗНІ незалежні рішення.
226
+
227
+ Поверни ЛИШЕ JSON, без markdown:
228
+ { "same": true|false, "confidence": 0..1, "reason": "<коротко українською>" }
229
+
230
+ same=true ЛИШЕ якщо це по суті одне рішення (дублікат, уточнення, продовження тієї самої теми). Різні аспекти однієї підсистеми, але окремі рішення → same=false. Якщо сумніваєшся — false.`
231
+
232
+ /**
233
+ * Бінарний суддя «те саме рішення?» з self-consistency. Консервативний: `same`
234
+ * лише якщо ВСІ голоси кажуть same з confidence ≥ minConf. Харднінг #2: для
235
+ * draft↔draft (ризик over-merge) піднімаємо до 3 голосів і порога 0.6.
236
+ * @param {string} aTitle заголовок запису A
237
+ * @param {string} aBody тіло запису A
238
+ * @param {string} bTitle заголовок запису B
239
+ * @param {string} bBody тіло запису B
240
+ * @param {{allowCloud:boolean, votes?:number, stats:object}} cfg конфіг каскаду (дозвіл на хмару, голоси, лічильники)
241
+ * @param {{votes?:number, minConf?:number}} [vote] override голосів і порога на тип ребра
242
+ * @returns {{same:boolean, votes:object[]}} підтвердження same та сирі голоси
243
+ */
244
+ function judgeEdge(aTitle, aBody, bTitle, bBody, cfg, vote = {}) {
245
+ const nVotes = vote.votes ?? cfg.votes ?? 2
246
+ const minConf = vote.minConf ?? 0.5
247
+ const user = `Запис A — "${aTitle}":\n${aBody.slice(0, 1500)}\n\n---\n\nЗапис B — "${bTitle}":\n${bBody.slice(0, 1500)}\n\nЦе одне й те саме рішення?`
248
+ const parse = (raw) => EdgeSchema.parse(extractJson(raw))
249
+ const votes = []
250
+ for (let v = 0; v < nVotes; v++) {
251
+ try {
252
+ votes.push(callWithCascade([{ role: 'system', content: EDGE_SYS }, { role: 'user', content: user }], parse, { label: 'edge', allowCloud: cfg.allowCloud, stats: cfg.stats, maxTokens: 300 }))
253
+ } catch {
254
+ votes.push({ same: false, confidence: 0, reason: 'judge failed → conservative different' })
255
+ }
256
+ }
257
+ const sameCount = votes.filter((v) => v.same && v.confidence >= minConf).length
258
+ return { same: sameCount === votes.length, votes }
259
+ }
260
+
261
+ // ─────────────────────────── Stage 1b: kind-judge (LLM) ────────────────────────
262
+
263
+ const KindSchema = z.object({
264
+ kind: z.enum(['standalone', 'trivial']),
265
+ reason: z.string().min(3).max(400)
266
+ })
267
+
268
+ const KIND_SYS = `Ти оцінюєш чернетку архітектурного рішення (ADR). Визнач:
269
+ - "standalone" — це самостійне рішення, варте збереження як decision record.
270
+ - "trivial" — порожнє / тривіальне / косметичне / без реального рішення, можна видалити.
271
+
272
+ Поверни ЛИШЕ JSON: { "kind": "standalone"|"trivial", "reason": "<коротко українською>" }
273
+ Якщо сумніваєшся — "standalone" (краще зберегти).`
274
+
275
+ function judgeKind(title, body, cfg) {
276
+ const user = `Чернетка — "${title}":\n${body.slice(0, 2500)}\n\nstandalone чи trivial?`
277
+ const parse = (raw) => KindSchema.parse(extractJson(raw))
278
+ try {
279
+ return callWithCascade([{ role: 'system', content: KIND_SYS }, { role: 'user', content: user }], parse, { label: 'kind', allowCloud: cfg.allowCloud, stats: cfg.stats, maxTokens: 200 })
280
+ } catch {
281
+ return { kind: 'standalone', reason: 'judge failed → conservative standalone' }
282
+ }
283
+ }
284
+
285
+ // ─────────────────────────── Stage 2: gen-MADR (LLM) ───────────────────────────
286
+
287
+ const MADR_HEADINGS = [
288
+ '## Context and Problem Statement',
289
+ '## Considered Options',
290
+ '## Decision Outcome',
291
+ '## More Information'
292
+ ]
293
+
294
+ /**
295
+ * Детермінований гейт якості згенерованого MADR.
296
+ * @param {string} content згенерований MADR-текст
297
+ * @returns {{ok:boolean, errors:string[]}} результат перевірки та перелік порушень
298
+ */
299
+ export function validateMadr(content) {
300
+ const errors = []
301
+ if (!content || content.length < 80) errors.push('too short')
302
+ if (RE_FENCE_LEAD.test(content) || RE_FENCE_TRAIL.test(content.trim())) errors.push('code-fence wrapper')
303
+ if (RE_FRONTMATTER.test(content.split('\n').slice(0, 3).join('\n'))) errors.push('has YAML frontmatter')
304
+ if (RE_SESSION.test(content)) errors.push('leaked session: field')
305
+ if (!RE_H1.test(content)) errors.push('missing # title')
306
+ if (!RE_STATUS.test(content)) errors.push('missing Status')
307
+ if (!RE_DATE.test(content)) errors.push('missing/!ISO Date')
308
+ for (const h of MADR_HEADINGS) if (!content.includes(h)) errors.push(`missing heading ${h}`)
309
+ return { ok: errors.length === 0, errors }
310
+ }
311
+
312
+ const GEN_SYS = `Ти нормалізуєш чернетку ADR у чистий фінальний MADR 4.0.0. Чернетка вже містить секції — твоя задача ОЧИСТИТИ й привести до канону, НЕ вигадуючи нового.
313
+
314
+ Поверни ЛИШЕ markdown файлу (починається з "# "), без code-fence, без передмови, без YAML frontmatter.
315
+
316
+ Структура РІВНО така:
317
+ # <Заголовок українською>
318
+
319
+ **Status:** Accepted
320
+ **Date:** <YYYY-MM-DD з поля captured чернетки, перші 10 символів>
321
+
322
+ ## Context and Problem Statement
323
+ <...>
324
+
325
+ ## Considered Options
326
+ <bullets; якщо альтернатив не було — "Інші варіанти не обговорювалися.">
327
+
328
+ ## Decision Outcome
329
+ Chosen option: "<...>", because <...>.
330
+
331
+ ### Consequences
332
+ <bullets "Good, because ...", "Bad, because ...">
333
+
334
+ ## More Information
335
+ <файли, команди, API; якщо нема — "Додаткової інформації не зафіксовано.">
336
+
337
+ Не вигадуй альтернатив, наслідків чи контексту, яких нема в чернетці. Прибери YAML frontmatter (session/captured/transcript) повністю.`
338
+
339
+ const slugify = (title) =>
340
+ title.toLowerCase().replace(RE_SLUG_NONWORD, '-').replace(RE_LEAD_HYPHEN, '').slice(0, 60).replace(RE_TRAIL_HYPHEN, '') || 'adr'
341
+
342
+ function genMadr(title, body, captured, cfg) {
343
+ const date = (captured ?? '').slice(0, 10)
344
+ const user = `Чернетка "${title}" (captured: ${captured ?? 'невідомо'}):\n\n${body}\n\nПоверни чистий MADR. Date = ${date || 'візьми з captured'}.`
345
+ const parse = (raw) => {
346
+ const content = stripFence(raw)
347
+ const v = validateMadr(content)
348
+ if (!v.ok) throw new Error(`MADR invalid: ${v.errors.join('; ')}`)
349
+ return content
350
+ }
351
+ try {
352
+ const content = callWithCascade([{ role: 'system', content: GEN_SYS }, { role: 'user', content: user }], parse, { label: 'gen', allowCloud: cfg.allowCloud, stats: cfg.stats, attempts: 3, maxTokens: 4096 })
353
+ return { content, slug: slugify(title), valid: true }
354
+ } catch (error) {
355
+ cfg.stats.madrInvalid++
356
+ return { content: null, slug: slugify(title), valid: false, error: error.message }
357
+ }
358
+ }
359
+
360
+ // ─────────────────────────── Stage 3: gen-merge (LLM) ──────────────────────────
361
+
362
+ const MERGE_SYS = `Ти готуєш короткий блок-доповнення до існуючого ADR. Поверни ЛИШЕ markdown-блок (без code-fence), що починається рядком "## Update <YYYY-MM-DD>", і містить ЛИШЕ новий зміст, якого ще нема в цільовому ADR (уточнення/виправлення/продовження). Стисло. Без передмови.`
363
+
364
+ function genMerge(title, body, captured, targetTitle, cfg) {
365
+ const date = (captured ?? '').slice(0, 10)
366
+ const user = `Цільовий ADR: "${targetTitle}".\nЧернетка-доповнення "${title}" (${date}):\n${body.slice(0, 2500)}\n\nДай блок "## Update ${date}" з НОВИМ змістом.`
367
+ const parse = (raw) => {
368
+ const t = stripFence(raw)
369
+ if (!RE_UPDATE_HEAD.test(t)) throw new Error('missing ## Update heading')
370
+ return t
371
+ }
372
+ try {
373
+ return callWithCascade([{ role: 'system', content: MERGE_SYS }, { role: 'user', content: user }], parse, { label: 'merge', allowCloud: cfg.allowCloud, stats: cfg.stats, attempts: 2, maxTokens: 1500 })
374
+ } catch {
375
+ return `## Update ${date}\n\n(доповнення з чернетки "${title}")`
376
+ }
377
+ }
378
+
379
+ // ─────────────────────────── union-find ────────────────────────────────────────
380
+
381
+ function makeDSU(n) {
382
+ const p = Array.from({ length: n }, (_, i) => i)
383
+ const find = (x) => (p[x] === x ? x : (p[x] = find(p[x])))
384
+ const union = (a, b) => { p[find(a)] = find(b) }
385
+ return { find, union }
386
+ }
387
+
388
+ const captureField = (body, field) => (body.match(new RegExp(`^${field}:\\s*(.+)$`, 'm')) ?? [])[1]?.trim()
389
+
390
+ /**
391
+ * No-op за замовчуванням для onProgress (коли caller не передав логер).
392
+ * @returns {void} нічого не робить
393
+ */
394
+ const noop = () => {
395
+ // навмисно порожньо: тихий fallback для onProgress
396
+ }
397
+
398
+ // ─────────────────────────── orchestrator ──────────────────────────────────────
399
+
400
+ /**
401
+ * Головний конвеєр. Повертає operations[] (контракт single-shot) + stats.
402
+ * @param {{file:string, body:string}[]} drafts батч чернеток
403
+ * @param {string[]} cleanList clean basename-и
404
+ * @param {{allowCloud?:boolean, votes?:number, onProgress?:(m:string)=>void}} [opts] хмарна ескалація, кількість голосів і колбек прогресу
405
+ * @returns {{operations:object[], stats:object, trace:object}} операції apply-ops, лічильники та діагностичний trace
406
+ */
407
+ export function normalizePipeline(drafts, cleanList, opts = {}) {
408
+ const allowCloud = opts.allowCloud ?? false
409
+ const log = opts.onProgress ?? noop
410
+ const stats = { localCalls: 0, cloudCalls: 0, escalations: 0, failures: 0, madrInvalid: 0 }
411
+ const cfg = { allowCloud, votes: opts.votes ?? 2, stats }
412
+
413
+ const titles = drafts.map((d) => draftTitle(d.body) || d.file.replace(RE_MD_EXT, ''))
414
+ const captured = drafts.map((d) => captureField(d.body, 'captured'))
415
+
416
+ // Харднінг #1: детермінований no-decision гейт. Такі драфти не кластеризуємо й
417
+ // не rewrite-имо — одразу delete (без LLM), як це робить gold.
418
+ const noDec = drafts.map((d) => isNoDecision(d.body))
419
+ if (noDec.some(Boolean)) log(`no-decision гейт: ${noDec.filter(Boolean).length} драфт(ів) → delete`)
420
+
421
+ // Stage 0: retrieval (ребра, що торкаються no-decision драфтів, відкидаємо)
422
+ const edges = buildEdges(drafts, cleanList)
423
+ const dd = edges.dd.filter(([i, j]) => !noDec[i] && !noDec[j])
424
+ const dc = edges.dc.filter(([i]) => !noDec[i])
425
+ log(`retrieval: ${dd.length} draft-draft ребер, ${dc.length} draft-clean кандидатів`)
426
+
427
+ // Stage 1: judge draft↔draft ребра (харднінг #2: 3 голоси, conf ≥ 0.6 проти over-merge)
428
+ const dsu = makeDSU(drafts.length)
429
+ const confirmedDD = []
430
+ for (const [i, j] of dd) {
431
+ const r = judgeEdge(titles[i], drafts[i].body, titles[j], drafts[j].body, cfg, { votes: 3, minConf: 0.6 })
432
+ if (r.same) { dsu.union(i, j); confirmedDD.push([i, j]) }
433
+ }
434
+ log(`edge-judge: ${confirmedDD.length}/${dd.length} draft-draft ребер підтверджено`)
435
+
436
+ // Stage 1: judge draft↔clean → найкращий existing-target на драфт
437
+ const cleanTarget = Array.from({ length: drafts.length }).fill(null)
438
+ const dcByDraft = new Map()
439
+ for (const [i, c] of dc) { if (!dcByDraft.has(i)) dcByDraft.set(i, []); dcByDraft.get(i).push(c) }
440
+ for (const [i, cands] of dcByDraft) {
441
+ for (const c of cands) {
442
+ const cTitle = stripAdrName(c)
443
+ const r = judgeEdge(titles[i], drafts[i].body, cTitle, cTitle, cfg)
444
+ if (r.same) { cleanTarget[i] = c; break }
445
+ }
446
+ }
447
+ log(`clean-match: ${cleanTarget.filter(Boolean).length} драфтів вже покриті clean-ADR`)
448
+
449
+ // Cluster (JS): групуємо за DSU
450
+ const clusters = new Map()
451
+ for (let i = 0; i < drafts.length; i++) {
452
+ const root = dsu.find(i)
453
+ if (!clusters.has(root)) clusters.set(root, [])
454
+ clusters.get(root).push(i)
455
+ }
456
+
457
+ const decision = Array.from({ length: drafts.length }).fill(null)
458
+ const operations = []
459
+
460
+ for (const [, members] of clusters) {
461
+ if (members.length > 1) {
462
+ // anchor — лише серед non-noDec (no-decision не може бути канонічним rewrite)
463
+ const live = members.filter((m) => !noDec[m])
464
+ // anchor = індекс із найдовшим drafts[idx].body.length; при рівності — перший
465
+ // зустрінутий (еквівалент reduce з `>=`, що зберігає поточний акумулятор a).
466
+ const candidates = live.length ? live : members
467
+ let anchor = candidates[0]
468
+ for (let k = 1; k < candidates.length; k++) {
469
+ if (drafts[candidates[k]].body.length > drafts[anchor].body.length) anchor = candidates[k]
470
+ }
471
+ decision[anchor] = { op: 'rewrite' }
472
+ for (const m of members) {
473
+ if (m === anchor) continue
474
+ decision[m] = noDec[m] ? { op: 'delete', reason: 'рішення не прийняте (transcript обірвався)' } : { op: 'merge-anchor', anchorIdx: anchor }
475
+ }
476
+ } else {
477
+ const i = members[0]
478
+ if (noDec[i]) decision[i] = { op: 'delete', reason: 'рішення не прийняте (transcript обірвався)' }
479
+ else if (cleanTarget[i]) decision[i] = { op: 'merge-existing', target: cleanTarget[i] }
480
+ else decision[i] = { op: 'kind' }
481
+ }
482
+ }
483
+
484
+ // одинаки без clean-target → kind-judge
485
+ for (let i = 0; i < drafts.length; i++) {
486
+ if (decision[i].op === 'kind') {
487
+ const k = judgeKind(titles[i], drafts[i].body, cfg)
488
+ decision[i] = k.kind === 'trivial' ? { op: 'delete', reason: k.reason } : { op: 'rewrite' }
489
+ }
490
+ }
491
+
492
+ // Stage 2: gen-MADR для всіх rewrite (anchors + standalones)
493
+ const slugByIdx = Array.from({ length: drafts.length }).fill(null)
494
+ for (let i = 0; i < drafts.length; i++) {
495
+ if (decision[i].op !== 'rewrite') continue
496
+ const g = genMadr(titles[i], drafts[i].body, captured[i], cfg)
497
+ slugByIdx[i] = g.slug
498
+ if (g.valid) {
499
+ operations.push({ op: 'rewrite', file: drafts[i].file, slug: g.slug, content: g.content })
500
+ } else {
501
+ decision[i] = { op: 'gen-failed' }
502
+ log(`gen-MADR FAILED для ${drafts[i].file}: ${g.error}`)
503
+ }
504
+ }
505
+
506
+ // Stage 3: merges
507
+ for (let i = 0; i < drafts.length; i++) {
508
+ const d = decision[i]
509
+ if (d.op === 'merge-anchor') {
510
+ const slug = slugByIdx[d.anchorIdx]
511
+ if (!slug) { log(`merge-anchor ${drafts[i].file}: anchor gen failed → skip`); continue }
512
+ const add = genMerge(titles[i], drafts[i].body, captured[i], titles[d.anchorIdx], cfg)
513
+ operations.push({ op: 'merge-into', file: drafts[i].file, target: `${slug}.md`, additions: add })
514
+ } else if (d.op === 'merge-existing') {
515
+ const cTitle = stripAdrName(d.target)
516
+ const add = genMerge(titles[i], drafts[i].body, captured[i], cTitle, cfg)
517
+ operations.push({ op: 'merge-into', file: drafts[i].file, target: d.target, additions: add })
518
+ } else if (d.op === 'delete') {
519
+ operations.push({ op: 'delete', file: drafts[i].file, reason: d.reason })
520
+ }
521
+ }
522
+
523
+ const trace = {
524
+ titles,
525
+ clusters: Array.from(clusters.values(), (m) => m.map((i) => drafts[i].file)),
526
+ cleanTargets: cleanTarget.map((c, i) => (c ? [drafts[i].file, c] : null)).filter(Boolean),
527
+ decisions: decision.map((d, i) => [drafts[i].file, d.op])
528
+ }
529
+ return { operations, stats, trace }
530
+ }