@rt-tools/agent-kit 0.22.0 → 0.24.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 (101) hide show
  1. package/README.md +8 -2
  2. package/assets/checks/board-long-work.github.mjs +101 -0
  3. package/assets/checks/board-runs.github.mjs +34 -0
  4. package/assets/checks/board.github.mjs +1 -1
  5. package/assets/checks/check-board.github.mjs +30 -2
  6. package/assets/checks/check-doc-paths.mjs +24 -5
  7. package/assets/checks/check-file-size.mjs +8 -2
  8. package/assets/checks/check-prose-style.mjs +10 -1
  9. package/assets/checks/check-reuse.mjs +4 -1
  10. package/assets/checks/check-schema-drift.mjs +65 -6
  11. package/assets/checks/rt-kit-checks.config.mjs +13 -0
  12. package/assets/checks/signals.mjs +41 -1
  13. package/assets/commands/next-session.md +16 -5
  14. package/assets/defaults/gate-map.sh +13 -0
  15. package/assets/defaults/project.sh +26 -0
  16. package/assets/defaults/shell.sh +18 -3
  17. package/assets/hooks/browser-guard-device-id.sh +42 -12
  18. package/assets/hooks/browser-guard-no-asking.sh +5 -1
  19. package/assets/hooks/dispatch.sh +40 -9
  20. package/assets/hooks/docs-guard.sh +10 -0
  21. package/assets/hooks/exam-guard.sh +66 -16
  22. package/assets/hooks/git-guard-delivery-draft.sh +78 -0
  23. package/assets/hooks/git-guard-delivery.sh +64 -122
  24. package/assets/hooks/git-guard-main.sh +39 -4
  25. package/assets/hooks/git-guard-push-tests.sh +71 -3
  26. package/assets/hooks/glossary-load.sh +23 -2
  27. package/assets/hooks/grill-gate.sh +62 -0
  28. package/assets/hooks/hook-input.sh +17 -6
  29. package/assets/hooks/rule-source-guard.sh +11 -0
  30. package/assets/hooks/stand-login-guard.sh +101 -0
  31. package/assets/hooks/write-targets.sh +37 -4
  32. package/assets/laws/autonomous-work.md +30 -0
  33. package/assets/laws/project-documentation.md +8 -0
  34. package/assets/laws/verifiability.md +12 -2
  35. package/assets/laws/work-conduct.md +59 -65
  36. package/assets/patterns/autonomous-work-run.md +105 -0
  37. package/assets/patterns/browser-verification-measure.md +41 -1
  38. package/assets/patterns/browser-verification-stand.md +57 -16
  39. package/assets/patterns/doc-style-human.md +75 -0
  40. package/assets/patterns/doc-style-write.md +16 -0
  41. package/assets/patterns/git-workflow-commit.azure.md +12 -0
  42. package/assets/patterns/git-workflow-commit.github.md +16 -3
  43. package/assets/patterns/git-workflow-commit.gitlab.md +12 -0
  44. package/assets/patterns/git-workflow-merge.md +8 -0
  45. package/assets/patterns/git-workflow-pr-ready.md +93 -0
  46. package/assets/patterns/git-workflow-pr.azure.md +1 -1
  47. package/assets/patterns/git-workflow-pr.github.md +1 -1
  48. package/assets/patterns/git-workflow-pr.gitlab.md +1 -1
  49. package/assets/patterns/task-flow-start.md +48 -48
  50. package/assets/patterns/testing-e2e.md +18 -8
  51. package/assets/patterns/ts-procedure.md +3 -2
  52. package/assets/pitfalls/task-flow.md +40 -40
  53. package/assets/rules/autonomous-work.md +92 -0
  54. package/assets/rules/browser-verification.md +35 -3
  55. package/assets/rules/deploy-flow.azure.md +7 -0
  56. package/assets/rules/deploy-flow.github.md +7 -0
  57. package/assets/rules/deploy-flow.gitlab.md +7 -0
  58. package/assets/rules/doc-style.md +54 -0
  59. package/assets/rules/git-workflow.azure.md +8 -0
  60. package/assets/rules/git-workflow.github.md +59 -63
  61. package/assets/rules/git-workflow.gitlab.md +8 -0
  62. package/assets/rules/reuse-first.md +25 -5
  63. package/assets/rules/styling-bem.md +8 -1
  64. package/assets/rules/task-flow.md +108 -109
  65. package/assets/rules/testing.md +21 -0
  66. package/assets/skills/agent-kit.md +72 -82
  67. package/lib/commands.d.ts.map +1 -1
  68. package/lib/commands.js +69 -2
  69. package/lib/commands.js.map +1 -1
  70. package/lib/enroll.d.ts.map +1 -1
  71. package/lib/enroll.js +1 -1
  72. package/lib/enroll.js.map +1 -1
  73. package/lib/observations.d.ts +10 -1
  74. package/lib/observations.d.ts.map +1 -1
  75. package/lib/observations.js +1 -0
  76. package/lib/observations.js.map +1 -1
  77. package/lib/override-marks.d.ts +24 -0
  78. package/lib/override-marks.d.ts.map +1 -0
  79. package/lib/override-marks.js +98 -0
  80. package/lib/override-marks.js.map +1 -0
  81. package/lib/push-gate.d.ts +14 -0
  82. package/lib/push-gate.d.ts.map +1 -0
  83. package/lib/push-gate.js +93 -0
  84. package/lib/push-gate.js.map +1 -0
  85. package/lib/shipment.d.ts.map +1 -1
  86. package/lib/shipment.js +1 -1
  87. package/lib/shipment.js.map +1 -1
  88. package/package.json +1 -1
  89. package/rt-tools-agent-kit-0.24.0.tgz +0 -0
  90. package/assets/laws/application/access.md +0 -34
  91. package/assets/laws/application/locales.md +0 -33
  92. package/assets/laws/application/search-visibility.md +0 -24
  93. package/assets/patterns/permissions-procedure.md +0 -71
  94. package/assets/patterns/seo-page.md +0 -104
  95. package/assets/patterns/seo-verify.md +0 -83
  96. package/assets/patterns/translations-content.md +0 -107
  97. package/assets/patterns/translations-key.md +0 -64
  98. package/assets/rules/permissions.md +0 -116
  99. package/assets/rules/seo.md +0 -139
  100. package/assets/rules/translations.md +0 -96
  101. package/rt-tools-agent-kit-0.22.0.tgz +0 -0
@@ -241,6 +241,21 @@ rt_task_branch_ok_default() {
241
241
  | grep -qE '^([A-Z]+-)?[0-9]+-[a-z0-9][a-z0-9-]*$|^(feat|fix|refactor|docs|chore|style|perf|test|build|ci)/[0-9]+-[a-z0-9][a-z0-9-]*$'
242
242
  }
243
243
 
244
+ # Номер задачи из имени ветки. Второй вопрос к тому же имени: форма отвечает «годится ли»,
245
+ # а это — «какой он», и свести их в одно нельзя: одна возвращает код, другой строку.
246
+ #
247
+ # Формы те же, что знает проверка формы выше: приставкой стоит либо ключ задач через дефис,
248
+ # либо род правки через косую черту, либо её нет вовсе. Разойдясь с ней, разбор оставит форму
249
+ # законной, а номер пустым — и сверка номера ветки с номером заголовка заявки пропустится молча,
250
+ # выглядя при этом сошедшейся.
251
+ #
252
+ # Хвост после номера здесь не судится намеренно: `RT-9_guest` — это промах в разделителе, а не
253
+ # осознанно беззадачная ветка, и номер из неё вынуть надо, чтобы форму имени было с чем сверить.
254
+ # Требование дефиса оставляло такое имя без номера, и гард пропускал его как ветку под пробу.
255
+ rt_task_branch_number_default() {
256
+ printf '%s' "$1" | sed -nE 's#^([A-Za-z]+[-/])?([0-9]+).*#\2#p'
257
+ }
258
+
244
259
  # Адрес боевого хранилища: образец, по которому его узнают в командной строке — порт туннеля,
245
260
  # хост, домен. Любая запись по нему отбивается совсем, и опт-аут не действует. Умолчание
246
261
  # молчит: чужой адрес здесь опаснее неназванного — он отбивал бы работу с локальной базой.
@@ -273,6 +288,16 @@ RT_TASK_BOT="${RT_TASK_BOT:-}"
273
288
  RT_PULL_TOKEN_VAR="${RT_PULL_TOKEN_VAR:-}"
274
289
  RT_PULL_TOKEN_HINT="${RT_PULL_TOKEN_HINT:-}"
275
290
 
291
+ # Кто придёт к хостингу по этому токену. Подстановка в команде говорит только о намерении: она
292
+ # читает файл, а файла на машине может не быть — тогда значение пустое, клиент отвечает от
293
+ # залогиненной записи, и заявка выходит от владельца при верной с виду команде. Спросить это
294
+ # стоит одного вызова, но как спрашивать, знает только дерево: хостинг, клиент и путь к токену
295
+ # у каждого свои. Умолчание молчит: дерево, не объявившее функции, второго яруса не получает.
296
+ #
297
+ # Контракт: печатает логин, под которым уйдёт пишущий вызов. Пустой вывод означает «спросить не
298
+ # удалось» — гард пропускает вызов и сообщает об этом.
299
+ rt_pull_token_login() { :; }
300
+
276
301
  # Раздел, который тело заявки обязано нести с минуты открытия: решение о слиянии принимается на
277
302
  # её странице, где переписки нет вовсе, и сказанного вслух там не остаётся. Умолчание молчит —
278
303
  # заголовок пишется языком заявки, а чужих слов пакет не знает: не названный деревом, раздел не
@@ -384,6 +409,7 @@ rt_push_docs_only() { rt_push_docs_only_default "$@"; }
384
409
  rt_docs_pair_for() { rt_docs_pair_for_default "$@"; }
385
410
  rt_lint_for() { rt_lint_for_default "$@"; }
386
411
  rt_task_branch_ok() { rt_task_branch_ok_default "$@"; }
412
+ rt_task_branch_number() { rt_task_branch_number_default "$@"; }
387
413
  rt_reinvented_in() { rt_reinvented_in_default "$@"; }
388
414
  rt_is_app_code() { rt_is_app_code_default "$@"; }
389
415
  # Каталог источников пакета правил в этом дереве, от корня. Пусто — дерево пакета не везёт, и
@@ -41,10 +41,25 @@
41
41
  # не латиницей, записью больше не считается — в дереве таких нет ни одного, а появятся, признак
42
42
  # придётся расширить.
43
43
  rt_shell_writes_default() {
44
- printf '%s' "$1" \
45
- | sed -E 's#(&|[0-9]*)>>?[[:space:]]*/dev/(null|stderr)##g; s#[0-9]*>&[0-9-]##g; s#[-=]+>##g' \
44
+ cleaned="$(printf '%s' "$1" \
45
+ | sed -E 's#(&|[0-9]*)>>?[[:space:]]*/dev/(null|stderr)##g; s#[0-9]*>&[0-9-]##g; s#[-=]+>##g')"
46
+
47
+ # Интерпретатор пишет телом, а не именем файла, который запускает. Путь, стоящий у него
48
+ # первым доводом, — это то, что он читает: запуск проверки дерева по её пути записью не
49
+ # считается. Раньше проверялось само имя, и заход, запускавший проверку ради диагностики,
50
+ # получал требование правила общего кода, ничего в нём не правя; за две задачи таких отказов
51
+ # набралось около пятнадцати, и часть пришлась на команды, не писавшие ничего.
52
+ #
53
+ # Тело у интерпретатора двух видов, и оба остаются записью: документ на входе и код доводом.
54
+ interp='(^|[|;&(]|[[:space:]])(python3?|node|ruby|deno|bun|php|perl)'
55
+ if printf '%s' "$cleaned" | grep -Eq \
56
+ "${interp}([[:space:]][^|]*)?<<|${interp}([[:space:]]+-[^[:space:]]*)*[[:space:]]+(-e|--eval|-c|-p|--print)([[:space:]]|\$)"; then
57
+ return 0
58
+ fi
59
+
60
+ printf '%s' "$cleaned" \
46
61
  | grep -Eq \
47
- '>>?[[:space:]]*[A-Za-z0-9_./~$"'"'"'-]|\btee\b|\bsed\b[^|]*-i|\bperl\b[^|]*-i|\bpython3?\b|\bnode\b|\bruby\b|\bdd\b[^|]*of=|\bcp\b|\bmv\b|\brm\b|\btouch\b|\btruncate\b|\binstall\b|\bpatch\b|\bgit[[:space:]]+(checkout|restore|apply|stash)\b'
62
+ '>>?[[:space:]]*[A-Za-z0-9_./~$"'"'"'-]|\btee\b|\bsed\b[^|]*-i|\bperl\b[^|]*-i|\bdd\b[^|]*of=|\bcp\b|\bmv\b|\brm\b|\btouch\b|\btruncate\b|\binstall\b|\bpatch\b|\bgit[[:space:]]+(checkout|restore|apply|stash)\b'
48
63
  }
49
64
 
50
65
  # Пути, названные командой оболочки. Печатает по одному в строке; судит их зовущий.
@@ -1,17 +1,26 @@
1
1
  #!/usr/bin/env bash
2
2
  # rt-hook: PreToolUse mcp__claude-in-chrome__select_browser
3
+ # rt-hook: PostToolUse mcp__claude-in-chrome__select_browser
3
4
  # Требует: hooks/deny-tail.sh
4
- # Гард выбора браузера. PreToolUse на выборе браузера расширением.
5
+ # Гард выбора браузера. Работает на двух событиях: до вызова и после него.
5
6
  #
6
- # Отклоняет любой профиль, кроме закреплённого: чужой стоит лишнего круга и приводит в браузер,
7
- # где сессий этого проекта нет вовсе.
7
+ # До вызова запрещает любой профиль, кроме закреплённого. Чужой профиль ведёт в браузер, где нет
8
+ # сессий этого проекта.
8
9
  #
9
- # На совпадении ставит метку сессии. Гард свежести читает ВОЗРАСТ этой метки — она и делает
10
- # законной всю дальнейшую работу с браузером.
10
+ # После вызова ставит метку сессии, если ответ подтверждает подключение. Гард свежести читает
11
+ # возраст этой метки и по нему разрешает дальнейшую работу с браузером.
11
12
  #
12
- # ОТКАЗ В ПОЛЬЗУ РАБОТЫ: помощник не назвал профиль пропуск.
13
+ # Почему метка ставится после вызова. Раньше она ставилась до вызова, при совпадении признака,
14
+ # то есть на попытке выбора. Если закреплённый профиль отключён, вызов возвращает отказ, а метка
15
+ # уже есть — и гард свежести пропускает состав вкладок, переход и снимок экрана в тот браузер,
16
+ # который расширение считает активным. Ошибку заметил владелец, а не гард. Метка до вызова
17
+ # подтверждает только запрос профиля, а не подключение к нему.
18
+ #
19
+ # При ошибке гард пропускает: помощник не назвал профиль — вызов разрешён. Ответ без признаков
20
+ # подключения метку не ставит; работа не останавливается — следующий вызов запретит гард
21
+ # свежести и потребует выбрать профиль заново.
13
22
 
14
- # Своё имя в наблюдениях: отбой пишет общий хвост отказа, а не сам гард.
23
+ # Имя гарда для наблюдений: его пишет общий хвост отказа.
15
24
  RT_GUARD_NAME=browser-guard-device-id
16
25
 
17
26
  . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/utf8.sh" 2>/dev/null || true
@@ -20,23 +29,44 @@ RT_GUARD_NAME=browser-guard-device-id
20
29
  rt_hook_read
21
30
  input="$RT_HOOK_INPUT"
22
31
 
23
- # Признак сессии помощнику передаётся: без него слово о ненастроенном дереве метится днём и
24
- # приходит один раз в сутки, а не один раз за заход.
32
+ # Идентификатор сессии передаётся помощнику: без него сообщение о ненастроенном дереве
33
+ # помечается датой и приходит раз в сутки, а не раз за заход.
25
34
  sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"' 2>/dev/null)"
26
35
 
27
36
  device_id="$("${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/browser-device-id.sh" "$sid")"
28
37
  [ -z "$device_id" ] && exit 0
29
38
 
30
39
  requested="$(printf '%s' "$input" | jq -r '.tool_input.deviceId // empty' 2>/dev/null)"
40
+ event="$(printf '%s' "$input" | jq -r '.hook_event_name // empty' 2>/dev/null)"
41
+
42
+ if [ "$event" = "PostToolUse" ]; then
43
+ # Ответ вызова приводится к строке: он приходит объектом, строкой или списком блоков, и
44
+ # разбирать каждую форму отдельно дорого. Проверяется текст — в нём есть и признак профиля,
45
+ # и слово об отказе.
46
+ answer="$(printf '%s' "$input" | jq -r '.tool_response // empty | if type == "string" then . else tojson end' 2>/dev/null)"
47
+
48
+ # Отказ выбора: подключения не было, метка не ставится. Слова отказа перечислены на двух
49
+ # языках: помощник отвечает на своём, дерево выводит текст на русском.
50
+ case "$answer" in
51
+ *'"error"'* | *'"isError":true'* | *"failed"* | *"not found"* | *"not connected"* | *"не найден"* | *"отключ"*)
52
+ exit 0
53
+ ;;
54
+ esac
55
+
56
+ # Пустой ответ подключением не считается: по нему ничего не проверить, а метка утверждала
57
+ # бы, что профиль подключён.
58
+ [ -z "$answer" ] && exit 0
31
59
 
32
- if [ "$requested" = "$device_id" ]; then
33
60
  marker_dir="${TMPDIR:-/tmp}/claude-browser-guard"
34
61
  mkdir -p "$marker_dir" 2>/dev/null && : >"$marker_dir/${sid}" 2>/dev/null
35
62
  exit 0
36
63
  fi
37
64
 
38
- # Общий хвост отказа: два законных хода и законная форма обхода, если она у отказа есть. Файл
39
- # может быть не разложен тогда хвоста нет, а причина отказа остаётся прежней.
65
+ # До вызова проверяется только признак профиля; метка здесь не ставится.
66
+ [ "$requested" = "$device_id" ] && exit 0
67
+
68
+ # Общий хвост отказа: два допустимых шага и допустимая форма обхода, если она есть. Файл может
69
+ # быть не разложен — тогда хвоста нет, причина отказа остаётся.
40
70
  # shellcheck disable=SC1090
41
71
  [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
42
72
  && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
@@ -12,6 +12,10 @@
12
12
  # being about which Chrome profile to automate. Anchor on selection phrasing instead
13
13
  # (deviceId, "which/what browser", "какой браузер").
14
14
  #
15
+ # The Russian pronoun is spelled out case by case on purpose: an optional ending would leave a
16
+ # bare "как" in the pattern, and "какие браузеры поддерживаем" — a support question, not a
17
+ # selection one — got denied by exactly that.
18
+ #
15
19
  # FAIL-OPEN when browser-device-id.sh prints nothing.
16
20
 
17
21
  # Своё имя в наблюдениях: отбой пишет общий хвост отказа, а не сам гард.
@@ -29,7 +33,7 @@ device_id="$("${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/browser-device-id.sh" 2>/de
29
33
  questions="$(printf '%s' "$input" | jq -r '[.tool_input.questions[]? | .question, .header, (.options[]?.label)] | join(" ")' 2>/dev/null)"
30
34
  [ -z "$questions" ] && exit 0
31
35
 
32
- printf '%s' "$questions" | grep -qiE 'deviceid|device id|(which|what|pick|choose|select)[^.]{0,25}\bbrowser\b|\bbrowser\b[^.]{0,25}(profile|to use)|как(ой|ую)?[^.]{0,15}браузер|брауз[а-я]*[^.]{0,20}(использовать|выбрать|выбор|нужен|запустить)' || exit 0
36
+ printf '%s' "$questions" | grep -qiE 'deviceid|device id|(which|what|pick|choose|select)[^.]{0,25}\bbrowser\b|\bbrowser\b[^.]{0,25}(profile|to use)|как(ой|ую|им|ого)[^.]{0,15}браузер|брауз[а-я]*[^.]{0,20}(использовать|выбрать|выбор|нужен|запустить)' || exit 0
33
37
 
34
38
  # Общий хвост отказа: два законных хода и законная форма обхода, если она у отказа есть. Файл
35
39
  # может быть не разложен — тогда хвоста нет, а причина отказа остаётся прежней.
@@ -33,10 +33,10 @@ input="$(cat 2>/dev/null)"
33
33
  # Разбор один на все ветки: четыре поля одним вызовом разборщика вместо шести на каждый гард.
34
34
  # Значения приходят уже закавыченными для оболочки — `@sh` в разборщике для того и сделан:
35
35
  # командная строка держит и кавычки, и переводы строк, и подставить её иначе нельзя.
36
- assignments="$(printf '%s' "$input" | jq -r '@sh "RT_HOOK_TOOL=\(.tool_name // "") RT_HOOK_CMD=\(.tool_input.command // "") RT_HOOK_FILE=\(.tool_input.file_path // "") RT_HOOK_CWD=\(.cwd // "")"' 2>/dev/null)"
36
+ assignments="$(printf '%s' "$input" | jq -r '@sh "RT_HOOK_TOOL=\(.tool_name // "") RT_HOOK_CMD=\(.tool_input.command // "") RT_HOOK_FILE=\(.tool_input.file_path // "") RT_HOOK_CWD=\(.cwd // "") RT_HOOK_SOURCE=\(.source // "")"' 2>/dev/null)"
37
37
  if [ -n "$assignments" ]; then
38
38
  eval "$assignments" 2>/dev/null || true
39
- export RT_HOOK_TOOL RT_HOOK_CMD RT_HOOK_FILE RT_HOOK_CWD
39
+ export RT_HOOK_TOOL RT_HOOK_CMD RT_HOOK_FILE RT_HOOK_CWD RT_HOOK_SOURCE
40
40
  # Признак разбора: по нему ветки отличают готовое поле от пустой переменной, случайно
41
41
  # оказавшейся в окружении прогона. Без него пустое значение читается как «поля нет».
42
42
  export RT_HOOK_PARSED=1
@@ -58,13 +58,21 @@ for branch in $branches; do
58
58
  branch_event="${declaration%% *}"
59
59
  [ "$branch_event" = "$event" ] || continue
60
60
 
61
- # Образец вызова: его нет вовсе — гард зовётся на любом; есть — сверяется с именем
62
- # инструмента целиком, а не куском. Звёздочка и точка со звёздочкой значат одно: любой
63
- # вызов.
61
+ # Образец вызова: его нет вовсе — гард зовётся на любом; есть — сверяется целиком, а
62
+ # не куском. Звёздочка и точка со звёздочкой значат одно: любой вызов.
63
+ #
64
+ # Предмет сверки зависит от события. У вызова инструмента это имя инструмента; у входа в
65
+ # сессию имени инструмента нет, и образец там называет род запуска — `startup`,
66
+ # `resume`, `compact`, `clear`. Сверка с пустым именем не совпадала ни разу, и через
67
+ # диспетчер не вызывался ни один хук входа: заход начинался без свода законов, без
68
+ # словаря, без состояния работы и без передачи прошлого захода — с нулевым кодом и
69
+ # пустым выводом.
64
70
  matcher="${declaration#"$branch_event"}"
65
71
  matcher="${matcher#"${matcher%%[![:space:]]*}"}"
72
+ subject="${RT_HOOK_TOOL:-}"
73
+ [ -z "$subject" ] && subject="${RT_HOOK_SOURCE:-}"
66
74
  if [ -n "$matcher" ] && [ "$matcher" != '*' ] && [ "$matcher" != '.*' ]; then
67
- [[ "${RT_HOOK_TOOL:-}" =~ ^(${matcher})$ ]] || continue
75
+ [[ "$subject" =~ ^(${matcher})$ ]] || continue
68
76
  fi
69
77
 
70
78
  matched=1
@@ -77,10 +85,33 @@ EOF
77
85
  # одном файле звали бы гард дважды на один ввод, и второй вызов судил бы то же самое.
78
86
  [ "$matched" = 1 ] || continue
79
87
 
80
- branch_out="$(printf '%s' "$input" | bash "$branch" 2>/dev/null)"
81
- code=$?
88
+ # Поток ошибок ветки собирается отдельно, а не отбрасывается: на удачном ходу он шум и
89
+ # наружу не идёт, а на отказе он и есть причина. Гард, печатающий свой отказ туда, приходил к
90
+ # исполнителю строкой о сломанном файле — при целом файле и понятном тексте, которого никто
91
+ # не видел. За один заход так пропало два отказа подряд.
92
+ branch_err="$(mktemp 2>/dev/null)"
93
+ if [ -n "$branch_err" ]; then
94
+ branch_out="$(printf '%s' "$input" | bash "$branch" 2>"$branch_err")"
95
+ code=$?
96
+ said_err="$(cat "$branch_err" 2>/dev/null)"
97
+ rm -f "$branch_err" 2>/dev/null
98
+ else
99
+ branch_out="$(printf '%s' "$input" | bash "$branch" 2>/dev/null)"
100
+ code=$?
101
+ said_err=""
102
+ fi
82
103
  if [ "$code" -ne 0 ]; then
83
- [ -n "$branch_out" ] && printf '%s\n' "$branch_out"
104
+ if [ -n "$branch_out" ]; then
105
+ printf '%s\n' "$branch_out"
106
+ elif [ -n "$said_err" ]; then
107
+ printf '%s\n' "$said_err"
108
+ else
109
+ # Ветка вышла ненулём и не сказала ничего ни одним потоком. Снаружи это неотличимо от
110
+ # отказа по делу, а починить нечего: какой файл сломан, не знает никто. Имя диспетчер
111
+ # поэтому называет сам, иначе о сломанной ветке не говорит ничто.
112
+ printf 'Гард %s вышел с кодом %s и ничего не напечатал: похоже, файл сломан.\n' \
113
+ "$(basename "$branch")" "$code"
114
+ fi
84
115
  exit "$code"
85
116
  fi
86
117
 
@@ -215,6 +215,16 @@ done
215
215
  if rt_needs rt_docs_pair_for docs-guard; then
216
216
  while IFS= read -r file; do
217
217
  [ -z "$file" ] && continue
218
+
219
+ # Файл, положенный раскладкой, пары не требует: автор у него в дереве-потребителе один —
220
+ # пакет, и документ о нём лежит там же. Иначе первая же раскладка требует обход на весь
221
+ # свой объём, а обход, объявленный на сотню файлов, снимает требование и с будущих правок
222
+ # этих файлов вручную. Признак — шапка раскладки: она стоит в каждом разложенном файле и
223
+ # отличает его надёжнее любого перечня путей.
224
+ if [ -f "$file" ] && head -12 "$file" 2>/dev/null | grep -qE 'rt-kit v[^ ]+ · [^ ]+ · [0-9a-f]+'; then
225
+ continue
226
+ fi
227
+
218
228
  want="$(rt_docs_pair_for "$file" 2>/dev/null)"
219
229
  [ -z "$want" ] && continue
220
230
  # Пара считается приехавшей, если хоть один файл коммита подходит под образец.
@@ -82,7 +82,10 @@ case "$tool" in
82
82
  ;;
83
83
  Bash | mcp__webstorm__execute_terminal_command)
84
84
  cmd="$(rt_hook_cmd)"
85
- if printf '%s' "$cmd" | grep -qE 'pr[[:space:]]+ready|mr[[:space:]]+update[^|;&]*--ready'; then
85
+ # Снятием черновика считается вызов клиента, а не вхождение слов: команда, которая только
86
+ # пишет о снятии — строка в файле предложений, тело коммита, разбор происшествия, —
87
+ # проверялась наравне с самим снятием, и отказ приходил на попытку описать этот дефект.
88
+ if printf '%s' "$cmd" | grep -qE "${RT_CMD_BOUND}(gh[[:space:]]+pr[[:space:]]+ready|glab[[:space:]]+mr[[:space:]]+update[^|;&]*--ready)([[:space:]]|\$)"; then
86
89
  ready=1
87
90
  else
88
91
  # Запись файла вызовом оболочки судится наравне с правкой: закрытый честный путь при
@@ -107,7 +110,27 @@ transcript="$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/nu
107
110
  && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
108
111
  command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
109
112
 
113
+ # Второй выход у отказа — не требующий снимать защиту.
114
+ #
115
+ # Единственным выходом гард называл список выключенных ролей в настройке дерева. Среда, где
116
+ # работает исполнитель, правку такого списка запрещает своим механизмом, о котором гард не знает:
117
+ # одно правило говорит «выйди отсюда», второе — «этим путём нельзя», и работа стоит при зелёном
118
+ # наборе и сказанном слове владельца.
119
+ #
120
+ # Обход объявляется строкой `Exam-skip: <причина>` в теле последнего коммита ветки: она остаётся
121
+ # в истории и видна владельцу на странице заявки. Причина обязательна — подстановка вместо неё
122
+ # обходом не считается, как и у гарда документов.
123
+ rt_exam_declared_skip() {
124
+ git -C "${CLAUDE_PROJECT_DIR:-.}" log -1 --format=%B 2>/dev/null \
125
+ | grep -qE '^Exam-skip:[[:space:]]*[^[:space:]<]'
126
+ }
127
+
110
128
  deny() {
129
+ if rt_exam_declared_skip; then
130
+ printf 'гард экзамена: обход объявлен в теле последнего коммита строкой Exam-skip. Вызов пропущен, запись осталась в истории.\n' >&2
131
+ exit 0
132
+ fi
133
+
111
134
  reason="$1"
112
135
  tail_text="$(rt_deny_tail "$2")"
113
136
  [ -n "$tail_text" ] && reason="$1 ${tail_text}"
@@ -136,7 +159,7 @@ verdict="$(jq -s -r '
136
159
  elif .type == "user" then
137
160
  ([ ((.message.content // []) | if type == "array" then .[] else empty end
138
161
  | select(.type == "tool_result")
139
- | select(((.tool_use_id // "") as $i | $muted | index($i)) == null)
162
+ | select((.tool_use_id // "") | if . == "" then true else ($muted | index(.)) == null end)
140
163
  | .content | textof),
141
164
  ((.message.content // "") | if type == "string" then . else "" end),
142
165
  # Поле результата вызова: та же запись, другая форма. Отбрасывается, только
@@ -163,19 +186,46 @@ if [ "$ready" = "1" ]; then
163
186
  # Записи сводятся в один поток в порядке их появления: команда и ответ инструмента лежат в
164
187
  # разных полях, и индекс из одного массива в другом не значит ничего.
165
188
  after="$(jq -s -r '
166
- [ .[]
167
- | if .type == "assistant"
168
- then ([(.message.content // [])[] | select(.type == "tool_use") | (.input.command // "")] | join("\n"))
169
- elif .type == "user"
170
- then ([(.message.content // []) | select(type == "array") | .[]
171
- | select(.type == "tool_result") | .content
172
- | if type == "string" then . elif type == "array"
173
- then (map(if type == "object" then (.text // "") else tostring end) | join("\n"))
174
- else tostring end] | join("\n"))
175
- else "" end ] as $flow
176
- | ($flow | map(test("pr[[:space:]]+create|mr[[:space:]]+create")) | index(true)) as $opened
189
+ def textof:
190
+ if type == "string" then .
191
+ elif type == "array" then (map(if type == "object" then (.text // "") else tostring end) | join("\n"))
192
+ else tostring end;
193
+
194
+ # Тот же набор форм, что у широкой выборки: вердикт приходит в той форме, какую выбрал
195
+ # хост, и роль, работающая фоном, отдаёт его уведомлением о завершении — записи вида
196
+ # «ответ инструмента» у неё нет. Раньше эта выборка читала только команды помощника и
197
+ # ответы инструментов, и второй экзамен в таком дереве не засчитывался: пять кругов с
198
+ # полным вердиктом не пропустили ни одной правки.
199
+ ["Bash", "Read", "Grep", "Glob", "Edit", "Write", "MultiEdit", "NotebookEdit"] as $mute
200
+ | [.[] | select(.type == "assistant") | (.message.content // [])[]
201
+ | select(.type == "tool_use") | select(.name as $n | $mute | index($n) != null) | (.id // "")] as $muted
202
+
203
+ # Запись даёт две строки: команду — по ней ищется момент открытия заявки — и вердикт,
204
+ # который проверяется теми же правилами, что и в широкой выборке. Порядок один и тот же,
205
+ # поэтому отсчёт от найденной команды остаётся верным.
206
+ | [ .[] | {
207
+ cmd: (if .type == "assistant"
208
+ then ([(.message.content // [])[] | select(.type == "tool_use") | (.input.command // "")] | join("\n"))
209
+ else "" end),
210
+ say: (if .type == "assistant" then ""
211
+ elif .type == "user" then
212
+ ([ ((.message.content // []) | if type == "array" then .[] else empty end
213
+ | select(.type == "tool_result")
214
+ | select((.tool_use_id // "") | if . == "" then true else ($muted | index(.)) == null end)
215
+ | .content | textof),
216
+ ((.message.content // "") | if type == "string" then . else "" end),
217
+ (. as $rec
218
+ | if ($rec.toolUseResult // null) == null then ""
219
+ elif ([($rec.message.content // []) | if type == "array" then .[] else empty end
220
+ | select(.type == "tool_result") | (.tool_use_id // "")]
221
+ | map($muted | index(.)) | any(. != null)) then ""
222
+ else ($rec.toolUseResult | textof) end)
223
+ ] | join("\n"))
224
+ else tostring end)
225
+ } ] as $flow
226
+ | ($flow | map(.cmd | test("pr[[:space:]]+create|mr[[:space:]]+create")) | index(true)) as $opened
177
227
  | if $opened == null then "нет-pr"
178
- else ($flow[($opened + 1):] | join("\n")
228
+ else ($flow[($opened + 1):] | map(.say) | join("\n")
179
229
  | [scan("ЭКЗАМЕН:[[:space:]]*сдано[[:space:]]*([0-9]+)[[:space:]]*из[[:space:]]*([0-9]+)")]
180
230
  | if length == 0 then "нет"
181
231
  elif (.[-1] | .[0] == .[1]) then "сдан"
@@ -185,7 +235,7 @@ if [ "$ready" = "1" ]; then
185
235
  case "$after" in
186
236
  сдан | нет-pr) exit 0 ;;
187
237
  *)
188
- deny "BLOCKED by exam-guard: черновик снимается после второго экзамена, а его за эту сессию не было. Позови роль strict-teacher с правилами поставки и с тем, чего требовала задача: между чтением этих правил и снятием черновика прошёл весь заход."
238
+ deny "BLOCKED by exam-guard: черновик снимается после второго экзамена, а его за эту сессию не было. Позови роль strict-teacher с правилами поставки и с тем, чего требовала задача: между чтением этих правил и снятием черновика прошёл весь заход. Выход через список выключенных ролей требует снять защиту, и среда исполнения такую правку может запрещать; второй выход её не требует — объяви обход строкой «Exam-skip: причина» в теле последнего коммита ветки: она остаётся в истории и видна владельцу на странице заявки."
189
239
  ;;
190
240
  esac
191
241
  fi
@@ -193,7 +243,7 @@ fi
193
243
  case "$verdict" in
194
244
  сдан) exit 0 ;;
195
245
  провален)
196
- deny "BLOCKED by exam-guard: экзамен по загруженным правилам провален. Перечитай правило целиком — не тот кусок, о котором спрашивали, — и позови роль strict-teacher снова. Показанный ответ даёт знание одной строки, а не правила."
246
+ deny "BLOCKED by exam-guard: экзамен по загруженным правилам провален. Перечитай правило целиком — не тот кусок, о котором спрашивали, — и позови роль strict-teacher снова. Показанный ответ даёт знание одной строки, а не правила. Выход через список выключенных ролей требует снять защиту, и среда исполнения такую правку может запрещать; второй выход её не требует — обход объявляется строкой «Exam-skip: причина» в теле последнего коммита ветки."
197
247
  ;;
198
248
  *)
199
249
  deny "BLOCKED by exam-guard: за эту сессию экзамена по загруженным правилам не было. Позови роль strict-teacher, передай ей список загруженных правил, ответь на её вопросы по памяти и верни ей ответы — вердикт она отдаёт строкой «ЭКЗАМЕН: сдано N из 5». Засчитывается он из ответа роли в любой форме, какой его доставил хост, но не из вывода оболочки и не из твоего же текста: печать этой строки эхом гард не отпускает. Роль уже звали и вердикт получен — значит, он пришёл формой, которой гард не видит: это дефект гарда, и правка `.claude/rt-kit.json` из-под него выведена. Загруженное правило и прочитанное правило — разные вещи, и цену этой разницы платит владелец."
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env bash
2
+ # Снятие черновика для гарда поставки: есть ли у заявки разбор, не конфликтует ли она и той ли
3
+ # записью открыта.
4
+ #
5
+ # Строки `# rt-hook:` здесь нет намеренно: событие и образец вызова объявляет сам гард, а
6
+ # помощник рядом хуком не регистрируется и в одиночку ничего не решает. Он зовётся из гарда и
7
+ # пользуется его же переменными — командой, корнем дерева, машинной записью — и его отказом.
8
+ #
9
+ # Вынесен он отсюда потому, что гард дорос до предела длины: снятие черновика среди его
10
+ # предметов самое отдельное — своя точка проверки, свой сетевой ярус, свои три отказа.
11
+
12
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/utf8.sh" 2>/dev/null || true
13
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/hook-input.sh" 2>/dev/null || true
14
+
15
+ rt_delivery_draft_ready() {
16
+ # --- снятие черновика --------------------------------------------------------------------
17
+ #
18
+ # Ревьювера до этой правки не спрашивал никто. Он стоял прозой в правиле, а хостинг запрос
19
+ # разбора на самого себя принимает молча и не создаёт: разбор при этом выглядит запрошенным.
20
+ # Раньше снятия черновика спросить негде — до открытия заявки ревьювера нет вовсе, — а само
21
+ # снятие и есть тот ход, которым работа объявляется готовой.
22
+ #
23
+ # Ярус сетевой, и молчит он так же, как ярус состояния задачи: нет ответа — нет требования.
24
+ #
25
+ # Возврат заявки в черновик под требование не подпадает: он делает ровно то, чего гард и
26
+ # добивается, — снимает с работы вид готовой.
27
+ if printf '%s' "$cmd" | grep -qE "${RT_CMD_BOUND}(gh[[:space:]]+pr[[:space:]]+ready|glab[[:space:]]+mr[[:space:]]+update[^|;&]*--ready)([[:space:]]|\$)" \
28
+ && ! printf '%s' "$cmd" | grep -q -- '--undo'; then
29
+ # Ссылка на заявку необязательна: без неё клиент берёт заявку текущей ветки, и это самая
30
+ # короткая форма вызова. Требовать номер значило бы снимать всё требование одним пробелом.
31
+ # Поэтому берётся первый довод, каким бы он ни был — номер, адрес или имя ветки, — а его
32
+ # отсутствие означает «спроси про текущую ветку».
33
+ pull_ref="$(printf '%s' "$cmd" | sed -nE 's/.*(gh[[:space:]]+pr[[:space:]]+ready|glab[[:space:]]+mr[[:space:]]+update)[[:space:]]+([^[:space:];&|-][^[:space:];&|]*).*/\2/p' | head -1)"
34
+ if rt_needs rt_pull_state git-guard-delivery; then
35
+ pull="$(cd "$root" && rt_pull_state "$pull_ref" 2>/dev/null)" || pull=''
36
+ if [ -n "$pull" ] && printf '%s' "$pull" | jq -e '.exists' >/dev/null 2>&1; then
37
+ # Номер берётся из ответа, а если его там нет — из самой команды: заявка,
38
+ # названная адресом или именем ветки, в отказе должна остаться узнаваемой.
39
+ pull_name="$(printf '%s' "$pull" | jq -r '.number // empty' 2>/dev/null)"
40
+ # Номер пишется с решёткой, а имя ветки или адрес — в кавычках: решётка перед
41
+ # адресом читается как опечатка, а не как ссылка на заявку.
42
+ [ -z "$pull_name" ] && pull_name="$pull_ref"
43
+ case "$pull_name" in
44
+ '') ;;
45
+ *[!0-9]*) pull_name=" «${pull_name}»" ;;
46
+ *) pull_name=" #${pull_name}" ;;
47
+ esac
48
+ printf '%s' "$pull" | jq -e '.reviewed' >/dev/null 2>&1 \
49
+ || fault "у заявки${pull_name} нет разбора: ревьювер не запрошен и отзыва никто не оставлял. Снятый черновик читается как «можно вливать», а вливать некому — назначь ревьювера и повтори."
50
+
51
+ # Конфликт приезжает в отданную заявку чужим слиянием, без единого действия её
52
+ # автора: основание, проверенное на открытии, к моменту снятия черновика уже
53
+ # вчерашнее. Снятый черновик читается как «можно вливать», а вливать нечего —
54
+ # владелец открывает заявку и находит там конфликт. Молчание при неизвестной
55
+ # сливаемости остаётся: хостинг считает её заново после каждой правки главной ветки,
56
+ # и «ещё не посчитано» — не «конфликтует».
57
+ printf '%s' "$pull" | jq -e '.conflicting' >/dev/null 2>&1 \
58
+ && fault "заявка${pull_name} конфликтует с главной веткой. Влей её в свою ветку, разбери конфликт и повтори: снятый черновик читается как «можно вливать», а слить эту заявку нельзя."
59
+
60
+ # Автор заявки. На открытии судить было нечем, кроме текста команды: личность вызова
61
+ # приходит окружением. Здесь она уже названа хостингом, и это последний ход, где
62
+ # промах ещё исправим — после снятия черновика заявку вливают, а переоткрыть влитую
63
+ # нельзя. Дерево, не назвавшее машинной записи, автора не судит.
64
+ if [ -n "$task_bot" ]; then
65
+ pull_author="$(printf '%s' "$pull" | jq -r '.author // empty' 2>/dev/null)"
66
+ [ -n "$pull_author" ] && [ "$pull_author" != "$task_bot" ] \
67
+ && fault "заявку${pull_name} открыла запись «${pull_author}», а не машинная «${task_bot}». Автор заявки её ревьювером не бывает, и разбор ей назначить нечем. Автора не сменить — закрой заявку и открой заново${pull_token_hint:+, подставив токен: ${pull_token_hint} …}."
68
+ fi
69
+ fi
70
+ fi
71
+
72
+ # Папка задачи: тот же предмет, что на открытии и на слиянии, третьим рубежом. Условие
73
+ # местное — оно читает ветку, а не хостинг, — и потому стоит вне сетевого яруса выше.
74
+ command -v rt_delivery_ready_folder >/dev/null 2>&1 && rt_delivery_ready_folder
75
+
76
+ deny_faults
77
+ fi
78
+ }