@nitra/cursor 1.8.185 → 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,48 @@
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
+
21
+ ## [1.8.188] - 2026-05-07
22
+
23
+ ### Changed
24
+
25
+ - `vue` (mdc v1.6 → v1.7): для Volar/асетів канонічно лише **`jsconfig.json`** у корені пакета — прибрано альтернативу з `tsconfig.json`. `check-vue.mjs`: перевіряється лише наявність `jsconfig.json`.
26
+
27
+ ## [1.8.187] - 2026-05-07
28
+
29
+ ### Added
30
+
31
+ - `check-vue.mjs`: перевірка `src/vite-env.d.ts` з `/// <reference types="vite/client" />` та наявності `jsconfig.json` або `tsconfig.json` у корені кожного Vue-пакета (типи для імпортів асетів у `.vue`).
32
+
33
+ ### Changed
34
+
35
+ - `vue` (mdc v1.5 → v1.6): секція **«Vite client types (Volar, імпорти асетів)»** — обов’язкові `vite-env.d.ts`, jsconfig/tsconfig; застереження щодо вузького `compilerOptions.types`. Оновлено блок **«Перевірка»**.
36
+
37
+ ## [1.8.186] - 2026-05-07
38
+
39
+ ### Added
40
+
41
+ - `check-js-run.mjs` + `scripts/utils/promise-settimeout-scan.mjs`: програмна перевірка нової секції js-run «Паузи через setTimeout». AST-сканер на `oxc-parser` ловить `new Promise(resolve => setTimeout(resolve, ms))` (з `await` чи без, arrow та function expression, concise та block body, тривіально загорнутий callback `() => resolve()`). Паттерни з передачею значення (`r => setTimeout(() => r(value), ms)`), іншим callback-ом замість resolve, або з додатковими стейтментами в блоці — поза правилом (це не «чиста» пауза).
42
+ - `tests/promise-settimeout-scan.test.mjs`: 13 модульних тестів (await/без, block-body, function expression, обгорнутий callback, false-positive guards, multiline номер рядка, кілька входжень, фільтр розширень).
43
+ - `tests/check-js-run-fixture.test.mjs`: 2 інтеграційні кейси на `check()` — fail при `await new Promise(r => setTimeout(r, ms))` у workspace-пакеті, pass при `await setTimeout(ms)` з `node:timers/promises`.
44
+
45
+ ### Changed
46
+
47
+ - `js-run` (mdc v1.3 → v1.4): додано секцію **«Паузи через setTimeout»** — заборонено `await new Promise(resolve => setTimeout(resolve, ms))`, замість цього треба `await setTimeout(ms)` з `node:timers/promises`. Зауваження про затінення глобального `setTimeout` у тому ж файлі (за потреби callback-варіант імпортувати під іншим іменем, наприклад `setTimeoutCb` з `node:timers`).
48
+
7
49
  ## [1.8.185] - 2026-05-06
8
50
 
9
51
  ### 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.3'
4
+ version: '1.4'
5
5
  ---
6
6
 
7
7
  ## Область застосування
@@ -136,6 +136,18 @@ console.log(env.OPTIONAL_ENV_VAR)
136
136
  `// @nitra/cursor ignore-next-line checkEnv` безпосередньо перед використанням
137
137
  (escape-hatch для legacy-коду, не для нових файлів).
138
138
 
139
+ ## Паузи через setTimeout
140
+
141
+ Заборонено робити паузи через `await new Promise(resolve => setTimeout(resolve, ms))` — таку обгортку треба замінити на promise-варіант `setTimeout` з `node:timers/promises`:
142
+
143
+ ```javascript title="Замість new Promise + setTimeout"
144
+ import { setTimeout } from 'node:timers/promises'
145
+
146
+ await setTimeout(500)
147
+ ```
148
+
149
+ Імпорт `setTimeout` з `node:timers/promises` затіняє глобальний таймер у файлі — якщо в тому ж файлі потрібен callback-варіант, імпортуй його під іншим іменем (наприклад, `import { setTimeout as setTimeoutCb } from 'node:timers'`).
150
+
139
151
  ## depcheck у GitHub Actions з path-фільтром
140
152
 
141
153
  Якщо в `.github/workflows/*.yml` є тригер з `paths:`, який обмежує запуск workflow змінами в каталозі конкретного backend-пакета, в job цього workflow має бути крок `npx depcheck` з `working-directory`, який вказує на той самий каталог пакета. Це гарантує, що декларація залежностей у `package.json` пакета відповідає реальним імпортам — інакше можна випадково зламати білд після видалення «зайвої» залежності, яка насправді використовується через побічний імпорт.
package/mdc/vue.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Vue
3
3
  alwaysApply: true
4
- version: '1.5'
4
+ version: '1.7'
5
5
  ---
6
6
 
7
7
  # Vue 3 Composition API — правила для .cursorrules
@@ -208,6 +208,45 @@ export default defineConfig({
208
208
  })
209
209
  ```
210
210
 
211
+ ## Vite client types (Volar, імпорти асетів)
212
+
213
+ Без типів **Vite** редактор (Volar / TypeScript) не знає, що імпорт статичного файлу (`import url from './hero.avif'`, `*.png`, `*.svg` тощо) відповідає модулю з `string` URL. Тоді у `.vue` з’являється помилка на кшталт **Cannot find module '…' or its corresponding type declarations**.
214
+
215
+ У **кожному** workspace-пакеті з **Vue + Vite** обов’язково:
216
+
217
+ 1. **`src/vite-env.d.ts`** — рівно з посиланням на клієнтські типи Vite (одного рядка достатньо):
218
+
219
+ ```ts title="src/vite-env.d.ts"
220
+ /// <reference types="vite/client" />
221
+ ```
222
+
223
+ Так підтягуються декларації з `vite/client.d.ts` (`declare module '*.avif'`, `*.png`, …).
224
+
225
+ 2. **Корінь пакета:** **`jsconfig.json`** із **`include`**, що охоплює `src` (наприклад `"include": ["src/**/*"]`), щоб мова служби бачила `vite-env.d.ts` і SFC.
226
+
227
+ Мінімальний приклад для JS-пакета:
228
+
229
+ ```json title="jsconfig.json"
230
+ {
231
+ "compilerOptions": {
232
+ "target": "ESNext",
233
+ "module": "ESNext",
234
+ "moduleResolution": "bundler",
235
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
236
+ "jsx": "preserve",
237
+ "strict": true,
238
+ "noEmit": true,
239
+ "skipLibCheck": true,
240
+ "resolveJsonModule": true,
241
+ "isolatedModules": true,
242
+ "allowJs": true
243
+ },
244
+ "include": ["src/**/*"]
245
+ }
246
+ ```
247
+
248
+ **Не** звужуй без потреби **`compilerOptions.types`** до `["vite/client"]`: це може відрізати інші пакети з `@types` і зламати інші підказки. Достатньо `/// <reference types="vite/client" />` у `vite-env.d.ts` і коректного `include`.
249
+
211
250
  ## Тести
212
251
 
213
252
  Проекту повинен бути покритий тестами E2E за допомогою Playwright.
@@ -287,4 +326,4 @@ import path from 'node:path'
287
326
 
288
327
  ## Перевірка
289
328
 
290
- `npx @nitra/cursor check vue` — перевіряє залежності, `vite.config`, а також обходить джерела Vue-пакета (`.vue`, `.ts`, `.js` тощо) на заборонені value-імпорти з модуля `vue` (дозволені лише type-only та side-effect `import 'vue'`) і додатково сканує `.vue` SFC на імпорти Node-нативних модулів (`node:*` префікс або bare-ім’я вбудованого модуля Node — `fs`, `path`, `timers/promises` тощо). Імпорти аналізуються через **oxc-parser** (`module.staticImports`); для `.vue` вміст `<script>` витягується з SFC, далі той самий парсер (логіка в `npm/scripts/utils/vue-forbidden-imports.mjs`).
329
+ `npx @nitra/cursor check vue` — перевіряє залежності, `vite.config`, наявність **`src/vite-env.d.ts`** з `/// <reference types="vite/client" />` та **`jsconfig.json`** у корені Vue-пакета; обходить джерела Vue-пакета (`.vue`, `.ts`, `.js` тощо) на заборонені value-імпорти з модуля `vue` (дозволені лише type-only та side-effect `import 'vue'`) і додатково сканує `.vue` SFC на імпорти Node-нативних модулів (`node:*` префікс або bare-ім’я вбудованого модуля Node — `fs`, `path`, `timers/promises` тощо). Імпорти аналізуються через **oxc-parser** (`module.staticImports`); для `.vue` вміст `<script>` витягується з SFC, далі той самий парсер (логіка в `npm/scripts/utils/vue-forbidden-imports.mjs`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.185",
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
+ }
@@ -21,7 +21,10 @@
21
21
  * - «depcheck у GitHub Actions з path-фільтром»: для кожного workflow з `paths:`,
22
22
  * обмеженим каталогом цього пакета (`<rootDir>/...`), має бути крок
23
23
  * `npx depcheck --ignores="graphql,bun"` (плюс інші, за потреби) з
24
- * `working-directory: <rootDir>` (див. `utils/depcheck-workflow.mjs`).
24
+ * `working-directory: <rootDir>` (див. `utils/depcheck-workflow.mjs`);
25
+ * - «Паузи через setTimeout»: `new Promise(resolve => setTimeout(resolve, ms))` (з/без `await`)
26
+ * треба замінити на `await setTimeout(ms)` з `node:timers/promises`
27
+ * (див. `utils/promise-settimeout-scan.mjs`).
25
28
  */
26
29
  import { existsSync } from 'node:fs'
27
30
  import { readFile } from 'node:fs/promises'
@@ -42,6 +45,10 @@ import {
42
45
  resolveConnDirFromPackageJson
43
46
  } from './utils/conn-imports-scan.mjs'
44
47
  import { loadCursorIgnorePaths } from './utils/load-cursor-config.mjs'
48
+ import {
49
+ findPromiseSetTimeoutInText,
50
+ isPromiseSetTimeoutScanSourceFile
51
+ } from './utils/promise-settimeout-scan.mjs'
45
52
  import { walkDir } from './utils/walkDir.mjs'
46
53
  import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
47
54
 
@@ -162,6 +169,30 @@ async function checkProcessEnvUsage(absPackageRoot, sourcePaths, label, fail) {
162
169
  return violations
163
170
  }
164
171
 
172
+ /**
173
+ * Сканує джерела пакета на паттерн `new Promise(resolve => setTimeout(resolve, ms))`.
174
+ * @param {string} absPackageRoot абсолютний корінь пакета
175
+ * @param {string[]} sourcePaths абсолютні шляхи до файлів
176
+ * @param {string} label префікс повідомлення `[<pkg>] `
177
+ * @param {(msg: string) => void} fail callback при помилці
178
+ * @returns {Promise<number>} кількість порушень
179
+ */
180
+ async function checkPromiseSetTimeoutPause(absPackageRoot, sourcePaths, label, fail) {
181
+ let violations = 0
182
+ for (const absPath of sourcePaths) {
183
+ const rel = relPosix(absPackageRoot, absPath)
184
+ if (!isPromiseSetTimeoutScanSourceFile(rel)) continue
185
+ const content = await readFile(absPath, 'utf8')
186
+ for (const v of findPromiseSetTimeoutInText(content, rel)) {
187
+ violations++
188
+ fail(
189
+ `${label}${rel}:${v.line} — заміни 'new Promise(r => setTimeout(r, ms))' на 'await setTimeout(ms)' з 'node:timers/promises': ${v.snippet}`
190
+ )
191
+ }
192
+ }
193
+ return violations
194
+ }
195
+
165
196
  /**
166
197
  * Перевіряє відповідність правилам js-run.mdc для одного workspace-пакета.
167
198
  * @param {string} rootDir відносний шлях workspace (не `'.'`)
@@ -205,6 +236,11 @@ async function checkWorkspacePackage(rootDir, ignorePaths, workflows, fail, pass
205
236
  )
206
237
  }
207
238
 
239
+ const pauseViolations = await checkPromiseSetTimeoutPause(absPackageRoot, sourcePaths, label, fail)
240
+ if (pauseViolations === 0) {
241
+ passFn(`${label}немає 'new Promise(r => setTimeout(r, ms))' — паузи через 'node:timers/promises'`)
242
+ }
243
+
208
244
  await checkOtelConfigmap(rootDir, label, fail, passFn)
209
245
 
210
246
  checkDepcheckInWorkflows(rootDir, workflows, label, fail, passFn)
@@ -4,6 +4,9 @@
4
4
  * Версії Vite та плагінів, vue-macros, auto-import, layouts, вміст `vite.config`;
5
5
  * у репозиторії — рекомендацію розширення Vue.volar.
6
6
  *
7
+ * У кожному Vue+Vite-пакеті очікується `src/vite-env.d.ts` з `/// <reference types="vite/client" />`
8
+ * та `jsconfig.json` у корені пакета (типи для імпортів асетів у `.vue`).
9
+ *
7
10
  * У `vite.config.*` заборонено використовувати `process.env.npm_lifecycle_event` (Bun не підставляє його як npm),
8
11
  * натомість використовуй `mode` з `defineConfig(({ mode }) => ...)`.
9
12
  *
@@ -32,6 +35,9 @@ import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
32
35
  const MAJOR_VERSION_RE = /(\d+)/
33
36
  const ESBUILD_RE = /\besbuild\b/
34
37
 
38
+ /** Регулярний вираз для triple-slash `reference types="vite/client"` у `src/vite-env.d.ts`. */
39
+ const VITE_CLIENT_REFERENCE_RE = /\/\/\/\s*<reference\s+types\s*=\s*["']vite\/client["']\s*\/>/
40
+
35
41
  /**
36
42
  * Визначає, чи можна сканувати файл як текст на згадки `esbuild`.
37
43
  * @param {string} relPosix відносний шлях у posix-форматі
@@ -202,6 +208,43 @@ function checkRequiredDep(deps, name, prefix, passFn, fail, hint = `${name} ві
202
208
  * @param {(msg: string) => void} passFn callback при успішній перевірці
203
209
  * @param {(msg: string) => void} fail callback при помилці
204
210
  */
211
+ /**
212
+ * Перевіряє `src/vite-env.d.ts` і наявність `jsconfig.json` для підтягування типів асетів Vite у IDE.
213
+ * @param {string} rootDir відносний шлях до кореня пакета
214
+ * @param {string} prefix префікс повідомлень
215
+ * @param {(msg: string) => void} passFn успіх
216
+ * @param {(msg: string) => void} fail помилка
217
+ * @returns {Promise<void>}
218
+ */
219
+ async function checkViteClientEnvAndEditorConfig(rootDir, prefix, passFn, fail) {
220
+ const envRel = join(rootDir, 'src/vite-env.d.ts')
221
+ if (!existsSync(envRel)) {
222
+ fail(
223
+ `${prefix}немає src/vite-env.d.ts — додай файл з рядком /// <reference types="vite/client" /> ` +
224
+ `(інакше TS/Volar не бачать типів для імпортів асетів: png, avif, css як URL).`
225
+ )
226
+ return
227
+ }
228
+ const envContent = await readFile(envRel, 'utf8')
229
+ if (!VITE_CLIENT_REFERENCE_RE.test(envContent)) {
230
+ fail(
231
+ `${prefix}src/vite-env.d.ts має містити /// <reference types="vite/client" /> ` +
232
+ `(без цього імпорти статичних файлів у .vue дають «Cannot find module … type declarations»).`
233
+ )
234
+ return
235
+ }
236
+ passFn(`${prefix}src/vite-env.d.ts посилається на vite/client`)
237
+
238
+ if (!existsSync(join(rootDir, 'jsconfig.json'))) {
239
+ fail(
240
+ `${prefix}немає jsconfig.json у корені пакета — додай файл з "include": ["src/**/*"] тощо, ` +
241
+ `щоб IDE підхопила vite-env.d.ts і .vue.`
242
+ )
243
+ return
244
+ }
245
+ passFn(`${prefix}jsconfig.json присутній`)
246
+ }
247
+
205
248
  function checkViteVersion(devDeps, prefix, passFn, fail) {
206
249
  const v = devDeps.vite
207
250
  if (!v) {
@@ -442,6 +485,8 @@ async function checkVuePackage(rootDir, ignorePaths, fail, passFn) {
442
485
  'vite-plugin-vue-layouts-next відсутній — bun add -d vite-plugin-vue-layouts-next'
443
486
  )
444
487
 
488
+ await checkViteClientEnvAndEditorConfig(rootDir, prefix, passFn, fail)
489
+
445
490
  const { hasVueAutoImport } = await checkViteConfig(rootDir, prefix, passFn, fail)
446
491
  await checkVueImportViolations(
447
492
  rootDir,
@@ -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
  }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Знаходить паттерн `new Promise(resolve => setTimeout(resolve, ms))` (з `await` чи без)
3
+ * у джерелах — таку обгортку треба замінити на `setTimeout` з `node:timers/promises`
4
+ * згідно з js-run.mdc, секція «Паузи через setTimeout».
5
+ *
6
+ * Семантика — структурна (без regex по тілу): `NewExpression` з ідентифікатор-callee `Promise`
7
+ * і єдиним аргументом-функцією, тіло якої — виклик `setTimeout(<resolve>, ms)`. Перший
8
+ * аргумент `setTimeout` має передавати `resolve` напряму або тривіально загорнутим у
9
+ * безпараметричну функцію `() => resolve()` / `function () { resolve() }` без жодних
10
+ * аргументів — інакше це не «чиста пауза», і паттерн не вмикається.
11
+ *
12
+ * Сканер не вимагає, щоб файл компілювався: при синтаксичних помилках повертається
13
+ * порожній результат (як інші сканери — спочатку треба полагодити синтаксис).
14
+ */
15
+ import { normalizeSnippet, offsetToLine, parseProgramOrNull } from './ast-scan-utils.mjs'
16
+
17
+ const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/
18
+
19
+ /**
20
+ * Чи аргумент, який передають у `setTimeout`, — це «голий» виклик `resolve`
21
+ * (тобто сам ідентифікатор або `() => resolve()` без аргументів).
22
+ * @param {Record<string, unknown> | null | undefined} arg AST-вузол першого аргументу `setTimeout`
23
+ * @param {string} paramName ім'я параметра-resolve у тіла-функції Promise
24
+ * @returns {boolean} `true`, якщо це чиста передача resolve без значення
25
+ */
26
+ function isBareResolveCallback(arg, paramName) {
27
+ if (!arg || typeof arg !== 'object') return false
28
+ if (arg.type === 'Identifier' && arg.name === paramName) return true
29
+ if (arg.type !== 'ArrowFunctionExpression' && arg.type !== 'FunctionExpression') return false
30
+ if ((arg.params?.length ?? 0) !== 0) return false
31
+ const callExpr = extractSingleCallExpression(arg.body)
32
+ if (!callExpr) return false
33
+ if (callExpr.callee?.type !== 'Identifier' || callExpr.callee.name !== paramName) return false
34
+ return !Array.isArray(callExpr.arguments) || callExpr.arguments.length === 0
35
+ }
36
+
37
+ /**
38
+ * Якщо тіло функції — рівно один `CallExpression` (концизне `() => foo()` або
39
+ * `{ foo() }` без інших стейтментів), повертає його. Інакше — `null`.
40
+ * @param {unknown} body тіло функції з AST
41
+ * @returns {Record<string, unknown> | null} AST-вузол `CallExpression` або `null`
42
+ */
43
+ function extractSingleCallExpression(body) {
44
+ if (!body || typeof body !== 'object') return null
45
+ if (body.type === 'CallExpression') return body
46
+ if (body.type !== 'BlockStatement') return null
47
+ if (!Array.isArray(body.body) || body.body.length !== 1) return null
48
+ const stmt = body.body[0]
49
+ if (!stmt || stmt.type !== 'ExpressionStatement') return null
50
+ const expr = stmt.expression
51
+ return expr?.type === 'CallExpression' ? expr : null
52
+ }
53
+
54
+ /**
55
+ * Чи це `NewExpression` виду `new Promise(<resolve> => setTimeout(<resolve>, ms))`.
56
+ * Параметр-resolve має бути простим Identifier; setTimeout — глобальним викликом
57
+ * за іменем (з будь-якого джерела — node:timers, global, тощо: значення для нас має
58
+ * лише структурний паттерн).
59
+ * @param {Record<string, unknown> | null | undefined} node AST-вузол
60
+ * @returns {boolean} `true`, якщо це проблемний паттерн «обгортки таймера у Promise»
61
+ */
62
+ function isPromiseSetTimeoutDelay(node) {
63
+ if (!node || node.type !== 'NewExpression') return false
64
+ if (node.callee?.type !== 'Identifier' || node.callee.name !== 'Promise') return false
65
+ if (!Array.isArray(node.arguments) || node.arguments.length !== 1) return false
66
+ const fn = node.arguments[0]
67
+ if (!fn || (fn.type !== 'ArrowFunctionExpression' && fn.type !== 'FunctionExpression')) return false
68
+ if (!Array.isArray(fn.params) || fn.params.length === 0) return false
69
+ const firstParam = fn.params[0]
70
+ if (!firstParam || firstParam.type !== 'Identifier') return false
71
+ const setTimeoutCall = extractSingleCallExpression(fn.body)
72
+ if (!setTimeoutCall) return false
73
+ if (setTimeoutCall.callee?.type !== 'Identifier' || setTimeoutCall.callee.name !== 'setTimeout') return false
74
+ if (!Array.isArray(setTimeoutCall.arguments) || setTimeoutCall.arguments.length < 1) return false
75
+ return isBareResolveCallback(setTimeoutCall.arguments[0], firstParam.name)
76
+ }
77
+
78
+ /**
79
+ * Простий рекурсивний обхід AST: заходимо в усі об'єкти/масиви, щоб знайти `NewExpression`.
80
+ * @param {unknown} node корінь або під-вузол AST
81
+ * @param {(n: Record<string, unknown>) => void} visit виклик для кожного об'єкта-вузла з `type`
82
+ * @returns {void}
83
+ */
84
+ function walkAst(node, visit) {
85
+ if (!node || typeof node !== 'object') return
86
+ if (Array.isArray(node)) {
87
+ for (const item of node) walkAst(item, visit)
88
+ return
89
+ }
90
+ if (typeof node.type === 'string') {
91
+ visit(node)
92
+ }
93
+ for (const key of Object.keys(node)) {
94
+ if (key === 'parent') continue
95
+ const v = node[key]
96
+ if (v && typeof v === 'object') walkAst(v, visit)
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Знаходить усі `new Promise(resolve => setTimeout(resolve, ms))` у тексті.
102
+ * @param {string} content вихідний код
103
+ * @param {string} [virtualPath] шлях для вибору `lang` (наприклад `pkg/src/foo.ts`)
104
+ * @returns {{ line: number, snippet: string }[]} список порушень
105
+ */
106
+ export function findPromiseSetTimeoutInText(content, virtualPath = 'scan.ts') {
107
+ const program = parseProgramOrNull(content, virtualPath)
108
+ if (!program) return []
109
+ /** @type {{ line: number, snippet: string }[]} */
110
+ const out = []
111
+ walkAst(program, node => {
112
+ if (!isPromiseSetTimeoutDelay(node)) return
113
+ out.push({
114
+ line: offsetToLine(content, node.start),
115
+ snippet: normalizeSnippet(content.slice(node.start, node.end))
116
+ })
117
+ })
118
+ return out
119
+ }
120
+
121
+ /**
122
+ * Чи сканувати цей файл за розширенням (JS/TS-сім'я, виключно з `.d.ts`).
123
+ * @param {string} relativePath відносний шлях до файлу
124
+ * @returns {boolean} `true`, якщо розширення підходить для сканування
125
+ */
126
+ export function isPromiseSetTimeoutScanSourceFile(relativePath) {
127
+ if (!SOURCE_FILE_RE.test(relativePath)) return false
128
+ return !relativePath.endsWith('.d.ts')
129
+ }