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.
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:
| Label | When |
|---|---|
priority:p0 | The epic has an open follow-up issue from a failed or blocked task |
priority:p1 | Unblocked, and other open phases depend on it |
priority:p2 | Unblocked, and no open phase depends on it |
priority:p3 | An 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:
- makes a worktree at
.carol/worktrees/<phase>, off the remote's default branch, so your checkout is never touched; - runs
zurdo scaffoldon the Design section to draft the PRD; - fills the row's PRD cell, sets its Status to
running, commits as the App's bot and pushescarol/specify-<phase>; - opens the pull request
PRD: <title> (<phase>), assigned to the acceptor; - publishes the draft's epic and tasks as the App, puts them on the board with your own
ghlogin (it needs theprojectscope), and labels the proposalcarol: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 withzurdo-prd-authoron 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 requestcarol:waiting. Reply as an acceptor, or addcarol: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-authoryourself, 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, sozurdo reportandcarol statussee it. - Zurdo's commits are kept, and anything it left uncommitted becomes one commit by the App's bot.
runpushes and opens the run pull request as the App, withCloses #<epic>. It opens it as a draft when Zurdo exited 5 or 6, and exits 0 with Zurdo's own code indata.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 reportreturned, 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 addcarol:signedon the task's issue with the evidence, or to runzurdo review --sign-off <task>:<n>; - opens one
priority:p0follow-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 inCargo.tomland the root package's version inCargo.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:
- Comment the evidence on the task issue.
- Add the
carol:signedlabel.reportcreates 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:acceptedto a research ticket's issue, as an acceptor, to have an agent answer it. A tick then runs a session withzurdo-projectoncarol/findings-<ticket>, off the default branch. It writes a## Findingssection in the ticket file, with a source for each finding, and setsstatus: resolved. When its commits leave the file that way, Carol pushes the branch as the App and opens the pull requestresearch: <ticket>, withRefs #<issue>. Anything else labels the ticket issuecarol:halt. A research ticket nobody accepted starts nothing, and a ticket issue that iscarol:waitingorcarol:halt, or already has a findings pull request, gets no session. Check the findings and merge the pull request by hand. The nextcarol ticketorcarol scopeposts them and closes the issue. - Grilling. A grilling ticket's question is yours to answer, so no session runs. Carol posts its
questionon the ticket's issue as one question comment, withNo recommendation:in place of a recommended answer, and labels the issuecarol:waiting. It asks each issue once. Reply as an acceptor, and the next tick removescarol:waiting. Writing the answer into the ticket file andscope.mdstays 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):
| Step | Due when |
|---|---|
propose | A row has no epic and no proposal (at most [limits] proposals_per_tick per tick) |
design | An accepted planned or ready row has no Design cell, no PRD and no design pull request, and its proposal isn't waiting or halted |
specify | An accepted, ready row has a Design cell |
| H2 session | A PRD pull request still carries the scaffold marker |
| merge | A PRD or run pull request has its approvals on its latest commit. Carol deletes its branch after. |
publish | The merged PRD differs from its task issues |
run | A running row's PRD pull request merged, and fewer than [limits] in_flight epics are carol:running |
report | A carol:running epic's run pull request exists. It's refused until the pull request carries a run-report comment. |
| H3 session | A run is reported and has no verdict yet |
release | The run pull request merged and the phase has no release pull request, or its release pull request merged and isn't tagged yet |
phase-review | The run pull request merged, every task issue reads passed, the row isn't done, and no phase-review pull request exists |
sign-off | An open task issue carries carol:signed and zurdo:pending-review, and an acceptor commented on it. Planned beside the phase's one transition. |
research | An acceptor accepted an open research ticket, and it has no findings pull request. Named for the ticket. |
grilling | An 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.
$ carol tick docs/billing/scope.md
tick: acme/shop (2 transitions, 0 refused)
phase-02 run (2 ops)
phase-03 propose (3 ops)
--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.
$ 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)
- Ctrl-C during the sleep ends
watchat once, with exit 0. During a tick, it lets the tick finish first. - The lock. Every
--applyholds one lock per repository.watchtakes it for each tick and releases it while it sleeps, so you can type an--applybetween ticks. A tick that finds the lock taken is skipped. - Failures. A tracker failure is printed and
watchcarries on. A usage or identity error stops it. watchnever 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