jira-agent/docs/superpowers/plans/2026-10-19-projects-agent.md
Andrey Epifancev f3e899ec2c План агента v12: доски проектов из параметров команд (#35)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:18:04 +04:00

488 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# projects — агент v12: доски проектов из параметров команд — план
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** агент v12 берёт доски Jira из параметров команд: `ensure_sprint` — из `board_id` команды, `refresh` — из
`boards` (`[{id, type}]`); для досок с `type = null` определяет тип (`GET /rest/agile/1.0/board/{id}`) и возвращает его
в результате `boards`, спринты задач собирает со всех scrum-досок; без `board_id`/`boards` работает по
`settings.board_id`, как v11.
**Architecture:** всё в `src/agent.py`, класс `Executor`. Кэш спринтов `_sprints` из одного словаря становится словарём
«доска → (имя → спринт)» и по-прежнему сбрасывается в `Executor.run` на каждую команду (а при смене настроек `Runner`
пересоздаёт `Executor`). `sprints(board)` читает активные и будущие спринты одной доски; `do_ensure_sprint` берёт доску
`p["board_id"]`, иначе `settings.board_id`. В `do_refresh` первым шагом `refresh_boards(p)` определяет типы досок с
`type = null` (`board_type(board)`, ошибка HTTP — `None` и строка в логе) и отдаёт список scrum-досок и словарь для
результата; `sprint_names(keys, boards)` обходит доски по порядку, последнее найденное имя побеждает.
**Tech Stack:** Python 3.9.6 (`/usr/bin/python3`), только стандартная библиотека; тесты — `unittest` на фейках
`tests/fake_jira.py`, `tests/fake_hub.py`.
**Spec:** change хаба `projects` (aep/jira-hub#61), пока в worktree хаба:
`/Users/andrey.epifancev/projects/jira-bridge/jira-hub/.wt/scopes/openspec/changes/projects/` —
`specs/agent/spec.md` (ADDED «Доски проектов» — основное, MODIFIED «Состав и настройки агента»),
`specs/commands/spec.md` (таблица команд: `ensure_sprint.board_id`, `refresh.boards`, результат `boards`),
`design.md` (решения 4, 7, 14, 15; «Decisions made without discussion» № 6, 7, 14). Действующая спека агента —
`/Users/andrey.epifancev/projects/jira-bridge/jira-hub/.wt/scopes/openspec/specs/agent/spec.md`. Решения агентской
части — `openspec/changes/agent-v12-project-boards/design.md` (создаётся в Task 3).
**Worktree:** `/Users/andrey.epifancev/projects/jira-bridge/jira-agent/.wt/projects`, ветка `feat/35-projects`
(aep/jira-agent#35). Все команды — из корня worktree; основные checkout'ы `jira-agent` и `jira-hub` не трогать.
## Global Constraints
- Python ≥ 3.9 (на виртуалке 3.9.6, тесты — `/usr/bin/python3`), только стандартная библиотека; синтаксис 3.10+
(`X | Y` в аннотациях функций, `match`) запрещён — `tests/test_py39.py`. Для кортежей в аннотациях — `typing.Tuple`.
- Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — зелёным считается только весь
набор (до плана — 262 теста; набор идёт ≈2 минуты, прогон всего набора — в фоне).
- `AGENT_VERSION` SHALL быть 12.
- `ensure_sprint` SHALL брать доску из `board_id` команды; нет `board_id` (или он `null`) — из `settings.board_id`, как v11.
- `refresh` SHALL брать доски из `boards` (`[{id, type}]`, `type` — `"scrum"`, `"kanban"` или `null`). Нет ключа
`boards` (хаб до проектов) — одна доска `settings.board_id`, как v11, результат без `boards`.
- Для каждой доски с `type = null` — ровно один `GET /rest/agile/1.0/board/{id}`; тип из ответа (`type`) — `"scrum"` или
`"kanban"`, иначе `null`; ошибка запроса — `null`, строка в логе с номером доски и кодом ответа, `refresh` продолжается.
- Результат `refresh` — `boards: {"<id>": "scrum" | "kanban" | null}` только для досок, чей тип агент запрашивал; ключ —
строка.
- Имена спринтов — со всех досок типа `scrum` (известного или только что определённого) в порядке `boards`; `kanban` и
`null` пропускаются; задача в спринтах двух досок получает последнее найденное имя (как в v11).
- `ensure_sprint` на доске без спринтов (Jira ответила ошибкой на список спринтов) — `failed` с текстом Jira.
- Коммиты на русском с `(#35)`, трейлер `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Review Focus
1. Хаб с проектами прислал `boards: []` (ни у одного проекта нет доски), а в его настройках остался `board_id = 4216`
(решение 14 хаба) — агент не читает спринты доски из настроек, спринты задач — `null` (Task 2,
`test_empty_boards_no_settings_fallback`).
2. `GET /board/{id}` ответил 5xx (а не 404) — тип `null`, строка в логе с кодом, `refresh` завершается `done`, состояние
уходит в хаб (Task 2, `test_board_type_5xx_logged_refresh_continues`).
3. Одна и та же доска с `type = null` дважды в `boards` — один запрос типа, один ключ в результате (Task 2,
`test_duplicate_null_board_requested_once`).
4. `refresh {keys: []}` с доской неизвестного типа — тип всё равно определяется и возвращается (иначе хвост пакета без
задач оставил бы тип неизвестным) (Task 2, `test_empty_keys_still_resolves_boards`).
5. Доска, известная хабу как `scrum`, перестала отдавать спринты (стала канбаном, удалена) — `refresh` `failed` с текстом
Jira, как v11, а не молчаливое обнуление спринтов в зеркале (Task 2, `test_scrum_board_sprints_error_fails_refresh`;
решение R-32).
---
### Task 1: Спринты по доске и `ensure_sprint` с `board_id` из команды
**Модель исполнителя:** sonnet.
**Files:**
- Modify: `tests/fake_jira.py` — `FakeJira.__init__` (новые поля), маршруты Agile в `route` (стр. 352–380)
- Modify: `src/agent.py` — `Executor.__init__` и `Executor.run` (стр. 556–564, сброс `_sprints`), `Executor.sprints`
(стр. 838–851), `Executor.do_ensure_sprint` (стр. 853–873)
- Test: `tests/test_executor_links.py`
**Interfaces:**
- Consumes: —
- Produces:
- `FakeJira.boards: Dict[int, str]` — доска → тип, по умолчанию `{4216: "scrum"}`. `GET /rest/agile/1.0/board/{id}`:
есть — 200 `{"id": id, "name": "Доска <id>", "type": <тип>}`, нет — 404 `{"errorMessages": ["Board does not exist"]}`.
`GET /rest/agile/1.0/board/{id}/sprint` у доски с типом `"kanban"` — 400
`{"errorMessages": ["The board does not support sprints"]}`.
- `FakeJira.board_sprints: Dict[int, List[dict]]` — спринты доски; доски нет в словаре — общий `self.sprints` (как
раньше, чтобы старые тесты не менялись).
- `FakeJira.sprint_members: Dict[int, List[str]]` — id спринта → ключи задач; для такого спринта
`GET /sprint/{id}/issue` отдаёт эти ключи (с фильтром по `jql` `key in (…)`) вместо сравнения по имени.
- `FakeJira.all_sprints() -> List[dict]` — `self.sprints` + все списки `board_sprints`; по нему ищут спринт
`/sprint/{id}/issue` и `GET /rest/agile/1.0/issue/{key}`.
- `Executor._sprints: Dict[str, Dict[str, dict]]` — `str(доска)` → имя → спринт; `{}` в `__init__` и в начале `run`.
- `Executor.sprints(self, board: Any) -> Dict[str, dict]` — активные и будущие спринты доски `board` (постранично, как
v11), с кэшем на команду; ошибку Jira не глотает.
- [ ] **Step 1: Фейк — доски, спринты по доске, участники спринта**
Добавить в `FakeJira` поля и маршрут `GET /rest/agile/1.0/board/{id}` из Interfaces (регулярка `^/rest/agile/1\.0/board/(\d+)$`
не пересекается с `…/sprint`), 400 для канбан-доски в маршруте `/board/{id}/sprint`, список спринтов по `board_sprints`,
`all_sprints()` в маршрутах `/sprint/{id}/issue` и `/agile/1.0/issue/{key}`, `sprint_members` в `GET /sprint/{id}/issue`.
Run: `/usr/bin/python3 -m unittest tests.test_executor_links tests.test_refresh -v`
Expected: PASS (фейк по умолчанию ведёт себя как раньше).
- [ ] **Step 2: Падающие тесты**
В `tests/test_executor_links.py`, после `test_sprint_without_board`:
```python
def test_sprint_board_from_params(self):
self.fake.boards[5120] = "scrum"
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт K", board_id=5120)
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"], {"sprint": "Спринт K", "from": None})
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт K")
paths = [r[1] for r in self.fake.requests]
self.assertIn("/rest/agile/1.0/board/5120/sprint", paths)
self.assertNotIn("/rest/agile/1.0/board/4216/sprint", paths) # settings.board_id = 4216 не трогается
again = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт K", board_id=5120)
self.assertEqual(again["result"], {"sprint": "Спринт K", "changed": False})
def test_sprint_board_from_params_without_settings(self): # сценарий «Спринт по доске из команды»
self.ex.s["board_id"] = None
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=4216)
self.assertEqual(out["status"], "done", out)
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 2")
def test_sprint_unknown_name_names_param_board(self):
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=5120)
self.assertEqual(out["status"], "failed")
self.assertIn("5120", out["error"])
self.assertIn("Спринт K", out["error"])
def test_sprint_kanban_board_failed_with_jira_text(self):
self.fake.boards[5120] = "kanban"
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=5120)
self.assertEqual(out["status"], "failed")
self.assertIn("HTTP 400", out["error"])
self.assertIn("does not support sprints", out["error"])
self.assertFalse(out["retryable"])
self.assertIsNone(self.fake.issues["AURORA-1"]["sprint"])
def test_sprint_backlog_ignores_board(self):
self.fake.boards[5120] = "kanban"
self.fake.issues["AURORA-1"]["sprint"] = "Спринт 1"
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog", board_id=5120)
self.assertEqual(out["result"], {"sprint": None, "from": "Спринт 1"})
self.assertFalse(any("/board/" in r[1] for r in self.fake.requests))
```
`test_sprint_without_board` (без `board_id` в команде и в настройках) не меняется: ошибка по-прежнему содержит `board_id`.
Run: `/usr/bin/python3 -m unittest tests.test_executor_links -v`
Expected: FAIL `test_sprint_board_from_params` (идёт на доску 4216: «нет спринта «Спринт K»»),
`test_sprint_board_from_params_without_settings` («board_id не задан…»), `test_sprint_unknown_name_names_param_board`
(в тексте 4216, а не 5120); `test_sprint_kanban_board_failed_with_jira_text` и `test_sprint_backlog_ignores_board` могут
пройти уже сейчас — это допустимо, они фиксируют поведение.
- [ ] **Step 3: Реализация**
- `Executor._sprints = {}` в `__init__` и в начале `run` (вместо `None`).
- `sprints(self, board: Any) -> Dict[str, dict]` — тело v11, доска из аргумента, кэш `self._sprints[str(board)]`;
docstring: «Имя → спринт (active и future) доски board».
- `do_ensure_sprint`: `board = p.get("board_id") or self.s.get("board_id")` после ветки `backlog`; нет доски —
`failed` «board_id нет ни в команде, ни в настройках хаба — спринты недоступны»; дальше как v11 с `self.sprints(board)`,
в тексте «нет спринта …» — номер `board`.
- `sprint_names` пока зовёт `self.sprints(self.s["board_id"])` (переделывается в Task 2).
Run: `/usr/bin/python3 -m unittest tests.test_executor_links tests.test_refresh -v`
Expected: PASS.
- [ ] **Step 4: Весь набор**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Expected: `OK`.
- [ ] **Step 5: Commit**
```bash
git add tests/fake_jira.py tests/test_executor_links.py src/agent.py
git commit -m "feat(agent): ensure_sprint по доске из команды, кэш спринтов по доскам (#35)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 2: `refresh` с досками проектов: тип доски, спринты со всех scrum-досок, результат `boards`
**Модель исполнителя:** sonnet.
**Files:**
- Modify: `src/agent.py` — импорт `Tuple` из `typing` (стр. 34), новые `Executor.board_type`, `Executor.refresh_boards`
(раздел `refresh`, перед `sprint_names`), `Executor.sprint_names` (стр. 1024–1043), `Executor.do_refresh`
(стр. 1045–1095)
- Test: `tests/test_refresh.py`
**Interfaces:**
- Consumes: Task 1 — `Executor.sprints(board)`, `FakeJira.boards`, `FakeJira.board_sprints`, `FakeJira.sprint_members`.
- Produces:
- `Executor.board_type(self, board: Any) -> Optional[str]` — `GET /rest/agile/1.0/board/{board}`; `type` из ответа,
если он `"scrum"` или `"kanban"` (точное совпадение), иначе `None` и в лог
`"refresh: доска %s — тип «%s» не scrum/kanban" % (board, t)`; `HttpError` любого кода — `None` и в лог
`"refresh: доска %s — тип не определён (HTTP %d)" % (board, e.code)`; `ConnectionError` не ловит.
- `Executor.refresh_boards(self, p: dict) -> Tuple[List[Any], Optional[Dict[str, Optional[str]]]]` — (scrum-доски в
порядке `boards` без повторов по `str(id)`, словарь для результата). Нет ключа `boards` — `([settings.board_id]` или
`[]`, `None)`. Есть — для каждой доски с `type is None` один `board_type`; словарь — запрошенные доски
(`str(id)` → тип), пустой словарь возвращается как `None`. Элемент без `id` пропускается.
- `Executor.sprint_names(self, keys: List[str], boards: List[Any]) -> Dict[str, str]` — как v11, но по каждой доске
`boards` по порядку (`for board in boards: for name, sp in self.sprints(board).items(): …`); пустые `keys` или
`boards` — `{}`.
- `do_refresh`: первым действием `scrum, types = self.refresh_boards(p)` (до поиска и до раннего выхода по пустым
`keys`); `names = self.sprint_names(keys, scrum)`; если `types is not None` — `result["boards"] = types` (и в раннем
выходе по пустым `keys`).
- [ ] **Step 1: Падающие тесты**
В `tests/test_refresh.py` поменять вызов в `test_sprint_names_paginated` на
`self.ex.sprint_names(["AURORA-%d" % i for i in range(1, 101)], [4216])` и добавить:
```python
JQL = "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"
def refresh(self, **params):
return self.ex.run({"id": 5, "type": "refresh", "params": params})
def logged(self, fn):
logs, orig = [], agent.log
agent.log = logs.append
try:
return fn(), logs
finally:
agent.log = orig
def test_two_boards_one_new(self): # сценарий «Две доски, одна новая»
self.fake.boards[5120] = "kanban"
self.fake.add("AURORA-1", sprint="Спринт 2")
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}])
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
paths = [r[1] for r in self.fake.requests]
self.assertEqual(paths.count("/rest/agile/1.0/board/5120"), 1)
self.assertNotIn("/rest/agile/1.0/board/5120/sprint", paths)
self.assertNotIn("/rest/agile/1.0/board/4216", paths) # тип известен — не спрашиваем
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
def test_board_not_found(self): # сценарий «Доска не найдена»
self.fake.add("AURORA-1")
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 9999, "type": None}]))
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["boards"], {"9999": None})
self.assertTrue(any("9999" in l and "HTTP 404" in l for l in logs), logs)
self.assertEqual(len(self.hub.states), 1)
def test_new_scrum_board_sprints_read(self):
self.fake.boards[5120] = "scrum"
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
self.fake.add("AURORA-1", sprint="Спринт K")
out = self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": None}])
self.assertEqual(out["result"]["boards"], {"5120": "scrum"})
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт K")
self.assertNotIn("/rest/agile/1.0/board/4216/sprint", [r[1] for r in self.fake.requests])
def test_old_hub_without_boards(self): # сценарий «Старый хаб»
self.fake.add("AURORA-1", sprint="Спринт 2")
out = self.refresh(jql=self.JQL)
self.assertNotIn("boards", out["result"])
self.assertIn("/rest/agile/1.0/board/4216/sprint", [r[1] for r in self.fake.requests])
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
def test_known_boards_no_type_requests(self):
self.fake.add("AURORA-1", sprint="Спринт 2")
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])
self.assertNotIn("boards", out["result"])
self.assertNotIn("/rest/agile/1.0/board/4216", [r[1] for r in self.fake.requests])
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
def test_empty_boards_no_settings_fallback(self):
self.fake.add("AURORA-1", sprint="Спринт 2")
out = self.refresh(jql=self.JQL, boards=[])
self.assertEqual(out["status"], "done", out)
self.assertNotIn("boards", out["result"])
self.assertFalse(any("/rest/agile/1.0/board/" in r[1] for r in self.fake.requests))
self.assertIsNone(self.hub.states[0]["issues"][0]["sprint"])
def test_board_type_5xx_logged_refresh_continues(self):
self.fake.add("AURORA-1")
self.fake.fail_next.append((500, {})) # первый вызов Jira в refresh — тип доски
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": None}]))
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["boards"], {"5120": None})
self.assertTrue(any("5120" in l and "HTTP 500" in l for l in logs), logs)
def test_board_other_type_is_null(self):
self.fake.boards[7000] = "simple"
out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 7000, "type": None}]))
self.assertEqual(out["result"]["boards"], {"7000": None})
self.assertTrue(any("7000" in l and "simple" in l for l in logs), logs)
def test_duplicate_null_board_requested_once(self):
self.fake.boards[5120] = "kanban"
out = self.refresh(keys=[], boards=[{"id": 5120, "type": None}, {"id": 5120, "type": None}])
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
self.assertEqual([r[1] for r in self.fake.requests].count("/rest/agile/1.0/board/5120"), 1)
def test_empty_keys_still_resolves_boards(self):
self.fake.boards[5120] = "kanban"
out = self.refresh(keys=[], boards=[{"id": 5120, "type": None}])
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0, "boards": {"5120": "kanban"}})
def test_last_found_sprint_wins_across_boards(self):
self.fake.boards[5120] = "scrum"
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
self.fake.add("AURORA-1")
self.fake.sprint_members = {2: ["AURORA-1"], 7: ["AURORA-1"]}
both = [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": "scrum"}]
self.refresh(jql=self.JQL, boards=both)
self.assertEqual(self.hub.states[-1]["issues"][0]["sprint"], "Спринт K")
self.refresh(jql=self.JQL, boards=both[::-1])
self.assertEqual(self.hub.states[-1]["issues"][0]["sprint"], "Спринт 2")
def test_scrum_board_sprints_error_fails_refresh(self): # R-32
self.fake.boards[5120] = "kanban" # хаб думает, что scrum
self.fake.add("AURORA-1")
out = self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": "scrum"}])
self.assertEqual(out["status"], "failed")
self.assertIn("HTTP 400", out["error"])
self.assertEqual(self.hub.states, [])
```
`FakeJira.search_keys` на любую JQL без `key in` и без `reporter` отдаёт задачи с меткой `AWPCA` — `self.JQL` подходит.
В `test_board_type_5xx_logged_refresh_continues` 500 достаётся запросу типа доски потому, что `refresh_boards` — первый
вызов Jira в `do_refresh` (R-30).
Run: `/usr/bin/python3 -m unittest tests.test_refresh -v`
Expected: FAIL — `KeyError: 'boards'` в результате, `TypeError` у `test_sprint_names_paginated` (лишний аргумент) и
запросы к доске 4216 там, где их быть не должно; `test_old_hub_without_boards` проходит уже сейчас.
- [ ] **Step 2: Реализация**
`board_type`, `refresh_boards`, `sprint_names(keys, boards)` и правка `do_refresh` — по Interfaces. Комментарий R-6 у
`sprint_names` сохранить.
Run: `/usr/bin/python3 -m unittest tests.test_refresh tests.test_executor_links -v`
Expected: PASS.
- [ ] **Step 3: Весь набор**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Expected: `OK`.
- [ ] **Step 4: Commit**
```bash
git add src/agent.py tests/test_refresh.py
git commit -m "feat(agent): refresh по доскам проектов — тип доски, спринты со всех scrum-досок, результат boards (#35)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 3: Версия 12, документация, change решений агента
**Модель исполнителя:** haiku (готовый текст).
**Files:**
- Modify: `src/agent.py:36` (`AGENT_VERSION`)
- Modify: `tests/test_folder.py:185-186` (`test_version`)
- Modify: `README.md` — абзац «Остальные настройки…» (стр. 27), строки `ensure_sprint` и `refresh` таблицы команд
(стр. 173, 175), пункт «Версия» (стр. 193–200), пункт 3 «Известных ограничений» (стр. 237–238)
- Modify: `ARCHITECTURE.md` — строка «Исполнитель» таблицы разделов (стр. 14)
- Modify: `CLAUDE.md` — раздел «Первая подсистема», строка «Последний change —»
- Create: `openspec/changes/agent-v12-project-boards/design.md` (через `openspec new change agent-v12-project-boards`)
**Interfaces:**
- Consumes: Task 1–2.
- Produces: `AGENT_VERSION = 12`.
- [ ] **Step 1: Падающий тест версии**
В `tests/test_folder.py`, `test_version`: `assertEqual(agent.AGENT_VERSION, 11)` → `assertEqual(agent.AGENT_VERSION, 12)`.
Run: `/usr/bin/python3 -m unittest tests.test_folder -v`
Expected: FAIL — `AssertionError: 11 != 12`.
- [ ] **Step 2: Версия**
В `src/agent.py` `AGENT_VERSION = 11` → `AGENT_VERSION = 12`.
Run: `/usr/bin/python3 -m unittest tests.test_folder -v`
Expected: PASS.
- [ ] **Step 3: README**
- Стр. 27: `(id полей, типы задач, карта переходов, типы связей, доска, темп)` → `(id полей, типы задач, карта переходов,
типы связей, темп)`, а после предложения дописать: «Доски Jira (с v12) приходят в параметрах команд: `ensure_sprint` —
`board_id`, `refresh` — `boards`; `board_id` из настроек — запасной для команд старого хаба.»
- Строка `ensure_sprint`: `переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и
будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira`.
- Строка `refresh`: дописать в конец ячейки `; доски с неизвестным типом (`boards`, `type = null`) — запрос
`/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок`.
- Пункт «Версия»: `(сейчас 11)` → `(сейчас 12)`; после строки «Агент v10 на `ensure_attachment` …» дописать строкой того
же отступа:
```markdown
Версия 12 — доски проектов из параметров команд (`ensure_sprint.board_id`, `refresh.boards`), тип доски в результате
`refresh`, спринты со всех scrum-досок; без этих параметров — `board_id` из настроек, как v11. Хаб с проектами
выкатывается только после v12 (design хаба `projects`, решение 14).
```
- Пункт 3 ограничений: `**Спринт в `refresh` — только активные и будущие спринты доски.**` → `**Спринт в `refresh` —
только активные и будущие спринты scrum-досок.**`; после «У задач закрытых спринтов `sprint = null`.» дописать: «Задачи
канбан-досок и досок неизвестного типа — тоже `null`. Задача в спринтах двух досок получает имя спринта последней по
списку `boards`. Если scrum-доска не отдала спринты (удалена, стала канбаном), `refresh` завершается `failed` с текстом
Jira (R-32).»
- [ ] **Step 4: ARCHITECTURE.md и CLAUDE.md**
- `ARCHITECTURE.md`, строка «Исполнитель», колонка «Ответственность» — дописать в конец: `; доски — из параметров команд
(`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной`.
- `CLAUDE.md`, строку `Последний change — …` заменить на:
```markdown
Последний change — `../jira-hub/openspec/changes/projects/` (агент v12: доски проектов из параметров команд, тип доски в `refresh`; решения агента — `openspec/changes/agent-v12-project-boards/design.md`), план — `docs/superpowers/plans/2026-10-19-projects-agent.md`.
```
- [ ] **Step 5: Change решений агента**
Run: `openspec new change agent-v12-project-boards`
Expected: создан каталог `openspec/changes/agent-v12-project-boards/`.
Создать `openspec/changes/agent-v12-project-boards/design.md`; таблица — те же строки R-30…R-35, что в разделе
«Decisions made without discussion» этого плана; решения, принятые при исполнении, контроллер дописывает следующими
номерами, начиная с R-36:
```markdown
# agent-v12-project-boards — решения агента
Спека и дизайн изменения — в `jira-hub/openspec/changes/projects/` (issue aep/jira-hub#61, aep/jira-agent#35).
Агентская часть — решения 7, 14, 15 дизайна хаба и требования `agent` «Доски проектов» (ADDED) и «Состав и настройки
агента» (MODIFIED); контракт команд — `commands` (`ensure_sprint.board_id`, `refresh.boards`, результат `boards`).
## Decisions made without discussion
Решения 1–21 записаны в `jira-hub/openspec/changes/projects/design.md` (раздел «Decisions made without discussion»;
для агента — № 6, 7, 14); здесь — только решения агентской части.
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
<строки R-30…R-35 из плана>
```
- [ ] **Step 6: Весь набор и синтаксис 3.9**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Expected: `OK` (262 + новые из Task 1–2).
Run: `/usr/bin/python3 -m unittest tests.test_py39 -v`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add src/agent.py tests/test_folder.py README.md ARCHITECTURE.md CLAUDE.md openspec/changes/agent-v12-project-boards
git commit -m "chore(agent): версия 12, README и решения агента для досок проектов (#35)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
## Decisions made without discussion
Нумерация рулингов агентской части — с R-30, чтобы не пересечься с рулингами исполнения хабовой части change.
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| R-30 | 2026-10-19 | Типы досок определяются первым шагом `refresh` — до поиска и в том числе при пустых `keys` | Спека: «для каждой доски с `type = null` агент SHALL запросить» — без условий; хвост пакета `refresh {keys: []}` не должен оставлять тип неизвестным | Лишний `GET /board/{id}` на доску неизвестного типа, если поиск затем упадёт; результат с типом при этом теряется до следующего `refresh` |
| R-31 | 2026-10-19 | Любой `HttpError` на `GET /board/{id}` (в том числе 5xx) — тип `null` и строка в логе, `refresh` продолжается; `ConnectionError` не ловится — весь `refresh` `failed` с повтором, как при обрыве связи с Jira | Спека: «ошибка запроса — в лог, `refresh` продолжается»; `null` тип в хабе не меняет, следующий `refresh` спросит снова; обрыв связи — не свойство доски | Временный 5xx откладывает определение типа до следующего `refresh` (автообновление) |
| R-32 | 2026-10-19 | Ошибка чтения спринтов scrum-доски (известной или только что определённой) в `refresh` не глотается: `refresh` `failed` с текстом Jira, как v11 | Пропуск доски записал бы в зеркало `sprint = null` всем её задачам; спека о такой ошибке молчит | Одна сломанная доска (удалена, стала канбаном) останавливает обновление зеркала всех проектов, пока владелец не поправит доску в базе хаба |
| R-33 | 2026-10-19 | Запасной `settings.board_id` — только когда ключа `boards` нет (`refresh`) или `board_id` нет / `null` (`ensure_sprint`); `boards: []` — ни одной доски, спринты не читаются | Новый хаб оставляет `board_id` в настройках (решение 14 хаба) и шлёт `boards` всегда; `[]` от него значит «у проектов нет досок», а не «старый хаб» | Если старый хаб когда-нибудь пришлёт `boards: []`, спринты пропадут из зеркала; такого хаба нет |
| R-34 | 2026-10-19 | Ключ `boards` в результате `refresh` — только если агент запросил тип хотя бы одной доски; иначе ключа нет | Спека: результат «для досок, чей тип агент запрашивал»; сценарий «Старый хаб» — «результат без `boards`» | Хаб SHALL принимать результат без `boards`; если он ждёт `{}`, — ошибка разбора результата в хабе |
| R-35 | 2026-10-19 | Доски сравниваются и выводятся по `str(id)`; повторы в `boards` отбрасываются (запрос типа — один); элемент без `id` пропускается; тип принимается только точным `"scrum"`/`"kanban"`, иное (`"simple"` и т. п.) — `null` со строкой в логе; спринты читаются в порядке `boards`, последнее найденное имя побеждает | Ключи результата в спеке — строки; хаб шлёт без повторов, но повтор не должен стоить второго запроса; сценарий спеки — «один раз» | Доска `"4216"` строкой и `4216` числом считаются одной; другой регистр типа от Jira (`"Scrum"`) дал бы `null` |
## После плана
- Финальное ревью ветки (opus), затем PR в aep/jira-agent со ссылкой на #35 и на change `projects` хаба. Агент
сливается и публикуется первым (design хаба, решения 14 и 15).
- После merge: `uv run --project ../jira-hub jira-hub agent publish src/agent.py` из основного checkout `jira-agent` на
main — пишет в каталог живого хаба, выполняется только по отдельной команде владельца; проверка — MCP `agent_status`
«обновление: ok v11 → v12». Затем — выкатка хаба (шаги 2–4 решения 14 хаба).