---
name: _autonomous_pickup
description: "[SHARED SNIPPET — do not invoke directly] Forgejo queue pickup logic, импортируется во все auto-*.md агенты. Содержит claim/state-transition contract."
status: draft
created_at: 2026-05-27
---

# Autonomous queue pickup — shared contract

> **NOT a standalone agent.** Этот файл — общая инструкция, которую копи-пастят
> внутрь каждого `auto-*.md`. Содержит claim/lifecycle/kill-switch логику.

## Pre-flight checklist (ОБЯЗАТЕЛЬНО до /loop запуска)

> **Это критично.** Без правильной настройки git identity → commits будут писаться
> под user'ом (lekss361), не под ботом. Audit trail сломается.

### Шаг 1 — Где живут PAT'ы

PAT'ы хранятся в **двух местах одновременно**:

1. **Vault** `meta/00_credentials.md` — sensitive backup (read-only reference)
2. **Windows User-scope env vars** — production-ready, **persistent**:
   - `FORGEJO_TOKEN_ANALYST`
   - `FORGEJO_TOKEN_BACKEND`
   - `FORGEJO_TOKEN_FRONTEND`
   - `FORGEJO_TOKEN_REVIEWER`
   - `FORGEJO_TOKEN_QA`
   - `FORGEJO_URL_BOTS` = `https://git.gendsgn.ru`
   - `FORGEJO_REPO_BOTS` = `lekss361/gendesign`

Setup один раз через PowerShell (см. `scripts/setup-bot-env.ps1`). После этого
env vars доступны во **всех** новых shell-сессиях автоматически — никаких
`Get-Content` / файлов.

Проверь что выставлены:

```powershell
# GetEnvironmentVariable возвращает $null если var отсутствует — НЕ throws.
# Поэтому проверяем результат напрямую (не через $? — он у Get* всегда $true).
if (-not [System.Environment]::GetEnvironmentVariable("FORGEJO_TOKEN_BACKEND", "User")) {
    Write-Error "❌ FORGEJO_TOKEN_BACKEND не выставлен. Запусти scripts/setup-bot-env.ps1 сначала"
}
```

### Шаг 2 — Env vars + git identity (per окно)

Замени `<ROLE>` на свою роль (`ANALYST`/`BACKEND`/`FRONTEND`/`REVIEWER`/`QA` — UPPER-case):

```powershell
$ROLE = "BACKEND"   # ← ИЗМЕНИ ПЕРЕД ЗАПУСКОМ (UPPER-case)
$BOT  = "bot-$($ROLE.ToLower())"

# Resolve token из persistent User env
$env:FORGEJO_TOKEN = [System.Environment]::GetEnvironmentVariable("FORGEJO_TOKEN_$ROLE", "User")
$env:BOT_USERNAME  = $BOT
$env:FORGEJO_URL   = [System.Environment]::GetEnvironmentVariable("FORGEJO_URL_BOTS", "User")
$env:FORGEJO_REPO  = [System.Environment]::GetEnvironmentVariable("FORGEJO_REPO_BOTS", "User")

# Sanity: token не пустой
if (-not $env:FORGEJO_TOKEN) {
    Write-Error "❌ FORGEJO_TOKEN_$ROLE не выставлен. Запусти scripts/setup-bot-env.ps1"
    return
}

# Git identity — КРИТИЧНО, иначе commit author будет user'а (lekss361)
$env:GIT_AUTHOR_NAME     = $BOT
$env:GIT_AUTHOR_EMAIL    = "$BOT@gendsgn.local"
$env:GIT_COMMITTER_NAME  = $BOT
$env:GIT_COMMITTER_EMAIL = "$BOT@gendsgn.local"
```

### Шаг 3 — Bot-remote (для git push audit-log)

Существующий `forgejo` remote использует lekss361's PAT — push через него
запишется в Forgejo audit log как lekss361. Создай **отдельный bot-remote**:

```powershell
git remote remove forgejo-bot 2>$null
git remote add forgejo-bot "https://$($env:BOT_USERNAME):$($env:FORGEJO_TOKEN)@git.gendsgn.ru/lekss361/gendesign.git"

# Везде в workflow:
#   git push forgejo-bot feat/X            (НЕ git push forgejo)
```

### Шаг 4 — Sanity check (verify identity)

```powershell
# 4a. PAT принадлежит правильному боту
$me = curl -sS -H "Authorization: token $env:FORGEJO_TOKEN" "$env:FORGEJO_URL/api/v1/user" | ConvertFrom-Json
if ($me.login -ne $env:BOT_USERNAME) {
    Write-Error "❌ Identity mismatch: PAT belongs to $($me.login), expected $env:BOT_USERNAME"
    exit 1
}
Write-Host "✓ PAT belongs to $($me.login)"

# 4b. Git identity (на сессию)
Write-Host "✓ Commits will be authored as: $env:GIT_AUTHOR_NAME <$env:GIT_AUTHOR_EMAIL>"

# 4c. Bot-remote configured
git remote -v | Select-String "forgejo-bot"
```

Только после `4a/4b/4c ✓` — запускай `/loop`.

## Forgejo операции — `mcp__forgejo__*` tools (PRIMARY)

forgejo MCP (goern) подключён, но **deferred** (`alwaysLoad:false` во всех `.claude/mcp/<role>.json` —
экономия контекста, ~90 схем не грузятся upfront). Токен бота — из `FORGEJO_ACCESS_TOKEN`
(его выставляет `scripts/start-bot.ps1 <role>` ДО запуска claude).

⚠️ **forgejo deferred → в начале work-тика ОДИН раз `ToolSearch`** свой набор tools (см. таблицу ниже),
если они ещё не в контексте; загруженные схемы живут до compaction. На idle-тиках НЕ грузи — kill-switch
ниже использует лёгкий `curl_forgejo` (piped jq, без temp-файлов), чтобы холостой poll не тянул MCP.

⛔ **НИКОГДА не транслируй HTTP-нотацию (`GET /pulls`, `POST /merge`) в ручной curl с temp-файлами.**
Анти-паттерн (incident 2026-05-31, PR #893): `curl ... -o /tmp/pr.json` → `python3 json.load(open(...))`
= на Windows `http=404` + `FileNotFoundError /tmp/...`. MCP-tool возвращает УЖЕ распарсенный объект —
ни temp-файлов, ни ручного JSON, ни `/tmp`. `curl_forgejo` ниже — **fallback only** (MCP недоступен,
напр. Task-spawn без forgejo в toolset): пиши через pipe `| jq`, POST-body через `--data-binary @file`
(не inline `-d` — Windows срезает кавычки → 422), не `/tmp` (используй `$env:TEMP`).

| Операция | MCP tool | Заметки |
|---|---|---|
| kill-switch / pickup / fixup-pickup | `mcp__forgejo__list_repo_issues` | фильтр `labels`,`state`; unassigned/assignee — фильтруй клиентом (`.assignees`) |
| claim: assign + label transition | `mcp__forgejo__update_issue` (assignees) + `mcp__forgejo__add_issue_labels` + `mcp__forgejo__remove_issue_labels` | |
| PR open | `mcp__forgejo__create_pull_request` | head/base/title/body |
| PR diff / files | `mcp__forgejo__get_pull_request_diff`, `mcp__forgejo__list_pull_request_files` | diff умеет `file_path` |
| review verdict | `mcp__forgejo__create_pull_review` | event=APPROVED / REQUEST_CHANGES / COMMENT |
| merge | `mcp__forgejo__merge_pull_request` | Do=squash, delete_branch_after_merge |
| comment (marker / fixup K/3) | `mcp__forgejo__create_issue_comment` | |
| status transition | `mcp__forgejo__add_issue_labels` / `mcp__forgejo__remove_issue_labels` | |
| close issue (qa done) | `mcp__forgejo__issue_state_change` | |

**Gotcha:** на user-репо (`lekss361` — не org) для label-листинга передавай `include_org_labels:false`,
иначе 403 на `/orgs/...`.

**⚠️ Token-limit на `list_repo_issues`:** без фильтра ответ рвёт лимит (видели 59k–140k символов →
дамп в файл, тратятся тики на slicing). ВСЕГДА передавай `labels`+`state`+узкий `limit` (напр.
`labels:"status/ready"`, `limit:30`). Не звать без фильтра «посмотреть все issues» — для дедупа
используй `q=<keywords>&state=all&limit=5`, не полный листинг.

**⚠️ Параллельные окна одной роли (analyst/worker) → дубли + взаимное закрытие issues.**
Два окна на одном токене не имеют claim-lock на *создание* issue. Incident 2026-05-30: два
analyst-окна завели #724/#728 vs #726/#727 на те же находки, потом закрыли друг друга →
work-item остался без open-issue, 3 тика на recovery. Защита:
- **Перед /loop**: убедись, что нет второго live-окна твоей роли (спроси человека / проверь recent
  issues на свой `bot-<role>` author за последние минуты).
- **Дедуп-before-create ОБЯЗАТЕЛЕН** (не опционален): `list_repo_issues` с `q=<keywords>&state=all`
  ПЕРЕД каждым `create_issue`. Совпадение по сути → не создавай, прокомментируй существующий.
- Если коллизия уже произошла — НЕ закрывай вслепую; reopen один канонический, дубли закрой
  комментом-ссылкой, проверь что work-item не остался без open-issue.

### Label IDs — goern `add_issue_labels`/`remove_issue_labels` требует ID, НЕ имя!

goern-MCP в add/remove принимает **числовой id** (несмотря на доку «names» — первый add по имени
упадёт). **Перед add/remove**: либо id из таблицы ниже, либо (надёжнее — id меняются при пересоздании
label) `mcp__forgejo__list_repo_labels` → построй map name→id рантайм.

Pipeline-labels (snapshot 2026-05-30):

| label | id | label | id |
|---|---|---|---|
| `scope/backend` | 46 | `status/ready` | 51 |
| `scope/frontend` | 47 | `status/wip` | 52 |
| `scope/db` | 48 | `status/review` | 53 |
| `scope/qa` | 49 | `status/qa` | 54 |
| `scope/devops` | 50 | `status/done` | 55 |
| `priority/p0` | 57 | `status/blocked` | 56 |
| `priority/p1` | 58 | `status/needs-fix` | 62 |
| `priority/p2` | 59 | `pause-bots` | 60 |
| `priority/p3` | 63 | `needs-human` | 61 |
| `bug` | 5 | `tech-debt` | 42 |
| `status/needs-analysis` | 64 | | |

⚠️ Таблица — снимок; при любом сомнении/ошибке резолвь id через `list_repo_labels` (источник истины).
`create_issue` принимает label-ids массивом; `issue_state_change` — для open/close (не labels).

## Forgejo API endpoints — curl FALLBACK (если MCP недоступен)

```bash
curl_forgejo() {
  curl -sS -H "Authorization: token $FORGEJO_TOKEN" \
       -H "Content-Type: application/json" \
       "$FORGEJO_URL/api/v1/$1" "${@:2}"
}
```

## Kill-switch check (выполняй ПЕРВЫМ делом в каждом /loop tick)

```bash
# Проверка через meta-issue с label "pause-bots"
if curl_forgejo "repos/$FORGEJO_REPO/issues?labels=pause-bots&state=open&limit=1" \
   | jq -e 'length > 0' > /dev/null; then
  echo "result: paused (pause-bots active)"
  exit 0  # /loop спит до следующего тика
fi
```

## Pickup query — по scope

```bash
SCOPE="backend"  # ∈ {backend, frontend, db, qa, devops}

NEXT=$(curl_forgejo \
  "repos/$FORGEJO_REPO/issues?state=open&labels=scope/$SCOPE,status/ready&assigned_to=none&sort=newest&limit=1" \
  | jq -r '.[0] | if . then [.number, .title] | @tsv else "" end')

if [[ -z "$NEXT" ]]; then
  echo "result: idle, no work for scope/$SCOPE"
  exit 0
fi
```

## Claim — atomic-ish через label transition

> **Race window note**: Forgejo не поддерживает conditional-update (ETag/If-Match
> для issue PATCH). Между шагом 1 и 3 другой worker теоретически может тоже claim'нуть.
> Verify checks `length == 1 AND .assignees[0] == me` — иначе откатываемся.

```bash
ISSUE=$(echo "$NEXT" | cut -f1)

# 1. Assign self (atomic на стороне Forgejo для самого set-assignees, но не для transition)
curl_forgejo "repos/$FORGEJO_REPO/issues/$ISSUE" -X PATCH \
  -d "{\"assignees\": [\"$BOT_USERNAME\"]}"

# 2. Transition ready → wip
curl_forgejo "repos/$FORGEJO_REPO/issues/$ISSUE/labels" -X POST \
  -d '{"labels": ["status/wip"]}'
curl_forgejo "repos/$FORGEJO_REPO/issues/$ISSUE/labels/status/ready" -X DELETE

# 3. Verify claim не перехвачен — STRICT check
ISSUE_JSON=$(curl_forgejo "repos/$FORGEJO_REPO/issues/$ISSUE")
ASSIGNEE_COUNT=$(echo "$ISSUE_JSON" | jq '.assignees | length')
ASSIGNEE=$(echo "$ISSUE_JSON" | jq -r '.assignees[0].login // ""')

if [[ "$ASSIGNEE_COUNT" != "1" || "$ASSIGNEE" != "$BOT_USERNAME" ]]; then
  echo "result: lost race for #$ISSUE (assignees=$ASSIGNEE_COUNT, first=$ASSIGNEE) — releasing"
  # Best-effort rollback — снять wip, вернуть ready (не критично если не получится)
  curl_forgejo "repos/$FORGEJO_REPO/issues/$ISSUE/labels" -X POST \
    -d '{"labels": ["status/ready"]}'
  curl_forgejo "repos/$FORGEJO_REPO/issues/$ISSUE/labels/status/wip" -X DELETE
  exit 0
fi
```

## Fixup pickup — own `status/needs-fix` PR (priority над new claim)

Reviewer НЕ дед-эндит 🟠 FIX в human (это был главный throughput-killer). FIX verdict → issue
получает `status/needs-fix`, assignee **остаётся** worker'а. Worker КАЖДЫЙ work-тик ПЕРВЫМ делом
проверяет свои `needs-fix` (приоритет над новым claim) и чинит свой же PR — НЕ создаёт новый branch/PR:

```bash
# Перед обычным ready-pickup — есть ли мой PR, который вернули на фикс?
MINE_FIX=$(curl_forgejo \
  "repos/$FORGEJO_REPO/issues?state=open&labels=scope/$SCOPE,status/needs-fix&sort=oldest&limit=20" \
  | jq -r --arg me "$BOT_USERNAME" '[.[] | select(.assignees[]?.login == $me)][0].number // ""')

if [[ -n "$MINE_FIX" ]]; then
  # FIXUP MODE (детальный flow — в auto-<scope>.md):
  #   1. CONTEXT LOAD (как при обычной работе — conventions обязательны)
  #   2. checkout СУЩЕСТВУЮЩЕЙ ветки feat/<N>-slug (git fetch forgejo-bot && checkout)
  #   3. прочитать последний review-bot comment (marker verdict=changes) → fix-list
  #   4. применить фиксы → lint → tests → push в ТОТ ЖЕ branch (PR обновится)
  #   5. issue: +status/review -status/needs-fix
  exit 0
fi
# иначе — обычный ready-pickup ниже
```

**Fix-attempt cap**: каждый fixup-цикл добавляет comment `fixup attempt K/3`. На 3-м FIX по одному PR
reviewer переводит в `+status/blocked +needs-human` (защита от бесконечного fix-loop).

## State transitions reference

| От → К | Кто переключает | Условие |
|---|---|---|
| (new, human) → `status/needs-analysis` | **человек** (или auto-analyst для своих raw-находок) | сырой/нечёткий тикет заведён в Forgejo, требует archeology+декомпозиции до того как worker сможет взять |
| `status/needs-analysis` → `status/ready` | **auto-analyst** (claim+refine in-place) | тикет single-scope, переписан в actionable-спек по шаблону, deps удовлетворены |
| `status/needs-analysis` → (closed, links на под-issues) | **auto-analyst** | тикет был multi-scope → расщеплён на N под-issues (scope/*+ready), parent закрыт коммент-ссылкой |
| `status/needs-analysis` → `+needs-human` | auto-analyst | неустранимая двусмысленность / нужно решение/caps человека |
| (new) → `status/ready` | auto-analyst | issue декомпозирован, deps удовлетворены |
| `status/ready` → `status/wip` | auto-backend / auto-frontend | claim успешный |
| `status/wip` → `status/review` | worker | PR открыт |
| `status/review` → `status/qa` | auto-code-reviewer | ✅ APPROVE + merge |
| `status/review` → `status/needs-fix` | auto-code-reviewer | 🟠 FIX verdict (assignee остаётся worker) |
| `status/needs-fix` → `status/review` | original worker | fixup-commit запушен в тот же PR |
| `status/review`/`status/needs-fix` → `status/blocked` | auto-code-reviewer | 🔴 BLOCK (security/data-loss) ИЛИ 3× fix-fail |
| `status/qa` → `status/done` | auto-qa-tester | smoke OK, issue closed |
| `status/qa` → `status/needs-fix` | auto-qa-tester | smoke FAIL = feature_regression (assignee → PR author) |
| `status/qa` → `status/blocked` | auto-qa-tester | prod_down (+ pause-bots) |
| `status/blocked` → `status/ready` | human ИЛИ **auto-resolver** | manual / human-proxy unblock |
| `+needs-human` (вешать) | auto-analyst / worker / qa | блокер требует caps/решения человека |
| `-needs-human` (снимать) → FSM | **only auto-resolver** (human-proxy окно) | блокер устранён; аналитику/воркерам снимать ЗАПРЕЩЕНО (anti-race #726/#727) |
| любой + `pause-bots` присутствует | (никто не работает) | kill-switch |

> **Новый label `status/needs-fix`** нужно создать в Forgejo (Settings → Labels) до первого запуска
> auto-fix loop. Семантика: «вернули worker'у на доработку, НЕ требует human» — в отличие от
> `status/blocked` (который только human снимает).
>
> **Label `status/needs-analysis` (id 64) — создан 2026-05-31.** Семантика: «человек завёл сырой
> тикет, нужна archeology + декомпозиция аналитиком до того как worker возьмёт». Это **входящая
> очередь auto-analyst** — единственный потребитель. Человек просто заводит issue с этим лейблом
> (тело может быть нечётким — аналитик дочистит); scope/* и priority/* опциональны (аналитик
> проставит). См. «Inbound pickup» в `auto-analyst.md`.

## Pause-bots поведение mid-work

Если `pause-bots` label появился ПОКА worker уже в wip:

1. **НЕ abort** — finish текущий commit + push (минимизирует потерю работы)
2. Open PR как обычно → PR попадёт в queue `status/review` (но reviewer тоже paused → PR не merge'нётся)
3. result: PR #N opened, then paused due to kill-switch
4. После un-pause — reviewer подхватит PR

Это **НЕ release claim** на исходный issue — он остаётся wip+assigned до merge.

## Stale-claim cleanup — ✅ имплементировано (cron)

Освобождение issues застрявших в `status/wip` >4h автоматизировано:

- **Workflow**: `.forgejo/workflows/stale-claims.yml` — cron `*/30 * * * *` (каждые 30 мин UTC)
- **Скрипт**: `scripts/cleanup-stale-claims.sh` (`STALE_HOURS=4`, пагинация, trace-comment на каждый release)
- **Действие**: clear assignee → `status/wip` → `status/ready` + comment "Stale claim released…"

Ручной мониторинг wip-issues больше **не нужен**. Manual trigger возможен через
Forgejo UI (`workflow_dispatch`).

> ⚠️ **Известный gap**: cron НЕ проверяет `pause-bots`. Если worker приостановлен mid-work
> (держит wip-claim до merge per «Pause-bots поведение») и завис >4h — cron всё равно снимет
> claim. Добавить early-exit по `pause-bots` в `cleanup-stale-claims.sh` (follow-up).

## Self-throttle rules

> **Подписка, не API → лупы ТУГИЕ, без cost-backoff.** Idle-тик = дешёвый poll (реальный usage
> тратится только когда есть work). Прогрессивный backoff был ради экономии API-стоимости — на
> подписке этой причины нет, а он лишь тормозил хэндофы (ready→wip→review→qa) до 30-60m.

1. **Idle** → спи на штатном коротком интервале роли (reviewer ~2m · qa ~5m · worker ≤5m ·
   analyst ~15m), БЕЗ прогрессивного роста. Хэндофы должны быть near-real-time.
2. **24h ничего не закрыл** → result: idle 24h, эскалация (label `needs-human`).
3. **Реальный потолок — usage-лимиты подписки** (Max 5h/weekly), не деньги-за-тик. Упёрся в
   лимит → удлини интервалы латентных окон ИЛИ `pause-bots`, когда не работаешь.

### Usage-limit awareness (weekly cap) — критично для /loop окон
Claude имеет ДВА лимита: 5h-rolling (сам сбрасывается каждые ~5ч) + **недельный cap** (накопительный, НЕ откатывается до weekly-reset). Автономные /loop окна — паттерн, выжигающий НЕДЕЛЬНЫЙ счётчик: каждая 5h-сессия откатывается, но недельная сумма растёт и «вдруг» вырубает в середине недели до сброса.
**Правила экономии недельного бюджета:**
- НЕ держать все окна (analyst/backend/reviewer/qa/frontend) параллельно 24/7 — запускать под текущую нагрузку очереди.
- **Idle-backoff:** если pickup-query пуст N тиков подряд (≈3) → увеличить /loop-интервал ×2; при дальнейшей пустоте → **остановить loop** (не спиннить пустое окно на дефолтном интервале — горит бюджет впустую). Перезапустить, когда появится работа.
- Пустая очередь + нет fixup-PR → выходить из loop, а не крутиться вхолостую.
- Тяжёлые batch-прогоны — вне пиковых часов (≈5–11 PT) при возможности.
- Перед длинной автономной сессией глянуть Settings → Usage (оба счётчика + дата weekly-reset).
- **Окна — на Sonnet, не Opus** (`start-bot.ps1` уже запускает с `--model sonnet`; reviewer — opus). Opus — только main-оркестратору. Sonnet-пул отдельный, недельный All-models (Opus) пул так не горит.
- **Context-hygiene (forgejo-MCP результаты = ~47% расхода, остаются в контексте):** `/compact` после всплеска forgejo-вызовов (много PR/issue/label за тик); `/clear` между независимыми issue в loop — флашит накопившиеся MCP-результаты, иначе каждый тик дороже при контексте >150k.

## Error escalation

| Ошибка | Действие |
|---|---|
| HTTP 401/403 от Forgejo | PAT истёк / отозван → result: AUTH_ERROR, остановка окна |
| HTTP 500 от Forgejo | result: forgejo down, sleep 30m |
| Subagent error 3× на одной issue | +status/blocked +needs-human, отпустить assignee, next issue |
