@rt-tools/agent-kit 0.9.1 → 0.10.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 (67) hide show
  1. package/README.md +5 -0
  2. package/assets/agents/conscience.md +58 -0
  3. package/assets/agents/prose-editor.md +44 -0
  4. package/assets/agents/strict-teacher.md +59 -0
  5. package/assets/checks/board.github.mjs +56 -1
  6. package/assets/checks/check-board.github.mjs +24 -0
  7. package/assets/checks/check-doc-paths.mjs +4 -15
  8. package/assets/checks/check-dupes.mjs +5 -6
  9. package/assets/checks/check-file-size.mjs +6 -20
  10. package/assets/checks/check-prose-style.mjs +137 -0
  11. package/assets/checks/check-reuse.mjs +5 -5
  12. package/assets/checks/check-styles.mjs +5 -5
  13. package/assets/checks/lib-common.mjs +3 -3
  14. package/assets/checks/rt-kit-checks.config.mjs +85 -1
  15. package/assets/defaults/project.sh +50 -0
  16. package/assets/docs/GLOSSARY.md +17 -15
  17. package/assets/hooks/browser-guard-device-id.sh +9 -1
  18. package/assets/hooks/browser-guard-no-asking.sh +9 -1
  19. package/assets/hooks/browser-guard-no-listing.sh +9 -1
  20. package/assets/hooks/browser-guard-no-other-drivers.sh +7 -1
  21. package/assets/hooks/browser-guard-require-select.sh +11 -2
  22. package/assets/hooks/claim-guard.sh +113 -0
  23. package/assets/hooks/conscience-guard.sh +98 -0
  24. package/assets/hooks/deny-tail.sh +32 -0
  25. package/assets/hooks/dev-server-guard.sh +8 -2
  26. package/assets/hooks/docs-guard.sh +14 -2
  27. package/assets/hooks/exam-guard.sh +121 -0
  28. package/assets/hooks/git-guard-delivery-signature.sh +75 -0
  29. package/assets/hooks/git-guard-delivery.sh +154 -68
  30. package/assets/hooks/git-guard-main.sh +12 -0
  31. package/assets/hooks/git-guard-push-tests.sh +49 -2
  32. package/assets/hooks/grill-gate.sh +12 -0
  33. package/assets/hooks/handoff-entry-guard.sh +71 -0
  34. package/assets/hooks/postmortem-guard.sh +12 -0
  35. package/assets/hooks/proposal-guard.sh +12 -0
  36. package/assets/hooks/prose-style-guard.sh +73 -0
  37. package/assets/hooks/qa-dataid-guard.sh +12 -1
  38. package/assets/hooks/rerun-guard.sh +86 -0
  39. package/assets/hooks/reuse-first-guard.sh +12 -1
  40. package/assets/hooks/roles.sh +32 -0
  41. package/assets/hooks/skill-gate.sh +12 -0
  42. package/assets/hooks/sql-guard-write.sh +2 -1
  43. package/assets/hooks/sql-guard.sh +12 -1
  44. package/assets/hooks/task-flow-guard.sh +71 -7
  45. package/assets/hooks/turn-exit-guard.sh +189 -0
  46. package/assets/hooks/waiting-turn-guard.sh +12 -0
  47. package/assets/hooks/window-fill-guard.sh +12 -1
  48. package/assets/patterns/task-flow-close.md +8 -8
  49. package/assets/patterns/task-flow-handoff.md +1 -1
  50. package/assets/patterns/task-flow-resume.md +30 -4
  51. package/assets/patterns/task-flow-start.md +15 -6
  52. package/assets/rules/git-workflow.azure.md +36 -9
  53. package/assets/rules/git-workflow.github.md +74 -9
  54. package/assets/rules/git-workflow.gitlab.md +40 -12
  55. package/assets/rules/task-flow.md +123 -71
  56. package/assets/rules/testing.md +15 -1
  57. package/assets/samples/tasks/_template/plan.md +4 -1
  58. package/assets/samples/tasks/_template/progress.md +1 -0
  59. package/assets/skills/agent-kit.md +33 -0
  60. package/assets/templates/project.sh +16 -0
  61. package/lib/config.d.ts +8 -0
  62. package/lib/config.d.ts.map +1 -1
  63. package/lib/config.js +1 -0
  64. package/lib/config.js.map +1 -1
  65. package/package.json +1 -1
  66. package/rt-tools-agent-kit-0.10.0.tgz +0 -0
  67. package/rt-tools-agent-kit-0.9.1.tgz +0 -0
@@ -187,8 +187,92 @@ export const allowlistOf = (name) => join(CONFIG.allowlistDir, `${name}-allowlis
187
187
  */
188
188
  export const readAllowlist = (name) => {
189
189
  const path = join(ROOT, allowlistOf(name));
190
+ if (!existsSync(path)) {
191
+ return {};
192
+ }
193
+ try {
194
+ return JSON.parse(readFileSync(path, 'utf8'));
195
+ } catch (error) {
196
+ // Нечитаемая настройка — это не пустой список, и молчать о ней нельзя: проверка,
197
+ // прочитавшая пустоту вместо перечня, назовёт долгом всё дерево разом.
198
+ console.error(`${allowlistOf(name)}: список известного не прочитан — не разбирается как JSON: ${error.message}`);
199
+ process.exit(1);
200
+ }
201
+ };
202
+
203
+ /**
204
+ * Разбор списка принятого: запись отвечает за себя сама.
205
+ *
206
+ * Причина, написанная прозой на весь список, за отдельную строку не отвечает: список пустеет и
207
+ * наполняется, а причина остаётся прежней — строка, внесённая позже, выглядит покрытой ею. Так
208
+ * и вышло у четырёх списков дерева: шапка говорила о разобранном долге, а под ней лежало
209
+ * принятое, которого в тот день ещё не было.
210
+ *
211
+ * Поэтому форма одна на все списки: сторона — объект, где ключ говорит, что принято, а
212
+ * значение несёт причину и номер задачи, которой запись внесена. Стороны называет зовущий:
213
+ * у большинства списков это `accepted` и `debt`, у иных свои имена, а разбор у всех один. Номер — это дорога
214
+ * к разговору, в котором заглушить разрешили: без него запись объясняет сама себя, а спросить
215
+ * о ней некого.
216
+ *
217
+ * Отказ называет файл и саму запись: список читают не целиком, а по строке, и «где-то здесь
218
+ * неверная запись» стоит того же, что и молчание.
219
+ */
220
+ export const parseAllowlist = (name, sides = ['accepted', 'debt']) => {
221
+ const file = allowlistOf(name);
222
+ const raw = readAllowlist(name);
223
+ const key = CONFIG.board.taskKey;
224
+ const taskForm = key ? new RegExp(`^${key}-\\d+$`) : /^[A-Za-z]+-\d+$/;
225
+ const refuse = (message) => {
226
+ console.error(`${file}: ${message}`);
227
+ process.exit(1);
228
+ };
229
+ const parseSide = (side) => {
230
+ const entries = raw[side];
231
+ if (entries === undefined) {
232
+ return new Map();
233
+ }
234
+ if (Array.isArray(entries) || typeof entries !== 'object' || entries === null) {
235
+ refuse(`«${side}» записан не объектом — у записи нет места ни для причины, ни для номера задачи`);
236
+ }
237
+ const parsed = new Map();
238
+ for (const [entry, value] of Object.entries(entries)) {
239
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
240
+ refuse(`«${entry}» записан без причины — рядом с записью стоят «reason» и «task»`);
241
+ }
242
+ if (typeof value.reason !== 'string' || value.reason.trim() === '') {
243
+ refuse(`у «${entry}» пустая причина — заглушённое без причины через месяц не отличить от забытого`);
244
+ }
245
+ if (typeof value.task !== 'string' || !taskForm.test(value.task)) {
246
+ refuse(`у «${entry}» нет номера задачи вида «${key || 'КЛЮЧ'}-<номер>» — спросить о записи будет некого`);
247
+ }
248
+ parsed.set(entry, { reason: value.reason, task: value.task });
249
+ }
250
+
251
+ return parsed;
252
+ };
253
+ const parsed = Object.fromEntries(sides.map((side) => [side, parseSide(side)]));
254
+
255
+ return { ...parsed, keys: new Set(sides.flatMap((side) => [...parsed[side].keys()])) };
256
+ };
257
+
258
+ /**
259
+ * Заготовка списка принятого для режима пересъёмки: прежние записи сохраняются целиком, а
260
+ * новые приходят с пустой причиной и пустым номером задачи.
261
+ *
262
+ * Пустые поля здесь намеренны. Пересъёмка — это помощник, а не разрешение: заглушить проверку
263
+ * можно только словом владельца, и записать его должен человек. Разбор такую запись отбивает,
264
+ * поэтому список, снятый пересъёмкой и не заполненный, дальше гейта не проходит.
265
+ */
266
+ export const baselineOf = (keys, parsed, side = 'debt') => {
267
+ const entryOf = (key) => parsed.debt?.get(key) ?? parsed.accepted?.get(key) ?? { reason: '', task: '' };
268
+ const fresh = keys.filter((key) => !parsed.keys.has(key));
269
+ if (fresh.length > 0) {
270
+ console.error(`новых записей ${fresh.length} — у каждой заполняются «reason» и «task», иначе разбор списка отбивает прогон`);
271
+ }
272
+ const filled = Object.fromEntries(keys.map((key) => [key, entryOf(key)]));
273
+ const rest = Object.fromEntries([...(parsed.accepted ?? new Map())].filter(([key]) => !keys.includes(key)));
190
274
 
191
- return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {};
275
+ return JSON.stringify(side === 'accepted' ? { accepted: filled } : { accepted: rest, debt: filled }, null, 4);
192
276
  };
193
277
 
194
278
  /** Есть ли в дереве то, без чего проверке нечего делать. Нет — она выходит с нулём и говорит это. */
@@ -50,6 +50,27 @@ RT_HOOKS_TESTS="${RT_HOOKS_TESTS:-.claude/hooks/tests/run.sh}"
50
50
  # её делают, а всплывает на чужой работе — раскладка отказывает по правленому файлу целиком и не
51
51
  # кладёт ни одного другого. Признак — сама настройка раскладки: дерево без неё пакета не ставит,
52
52
  # и звать в нём нечего.
53
+ # Тронула ли ветка только тексты. Нулевой код — только тексты, иначе — код.
54
+ #
55
+ # Признак нужен тому дереву, которое дописало в набор гейта тяжёлый шаг: стенд, снимки витрин,
56
+ # сборку образов. Без него такой шаг идёт подряд, чем бы ветка ни была, и пуш коммита, менявшего
57
+ # одну строку в таблице markdown, занимает минуты — за которые владелец успевает прочитать его
58
+ # как зависший и отбить. Правило поставки требует того же от конвейера: ветка, не тронувшая ни
59
+ # строки кода, не поднимает стенда, не снимает кадров и не собирает образов.
60
+ #
61
+ # Текстовой считается ветка, у которой каждый задетый файл — либо `.md`, либо лежит под каталогом
62
+ # текстов. Всё остальное, включая обвязку и настройки, считается кодом: признак обязан ошибаться
63
+ # в сторону лишнего прогона, а не пропущенного. Пустая база означает, что сравнивать не с чем, —
64
+ # тогда признак молчит и гоняется всё.
65
+ rt_push_docs_only_default() {
66
+ [ -z "$1" ] && return 1
67
+ changed="$(git diff --name-only "$1"...HEAD 2>/dev/null)"
68
+ [ -z "$changed" ] && return 1
69
+ printf '%s\n' "$changed" | grep -qvE "(^${RT_DOCS_DIR:-docs}/|\.md\$)" && return 1
70
+
71
+ return 0
72
+ }
73
+
53
74
  rt_push_checks_default() {
54
75
  runner="$(rt_runner)"
55
76
  root="${CLAUDE_PROJECT_DIR:-.}"
@@ -101,6 +122,9 @@ rt_lint_for_default() {
101
122
  }
102
123
 
103
124
  # Каталог папок задач. Пусто — ведения работы папкой в дереве нет, и гард замысла молчит.
125
+ # Каталог текстов проекта: по нему признак состава правки отличает текстовую ветку от кодовой.
126
+ RT_DOCS_DIR="${RT_DOCS_DIR:-docs}"
127
+
104
128
  RT_TASKS_DIR="${RT_TASKS_DIR:-docs/tasks}"
105
129
 
106
130
  # Каталог записей о законченных работах. Туда переносят то, что объясняет решения закрытой
@@ -144,6 +168,11 @@ RT_SPECS_DIR="${RT_SPECS_DIR:-docs/specs}"
144
168
  # папкой задачи и с архивом. Если общего предка нет, сравнивать не с чем — проверка молчит.
145
169
  RT_MAIN_BRANCH="${RT_MAIN_BRANCH:-main}"
146
170
 
171
+ # Имя клиента хостинга в строке запуска. По нему гард перезапуска узнаёт вызов: у каждого вида
172
+ # хостинга свой клиент, и угаданное имя не совпадает ни с чем. Умолчание записано под самый
173
+ # распространённый вид; дерево с другим хостингом называет своё.
174
+ RT_HOST_CLI="${RT_HOST_CLI:-gh}"
175
+
147
176
  # Тело PR по его номеру. Обход требования пишут в PR, а в команде слияния его нет — там только
148
177
  # номер. Пустой ответ значит «спросить не у кого»: тогда обход ищется только в тексте команды.
149
178
  rt_report_body_default() {
@@ -239,6 +268,12 @@ RT_BOARD_CHECK_CMD="${RT_BOARD_CHECK_CMD:-npm run check:board}"
239
268
  # Учётная запись, которую ставят исполнителем. Умолчание молчит: у каждого дерева она своя.
240
269
  RT_TASK_BOT="${RT_TASK_BOT:-}"
241
270
 
271
+ # Команда перевода задачи между колонками очереди работ и имя первой колонки — той, из которой
272
+ # задача уходит, когда её берут в работу. Имя без умолчания: колонки дерево называет своими
273
+ # словами, а выдуманное не совпало бы ни с чем и молча выключило бы проверку колонки.
274
+ RT_TASK_MOVE_CMD="${RT_TASK_MOVE_CMD:-npm run task:move}"
275
+ RT_BOARD_BACKLOG="${RT_BOARD_BACKLOG:-}"
276
+
242
277
  # Почта, которой подписан коммит машинной учётной записи. Целым значением, а не образцом:
243
278
  # служебный адрес хостинга состоит из числа, логина и домена, а сопоставляется по числу — логин
244
279
  # рядом с ним не сверяет никто. Образец «число, плюс, логин» прошёл бы с чужим числом, то есть
@@ -268,6 +303,18 @@ rt_task_state_default() {
268
303
  printf '%s' "$state"
269
304
  }
270
305
 
306
+ # Состояние заявки одним объектом: exists, draft, author, reviewers, reviewed. Спрашивает того
307
+ # же помощника очереди работ, что и состояние задачи, — чтобы гард и сверка одинаково понимали
308
+ # «у заявки есть разбор». Нет узла, нет помощника, нет сети — молчание, и ярус пропускается.
309
+ rt_pull_state_default() {
310
+ command -v node >/dev/null 2>&1 || return 1
311
+ [ -f "${RT_BOARD_HELPER:-tools/board.mjs}" ] || return 1
312
+ state="$(node "${RT_BOARD_HELPER:-tools/board.mjs}" pr "$1" 2>/dev/null)" || return 1
313
+ [ -z "$state" ] && return 1
314
+ printf '%s' "$state" | jq -e 'has("offline") | not' >/dev/null 2>&1 || return 1
315
+ printf '%s' "$state"
316
+ }
317
+
271
318
  # Что в этом дереве считается переизобретением. По строке «образец<таб>чем заменить».
272
319
  # Образцы узкие намеренно: гард сверяет только НОВЫЙ текст, и широкий образец отбивал бы
273
320
  # правку, которая ничего нового не заводит.
@@ -306,6 +353,8 @@ rt_qa_decorative_default() {
306
353
  # Умолчания под общими именами. Надстройка проекта объявит любое из них заново — и позовёт
307
354
  # отсюда то же имя с суффиксом `_default` для всего, чего не назвала сама.
308
355
  rt_push_checks() { rt_push_checks_default "$@"; }
356
+ rt_push_docs_only() { rt_push_docs_only_default "$@"; }
357
+
309
358
  rt_docs_pair_for() { rt_docs_pair_for_default "$@"; }
310
359
  rt_lint_for() { rt_lint_for_default "$@"; }
311
360
  rt_task_branch_ok() { rt_task_branch_ok_default "$@"; }
@@ -315,5 +364,6 @@ rt_shell_writes() { rt_shell_writes_default "$@"; }
315
364
  rt_shell_paths() { rt_shell_paths_default "$@"; }
316
365
  rt_qa_decorative() { rt_qa_decorative_default "$@"; }
317
366
  rt_task_state() { rt_task_state_default "$@"; }
367
+ rt_pull_state() { rt_pull_state_default "$@"; }
318
368
  rt_report_body() { rt_report_body_default "$@"; }
319
369
  rt_handoff_allowed_cmd() { rt_handoff_allowed_cmd_default "$@"; }
@@ -30,6 +30,7 @@
30
30
  | Раскладка | Перенос ресурса из пакета в дерево по его роду и настройке слоя |
31
31
  | Разложенный файл | Файл в дереве с шапкой пакета. Правится не на месте, а надстройкой: правка на месте теряется на следующей раскладке |
32
32
  | Надстройка | Файл дерева, который сливается с разложенным по заголовкам разделов |
33
+ | Выключенная роль | Роль, вызов которой дерево перестало считать обязательным: гард при ней молчит. Названа списком в настройке дерева, из раскладки не убирается и зовётся руками |
33
34
  | Наблюдение | Строка о событии слоя правил: правило загружено, гейт отбил, гард отказал. Пишет гард, живёт в дереве, наружу уезжает счётчиками. Журналом не называется |
34
35
  | Сводка | Что наблюдения говорят за отрезок дней: чем пользовались, чем ни разу, обо что спотыкались |
35
36
  | Предложение | Готовая формулировка правки правил с адресом: пакет, компаньон или дерево. Приносит разбор закрытой задачи или слово посреди работы, отправляет человек командой |
@@ -37,21 +38,22 @@
37
38
 
38
39
  ## Работа
39
40
 
40
- | Термин | Что это |
41
- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
42
- | Задача | Единица работы в очереди работ. Заводится до ветки, и номер её стоит в имени ветки и в заголовке PR |
43
- | PR | Заявка на слияние: то же название, что у задачи, переведённое в сделанное. Отчётом, пул-реквестом и мёрдж-реквестом не называется — ни в файлах, ни в разговоре |
44
- | Очередь работ | Доска, на которой видно состояние каждой задачи. Ветки она не видит |
45
- | Папка задачи | Одна работа от разбора до слияния: разбор просьбы, замысел, ход работы. Умирает со слиянием — разбирается, и объясняющее решение уезжает в архив |
46
- | Разбор | Расспрос владельца до первой правки. Записывается его словами и задним числом не переписывается |
47
- | Замысел | Файл папки задачи: след задачи и этапы с признаками готовности. После написания не правится — с ним сверяют результат при приёмке |
48
- | Ход работы | Файл папки задачи: «Где стоим», решения по ходу с причинами, записи заходов. Единственное место, где отмечается сделанное. Журналом не называется |
49
- | След задачи | Раздел замысла: какие спеки, законы, правила и части кода работа задевает |
50
- | Заход | Одна сессия работы над задачей. Работа живёт дольше одного захода, и между ними её состояние держит только ход работы |
51
- | Заполнение окна | Доля места захода, которую он уже занял: вход, запись в кэш, прочитанное из кэша и вывод последнего ответа, делённые на размер окна. Не «расход» и не «бюджет»: речь о месте, а не о деньгах |
52
- | Передача | Текст, которым заход закрывается: рабочее дерево, ветка, задача, где лежит ход работы, что сделано, следующий шаг, особенности захода. Кладётся вне дерева и не коммитится |
53
- | Эпик | Серия задач одной темы, выполняемых в назначенном порядке. Живёт в двух местах сразу: карточка в очереди работ с меткой эпика и замысел рядом с ней — что за возможность разрабатывается, какие задачи входят и в каком порядке. Шире одной ветки. Линией работ не называется |
54
- | Архив | Записи о состоявшемся: что объясняет закрытое решение. После выкатки не правится |
41
+ | Термин | Что это |
42
+ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
43
+ | Задача | Единица работы в очереди работ. Заводится до ветки, и номер её стоит в имени ветки и в заголовке PR |
44
+ | PR | Заявка на слияние: то же название, что у задачи, переведённое в сделанное. Отчётом, пул-реквестом и мёрдж-реквестом не называется — ни в файлах, ни в разговоре |
45
+ | Очередь работ | Доска, на которой видно состояние каждой задачи. Ветки она не видит |
46
+ | Папка задачи | Одна работа от разбора до слияния: разбор просьбы, замысел, ход работы. Умирает со слиянием — разбирается, и объясняющее решение уезжает в архив |
47
+ | Разбор | Расспрос владельца до первой правки. Записывается его словами и задним числом не переписывается |
48
+ | Замысел | Файл папки задачи: след задачи и этапы с признаками готовности. После написания не правится — с ним сверяют результат при приёмке |
49
+ | Ход работы | Файл папки задачи: «Где стоим», решения по ходу с причинами, записи заходов. Единственное место, где отмечается сделанное. Журналом не называется |
50
+ | Состояние работы | Единица, которой работа ведётся: у каждого состояния названы вход, обязательное действие и выход. Объявляется машиночитаемой строкой в разделе «Где стоим» хода работы — гард судит объявленный переход, а не наличие файлов |
51
+ | След задачи | Раздел замысла: какие спеки, законы, правила и части кода работа задевает |
52
+ | Заход | Одна сессия работы над задачей. Работа живёт дольше одного захода, и между ними её состояние держит только ход работы |
53
+ | Заполнение окна | Доля места захода, которую он уже занял: вход, запись в кэш, прочитанное из кэша и вывод последнего ответа, делённые на размер окна. Не «расход» и не «бюджет»: речь о месте, а не о деньгах |
54
+ | Передача | Текст, которым заход закрывается: рабочее дерево, ветка, задача, где лежит ход работы, что сделано, следующий шаг, особенности захода. Кладётся вне дерева и не коммитится |
55
+ | Эпик | Серия задач одной темы, выполняемых в назначенном порядке. Живёт в двух местах сразу: карточка в очереди работ с меткой эпика и замысел рядом с ней — что за возможность разрабатывается, какие задачи входят и в каком порядке. Шире одной ветки. Линией работ не называется |
56
+ | Архив | Записи о состоявшемся: что объясняет закрытое решение. После выкатки не правится |
55
57
 
56
58
  ## Проверки
57
59
 
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # rt-hook: PreToolUse mcp__claude-in-chrome__select_browser
3
+ # Требует: hooks/deny-tail.sh
3
4
  # Гард выбора браузера. PreToolUse на выборе браузера расширением.
4
5
  #
5
6
  # Отклоняет любой профиль, кроме закреплённого: чужой стоит лишнего круга и приводит в браузер,
@@ -24,5 +25,12 @@ if [ "$requested" = "$device_id" ]; then
24
25
  exit 0
25
26
  fi
26
27
 
27
- echo "Профиль «${requested}» не тот, что закреплён за проектом. Бери ${device_id} единственный профиль, где сделан вход." >&2
28
+ # Общий хвост отказа: два законных хода и законная форма обхода, если она у отказа есть. Файл
29
+ # может быть не разложен — тогда хвоста нет, а причина отказа остаётся прежней.
30
+ # shellcheck disable=SC1090
31
+ [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
32
+ && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
33
+ command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
34
+
35
+ echo "Профиль «${requested}» не тот, что закреплён за проектом. Бери ${device_id} — единственный профиль, где сделан вход. $(rt_deny_tail)" >&2
28
36
  exit 2
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # rt-hook: PreToolUse AskUserQuestion
3
+ # Требует: hooks/deny-tail.sh
3
4
  # PreToolUse guard for AskUserQuestion.
4
5
  #
5
6
  # The browser choice is already made and pinned in browser-device-id.sh — asking again is noise, and the
@@ -23,5 +24,12 @@ questions="$(printf '%s' "$input" | jq -r '[.tool_input.questions[]? | .question
23
24
 
24
25
  printf '%s' "$questions" | grep -qiE 'deviceid|device id|(which|what|pick|choose|select)[^.]{0,25}\bbrowser\b|\bbrowser\b[^.]{0,25}(profile|to use)|как(ой|ую)?[^.]{0,15}браузер|брауз[а-я]*[^.]{0,20}(использовать|выбрать|выбор|нужен|запустить)' || exit 0
25
26
 
26
- echo "Do not ask which browser to use the profile is pinned. Call select_browser with deviceId ${device_id} ('Main' profile)." >&2
27
+ # Общий хвост отказа: два законных хода и законная форма обхода, если она у отказа есть. Файл
28
+ # может быть не разложен — тогда хвоста нет, а причина отказа остаётся прежней.
29
+ # shellcheck disable=SC1090
30
+ [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
31
+ && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
32
+ command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
33
+
34
+ echo "Do not ask which browser to use — the profile is pinned. Call select_browser with deviceId ${device_id} ('Main' profile). $(rt_deny_tail)" >&2
27
35
  exit 2
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # rt-hook: PreToolUse mcp__claude-in-chrome__(list_connected_browsers|switch_browser)
3
+ # Требует: hooks/deny-tail.sh
3
4
  # Гард перечисления и переключения браузеров. PreToolUse.
4
5
  #
5
6
  # Сессии проекта живут в одном закреплённом профиле. Перечисление и переключение отдают общие
@@ -14,5 +15,12 @@ cat >/dev/null 2>&1
14
15
  device_id="$("${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/browser-device-id.sh" 2>/dev/null)"
15
16
  [ -z "$device_id" ] && exit 0
16
17
 
17
- echo "Не перечисляй и не переключай браузеры. Вызови выбор браузера с профилем ${device_id} единственным, где сделан вход." >&2
18
+ # Общий хвост отказа: два законных хода и законная форма обхода, если она у отказа есть. Файл
19
+ # может быть не разложен — тогда хвоста нет, а причина отказа остаётся прежней.
20
+ # shellcheck disable=SC1090
21
+ [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
22
+ && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
23
+ command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
24
+
25
+ echo "Не перечисляй и не переключай браузеры. Вызови выбор браузера с профилем ${device_id} — единственным, где сделан вход. $(rt_deny_tail)" >&2
18
26
  exit 2
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # rt-hook: PreToolUse mcp__playwright__.*|mcp__chrome-devtools__.*|Bash
3
+ # Требует: hooks/deny-tail.sh
3
4
  # Гард обходных путей к браузеру. PreToolUse.
4
5
  #
5
6
  # Закрепление профиля чего-то стоит только тогда, когда дверь одна. Здесь перечислены двери,
@@ -19,8 +20,13 @@ device_id="$("${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/browser-device-id.sh" 2>/de
19
20
 
20
21
  tool="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null)"
21
22
 
23
+ # shellcheck disable=SC1090
24
+ [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
25
+ && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
26
+ command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
27
+
22
28
  deny() {
23
- echo "$1 Води браузер закреплённым расширением: выбери профиль ${device_id} и работай его инструментами." >&2
29
+ echo "$1 Води браузер закреплённым расширением: выбери профиль ${device_id} и работай его инструментами. $(rt_deny_tail)" >&2
24
30
  exit 2
25
31
  }
26
32
 
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # rt-hook: PreToolUse mcp__claude-in-chrome__.*
3
+ # Требует: hooks/deny-tail.sh
3
4
  # Гард свежести выбора браузера. PreToolUse на всех остальных вызовах расширения.
4
5
  #
5
6
  # ЗАЧЕМ ОН ЕСТЬ — отказ, из которого он вырос: расширение действует на тот браузер, который
@@ -35,8 +36,16 @@ ttl=300
35
36
  sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"' 2>/dev/null)"
36
37
  marker="${TMPDIR:-/tmp}/claude-browser-guard/${sid}"
37
38
 
39
+ # Общий хвост отказа: два законных хода и законная форма обхода, если она у отказа есть. Файл
40
+ # может быть не разложен — тогда хвоста нет, а причина отказа остаётся прежней.
41
+ # shellcheck disable=SC1090
42
+ [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
43
+ && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
44
+ command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
45
+
46
+
38
47
  if [ ! -f "$marker" ]; then
39
- echo "В этой сессии браузер не выбран. Вызови выбор браузера с профилем ${device_id} до любого другого вызова." >&2
48
+ echo "В этой сессии браузер не выбран. Вызови выбор браузера с профилем ${device_id} до любого другого вызова. $(rt_deny_tail)" >&2
40
49
  exit 2
41
50
  fi
42
51
 
@@ -46,7 +55,7 @@ age=$(( now - stamped ))
46
55
 
47
56
  if [ "$age" -gt "$ttl" ]; then
48
57
  rm -f "$marker" 2>/dev/null
49
- echo "Последний выбор браузера был ${age} с назад (предел ${ttl} с) — на таких перерывах активный браузер расширения уплывает, и это может быть уже не закреплённый профиль. Вызови выбор с профилем ${device_id} заново и повтори." >&2
58
+ echo "Последний выбор браузера был ${age} с назад (предел ${ttl} с) — на таких перерывах активный браузер расширения уплывает, и это может быть уже не закреплённый профиль. Вызови выбор с профилем ${device_id} заново и повтори. $(rt_deny_tail)" >&2
50
59
  exit 2
51
60
  fi
52
61
 
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env bash
2
+ # rt-hook: Stop
3
+ # Гард утверждения: сказанное владельцу о состоянии дерева несёт команду, показавшую это. Stop.
4
+ #
5
+ # Зачем именно так. Правило текстов само говорит, что ответ владельцу не читает ни одна
6
+ # проверка: за файл отвечает гейт, за ответ — автор, и цену ошибки в ответе платит владелец.
7
+ # Восемь разборов подряд про одно и то же — готовым названо лежащее на диске, снятыми названы
8
+ # только локальные ветки, проверенным назван набор уже конвейерного, состоянием дерева названа
9
+ # отставшая локальная ссылка. Каждый раз в правило дописывалась статья, и каждый раз промах
10
+ # повторялся: текст читают в начале захода, а утверждение говорят в конце.
11
+ #
12
+ # Что судится: текст, сказанный владельцу за этот ход. Вывод инструмента и записанное в файл
13
+ # сюда не входят — их читают гейт и сверки.
14
+ #
15
+ # Как судится: у каждого слова-утверждения назван свой род команды. Утверждение о зелёном
16
+ # наборе подтверждает прогон набора, о запушенном — вызов пуша, о снятом — вызов удаления.
17
+ # Команда ищется в этом же ходе: состояние дерева меняется, и вывод прошлого хода о нынешнем
18
+ # уже не говорит.
19
+ #
20
+ # Чего гард не судит. Неверный вывод: об образце, судимом по одному его файлу, и о пути, которым
21
+ # человек не пойдёт, машине судить нечем — там нет ни слова-утверждения, ни команды, с которой
22
+ # сверять. Эти случаи держит правило ведения работы, и это известная граница гарда, а не
23
+ # обещание.
24
+ #
25
+ # ОТКАЗ В ПОЛЬЗУ РАБОТЫ: при любой ошибке, нехватке `jq`, отсутствии записи хода или пустом
26
+ # тексте ход РАЗРЕШАЕТСЯ (exit 0). Сломанный гард не имеет права заклинить разговор.
27
+
28
+ input="$(cat 2>/dev/null)"
29
+ [ -z "$input" ] && exit 0
30
+ command -v jq >/dev/null 2>&1 || exit 0
31
+
32
+ # Повторный заход по тому же ходу не судится: гард сказал своё один раз и отпускает.
33
+ active="$(printf '%s' "$input" | jq -r '.stop_hook_active // false' 2>/dev/null)"
34
+ [ "$active" = "true" ] && exit 0
35
+
36
+ transcript="$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null)"
37
+ [ -z "$transcript" ] && exit 0
38
+ [ -f "$transcript" ] || exit 0
39
+
40
+ # Ход — всё, что записано после последней настоящей реплики владельца: ответ инструмента
41
+ # приходит той же ролью и репликой не считается.
42
+ turn="$(tail -n 400 "$transcript" 2>/dev/null | jq -s -r '
43
+ def is_input:
44
+ .type == "user"
45
+ and (((.message.content // []) | if type == "array"
46
+ then ([.[] | select(.type == "tool_result")] | length)
47
+ else 0 end) == 0);
48
+
49
+ (map(is_input) | rindex(true)) as $i
50
+ | (if $i == null then [] else .[$i:] end) as $turn
51
+ | [$turn[] | select(.type == "assistant") | (.message.content // [])[]
52
+ | select(.type == "text") | (.text // "")] as $said
53
+ | [$turn[] | select(.type == "assistant") | (.message.content // [])[]
54
+ | select(.type == "tool_use") | (.input.command // "")] as $ran
55
+ | { said: ($said | join("\n")), ran: ($ran | join("\n")) }
56
+ ' 2>/dev/null)"
57
+
58
+ [ -z "$turn" ] && exit 0
59
+
60
+ said="$(printf '%s' "$turn" | jq -r '.said // ""' 2>/dev/null)"
61
+ ran="$(printf '%s' "$turn" | jq -r '.ran // ""' 2>/dev/null)"
62
+ [ -z "$said" ] && exit 0
63
+
64
+ # Карта утверждений: что сказано о дереве | чем это показывают | что назвать в отказе.
65
+ #
66
+ # Слово берётся в той форме, в какой его говорят владельцу. Будущее время сюда не идёт:
67
+ # «проверю» и «запущу» утверждениями не являются — они обещание, и врать им нечем.
68
+ claims=(
69
+ 'проверено|прогнал[а]?|тесты (зелёные|прошли)|линт(ер)? (зелёный|прошёл|чистый)|сборка (зелёная|прошла)|набор зелёный|проверки зелёные|спеки зелёные|всё зелен(о|ое)§nx (test|lint|build|run|affected|run-many)|npm (run|test)|pnpm (run|exec|test)|jest|vitest|playwright|check:|run\.sh§команду набора — прогон тестов, линта или сборки'
70
+ 'запушен[аоы]?|запушил[а]?|пуш прошёл|ветка уехала§git push§`git push`'
71
+ '(PR|правка|ветка|работа)[^.]{0,20}(влит|слит|смержен)|влит[оа] в|слит[оа] в§git merge|gh pr merge§`git merge` или `gh pr merge`'
72
+ '(ветки|ветка|файлы|файл|папка|каталог)[^.]{0,40}(снят|удал|почищ|вычищ)|снят[оыа] с§git branch|git push .*--delete|git rm|gh api|rm §команду удаления — `git branch -d`, `git push --delete` или `git rm`'
73
+ 'прогон (зелёный|прошёл|кончился)|конвейер зелёный|проверки на PR зелёные§gh run§`gh run list` или `gh run view`'
74
+ '(работа|правка|задача) готова|можно вливать|PR открыт|черновик снят§gh pr §`gh pr create`, `gh pr view` или `gh pr ready`'
75
+ 'задача заведена|задача (в|переведена в) колонк|колонка переведена§gh issue|gh api|task:new|task:move|board\.mjs§команду очереди работ — заведение задачи или перевод колонки'
76
+ '(в дереве|в репозитории|здесь|такого файла|такой команды)[^.]{0,30}(нет|не бывает)|не заводили|нигде не встречается§grep|rg |ls |find |git ls-files|git grep|git log|cat §команду поиска — `grep`, `git ls-files` или обход каталога'
77
+ )
78
+
79
+ for row in "${claims[@]}"; do
80
+ words="${row%%§*}"
81
+ rest="${row#*§}"
82
+ proof="${rest%%§*}"
83
+ name="${rest#*§}"
84
+
85
+ found="$(printf '%s' "$said" | grep -oiE "$words" 2>/dev/null | head -1)"
86
+ [ -z "$found" ] && continue
87
+ printf '%s' "$ran" | grep -qiE "$proof" 2>/dev/null && continue
88
+
89
+ reason="BLOCKED by claim-guard: владельцу сказано «${found}» — это утверждение о состоянии дерева, а команды, которая его показывает, за этот ход не было.
90
+
91
+ Утверждение о дереве стоит ровно столько, сколько команда, его показавшая: сказанное без команды владелец читает как проверенный факт и узнаёт о расхождении последним.
92
+
93
+ Запусти ${name} этим же ходом и назови её вывод — либо убери утверждение из ответа.
94
+
95
+ Гард судит один ход: следующий заход не отбивается."
96
+
97
+ # Общий хвост отказа: два законных хода. Файл может быть не разложен — тогда хвоста нет,
98
+ # а причина отказа остаётся прежней.
99
+ # shellcheck disable=SC1090
100
+ [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
101
+ && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
102
+ command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
103
+ deny_tail_text="$(rt_deny_tail "")"
104
+ [ -n "$deny_tail_text" ] && reason="${reason}
105
+
106
+ ${deny_tail_text}"
107
+
108
+ jq -n --arg r "$reason" '{decision:"block",reason:$r}' 2>/dev/null \
109
+ || printf '{"decision":"block","reason":"claim-guard: утверждение о дереве не подтверждено командой."}\n'
110
+ exit 0
111
+ done
112
+
113
+ exit 0
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env bash
2
+ # rt-hook: Stop
3
+ # Требует: agents/conscience.md, hooks/roles.sh, hooks/deny-tail.sh
4
+ # Гард совести: ход, в котором роль совести нашла повтор разобранного промаха, не заканчивается,
5
+ # пока повтор не разобран или не назван владельцу.
6
+ #
7
+ # Зачем именно так. Разбор происшествия объясняет механизм промаха, но читает его только тот, кто
8
+ # открывает каталог сам. Промах, о котором надо напомнить, — ровно тот, о котором исполнитель в
9
+ # эту минуту не помнит, поэтому вызов роли не оставляют на его усмотрение: он не случится там,
10
+ # где нужнее всего.
11
+ #
12
+ # Роль отвечает первой строкой: «СОВЕСТЬ: повтор» либо «СОВЕСТЬ: чисто». Гард судит последний
13
+ # ответ за ход и ничего не знает о том, верна ли находка: это решает исполнитель, и его решение
14
+ # — работа следующего хода, а не молчание этого.
15
+ #
16
+ # Ход отпускается, когда после находки исполнитель сделал хоть что-то по ней: завёл разбор
17
+ # происшествия, поправил работу или назвал повтор владельцу. Проверяется это по тому же ходу.
18
+ #
19
+ # ОТКАЗ В ПОЛЬЗУ РАБОТЫ: нет `jq`, нет записи хода, роль молчит или отвечает не по форме — ход
20
+ # разрешается. Сломанная совесть не имеет права заклинить разговор.
21
+
22
+ input="$(cat 2>/dev/null)"
23
+ [ -z "$input" ] && exit 0
24
+ command -v jq >/dev/null 2>&1 || exit 0
25
+
26
+ # Роль, выключенная деревом, гарда не держит: список выключенных лежит в настройке дерева, а
27
+ # читает его помощник рядом. Нечитаемая настройка выключением не считается — гард работает как
28
+ # прежде.
29
+ rt_hooks_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
30
+ # shellcheck disable=SC1090
31
+ [ -f "$rt_hooks_dir/roles.sh" ] && . "$rt_hooks_dir/roles.sh" 2>/dev/null
32
+ command -v rt_role_off >/dev/null 2>&1 && rt_role_off conscience && exit 0
33
+
34
+ active="$(printf '%s' "$input" | jq -r '.stop_hook_active // false' 2>/dev/null)"
35
+ [ "$active" = "true" ] && exit 0
36
+
37
+ transcript="$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null)"
38
+ [ -z "$transcript" ] && exit 0
39
+ [ -f "$transcript" ] || exit 0
40
+
41
+ # Ход — всё, что записано после последней настоящей реплики владельца. Ответ инструмента
42
+ # приходит той же ролью, поэтому строки с `tool_result` репликой не считаются.
43
+ verdict="$(tail -n 400 "$transcript" 2>/dev/null | jq -s -r '
44
+ def is_input:
45
+ .type == "user"
46
+ and (((.message.content // []) | if type == "array"
47
+ then ([.[] | select(.type == "tool_result")] | length)
48
+ else 0 end) == 0);
49
+
50
+ (map(is_input) | rindex(true)) as $i
51
+ | (if $i == null then [] else .[$i:] end) as $turn
52
+ | [ $turn[]
53
+ | if .type == "assistant"
54
+ then ([(.message.content // [])[]
55
+ | if .type == "tool_use" then (.input.command // "") else (.text // "") end] | join("\n"))
56
+ elif .type == "user"
57
+ then ([(.message.content // []) | select(type == "array") | .[]
58
+ | select(.type == "tool_result") | .content
59
+ | if type == "string" then . elif type == "array"
60
+ then (map(if type == "object" then (.text // "") else tostring end) | join("\n"))
61
+ else tostring end] | join("\n"))
62
+ else "" end ] as $flow
63
+ | ($flow | map(test("СОВЕСТЬ:[[:space:]]*повтор")) | index(true)) as $found
64
+ | if $found == null then "нет-находки"
65
+ else ($flow[($found + 1):] | join("\n")
66
+ | if test("postmortems|разбор происшествия|СОВЕСТЬ: разобрано") then "разобрано" else "висит" end)
67
+ end
68
+ ' 2>/dev/null)"
69
+
70
+ [ "$verdict" = "висит" ] || exit 0
71
+
72
+ detail="$(tail -n 400 "$transcript" 2>/dev/null | grep -m1 -A3 'СОВЕСТЬ:[[:space:]]*повтор' | tr -d '\\"' | head -4)"
73
+
74
+ reason="BLOCKED by conscience-guard: совесть нашла в этом ходе повтор разобранного промаха, и по нему не сделано ничего.
75
+
76
+ ${detail}
77
+
78
+ Ход не кончается на находке. Сделай одно из трёх этим же ходом: поправь работу, заведи разбор происшествия, если механизм новый, или назови повтор владельцу словами — что повторяется и чем это кончилось в прошлый раз.
79
+
80
+ Находка неверна — так и скажи владельцу: ложная находка тоже стоит хода, и молчанием она не чинится.
81
+
82
+ Гард судит один ход: следующий заход не отбивается."
83
+
84
+ # Общий хвост отказа: два законных хода. Файл может быть не разложен — тогда хвоста нет,
85
+ # а причина отказа остаётся прежней.
86
+ # shellcheck disable=SC1090
87
+ [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
88
+ && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
89
+ command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
90
+ deny_tail_text="$(rt_deny_tail "")"
91
+ [ -n "$deny_tail_text" ] && reason="${reason}
92
+
93
+ ${deny_tail_text}"
94
+
95
+ jq -n --arg r "$reason" '{decision:"block",reason:$r}' 2>/dev/null \
96
+ || printf '{"decision":"block","reason":"conscience-guard: найден повтор разобранного промаха — разбери его или назови владельцу."}\n'
97
+
98
+ exit 0