This is the English version of an article I originally published in Japanese on Zenn.
Since I started handing implementation work to Claude Code, I've been getting through bug fixes and features at a volume I would previously have left untouched. What grew alongside that was rework. Something I fixed comes back in the next change; a place I thought was unrelated breaks. The more I could build, the more I could break.
Tests are what protect you. Except those tests are also written by Claude Code. They run green, I skim the diff and let them through, and the count goes up. Then, the next time I touch the code, my only way of learning what is currently protected is to read tests/ from the top.
Telling the AI "check that you haven't broken anything" on every change doesn't work either. Nothing has decided what should be protected, so it looks only as far as whatever happened to be in view.
What I do now is swap out the document that drives development depending on the stage the repository is in, and run the second half off a single file I call the guarantee ledger. The startup phase moves fast on a spec; once I draw a line, it hands over to the ledger. I call the second half Guarantee-Driven Development (GDD).
Fixing the spec up front is harder when you build with AI
When you're starting something up with an AI, you can't nail down the spec first. What's needed gets decided by touching something that runs, and the structure you settled on changes within days. Writing a detailed spec here means everything you wrote becomes a discard. The startup phase suits building fluidly and fast.
At the same time, you can't keep going like that forever. The moment you ship and people start using it, the range you're allowed to break narrows sharply. Keep throwing work at an AI without deciding what to protect, and you lose track of the balance between what you fixed and what you broke.
So I split development into two phases and swap the driving document between them.
| Phase | Driving document | How it runs |
|---|---|---|
| MVP phase |
PLAN.md / JUDGE.md
|
Spec-Driven Development (SDD). Build fluidly and fast |
| Issue-driven phase | docs/guarantees.md |
Guarantee-Driven Development (GDD). Approve the promises, then implement |
The phase is declared in each repository's CLAUDE.md. I apply it only to repositories under ~/github-public/, and if there's no declaration, it's treated as the issue-driven phase. That default exists so the judgment isn't left to whatever mood I'm in that day.
This lifecycle, and the skills that run it, are published along with my dotfiles.
yktsnet
/
dotfiles-public
A two-phase development lifecycle for AI-agent collaboration: spec-driven bootstrap, guarantee-driven maintenance — on Nix.
Two-Phase Development Lifecycle for AI-Agent Collaboration
AI エージェントとの開発では、ボトルネックは生成から検証と意図伝達に移る。 本リポジトリは開発を2フェーズに分け、立ち上げ期は仕様書(PLAN.md / JUDGE.md)、保守期は保証台帳(guarantees.md)とテストという異なる駆動文書で回す。 このライフサイクルを支える実行環境(Nix・ロール分離・skill 群)ごと、コードとして公開する。
Development Lifecycle(2つの駆動文書)
開発文書には寿命がある。単一の仕様書を永続させようとせず、フェーズごとに駆動文書を交代させる。フェーズは各リポジトリの CLAUDE.md で宣言する。
MVP期: Spec-Driven Development (SDD)
方向性と構造が固まっていない立ち上げ期は、PLAN.md(仕様・計画・作業記録)と JUDGE.md(実装中の判断記録)が開発を駆動する。エージェントに両ファイルを更新させながら実装を進め、リリース時に README へ昇華して役目を終える。仕様書はこのフェーズ限りの足場であり、永続を求めない。
Issueドリブン期: Guarantee-Driven Development (GDD)
リリース後は、仕様書を書くほどではない修正が積み重なり、最初の仕様書は実装から乖離していく。そこで駆動文書を保証台帳(docs/guarantees.md)へ交代させる。台帳は「何を約束し、何を約束していないか」だけを記し、各約束は対応するテストが継続検証する。README と違い、破れば落ちるため黙って腐れない。
保証の宣言(何が成り立つべきか)は user が Issue の保証節で裁可し、テストコードの実装はエージェントが書く。人間の仕事はテストを書くことから約束を裁可することへ移る。詳細は test-policy.md を参照。
Role Separation(ロールの分離)
上記2ワークフローの実行機構。人間、対話型AI、自律型AIエージェントの担当範囲を厳格に定義し、エージェントの編集がレビューを経ないままメインブランチや本番に及ばないようにする。
- WebChat(設計・対話型AI): ユーザーと対話しながら、MVP期は仕様策定と設計ファイルの作成を、Issueドリブン期は調査と Issue 設計を行う。実装はしない。
-
AI Agent(実装・自律型AI):
Issue ファイルをインプットとしてコード編集・テスト実装・静的エラー確認・ローカルコミットまでを自律実行し、リモートには触れない。手順は pr-workflow に固定してある。
rebuild等の破壊的コマンドや機密へのアクセスは.claude/settings.jsonの deny で遮断し、前方一致では判定できないもの(パス付き実行のパッケージ導入、生成物への直接編集)は.claude/hooks/の PreToolUse フックが受け持つ。 -
User(裁可・検証・人間):
Issue の保証節を裁可し、エージェントのコミットをローカルでレビュー・動作確認し、
issue-finishで公開(push・PR作成・マージ)を実行する。レビューを通った変更だけがリモートに残る。
ロール間の受け渡しは Zsh マクロで行う:
-
issue: 対象 Issue を選択し、worktree を隔離作成してエージェントを起動。main を汚さず複数 Issue を並列実行できる。 -
issue-abort: 進行中の worktree を作業ブランチごと破棄。 -
issue-finish: レビュー済みブランチの push → PR 作成 → マージ → 後片付けを一括実行。
分離を硬直させないための例外も定義している。障害対応などのリアルタイム ops、user が明示宣言する単発例外、そしてロジックに触れない小規模変更を Issue 化なしで通す軽量経路の3経路である。
このロール分離は1本の Issue の流れを説明したものであり、実際には複数の worktree と相談者セッションが同時に走る。同じモデル・同じ規則で動くセッションは、自分が方向を外したことを自分では検出できない。外部の読み手を用意するのが M-m(session-nudge、キーバインド)で、送信は cross-session messaging で行うが、文案は必ず user が承認してから送る。自動で他セッションへ介入はしない。
詳細は issue-driven-workflow.md を参照。
Foundation(自律実行の前提条件)
エージェントの自律実行は、環境・機密・知識の3点を構造的に整えてはじめて成立する。
-
Nix による環境同一性: 環境差はエージェントの「コマンド未検出」「実行時エラー」を招く。Nix Flakes と Home Manager で macOS / Linux のツールチェーンをコードとして同一化し、CI(
nix flake check)で継続検証する。導入経路の逸脱(brew/…
The startup phase runs on a light PLAN.md and a JUDGE.md that keeps the decisions
There are two documents in the MVP phase.
-
PLAN.md— spec, plan, work log. Keep it light. The more detail you write in, the faster it goes stale -
JUDGE.md— decisions made during implementation. Why this structure, why the alternative wasn't taken
The reason I keep PLAN.md light but don't skip JUDGE.md is that what you need later is the record of decisions. You can read the running code to learn the spec, but the options you considered and rejected leave no trace in the code.
Both are promoted into the README at release and their job ends. They're scaffolding that isn't meant to persist. In practice, none of my published repositories still contain a PLAN.md or a JUDGE.md. They all folded into the README and disappeared.
Tests are enforced but unreadable. READMEs are readable but unenforced
When I went to decide what document should drive things after the MVP phase, the two that already existed each satisfied only one side.
People say that if you have tests, you have the spec. What you can actually read off test code is green or red, not what is being promised. It takes chasing several files of fixture assembly, fake substitution, and assertion fragments, and converting them back into meaning in your head. With tests you wrote yourself, at least your understanding at the time of writing is still in there. Generated tests don't even have that first pass.
Specs and READMEs are the reverse. They're written for humans to read, but nothing happens when you break them. Small post-release fixes aren't worth writing a spec for, so only the implementation moves. Nobody notices the drift, and by the time anyone does, all that's left is a shared sense that the doc can't be trusted.
"Tests are hard to read" and "docs rot" get talked about as separate complaints, but they're two sides of one thing. The readable document isn't enforced, and the enforced document isn't readable. Once AI started mass-producing tests, that gap opened up fast.
What the successor document needs is both: humans can read it, and breaking it makes something fail.
Twenty years ago, we tried to execute the spec instead
There have been plenty of attempts to close this gap. They all pointed the same direction: make the document humans read executable.
Ward Cunningham's FIT (Framework for Integrated Test, 2002) ran the examples written in wiki tables directly as acceptance tests. Cucumber, in 2008, binds scenarios written in the plain-language Gherkin syntax to step definitions and executes them. Gojko Adzic's Specification by Example (2011) systematized this as living documentation — a spec that keeps getting updated alongside the implementation.
And the central part didn't take hold. Adzic himself published the numbers in his ten-year retrospective. Twelve percent of teams keep requirements as version-controlled specification files; 57% have a task tracker like Jira as the source of truth. About a third of the teams that use examples don't automate them.
The figures come from Gojko Adzic's ten-year retrospective, Specification by Example, 10 years later (2020). What did stick was the part where stakeholders talk through acceptance criteria together.
The reason it broke wasn't the philosophy, it was the cost. Write the scenario, bind it to step-definition code, and fix both every time the implementation changes. A human had to keep paying for that double bookkeeping, and couldn't.
What all of these have in common is that they tried to make one document last from beginning to end. The bet was that making it executable would keep it from rotting. Swapping documents by phase wasn't on the table at all.
Extract only the promises the tests are holding
What I do now runs the other way. I gave up on making the document executable and write tests as ordinary tests. Then I extract only the promises those tests are holding, in a form humans can read, and put them in one file. That's the guarantee ledger, and it lives at docs/guarantees.md.
Here's the contact-form endpoint section, taken from the ledger for my own portfolio site.
### 1. `src/lib/api.test.ts` — src/lib/api.ts (Hono app)
- `POST /api/contact` returns 400 with `{ error: 'invalid_json' }` when the
request body cannot be parsed as JSON.
- `POST /api/contact` returns 400 when any of `name` / `email` / `phone` /
`category` is missing.
- `POST /api/contact` returns 502 when the Telegram API call fails
(non-2xx response).
- `POST /api/contact` skips Turnstile verification and continues processing
when no `cfToken` is supplied, even if `TURNSTILE_SECRET_KEY` is set.
| Guarantee (summary) | Covering test |
|---|---|
| `/api/contact` invalid JSON | `POST /api/contact > 400 for invalid JSON` |
| `/api/contact` missing required fields | `POST /api/contact > 400 when required fields missing` |
| `/api/contact` Telegram API failure | `POST /api/contact > 502 when Telegram API fails` |
| `/api/contact` skip when cfToken omitted | `POST /api/contact > Turnstile skipped when no token provided` |
That last line is the typical case. Turnstile, the bot protection, is built to verify only when a token is supplied. That was a deliberate decision, but looking at the code alone it reads like a missing check. The more intentional a behavior is, the more it becomes something the next person fixes unless it's written down.
The body is the declaration of the promise; the table is an index of which test backs it. For "if I change this, what breaks?", you narrow it down in the table, read the declaration, and drop into the test code if you need to. That's the order.
The format doesn't change when the shape of the public surface does. In the ledger for a CLI that fills values into Excel templates, exit codes and stdout are what's under guarantee.
### 4. `tests/template_fill/test_cli_template_fill.py` — `packages/template_fill/cli.py` (`main`)
- `main(argv)` reads a `mapping` YAML and a `data` JSON (`-` for stdin),
prints the result path from `fill_template` to stdout, and returns `0`
- Returns `2` with a message on stderr when the root of the `data` JSON is
not an object
- `MappingError` / `FillError` / `OSError` / `json.JSONDecodeError` are
emitted to stderr as `"error: {message}"` and return `2`
An HTTP status code and a CLI exit code are the same grain of thing: a promise observable from outside. I've never needed to vary the ledger format by the kind of public surface.
The difference from Gherkin is that this table is an index, not glue code. The tests are written normally, in whatever test framework the repository already uses. Once you start pushing natural language through a machine-readable syntax so it can execute, you grow another layer that looks business-facing but can only be maintained by programmers. The audience for reading is humans and the audience for executing is CI, so I don't force them into one file.
And the double bookkeeping — keeping the document and the tests separate — is now paid by Claude Code. If it can change the implementation and fix the tests, it can fix the ledger line in the same change. The cost humans couldn't pay twenty years ago moved to a side that can pay it.
What gets promised is approved before implementation starts
Update the ledger in a batch after the fact and there will always be a round where it doesn't get updated. So I approve at the entrance to the change. I write an issue file per change (I've written about this handoff separately, in Japanese), and it contains a guarantees section.
Here's a real one, from adding a listing API to the same repository.
### Guarantees
- Newly declared guarantees:
- `GET /api/documents` is callable by any authenticated user (viewer or editor)
- Returns only rows where `documents.pdf_path IS NOT NULL` (PDF generated).
Rows whose PDF is unresolved due to generation failure are not included
- Each element includes `id` / `doc_type` / `fields` / `pdf_path` / `created_at`
- Returned in descending `created_at` order
- Guarantees maintained:
- The response contracts of the existing `/api/documents/types`,
`/api/generate`, `/api/files`, and `/api/search` are unchanged
"Newly declared guarantees" are the promises being added; "guarantees maintained" is the range this change promises not to touch. Part of why I have the latter written out is to hand the implementation side a boundary of what it may touch.
My job is to read these few lines and cut, add, or correct. The issue is written out with status: draft, and the implementing Claude Code doesn't start until I approve it and flip that to open. Approval isn't a recommendation; it's the precondition for starting.
Some changes add no guarantees. In that case I don't leave it blank — I have the reason written instead.
### Guarantees
- Newly declared guarantees: none (a visual fix; no behavior to be written
down and promised is added)
- Guarantees maintained: none (`docs/guarantees.md` covers only `packages/`,
and there are no existing guarantees recorded for frontend appearance.
The existing desktop-width layout will not be broken)
Reverse the order and it stops working. Have the guarantees written out after implementation is done and you get a transcription of the fact that the implementation happens to work that way. Whatever happened, the guarantees are satisfied. If TDD is the discipline of writing the test first, this is the discipline of putting approval of the promise first. The AI writes the test code; the human decides what gets promised.
One change runs in this order:
The ledger update goes into the same change as the implementation and the tests. Push it back as separate work and that's exactly where it gets skipped.
The full policy is in docs-agents/test-policy.md.
A repository stays in the MVP phase until its ledger is in production use
I don't decide the phase transition by feel. The condition for moving from the MVP phase to the issue-driven phase is that the guarantee ledger is in production use. Rewriting the phase declaration and laying down the ledger are bundled as the same event.
A skill called guarantee-audit does the laying down. The procedure:
- Read every test file in the repository and write out, in natural language, the behaviors the tests pin down
- Sort contract surfaces (public APIs, CLIs, distributed artifacts — anything observable from outside) from internal implementation, and keep only the contract surfaces
- Write out a draft headed
# Guarantee Ledger (Draft) - I cut, correct, and add
- Write the tests for whatever was accepted under "add" as missing, right there, and confirm everything is green
- Drop the
(Draft)and switch to production use
If a test goes red at step 5 — meaning the implementation didn't satisfy that guarantee — I don't add the test. I open an issue to fix the implementation. The moment there's latitude to touch the implementation, it's beyond the scope of an audit.
So the guarantee ledger is, before it is an inventory of tests, a declaration that the period during which the AI may implement in whatever shape it likes is over. A repository that can't produce a ledger doesn't yet have a settled public surface to promise anything about, so it stays in the MVP phase.
What couldn't be written stays as Gaps
The bigger yield from an audit is actually the other side: finding things that should be guaranteed but have no test. I don't mix these into the body — they go in a separate ## Gaps section.
## Gaps
Contract surfaces in app/ with no tests:
- `POST /api/auth/login` — issues a token on correct credentials, `401` on wrong
- `GET /api/documents/types` — returns doc_type and `fields` order from the mapping YAML
- `POST /api/generate` — editor only (viewer gets `403`), unknown doc_type gets `404`
- `GET /api/search` — FTS5 search filtering (including `pdf_path IS NOT NULL`)
- `GET /api/files` / `GET /api/pdf/{path}` — listing and PDF delivery (including path traversal rejection)
When a test gets written and the entry moves into the body, the line disappears from this section. When it's empty, the section is deleted entirely. I don't leave "none at this time" behind, because the more filler text there is, the more the whole ledger becomes something you skim.
I list only externally visible behavior. Line up internal implementation tests too and it grows past the length where it can be read as a map, and the ledger shifts on every refactor, so you can't tell whether a promise changed or just the construction did. The end of the ledger states explicitly that behavior not listed is not a promise and may change without notice. It's a declaration of boundaries, not of coverage.
Both the human and the AI now open the ledger first
The biggest change from running this is that the file you open before implementing is now decided. Bug fix or new feature, I start by knowing what must not break — and, conversely, what I'm about to break. That's not a change on my side alone; the Claude Code doing the implementation starts from the same file.
Call the value of tests "quality assurance" and it looks like the job is done the moment they go green. The value actually shows up later, the next time someone touches that code. What's heavy about adding a feature or fixing a bug isn't the writing so much as not knowing how far the damage can reach. Without a visible range of what's safe to touch, you either go read unrelated places or push on without checking.
And for that fix or that new feature, you add one more guarantee line. The protected range widens a little at a time, and what the next implementation refers to is the ledger as it stands at that moment.
A natural-language ledger runs on trust
The limits are just as clear. Break a guarantee and a test fails, but drift between what the ledger says and what the implementation does fails nothing. Forget to delete a line, leave the wording stale — CI says nothing. The enforcement is attached to the content of the guarantee, not to the ledger as a document.
On top of that, the ledger is natural language. The only ways to confirm that each line is actually true are for a human to read and judge, or to trust the Claude Code that wrote it.
That's the thinnest point in this approach. The moment you start suspecting the ledger might be out of sync, nobody uses it as a starting point anymore, and the whole workflow stops working. Standing on trust rather than on an enforcement mechanism means it ends the moment it's doubted. I do run periodic drift checks through guarantee-audit to catch things, but the room to rot the same way a README rots is still there, and I haven't eliminated it.
There's one more: when a new public surface appears outside the ledger's scope, "it's not listed, so skip it" becomes available as a choice. The policy says being out of scope is not grounds for skipping, but that's a discipline written in prose, not something a machine stops you on.
Even so — building fast on PLAN.md and JUDGE.md in the startup phase, then handing over to the ledger once there's a line to draw — since setting it up this way, the file to open next is decided at every stage. While you're having the tests written, have the promises written in the same change, and read just that part yourself. One extra file, and you can keep expanding while widening the territory you're not allowed to break.

Top comments (0)