@rt-tools/agent-kit 0.8.1 → 0.8.2

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 +24 -19
  2. package/assets/agents/qa-engineer.md +1 -1
  3. package/assets/checks/board.github.mjs +56 -17
  4. package/assets/checks/check-board.github.mjs +49 -5
  5. package/assets/checks/task-new.github.mjs +33 -5
  6. package/assets/commands/agent-kit-digest.md +10 -5
  7. package/assets/commands/next-session.md +4 -4
  8. package/assets/commands/skill-curator.md +11 -9
  9. package/assets/defaults/gate-map.sh +11 -4
  10. package/assets/defaults/project.sh +46 -0
  11. package/assets/docs/GLOSSARY.md +28 -26
  12. package/assets/hooks/docs-guard.sh +19 -3
  13. package/assets/hooks/git-guard-delivery.sh +106 -13
  14. package/assets/hooks/proposal-guard.sh +93 -0
  15. package/assets/hooks/reuse-first-guard.sh +68 -14
  16. package/assets/hooks/skill-gate-layers.sh +1 -1
  17. package/assets/hooks/skill-gate.sh +26 -0
  18. package/assets/hooks/task-flow-guard.sh +44 -18
  19. package/assets/hooks/window-fill-guard.sh +1 -1
  20. package/assets/laws/delivery.md +13 -7
  21. package/assets/laws/project-documentation.md +21 -0
  22. package/assets/laws/verifiability.md +6 -1
  23. package/assets/laws/work-conduct.md +67 -3
  24. package/assets/patterns/git-workflow-commit.azure.md +10 -2
  25. package/assets/patterns/git-workflow-commit.github.md +15 -2
  26. package/assets/patterns/git-workflow-commit.gitlab.md +10 -2
  27. package/assets/patterns/git-workflow-merge.md +1 -1
  28. package/assets/patterns/spec-driven-domain.md +7 -1
  29. package/assets/patterns/spec-driven-rule.md +6 -0
  30. package/assets/patterns/task-flow-close.md +78 -9
  31. package/assets/patterns/task-flow-handoff.md +27 -4
  32. package/assets/patterns/task-flow-resume.md +23 -5
  33. package/assets/patterns/task-flow-start.md +5 -1
  34. package/assets/rules/browser-verification.md +10 -1
  35. package/assets/rules/doc-style.md +13 -5
  36. package/assets/rules/git-workflow.azure.md +12 -7
  37. package/assets/rules/git-workflow.github.md +31 -13
  38. package/assets/rules/git-workflow.gitlab.md +12 -7
  39. package/assets/rules/spec-driven.md +4 -0
  40. package/assets/rules/task-flow.md +47 -22
  41. package/assets/rules/testing.md +19 -0
  42. package/assets/rules/typescript-conventions.md +12 -0
  43. package/assets/skills/agent-kit-extend.md +24 -0
  44. package/assets/skills/agent-kit.md +54 -6
  45. package/assets/traits.json +14 -0
  46. package/bin/agent-kit.d.ts.map +1 -1
  47. package/bin/agent-kit.js +31 -16
  48. package/bin/agent-kit.js.map +1 -1
  49. package/index.d.ts +1 -0
  50. package/index.d.ts.map +1 -1
  51. package/index.js +1 -0
  52. package/index.js.map +1 -1
  53. package/lib/argv.d.ts +17 -0
  54. package/lib/argv.d.ts.map +1 -0
  55. package/lib/argv.js +44 -0
  56. package/lib/argv.js.map +1 -0
  57. package/lib/cargo.d.ts +88 -0
  58. package/lib/cargo.d.ts.map +1 -0
  59. package/lib/cargo.js +16 -0
  60. package/lib/cargo.js.map +1 -0
  61. package/lib/catalog.d.ts +18 -1
  62. package/lib/catalog.d.ts.map +1 -1
  63. package/lib/catalog.js +12 -2
  64. package/lib/catalog.js.map +1 -1
  65. package/lib/commands.d.ts +0 -26
  66. package/lib/commands.d.ts.map +1 -1
  67. package/lib/commands.js +78 -122
  68. package/lib/commands.js.map +1 -1
  69. package/lib/companion.d.ts +37 -0
  70. package/lib/companion.d.ts.map +1 -1
  71. package/lib/companion.js +42 -1
  72. package/lib/companion.js.map +1 -1
  73. package/lib/config.d.ts +28 -0
  74. package/lib/config.d.ts.map +1 -1
  75. package/lib/config.js +20 -0
  76. package/lib/config.js.map +1 -1
  77. package/lib/ship.d.ts +39 -0
  78. package/lib/ship.d.ts.map +1 -0
  79. package/lib/ship.js +87 -0
  80. package/lib/ship.js.map +1 -0
  81. package/lib/shipment.d.ts +60 -0
  82. package/lib/shipment.d.ts.map +1 -0
  83. package/lib/shipment.js +247 -0
  84. package/lib/shipment.js.map +1 -0
  85. package/lib/snapshot.d.ts +30 -0
  86. package/lib/snapshot.d.ts.map +1 -0
  87. package/lib/snapshot.js +73 -0
  88. package/lib/snapshot.js.map +1 -0
  89. package/lib/traits.d.ts +32 -0
  90. package/lib/traits.d.ts.map +1 -0
  91. package/lib/traits.js +82 -0
  92. package/lib/traits.js.map +1 -0
  93. package/package.json +6 -2
  94. package/rt-tools-agent-kit-0.8.2.tgz +0 -0
  95. package/lib/submit.d.ts +0 -24
  96. package/lib/submit.d.ts.map +0 -1
  97. package/lib/submit.js +0 -26
  98. package/lib/submit.js.map +0 -1
  99. package/rt-tools-agent-kit-0.8.1.tgz +0 -0
  100. /package/assets/rules/{entity-conventions.md → entity-conventions.needs-admin.md} +0 -0
  101. /package/assets/rules/{observability.md → observability.needs-app.md} +0 -0
@@ -1,7 +1,7 @@
1
1
  # Словарь проекта
2
2
 
3
3
  Слова, которые в этом дереве значат что-то определённое. Читается перед тем, как написать спек,
4
- правило, комментарий, тело коммита или описание отчёта: слово отсюда употребляется в том
4
+ правило, комментарий, тело коммита или описание PR: слово отсюда употребляется в том
5
5
  значении, что здесь, а слово не отсюда либо заводится здесь же, либо заменяется простым.
6
6
 
7
7
  Термины одного домена живут в разделе «Терминология» его спека — здесь только те, что проходят
@@ -36,21 +36,21 @@
36
36
 
37
37
  ## Работа
38
38
 
39
- | Термин | Что это |
40
- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
41
- | Задача | Единица работы в очереди работ. Заводится до ветки, и номер её стоит в имени ветки и в заголовке отчёта |
42
- | Отчёт | Заявка на слияние: то же название, что у задачи, переведённое в сделанное |
43
- | Очередь работ | Доска, на которой видно состояние каждой задачи. Ветки она не видит |
44
- | Папка задачи | Одна работа от разбора до слияния: разбор просьбы, замысел, ход работы. Умирает со слиянием — разбирается, и объясняющее решение уезжает в архив |
45
- | Разбор | Расспрос владельца до первой правки. Записывается его словами и задним числом не переписывается |
46
- | Замысел | Файл папки задачи: след задачи и этапы с признаками готовности. После написания не правится — с ним сверяют результат при приёмке |
47
- | Ход работы | Файл папки задачи: «Где стоим», решения по ходу с причинами, записи заходов. Единственное место, где отмечается сделанное. Журналом не называется |
48
- | След задачи | Раздел замысла: какие спеки, законы, правила и части кода работа задевает |
49
- | Заход | Одна сессия работы над задачей. Работа живёт дольше одного захода, и между ними её состояние держит только ход работы |
50
- | Заполнение окна | Доля места захода, которую он уже занял: вход, запись в кэш, прочитанное из кэша и вывод последнего ответа, делённые на размер окна. Не «расход» и не «бюджет»: речь о месте, а не о деньгах |
51
- | Передача | Текст, которым заход закрывается: рабочее дерево, ветка, задача, где лежит ход работы, что сделано, следующий шаг, особенности захода. Кладётся вне дерева и не коммитится |
52
- | Линия работ | Файл с порядком задач и зависимостями между ними, когда из одного разбора вышло несколько задач. Шире одной ветки |
53
- | Архив | Записи о состоявшемся: что объясняет закрытое решение. После выкатки не правится |
39
+ | Термин | Что это |
40
+ | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
41
+ | Задача | Единица работы в очереди работ. Заводится до ветки, и номер её стоит в имени ветки и в заголовке PR |
42
+ | PR | Заявка на слияние: то же название, что у задачи, переведённое в сделанное. Отчётом, пул-реквестом и мёрдж-реквестом не называется — ни в файлах, ни в разговоре |
43
+ | Очередь работ | Доска, на которой видно состояние каждой задачи. Ветки она не видит |
44
+ | Папка задачи | Одна работа от разбора до слияния: разбор просьбы, замысел, ход работы. Умирает со слиянием — разбирается, и объясняющее решение уезжает в архив |
45
+ | Разбор | Расспрос владельца до первой правки. Записывается его словами и задним числом не переписывается |
46
+ | Замысел | Файл папки задачи: след задачи и этапы с признаками готовности. После написания не правится — с ним сверяют результат при приёмке |
47
+ | Ход работы | Файл папки задачи: «Где стоим», решения по ходу с причинами, записи заходов. Единственное место, где отмечается сделанное. Журналом не называется |
48
+ | След задачи | Раздел замысла: какие спеки, законы, правила и части кода работа задевает |
49
+ | Заход | Одна сессия работы над задачей. Работа живёт дольше одного захода, и между ними её состояние держит только ход работы |
50
+ | Заполнение окна | Доля места захода, которую он уже занял: вход, запись в кэш, прочитанное из кэша и вывод последнего ответа, делённые на размер окна. Не «расход» и не «бюджет»: речь о месте, а не о деньгах |
51
+ | Передача | Текст, которым заход закрывается: рабочее дерево, ветка, задача, где лежит ход работы, что сделано, следующий шаг, особенности захода. Кладётся вне дерева и не коммитится |
52
+ | Эпик | Серия задач одной темы, выполняемых в назначенном порядке. Живёт в двух местах сразу: карточка в очереди работ с меткой эпика и замысел рядом с ней что за возможность разрабатывается, какие задачи входят и в каком порядке. Шире одной ветки. Линией работ не называется |
53
+ | Архив | Записи о состоявшемся: что объясняет закрытое решение. После выкатки не правится |
54
54
 
55
55
  ## Проверки
56
56
 
@@ -65,13 +65,15 @@
65
65
 
66
66
  ## Так не пишем
67
67
 
68
- | Так не пишем | Пишем так |
69
- | -------------------------- | ---------------------------------------------------------------------------------------------- |
70
- | спека (о тесте) | тест — файл рядом с исходником; спек — документ. Одна буква разницы, а значения противоположны |
71
- | таска, тикет | задача |
72
- | пул-реквест, мёрдж-реквест | отчёт, а действие — слияние |
73
- | джоба, пайплайн | конвейер и его шаг |
74
- | хендофф | передача |
75
- | бэклог | очередь работ |
76
- | контекст-виндоу | окно захода, а его доля — заполнение окна |
77
- | скилл, скилы | правило, паттерн или скил без закона — по тому, что это на самом деле |
68
+ | Так не пишем | Пишем так |
69
+ | --------------------------- | ---------------------------------------------------------------------------------------------- |
70
+ | спека (о тесте) | тест — файл рядом с исходником; спек — документ. Одна буква разницы, а значения противоположны |
71
+ | таска, тикет | задача |
72
+ | пул-реквест, мёрдж-реквест | PR, а действие — слияние |
73
+ | отчёт (о заявке на слияние) | PR; отчёт — сводка данных, и слово занято ею |
74
+ | джоба, пайплайн | конвейер и его шаг |
75
+ | хендофф | передача |
76
+ | бэклог | очередь работ |
77
+ | линия работ | эпик |
78
+ | контекст-виндоу | окно захода, а его доля — заполнение окна |
79
+ | скилл, скилы | правило, паттерн или скил без закона — по тому, что это на самом деле |
@@ -17,8 +17,8 @@
17
17
  #
18
18
  # Отдельно — законы. Совпал ли код с законом, машина не знает: правка файла, на который закон
19
19
  # ссылается якорем, поэтому не отклоняется, а выносится вопросом владельцу. Тем же вопросом
20
- # встречается и правка самого закона: закон описывает договорённость о продукте, и менять её
21
- # молча гард не даёт.
20
+ # встречается и правка самого закона как на месте, так и надстройкой над ним: закон описывает
21
+ # договорённость о продукте, и менять её молча гард не даёт.
22
22
  #
23
23
  # Обход — строка `Docs-skip: <причина>` в теле коммита. Причина остаётся в истории и видна при
24
24
  # разборе ветки; пустая не принимается.
@@ -66,11 +66,18 @@ command -v rt_needs >/dev/null 2>&1 || rt_needs() { command -v "$1" >/dev/null 2
66
66
 
67
67
  laws_dir="${RT_LAWS_DIR:-docs/constitution}"
68
68
  lib_marker="${RT_LIB_MARKER:-project.json}"
69
+ overrides_dir="${RT_OVERRIDES_DIR:-.claude/rt-kit/overrides}"
69
70
 
70
71
  # ── Правка закона спрашивает владельца ────────────────────────────────────────
71
72
  #
72
73
  # Спутник рядом с законом — привязка статей к коду, она устаревает при каждом переименовании и
73
74
  # правится свободно. Спрашивается только сам текст закона.
75
+ #
76
+ # Путей к тексту закона два, и ходят чаще вторым. Разложенный закон на месте не правится вовсе:
77
+ # правка теряется на следующей раскладке, а сама раскладка на неё отказывает, — поэтому правят
78
+ # надстройку, и файл закона переписывает раскладка. Гард, знающий один каталог законов, сторожит
79
+ # ровно тот путь, которым к закону и не ходят: чем сложнее надстройка дерева, тем реже закон
80
+ # правят на месте. Спрашивается и надстройка — по содержанию это правка закона, а не обвязки.
74
81
  case "$tool" in
75
82
  Edit | Write | MultiEdit | NotebookEdit | mcp__webstorm__create_new_file)
76
83
  target="$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.filePath // .tool_input.path // .tool_input.pathInProject // empty' 2>/dev/null)"
@@ -79,6 +86,10 @@ case "$tool" in
79
86
  */"$laws_dir"/*.md | "$laws_dir"/*.md)
80
87
  decide ask "Правка закона: \`${target##*/}\`. Закон описывает договорённость о продукте, а не устройство кода, — назови владельцу, что и почему меняешь, и дождись ответа. Если правка уже согласована, подтверди вызов."
81
88
  ;;
89
+ */"$overrides_dir"/laws/*.implementation.md | "$overrides_dir"/laws/*.implementation.md) exit 0 ;;
90
+ */"$overrides_dir"/laws/*.md | "$overrides_dir"/laws/*.md)
91
+ decide ask "Правка закона надстройкой: \`${target##*/}\`. Разложенный файл переписывает раскладка, поэтому правка надстройки — это правка самого закона: назови владельцу, что и почему меняешь, и дождись ответа. Если правка уже согласована, подтверди вызов."
92
+ ;;
82
93
  esac
83
94
  exit 0
84
95
  ;;
@@ -112,7 +123,12 @@ esac
112
123
 
113
124
  # Причина обхода остаётся в истории, поэтому обход законен. Пустая строка обходом не считается:
114
125
  # «Docs-skip:» без причины — это тот же молчаливый пропуск, только с двоеточием.
115
- if printf '%s' "$cmd" | grep -qiE 'Docs-skip:[[:space:]]*[^[:space:]"'"'"']{3,}'; then
126
+ #
127
+ # Строка начинает строку — свою в теле коммита или комментарий в конце команды — и подстановки
128
+ # не принимает. То же условие, что у обхода при слиянии: иначе текст, который ОБЪЯСНЯЕТ обход,
129
+ # снимает требование сам собой. Тело коммита о правке гарда как раз называет эту строку, и без
130
+ # привязки к началу гард пропускал бы такой коммит молча.
131
+ if printf '%s' "$cmd" | grep -qiE '(^|#)[[:space:]]*Docs-skip:[[:space:]]*[^[:space:]<"'"'"'][^[:space:]"'"'"']{2,}'; then
116
132
  exit 0
117
133
  fi
118
134
 
@@ -1,19 +1,21 @@
1
1
  #!/usr/bin/env bash
2
2
  # rt-hook: PreToolUse Bash|mcp__webstorm__execute_terminal_command|mcp__webstorm__execute_tool
3
3
  # Требует: hooks/profile-check.sh
4
- # Гард поставки. PreToolUse на заведении ветки и открытии заявки на слияние.
4
+ # Гард поставки. PreToolUse на заведении ветки, пуше и открытии заявки на слияние.
5
5
  #
6
6
  # Закон о поставке требует трёх вещей, которых обычно не проверяет ничто: правка начинается с
7
- # задачи, видимой в очереди работ; задача, ветка и отчёт несут один номер; у задачи есть
7
+ # задачи, видимой в очереди работ; задача, ветка и PR несут один номер; у задачи есть
8
8
  # исполнитель. Держатся они памятью — и не удерживаются: задачи стоят вне очереди, исполнитель
9
9
  # не проставлен, а большинство влитых заявок приходит с веток, за которыми задачи не стояло
10
10
  # вовсе.
11
11
  #
12
- # Гард стоит в двух точках, и в каждой требует того, что в этот момент исправимо:
12
+ # Гард стоит в трёх точках, и в каждой требует того, что в этот момент исправимо:
13
13
  #
14
14
  # заведение ветки — имя с номером разбирается на месте; имя без номера пропускается:
15
15
  # локальная ветка под пробу законна, в главную она не поедет, потому что заявка с неё
16
16
  # не откроется;
17
+ # пуш — подпись машинного коммита ещё переписывается на месте; после пуша её чинит только
18
+ # силовая отправка;
17
19
  # открытие заявки — ветка обязана нести номер, заголовок обязан начинаться с того же номера,
18
20
  # а задача — быть открытой, стоять в очереди работ и иметь исполнителя.
19
21
  #
@@ -28,7 +30,9 @@
28
30
  # numbered); молчание значит «спросить некого»;
29
31
  # RT_TASK_NEW_CMD — чем заводится задача;
30
32
  # RT_BOARD_CHECK_CMD — чем сверяется очередь работ;
31
- # RT_TASK_BOT — учётная запись, которую ставят исполнителем.
33
+ # RT_TASK_BOT — учётная запись, которую ставят исполнителем;
34
+ # RT_COMMIT_EMAIL — почта, которой подписан машинный коммит; её же левой частью он и
35
+ # опознаётся.
32
36
  # Отказ называет и то, что не так, и чем это чинится: отказ без действия обходят, а не исполняют.
33
37
  #
34
38
  # ОТКАЗ В ПОЛЬЗУ РАБОТЫ: не репозиторий, нет разборщика, битый ввод, нет профиля — пропуск.
@@ -87,6 +91,7 @@ title_re="${RT_TASK_TITLE_RE:-^\[[A-Za-z]+-[0-9]+\][[:space:]]+[^[:space:]]}"
87
91
  task_new="${RT_TASK_NEW_CMD:-npm run task:new}"
88
92
  board_check="${RT_BOARD_CHECK_CMD:-npm run check:board}"
89
93
  task_bot="${RT_TASK_BOT:-}"
94
+ commit_email="${RT_COMMIT_EMAIL:-}"
90
95
  tasks_dir="${RT_TASKS_DIR:-}"
91
96
  archive_dir="${RT_ARCHIVE_DIR:-}"
92
97
  main_branch="${RT_MAIN_BRANCH:-main}"
@@ -94,7 +99,13 @@ main_branch="${RT_MAIN_BRANCH:-main}"
94
99
  # Обход требования: строка с причиной. Причина видна тому, кто вливает, поэтому обход законен.
95
100
  # Без причины это просто молчаливый пропуск, поэтому она обязательна. Порог в три знака — тот
96
101
  # же, что у гарда документа: если сделать по-разному, две формы одного обхода разойдутся.
97
- folder_skip_re='Task-folder-skip:[[:space:]]*[^[:space:]"'"'"']{3,}'
102
+ #
103
+ # Строка обхода начинает строку — свою в теле PR или комментарий в конце команды — и
104
+ # подстановки не принимает. Без этих двух условий текст, который ОБЪЯСНЯЕТ, что обход называется
105
+ # так-то, от самого обхода неотличим: тело PR со строкой-примером снимало требование само
106
+ # собой, и тот же пример гард печатает в своём отказе ниже. Нашлось это приёмкой такого же
107
+ # признака — прогон обязан был покраснеть на настоящем нарушении и остался зелёным.
108
+ folder_skip_re='(^|#)[[:space:]]*Task-folder-skip:[[:space:]]*[^[:space:]<"'"'"'][^[:space:]"'"'"']{2,}'
98
109
 
99
110
  deny() {
100
111
  # Отказ гарда — наблюдение: гард, отбивающий чаще прочих, говорит, какое место поставки
@@ -109,7 +120,7 @@ deny() {
109
120
  exit 0
110
121
  }
111
122
 
112
- # Подсказка вместо отказа: на открытии отчёта папка ещё нужна. Решения подсказка не несёт,
123
+ # Подсказка вместо отказа: на открытии PR папка ещё нужна. Решения подсказка не несёт,
113
124
  # команда идёт дальше своим ходом.
114
125
  hint() {
115
126
  jq -n --arg c "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$c}}' 2>/dev/null
@@ -165,6 +176,66 @@ if [ -n "$branch_arg" ]; then
165
176
  exit 0
166
177
  fi
167
178
 
179
+ # --- подпись машинного коммита ------------------------------------------------------------
180
+ #
181
+ # Служебный адрес хостинга состоит из числа, логина и домена, а сопоставляется по числу: логин
182
+ # рядом с ним не сверяет никто. Коммит с чужим числом хостинг припишет постороннему человеку, и
183
+ # изнутри это выглядит верным — имя учётной записи в истории то самое. Так одиннадцать коммитов
184
+ # и уехали в главную ветку за чужой подписью; нашёл это владелец, читая историю глазами.
185
+ #
186
+ # Точка — пуш: до него подпись переписывается на месте всей веткой сразу, после — только силовой
187
+ # отправкой. На коммите гард не стоит: почта задаётся переменными самой команды, и разбор её из
188
+ # текста ловил бы ту же строку, которая и так перед глазами у набравшего её.
189
+ #
190
+ # Судится коммит, НАЗВАВШИЙСЯ машинной записью: её логин стоит именем автора либо левой частью
191
+ # почты. Опознавать по самой почте нельзя — она в таком коммите как раз и неверна, а требовать
192
+ # машинную подпись от каждого коммита значило бы отбивать работу, сделанную человеком своими
193
+ # руками в том же дереве.
194
+ #
195
+ # Логин читается из объявленной почты, а не объявляется вторым свойством: два объявления одного
196
+ # логина разошлись бы молча. Исполнителем задачи он тоже не бывает — в дереве, где хостинг
197
+ # ограничил машинную запись, исполнителем ставят человека, а коммит остаётся машинным.
198
+ #
199
+ # Вызов пуша узнаётся по двум признакам сразу — команда `git` в начале строки или за
200
+ # разделителем и слово `push` отдельным словом. Одной подстрокой «git push» его не поймать:
201
+ # помощник учётных данных и заголовок запроса ставятся ключами `-c` между ними.
202
+ # Пробный пуш не отправляет ничего, и подпись у него не спрашивают.
203
+ if printf '%s' "$cmd" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*git([[:space:]]|$)' \
204
+ && printf '%s' "$cmd" | grep -qE '(^|[[:space:]])push([[:space:]]|$)' \
205
+ && ! printf '%s' "$cmd" | grep -q -- '--dry-run'; then
206
+ # Два условия молчания. Дерево, не назвавшее почты машинной записи, требования не получает:
207
+ # своей машинной записи у пакета нет, а выдуманная отбивала бы работу в чужом дереве.
208
+ # Вершины главной ветки нет — вклад считать не от чего.
209
+ #
210
+ # Судится вклад ветки, а не вся история: влитое в главную этой веткой уже не чинится, и
211
+ # отказ за него отбивал бы работу вместо промаха.
212
+ if [ -n "$commit_email" ] \
213
+ && git rev-parse --verify --quiet "refs/remotes/origin/${main_branch}" >/dev/null 2>&1; then
214
+ # Левая часть служебного адреса: `<число>+<логин>` либо просто логин.
215
+ bot_login="${commit_email%%@*}"
216
+ bot_login="${bot_login##*+}"
217
+
218
+ strangers=''
219
+ while IFS="$(printf '\t')" read -r short author email; do
220
+ [ -z "$short" ] && continue
221
+ login="${email%%@*}"
222
+ login="${login##*+}"
223
+ [ "$author" = "$bot_login" ] || [ "$login" = "$bot_login" ] || continue
224
+ [ "$email" = "$commit_email" ] && continue
225
+ strangers="${strangers}${strangers:+, }${short} <${email}>"
226
+ done <<EOF
227
+ $(git log --format='%h%x09%an%x09%ae' "origin/${main_branch}..HEAD" 2>/dev/null)
228
+ EOF
229
+
230
+ [ -n "$strangers" ] \
231
+ && deny "BLOCKED: машинный коммит подписан не той почтой, что объявлена деревом. Хостинг сопоставляет служебный адрес по числу в нём, и коммит с чужим числом он припишет постороннему человеку — изнутри промах не виден, потому что имя учётной записи рядом верное. Расходятся: ${strangers}. Объявлено: ${commit_email} — почта берётся оттуда, а не набирается по памяти. Перепиши подпись до пуша, после него это чинит только силовая отправка:
232
+ последний коммит — GIT_AUTHOR_NAME=\"${bot_login}\" GIT_AUTHOR_EMAIL=\"${commit_email}\" GIT_COMMITTER_NAME=\"${bot_login}\" GIT_COMMITTER_EMAIL=\"${commit_email}\" git commit --amend --no-edit --reset-author
233
+ весь вклад ветки — git filter-branch -f --env-filter 'GIT_AUTHOR_EMAIL=\"${commit_email}\"; GIT_COMMITTER_EMAIL=\"${commit_email}\"' origin/${main_branch}..HEAD"
234
+ fi
235
+ fi
236
+ # Своего выхода у этой точки нет: пуш бывает и составной командой, а второе её звено судят
237
+ # разделы ниже.
238
+
168
239
  # --- слияние заявки ----------------------------------------------------------------------
169
240
  #
170
241
  # Папку задачи разбирают тем же PR, что и работу. После слияния этого уже никто не сделает:
@@ -243,24 +314,46 @@ fi
243
314
 
244
315
  if [ -n "$title" ]; then
245
316
  printf '%s' "$title" | grep -qE "$title_re" \
246
- || deny "BLOCKED: заголовок заявки не начинается с номера задачи. В списке заявок тела не видно, а строка связи живёт именно там — без номера в заголовке отчёт с задачей не сопоставить."
317
+ || deny "BLOCKED: заголовок заявки не начинается с номера задачи. В списке заявок тела не видно, а строка связи живёт именно там — без номера в заголовке PR с задачей не сопоставить."
247
318
  title_number="$(printf '%s' "$title" | sed -nE 's/^\[[A-Za-z]+-([0-9]+)\].*/\1/p')"
248
319
  if [ -n "$number" ] && [ -n "$title_number" ]; then
249
320
  [ "$title_number" = "$number" ] \
250
- || deny "BLOCKED: в заголовке заявки номер ${title_number}, у ветки — ${number}. Задача, ветка и отчёт несут один и тот же номер."
321
+ || deny "BLOCKED: в заголовке заявки номер ${title_number}, у ветки — ${number}. Задача, ветка и PR несут один и тот же номер."
251
322
  fi
252
323
  fi
253
324
 
254
- # Главная ветка влита до открытия отчёта. Отчёт от разошедшейся ветки показывает ревьюверу свою
325
+ # Главная ветка влита до открытия PR. PR от разошедшейся ветки показывает ревьюверу свою
255
326
  # правку вперемешку с чужой, а проверки на нём гоняются от устаревшего основания.
256
327
  #
257
- # Судится локальная вершина главной ветки, без сети: сетевой вызов в разборе команды падал бы
258
- # вместе со связью и отбивал бы работу вместо промаха. Отсюда и граница гард ловит ветку,
259
- # отставшую заведомо; свежесть самой вершины держит `git fetch`, и требует его чеклист.
328
+ # Ярусов два, и порядок между ними такой же, как у проверки задачи. Первый читает локальную
329
+ # вершину и работает без сети. Второй спрашивает удалённую ссылкубез него молчание гарда
330
+ # значит лишь «твоя ссылка не старше твоей ветки», а читается как «главная ветка влита»: ровно
331
+ # так открытый PR и оказался конфликтующим, и узнал об этом владелец.
332
+ #
333
+ # Сеть здесь допустима по той же причине, по которой её зовёт проверка очереди работ ниже:
334
+ # отсутствие ответа пропускается молча, и проверка, падающая в самолёте, не отбивает работу.
335
+ # Предел ожидания задаётся переменными самого git — внешний `timeout` есть не на всякой машине.
260
336
  if git rev-parse --verify --quiet "refs/remotes/origin/${main_branch}" >/dev/null 2>&1 \
261
337
  && ! git merge-base --is-ancestor "origin/${main_branch}" HEAD 2>/dev/null; then
262
338
  behind="$(git rev-list --count "HEAD..origin/${main_branch}" 2>/dev/null)"
263
- deny "BLOCKED: «${main_branch}» ушла вперёд на ${behind:-несколько} коммитов, а в ветку не влита. Отчёт от разошедшейся ветки показывает ревьюверу правку вперемешку с чужой, а проверки на нём идут от устаревшего основания. Влей и повтори: git fetch origin && git merge origin/${main_branch} — порядок и разбор конфликта в паттерне git-workflow-merge."
339
+ deny "BLOCKED: «${main_branch}» ушла вперёд на ${behind:-несколько} коммитов, а в ветку не влита. PR от разошедшейся ветки показывает ревьюверу правку вперемешку с чужой, а проверки на нём идут от устаревшего основания. Влей и повтори: git fetch origin && git merge origin/${main_branch} — порядок и разбор конфликта в паттерне git-workflow-merge."
340
+ fi
341
+
342
+ # Второй ярус: локальная ссылка сама могла протухнуть. Ответа нет — ярус молчит.
343
+ remote_main="$(GIT_HTTP_LOW_SPEED_LIMIT=1000 GIT_HTTP_LOW_SPEED_TIME=5 GIT_TERMINAL_PROMPT=0 \
344
+ git ls-remote origin "refs/heads/${main_branch}" 2>/dev/null | cut -f1)"
345
+ local_main="$(git rev-parse --verify --quiet "refs/remotes/origin/${main_branch}" 2>/dev/null)"
346
+ if [ -n "$remote_main" ] && [ -n "$local_main" ] && [ "$remote_main" != "$local_main" ]; then
347
+ # Возраст ссылки — то, чего исполнитель не видит вовсе, и именно он отличает «ветка
348
+ # отстала» от «я не знаю, отстала ли». Формат `stat` на BSD и на GNU разный, поэтому
349
+ # спрашиваются оба, а не угадывается система.
350
+ fetch_head="$(git rev-parse --git-dir 2>/dev/null)/FETCH_HEAD"
351
+ fetched_at="$(stat -f %m "$fetch_head" 2>/dev/null || stat -c %Y "$fetch_head" 2>/dev/null)"
352
+ age=''
353
+ if [ -n "$fetched_at" ]; then
354
+ age=" Последний git fetch — $(( ( $(date +%s) - fetched_at ) / 60 )) мин. назад."
355
+ fi
356
+ deny "BLOCKED: твоя ссылка origin/${main_branch} отстала от удалённой — ${local_main:0:8} против ${remote_main:0:8}.${age} Гард сравнивает ветку с тем, что лежит в дереве, поэтому молчание первого яруса значит «ссылка не старше ветки», а не «главная ветка влита». Влей и повтори: git fetch origin && git merge origin/${main_branch}."
264
357
  fi
265
358
 
266
359
  check_task "$number" "заявка с ветки «${branch}»"
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env bash
2
+ # rt-hook: Stop
3
+ # Гард предложения: ход, в котором владелец сказал завести или отправить предложение слою правил,
4
+ # не заканчивается, пока отправки не было. Stop.
5
+ #
6
+ # Зачем именно так. Написанное и не отправленное лежит в дереве неотличимо от отправленного:
7
+ # своей записи в слое правил у него нет, и владелец читает работу сделанной, пока не спросит
8
+ # прямо. Отправка при этом неудобна ровно в одном месте — она пишет отметки в файлы предложений и
9
+ # делает дерево грязным, — и этим доводом исполнитель закрывает молчание правил.
10
+ #
11
+ # Ловится просьба образцами, а не пониманием смысла: оценку «владелец просил отправить» назначал
12
+ # бы тот, кому она мешает. Глагол обязателен: «разбери пропозалы» — работа над уже приехавшими, и
13
+ # отправкой она не кончается. Слово «предложение» само по себе не считается тоже — оно ходит в
14
+ # каждом втором ходе о другом, и рядом с ним нужно слово о слое правил или о пакете.
15
+ #
16
+ # Отправкой считается вызов команды пакета без сухого прогона, сделанный в том же ходе: сухой
17
+ # прогон показывает, что уехало бы, ничего не заводит и следа наружу не оставляет.
18
+ #
19
+ # ОТКАЗ В ПОЛЬЗУ РАБОТЫ: при любой ошибке, нехватке `jq`, отсутствии записи хода и повторном
20
+ # заходе ход РАЗРЕШАЕТСЯ (exit 0). Сломанный гард не имеет права заклинить разговор.
21
+
22
+ input="$(cat 2>/dev/null)"
23
+ [ -z "$input" ] && exit 0
24
+
25
+ command -v jq >/dev/null 2>&1 || exit 0
26
+
27
+ # Повторный заход по тому же ходу не судится: гард сказал своё один раз и отпускает.
28
+ active="$(printf '%s' "$input" | jq -r '.stop_hook_active // false' 2>/dev/null)"
29
+ [ "$active" = "true" ] && exit 0
30
+
31
+ transcript="$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null)"
32
+ [ -z "$transcript" ] && exit 0
33
+ [ -f "$transcript" ] || exit 0
34
+
35
+ # Каталог предложений у дерева свой. Заданный пустым — отказ дерева от требования: дереву, не
36
+ # берущему переносимый слой правил, гард не навязывается.
37
+ proposals_dir="${RT_PROPOSALS_DIR-.claude/rt-kit/proposals}"
38
+ [ -z "$proposals_dir" ] && exit 0
39
+
40
+ # Просьба владельца. Набор открыт и пополняется правкой: полнота его — открытый вопрос
41
+ # договорённости, а не обещание.
42
+ asked_re='(завед|напиш|отправ|пошл|зашл|отошл|выгруз)[а-яё]*[^.!?]{0,40}(пропозал|предложени)|(пропозал|предложени)[а-яё]*[^.!?]{0,40}(завед|напиш|отправ|пошл|зашл|отошл|выгруз)'
43
+
44
+ # Сосед, без которого слово «предложение» не считается просьбой о слое правил.
45
+ context_re='пропозал|слою правил|слоя правил|слой правил|пакет|agent-kit|наверх'
46
+
47
+ # Ход — это всё, что записано после последнего настоящего ввода владельца. Ответ инструмента
48
+ # приходит той же ролью, поэтому строки с `tool_result` вводом не считаются.
49
+ #
50
+ # Хвост в 400 строк: запись хода растёт всю сессию, а судится только последний ход.
51
+ verdict="$(tail -n 400 "$transcript" 2>/dev/null | jq -s -r --arg asked "$asked_re" --arg ctx "$context_re" '
52
+ def is_input:
53
+ .type == "user"
54
+ and (((.message.content // []) | if type == "array"
55
+ then ([.[] | select(.type == "tool_result")] | length)
56
+ else 0 end) == 0);
57
+
58
+ def text_of:
59
+ (.message.content // []) | if type == "array"
60
+ then ([.[] | select(.type == "text") | .text] | join("\n"))
61
+ else (. // "") end;
62
+
63
+ (map(is_input) | rindex(true)) as $i
64
+ | (if $i == null then [] else .[$i:] end) as $turn
65
+ | (if $i == null then "" else ($turn[0] | text_of) end) as $said
66
+ | [$turn[] | select(.type == "assistant") | (.message.content // [])[] | select(.type == "tool_use")] as $uses
67
+ # Признак нечувствителен к регистру флагом, а не приведением: приведение знает только
68
+ # латиницу, и «Отправь Пропозал» с большой буквы проходило бы мимо набора образцов молча.
69
+ | (($said | test($asked; "i")) and ($said | test($ctx; "i"))) as $wanted
70
+ | ($uses | map(
71
+ ((.name // "") | test("^Bash$"))
72
+ and ((.input.command // "") | test("agent-kit[^|;&]*propose"))
73
+ and ((.input.command // "") | test("--dry-run") | not)
74
+ ) | any) as $sent
75
+ | if $wanted and ($sent | not) then "owe" else "pass" end
76
+ ' 2>/dev/null)"
77
+
78
+ [ "$verdict" = "owe" ] || exit 0
79
+
80
+ reason="BLOCKED by proposal-guard: владелец сказал завести или отправить предложение слою правил, а отправки в этом ходе не было. Написанное и не отправленное лежит в дереве неотличимо от отправленного: своей записи в слое правил у него нет, и владелец читает работу сделанной, пока не спросит прямо.
81
+
82
+ Предложение пишется файлом в \`$proposals_dir/\` и уезжает в тот же ход:
83
+
84
+ npx agent-kit propose
85
+
86
+ Сухой прогон отправкой не является: он показывает, что уехало бы, и следа наружу не оставляет. Отправка пишет отметки в файлы предложений и делает дерево грязным — при открытом PR они ложатся вторым коммитом в ту же ветку, и это их место, а не повод отложить.
87
+
88
+ Гард судит один ход: следующий заход не отбивается."
89
+
90
+ jq -n --arg r "$reason" '{decision:"block",reason:$r}' 2>/dev/null \
91
+ || printf '{"decision":"block","reason":"proposal-guard: владелец просил предложение — отправь его командой пакета."}\n'
92
+
93
+ exit 0
@@ -38,14 +38,46 @@ command -v jq >/dev/null 2>&1 || exit 0
38
38
  command -v perl >/dev/null 2>&1 || exit 0
39
39
 
40
40
  tool="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null)"
41
+ shell_cmd=""
41
42
  case "$tool" in
42
43
  # Инструмент среды заводит файл теми же двумя данными, только называет их иначе — без этой
43
44
  # ветки файл заводился мимо всех проверок.
44
45
  Edit | Write | MultiEdit | mcp__webstorm__create_new_file) ;;
46
+ # Команда оболочки, которая пишет файл, — та же правка. Без этой ветки гард обходится
47
+ # сменой не инструмента, а способа записи; текстом правки тогда служит сама команда, и
48
+ # заведённое ею в heredoc читается наравне с телом правки. Разбор —
49
+ # `2026-08-15-guard-denied-shell-wrote-anyway.md`.
50
+ Bash)
51
+ shell_cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null)"
52
+ [ -z "$shell_cmd" ] && exit 0
53
+ ;;
45
54
  *) exit 0 ;;
46
55
  esac
47
56
 
48
- path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.pathInProject // empty' 2>/dev/null)"
57
+ # Профиль дерева: сперва умолчание пакета, поверх него надстройка проекта, если она есть.
58
+ # Читается до разбора пути: пути из команды оболочки вынимает как раз профиль.
59
+ rt_hooks_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
60
+ for profile in "$rt_hooks_dir/../rt-kit/defaults/project.sh" "$rt_hooks_dir/../defaults/project.sh" "${CLAUDE_PROJECT_DIR:-.}/.claude/rt-kit/defaults/project.sh" "${CLAUDE_PROJECT_DIR:-.}/.claude/rt-kit/project.sh"; do
61
+ # shellcheck disable=SC1090
62
+ [ -f "$profile" ] && . "$profile" 2>/dev/null
63
+ done
64
+
65
+ if [ -n "$shell_cmd" ]; then
66
+ command -v rt_shell_writes >/dev/null 2>&1 && command -v rt_shell_paths >/dev/null 2>&1 || exit 0
67
+ rt_shell_writes "$shell_cmd" || exit 0
68
+ # Из команды берётся первый путь, чьё расширение гарду интересно: остальные ему безразличны.
69
+ path=""
70
+ while IFS= read -r candidate; do
71
+ case "$candidate" in
72
+ *.html | *.scss | *.ts) path="$candidate"; break ;;
73
+ esac
74
+ done <<EOF
75
+ $(rt_shell_paths "$shell_cmd")
76
+ EOF
77
+ [ -z "$path" ] && exit 0
78
+ else
79
+ path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.pathInProject // empty' 2>/dev/null)"
80
+ fi
49
81
  # Путь от корня дерева приводится к абсолютному один раз, чтобы образцы не двоились.
50
82
  case "$path" in
51
83
  /*) ;;
@@ -56,13 +88,6 @@ case "$path" in
56
88
  *) exit 0 ;;
57
89
  esac
58
90
 
59
- # Профиль дерева: сперва умолчание пакета, поверх него — надстройка проекта, если она есть.
60
- rt_hooks_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
61
- for profile in "$rt_hooks_dir/../rt-kit/defaults/project.sh" "$rt_hooks_dir/../defaults/project.sh" "${CLAUDE_PROJECT_DIR:-.}/.claude/rt-kit/defaults/project.sh" "${CLAUDE_PROJECT_DIR:-.}/.claude/rt-kit/project.sh"; do
62
- # shellcheck disable=SC1090
63
- [ -f "$profile" ] && . "$profile" 2>/dev/null
64
- done
65
-
66
91
  # Слово о нехватке функции профиля: хук, вышедший молча, неотличим от работающего. Файл может
67
92
  # быть не разложен — тогда остаётся прежнее поведение, молчаливое.
68
93
  # shellcheck disable=SC1090
@@ -82,11 +107,16 @@ if [ -n "${RT_REUSE_SKIP_RE:-}" ] && printf '%s' "$path" | grep -qE "$RT_REUSE_S
82
107
  exit 0
83
108
  fi
84
109
 
85
- # Только новый текст: строка, уже лежавшая в файле, этой правкой не заводилась.
86
- added="$(printf '%s' "$input" | jq -r '
87
- [ .tool_input.content?, .tool_input.text?, .tool_input.new_string?, (.tool_input.edits[]?.new_string) ]
88
- | map(select(. != null)) | join("\n")
89
- ' 2>/dev/null)"
110
+ # Только новый текст: строка, уже лежавшая в файле, этой правкой не заводилась. У команды
111
+ # оболочки таким текстом служит она сама: что она кладёт в файл, лежит в ней же.
112
+ if [ -n "$shell_cmd" ]; then
113
+ added="$shell_cmd"
114
+ else
115
+ added="$(printf '%s' "$input" | jq -r '
116
+ [ .tool_input.content?, .tool_input.text?, .tool_input.new_string?, (.tool_input.edits[]?.new_string) ]
117
+ | map(select(. != null)) | join("\n")
118
+ ' 2>/dev/null)"
119
+ fi
90
120
  [ -z "$added" ] && exit 0
91
121
 
92
122
  # Явный отказ от правила: готового такого нет, автор это осознал и пометил. Считается по
@@ -138,11 +168,19 @@ has_re() {
138
168
  rt_checks_json="${CLAUDE_PROJECT_DIR:-.}/.claude/rt-kit/checks.json"
139
169
  rt_bundles=''
140
170
  rt_own_signals=''
171
+ rt_backend_roots=''
141
172
  if [ -f "$rt_checks_json" ]; then
142
173
  rt_bundles="$(jq -r '.reuse.bundles[]? // empty' "$rt_checks_json" 2>/dev/null | tr '\n' ' ')"
143
174
  own="$(jq -r '.reuse.signals // empty' "$rt_checks_json" 2>/dev/null)"
144
175
  [ -n "$own" ] && rt_own_signals="${CLAUDE_PROJECT_DIR:-.}/$own"
176
+ # Тем же ключом, каким их читает сплошная проверка: второе объявление тех же корней
177
+ # разошлось бы с первым молча.
178
+ rt_backend_roots="$(jq -r '.backendRoots[]? // empty' "$rt_checks_json" 2>/dev/null | tr '\n' ' ')"
145
179
  fi
180
+
181
+ # Путь от корня дерева: образец в признаке пишет дерево, и писать его от корня машины оно не
182
+ # может. Абсолютный путь нужен только для чтения файла с диска.
183
+ rel_path="${path#"${CLAUDE_PROJECT_DIR:-.}"/}"
146
184
  rt_signals_dir="${CLAUDE_PROJECT_DIR:-.}/${RT_REUSE_SIGNALS_DIR:-tools/signals}"
147
185
 
148
186
  # Признаки объявленных наборов: те же файлы читает сплошная проверка. Ключ признака совпал с
@@ -172,9 +210,25 @@ while IFS= read -r signal; do
172
210
  '') ;;
173
211
  *) case "$path" in *"$ext") ;; *) continue ;; esac ;;
174
212
  esac
213
+ # Пропуск корней бэкенда: признак, объявленный с ним, на бэкенде не действует вовсе — там
214
+ # принят другой способ, и базового класса, которого признак требует, у бэкенда нет. Поле
215
+ # читает и сплошная проверка; читать его одному из двоих значит отбивать гардом ту самую
216
+ # правку, которую проверка пропускает, — а провести её больше нечем: маркер отступления
217
+ # объявляет обход готового, а обхода тут не было.
218
+ if [ "$(field "$signal" '.skipBackendRoots')" = 'true' ] && [ -n "$rt_backend_roots" ]; then
219
+ skip_backend=''
220
+ for backend_root in $rt_backend_roots; do
221
+ case "$rel_path" in "$backend_root"*) skip_backend=1 ;; esac
222
+ done
223
+ [ -n "$skip_backend" ] && continue
224
+ fi
225
+
226
+ # Образец имени сверяется с путём от корня дерева, а не с именем файла: слои, которые
227
+ # признак и разделяет, зовут свои файлы одинаково, и по имени они неразличимы. Сплошная
228
+ # проверка сверяет с путём — расходиться им нельзя.
175
229
  only_named="$(field "$signal" '.onlyNamed')"
176
230
  if [ -n "$only_named" ]; then
177
- printf '%s' "${path##*/}" | grep -qE "$only_named" || continue
231
+ printf '%s' "$rel_path" | grep -qE "$only_named" || continue
178
232
  fi
179
233
 
180
234
  case "$(field "$signal" '.scope')" in
@@ -124,7 +124,7 @@ esac
124
124
 
125
125
  # --- слой по пути: ведение работы -------------------------------------------------------------
126
126
  #
127
- # Папка задачи, договорённость о продукте до кода и линия работ — первые файлы, которые
127
+ # Папка задачи, договорённость о продукте до кода и замысел эпика — первые файлы, которые
128
128
  # заводятся в работе. Требование ловит на них того, кто пошёл мимо порядка: «пришла новая
129
129
  # задача» инструментом не является, и поймать это больше нечем.
130
130
  case "$target" in