@nitra/cursor 1.8.188 → 1.8.189

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,20 @@
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.189] - 2026-05-07
8
+
9
+ ### Added
10
+
11
+ - Нове правило `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/`.
12
+ - `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`).
13
+ - `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`.
14
+ - `tests/check-adr.test.mjs` (7 кейсів) і нові кейси у `tests/sync-claude-config.test.mjs`: copy + Stop-merge + ідемпотентність + автоматичне видалення managed-групи при видаленні `adr` з `rules`.
15
+
16
+ ### Changed
17
+
18
+ - `sync-claude-config.mjs`: `MANAGED_HOOK_COMMAND_MARKERS` (масив) замість одиничного маркера; `mergeSettings(existing, template, { includeAdrHook })`; `syncClaudeConfig` приймає `rules` і умовно копіює ADR Stop-hook script + додає managed-групу до Stop. `syncClaudeConfig` повертає додатковий прапорець `adrHook`.
19
+ - `bin/n-cursor.js`: передає `rules` у `syncClaudeConfig` і логує `.claude/hooks/capture-decisions.sh` у підсумку Claude-конфіга.
20
+
7
21
  ## [1.8.188] - 2026-05-07
8
22
 
9
23
  ### 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.188",
3
+ "version": "1.8.189",
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
+ }
@@ -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
  }