@rt-tools/agent-kit 0.23.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 (61) hide show
  1. package/README.md +8 -2
  2. package/assets/checks/board.github.mjs +1 -1
  3. package/assets/checks/check-board.github.mjs +14 -0
  4. package/assets/checks/check-doc-paths.mjs +24 -5
  5. package/assets/checks/check-file-size.mjs +8 -2
  6. package/assets/checks/check-prose-style.mjs +10 -1
  7. package/assets/defaults/gate-map.sh +13 -0
  8. package/assets/defaults/project.sh +10 -0
  9. package/assets/defaults/shell.sh +18 -3
  10. package/assets/hooks/browser-guard-device-id.sh +42 -12
  11. package/assets/hooks/dispatch.sh +40 -9
  12. package/assets/hooks/docs-guard.sh +10 -0
  13. package/assets/hooks/exam-guard.sh +66 -16
  14. package/assets/hooks/git-guard-delivery-draft.sh +78 -0
  15. package/assets/hooks/git-guard-delivery.sh +49 -111
  16. package/assets/hooks/git-guard-main.sh +39 -4
  17. package/assets/hooks/git-guard-push-tests.sh +49 -3
  18. package/assets/hooks/hook-input.sh +17 -6
  19. package/assets/hooks/rule-source-guard.sh +11 -0
  20. package/assets/hooks/stand-login-guard.sh +101 -0
  21. package/assets/hooks/write-targets.sh +37 -4
  22. package/assets/laws/verifiability.md +12 -2
  23. package/assets/laws/work-conduct.md +59 -65
  24. package/assets/patterns/browser-verification-measure.md +41 -1
  25. package/assets/patterns/browser-verification-stand.md +56 -15
  26. package/assets/patterns/doc-style-human.md +75 -0
  27. package/assets/patterns/git-workflow-commit.azure.md +12 -0
  28. package/assets/patterns/git-workflow-commit.github.md +16 -3
  29. package/assets/patterns/git-workflow-commit.gitlab.md +12 -0
  30. package/assets/patterns/git-workflow-merge.md +8 -0
  31. package/assets/patterns/task-flow-start.md +1 -1
  32. package/assets/patterns/testing-e2e.md +18 -8
  33. package/assets/pitfalls/task-flow.md +40 -40
  34. package/assets/rules/browser-verification.md +29 -3
  35. package/assets/rules/doc-style.md +36 -0
  36. package/assets/rules/git-workflow.github.md +20 -23
  37. package/assets/rules/reuse-first.md +8 -2
  38. package/assets/rules/styling-bem.md +8 -1
  39. package/assets/rules/task-flow.md +73 -73
  40. package/assets/rules/testing.md +21 -0
  41. package/assets/skills/agent-kit.md +60 -70
  42. package/lib/commands.d.ts.map +1 -1
  43. package/lib/commands.js +63 -2
  44. package/lib/commands.js.map +1 -1
  45. package/lib/enroll.d.ts.map +1 -1
  46. package/lib/enroll.js +1 -1
  47. package/lib/enroll.js.map +1 -1
  48. package/lib/observations.d.ts +10 -1
  49. package/lib/observations.d.ts.map +1 -1
  50. package/lib/observations.js +1 -0
  51. package/lib/observations.js.map +1 -1
  52. package/lib/override-marks.d.ts +24 -0
  53. package/lib/override-marks.d.ts.map +1 -0
  54. package/lib/override-marks.js +98 -0
  55. package/lib/override-marks.js.map +1 -0
  56. package/lib/shipment.d.ts.map +1 -1
  57. package/lib/shipment.js +1 -1
  58. package/lib/shipment.js.map +1 -1
  59. package/package.json +1 -1
  60. package/rt-tools-agent-kit-0.24.0.tgz +0 -0
  61. package/rt-tools-agent-kit-0.23.0.tgz +0 -0
package/README.md CHANGED
@@ -358,10 +358,16 @@ npx agent-kit enroll --token <токен> # положить токе
358
358
  | Где | Что нужно |
359
359
  | --- | --- |
360
360
  | у потребителя | `jq` — его требуют и сами гарды |
361
- | у потребителя | адрес приёма ключом `intake` в `.claude/rt-kit.json` |
361
+ | у потребителя | адрес приёма ключом `intake` в `.claude/rt-kit.json` — берётся у владельца приёма вместе с кодом приглашения |
362
362
  | у потребителя | токен дерева в файле вне дерева, названном ключом `token` |
363
363
  | у приёма | заведённое дерево: токен выдаёт его команда и печатает один раз |
364
364
 
365
+ Адреса приёма пакет не знает: приём поднимает владелец, и у каждой мастерской он свой.
366
+ Спрашивается он у того же человека, который выдаёт код приглашения, — код и адрес идут парой и
367
+ порознь не работают. Вид адреса — `https://<приём мастерской>`; без TLS команда запрещает обмен у
368
+ себя, не доходя до сети: токен приходит единственным ответом, и по открытому пути его прочитает
369
+ любой. Исключение сделано только для локальной машины.
370
+
365
371
  Признак, которым дерево представляется приёму, считается хешем от адреса его удалённого
366
372
  репозитория: адрес по нему не восстанавливается, а у двух рабочих копий одного репозитория он
367
373
  один. Репозитория нет — признак называется ключом `tree`.
@@ -420,7 +426,7 @@ PR влит, и вливанием в текущую ветку во всех п
420
426
  либо надстройка `.claude/rt-kit/overrides/commands/<имя>.md`. Свою команду дерево заводит файлом
421
427
  рядом; файл без шапки раскладки пакет чужим считает и не перезаписывает.
422
428
 
423
- Слешем зовётся и то, что командой не является: скилы — правила, паттерны и скилы без закона из
429
+ Слешем зовётся не только команда: скилы — правила, паттерны и скилы без закона из
424
430
  `.claude/skills/` — и конвейеры из `.claude/workflows/`. Что из всего этого разложено и откуда
425
431
  взялось, говорят `npx agent-kit list` и `npx agent-kit doctor`.
426
432
 
@@ -213,7 +213,7 @@ export function behindMain(branch, mainBranch, options) {
213
213
  * потом значило бы второй вызов на каждый PR. */
214
214
  export function fetchOpenPulls(options) {
215
215
  return ghJson(
216
- ['pr', 'list', '--state', 'open', '--limit', '200', '--json', 'number,title,headRefName,headRefOid,isDraft,body,mergeable'],
216
+ ['pr', 'list', '--state', 'open', '--limit', '200', '--json', 'number,title,headRefName,headRefOid,isDraft,body,mergeable,baseRefName'],
217
217
  options
218
218
  );
219
219
  }
@@ -196,6 +196,20 @@ function checkHeadRun(pull, options) {
196
196
  return;
197
197
  }
198
198
 
199
+ // Заявка поверх соседней прогона не получает: рабочий поток слушает заявки в главную ветку и
200
+ // событий с другой базой не видит. Строка о потерянном событии здесь неверна дважды: событие
201
+ // не терялось, и перезакрытие его не вернёт — совет исполняется буквально, прогон не
202
+ // запускается, и на второй попытке поломку начинают искать в хостинге.
203
+ if (pull.baseRefName && pull.baseRefName !== MAIN_BRANCH) {
204
+ report(
205
+ `PR #${pull.number}: на вершине ${pull.headRefOid.slice(0, 8)} прогона нет и не будет — ` +
206
+ `заявка открыта в ветку «${pull.baseRefName}», а рабочий поток слушает заявки в главную; ` +
207
+ `перенеси базу на «${MAIN_BRANCH}», когда нижняя заявка влита`
208
+ );
209
+
210
+ return;
211
+ }
212
+
199
213
  report(
200
214
  `PR #${pull.number}: на вершине ${pull.headRefOid.slice(0, 8)} прогона нет, а лежит она ${minutes} мин — ` +
201
215
  `конвейер события не получил; верни его новым коммитом либо перезакрытием PR ` +
@@ -28,7 +28,7 @@
28
28
  */
29
29
  import { spawnSync } from 'node:child_process';
30
30
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
31
- import { join } from 'node:path';
31
+ import { join, posix } from 'node:path';
32
32
 
33
33
  import { allowlistOf, CONFIG, ROOT, parseAllowlist } from './rt-kit-checks.config.mjs';
34
34
 
@@ -163,7 +163,7 @@ const TREE = treeOfRepo();
163
163
  * каталог — оба ищутся по дереву, потому что адрес у них один, а написан он коротко.
164
164
  */
165
165
  function looksLikePath(candidate) {
166
- if (/[*<>{}$|\s]|\.\.\.|…/.test(candidate)) {
166
+ if (/[*<>{}$|\s()[\]'",;=]|\.\.\.|…/.test(candidate)) {
167
167
  return false;
168
168
  }
169
169
  if (/^(https?:|@|~|\/|-)/.test(candidate) || candidate.includes(':')) {
@@ -187,9 +187,21 @@ function looksLikePath(candidate) {
187
187
  * среди каталогов: имя каталога в обзорном документе либы означает каталог рядом, а не
188
188
  * каталог в корне.
189
189
  */
190
- function existsInTree(candidate) {
190
+ function existsInTree(candidate, fromDir = '') {
191
191
  const bare = candidate.replace(/\/$/, '');
192
192
 
193
+ /**
194
+ * Относительный адрес принадлежит каталогу документа, а не корню дерева: `../routes.ts` из
195
+ * `libs/x/shell/src/shell/README.md` — это `libs/x/shell/src/routes.ts`. Без разрешения от
196
+ * каталога такой адрес ищется по дереву строкой и не находится никогда, то есть проверка
197
+ * краснеет на каждой ссылке, написанной так, как её пишут в разметке.
198
+ */
199
+ if (/^\.{1,2}(\/|$)/.test(bare)) {
200
+ const resolved = posix.normalize(posix.join(fromDir, bare));
201
+
202
+ return resolved.startsWith('..') ? false : existsSync(join(ROOT, resolved));
203
+ }
204
+
193
205
  if (ROOTED_IN.has(bare.split('/')[0])) {
194
206
  return existsSync(join(ROOT, bare));
195
207
  }
@@ -260,6 +272,7 @@ function checkIndex(dir) {
260
272
 
261
273
  function checkDoc(doc, allowed) {
262
274
  const lines = readFileSync(join(ROOT, doc), 'utf8').split('\n');
275
+ const fromDir = posix.dirname(doc);
263
276
  let insideFence = false;
264
277
 
265
278
  lines.forEach((line, index) => {
@@ -276,7 +289,7 @@ function checkDoc(doc, allowed) {
276
289
  if (!looksLikePath(candidate) || allowed.has(candidate)) {
277
290
  continue;
278
291
  }
279
- if (!existsInTree(candidate)) {
292
+ if (!existsInTree(candidate, fromDir)) {
280
293
  report(doc, index + 1, candidate);
281
294
  }
282
295
  }
@@ -308,8 +321,14 @@ INDEXED_DIRS.forEach((dir) => checkIndex(dir));
308
321
  if (problems.length > 0) {
309
322
  console.error(`check-doc-paths: расхождений ${problems.length}\n`);
310
323
  problems.forEach((problem) => console.error(` ${problem}`));
324
+ // Вариантов три, и третий назван: список известного хранит принятое, а не результаты
325
+ // сломанной проверки. Прежний текст предлагал вносить в список всё спорное — учил обходу,
326
+ // который закон о проверяемости запрещает; один разбор дал 51 ложный отказ из 264, каждый
327
+ // на существующий адрес.
311
328
  console.error(
312
- `\nЛибо адрес устарел и его надо поправить, либо документ описывает ещё не созданное —\nтогда он вносится в ${ALLOWLIST}.`
329
+ `\nХодов отсюда три: поправить устаревший адрес; внести имя в ${ALLOWLIST}, если документ` +
330
+ `\nописывает ещё не созданное; починить саму проверку, если ошибается она, — разобрать` +
331
+ `\nотказы поимённо и показать разбор владельцу. Спорное в список не вносится.`
313
332
  );
314
333
  }
315
334
 
@@ -144,8 +144,14 @@ if (process.argv.includes('--baseline')) {
144
144
  const fresh = [...tooLong].filter(([path]) => !known.has(path));
145
145
  /** Строка на файл, которого в дереве нет, — устаревшая: иначе список копит мёртвое. */
146
146
  const gone = [...known.keys()].filter((path) => !existsSync(join(ROOT, path)));
147
- /** Файл поделили, а строку оставили: список перестал бы отвечать за то, что в нём стоит. */
148
- const shrunk = [...known.keys()].filter((path) => !tooLong.has(path) && existsSync(join(ROOT, path)));
147
+ /**
148
+ * Файл поделили, а строку оставили: список перестал бы отвечать за то, что в нём стоит.
149
+ * Тяжёлый по знакам файл под предел строк не подпадает, и без второй проверки его запись
150
+ * читалась бы устаревшей — долг нельзя было бы ни записать, ни оставить.
151
+ */
152
+ const shrunk = [...known.keys()].filter(
153
+ (path) => !tooLong.has(path) && !overweight.has(path) && existsSync(join(ROOT, path))
154
+ );
149
155
 
150
156
  /** Тяжёлое по знакам судится тем же списком известного: один долг на файл, а не два. */
151
157
  const heavy = [...overweight].filter(([path]) => !known.has(path) && !tooLong.has(path));
@@ -39,7 +39,16 @@ const MARKS = [
39
39
  [/(?<![а-яёА-ЯЁ])в рамках(?![а-яёА-ЯЁ])/giu, 'назвать отношение прямо: «в», «при», «для»'],
40
40
  [/(?<![а-яёА-ЯЁ])на основании(?![а-яёА-ЯЁ])/giu, '«по»'],
41
41
  [/(?<![а-яёА-ЯЁ])посредством(?![а-яёА-ЯЁ])/giu, '«через», «командой», «вызовом»'],
42
- [/(?<![а-яёА-ЯЁ])данн(ый|ая|ое|ые)(?![а-яёА-ЯЁ])/giu, '«этот» или ничего'],
42
+ [/(?<![а-яёА-ЯЁ])данн(ый|ая|ое)(?![а-яёА-ЯЁ])/giu, '«этот» или ничего'],
43
+ // Форма «данные» бывает и существительным, и заменить его нечем: данные стенда — это данные.
44
+ // Проверяется она поэтому по тому, что стоит следом: местоимением она читается перед словом,
45
+ // которое сама и определяет. Перечень корней короткий намеренно — распознаётся то, что
46
+ // встречалось, а ложный отказ здесь дороже пропуска: проверка стоит в наборе гейта пуша, и
47
+ // красное на слове, которому нет замены, останавливает работу целиком.
48
+ [
49
+ /(?<![а-яёА-ЯЁ])данные\s+(требовани|услови|значени|обстоятельств|сведени|фактор|параметр|вопрос|правил|подход|принцип|случа)[а-яё]*/giu,
50
+ '«эти» или ничего',
51
+ ],
43
52
  [/(?<![а-яёА-ЯЁ])соответствующ(ий|ая|ее|ие)(?![а-яёА-ЯЁ])/giu, 'назвать, чему именно соответствует'],
44
53
  [/(?<![а-яёА-ЯЁ])необходимо(?![а-яёА-ЯЁ])/giu, '«надо» или повелительное наклонение'],
45
54
  [/(?<![а-яёА-ЯЁ])должен быть (выполнен|произведён|осуществлён)(?![а-яёА-ЯЁ])/giu, 'сказать, кто это делает'],
@@ -175,6 +175,19 @@ skill_for_default() {
175
175
  printf '%s\n' 'git-workflow'
176
176
  fi
177
177
 
178
+ # Тело задачи и тело заявки публикуются вызовом клиента и файлом дерева не
179
+ # становятся: гейт проверял расширение правимого файла и на такой команде молчал.
180
+ # Читает этот текст человек, и чаще, чем любой файл дерева: владелец прочитал семь
181
+ # своих задач и две заявки и назвал язык в них нечитаемым, а ни одна проверка об этом
182
+ # не сообщила. Признака два сразу: вызов клиента и тело в доводах — одного слова о
183
+ # заявке мало, оно есть в строке любой команды, которая о ней пишет.
184
+ if { rt_gate_invokes "$target" "(gh|glab)[[:space:]]+(pr|mr|issue)[[:space:]]+(create|edit)" \
185
+ || rt_gate_invokes "$target" "[^[:space:]]*task:new"; } \
186
+ && printf '%s' "$target" | grep -qE '(--body|--body-file|--description|-F[[:space:]]*body)'; then
187
+ printf '%s\n' 'doc-style'
188
+ printf '%s\n' 'doc-style-human'
189
+ fi
190
+
178
191
  # Слияние PR — последний момент, когда папку закрытой задачи ещё можно разобрать
179
192
  # тем же PR: после слияния сверка очереди её видит, а отвечать за неё уже
180
193
  # некому. Требуется ВТОРЫМ слоем, дополнительно к правилу поставки.
@@ -288,6 +288,16 @@ RT_TASK_BOT="${RT_TASK_BOT:-}"
288
288
  RT_PULL_TOKEN_VAR="${RT_PULL_TOKEN_VAR:-}"
289
289
  RT_PULL_TOKEN_HINT="${RT_PULL_TOKEN_HINT:-}"
290
290
 
291
+ # Кто придёт к хостингу по этому токену. Подстановка в команде говорит только о намерении: она
292
+ # читает файл, а файла на машине может не быть — тогда значение пустое, клиент отвечает от
293
+ # залогиненной записи, и заявка выходит от владельца при верной с виду команде. Спросить это
294
+ # стоит одного вызова, но как спрашивать, знает только дерево: хостинг, клиент и путь к токену
295
+ # у каждого свои. Умолчание молчит: дерево, не объявившее функции, второго яруса не получает.
296
+ #
297
+ # Контракт: печатает логин, под которым уйдёт пишущий вызов. Пустой вывод означает «спросить не
298
+ # удалось» — гард пропускает вызов и сообщает об этом.
299
+ rt_pull_token_login() { :; }
300
+
291
301
  # Раздел, который тело заявки обязано нести с минуты открытия: решение о слиянии принимается на
292
302
  # её странице, где переписки нет вовсе, и сказанного вслух там не остаётся. Умолчание молчит —
293
303
  # заголовок пишется языком заявки, а чужих слов пакет не знает: не названный деревом, раздел не
@@ -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
@@ -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` из-под него выведена. Загруженное правило и прочитанное правило — разные вещи, и цену этой разницы платит владелец."