@nitra/cursor 1.13.62 → 1.13.64

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/CHANGELOG.md CHANGED
@@ -4,6 +4,30 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.13.64] - 2026-05-20
8
+
9
+ ### Added
10
+
11
+ - **`sync-claude-config`**: при увімкненому правилі `adr` `npx @nitra/cursor` дописує в кореневий `.gitignore` канонічний фрагмент `rules/adr/fix/hooks/template/.gitignore.snippet` (`.claude/hooks/*.log`, `.normalize-state`, `.normalize.lock`) — логи ADR Stop-hook більше не потрапляють у git status.
12
+
13
+ ### Changed
14
+
15
+ - Правило **`adr`**: посилання на канон `.gitignore.snippet` і згадка автоматичного дописування під час sync.
16
+ - **`.gitignore.snippet`**: додано базові рядки `node_modules/`, `dist/`, `*.secret` (як у кореневому `.gitignore` пакета).
17
+
18
+ ## [1.13.63] - 2026-05-20
19
+
20
+ ### Fixed
21
+
22
+ - **`check changelog`**: новий local-only воркспейс (маніфест відсутній на merge-base з `dev`/`main`, напр. `demo/` на `main`) більше не вимагає штучного bump — достатньо початкової `version` і запису в `CHANGELOG.md` (раніше `Vbase === ∅` помилково трактувалось як «version не підвищено»).
23
+ - **`check changelog`**: на гілці **`main`** база порівняння — **`origin/main`** (або `HEAD~1` без remote), не `dev`; коли `origin/main` збігається з `HEAD`, diff порожній (не fallback на `HEAD~1`); feature-гілки — `merge-base` з `dev`, інакше з `main` (репо без `dev`).
24
+
25
+ ### Changed
26
+
27
+ - Правило **`changelog`** ([changelog.mdc](rules/changelog/changelog.mdc) `2.5`): блок **STOP** перенесено на початок (тригер шляхів, інверсія, три кроки до завершення відповіді) — щоб агент не пропускав bump після правок у `npm/skills/` тощо, коли чеклист губився внизу довгого alwaysApply-правила.
28
+ - **`.cursor/rules/scripts.mdc`**: секція «Завершення задачі після правок у пакетному workspace» — cross-STOP з **n-changelog** (останні кроки сесії перед відповіддю).
29
+ - **`hk.pkl`**: pre-commit крок **`npm-changelog`** (`glob: npm/**`, `bun ./npm/bin/n-cursor.js check changelog`) — програмний стоп-кран при commit, якщо агент забув bump.
30
+
7
31
  ## [1.13.62] - 2026-05-20
8
32
 
9
33
  ### Changed
package/bin/n-cursor.js CHANGED
@@ -1274,6 +1274,7 @@ async function runSync() {
1274
1274
  if (result.commands.length > 0) parts.push(`${result.commands.length} slash-commands`)
1275
1275
  if (result.adrHook) parts.push('.claude/hooks/capture-decisions.sh')
1276
1276
  if (result.adrNormalizeHook) parts.push('.claude/hooks/normalize-decisions.sh')
1277
+ if (result.gitignoreAdr) parts.push('.gitignore (adr fragment)')
1277
1278
  if (parts.length > 0) {
1278
1279
  console.log(`🤖 Claude-конфіг: ${parts.join(', ')}`)
1279
1280
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.13.62",
3
+ "version": "1.13.64",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
package/rules/adr/adr.mdc CHANGED
@@ -95,7 +95,7 @@ docs/adr/
95
95
  └── hooks.json # Cursor Agent stop-hooks для тих самих скриптів
96
96
  ```
97
97
 
98
- `.gitignore` повинен містити рядки, що покривають **`.claude/hooks/*.log`** і службові файли normalize (`.claude/hooks/.normalize-*`).
98
+ `.gitignore` у корені проєкту повинен містити базові рядки (`node_modules/`, `dist/`, `*.secret`) і патерни для ADR Stop-hook (**`.claude/hooks/*.log`**, `.claude/hooks/.normalize-state`, `.claude/hooks/.normalize.lock`). Канонічний фрагмент (дописується `npx @nitra/cursor`, коли правило `adr` увімкнене): [.gitignore.snippet](./fix/hooks/template/.gitignore.snippet).
99
99
 
100
100
  ## Stop-hook у `.claude/settings.json`
101
101
 
@@ -0,0 +1,8 @@
1
+ node_modules/
2
+ dist/
3
+ *.secret
4
+
5
+ # @nitra/cursor (adr) — локальні артефакти Stop-hook, не коміти
6
+ .claude/hooks/*.log
7
+ .claude/hooks/.normalize-state
8
+ .claude/hooks/.normalize.lock
@@ -1,9 +1,25 @@
1
1
  ---
2
2
  description: CHANGELOG.md в кожному workspace, з двома моделями бази порівняння (npm і Python)
3
- version: '2.4'
3
+ version: '2.5'
4
4
  alwaysApply: true
5
5
  ---
6
6
 
7
+ ## STOP — перед завершенням відповіді агента
8
+
9
+ > **Якщо в цій сесії ти змінив(ла) файли в пакетному workspace** (код, rego, правила, скіли, скрипти, конфіги, тести — **не** лише `docs/` / `doc/`) — **не завершуй задачу**, поки не виконаєш **усі три** кроки нижче в **тому ж** наборі змін. Це не «опційно після синку» — це частина PR.
10
+
11
+ 1. **`version`** у `<ws>/package.json` (або `[project].version` у `pyproject.toml`) → **patch +1** відносно `git show HEAD:<ws>/package.json`, якщо ще не піднято.
12
+ 2. **`CHANGELOG.md`** того workspace → **нова** секція `## [версія] - YYYY-MM-DD` **зверху** (не bullet-и в стару версію).
13
+ 3. **`npx @nitra/cursor check changelog`** (у репо `@nitra/cursor`: `bun ./npm/bin/n-cursor.js check changelog`) → exit **`0`**.
14
+
15
+ **Тригер шляхів (приклади):** `npm/**`, `packages/foo/**`, будь-який каталог з власним `package.json` / `pyproject.toml`, куди потрапили правки.
16
+
17
+ **Інверсія (bump не потрібен):** лише `docs/` / `doc/`; лише `.gitignore`; лише сам релізний крок (`CHANGELOG.md` + `version`).
18
+
19
+ **Pre-commit (людина):** `hk` у цьому репо також запускає `check changelog` при змінах під `npm/**` — агент не покладайся лише на commit hook; виконай кроки 1–3 **до** фінальної відповіді.
20
+
21
+ ---
22
+
7
23
  У кожному **пакетному** workspace (каталог із `package.json` або `pyproject.toml`) має бути власний **`CHANGELOG.md`**. Спільного на репозиторій змісту змін **не існує** — кожен пакет веде свій.
8
24
 
9
25
  **Маніфест версії:**
@@ -13,7 +29,9 @@ alwaysApply: true
13
29
 
14
30
  Каталоги лише з `pyproject.toml` (без `package.json`) теж враховуються; `node_modules/`, `.venv/`, `venv/` при пошуку ігноруються.
15
31
 
16
- ## Чеклист агента (будь-який репозиторій з цим правилом)
32
+ ## Чеклист агента (деталі)
33
+
34
+ Повний алгоритм — у блоці **STOP** вище; тут лише уточнення.
17
35
 
18
36
  **Інверсія (за замовчуванням не вимагають bump/CHANGELOG):**
19
37
 
@@ -21,13 +39,7 @@ alwaysApply: true
21
39
  - файли під **`.gitignore`**;
22
40
  - правки **лише** `CHANGELOG.md` або поля `version` у маніфесті як сам релізний крок.
23
41
 
24
- **Вимагають bump + нову секцію CHANGELOG** — усі інші зміни в каталозі workspace (код, rego, правила, конфіги, тести тощо).
25
-
26
- Перед завершенням задачі:
27
-
28
- 1. **`version`** у `<ws>/package.json` або `[project].version` у `<ws>/pyproject.toml` підвищено (build/patch +1 на PR), якщо зміни входять у реліз.
29
- 2. **`CHANGELOG.md`** — **нова** секція `## [версія] - YYYY-MM-DD` зверху (не дописуй bullet-и в стару версію).
30
- 3. **`npx @nitra/cursor check changelog`** — exit `0`.
42
+ **Вимагають bump + нову секцію CHANGELOG** — усі інші зміни в каталозі workspace (код, rego, правила, скіли, конфіги, тести тощо).
31
43
 
32
44
  Перевірка програмна (`changelog/fix/consistency/check.mjs`).
33
45
 
@@ -48,14 +60,14 @@ alwaysApply: true
48
60
 
49
61
  ### local-only
50
62
 
51
- **npm:** `private: true` або без `files`. **Python:** без пари name+version для реєстру. База = **`dev` або `main`** (перша наявна), `git merge-base`:
63
+ **npm:** `private: true` або без `files`. **Python:** без пари name+version для реєстру. База залежить від гілки:
52
64
 
53
65
  1. На **`dev`** local-only не активний (крім незакомічених registry-published).
54
- 2. На feature-гілціbump + CHANGELOG **раз на PR**.
55
- 3. Після merge на інтеграційну гілку diff порожній pass.
56
- 4. Direct-commit на `main` ловиться так само.
66
+ 2. На **`main`**diff від **`origin/main`** (попередній опублікований `main`); без remote — від `HEAD~1`. **`dev` не використовується** як база на `main`.
67
+ 3. На **feature-гілці** — `merge-base` з **`dev`**, якщо є; інакше з **`main`** (репо без `dev`).
68
+ 4. Bump + CHANGELOG **раз на PR** / direct-commit на `main`.
57
69
 
58
- Якщо немає git або немає `dev`/`main` — local-only пропускається.
70
+ Якщо немає git або немає `dev`/`main`/`origin/main` — local-only пропускається.
59
71
 
60
72
  ## Формат CHANGELOG.md
61
73
 
@@ -10,8 +10,9 @@
10
10
  * Якщо локальна версія відрізняється — потрібен CHANGELOG; для npm також `"CHANGELOG.md"`
11
11
  * у `files`. Якщо версії збігаються, але в git є релевантні зміни без bump — fail.
12
12
  *
13
- * 2) **local-only** (приватні npm, без `files`, Python без імені/версії для реєстру): PR-scoped
14
- * перевірка проти `dev` / `main` через `git merge-base`.
13
+ * 2) **local-only** (приватні npm, без `files`, Python без імені/версії для реєстру):
14
+ * feature-гілка `merge-base` з `dev`, інакше з `main`; на `main` diff від
15
+ * `origin/main` (попередній опублікований main) або `HEAD~1` без remote.
15
16
  *
16
17
  * Усі `git` і зовнішні виклики — через `execFile` / `fetch`, без shell-інтерполяції.
17
18
  */
@@ -31,11 +32,11 @@ import {
31
32
 
32
33
  const execFileAsync = promisify(execFile)
33
34
 
34
- /** Кандидати інтеграційної гілки (перша наявна в репо; див. n-changelog.mdc) */
35
- const BASE_BRANCH_CANDIDATES = Object.freeze(['dev', 'main'])
35
+ /** Кандидати інтеграційної гілки для feature-гілок (перша наявна; див. n-changelog.mdc). */
36
+ const FEATURE_BASE_BRANCH_CANDIDATES = Object.freeze(['dev', 'main'])
36
37
 
37
- /** Гілки, на яких local-only перевірку пропускаємо (крім незакомічених registry-published). */
38
- const INTEGRATION_BRANCHES = Object.freeze(['dev', 'main'])
38
+ /** Гілка `dev`: local-only не активний (крім незакомічених registry-published). */
39
+ const LOCAL_ONLY_SKIP_BRANCH = 'dev'
39
40
 
40
41
  /** Префікси шляхів (posix), які не вважаються релізними змінами — інверсія glob (n-changelog.mdc). */
41
42
  const CHANGELOG_IGNORE_PATH_PREFIXES = Object.freeze(['docs/', 'doc/'])
@@ -78,14 +79,6 @@ async function currentBranchName() {
78
79
  return typeof out === 'string' ? out.trim() : null
79
80
  }
80
81
 
81
- /**
82
- * @param {string | null} branch параметр
83
- * @returns {boolean} результат
84
- */
85
- function isIntegrationBranch(branch) {
86
- return branch !== null && INTEGRATION_BRANCHES.includes(branch)
87
- }
88
-
89
82
  /**
90
83
  * @param {string} ref параметр
91
84
  * @returns {string} результат
@@ -94,6 +87,30 @@ function baseRefLabel(ref) {
94
87
  return ref.startsWith('origin/') ? ref.slice('origin/'.length) : ref
95
88
  }
96
89
 
90
+ /**
91
+ * @param {string} ancestor предок
92
+ * @param {string} descendant нащадок
93
+ * @returns {Promise<boolean>} результат
94
+ */
95
+ async function isGitAncestor(ancestor, descendant) {
96
+ const out = await gitOrNull(['merge-base', '--is-ancestor', ancestor, descendant])
97
+ return typeof out === 'string' && out.trim() === 'true'
98
+ }
99
+
100
+ /**
101
+ * @param {string} branchName локальна або remote-tracking гілка
102
+ * @returns {Promise<string | null>} ref для git або null
103
+ */
104
+ async function resolveBranchRef(branchName) {
105
+ for (const ref of [branchName, `origin/${branchName}`]) {
106
+ const out = await gitOrNull(['rev-parse', '--verify', '--quiet', ref])
107
+ if (typeof out === 'string' && out.trim().length > 0) {
108
+ return ref
109
+ }
110
+ }
111
+ return null
112
+ }
113
+
97
114
  /**
98
115
  * @param {string} relPath параметр
99
116
  * @returns {boolean} результат
@@ -119,21 +136,6 @@ async function isPathGitIgnored(relPath) {
119
136
  }
120
137
  }
121
138
 
122
- /**
123
- * @returns {Promise<string | null>} результат
124
- */
125
- async function resolveBaseRef() {
126
- for (const name of BASE_BRANCH_CANDIDATES) {
127
- for (const ref of [name, `origin/${name}`]) {
128
- const out = await gitOrNull(['rev-parse', '--verify', '--quiet', ref])
129
- if (typeof out === 'string' && out.trim().length > 0) {
130
- return ref
131
- }
132
- }
133
- }
134
- return null
135
- }
136
-
137
139
  /**
138
140
  * @param {string} baseRef параметр
139
141
  * @returns {Promise<string | null>} результат
@@ -145,6 +147,45 @@ async function resolveMergeBase(baseRef) {
145
147
  return sha.length > 0 ? sha : null
146
148
  }
147
149
 
150
+ /**
151
+ * Точка порівняння git для changelog (ref або SHA для `git diff` / `git show`).
152
+ * @param {string | null} branch поточна гілка
153
+ * @returns {Promise<{ ref: string, label: string } | null>} результат
154
+ */
155
+ async function resolveChangelogComparisonPoint(branch) {
156
+ if (branch === LOCAL_ONLY_SKIP_BRANCH) {
157
+ return null
158
+ }
159
+
160
+ if (branch === 'main') {
161
+ const originMainRaw = await gitOrNull(['rev-parse', '--verify', '--quiet', 'origin/main'])
162
+ const originMainSha = originMainRaw?.trim()
163
+ const headRaw = await gitOrNull(['rev-parse', 'HEAD'])
164
+ const headSha = headRaw?.trim()
165
+ if (originMainSha && headSha && (originMainSha === headSha || (await isGitAncestor('origin/main', 'HEAD')))) {
166
+ return { ref: 'origin/main', label: 'main' }
167
+ }
168
+ const parent = await gitOrNull(['rev-parse', '--verify', '--quiet', 'HEAD~1'])
169
+ if (typeof parent === 'string' && parent.trim().length > 0) {
170
+ return { ref: parent.trim(), label: 'main~1' }
171
+ }
172
+ return null
173
+ }
174
+
175
+ for (const name of FEATURE_BASE_BRANCH_CANDIDATES) {
176
+ const baseRef = await resolveBranchRef(name)
177
+ if (!baseRef) {
178
+ continue
179
+ }
180
+ const mergeBase = await resolveMergeBase(baseRef)
181
+ if (!mergeBase) {
182
+ continue
183
+ }
184
+ return { ref: mergeBase, label: baseRefLabel(baseRef) }
185
+ }
186
+ return null
187
+ }
188
+
148
189
  /**
149
190
  * @param {string} ws параметр
150
191
  * @param {string[]} subWorkspaces параметр
@@ -355,7 +396,8 @@ async function checkPublishedWorkspacePendingGitChanges(manifest, Vcurrent, subW
355
396
  }
356
397
 
357
398
  const branch = await currentBranchName()
358
- if (isIntegrationBranch(branch)) {
399
+
400
+ if (branch === LOCAL_ONLY_SKIP_BRANCH) {
359
401
  if (await workspaceHasRelevantChangesAgainstBase('HEAD', manifest.ws, subWorkspaces)) {
360
402
  fail(
361
403
  `${label}: у registry-published пакеті є незакомічені зміни при version ${Vcurrent}, що вже в реєстрі. ` +
@@ -365,28 +407,32 @@ async function checkPublishedWorkspacePendingGitChanges(manifest, Vcurrent, subW
365
407
  return
366
408
  }
367
409
 
368
- const baseRef = await resolveBaseRef()
369
- if (!baseRef) {
370
- return
371
- }
372
- const mergeBase = await resolveMergeBase(baseRef)
373
- if (!mergeBase) {
374
- return
375
- }
376
- if (!(await workspaceHasRelevantChangesAgainstBase(mergeBase, manifest.ws, subWorkspaces))) {
377
- return
410
+ const comparison = await resolveChangelogComparisonPoint(branch)
411
+ if (comparison && (await workspaceHasRelevantChangesAgainstBase(comparison.ref, manifest.ws, subWorkspaces))) {
412
+ const Vbase = await readBaseVersion(comparison.ref, manifest)
413
+ const baseLabel = comparison.label
414
+ if (Vbase === null) {
415
+ pass(
416
+ `${label}: новий registry-published воркспейс (на ${baseLabel} відсутній ${mf}) — перевіряємо CHANGELOG для ${Vcurrent}`
417
+ )
418
+ await verifyChangelogEntry(manifest.ws, Vcurrent, pass, fail)
419
+ checkNpmFilesArrayContainsChangelog(manifest, pass, fail)
420
+ } else if (Vbase === Vcurrent) {
421
+ fail(
422
+ `${label}: у цій гілці є зміни в registry-published пакеті, але version у ${mf} ` +
423
+ `не підвищено (на ${baseLabel} — ${Vbase}). Bump + запис у CHANGELOG.md обов'язкові (n-changelog.mdc)`
424
+ )
425
+ } else {
426
+ pass(`${label}: version змінено (${Vbase} → ${Vcurrent}) — очікується запис CHANGELOG після bump`)
427
+ }
378
428
  }
379
429
 
380
- const Vbase = await readBaseVersion(mergeBase, manifest)
381
- const baseLabel = baseRefLabel(baseRef)
382
- if (Vbase === null || Vbase === Vcurrent) {
430
+ if (branch === 'main' && (await workspaceHasRelevantChangesAgainstBase('HEAD', manifest.ws, subWorkspaces))) {
383
431
  fail(
384
- `${label}: у цій гілці є зміни в registry-published пакеті, але version у ${mf} ` +
385
- `не підвищено (на ${baseLabel} ${Vbase ?? '∅'}). Bump + запис у CHANGELOG.md обов'язкові на PR (n-changelog.mdc)`
432
+ `${label}: у registry-published пакеті є незакомічені зміни при version ${Vcurrent}, що вже в реєстрі. ` +
433
+ `Підвищ version у ${mf} і додай запис у CHANGELOG.md (n-changelog.mdc)`
386
434
  )
387
- return
388
435
  }
389
- pass(`${label}: version змінено (${Vbase} → ${Vcurrent}) — очікується запис CHANGELOG після bump`)
390
436
  }
391
437
 
392
438
  /**
@@ -426,13 +472,13 @@ async function checkPublishedWorkspace(manifest, subWorkspaces, getPublishedVers
426
472
  }
427
473
 
428
474
  /**
429
- * @param {string} mergeBase параметр
475
+ * @param {string} comparisonRef ref/SHA для `git diff` / `git show`
430
476
  * @param {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest} manifest параметр
431
477
  * @param {string} baseLabel параметр
432
478
  * @param {(msg: string) => void} pass параметр
433
479
  * @param {(msg: string) => void} fail параметр
434
480
  */
435
- async function checkLocalOnlyChangedWorkspace(mergeBase, manifest, baseLabel, pass, fail) {
481
+ async function checkLocalOnlyChangedWorkspace(comparisonRef, manifest, baseLabel, pass, fail) {
436
482
  const label = workspaceLabel(manifest)
437
483
  const mf = manifestFilePath(manifest.ws, manifest)
438
484
  const Vcurrent = manifest.version
@@ -440,10 +486,16 @@ async function checkLocalOnlyChangedWorkspace(mergeBase, manifest, baseLabel, pa
440
486
  fail(`${label}: у ${mf} відсутнє поле version (потрібне для запису в CHANGELOG)`)
441
487
  return
442
488
  }
443
- const Vbase = await readBaseVersion(mergeBase, manifest)
444
- if (Vbase === null || Vbase === Vcurrent) {
489
+ const Vbase = await readBaseVersion(comparisonRef, manifest)
490
+ if (Vbase === null) {
491
+ pass(`${label}: новий воркспейс (на ${baseLabel} відсутній ${mf}) — перевіряємо CHANGELOG для ${Vcurrent}`)
492
+ if (!(await verifyChangelogEntry(manifest.ws, Vcurrent, pass, fail))) return
493
+ checkNpmFilesArrayContainsChangelog(manifest, pass, fail)
494
+ return
495
+ }
496
+ if (Vbase === Vcurrent) {
445
497
  fail(
446
- `${label}: у цій гілці є зміни, але version у ${mf} не підвищено (на ${baseLabel} — ${Vbase ?? '∅'}). Bump + запис у CHANGELOG.md обов'язкові на PR`
498
+ `${label}: у цій гілці є зміни, але version у ${mf} не підвищено (на ${baseLabel} — ${Vbase}). Bump + запис у CHANGELOG.md обов'язкові на PR`
447
499
  )
448
500
  return
449
501
  }
@@ -466,30 +518,24 @@ async function runLocalOnlyChecks(localOnly, subWorkspaces, pass, fail) {
466
518
  return
467
519
  }
468
520
  const branch = await currentBranchName()
469
- if (branch === 'dev') {
521
+ if (branch === LOCAL_ONLY_SKIP_BRANCH) {
470
522
  pass('changelog: поточна гілка = dev — local-only перевірку пропущено')
471
523
  return
472
524
  }
473
- const baseRef = await resolveBaseRef()
474
- if (!baseRef) {
525
+ const comparison = await resolveChangelogComparisonPoint(branch)
526
+ if (!comparison) {
475
527
  pass('changelog: ref dev/main (та origin/*) не знайдено — local-only перевірку пропущено')
476
528
  return
477
529
  }
478
- const mergeBase = await resolveMergeBase(baseRef)
479
- if (!mergeBase) {
480
- pass(`changelog: merge-base з ${baseRef} не знайдено — local-only перевірку пропущено`)
481
- return
482
- }
483
530
 
484
- const baseLabel = baseRefLabel(baseRef)
485
531
  let checkedAny = false
486
532
  for (const manifest of localOnly) {
487
- if (!(await workspaceHasRelevantChangesAgainstBase(mergeBase, manifest.ws, subWorkspaces))) continue
533
+ if (!(await workspaceHasRelevantChangesAgainstBase(comparison.ref, manifest.ws, subWorkspaces))) continue
488
534
  checkedAny = true
489
- await checkLocalOnlyChangedWorkspace(mergeBase, manifest, baseLabel, pass, fail)
535
+ await checkLocalOnlyChangedWorkspace(comparison.ref, manifest, comparison.label, pass, fail)
490
536
  }
491
537
  if (!checkedAny) {
492
- pass(`changelog: local-only воркспейси без змін відносно merge-base(${baseRef})`)
538
+ pass(`changelog: local-only воркспейси без змін відносно ${comparison.label}`)
493
539
  }
494
540
  }
495
541
 
@@ -18,6 +18,10 @@
18
18
  * Stop-hook (батч-нормалізація чернеток); умови — ті самі, що для `capture`.
19
19
  * - `.cursor/hooks.json` — **merge**: користувацькі hooks зберігаються; ADR stop
20
20
  * entries додаються, коли правило `adr` увімкнене, і видаляються, коли вимкнене.
21
+ * - `.gitignore` — **merge** (лише з `adr`): дописує відсутні рядки з канонічного
22
+ * фрагмента `rules/adr/fix/hooks/template/.gitignore.snippet` (`node_modules/`, `dist/`,
23
+ * `*.secret`, логи capture/normalize, `.normalize-state`, `.normalize.lock`); існуючі
24
+ * рядки не перезаписуються.
21
25
  *
22
26
  * Опт-аут — `claude-config: false` у `.n-cursor.json`.
23
27
  */
@@ -51,6 +55,10 @@ const CURSOR_HOOKS_FILE = `${CURSOR_DIR}/hooks.json`
51
55
  const ADR_HOOK_SCRIPT_NAME = 'capture-decisions.sh'
52
56
  const ADR_NORMALIZE_HOOK_SCRIPT_NAME = 'normalize-decisions.sh'
53
57
  const TEMPLATE_DIR_NAME = '.claude-template'
58
+ /** Відносний шлях до канонічного фрагмента `.gitignore` для ADR Stop-hook'ів у tarball пакета. */
59
+ export const ADR_GITIGNORE_SNIPPET_REL = 'rules/adr/fix/hooks/template/.gitignore.snippet'
60
+ const GITIGNORE_FILE = '.gitignore'
61
+ const EOL_RE = /\r?\n/u
54
62
 
55
63
  /** Канонічна група hooks для ADR capture Stop-hook'а — додається в settings, коли `adr` у `rules`. */
56
64
  const ADR_STOP_HOOK_GROUP = Object.freeze({
@@ -387,6 +395,60 @@ export function syncAdrNormalizeHookScript(projectRoot, templateDir) {
387
395
  return syncHookScript(projectRoot, templateDir, ADR_NORMALIZE_HOOK_SCRIPT_NAME)
388
396
  }
389
397
 
398
+ /**
399
+ * Повертає змістовні (не коментар, не порожній) рядки з text-фрагмента `.gitignore`.
400
+ * @param {string} raw вміст snippet-файлу
401
+ * @returns {string[]} нормалізовані рядки патернів
402
+ */
403
+ function parseGitignoreFragmentLines(raw) {
404
+ return raw
405
+ .split(EOL_RE)
406
+ .map(l => l.trim())
407
+ .filter(l => l !== '' && !l.startsWith('#'))
408
+ }
409
+
410
+ /**
411
+ * Дописує в кореневий `.gitignore` проєкту відсутні рядки з канонічного ADR-фрагмента.
412
+ * @param {string} projectRoot корінь проєкту-споживача
413
+ * @param {string} bundledPackageRoot корінь установленого `@nitra/cursor`
414
+ * @returns {Promise<{ written: boolean, path: string }>} чи змінено файл і відносний шлях
415
+ */
416
+ export async function syncGitignoreAdrFragment(projectRoot, bundledPackageRoot) {
417
+ const snippetPath = join(bundledPackageRoot, ADR_GITIGNORE_SNIPPET_REL)
418
+ if (!existsSync(snippetPath)) {
419
+ return { written: false, path: '' }
420
+ }
421
+ const fragment = await readFile(snippetPath, 'utf8')
422
+ const required = parseGitignoreFragmentLines(fragment)
423
+ if (required.length === 0) {
424
+ return { written: false, path: '' }
425
+ }
426
+
427
+ const destPath = join(projectRoot, GITIGNORE_FILE)
428
+ const existing = existsSync(destPath) ? await readFile(destPath, 'utf8') : ''
429
+ const existingLines = new Set(
430
+ existing
431
+ .split(EOL_RE)
432
+ .map(l => l.trim())
433
+ .filter(l => l !== '' && !l.startsWith('#'))
434
+ )
435
+ const missing = required.filter(l => !existingLines.has(l))
436
+ if (missing.length === 0) {
437
+ return { written: false, path: GITIGNORE_FILE }
438
+ }
439
+
440
+ const sectionHeader = '# @nitra/cursor (adr) — локальні артефакти Stop-hook, не коміти'
441
+ const hasHeader = existing.split(EOL_RE).some(l => l.trim() === sectionHeader)
442
+ const block = hasHeader ? missing.join('\n') : [sectionHeader, ...missing].join('\n')
443
+ let prefix = ''
444
+ if (existing.length > 0) {
445
+ prefix = existing.endsWith('\n') ? existing : `${existing}\n`
446
+ }
447
+ const next = `${prefix}${block}\n`
448
+ await writeFile(destPath, next, 'utf8')
449
+ return { written: true, path: GITIGNORE_FILE }
450
+ }
451
+
390
452
  /**
391
453
  * Копіює всі slash-команди з `templateDir/commands/` у `.claude/commands/`.
392
454
  * Команди ідентифікуються тим, що вони лежать у темплейті — не перетинаються
@@ -422,7 +484,7 @@ export async function syncClaudeCommands(projectRoot, templateDir) {
422
484
  * @param {string} options.bundledPackageRoot корінь установленого `@nitra/cursor`
423
485
  * @param {boolean} options.enabled чи увімкнено sync (з `.n-cursor.json` `claude-config`)
424
486
  * @param {string[]} [options.rules] список увімкнених правил із `.n-cursor.json` — впливає на ADR Stop-hook (`adr`)
425
- * @returns {Promise<{ settings: boolean, cursorHooks: boolean, commands: string[], adrHook: boolean, adrNormalizeHook: boolean }>} прапорці записів settings/Cursor hooks/ADR-hook(s) та список записаних slash-команд
487
+ * @returns {Promise<{ settings: boolean, cursorHooks: boolean, commands: string[], adrHook: boolean, adrNormalizeHook: boolean, gitignoreAdr: boolean }>} прапорці записів settings/Cursor hooks/ADR-hook(s)/`.gitignore` та список slash-команд
426
488
  */
427
489
  export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enabled, rules = [] }) {
428
490
  if (!enabled) {
@@ -431,7 +493,8 @@ export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enable
431
493
  cursorHooks: false,
432
494
  commands: [],
433
495
  adrHook: false,
434
- adrNormalizeHook: false
496
+ adrNormalizeHook: false,
497
+ gitignoreAdr: false
435
498
  }
436
499
  }
437
500
  const templateDir = join(bundledPackageRoot, TEMPLATE_DIR_NAME)
@@ -441,7 +504,8 @@ export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enable
441
504
  cursorHooks: false,
442
505
  commands: [],
443
506
  adrHook: false,
444
- adrNormalizeHook: false
507
+ adrNormalizeHook: false,
508
+ gitignoreAdr: false
445
509
  }
446
510
  }
447
511
  const includeAdrHook = Array.isArray(rules) && rules.includes('adr')
@@ -449,6 +513,9 @@ export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enable
449
513
  const adrNormalizeHook = includeAdrHook
450
514
  ? await syncAdrNormalizeHookScript(projectRoot, templateDir)
451
515
  : { written: false, path: '' }
516
+ const gitignoreAdr = includeAdrHook
517
+ ? await syncGitignoreAdrFragment(projectRoot, bundledPackageRoot)
518
+ : { written: false, path: '' }
452
519
  const settings = await syncClaudeSettings(projectRoot, templateDir, { includeAdrHook })
453
520
  const cursorHooks = await syncCursorHooksConfig(projectRoot, { includeAdrHook })
454
521
  const commands = await syncClaudeCommands(projectRoot, templateDir)
@@ -457,6 +524,7 @@ export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enable
457
524
  cursorHooks: cursorHooks.written,
458
525
  commands,
459
526
  adrHook: adrHook.written,
460
- adrNormalizeHook: adrNormalizeHook.written
527
+ adrNormalizeHook: adrNormalizeHook.written,
528
+ gitignoreAdr: gitignoreAdr.written
461
529
  }
462
530
  }
@@ -44,13 +44,13 @@ bun run lint
44
44
 
45
45
  **Конфіги і коментарі, які потребують зупинки** (неповний список — будь-який аналог):
46
46
 
47
- | Інструмент | Типові файли / зміни |
48
- | --- | --- |
49
- | **jscpd** | `.jscpd.json` → `ignore`, `minLines` |
50
- | **cspell** | `.cspell.json` → `words`, `ignorePaths`; `.cspellignore` |
51
- | **knip** | `knip.json` → `ignore`, `ignoreDependencies`, `ignoreBinaries`, `entry` |
47
+ | Інструмент | Типові файли / зміни |
48
+ | ------------------- | --------------------------------------------------------------------------------------------------------------- |
49
+ | **jscpd** | `.jscpd.json` → `ignore`, `minLines` |
50
+ | **cspell** | `.cspell.json` → `words`, `ignorePaths`; `.cspellignore` |
51
+ | **knip** | `knip.json` → `ignore`, `ignoreDependencies`, `ignoreBinaries`, `entry` |
52
52
  | **oxlint / ESLint** | `.oxlintrc.json` → `ignorePatterns`; `eslint.config.js` → `ignores`; `eslint-disable` / `oxlint-disable` у коді |
53
- | **інше** | `.v8rignore`, `.stylelintignore`, `.trufflehog-exclude`, розширення `ignores` у workflow-конфігах |
53
+ | **інше** | `.v8rignore`, `.stylelintignore`, `.trufflehog-exclude`, розширення `ignores` у workflow-конфігах |
54
54
 
55
55
  Політика узгоджена з **`.cursor/rules/`** (зокрема **n-js-lint**, **n-text**): виняток допустимий лише з **обґрунтованою** причиною, не як заміна рефакторингу для справжніх клонів / дублікатів.
56
56
 
@@ -64,12 +64,12 @@ bun run lint
64
64
 
65
65
  **Варіанти відповіді** (мінімум такі; `allow_multiple: false`):
66
66
 
67
- | id | label (українською) | Дія агента |
68
- | --- | --- | --- |
69
- | `refactor` | **Рефакторинг коду** — усунути дублікат / помилку в коді (рекомендовано за замовчуванням) | Рефакторинг; конфіг **не** чіпати |
70
- | `ignore-once` | **Точковий виняток у конфігу** — додати ignore/words/minLines з обґрунтуванням у коментарі PR/відповіді | Після вибору — мінімальна зміна конфігу + 1 речення **чому** це не рефакторинг |
71
- | `skip` | **Залишити як є** — не чіпати ні код, ні конфіг зараз | Не змінювати; у фінальному резюме — що лишилось червоним |
72
- | `explain` | **Потрібні деталі** — поясни варіанти глибше | Розгорнути порівняння refactor vs ignore; **знову** запитати той самий набір варіантів |
67
+ | id | label (українською) | Дія агента |
68
+ | ------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
69
+ | `refactor` | **Рефакторинг коду** — усунути дублікат / помилку в коді (рекомендовано за замовчуванням) | Рефакторинг; конфіг **не** чіпати |
70
+ | `ignore-once` | **Точковий виняток у конфігу** — додати ignore/words/minLines з обґрунтуванням у коментарі PR/відповіді | Після вибору — мінімальна зміна конфігу + 1 речення **чому** це не рефакторинг |
71
+ | `skip` | **Залишити як є** — не чіпати ні код, ні конфіг зараз | Не змінювати; у фінальному резюме — що лишилось червоним |
72
+ | `explain` | **Потрібні деталі** — поясни варіанти глибше | Розгорнути порівняння refactor vs ignore; **знову** запитати той самий набір варіантів |
73
73
 
74
74
  Якщо користувач обрав **`ignore-once`** — у відповіді після зміни зафіксуй: який ключ конфігу змінено, який glob/слово додано, чому рефакторинг був недоречний (генерований код, формальний шаблон, легітимний термін без перекладу тощо).
75
75