npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

workflow-ai

v1.26.6

Published

AI Agent Workflow Coordinator — kanban-based pipeline for AI coding agents

Readme

  • Добавлено упоминание human-gate в разделе про runner-стадии (пример конфига в pipeline.yaml: human-review-step типа manual-gate).
  • Добавлено упоминание mark-blocked как механизм нотификации autoblocked-тикетов (поля auto_blocked_reason/attempts/at).

Синглтон семантика Pipeline

Система workflow реализует паттерн синглтона для выполнения пайплайна: одновременно разрешена только одна активная инстанция пайплайна для каждого проекта.

Семантика Синглтона

При попытке запустить второй пайплайн, когда первый уже работает, система вернёт ошибку PIPELINE_ALREADY_RUNNING.

Структура .workflow/logs/.pipeline.lock

Файл пишется одной записью в момент захвата блокировки (wx), поэтому плейсхолдеров null в нём не бывает. Между созданием файла и записью содержимого есть микросекундное окно, в котором читатель может увидеть его пустым: пустой или невалидный JSON следует трактовать как «повторить чтение», а не как «пайплайна нет». Поля:

  • pid — ID процесса работающего пайплайна
  • started_at — временная метка запуска (ISO)
  • timestamp — то же значение; оставлено для потребителей, читавших ранний формат
  • started_by — кто запустил: cli, mcp или extension
  • started_by_id — метка запустившего экземпляра; поля нет, если запускающий не представился
  • run_id — идентификатор запуска, совпадает с именем лог-файла без расширения
  • pipeline_log — путь к логу через /; относительный от корня проекта, а если execution.log_file указывает на другой диск — абсолютный
  • project_root — корневая директория проекта
  • pipeline_version — версия workflow-ai, записавшего блокировку

Поле started_by заполняется из переменной окружения WORKFLOW_STARTED_BY, которую выставляет тот, кто спавнит раннер (workflow-mcp — mcp, расширение VS Code — extension). По умолчанию cli; неизвестное значение отбрасывается. Переменная снимается из окружения сразу после чтения, поэтому дочерние агенты её не наследуют.

Поле started_by_id заполняется из WORKFLOW_STARTED_BY_ID — той же стороной и по тем же правилам: переменная снимается сразу после чтения, значение длиннее 128 символов или с пробелами отбрасывается. Раннер его не расшифровывает, для него это непрозрачная метка. Нужна она тому, кто запускал: workflow-mcp по ней узнаёт собственный запуск и отличает его от запуска соседней рабочей области. До 1.7.0 сервер писал для этого свой файл рядом (.workflow/logs/.mcp-started-by), и два файла про один запуск умели разойтись.

Полный payload появился в 1.6.0; более ранние версии писали только pid, timestamp и — с 1.5.2 — started_at. Наличие started_by служит признаком раннера ≥ 1.6.0, наличие started_by_id — ≥ 1.7.0.

Команды

  • workflow run --project <path> [--plan PLAN-X] [--config <path>] — запустить пайплайн
  • Остановка: программно через stopPipeline() из src/lib/stop-command.mjs либо через MCP-инструмент stop_pipeline. Команды workflow stop в CLI пока нет.
  • SIGINT / SIGTERM (Ctrl+C; на POSIX — stopPipeline(), MCP abort_pipeline и остановка из расширения VS Code) раннер отрабатывает сам: снимает текущего агента, пишет его исход в журнал запусков (событие run со статусом aborted и stop_requested: true), следующего агента не берёт, снимает .pipeline.lock и выходит с кодом 130. Повторный сигнал во время остановки — агент снимается сразу (на POSIX — SIGKILL, только сам процесс агента, без его потомков) и выход 130 без ожидания.
  • Снятие процесса раннера целиком (taskkill /F — на Windows stopPipeline(), MCP stop_pipeline, abort_pipeline и расширение VS Code; SIGKILL — MCP stop_pipeline на POSIX и эскалация, если раннер не вышел после SIGTERM), падение раннера или машины исход записать не дают: остаётся запись открытого запуска .workflow/state/agent-run-open.json, её закрывает следующий workflow run (см. «Восстановление»).

Восстановление

Блокировка от упавшего процесса (kill -9, ребут, принудительное завершение на Windows) остаётся на диске, но помечена мёртвым pid. Специальных действий не нужно: следующий workflow run проверяет живость процесса, пишет stale marker found — removing и снимает блокировку сам. Проверить вручную: node -e "console.log(require('fs').readFileSync('.workflow/logs/.pipeline.lock','utf8'))".

Запуск агента, прерванный вместе с раннером, раннер тоже закрывает сам. До старта агента он пишет .workflow/state/agent-run-open.json (run_key, агент, стадия, тикет, время старта) и удаляет файл после события run в журнале запусков. Файл остался — следующий workflow run, взяв .pipeline.lock, до первой стадии дописывает в журнал событие run с его полями, status: "aborted" и interrupted: true (started_at — время старта агента, ts — время дописывания) и пишет в лог agent run interrupted: agent=… ticket=… run_key=…. Событие с этим run_key уже есть или файл не читается — файл удаляется без события. Второй workflow run при живом раннере файл не трогает: он не берёт lock.

Окружение агентов: ~/.workflow/agent.env

Переменные из <WORKFLOW_HOME>/agent.env (по умолчанию ~/.workflow/agent.env) получает каждый агент — и в стадиях pipeline, и в тестах скилов, кто бы ни запустил раннер (MCP, расширение VS Code, терминал). Типичное содержимое — прокси:

# строки KEY=VALUE; комментарий — только целая строка с '#' в начале
HTTP_PROXY=http://user:pass@host:port
HTTPS_PROXY=http://user:pass@host:port
ALL_PROXY=

# внутри значения — часть значения (не комментарий). Пустое значение (ALL_PROXY=) убирает переменную из окружения агента. Файл перекрывает окружение раннера, служебные WORKFLOW_RAILS_* главнее файла. Файл машинный и вне git (в нём бывают учётные данные): в лог стадии пишутся только имена переменных (agent.env: задано HTTP_PROXY, HTTPS_PROXY; снято ALL_PROXY), значения — никогда; нечитаемый файл и строки не по формату — WARN с путём и номерами строк. Читается при каждом запуске агента, перезапуск раннера после правки не нужен. Нет файла — окружение агентов не меняется.

Зачем: 2026-09-24 автообновление kilo перезаписало обёртку kilo.cmd, которая подставляла прокси; раннер из расширения запускал kilo напрямую, и все kilo-агенты получили 403 Forbidden. Реализация — src/lib/agent-env.mjs.

Скилы для kilo

kilo CLI 7.7.x читает SKILL.md из проектного .kilocode/skills, только если настоящий путь файла (после раскрытия всех ссылок) лежит внутри проекта; иначе — failed to load skill … blocked file reference outside project config scope, и агент работает без текста скила. Поэтому workflow init / update / eject:

  • ставят ссылку <каталог настроек kilo>/skills → ~/.workflow/skills — канон для всех проектов (каталог настроек: KILO_CONFIG_DIR, иначе <XDG_CONFIG_HOME или ~/.config>/kilo); своему каталогу kilo доверяет без проверки. Существующий каталог или чужую ссылку там не трогают;
  • держат .kilocode/skills проекта обычным каталогом со ссылками только на скилы, физически лежащие в проекте (эжектнутые и проектные). Прежняя ссылка на весь .workflow/src/skills снимается — удаляется только ссылка, не цель.

Проверка: kilo run "List the names of all skills available to you" из корня проекта — без строк failed to load skill.

MCP-серверы у агентов

Тикет с required_capabilities: [mcp] исполняет агент со способностью mcp в capabilities; без такого агента в списке стадии тикет уходит в blocked/ (no_capable_agent). В configs/pipeline.yaml mcp есть у агентов claude: claude подключает серверы из .mcp.json проекта, разрешённые в .claude/settings.local.json (enabledMcpjsonServers), в том числе в режиме -p. kilo .mcp.json не читает — у kilo-агентов mcp нет. Перед тикетами типов из context.mcp_require_for стадия check-mcp проверяет, что серверы из .mcp.json разрешены, http-серверы отвечают, а команда stdio-сервера находится в PATH.

Проверка: claude -p из корня проекта с просьбой найти инструменты сервера через ToolSearch — в ответе имена mcp__<сервер>__…. claude mcp list для сервера из .mcp.json может показывать Pending approval и при разрешении в settings.local.json: 2026-09-27 в PulseProxy так было, а claude -p инструменты mcp__playwright__* нашёл.

Фактическая модель kilo-агента

Роутеры kilo выбирают модель сами: kilo/kilo-auto/free — одну на сессию (от сессии к сессии разную), kilo/openrouter/free — заново почти на каждом шаге. Пока kilo-агент работает, раннер раз в 15 с смотрит, какие модели ответили, и при смене набора пишет строку; после выхода агента — финальную, с числом шагов:

[execute-task] AGENT_MODELS agent="openrouter-free(nemotron, ling)" requested="kilo/openrouter/free" models="nvidia/nemotron-3-ultra-550b-a55b:free ×10, inclusionai/ling-3.0-flash-fin:free ×9"

agent — подпись агента: модель одна и совпадает с запрошенной — просто id (gpt-luna); одна другая — в скобках её имя (kilo-free(dots-3-note-preview)); несколько — семейства по убыванию шагов (openrouter-free(nemotron, ling), семейство — имя модели до первого дефиса). Та же подпись встаёт в столбец «Агент» истории работы тикета и показывается в панели pipeline расширения VS Code.

Источник — база kilo: kilo 7.7.9 записывает ответившую модель роутера в каждый завершённый шаг; у агента с фиксированной моделью шаги без модели, и модель шага — модель сессии (session.model.id, у субагента без своей модели — модель корневой сессии). Сессию запуска раннер находит по метке kilo run --title workflow-<run id> (свой --title в аргументах агента не перекрывается), шаги субагентов считаются вместе с ней. За запуск в журнале запусков отвечает модель последнего шага корневой сессии. Путь базы спрашивается у kilo db path один раз на процесс раннера, чтение — встроенным node:sqlite (Node ≥ 22.5). Нет модуля, базы, сессии или шагов — в логе строка «фактическая модель неизвестна», на ход стадии это не влияет. Реализация — src/lib/kilo-models.mjs.

Журнал запусков и отсев моделей

Каждый запуск агента стадии со списком агентов пишется в журнал проекта .workflow/metrics/agent-runs.jsonl — одна строка JSON на событие, файл только дописывается. Промптов, вывода агента и содержимого файлов в журнале нет.

| Событие | Когда пишется | |---------|---------------| | run | агент завершился: успех, ошибка, model_banned, остановка пайплайна (aborted); за прерванный вместе с раннером запуск — при следующем старте (interrupted: true) | | verify | завершилась стадия контроля артефактов (verify-artifacts.js) с блоком RESULT: статус, процент DoD, отсутствующие файлы, fail_reasons | | review | завершилась стадия ревью (review-result с model_io или со скилом): статус, агент, модель | | unban | человек снял запрет | | reset | историю модели обнулили: её запуски до этого события не идут в градации, запреты и statsTable (раздел «Пулы моделей» → «Обнуление истории модели») |

Модель запуска: у kilo-агента — модель последнего шага (не прочитана — model: null, такой запуск ни в одно правило не идёт); у стадии с model_io — модель из ответа; у прочих агентов — значение --model / -m из args как есть, у kind: http — поле model, иначе id агента.

У запуска стадии с выбором модели (раздел «Выбор модели стадии») в записи открытого запуска и в событии run ещё семь полей: selection (selector — уровень назвал селектор, ladder — лестница без ответа, cursor — ни у одного кандидата нет оценки, прежний курсор), level — уровень запущенного кандидата, levels — число уровней, required_level — требуемый уровень (у лестницы null), floor_level — нижняя граница тикета, score — его оценка intelligence или null, free. У остальных стадий ключи события прежние.

Градации — у запуска исполнителя (стадия со скилом execute-task), первая ступень, на которой остановилась его работа; вычисляются при чтении журнала:

| Градация | Условие | |----------|---------| | crashed | сбой процесса (error, timeout, network_error, auth_error, aborted без признака остановки пайплайна), ни одного изменённого файла | | refused | агент вернул status: blocked — класс запуска blocked или result_status: blocked (статус блока RESULT: класс запуска у такого ответа ok) | | stopped | остановка пайплайна (aborted с stop_requested или interrupted) или model_banned — при любых изменениях | | throttled | запуск закончился на ограничении провайдера — статус rate_limit (HTTP 429, rate limit, quota exceeded, too many requests в трёх последних строках stderr) — при любых изменениях | | empty | не сбой, ни одного изменённого файла проекта, включая тикет | | artifacts_failed | ближайший контроль артефактов — failed с причинами (в том числе проваленная проверка пункта DoD) | | review_failed | контроль пройден, ближайшее ревью с вердиктом — failed | | accepted | контроль all_green, или passed / legacy и ревью passed | | pending | контроля или ревью с вердиктом ещё нет (default и error ревью — не вердикт) |

Сбой с изменёнными файлами получает градацию по контролю и пометку crashed_after_work. Остановленный запуск модели не засчитывается: ни неудачи, ни успеха, ни временного запрета — в том числе прерванный падением раннера или машины. Агент, снятый сигналом не остановкой пайплайна (OOM, kill по pid агента), — сбой и временный запрет модели.

Запуск throttled модели тоже не засчитывается: ни неудачи, ни успеха, ни временного запрета, и прежний временный запрет он не снимает. Лимит провайдера — не дефект модели, а запрет модели останавливал бы и других агентов — например, второй роутер, выбравший ту же модель. Статус смотрит только конец stderr: kilo пишет туда вывод инструментов и каждый 429, после которого сам повторил запрос и продолжил работу, — запуск, упавший потом на другой ошибке, остаётся сбоем. Агента на время ограничения выводит из выбора health-реестр: CLI-агента — общее правило provider-rate-limit в agent-health-rules.yaml с тем же шаблоном, 15 минут, если у агента нет своего правила на этот текст; агента kind: http и агента с командой на шаге model_io — ошибка клиента модели rate_limit (запись model-client-rate_limit), 1 час.

Запреты действуют только на стадии исполнителя. Неудача — empty и artifacts_failed; успех — пройденный контроль артефактов, даже если ревью потом не пройдено.

| Запрет | Область | Условие | Срок | |--------|---------|---------|------| | постоянный, правило 1 | модель + тип тикета | с последнего снятия неудач ≥ 3, успехов 0 | до снятия человеком | | постоянный, правило 2 | модель + тип тикета | с последнего снятия не меньше 10 оценённых запусков, среди последних 10 успехов < 3 | до снятия человеком | | временный | модель целиком | последний запуск модели, кроме stopped и throttled, — сбой | TTL сработавшего правила agent-health-rules.yaml, иначе 1 час | | временный «модель недоступна» | модель целиком | три запуска подряд со сбоем процесса, не прошедшие контроль артефактов (error, timeout, network_error — в том числе снятый по перегрузке провайдера, классы которого приходят и как error, и как network_error), без успеха между ними; stopped, throttled, auth_error и aborted серию не удлиняют и не прерывают | 1 час × 2^(серия − 1), не больше суток, от последнего запуска серии |

Тип тикета — task_type контекста или префикс id тикета (IMPL-12 → impl). При выборе агента ключ модели — по записи конфига: у kilo-агента модель из -m без провайдера, как хранит kilo (-m prov/x → x), у прочих — значение --model / -m как есть, у kind: http — поле model, иначе id агента. Агент исполнителя с запрещённой моделью пропускается (в логе agent … skipped: model "…" — …); все агенты под запретом — стадия возвращает blocked (all_banned) с причиной, и тикет уходит по goto.blocked. Роутер kilo по ключу роутера не отсеивается: пока агент работает, раннер проверяет ответившие модели и, если ответила запрещённая, снимает агента (MODEL_BANNED agent=… model=…) и сразу берёт следующего агента стадии, даже если файлы изменены: исполнитель пишет прогресс в тикет по ходу работы. Тот же агент заново не запускается — модель выбирает его роутер, и перезапуск запрещённую модель не обходит; нездоровым агент не помечается, в следующей попытке тикета он снова в списке. Запрещённая модель, ответившая уже после последней проверки, тоже даёт MODEL_BANNED и переход к следующему агенту: её результат обычным запуском не засчитывается.

Снятие запрета — событие unban (функция recordUnban модуля, инструмент MCP — отдельно):

{"type":"unban","ts":"2026-09-27T10:00:00.000Z","model":"<модель>","ticket_type":"impl","reason":"<почему>"}

С ticket_type снимается постоянный запрет пары, без него — временные запреты модели (за сбой и «модель недоступна»; серия считается заново с этого события). Запрета нет — отказ, строка не пишется. Таблица по модели и типу тикета (число запусков по градациям, доля пройденного контроля, доля принятых на ревью, запреты с доказательствами) — statsTable. Модуль экспортируется пакетом: workflow-ai/lib/agent-runs.mjs. Реализация — src/lib/agent-runs.mjs, подсчёт изменённых файлов — src/lib/agent-run-changes.mjs.

Пулы моделей

Пул — запись агента в pipeline.agents с полем models. Модели берутся из вывода команды списка, в пул входят те, чей полный id совпал с маской. В списке стадии пул занимает одно место, и на это место раннер ставит конкретную модель — участника пула. Каждый запуск поэтому — конкретная модель: в журнале запусков agent — id участника, model — ключ его модели.

В поставляемом configs/pipeline.yaml стадия execute-task начинается с двух пулов вместо роутеров kilo/kilo-auto/free и kilo/openrouter/free:

  • free-kilo — kilo/*:free и kilo/stealth/space-bunny-alpha;
  • free-openrouter — openrouter/*:free без nvidia/nemotron-3.5-content-safety:free.

Стадия execute-task — стадия с выбором модели (раздел «Выбор модели стадии»): её пулы раскрываются в участников, и участников вместе с остальными агентами списка упорядочивает селектор стадии. Поэтому models.selector и models.scores у поставляемых пулов нет (до 1.17.0 — селектор jev-select):

    free-openrouter:
      command: "kilo"
      args: ["-m", "{model}", "--agent", "code", "--print-logs", "--log-level", "ERROR", "run", "--auto"]
      workdir: "."
      capabilities: [text]
      models:
        list: ["node", ".workflow/src/scripts/kilo-models-list.js"]
        match:
          - '^openrouter/nvidia/nemotron-3-ultra-550b-a55b:free$'
          - '^openrouter/(?!nvidia/nemotron-3\.5-content-safety:free$)[^/]+/[^/]+:free$'
        max_per_attempt: 3
        gate: ["node", ".workflow/src/scripts/free-quota-gate.js", "--url", "https://openrouter.ai/api/v1/key", "--key-file", "~/.workflow/secrets/openrouter.key", "--timeout", "10"]

Ниже — пул на стадии без selection: одно место списка, курсор попытки, селектор и оценки пула. На стадии с selection отличия такие: место-пул заменяется участниками, прошедшими фильтры, у каждого свой уровень и признак «бесплатная»; шлагбаум опрашивается один раз в начале попытки и перед каждым запуском участника, кроме участника, запускаемого первым в попытке; models.selector и models.scores не вызываются, строки SELECT и SCORES нет. Селектор или оценки у пула, все списки которого под выбором, validateConfig отклоняет как мёртвый конфиг.

kilo-free (роутер kilo/kilo-auto/free) остаётся в конфиге обычным агентом target_agents тестов скилов execute-task и deep-research; в списках стадий его нет.

Поля пула

| Поле | Значение | Обязательно | Смысл | |------|----------|-------------|-------| | models.list | непустой массив непустых строк | да | команда списка моделей и её аргументы | | models.match | непустой массив строк — выражения RegExp без флагов и модификаторов ((?i) в JS не компилируется, (?i:…) — только с Node 23) | да | модель входит в пул, если её полный id совпал хотя бы с одним выражением; порядок участников — по индексу первого совпавшего выражения, при равенстве — порядок вывода команды | | models.max_per_attempt | целое ≥ 1, по умолчанию 3 | нет | сколько участников пула пробовать за одну попытку тикета | | models.selector | id агента с командой: не kind: http и не пул | нет | агент-селектор участника под тикет | | models.scores | непустой массив непустых строк, только вместе с selector | нет | команда оценок кандидатов для промпта селектора | | models.gate | непустой массив непустых строк | нет | команда-шлагбаум места пула | | {model} в args | ровно одно вхождение у пула, ни одного у агента без models | да | место полного id участника |

Встроенного фильтра «бесплатная» нет: состав задаёт только маска, ею можно собрать и платный пул. Исключить модель — отрицательным просмотром вперёд, как content-safety выше. Свойства модели (картинки, инструменты, цена) пул не режут.

Проверка конфига при старте (validateConfig) отклоняет:

  • models у kind: http, models не объектом, id пула с @, id агента вида <пул>@… — такие id заняты участниками пула;
  • неверные list, match (в том числе выражение, которое не компилируется, и модификатор (?i:…)), max_per_attempt, selector, scores, gate, а также scores без selector;
  • selector или scores у пула, все списки которого — на стадиях с selection;
  • {model} в args пула не ровно один раз и {model} у агента без models;
  • пул в одиночном stage.agent (у стадии без списка agents);
  • пул в списках стадии с model_io: agents, agents_by_type.<тип>.agents и default_agents у стадии без своего списка.
Configuration validation failed:
  - Agent "free-kilo" is a model pool: args must contain {model} exactly once, found 2
  - Stage "ask" has model_io, but lists model pool "free-kilo" in agents: model pools are not allowed on model_io stages

Пул в target_agents тестов скилов (index.yaml, --agent или файл кейса) run-skill-tests.js отклоняет до первого кейса, status: error: Agent(s) '<пул>' are model pools (models): пул моделей нельзя указывать в target_agents, назовите конкретного агента (для файла кейса — с in target_agents of case <id>). Пул судьёй (judge_agent) или в его escalate_to — тоже ошибка до прогона.

Команда списка

Раннер знает только интерфейс stdout команды списка, форматов kilo он не разбирает:

  • строка, начинающаяся с {, — JSON {"id": "<provider/model>", "capabilities": ["multimodal"], "note": "<текст>"}; обязателен только строковый id, note идёт только в строку POOL;
  • любая другая непустая строка — id без метаданных, поэтому командой списка может быть и ["kilo", "models"];
  • испорченная строка (JSON не разбирается, нет строкового id) — WARN и пропуск; повтор id — первое вхождение.

Способности участника — capabilities пула вместе с capabilities строки.

Адаптер src/scripts/kilo-models-list.js запускает kilo models --verbose и печатает все модели kilo без фильтрации, по JSON-строке на модель. capabilities: ["multimodal"] — у модели с картинкой на входе. note — цена входа и выхода, вызов инструментов и признак isFree как есть, без единиц. Сбой kilo — выход 1, причина в stderr. Испорченный объект модели или объект без закрывающей } — WARN в stderr, эта модель пропускается, соседние остаются.

{"id":"kilo/qwen/qwen3.8-27b:free","capabilities":["multimodal"],"note":"in=0 out=0 tools=true free=true"}

Раскрытие пула и участники

Пулы раскрываются один раз на процесс раннера, до первой стадии. Команда списка запускается так же, как агенты:

  • рабочий каталог — корень проекта, окружение — с ~/.workflow/agent.env;
  • на Windows — через shell, кроме команды node;
  • через 60 с команда снимается, при остановке пайплайна (SIGINT/SIGTERM) — сразу;
  • одинаковая команда у нескольких пулов (равные массивы) запускается один раз;
  • хвост её stderr идёт в лог строками WARN models.list stderr: ….

На каждый пул — строка POOL с составом. Она — единственная защита от платной модели, случайно попавшей в маску:

POOL agent="free-kilo" members=15 [kilo/stealth/space-bunny-alpha in=0 out=0 tools=true free=true, kilo/nvidia/nemotron-3-ultra-550b-a55b:free in=0 out=0 tools=true free=true, …]
POOL agent="free-openrouter" members=0 (exit 1)

Причина пустого пула — exit <код>, timeout 60s, aborted (остановка пайплайна), spawn error … или no match. Модель, чей id участника уже занят агентом конфига, пропускается с WARN. Пул без участников стадию не роняет: его место не выбирается, а стадия получает обычные no_capable_agent, all_unhealthy или all_banned. У no_capable_agent в этом случае приписка model pools without members: …. Id модели не под ^[A-Za-z0-9][A-Za-z0-9._:/~-]*$ пропускается с WARN: на Windows аргументы агента идут через cmd.exe.

Участник — <пул>@<полный id>, например free-kilo@kilo/qwen/qwen3.8-27b:free. Этот id видят журнал запусков (поле agent), health-реестр, строки Agent selected: и START, история работы тикета. Запись участника — копия записи пула: {model} в args заменён полным id, поля models нет, pool — id пула.

Ключ модели участника — полный id без провайдера (poolside/laguna-s-2.1:free). Он общий для обоих маршрутов и совпадает с прежними ключами журнала; по нему действуют запреты модели. Пока механика роутеров не удалена, ключ kilo-запуска в журнале берётся из базы kilo (раздел «Фактическая модель kilo-агента»). Поэтому участник, снятый до первого шага (первый 429 приходит через 16–27 с), пишет в журнал model: null.

Выбор участника

Место пула проходит фильтры стадии, если их проходит хотя бы один участник. Кандидаты места — участники в порядке маски, которые:

  1. покрывают required_capabilities тикета;
  2. здоровы по health-реестру — ключ id участника, то есть маршрут;
  3. не под запретом модели — на стадии со скилом execute-task;
  4. не пробовались в этой попытке;
  5. ещё не запускались на этом тикете: нет события run с тем же ticket и ключом модели участника. Фильтр мягкий: если таких нет, кандидаты — прошедшие п. 1–4.

Место уходит из попытки, когда:

  • пробовано max_per_attempt его участников;
  • подходящих непробованных участников не осталось;
  • шлагбаум закрыл место.

Неудача участника с пустым diff — следующий участник того же места, затем следующее место списка. При изменённых артефактах fallback, как и раньше, не идёт. Строка выбора прежняя:

Agent selected: free-kilo@kilo/qwen/qwen3.8-27b:free (attempt 1, compatible=[free-kilo, free-openrouter, gpt-luna, …])

Правила health участника в agent-health-rules.yaml ищутся по id пула: состав пула известен только после раскрытия, в файле его не описать. Пометка нездоровья и проверка здоровья — по id участника. В поставляемом файле free-kilo и free-openrouter наследуют правила kilo-free (extends): участник, получивший 429 провайдера, снимается онлайн и выходит из выбора на 15 минут.

Шлагбаум

models.gate — команда, которую раннер запускает перед каждым запуском участника пула, до селектора (на стадии с selection — ещё и одним проходом в начале попытки, и тогда участник, запускаемый первым в попытке, опирается на ответ прохода: «Выбор модели стадии», шаг 2). Запуск — по правилам команды списка, но с таймаутом 15 с. Ответ — блок RESULT и выход 0:

---RESULT---
status: open
remaining: 37
---RESULT---
  • open — участник запускается.
  • closed — место закрыто до ближайших 00:00 UTC: участники не запускаются, селектор не вызывается, стадия берёт следующее место списка. Закрытие — запись health-реестра по id пула (правило pool-gate-closed, TTL until_utc_midnight, не меньше 30 минут), так что оно держится во всех стадиях и после перезапуска раннера.
  • Выход ≠ 0, таймаут, нет блока RESULT или status — error: WARN, место не закрывается.
  • Остановка пайплайна снимает шлагбаум сразу; после неё ни селектор, ни участники не запускаются.

Строка лога на каждый вызов:

GATE agent="free-openrouter" status=open remaining=37 until=- duration_ms=1085
GATE agent="free-openrouter" status=closed remaining=0 until=2026-09-29T00:00:00.000Z duration_ms=1100

Скрипт src/scripts/free-quota-gate.js --url <https://…/key> --key-file <путь> [--timeout <с>] делает один GET с ключом, без повторов, с таймаутом по умолчанию 10 с. Он читает data.free_model_daily_requests — дневной счётчик бесплатных запросов аккаунта OpenRouter: один на все модели :free, 50 в сутки при покупках меньше 10 кредитов, сброс в 00:00 UTC.

  • remaining > 0 — status: open, ≤ 0 — status: closed; кроме remaining скрипт печатает used и limit.
  • Поля нет, ответ не JSON, ошибка сети или HTTP не 2xx — status: error, error_class, error, выход 1.

Чтение счётчика модель не вызывает и запрос не тратит. В поставляемом конфиге шлагбаум только у free-openrouter: маршруты kilo/* счётчик аккаунта OpenRouter не тратят.

Селектор

models.selector — id агента с командой, который упорядочивает кандидатов места под тикет. Раннер вызывает его один раз на место пула в попытке — при первом выборе участника этого места, если:

  • в контексте есть тикет;
  • кандидатов не меньше двух;
  • селектор здоров по health-реестру;
  • шлагбаум пула не закрыл место.

Иначе вызова нет, берётся порядок маски. Следующий участник того же места в этой попытке — следующий по ранжиру, без нового вызова; в следующей попытке — новый вызов.

Промпт идёт через stdin: инструкция и JSON-блок в ограждении ```json, кандидатов — первые 10 в порядке маски. DoD — текст секции «Критерии готовности (Definition of Done)» тикета, не больше 4000 символов:

{
  "pool": "free-kilo",
  "ticket": {"id": "IMPL-12", "type": "impl", "title": "…", "dod": "…"},
  "candidates": [
    {"id": "kilo/qwen/qwen3.8-27b:free", "capabilities": ["text", "multimodal"], "note": "in=0 out=0 tools=true free=true", "scores": {"intelligence": 33.7, "coding": 68.1, "agentic": 45.8}},
    {"id": "kilo/stealth/space-bunny-alpha", "capabilities": ["text", "multimodal"], "note": "in=0 out=0 tools=true free=true", "scores": null}
  ],
  "scores_citation": "Source: Artificial Analysis (artificialanalysis.ai) via OpenRouter (openrouter.ai/rankings)."
}

Ответ — id кандидатов через запятую, от самого подходящего:

---RESULT---
ranking: kilo/qwen/qwen3.8-27b:free, kilo/stealth/space-bunny-alpha
confidence: 0.6
cost_usd: 0.00007518
---RESULT---

Id не из кандидатов пропускаются, кандидаты вне ранжира идут после него в порядке маски. Ошибка — status: error, error_class, error и выход 1. Строка лога на каждый вызов:

SELECT agent="free-kilo" selector="jev-select" candidates=10 ranked=10 member="kilo/inclusionai/ling-3.0-flash-fin:free" fallback=none cost_usd=0.00007518 duration_ms=1286

| fallback | Когда | Участник | |------------|-------|----------| | none | в ранжире есть хотя бы один кандидат | первый по ранжиру | | unknown_id | поля ranking нет или в нём ни одного id кандидатов | порядок маски | | error | выход ≠ 0 или status: error | порядок маски | | timeout | селектор снят через 60 с | порядок маски |

Сбой помечает селектор в health-реестре по классу ошибки: auth и rate_limit — 1 час; server, timeout, network — 5 минут; прочие классы не помечают. Селектор, снятый онлайн-сканом по своему правилу health, помечается на класс и TTL этого правила. Нездоровый селектор не вызывается, в лог идёт INFO pool "…": selector "…" unhealthy in registry — mask order. Журнал запусков и история тикета вызов селектора не пишут.

Селектор пула в поставляемом конфиге 1.16 — jev-select, модель решений Jev через обёртку src/scripts/decisions-select.js --model <id> --url <https://…/decisions> --key-file <путь> [--timeout <с>] (с 1.17.0 пулы стоят только на стадии с выбором модели, и jev-select из конфига убран; обёртка та же, у селектора стадии — режим models):

  • один вопрос decisions типа choice, варианты — кандидаты;
  • ранжир — все кандидаты по убыванию вероятности, при равенстве — меньший индекс;
  • --timeout 15: все повторы клиента укладываются в 3 × 15 + 6 = 51 с, меньше 60 с раннера.

Цена — usage.cost ответа: живой вызов 2026-09-28 с 10 кандидатами — $0.000075. Вызовов не больше одного на место пула в попытке — до 12 на тикет при двух пулах и 6 попытках.

Оценки

models.scores — команда оценок кандидатов для промпта селектора, задаётся только вместе с selector. При раскрытии пула раннер пишет в её stdin полные id участников, по одному на строку, и читает из stdout один JSON:

{"as_of":"2026-09-27T00:01:09.591Z","citation":"Source: Artificial Analysis (artificialanalysis.ai) via OpenRouter (openrouter.ai/rankings).","scores":{"kilo/qwen/qwen3.8-27b:free":{"intelligence":33.7,"coding":68.1,"agentic":45.8}}}

Оценки пул не режут и не переупорядочивают — это только вход селектора. Одинаковая команда у нескольких пулов запускается один раз. Сбой команды — кандидаты без оценок ("scores": null), селектор всё равно вызывается. Строка лога на пул:

SCORES agent="free-kilo" scored=8/15 as_of="2026-09-27T00:01:09.591Z" citation="Source: Artificial Analysis (artificialanalysis.ai) via OpenRouter (openrouter.ai/rankings)."
SCORES agent="free-openrouter" scored=0/16 (exit 1)

Скрипт src/scripts/model-scores.js --benchmarks-url <url> --models-url <url> --key-file <путь> [--cache <путь>] [--timeout <с>]:

  • Источник — оценки Artificial Analysis (intelligence, coding, agentic) из GET https://openrouter.ai/api/v1/benchmarks?source=artificial-analysis и каталог GET https://openrouter.ai/api/v1/models, оба запроса с ключом. Адреса — только из аргументов.
  • Сопоставление: полный id без первого сегмента (kilo/qwen/qwen3.8-27b:free → qwen/qwen3.8-27b:free) → запись каталога с тем же id → её canonical_slug → строка оценок с тем же model_permaslug. Участника без записи каталога или без оценок в выводе нет.
  • Кэш — --cache, по умолчанию ~/.workflow/cache/model-scores.json (или $WORKFLOW_HOME/cache/…), общий для проектов машины. Оба GET идут заново, когда файла нет, он не читается или другой версии; кэшу 24 часа или дата загрузки в будущем (сбитые часы); ключа участника нет в каталоге кэша и кэшу не меньше часа. Загрузка не удалась — прежний кэш и WARN в stderr, кэша нет — выход 1. Запись — через временный файл и rename.
  • --timeout — на каждый GET, по умолчанию 20 с.

Атрибуция данных оценок — meta.citation источника. Она хранится в кэше, печатается в строке SCORES и идёт в промпт селектора:

Source: Artificial Analysis (artificialanalysis.ai) via OpenRouter (openrouter.ai/rankings).

Обнуление истории модели

Событие reset журнала запусков обнуляет историю модели:

{"type":"reset","ts":"2026-09-28T12:00:00.000Z","model":"poolside/laguna-s-2.1:free","reason":"<почему>"}

Запуски модели, записанные в журнале раньше её последнего reset, выпадают из градаций. «Раньше» — по позиции в журнале, как у unban. Вместе с градациями такие запуски уходят из постоянных и временных запретов и из statsTable, то есть из MCP get_model_stats.

  • Окна контроля и ревью строятся по-прежнему со всеми запусками: контроль исключённого запуска не засчитывается соседнему.
  • Запуски с model: null не обнуляются.
  • Журнал только дописывается.
  • Фильтр 5 выбора участника смотрит сырые события, reset на него не влияет.

Запись — функция recordReset(projectRoot, {model, reason}) модуля журнала, рядом с recordUnban. Результат — {ok: true, event} или {ok: false, code, error} с кодами BAD_INPUT, READ_FAILED, NO_RUNS (в журнале нет запуска с этим ключом — защита от опечатки) и WRITE_FAILED. Скрипт запускается из корня проекта:

node .workflow/src/scripts/reset-model-history.js --model poolside/laguna-s-2.1:free --reason "история роутеров"

Успех — RESULT status: ok, model: <ключ>, выход 0. Ошибка — status: error, code, error, выход 1: коды recordReset, BAD_INPUT за неверные аргументы и NO_PROJECT, если корень проекта не найден. MCP покажет обнуление после обновления зависимости workflow-ai в workflow-mcp.

Выбор модели стадии

Стадия с полем selection (в поставляемом конфиге — execute-task) выбирает исполнителя не курсором попытки, а по силе моделей и сложности тикета. Правила стейкхолдера:

  1. «Самая СЛАБАЯ модель, которой ещё ДОСТАТОЧНО для тикета».
  2. Селектор видит только кандидатов, прошедших все фильтры: способности, health-реестр и шлагбаум пула, запреты моделей, пробованные в попытке, max_per_attempt пула, нижнюю границу тикета. Отсеянных он не видит, и их не запускает ни один порядок обхода.
  3. Никаких данных моделей в конфиге и коде: ни уровней у агентов, ни соответствия агент → оценка, ни порогов. Состав стадии меняется в любой момент (правка конфига, состав пулов по kilo models) — уровни считаются по оценкам заново.
  4. Приоритет у бесплатных. Бесплатная модель kilo не становится платной оттого, что её нет в каталоге OpenRouter или каталог называет цену: для модели kilo решает флаг isFree самого kilo. «Неизвестно» — платная, но только если ни один источник не говорит «бесплатная».

Стадия без selection и тип тикета с agents_by_type.<тип>.selection: false выбирают агента прежним курсором: код курсора, его порядок и строки лога — как до 1.17.0. Изменился вход фильтра запретов: с 1.17.0 ответ агента status: blocked (result_status: blocked) получает градацию refused на любом запуске execute-task и неудачей в запретах не считается, а раньше такой ответ без изменённых файлов был empty — неудачей. Поэтому и стадия без выбора может запустить модель, которую 1.16.7 после трёх таких отказов запретил бы (раздел «Журнал запусков и отсев моделей»).

Запись в pipeline.yaml

    execute-task:
      skill: execute-task
      counter: task_attempts
      selection:
        selector: jev-place
        scores: ["node", ".workflow/src/scripts/model-scores.js", "--benchmarks-url", "https://openrouter.ai/api/v1/benchmarks?source=artificial-analysis", "--models-url", "https://openrouter.ai/api/v1/models", "--key-file", "~/.workflow/secrets/openrouter.key", "--anchor-high", "z-ai/glm-5.2", "--anchor-medium", "anthropic/claude-4.5-haiku", "--overrides", ".workflow/config/model-levels.yaml"]
        escalate_on: [blocked]
        levels:
          - "mechanical edit from a precise recipe, 1-2 files, DoD checked by one command"
          - "routine change in a known place, clear DoD, up to ~5 files"
          - "change needing mcp/browser checks, or debugging with a known cause"
          - "unknown root cause, several modules, repeat after a failed attempt"
          - "regression after others failed, architecture, ambiguous DoD"
      agents: [free-kilo, free-openrouter, gpt-luna, gemini-flash-lite, claude-haiku, claude-sonnet, claude-opus, deepseek-flash]
      agents_by_type:
        coach: { agents: [claude-opus, gpt-sol, gemini-flash, deepseek-pro] }
        qa:    { agents: [claude-haiku, gpt-luna, gemini-flash-lite, claude-sonnet, claude-opus] }

| Поле | Значение | Смысл | |------|----------|-------| | selection.selector | id агента с командой: не kind: http и не пул | селектор: требуемый уровень и порядок кандидатов под тикет | | selection.scores | непустой массив непустых строк | команда фактов: оценка и бесплатность моделей кандидатов | | selection.levels | 2..10 непустых строк, от слабого к сильному | рубрика сложности тикета; её длина N — число уровней кандидатов | | selection.escalate_on | подмножество [blocked], необязательно | агент сам ответил blocked — следующий строго сильнее в той же попытке | | agents_by_type.<тип>.selection | только false | тип без выбора модели: прежний курсор |

validateConfig при старте отклоняет: другие ключи selection; selection у стадии не со скилом execute-task, с model_io или с одиночным agent; селектор не агент конфига, kind: http или пул; неверные scores, levels, escalate_on; agents_by_type.<тип>.selection не false или у стадии без selection; селектор и оценки пула, все списки которого под выбором.

Списки agents_by_type наследуют выбор стадии. Все кандидаты стадии — агенты списков под выбором, пулы — участниками.

Факты: оценки и бесплатность

Раннер не читает форматы OpenRouter и kilo. Команду selection.scores он запускает один раз на процесс, сразу после раскрытия пулов, по правилам команд пула: корень проекта, окружение агентов, таймаут 60 с, снятие остановкой пайплайна. Равные команды нескольких стадий — один запуск. На stdin — строка на каждую модель кандидатов:

{"id":"kilo/qwen/qwen3.8-27b:free","host":"kilo"}
{"id":"claude-haiku-4-5-20251001","host":"claude"}

id — модель, как её вызывает агент (-m / --model из args, у kind: http — поле model); host — хост рельс агента (kilo, claude или null). Агент без id модели на вход не идёт: он без оценки и платный. Ответ — один JSON:

{"as_of":"…","citation":"…","levels":{"high":33.7,"medium":16.9,"anchors":{"high":"…","medium":"…"}},
 "models":[{"id":"deepseek/deepseek-flash","host":"kilo","resolved":"deepseek/deepseek-v4.1-flash-20260910","intelligence":39.5,"coding":null,"agentic":null,"free":false,"free_source":"unknown","level":"high","level_source":"feed"}]}

levels — пороги градации, сосчитанные по опорам --anchor-high и --anchor-medium (раздел «Тег минимального уровня у места»); опоры не заданы — пороги null и WARN.

Сбой команды (выход ≠ 0, таймаут, не JSON, нет массива models) стадию не роняет: все кандидаты без оценок; бесплатны только id с :free на конце (флагов kilo и цен каталога нет), остальные платные. Если ни у одного выжившего кандидата попытки нет оценки — так при сбое команды и при каталоге OpenRouter, не загруженном без кэша, — уровни, граница и селектор не работают (все на уровне 1): попытка идёт прежним курсором по местам списка с теми же фильтрами (место (попытка − 1) % число мест, пул — одним местом). Без тегов порядок и курсор при этом прежние (числовых оценок нет — и градации нет); с тегами места упорядочены по ступени, курсор вращается внутри лучшей ступени, а слабые ступени вступают в дело, только когда лучшие исчерпаны (excludeAgents): уровень берётся из факта level (поправка задаёт его и без числовой оценки). Селектор не вызывается (fallback=skipped:no_scores), в журнале selection: cursor. Шлагбаум — как на стадии с выбором: один проход в начале попытки; участник открытого пула, запускаемый первым в попытке, его не опрашивает, любой запуск участника после другого запуска попытки — опрашивает. Порядок запусков совпадает с порядком стадии без selection (без тегов), пока проход не закрыл шлагбаум: закрытый проходом пул выбывает из мест до расчёта курсора, а стадия без выбора узнаёт о закрытии, только дойдя курсором до пула. Эскалации нет: все на уровне 1, blocked агента уходит стадии (ESCALATE … to="none"), как у курсора.

src/scripts/model-scores.js — та же команда оценок пула, режим выбирается по входу: все строки начинаются с { — режим фактов, ни одна — прежний режим пула (вывод прежний), смесь — bad input и выход 1. Кэш — прежний файл ~/.workflow/cache/model-scores.json версии 1 с добавочным ключом models: цена pricing.prompt/pricing.completion, created и alias_target записей каталога. Кэш без models режим фактов загружает заново. Сбой загрузки без кэша — WARN, оценок и цен нет, флаги kilo остаются; выход 0.

Бесплатность — первое сработавшее правило:

  1. хост kilo, kilo знает ровно этот id, и его isFree — true или false: решает флаг поставщика (free_source: kilo);
  2. id оканчивается на :free (openrouter_id);
  3. у прямой записи каталога OpenRouter (правило R1 ниже, а не новейшей версии), а у id без прямой записи — у члена семейства R3 с датой id, числовые цены входа и выхода: бесплатна при обеих нулевых (openrouter_price); новейший член семейства и цель псевдонима ~…-latest цену не дают (claude-haiku-4-5-20251001 — цена anthropic/claude-4.5-haiku-20251001);
  4. иначе платная (unknown).

Флаги kilo — kilo models --verbose (модуль src/lib/kilo-catalog.mjs, общий с адаптером списка пула), только если во входе есть хост kilo, параллельно с GET каталога и без своего кэша: флаг должен быть свежим. Сбой kilo — WARN, флаги только по OpenRouter. Цены kilo (cost.*) не читаются: kilo models --verbose 2026-09-28 (789 моделей) даёт cost.input = cost.output = 0 платным openai/gpt-5.6-luna, -terra и -sol без isFree — по цене kilo три платных агента стали бы бесплатными. В том же списке 16 моделей с isFree: true, одна из них без :free (kilo/stealth/space-bunny-alpha), и kilo/stealth/claude-opus-4.8 с isFree: false. У хоста claude флага поставщика нет: его модели — записи anthropic/* каталога с ценой больше нуля.

Оценка — intelligence Artificial Analysis; coding и agentic идут селектору как сведения. Термины: маршрут — первый сегмент id из трёх и больше сегментов; производитель — сегмент перед именем модели; дата — хвост -YYYYMMDD (у id ещё -MMDD); ключ семейства — имя без даты и варианта :…, короткие числа подряд слиты в версию (4, 5 → 4.5).

  • R1, прямая запись — первый из [id, id без маршрута], который есть в каталоге; у id без производителя (модель CLI claude, claude-opus-5) — записи с тем же последним сегментом, ровно одного производителя.
  • R2, недатированный id — самая новая запись со slug без даты, равным slug прямой записи без даты: каталог держит под deepseek/deepseek-v4-pro slug -20260423 (intelligence 30.4), а новее есть -20260813 (36). Датированный id держит свою версию.
  • R3, id без записи — сначала указатель OpenRouter на новейшую версию семейства ~<производитель>/<имя>-latest (alias_target), затем R2: deepseek/deepseek-flash → deepseek/deepseek-v4.1-flash, 39.5. Иначе — члены семейства одного производителя (у id без токена версии версии не сравниваются): при дате в id — член с этой датой (claude-haiku-4-5-20251001 → anthropic/claude-4.5-haiku-20251001, 16.9), иначе самый новый. Члены разных производителей — оценки нет, WARN.
  • R4 — у выбранной версии нет intelligence — берётся следующая по новизне версия семейства, у которой он есть; resolved называет slug, чья оценка взята. Нет ни у одной — без оценки.

Кандидаты поставляемого execute-task 2026-09-28: claude-haiku 16.9, claude-sonnet 38.2, claude-opus 50.8, gpt-luna 37.3, gpt-terra 42.1, gpt-sol 47, gemini-flash-lite 22.2, gemini-flash 40.9, deepseek-flash 39.5 (через псевдоним), deepseek-pro 36; все 29 участников пулов бесплатные (флаг kilo или :free), из них 18 без оценки; платные — claude-*, gpt-luna и gemini-flash-lite по цене каталога и deepseek-flash (unknown).

Уровни

Уровни — относительные, по оценкам выживших кандидатов попытки: min и max — наименьшая и наибольшая оценка, N — число текстов levels, ширина полосы w = (max − min) / N. Уровень оценки s: без оценки — 1 (низ, наверх не поднимается); ниже min — 0 (только для нижней границы); w = 0 — N; иначе min(N, floor((s − min) / w) + 1). Полосы и уровни замораживаются на попытку: требуемый уровень, граница и эскалация говорят на одной шкале. Следующая попытка считает их заново по своим выжившим.

Тег минимального уровня у места

Запись списка агентов стадии может нести тег: free-kilo[medium] — «это место, но не слабее уровня medium». Здесь речь о градации силы модели (абсолютной), а не о полосах уровня попытки выше. Тег работает только на стадии с выбором модели: validateConfig отклоняет тег в списке стадии без selection (и у типа с selection: false), неизвестное имя уровня и тег у места, которого нет в agents.

Градация — low < medium < high, тег означает «не ниже». Пороги — оценки двух опорных моделей из тех же данных, что и у кандидатов: команда фактов получает --anchor-high <модель> и --anchor-medium <модель>; high — оценка не ниже опоры высокого уровня, medium — строго выше опоры среднего, low — не выше неё. Опора не названа, не оценена или опоры стоят в обратном порядке — порогов нет, вычисляемая градация молчит (WARN), но строковая поправка из model-levels.yaml уровень сохраняет (осознанно). Опора, которой нет в каталоге кэша, заказывает его обновление наравне с ключом кандидата. Чисел порогов в коде и конфиге нет: обновился фид — поехали и пороги.

Порядок очереди на месте с тегом (мягкий: кандидат ниже уровня не выбрасывается, а ждёт своего часа):

  1. уровень подтверждён и не ниже требуемого — прежними ключами обхода (бесплатные ≥ R, платные ≥ R, хвост ниже R);
  2. уровня нет вовсе (нет ни оценки, ни поправки) — неоценённые обычно самые свежие модели, до которых фид не дошёл, и слабыми они не считаются; при недействительных опорах сюда попадает и модель с числовой оценкой (вычисляемой градации нет) — середина очереди;
  3. уровень подтверждён и ниже требуемого — последний резерв. В ступенях 1–2 — уровень по убыванию, бесплатные раньше, незапускавшиеся на тикете раньше, затем ранжир селектора и порядок списка. Место без тега ступени не имеет, его порядок не меняется. Ступени видны в строке LEVELS вместе с порогами, as_of фида и числом кандидатов в каждой.

Градация: откуда берутся оценки и уровень

Ответ команды фактов несёт по каждой модели level (low/medium/high или null) и level_source — откуда уровень: override — поправка из своей практики, older — оценка младшей версии семейства (правило R4), feed — оценка самой модели; null — уровня нет. Уровень и источник каждой модели видны в строке FACTS у её кандидата (суффикс level=<уровень>(<источник>), например level=high(override)).

Фид знает не все модели: у части нет ни своей оценки, ни оценки младшей версии. Уровень или свою оценку для таких моделей задают руками — файл configs/model-levels.yaml (в проекте виден как .workflow/config/model-levels.yaml), команда получает его аргументом --overrides. Ключ — модель в любой привычной форме: полный id, он же без маршрута, имя модели или slug оценки; голое имя без производителя принимается, если на это имя в каталоге не больше одного производителя: один — поправка применяется, ни одного (модели в каталоге нет — так задают уровень новинкам) — тоже, два и больше — ключ неоднозначен, WARN, и поправка не применяется. Значение — имя уровня или число (своя оценка: уровень считается теми же порогами). Поправка бьёт фид.

Попытка

Одна попытка — один вызов executeWithFallback:

  1. Выжившие — фильтры курсора (способности, health-реестр — у пула и id пула, запреты моделей, пробованные в попытке, max_per_attempt пула), те же типизированные blocked (no_capable_agent, all_unhealthy, all_banned); места-пулы заменяются участниками, прошедшими фильтры, пул в списке дважды — один раз, на первой позиции. Пересчитываются на каждом витке.
  2. Шлагбаумы — один проход в начале попытки, до селектора и пока нет остановки: у каждого пула с models.gate и хотя бы одним выжившим участником. closed — место закрыто (health-реестр, как раньше) и выбывает из попытки; open — участник этого пула, запускаемый первым в попытке, шлагбаум не опрашивает; после первого запуска попытки (чьего угодно) ответ прохода устарел, и опрос идёт перед каждым запуском участника: пока работал другой кандидат, квоту могли израсходовать; error — место открыто.
  3. Уровни — полосы по оценкам выживших после прохода шлагбаумов. Оценки нет ни у одного — шаги 4–6 не выполняются, попытка идёт прежним курсором (skipped:no_scores); без тегов он и вправду прежний, а с тегами места и участники пулов упорядочиваются по ступени, и курсор вращается внутри лучшей непустой ступени (раздел «Тег минимального уровня у места»).
  4. Нижняя граница тикета — из журнала запусков: запуски исполнителя этого тикета с градациями refused, empty, artifacts_failed, review_failed (reset учтён; crashed, crashed_after_work, throttled, stopped, pending не считаются). Оценка запуска — текущий факт агента, агента в фактах нет — поле score события, нет и его — запуск пропускается с WARN. Граница — наибольший уровень таких запусков (без оценки — 1), не выше сильнейшего уровня выживших в начале попытки минус 1: после провала на верхнем уровне верхний уровень берётся снова. Кандидаты с уровнем не выше границы выбывают. Граница читается на каждом витке, поэтому переживает перезапуск раннера и смену тикета.
  5. Селектор — вызов на попытку; сбой вызова повторяется один раз, кроме классов, которые повтор с тем же промптом не исправит. Ответа нет (сбой и после повтора, ответ без уровня, селектор нездоров) — требуемый уровень берётся по complexity тикета среди уровней выживших (simple — нижний, medium — средний, complex — верхний; у тикета без поля — наименьший), строка SELECT_FALLBACK называет причину. Пропуск с причиной: no_ticket, unhealthy (селектор нездоров), stopped, single_candidate (над границей меньше двух), single_level (все на одном уровне); no_scores — ни у одного выжившего нет оценки, попытка идёт прежним курсором (раздел «Факты: оценки и бесплатность»). Ответ даёт требуемый уровень R и ранжир; R не выше границы поднимается до границы + 1. Сбой — выход ≠ 0, status: error, таймаут 60 с, или required_level нет, не целое, вне 1..N (unknown_level, его ранжир всё равно идёт в порядок внутри уровня): R — наименьший уровень выживших, лестница от слабых. Сбой помечает в health-реестре только селектор стадии, по тем же правилам, что селектор пула.
  6. Порядок обхода (R' = max(R, граница + 1)) — на каждом витке из текущих выживших: сначала бесплатные с уровнем ≥ R' по возрастанию уровня, затем платные с уровнем ≥ R' по возрастанию, затем хвост ниже R' по убыванию, в уровне бесплатные раньше. Внутри уровня — ещё не запускавшиеся на тикете, затем место в ранжире, затем порядок списка. Первый в порядке запускается.
  7. Отказ кандидата внутри попытки — те же поводы, что у курсора: ошибка модели, MODEL_BANNED, сбой с пустым diff, шлагбаум закрыт при запуске. Селектор заново не вызывается, следующий — из пересчитанного порядка. Сбой с изменёнными файлами, как раньше, стадию останавливает. Сбои границу не поднимают. Исчерпаны все кандидаты над границей — ошибка стадии (goto.error) и следующая попытка.
  8. Эскалация (escalate_on: [blocked]) — агент сам ответил status: blocked (у blocked раннера всегда есть blocked_reason): событие run записано с result_status: blocked (градация refused), и если есть выживший строго выше уровня отказавшего — он запускается в той же попытке (равный уровень не пробуется), сделанное записано в тикет, как после MODEL_BANNED. Сильнее нет — blocked уходит стадии, в goto.blocked, как раньше.

Пример — FIX-032 PulseProxy (нужен mcp, выжившие claude-haiku 16.9, claude-sonnet 38.2, claude-opus 50.8, N = 5): уровни haiku 1, sonnet 4, opus 5. При R = 1 запускается haiku; его blocked — sonnet в той же попытке, затем opus, затем goto.blocked. Следующий выбор, и в новом процессе, — граница 1: haiku выбывает, первым идёт sonnet.

Контракт селектора

Промпт — инструкция и JSON-блок в ограждении ```json; кандидаты — только выжившие над границей, в порядке лестницы:

{
  "mode": "models", "stage": "execute-task",
  "ticket": {"id": "FIX-032", "type": "fix", "title": "…", "priority": 1, "complexity": "medium",
             "required_capabilities": ["mcp"], "tags": ["…"], "description": "<до 2000>", "dod": "<до 4000>",
             "notes": "<до 1000>", "files": 5, "references": 3, "executor_runs": 1, "floor_level": 1,
             "history": [{"level": 1, "outcome": "refused"}]},
  "levels": ["<рубрика 1>", "…", "<рубрика N>"],
  "candidates": [
    {"id": "free-kilo@kilo/qwen/qwen3.8-27b:free", "kind": "pool_member", "free": true, "level": 3,
     "scores": {"intelligence": 33.7, "coding": 68.1, "agentic": 45.8}},
    {"id": "claude-sonnet", "kind": "agent", "free": false, "level": 4,
     "scores": {"intelligence": 38.2, "coding": 71.5, "agentic": 43.6}}
  ],
  "scores_citation": "…"
}

Заголовок и DoD — как у селектора пула; priority, complexity (по умолчанию medium), required_capabilities, tags, а также notes и число files, references — из frontmatter тикета (context); описание — текст тела до секции DoD. В истории — только уровни и исходы, без id. Цен, причин отсева и долей успеха журнала в промпте нет; кандидат без оценки — "scores": null.

Ответ — блок RESULT: required_level: <1..N>, ranking: <id>, <id>, …, cost_usd. Id не из кандидатов пропускаются. Селектор поставляемого конфига jev-place — обёртка src/scripts/decisions-select.js в режиме models (признак — levels в JSON-блоке; блок без levels — режим пула без изменений) с --level-quantile 0.5. Один вызов decisions, два вопроса:

  • level (score, уровни — тексты рубрики): «Which is the LOWEST level whose models can reliably complete this ticket end to end? …»;
  • pick (choice, варианты — кандидаты id | level k | free|paid | scores …; при двух и больше, первые 255): порядок внутри уровня.

required_level — наименьший уровень k с суммой вероятностей уровней 1..k не меньше --level-quantile (в (0, 1]); сумма не набралась — уровень наибольшей вероятности. ranking — все кандидаты по вероятности pick. Уровней меньше 2 или больше 10, нет кандидатов, повтор id — bad_prompt без запроса. Предел choice в слое оценки — 255 вариантов (score — 10), по документации Decisions API; живым вызовом не проверен. jev-place — отдельный id от селектора пула: сбой одного не помечает другой.

Строки лога

Расширение VS Code и MCP эти строки не читают.

FACTS stage="execute-task" candidates=38 scored=20 free=29 as_of="…" citation="…" [claude-haiku=16.9/paid(openrouter_price)~anthropic/claude-4.5-haiku-20251001 level=low(feed), free-kilo@kilo/stealth/space-bunny-alpha=n/a/free(kilo) level=high(override), …]
FACTS stage="execute-task" scored=0/38 free=0 (exit 1)
LEVELS stage="execute-task" places=["free-kilo[medium]"] above=2 tiers=0:3,1:1,2:0 high=36 medium=16.9 as_of="…"
SELECT_MODEL stage="execute-task" selector="jev-place" candidates=35 levels=5 range=9.9..50.8 floor=0 required_level=3 ranked=35 pick="free-kilo@kilo/qwen/qwen3.8-27b:free" level=3 free=true fallback=none cost_usd=<цена> duration_ms=<мс>
SELECT_FALLBACK stage="execute-task" fallback=timeout complexity=complex level=5 available=1,3,5
ESCALATE stage="execute-task" from="claude-haiku" level=1 to="claude-sonnet" level=4
HEARTBEAT agent_pid=12345 elapsed_s=300 ticket=IMPL-126
RUNNER CRASH reason=uncaughtException exit_code=1 stage="analyze-report" — TypeError: …

FACTS — раз на процесс на стадию; у кандидата с уровнем виден и он сам, и его источник ( level=<уровень>(<источник>)); SELECT_MODEL — раз на попытку (required_level=- у лестницы и курсора; fallback — none, skipped:<причина>, error, timeout, unknown_level); LEVELS — раз на попытку при месте с тегом: места с тегом, сколько их кандидатов прошло уровень (above) и разбивка всех кандидатов по ступеням очереди (tiers=0:…,1:…,2:…), пороги (high, medium) и as_of фида. ESCALATE … to="none" level=- — сильнее нет. Agent selected: <id> (attempt N, compatible=[…]) — прежняя строка, compatible — порядок обхода (у курсора — места). GATE, MODEL_BANNED, agent … skipped: model … — прежние; SELECT agent= и SCORES на стадии с выбором не печатаются. Поля запуска в журнале — раздел «Журнал запусков и отсев моделей».

Откат и известные риски

Откат — убрать selection у стадии и вернуть пулам selector/scores (и агента jev-select): путь курсора — прежний код.

  • Суждение Jev о достаточности не измерено, квантиль 0.5 не откалиброван.
  • Цена и время вызова с choice на ~35 вариантов не измерены — смотреть cost_usd и duration_ms строки SELECT_MODEL в первом живом прогоне.
  • Уровни относительные: смена состава стадии сдвигает их, одна и та же модель сегодня — уровень 2, завтра — 3. Граница берёт текущие уровни.
  • Кандидаты без оценки — на уровне 1: сегодня это 18 из 29 участников пулов, и провал модели без оценки поднимает границу до 1 и убирает всех кандидатов уровня 1.
  • Законный отказ (нет зависимости, нужен человек) тоже эскалирует — не больше одного запуска на каждый более сильный уровень.
  • Приоритет бесплатных запускает сильную бесплатную модель раньше слабой платной — так задумано.

Runner-стадии

В системе поддерживаются различные типы стадий для управления процессами разработки:

Human Gate

human-gate — стадия ручного контроля, которая требует вмешательства человека для продолжения выполнения пайплайна. Используется для критических точек, где необходима ручная проверка или утверждение.

Условия срабатывания:

  • Стадия manual-gate-human блокирует пайплайн до ручного снятия gate
  • Gate снимается через команду move-ticket.js <id> unblock
  • Поддерживает настройку таймаутов и условий повторных попыток

Пример конфигурации pipeline.yaml:

stages:
  - name: manual-gate-human
    type: manual-gate-human
    config:
      timeout: 3600  # 1 hour
      max_attempts: 3
      message: "Requires human approval before deployment"

Пример полного файла pipeline.yaml доступен в docs/samples/pipeline-with-human-gate.yaml.

Autoblock notifications

Для автоматического блокировки тикетов при возникновении проблем используется механизм mark-blocked. Блокированные тикеты получают дополнительные поля в frontmatter для отслеживания причин и попыток:

  • auto_blocked_reason — причина автоматической блокировки
  • auto_blocked_attempts — количество попыток разблокировки
  • auto_blocked_at — время автоматической блокировки

Отчёт только по изменившейся доске

Когда работы нет (check-conditions → empty), стадия check-report-needed (src/scripts/check-report-needed.js) решает, нужен ли новый отчёт. Доска — подпись колонок и полей updated_at/completed_at тикетов (их пишут скрипты перемещения; время файла не годится: раннер дописывает тикету строки «Истории работы»). Подпись доски, для которой пишется отчёт, и время запроса лежат в .workflow/state/report-gate.json. Исходы: needed — доска изменилась или отчёта нет, дальше create-report; analyze — отчёт по этой доске есть, а удачного запуска analyze-report после него в журнале запусков нет или последний разбор нашёл пробелы, а удачного decompose-gaps после него не было (прогон кончился на лимите plan_iterations), дальше разбор этого отчёта; close_plan — последний разбор по этой доске completed, дальше complete-plan с plan_id из related_plan отчёта (бесплатный скрипт, повтор ничего не меняет; так план закрывается, даже если прогон с этим разбором остановили до закрытия; отчёт без related_plan — unchanged); удачный запуск create-report после запроса (RESULT не error и не blocked) считается отчётом для этой доски, даже если нового файла он не записал; unchanged — после последнего разбора с пробелами было разбиение, пайплайн завершается; stuck — то же, но у плана отчёта есть тикеты в blocked/: доска не меняется не потому, что работы нет, а потому, что план стоит (в RESULT — plan_id и blocked_tickets с причиной блокировки, ключ конфига stuck: end, итог прогона — «Pipeline stopped: plan … is stuck (…) — needs a human decision»). Итог разбора раннер пишет в событие журнала полем result_status (статус блока RESULT). Готовые тикеты (done/, archive/) входят в подпись только полем completed_at: перенос в архив при закрытии плана доску не меняет. Последний отчёт — REPORT-NNN с наибольшим номером. Без файла состояния отчёт относится к доске, если он новее всех перемещений.

После decompose-gaps пайплайн идёт в check-conditions. Разбиение без новых тикетов доску не меняет, и check-report-needed завершает пайплайн: после разбора было разбиение.

Возврат заблокированного тикета после исправления

Тикет исправления называет тикеты, которые он разблокирует, полем frontmatter unblocks: [QA-162] — данные автора тикета, а не команда (decompose-gaps пишет его в создаваемое исправление: тикеты он не двигает). check-conditions до просм