@rt-tools/agent-kit 0.3.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.
Files changed (202) hide show
  1. package/README.md +194 -30
  2. package/assets/agents/business-analyst.md +74 -0
  3. package/assets/agents/project-manager.md +70 -0
  4. package/assets/agents/qa-engineer.md +72 -0
  5. package/assets/agents/skill-curator.md +110 -0
  6. package/assets/agents/spec-critic.md +44 -0
  7. package/assets/agents/spec-writer.md +50 -0
  8. package/assets/checks/board.github.mjs +329 -0
  9. package/assets/checks/check-board.github.mjs +181 -0
  10. package/assets/checks/check-doc-paths.mjs +163 -0
  11. package/assets/checks/check-dupes.mjs +277 -0
  12. package/assets/checks/check-lib-layers.mjs +573 -0
  13. package/assets/checks/check-reuse.mjs +208 -0
  14. package/assets/checks/check-schema-drift.mjs +186 -0
  15. package/assets/checks/check-specs.mjs +1086 -0
  16. package/assets/checks/check-styles.mjs +109 -0
  17. package/assets/checks/rt-kit-checks.config.mjs +134 -0
  18. package/assets/checks/task-new.github.mjs +198 -0
  19. package/assets/commands/skill-curator.md +70 -0
  20. package/assets/defaults/gate-map.sh +106 -0
  21. package/assets/defaults/project.sh +204 -0
  22. package/assets/hooks/browser-device-id.sh +0 -0
  23. package/assets/hooks/browser-guard-device-id.sh +2 -1
  24. package/assets/hooks/browser-guard-no-asking.sh +27 -0
  25. package/assets/hooks/browser-guard-no-listing.sh +2 -1
  26. package/assets/hooks/browser-guard-no-other-drivers.sh +2 -1
  27. package/assets/hooks/browser-guard-require-select.sh +2 -1
  28. package/assets/hooks/commit-msg.sh +1 -1
  29. package/assets/hooks/constitution-index.sh +5 -4
  30. package/assets/hooks/dev-server-guard.sh +8 -6
  31. package/assets/hooks/docs-guard.sh +223 -37
  32. package/assets/hooks/git-guard-delivery.sh +171 -31
  33. package/assets/hooks/git-guard-main.sh +1 -0
  34. package/assets/hooks/git-guard-push-tests.sh +34 -13
  35. package/assets/hooks/glossary-load.sh +23 -0
  36. package/assets/hooks/grill-gate.sh +96 -0
  37. package/assets/hooks/lint-after-edit.sh +155 -30
  38. package/assets/hooks/qa-dataid-guard.sh +72 -32
  39. package/assets/hooks/reuse-first-guard.sh +105 -34
  40. package/assets/hooks/skill-gate-rearm.sh +1 -0
  41. package/assets/hooks/skill-gate.sh +75 -15
  42. package/assets/hooks/skill-loaded.sh +1 -0
  43. package/assets/hooks/sql-guard.sh +606 -56
  44. package/assets/hooks/task-context-load.sh +100 -0
  45. package/assets/hooks/task-flow-guard.sh +118 -0
  46. package/assets/laws/{access.md → application/access.md} +1 -4
  47. package/assets/laws/{locales.md → application/locales.md} +1 -3
  48. package/assets/laws/application/money.md +41 -0
  49. package/assets/laws/application/ownership.md +32 -0
  50. package/assets/laws/{search-visibility.md → application/search-visibility.md} +1 -1
  51. package/assets/laws/code-structure.md +7 -6
  52. package/assets/laws/delivery.md +53 -3
  53. package/assets/laws/entity-editing.md +49 -55
  54. package/assets/laws/entity-models.md +4 -14
  55. package/assets/laws/frontend-application.md +5 -5
  56. package/assets/laws/lib-imports.md +14 -1
  57. package/assets/laws/lists.md +33 -0
  58. package/assets/laws/navigation.md +40 -0
  59. package/assets/laws/project-documentation.md +27 -8
  60. package/assets/laws/reuse-first.md +26 -21
  61. package/assets/laws/shared-code.md +13 -1
  62. package/assets/laws/verifiability.md +30 -1
  63. package/assets/laws/work-conduct.md +59 -0
  64. package/assets/patterns/admin-lists-screen.md +131 -0
  65. package/assets/patterns/admin-nav-item.md +71 -0
  66. package/assets/patterns/angular-patterns-state.md +29 -22
  67. package/assets/patterns/api-layer-pair.md +40 -30
  68. package/assets/patterns/browser-verification-measure.md +41 -38
  69. package/assets/patterns/browser-verification-stand.md +106 -42
  70. package/assets/patterns/component-structure-new.md +33 -32
  71. package/assets/patterns/dependencies-upgrade.md +65 -0
  72. package/assets/patterns/doc-style-sweep.md +65 -28
  73. package/assets/patterns/doc-style-write.md +36 -33
  74. package/assets/patterns/entity-aside.md +136 -0
  75. package/assets/patterns/entity-models-new.md +124 -0
  76. package/assets/patterns/entity-store.md +91 -0
  77. package/assets/patterns/git-workflow-commit.azure.md +259 -0
  78. package/assets/patterns/git-workflow-commit.github.md +337 -0
  79. package/assets/patterns/git-workflow-commit.gitlab.md +283 -0
  80. package/assets/patterns/git-workflow-merge.md +42 -25
  81. package/assets/patterns/git-workflow-migration.md +61 -31
  82. package/assets/patterns/git-workflow-restart.md +20 -20
  83. package/assets/patterns/lib-layers-move.md +50 -32
  84. package/assets/patterns/lib-layers-new.md +41 -29
  85. package/assets/patterns/ownership-scope-resolve.md +69 -0
  86. package/assets/patterns/permissions-procedure.md +35 -33
  87. package/assets/patterns/platform-access-di.md +39 -25
  88. package/assets/patterns/pricing-quote.md +71 -0
  89. package/assets/patterns/reuse-first-extend.md +22 -22
  90. package/assets/patterns/seo-page.md +52 -40
  91. package/assets/patterns/seo-verify.md +48 -29
  92. package/assets/patterns/shared-code-new.md +37 -31
  93. package/assets/patterns/spec-driven-domain.md +60 -37
  94. package/assets/patterns/spec-driven-rule.md +55 -40
  95. package/assets/patterns/styling-bem-component.md +43 -32
  96. package/assets/patterns/styling-bem-layout.md +30 -24
  97. package/assets/patterns/task-flow-close.md +154 -0
  98. package/assets/patterns/task-flow-resume.md +94 -0
  99. package/assets/patterns/task-flow-start.md +129 -0
  100. package/assets/patterns/testing-e2e.md +53 -51
  101. package/assets/patterns/testing-unit.md +70 -46
  102. package/assets/patterns/translations-key.md +32 -19
  103. package/assets/patterns/ts-procedure.md +24 -25
  104. package/assets/rules/angular-patterns.md +50 -27
  105. package/assets/rules/api-layer.md +46 -28
  106. package/assets/rules/browser-verification.md +67 -48
  107. package/assets/rules/component-structure.md +43 -27
  108. package/assets/rules/dependencies.md +66 -0
  109. package/assets/rules/doc-style.md +95 -39
  110. package/assets/rules/entity-conventions.md +78 -0
  111. package/assets/rules/entity-models.md +70 -0
  112. package/assets/rules/git-workflow.azure.md +116 -0
  113. package/assets/rules/git-workflow.github.md +123 -0
  114. package/assets/rules/git-workflow.gitlab.md +113 -0
  115. package/assets/rules/lib-layers.md +56 -30
  116. package/assets/rules/lists.md +73 -0
  117. package/assets/rules/navigation.md +78 -0
  118. package/assets/rules/ownership-scope.md +63 -0
  119. package/assets/rules/permissions.md +43 -25
  120. package/assets/rules/platform-access.md +57 -29
  121. package/assets/rules/pricing.md +64 -0
  122. package/assets/rules/reuse-first.md +57 -43
  123. package/assets/rules/seo.md +51 -30
  124. package/assets/rules/shared-code.md +51 -26
  125. package/assets/rules/spec-driven.md +107 -51
  126. package/assets/rules/styling-bem.md +54 -39
  127. package/assets/rules/task-flow.md +150 -0
  128. package/assets/rules/testing.md +78 -47
  129. package/assets/rules/translations.md +48 -31
  130. package/assets/rules/typescript-conventions.md +57 -27
  131. package/assets/skills/agent-kit.md +85 -0
  132. package/assets/skills/write-a-skill.md +108 -0
  133. package/assets/templates/gate-map.sh +23 -15
  134. package/assets/templates/implementation.md +14 -8
  135. package/assets/templates/pattern.md +1 -1
  136. package/assets/templates/project.sh +32 -19
  137. package/assets/templates/rule.md +2 -2
  138. package/assets/variants.json +20 -0
  139. package/assets/workflows/feature.js +134 -0
  140. package/assets/workflows/plan.js +150 -0
  141. package/bin/agent-kit.d.ts.map +1 -1
  142. package/bin/agent-kit.js +78 -5
  143. package/bin/agent-kit.js.map +1 -1
  144. package/bin/prompt.d.ts +5 -0
  145. package/bin/prompt.d.ts.map +1 -1
  146. package/bin/prompt.js +19 -7
  147. package/bin/prompt.js.map +1 -1
  148. package/index.d.ts +1 -0
  149. package/index.d.ts.map +1 -1
  150. package/index.js +1 -0
  151. package/index.js.map +1 -1
  152. package/lib/assets.d.ts +8 -3
  153. package/lib/assets.d.ts.map +1 -1
  154. package/lib/assets.js +13 -3
  155. package/lib/assets.js.map +1 -1
  156. package/lib/catalog.d.ts +52 -5
  157. package/lib/catalog.d.ts.map +1 -1
  158. package/lib/catalog.js +104 -16
  159. package/lib/catalog.js.map +1 -1
  160. package/lib/commands.d.ts +22 -1
  161. package/lib/commands.d.ts.map +1 -1
  162. package/lib/commands.js +202 -14
  163. package/lib/commands.js.map +1 -1
  164. package/lib/companion.d.ts +5 -1
  165. package/lib/companion.d.ts.map +1 -1
  166. package/lib/companion.js +29 -2
  167. package/lib/companion.js.map +1 -1
  168. package/lib/config.d.ts +26 -9
  169. package/lib/config.d.ts.map +1 -1
  170. package/lib/config.js +41 -15
  171. package/lib/config.js.map +1 -1
  172. package/lib/freshness.d.ts +14 -0
  173. package/lib/freshness.d.ts.map +1 -0
  174. package/lib/freshness.js +116 -0
  175. package/lib/freshness.js.map +1 -0
  176. package/lib/hooks-map.d.ts +27 -0
  177. package/lib/hooks-map.d.ts.map +1 -0
  178. package/lib/hooks-map.js +77 -0
  179. package/lib/hooks-map.js.map +1 -0
  180. package/lib/integrity.d.ts +36 -0
  181. package/lib/integrity.d.ts.map +1 -0
  182. package/lib/integrity.js +44 -0
  183. package/lib/integrity.js.map +1 -0
  184. package/lib/picker.d.ts +11 -1
  185. package/lib/picker.d.ts.map +1 -1
  186. package/lib/picker.js +44 -6
  187. package/lib/picker.js.map +1 -1
  188. package/lib/sync.d.ts +26 -0
  189. package/lib/sync.d.ts.map +1 -1
  190. package/lib/sync.js +59 -4
  191. package/lib/sync.js.map +1 -1
  192. package/lib/variants.d.ts +44 -0
  193. package/lib/variants.d.ts.map +1 -0
  194. package/lib/variants.js +82 -0
  195. package/lib/variants.js.map +1 -0
  196. package/package.json +1 -1
  197. package/rt-tools-agent-kit-0.5.0.tgz +0 -0
  198. package/assets/laws/admin-lists.md +0 -35
  199. package/assets/laws/admin-navigation.md +0 -38
  200. package/assets/patterns/git-workflow-commit.md +0 -175
  201. package/assets/rules/git-workflow.md +0 -106
  202. package/rt-tools-agent-kit-0.3.0.tgz +0 -0
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bash
2
+ # rt-hook: PreToolUse Edit|Write|MultiEdit|Bash|mcp__webstorm__create_new_file|mcp__webstorm__execute_terminal_command|mcp__webstorm__execute_tool
2
3
  # Гард пары «правка и её документ». PreToolUse.
3
4
  #
4
5
  # Расхождение кода с текстом беззвучно. Ни линтер, ни сборка, ни тесты не читают правила,
@@ -6,13 +7,17 @@
6
7
  # действующей справкой — тем убедительнее, чем он старше. Ловится это только чтением, и ловит
7
8
  # обычно владелец, а не проверка.
8
9
  #
9
- # Гард требует ровно тех пар, где связь механическая и спорить не о чем. Какие это пары, знает
10
- # профиль проекта: {{projectProfile}}, функция `rt_docs_pair_for <файл>` — печатает образец
11
- # пути, который обязан ехать тем же коммитом, или молчит.
10
+ # Гард требует ровно тех пар, где связь механическая и спорить не о чем:
12
11
  #
13
- # Отдельнозаконы. Совпал ли код с законом, машина не знает, поэтому правка самого закона не
14
- # отклоняется, а выносится вопросом владельцу: закон описывает договорённость о продукте, и
15
- # менять её молча гард не даёт.
12
+ # правило и его спутник когда правится раздел с утверждениями, а не «Ловушки»;
13
+ # заведение и удаление либы — README этой либы;
14
+ # переезд файла между либами README обеих;
15
+ # остальные пары — их называет профиль дерева, функция `rt_docs_pair_for <файл>`.
16
+ #
17
+ # Отдельно — законы. Совпал ли код с законом, машина не знает: правка файла, на который закон
18
+ # ссылается якорем, поэтому не отклоняется, а выносится вопросом владельцу. Тем же вопросом
19
+ # встречается и правка самого закона: закон описывает договорённость о продукте, и менять её
20
+ # молча гард не даёт.
16
21
  #
17
22
  # Обход — строка `Docs-skip: <причина>` в теле коммита. Причина остаётся в истории и видна при
18
23
  # разборе ветки; пустая не принимается.
@@ -33,32 +38,64 @@ decide() {
33
38
  exit 0
34
39
  }
35
40
 
41
+ # Профиль дерева: сперва умолчание пакета, поверх него — надстройка проекта, если она есть.
42
+ # Объявленная в надстройке функция замещает умолчание целиком и вправе позвать его обратно
43
+ # суффиксом `_default`.
44
+ rt_hooks_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
45
+ 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
46
+ # shellcheck disable=SC1090
47
+ [ -f "$profile" ] && . "$profile" 2>/dev/null
48
+ done
49
+
50
+ laws_dir="${RT_LAWS_DIR:-docs/constitution}"
51
+ lib_marker="${RT_LIB_MARKER:-project.json}"
52
+
36
53
  # ── Правка закона спрашивает владельца ────────────────────────────────────────
54
+ #
55
+ # Спутник рядом с законом — привязка статей к коду, она устаревает при каждом переименовании и
56
+ # правится свободно. Спрашивается только сам текст закона.
37
57
  case "$tool" in
38
- Edit | Write | MultiEdit | mcp__webstorm__create_new_file)
39
- target="$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.pathInProject // empty' 2>/dev/null)"
58
+ Edit | Write | MultiEdit | NotebookEdit | mcp__webstorm__create_new_file)
59
+ target="$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.filePath // .tool_input.path // .tool_input.pathInProject // empty' 2>/dev/null)"
40
60
  case "$target" in
41
- */{{lawsDir}}/*.md | {{lawsDir}}/*.md)
42
- decide ask "Правка закона: \`${target##*/}\`. Закон описывает договорённость о продукте, а не устройство кода, — назови владельцу, что и почему меняешь, и дождись ответа. Если правка уже согласована, подтверди вызов." ;;
61
+ */"$laws_dir"/*.implementation.md | "$laws_dir"/*.implementation.md) exit 0 ;;
62
+ */"$laws_dir"/*.md | "$laws_dir"/*.md)
63
+ decide ask "Правка закона: \`${target##*/}\`. Закон описывает договорённость о продукте, а не устройство кода, — назови владельцу, что и почему меняешь, и дождись ответа. Если правка уже согласована, подтверди вызов."
64
+ ;;
43
65
  esac
44
- exit 0 ;;
66
+ exit 0
67
+ ;;
45
68
  esac
46
69
 
47
- # ── Коммит: пара обязана ехать тем же коммитом ───────────────────────────────
70
+ # ── Коммит ────────────────────────────────────────────────────────────────────
71
+
72
+ # Терминал среды и универсальный исполнитель кладут команду в то же поле.
48
73
  case "$tool" in
49
74
  Bash | mcp__webstorm__execute_terminal_command | mcp__webstorm__execute_tool) ;;
50
75
  *) exit 0 ;;
51
76
  esac
52
77
 
53
78
  cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null)"
79
+
80
+ # Универсальный исполнитель передаёт настоящую команду вложенной строкой. Разбирать надо её,
81
+ # иначе имя команды стоит сразу за кавычкой и ни одно правило до него не дотягивается.
82
+ if [ "$tool" = "mcp__webstorm__execute_tool" ] && command -v perl >/dev/null 2>&1; then
83
+ inner="$(printf '%s' "$cmd" | perl -0ne '
84
+ if (/--command(?:=|\s+)(?:"((?:[^"\\]|\\.)*)"|\x27([^\x27]*)\x27|(.+))/s) {
85
+ print defined $1 ? $1 : (defined $2 ? $2 : $3);
86
+ }
87
+ ' 2>/dev/null)"
88
+ [ -n "$inner" ] && cmd="$inner"
89
+ fi
90
+
54
91
  case "$cmd" in
55
92
  *git\ commit*) ;;
56
93
  *) exit 0 ;;
57
94
  esac
58
95
 
59
- # Обход с причиной. Пустая причина не принимается: «Docs-skip:» без слов означает только то,
60
- # что строку дописали, чтобы пройти гард.
61
- if printf '%s' "$cmd" | grep -qE 'Docs-skip:[[:space:]]*[^[:space:]]'; then
96
+ # Причина обхода остаётся в истории, поэтому обход законен. Пустая строка обходом не считается:
97
+ # «Docs-skip:» без причины это тот же молчаливый пропуск, только с двоеточием.
98
+ if printf '%s' "$cmd" | grep -qiE 'Docs-skip:[[:space:]]*[^[:space:]"'"'"']{3,}'; then
62
99
  exit 0
63
100
  fi
64
101
 
@@ -67,30 +104,179 @@ workdir="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null)"
67
104
  cd "$workdir" 2>/dev/null || exit 0
68
105
  git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
69
106
 
70
- profile="${CLAUDE_PROJECT_DIR:-.}/{{projectProfile}}"
71
- [ -f "$profile" ] || exit 0
72
- # shellcheck disable=SC1090
73
- . "$profile" 2>/dev/null || exit 0
74
- command -v rt_docs_pair_for >/dev/null 2>&1 || exit 0
75
-
76
- staged="$(git diff --cached --name-only 2>/dev/null)"
77
- [ -z "$staged" ] && exit 0
78
-
79
- missing=""
80
- while IFS= read -r file; do
81
- [ -z "$file" ] && continue
82
- want="$(rt_docs_pair_for "$file" 2>/dev/null)"
83
- [ -z "$want" ] && continue
84
- # Пара считается приехавшей, если хоть один подготовленный файл подходит под образец.
85
- printf '%s\n' "$staged" | grep -qE "$want" && continue
86
- missing="${missing} ${file} ждёт документ: ${want}"$'\n'
107
+ # `-a` берёт все отслеживаемые правки, `--amend` — ещё и файлы переписываемого коммита.
108
+ # Считать всегда по индексу значило бы проверять не тот набор, который уедет в историю.
109
+ changes=''
110
+ source_mode='index'
111
+ case "$cmd" in
112
+ *' -a'* | *--all*)
113
+ changes="$(git diff HEAD --name-status -M 2>/dev/null)"
114
+ source_mode='worktree'
115
+ ;;
116
+ *) changes="$(git diff --cached --name-status -M 2>/dev/null)" ;;
117
+ esac
118
+ case "$cmd" in
119
+ *--amend*) changes="$(printf '%s\n%s' "$changes" "$(git show --name-status --format= HEAD 2>/dev/null)")" ;;
120
+ esac
121
+
122
+ changes="$(printf '%s\n' "$changes" | grep -v '^[[:space:]]*$')"
123
+ [ -z "$changes" ] && exit 0
124
+
125
+ # Пути коммита без статусов. Переименование подаётся тремя полями — берём обе стороны.
126
+ paths="$(printf '%s\n' "$changes" | awk -F'\t' '{ for (i = 2; i <= NF; i++) if ($i != "") print $i }' | sort -u)"
127
+
128
+ has_path() { printf '%s\n' "$paths" | grep -qxF "$1"; }
129
+
130
+ # Содержимое таким, каким оно уедет в коммит. Источник тот же, по которому собран список
131
+ # правок: `-a` заберёт рабочее дерево, обычный коммит — индекс. Читать индекс всегда нельзя —
132
+ # при `-a` там лежит прежняя редакция, и гард держал бы коммит, который расхождение и чинит;
133
+ # читать рабочее дерево всегда тоже нельзя — неподготовленная правка в коммит не попадёт.
134
+ content_of() {
135
+ if [ "$source_mode" = 'worktree' ]; then
136
+ cat "$1" 2>/dev/null
137
+ else
138
+ git show ":$1" 2>/dev/null || cat "$1" 2>/dev/null
139
+ fi
140
+ }
141
+
142
+ problems=''
143
+ add() { problems="${problems}
144
+ • $1"; }
145
+
146
+ # ── 1. Правило и его спутник ──────────────────────────────────────────────────
147
+ #
148
+ # Утверждения правила ключуются своим текстом, и переформулировка без правки спутника рвёт
149
+ # связь молча. Сверка спеков это ловит, но гоняется в гейте пуша — здесь та же пара
150
+ # встречается коммитом раньше, пока правка ещё в голове у автора.
151
+ #
152
+ # Раздел с утверждениями — единственное, что связано со спутником; правка «Ловушек» или
153
+ # таблицы «Где это лежит» его не трогает, поэтому сравнивается только этот раздел.
154
+ STATEMENTS='/^## Как закон применяется здесь$/,/^## [^К]/'
155
+ statements_of() { content_of "$1" | awk "$STATEMENTS"; }
156
+
157
+ for rule in $(printf '%s\n' "$paths" | grep -E '/SKILL\.md$'); do
158
+ grep -q '^kind: rule$' "$rule" 2>/dev/null || continue
159
+ companion="${rule%/SKILL.md}/implementation.md"
160
+
161
+ [ -f "$companion" ] || continue
162
+ has_path "$companion" && continue
163
+
164
+ if [ "$(statements_of "$rule")" != "$(git show "HEAD:$rule" 2>/dev/null | awk "$STATEMENTS")" ]; then
165
+ add "утверждения \`$rule\` правятся без спутника — добавь в коммит \`$companion\`"
166
+ fi
167
+ done
168
+
169
+ # ── 2. Пары, которые называет дерево ──────────────────────────────────────────
170
+ #
171
+ # Контракт и спек домена, гард и его сценарии — что именно, знает профиль: связь у каждого
172
+ # дерева своя, а механика одна.
173
+ if command -v rt_docs_pair_for >/dev/null 2>&1; then
174
+ while IFS= read -r file; do
175
+ [ -z "$file" ] && continue
176
+ want="$(rt_docs_pair_for "$file" 2>/dev/null)"
177
+ [ -z "$want" ] && continue
178
+ # Пара считается приехавшей, если хоть один файл коммита подходит под образец.
179
+ printf '%s\n' "$paths" | grep -qE "$want" && continue
180
+ add "\`$file\` правится без документа — тем же коммитом ждёт \`$want\`"
181
+ done <<EOF
182
+ $paths
183
+ EOF
184
+ fi
185
+
186
+ # ── 3. Либа и её README ───────────────────────────────────────────────────────
187
+
188
+ lib_root() {
189
+ dir="${1%/*}"
190
+ while [ -n "$dir" ] && [ "$dir" != "." ]; do
191
+ [ -f "$dir/$lib_marker" ] && { printf '%s' "$dir"; return 0; }
192
+ case "$dir" in */*) dir="${dir%/*}" ;; *) dir='' ;; esac
193
+ done
194
+
195
+ return 1
196
+ }
197
+
198
+ need_readme=''
199
+ want_readme() {
200
+ root="$1"
201
+ [ -n "$root" ] || return 0
202
+ case " $need_readme " in *" $root "*) return 0 ;; esac
203
+ need_readme="$need_readme $root"
204
+ }
205
+
206
+ # Заведение и удаление либы: README — единственное место, где написано, что в ней лежит и кто
207
+ # её зовёт, и раскладку она переживает только вместе с правкой этого текста.
208
+ while IFS="$(printf '\t')" read -r status first second; do
209
+ case "$status" in
210
+ A* | D*)
211
+ case "$first" in */"$lib_marker") want_readme "${first%/"$lib_marker"}" ;; esac
212
+ ;;
213
+ R*)
214
+ # Переезд файла меняет обе стороны: у одной либы он пропал из состава, у другой
215
+ # появился.
216
+ from="$(lib_root "$first")" && to="$(lib_root "$second")"
217
+ if [ -n "$from" ] && [ -n "$to" ] && [ "$from" != "$to" ]; then
218
+ want_readme "$from"
219
+ want_readme "$to"
220
+ fi
221
+ ;;
222
+ esac
87
223
  done <<EOF
88
- $staged
224
+ $changes
89
225
  EOF
90
226
 
91
- [ -z "$missing" ] && exit 0
227
+ # Переезд, поданный парой «удалено там, добавлено тут»: переименованием считается не всё, что
228
+ # им является, — правка содержимого при переносе сбивает поиск.
229
+ moved_added="$(printf '%s\n' "$changes" | awk -F'\t' '$1 ~ /^A/ { n = split($2, p, "/"); print p[n] "\t" $2 }' | sort -u)"
230
+ moved_deleted="$(printf '%s\n' "$changes" | awk -F'\t' '$1 ~ /^D/ { n = split($2, p, "/"); print p[n] "\t" $2 }' | sort -u)"
231
+ if [ -n "$moved_added" ] && [ -n "$moved_deleted" ]; then
232
+ while IFS="$(printf '\t')" read -r base added; do
233
+ [ -n "$base" ] || continue
234
+ gone="$(printf '%s\n' "$moved_deleted" | awk -F'\t' -v b="$base" '$1 == b { print $2; exit }')"
235
+ [ -n "$gone" ] || continue
236
+ to="$(lib_root "$added")" || continue
237
+ from="$(lib_root "$gone")" || continue
238
+ [ "$from" = "$to" ] && continue
239
+ want_readme "$from"
240
+ want_readme "$to"
241
+ done <<EOF
242
+ $moved_added
243
+ EOF
244
+ fi
245
+
246
+ for root in $need_readme; do
247
+ [ -d "$root" ] || continue # либу удалили целиком — править в ней нечего
248
+ has_path "$root/README.md" || add "состав либы \`$root\` изменился без правки \`$root/README.md\`"
249
+ done
250
+
251
+ if [ -n "$problems" ]; then
252
+ decide deny "BLOCKED: коммит правит код, но не документ, который его описывает.
253
+ ${problems}
254
+
255
+ Текст правится в той же ветке, что и код: документ, разошедшийся с деревом, выглядит действующей справкой и уводит следующего читателя. Если правка документа здесь действительно не нужна — назови причину строкой \`Docs-skip: <причина>\` в теле коммита, она останется в истории."
256
+ fi
92
257
 
93
- decide deny "Документ едет в том же коммите, что и правка, которую он описывает. Не хватает пар:
258
+ # ── 4. Законы ─────────────────────────────────────────────────────────────────
259
+ #
260
+ # Отклонять нечего: сошлась ли правка с законом, видно только чтением. Но место, где статья
261
+ # закона исполняется, названо якорем, и правка ровно этого файла — единственный момент, когда
262
+ # сверку ещё дёшево сделать.
263
+ #
264
+ # Слоёв законов два: общий в корне, закон приложения — в каталоге под ним. Образец принимает
265
+ # оба, иначе правка предметного закона считалась бы правкой мимо законов.
266
+ if [ -d "$laws_dir" ] && ! printf '%s\n' "$paths" | grep -qE "^${laws_dir}/([^/]+/)?[^/]+\.md$"; then
267
+ touched=''
268
+ for anchor in $(grep -ohE '`[A-Za-z0-9_./@-]+\.(ts|mjs|html|scss|prisma|proto|json)(:[A-Za-z0-9_#.-]+)?`' "$laws_dir"/*.implementation.md 2>/dev/null \
269
+ | tr -d '`' | sed 's/:.*//' | grep '/' | sort -u); do
270
+ has_path "$anchor" && touched="${touched}
271
+ • \`$anchor\`"
272
+ done
273
+
274
+ if [ -n "$touched" ]; then
275
+ decide ask "Правка задевает места, где исполняются статьи законов:
276
+ ${touched}
277
+
278
+ Законы лежат в \`${laws_dir}/\`, привязка — в спутниках рядом. Прочитай закон и скажи владельцу, сошлась ли с ним правка: если правка его уточняет или ему противоречит, закон правится в этой же ветке, но только с его ведома. Если закон не изменился — подтверди вызов."
279
+ fi
280
+ fi
94
281
 
95
- ${missing}
96
- Если документа здесь правда не нужно — строка «Docs-skip: <причина>» в теле коммита; пустая причина не принимается."
282
+ exit 0
@@ -1,23 +1,36 @@
1
1
  #!/usr/bin/env bash
2
- # Гард поставки. PreToolUse на заведении ветки и открытии PR.
2
+ # rt-hook: PreToolUse Bash|mcp__webstorm__execute_terminal_command|mcp__webstorm__execute_tool
3
+ # Гард поставки. PreToolUse на заведении ветки и открытии заявки на слияние.
3
4
  #
4
5
  # Закон о поставке требует трёх вещей, которых обычно не проверяет ничто: правка начинается с
5
6
  # задачи, видимой в очереди работ; задача, ветка и отчёт несут один номер; у задачи есть
6
7
  # исполнитель. Держатся они памятью — и не удерживаются: задачи стоят вне очереди, исполнитель
7
- # не проставлен, а большинство влитых PR приходит с веток, за которыми задачи не стояло вовсе.
8
+ # не проставлен, а большинство влитых заявок приходит с веток, за которыми задачи не стояло
9
+ # вовсе.
8
10
  #
9
11
  # Гард стоит в двух точках, и в каждой требует того, что в этот момент исправимо:
10
12
  #
11
13
  # заведение ветки — имя с номером разбирается на месте; имя без номера пропускается:
12
- # локальная ветка под пробу законна, в главную она не поедет, потому что PR с неё не
13
- # откроется;
14
- # открытие PR — ветка обязана нести номер, а заголовок начинаться с того же номера.
14
+ # локальная ветка под пробу законна, в главную она не поедет, потому что заявка с неё
15
+ # не откроется;
16
+ # открытие заявки — ветка обязана нести номер, заголовок обязан начинаться с того же номера,
17
+ # а задача — быть открытой, стоять в очереди работ и иметь исполнителя.
15
18
  #
16
- # Форму имени ветки и номер в заголовке знает профиль проекта: {{projectProfile}}, функция
17
- # `rt_task_branch_ok <имя>` и переменная RT_TASK_TITLE_RE. Нет профиля гард пропускает: форму
18
- # имени пакет выдумать не может.
19
+ # Два яруса. Формат номер в имени ветки, номер в заголовке, их совпадение — читается из
20
+ # текста команды и работает всегда. Состояние задачи требует сети: нет её, нет помощника или
21
+ # нет токена ярус пропускается, потому что проверять нечем.
19
22
  #
20
- # ОТКАЗ В ПОЛЬЗУ РАБОТЫ: не репозиторий, нет разборщика, битый ввод — пропуск.
23
+ # Что здесь чем зовётся, знает профиль дерева:
24
+ # rt_task_branch_ok — форма имени ветки под задачу;
25
+ # RT_TASK_TITLE_RE — форма номера в заголовке заявки;
26
+ # rt_task_state — состояние задачи одним объектом (existsize, open, onBoard, assigned,
27
+ # numbered); молчание значит «спросить некого»;
28
+ # RT_TASK_NEW_CMD — чем заводится задача;
29
+ # RT_BOARD_CHECK_CMD — чем сверяется очередь работ;
30
+ # RT_TASK_BOT — учётная запись, которую ставят исполнителем.
31
+ # Отказ называет и то, что не так, и чем это чинится: отказ без действия обходят, а не исполняют.
32
+ #
33
+ # ОТКАЗ В ПОЛЬЗУ РАБОТЫ: не репозиторий, нет разборщика, битый ввод, нет профиля — пропуск.
21
34
 
22
35
  input="$(cat 2>/dev/null)"
23
36
  [ -z "$input" ] && exit 0
@@ -25,6 +38,7 @@ command -v jq >/dev/null 2>&1 || exit 0
25
38
 
26
39
  tool="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null)"
27
40
  case "$tool" in
41
+ # Терминал среды и универсальный исполнитель кладут команду в то же поле.
28
42
  Bash | mcp__webstorm__execute_terminal_command | mcp__webstorm__execute_tool) ;;
29
43
  *) exit 0 ;;
30
44
  esac
@@ -32,6 +46,8 @@ esac
32
46
  cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null)"
33
47
  [ -z "$cmd" ] && exit 0
34
48
 
49
+ # Универсальный исполнитель передаёт настоящую команду вложенной строкой. Разбирать надо её,
50
+ # а не обёртку.
35
51
  if [ "$tool" = "mcp__webstorm__execute_tool" ] && command -v perl >/dev/null 2>&1; then
36
52
  inner="$(printf '%s' "$cmd" | perl -0ne '
37
53
  if (/--command(?:=|\s+)(?:"((?:[^"\\]|\\.)*)"|\x27([^\x27]*)\x27|(.+))/s) {
@@ -41,18 +57,79 @@ if [ "$tool" = "mcp__webstorm__execute_tool" ] && command -v perl >/dev/null 2>&
41
57
  [ -n "$inner" ] && cmd="$inner"
42
58
  fi
43
59
 
44
- profile="${CLAUDE_PROJECT_DIR:-.}/{{projectProfile}}"
45
- [ -f "$profile" ] || exit 0
46
- # shellcheck disable=SC1090
47
- . "$profile" 2>/dev/null || exit 0
60
+ workdir="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null)"
61
+ [ -z "$workdir" ] && workdir="${CLAUDE_PROJECT_DIR:-.}"
62
+ cd "$workdir" 2>/dev/null || exit 0
63
+ git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
64
+
65
+ root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
66
+
67
+ # Профиль дерева: сперва умолчание пакета, поверх него — надстройка проекта, если она есть.
68
+ # Объявленная в надстройке функция замещает умолчание целиком и вправе позвать его обратно
69
+ # суффиксом `_default`. Нет ни того ни другого — хук пропускает: пустой гард лучше гарда,
70
+ # отбивающего наугад.
71
+ rt_hooks_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
72
+ for profile in "$rt_hooks_dir/../rt-kit/defaults/project.sh" "$rt_hooks_dir/../defaults/project.sh" "$root/.claude/rt-kit/defaults/project.sh" "$root/.claude/rt-kit/project.sh"; do
73
+ # shellcheck disable=SC1090
74
+ [ -f "$profile" ] && . "$profile" 2>/dev/null
75
+ done
48
76
  command -v rt_task_branch_ok >/dev/null 2>&1 || exit 0
49
77
 
78
+ title_re="${RT_TASK_TITLE_RE:-^\[[A-Za-z]+-[0-9]+\][[:space:]]+[^[:space:]]}"
79
+ task_new="${RT_TASK_NEW_CMD:-npm run task:new}"
80
+ board_check="${RT_BOARD_CHECK_CMD:-npm run check:board}"
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,}'
90
+
50
91
  deny() {
51
92
  jq -n --arg r "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}' 2>/dev/null \
52
93
  || printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Гард поставки."}}\n'
53
94
  exit 0
54
95
  }
55
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
+
112
+ check_task() {
113
+ number="$1"
114
+ where="$2"
115
+ command -v rt_task_state >/dev/null 2>&1 || return 0
116
+ state="$(cd "$root" && rt_task_state "$number" 2>/dev/null)" || return 0
117
+ [ -z "$state" ] && return 0
118
+
119
+ printf '%s' "$state" | jq -e '.exists' >/dev/null 2>&1 \
120
+ || deny "BLOCKED: ${where} ссылается на задачу #${number}, которой нет. Проверь номер или заведи задачу — ${task_new}."
121
+ printf '%s' "$state" | jq -e '.open' >/dev/null 2>&1 \
122
+ || deny "BLOCKED: задача #${number} закрыта, а у задачи одна ветка. Работа за закрытой задачей заводится новой задачей — ${task_new}."
123
+ printf '%s' "$state" | jq -e '.onBoard' >/dev/null 2>&1 \
124
+ || deny "BLOCKED: задачи #${number} нет в очереди работ — правка за ней не видна. Очередь к репозиторию не привязана и задачу сама не забирает; добавь её и сверь — ${board_check}."
125
+ printf '%s' "$state" | jq -e '.assigned' >/dev/null 2>&1 \
126
+ || deny "BLOCKED: у задачи #${number} нет исполнителя — по очереди работ не видно, кто её взял. Поставь исполнителя${task_bot:+: }${task_bot}."
127
+ printf '%s' "$state" | jq -e '.numbered' >/dev/null 2>&1 \
128
+ || deny "BLOCKED: заголовок задачи #${number} не начинается с её номера — одну работу придётся узнавать по тексту названия. Поправь заголовок и сверь очередь — ${board_check}."
129
+
130
+ return 0
131
+ }
132
+
56
133
  # --- заведение ветки ---------------------------------------------------------------------
57
134
  branch_arg=''
58
135
  if printf '%s' "$cmd" | grep -qE '(^|[;&|[:space:]])git[[:space:]]+(checkout[[:space:]]+-b|switch[[:space:]]+-c)[[:space:]]'; then
@@ -64,47 +141,110 @@ fi
64
141
  if [ -n "$branch_arg" ]; then
65
142
  # Имя, притворяющееся веткой под задачу, но не совпадающее с формой, — это промах в имени,
66
143
  # а не осознанная беззадачная ветка. Ловится до первого коммита.
67
- if printf '%s' "$branch_arg" | grep -qE '^[A-Za-z]+-[0-9]+' && ! rt_task_branch_ok "$branch_arg"; then
68
- deny "Имя ветки «${branch_arg не той формы, что принята здесь. Номер у ветки тот же, что у задачи и у заголовка PR."
144
+ if printf '%s' "$branch_arg" | grep -qiE '^[A-Za-z]+-[0-9]+'; then
145
+ rt_task_branch_ok "$branch_arg" \
146
+ || deny "BLOCKED: имя ветки «${branch_arg}» не той формы, что принята здесь. Номер у ветки тот же, что у задачи и у заголовка заявки на слияние."
147
+ check_task "$(printf '%s' "$branch_arg" | sed -nE 's/^[A-Za-z]+-([0-9]+).*/\1/p')" "ветка «${branch_arg}»"
69
148
  fi
149
+ # Ветка без номера законна и живёт локально: заявка с неё не откроется.
70
150
  exit 0
71
151
  fi
72
152
 
73
- # --- открытие PR -------------------------------------------------------------------------
74
- case "$cmd" in
75
- *gh\ pr\ create*) ;;
76
- *) exit 0 ;;
77
- esac
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
78
180
 
79
- workdir="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null)"
80
- [ -z "$workdir" ] && workdir="${CLAUDE_PROJECT_DIR:-.}"
81
- cd "$workdir" 2>/dev/null || exit 0
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
+
203
+ # --- открытие заявки на слияние ----------------------------------------------------------
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
82
209
 
83
210
  branch="$(git branch --show-current 2>/dev/null)"
84
211
  [ -z "$branch" ] && exit 0 # открепившийся HEAD — не про этот случай
85
212
 
86
- # Локальная ветка без номера законна, а PR с неё — уже нет: правка, доезжающая до главной
213
+ # Локальная ветка без номера законна, а заявка с неё — уже нет: правка, доезжающая до главной
87
214
  # ветки, начинается с задачи. Это единственное место, где беззадачная ветка упирается.
88
215
  rt_task_branch_ok "$branch" \
89
- || deny "PR с ветки «${branch}», за которой не стоит задачи. Правка начинается с задачи, видимой в очереди работ: заведи её и перенеси работу в ветку с её номером."
216
+ || deny "BLOCKED: заявка с ветки «${branch}», за которой не стоит задачи. Правка начинается с задачи, видимой в очереди работ: заведи её — ${task_new} — и перенеси работу в ветку с её номером."
90
217
 
91
218
  number="$(printf '%s' "$branch" | sed -nE 's/^[A-Za-z]+-([0-9]+).*/\1/p')"
92
219
 
93
220
  title=''
94
221
  if command -v perl >/dev/null 2>&1; then
95
222
  title="$(printf '%s' "$cmd" | perl -0ne '
96
- if (/--title(?:=|\s+)(?:"((?:[^"\\]|\\.)*)"|\x27([^\x27]*)\x27|([^\s]+))/s) {
223
+ if (/(?:--title|-t)(?:=|\s+)(?:"((?:[^"\\]|\\.)*)"|\x27([^\x27]*)\x27|(\S+))/s) {
97
224
  print defined $1 ? $1 : (defined $2 ? $2 : $3);
98
225
  }
99
226
  ' 2>/dev/null)"
100
227
  fi
101
228
 
102
- if [ -n "$title" ] && [ -n "$number" ]; then
229
+ if [ -n "$title" ]; then
230
+ printf '%s' "$title" | grep -qE "$title_re" \
231
+ || deny "BLOCKED: заголовок заявки не начинается с номера задачи. В списке заявок тела не видно, а строка связи живёт именно там — без номера в заголовке отчёт с задачей не сопоставить."
103
232
  title_number="$(printf '%s' "$title" | sed -nE 's/^\[[A-Za-z]+-([0-9]+)\].*/\1/p')"
104
- [ -z "$title_number" ] \
105
- && deny "Заголовок PR не начинается с номера задачи. В списке PR тела не видно, а строка связи живёт именно там — без номера в заголовке отчёт с задачей не сопоставить."
106
- [ "$title_number" = "$number" ] \
107
- || deny "В заголовке PR номер ${title_number}, у ветки — ${number}. Задача, ветка и отчёт несут один и тот же номер."
233
+ if [ -n "$number" ] && [ -n "$title_number" ]; then
234
+ [ "$title_number" = "$number" ] \
235
+ || deny "BLOCKED: в заголовке заявки номер ${title_number}, у ветки — ${number}. Задача, ветка и отчёт несут один и тот же номер."
236
+ fi
237
+ fi
238
+
239
+ check_task "$number" "заявка с ветки «${branch}»"
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: потом за неё уже никто не возьмётся. На слиянии это будет отказ, а не напоминание."
108
248
  fi
109
249
 
110
250
  exit 0
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bash
2
+ # rt-hook: PreToolUse Bash|mcp__webstorm__execute_terminal_command|mcp__webstorm__execute_tool
2
3
  # Гард главной ветки. PreToolUse на вызове коммита.
3
4
  #
4
5
  # Коммит в главную ветку минует ветку, PR и разбор, а поставка построена на них целиком —