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

37 KiB
Raw Blame History

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:

    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
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]) и добавить:

    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
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 …» дописать строкой того же отступа:

  Версия 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 — … заменить на:

Последний 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:

# 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
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 хаба).