@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.
- package/README.md +8 -2
- package/assets/checks/board.github.mjs +1 -1
- package/assets/checks/check-board.github.mjs +14 -0
- package/assets/checks/check-doc-paths.mjs +24 -5
- package/assets/checks/check-file-size.mjs +8 -2
- package/assets/checks/check-prose-style.mjs +10 -1
- package/assets/defaults/gate-map.sh +13 -0
- package/assets/defaults/project.sh +10 -0
- package/assets/defaults/shell.sh +18 -3
- package/assets/hooks/browser-guard-device-id.sh +42 -12
- package/assets/hooks/dispatch.sh +40 -9
- package/assets/hooks/docs-guard.sh +10 -0
- package/assets/hooks/exam-guard.sh +66 -16
- package/assets/hooks/git-guard-delivery-draft.sh +78 -0
- package/assets/hooks/git-guard-delivery.sh +49 -111
- package/assets/hooks/git-guard-main.sh +39 -4
- package/assets/hooks/git-guard-push-tests.sh +49 -3
- package/assets/hooks/hook-input.sh +17 -6
- package/assets/hooks/rule-source-guard.sh +11 -0
- package/assets/hooks/stand-login-guard.sh +101 -0
- package/assets/hooks/write-targets.sh +37 -4
- package/assets/laws/verifiability.md +12 -2
- package/assets/laws/work-conduct.md +59 -65
- package/assets/patterns/browser-verification-measure.md +41 -1
- package/assets/patterns/browser-verification-stand.md +56 -15
- package/assets/patterns/doc-style-human.md +75 -0
- package/assets/patterns/git-workflow-commit.azure.md +12 -0
- package/assets/patterns/git-workflow-commit.github.md +16 -3
- package/assets/patterns/git-workflow-commit.gitlab.md +12 -0
- package/assets/patterns/git-workflow-merge.md +8 -0
- package/assets/patterns/task-flow-start.md +1 -1
- package/assets/patterns/testing-e2e.md +18 -8
- package/assets/pitfalls/task-flow.md +40 -40
- package/assets/rules/browser-verification.md +29 -3
- package/assets/rules/doc-style.md +36 -0
- package/assets/rules/git-workflow.github.md +20 -23
- package/assets/rules/reuse-first.md +8 -2
- package/assets/rules/styling-bem.md +8 -1
- package/assets/rules/task-flow.md +73 -73
- package/assets/rules/testing.md +21 -0
- package/assets/skills/agent-kit.md +60 -70
- package/lib/commands.d.ts.map +1 -1
- package/lib/commands.js +63 -2
- package/lib/commands.js.map +1 -1
- package/lib/enroll.d.ts.map +1 -1
- package/lib/enroll.js +1 -1
- package/lib/enroll.js.map +1 -1
- package/lib/observations.d.ts +10 -1
- package/lib/observations.d.ts.map +1 -1
- package/lib/observations.js +1 -0
- package/lib/observations.js.map +1 -1
- package/lib/override-marks.d.ts +24 -0
- package/lib/override-marks.d.ts.map +1 -0
- package/lib/override-marks.js +98 -0
- package/lib/override-marks.js.map +1 -0
- package/lib/shipment.d.ts.map +1 -1
- package/lib/shipment.js +1 -1
- package/lib/shipment.js.map +1 -1
- package/package.json +1 -1
- package/rt-tools-agent-kit-0.24.0.tgz +0 -0
- 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
|
|
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
|
-
|
|
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
|
-
[/(?<![а-яёА-ЯЁ])данн(
|
|
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
|
# заголовок пишется языком заявки, а чужих слов пакет не знает: не названный деревом, раздел не
|
package/assets/defaults/shell.sh
CHANGED
|
@@ -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|\
|
|
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
|
-
# Гард выбора браузера.
|
|
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
|
package/assets/hooks/dispatch.sh
CHANGED
|
@@ -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
|
-
[[ "$
|
|
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
|
-
|
|
81
|
-
|
|
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" ]
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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` из-под него выведена. Загруженное правило и прочитанное правило — разные вещи, и цену этой разницы платит владелец."
|