第 7 章: 実装中にアプリが変わったとき
計画は、特定のコミットの時点のアプリを前提に承認されます。しかし実際の開発はそこで止まらず、同僚がリファクタリングをマージして、計画が前提にしていた名前が消えることもあります。この章では参加登録の計画を実装しながら、途中でわざとそうした変更を起こします。そして、設計が合わなくなったまま作業を続けようとするエージェントを、Guren がどう止めるかを確認します。
第 7 章: 実装中にアプリが変わったとき
計画は、特定のコミットの時点のアプリを前提に承認されます。しかし実際の開発はそこで止まらず、同僚がリファクタリングをマージして、計画が前提にしていた名前が消えることもあります。この章では参加登録の計画を実装しながら、途中でわざとそうした変更を起こします。そして、設計が合わなくなったまま作業を続けようとするエージェントを、Guren がどう止めるかを確認します。
この章で学ぶこと:
- 保留 (
held) になったステップの意味と、エージェントだけでは解決できない理由 - 解決のための 2 つの選択肢 (変更を戻すか、計画を改訂するか)
- 計画の改訂が、検証済みのステップに与える影響
- 免除 (waiver) の役割
1. 実装を始める
Claude Code のセッションに、次のプロンプトを送ります。
docs/plans/registrations/plan.json を plan-implement スキルで実装してください。1 ステップにつき 1 コミットです。
最初の 3 ステップは第 4 章と同じように進みます。中でも data ステップのコミットは丁寧に読んでください。第 6 章で alter として計画した registrations のリレーションが、このステップで Meetup に加わります。
エージェントなしの場合:
bunx guren plan:next docs/plans/registrations/plan.json
bunx guren plan:scaffold docs/plans/registrations/plan.json --step task/entity/model.registration/scaffold
bunx guren plan:verify docs/plans/registrations/plan.json --step task/entity/model.registration/scaffold
git add -A
git commit -m "feat(registrations): scaffold [task/entity/model.registration/scaffold]"
bunx guren plan:next docs/plans/registrations/plan.json
bunx guren plan:scaffold docs/plans/registrations/plan.json --step task/entity/model.registration/tests
tests/plans/registrations/registrations.test.ts (セットアップ記入済み)
tests/plans/registrations/registrations.test.tsimport { beforeEach, describe, expect, test } from 'bun:test'
import { TestApp } from '@guren/testing'
import { resetDatabase } from '../../../config/database.js'
import { Meetup, type MeetupRecord } from '../../../app/Models/Meetup.js'
import { Registration } from '../../../app/Models/Registration.js'
import { User, type UserRecord } from '../../../app/Models/User.js'
let booted: Promise<TestApp> | undefined
function ready(): Promise<TestApp> {
booted ??= import('../../../src/app.js').then(({ default: app }) => TestApp.fromApp(app))
return booted
}
async function client(actor?: object): Promise<TestApp> {
const http = actor === undefined ? await ready() : (await ready()).actingAs(actor)
return http.withCsrf()
}
let ada: UserRecord
let grace: UserRecord
let meetup: MeetupRecord
async function seats(capacity: number) {
meetup = await Meetup.forceCreate({ title: 'Bun night', startsAt: '2026-10-20T19:00', capacity, organizerId: grace.id })
}
function register(user: UserRecord) {
return Registration.forceCreate({ meetupId: meetup.id, userId: user.id })
}
function count() {
return Registration.where({ meetupId: meetup.id }).count()
}
beforeEach(async () => {
await ready()
await resetDatabase()
ada = await User.create({ name: 'Ada', email: 'ada@example.com', password: 'correct horse battery' })
grace = await User.create({ name: 'Grace', email: 'grace@example.com', password: 'correct horse battery' })
})
describe('Registration', () => {
test('[AC-registrations-1] A signed-in user can register for a meetup with seats left.', async () => {
await seats(2)
await (await client(ada)).post(`/meetups/${meetup.id}/registrations`).assertStatus(303)
expect(await Registration.where({ meetupId: meetup.id, userId: ada.id }).first()).not.toBeNull()
})
test('[AC-registrations-2] Registering for a full meetup writes no row.', async () => {
await seats(1)
await register(grace)
await (await client(ada)).post(`/meetups/${meetup.id}/registrations`).assertStatus(303)
expect(await count()).toBe(1)
})
test('[AC-registrations-3] Registering twice writes no second row.', async () => {
await seats(5)
await register(ada)
await (await client(ada)).post(`/meetups/${meetup.id}/registrations`).assertStatus(303)
expect(await count()).toBe(1)
})
test('[AC-registrations-4] A guest cannot register.', async () => {
await seats(2)
await (await client()).post(`/meetups/${meetup.id}/registrations`).assertRedirect('/login')
})
test('[AC-registrations-5] A user cannot cancel someone else\'s registration.', async () => {
await seats(2)
const registration = await register(grace)
await (await client(ada)).delete(`/registrations/${registration.id}`).assertStatus(403)
expect(await count()).toBe(1)
})
test('[AC-registrations-6] A guest cannot cancel a registration.', async () => {
await seats(2)
const registration = await register(grace)
await (await client()).delete(`/registrations/${registration.id}`).assertRedirect('/login')
})
test('[AC-registrations-8] A user can cancel their own registration.', async () => {
await seats(2)
const registration = await register(ada)
await (await client(ada)).delete(`/registrations/${registration.id}`).assertStatus(303)
expect(await count()).toBe(0)
})
test('[AC-registrations-7] The meetup page shows the seats left.', async () => {
await seats(2)
await register(grace)
const response = await (await client()).get(`/meetups/${meetup.id}`).assertStatus(200)
await response.assertBodyContains('"seatsLeft":1')
})
})
bunx guren plan:verify docs/plans/registrations/plan.json --step task/entity/model.registration/tests
git add -A
git commit -m "test(registrations): acceptance tests [task/entity/model.registration/tests]"
bunx guren plan:next docs/plans/registrations/plan.json
app/Models/Meetup.ts (registrations のリレーションを追加)
app/Models/Meetup.tsimport { defineModel, type BelongsToRecord, type HasManyRecord } from '@guren/core'
import { meetups, registrations, users } from '../../db/schema.js'
export type MeetupRecord = typeof meetups.$inferSelect
export type NewMeetupRecord = typeof meetups.$inferInsert
type UserRecord = typeof users.$inferSelect
type RegistrationRecord = typeof registrations.$inferSelect
export class Meetup extends defineModel(meetups, {
fillable: ['title', 'startsAt', 'capacity'],
}) {
static override relationTypes: {
organizer: BelongsToRecord<UserRecord>
registrations: HasManyRecord<RegistrationRecord>
} = {
organizer: null,
registrations: [],
}
}
Meetup.belongsTo('organizer', () => import('./User.js').then((module) => module.User), 'organizerId', 'id')
Meetup.hasMany('registrations', () => import('./Registration.js').then((module) => module.Registration), 'meetupId', 'id')
bun run db:make create_registrations
bunx guren plan:verify docs/plans/registrations/plan.json --step task/entity/model.registration/data
git add -A
git commit -m "feat(registrations): migration [task/entity/model.registration/data]"
2. 同僚がコミットする
エージェントが作業している間に、同僚が勉強会ページのルート名を meetups.show から meetups.detail に変えた、という想定です。同僚の役になって、次のコミットを作ってください。
sed -i.bak "s/name: 'meetups.show'/name: 'meetups.detail'/" routes/meetups.ts
rm routes/meetups.ts.bak
bunx guren codegen
git add -A
git commit -m "refactor: rename the meetup page route"
アプリは問題なく動き、テストも通るので、何も壊れてはいません。ただし計画は meetups.show という名前でルートを指していて、AC-registrations-7 もそのルートにリクエストを送ります。
3. ステップが保留になる
bunx guren plan:next docs/plans/registrations/plan.json
No step can be returned: every step left is held, or waits on one that is.
Held, since what they depend on changed after the plan was approved:
task/entity/model.registration/http
route.meetups.show (routes, existing), named by AC-registrations-7: …
fail The route name "meetups.show" was not found in this application, …
第 3 章で説明した基準点 (baseline) の仕組みが、ここで働いています。Guren は承認時に、要素ごとにアプリ側の状態のハッシュを取っています。いま取り直すと、route.meetups.show のハッシュは承認時の状態とも、計画どおりに実装した後の状態とも一致しません。そのため、この要素に依存するステップは 保留 になり、そのステップを待つステップもすべて止まります。
エージェントにも同じ出力が届きます。ただし、どちらの直し方を選ぶかは設計上の判断なので、plan-implement スキルはエージェントに、自分で直さずに作業を止めて報告するよう指示しています。
| 選択肢 | 選ぶとき | すること |
|---|---|---|
| 変更を戻す | 同僚のコミットが間違っている | コミットを revert します。ステップの保留が解けます |
| 計画を改訂する | 変更は正しく、計画をそれに合わせるべき | アプリの現在の状態を指すように計画を直し、承認し直します |
今回の改名は妥当な変更なので、計画のほうを合わせます。
4. 改訂して承認し直す
Claude Code のセッションに、次のプロンプトを送ります。
同僚がルート meetups.show を meetups.detail に改名しました。docs/plans/registrations/plan.json を plan:revise で改訂し、route.meetups.show が meetups.detail を名指すようにしてください。id はそのままにします。
計画のほかの部分や各種の記録はこの id で要素を指しているので、要素の id route.meetups.show は変えず、名前だけを変えます。
エージェントなしの場合:
bunx guren plan:revise docs/plans/registrations/plan.json --ops - <<'EOF'
{
"ops": [
{"op": "modify", "section": "routes", "id": "route.meetups.show", "element": {"change": {"kind": "existing"}, "method": "GET", "path": "/meetups/:id", "name": "meetups.detail", "action": "action.meetups.show", "middleware": [], "bind": [{"param": "id", "model": "model.meetup"}]}, "reason": "A teammate renamed the route to meetups.detail."}
]
}
EOF
git add docs/plans
git commit -m "docs: follow the meetups.detail rename in the registrations plan"
bunx guren plan:approve docs/plans/registrations/plan.json
git add docs/plans
git commit -m "docs: approve the revised registrations plan"
基準点は第 6 章で記録したものから変わりません。承認し直すと新しいハッシュが記録されますが、計画を今日書いたものとして扱い直すわけではありません。
検証済みのステップ
検証の記録には、検証を実行したときの計画のハッシュが入っています。改訂でハッシュが変わったため、完了済みの 3 つのステップの記録は無効になりました。コードは変わっていないので、検証し直すだけで済みます。
bunx guren plan:verify docs/plans/registrations/plan.json --step task/entity/model.registration/scaffold
bunx guren plan:verify docs/plans/registrations/plan.json --step task/entity/model.registration/tests
bunx guren plan:verify docs/plans/registrations/plan.json --step task/entity/model.registration/data
エージェントに任せている場合は、作業の続きを指示すれば plan-implement スキルがこの再検証も行います。
5. 実装を終える
Claude Code のセッションに、次のプロンプトを送ります。
docs/plans/registrations/plan.json の実装を続けてください。
第 6 章で読んだ Impact は、http ステップで役に立ちます。MeetupResource が登録数を必要とするようになり、この Resource は index、show、edit のすべてで組み立てられています。
エージェントなしの場合:
bunx guren plan:next docs/plans/registrations/plan.json
bunx guren plan:scaffold docs/plans/registrations/plan.json --step task/entity/model.registration/http --mount
RegistrationController、RegistrationPolicy、MeetupResource、MeetupController、勉強会のページ
app/Http/Controllers/RegistrationController.tsimport { Controller } from '@guren/core'
import { Meetup } from '../../Models/Meetup.js'
import { Registration } from '../../Models/Registration.js'
import type { UserRecord } from '../../Models/User.js'
export default class RegistrationController extends Controller {
async store(): Promise<Response> {
const meetup = this.model(Meetup)
const user = await this.auth.userOrFail<UserRecord>()
const already = await Registration.where({ meetupId: meetup.id, userId: user.id }).first()
const taken = await Registration.where({ meetupId: meetup.id }).count()
if (!already && taken < meetup.capacity) {
await Registration.forceCreate({ meetupId: meetup.id, userId: user.id })
}
return this.redirect(`/meetups/${meetup.id}`)
}
async destroy(): Promise<Response> {
const registration = this.model(Registration)
await this.authorize('delete', [Registration, registration])
await Registration.delete({ id: registration.id })
return this.redirect(`/meetups/${registration.meetupId}`)
}
}
app/Policies/RegistrationPolicy.tsimport { Policy, type AuthUser } from '@guren/core'
import type { RegistrationRecord } from '../Models/Registration.js'
export class RegistrationPolicy extends Policy {
delete(user: AuthUser | null, registration: RegistrationRecord): boolean {
return user !== null && user.id === registration.userId
}
}
app/Http/Resources/MeetupResource.tsimport { Resource } from '@guren/core'
import type { MeetupRecord } from '../../Models/Meetup.js'
export interface MeetupResourceData extends Record<string, unknown> {
id: number
title: string
startsAt: string
capacity: number
seatsLeft: number
}
export class MeetupResource extends Resource<MeetupRecord & { registrationsCount: number }, MeetupResourceData> {
toArray(): MeetupResourceData {
return {
id: this.resource.id,
title: this.resource.title,
startsAt: this.resource.startsAt,
capacity: this.resource.capacity,
seatsLeft: this.resource.capacity - this.resource.registrationsCount,
}
}
}
app/Http/Controllers/MeetupController.tsimport { Controller } from '@guren/core'
import { pages } from '@/.guren/pages.gen'
import { MeetupPayloadSchema } from '../Validators/MeetupValidator.js'
import { MeetupResource } from '../Resources/MeetupResource.js'
import { Meetup } from '../../Models/Meetup.js'
import { Registration } from '../../Models/Registration.js'
import type { UserRecord } from '../../Models/User.js'
async function withSeats(id: number) {
const [meetup] = await Meetup.withCount('registrations', { id })
return new MeetupResource(meetup!).toArray()
}
export default class MeetupController extends Controller {
async index(): Promise<Response> {
const meetups = await Meetup.withCount('registrations')
meetups.sort((a, b) => a.startsAt.localeCompare(b.startsAt))
return this.inertia(pages.meetups.Index, { meetups: meetups.map((meetup) => new MeetupResource(meetup).toArray()) })
}
async show(): Promise<Response> {
const meetup = this.model(Meetup)
const user = await this.auth.user<UserRecord | null>()
const registration = user ? await Registration.where({ meetupId: meetup.id, userId: user.id }).first() : null
return this.inertia(pages.meetups.Show, { meetup: await withSeats(meetup.id), registrationId: registration?.id ?? null })
}
async create(): Promise<Response> {
return this.inertia(pages.meetups.Create, {})
}
async store(): Promise<Response> {
const data = await this.validateBody(MeetupPayloadSchema)
const user = await this.auth.userOrFail<UserRecord>()
const meetup = await Meetup.forceCreate({ ...data, organizerId: user.id })
return this.redirect(`/meetups/${meetup.id}`)
}
async edit(): Promise<Response> {
const meetup = this.model(Meetup)
await this.authorize('update', [Meetup, meetup])
return this.inertia(pages.meetups.Edit, { meetup: await withSeats(meetup.id) })
}
async update(): Promise<Response> {
const meetup = this.model(Meetup)
await this.authorize('update', [Meetup, meetup])
const data = await this.validateBody(MeetupPayloadSchema)
await Meetup.update({ id: meetup.id }, data)
return this.redirect(`/meetups/${meetup.id}`)
}
}
resources/js/pages/meetups/Show.tsximport { Head, Link, router } from '@inertiajs/react'
import Layout from '../../components/Layout.js'
import type { MeetupResourceData } from '@/app/Http/Resources/MeetupResource'
interface Props {
meetup: MeetupResourceData
registrationId: number | null
}
export default function Show({ meetup, registrationId }: Props) {
return (
<Layout>
<Head title={meetup.title} />
<h1 className="text-3xl font-bold text-g-heading">{meetup.title}</h1>
<p className="mt-2 text-g-text-2">{`${meetup.startsAt} · ${meetup.seatsLeft} of ${meetup.capacity} seats left`}</p>
<div className="mt-6 flex gap-4">
{registrationId !== null ? (
<button type="button" className="text-g-danger" onClick={() => router.delete(`/registrations/${registrationId}`)}>Cancel</button>
) : meetup.seatsLeft > 0 ? (
<button type="button" className="rounded-g-ctl bg-g-accent px-4 py-2 font-bold text-white" onClick={() => router.post(`/meetups/${meetup.id}/registrations`)}>Register</button>
) : (
<p className="text-g-text-2">The meetup is full.</p>
)}
<Link href={`/meetups/${meetup.id}/edit`} className="text-sm text-g-accent">Edit</Link>
</div>
</Layout>
)
}
bunx guren plan:verify docs/plans/registrations/plan.json --step task/entity/model.registration/http
git add -A
git commit -m "feat(registrations): register and cancel [task/entity/model.registration/http]"
bunx guren plan:next docs/plans/registrations/plan.json
bunx guren plan:verify docs/plans/registrations/plan.json --step task/entity/model.registration/pages
bunx guren plan:next docs/plans/registrations/plan.json
bunx guren plan:verify docs/plans/registrations/plan.json --step task/entity/model.meetup/http
最後のステップ task/entity/model.meetup/http は、承認時に警告が出た alter、つまり MeetupController.show のステップです。このステップではコードを追加せず、AC-registrations-7 がこのアクションに到達することで確認が済みます。
http ステップのコミットは第 4 章のチェック表で確認し、この計画ではさらに次の 2 行を加えます。
| 確かめること | 理由 |
|---|---|
store が登録を書く前に登録数を数えている |
「満席なら行を書かない」というルールを確かめるテストは、AC-registrations-2 しかありません |
MeetupResource を組み立てるアクションすべてが登録数を渡している |
Impact に index、show、edit が挙がっていました |
6. クローズする
bunx guren plan:next docs/plans/registrations/plan.json
bunx guren plan:close docs/plans/registrations/plan.json
git add -A
git commit -m "docs: close the registrations plan"

docs/entities/Registration.md が新しく作られます。docs/entities/Meetup.md にはこの計画の履歴が 1 行追加され、1 本目の計画で書かれたブロックはそのまま残ります。
未完成のまま受け入れる: 免除
計画では通知を送ることにしていたものの、リリースを来月に回すと決めた場合のように、要素をあえて仕上げないこともあります。そのままでは plan:close が拒否するので、計画を書き換えるのではなく、その判断を記録します。
bunx guren plan:waive docs/plans/<slug>/plan.json <element-id> --reason "Ships with the mail plan next month"
免除の記録は計画と同じディレクトリの decisions.json に書き込まれ、コミットされます。記録には計画のハッシュが入っているので、後の改訂版には引き継がれません。また plan:close は免除ごとに make:adr のコマンドを表示するので、判断はアーキテクチャの決定を記録する場所にも残ります。この講座では免除を使いませんでしたが、免除も承認と同じく人が下す判断です。
ここまでの状態
- 参加登録を実装して検証を通し、計画をクローズしてドキュメントに残しました。
- 保留になったステップを計画の改訂で解決し、その理由は
revisions/0002.jsonに残っています。
よくあるつまずき
- エージェントがルート名を元に戻して保留を「直す」。 これでは同僚の作業を断りなく取り消すことになります。どちらの選択肢を選んだかを、エージェントに伝えてください。
.guren/*.gen.tsが変わったのでplan:nextが拒否する。 codegen を実行しないままルートを変更したコミットがあります。bunx guren codegenを実行し、生成ファイルをコミットしてください。
演習
- ブランチを切って、計画を改訂する代わりに同僚のコミットを revert し、
plan:nextを実行してください。ステップはまだ保留のままですか。 docs/plans/registrations/revisions/0002.jsonを読んでください。1 年後にレビューする人が、計画がmeetups.detailを指している理由を知りたいとき、どのフィールドを見ればよいでしょうか。
演習 1: ヒントと答えの例
計画を改訂する前、3 節の時点で試してください。要素が依存しているもののハッシュが、承認時の記録とも、計画どおりに実装した後の状態とも一致しないあいだ、ステップは保留になります。
保留は解けます。git revert でルート名が meetups.show に戻り、route.meetups.show のハッシュが第 6 章の承認時の記録と一致するので、plan:next は http ステップを返します。3 節の表の「変更を戻す」にあたります。plan:next が印を書く .guren/plans/ は git の管理外なので、元のブランチに戻っても印はそのまま残ります。元のブランチでは、3 節と同じようにステップがまた保留になります。
演習 2: ヒントと答えの例
改訂の記録には、変更前と変更後の計画のハッシュである parent と result、変更内容の ops が入っています。ops の各操作には、その変更をした理由が付いています。
見るのは、route.meetups.show を変更する操作の reason です。Without an agent のブロックで進めた場合は「A teammate renamed the route to meetups.detail.」と書かれていて、この操作は revisions/0002.json にあります。0001.json は、第 6 章で Q-full に答えた最初の改訂です。エージェントで進めた場合、reason にはエージェントが plan:revise に渡したメッセージが入り、番号も変わることがあります。route.meetups.show を変更している記録を探してください。
次へ
第 8 章: CI の中の計画 では、ここまで手で実行してきた検査を、プルリクエストのたびに実行するようにします。