The engine loop

propose, carol:accepted, design, specify, PR review, run, report, release, sign-off and phase review: how Carol moves a phase from proposal to done as a GitHub App, one tick at a time, and answers its tickets.

Carol v0.1.22

In the sync commands you type every step. In the engine loop Carol takes the steps itself, as a GitHub App, and you steer from GitHub: a label on a proposal, a review on a pull request, a reply to a question.

propose opens a proposal carol:accepted you add it specify opens the PRD PR PRD review you approve run opens the run PR report verdict on epic Run review you approve Merged phase review propose opens a proposal carol:accepted you add it specify opens the PRD PR PRD review you approve run opens the run PR report verdict on epic Run review you approve Merged phase review
Highlighted steps are yours. carol tick takes the next step for every phase, and carol watch repeats it.

The loop needs the GitHub App set up first: every step below writes as the App's bot, and every human step is checked against your acceptors. Each command is also a plan-then-apply command like the rest of Carol, so you can run any step by hand before you let watch run them all.

1 · Propose

carol propose docs/billing/scope.md [--apply]

For every Phases row that isn't done and has no epic, propose opens one proposal issue, titled Proposal: <title> (<phase>) and labelled carol:proposed. Each proposal is blocked by the proposals or epics of the rows in its Depends on cell, and by the tickets that block the phase. Once the phase gets its epic, propose closes the proposal with a pointer to it.

propose also ranks every open proposal and epic with one priority:* label and prints the rule that matched:

LabelWhen
priority:p0The epic has an open follow-up issue from a failed or blocked task
priority:p1Unblocked, and other open phases depend on it
priority:p2Unblocked, and no open phase depends on it
priority:p3An open blocker: a dependency not done, an open ticket, or a contract not yet agreed

A priority:* label that a person set last is never changed. Re-rank by hand and Carol leaves it alone. A label that a bot set last is Carol's to rewrite, whichever App set it, so moving Carol to a new GitHub App doesn't freeze the old App's labels.

2 · Accept: carol:accepted

This is the first human gate. An acceptor adds the carol:accepted label to the proposal they want built. On its next run, propose reports each proposal as one of:

  • accepted: the label's latest event was added by a person whose user id is in [identity].acceptors;
  • unaccepted: no such label;
  • unauthorised: someone else added it, or the App did. Carol posts one comment saying so and leaves the label where it is.

Before specify will draft the PRD, the row needs two things in scope.md: its Status set to ready, and a Design cell naming a design-record section, design/<topic>.md#<heading>. See Planning files. Fill both yourself, or, under watch, accept a row that has no Design cell and let a design session write them.

No Design cell: the design session

A tick runs a design session once an acceptor has accepted a proposal whose row is planned or ready with no Design cell and no PRD, the proposal is neither carol:waiting nor carol:halt, and no pull request carries the row's design marker. A row that has a Design cell goes straight to specify.

The session runs zurdo-design-author on carol/design-<phase>, off the default branch, with the proposal's thread. It interviews first: its questions go on the proposal as one numbered comment, the proposal is labelled carol:waiting, and nothing is pushed. Answer them as on any question, and the next tick runs the session again with your answers. Once it has them, it writes the design record under the initiative's design/ directory, fills the row's Design cell and sets the row to ready.

When the session's commits leave the row ready, with a Design cell whose record exists on the branch, Carol pushes the branch as the App and opens the pull request design: <phase> — <title>, with Refs #<proposal>, the commits and the session's handoff. Anything else labels the proposal carol:halt and pushes nothing. A branch already pushed with no pull request gets its pull request, not a second session.

Merge the design pull request by hand: watch never merges it. The row is then ready with its Design cell, and the next tick plans specify for it. A tick that reads the scope from the remote, because your checkout is clean and only behind it, also looks for the design record there, so specify isn't refused before you pull.

3 · Specify

carol specify docs/billing/scope.md phase-02 [--apply]

carol specify drafts the phase's PRD and opens it as a pull request. The plan shows what it will do and runs nothing. With --apply it:

  1. makes a worktree at .carol/worktrees/<phase>, off the remote's default branch, so your checkout is never touched;
  2. runs zurdo scaffold on the Design section to draft the PRD;
  3. fills the row's PRD cell, sets its Status to running, commits as the App's bot and pushes carol/specify-<phase>;
  4. opens the pull request PRD: <title> (<phase>), assigned to the acceptor;
  5. publishes the draft's epic and tasks as the App, puts them on the board with your own gh login (it needs the project scope), and labels the proposal carol:specified.

specify refuses with exit 4 a row that already has a PRD cell, isn't ready, has no Design cell, or has no accepted proposal. A second apply writes nothing, and a failed apply resumes from the pushed branch. If zurdo scaffold fails, its exit code passes through and its last attempt stays under .zurdo/scaffold/<phase>/. It needs Zurdo 1.30.0 or later and an installed zurdo-prd-author skill. carol doctor checks both.

4 · The PRD review

The draft carries Zurdo's unreviewed-scaffold marker, so zurdo validate --strict fails on it until someone reviews it.

  • Under watch, the next tick runs an agent session with zurdo-prd-author on the pull request's branch and pushes its commits as the App. Each push gets one <!-- carol:h2 --> comment on the pull request that lists the commits and says what comes next: more questions, ready for an approving review, or halted. If the session has questions, Carol posts them in one comment, numbered, each with a recommended answer, and labels the pull request carol:waiting. Reply as an acceptor, or add carol:take-recommended, and the next tick reruns the review with your answers. The comment for that rerun's push quotes each answer under its question. Both formats are on Human gates.
  • By hand, check out the branch and run zurdo-prd-author yourself, from its "Entering from a draft" path, until it reaches ✓ READY TO RUN.

Then approve the pull request. Once enough acceptors have approved its latest commit (see Human gates), watch merges it as the App and deletes the carol/specify-<phase> branch. Merging is the step from ready to running, because the pull request already set the row's Status. Before running, the next tick re-publishes the PRD as merged, so the task issues match the reviewed tasks rather than the draft's.

5 · Run

carol run docs/billing/scope.md phase-02 [--apply]

For a running row whose PRD pull request has merged, run labels the epic carol:running, then runs zurdo run <prd> --no-prompt in its own worktree, on carol/run-<phase> off the default branch. It blocks until Zurdo exits.

  • Zurdo's run state is copied to .zurdo/<slug>/ in your checkout, so zurdo report and carol status see it.
  • Zurdo's commits are kept, and anything it left uncommitted becomes one commit by the App's bot.
  • run pushes and opens the run pull request as the App, with Closes #<epic>. It opens it as a draft when Zurdo exited 5 or 6, and exits 0 with Zurdo's own code in data.zurdo_exit.
  • Then it posts Zurdo's report of the run on the run pull request, as a run-report comment: the marker line, a summary for people (the tally, a row per task with its issue, criteria and attempts, and what still fails), then the JSON zurdo report returned, in a collapsed block. That JSON is the run state the rest of the loop reads.
  • Zurdo's exits 1 to 4 pass through with nothing pushed.
<!-- carol:run-report -->
### Zurdo run: 2 ✅ passed · 1 ❌ failed

`docs/billing/prds/prd-02-refunds.md` · 1h 5m · ~$7.12

| Issue | Task | | Criteria | Attempts |
|---|---|---|---|---|
| #12 | task-01 Refund model | ✅ passed | 3/3 | 1 |
…

**Still failing**
- task-03: `shell: cargo test refunds`

<details><summary>Zurdo's report (JSON, read by Carol)</summary>

```json
{"prd_path": "docs/billing/prds/prd-02-refunds.md", "started_at": "…", "tasks": [ … ], …}
```

</details>

A run-report comment posted before v0.1.20 holds the bare JSON after the marker line. report still reads it.

A run can take longer than the App's token lives, so Carol mints a fresh token before it pushes and before it writes to the pull request. See GitHub App setup.

The plan lists the steps still to do, from what already exists: label, run, commit, push, pull_request and run_report. A run whose branch is pushed and whose pull request is open but has no run-report comment plans only run_report, which posts the report from your checkout's run state without running Zurdo again.

run refuses a row that isn't running, has no epic, has an unmerged PRD pull request, or has an epic labelled carol:halt.

6 · Report

carol report docs/billing/scope.md phase-02 [--apply]

report reads the run's state from the newest run-report comment on the run pull request, never from .zurdo/, so any checkout of the repository can report a run, not only the one that ran it. Without that comment it refuses with exit 4. Run carol run again from the checkout that ran it, and it posts the comment. Then, as the App, it:

  • runs carol sync's plan, so each task issue gets its outcome, labels and closing;
  • posts one verdict comment on the epic: the tally, a line per task naming its issue (task-02 (#14): passed-pending-review, 1 attempt), the run pull request, and a line for each [manual] criterion waiting on a person, which says to add carol:signed on the task's issue with the evidence, or to run zurdo review --sign-off <task>:<n>;
  • opens one priority:p0 follow-up issue under the epic for each failed or blocked task, quoting its last failing hint;
  • labels the epic carol:reported.

7 · The run review

Under watch, once a run is reported, a tick runs an agent session with zurdo-prd-review on the run pull request's branch and posts its verdict as the App, in a <!-- carol:h3 --> comment.

The session gives Carol the facts, and Carol lays out the comment, so every verdict reads the same way. It opens with the PRD's title and one bold line that says how many tasks landed and what to do now, then a table with one row per task. Each row names the task's issue, its verdict (landed, landed-with-drift, vacuous-pass or missed), how many acceptance criteria passed, the attempts it took and what it did, in one line. The evidence for each criterion is folded away under the table:

<!-- carol:h3 -->

## Run review: Refund limits

**Landed — 2 of 2 tasks landed. Approve to merge.**

| Task | Verdict | Criteria | Attempts | In one line |
|---|---|---|---|---|
| #14 task-01 | landed | 4 of 4 | 1 | Refunds above the limit are refused with the limit named |
| #15 task-02 | landed | 3 of 3 | 2 | A refund over the limit waits for a second approver |

<details>
<summary>Evidence per task</summary>
…
</details>

### For later

- **backlog**: The limit is per refund, not per day.

For later lists what the run leaves for someone else, each note led by where it goes: a phase or backlog. Not checked follows when the session couldn't check something. Either is left out when it's empty. When a follow-up ran, the bold line ends A follow-up ran; see below. instead of Approve to merge.

A session that writes only a prose verdict gets it posted as written. If Carol can't read the session's structured review, the comment's first line says so and the prose verdict follows. Neither halts the pull request.

If the verdict is that a follow-up PRD is needed, Carol runs zurdo run on that follow-up, on the same branch, and pushes before it posts the verdict. It posts the follow-up's own run-report comment on the pull request too. The verdict then ends with a Follow-up result section, so it says where the pull request stands now, not only what the review found before the follow-up:

---

### Follow-up result

`docs/billing/prds/prd-02b-refund-limits.md` ran on `carol/run-phase-02` and was pushed (zurdo exit 0).

| Task | Status | Attempts | Criteria |
|---|---|---|---|
| task-01: Cap refunds at the limit | passed | 1/3 | 4/4 |
| task-02: Ask for a second approver | passed | 2/3 | 3/3 |

2 of 2 tasks passed.

A follow-up can pass without changing anything, when the review session committed the fix itself and the follow-up only re-runs the criteria. zurdo then passes it at pre-flight, and the Follow-up result says it left nothing new to push (zurdo exit 0). That still counts as a passed follow-up, and the verdict keeps its carol:h3 marker.

If the follow-up run fails, Carol still posts the review's text with a Follow-up result section that says why, but without the carol:h3 marker, and labels the pull request carol:halt. A run whose follow-up failed never counts as reviewed, so it can't merge.

A run pull request merges only after that verdict, and only once enough acceptors approve its latest commit. Carol then deletes its carol/run-<phase> branch. The release and the phase review follow under watch.

A session that fails, leaves the draft unreviewed with no questions, or leaves no verdict labels the pull request carol:halt. Carol plans nothing more for that phase until a person looks into it and removes the label.

8 · Release

Once a phase's run pull request has merged, a tick opens the release pull request. Carol reads the version in the root Cargo.toml on the default branch and adds one to its patch. A repository with no root Cargo.toml has nothing to release, and gets no release step. On a carol/release-v<version> branch off the default branch, Carol:

  • sets the [package] version in Cargo.toml and the root package's version in Cargo.lock, and nothing else;
  • cuts CHANGELOG.md: ## [Unreleased] becomes ## [<version>] — <date>, its notes kept under it, followed by an entry naming the PRD and each task that passed. A fresh, empty ## [Unreleased] goes above it, and the compare links move to the new version;
  • runs an agent session on that branch to update your user docs to the new version, as your CLAUDE.md's release steps say, and to add release notes under the version's heading. A session that commits nothing pushes nothing, and the step is refused;
  • pushes the branch as the App and opens the pull request release: v<version> — <title>.

While any epic is carol:running, the release waits for that run to finish. A branch already pushed with no pull request gets its pull request, not a second session.

You merge the release pull request by hand. watch never merges it. Then the next tick pushes an annotated v<version> tag on its merge commit, as the App, so your release workflow publishes it. Tagging is the only step Carol can't take back, and it never happens before your merge. Closing the release pull request declines the release, and Carol never opens another for that phase. A row already done, or a tag that already exists, gets no release.

9 · Sign-off

A task with a [manual] criterion passes as passed-pending-review, and its issue keeps zurdo:pending-review until a person signs it. You can sign from your terminal with zurdo review --sign-off <task>:<n>, or from GitHub:

  1. Comment the evidence on the task issue.
  2. Add the carol:signed label. report creates it once a verdict names a sign-off.

The next tick signs each unsigned [manual] criterion of that task as zurdo review <prd> --sign-off <task>:<n> --by <login> --note <note>, in watch's checkout, where the run state was copied. The signer is the acceptor who left the latest non-blank comment on the issue, matched by user id, and the note is that comment on one line. Once zurdo reports the task passed, Carol removes zurdo:pending-review and closes the issue, so it's never signed twice. A comment from anyone who isn't an acceptor doesn't sign.

10 · Phase review

The phase review is due once the run pull request has merged, the phase has task issues and every one reads passed (closed, with neither zurdo:failed nor zurdo:pending-review), and the row isn't done yet. So a task waiting on a sign-off holds it.

A tick then runs an agent session on carol/review-<phase>, off the default branch. Its prompt names the phase, scope.md, the PRD and the design record, and carries the run's verdict from the epic. The session writes a Decisions line, sets the row to done and records the design record's status. Carol pushes the branch as the App and opens the pull request review: <phase> done (<title>).

Check the work and merge it by hand. watch never merges it. Once it merges, the row is done and propose moves on to the next phase. Closing it declines the review, and Carol never opens another. A branch already pushed with no pull request gets its pull request, not a second session. An epic labelled carol:halt gets neither a release nor a phase review.

Tickets in the loop

A ticket asks a question a phase waits on. Each tick looks at every ticket whose file says status: open and whose issue is open.

  • Research. Add carol:accepted to a research ticket's issue, as an acceptor, to have an agent answer it. A tick then runs a session with zurdo-project on carol/findings-<ticket>, off the default branch. It writes a ## Findings section in the ticket file, with a source for each finding, and sets status: resolved. When its commits leave the file that way, Carol pushes the branch as the App and opens the pull request research: <ticket>, with Refs #<issue>. Anything else labels the ticket issue carol:halt. A research ticket nobody accepted starts nothing, and a ticket issue that is carol:waiting or carol:halt, or already has a findings pull request, gets no session. Check the findings and merge the pull request by hand. The next carol ticket or carol scope posts them and closes the issue.
  • Grilling. A grilling ticket's question is yours to answer, so no session runs. Carol posts its question on the ticket's issue as one question comment, with No recommendation: in place of a recommended answer, and labels the issue carol:waiting. It asks each issue once. Reply as an acceptor, and the next tick removes carol:waiting. Writing the answer into the ticket file and scope.md stays your step.

One step at a time: carol tick

carol tick docs/billing/scope.md [--format json] [--apply]

tick reads the tracker once and plans, for each phase, the one transition that is due. It makes every read first, then decides from what it read alone (see Internals):

StepDue when
proposeA row has no epic and no proposal (at most [limits] proposals_per_tick per tick)
designAn accepted planned or ready row has no Design cell, no PRD and no design pull request, and its proposal isn't waiting or halted
specifyAn accepted, ready row has a Design cell
H2 sessionA PRD pull request still carries the scaffold marker
mergeA PRD or run pull request has its approvals on its latest commit. Carol deletes its branch after.
publishThe merged PRD differs from its task issues
runA running row's PRD pull request merged, and fewer than [limits] in_flight epics are carol:running
reportA carol:running epic's run pull request exists. It's refused until the pull request carries a run-report comment.
H3 sessionA run is reported and has no verdict yet
releaseThe run pull request merged and the phase has no release pull request, or its release pull request merged and isn't tagged yet
phase-reviewThe run pull request merged, every task issue reads passed, the row isn't done, and no phase-review pull request exists
sign-offAn open task issue carries carol:signed and zurdo:pending-review, and an acceptor commented on it. Planned beside the phase's one transition.
researchAn acceptor accepted an open research ticket, and it has no findings pull request. Named for the ticket.
grillingAn open grilling ticket's issue has no question comment yet. Named for the ticket.

A transition that has a command of its own is planned by the same planner as that command, and applied through its own apply path. Before each one, Carol mints a fresh App token once the current one is 45 minutes old. An item that moves waits for the next tick, so one tick never takes a phase two steps. An epic labelled carol:reported gets no more run or report, only its run review, merge, release and phase review. An epic labelled carol:halt gets nothing more, except the sign-offs acceptors ask for on its task issues.

acme/shop
$ carol tick docs/billing/scope.md
tick: acme/shop (2 transitions, 0 refused)
  phase-02 run (2 ops)
  phase-03 propose (3 ops)
A plan, as always. Add --apply to take the steps.

If a due item's command refuses, the refusal goes to stderr and into data.refusals, the other items still go ahead, and tick still exits 0.

Keep it going: carol watch

carol watch docs/billing/scope.md [--interval 10m] --apply

carol watch runs tick --apply in a loop, in the foreground, --interval apart (<n>s, <n>m or <n>h, default 10m). Run it in a tmux window. --apply is required: without it, watch exits 2.

tmux: carol
$ carol watch docs/billing/scope.md --apply
2026-10-14T09:00:02.114Z acme/shop: phase-02 run, phase-03 propose (0 refused)
2026-10-14T09:41:37.502Z acme/shop: phase-02 report (0 refused)
2026-10-14T09:51:38.020Z acme/shop: nothing due (0 refused)
One line per tick: when it started, the repository, and what it applied.
  • Ctrl-C during the sleep ends watch at once, with exit 0. During a tick, it lets the tick finish first.
  • The lock. Every --apply holds one lock per repository. watch takes it for each tick and releases it while it sleeps, so you can type an --apply between ticks. A tick that finds the lock taken is skipped.
  • Failures. A tracker failure is printed and watch carries on. A usage or identity error stops it.
  • watch never forks or daemonises. When you close the window, the loop stops.
  • Each tick also keeps one pinned digest issue up to date, listing what waits on a person. See Human gates.

Next: Human gates