Skip to content

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:

StageWhoWhat
ticket-docplanner, claudewrites an implementation-ready ticket doc, with a skill that says what one holds
doc-reviewreviewer, codexgates the doc; asks the planner for each revision by message
implementationimplementer, codexbuilds the change from the doc, and records decisions in it
implementation-reviewreviewergates the change the same way
additional-reviewone per --reviewermore 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:

sh
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 ​

ts
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:

ts
/**
 * 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 --continue reuses it, so a stop in implementation-review keeps 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-review costs 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.ts refuses 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 --revisions of 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-root points elsewhere. Give the same --run-root to 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 --timeout when it shouldn't run longer than you'd wait: Timeouts.

See also ​