Goals, plans and todos
Three durable structures track work, and they answer different questions. Goal is why the work continues. Plan is how it is staged. Todo is what concrete items sit under a stage.
All three live in SQLite. An active Goal owned by the current cycle authorizes continuation; a Plan or Todo describes work and evidence. After a genuine ordinary provider final, the cycle finishes even if steps remain unfinished or runnable, unless a typed wait or protected gate takes precedence. Their status stays unchanged. Real tool/external waits and protected gates remain authoritative; neither "next I will run the tests" nor an old in_progress step creates another model turn.
The three layers
| Layer | Question | Lifetime | Typical size |
|---|---|---|---|
| Goal | Why is this session still running? | Until complete, paused, blocked, out of budget, or permanently failed | One per session |
| Plan | What are the stages, dependencies, and acceptance gates? | Revised as work proceeds | A handful of steps |
| Todo | What concrete work sits beneath a stage? | Created and closed within a plan step | Optional, as many as useful |
They are not required to mirror each other. Plan steps are stages; Todo items are optional concrete work beneath them. Creating one Todo per Plan step mechanically adds bookkeeping without adding information.
Goal
A goal is the continuation authority. An active goal continues until it completes, is explicitly paused or blocked, reaches its budget, or hits a typed permanent failure. That is what makes long work resumable instead of stopping whenever a turn ends.
Recovery is layered. The provider recovery window starts only after the original request returns its first retryable failure. That initial request remains transport-governed and does not consume the window, while rollback, locally jittered backoff, and every replacement attempt must complete before the resulting absolute deadline; expiry cancels and records an active replay. Before every provider-request backoff, the request layer commits a provider_retry_backoff checkpoint containing the request, turn, failed and next attempt, typed reason, selected delay, and wait deadline. A restart never revives that old HTTP request. It observes the remaining wait, then starts a fresh Goal turn. If the bounded provider sequence still ends in a recoverable error, the Goal controller writes a goal_retry row before waiting and likewise starts a fresh turn when its persisted deadline arrives.
The two layers hand over cleanly when the peer names its own delay. Under the default policy, the provider layer gives replacement work a 180-second recovery window; if a Retry-After is longer than what remains of that window, the request layer neither sleeps past its deadline nor substitutes a shorter local backoff, because the peer has already said that a local delay is too soon. The turn ends with the peer's typed error, and the Goal retry waits the peer's value clamped to max_delay_ms. Under that policy a peer that asked for 400 seconds produces a 300 second Goal wait, not a two second local retry.
{
"goal": {
"retry": {
"initial_delay_ms": 2000,
"max_delay_ms": 300000,
"jitter_percent": 20,
"poll_interval_ms": 250
}
}
}2
3
4
5
6
7
8
9
10
Failure handling uses the cycle, turn and Goal identity captured before execution. A late failure cannot block a newer cycle or mutate a replacement or unrelated Goal. Exhausting bounded provider retries or their recovery deadline closes only the failed ordinary cycle; the next independent input does not inherit that failure. For the exact active owned Goal, a recoverable failure schedules durable retry instead. Existing approval, required-input, authentication, turn_budget, blocked-state and uncertain-effect gates are preserved.
There is no cross-turn retry ceiling for recoverable Goal failures: the delay grows exponentially, reaches the cap, and the goal stays active. The retry row is tied to the exact goal id and stores the attempt, typed reason, selected delay, schedule time, and next eligible time, so reopening the session reconstructs the wait.
Queued user input has priority over an automatic turn, and long waits are split by poll_interval_ms so an interactive surface notices input promptly.
For a failure owned by an active Goal, recovery is selected from typed errors:
| Class | Outcome |
|---|---|
| Transport failures, rate limits, incomplete streams, SQLite writer contention, empty assistant messages | Schedule another turn |
| Context-limit failures | Compact retained history, then retry |
| Authentication failures, user interruption, human input, permissions, a spent turn allowance, or an uncertain side effect | Pause with a typed reason |
| Invalid provider protocol, unsupported typed input, unavailable agent or model, corrupt durable state | Block |
Permanent runtime failures store a stable typed code and scrubbed explanation in blocked_reason; a blocked Goal is never left without a diagnosis.
Durable storage is classified the same way, from the typed error and never from its message. SQLite contention met while the goal's budget is read or charged, or while the host writes Goal-owned state such as a Plan reconciliation or a human request, persists a database_busy retry and the Goal stays active, because a lock another writer holds is a condition that clears on its own; the delay is the store's own suggestion when it reports one and the exponential backoff otherwise.
Any other database failure met while the budget is read or charged stops the turn with usage_unknown and pauses the Goal, so a person can look at the database instead of the run continuing unmeasured. Durable state this build cannot read at all, such as a value that will not decode or a format it does not know, blocks the Goal with a typed reason in blocked_reason, and a host write that fails for any reason other than contention blocks it as database_permanent. The goal_retry row is what a restarted process reads back, so a restart neither loses nor shortens the wait.
Inspect and manage it with /goal in the terminal application:
/goal
/goal <objective>
/goal create <objective>
/goal edit <objective>
/goal budget <positive tokens|none>
/goal pause
/goal resume
/goal block <reason>
/goal complete
/goal cancel
/goal history2
3
4
5
6
7
8
9
10
11
/goal shows the current state. /goal <objective> creates a goal when none exists or the previous goal is complete or cancelled. Otherwise it updates the current objective without resetting its status, budget, or accumulated usage. The explicit action forms remain available for lifecycle management. Create, edit, and shorthand objective changes also reconcile an active Plan: a multi-stage objective archives the previous visible Plan and installs a new root bound to the current goal_id. An atomic objective may terminalize stale unfinished work without rebinding an already terminal historical Plan; when that Plan belongs to a previous Goal it is archived as completed history, so the new Goal is never judged against another Goal's checklist.
Creating or editing an active Goal is an execution command, not a status-only write. After the native command reaches its terminal event, the shared Goal driver immediately prepares the next autonomous turn. If the session has no earlier user message, Zuno first admits the user-provided objective through the durable FIFO inbox and persists it as the initial user turn anchor. The literal /goal ... control text never enters provider input.
Every autonomous Goal turn takes its Agent and catalog model from the current turn host at start time. An older user message remains only the causal transcript anchor; it grants no authority and its saved Agent and model do not route Goal execution. Agent or model reconfiguration therefore leaves history immutable while the next /goal or /goal resume turn uses the newly selected configuration. Ordinary user turns still derive their identity from their own durable message. The turn records this boundary in session.turn.started.1, including the trigger, Goal id and revision, anchor message, Agent, provider, and model. A prepared continuation is discarded if that Goal revision changes before execution.
ACP session/load and session/resume also inspect the durable Goal after rebuilding the session runtime. An active Goal resumes in the background without requiring a sacrificial prompt. This also repairs sessions created by older releases that persisted an active Goal but stopped before admitting the first user turn.
Known action names take precedence when they are the first token: show, get, status, history, create, edit, budget, pause, resume, block, complete, cancel, and help. /goal budget 256000000 sets this Goal's explicit ceiling; /goal budget none removes it without changing the Goal's lifecycle status or accumulated usage. If an objective itself starts with one of those words, use /goal create <objective> or /goal edit <objective> to disambiguate it. /goal help prints the compact command summary.
Goal status also shows the typed pause, cross-turn retry, provider backoff checkpoint, pending human requests, and the calls of this objective that still owe an authoritative-state inspection. Completion is rejected while any Plan step, WorkItem, Job, next-step report, or Goal-owned human request remains unfinished.
A pause is only actionable if it names what to look at. /goal show reports pendingUncertainCalls, one entry per uncertain call with the tool, the call id, the paths the call reported having applied, the typed cause, and when it was observed. /goal resume and the optional Resume goal choice do not attest an inspection: they preserve the inspection obligation and reject generic recovery while it is unresolved. /resume likewise never writes an inspection marker or clears the authentication or turn-budget gates.
/inspect-outcome lists pending part IDs. /inspect-outcome <part-id> [part-id ...] reads the actual current file state for 1–16 native write, edit, or apply_patch calls with a durable native.filesystem.intent witness. It works without a model, including on a stopped cycle, and keeps the native session lease until settlement. The receipt records file presence, content hashes, metadata, exact original calls and observation time. Paths are workspace-bound and handle-anchored; links, unsupported operations, missing native provenance and stale call records fail closed.
Limits are 64 targets, 8 MiB per file, 32 MiB total, and a 30-second read deadline. The original outcome remains uncertain; inspection neither claims success nor resumes work or authorizes replay. After a successful inspection, explicitly use /resume or /goal resume; their other gates still apply. Legacy calls without a native witness, shell operations and remote actions require their own target-aware inspection integration. A successful command or a claim of manual checking is not such evidence. The existing job_reconcile tool handles background Job records, not these pending tool-call obligations. /goal pause and /goal cancel retire nothing, because neither claims an inspection happened.
Success criteria and evidence
Declared success criteria remain evidence-gated. Prose such as "the tests pass" does not satisfy a declared check. A user-created Goal may intentionally have no checklist: when both its declaration and criterion ledger are empty, changing files does not retroactively invent a checklist or make completion impossible. Pending work, ownership, uncertain outcomes and the existing capability audit still apply.
goal_propose requires success_criteria, a list of concrete checks: a proposal that names none is refused, and criteria cannot be added afterwards. Each becomes a row with a short id such as c1, echoed once in the result. The model cannot rewrite them later: criteria that could be edited to match whatever happened are not criteria. A proposal may name at most 32 criteria, each at most 500 characters, and a statement with no visible character — whitespace, or a format character such as U+200B — is not a criterion, because a checklist that is populated in the database and empty on screen is not an audit surface. /goal create is the human's own path and is not held to a checklist; these bounds apply when a proposal is written, so a goal an earlier version stored with a longer checklist still reads, still renders and still completes.
A goal a 0.6.x release proposed keeps its criteria in the goal row. Opening the database mints the criterion rows c1..cN, open, for every such goal that is not complete or cancelled; a finished goal is left reading as it did, and is backfilled the same way if it is made live again.
Two things close a criterion, both through goal_update:
| Field | Meaning |
|---|---|
satisfy_criteria | criterionId plus the receiptId of a check that ran and passed |
waive_criteria | criterionId plus a reason, recorded verbatim |
Receipt ids come from the tool results themselves. A tool that ran a command appends a line naming the receipt it recorded:
[verification rcp_01HQ...] passed: cargo test --workspace (exit 0, authoritative).
Cite this id as evidence that the check passed.2
An exit status that was inferred rather than observed says so instead, and states that it cannot be cited. That distinction is the point: a shell pipeline whose failing stage was swallowed by a later grep produces a zero exit status that proves nothing, so it is recorded and refused rather than quietly accepted. See Shell exit status.
Evidence is bounded at both ends. A cited receipt must have been recorded after the goal was proposed: a check that ran earlier proves nothing about work the goal had not yet described, and after a goal is replaced its predecessor's receipts are exactly that. The refusal prints both times and asks for the check to be run again. At the other end, every tool call that writes files stamps the goal with the time of the change, which retires any criterion whose receipt is older than that stamp. The retirement is reported in the tool result, at the moment of the write:
[goal evidence] 2 satisfied criteria went back to open, because this change came
after the check that satisfied them. Verify again after your last edit and cite the
new receipts.2
3
The refusals name what is wrong rather than asking for a retry. A cited receipt that does not exist in this session, one whose outcome was failure or was undecidable, and one that predates the last write each produce a different sentence, and the stale case prints both timestamps so the mismatch is visible. Completing with criteria still open reports which ids are unproven.
A file write changes the Goal's kind, not its original acceptance contract. A nonempty declaration with missing or inconsistent ledger rows fails closed; it is not a criteria-less Goal. Existing checklist evidence and freshness checks remain intact. One thing is audited on every completion the run itself reports, checklist or not: a capability claim it recorded and never verified (see below). Your own /goal complete on a goal with no checklist is not refused over such a claim — the claim is the model's record, and there is no command that clears one — but the run cannot carry it out with the goal. There is also no command yet to satisfy or waive a criterion from the CLI, so a goal whose checklist is still open is finished by the run or cancelled with /goal cancel.
The rendered goal document lists the criteria with their state, so a human reads the same gate the model is held to.
The model cannot complete a paused Goal from an independent request or relabel the pause as blocked to bypass it. The native /goal complete command can close a legitimate legacy Goal after its remaining audits pass, preserving its identity, history, budget and usage; cancelling and recreating it is unnecessary just because the original checklist was empty.
Blocking observations
Report a concrete blocking condition when discovered. goal_update returns staged, the actual Goal status and the count of matching completed engine turns; staging does not mean the Goal is already blocked. The host applies the three-turn threshold. The model does not first wait three turns and then ask the host to count three more. Observation, count, status, history and settlement receipt commit atomically. Replaying the same Goal/cycle/turn receipt does not count again or consume a newer observation. An explicit resume starts a fresh streak; unbound legacy observations do not count.
Capability claims
Some claims are not about the workspace at all. Enabling a provider feature because a related model is documented to have it, and then reporting success, leaves nothing durable that says the belief was inferred rather than observed. The capability_claim tool records one claim per capability and subject, and answers plainly whether it may be relied on.
| State | What it requires |
|---|---|
documented | At least one cited source |
probed | A receipt from this session that proves success and is newer than the last write |
inferred | Nothing, and it blocks completion |
unknown | Nothing, and it blocks completion |
The completion audit refuses a change goal while a claim recorded under it is inferred or unknown, and re-checks a probed claim's receipt against the mutation mark at audit time, so a later write retires the probe without anyone rewriting the ledger. Re-recording a claim updates the row and reports its previous state, which makes a retraction to a weaker state a recorded event rather than a refusal or a silent overwrite. Claims outlive goal replacement as provenance, but only claims recorded since the current goal instance began gate it.
The bedrock-model-capability-review Skill says what counts as evidence for an Amazon Bedrock model: a vendor document naming that exact model id and region, or an observed probe. It also says to record the claim before writing configuration, not after.
Review evidence
A review claim is a third kind: not about the workspace after a change, and not about an external system, but about what the repository does right now. The failure it prevents is specific. A delegated reviewer read one mutual-exclusion guard, reported that a concurrent request "only returns session_busy", and the parent inherited the conclusion because the report was prose and nothing required the main call path to be traced. The plan was then described as ready to implement.
Four tools make that checkable.
| Tool | What it does | Replay |
|---|---|---|
review_open | Opens a review for an artifact or Plan; the host captures Git, scoped worktree bytes and CodeGraph status | Never |
review_claim | Records, verifies, contests or refutes a claim, or resolves a recorded review issue | Never |
review_get | Returns the current event-backed review projection and a bounded claim selection | Safe |
review_finalize | Re-reads every anchor and decides draft or ready at one exact revision | Never |
The model never supplies a head SHA, dirty flag, CodeGraph status, anchor digest, verifiedByParent, or readiness receipt. review_open captures source identity through the host's repository probe. Recording and finalizing a claim re-open its repository-relative anchors and compute their digests from the actual bytes. An assertion with no anchor is refused, and a changed scoped diff or changed anchor makes the claim stale even when the worktree was already dirty before both reads.
Review state uses the existing per-session event stream rather than review-specific tables: review.started.1, review.claim.recorded.1, review.claim.changed.1, and review.finalized.1. Claim or issue changes increment the review revision, invalidate an earlier Ready decision, and clear its receipt in the same SQLite transaction.
A negative claim — "does not exist", "only ever returns", "never happens" — cannot be verified until it records all five counterchecks: the definition lookup, direct and indirect callers and callees, at least one alternate entry point or opposite path, the relevant tests or protocol contracts, and an explicit search for where the claim would show up if it were false. Reading one guard does not establish an absence.
The source snapshot has no indexed-revision field, because codegraph status --json publishes none. It stores the values the command does publish, a digest of the scoped Git diff and untracked bytes, and a separate digest for the named artifact even when that file is ignored by Git. Literal pathspecs, capability-relative no-follow opens, bounded actual reads, a 4 MiB file ceiling, a 32 MiB deduplicated budget for each complete anchor observation, and bounded local commands keep probing inside the repository even if a path is replaced while it is being inspected. A missing or stale index is reported as degraded; direct host reads remain the authority for evidence anchors.
review_open automatically starts balanced-review; the review model cannot call council_run or substitute another preset. Seats return a parsed DelegationEvidenceReport. Malformed reports are invalid seat attempts and may use the preset retry budget; only reports that pass the raw-byte, schema, source-snapshot and anchor rules reach synthesis. The runtime imports accepted claims, contradictions and unresolved questions with structured run/job/seat receipts. Finalization accepts a receipt only when the completed durable Job contains that exact seat, Agent and report digest.
task_report is separate from the review ledger. It reads the terminal record for a durable jobID or child-session taskID, is parent-session scoped, and omits an oversized result as one bounded value instead of replaying or truncating a child transcript.
draft and ready are different answers and must not stand in for each other. A draft is a normal delivery: it lists its blockers and does not block the goal that produced it. Only ready authorizes implementation, and it holds only when every load-bearing claim is an evidence-bearing factual claim, was re-verified through a host-derived review tool-call receipt, and is anchored to the source the review now stands on, with no open issue or claim that is contested, stale, or an unverified P0. The bound Plan must still be current and two validated seats must belong to the same balanced-review run. The host-generated Ready receipt binds the final review revision, source snapshot, HEAD, worktree digest and artifact digest plus a digest of every evidence anchor; the caller cannot provide one. Finalization performs a post-commit reconciliation, and every later review_get reopens the evidence, so a mutation observed after the commit immediately retires the Ready receipt. A requested ready that fails the gate is recorded as a draft with all blockers.
Generated state stays out of the commit
Everything directly under .zuno/ is generated unless a person authors it. The goal document, spilled tool output, and background execution records are the ones Zuno registers by name; a directory a future release adds is generated too, without anybody updating a list. What is excepted is the configuration a person writes there — zuno.json, zuno.jsonc, tui.json, tui.jsonc, RULES.md, agents/, commands/, extensions/, plans/, rules/, and skills/, along with the singular agent/, command/, and skill/ spellings Zuno also loads. Those stay visible to git status and belong in your commits.
The exclusion is written in two places, from that one registry. The pattern **/.zuno/* and one negation per authored entry go into the repository-private .git/info/exclude, not into a tracked .gitignore, because Zuno editing a file the repository's history owns would land as an unexplained diff in somebody else's next commit. Each generated directory also writes its own .gitignore containing * as it is created, which covers that file as well, so the directory stays hidden even where no exclude block was written — a fresh clone, a second worktree, a repository whose block someone deleted, or a checkout where the query below did not answer in time. Every pattern is matched at every depth, so a .zuno/ directory an older release left in a subdirectory is hidden as well; it is inert, and you can delete it.
Writing the block first asks git where its exclude file lives, and that question is bounded at about ten seconds. If git does not answer — an unresponsive mount is the usual reason — the session starts anyway and carries a warning naming the timeout and the worktree, and Zuno runs without the block. Generated state that lives in a directory of its own is still hidden by that directory's .gitignore, and the delivery refusal below still holds, so what the timeout costs is the exclusion of anything directly under .zuno/ that is not a directory Zuno created.
An ignore rule never applies to a path git already tracks, and that is why a refusal exists on top of the exclusions. A git commit that would deliver generated state anyway is refused before it runs, naming each path, why it counts as generated, and what to do: git restore --staged for a path that is merely staged, and git rm --cached for one git tracks, because untracking it is the only thing that stops the next git add -A from collecting it again.
What a commit delivers is read from git, never from the command line, because the command line does not know it — an alias, a commit.template, or a pre-commit hook that stages can all put a path in a commit that no argument named. The index is always read. A command that stages before it commits is read from the worktree instead, across the part of it the staging reaches: when the check runs, git add -A && git commit -m wip has staged nothing yet, so what gets classified is that reach, meaning every tracked modification plus every untracked file git does not ignore. A narrower pathspec narrows the read to it. A pathspec whose reach cannot be read as a path — a glob, or one of git's :-prefixed magic forms — widens the read back to the whole worktree rather than being guessed at.
Every git commit on the line is read, not only the first: a chain like git commit -m a && git commit -am b delivers the worktree's tracked changes through its second commit, and a git add counts towards the commit that follows it rather than one that already ran. Git is recognised however the program is spelled — GIT, a path-qualified /usr/bin/git, a .exe suffix — and git's own global options are skipped the way git skips them, so no spelling walks past a check keyed on the subcommand.
A commit that selects its own repository is refused rather than inspected. -C, --git-dir, --work-tree, --namespace, and an inline GIT_DIR=… all name a repository these reads do not follow, so inspecting anyway would report on a tree other than the one being written. Name the repository in the Shell tool's workdir, where it is one fact the check and the commit share. Outside a repository there is nothing to check and nothing is refused.
Those reads are bounded, and the bound belongs to the check rather than to any one read. A single call makes up to five of them — the worktree root, the index, a staging reach, an untracked listing, and the expected_head comparison when the call supplies one — and they share one thirty-second ceiling in total. Five independent thirty-second ceilings would be two and a half minutes a model could spend by writing one command. When the ceiling expires, the command is refused before it runs and the refusal names the git invocation that did not answer, the ceiling it shared, and that the repository state the decision depends on is unknown. That is deliberate: a read that never answered is not an empty answer, and treating a hung read as "nothing to report" would turn it into permission to commit.
The same reads race the session's interrupt, so Esc or a client cancel answers during the pre-flight phase and not only once git is done. Whatever git started is torn down on either exit — the expiry and the interrupt both stop the process group before the read is abandoned, because nothing else would. A git commit that has already started is never interrupted this way; the phase runs entirely before the command does.
This exists because generated state that reaches the index is how an agent reports a dirty tree as evidence of a change it did not make, or delivers its own scratch output as part of the work.
Token budget
A goal's token_budget is enforced around every provider request inside a turn, not at the turn boundary. A single long turn can otherwise spend an entire allowance before anything reads a counter. Each response is recorded against the goal first, and the decision is read back from the row that write produced, so what stops a run is exactly what a human sees afterwards.
| Condition | Outcome |
|---|---|
| Goal token budget spent | The Goal ends as budget_limited; this is distinct from a per-turn allowance stopping with turn_budget |
| Provider reported no usage | The turn stops; a budget you set that cannot be counted cannot be honoured |
| Only the last tenth of the allowance is left | Compaction is requested, then the turn continues |
| The database is busy while the goal is read or charged | The turn ends and the Goal schedules a database_busy retry |
| The database fails in any other way while the goal is read or charged | The turn stops with usage_unknown and the Goal pauses until the database is readable |
| The stored goal cannot be read by this build | The turn fails and the Goal blocks |
The last tenth is held back deliberately. Compaction costs a request of its own and has to leave room for the summary plus the next real request, so asking for it exactly when the budget runs out would be asking when there is nothing left to pay with.
Recording a response's tokens does not move the goal's revision. A charge is bookkeeping the host does on the model's behalf, so a model that read the goal and then completes it must not lose the optimistic-concurrency race to its own accounting: it would re-read, complete again, be charged again, and never land. The revision moves only when the charge changes the goal's status, which is a fact about the goal the model does have to see.
The host's default allowance
A goal whose token_budget is unset is unbounded by default. The standard Harness profile publishes TurnAllowance::UNLIMITED; it does not invent a token, tool-call, or wall-time ceiling.
A deployment that wants a shared fallback for unbudgeted Goals sets goal.default_token_budget in zuno.json:
{
"goal": {
"default_token_budget": 256000000
}
}2
3
4
5
An explicit per-Goal budget always wins, and the configured fallback is never written into the Goal row. Remove the configuration key to restore the unbounded default, or use /goal budget <positive tokens|none> to change one existing Goal.
One rule from the table above does not carry over. A provider that reports no usage stops a turn under a budget you set, because a budget that cannot be counted cannot be honoured and continuing on unreported numbers would quietly make it advisory. Under a configured host fallback the turn continues: an endpoint that withholds usage is its own choice and not a runaway, and ending every such run on a limit nobody set on the Goal leaves no remedy but to set one. The fallback still binds on whatever was counted, so a floor that crosses it stops above.
Two further ceilings bound the turn rather than the goal. Both are off unless the host sets them, and both apply whether or not a Goal is active, because a turn without a goal can loop just as well as one with. A host that wants a bound no provider can withhold uses them.
| Ceiling | Stops the turn when |
|---|---|
| Tool calls | That many calls have been dispatched inside one turn |
| Wall time | The turn has run that long, at one-second resolution |
Tool calls are counted from the dispatch groups the loop actually ran, so a call that a stop or an urgent human request kept from running is never counted. A reached ceiling overrides a compaction request or a continue, because either would spend a request the ceiling no longer allows, and it defers to a stop the Goal already produced. A request already in flight when the clock passes a ceiling completes first.
A session with no goal is unaffected by the token default. There is no durable counter to charge it against, and a default enforced from an in-memory turn total would reset every turn and never bind.
Nor is a session whose goal has finished. A budget bounds work towards an objective, so a goal that does not own the current native work scope does not stop a turn or receive its usage. Independent input is still counted in session totals; it does not charge or pause a goal the model had completed. Ownership is frozen for each provider request and checked at turn-end accounting, so a Goal replacement or mid-request resume cannot retroactively claim that spend. A final Goal-owned response is still charged to the goal that owned the request. budget_limited is the exception for continuing that Goal: its own work cannot cross its spent ceiling. An independent request neither resumes that Goal nor changes its budget or status. Host turn and session limits still apply to the independent request.
Human requests and autonomy
Work and Goal optional questions use deferred delivery: they may collect an answer without parking execution or preventing a final response. A retained older form does not, by itself, forbid new input or authorize another turn. When a missing fact or decision truly blocks the active Goal, goal_request_input atomically creates a human_request row and pauses the exact Goal with human_input. Permission waits use the same store with kind permission. The turn ends as WaitingForHuman; it is not held open by a process-local receiver.
TUI, HTTP, and ACP list and answer the same rows. The answer and its model-visible FIFO input are committed together. Native Goal/Plan controls still validate their bound identities and gates before authorizing execution. On restart, clients read the same durable requests and receipts; in-process channels only accelerate delivery. Every reply a client actually gives is a settlement, including a withdrawal. Because Goal resumption accepts only an answered request, a dismissed, expired, or unreadable reply is stored as a settled request whose response records no answers and names the outcome that ended it; the response, not the state column, is where the record says whether a human answered. A dialog that never reached a client is not settled at all and stays pending for another surface.
Ordinary Work uses evidence-backed reversible defaults where available. If a required user choice actually blocks progress, the Agent ends the current turn with one clear plain-text final question before the affected action. This creates no synthetic persistent pause. Plan retains structured clarification and its exact revision-bound approval gate; required Goal input retains goal_request_input. The runtime execution wait reference is waiting authority, not the presence of a form or an unfinished Plan.
QuestionView.delivery is a derived view of the associated input receipts:
Deferred clarification forms additionally expose autoDefer with deadlineAt and armed|snoozed|deferred. The native service, not a client guess, owns the 120-second deadline. Expiry leaves the question unanswered and creates no inbox entry; late answers still use its stable ID. Interacting users can snooze without selecting an answer. Only an exact auto-defer successor may rebase an in-flight form response; other stale revisions remain conflicts. Required and approval forms never inherit this timer.
| Phase | Meaning |
|---|---|
WaitingAnswer (waiting_answer) | The form is pending and still accepts an answer. |
AnsweredPendingDelivery (answered_pending_delivery) | The closed form has unapplied inputs, or an early Plan approval still awaits handoff. |
Applied (applied) | The closed form has application evidence with no remaining unapplied inputs. |
Settled controls such as Keep paused that create no model input have delivery: None, omitted in serialization. They are not WaitingAnswer and do not reopen a form. An early Plan approval awaiting handoff remains AnsweredPendingDelivery even before its control input exists.
Question lists retain closed forms awaiting delivery as status entries without reopening them. Pending/applied counts, the latest input state and appliedAt distinguish a saved answer from application to a provider request. Application still does not prove provider success or completion.
A side-effecting tool whose response is lost has an uncertain outcome. The Goal pauses with uncertain_side_effect, requires authoritative-state inspection, and never mechanically replays that invocation. Only tools explicitly marked read-only or idempotent may use safe retry behavior. A call cancelled before it settled its own result is the same kind of outcome under a different typed cause: lost_outcome for a response that never arrived, interrupted for a claim the interruption left unsettled.
The obligation is durable evidence on the tool record, not a field of the pause. The dispatcher writes state.outcome = "uncertain" together with state.uncertain (tool, callID, appliedPaths, cause, observedAtMs) in the statement that makes the result model-visible, so the record exists before anything about the Goal is decided. That ordering is what makes recovery survive a crash: a process that died between the tool write and the pause row leaves the pause missing and the obligation intact, and the next continuation reads the obligation and pauses again. state.uncertain.reconciledAtMs stays absent on pending calls; generic resume never writes it. /inspect-outcome retains fresh authoritative observations bound to the exact original native file calls before marking them inspected. Inspection does not establish that the original operation succeeded or authorize its automatic replay.
Not every unanswered tool row earns that obligation, and the difference is durable rather than inferred. A checkpointed call carries state.dispatchTracked from the moment it is persisted, and state.dispatchedAtMs from the moment it is handed to the executor, each committed on its own. Restart repair reads the pair and produces one of three outcomes:
dispatchTrackedand nodispatchedAtMs— the call provably never reached a tool. The row closes as an interrupted call with nostate.outcome, and nothing is queued for inspection.dispatchedAtMspresent — the call reached a tool and its answer was lost. The row closes with the uncertain outcome above.- Neither field — the row was written by a build that did not record hand-off, so the hand-off cannot be ruled out, and the row closes as uncertain.
The third class is why the first goal continuation after an upgrade from any release up to and including 0.9.0 can pause with uncertain_side_effect for a call the older process had only queued: every released build through 0.9.0 persisted exactly status, input, and raw for a call it then handed to the executor, so the older row does not say which of the two happened, and reporting a decided failure the model is free to reissue is the worse of the two errors. Those rows are read exactly as stored — there is no migration and no backfill — and the class takes no new members once this build is the one writing the rows.
A dispatched call whose own record cannot name it still owes an inspection. When a provider or gateway supplies an empty call id or tool name, state.uncertain.tool and state.uncertain.callID record <unnamed> rather than dropping the obligation, and generic Goal resume cannot retire it merely by naming the part. <unnamed> cannot collide with a real tool name, and its presence is itself the report that the provider's tool-call identity was unusable.
A tool call that arrives from an OpenAI-compatible chat or Responses gateway without an id, or with an empty one, is given a synthesized identity before it reaches the engine: zuno-unnamed-call-<position>, where <position> is the stream's own correlation key for the call — the chat tool_calls[].index, or the Responses item id, with an item whose own id is also empty numbered instead. The engine correlates the call's events by that exact string, persists it as the row's callID, and replays it on both sides of the next request, so the value is self-consistent on the wire and needs no echo from the peer. The prefix is distinct from anything a gateway issues (call_…, fc_…, toolu_…), so a synthesized identity is never mistaken for a real one in a durable row or a log, and two id-less calls in one response no longer collide as a duplicate start. <unnamed> above remains the recorded identity only for a call that still reaches the engine with an empty id or an empty tool name.
On the Responses surface that numbered position is also the key the call is tracked under, so two id-less items can never share an entry. An arguments delta or a completion that arrives with item_id: "" is routed to the one open id-less item. Two shapes have no reading that does not misattribute an event, and both are refused as a typed invalid_upstream_tool_call protocol failure rather than guessed at: a second id-less function_call item added while another is still open, and a completion for an id-less item when none is open. A protocol failure is terminal for the turn; it is not retried.
Plan
A plan carries stages, their dependency order, and their acceptance status. It exists so that progress visibility, interruption recovery, and verification survive a restart or a context compaction.
Use one when durable coordination adds information: cross-component work, delegation, independent acceptance gates or dependencies, and work likely to be interrupted. A bounded single-owner change does not need a plan merely because it has inspect, edit, and test phases. When active Goal criteria already name the gates, add a Plan only when it contributes ordering, ownership, or restart value. In ordinary Work mode the model makes that decision from the full conversation; the host does not classify prompt vocabulary. Straightforward and single-step work proceeds directly, and a Plan must never contain only one step. Explicit Plan collaboration mode still requires durable Plan state.
Rules that matter in practice:
- Step ids stay stable across revisions. An update carries the revision returned by the last read, and a stale revision is rejected without changing anything.
- While steps remain pending, exactly one step is in progress.
- Completed and superseded steps are terminal and cannot regress.
- A fully completed plan has no in-progress step.
- Explicit Plan mode requires durable Plan state. Work mode leaves Plan creation optional; when a Plan is useful, the model creates the first root with
action=create. A new objective usescreateplus the currentexpected_revision, archiving the previous root instead of appending generic steps. patchsends only changed ids,appendsends only new step definitions, and the host generates ids forcreate,append, andpush.- A focused temporary workflow uses
plan_updatewithaction=push; the parent is suspended durably and the child becomes the visible Plan. After every child step is terminal,action=popcarries onlyexpected_revisionand restores the exact parent once. Update the active Plan when work starts, completes, is superseded, or changes scope, and reconcile it before the final answer. - Verification is scoped to the exact commit, build, tag, deployment, configuration, and inputs inspected. When any of them changes, append a new gate instead of reusing an older completed result.
- Task jobs are host-linked to the Plan step that admitted them. A step cannot complete while a linked job is queued, running, uncertain, or still has an unconsumed report. An explicit new Goal may supersede that step, but it does not settle or cancel the Job implicitly.
Plan mode enforces the read-only side of this below the prompt: a deny-by-default overlay allows inspection, read-only search and LSP, shell and bg, questions, Skills, and typed Goal/Plan/Todo operations, while denying file mutation, delegation, job, and execute. Plan mode is a no-mutation boundary, not a shell-free one: the runtime forces any read-only role to SandboxMode::ReadOnly, so a command can gather evidence and cannot change the tree. /plan and /start-plan enter this mode idempotently; Agent selection only changes the saved future Work Agent. Returning to Work requires /start-work, the exact current Plan revision to have a handoff-ready record, and any bound review to be Ready unless the user explicitly persists a Draft-risk reason.
The default host owns collaboration-mode enforcement, active-state reconciliation, and final reconciliation through typed planning services. Ordinary final reconciliation returns Finish without completing unfinished Plan/Todo steps or continuing solely because they exist. Foreground tool waits and real continuation are handled before that boundary; active owned Goals keep their continuation and no-progress checks. The model owns the Work-mode decision and strategic step creation through operation-based plan_update. Disabling that tool prevents new model mutations, while existing Plan persistence, client projection, and restart recovery remain intact.
Entering Plan while a Goal is active atomically records paused(plan_mode). Start Work atomically restores the saved Agent/provider/model/reasoning identity, records authorized_plan_id, authorized_plan_revision, cycle_id, and the continuation token, then admits one idempotent UserControl input. It resumes only an eligible pause and does so once, even after a process restart. It deliberately does not use Goal resume as a substitute for Work authorization.
A Goal that an older release parked on a request it recorded as cancelled, expired, or failed cannot be resumed by Start Work, because that request can no longer become answered. Recover the session with /goal cancel followed by /goal create <objective>; the request row is left intact as evidence.
/plan
/start-plan
/start-work2
3
Automatic completion and recovery use their own durable triggers rather than synthetic user messages. session_input.source_key deduplicates producer delivery; completion_delivery gives synchronous wait and callback one consumption owner; and session_execution_state.context_epoch advances after compaction. A background callback can wake an eligible idle Work cycle, subject to its exact wait and ownership. It remains read-only in Plan mode and cannot start a duplicate turn after bg wait already consumed the same terminal result.
Todo
Todo items are the concrete work beneath a plan step. They carry stable ids, revisions, goal and plan links, parent and dependency ids, owner, status, priority, timing, and token usage.
The constraints exist to keep the graph meaningful:
- At most one item is in progress in a session.
- Every referenced parent, dependency, and plan step must exist after a batch.
- Parent and dependency graphs are acyclic.
- A whole batch rolls back on any validation or revision error; partial updates are never committed.
Preserve an existing id rather than deleting and recreating an item, and when one atomic batch adds dependent items, assign explicit stable ids before referencing them.
How this survives compaction
Compaction changes the provider transcript boundary. It does not delete Goal, Plan, Todo, Job, inbox, event log, or prompt receipt state.
Before a resumed provider request, a durable FIFO scan selects eligible saved answers, settled reports and steering at the safe point, even when wake hints are missing. A resume control cannot skip earlier eligible answers; ordinary inputs explicitly queued for a later turn stay queued. InputDeliveryBatch and session.input.delivery_batch.1 record the inputs actually consumed, with their identities and revisions. They do not establish application: the input receipt at actual post-hook provider dispatch does that.
Instead, every relevant provider request regenerates a bounded runtime.work_state developer section from SQLite: the current plan revision and steps, Todo identities and dependencies, active or uncertain jobs, terminal jobs linked to an unfinished Plan step, terminal jobs with an unconsumed report, pending report identities, and the latest prior prompt receipt id. Job entries include their versioned Plan workContext when present. One deferred transaction reads all of it from the same snapshot.
Each collection is capped at 64 entries and the rendered section at 16 KiB. Verbose text is shortened UTF-8-safely before whole tail entries are omitted, omitted counts stay explicit, and identity fields are retained. Assembly fails closed if even the identity fields cannot fit, because a work-state section that silently lost an identity would be worse than none.
A transcript revert is the other operation that moves the transcript boundary, and it keeps the same rule for the inbox. Committing a revert discards the transcript rows after the staged boundary, but it never deletes an inbox row. Every queued, steering, or promoted input was aimed at the tail that is now gone, so each is retired through the ordinary cancellation transition and logs its own session.input.cancelled; an input that was already consumed is immutable history and is not touched. The commit then appends one session.reverted event recording the boundary, the removed-row counts, and the retired input ids, so the discarded tail stays reconstructable from the durable log. The full payload is listed under Durable inputs in the runtime reference.
Legacy pauses are not automatically unlocked by restart, new input or migration. Database format 15 is unchanged. For an exact proven legacy false block, inspect one already consumed input whose receipt is recorded and has never been applied:
zuno session repair SESSION --input INPUT --dry-run
zuno session repair SESSION --input INPUT --apply --expected-revision N2
Use the inspection's positive expectedRevision for N. Apply requires offline, exclusive access to the existing database and revalidates the complete native proof. It admits an audited recovery control without requeueing the original input, replaying tools, changing a Goal or migrating storage. Success returns control_queued, not proof of input application or provider success. A repeat is idempotent as already_queued only while the same control is still queued and all evidence and execution state are unchanged. Once the control advances or the original input is bound/applied, repair rejects resubmission. Missing evidence and real protected gates remain blocked. See bounded repair.
Long-task context is not execution authority
task_context stores bounded snapshots in versioned session events. Each snapshot retains user-source IDs/digests, revision, objective, constraints and reported delivery/safety evidence. Sources exclude compaction markers and automatic reports; digests cover all stored user-input parts, including attachment references. Check-definition revisions require explicit user sources and reset status/evidence. Context updates are atomic and idempotent by tool invocation; no database format change or new permission is implied. runtime.task_context refreshes before model requests, flags newer user input and stale sources, and distinguishes current work from a completed historical task. A status query does not acquire an old task's execution authority.
Routine implementation choices remain with the implementation owner. Completion requires successful required delivery checks, not merely proof that a failed release stayed private. Context status does not override Plan/Goal ownership, human waits, budgets, authentication or uncertainty, and never makes an unfinished task automatically runnable.
Jobs
Background delegation produces durable jobs, and their lifecycle is part of this state.
A background job commits queued before waiting for delegation capacity and becomes running only at admission. On restart, a still-queued job settles as cancelled because its runner never started; a running job settles as uncertain and is never replayed.
Native Task admission records the optional Goal id, Plan id, admission revision, and active Plan step id in the Job's workContext. Completed or failed child evidence remains model-visible while that step is unfinished, including after compaction or restart. This keeps a failed investigation from disappearing and being delegated again without a changed hypothesis.
Do not report a Job or its linked Plan step complete while required job evidence is unsettled. This does not force another ordinary turn after a final. See Orchestration for report delivery.
Durable background commands keep their authoritative process state and output under .zuno/background, but their model-facing completion is a deterministic session inbox row. The filesystem event is only a wake hint. Terminal state is scanned on session activation and periodically while the process is resident, so these crash points are recoverable without replaying the command:
- status persisted, completion input not yet admitted;
- input admitted, wake not yet attempted;
- input promoted, process lost before model-visible consumption.
The last case returns the original row to its admitted lane. Job-backed child, product-agent, workflow, and council reports perform that transition before the pending inbox scan, so the recovered report can wake an idle parent without a new user prompt. A consumed, cancelled, or failed input is terminal and is never synthesized again.
An automatic report wake follows its frozen work-cycle ownership. If that cycle owns a paused or otherwise non-active Goal, reports may be recorded and consumed without a provider request. An explicit ordinary cycle with no Goal is not blocked by an unrelated retained Goal; missing legacy ownership is not proof of independence. Stopped cycles and real authentication, permission, human-input, Plan-mode or uncertain-effect gates remain effective. Recording a report does not apply it or resume a Goal, and opening a report-only host does not grant Start Work authority.
Use foreground execution when the next useful action depends on a CI or release result. Choose explicit background only when the parent has meaningful independent work. A chosen background Shell observer uses backgroundPurpose: "remoteObserver" and its durable terminal report can wake the eligible cycle. Prose such as "the task is still running; I will wait" does not create continuation state. The observer's process exit is only a wake signal: use bg output, then re-query the remote workflow or release by stable run/attempt, ref, or release id. Do not launch overlapping watchers or hand-written poll loops, and do not treat an overall green run as proof that skipped, cancelled, missing, or unexpanded required jobs executed.
An exact runtime external-wait reference makes reconciliation persist waiting_background; unfinished work alone does not. The matching completion satisfies that wait without polling or a synthetic human question. Active Goal continuation then follows its remaining gates, while an ordinary final finishes its cycle. The observer's terminal report is not remote success evidence.