Internals
How Carol v0.1.22 works underneath: plans and read-back, the stateless tick, markers and idempotency, the run-state mapping, handoff freshness, and the status JSON.
Carol v0.1.22
You don't need this page to use Carol. It explains what the commands do underneath, for when an output surprises you or you write a script against it.
Plan, apply, read back
Every writing command is a pure function from two inputs to a plan: what the planning files want, and one snapshot of the tracker.
The plan has no I/O of its own, so the same inputs always give the same plan.
- The printed plan is the applied plan.
--applyexecutes the value it printed, not a recomputed one. - Carol never invents an issue number. A body that quotes an issue the plan hasn't created yet is a template, filled in once that create lands.
- Every write is read back. Labels compare as a set. A field that didn't stick stops the run with exit 5, naming it.
- Parsing comes first. Every planning file is read before the tracker is opened, so a malformed file can't leave a half-applied plan.
The tick
A tick is three steps.
- Gather. Every read happens first: the tracker snapshot, the reviews, comments and label events of the items that need them, and the scope and PRD texts on the remote's branches. They go into one value, the tick's snapshot. A read that fails is kept in it as its error message, so that item's gate stays in force.
- Plan.
plan_tickdecides every transition and the digest from that one value. It starts no process, reads no file and calls no tracker, so a test can build the value by hand and check what a tick would do.specify,runandreportdecide with the same pure planners when you type them: each command makes its own reads, then hands them in. - Apply. Every write happens last, in plan order. Long-running work goes through an
Executor: it runszurdo runon a branch, and an agent session on a pull request's branch or on the design, findings, release or phase-review branch it is about to open. The only one today,LocalExecutor, runs both as child processes on this machine.
The output envelope, the journal lines and the order of transitions are the same as before the split.
Markers
A marker is the hidden comment in an item's body that gives it its identity: <!-- zurdo-github prd=<path> task=task-03 -->. Carol finds items by marker, never by number or title. That is what makes every command safe to re-run:
- An item whose marker is missing is planned as a create.
- An item whose marker is found is diffed field by field. Only the differences become ops, so a rename renames.
- An item that already matches plans nothing. A full re-run on a synced tree prints
0 ops. - Two items with one marker are one object, and ambiguity is refused. Carol won't guess which is real. It stops with exit 4 and names the marker.
Pull requests are matched the same way, among open ones only.
Comments carry their own markers, so a notice or verdict is posted once per event. A sync comment's marker includes the run's started_at, so the same run posts nothing new and a new run posts again.
The zurdo-github prefix comes from the zurdo-github.sh script that Carol ports. Each deliberate difference from the script is numbered on a drift list in Carol's repository. Every marker format is on Labels & markers.
The lock and the journal
Every --apply takes an advisory flock on $(git rev-parse --git-common-dir)/carol/lock, through a Lock whose only implementation today is that flock. It lives in the common git directory, so worktrees share their repository's lock and your checkout never sees it.
The kernel drops the lock however the holder exits. A second --apply exits 4, naming the holder's pid and command.
Plans, --help and status take no lock. watch takes it per tick and releases it while it sleeps.
Each applied op appends one JSON line to journal.jsonl beside the lock: at, command, tick, op, target, actor and read_back. It is append-only and is a record for people. Carol never reads it to decide anything.
Run state
carol status reads a phase's run only from Zurdo, through zurdo state list and zurdo report. It matches a run to a phase by the PRD path in the Phases row.
| Zurdo's run status | Carol shows | Means |
|---|---|---|
| No run for the PRD | no run | The PRD hasn't been run, or the row has no PRD |
active | in flight | A live zurdo run holds the run's lock |
clean | settled | The run ended. A tally follows. |
stale | crashed | A lock left by a run that died |
The tally comes from zurdo report's totals: passed, passed_pending_review, failed and blocked_by_dependency. Any other status Zurdo reports is shown as it is.
The engine loop keeps a run's state on the tracker instead. After a run, and after a run review's follow-up run, Carol posts a <!-- carol:run-report --> comment on the run pull request: a summary for people, then the JSON of zurdo report in a collapsed block. report plans from the newest such comment whose report names the phase's PRD, and never reads .zurdo/. So any checkout of the repository can report a run. run still copies the run state to .zurdo/<slug>/ in the checkout that ran it, for status and zurdo report. A missing run-report comment can only be posted again from there, by carol run's run_report step.
carol sync maps the same report onto issues: a passed task closes, passed-pending-review gets zurdo:pending-review, failed gets zurdo:failed, and in_progress moves only the board card.
Handoff freshness
The handoff is a hint, so carol status tests whether the files have moved past it. The test is anchored on the handoff's own last commit, not on its stopped_at alone.
- Commits since. Carol finds the last commit that touched
handoff.md, then lists commits from there toHEADthat touch the initiative's directory. - Runs since. For each phase with a PRD, Carol compares Zurdo's
last_runwith the handoff'sstopped_at. A later run counts. - Verdict. No commits and no runs since:
fresh. Anything at all:stale. Nohandoff.md:none.
An uncommitted handoff has no commit to anchor on, so only runs can make it stale.
A stale handoff's station and next action are re-derived from the files, not trusted. status lists the commits and runs that made it stale, plus the dirty paths from git status --short.
JSON for agents
carol status --format json prints one envelope. The zurdo-wayfinder skill reads it to orient a session.
{
"schema_version": 1,
"command": "status",
"data": {
"initiative": "billing",
"path": "docs/billing",
"title": "Billing",
"destination": "Customers get itemised invoices and self-serve refunds.",
"phases": [
{
"id": "phase-02",
"title": "Refunds",
"status": "running",
"depends_on": ["phase-01"],
"design": { "path": "docs/billing/design/refunds.md", "section": "Self-serve refunds" },
"prd": "docs/billing/prds/prd-02-refunds.md",
"prd_exists": true,
"run": { "state": "settled", "slug": "prd-02-refunds-3f1d", "last_run": "…", "tally": { "passed": 6, "passed_pending_review": 1, "failed": 0, "blocked_by_dependency": 0 } }
}
],
"tickets": [ { "file": "docs/billing/tickets/tax-rules.md", "type": "research", "question": "…", "status": "resolved", "blocks": ["phase-03"] } ],
"row_conflicts": [ { "phase": "phase-03", "kind": "researching-unblocked", "tickets": ["…"] } ],
"tree": { "dirty": [" M docs/billing/scope.md"] },
"handoff": { "verdict": "stale", "path": "docs/billing/handoff.md", "commit": "…", "stopped_at": "…", "station": "run-and-sync", "phase": "phase-02", "by": "agent", "since": [ { "sha": "…", "subject": "…" } ], "runs_after": [ { "phase": "phase-02", "slug": "…", "last_run": "…" } ] }
}
}
| Field | Notes |
|---|---|
destination | The first sentence of the Destination's first paragraph, on one line |
phases[].run | {"state": "no run"} when there is no run. tally is null when Zurdo has no report. |
row_conflicts[].kind | researching-unblocked or ready-blocked |
handoff | {"verdict": "none"} when there's no handoff |
Every other command's JSON uses the same envelope. A writing command's data holds repo and its ops. Notices (defer:, skip:, warn:) go to stderr, so stdout stays one parseable document.
The rest
The full specs and design records stay with the tool.