@rt-tools/agent-kit 0.12.0 → 0.13.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 (48) hide show
  1. package/assets/checks/board-runs.github.mjs +87 -0
  2. package/assets/checks/board.github.mjs +0 -40
  3. package/assets/checks/check-board.github.mjs +39 -8
  4. package/assets/checks/check-schema-drift.mjs +28 -5
  5. package/assets/checks/rt-kit-checks.config.mjs +12 -0
  6. package/assets/commands/feedback.md +8 -0
  7. package/assets/defaults/project.sh +50 -5
  8. package/assets/hooks/browser-guard-no-other-drivers.sh +1 -1
  9. package/assets/hooks/dev-server-guard.sh +1 -1
  10. package/assets/hooks/git-guard-delivery-folder.sh +99 -0
  11. package/assets/hooks/git-guard-delivery-signature.sh +10 -4
  12. package/assets/hooks/git-guard-delivery.sh +21 -69
  13. package/assets/hooks/git-guard-push-tests.sh +2 -2
  14. package/assets/hooks/hook-input.sh +23 -0
  15. package/assets/hooks/override-write-guard.sh +107 -0
  16. package/assets/hooks/rerun-guard.sh +1 -1
  17. package/assets/hooks/rule-source-guard.sh +126 -0
  18. package/assets/hooks/task-flow-guard.sh +18 -0
  19. package/assets/hooks/turn-exit-guard.sh +17 -0
  20. package/assets/patterns/git-workflow-pr.azure.md +4 -4
  21. package/assets/patterns/git-workflow-pr.github.md +4 -4
  22. package/assets/patterns/git-workflow-pr.gitlab.md +4 -4
  23. package/assets/patterns/task-flow-archive.md +23 -21
  24. package/assets/patterns/task-flow-close.md +98 -96
  25. package/assets/patterns/task-flow-resume.md +5 -3
  26. package/assets/pitfalls/agent-kit.md +80 -0
  27. package/assets/rules/deploy-flow.azure.md +15 -7
  28. package/assets/rules/deploy-flow.github.md +15 -6
  29. package/assets/rules/deploy-flow.gitlab.md +15 -7
  30. package/assets/rules/git-workflow.github.md +6 -1
  31. package/assets/rules/task-flow.md +41 -25
  32. package/assets/rules/turn-conduct.md +4 -0
  33. package/assets/skills/agent-kit.md +32 -77
  34. package/assets/templates/proposal.md +16 -1
  35. package/lib/proposals.d.ts +5 -1
  36. package/lib/proposals.d.ts.map +1 -1
  37. package/lib/proposals.js +74 -5
  38. package/lib/proposals.js.map +1 -1
  39. package/lib/shipment.d.ts.map +1 -1
  40. package/lib/shipment.fixture.d.ts +39 -0
  41. package/lib/shipment.fixture.d.ts.map +1 -0
  42. package/lib/shipment.fixture.js +99 -0
  43. package/lib/shipment.fixture.js.map +1 -0
  44. package/lib/shipment.js +59 -7
  45. package/lib/shipment.js.map +1 -1
  46. package/package.json +1 -1
  47. package/rt-tools-agent-kit-0.13.0.tgz +0 -0
  48. package/rt-tools-agent-kit-0.12.0.tgz +0 -0
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # rt-hook: PreToolUse Bash|mcp__webstorm__execute_terminal_command|mcp__webstorm__execute_tool
3
- # Требует: hooks/profile-check.sh, hooks/deny-tail.sh
3
+ # Требует: hooks/git-guard-delivery-folder.sh, hooks/profile-check.sh, hooks/deny-tail.sh
4
4
  # Гард поставки. PreToolUse на заведении ветки, пуше и открытии заявки на слияние.
5
5
  #
6
6
  # Закон о поставке требует трёх вещей, которых обычно не проверяет ничто: правка начинается с
@@ -152,6 +152,12 @@ fault() {
152
152
  }— $1"
153
153
  }
154
154
 
155
+ # Условия о папке задачи вынесены соседним файлом: у папки свой предмет, свой обход и своя
156
+ # запись в архиве, а вместе с разбором команды и очередью работ они переросли предел длины.
157
+ # Подключается он после `deny` и `fault` — обе зовутся изнутри.
158
+ # shellcheck disable=SC1090
159
+ [ -f "$rt_hooks_dir/git-guard-delivery-folder.sh" ] && . "$rt_hooks_dir/git-guard-delivery-folder.sh" 2>/dev/null
160
+
155
161
  # Отказ по накопленному. Пусто — вызывающая сторона идёт дальше.
156
162
  deny_faults() {
157
163
  [ -z "$faults" ] && return 0
@@ -161,21 +167,6 @@ deny_faults() {
161
167
  ${faults}"
162
168
  }
163
169
 
164
- # Подсказка вместо отказа: на открытии PR папка ещё нужна. Решения подсказка не несёт,
165
- # команда идёт дальше своим ходом.
166
- hint() {
167
- jq -n --arg c "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$c}}' 2>/dev/null
168
- exit 0
169
- }
170
-
171
- # Есть ли папка задачи в ветке. Смотрим содержимое ветки, а не рабочее дерево: если папку
172
- # удалили, но не закоммитили, проверка прошла бы, а папка всё равно уехала бы в main. Имя
173
- # ветки подставляем целиком, вместе с косой: у ветки вида `chore/312-slug` папка лежит во
174
- # вложенном каталоге.
175
- folder_in_branch() {
176
- git ls-tree -d --name-only HEAD -- "$1" 2>/dev/null | head -1
177
- }
178
-
179
170
  check_task() {
180
171
  number="$1"
181
172
  where="$2"
@@ -292,7 +283,7 @@ command -v rt_delivery_signature >/dev/null 2>&1 && rt_delivery_signature
292
283
  #
293
284
  # Возврат заявки в черновик под требование не подпадает: он делает ровно то, чего гард и
294
285
  # добивается, — снимает с работы вид готовой.
295
- if printf '%s' "$cmd" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*(gh[[:space:]]+pr[[:space:]]+ready|glab[[:space:]]+mr[[:space:]]+update[^|;&]*--ready)([[:space:]]|$)' \
286
+ if printf '%s' "$cmd" | grep -qE "${RT_CMD_BOUND}(gh[[:space:]]+pr[[:space:]]+ready|glab[[:space:]]+mr[[:space:]]+update[^|;&]*--ready)([[:space:]]|\$)" \
296
287
  && ! printf '%s' "$cmd" | grep -q -- '--undo'; then
297
288
  # Ссылка на заявку необязательна: без неё клиент берёт заявку текущей ветки, и это самая
298
289
  # короткая форма вызова. Требовать номер значило бы снимать всё требование одним пробелом.
@@ -341,59 +332,20 @@ fi
341
332
 
342
333
  # --- слияние заявки ----------------------------------------------------------------------
343
334
  #
344
- # Папку задачи разбирают тем же PR, что и работу. После слияния этого уже никто не сделает:
345
- # работа перешла к следующей задаче, а PR закрыт. Раньше слияния требовать нельзя — пока идёт
346
- # ревью, plan.md нужен на диске, иначе гард хода работы не даст править код.
335
+ # Второй рубеж того же условия, что стоит на открытии заявки: он ловит слияние, идущее командой.
347
336
  # Команду ищем от начала строки или после разделителя, а не где угодно в тексте. Иначе гард
348
337
  # отбивает сообщение, где `gh pr merge` просто упомянут в кавычках, — так он и сработал на
349
338
  # правке этого же текста. Полностью подстроку в кавычках так не отсечь, но случайное упоминание
350
339
  # внутри слова или пути мимо уже не пройдёт.
351
- if printf '%s' "$cmd" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*(gh[[:space:]]+pr[[:space:]]+merge|glab[[:space:]]+mr[[:space:]]+merge|az[[:space:]]+repos[[:space:]]+pr[[:space:]]+update)([[:space:]]|$)'; then
352
- [ -n "$tasks_dir" ] || exit 0 # ведения работы папкой в дереве нет
353
-
354
- merge_branch="$(git branch --show-current 2>/dev/null)"
355
- [ -z "$merge_branch" ] && exit 0
356
- rt_task_branch_ok "$merge_branch" || exit 0 # за беззадачной веткой папки не стоит
357
-
358
- folder="$tasks_dir/$merge_branch"
359
-
360
- # Сначала ищем обход в самой команде — это работает и без сети. Если читать только
361
- # тело PR, то без сети гард отбил бы слияние, причина которого в этом теле и написана.
362
- printf '%s' "$cmd" | grep -qiE "$folder_skip_re" && exit 0
363
-
364
- merge_number="$(printf '%s' "$cmd" | sed -nE 's/.*(pr|mr)[[:space:]]+(merge|update)[[:space:]]+([0-9]+).*/\3/p' | head -1)"
365
- if [ -n "$merge_number" ] && rt_needs rt_report_body git-guard-delivery; then
366
- body="$(cd "$root" && rt_report_body "$merge_number" 2>/dev/null)"
367
- [ -n "$body" ] && printf '%s' "$body" | grep -qiE "$folder_skip_re" && exit 0
368
- fi
369
-
370
- lying="$(folder_in_branch "$folder")"
371
- [ -n "$lying" ] \
372
- && deny "BLOCKED: в ветке осталась папка задачи «${lying}» — она уедет в главную. Разобрать её потом будет некому: работа перейдёт к следующей задаче, а этот PR закроется. Перенеси в «${archive_dir:-архив}» то, что объясняет принятые решения, остальное удали и повтори. Если работа вливается частями, поставь в тело PR строку «Task-folder-skip: <причина>»."
373
-
374
- # Запись в архиве спрашиваем только у ветки, которая папку удалила. Иначе проверка
375
- # цеплялась бы к работе, у которой папки и не было. Без общего предка с главной веткой
376
- # сравнивать не с чем — тогда молчим.
377
- [ -n "$archive_dir" ] || exit 0
378
- base="$(git merge-base "$main_branch" HEAD 2>/dev/null)"
379
- [ -z "$base" ] && exit 0
380
-
381
- had="$(git ls-tree -d --name-only "$base" -- "$folder" 2>/dev/null | head -1)"
382
- [ -z "$had" ] && had="$(git log "$base..HEAD" --diff-filter=A --name-only --pretty=format: -- "$folder" 2>/dev/null | head -1)"
383
- [ -z "$had" ] && exit 0
384
-
385
- gained="$(git diff --name-only --diff-filter=A "$base" HEAD -- "$archive_dir" 2>/dev/null | head -1)"
386
- [ -z "$gained" ] \
387
- && deny "BLOCKED: папку задачи удалили, но в «${archive_dir}» ветка ничего не добавила. Удалить проще, чем разобрать, — и вместе с папкой пропадает разбор просьбы, единственная запись слов владельца. Перенеси то, что объясняет принятые решения, одним файлом с понятным именем и повтори."
388
-
389
- exit 0
340
+ if printf '%s' "$cmd" | grep -qE "${RT_CMD_BOUND}(gh[[:space:]]+pr[[:space:]]+merge|glab[[:space:]]+mr[[:space:]]+merge|az[[:space:]]+repos[[:space:]]+pr[[:space:]]+update)([[:space:]]|\$)"; then
341
+ rt_delivery_merge_folder
390
342
  fi
391
343
 
392
344
  # --- открытие заявки на слияние ----------------------------------------------------------
393
345
  # Команду ищем от начала строки или после разделителя — по той же причине, что и слияние:
394
346
  # упоминание в кавычках командой не является.
395
347
  printf '%s' "$cmd" \
396
- | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*(gh[[:space:]]+pr[[:space:]]+create|glab[[:space:]]+mr[[:space:]]+create|az[[:space:]]+repos[[:space:]]+pr[[:space:]]+create)([[:space:]]|$)' \
348
+ | grep -qE "${RT_CMD_BOUND}(gh[[:space:]]+pr[[:space:]]+create|glab[[:space:]]+mr[[:space:]]+create|az[[:space:]]+repos[[:space:]]+pr[[:space:]]+create)([[:space:]]|\$)" \
397
349
  || exit 0
398
350
 
399
351
  branch="$(git branch --show-current 2>/dev/null)"
@@ -474,17 +426,17 @@ fi
474
426
 
475
427
  check_task "$number" "заявка с ветки «${branch}»" да
476
428
 
429
+ # Папка задачи разбирается до открытия заявки, а не после одобрения. Прежде здесь стояло
430
+ # напоминание, а требование — на слиянии: считалось, что папка нужна на диске всё время
431
+ # разбора, потому что правку по замечаниям без замысла отбивает гард хода работы. Владелец же
432
+ # вливает, как только видит зелёное, и закрывающему коммиту места не остаётся — трижды подряд
433
+ # папка уехала в главную неразобранной, а гард слияния этого не видел вовсе: кнопку нажимает
434
+ # человек на хостинге, и до него гард не достаёт. Разбор переехал сюда, а замысел после уборки
435
+ # гард хода работы берёт из истории ветки, а не с диска.
436
+ rt_delivery_open_folder
437
+
477
438
  # Всё несошедшееся названо здесь, разом: до этой строки собирались условия, каждое из которых
478
439
  # прежде отбивало вызов в одиночку.
479
440
  deny_faults
480
441
 
481
- # Сейчас папка ещё нужна: правки по замечаниям ревью идут в эту же ветку, а без plan.md их не
482
- # пропустит гард хода работы. Поэтому здесь только напоминание. Требование стоит на слиянии —
483
- # там папка уже не нужна, а вред от неё как раз и наступает.
484
- if [ -n "$tasks_dir" ]; then
485
- lying="$(folder_in_branch "$tasks_dir/$branch")"
486
- [ -n "$lying" ] \
487
- && hint "В ветке лежит папка задачи «${lying}». Разбери её до слияния, этим же PR: потом за неё уже никто не возьмётся. На слиянии это будет отказ, а не напоминание."
488
- fi
489
-
490
442
  exit 0
@@ -46,7 +46,7 @@ cmd="$(rt_hook_cmd)"
46
46
  # подстрокой «git push» пуш не поймать — помощник учётных данных и заголовок запроса ставятся
47
47
  # ключами `-c` между ними, и ровно этой формой здесь и пушат. Пока признаком была подстрока,
48
48
  # весь набор гейта на таком пуше не гонялся вовсе, а молчание гарда читалось как «зелено».
49
- printf '%s' "$cmd" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*git([[:space:]]|$)' || exit 0
49
+ printf '%s' "$cmd" | grep -qE "${RT_CMD_BOUND}git([[:space:]]|\$)" || exit 0
50
50
 
51
51
  # Отложенная правка пушем не бывает: `git stash push` кладёт правку в тайник этой же машины и
52
52
  # наружу не отправляет ничего. Слово `push` в ней стоит отдельным, и без этой строки гард гонял
@@ -71,7 +71,7 @@ esac
71
71
  #
72
72
  # Судится переключение на существующую ветку. Заведение новой (`checkout -b`, `switch -c`)
73
73
  # сюда не попадает: у свежей ветки дерево то же самое, что и было.
74
- if printf '%s' "$cmd" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*git[[:space:]]+(checkout|switch)[[:space:]]+' &&
74
+ if printf '%s' "$cmd" | grep -qE "${RT_CMD_BOUND}git[[:space:]]+(checkout|switch)[[:space:]]+" &&
75
75
  ! printf '%s' "$cmd" | grep -qE 'git[[:space:]]+(checkout[[:space:]]+-b|switch[[:space:]]+-c)([[:space:]]|$)'; then
76
76
  reason="BLOCKED: переключение ветки и пуш одной командой. Набор гейта гоняется в том дереве, какое лежит на момент разбора команды, — то есть по ПРЕЖНЕЙ ветке, а не по той, что уходит на хостинг. Зелёный набор при этом читается как проверка ушедшего, хотя проверял он другое. Раздели вызовы: сперва переключись, затем отдельной командой пушь."
77
77
  # Общий хвост отказа: два законных хода и законная форма обхода, если она у отказа есть.
@@ -47,6 +47,29 @@ rt_hook_field() {
47
47
  printf '%s' "$RT_HOOK_INPUT" | jq -r "${path} // empty" 2>/dev/null
48
48
  }
49
49
 
50
+ # НАЧАЛО ВЫЗОВА В КОМАНДНОЙ СТРОКЕ. Приставка образца, которым гард узнаёт свой вызов: команда
51
+ # стоит в начале строки, за разделителем — и после любого числа присваиваний переменных
52
+ # окружения перед её именем.
53
+ #
54
+ # Присваивания входят сюда потому, что вызов с ними — обычная форма, и часть набора требует её
55
+ # прямо: личность вызова, открывающего заявку, видна из команды только явной подстановкой
56
+ # токена. Признак, не знавший о присваиваниях, на этой форме слепнул молча — он не отказывал и
57
+ # не предупреждал, он не считал вызов вызовом, и весь предмет гарда оставался несудимым. Так
58
+ # прошли мимо своих гардов пуш, открытие заявки, снятие черновика и слияние: коммит с чужой
59
+ # подписью уехал в главную ветку, а запрет на слияние агентом снимался подстановкой токена.
60
+ #
61
+ # Значение присваивания берётся без пробелов: `ИМЯ="a b"` в кавычках сюда не подпадает, и вызов
62
+ # за ним останется несудимым. Пробел внутри значения перед вызовом встречается редко, а
63
+ # образец, разбирающий кавычки, ошибается в другую сторону — считает вызовом то, что им не
64
+ # является. Признак обязан ошибаться в сторону лишнего срабатывания: гард, не узнавший вызов,
65
+ # молчит и выглядит исправным, а сработавший на лишнем виден сразу и правится.
66
+ #
67
+ # Само по себе присваивание вызовом не считается: за ним обязано стоять имя команды.
68
+ #
69
+ # Объявлено это одним местом, а не литералом в каждом гарде: разойдясь, копии чинятся по одной
70
+ # и молчат о том, что остальные остались слепыми.
71
+ RT_CMD_BOUND='(^|[;&|(]|&&|\|\|)[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*'
72
+
50
73
  # Поля, которые спрашивают все гарды.
51
74
  rt_hook_tool() { rt_hook_field RT_HOOK_TOOL '.tool_name'; }
52
75
  rt_hook_cmd() { rt_hook_field RT_HOOK_CMD '.tool_input.command'; }
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env bash
2
+ # rt-hook: PreToolUse Write|Bash|mcp__webstorm__create_new_file|mcp__webstorm__execute_terminal_command|mcp__webstorm__execute_tool
3
+ # Требует: hooks/deny-tail.sh
4
+ # Гард затирания надстройки: запись поверх — не то же самое, что правка.
5
+ #
6
+ # Надстройка дерева сливается с ресурсом пакета по разделу «## »: раздел, который дерево
7
+ # переписало, замещает пакетный, а остальные приходят из пакета. Файл надстройки поэтому
8
+ # накапливается — разделы в него дописывают разные ветки и разные заходы, — и выглядит он как
9
+ # обычный текст, который можно положить целиком.
10
+ #
11
+ # Положенный целиком, он уносит с собой все разделы, которых эта правка не касалась. Пропажу не
12
+ # видно ничем: раскладка сходится, проверки зелёные, а пакетный раздел молча вернулся на место
13
+ # того, что дерево о себе говорило. Заметит это тот, кто через месяц удивится, почему правило
14
+ # снова требует чужих имён.
15
+ #
16
+ # ЧТО СУДИТСЯ. Запись файла целиком поверх существующей непустой надстройки: инструмент записи,
17
+ # перенаправление `>`, `tee` без дописывания, копирование поверх. Дописывание в конец — `>>`,
18
+ # `tee -a` — и правка по месту проходят: они ничего не уносят.
19
+ #
20
+ # ОТКАЗ В ПОЛЬЗУ РАБОТЫ: нет `jq`, битый ввод, чужой инструмент, файла нет, файл пуст —
21
+ # правка РАЗРЕШАЕТСЯ. Сломанный гард не имеет права заклинить работу.
22
+
23
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/utf8.sh" 2>/dev/null || true
24
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/hook-input.sh" 2>/dev/null || true
25
+
26
+ rt_hook_read
27
+ input="$RT_HOOK_INPUT"
28
+ [ -z "$input" ] && exit 0
29
+ command -v jq >/dev/null 2>&1 || exit 0
30
+
31
+ rt_hooks_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
32
+
33
+ # shellcheck disable=SC1090
34
+ [ -f "$rt_hooks_dir/deny-tail.sh" ] && . "$rt_hooks_dir/deny-tail.sh" 2>/dev/null
35
+ command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { printf ''; }
36
+
37
+ deny() {
38
+ # shellcheck disable=SC1090
39
+ [ -f "$rt_hooks_dir/observe.sh" ] && . "$rt_hooks_dir/observe.sh" 2>/dev/null
40
+ command -v rt_note >/dev/null 2>&1 && rt_note guard-deny res=override-write-guard
41
+
42
+ reason="$1 $(rt_deny_tail "$2")"
43
+ jq -n --arg r "$reason" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}' 2>/dev/null \
44
+ || printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Гард затирания надстройки."}}\n'
45
+ exit 0
46
+ }
47
+
48
+ # Цели записи поверх, названные командой прямо. Дописывание сюда не входит: `>>` прячется до
49
+ # разбора и обратно не разворачивается, `tee` берётся только без довода о дописывании.
50
+ rt_overwrite_targets() {
51
+ tr "\"'\`" ' ' \
52
+ | sed -E 's/>>/\
53
+ APPEND/g' \
54
+ | sed -E 's/>/\
55
+ >/g' \
56
+ | sed -nE '
57
+ s/^>[[:space:]]*([^[:space:]|&;]+).*/\1/p
58
+ s/(^|.*[[:space:]])tee[[:space:]]+([^-[:space:]][^[:space:]|&;]*).*/\2/p
59
+ s/(^|.*[[:space:]])(cp|mv|install)[[:space:]]+([^[:space:]]+[[:space:]]+)+([^[:space:]|&;]+).*/\4/p
60
+ ' \
61
+ | sort -u
62
+ }
63
+
64
+ tool="$(rt_hook_tool)"
65
+ candidates=""
66
+ case "$tool" in
67
+ Write | mcp__webstorm__create_new_file)
68
+ candidates="$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.pathInProject // empty' 2>/dev/null)"
69
+ ;;
70
+ Bash | mcp__webstorm__execute_terminal_command | mcp__webstorm__execute_tool)
71
+ cmd="$(rt_hook_cmd)"
72
+ [ -z "$cmd" ] && exit 0
73
+ candidates="$(printf '%s' "$cmd" | rt_overwrite_targets)"
74
+ ;;
75
+ *) exit 0 ;;
76
+ esac
77
+ [ -z "$candidates" ] && exit 0
78
+
79
+ root="${CLAUDE_PROJECT_DIR:-.}"
80
+ overrides_dir="${RT_OVERRIDES_DIR:-.claude/rt-kit/overrides}"
81
+
82
+ while IFS= read -r candidate; do
83
+ [ -z "$candidate" ] && continue
84
+ case "$candidate" in
85
+ /*) relative="${candidate#"$root"/}" ;;
86
+ *)
87
+ relative="$candidate"
88
+ candidate="$root/$candidate"
89
+ ;;
90
+ esac
91
+ case "$relative" in "$overrides_dir"/*) ;; *) continue ;; esac
92
+ [ -f "$candidate" ] || continue
93
+
94
+ lines="$(wc -l <"$candidate" 2>/dev/null | tr -d ' ')"
95
+ [ -z "$lines" ] && continue
96
+ [ "$lines" -eq 0 ] && continue
97
+
98
+ sections="$(grep -c '^## ' "$candidate" 2>/dev/null || printf '0')"
99
+ resource="${relative#"$overrides_dir"/}"
100
+
101
+ deny "BLOCKED by override-write-guard: «${relative}» — надстройка этого дерева, и в ней уже лежит ${lines} строк, разделов «## » — ${sections}. Запись целиком уносит все разделы, которых эта правка не касалась, и на их место молча возвращается пакетный текст ресурса «${resource}»: раскладка после этого сходится, проверки зелёные, а сказанного деревом о себе больше нет. Правь по месту — правкой раздела, а не записью файла." \
102
+ "новый раздел дописывается в конец: надстройка сливается с пакетным ресурсом по заголовку «## », и пакетный текст замещается только теми разделами, которые дерево назвало"
103
+ done <<EOF
104
+ $candidates
105
+ EOF
106
+
107
+ exit 0
@@ -49,7 +49,7 @@ done
49
49
 
50
50
  # Вызов перезапуска: слово перезапуска отдельным словом рядом со словом прогона.
51
51
  host_cli="${RT_HOST_CLI:-gh}"
52
- printf '%s' "$cmd" | grep -qE "(^|[;&|(]|&&|\|\|)[[:space:]]*${host_cli}([[:space:]]|$)" || exit 0
52
+ printf '%s' "$cmd" | grep -qE "${RT_CMD_BOUND}${host_cli}([[:space:]]|\$)" || exit 0
53
53
  printf '%s' "$cmd" | grep -qE '(^|[[:space:]])(run|workflow)([[:space:]]|$)' || exit 0
54
54
  printf '%s' "$cmd" | grep -qE '(^|[[:space:]])rerun([[:space:]]|$)' || exit 0
55
55
 
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env bash
2
+ # rt-hook: PreToolUse Edit|Write|MultiEdit|Bash|mcp__webstorm__create_new_file|mcp__webstorm__execute_terminal_command|mcp__webstorm__execute_tool
3
+ # Требует: hooks/profile-check.sh, hooks/deny-tail.sh
4
+ # Гард места правки: слой правил чинится там, где сломано, а не там, где виден.
5
+ #
6
+ # Слой правил правит тот же исполнитель, которым слой правил управляет, и разницы между
7
+ # «исполняю правило» и «правлю правило» в дереве не видно ничем: разложенная копия лежит рядом
8
+ # с обычными файлами и правится так же. Правка доходит до диска и не доходит до места, где
9
+ # промах чинится: в дереве она видна, в пакете её нет, а через месяц раскладка отказывает по
10
+ # правленому файлу целиком — и цену платит тот, кто в этот день правил соседний ресурс.
11
+ #
12
+ # Знал об этом только `sync --check`, и говорил он на следующей раскладке, то есть в чужой
13
+ # ветке и чужим ходом. Гард говорит в минуту правки и называет адрес: источник пакета, если
14
+ # дерево его держит, иначе — надстройку.
15
+ #
16
+ # ЧТО СУДИТСЯ. Файл, который правят, несёт шапку раскладки: `rt-kit v<версия> · <ресурс> ·
17
+ # <дайджест>`. Ресурс из неё и есть адрес: `<источники>/<ресурс>` в дереве пакета,
18
+ # `.claude/rt-kit/overrides/<ресурс>` у потребителя.
19
+ #
20
+ # ОТКАЗ В ПОЛЬЗУ РАБОТЫ: нет `jq`, битый ввод, чужой инструмент, файла нет, шапки в нём нет,
21
+ # не git-репозиторий — правка РАЗРЕШАЕТСЯ. Сломанный гард не имеет права заклинить работу.
22
+
23
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/utf8.sh" 2>/dev/null || true
24
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/hook-input.sh" 2>/dev/null || true
25
+
26
+ rt_hook_read
27
+ input="$RT_HOOK_INPUT"
28
+ [ -z "$input" ] && exit 0
29
+ command -v jq >/dev/null 2>&1 || exit 0
30
+
31
+ rt_hooks_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
32
+ for profile in "$rt_hooks_dir/../rt-kit/defaults/project.sh" "$rt_hooks_dir/../defaults/project.sh" "${CLAUDE_PROJECT_DIR:-.}/.claude/rt-kit/defaults/project.sh" "${CLAUDE_PROJECT_DIR:-.}/.claude/rt-kit/project.sh"; do
33
+ # shellcheck disable=SC1090
34
+ [ -f "$profile" ] && . "$profile" 2>/dev/null
35
+ done
36
+
37
+ # shellcheck disable=SC1090
38
+ [ -f "$rt_hooks_dir/profile-check.sh" ] && . "$rt_hooks_dir/profile-check.sh"
39
+ command -v rt_needs >/dev/null 2>&1 || rt_needs() { command -v "$1" >/dev/null 2>&1; }
40
+
41
+ # shellcheck disable=SC1090
42
+ [ -f "$rt_hooks_dir/deny-tail.sh" ] && . "$rt_hooks_dir/deny-tail.sh" 2>/dev/null
43
+ command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { printf ''; }
44
+
45
+ deny() {
46
+ # shellcheck disable=SC1090
47
+ [ -f "$rt_hooks_dir/observe.sh" ] && . "$rt_hooks_dir/observe.sh" 2>/dev/null
48
+ command -v rt_note >/dev/null 2>&1 && rt_note guard-deny res=rule-source-guard
49
+
50
+ reason="$1 $(rt_deny_tail "$2")"
51
+ jq -n --arg r "$reason" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}' 2>/dev/null \
52
+ || printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Гард места правки."}}\n'
53
+ exit 0
54
+ }
55
+
56
+ # Цели записи, названные командой прямо. Печатает по одной в строке.
57
+ rt_write_targets() {
58
+ tr "\"'\`" ' ' \
59
+ | sed -E 's/>>?/\n>/g' \
60
+ | sed -nE '
61
+ s/^>[[:space:]]*([^[:space:]|&;]+).*/\1/p
62
+ s/(^|.*[[:space:]])tee[[:space:]]+(-a[[:space:]]+)?([^[:space:]|&;]+).*/\3/p
63
+ s/(^|.*[[:space:]])sed[[:space:]]+-i[[:space:]]+([^[:space:]]+[[:space:]]+)*([^[:space:]|&;]+)$/\3/p
64
+ s/(^|.*[[:space:]])(cp|mv|install)[[:space:]]+([^[:space:]]+[[:space:]]+)+([^[:space:]|&;]+).*/\4/p
65
+ ' \
66
+ | sort -u
67
+ }
68
+
69
+ tool="$(rt_hook_tool)"
70
+ candidates=""
71
+ case "$tool" in
72
+ Edit | Write | MultiEdit | mcp__webstorm__create_new_file)
73
+ candidates="$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.pathInProject // empty' 2>/dev/null)"
74
+ ;;
75
+ Bash | mcp__webstorm__execute_terminal_command | mcp__webstorm__execute_tool)
76
+ cmd="$(rt_hook_cmd)"
77
+ [ -z "$cmd" ] && exit 0
78
+ # Здесь берётся не общий признак записи, а цель, названная в команде прямо:
79
+ # перенаправление, `tee`, правка на месте, копирование поверх. Общий признак широк
80
+ # намеренно — в нём и имя интерпретатора, — и запуск разложенной проверки читался бы
81
+ # как правка её самой. Отбитие на чтении стоит дороже пропуска: гард, мешающий читать,
82
+ # выключают в первый же день.
83
+ #
84
+ # Снятие копии сюда не входит намеренно: снятый файл раскладка кладёт заново, и так
85
+ # чинят копию, которую переписал форматтер.
86
+ candidates="$(printf '%s' "$cmd" | rt_write_targets)"
87
+ ;;
88
+ *) exit 0 ;;
89
+ esac
90
+ [ -z "$candidates" ] && exit 0
91
+
92
+ root="${CLAUDE_PROJECT_DIR:-.}"
93
+
94
+ # Каталог источников пакета в этом дереве. Есть он только у дерева, которое пакет и везёт; у
95
+ # потребителя источника нет вовсе, и адрес правки у него один — надстройка.
96
+ sources=""
97
+ if rt_needs rt_kit_sources_dir rule-source-guard; then
98
+ sources="$(rt_kit_sources_dir 2>/dev/null)"
99
+ fi
100
+
101
+ while IFS= read -r candidate; do
102
+ [ -z "$candidate" ] && continue
103
+ case "$candidate" in
104
+ /*) ;;
105
+ *) candidate="$root/$candidate" ;;
106
+ esac
107
+ [ -f "$candidate" ] || continue
108
+
109
+ # Шапка стоит в начале файла, но не первой строкой: у сценария её отодвигает `#!`, у
110
+ # правила — заголовок с именем и родом ресурса. Десяти строк хватает обоим, а читать файл
111
+ # целиком нельзя: гард стоит на каждой правке.
112
+ resource="$(head -12 "$candidate" 2>/dev/null | sed -nE 's/.*rt-kit v[^ ]+ · ([^ ]+) · [0-9a-f]+.*/\1/p' | head -1)"
113
+ [ -z "$resource" ] && continue
114
+
115
+ if [ -n "$sources" ] && [ -f "$root/$sources/$resource" ]; then
116
+ deny "BLOCKED by rule-source-guard: «${candidate#"$root"/}» разложен пакетом, и правка на его месте теряется на следующей раскладке — а до тех пор раскладка отказывает по этому файлу целиком, и цену платит тот, кто в этот день правит соседний ресурс. Ресурс — «${resource}». Правь источник: ${sources}/${resource} — потом собери пакет и разложи." \
117
+ "правка, верная только этому дереву, идёт в надстройку .claude/rt-kit/overrides/${resource} — она сливается по разделу «## » и раскладку переживает"
118
+ fi
119
+
120
+ deny "BLOCKED by rule-source-guard: «${candidate#"$root"/}» разложен пакетом @rt-tools/agent-kit, и правка на его месте теряется на следующей раскладке. Ресурс — «${resource}». Правь надстройку: .claude/rt-kit/overrides/${resource} — она сливается по разделу «## » и раскладку переживает." \
121
+ "то, что верно любому дереву, правится в самом пакете и приезжает сюда новой редакцией"
122
+ done <<EOF
123
+ $candidates
124
+ EOF
125
+
126
+ exit 0
@@ -110,6 +110,7 @@ EOF
110
110
 
111
111
  # Каталог папок задач: у дерева он свой, но имя обычно общее.
112
112
  tasks_dir="${RT_TASKS_DIR:-docs/tasks}"
113
+ main_branch="${RT_MAIN_BRANCH:-main}"
113
114
 
114
115
  # Общий хвост отказа: два законных хода и законная форма обхода, если она у отказа есть. Файл
115
116
  # может быть не разложен — тогда хвоста нет, а причина отказа остаётся прежней.
@@ -146,6 +147,23 @@ root="$(git rev-parse --show-toplevel 2>/dev/null)"
146
147
  dir="$root/$tasks_dir/$branch"
147
148
  plan="$dir/plan.md"
148
149
 
150
+ # Папка, разобранная коммитом этой ветки, — признак того, что работа отдана. Замысел с диска к
151
+ # этой минуте снят намеренно: уборка стоит до открытия заявки, потому что кнопку слияния
152
+ # нажимает человек на хостинге и закрывающему коммиту места после одобрения не остаётся.
153
+ # Правка после уборки — это правка по замечаниям разбора, и требовать под неё замысла значило
154
+ # бы запирать ветку собственным порядком. Признак берётся из истории ветки, а не с диска:
155
+ # снесённая, но не закоммиченная папка отданной работы не означает.
156
+ folder_archived() {
157
+ [ -n "$(git ls-tree -d --name-only HEAD -- "$tasks_dir/$branch" 2>/dev/null | head -1)" ] && return 1
158
+ base="$(git merge-base "$main_branch" HEAD 2>/dev/null)"
159
+ [ -z "$base" ] && return 1
160
+ had="$(git ls-tree -d --name-only "$base" -- "$tasks_dir/$branch" 2>/dev/null | head -1)"
161
+ [ -z "$had" ] && had="$(git log "$base..HEAD" --diff-filter=A --name-only --pretty=format: -- "$tasks_dir/$branch" 2>/dev/null | head -1)"
162
+ [ -n "$had" ]
163
+ }
164
+
165
+ folder_archived && exit 0
166
+
149
167
  if [ ! -f "$plan" ]; then
150
168
  deny "BLOCKED by task-flow: нет замысла — '${tasks_dir}/${branch}/plan.md'. Собери папку задачи с образца (cp -r ${tasks_dir}/_template ${tasks_dir}/${branch}) и заполни шапку, след задачи и этапы, затем повтори. Правило — скил task-flow."
151
169
  fi
@@ -74,6 +74,23 @@ case "$state" in
74
74
  работа-отдана | влито) exit 0 ;;
75
75
  esac
76
76
 
77
+ # То же самое, но объявить это на диске уже нечем: папка задачи разбирается до открытия заявки,
78
+ # и ход работы уезжает вместе с ней. Признак берётся из истории ветки — папка, снятая её
79
+ # коммитом. Без этой ветки хвост работы судился бы вторым признаком, то есть отбивался бы за
80
+ # ход, в котором исполнитель ждёт чужого прогона и ничего в дереве не двигает.
81
+ folder_archived() {
82
+ [ -n "$branch" ] || return 1
83
+ [ -n "$(git ls-tree -d --name-only HEAD -- "$tasks_dir/$branch" 2>/dev/null | head -1)" ] && return 1
84
+ main_branch="${RT_MAIN_BRANCH:-main}"
85
+ base="$(git merge-base "$main_branch" HEAD 2>/dev/null)"
86
+ [ -z "$base" ] && return 1
87
+ had="$(git ls-tree -d --name-only "$base" -- "$tasks_dir/$branch" 2>/dev/null | head -1)"
88
+ [ -z "$had" ] && had="$(git log "$base..HEAD" --diff-filter=A --name-only --pretty=format: -- "$tasks_dir/$branch" 2>/dev/null | head -1)"
89
+ [ -n "$had" ]
90
+ }
91
+
92
+ [ -z "$progress" ] && folder_archived && exit 0
93
+
77
94
  # Следующий шаг из хода работы — его страж и называет в отказе: исполнитель, которому сказано
78
95
  # только «работа не кончена», перечитывает ту же строку сам.
79
96
  next_step=""
@@ -114,17 +114,17 @@ az repos pr update --id 205 --description "$(cat тело.md)"
114
114
 
115
115
  ## Оставшийся шаг
116
116
 
117
- После одобрения ветка получает ещё один коммит разбор папки задачи, и только потом
118
- вливается. До этого коммита вливать рано: папка уедет в главную ветку неразобранной.
117
+ Папка задачи разобрана коммитом `<sha>` за работой убрано. Осталось дождаться прогона и снять
118
+ черновик; до этого кнопка слияния заблокирована хостингом.
119
119
  ```
120
120
 
121
121
  Раздел «Оставшийся шаг» стоит последним и переписывается тем же вызовом, что и остальное тело,
122
- — в тот ход, которым папка разбирается и снимается черновик:
122
+ — в тот ход, которым снимается черновик:
123
123
 
124
124
  ```markdown
125
125
  ## Оставшийся шаг
126
126
 
127
- Не осталось: папка задачи разобрана коммитом `<sha>`, черновик снят. Можно вливать.
127
+ Не осталось: прогон зелёный, черновик снят. Можно вливать.
128
128
  ```
129
129
 
130
130
  Стоит он там потому, что решение о слиянии принимается на этой странице, а не в переписке:
@@ -141,17 +141,17 @@ Closes #86
141
141
 
142
142
  ## Оставшийся шаг
143
143
 
144
- После одобрения ветка получает ещё один коммит разбор папки задачи, и только потом
145
- вливается. До этого коммита вливать рано: папка уедет в главную ветку неразобранной.
144
+ Папка задачи разобрана коммитом `<sha>` за работой убрано. Осталось дождаться прогона и снять
145
+ черновик; до этого кнопка слияния заблокирована хостингом.
146
146
  ```
147
147
 
148
148
  Раздел «Оставшийся шаг» стоит последним и переписывается тем же вызовом, что и остальное тело,
149
- — в тот ход, которым папка разбирается и снимается черновик:
149
+ — в тот ход, которым снимается черновик:
150
150
 
151
151
  ```markdown
152
152
  ## Оставшийся шаг
153
153
 
154
- Не осталось: папка задачи разобрана коммитом `<sha>`, черновик снят. Можно вливать.
154
+ Не осталось: прогон зелёный, черновик снят. Можно вливать.
155
155
  ```
156
156
 
157
157
  Стоит он там потому, что решение о слиянии принимается на этой странице, а не в переписке:
@@ -125,17 +125,17 @@ Closes #86
125
125
 
126
126
  ## Оставшийся шаг
127
127
 
128
- После одобрения ветка получает ещё один коммит разбор папки задачи, и только потом
129
- вливается. До этого коммита вливать рано: папка уедет в главную ветку неразобранной.
128
+ Папка задачи разобрана коммитом `<sha>` за работой убрано. Осталось дождаться прогона и снять
129
+ черновик; до этого кнопка слияния заблокирована хостингом.
130
130
  ```
131
131
 
132
132
  Раздел «Оставшийся шаг» стоит последним и переписывается тем же вызовом, что и остальное
133
- описание, — в тот ход, которым папка разбирается и снимается черновик:
133
+ описание, — в тот ход, которым снимается черновик:
134
134
 
135
135
  ```markdown
136
136
  ## Оставшийся шаг
137
137
 
138
- Не осталось: папка задачи разобрана коммитом `<sha>`, черновик снят. Можно вливать.
138
+ Не осталось: прогон зелёный, черновик снят. Можно вливать.
139
139
  ```
140
140
 
141
141
  Стоит он там потому, что решение о слиянии принимается на этой странице, а не в переписке: