diff --git a/docs/superpowers/plans/2026-10-12-folder-transport-agent.md b/docs/superpowers/plans/2026-10-12-folder-transport-agent.md new file mode 100644 index 0000000..b9755ae --- /dev/null +++ b/docs/superpowers/plans/2026-10-12-folder-transport-agent.md @@ -0,0 +1,555 @@ +# Транспорт агента через общую папку — план реализации (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/.json` атомарно, ждёт `resp/.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/.json`: `{id, method, path, body, authorization, deadline}`; `id` — `uuid.uuid4().hex`; `authorization` — `"Bearer "`; `deadline` — ISO 8601 UTC = сейчас + таймаут вызова; для `claim` таймаут ≥ 45 с (как сейчас в `HubClient.claim`). +- Формат ответа `resp/.json`: `{id, status, body}`, `body` — строка; 2xx → разобранный JSON (`{}` для пустой строки), иначе `HttpError(status, body)`. +- Запись — во временный файл с именем на точку и `os.replace`; агент MUST NOT трогать чужие файлы в `resp/`. +- Нет ответа к сроку — удалить свой `req/.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 `. +- Решения, принятые при исполнении без обсуждения, — в `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`: + +```python +"""Фейковое реле хаба для тестов агента: забирает req/.json из папки и отвечает resp/.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`: + +```python +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):` вставить: + +```python +class FolderTransport: + """Обмен с хабом через общую папку (hub_url = folder:<путь>), когда сеть до хаба закрыта (VPN на виртуалке). + Запрос — файл req/.json, ответ кладёт реле хаба в resp/.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 "" % 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`: + +```python +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** + +```bash +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`): + +```python +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` после проверки обязательных полей: + +```python + 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` заменить блок хаба: + +```python + 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 хаба);»; +- добавить раздел после «Проверка»: + +````markdown +## Связь с хабом через общую папку + +Когда VPN «Континент ZTN» включён, его фильтр закрывает локальную сеть: хаб по `http://` недоступен. Тогда в `agent.json`: + +```json +"hub_url": "folder:/Volumes/My Shared Files/RSHB/jira-bridge" +``` + +Агент пишет каждый запрос к хабу файлом `req/.json` в этой папке и ждёт ответ `resp/.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` — создать: + +```markdown +# 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** + +```bash +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.