Implementation Plans
An implementation plan is a design document for a change, written as JSON before any code exists. Guren checks it against your application, renders it as a page a person can review, stamps it when it is approved, breaks it into steps, and then reads from the code which parts of it exist. The agent that implements the plan never reports its own progress: plan:status and plan:verify derive it from the schema, the route graph, the controllers, the pages and the test results.
Implementation Plans
An implementation plan is a design document for a change, written as JSON before any code exists. Guren checks it against your application, renders it as a page a person can review, stamps it when it is approved, breaks it into steps, and then reads from the code which parts of it exist. The agent that implements the plan never reports its own progress: plan:status and plan:verify derive it from the schema, the route graph, the controllers, the pages and the test results.
A plan is worth writing when a change spans a table, several routes and a page, and the design is cheaper to correct than the diff. If you can describe the diff in one sentence, skip the plan.
For a walkthrough, Building with an Agent takes two plans from request to close with Claude Code, with a checklist for each decision along the way.
flowchart LR Write["plan.json<br/>written by you or your agent"] Render["plan:render<br/>checks + review page"] Approve["plan:approve<br/>baseline + approval"] Next["plan:next<br/>one step"] Verify["plan:verify<br/>commands + tests"] Close["plan:close<br/>docs/plans, docs/entities"] Write --> Render --> Approve --> Next --> Verify --> Next Verify --> Close
The examples on this page come from one plan, comments on the posts of examples/blog, run against a copy of that application.
Where a plan lives
A plan is a directory under docs/plans/, named by its slug:
| File | What it is | Committed |
|---|---|---|
docs/plans/comments/plan.json |
The plan | yes |
docs/plans/comments/approvals.json |
The hashes plan:approve recorded, with the readings of each alter |
yes |
docs/plans/comments/decisions.json |
Waivers, written by plan:waive |
yes |
docs/plans/comments/revisions/0001.json |
Revisions, written by plan:revise |
yes |
docs/plans/comments/plan.html |
The page plan:render writes |
no |
.guren/plans/comments.state.json |
Verification results and the marked step | no, it ignores itself |
The slug is the directory name for a file called plan.json. Any other name works too: comments.plan.json has the slug comments, and keeps its records beside it as comments.approvals.json, comments.decisions.json and comments.revisions/.
The rendered page is generated output and belongs out of the repository. plan:next ignores it and its temporary file where plan:render writes them by default, so it does not have to be ignored for the loop to run, but it should not be committed either. A page written elsewhere with -o is an ordinary untracked file, and plan:next refuses the tree it sits in. An app scaffolded by create-guren-app already ignores both default locations; an older app adds the two patterns to its .gitignore. The first covers the docs/plans/<slug>/ layout, the second a plan named <slug>.plan.json anywhere else, such as the application root:
docs/plans/**/*.html
*.plan.html
The plan, its approvals, its decision log and its revisions are a different matter: plan:next refuses while one of them is uncommitted, since a waiver decides which step it hands out and a revision belongs in the commit that changes the plan.
Writing a plan
You write the JSON, or your agent does in the session where you discussed the feature. guren plan --print-prompt prints what that agent needs: a prompt with your request and the conventions on this page, then the plan's JSON Schema. It calls no model and runs nothing.
bunx guren plan "comments on posts, authors can delete their own" --print-prompt
Paste the output into the session, or have the agent run the command itself. The prompt has the agent read the application with context, model:list and guidelines, ask you what it cannot decide, write docs/plans/<slug>/plan.json, and run plan:render --json until no check fails. Approving stays with you. Without a request, the prompt tells the agent to ask you for one, and --json prints the prompt and the schema as one object. Without --print-prompt, guren plan exits with an error: the form that asks a model for the plan by itself is not available yet (see the end of this page).
In an app whose agent harness is installed (bunx guren agent:init, refreshed by agent:sync), the plan-write skill runs this for you. Ask your agent to plan the feature: it asks you the questions that change the design before writing anything, writes the plan from the prompt, runs plan:render --json until no check fails, and tells you where the page is and what is still open. After your review it records your changes with plan:revise. It never approves the plan; once you have, the plan-implement skill builds it.
plan:render validates the file against the plan schema and names the field at fault:
ERROR The plan does not match the plan schema:
models.0.columns.0.change.from: Invalid input: expected string, received undefined
The document
| Field | Holds |
|---|---|
planVersion |
1 |
title, summary, locale |
What the plan is about, and the language its prose is written in (en, ja) |
scope |
goals and nonGoals |
assumptions, questions |
What the plan decided without being told, and what it could not decide |
models, validators, controllers, routes, views, resources, policies, sideEffects |
The design, one section per kind of element |
flows |
How a request moves through what the plan adds, as nodes and edges |
commands |
Commands such as guren add attachments the change needs first |
tasks |
What each slice must achieve, and its acceptance behaviours |
hints |
Ordering advice, such as task/entity/model.tag before task/entity/model.comment |
baseline |
Written by plan:approve, never by hand |
Every section may be left out. An omitted section and an empty one are the same plan.
Ids and changes
Every element carries an id and a change. Ids share one namespace across the whole plan, start with a letter, and may contain letters, digits, _, ., : and -; the convention is the section and a name (model.comment, route.comments.store). Other elements refer to each other by these ids, and a revision later names an element by its id alone, so keep them stable.
change.kind |
Meaning |
|---|---|
existing |
Referenced and left as it is |
add |
New |
alter |
Changed in place |
rename |
Renamed; from is the old name (a model's class, a column's property, a route's name) |
drop |
Removed; reason says why |
An alter, rename or drop of an existing table or column must say what happens to its rows with dataMigration: { "kind": "none", "reason": "…" }, backfill or manual with a description. The plan is refused without one:
column.post.summary: Column "summary" of "Post" is a "rename" on an existing table and states no dataMigration.
A model lists only the columns the plan touches or references. This is the new Comment model from the example, cut to two of its five columns:
{
"id": "model.comment",
"change": { "kind": "add" },
"name": "Comment",
"table": "comments",
"columns": [
{
"id": "column.comment.body",
"name": "body",
"change": { "kind": "add" },
"type": "text",
"nullable": false,
"unique": false,
"index": false
},
{
"id": "column.comment.postId",
"name": "postId",
"columnName": "post_id",
"change": { "kind": "add" },
"type": "integer",
"nullable": false,
"unique": false,
"index": true,
"references": { "model": "model.post", "column": "id", "onDelete": "cascade" }
}
],
"relationships": [
{ "name": "post", "type": "belongsTo", "target": "model.post" },
{ "name": "author", "type": "belongsTo", "target": "model.user" }
],
"fillable": ["body"]
}
Column types are an abstract vocabulary rather than Drizzle builders: string, text, integer, number, decimal, boolean, date, datetime, json, uuid. datetime takes withTimezone, and decimal takes precision and scale.
The other sections follow the same pattern. A controller holds its actions, each with the validator its body, params or query uses, its authorization (middleware, and a policy ability), its response (an Inertia view, a redirect, a resource) and its business rules as prose. A route names its method, path, name, action id, middleware and bind. A view names its page id, its props, and a form whose fields point at a validator's fields rather than restating their rules. Elements in an application module carry module.
Acceptance behaviours
A task intent names an entity, the elements it covers, and the behaviours that prove it works:
{
"id": "AC-comments-4",
"description": "A user cannot delete someone else's comment.",
"kind": "forbidden",
"actor": "user",
"route": "route.comments.destroy",
"given": ["a comment written by another user exists"],
"expect": { "status": 403 }
}
kind is one of success, validation, unauthenticated, forbidden, not-found and state. The checks count them: a route with a validator and no validation behaviour, or with authentication and no unauthenticated one, is reported. expect takes status, redirect, inertia, errors and database; request input and database values are written as { "name": "body", "json": "\"Nice post\"" }, the value as JSON text.
Each behaviour becomes a test whose title carries its id in brackets. Start acceptance ids with AC-: plan:verify reports a bracketed AC- token that the plan does not declare, which is how a mistyped id gets caught.
test("[AC-comments-4] a user cannot delete someone else's comment", async () => {
const comment = await Comment.forceCreate({ body: 'Mine', postId: post.id, userId: author.id })
await http.actingAs(reader).delete(`/comments/${comment!.id}`).assertStatus(403)
})
Guren redirects a non-GET request with 303, so a behaviour that expects the redirect after a form post says "status": 303.
Questions
A question is a decision the author could not make alone, with the options, the one the plan assumed, and the elements that change if the answer differs:
{
"id": "Q-delete",
"question": "Does deleting a comment remove the row?",
"options": [
{ "label": "hard delete", "consequence": "The row is removed; no deleted_at column." },
{ "label": "soft delete", "consequence": "A deleted_at column is added and lists filter on it." }
],
"assumed": "hard delete",
"affects": ["model.comment", "action.comments.destroy"]
}
A plan with an open question cannot be approved. Answer it by editing the plan: apply the answer, remove the question, and record the decision under assumptions. Before approval, editing plan.json by hand is the normal way to change it.
Commands
plan:next hands a plan's commands to the implementing agent, which runs them as written. Each one therefore has to be a Guren generator:
{ "id": "command.attachments", "command": "guren add attachments", "reason": "Comments take images." }
A command passes when it reads guren <subcommand> or bunx guren <subcommand> and the subcommand is a make:* generator other than make:migration, lang:publish, or add <blueprint> other than add plugin. Arguments may hold letters, digits and _-.,:/=@+%, with single or double quotes around a value that has spaces (--fields "title:string,body:text?"). A shell operator, $, a backslash or an unclosed quote fails the check, and so does an argument that is an absolute path or climbs out with .. (--path /etc, --app=../other). The check bounds shell syntax and where a generator may write; it does not judge each generator's other flags, such as --force. Every other command fails too: bun run db:migrate runs as the data step's verify command, not from the plan. plan:approve refuses while this check fails, and plan:next hands out no step of a plan that carries such a command, draft or not.
Rendering and checking: plan:render
bunx guren plan:render docs/plans/comments/plan.json
It writes docs/plans/comments/plan.html and prints the path. -o writes somewhere else, --app <dir> names the application to check against when you run it from another directory, and --locale ja opens the page's own labels in Japanese (the page switches between en and ja; the plan's text is never translated). --json prints the page's path and every check instead of the path alone, so an agent can read the failing checks without opening the page.
The page is one file with no network access: it opens from disk and can be attached to a review. It has a tab per section, a filter per entity, a "Changes only" toggle that hides existing elements, an entity relationship diagram of the plan merged over the current schema, and every id links to the element it names. Failed checks and breaking changes are pinned under "Needs attention". Each element has Approve and Request changes buttons and a comment box, and the footer exports the review as feedback.json. Nothing done on the page changes the plan file. Copy prompt for the agent copies one message, in the page's language, that names the plan, asks the agent to apply the review with the plan-write skill, and carries the feedback, so the review reaches the plan through a revision. plan:revise reads its approvals and answered questions (below); the comments are for you or your agent to apply to a copy of the plan. The two commands it prints are the ones that follow a revision, plan:render and plan:approve.
The checks run against the application as it is now. They report, among others, a route whose action is not in the plan, a foreign key to a model that exists nowhere, an add whose name is already taken, an existing or alter target that does not exist, a mutating route with authentication and no authorization, a body-carrying route with no validator, and the missing behaviours above. Rendering never fails on a check; plan:approve does. On a plan with a baseline, plan:render settles the same findings approval does, so a collision the plan's own work explains shows on the page as a passing check rather than a blocking one. Renaming the delete route of the example to a name the blog already uses gives:
route.comments.destroy: The route name "posts.destroy" already exists in this application.
Impact
For every element the plan alters, renames or drops, the page lists what in the application depends on it: relationships, routes with their ApiRoutes entries and agent tools, resources, policies, controller actions, tests, and for a column the places that read or write it. A plan that renames posts.excerpt to summary in the blog shows, under the column:
PostResource reads it app/Http/Resources/PostResource.ts:32
posts/Index reads it through PostResource resources/js/pages/posts/Index.tsx:80
posts/Show reads it through PostResource resources/js/pages/posts/Show.tsx:60
PostController.store writes data no static scan can name the columns of
PostController.update writes data no static scan can name the columns of
Tests are found two ways. A TestApp request (get, post, put, patch, delete, query, and an agent tool call) is matched against the route graph, so a route lists the requests that reach it. A test file named after the controller or model is listed too, marked as matched by name, because a test that calls the action directly makes no request to read. After the implementation of the example, a plan moving the comment delete route shows:
Route comments.destroy
ApiRoutes entry comments.destroy
Request DELETE /comments/${…} reaches comments.destroy tests/comments.test.ts:38
The blog's own tests call their controllers without HTTP, so a plan that moves posts.show finds a test by name and no request:
Route posts.show
ApiRoutes entry posts.show
Test tests/controllers/PostController.test.ts, named after it
No TestApp request in the existing tests reaches the routes above.
That last note appears only when nothing could have hidden a request. A request whose path the scan cannot read (built from a variable, or on a receiver it does not know as a TestApp), a request whose route parameter constraint could not be checked, and a test file that did not parse are noted beside the entry instead.
Impact is a lower bound. The scan is static, so a value passed to another function or file, a reassignment and a column held in a variable are not followed, and an empty list means nothing was found, not that nothing is affected. Dropping a column, changing its shape, renaming or dropping a route, and changing a published agent tool are marked breaking whatever Impact found.
Approving: plan:approve
Approval is a person's decision, made after reading the page. It refuses while a check fails or a question is open:
ERROR docs/plans/comments/plan.json is not approved while a check fails or a question is open; an assumption nobody confirmed is not approved by silence.
question Q-delete is unanswered: Does deleting a comment remove the row?
An answer chosen on the review page does not change the plan file. Send the agent the prompt the page copies with "Copy prompt for the agent", or remove each answered question yourself with plan:revise (--edited with a copy of the plan, or --ops with a remove op), passing the page's feedback with --feedback.
Once the plan is clean:
bunx guren plan:approve docs/plans/comments/plan.json
Comments on posts (plan.json)
Stamped the baseline at 0c871a5b9dc25587d33ae3d6bb6c3befe2c7e6a2: 14 element(s) hashed.
Approved 22735cb551ac15559cd5cabc344925f8f75af7a62efe39570ac49d8c032a59c0, recorded in docs/plans/comments/approvals.json.
The first approval writes a baseline into the plan: rev, the commit the plan was written against, and contextHash, a hash per referenced element of what the application holds for it today. That is why it refuses a repository with no commit and a working tree with uncommitted changes (the plan's own files excepted). The approval itself goes to approvals.json, beside the plan and never inside it. Commit both.
A validator is found by its exported schema symbol, read from the files under app/Http/Validators/ without importing them. A name none of those files declares and exports is a warning rather than a failure, since a schema kept in a controller or re-exported from elsewhere is not seen. If a section cannot be read (a file there that does not parse, or one exporting through export *), approval refuses and names the elements that would stay unhashed; --allow-unstamped approves without them.
A plan approved before validators were read has no hash for them. Re-approving it after its validator was written reports the name as a plan:app-unjudged warning instead of a collision: the baseline cannot show whether the plan wrote it.
The plan's hash identifies it: a SHA-256 of the plan with its baseline. Approvals, verification records and waivers all name it, so a plan edited after approval is a different plan. plan:next, plan:scaffold, plan:verify, plan:waive and plan:close refuse a plan with a baseline whose current hash no approval names:
ERROR docs/plans/comments/plan.json is not approved at its current hash dc9a6ce3ad173e23290f743293fa0e3495c932b3b2cdf07c0cda8c9b063a5465, so no step of it is handed out: it was edited after approval, or never approved, and what it says now may not be what anyone agreed to. Run guren plan:approve docs/plans/comments/plan.json once the plan says what you mean to build.
plan:status and plan:render keep working, since they are how you read the change before approving it. A draft, which has no baseline and so no hash, is still accepted by plan:next and plan:verify; plan:scaffold refuses one, since it writes code from what someone approved. A draft with approvals recorded beside it is refused like an unapproved plan: deleting baseline from an approved plan does not take it out of the gate.
Approving an edited plan again records the new hash and leaves the baseline as it was, so its steps verify again under the new hash. The checks and questions are asked again first, against the application as it is at that moment. The plan's own work does not stand in the way: an element the implementation has already built where the plan leaves it is settled, and the approval says which:
Built as the plan leaves them, so their collision or absence is the plan's own work: model.comment, controller.comments, resource.comment, policy.comment, action.comments.store, action.comments.destroy
An element is settled only when the application started it where the plan says and now holds what the plan leaves. Anything else still refuses: an add a later edit retargets onto a name the application already had, a table the plan adds that another application root declares, an endpoint another route holds, and a model whose class is written while its table is not, which is at neither end. A rename or alter of a route that also moves its path reads as not built, which errs towards refusing. A draft is unchanged: nothing is settled for it, so a draft whose add already exists is still refused.
The rule is as sharp as freshness, and no sharper. A class another commit adds in the plan's own root, and a second route on the endpoint of one the plan built, read as the plan's own work. An existing element a revision turns into a drop is settled once someone else has deleted it, since the stamp recorded it present and it is gone.
Readings of an alter
An alter changes something that existed before the plan, so a planned property that already held proves nothing about the change. For every alter, the approval therefore records what each planned property reads at that moment, on its entry in approvals.json. plan:status counts an alter's property as done only when it read differ or unknown at approval and matches now. Approve the plan before implementing it, so the readings describe the application before the work. Approving an edited plan keeps the readings recorded under the same baseline. It keeps them only for properties whose planned value and name in code the edit left unchanged.
A property with no reading from approval is not counted, even when it matches. Before the work, plan:status names each planned property without a reading that still differs, and the fix:
planned alter Post model.post
differs: relationship comments (planned hasMany, found not declared)
differs: relationship comments target (planned Comment, found not declared)
The approval recorded no reading of relationship comments, relationship comments target: run guren plan:approve on the plan before changing them, since a match with no reading from before the work does not count.
plan:approve on a hash already approved records only the readings the entry lacks:
Already approved at 2026-09-22T10:16:20.673Z; recorded the readings it lacked in docs/plans/comments/approvals.json: model.post, view.posts.show.
After the work, a reading would find the property already held, so approving again cannot help. An alter whose matches all lack a reading reads unjudged, and its note says to verify the change through a behaviour that reaches it; once its step has verified, a second note adds the waiver. For an element no behaviour can reach, such as a column, the note names only the waiver.
An alter whose readable planned properties all held at approval cannot complete on them. plan:approve still approves it, and warns, naming the element and the properties; --json lists them under heldAlters. The warning is judged on the readings of the approval entry, so approving the same hash again repeats it, and a re-approval after the work, under the same baseline, does not raise it for a property the work changed. For a plan whose view.posts.show only restates the post prop the page already declares:
Warning, advisory (the approval stands):
view.posts.show (posts/Show): every readable planned property already held at approval (prop post); none shows the change, so plan:status reports it unjudged. State the change in a property the application does not hold yet and approve the plan again, or expect that it completes only through a verified behaviour that reaches it, or by a waiver.
A property that read unknown at approval is not counted as held: the warning names it as the only one that can still show the change, which it does only if a reader comes to see it match. An alter none of whose properties could be read gets no warning, and plan:status reports it unjudged as above.
Revising: plan:revise
A plan changes through a revision, before approval and after. plan:revise records one without a model. The plan file as it stands is the parent, and the change comes separately: a copy of the plan with the change made in it, or the ops themselves.
cp docs/plans/comments/plan.json /tmp/comments.edited.json
# edit the copy: rename a column, change a type, drop a route
bunx guren plan:revise docs/plans/comments/plan.json --edited /tmp/comments.edited.json --message "soft-delete comments instead"
The command derives the ops from the difference between the two, one op per element added, changed or removed, each with --message as its reason. It writes { parent, ops, result } to docs/plans/comments/revisions/0001.json and then replaces plan.json with the copy. A plan named comments.plan.json keeps its revisions in comments.revisions/. --ops ops.json takes the ops directly, as a { "ops": [...] } document in which each op carries its own reason.
With --feedback feedback.json (or - for the copied text), the page's review becomes a rule. An element approved there changes only when --reopens "<reason>" says why, and a question answered there has to be gone from the revised plan. That is all the command reads from the feedback: the comments stay for you to apply to the copy.
After approval, this is how the plan changes. Editing plan.json in place moves its hash to one that no approval or revision names, and plan:revise refuses that plan: restore it with git checkout -- docs/plans/comments/plan.json, keep the edit in a copy, and pass the copy with --edited. A plan revised and not yet approved can be revised again. A revision carries the baseline over unchanged, so plan:next and the other gated commands refuse the result until plan:approve records it, and a waiver taken against the old hash does not carry over; the command lists those waivers. A draft can be revised the same way before its first approval, or edited directly.
Revising a plan that is already implemented brings every step back, since no record of the old hash counts under the new one. plan:next marks a step verified against an earlier hash as one to re-check. It names a scaffold or tests step whose files are already on disk as built under an earlier version of the plan. plan:scaffold refuses a step any of whose files exist, so plan:next points to plan:verify --step and lists what is still missing, to write by hand. plan:verify without --step re-checks the whole plan in one run. The tests step is the one that cannot simply run again: its behaviours pass once the code exists, and tests:fail asks them to fail. A verified run of that step therefore records each behaviour as seen failing, keyed on its test as the plan states it (everything but the description, with the route's method and path and the expected page). A later run carries that record for every behaviour whose test the revision left alone, and asks only a changed one to fail again, against the code the old plan was implemented with. A tests step with no such record (verified by a CLI older than this rule) cannot verify once its behaviours pass. The Stop hook gives up on it at once, and since the step owns no element, plan:close does not wait for it.
Implementing: plan:next and plan:verify
Guren derives the work from the plan, and the order does not depend on a model. Every entity the plan adds or changes is a task, ordered by foreign keys, and there are six kinds of step, and a task gets only those it has work for:
| Step | Work | Verified by |
|---|---|---|
commands |
The plan's commands, such as guren add attachments, in task/foundation |
codegen, typecheck |
scaffold |
The first version of a new entity, written by plan:scaffold |
codegen, typecheck |
tests |
One test per acceptance behaviour, written as a skeleton by plan:scaffold, failing |
codegen, the tests failing |
data |
Table, migration, model relationships and fillable; after a scaffold, the migration and what plan:scaffold left out |
codegen, db:migrate, typecheck |
http |
Controllers and routes; validators, resources and policies, or after a scaffold, what plan:scaffold left as a stub or unwritten |
codegen, typecheck, guren check, the tests passing |
pages |
Page components | codegen, typecheck, guren check |
The tests run on one step per task, the one its behaviours are judged at: the last http step, or the task's last step when it has none, so a data or pages step can run them too. Any other step, an earlier part of a split http step included, runs no tests. Only the task's last http step runs typecheck, since an earlier part may import a resource or job a later part writes. A page an http action renders that the pages step adds is not in .guren/pages.gen.ts until its file exists, so plan:next lists it with the http step: create it there as a stub with a default export and the plan's Props, and write the rest in the pages step. Work shared by several entities goes to a task/foundation task. Step ids read task/entity/model.comment/http. A commands, data, http or pages step whose elements span more than five files is split into parts with ids such as task/entity/model.comment/http/1 and task/entity/model.comment/http/2; scaffold and tests are never split. plan:next prints the exact id to pass to --step. The loop is: ask for the next step, implement it, verify it, commit.
bunx guren plan:next docs/plans/comments/plan.json
Comments on posts (plan.json)
Verified: task/entity/model.comment/scaffold
Next: task/entity/model.comment/tests
task: entity Comment (task/entity/model.comment)
verify: codegen → tests:fail
Write this step’s test skeletons with `bunx guren plan:scaffold docs/plans/comments/plan.json --step task/entity/model.comment/tests`, not by hand, then fill them in.
It writes one TestApp test per behaviour (AC-comments-1, AC-comments-2, AC-comments-3, AC-comments-4), with its request and the expectations the plan states, into one file.
Each fails at a given() call until the setup it names is written (records, the signed-in actor, path parameters); replace every call, and keep each title’s id and its request.
Behaviours to write, as test titles `[<id>] <description>`, failing:
[AC-comments-1] A signed-in user can comment on a post.
success; actor user; route route.comments.store; given a post exists; expect status 302; comments has 1 row(s)
[AC-comments-2] An empty comment is rejected.
validation; actor user; route route.comments.store; given a post exists; expect status 422; errors on body
[AC-comments-3] A guest cannot comment.
unauthenticated; actor guest; route route.comments.store; given a post exists; expect redirect /login
[AC-comments-4] A user cannot delete someone else's comment.
forbidden; actor user; route route.comments.destroy; given a comment written by another user exists; expect status 403
Each test requests its route through a TestApp, in its body or a function of its file it calls: plan:verify reads the requests before it runs them.
Implement this step only, then run `bunx guren plan:verify docs/plans/comments/plan.json --step task/entity/model.comment/tests` and commit once it is verified.
Marked in .guren/plans/comments.state.json
plan:next prints one step and never the whole plan. --json prints the same as data. It marks the step in the state file, which is what the Stop hook reads. It refuses a plan no approval names, as above, and a working tree with uncommitted changes unless they are the marked step's own, so run it before you start a step, not after:
ERROR The working tree under /app has uncommitted changes (paths relative to the repository root), and one step is one commit. Commit or discard them first:
?? tests/plans/comments/comments.test.ts
bunx guren plan:verify docs/plans/comments/plan.json --step task/entity/model.comment/tests
task/entity/model.comment/tests: verified (607 ms)
pass codegen bun run codegen
pass tests:fail bun test tests/plans/comments/comments.test.ts
failing [AC-comments-1]
failing [AC-comments-2]
failing [AC-comments-3]
failing [AC-comments-4]
work: 1 file, +38 -0 since 3f1c2a9b0d4e
Recorded in .guren/plans/comments.state.json
The tests step passes only when every behaviour has a test and each one fails: a test that passes before the code exists proves nothing, and a skipped test is not a failing one. plan:verify selects the test files whose source carries the step's ids, and runs them with bun test. Later steps run the same files and need them to pass. After the verify output it prints the plan's status, described below.
Before it runs the tests, plan:verify reads each behaviour's tests for a TestApp request to the route the behaviour names. Some test, it or describe whose title carries the id must request that method and path (or call the route's agent tool), in its own body or in a function of the same file it calls. A path segment filled whole at runtime, `/comments/${id}`, counts for a parameter, a constrained one included. A test that requests another route, or none, fails the command with what it requests instead, and bun test does not run.
A request this reading cannot resolve fails the command too, with its own reason: a path the file does not spell, a request on what an imported helper returns, a request on what a function of the same file returns when nothing annotates it TestApp or Promise<TestApp>, a TestApp handed to a function from another file, or a title built at runtime. The check fails closed because a test rewritten that way would otherwise verify the step, and the finding asks for the request to be spelled in the test (or the helper annotated). It is tamper detection, not proof: a request the file spells passes whether or not it runs.
The scaffold step: plan:scaffold
A task that adds its own model starts with a scaffold step, and plan:next names the command that writes it:
Next: task/entity/model.comment/scaffold
task: entity Comment (task/entity/model.comment)
verify: codegen → typecheck
Write this step with `bunx guren plan:scaffold docs/plans/comments/plan.json --step task/entity/model.comment/scaffold`, not by hand.
It writes each added model (table and class), its validators and resources, each policy with a provider registering it, each added controller with its actions as stubs, the routes to them in a file of their own that the http step mounts, and the side-effect classes: model.comment, column.comment.id, column.comment.body, column.comment.postId, column.comment.createdAt, validator.comment, controller.comments, action.comments.store, action.comments.destroy, route.comments.store, route.comments.destroy, resource.comment, policy.comment
bunx guren plan:scaffold docs/plans/comments/plan.json --step task/entity/model.comment/scaffold
It appends each added model's table to db/schema.ts in the schema's dialect. The table carries every option the plan states for a column (type, nullability, unique, index, default, columnName, withTimezone, precision and scale, the primary key, the foreign key and its onDelete) and the model's multi-column indexes:
export const comments = pgTable('comments', {
id: serial('id').primaryKey(),
body: text('body').notNull(),
postId: integer('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('comments_post_id_index').on(table.postId),
])
It writes app/Models/Comment.ts with the plan's fillable and relationships, each keyed by the foreign key the plan states. A relationship whose keys or target do not exist yet is left out and listed, for the step where they exist. When it waits on a later task's work, such as a hasMany to a child the next task adds, the list names the step that owns that work (the later task's data step, or one of its parts): the relationship is judged there, as a property of the model it waits on, and written in the declaring model's file; plan:next lists it under that step. Any other relationship left out makes plan:status read the model as drifted until it is added.
The step's validators go in one file named after the model, app/Http/Validators/CommentValidator.ts, one exported schema per validator. Each field is written from its planned type, required and rules (min, max, email, url, uuid), in the form plan:status reads back:
import { z } from 'zod'
export const CommentPayloadSchema = z.object({
body: z.string().min(1).max(2000),
})
A validator an action takes its query or params from gets z.coerce.number() and z.stringbool() for numbers and booleans, since those values arrive as text. A rule written in prose, or one that does not fit the field's type (a bound on a boolean), is not written, and the report lists it.
Each resource whose model the step adds is written as a Resource subclass with the planned payload type, which guren codegen reads for data.gen.ts. A field is copied from the model's column when the planned type admits every value the column reads back as, a date-time column is serialized with toISOString() for a planned string, and a JSON column is cast to the planned type. Any other field calls a stub that throws until you map it, and the report lists it:
export class CommentResource extends Resource<CommentRecord, CommentResourceData> {
toArray(): CommentResourceData {
return {
id: this.resource.id,
body: this.resource.body,
createdAt: this.resource.createdAt.toISOString(),
}
}
}
Each policy is written with one method per planned ability, and every method returns false until you write its rule, which the method's comment quotes. app/Providers/CommentPolicyProvider.ts registers it with the gate in boot(), and the command adds that provider to createApp({ providers }) in src/app.ts. plan:status reads a policy by its abilities and does not read its registration, so a policy is complete at present.
Each controller the step adds holds exactly the planned actions and nothing else. An action validates its params and query with the planned validators, authorizes with the planned policy ability, validates its body, then answers 501. A caller the policy denies gets 403 whatever it sent:
export default class CommentController extends Controller {
// Planned response: a redirect to /posts/:postId
// Rule: The comment's author is the signed-in user.
// postId is not fillable: write it with Comment.create(data, { set: { postId } }) (RFC 0031)
async store(): Promise<Response> {
await this.validateBody(CommentPayloadSchema)
throw HttpException.notImplemented('CommentController.store is planned and not written yet')
}
// Planned response: a redirect to /posts/:postId
async destroy(): Promise<Response> {
const comment = this.model(Comment)
await this.authorize('delete', [Comment, comment])
throw HttpException.notImplemented('CommentController.destroy is planned and not written yet')
}
}
The ability is asked of a record in the [Model, record] form, because an ORM record carries no class the gate could find the policy by. For any ability but viewAny and create, which a policy asks without a record, where every route to the action binds one record of the policy's model, the stub reads it with this.model() and passes the tuple. Otherwise it passes the bare class, and for a record ability (view, update, delete and the like) the comment above the action names the tuple to pass once the action loads the record. An action a POST route takes a body to lists, in the same comment, the foreign keys an added model's fillable leaves out: create() refuses them in its data, so they go through set (RFC 0031).
It validates with validateBody() rather than validated('comments.store'), since validated() is typed from the generated route names, and the route is not registered until the http step mounts it. No response is written: plan:status credits a response it can name (a resource, a page, a redirect), so a stub that named one would read as done. The report lists every action's response as left to write.
The routes to those actions go in a file of their own, routes/comments.ts, with each route's method, path, name, contract schemas, bindings, auth middleware and .agent() metadata as planned:
export function registerCommentRoutes(router: Router): void {
const authRouter = router.aliasMiddleware('auth', requireAuthenticated({ redirectTo: '/login' }))
authRouter.post('/posts/:postId/comments', { name: 'comments.store', body: CommentPayloadSchema, bind: { postId: Post } }, [CommentController, 'store']).middleware('auth')
authRouter.delete('/comments/:id', { name: 'comments.destroy', bind: { id: Comment } }, [CommentController, 'destroy']).middleware('auth')
}
Nothing calls the file yet, so its routes are not registered and read as planned: a mounted route would answer 401 or 422 before the tests step, and a behaviour that already passes fails that step. auth is the only middleware it applies; any other name the plan gives is listed for the http step, which knows the handler the application aliases it to. guren check reports the unmounted file as advisory while the plan is approved and not closed and its http step is not verified, so the gate does not block the steps in between. Once that step verifies or the plan closes, an unmounted file is a warning again.
Each job, event, listener, mail and notification the step adds is written as the matching make:* command writes it, under the plan's class name. plan:status reads one as present; it is wired once something dispatches, registers or sends it, which is the http step's work. Where docs/entities/Comment.md already exists, the controller and the routes file carry @docs docs/entities/Comment.md; a tag to a document that does not exist yet would fail guren check.
It runs neither codegen nor a migration: plan:verify runs codegen and the typecheck, and the data step generates the migration.
Every refusal comes before the first write. It refuses:
- a draft, or a plan no approval names;
- a step other than a
scaffoldortestsstep (it names the task's own scaffold step), or oneplan:nexthas not marked; - a model, validator, resource, policy, controller or side effect in a module (it writes to the project root only), and an API-only application;
- on MySQL, a key over a
textorjsoncolumn (a primary key,unique, an index, or a foreign key, which MySQL indexes), which drizzle-kit refuses and MySQL rejects without a prefix length (plan the column asstring, or drop the key), and adefaultofnull; - a resource whose name does not end in
Resource, whichguren codegenwould not discover, a policy ability named after one ofPolicy's own members (before,allow,deny), and an action named after one ofController's (redirect,json); - a policy provider it cannot register: no
src/app.tsorapp.ts, nocreateApp()call it can patch, or one that already registers it; - any target that already exists: the model file or class, the schema export, the table name in any application root, a file it would create, a validator name another validator file exports, and a resource, policy, controller or side-effect class of the same name.
Running it again on a scaffolded step is refused the same way, since its files exist; verify the step instead. --json prints the files it created, the tables it appended, the providers it registered, the routes file it left unmounted and the step that mounts it, the elements it wrote, the ones it left, what it wrote as a stub or not at all, and the relationships it left out.
Mounting the routes: plan:scaffold --mount
The http step holding the routes a scaffold wrote starts by mounting them, and plan:next names the command:
Mount the routes the scaffold step wrote first, with `bunx guren plan:scaffold docs/plans/comments/plan.json --step task/entity/model.comment/http --mount`, not by hand: it calls routes/comments.ts from the entry registrar.
Written as stubs by plan:scaffold, to finish: validator.comment, controller.comments, action.comments.store, action.comments.destroy, route.comments.store, route.comments.destroy, resource.comment, policy.comment. Each action validates and authorizes as planned and answers 501; write its body and response.
It imports registerCommentRoutes into routes/web.ts and calls it first in the registrar there. Being first, an auth alias the entry sets replaces the one the routes file sets. The mounted routes also register ahead of the entry's own, so a scaffolded path with a parameter, such as /posts/:id, can shadow an entry route like /posts/create: check the order when the two overlap. plan:status then reads the routes and their actions as wired, and the validators they use too. What is left is each action's body and response, and what the scaffold listed as a stub or not at all.
It refuses, writing nothing, a draft or a plan no approval names, a step plan:next has not marked, a step that holds no scaffolded routes (it names the one that does), a routes file that does not exist or no longer exports its registrar, an application with no routes/web.ts, an entry that already imports another binding under the registrar's name, and a file already mounted, whether the entry calls it or another routes file does.
Test skeletons: plan:scaffold on a tests step
The tests step is written by the same command. plan:scaffold <plan> --step <task>/tests writes one file, tests/plans/<plan>/<collection>.test.ts (for the comments plan, tests/plans/comments/comments.test.ts), with one TestApp test per behaviour of the step:
test('[AC-comments-1] A signed-in user can comment on a post.', async () => {
given('a post exists')
const actor = given<object>('the actor: user')
const postId = given<number | string>('the :postId parameter')
await (await client(actor)).post(`/posts/${postId}/comments`, { body: 'Nice post' }).assertStatus(302)
expect(await Comment.where({ body: 'Nice post' }).first()).not.toBeNull()
})
- The title starts with the behaviour's id, which is how
plan:verifyselects the file. A bracket in the plan's prose is written as a parenthesis, so the file carries no other id. - The request is the one its route names: the method, the path with each parameter as a whole-segment interpolation, and the
inputas the body (as the query string for aGET). - The expectations are the plan's:
status,redirect(a parameter it shares with the route reuses the route's value),inertia(the request asks for JSON, which returns the page without Inertia's version check),errors(read from the JSON body), and adatabaserow as a query through the model, for a table whose model the root declares and a value the plan's column type can compare with. Setting up those rows and cleaning them up is yours: a row another test left behind can make the expectation pass or fail whatever the code does. - The setup the plan states in prose, the signed-in actor where the route requires one, and each path parameter are
given()calls, which throw. An expectation the skeleton cannot write is anunwritten()call, which throws too, and so is an expected 404, which a route that does not exist yet answers as well. The report and the output list each one. ready()importssrc/app.tsand boots it once withTestApp.fromApp(). The file'sbeforeAllcalls it, so the ORM is configured before anybeforeEachyou add to create or clear rows. It waits up to 120 seconds, past Bun's 5-second hook default. A boot that fails is printed there rather than thrown, soplan:verifyrecords the stepblockedwhatever your hooks do, and each test that callsready()orclient()fails by name with it. Open a hook of your own withawait ready(), so the boot failure, not a database error, is what each test reports.client()returns the booted application, acting as the actor when given one, and primes CSRF withwithCsrf()when the application mounts it; an application that mounts CSRF withcookie: falseis not handled.- Only
authorauth:*middleware, a policy, or aforbiddenbehaviour gets a signed-in actor.
Before the implementation exists every test fails, either at a given() call or on the route that is not mounted yet, and none is skipped, so the step verifies as tests:fail asks. When the application does not boot, plan:verify records the step blocked, since no test reached its route. Two cases can pass before any code is written, and tests:fail then fails the step: a behaviour on a route that exists already with nothing to set up, which the report lists as may pass now, and a behaviour on a new route whose path an existing route already answers, which nothing lists. Replace each given() and unwritten() call with the setup or assertion it names. Do not turn a test into test.skip or test.todo: a skipped case is not a run, and the step fails on it. Keep each title's id and each request, since the later steps run the same file and need it to pass.
It refuses, writing nothing, a draft or a plan no approval names, a step plan:next has not marked, a file that already exists (a re-run), a behaviour another test file already carries (plan:verify would find it in two files), a request body or expected value holding another behaviour's id, and an application whose src/app.ts or app.ts has no default export to boot. An API-only application has a tests step like any other, and gets its skeletons, although it has no scaffold step. A route parameter with a constraint (:id{[0-9]+}) is a limit of the static reading: a runtime value may fail the constraint, so the request is read as uncertain rather than as reaching the route.
Outcomes
A step ends in one of four outcomes.
| Outcome | Meaning |
|---|---|
verified |
Every command passed and every element the step owns is at the state that completes it |
failed |
A command failed; there is something in the implementation to fix |
incomplete |
The commands passed, but an element is not there yet |
blocked |
The environment could not run a command: a script package.json lacks, a tool not installed, a timeout, a database that cannot be reached, a migration check with no drizzle-kit or drizzle config, or a drizzle-kit that gives no answer |
From the example, a data step whose model relationship does not typecheck yet:
task/entity/model.comment/data: failed (1254 ms)
pass codegen bun run codegen
pass db:migrate bun run db:migrate
fail typecheck bun run typecheck
`bun run typecheck` exited 1
app/Models/Post.ts(26,14): error TS2345: Argument of type '"comments"' is not assignable to parameter of type '"author"'.
The same step before the relationships were written:
task/entity/model.comment/data: incomplete (1055 ms)
pass codegen bun run codegen
pass db:migrate bun run db:migrate
pass typecheck bun run typecheck
not at its completion state: model.post: planned
not at its completion state: model.comment: drifted
And a machine where the TypeScript compiler was not on the path:
task/entity/model.comment/scaffold: blocked (354 ms)
pass codegen bun run codegen
blocked typecheck bun run typecheck
`bun run typecheck` exited 127: a tool it needs is not installed
Before a data step runs db:migrate, it asks the application's own drizzle-kit whether the migrations cover the schema (drizzle-kit generate --explain, a dry run that writes no migration and opens no database). Without that check, a table with no migration would pass, since db:migrate then has nothing to apply. From the example, the data step with its migration left out:
task/entity/model.comment/data: failed (1383 ms)
pass codegen bun run codegen
fail db:migrate drizzle-kit generate --explain
the schema has changes no migration covers: generate one with `guren make:migration`
create_table comments
create_index comments
create_index comments
create_fk
create_fk
pass typecheck bun run typecheck
Generate the migration (bunx guren make:migration --name create_comments_table), commit it and verify again. The dry run compares the whole schema with the migrations folder, so a schema change outside the plan fails the step too.
plan:verify refuses an unapproved plan before it runs anything, so nothing is recorded against a hash nobody agreed to. Past that, it executes your application: bun test boots it and db:migrate opens the database it is configured for, so run it against a development or test database, never production. Each command may take 600 seconds before it counts as blocked; --timeout <seconds> changes that. Without --step it runs every step in order and skips the ones whose record still holds; steps whose files changed since they verified are re-checked last, as described next. --ci exits 1 when a step it ran did not verify, and --json prints the report as data.
Files and lines per step
Each record also carries the work that implemented the step, printed as its work: line: the files touched and the lines added and removed since the commit HEAD named when plan:next marked the step, commits and uncommitted changes alike. The plan and its records, .guren/, lockfiles and drizzle-kit snapshots are left out, and a migration's SQL counts. The first run that verifies the step settles the numbers, so a later re-check keeps them. A step plan:next did not mark, such as one a whole-plan plan:verify ran, reads not measured with the reason, and so does a start commit a rebase took out of the history. plan:status --json lists the numbers by step id under verification.work. Nothing refuses or waits on them: they are what the default step width will be retuned from.
One step, one commit
Change only the elements a step lists, and commit it once it verifies. A verified step records a fingerprint of the files that hold its elements, of the files that wire them (the routes dispatching to an action, the controller returning a page), and of its test files. When one of them changes, the step's elements read drifted. A later step often has good reason to write into such a file: a route beside an earlier one in routes/web.ts, a table in db/schema.ts, a field on a resource. In a copy of the example, a commit after the pages step added a field to CommentResource.ts, a file the http step had verified, so most elements of http drifted:
Routes
drifted add comments.store route.comments.store
Verified 2026-09-22T10:18:13.443Z by task/entity/model.comment/http; changed since: app/Http/Resources/CommentResource.ts.
The elements verified only through a behaviour of http (its controller and policy) fell back to the state plan:status reads for them, since a step whose files changed carries no reach (see Reading progress).
plan:verify --step re-checks such steps. Once the given step verifies, the same run re-checks the earlier steps whose files changed, in task order, stopping at the first one that runs commands and does not verify. Each outcome is recorded (a failed one names what broke) and listed under "Re-checked"; plan:next then returns the earliest step left unverified, usually the one that failed. A re-check that comes out blocked is left for a later run. While the given step does not verify, the earlier records are left alone and listed as left for a later run, since the commands the steps share would fail them too.
A tests step is re-checked without running anything, because its tests pass once the code exists: it stays verified while exactly one test file carries each of its behaviour ids and each behaviour's tests still request its route, read as above, and otherwise the run names the behaviour and leaves the step drifted.
plan:next runs nothing, so when the next step has drifted, it prints the re-check command:
Verified before; files it was verified at have changed since: app/Http/Resources/CommentResource.ts.
Re-check it with `bunx guren plan:verify docs/plans/comments/plan.json --step task/entity/model.comment/http` rather than re-implementing it, fix only what that run reports, and commit once it is verified.
When every step is verified, plan:next says so:
Every step is verified. Nothing is left to implement.
The Stop hook
In an application with the agent harness (bunx guren agent:init), the plan-implement skill runs this loop, and the Stop hook of Claude Code, Codex and Cursor watches the marked step. Whenever the agent ends a turn, the hook verifies the step and sends the agent back while it is not verified:
plan:verify on stop (docs/plans/comments/plan.json, task/entity/model.comment/data): the step is incomplete, so this turn is not done (continuation 1 of 3).
The hook verifies the marked step through the same run as plan:verify --step, so earlier steps whose files changed are re-checked on every stop that verifies it, without spending a continuation. When the marked step verifies but its changes broke an earlier step, the hook lets the turn end and names that step.
It gives up after three continuations, when nothing about the step changed since the last one, when the step or one of its elements is blocked, or when something the step depends on went stale since approval. The step is then recorded as stalled:
plan:verify on stop (docs/plans/comments/plan.json, task/entity/model.comment/data): giving up, nothing about the step changed since the last continuation.
plan:next returns a stalled step again, with the reason. A stall is for a person to settle, in one of three ways: fix the environment, edit the plan and approve it, or waive the element.
A plan edited after approval stalls the step at the next stop without sending the agent back, since no continuation can approve a plan. Later stops stay silent. Approve the edited plan, or restore the approved text, and plan:next hands the step out again:
plan:verify on stop (docs/plans/comments/plan.json, task/entity/model.comment/data): giving up, docs/plans/comments/plan.json is not approved at its current hash dc9a6ce3ad173e23290f743293fa0e3495c932b3b2cdf07c0cda8c9b063a5465, so the step is not verified against it: it was edited after approval, or never approved, and what it says now may not be what anyone agreed to. Run guren plan:approve docs/plans/comments/plan.json once the plan says what you mean to build.
The step is recorded as stalled; `bunx guren plan:next docs/plans/comments/plan.json` returns it once an approval names the plan's hash.
The plan-implement skill tells the agent to report the refusal and leave approving to you.
Reading progress: plan:status
bunx guren plan:status docs/plans/comments/plan.json
plan:status compares every element with the code. It imports the routes file, the schema and the validator files, and parses source; it boots nothing, runs nothing, needs no database, and exits 0 whatever it finds. At the end of the example, with every step verified:
Validators
verified add CommentPayloadSchema validator.comment
Actions
verified add CommentController.store action.comments.store
verified add CommentController.destroy action.comments.destroy
Views
verified alter posts/Show view.posts.show
Resources
verified add CommentResource resource.comment
Policies
verified add CommentPolicy policy.comment
Elements the plan changes: 16
planned 0, present 0, wired 0, verified 16, drifted 0, unjudged 0, blocked 0, waived 0
| State | Meaning |
|---|---|
planned |
Not in the code yet |
present |
In the code, and every planned property the scanners can read matches (for a drop, absent) |
wired |
Reachable: a route mounted by createApp() that no earlier route answers first, an action such a route dispatches to, a page such an action returns, a validator such a route or action validates with, a side effect the application dispatches, emits, registers or sends |
verified |
Its step verified, and the files it fingerprinted are unchanged |
drifted |
Partly there with a property that differs, or changed since it was verified |
unjudged |
No planned property of it could be read (for an alter: none differs, and no match counts against its readings), and nothing else says whether the change happened |
blocked |
Cannot be judged here; the line says why |
waived |
Accepted incomplete by a person, with a reason |
A property no scanner reads is never counted as a match, and an element whose planned properties are all unreadable is unjudged rather than complete. A kind that has a mount point is the exception: a validator, an action, a route, a page and a side effect complete on being mounted, since the mount is a reading of the element itself. An element that plans no property at all, such as a controller, completes on existing. An alter counts only what moved since its readings at approval, whatever it mounts (see Readings of an alter, under Approving).
A route is mounted but not reached when a route registered before it, with the same method or ALL, answers every request its path matches: a planned GET /comments/new after GET /comments/:id never receives a request. Such a route stays present, and so do the action, validator and page only it reaches; so does a route that may be shadowed, where the order or the paths cannot be compared. The note names the earlier route and the routes file or module that registered it; register the planned route first, or change its path.
A side effect is mounted when the application's source, outside tests and the class's own file, uses the class through the framework's API: a job dispatched or scheduled, an event emitted, a listener registered, a mail sent or queued, a notification sent. Its step is incomplete until that use exists. Once it does, the step can verify, but the element stays wired. Which action uses it is not checked against the plan's trigger.
A verified step does not lift an element none of whose planned properties matched beyond its existence. A key a validator or resource declares, and an ability a policy declares, count as such a match: they show only that the name exists. Such an element becomes verified only while a behaviour of a step whose record stands reaches it, following the plan's own references: a behaviour's route and expected page, a route's action and bound models, an action's validators, policy and response page or resource, a page's prop resources, the model behind a reached resource or policy, and the controller of an action it reaches. A form's validator, the route the form submits to and the routes a page's buttons call do not carry reach, since a request to a route shows nothing of the page that links to it. The behaviours that count belong to the steps that must see them pass; the tests step, which verifies by seeing them fail, never carries reach. So such an element needs a behaviour that reaches it, or a waiver, before the plan can close. That covers a validator, resource or policy whose keys or abilities are all that matched, a page none of whose planned props matched, a controller (it plans no property), and an alter none of whose matches counts. No behaviour can reach a column, command, job, event, listener, mail or notification, so one of these that does not lift on its own properties closes only with a waiver. --json records why an element was not lifted under hold.
A planned body, params or query validator counts as matched when the action validates with it or a route holds it as a contract schema. An action that validates with something else, a schema built in place (this.validateBody(PostSchema.partial())) included, or through a helper, keeps the action at present with a note instead of drifting it.
A validator's fields are read off the exported zod schema, a resource's from the payload type guren codegen reads, and a policy's abilities from its member names. A missing key or ability, a field whose type or required differs from the plan, and a bound tighter than the planned one read differ, and plan:verify reports the step incomplete. Whatever the reader cannot be sure of, such as a field behind a transform, a refinement or a union, reads unknown rather than a guess, and --json gives the reason on the property. The per-construct rules are in the field-reader and policy-ability amendments to §6 of RFC 0030.
The report lists every planned property left unknown under "Planned, not checkable": one no scanner reads, one read but not decidable (a type compared only as text, a bound looser than planned), and an alter's match that already held at approval. A gap in what Guren can judge stays visible rather than passing as green:
Planned, not checkable:
column.comment.postId: references.onDelete
view.posts.show: form, actions, states
resource.comment: field id type
policy.comment: ability delete rule
For a plan with a baseline, the report ends with its approval: the time and approver when an approval names the current hash, or which commands refuse it when none does:
Not approved at this hash: plan:next, plan:scaffold, plan:verify, plan:waive, plan:close refuse the plan until guren plan:approve records an approval of it.
Verification results live in .guren/plans/, which git ignores: a result is a fact about one machine. A fresh clone and CI see every element at most wired until plan:verify has run there.
Freshness
For an approved plan, plan:status also compares each referenced element with the hash stamped at approval:
Against the approved baseline: fresh 14, stale 0, unstamped 0, unjudged 0
An element is fresh while the application holds what was stamped, or what the plan says it will hold (--json says which under basis). It is stale when another change moved it somewhere else. unstamped has no hash (its section was unreadable at approval, a revision named it later, or, for a validator, the plan was approved before validators were read), and unjudged cannot be read now; each is listed by id under the summary line. A commit elsewhere that did not touch a referenced element leaves the plan fresh.
A stale element holds every step that depends on it. In a copy of the example, another commit registered a comments.store route before the implementation started:
Held, since what they depend on changed after the plan was approved:
task/entity/model.comment/http
route.comments.store (routes, add), owned by the step; named by AC-comments-1, AC-comments-2, AC-comments-3: What the scanners read for it changed since approval, to neither what was stamped nor what the plan leaves.
fail The route name "comments.store" already exists in this application.
plan:next returns the next step that does not depend on it, exits 0, and ends with the two ways out:
A held step is a person’s decision: undo the change that moved it, or edit the plan so each stale element states what the application holds now (an `existing` action another commit renamed or removed names the one that stands in its place) and approve the edit:
bunx guren plan:approve docs/plans/comments/plan.json
Approval keeps the baseline the plan was first stamped with. Commit the edited plan and its approvals file before the next plan:next, which refuses them uncommitted.
Freshness counts the edited plan's end state, so the stale element turns fresh once the approval goes through.
Across plans: guren check --plan
bunx guren check --plan
check --plan looks at every open plan at once. A plan is found at the application root as *.plan.json, and under docs/plans/ as plan.json or *.plan.json. Open means approved at its current hash and not closed. It reports an open plan with drifted elements or with a command the check above refuses (one approved before that check existed), and two open plans that change the same element, matched by what they change in the application rather than by id. Midway through the example, with a second approved plan renaming posts.excerpt:
WARN [warn] Approved plan drifted: docs/plans/comments/plan.json has 2 drifted element(s): model.comment, resource.comment.
ℹ → Run guren plan:status docs/plans/comments/plan.json for what differs, then fix the code or revise the plan.
WARN [warn] Open plans overlap: docs/plans/comments/plan.json and docs/plans/post-summary/plan.json are both approved and open, and both change: model class Post (model.post / model.post).
ℹ → Land or close one plan before implementing the other, or revise one so they stop changing the same element.
It also warns when two plan files share a slug, since they would share one state file and one docs/plans/<slug>.md, and when a plan file or a plans directory cannot be read. Every finding is a warning and the command exits 0. The plan checks run only under --plan: plain guren check, check --ci and guren gate never include them, because they import db/schema.ts and the validator files. A draft beside an approvals file (a deleted baseline) and an approvals file that will not read are reported too. Drafts and plans edited since approval are otherwise left out, since nobody has agreed to them.
Waiving an element: plan:waive
When an element will not be finished under this plan, or nothing in the plan can judge it, a person can accept it incomplete, with a reason. A side effect is the usual case. In a copy of the example whose plan also declared a CommentPosted event, emitted from CommentController.store, the event read wired, and plan:close listed it among the elements left:
event.commentPosted: wired
No planned property of it matched beyond its existence and no behaviour can reach it, so no plan:verify run lifts it: waive it with bunx guren plan:waive docs/plans/comments/plan.json event.commentPosted --reason "<why>"
No behaviour can reach a side effect (see Reading progress), so the line names only the waiver:
bunx guren plan:waive docs/plans/comments/plan.json event.commentPosted --reason "no behaviour can observe an emitted event; the listener's own plan tests the notification"
Comments on posts (plan.json)
Waived event.commentPosted: no behaviour can observe an emitted event; the listener's own plan tests the notification
Recorded in docs/plans/comments/decisions.json
The decision log is committed with the plan. A waiver names this plan hash, so a revision does not inherit it.
The element reads waived, plan:verify leaves it out of its step's judgement, and plan:next lists it under "Waived, not to be implemented". A waiver lifts an element and nothing else: a behaviour that fails still fails its step, so a behaviour the code will not satisfy needs a changed plan instead. plan:waive refuses an existing element, an id the plan does not declare, an element of a section plan:status does not judge (flows, tasks, behaviours, questions), a draft that has no baseline yet, a plan whose current hash no approval names, and a missing --reason. --remove withdraws the waivers of the named elements and asks for none of this, so it also works on a revision that dropped a waived element. The plan-implement skill tells the agent to report a stall and leave the waiver to you.
Closing: plan:close
A plan is closed when an approval names its current hash and every element it changes is verified or waived. Until then it refuses, and names each element that is left, what holds it, and on the next line the command that moves it. In the drift above, before http was re-checked (cut to two of the eight elements it listed):
ERROR docs/plans/comments/plan.json is not closed: every element must be verified or waived with a reason (guren plan:waive), and these are not, each with what holds it and what moves it:
validator.comment: drifted (Verified 2026-09-22T10:18:13.443Z by task/entity/model.comment/http; changed since: app/Http/Resources/CommentResource.ts)
Run bunx guren plan:verify docs/plans/comments/plan.json --step task/entity/model.comment/http again, since that run no longer holds; or waive it: bunx guren plan:waive docs/plans/comments/plan.json validator.comment --reason "<why>"
controller.comments: present (Verified 2026-09-22T10:18:13.443Z by task/entity/model.comment/http, but no planned property of it matched beyond its existence and no verified run of a step whose behaviours reach it (task/entity/model.comment/http) holds now, so that result is not counted: run plan:verify on that step, or waive it)
Run bunx guren plan:verify docs/plans/comments/plan.json --step task/entity/model.comment/http; or waive it: bunx guren plan:waive docs/plans/comments/plan.json controller.comments --reason "<why>"
controller.comments is verified only through a behaviour of http, so re-checking that step lifts it too. An element below its completion state, or blocked, needs the code or the environment fixed before plan:verify. Where no plan:verify run can lift an element, the line names plan:waive. When all the element lacks is a behaviour that reaches it, the line also offers adding one and approving the plan again, except for a column, a command or a side effect, where it names only the waiver (see Waiving). An element plan:verify cannot fingerprint gets only the waiver too, since no run lifts it. plan:next prints the same lines once every step is verified, so an agent at the end of the loop still sees what keeps the plan open.
When every element is verified or waived, --dry-run prints everything the close would write. Then:
bunx guren plan:close docs/plans/comments/plan.json
Comments on posts (plan.json)
created docs/plans/comments.md
created docs/entities/Post.md
created docs/entities/Comment.md
Closed 22735cb551ac15559cd5cabc344925f8f75af7a62efe39570ac49d8c032a59c0. The plan, its approvals and its decision log stay where they are, committed; docs/spec/ stays the description of record.
docs/plans/comments.md is a record of the plan: scope, assumptions, decisions, each element's final state and the acceptance behaviours. Each entity the plan touched gets a document under docs/entities/, with a block per section between markers:
## Rules
<!-- guren:plan comments 22735cb551ac15559cd5cabc344925f8f75af7a62efe39570ac49d8c032a59c0 rules -->
- A signed-in user can comment on a post. (AC-comments-1)
- An empty comment is rejected. (AC-comments-2)
- A guest cannot comment. (AC-comments-3)
- A user cannot delete someone else's comment. (AC-comments-4)
- The comment's author is the signed-in user. (AC-comments-1, AC-comments-2, AC-comments-3)
- The signed-in user wrote the comment. (AC-comments-4)
<!-- /guren:plan comments rules -->
Edit the text outside the markers freely: closing a later plan for the same entity replaces only what is inside its own markers. A heading the close has to add is written in the plan's locale, so a ja plan gets Japanese headings. Each rule cites the behaviours that test it, and bunx guren check --docs warns about a cited id no test carries. A plan closed with waivers also prints a make:adr command per waiver, for the ones worth recording as decisions. A closed plan drops out of check --plan: the close writes closed: true and the plan's hash into docs/plans/<slug>.md, and a revision approved after the close counts as open again. Nothing is deleted: the plan, its approvals and its decision log stay committed, and docs/spec/ from bunx guren spec:generate stays the description of what the code is.
Not available yet
The RFC behind this feature (rfcs/0030-implementation-plans.md) describes more than the commands on this page. These parts do not exist yet:
- a
guren planthat asks Claude for the plan JSON by itself (--print-promptis the form that exists), andplan --revise, which would turn review comments into a revision (plan:reviserecords a change you make yourself). Writeplan.jsonyourself or in your agent session; - keeping plans in GitHub issues instead of
docs/plans/; - pages from
plan:scaffold. It writes every other element of a slice the plan adds, with the action bodies and responses left for thehttpstep. It will not write pages: a page written from the plan's props would match the plan by construction.
Next steps
- Spec-Anchored Development: the entity documents and doc links a closed plan feeds
- Testing:
TestApp,actingAs()andwithCsrf()for acceptance tests - CLI: the rest of the commands