Plan, implement, review
The problem: take a ticket to a reviewed change, with a plan checked before any code is written, and pick up where it stopped when a step fails, without redoing the steps that worked.
The workflow:examples/feature-delivery. Five stages, one after the other:
| Stage | Who | What |
|---|---|---|
ticket-doc | planner, claude | writes an implementation-ready ticket doc, with a skill that says what one holds |
doc-review | reviewer, codex | gates the doc; asks the planner for each revision by message |
implementation | implementer, codex | builds the change from the doc, and records decisions in it |
implementation-review | reviewer | gates the change the same way |
additional-review | one per --reviewer | more reviewers at once; the implementer fixes what they ask, and the reviewer checks again |
It needs claude and codex logged in, and Herdr, since the planner and the implementer run in their TUIs; Install has both. From a clone of the repository, after bun install, with --cwd at the repository the ticket is for:
bun awf run --cwd ../my-app --run-root ~/awf-runs examples/feature-delivery/workflow.ts -- ABC-1
bun awf run --cwd ../my-app --run-root ~/awf-runs examples/feature-delivery/workflow.ts --id ABC-1 --continue--revisions {n} bounds each review's revisions, 3 by default, and --reviewer {name}={runtime}, given once per reviewer, adds an additional reviewer: --reviewer security=claude. The workflow's id: (args) => args.ticket names the run after the ticket, so the continue finds it with --id ABC-1.
Cost and time: not recorded yet. They come from a recorded run of the example, priced at list prices; Cost has where an estimate comes from.
The workflow
async function deliverFeature(wf: WorkflowContext, args: FeatureArgs): Promise<Handoff> {
const { ticket } = args;
const team = await openTeam(wf, args);
const { planner, reviewer } = team;
const reviewUntilApproved = reviewLoop(wf, reviewer, args.maxRevisions);
const plan = await wf.stage("ticket-doc", { result: WORK, summary: (work) => work.docPath }, () =>
planner.ask(WORK, {
label: "Create ticket doc",
prompt: md`Create an implementation-ready ticket doc for ${ticket}.`,
}),
);
const approvedPlan = await wf.stage(
"doc-review",
{ result: REVIEWED, summary: (review) => review.summary },
() => reviewUntilApproved(planner, plan, ticketDoc(ticket)),
);
const { docPath } = approvedPlan.work;
const implementer = await team.implementer();
const built = await wf.stage(
"implementation",
{ result: WORK, summary: (work) => work.summary },
() =>
workOn(implementer, docPath, {
label: "Implement feature",
prompt: md`
Implement ${ticket} from ${docPath}.
Update that document with the decisions made and any deviations from the plan.
`,
}),
);
const approvedBuild = await wf.stage(
"implementation-review",
{ result: REVIEWED, summary: (review) => review.summary },
() => reviewUntilApproved(implementer, built, implementation(ticket)),
);
const { final, additional } = await wf.stage(
"additional-review",
{ result: ADDITIONAL, summary: ({ additional }) => tally(additional) },
async () => {
const additional = await additionalReviews(
wf,
args,
team.additionalReviewer,
approvedBuild.work,
);
const asked = requestedChanges(additional);
if (asked.length === 0) return { final: approvedBuild, additional };
const fixed = await workOn(implementer, docPath, {
label: "Apply additional review",
prompt: applyFeedback(ticket, approvedBuild.work, asked),
});
const final = await reviewUntilApproved(implementer, fixed, implementation(ticket, asked));
return { final, additional };
},
);
return {
docPath,
summary: final.work.summary,
decisions: final.work.decisions,
review: { primary: final.summary, additional },
};
}
export const featureDelivery = defineWorkflow<FeatureArgs, Handoff>({
meta: {
name: "feature-delivery",
description: "Plan, implement, review, and prepare a feature for human review.",
version: "1.0.0",
},
run: deliverFeature,
prepare: ({ argv }) => parseArgs(argv),
id: (args) => args.ticket,
present: ({ docPath, summary, review }) =>
[summary, docPath, tally(review.additional)].join("\n"),
});
export default featureDelivery;planner, reviewer and team.implementer() come from team.ts; each is a small wrapper from session.ts whose ask returns the typed answer or stops the run, and whose stop names the agent in the reason. md and list, from prompt.ts, write a prompt inline.
The review loop it runs at each gate:
/**
* Returns the loop where the reviewer owns the revisions: it messages the author the changes it
* needs and reviews each revision the author replies with, until it approves. Code cannot read the
* revisions, so the work as it stands after review comes from one more turn of the author's.
*/
export function reviewLoop(wf: WorkflowContext, reviewer: Session, maxRevisions = 3) {
return async (author: Session, work: WorkUpdate, subject: Subject): Promise<Reviewed> => {
const revising = maxRevisions > 0;
if (revising) {
await wf.messages.allow({
from: reviewer.agent,
to: author.agent,
purpose: `Revise the ${subject.name} until the reviewer approves it.`,
maxMessages: 2 * maxRevisions,
});
}
const verdict = await reviewer.ask(VERDICT, {
label: `Review ${subject.name}`,
prompt: revising
? md`
${subject.review(work)}
You own the revision loop. When changes are needed, send them with
\`wfa send --await-reply ${author.agent.key}\` and end your turn; review the revision
it reports. Return your verdict when it is ready, or the changes still needed once
the route's limit is reached.
`
: subject.review(work),
});
if (verdict.kind === "inconclusive") return reviewer.stop(verdict.reason);
if (verdict.kind === "changes-requested") {
return wf.stop(`${subject.name}: not approved; ${verdict.feedback.length} changes open`);
}
const final = await workOn(author, work.docPath, {
label: `Record ${subject.name}`,
prompt: md`Report ${work.docPath} as it stands after review, with every decision made.`,
});
return { summary: verdict.summary, work: final };
};
}The rest is in the folder: cli.ts reads the arguments and schema.ts holds the answers' schemas. Its test covers a full run, each kind of stop, and continues.
Why it's shaped this way
- Each step is a stage, and its value is what the next steps need. A stage records its value when it succeeds, and
--continuereuses it, so a stop inimplementation-reviewkeeps the plan and the build. The continue opens fresh agents, so every prompt says everything its agent needs: the ticket, the doc's path, what the author said it did. Nothing depends on an agent remembering an earlier attempt. Durability has how a continue works. - The plan is reviewed before code exists. A wrong plan caught in
doc-reviewcosts one planner revision; caught after implementation, it costs the build. The implementer isn't even opened until the plan is approved. - The ticket doc is the record. The planner writes it, the implementer keeps it current with each decision, and both reviews read it. An author whose answer names a different doc stops the run, so the record can't drift to another file.
- The reviewer is on another model.
team.tsrefuses a planner and reviewer on the same model, so the gate doesn't share the author's blind spots. - The reviewer owns the revisions. It messages the author each change it needs and reviews the revision it gets back, up to
--revisionsof them: the review loop, inside a stage, so its route ends with the stage. The reviewer runs headless because a codex TUI agent can't wait for a reply. - After approval, the author reports the work once more. Code can't read the revisions that happened in messages, so the stage's value comes from one more author turn, after review.
- Every way of not finishing stops the run with a reason. No answer, an inconclusive review, or changes still asked for when the revisions run out each end the attempt
stopped, naming who stopped it and why, ready for a person and a continue. A stop exits 3: Runs has the exit codes.
Catches
- The prompt names only the ticket. The planner needs a way to read it: a file in the repository, or a tracker its tools reach.
- Keep the run's records outside the repository. The planner's skill is copied into the run's records, and claude takes no skill from inside its working directory, so
--run-rootpoints elsewhere. Give the same--run-rootto the continue, so it finds the run. - A continue redoes the whole stage that stopped, a review loop included, from its first round.
- The bound is on revisions, not time. Give the run a deadline with
--timeoutwhen it shouldn't run longer than you'd wait: Timeouts.
See also
- Stages and Messaging in the workflow API.
- Runs and Durability in the guide.