@nitra/cursor 1.9.23 → 1.11.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.
Files changed (84) hide show
  1. package/.claude-template/hooks/capture-decisions.sh +3 -3
  2. package/.claude-template/hooks/normalize-decisions.sh +370 -0
  3. package/CHANGELOG.md +52 -0
  4. package/bin/n-cursor.js +30 -29
  5. package/package.json +2 -1
  6. package/rules/abie/js/applies/check.mjs +24 -0
  7. package/rules/abie/js/env_dns/check.mjs +53 -0
  8. package/rules/abie/js/firebase_hosting/check.mjs +49 -0
  9. package/rules/abie/js/hc_pairing/check.mjs +58 -0
  10. package/rules/abie/js/ua_http_route/check.mjs +86 -0
  11. package/rules/abie/js/ua_node_selector/check.mjs +65 -0
  12. package/rules/abie/policy/base_deployment_preem/target.json +10 -0
  13. package/rules/abie/policy/clean_merged_ignore_branches/target.json +4 -0
  14. package/rules/abie/policy/health_check_policy/target.json +4 -0
  15. package/rules/abie/policy/http_route_base/target.json +4 -0
  16. package/rules/abie/utils/enabled.mjs +35 -0
  17. package/rules/abie/utils/env-dns.mjs +81 -0
  18. package/rules/abie/utils/hc-yaml.mjs +27 -0
  19. package/rules/abie/utils/http-route.mjs +93 -0
  20. package/rules/abie/utils/k8s-tree.mjs +102 -0
  21. package/rules/abie/utils/kustomization-patches.mjs +224 -0
  22. package/rules/abie/utils/overlay-paths.mjs +97 -0
  23. package/rules/abie/utils/yaml.mjs +72 -0
  24. package/rules/adr/adr.mdc +82 -18
  25. package/rules/adr/js/check.mjs +84 -40
  26. package/rules/adr/policy/settings_json/settings_json.rego +17 -11
  27. package/rules/adr/policy/settings_json/target.json +4 -0
  28. package/rules/adr/policy/settings_local_json/settings_local_json.rego +24 -12
  29. package/rules/adr/policy/settings_local_json/target.json +4 -0
  30. package/rules/bun/policy/bunfig/target.json +4 -0
  31. package/rules/bun/policy/package_json/target.json +4 -0
  32. package/rules/capacitor/policy/package_json/target.json +4 -0
  33. package/rules/docker/policy/lint_docker_yml/target.json +4 -0
  34. package/rules/docker/policy/package_json/target.json +4 -0
  35. package/rules/hasura/policy/svc_hl/target.json +4 -0
  36. package/rules/image-avif/policy/package_json/target.json +4 -0
  37. package/rules/image-compress/policy/package_json/target.json +4 -0
  38. package/rules/js-bun-db/policy/package_json/target.json +4 -0
  39. package/rules/js-bun-redis/policy/package_json/target.json +4 -0
  40. package/rules/js-lint/policy/lint_js_yml/target.json +4 -0
  41. package/rules/js-lint/policy/package_json/target.json +4 -0
  42. package/rules/js-mssql/policy/package_json/target.json +4 -0
  43. package/rules/js-run/policy/configmap/target.json +4 -0
  44. package/rules/js-run/policy/package_json/target.json +4 -0
  45. package/rules/k8s/policy/base_kustomization/target.json +4 -0
  46. package/rules/k8s/policy/base_manifest/target.json +10 -0
  47. package/rules/k8s/policy/gateway/target.json +4 -0
  48. package/rules/k8s/policy/hpa_pdb/target.json +4 -0
  49. package/rules/k8s/policy/kustomization/target.json +4 -0
  50. package/rules/k8s/policy/manifest/target.json +4 -0
  51. package/rules/k8s/policy/svc_hl_yaml/target.json +4 -0
  52. package/rules/k8s/policy/svc_yaml/target.json +4 -0
  53. package/rules/npm-module/policy/emit_types_config/target.json +4 -0
  54. package/rules/npm-module/policy/npm_package_json/target.json +4 -0
  55. package/rules/npm-module/policy/npm_publish_yml/target.json +4 -0
  56. package/rules/npm-module/policy/root_package_json/target.json +4 -0
  57. package/rules/php/policy/lint_php_yml/target.json +4 -0
  58. package/rules/php/policy/package_json/target.json +4 -0
  59. package/rules/rego/js/applies/check.mjs +54 -0
  60. package/rules/rego/policy/package_json/target.json +5 -0
  61. package/rules/rego/policy/vscode_extensions/target.json +5 -0
  62. package/rules/rego/policy/vscode_settings/target.json +5 -0
  63. package/rules/style-lint/policy/lint_style_yml/target.json +4 -0
  64. package/rules/style-lint/policy/package_json/target.json +4 -0
  65. package/rules/style-lint/policy/vscode_extensions/target.json +4 -0
  66. package/rules/style-lint/policy/vscode_settings/target.json +4 -0
  67. package/rules/text/policy/cspell/target.json +4 -0
  68. package/rules/text/policy/markdownlint/target.json +4 -0
  69. package/rules/text/policy/oxfmtrc/target.json +4 -0
  70. package/rules/text/policy/package_json/target.json +4 -0
  71. package/rules/text/policy/vscode_extensions/target.json +4 -0
  72. package/rules/text/policy/vscode_settings/target.json +4 -0
  73. package/rules/vue/policy/package_json/target.json +4 -0
  74. package/schemas/target.json +58 -0
  75. package/scripts/auto-skills.mjs +2 -0
  76. package/scripts/lint-conftest.mjs +65 -414
  77. package/scripts/sync-claude-config.mjs +70 -14
  78. package/scripts/utils/discover-checkable-rules.mjs +123 -0
  79. package/scripts/utils/resolve-target-files.mjs +109 -0
  80. package/scripts/utils/run-rule.mjs +131 -0
  81. package/skills/adr-normalize/SKILL.md +71 -0
  82. package/skills/adr-normalize/auto.md +1 -0
  83. package/rules/abie/js/check.mjs +0 -1152
  84. package/rules/rego/js/check.mjs +0 -106
@@ -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,58 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.11.0] - 2026-05-15
8
+
9
+ ### Added
10
+
11
+ - **Concern-based JS + per-policy `target.json`** — нова інфраструктура для CLI `check`:
12
+ - `npm/rules/<id>/js/<concern>/check*.mjs` — JS-концерни замість одного плаского `js/check.mjs`. Дзеркалить `policy/<name>/`: один `<name>` = одна одиниця відповідальності (rego, JS, або hybrid).
13
+ - `npm/rules/<id>/policy/<name>/target.json` — декларативний маніфест поруч із `<name>.rego` описує, які файли фідити в conftest (`{ "files": { "single": "..." | "walkGlob": [...] } }`). CLI читає сам і викликає `runConftestBatch` — JS не зобовʼязаний дублювати.
14
+ - **Pure-rego правила** працюють без жодного `.mjs`: CLI знаходить полісі за `target.json` і прогонить їх через `runConftestBatch`.
15
+ - **Applies-гейт**: `rules/<id>/js/applies/check.mjs` може експортувати `applies()`. Якщо повертає `false` — CLI пропускає правило цілком (включно з policy-концернами).
16
+ - **JSON Schema** у `npm/schemas/target.json` для IDE-валідації `target.json`.
17
+ - **picomatch@^4.0.4** — runtime dependency для `walkGlob`-резолверу.
18
+ - **Нові утиліти** в `npm/scripts/utils/`:
19
+ - `discover-checkable-rules.mjs` — обхід `rules/`, повертає `{ id, jsConcerns, policyConcerns }[]`. Legacy-fallback: плаский `js/check.mjs` маппиться у концерн `legacy`, щоб не ламати ще не мігровані правила під час переходу.
20
+ - `resolve-target-files.mjs` — резолвер `files.single` / `files.walkGlob` з спільним walk-кешем на check-прогон (повторні таргети з тим самим `ignorePaths` не роблять додаткового `walkDir`). Path-traversal у `single` блокується.
21
+ - `run-rule.mjs` — оркестратор одного правила: applies-гейт → JS-концерни → policy-концерни. Exit-код агрегується OR-ом.
22
+
23
+ ### Changed
24
+
25
+ - **`npm/bin/n-cursor.js`** — `discoverCheckScripts` тепер делегує у `discoverCheckableRules` і повертає `CheckableRule[]` замість `string[]`. `runChecks` запускає `runRule` для кожного id зі shared `walkCache` (один обхід дерева на унікальний `ignorePaths`-сигнатуру).
26
+ - **`npm/rules/rego/`** — пілот міграції на нову структуру:
27
+ - `js/check.mjs` → `js/applies/check.mjs` з експортами `applies()` (gate за наявністю `*.rego` у дереві) і `check()` (короткий context-pass). Виклики `runConftestBatch` прибрано — їх тепер виконує CLI через `target.json`.
28
+ - `policy/{package_json,vscode_extensions,vscode_settings}/target.json` додано (`single` + `required: true` + кастомні `missingMessage`).
29
+ - **`npm/package.json`** — додано `picomatch` у `dependencies`.
30
+
31
+ - **Правило `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` чернетки.
32
+ - **Skill `adr-normalize`** (slash-команда `/n-adr-normalize`) — ручний запуск normalize поза порогом і поза Stop-hook (виставляє `ADR_NORMALIZE_THRESHOLD=0` і `ADR_NORMALIZE_MIN_INTERVAL_HOURS=0`, корисно для dry-run або разової чистки). Авто-додається при `adr` у `rules`.
33
+ - **`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`.
34
+ - **Rego-перевірка `adr.settings_json`** тепер вимагає Stop-hook групу і для capture, і для normalize; **`adr.settings_local_json`** забороняє дублі обох хуків.
35
+ - **Інкрементальна міграція правил на `target.json`** (декларативні маніфести поруч із кожним `<concern>.rego`):
36
+ - **Single-file правила** (11): `bun`, `text`, `style-lint`, `php`, `docker`, `npm-module`, `js-lint`, `image-compress`, `capacitor`, `hasura`, `adr` — `target.json` з `single` для кожного канонічного конфіг-файлу. Сумарно 27 нових маніфестів. `bun.bunfig`, `text.cspell`, `npm_module.npm_publish_yml` тощо тепер прогоняються через CLI `check <id>` без додаткового `bun run lint-conftest`.
37
+ - **Walk-glob правила** (6): `js-mssql`, `js-bun-db`, `js-bun-redis`, `js-run` (package_json + configmap), `vue`, `image-avif` — `walkGlob: "**/package.json"` або відповідний патерн.
38
+ - **k8s.* концерни** (8): `manifest`, `gateway`, `hpa_pdb`, `kustomization`, `svc_yaml`, `svc_hl_yaml`, `base_kustomization`, `base_manifest` — `walkGlob` по YAML під сегментом `k8s/`; `base_manifest` використовує негативний glob для виключення `kustomization.yaml`.
39
+ - **abie концерни** (4): `clean_merged_ignore_branches` (single), `health_check_policy` (walkGlob `**/k8s/**/hc.yaml`), `http_route_base` (walkGlob `**/k8s/**/base/**/hr.yaml`), `base_deployment_preem` (walkGlob `**/k8s/**/base/**/*.{yaml,yml}` з виключенням `kustomization.yaml`).
40
+
41
+ ### Changed
42
+
43
+ - **`capture-decisions.sh` тепер пише чернетки напряму в `docs/adr/<timestamp>-<sid>.md`** (раніше — у `docs/adr/_inbox/`). Сам каталог `_inbox/` більше не створюється, але `normalize-decisions.sh` бачить його рекурсивно — старі чернетки з `_inbox/` поступово розчищаються нормалізацією. Можна також одноразово `git mv docs/adr/_inbox/*.md docs/adr/` і прибрати порожній каталог.
44
+ - **Правило `adr` (`npm/rules/adr/adr.mdc`)**: повне переписування під дві фази (capture + normalize). Видалено згадки `_inbox/`. Версія `version: '2.0'`.
45
+ - **`npm/rules/adr/js/check.mjs`**: перевірка обох hook-скриптів (canonicity), обох log-файлів у `.gitignore`.
46
+ - **`npm/scripts/lint-conftest.mjs`**: повне переписування. Замість hardcoded `TARGETS`-таблиці (~280 рядків з регексами та walker-преди­катами) скрипт викликає `discoverCheckableRules` і читає `target.json` для кожного policy-концерну. Файл-резолвер — спільний з CLI `check` (`resolveTargetFiles` + walk-кеш). Поведінка для користувача однакова (`stdio: 'inherit'` зберігається для рідного форматування conftest), але джерело правди тепер — `target.json` поруч із `.rego`. Скрипт став `async` (для `await readFile`/`discoverCheckableRules`).
47
+ - **`npm/rules/abie/`** — завершено concern-split:
48
+ - `js/check.mjs` (1153 рядки) видалено, його логіка розпорошена по `js/{applies,firebase_hosting,hc_pairing,ua_node_selector,ua_http_route,env_dns}/check.mjs`.
49
+ - Спільні стан і утиліти у `utils/{enabled,k8s-tree,overlay-paths,kustomization-patches,http-route,hc-yaml,env-dns,yaml}.mjs`. `k8s-tree.mjs` тримає module-level кеш `findK8sYamlFiles` + `collectDeploymentDirs` — повторні виклики з різних концернів не роблять нового обходу дерева.
50
+ - Виклики `runConftestBatch` прибрано з JS — їх тепер виконує CLI через `target.json`.
51
+
52
+ ### Fixed
53
+
54
+ - `npm/rules/adr/js/check.{mjs,test.mjs}`: виправлено `BUNDLED_HOOKS_DIR` (після phase 3 co-location шлях `'..'` указував у `npm/rules/adr/.claude-template/`, потрібно `'../../..'` — до `npm/.claude-template/`).
55
+ - **`scripts/utils/run-rule.mjs`**: kebab-id правила (`style-lint`, `image-compress`, `js-lint`, `npm-module`, `js-mssql`, `js-bun-db`, `js-bun-redis`, `js-run`, `image-avif`, `nginx-default-tpl`) тепер коректно мапиться у snake-namespace rego (`style_lint.<concern>` тощо). Раніше `namespace: <id>.<concern>` давав `style-lint.package_json`, що не збігалося з `package style_lint.package_json` у `.rego` → conftest повертав 0 violations попри реальні порушення.
56
+ - **`scripts/utils/resolve-target-files.mjs`**: виправлено інтерпретацію негативних glob-патернів. `picomatch(['pos', '!neg'])` за дефолтом трактує `!neg` як окремий позитивний матчер «не-neg» (OR-логіка), що матчило майже всі шляхи. Тепер позитивні/негативні розділяються вручну, негативи застосовуються через `!isExcluded`. Виправляє `k8s.base_manifest`-таргет, який мав виключати `kustomization.yaml`, але до фіксу матчив усе дерево.
57
+ - **`scripts/utils/discover-checkable-rules.mjs`**: коли правило має й плаский `js/check.mjs`, і concern-підкаталоги, CLI прогонить **тільки** концерни. Раніше додавалися обидва → дублюючий вивід для правил у стані часткової міграції.
58
+
7
59
  ## [1.9.23] - 2026-05-14
8
60
 
9
61
  ### Fixed
package/bin/n-cursor.js CHANGED
@@ -66,8 +66,10 @@ import {
66
66
  } from '../scripts/auto-rules.mjs'
67
67
  import { detectAutoSkills } from '../scripts/auto-skills.mjs'
68
68
  import { runStopHookCli } from '../scripts/claude-stop-hook.mjs'
69
+ import { discoverCheckableRules } from '../scripts/utils/discover-checkable-rules.mjs'
69
70
  import { ensureNitraCursorInRootDevDependencies } from '../scripts/ensure-nitra-cursor-dev-dependencies.mjs'
70
71
  import { runLintGaCli } from '../rules/ga/js/lint.mjs'
72
+ import { runRule } from '../scripts/utils/run-rule.mjs'
71
73
  import { syncClaudeConfig } from '../scripts/sync-claude-config.mjs'
72
74
  import { upgradeNitraCursorToLatestAndBunInstall } from '../scripts/upgrade-nitra-cursor-and-install.mjs'
73
75
  import { runRenameYamlExtensionsCli } from './rename-yaml-extensions.mjs'
@@ -986,18 +988,14 @@ function logRemovedManagedItems(title, basePath, names) {
986
988
  }
987
989
 
988
990
  /**
989
- * Знаходить доступні check-скрипти. Кожне правило з check-скриптом тримає його у
990
- * `rules/<id>/js/check.mjs` (rule-centric layout).
991
- * @returns {Promise<string[]>} відсортовані id правил, для яких є check.mjs
991
+ * Знаходить правила, для яких є хоча б щось прогонне: JS-концерн у `rules/<id>/js/<concern>/check*.mjs`
992
+ * (плюс legacy `rules/<id>/js/check.mjs` як концерн `legacy`) або policy-концерн у
993
+ * `rules/<id>/policy/<concern>/target.json`. Делегує у `discoverCheckableRules`
994
+ * див. `scripts/utils/discover-checkable-rules.mjs`.
995
+ * @returns {Promise<import('../scripts/utils/discover-checkable-rules.mjs').CheckableRule[]>} опис правил у алфавітному порядку
992
996
  */
993
997
  async function discoverCheckScripts() {
994
- if (!existsSync(BUNDLED_RULES_DIR)) return []
995
- const entries = await readdir(BUNDLED_RULES_DIR, { withFileTypes: true })
996
- return entries
997
- .filter(e => e.isDirectory() && !e.name.startsWith('.'))
998
- .filter(e => existsSync(join(BUNDLED_RULES_DIR, e.name, 'js', 'check.mjs')))
999
- .map(e => e.name)
1000
- .toSorted((a, b) => a.localeCompare(b))
998
+ return discoverCheckableRules(BUNDLED_RULES_DIR)
1001
999
  }
1002
1000
 
1003
1001
  /**
@@ -1048,13 +1046,15 @@ async function discoverCheckRulesFromAgentsMd(available) {
1048
1046
  }
1049
1047
 
1050
1048
  /**
1051
- * Запускає перевірки: без аргументів — за списком у AGENTS.md; з аргументами — лише вказані правила
1049
+ * Запускає перевірки: без аргументів — за списком у AGENTS.md; з аргументами — лише вказані правила.
1050
+ * Делегує оркестрацію concern-ів (JS-checks + policy через `runConftestBatch`) у `runRule`;
1051
+ * сам `runChecks` відповідає лише за фільтр id, агрегацію exit-кодів і shared walk-cache на прогон.
1052
1052
  * @param {string[]} requestedRules імена правил; порожній масив — брати з AGENTS.md
1053
1053
  * @returns {Promise<void>}
1054
1054
  */
1055
1055
  async function runChecks(requestedRules) {
1056
- const available = await discoverCheckScripts()
1057
- if (available.length === 0) {
1056
+ const allRules = await discoverCheckScripts()
1057
+ if (allRules.length === 0) {
1058
1058
  console.error('❌ Не знайдено жодного check-скрипта у пакеті')
1059
1059
  throw new Error('No check scripts found')
1060
1060
  }
@@ -1070,38 +1070,38 @@ async function runChecks(requestedRules) {
1070
1070
  }
1071
1071
  }
1072
1072
 
1073
- let rulesToCheck
1073
+ const available = allRules.map(r => r.id)
1074
+ let idsToCheck
1074
1075
  if (requestedRules.length > 0) {
1075
- rulesToCheck = requestedRules
1076
+ idsToCheck = requestedRules
1076
1077
  } else {
1077
- rulesToCheck = await discoverCheckRulesFromAgentsMd(available)
1078
- if (rulesToCheck.length === 0) {
1078
+ idsToCheck = await discoverCheckRulesFromAgentsMd(available)
1079
+ if (idsToCheck.length === 0) {
1079
1080
  console.log(
1080
1081
  `\n🔍 ${PACKAGE_NAME} check — у ${AGENTS_FILE} немає правил з programmatic перевіркою ` +
1081
- `(відповідного check-*.mjs у пакеті). Нічого не запущено.\n`
1082
+ `(відповідного check-*.mjs або policy/<name>/target.json у пакеті). Нічого не запущено.\n`
1082
1083
  )
1083
1084
  return
1084
1085
  }
1085
1086
  }
1086
1087
 
1087
- const unknown = rulesToCheck.filter(r => !available.includes(r))
1088
+ const unknown = idsToCheck.filter(id => !available.includes(id))
1088
1089
  if (unknown.length > 0) {
1089
1090
  console.error(`❌ Невідомі правила: ${unknown.join(', ')}`)
1090
1091
  console.log(` Доступні: ${available.join(', ')}`)
1091
1092
  throw new Error(`Unknown rules: ${unknown.join(', ')}`)
1092
1093
  }
1093
1094
 
1094
- console.log(`\n🔍 ${PACKAGE_NAME} check — перевірка правил (${rulesToCheck.length})\n`)
1095
+ console.log(`\n🔍 ${PACKAGE_NAME} check — перевірка правил (${idsToCheck.length})\n`)
1095
1096
 
1097
+ const ruleMap = new Map(allRules.map(r => [r.id, r]))
1098
+ /** @type {Map<string, Promise<string[]>>} shared walk-cache (cross-concern, cross-rule у межах одного прогону) */
1099
+ const walkCache = new Map()
1096
1100
  let totalFailed = 0
1097
1101
 
1098
- for (const rule of rulesToCheck) {
1099
- const scriptPath = join(BUNDLED_RULES_DIR, rule, 'js', 'check.mjs')
1100
- console.log(`📋 ${rule}:`)
1102
+ for (const id of idsToCheck) {
1101
1103
  try {
1102
- // eslint-disable-next-line no-unsanitized/method -- rule валідовано проти available, scriptPath будується з фіксованої BUNDLED_RULES_DIR
1103
- const { check } = await import(scriptPath)
1104
- const code = await check()
1104
+ const code = await runRule(ruleMap.get(id), BUNDLED_RULES_DIR, walkCache)
1105
1105
  if (code !== 0) totalFailed++
1106
1106
  } catch (error) {
1107
1107
  console.log(` ❌ Помилка виконання: ${error.message}`)
@@ -1110,11 +1110,11 @@ async function runChecks(requestedRules) {
1110
1110
  console.log()
1111
1111
  }
1112
1112
 
1113
- const passedCount = rulesToCheck.length - totalFailed
1114
- console.log(`✨ Результат: ${passedCount}/${rulesToCheck.length} правил без зауважень\n`)
1113
+ const passedCount = idsToCheck.length - totalFailed
1114
+ console.log(`✨ Результат: ${passedCount}/${idsToCheck.length} правил без зауважень\n`)
1115
1115
 
1116
1116
  if (totalFailed > 0) {
1117
- throw new Error(`${totalFailed} з ${rulesToCheck.length} правил мають проблеми`)
1117
+ throw new Error(`${totalFailed} з ${idsToCheck.length} правил мають проблеми`)
1118
1118
  }
1119
1119
  }
1120
1120
 
@@ -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.11.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -45,6 +45,7 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "oxc-parser": "^0.128.0",
48
+ "picomatch": "^4.0.4",
48
49
  "yaml": "^2.8.3"
49
50
  },
50
51
  "engines": {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Applies-гейт правила abie: rule-level через `isAbieRuleEnabled` (поле `rules` у `.n-cursor.json`).
3
+ * Якщо повертає `false` — CLI пропускає всі концерни (JS і policy) цього правила.
4
+ * `check()` друкує тільки context-pass; решта концернів роблять справжню роботу.
5
+ */
6
+ import { createCheckReporter } from '../../../../scripts/utils/check-reporter.mjs'
7
+
8
+ import { isAbieRuleEnabled } from '../../utils/enabled.mjs'
9
+
10
+ /**
11
+ * @returns {Promise<boolean>}
12
+ */
13
+ export async function applies() {
14
+ return isAbieRuleEnabled(process.cwd())
15
+ }
16
+
17
+ /**
18
+ * @returns {Promise<number>}
19
+ */
20
+ export async function check() {
21
+ const reporter = createCheckReporter()
22
+ reporter.pass('Правило abie увімкнено — виконуємо перевірки')
23
+ return reporter.getExitCode()
24
+ }