# bridge-hygiene — агент: план реализации > **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:** агент v4: сравнение текстов без различия `\r\n`/`\n`, честное время последнего вызова Jira в `claim`, полная пауза при времени журнала в будущем, устойчивость к ответу папки с неверными типами, `prune` под блокировкой. **Architecture:** точечные правки в `src/agent.py` (`Executor.do_ensure_fields`, `do_ensure_comment`, `Runner.restore_pace`, `FolderTransport`, CLI `prune`); контракт с хабом не меняется. **Tech Stack:** Python 3.9.6 (`/usr/bin/python3`), только stdlib, `unittest`. **Spec:** `../jira-hub/.wt/hygiene/openspec/changes/bridge-hygiene/` (design.md решения 16–21, specs/agent, specs/commands); агентские решения — `openspec/changes/bridge-hygiene/design.md` в этом репозитории. ## Global Constraints - Рабочий каталог: `/Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/hygiene` (ветка `feat/15-bridge-hygiene`). - Только синтаксис и stdlib Python 3.9: никаких `X | Y` в аннотациях функций, `match`, сторонних импортов. - Весь набор: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` (до начала: 141 OK). - `AGENT_VERSION = 4`. - Комментарии, docstring, логи и коммиты — по-русски; коммит с суффиксом `(#15)` и строкой `Co-Authored-By: Claude Opus 5.5 `. - Решения без обсуждения — в `openspec/changes/bridge-hygiene/design.md` этого репозитория, следующий маркер `R-12`. ## Review Focus 1. Описание в Jira `None`, а в команде `""` (или наоборот) — изменений нет, как и до правки — тест в Task 1. 2. Перезапуск с журналом, где последняя запись в будущем, и сразу Ctrl-C в паузе — пауза прерывается, команда остаётся на восстановление — существующий `test_stop_during_pause` (Task 2 не должен его ломать). 3. Ответ папки `{"status": true, …}` (bool — подкласс int в Python) — нечитаемый ответ, а не статус 1 — тест в Task 3. 4. `prune` при работающем агенте — отказ без изменения журнала; после остановки — работает — тест в Task 4. 5. Первый `claim` после перезапуска несёт `last_jira_call_at = null`, после первого вызова Jira — настоящее время — тест в Task 2. --- ### Task 1: Сравнение описания и комментария без различия переводов строк **Files:** - Modify: `src/agent.py` (новая `_nl` рядом с `_date`; `do_ensure_fields`, `do_ensure_comment`) - Test: `tests/test_executor_fields.py` **Interfaces:** - Produces: `agent._nl(value) -> str` — `(value or "").replace("\r\n", "\n")`. - [ ] **Step 1: Write the failing tests** — дописать в класс `ExecutorFieldsTest`: ```python def test_fields_description_crlf_equals_lf(self): self.fake.add("AURORA-1", description="а\r\nб") # так Jira хранит текст, правленный в UI out = self.run_cmd("ensure_fields", key="AURORA-1", description="а\nб") self.assertEqual(out["result"]["changed"], []) out = self.run_cmd("ensure_fields", key="AURORA-1", description="а\nв") self.assertEqual(out["result"]["changed"], ["description"]) self.assertEqual(self.fake.issues["AURORA-1"]["description"], "а\nв") # отправлен текст команды как есть def test_fields_description_none_equals_empty(self): self.fake.add("AURORA-1", description=None) out = self.run_cmd("ensure_fields", key="AURORA-1", description="") self.assertEqual(out["result"]["changed"], []) def test_comment_crlf_equals_lf(self): self.fake.add("AURORA-1") self.fake.comments["AURORA-1"] = [{"id": "10", "body": "*Актуализация 10.10.2026.* строка1\r\nстрока2"}] out = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="строка1\nстрока2") self.assertEqual(out["result"], {"comment_id": "10", "changed": False}) ``` Сверить формат элемента `fake.comments[key]` с `tests/fake_jira.py` (около строки 204) и подогнать словарь под него. - [ ] **Step 2: Run tests to verify they fail** Run: `/usr/bin/python3 -m unittest tests.test_executor_fields -v` Expected: FAIL — `changed == ["description"]` для CRLF; комментарий `changed: True`. - [ ] **Step 3: Implement** — в `src/agent.py` сразу после `_date`: ```python def _nl(value) -> str: """Текст для сравнения: Jira хранит правленное в UI с \r\n, команды приходят с \n (T3.9, проверено 13.10.2026).""" return (value or "").replace("\r\n", "\n") ``` В `do_ensure_fields` строку сравнения описания: ```python if "description" in p and _nl(cur.get(self.fid("description"))) != _nl(p["description"]): ``` В `do_ensure_comment` строку проверки совпадения: ```python if existing and _nl(existing.get("body")) == _nl(body): ``` - [ ] **Step 4: Run tests** Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` Expected: OK (включая `test_fields_description_exact_compare` — разница в завершающем `\n` по-прежнему изменение). - [ ] **Step 5: Commit** ```bash git add src/agent.py tests/test_executor_fields.py git commit -m "fix(agent): описание и комментарий сравниваются без различия CRLF/LF (#15) T3.9 (agent#4). Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 2: Темп после перезапуска — будущее время журнала и `last_jira_call_at` **Files:** - Modify: `src/agent.py` (`Runner.restore_pace`) - Modify: `tests/fake_hub.py` (запоминать тела `claim`) - Test: `tests/test_runner.py` **Interfaces:** - Consumes: `JiraClient.last_call_at` (monotonic), `JiraClient.last_call_iso` (ISO UTC, ставится в `JiraClient` при каждом вызове Jira). - Produces: `FakeHub.claim_bodies: list[dict]` — тела всех `POST /agent/claim` по порядку. - [ ] **Step 1: `tests/fake_hub.py`** — в `__init__` добавить `self.claim_bodies = []`; в `route` в ветке `if path == "/agent/claim":` первой строкой `self.claim_bodies.append(body or {})`. - [ ] **Step 2: Write the failing tests** — в `tests/test_runner.py` заменить `test_future_journal_time_ignored` и дописать новый тест: ```python def test_future_journal_time_means_full_pause(self): at = (datetime.now(timezone.utc) + timedelta(hours=2)).replace(microsecond=0).isoformat() self.journal.append({"kind": "acked", "command_id": 99, "at": at}) self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=30) self.runner.loop() self.assertAlmostEqual(sum(self.sleeps), 30, delta=1.5) # часы ушли назад — пауза полная (M-6 #12) self.assertEqual(self.hub.results[1]["status"], "done") def test_claim_after_restart_sends_no_journal_estimate(self): at = (datetime.now(timezone.utc) - timedelta(seconds=10)).replace(microsecond=0).isoformat() self.journal.append({"kind": "acked", "command_id": 99, "at": at}) self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) self.runner.loop() self.assertIsNone(self.hub.claim_bodies[0].get("last_jira_call_at")) # оценка журнала — только для паузы (M-7 #12) self.assertIsNotNone(self.hub.claim_bodies[-1].get("last_jira_call_at")) # после вызова Jira — настоящее время self.assertIsNotNone(self.runner.jira.last_call_at) ``` - [ ] **Step 3: Run tests to verify they fail** Run: `/usr/bin/python3 -m unittest tests.test_runner -v` Expected: FAIL — пауза 0 при будущем времени; первый `claim` несёт время журнала. - [ ] **Step 4: Implement** — `Runner.restore_pace` целиком: ```python def restore_pace(self) -> None: """После перезапуска время последнего вызова Jira неизвестно (живёт в памяти). Берём самое позднее время записи журнала: finished пишется сразу после последнего вызова команды, оценка не раньше настоящего вызова (M-9). Время в будущем (часы ушли назад) — считаем «сейчас»: пауза полная (M-6 #12). Оценка служит только паузе: last_call_iso (уходит хабу в claim) ставит лишь настоящий вызов Jira (M-7 #12).""" if self.jira.last_call_at is not None: return at = self.journal.last_at() if at is None: return ago = (datetime.now(timezone.utc) - at).total_seconds() if ago < 0: log("журнал: время последней записи в будущем (%s) — пауза перед первой командой полная" % at.isoformat()) ago = 0 self.jira.last_call_at = time.monotonic() - ago log("темп: последний вызов Jira не позже %s (по журналу)" % at.astimezone(timezone.utc).replace(microsecond=0).isoformat()) ``` - [ ] **Step 5: Run tests** Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` Expected: OK. Если другой тест проверял `last_call_iso` после `restore_pace` — привести его к спеке (оценка в `last_call_iso` не попадает) и записать рулинг `R-12` в design.md. - [ ] **Step 6: Commit** ```bash git add src/agent.py tests/fake_hub.py tests/test_runner.py git commit -m "fix(agent): полная пауза при времени журнала в будущем, claim без оценки из журнала (#15) M-6, M-7 (agent#12). Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 3: Ответ папки с неверными типами; текст ошибки без «хаб: хаб» **Files:** - Modify: `src/agent.py` (`FolderTransport.call`, `FolderTransport._take`) - Test: `tests/test_folder.py` **Interfaces:** - Consumes: `FolderTransport(root, authorization)`, `FolderTransport.BAD_JSON_WAIT`, `FolderTransport._take(path)`. - [ ] **Step 1: Write the failing tests** — дописать в `FolderTransportTest`: ```python def _bad_response(self, payload): path = os.path.join(self.root, "resp-bad.json") with open(path, "w", encoding="utf-8") as f: json.dump(payload, f) ft = agent.FolderTransport(self.root, "Bearer x") ft.BAD_JSON_WAIT = 0.3 return ft, path def test_wrong_status_type_is_unreadable(self): for payload in ({"id": "x", "status": "ok", "body": "{}"}, {"id": "x", "status": True, "body": "{}"}, {"id": "x", "status": 200, "body": 5}): ft, path = self._bad_response(payload) with self.assertRaises(ConnectionError) as e: ft._take(path) self.assertIn("нечитаемый ответ", str(e.exception)) def test_no_answer_message_has_no_hub_prefix(self): hub = self.client(timeout=0.3) with self.assertRaises(ConnectionError) as e: hub.settings() self.assertFalse(str(e.exception).startswith("хаб")) # цикл сам пишет «хаб: …» (O-3 agent#8) ``` - [ ] **Step 2: Run tests to verify they fail** Run: `/usr/bin/python3 -m unittest tests.test_folder -v` Expected: FAIL — `ValueError: invalid literal for int()` / `TypeError` вместо `ConnectionError`; текст начинается с «хаб». - [ ] **Step 3: Implement** — в `FolderTransport._take` после проверки `if not isinstance(resp, dict): raise ValueError(...)`: ```python status, body = resp.get("status"), resp.get("body") if type(status) is not int or not (body is None or isinstance(body, str)): raise ValueError("неверные типы status/body") # bool — подкласс int, поэтому type(), не isinstance (M-2 agent#8) ``` В `FolderTransport.call` последнюю строку заменить на `raise ConnectionError("не ответил через папку %s за %.0f с" % (self.root, timeout))`. - [ ] **Step 4: Run tests** Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` Expected: OK (тесты с «не ответил через папку» проходят — подстрока сохранена). - [ ] **Step 5: Commit** ```bash git add src/agent.py tests/test_folder.py git commit -m "fix(agent): ответ папки с неверными типами — ConnectionError; лог без повтора «хаб» (#15) M-2, O-3 (agent#8). Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 4: `prune` под блокировкой, версия 4, README, `.gitignore` **Files:** - Modify: `src/agent.py` (`AGENT_VERSION`, новая `cmd_prune`, `main`) - Modify: `README.md`, `.gitignore`, `CLAUDE.md` - Test: `tests/test_runner.py`, `tests/test_folder.py` **Interfaces:** - Consumes: `agent.acquire_lock(journal_path) -> file | None`, `Journal(path).prune(days) -> int`. - Produces: `agent.cmd_prune(journal_path: str) -> int` — 0 после очистки, 1 если журнал занят работающим агентом. - [ ] **Step 1: Write the failing tests** В `tests/test_runner.py` (класс `RunnerTest`): ```python def test_prune_refused_while_agent_runs(self): old = "2020-01-01T00:00:00+00:00" self.journal.append({"kind": "finished", "command_id": 7, "status": "done", "result": None, "error": None, "jira_calls": 1, "rate_limited": 0, "at": old}) self.journal.append({"kind": "acked", "command_id": 7, "at": old}) held = agent.acquire_lock(self.journal.path) try: self.assertEqual(agent.cmd_prune(self.journal.path), 1) self.assertEqual(len(self.journal.records()), 2) # журнал не тронут (T2.3 agent#4) finally: held.close() self.assertEqual(agent.cmd_prune(self.journal.path), 0) self.assertEqual(self.journal.records(), []) ``` В `tests/test_folder.py` переименовать `test_version_is_3` в `test_version_is_4` с `self.assertEqual(agent.AGENT_VERSION, 4)`. - [ ] **Step 2: Run tests to verify they fail** Run: `/usr/bin/python3 -m unittest tests.test_runner tests.test_folder -v` Expected: FAIL — `AttributeError: module 'agent' has no attribute 'cmd_prune'`; версия 3. - [ ] **Step 3: Implement** — в `src/agent.py`: `AGENT_VERSION = 4`; после `cmd_run`: ```python def cmd_prune(journal_path: str) -> int: """Удалить подтверждённые записи старше 30 дней. Под той же блокировкой, что run: prune перезаписывает файл, и запись, добавленная работающим агентом (finished — неотправленный отчёт), пропала бы (T2.3).""" lock = acquire_lock(journal_path) if lock is None: log("агент запущен с журналом %s — prune невозможен, сначала остановите агента" % journal_path) return 1 try: removed = Journal(journal_path).prune(30) log("журнал: удалено записей %d" % removed) return 0 finally: lock.close() ``` В `main` ветку `prune` заменить на `return cmd_prune(args.journal)`. - [ ] **Step 4: Документы** - `.gitignore`: дописать `.wt/`. - `README.md`: - раздел про `prune` (около строки 121): «Не запускать при работающем агенте (ограничение 1 ниже).» → «При работающем агенте `prune` откажет («агент запущен…», код 1): сначала остановить агента.»; - ограничение 1 («`prune` не запускать при работающем агенте. Блокировки нет…») удалить, перенумеровать список; - ограничение «Сравнение описания в `ensure_fields` точное…» заменить на «**Сравнение текстов без различия переводов строк.** Описание и тело комментария сравниваются после замены `\r\n` на `\n` (Jira хранит правленное в UI с `\r\n`); отправляется текст команды как есть. Прочие различия (пробелы, завершающий перевод строки) — изменение.»; - «Версия»: «(сейчас 3)» → «(сейчас 4)»; добавить строку: «Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой.» - `CLAUDE.md`: в «Команды» у `prune` «(не при работающем агенте)» → «(при работающем агенте откажет)». - [ ] **Step 5: Run tests** Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t . && git status --short` Expected: OK; `.wt/` не виден в `git status`. - [ ] **Step 6: Commit** ```bash git add src/agent.py tests/test_runner.py tests/test_folder.py README.md .gitignore CLAUDE.md git commit -m "feat(agent): prune под блокировкой журнала, версия 4, README (#15) T2.3 (agent#4). Co-Authored-By: Claude Opus 5.5 " ```