@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.
- package/assets/checks/board-runs.github.mjs +132 -0
- package/assets/checks/board.github.mjs +43 -41
- package/assets/checks/check-board.github.mjs +73 -8
- package/assets/checks/check-schema-drift.mjs +28 -5
- package/assets/checks/rt-kit-checks.config.mjs +12 -0
- package/assets/checks/task-new.github.mjs +15 -3
- package/assets/commands/feedback.md +8 -0
- package/assets/defaults/project.sh +50 -5
- package/assets/hooks/browser-guard-no-other-drivers.sh +1 -1
- package/assets/hooks/claim-guard.sh +34 -0
- package/assets/hooks/dev-server-guard.sh +1 -1
- package/assets/hooks/dispatch.sh +48 -15
- package/assets/hooks/git-guard-delivery-folder.sh +99 -0
- package/assets/hooks/git-guard-delivery-signature.sh +10 -4
- package/assets/hooks/git-guard-delivery.sh +21 -69
- package/assets/hooks/git-guard-push-tests.sh +2 -2
- package/assets/hooks/hook-input.sh +62 -0
- package/assets/hooks/override-write-guard.sh +107 -0
- package/assets/hooks/rerun-guard.sh +1 -1
- package/assets/hooks/rule-source-guard.sh +126 -0
- package/assets/hooks/task-flow-guard.sh +18 -0
- package/assets/hooks/turn-exit-guard.sh +33 -1
- package/assets/hooks/waiting-turn-guard.sh +39 -3
- package/assets/patterns/git-workflow-pr.azure.md +4 -4
- package/assets/patterns/git-workflow-pr.github.md +4 -4
- package/assets/patterns/git-workflow-pr.gitlab.md +4 -4
- package/assets/patterns/task-flow-archive.md +23 -21
- package/assets/patterns/task-flow-close.md +98 -96
- package/assets/patterns/task-flow-resume.md +5 -3
- package/assets/pitfalls/agent-kit.md +80 -0
- package/assets/pitfalls/git-workflow.github.md +8 -0
- package/assets/rules/deploy-flow.azure.md +15 -7
- package/assets/rules/deploy-flow.github.md +15 -6
- package/assets/rules/deploy-flow.gitlab.md +15 -7
- package/assets/rules/git-workflow.github.md +10 -5
- package/assets/rules/task-flow.md +44 -26
- package/assets/rules/turn-conduct.md +33 -0
- package/assets/skills/agent-kit.md +32 -77
- package/assets/templates/proposal.md +16 -1
- package/lib/proposals.d.ts +5 -1
- package/lib/proposals.d.ts.map +1 -1
- package/lib/proposals.js +74 -5
- package/lib/proposals.js.map +1 -1
- package/lib/shipment.d.ts.map +1 -1
- package/lib/shipment.fixture.d.ts +39 -0
- package/lib/shipment.fixture.d.ts.map +1 -0
- package/lib/shipment.fixture.js +99 -0
- package/lib/shipment.fixture.js.map +1 -0
- package/lib/shipment.js +59 -7
- package/lib/shipment.js.map +1 -1
- package/package.json +1 -1
- package/rt-tools-agent-kit-0.14.0.tgz +0 -0
- 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=
|
|
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 "Запуск ещё одного сервера разработки через каркас."
|
package/assets/hooks/dispatch.sh
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 ]
|
|
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
|
-
# Вызов пуша узнаётся по двум признакам сразу —
|
|
37
|
-
#
|
|
38
|
-
#
|
|
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
|
|
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
|
|
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
|
-
#
|
|
345
|
-
# работа перешла к следующей задаче, а PR закрыт. Раньше слияния требовать нельзя — пока идёт
|
|
346
|
-
# ревью, plan.md нужен на диске, иначе гард хода работы не даст править код.
|
|
335
|
+
# Второй рубеж того же условия, что стоит на открытии заявки: он ловит слияние, идущее командой.
|
|
347
336
|
# Команду ищем от начала строки или после разделителя, а не где угодно в тексте. Иначе гард
|
|
348
337
|
# отбивает сообщение, где `gh pr merge` просто упомянут в кавычках, — так он и сработал на
|
|
349
338
|
# правке этого же текста. Полностью подстроку в кавычках так не отсечь, но случайное упоминание
|
|
350
339
|
# внутри слова или пути мимо уже не пройдёт.
|
|
351
|
-
if printf '%s' "$cmd" | grep -qE
|
|
352
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|