Реалізація під контролем: від плану до PR
План затверджено, кроки відомі - і здається, що далі найпростіше. Насправді саме тут найчастіше втрачають контроль: модель охоче робить більше, ніж просили, а бадьоре "готово, все працює" на вигляд нічим не відрізняється від результату, який розвалиться на чужому review.
Розберемо це на одному завданні: від затвердженого плану до відправленого PR. Дорогою - п'ять речей, які зазвичай дізнаються на власних PR:
- зелений тест буває доказом поломки, а не її відсутності;
- тест, написаний після фіксу, не доводить нічого;
- "полагоди" замість діагнозу коштує дорожче, ніж здається;
- опис PR, що розходиться з diff, вбиває довіру швидше за сам bug;
- review, яке ви робите самі, - не формальність перед справжнім review.
Затверджений план ще не результат
План на три кроки виглядає як гарантія. Але "реалізуй план" повертає зміни в шести файлах, і злого наміру тут немає: дорогою знайшовся дубльований код, сусідній метод просив уніфікації, а поруч трапився bug, який гріх не виправити.
Далі тонке місце. Claude не просто пропонує - він пропонує впевнено: у відповіді сказано, що зроблено і чому це правильно, і текст завжди виглядає переконливіше за сам diff. Читати треба diff, а не пояснення до нього - розбіжність між ними і є головним ризиком цього етапу.
Керований цикл повертає контроль однією рамкою: Claude вільний усередині кроку, а не всередині завдання. Тривожне "що він там переробив?" перетворюється на спокійне "цей diff відповідає поточному кроку?".
Зворотна стрілка тут одна: відхилений diff повертає до того самого кроку, а не рухає вперед. Зупинка з циклу виводить - це теж рішення, а не збій. Кожне коло закінчується вибором, а не відчуттям "наче нормально".
Запит на крок тримають три обмеження
У дослідженні розпливчастий запит дає розпливчасту відповідь. У реалізації він дає розпливчастий diff - а його потім ще й розгрібати. Тому в запиті на крок завжди є три речі:
- який крок робите зараз і куди переходити не можна;
- які файли можна змінювати і що робити, якщо знадобиться інший;
- яку перевірку запустити одразу після правки - і що робити, якщо знадобиться файл поза списком. Заборону без виходу Claude порушить мовчки: або розширить зону правки, або зупиниться на півдорозі. Вихід - назвати файл і пояснити причину до того, як його чіпати.
І одна умова до запиту: робоче дерево чисте. Інакше правки кроку не відділити від учорашніх, і доводити нічим.
Перший крок плану для issue #731 - відтворити bug тестом, production-код не чіпати. Ось як це виглядає в запиті:
Реалізуй тільки крок 1. До кроку 2 не переходь.
Додай падаючий API-тест: GET /api/deliveries?status=active
не має повертати скасовані доставки.
Змінюй тільки test/api/deliveries.test.ts.
Якщо знадобиться інший файл - спершу назви його і поясни навіщо.
Після цього запусти тільки цей тест і покажи зведення diff.
І що повернулося:
git diff --stat
# test/api/deliveries.test.ts | 14 +++++++++++++
# 1 file changed, 14 insertions(+)
npm test -- deliveries.test.ts -t "cancelled"
# FAIL excludes cancelled from active list
# expected 2 deliveries, received 3
Червоний тест тут - хороший знак: крок був саме про відтворення. І якби у відповідь прийшло "мені знадобиться ще ось цей файл, тому що..." - це теж не збій, а саме те, заради чого додали третє обмеження. Ознаку поганого кроку видно раніше: формулювання зачіпає controller, service, repository і принагідно конфіг - отже це не крок, а завдання, яке коротко назвали.
Diff вирішує: прийняти, повторити, зупинитися
Зелена перевірка заспокоює, але відповідає вона лише на питання "не розвалилося одразу". Чи відповідає правка кроку плану - показує тільки diff. Читаєте його не на красу коду, а на кілька питань:
| Питання до diff | Що захищає |
|---|---|
| Це все ще поточний крок плану? | межі scope |
| Змінено тільки очікувані файли? | від випадкових побічних правок |
| Чи немає принагідного refactoring? | від "корисних бонусів" |
| Чи можу я пояснити кожен рядок за хвилину? | від сліпого прийняття |
| Я б написав це так само, якби робив руками? | від правильного на вигляд рішення не в тому шарі |
Перші чотири питання ловлять розповзання. П'яте ловить те, що розповзанням не є: файл потрібний, розмір нормальний, тема збігається - а спосіб обрано чужий. Саме такі правки проходять самоперевірку і розвалюються на review.
Далі - одне з трьох рішень: прийняти і зафіксувати крок, відхилити diff і повторити той самий крок, зупинитися і повернутися до плану. Четвертого варіанта "ну майже нормально, поїхали далі" немає - саме так накопичується хаос, де кожен наступний запит лагодить наслідки попереднього.
А щоб у момент утоми не торгуватися із собою, стоп-правила пишете заздалегідь:
## Stop rules
- змінилися файли поза поточним кроком - не приймати diff
- одна перевірка падає двічі поспіль - припинити правки
- diff не пояснюється за хвилину - не приймати
/goal звільняє від наступного ручного prompt, але не перетворює роботу на детермінований gate. Після кожного turn окрема evaluator-модель визначає на підставі transcript, чи досягнуто condition; сама вона команди не запускає і файли не читає. Тому задайте точну перевірку, заборону змінювати тести, межу turns і вимогу показати вивід команди. Фінальний diff усе одно читаєте ви; для авторизації, платежів і міграцій лишається покроковий режим.Ваш хід: три diff
Критерії здаються однозначними лише до зустрічі з реальним diff. Ось три результати одного й того самого кроку - відтворити bug тестом. Ззовні сигнал однаковий: тест червоний, expected/actual схожі. Однаковий вивід - ще не однакова причина.
# A 2 files changed, 9 insertions(+)
# Claude змінив DeliveryController, хоча запит дозволяв тільки test-файл
# B 2 files changed, 18 insertions(+)
# другий файл Claude назвав до правки, ви перевірили причину і дозволили
# C 1 file changed, 24 insertions(+)
# тест написано, але в ньому обхід: контракт репозиторію довелося підмінити,
# інакше відтворити bug на цьому рівні неможливо
Що ви робите в кожному випадку - приймаєте, відхиляєте diff і повторюєте крок чи зупиняєтесь і повертаєтесь до плану? Вирішуйте до того, як перегорнемо сторінку.
Розбір: чому рішення різні
| Випадок | Рішення | Чому |
|---|---|---|
| A | повторити | обмеження вже було явним, але Claude його порушив: diff відхиляємо, файли повертаємо і повторюємо крок |
| B | прийняти | розширення зони пройшло через вас: названо до правки, причину перевірено, дозвіл дано |
| C | зупинитися | тест падає через підмінений контракт, а не через bug: на цьому рівні bug не відтворюється чесно |
Випадок B - єдиний, де "пояснив" перетворюється на "можна". Якби Claude назвав файл і тут же його виправив, рішення було б іншим: пояснення моделі не замінює вашого дозволу, інакше обмеження не працює.
А між A і C проходить межа, яку легко пропустити, бо обидва diff ви не приймаєте. У A план і крок так само правильні, порушено виконання: повертаєте файли і повторюєте той самий крок. Зупиняєтесь, коли робота розкрила знання, що ламає сам план: у C обраний рівень тесту не відтворює bug чесно, і повертатися треба до декомпозиції.
Червоний тест - це ще не діагноз
Другий крок плану - передати status у repository query. Правка на один рядок, цільовий тест позеленів. І тут же почервонів сусідній.
npm test -- deliveries.test.ts
# PASS excludes cancelled from active list
# FAIL returns delayed deliveries in active list
# expected 1 delivery, received 0
Зауважте, чому цільовий тест зелений: список став порожнім, а в порожньому списку скасованої доставки справді немає. Класичний хибний зелений - перевірка формально пройшла, поведінку зламано.
Цієї миті найпростіше написати "полагоди". Отримаєте менш галасливу помилку і нуль розуміння, а bug повернеться пізніше і в іншому місці. Тому спершу розводимо рівні, які легко склеїти в один:
| Рівень | Питання | У нашому випадку |
|---|---|---|
| Симптом | що видно ззовні? | Active-список порожній: expected 1, received 0 |
| Гіпотеза | що могло це спричинити? | "active" - не значення поля status, а група статусів |
| Коренева причина | що підтверджено? | поки нічого: гіпотезу ще не перевіряли |
Зауважте, куди вказує червоний: упав тест про delayed, хоча чіпали ми фільтрацію active. Місце падіння і місце причини розійшлися на один шар - і якщо лагодити там, де почервоніло, правка поїде в тест або в сусідній сценарій, а причина залишиться.
Докази збирають до першого питання
"У мене не працює, розберись" - і Claude починає будувати здогади з повітря. А повітря дороге, особливо коли модель уже править файли.
Пакет доказів збирають не для повноти, а для напрямку - і в цьому ж його ризик. Доклали diff - гіпотези зберуться навколо змінених рядків. Назвали підозрілий файл - навколо нього. Тому в пакет ідуть факти: вивід перевірки, що впала, команда відтворення, останній diff. Здогад "як на мене, річ у сервісі" в пакет не йде - він повернеться ранжованим списком гіпотез, кожна з яких буде про сервіс.
І сам запит відрізняється від звичайного однією деталлю - забороною на правки:
Розберись, чому падає тест. Файли не змінюй.
Вхід: вивід тесту вище, останній diff у DeliveryService.ts,
fixtures у test/fixtures/deliveries.json.
Поверни: гіпотези за спаданням імовірності, докази до кожної,
мінімальну перевірку для гіпотези 1
і те, що поки лишається невідомим.
Гіпотеза, яка не підтвердилася, повертає вас не на початок, а до списку гіпотез - докази і відтворення залишаються. І зауважте, чого у схемі немає: кроку "змінити три файли і подивитися, чи стало краще". Діагностична сесія має бути нуднішою, ніж сесія реалізації.
Мінімальна підтверджувальна перевірка не зобов'язана бути красивою, вона зобов'язана бути маленькою:
rg -o '"status": "[a-z_]+"' test/fixtures/deliveries.json | sort -u
# "status": "cancelled"
# "status": "delayed"
# "status": "in_transit"
# "status": "pending"
Значення "active" у даних немає взагалі. Гіпотеза зі здогаду перетворилася на підтверджений факт, і на це пішло менше хвилини.
Patch loop упізнається за однією ознакою
Зовні ви все ще лагодите bug. За фактом - додаєте правку за правкою, а розуміння не додається. Ознака проста: лагодимо двадцять хвилин, а причину однією фразою сформулювати не можемо.
Здорове налагодження тримається на одній провідній гіпотезі, відтворенні і diff, що майже не росте. У петлі навпаки: правки випадкові, diff розповзається по сусідніх файлах, а замість доказу - "наче має допомогти".
git restore і git stash повертають робоче дерево, але не розмову. У /rewind інший вибір: повернути code, conversation або обидва стани до checkpoint. Межа важлива: rewind відстежує прямі edits Claude, але не зміни з Bash-команд і зовнішніх процесів, тому Git лишається джерелом істини для дерева.
Якщо файли повернули через Git, відкинута гіпотеза все ще звучить у session context. Тоді або відкотіть conversation через /rewind, або почніть чисту сесію з компактним пакетом доказів, або прямо зафіксуйте, що гіпотеза не підтвердилася, і додайте доказ.
git diff --stat
git restore src/server/DeliveryService.ts
Коли провідну гіпотезу підтверджено, причину записуєте явно - до того, як почнете лагодити:
## Root cause
`DeliveryService` передає "active" у repository як значення поля status,
хоча такого статусу в даних немає: Active - це група pending, in_transit, delayed.
Точне порівняння повернуло порожній список.
## Confirmation
- fixtures містять тільки pending, in_transit, delayed, cancelled
- після правки падає тест "returns delayed deliveries in active list"
Такий запис можна передати іншій сесії, показати колезі і через годину не згадувати заново, "що ми там наче зрозуміли".
Спершу падаючий тест, потім фікс
Порядок "тест раніше за фікс" захищає не від поганого фіксу, а від доброго фіксу не тієї проблеми. Згадайте хибний зелений: список став порожнім, і перевірка на скасовані пройшла. Напиши ми тест після правки - він закріпив би саме цей стан і залишився б зеленим назавжди, бо писався б під уже зламану поведінку.
Поки тест не падав на старій поведінці, у вас немає точки відліку: він доводить лише те, що новий код узгоджений сам із собою. Узгодженість буває і в помилки.
Ланцюжок іде зліва направо і закінчується не зеленим тестом, а читанням diff: зелений сигнал говорить про поведінку, diff - про те, якою ціною її отримано. Сам фікс б'є саме по знайденій причині:
// Active - це група статусів, а не значення поля status
const ACTIVE_STATUSES = ["pending", "in_transit", "delayed"];
class DeliveryService {
list(status?: string) {
if (status === "active") {
return this.repo.findByStatuses(ACTIVE_STATUSES);
}
return this.repo.findAll();
}
}
Він хороший не геніальністю, а тим, що його легко пояснити: контракт endpoint не змінювався, сусідні маршрути не зачеплені, нових залежностей немає.
npm test -- deliveries.test.ts
# PASS excludes cancelled from active list
# PASS returns delayed deliveries in active list
# Tests: 5 passed, 5 total
Одного зеленого тесту мало
Тест позеленів - і хочеться закрити завдання. Але reviewer спитає не "що стало зеленим", а "чому цьому можна вірити". Переконливим виправлення роблять кілька незалежних ознак:
| Доказ | Що підтверджує | У нашому PR |
|---|---|---|
| red з очікуваної причини | тест відтворює вихідну поведінку | excludes cancelled отримав 3 доставки через cancelled |
| той самий сценарій red → green | фікс змінив відтворену поведінку | cancelled більше не повертається |
| сусідні перевірки | покриті сценарії поруч збереглися | 5 тестів файлу зелені |
| повний прогін | покриті сценарії поза файлом збереглися | увесь набір один раз перед відправленням |
| diff звірено з планом | scope не розповзся | тільки test і service, без принагідних правок |
Прицільні перевірки економлять час усередині циклу, але перед відправленням набір проганяєте цілком один раз - інакше поломка в сусідньому модулі спливе вже у reviewer.
І ще тонкість. Сесія, яка писала фікс, збирає докази з пам'яті про власну роботу, а не з repository: перелічить перевірки, які збиралася запустити, тести, які вважає дотичними до справи, файли, які пам'ятає зміненими. Виходить правдоподібно і майже правда - найгірший вид звіту, бо очима його ніхто не перевіряє. Тому тест, фікс і збір доказів просіть окремими запитами, а в останньому вимагайте факти і забороняйте оцінки:
Збери докази, що bug закрито: який тест падав до фіксу,
що змінилося після, які додаткові перевірки запускалися,
які файли змінено.
Тільки факти. Якість рішення не оцінюй.
Commit - точка відкату, а не збереження
Review-ready commit має бути таким, що його легко пояснити, і залишатися робочим після checkout чи revert. Regression-тест і фікс тут змінюють один контракт і відкочуються разом. Червоний test commit показує послідовність TDD, але не дає deployable-точку: без наступного commit CI залишиться червоним.
git add test/api/deliveries.test.ts src/server/DeliveryService.ts
git commit -m "fix(deliveries): filter active list by status group"
git show --stat --oneline HEAD
# 4c9f21a fix(deliveries): filter active list by status group
# 2 files changed, 20 insertions(+), 1 deletion(-)
Один рядок історії зберігає і доказ regression, і виправлення, тому лишається зеленим в обидва боки історії. Якщо команда зберігає TDD-етапи і допускає червоні проміжні commits, test і fix можна залишити окремо. Інакше локальні WIP/fixup-точки перед PR об'єднують.
Файли додано поіменно, і це не педантизм: git add . в AI-сесії затягує все, що модель зачепила дорогою, - від форматування в сусідньому файлі до незапланованого конфіга.
Повідомлення Claude запропонує охоче, але джерело правди - diff:
Подивись поточний diff і запропонуй один commit message.
Не вигадуй змін, яких немає.
Якщо бачиш файли поза поточним кроком плану - спершу перелічи їх окремо.
CLAUDE.md, щоб стиль не винаходився заново в кожному завданні. А attribution Claude для commits і PR налаштовується окремо - секцією attribution в settings.json.PR - запакована розповідь, а не набір commits
Reviewer відкриває ваш PR між двома зустрічами. Він не починає з git log і не хоче бути археологом - йому треба швидко зрозуміти, що змінилося, навіщо, як це перевірити і де межі завдання.
Після діагностики крок 3 про consumers виявився окремим результатом. Tech lead погодив розбиття: поточний PR закриває server-контракт issue #731, а перевірка consumer-поведінки йде у follow-up #742. Без такого рішення запис у Out of scope лише ховає незавершений крок затвердженого плану.
# PR walkthrough - issue #731
## What changed
- `DeliveryService.list`: запит зі status=active фільтрує за групою статусів
- `deliveries.test.ts`: regression-тест на скасовану доставку в Active
## Why
Service ігнорував status і кликав findAll(), тому скасована доставка
поверталася в Active після оновлення сторінки.
## Verification performed
- `npm test -- deliveries.test.ts` → 5 passed
- `npm test -- mobile-deliveries.contract.test.ts` → 3 passed
- `curl -s http://localhost:3000/api/deliveries?status=active`
→ pending, in_transit, delayed; cancelled відсутній
## Risks
Mobile використовує той самий endpoint; contract-тест зелений, форма відповіді не змінювалася.
UI-level regression-тест лишається у follow-up #742.
## Out of scope
- фільтр у frontend-компоненті
- поведінка при невідомому значенні status
- consumer-аудит: погоджений follow-up #742
Пишеться цей опис не тільки для reviewer. Це останній дешевий момент упіймати розбіжність: якщо, заповнюючи "що змінилося", ви знаходите пункт, якого не було в плані, - знахідка, а не завада. Пізніше вона обійдеться дорожче.
Verification performed - це evidence автора: тільки реально запущені команди і результат, який спостерігали. Інструкції для reviewer живуть окремо у How to verify; одне не підміняє інше. А Out of scope показує погоджені межі. PR розрізають не за довжиною опису, а коли всередині два результати, які можна незалежно перевірити і відкотити.
Файл - це чернетка, сам PR створюється з неї. gh тут - консольний клієнт GitHub:
git push -u origin bugfix/issue-731-cancelled-in-active
gh pr create --title "Не показувати скасовані доставки в Active" \
--body-file PR_DESCRIPTION.md
# https://github.com/parcelflow/parcelflow/pull/812
Заголовок описує результат, а не реалізацію: "Не показувати скасовані доставки в Active" корисніше reviewer, ніж "змінити фільтр у DeliveryService".
git diff і git log робите ви.Один diff, кілька проходів
Локальні тести зелені, опис зібрано - і хочеться швидше натиснути "створити PR". Саме тут найдешевше впіймати зайвий файл, випадковий rename і правку "заразом".
Локальне review - це кілька проходів по одному й тому самому diff, поки його не побачив хтось інший.
Схема читається згори вниз як маршрут однієї зміни: кожен прохід додає знахідки, але жоден із них не виносить вердикт замість вас. Опціональні прискорювачі на схему не винесені - їхнє місце між свіжим поглядом і нотатками, коли diff того вартий.
| Прохід | Хто дивиться | Що дає |
|---|---|---|
| Self-review | ви як автор | звірка diff з планом і сенсом завдання |
| Claude-assisted | поточна сесія | швидкий список знахідок за scope і тестами |
| Fresh context | інша сесія або reviewer-агент | погляд без історії вашої сесії |
| Плагін або хмарне review | опціонально, за розміром і ризиком diff | ще один автоматичний прожектор |
| Рішення | ви | go або no-go |
Порядок тут не декоративний, і річ не лише у відповідальності. Сесія, яка писала код, бачила, як він народжувався, і поділяє ваші передумови: вона перевірить, чи добре реалізовано задум, але не спитає, чи вірний сам задум. Це робить fresh context не розкішшю, а єдиним проходом, де задум узагалі перевіряється.
І про міру: для правки на два рядки чесного self-review цілком достатньо - решту підключаєте, коли diff багатошаровий, зачіпає чужий код або ви в цьому модулі вперше. А якщо пропустити self-review і одразу спитати модель "ну як там?", ви тихо призначаєте Claude заступником з відповідальності. Кадрове рішення так собі.
Спершу ви, потім Claude
Diff ви читаєте звичними командами - git diff --stat, по файлах, git status, повний прогін перед відправленням. Цікавіше те, чого у звичайному self-review не шукають, а в AI-diff шукати доводиться завжди:
- тимчасовий налагоджувальний вивід - його додали під час діагностики, і він лишається, бо до фіксу стосунку не має і око за нього не чіпляється;
- правки, яких ви не просили - перейменування, форматування, "заразом" у сусідньому методі: кожна окремо безневинна, разом вони роблять diff чужим.
Фінальна самоперевірка звучить так: якби цей PR відкрив колега, чи зрозуміли б ви за diff, що сталося і навіщо?
Тільки після цього підключається Claude - і не вільно, а з фокусом. Без списку зон уваги відповідь передбачувана: "загалом добре, можна покращити читабельність". Фокус потрібен не моделі, а вам - він перетворює думку на перевірюваний список, де по кожному пункту видно, згодні ви чи ні:
Перевір поточний diff за затвердженим планом.
Дивись тільки на: розповзання scope, відсутні тести, крайні випадки,
зворотну сумісність, ризиковані зміни валідації.
Згрупуй знахідки як blocker / major / minor і вказуй file:line.
Файли не редагуй.
| Знахідка | Рівень | Рішення |
|---|---|---|
| для mobile UI немає regression-сценарію, але API contract-тест зелений | minor | follow-up #742; server-fix не блокує |
| ім'я тесту варто уточнити | minor | прийнято |
| додати логування в service | minor | відхилено, поза scope |
Відхилені зауваження фіксуються нарівні з прийнятими: Claude звучить однаково впевнено і коли має рацію, і коли пропонує вам роботу із сусіднього PR.
/diff відкриває інтерактивний перегляд uncommitted changes і правок за окремими turns, а готовий skill /code-review розбирає поточний diff на обраному рівні глибини. Інструмент допомагає помітити - рішення все одно за людиною.Фінальне рішення лишається за вами
Після двох годин у завданні ви бачите в diff не те, що написано, а те, що хотіли написати. Свіжа сесія цього багажу не має - вона дивиться на PR приблизно як майбутній reviewer, і дають їй тільки артефакти: план, опис PR, diff і список перевірок.
Fresh context звіряє обіцянки з evidence: ручний GET прогнано, mobile-контракт покрито тестом, перенесення consumer-кроку прив'язане до погодженого follow-up. Результат "blocker не знайдено" цінний лише разом із цими підставами.
# REVIEW_NOTES.md - issue #731
## Self-review
- diff збігається з переузгодженим планом, змінено два файли
## Claude-assisted
- minor: немає UI-level regression-тесту mobile; API contract зелений - follow-up #742
- minor: логування - відхилено, поза scope
## Fresh context
- команди і результати, які спостерігали, записано у Verification performed
- перенесення consumer-кроку підтверджено рішенням tech lead і follow-up #742
## Final decision
PR готовий до зовнішнього review. Mobile follow-up не блокує server-fix:
contract-тест зелений, форма відповіді не змінювалася, розбиття погоджено.
Розумних результатів кілька: PR готовий; потрібна ще одна невелика правка; PR краще розбити надвоє, бо в нього затесалася зайва ідея; review повертає в налагодження, бо причину описано слабко. Ненормальний тільки п'ятий - "нічого не зрозумів, але відправлю так".
Критерій фіналу: назвіть, що саме перевірить reviewer і на що подивиться першим. Не виходить - PR не готовий. А розділяти його треба не за розміром, а коли результати можна незалежно перевірити і відкотити.
І про обсяг: за сьогодні прозвучали чотири записи - причина, докази, опис PR, нотатки review. Це чотири факти, а не чотири файли: для правки на два файли вони цілком уміщаються в опис PR. Окремі документи з'являються, коли завдання довше за день або його підхоплює хтось інший.
/code-review ultra. Там на один diff паралельно дивляться кілька спеціалізованих агентів, а знахідки приїжджають одним звітом. Не для маленьких PR, не для термінових виправлень і не там, де код не можна вивантажувати назовні.