@rt-tools/agent-kit 0.9.1 → 0.11.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 (119) hide show
  1. package/README.md +5 -0
  2. package/assets/agents/conscience.md +58 -0
  3. package/assets/agents/prose-editor.md +44 -0
  4. package/assets/agents/strict-teacher.md +59 -0
  5. package/assets/checks/board.github.mjs +56 -1
  6. package/assets/checks/check-board.github.mjs +24 -0
  7. package/assets/checks/check-doc-paths.mjs +4 -15
  8. package/assets/checks/check-dupes.mjs +5 -6
  9. package/assets/checks/check-file-size.mjs +6 -20
  10. package/assets/checks/check-prose-style.mjs +137 -0
  11. package/assets/checks/check-reuse.mjs +5 -5
  12. package/assets/checks/check-state-next.mjs +194 -0
  13. package/assets/checks/check-states.mjs +142 -0
  14. package/assets/checks/check-styles.mjs +5 -5
  15. package/assets/checks/check-turn-map.mjs +146 -0
  16. package/assets/checks/lib-common.mjs +3 -3
  17. package/assets/checks/rt-kit-checks.config.mjs +85 -1
  18. package/assets/commands/agent-kit-digest.md +6 -5
  19. package/assets/defaults/project.sh +108 -6
  20. package/assets/defaults/turn-map.md +46 -0
  21. package/assets/docs/GLOSSARY.md +17 -15
  22. package/assets/hooks/browser-device-id.sh +2 -0
  23. package/assets/hooks/browser-guard-device-id.sh +11 -1
  24. package/assets/hooks/browser-guard-no-asking.sh +11 -1
  25. package/assets/hooks/browser-guard-no-listing.sh +11 -1
  26. package/assets/hooks/browser-guard-no-other-drivers.sh +9 -1
  27. package/assets/hooks/browser-guard-require-select.sh +13 -2
  28. package/assets/hooks/claim-guard.sh +115 -0
  29. package/assets/hooks/commit-msg.sh +2 -0
  30. package/assets/hooks/conscience-guard.sh +100 -0
  31. package/assets/hooks/constitution-index.sh +2 -0
  32. package/assets/hooks/deny-tail.sh +32 -0
  33. package/assets/hooks/dev-server-guard.sh +10 -2
  34. package/assets/hooks/docs-guard.sh +16 -2
  35. package/assets/hooks/exam-guard.sh +123 -0
  36. package/assets/hooks/git-guard-delivery-signature.sh +77 -0
  37. package/assets/hooks/git-guard-delivery.sh +156 -68
  38. package/assets/hooks/git-guard-main.sh +14 -0
  39. package/assets/hooks/git-guard-push-tests.sh +51 -2
  40. package/assets/hooks/glossary-load.sh +2 -0
  41. package/assets/hooks/grill-gate.sh +14 -0
  42. package/assets/hooks/handoff-entry-guard.sh +73 -0
  43. package/assets/hooks/handoff-write.sh +103 -0
  44. package/assets/hooks/lint-after-edit.sh +2 -0
  45. package/assets/hooks/observe.sh +2 -0
  46. package/assets/hooks/postmortem-guard.sh +14 -0
  47. package/assets/hooks/proposal-guard.sh +14 -0
  48. package/assets/hooks/prose-style-guard.sh +75 -0
  49. package/assets/hooks/qa-dataid-guard.sh +14 -1
  50. package/assets/hooks/rerun-guard.sh +88 -0
  51. package/assets/hooks/reuse-first-guard.sh +14 -1
  52. package/assets/hooks/roles.sh +34 -0
  53. package/assets/hooks/skill-gate-layers.sh +2 -0
  54. package/assets/hooks/skill-gate-rearm.sh +2 -0
  55. package/assets/hooks/skill-gate.sh +14 -0
  56. package/assets/hooks/skill-loaded.sh +2 -0
  57. package/assets/hooks/sql-guard-parse.sh +2 -0
  58. package/assets/hooks/sql-guard-request.sh +2 -0
  59. package/assets/hooks/sql-guard-target.sh +2 -0
  60. package/assets/hooks/sql-guard-write.sh +4 -1
  61. package/assets/hooks/sql-guard.sh +14 -1
  62. package/assets/hooks/task-context-load.sh +2 -0
  63. package/assets/hooks/task-flow-guard.sh +73 -7
  64. package/assets/hooks/turn-entry-load.sh +62 -0
  65. package/assets/hooks/turn-exit-guard.sh +191 -0
  66. package/assets/hooks/utf8.sh +35 -0
  67. package/assets/hooks/waiting-turn-guard.sh +14 -0
  68. package/assets/hooks/window-fill-guard.sh +43 -2
  69. package/assets/laws/work-conduct.md +29 -0
  70. package/assets/patterns/cargo-triage-mark.md +119 -0
  71. package/assets/patterns/task-flow-close.md +29 -8
  72. package/assets/patterns/task-flow-handoff.md +19 -1
  73. package/assets/patterns/task-flow-resume.md +36 -4
  74. package/assets/patterns/task-flow-start.md +56 -6
  75. package/assets/patterns/turn-entry-map.md +81 -0
  76. package/assets/rules/cargo-triage.md +126 -0
  77. package/assets/rules/git-workflow.azure.md +36 -9
  78. package/assets/rules/git-workflow.github.md +74 -9
  79. package/assets/rules/git-workflow.gitlab.md +40 -12
  80. package/assets/rules/task-flow.md +136 -71
  81. package/assets/rules/testing.md +15 -1
  82. package/assets/rules/turn-entry.md +93 -0
  83. package/assets/samples/tasks/_template/plan.md +4 -1
  84. package/assets/samples/tasks/_template/progress.md +1 -0
  85. package/assets/skills/agent-kit.md +33 -0
  86. package/assets/templates/project.sh +16 -0
  87. package/bin/agent-kit.d.ts.map +1 -1
  88. package/bin/agent-kit.js +42 -1
  89. package/bin/agent-kit.js.map +1 -1
  90. package/lib/cargo-state.d.ts +62 -0
  91. package/lib/cargo-state.d.ts.map +1 -0
  92. package/lib/cargo-state.js +118 -0
  93. package/lib/cargo-state.js.map +1 -0
  94. package/lib/cargo.d.ts +42 -0
  95. package/lib/cargo.d.ts.map +1 -1
  96. package/lib/cargo.js +2 -0
  97. package/lib/cargo.js.map +1 -1
  98. package/lib/commands.d.ts.map +1 -1
  99. package/lib/commands.js +64 -4
  100. package/lib/commands.js.map +1 -1
  101. package/lib/config.d.ts +8 -0
  102. package/lib/config.d.ts.map +1 -1
  103. package/lib/config.js +1 -0
  104. package/lib/config.js.map +1 -1
  105. package/lib/observations.d.ts +35 -1
  106. package/lib/observations.d.ts.map +1 -1
  107. package/lib/observations.js +14 -2
  108. package/lib/observations.js.map +1 -1
  109. package/lib/ship.d.ts +3 -0
  110. package/lib/ship.d.ts.map +1 -1
  111. package/lib/ship.js +56 -0
  112. package/lib/ship.js.map +1 -1
  113. package/lib/thresholds.d.ts +49 -0
  114. package/lib/thresholds.d.ts.map +1 -0
  115. package/lib/thresholds.js +151 -0
  116. package/lib/thresholds.js.map +1 -0
  117. package/package.json +1 -1
  118. package/rt-tools-agent-kit-0.11.0.tgz +0 -0
  119. package/rt-tools-agent-kit-0.9.1.tgz +0 -0
@@ -15,6 +15,8 @@
15
15
  # ОТКАЗ В ПОЛЬЗУ РАБОТЫ: проверяльщика нет (свежий клон без установленных зависимостей) —
16
16
  # пропуск. Формат тогда не проверяется, и это лучше, чем заклинивший коммит.
17
17
 
18
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/utf8.sh" 2>/dev/null || true
19
+
18
20
  msg_file="$1"
19
21
  [ -z "$msg_file" ] && exit 0
20
22
 
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env bash
2
+ # rt-hook: Stop
3
+ # Требует: agents/conscience.md, hooks/roles.sh, hooks/deny-tail.sh
4
+ # Гард совести: ход, в котором роль совести нашла повтор разобранного промаха, не заканчивается,
5
+ # пока повтор не разобран или не назван владельцу.
6
+ #
7
+ # Зачем именно так. Разбор происшествия объясняет механизм промаха, но читает его только тот, кто
8
+ # открывает каталог сам. Промах, о котором надо напомнить, — ровно тот, о котором исполнитель в
9
+ # эту минуту не помнит, поэтому вызов роли не оставляют на его усмотрение: он не случится там,
10
+ # где нужнее всего.
11
+ #
12
+ # Роль отвечает первой строкой: «СОВЕСТЬ: повтор» либо «СОВЕСТЬ: чисто». Гард судит последний
13
+ # ответ за ход и ничего не знает о том, верна ли находка: это решает исполнитель, и его решение
14
+ # — работа следующего хода, а не молчание этого.
15
+ #
16
+ # Ход отпускается, когда после находки исполнитель сделал хоть что-то по ней: завёл разбор
17
+ # происшествия, поправил работу или назвал повтор владельцу. Проверяется это по тому же ходу.
18
+ #
19
+ # ОТКАЗ В ПОЛЬЗУ РАБОТЫ: нет `jq`, нет записи хода, роль молчит или отвечает не по форме — ход
20
+ # разрешается. Сломанная совесть не имеет права заклинить разговор.
21
+
22
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/utf8.sh" 2>/dev/null || true
23
+
24
+ input="$(cat 2>/dev/null)"
25
+ [ -z "$input" ] && exit 0
26
+ command -v jq >/dev/null 2>&1 || exit 0
27
+
28
+ # Роль, выключенная деревом, гарда не держит: список выключенных лежит в настройке дерева, а
29
+ # читает его помощник рядом. Нечитаемая настройка выключением не считается — гард работает как
30
+ # прежде.
31
+ rt_hooks_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
32
+ # shellcheck disable=SC1090
33
+ [ -f "$rt_hooks_dir/roles.sh" ] && . "$rt_hooks_dir/roles.sh" 2>/dev/null
34
+ command -v rt_role_off >/dev/null 2>&1 && rt_role_off conscience && exit 0
35
+
36
+ active="$(printf '%s' "$input" | jq -r '.stop_hook_active // false' 2>/dev/null)"
37
+ [ "$active" = "true" ] && exit 0
38
+
39
+ transcript="$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null)"
40
+ [ -z "$transcript" ] && exit 0
41
+ [ -f "$transcript" ] || exit 0
42
+
43
+ # Ход — всё, что записано после последней настоящей реплики владельца. Ответ инструмента
44
+ # приходит той же ролью, поэтому строки с `tool_result` репликой не считаются.
45
+ verdict="$(tail -n 400 "$transcript" 2>/dev/null | jq -s -r '
46
+ def is_input:
47
+ .type == "user"
48
+ and (((.message.content // []) | if type == "array"
49
+ then ([.[] | select(.type == "tool_result")] | length)
50
+ else 0 end) == 0);
51
+
52
+ (map(is_input) | rindex(true)) as $i
53
+ | (if $i == null then [] else .[$i:] end) as $turn
54
+ | [ $turn[]
55
+ | if .type == "assistant"
56
+ then ([(.message.content // [])[]
57
+ | if .type == "tool_use" then (.input.command // "") else (.text // "") end] | join("\n"))
58
+ elif .type == "user"
59
+ then ([(.message.content // []) | select(type == "array") | .[]
60
+ | select(.type == "tool_result") | .content
61
+ | if type == "string" then . elif type == "array"
62
+ then (map(if type == "object" then (.text // "") else tostring end) | join("\n"))
63
+ else tostring end] | join("\n"))
64
+ else "" end ] as $flow
65
+ | ($flow | map(test("СОВЕСТЬ:[[:space:]]*повтор")) | index(true)) as $found
66
+ | if $found == null then "нет-находки"
67
+ else ($flow[($found + 1):] | join("\n")
68
+ | if test("postmortems|разбор происшествия|СОВЕСТЬ: разобрано") then "разобрано" else "висит" end)
69
+ end
70
+ ' 2>/dev/null)"
71
+
72
+ [ "$verdict" = "висит" ] || exit 0
73
+
74
+ detail="$(tail -n 400 "$transcript" 2>/dev/null | grep -m1 -A3 'СОВЕСТЬ:[[:space:]]*повтор' | tr -d '\\"' | head -4)"
75
+
76
+ reason="BLOCKED by conscience-guard: совесть нашла в этом ходе повтор разобранного промаха, и по нему не сделано ничего.
77
+
78
+ ${detail}
79
+
80
+ Ход не кончается на находке. Сделай одно из трёх этим же ходом: поправь работу, заведи разбор происшествия, если механизм новый, или назови повтор владельцу словами — что повторяется и чем это кончилось в прошлый раз.
81
+
82
+ Находка неверна — так и скажи владельцу: ложная находка тоже стоит хода, и молчанием она не чинится.
83
+
84
+ Гард судит один ход: следующий заход не отбивается."
85
+
86
+ # Общий хвост отказа: два законных хода. Файл может быть не разложен — тогда хвоста нет,
87
+ # а причина отказа остаётся прежней.
88
+ # shellcheck disable=SC1090
89
+ [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
90
+ && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
91
+ command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
92
+ deny_tail_text="$(rt_deny_tail "")"
93
+ [ -n "$deny_tail_text" ] && reason="${reason}
94
+
95
+ ${deny_tail_text}"
96
+
97
+ jq -n --arg r "$reason" '{decision:"block",reason:$r}' 2>/dev/null \
98
+ || printf '{"decision":"block","reason":"conscience-guard: найден повтор разобранного промаха — разбери его или назови владельцу."}\n'
99
+
100
+ exit 0
@@ -12,6 +12,8 @@
12
12
  # ОТКАЗ В ПОЛЬЗУ РАБОТЫ: любая ошибка начинает сессию без добавленного контекста (exit 0).
13
13
  # Сломанный вход не имеет права остановить сессию.
14
14
 
15
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/utf8.sh" 2>/dev/null || true
16
+
15
17
  dir="${CLAUDE_PROJECT_DIR:-.}/docs/constitution"
16
18
  [ -d "$dir" ] || exit 0
17
19
 
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env bash
2
+ # Общий хвост отказа. НЕ гард: объявления `rt-hook:` у него нет, к событиям агента он не
3
+ # подключается. Его источают сами гарды — тем же приёмом, каким они источают запись наблюдений и
4
+ # слово о нехватке функции профиля.
5
+ #
6
+ # Зачем он есть. Отказ гарда называет промах и способ его снять, а что делать, когда снять не
7
+ # выходит, не называет никто. Пустое место занимает третий ход, которого нет в правилах: обойти
8
+ # молча — командой оболочки, соседним инструментом, правкой самого гарда. Так и было: за один час
9
+ # отказ обошли дважды, и оба раза правка легла мимо гарда. Ходов законных два, и оба называются
10
+ # прямо в тексте отказа, а не остаются на догадку читающего.
11
+ #
12
+ # ЧТО ГОВОРИТСЯ. Починить названное и повторить вызов — либо принести владельцу цену обхода и
13
+ # ждать его слова. Там, где у обхода есть законная форма — строка в теле коммита, строка в
14
+ # замысле, ключ команды, — она называется тем же хвостом: обход, о котором известно, что он
15
+ # законен, ищут не мимо гарда, а по его же словам.
16
+ #
17
+ # ЧЕГО ХВОСТ НЕ ДЕЛАЕТ. Он не судит, годится ли обход в этом случае: это решает владелец. И он не
18
+ # заменяет причины отказа — она стоит перед ним и называет промах поимённо.
19
+
20
+ # Хвост отказа строкой. Первый параметр — законная форма обхода, если она у отказа есть; пусто —
21
+ # значит законной формы нет вовсе, и говорится об этом прямо.
22
+ #
23
+ # reason="BLOCKED: <причина>. $(rt_deny_tail 'строка `Docs-skip: <причина>` в теле коммита')"
24
+ # reason="BLOCKED: <причина>. $(rt_deny_tail)"
25
+ rt_deny_tail() {
26
+ if [ -n "$1" ]; then
27
+ printf 'Ходов отсюда два: починить названное и повторить вызов — либо принести владельцу цену обхода и ждать его слова. Законная форма обхода: %s. Молча мимо гарда правка не кладётся: он отбивает один вызов, а обойдённый снимает требование со всего дерева и молчит об этом.' "$1"
28
+ return 0
29
+ fi
30
+
31
+ printf 'Ходов отсюда два: починить названное и повторить вызов — либо принести владельцу цену обхода и ждать его слова. Законной формы обхода у этого отказа нет. Молча мимо гарда правка не кладётся: он отбивает один вызов, а обойдённый снимает требование со всего дерева и молчит об этом.'
32
+ }
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # rt-hook: PreToolUse Bash|mcp__webstorm__execute_terminal_command|mcp__webstorm__execute_run_configuration|mcp__webstorm__execute_tool
3
+ # Требует: hooks/deny-tail.sh
3
4
  # Гард второго сервера разработки. PreToolUse.
4
5
  #
5
6
  # Приложения уже подняты владельцем, и всякая проверка через браузер идёт туда. Второй
@@ -13,6 +14,8 @@
13
14
  # Где именно подняты приложения, знает профиль проекта: .claude/rt-kit/project.sh, переменная
14
15
  # RT_STANDS. Нет профиля — текст отказа остаётся общим, сам гард работает.
15
16
 
17
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/utf8.sh" 2>/dev/null || true
18
+
16
19
  input="$(cat 2>/dev/null)"
17
20
 
18
21
  tool="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null)"
@@ -75,11 +78,16 @@ for profile in "$rt_hooks_dir/../rt-kit/defaults/project.sh" "$rt_hooks_dir/../d
75
78
  done
76
79
  stands="${RT_STANDS:-}"
77
80
 
81
+ # shellcheck disable=SC1090
82
+ [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
83
+ && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
84
+ command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
85
+
78
86
  deny() {
79
87
  if [ -n "$stands" ]; then
80
- echo "$1 Приложения уже подняты владельцем: ${stands} — проверяй их. Свой экземпляр не поднимай; если порт не отвечает, скажи владельцу, а не запускай второй." >&2
88
+ echo "$1 Приложения уже подняты владельцем: ${stands} — проверяй их. Свой экземпляр не поднимай; если порт не отвечает, скажи владельцу, а не запускай второй. $(rt_deny_tail)" >&2
81
89
  else
82
- echo "$1 Приложения уже подняты владельцем — проверяй их. Свой экземпляр не поднимай; если порт не отвечает, скажи владельцу, а не запускай второй." >&2
90
+ echo "$1 Приложения уже подняты владельцем — проверяй их. Свой экземпляр не поднимай; если порт не отвечает, скажи владельцу, а не запускай второй. $(rt_deny_tail)" >&2
83
91
  fi
84
92
  exit 2
85
93
  }
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # rt-hook: PreToolUse Edit|Write|MultiEdit|Bash|mcp__webstorm__create_new_file|mcp__webstorm__execute_terminal_command|mcp__webstorm__execute_tool
3
- # Требует: hooks/profile-check.sh
3
+ # Требует: hooks/profile-check.sh, hooks/deny-tail.sh
4
4
  # Гард пары «правка и её документ». PreToolUse.
5
5
  #
6
6
  # Расхождение кода с текстом беззвучно. Ни линтер, ни сборка, ни тесты не читают правила,
@@ -26,6 +26,8 @@
26
26
  # ОТКАЗ В ПОЛЬЗУ РАБОТЫ: не репозиторий, нет разборщика, битый ввод, пустой список файлов —
27
27
  # пропуск.
28
28
 
29
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/utf8.sh" 2>/dev/null || true
30
+
29
31
  input="$(cat 2>/dev/null)"
30
32
  [ -z "$input" ] && exit 0
31
33
  command -v jq >/dev/null 2>&1 || exit 0
@@ -43,7 +45,19 @@ decide() {
43
45
  && rt_note guard-deny res=docs-guard "sid=$(printf '%s' "$input" | jq -r '.session_id // "nosession"' 2>/dev/null)"
44
46
  fi
45
47
 
46
- jq -n --arg d "$1" --arg r "$2" \
48
+ reason="$2"
49
+ if [ "$1" = "deny" ]; then
50
+ # shellcheck disable=SC1090
51
+ [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
52
+ && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
53
+ if command -v rt_deny_tail >/dev/null 2>&1; then
54
+ reason="$2
55
+
56
+ $(rt_deny_tail "строка \`Docs-skip: <причина>\` в теле коммита; пустая причина не принимается")"
57
+ fi
58
+ fi
59
+
60
+ jq -n --arg d "$1" --arg r "$reason" \
47
61
  '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$d,permissionDecisionReason:$r}}' 2>/dev/null \
48
62
  || printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Документ едет тем же коммитом."}}\n'
49
63
  exit 0
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env bash
2
+ # rt-hook: PreToolUse Edit|Write|MultiEdit|mcp__webstorm__create_new_file|Bash
3
+ # Требует: agents/strict-teacher.md, hooks/roles.sh, hooks/deny-tail.sh
4
+ # Гард экзамена: правка не идёт, пока за сессию не сдан экзамен по загруженным правилам.
5
+ #
6
+ # Зачем именно так. Гейт правил требует загрузить правило перед правкой и на этом кончается:
7
+ # загруженное правило и прочитанное правило для дерева неразличимы. Правило на четыре сотни
8
+ # строк уезжает в контекст целиком, а исполняется выборочно — промахи случаются после того, как
9
+ # правило было загружено. Спрашивает роль экзаменатора, а гард судит её вердикт.
10
+ #
11
+ # Вердикт роль отдаёт первой строкой: «ЭКЗАМЕН: сдано N из 5». Сдано — это пять из пяти; любое
12
+ # другое число означает, что правило перечитывается целиком и экзамен пересдаётся.
13
+ #
14
+ # Списанный ответ экзаменом не считается. Отличить его от знания роль не может — а гард может:
15
+ # между вопросами и ответами не должно быть чтения тех же правил. Признак грубый и своей границы
16
+ # не скрывает: чтение соседнего правила он засчитает списыванием тоже.
17
+ #
18
+ # FAIL-OPEN: нет jq, нет записи хода, чужой инструмент → пропуск. Сломанный гард не должен
19
+ # мешать работать.
20
+
21
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/utf8.sh" 2>/dev/null || true
22
+
23
+ input="$(cat 2>/dev/null)"
24
+ [ -z "$input" ] && exit 0
25
+ command -v jq >/dev/null 2>&1 || exit 0
26
+
27
+ # Роль, выключенная деревом, гарда не держит: список выключенных лежит в настройке дерева, а
28
+ # читает его помощник рядом. Нечитаемая настройка выключением не считается — гард работает как
29
+ # прежде.
30
+ rt_hooks_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
31
+ # shellcheck disable=SC1090
32
+ [ -f "$rt_hooks_dir/roles.sh" ] && . "$rt_hooks_dir/roles.sh" 2>/dev/null
33
+ command -v rt_role_off >/dev/null 2>&1 && rt_role_off strict-teacher && exit 0
34
+
35
+ tool="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null)"
36
+ # Второй экзамен спрашивается на снятии черновика: работа кончилась, и правила поставки к этому
37
+ # моменту читались давно — между их чтением и этой минутой прошёл весь заход.
38
+ ready=0
39
+ case "$tool" in
40
+ Edit | Write | MultiEdit | mcp__webstorm__create_new_file) ;;
41
+ Bash)
42
+ cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null)"
43
+ printf '%s' "$cmd" | grep -qE 'pr[[:space:]]+ready|mr[[:space:]]+update[^|;&]*--ready' || exit 0
44
+ ready=1
45
+ ;;
46
+ *) exit 0 ;;
47
+ esac
48
+
49
+ transcript="$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null)"
50
+ [ -z "$transcript" ] && exit 0
51
+ [ -f "$transcript" ] || exit 0
52
+
53
+ # Общий хвост отказа: два законных хода и законная форма обхода, если она у отказа есть. Файл
54
+ # может быть не разложен — тогда хвоста нет, а причина отказа остаётся прежней.
55
+ # shellcheck disable=SC1090
56
+ [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
57
+ && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
58
+ command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
59
+
60
+ deny() {
61
+ reason="$1"
62
+ tail_text="$(rt_deny_tail "$2")"
63
+ [ -n "$tail_text" ] && reason="$1 ${tail_text}"
64
+ jq -n --arg r "$reason" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}' 2>/dev/null \
65
+ || printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$reason"
66
+ exit 0
67
+ }
68
+
69
+ # Судится вся сессия, а не последний ход: экзамен сдаётся один раз на старте и держится до конца.
70
+ verdict="$(jq -s -r '
71
+ [.[] | select(.type == "user") | .message.content // [] | select(type == "array") | .[]
72
+ | select(.type == "tool_result") | .content
73
+ | if type == "string" then .
74
+ elif type == "array" then (map(if type == "object" then (.text // "") else tostring end) | join("\n"))
75
+ else tostring end] | join("\n")
76
+ | [scan("ЭКЗАМЕН:[[:space:]]*сдано[[:space:]]*([0-9]+)[[:space:]]*из[[:space:]]*([0-9]+)")]
77
+ | if length == 0 then "нет"
78
+ else (.[-1] | if .[0] == .[1] then "сдан" else "провален" end)
79
+ end
80
+ ' "$transcript" 2>/dev/null)"
81
+
82
+ # Второй экзамен — тот, что вынесен после открытия PR. Первый его не заменяет: спрашивают о
83
+ # разном, и между ними лежит вся работа.
84
+ if [ "$ready" = "1" ]; then
85
+ # Записи сводятся в один поток в порядке их появления: команда и ответ инструмента лежат в
86
+ # разных полях, и индекс из одного массива в другом не значит ничего.
87
+ after="$(jq -s -r '
88
+ [ .[]
89
+ | if .type == "assistant"
90
+ then ([(.message.content // [])[] | select(.type == "tool_use") | (.input.command // "")] | join("\n"))
91
+ elif .type == "user"
92
+ then ([(.message.content // []) | select(type == "array") | .[]
93
+ | select(.type == "tool_result") | .content
94
+ | if type == "string" then . elif type == "array"
95
+ then (map(if type == "object" then (.text // "") else tostring end) | join("\n"))
96
+ else tostring end] | join("\n"))
97
+ else "" end ] as $flow
98
+ | ($flow | map(test("pr[[:space:]]+create|mr[[:space:]]+create")) | index(true)) as $opened
99
+ | if $opened == null then "нет-pr"
100
+ else ($flow[($opened + 1):] | join("\n")
101
+ | [scan("ЭКЗАМЕН:[[:space:]]*сдано[[:space:]]*([0-9]+)[[:space:]]*из[[:space:]]*([0-9]+)")]
102
+ | if length == 0 then "нет"
103
+ elif (.[-1] | .[0] == .[1]) then "сдан"
104
+ else "провален" end)
105
+ end
106
+ ' "$transcript" 2>/dev/null)"
107
+ case "$after" in
108
+ сдан | нет-pr) exit 0 ;;
109
+ *)
110
+ deny "BLOCKED by exam-guard: черновик снимается после второго экзамена, а его за эту сессию не было. Позови роль strict-teacher с правилами поставки и с тем, чего требовала задача: между чтением этих правил и снятием черновика прошёл весь заход."
111
+ ;;
112
+ esac
113
+ fi
114
+
115
+ case "$verdict" in
116
+ сдан) exit 0 ;;
117
+ провален)
118
+ deny "BLOCKED by exam-guard: экзамен по загруженным правилам провален. Перечитай правило целиком — не тот кусок, о котором спрашивали, — и позови роль strict-teacher снова. Показанный ответ даёт знание одной строки, а не правила."
119
+ ;;
120
+ *)
121
+ deny "BLOCKED by exam-guard: за эту сессию экзамена по загруженным правилам не было. Позови роль strict-teacher, передай ей список загруженных правил, ответь на её вопросы по памяти и верни ей ответы — вердикт она отдаёт строкой «ЭКЗАМЕН: сдано N из 5». Загруженное правило и прочитанное правило — разные вещи, и цену этой разницы платит владелец."
122
+ ;;
123
+ esac
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env bash
2
+ # Подпись машинного коммита для гарда поставки: чей это коммит, той ли почтой он подписан и что
3
+ # делать, если нет.
4
+ #
5
+ # Строки `# rt-hook:` здесь нет намеренно: событие и образец вызова объявляет сам гард, а
6
+ # помощник рядом хуком не регистрируется и в одиночку ничего не решает. Он зовётся из гарда и
7
+ # пользуется его же переменными — командой, объявленной почтой, именем главной ветки — и его
8
+ # отказом.
9
+ #
10
+ # Вынесен он отсюда потому, что гард дорос до предела длины: предметов в нём пять, и подпись
11
+ # среди них самая отдельная — своя точка проверки, свои условия молчания, свой отказ.
12
+
13
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/utf8.sh" 2>/dev/null || true
14
+
15
+ rt_delivery_signature() {
16
+ # --- подпись машинного коммита ------------------------------------------------------------
17
+ #
18
+ # Служебный адрес хостинга состоит из числа, логина и домена, а сопоставляется по числу: логин
19
+ # рядом с ним не сверяет никто. Коммит с чужим числом хостинг припишет постороннему человеку, и
20
+ # изнутри это выглядит верным — имя учётной записи в истории то самое. Так одиннадцать коммитов
21
+ # и уехали в главную ветку за чужой подписью; нашёл это владелец, читая историю глазами.
22
+ #
23
+ # Точка — пуш: до него подпись переписывается на месте всей веткой сразу, после — только силовой
24
+ # отправкой. На коммите гард не стоит: почта задаётся переменными самой команды, и разбор её из
25
+ # текста ловил бы ту же строку, которая и так перед глазами у набравшего её.
26
+ #
27
+ # Судится коммит, НАЗВАВШИЙСЯ машинной записью: её логин стоит именем автора либо левой частью
28
+ # почты. Опознавать по самой почте нельзя — она в таком коммите как раз и неверна, а требовать
29
+ # машинную подпись от каждого коммита значило бы отбивать работу, сделанную человеком своими
30
+ # руками в том же дереве.
31
+ #
32
+ # Логин читается из объявленной почты, а не объявляется вторым свойством: два объявления одного
33
+ # логина разошлись бы молча. Исполнителем задачи он тоже не бывает — в дереве, где хостинг
34
+ # ограничил машинную запись, исполнителем ставят человека, а коммит остаётся машинным.
35
+ #
36
+ # Вызов пуша узнаётся по двум признакам сразу — команда `git` в начале строки или за
37
+ # разделителем и слово `push` отдельным словом. Одной подстрокой «git push» его не поймать:
38
+ # помощник учётных данных и заголовок запроса ставятся ключами `-c` между ними.
39
+ # Пробный пуш не отправляет ничего, и подпись у него не спрашивают. Отложенная правка — тоже:
40
+ # `git stash push` кладёт правку в тайник этой же машины, поэтому тайник вырезается из строки, а
41
+ # признак считается по остатку.
42
+ if printf '%s' "$cmd" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*git([[:space:]]|$)' \
43
+ && printf '%s' "$cmd" | sed -E 's/git[[:space:]]+stash[[:space:]]+push/git stash/g' | grep -qE '(^|[[:space:]])push([[:space:]]|$)' \
44
+ && ! printf '%s' "$cmd" | grep -q -- '--dry-run'; then
45
+ # Два условия молчания. Дерево, не назвавшее почты машинной записи, требования не получает:
46
+ # своей машинной записи у пакета нет, а выдуманная отбивала бы работу в чужом дереве.
47
+ # Вершины главной ветки нет — вклад считать не от чего.
48
+ #
49
+ # Судится вклад ветки, а не вся история: влитое в главную этой веткой уже не чинится, и
50
+ # отказ за него отбивал бы работу вместо промаха.
51
+ if [ -n "$commit_email" ] \
52
+ && git rev-parse --verify --quiet "refs/remotes/origin/${main_branch}" >/dev/null 2>&1; then
53
+ # Левая часть служебного адреса: `<число>+<логин>` либо просто логин.
54
+ bot_login="${commit_email%%@*}"
55
+ bot_login="${bot_login##*+}"
56
+
57
+ strangers=''
58
+ while IFS="$(printf '\t')" read -r short author email; do
59
+ [ -z "$short" ] && continue
60
+ login="${email%%@*}"
61
+ login="${login##*+}"
62
+ [ "$author" = "$bot_login" ] || [ "$login" = "$bot_login" ] || continue
63
+ [ "$email" = "$commit_email" ] && continue
64
+ strangers="${strangers}${strangers:+, }${short} <${email}>"
65
+ done <<EOF
66
+ $(git log --format='%h%x09%an%x09%ae' "origin/${main_branch}..HEAD" 2>/dev/null)
67
+ EOF
68
+
69
+ [ -n "$strangers" ] \
70
+ && deny "BLOCKED: машинный коммит подписан не той почтой, что объявлена деревом. Хостинг сопоставляет служебный адрес по числу в нём, и коммит с чужим числом он припишет постороннему человеку — изнутри промах не виден, потому что имя учётной записи рядом верное. Расходятся: ${strangers}. Объявлено: ${commit_email} — почта берётся оттуда, а не набирается по памяти. Перепиши подпись до пуша, после него это чинит только силовая отправка:
71
+ последний коммит — GIT_AUTHOR_NAME=\"${bot_login}\" GIT_AUTHOR_EMAIL=\"${commit_email}\" GIT_COMMITTER_NAME=\"${bot_login}\" GIT_COMMITTER_EMAIL=\"${commit_email}\" git commit --amend --no-edit --reset-author
72
+ весь вклад ветки — git filter-branch -f --env-filter 'GIT_AUTHOR_EMAIL=\"${commit_email}\"; GIT_COMMITTER_EMAIL=\"${commit_email}\"' origin/${main_branch}..HEAD"
73
+ fi
74
+ fi
75
+ # Своего выхода у этой точки нет: пуш бывает и составной командой, а второе её звено судят
76
+ # разделы ниже.
77
+ }