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

556 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Транспорт агента через общую папку — план реализации (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<N>, проект <KEY>»; ожидание хаба через папку ≤ 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`:
```python
"""Фейковое реле хаба для тестов агента: забирает 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`:
```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/<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`:
```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/<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` — создать:
```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.