@rt-tools/agent-kit 0.17.0 → 0.18.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 (75) hide show
  1. package/assets/checks/board-epics.github.mjs +123 -0
  2. package/assets/checks/board-gh.github.mjs +121 -0
  3. package/assets/checks/board.github.mjs +77 -73
  4. package/assets/checks/check-board.github.mjs +39 -3
  5. package/assets/checks/check-hook-scope.mjs +126 -0
  6. package/assets/checks/check-profile-drift.mjs +195 -0
  7. package/assets/checks/check-specs.mjs +1 -1
  8. package/assets/checks/rt-kit-checks.config.mjs +13 -0
  9. package/assets/checks/spec-anchors.mjs +2 -2
  10. package/assets/defaults/project.sh +21 -117
  11. package/assets/defaults/shell.sh +139 -0
  12. package/assets/hooks/browser-guard-no-other-drivers.sh +41 -2
  13. package/assets/hooks/claim-guard.sh +22 -1
  14. package/assets/hooks/docs-guard.sh +1 -1
  15. package/assets/hooks/exam-guard.sh +85 -12
  16. package/assets/hooks/git-guard-delivery-conflict.sh +85 -0
  17. package/assets/hooks/git-guard-delivery-signature.sh +14 -5
  18. package/assets/hooks/git-guard-delivery.sh +15 -2
  19. package/assets/hooks/grill-gate.sh +63 -6
  20. package/assets/hooks/proposal-guard.sh +11 -4
  21. package/assets/hooks/rule-source-guard.sh +8 -13
  22. package/assets/hooks/task-context-load.sh +22 -0
  23. package/assets/hooks/task-flow-context.sh +9 -0
  24. package/assets/hooks/task-flow-draft-guard.sh +17 -0
  25. package/assets/hooks/turn-exit-guard.sh +45 -87
  26. package/assets/hooks/waiting-turn-guard.sh +65 -2
  27. package/assets/hooks/work-start-guard.sh +166 -0
  28. package/assets/hooks/write-targets.sh +24 -0
  29. package/assets/laws/verifiability.md +23 -0
  30. package/assets/patterns/browser-verification-measure.md +3 -1
  31. package/assets/patterns/browser-verification-stand.md +10 -2
  32. package/assets/patterns/doc-style-write.md +21 -1
  33. package/assets/patterns/git-workflow-commit.github.md +18 -3
  34. package/assets/patterns/git-workflow-docker.md +8 -0
  35. package/assets/patterns/git-workflow-freshness.md +87 -0
  36. package/assets/patterns/git-workflow-merge.md +23 -0
  37. package/assets/patterns/git-workflow-pr.github.md +6 -0
  38. package/assets/patterns/git-workflow-restart.md +18 -0
  39. package/assets/patterns/git-workflow-stack.md +30 -0
  40. package/assets/patterns/lib-layers-move.md +4 -0
  41. package/assets/patterns/spec-driven-domain.md +13 -1
  42. package/assets/patterns/spec-driven-rule.md +5 -4
  43. package/assets/patterns/spec-driven-sweep.md +1 -1
  44. package/assets/patterns/status-report-table.github.md +1 -1
  45. package/assets/patterns/task-flow-archive.md +12 -2
  46. package/assets/patterns/task-flow-close.md +31 -24
  47. package/assets/patterns/task-flow-handoff.md +14 -2
  48. package/assets/patterns/task-flow-resume.md +18 -3
  49. package/assets/patterns/task-flow-start.md +23 -23
  50. package/assets/patterns/testing-e2e.md +23 -0
  51. package/assets/patterns/turn-entry-map.md +1 -1
  52. package/assets/pitfalls/agent-kit.md +4 -6
  53. package/assets/pitfalls/doc-style.md +10 -0
  54. package/assets/pitfalls/git-workflow.github.md +20 -0
  55. package/assets/pitfalls/spec-driven.md +6 -0
  56. package/assets/pitfalls/task-flow.md +20 -0
  57. package/assets/pitfalls/testing.md +6 -0
  58. package/assets/pitfalls/turn-conduct.md +35 -0
  59. package/assets/rules/browser-verification.md +19 -0
  60. package/assets/rules/deploy-flow.github.md +9 -0
  61. package/assets/rules/doc-style.md +6 -0
  62. package/assets/rules/git-workflow.azure.md +17 -2
  63. package/assets/rules/git-workflow.github.md +89 -90
  64. package/assets/rules/git-workflow.gitlab.md +12 -4
  65. package/assets/rules/lists.md +5 -0
  66. package/assets/rules/observability.needs-app.md +4 -0
  67. package/assets/rules/reuse-first.md +6 -0
  68. package/assets/rules/spec-driven.md +29 -25
  69. package/assets/rules/task-flow.md +52 -46
  70. package/assets/rules/testing.md +27 -1
  71. package/assets/rules/turn-conduct.md +46 -43
  72. package/assets/skills/agent-kit.md +9 -0
  73. package/package.json +1 -1
  74. package/rt-tools-agent-kit-0.18.0.tgz +0 -0
  75. package/rt-tools-agent-kit-0.17.0.tgz +0 -0
@@ -21,6 +21,15 @@
21
21
  # правил, поиск по ним. Требовать именно загрузку значило бы гнать на неё там, где хватило
22
22
  # одного поиска, — гард мешал бы работе вместо того, чтобы её выправлять.
23
23
  #
24
+ # Но чтение чего угодно из слоя правил вопроса не закрывает: прочитанный разбор чужого промаха
25
+ # и поиск по каталогу засчитывались наравне с правилом, которое этой работе и требуется, — а
26
+ # ответ на заданный владельцу вопрос лежал ровно в нём. Поэтому, когда область работы этого хода
27
+ # известна, чтением считается только правило этой области. Область берётся оттуда же, откуда её
28
+ # берёт гейт правил: по путям правок хода.
29
+ #
30
+ # ОТКАЗ В ПОЛЬЗУ РАБОТЫ и здесь: правок в ходу нет, карты гейта нет, область не определилась —
31
+ # засчитывается любое чтение, как прежде.
32
+ #
24
33
  # ОТКАЗ В ПОЛЬЗУ РАБОТЫ: при любой ошибке, отсутствии записи хода и повторном заходе ход
25
34
  # РАЗРЕШАЕТСЯ (exit 0). Сломанный гард не имеет права заклинить разговор.
26
35
 
@@ -68,11 +77,47 @@ archive_dir="${RT_ARCHIVE_DIR-docs/archive}"
68
77
  # Дерево, у которого нет ни законов, ни правил, требования не получает: читать нечего.
69
78
  [ -z "$laws_dir" ] && [ -z "$rules_dir" ] && exit 0
70
79
 
80
+ # Карта гейта: по ней имя правила достаётся из пути правки. Нет её — область не определяется, и
81
+ # всё остаётся прежним.
82
+ rt_hooks_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
83
+ for map in "$rt_hooks_dir/../rt-kit/defaults/gate-map.sh" "$rt_hooks_dir/../defaults/gate-map.sh" "${CLAUDE_PROJECT_DIR:-.}/.claude/rt-kit/defaults/gate-map.sh" "${CLAUDE_PROJECT_DIR:-.}/.claude/rt-kit/gate-map.sh"; do
84
+ # shellcheck disable=SC1090
85
+ [ -f "$map" ] && . "$map" 2>/dev/null
86
+ done
87
+
71
88
  # Образец, по которому вызов инструмента считается чтением правил. Каталоги идут в него как
72
89
  # есть: точка в `.claude` совпадает с любым знаком и лишнего сюда не приводит.
73
90
  read_re="$(printf '%s' "$laws_dir|$rules_dir|$specs_dir|$plans_dir|$archive_dir" | sed 's/^|*//; s/|*$//; s/||*/|/g')"
74
91
  [ -z "$read_re" ] && exit 0
75
92
 
93
+ # Область работы этого хода: правила, которых требует гейт от путей, правленных в ходу. Пути
94
+ # достаются тем же разбором хода, что и ниже, — своим вызовом, чтобы образец чтения был готов
95
+ # к главному разбору.
96
+ need_re=''
97
+ if command -v skill_for >/dev/null 2>&1 && [ -n "$rules_dir" ]; then
98
+ edited="$(tail -n 400 "$transcript" 2>/dev/null | jq -s -r '
99
+ def is_input:
100
+ .type == "user"
101
+ and (((.message.content // []) | if type == "array"
102
+ then ([.[] | select(.type == "tool_result")] | length)
103
+ else 0 end) == 0);
104
+ (map(is_input) | rindex(true)) as $i
105
+ | (if $i == null then . else .[$i + 1:] end)
106
+ | [.[] | select(.type == "assistant") | (.message.content // [])[]
107
+ | select(.type == "tool_use") | select(.name == "Edit" or .name == "Write" or .name == "NotebookEdit")
108
+ | (.input.file_path // .input.notebook_path // "")]
109
+ | map(select(. != "")) | unique | .[]
110
+ ' 2>/dev/null)"
111
+ for path in $edited; do
112
+ for rule in $(skill_for edit "$path" '' 2>/dev/null); do
113
+ case "|$need_re|" in
114
+ *"|$rule|"*) ;;
115
+ *) need_re="${need_re}${need_re:+|}${rule}" ;;
116
+ esac
117
+ done
118
+ done
119
+ fi
120
+
76
121
  # Ход — это всё, что записано после последнего настоящего ввода владельца. Ответ инструмента
77
122
  # приходит той же ролью `user`, поэтому строки с `tool_result` вводом не считаются: иначе ходом
78
123
  # оказался бы кусок после последнего вызова инструмента, и чтение правил в его начале потерялось
@@ -82,7 +127,7 @@ read_re="$(printf '%s' "$laws_dir|$rules_dir|$specs_dir|$plans_dir|$archive_dir"
82
127
  # На событии вызова инструмента вопрос уже известен — он и есть вызов; судится только то,
83
128
  # читались ли за этот ход правила. На завершении хода вопрос ищется в тексте реплик: меню к
84
129
  # этому моменту уже отбито раньше.
85
- verdict="$(tail -n 400 "$transcript" 2>/dev/null | jq -s -r --arg re "$read_re" --arg tool "$tool" '
130
+ verdict="$(tail -n 400 "$transcript" 2>/dev/null | jq -s -r --arg re "$read_re" --arg tool "$tool" --arg need "$need_re" --arg rules "$rules_dir" '
86
131
  def is_input:
87
132
  .type == "user"
88
133
  and (((.message.content // []) | if type == "array"
@@ -93,11 +138,23 @@ verdict="$(tail -n 400 "$transcript" 2>/dev/null | jq -s -r --arg re "$read_re"
93
138
  | (if $i == null then . else .[$i + 1:] end) as $turn
94
139
  | [$turn[] | select(.type == "assistant") | (.message.content // [])[] | select(.type == "text") | .text] as $texts
95
140
  | [$turn[] | select(.type == "assistant") | (.message.content // [])[] | select(.type == "tool_use")] as $uses
96
- | ($uses | map(
97
- (.name == "Skill")
98
- or ((.name // "") | test("^(Read|Grep|Glob)$")) and ((.input | tostring) | test($re))
99
- or ((.name == "Bash") and ((.input.command // "") | test($re)))
100
- ) | any) as $read
141
+ | (if $need == "" then
142
+ $uses | map(
143
+ (.name == "Skill")
144
+ or ((.name // "") | test("^(Read|Grep|Glob)$")) and ((.input | tostring) | test($re))
145
+ or ((.name == "Bash") and ((.input.command // "") | test($re)))
146
+ ) | any
147
+ else
148
+ # Область работы известна — засчитывается только правило этой области: прочитанный
149
+ # разбор чужого промаха на заданный вопрос не отвечает.
150
+ ($need | split("|")) as $rules_needed
151
+ | $uses | map(
152
+ ((.name == "Skill") and (((.input.skill // "") | tostring) as $s | $rules_needed | index($s) != null))
153
+ or (((.name // "") | test("^(Read|Grep|Glob|Bash)$"))
154
+ and ((.input | tostring) as $text
155
+ | $rules_needed | map(. as $rule | $text | test($rules + "/" + $rule + "(/|\\b)")) | any))
156
+ ) | any
157
+ end) as $read
101
158
  | (($texts | join("\n")) | test("\\?[[:space:]]*$"; "m")) as $asked_prose
102
159
  | ($tool != "") as $asking_now
103
160
  | if ($asked_prose or $asking_now) and ($read | not) then "ask" else "pass" end
@@ -38,16 +38,24 @@ transcript="$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/nu
38
38
  [ -f "$transcript" ] || exit 0
39
39
 
40
40
  # Каталог предложений у дерева свой. Заданный пустым — отказ дерева от требования: дереву, не
41
- # берущему переносимый слой правил, гард не навязывается.
41
+ # берущему переносимый слой правил, гард не навязывается. Каталога нет на диске — то же самое:
42
+ # дерево механизмом не пользуется, и стража ему не навязывают. Судить по одной переменной значило
43
+ # бы требовать отправки там, где отправлять нечем и некуда.
44
+ root="${CLAUDE_PROJECT_DIR:-.}"
42
45
  proposals_dir="${RT_PROPOSALS_DIR-.claude/rt-kit/proposals}"
43
46
  [ -z "$proposals_dir" ] && exit 0
47
+ [ -d "$root/$proposals_dir" ] || exit 0
44
48
 
45
49
  # Просьба владельца. Набор открыт и пополняется правкой: полнота его — открытый вопрос
46
50
  # договорённости, а не обещание.
47
- asked_re='(завед|напиш|отправ|пошл|зашл|отошл|выгруз)[а-яё]*[^.!?]{0,40}(пропозал|предложени)|(пропозал|предложени)[а-яё]*[^.!?]{0,40}(завед|напиш|отправ|пошл|зашл|отошл|выгруз)'
51
+ #
52
+ # Латиница читается наравне: заход, который владелец ведёт по-английски, отличается от русского
53
+ # словами, а требование в нём то же. Своей ветки ей не заводится — обе пары стоят в одном
54
+ # образце, иначе одну из них правили бы, забывая про вторую.
55
+ asked_re='(завед|напиш|отправ|пошл|зашл|отошл|выгруз|send|file|open|submit|raise|report)[а-яёa-z]*[^.!?]{0,40}(пропозал|предложени|proposal)|(пропозал|предложени|proposal)[а-яёa-z]*[^.!?]{0,40}(завед|напиш|отправ|пошл|зашл|отошл|выгруз|send|file|open|submit|raise|report)'
48
56
 
49
57
  # Сосед, без которого слово «предложение» не считается просьбой о слое правил.
50
- context_re='пропозал|слою правил|слоя правил|слой правил|пакет|agent-kit|наверх'
58
+ context_re='пропозал|слою правил|слоя правил|слой правил|пакет|agent-kit|наверх|proposal|rule layer|upstream|package'
51
59
 
52
60
  # Ход — это всё, что записано после последнего настоящего ввода владельца. Ответ инструмента
53
61
  # приходит той же ролью, поэтому строки с `tool_result` вводом не считаются.
@@ -86,7 +94,6 @@ verdict="$(tail -n 400 "$transcript" 2>/dev/null | jq -s -r --arg asked "$asked_
86
94
  # зависимостью, зовёт бинарь из зависимостей; дерево, где пакет живёт исходниками, бинаря не
87
95
  # имеет вовсе — там зовут собранный bin. Названная наугад команда стоит исполнителю хода: отказ
88
96
  # читается как указание, и вызов `npx agent-kit` отвечает в таком дереве отказом установки.
89
- root="${CLAUDE_PROJECT_DIR:-.}"
90
97
  if [ -x "$root/node_modules/.bin/agent-kit" ]; then
91
98
  propose_cmd="npx agent-kit propose"
92
99
  else
@@ -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, hooks/deny-tail.sh
3
+ # Требует: hooks/profile-check.sh, hooks/deny-tail.sh, hooks/write-targets.sh
4
4
  # Гард места правки: слой правил чинится там, где сломано, а не там, где виден.
5
5
  #
6
6
  # Слой правил правит тот же исполнитель, которым слой правил управляет, и разницы между
@@ -53,18 +53,13 @@ deny() {
53
53
  exit 0
54
54
  }
55
55
 
56
- # Цели записи, названные командой прямо. Печатает по одной в строке.
57
- rt_write_targets() {
58
- tr "\"'\`" ' ' \
59
- | sed -E 's/>>?/\n>/g' \
60
- | sed -nE '
61
- s/^>[[:space:]]*([^[:space:]|&;]+).*/\1/p
62
- s/(^|.*[[:space:]])tee[[:space:]]+(-a[[:space:]]+)?([^[:space:]|&;]+).*/\3/p
63
- s/(^|.*[[:space:]])sed[[:space:]]+-i[[:space:]]+([^[:space:]]+[[:space:]]+)*([^[:space:]|&;]+)$/\3/p
64
- s/(^|.*[[:space:]])(cp|mv|install)[[:space:]]+([^[:space:]]+[[:space:]]+)+([^[:space:]|&;]+).*/\4/p
65
- ' \
66
- | sort -u
67
- }
56
+ # Цели записи разбирает общий помощник: тот же признак нужен гарду экзамена, и разойдясь, две
57
+ # копии пропустили бы разные формы записи. Файла нет — остаётся молчаливое умолчание, чтобы гард
58
+ # не сломался на неполной раскладке.
59
+ # shellcheck disable=SC1090
60
+ [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/write-targets.sh" ] \
61
+ && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/write-targets.sh" 2>/dev/null
62
+ command -v rt_write_targets >/dev/null 2>&1 || rt_write_targets() { cat >/dev/null; }
68
63
 
69
64
  tool="$(rt_hook_tool)"
70
65
  candidates=""
@@ -52,6 +52,28 @@ emit() {
52
52
  # Ветка под задачу без папки — работа идёт мимо. Сессию не рвём: SessionStart, отбивающий
53
53
  # запуск, оставляет владельца без агента вовсе, а правку кода поймает `task-flow-guard`.
54
54
  if [ ! -d "$DIR" ]; then
55
+ # Папки нет по двум разным причинам, и говорить о них надо разное. Первая — работа шла мимо
56
+ # правила. Вторая — папку разобрала сама ветка последним коммитом перед заявкой: это законный
57
+ # исход, и указание собрать её заново уводит заход с хвоста работы обратно в её начало.
58
+ #
59
+ # Различает их история ветки: снос папки её же коммитом после общего предка с главной. Ход
60
+ # работы к этой минуте уехал вместе с папкой, и состояние держат заявка и передача захода.
61
+ main_branch="${RT_MAIN_BRANCH:-main}"
62
+ base="$(git merge-base "origin/${main_branch}" HEAD 2>/dev/null || git merge-base "$main_branch" HEAD 2>/dev/null)"
63
+ dropped=''
64
+ [ -n "$base" ] && dropped="$(git log "${base}..HEAD" --diff-filter=D --name-only --pretty=format: -- "$DIR" 2>/dev/null | head -1)"
65
+
66
+ if [ -n "$dropped" ]; then
67
+ {
68
+ printf 'РАБОТА ЗАКРЫВАЕТСЯ — папка задачи разобрана этой веткой.\n\n'
69
+ printf 'Ход работы удалён вместе с папкой: состояние держат заявка и передача захода.\n'
70
+ printf 'Папка заново не собирается. Правка кода после разбора требует восстановить её\n'
71
+ printf 'на время правки и повторить разбор тем же коммитом. Порядок — скил `task-flow`,\n'
72
+ printf 'паттерны `task-flow-close` и `task-flow-archive`.\n'
73
+ } | emit
74
+ exit 0
75
+ fi
76
+
55
77
  if rt_needs rt_task_branch_ok task-context-load && rt_task_branch_ok "$branch"; then
56
78
  {
57
79
  printf 'РАБОТА БЕЗ ПАПКИ ЗАДАЧИ.\n\n'
@@ -97,6 +97,15 @@ rt_task_flow_context() {
97
97
  ' 2>/dev/null)"
98
98
  [ -n "$rt_tf_inner" ] && rt_tf_cmd="$rt_tf_inner"
99
99
  fi
100
+ # Команда заведения задачи не судится вовсе. Она пишет папку задачи и карточку в
101
+ # очереди работ, а текст её несёт тело задачи целиком: цитата со знаком «больше»
102
+ # подходит под признак записи, путь к коду в прозе тела — под признак пути. Отбитая,
103
+ # она отбивается тем самым гардом, который печатает её в тексте своего отказа, и
104
+ # завести задачу становится нечем — ни с ветки закрытой задачи, ни с главной. Имя
105
+ # команды берётся у профиля дерева: своя копия разошлась бы с ним молча.
106
+ case "$rt_tf_cmd" in
107
+ *"${RT_TASK_NEW_CMD:-npm run task:new}"*) return 1 ;;
108
+ esac
100
109
  rt_needs rt_shell_writes task-flow-guard || return 1
101
110
  rt_needs rt_shell_paths task-flow-guard || return 1
102
111
  rt_shell_writes "$rt_tf_cmd" || return 1
@@ -89,4 +89,21 @@ if git -C "$root" log --oneline -1 -- "$draft" 2>/dev/null | grep -q .; then
89
89
  exit 0
90
90
  fi
91
91
 
92
+ # Вливание бывает и первым коммитом, который вообще завёл названный путь: черновик писали, не
93
+ # коммитя, а в историю уехал уже спек домена. Тогда истории у пути нет, а работа сделана ровно
94
+ # так, как велит паттерн закрытия, — и отказ запирал бы ветку на последнем шаге. Признак:
95
+ # договорённость названа путём вида `<домен>/proposed/<фича>`, каталог домена в дереве есть, и
96
+ # имя фичи в нём встречается. Пока такого спека нет, отказ остаётся прежним.
97
+ case "$draft" in
98
+ */proposed/*)
99
+ domain_dir="${draft%%/proposed/*}"
100
+ feature="${draft##*/proposed/}"
101
+ feature="${feature%/}"
102
+ if [ -n "$feature" ] && [ -d "$root/$domain_dir" ] \
103
+ && grep -rq -- "$feature" "$root/$domain_dir" 2>/dev/null; then
104
+ exit 0
105
+ fi
106
+ ;;
107
+ esac
108
+
92
109
  deny "BLOCKED by task-flow: замысел называет договорённость '${draft}', а её на диске нет и в истории ветки не было. Заведи её с образца (docs/specs/_template) или поправь путь в '${tasks_dir}/${branch}/plan.md'. Правило — скил task-flow."
@@ -38,6 +38,27 @@
38
38
  . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/hook-input.sh" 2>/dev/null || true
39
39
 
40
40
  rt_hook_read
41
+
42
+ # Отказ печатается одной формой: причина, общий хвост о двух законных ходах и запасная строка
43
+ # на случай, если разборщика нет. Прежде эти двенадцать строк стояли восемью копиями — файл рос
44
+ # на каждом новом ярусе быстрее, чем на самом требовании.
45
+ rt_te_deny() {
46
+ rt_te_reason="$1"
47
+ rt_te_short="$2"
48
+ # shellcheck disable=SC1090
49
+ [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
50
+ && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
51
+ command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
52
+ rt_te_tail="$(rt_deny_tail "")"
53
+ [ -n "$rt_te_tail" ] && rt_te_reason="${rt_te_reason}
54
+
55
+ ${rt_te_tail}"
56
+
57
+ jq -n --arg r "$rt_te_reason" '{decision:"block",reason:$r}' 2>/dev/null \
58
+ || printf '{"decision":"block","reason":"turn-exit-guard: %s"}\n' "$rt_te_short"
59
+ exit 0
60
+ }
61
+
41
62
  input="$RT_HOOK_INPUT"
42
63
  [ -z "$input" ] && exit 0
43
64
  command -v jq >/dev/null 2>&1 || exit 0
@@ -97,20 +118,7 @@ if [ "$state" = "замысел-записан" ]; then
97
118
 
98
119
  Страж судит один ход: следующий заход не отбивается."
99
120
 
100
- # Общий хвост отказа: два законных хода. Файл может быть не разложен — тогда хвоста нет,
101
- # а причина отказа остаётся прежней.
102
- # shellcheck disable=SC1090
103
- [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
104
- && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
105
- command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
106
- deny_tail_text="$(rt_deny_tail "")"
107
- [ -n "$deny_tail_text" ] && reason="${reason}
108
-
109
- ${deny_tail_text}"
110
-
111
- jq -n --arg r "$reason" '{decision:"block",reason:$r}' 2>/dev/null \
112
- || printf '{"decision":"block","reason":"turn-exit-guard: замысел записан, а первый этап не начат."}\n'
113
- exit 0
121
+ rt_te_deny "$reason" "замысел записан, а первый этап не начат."
114
122
  fi
115
123
 
116
124
  # То же самое, но объявить это на диске уже нечем: папка задачи разбирается до открытия заявки,
@@ -277,18 +285,7 @@ if [ "$archived" != "true" ] && [ -z "$progress" ] && [ -n "$branch" ] && [ ! -d
277
285
 
278
286
  Страж судит один ход: следующий заход не отбивается."
279
287
 
280
- # shellcheck disable=SC1090
281
- [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
282
- && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
283
- command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
284
- deny_tail_text="$(rt_deny_tail "")"
285
- [ -n "$deny_tail_text" ] && reason="${reason}
286
-
287
- ${deny_tail_text}"
288
-
289
- jq -n --arg r "$reason" '{decision:"block",reason:$r}' 2>/dev/null \
290
- || printf '{"decision":"block","reason":"turn-exit-guard: работа взята и не начата — напиши замысел."}\n'
291
- exit 0
288
+ rt_te_deny "$reason" "работа взята и не начата — напиши замысел."
292
289
  fi
293
290
  fi
294
291
 
@@ -327,20 +324,7 @@ EOF
327
324
  Запусти их этим же ходом либо верни прежний номер этапа в ход работы.
328
325
 
329
326
  Страж судит один ход: следующий заход не отбивается."
330
- # Общий хвост отказа: два законных хода. Файл может быть не разложен тогда хвоста нет,
331
- # а причина отказа остаётся прежней.
332
- # shellcheck disable=SC1090
333
- [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
334
- && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
335
- command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
336
- deny_tail_text="$(rt_deny_tail "")"
337
- [ -n "$deny_tail_text" ] && reason="${reason}
338
-
339
- ${deny_tail_text}"
340
-
341
- jq -n --arg r "$reason" '{decision:"block",reason:$r}' 2>/dev/null \
342
- || printf '{"decision":"block","reason":"turn-exit-guard: закрытый этап не подтверждён выводом команды."}\n'
343
- exit 0
327
+ rt_te_deny "$reason" "закрытый этап не подтверждён выводом команды."
344
328
  fi
345
329
  fi
346
330
 
@@ -363,18 +347,7 @@ if [ "$handed_over" = "true" ] && [ "$started_next" != "true" ]; then
363
347
 
364
348
  Страж судит один ход: следующий заход не отбивается."
365
349
 
366
- # shellcheck disable=SC1090
367
- [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
368
- && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
369
- command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
370
- deny_tail_text="$(rt_deny_tail "")"
371
- [ -n "$deny_tail_text" ] && reason="${reason}
372
-
373
- ${deny_tail_text}"
374
-
375
- jq -n --arg r "$reason" '{decision:"block",reason:$r}' 2>/dev/null \
376
- || printf '{"decision":"block","reason":"turn-exit-guard: работа отдана, а следующая не начата."}\n'
377
- exit 0
350
+ rt_te_deny "$reason" "работа отдана, а следующая не начата."
378
351
  fi
379
352
 
380
353
  # Ход кончился ожиданием чужого шага. Работа в нём была — тем он и обманчив: полон, и пустоты за
@@ -390,18 +363,7 @@ if [ "$waited" = "true" ]; then
390
363
 
391
364
  Страж судит один ход: следующий заход не отбивается."
392
365
 
393
- # shellcheck disable=SC1090
394
- [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
395
- && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
396
- command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
397
- deny_tail_text="$(rt_deny_tail "")"
398
- [ -n "$deny_tail_text" ] && reason="${reason}
399
-
400
- ${deny_tail_text}"
401
-
402
- jq -n --arg r "$reason" '{decision:"block",reason:$r}' 2>/dev/null \
403
- || printf '{"decision":"block","reason":"turn-exit-guard: ход кончился ожиданием чужого шага."}\n'
404
- exit 0
366
+ rt_te_deny "$reason" "ход кончился ожиданием чужого шага."
405
367
  fi
406
368
 
407
369
  # Общий рубеж. Работа за ход была — но последним действием стал не она, а текст владельцу.
@@ -417,18 +379,26 @@ if [ "$worked" = "true" ] && [ "$ended_working" != "true" ]; then
417
379
 
418
380
  Страж судит один ход: следующий заход не отбивается."
419
381
 
420
- # shellcheck disable=SC1090
421
- [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
422
- && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
423
- command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
424
- deny_tail_text="$(rt_deny_tail "")"
425
- [ -n "$deny_tail_text" ] && reason="${reason}
382
+ rt_te_deny "$reason" "последним действием хода стала не работа."
383
+ fi
426
384
 
427
- ${deny_tail_text}"
385
+ # Работа, оставшаяся в рабочем дереве. Ветка ушла вперёд своей удалённой ссылки, а заявки при
386
+ # ней в этом ходе не открывали: сделанное лежит там, где его не видит никто, кроме сделавшего.
387
+ # Признак читается без сети и молчит там, где ссылки нет вовсе — под пробу заводят и такие ветки.
388
+ # Разбор — `docs/postmortems/2026-08-25-fifteen-branches-over-one-index.md`.
389
+ if [ "$worked" = "true" ] && [ "$handed_over" != "true" ]; then
390
+ unpushed="$(git -C "$root" rev-list --count '@{u}..HEAD' 2>/dev/null)"
391
+ if [ -n "$unpushed" ] && [ "$unpushed" -gt 0 ] 2>/dev/null; then
392
+ reason="BLOCKED by turn-exit-guard: за ход была работа, но она осталась в рабочем дереве — неотданных коммитов ${unpushed}.
428
393
 
429
- jq -n --arg r "$reason" '{decision:"block",reason:$r}' 2>/dev/null \
430
- || printf '{"decision":"block","reason":"turn-exit-guard: последним действием хода стала не работа."}\n'
431
- exit 0
394
+ Владелец видит прежнее состояние и читает его как «не сделано ничего». Ходов отсюда два: довести работу до хостинга — запушить ветку и открыть заявку — либо снять сделанное, если оно не нужно.
395
+
396
+ Оставшееся в дереве называется причиной — словом владельца, а не списком остатков.
397
+
398
+ Страж судит один ход: следующий заход не отбивается."
399
+
400
+ rt_te_deny "$reason" "работа осталась в рабочем дереве."
401
+ fi
432
402
  fi
433
403
 
434
404
  [ "$worked" = "true" ] && exit 0
@@ -463,18 +433,6 @@ else
463
433
  Страж судит один ход: следующий заход не отбивается."
464
434
  fi
465
435
 
466
- # Общий хвост отказа: два законных хода. Файл может быть не разложен — тогда хвоста нет,
467
- # а причина отказа остаётся прежней.
468
- # shellcheck disable=SC1090
469
- [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
470
- && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
471
- command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
472
- deny_tail_text="$(rt_deny_tail "")"
473
- [ -n "$deny_tail_text" ] && reason="${reason}
474
-
475
- ${deny_tail_text}"
476
-
477
- jq -n --arg r "$reason" '{decision:"block",reason:$r}' 2>/dev/null \
478
- || printf '{"decision":"block","reason":"turn-exit-guard: работа не кончена — следующий шаг стоит в ходе работы."}\n'
436
+ rt_te_deny "$reason" "работа не кончена следующий шаг стоит в ходе работы."
479
437
 
480
438
  exit 0
@@ -69,13 +69,20 @@ moved_re='task:new|task:move|checkout[[:space:]]+-b|docs/tasks/'
69
69
  # следующая задача была взята вместо этого, а не сверх этого.
70
70
  ready_re='pr[[:space:]]+ready|run[[:space:]]+(list|view|watch)|pr[[:space:]]+checks|check-runs|check:board|board\.mjs'
71
71
 
72
+ # Своё названное действие. Пустой ход, объявивший, что сделает дальше, ничем себя не выдаёт:
73
+ # заявку он не открывал, прогона не читал, и оба прежних признака молчат. Ловится он формой —
74
+ # набором образцов будущего времени о собственном шаге, — а не пониманием смысла; за словами
75
+ # при этом не должно стоять ни одного вызова, иначе объявление сказано по ходу работы, а не
76
+ # вместо неё.
77
+ vow_re='дальше беру|дальше возьму|дальше иду|следующим шагом|следующий шаг:|затем сделаю|затем возьму|потом сделаю|после этого сделаю|далее беру'
78
+
72
79
  # Ход — это всё, что записано после последнего настоящего ввода владельца. Ответ инструмента
73
80
  # приходит той же ролью, поэтому строки с `tool_result` вводом не считаются.
74
81
  #
75
82
  # Хвост в 400 строк: запись хода растёт всю сессию, а судится только последний ход.
76
83
  verdict="$(tail -n 400 "$transcript" 2>/dev/null | jq -s -r \
77
84
  --arg opened "$opened_re" --arg moved "$moved_re" --arg read "$read_re" --arg red "$red_re" \
78
- --arg ready "$ready_re" '
85
+ --arg ready "$ready_re" --arg vow "$vow_re" '
79
86
  def is_input:
80
87
  .type == "user"
81
88
  and (((.message.content // []) | if type == "array"
@@ -98,13 +105,69 @@ verdict="$(tail -n 400 "$transcript" 2>/dev/null | jq -s -r \
98
105
  | (($ran | test($read; "i")) and ($out | test($red; "i"))) as $red_run
99
106
  | ($ran | test($moved; "i")) as $went_on
100
107
  | ($ran | test($ready; "i")) as $checked
101
- | if $opened_pr and ($went_on | not) then "owe:pr"
108
+ | ([$turn[] | select(.type == "assistant") | (.message.content // [])[]
109
+ | select(.type == "text") | .text] | join("\n")) as $said
110
+ | ($said | test($vow; "i")) as $announced
111
+ | ($uses | length) as $tools
112
+ | if $opened_pr and ($went_on | not) and ($checked | not) then "owe:both"
113
+ elif $opened_pr and ($went_on | not) then "owe:pr"
102
114
  elif $opened_pr and ($checked | not) then "owe:draft"
103
115
  elif $went_on then "pass"
104
116
  elif $red_run then "owe:run"
117
+ elif $announced and $tools == 0 then "owe:vow"
105
118
  else "pass" end
106
119
  ' 2>/dev/null)"
107
120
 
121
+ # Ни одного из двух действий: отказ называет оба разом. Прежде он называл только первое, и
122
+ # снимался тоже первым — исполнитель брал следующую задачу, признак открытой заявки уходил
123
+ # вместе с ходом, и второе требование испарялось, ни разу не прозвучав.
124
+ if [ "$verdict" = "owe:both" ]; then
125
+ reason="BLOCKED by waiting-turn-guard: в этом ходе открыт PR, а по отданной работе не сделано ни одного из двух действий — ни состояние её не спрошено, ни следующая задача не взята.
126
+
127
+ Действий именно два, и снять отказ одним нельзя: отданное доводится до снятого черновика тем, кто его отдал, а следующая берётся сверх этого, а не вместо. Взятая следующая уносит признак открытой заявки с собой — второе требование после неё не прозвучит уже никогда.
128
+
129
+ gh run list # состояние отданного
130
+ npm run task:new -- <заголовок> # следующая работа
131
+
132
+ Гард судит один ход: следующий заход не отбивается."
133
+
134
+ # shellcheck disable=SC1090
135
+ [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
136
+ && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
137
+ command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
138
+ deny_tail_text="$(rt_deny_tail "")"
139
+ [ -n "$deny_tail_text" ] && reason="${reason}
140
+
141
+ ${deny_tail_text}"
142
+
143
+ jq -n --arg r "$reason" '{decision:"block",reason:$r}' 2>/dev/null \
144
+ || printf '{"decision":"block","reason":"waiting-turn-guard: по отданной работе не сделано ни одного из двух действий."}\n'
145
+ exit 0
146
+ fi
147
+
148
+ # Ход, объявивший своё следующее действие и не сделавший по нему ничего. Отказ называет само
149
+ # объявление: исполнитель, которому сказано «ход пуст», перепишет слова, а не сделает шаг.
150
+ if [ "$verdict" = "owe:vow" ]; then
151
+ reason="BLOCKED by waiting-turn-guard: за ход не сделано ни одной правки и не позвана ни одна команда, а следующее действие названо словами — «дальше беру», «следующим шагом», «затем сделаю».
152
+
153
+ Объявление своего же шага работой не бывает: оно точнее всякого обещания и пустоты за ним не видно никому. Сделай названное этим же ходом — либо скажи владельцу, что работу останавливает, и назови, чем именно.
154
+
155
+ Гард судит один ход: следующий заход не отбивается."
156
+
157
+ # shellcheck disable=SC1090
158
+ [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
159
+ && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
160
+ command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
161
+ deny_tail_text="$(rt_deny_tail "")"
162
+ [ -n "$deny_tail_text" ] && reason="${reason}
163
+
164
+ ${deny_tail_text}"
165
+
166
+ jq -n --arg r "$reason" '{decision:"block",reason:$r}' 2>/dev/null \
167
+ || printf '{"decision":"block","reason":"waiting-turn-guard: следующее действие названо словами, а за ход не сделано ничего."}\n'
168
+ exit 0
169
+ fi
170
+
108
171
  # Черновик, оставленный при взятой следующей задаче, — отдельный отказ: там требование не про
109
172
  # следующую задачу, а про доведение отданной.
110
173
  if [ "$verdict" = "owe:draft" ]; then