@rt-tools/agent-kit 0.4.0 → 0.5.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/assets/checks/board.github.mjs +45 -2
- package/assets/checks/check-board.github.mjs +7 -14
- package/assets/checks/check-specs.mjs +89 -10
- package/assets/defaults/gate-map.sh +8 -2
- package/assets/defaults/project.sh +25 -0
- package/assets/hooks/git-guard-delivery.sh +87 -4
- package/assets/hooks/grill-gate.sh +96 -0
- package/assets/hooks/task-flow-guard.sh +14 -3
- package/assets/laws/project-documentation.md +10 -0
- package/assets/laws/verifiability.md +13 -0
- package/assets/laws/work-conduct.md +11 -0
- package/assets/patterns/git-workflow-commit.github.md +4 -0
- package/assets/patterns/spec-driven-domain.md +16 -0
- package/assets/patterns/task-flow-close.md +71 -7
- package/assets/patterns/task-flow-start.md +14 -2
- package/assets/rules/angular-patterns.md +4 -0
- package/assets/rules/browser-verification.md +4 -3
- package/assets/rules/doc-style.md +14 -0
- package/assets/rules/spec-driven.md +11 -1
- package/assets/rules/task-flow.md +42 -2
- package/assets/skills/agent-kit.md +4 -0
- package/assets/templates/rule.md +1 -1
- package/lib/commands.d.ts.map +1 -1
- package/lib/commands.js +1 -1
- package/lib/commands.js.map +1 -1
- package/lib/hooks-map.d.ts +5 -2
- package/lib/hooks-map.d.ts.map +1 -1
- package/lib/hooks-map.js +11 -6
- package/lib/hooks-map.js.map +1 -1
- package/package.json +1 -1
- package/rt-tools-agent-kit-0.5.0.tgz +0 -0
- package/rt-tools-agent-kit-0.4.0.tgz +0 -0
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
* функции возвращают `null`, командный режим печатает `{"offline":true}`.
|
|
20
20
|
*/
|
|
21
21
|
import { execFileSync } from 'node:child_process';
|
|
22
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
23
23
|
import { homedir } from 'node:os';
|
|
24
|
+
import { join } from 'node:path';
|
|
24
25
|
|
|
25
|
-
import { CONFIG } from './rt-kit-checks.config.mjs';
|
|
26
|
+
import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Адрес борды и её колонки живут в `.claude/rt-kit/checks.json`: идентификаторы проекта, поля
|
|
@@ -218,6 +219,48 @@ export function numberFromTitle(title) {
|
|
|
218
219
|
return match ? Number(match[1]) : null;
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Номер задачи по имени папки. Ключ впереди необязателен: имя ветки вида `chore/312-slug`
|
|
224
|
+
* тоже законно, и папка под ним называется голым числом. Если сверка не распознает в имени
|
|
225
|
+
* номер, папка будет лежать среди текущих сколько угодно — одну такую нашли грепом, а не
|
|
226
|
+
* проверкой.
|
|
227
|
+
*/
|
|
228
|
+
export function numberFromTaskDir(name) {
|
|
229
|
+
// Ключ подставляем, только если дерево его задало: из пустого получилось бы `^(?:-)?`, и
|
|
230
|
+
// папка с ключом в имени вообще перестала бы распознаваться.
|
|
231
|
+
const prefix = TASK_KEY ? `(?:${TASK_KEY}-)?` : '';
|
|
232
|
+
const match = new RegExp(`^${prefix}(\\d+)-`).exec(name ?? '');
|
|
233
|
+
return match ? Number(match[1]) : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Папки задач, включая вложенные. Путь повторяет имя ветки целиком, вместе с косой, поэтому
|
|
238
|
+
* папка ветки `chore/312-slug` лежит на втором уровне — обход только по верхнему её не видит.
|
|
239
|
+
*
|
|
240
|
+
* Вглубь спускаемся ровно на один уровень: в имени ветки одна косая, а всё, что глубже, папкой
|
|
241
|
+
* задачи уже не будет — зато туда попал бы архив, если дерево держит его внутри.
|
|
242
|
+
*/
|
|
243
|
+
export function taskDirs(dir = join(ROOT, CONFIG.tasksDir), prefix = '') {
|
|
244
|
+
if (!existsSync(dir)) {
|
|
245
|
+
return [];
|
|
246
|
+
}
|
|
247
|
+
const found = [];
|
|
248
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
249
|
+
if (!entry.isDirectory() || entry.name === '_template') {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const name = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
253
|
+
if (entry.name.startsWith('_draft-') || numberFromTaskDir(entry.name) !== null) {
|
|
254
|
+
found.push(name);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (!prefix) {
|
|
258
|
+
found.push(...taskDirs(join(dir, entry.name), name));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return found;
|
|
262
|
+
}
|
|
263
|
+
|
|
221
264
|
export function numberFromBranch(branch) {
|
|
222
265
|
const match = BRANCH_NUMBER.exec(branch ?? '');
|
|
223
266
|
return match ? Number(match[1]) : null;
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*
|
|
25
25
|
* Ненулевой код возврата и перечень расхождений.
|
|
26
26
|
*/
|
|
27
|
-
import {
|
|
27
|
+
import { statSync } from 'node:fs';
|
|
28
28
|
import { join } from 'node:path';
|
|
29
29
|
|
|
30
30
|
import {
|
|
@@ -36,7 +36,9 @@ import {
|
|
|
36
36
|
fetchBoard,
|
|
37
37
|
fetchIssues,
|
|
38
38
|
fetchOpenPulls,
|
|
39
|
+
numberFromTaskDir,
|
|
39
40
|
numberFromTitle,
|
|
41
|
+
taskDirs,
|
|
40
42
|
} from './board.mjs';
|
|
41
43
|
import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
|
|
42
44
|
|
|
@@ -48,15 +50,6 @@ const DRAFT_DAYS = 7;
|
|
|
48
50
|
const problems = [];
|
|
49
51
|
const report = (message) => problems.push(message);
|
|
50
52
|
|
|
51
|
-
function taskDirs() {
|
|
52
|
-
if (!existsSync(TASKS_DIR)) {
|
|
53
|
-
return [];
|
|
54
|
-
}
|
|
55
|
-
return readdirSync(TASKS_DIR, { withFileTypes: true })
|
|
56
|
-
.filter((entry) => entry.isDirectory() && entry.name !== '_template')
|
|
57
|
-
.map((entry) => entry.name);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
53
|
/**
|
|
61
54
|
* Разбор просьбы владельца идёт до заведения задачи и лежит в `_draft-<slug>`. Заглохший на
|
|
62
55
|
* середине, он остаётся на диске вне истории и выглядит так же, как начатый сегодня: второй
|
|
@@ -71,7 +64,7 @@ function checkDrafts() {
|
|
|
71
64
|
}
|
|
72
65
|
const age = Math.floor((now - statSync(join(TASKS_DIR, name)).mtimeMs) / 86400000);
|
|
73
66
|
if (age >= DRAFT_DAYS) {
|
|
74
|
-
report(
|
|
67
|
+
report(`${CONFIG.tasksDir}/${name}/: разбор брошен ${age} дн. назад — заведи задачу или удали папку`);
|
|
75
68
|
}
|
|
76
69
|
}
|
|
77
70
|
}
|
|
@@ -153,12 +146,12 @@ try {
|
|
|
153
146
|
// в `docs/archive/`, остальное удаляется. Оставленная рядом с текущими, она читается как
|
|
154
147
|
// текущая — тем убедительнее, чем старше.
|
|
155
148
|
for (const name of taskDirs()) {
|
|
156
|
-
const number =
|
|
157
|
-
if (
|
|
149
|
+
const number = numberFromTaskDir(name.split('/').pop());
|
|
150
|
+
if (number === null || openNumbers.has(number)) {
|
|
158
151
|
continue;
|
|
159
152
|
}
|
|
160
153
|
if (issues.some((issue) => issue.number === number)) {
|
|
161
|
-
report(
|
|
154
|
+
report(`${CONFIG.tasksDir}/${name}/: задача #${number} закрыта, а папка лежит среди текущих — разбери её`);
|
|
162
155
|
}
|
|
163
156
|
}
|
|
164
157
|
} catch (error) {
|
|
@@ -730,6 +730,20 @@ function collectReferences() {
|
|
|
730
730
|
// идентификатором: состояние теста читается, когда файл разобран целиком
|
|
731
731
|
found.forEach(({ id, test: own, place }) => remember(id, { place, screen: Boolean(e2eRoot), off: Boolean(own?.off) }));
|
|
732
732
|
}
|
|
733
|
+
|
|
734
|
+
// Наборы сценариев на shell. Так проверяются исполняемые файлы — гарды, проверки,
|
|
735
|
+
// умолчания: они не на TypeScript, и набор к ним пишут на том же языке, что и их
|
|
736
|
+
// самих. Выключателей здесь нет: пропустить сценарий в таком наборе нечем, поэтому
|
|
737
|
+
// достаточно найти идентификатор.
|
|
738
|
+
for (const file of walk(root, (name) => name.endsWith('.test.sh'))) {
|
|
739
|
+
read(file)
|
|
740
|
+
.split('\n')
|
|
741
|
+
.forEach((line, index) => {
|
|
742
|
+
for (const [id] of line.matchAll(SCENARIO_REFERENCE)) {
|
|
743
|
+
remember(id, { place: `${file}:${index + 1}`, screen: false, off: false });
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
}
|
|
733
747
|
}
|
|
734
748
|
|
|
735
749
|
return references;
|
|
@@ -774,15 +788,47 @@ for (const file of walk(CONSTITUTION_DIR, (name) => name.endsWith('.md'))) {
|
|
|
774
788
|
laws.set(name, file);
|
|
775
789
|
}
|
|
776
790
|
|
|
791
|
+
/**
|
|
792
|
+
* Директории, описывающие предмет: сам домен и его поддомены. Поддомен заводится, когда домен
|
|
793
|
+
* вырос настолько, что читать его целиком ради одной подробности дороже, чем найти её; устроен
|
|
794
|
+
* он так же — три файла и свой префикс сценариев.
|
|
795
|
+
*
|
|
796
|
+
* `proposed/` предметом не является: это договорённость о продукте до кода, и её сценарии живут
|
|
797
|
+
* в нумерации того спека, в который она вольётся.
|
|
798
|
+
*/
|
|
799
|
+
function collectSpecDirs(base) {
|
|
800
|
+
const found = [base];
|
|
801
|
+
let entries;
|
|
802
|
+
try {
|
|
803
|
+
entries = readdirSync(join(ROOT, base), { withFileTypes: true });
|
|
804
|
+
} catch {
|
|
805
|
+
return found;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
for (const entry of entries) {
|
|
809
|
+
if (entry.isDirectory() && entry.name !== 'proposed') {
|
|
810
|
+
found.push(...collectSpecDirs(`${base}/${entry.name}`));
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return found;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Префикс сценариев принадлежит одному спеку по всему дереву. */
|
|
818
|
+
const prefixOwners = new Map();
|
|
819
|
+
|
|
777
820
|
for (const domain of domains) {
|
|
778
821
|
const base = `${SPECS_DIR}/${domain}`;
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
822
|
+
for (const dir of collectSpecDirs(base)) {
|
|
823
|
+
// Спек, у которого есть только `proposed/`, ещё не существует: его самого нет, пока
|
|
824
|
+
// фича не выкачена
|
|
825
|
+
if (exists(`${dir}/proposed`) && !exists(`${dir}/spec.md`)) {
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
const what = dir === base ? 'домен' : 'поддомен';
|
|
783
829
|
['spec.md', 'scenarios.md']
|
|
784
|
-
.filter((name) => !exists(`${
|
|
785
|
-
.forEach((name) => report(
|
|
830
|
+
.filter((name) => !exists(`${dir}/${name}`))
|
|
831
|
+
.forEach((name) => report(dir, `нет файла \`${name}\` — ${what} описан наполовину`));
|
|
786
832
|
}
|
|
787
833
|
|
|
788
834
|
// Спеки фич из `proposed/` проверяются наравне со спеком домена: они и есть
|
|
@@ -803,10 +849,38 @@ for (const domain of domains) {
|
|
|
803
849
|
}
|
|
804
850
|
|
|
805
851
|
const found = walk(base, (name) => name === 'scenarios.md').flatMap(parseScenarios);
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
852
|
+
|
|
853
|
+
// Префикс судится в пределах одного спека, а не всего дерева домена: у поддомена он свой, и
|
|
854
|
+
// по номеру видно, о чём сценарий. Два префикса в одном спеке по-прежнему означают, что
|
|
855
|
+
// предмет описан дважды.
|
|
856
|
+
const prefixesOf = new Map();
|
|
857
|
+
for (const scenario of found) {
|
|
858
|
+
const dir = dirname(scenario.file);
|
|
859
|
+
if (!prefixesOf.has(dir)) {
|
|
860
|
+
prefixesOf.set(dir, new Set());
|
|
861
|
+
}
|
|
862
|
+
prefixesOf.get(dir).add(scenario.prefix);
|
|
809
863
|
}
|
|
864
|
+
|
|
865
|
+
for (const [dir, prefixes] of prefixesOf) {
|
|
866
|
+
if (prefixes.size > 1) {
|
|
867
|
+
report(dir, `в спеке больше одного префикса сценариев: ${[...prefixes].sort().join(', ')}`);
|
|
868
|
+
}
|
|
869
|
+
// Договорённость о продукте нумеруется вместе со спеком, в который вольётся:
|
|
870
|
+
// идентификаторы переезд переживают, и занятым префикс от неё не становится
|
|
871
|
+
if (dir.includes('/proposed/')) {
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
for (const prefix of prefixes) {
|
|
875
|
+
const owner = prefixOwners.get(prefix);
|
|
876
|
+
if (owner && owner !== dir) {
|
|
877
|
+
report(dir, `префикс \`SC-${prefix}\` уже занят — \`${owner}\`; по номеру не видно, чей сценарий`);
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
prefixOwners.set(prefix, dir);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
810
884
|
scenarios.push(...found);
|
|
811
885
|
}
|
|
812
886
|
|
|
@@ -887,11 +961,16 @@ for (const file of walk('.claude/skills', (name) => name === 'SKILL.md')) {
|
|
|
887
961
|
}
|
|
888
962
|
}
|
|
889
963
|
|
|
964
|
+
// Предложенный закон правила не требует: договорённость записана раньше кода, привязывать её
|
|
965
|
+
// не к чему, и требование правила заставило бы завести его с якорями в несуществующие места.
|
|
966
|
+
// Признак стоит строкой статуса в самом законе, а не списком исключений рядом с проверкой.
|
|
967
|
+
const isProposedLaw = (file) => /^\*\*Статус:\*\*\s*предложен/m.test(read(file));
|
|
968
|
+
|
|
890
969
|
// Обратные стороны связи. Закон без правила читается как договорённость, которую этот проект
|
|
891
970
|
// не применяет; правило без паттерна оставляет готовый код там, где ему не место, — в самом
|
|
892
971
|
// правиле, которое читается при каждой правке.
|
|
893
972
|
[...laws]
|
|
894
|
-
.filter(([law]) => !ruled.has(law))
|
|
973
|
+
.filter(([law, file]) => !ruled.has(law) && !isProposedLaw(file))
|
|
895
974
|
.forEach(([, file]) => report(file, 'у закона нет ни одного правила — заведи скил с `law:` на него'));
|
|
896
975
|
|
|
897
976
|
for (const file of walk('.claude/skills', (name) => name === 'SKILL.md')) {
|
|
@@ -61,8 +61,14 @@ skill_for_default() {
|
|
|
61
61
|
*/docs/constitution/*) printf '%s\n' 'spec-driven' ;;
|
|
62
62
|
*.md) printf '%s\n' 'doc-style' ;;
|
|
63
63
|
|
|
64
|
-
# Поставка: состав зависимостей — это то, что приезжает на прод.
|
|
65
|
-
|
|
64
|
+
# Поставка: состав зависимостей — это то, что приезжает на прод. Правка
|
|
65
|
+
# скриптов зависимостью не является, и правило про версии на неё не вступает.
|
|
66
|
+
# Оговорка: удаление зависимости приходит правкой без номера версии и сюда не
|
|
67
|
+
# попадает — его ловит снимок дерева, который правится тем же коммитом.
|
|
68
|
+
*/package.json)
|
|
69
|
+
printf '%s' "$written" | grep -qE '"(dependencies|devDependencies|peerDependencies|optionalDependencies|overrides|resolutions|packageManager)"|"[^"]+"[[:space:]]*:[[:space:]]*"[~^]?[0-9]+\.[0-9]+' \
|
|
70
|
+
&& printf '%s\n' 'dependencies' ;;
|
|
71
|
+
*/pnpm-lock.yaml | */pnpm-workspace.yaml | */package-lock.json) printf '%s\n' 'dependencies' ;;
|
|
66
72
|
|
|
67
73
|
# Границы между либами: манифест, алиасы, барель.
|
|
68
74
|
*/project.json | */tsconfig.base.json | */eslint/boundaries/* | */src/index.ts | */public-api.ts | */ng-package.json)
|
|
@@ -72,6 +72,30 @@ rt_lint_for_default() {
|
|
|
72
72
|
# Каталог папок задач. Пусто — ведения работы папкой в дереве нет, и гард замысла молчит.
|
|
73
73
|
RT_TASKS_DIR="${RT_TASKS_DIR:-docs/tasks}"
|
|
74
74
|
|
|
75
|
+
# Каталог записей о законченных работах. Туда переносят то, что объясняет решения закрытой
|
|
76
|
+
# задачи. Гард поставки требует, чтобы ветка, удалившая папку задачи, что-то сюда добавила:
|
|
77
|
+
# удалить проще, чем разобрать, а слова владельца больше нигде не записаны.
|
|
78
|
+
RT_ARCHIVE_DIR="${RT_ARCHIVE_DIR:-docs/archive}"
|
|
79
|
+
|
|
80
|
+
# Где лежат тексты, которые читают до вопроса владельцу: законы, правила и договорённости о
|
|
81
|
+
# продукте. По ним гард разговора судит, читалось ли за ход хоть что-то, и их же называет в
|
|
82
|
+
# подсказке. Пусто у законов и правил разом — дерево этого требования не получает: читать
|
|
83
|
+
# нечего.
|
|
84
|
+
RT_LAWS_DIR="${RT_LAWS_DIR:-docs/constitution}"
|
|
85
|
+
RT_RULES_DIR="${RT_RULES_DIR:-.claude/skills}"
|
|
86
|
+
RT_SPECS_DIR="${RT_SPECS_DIR:-docs/specs}"
|
|
87
|
+
|
|
88
|
+
# Главная ветка. Гарду она нужна, чтобы найти общего предка и понять, что ветка сделала с
|
|
89
|
+
# папкой задачи и с архивом. Если общего предка нет, сравнивать не с чем — проверка молчит.
|
|
90
|
+
RT_MAIN_BRANCH="${RT_MAIN_BRANCH:-main}"
|
|
91
|
+
|
|
92
|
+
# Тело PR по его номеру. Обход требования пишут в PR, а в команде слияния его нет — там только
|
|
93
|
+
# номер. Пустой ответ значит «спросить не у кого»: тогда обход ищется только в тексте команды.
|
|
94
|
+
rt_report_body_default() {
|
|
95
|
+
command -v gh >/dev/null 2>&1 || return 1
|
|
96
|
+
gh pr view "$1" --json body -q '.body' 2>/dev/null
|
|
97
|
+
}
|
|
98
|
+
|
|
75
99
|
# Код приложения ли это. Успех — да, и тогда правка требует замысла на диске.
|
|
76
100
|
#
|
|
77
101
|
# Признак — путь, а не оценка на глаз: оценку назначает тот, кому она мешает, и порог плывёт.
|
|
@@ -177,3 +201,4 @@ rt_reinvented_in() { rt_reinvented_in_default "$@"; }
|
|
|
177
201
|
rt_is_app_code() { rt_is_app_code_default "$@"; }
|
|
178
202
|
rt_qa_decorative() { rt_qa_decorative_default "$@"; }
|
|
179
203
|
rt_task_state() { rt_task_state_default "$@"; }
|
|
204
|
+
rt_report_body() { rt_report_body_default "$@"; }
|
|
@@ -79,6 +79,14 @@ title_re="${RT_TASK_TITLE_RE:-^\[[A-Za-z]+-[0-9]+\][[:space:]]+[^[:space:]]}"
|
|
|
79
79
|
task_new="${RT_TASK_NEW_CMD:-npm run task:new}"
|
|
80
80
|
board_check="${RT_BOARD_CHECK_CMD:-npm run check:board}"
|
|
81
81
|
task_bot="${RT_TASK_BOT:-}"
|
|
82
|
+
tasks_dir="${RT_TASKS_DIR:-}"
|
|
83
|
+
archive_dir="${RT_ARCHIVE_DIR:-}"
|
|
84
|
+
main_branch="${RT_MAIN_BRANCH:-main}"
|
|
85
|
+
|
|
86
|
+
# Обход требования: строка с причиной. Причина видна тому, кто вливает, поэтому обход законен.
|
|
87
|
+
# Без причины это просто молчаливый пропуск, поэтому она обязательна. Порог в три знака — тот
|
|
88
|
+
# же, что у гарда документа: если сделать по-разному, две формы одного обхода разойдутся.
|
|
89
|
+
folder_skip_re='Task-folder-skip:[[:space:]]*[^[:space:]"'"'"']{3,}'
|
|
82
90
|
|
|
83
91
|
deny() {
|
|
84
92
|
jq -n --arg r "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}' 2>/dev/null \
|
|
@@ -86,6 +94,21 @@ deny() {
|
|
|
86
94
|
exit 0
|
|
87
95
|
}
|
|
88
96
|
|
|
97
|
+
# Подсказка вместо отказа: на открытии отчёта папка ещё нужна. Решения подсказка не несёт,
|
|
98
|
+
# команда идёт дальше своим ходом.
|
|
99
|
+
hint() {
|
|
100
|
+
jq -n --arg c "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$c}}' 2>/dev/null
|
|
101
|
+
exit 0
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Есть ли папка задачи в ветке. Смотрим содержимое ветки, а не рабочее дерево: если папку
|
|
105
|
+
# удалили, но не закоммитили, проверка прошла бы, а папка всё равно уехала бы в main. Имя
|
|
106
|
+
# ветки подставляем целиком, вместе с косой: у ветки вида `chore/312-slug` папка лежит во
|
|
107
|
+
# вложенном каталоге.
|
|
108
|
+
folder_in_branch() {
|
|
109
|
+
git ls-tree -d --name-only HEAD -- "$1" 2>/dev/null | head -1
|
|
110
|
+
}
|
|
111
|
+
|
|
89
112
|
check_task() {
|
|
90
113
|
number="$1"
|
|
91
114
|
where="$2"
|
|
@@ -127,11 +150,62 @@ if [ -n "$branch_arg" ]; then
|
|
|
127
150
|
exit 0
|
|
128
151
|
fi
|
|
129
152
|
|
|
153
|
+
# --- слияние заявки ----------------------------------------------------------------------
|
|
154
|
+
#
|
|
155
|
+
# Папку задачи разбирают тем же PR, что и работу. После слияния этого уже никто не сделает:
|
|
156
|
+
# работа перешла к следующей задаче, а PR закрыт. Раньше слияния требовать нельзя — пока идёт
|
|
157
|
+
# ревью, plan.md нужен на диске, иначе гард хода работы не даст править код.
|
|
158
|
+
# Команду ищем от начала строки или после разделителя, а не где угодно в тексте. Иначе гард
|
|
159
|
+
# отбивает сообщение, где `gh pr merge` просто упомянут в кавычках, — так он и сработал на
|
|
160
|
+
# правке этого же текста. Полностью подстроку в кавычках так не отсечь, но случайное упоминание
|
|
161
|
+
# внутри слова или пути мимо уже не пройдёт.
|
|
162
|
+
if printf '%s' "$cmd" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*(gh[[:space:]]+pr[[:space:]]+merge|glab[[:space:]]+mr[[:space:]]+merge|az[[:space:]]+repos[[:space:]]+pr[[:space:]]+update)([[:space:]]|$)'; then
|
|
163
|
+
[ -n "$tasks_dir" ] || exit 0 # ведения работы папкой в дереве нет
|
|
164
|
+
|
|
165
|
+
merge_branch="$(git branch --show-current 2>/dev/null)"
|
|
166
|
+
[ -z "$merge_branch" ] && exit 0
|
|
167
|
+
rt_task_branch_ok "$merge_branch" || exit 0 # за беззадачной веткой папки не стоит
|
|
168
|
+
|
|
169
|
+
folder="$tasks_dir/$merge_branch"
|
|
170
|
+
|
|
171
|
+
# Сначала ищем обход в самой команде — это работает и без сети. Если читать только
|
|
172
|
+
# тело PR, то без сети гард отбил бы слияние, причина которого в этом теле и написана.
|
|
173
|
+
printf '%s' "$cmd" | grep -qiE "$folder_skip_re" && exit 0
|
|
174
|
+
|
|
175
|
+
merge_number="$(printf '%s' "$cmd" | sed -nE 's/.*(pr|mr)[[:space:]]+(merge|update)[[:space:]]+([0-9]+).*/\3/p' | head -1)"
|
|
176
|
+
if [ -n "$merge_number" ] && command -v rt_report_body >/dev/null 2>&1; then
|
|
177
|
+
body="$(cd "$root" && rt_report_body "$merge_number" 2>/dev/null)"
|
|
178
|
+
[ -n "$body" ] && printf '%s' "$body" | grep -qiE "$folder_skip_re" && exit 0
|
|
179
|
+
fi
|
|
180
|
+
|
|
181
|
+
lying="$(folder_in_branch "$folder")"
|
|
182
|
+
[ -n "$lying" ] \
|
|
183
|
+
&& deny "BLOCKED: в ветке осталась папка задачи «${lying}» — она уедет в главную. Разобрать её потом будет некому: работа перейдёт к следующей задаче, а этот PR закроется. Перенеси в «${archive_dir:-архив}» то, что объясняет принятые решения, остальное удали и повтори. Если работа вливается частями, поставь в тело PR строку «Task-folder-skip: <причина>»."
|
|
184
|
+
|
|
185
|
+
# Запись в архиве спрашиваем только у ветки, которая папку удалила. Иначе проверка
|
|
186
|
+
# цеплялась бы к работе, у которой папки и не было. Без общего предка с главной веткой
|
|
187
|
+
# сравнивать не с чем — тогда молчим.
|
|
188
|
+
[ -n "$archive_dir" ] || exit 0
|
|
189
|
+
base="$(git merge-base "$main_branch" HEAD 2>/dev/null)"
|
|
190
|
+
[ -z "$base" ] && exit 0
|
|
191
|
+
|
|
192
|
+
had="$(git ls-tree -d --name-only "$base" -- "$folder" 2>/dev/null | head -1)"
|
|
193
|
+
[ -z "$had" ] && had="$(git log "$base..HEAD" --diff-filter=A --name-only --pretty=format: -- "$folder" 2>/dev/null | head -1)"
|
|
194
|
+
[ -z "$had" ] && exit 0
|
|
195
|
+
|
|
196
|
+
gained="$(git diff --name-only --diff-filter=A "$base" HEAD -- "$archive_dir" 2>/dev/null | head -1)"
|
|
197
|
+
[ -z "$gained" ] \
|
|
198
|
+
&& deny "BLOCKED: папку задачи удалили, но в «${archive_dir}» ветка ничего не добавила. Удалить проще, чем разобрать, — и вместе с папкой пропадает разбор просьбы, единственная запись слов владельца. Перенеси то, что объясняет принятые решения, одним файлом с понятным именем и повтори."
|
|
199
|
+
|
|
200
|
+
exit 0
|
|
201
|
+
fi
|
|
202
|
+
|
|
130
203
|
# --- открытие заявки на слияние ----------------------------------------------------------
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
204
|
+
# Команду ищем от начала строки или после разделителя — по той же причине, что и слияние:
|
|
205
|
+
# упоминание в кавычках командой не является.
|
|
206
|
+
printf '%s' "$cmd" \
|
|
207
|
+
| grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*(gh[[:space:]]+pr[[:space:]]+create|glab[[:space:]]+mr[[:space:]]+create|az[[:space:]]+repos[[:space:]]+pr[[:space:]]+create)([[:space:]]|$)' \
|
|
208
|
+
|| exit 0
|
|
135
209
|
|
|
136
210
|
branch="$(git branch --show-current 2>/dev/null)"
|
|
137
211
|
[ -z "$branch" ] && exit 0 # открепившийся HEAD — не про этот случай
|
|
@@ -164,4 +238,13 @@ fi
|
|
|
164
238
|
|
|
165
239
|
check_task "$number" "заявка с ветки «${branch}»"
|
|
166
240
|
|
|
241
|
+
# Сейчас папка ещё нужна: правки по замечаниям ревью идут в эту же ветку, а без plan.md их не
|
|
242
|
+
# пропустит гард хода работы. Поэтому здесь только напоминание. Требование стоит на слиянии —
|
|
243
|
+
# там папка уже не нужна, а вред от неё как раз и наступает.
|
|
244
|
+
if [ -n "$tasks_dir" ]; then
|
|
245
|
+
lying="$(folder_in_branch "$tasks_dir/$branch")"
|
|
246
|
+
[ -n "$lying" ] \
|
|
247
|
+
&& hint "В ветке лежит папка задачи «${lying}». Разбери её до слияния, этим же PR: потом за неё уже никто не возьмётся. На слиянии это будет отказ, а не напоминание."
|
|
248
|
+
fi
|
|
249
|
+
|
|
167
250
|
exit 0
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# rt-hook: Stop
|
|
3
|
+
# Гард разговора: ход, в котором владельцу задан вопрос, не заканчивается, пока за этот же ход
|
|
4
|
+
# не читались законы и правила. Stop.
|
|
5
|
+
#
|
|
6
|
+
# Зачем именно так. Требование «правила читаются до разговора» записано в правиле ведения
|
|
7
|
+
# работы, а исполнения у него не было: все прочие гарды судят правку файла или команду, а
|
|
8
|
+
# вопрос в чат ни тем, ни другим не является. Поймать его можно только на завершении хода —
|
|
9
|
+
# событие получает путь к записи хода и видит его целиком.
|
|
10
|
+
#
|
|
11
|
+
# Перехват инструмента меню вариантов эту дыру не закрывает: вопрос чаще задаётся прозой, и
|
|
12
|
+
# ровно так был задан тот, из-за которого гард заведён.
|
|
13
|
+
#
|
|
14
|
+
# Чтением правил считается любой из трёх путей: загрузка правила, чтение файла законов или
|
|
15
|
+
# правил, поиск по ним. Требовать именно загрузку значило бы гнать на неё там, где хватило
|
|
16
|
+
# одного поиска, — гард мешал бы работе вместо того, чтобы её выправлять.
|
|
17
|
+
#
|
|
18
|
+
# ОТКАЗ В ПОЛЬЗУ РАБОТЫ: при любой ошибке, отсутствии записи хода и повторном заходе ход
|
|
19
|
+
# РАЗРЕШАЕТСЯ (exit 0). Сломанный гард не имеет права заклинить разговор.
|
|
20
|
+
|
|
21
|
+
input="$(cat 2>/dev/null)"
|
|
22
|
+
[ -z "$input" ] && exit 0
|
|
23
|
+
|
|
24
|
+
command -v jq >/dev/null 2>&1 || exit 0
|
|
25
|
+
|
|
26
|
+
# Повторный заход по тому же ходу не судится: иначе ход не кончится никогда — гард сказал своё
|
|
27
|
+
# один раз и отпускает.
|
|
28
|
+
active="$(printf '%s' "$input" | jq -r '.stop_hook_active // false' 2>/dev/null)"
|
|
29
|
+
[ "$active" = "true" ] && exit 0
|
|
30
|
+
|
|
31
|
+
transcript="$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null)"
|
|
32
|
+
[ -z "$transcript" ] && exit 0
|
|
33
|
+
[ -f "$transcript" ] || exit 0
|
|
34
|
+
|
|
35
|
+
# Профиль дерева: каталоги законов, правил и спеков у каждого свои, а знать их надо и для
|
|
36
|
+
# признака чтения, и для подсказки в отказе.
|
|
37
|
+
rt_hooks_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
38
|
+
for profile in "$rt_hooks_dir/../rt-kit/defaults/project.sh" "$rt_hooks_dir/../defaults/project.sh" "${CLAUDE_PROJECT_DIR:-.}/.claude/rt-kit/defaults/project.sh" "${CLAUDE_PROJECT_DIR:-.}/.claude/rt-kit/project.sh"; do
|
|
39
|
+
# shellcheck disable=SC1090
|
|
40
|
+
[ -f "$profile" ] && . "$profile" 2>/dev/null
|
|
41
|
+
done
|
|
42
|
+
|
|
43
|
+
# Подстановка без двоеточия намеренно: заданное пустым — это отказ дерева от требования, и
|
|
44
|
+
# подменять его умолчанием нельзя. Умолчание достаётся только тому, кто не задал переменной
|
|
45
|
+
# вовсе.
|
|
46
|
+
laws_dir="${RT_LAWS_DIR-docs/constitution}"
|
|
47
|
+
rules_dir="${RT_RULES_DIR-.claude/skills}"
|
|
48
|
+
specs_dir="${RT_SPECS_DIR-docs/specs}"
|
|
49
|
+
|
|
50
|
+
# Дерево, у которого нет ни законов, ни правил, требования не получает: читать нечего.
|
|
51
|
+
[ -z "$laws_dir" ] && [ -z "$rules_dir" ] && exit 0
|
|
52
|
+
|
|
53
|
+
# Образец, по которому вызов инструмента считается чтением правил. Каталоги идут в него как
|
|
54
|
+
# есть: точка в `.claude` совпадает с любым знаком и лишнего сюда не приводит.
|
|
55
|
+
read_re="$(printf '%s' "$laws_dir|$rules_dir|$specs_dir" | sed 's/^|*//; s/|*$//; s/||*/|/g')"
|
|
56
|
+
[ -z "$read_re" ] && exit 0
|
|
57
|
+
|
|
58
|
+
# Ход — это всё, что записано после последнего настоящего ввода владельца. Ответ инструмента
|
|
59
|
+
# приходит той же ролью `user`, поэтому строки с `tool_result` вводом не считаются: иначе ходом
|
|
60
|
+
# оказался бы кусок после последнего вызова инструмента, и чтение правил в его начале потерялось
|
|
61
|
+
# бы.
|
|
62
|
+
#
|
|
63
|
+
# Хвост в 400 строк: запись хода растёт всю сессию, а судится только последний ход.
|
|
64
|
+
verdict="$(tail -n 400 "$transcript" 2>/dev/null | jq -s -r --arg re "$read_re" '
|
|
65
|
+
def is_input:
|
|
66
|
+
.type == "user"
|
|
67
|
+
and (((.message.content // []) | if type == "array"
|
|
68
|
+
then ([.[] | select(.type == "tool_result")] | length)
|
|
69
|
+
else 0 end) == 0);
|
|
70
|
+
|
|
71
|
+
(map(is_input) | rindex(true)) as $i
|
|
72
|
+
| (if $i == null then . else .[$i + 1:] end) as $turn
|
|
73
|
+
| [$turn[] | select(.type == "assistant") | (.message.content // [])[] | select(.type == "text") | .text] as $texts
|
|
74
|
+
| [$turn[] | select(.type == "assistant") | (.message.content // [])[] | select(.type == "tool_use")] as $uses
|
|
75
|
+
| ($uses | map(
|
|
76
|
+
(.name == "Skill")
|
|
77
|
+
or ((.name // "") | test("^(Read|Grep|Glob)$")) and ((.input | tostring) | test($re))
|
|
78
|
+
or ((.name == "Bash") and ((.input.command // "") | test($re)))
|
|
79
|
+
) | any) as $read
|
|
80
|
+
| (($texts | join("\n")) | test("\\?[[:space:]]*$"; "m")) as $asked_prose
|
|
81
|
+
| ($uses | map(.name == "AskUserQuestion") | any) as $asked_menu
|
|
82
|
+
| if ($asked_prose or $asked_menu) and ($read | not) then "ask" else "pass" end
|
|
83
|
+
' 2>/dev/null)"
|
|
84
|
+
|
|
85
|
+
[ "$verdict" = "ask" ] || exit 0
|
|
86
|
+
|
|
87
|
+
reason="BLOCKED by grill-gate: в ответе есть вопрос владельцу, а законы и правила за этот ход не читались. Вопрос, ответ на который уже записан, владельцу не задаётся — правило ведения работы. Прогони поиск по словам темы и ответь по найденному; спрашивай только то, что документацией не покрыто:
|
|
88
|
+
|
|
89
|
+
grep -rn -i \"<слово темы>\" $laws_dir $rules_dir $specs_dir
|
|
90
|
+
|
|
91
|
+
Гард судит один ход: следующий заход не отбивается."
|
|
92
|
+
|
|
93
|
+
jq -n --arg r "$reason" '{decision:"block",reason:$r}' 2>/dev/null \
|
|
94
|
+
|| printf '{"decision":"block","reason":"grill-gate: прочитай законы и правила по теме, прежде чем спрашивать владельца."}\n'
|
|
95
|
+
|
|
96
|
+
exit 0
|
|
@@ -100,8 +100,19 @@ case "$draft" in
|
|
|
100
100
|
*) draft_path="$root/$draft" ;;
|
|
101
101
|
esac
|
|
102
102
|
|
|
103
|
-
if [
|
|
104
|
-
|
|
103
|
+
if [ -e "$draft_path" ]; then
|
|
104
|
+
exit 0
|
|
105
|
+
fi
|
|
106
|
+
|
|
107
|
+
# Договорённость, влитая в спек домена, с диска уходит — так и задумано: в главной ветке
|
|
108
|
+
# директории «предложено» быть не должно. Но замысел на неё ссылается до конца работы, и без
|
|
109
|
+
# этой развилки последний коммит отчёта запирал бы ветку: ни правки по замечаниям разбора, ни
|
|
110
|
+
# записи в журнал изменений после вливания уже не сделать.
|
|
111
|
+
#
|
|
112
|
+
# Влитое от незаведённого отличает история ветки: путь, которого в ней никогда не было,
|
|
113
|
+
# договорённостью не был. Спросить об этом нечем, кроме git, поэтому нет git — отказ остаётся.
|
|
114
|
+
if git -C "$root" log --oneline -1 -- "$draft" 2>/dev/null | grep -q .; then
|
|
115
|
+
exit 0
|
|
105
116
|
fi
|
|
106
117
|
|
|
107
|
-
|
|
118
|
+
deny "BLOCKED by task-flow: замысел называет договорённость '${draft}', а её на диске нет и в истории ветки не было. Заведи её с образца (docs/specs/_template) или поправь путь в '${tasks_dir}/${branch}/plan.md'. Правило — скил task-flow."
|
|
@@ -29,7 +29,17 @@
|
|
|
29
29
|
— это намерение, а не свойство приложения: сверить его не с чем, и оно проходит любую
|
|
30
30
|
проверку. Машине это не поручить: открытый вопрос пишется теми же словами, что и обещание,
|
|
31
31
|
и проверка отбивала бы оба.
|
|
32
|
+
- **Документ утверждает о состоявшемся, а не о том, что должно сработать.** Лечение,
|
|
33
|
+
записанное готовым до того, как его прогнали, дороже отсутствия записи: следующий читатель
|
|
34
|
+
берёт его за проверенное — и берёт в тот день, когда лечение понадобилось, а времени на
|
|
35
|
+
разбор нет. Непрогнанное либо не пишется вовсе, либо названо непроверенным тем же
|
|
36
|
+
предложением.
|
|
32
37
|
- **Число в тексте пересчитывается тем же изменением, которым пишется, и за этим тоже следит
|
|
33
38
|
автор.** Устаревшее число выглядит так же, как свежее, а машине их не различить: дата,
|
|
34
39
|
версия и номер — такие же числа, и проверка, которая знает один способ записи, на другом
|
|
35
40
|
ошибается молча.
|
|
41
|
+
- **Отказ от слова распространяется на всё, что уже прочитано снаружи, а не только на файлы.**
|
|
42
|
+
Название работы, её описание и запись о правке живут вне дерева: поиск по файлам их не
|
|
43
|
+
видит, проверки текстов на них не смотрят, и отказ выглядит сделанным ровно до того, как
|
|
44
|
+
читатель наткнётся на снятое слово в заголовке. Читатель при этом заключает, что от слова
|
|
45
|
+
не отказывались вовсе.
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
выкинутый сценарий: без этого он пропадает молча.
|
|
22
22
|
- **Работающее приложение проверяется там, где его видит пользователь.** Отладочный режим
|
|
23
23
|
ведёт себя иначе рабочего, и проверка в нём подтверждает не то, что будет у пользователя.
|
|
24
|
+
- **Перед отправкой правка проверяется тем же набором, что и конвейер, и теми же командами.**
|
|
25
|
+
Набор, собранный по изменённым файлам, пропускает то, до чего правка дошла связями: проверка
|
|
26
|
+
зелёная, а конвейер красный. Что проверять, считает инструмент от той же базы, а не память
|
|
27
|
+
автора.
|
|
24
28
|
- **Проверка признака окружения относится только к тому пути запуска, на котором она
|
|
25
29
|
сделана.** Пути, которыми одно и то же приложение поднимается, задают признаки по-разному,
|
|
26
30
|
и подтверждённое на одном из них на остальных неверно — а выглядит проверенным целиком.
|
|
@@ -30,6 +34,15 @@
|
|
|
30
34
|
наступило.** Часть запросов выполняется наполовину, и об отклонённой части в ответе ничего
|
|
31
35
|
нет: по коду возврата такой вызов не отличить от исполненного. Поэтому результат читают
|
|
32
36
|
отдельным запросом, и в отчёт идёт то, что прочитали, а не то, что заказывали.
|
|
37
|
+
- **Служба считается поднятой, когда она выполнила задание, а не когда сообщила о
|
|
38
|
+
готовности.** Сообщение о готовности говорит лишь, что служба себя объявила: та, которой не
|
|
39
|
+
досталось ни одного задания, выглядит в нём точно так же, как работающая. Проверяются обе
|
|
40
|
+
стороны связи — что заказчик выбирает именно её и что задание через неё прошло.
|
|
41
|
+
- **Причина отказа, на которой строится решение, подтверждается измерением, а не
|
|
42
|
+
правдоподобием.** Объяснение, пришедшее первым, объясняет наблюдаемое не хуже верного:
|
|
43
|
+
свойство среды и собственный промах выглядят в отказе одинаково, и разводит их только замер,
|
|
44
|
+
поставленный так, чтобы одно из двух не прошло. Решение, выведенное из неподтверждённой
|
|
45
|
+
причины, лечит не то — и стоит отката всей работы, а не одной правки.
|
|
33
46
|
- **Если инструмент проверки запрещает приём, которым здесь пользуются постоянно, его правило
|
|
34
47
|
выключают в настройке инструмента, а не обходят в каждом месте.** Обход приходится повторять
|
|
35
48
|
столько раз, сколько таких мест, и ни в одном из них не написано, зачем он: со стороны это
|