Completion detection: turn-ended vs. task-done¶
A single claude -p invocation is one turn. It can end because the overall
task is genuinely finished, or just because that turn's response ended
(ran out of steps, paused, whatever) while multi-part work still remains.
From the outside — exit code 0, no limit language — the two look identical.
This is the exact problem the legacy script's done-marker mechanism was
built to solve, and claudeloop replaces it with something more reliable
while keeping the marker as a fallback.
Primary mechanism: structured output¶
Every turn is asked to return a typed JSON verdict via
ClaudeAgentOptions.output_format:
{"complete": bool, "remaining_work": [str], "blocked_on": str | null, "summary": str}
domain.completion.evaluate() maps this to one of three outcomes:
Done(summary)—complete: trueand noblocked_on.Blocked(reason)—blocked_onis set, regardless ofcomplete. A turn can't claim done and blocked at the same time; blocked wins.Continue(remaining_work)— anything else.remaining_worktracks which specific plan items are left, not just a boolean, so the audit log shows real progress.
Fallback: the legacy marker¶
If structured output isn't available (an older model, or a configuration
that doesn't support it), evaluate() falls back to substring-matching a
marker string — CLAUDELOOP_TASK_FULLY_COMPLETE by default, overridable
with --done-marker — in the raw turn output, exactly as
legacy/claude_autoresume.py's with_done_marker_instruction() does today.
This is a fallback, not the primary path, because a bare substring match has
two known failure modes the structured path doesn't:
- Collision — the marker happens to appear in the user's own prompt text or in something the model quotes.
- Truncation — a rate-limit message cutting a response off mid-stream could coincidentally contain marker-like text.
The limit always wins¶
Regardless of which detection path produced a Done verdict,
domain.loop.decide_after_turn checks capacity before it looks at the
verdict at all. A Done claim on a turn that also hit a rate limit is
discarded in favor of entering the waiting state — see
../architecture/run-loop-state-machine.md
for the exact rule and its dedicated test.
Tracking a plan's checklist across turns¶
When the input is a markdown plan with checkbox items
(domain.plan.WorkPlan.parse), a Continue verdict's remaining_work list
is reconciled back against the plan via with_items_marked_done(), so
claudeloop sessions and the audit log can show which specific items are
still open rather than a single "not done yet" boolean.