@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.
- package/.claude-template/hooks/normalize-decisions.sh +40 -10
- package/CHANGELOG.md +12 -0
- package/bin/n-cursor.js +16 -50
- package/package.json +1 -1
- package/rules/doc-files/js/docgen-extract.mjs +16 -5
- package/rules/doc-files/js/docgen-prompts.mjs +10 -8
- package/rules/js-lint-ci/js-lint-ci.mdc +2 -2
- package/rules/tauri/tauri.mdc +23 -1
- package/rules/tool-surface/fix.mjs +18 -0
- package/rules/tool-surface/meta.json +1 -0
- package/rules/tool-surface/tool-surface.mdc +72 -0
- package/schemas/rule-meta.json +2 -2
- package/scripts/lib/adr/docs/normalize-cli.md +36 -0
- package/scripts/lib/adr/docs/normalize-pipeline.md +40 -0
- package/scripts/lib/adr/normalize-cli.mjs +73 -0
- package/scripts/lib/adr/normalize-pipeline.mjs +530 -0
|
@@ -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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
*
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
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-
|
|
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 '
|
|
1673
|
-
//
|
|
1674
|
-
//
|
|
1675
|
-
//
|
|
1676
|
-
|
|
1677
|
-
|
|
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,
|
|
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
|
@@ -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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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('За
|
|
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
|
-
|
|
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('- За
|
|
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 (!
|
|
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
|
|
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-
|
|
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
|
|
package/rules/tauri/tauri.mdc
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
description: Tauri
|
|
3
3
|
globs: "**/src-tauri/**,**/tauri.conf.json"
|
|
4
4
|
alwaysApply: false
|
|
5
|
-
version: '1.
|
|
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
|
+
```
|
package/schemas/rule-meta.json
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"properties": {
|
|
9
9
|
"lint": {
|
|
10
10
|
"type": "string",
|
|
11
|
-
"enum": ["
|
|
12
|
-
"description": "
|
|
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
|
+
}
|