Зачем это нужно
SDD-цикл - это последовательность команд с ревью между ними. Workflows автоматизируют такие последовательности: сцепляют команды, промпты, shell-шаги и человеческие чекпойнты в повторяемые цепочки с условной логикой, циклами, fan-out/fan-in и паузой/возобновлением с точного места остановки. First-party процессы используют эту механику: бандлы assess и bugfix - «расширение + resumable workflow + ворота ревью» (урок 16.4).
Урок - справочник: команды запуска и жизненного цикла, оверлеи, определение workflow на YAML, типы шагов, кастомные step-пакеты, выражения и - критически важный раздел - безопасность shell-шагов.
Основная идея
Workflow описывается YAML-файлом: метаданные (workflow.id, name, version), требования (requires), типизированные inputs и список steps. Движок выполняет шаги, материализует состояние прогона в .specify/workflows/runs/<run_id>/ и умеет продолжать с точки паузы (gate) или сбоя. Источник запуска - ID каталога, URL или локальный файл.
Запуск, возобновление, статус
specify workflow run <source> # -i/--input key=value (повторяемый), --json
specify workflow resume <run_id> # -i/--input, --json
specify workflow status [<run_id>] # --json; без ID - все прогоны
specify workflow list # установленные в проекте
Пример запуска встроенного SDD-цикла:
specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management"
Инпуты, объявленные workflow, даются через --input или запрашиваются интерактивно. --json печатает один machine-readable-объект; прогресс шагов при этом уходит в stderr, stdout несет только JSON:
{
"run_id": "662bf791",
"workflow_id": "build-and-review",
"status": "paused",
"current_step_id": "review",
"current_step_index": 0
}
workflow_id - это workflow.id из YAML, не имя файла. Для failed/aborted объект содержит error с сообщением терминального шага (то же сообщение потом даст status <run_id> --json); completed и paused поле error опускают. Состояния прогона: created, running, completed, paused, failed, aborted.
resume продолжает с точного шага остановки. Переданные --input сливаются поверх сохраненных, ревалидируются по типам, и заблокированный шаг перезапускается с обновленными значениями - так прогон продолжается с информацией, появившейся после паузы:
specify workflow resume <run_id> --input cmd="exit 0"
Большинство команд требуют инициализированный проект; исключение - workflow run <local-file.{yml,yaml}> вне проекта (состояние пишется в .specify/workflows/runs/ текущего каталога).
Установка и жизненный цикл
specify workflow add <source> # --dev (локальный YAML/пакет/архив), --from <url>, --version <v>
specify workflow update [<id>] # один или все каталоговые; подтверждение; сбой - копия остается
specify workflow enable|disable <id> # выключенный: [disabled], запуск запрещен
specify workflow remove <id>
specify workflow search [query] # --tag, --author
specify workflow info <id> [--versions] # шаги, инпуты, требования; --versions - история каталога
add принимает каталог, HTTPS-URL, локальный YAML, каталог с workflow.yml или архив .zip/.tar.gz/.tgz (workflow.yml в корне или в одном top-level каталоге). Каталожные записи держат текущий релиз в верхних полях и исторические в releases (у каждого - свой URL и SHA-256, опционально requires); --version требует наличия версии в каталоге высшего приоритета - fallback на другие источники отсутствует, discovery-only не устанавливаются. ID, версия и digest скачанного проверяются до установки; --version не действует для локальных путей и прямых URL.
Оверлеи workflow
Оверлеи позволяют проекту расширять или переопределять установленный workflow, не редактируя его workflow.yml - локальные кастомизации переживают bundle update и workflow add. При run <workflow-id> движок композирует базовый workflow со всеми включенными оверлеями этого ID и валидирует результат.
Оверлей - YAML с списком операций редактирования против списка шагов базового workflow. Приоритет lower-wins: сначала применяются большие числа, меньшие - последними; равные - по алфавиту ID, последний выигрывает конфликты. Файлы живут в .specify/workflows/overlays/<extends>/<id>.yml.
id: "my-overlay"
extends: "speckit"
priority: 10
enabled: true
edits:
- insert_after: implement
step:
id: run-lint
type: shell
run: "ruff check src/"
- replace: review-spec
step:
id: review-spec
type: gate
message: "Review the generated spec (overlay override)."
options: [approve, reject]
on_reject: abort
Поля: id (строчные буквы/цифры/дефисы, без overlays), extends (ID целевого workflow), priority (дефолт 10), enabled (дефолт true), edits (непустой список). Операции:
| Операция | step нужен |
Эффект |
|---|---|---|
insert_after |
да | вставить шаг сразу после якоря |
insert_before |
да | вставить сразу до |
replace |
да | заменить якорный шаг |
remove |
нет | удалить якорный шаг |
Якорь - id шага базового workflow; якоря резолвятся рекурсивно внутри then, else, steps, cases.*, default - вложенные шаги достижимы. Шаблоны fan-out (step внутри fan-out) якорями быть не могут. ID шагов не содержат : - символ зарезервирован для движка.
CLI оверлеев:
specify workflow overlay add <файл.yml> --priority <n> # валидация + копия в overlays/<extends>/
specify workflow overlay list <workflow-id> # порядок резолвера
specify workflow overlay set-priority <wf> <overlay> <n>
specify workflow overlay enable|disable <wf> <overlay>
specify workflow overlay remove <wf> <overlay>
specify workflow resolve <workflow-id> # составленный workflow: слои и атрибуция шагов
Примеры. Автолинт после implement (вставка после шага встроенного цикла):
id: "add-lint"
extends: "speckit"
priority: 10
edits:
- insert_after: implement
step:
id: run-lint
type: shell
run: "ruff check src/"
Замена gate на неинтерактивную команду (priority: 5 - выиграет конфликт у приоритета 10):
id: "skip-plan-review"
extends: "speckit"
priority: 5
edits:
- replace: review-plan
step:
id: review-plan
type: command
command: speckit.plan
input:
args: "{{ inputs.spec }}"
При refresh/reinstall установленного workflow проектные оверлеи сохраняются - они живут вне каталога установленного workflow.
Слоты: точки расширения вверх по потоку
Автор workflow может объявить именованный no-op слот type: slot:
- id: post-implement
type: slot
name: "Post-implementation checks"
id слота - уникальный якорь оверлея; name - обязательная человекочитаемая метка. Незаполненный слот завершается как skipped с output: {slot: <name>} - последующие шаги идут дальше. Заполнение - schema-валидный replace по id (не по name); сохраняйте каждый ключ output, который читают последующие шаги (незаполненный слот дает только steps.<id>.output.slot). Слоты не поддерживаются внутри шаблонов fan-out.step.
Ограничения оверлеев
Оверлеи действуют только на список шагов - не на метаданные (name, description, inputs, requires) и не на логику выражений. Нельзя: якориться на fan-out шаблоны и на шаги, добавленные другими оверлеями; добавлять инпуты или менять input-схему; отсутствующий в базе ID шага - ошибка валидации при резолве.
Каталоги
Каталоги определяют, где search и add ищут workflow (и step-каталоги для шагов):
specify workflow catalog list
specify workflow catalog add <url> # --name; повтор с тем же именем - no-op, с другим - отказ
specify workflow catalog remove <index>
Предупреждение документации: .specify/workflow-catalogs.yml проекта может направить команды на каталог, который вы не выбирали. В незнакомом проекте запускайте specify workflow catalog list (и specify workflow step catalog list); мейнтейнеры поля run не аудируют - читайте shell-шаги workflow сами перед запуском.
Порядок резолюции: SPECKIT_WORKFLOW_CATALOG_URL -> .specify/workflow-catalogs.yml -> ~/.specify/workflow-catalogs.yml -> встроенные (официальный + community).
Определение workflow
Встроенный Full SDD Cycle (speckit) - канонический пример:
schema_version: "1.0"
workflow:
id: "speckit"
name: "Full SDD Cycle"
version: "1.0.1"
author: "GitHub"
description: "Runs specify → plan → tasks → implement with review gates"
requires:
speckit_version: ">=0.8.5"
integrations:
any:
- "alquimia"
- "claude"
- "copilot"
- "gemini"
- "opencode"
inputs:
spec:
type: string
required: true
prompt: "Describe what you want to build"
integration:
type: string
default: "auto"
prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)"
steps:
- id: specify
command: speckit.specify
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
- id: review-spec
type: gate
message: "Review the generated spec before planning."
options: [approve, reject]
on_reject: abort
- id: plan
command: speckit.plan
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
- id: review-plan
type: gate
message: "Review the plan before generating tasks."
options: [approve, reject]
on_reject: abort
- id: tasks
command: speckit.tasks
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
- id: implement
command: speckit.implement
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
Поток исполнения:
Типы шагов
| Тип | Назначение |
|---|---|
command |
вызвать команду Spec Kit (например, speckit.plan) |
prompt |
отправить произвольный промпт AI-агенту |
shell |
выполнить shell-команду и захватить вывод |
init |
bootstrap проекта (как specify init) |
slot |
именованный слот; пропускается, если не заполнен |
gate |
пауза на человеческое одобрение |
if |
условное ветвление (then/else) |
switch |
мультиветочный dispatch по выражению |
while |
цикл, пока условие истинно |
do-while |
выполнить минимум раз, затем цикл по условию |
fan-out |
dispatch шага на каждый элемент списка |
fan-in |
агрегация результатов fan-out |
Замечание безопасности: shell-шаг запускает локальную команду с вашими привилегиями. Песочницы нет - requires это консультативный предблок (версия spec-kit, интеграции), а не рантайм-гейт; requires.permissions отвергается валидацией именно потому, что подразумевал бы несуществующую песочницу. Ревьюте любые каталоговые и скачанные workflow до запуска; для чувствительных команд требуйте явное одобрение через gate.
Кастомные step-пакеты
Кастомные типы шагов ставятся через specify workflow step. Пакет - каталог с метаданными и исполняемым Python:
my-step/
├── step.yml # обязательный, в корне
├── __init__.py # обязательный, в корне
└── helpers.py # опциональные вложенные модули и файлы данных
step.yml объявляет идентичность; step.type_key обязан точно совпадать с <step_id> из командной строки - ID из содержимого пакета не выводится:
step:
type_key: my-step
name: My Step
version: 0.1.0
author: you
description: What this step does
__init__.py определяет подкласс StepBase с совпадающим type_key:
from specify_cli.workflows.base import StepBase, StepResult
class MyStep(StepBase):
type_key = "my-step"
def execute(self, config, context):
return StepResult(output={"ok": True})
Установка:
# локальный авторинг-цикл (без каталога и сети)
specify workflow step add my-step --dev ./my-step
specify workflow step list
specify workflow step info my-step
specify workflow step add my-step --dev ./my-step --force # замена после правок
specify workflow step remove my-step
# из архива по URL: .zip/.tar.gz/.tgz (не голый step.yml)
specify workflow step add my-step --from https://example.com/my-step.zip
# из каталога
specify workflow step add my-step
--from показывает default-deny подтверждение доверия до любого сетевого запроса (пакет содержит исполняемый Python); HTTPS обязателен (HTTP - только loopback), редиректы - безопасные, загрузки ограничены по размеру. Discovery-only каталоги не устанавливают. --force стейджит и валидирует замену до касания существующей установки; сбой публикации после удаления старого каталога может оставить неполную установку - повторите с исходным источником и --force, автоската нет.
Валидация пакетов одинакова для всех источников: step.yml и __init__.py - обычные несимлинк-файлы в корне; дерево копируется рекурсивно (относительные импорты, вложенные модули, файлы данных), симлинки и нерегулярные объекты отвергаются; .git, __pycache__, .DS_Store пропускаются; лимиты - 512 записей, 32 уровня вложенности, 50 MiB; архивы - до 512 записей, 50 MiB суммарно, 10 MiB на член. Установка копирует, но не импортирует пакет - модули грузятся при старте workflow add/run/resume.
Реестр происхождения: записывается только вид источника (catalog/local/url); локальные пути и URL не персистятся (step info показывает источник). Ограничение бандлов: provides.steps резолвится только через активные step-каталоги; bundle-local steps/<id>/ и относительные source-оверрайды в этом релизе не резолвятся - офлайн такие шаги не ставятся (урок 16.15).
Конфигурация интеграции на шаг
Command-шаги могут передавать структурированную рантайм-конфигурацию:
- id: implement-with-docker-agent
type: command
command: speckit.implement
integration: docker-agent
integration_args:
- "{{ inputs.agent_config }}"
integration_options:
agent: root
safety: balanced
model: "openai/gpt-5"
input:
args: "{{ inputs.spec }}"
integration_args - упорядоченный список строк; integration_options - мэппинг со строковыми ключами. Значения резолвятся механизмом выражений и валидируются выбранной интеграцией. Docker Agent принимает единственный позиционный аргумент как ссылку на конфигурацию агента и опции agent/safety; модель - через top-level model шага. Per-step ссылка агента перекрывает SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS для этого шага. Резолвированная конфигурация пишется в состояние прогона; resume с новыми --input перерезолвит значения, без - воспроизведет те же.
Выражения и shell-безопасность
Шаги ссылаются на инпуты и выходы предыдущих шагов через {{ expression }}:
| Namespace | Описание |
|---|---|
inputs.spec |
значения инпутов workflow |
steps.specify.output.file |
выход предыдущего шага |
item |
текущий элемент итерации fan-out |
context.run_id |
ID текущего прогона |
context.workflow_dir |
абсолютный путь к каталогу источника workflow |
Фильтры: default, join, contains, map, from_json.
condition: "{{ steps.test.output.exit_code == 0 }}"
args: "{{ inputs.spec }}"
message: "{{ status | default('pending') }}"
Интерполяция и безопасность shell
Выражения резолвятся плоской подстановкой строки - значение врезается в текст как есть, без кавычек и экранирования. Для shell-шагов это значит: интерполированное значение интерпретируется шеллом (/bin/sh -c на POSIX) как синтаксис, а не данные.
Если значение может содержать ;, |, &, $( ), бэктики или кавычки - оно способно изменить или расширить выполняемую команду. Главные источники риска: inputs.* (кто запускает workflow) и выход предыдущего шага - для prompt-шага это текст, сгенерированный AI-агентом, на который влияли файлы, тикеты и web-контент, прочитанные агентом. Выход агента - недоверенный, когда он попадает в shell-шаг.
Фильтра экранирования в языке выражений нет, песочницы вокруг shell нет - надежный контроль один: ограничить, чем значение может быть, и не допускать неконтролируемые значения в run вообще:
-
Ограничивайте источник через
enum/allowlist - сильнейший контроль движка. Когдаinputs.*питаетrun, зафиксируйте набор известных значений:yamlinputs: target: type: string enum: [staging, production] # вызывающий не подставит произвольный shell-текст
-
Держите неконтролируемые значения вне
run- ветвьтесь по ним черезif/switchс фиксированными условиями или обрабатывайте вcommand/prompt-шаге, а не в shell-команде, собранной из них. -
Кавычки - не граница безопасности: обрамление
{{ inputs.x }}кавычками помогает шеллу считать доверенное значение одним аргументом, но значение, содержащее саму кавычку, все равно вырвется. Кавычьте ограниченные значения для корректности - никогда как защиту. -
Gate не инспектирует следующий шаг,
messageпечатается дословно: gate рендерит только собственныйmessage/show_fileи не показывает/не санирует команду после него; одобрение не нейтрализует инъекцию. Не интерполируйте сырые недоверенные данные вmessage(без стриппинга control-символов агентский вывод может инъектировать ANSI-эскейпы, меняющие промпт); недоверенный материал показывайте черезshow_file- его путь и контент зачищаются от control/ANSI.
Shell-шаг по дизайну - примитив произвольной команды; практики снижают экспозицию и держат под контролем автора, какая команда выполняется, но не устраняют риск интерполяции значений, которыми вы не владеете полностью.
Переменные окружения shell-шагов
| Переменная | Описание |
|---|---|
SPECKIT_WORKFLOW_DIR |
абсолютный путь к каталогу источника workflow (равен {{ context.workflow_dir }}); не ставится, если источник без пути |
Типы инпутов и коэрция
| Тип | Коэрция |
|---|---|
string |
passthrough |
number |
"42" -> 42, "3.14" -> 3.14 |
boolean |
"true" / "1" / "yes" -> True |
Состояние и возобновление
Каждый прогон персистит состояние в .specify/workflows/runs/<run_id>/:
state.json- текущее состояние и прогресс шагов;inputs.json- резолвированные инпуты;log.jsonl- пошаговый журнал исполнения.
Это и дает resume продолжение с точного шага паузы (gate) или сбоя. Каждый запуск - уникальный ID и собственный каталог состояния; status показывает все прогоны.
Вердикты gate через инпуты
verdict_input привязывает вердикт gate к именованному инпуту (объявленному в inputs; необъявленная ссылка - ошибка workflow validate). Внутри fan-out не поддерживается: элементы делят инпуты, а состояние представляет одну паузу - ставьте gate до fan-out (одобрение партии) или после fan-in (ревью агрегата).
Семантика значения: непустая строка, совпавшая с опцией (case-insensitive) - gate решает автоматически; непустая без совпадения - немедленный сбой; не-строка - сбой; отсутствующее/пустое - промпт на TTY, иначе пауза.
inputs:
spec_verdict:
type: string
default: ""
steps:
- id: review-spec
type: gate
message: "Approve the specification?"
options: [approve, reject]
on_reject: retry
verdict_input: spec_verdict
Возобновление с вердиктом:
specify workflow resume <run_id> --input spec_verdict=approve
Нюанс on_reject: retry: привязанный reject потребляется до паузы - именованный инпут сбрасывается в "", и следующий resume снова промптит/паузит. Поэтому verdict-input с retry обязан допускать пустую строку; с enum включите "", иначе сброс нарушит собственный enum и прогон нельзя будет продолжить (workflow add репортит это ошибкой валидации).
Частые вопросы
- Что происходит на gate? Прогон встает и ждет человека; после ревью -
specify workflow resume <run_id>. - Можно гонять один workflow многократно? Да: у каждого запуска свой ID и каталог состояния.
- Кто поддерживает workflows? Независимые авторы; мейнтейнеры не ревьюят, не аудируют и не поддерживают код workflow. Ревьюте исходник до установки.
Практика
- Запустите встроенный
speckit-цикл с инпутомspecна маленькой фиче; на первом gate прервите, изучитеstate.json/log.jsonlв каталоге прогона, затем продолжите черезresume. - Напишите оверлей
add-lintиз примера урока и проверьтеspecify workflow resolve speckit- какой слой дал каждый шаг. - Соберите минимальный step-пакет (
step.yml+__init__.pyсStepBase), установите через--devи используйте в собственном workflow.
Краткое резюме
Workflows автоматизируют многошаговые процессы: YAML с типизированными инпутами, требованиями и шагами двенадцати типов (команды, промпты, shell, gate, условия, циклы, fan-out/fan-in, слоты). Состояние персистится по прогонам и позволяет resume с точного шага. Оверлеи правят список шагов поверх установленного workflow и переживают обновления; слоты - заявляемые автором точки расширения. Кастомные шаги - Python-пакеты с жесткими лимитами и записью вида источника.
Главное из урока:
- интерполяция - плоская подстановка без экранирования: недоверенные значения (включая выход агента) не должны попадать в
runиmessage; - ограничение
enumна инпуте - сильнейший контроль против shell-инъекции; кавычки и gate от нее не защищают; requires- консультативный предблок, не песочница; песочницы нет вовсе;- оверлеи не меняют метаданные и не якорятся на шаги других оверлеев и fan-out шаблоны;
- verdict-input с
on_reject: retryтребует пустую строку в допустимых значениях.
Вопросы для самопроверки
- Опишите структуру YAML-определения workflow. Что проверяет блок
requiresи чего он не гарантирует? - Что хранится в каталоге прогона и как это обеспечивает resume с точного шага?
- Перечислите операции оверлеев и правила приоритетов. Какие объекты не могут быть якорями?
- Что такое слот
type: slotи как его заполняет оверлей? Какой output дает незаполненный слот? - Почему интерполяция
{{ steps.plan.output.stdout }}вrunопасна? Какие три практики снижают риск? - Как работает
verdict_input? Почему связка сon_reject: retryтребует пустую строку в enum?
Что дальше
Бандлы пакуют расширения, пресеты, workflows и шаги в один версионируемый юнит. Урок 16.15 - манифест bundle.yml, version pinning, проверка конфликтов, provenance, установка и удаление одним шагом.