第 4 章: 1 ステップずつ
承認した計画を、この章でエージェントに実装してもらいます。Guren が計画をステップに分け、エージェントはステップを 1 つずつ実装して、検証が通ったらコミットします。読者は、次のコミットが届く前にそれぞれのコミットを読んでいきます。
第 4 章: 1 ステップずつ
承認した計画を、この章でエージェントに実装してもらいます。Guren が計画をステップに分け、エージェントはステップを 1 つずつ実装して、検証が通ったらコミットします。読者は、次のコミットが届く前にそれぞれのコミットを読んでいきます。
この章で学ぶこと:
- 計画がステップに分かれる仕組みと、ステップの種類ごとに書かれるもの
- 検証済み (
verified) の意味と、その判定を下す仕組み - ステップごとのコミットで確かめること
1. ステップ
ステップは人が書くものではなく、Guren が計画から自動で組み立てます。この計画はエンティティが 1 つなので、5 つのステップからなるタスクが 1 つできます。
flowchart LR S["scaffold<br/>生成されるコード"] --> T["tests<br/>振る舞いごとの失敗するテスト"] --> D["data<br/>マイグレーション"] --> H["http<br/>アクション、policy、ページ"] --> P["pages<br/>typecheck、check"]
どのステップも、次の流れを繰り返します。
flowchart LR Next["plan:next<br/>ステップに印を付ける"] --> Work["実装<br/>そのステップだけ"] --> Verify["plan:verify<br/>コマンドを実行"] --> Commit["コミット"] Commit --> Next
plan:verify を実行すると、そのステップのコマンド (codegen、typecheck、guren check、マイグレーション、テスト) が走り、結果が .guren/plans/ に記録されます。このディレクトリは git の管理対象外です。コマンドがすべて通り、ステップが受け持つ計画の要素がコード上で実装済みと読み取れれば、そのステップは 検証済み になります。判定にエージェントの意見は使われません。
2. エージェントを動かす
Claude Code のセッションに、次のプロンプトを送ります。
docs/plans/meetups/plan.json を plan-implement スキルで実装してください。1 ステップにつき 1 コミットです。plan:next がすべてのステップが verified だと言ったら止まってください。
ここから先は、エージェントがこの流れを自分で繰り返します。途中でターンを終えようとすると、第 1 章の Stop hook が印の付いたステップを検証し、通らなければエージェントに差し戻します。
エージェントが作業している間は、手元のターミナルで進み具合を追いかけます。
git log --oneline
コミットメッセージには、角括弧で囲んだステップ名が入ります。コミットが届いたら、4 節のチェック表を見ながら順に読んでください。
3. ステップを 1 つずつ進める
この節では、各ステップで何ができるかを見ていきます。エージェントに任せている場合は読むだけで構いません。任せていない場合は、各ブロックを順に実行してください。
scaffold
bunx guren plan:next docs/plans/meetups/plan.json
bunx guren plan:scaffold docs/plans/meetups/plan.json --step task/entity/model.meetup/scaffold
bunx guren plan:verify docs/plans/meetups/plan.json --step task/entity/model.meetup/scaffold
git add -A
git commit -m "feat(meetups): scaffold [task/entity/model.meetup/scaffold]"
plan:scaffold は、計画の内容だけで決まるコードを書き出します。db/schema.ts の meetups テーブル、Meetup モデル、validator、Resource、すべてを拒否する Policy、どのアクションも 501 を返すコントローラー、それにまだマウントしていない routes/meetups.ts です。これらのファイルは、エージェントも含めて誰も手では書きません。
tests
bunx guren plan:next docs/plans/meetups/plan.json
bunx guren plan:scaffold docs/plans/meetups/plan.json --step task/entity/model.meetup/tests
このコマンドで tests/plans/meetups/meetups.test.ts ができます。振る舞いごとに 1 つのテストがあり、タイトルは [AC-meetups-1] … の形で、リクエストもすでに書かれています。ただし、振る舞いが前提とするデータベースの行やサインイン済みのユーザーといったセットアップは生成できません。各テストは、必要なセットアップを示す given('…') の呼び出しで止まっているので、そこを埋めていくのがこのステップの作業です。
セットアップを書いた tests/plans/meetups/meetups.test.ts
tests/plans/meetups/meetups.test.tsimport { beforeEach, describe, expect, test } from 'bun:test'
import { TestApp } from '@guren/testing'
import { resetDatabase } from '../../../config/database.js'
import { Meetup } from '../../../app/Models/Meetup.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
function meetupBy(organizer: UserRecord) {
return Meetup.forceCreate({ title: 'Bun night', startsAt: '2026-10-20T19:00', capacity: 20, organizerId: organizer.id })
}
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('Meetup', () => {
test('[AC-meetups-1] A signed-in user can organize a meetup.', async () => {
await (await client(ada)).post('/meetups', { title: 'Bun night', startsAt: '2026-10-20T19:00', capacity: 20 }).assertStatus(303)
const meetup = await Meetup.where({ title: 'Bun night' }).first()
expect(meetup?.organizerId).toBe(ada.id)
})
test('[AC-meetups-2] A meetup needs at least one seat.', async () => {
const response = await (await client(ada)).post('/meetups', { title: 'Bun night', startsAt: '2026-10-20T19:00', capacity: 0 }).assertStatus(422)
const body = await response.json<{ errors?: Record<string, unknown> }>()
expect(Object.keys(body.errors ?? {})).toEqual(expect.arrayContaining(['capacity']))
})
test('[AC-meetups-3] A guest cannot organize a meetup.', async () => {
await (await client()).post('/meetups').assertRedirect('/login')
})
test('[AC-meetups-4] A user cannot edit someone else\'s meetup.', async () => {
const meetup = await meetupBy(grace)
await (await client(ada)).put(`/meetups/${meetup.id}`, { title: 'Taken over', startsAt: '2026-10-20T19:00', capacity: 20 }).assertStatus(403)
})
test('[AC-meetups-5] Anyone can see the list of meetups.', async () => {
await meetupBy(grace)
const response = await (await client()).get('/meetups').assertStatus(200)
await response.assertBodyContains('Bun night')
})
test('[AC-meetups-6] A guest cannot open the edit form.', async () => {
const meetup = await meetupBy(grace)
await (await client()).get(`/meetups/${meetup.id}/edit`).assertRedirect('/login')
})
test('[AC-meetups-7] The organizer can edit their meetup.', async () => {
const meetup = await meetupBy(ada)
await (await client(ada)).put(`/meetups/${meetup.id}`, { title: 'Bun night 2', startsAt: '2026-10-20T19:00', capacity: 30 }).assertStatus(303)
expect(await Meetup.where({ title: 'Bun night 2' }).first()).not.toBeNull()
})
test('[AC-meetups-8] A guest cannot open the form for a new meetup.', async () => {
await (await client()).get('/meetups/create').assertRedirect('/login')
})
test('[AC-meetups-9] A user cannot open the edit form of someone else\'s meetup.', async () => {
const meetup = await meetupBy(grace)
await (await client(ada)).get(`/meetups/${meetup.id}/edit`).assertStatus(403)
})
test('[AC-meetups-10] An edit needs at least one seat.', async () => {
const meetup = await meetupBy(ada)
const response = await (await client(ada)).put(`/meetups/${meetup.id}`, { title: 'Bun night', startsAt: '2026-10-20T19:00', capacity: 0 }).assertStatus(422)
const body = await response.json<{ errors?: Record<string, unknown> }>()
expect(Object.keys(body.errors ?? {})).toEqual(expect.arrayContaining(['capacity']))
})
test('[AC-meetups-11] A guest cannot edit a meetup.', async () => {
const meetup = await meetupBy(grace)
await (await client()).put(`/meetups/${meetup.id}`).assertRedirect('/login')
})
test('[AC-meetups-12] The organizer can open the edit form.', async () => {
const meetup = await meetupBy(ada)
await (await client(ada)).get(`/meetups/${meetup.id}/edit`).assertStatus(200)
})
})
bunx guren plan:verify docs/plans/meetups/plan.json --step task/entity/model.meetup/tests
git add -A
git commit -m "test(meetups): acceptance tests [task/entity/model.meetup/tests]"
このステップは、すべてのテストが 失敗する ことを確かめて検証済みになります。コードを書く前から通ってしまうテストでは、何も確かめられないからです。
data
bunx guren plan:next docs/plans/meetups/plan.json
bun run db:make create_meetups
bunx guren plan:verify docs/plans/meetups/plan.json --step task/entity/model.meetup/data
git add -A
git commit -m "feat(meetups): migration [task/entity/model.meetup/data]"
db:make で scaffold ステップが書いたテーブルからマイグレーションを生成し、plan:verify の中でそれが適用されます。
http
ルートファイルは、手で書き足さずにコマンドでマウントします。
bunx guren plan:next docs/plans/meetups/plan.json
bunx guren plan:scaffold docs/plans/meetups/plan.json --step task/entity/model.meetup/http --mount
続いて、スタブを実際のコードに置き換えます。コードを書く量は、このステップがいちばん多くなります。
app/Http/Controllers/MeetupController.ts
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 type { UserRecord } from '../../Models/User.js'
export default class MeetupController extends Controller {
async index(): Promise<Response> {
const meetups = await Meetup.orderBy([['startsAt', 'asc']])
return this.inertia(pages.meetups.Index, { meetups: meetups.map((meetup) => new MeetupResource(meetup).toArray()) })
}
async show(): Promise<Response> {
const meetup = this.model(Meetup)
return this.inertia(pages.meetups.Show, { meetup: new MeetupResource(meetup).toArray() })
}
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: new MeetupResource(meetup).toArray() })
}
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}`)
}
}
app/Policies/MeetupPolicy.ts
app/Policies/MeetupPolicy.tsimport { Policy, type AuthUser } from '@guren/core'
import type { MeetupRecord } from '../Models/Meetup.js'
export class MeetupPolicy extends Policy {
update(user: AuthUser | null, meetup: MeetupRecord): boolean {
return user !== null && user.id === meetup.organizerId
}
}
4 つのページと、共有するフォーム
resources/js/components/MeetupForm.tsximport { useForm } from '@inertiajs/react'
export interface MeetupFormData {
title: string
startsAt: string
capacity: number
}
interface Props {
initial: MeetupFormData
submit: (form: ReturnType<typeof useForm<MeetupFormData>>) => void
label: string
}
const inputClass = 'mt-1 w-full rounded-g-ctl border border-g-line-strong bg-g-panel px-3 py-2 text-g-text'
export default function MeetupForm({ initial, submit, label }: Props) {
const form = useForm<MeetupFormData>(initial)
return (
<form
className="mt-6 space-y-4"
onSubmit={(event) => {
event.preventDefault()
submit(form)
}}
>
<label className="block text-sm font-bold text-g-heading">
Title
<input className={inputClass} value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} />
</label>
{form.errors.title && <p className="text-sm text-g-danger">{form.errors.title}</p>}
<label className="block text-sm font-bold text-g-heading">
Starts at
<input type="datetime-local" className={inputClass} value={form.data.startsAt} onChange={(event) => form.setData('startsAt', event.target.value)} />
</label>
{form.errors.startsAt && <p className="text-sm text-g-danger">{form.errors.startsAt}</p>}
<label className="block text-sm font-bold text-g-heading">
Capacity
<input type="number" className={inputClass} value={form.data.capacity} onChange={(event) => form.setData('capacity', Number(event.target.value))} />
</label>
{form.errors.capacity && <p className="text-sm text-g-danger">{form.errors.capacity}</p>}
<button type="submit" disabled={form.processing} className="rounded-g-ctl bg-g-accent px-4 py-2 font-bold text-white">
{label}
</button>
</form>
)
}
resources/js/pages/meetups/Index.tsximport { Head, Link } from '@inertiajs/react'
import Layout from '../../components/Layout.js'
import type { MeetupResourceData } from '@/app/Http/Resources/MeetupResource'
interface Props {
meetups: MeetupResourceData[]
}
export default function Index({ meetups }: Props) {
return (
<Layout>
<Head title="Meetups" />
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold text-g-heading">Meetups</h1>
<Link href="/meetups/create" className="text-sm text-g-accent">New meetup</Link>
</div>
{meetups.length === 0 ? (
<p className="mt-6 text-g-text-2">No meetups yet.</p>
) : (
<ul className="mt-6 divide-y divide-g-line">
{meetups.map((meetup) => (
<li key={meetup.id} className="py-3">
<Link href={`/meetups/${meetup.id}`} className="font-bold text-g-heading">{meetup.title}</Link>
<p className="text-sm text-g-text-2">{meetup.startsAt} · {meetup.capacity} seats</p>
</li>
))}
</ul>
)}
</Layout>
)
}
resources/js/pages/meetups/Show.tsximport { Head, Link } from '@inertiajs/react'
import Layout from '../../components/Layout.js'
import type { MeetupResourceData } from '@/app/Http/Resources/MeetupResource'
interface Props {
meetup: MeetupResourceData
}
export default function Show({ meetup }: 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.capacity} seats</p>
<Link href={`/meetups/${meetup.id}/edit`} className="mt-6 inline-block text-sm text-g-accent">Edit</Link>
</Layout>
)
}
resources/js/pages/meetups/Create.tsximport { Head } from '@inertiajs/react'
import Layout from '../../components/Layout.js'
import MeetupForm from '../../components/MeetupForm.js'
export default function Create() {
return (
<Layout>
<Head title="New meetup" />
<h1 className="text-3xl font-bold text-g-heading">New meetup</h1>
<MeetupForm
initial={{ title: '', startsAt: '', capacity: 20 }}
submit={(form) => form.post('/meetups')}
label="Create"
/>
</Layout>
)
}
resources/js/pages/meetups/Edit.tsximport { Head } from '@inertiajs/react'
import Layout from '../../components/Layout.js'
import MeetupForm from '../../components/MeetupForm.js'
import type { MeetupResourceData } from '@/app/Http/Resources/MeetupResource'
interface Props {
meetup: MeetupResourceData
}
export default function Edit({ meetup }: Props) {
return (
<Layout>
<Head title={`Edit ${meetup.title}`} />
<h1 className="text-3xl font-bold text-g-heading">Edit meetup</h1>
<MeetupForm
initial={{ title: meetup.title, startsAt: meetup.startsAt, capacity: meetup.capacity }}
submit={(form) => form.put(`/meetups/${meetup.id}`)}
label="Save"
/>
</Layout>
)
}
bunx guren plan:verify docs/plans/meetups/plan.json --step task/entity/model.meetup/http
git add -A
git commit -m "feat(meetups): controller, policy, pages [task/entity/model.meetup/http]"
このステップは、すべての振る舞いのテストが 通る と検証済みになります。
pages
コントローラーは存在しないページを描画できないので、ページは http ステップの時点ですでに必要でした。このステップでは、ページを独立したステップとして typecheck と guren check で確かめます。
bunx guren plan:next docs/plans/meetups/plan.json
bunx guren plan:verify docs/plans/meetups/plan.json --step task/entity/model.meetup/pages
4. コミットを 1 つずつ読む
検証済みになっても、わかるのはテストが通ったことだけです。テストが確かめるべきことを確かめているか、コードが計画にあることだけをしているかは、読者がコミットを読んで判断します。1 ステップを 1 コミットにしているので、一度に読む量は少なくて済みます。
git show --stat HEAD
| ステップ | 確かめること |
|---|---|
| scaffold | 生成されたファイルだけが入っていて、エージェントが手で書き換えた箇所がない |
| tests | どのタイトルにも [AC-…] の id が残っている。セットアップが、振る舞いの given に書かれたものだけを作っている |
| data | マイグレーションが meetups を作るだけで、ほかのテーブルに触れていない |
| http | this.authorize('update', [Meetup, meetup]) にクラスだけでなく レコード も渡している。organizerId をリクエストからではなく this.auth から取っている。Policy が id を比較している |
| pages | 通常は特にない。コマンドの結果で確認できる |
表の中では、http の行を特に注意して読んでください。scaffold が書く Policy の update は、誰かがルールを書くまで false を返します。エージェントがコントローラーだけを書いて Policy を書き忘れると、誰も編集できない機能ができあがります。全員を拒否すれば拒否すべきユーザーも拒否されるので、この状態でも forbidden のテストはすべて通ります。主催者自身の success の振る舞いである AC-meetups-7 と AC-meetups-12 の 2 つだけが失敗します。第 2 章のチェック表の 5 行目で確かめた点が、ここで役に立ちます。
5. 計画の現在地
bunx guren plan:next docs/plans/meetups/plan.json
「Every step is verified.」と表示されます。要素ごとの状態は次のコマンドで確認できます。
bunx guren plan:status docs/plans/meetups/plan.json
計画で追加した要素は、どれも verified になっています。計画どおりの形でコードの中に見つかり、検証結果がまだ有効なステップに含まれている、という意味です。計画から参照しているだけの User と users.id は、コードに存在することを示す present です。要素の検証に使ったファイルを変更すると、その要素は検証後に変更された (drifted) 状態になり、ステップを検証し直すまで戻りません。
アプリを起動して、できあがったものを見てみましょう。
bun run dev
/register でサインアップしてから、/meetups を開いてください。

# Press Ctrl-C in the terminal running bun run dev.
ここまでの状態
- 5 つのステップがすべて検証済みになり、ファイルを書いた 4 つのステップはそれぞれコミットされています。
- コードを手で打たずに勉強会の機能ができ、仕様を決めた 12 件のテストもすべて通っています。
- すべてのステップが検証済みになり、計画はクローズできる状態です。
よくあるつまずき
- すべてのテストが "database has not been configured" で失敗する。 読者が書き足した
beforeEachが、アプリの起動前にデータベースに触れています。雛形の冒頭のコメントと参照用のテストにあるとおり、最初にawait ready()を呼んでください。 - 作業ツリーに未コミットの変更があり、
plan:nextが拒否する。 前のステップがコミットされていません。次のステップを頼む前に、コミットするか破棄してください。 - http ステップが
AC-meetups-7かAC-meetups-12の 403 で失敗する。 Policy がまだfalseを返しているか、コントローラーがthis.authorizeに[Meetup, meetup]ではなくMeetupだけを渡しています。上のチェック表の http の行を見てください。 - エージェントが同じステップを繰り返す。 ターンを終えようとして 3 回差し戻されると、Stop hook はそのステップを理由とともに行き詰まり (
stalled) として記録し、エージェントが止まれるようにします。行き詰まりはplan:nextに表示されるので、続けるよう指示する前に理由を読んでください。
演習
- ブランチを切って Policy を
return trueに書き換え、http ステップのplan:verifyを実行してください。どの振る舞いが失敗しますか。確かめたら、コミットせずに元に戻します。 bunx guren plan:status docs/plans/meetups/plan.json --jsonを実行し、policy.meetupの項目を探してください。filesには何が並んでいますか。また、そのファイルを変更するとdriftedになるのはなぜでしょうか。
演習 1: ヒントと答えの例
ゲストは Policy が動く前に auth ミドルウェアでリダイレクトされます。主催者はどちらの Policy でも通ります。Policy の中身で結果が変わるのは、サインインしていても主催者ではないユーザーを拒否するべき振る舞いだけです。
失敗するのは 2 つです。AC-meetups-4 (他人の勉強会への PUT に、期待した 403 の代わりに 303 が返る) と、AC-meetups-9 (他人の勉強会の編集フォームに、403 の代わりに 200 が返る) です。残りの 10 個は通ります。scaffold が書く return false とちょうど逆の関係です。全員を通す Policy は forbidden の振る舞いが見つけ、誰も通さない Policy は主催者の success の振る舞いが見つけます。
元に戻すときは、まず git restore app/Policies/MeetupPolicy.ts で変更を捨ててください。コミットしていない変更は git switch で切り替えた先にもついてきます。そのあと元のブランチに戻ります。失敗した実行で、.guren/plans/ にある http ステップの記録が上書きされています。このディレクトリは git の管理外なので、元のブランチで bunx guren plan:verify docs/plans/meetups/plan.json --step task/entity/model.meetup/http をもう一度実行してください。実行しないと、第 5 章の plan:close が拒否されます。
演習 2: ヒントと答えの例
files には、その要素が見つかったファイルが並びます。要素を担当するステップが検証されると、plan:verify はこれらのファイルのハッシュを .guren/plans/ に記録します。
policy.meetup の files には、Policy 自身のファイル app/Policies/MeetupPolicy.ts が並びます。Policy を担当するのは http ステップで、検証の時点でこのファイルのハッシュを記録しています。ファイルを変更するとハッシュが一致しなくなり、「検証済み」という結果は、もう存在しないコードについての結果になります。そのため、http ステップを plan:verify でもう一度検証するまで、この要素は drifted と表示されます。
次へ
第 5 章: 計画をクローズする では、完了した計画を、コードと一緒に残るドキュメントに書き出します。