@rt-tools/agent-kit 0.5.2 → 0.6.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 (52) hide show
  1. package/README.md +34 -6
  2. package/assets/checks/check-file-size.mjs +127 -0
  3. package/assets/checks/rt-kit-checks.config.mjs +10 -0
  4. package/assets/defaults/gate-map.sh +90 -34
  5. package/assets/defaults/project.sh +26 -3
  6. package/assets/hooks/skill-gate-layers.sh +156 -0
  7. package/assets/hooks/skill-gate.sh +11 -2
  8. package/assets/laws/code-structure.md +3 -0
  9. package/assets/laws/delivery.md +9 -0
  10. package/assets/laws/observability.md +46 -0
  11. package/assets/laws/project-documentation.md +4 -0
  12. package/assets/laws/reuse-first.md +2 -0
  13. package/assets/laws/verifiability.md +5 -0
  14. package/assets/patterns/browser-verification-stand.md +22 -2
  15. package/assets/patterns/doc-style-trace.md +111 -0
  16. package/assets/patterns/git-workflow-commit.github.md +1 -1
  17. package/assets/patterns/git-workflow-docker.md +203 -0
  18. package/assets/patterns/git-workflow-secrets.md +93 -0
  19. package/assets/patterns/observability-record.md +114 -0
  20. package/assets/patterns/ownership-session-procedure.md +102 -0
  21. package/assets/patterns/seo-verify.md +1 -1
  22. package/assets/patterns/spec-driven-rule.md +5 -0
  23. package/assets/patterns/styling-bem-sheet.md +178 -0
  24. package/assets/patterns/task-flow-close.md +20 -0
  25. package/assets/patterns/task-flow-resume.md +5 -0
  26. package/assets/patterns/translations-content.md +107 -0
  27. package/assets/patterns/translations-key.md +1 -1
  28. package/assets/rules/angular-patterns.md +5 -0
  29. package/assets/rules/browser-verification.md +17 -12
  30. package/assets/rules/component-structure.md +6 -2
  31. package/assets/rules/doc-style.md +16 -0
  32. package/assets/rules/git-workflow.azure.md +45 -1
  33. package/assets/rules/git-workflow.github.md +52 -1
  34. package/assets/rules/git-workflow.gitlab.md +46 -1
  35. package/assets/rules/lists.md +13 -0
  36. package/assets/rules/observability.md +147 -0
  37. package/assets/rules/ownership-scope.md +5 -2
  38. package/assets/rules/ownership-session.md +124 -0
  39. package/assets/rules/permissions.md +23 -0
  40. package/assets/rules/pricing.md +4 -0
  41. package/assets/rules/reuse-first.md +9 -0
  42. package/assets/rules/seo.md +57 -9
  43. package/assets/rules/shared-code.md +6 -0
  44. package/assets/rules/spec-driven.md +9 -0
  45. package/assets/rules/styling-bem.md +34 -1
  46. package/assets/rules/task-flow.md +5 -0
  47. package/assets/rules/testing.md +46 -8
  48. package/assets/rules/translations.md +11 -5
  49. package/assets/rules/typescript-conventions.md +5 -0
  50. package/package.json +1 -1
  51. package/rt-tools-agent-kit-0.6.0.tgz +0 -0
  52. package/rt-tools-agent-kit-0.5.2.tgz +0 -0
package/README.md CHANGED
@@ -325,13 +325,41 @@ gh label create agent-kit-feedback --description 'Предложение по с
325
325
  Роль вопросов владельцу не задаёт: ни она, ни конвейер до него не достучатся. Поэтому разбор
326
326
  ведёт главный агент, а роли стоят по обе стороны от него — разведка до, конвейер после.
327
327
 
328
- `skill-curator` приезжает и слеш-командой: она собирает сводку о задаче и список загруженных
329
- правил, без которых разбор выродится в пересказ.
328
+ `skill-curator` приезжает и слеш-командой о ней и остальных двух ниже.
330
329
 
331
- Вторая команда, `next-session`, закрывает заход: приводит дерево к главной ветке — переходом на
332
- неё, если работа шла по правилу и отчёт влит, и вливанием в текущую ветку во всех прочих
333
- случаях, снимает влитые локальные ветки, называет невлитые и пишет передачу для следующего
334
- захода. Незакоммиченная правка её останавливает до первого действия; поставки она не касается.
330
+ ## Слеш-команды
331
+
332
+ Род `commands` ложится в `.claude/commands/`, по файлу на команду. **Имя команды это имя
333
+ файла:** `next-session.md` зовётся `/next-session`. Больше нигде команды не объявляются
334
+ перечня, который пришлось бы держать в согласии с каталогом, нет вовсе. Строка `description:` во
335
+ вступлении файла — то единственное, что агент видит до вызова и по чему решает, брать ли
336
+ команду; `argument-hint:` говорит, что она принимает.
337
+
338
+ | Команда | Ресурс | Что делает |
339
+ | ------------------- | ------------------------------ | -------------------------------------------------------------- |
340
+ | `/next-session` | `commands/next-session.md` | закрывает заход: главная ветка, влитые ветки, передача |
341
+ | `/skill-curator` | `commands/skill-curator.md` | разбирает закрытую задачу и выгружает предложения файлом |
342
+ | `/agent-kit-digest` | `commands/agent-kit-digest.md` | сводит накопленные предложения и наблюдения в правки ресурсов |
343
+
344
+ `/next-session` приводит дерево к главной ветке — переходом на неё, если работа шла по правилу и
345
+ отчёт влит, и вливанием в текущую ветку во всех прочих случаях, — снимает влитые локальные
346
+ ветки, называет невлитые и пишет передачу для следующего захода. Незакоммиченная правка
347
+ останавливает её до первого действия; поставки она не касается.
348
+
349
+ `/skill-curator` — та же роль, приехавшая командой: она собирает сводку о задаче и список
350
+ загруженных за неё правил, без которых разбор выродится в пересказ, и кладёт предложения файлом
351
+ с адресом в заголовке.
352
+
353
+ `/agent-kit-digest` зовётся **в репозитории самого пакета**, а не в дереве, где он стоит: там
354
+ лежат ресурсы, которые предстоит править, и видно всех потребителей сразу.
355
+
356
+ Разложенная команда правится как любой разложенный файл — не на месте: либо ресурс в пакете,
357
+ либо надстройка `.claude/rt-kit/overrides/commands/<имя>.md`. Свою команду дерево заводит файлом
358
+ рядом; файл без шапки раскладки пакет чужим считает и не перезаписывает.
359
+
360
+ Слешем зовётся и то, что командой не является: скилы — правила, паттерны и скилы без закона из
361
+ `.claude/skills/` — и конвейеры из `.claude/workflows/`. Что из всего этого разложено и откуда
362
+ взялось, говорят `npx agent-kit list` и `npx agent-kit doctor`.
335
363
 
336
364
  ## Как этим пользуются в дереве
337
365
 
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Проверка того, что файл не длиннее предела.
4
+ *
5
+ * Файл, который не влезает на экран целиком, читают по частям, и правку в нём
6
+ * делают, не увидев остального. У кода длину стережёт линтер; здесь — всё, до
7
+ * чего он не доходит: проза, стили, шаблоны, обвязка разработки и сами гарды.
8
+ *
9
+ * Судятся `.md`, `.scss`, `.html`, `.js`, `.mjs` и `.sh`. Данные не судятся
10
+ * вовсе: словарь локали и настройка сборки читаются поиском, а не подряд, и
11
+ * делить их не на что. Код на языке, где длину стережёт линтер, тоже не
12
+ * судится — два отказа на один файл читаются как две разные претензии.
13
+ *
14
+ * Строки считаются все, включая пустые и комментарии, и тем же способом, каким
15
+ * их считает линтер: по числу разрывов плюс один. Файл, кончающийся переводом
16
+ * строки, поэтому весит на строку больше, чем показывает `wc -l`, — зато у обеих
17
+ * проверок дерева одно понятие длины.
18
+ *
19
+ * Описание прошлого из счёта выведено: архив по устройству перечисляет то, чего
20
+ * в дереве уже нет, а папка задачи умирает со слиянием. Сгенерированное выведено
21
+ * каталогом: его переписывает генератор целиком, и спорить с ним о длине некому.
22
+ *
23
+ * Накопленное к моменту заведения проверки лежит в списке известного и отказом
24
+ * не считается: гейт падает на НОВОМ длинном файле, а старое остаётся видимым
25
+ * числом в сводке. Принятое и долг там различаются: принятое дерево делить не
26
+ * собирается, на долг заведена работа. Строка снимается вместе с делением своего
27
+ * файла, и проверка сама говорит, какую строку пора убрать.
28
+ *
29
+ * Ненулевой код возврата и перечень расхождений.
30
+ */
31
+ import { execFileSync } from 'node:child_process';
32
+ import { existsSync, readFileSync } from 'node:fs';
33
+ import { join } from 'node:path';
34
+
35
+ import { allowlistOf, CONFIG, ROOT } from './rt-kit-checks.config.mjs';
36
+
37
+ const ALLOWLIST = allowlistOf('file-size');
38
+ /** Предел один на все роды файлов: своё число каждому роду — спор о числе на каждой правке. */
39
+ const LIMIT = CONFIG.fileSizeLimit;
40
+
41
+ /** Роды файлов, которых не читает линтер. Код остаётся за ним. */
42
+ const JUDGED = ['.md', '.scss', '.html', '.js', '.mjs', '.sh'];
43
+
44
+ /** Описание прошлого, папка задачи и то, что переписывает генератор. */
45
+ const SKIPPED_PREFIXES = [CONFIG.archiveDir, `${CONFIG.tasksDir}/`, ...CONFIG.generatedDirs];
46
+
47
+ /** Дерево спрашивается у системы контроля версий: иначе каталоги с точки не видны, а сборка видна. */
48
+ function trackedFiles() {
49
+ return execFileSync('git', ['ls-files'], { cwd: ROOT, encoding: 'utf8', maxBuffer: 1024 * 1024 * 32 })
50
+ .split('\n')
51
+ .filter(Boolean);
52
+ }
53
+
54
+ function judged(path) {
55
+ if (SKIPPED_PREFIXES.some((prefix) => prefix && path.startsWith(prefix))) {
56
+ return false;
57
+ }
58
+
59
+ return JUDGED.some((extension) => path.endsWith(extension));
60
+ }
61
+
62
+ /** Тем же способом, каким считает линтер: число разрывов плюс один. */
63
+ function lineCount(path) {
64
+ return readFileSync(join(ROOT, path), 'utf8').split('\n').length;
65
+ }
66
+
67
+ /**
68
+ * Список известного читается отдельно от общего читателя: у этой проверки нет файла — это
69
+ * не пустой список, а нечитаемая настройка, и молчать о ней нельзя. Пустой список законен
70
+ * ровно один раз — в дереве, где длинных файлов нет вовсе.
71
+ */
72
+ function readKnown() {
73
+ const path = join(ROOT, ALLOWLIST);
74
+ if (!existsSync(path)) {
75
+ return { accepted: [], debt: [] };
76
+ }
77
+ try {
78
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
79
+
80
+ return { accepted: parsed.accepted ?? [], debt: parsed.debt ?? [] };
81
+ } catch (error) {
82
+ console.error(`check-file-size: список известного не прочитан — ${ALLOWLIST}: ${error.message}`);
83
+ process.exit(1);
84
+ }
85
+ }
86
+
87
+ const { accepted, debt } = readKnown();
88
+ const known = new Map([...accepted.map((path) => [path, 'принято']), ...debt.map((path) => [path, 'долг'])]);
89
+
90
+ const tooLong = new Map();
91
+ const tracked = trackedFiles().filter(judged);
92
+
93
+ for (const path of tracked) {
94
+ const lines = lineCount(path);
95
+ if (lines > LIMIT) {
96
+ tooLong.set(path, lines);
97
+ }
98
+ }
99
+
100
+ if (process.argv.includes('--baseline')) {
101
+ console.log(JSON.stringify({ accepted, debt: [...tooLong.keys()].sort() }, null, 4));
102
+ process.exit(0);
103
+ }
104
+
105
+ const fresh = [...tooLong].filter(([path]) => !known.has(path));
106
+ /** Строка на файл, которого в дереве нет, — устаревшая: иначе список копит мёртвое. */
107
+ const gone = [...known.keys()].filter((path) => !existsSync(join(ROOT, path)));
108
+ /** Файл поделили, а строку оставили: список перестал бы отвечать за то, что в нём стоит. */
109
+ const shrunk = [...known.keys()].filter((path) => !tooLong.has(path) && existsSync(join(ROOT, path)));
110
+
111
+ const problems = [
112
+ ...fresh.map(([path, lines]) => `${path}: ${lines} строк, предел ${LIMIT} — делить, а не дописывать строку в ${ALLOWLIST}`),
113
+ ...gone.map((path) => `${path}: строка в ${ALLOWLIST} устарела — файла в дереве нет`),
114
+ ...shrunk.map((path) => `${path}: значится в ${ALLOWLIST}, но уже короче предела — строку убрать`),
115
+ ];
116
+
117
+ if (problems.length > 0) {
118
+ console.error(`check-file-size: расхождений ${problems.length}\n`);
119
+ problems.forEach((problem) => console.error(` ${problem}`));
120
+ console.error('\nПредел длины файла — правило о языке для кода и правило о текстах для прозы.');
121
+ process.exit(1);
122
+ }
123
+
124
+ console.log(
125
+ `check-file-size: проверено ${tracked.length} файлов, длиннее ${LIMIT} строк ${tooLong.size}, ` +
126
+ `из них принято ${accepted.length}, долг ${debt.length} — новых нет`
127
+ );
@@ -53,6 +53,16 @@ const DEFAULTS = {
53
53
  tasksDir: 'docs/tasks',
54
54
  /** Куда сложены списки принятых долгов. */
55
55
  allowlistDir: 'tools',
56
+ /**
57
+ * Каталоги, которые переписывает генератор целиком: контракт, клиент хранилища, разложенное
58
+ * из пакета. Спорить с генератором о длине файла и о повторе некому.
59
+ */
60
+ generatedDirs: [],
61
+ /**
62
+ * Предел длины файла — одно число на все роды: своё число каждому роду означает спор о
63
+ * числе на каждой правке, а не о длине файла.
64
+ */
65
+ fileSizeLimit: 500,
56
66
  /** Корни сквозных тестов; пусто — их в дереве нет. */
57
67
  e2eRoots: ['apps/site-e2e', 'apps/admin-e2e'],
58
68
  /** Корни бэкенда: у него нет ни компонентов, ни шаблонов, и часть признаков к нему не применяется. */
@@ -15,32 +15,21 @@
15
15
  #
16
16
  # Порядок веток решает: первое совпадение выигрывает, поэтому частное идёт раньше общего.
17
17
 
18
- # Правила, которые вступают не от рода файла, а от того, что в него пишут.
19
- #
20
- # Обращение к среде исполнения приходит в обычный сервис, а число-настройка и перечисление —
21
- # в обычный класс: по имени файла ни то ни другое не видно, и правило, требуемое только по
22
- # расширению, здесь молчало бы.
23
- skill_for_written() {
24
- target="$1"
25
- written="$2"
26
-
27
- [ -z "$written" ] && return 0
28
-
29
- case "$target" in
30
- *.spec.ts | */docs/* | *.md) return 0 ;;
31
- esac
32
-
33
- printf '%s' "$written" | grep -qE '(globalThis|window\.|document\.defaultView|PLATFORM_ID|isPlatformBrowser|localStorage|sessionStorage)' \
34
- && printf '%s\n' 'platform-access'
18
+ # Правила, вступающие не от рода файла, а от того, что в него пишут, здесь не выбираются:
19
+ # они приходят слоем поверх доменного — `hooks/skill-gate-layers.sh`. Карта судит путь, слой
20
+ # судит текст, и оба зовутся из гейта в одной оболочке.
35
21
 
36
- case "$target" in
37
- *.ts)
38
- printf '%s' "$written" | grep -qE '^[[:space:]]*(export[[:space:]]+)?(const[[:space:]]+[A-Z][A-Z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*-?[0-9]|enum[[:space:]])' \
39
- && printf '%s\n' 'shared-code'
40
- ;;
41
- esac
42
-
43
- return 0
22
+ # Команда считается ВЫЗОВОМ, только когда стоит в начале строки или сразу за разделителем.
23
+ # Совпадение по подстроке ловит любое УПОМИНАНИЕ: строка о коммите в теле самого коммита и поиск
24
+ # по истории отбивались как настоящий коммит.
25
+ #
26
+ # `([A-Za-z_]…=…[[:space:]]+)*` — переменные окружения перед вызовом: адрес хранилища ставят
27
+ # приставкой самой команды, и без этого куска вызов не опознавался вовсе. `(npx…)?` — запуск
28
+ # через раннер пакетов, `([^[:space:]]*/)?` — путь до исполняемого файла. Многострочную команду
29
+ # поиск разбирает построчно, поэтому начало строки — начало каждой.
30
+ rt_gate_invokes() {
31
+ printf '%s\n' "$1" \
32
+ | grep -qE "(^|[;&|(])[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*((npx|pnpm|yarn|bun|npm)([[:space:]]+(exec|run|dlx))?[[:space:]]+)?([^[:space:]]*/)?$2([[:space:]]|$)"
44
33
  }
45
34
 
46
35
  skill_for_default() {
@@ -81,6 +70,37 @@ skill_for_default() {
81
70
  # строк комментария стоят захода, а второе прочитанное правило не пригождается.
82
71
  */tools/check-dupes.mjs | */tools/dupes-allowlist.json) printf '%s\n' 'shared-code' ;;
83
72
 
73
+ # Остальные проверки — то же самое: проверка исполняет утверждения своего
74
+ # правила, и признаки, по которым она судит, объявлены у него в привязке. Правя
75
+ # признак в проверке, второе место открывают рядом — иначе они расходятся молча,
76
+ # и проверка числит отказом то, что правило разрешает.
77
+ */check-specs.mjs) printf '%s\n' 'spec-driven' ;;
78
+ */check-doc-paths.mjs | */doc-paths-allowlist.json | */check-file-size.mjs)
79
+ printf '%s\n' 'doc-style' ;;
80
+ */check-styles.mjs | */styles-allowlist.json | */stylelint-rules/*)
81
+ printf '%s\n' 'styling-bem' ;;
82
+ */check-lib-layers.mjs | */lib-layers-allowlist.json) printf '%s\n' 'lib-layers' ;;
83
+ */check-reuse.mjs | */reuse-allowlist.json) printf '%s\n' 'reuse-first' ;;
84
+ */check-board.mjs | */board.mjs | */task-new.mjs | */check-schema-drift.mjs)
85
+ printf '%s\n' 'git-workflow' ;;
86
+ # Своё правило линтера кода пишется по тем же соглашениям, что и код под ним.
87
+ */eslint-rules/*) printf '%s\n' 'typescript-conventions' ;;
88
+
89
+ # Схема хранилища и её миграции: порядок каталогов лексикографический, а метку
90
+ # времени ставит инструмент в момент заведения — цепочка ломается молча и падает
91
+ # только накатом с нуля, то есть уже после слияния. Правило живёт при поставке.
92
+ */schema.prisma | */prisma/migrations/*) printf '%s\n' 'git-workflow' ;;
93
+ # Конвейер и образ: проверки решают, что вообще гоняется до слияния, а образ —
94
+ # что приезжает на прод. И то и другое правилось без единого правила поставки.
95
+ */.github/workflows/*.yml | */.gitlab-ci.yml | */azure-pipelines*.yml)
96
+ printf '%s\n' 'git-workflow' ;;
97
+ */Dockerfile | */*.Dockerfile | */docker-compose*.yml | */docker-compose*.yaml)
98
+ printf '%s\n' 'git-workflow' ;;
99
+
100
+ # Сквозная спека проверяет поднятое приложение, а не класс: по имени файла она от
101
+ # обычного модуля не отличается, и без этой ветки уходила бы в соглашения языка.
102
+ *-e2e/*) printf '%s\n' 'testing' ;;
103
+
84
104
  # Поставка: состав зависимостей — это то, что приезжает на прод. Правка
85
105
  # скриптов зависимостью не является, и правило про версии на неё не вступает.
86
106
  # Оговорка: удаление зависимости приходит правкой без номера версии и сюда не
@@ -105,18 +125,54 @@ skill_for_default() {
105
125
 
106
126
  *.ts) printf '%s\n' 'typescript-conventions' ;;
107
127
  esac
108
- skill_for_written "$target" "$written"
109
128
  ;;
110
129
  bash)
111
- case "$target" in
112
- *git\ commit* | *git\ push* | *git\ merge* | *git\ rebase* | *git\ cherry-pick* | *gh\ pr\ * | *glab\ mr\ * | *az\ repos\ *)
113
- printf '%s\n' 'git-workflow' ;;
114
- *git\ worktree\ add* | *git\ worktree\ remove*)
115
- printf '%s\n' 'git-workflow' ;;
116
- *prisma\ migrate* | *prisma\ db\ *) printf '%s\n' 'git-workflow' ;;
117
- *curl\ *localhost* | *wget\ *localhost*) printf '%s\n' 'browser-verification' ;;
118
- esac
130
+ # Ветки идут проверкой на вызов, а не совпадением по подстроке: упоминание команды
131
+ # командой не является, и гейт отбивал собственный текст о коммите.
132
+ if rt_gate_invokes "$target" "git[[:space:]]+(commit|push|merge|rebase|cherry-pick)" \
133
+ || rt_gate_invokes "$target" "git[[:space:]]+worktree[[:space:]]+(add|remove)" \
134
+ || rt_gate_invokes "$target" "git[[:space:]]+checkout[[:space:]]+-b" \
135
+ || rt_gate_invokes "$target" "git[[:space:]]+switch[[:space:]]+-c" \
136
+ || rt_gate_invokes "$target" "(gh|glab)[[:space:]]+(pr|mr|issue)[[:space:]]+(create|merge|edit)" \
137
+ || rt_gate_invokes "$target" "az[[:space:]]+(repos|boards)" \
138
+ || rt_gate_invokes "$target" "[^[:space:]]*task:new" \
139
+ || rt_gate_invokes "$target" "prisma[[:space:]]+(migrate|db)"; then
140
+ printf '%s\n' 'git-workflow'
141
+ # Правка тела отчёта через клиент хостинга ловится двумя признаками сразу — вызовом
142
+ # клиента И адресом запроса: одного слова о заявке мало, оно попадает в строку любой
143
+ # команды, которая о ней пишет. Тело отчёта не читает ни одна проверка, и утверждение
144
+ # о дереве стареет в нём молча.
145
+ elif rt_gate_invokes "$target" "(gh|glab)[[:space:]]+api" \
146
+ && printf '%s' "$target" | grep -qE '(-X|--method)[[:space:]]+(PATCH|PUT).*(pulls|merge_requests)/[0-9]+'; then
147
+ printf '%s\n' 'git-workflow'
148
+ # Образы и реестр на машине владельца: там же лежат его собственные стенды и работы
149
+ # других его веток. Снятие и чистка важнее сборки — они уносят чужое безвозвратно.
150
+ # Команды чтения остаются вне гейта: ими нехватку места и разбирают, и требовать на
151
+ # них правило значило бы отбивать сам приём. Поэтому общая чистка ловится с `prune`.
152
+ elif rt_gate_invokes "$target" "docker[[:space:]]+(build|buildx|pull|push|run|compose|login|rm|rmi|stop|start|restart|image|volume|builder|network)" \
153
+ || rt_gate_invokes "$target" "docker[[:space:]]+system[[:space:]]+prune"; then
154
+ printf '%s\n' 'git-workflow'
155
+ fi
156
+
157
+ # Слияние отчёта — последний момент, когда папку закрытой задачи ещё можно разобрать
158
+ # тем же отчётом: после слияния сверка очереди её видит, а отвечать за неё уже
159
+ # некому. Требуется ВТОРЫМ слоем, дополнительно к правилу поставки.
160
+ rt_gate_invokes "$target" "(gh[[:space:]]+pr|glab[[:space:]]+mr)[[:space:]]+merge" \
161
+ && printf '%s\n' 'task-flow'
162
+
163
+ # Обращение к поднятому приложению: врёт здесь не код, а то, что отвечает на порту.
164
+ # Ответ сборки прошлого захода неотличим от ответа живой ветки. Нужны оба признака —
165
+ # вызов клиента И адрес: одного адреса мало, он попадает в строку любой команды,
166
+ # которая о нём пишет, и гейт отбивал проверку самого гейта. Порт не перечисляется:
167
+ # свой разовый стенд поднимается на любом свободном.
168
+ if printf '%s' "$target" | grep -qE '(localhost|127\.0\.0\.1):[0-9]{4,5}' \
169
+ && { rt_gate_invokes "$target" curl || rt_gate_invokes "$target" wget; }; then
170
+ printf '%s\n' 'browser-verification'
171
+ fi
119
172
  ;;
173
+ # Проверка через браузер — единственная область, где правило нужно не под правку файла, а
174
+ # под инструмент: врут там не файлы, а стенд и координаты.
175
+ browser) printf '%s\n' 'browser-verification' ;;
120
176
  esac
121
177
 
122
178
  return 0
@@ -25,19 +25,42 @@ rt_runner() {
25
25
  # Порты у каждого дерева свои, поэтому умолчание молчит: назвать чужой порт хуже, чем не назвать.
26
26
  RT_STANDS="${RT_STANDS:-}"
27
27
 
28
+ # Где лежат проверки дерева и набор сценариев его гардов. Проверку, которой в дереве нет, гейт
29
+ # пуша не зовёт: список печатается по тому, что лежит на диске.
30
+ RT_CHECKS_DIR="${RT_CHECKS_DIR:-tools}"
31
+ RT_HOOKS_TESTS="${RT_HOOKS_TESTS:-.claude/hooks/tests/run.sh}"
32
+
28
33
  # Команды, которые обязаны пройти перед пушем. По одной на строку; первая упавшая отбивает пуш.
29
34
  # Линтер стилей отдельной строкой: линтер кода файлы стилей не читает вовсе.
30
35
  #
31
36
  # Первый параметр — база: ветка, относительно которой считается вклад. Пустая означает, что
32
37
  # удалённого нет, и тогда гоняется всё: набор строже нужного безопасен, набор уже нужного — нет.
38
+ #
39
+ # Сборка идёт наравне с линтом и спеками. Линтер типов не читает, а спеки читают только то, что
40
+ # кто-то ввёз в них импортом: ошибка типов в непокрытом коде доживает до сборки образа, то есть
41
+ # до слияния. Стоит это мало — дальше работает кэш прогонщика.
42
+ #
43
+ # Заведённая проверка встаёт сюда, а не только в общий прогон, который никто не зовёт сам:
44
+ # новая строка в её списке известного уезжает в главную ветку молча, а список при этом читается
45
+ # как действующая охрана.
33
46
  rt_push_checks_default() {
34
47
  runner="$(rt_runner)"
48
+ root="${CLAUDE_PROJECT_DIR:-.}"
35
49
  if [ -n "$1" ]; then
36
- printf '%s\n' "$runner nx affected -t lint test --base=$1"
50
+ printf '%s\n' "$runner nx affected -t lint test build --base=$1"
37
51
  else
38
- printf '%s\n' "$runner nx run-many -t lint test --all"
52
+ printf '%s\n' "$runner nx run-many -t lint test build --all"
39
53
  fi
40
- [ -f "${CLAUDE_PROJECT_DIR:-.}/stylelint.config.js" ] && printf '%s\n' "$runner stylelint \"**/*.scss\" --max-warnings 0"
54
+ [ -f "$root/stylelint.config.js" ] && printf '%s\n' "$runner stylelint \"**/*.scss\" --max-warnings 0"
55
+
56
+ # Сценарии гардов — такой же код, как всё остальное: на них держится и разбор ветки, и
57
+ # уверенность, что обвязка ещё работает. Прогон занимает секунды: он ничего не собирает.
58
+ [ -x "$root/$RT_HOOKS_TESTS" ] && printf '%s\n' "bash $RT_HOOKS_TESTS"
59
+
60
+ for check in check-doc-paths check-specs check-file-size check-dupes check-styles \
61
+ check-lib-layers check-reuse check-schema-drift; do
62
+ [ -f "$root/$RT_CHECKS_DIR/$check.mjs" ] && printf '%s\n' "node $RT_CHECKS_DIR/$check.mjs"
63
+ done
41
64
 
42
65
  return 0
43
66
  }
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env bash
2
+ # Слои гейта правил: требования, которые приходят ПОВЕРХ доменного.
3
+ #
4
+ # Доменное правило выбирается один раз по пути файла — у правки один предмет, и правило под него
5
+ # одно. Слоёв поверх него полтора десятка: доступ к среде исполнения виден только в тексте
6
+ # правки, наблюдаемость приходит вместе с доменом, а не вместо него, проза и ведение работы
7
+ # судят тот же файл вторым признаком. Вместе они не помещаются в карту гейта, которую читают
8
+ # целиком, — поэтому лежат здесь.
9
+ #
10
+ # Подключается из `skill-gate.sh` в его же оболочке: читает `$input`, `$target` и `$req` и
11
+ # дописывает имена правил в `$req`. Отдельным процессом слои возвращали бы то же самое через
12
+ # диск.
13
+ #
14
+ # ОТКАЗ В ПОЛЬЗУ РАБОТЫ: нечем разобрать вход — слой молчит. Разбор входа здесь побочная
15
+ # работа, и её поломка не имеет права остановить правку.
16
+ #
17
+ # Адреса дерева слои не знают: где у него бэкенд, витрина и сквозные тесты, говорит само дерево
18
+ # — функцией `skill_layer_skip <правило> <цель>` в своей карте гейта. Нет её — слой действует
19
+ # везде, где подошёл его признак.
20
+
21
+ # Уже названное вторым разом не требуется: отказ, перечисляющий одно правило дважды, читается
22
+ # как два разных требования.
23
+ rt_layer_add() {
24
+ case " $req " in
25
+ *" $1 "*) return 0 ;;
26
+ esac
27
+ req="${req:+$req }$1"
28
+ }
29
+
30
+ # Дерево вправе снять слой с места, где признак законен: прямое обращение к среде исполнения на
31
+ # бэкенде, чтение окружения в обвязке, заведение файла в сквозных тестах.
32
+ rt_layer_allowed() {
33
+ command -v skill_layer_skip >/dev/null 2>&1 || return 0
34
+ skill_layer_skip "$1" "$2" && return 1
35
+ return 0
36
+ }
37
+
38
+ # Текст правки читается один раз на все слои: разбор входа стоит дороже самих признаков.
39
+ rt_layer_payload=""
40
+ if command -v jq >/dev/null 2>&1; then
41
+ rt_layer_payload="$(printf '%s' "$input" \
42
+ | jq -r '[.tool_input.content, .tool_input.text, .tool_input.new_string, (.tool_input.edits[]?.new_string)]
43
+ | map(select(. != null)) | join("\n")' 2>/dev/null)"
44
+ fi
45
+
46
+ # Спека проверяет поведение, а не заводит его: там нужен `testing`, и слои поведения её обходят.
47
+ rt_layer_is_spec=1
48
+ case "$target" in *.spec.ts) rt_layer_is_spec=0 ;; esac
49
+
50
+ # --- слой по тексту: обращение к среде исполнения ---------------------------------------------
51
+ #
52
+ # Путь говорит, ЧТО за файл, а обращение к глобальному объекту видно только в содержимом: гейт,
53
+ # знающий один путь, пропускает его молча. Значение, полученное внедрением, признаком не
54
+ # считается — это уже зависимость, а не прямое обращение.
55
+ if [ -n "$rt_layer_payload" ] && [ "$rt_layer_is_spec" = 1 ]; then
56
+ case "$target" in
57
+ */main.ts|*/main.server.ts|*/server.ts|*/index.html) ;;
58
+ *.ts|*.html)
59
+ if printf '%s' "$rt_layer_payload" | grep -qE \
60
+ 'globalThis|PLATFORM_ID|isPlatformBrowser|defaultView|(^|[^[:alnum:]_.#$])window[[:space:]]*\.'; then
61
+ rt_layer_allowed platform-access "$target" && rt_layer_add platform-access
62
+ fi
63
+ ;;
64
+ esac
65
+ fi
66
+
67
+ # --- слой по тексту: наблюдаемость ------------------------------------------------------------
68
+ #
69
+ # Чтение переменной окружения и есть тот момент, когда заводится новая необязательная
70
+ # возможность. Сводка старта перечисляет их руками, и забывшая дописать себя не попадёт ни в
71
+ # один из трёх списков — на проде она выглядит не выключенной, а несуществующей.
72
+ if [ -n "$rt_layer_payload" ] && [ "$rt_layer_is_spec" = 1 ]; then
73
+ case "$target" in
74
+ *.ts)
75
+ if printf '%s' "$rt_layer_payload" | grep -qE 'process\.env'; then
76
+ rt_layer_allowed observability "$target" && rt_layer_add observability
77
+ fi
78
+ ;;
79
+ esac
80
+ fi
81
+
82
+ # --- слой по тексту: общий код ----------------------------------------------------------------
83
+ #
84
+ # Заводимое число-настройка и заводимое перечисление — тот момент, когда рядом с уже общим
85
+ # появляется копия. Каждая копия сама по себе исправна, и ни линт, ни сборка второй не видят.
86
+ # Спеки не в счёт: там значения местные, это фикстуры.
87
+ if [ -n "$rt_layer_payload" ] && [ "$rt_layer_is_spec" = 1 ]; then
88
+ case "$target" in
89
+ *.ts)
90
+ if printf '%s' "$rt_layer_payload" | grep -qE \
91
+ '(^|[[:space:]])(export[[:space:]]+)?const[[:space:]]+[A-Z][A-Z0-9_]*[[:space:]]*(:[[:space:]]*number[[:space:]]*)?=[[:space:]]*[0-9]|(^|[[:space:]])export[[:space:]]+enum[[:space:]]'; then
92
+ rt_layer_allowed shared-code "$target" && rt_layer_add shared-code
93
+ fi
94
+ ;;
95
+ esac
96
+ fi
97
+
98
+ # --- слой по пути: классы Angular -------------------------------------------------------------
99
+ #
100
+ # Компонент и стор — тоже классы: сигнальный API входов, обнаружение изменений и место подписки
101
+ # живут в `angular-patterns`, а первым слоем эти файлы уходят в устройство компонента и в
102
+ # соглашения языка, где ничего этого нет.
103
+ if [ "$rt_layer_is_spec" = 1 ]; then
104
+ case "$target" in
105
+ *.component.ts|*.store.ts)
106
+ rt_layer_allowed angular-patterns "$target" && rt_layer_add angular-patterns
107
+ ;;
108
+ esac
109
+ fi
110
+
111
+ # --- слой по пути: проза ----------------------------------------------------------------------
112
+ #
113
+ # Формат спека держит `spec-driven`, а как формулировать — `doc-style`, и нужен он не только
114
+ # спекам. Список известного у проверки — та же проза: его поле объясняет, что перечисленное
115
+ # отказом не считается, а сверка текстов читает только `.md`.
116
+ #
117
+ # Хозяйство самого агента слой обходит: правило на него — оно само, и карта гейта решает про
118
+ # эти файлы целиком. Слой, наложенный поверх, требовал бы правило там, где карта его нарочно
119
+ # не назвала.
120
+ case "$target" in
121
+ */.claude/*) ;;
122
+ *.md|*-allowlist.json) rt_layer_allowed doc-style "$target" && rt_layer_add doc-style ;;
123
+ esac
124
+
125
+ # --- слой по пути: ведение работы -------------------------------------------------------------
126
+ #
127
+ # Папка задачи, договорённость о продукте до кода и линия работ — первые файлы, которые
128
+ # заводятся в работе. Требование ловит на них того, кто пошёл мимо порядка: «пришла новая
129
+ # задача» инструментом не является, и поймать это больше нечем.
130
+ case "$target" in
131
+ */docs/tasks/*|*/docs/specs/*/proposed/*|*/docs/plans/*)
132
+ rt_layer_allowed task-flow "$target" && rt_layer_add task-flow
133
+ ;;
134
+ esac
135
+
136
+ # --- слой по заведению файла ------------------------------------------------------------------
137
+ #
138
+ # Ничего не пишется с нуля, и спрашивается это там, где решение и принимается, — на заведении
139
+ # нового файла: у правки существующего опора уже выбрана, а требовать правило на каждую строку
140
+ # значит сделать его фоном.
141
+ case "$target" in
142
+ */docs/*) ;;
143
+ *.spec.ts|*.stories.ts) ;;
144
+ *.ts|*.html|*.scss)
145
+ [ -f "$target" ] || { rt_layer_allowed reuse-first "$target" && rt_layer_add reuse-first; }
146
+ ;;
147
+ esac
148
+
149
+ # Куда встаёт заведённая проверка и какой формы у неё список известного — утверждения `testing`,
150
+ # и нужны они ровно в момент заведения: у существующей проверки и место в гейте, и форма списка
151
+ # уже выбраны.
152
+ case "$target" in
153
+ */check-*.mjs|*-allowlist.json)
154
+ [ -f "$target" ] || { rt_layer_allowed testing "$target" && rt_layer_add testing; }
155
+ ;;
156
+ esac
@@ -62,6 +62,11 @@ case "$tool" in
62
62
  # приходит в обычный сервис, а число-настройка в обычный класс.
63
63
  written="$(printf '%s' "$input" | jq -r '[.tool_input.content, .tool_input.text, .tool_input.new_string, (.tool_input.edits[]?.new_string)] | map(select(. != null)) | join("\n")' 2>/dev/null)"
64
64
  req="$(skill_for edit "$target" "$written" 2>/dev/null)"
65
+ # Слои поверх доменного правила лежат отдельным файлом и зовутся в этой же оболочке:
66
+ # доменное правило выбирается один раз по пути, а слоёв полтора десятка, и вместе они не
67
+ # помещаются в карту, которую читают целиком. Нет файла — гейт остаётся одним слоем.
68
+ # shellcheck disable=SC1090
69
+ [ -f "$rt_hooks_dir/skill-gate-layers.sh" ] && . "$rt_hooks_dir/skill-gate-layers.sh" 2>/dev/null
65
70
  # Род правки для наблюдения. Одно расширение, без пути и без имени файла: наблюдение
66
71
  # уезжает наружу, и всё, кроме рода, там было бы адресом этого дерева.
67
72
  case "${target##*/}" in
@@ -123,7 +128,11 @@ req="$want"
123
128
  [ -f "$rt_hooks_dir/observe.sh" ] && . "$rt_hooks_dir/observe.sh" 2>/dev/null
124
129
  command -v rt_note >/dev/null 2>&1 && rt_note gate-deny "res=$req" "kind=$kind" "sid=$sid"
125
130
 
126
- reason="Отбито гейтом правил: загрузи правило «${req}» инструментом Skill и повтори действие. Для этой области это происходит один раз за сессию."
131
+ # Запасной ход называется прямо в отказе: правило, заведённое в этой же ветке, реестру правил
132
+ # неизвестно — он собирается на запуске сессии, а гейт читает диск. Без этой строки следующий
133
+ # заход ищет обход перебором и обычно находит не тот.
134
+ fallback="Если инструмент такого имени не знает, правило завели после начала сессии — прочитай ${rules_dir}/${req}/SKILL.md и спутник рядом с ним."
135
+ reason="Отбито гейтом правил: загрузи правило «${req}» инструментом Skill и повтори действие. ${fallback} Для этой области это происходит один раз за сессию."
127
136
 
128
137
  # Правило называет свой закон одним словом, а слоёв законов два: общий лежит в корне, закон
129
138
  # приложения — в каталоге под ним. Путь ищется, а не собирается из имени, иначе отказ ведёт в
@@ -134,7 +143,7 @@ if [ -n "$law" ]; then
134
143
  law_path="$laws_dir/${law}.md"
135
144
  [ -f "$root/$law_path" ] || law_path="$laws_dir/application/${law}.md"
136
145
  [ -f "$root/$law_path" ] \
137
- && reason="Отбито гейтом правил: загрузи правило «${req}» инструментом Skill — оно применяет закон ${law_path} к этому дереву — и повтори действие. Для этой области это происходит один раз за сессию."
146
+ && reason="Отбито гейтом правил: загрузи правило «${req}» инструментом Skill — оно применяет закон ${law_path} к этому дереву — и повтори действие. ${fallback} Для этой области это происходит один раз за сессию."
138
147
  fi
139
148
 
140
149
  jq -n --arg r "$reason" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}' 2>/dev/null \
@@ -20,6 +20,9 @@
20
20
  причина названа рядом.
21
21
  - **Отметка об устаревании — повод убрать, а не повод оставить.** Устаревшее объявление,
22
22
  которое молча продолжает работать, переживает того, кто его пометил.
23
+ - **Файл читается целиком.** Длина, при которой его читают по частям, объявлена одним числом на
24
+ все роды файлов, и накопленное до объявления перечислено поимённо: перечень отмечает долг, а
25
+ не выдаёт разрешение.
23
26
 
24
27
  ## Открытые вопросы
25
28
 
@@ -75,3 +75,12 @@
75
75
  его написали, а разбора ждёт днями: за это время главная ветка вливается в ветку, и
76
76
  утверждение отчёта о соседних файлах становится неправдой молча — тел отчётов не читает ни
77
77
  одна проверка. Всё, что вливается в ветку после публикации отчёта, — повод перечитать его.
78
+ - **Слияние в главную ветку ещё не означает, что правка доехала.** Отказ выкатки не трогает ни
79
+ задачу, ни очередь работ, поэтому расхождение главной ветки с тем, что работает, обязано быть
80
+ видно там, где очередь читают. Иначе следующие работы вливаются поверх поломки, которую не
81
+ приносили, и каждая выглядит доехавшей.
82
+ - **Состоявшаяся поломка разбирается записью, которая переживает задачу.** Починка уезжает
83
+ веткой, задача закрывается — и причина, по которой приложение встало, остаётся знанием одного
84
+ исполнителя. Запись называет, что сломалось, чем это стало видно и почему починка чинит
85
+ причину, а не признак; живёт она среди описаний состоявшегося, а не там, что умирает вместе с
86
+ задачей.