jira-agent/docs/superpowers/plans/2026-10-11-bridge-core-agent.md
Andrey Epifancev bdcdc3acce plan: bridge-core — план реализации агента (7 задач)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 21:35:21 +04:00

107 KiB
Raw Blame History

Мост Jira — агент (jira-agent) — план реализации

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: Один файл agent.py для виртуалки с доступом к Jira: опрашивает хаб, выполняет команды «проверь, потом сделай», ведёт журнал на диске, возвращает хабу состояние задач.

Architecture: src/agent.py (Python 3.9, только stdlib): JiraClient (REST v2 + Agile, 429/503 по Retry-After), HubClient, Journal (jsonl, только дозапись), Executor (по методу на тип команды, каждая — чтение → сравнение → запись разницы), Runner (цикл claim → пауза → журнал → выполнение → отчёт до подтверждения; восстановление по журналу при старте). Политику темпа агент не знает: пауза приходит с командой, а зазор внутри многошаговых команд — из настроек хаба.

Tech Stack: Python 3.9.6 (/usr/bin/python3 и здесь, и на виртуалке), stdlib urllib, json, http.server (только в тестах), unittest.

Spec: ../jira-hub/openspec/changes/bridge-core/ — specs/agent/spec.md (агент), specs/commands/spec.md (контракт команд), specs/hub-api/spec.md (маршруты /agent/*), design.md. План хаба — ../jira-hub/docs/superpowers/plans/2026-10-11-bridge-core-hub.md.

Global Constraints

  • Python >= 3.9, только стандартная библиотека; никаких match, X | Y в аннотациях, str.removeprefix допустим (3.9). Тесты запускать /usr/bin/python3 -m unittest discover -s tests -t ..
  • Учётные данные Jira — только в agent.json; в хаб, журнал и логи не попадают.
  • Одна команда в полёте; started/finished/acked в journal.jsonl; отчёт повторяется до подтверждения хаба.
  • Все вызовы Jira идут через JiraClient.call: счётчики calls, rate_limited, last_call_at; 429/503 — Retry-After или 60 с × попытка, до 5 раз.
  • Семантика связей: source блокирует target; POST /issueLink — inwardIssue = source, outwardIssue = target; в GET у X outwardIssue = Y значит «X → Y».
  • Язык кода и комментариев — русский, идентификаторы — английские; коммиты — русские, с Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>.
  • Файл agent.py лежит в src/ (хук require-plan защищает src/); на виртуалку копируется один файл плюс agent.json.

Review Focus

  1. Описание в ensure_fields с переводами строк и wiki-разметкой должно сравниваться точно, без «нормализации» (иначе каждая команда будет перезаписывать описание). Тест — Task 3 (test_fields_description_exact_compare).
  2. ensure_status, когда текущий статус уже внутри пути (например «Разработка» при пути Разработка → Готово): выполняется только остаток пути. Тест — Task 4 (test_status_resumes_from_middle).
  3. refresh при пустом списке keys (все временные ключи отвалились) — done с нулём задач, без вызовов Jira. Тест — Task 5 (test_refresh_empty_keys).
  4. Отчёт хабу, когда хаб вернул 404/422 (команда отменена с force или удалена): агент помечает acked и идёт дальше, не зацикливается. Тест — Task 6 (test_result_rejected_is_acked).
  5. Ctrl-C во время паузы перед командой: команда не начинается, журнал без started, процесс выходит. Тест — Task 6 (test_stop_during_pause).

Структура файлов

Файл Ответственность
src/agent.py весь агент: конфиг, HTTP-клиенты, журнал, исполнители, чтение состояния, цикл, CLI (run, check, prune)
src/agent.example.json шаблон настроек
tests/_common.py путь к src, общие настройки хаба для тестов, фабрики
tests/fake_jira.py фейковый Jira Server в памяти (http.server)
tests/fake_hub.py фейковый хаб (маршруты /agent/*)
tests/test_*.py unittest
README.md, CLAUDE.md, ARCHITECTURE.md доставка, запуск, launchd, prune

Task 1: Скелет агента, HTTP-клиенты, фейковый Jira, check

Files:

  • Create: src/agent.py, src/agent.example.json, tests/__init__.py (пустой), tests/_common.py, tests/fake_jira.py
  • Test: tests/test_clients.py

Interfaces:

  • Produces (в agent.py): AGENT_VERSION = 1, now_iso() -> str, log(msg), load_config(path) -> dict, HttpError(code, text, headers), Http(base, headers, timeout, ctx).call(method, path, body=None, params=None), JiraClient(cfg).get/post/put/delete, счётчики calls, rate_limited, last_call_at, reset_counters(), sleep (подменяемый), HubClient(cfg).claim(agent_version, settings_version, last_jira_call_at), .settings(), .command(id), .result(id, payload), .state(payload), .known_keys(), main(argv) -> int с подкомандами run | check | prune (run и prune — заглушки до Task 6/2).

  • Produces (тесты): fake_jira.FakeJira() (.base_url, .add(key, in_base=True, **fields), .issues, .links, .comments, .sprints, .requests, .writes(), .fail_next, .stop()), _common.SETTINGS, _common.jira_cfg(fake) -> dict, _common.agent (модуль).

  • Step 1: Общие тестовые файлы

tests/_common.py:

"""Общее для тестов: путь к src, настройки хаба как их отдаёт /agent/settings, конфиг агента под фейковый Jira."""
import os
import sys

SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src")
if SRC not in sys.path:
    sys.path.insert(0, SRC)

import agent  # noqa: E402

ZERO_PACING = {"ensure_issue": 0, "ensure_fields": 0, "ensure_comment": 0, "ensure_status": 0, "ensure_link": 0,
               "ensure_sprint": 0, "refresh": 0}

SETTINGS = {
    "base_jql": "project = AURORA AND labels = AWPCA",
    "project_key": "AURORA",
    "issue_types": {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"}},
    "epic_name_field_id": "customfield_15304",
    "field_ids": {"description": "description", "priority": "priority", "duedate": "duedate", "labels": "labels",
                  "planned_start": "customfield_10801", "planned_end": "customfield_10802"},
    "epic_link": {"mode": "field", "field_id": "customfield_15302"},
    "priority_map": {"Наивысший": "Критичный", "Высокий": "Высокий", "Средний": "Средний", "Низкий": "Низкий"},
    "priority_value_key": "name",
    "labels": ["AWPCA"],
    "status_labels": {},
    "kind_labels": {},
    "comment_marker": "Актуализация 10.10.2026",
    "transitions_map": {},
    "link_types": {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"},
    "board_id": 4216,
    "pacing": dict(ZERO_PACING),
    "min_agent_version": 1,
    "settings_version": 1,
}


def jira_cfg(fake, **over):
    cfg = {"hub_url": "http://127.0.0.1:1", "hub_token": "agent-token",
           "jira": {"base_url": fake.base_url, "auth": {"type": "basic", "user": "me", "password": "secret"}},
           "verify_ssl": True, "ca_bundle": None, "timeout": 5, "poll_seconds": 0}
    cfg.update(over)
    return cfg


def no_sleep(_seconds):
    pass

tests/fake_jira.py:

"""Фейковый Jira Server для тестов агента: состояние в памяти, http.server в потоке,
только маршруты, которые использует агент. Семантика связей — как у настоящего Jira Server."""
import json
import re
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse

WORKFLOW = {   # статус → [(имя перехода, целевой статус)]
    "Ожидание разработки": [("Взять в разработку", "Разработка"), ("Ожидает анализа", "Ожидает анализа"), ("Снято", "Снято")],
    "Разработка": [("Малое исправление", "Готово к тестированию"), ("Готово", "Готово"), ("Снято", "Снято")],
    "Ожидает анализа": [("Анализ", "Анализ"), ("Снято", "Снято")],
    "Анализ": [("Готово", "Готово")],
    "Готово к тестированию": [("Тестирование", "Тестирование")],
    "Готово": [],
    "Снято": [],
}
LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начало заблокировано", "outward": "Блокирует начало"},
              {"name": "ASUPR_Связанные", "inward": "Связанные", "outward": "Связанные"}]
CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304")


class FakeJira:
    def __init__(self, project="AURORA"):
        self.project = project
        self.issues = {}
        self.comments = {}
        self.links = []
        self.sprints = [{"id": 1, "name": "Спринт 1", "state": "active"}, {"id": 2, "name": "Спринт 2", "state": "future"}]
        self.seq, self.link_seq, self.comment_seq = 1000, 100, 10
        self.fail_next = []          # [(код, {заголовки})] — следующие ответы с ошибкой
        self.requests = []           # [(метод, путь)]
        self.lock = threading.Lock()
        fake = self

        class Handler(BaseHTTPRequestHandler):
            def log_message(self, *a):
                pass

            def do_GET(self):
                fake.handle(self, "GET")

            def do_POST(self):
                fake.handle(self, "POST")

            def do_PUT(self):
                fake.handle(self, "PUT")

            def do_DELETE(self):
                fake.handle(self, "DELETE")

        self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
        threading.Thread(target=self.server.serve_forever, daemon=True).start()

    @property
    def base_url(self):
        return "http://127.0.0.1:%d" % self.server.server_port

    def stop(self):
        self.server.shutdown()

    def add(self, key, in_base=True, **fields):
        base = {"summary": "Тема " + key, "description": None, "status": "Ожидание разработки", "priority": "Средний",
                "assignee": None, "reporter": "someone", "duedate": None, "labels": ["AWPCA"] if in_base else [],
                "issuetype": "Задача", "created": "2026-10-01T10:00:00.000+0300", "updated": "2026-10-01T10:00:00.000+0300",
                "sprint": None}
        base.update({c: None for c in CUSTOM})
        base.update(fields)
        self.issues[key] = base
        return base

    def writes(self):
        return [r for r in self.requests if r[0] in ("POST", "PUT", "DELETE")]

    # --- представление ---------------------------------------------------------
    def _user(self, name):
        return {"name": name, "displayName": name} if name else None

    def issue_json(self, key):
        f = self.issues[key]
        fields = {"summary": f["summary"], "description": f["description"], "status": {"name": f["status"]},
                  "priority": {"name": f["priority"]}, "assignee": self._user(f["assignee"]), "reporter": self._user(f["reporter"]),
                  "duedate": f["duedate"], "labels": list(f["labels"]), "issuetype": {"name": f["issuetype"]},
                  "created": f["created"], "updated": f["updated"], "issuelinks": self.links_of(key)}
        fields.update({c: f.get(c) for c in CUSTOM})
        return {"key": key, "id": str(10000 + sorted(self.issues).index(key)), "fields": fields}

    def links_of(self, key):
        out = []
        for l in self.links:
            if l["source"] == key:
                out.append({"id": str(l["id"]), "type": {"name": l["type"]}, "outwardIssue": {"key": l["target"]}})
            elif l["target"] == key:
                out.append({"id": str(l["id"]), "type": {"name": l["type"]}, "inwardIssue": {"key": l["source"]}})
        return out

    def search_keys(self, jql):
        m = re.search(r"key in \(([^)]*)\)", jql or "")
        if m:
            keys = [k.strip() for k in m.group(1).split(",")]
            return [k for k in keys if k in self.issues]
        if "reporter = currentUser()" in (jql or ""):
            return [k for k, f in self.issues.items() if f["reporter"] == "me"]
        return [k for k, f in self.issues.items() if "AWPCA" in f["labels"]]

    # --- маршрутизация ----------------------------------------------------------
    def handle(self, h, method):
        url = urlparse(h.path)
        q = {k: v[0] for k, v in parse_qs(url.query).items()}
        length = int(h.headers.get("Content-Length") or 0)
        body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {}
        with self.lock:
            self.requests.append((method, url.path))
            if self.fail_next:
                code, headers = self.fail_next.pop(0)
                return self._send(h, code, {"errorMessages": ["имитация ошибки"]}, headers)
            try:
                code, payload = self.route(method, url.path, q, body)
            except KeyError as e:
                code, payload = 404, {"errorMessages": ["Issue Does Not Exist: %s" % e]}
        self._send(h, code, payload)

    def _send(self, h, code, payload, headers=None):
        data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else b""
        h.send_response(code)
        for k, v in (headers or {}).items():
            h.send_header(k, v)
        h.send_header("Content-Type", "application/json")
        h.send_header("Content-Length", str(len(data)))
        h.end_headers()
        h.wfile.write(data)

    def route(self, method, path, q, body):
        p = path
        if p == "/rest/api/2/myself":
            return 200, {"name": "me", "displayName": "Me"}
        if p == "/rest/api/2/search":
            keys = self.search_keys(q.get("jql"))
            start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50))
            page = keys[start:start + maxr]
            return 200, {"startAt": start, "maxResults": maxr, "total": len(keys), "issues": [self.issue_json(k) for k in page]}
        if p == "/rest/api/2/issue" and method == "POST":
            self.seq += 1
            key = "%s-%d" % (self.project, self.seq)
            f = body["fields"]
            self.add(key, summary=f.get("summary"), description=f.get("description"),
                     priority=(f.get("priority") or {}).get("name", "Средний"), duedate=f.get("duedate"),
                     labels=list(f.get("labels") or []), issuetype=self._type_name(f.get("issuetype")),
                     reporter="me", created="2026-10-11T12:00:00.000+0300",
                     **{c: f.get(c) for c in CUSTOM})
            return 201, {"id": "1", "key": key}
        if p == "/rest/api/2/issueLinkType":
            return 200, {"issueLinkTypes": LINK_TYPES}
        if p == "/rest/api/2/issueLink" and method == "POST":
            self.link_seq += 1
            self.links.append({"id": self.link_seq, "type": body["type"]["name"],
                               "source": body["inwardIssue"]["key"], "target": body["outwardIssue"]["key"]})
            return 201, {}
        m = re.match(r"^/rest/api/2/issueLink/(\d+)$", p)
        if m and method == "DELETE":
            self.links = [l for l in self.links if str(l["id"]) != m.group(1)]
            return 204, None
        m = re.match(r"^/rest/api/2/issue/([^/]+)$", p)
        if m:
            key = m.group(1)
            if method == "GET":
                return 200, self.issue_json(key)
            if method == "PUT":
                f = self.issues[key]
                for k, v in body.get("fields", {}).items():
                    if k in ("priority", "assignee"):
                        f[k] = (v or {}).get("name")
                    elif k == "labels":
                        f[k] = list(v)
                    else:
                        f[k] = v
                f["updated"] = "2026-10-11T13:00:00.000+0300"
                return 204, None
        m = re.match(r"^/rest/api/2/issue/([^/]+)/comment$", p)
        if m:
            key = m.group(1)
            self.issues[key]
            lst = self.comments.setdefault(key, [])
            if method == "GET":
                return 200, {"comments": lst}
            self.comment_seq += 1
            lst.append({"id": str(self.comment_seq), "body": body["body"]})
            return 201, lst[-1]
        m = re.match(r"^/rest/api/2/issue/([^/]+)/comment/(\d+)$", p)
        if m and method == "PUT":
            for c in self.comments.get(m.group(1), []):
                if c["id"] == m.group(2):
                    c["body"] = body["body"]
            return 200, {}
        m = re.match(r"^/rest/api/2/issue/([^/]+)/transitions$", p)
        if m:
            key = m.group(1)
            trs = WORKFLOW.get(self.issues[key]["status"], [])
            if method == "GET":
                return 200, {"transitions": [{"id": str(i + 1), "name": n, "to": {"name": to}} for i, (n, to) in enumerate(trs)]}
            tid = int(body["transition"]["id"]) - 1
            self.issues[key]["status"] = trs[tid][1]
            return 204, None
        m = re.match(r"^/rest/agile/1\.0/board/(\d+)/sprint$", p)
        if m:
            states = (q.get("state") or "active,future").split(",")
            return 200, {"values": [s for s in self.sprints if s["state"] in states], "isLast": True}
        m = re.match(r"^/rest/agile/1\.0/sprint/(\d+)/issue$", p)
        if m:
            sp = next(s for s in self.sprints if s["id"] == int(m.group(1)))
            if method == "GET":
                keys = [k for k in self.search_keys(q.get("jql") or "key in ()") if self.issues[k]["sprint"] == sp["name"]]
                return 200, {"issues": [{"key": k} for k in keys]}
            for k in body["issues"]:
                self.issues[k]["sprint"] = sp["name"]
            return 204, None
        if p == "/rest/agile/1.0/backlog/issue" and method == "POST":
            for k in body["issues"]:
                self.issues[k]["sprint"] = None
            return 204, None
        m = re.match(r"^/rest/agile/1\.0/issue/([^/]+)$", p)
        if m:
            name = self.issues[m.group(1)]["sprint"]
            sp = next((s for s in self.sprints if s["name"] == name), None)
            return 200, {"key": m.group(1), "fields": {"sprint": sp}}
        return 404, {"errorMessages": ["нет маршрута %s %s" % (method, p)]}

    def _type_name(self, ref):
        ref = ref or {}
        if ref.get("name"):
            return ref["name"]
        return {"10100": "Задача", "12300": "Фича"}.get(str(ref.get("id")), "Задача")
  • Step 2: Тесты клиентов

tests/test_clients.py:

import unittest

from _common import agent, jira_cfg, no_sleep
from fake_jira import FakeJira


class ClientsTest(unittest.TestCase):
    def setUp(self):
        self.fake = FakeJira()
        self.jira = agent.JiraClient(jira_cfg(self.fake))
        self.jira.sleep = no_sleep

    def tearDown(self):
        self.fake.stop()

    def test_get_and_counters(self):
        self.fake.add("AURORA-1")
        data = self.jira.get("/rest/api/2/issue/AURORA-1", fields="summary")
        self.assertEqual(data["fields"]["summary"], "Тема AURORA-1")
        self.assertEqual(self.jira.calls, 1)
        self.assertIsNotNone(self.jira.last_call_at)
        self.assertEqual(self.fake.requests[-1], ("GET", "/rest/api/2/issue/AURORA-1"))

    def test_retry_on_429_with_retry_after(self):
        self.fake.add("AURORA-1")
        self.fake.fail_next.append((429, {"Retry-After": "0"}))
        self.fake.fail_next.append((503, {}))
        data = self.jira.get("/rest/api/2/issue/AURORA-1")
        self.assertEqual(data["key"], "AURORA-1")
        self.assertEqual(self.jira.rate_limited, 2)
        self.assertEqual(self.jira.calls, 1)

    def test_http_error_not_retried(self):
        with self.assertRaises(agent.HttpError) as cm:
            self.jira.get("/rest/api/2/issue/NOPE-1")
        self.assertEqual(cm.exception.code, 404)
        self.assertIn("Does Not Exist", cm.exception.text)

    def test_gives_up_after_five(self):
        self.fake.add("AURORA-1")
        self.fake.fail_next.extend([(429, {"Retry-After": "0"})] * 5)
        with self.assertRaises(agent.HttpError):
            self.jira.get("/rest/api/2/issue/AURORA-1")
        self.assertEqual(self.jira.rate_limited, 5)

    def test_basic_auth_header_and_no_secret_in_repr(self):
        self.assertTrue(self.jira.headers["Authorization"].startswith("Basic "))
        self.assertNotIn("secret", repr(self.jira))

    def test_load_config_requires_fields(self):
        import json
        import os
        import tempfile
        with tempfile.TemporaryDirectory() as d:
            path = os.path.join(d, "agent.json")
            with open(path, "w", encoding="utf-8") as f:
                json.dump({"hub_url": "http://x", "hub_token": "t"}, f)
            with self.assertRaises(SystemExit):
                agent.load_config(path)
            with open(path, "w", encoding="utf-8") as f:
                json.dump(jira_cfg(self.fake), f)
            cfg = agent.load_config(path)
            self.assertEqual(cfg["poll_seconds"], 0)

    def test_check_command(self):
        import json
        import os
        import tempfile
        with tempfile.TemporaryDirectory() as d:
            path = os.path.join(d, "agent.json")
            with open(path, "w", encoding="utf-8") as f:
                json.dump(jira_cfg(self.fake), f)
            # хаб недоступен (порт 1), Jira доступен — check печатает оба результата и возвращает 1
            self.assertEqual(agent.main(["--config", path, "check"]), 1)


if __name__ == "__main__":
    unittest.main()
  • Step 3: Запустить — падает

Run: /usr/bin/python3 -m unittest tests.test_clients -v Expected: FAIL — ModuleNotFoundError: No module named 'agent' (или AttributeError)

  • Step 4: agent.py (скелет)

src/agent.example.json:

{
  "hub_url": "http://192.168.0.106:8790",
  "hub_token": "агентский токен из `jira-hub tokens`",
  "jira": {
    "base_url": "https://sdlc.go.rshbank.ru/jira",
    "auth": {"type": "basic", "user": "логин", "password": "пароль", "token": ""}
  },
  "verify_ssl": true,
  "ca_bundle": null,
  "timeout": 60,
  "poll_seconds": 5
}

src/agent.py:

#!/usr/bin/env python3
"""jira-agent — клиент моста Jira для виртуалки с доступом к Jira банка.

Опрашивает хаб (jira-hub) на Mac mini, выполняет команды «желаемый результат» в Jira по правилу
«проверь, потом сделай», ведёт журнал на диске и возвращает хабу состояние задач. Только стандартная библиотека,
Python 3.9. Спека: jira-hub/openspec/changes/bridge-core/specs/agent/spec.md.

    python3 agent.py check     # доступ к хабу и Jira, без записи
    python3 agent.py run       # основной цикл (по умолчанию)
    python3 agent.py prune     # почистить journal.jsonl от подтверждённых записей старше 30 дней
"""
import argparse
import base64
import json
import os
import signal
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional

AGENT_VERSION = 1
HERE = os.path.dirname(os.path.abspath(__file__))
API = "/rest/api/2"
AGILE = "/rest/agile/1.0"


def now_iso() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat()


def log(msg: str) -> None:
    print("[%s] %s" % (datetime.now().strftime("%H:%M:%S"), msg), flush=True)


def load_config(path: str) -> dict:
    try:
        with open(path, encoding="utf-8") as f:
            cfg = json.load(f)
    except FileNotFoundError:
        sys.exit("нет файла настроек %s — скопируйте agent.example.json в agent.json и заполните" % path)
    for key in ("hub_url", "hub_token", "jira"):
        if not cfg.get(key):
            sys.exit("в %s нет обязательного поля %s" % (path, key))
    if not cfg["jira"].get("base_url"):
        sys.exit("в %s нет jira.base_url" % path)
    cfg.setdefault("timeout", 60)
    cfg.setdefault("poll_seconds", 5)
    cfg.setdefault("verify_ssl", True)
    return cfg


# ---------------------------------------------------------------------------
# HTTP
# ---------------------------------------------------------------------------
class HttpError(Exception):
    def __init__(self, code: int, text: str, headers=None):
        super().__init__("HTTP %d: %s" % (code, text[:300]))
        self.code, self.text, self.headers = code, text, headers or {}


class Http:
    def __init__(self, base: str, headers: Dict[str, str], timeout: float = 60, ctx=None):
        self.base, self.headers, self.timeout, self.ctx = base.rstrip("/"), headers, timeout, ctx

    def __repr__(self):
        return "<%s %s>" % (self.__class__.__name__, self.base)

    def call(self, method: str, path: str, body=None, params=None) -> Any:
        url = self.base + path
        if params:
            url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
        data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
        req = urllib.request.Request(url, data=data, method=method)
        for k, v in self.headers.items():
            req.add_header(k, v)
        req.add_header("Accept", "application/json")
        if data is not None:
            req.add_header("Content-Type", "application/json; charset=utf-8")
        try:
            with urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx) as resp:
                raw = resp.read()
                return json.loads(raw.decode("utf-8")) if raw else {}
        except urllib.error.HTTPError as e:
            raise HttpError(e.code, e.read().decode("utf-8", "replace"), dict(e.headers)) from None
        except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as e:
            raise ConnectionError("%s недоступен: %s" % (self.base, e)) from None


class JiraClient(Http):
    """Все вызовы Jira идут здесь: счётчики вызовов и 429/503, время последнего вызова, повтор по Retry-After."""

    def __init__(self, cfg: dict):
        j = cfg["jira"]
        auth = j.get("auth") or {}
        kind = auth.get("type") or ("bearer" if auth.get("token") else "basic")
        if kind == "bearer":
            if not auth.get("token"):
                sys.exit("jira.auth.token пуст")
            header = "Bearer " + auth["token"]
        else:
            if not (auth.get("user") and auth.get("password")):
                sys.exit("jira.auth.user / jira.auth.password пусты")
            header = "Basic " + base64.b64encode(("%s:%s" % (auth["user"], auth["password"])).encode()).decode()
        ctx = None
        if not cfg.get("verify_ssl", True):
            ctx = ssl.create_default_context()
            ctx.check_hostname = False
            ctx.verify_mode = ssl.CERT_NONE
        elif cfg.get("ca_bundle"):
            ctx = ssl.create_default_context(cafile=cfg["ca_bundle"])
        super().__init__(j["base_url"], {"Authorization": header, "X-Atlassian-Token": "no-check"}, cfg.get("timeout", 60), ctx)
        self.calls = 0
        self.rate_limited = 0
        self.last_call_at = None     # type: Optional[float]  (time.monotonic)
        self.last_call_iso = None    # type: Optional[str]
        self.sleep = time.sleep

    def reset_counters(self) -> None:
        self.calls, self.rate_limited = 0, 0

    def call(self, method, path, body=None, params=None):
        for attempt in range(1, 6):
            try:
                out = super().call(method, path, body, params)
                self.calls += 1
                self.last_call_at, self.last_call_iso = time.monotonic(), now_iso()
                return out
            except HttpError as e:
                if e.code in (429, 503) and attempt < 5:
                    retry = e.headers.get("Retry-After")
                    wait = int(retry) if retry and retry.isdigit() else 60 * attempt
                    self.rate_limited += 1
                    log("  ! HTTP %d, жду %d с (попытка %d/5)" % (e.code, wait, attempt))
                    self.sleep(wait)
                    continue
                if e.code in (429, 503):
                    self.rate_limited += 1
                raise

    def get(self, path, **params):
        return self.call("GET", path, params=params or None)

    def post(self, path, body):
        return self.call("POST", path, body)

    def put(self, path, body):
        return self.call("PUT", path, body)

    def delete(self, path):
        return self.call("DELETE", path)


class HubClient(Http):
    def __init__(self, cfg: dict):
        super().__init__(cfg["hub_url"], {"Authorization": "Bearer " + cfg["hub_token"]}, cfg.get("timeout", 60))

    def claim(self, agent_version, settings_version, last_jira_call_at) -> dict:
        return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version,
                                                  "last_jira_call_at": last_jira_call_at})

    def settings(self) -> dict:
        return self.call("GET", "/agent/settings")

    def command(self, command_id) -> dict:
        return self.call("GET", "/agent/commands/%d" % int(command_id))

    def result(self, command_id, payload: dict) -> dict:
        return self.call("POST", "/agent/commands/%d/result" % int(command_id), payload)

    def state(self, payload: dict) -> dict:
        return self.call("POST", "/agent/state", payload)

    def known_keys(self) -> List[str]:
        return self.call("GET", "/agent/known-keys")["keys"]


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def cmd_check(cfg: dict) -> int:
    ok = True
    try:
        s = HubClient(cfg).settings()
        log("хаб: доступен, настройки v%s, проект %s" % (s.get("settings_version"), s.get("project_key")))
    except (ConnectionError, HttpError) as e:
        log("хаб: НЕДОСТУПЕН — %s" % e)
        ok = False
    try:
        me = JiraClient(cfg).get(API + "/myself")
        log("Jira: доступен, пользователь %s" % me.get("name"))
    except (ConnectionError, HttpError) as e:
        log("Jira: НЕДОСТУПЕН — %s" % e)
        ok = False
    return 0 if ok else 1


def main(argv=None) -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--config", default=os.path.join(HERE, "agent.json"))
    ap.add_argument("--journal", default=os.path.join(HERE, "journal.jsonl"))
    ap.add_argument("cmd", nargs="?", default="run", choices=["run", "check", "prune"])
    args = ap.parse_args(argv)
    cfg = load_config(args.config)
    if args.cmd == "check":
        return cmd_check(cfg)
    log("команда %s ещё не реализована" % args.cmd)
    return 2


if __name__ == "__main__":
    sys.exit(main())
  • Step 5: Запустить — зелёный

Run: /usr/bin/python3 -m unittest tests.test_clients -v Expected: 7 passed

  • Step 6: Коммит
git add src/agent.py src/agent.example.json tests/__init__.py tests/_common.py tests/fake_jira.py tests/test_clients.py
git commit -m "feat(agent): скелет, клиенты Jira и хаба, фейковый Jira, команда check

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

Task 2: Журнал на диске и prune

Files:

  • Modify: src/agent.py (класс Journal после HubClient; prune в main)
  • Test: tests/test_journal.py

Interfaces:

  • Produces: Journal(path): .append(record), .records() -> list, .started(command_id), .finished(command_id, status, result, error, jira_calls, rate_limited), .acked(command_id), .unacked_finished() -> list[dict] (последняя finished на команду без acked после неё, в порядке журнала), .unfinished_started() -> list[int], .prune(days=30) -> int (сколько записей удалено).

  • Step 1: Тест

tests/test_journal.py:

import json
import os
import tempfile
import unittest

from _common import agent


class JournalTest(unittest.TestCase):
    def setUp(self):
        self.dir = tempfile.TemporaryDirectory()
        self.path = os.path.join(self.dir.name, "journal.jsonl")
        self.j = agent.Journal(self.path)

    def tearDown(self):
        self.dir.cleanup()

    def test_empty(self):
        self.assertEqual(self.j.records(), [])
        self.assertEqual(self.j.unacked_finished(), [])
        self.assertEqual(self.j.unfinished_started(), [])

    def test_lifecycle(self):
        self.j.started(1)
        self.assertEqual(self.j.unfinished_started(), [1])
        self.j.finished(1, "done", {"key": "A-1"}, None, 2, 0)
        self.assertEqual(self.j.unfinished_started(), [])
        pending = self.j.unacked_finished()
        self.assertEqual(len(pending), 1)
        self.assertEqual(pending[0]["command_id"], 1)
        self.assertEqual(pending[0]["result"], {"key": "A-1"})
        self.assertEqual(pending[0]["jira_calls"], 2)
        self.j.acked(1)
        self.assertEqual(self.j.unacked_finished(), [])
        self.j.started(2)
        self.j.started(3)
        self.j.finished(3, "failed", None, "HTTP 400", 1, 0)
        self.assertEqual(self.j.unfinished_started(), [2])
        self.assertEqual([r["command_id"] for r in self.j.unacked_finished()], [3])

    def test_broken_line_is_skipped(self):
        self.j.started(1)
        with open(self.path, "a", encoding="utf-8") as f:
            f.write("{не json\n")
        self.j.finished(1, "done", None, None, 1, 0)
        self.assertEqual(len(self.j.records()), 2)

    def test_prune_keeps_unacked_and_recent(self):
        self.j.started(1)
        self.j.finished(1, "done", None, None, 1, 0)
        self.j.append({"kind": "acked", "command_id": 1, "at": "2020-01-01T00:00:00+00:00"})
        self.j.started(2)
        self.j.finished(2, "done", None, None, 1, 0)
        self.j.acked(2)
        self.j.started(3)
        removed = self.j.prune(days=30)
        self.assertEqual(removed, 3)
        ids = [(r["kind"], r["command_id"]) for r in self.j.records()]
        self.assertEqual(ids, [("started", 2), ("finished", 2), ("acked", 2), ("started", 3)])
        self.assertEqual(self.j.prune(days=30), 0)

    def test_no_secrets_in_records(self):
        self.j.finished(1, "done", {"key": "A-1"}, None, 1, 0)
        with open(self.path, encoding="utf-8") as f:
            text = f.read()
        self.assertNotIn("password", text)
        self.assertEqual(json.loads(text.splitlines()[0])["kind"], "finished")


if __name__ == "__main__":
    unittest.main()
  • Step 2: Запустить — падает

Run: /usr/bin/python3 -m unittest tests.test_journal -v Expected: FAIL — AttributeError: module 'agent' has no attribute 'Journal'

  • Step 3: Реализация

В src/agent.py после класса HubClient:

# ---------------------------------------------------------------------------
# Журнал: только дозапись; started → finished → acked по id команды
# ---------------------------------------------------------------------------
class Journal:
    def __init__(self, path: str):
        self.path = path

    def append(self, record: dict) -> None:
        record = dict(record)
        record.setdefault("at", now_iso())
        with open(self.path, "a", encoding="utf-8") as f:
            f.write(json.dumps(record, ensure_ascii=False) + "\n")

    def records(self) -> List[dict]:
        if not os.path.exists(self.path):
            return []
        out = []
        with open(self.path, encoding="utf-8") as f:
            for n, line in enumerate(f, 1):
                line = line.strip()
                if not line:
                    continue
                try:
                    out.append(json.loads(line))
                except ValueError:
                    log("журнал: строка %d не читается, пропускаю" % n)
        return out

    def started(self, command_id) -> None:
        self.append({"kind": "started", "command_id": int(command_id)})

    def finished(self, command_id, status, result, error, jira_calls, rate_limited) -> None:
        self.append({"kind": "finished", "command_id": int(command_id), "status": status, "result": result,
                     "error": error, "jira_calls": int(jira_calls or 0), "rate_limited": int(rate_limited or 0)})

    def acked(self, command_id) -> None:
        self.append({"kind": "acked", "command_id": int(command_id)})

    def unacked_finished(self) -> List[dict]:
        last, acked = {}, set()
        for r in self.records():
            if r.get("kind") == "finished":
                last[r["command_id"]] = r
                acked.discard(r["command_id"])
            elif r.get("kind") == "acked":
                acked.add(r["command_id"])
        return [r for cid, r in last.items() if cid not in acked]

    def unfinished_started(self) -> List[int]:
        started, finished = [], set()
        for r in self.records():
            if r.get("kind") == "started":
                started.append(r["command_id"])
            elif r.get("kind") in ("finished", "acked"):
                finished.add(r["command_id"])
        return [cid for cid in started if cid not in finished]

    def prune(self, days: int = 30) -> int:
        """Удалить записи команд, подтверждённых (acked) раньше, чем days дней назад. Неподтверждённые — остаются."""
        records = self.records()
        cutoff = datetime.now(timezone.utc).timestamp() - days * 86400
        old = set()
        for r in records:
            if r.get("kind") == "acked":
                try:
                    ts = datetime.fromisoformat(r.get("at", "")).timestamp()
                except ValueError:
                    continue
                if ts < cutoff:
                    old.add(r["command_id"])
        keep = [r for r in records if r.get("command_id") not in old]
        tmp = self.path + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            for r in keep:
                f.write(json.dumps(r, ensure_ascii=False) + "\n")
        os.replace(tmp, self.path)
        return len(records) - len(keep)

В main заменить заглушку:

    if args.cmd == "prune":
        removed = Journal(args.journal).prune(30)
        log("журнал: удалено записей %d" % removed)
        return 0
    log("команда %s ещё не реализована" % args.cmd)
    return 2
  • Step 4: Запустить — зелёный

Run: /usr/bin/python3 -m unittest tests.test_journal -v Expected: 5 passed

  • Step 5: Коммит
git add src/agent.py tests/test_journal.py
git commit -m "feat(agent): журнал на диске, восстановление по нему, prune

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

Task 3: Исполнитель — ensure_issue, ensure_fields, ensure_comment

Files:

  • Modify: src/agent.py (класс Executor после Journal)
  • Test: tests/test_executor_fields.py

Interfaces:

  • Produces: Executor(jira, settings, hub=None): .run(cmd: dict) -> {"status", "result", "error", "jira_calls", "rate_limited"}, .sleep (подменяемый), .gap(cmd_type), .fid(logical), .priority_value(name), .issue_type_ref(kind), .epic_fields(epic_key), методы do_ensure_issue(p, cmd), do_ensure_fields(p, cmd), do_ensure_comment(p, cmd).

  • Правило сравнения дат: по первым 10 символам (YYYY-MM-DD), потому что кастомные даты Jira приходят со временем.

  • Step 1: Тесты

tests/test_executor_fields.py:

import unittest

from _common import SETTINGS, agent, jira_cfg, no_sleep
from fake_jira import FakeJira


class ExecutorFieldsTest(unittest.TestCase):
    def setUp(self):
        self.fake = FakeJira()
        self.jira = agent.JiraClient(jira_cfg(self.fake))
        self.jira.sleep = no_sleep
        self.ex = agent.Executor(self.jira, dict(SETTINGS))
        self.ex.sleep = no_sleep

    def tearDown(self):
        self.fake.stop()

    def run_cmd(self, ctype, **params):
        return self.ex.run({"id": 1, "type": ctype, "params": params})

    def test_ensure_issue_creates_then_finds(self):
        out = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="task", summary="Новая задача", description="h3. Контекст",
                           priority="Высокий", duedate="2026-10-23", planned_start="2026-10-20", labels=["AWPCA", "kind-dev"],
                           epic_key="AURORA-1350", assignee="ivanov")
        self.assertEqual(out["status"], "done")
        key = out["result"]["key"]
        self.assertTrue(out["result"]["created"])
        f = self.fake.issues[key]
        self.assertEqual(f["priority"], "Высокий")
        self.assertEqual(f["customfield_10801"], "2026-10-20")
        self.assertEqual(f["customfield_15302"], "AURORA-1350")
        self.assertEqual(f["labels"], ["AWPCA", "kind-dev"])
        self.assertEqual(out["jira_calls"], 2)
        again = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="task", summary="Новая задача")
        self.assertEqual(again["status"], "done")
        self.assertEqual(again["result"], {"key": key, "existed": True})
        self.assertEqual(len(self.fake.issues), 1)
        self.assertEqual(again["jira_calls"], 1)

    def test_ensure_issue_epic_sets_epic_name(self):
        out = self.run_cmd("ensure_issue", temp_key="new:2", issue_type="epic", summary="Приёмка")
        f = self.fake.issues[out["result"]["key"]]
        self.assertEqual(f["issuetype"], "Фича")
        self.assertEqual(f["customfield_15304"], "Приёмка")

    def test_ensure_issue_priority_mapped(self):
        out = self.run_cmd("ensure_issue", temp_key="new:3", issue_type="task", summary="x", priority="Наивысший")
        self.assertEqual(self.fake.issues[out["result"]["key"]]["priority"], "Критичный")

    def test_ensure_fields_writes_only_diff(self):
        self.fake.add("AURORA-1", duedate="2026-10-20", labels=["AWPCA", "fact-open"], priority="Средний")
        out = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-23", priority="Средний",
                           labels_add=["kind-dev", "AWPCA"], labels_remove=["fact-open"], assignee="ivanov",
                           planned_end="2026-10-23", epic_key="AURORA-1350")
        self.assertEqual(out["status"], "done")
        self.assertEqual(out["result"]["changed"], ["duedate", "planned_end", "epic_key", "assignee", "labels"])
        f = self.fake.issues["AURORA-1"]
        self.assertEqual(f["labels"], ["AWPCA", "kind-dev"])
        self.assertEqual(f["assignee"], "ivanov")
        self.assertEqual(f["customfield_10802"], "2026-10-23")
        self.assertEqual(len(self.fake.writes()), 1)
        again = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-23", labels_add=["kind-dev"], assignee="ivanov")
        self.assertEqual(again["result"], {"changed": []})
        self.assertEqual(len(self.fake.writes()), 1)

    def test_fields_description_exact_compare(self):
        text = "h3. Контекст\n* пункт *жирный*\n\nh3. Что сделать"
        self.fake.add("AURORA-1", description=text)
        out = self.run_cmd("ensure_fields", key="AURORA-1", description=text)
        self.assertEqual(out["result"]["changed"], [])
        out = self.run_cmd("ensure_fields", key="AURORA-1", description=text + "\n")
        self.assertEqual(out["result"]["changed"], ["description"])

    def test_fields_date_with_time_from_jira(self):
        self.fake.add("AURORA-1", customfield_10802="2026-10-23T00:00:00.000+0300")
        out = self.run_cmd("ensure_fields", key="AURORA-1", planned_end="2026-10-23")
        self.assertEqual(out["result"]["changed"], [])

    def test_fields_clear_assignee_and_epic(self):
        self.fake.add("AURORA-1", assignee="ivanov", customfield_15302="AURORA-1350")
        out = self.run_cmd("ensure_fields", key="AURORA-1", assignee=None, epic_key=None)
        self.assertEqual(out["result"]["changed"], ["epic_key", "assignee"])
        self.assertIsNone(self.fake.issues["AURORA-1"]["assignee"])
        self.assertIsNone(self.fake.issues["AURORA-1"]["customfield_15302"])

    def test_fields_missing_issue_is_failed(self):
        out = self.run_cmd("ensure_fields", key="AURORA-404", duedate="2026-10-23")
        self.assertEqual(out["status"], "failed")
        self.assertIn("HTTP 404", out["error"])

    def test_ensure_comment_upsert(self):
        self.fake.add("AURORA-1")
        out = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: список")
        self.assertEqual(out["status"], "done")
        self.assertTrue(out["result"]["created"])
        self.assertEqual(self.fake.comments["AURORA-1"][0]["body"], "*Актуализация 10.10.2026.* Сделано: список")
        same = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: список")
        self.assertEqual(same["result"]["changed"], False)
        upd = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: всё")
        self.assertEqual(upd["result"]["changed"], True)
        self.assertEqual(len(self.fake.comments["AURORA-1"]), 1)
        self.assertEqual(self.fake.comments["AURORA-1"][0]["body"], "*Актуализация 10.10.2026.* Сделано: всё")

    def test_unknown_type(self):
        self.assertEqual(self.run_cmd("ensure_magic", key="x")["status"], "failed")


if __name__ == "__main__":
    unittest.main()
  • Step 2: Запустить — падает

Run: /usr/bin/python3 -m unittest tests.test_executor_fields -v Expected: FAIL — AttributeError: module 'agent' has no attribute 'Executor'

  • Step 3: Реализация

В src/agent.py после класса Journal:

# ---------------------------------------------------------------------------
# Исполнитель: команда — желаемый результат; прочитать → сравнить → записать разницу
# ---------------------------------------------------------------------------
def _date(value) -> Optional[str]:
    """Даты Jira (duedate — YYYY-MM-DD, кастомные — с временем) сравниваем по дню."""
    return (value or "")[:10] or None


class Executor:
    def __init__(self, jira: JiraClient, settings: dict, hub: Optional[HubClient] = None):
        self.jira, self.s, self.hub = jira, settings, hub
        self.sleep = time.sleep
        self._symmetric = None
        self._sprints = None

    def run(self, cmd: dict) -> dict:
        self.jira.reset_counters()
        self._sprints = None
        handler = getattr(self, "do_" + str(cmd.get("type")), None)
        if handler is None:
            return self._out("failed", error="неизвестный тип команды %s" % cmd.get("type"))
        try:
            return handler(cmd.get("params") or {}, cmd)
        except HttpError as e:
            return self._out("failed", error="HTTP %d %s" % (e.code, e.text[:1500]))
        except ConnectionError as e:
            return self._out("failed", error=str(e))

    def _out(self, status, result=None, error=None) -> dict:
        return {"status": status, "result": result, "error": error,
                "jira_calls": self.jira.calls, "rate_limited": self.jira.rate_limited}

    def gap(self, cmd_type: str) -> None:
        """Зазор между записями внутри одной команды — по политике хаба для этого типа."""
        seconds = int((self.s.get("pacing") or {}).get(cmd_type, 0) or 0)
        if seconds > 0:
            log("  пауза %d с" % seconds)
            self.sleep(seconds)

    def fid(self, logical: str) -> str:
        return (self.s.get("field_ids") or {}).get(logical, logical)

    def has_field(self, logical: str) -> bool:
        return logical in ("summary", "description", "priority", "duedate", "labels") or bool((self.s.get("field_ids") or {}).get(logical))

    def priority_value(self, name: str) -> dict:
        mapped = (self.s.get("priority_map") or {}).get(name, name)
        return {self.s.get("priority_value_key") or "name": mapped}

    def issue_type_ref(self, kind: str) -> dict:
        it = (self.s.get("issue_types") or {}).get(kind) or {}
        return {"id": str(it["id"])} if it.get("id") else {"name": it.get("name")}

    def epic_fields(self, epic_key) -> dict:
        ep = self.s.get("epic_link") or {}
        if epic_key and ep.get("mode", "field") == "field" and ep.get("field_id"):
            return {ep["field_id"]: epic_key}
        return {}

    # --- ensure_issue ----------------------------------------------------------
    def do_ensure_issue(self, p: dict, cmd: dict) -> dict:
        jql = "project = %s AND reporter = currentUser() AND created >= -7d" % self.s["project_key"]
        found = self.jira.get(API + "/search", jql=jql, fields="summary", maxResults=200)
        for it in found.get("issues", []):
            if (it.get("fields", {}).get("summary") or "") == p["summary"]:
                return self._out("done", {"key": it["key"], "existed": True})
        fields = {"project": {"key": self.s["project_key"]}, "issuetype": self.issue_type_ref(p["issue_type"]),
                  "summary": p["summary"]}
        if p.get("description") is not None:
            fields[self.fid("description")] = p["description"]
        if p.get("priority"):
            fields[self.fid("priority")] = self.priority_value(p["priority"])
        if p.get("duedate"):
            fields[self.fid("duedate")] = p["duedate"]
        for logical in ("planned_start", "planned_end"):
            if p.get(logical) and self.has_field(logical):
                fields[self.fid(logical)] = p[logical]
        if p.get("labels") is not None:
            fields[self.fid("labels")] = list(p["labels"])
        if p.get("assignee"):
            fields["assignee"] = {"name": p["assignee"]}
        fields.update(self.epic_fields(p.get("epic_key")))
        if p["issue_type"] == "epic" and self.s.get("epic_name_field_id"):
            fields[self.s["epic_name_field_id"]] = p["summary"]
        created = self.jira.post(API + "/issue", {"fields": fields})
        return self._out("done", {"key": created["key"], "created": True})

    # --- ensure_fields ---------------------------------------------------------
    def do_ensure_fields(self, p: dict, cmd: dict) -> dict:
        key = p["key"]
        cur = self.jira.get(API + "/issue/%s" % key)["fields"]
        fields, changed = {}, []
        if "summary" in p and (cur.get("summary") or "") != (p["summary"] or ""):
            fields["summary"] = p["summary"]
            changed.append("summary")
        if "description" in p and (cur.get(self.fid("description")) or "") != (p["description"] or ""):
            fields[self.fid("description")] = p["description"]
            changed.append("description")
        if "priority" in p:
            want = self.priority_value(p["priority"])
            vkey = list(want)[0]
            if ((cur.get(self.fid("priority")) or {}).get(vkey)) != want[vkey]:
                fields[self.fid("priority")] = want
                changed.append("priority")
        for logical in ("duedate", "planned_start", "planned_end"):
            if logical in p and self.has_field(logical) and _date(cur.get(self.fid(logical))) != _date(p[logical]):
                fields[self.fid(logical)] = p[logical]
                changed.append(logical)
        if "epic_key" in p:
            ep = self.s.get("epic_link") or {}
            if ep.get("field_id") and (cur.get(ep["field_id"]) or None) != (p["epic_key"] or None):
                fields[ep["field_id"]] = p["epic_key"]
                changed.append("epic_key")
        if "assignee" in p and ((cur.get("assignee") or {}).get("name") or None) != (p["assignee"] or None):
            fields["assignee"] = {"name": p["assignee"]} if p["assignee"] else None
            changed.append("assignee")
        if "labels_add" in p or "labels_remove" in p:
            labels = list(cur.get(self.fid("labels")) or [])
            remove = set(p.get("labels_remove") or [])
            new = [l for l in labels if l not in remove] + [l for l in (p.get("labels_add") or []) if l not in labels]
            if new != labels:
                fields[self.fid("labels")] = new
                changed.append("labels")
        if not fields:
            return self._out("done", {"changed": []})
        self.jira.put(API + "/issue/%s" % key, {"fields": fields})
        return self._out("done", {"changed": changed})

    # --- ensure_comment --------------------------------------------------------
    def do_ensure_comment(self, p: dict, cmd: dict) -> dict:
        key, marker, body = p["key"], p["marker"], p["body"]
        if marker not in body[:80]:
            body = "*%s.* %s" % (marker, body)
        existing = None
        for c in self.jira.get(API + "/issue/%s/comment" % key).get("comments", []):
            if marker in (c.get("body") or "")[:80]:
                existing = c
                break
        if existing and (existing.get("body") or "") == body:
            return self._out("done", {"comment_id": existing["id"], "changed": False})
        if existing:
            self.jira.put(API + "/issue/%s/comment/%s" % (key, existing["id"]), {"body": body})
            return self._out("done", {"comment_id": existing["id"], "changed": True})
        c = self.jira.post(API + "/issue/%s/comment" % key, {"body": body})
        return self._out("done", {"comment_id": c.get("id"), "created": True})
  • Step 4: Запустить — зелёный

Run: /usr/bin/python3 -m unittest tests.test_executor_fields -v Expected: 10 passed

  • Step 5: Коммит
git add src/agent.py tests/test_executor_fields.py
git commit -m "feat(agent): исполнитель — ensure_issue, ensure_fields, ensure_comment

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

Files:

  • Modify: src/agent.py (методы в Executor)
  • Test: tests/test_executor_links.py

Interfaces:

  • Produces: Executor.do_ensure_status, .do_ensure_link, .do_ensure_sprint, .symmetric_types() -> set, .sprints() -> dict[name, sprint] (активные и будущие доски board_id; {} без доски).

  • Step 1: Тесты

tests/test_executor_links.py:

import unittest

from _common import SETTINGS, agent, jira_cfg, no_sleep
from fake_jira import FakeJira


class ExecutorLinksTest(unittest.TestCase):
    def setUp(self):
        self.fake = FakeJira()
        self.jira = agent.JiraClient(jira_cfg(self.fake))
        self.jira.sleep = no_sleep
        self.ex = agent.Executor(self.jira, dict(SETTINGS))
        self.gaps = []
        self.ex.sleep = self.gaps.append
        self.fake.add("AURORA-1")
        self.fake.add("AURORA-2")

    def tearDown(self):
        self.fake.stop()

    def run_cmd(self, ctype, **params):
        return self.ex.run({"id": 1, "type": ctype, "params": params})

    # --- статус ---
    def test_status_path(self):
        out = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"])
        self.assertEqual(out["status"], "done")
        self.assertEqual(out["result"], {"from": "Ожидание разработки", "to": "Готово", "steps": 2})
        self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Готово")
        again = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"])
        self.assertEqual(again["result"]["steps"], 0)
        self.assertEqual(again["jira_calls"], 1)

    def test_status_resumes_from_middle(self):
        self.fake.issues["AURORA-1"]["status"] = "Разработка"
        out = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово к тестированию"])
        self.assertEqual(out["result"]["steps"], 1)
        self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Готово к тестированию")

    def test_status_single_and_by_transition_name(self):
        out = self.run_cmd("ensure_status", key="AURORA-1", target="Взять в разработку")
        self.assertEqual(out["result"]["to"], "Взять в разработку")   # имя перехода принято как шаг
        self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Разработка")

    def test_status_no_transition_is_failed(self):
        out = self.run_cmd("ensure_status", key="AURORA-1", target="Тестирование")
        self.assertEqual(out["status"], "failed")
        self.assertIn("нет перехода из «Ожидание разработки» в «Тестирование»", out["error"])
        self.assertIn("Взять в разработку→Разработка", out["error"])
        self.assertEqual(out["result"], {"from": "Ожидание разработки", "reached": "Ожидание разработки"})

    def test_status_gap_between_steps(self):
        self.ex.s["pacing"] = {"ensure_status": 7}
        self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"])
        self.assertEqual(self.gaps, [7])

    # --- связи ---
    def test_link_create_and_idempotent(self):
        out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало")
        self.assertEqual(out["result"], {"created": True, "removed": []})
        link = self.fake.links[0]
        self.assertEqual((link["source"], link["target"], link["type"]), ("AURORA-1", "AURORA-2", "ASUPR_Блокер_Начала"))
        again = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало")
        self.assertEqual(again["result"], {"existed": True, "removed": []})
        self.assertEqual(len(self.fake.links), 1)

    def test_link_symmetric_any_direction(self):
        self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-2", "target": "AURORA-1"})
        out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="связанные")
        self.assertEqual(out["result"], {"existed": True, "removed": []})

    def test_link_conflict_without_replace(self):
        self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-2", "target": "AURORA-1"})
        out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало")
        self.assertEqual(out["status"], "failed")
        self.assertIn("обратное направление", out["error"])
        self.assertEqual(len(self.fake.links), 1)

    def test_link_replace_reverse_and_other_type(self):
        self.ex.s["pacing"] = {"ensure_link": 3}
        self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-2", "target": "AURORA-1"})
        self.fake.links.append({"id": 2, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "AURORA-2"})
        out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало", replace=True)
        self.assertEqual(out["status"], "done")
        self.assertEqual(sorted(out["result"]["removed"]), ["ASUPR_Блокер_Начала", "ASUPR_Связанные"])
        self.assertTrue(out["result"]["created"])
        self.assertEqual([(l["source"], l["target"], l["type"]) for l in self.fake.links],
                         [("AURORA-1", "AURORA-2", "ASUPR_Блокер_Начала")])
        self.assertEqual(self.gaps, [3, 3])          # между двумя DELETE и перед POST
        self.assertEqual([r[0] for r in self.fake.writes()], ["DELETE", "DELETE", "POST"])

    def test_link_unmanaged_type_ignored(self):
        self.fake.links.append({"id": 1, "type": "Duplicate", "source": "AURORA-1", "target": "AURORA-2"})
        out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="связанные")
        self.assertEqual(out["result"], {"created": True, "removed": []})
        self.assertEqual(len(self.fake.links), 2)

    # --- спринты ---
    def test_sprint_move_and_idempotent(self):
        out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2")
        self.assertEqual(out["result"], {"sprint": "Спринт 2", "from": None})
        self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 2")
        again = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2")
        self.assertEqual(again["result"], {"sprint": "Спринт 2", "changed": False})
        self.assertEqual(again["jira_calls"], 1)

    def test_sprint_backlog(self):
        self.fake.issues["AURORA-1"]["sprint"] = "Спринт 1"
        out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog")
        self.assertEqual(out["result"], {"sprint": None, "from": "Спринт 1"})
        self.assertIsNone(self.fake.issues["AURORA-1"]["sprint"])
        self.assertEqual(self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog")["result"], {"sprint": None, "changed": False})

    def test_sprint_unknown_name(self):
        out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 9")
        self.assertEqual(out["status"], "failed")
        self.assertIn("Спринт 9", out["error"])
        self.assertIn("Спринт 2", out["error"])

    def test_sprint_without_board(self):
        self.ex.s["board_id"] = None
        out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2")
        self.assertEqual(out["status"], "failed")
        self.assertIn("board_id", out["error"])


if __name__ == "__main__":
    unittest.main()
  • Step 2: Запустить — падает

Run: /usr/bin/python3 -m unittest tests.test_executor_links -v Expected: FAIL — AttributeError: 'Executor' object has no attribute 'do_ensure_status' (команды failed с «неизвестный тип»)

  • Step 3: Реализация

В класс Executor после do_ensure_comment:

    # --- ensure_status ---------------------------------------------------------
    def do_ensure_status(self, p: dict, cmd: dict) -> dict:
        key, target = p["key"], p["target"]
        path = list(target) if isinstance(target, list) else [target]
        final = path[-1]
        cur = self.jira.get(API + "/issue/%s" % key, fields="status")["fields"]["status"]["name"]
        start = cur
        if cur == final:
            return self._out("done", {"from": start, "to": final, "steps": 0})
        if cur in path:
            path = path[path.index(cur) + 1:]
        steps = 0
        for step in path:
            if cur == step:
                continue
            trs = self.jira.get(API + "/issue/%s/transitions" % key).get("transitions", [])
            match = [t for t in trs if (t.get("to") or {}).get("name") == step or t.get("name") == step]
            if not match:
                avail = ", ".join("%s→%s" % (t.get("name"), (t.get("to") or {}).get("name")) for t in trs)
                return self._out("failed", {"from": start, "reached": cur},
                                 "нет перехода из «%s» в «%s»; доступны: %s" % (cur, step, avail or "—"))
            if steps:
                self.gap("ensure_status")
            self.jira.post(API + "/issue/%s/transitions" % key, {"transition": {"id": match[0]["id"]}})
            cur = step
            steps += 1
        return self._out("done", {"from": start, "to": cur, "steps": steps})

    # --- ensure_link -----------------------------------------------------------
    def symmetric_types(self) -> set:
        """Типы, у которых inward == outward («Связанные»): направление не важно."""
        if self._symmetric is None:
            try:
                types = self.jira.get(API + "/issueLinkType").get("issueLinkTypes", [])
                self._symmetric = {t["name"] for t in types if t.get("inward") == t.get("outward")}
            except HttpError:
                self._symmetric = set()
        return self._symmetric

    def do_ensure_link(self, p: dict, cmd: dict) -> dict:
        src, dst = p["source"], p["target"]
        types = self.s.get("link_types") or {}
        lt = types.get(p["link_type"], p["link_type"])
        managed = set(types.values()) | {lt}
        symmetric = self.symmetric_types()
        raw = self.jira.get(API + "/issue/%s" % src, fields="issuelinks")["fields"].get("issuelinks") or []
        links = []                                      # (тип, другая задача, направление, id)
        for l in raw:
            t = (l.get("type") or {}).get("name")
            if l.get("outwardIssue"):
                links.append((t, l["outwardIssue"]["key"], "out", l.get("id")))
            if l.get("inwardIssue"):
                links.append((t, l["inwardIssue"]["key"], "in", l.get("id")))
        wanted = [x for x in links if x[1] == dst and x[0] == lt and (x[2] == "out" or lt in symmetric)]
        conflicts = [x for x in links if x[1] == dst and x[0] in managed and x not in wanted]
        if conflicts and not p.get("replace"):
            desc = ", ".join("«%s» (%s)" % (t, "обратное направление" if t == lt else "другой тип") for t, _, _, _ in conflicts)
            return self._out("failed", error="между %s и %s уже есть: %s; нужен replace" % (src, dst, desc))
        removed = []
        for t, other, direction, lid in conflicts:
            if not lid:
                return self._out("failed", {"removed": removed}, "у связи «%s» с %s нет id — снять нельзя" % (t, other))
            if removed:
                self.gap("ensure_link")
            self.jira.delete(API + "/issueLink/%s" % lid)
            removed.append(t)
        if wanted:
            return self._out("done", {"existed": True, "removed": removed})
        if removed:
            self.gap("ensure_link")
        # inwardIssue — источник (получает outward-описание «Блокирует начало…»), outwardIssue — цель
        self.jira.post(API + "/issueLink", {"type": {"name": lt}, "inwardIssue": {"key": src}, "outwardIssue": {"key": dst}})
        return self._out("done", {"created": True, "removed": removed})

    # --- ensure_sprint ---------------------------------------------------------
    def sprints(self) -> Dict[str, dict]:
        """Имя → спринт (active и future) доски board_id; без доски — пусто."""
        if self._sprints is None:
            out, start = {}, 0
            board = self.s.get("board_id")
            while board:
                page = self.jira.get(AGILE + "/board/%s/sprint" % board, state="active,future", startAt=start, maxResults=50)
                for sp in page.get("values", []):
                    out[sp["name"]] = sp
                if page.get("isLast", True) or not page.get("values"):
                    break
                start += len(page["values"])
            self._sprints = out
        return self._sprints

    def do_ensure_sprint(self, p: dict, cmd: dict) -> dict:
        key, want = p["key"], p["sprint"]
        if not self.s.get("board_id"):
            return self._out("failed", error="board_id не задан в настройках хаба — спринты недоступны")
        cur = ((self.jira.get(AGILE + "/issue/%s" % key, fields="sprint")["fields"].get("sprint") or {}).get("name"))
        if want == "backlog":
            if cur is None:
                return self._out("done", {"sprint": None, "changed": False})
            self.jira.post(AGILE + "/backlog/issue", {"issues": [key]})
            return self._out("done", {"sprint": None, "from": cur})
        if cur == want:
            return self._out("done", {"sprint": want, "changed": False})
        sp = self.sprints().get(want)
        if not sp:
            return self._out("failed", error="нет спринта «%s» среди активных и будущих доски %s: %s"
                             % (want, self.s["board_id"], ", ".join(sorted(self.sprints())) or "—"))
        self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]})
        return self._out("done", {"sprint": want, "from": cur})
  • Step 4: Запустить — зелёный

Run: /usr/bin/python3 -m unittest tests.test_executor_links -v Expected: 14 passed

  • Step 5: Коммит
git add src/agent.py tests/test_executor_links.py
git commit -m "feat(agent): исполнитель — переходы статусов, связи с заменой, спринты

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

Task 5: Чтение состояния (refresh)

Files:

  • Modify: src/agent.py (методы в Executor)
  • Create: tests/fake_hub.py
  • Test: tests/test_refresh.py

Interfaces:

  • Produces: Executor.state_fields() -> str, .search_all(jql) -> list[raw], .to_state(raw) -> dict (ключи зеркала хаба), .links_from(raw) -> list[{"source_key","target_key","link_type"}], .sprint_names(keys) -> dict, .do_refresh(p, cmd) (шлёт hub.state(...) страницами по 100, возвращает {"issues", "neighbors", "pages"}).

  • Produces (тесты): fake_hub.FakeHub(settings): .base_url, .token = "agent-token", .enqueue(type, params, pause=0) -> id, .commands, .results, .states, .known, .claims, .fail_results (сколько раз ответить 503 на отчёт), .reject_results ({id: код}), .stop().

  • Step 1: Фейковый хаб

tests/fake_hub.py:

"""Фейковый хаб для тестов агента: маршруты /agent/* в памяти."""
import json
import re
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer


class FakeHub:
    token = "agent-token"

    def __init__(self, settings):
        self.settings = dict(settings)
        self.queue = []
        self.commands = {}
        self.results = {}
        self.states = []
        self.known = []
        self.claims = 0
        self.fail_results = 0
        self.reject_results = {}
        self.in_flight = None
        self.lock = threading.Lock()
        hub = self

        class Handler(BaseHTTPRequestHandler):
            def log_message(self, *a):
                pass

            def do_GET(self):
                hub.handle(self, "GET")

            def do_POST(self):
                hub.handle(self, "POST")

        self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
        threading.Thread(target=self.server.serve_forever, daemon=True).start()

    @property
    def base_url(self):
        return "http://127.0.0.1:%d" % self.server.server_port

    def stop(self):
        self.server.shutdown()

    def enqueue(self, ctype, params, pause=0):
        cid = len(self.commands) + 1
        self.commands[cid] = {"id": cid, "type": ctype, "params": params, "pause_seconds": pause, "status": "queued"}
        self.queue.append(cid)
        return cid

    def handle(self, h, method):
        length = int(h.headers.get("Content-Length") or 0)
        body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {}
        if h.headers.get("Authorization") != "Bearer " + self.token:
            return self._send(h, 401, {"error": "unauthorized"})
        with self.lock:
            code, payload = self.route(method, h.path.split("?")[0], body)
        self._send(h, code, payload)

    def _send(self, h, code, payload):
        data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        h.send_response(code)
        h.send_header("Content-Type", "application/json")
        h.send_header("Content-Length", str(len(data)))
        h.end_headers()
        h.wfile.write(data)

    def route(self, method, path, body):
        if path == "/agent/claim":
            self.claims += 1
            if self.in_flight and self.commands[self.in_flight]["status"] == "claimed":
                return 200, {"command": None, "settings_version": self.settings["settings_version"]}
            if not self.queue:
                return 200, {"command": None, "settings_version": self.settings["settings_version"]}
            cid = self.queue.pop(0)
            self.commands[cid]["status"] = "claimed"
            self.in_flight = cid
            c = self.commands[cid]
            return 200, {"command": {"id": cid, "type": c["type"], "params": c["params"], "pause_seconds": c["pause_seconds"]},
                         "settings_version": self.settings["settings_version"]}
        if path == "/agent/settings":
            return 200, self.settings
        if path == "/agent/known-keys":
            return 200, {"keys": list(self.known)}
        if path == "/agent/state":
            self.states.append(body)
            return 200, {"issues": len(body.get("issues") or []), "links": len(body.get("links") or [])}
        m = re.match(r"^/agent/commands/(\d+)$", path)
        if m:
            c = self.commands.get(int(m.group(1)))
            return (200, c) if c else (404, {"error": "not_found"})
        m = re.match(r"^/agent/commands/(\d+)/result$", path)
        if m:
            cid = int(m.group(1))
            if self.fail_results > 0:
                self.fail_results -= 1
                return 503, {"error": "busy"}
            if cid in self.reject_results:
                return self.reject_results[cid], {"error": "rejected"}
            if cid not in self.commands:
                return 404, {"error": "not_found"}
            self.results[cid] = body
            self.commands[cid]["status"] = body.get("status")
            if self.in_flight == cid:
                self.in_flight = None
            return 200, self.commands[cid]
        return 404, {"error": "not_found", "detail": path}
  • Step 2: Тесты refresh

tests/test_refresh.py:

import unittest

from _common import SETTINGS, agent, jira_cfg, no_sleep
from fake_hub import FakeHub
from fake_jira import FakeJira


class RefreshTest(unittest.TestCase):
    def setUp(self):
        self.fake = FakeJira()
        self.hub = FakeHub(SETTINGS)
        cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token)
        self.jira = agent.JiraClient(cfg)
        self.jira.sleep = no_sleep
        self.ex = agent.Executor(self.jira, dict(SETTINGS), agent.HubClient(cfg))
        self.ex.sleep = no_sleep

    def tearDown(self):
        self.fake.stop()
        self.hub.stop()

    def test_to_state_and_links(self):
        self.fake.add("AURORA-1", assignee="ivanov", customfield_10801="2026-10-20T00:00:00.000+0300",
                      customfield_15302="AURORA-1350", labels=["AWPCA", "kind-dev"], sprint="Спринт 2")
        self.fake.add("AURORA-2")
        self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-1", "target": "AURORA-2"})
        raw = self.fake.issue_json("AURORA-1")
        st = self.ex.to_state(raw)
        self.assertEqual(st["key"], "AURORA-1")
        self.assertEqual(st["assignee"], "ivanov")
        self.assertEqual(st["planned_start"], "2026-10-20")
        self.assertEqual(st["epic_key"], "AURORA-1350")
        self.assertEqual(st["labels"], ["AWPCA", "kind-dev"])
        self.assertEqual(st["issue_type"], "Задача")
        self.assertEqual(st["jira_updated"], "2026-10-01T10:00:00.000+0300")
        self.assertIsNone(st["sprint"])                      # спринт заполняет do_refresh
        self.assertEqual(self.ex.links_from(raw), [{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "ASUPR_Блокер_Начала"}])
        self.assertEqual(self.ex.links_from(self.fake.issue_json("AURORA-2")),
                         [{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "ASUPR_Блокер_Начала"}])
        self.assertIn("customfield_15302", self.ex.state_fields())

    def test_full_refresh_with_neighbors_and_sprints(self):
        for i in range(1, 4):
            self.fake.add("AURORA-%d" % i, sprint="Спринт 2" if i < 3 else None)
        self.fake.add("OTHER-9", in_base=False)
        self.fake.add("AURORA-7", in_base=False)
        self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "OTHER-9"})
        self.fake.links.append({"id": 2, "type": "ASUPR_Связанные", "source": "AURORA-7", "target": "AURORA-2"})
        self.hub.known = ["AURORA-7"]
        out = self.ex.run({"id": 5, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}})
        self.assertEqual(out["status"], "done")
        self.assertEqual(out["result"], {"issues": 3, "neighbors": 1, "pages": 1})
        state = self.hub.states[0]
        self.assertEqual(state["command_id"], 5)
        self.assertEqual(sorted(i["key"] for i in state["issues"]), ["AURORA-1", "AURORA-2", "AURORA-3", "OTHER-9"])
        self.assertEqual(state["neighbor_keys"], ["OTHER-9"])
        self.assertTrue(state["last_page"])
        sprints = {i["key"]: i["sprint"] for i in state["issues"]}
        self.assertEqual(sprints["AURORA-1"], "Спринт 2")
        self.assertIsNone(sprints["AURORA-3"])
        links = {(l["source_key"], l["target_key"]) for l in state["links"]}
        self.assertEqual(links, {("AURORA-1", "OTHER-9"), ("AURORA-7", "AURORA-2")})
        self.assertEqual([r[1] for r in self.fake.requests if r[1] == "/rest/api/2/search"].count("/rest/api/2/search"), 2)

    def test_refresh_by_keys_pages(self):
        for i in range(1, 121):
            self.fake.add("AURORA-%d" % i)
        keys = ["AURORA-%d" % i for i in range(1, 121)]
        out = self.ex.run({"id": 6, "type": "refresh", "params": {"keys": keys}})
        self.assertEqual(out["result"], {"issues": 120, "neighbors": 0, "pages": 2})
        self.assertEqual(len(self.hub.states), 2)
        self.assertEqual(self.hub.states[0]["page"], 1)
        self.assertFalse(self.hub.states[0]["last_page"])
        self.assertTrue(self.hub.states[1]["last_page"])
        self.assertEqual(len(self.hub.states[0]["issues"]) + len(self.hub.states[1]["issues"]), 120)

    def test_refresh_empty_keys(self):
        out = self.ex.run({"id": 7, "type": "refresh", "params": {"keys": []}})
        self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0})
        self.assertEqual(self.fake.requests, [])
        self.assertEqual(self.hub.states, [])


if __name__ == "__main__":
    unittest.main()
  • Step 3: Запустить — падает

Run: /usr/bin/python3 -m unittest tests.test_refresh -v Expected: FAIL — AttributeError: 'Executor' object has no attribute 'to_state'

  • Step 4: Реализация

В класс Executor после do_ensure_sprint:

    # --- refresh ---------------------------------------------------------------
    STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels",
                    "issuetype", "created", "updated", "issuelinks"]

    def state_fields(self) -> str:
        extra = [self.fid(x) for x in ("planned_start", "planned_end") if self.has_field(x)]
        ep = (self.s.get("epic_link") or {})
        if ep.get("mode", "field") == "field" and ep.get("field_id"):
            extra.append(ep["field_id"])
        return ",".join(self.STATE_FIELDS + extra)

    def search_all(self, jql: str) -> List[dict]:
        issues, start = [], 0
        while True:
            page = self.jira.get(API + "/search", jql=jql, fields=self.state_fields(), startAt=start, maxResults=100)
            got = page.get("issues") or []
            issues += got
            start += len(got)
            if not got or start >= int(page.get("total") or 0):
                return issues

    def to_state(self, raw: dict) -> dict:
        f = raw.get("fields") or {}

        def name(obj):
            return (obj or {}).get("name")

        ep = self.s.get("epic_link") or {}
        epic = f.get(ep["field_id"]) if ep.get("mode", "field") == "field" and ep.get("field_id") else None
        return {"key": raw["key"], "id": raw.get("id"), "issue_type": name(f.get("issuetype")), "summary": f.get("summary"),
                "description": f.get("description"), "status": name(f.get("status")), "priority": name(f.get("priority")),
                "assignee": name(f.get("assignee")), "reporter": name(f.get("reporter")), "duedate": _date(f.get("duedate")),
                "planned_start": _date(f.get(self.fid("planned_start"))) if self.has_field("planned_start") else None,
                "planned_end": _date(f.get(self.fid("planned_end"))) if self.has_field("planned_end") else None,
                "epic_key": epic, "labels": list(f.get("labels") or []), "sprint": None,
                "created": f.get("created"), "jira_updated": f.get("updated")}

    def links_from(self, raw: dict) -> List[dict]:
        key, out = raw["key"], []
        for l in (raw.get("fields") or {}).get("issuelinks") or []:
            t = (l.get("type") or {}).get("name")
            if l.get("outwardIssue"):
                out.append({"source_key": key, "target_key": l["outwardIssue"]["key"], "link_type": t})
            if l.get("inwardIssue"):
                out.append({"source_key": l["inwardIssue"]["key"], "target_key": key, "link_type": t})
        return out

    def sprint_names(self, keys: List[str]) -> Dict[str, str]:
        names = {}
        if not keys or not self.s.get("board_id"):
            return names
        for name, sp in self.sprints().items():
            for i in range(0, len(keys), 100):
                chunk = keys[i:i + 100]
                page = self.jira.get(AGILE + "/sprint/%s/issue" % sp["id"], jql="key in (%s)" % ",".join(chunk),
                                     fields="key", maxResults=200)
                for it in page.get("issues") or []:
                    names[it["key"]] = name
        return names

    def do_refresh(self, p: dict, cmd: dict) -> dict:
        if p.get("jql"):
            raws = self.search_all(p["jql"])
        else:
            keys = [k for k in (p.get("keys") or []) if k]
            if not keys:
                return self._out("done", {"issues": 0, "neighbors": 0, "pages": 0})
            raws = []
            for i in range(0, len(keys), 100):
                raws += self.search_all("key in (%s)" % ",".join(keys[i:i + 100]))
        fetched = {r["key"] for r in raws}
        known = set(self.hub.known_keys()) if self.hub else set()
        linked = set()
        for r in raws:
            for l in self.links_from(r):
                linked.add(l["source_key"])
                linked.add(l["target_key"])
        missing = sorted(k for k in linked if k not in fetched and k not in known)
        neighbors = []
        for i in range(0, len(missing), 100):
            neighbors += self.search_all("key in (%s)" % ",".join(missing[i:i + 100]))
        everything = raws + neighbors
        names = self.sprint_names([r["key"] for r in everything])
        issues, links, seen = [], [], set()
        for r in everything:
            st = self.to_state(r)
            st["sprint"] = names.get(r["key"])
            issues.append(st)
            for l in self.links_from(r):
                sig = (l["source_key"], l["target_key"], l["link_type"])
                if sig not in seen:
                    seen.add(sig)
                    links.append(l)
        neighbor_keys = [r["key"] for r in neighbors]
        pages = 0
        for i in range(0, len(issues), 100):
            chunk = issues[i:i + 100]
            chunk_keys = {x["key"] for x in chunk}
            pages += 1
            self.hub.state({"command_id": cmd.get("id"), "issues": chunk,
                            "links": [l for l in links if l["source_key"] in chunk_keys or l["target_key"] in chunk_keys],
                            "neighbor_keys": [k for k in neighbor_keys if k in chunk_keys],
                            "page": pages, "last_page": i + 100 >= len(issues)})
        return self._out("done", {"issues": len(raws), "neighbors": len(neighbors), "pages": pages})
  • Step 5: Запустить — зелёный

Run: /usr/bin/python3 -m unittest tests.test_refresh -v Expected: 4 passed

  • Step 6: Коммит
git add src/agent.py tests/fake_hub.py tests/test_refresh.py
git commit -m "feat(agent): чтение состояния — поиск, соседи по связям, спринты, страницы в хаб

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

Task 6: Цикл агента, восстановление, команда run

Files:

  • Modify: src/agent.py (класс Runner после Executor, cmd_run, main)
  • Test: tests/test_runner.py

Interfaces:

  • Produces: Runner(cfg, hub, jira, journal): .settings, .executor, .stop (флаг), .sleep (подменяемый), .idle_limit (None | int — выйти после N пустых опросов, для тестов), .load_settings(), .send_result(rec) -> bool, .wait_pause(pause_seconds) -> bool, .execute(cmd), .recover(), .loop(); cmd_run(cfg, journal_path) -> int.

  • Step 1: Тесты

tests/test_runner.py:

import os
import tempfile
import time
import unittest

from _common import SETTINGS, agent, jira_cfg, no_sleep
from fake_hub import FakeHub
from fake_jira import FakeJira


class RunnerTest(unittest.TestCase):
    def setUp(self):
        self.fake = FakeJira()
        self.hub = FakeHub(SETTINGS)
        self.dir = tempfile.TemporaryDirectory()
        self.journal = agent.Journal(os.path.join(self.dir.name, "journal.jsonl"))
        cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token)
        self.runner = agent.Runner(cfg, agent.HubClient(cfg), agent.JiraClient(cfg), self.journal)
        self.sleeps = []
        self.runner.sleep = self.sleeps.append
        self.runner.jira.sleep = no_sleep
        self.runner.idle_limit = 1
        self.fake.add("AURORA-1", duedate="2026-10-20")

    def tearDown(self):
        self.fake.stop()
        self.hub.stop()
        self.dir.cleanup()

    def kinds(self):
        return [(r["kind"], r["command_id"]) for r in self.journal.records()]

    def test_processes_queue(self):
        self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
        self.hub.enqueue("refresh", {"keys": ["AURORA-1"]})
        self.runner.loop()
        self.assertEqual(self.hub.results[1]["status"], "done")
        self.assertEqual(self.hub.results[1]["result"], {"changed": ["duedate"]})
        self.assertEqual(self.hub.results[1]["jira_calls"], 2)
        self.assertEqual(self.hub.results[2]["status"], "done")
        self.assertEqual(len(self.hub.states), 1)
        self.assertEqual(self.hub.states[0]["issues"][0]["duedate"], "2026-10-23")
        self.assertEqual(self.kinds(), [("started", 1), ("finished", 1), ("acked", 1), ("started", 2), ("finished", 2), ("acked", 2)])
        self.assertEqual(self.sleeps, [])                      # пауз не было: last_call_at пуст, потом pause 0

    def test_pause_waits_from_last_jira_call(self):
        self.runner.jira.last_call_at = time.monotonic()
        self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=3)
        self.runner.loop()
        self.assertEqual(len(self.sleeps), 3)
        self.assertAlmostEqual(sum(self.sleeps), 3, delta=0.1)
        self.assertEqual(self.hub.results[1]["status"], "done")

    def test_result_retried_when_hub_down(self):
        self.hub.fail_results = 2
        self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
        self.runner.loop()
        self.assertEqual(self.hub.results[1]["status"], "done")
        self.assertEqual(self.sleeps, [10, 10])
        self.assertEqual(self.journal.unacked_finished(), [])

    def test_result_rejected_is_acked(self):
        self.hub.reject_results = {1: 404}
        self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
        self.runner.loop()
        self.assertNotIn(1, self.hub.results)
        self.assertEqual(self.journal.unacked_finished(), [])
        self.assertEqual(self.kinds()[-1], ("acked", 1))

    def test_recover_unfinished_started(self):
        cid = self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-20"}, pause=99)
        self.hub.queue.remove(cid)
        self.hub.commands[cid]["status"] = "claimed"
        self.hub.in_flight = cid
        self.journal.started(cid)
        self.runner.loop()
        self.assertEqual(self.hub.results[cid]["result"], {"changed": []})   # уже так в Jira — записи нет
        self.assertEqual(self.journal.unfinished_started(), [])
        self.assertEqual(self.sleeps, [])                                     # пауза при восстановлении не применяется
        self.assertEqual([r[0] for r in self.fake.writes()], [])

    def test_recover_skips_command_not_claimed_anymore(self):
        cid = self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
        self.hub.queue.remove(cid)
        self.hub.commands[cid]["status"] = "cancelled"
        self.journal.started(cid)
        self.runner.loop()
        self.assertNotIn(cid, self.hub.results)
        self.assertEqual(self.kinds(), [("started", cid), ("acked", cid)])

    def test_recover_unacked_finished(self):
        cid = self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
        self.hub.queue.remove(cid)
        self.hub.commands[cid]["status"] = "claimed"
        self.hub.in_flight = cid
        self.journal.started(cid)
        self.journal.finished(cid, "done", {"changed": ["duedate"]}, None, 2, 0)
        self.hub.enqueue("refresh", {"keys": ["AURORA-1"]})
        self.runner.loop()
        self.assertEqual(self.hub.results[cid]["result"], {"changed": ["duedate"]})
        self.assertEqual(self.hub.results[2]["status"], "done")
        self.assertEqual(self.kinds()[:3], [("started", 1), ("finished", 1), ("acked", 1)])

    def test_stop_during_pause(self):
        self.runner.jira.last_call_at = time.monotonic()
        self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=5)
        self.runner.sleep = lambda s: setattr(self.runner, "stop", True)
        self.runner.loop()
        self.assertEqual(self.journal.records(), [])
        self.assertEqual(self.hub.results, {})
        self.assertEqual(self.hub.commands[1]["status"], "claimed")

    def test_settings_reload_on_version_change(self):
        self.runner.load_settings()
        self.runner.settings["settings_version"] = 0
        self.runner.loop()
        self.assertEqual(self.runner.settings["settings_version"], 1)

    def test_hub_down_at_start_keeps_polling(self):
        calls = []

        def hub_settings():
            calls.append(1)
            if len(calls) < 3:
                raise ConnectionError("нет связи")
            return dict(SETTINGS)

        self.runner.hub.settings = hub_settings
        self.runner.loop()
        self.assertEqual(len(calls), 3)
        self.assertEqual(self.sleeps, [0, 0])


if __name__ == "__main__":
    unittest.main()
  • Step 2: Запустить — падает

Run: /usr/bin/python3 -m unittest tests.test_runner -v Expected: FAIL — AttributeError: module 'agent' has no attribute 'Runner'

  • Step 3: Реализация

В src/agent.py после класса Executor:

# ---------------------------------------------------------------------------
# Цикл: claim → пауза → журнал → выполнение → отчёт до подтверждения; восстановление при старте
# ---------------------------------------------------------------------------
class Runner:
    def __init__(self, cfg: dict, hub: HubClient, jira: JiraClient, journal: Journal):
        self.cfg, self.hub, self.jira, self.journal = cfg, hub, jira, journal
        self.settings = None          # type: Optional[dict]
        self.executor = None          # type: Optional[Executor]
        self.stop = False
        self.sleep = time.sleep
        self.idle_limit = None        # type: Optional[int]

    @property
    def poll(self) -> float:
        return max(float(self.cfg.get("poll_seconds", 5) or 0), 0)

    def load_settings(self) -> None:
        self.settings = self.hub.settings()
        self.executor = Executor(self.jira, self.settings, self.hub)
        self.executor.sleep = self.sleep
        log("настройки хаба v%s (проект %s)" % (self.settings.get("settings_version"), self.settings.get("project_key")))

    def send_result(self, rec: dict) -> bool:
        """Отчёт хабу; повторяется, пока хаб не подтвердит. 404/409/422 — хаб отверг (команда отменена или удалена):
        считаем закрытой, чтобы не зациклиться."""
        cid = rec["command_id"]
        payload = {"status": rec["status"], "result": rec.get("result"), "error": rec.get("error"),
                   "jira_calls": rec.get("jira_calls", 0), "rate_limited": rec.get("rate_limited", 0)}
        while not self.stop:
            try:
                self.hub.result(cid, payload)
                self.journal.acked(cid)
                return True
            except HttpError as e:
                if e.code in (404, 409, 422):
                    log("хаб отверг отчёт по #%s: %s — считаю закрытым" % (cid, e))
                    self.journal.acked(cid)
                    return True
                log("хаб: %s, повтор через 10 с" % e)
            except ConnectionError as e:
                log("хаб недоступен: %s, повтор через 10 с" % e)
            self.sleep(10)
        return False

    def wait_pause(self, pause_seconds) -> bool:
        """Досыпать до зазора от последнего вызова Jira; False — остановлены во время паузы."""
        pause = float(pause_seconds or 0)
        if pause > 0 and self.jira.last_call_at is not None:
            remaining = pause - (time.monotonic() - self.jira.last_call_at)
            if remaining > 0:
                log("  пауза %.0f с до команды" % remaining)
            while remaining > 0 and not self.stop:
                step = min(remaining, 1.0)
                self.sleep(step)
                remaining -= step
        return not self.stop

    def execute(self, cmd: dict) -> None:
        log("#%s %s %s" % (cmd["id"], cmd["type"], json.dumps(cmd.get("params"), ensure_ascii=False)[:200]))
        if not self.wait_pause(cmd.get("pause_seconds")):
            return
        self.journal.started(cmd["id"])
        out = self.executor.run(cmd)
        self.journal.finished(cmd["id"], out["status"], out.get("result"), out.get("error"), out["jira_calls"], out["rate_limited"])
        log("  → %s %s" % (out["status"], out.get("error") or json.dumps(out.get("result"), ensure_ascii=False)))
        rec = dict(out)
        rec["command_id"] = cmd["id"]
        self.send_result(rec)

    def recover(self) -> None:
        for rec in self.journal.unacked_finished():
            log("досылаю отчёт по #%s" % rec["command_id"])
            self.send_result(rec)
        for cid in self.journal.unfinished_started():
            try:
                cmd = self.hub.command(cid)
            except HttpError as e:
                log("#%s: хаб ответил %s — закрываю запись журнала" % (cid, e))
                self.journal.acked(cid)
                continue
            if cmd.get("status") != "claimed":
                log("#%s уже %s в хабе — пропускаю" % (cid, cmd.get("status")))
                self.journal.acked(cid)
                continue
            log("#%s начата до перезапуска — выполняю заново через проверку" % cid)
            cmd["pause_seconds"] = 0
            self.execute(cmd)

    def loop(self) -> None:
        idle, recovered = 0, False
        while not self.stop:
            if self.settings is None:
                try:
                    self.load_settings()
                except (ConnectionError, HttpError) as e:
                    log("хаб: %s" % e)
                    self.sleep(self.poll)
                    continue
            if not recovered:
                self.recover()
                recovered = True
            try:
                resp = self.hub.claim(AGENT_VERSION, self.settings.get("settings_version"), self.jira.last_call_iso)
            except (ConnectionError, HttpError) as e:
                log("хаб: %s" % e)
                self.sleep(self.poll)
                continue
            if resp.get("settings_version") != self.settings.get("settings_version"):
                try:
                    self.load_settings()
                except (ConnectionError, HttpError) as e:
                    log("хаб: не перечитать настройки: %s" % e)
            cmd = resp.get("command")
            if cmd:
                idle = 0
                self.execute(cmd)
                continue
            idle += 1
            if self.idle_limit is not None and idle >= self.idle_limit:
                return
            self.sleep(self.poll)


def cmd_run(cfg: dict, journal_path: str) -> int:
    runner = Runner(cfg, HubClient(cfg), JiraClient(cfg), Journal(journal_path))

    def on_signal(signum, frame):
        if runner.stop:
            log("повторный сигнал — выхожу немедленно")
            sys.exit(130)
        runner.stop = True
        log("сигнал %s: доделываю текущую команду и выхожу" % signum)

    signal.signal(signal.SIGINT, on_signal)
    signal.signal(signal.SIGTERM, on_signal)
    log("jira-agent v%d: хаб %s, Jira %s, журнал %s" % (AGENT_VERSION, cfg["hub_url"], cfg["jira"]["base_url"], journal_path))
    runner.loop()
    log("остановлен")
    return 0

В main заменить хвост:

    if args.cmd == "check":
        return cmd_check(cfg)
    if args.cmd == "prune":
        removed = Journal(args.journal).prune(30)
        log("журнал: удалено записей %d" % removed)
        return 0
    return cmd_run(cfg, args.journal)
  • Step 4: Запустить — зелёный, затем весь набор

Run: /usr/bin/python3 -m unittest tests.test_runner -v && /usr/bin/python3 -m unittest discover -s tests -t . -v Expected: 10 passed; весь набор зелёный

  • Step 5: Коммит
git add src/agent.py tests/test_runner.py
git commit -m "feat(agent): цикл claim → пауза → журнал → отчёт, восстановление после перезапуска, run

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

Task 7: README, CLAUDE.md, ARCHITECTURE.md, проверка синтаксиса 3.9

Files:

  • Create: README.md, tests/test_py39.py, .claude/code-paths (если хук require-plan не видит src/ по умолчанию — проверить cat .claude/hooks/* в репозитории; при стандартной обвязке файл не нужен)

  • Modify: CLAUDE.md, ARCHITECTURE.md

  • Step 1: Тест синтаксиса

tests/test_py39.py:

import ast
import os
import unittest

from _common import SRC


class Py39Test(unittest.TestCase):
    def test_agent_parses_as_python_39(self):
        with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
            ast.parse(f.read(), feature_version=(3, 9))

    def test_no_third_party_imports(self):
        with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
            tree = ast.parse(f.read())
        allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "sys", "time", "urllib", "datetime", "typing"}
        for node in ast.walk(tree):
            if isinstance(node, ast.Import):
                for a in node.names:
                    self.assertIn(a.name.split(".")[0], allowed, a.name)
            elif isinstance(node, ast.ImportFrom):
                self.assertIn((node.module or "").split(".")[0], allowed, node.module)


if __name__ == "__main__":
    unittest.main()

Run: /usr/bin/python3 -m unittest tests.test_py39 -v Expected: 2 passed

  • Step 2: README.md

Написать полностью на русском по пунктам:

  1. Что это и откуда спека (../jira-hub/openspec/changes/bridge-core/specs/agent/spec.md); связь с jira-hub.
  2. Доставка на виртуалку: скопировать src/agent.py и src/agent.example.json через общую папку в каталог на виртуалке (например ~/jira-agent/), переименовать в agent.json, заполнить hub_url (http://192.168.0.106:8790), hub_token (из jira-hub tokens на Mac mini), jira.base_url, jira.auth (логин/пароль или token), verify_ssl/ca_bundle при корпоративном сертификате. Напоминание: agent.json никуда не копировать, кроме виртуалки.
  3. Проверка: python3 agent.py check — что печатает в норме и при ошибках.
  4. Запуск: python3 agent.py в терминале; в фоне — nohup python3 -u agent.py > agent.log 2>&1 &; остановка — Ctrl-C или kill <pid> (текущая команда доделывается, повторный сигнал — выход немедленно).
  5. Автозапуск через launchd: пример ~/Library/LaunchAgents/ru.aepif.jira-agent.plist (ProgramArguments — /usr/bin/python3, -u, путь к agent.py; WorkingDirectory; RunAtLoad; KeepAlive; StandardOutPath/StandardErrorPath), команды launchctl load/unload.
  6. Журнал: journal.jsonl рядом с агентом — что в нём, зачем, python3 agent.py prune (подтверждённые записи старше 30 дней); журнал содержит параметры команд (тексты задач) — не выносить с виртуалки.
  7. Что делает агент с командами: таблица типов со ссылкой на спеку; правило «проверь, потом сделай»; паузы приходят от хаба; 429/503 — Retry-After; версия агента AGENT_VERSION и предупреждение хаба об устаревшем агенте.
  8. Тесты: /usr/bin/python3 -m unittest discover -s tests -t . (тот же Python 3.9.6, что на виртуалке).
  9. Обновление агента: скопировать новый agent.py, перезапустить; журнал и agent.json не трогать.
  • Step 3: CLAUDE.md и ARCHITECTURE.md

Заменить {{...}} шаблона: что это (абзац из proposal хаба про агента), стек (Python 3.9.6, только stdlib, unittest), архитектура — один файл src/agent.py с разделами (HTTP-клиенты, Journal, Executor, Runner, CLI) и тесты с фейковыми Jira/хабом, команды (/usr/bin/python3 -m unittest discover -s tests -t ., python3 src/agent.py check), тесты (фейки в tests/fake_*.py, внешних сервисов нет), первая подсистема — исполнитель команд. В ARCHITECTURE.md — таблица разделов файла и границы: «пароль Jira только в agent.json», «политика темпа не хранится», «одна команда в полёте», «отчёт до подтверждения». Указать, что спека и план живут в jira-hub (openspec/changes/bridge-core, docs/superpowers/plans/2026-10-11-bridge-core-hub.md).

  • Step 4: Весь набор и коммит

Run: /usr/bin/python3 -m unittest discover -s tests -t . -v Expected: все зелёные

git add README.md CLAUDE.md ARCHITECTURE.md tests/test_py39.py
git commit -m "docs(agent): README (доставка, запуск, launchd, prune), архитектура, проверка синтаксиса 3.9

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"