@rt-tools/agent-kit 0.22.0 → 0.23.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 (58) hide show
  1. package/assets/checks/board-long-work.github.mjs +101 -0
  2. package/assets/checks/board-runs.github.mjs +34 -0
  3. package/assets/checks/check-board.github.mjs +16 -2
  4. package/assets/checks/check-reuse.mjs +4 -1
  5. package/assets/checks/check-schema-drift.mjs +65 -6
  6. package/assets/checks/rt-kit-checks.config.mjs +13 -0
  7. package/assets/checks/signals.mjs +41 -1
  8. package/assets/commands/next-session.md +16 -5
  9. package/assets/defaults/project.sh +16 -0
  10. package/assets/hooks/browser-guard-no-asking.sh +5 -1
  11. package/assets/hooks/git-guard-delivery.sh +15 -11
  12. package/assets/hooks/git-guard-push-tests.sh +22 -0
  13. package/assets/hooks/glossary-load.sh +23 -2
  14. package/assets/hooks/grill-gate.sh +62 -0
  15. package/assets/laws/autonomous-work.md +30 -0
  16. package/assets/laws/project-documentation.md +8 -0
  17. package/assets/patterns/autonomous-work-run.md +105 -0
  18. package/assets/patterns/browser-verification-stand.md +1 -1
  19. package/assets/patterns/doc-style-write.md +16 -0
  20. package/assets/patterns/git-workflow-pr-ready.md +93 -0
  21. package/assets/patterns/git-workflow-pr.azure.md +1 -1
  22. package/assets/patterns/git-workflow-pr.github.md +1 -1
  23. package/assets/patterns/git-workflow-pr.gitlab.md +1 -1
  24. package/assets/patterns/task-flow-start.md +47 -47
  25. package/assets/patterns/ts-procedure.md +3 -2
  26. package/assets/rules/autonomous-work.md +92 -0
  27. package/assets/rules/browser-verification.md +6 -0
  28. package/assets/rules/deploy-flow.azure.md +7 -0
  29. package/assets/rules/deploy-flow.github.md +7 -0
  30. package/assets/rules/deploy-flow.gitlab.md +7 -0
  31. package/assets/rules/doc-style.md +18 -0
  32. package/assets/rules/git-workflow.azure.md +8 -0
  33. package/assets/rules/git-workflow.github.md +44 -45
  34. package/assets/rules/git-workflow.gitlab.md +8 -0
  35. package/assets/rules/reuse-first.md +17 -3
  36. package/assets/rules/task-flow.md +62 -63
  37. package/assets/skills/agent-kit.md +20 -20
  38. package/lib/commands.d.ts.map +1 -1
  39. package/lib/commands.js +6 -0
  40. package/lib/commands.js.map +1 -1
  41. package/lib/push-gate.d.ts +14 -0
  42. package/lib/push-gate.d.ts.map +1 -0
  43. package/lib/push-gate.js +93 -0
  44. package/lib/push-gate.js.map +1 -0
  45. package/package.json +1 -1
  46. package/rt-tools-agent-kit-0.23.0.tgz +0 -0
  47. package/assets/laws/application/access.md +0 -34
  48. package/assets/laws/application/locales.md +0 -33
  49. package/assets/laws/application/search-visibility.md +0 -24
  50. package/assets/patterns/permissions-procedure.md +0 -71
  51. package/assets/patterns/seo-page.md +0 -104
  52. package/assets/patterns/seo-verify.md +0 -83
  53. package/assets/patterns/translations-content.md +0 -107
  54. package/assets/patterns/translations-key.md +0 -64
  55. package/assets/rules/permissions.md +0 -116
  56. package/assets/rules/seo.md +0 -139
  57. package/assets/rules/translations.md +0 -96
  58. package/rt-tools-agent-kit-0.22.0.tgz +0 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Работа, которую одним заходом не закрыть: метка карточки против записи в линии работ.
3
+ *
4
+ * Своим файлом по той же причине, что и связь с эпиком: сверка очереди работ и без них стоит у
5
+ * предела длины, а читают эти проверки порознь.
6
+ *
7
+ * Помечена такая работа в двух местах, и одна метка без другой лжёт молча: исполнитель открывает
8
+ * карточку раньше, чем линию работ, а планирует по линии. Карточка без записи обещает
9
+ * многозаходность, которой линия не знает; запись без метки оставляет карточку выглядеть работой
10
+ * на один заход — и следующий заход берёт её, рассчитывая закрыть за раз.
11
+ */
12
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+
15
+ import { TASK_KEY } from './board.mjs';
16
+ import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
17
+
18
+ /**
19
+ * Метка многозаходной карточки и каталог линий работ. Не названо любое из двух — связь не
20
+ * судится вовсе: отличить многозаходную карточку от обычной станет нечем, а каталог линий у
21
+ * каждого дерева свой.
22
+ */
23
+ const LONG_LABEL = CONFIG.longWork?.label ?? '';
24
+ const PLANS_DIR = CONFIG.longWork?.plansDir ?? '';
25
+
26
+ /** Строки линий работ, где стоит слово метки: только они считаются записью о многозаходности. */
27
+ function markedRows() {
28
+ const dir = join(ROOT, PLANS_DIR);
29
+ if (!existsSync(dir)) {
30
+ return null;
31
+ }
32
+
33
+ const rows = [];
34
+ for (const name of readdirSync(dir).filter((one) => one.endsWith('.md'))) {
35
+ for (const line of readFileSync(join(dir, name), 'utf8').split('\n')) {
36
+ if (line.includes(LONG_LABEL)) {
37
+ rows.push({ file: name, line });
38
+ }
39
+ }
40
+ }
41
+
42
+ return rows;
43
+ }
44
+
45
+ /**
46
+ * Связь метки с линией работ, прочитанная в обе стороны.
47
+ *
48
+ * Записью считается строка, где стоят и слово метки, и номер задачи. Голое упоминание номера не
49
+ * годится: линия работ называет все свои задачи, и большинство из них однозаходные — обратная
50
+ * сторона краснела бы на каждой.
51
+ *
52
+ * Судится только открытое, как и вся остальная сверка: закрытая задача — история, и строку о ней
53
+ * нечем закрыть.
54
+ */
55
+ export function checkLongWork(open, report) {
56
+ if (!LONG_LABEL || !PLANS_DIR) {
57
+ return;
58
+ }
59
+
60
+ const rows = markedRows();
61
+ if (rows === null) {
62
+ report(`каталога линий работ «${PLANS_DIR}» нет на диске — многозаходную работу сверять не с чем`);
63
+
64
+ return;
65
+ }
66
+
67
+ // Номера, названные в помеченных строках. Ключ задачи и решётка читаются оба: линия пишется
68
+ // человеком, и форма номера в ней от строки к строке разная.
69
+ const written = new Set();
70
+ const whereWritten = new Map();
71
+ for (const row of rows) {
72
+ for (const match of row.line.matchAll(new RegExp(`(?:#|${TASK_KEY}-)(\\d+)`, 'g'))) {
73
+ const number = Number(match[1]);
74
+ written.add(number);
75
+ if (!whereWritten.has(number)) {
76
+ whereWritten.set(number, row.file);
77
+ }
78
+ }
79
+ }
80
+
81
+ const labelled = new Set(
82
+ open.filter((issue) => (issue.labels ?? []).some((label) => label.name === LONG_LABEL)).map((issue) => issue.number)
83
+ );
84
+
85
+ for (const number of labelled) {
86
+ if (!written.has(number)) {
87
+ report(
88
+ `#${number}: помечена как «${LONG_LABEL}», а в линиях работ «${PLANS_DIR}» такой строки нет — планируют по линии, а не по карточке`
89
+ );
90
+ }
91
+ }
92
+
93
+ // Обратная сторона: линия знает работу многозаходной, а карточка выглядит работой на один
94
+ // заход — и следующий заход берёт её, рассчитывая закрыть за раз.
95
+ const byNumber = new Set(open.map((issue) => issue.number));
96
+ for (const number of written) {
97
+ if (byNumber.has(number) && !labelled.has(number)) {
98
+ report(`#${number}: линия работ «${whereWritten.get(number)}» знает её многозаходной, а метки «${LONG_LABEL}» на карточке нет`);
99
+ }
100
+ }
101
+ }
@@ -65,6 +65,40 @@ export function deployLag(workflow, mainBranch, options) {
65
65
  return { sha: run.sha, at: run.at, behind: Number(String(behind).trim()) };
66
66
  }
67
67
 
68
+ /**
69
+ * Чем кончилась последняя выкатка: `success`, `failure`, `running` либо `none`, если её не
70
+ * запускали ни разу.
71
+ *
72
+ * Спрашивается отдельно от отставания прода. Отставание считается по последней УСПЕШНОЙ выкатке,
73
+ * и две разные беды выглядят через него одинаково: выкатку не запускали и выкатка упала. Ведут
74
+ * они к разному — первую запускают, вторую читают журналом и чинят, — а четыре слияния подряд
75
+ * уехали поверх поломки, которую принёс первый, и прод простоял почти два часа.
76
+ *
77
+ * Идущая выкатка расхождением не считается: она ещё может кончиться успехом.
78
+ */
79
+ export function lastDeploy(workflow, options) {
80
+ const runs = gh(
81
+ [
82
+ 'api',
83
+ `repos/${OWNER}/${REPO}/actions/workflows/${encodeURIComponent(workflow)}/runs?per_page=1`,
84
+ '--jq',
85
+ '[.workflow_runs[] | {status, conclusion, sha: .head_sha, at: .created_at, url: .html_url}] | first // empty',
86
+ ],
87
+ options
88
+ );
89
+ const last = String(runs).trim();
90
+ if (!last) {
91
+ return { verdict: 'none' };
92
+ }
93
+
94
+ const run = JSON.parse(last);
95
+ if (run.status !== 'completed') {
96
+ return { verdict: 'running', ...run };
97
+ }
98
+
99
+ return { verdict: run.conclusion === 'success' ? 'success' : 'failure', ...run };
100
+ }
101
+
68
102
  /**
69
103
  * Чем кончились прогоны на этой вершине: `success`, если все завершились успехом, `running`,
70
104
  * если хоть один ещё идёт, `failure` — если хоть один упал. Прогонов нет вовсе — `none`.
@@ -50,7 +50,8 @@ import {
50
50
  taskDirs,
51
51
  } from './board.mjs';
52
52
  import { onlyIgnoredPaths } from './board-paths.mjs';
53
- import { HAS_PIPELINE, deployLag, evictedOnHead, headCommittedAt, runsOnHead, verdictOnHead } from './board-runs.mjs';
53
+ import { checkLongWork } from './board-long-work.mjs';
54
+ import { HAS_PIPELINE, deployLag, evictedOnHead, headCommittedAt, lastDeploy, runsOnHead, verdictOnHead } from './board-runs.mjs';
54
55
  import { checkEpicLinks } from './board-epics.mjs';
55
56
  import { similarTitles } from './board-titles.mjs';
56
57
  import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
@@ -317,6 +318,7 @@ try {
317
318
 
318
319
  similarTitles(open);
319
320
  checkEpicLinks(open, report);
321
+ checkLongWork(open, report);
320
322
 
321
323
  const openNumbers = new Set(open.map((issue) => issue.number));
322
324
  const claimed = new Map();
@@ -415,7 +417,8 @@ try {
415
417
  // сколько угодно коммитов, и заметить это неоткуда.
416
418
  if (!offline && DEPLOY_WORKFLOW) {
417
419
  try {
418
- const lag = deployLag(DEPLOY_WORKFLOW, MAIN_BRANCH, { token: botToken() ?? undefined });
420
+ const token = botToken() ?? undefined;
421
+ const lag = deployLag(DEPLOY_WORKFLOW, MAIN_BRANCH, { token });
419
422
  if (lag === null) {
420
423
  report(`выкаток по «${DEPLOY_WORKFLOW}» не было ни одной — сравнить прод не с чем`);
421
424
  } else if (lag.behind > 0) {
@@ -423,6 +426,17 @@ if (!offline && DEPLOY_WORKFLOW) {
423
426
  `прод отстал от «${MAIN_BRANCH}» на ${lag.behind} коммитов: последняя выкатка — ${lag.sha.slice(0, 8)} от ${String(lag.at).slice(0, 10)}`
424
427
  );
425
428
  }
429
+
430
+ // Отчего прод отстал, отставание не говорит: оно считается по последней УСПЕШНОЙ
431
+ // выкатке, и «не запускали» с «упала» выглядят через него одинаково. Ведут они к
432
+ // разному — первую запускают, вторую читают журналом и чинят.
433
+ const deploy = lastDeploy(DEPLOY_WORKFLOW, { token });
434
+ if (deploy.verdict === 'failure') {
435
+ report(
436
+ `выкатка «${DEPLOY_WORKFLOW}» упала на ${String(deploy.sha).slice(0, 8)} от ${String(deploy.at).slice(0, 10)}: ` +
437
+ `главная ветка впереди прода, и слияния поверх уедут туда же — ${deploy.url}`
438
+ );
439
+ }
426
440
  } catch (error) {
427
441
  if (error instanceof OfflineError) {
428
442
  console.log(`check-board: прод не сверялся — ${error.message}`);
@@ -137,7 +137,10 @@ if (process.argv.includes('--baseline')) {
137
137
  }
138
138
 
139
139
  const problems = [
140
- ...fresh.map((finding) => `${finding.key}готовое: ${finding.instead}`),
140
+ // Готовое названо оговоркой намеренно: признак судит тег, а место показа нет. Замена своего
141
+ // абзаца отказа на готовый компонент того же экрана закрывает признак и оставляет нарушение
142
+ // на месте — так починили тринадцать шаблонов сразу. Строка вывода — измерение, а не задание.
143
+ ...fresh.map((finding) => `${finding.key} — готовое (если место показа верно): ${finding.instead}`),
141
144
  ...stale.map((key) => `${key}: значится в ${ALLOWLIST}, а в дереве такого расхождения больше нет — строку поправить или убрать`),
142
145
  ];
143
146
 
@@ -83,6 +83,43 @@ function shadowAddresses(url) {
83
83
  // которой нечего смотреть, поломкой не является.
84
84
  const SKIP = Number(process.env.RT_SKIP_CODE ?? 7);
85
85
 
86
+ /**
87
+ * Тронула ли ветка хранилище — схему либо каталог миграций.
88
+ *
89
+ * Пропуск проверки законен ровно до этой черты. База на машине разработчика бывает погашена
90
+ * буднично, и отбивать за это пуш документации не за что; но ветка, правившая миграции, без
91
+ * прогона цепочки уезжает в главную вслепую — и падает не у неё, а на выкатке. Так и упал прод:
92
+ * пять полей появились в схеме без миграций, часть страниц стала отвечать «не найдено», полчаса
93
+ * недоступности, чинили откатом. Гейт при этом был зелёным: проверка вернула код пропуска.
94
+ *
95
+ * Смотрятся обе стороны — незакоммиченное в рабочем дереве и вклад ветки от главной. Одного
96
+ * вклада мало: правка, ещё не попавшая в коммит, уходит тем же пушем следом.
97
+ *
98
+ * ОТКАЗ В ПОЛЬЗУ РАБОТЫ: нет git, нет главной ветки, вызов упал — считается, что не тронула.
99
+ * Проверка, отбивающая пуш по своей слепоте, хуже пропуска: чинить в ней нечего.
100
+ */
101
+ function touchedStorage() {
102
+ const paths = [CONFIG.schemaFile, CONFIG.migrationsDir].filter(Boolean);
103
+ if (!paths.length) {
104
+ return false;
105
+ }
106
+
107
+ const git = (args) => {
108
+ const out = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
109
+
110
+ return out.status === 0 ? (out.stdout ?? '') : '';
111
+ };
112
+
113
+ // Главная ветка берётся удалённой ссылкой: локальная — снимок последнего подтягивания, и
114
+ // вклад, посчитанный от неё, врёт ровно в ту сторону, где проверка молчит.
115
+ const main = CONFIG.mainBranch ?? 'main';
116
+ const base = [`origin/${main}`, main].find((ref) => git(['rev-parse', '--verify', '--quiet', ref]).trim()) ?? '';
117
+
118
+ const changed = [git(['status', '--porcelain']), base ? git(['diff', '--name-only', `${base}...HEAD`]) : ''].join('\n');
119
+
120
+ return paths.some((one) => changed.split('\n').some((line) => line.includes(one)));
121
+ }
122
+
86
123
  function prisma(args, url) {
87
124
  return spawnSync('npx', ['prisma', ...args], {
88
125
  cwd: ROOT,
@@ -103,9 +140,7 @@ async function withServiceClient(serviceUrl, run) {
103
140
  // Погашенный докер — обычное состояние машины, а не повод не дать запушить
104
141
  // документацию.
105
142
  if (SERVER_DOWN_CODES.includes(error?.code)) {
106
- console.log('check-schema-drift: база недоступна — сверять негде');
107
-
108
- return SKIP;
143
+ return unavailable('база недоступна');
109
144
  }
110
145
  throw error;
111
146
  }
@@ -117,6 +152,32 @@ async function withServiceClient(serviceUrl, run) {
117
152
  }
118
153
  }
119
154
 
155
+ /**
156
+ * Ответ на «проверять негде»: пропуск либо отказ — смотря тронула ли ветка хранилище.
157
+ *
158
+ * Отказ называет, чем поднять базу. Сказанное только «негде» исполнитель читает как разрешение:
159
+ * поднимать её он не обязан, а гейт зелёный.
160
+ */
161
+ function unavailable(why) {
162
+ if (!touchedStorage()) {
163
+ console.log(`check-schema-drift: ${why} — сверять негде`);
164
+
165
+ return SKIP;
166
+ }
167
+
168
+ console.error(`check-schema-drift: ${why}, а ветка правила схему или миграции — сверять негде, но было чем\n`);
169
+ console.error(
170
+ 'Цепочка миграций на пустом хранилище — единственное место, где виден их настоящий порядок:\n' +
171
+ 'метку времени ставит момент создания, и миграция из ветки, начатой раньше, встаёт перед той,\n' +
172
+ 'от которой зависит. На развёрнутом хранилище разработчика она ложится, на чистом падает — и\n' +
173
+ 'видно это в первый раз на выкатке.\n\n' +
174
+ 'Подними базу и повтори; когда её под рукой нет, цепочка гоняется на одноразовом контейнере —\n' +
175
+ 'готовые команды в паттерне `git-workflow-migration`.'
176
+ );
177
+
178
+ return 1;
179
+ }
180
+
120
181
  async function main() {
121
182
  // Незаданное имя спрашивается отдельно от несуществующего файла. Склеенное с корнем, пустое
122
183
  // имя даёт сам корень — а он есть всегда, и проверка шла дальше, будто схема на месте.
@@ -135,9 +196,7 @@ async function main() {
135
196
 
136
197
  const url = databaseUrl();
137
198
  if (!url) {
138
- console.log('check-schema-drift: адрес базы не задан — сверять негде');
139
-
140
- return SKIP;
199
+ return unavailable('адрес базы не задан');
141
200
  }
142
201
 
143
202
  if (PRODUCTION_MARKS.some((mark) => url.includes(mark))) {
@@ -175,6 +175,19 @@ const DEFAULTS = {
175
175
  workflow: '',
176
176
  mainBranch: 'main',
177
177
  },
178
+ /**
179
+ * Работа, которую одним заходом не закрыть. `label` — метка такой карточки; `plansDir` —
180
+ * каталог, где живут линии работ. Метка и запись в линии сверяются в обе стороны: одна без
181
+ * другой лжёт молча, потому что исполнитель открывает карточку раньше, чем линию, а
182
+ * планирует по линии.
183
+ *
184
+ * Не названо любое из двух — связь не судится вовсе: отличить многозаходную карточку от
185
+ * обычной станет нечем, а каталог линий у каждого дерева свой.
186
+ */
187
+ longWork: {
188
+ label: '',
189
+ plansDir: '',
190
+ },
178
191
  /** Очередь работ: владелец, репозиторий, борда и учётная запись машинной работы. */
179
192
  board: {
180
193
  owner: '',
@@ -48,6 +48,46 @@ function readBundle(name) {
48
48
  return parseSignals(readFileSync(path, 'utf8'));
49
49
  }
50
50
 
51
+ /** Признак, ищущий нативный тег: `<input\\b`, `<textarea\\b`, `<select\\b`. */
52
+ const NATIVE_TAG = /^<([a-z]+)\\b$/;
53
+
54
+ /** Спецзнаки выражения в имени директивы: имя приходит из настройки дерева, а не из кода. */
55
+ function escaped(name) {
56
+ return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
57
+ }
58
+
59
+ /**
60
+ * Директивы своей дизайн-системы вычёркиваются из признаков нативных тегов.
61
+ *
62
+ * Источник вида в дереве бывает не один: панель владельца собирается набором кита, публичный
63
+ * сайт — своей системой, и закон о единообразии разводит их по приложениям. Директива такой
64
+ * системы стоит на нативном теге ровно так же, как директива кнопки кита, — и признак кнопки её
65
+ * уже вырезает своим `strip`, а признаки поля, области текста и списка не вырезают ничего: форма,
66
+ * собранная из готового своего, числится расхождением целиком, пять полей из пяти.
67
+ *
68
+ * Список директив называет дерево — ключом `reuse.kitDirectives`. Не назвало — не вырезается
69
+ * ничего, и дерево с одним источником вида ведёт себя как прежде.
70
+ */
71
+ export function withKitDirectives(signals, directives) {
72
+ const names = (directives ?? []).filter(Boolean).map(escaped);
73
+ if (names.length === 0) {
74
+ return signals;
75
+ }
76
+
77
+ const alternatives = names.join('|');
78
+
79
+ return signals.map((signal) => {
80
+ const tag = NATIVE_TAG.exec(signal.find ?? '')?.[1];
81
+ if (!tag || signal.ext !== '.html') {
82
+ return signal;
83
+ }
84
+
85
+ const own = `<${tag}\\b[^>]*(?:${alternatives})[^>]*>`;
86
+
87
+ return { ...signal, strip: signal.strip ? `${signal.strip}|${own}` : own };
88
+ });
89
+ }
90
+
51
91
  /**
52
92
  * Признаки объявленных наборов, а поверх них — свои признаки дерева.
53
93
  *
@@ -84,5 +124,5 @@ export function loadSignals(config, root) {
84
124
  );
85
125
  }
86
126
 
87
- return [...byKey.values()];
127
+ return withKitDirectives([...byKey.values()], config.kitDirectives);
88
128
  }
@@ -54,12 +54,19 @@ git merge-base --is-ancestor HEAD "origin/${RT_MAIN_BRANCH:-main}" && echo вл
54
54
 
55
55
  ## 4. Приведи дерево к главной ветке
56
56
 
57
- - **Работа по правилу и PR влит** — задача закрыта, ветка больше не нужна:
57
+ - **Работа по правилу и PR влит** — задача закрыта, ветка больше не нужна. Локальная главная
58
+ двигается до удалённой без перехода на неё:
58
59
 
59
60
  ```bash
60
- git switch "${RT_MAIN_BRANCH:-main}" && git pull --ff-only
61
+ git fetch origin "${RT_MAIN_BRANCH:-main}:${RT_MAIN_BRANCH:-main}"
61
62
  ```
62
63
 
64
+ Рабочее дерево при этом не трогается вовсе, поэтому шаг исполним и тогда, когда в нём лежит
65
+ незакоммиченное — а лежит оно там по слову самой этой команды, велевшей шагом раньше его не
66
+ трогать. Переход на главную нужен, только чтобы там работать; уборке он не нужен, а
67
+ `git pull` на ветке задачи тянет не главную вовсе и отвечает «no such ref was fetched»:
68
+ ветку к этой минуте уже сняли с удалённого слиянием заявки.
69
+
63
70
  - **Всё остальное** — работа не кончилась, и ветка остаётся местом, где она продолжится:
64
71
 
65
72
  ```bash
@@ -74,17 +81,21 @@ git merge-base --is-ancestor HEAD "origin/${RT_MAIN_BRANCH:-main}" && echo вл
74
81
  Снимаются только влитые в главную: их коммиты есть в ней, и восстанавливать нечего.
75
82
 
76
83
  ```bash
77
- git branch --merged "${RT_MAIN_BRANCH:-main}" \
84
+ git branch --merged "origin/${RT_MAIN_BRANCH:-main}" \
78
85
  | grep -vE "^\*|^\s*${RT_MAIN_BRANCH:-main}$" \
79
86
  | xargs -r git branch -d
80
87
  ```
81
88
 
89
+ Влитость судится от удалённой ссылки, а не от локальной главной: локальная отстаёт молча, и
90
+ влитая ветка при ней числится невлитой. Три ветки так показались несущими по два коммита мимо
91
+ главной, а мимо удалённой у них было ноль — заход кончился бы списком невлитого, которого нет.
92
+
82
93
  Невлитую ветку **не сноси**. Назови её владельцу вместе с числом коммитов, которых нет в
83
94
  главной, — по ним видно, что именно потеряется, если её снести:
84
95
 
85
96
  ```bash
86
- for b in $(git branch --no-merged "${RT_MAIN_BRANCH:-main}" --format='%(refname:short)'); do
87
- printf '%s: %s коммитов мимо главной\n' "$b" "$(git rev-list --count "${RT_MAIN_BRANCH:-main}..$b")"
97
+ for b in $(git branch --no-merged "origin/${RT_MAIN_BRANCH:-main}" --format='%(refname:short)'); do
98
+ printf '%s: %s коммитов мимо главной\n' "$b" "$(git rev-list --count "origin/${RT_MAIN_BRANCH:-main}..$b")"
88
99
  done
89
100
  ```
90
101
 
@@ -241,6 +241,21 @@ rt_task_branch_ok_default() {
241
241
  | grep -qE '^([A-Z]+-)?[0-9]+-[a-z0-9][a-z0-9-]*$|^(feat|fix|refactor|docs|chore|style|perf|test|build|ci)/[0-9]+-[a-z0-9][a-z0-9-]*$'
242
242
  }
243
243
 
244
+ # Номер задачи из имени ветки. Второй вопрос к тому же имени: форма отвечает «годится ли»,
245
+ # а это — «какой он», и свести их в одно нельзя: одна возвращает код, другой строку.
246
+ #
247
+ # Формы те же, что знает проверка формы выше: приставкой стоит либо ключ задач через дефис,
248
+ # либо род правки через косую черту, либо её нет вовсе. Разойдясь с ней, разбор оставит форму
249
+ # законной, а номер пустым — и сверка номера ветки с номером заголовка заявки пропустится молча,
250
+ # выглядя при этом сошедшейся.
251
+ #
252
+ # Хвост после номера здесь не судится намеренно: `RT-9_guest` — это промах в разделителе, а не
253
+ # осознанно беззадачная ветка, и номер из неё вынуть надо, чтобы форму имени было с чем сверить.
254
+ # Требование дефиса оставляло такое имя без номера, и гард пропускал его как ветку под пробу.
255
+ rt_task_branch_number_default() {
256
+ printf '%s' "$1" | sed -nE 's#^([A-Za-z]+[-/])?([0-9]+).*#\2#p'
257
+ }
258
+
244
259
  # Адрес боевого хранилища: образец, по которому его узнают в командной строке — порт туннеля,
245
260
  # хост, домен. Любая запись по нему отбивается совсем, и опт-аут не действует. Умолчание
246
261
  # молчит: чужой адрес здесь опаснее неназванного — он отбивал бы работу с локальной базой.
@@ -384,6 +399,7 @@ rt_push_docs_only() { rt_push_docs_only_default "$@"; }
384
399
  rt_docs_pair_for() { rt_docs_pair_for_default "$@"; }
385
400
  rt_lint_for() { rt_lint_for_default "$@"; }
386
401
  rt_task_branch_ok() { rt_task_branch_ok_default "$@"; }
402
+ rt_task_branch_number() { rt_task_branch_number_default "$@"; }
387
403
  rt_reinvented_in() { rt_reinvented_in_default "$@"; }
388
404
  rt_is_app_code() { rt_is_app_code_default "$@"; }
389
405
  # Каталог источников пакета правил в этом дереве, от корня. Пусто — дерево пакета не везёт, и
@@ -12,6 +12,10 @@
12
12
  # being about which Chrome profile to automate. Anchor on selection phrasing instead
13
13
  # (deviceId, "which/what browser", "какой браузер").
14
14
  #
15
+ # The Russian pronoun is spelled out case by case on purpose: an optional ending would leave a
16
+ # bare "как" in the pattern, and "какие браузеры поддерживаем" — a support question, not a
17
+ # selection one — got denied by exactly that.
18
+ #
15
19
  # FAIL-OPEN when browser-device-id.sh prints nothing.
16
20
 
17
21
  # Своё имя в наблюдениях: отбой пишет общий хвост отказа, а не сам гард.
@@ -29,7 +33,7 @@ device_id="$("${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/browser-device-id.sh" 2>/de
29
33
  questions="$(printf '%s' "$input" | jq -r '[.tool_input.questions[]? | .question, .header, (.options[]?.label)] | join(" ")' 2>/dev/null)"
30
34
  [ -z "$questions" ] && exit 0
31
35
 
32
- 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
36
+ 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
33
37
 
34
38
  # Общий хвост отказа: два законных хода и законная форма обхода, если она у отказа есть. Файл
35
39
  # может быть не разложен — тогда хвоста нет, а причина отказа остаётся прежней.
@@ -25,6 +25,7 @@
25
25
  #
26
26
  # Что здесь чем зовётся, знает профиль дерева:
27
27
  # rt_task_branch_ok — форма имени ветки под задачу;
28
+ # rt_task_branch_number — номер задачи из этого имени: приставкой бывает и род правки;
28
29
  # RT_TASK_TITLE_RE — форма номера в заголовке заявки;
29
30
  # rt_task_state — состояние задачи одним объектом (existsize, open, onBoard, assigned,
30
31
  # numbered); молчание значит «спросить некого»;
@@ -95,6 +96,7 @@ done
95
96
  [ -f "$rt_hooks_dir/profile-check.sh" ] && . "$rt_hooks_dir/profile-check.sh"
96
97
  command -v rt_needs >/dev/null 2>&1 || rt_needs() { command -v "$1" >/dev/null 2>&1; }
97
98
  rt_needs rt_task_branch_ok git-guard-delivery || exit 0
99
+ rt_needs rt_task_branch_number git-guard-delivery || exit 0
98
100
 
99
101
  title_re="${RT_TASK_TITLE_RE:-^\[[A-Za-z]+-[0-9]+\][[:space:]]+[^[:space:]]}"
100
102
  task_new="${RT_TASK_NEW_CMD:-npm run task:new}"
@@ -146,10 +148,9 @@ deny() {
146
148
  exit 0
147
149
  }
148
150
 
149
- # Несошедшиеся условия копятся и называются разом. Отказ по первому промаху заставляет чинить
150
- # их по одному: исполнитель правит основание, повторяет вызов, упирается в заголовок, правит
151
- # заголовок, упирается в задачу и цена отказа растёт на каждом круге, хотя всё несошедшееся
152
- # было известно уже на первом. Готовность к поставке — одно состояние, и называется оно целиком.
151
+ # Несошедшиеся условия копятся и называются разом: отказ по первому промаху заставляет чинить их
152
+ # по одному правка основания, повтор, заголовок, снова повтор, задача, — хотя всё несошедшееся
153
+ # известно уже на первом круге. Готовность к поставке одно состояние, и называется оно целиком.
153
154
  faults=''
154
155
 
155
156
  fault() {
@@ -163,10 +164,9 @@ fault() {
163
164
  # shellcheck disable=SC1090
164
165
  [ -f "$rt_hooks_dir/git-guard-delivery-folder.sh" ] && . "$rt_hooks_dir/git-guard-delivery-folder.sh" 2>/dev/null
165
166
 
166
- # Конфликтующая своя заявка: тот же приём, что у папки и подписи, свой предмет живёт
167
- # помощником рядом. Зовётся он до всех ярусов ниже и судит не готовность этой работы, а право
168
- # брать следующую: пока отданное конфликтует, его чинят первым действием хода. Нет помощника —
169
- # ярус не судится, а работа идёт дальше.
167
+ # Конфликтующая своя заявка: тот же приём, что у папки и подписи. Зовётся помощник до всех ярусов
168
+ # ниже и судит не готовность этой работы, а право брать следующую: пока отданное конфликтует, его
169
+ # чинят первым действием хода. Нет помощника — ярус не судится, а работа идёт дальше.
170
170
  # shellcheck disable=SC1090
171
171
  [ -f "$rt_hooks_dir/git-guard-delivery-conflict.sh" ] && . "$rt_hooks_dir/git-guard-delivery-conflict.sh" 2>/dev/null
172
172
  command -v rt_delivery_conflict >/dev/null 2>&1 && rt_delivery_conflict
@@ -225,10 +225,14 @@ fi
225
225
  if [ -n "$branch_arg" ]; then
226
226
  # Имя, притворяющееся веткой под задачу, но не совпадающее с формой, — это промах в имени,
227
227
  # а не осознанная беззадачная ветка. Ловится до первого коммита.
228
- if printf '%s' "$branch_arg" | grep -qiE '^[A-Za-z]+-[0-9]+'; then
228
+ # Номер вынимает профиль: своя регулярка знала одну приставку — ключ задач, — и ветка вида
229
+ # `feat/88-slug`, законная по форме того же профиля, номера не давала вовсе. Проверка формы
230
+ # при заведении не срабатывала на ней ни разу, а сверка номеров пропускалась молча.
231
+ number_arg="$(rt_task_branch_number "$branch_arg")"
232
+ if [ -n "$number_arg" ]; then
229
233
  rt_task_branch_ok "$branch_arg" \
230
234
  || deny "BLOCKED: имя ветки «${branch_arg}» не той формы, что принята здесь. Номер у ветки тот же, что у задачи и у заголовка заявки на слияние."
231
- check_task "$(printf '%s' "$branch_arg" | sed -nE 's/^[A-Za-z]+-([0-9]+).*/\1/p')" "ветка «${branch_arg}»"
235
+ check_task "$number_arg" "ветка «${branch_arg}»"
232
236
 
233
237
  # Условия поставки, известные уже здесь, спрашиваются здесь. Прежде их спрашивали на
234
238
  # пуше и на открытии PR — то есть после того, как работа сделана: основание чинится
@@ -374,7 +378,7 @@ branch="$(git branch --show-current 2>/dev/null)"
374
378
  rt_task_branch_ok "$branch" \
375
379
  || deny "BLOCKED: заявка с ветки «${branch}», за которой не стоит задачи. Правка начинается с задачи, видимой в очереди работ: заведи её — ${task_new} — и перенеси работу в ветку с её номером."
376
380
 
377
- number="$(printf '%s' "$branch" | sed -nE 's/^[A-Za-z]+-([0-9]+).*/\1/p')"
381
+ number="$(rt_task_branch_number "$branch")"
378
382
 
379
383
  title=''
380
384
  if command -v perl >/dev/null 2>&1; then
@@ -154,6 +154,28 @@ if [ -z "$failed" ] && [ -n "$skipped" ]; then
154
154
  printf 'гейт пуша: набор прошёл, но эти проверки смотреть было не на что:\n%s\n' "$skipped" >&2
155
155
  fi
156
156
 
157
+ # Чем набор гейта уже набора конвейера — сказанное один раз за сессию.
158
+ #
159
+ # Правило поставки требует, чтобы гейт не был уже конвейера, а собрать это требование нечем:
160
+ # файл конвейера у каждого дерева свой, и вывести из него шаги набора пакет не может. Молчание
161
+ # при этом читается как «проверено всё»: расхождение узнаётся из красного конвейера после
162
+ # заявки, когда правка уже отдана человеку.
163
+ #
164
+ # Отметка живёт в каталоге временных файлов, как у слова о нехватке функции профиля: на каждый
165
+ # пуш та же строка повторялась бы за заход десятки раз и перестала бы читаться.
166
+ gap_key="$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null)"
167
+ [ -z "$gap_key" ] && gap_key="$(date +%Y%m%d 2>/dev/null || printf 'nosession')"
168
+ gap_mark="${TMPDIR:-/tmp}/rt-kit-push-gate-gap-$gap_key"
169
+ if [ ! -f "$gap_mark" ]; then
170
+ printf 'гейт пуша: набор гейта — не набор конвейера. Сборка образов, снимки показа и проверка
171
+ ' >&2
172
+ printf 'собранных пакетов в него не входят: что в нём стоит, показывает разбор состояния
173
+ ' >&2
174
+ printf '(agent-kit doctor, раздел «набор перед пушем»), — прогони недостающее до заявки.
175
+ ' >&2
176
+ : >"$gap_mark" 2>/dev/null || true
177
+ fi
178
+
157
179
  [ -z "$failed" ] && exit 0
158
180
 
159
181
  # Хвост вывода, а не весь: у прогонщика он длинный, а нужна причина отказа.
@@ -15,11 +15,32 @@ GLOSSARY="${CLAUDE_PROJECT_DIR:-.}/docs/GLOSSARY.md"
15
15
  [ -f "$GLOSSARY" ] || exit 0
16
16
  command -v jq >/dev/null 2>&1 || exit 0
17
17
 
18
+ # Где слово заводят. Словарь бывает разложен пакетом — тогда правка на его месте теряется на
19
+ # следующей раскладке, а до тех пор раскладка отказывает по нему целиком. Вводная, которую читает
20
+ # каждая сессия, обязана вести в надстройку: она — единственный текст о словаре, доезжающий до
21
+ # читателя гарантированно, и, ведя в собранный файл, она отменяет собой всё сказанное на других
22
+ # слоях. Опровержение стоит строкой ниже, в шапке раскладки, но читается служебной строкой сборки:
23
+ # начинается оно с версии и суммы, а не со слова.
24
+ #
25
+ # Адрес выводится из идентификатора ресурса в самой шапке, а не зашивается: каталог надстроек у
26
+ # каждого дерева свой, и зашитый путь врал бы в первом же, которое держит их иначе. Шапки нет —
27
+ # словарь принадлежит дереву целиком, и «заводится здесь же» верно как есть.
28
+ header="$(head -1 "$GLOSSARY" 2>/dev/null)"
29
+ resource="$(printf '%s' "$header" | sed -n 's/.*rt-kit v[^ ]* · \([^ ]*\) · .*/\1/p')"
30
+
31
+ if [ -n "$resource" ]; then
32
+ where="$(printf 'Словарь разложен пакетом и на месте не правится: новое слово идёт в надстройку\n.claude/rt-kit/overrides/%s — она сливается по разделу «## » и раскладку переживает.\n' "$resource")"
33
+ else
34
+ where='Словарь принадлежит дереву целиком: новое слово заводится в нём же той же правкой.
35
+ '
36
+ fi
37
+
18
38
  {
19
39
  printf 'СЛОВАРЬ ПРОЕКТА — `docs/GLOSSARY.md`, ниже целиком.\n\n'
20
40
  printf 'Слово отсюда употребляется в том значении, что здесь: и в файлах, и в ответах владельцу.\n'
21
41
  printf 'Раздел «Так не пишем» действует так же — левая колонка не пишется и не произносится нигде.\n'
22
- printf 'Слова, которого здесь нет, у читателя нет тоже: оно либо заводится здесь же той же правкой,\n'
23
- printf 'либо заменяется тем, что уже есть.\n\n'
42
+ printf 'Слова, которого здесь нет, у читателя нет тоже: оно вводится тем же изменением, которым\n'
43
+ printf 'впервые употреблено, либо заменяется тем, что уже есть.\n'
44
+ printf '%s\n' "$where"
24
45
  cat "$GLOSSARY"
25
46
  } | jq -Rs '{hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:.}}'