# 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: {"": "scrum" | "kanban" | null}` только для досок, чей тип агент запрашивал; ключ — строка. - Имена спринтов — со всех досок типа `scrum` (известного или только что определённого) в порядке `boards`; `kanban` и `null` пропускаются; задача в спринтах двух досок получает последнее найденное имя (как в v11). - `ensure_sprint` на доске без спринтов (Jira ответила ошибкой на список спринтов) — `failed` с текстом Jira. - Коммиты на русском с `(#35)`, трейлер `Co-Authored-By: Claude Opus 5.5 `. ## 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": "Доска ", "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 " ``` --- ### 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 " ``` --- ### 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 " ``` --- ## 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 хаба).