@rt-tools/agent-kit 0.13.0 → 0.14.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.
@@ -85,3 +85,48 @@ export function headCommittedAt(sha, options) {
85
85
  const answer = gh(['api', `repos/${OWNER}/${REPO}/commits/${sha}`, '--jq', '.commit.committer.date'], options);
86
86
  return Date.parse(String(answer).trim());
87
87
  }
88
+
89
+ /**
90
+ * Прогоны вершины, вытесненные из очереди конвейера.
91
+ *
92
+ * Группа очереди бережёт идущий прогон и не бережёт ждущего: хостинг держит в группе один
93
+ * ждущий, и следующий встающий вытесняет прежний. Вытесненный завершается отменой и в списке
94
+ * неотличим от упавшего, хотя ветку не проверял ни строчкой.
95
+ *
96
+ * Отличает их число заданий. Отмена — общее слово для двух случаев: у прогона, остановленного
97
+ * на ходу, задания есть и журналы у них читаются; у вытесненного из очереди их ноль, потому что
98
+ * он не начинался. Замером по семи отменённым прогонам дерева: шесть с нулём заданий и один
99
+ * остановленный на ходу с одним.
100
+ *
101
+ * Число заданий спрашивается отдельным вызовом и только у отменённых: спрошенное у каждого
102
+ * прогона стоило бы вызова на прогон при каждой сверке.
103
+ *
104
+ * Зелёный прогон на той же вершине снимает ответ целиком — вытесненный за ним уже перезапущен,
105
+ * и говорить о нём нечего.
106
+ */
107
+ export function evictedOnHead(sha, options) {
108
+ const answer = gh(
109
+ [
110
+ 'api',
111
+ `repos/${OWNER}/${REPO}/actions/runs?head_sha=${sha}&per_page=20`,
112
+ '--jq',
113
+ '[.workflow_runs[] | {id, status, conclusion}] | tojson',
114
+ ],
115
+ options
116
+ );
117
+ const runs = JSON.parse(String(answer).trim() || '[]');
118
+ if (runs.some((run) => run.status === 'completed' && run.conclusion === 'success')) {
119
+ return [];
120
+ }
121
+
122
+ return runs
123
+ .filter((run) => run.status === 'completed' && run.conclusion === 'cancelled')
124
+ .filter((run) => jobCount(run.id, options) === 0)
125
+ .map((run) => run.id);
126
+ }
127
+
128
+ /** Сколько заданий завелось у прогона. Ноль означает, что он не начинался вовсе. */
129
+ function jobCount(id, options) {
130
+ const answer = gh(['api', `repos/${OWNER}/${REPO}/actions/runs/${id}/jobs?per_page=1`, '--jq', '.total_count'], options);
131
+ return Number(String(answer).trim());
132
+ }
@@ -19,7 +19,7 @@
19
19
  * функции возвращают `null`, командный режим печатает `{"offline":true}`.
20
20
  */
21
21
  import { execFileSync } from 'node:child_process';
22
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
22
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
23
23
  import { homedir } from 'node:os';
24
24
  import { join } from 'node:path';
25
25
 
@@ -287,6 +287,48 @@ export function numberFromTaskDir(name) {
287
287
  * Вглубь спускаемся ровно на один уровень: в имени ветки одна косая, а всё, что глубже, папкой
288
288
  * задачи уже не будет — зато туда попал бы архив, если дерево держит его внутри.
289
289
  */
290
+ /** Шапка раскладки: по ней разложенную копию узнаёт и гард места правки. */
291
+ const STAMP = /^<!-- rt-kit v[^\n]*-->\n/m;
292
+
293
+ /**
294
+ * Снять с копий образца шапку раскладки.
295
+ *
296
+ * Образец разложен пакетом и шапку несёт по праву: его кладёт и обновляет раскладка. Копия под
297
+ * задачу — уже текст проекта, тем же доводом, каким пакет кладёт без шапки черновик компаньона:
298
+ * с первой правки сверять в ней нечего.
299
+ *
300
+ * Оставленная в копии, шапка отбивает первую же правку разбора просьбы — то есть первое движение
301
+ * любой работы, — и отказ уводит править образец пакета вместо копии под задачу. Снималась она
302
+ * тремя строками руками, каждой работой заново.
303
+ *
304
+ * Возвращает имена файлов, с которых шапка снята: по ним сценарий и судит, что снятие работает,
305
+ * а вызывающий — что папка собрана.
306
+ */
307
+ export function unstampFolder(folder) {
308
+ const cleaned = [];
309
+
310
+ if (!existsSync(folder)) {
311
+ return cleaned;
312
+ }
313
+
314
+ for (const name of readdirSync(folder)) {
315
+ if (!name.endsWith('.md')) {
316
+ continue;
317
+ }
318
+
319
+ const path = join(folder, name);
320
+ const before = readFileSync(path, 'utf8');
321
+ const after = before.replace(STAMP, '');
322
+
323
+ if (after !== before) {
324
+ writeFileSync(path, after);
325
+ cleaned.push(name);
326
+ }
327
+ }
328
+
329
+ return cleaned;
330
+ }
331
+
290
332
  export function taskDirs(dir = join(ROOT, CONFIG.tasksDir), prefix = '') {
291
333
  if (!existsSync(dir)) {
292
334
  return [];
@@ -47,7 +47,7 @@ import {
47
47
  numberFromTitle,
48
48
  taskDirs,
49
49
  } from './board.mjs';
50
- import { deployLag, headCommittedAt, runsOnHead, verdictOnHead } from './board-runs.mjs';
50
+ import { deployLag, evictedOnHead, headCommittedAt, runsOnHead, verdictOnHead } from './board-runs.mjs';
51
51
  import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
52
52
 
53
53
  const IN_REVIEW = STATUS_OPTIONS[IN_REVIEW_STATUS].name;
@@ -140,6 +140,10 @@ function folderInBranch(branch, options) {
140
140
  * промежутке значила бы «подожди», а не «чини».
141
141
  */
142
142
  function checkHeadRun(pull, options) {
143
+ if (checkEvicted(pull, options)) {
144
+ return;
145
+ }
146
+
143
147
  if (runsOnHead(pull.headRefOid, options) > 0) {
144
148
  checkReadyDraft(pull, options);
145
149
  return;
@@ -157,6 +161,36 @@ function checkHeadRun(pull, options) {
157
161
  );
158
162
  }
159
163
 
164
+ /**
165
+ * Прогон вершины, вытесненный из очереди конвейера.
166
+ *
167
+ * Группа очереди бережёт идущий прогон и не бережёт ждущего: хостинг держит в группе один
168
+ * ждущий, и следующий встающий вытесняет прежний. Ветка за таким прогоном не проверялась ни
169
+ * строчкой, а по очереди работ выглядит проверенной — прогон на вершине есть, и сверка считает
170
+ * именно факт.
171
+ *
172
+ * Судится раньше отсутствия прогона и раньше цвета: иначе одна вершина получает две строки об
173
+ * одном. Отвечает `true`, когда строка сказана, и остальные проверки вершины пропускаются.
174
+ *
175
+ * Строка называет обе команды и в том порядке, в каком их зовут. Перезапуск отбивает гард, пока
176
+ * за тот же ход не читался журнал этого задания, и порядок в строке выполняет требование сам:
177
+ * исполнитель зовёт написанное и не упирается в отказ на втором шаге.
178
+ */
179
+ function checkEvicted(pull, options) {
180
+ const evicted = evictedOnHead(pull.headRefOid, options);
181
+ if (evicted.length === 0) {
182
+ return false;
183
+ }
184
+
185
+ const run = evicted[0];
186
+ report(
187
+ `PR #${pull.number}: прогон ${run} на вершине ${pull.headRefOid.slice(0, 8)} вытеснен из очереди конвейера — ` +
188
+ `заданий у него ноль, ветка не проверялась, а в списке он выглядит упавшим; ` +
189
+ `прочитай прогон и перезапусти его (gh run view ${run} && gh run rerun ${run})`
190
+ );
191
+ return true;
192
+ }
193
+
160
194
  /**
161
195
  * Готовая работа, оставленная черновиком.
162
196
  *
@@ -17,7 +17,7 @@
17
17
  * Тело читается со стандартного ввода. Автор и исполнитель — учётная запись бота,
18
18
  * та же, от которой идут коммиты; `--assignee` перекрывает исполнителя.
19
19
  */
20
- import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
20
+ import { cpSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
21
21
  import { dirname, join, resolve } from 'node:path';
22
22
  import { fileURLToPath } from 'node:url';
23
23
 
@@ -38,6 +38,7 @@ import {
38
38
  graphql,
39
39
  numberFromTitle,
40
40
  taskState,
41
+ unstampFolder,
41
42
  } from './board.mjs';
42
43
 
43
44
  function parseArgs(argv) {
@@ -161,6 +162,7 @@ const branch = args.slug ? `${TASK_KEY}-${number}-${args.slug}` : `${TASK_KEY}-$
161
162
  * `docs/tasks/_draft-<slug>`. Оставленный черновиком, он остаётся вне истории, а следующий
162
163
  * заход его не находит: хук запуска ищет папку по имени ветки.
163
164
  */
165
+
164
166
  function adoptDraft() {
165
167
  if (!args.slug) {
166
168
  console.log(`\nПапка задачи: --slug не задан, переименовать черновик нечем.`);
@@ -174,10 +176,20 @@ function adoptDraft() {
174
176
  console.log(`\nПапка задачи уже на месте: docs/tasks/${branch}/`);
175
177
  } else if (existsSync(draft)) {
176
178
  renameSync(draft, target);
179
+ // Черновик тоже собирают с образца, и шапка в нём та же: снимается она и здесь.
180
+ unstampFolder(target);
177
181
  console.log(`\nПапка задачи: docs/tasks/_draft-${args.slug}/ → docs/tasks/${branch}/`);
178
182
  } else {
179
- console.log(`\nПапка задачи собирается с образца:\n cp -r docs/tasks/_template docs/tasks/${branch}`);
180
- return;
183
+ const template = join(root, 'docs/tasks/_template');
184
+
185
+ if (!existsSync(template)) {
186
+ console.log(`\nПапки задачи нет, и собрать её не с чего: образца ${'docs/tasks/_template'} в дереве не лежит`);
187
+ return;
188
+ }
189
+
190
+ cpSync(template, target, { recursive: true });
191
+ unstampFolder(target);
192
+ console.log(`\nПапка задачи собрана с образца: docs/tasks/${branch}/`);
181
193
  }
182
194
 
183
195
  // Шапку замысла читает гард: по ней он находит договорённость о продукте. Номер в ней
@@ -41,6 +41,36 @@ transcript="$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/nu
41
41
  [ -z "$transcript" ] && exit 0
42
42
  [ -f "$transcript" ] || exit 0
43
43
 
44
+ # Текст ответа ложится в запись хода не раньше, чем хост позовёт хук: гард, прочитавший файл
45
+ # первым, судит ход, у которого сказанного нет вовсе, — и молчит, будучи неотличим от гарда,
46
+ # который посмотрел и пропустил. Ждём его появления, и не дождавшись — возвращаем ход: пустая
47
+ # запись означает не «владельцу ничего не сказано», а «прочитать нечего».
48
+ #
49
+ # Отказ этот принадлежит одному гарду нарочно. Текст судят трое, и печатай они свои объекты
50
+ # подряд, вывод перестал бы разбираться целиком — то есть отбой пропал бы весь.
51
+ if ! rt_turn_has_text "$transcript"; then
52
+ reason="BLOCKED by claim-guard: запись хода не отдала ни одного текста ответа, и судить сказанное владельцу нечем.
53
+
54
+ Текст ложится в запись не раньше, чем хост зовёт хук. Прочитанная слишком рано запись выглядит ходом, в котором владельцу ничего не сказано, — и все гарды, судящие сказанное, проходят мимо молча.
55
+
56
+ Повтори завершение хода: к этой минуте текст в записи уже есть. Ход при этом ничего не теряет — сказанное владельцу остаётся тем же.
57
+
58
+ Гард судит один ход: следующий заход не отбивается."
59
+
60
+ # shellcheck disable=SC1090
61
+ [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
62
+ && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
63
+ command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
64
+ deny_tail_text="$(rt_deny_tail "")"
65
+ [ -n "$deny_tail_text" ] && reason="${reason}
66
+
67
+ ${deny_tail_text}"
68
+
69
+ jq -n --arg r "$reason" '{decision:"block",reason:$r}' 2>/dev/null \
70
+ || printf '{"decision":"block","reason":"claim-guard: запись хода не отдала текста ответа — повтори завершение хода."}\n'
71
+ exit 0
72
+ fi
73
+
44
74
  # Ход — всё, что записано после последней настоящей реплики владельца: ответ инструмента
45
75
  # приходит той же ролью и репликой не считается.
46
76
  turn="$(tail -n 400 "$transcript" 2>/dev/null | jq -s -r '
@@ -76,6 +106,10 @@ claims=(
76
106
  '(ветки|ветка|файлы|файл|папка|каталог)[^.]{0,40}(снят|удал|почищ|вычищ)|снят[оыа] с§git branch|git push .*--delete|git rm|gh api|rm §команду удаления — `git branch -d`, `git push --delete` или `git rm`'
77
107
  'прогон (зелёный|прошёл|кончился)|конвейер зелёный|проверки на PR зелёные§gh run§`gh run list` или `gh run view`'
78
108
  '(работа|правка|задача) готова|можно вливать|PR открыт|черновик снят§gh pr §`gh pr create`, `gh pr view` или `gh pr ready`'
109
+ # Ожидание чужого шага — тоже утверждение о состоянии, и врать ему есть чем: прогон бывает
110
+ # зелёным час, бывает не встав вовсе. Сказанное без команды оставляет готовую работу
111
+ # черновиком, и владелец узнаёт об этом последним — дважды за сутки так и вышло.
112
+ 'жд[уёя][^.]{0,20}прогон|дожида[ею][^.]{0,20}прогон|прогон[^.]{0,20}(ещё идёт|не встал|не кончился|не дошёл)|черновик[^.]{0,30}(не снимаю|сниму|снимется)§gh run|gh pr checks|check-runs|check:board|board\.mjs§команду о прогоне — `gh run list`, `gh pr checks` или сверку очереди работ'
79
113
  'задача заведена|задача (в|переведена в) колонк|колонка переведена§gh issue|gh api|task:new|task:move|board\.mjs§команду очереди работ — заведение задачи или перевод колонки'
80
114
  '(в дереве|в репозитории|здесь|такого файла|такой команды)[^.]{0,30}(нет|не бывает)|не заводили|нигде не встречается§grep|rg |ls |find |git ls-files|git grep|git log|cat §команду поиска — `grep`, `git ls-files` или обход каталога'
81
115
  )
@@ -46,24 +46,57 @@ export RT_HOOK_INPUT="$input"
46
46
  branches="$(grep -l '^# rt-hook:' "$here"/*.sh 2>/dev/null | sort)"
47
47
  [ -z "$branches" ] && exit 0
48
48
 
49
+ # Объявлений у файла бывает несколько: гард, стоящий и на вызове инструмента, и на завершении
50
+ # хода, называет оба события своими строками. Читалось прежде только первое — и вторая ветка не
51
+ # звалась ни разу, молча: снаружи это неотличимо от гарда, который посмотрел и пропустил.
52
+ collected=""
49
53
  for branch in $branches; do
50
- declaration="$(sed -n 's/^# rt-hook:[[:space:]]*//p' "$branch" 2>/dev/null | head -1)"
51
- [ -z "$declaration" ] && continue
52
-
53
- branch_event="${declaration%% *}"
54
- [ "$branch_event" = "$event" ] || continue
55
-
56
- # Образец вызова: его нет вовсе — гард зовётся на любом; есть — сверяется с именем
57
- # инструмента целиком, а не куском. Звёздочка и точка со звёздочкой значат одно: любой вызов.
58
- matcher="${declaration#"$branch_event"}"
59
- matcher="${matcher#"${matcher%%[![:space:]]*}"}"
60
- if [ -n "$matcher" ] && [ "$matcher" != '*' ] && [ "$matcher" != '.*' ]; then
61
- [[ "${RT_HOOK_TOOL:-}" =~ ^(${matcher})$ ]] || continue
62
- fi
54
+ matched=0
55
+ while IFS= read -r declaration; do
56
+ [ -z "$declaration" ] && continue
57
+
58
+ branch_event="${declaration%% *}"
59
+ [ "$branch_event" = "$event" ] || continue
60
+
61
+ # Образец вызова: его нет вовсе гард зовётся на любом; есть сверяется с именем
62
+ # инструмента целиком, а не куском. Звёздочка и точка со звёздочкой значат одно: любой
63
+ # вызов.
64
+ matcher="${declaration#"$branch_event"}"
65
+ matcher="${matcher#"${matcher%%[![:space:]]*}"}"
66
+ if [ -n "$matcher" ] && [ "$matcher" != '*' ] && [ "$matcher" != '.*' ]; then
67
+ [[ "${RT_HOOK_TOOL:-}" =~ ^(${matcher})$ ]] || continue
68
+ fi
69
+
70
+ matched=1
71
+ break
72
+ done <<EOF
73
+ $(sed -n 's/^# rt-hook:[[:space:]]*//p' "$branch" 2>/dev/null)
74
+ EOF
75
+
76
+ # Совпало хоть одно объявление — ветка зовётся один раз. Два объявления одного события в
77
+ # одном файле звали бы гард дважды на один ввод, и второй вызов судил бы то же самое.
78
+ [ "$matched" = 1 ] || continue
63
79
 
64
- printf '%s' "$input" | bash "$branch"
80
+ branch_out="$(printf '%s' "$input" | bash "$branch" 2>/dev/null)"
65
81
  code=$?
66
- [ "$code" -ne 0 ] && exit "$code"
82
+ if [ "$code" -ne 0 ]; then
83
+ [ -n "$branch_out" ] && printf '%s\n' "$branch_out"
84
+ exit "$code"
85
+ fi
86
+
87
+ # Отбой ветки приходит не кодом возврата, а решением в выводе: гарды завершения хода
88
+ # печатают его и выходят нулём. Не остановившись здесь, диспетчер склеил бы этот объект с
89
+ # выводом следующей ветки — а склеенное не разбирается, и отбой пропадает целиком.
90
+ if [ -n "$branch_out" ] && printf '%s' "$branch_out" | jq -e '.decision == "block"' >/dev/null 2>&1; then
91
+ printf '%s\n' "$branch_out"
92
+ exit 0
93
+ fi
94
+
95
+ [ -n "$branch_out" ] && collected="${collected}${branch_out}
96
+ "
67
97
  done
68
98
 
99
+ # Ни одна ветка не отбила: отдаётся то, что они напечатали, — подсказки и сводки.
100
+ [ -n "${collected:-}" ] && printf '%s' "$collected"
101
+
69
102
  exit 0
@@ -75,3 +75,42 @@ rt_hook_tool() { rt_hook_field RT_HOOK_TOOL '.tool_name'; }
75
75
  rt_hook_cmd() { rt_hook_field RT_HOOK_CMD '.tool_input.command'; }
76
76
  rt_hook_file() { rt_hook_field RT_HOOK_FILE '.tool_input.file_path'; }
77
77
  rt_hook_cwd() { rt_hook_field RT_HOOK_CWD '.cwd'; }
78
+
79
+ # ЖДЁТ ПОСЛЕДНИЙ ТЕКСТ ХОДА В ЗАПИСИ. Гарды завершения судят то, что сказано владельцу, а запись
80
+ # хода на этот момент бывает неполна: текст ответа ложится в файл не раньше, чем хост позовёт
81
+ # хук, и гард читает ход, у которого текста нет вовсе. Молчит он при этом честно — и снаружи
82
+ # неотличим от гарда, который посмотрел и пропустил. Ровно так ход, поставивший работу в
83
+ # зависимость от слова владельца, ушёл мимо трёх гардов сразу, а тот же ход, поданный им
84
+ # повторно, был отбит.
85
+ #
86
+ # Ждём короткими попытками: файл дописывается за миллисекунды, а ход и без того кончается не
87
+ # мгновенно. Дождались — ноль; текста так и нет — единица, и решает уже гард.
88
+ rt_turn_has_text() {
89
+ local transcript="$1" tries="${2:-20}" got
90
+
91
+ [ -n "$transcript" ] && [ -f "$transcript" ] || return 1
92
+ command -v jq >/dev/null 2>&1 || return 1
93
+
94
+ while [ "$tries" -gt 0 ]; do
95
+ got="$(tail -n 400 "$transcript" 2>/dev/null | jq -s -r '
96
+ def is_input:
97
+ .type == "user"
98
+ and (((.message.content // []) | if type == "array"
99
+ then ([.[] | select(.type == "tool_result")] | length)
100
+ else 0 end) == 0);
101
+
102
+ (map(is_input) | rindex(true)) as $i
103
+ | (if $i == null then . else .[$i + 1:] end)
104
+ | [.[] | select(.type == "assistant") | (.message.content // [])[]
105
+ | select(.type == "text") | .text]
106
+ | length
107
+ ' 2>/dev/null)"
108
+
109
+ [ -n "$got" ] && [ "$got" != '0' ] && return 0
110
+
111
+ tries=$((tries - 1))
112
+ [ "$tries" -gt 0 ] && sleep 0.05
113
+ done
114
+
115
+ return 1
116
+ }
@@ -76,8 +76,14 @@ esac
76
76
 
77
77
  # То же самое, но объявить это на диске уже нечем: папка задачи разбирается до открытия заявки,
78
78
  # и ход работы уезжает вместе с ней. Признак берётся из истории ветки — папка, снятая её
79
- # коммитом. Без этой ветки хвост работы судился бы вторым признаком, то есть отбивался бы за
80
- # ход, в котором исполнитель ждёт чужого прогона и ничего в дереве не двигает.
79
+ # коммитом.
80
+ #
81
+ # Отпускать по нему ход нельзя: папка снимается ДО заявки, и между этими двумя движениями работа
82
+ # не отдана никому. Ход, попавший в зазор, прежде выходил отсюда нулём — а до отдачи ему
83
+ # оставался ровно один шаг, и он его не сделал. Признак поэтому только снимает требование
84
+ # состояния: дальше ход судится вторым признаком, как всякий другой. Ход, в котором заявку
85
+ # открыли или прочитали, второй признак пропускает сам — вызов клиента хостинга он считает
86
+ # работой.
81
87
  folder_archived() {
82
88
  [ -n "$branch" ] || return 1
83
89
  [ -n "$(git ls-tree -d --name-only HEAD -- "$tasks_dir/$branch" 2>/dev/null | head -1)" ] && return 1
@@ -89,7 +95,8 @@ folder_archived() {
89
95
  [ -n "$had" ]
90
96
  }
91
97
 
92
- [ -z "$progress" ] && folder_archived && exit 0
98
+ archived=false
99
+ [ -z "$progress" ] && folder_archived && archived=true
93
100
 
94
101
  # Следующий шаг из хода работы — его страж и называет в отказе: исполнитель, которому сказано
95
102
  # только «работа не кончена», перечитывает ту же строку сам.
@@ -194,7 +201,15 @@ fi
194
201
 
195
202
  [ "$worked" = "true" ] && exit 0
196
203
 
197
- if [ -z "$state" ]; then
204
+ if [ "$archived" = "true" ]; then
205
+ reason="BLOCKED by turn-exit-guard: папка задачи снята коммитом ветки, а за этот ход не сделано ничего — ни правки, ни команды, меняющей дерево.
206
+
207
+ Снятая папка означает середину отдачи, а не её конец: убирают её ДО того, как открыта заявка, и между этими двумя движениями работу не видит никто, кроме того, кто её сделал. Состояние работы с этой минуты объявить нечем, поэтому ход судится по второму признаку — была ли за него работа.
208
+
209
+ Оставшийся шаг один: открыть заявку черновиком. Заявка уже открыта — возьми следующую задачу: ожидание чужого прогона работой не является и ходом не кончается.
210
+
211
+ Страж судит один ход: следующий заход не отбивается."
212
+ elif [ -z "$state" ]; then
198
213
  reason="BLOCKED by turn-exit-guard: за этот ход не сделано ничего — ни правки, ни команды, меняющей дерево. Папки задачи у этой работы нет, и состояние взять неоткуда, но ход это не кончает.
199
214
 
200
215
  Ход кончается четырьмя способами, и других нет: вопрос владельцу, ответа на который в правилах нет; отказ гарда; заполненное окно захода; отданная работа с начатой следующей. Названная и не запущенная команда выходом не является: строка «сейчас запущу» — объявление намерения, а оно прямо названо ложным концом хода.
@@ -63,12 +63,19 @@ red_re='completed[[:space:]]+failure|"conclusion"[[:space:]]*:[[:space:]]*"failu
63
63
  # по следующей задаче не считается — она чинит прежнюю, а не двигает работу дальше.
64
64
  moved_re='task:new|task:move|checkout[[:space:]]+-b|docs/tasks/'
65
65
 
66
+ # Чем ход показывает, что отданную работу он довёл до конца, а не бросил черновиком. Снятие
67
+ # черновика — очевидный случай; чтение прогона — тот, где снимать ещё нечего, но исполнитель
68
+ # посмотрел, а не сказал «жду». Две готовые заявки простояли черновиками именно потому, что
69
+ # следующая задача была взята вместо этого, а не сверх этого.
70
+ ready_re='pr[[:space:]]+ready|run[[:space:]]+(list|view|watch)|pr[[:space:]]+checks|check-runs|check:board|board\.mjs'
71
+
66
72
  # Ход — это всё, что записано после последнего настоящего ввода владельца. Ответ инструмента
67
73
  # приходит той же ролью, поэтому строки с `tool_result` вводом не считаются.
68
74
  #
69
75
  # Хвост в 400 строк: запись хода растёт всю сессию, а судится только последний ход.
70
76
  verdict="$(tail -n 400 "$transcript" 2>/dev/null | jq -s -r \
71
- --arg opened "$opened_re" --arg moved "$moved_re" --arg read "$read_re" --arg red "$red_re" '
77
+ --arg opened "$opened_re" --arg moved "$moved_re" --arg read "$read_re" --arg red "$red_re" \
78
+ --arg ready "$ready_re" '
72
79
  def is_input:
73
80
  .type == "user"
74
81
  and (((.message.content // []) | if type == "array"
@@ -90,12 +97,41 @@ verdict="$(tail -n 400 "$transcript" 2>/dev/null | jq -s -r \
90
97
  | ($ran | test($opened; "i")) as $opened_pr
91
98
  | (($ran | test($read; "i")) and ($out | test($red; "i"))) as $red_run
92
99
  | ($ran | test($moved; "i")) as $went_on
93
- | if $went_on then "pass"
94
- elif $opened_pr then "owe:pr"
100
+ | ($ran | test($ready; "i")) as $checked
101
+ | if $opened_pr and ($went_on | not) then "owe:pr"
102
+ elif $opened_pr and ($checked | not) then "owe:draft"
103
+ elif $went_on then "pass"
95
104
  elif $red_run then "owe:run"
96
105
  else "pass" end
97
106
  ' 2>/dev/null)"
98
107
 
108
+ # Черновик, оставленный при взятой следующей задаче, — отдельный отказ: там требование не про
109
+ # следующую задачу, а про доведение отданной.
110
+ if [ "$verdict" = "owe:draft" ]; then
111
+ reason="BLOCKED by waiting-turn-guard: в этом ходе открыт PR, следующая задача взята, а состояние отданной работы не спрошено ни одной командой.
112
+
113
+ Черновик читается владельцем как «работа не кончена»: кнопка слияния у него заблокирована самим хостингом, и по списку заявок готовое от недоделанного не отличить — серое и там и там. Довести отданное до снятого черновика обязан тот, кто его отдал.
114
+
115
+ Спроси прогон на вершине этим же ходом — `gh run list`, `gh pr checks` или сверку очереди работ — и сними черновик, когда он зелёный, а ветка сливается. Прогон ещё идёт — так и скажи владельцу, назвав его вывод.
116
+
117
+ Следующая задача берётся сверх этого, а не вместо: обе готовые заявки простояли черновиками ровно на такой подмене.
118
+
119
+ Гард судит один ход: следующий заход не отбивается."
120
+
121
+ # shellcheck disable=SC1090
122
+ [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" ] \
123
+ && . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deny-tail.sh" 2>/dev/null
124
+ command -v rt_deny_tail >/dev/null 2>&1 || rt_deny_tail() { :; }
125
+ deny_tail_text="$(rt_deny_tail "")"
126
+ [ -n "$deny_tail_text" ] && reason="${reason}
127
+
128
+ ${deny_tail_text}"
129
+
130
+ jq -n --arg r "$reason" '{decision:"block",reason:$r}' 2>/dev/null \
131
+ || printf '{"decision":"block","reason":"waiting-turn-guard: отданная работа осталась черновиком — спроси прогон и сними черновик."}\n'
132
+ exit 0
133
+ fi
134
+
99
135
  case "$verdict" in
100
136
  owe:pr) said="в этом ходе открыт PR" ;;
101
137
  owe:run) said="в этом ходе прочитан красный прогон" ;;
@@ -76,3 +76,11 @@
76
76
  входят: свежее дерево выглядит готовым и упирается в нехватку не сразу, а на первом гарде,
77
77
  которому нужен ключ. Что именно переносится руками, названо списком в компаньоне правила, и
78
78
  список пополняется тем же движением, которым заводится новый файл вне индекса.
79
+ - **Вытесненный из очереди прогон и упавший в списке неразличимы.** Оба отвечают красным, и
80
+ слово отмены у них одно. Ветка за вытесненным не проверялась ни строчкой: заданий у него
81
+ ноль, потому что он не начинался. Перезапуск такого прогона отбивает гард, пока за тот же ход
82
+ не читался журнал задания, — поэтому строка сверки называет чтение прогона первым.
83
+ - **Одно и то же вливание главной ветки, сделанное третий раз за заход, останавливает работу.**
84
+ Ветки, дописывающие строку в один и тот же список, роняют друг друга в конфликт при каждом
85
+ слиянии, и вливание главной по кругу — это починка проявления. На третьем круге называется
86
+ причина и спрашивается владелец, а не делается четвёртый круг.
@@ -139,6 +139,10 @@ flowchart TD
139
139
  которой прогон не встал, замечали только тем, что открывали список прогонов руками. Сверка
140
140
  спрашивает вершину, а не ветку, считает сам факт прогона, а не его цвет, и свежей вершине
141
141
  даёт время: между пушем и прогоном проходят минуты.
142
+ - **Прогон, вытесненный из очереди конвейера, сверка называет отдельной строкой.** Ждущего
143
+ прогона группа очереди не бережёт: следующий встающий вытесняет прежний, и тот выглядит
144
+ упавшим, хотя ветку не проверял ни строчкой. Отличает их число заданий — у вытесненного оно
145
+ ноль; строка называет обе команды по порядку, сперва чтение прогона.
142
146
  - **Черновик при зелёном прогоне на вершине — расхождение сверки.** У черновика кнопка слияния
143
147
  заблокирована самим хостингом: зелёная страница PR владельцу ничего не разрешает, а список, в
144
148
  котором всё серое, читается как «работа не сделана». Гард снятия черновика сюда не достаёт —
@@ -218,10 +222,6 @@ flowchart TD
218
222
  прежнее состояние и читает его как «не сделано ничего», а сделанное лежит в рабочем дереве
219
223
  исполнителя, где его не видит никто. Ветка, в которой разрешён конфликт, пушится тем же
220
224
  ходом — либо конфликт не разрешается вовсе.
221
- - **Одно и то же вливание главной ветки, сделанное третий раз за заход, останавливает
222
- работу.** Ветки, дописывающие строку в один и тот же список, роняют друг друга в конфликт при
223
- каждом слиянии, и вливание главной по кругу — это починка проявления. На третьем круге
224
- называется причина и спрашивается владелец, а не делается четвёртый круг.
225
225
  - **Черновик не снимается, пока у PR нет разбора.** Гард поставки смотрит запрошенного ревьювера
226
226
  и оставленный отзыв: снятый черновик читается как «можно вливать», а вливать некому. Раньше
227
227
  этого хода спросить негде — до открытия PR ревьювера нет вовсе. Судится и вызов без номера:
@@ -176,7 +176,9 @@ flowchart TD
176
176
  целиком, поэтому «не читал» основанием не бывает.
177
177
  - **Папка задачи заводится черновиком и получает номер командой.** До конца разбора
178
178
  неизвестно, сколько задач из него выйдет, поэтому номер не может быть первым;
179
- `npm run task:new` переименовывает черновик и проставляет шапку замысла.
179
+ `npm run task:new` переименовывает черновик и проставляет шапку замысла. Папку он и собирает —
180
+ с образца, снимая с копий шапку раскладки: оставленная в копии, она отбивает первую же правку
181
+ разбора просьбы, а отказ уводит править образец пакета вместо копии под задачу.
180
182
  - **Брошенный разбор виден.** Черновик старше недели перечисляет сверка очереди работ —
181
183
  задачи за ним ещё нет, и спросить о нём некого.
182
184
  - **Замысел эпика лежит там, где его найдут без сети и после мержа.** Карточка в очереди работ
@@ -101,10 +101,13 @@ flowchart TD
101
101
  работы и то, что за ход по ней сделано: правку файла или команду, меняющую дерево. Ход, в
102
102
  котором не было ни того ни другого, возвращается исполнителю вместе со следующим шагом из
103
103
  хода работы. Отданную и влитую работу страж не судит: она уже дождалась чужого шага.
104
- - **Отданную работу страж узнаёт по истории ветки, когда объявить её на диске уже нечем.** Ход
105
- работы уезжает вместе с папкой задачи, а папка разбирается до открытия заявки: с этой минуты
106
- и до слияния строки состояния нет вовсе. Судимый вторым признаком, такой ход отбивался бы за
107
- ожидание чужого прогона за то самое, что работой не является и правильно им не считается.
104
+ - **Снятая папка задачи снимает требование состояния, а ход не кончает.** Ход работы уезжает
105
+ вместе с папкой, а папка разбирается до открытия заявки: с этой минуты и до слияния строки
106
+ состояния нет вовсе, и первый признак стражу взять неоткуда. Отпускать по этому признаку ход
107
+ нельзя снятая папка означает середину отдачи, а не её конец: между уборкой и заявкой работу
108
+ не видит никто, кроме того, кто её сделал. Дальше ход судится вторым признаком, как всякий
109
+ другой; ход, в котором заявку открыли или прочитали, второй признак пропускает сам. Прежде
110
+ страж выходил здесь молча — и ход, которому до отдачи оставался один шаг, закрывался пустым.
108
111
  - **Работа без ветки и без папки задачи судится тем же стражем по второму признаку.** Состояния
109
112
  у неё нет, и первый признак взять неоткуда, — но ход, в котором не было ни одной правки
110
113
  дерева, не кончается и здесь. Раньше страж отпускал такую работу молча, и защищена она была
@@ -129,6 +132,13 @@ flowchart TD
129
132
  годится: состояние дерева меняется, и вчерашний вывод о нынешнем молчит. Восемь разборов
130
133
  подряд пришлись на этот промах, и каждый раз в правило дописывалась ещё одна статья —
131
134
  держит его теперь машина.
135
+ - **Гард утверждения ждёт текст ответа, а не судит запись, какой застал.** Текст ложится в
136
+ запись хода не раньше, чем хост зовёт хук, и гард, прочитавший файл первым, видит ход, в
137
+ котором владельцу не сказано ничего. Молчит он при этом честно — и снаружи неотличим от гарда,
138
+ который посмотрел и пропустил: одним таким ходом мимо прошли сразу трое. Не дождавшись текста,
139
+ гард возвращает ход: пустая запись означает не «сказать было нечего», а «прочитать нечего».
140
+ Отказ этот принадлежит одному гарду: печатай своё решение все трое, вывод перестал бы
141
+ разбираться целиком.
132
142
  - **Слово-утверждение гард ловит, неверный вывод — нет.** Об образце, судимом по одному его
133
143
  файлу, и о пути, которым человек не пойдёт, судить нечем: там нет ни слова, ни команды, с
134
144
  которой сверять. Это известная граница гарда, и держат её статьи ниже, а не он.
@@ -140,6 +150,17 @@ flowchart TD
140
150
  и отбивал бы работу вместо промаха, а вывод команды о прогоне в записи хода уже лежит. Слова
141
151
  «беру следующую задачу» гард действием не считает — ровно потому, что их и произносят вместо
142
152
  неё.
153
+ - **Ход, отдавший работу, доводит её до снятого черновика.** Черновик читается владельцем как
154
+ «работа не кончена»: кнопка слияния у него заблокирована самим хостингом, а по списку заявок
155
+ готовое от недоделанного не отличить — серое и там и там. Следующая задача берётся сверх этого,
156
+ а не вместо: требование взять её исполняется буквально и оставляет отданное невидимым. Две
157
+ готовые заявки простояли так, пока владелец не вернул исполнителя сам. Стережёт это гард
158
+ ожидания: ход, открывший заявку, не кончается, пока состояние отданной работы не спрошено
159
+ командой того же хода.
160
+ - **«Жду прогона» — утверждение о чужом шаге, а не состояние работы.** Прогон бывает зелёным
161
+ час, а бывает не встав вовсе — и второе само не чинится. Слово это требует команды того же
162
+ хода, которая прогон показывает, и без неё не говорится: сказанное без команды владелец читает
163
+ как «работа ещё идёт» и ждёт напрасно. Держит это гард утверждения, а не память исполнителя.
143
164
  - **Конец прогона узнаётся возвратом фоновой команды, а не взглядом на страницу.** Ожидание,
144
165
  запущенное в фоне отдельным ходом, возвращает исполнителя к PR само; до тех пор ход занят
145
166
  следующей задачей. Взгляд на страницу этого не даёт: он либо повторяется вхолостую, либо не
@@ -168,6 +189,14 @@ flowchart TD
168
189
  читается как работа — тем полнее, чем аккуратнее он составлен: он пронумерован, в нём названы
169
190
  цифры, и именно поэтому пустота хода за ним не видна. Владельцу называется, что уже сделано и
170
191
  что осталось за его словом, — а не выбор из вариантов вместо и того и другого.
192
+ - **Признак необратимости берётся из списка, а не выводится доводом.** Список составлен тем, кто
193
+ обратимость уже взвесил: необратимое в нём осталось, обратимое из него снято. Довод «действие
194
+ уходит наружу и не откатывается» приходит в контекст всегда, а список — только когда его
195
+ прочитали, и применённый поверх списка довод отменяет список целиком. Отменяет молча: со
196
+ стороны это выглядит осторожностью, а не пропуском шага. Действия, которого в списке нет,
197
+ исполнитель на слово владельца не гейтит, даже если оно уходит наружу. Шаг, снятый из списка
198
+ разбором прошлого промаха, тем более: довод, которым его туда возвращают, уже разобран и
199
+ отклонён — и дважды подряд готовая работа вставала перед снятым шагом.
171
200
  - **Ход, в котором исполнитель признал промах, не заканчивается, пока записи о происшествии
172
201
  нет.** Отбивает гард происшествия — на завершении хода: к моменту признания промах уже
173
202
  случился, и ловить раньше нечего. Признание ловится набором образцов, а не пониманием смысла;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rt-tools/agent-kit",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Переносимый слой правил для агента: законы, хуки, проверки и агенты, раскладываемые в репозиторий одной командой",
5
5
  "author": "RT Team",
6
6
  "license": "Apache-2.0",
Binary file
Binary file