@rt-tools/agent-kit 0.8.0 → 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 (104) 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/check-reuse.mjs +9 -7
  6. package/assets/checks/task-new.github.mjs +33 -5
  7. package/assets/commands/agent-kit-digest.md +10 -5
  8. package/assets/commands/next-session.md +4 -4
  9. package/assets/commands/skill-curator.md +11 -9
  10. package/assets/defaults/gate-map.sh +11 -4
  11. package/assets/defaults/project.sh +46 -0
  12. package/assets/docs/GLOSSARY.md +28 -26
  13. package/assets/hooks/docs-guard.sh +19 -3
  14. package/assets/hooks/git-guard-delivery.sh +106 -13
  15. package/assets/hooks/proposal-guard.sh +93 -0
  16. package/assets/hooks/reuse-first-guard.sh +68 -14
  17. package/assets/hooks/skill-gate-layers.sh +1 -1
  18. package/assets/hooks/skill-gate.sh +26 -0
  19. package/assets/hooks/task-flow-guard.sh +44 -18
  20. package/assets/hooks/window-fill-guard.sh +1 -1
  21. package/assets/laws/delivery.md +13 -7
  22. package/assets/laws/project-documentation.md +21 -0
  23. package/assets/laws/verifiability.md +6 -1
  24. package/assets/laws/work-conduct.md +67 -3
  25. package/assets/patterns/git-workflow-commit.azure.md +10 -2
  26. package/assets/patterns/git-workflow-commit.github.md +15 -2
  27. package/assets/patterns/git-workflow-commit.gitlab.md +10 -2
  28. package/assets/patterns/git-workflow-merge.md +1 -1
  29. package/assets/patterns/reuse-first-extend.md +12 -3
  30. package/assets/patterns/spec-driven-domain.md +7 -1
  31. package/assets/patterns/spec-driven-rule.md +6 -0
  32. package/assets/patterns/task-flow-close.md +78 -9
  33. package/assets/patterns/task-flow-handoff.md +27 -4
  34. package/assets/patterns/task-flow-resume.md +23 -5
  35. package/assets/patterns/task-flow-start.md +5 -1
  36. package/assets/rules/browser-verification.md +10 -1
  37. package/assets/rules/doc-style.md +13 -5
  38. package/assets/rules/git-workflow.azure.md +12 -7
  39. package/assets/rules/git-workflow.github.md +31 -13
  40. package/assets/rules/git-workflow.gitlab.md +12 -7
  41. package/assets/rules/reuse-first.md +1 -1
  42. package/assets/rules/spec-driven.md +4 -0
  43. package/assets/rules/task-flow.md +47 -22
  44. package/assets/rules/testing.md +19 -0
  45. package/assets/rules/typescript-conventions.md +12 -0
  46. package/assets/skills/agent-kit-extend.md +173 -0
  47. package/assets/skills/agent-kit.md +62 -10
  48. package/assets/traits.json +14 -0
  49. package/bin/agent-kit.d.ts.map +1 -1
  50. package/bin/agent-kit.js +31 -16
  51. package/bin/agent-kit.js.map +1 -1
  52. package/index.d.ts +1 -0
  53. package/index.d.ts.map +1 -1
  54. package/index.js +1 -0
  55. package/index.js.map +1 -1
  56. package/lib/argv.d.ts +17 -0
  57. package/lib/argv.d.ts.map +1 -0
  58. package/lib/argv.js +44 -0
  59. package/lib/argv.js.map +1 -0
  60. package/lib/cargo.d.ts +88 -0
  61. package/lib/cargo.d.ts.map +1 -0
  62. package/lib/cargo.js +16 -0
  63. package/lib/cargo.js.map +1 -0
  64. package/lib/catalog.d.ts +18 -1
  65. package/lib/catalog.d.ts.map +1 -1
  66. package/lib/catalog.js +12 -2
  67. package/lib/catalog.js.map +1 -1
  68. package/lib/commands.d.ts +0 -26
  69. package/lib/commands.d.ts.map +1 -1
  70. package/lib/commands.js +78 -122
  71. package/lib/commands.js.map +1 -1
  72. package/lib/companion.d.ts +37 -0
  73. package/lib/companion.d.ts.map +1 -1
  74. package/lib/companion.js +42 -1
  75. package/lib/companion.js.map +1 -1
  76. package/lib/config.d.ts +28 -0
  77. package/lib/config.d.ts.map +1 -1
  78. package/lib/config.js +20 -0
  79. package/lib/config.js.map +1 -1
  80. package/lib/ship.d.ts +39 -0
  81. package/lib/ship.d.ts.map +1 -0
  82. package/lib/ship.js +87 -0
  83. package/lib/ship.js.map +1 -0
  84. package/lib/shipment.d.ts +60 -0
  85. package/lib/shipment.d.ts.map +1 -0
  86. package/lib/shipment.js +247 -0
  87. package/lib/shipment.js.map +1 -0
  88. package/lib/snapshot.d.ts +30 -0
  89. package/lib/snapshot.d.ts.map +1 -0
  90. package/lib/snapshot.js +73 -0
  91. package/lib/snapshot.js.map +1 -0
  92. package/lib/traits.d.ts +32 -0
  93. package/lib/traits.d.ts.map +1 -0
  94. package/lib/traits.js +82 -0
  95. package/lib/traits.js.map +1 -0
  96. package/package.json +6 -2
  97. package/rt-tools-agent-kit-0.8.2.tgz +0 -0
  98. package/lib/submit.d.ts +0 -24
  99. package/lib/submit.d.ts.map +0 -1
  100. package/lib/submit.js +0 -26
  101. package/lib/submit.js.map +0 -1
  102. package/rt-tools-agent-kit-0.8.0.tgz +0 -0
  103. /package/assets/rules/{entity-conventions.md → entity-conventions.needs-admin.md} +0 -0
  104. /package/assets/rules/{observability.md → observability.needs-app.md} +0 -0
@@ -166,6 +166,36 @@ rt_is_app_code_default() {
166
166
  esac
167
167
  }
168
168
 
169
+ # Пишет ли команда оболочки файл. Успех — да, и тогда пути из неё судятся тем же признаком,
170
+ # что и путь из вызова инструмента правки.
171
+ #
172
+ # Гарды подписаны на инструменты правки файла, и этого мало: ту же правку кладут командой —
173
+ # перенаправлением, `tee`, `sed -i`, интерпретатором с heredoc. Отбитая правка дважды за один
174
+ # заход легла именно так, и не увидел этого никто: в дереве она неотличима от положенной
175
+ # инструментом. Разбор — `2026-08-15-guard-denied-shell-wrote-anyway.md`.
176
+ #
177
+ # Список намеренно широк, и цена этого названа: команда чтения, в которой стоит имя
178
+ # интерпретатора, будет отбита наравне с командой правки. Узкий список стоил бы дороже —
179
+ # пропущенная форма записи возвращает обход целиком, а найти её можно только промахом.
180
+ rt_shell_writes_default() {
181
+ printf '%s' "$1" | grep -Eq \
182
+ '>>?[[:space:]]*[^|&>[:space:]]|\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'
183
+ }
184
+
185
+ # Пути, названные командой оболочки. Печатает по одному в строке; судит их зовущий.
186
+ #
187
+ # Разбирать оболочку по-настоящему нечем — здесь и не разбирают: из текста вынимается всё, что
188
+ # похоже на путь, и каждое отдаётся признаку. Лишнее он отсеет сам, а пропущенное вернуло бы
189
+ # обход. Кавычки и heredoc снимаются заменой на пробел: путь внутри них тот же самый.
190
+ rt_shell_paths_default() {
191
+ printf '%s' "$1" \
192
+ | tr "\"'\`(),;=" ' ' \
193
+ | tr '[:space:]' '\n' \
194
+ | grep -E '^[A-Za-z0-9_@.-]*/[A-Za-z0-9_@./-]+$' \
195
+ | sed 's|^\./||' \
196
+ | sort -u
197
+ }
198
+
169
199
  # Имя ветки, с которой разрешено открывать заявку на слияние: в имени стоит номер задачи.
170
200
  # Приставка — либо род правки, либо метка очереди работ: обе формы носят номер, а он и нужен.
171
201
  #
@@ -201,6 +231,20 @@ RT_BOARD_CHECK_CMD="${RT_BOARD_CHECK_CMD:-npm run check:board}"
201
231
  # Учётная запись, которую ставят исполнителем. Умолчание молчит: у каждого дерева она своя.
202
232
  RT_TASK_BOT="${RT_TASK_BOT:-}"
203
233
 
234
+ # Почта, которой подписан коммит машинной учётной записи. Целым значением, а не образцом:
235
+ # служебный адрес хостинга состоит из числа, логина и домена, а сопоставляется по числу — логин
236
+ # рядом с ним не сверяет никто. Образец «число, плюс, логин» прошёл бы с чужим числом, то есть
237
+ # ровно с тем промахом, ради которого гард поставки подпись и читает.
238
+ #
239
+ # Отсюда же он берёт логин машинной записи — левой частью адреса, до собаки и после плюса.
240
+ # Вторым свойством логин не объявляется: два объявления одного имени разошлись бы молча.
241
+ # Исполнитель задачи для этого не годится — там, где хостинг ограничил машинную запись,
242
+ # исполнителем ставят человека, а коммит остаётся машинным.
243
+ #
244
+ # Умолчание молчит, и тогда подпись не судится: своей машинной записи у пакета нет, а выдуманная
245
+ # отбивала бы работу в чужом дереве.
246
+ RT_COMMIT_EMAIL="${RT_COMMIT_EMAIL:-}"
247
+
204
248
  # Состояние задачи одним объектом: exists, open, onBoard, assigned, numbered. Спрашивает
205
249
  # помощника очереди работ — того же, которым пользуются сверка и команда заведения, чтобы
206
250
  # гард и очередь одинаково понимали «задача в порядке». Нет узла, нет помощника, нет сети —
@@ -259,6 +303,8 @@ rt_lint_for() { rt_lint_for_default "$@"; }
259
303
  rt_task_branch_ok() { rt_task_branch_ok_default "$@"; }
260
304
  rt_reinvented_in() { rt_reinvented_in_default "$@"; }
261
305
  rt_is_app_code() { rt_is_app_code_default "$@"; }
306
+ rt_shell_writes() { rt_shell_writes_default "$@"; }
307
+ rt_shell_paths() { rt_shell_paths_default "$@"; }
262
308
  rt_qa_decorative() { rt_qa_decorative_default "$@"; }
263
309
  rt_task_state() { rt_task_state_default "$@"; }
264
310
  rt_report_body() { rt_report_body_default "$@"; }
@@ -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