@nitra/cursor 1.20.0 → 1.22.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.
@@ -10,14 +10,14 @@
10
10
  ]
11
11
  },
12
12
  "hooks": {
13
- "Stop": [
13
+ "PostToolUse": [
14
14
  {
15
- "matcher": "",
15
+ "matcher": "Edit|Write|MultiEdit",
16
16
  "hooks": [
17
17
  {
18
18
  "type": "command",
19
- "command": "npx --no @nitra/cursor stop-hook",
20
- "timeout": 60
19
+ "command": "npx --no @nitra/cursor post-tool-use-fix",
20
+ "timeout": 300
21
21
  }
22
22
  ]
23
23
  }
package/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.22.0] - 2026-05-25
8
+
9
+ ### Added
10
+
11
+ - **`npx @nitra/cursor lint`** — оркестратор лінт-ланцюжка з тайменгом на кожен крок. Послідовно запускає присутні у root `package.json` скрипти з фіксованого списку (`lint-ga`, `lint-js`, `lint-rego`, `lint-style`, `lint-text`, `lint-security`, `oxfmt`), **fail-fast** на першому ненульовому exit-коді. Наприкінці друкує таблицю `⏱ Lint timing` з часом кожного кроку — для атрибуції повільних кроків замість анонімного `&&`-агрегатора.
12
+ - **`runFixCommand` тепер друкує `⏱ Fix timing`** після прогону всіх `rules/<id>/fix.mjs` — per-rule час + сума. Маркер `❌` на впалих рядках.
13
+ - `npm/scripts/lib/timing-summary.mjs` — чистий форматер `formatTimingSummary(title, entries)` (спільний для fix і lint). 9 тестів у `tests/timing-summary.test.mjs`.
14
+ - `npm/scripts/lib/run-lint-cli.mjs` — `runLintCli({ cwd, spawnSyncFn, now, log, logError })` з DI для юніт-тестів. 7 тестів у `tests/run-lint-cli.test.mjs`.
15
+
16
+ ### Changed
17
+
18
+ - Кореневий `package.json` цього монорепо: `lint` → `n-cursor lint`; додано окремий скрипт `oxfmt: "oxfmt ."`, який раніше йшов у хвості ланцюжка прямою командою.
19
+ - Скіли `/n-fix` і `/n-lint`: додано вимогу копіювати таблицю `⏱` з виводу інструмента у фінальне резюме відповіді користувачу.
20
+
21
+ ## [1.21.0] - 2026-05-25
22
+
23
+ ### Changed
24
+
25
+ - **Stop-hook → PostToolUse з маршрутизацією за типом файла** (BREAKING для консьюмерів із кастомним `stop-hook` записом). `.claude-template/settings.template.json` тепер реєструє `PostToolUse` (matcher `Edit|Write|MultiEdit`, timeout 300) із командою `npx --no @nitra/cursor post-tool-use-fix` замість попереднього синхронного `Stop`-хука, що ганяв повний `fix` усіх правил на кожному turn-і. Новий хук читає `tool_input.file_path` зі stdin і запускає `fix` **лише** з релевантними правилами: `*.{mjs,js,cjs,ts,tsx,jsx}` → `js-lint`; `*.vue` → `js-lint style-lint vue`; `*.{css,scss,sass}` → `style-lint`; `**/k8s/**/*.{yaml,yml}` → `k8s`; `*.rego` → `rego`; `Dockerfile`/`*.Dockerfile` → `docker`; `.github/workflows/*.{yml,yaml}` → `ga`; `package.json` → `npm-module bun`; `*.sh` → `security`; `*.md` → `text` (поза `docs/adr/**` — там покриває async `normalize-decisions.sh`).
26
+ - **CLI**: підкоманду `npx @nitra/cursor stop-hook` видалено; замість неї — `npx @nitra/cursor post-tool-use-fix`. `MANAGED_HOOK_COMMAND_MARKER` у `sync-claude-config.mjs` змінено на `@nitra/cursor post-tool-use-fix`; legacy-маркер `@nitra/cursor stop-hook` лишається у `MANAGED_HOOK_COMMAND_MARKERS` для автоматичного cleanup-у старих entries при наступному `npx @nitra/cursor`. `mergeHooks` тепер обходить union usually template+existing events, тому застарілі managed-групи у вже-непотрібних подіях (`Stop` у даному випадку) теж зачищаються.
27
+
28
+ ### Added
29
+
30
+ - `npm/scripts/post-tool-use-fix.mjs` — реалізація `routeFilePathToRules(filePath)` (чиста функція, picomatch) і `runPostToolUseFixCli({ stdinJson, spawnFn })` (DI-friendly для тестів). 21 тест у `npm/scripts/tests/post-tool-use-fix.test.mjs`.
31
+ - `LEGACY_STOP_HOOK_COMMAND_MARKER` — публічний export для тестів і потенційних консьюмерів, які перевіряють відсутність застарілого хука.
32
+
33
+ ### Removed
34
+
35
+ - `npm/scripts/claude-stop-hook.mjs` — більше не потрібен.
36
+
7
37
  ## [1.20.0] - 2026-05-25
8
38
 
9
39
  ### Added
package/bin/n-cursor.js CHANGED
@@ -9,8 +9,13 @@
9
9
  * якщо в корені вже є `.n-cursor.json`, спочатку зчитується конфіг і за потреби дописується `$schema`
10
10
  * `npx \@nitra/cursor fix bun` — перевірити лише вказані правила (ігнорує `.cursor/rules/`)
11
11
  * `npx \@nitra/cursor rename-yaml-extensions` — k8s `*.yml` → `*.yaml`, `.github` `*.yaml` → `*.yml` (опції: `--dry-run`, `--root=…`; див. bin/rename-yaml-extensions.mjs)
12
- * `npx \@nitra/cursor stop-hook` — точка входу Stop hook Claude Code (читає stdin, виходить 0 при `stop_hook_active`,
13
- * інакше викликає `fix`); прописується автоматично в `.claude/settings.json`
12
+ * `npx \@nitra/cursor post-tool-use-fix` — точка входу PostToolUse hook Claude Code: читає stdin JSON,
13
+ * дістає `tool_input.file_path`, маршрутизує його у відповідні правила
14
+ * (`*.mjs` → `js-lint`, `*.vue` → `js-lint style-lint vue` тощо) і викликає
15
+ * `fix` лише з ними. Прописується автоматично в `.claude/settings.json`.
16
+ * `npx \@nitra/cursor lint` — оркестратор lint-ланцюжка з кореневого `package.json` з тайменгом
17
+ * кожного `lint-*` / `oxfmt` скрипта (fail-fast); канонічна заміна
18
+ * раніше ручного `lint-ga && lint-js && …` агрегатора.
14
19
  * `npx \@nitra/cursor lint-ga` — канонічний lint-ga (ga.mdc): preflight на `shellcheck` →
15
20
  * `bunx github-actionlint` → `uvx zizmor --offline --collect=workflows .`
16
21
  * `npx \@nitra/cursor lint-rego` — канонічний lint-rego (conftest.mdc + rego.mdc):
@@ -83,7 +88,7 @@ import {
83
88
  RULE_MIGRATIONS
84
89
  } from '../scripts/auto-rules.mjs'
85
90
  import { detectAutoSkills } from '../scripts/auto-skills.mjs'
86
- import { runStopHookCli } from '../scripts/claude-stop-hook.mjs'
91
+ import { runPostToolUseFixCli } from '../scripts/post-tool-use-fix.mjs'
87
92
  import { discoverCheckRulesFromCursorRules } from '../scripts/lib/discover-check-rules-from-cursor.mjs'
88
93
  import { listRuleIds } from '../scripts/lib/list-rule-ids.mjs'
89
94
  import { ensureNitraCursorInRootDevDependencies } from '../scripts/ensure-nitra-cursor-dev-dependencies.mjs'
@@ -97,6 +102,8 @@ import { upgradeNitraCursorToLatestAndBunInstall } from '../scripts/upgrade-nitr
97
102
  import { runRenameYamlExtensionsCli } from './rename-yaml-extensions.mjs'
98
103
  import { runSkillsCli } from '../scripts/skills-cli.mjs'
99
104
  import { syncSetupBunDepsAction } from '../scripts/sync-setup-bun-deps-action.mjs'
105
+ import { runLintCli } from '../scripts/lib/run-lint-cli.mjs'
106
+ import { formatTimingSummary } from '../scripts/lib/timing-summary.mjs'
100
107
 
101
108
  const PACKAGE_NAME = '@nitra/cursor'
102
109
  const CONFIG_FILE = '.n-cursor.json'
@@ -1181,12 +1188,19 @@ async function runFixCommand(requestedRules) {
1181
1188
  }
1182
1189
 
1183
1190
  let totalFailed = 0
1191
+ /** @type {{ id: string, ms: number, ok: boolean }[]} */
1192
+ const timings = []
1184
1193
  for (const id of idsToRun) {
1185
1194
  const fixPath = join(BUNDLED_RULES_DIR, id, 'fix.mjs')
1195
+ const startedAt = Date.now()
1186
1196
  const result = spawnSync('bun', [fixPath], { stdio: 'inherit' })
1187
- if (result.status !== 0) totalFailed++
1197
+ const ok = result.status === 0
1198
+ timings.push({ id: `fix-${id}`, ms: Date.now() - startedAt, ok })
1199
+ if (!ok) totalFailed++
1188
1200
  }
1189
1201
 
1202
+ process.stdout.write(formatTimingSummary('Fix timing', timings))
1203
+
1190
1204
  if (totalFailed > 0) {
1191
1205
  throw new Error(`${totalFailed} з ${idsToRun.length} правил мають проблеми`)
1192
1206
  }
@@ -1427,14 +1441,21 @@ try {
1427
1441
 
1428
1442
  break
1429
1443
  }
1430
- case 'stop-hook': {
1431
- // Викликається з .claude/settings.json як Stop hook Claude Code.
1432
- // Прокидає `check` і поважає stop_hook_active, щоб не зациклюватись.
1433
- const code = await runStopHookCli()
1444
+ case 'post-tool-use-fix': {
1445
+ // Викликається з .claude/settings.json як PostToolUse hook Claude Code.
1446
+ // Маршрутизує змінений файл у релевантні правила і прокидає `fix` лише з ними.
1447
+ const code = await runPostToolUseFixCli()
1434
1448
  process.exitCode = code
1435
1449
 
1436
1450
  break
1437
1451
  }
1452
+ case 'lint': {
1453
+ // Оркестратор lint-ланцюжка з тайменгом на кожен крок (fail-fast).
1454
+ // Замінює раніше використовуваний агрегатор `bun run lint-ga && bun run lint-js && …` у root package.json.
1455
+ process.exitCode = runLintCli()
1456
+
1457
+ break
1458
+ }
1438
1459
  case 'lint-ga': {
1439
1460
  // Канонічний lint-ga з preflight на shellcheck → actionlint → zizmor → check-ga (ga.mdc).
1440
1461
  // Останній крок (check-ga) async — тому await обов'язковий, інакше process.exitCode буде Promise.
@@ -1488,7 +1509,7 @@ try {
1488
1509
  default: {
1489
1510
  console.error(`❌ Невідома команда: ${command}`)
1490
1511
  console.error(
1491
- ` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions, stop-hook, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, coverage, skill`
1512
+ ` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions, post-tool-use-fix, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, coverage, skill`
1492
1513
  )
1493
1514
  process.exitCode = 1
1494
1515
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.20.0",
3
+ "version": "1.22.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
package/rules/adr/adr.mdc CHANGED
@@ -103,22 +103,12 @@ docs/adr/
103
103
 
104
104
  ## Stop-hook у `.claude/settings.json`
105
105
 
106
- Канонічний запис, який вставляє sync (поряд із lint stop-hook):
106
+ Канонічний запис, який вставляє sync (поряд із PostToolUse fix-хуком — той живе в іншій події, тут не показаний):
107
107
 
108
108
  ```json title=".claude/settings.json"
109
109
  {
110
110
  "hooks": {
111
111
  "Stop": [
112
- {
113
- "matcher": "",
114
- "hooks": [
115
- {
116
- "type": "command",
117
- "command": "npx --no @nitra/cursor stop-hook",
118
- "timeout": 60
119
- }
120
- ]
121
- },
122
112
  {
123
113
  "matcher": "",
124
114
  "hooks": [
@@ -146,7 +136,7 @@ docs/adr/
146
136
  }
147
137
  ```
148
138
 
149
- Усі три групи ідентифікуються пакетом за маркером у `command` (`@nitra/cursor stop-hook`, `.claude/hooks/capture-decisions.sh`, `.claude/hooks/normalize-decisions.sh`) — користувацькі hook-групи поряд не чіпаються. Якщо `adr` прибрати з `rules`, обидві ADR-групи автоматично видаляються на наступному `npx @nitra/cursor`.
139
+ Обидві ADR-групи ідентифікуються пакетом за маркером у `command` (`.claude/hooks/capture-decisions.sh`, `.claude/hooks/normalize-decisions.sh`) — користувацькі hook-групи поряд не чіпаються. Якщо `adr` прибрати з `rules`, обидві ADR-групи автоматично видаляються на наступному `npx @nitra/cursor`.
150
140
 
151
141
  ## Stop-hook у `.cursor/hooks.json`
152
142
 
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `n-cursor lint` — оркестратор лінт-ланцюжка з тайменгом на кожен крок.
3
+ *
4
+ * Замість агрегатора `bun run lint-ga && bun run lint-js && ... && oxfmt .` у кореневому
5
+ * `package.json` (де child-процеси анонімні і час кожного не видно), цей орекстратор:
6
+ *
7
+ * - читає `scripts` з кореневого `package.json`,
8
+ * - бере **присутні** ключі з фіксованого списку `LINT_SCRIPTS` (відсутні мовчки пропускає),
9
+ * - послідовно запускає `bun run <script>`,
10
+ * - заміряє час кожного,
11
+ * - **fail-fast**: при першому ненульовому exit-коді зупиняється, друкує таблицю
12
+ * лише по виконаних і повертає той самий код,
13
+ * - друкує підсумкову таблицю `⏱ Lint timing` і повертає 0, якщо все ОК.
14
+ *
15
+ * Список + порядок зумисне фіксований: збігається з канонічним ланцюжком, що його раніше
16
+ * тримав root `package.json`. Динамічний discovery (`scripts/^lint-/`) дав би непередбачуваний
17
+ * порядок і небажану інтерпретацію кастомних `lint-*` користувача.
18
+ *
19
+ * `oxfmt` — окремий рядок поза префіксом `lint-`, ставиться в кінець (як було у `lint`).
20
+ */
21
+ import { spawnSync as defaultSpawnSync } from 'node:child_process'
22
+ import { existsSync, readFileSync } from 'node:fs'
23
+ import { join } from 'node:path'
24
+
25
+ import { formatTimingSummary } from './timing-summary.mjs'
26
+
27
+ /**
28
+ * Імена npm-скриптів, які `n-cursor lint` запускає **по черзі**, якщо вони є у root `package.json`.
29
+ * Порядок дзеркалить попередній агрегатор `lint`: cheap-checks першими, формат — в кінці.
30
+ */
31
+ export const LINT_SCRIPTS = /** @type {const} */ ([
32
+ 'lint-ga',
33
+ 'lint-js',
34
+ 'lint-rego',
35
+ 'lint-style',
36
+ 'lint-text',
37
+ 'lint-security',
38
+ 'oxfmt'
39
+ ])
40
+
41
+ /**
42
+ * Читає `scripts` з `package.json` у заданій теці. Повертає `null`, якщо файла немає, JSON
43
+ * некоректний або поля `scripts` нема. Не кидає — викликач сам вирішує, що робити.
44
+ *
45
+ * @param {string} root абсолютний шлях до теки з `package.json`
46
+ * @returns {Record<string, string> | null} мапа scripts або null
47
+ */
48
+ function readRootScripts(root) {
49
+ const packageJsonPath = join(root, 'package.json')
50
+ if (!existsSync(packageJsonPath)) {
51
+ return null
52
+ }
53
+ try {
54
+ const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
55
+ const scripts = parsed?.scripts
56
+ if (!scripts || typeof scripts !== 'object') {
57
+ return null
58
+ }
59
+ return /** @type {Record<string, string>} */ (scripts)
60
+ } catch {
61
+ return null
62
+ }
63
+ }
64
+
65
+ /**
66
+ * @typedef {{
67
+ * cwd?: string,
68
+ * spawnSyncFn?: typeof defaultSpawnSync,
69
+ * now?: () => number,
70
+ * log?: (text: string) => void,
71
+ * logError?: (text: string) => void
72
+ * }} RunLintCliOptions
73
+ */
74
+
75
+ /**
76
+ * Виконує лінт-ланцюжок з тайменгом. Повертає exit-код, не кидає винятків (для прямого
77
+ * присвоєння у `process.exitCode`).
78
+ *
79
+ * @param {RunLintCliOptions} [options] DI для тестів (мокаємо spawn / fs / clock)
80
+ * @returns {number} 0 = успіх, ненульовий = code першого впалого скрипта, або 1 при структурних проблемах
81
+ */
82
+ export function runLintCli(options = {}) {
83
+ const root = options.cwd ?? process.cwd()
84
+ const spawnSync = options.spawnSyncFn ?? defaultSpawnSync
85
+ const now = options.now ?? Date.now
86
+ const log = options.log ?? (text => process.stdout.write(text))
87
+ const logError = options.logError ?? (text => process.stderr.write(text))
88
+
89
+ const scripts = readRootScripts(root)
90
+ if (scripts === null) {
91
+ logError(`❌ n-cursor lint: не знайдено package.json або поля "scripts" у ${root}\n`)
92
+ return 1
93
+ }
94
+
95
+ const present = LINT_SCRIPTS.filter(name => typeof scripts[name] === 'string' && scripts[name].length > 0)
96
+ if (present.length === 0) {
97
+ log('\nℹ️ n-cursor lint: у package.json немає жодного з lint-* / oxfmt скриптів — нічого запускати.\n')
98
+ return 0
99
+ }
100
+
101
+ /** @type {{ id: string, ms: number, ok: boolean }[]} */
102
+ const timings = []
103
+ let failedCode = 0
104
+ for (const name of present) {
105
+ const startedAt = now()
106
+ const result = spawnSync('bun', ['run', name], { stdio: 'inherit', cwd: root })
107
+ const code = typeof result.status === 'number' ? result.status : 1
108
+ const ok = code === 0
109
+ timings.push({ id: name, ms: now() - startedAt, ok })
110
+ if (!ok) {
111
+ failedCode = code === 0 ? 1 : code
112
+ break
113
+ }
114
+ }
115
+
116
+ log(formatTimingSummary('Lint timing', timings))
117
+ return failedCode
118
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Формат таблиці-резюме часу виконання для оркестраторів `fix` / `lint`.
3
+ *
4
+ * Дві спільні точки використання:
5
+ * - `runFixCommand` у `bin/n-cursor.js` — після прогону всіх `rules/<id>/fix.mjs`.
6
+ * - `runLintCli` у `scripts/lib/run-lint-cli.mjs` — після прогону `lint-*` скриптів з кореневого `package.json`.
7
+ *
8
+ * Чиста функція без I/O — повертає готовий рядок (з фінальним `\n`), друк — на стороні виклику.
9
+ * Час виводиться як `<ціла>.<десята>s`, навіть для субсекундних інтервалів — щоб одиниця була стабільна.
10
+ *
11
+ * Маркер `❌` на рядку — якщо `ok === false`.
12
+ *
13
+ * @typedef {{ id: string, ms: number, ok: boolean }} TimingEntry
14
+ */
15
+
16
+ /** @type {string} символ горизонтальної риски між списком і `total` */
17
+ const RULER = '─'
18
+
19
+ /**
20
+ * Форматує мілісекунди як `<sec>.<десята>s`. Округлення до десятої — нижнє (floor), щоб
21
+ * однаковий ms давав однаковий вивід у різних таблицях незалежно від платформи (Number.prototype.toFixed
22
+ * робить round-half-to-even, що для 950ms дає `0.9s` — приймаємо).
23
+ *
24
+ * @param {number} ms тривалість у мілісекундах (>= 0)
25
+ * @returns {string} наприклад `0.0s`, `1.2s`, `12.3s`
26
+ */
27
+ export function formatDurationMs(ms) {
28
+ const seconds = Math.max(0, ms) / 1000
29
+ return `${seconds.toFixed(1)}s`
30
+ }
31
+
32
+ /**
33
+ * Рендерить таблицю-резюме у вигляді багаторядкового тексту, готового до stdout.
34
+ *
35
+ * Структура:
36
+ *
37
+ * ```
38
+ * ⏱ <title>:
39
+ * <id> <duration> [❌]
40
+ * ...
41
+ * ──────────────
42
+ * total <sum>
43
+ * ```
44
+ *
45
+ * Ширина колонки id вирівнюється під найдовший id у списку. Мінімальна ширина riski — 14
46
+ * (узгоджено з типовою довжиною заголовків `fix-js-lint` / `lint-security`).
47
+ *
48
+ * @param {string} title заголовок таблиці (наприклад, `Fix timing` або `Lint timing`)
49
+ * @param {TimingEntry[]} timings записи в порядку запуску — друкуються як є, не сортуються
50
+ * @returns {string} готовий до stdout текст з кінцевим `\n`
51
+ */
52
+ export function formatTimingSummary(title, timings) {
53
+ if (timings.length === 0) {
54
+ return ''
55
+ }
56
+ const idWidth = Math.max(14, ...timings.map(t => t.id.length))
57
+ const lines = [`\n⏱ ${title}:`]
58
+ let totalMs = 0
59
+ for (const { id, ms, ok } of timings) {
60
+ totalMs += ms
61
+ const failMark = ok ? '' : ' ❌'
62
+ lines.push(` ${id.padEnd(idWidth)} ${formatDurationMs(ms)}${failMark}`)
63
+ }
64
+ lines.push(` ${RULER.repeat(idWidth + 2 + 6)}`)
65
+ lines.push(` ${'total'.padEnd(idWidth)} ${formatDurationMs(totalMs)}`)
66
+ return `${lines.join('\n')}\n`
67
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * PostToolUse hook для Claude Code: точкова маршрутизація `npx @nitra/cursor fix`
3
+ * за типом зміненого файла. Запускається після кожного `Edit` / `Write` / `MultiEdit`;
4
+ * замінює дорогий синхронний `Stop`-хук, що ганяв повний `fix` усіх правил на кожному
5
+ * turn-і.
6
+ *
7
+ * Контракт:
8
+ * - stdin Claude Code: JSON із `tool_input.file_path` (відносний шлях зміненого файла);
9
+ * - exit 0, якщо файл не маршрутизується (PostToolUse не блокує turn у будь-якому випадку,
10
+ * але ми лишаємо exit-код прозорим — для діагностики);
11
+ * - інакше spawn `npx --no @nitra/cursor fix <rules…>` із пробрасуванням exit-коду.
12
+ *
13
+ * Маршрути впорядковані від найбільш специфічного до загального; перший збіг — переможець.
14
+ * `docs/adr/**\/*.md` свідомо повертає `[]`: ADR-нормалізація вже покривається async
15
+ * Stop-hook'ом `normalize-decisions.sh` — повторний `fix adr` тут лише сповільнював би turn.
16
+ */
17
+ import { spawn } from 'node:child_process'
18
+ import { once } from 'node:events'
19
+
20
+ import picomatch from 'picomatch'
21
+
22
+ /**
23
+ * @typedef {object} Route
24
+ * @property {string} pattern picomatch glob (з підтримкою `**` і `{a,b}`)
25
+ * @property {string[]} rules ID правил `npm/rules/<id>` (бо `fix.mjs` обов'язковий)
26
+ */
27
+
28
+ /** Порядок важливий: специфічні маршрути (`.github/workflows/*`, `**\/k8s/**`) — перед загальними. */
29
+ /** @type {readonly Route[]} */
30
+ const ROUTES = Object.freeze([
31
+ { pattern: 'docs/adr/**/*.md', rules: [] },
32
+ { pattern: '.github/workflows/*.{yml,yaml}', rules: ['ga'] },
33
+ { pattern: '**/k8s/**/*.{yaml,yml}', rules: ['k8s'] },
34
+ { pattern: '**/*.vue', rules: ['js-lint', 'style-lint', 'vue'] },
35
+ { pattern: '**/*.{mjs,js,cjs,ts,tsx,jsx}', rules: ['js-lint'] },
36
+ { pattern: '**/*.{css,scss,sass}', rules: ['style-lint'] },
37
+ { pattern: '**/*.rego', rules: ['rego'] },
38
+ { pattern: '{**/,}Dockerfile', rules: ['docker'] },
39
+ { pattern: '**/*.Dockerfile', rules: ['docker'] },
40
+ { pattern: '**/*.sh', rules: ['security'] },
41
+ { pattern: '{**/,}package.json', rules: ['npm-module', 'bun'] },
42
+ { pattern: '**/*.md', rules: ['text'] }
43
+ ])
44
+
45
+ /**
46
+ * Повертає список правил, які слід прогнати для зміненого `filePath`.
47
+ * Перший збіг із `ROUTES` — переможець; невідомі шляхи / некоректні входи → `[]`.
48
+ * @param {unknown} filePath відносний шлях зміненого файла зі stdin Claude Code
49
+ * @returns {string[]} ID правил для `npx @nitra/cursor fix`
50
+ */
51
+ export function routeFilePathToRules(filePath) {
52
+ if (typeof filePath !== 'string' || filePath === '') {
53
+ return []
54
+ }
55
+ for (const { pattern, rules } of ROUTES) {
56
+ if (picomatch.isMatch(filePath, pattern, { dot: true })) {
57
+ return [...rules]
58
+ }
59
+ }
60
+ return []
61
+ }
62
+
63
+ /**
64
+ * Зчитує stdin до EOF як utf8 рядок. На TTY — повертає `''` одразу.
65
+ * @returns {Promise<string>} вміст stdin
66
+ */
67
+ async function readStdin() {
68
+ if (process.stdin.isTTY) {
69
+ return ''
70
+ }
71
+ process.stdin.setEncoding('utf8')
72
+ const chunks = []
73
+ process.stdin.on('data', chunk => {
74
+ chunks.push(chunk)
75
+ })
76
+ try {
77
+ await once(process.stdin, 'end')
78
+ } catch {
79
+ // 'error' на stdin — повертаємо те, що встигли зібрати
80
+ }
81
+ return chunks.join('')
82
+ }
83
+
84
+ /**
85
+ * Дістає `tool_input.file_path` зі stdin JSON Claude Code. Невалідний JSON
86
+ * або відсутнє поле → `null` (не помилка: дехто з тулів — напр. Bash — не пише `file_path`).
87
+ * @param {string} stdinJson сирий вміст stdin
88
+ * @returns {string | null} відносний шлях або `null`
89
+ */
90
+ function extractFilePath(stdinJson) {
91
+ if (!stdinJson) {
92
+ return null
93
+ }
94
+ try {
95
+ const obj = JSON.parse(stdinJson)
96
+ const fp = obj?.tool_input?.file_path
97
+ return typeof fp === 'string' && fp !== '' ? fp : null
98
+ } catch {
99
+ return null
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Точка входу. Викликається з `bin/n-cursor.js` коли argv[0] === `post-tool-use-fix`.
105
+ * Параметри ін'єктовні для тестів: `stdinJson` обходить read від `process.stdin`,
106
+ * `spawnFn` — заміна `node:child_process.spawn` (повертає EventEmitter-сумісний об'єкт).
107
+ * @param {{ stdinJson?: string, spawnFn?: typeof spawn }} [options]
108
+ * @returns {Promise<number>} exit code (0 — пропущено / fix ОК; інше — exit-код `fix`)
109
+ */
110
+ export async function runPostToolUseFixCli(options = {}) {
111
+ const stdinJson = options.stdinJson ?? (await readStdin())
112
+ const filePath = extractFilePath(stdinJson)
113
+ if (filePath === null) {
114
+ return 0
115
+ }
116
+ const rules = routeFilePathToRules(filePath)
117
+ if (rules.length === 0) {
118
+ return 0
119
+ }
120
+ const spawnFn = options.spawnFn ?? spawn
121
+ const child = spawnFn('npx', ['--no', '@nitra/cursor', 'fix', ...rules], { stdio: 'inherit' })
122
+ try {
123
+ const [code] = await once(child, 'exit')
124
+ return code ?? 1
125
+ } catch (error) {
126
+ process.stderr.write(`post-tool-use-fix: не вдалося запустити npx @nitra/cursor fix — ${error.message}\n`)
127
+ return 1
128
+ }
129
+ }
@@ -30,8 +30,10 @@ import { existsSync } from 'node:fs'
30
30
  import { chmod, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
31
31
  import { join } from 'node:path'
32
32
 
33
- /** Маркер lint Stop-hook'а (`npx --no \@nitra/cursor stop-hook`). */
34
- export const MANAGED_HOOK_COMMAND_MARKER = '@nitra/cursor stop-hook'
33
+ /** Маркер PostToolUse fix-hook'а (`npx --no \@nitra/cursor post-tool-use-fix`). */
34
+ export const MANAGED_HOOK_COMMAND_MARKER = '@nitra/cursor post-tool-use-fix'
35
+ /** Legacy-маркер старого Stop-hook'а — лишаємо для cleanup-у при оновленні існуючих інсталяцій. */
36
+ export const LEGACY_STOP_HOOK_COMMAND_MARKER = '@nitra/cursor stop-hook'
35
37
  /** Маркер ADR Stop-hook'а — підрядок шляху до bash-скрипта capture-decisions. */
36
38
  export const ADR_HOOK_COMMAND_MARKER = '.claude/hooks/capture-decisions.sh'
37
39
  /** Маркер ADR Stop-hook'а — підрядок шляху до bash-скрипта normalize-decisions. */
@@ -40,9 +42,13 @@ export const ADR_NORMALIZE_HOOK_COMMAND_MARKER = '.claude/hooks/normalize-decisi
40
42
  export const CURSOR_ADR_HOOK_COMMAND_MARKER = '.claude/hooks/capture-decisions.sh'
41
43
  /** Маркер Cursor ADR Normalize Stop-hook'а — той самий script path, але в `.cursor/hooks.json`. */
42
44
  export const CURSOR_ADR_NORMALIZE_HOOK_COMMAND_MARKER = '.claude/hooks/normalize-decisions.sh'
43
- /** Усі маркери managed-hook'ів пакета — за ними відрізняємо свої записи від користувацьких. */
45
+ /**
46
+ * Усі маркери managed-hook'ів пакета — за ними відрізняємо свої записи від користувацьких.
47
+ * Legacy stop-hook включений сюди, щоб старі entries автоматично видалялись при наступному sync-у.
48
+ */
44
49
  export const MANAGED_HOOK_COMMAND_MARKERS = Object.freeze([
45
50
  MANAGED_HOOK_COMMAND_MARKER,
51
+ LEGACY_STOP_HOOK_COMMAND_MARKER,
46
52
  ADR_HOOK_COMMAND_MARKER,
47
53
  ADR_NORMALIZE_HOOK_COMMAND_MARKER
48
54
  ])
@@ -189,9 +195,13 @@ export function mergeAllowList(existing, fromTemplate) {
189
195
  }
190
196
 
191
197
  /**
192
- * Зливає hooks-секцію: для кожної події в темплейті видаляємо managed-групи
193
- * з існуючої конфігурації і додаємо актуальні з темплейту. Немені події в
194
- * темплейті не чіпаються.
198
+ * Зливає hooks-секцію. Для **кожної події** з обох сторін:
199
+ * 1) видаляємо managed-групи з існуючої конфігурації (їх ідентифікують маркери з
200
+ * `MANAGED_HOOK_COMMAND_MARKERS`, включно з legacy-маркерами — це автоматично
201
+ * прибирає застарілі hook'и при переході на нову версію темплейту);
202
+ * 2) дописуємо managed-групи з темплейту.
203
+ * Перебір union-у подій важливий: коли пакет переносить hook між подіями (напр. `Stop`
204
+ * → `PostToolUse`), старі managed entries у вже-непотрібній події теж мають піти.
195
205
  * @param {Record<string, HookGroup[]> | undefined} existing поточна `hooks`-секція з .claude/settings.json
196
206
  * @param {Record<string, HookGroup[]> | undefined} fromTemplate цільова `hooks`-секція з темплейту
197
207
  * @returns {Record<string, HookGroup[]>} результат злиття (порожні події видаляються)
@@ -199,14 +209,13 @@ export function mergeAllowList(existing, fromTemplate) {
199
209
  export function mergeHooks(existing, fromTemplate) {
200
210
  /** @type {Record<string, HookGroup[]>} */
201
211
  const out = {}
202
- for (const [event, groups] of Object.entries(existing ?? {})) {
203
- out[event] = Array.isArray(groups) ? [...groups] : []
204
- }
205
- for (const [event, templateGroups] of Object.entries(fromTemplate ?? {})) {
206
- const existingGroups = (out[event] ?? []).filter(g => !isManagedHookGroup(g))
207
- out[event] = [...existingGroups, ...(templateGroups ?? [])]
208
- if (out[event].length === 0) {
209
- delete out[event]
212
+ const allEvents = new Set([...Object.keys(existing ?? {}), ...Object.keys(fromTemplate ?? {})])
213
+ for (const event of allEvents) {
214
+ const existingClean = (existing?.[event] ?? []).filter(g => !isManagedHookGroup(g))
215
+ const templateGroups = fromTemplate?.[event] ?? []
216
+ const combined = [...existingClean, ...templateGroups]
217
+ if (combined.length > 0) {
218
+ out[event] = combined
210
219
  }
211
220
  }
212
221
  return out
@@ -1,74 +0,0 @@
1
- /**
2
- * Stop-hook для Claude Code: запускається hook'ом із `.claude/settings.json` після того,
3
- * як агент сигналізує завершення ходу. Прозоро прокидає `npx \@nitra/cursor fix`
4
- * і повертає його exit code, щоб помилки правил блокували завершення.
5
- *
6
- * Захист від нескінченної рекурсії: якщо stdin містить `"stop_hook_active": true`
7
- * (Claude Code позначає цей прапорець, коли hook сам спричинив повторний Stop),
8
- * виходимо з кодом 0 без повторного запуску перевірок.
9
- *
10
- * Виклик з `bin/n-cursor.js`:
11
- * `npx --no \@nitra/cursor stop-hook`
12
- */
13
- import { spawn } from 'node:child_process'
14
- import { once } from 'node:events'
15
-
16
- /**
17
- * Зчитує stdin до EOF як utf8 рядок. Якщо stdin порожній (TTY) — повертає '' одразу.
18
- * @returns {Promise<string>} вміст stdin
19
- */
20
- async function readStdin() {
21
- if (process.stdin.isTTY) {
22
- return ''
23
- }
24
- process.stdin.setEncoding('utf8')
25
- const chunks = []
26
- process.stdin.on('data', chunk => {
27
- chunks.push(chunk)
28
- })
29
- try {
30
- await once(process.stdin, 'end')
31
- } catch {
32
- // 'error' на stdin — повертаємо те, що встигли зібрати
33
- }
34
- return chunks.join('')
35
- }
36
-
37
- /**
38
- * Чи stdin вказує, що поточний Stop вже виник через попередній Stop hook
39
- * (Claude Code передає `stop_hook_active: true`). У такому випадку повторний
40
- * запуск перевірок створив би нескінченний цикл — пропускаємо.
41
- * @param {string} stdin сирий вміст stdin
42
- * @returns {boolean} true, якщо рекурсивний виклик
43
- */
44
- export function isRecursiveStopHookCall(stdin) {
45
- if (!stdin) {
46
- return false
47
- }
48
- try {
49
- const obj = JSON.parse(stdin)
50
- return obj?.stop_hook_active === true
51
- } catch {
52
- return false
53
- }
54
- }
55
-
56
- /**
57
- * Точка входу. Викликається з `bin/n-cursor.js` коли argv[0] === 'stop-hook'.
58
- * @returns {Promise<number>} exit code (0 — OK / пропуск, 1 — помилки правил)
59
- */
60
- export async function runStopHookCli() {
61
- const stdin = await readStdin()
62
- if (isRecursiveStopHookCall(stdin)) {
63
- return 0
64
- }
65
-
66
- const child = spawn('npx', ['--no', '@nitra/cursor', 'fix'], { stdio: 'inherit' })
67
- try {
68
- const [code] = await once(child, 'exit')
69
- return code ?? 1
70
- } catch (error) {
71
- process.stderr.write(`stop-hook: не вдалося запустити npx @nitra/cursor fix — ${error.message}\n`)
72
- return 1
73
- }
74
- }