@rt-tools/agent-kit 0.12.0 → 0.14.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 (53) hide show
  1. package/assets/checks/board-runs.github.mjs +132 -0
  2. package/assets/checks/board.github.mjs +43 -41
  3. package/assets/checks/check-board.github.mjs +73 -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/checks/task-new.github.mjs +15 -3
  7. package/assets/commands/feedback.md +8 -0
  8. package/assets/defaults/project.sh +50 -5
  9. package/assets/hooks/browser-guard-no-other-drivers.sh +1 -1
  10. package/assets/hooks/claim-guard.sh +34 -0
  11. package/assets/hooks/dev-server-guard.sh +1 -1
  12. package/assets/hooks/dispatch.sh +48 -15
  13. package/assets/hooks/git-guard-delivery-folder.sh +99 -0
  14. package/assets/hooks/git-guard-delivery-signature.sh +10 -4
  15. package/assets/hooks/git-guard-delivery.sh +21 -69
  16. package/assets/hooks/git-guard-push-tests.sh +2 -2
  17. package/assets/hooks/hook-input.sh +62 -0
  18. package/assets/hooks/override-write-guard.sh +107 -0
  19. package/assets/hooks/rerun-guard.sh +1 -1
  20. package/assets/hooks/rule-source-guard.sh +126 -0
  21. package/assets/hooks/task-flow-guard.sh +18 -0
  22. package/assets/hooks/turn-exit-guard.sh +33 -1
  23. package/assets/hooks/waiting-turn-guard.sh +39 -3
  24. package/assets/patterns/git-workflow-pr.azure.md +4 -4
  25. package/assets/patterns/git-workflow-pr.github.md +4 -4
  26. package/assets/patterns/git-workflow-pr.gitlab.md +4 -4
  27. package/assets/patterns/task-flow-archive.md +23 -21
  28. package/assets/patterns/task-flow-close.md +98 -96
  29. package/assets/patterns/task-flow-resume.md +5 -3
  30. package/assets/pitfalls/agent-kit.md +80 -0
  31. package/assets/pitfalls/git-workflow.github.md +8 -0
  32. package/assets/rules/deploy-flow.azure.md +15 -7
  33. package/assets/rules/deploy-flow.github.md +15 -6
  34. package/assets/rules/deploy-flow.gitlab.md +15 -7
  35. package/assets/rules/git-workflow.github.md +10 -5
  36. package/assets/rules/task-flow.md +44 -26
  37. package/assets/rules/turn-conduct.md +33 -0
  38. package/assets/skills/agent-kit.md +32 -77
  39. package/assets/templates/proposal.md +16 -1
  40. package/lib/proposals.d.ts +5 -1
  41. package/lib/proposals.d.ts.map +1 -1
  42. package/lib/proposals.js +74 -5
  43. package/lib/proposals.js.map +1 -1
  44. package/lib/shipment.d.ts.map +1 -1
  45. package/lib/shipment.fixture.d.ts +39 -0
  46. package/lib/shipment.fixture.d.ts.map +1 -0
  47. package/lib/shipment.fixture.js +99 -0
  48. package/lib/shipment.fixture.js.map +1 -0
  49. package/lib/shipment.js +59 -7
  50. package/lib/shipment.js.map +1 -1
  51. package/package.json +1 -1
  52. package/rt-tools-agent-kit-0.14.0.tgz +0 -0
  53. package/rt-tools-agent-kit-0.12.0.tgz +0 -0
@@ -41,6 +41,36 @@ transcript="$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/nu
41
41
  [ -z "$transcript" ] && exit 0
42
42
  [ -f "$transcript" ] || exit 0
43
43
 
44
+ # Текст ответа ложится в запись хода не раньше, чем хост позовёт хук: гард, прочитавший файл
45
+ # первым, судит ход, у которого сказанного нет вовсе, — и молчит, будучи неотличим от гарда,
46
+ # который посмотрел и пропустил. Ждём его появления, и не дождавшись — возвращаем ход: пустая
47
+ # запись означает не «владельцу ничего не сказано», а «прочитать нечего».
48
+ #
49
+ # Отказ этот принадлежит одному гарду нарочно. Текст судят трое, и печатай они свои объекты
50
+ # подряд, вывод перестал бы разбираться целиком — то есть отбой пропал бы весь.
51
+ if ! rt_turn_has_text "$transcript"; then
52
+ reason="BLOCKED by claim-guard: запись хода не отдала ни одного текста ответа, и судить сказанное владельцу нечем.
53
+
54
+ Текст ложится в запись не раньше, чем хост зовёт хук. Прочитанная слишком рано запись выглядит ходом, в котором владельцу ничего не сказано, — и все гарды, судящие сказанное, проходят мимо молча.
55
+
56
+ Повтори завершение хода: к этой минуте текст в записи уже есть. Ход при этом ничего не теряет — сказанное владельцу остаётся тем же.
57
+
58
+ Гард судит один ход: следующий заход не отбивается."
59
+
60
+ # shellcheck disable=SC1090
61
+ [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
62
+ && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
63
+ command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
64
+ deny_tail_text="$(rt_deny_tail "")"
65
+ [ -n "$deny_tail_text" ] && reason="${reason}
66
+
67
+ ${deny_tail_text}"
68
+
69
+ jq -n --arg r "$reason" '{decision:"block",reason:$r}' 2>/dev/null \
70
+ || printf '{"decision":"block","reason":"claim-guard: запись хода не отдала текста ответа — повтори завершение хода."}\n'
71
+ exit 0
72
+ fi
73
+
44
74
  # Ход — всё, что записано после последней настоящей реплики владельца: ответ инструмента
45
75
  # приходит той же ролью и репликой не считается.
46
76
  turn="$(tail -n 400 "$transcript" 2>/dev/null | jq -s -r '
@@ -76,6 +106,10 @@ claims=(
76
106
  '(ветки|ветка|файлы|файл|папка|каталог)[^.]{0,40}(снят|удал|почищ|вычищ)|снят[оыа] с§git branch|git push .*--delete|git rm|gh api|rm §команду удаления — `git branch -d`, `git push --delete` или `git rm`'
77
107
  'прогон (зелёный|прошёл|кончился)|конвейер зелёный|проверки на PR зелёные§gh run§`gh run list` или `gh run view`'
78
108
  '(работа|правка|задача) готова|можно вливать|PR открыт|черновик снят§gh pr §`gh pr create`, `gh pr view` или `gh pr ready`'
109
+ # Ожидание чужого шага — тоже утверждение о состоянии, и врать ему есть чем: прогон бывает
110
+ # зелёным час, бывает не встав вовсе. Сказанное без команды оставляет готовую работу
111
+ # черновиком, и владелец узнаёт об этом последним — дважды за сутки так и вышло.
112
+ 'жд[уёя][^.]{0,20}прогон|дожида[ею][^.]{0,20}прогон|прогон[^.]{0,20}(ещё идёт|не встал|не кончился|не дошёл)|черновик[^.]{0,30}(не снимаю|сниму|снимется)§gh run|gh pr checks|check-runs|check:board|board\.mjs§команду о прогоне — `gh run list`, `gh pr checks` или сверку очереди работ'
79
113
  'задача заведена|задача (в|переведена в) колонк|колонка переведена§gh issue|gh api|task:new|task:move|board\.mjs§команду очереди работ — заведение задачи или перевод колонки'
80
114
  '(в дереве|в репозитории|здесь|такого файла|такой команды)[^.]{0,30}(нет|не бывает)|не заводили|нигде не встречается§grep|rg |ls |find |git ls-files|git grep|git log|cat §команду поиска — `grep`, `git ls-files` или обход каталога'
81
115
  )
@@ -100,7 +100,7 @@ RUNNER='((npx|pnpm|yarn|bun|npm)([[:space:]]+(exec|run|dlx))?[[:space:]]+)?'
100
100
 
101
101
  # Начало вызова: начало строки или разделитель команд. Кавычку в границы вносить нельзя — тогда
102
102
  # поиск по тексту и снятие процесса по шаблону читаются как запуск.
103
- BOUND='(^|[;&|(]|&&|\|\|)[[:space:]]*'
103
+ BOUND="$RT_CMD_BOUND"
104
104
 
105
105
  printf '%s' "$cmd" | grep -qE "${BOUND}${RUNNER}(nx|ng)[[:space:]]+(run[[:space:]]+[^[:space:]]*:)?(serve|dev)" \
106
106
  && deny "Запуск ещё одного сервера разработки через каркас."
@@ -46,24 +46,57 @@ export RT_HOOK_INPUT="$input"
46
46
  branches="$(grep -l '^# rt-hook:' "$here"/*.sh 2>/dev/null | sort)"
47
47
  [ -z "$branches" ] && exit 0
48
48
 
49
+ # Объявлений у файла бывает несколько: гард, стоящий и на вызове инструмента, и на завершении
50
+ # хода, называет оба события своими строками. Читалось прежде только первое — и вторая ветка не
51
+ # звалась ни разу, молча: снаружи это неотличимо от гарда, который посмотрел и пропустил.
52
+ collected=""
49
53
  for branch in $branches; do
50
- declaration="$(sed -n 's/^# rt-hook:[[:space:]]*//p' "$branch" 2>/dev/null | head -1)"
51
- [ -z "$declaration" ] && continue
52
-
53
- branch_event="${declaration%% *}"
54
- [ "$branch_event" = "$event" ] || continue
55
-
56
- # Образец вызова: его нет вовсе — гард зовётся на любом; есть — сверяется с именем
57
- # инструмента целиком, а не куском. Звёздочка и точка со звёздочкой значат одно: любой вызов.
58
- matcher="${declaration#"$branch_event"}"
59
- matcher="${matcher#"${matcher%%[![:space:]]*}"}"
60
- if [ -n "$matcher" ] && [ "$matcher" != '*' ] && [ "$matcher" != '.*' ]; then
61
- [[ "${RT_HOOK_TOOL:-}" =~ ^(${matcher})$ ]] || continue
62
- fi
54
+ matched=0
55
+ while IFS= read -r declaration; do
56
+ [ -z "$declaration" ] && continue
57
+
58
+ branch_event="${declaration%% *}"
59
+ [ "$branch_event" = "$event" ] || continue
60
+
61
+ # Образец вызова: его нет вовсе гард зовётся на любом; есть сверяется с именем
62
+ # инструмента целиком, а не куском. Звёздочка и точка со звёздочкой значат одно: любой
63
+ # вызов.
64
+ matcher="${declaration#"$branch_event"}"
65
+ matcher="${matcher#"${matcher%%[![:space:]]*}"}"
66
+ if [ -n "$matcher" ] && [ "$matcher" != '*' ] && [ "$matcher" != '.*' ]; then
67
+ [[ "${RT_HOOK_TOOL:-}" =~ ^(${matcher})$ ]] || continue
68
+ fi
69
+
70
+ matched=1
71
+ break
72
+ done <<EOF
73
+ $(sed -n 's/^# rt-hook:[[:space:]]*//p' "$branch" 2>/dev/null)
74
+ EOF
75
+
76
+ # Совпало хоть одно объявление — ветка зовётся один раз. Два объявления одного события в
77
+ # одном файле звали бы гард дважды на один ввод, и второй вызов судил бы то же самое.
78
+ [ "$matched" = 1 ] || continue
63
79
 
64
- printf '%s' "$input" | bash "$branch"
80
+ branch_out="$(printf '%s' "$input" | bash "$branch" 2>/dev/null)"
65
81
  code=$?
66
- [ "$code" -ne 0 ] && exit "$code"
82
+ if [ "$code" -ne 0 ]; then
83
+ [ -n "$branch_out" ] && printf '%s\n' "$branch_out"
84
+ exit "$code"
85
+ fi
86
+
87
+ # Отбой ветки приходит не кодом возврата, а решением в выводе: гарды завершения хода
88
+ # печатают его и выходят нулём. Не остановившись здесь, диспетчер склеил бы этот объект с
89
+ # выводом следующей ветки — а склеенное не разбирается, и отбой пропадает целиком.
90
+ if [ -n "$branch_out" ] && printf '%s' "$branch_out" | jq -e '.decision == "block"' >/dev/null 2>&1; then
91
+ printf '%s\n' "$branch_out"
92
+ exit 0
93
+ fi
94
+
95
+ [ -n "$branch_out" ] && collected="${collected}${branch_out}
96
+ "
67
97
  done
68
98
 
99
+ # Ни одна ветка не отбила: отдаётся то, что они напечатали, — подсказки и сводки.
100
+ [ -n "${collected:-}" ] && printf '%s' "$collected"
101
+
69
102
  exit 0
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env bash
2
+ # Условия поставки о папке задачи. НЕ гард: объявления `rt-hook:` у него нет, к событиям агента
3
+ # он не подключается. Его источает гард поставки — тем же приёмом, каким источает хвост отказа и
4
+ # запись наблюдений.
5
+ #
6
+ # Зачем он отдельным файлом. Условий о папке два, и живут они в разных местах вызова: одно на
7
+ # открытии заявки, другое на слиянии. Вместе с разбором команды, подписью, основанием и очередью
8
+ # работ они переросли предел длины файла, а делить гард по событиям нельзя — он одно целое,
9
+ # и разбор команды у него общий. Делится он по предмету: папка задачи — свой предмет, у неё свой
10
+ # обход и своя запись в архиве.
11
+ #
12
+ # ЧТО ЗДЕСЬ. Три вызова, и все три зовутся из гарда поставки, где уже объявлены `deny`, `fault`
13
+ # и переменные профиля: `tasks_dir`, `archive_dir`, `main_branch`, `folder_skip_re`, `root`.
14
+ # Своих у помощника нет — он не читает ни ввода, ни настроек.
15
+
16
+ # Есть ли папка задачи в ветке. Смотрим содержимое ветки, а не рабочее дерево: если папку
17
+ # удалили, но не закоммитили, проверка прошла бы, а папка всё равно уехала бы в главную. Имя
18
+ # ветки подставляем целиком, вместе с косой: у ветки вида `chore/312-slug` папка лежит во
19
+ # вложенном каталоге.
20
+ rt_folder_in_branch() {
21
+ git ls-tree -d --name-only HEAD -- "$1" 2>/dev/null | head -1
22
+ }
23
+
24
+ # Была ли папка задачи в этой ветке вообще. Спрашивается у ветки, которая её удалила: иначе
25
+ # требование записи в архиве цеплялось бы к работе, у которой папки и не было.
26
+ rt_folder_was_in_branch() {
27
+ _base="$1"
28
+ _folder="$2"
29
+ _had="$(git ls-tree -d --name-only "$_base" -- "$_folder" 2>/dev/null | head -1)"
30
+ [ -z "$_had" ] && _had="$(git log "$_base..HEAD" --diff-filter=A --name-only --pretty=format: -- "$_folder" 2>/dev/null | head -1)"
31
+ [ -n "$_had" ]
32
+ }
33
+
34
+ # Условие открытия заявки: папка разобрана, и запись в архиве от ветки прибыла. Отказ копится
35
+ # вместе с прочими условиями поставки и печатается разом.
36
+ #
37
+ # Разбор стоит здесь, а не на слиянии. Прежде было наоборот: считалось, что папка нужна на диске
38
+ # всё время разбора, потому что правку по замечаниям без замысла отбивает гард хода работы.
39
+ # Владелец же вливает, как только видит зелёное, и закрывающему коммиту места не остаётся —
40
+ # трижды подряд папка уехала в главную неразобранной, а гард слияния этого не видел вовсе:
41
+ # кнопку нажимает человек на хостинге, и до него гард не достаёт. Замысел после уборки гард хода
42
+ # работы берёт из истории ветки, а не с диска.
43
+ rt_delivery_open_folder() {
44
+ [ -n "$tasks_dir" ] || return 0
45
+ printf '%s' "$cmd" | grep -qiE "$folder_skip_re" && return 0
46
+
47
+ _folder="$tasks_dir/$branch"
48
+ _lying="$(rt_folder_in_branch "$_folder")"
49
+ if [ -n "$_lying" ]; then
50
+ fault "в ветке лежит папка задачи «${_lying}» — заявка открывается после уборки, а не до неё. Перенеси в «${archive_dir:-архив}» то, что объясняет принятые решения, остальное удали, закоммить последним коммитом и повтори. Если работа вливается частями, поставь в команду комментарий «# Task-folder-skip: <причина>»."
51
+ return 0
52
+ fi
53
+
54
+ [ -n "$archive_dir" ] || return 0
55
+ _base="$(git merge-base "$main_branch" HEAD 2>/dev/null)"
56
+ [ -z "$_base" ] && return 0
57
+ rt_folder_was_in_branch "$_base" "$_folder" || return 0
58
+
59
+ _gained="$(git diff --name-only --diff-filter=A "$_base" HEAD -- "$archive_dir" 2>/dev/null | head -1)"
60
+ [ -z "$_gained" ] \
61
+ && fault "папку задачи удалили, но в «${archive_dir}» ветка ничего не добавила. Удалить проще, чем разобрать, — и вместе с папкой пропадает разбор просьбы, единственная запись слов владельца. Перенеси то, что объясняет принятые решения, одним файлом с понятным именем и повтори."
62
+ }
63
+
64
+ # Условие слияния: тот же предмет вторым рубежом. Он ловит слияние, идущее командой, — то, до
65
+ # которого гард достаёт. Отсюда выходят только отказом или молчанием: слияние решается целиком.
66
+ rt_delivery_merge_folder() {
67
+ [ -n "$tasks_dir" ] || exit 0 # ведения работы папкой в дереве нет
68
+
69
+ _branch="$(git branch --show-current 2>/dev/null)"
70
+ [ -z "$_branch" ] && exit 0
71
+ rt_task_branch_ok "$_branch" || exit 0 # за беззадачной веткой папки не стоит
72
+
73
+ _folder="$tasks_dir/$_branch"
74
+
75
+ # Сначала ищем обход в самой команде — это работает и без сети. Если читать только
76
+ # тело заявки, то без сети гард отбил бы слияние, причина которого в этом теле и написана.
77
+ printf '%s' "$cmd" | grep -qiE "$folder_skip_re" && exit 0
78
+
79
+ _number="$(printf '%s' "$cmd" | sed -nE 's/.*(pr|mr)[[:space:]]+(merge|update)[[:space:]]+([0-9]+).*/\3/p' | head -1)"
80
+ if [ -n "$_number" ] && rt_needs rt_report_body git-guard-delivery; then
81
+ _body="$(cd "$root" && rt_report_body "$_number" 2>/dev/null)"
82
+ [ -n "$_body" ] && printf '%s' "$_body" | grep -qiE "$folder_skip_re" && exit 0
83
+ fi
84
+
85
+ _lying="$(rt_folder_in_branch "$_folder")"
86
+ [ -n "$_lying" ] \
87
+ && deny "BLOCKED: в ветке осталась папка задачи «${_lying}» — она уедет в главную. Разобрать её потом будет некому: работа перейдёт к следующей задаче, а эта заявка закроется. Перенеси в «${archive_dir:-архив}» то, что объясняет принятые решения, остальное удали и повтори. Если работа вливается частями, поставь в тело заявки строку «Task-folder-skip: <причина>»."
88
+
89
+ [ -n "$archive_dir" ] || exit 0
90
+ _base="$(git merge-base "$main_branch" HEAD 2>/dev/null)"
91
+ [ -z "$_base" ] && exit 0
92
+ rt_folder_was_in_branch "$_base" "$_folder" || exit 0
93
+
94
+ _gained="$(git diff --name-only --diff-filter=A "$_base" HEAD -- "$archive_dir" 2>/dev/null | head -1)"
95
+ [ -z "$_gained" ] \
96
+ && deny "BLOCKED: папку задачи удалили, но в «${archive_dir}» ветка ничего не добавила. Удалить проще, чем разобрать, — и вместе с папкой пропадает разбор просьбы, единственная запись слов владельца. Перенеси то, что объясняет принятые решения, одним файлом с понятным именем и повтори."
97
+
98
+ exit 0
99
+ }
@@ -11,6 +11,10 @@
11
11
  # среди них самая отдельная — своя точка проверки, свои условия молчания, свой отказ.
12
12
 
13
13
  . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/utf8.sh" 2>/dev/null || true
14
+ # Начало вызова помощник берёт оттуда же, откуда все гарды. Источается он и здесь, хотя зовущий
15
+ # гард это уже сделал: пустой образец превратил бы признак в поиск слова по всей строке, то есть
16
+ # в отказ на каждом упоминании команды.
17
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/hook-input.sh" 2>/dev/null || true
14
18
 
15
19
  rt_delivery_signature() {
16
20
  # --- подпись машинного коммита ------------------------------------------------------------
@@ -33,13 +37,15 @@ rt_delivery_signature() {
33
37
  # логина разошлись бы молча. Исполнителем задачи он тоже не бывает — в дереве, где хостинг
34
38
  # ограничил машинную запись, исполнителем ставят человека, а коммит остаётся машинным.
35
39
  #
36
- # Вызов пуша узнаётся по двум признакам сразу — команда `git` в начале строки или за
37
- # разделителем и слово `push` отдельным словом. Одной подстрокой «git push» его не поймать:
38
- # помощник учётных данных и заголовок запроса ставятся ключами `-c` между ними.
40
+ # Вызов пуша узнаётся по двум признакам сразу — начало вызова перед словом `git` и слово `push`
41
+ # отдельным словом. Одной подстрокой «git push» его не поймать: помощник учётных данных и
42
+ # заголовок запроса ставятся ключами `-c` между ними. Начало вызова считает присваивания
43
+ # переменных частью команды: пуш подписанного коммита набирают с подстановкой токена, и признак
44
+ # без них пропускал ровно тот вызов, ради которого этот помощник и заведён.
39
45
  # Пробный пуш не отправляет ничего, и подпись у него не спрашивают. Отложенная правка — тоже:
40
46
  # `git stash push` кладёт правку в тайник этой же машины, поэтому тайник вырезается из строки, а
41
47
  # признак считается по остатку.
42
- if printf '%s' "$cmd" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*git([[:space:]]|$)' \
48
+ if printf '%s' "$cmd" | grep -qE "${RT_CMD_BOUND}git([[:space:]]|\$)" \
43
49
  && printf '%s' "$cmd" | sed -E 's/git[[:space:]]+stash[[:space:]]+push/git stash/g' | grep -qE '(^|[[:space:]])push([[:space:]]|$)' \
44
50
  && ! printf '%s' "$cmd" | grep -q -- '--dry-run'; then
45
51
  # Два условия молчания. Дерево, не назвавшее почты машинной записи, требования не получает:
@@ -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,8 +47,70 @@ 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'; }
53
76
  rt_hook_file() { rt_hook_field RT_HOOK_FILE '.tool_input.file_path'; }
54
77
  rt_hook_cwd() { rt_hook_field RT_HOOK_CWD '.cwd'; }
78
+
79
+ # ЖДЁТ ПОСЛЕДНИЙ ТЕКСТ ХОДА В ЗАПИСИ. Гарды завершения судят то, что сказано владельцу, а запись
80
+ # хода на этот момент бывает неполна: текст ответа ложится в файл не раньше, чем хост позовёт
81
+ # хук, и гард читает ход, у которого текста нет вовсе. Молчит он при этом честно — и снаружи
82
+ # неотличим от гарда, который посмотрел и пропустил. Ровно так ход, поставивший работу в
83
+ # зависимость от слова владельца, ушёл мимо трёх гардов сразу, а тот же ход, поданный им
84
+ # повторно, был отбит.
85
+ #
86
+ # Ждём короткими попытками: файл дописывается за миллисекунды, а ход и без того кончается не
87
+ # мгновенно. Дождались — ноль; текста так и нет — единица, и решает уже гард.
88
+ rt_turn_has_text() {
89
+ local transcript="$1" tries="${2:-20}" got
90
+
91
+ [ -n "$transcript" ] && [ -f "$transcript" ] || return 1
92
+ command -v jq >/dev/null 2>&1 || return 1
93
+
94
+ while [ "$tries" -gt 0 ]; do
95
+ got="$(tail -n 400 "$transcript" 2>/dev/null | jq -s -r '
96
+ def is_input:
97
+ .type == "user"
98
+ and (((.message.content // []) | if type == "array"
99
+ then ([.[] | select(.type == "tool_result")] | length)
100
+ else 0 end) == 0);
101
+
102
+ (map(is_input) | rindex(true)) as $i
103
+ | (if $i == null then . else .[$i + 1:] end)
104
+ | [.[] | select(.type == "assistant") | (.message.content // [])[]
105
+ | select(.type == "text") | .text]
106
+ | length
107
+ ' 2>/dev/null)"
108
+
109
+ [ -n "$got" ] && [ "$got" != '0' ] && return 0
110
+
111
+ tries=$((tries - 1))
112
+ [ "$tries" -gt 0 ] && sleep 0.05
113
+ done
114
+
115
+ return 1
116
+ }
@@ -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