@nitra/cursor 1.8.188 → 1.8.191

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.
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env bash
2
+ # Stop hook: extract ADR/Runbook/Knowledge drafts from session transcript.
3
+ # Runs async. Recursion guard: env var prevents the inner LLM CLI from
4
+ # re-triggering this hook (the inner session inherits CAPTURE_DECISIONS_RUNNING=1).
5
+ #
6
+ # LLM CLI selection (first available wins):
7
+ # 1. claude — use `claude -p --model "$CAPTURE_DECISIONS_CLAUDE_MODEL"` (default: sonnet)
8
+ # 2. cursor-agent — use `cursor-agent -p --mode ask --model "$CAPTURE_DECISIONS_CURSOR_MODEL"`
9
+ # (default: claude-4.6-sonnet-medium)
10
+ # neither — exit 0 silently
11
+ #
12
+ # Bundled with @nitra/cursor; project copy is auto-synced by the `adr` rule.
13
+ set -euo pipefail
14
+
15
+ if [[ -n "${CAPTURE_DECISIONS_RUNNING:-}" ]]; then
16
+ exit 0
17
+ fi
18
+ export CAPTURE_DECISIONS_RUNNING=1
19
+
20
+ INPUT=$(cat)
21
+ TRANSCRIPT_PATH=$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty')
22
+ SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // "unknown"')
23
+
24
+ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
25
+ INBOX="$PROJECT_ROOT/docs/adr/_inbox"
26
+ LOG_DIR="$PROJECT_ROOT/.claude/hooks"
27
+ LOG="$LOG_DIR/capture-decisions.log"
28
+ mkdir -p "$INBOX" "$LOG_DIR"
29
+
30
+ log() { printf '%s %s\n' "$(date -Iseconds)" "$*" >> "$LOG"; }
31
+
32
+ log "fired: $SESSION_ID"
33
+
34
+ if [[ -z "$TRANSCRIPT_PATH" || ! -f "$TRANSCRIPT_PATH" ]]; then
35
+ log " → no transcript path"
36
+ exit 0
37
+ fi
38
+
39
+ # Extract role + text + thinking + tool_use names from JSONL transcript.
40
+ # We keep reasoning/decisions visible to the analyzer but drop large tool outputs.
41
+ TRANSCRIPT=$(jq -r '
42
+ select(.type == "user" or .type == "assistant")
43
+ | .message as $m
44
+ | ($m.role // .type) as $role
45
+ | ($m.content
46
+ | if type == "string" then .
47
+ else (
48
+ map(
49
+ if .type == "text" then .text
50
+ elif .type == "thinking" then "[thinking]\n" + (.thinking // "")
51
+ elif .type == "tool_use" then
52
+ "[tool: " + .name + "]" +
53
+ (if .input then
54
+ " " + (.input | tostring | .[0:300])
55
+ else "" end)
56
+ elif .type == "tool_result" then
57
+ "[tool_result] " + (
58
+ (.content
59
+ | if type == "string" then . else (map(select(.type=="text") | .text) | join(" ")) end
60
+ ) // "" | .[0:300]
61
+ )
62
+ else "" end
63
+ ) | map(select(length > 0)) | join("\n")
64
+ )
65
+ end) as $body
66
+ | select($body | length > 0)
67
+ | "[" + $role + "]\n" + $body
68
+ ' "$TRANSCRIPT_PATH" 2>/dev/null || true)
69
+
70
+ # Cap input size to keep latency/cost predictable.
71
+ MAX_CHARS=120000
72
+ if (( ${#TRANSCRIPT} > MAX_CHARS )); then
73
+ TRANSCRIPT="${TRANSCRIPT: -$MAX_CHARS}"
74
+ fi
75
+
76
+ if [[ -z "$TRANSCRIPT" ]]; then
77
+ exit 0
78
+ fi
79
+
80
+ PROMPT=$(cat <<'EOF'
81
+ You analyze a Claude Code session transcript and produce a durable knowledge artifact (ADR, Runbook, or Knowledge note) capturing what was done and why.
82
+
83
+ LANGUAGE: Write the ENTIRE output in Ukrainian. This applies to the title, all section content, prose, and rationale. Keep code identifiers, file paths, commands, and tool or library names in their original form (do not translate `walkDir`, `package.json`, `npm`, etc.) — only translate the natural-language prose around them. Section labels themselves stay in Ukrainian per the template below.
84
+
85
+ IMPORTANT: by "decision" we mean the design choice expressed in the session — even if the user pre-specified it in their brief. The user dictating the approach IS the decision; capture it, including the rationale they gave or that became apparent during implementation. Do NOT return NONE just because the user gave detailed instructions upfront.
86
+
87
+ OUTPUT RULES:
88
+ - Emit one or more markdown blocks in this exact shape (no preamble, no trailing prose):
89
+
90
+ ## [ADR|Runbook|Knowledge] <короткий заголовок українською>
91
+ **Контекст:** <яка проблема / ситуація це спричинила — 1-2 речення>
92
+ **Рішення/Процедура/Факт:** <що було зроблено — конкретно: змінені файли, введена семантика, кроки>
93
+ **Обґрунтування:** <чому саме такий підхід — з ТЗ користувача або міркувань асистента>
94
+ **Розглянуті альтернативи:** <перелік явно обговорених, або «не обговорювалися»>
95
+ **Зачіпає:** <файли, модулі, публічні API, конфіги>
96
+
97
+ WHEN TO PICK EACH TYPE:
98
+ - ADR: a design choice (library, schema, pattern, semantics of a field/API). Most substantive code work qualifies.
99
+ - Runbook: a procedure to operate, fix, deploy, or reproduce something.
100
+ - Knowledge: a non-obvious constraint, gotcha, or invariant uncovered (without a corresponding code change).
101
+
102
+ OUTPUT NONE ONLY IF the session is genuinely trivial:
103
+ - A single typo fix, comment edit, or lint cleanup with no design content
104
+ - A pure question/answer with no code change and no surprising fact
105
+ - An aborted/empty session
106
+
107
+ When in doubt, emit a block. Capturing too much is acceptable; missing real work is not.
108
+
109
+ TRANSCRIPT FOLLOWS:
110
+ ---
111
+ EOF
112
+ )
113
+
114
+ PROMPT_FULL=$(printf '%s\n%s\n' "$PROMPT" "$TRANSCRIPT")
115
+
116
+ CLAUDE_MODEL="${CAPTURE_DECISIONS_CLAUDE_MODEL:-sonnet}"
117
+ CURSOR_MODEL="${CAPTURE_DECISIONS_CURSOR_MODEL:-claude-4.6-sonnet-medium}"
118
+
119
+ if command -v claude >/dev/null 2>&1; then
120
+ log " → using claude CLI (model: $CLAUDE_MODEL)"
121
+ RESPONSE=$(printf '%s' "$PROMPT_FULL" | claude -p --model "$CLAUDE_MODEL" 2>>"$LOG" || true)
122
+ elif command -v cursor-agent >/dev/null 2>&1; then
123
+ log " → using cursor-agent CLI (model: $CURSOR_MODEL)"
124
+ RESPONSE=$(cursor-agent -p --mode ask --output-format text --model "$CURSOR_MODEL" -- "$PROMPT_FULL" 2>>"$LOG" || true)
125
+ else
126
+ log " → no LLM CLI found (claude/cursor-agent), skipping"
127
+ exit 0
128
+ fi
129
+
130
+ RESPONSE_TRIMMED=$(printf '%s' "$RESPONSE" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
131
+
132
+ log " → response length: ${#RESPONSE_TRIMMED}, first 200: ${RESPONSE_TRIMMED:0:200}"
133
+
134
+ if [[ -z "$RESPONSE_TRIMMED" ]]; then
135
+ log " → empty response from LLM CLI"
136
+ exit 0
137
+ fi
138
+ if [[ "$RESPONSE_TRIMMED" == "NONE" ]]; then
139
+ log " → NONE"
140
+ exit 0
141
+ fi
142
+ if ! printf '%s' "$RESPONSE_TRIMMED" | grep -q '^## '; then
143
+ log " → response missing '## ' header"
144
+ exit 0
145
+ fi
146
+
147
+ TS=$(date +%Y%m%d-%H%M%S)
148
+ OUT="$INBOX/$TS-${SESSION_ID:0:8}.md"
149
+ {
150
+ printf -- '---\nsession: %s\ncaptured: %s\ntranscript: %s\n---\n\n' \
151
+ "$SESSION_ID" "$(date -Iseconds)" "$TRANSCRIPT_PATH"
152
+ printf '%s\n' "$RESPONSE_TRIMMED"
153
+ } > "$OUT"
154
+ log "wrote: $OUT"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,37 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.8.191] - 2026-05-07
8
+
9
+ ### Added
10
+
11
+ - `check-npm-module.mjs`: перший заголовок **`## [version]`** у `npm/CHANGELOG.md` має збігатися з **`version`** у `npm/package.json` (найсвіжіший реліз зверху — Keep a Changelog); якщо є незакомічені зміни під **`npm/`**, `version` у робочому `npm/package.json` має відрізнятися від **`HEAD`** (інакше ризик дописати новий функціонал без bump).
12
+
13
+ ### Changed
14
+
15
+ - `npm-module` (mdc v1.9 → v1.10): розширено **«Build версія»** і **«CHANGELOG»** — чеклист для агента, заборона дописувати нові пункти в уже існуючу секцію релізу замість нового номера; впорядковано `CHANGELOG` (1.8.190 перед 1.8.189).
16
+
17
+ ## [1.8.190] - 2026-05-07
18
+
19
+ ### Added
20
+
21
+ - `js-run` (mdc v1.4 → v1.5): секція **`jsconfig.json`** — канонічний файл для backend-пакетів із каталогом **`src/`** (NodeNext, `include: ['src/**/*']`); для пакетів без `src/` вимога не діє.
22
+ - `check-js-run.mjs`: перевірка наявності та вмісту `jsconfig.json`, якщо в workspace-пакеті (без vite) є **`src/`**; тести у `check-js-run-fixture.test.mjs`.
23
+
24
+ ## [1.8.189] - 2026-05-07
25
+
26
+ ### Added
27
+
28
+ - Нове правило `adr` (вмикається **вручну** через `.n-cursor.json` `rules`): автоматичне копіювання канонічного `.claude/hooks/capture-decisions.sh` з пакета та керована Stop-група у `.claude/settings.json`, яка викликає скрипт асинхронно (`async: true`, timeout `180`s). Скрипт зчитує JSONL-транскрипт сесії, передає дайджест у LLM CLI і пише чернетки ADR/Runbook/Knowledge у `docs/adr/_inbox/`.
29
+ - `capture-decisions.sh`: fallback `claude` → `cursor-agent` (LLM CLI). Якщо `claude` відсутній, береться `cursor-agent -p --mode ask --output-format text`. Моделі задаються через ENV `CAPTURE_DECISIONS_CLAUDE_MODEL` (default `sonnet`) і `CAPTURE_DECISIONS_CURSOR_MODEL` (default `claude-4.6-sonnet-medium`).
30
+ - `check-adr.mjs`: програмна перевірка наявності та канонічності `.claude/hooks/capture-decisions.sh`, ADR-групи у `.claude/settings.json`, відсутності дубля у `.claude/settings.local.json`, ігнорування `.claude/hooks/capture-decisions.log` у `.gitignore`, інформативно — наявність бодай одного LLM CLI (`claude`/`cursor-agent`) у `PATH`.
31
+ - `tests/check-adr.test.mjs` (7 кейсів) і нові кейси у `tests/sync-claude-config.test.mjs`: copy + Stop-merge + ідемпотентність + автоматичне видалення managed-групи при видаленні `adr` з `rules`.
32
+
33
+ ### Changed
34
+
35
+ - `sync-claude-config.mjs`: `MANAGED_HOOK_COMMAND_MARKERS` (масив) замість одиничного маркера; `mergeSettings(existing, template, { includeAdrHook })`; `syncClaudeConfig` приймає `rules` і умовно копіює ADR Stop-hook script + додає managed-групу до Stop. `syncClaudeConfig` повертає додатковий прапорець `adrHook`.
36
+ - `bin/n-cursor.js`: передає `rules` у `syncClaudeConfig` і логує `.claude/hooks/capture-decisions.sh` у підсумку Claude-конфіга.
37
+
7
38
  ## [1.8.188] - 2026-05-07
8
39
 
9
40
  ### Changed
package/bin/n-cursor.js CHANGED
@@ -1177,7 +1177,8 @@ async function runSync() {
1177
1177
  const result = await syncClaudeConfig({
1178
1178
  projectRoot: cwd(),
1179
1179
  bundledPackageRoot: effectivePackageRoot,
1180
- enabled: claudeConfigEnabled
1180
+ enabled: claudeConfigEnabled,
1181
+ rules
1181
1182
  })
1182
1183
  if (!claudeConfigEnabled) {
1183
1184
  console.log('🤖 Claude-конфіг: пропущено (claude-config: false у .n-cursor.json)')
@@ -1187,6 +1188,7 @@ async function runSync() {
1187
1188
  if (result.settings) parts.push('.claude/settings.json')
1188
1189
  if (result.npmClaudeMd) parts.push('npm/CLAUDE.md')
1189
1190
  if (result.commands.length > 0) parts.push(`${result.commands.length} slash-commands`)
1191
+ if (result.adrHook) parts.push('.claude/hooks/capture-decisions.sh')
1190
1192
  if (parts.length > 0) {
1191
1193
  console.log(`🤖 Claude-конфіг: ${parts.join(', ')}`)
1192
1194
  }
package/mdc/adr.mdc ADDED
@@ -0,0 +1,86 @@
1
+ ---
2
+ description: Автоматичний збір ADR/Runbook/Knowledge-чернеток із Stop-хука Claude Code (capture-decisions)
3
+ alwaysApply: true
4
+ version: '1.0'
5
+ ---
6
+
7
+ Правило вмикається **вручну** — додай `"adr"` у масив `rules` файлу `.n-cursor.json`. У `auto-rules.md` його немає, бо корисність залежить від робочого процесу команди (не кожен проєкт хоче літати ADR-чернеткам у `docs/`).
8
+
9
+ Коли правило увімкнене, **`npx @nitra/cursor`** автоматично:
10
+
11
+ - копіює канонічний bash-скрипт у **`.claude/hooks/capture-decisions.sh`** (executable, повністю керується пакетом — на кожен sync перезаписується);
12
+ - додає managed-групу у **`hooks.Stop`** в **`.claude/settings.json`**, яка викликає цей скрипт асинхронно (`async: true`, `timeout: 180`);
13
+ - ці зміни — додаткові до lint Stop-hook (`@nitra/cursor stop-hook`); обидві групи живуть поряд у `Stop`.
14
+
15
+ ## Що робить механізм
16
+
17
+ Stop-hook Claude Code зчитує JSONL-транскрипт сесії (через `jq`), витягає текст, `thinking`-блоки та назви `tool_use`-викликів, передає компактний дайджест у LLM CLI з промптом українською і записує результат у **`docs/adr/_inbox/<timestamp>-<session>.md`**, якщо модель повернула блок з шапкою `## ADR|Runbook|Knowledge …`. Якщо модель повернула `NONE` (тривіальна сесія) — нічого не пишеться. Рекурсію з внутрішнього виклику моделі блокує env-var `CAPTURE_DECISIONS_RUNNING=1`.
18
+
19
+ ## LLM CLI: claude → cursor-agent fallback
20
+
21
+ Скрипт сам обирає доступний CLI (порядок фіксований):
22
+
23
+ 1. **`claude`** (Anthropic Claude Code CLI) — `claude -p --model "$CAPTURE_DECISIONS_CLAUDE_MODEL"` (default `sonnet`).
24
+ 2. **`cursor-agent`** (Cursor IDE CLI) — `cursor-agent -p --mode ask --output-format text --model "$CAPTURE_DECISIONS_CURSOR_MODEL"` (default `claude-4.6-sonnet-medium`).
25
+ 3. Жодного CLI у `PATH` — скрипт виходить з кодом `0` і нічого не пише.
26
+
27
+ `--mode ask` для cursor-agent навмисно: режим Q&A read-only, без shell/edit-інструментів — для класифікації сесії інструменти не потрібні. Моделі можна перевизначити через ENV: `CAPTURE_DECISIONS_CLAUDE_MODEL`, `CAPTURE_DECISIONS_CURSOR_MODEL`.
28
+
29
+ ## Структура каталогу
30
+
31
+ ```text
32
+ docs/adr/
33
+ └── _inbox/ # чернетки, що пишуться Stop-хуком
34
+ └── YYYYMMDD-HHMMSS-<sid>.md # session_id (перші 8 символів)
35
+ .claude/hooks/
36
+ ├── capture-decisions.sh # auto-synced з пакета
37
+ └── capture-decisions.log # лог запусків (НЕ коміти)
38
+ ```
39
+
40
+ `.gitignore` повинен містити рядок **`.claude/hooks/capture-decisions.log`**, щоб лог не потрапляв у git.
41
+
42
+ `docs/adr/_inbox/` — це робоча скринька; чернетки звідти переносяться у структуровані ADR-файли вручну (під час оглядів) або тримаються як архів сесій. Каталог сам створюється скриптом, тому пустим у git тримати не потрібно.
43
+
44
+ ## Stop-hook у `.claude/settings.json`
45
+
46
+ Канонічний запис, який вставляє sync (поряд із lint stop-hook):
47
+
48
+ ```json title=".claude/settings.json"
49
+ {
50
+ "hooks": {
51
+ "Stop": [
52
+ {
53
+ "matcher": "",
54
+ "hooks": [
55
+ {
56
+ "type": "command",
57
+ "command": "npx --no @nitra/cursor stop-hook",
58
+ "timeout": 60
59
+ }
60
+ ]
61
+ },
62
+ {
63
+ "matcher": "",
64
+ "hooks": [
65
+ {
66
+ "type": "command",
67
+ "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/capture-decisions.sh\"",
68
+ "async": true,
69
+ "timeout": 180
70
+ }
71
+ ]
72
+ }
73
+ ]
74
+ }
75
+ }
76
+ ```
77
+
78
+ Обидві групи ідентифікуються пакетом за маркером у `command` (`@nitra/cursor stop-hook` і `.claude/hooks/capture-decisions.sh`) — користувацькі hook-групи поряд не чіпаються. Якщо `adr` прибрати з `rules`, ADR-група автоматично видаляється на наступному `npx @nitra/cursor`.
79
+
80
+ ## Локальні vs project-shared налаштування
81
+
82
+ Stop-hook ADR живе у **project-shared** `.claude/settings.json` (закомічений), щоб механізм працював у всіх членів команди. Якщо хук колись був у `.claude/settings.local.json` — прибери дубль вручну: project-shared і local-копія створили б два запуски на одну подію.
83
+
84
+ ## Перевірка
85
+
86
+ `npx @nitra/cursor check adr`
package/mdc/js-run.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Це правила для backend проектів на JavaScript/Node.js, сюди входять і job і WEB сервери.
3
3
  alwaysApply: true
4
- version: '1.4'
4
+ version: '1.5'
5
5
  ---
6
6
 
7
7
  ## Область застосування
@@ -28,6 +28,25 @@ package.json
28
28
  readme.md
29
29
  ```
30
30
 
31
+ ## `jsconfig.json` (редактор / перевірка типів)
32
+
33
+ Якщо в **backend** workspace-пакеті (без `vite` у `devDependencies`) є каталог **`src/`**, у **корені цього пакета** має бути **`jsconfig.json`**. Якщо файлу ще немає — створи його з таким вмістом (канон js-run):
34
+
35
+ ```json title="jsconfig.json"
36
+ {
37
+ "compilerOptions": {
38
+ "lib": ["esnext"],
39
+ "module": "NodeNext",
40
+ "moduleResolution": "NodeNext",
41
+ "target": "esnext",
42
+ "checkJs": false
43
+ },
44
+ "include": ["src/**/*"]
45
+ }
46
+ ```
47
+
48
+ Якщо пакет не слідує структурі з `src/` (наприклад, лише `scripts/` у корені) — ця вимога не застосовується; для типових сервісів із `src/` файл обов’язковий і має збігатися з каноном.
49
+
31
50
  ## Використання @nitra/pino
32
51
 
33
52
  Проект використовує @nitra/pino для логування.
@@ -170,4 +189,4 @@ on:
170
189
 
171
190
  ## Перевірка
172
191
 
173
- `npx @nitra/cursor check js-run`
192
+ `npx @nitra/cursor check js-run` — зокрема для кожного backend workspace-пакета з каталогом **`src/`** перевіряє наявність **`jsconfig.json`** і збіг вмісту з каноном вище.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Оформлення репозиторію для npm модуля
3
3
  alwaysApply: true
4
- version: '1.9'
4
+ version: '1.10'
5
5
  ---
6
6
 
7
7
  Bun monorepo: workspace **`npm/`**, кореневий **`package.json`**, **`.github/workflows/`**; опційно **`demo/`**.
@@ -47,12 +47,18 @@ bunx -p typescript tsc src/**/*.js --declaration --allowJs --emitDeclarationOnly
47
47
 
48
48
  У робочій копії не повинно бути більше одного незбереженого в **git** підвищення **build**-версії за раз.
49
49
 
50
+ **Чеклист у тому ж наборі змін, що й правки під `npm/`:** `version` у **`npm/package.json`** → **+1**; зверху **`npm/CHANGELOG.md`** нова секція **`## [нова версія] - …`**; у секції лише те, що входить у цей реліз.
51
+
52
+ **Антипатерн:** не дописувати нові bullet-и в уже існуючу секцію **`## [X.Y.Z]`**, якщо паралельно не піднімаєш **`version`** до нового номера й не створюєш **нову** секцію зверху. Інакше змішуються різні релізи в одному номері, а `check npm-module` / `check changelog` гірше ловлять порушення.
53
+
50
54
  **Підказка:** щоб не дублювати bump і бачити різницю зі збереженим деревом, перевір `git status npm/package.json` або `git diff HEAD -- npm/package.json` перед другим підвищенням у тій самій гілці / наборі змін.
51
55
 
52
56
  ## CHANGELOG
53
57
 
54
58
  Окреме правило **`changelog`** ([changelog.mdc](changelog.mdc)) вимагає `npm/CHANGELOG.md` із записом для поточної версії (Keep a Changelog) і присутність `"CHANGELOG.md"` у масиві `files` у `npm/package.json`. Логіка — PR-scoped (сума по гілці vs `dev`).
55
59
 
60
+ Найновіша версія — **перша** секція **`## [version]`** у файлі (зверху після заголовка). Вона **має збігатися** з полем **`version`** у **`npm/package.json`** — це перевіряє **`npx @nitra/cursor check npm-module`**.
61
+
56
62
  ## npm publish
57
63
 
58
64
  **`npm-publish.yml`:** push у **`main`**, **`on.push.paths`** з **`npm/**`**, **`JS-DevTools/npm-publish@v4.1.5`**, **`with.package: npm/package.json`**, **`permissions.id-token: write`** (OIDC на npm).
@@ -96,4 +102,4 @@ jobs:
96
102
 
97
103
  ## Перевірка
98
104
 
99
- `npx @nitra/cursor check npm-module`
105
+ `npx @nitra/cursor check npm-module` — зокрема узгодженість першої секції **`npm/CHANGELOG.md`** з **`version`** у **`npm/package.json`** і нагадування про bump при незакомічених змінах під **`npm/`** (через `git`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.188",
3
+ "version": "1.8.191",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Перевіряє вимоги правила adr.mdc: ADR Stop-hook capture-decisions.sh у Claude Code.
3
+ *
4
+ * Очікування:
5
+ * - `.claude/hooks/capture-decisions.sh` існує і байт-у-байт збігається з канонічним
6
+ * `.claude-template/hooks/capture-decisions.sh` пакета (sync керує файлом повністю).
7
+ * - `.claude/settings.json` (project-shared) має managed-групу у `hooks.Stop`, яка
8
+ * викликає цей bash-скрипт; маркер у `command` — `.claude/hooks/capture-decisions.sh`.
9
+ * - `.claude/settings.local.json` (якщо існує) НЕ має дубля цієї managed-групи —
10
+ * після переходу на project-shared такий запис створив би два запуски на одну подію.
11
+ * - `.gitignore` у корені містить шаблон, який покриває `.claude/hooks/capture-decisions.log`.
12
+ *
13
+ * LLM CLI (`claude` або `cursor-agent`) у `PATH` — інформативна перевірка: якщо жодного
14
+ * немає, скрипт працює, але мовчки виходить, тому це warning, а не fail.
15
+ */
16
+ import { existsSync } from 'node:fs'
17
+ import { readFile } from 'node:fs/promises'
18
+ import { delimiter, dirname, join } from 'node:path'
19
+ import { env } from 'node:process'
20
+ import { fileURLToPath } from 'node:url'
21
+
22
+ import { createCheckReporter } from './utils/check-reporter.mjs'
23
+
24
+ const PROJECT_HOOK_PATH = '.claude/hooks/capture-decisions.sh'
25
+ const PROJECT_SETTINGS_PATH = '.claude/settings.json'
26
+ const PROJECT_LOCAL_SETTINGS_PATH = '.claude/settings.local.json'
27
+ const PROJECT_LOG_PATH = '.claude/hooks/capture-decisions.log'
28
+ const HOOK_COMMAND_MARKER = '.claude/hooks/capture-decisions.sh'
29
+
30
+ const here = dirname(fileURLToPath(import.meta.url))
31
+ /** Канонічний bundled-скрипт у пакеті — джерело правди для звірки з проєктним. */
32
+ const BUNDLED_HOOK_PATH = join(here, '..', '.claude-template', 'hooks', 'capture-decisions.sh')
33
+
34
+ /**
35
+ * Чи містить рядок `.gitignore` шаблон, який покриває `.claude/hooks/capture-decisions.log`.
36
+ * Враховує точний шлях, glob `.claude/hooks/*.log` та широкий glob `**\/*.log`.
37
+ * @param {string} line одна нормалізована (trim) лінія `.gitignore`
38
+ * @returns {boolean} `true`, якщо лінія матчить лог-файл хука
39
+ */
40
+ function gitignoreLineCoversHookLog(line) {
41
+ if (!line || line.startsWith('#')) {
42
+ return false
43
+ }
44
+ if (line === PROJECT_LOG_PATH) {
45
+ return true
46
+ }
47
+ if (line === '.claude/hooks/*.log' || line === '.claude/hooks/**/*.log') {
48
+ return true
49
+ }
50
+ if (line === '*.log' || line === '**/*.log') {
51
+ return true
52
+ }
53
+ return false
54
+ }
55
+
56
+ /**
57
+ * Перевіряє наявність і канонічність `.claude/hooks/capture-decisions.sh` у проєкті.
58
+ * @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
59
+ * @returns {Promise<void>}
60
+ */
61
+ async function checkHookScript(reporter) {
62
+ const { pass, fail } = reporter
63
+ if (!existsSync(PROJECT_HOOK_PATH)) {
64
+ fail(`${PROJECT_HOOK_PATH} не існує — запусти \`npx @nitra/cursor\` (правило adr копіює канонічний скрипт)`)
65
+ return
66
+ }
67
+ if (!existsSync(BUNDLED_HOOK_PATH)) {
68
+ fail(`канонічний скрипт у пакеті не знайдено: ${BUNDLED_HOOK_PATH} — перевстанови @nitra/cursor`)
69
+ return
70
+ }
71
+ const [project, bundled] = await Promise.all([readFile(PROJECT_HOOK_PATH, 'utf8'), readFile(BUNDLED_HOOK_PATH, 'utf8')])
72
+ if (project === bundled) {
73
+ pass(`${PROJECT_HOOK_PATH} збігається з канонічним`)
74
+ } else {
75
+ fail(`${PROJECT_HOOK_PATH} відрізняється від канонічного — запусти \`npx @nitra/cursor\` для повторного синку`)
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Знаходить у `hooks.Stop` групу, де `command` будь-якого hook-а містить маркер.
81
+ * @param {unknown} settings розпарсений `.claude/settings.json`
82
+ * @returns {boolean} `true`, якщо знайдено хоч одну групу з маркером
83
+ */
84
+ function settingsHaveAdrHookGroup(settings) {
85
+ if (!settings || typeof settings !== 'object') {
86
+ return false
87
+ }
88
+ const hooks = /** @type {Record<string, unknown>} */ (settings).hooks
89
+ if (!hooks || typeof hooks !== 'object') {
90
+ return false
91
+ }
92
+ const stopGroups = /** @type {Record<string, unknown>} */ (hooks).Stop
93
+ if (!Array.isArray(stopGroups)) {
94
+ return false
95
+ }
96
+ return stopGroups.some(group => {
97
+ const inner = group && typeof group === 'object' ? /** @type {Record<string, unknown>} */ (group).hooks : null
98
+ if (!Array.isArray(inner)) {
99
+ return false
100
+ }
101
+ return inner.some(h => {
102
+ const cmd = h && typeof h === 'object' ? /** @type {Record<string, unknown>} */ (h).command : null
103
+ return typeof cmd === 'string' && cmd.includes(HOOK_COMMAND_MARKER)
104
+ })
105
+ })
106
+ }
107
+
108
+ /**
109
+ * Зчитує JSON-файл або повертає `undefined`, якщо файл відсутній чи невалідний.
110
+ * @param {string} path відносний шлях до JSON-файлу
111
+ * @returns {Promise<unknown | undefined>} розпарсений вміст або `undefined`
112
+ */
113
+ async function readJsonOrUndefined(path) {
114
+ if (!existsSync(path)) {
115
+ return
116
+ }
117
+ try {
118
+ return JSON.parse(await readFile(path, 'utf8'))
119
+ } catch {
120
+ return
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Перевіряє project-shared `.claude/settings.json` на наявність ADR Stop-hook'а.
126
+ * @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
127
+ * @returns {Promise<void>}
128
+ */
129
+ async function checkProjectSettings(reporter) {
130
+ const { pass, fail } = reporter
131
+ const settings = await readJsonOrUndefined(PROJECT_SETTINGS_PATH)
132
+ if (settings === undefined) {
133
+ fail(`${PROJECT_SETTINGS_PATH} не існує або невалідний — запусти \`npx @nitra/cursor\``)
134
+ return
135
+ }
136
+ if (settingsHaveAdrHookGroup(settings)) {
137
+ pass(`${PROJECT_SETTINGS_PATH} містить ADR Stop-hook (capture-decisions.sh)`)
138
+ } else {
139
+ fail(
140
+ `${PROJECT_SETTINGS_PATH}: у hooks.Stop немає групи з \`${HOOK_COMMAND_MARKER}\` — переконайся, що "adr" у rules і запусти \`npx @nitra/cursor\``
141
+ )
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Перевіряє, що `.claude/settings.local.json` не дублює ADR Stop-hook (project-shared — джерело правди).
147
+ * @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
148
+ * @returns {Promise<void>}
149
+ */
150
+ async function checkLocalSettingsNoDuplicate(reporter) {
151
+ const { pass, fail } = reporter
152
+ if (!existsSync(PROJECT_LOCAL_SETTINGS_PATH)) {
153
+ pass(`${PROJECT_LOCAL_SETTINGS_PATH} відсутній — дубля немає`)
154
+ return
155
+ }
156
+ const local = await readJsonOrUndefined(PROJECT_LOCAL_SETTINGS_PATH)
157
+ if (local === undefined) {
158
+ pass(`${PROJECT_LOCAL_SETTINGS_PATH} нечитабельний — дубля немає`)
159
+ return
160
+ }
161
+ if (settingsHaveAdrHookGroup(local)) {
162
+ fail(
163
+ `${PROJECT_LOCAL_SETTINGS_PATH} містить дубль ADR Stop-hook (capture-decisions.sh) — прибери, бо project-shared settings.json уже керує цим`
164
+ )
165
+ } else {
166
+ pass(`${PROJECT_LOCAL_SETTINGS_PATH} не дублює ADR Stop-hook`)
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Перевіряє `.gitignore` на ігнорування лог-файлу хука.
172
+ * @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
173
+ * @returns {Promise<void>}
174
+ */
175
+ async function checkGitignore(reporter) {
176
+ const { pass, fail } = reporter
177
+ if (!existsSync('.gitignore')) {
178
+ fail(`.gitignore не існує — додай рядок \`${PROJECT_LOG_PATH}\``)
179
+ return
180
+ }
181
+ const content = await readFile('.gitignore', 'utf8')
182
+ const covers = content
183
+ .split(/\r?\n/u)
184
+ .map(l => l.trim())
185
+ .some(gitignoreLineCoversHookLog)
186
+ if (covers) {
187
+ pass(`.gitignore покриває ${PROJECT_LOG_PATH}`)
188
+ } else {
189
+ fail(`.gitignore не ігнорує \`${PROJECT_LOG_PATH}\` — додай цей рядок`)
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Чи виконуваний бінарник з іменем `name` доступний у `PATH` поточного процесу.
195
+ * Перевірка без spawn — просто шукаємо файл у каталогах PATH (як `which`).
196
+ * @param {string} name ім'я бінарника без розширення
197
+ * @returns {boolean} `true`, якщо знайдено в одному з каталогів `PATH`
198
+ */
199
+ function isBinaryInPath(name) {
200
+ const path = env.PATH ?? ''
201
+ if (!path) {
202
+ return false
203
+ }
204
+ for (const dir of path.split(delimiter)) {
205
+ if (!dir) continue
206
+ if (existsSync(join(dir, name))) {
207
+ return true
208
+ }
209
+ }
210
+ return false
211
+ }
212
+
213
+ /**
214
+ * Інформативна перевірка: чи доступний бодай один LLM CLI (`claude` або `cursor-agent`).
215
+ * Якщо жодного немає — це warning (`pass` з підказкою), бо хук просто мовчки no-op'ає.
216
+ * @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
217
+ * @returns {void}
218
+ */
219
+ function checkLlmCliAvailable(reporter) {
220
+ const { pass } = reporter
221
+ const hasClaude = isBinaryInPath('claude')
222
+ const hasCursor = isBinaryInPath('cursor-agent')
223
+ if (hasClaude && hasCursor) {
224
+ pass('LLM CLI: знайдено `claude` і `cursor-agent`')
225
+ } else if (hasClaude) {
226
+ pass('LLM CLI: знайдено `claude` (cursor-agent відсутній — fallback не використовується)')
227
+ } else if (hasCursor) {
228
+ pass('LLM CLI: знайдено `cursor-agent` (claude відсутній — буде використано fallback)')
229
+ } else {
230
+ pass(
231
+ 'LLM CLI: жодного з `claude`/`cursor-agent` не знайдено у PATH — Stop-hook буде мовчки no-op до встановлення CLI'
232
+ )
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Перевіряє відповідність проєкту правилам adr.mdc.
238
+ * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
239
+ */
240
+ export async function check() {
241
+ const reporter = createCheckReporter()
242
+ await checkHookScript(reporter)
243
+ await checkProjectSettings(reporter)
244
+ await checkLocalSettingsNoDuplicate(reporter)
245
+ await checkGitignore(reporter)
246
+ checkLlmCliAvailable(reporter)
247
+ return reporter.getExitCode()
248
+ }
@@ -24,9 +24,11 @@
24
24
  * `working-directory: <rootDir>` (див. `utils/depcheck-workflow.mjs`);
25
25
  * - «Паузи через setTimeout»: `new Promise(resolve => setTimeout(resolve, ms))` (з/без `await`)
26
26
  * треба замінити на `await setTimeout(ms)` з `node:timers/promises`
27
- * (див. `utils/promise-settimeout-scan.mjs`).
27
+ * (див. `utils/promise-settimeout-scan.mjs`);
28
+ * - «jsconfig.json»: у backend-пакеті з каталогом `src/` у корені має бути `jsconfig.json`,
29
+ * вміст якого збігається з каноном js-run.mdc (NodeNext і include на дерево `src`).
28
30
  */
29
- import { existsSync } from 'node:fs'
31
+ import { existsSync, statSync } from 'node:fs'
30
32
  import { readFile } from 'node:fs/promises'
31
33
  import { join, relative } from 'node:path'
32
34
 
@@ -52,6 +54,99 @@ import {
52
54
  import { walkDir } from './utils/walkDir.mjs'
53
55
  import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
54
56
 
57
+ /** Канонічний `jsconfig.json` для backend workspace-пакетів із каталогом `src/` (js-run.mdc). */
58
+ const CANONICAL_BACKEND_JSCONFIG = Object.freeze({
59
+ compilerOptions: Object.freeze({
60
+ lib: Object.freeze(['esnext']),
61
+ module: 'NodeNext',
62
+ moduleResolution: 'NodeNext',
63
+ target: 'esnext',
64
+ checkJs: false
65
+ }),
66
+ include: Object.freeze(['src/**/*'])
67
+ })
68
+
69
+ /**
70
+ * Глибока рівність для JSON-подібних значень (масиви — порядок важливий).
71
+ * @param {unknown} a
72
+ * @param {unknown} b
73
+ * @returns {boolean}
74
+ */
75
+ function deepEqualJson(a, b) {
76
+ if (a === b) return true
77
+ if (a === null || b === null || typeof a !== typeof b) return false
78
+ if (typeof a !== 'object') return false
79
+ if (Array.isArray(a) !== Array.isArray(b)) return false
80
+ if (Array.isArray(a)) {
81
+ if (a.length !== b.length) return false
82
+ for (const [i, v] of a.entries()) {
83
+ if (!deepEqualJson(v, b[i])) return false
84
+ }
85
+ return true
86
+ }
87
+ const ao = /** @type {Record<string, unknown>} */ (a)
88
+ const bo = /** @type {Record<string, unknown>} */ (b)
89
+ const keysA = Object.keys(ao).sort()
90
+ const keysB = Object.keys(bo).sort()
91
+ if (keysA.length !== keysB.length) return false
92
+ for (const [i, k] of keysA.entries()) {
93
+ if (k !== keysB[i]) return false
94
+ if (!deepEqualJson(ao[k], bo[k])) return false
95
+ }
96
+ return true
97
+ }
98
+
99
+ /**
100
+ * Чи існує непорожній за змістом маркер каталогу `src/` (рекомендована структура js-run).
101
+ * @param {string} absPackageRoot абсолютний корінь пакета
102
+ * @returns {boolean}
103
+ */
104
+ function backendPackageHasSrcDir(absPackageRoot) {
105
+ const srcPath = join(absPackageRoot, 'src')
106
+ try {
107
+ return statSync(srcPath).isDirectory()
108
+ } catch {
109
+ return false
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Перевіряє `jsconfig.json` для backend-пакетів із `src/`.
115
+ * @param {string} rootDir відносний шлях workspace
116
+ * @param {string} absPackageRoot абсолютний корінь пакета
117
+ * @param {string} label префікс `[pkg] `
118
+ * @param {(msg: string) => void} fail
119
+ * @param {(msg: string) => void} passFn
120
+ * @returns {Promise<void>}
121
+ */
122
+ async function checkBackendJsconfigWhenSrcPresent(rootDir, absPackageRoot, label, fail, passFn) {
123
+ if (!backendPackageHasSrcDir(absPackageRoot)) return
124
+
125
+ const jcPath = join(rootDir, 'jsconfig.json')
126
+ if (!existsSync(jcPath)) {
127
+ fail(
128
+ `${label}є каталог src/, але немає jsconfig.json — додай канонічний файл з js-run.mdc ` +
129
+ `(NodeNext, include: src/**/*).`
130
+ )
131
+ return
132
+ }
133
+ let parsed
134
+ try {
135
+ parsed = JSON.parse(await readFile(jcPath, 'utf8'))
136
+ } catch {
137
+ fail(`${label}jsconfig.json не вдалося розпарсити як JSON`)
138
+ return
139
+ }
140
+ if (!deepEqualJson(parsed, CANONICAL_BACKEND_JSCONFIG)) {
141
+ fail(
142
+ `${label}jsconfig.json не збігається з каноном js-run.mdc — заміни на шаблон з правила ` +
143
+ `(compilerOptions: lib esnext, module/moduleResolution NodeNext, target esnext, checkJs false; include: src/**/*).`
144
+ )
145
+ return
146
+ }
147
+ passFn(`${label}jsconfig.json узгоджено з js-run (пакет з src/)`)
148
+ }
149
+
55
150
  /**
56
151
  * Перетворює абсолютний шлях у posix-формі відносно кореня пакета.
57
152
  * @param {string} absPackageRoot абсолютний корінь пакета
@@ -216,6 +311,8 @@ async function checkWorkspacePackage(rootDir, ignorePaths, workflows, fail, pass
216
311
  return
217
312
  }
218
313
 
314
+ await checkBackendJsconfigWhenSrcPresent(rootDir, absPackageRoot, label, fail, passFn)
315
+
219
316
  const importViolations = await checkBunyanImports(absPackageRoot, ignorePaths, label, fail)
220
317
  if (importViolations === 0) {
221
318
  passFn(`${label}немає імпортів '@nitra/bunyan' / 'bunyan' у джерелах`)
@@ -10,10 +10,16 @@
10
10
  * файл під `./types/…`, у hk — `tsc -p tsconfig.emit-types.json`, у JSON-конфігу — потрібні compilerOptions для emit.
11
11
  *
12
12
  * Поля workflow перевіряються після **YAML parse**, щоб не плутати з коментарями.
13
+ *
14
+ * Версія та CHANGELOG: перший заголовок `## [version]` у `npm/CHANGELOG.md` має збігатися з `version` у
15
+ * `npm/package.json` (найсвіжіший реліз зверху). Якщо в git є незакомічені зміни під `npm/`, `version` у робочому
16
+ * файлі має відрізнятися від `HEAD` — інакше типовий пропуск bump після правок у пакеті.
13
17
  */
18
+ import { execFile } from 'node:child_process'
14
19
  import { existsSync } from 'node:fs'
15
20
  import { readFile, stat } from 'node:fs/promises'
16
21
  import { join } from 'node:path'
22
+ import { promisify } from 'node:util'
17
23
 
18
24
  import { createCheckReporter } from './utils/check-reporter.mjs'
19
25
  import {
@@ -26,8 +32,13 @@ import {
26
32
  import { loadCursorIgnorePaths } from './utils/load-cursor-config.mjs'
27
33
  import { walkDir } from './utils/walkDir.mjs'
28
34
 
35
+ const execFileAsync = promisify(execFile)
36
+
29
37
  const TYPES_FILE_RE = /^\.\/types\/.+\.d\.(ts|mts)$/
30
38
 
39
+ /** Перший заголовок релізу у Keep a Changelog (`## [1.2.3]`). */
40
+ const CHANGELOG_FIRST_VERSION_RE = /^## \[([^\]]+)\]/m
41
+
31
42
  /** Канонічний entrypoint типів для пакетів із вихідним `.js` під каталогом `npm/src` */
32
43
  const TYPES_INDEX = './types/index.d.ts'
33
44
 
@@ -233,6 +244,122 @@ async function checkEmitTypesConfig(passFn, failFn) {
233
244
  * @param {(msg: string) => void} passFn callback при успішній перевірці
234
245
  * @param {(msg: string) => void} failFn callback при помилці
235
246
  */
247
+ /**
248
+ * Чи виконано `git` у корені робочого дерева.
249
+ * @returns {Promise<boolean>}
250
+ */
251
+ async function gitInsideWorkTree() {
252
+ try {
253
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { encoding: 'utf8' })
254
+ return stdout.trim() === 'true'
255
+ } catch {
256
+ return false
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Список незакомічених шляхів під `npm/` відносно `HEAD`.
262
+ * @returns {Promise<string[] | null>} шляхи або `null`, якщо `git` недоступний
263
+ */
264
+ async function gitDiffNameOnlyNpm() {
265
+ try {
266
+ const { stdout } = await execFileAsync('git', ['diff', '--name-only', 'HEAD', '--', 'npm'], { encoding: 'utf8' })
267
+ return stdout.trim().split('\n').filter(Boolean)
268
+ } catch {
269
+ return null
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Поле `version` з `npm/package.json` на заданому git-ref (`HEAD:npm/package.json`).
275
+ * @param {string} refPath на кшталт `HEAD:npm/package.json`
276
+ * @returns {Promise<string | null>}
277
+ */
278
+ async function gitShowNpmPackageVersionAt(refPath) {
279
+ try {
280
+ const { stdout } = await execFileAsync('git', ['show', refPath], { encoding: 'utf8' })
281
+ const m = stdout.match(/"version":\s*"([^"]+)"/)
282
+ return m ? m[1] : null
283
+ } catch {
284
+ return null
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Версія з першого заголовка `## […]` у тексті CHANGELOG.
290
+ * @param {string} changelogText
291
+ * @returns {string | null}
292
+ */
293
+ function firstChangelogSectionVersion(changelogText) {
294
+ const m = changelogText.match(CHANGELOG_FIRST_VERSION_RE)
295
+ return m ? m[1] : null
296
+ }
297
+
298
+ /**
299
+ * Перший реліз у CHANGELOG має збігатися з `version` у `npm/package.json`.
300
+ * @param {(msg: string) => void} passFn
301
+ * @param {(msg: string) => void} failFn
302
+ * @returns {Promise<void>}
303
+ */
304
+ async function checkChangelogTopMatchesPackageVersion(passFn, failFn) {
305
+ if (!existsSync('npm/CHANGELOG.md') || !existsSync('npm/package.json')) return
306
+ const pkg = JSON.parse(await readFile('npm/package.json', 'utf8'))
307
+ const ver = typeof pkg.version === 'string' ? pkg.version : null
308
+ if (!ver) {
309
+ failFn('npm/package.json: відсутнє поле version')
310
+ return
311
+ }
312
+ const cl = await readFile('npm/CHANGELOG.md', 'utf8')
313
+ const first = firstChangelogSectionVersion(cl)
314
+ if (!first) {
315
+ failFn('npm/CHANGELOG.md: не знайдено жодного заголовка ## [version]')
316
+ return
317
+ }
318
+ if (first !== ver) {
319
+ failFn(
320
+ `npm/CHANGELOG.md: перша секція [${first}] не збігається з npm/package.json version "${ver}" ` +
321
+ '(зверху має бути найсвіжіший реліз і той самий номер — npm-module.mdc).'
322
+ )
323
+ return
324
+ }
325
+ passFn(`npm/CHANGELOG.md: перша секція [${first}] збігається з npm/package.json`)
326
+ }
327
+
328
+ /**
329
+ * Незакомічені зміни під `npm/` вимагають підвищення `version` відносно `HEAD`.
330
+ * @param {(msg: string) => void} passFn
331
+ * @param {(msg: string) => void} failFn
332
+ * @returns {Promise<void>}
333
+ */
334
+ async function checkDirtyNpmRequiresVersionBump(passFn, failFn) {
335
+ if (!(await gitInsideWorkTree())) {
336
+ passFn('npm-module: git недоступний або поза work tree — перевірку незакоміченого bump пропущено')
337
+ return
338
+ }
339
+ const changed = await gitDiffNameOnlyNpm()
340
+ if (changed === null) {
341
+ passFn('npm-module: git diff під npm/ недоступний — пропущено')
342
+ return
343
+ }
344
+ if (changed.length === 0) return
345
+
346
+ const headVer = await gitShowNpmPackageVersionAt('HEAD:npm/package.json')
347
+ if (headVer === null) return
348
+
349
+ const pkg = JSON.parse(await readFile('npm/package.json', 'utf8'))
350
+ const cur = typeof pkg.version === 'string' ? pkg.version : null
351
+ if (!cur) return
352
+
353
+ if (cur === headVer) {
354
+ failFn(
355
+ `Незакомічені зміни під npm/ (${changed.join(', ')}), але "version" у npm/package.json лишився ${cur} ` +
356
+ '(як у HEAD). Підвищ version (+1) і додай секцію ## [нова версія] зверху CHANGELOG (npm-module.mdc).'
357
+ )
358
+ return
359
+ }
360
+ passFn(`npm/: незакомічені зміни під npm/ узгоджені з підвищенням version (${headVer} → ${cur})`)
361
+ }
362
+
236
363
  async function checkPublishWorkflow(passFn, failFn) {
237
364
  const publishWf = '.github/workflows/npm-publish.yml'
238
365
  if (!existsSync(publishWf)) {
@@ -371,5 +498,8 @@ export async function check() {
371
498
 
372
499
  await checkPublishWorkflow(pass, fail)
373
500
 
501
+ await checkChangelogTopMatchesPackageVersion(pass, fail)
502
+ await checkDirtyNpmRequiresVersionBump(pass, fail)
503
+
374
504
  return reporter.getExitCode()
375
505
  }
@@ -1,31 +1,54 @@
1
1
  /**
2
2
  * Синхронізує конфігурацію Claude Code (`.claude/settings.json`, `npm/CLAUDE.md`,
3
- * slash-команди для checks) у поточний проєкт із темплейтів пакету
3
+ * slash-команди для checks, ADR Stop-hook) у поточний проєкт із темплейтів пакету
4
4
  * `npm/.claude-template/`.
5
5
  *
6
6
  * Архітектура:
7
7
  * - `settings.json` — **merge**: користувацькі поля зберігаються; наші hooks
8
- * ідентифікуються командою-маркером (`MANAGED_HOOK_COMMAND_MARKER`) і
8
+ * ідентифікуються командою-маркером (`MANAGED_HOOK_COMMAND_MARKERS`) і
9
9
  * перезаписуються; permissions.allow зливається через union (із дедублікацією).
10
10
  * - `npm/CLAUDE.md` — **fully owned**: завжди перезаписується; пропускається,
11
11
  * якщо в проєкті немає каталогу `npm/`.
12
12
  * - `.claude/commands/n-check.md` — fully owned slash-команда.
13
+ * - `.claude/hooks/capture-decisions.sh` — fully owned bash-скрипт ADR Stop-hook;
14
+ * копіюється з `.claude-template/hooks/`, лише коли в `.n-cursor.json` `rules`
15
+ * присутнє `adr` (правило вмикається вручну). Якщо правила немає, керована
16
+ * ADR-група в hooks так само автоматично прибирається з settings.json.
13
17
  *
14
18
  * Опт-аут — `claude-config: false` у `.n-cursor.json`.
15
19
  */
16
20
  import { existsSync } from 'node:fs'
17
- import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
21
+ import { chmod, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
18
22
  import { join } from 'node:path'
19
23
 
20
- /** Маркер у command нашого managed-hook'а за ним відрізняємо свої записи від користувацьких */
24
+ /** Маркер lint Stop-hook'а (`npx --no @nitra/cursor stop-hook`). */
21
25
  export const MANAGED_HOOK_COMMAND_MARKER = '@nitra/cursor stop-hook'
26
+ /** Маркер ADR Stop-hook'а — підрядок шляху до bash-скрипта. */
27
+ export const ADR_HOOK_COMMAND_MARKER = '.claude/hooks/capture-decisions.sh'
28
+ /** Усі маркери managed-hook'ів пакета — за ними відрізняємо свої записи від користувацьких. */
29
+ export const MANAGED_HOOK_COMMAND_MARKERS = Object.freeze([MANAGED_HOOK_COMMAND_MARKER, ADR_HOOK_COMMAND_MARKER])
22
30
 
23
31
  const CLAUDE_DIR = '.claude'
24
32
  const CLAUDE_SETTINGS_FILE = `${CLAUDE_DIR}/settings.json`
25
33
  const CLAUDE_COMMANDS_DIR = `${CLAUDE_DIR}/commands`
34
+ const CLAUDE_HOOKS_DIR = `${CLAUDE_DIR}/hooks`
35
+ const ADR_HOOK_SCRIPT_NAME = 'capture-decisions.sh'
26
36
  const NPM_CLAUDE_MD_FILE = 'npm/CLAUDE.md'
27
37
  const TEMPLATE_DIR_NAME = '.claude-template'
28
38
 
39
+ /** Канонічна група hooks для ADR Stop-hook'а — додається в settings, коли `adr` у `rules`. */
40
+ const ADR_STOP_HOOK_GROUP = Object.freeze({
41
+ matcher: '',
42
+ hooks: Object.freeze([
43
+ Object.freeze({
44
+ type: 'command',
45
+ command: `bash "$CLAUDE_PROJECT_DIR/${ADR_HOOK_COMMAND_MARKER}"`,
46
+ async: true,
47
+ timeout: 180
48
+ })
49
+ ])
50
+ })
51
+
29
52
  /**
30
53
  * @typedef {object} HookEntry
31
54
  * @property {string} type тип hook'а у форматі Claude Code (зазвичай `'command'`)
@@ -46,15 +69,17 @@ const TEMPLATE_DIR_NAME = '.claude-template'
46
69
  */
47
70
 
48
71
  /**
49
- * Чи hook-група містить лише наші managed-команди (за маркером).
72
+ * Чи hook-група містить лише наші managed-команди (за будь-яким із маркерів пакета).
50
73
  * @param {HookGroup} group hook-група з .claude/settings.json
51
- * @returns {boolean} `true`, якщо всі hooks мають маркер `MANAGED_HOOK_COMMAND_MARKER`
74
+ * @returns {boolean} `true`, якщо всі hooks мають маркер з `MANAGED_HOOK_COMMAND_MARKERS`
52
75
  */
53
76
  function isManagedHookGroup(group) {
54
77
  if (!group?.hooks?.length) {
55
78
  return false
56
79
  }
57
- return group.hooks.every(h => typeof h?.command === 'string' && h.command.includes(MANAGED_HOOK_COMMAND_MARKER))
80
+ return group.hooks.every(
81
+ h => typeof h?.command === 'string' && MANAGED_HOOK_COMMAND_MARKERS.some(marker => h.command.includes(marker))
82
+ )
58
83
  }
59
84
 
60
85
  /**
@@ -103,20 +128,39 @@ export function mergeHooks(existing, fromTemplate) {
103
128
  return out
104
129
  }
105
130
 
131
+ /**
132
+ * Будує копію темплейту із додатковою ADR Stop hook-групою у `Stop`.
133
+ * Темплейт залишається незмінним; повертається новий об'єкт з доданою групою.
134
+ * @param {ClaudeSettings} template вихідний темплейт із `.claude-template/settings.template.json`
135
+ * @returns {ClaudeSettings} копія з доданою ADR-групою у `hooks.Stop`
136
+ */
137
+ function templateWithAdrHook(template) {
138
+ /** @type {Record<string, HookGroup[]>} */
139
+ const hooks = {}
140
+ for (const [event, groups] of Object.entries(template.hooks ?? {})) {
141
+ hooks[event] = Array.isArray(groups) ? [...groups] : []
142
+ }
143
+ hooks.Stop = [...(hooks.Stop ?? []), /** @type {HookGroup} */ (ADR_STOP_HOOK_GROUP)]
144
+ return { ...template, hooks }
145
+ }
146
+
106
147
  /**
107
148
  * Повертає об'єднаний об'єкт settings.json.
108
149
  * @param {ClaudeSettings | undefined} existing існуючий вміст `.claude/settings.json` користувача (або undefined, якщо файла нема)
109
150
  * @param {ClaudeSettings} template settings із темплейту пакета `@nitra/cursor`
151
+ * @param {object} [options] опції merge-у
152
+ * @param {boolean} [options.includeAdrHook] чи додати ADR Stop-hook групу до managed-hooks (коли в `.n-cursor.json` `rules` присутнє `adr`)
110
153
  * @returns {ClaudeSettings} результат merge-у (користувацькі поля збережено, наші перевизначено)
111
154
  */
112
- export function mergeSettings(existing, template) {
155
+ export function mergeSettings(existing, template, options = {}) {
156
+ const effectiveTemplate = options.includeAdrHook ? templateWithAdrHook(template) : template
113
157
  /** @type {ClaudeSettings} */
114
158
  const merged = { ...existing }
115
- const mergedAllow = mergeAllowList(existing?.permissions?.allow, template.permissions?.allow)
159
+ const mergedAllow = mergeAllowList(existing?.permissions?.allow, effectiveTemplate.permissions?.allow)
116
160
  if (mergedAllow.length > 0) {
117
161
  merged.permissions = { ...existing?.permissions, allow: mergedAllow }
118
162
  }
119
- const mergedHooks = mergeHooks(existing?.hooks, template.hooks)
163
+ const mergedHooks = mergeHooks(existing?.hooks, effectiveTemplate.hooks)
120
164
  if (Object.keys(mergedHooks).length > 0) {
121
165
  merged.hooks = mergedHooks
122
166
  } else {
@@ -146,9 +190,11 @@ async function readJsonOrUndefined(path) {
146
190
  * користувацьких полів.
147
191
  * @param {string} projectRoot корінь проєкту, куди писати
148
192
  * @param {string} templateDir каталог `.claude-template/` усередині пакету
193
+ * @param {object} [options] опції merge-у
194
+ * @param {boolean} [options.includeAdrHook] чи додавати ADR Stop-hook (правило `adr` увімкнене у `rules`)
149
195
  * @returns {Promise<{ written: boolean, path: string }>} результат: чи писали файл, та його відносний шлях
150
196
  */
151
- export async function syncClaudeSettings(projectRoot, templateDir) {
197
+ export async function syncClaudeSettings(projectRoot, templateDir, options = {}) {
152
198
  const templatePath = join(templateDir, 'settings.template.json')
153
199
  if (!existsSync(templatePath)) {
154
200
  return { written: false, path: '' }
@@ -156,12 +202,33 @@ export async function syncClaudeSettings(projectRoot, templateDir) {
156
202
  const template = /** @type {ClaudeSettings} */ (JSON.parse(await readFile(templatePath, 'utf8')))
157
203
  const settingsPath = join(projectRoot, CLAUDE_SETTINGS_FILE)
158
204
  const existing = await readJsonOrUndefined(settingsPath)
159
- const merged = mergeSettings(existing, template)
205
+ const merged = mergeSettings(existing, template, options)
160
206
  await mkdir(join(projectRoot, CLAUDE_DIR), { recursive: true })
161
207
  await writeFile(settingsPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8')
162
208
  return { written: true, path: CLAUDE_SETTINGS_FILE }
163
209
  }
164
210
 
211
+ /**
212
+ * Копіює канонічний `.claude/hooks/capture-decisions.sh` з темплейту пакета.
213
+ * Файл повністю керується пакетом — на кожен sync перезаписується (як setup-bun-deps).
214
+ * @param {string} projectRoot корінь проєкту, куди писати
215
+ * @param {string} templateDir каталог `.claude-template/` усередині пакету
216
+ * @returns {Promise<{ written: boolean, path: string }>} результат: чи писали файл, та його відносний шлях
217
+ */
218
+ export async function syncAdrHookScript(projectRoot, templateDir) {
219
+ const templatePath = join(templateDir, 'hooks', ADR_HOOK_SCRIPT_NAME)
220
+ if (!existsSync(templatePath)) {
221
+ return { written: false, path: '' }
222
+ }
223
+ const content = await readFile(templatePath, 'utf8')
224
+ const hooksDir = join(projectRoot, CLAUDE_HOOKS_DIR)
225
+ await mkdir(hooksDir, { recursive: true })
226
+ const destPath = join(hooksDir, ADR_HOOK_SCRIPT_NAME)
227
+ await writeFile(destPath, content, 'utf8')
228
+ await chmod(destPath, 0o755)
229
+ return { written: true, path: `${CLAUDE_HOOKS_DIR}/${ADR_HOOK_SCRIPT_NAME}` }
230
+ }
231
+
165
232
  /**
166
233
  * Копіює `npm/CLAUDE.md` з темплейту, якщо в проєкті є каталог `npm/`.
167
234
  * @param {string} projectRoot корінь проєкту, куди писати
@@ -215,18 +282,21 @@ export async function syncClaudeCommands(projectRoot, templateDir) {
215
282
  * @param {string} options.projectRoot корінь проєкту-споживача
216
283
  * @param {string} options.bundledPackageRoot корінь установленого `@nitra/cursor`
217
284
  * @param {boolean} options.enabled чи увімкнено sync (з `.n-cursor.json` `claude-config`)
218
- * @returns {Promise<{ settings: boolean, npmClaudeMd: boolean, commands: string[] }>} прапорці записів settings/CLAUDE.md та список записаних slash-команд
285
+ * @param {string[]} [options.rules] список увімкнених правил із `.n-cursor.json` впливає на ADR Stop-hook (`adr`)
286
+ * @returns {Promise<{ settings: boolean, npmClaudeMd: boolean, commands: string[], adrHook: boolean }>} прапорці записів settings/CLAUDE.md/ADR-hook та список записаних slash-команд
219
287
  */
220
- export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enabled }) {
288
+ export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enabled, rules = [] }) {
221
289
  if (!enabled) {
222
- return { settings: false, npmClaudeMd: false, commands: [] }
290
+ return { settings: false, npmClaudeMd: false, commands: [], adrHook: false }
223
291
  }
224
292
  const templateDir = join(bundledPackageRoot, TEMPLATE_DIR_NAME)
225
293
  if (!existsSync(templateDir)) {
226
- return { settings: false, npmClaudeMd: false, commands: [] }
294
+ return { settings: false, npmClaudeMd: false, commands: [], adrHook: false }
227
295
  }
228
- const settings = await syncClaudeSettings(projectRoot, templateDir)
296
+ const includeAdrHook = Array.isArray(rules) && rules.includes('adr')
297
+ const adrHook = includeAdrHook ? await syncAdrHookScript(projectRoot, templateDir) : { written: false, path: '' }
298
+ const settings = await syncClaudeSettings(projectRoot, templateDir, { includeAdrHook })
229
299
  const npmClaudeMd = await syncNpmClaudeMd(projectRoot, templateDir)
230
300
  const commands = await syncClaudeCommands(projectRoot, templateDir)
231
- return { settings: settings.written, npmClaudeMd: npmClaudeMd.written, commands }
301
+ return { settings: settings.written, npmClaudeMd: npmClaudeMd.written, commands, adrHook: adrHook.written }
232
302
  }