jira-agent/docs/superpowers/plans/2026-10-12-folder-transport-agent.md
Andrey Epifancev de0a3d9003 docs(plan): план агента folder-transport (#5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:13:52 +04:00

30 KiB
Raw Blame History

Транспорт агента через общую папку — план реализации (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: hub_url = "folder:<путь>" переключает агента на обмен с хабом через файлы в общей папке UTM, без изменений цикла, журнала и восстановления.

Architecture: В src/agent.py появляется FolderTransport — call(method, path, body, timeout) пишет req/<id>.json атомарно, ждёт resp/<id>.json, сводит ответ к тем же исключениям, что Http.call (HttpError, ConnectionError). HubClient при folder: делегирует call транспорту; остальные методы HubClient и Runner не меняются. check сообщает транспорт и ждёт хаб через папку не дольше 10 с. Версия агента 2.

Tech Stack: Python 3.9.6 (системный /usr/bin/python3), только stdlib (uuid, os, json, time, datetime), unittest.

Spec: ../jira-hub/openspec/changes/folder-transport/ (design.md, specs/agent). Реле на стороне хаба — план jira-hub/docs/superpowers/plans/2026-10-12-folder-transport-hub.md.

Global Constraints

  • Python ≥ 3.9, только стандартная библиотека; tests/test_py39.py должен оставаться зелёным.
  • Формат запроса req/<id>.json: {id, method, path, body, authorization, deadline}; id — uuid.uuid4().hex; authorization — "Bearer <hub_token>"; deadline — ISO 8601 UTC = сейчас + таймаут вызова; для claim таймаут ≥ 45 с (как сейчас в HubClient.claim).
  • Формат ответа resp/<id>.json: {id, status, body}, body — строка; 2xx → разобранный JSON ({} для пустой строки), иначе HttpError(status, body).
  • Запись — во временный файл с именем на точку и os.replace; агент MUST NOT трогать чужие файлы в resp/.
  • Нет ответа к сроку — удалить свой req/<id>.json, если он ещё там, и ConnectionError; нечитаемый ответ — перечитывать до 2 с, затем ConnectionError. Опрос resp/ — каждые 0,1 с.
  • Нет каталога <путь> — ConnectionError("нет папки …") сразу; подкаталоги req/, resp/ агент создаёт сам при их отсутствии.
  • hub_url вида folder: с неабсолютным путём — load_config завершает процесс с понятной ошибкой.
  • check: «хаб: доступен через папку <путь>, настройки v, проект »; ожидание хаба через папку ≤ 10 с.
  • AGENT_VERSION = 2.
  • Учётные данные Jira не попадают в файлы общей папки.
  • Тесты: /usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t . — весь набор зелёный. Коммиты на русском, трейлер Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
  • Решения, принятые при исполнении без обсуждения, — в openspec/changes/folder-transport/design.md этого репозитория (раздел ## Decisions made without discussion, со ссылкой на спеку хаба), как было в bridge-core.

Review Focus

  1. Путь общей папки с пробелами (/Volumes/My Shared Files/RSHB/jira-bridge) — всё работает, путь не режется и не экранируется (тесты в Task 1 используют временный каталог с пробелом в имени).
  2. Ответ реле лежит в resp/, но ещё недописан (временный файл переименовался, а содержимое кривое) — агент перечитывает до 2 с, потом ConnectionError, не падает с ValueError (тест test_bad_json_response_gives_connection_error).
  3. Реле ответило после того, как агент сдался, — опоздавший ответ остаётся в resp/ (его уберёт реле), следующий вызов агента его не подхватывает (тест test_late_response_is_not_reused).
  4. hub_url = "folder:relative/path" или "folder:" — понятный выход из load_config, а не трассировка в цикле (тест test_relative_folder_url_rejected в Task 2).
  5. В resp/ лежит чужой файл (ответ другому агенту/старый) — агент его не удаляет и не читает (тест test_foreign_files_untouched).

Task 1: FolderTransport и HubClient через папку

Files:

  • Modify: src/agent.py (импорт uuid; новый класс FolderTransport перед class HubClient; HubClient.__init__ и новый HubClient.call)
  • Create: tests/fake_relay.py
  • Test: tests/test_folder.py

Interfaces:

  • Consumes: Http, HttpError, HubClient из src/agent.py; FakeHub из tests/fake_hub.py.

  • Produces:

    • agent.FolderTransport(root: str, authorization: str); атрибуты root, POLL = 0.1, BAD_JSON_WAIT = 2.0;
    • FolderTransport.call(method: str, path: str, body=None, timeout: float = 60) -> Any — ConnectionError/HttpError как у Http.call;
    • HubClient.folder: Optional[FolderTransport] (None для http://);
    • HubClient.call(method, path, body=None, params=None, timeout=None) — при folder делегирует, иначе Http.call;
    • tests/fake_relay.FakeRelay(root: str, target_base_url: str, mode: str = "forward") с start(), stop(), requests: list[dict]; режимы "forward" (переслать в HTTP), "silent" (забирать и молчать), "garbage" (класть нечитаемый ответ), "late" (ответ через delay с после срока).
  • Step 1: Фейковое реле для тестов

Создать tests/fake_relay.py:

"""Фейковое реле хаба для тестов агента: забирает req/<id>.json из папки и отвечает resp/<id>.json.
Режимы: forward — переслать в HTTP (FakeHub) и вернуть ответ; silent — забрать и не отвечать;
garbage — положить нечитаемый ответ; late — ответить через delay секунд."""
import json
import os
import threading
import time
import urllib.error
import urllib.request


def write_atomic(directory, name, payload):
    tmp = os.path.join(directory, "." + name + ".tmp")
    with open(tmp, "w", encoding="utf-8") as f:
        f.write(payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False))
    os.replace(tmp, os.path.join(directory, name))


class FakeRelay:
    def __init__(self, root, target_base_url="", mode="forward", delay=0.0):
        self.root, self.base, self.mode, self.delay = root, target_base_url, mode, delay
        self.requests = []
        self._stop = threading.Event()
        self._thread = threading.Thread(target=self._loop, daemon=True)
        for sub in ("req", "resp"):
            os.makedirs(os.path.join(root, sub), exist_ok=True)

    def start(self):
        self._thread.start()
        return self

    def stop(self):
        self._stop.set()
        self._thread.join(5)

    def _loop(self):
        req_dir, resp_dir = os.path.join(self.root, "req"), os.path.join(self.root, "resp")
        while not self._stop.is_set():
            for name in sorted(os.listdir(req_dir)):
                if name.startswith(".") or not name.endswith(".json"):
                    continue
                path = os.path.join(req_dir, name)
                try:
                    with open(path, encoding="utf-8") as f:
                        req = json.load(f)
                    os.remove(path)
                except (OSError, ValueError):
                    continue
                self.requests.append(req)
                if self.mode == "silent":
                    continue
                if self.mode == "garbage":
                    write_atomic(resp_dir, name, "{не json")
                    continue
                if self.mode == "late":
                    time.sleep(self.delay)
                status, body = self._forward(req)
                write_atomic(resp_dir, name, {"id": req["id"], "status": status, "body": body})
            time.sleep(0.02)

    def _forward(self, req):
        data = json.dumps(req["body"]).encode("utf-8") if req.get("body") is not None else None
        r = urllib.request.Request(self.base + req["path"], data=data, method=req["method"])
        r.add_header("Authorization", req["authorization"])
        if data is not None:
            r.add_header("Content-Type", "application/json")
        try:
            with urllib.request.urlopen(r, timeout=10) as resp:
                return resp.status, resp.read().decode("utf-8")
        except urllib.error.HTTPError as e:
            try:
                return e.code, e.read().decode("utf-8")
            finally:
                e.close()
  • Step 2: Написать падающие тесты

Создать tests/test_folder.py:

import json
import os
import tempfile
import time
import unittest
from datetime import datetime, timezone

from _common import SETTINGS, agent
from fake_hub import FakeHub
from fake_relay import FakeRelay


class FolderTransportTest(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.root = os.path.join(self.tmp.name, "My Shared Files", "jira-bridge")   # пробелы в пути
        os.makedirs(self.root)
        self.hub = FakeHub(SETTINGS)
        self.relay = None

    def tearDown(self):
        if self.relay:
            self.relay.stop()
        self.hub.stop()
        self.tmp.cleanup()

    def client(self, timeout=5):
        cfg = {"hub_url": "folder:" + self.root, "hub_token": self.hub.token, "timeout": timeout}
        return agent.HubClient(cfg)

    def run_relay(self, mode="forward", delay=0.0):
        self.relay = FakeRelay(self.root, self.hub.base_url, mode, delay).start()
        return self.relay

    def listing(self, sub):
        return sorted(os.listdir(os.path.join(self.root, sub)))

    def test_settings_through_folder(self):
        self.run_relay()
        hub = self.client()
        self.assertIsNotNone(hub.folder)
        self.assertEqual(hub.settings()["project_key"], "AURORA")
        self.assertEqual(self.listing("resp"), [])
        self.assertEqual(self.listing("req"), [])
        req = self.relay.requests[0]
        self.assertEqual(req["method"], "GET")
        self.assertEqual(req["path"], "/agent/settings")
        self.assertEqual(req["authorization"], "Bearer " + self.hub.token)
        self.assertEqual(len(req["id"]), 32)

    def test_error_status_raises_http_error(self):
        self.run_relay()
        with self.assertRaises(agent.HttpError) as e:
            self.client().result(999, {"status": "done"})
        self.assertEqual(e.exception.code, 404)

    def test_claim_deadline_at_least_45_seconds(self):
        self.run_relay()
        before = time.time()
        self.client(timeout=5).claim(2, 1, None)
        deadline = datetime.fromisoformat(self.relay.requests[0]["deadline"])
        self.assertIsNotNone(deadline.tzinfo)
        self.assertGreaterEqual(deadline.timestamp() - before, 44.5)

    def test_no_answer_gives_connection_error_and_removes_request(self):
        hub = self.client(timeout=0.5)
        t0 = time.monotonic()
        with self.assertRaises(ConnectionError) as e:
            hub.settings()
        self.assertLess(time.monotonic() - t0, 2)
        self.assertIn("не ответил через папку", str(e.exception))
        self.assertEqual([n for n in self.listing("req") if not n.startswith(".")], [])

    def test_missing_folder_gives_connection_error(self):
        cfg = {"hub_url": "folder:" + os.path.join(self.tmp.name, "нет"), "hub_token": "t", "timeout": 5}
        with self.assertRaises(ConnectionError) as e:
            agent.HubClient(cfg).settings()
        self.assertIn("нет папки", str(e.exception))

    def test_bad_json_response_gives_connection_error(self):
        self.run_relay("garbage")
        hub = self.client()
        hub.folder.BAD_JSON_WAIT = 0.3
        with self.assertRaises(ConnectionError) as e:
            hub.settings()
        self.assertIn("нечитаемый ответ", str(e.exception))

    def test_late_response_is_not_reused(self):
        self.run_relay("late", delay=1.0)
        with self.assertRaises(ConnectionError):
            self.client(timeout=0.3).settings()
        time.sleep(1.3)                                   # опоздавший ответ лёг в resp/
        late = self.listing("resp")
        self.assertEqual(len(late), 1)
        self.relay.mode = "forward"
        self.assertEqual(self.client().settings()["project_key"], "AURORA")
        self.assertEqual(self.listing("resp"), late)      # чужой (опоздавший) ответ не тронут

    def test_foreign_files_untouched(self):
        resp_dir = os.path.join(self.root, "resp")
        os.makedirs(resp_dir, exist_ok=True)
        foreign = os.path.join(resp_dir, "0" * 32 + ".json")
        with open(foreign, "w", encoding="utf-8") as f:
            json.dump({"id": "0" * 32, "status": 200, "body": "{}"}, f)
        self.run_relay()
        self.client().settings()
        self.assertTrue(os.path.exists(foreign))

    def test_http_url_keeps_http_transport(self):
        cfg = {"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5}
        hub = agent.HubClient(cfg)
        self.assertIsNone(hub.folder)
        self.assertEqual(hub.settings()["project_key"], "AURORA")


if __name__ == "__main__":
    unittest.main()
  • Step 3: Убедиться, что падают

Run: /usr/bin/python3 -m unittest tests.test_folder -v (из корня репозитория; если импорт _common не находится — /usr/bin/python3 -m unittest discover -s tests -t . -p test_folder.py -v) Expected: FAIL — у HubClient нет атрибута folder.

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

В src/agent.py:

  • в импорты добавить import uuid (по алфавиту, после import urllib.request);
  • перед class HubClient(Http): вставить:
class FolderTransport:
    """Обмен с хабом через общую папку (hub_url = folder:<путь>), когда сеть до хаба закрыта (VPN на виртуалке).
    Запрос — файл req/<id>.json, ответ кладёт реле хаба в resp/<id>.json. Ошибки те же, что у Http.call:
    нет ответа к сроку или нет папки — ConnectionError, код не 2xx — HttpError. Спека: specs/agent, folder-transport."""
    POLL = 0.1
    BAD_JSON_WAIT = 2.0

    def __init__(self, root: str, authorization: str):
        self.root, self.authorization = root, authorization

    def __repr__(self):
        return "<FolderTransport %s>" % self.root

    def call(self, method: str, path: str, body=None, timeout: float = 60) -> Any:
        if not os.path.isdir(self.root):
            raise ConnectionError("нет папки %s" % self.root)
        req_dir, resp_dir = os.path.join(self.root, "req"), os.path.join(self.root, "resp")
        rid = uuid.uuid4().hex
        try:
            os.makedirs(req_dir, exist_ok=True)
            os.makedirs(resp_dir, exist_ok=True)
            deadline = time.time() + timeout
            self._write(req_dir, rid + ".json", {
                "id": rid, "method": method, "path": path, "body": body, "authorization": self.authorization,
                "deadline": datetime.fromtimestamp(deadline, timezone.utc).isoformat()})
        except OSError as e:
            raise ConnectionError("папка %s недоступна: %s" % (self.root, e)) from None
        resp_path = os.path.join(resp_dir, rid + ".json")
        end = time.monotonic() + timeout
        while time.monotonic() < end:
            if os.path.exists(resp_path):
                return self._take(resp_path)
            time.sleep(self.POLL)
        try:
            os.remove(os.path.join(req_dir, rid + ".json"))
        except OSError:
            pass
        raise ConnectionError("хаб не ответил через папку %s за %.0f с" % (self.root, timeout))

    @staticmethod
    def _write(directory: str, name: str, payload: dict) -> None:
        tmp = os.path.join(directory, "." + name + ".tmp")
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(payload, f, ensure_ascii=False)
            f.flush()
            os.fsync(f.fileno())
        os.replace(tmp, os.path.join(directory, name))

    def _take(self, path: str) -> Any:
        end = time.monotonic() + self.BAD_JSON_WAIT
        while True:
            try:
                with open(path, encoding="utf-8") as f:
                    resp = json.load(f)
                if not isinstance(resp, dict):
                    raise ValueError("ответ не объект")
                break
            except (ValueError, OSError):
                if time.monotonic() >= end:
                    raise ConnectionError("нечитаемый ответ хаба в %s" % path) from None
                time.sleep(self.POLL)
        try:
            os.remove(path)
        except OSError:
            pass
        status, text = int(resp.get("status") or 0), resp.get("body") or ""
        if not 200 <= status < 300:
            raise HttpError(status, text)
        try:
            return json.loads(text) if text else {}
        except ValueError as e:
            raise HttpError(status, "ответ не JSON: %s; начало: %s" % (e, text[:200])) from None
  • заменить HubClient.__init__ и добавить call:
class HubClient(Http):
    def __init__(self, cfg: dict):
        auth = "Bearer " + cfg["hub_token"]
        super().__init__(cfg["hub_url"], {"Authorization": auth}, cfg.get("timeout", 60))
        url = cfg["hub_url"]
        self.folder = FolderTransport(url[len("folder:"):], auth) if url.startswith("folder:") else None

    def call(self, method: str, path: str, body=None, params=None, timeout=None) -> Any:
        if self.folder is None:
            return super().call(method, path, body, params, timeout)
        if params:
            path += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
        return self.folder.call(method, path, body, timeout or self.timeout)

(Методы claim, settings, command, result, state, known_keys не меняются.)

  • Step 5: Тесты зелёные

Run: /usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t . Expected: OK (весь набор, включая test_py39).

  • Step 6: Commit
git add src/agent.py tests/fake_relay.py tests/test_folder.py
git commit -m "feat(agent): транспорт folder: — обмен с хабом через общую папку (#5)"

Task 2: Выбор транспорта в настройках, check, версия 2, документация

Files:

  • Modify: src/agent.py (AGENT_VERSION, load_config, cmd_check, docstring модуля)
  • Modify: README.md, CLAUDE.md, ARCHITECTURE.md, src/agent.example.json (не меняется по ключам — только README объясняет folder:)
  • Create: openspec/changes/folder-transport/design.md (решения агента без обсуждения; если их нет — файл с заголовком, ссылкой на спеку хаба и пустой таблицей)
  • Test: tests/test_folder.py

Interfaces:

  • Consumes: FolderTransport, HubClient.folder, FakeRelay из Task 1; Runner, Journal, JiraClient, FakeJira, jira_cfg, no_sleep из существующих тестов.

  • Produces: agent.AGENT_VERSION == 2; cmd_check печатает «хаб: доступен через папку <путь>, …»; load_config завершает процесс при folder: без абсолютного пути.

  • Step 1: Падающие тесты

Добавить в tests/test_folder.py (новые импорты в шапку: import io, from contextlib import redirect_stdout, from _common import jira_cfg, no_sleep, from fake_jira import FakeJira):

class FolderIntegrationTest(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.root = os.path.join(self.tmp.name, "My Shared Files", "jira-bridge")
        os.makedirs(self.root)
        self.fake = FakeJira()
        self.hub = FakeHub(SETTINGS)
        self.relay = FakeRelay(self.root, self.hub.base_url).start()

    def tearDown(self):
        self.relay.stop()
        self.fake.stop()
        self.hub.stop()
        self.tmp.cleanup()

    def cfg(self):
        return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token)

    def test_version_is_2(self):
        self.assertEqual(agent.AGENT_VERSION, 2)

    def test_runner_through_folder(self):
        self.fake.add("AURORA-1", duedate="2026-10-20")
        cid = self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
        cfg = self.cfg()
        journal = agent.Journal(os.path.join(self.tmp.name, "journal.jsonl"))
        runner = agent.Runner(cfg, agent.HubClient(cfg), agent.JiraClient(cfg), journal)
        runner.sleep = lambda s: None
        runner.jira.sleep = no_sleep
        runner.idle_limit = 1
        runner.loop()
        self.assertEqual(self.hub.results[cid]["status"], "done")
        self.assertEqual(self.hub.results[cid]["agent_version"], 2)
        self.assertEqual(self.fake.issues["AURORA-1"]["duedate"], "2026-10-23")
        self.assertIn(("acked", cid), [(r["kind"], r["command_id"]) for r in journal.records()])

    def test_check_reports_folder(self):
        out = io.StringIO()
        with redirect_stdout(out):
            code = agent.cmd_check(self.cfg())
        self.assertEqual(code, 0)
        self.assertIn("хаб: доступен через папку %s, настройки v1, проект AURORA" % self.root, out.getvalue())

    def test_check_folder_silent_relay(self):
        self.relay.stop()
        self.relay = FakeRelay(self.root, self.hub.base_url, "silent").start()
        out = io.StringIO()
        t0 = time.monotonic()
        with redirect_stdout(out):
            code = agent.cmd_check(self.cfg())
        self.assertEqual(code, 1)
        self.assertLess(time.monotonic() - t0, 13)
        self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue())
        self.assertIn("не ответил через папку", out.getvalue())

    def test_relative_folder_url_rejected(self):
        path = os.path.join(self.tmp.name, "agent.json")
        for url in ("folder:relative/path", "folder:"):
            cfg = self.cfg()
            cfg["hub_url"] = url
            with open(path, "w", encoding="utf-8") as f:
                json.dump(cfg, f)
            with self.assertRaises(SystemExit) as e:
                agent.load_config(path)
            self.assertIn("абсолютный путь", str(e.exception.code))

Проверить по tests/test_runner.py, что FakeHub.results[cid] содержит agent_version (отчёт агента включает его по рулингу R-12 bridge-core); если ключ называется иначе — взять имя из Runner и поправить ассерт.

Run: /usr/bin/python3 -m unittest discover -s tests -t . -p test_folder.py -v Expected: FAIL — AGENT_VERSION == 1, check без «через папку», относительный путь принимается. test_runner_through_folder может пройти уже после Task 1 — это нормально (он страхует цикл целиком).

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

В src/agent.py:

  • AGENT_VERSION = 2;
  • в docstring модуля после абзаца об опросе хаба добавить строку: «Связь с хабом — HTTP (hub_url = http://…) или общая папка (hub_url = folder:<путь>), если сеть закрыта VPN.»;
  • в load_config после проверки обязательных полей:
    if cfg["hub_url"].startswith("folder:") and not os.path.isabs(cfg["hub_url"][len("folder:"):]):
        sys.exit("hub_url: после folder: нужен абсолютный путь общей папки, например "
                 "folder:/Volumes/My Shared Files/RSHB/jira-bridge")
  • в cmd_check заменить блок хаба:
    hub = HubClient(cfg)
    via = ""
    if hub.folder is not None:
        hub.timeout = 10                       # реле молчит дольше 10 с — хаб недоступен
        via = " через папку %s" % hub.folder.root
    try:
        s = hub.settings()
        log("хаб: доступен%s, настройки v%s, проект %s" % (via, s.get("settings_version"), s.get("project_key")))
    except (ConnectionError, HttpError) as e:
        log("хаб: НЕДОСТУПЕН — %s" % e)
        ok = False
  • Step 3: Тесты зелёные

Run: /usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t . Expected: OK.

  • Step 4: Документация

README.md:

  • в «Доставка на виртуалку», п. 2, пункт hub_url заменить на: «hub_url — http://<адрес Mac mini>:8790, или, если сеть до Mac mini закрыта VPN (Континент), folder:/Volumes/My Shared Files/RSHB/jira-bridge — обмен через общую папку UTM (на хабе должна быть задана настройка relay_dir, см. README хаба);»;
  • добавить раздел после «Проверка»:
## Связь с хабом через общую папку

Когда VPN «Континент ZTN» включён, его фильтр закрывает локальную сеть: хаб по `http://` недоступен. Тогда в `agent.json`:

```json
"hub_url": "folder:/Volumes/My Shared Files/RSHB/jira-bridge"
```

Агент пишет каждый запрос к хабу файлом `req/<id>.json` в этой папке и ждёт ответ `resp/<id>.json` от реле хаба
(круг ≈ 0,8 с). Если ответа нет до срока — то же, что недоступный хаб по сети: агент пишет в лог и повторяет. Пароль Jira
в папку не попадает; в файлах запросов есть только токен хаба. `check` в этом режиме печатает
`хаб: доступен через папку …` или `НЕДОСТУПЕН` (нет папки; реле не ответило за 10 с).
  • в «Что делает агент с командами» → «Версия»: «(сейчас 2)» вместо «(сейчас 1)».

CLAUDE.md и ARCHITECTURE.md — одной строкой: FolderTransport (транспорт folder: к хабу через общую папку, ошибки как у Http), рядом с HubClient; ссылка на спеку jira-hub/openspec/changes/folder-transport/.

openspec/changes/folder-transport/design.md — создать:

# folder-transport — решения агента

Спека и дизайн: `jira-hub/openspec/changes/folder-transport/`. Здесь — решения, принятые при реализации агента.

## Decisions made without discussion

| Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|
| 2026-10-12 | `HubClient` остаётся наследником `Http`, при `folder:` переопределённый `call` делегирует `FolderTransport` | методы `claim/settings/...` и `Runner` не меняются; один класс для обоих транспортов | нет |
| 2026-10-12 | Агент создаёт `req/` и `resp/`, если их нет (корень папки должен существовать) | агент может стартовать раньше, чем реле подготовит папку | нет |

(дописать сюда решения, принятые по ходу Task 1–2, если появятся).

  • Step 5: Commit
git add src/agent.py tests/test_folder.py README.md CLAUDE.md ARCHITECTURE.md openspec/changes/folder-transport/design.md
git commit -m "feat(agent): выбор транспорта по hub_url, check через папку, версия 2; README (#5)"

Приёмка

См. раздел «Приёмка» плана хаба: прогон на моке Jira через временную папку, настоящая папка под launchd, check на виртуалке при включённом Континенте, refresh full, import, сверка с CSV.