@taskless-app/swarm 0.6.24 → 0.6.25

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.
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env bash
2
+ # TASWM-355 — pre-merge gate: `gh pr merge` не проходит, пока по PR нет вердикта ревьюера.
3
+ #
4
+ # ЗАЧЕМ. Правило «при autoReview мержим только green+reviewed» жило текстом в уставе
5
+ # оркестратора, и 10.08 PR #704 уехал в main, пока ревьюер ещё стоял в паузе. Демон узнал
6
+ # о мерже постфактум. Ни одной машинной проверки на пути мержа не стояло.
7
+ #
8
+ # ПОЧЕМУ ХУК, А НЕ BRANCH PROTECTION (померено 13.08.2026 на самом репозитории):
9
+ # • репозиторий приватный на бесплатном плане — и branch protection, и rulesets отвечают
10
+ # 403 «Upgrade to GitHub Pro or make this repository public»: защиту нельзя включить;
11
+ # • все PR роя открыты аккаунтом владельца, и ревьюер ходит ТЕМ ЖЕ токеном, а GitHub
12
+ # запрещает апрувить свой PR — «required approving review» не выполнилось бы никогда.
13
+ # Мерж делает АГЕНТ (оркестратор/дрон) под Claude Code, которого поднимает сам рой, — значит
14
+ # точка мержа роем контролируется: PreToolUse видит команду до запуска и отвечает deny.
15
+ #
16
+ # ЧТО ДЕЛАЕТ. Ловит Bash-команды с `gh pr merge` (и прямой вызов merge-эндпоинта через
17
+ # `gh api`), достаёт ссылку/номер PR и спрашивает рой. Нельзя → deny с причиной. Решение
18
+ # считает не хук: политика — `computeMergeGate` в `@taskless/swarm-core`, здесь перехват.
19
+ #
20
+ # У КОГО СПРАШИВАЕТ (TASWM-500). Раньше — только у ЛОКАЛЬНОЙ машины, и оба края вилки были
21
+ # неверны: без живого демона гейт отказывал даже по PR с вынесенным approve, а без CLI роя
22
+ # разрешал ВСЁ, включая PR вообще без ревью («машина не роевая, гейту нечего проверять» —
23
+ # проверять есть что, спрашивать надо не здесь). Вердикты, прогоны всех машин и настройка
24
+ # `autoReview` проекта живут на ПРОДЕ. Поэтому:
25
+ # • есть `swarm` → `swarm merge-gate <pr>`: CLI идёт к живому демону (его ответ полнее —
26
+ # в нём ещё и очередь мержа, TASWM-472), а если демона нет — к проду;
27
+ # • `swarm` не установлен → идём к проду прямо отсюда (curl + токен из TASKLESS_TOKEN /
28
+ # ~/.taskless-swarm/agent.json / ~/.taskless-swarm/token, а если роя на машине нет вовсе —
29
+ # OAuth-токеном ПОДКЛЮЧЁННОГО MCP, TASWM-504: см. блок про креды claude ниже);
30
+ # • НЕ ОТВЕТИЛ НИКТО → ЗАПРЕТ. Гейт, который при своей поломке разрешает мерж, — это ровно
31
+ # инцидент, ради которого он написан. Как снять его человеку — см. $ESCAPE ниже;
32
+ # • у проекта выключено авторевью или PR не роя → отвечающий говорит «можно», хук молчит.
33
+ #
34
+ # ГДЕ ЖИВЁТ (TASWM-481). Этот файл — ВНУТРИ пакета роя (`packages/swarm-daemon/hooks/`) и едет
35
+ # с ним в npm (`files: ["hooks", …]`). Раньше он лежал в `.claude/hooks/` НАШЕГО репозитория и
36
+ # никому больше не доставался: у пользователя, поставившего рой, гейта не существовало вовсе,
37
+ # хотя канон оркестратора (инжектится через MCP каждому агенту) обещал перехват. Теперь его
38
+ # ставят: `swarm install` / `swarm install-hook` кладут копию в `~/.taskless-swarm/hooks/` и
39
+ # прописывают в `~/.claude/settings.json`, а дронам он приезжает флагом `--settings` спавна
40
+ # (юзерские настройки дрон не читает намеренно — `--setting-sources project`).
41
+
42
+ set -uo pipefail
43
+
44
+ input="$(cat)"
45
+
46
+ # JSON-строка причины отказа. Не экранируем спецсимволы, а ВЫЧИЩАЕМ их: причина — это
47
+ # человеческая проза, и ей достаточно одной строки без кавычек и слэшей, зато результат
48
+ # гарантированно валиден на любом sed.
49
+ #
50
+ # Почему так строго. Живой прогон 13.08 показал: сырой перевод строки внутри причины делает
51
+ # ответ хука невалидным JSON, харнесс его не разбирает — и отказ теряется МОЛЧА, то есть
52
+ # мерж проходит. `bash -n` этого не видит. Экранирование через `sed ':a;N;$!ba'` починило бы
53
+ # перевод строки, но это GNU-конструкция: на macOS (а дроны работают и там — см. droneHome)
54
+ # она молча даёт мусор, и гейт снова беззвучно превращается в «разрешаю».
55
+ json_str() {
56
+ printf '%s' "$1" | tr '\n\r\t' ' ' | sed "s/\\\\/ /g; s/\"/'/g; s/^/\"/; s/\$/\"/"
57
+ }
58
+ allow() { exit 0; }
59
+ deny() {
60
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\n' "$(json_str "$1")"
61
+ exit 0
62
+ }
63
+
64
+ # КАК СНЯТЬ ГЕЙТ — текст, который печатается в КАЖДОМ отказе. Раньше в нём советовали
65
+ # `SWARM_MERGE_GATE=off gh pr merge …`, и это было ВРАНЬЁ (TASWM-500): переменную хук читает
66
+ # из СВОЕГО окружения, а не из команды, которую перехватил, — префикс не делал ничего.
67
+ # Учить хук видеть префикс мы сознательно не стали: это однотокенный обход гейта прямо из-под
68
+ # агента, а гейт написан ровно против «агент смержил непроверенное» (PR #704) — текст отказа
69
+ # сам подсказывал бы дорогу в обход. Названы способы, которые ДЕЙСТВИТЕЛЬНО работают, и оба
70
+ # требуют человека за машиной, а не решения агента.
71
+ ESCAPE="Снять гейт может человек за машиной: смержить из СВОЕГО терминала (хук перехватывает только команды агента) либо перезапустить агента с SWARM_MERGE_GATE=off в окружении (SWARM_MERGE_GATE=off claude …)."
72
+
73
+ # Явное снятие гейта человеком (осознанный ручной мерж, лежащий демон) — переменная в
74
+ # окружении САМОГО процесса агента, ровно как обещает $ESCAPE.
75
+ [ "${SWARM_MERGE_GATE:-on}" = "off" ] && allow
76
+
77
+ # Только Bash; остальные тулы проходят мимо.
78
+ tool="$(printf '%s' "$input" | sed -n 's/.*"tool_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)"
79
+ [ "$tool" = "Bash" ] || allow
80
+
81
+ # Команда как есть. Значение поля берём РОВНО одно (ERE с учётом экранирования), а не
82
+ # «от первой кавычки до последней»: жадный sed утаскивал весь payload, и в текст отказа
83
+ # уезжал JSON целиком. Дальше \" и \\ разэкранируем — иначе не найдём ссылку.
84
+ cmd="$(printf '%s' "$input" | grep -oE '"command"[[:space:]]*:[[:space:]]*"([^"\\]|\\.)*"' | head -n1 |
85
+ sed 's/^"command"[[:space:]]*:[[:space:]]*"//; s/"$//')"
86
+ # Перевод строки схлопываем в `;`, а НЕ в пробел: в многострочной команде
87
+ # («cd repo» \n «gh pr merge …») перевод строки — такой же разделитель команд, и заменив
88
+ # его пробелом, мы бы перестали видеть `gh` в позиции команды. Это был бы обход гейта
89
+ # самой обычной формой записи, а не хитростью.
90
+ cmd="$(printf '%s' "$cmd" | sed 's/\\n/;/g; s/\\"/"/g; s/\\\\/\\/g')"
91
+ [ -n "$cmd" ] || allow
92
+
93
+ # Мержем считаем ВЫЗОВ `gh pr merge …` (и прямой PUT на …/pulls/N/merge через `gh api`), а
94
+ # не УПОМИНАНИЕ этих слов. Разница не теоретическая: первая же версия хука отказала в
95
+ # `git commit -m "…хук на gh pr merge…"` — гейт, ловящий подстроку, ловит и текст про себя.
96
+ # Поэтому требуем, чтобы `gh` стоял в позиции команды: начало строки или после
97
+ # разделителя (`;` `&&` `||` `|` `(` `{` перевод строки), опционально через env/sudo.
98
+ if ! printf '%s' "$cmd" | grep -qE '(^|[;&|(){]|[[:space:]]&&|[[:space:]]\|\|)[[:space:]]*(sudo[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*gh[[:space:]]+(pr[[:space:]]+merge\b|api\b.*/merge\b)'; then
99
+ allow
100
+ fi
101
+
102
+ # Ссылка или номер PR из команды: полный URL, «#778» или голое число после `merge`.
103
+ ref="$(printf '%s' "$cmd" | sed -n 's#.*\(https://github.com/[^ "'"'"']*/pull/[0-9]\{1,\}\).*#\1#p' | head -n1)"
104
+ [ -n "$ref" ] || ref="$(printf '%s' "$cmd" | sed -n 's#.*pulls/\([0-9]\{1,\}\)/merge.*#\1#p' | head -n1)"
105
+ [ -n "$ref" ] || ref="$(printf '%s' "$cmd" | sed -n 's/.*gh pr merge[[:space:]]\{1,\}#\{0,1\}\([0-9]\{1,\}\).*/\1/p' | head -n1)"
106
+ if [ -z "$ref" ]; then
107
+ # `gh pr merge` без аргумента мержит PR ТЕКУЩЕЙ ветки — спрашиваем gh, какой это PR.
108
+ ref="$(gh pr view --json url --jq .url 2>/dev/null)"
109
+ fi
110
+ [ -n "$ref" ] || deny "не смог определить, какой PR мержится ($cmd) — а без этого рой не может проверить, есть ли по нему вердикт ревьюера. Укажи PR явно: gh pr merge <url|номер>. $ESCAPE"
111
+
112
+ # ── КОГО СПРАШИВАТЬ ────────────────────────────────────────────────────────────────────
113
+ # Вопрос один, отвечающих двое (TASWM-500):
114
+ # • есть `swarm` → CLI: он сам сходит к живому демону, а если демона нет — к проду;
115
+ # • CLI нет вовсе → идём к проду ОТСЮДА. Раньше здесь стояло `command -v swarm || allow`
116
+ # («машина не роевая, гейту нечего проверять») — и на конфигурации «MCP здесь, рой на
117
+ # другом компе» гейт молча пропускал ЛЮБОЙ мерж, включая PR вообще без ревью. Проверять
118
+ # есть что: прогоны, вердикты и autoReview проекта лежат на проде.
119
+ # Никто не ответил → deny, как и было: гейт, разрешающий мерж при своей поломке, — это и есть
120
+ # инцидент, ради которого он написан.
121
+
122
+ API="${TASKLESS_API:-https://taskless.ru}"
123
+ # `${HOME:-…}`, а не `$HOME`: под `set -u` незаданный HOME (cron, systemd, чужой шелл)
124
+ # уронил бы весь хук ДО единого решения — то есть гейт бы просто не сработал.
125
+ HOME_DIR="${HOME:-/nonexistent}"
126
+ SWARM_STORE="${SWARM_HOME:-$HOME_DIR/.taskless-swarm}"
127
+
128
+ # ── ЧЕМ СПРАШИВАТЬ ПРОД, КОГДА РОЯ НА МАШИНЕ НЕТ ВОВСЕ (TASWM-504) ─────────────────────
129
+ # Третий случай, не разобранный в TASWM-481: хук на машине ЕСТЬ (он приехал проводкой из
130
+ # самого репозитория — `.claude/settings.json` закоммичен), а рой не установлен: ни CLI,
131
+ # ни демона, ни токена в `~/.taskless-swarm`. Спросить хук обязан, а спросить нечем — и
132
+ # fail-closed превращался в «мержить нельзя никогда» (24.08, PR #949: вердикт есть, CI
133
+ # зелёный, отказ). Обиднее всего, что вердикт лежал в двух шагах: агент в этот момент
134
+ # подключён к тому же проду по MCP и читает вердикты `swarm_list_runs`.
135
+ #
136
+ # Отсюда решение: дать хуку РОВНО ТОТ ЖЕ путь к проду, что у MCP. Ручка
137
+ # `GET /api/swarm/merge-gate` принимает Bearer двух видов (packages/api/src/auth/middleware.ts):
138
+ # токен аккаунта-агента роя И обычный OAuth access token — тот самый, которым ходит
139
+ # MCP-клиент. Claude Code хранит его на машине сам:
140
+ # • `${CLAUDE_CONFIG_DIR:-~/.claude}/.credentials.json` — компактный JSON, ветка
141
+ # `mcpOAuth."<имя>|<хэш>"` с полями `serverUrl` / `accessToken` / (иногда) `expiresAt`;
142
+ # • macOS — та же строка в связке ключей (`security find-generic-password -s
143
+ # "Claude Code-credentials" -w`), файла может не быть вовсе. Инцидент как раз на маке.
144
+ #
145
+ # ГРАНИЦА, которую нельзя переступать: берём токен ТОЛЬКО у записи, чей `serverUrl`
146
+ # совпадает с `$API` по origin (scheme+host+port). Иначе первая же машина с двумя MCP
147
+ # отправила бы чужой токен нашему проду (или наш — чужому, если кто-то подменит
148
+ # TASKLESS_API). Совпадение origin и есть здесь предохранитель.
149
+ #
150
+ # Чего НЕ делаем: не обновляем протухший токен по refresh_token. Это стор Claude Code, и
151
+ # ротация из-под хука рассинхронизировала бы его с самим агентом. Протух — говорим человеку
152
+ # «переподключи MCP», это одна команда, а не тихая стена.
153
+
154
+ # Сырые креды claude: файл → связка ключей macOS. Пусто/не прочитали → код 1.
155
+ claude_creds() {
156
+ cf="${CLAUDE_CONFIG_DIR:-$HOME_DIR/.claude}/.credentials.json"
157
+ if [ -r "$cf" ]; then cat "$cf"; return 0; fi
158
+ [ "$(uname -s 2>/dev/null)" = "Darwin" ] || return 1
159
+ command -v security >/dev/null 2>&1 || return 1
160
+ # Связку читаем ПОД СТОРОЖЕМ. `security` на элементе, чей ACL нас не знает, показывает
161
+ # модальное окно и ждёт человека — а мы внутри PreToolUse: агент завис бы молча. Пять
162
+ # секунд и продолжаем без токена (дальше честный отказ с причиной).
163
+ # Шаблон обязателен: у BSD-mktemp (это и есть macOS-путь) голый `mktemp` — ошибка usage.
164
+ kc="$(mktemp "${TMPDIR:-/tmp}/swarm-merge-gate.XXXXXX")" || return 1
165
+ security find-generic-password -s "Claude Code-credentials" -w >"$kc" 2>/dev/null &
166
+ kcpid=$!
167
+ ( sleep 5; kill -9 "$kcpid" 2>/dev/null ) >/dev/null 2>&1 &
168
+ kcguard=$!
169
+ wait "$kcpid" 2>/dev/null; kcrc=$?
170
+ kill "$kcguard" 2>/dev/null
171
+ [ "$kcrc" -eq 0 ] && [ -s "$kc" ] && cat "$kc"
172
+ rm -f "$kc"
173
+ [ "$kcrc" -eq 0 ]
174
+ }
175
+
176
+ # Токен MCP для origin `$1`. Печатает токен и выходит 0; 1 — записи нет; 3 — запись есть,
177
+ # но токен протух (это ДРУГОЙ диагноз: гейт не сломан, его надо переподключить).
178
+ #
179
+ # Разбор — на awk, а не «grep по всему файлу»: у записи есть вложенный `discoveryState`,
180
+ # а у файла — вторая ветка `claudeAiOauth` со СВОИМ `accessToken` (это токен подписки
181
+ # claude.ai, ему в наш прод ехать незачем). Поэтому идём по структуре: находим `mcpOAuth`,
182
+ # режем его на записи по скобкам с учётом строк и экранирования и смотрим поля записи.
183
+ mcp_token() {
184
+ claude_creds 2>/dev/null | awk -v want="$1" -v now="$(date +%s 2>/dev/null || echo 0)" '
185
+ function origin(u) {
186
+ # https://host:port/api/mcp → https://host:port
187
+ if (match(u, /^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\/]+/)) return tolower(substr(u, RSTART, RLENGTH))
188
+ return ""
189
+ }
190
+ function strfield(e, key, v) {
191
+ # Ведущая кавычка обязательна: без неё `"authorizationServerUrl"` совпал бы с serverUrl.
192
+ if (!match(e, "\"" key "\"[ \t]*:[ \t]*\"[^\"]*\"")) return ""
193
+ v = substr(e, RSTART, RLENGTH)
194
+ sub(/^"[^"]*"[ \t]*:[ \t]*"/, "", v)
195
+ sub(/"$/, "", v)
196
+ return v
197
+ }
198
+ function numfield(e, key, v) {
199
+ if (!match(e, "\"" key "\"[ \t]*:[ \t]*[0-9]+")) return ""
200
+ v = substr(e, RSTART, RLENGTH)
201
+ sub(/^.*:[ \t]*/, "", v)
202
+ return v
203
+ }
204
+ # `ttl`, а не `exp`: у mawk `exp` — встроенная функция, и параметр с таким именем
205
+ # роняет ВЕСЬ разбор синтаксической ошибкой (поймано на первом же прогоне).
206
+ function consider(e, url, tok, ttl) {
207
+ url = strfield(e, "serverUrl"); tok = strfield(e, "accessToken")
208
+ if (tok == "" || origin(url) != want) return
209
+ ttl = numfield(e, "expiresAt")
210
+ # expiresAt у claude в МИЛЛИСЕКУНДАХ. Запас в минуту: токен, живущий последние
211
+ # секунды, до ответа прода не доедет.
212
+ if (ttl != "" && now > 0 && (ttl + 0) / 1000 <= now + 60) { expired = 1; return }
213
+ print tok; found = 1
214
+ }
215
+ { s = s $0 }
216
+ END {
217
+ i = index(s, "\"mcpOAuth\"")
218
+ if (i == 0) exit 1
219
+ n = length(s)
220
+ while (i <= n && substr(s, i, 1) != "{") i++
221
+ for (; i <= n && !found; i++) {
222
+ c = substr(s, i, 1)
223
+ if (esc) { esc = 0; continue }
224
+ if (instr) {
225
+ if (c == "\\") esc = 1; else if (c == "\"") instr = 0
226
+ continue
227
+ }
228
+ if (c == "\"") { instr = 1; continue }
229
+ if (c == "{") { depth++; if (depth == 2) estart = i; continue }
230
+ if (c == "}") {
231
+ depth--
232
+ if (depth == 1 && estart) { consider(substr(s, estart, i - estart + 1)); estart = 0 }
233
+ if (depth == 0) break
234
+ }
235
+ }
236
+ if (found) exit 0
237
+ exit expired ? 3 : 1
238
+ }
239
+ '
240
+ }
241
+
242
+ # Чем ходить в прод. Порядок — тот же, что у CLI (см. taskless.ts resolveApiToken), плюс
243
+ # токен MCP последним: он не «токен роя», а личный токен человека, и берётся только когда
244
+ # роевого на машине нет вовсе. Печатает токен (код 0) либо возвращает 1 / 3 (см. mcp_token).
245
+ pick_token() {
246
+ t="${TASKLESS_TOKEN:-}"
247
+ [ -n "$t" ] || t="$(sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$SWARM_STORE/agent.json" 2>/dev/null | head -n1)"
248
+ [ -n "$t" ] || t="$( { tr -d ' \t\r\n' < "$SWARM_STORE/token"; } 2>/dev/null)"
249
+ if [ -n "$t" ]; then printf '%s' "$t"; return 0; fi
250
+ mcp_token "$(printf '%s' "$API" | sed -n 's#^\([A-Za-z][A-Za-z0-9+.-]*://[^/]*\).*#\1#p' | tr 'A-Z' 'a-z')"
251
+ }
252
+
253
+ # Чем НЕ смогли спросить — человеческим текстом, для отказа по коду 2. Считается отдельно,
254
+ # а не внутри prod_gate: та зовётся в `$( … )`, то есть в субоболочке, и её переменные сюда
255
+ # не долетают. Путь редкий (отказ), лишний разбор кредов на нём не жалко.
256
+ token_hint() {
257
+ # Отдельная причина, и путать её с «нет токена» нельзя: без curl хук не сходил бы к проду
258
+ # и с самым живым токеном.
259
+ command -v curl >/dev/null 2>&1 || { printf '%s' "на машине нет curl — ходить к ${API} нечем"; return; }
260
+ pick_token >/dev/null 2>&1
261
+ case $? in
262
+ 0) printf '%s' "токен на машине есть, но прод не ответил (${API} — сеть, прокси или доступ)" ;;
263
+ 3) printf '%s' "OAuth-токен MCP для ${API} на этой машине ПРОТУХ. Переподключи MCP (в агенте — /mcp), и гейт снова сможет спросить прод сам" ;;
264
+ *) printf '%s' "спрашивать нечем: нет ни токена роя (TASKLESS_TOKEN, ~/.taskless-swarm), ни OAuth-токена MCP для ${API} в кредах claude (${CLAUDE_CONFIG_DIR:-~/.claude}/.credentials.json, на macOS — связка ключей). Подключи MCP: claude mcp add --transport http -s user taskless ${API%/}/api/mcp — или поставь рой: swarm login" ;;
265
+ esac
266
+ }
267
+
268
+ # Ответ прода: печатает строку решения, код 0 (можно) / 1 (нельзя) / 2 (прод не ответил).
269
+ prod_gate() {
270
+ command -v curl >/dev/null 2>&1 || return 2
271
+ api="$API"
272
+ # Ничего нет — прод для этой машины недоступен (код 2), и это ЗАПРЕТ, а не «можно».
273
+ token="$(pick_token)" || return 2
274
+ [ -n "$token" ] || return 2
275
+ # Репозиторий КОНТЕКСТА (TASWM-492): у короткой формы `#778` его нет, а номера PR у разных
276
+ # репозиториев независимы. Берём origin текущего каталога, как это делает CLI.
277
+ ctx="$(git remote get-url origin 2>/dev/null)"
278
+ # --get + --data-urlencode: ссылка на PR содержит `:`/`/`, а короткая форма — `#`, который
279
+ # в сыром query оборвал бы адрес на решётке. Аргументы собираем массивом, а не строкой:
280
+ # подстановка с кавычками внутри развалилась бы по пробелам.
281
+ args=(--get --data-urlencode "pr=$ref")
282
+ [ -n "$ctx" ] && args+=(--data-urlencode "repo=$ctx")
283
+ # Токен отдаём curl'у КОНФИГОМ ЧЕРЕЗ STDIN (-K -), а не флагом `-H`: аргументы процесса
284
+ # видны в `ps` любому пользователю машины, а это полноценный токен доступа к трекеру.
285
+ body="$(printf 'header = "Authorization: Bearer %s"\n' "$token" |
286
+ curl -fsS --max-time 15 -K - "${args[@]}" "${api%/}/api/swarm/merge-gate" 2>/dev/null)" || return 2
287
+ # Разбираем РОВНО два поля. Нет `allowed` (прокси, страница логина, чужой JSON) — считаем,
288
+ # что прод не ответил: 200 с чужим телом не должно превращаться в «мерж разрешён».
289
+ case "$body" in
290
+ *'"allowed":true'*) printf '%s' "$body" | prod_line; return 0 ;;
291
+ *'"allowed":false'*) printf '%s' "$body" | prod_line; return 1 ;;
292
+ esac
293
+ return 2
294
+ }
295
+ # Человеко-строка из ответа прода (поле `line`), либо запасная фраза, если его нет.
296
+ prod_line() {
297
+ line="$(sed -n 's/.*"line"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)"
298
+ printf '%s' "${line:-ответ прода без человеко-причины}"
299
+ }
300
+
301
+ if ! command -v swarm >/dev/null 2>&1; then
302
+ prod_out="$(prod_gate)"
303
+ case $? in
304
+ 0) allow ;;
305
+ 1) deny "$prod_out"$'\n\n'"Мерж отменён гейтом роя (TASWM-355): PR проекта с включённым авторевью не мержится, пока дрон-ревьюер не вынес вердикт. Ответ дал прод — рой на этой машине не установлен. Дождись вердикта (в дашборде — фаза ревью прогона). $ESCAPE" ;;
306
+ *) deny "Гейт мержа роя (TASWM-500) не смог получить ответ: демона на этой машине нет (нет и CLI swarm), прод не ответил — $(token_hint). Мерж отменён: без ответа рой не может подтвердить, что по PR есть вердикт ревьюера. $ESCAPE" ;;
307
+ esac
308
+ fi
309
+
310
+ out="$(swarm merge-gate "$ref" 2>&1)"
311
+ code=$?
312
+
313
+ # Четыре исхода, и различать их обязательно (иначе гейт беззвучно превращается в свою
314
+ # противоположность):
315
+ # • «мерж разрешён» — пропускаем;
316
+ # • «мерж запрещён» — deny (CLI сам выходит с 1 и говорит, почему);
317
+ # • СПРОСИТЬ НЕ УДАЛОСЬ («демон не ответил» / «ответа нет ни у кого») — не решение политики:
318
+ # идём к проду сами (TASWM-500), и только его молчание превращается в запрет;
319
+ # • рой ЕЩЁ НЕ УМЕЕТ отвечать (старый CLI/демон без команды merge-gate: печатает справку
320
+ # и выходит с 0!) — пропускаем, но говорим об этом в stderr. Запрещать здесь значило бы
321
+ # застопорить мержи на машине, чей рой просто не обновлён, — причина, к дисциплине
322
+ # ревью отношения не имеющая. Гейт включится сам, как только машина обновится.
323
+ case "$out" in
324
+ *"мерж разрешён"*) allow ;;
325
+ # «Спросить не удалось» — НЕ решение политики, хотя CLI и печатает его как отказ. Старый
326
+ # CLI (без прод-фолбэка) говорит так о лежащем демоне, новый — когда молчат оба. Отличать
327
+ # обязательно и проверять раньше «мерж запрещён»: иначе на машине со СТАРЫМ CLI хук
328
+ # отказывал бы по PR, вердикт которого лежит на проде, — ровно случай 24.08 (TASWM-500).
329
+ # Ничего не решаем здесь, проваливаемся к вопросу проду ниже.
330
+ *"демон не ответил"*|*"ответа нет ни у кого"*) ;;
331
+ *"мерж запрещён"*) deny "$out"$'\n\n'"Мерж отменён гейтом роя (TASWM-355): PR проекта с включённым авторевью не мержится, пока дрон-ревьюер не вынес вердикт. Дождись вердикта (в дашборде — фаза ревью прогона). $ESCAPE" ;;
332
+ esac
333
+ if [ "$code" -eq 0 ]; then
334
+ # Старый CLI (TASWM-481). Раньше здесь СРАЗУ пропускали: спросить было не у кого, а стопорить
335
+ # мержи на машине, чей рой просто не обновлён, — причина, к дисциплине ревью отношения не
336
+ # имеющая. Теперь спрашивать ЕСТЬ у кого: вердикты лежат на проде, и путь к нему не зависит
337
+ # ни от версии CLI, ни от живости демона. Поэтому пропускаем только тогда, когда и прод
338
+ # промолчал, — и по-прежнему говорим об этом вслух.
339
+ prod_out="$(prod_gate)"
340
+ case $? in
341
+ 0) allow ;;
342
+ 1) deny "$prod_out"$'\n\n'"Мерж отменён гейтом роя (TASWM-355): вердикта ревьюера по этому PR нет. Ответ дал прод — рой на этой машине старой версии (команды merge-gate он не знает). Обнови: swarm update. $ESCAPE" ;;
343
+ esac
344
+ printf '%s\n' "pre-merge-gate: рой на этой машине не знает команды merge-gate (старая версия), прод тоже не ответил — мерж пропущен БЕЗ проверки вердикта. Обнови: swarm update" >&2
345
+ allow
346
+ fi
347
+ # CLI есть, но ответа не дал (старый CLI без прод-фолбэка при лежащем демоне, сломанная
348
+ # установка). Прежде чем запрещать, спрашиваем прод сами — ответ у него есть и тогда, когда
349
+ # CLI до него не дошёл; молчит и он — запрет, как и было.
350
+ prod_out="$(prod_gate)"
351
+ case $? in
352
+ 0) allow ;;
353
+ 1) deny "$prod_out"$'\n\n'"Мерж отменён гейтом роя (TASWM-355): вердикта ревьюера по этому PR нет. Ответ дал прод — локальный демон молчит. $ESCAPE" ;;
354
+ esac
355
+ deny "$out"$'\n\n'"Гейт мержа роя (TASWM-500) не смог получить ответ — ни локальный демон, ни прод: $(token_hint). Мерж отменён: без ответа рой не может подтвердить, что по PR есть вердикт ревьюера. $ESCAPE"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taskless-app/swarm",
3
- "version": "0.6.24",
3
+ "version": "0.6.25",
4
4
  "type": "module",
5
5
  "description": "Local control plane for a fleet of AI coding agents (drones), coordinating through Taskless. The overmind runs the swarm.",
6
6
  "keywords": [
@@ -30,6 +30,7 @@
30
30
  "files": [
31
31
  "dist",
32
32
  "docs/HERO.md",
33
+ "hooks",
33
34
  "roles"
34
35
  ],
35
36
  "dependencies": {
@@ -38,7 +39,7 @@
38
39
  "execa": "^10.0.0",
39
40
  "zod": "3.25.76"
40
41
  },
41
- "gitHead": "af6c4af0f523891b5db805d9b4583021688ed1f5",
42
+ "gitHead": "36aaed0414a46ea272ad762b81e051fecec40eaa",
42
43
  "scripts": {
43
44
  "overmind": "NODE_OPTIONS=\"${NODE_OPTIONS:-} --conditions=development\" tsx src/overmind.ts",
44
45
  "swarm": "NODE_OPTIONS=\"${NODE_OPTIONS:-} --conditions=development\" tsx src/cli.ts",