diff --git a/docs/superpowers/plans/2026-10-11-bridge-core-agent.md b/docs/superpowers/plans/2026-10-11-bridge-core-agent.md new file mode 100644 index 0000000..d5f1aef --- /dev/null +++ b/docs/superpowers/plans/2026-10-11-bridge-core-agent.md @@ -0,0 +1,2222 @@ +# Мост 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 `. +- Файл `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`: +```python +"""Общее для тестов: путь к 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`: +```python +"""Фейковый 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`: +```python +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`: +```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`: +```python +#!/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: Коммит** + +```bash +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 " +``` + +--- + +### 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`: +```python +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`: +```python +# --------------------------------------------------------------------------- +# Журнал: только дозапись; 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` заменить заглушку: +```python + 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: Коммит** + +```bash +git add src/agent.py tests/test_journal.py +git commit -m "feat(agent): журнал на диске, восстановление по нему, prune + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### 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`: +```python +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`: +```python +# --------------------------------------------------------------------------- +# Исполнитель: команда — желаемый результат; прочитать → сравнить → записать разницу +# --------------------------------------------------------------------------- +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: Коммит** + +```bash +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 " +``` + +--- + +### Task 4: Исполнитель — `ensure_status`, `ensure_link`, `ensure_sprint` + +**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`: +```python +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`: +```python + # --- 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: Коммит** + +```bash +git add src/agent.py tests/test_executor_links.py +git commit -m "feat(agent): исполнитель — переходы статусов, связи с заменой, спринты + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### 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`: +```python +"""Фейковый хаб для тестов агента: маршруты /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`: +```python +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`: +```python + # --- 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: Коммит** + +```bash +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 " +``` + +--- + +### 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`: +```python +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`: +```python +# --------------------------------------------------------------------------- +# Цикл: 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` заменить хвост: +```python + 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: Коммит** + +```bash +git add src/agent.py tests/test_runner.py +git commit -m "feat(agent): цикл claim → пауза → журнал → отчёт, восстановление после перезапуска, run + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### 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`: +```python +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 ` (текущая команда доделывается, повторный сигнал — выход немедленно). +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: все зелёные + +```bash +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 " +```