@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.
- package/.claude-template/hooks/capture-decisions.sh +3 -3
- package/.claude-template/hooks/normalize-decisions.sh +370 -0
- package/CHANGELOG.md +19 -0
- package/bin/n-cursor.js +1 -0
- package/package.json +1 -1
- package/rules/adr/adr.mdc +82 -18
- package/rules/adr/js/check.mjs +84 -40
- package/rules/adr/policy/settings_json/settings_json.rego +17 -11
- package/rules/adr/policy/settings_local_json/settings_local_json.rego +24 -12
- package/scripts/auto-skills.mjs +2 -0
- package/scripts/sync-claude-config.mjs +70 -14
- package/skills/adr-normalize/SKILL.md +71 -0
- package/skills/adr-normalize/auto.md +1 -0
|
@@ -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
|
-
|
|
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 "$
|
|
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="$
|
|
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
package/rules/adr/adr.mdc
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Автоматичний збір ADR/Runbook/Knowledge-чернеток
|
|
2
|
+
description: Автоматичний збір ADR/Runbook/Knowledge-чернеток і батч-нормалізація у `docs/adr/` через Stop-хук Claude Code
|
|
3
3
|
alwaysApply: true
|
|
4
|
-
version: '
|
|
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
|
-
-
|
|
13
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
+
Обидва скрипти обирають доступний CLI (порядок фіксований):
|
|
22
71
|
|
|
23
|
-
1. **`claude`** (Anthropic Claude Code CLI) — `claude -p --model "
|
|
24
|
-
2. **`cursor-agent`** (Cursor IDE CLI) — `cursor-agent -p --mode ask --output-format text --model "
|
|
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-інструментів — для класифікації
|
|
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
|
-
|
|
34
|
-
|
|
82
|
+
├── YYYYMMDD-HHMMSS-<sid>.md # drafts (frontmatter session:/captured:/transcript:)
|
|
83
|
+
└── <slug>.md # clean ADR-и (без frontmatter)
|
|
35
84
|
.claude/hooks/
|
|
36
|
-
├── capture-decisions.sh
|
|
37
|
-
|
|
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` повинен містити
|
|
93
|
+
`.gitignore` повинен містити рядки, що покривають **`.claude/hooks/*.log`** і службові файли normalize (`.claude/hooks/.normalize-*`).
|
|
41
94
|
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
146
|
+
Обидва Stop-hook'и ADR живуть у **project-shared** `.claude/settings.json` (закомічений), щоб механізм працював у всіх членів команди. Якщо хук колись був у `.claude/settings.local.json` — прибери дубль вручну: project-shared і local-копія створили б два запуски на одну подію.
|
|
83
147
|
|
|
84
148
|
## Перевірка
|
|
85
149
|
|
package/rules/adr/js/check.mjs
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Перевіряє вимоги правила adr.mdc: ADR Stop-hook capture-decisions.sh
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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` шаблон, який покриває
|
|
58
|
+
* Чи містить рядок `.gitignore` шаблон, який покриває цей конкретний лог-файл хука.
|
|
35
59
|
* Враховує точний шлях, glob `.claude/hooks/*.log` та широкий glob `**\/*.log`.
|
|
36
60
|
* @param {string} line одна нормалізована (trim) лінія `.gitignore`
|
|
37
|
-
* @
|
|
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 ===
|
|
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
|
-
* Перевіряє наявність і канонічність
|
|
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
|
-
|
|
63
|
-
|
|
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(
|
|
67
|
-
fail(`канонічний скрипт у пакеті не знайдено: ${
|
|
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(`${
|
|
100
|
+
pass(`${projectPath} збігається з канонічним`)
|
|
76
101
|
} else {
|
|
77
|
-
fail(`${
|
|
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 {
|
|
149
|
+
const { fail } = reporter
|
|
104
150
|
if (!existsSync('.gitignore')) {
|
|
105
|
-
|
|
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
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
2
|
-
# `hooks.Stop[*]` має містити
|
|
3
|
-
#
|
|
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/
|
|
6
|
+
# conftest test .claude/settings.json -p npm/rules/adr/policy \
|
|
7
7
|
# --namespace adr.settings_json
|
|
8
8
|
#
|
|
9
|
-
# Hash-порівняння bash
|
|
10
|
-
# — у JS (`check
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
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", ""),
|
|
36
|
+
contains(object.get(hook, "command", ""), marker)
|
|
31
37
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
#
|
|
2
|
-
#
|
|
3
|
-
#
|
|
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/
|
|
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
|
-
|
|
17
|
+
capture_marker := ".claude/hooks/capture-decisions.sh"
|
|
18
|
+
normalize_marker := ".claude/hooks/normalize-decisions.sh"
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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", ""),
|
|
39
|
+
contains(object.get(hook, "command", ""), marker)
|
|
28
40
|
}
|
package/scripts/auto-skills.mjs
CHANGED
|
@@ -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([
|
|
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 = [
|
|
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
|
-
* Копіює канонічний
|
|
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
|
-
|
|
219
|
-
const templatePath = join(templateDir, 'hooks',
|
|
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,
|
|
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}/${
|
|
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 {
|
|
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]
|