@nitra/cursor 10.2.0 → 11.0.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
+ ## [11.0.0] - 2026-06-15
4
+
5
+ ### Removed
6
+
7
+ - CLI: прибрано надлишкові точки входу заради мінімальної поверхні. `lint-ci` видалено — це був чистий аліас `lint --read-only --full` (CI лишається тим самим прапор-комбо). Deprecated-аліас `doc-files <sub>` (`scan|check|gen|stamp`) видалено — 0 живих callerів (hook давно на `lint-doc-files`, скіл на `lint-doc-files`/`fix-doc-files`). Заодно полагоджено застарілу документацію точок входу в `bin/n-cursor.js` (мертві згадки `fix`, опис `lint` → data-driven по `meta.json:lint`) і схему `rule-meta.json` (enum `quick|ci` → `per-file|full`, відповідає реальним значенням і `parseRuleLintSpec`).
8
+
9
+ ## [10.3.0] - 2026-06-15
10
+
11
+ ### Added
12
+
13
+ - adr-normalize: локальний конвеєр-оркестратор як backend (retrieval→edge-judge→cluster→gen на малій моделі); субкоманда adr-normalize-local + backend-селектор у normalize-decisions.sh (local|claude|cursor)
14
+
3
15
  ## [10.2.0] - 2026-06-15
4
16
 
5
17
  ### Added
package/bin/n-cursor.js CHANGED
@@ -26,7 +26,6 @@
26
26
  * `markdownlint-cli2 --fix` → `v8r` (json/json5/yaml/yml/toml)
27
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`
28
28
  * `npx \@nitra/cursor fix-doc-files` — JS-оркестрована генерація файлових док (роутинг local/cloud) зі штампом CRC (`--limit`/`--from`/`--overwrite`/`--retry-degraded`); `--stamp` — детерміноване перештампування CRC без LLM
29
- * `npx \@nitra/cursor doc-files <sub>` — DEPRECATED-аліас (scan|check|gen|stamp) → `lint-doc-files`/`fix-doc-files`
30
29
  * `npx \@nitra/cursor doc-aggregate modules` — JSON-лістинг логічних модулів (межі за `package.json`) для Tier 2 скілу doc-aggregate
31
30
  * `npx \@nitra/cursor skill list` — скіли пакета без синку в проєкт
32
31
  * `npx \@nitra/cursor skill taze` — промпт на stdout
@@ -660,7 +659,7 @@ function buildClaudeDocFilesSectionLines() {
660
659
  '',
661
660
  '## Файлова документація (`doc-files` — обовʼязковий крок, як lint)',
662
661
  '',
663
- 'Після зміни чи додавання кодового файлу його файлова дока (`<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`, за запитом.',
664
663
  ''
665
664
  ]
666
665
  }
@@ -1506,7 +1505,7 @@ try {
1506
1505
  // .n-cursor.json + bun install, а fix/lint/coverage/change/release переписують файли в CWD —
1507
1506
  // усе це ключиться на cwd(). Запуск із піддиректорії git-репо (типово прямий
1508
1507
  // `bun npm/bin/n-cursor.js` не з кореня) зачепив би не той каталог → STOP. Read-only та
1509
- // `--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) не зачіпаємо.
1510
1509
  if (ROOT_GUARDED_COMMANDS.has(command)) {
1511
1510
  assertCwdIsProjectRoot(cwd(), describeRootGuardedAction(command))
1512
1511
  }
@@ -1662,38 +1661,12 @@ try {
1662
1661
 
1663
1662
  break
1664
1663
  }
1665
- case 'doc-files': {
1666
- // Делегувальний аліас (deprecated): doc-files scan|check|gen|stamp →
1667
- // lint-doc-files / fix-doc-files. Зняття наступний major (спека 2026-06-12).
1668
- // Зміна exit: plain `check` 2→1 (через lint-doc-files); --hook/--git зберігають 2.
1669
- console.error('⚠ `doc-files <sub>` застаріло використовуй `lint-doc-files` / `fix-doc-files`.')
1670
- const rest = args.slice(1)
1671
- switch (args[0]) {
1672
- case 'scan': {
1673
- const { runLintDocFilesCli } = await import('../rules/doc-files/lint/lint.mjs')
1674
- process.exitCode = await runLintDocFilesCli(['--json', ...rest])
1675
- break
1676
- }
1677
- case 'check': {
1678
- const { runLintDocFilesCli } = await import('../rules/doc-files/lint/lint.mjs')
1679
- process.exitCode = await runLintDocFilesCli(rest)
1680
- break
1681
- }
1682
- case 'gen': {
1683
- const { runDocFilesGenCli } = await import('../rules/doc-files/js/docgen-files-batch.mjs')
1684
- process.exitCode = await runDocFilesGenCli(rest)
1685
- break
1686
- }
1687
- case 'stamp': {
1688
- const { runDocFilesStampCli } = await import('../rules/doc-files/js/docgen-files-batch.mjs')
1689
- process.exitCode = runDocFilesStampCli(rest)
1690
- break
1691
- }
1692
- default: {
1693
- console.error('Usage: npx @nitra/cursor lint-doc-files | fix-doc-files [--root <dir>]')
1694
- process.exitCode = 1
1695
- }
1696
- }
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)
1697
1670
 
1698
1671
  break
1699
1672
  }
@@ -1720,7 +1693,7 @@ try {
1720
1693
  default: {
1721
1694
  console.error(`❌ Невідома команда: ${command}`)
1722
1695
  console.error(
1723
- ` Очікується: (без аргументів) синхронізація правил, 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, 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`
1724
1697
  )
1725
1698
  process.exitCode = 1
1726
1699
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "10.2.0",
3
+ "version": "11.0.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -49,6 +49,10 @@ const FALSY_RETURN_RE = /catch[\s\S]{0,400}?return\s+(false|null|''|"")/
49
49
  // octokit/.request/.query). Хибний false-negative тут = небезпечна гарантія
50
50
  // «без мережі», тож свідомо схиляємось до over-detection (м'якший бік помилки).
51
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)\(/
52
56
  // Кеш — лише за ІМЕНОВАНИМ маркером (`cache`/`Cache`/`memoize`), не за будь-яким
53
57
  // `new Map()`: акумулятор (напр. `byPath = new Map()`) — не кеш, а хибна гарантія
54
58
  // «Кешує результати» гірша за пропуск (фабрикація > мовчання).
@@ -207,9 +211,11 @@ function extractMarkers(src) {
207
211
  if (src.includes(`'${lit}`) || src.includes(`"${lit}`) || src.includes(`/${lit}`)) skips.add(lit)
208
212
  }
209
213
  return {
210
- readOnly: !WRITE_FS_RE.test(src),
211
- catchesErrors: CATCH_RE.test(src) || TRY_RE.test(src),
212
- 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),
213
219
  network: NETWORK_RE.test(src),
214
220
  caches: CACHE_RE.test(src),
215
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,7 +1,8 @@
1
1
  ---
2
2
  docgen:
3
3
  source: npm/rules/doc-files/js/docgen-extract.mjs
4
- crc: e790ff64
4
+ crc: a0680e77
5
+ model: omlx/gemma-4-e4b-it-OptiQ-4bit
5
6
  score: 100
6
7
  ---
7
8
 
@@ -9,31 +10,32 @@ docgen:
9
10
 
10
11
  ## Огляд
11
12
 
12
- Файл витягує факти про конфігурацію та структуру проекту. Визначає мову та заголовок файлу. Витягує публічні експорти та імпорти з різних джерел. Витягує внутрішні та локальні символи. Документація перевіряє наявність операцій запису, обробки помилок, повернення хибних значень при невдачах, мережевих викликів, механізмів кешування та пропуск певних шляхів.
13
+ Витягує структурований факт-лист з вмісту файлів, аналізуючи їх залежно від мови. Для Rust витягує модульний опис, публічні експорти, локальні символи та класифікує імпорти й поведінкові маркери. Для JavaScript/TypeScript/MJS витягує опис, експортовані елементи з JSDoc, класифікує імпорти та визначає поведінкові маркери. При аналізі ігноруються директорії: .github, .git, node_modules, base/, ua/, .firebase. Звертається до мережі та кешує дані протягом одного прогону.
13
14
 
14
15
  ## Поведінка
15
16
 
16
- 1. Витягується факт про файл.
17
- 2. Визначається мова файлу.
18
- 3. Витягується заголовок файлу.
19
- 4. Витягуються публічні експорти.
20
- 5. Витягуються імпорти, класифіковані за джерелом.
21
- 6. Витягуються внутрішні символи, імпортовані з внутрішніх модулів.
22
- 7. Витягуються локальні символи, неекспортовані функції.
23
- 8. Визначаються евристичні маркери.
24
- * readOnly: перевірка на наявність операцій запису.
25
- * catchesErrors: перевірка на наявність обробки помилок.
26
- * returnsFalsyOnFail: перевірка на повернення хибних значень при невдачах.
27
- * network: перевірка на наявність мережевих викликів.
28
- * caches: перевірка на наявність механізмів кешування.
29
- * skips: пропуск шляхів .github, .git, node_modules, base/, ua/, .firebase.
17
+ 1. Витягує факт-лист з вмісту файлу.
18
+ 2. Визначає мову файлу за розширенням.
19
+ 3. Якщо мова — Rust, виконує аналіз Rust-коду:
20
+ а. Витягує модульний опис з `//!`.
21
+ б. Визначає публічні експорти (структури, функції, енуми) на основі `pub` префікса або експозиційних атрибутів.
22
+ в. Визначає локальні (приватні) символи, які не є публічними.
23
+ г. Класифікує імпорти (`std`, зовнішні, внутрішні).
24
+ д. Визначає поведінкові маркери (readOnly, network, caches тощо) на основі специфічних Rust-конструкцій.
25
+ 4. Якщо мова JavaScript/TypeScript/MJS, виконує аналіз JS-коду:
26
+ а. Витягує загальний опис файлу з верхнього блоку коментарів.
27
+ б. Витягує експортовані функції та класи разом із їхніми JSDoc-описами.
28
+ в. Класифікує імпорти на стандартні бібліотеки, NPM-пакети та внутрішні модулі.
29
+ г. Визначає локальні (неекспортовані) функції та класи.
30
+ д. Визначає поведінкові маркери (readOnly, network, caches тощо) на основі евристик.
31
+ 5. При аналізі JS-коду, свідомо ігнорує шляхи: .github, .git, node_modules, base/, ua/, .firebase.
32
+ 6. Повертає структуру фактів, що містить метадані про файл.
30
33
 
31
34
  ## Публічний API
32
35
 
33
- extractFacts — код файлу перетворює на список фактів
36
+ extractFacts — витягує факти з вмісту файлу.
34
37
 
35
38
  ## Гарантії поведінки
36
39
 
37
- - За невдачі повертає значення помилки (`false`/`null`/`Err`) замість генерування винятку чи паніки.
38
40
  - Кешує результати в межах одного прогону.
39
41
  - Свідомо пропускає шляхи: `.github`, `.git`, `node_modules`, `base/`, `ua/`, `.firebase`.
@@ -0,0 +1,30 @@
1
+ ---
2
+ docgen:
3
+ source: npm/rules/doc-files/js/docgen-judge-measure.mjs
4
+ crc: b40b626c
5
+ model: omlx/gemma-4-e4b-it-OptiQ-4bit
6
+ score: 100
7
+ ---
8
+
9
+ # docgen-judge-measure.mjs
10
+
11
+ ## Огляд
12
+
13
+ Файл аналізує вміст визначеного списку файлів, створюючи технічну документацію на основі вихідного коду. Процес спирається на конфігурацію, визначену в report.json. Для кожного файлу відбувається кешування результатів у межах прогону. Якість згенерованої документації оцінюється хмарною моделлю шляхом порівняння з пороговими значеннями, заданими в report.json. Результати аналізу агрегуються, обчислюється відсоток хибнопозитивних спрацювань та зберігаються у report.json.
14
+
15
+ ## Поведінка
16
+
17
+ 1. Зчитує список файлів для аналізу.
18
+ 2. Для кожного файлу зчитує його вміст.
19
+ 3. Генерує технічну документацію для файлу, використовуючи кеш за вмістом вихідного коду.
20
+ 4. Якщо документація згенерована, перевіряє її якість за встановленим порогом.
21
+ 5. Якщо якість документації відповідає порогу, передає вміст вихідного коду та згенеровану документацію для оцінки.
22
+ 6. Оцінює документацію за допомогою сильної хмарної моделі, використовуючи кеш за вмістом вихідного коду та документації.
23
+ 7. Збирає результати для кожного файлу.
24
+ 8. Агрегує результати, обчислюючи відсоток хибнопозитивних спрацювань (false-positive rate) серед документів, які пройшли порогову перевірку та були оцінені.
25
+ 9. Зберігає повний звіт у файл `report.json` у директорії кешу.
26
+ 10. Виводить консольний звіт про результати вимірювання.
27
+
28
+ ## Гарантії поведінки
29
+
30
+ - Кешує результати в межах одного прогону.
@@ -1,7 +1,8 @@
1
1
  ---
2
2
  docgen:
3
3
  source: npm/rules/doc-files/js/docgen-prompts.mjs
4
- crc: 72ac304f
4
+ crc: 28c9e818
5
+ model: omlx/gemma-4-e4b-it-OptiQ-4bit
5
6
  score: 100
6
7
  ---
7
8
 
@@ -9,31 +10,29 @@ docgen:
9
10
 
10
11
  ## Огляд
11
12
 
12
- Файл надає інструкції для створення лаконічної поведінкової документації українською мовою. Він містить інструменти для формування, перевірки та виправлення тексту документації, а також для визначення гарантій щодо роботи файлу
13
+ Генерує технічну документацію на основі коду. Створює компоненти документації, такі як огляд (`overviewMessages`), повідомлення для секцій (`sectionMessages`), повідомлення для критики (`criticMessages`), повідомлення для уточнення (`refineMessages`) та гарантії, отримані з маркерів (`guaranteesFromMarkers`), застосовуючи заданий стиль (`STYLE`).
13
14
 
14
15
  ## Поведінка
15
16
 
16
- Поведінка:
17
- - STYLE: Надає інструкцію для генерації лаконічної поведінкової документації українською мовою.
18
- - sectionMessages: Генерує секційні промпти для різних частин документації.
19
- - overviewMessages: Формує текст для узагальнення ролі та призначення файлу.
20
- - criticMessages: Створює запити для перевірки чорновиків документації на наявність дефектів.
21
- - refineMessages: Переписує та виправляє чорновки документації відповідно до критики.
22
- - guaranteesFromMarkers: Формує список гарантій щодо поведінки файлу на основі маркерів.
23
- - oneShotMessages: Створює універсальний запит для генерації повної документації.
17
+ STYLE: Пише лаконічну поведінкову документацію до коду українською мовою, використовуючи чистий Markdown.
18
+ sectionMessages: Генерує набір промптів для різних секцій документації, використовуючи метадані файлу та вміст коду.
19
+ overviewMessages: Створює узагальнений огляд файлу, базуючись на вже згенерованій секції «Поведінка».
20
+ criticMessages: Перевіряє чорнетку секції на відповідність технічним критеріям, виявляючи дефекти.
21
+ refineMessages: Переписує чорнетку секції, виправляючи дефекти, виявлені критиком.
22
+ guaranteesFromMarkers: Формує список гарантій поведінки, виходячи з машинно-виведених маркерів файлу.
23
+ oneShotMessages: Генерує базовий промпт для генерації повної документації за один виклик.
24
24
 
25
25
  ## Публічний API
26
26
 
27
- STYLE — Формулює стиль файлу.
28
- sectionMessages — Збирає повідомлення з мінімальним контекстом для кожної секції.
29
- overviewMessages — Надає узагальнення поведінки файлу.
30
- criticMessages — Виявляє дефекти в чорнетці секції.
31
- refineMessages — Виправляє чорнетку, усуваючи виявлені дефекти.
32
- guaranteesFromMarkers — Створює детермінований шаблон гарантій поведінки з фактів.
27
+ STYLE — Визначає загальний стиль та намір файлу.
28
+ sectionMessages — Містить мінімально необхідний контекст для кожної секції.
29
+ overviewMessages — Генерує узагальнений огляд на основі вже визначеної поведінки.
30
+ criticMessages — Виявляє конкретні недоліки у чорновому тексті секції, повертаючи список проблем або відсутність проблем.
31
+ refineMessages — Переписує чорновий текст, виправляючи виявлені недоліки.
32
+ guaranteesFromMarkers — Створює чіткий шаблон секції «Гарантії поведінки» на основі фактів.
33
+ oneShotMessages — Надає приклади для порівняння.
33
34
 
34
35
  ## Гарантії поведінки
35
36
 
36
- - Read-only: файл не виконує операцій запису у файлову систему.
37
- - За невдачі повертає значення помилки (`false`/`null`/`Err`) замість генерування винятку чи паніки.
37
+ - Read-only: не виконує операцій запису (ФС/БД).
38
38
  - Кешує результати в межах одного прогону.
39
- - Не звертається до мережі.
@@ -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
+ }