@nitra/cursor 1.9.23 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,10 +22,10 @@ TRANSCRIPT_PATH=$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty')
22
22
  SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // "unknown"')
23
23
 
24
24
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
25
- INBOX="$PROJECT_ROOT/docs/adr/_inbox"
25
+ ADR_DIR="$PROJECT_ROOT/docs/adr"
26
26
  LOG_DIR="$PROJECT_ROOT/.claude/hooks"
27
27
  LOG="$LOG_DIR/capture-decisions.log"
28
- mkdir -p "$INBOX" "$LOG_DIR"
28
+ mkdir -p "$ADR_DIR" "$LOG_DIR"
29
29
 
30
30
  log() { printf '%s %s\n' "$(date -Iseconds)" "$*" >> "$LOG"; }
31
31
 
@@ -145,7 +145,7 @@ if ! printf '%s' "$RESPONSE_TRIMMED" | grep -q '^## '; then
145
145
  fi
146
146
 
147
147
  TS=$(date +%Y%m%d-%H%M%S)
148
- OUT="$INBOX/$TS-${SESSION_ID:0:8}.md"
148
+ OUT="$ADR_DIR/$TS-${SESSION_ID:0:8}.md"
149
149
  {
150
150
  printf -- '---\nsession: %s\ncaptured: %s\ntranscript: %s\n---\n\n' \
151
151
  "$SESSION_ID" "$(date -Iseconds)" "$TRANSCRIPT_PATH"
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env bash
2
+ # Stop hook: normalize ADR drafts in docs/adr/ via LLM.
3
+ #
4
+ # Triggers when number of draft files (with `session:` in YAML frontmatter)
5
+ # reaches ADR_NORMALIZE_THRESHOLD (default 30). Asks LLM to return a JSON list
6
+ # of operations (rewrite / delete / merge-into) and applies them to the working
7
+ # tree. Never invokes git — developer reviews via `git status` / `git diff`.
8
+ #
9
+ # LLM CLI selection (first available wins):
10
+ # 1. claude — `claude -p --model "$ADR_NORMALIZE_MODEL"` (default: sonnet)
11
+ # 2. cursor-agent — `cursor-agent -p --mode ask --output-format text --model …`
12
+ # (default: claude-4.6-sonnet-medium)
13
+ # neither — exit 0 silently
14
+ #
15
+ # Portable bash 3.2 (macOS /bin/bash): no `mapfile`, no associative arrays.
16
+ #
17
+ # Bundled with @nitra/cursor; project copy is auto-synced by the `adr` rule.
18
+ set -eu
19
+ set -o pipefail
20
+
21
+ if [ -n "${ADR_NORMALIZE_RUNNING:-}" ]; then
22
+ exit 0
23
+ fi
24
+ export ADR_NORMALIZE_RUNNING=1
25
+
26
+ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
27
+ ADR_DIR="$PROJECT_ROOT/docs/adr"
28
+ LOG_DIR="$PROJECT_ROOT/.claude/hooks"
29
+ LOG="$LOG_DIR/normalize-decisions.log"
30
+ STATE_FILE="$LOG_DIR/.normalize-state"
31
+ LOCK_FILE="$LOG_DIR/.normalize.lock"
32
+ mkdir -p "$LOG_DIR"
33
+
34
+ log() { printf '%s %s\n' "$(date -Iseconds)" "$*" >> "$LOG"; }
35
+
36
+ # Skip if repo is mid-rebase / mid-merge — editing files now would tangle the user.
37
+ if [ -d "$PROJECT_ROOT/.git" ]; then
38
+ for marker in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD rebase-apply rebase-merge; do
39
+ if [ -e "$PROJECT_ROOT/.git/$marker" ]; then
40
+ log "skip: git is mid-$marker"
41
+ exit 0
42
+ fi
43
+ done
44
+ fi
45
+
46
+ if [ ! -d "$ADR_DIR" ]; then
47
+ exit 0
48
+ fi
49
+
50
+ # Acquire lock if `flock` is available (Linux). macOS lacks flock by default —
51
+ # treat absence as "no concurrent runs expected" and skip locking.
52
+ if command -v flock >/dev/null 2>&1; then
53
+ exec 9>"$LOCK_FILE"
54
+ if ! flock -n 9; then
55
+ log "skip: another normalize run holds the lock"
56
+ exit 0
57
+ fi
58
+ fi
59
+
60
+ # Min interval between attempts — when LLM returns nothing for the batch, do not
61
+ # spin on every Stop event. Default 6 hours.
62
+ MIN_INTERVAL_HOURS="${ADR_NORMALIZE_MIN_INTERVAL_HOURS:-6}"
63
+ if [ -f "$STATE_FILE" ]; then
64
+ LAST_ATTEMPT=$(cat "$STATE_FILE" 2>/dev/null || printf '0')
65
+ NOW=$(date +%s)
66
+ ELAPSED=$(( NOW - LAST_ATTEMPT ))
67
+ MIN_SECS=$(( MIN_INTERVAL_HOURS * 3600 ))
68
+ if [ "$ELAPSED" -lt "$MIN_SECS" ]; then
69
+ log "skip: only $ELAPSED s since last attempt (min ${MIN_SECS}s)"
70
+ exit 0
71
+ fi
72
+ fi
73
+
74
+ THRESHOLD="${ADR_NORMALIZE_THRESHOLD:-30}"
75
+ BATCH_SIZE="${ADR_NORMALIZE_BATCH:-30}"
76
+ DRY_RUN="${ADR_NORMALIZE_DRY:-0}"
77
+
78
+ # Detects whether a markdown file is a draft: has YAML frontmatter with `session:` field.
79
+ is_draft() {
80
+ awk '
81
+ NR==1 && /^---$/ { fm=1; next }
82
+ fm && /^---$/ { exit }
83
+ fm && /^session: / { found=1 }
84
+ END { exit !found }
85
+ ' "$1" 2>/dev/null
86
+ }
87
+
88
+ TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/adr-normalize.XXXXXX")
89
+ trap 'rm -rf "$TMP_DIR"' EXIT
90
+
91
+ DRAFTS_LIST="$TMP_DIR/drafts.txt"
92
+ CLEAN_LIST="$TMP_DIR/clean.txt"
93
+ BATCH_LIST="$TMP_DIR/batch.txt"
94
+ CLAIMED_SLUGS="$TMP_DIR/claimed.txt"
95
+ : > "$DRAFTS_LIST"
96
+ : > "$CLEAN_LIST"
97
+ : > "$CLAIMED_SLUGS"
98
+
99
+ # Find all draft files (recursive) under docs/adr/.
100
+ find "$ADR_DIR" -type f -name '*.md' 2>/dev/null | while IFS= read -r f; do
101
+ if is_draft "$f"; then
102
+ printf '%s\n' "$f"
103
+ fi
104
+ done | sort > "$DRAFTS_LIST"
105
+
106
+ DRAFT_COUNT=$(wc -l < "$DRAFTS_LIST" | tr -d ' ')
107
+ log "drafts found: $DRAFT_COUNT (threshold: $THRESHOLD)"
108
+
109
+ if [ "$DRAFT_COUNT" -lt "$THRESHOLD" ]; then
110
+ exit 0
111
+ fi
112
+
113
+ head -n "$BATCH_SIZE" "$DRAFTS_LIST" > "$BATCH_LIST"
114
+ BATCH_COUNT=$(wc -l < "$BATCH_LIST" | tr -d ' ')
115
+ log "batch size: $BATCH_COUNT"
116
+
117
+ # Find clean ADR files at root of docs/adr/ (no `session:`).
118
+ find "$ADR_DIR" -maxdepth 1 -type f -name '*.md' 2>/dev/null | while IFS= read -r f; do
119
+ if ! is_draft "$f"; then
120
+ basename "$f"
121
+ fi
122
+ done | sort > "$CLEAN_LIST"
123
+
124
+ # Build prompt input section.
125
+ INPUT_FILE="$TMP_DIR/input.md"
126
+ {
127
+ i=0
128
+ while IFS= read -r f; do
129
+ i=$(( i + 1 ))
130
+ idx=$(printf '%03d' "$i")
131
+ rel="${f#"$ADR_DIR/"}"
132
+ printf '\n[DRAFT-%s] %s\n' "$idx" "$rel"
133
+ cat "$f"
134
+ printf '\n[END DRAFT-%s]\n' "$idx"
135
+ done < "$BATCH_LIST"
136
+ } > "$INPUT_FILE"
137
+
138
+ CLEAN_SECTION_FILE="$TMP_DIR/clean-section.md"
139
+ : > "$CLEAN_SECTION_FILE"
140
+ if [ -s "$CLEAN_LIST" ]; then
141
+ {
142
+ printf '\nClean ADR files already in docs/adr/ (potential merge-into targets):\n'
143
+ while IFS= read -r c; do
144
+ printf '%s\n' "- $c"
145
+ done < "$CLEAN_LIST"
146
+ } > "$CLEAN_SECTION_FILE"
147
+ fi
148
+
149
+ PROMPT_HEADER=$(cat <<'EOF'
150
+ Ти нормалізуєш чернетки ADR/Runbook/Knowledge у `docs/adr/` репозиторію. Для кожного драфта обери одну з трьох операцій і поверни ЛИШЕ JSON-обʼєкт без markdown-обгортки, без передмови.
151
+
152
+ Схема відповіді:
153
+
154
+ {
155
+ "operations": [
156
+ { "op": "delete", "file": "<basename>.md", "reason": "..." },
157
+ { "op": "rewrite", "file": "<basename>.md", "slug": "<kebab-case-ukrainian>", "content": "<повний markdown файлу>" },
158
+ { "op": "merge-into", "file": "<basename>.md", "target": "<slug>.md", "additions": "<markdown для дописування>" }
159
+ ]
160
+ }
161
+
162
+ Правила:
163
+
164
+ 1. `delete` — драфт тривіальний / повністю покритий іншим існуючим clean-ADR-ом / порожній. Поясни короткою причиною українською.
165
+
166
+ 2. `rewrite` — драфт має самостійну цінність. Повертай у `content` повний фінальний вміст файлу:
167
+ - Без YAML frontmatter (жодного `session:`, `captured:`, `transcript:`).
168
+ - Заголовок `# <Title>` українською.
169
+ - Один рядок `**Status:** Accepted` і один рядок `**Date:** YYYY-MM-DD` — дату беремо з поля `captured:` оригінальної чернетки (перші 10 символів ISO-дати).
170
+ - Далі розділи **Контекст**, **Рішення/Процедура/Факт**, **Обґрунтування**, **Розглянуті альтернативи**, **Зачіпає** — як у драфті, але причесані: цілісні речення, без скорочень, без слідів автогенерованого тегу типу `## Knowledge`.
171
+ - `slug` — kebab-case українською (наприклад `ланцюжок-запуску-abie`, `npm-publish-flow`). Без розширення `.md`. Літери малі, дозволено цифри, дефіс, кирилиця. Якщо тема технічна англійською (назва пакету, ключове слово) — лиши англійською без транслітерації.
172
+
173
+ 3. `merge-into` — драфт повторює тему вже існуючого clean-файлу зі списку нижче. `target` — точна назва файлу зі списку (з `.md`). `additions` — лише новий зміст, який варто дописати в кінець target-файлу під підзаголовком `## Update YYYY-MM-DD` (date з `captured` драфта). Якщо нічого нового додати — використовуй `delete`.
174
+
175
+ Жорсткі обмеження:
176
+
177
+ - Поверни валідний JSON, нічого крім нього. Жодних code-fence, жодних коментарів.
178
+ - Кожен файл з вхідного списку має зʼявитися у `operations` рівно один раз.
179
+ - Слаги не повторювати між операціями того самого батча. Якщо дві чернетки про одну тему — одна `rewrite`, інша `merge-into target: <slug>.md` з тим самим slug-ом.
180
+ - Не вигадуй target, якого нема у списку clean-файлів.
181
+
182
+ Вхідні драфти і clean-список — нижче.
183
+ EOF
184
+ )
185
+
186
+ FULL_PROMPT_FILE="$TMP_DIR/prompt.md"
187
+ {
188
+ printf '%s\n' "$PROMPT_HEADER"
189
+ cat "$CLEAN_SECTION_FILE"
190
+ printf '\n=== DRAFTS ===\n'
191
+ cat "$INPUT_FILE"
192
+ } > "$FULL_PROMPT_FILE"
193
+
194
+ # Update state BEFORE calling LLM — even if LLM fails, we honor min-interval.
195
+ date +%s > "$STATE_FILE"
196
+
197
+ CLAUDE_MODEL="${ADR_NORMALIZE_MODEL:-sonnet}"
198
+ CURSOR_MODEL="${ADR_NORMALIZE_CURSOR_MODEL:-claude-4.6-sonnet-medium}"
199
+
200
+ RESPONSE_FILE="$TMP_DIR/response.txt"
201
+
202
+ if command -v claude >/dev/null 2>&1; then
203
+ log "using claude CLI (model: $CLAUDE_MODEL)"
204
+ claude -p --model "$CLAUDE_MODEL" < "$FULL_PROMPT_FILE" > "$RESPONSE_FILE" 2>>"$LOG" || true
205
+ elif command -v cursor-agent >/dev/null 2>&1; then
206
+ log "using cursor-agent CLI (model: $CURSOR_MODEL)"
207
+ FULL_PROMPT=$(cat "$FULL_PROMPT_FILE")
208
+ cursor-agent -p --mode ask --output-format text --model "$CURSOR_MODEL" -- "$FULL_PROMPT" > "$RESPONSE_FILE" 2>>"$LOG" || true
209
+ else
210
+ log "no LLM CLI found, skipping"
211
+ exit 0
212
+ fi
213
+
214
+ if [ ! -s "$RESPONSE_FILE" ]; then
215
+ log "empty LLM response"
216
+ exit 0
217
+ fi
218
+
219
+ # Strip markdown code-fence lines and surrounding blank lines.
220
+ # Use awk to avoid backtick/end-anchor lint false positives in sed regex.
221
+ RESPONSE_CLEAN_FILE="$TMP_DIR/response-clean.json"
222
+ awk '
223
+ /^[[:space:]]*```/ { next }
224
+ started || /[^[:space:]]/ { started = 1; print }
225
+ ' "$RESPONSE_FILE" > "$RESPONSE_CLEAN_FILE"
226
+
227
+ # Validate JSON.
228
+ if ! jq -e '.operations | type == "array"' "$RESPONSE_CLEAN_FILE" >/dev/null 2>&1; then
229
+ HEAD200=$(head -c 200 "$RESPONSE_CLEAN_FILE" | tr '\n' ' ')
230
+ log "invalid JSON response (first 200 chars): $HEAD200"
231
+ exit 0
232
+ fi
233
+
234
+ OP_COUNT=$(jq '.operations | length' "$RESPONSE_CLEAN_FILE")
235
+ log "operations parsed: $OP_COUNT"
236
+
237
+ if [ "$DRY_RUN" = "1" ]; then
238
+ log "DRY RUN — would apply $OP_COUNT operations:"
239
+ jq -r '.operations[] | " " + .op + " " + .file + (if .slug then " → " + .slug else "" end) + (if .target then " → " + .target else "" end)' \
240
+ "$RESPONSE_CLEAN_FILE" >> "$LOG"
241
+ exit 0
242
+ fi
243
+
244
+ # Resolve unique target path for slug — appends -2, -3 on collision.
245
+ # Tracks claims via CLAIMED_SLUGS file (one slug per line).
246
+ resolve_unique_slug_path() {
247
+ slug="$1"
248
+ base="$ADR_DIR/${slug}.md"
249
+ if [ ! -e "$base" ] && ! grep -Fxq "$slug" "$CLAIMED_SLUGS" 2>/dev/null; then
250
+ printf '%s\n' "$slug" >> "$CLAIMED_SLUGS"
251
+ printf '%s\n' "$base"
252
+ return
253
+ fi
254
+ n=2
255
+ while :; do
256
+ cand="$ADR_DIR/${slug}-${n}.md"
257
+ key="${slug}-${n}"
258
+ if [ ! -e "$cand" ] && ! grep -Fxq "$key" "$CLAIMED_SLUGS" 2>/dev/null; then
259
+ printf '%s\n' "$key" >> "$CLAIMED_SLUGS"
260
+ printf '%s\n' "$cand"
261
+ return
262
+ fi
263
+ n=$(( n + 1 ))
264
+ done
265
+ }
266
+
267
+ APPLIED=0
268
+ SKIPPED=0
269
+
270
+ jq -c '.operations[]' "$RESPONSE_CLEAN_FILE" | while IFS= read -r op_json; do
271
+ OP=$(printf '%s' "$op_json" | jq -r '.op // empty')
272
+ FILE=$(printf '%s' "$op_json" | jq -r '.file // empty')
273
+ SRC_PATH="$ADR_DIR/$FILE"
274
+
275
+ if [ -z "$OP" ] || [ -z "$FILE" ]; then
276
+ log "skip: malformed op (missing op/file)"
277
+ SKIPPED=$(( SKIPPED + 1 ))
278
+ continue
279
+ fi
280
+ case "$FILE" in
281
+ */*|.*)
282
+ log "skip: refusing path-like file '$FILE'"
283
+ SKIPPED=$(( SKIPPED + 1 ))
284
+ continue
285
+ ;;
286
+ esac
287
+
288
+ # Resolve nested batch files by basename if not at docs/adr/ root.
289
+ if [ ! -f "$SRC_PATH" ]; then
290
+ while IFS= read -r bf; do
291
+ bn=$(basename "$bf")
292
+ if [ "$bn" = "$FILE" ]; then
293
+ SRC_PATH="$bf"
294
+ break
295
+ fi
296
+ done < "$BATCH_LIST"
297
+ fi
298
+ if [ ! -f "$SRC_PATH" ]; then
299
+ log "skip: source missing '$FILE'"
300
+ SKIPPED=$(( SKIPPED + 1 ))
301
+ continue
302
+ fi
303
+
304
+ case "$OP" in
305
+ delete)
306
+ REASON=$(printf '%s' "$op_json" | jq -r '.reason // ""')
307
+ rm -- "$SRC_PATH"
308
+ log "delete: $FILE — $REASON"
309
+ APPLIED=$(( APPLIED + 1 ))
310
+ ;;
311
+ rewrite)
312
+ SLUG=$(printf '%s' "$op_json" | jq -r '.slug // empty')
313
+ CONTENT=$(printf '%s' "$op_json" | jq -r '.content // empty')
314
+ if [ -z "$SLUG" ] || [ -z "$CONTENT" ]; then
315
+ log "skip rewrite: missing slug or content for '$FILE'"
316
+ SKIPPED=$(( SKIPPED + 1 ))
317
+ continue
318
+ fi
319
+ case "$SLUG" in
320
+ */*|.*)
321
+ log "skip rewrite: refusing path-like slug '$SLUG'"
322
+ SKIPPED=$(( SKIPPED + 1 ))
323
+ continue
324
+ ;;
325
+ esac
326
+ DEST_PATH=$(resolve_unique_slug_path "$SLUG")
327
+ printf '%s\n' "$CONTENT" > "$DEST_PATH"
328
+ rm -- "$SRC_PATH"
329
+ log "rewrite: $FILE → $(basename "$DEST_PATH")"
330
+ APPLIED=$(( APPLIED + 1 ))
331
+ ;;
332
+ merge-into)
333
+ TARGET=$(printf '%s' "$op_json" | jq -r '.target // empty')
334
+ ADDITIONS=$(printf '%s' "$op_json" | jq -r '.additions // empty')
335
+ if [ -z "$TARGET" ] || [ -z "$ADDITIONS" ]; then
336
+ log "skip merge-into: missing target or additions for '$FILE'"
337
+ SKIPPED=$(( SKIPPED + 1 ))
338
+ continue
339
+ fi
340
+ case "$TARGET" in
341
+ */*|.*)
342
+ log "skip merge-into: refusing path-like target '$TARGET'"
343
+ SKIPPED=$(( SKIPPED + 1 ))
344
+ continue
345
+ ;;
346
+ esac
347
+ TARGET_PATH="$ADR_DIR/$TARGET"
348
+ if [ ! -f "$TARGET_PATH" ]; then
349
+ log "skip merge-into: target '$TARGET' missing"
350
+ SKIPPED=$(( SKIPPED + 1 ))
351
+ continue
352
+ fi
353
+ if is_draft "$TARGET_PATH"; then
354
+ log "skip merge-into: target '$TARGET' is itself a draft"
355
+ SKIPPED=$(( SKIPPED + 1 ))
356
+ continue
357
+ fi
358
+ printf '\n%s\n' "$ADDITIONS" >> "$TARGET_PATH"
359
+ rm -- "$SRC_PATH"
360
+ log "merge-into: $FILE → $TARGET"
361
+ APPLIED=$(( APPLIED + 1 ))
362
+ ;;
363
+ *)
364
+ log "skip: unknown op '$OP' for '$FILE'"
365
+ SKIPPED=$(( SKIPPED + 1 ))
366
+ ;;
367
+ esac
368
+ done
369
+
370
+ log "done"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.10.0] - 2026-05-15
8
+
9
+ ### Added
10
+
11
+ - **Правило `adr` — фаза 2 (Normalize).** Новий Stop-hook `.claude/hooks/normalize-decisions.sh` батчево нормалізує ADR-чернетки через LLM: коли кількість файлів з `session:` у frontmatter досягає `ADR_NORMALIZE_THRESHOLD` (default 30), бере до `ADR_NORMALIZE_BATCH` найстарших, отримує JSON-операції (`rewrite` / `delete` / `merge-into`) і застосовує їх до робочого дерева. **Жодних git-операцій** — розробник дивиться `git status`/`git diff` і вирішує сам. Recursion guard `ADR_NORMALIZE_RUNNING=1`, мінімальний інтервал між спробами `ADR_NORMALIZE_MIN_INTERVAL_HOURS=6`, lock-файл, skip при mid-rebase/mid-merge, `ADR_NORMALIZE_DRY=1` для dry-run. Slug-стиль — kebab-case українською; дата у фінальному ADR береться з `captured` чернетки.
12
+ - **Skill `adr-normalize`** (slash-команда `/n-adr-normalize`) — ручний запуск normalize поза порогом і поза Stop-hook (виставляє `ADR_NORMALIZE_THRESHOLD=0` і `ADR_NORMALIZE_MIN_INTERVAL_HOURS=0`, корисно для dry-run або разової чистки). Авто-додається при `adr` у `rules`.
13
+ - **`sync-claude-config`**: експортовано `ADR_NORMALIZE_HOOK_COMMAND_MARKER` і функцію `syncAdrNormalizeHookScript`; managed-група normalize додається до `hooks.Stop` поряд з capture-групою (`async: true`, `timeout: 600`) при `"adr"` у `rules`. Маркер `.claude/hooks/normalize-decisions.sh` додано в `MANAGED_HOOK_COMMAND_MARKERS`.
14
+ - **Rego-перевірка `adr.settings_json`** тепер вимагає Stop-hook групу і для capture, і для normalize; **`adr.settings_local_json`** забороняє дублі обох хуків.
15
+
16
+ ### Changed
17
+
18
+ - **`capture-decisions.sh` тепер пише чернетки напряму в `docs/adr/<timestamp>-<sid>.md`** (раніше — у `docs/adr/_inbox/`). Сам каталог `_inbox/` більше не створюється, але `normalize-decisions.sh` бачить його рекурсивно — старі чернетки з `_inbox/` поступово розчищаються нормалізацією. Можна також одноразово `git mv docs/adr/_inbox/*.md docs/adr/` і прибрати порожній каталог.
19
+ - **Правило `adr` (`npm/rules/adr/adr.mdc`)**: повне переписування під дві фази (capture + normalize). Видалено згадки `_inbox/`. Версія `version: '2.0'`.
20
+ - **`npm/rules/adr/js/check.mjs`**: перевірка обох hook-скриптів (canonicity), обох log-файлів у `.gitignore`.
21
+
22
+ ### Fixed
23
+
24
+ - `npm/rules/adr/js/check.{mjs,test.mjs}`: виправлено `BUNDLED_HOOKS_DIR` (після phase 3 co-location шлях `'..'` указував у `npm/rules/adr/.claude-template/`, потрібно `'../../..'` — до `npm/.claude-template/`).
25
+
7
26
  ## [1.9.23] - 2026-05-14
8
27
 
9
28
  ### Fixed
package/bin/n-cursor.js CHANGED
@@ -1294,6 +1294,7 @@ async function runSync() {
1294
1294
  if (result.npmClaudeMd) parts.push('npm/CLAUDE.md')
1295
1295
  if (result.commands.length > 0) parts.push(`${result.commands.length} slash-commands`)
1296
1296
  if (result.adrHook) parts.push('.claude/hooks/capture-decisions.sh')
1297
+ if (result.adrNormalizeHook) parts.push('.claude/hooks/normalize-decisions.sh')
1297
1298
  if (parts.length > 0) {
1298
1299
  console.log(`🤖 Claude-конфіг: ${parts.join(', ')}`)
1299
1300
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.9.23",
3
+ "version": "1.10.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
package/rules/adr/adr.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- description: Автоматичний збір ADR/Runbook/Knowledge-чернеток із Stop-хука Claude Code (capture-decisions)
2
+ description: Автоматичний збір ADR/Runbook/Knowledge-чернеток і батч-нормалізація у `docs/adr/` через Stop-хук Claude Code
3
3
  alwaysApply: true
4
- version: '1.0'
4
+ version: '2.0'
5
5
  ---
6
6
 
7
7
  Правило вмикається **вручну** — додай `"adr"` у масив `rules` файлу `.n-cursor.json`. У `auto-rules.md` його немає, бо корисність залежить від робочого процесу команди (не кожен проєкт хоче літати ADR-чернеткам у `docs/`).
@@ -9,37 +9,90 @@ version: '1.0'
9
9
  Коли правило увімкнене, **`npx @nitra/cursor`** автоматично:
10
10
 
11
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`.
12
+ - копіює канонічний bash-скрипт у **`.claude/hooks/normalize-decisions.sh`** (батч-нормалізація чернеток через LLM);
13
+ - додає у **`hooks.Stop`** в **`.claude/settings.json`** дві managed-групи: capture (`async: true`, `timeout: 180`) і normalize (`async: true`, `timeout: 600`);
14
+ - ці зміни — додаткові до lint Stop-hook (`@nitra/cursor stop-hook`); усі три групи живуть поряд у `Stop`.
14
15
 
15
- ## Що робить механізм
16
+ ## Дві фази, один каталог
16
17
 
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
+ ADR живуть у єдиному каталозі **`docs/adr/`**. Є два стани файлу, які відрізняються YAML frontmatter:
19
+
20
+ - **Draft** — файл з frontmatter `session: …`, `captured: …`, `transcript: …` та timestamp-іменем `YYYYMMDD-HHMMSS-<sid>.md`. Пише `capture-decisions.sh` після кожної сесії.
21
+ - **Clean** — файл без frontmatter, з kebab-case-іменем `<slug>.md` (наприклад `ланцюжок-запуску-abie.md`). Створює `normalize-decisions.sh` або людина руками.
22
+
23
+ `normalize-decisions.sh` ніколи не чіпає clean-файли — крім випадку `merge-into`, коли дописує `## Update YYYY-MM-DD` в кінець наявного clean-файлу.
24
+
25
+ ### Фаза 1 — Capture
26
+
27
+ Stop-hook `capture-decisions.sh` зчитує JSONL-транскрипт сесії (через `jq`), витягає текст, `thinking`-блоки та назви `tool_use`-викликів, передає компактний дайджест у LLM CLI з промптом українською і записує результат у **`docs/adr/<timestamp>-<session>.md`**, якщо модель повернула блок з шапкою `## ADR|Runbook|Knowledge …`. Якщо модель повернула `NONE` (тривіальна сесія) — нічого не пишеться. Рекурсію з внутрішнього виклику моделі блокує env-var `CAPTURE_DECISIONS_RUNNING=1`.
28
+
29
+ ### Фаза 2 — Normalize
30
+
31
+ Stop-hook `normalize-decisions.sh` спрацьовує на тому самому `Stop`-евенті, але:
32
+
33
+ - Виходить миттєво, якщо чернеток (`session:` у frontmatter) менше ніж **`ADR_NORMALIZE_THRESHOLD`** (default 30).
34
+ - Виходить миттєво, якщо від попередньої спроби пройшло менше **`ADR_NORMALIZE_MIN_INTERVAL_HOURS`** годин (default 6) — щоб не крутитися щоразу, коли поріг постійний.
35
+ - Бере не більше **`ADR_NORMALIZE_BATCH`** чернеток (default 30, найстарші за іменем-timestamp), формує один промпт LLM і чекає JSON-відповідь зі списком операцій.
36
+ - Виходить миттєво, якщо репозиторій у стані `MERGE_HEAD` / `rebase-*` — небезпечно правити файли посеред конфлікту.
37
+ - Виходить миттєво, якщо інший normalize-запуск тримає `flock` на `.claude/hooks/.normalize.lock` (тільки де `flock` доступний).
38
+
39
+ LLM повертає масив операцій:
40
+
41
+ | `op` | Семантика | Поля |
42
+ |---|---|---|
43
+ | `delete` | Чернетка тривіальна / повністю покрита іншим clean-ADR-ом. | `file`, `reason` |
44
+ | `rewrite` | Чернетка стає окремим clean-файлом: frontmatter знімається, ім'я → `<slug>.md`, додаються `**Status: Accepted**` і `**Date:**` з `captured`. | `file`, `slug`, `content` |
45
+ | `merge-into` | Чернетка повторює тему вже існуючого clean-файлу; дописуємо `## Update YYYY-MM-DD` у кінець `target`. | `file`, `target`, `additions` |
46
+
47
+ `slug` — kebab-case українською (`ланцюжок-запуску-abie`, `npm-publish-flow`); англійські технічні терміни лишаються англійською без транслітерації. Колізія slug-ів обробляється детермінованим суфіксом `-2`, `-3`.
48
+
49
+ ### Жодних git-операцій
50
+
51
+ `normalize-decisions.sh` **не комітить, не `git add`, нічого з git**. Усі зміни — у робочому дереві. Розробник у зручний момент дивиться `git status` / `git diff` і вирішує: `git add` + commit, `git checkout -- <file>` для відкату, або правки руками. Це і є review-вікно.
52
+
53
+ ### Recursion guard і ENV-керування
54
+
55
+ Інший LLM CLI, який запустить normalize, успадковує `ADR_NORMALIZE_RUNNING=1` — внутрішній Stop-hook вийде відразу. Доступні ENV:
56
+
57
+ | Змінна | Default | Призначення |
58
+ |---|---|---|
59
+ | `ADR_NORMALIZE_THRESHOLD` | `30` | Поріг чернеток для запуску фази. |
60
+ | `ADR_NORMALIZE_BATCH` | `30` | Максимум чернеток у одному виклику LLM. |
61
+ | `ADR_NORMALIZE_MIN_INTERVAL_HOURS` | `6` | Мінімум між спробами (навіть якщо поріг). |
62
+ | `ADR_NORMALIZE_DRY` | `0` | `1` — лише лог запланованих операцій, без змін на диску. |
63
+ | `ADR_NORMALIZE_MODEL` | `sonnet` | Модель для `claude -p`. |
64
+ | `ADR_NORMALIZE_CURSOR_MODEL` | `claude-4.6-sonnet-medium` | Модель для `cursor-agent -p`. |
65
+
66
+ Для ручного запуску (поза порогом і поза Stop-хуком) є **`/n-adr-normalize`** — slash-команда тимчасово виставляє `ADR_NORMALIZE_THRESHOLD=0` і `ADR_NORMALIZE_MIN_INTERVAL_HOURS=0` та викликає скрипт напряму.
18
67
 
19
68
  ## LLM CLI: claude → cursor-agent fallback
20
69
 
21
- Скрипт сам обирає доступний CLI (порядок фіксований):
70
+ Обидва скрипти обирають доступний CLI (порядок фіксований):
22
71
 
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`).
72
+ 1. **`claude`** (Anthropic Claude Code CLI) — `claude -p --model "<model>"`.
73
+ 2. **`cursor-agent`** (Cursor IDE CLI) — `cursor-agent -p --mode ask --output-format text --model "<model>"`.
25
74
  3. Жодного CLI у `PATH` — скрипт виходить з кодом `0` і нічого не пише.
26
75
 
27
- `--mode ask` для cursor-agent навмисно: режим Q&A read-only, без shell/edit-інструментів — для класифікації сесії інструменти не потрібні. Моделі можна перевизначити через ENV: `CAPTURE_DECISIONS_CLAUDE_MODEL`, `CAPTURE_DECISIONS_CURSOR_MODEL`.
76
+ `--mode ask` для cursor-agent навмисно: режим Q&A read-only, без shell/edit-інструментів — для класифікації / нормалізації інструменти не потрібні.
28
77
 
29
78
  ## Структура каталогу
30
79
 
31
80
  ```text
32
81
  docs/adr/
33
- └── _inbox/ # чернетки, що пишуться Stop-хуком
34
- └── YYYYMMDD-HHMMSS-<sid>.md # session_id (перші 8 символів)
82
+ ├── YYYYMMDD-HHMMSS-<sid>.md # drafts (frontmatter session:/captured:/transcript:)
83
+ └── <slug>.md # clean ADR-и (без frontmatter)
35
84
  .claude/hooks/
36
- ├── capture-decisions.sh # auto-synced з пакета
37
- └── capture-decisions.log # лог запусків (НЕ коміти)
85
+ ├── capture-decisions.sh # auto-synced з пакета
86
+ ├── normalize-decisions.sh # auto-synced з пакета
87
+ ├── capture-decisions.log # лог запусків capture (НЕ коміти)
88
+ ├── normalize-decisions.log # лог запусків normalize (НЕ коміти)
89
+ ├── .normalize-state # timestamp останнього normalize-запуску (НЕ коміти)
90
+ └── .normalize.lock # lock-файл (НЕ коміти)
38
91
  ```
39
92
 
40
- `.gitignore` повинен містити рядок **`.claude/hooks/capture-decisions.log`**, щоб лог не потрапляв у git.
93
+ `.gitignore` повинен містити рядки, що покривають **`.claude/hooks/*.log`** і службові файли normalize (`.claude/hooks/.normalize-*`).
41
94
 
42
- `docs/adr/_inbox/` це робоча скринька; чернетки звідти переносяться у структуровані ADR-файли вручну (під час оглядів) або тримаються як архів сесій. Каталог сам створюється скриптом, тому пустим у git тримати не потрібно.
95
+ > Якщо в репозиторії лишився старий каталог **`docs/adr/_inbox/`** з попередньої версії правила `normalize-decisions.sh` бачить його рекурсивно й поступово розчистить. Можна також одразу `git mv docs/adr/_inbox/*.md docs/adr/` і прибрати порожній каталог.
43
96
 
44
97
  ## Stop-hook у `.claude/settings.json`
45
98
 
@@ -69,17 +122,28 @@ docs/adr/
69
122
  "timeout": 180
70
123
  }
71
124
  ]
125
+ },
126
+ {
127
+ "matcher": "",
128
+ "hooks": [
129
+ {
130
+ "type": "command",
131
+ "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/normalize-decisions.sh\"",
132
+ "async": true,
133
+ "timeout": 600
134
+ }
135
+ ]
72
136
  }
73
137
  ]
74
138
  }
75
139
  }
76
140
  ```
77
141
 
78
- Обидві групи ідентифікуються пакетом за маркером у `command` (`@nitra/cursor stop-hook` і `.claude/hooks/capture-decisions.sh`) — користувацькі hook-групи поряд не чіпаються. Якщо `adr` прибрати з `rules`, ADR-група автоматично видаляється на наступному `npx @nitra/cursor`.
142
+ Усі три групи ідентифікуються пакетом за маркером у `command` (`@nitra/cursor stop-hook`, `.claude/hooks/capture-decisions.sh`, `.claude/hooks/normalize-decisions.sh`) — користувацькі hook-групи поряд не чіпаються. Якщо `adr` прибрати з `rules`, обидві ADR-групи автоматично видаляються на наступному `npx @nitra/cursor`.
79
143
 
80
144
  ## Локальні vs project-shared налаштування
81
145
 
82
- Stop-hook ADR живе у **project-shared** `.claude/settings.json` (закомічений), щоб механізм працював у всіх членів команди. Якщо хук колись був у `.claude/settings.local.json` — прибери дубль вручну: project-shared і local-копія створили б два запуски на одну подію.
146
+ Обидва Stop-hook ADR живуть у **project-shared** `.claude/settings.json` (закомічений), щоб механізм працював у всіх членів команди. Якщо хук колись був у `.claude/settings.local.json` — прибери дубль вручну: project-shared і local-копія створили б два запуски на одну подію.
83
147
 
84
148
  ## Перевірка
85
149
 
@@ -1,14 +1,17 @@
1
1
  /**
2
- * Перевіряє вимоги правила adr.mdc: ADR Stop-hook capture-decisions.sh у Claude Code.
2
+ * Перевіряє вимоги правила adr.mdc: ADR Stop-hook `capture-decisions.sh` і
3
+ * `normalize-decisions.sh` у Claude Code.
3
4
  *
4
5
  * Очікування:
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`.
6
+ * - `.claude/hooks/capture-decisions.sh` та `.claude/hooks/normalize-decisions.sh`
7
+ * існують і байт-у-байт збігаються з канонічними `.claude-template/hooks/*`
8
+ * пакета (sync керує файлами повністю).
9
+ * - `.claude/settings.json` (project-shared) має managed-групи у `hooks.Stop` для
10
+ * обох скриптів (маркери у `command` самі шляхи до скриптів).
11
+ * - `.claude/settings.local.json` (якщо існує) НЕ має дублів цих managed-груп
12
+ * після переходу на project-shared такі записи створили б два запуски на одну подію.
13
+ * - `.gitignore` у корені містить шаблон, який покриває
14
+ * `.claude/hooks/capture-decisions.log` і `.claude/hooks/normalize-decisions.log`.
12
15
  *
13
16
  * LLM CLI (`claude` або `cursor-agent`) у `PATH` — інформативна перевірка: якщо жодного
14
17
  * немає, скрипт працює, але мовчки виходить, тому це warning, а не fail.
@@ -21,26 +24,48 @@ import { fileURLToPath } from 'node:url'
21
24
 
22
25
  import { createCheckReporter } from '../../../scripts/utils/check-reporter.mjs'
23
26
 
24
- const PROJECT_HOOK_PATH = '.claude/hooks/capture-decisions.sh'
27
+ /** Один hook-артефакт: bash-скрипт + його лог-файл, які перевіряємо однотипно. */
28
+ const HOOK_ARTIFACTS = /** @type {const} */ ([
29
+ { scriptName: 'capture-decisions.sh', logName: 'capture-decisions.log' },
30
+ { scriptName: 'normalize-decisions.sh', logName: 'normalize-decisions.log' }
31
+ ])
32
+
25
33
  const PROJECT_SETTINGS_PATH = '.claude/settings.json'
26
- const PROJECT_LOG_PATH = '.claude/hooks/capture-decisions.log'
27
34
  const EOL_RE = /\r?\n/u
28
35
 
29
36
  const here = dirname(fileURLToPath(import.meta.url))
30
- /** Канонічний bundled-скрипт у пакеті джерело правди для звірки з проєктним. */
31
- const BUNDLED_HOOK_PATH = join(here, '..', '.claude-template', 'hooks', 'capture-decisions.sh')
37
+ const BUNDLED_HOOKS_DIR = join(here, '..', '..', '..', '.claude-template', 'hooks')
38
+
39
+ /**
40
+ * Відносний шлях до managed hook-скрипта у проєкті.
41
+ * @param {string} scriptName базове ім'я скрипта (наприклад `capture-decisions.sh`)
42
+ * @returns {string} `.claude/hooks/<scriptName>`
43
+ */
44
+ function projectHookPath(scriptName) {
45
+ return `.claude/hooks/${scriptName}`
46
+ }
47
+
48
+ /**
49
+ * Відносний шлях до лог-файлу managed hook'а у проєкті.
50
+ * @param {string} logName базове ім'я лог-файлу (наприклад `capture-decisions.log`)
51
+ * @returns {string} `.claude/hooks/<logName>`
52
+ */
53
+ function projectLogPath(logName) {
54
+ return `.claude/hooks/${logName}`
55
+ }
32
56
 
33
57
  /**
34
- * Чи містить рядок `.gitignore` шаблон, який покриває `.claude/hooks/capture-decisions.log`.
58
+ * Чи містить рядок `.gitignore` шаблон, який покриває цей конкретний лог-файл хука.
35
59
  * Враховує точний шлях, glob `.claude/hooks/*.log` та широкий glob `**\/*.log`.
36
60
  * @param {string} line одна нормалізована (trim) лінія `.gitignore`
37
- * @returns {boolean} `true`, якщо лінія матчить лог-файл хука
61
+ * @param {string} logPath шлях `.claude/hooks/<name>.log`, який треба покрити
62
+ * @returns {boolean} `true`, якщо лінія матчить цей лог-файл
38
63
  */
39
- function gitignoreLineCoversHookLog(line) {
64
+ function gitignoreLineCoversHookLog(line, logPath) {
40
65
  if (!line || line.startsWith('#')) {
41
66
  return false
42
67
  }
43
- if (line === PROJECT_LOG_PATH) {
68
+ if (line === logPath) {
44
69
  return true
45
70
  }
46
71
  if (line === '.claude/hooks/*.log' || line === '.claude/hooks/**/*.log') {
@@ -53,28 +78,28 @@ function gitignoreLineCoversHookLog(line) {
53
78
  }
54
79
 
55
80
  /**
56
- * Перевіряє наявність і канонічність `.claude/hooks/capture-decisions.sh` у проєкті.
81
+ * Перевіряє наявність і канонічність одного hook-скрипта.
57
82
  * @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
83
+ * @param {string} scriptName базове ім'я скрипта (наприклад `capture-decisions.sh`)
58
84
  * @returns {Promise<void>}
59
85
  */
60
- async function checkHookScript(reporter) {
86
+ async function checkHookScript(reporter, scriptName) {
61
87
  const { pass, fail } = reporter
62
- if (!existsSync(PROJECT_HOOK_PATH)) {
63
- fail(`${PROJECT_HOOK_PATH} не існує — запусти \`npx @nitra/cursor\` (правило adr копіює канонічний скрипт)`)
88
+ const projectPath = projectHookPath(scriptName)
89
+ const bundledPath = join(BUNDLED_HOOKS_DIR, scriptName)
90
+ if (!existsSync(projectPath)) {
91
+ fail(`${projectPath} не існує — запусти \`npx @nitra/cursor\` (правило adr копіює канонічний скрипт)`)
64
92
  return
65
93
  }
66
- if (!existsSync(BUNDLED_HOOK_PATH)) {
67
- fail(`канонічний скрипт у пакеті не знайдено: ${BUNDLED_HOOK_PATH} — перевстанови @nitra/cursor`)
94
+ if (!existsSync(bundledPath)) {
95
+ fail(`канонічний скрипт у пакеті не знайдено: ${bundledPath} — перевстанови @nitra/cursor`)
68
96
  return
69
97
  }
70
- const [project, bundled] = await Promise.all([
71
- readFile(PROJECT_HOOK_PATH, 'utf8'),
72
- readFile(BUNDLED_HOOK_PATH, 'utf8')
73
- ])
98
+ const [project, bundled] = await Promise.all([readFile(projectPath, 'utf8'), readFile(bundledPath, 'utf8')])
74
99
  if (project === bundled) {
75
- pass(`${PROJECT_HOOK_PATH} збігається з канонічним`)
100
+ pass(`${projectPath} збігається з канонічним`)
76
101
  } else {
77
- fail(`${PROJECT_HOOK_PATH} відрізняється від канонічного — запусти \`npx @nitra/cursor\` для повторного синку`)
102
+ fail(`${projectPath} відрізняється від канонічного — запусти \`npx @nitra/cursor\` для повторного синку`)
78
103
  }
79
104
  }
80
105
 
@@ -95,25 +120,42 @@ function checkProjectSettings(reporter) {
95
120
  }
96
121
 
97
122
  /**
98
- * Перевіряє `.gitignore` на ігнорування лог-файлу хука.
123
+ * Перевіряє `.gitignore` на ігнорування лог-файлу одного хука.
124
+ * @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
125
+ * @param {string} logName базове ім'я лог-файлу (наприклад `capture-decisions.log`)
126
+ * @param {string} gitignoreContent попередньо прочитаний вміст `.gitignore`
127
+ * @returns {void}
128
+ */
129
+ function checkGitignoreForLog(reporter, logName, gitignoreContent) {
130
+ const { pass, fail } = reporter
131
+ const logPath = projectLogPath(logName)
132
+ const covers = gitignoreContent
133
+ .split(EOL_RE)
134
+ .map(l => l.trim())
135
+ .some(line => gitignoreLineCoversHookLog(line, logPath))
136
+ if (covers) {
137
+ pass(`.gitignore покриває ${logPath}`)
138
+ } else {
139
+ fail(`.gitignore не ігнорує \`${logPath}\` — додай цей рядок`)
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Перевіряє `.gitignore` для всіх hook-логів одним проходом.
99
145
  * @param {import('./utils/check-reporter.mjs').CheckReporter} reporter репортер для збору результатів
100
146
  * @returns {Promise<void>}
101
147
  */
102
148
  async function checkGitignore(reporter) {
103
- const { pass, fail } = reporter
149
+ const { fail } = reporter
104
150
  if (!existsSync('.gitignore')) {
105
- fail(`.gitignore не існує додай рядок \`${PROJECT_LOG_PATH}\``)
151
+ for (const { logName } of HOOK_ARTIFACTS) {
152
+ fail(`.gitignore не існує — додай рядок \`${projectLogPath(logName)}\``)
153
+ }
106
154
  return
107
155
  }
108
156
  const content = await readFile('.gitignore', 'utf8')
109
- const covers = content
110
- .split(EOL_RE)
111
- .map(l => l.trim())
112
- .some(line => gitignoreLineCoversHookLog(line))
113
- if (covers) {
114
- pass(`.gitignore покриває ${PROJECT_LOG_PATH}`)
115
- } else {
116
- fail(`.gitignore не ігнорує \`${PROJECT_LOG_PATH}\` — додай цей рядок`)
157
+ for (const { logName } of HOOK_ARTIFACTS) {
158
+ checkGitignoreForLog(reporter, logName, content)
117
159
  }
118
160
  }
119
161
 
@@ -166,7 +208,9 @@ function checkLlmCliAvailable(reporter) {
166
208
  */
167
209
  export async function check() {
168
210
  const reporter = createCheckReporter()
169
- await checkHookScript(reporter)
211
+ for (const { scriptName } of HOOK_ARTIFACTS) {
212
+ await checkHookScript(reporter, scriptName)
213
+ }
170
214
  checkProjectSettings(reporter)
171
215
  await checkGitignore(reporter)
172
216
  checkLlmCliAvailable(reporter)
@@ -1,13 +1,13 @@
1
- # Порт перевірки `.claude/settings.json` з `npm/scripts/check-adr.mjs` (adr.mdc):
2
- # `hooks.Stop[*]` має містити групу, де хоча б один елемент `hooks[]` має `command`
3
- # зі substring `.claude/hooks/capture-decisions.sh`.
1
+ # Перевірка `.claude/settings.json` для правила adr.mdc:
2
+ # `hooks.Stop[*]` має містити дві managed-групи capture і normalize у
3
+ # кожній хоча б один елемент `hooks[]` з відповідним маркером у `command`.
4
4
  #
5
5
  # Запуск (локально):
6
- # conftest test .claude/settings.json -p npm/policy/adr \
6
+ # conftest test .claude/settings.json -p npm/rules/adr/policy \
7
7
  # --namespace adr.settings_json
8
8
  #
9
- # Hash-порівняння bash-скрипта з канонічним bundled-варіантом і `.gitignore`-перевірки
10
- # — у JS (`check-adr.mjs`).
9
+ # Hash-порівняння bash-скриптів з канонічними bundled-варіантами і `.gitignore`-перевірки
10
+ # — у JS (`js/check.mjs`).
11
11
  #
12
12
  # Структура каталогу збігається зі шляхом пакету (regal: directory-package-mismatch).
13
13
  # Конвенція проєкту — `import rego.v1` + multi-value `deny contains msg if { … }`
@@ -16,16 +16,22 @@ package adr.settings_json
16
16
 
17
17
  import rego.v1
18
18
 
19
- hook_command_marker := ".claude/hooks/capture-decisions.sh"
19
+ capture_marker := ".claude/hooks/capture-decisions.sh"
20
+ normalize_marker := ".claude/hooks/normalize-decisions.sh"
20
21
 
21
22
  deny contains msg if {
22
- not has_adr_stop_hook
23
+ not has_stop_hook_with_marker(capture_marker)
23
24
  msg := ".claude/settings.json: відсутній Stop-hook для `capture-decisions.sh` у hooks.Stop (adr.mdc)"
24
25
  }
25
26
 
26
- # Чи є в `hooks.Stop[*].hooks[*].command` рядок з маркером скрипта.
27
- has_adr_stop_hook if {
27
+ deny contains msg if {
28
+ not has_stop_hook_with_marker(normalize_marker)
29
+ msg := ".claude/settings.json: відсутній Stop-hook для `normalize-decisions.sh` у hooks.Stop (adr.mdc)"
30
+ }
31
+
32
+ # Чи є в `hooks.Stop[*].hooks[*].command` рядок з заданим маркером.
33
+ has_stop_hook_with_marker(marker) if {
28
34
  some group in object.get(object.get(input, "hooks", {}), "Stop", [])
29
35
  some hook in object.get(group, "hooks", [])
30
- contains(object.get(hook, "command", ""), hook_command_marker)
36
+ contains(object.get(hook, "command", ""), marker)
31
37
  }
@@ -1,10 +1,10 @@
1
- # Порт перевірки `.claude/settings.local.json` з `npm/scripts/check-adr.mjs`
2
- # (adr.mdc): після переходу на project-shared `settings.json` цей файл (якщо є)
3
- # НЕ повинен мати дубля Stop-хука з маркером `.claude/hooks/capture-decisions.sh`,
4
- # інакше один і той самий скрипт виконається двічі на одну подію.
1
+ # Перевірка `.claude/settings.local.json` для правила adr.mdc: після переходу на
2
+ # project-shared `settings.json` цей файл (якщо є) НЕ повинен мати дубля жодного
3
+ # з керованих Stop-хуків (`capture-decisions.sh` або `normalize-decisions.sh`),
4
+ # інакше відповідний скрипт виконається двічі на одну подію.
5
5
  #
6
6
  # Запуск (локально):
7
- # conftest test .claude/settings.local.json -p npm/policy/adr \
7
+ # conftest test .claude/settings.local.json -p npm/rules/adr/policy \
8
8
  # --namespace adr.settings_local_json
9
9
  #
10
10
  # Структура каталогу збігається зі шляхом пакету (regal: directory-package-mismatch).
@@ -14,15 +14,27 @@ package adr.settings_local_json
14
14
 
15
15
  import rego.v1
16
16
 
17
- hook_command_marker := ".claude/hooks/capture-decisions.sh"
17
+ capture_marker := ".claude/hooks/capture-decisions.sh"
18
+ normalize_marker := ".claude/hooks/normalize-decisions.sh"
18
19
 
19
- duplicate_template := concat(" ", [
20
- ".claude/settings.local.json: видали дубль Stop-хука для",
21
- "`capture-decisions.sh` він уже у project-shared settings.json (adr.mdc)",
22
- ])
20
+ deny contains msg if {
21
+ has_stop_hook_with_marker(capture_marker)
22
+ msg := concat(" ", [
23
+ ".claude/settings.local.json: видали дубль Stop-хука для",
24
+ "`capture-decisions.sh` — він уже у project-shared settings.json (adr.mdc)",
25
+ ])
26
+ }
27
+
28
+ deny contains msg if {
29
+ has_stop_hook_with_marker(normalize_marker)
30
+ msg := concat(" ", [
31
+ ".claude/settings.local.json: видали дубль Stop-хука для",
32
+ "`normalize-decisions.sh` — він уже у project-shared settings.json (adr.mdc)",
33
+ ])
34
+ }
23
35
 
24
- deny contains duplicate_template if {
36
+ has_stop_hook_with_marker(marker) if {
25
37
  some group in object.get(object.get(input, "hooks", {}), "Stop", [])
26
38
  some hook in object.get(group, "hooks", [])
27
- contains(object.get(hook, "command", ""), hook_command_marker)
39
+ contains(object.get(hook, "command", ""), marker)
28
40
  }
@@ -14,6 +14,7 @@
14
14
  /** Порядок автододавання skills відповідно до `skills/<skill>/auto.md`. */
15
15
  export const AUTO_SKILL_ORDER = Object.freeze([
16
16
  'abie-kustomize',
17
+ 'adr-normalize',
17
18
  'fix',
18
19
  'lint',
19
20
  'llm-patch',
@@ -28,6 +29,7 @@ export const AUTO_SKILL_ORDER = Object.freeze([
28
29
  export const AUTO_SKILL_RULE_DEPENDENCIES = Object.freeze(
29
30
  /** @type {Record<string, readonly string[]>} */ ({
30
31
  'abie-kustomize': Object.freeze(['abie']),
32
+ 'adr-normalize': Object.freeze(['adr']),
31
33
  taze: Object.freeze(['bun'])
32
34
  })
33
35
  )
@@ -10,10 +10,12 @@
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;
13
+ * - `.claude/hooks/capture-decisions.sh` — fully owned bash-скрипт ADR capture Stop-hook;
14
14
  * копіюється з `.claude-template/hooks/`, лише коли в `.n-cursor.json` `rules`
15
15
  * присутнє `adr` (правило вмикається вручну). Якщо правила немає, керована
16
16
  * ADR-група в hooks так само автоматично прибирається з settings.json.
17
+ * - `.claude/hooks/normalize-decisions.sh` — fully owned bash-скрипт ADR normalize
18
+ * Stop-hook (батч-нормалізація чернеток); умови — ті самі, що для `capture`.
17
19
  *
18
20
  * Опт-аут — `claude-config: false` у `.n-cursor.json`.
19
21
  */
@@ -23,20 +25,27 @@ import { join } from 'node:path'
23
25
 
24
26
  /** Маркер lint Stop-hook'а (`npx --no \@nitra/cursor stop-hook`). */
25
27
  export const MANAGED_HOOK_COMMAND_MARKER = '@nitra/cursor stop-hook'
26
- /** Маркер ADR Stop-hook'а — підрядок шляху до bash-скрипта. */
28
+ /** Маркер ADR Stop-hook'а — підрядок шляху до bash-скрипта capture-decisions. */
27
29
  export const ADR_HOOK_COMMAND_MARKER = '.claude/hooks/capture-decisions.sh'
30
+ /** Маркер ADR Stop-hook'а — підрядок шляху до bash-скрипта normalize-decisions. */
31
+ export const ADR_NORMALIZE_HOOK_COMMAND_MARKER = '.claude/hooks/normalize-decisions.sh'
28
32
  /** Усі маркери managed-hook'ів пакета — за ними відрізняємо свої записи від користувацьких. */
29
- export const MANAGED_HOOK_COMMAND_MARKERS = Object.freeze([MANAGED_HOOK_COMMAND_MARKER, ADR_HOOK_COMMAND_MARKER])
33
+ export const MANAGED_HOOK_COMMAND_MARKERS = Object.freeze([
34
+ MANAGED_HOOK_COMMAND_MARKER,
35
+ ADR_HOOK_COMMAND_MARKER,
36
+ ADR_NORMALIZE_HOOK_COMMAND_MARKER
37
+ ])
30
38
 
31
39
  const CLAUDE_DIR = '.claude'
32
40
  const CLAUDE_SETTINGS_FILE = `${CLAUDE_DIR}/settings.json`
33
41
  const CLAUDE_COMMANDS_DIR = `${CLAUDE_DIR}/commands`
34
42
  const CLAUDE_HOOKS_DIR = `${CLAUDE_DIR}/hooks`
35
43
  const ADR_HOOK_SCRIPT_NAME = 'capture-decisions.sh'
44
+ const ADR_NORMALIZE_HOOK_SCRIPT_NAME = 'normalize-decisions.sh'
36
45
  const NPM_CLAUDE_MD_FILE = 'npm/CLAUDE.md'
37
46
  const TEMPLATE_DIR_NAME = '.claude-template'
38
47
 
39
- /** Канонічна група hooks для ADR Stop-hook'а — додається в settings, коли `adr` у `rules`. */
48
+ /** Канонічна група hooks для ADR capture Stop-hook'а — додається в settings, коли `adr` у `rules`. */
40
49
  const ADR_STOP_HOOK_GROUP = Object.freeze({
41
50
  matcher: '',
42
51
  hooks: Object.freeze([
@@ -49,6 +58,19 @@ const ADR_STOP_HOOK_GROUP = Object.freeze({
49
58
  ])
50
59
  })
51
60
 
61
+ /** Канонічна група hooks для ADR normalize Stop-hook'а — батч-нормалізація чернеток у `docs/adr/`. */
62
+ const ADR_NORMALIZE_STOP_HOOK_GROUP = Object.freeze({
63
+ matcher: '',
64
+ hooks: Object.freeze([
65
+ Object.freeze({
66
+ type: 'command',
67
+ command: `bash "$CLAUDE_PROJECT_DIR/${ADR_NORMALIZE_HOOK_COMMAND_MARKER}"`,
68
+ async: true,
69
+ timeout: 600
70
+ })
71
+ ])
72
+ })
73
+
52
74
  /**
53
75
  * @typedef {object} HookEntry
54
76
  * @property {string} type тип hook'а у форматі Claude Code (зазвичай `'command'`)
@@ -140,7 +162,11 @@ function templateWithAdrHook(template) {
140
162
  for (const [event, groups] of Object.entries(template.hooks ?? {})) {
141
163
  hooks[event] = Array.isArray(groups) ? [...groups] : []
142
164
  }
143
- hooks.Stop = [...(hooks.Stop ?? []), /** @type {HookGroup} */ (ADR_STOP_HOOK_GROUP)]
165
+ hooks.Stop = [
166
+ ...(hooks.Stop ?? []),
167
+ /** @type {HookGroup} */ (ADR_STOP_HOOK_GROUP),
168
+ /** @type {HookGroup} */ (ADR_NORMALIZE_STOP_HOOK_GROUP)
169
+ ]
144
170
  return { ...template, hooks }
145
171
  }
146
172
 
@@ -209,24 +235,45 @@ export async function syncClaudeSettings(projectRoot, templateDir, options = {})
209
235
  }
210
236
 
211
237
  /**
212
- * Копіює канонічний `.claude/hooks/capture-decisions.sh` з темплейту пакета.
238
+ * Копіює один канонічний bash-скрипт hook'а з темплейту пакета у `.claude/hooks/`.
213
239
  * Файл повністю керується пакетом — на кожен sync перезаписується (як setup-bun-deps).
214
240
  * @param {string} projectRoot корінь проєкту, куди писати
215
241
  * @param {string} templateDir каталог `.claude-template/` усередині пакету
242
+ * @param {string} scriptName базове ім'я скрипта (наприклад `capture-decisions.sh`)
216
243
  * @returns {Promise<{ written: boolean, path: string }>} результат: чи писали файл, та його відносний шлях
217
244
  */
218
- export async function syncAdrHookScript(projectRoot, templateDir) {
219
- const templatePath = join(templateDir, 'hooks', ADR_HOOK_SCRIPT_NAME)
245
+ async function syncHookScript(projectRoot, templateDir, scriptName) {
246
+ const templatePath = join(templateDir, 'hooks', scriptName)
220
247
  if (!existsSync(templatePath)) {
221
248
  return { written: false, path: '' }
222
249
  }
223
250
  const content = await readFile(templatePath, 'utf8')
224
251
  const hooksDir = join(projectRoot, CLAUDE_HOOKS_DIR)
225
252
  await mkdir(hooksDir, { recursive: true })
226
- const destPath = join(hooksDir, ADR_HOOK_SCRIPT_NAME)
253
+ const destPath = join(hooksDir, scriptName)
227
254
  await writeFile(destPath, content, 'utf8')
228
255
  await chmod(destPath, 0o755)
229
- return { written: true, path: `${CLAUDE_HOOKS_DIR}/${ADR_HOOK_SCRIPT_NAME}` }
256
+ return { written: true, path: `${CLAUDE_HOOKS_DIR}/${scriptName}` }
257
+ }
258
+
259
+ /**
260
+ * Копіює канонічний `.claude/hooks/capture-decisions.sh` з темплейту пакета.
261
+ * @param {string} projectRoot корінь проєкту, куди писати
262
+ * @param {string} templateDir каталог `.claude-template/` усередині пакету
263
+ * @returns {Promise<{ written: boolean, path: string }>} результат: чи писали файл, та його відносний шлях
264
+ */
265
+ export function syncAdrHookScript(projectRoot, templateDir) {
266
+ return syncHookScript(projectRoot, templateDir, ADR_HOOK_SCRIPT_NAME)
267
+ }
268
+
269
+ /**
270
+ * Копіює канонічний `.claude/hooks/normalize-decisions.sh` з темплейту пакета.
271
+ * @param {string} projectRoot корінь проєкту, куди писати
272
+ * @param {string} templateDir каталог `.claude-template/` усередині пакету
273
+ * @returns {Promise<{ written: boolean, path: string }>} результат: чи писали файл, та його відносний шлях
274
+ */
275
+ export function syncAdrNormalizeHookScript(projectRoot, templateDir) {
276
+ return syncHookScript(projectRoot, templateDir, ADR_NORMALIZE_HOOK_SCRIPT_NAME)
230
277
  }
231
278
 
232
279
  /**
@@ -283,20 +330,29 @@ export async function syncClaudeCommands(projectRoot, templateDir) {
283
330
  * @param {string} options.bundledPackageRoot корінь установленого `@nitra/cursor`
284
331
  * @param {boolean} options.enabled чи увімкнено sync (з `.n-cursor.json` `claude-config`)
285
332
  * @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-команд
333
+ * @returns {Promise<{ settings: boolean, npmClaudeMd: boolean, commands: string[], adrHook: boolean, adrNormalizeHook: boolean }>} прапорці записів settings/CLAUDE.md/ADR-hook(s) та список записаних slash-команд
287
334
  */
288
335
  export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enabled, rules = [] }) {
289
336
  if (!enabled) {
290
- return { settings: false, npmClaudeMd: false, commands: [], adrHook: false }
337
+ return { settings: false, npmClaudeMd: false, commands: [], adrHook: false, adrNormalizeHook: false }
291
338
  }
292
339
  const templateDir = join(bundledPackageRoot, TEMPLATE_DIR_NAME)
293
340
  if (!existsSync(templateDir)) {
294
- return { settings: false, npmClaudeMd: false, commands: [], adrHook: false }
341
+ return { settings: false, npmClaudeMd: false, commands: [], adrHook: false, adrNormalizeHook: false }
295
342
  }
296
343
  const includeAdrHook = Array.isArray(rules) && rules.includes('adr')
297
344
  const adrHook = includeAdrHook ? await syncAdrHookScript(projectRoot, templateDir) : { written: false, path: '' }
345
+ const adrNormalizeHook = includeAdrHook
346
+ ? await syncAdrNormalizeHookScript(projectRoot, templateDir)
347
+ : { written: false, path: '' }
298
348
  const settings = await syncClaudeSettings(projectRoot, templateDir, { includeAdrHook })
299
349
  const npmClaudeMd = await syncNpmClaudeMd(projectRoot, templateDir)
300
350
  const commands = await syncClaudeCommands(projectRoot, templateDir)
301
- return { settings: settings.written, npmClaudeMd: npmClaudeMd.written, commands, adrHook: adrHook.written }
351
+ return {
352
+ settings: settings.written,
353
+ npmClaudeMd: npmClaudeMd.written,
354
+ commands,
355
+ adrHook: adrHook.written,
356
+ adrNormalizeHook: adrNormalizeHook.written
357
+ }
302
358
  }
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: n-adr-normalize
3
+ description: >-
4
+ Ручний запуск ADR-нормалізації — обхід порогу й min-interval, прогон одного
5
+ батчу чернеток через LLM, перегляд результату через git diff
6
+ ---
7
+
8
+ # n-adr-normalize — ручна нормалізація ADR-чернеток
9
+
10
+ Скіл запускає `.claude/hooks/normalize-decisions.sh` поза звичайним Stop-hook-тригером. Корисно, коли:
11
+
12
+ - Поріг `ADR_NORMALIZE_THRESHOLD` ще не досягнуто, але хочеш почистити inbox.
13
+ - Минулого разу LLM відмовився, тепер минув ще не весь `ADR_NORMALIZE_MIN_INTERVAL_HOURS` — хочеш повторити одразу.
14
+ - Спочатку треба побачити, що саме LLM зробить (`ADR_NORMALIZE_DRY=1`).
15
+
16
+ ## Передумови
17
+
18
+ - Правило `adr` увімкнене у `.n-cursor.json` (`"adr"` у `rules`).
19
+ - `.claude/hooks/normalize-decisions.sh` існує (`npx @nitra/cursor` поклав його сюди).
20
+ - У `PATH` доступний `claude` або `cursor-agent` (інакше скрипт мовчки вийде).
21
+ - У `docs/adr/` є чернетки — файли з `session: …` у YAML frontmatter.
22
+
23
+ ## Кроки
24
+
25
+ 1. **Dry-run** (не міняє файли, лише пише план у `.claude/hooks/normalize-decisions.log`):
26
+
27
+ ```bash
28
+ ADR_NORMALIZE_THRESHOLD=0 \
29
+ ADR_NORMALIZE_MIN_INTERVAL_HOURS=0 \
30
+ ADR_NORMALIZE_DRY=1 \
31
+ bash .claude/hooks/normalize-decisions.sh
32
+ ```
33
+
34
+ Потім переглянь план: `tail -100 .claude/hooks/normalize-decisions.log`.
35
+
36
+ 2. **Реальний прогон одного батчу** (за замовчуванням до 30 чернеток):
37
+
38
+ ```bash
39
+ ADR_NORMALIZE_THRESHOLD=0 \
40
+ ADR_NORMALIZE_MIN_INTERVAL_HOURS=0 \
41
+ bash .claude/hooks/normalize-decisions.sh
42
+ ```
43
+
44
+ 3. **Перегляд результату** — скрипт нічого не комітить:
45
+
46
+ ```bash
47
+ git status docs/adr/
48
+ git diff docs/adr/
49
+ ```
50
+
51
+ Видалені файли — `delete`-операція. Нові файли `<slug>.md` — `rewrite`. Модифіковані clean-файли — `merge-into`.
52
+
53
+ 4. **Прийняти / відкотити:**
54
+
55
+ - Прийняти все: `git add docs/adr/ && git commit -m "adr: normalize batch"`.
56
+ - Відкотити конкретний файл: `git checkout -- docs/adr/<file>` (для `rewrite` цього мало — треба ще `git restore --staged` і `rm` нового).
57
+ - Відкотити весь батч: `git checkout -- docs/adr/ && git clean -f docs/adr/` (видалить і untracked rewrite-результати).
58
+
59
+ 5. **Повторити для наступного батчу**, якщо чернеток ще багато. Кожен запуск обробляє до `ADR_NORMALIZE_BATCH` файлів (default 30, найстарші за часовою позначкою у назві).
60
+
61
+ ## Tuning через ENV
62
+
63
+ - `ADR_NORMALIZE_BATCH=10` — менший батч (менше токенів, частіші коміти).
64
+ - `ADR_NORMALIZE_MODEL=opus` — інша модель `claude -p`.
65
+ - `ADR_NORMALIZE_CURSOR_MODEL=…` — інша модель для cursor-agent fallback.
66
+
67
+ ## Якщо щось пішло не так
68
+
69
+ - LLM повернув криву JSON → у логу буде `invalid JSON response (first 200 chars): …`. Запусти ще раз — нерідко це разовий збій.
70
+ - Скрипт виходить миттєво без логу → перевір `ADR_NORMALIZE_RUNNING` у env (recursion guard) і чи репо не у стані merge/rebase.
71
+ - Перейменування зробило слаги-дублі (`<slug>-2.md`) → це нормально, скрипт детермінований; під час review можна обʼєднати руками й видалити `-2`.
@@ -0,0 +1 @@
1
+ [adr]