Agent Dev 101

Модуль 15 · Урок 15.12

Урок 15.12. Командная работа и Stores (beta)

На этой странице
  1. Зачем это нужно
  2. Основная идея
  3. Часть 1. OpenSpec в команде
  4. Часть 2. Stores (beta)
  5. Практика
  6. Краткое резюме
  7. Вопросы для самопроверки
  8. Что дальше
  9. Источники

Зачем это нужно

Всё, что разобрано в уроках 15.2-15.11, работает одинаково и для соло-разработчика, и для команды из двадцати человек. В команде меняются вопросы по краям:

  • где живут спецификации;
  • как коллеги ревьюят план до кода;
  • как это встраивается в существующий pull request-процесс;
  • что делать, когда планирование перестает помещаться в один репозиторий - например, одна фича затрагивает API-сервер, веб-приложение и общую библиотеку.

Первые три вопроса закрывает обычный командный workflow OpenSpec. Четвертый - beta-функциональность Stores: репозиторий, единственная задача которого - планирование.

Основная идея

Изменение - это просто файлы, и OpenSpec никогда не трогает git. Поэтому OpenSpec встраивается в существующий командный процесс вместо того, чтобы заменять его: change отображается на branch и pull request, ревью плана происходит до ревью кода, а архивация синхронизирует общую «истину» только с тем, что реально доехало до main.

Когда планирование вырастает из одного репозитория, план переезжает в Store - отдельный git-репозиторий той же формы openspec/, который кодовые репозитории подключают по имени. Обмен - обычным git push, без скрытой синхронизации.

Часть 1. OpenSpec в команде

Одно правило: OpenSpec не трогает git

OpenSpec читает и пишет обычный Markdown в openspec/. Он никогда не делает commit, branch, push или pull в вашем проекте - и никогда сам не клонирует и не синхронизирует Store. Из этого следует:

  • openspec/ коммитится как любой исходник. Спецификации, активные изменения и архив - часть истории проекта. Да, коммитить нужно весь каталог - включая архив выполненных изменений.
  • Изменение - это папка, которую версионируют как код. openspec/changes/add-dark-mode/ - просто файлы на ветке.
  • Все договоренности ниже - конвенции, а не принуждение. OpenSpec не заставляет работать именно так; этот способ просто ложится в git без трения.

Повседневный цикл: change, branch, pull request

Workflow, который хорошо работает, отображает изменение на ветку и pull request:

text
git switch -c add-dark-mode        завести ветку, как обычно
   │
/opsx:propose add-dark-mode        черновик плана (proposal + specs + tasks)
   │
РЕВЬЮ ПЛАНА                         вы читаете его до кода
   │
/opsx:apply                        реализация; артефакты и код меняются вместе
   │
git commit && открыть PR           PR содержит и spec delta, и код
   │
коллега ревьюит, мержит
   │
/opsx:archive                      дельта вливается в specs/, change уходит в archive/

Тот же цикл в виде схемы:

flowchart TD A["git switch -c add-dark-mode"] --> B["/opsx:propose add-dark-mode"] B --> C["Ревью плана до кода"] C --> D["/opsx:apply"] D --> E["git commit + pull request"] E --> F["Ревью и merge"] F --> G["/opsx:archive"]

План и код живут рядом на одной ветке, поэтому коллеги ревьюят их вместе, а через полгода архивированная спецификация все еще объясняет, почему код выглядит именно так.

Ревью спецификаций в pull request

Именно здесь команда чувствует отдачу. Когда PR включает дельта-спецификацию изменения, ревьюер получает то, чего сырой diff никогда не даст: формулировку на естественном языке, что изменение должно делать - до чтения первой строки кода.

Хороший порядок ревью:

  1. Прочитать proposal.md - та ли это проблема и тот ли scope.
  2. Прочитать дельты в specs/ - правильно ли определено «готово» (двухминутный pass из урока 15.10, теперь внутри PR).
  3. Потом читать diff кода - доставляет ли он именно эти требования.

Ревьюер, не согласный с подходом, может возразить против proposal дешево - вместо того чтобы пересуживать подход по 300 строкам кода. Поместите дельта-спецификацию в начало описания PR или укажите ревьюерам путь к папке изменения, чтобы они начинали оттуда.

Когда архивировать

Архивация вливает дельты изменения в общий openspec/specs/ и переносит папку в openspec/changes/archive/YYYY-MM-DD-<name>/. Поскольку specs/ - общий источник истины, в команде важен момент архивации. Два рабочих варианта:

  • Архивировать после merge PR (рекомендуется). Ветка несет активное изменение; после merge в main архивация происходит там - обычно маленьким follow-up-коммитом или плановой уборкой. Так общий specs/ движется вперед только вместе с реально доехавшей работой.
  • Архивировать внутри PR. Проще для маленьких команд: тот же PR, который добавляет код, синхронизирует и архивирует. Плата - diff спецификаций и diff кода приземляются вместе, PR становится шумнее.

Выберите один вариант и держитесь его. В любом случае /opsx:archive проверяет завершенность задач и предлагает сначала синхронизировать - случайно «недоделанное» не смержится.

Два человека, параллельные изменения

Изменения - отдельные папки, поэтому не конфликтуют:

  • Разные изменения, разные люди - без проблем. add-dark-mode и rate-limit-login - разные папки на разных ветках; они не пересекаются, пока оба не заархивируются.
  • Одно изменение - один владелец. Два человека, правящие одну папку изменения, конфликтуют ровно как два человека, правящие один файл. Держите изменение за одним автором или разбейте на два (еще один аргумент за правильный размер изменения из урока 15.10).
  • Единственное место конфликтов - specs/. Если два изменения правят одно и то же требование, архивация второго конфликтует в openspec/…/spec.md. Разрешается как любой merge-конфликт - побеждает формулировка, отражающая реальность. Это редкость, и это фича: git сообщает, что два изменения разошлись во мнениях о поведении системы.

Когда планирование вырастает из одного репозитория

Все выше предполагало, что план живет в собственной папке openspec/ кодового репозитория - и это правильный дефолт. Когда планирование реально расползается на несколько репозиториев или команд - одна фича на три сервиса, требования одной команды потребляются другими - включается beta-функциональность Stores: планирование получает собственный репозиторий, на который может указать любой кодовый репозиторий.

Часть 2. Stores (beta)

Beta. Stores, references, рабочий контекст и worksets - новое. Имена команд, флаги, форматы файлов и JSON-вывод могут еще меняться между релизами. Все примеры ниже выполнялись на текущей сборке, но после обновления перечитайте руководство.

Проблема, которую решают Stores

Обычно OpenSpec живет внутри одного кодового репозитория: папка openspec/ рядом с кодом хранит specs и changes этого репозитория.

Это перестает подходить, когда планирование больше одного репозитория:

  • работа охватывает несколько репозиториев - одна фича затрагивает API-сервер, веб-приложение и общую библиотеку. В чьей папке openspec/ живет план?
  • команда планирует до появления кода или планирует вещи, которые никогда не станут кодом в этом репозитории;
  • требования принадлежат одной команде, а потребляются другими. Версия в вики расползается с реальностью, а ваш coding-агент все равно не умеет ее читать.

Store - ответ: отдельный репозиторий, единственная работа которого - планирование. Внутри та же форма openspec/, которую вы уже знаете (specs и changes), плюс маленький файл идентичности. Один раз регистрируете его на своей машине по имени - и каждая обычная команда OpenSpec может работать в нем откуда угодно.

Устройство

text
            team-plans  (Store: планирование в отдельном репозитории)
            ├── .openspec-store/store.yaml     идентичность: «я - team-plans»
            └── openspec/
                ├── specs/      что истинно
                └── changes/    что в движении
                      ▲
                      │ зарегистрирован на каждой машине по имени;
                      │ расшаривается push/clone как любой репозиторий
        ┌─────────────┼─────────────┐
        │             │             │
    web-app       api-server     mobile-app
   (кодовый репо) (кодовый репо) (кодовый репо)

Два правила удерживают модель простой:

  1. Store - это просто git-репозиторий. Коммитите, пушите, пуллите и ревьюите его сами. OpenSpec никогда ничего не клонирует, не синхронизирует и не пушит сам.
  2. Декларации вместо машинерии. Репозитории могут декларировать свою связь со Store (ниже). Декларации меняют то, что OpenSpec может вам сообщить, - но не то, где работают команды.

Пять минут до первого Store

Две команды ведут от нуля до рабочего изменения в scope Store:

bash
openspec store setup team-plans --path ~/openspec/team-plans
text
Store ready: team-plans
Location: /Users/you/openspec/team-plans
OpenSpec root: ready
Registry: registered

Next: run normal OpenSpec commands against this store, for example:
  openspec new change <change-id> --store team-plans
Share this store by committing and pushing it like any Git repo.
bash
openspec new change add-login --store team-plans
text
Using OpenSpec root: team-plans (/Users/you/openspec/team-plans)
Created change 'add-login' at /Users/you/openspec/team-plans/openspec/changes/add-login/
Schema: spec-driven
Next: openspec status --change add-login --store team-plans

Это вся модель. Дальше жизненный цикл в точности привычный - status, instructions, validate, archive - с --store team-plans у каждой команды, и каждая подсказка CLI сама несет нужный флаг. Строка Using OpenSpec root: всегда сообщает, где команда действует.

История: одна команда, один репозиторий планирования

Команда хранит specs и changes в team-plans вместо того, чтобы раскидывать их по кодовым репозиториям.

День первый (кто настраивает):

bash
openspec store setup team-plans --path ~/openspec/team-plans \
  --remote git@github.com:acme/team-plans.git
git -C ~/openspec/team-plans push -u origin main

Флаг --remote записывает clone-URL в файл идентичности Store (.openspec-store/store.yaml) в первом коммите. Каждый будущий clone «рождается» зная, откуда он, поэтому проверки здоровья и сообщения об ошибках печатают полную копируемую команду исправления для коллег, у которых Store еще нет.

Каждый коллега (один раз на машину):

bash
git clone git@github.com:acme/team-plans.git ~/openspec/team-plans
openspec store register ~/openspec/team-plans

Дальше все работают в общем репозитории планирования по имени:

bash
openspec status --store team-plans --change add-login
openspec show add-login --store team-plans

Разделение работы - это git, намеренно. Созданное изменение существует только в вашем checkout, пока вы не закоммитите и не запушите - как код. Планы получают ветки, PR и ревью бесплатно, потому что Store - обычный репозиторий.

Подключение кодовых репозиториев команды. Кодовому репозиторию с полностью вынесенным планированием нужна ровно одна строка в openspec/config.yaml:

yaml
# web-app/openspec/config.yaml
store: team-plans

Теперь каждая команда OpenSpec внутри web-app работает с team-plans вообще без флагов:

bash
cd ~/src/web-app
openspec status --change add-login
text
Using OpenSpec root: team-plans (/Users/you/openspec/team-plans)
...

Указатель - это fallback, а не override: явный --store всегда побеждает, а если репозиторий вырастит собственные папки планирования, побеждают они (с предупреждением убрать устаревший указатель).

Один дефолт на все репозитории машины. Если вы работаете в куче репозиториев, планирующих в один Store, задайте его один раз глобально:

bash
openspec config set defaultStore team-plans

Теперь любая команда вне корня планирования - без --store и без проектного указателя - резолвится в team-plans. Дефолт сидит на самом дне списка приоритетов: --store, локальный корень и проектный указатель store: все равно побеждают. Баннер root и JSON-блок root сообщают source: "global_default" с id Store, так что машинный дефолт всегда отличим от собственного указателя репозитория. Сброс: openspec config unset defaultStore. Если id не зарегистрирован, команды падают с подсказкой зарегистрировать или убрать дефолт.

Пример: одна фича, два компонентных репозитория

Пусть add-checkout-promo меняет и checkout-api, и checkout-web. Команде нужен один общий продуктовый контракт, но каждому репозиторию - свои задачи реализации, ветка и ревью.

Два слоя:

  1. общее поведение - в team-plans;
  2. планы реализации - в каждом компонентном репозитории, со ссылкой на Store как на read-only upstream-контекст.

Сначала общий контракт в Store:

bash
openspec new change add-checkout-promo --store team-plans
openspec status --change add-checkout-promo --store team-plans

Proposal и спецификации должны описывать поведение на границе компонентов - например, поля промоушена, которые возвращает сервис, и как фронтенд обрабатывает не подходящий под условия checkout. Ревью этого изменения в Store - как любой branch и pull request.

Какой контекст видит планирование?

Выбор Store меняет корень OpenSpec; он не обнаруживает и не читает все кодовые репозитории, которые этим Store пользуются. Инструкции Store видят артефакты и настроенный контекст в Store. Код компонентов они видят, только если эти папки доступны агенту или редактору и агент их читает.

Workset - удобный способ открыть Store планирования и оба кодовых репозитория вместе:

bash
openspec workset create checkout-promo \
  --member ~/openspec/team-plans \
  --member ~/src/checkout-api \
  --member ~/src/checkout-web \
  --tool code
openspec workset open checkout-promo

Это делает папки видимыми в одном workspace IDE. Это не копирует исходный контекст в Store, не выбирает затронутые репозитории и не выдает агенту прав на их правку. Прочные межкомпонентные факты кладите в общие спецификации; не рассчитывайте, что планировщик запомнит исходники, которые он когда-то посмотрел.

Как начинается реализация в каждом репозитории?

Когда нет явного --store и более близкого корня openspec/, указатель store: team-plans направляет команды в Store. Он не делит task list Store по каталогу, из которого вызван apply. OpenSpec пока не маршрутизирует задачи по репозиториям.

Когда каждому компоненту нужен независимый цикл apply/ревью, дайте ему локальный корень OpenSpec и ссылку (reference) на центральный Store вместо указателя на него:

yaml
# checkout-api/openspec/config.yaml (и так же в checkout-web)
schema: spec-driven
references:
  - team-plans

После утверждения общего контракта и его попадания в основные specs Store создайте маленькое локальное изменение для части компонента:

bash
cd ~/src/checkout-api
openspec new change implement-checkout-promo-api

cd ~/src/checkout-web
openspec new change implement-checkout-promo-ui

Индекс ссылок в инструкциях каждого репозитория подставляет сводку спецификации Store и точную команду выборки openspec show ... --store team-plans. Каждый локальный proposal цитирует общий контракт, а его tasks описывают работу только в этом компоненте. Затем /opsx:apply запускается в каждом репозитории отдельно; резолюция корня удерживает артефакты и правки реализации в scope этого репозитория. Изменения сервиса и фронтенда можно тестировать, ревьюить, мержить и архивировать независимо.

Если реализацию нужно начинать, пока общее изменение в Store еще активно, выберите его явно: openspec show add-checkout-promo --store team-plans - индексы ссылок перечисляют канонические specs Store, а не активные изменения Store. Свяжите ветку в Store и ветки компонентов в описаниях PR, чтобы ревьюеры видели, какую версию контракта следует каждая реализация.

История: требования через границы команд

Команда платформы владеет требованиями. Продуктовые команды строят по ним - в своих репозиториях, со своими design. Reference описывает эту связь, не двигая ничью работу.

text
   platform-reqs (Store)              api-server (кодовый репозиторий)
   владеет команда платформы          владеет продуктовая команда
   ┌──────────────────────────┐      ┌──────────────────────────┐
   │ openspec/specs/          │◀──────│ openspec/config.yaml     │
   │   payments/spec.md       │ читает│   references:           │
   │   auth/spec.md           │      │     - platform-reqs      │
   │                          │      │ openspec/specs/          │
   │ openspec/changes/        │      │   (свои design)           │
   │   работа платформы       │      │ openspec/changes/        │
   │                          │      │   (своя работа)          │
   └──────────────────────────┘      └──────────────────────────┘

Продуктовая команда декларирует, на что опирается, в openspec/config.yaml своего репозитория:

yaml
references:
  - platform-reqs

References - read-only контекст. Репозиторий сохраняет собственный корень openspec/; работа остается там. Что меняется: openspec instructions в этом репозитории теперь включает индекс specs указанного Store - каждая позиция с однострочной сводкой и точной командой выборки (openspec show <spec-id> --type spec --store platform-reqs). Агент, работающий в api-server, находит upstream-требования по платежам, цитирует их и пишет свой low-level design в собственном корне репозитория - без ручной пересылки контекста.

Reference может нести свой clone-источник, чтобы коллеги без Store получали полную команду исправления вместо тупика:

yaml
references:
  - { id: platform-reqs, remote: "git@github.com:acme/platform-reqs.git" }

Когда план и код открываются вместе - сделайте workset. Это личное и явное: каждый выбирает папки, с которыми реально работает, на своей машине. Ничего о локальных путях в общий репозиторий планирования не коммитится.

bash
openspec workset create platform \
  --member ~/openspec/platform-reqs \
  --member ~/src/api-server \
  --member ~/src/web-app

Два вопроса, которые всегда можно задать

«Здоров ли мой setup?» - openspec doctor проверяет текущий корень и его Store-ссылки, только читая, и выдает копируемое исправление на каждую находку:

text
Doctor

Root
  Location: /Users/you/src/api-server
  OpenSpec root: ok

References
  - platform-reqs: ok (/Users/you/openspec/platform-reqs)
  - design-system: Referenced store 'design-system' is not registered on this machine.
    Fix: git clone -- git@github.com:acme/design-system.git '/Users/you/openspec/design-system' && openspec store register '/Users/you/openspec/design-system' --id design-system

«С чем я работаю?» - openspec context собирает рабочий набор из деклараций OpenSpec: корень и Store, на которые он ссылается.

text
Working context for api-server (/Users/you/src/api-server)

OpenSpec root
  api-server  /Users/you/src/api-server

Referenced stores
  platform-reqs  /Users/you/openspec/platform-reqs
    Fetch: openspec show <spec-id> --type spec --store platform-reqs

Обе команды поддерживают --json для агентов. openspec context --code-workspace <path> дополнительно пишет workspace-файл VS Code со всем набором - единственная запись, которую выполняет эта команда.

Worksets: снова открыть папки, с которыми работаете

Отдельно ото всего выше: большинство людей открывают одни и те же несколько папок каждую сессию - репозиторий планирования плюс два-три кодовых. Workset - личный именованный набор именно этого, открываемый одной командой в выбранном инструменте.

text
 workset "platform"                openspec workset open platform
 ├── team-plans   ~/openspec/team-plans        │
 ├── api-server   ~/src/api-server             ▼
 └── web-app      ~/src/web-app     все три открыты в вашем инструменте
bash
openspec workset create platform \
  --member ~/openspec/team-plans --member ~/src/api-server \
  --tool code
openspec workset list
text
platform  (opens in VS Code)
  team-plans  /Users/you/openspec/team-plans
  api-server  /Users/you/src/api-server

openspec workset open platform запускает сохраненный инструмент: редакторы (VS Code, Cursor) открывают одно окно со всеми участниками и возвращают управление. Первый участник - основной. Инструмент переопределяется в любой момент флагом --tool <id>.

Worksets намеренно не общие. Они живут на вашей машине, никогда не коммитятся и ничего не утверждают о работе - только фиксируют, что вам нравится открывать вместе. Удаление workset никогда не трогает папки-участники. Новые инструменты - это конфигурация, а не код: все, что запускается через workspace-файл или флаги attach по папкам, добавляется под ключом openers в глобальной конфигурации (openspec config edit).

Как команды выбирают, где действовать

Каждая обычная команда резолвит корень одинаково, в этом порядке:

text
1. --store <id>          явно указано                 → этот Store
2. ближайший openspec/  настоящий корень планирования → этот репозиторий
   (подъем от cwd)
3. указатель store:      config.yaml декларирует Store → этот Store
4. defaultStore          глобальный машинный дефолт   → этот Store
5. ничего из перечисленного
   есть зарегистрированные Store на машине? → ошибка с подсказкой выбора
   нет зарегистрированных Store?            → текущий каталог
                                              (классическое поведение)

Строка Using OpenSpec root: (и блок root в --json) сообщает, какой случай ваш.

Известные ограничения Stores

  • Beta-форма. Все на этой странице может меняться между релизами - имена, флаги, форматы файлов, ключи JSON.
  • Один checkout на id Store на машину. Регистрация второго checkout под тем же id падает с подсказкой сначала выполнить store unregister.
  • Никакой синхронизации - никогда, by design. OpenSpec не клонирует, не пуллит, не пушит. Устаревший checkout показывает устаревшие specs, пока вы не сделаете pull; references индексируются live из того, что на диске.
  • Пустые папки планирования могут отсутствовать. У нового Store может еще не быть openspec/changes/, openspec/specs/ или openspec/changes/archive/ в git. В beta это допустимо; папки появляются, когда обычные команды создают в них файлы.
  • Репозитории-указатели остаются указателями. Репозиторий только с конфигурацией, где openspec/config.yaml декларирует store: <id>, трактуется как вынесенное планирование, а не как checkout Store для регистрации. Уберите строку store:, если вы сознательно превращаете репозиторий в локальный корень Store.
  • Некоторые команды остаются на месте. templates и устаревшие именные формы (openspec change show, ...) работают только с текущим каталогом - без --store. schemas следует канонической схеме выбора корня и принимает --store <id>, сохраняя прежнюю форму JSON-массива.
  • Состояние машины остается состоянием машины. Реестр Store и worksets - локальные настройки. Ничего о вашей машине никогда не коммитится в общее планирование.
  • Два стиля запуска для worksets. Инструмент, который нельзя запустить workspace-файлом или флагами attach по папкам, нельзя добавить как opener.
  • У agent JSON есть известное расхождение регистра (ключи store-семейства - snake_case, workflow-семейства - camelCase). Задокументировано в agent contract (урок 15.14); объединение отложено до версионируемого релиза.

Где что лежит

Что Где Общее?
Планирование Store <store>/openspec/ (specs, changes) Да - коммитить и пушить
Идентичность Store <store>/.openspec-store/store.yaml Да - коммитится вместе со Store
Реестр Store <data dir>/openspec/stores/registry.yaml Нет - только эта машина
Worksets <data dir>/openspec/worksets/ Нет - только эта машина

<data dir> - это ~/.local/share/openspec на macOS и Linux (или $XDG_DATA_HOME/openspec, если задан) и %LOCALAPPDATA%\openspec на Windows.

Точные флаги и JSON-формы всех команд страницы - в справочнике CLI (урок 15.6) и agent contract (урок 15.14).

Практика

  1. Возьмите любой парный набор: «репозиторий планирования» (можно пустой) и один «кодовый» репозиторий с инициализированным OpenSpec.
  2. Выполните openspec store setup demo-plans --path ~/openspec/demo-plans и openspec new change demo --store demo-plans; убедитесь, что вывод содержит строку Using OpenSpec root:.
  3. В кодовом репозитории добавьте store: demo-plans в openspec/config.yaml и выполните openspec status без флагов - проверьте, что команда резолвится в Store по указателю.
  4. Выполните openspec doctor и openspec context и сравните их вывод с примерами выше.
  5. Смоделируйте командный цикл соло: ветка → /opsx:propose → ревью плана → /opsx:apply → коммит и «PR» → /opsx:archive после «merge». Заметьте, какие артефакты оказались бы в PR.

Краткое резюме

  • OpenSpec не трогает git: openspec/ коммитится как исходник, изменение - папка на ветке, все командные договоренности - конвенции.
  • Повседневный цикл команды: change ↔ branch ↔ PR; ревьюер читает proposal.md → дельты specs/ → diff кода; архивация - после merge PR (рекомендуется) или внутри него.
  • Параллельные изменения не конфликтуют, кроме двух правок одного требования в specs/ - и это фича, а не баг.
  • Когда планирование не помещается в один репозиторий, Store выносит его в отдельный git-репозиторий: store setup, register, указатель store: в config или глобальный defaultStore, references как read-only upstream-контекст, doctor и context для диагностики, worksets для личных наборов папок.
  • Stores - beta: имена и формы могут меняться, синхронизации нет by design, состояние машины не коммитится.

Вопросы для самопроверки

  1. Почему «OpenSpec не трогает git» - ключевое правило для командного workflow, и что из него следует для коммитов openspec/?
  2. В каком порядке ревьюер читает PR с дельта-спецификацией и почему дельта должна быть видна в PR до diff кода?
  3. Чем «архивировать после merge» отличается от «архивировать внутри PR» и почему документация рекомендует первое?
  4. В каком единственном месте возникают конфликты между параллельными изменениями и как их разрешать?
  5. Как указатель store: в openspec/config.yaml соотносится по приоритету с --store, локальным корнем openspec/ и defaultStore?
  6. Чем reference (references:) отличается от указателя (store:), и какую задачу каждый решает в сценарии «команда платформы владеет требованиями»?
  7. Почему отсутствие автоматической синхронизации Store - осознанное решение, а не недоработка?

Что дальше

Урок 15.13 - кастомизация: конфигурация проекта, кастомные схемы, shared context, генерация артефактов на других языках и полный список поддерживаемых AI-инструментов с путями, куда пишет файлы OpenSpec.

Источники

Обновлено 03.10.2026