ZeNorm

CLI offline resilience and outcome durability

A CLI user on flaky wifi loses completed task outcomes when the API is unreachable. Every API call currently gets one attempt — no retry, no spool. This spec defines what durability the CLI owes a user whose network is unreliable.

July 26, 2026grounded in ACE-72/zenormcli-offline-resilience-outcome-durability
The request that started it

Someone ran our CLI on a flaky hotel wifi and lost an hour of work because the results never made it back.

5Rounds
4Questions
3Gate refusals
16Blocks

The grilling

Round 1read 12 files

Someone ran our CLI on a flaky hotel wifi and lost an hour of work because the results never made it back. That's the report I got, in those words. Before you ask me anything: go read public/cli and work out what actually happens on a network failure today. I want specifics — what the HTTP client retries and what it doesn't, how `zenorm work` behaves when the API is unreachable, and where a completed task's outcome is supposed to be posted from. Then tell me what you found and start grilling. Feature scale. This isn't "add a retry", it's the question of what durability the CLI owes a user whose network is unreliable.

ZeNorm

Let me also look at how the CLI entrypoint and local dev work, and check what `zenorm task complete` posts: Here's what I found by reading the code. --- **Current network-failure behavior:** **1. HTTP client (`client.ts`) — zero retry on transport errors.** `sendOnce` calls raw `fetch()`. If it throws (DNS failure, connection reset, timeout), it wraps the error in `ApiError("Request failed: …", 0, null)` and throws. The `request` method only has retry logic for **401** (token refresh → retry once). A flaky network that drops packets gets exactly one try per API call, then fails. **2. `zenorm work` claim loop (`work.ts`) — the one survivable path.** `claimNext` catches any error from the `POST /v1/tasks/claim` call, logs a warning, and retries after the poll interval. A claim blip is non-fatal. **3. Outcome posting is delegated to the agent session.** The agent runs `zenorm task complete <id> --summary="…"` *inside* the spawned Claude/Codex session. That call does a `POST /v1/tasks/:id/outcome` with no retry. If the network fails during this call, the task outcome is **lost** — the agent exits 0 (code changes are committed locally), the daemon sees a clean exit and marks success, but the server never receives the outcome. The task stays `active` on the server, orphaned. **4. Heartbeat — silent failure.** `runTaskWithHeartbeat` runs `POST /v1/tasks/:id/heartbeat` every 30s. A failure logs a warning and continues. No retry, no escalation. **5. Failure recovery (`runTask` catch block) — itself fragile.** When `runner.run()` throws, the daemon calls `releaseTaskToTodo(client, task)` which does a `PATCH /v1/tasks/:id` with status `todo`. If that PATCH also fails (same flaky network), the warning is logged but the task remains `active` on the server — orphaned the other direction. **6. `session-check` stop hook — partial safety net.** A Claude Code stop hook reads session transcripts, checks for orphaned `active` tasks, and blocks the stop with a prompt to re-run `zenorm task complete`. This only fires **within the Claude Code lifecycle** — it does not help when `zenorm work` runs headlessly and the network failed during outcome posting. And the hook itself can fail silently (any API error → `process.exit(0)` allowing the stop). --- **The gap:** There is NO retry mechanism, local queue/spool, or offline durability layer anywhere in the CLI. Every network call is exactly one shot. A completed task whose outcome POST fails is lost — the code changes exist locally but the server has no record of what was done. Now let me start grilling on the scope. Good — I've read the full story. Here's what actually happens today: **The client (`ZenormClient.sendOnce`)** — zero retry on transport failures. Calls raw `fetch()`. If DNS resolution fails or the hotel drops the packet, it wraps the error in `ApiError("Request failed: …", 0, null)` and throws. The `request` method has retry logic only for **401** (expired token → refresh → retry once). Everything else is one shot, then fail. **The claim loop** is the one survivable path — it catches claim failures, logs a warning, and retries after the poll interval. **The critical gap: outcome posting is delegated to the agent session.** The spawned Claude/Codex agent runs `zenorm task complete <id> --summary="…"` from *inside* the agent. That call does `POST /v1/tasks/:id/outcome` — zero retry. If the network flakes during that call, the agent exits 0 (code changes are in the repo), the daemon sees success, but the **server never receives the outcome**. The task stays `active` on the server, orphaned. That's the "lost an hour of work" scenario. **Heartbeats** also have no retry — just a warning on failure. **Task release on agent failure** (`releaseTaskToTodo`) uses the same fragile one-shot PATCH. **`session-check`** stop hook provides a partial safety net for Claude Code interactive sessions, but doesn't help the headless `zenorm work` daemon and itself gives up silently on API error. Now the first scope question: The specifics of what I found are above. The most important cut is which operations get retry protection. The report says "results never made it back" — that's the outcome POST. Claims, heartbeats, and status patches failing are survivable (losing a completed outcome is not). I've recommended option A as the starting point.

Refusedupdate_spec_metadatasummary too long

This summary is 411 characters — past the 1-3 sentence, 400-char budget. A summary states the decided scope once, like an elevator pitch; it must not re-enumerate the goals, constraints, or acceptance criteria the blocks already carry. Rewrite it as 1-3 sentences of what ships and for whom.

What the agent was told to do instead

Stop the current edit sequence. Treat this as a plan-shape failure, not a wording issue: re-plan from the acceptance criteria into the smallest set of coarse, independently shippable user slices before retrying — as few as the distinct capabilities require (commonly 1-3, hard cap 7, no minimum); one narrow regression fix is exactly 1 slice. If two slices are steps of the same capability, merge them into one. If bad tasks or blocks already exist, update/delete that bad shape before adding more. Metadata updates are atomic: nothing from this call was committed. Retry with every requested field, preserving valid fields unchanged and correcting only rejected fields.

Refusedupdate_spec_metadataunsupported scope boundary

Requirement invents high-risk non-goal(s) absent from authoritative PM evidence: failure recovery. A primary scenario does not exclude adjacent behavior; remove the boundary or ask the PM to decide it explicitly.

What the agent was told to do instead

Remove or reword the unsupported claim: keep only content the PM stated, selected, or the repository verified. If the claim is load-bearing, ask the PM instead of asserting it. This gate family will not block again this session for a rewrite without the claim — but resubmitting this same rejected content is rejected every time; retry once without the claim, then move on; do not loop on rewording. Metadata updates are atomic: nothing from this call was committed. Retry with every requested field, preserving valid fields unchanged and correcting only rejected fields.

Advisoryupdate_spec_metadataunsupported scope boundary

Requirement invents high-risk non-goal(s) absent from authoritative PM evidence: failure recovery. A primary scenario does not exclude adjacent behavior; remove the boundary or ask the PM to decide it explicitly.

Files the agent read
  • ls whole repo
  • find public/cli/**/*
  • read public/cli/src/api/client.ts
  • read public/cli/src/api/types.ts
  • read public/cli/src/commands/work.ts
  • read public/cli/src/commands/task.ts
  • read public/cli/src/util/errors.ts
  • read public/cli/src/api/client.test.ts
  • read public/cli/src/commands/work.test.ts
  • read public/cli/src/index.ts
  • read public/cli/src/local.ts
  • read public/cli/src/commands/session-check.ts
Round 2

That's worse than I thought and it's exactly what I needed to know. So the client only retries on 401, a failed outcome post is just gone, and the daemon's entire resilience story is "log a warning and try again in five seconds". You're recommending we add exponential backoff with retries on 5xx and network errors, and treat that as the feature. I'm not taking that as the answer — it's necessary and it's not sufficient, and I want to be precise about why. Backoff keeps a *running process* alive across a blip. It does nothing for the case in the bug report, which is that the process ended, the machine went to sleep, or the user closed the laptop. Retrying in memory means the work survives exactly as long as the process does, and the thing we lost was the work of an agent that had already finished. So the feature is durability on disk, and backoff is one part of how the queue drains. Grill me on the queue. I have opinions about what belongs in it but I want to hear your read on the risk first.

ZeNorm

You're right and I should have seen that. A retry in process memory keeps the outcome alive for at most `sleep + reconnect`. The moment `zenorm work` exits, that queue evaporates. The bug report's "laptop closed" kills it instantly. A disk-backed spool is the correct shape. Here are the risks I see in a disk-backed delivery queue: 1. **Concurrent writers.** If `zenorm task complete` and `session-check` both try to spool, or two `zenorm work` daemons run on the same machine, you get file corruption or lost writes. Need a lock discipline or an atomic-per-item design (one file per spool entry). 2. **Drain ordering.** Outcomes for task A, then task B, then a release-to-todo for task C. If we replay FIFO but B's spooled POST fails (server says "task B is not active"), does A's outcome need to land first? This gets into whether the queue is purely "POST /outcome" or a general mutation log. 3. **Idempotency.** What happens when the POST succeeds server-side, the response is lost to a TCP timeout, and the queue retries? Sends the outcome twice. The server needs an idempotency key — likely the `sessionId` that's already in the outcome payload. 4. **Drain security.** The queue drains via the same HTTP client. If draining picks up an old spooled outcome with a stale auth token (machine logged out overnight), does it fail noisily or silently? Does the user need to `zenorm login` before the drain can proceed? 5. **Observability.** A wall of silence while the queue retries is as bad as losing the outcome. Do we surface "3 outcomes queued, last attempt 2m ago" via `zenorm status` or in the daemon's output? 6. **Queue lifecycle.** A spooled item sits on disk for how long? Until the server confirms receipt. What if the server is down for a week — unbounded growth? Max retries with a dead-letter state? 7. **Spool write failure.** Disk full, permissions wrong on the CI runner, read-only container filesystem. Does `zenorm task complete` fall through to the current one-shot POST as a best-effort, or fail loud? I laid out the seven risks I see above. I'll wait for your take on which ones bite hardest and which you've already got answers for.

Round 3

Agreed on the risk you raised, and that determines the design. Here's my ruling. Only terminal outcome writes get queued — the result of a finished task. Nothing that claims work, nothing that takes a lease, nothing that heartbeats. Those are all statements about the present, and replaying them later is worse than dropping them: a claim that lands twenty minutes after the fact steals a task somebody else already picked up, and a heartbeat replayed from a queue asserts that a process is alive when it is not. A stale heartbeat is a lie the system will act on. So record this as the governing constraint: the offline queue holds only idempotent terminal writes. Liveness and claim operations must fail immediately and visibly, never queue. That also means the API needs to tolerate the same outcome arriving twice, since a queued write can land after a retry already succeeded. You noted the API already accepts an idempotency-key on turn routes and that the CLI never sends one — use that mechanism rather than inventing a second one, and if the task routes don't accept it, that's part of the work. One more thing: the queue must be visible. A user who has undelivered results needs to be told by the CLI, not discover it by noticing the board is wrong.

ZeNorm

That's a clean design. Here's what I'm capturing from your decision: **Scope settled so far:** - Queue holds only **terminal outcome writes** (`POST /v1/tasks/:id/outcome`) - Claims, heartbeats, status patches — fail immediately, never queue - Duplicate outcome delivery via the **existing idempotency-key** mechanism (task routes must accept it) - **Visibility**: user sees queued/pending outcomes Let me lock these in as blocks. Good, those are in. Still unresolved: Three open questions remain beyond that one — let me surface them in order as you answer. The retry-budget question is the most structural: it determines whether the queue has a dead-letter state at all, and what tools the user needs to manage it.

Round 4

On where it lives: alongside the credentials, under ~/.zenorm, scoped per environment the same way credentials already are. I don't want a prod outcome draining into a dev stack because someone switched ZENORM_CLI between the failure and the retry. On giving up: the queue never silently discards. If an entry can't be delivered it stays and the CLI keeps telling the user it's there. I'd rather a user has to run a command to throw it away than have the tool decide on their behalf that their work no longer matters — that's the whole complaint in the original bug report, just with extra steps. Explicit non-goal, write it down: this is not offline authoring. You cannot create or edit specs without a network. The queue exists so that work already done by a coding agent survives the trip home, nothing more. Score acceptance and cut the tasks.

ZeNorm

Good. Capturing the remaining decisions and closing out the answered questions. Intent is locked. Moving to acceptance. One load-bearing question before I write the acceptance criteria: I've got the three goals and six constraints locked. The last open question for acceptance is what the visibility surface looks like — subtle status line vs. a dedicated command the user can run to inspect the queue.

Round 5

The CLI tells them, unprompted, on the next command they run. Not a status subcommand they have to know to type. If discovery requires the user to suspect something is wrong, then the failure mode is exactly the one from the bug report: they don't suspect, they carry on, and the work stays undelivered. So any command that talks to the API reports undelivered outcomes, and the drain attempt happens then too — the natural moment is the next time we know the network is back, because we just used it. Write that as a constraint: undelivered work must be surfaced without the user asking, on the next command that runs. All dimensions answered. Score constraints and acceptance and generate the tasks.

ZeNorm

All three sections are scoring 0.9+. Time to create the task. This is one capability — the spool, automatic retry, and unprompted visibility are all part of a single user-facing outcome. All three sections scored, one task created, full trace graph committed. Here's the final spec: --- **Title:** CLI offline resilience and outcome durability **Summary:** A CLI user on flaky wifi loses completed task outcomes when the API is unreachable. Every API call currently gets one attempt — no retry, no spool. This spec defines what durability the CLI owes a user whose network is unreliable. **3 Goals:** - Outcome delivery survives network failure - Queued outcomes drain automatically - User sees queued outcome status **7 Constraints:** - Only terminal writes (`POST /v1/tasks/:id/outcome`) go in the queue — no claims, heartbeats, or status patches - Liveness ops fail immediately and visibly, never queue - Duplicate delivery via existing idempotency-key mechanism - Queue lives under `~/.zenorm/queue/<env>/`, scoped per environment - Queue never silently discards — only server ACK or explicit user command removes entries - Undelivered outcomes surfaced unprompted on any API-using command - Offline authoring explicitly out of scope **6 Acceptance Criteria:** spool on failure, drain on reconnect, liveness ops never queued, unprompted status on any command, duplicate delivery is a no-op, never silently discards. **1 Task:** - **Spool completed task outcomes to disk with automatic retry across CLI restarts** — one task covering the full capability: file-based spool under `~/.zenorm/queue/`, exponential-backoff delivery on any API-using command, idempotency-key on task outcome routes so replays are safe, and unprompted queue-status reporting. Claims/heartbeats/patches explicitly excluded from the queue.

Refusedinsert_blocksunsupported failure notification

Requirement invents a failure/error notification outcome absent from authoritative PM or repository evidence. Completion/success confirmation does not authorize failure messaging; remove it or confirm the failure recipient and outcome explicitly.

What the agent was told to do instead

Remove or reword the unsupported claim: keep only content the PM stated, selected, or the repository verified. If the claim is load-bearing, ask the PM instead of asserting it. This gate family will not block again this session for a rewrite without the claim — but resubmitting this same rejected content is rejected every time; retry once without the claim, then move on; do not loop on rewording. Block inserts are atomic: nothing from this call was committed. Retry the batch without the rejected claim.

Advisoryinsert_blocksunsupported scope boundary

Requirement invents high-risk non-goal(s) absent from authoritative PM evidence: failure recovery. A primary scenario does not exclude adjacent behavior; remove the boundary or ask the PM to decide it explicitly.

Advisorycreate_tasktask description sprawl

This task description runs 6 sentences — past the 1-3 sentence capability handoff. Keep the outcome, the surfaces it touches, and what proves it works; cut restated acceptance assertions, step lists, and implementation detail the blocks and trace graph already carry.

What it asked

Q1intent

Which operations must survive a network failure?

  • AOutcome delivery onlyAgent's pick

    Only the task outcome POST must be retried until delivered. Claims, heartbeats, and status patches can fail.

  • BAll task lifecycle calls

    Outcome POST, task release-on-failure PATCH, and heartbeat must all retry. Claim already retries.

  • CAll API calls

    Every CLI API call (pull, list, create, update) gets retry. The full surface.

What the PM said

That's worse than I thought and it's exactly what I needed to know. So the client only retries on 401, a failed outcome post is just gone, and the daemon's entire resilience story is "log a warning and try again in five seconds". You're recommending we add exponential backoff with retries on 5xx and network errors, and treat that as the feature. I'm not taking that as the answer — it's necessary and it's not sufficient, and I want to be precise about why. Backoff keeps a *running process* alive across a blip. It does nothing for the case in the bug report, which is that the process ended, the machine went to sleep, or the user closed the laptop. Retrying in memory means the work survives exactly as long as the process does, and the thing we lost was the work of an agent that had already finished. So the feature is durability on disk, and backoff is one part of how the queue drains. Grill me on the queue. I have opinions about what belongs in it but I want to hear your read on the risk first.

Q2intent

Which spool risk do you want to address first?

  • AWhat goes in the queueAgent's pick

    Only outcome POSTs, or also heartbeats, status patches, and task releases?

  • BIdempotency model

    How the server deduplicates a replayed outcome, and what's the retry budget.

  • CDrain lifecycle

    When the queue drains, ordering, concurrent daemon safety, and dead-letter behavior.

What the PM said

Agreed on the risk you raised, and that determines the design. Here's my ruling. Only terminal outcome writes get queued — the result of a finished task. Nothing that claims work, nothing that takes a lease, nothing that heartbeats. Those are all statements about the present, and replaying them later is worse than dropping them: a claim that lands twenty minutes after the fact steals a task somebody else already picked up, and a heartbeat replayed from a queue asserts that a process is alive when it is not. A stale heartbeat is a lie the system will act on. So record this as the governing constraint: the offline queue holds only idempotent terminal writes. Liveness and claim operations must fail immediately and visibly, never queue. That also means the API needs to tolerate the same outcome arriving twice, since a queued write can land after a retry already succeeded. You noted the API already accepts an idempotency-key on turn routes and that the CLI never sends one — use that mechanism rather than inventing a second one, and if the task routes don't accept it, that's part of the work. One more thing: the queue must be visible. A user who has undelivered results needs to be told by the CLI, not discover it by noticing the board is wrong.

Q3intent

What happens to an outcome that can't be delivered after exhausting retries?

  • ADead-letter on disk forever

    The spooled outcome stays on disk. The user must manually inspect and retry or delete it via a command.

  • BDead-letter with max retries

    After N retries (e.g. 100) over a window (e.g. 7 days), the outcome is marked undeliverable. The CLI warns and stops retrying automatically.

  • CNever give upAgent's pick

    Retry forever with exponential backoff capped at, say, 1 hour. The queue drains when the server comes back, even weeks later.

What the PM said

On where it lives: alongside the credentials, under ~/.zenorm, scoped per environment the same way credentials already are. I don't want a prod outcome draining into a dev stack because someone switched ZENORM_CLI between the failure and the retry. On giving up: the queue never silently discards. If an entry can't be delivered it stays and the CLI keeps telling the user it's there. I'd rather a user has to run a command to throw it away than have the tool decide on their behalf that their work no longer matters — that's the whole complaint in the original bug report, just with extra steps. Explicit non-goal, write it down: this is not offline authoring. You cannot create or edit specs without a network. The queue exists so that work already done by a coding agent survives the trip home, nothing more. Score acceptance and cut the tasks.

Q4acceptance

How should the user discover queued outcomes?

  • ADaemon output only

    Pending outcomes appear in the `zenorm work` status line. No dedicated command needed.

  • BDaemon output + dedicated commandAgent's pick

    Daemon shows pending count. A `zenorm queue` or `zenorm task queue` command lists pending outcomes with details.

  • CEvery command shows pending count

    Any CLI command that hits the API (work, task, pull, etc.) includes a banner if the queue is non-empty.

What the PM said

The CLI tells them, unprompted, on the next command they run. Not a status subcommand they have to know to type. If discovery requires the user to suspect something is wrong, then the failure mode is exactly the one from the bug report: they don't suspect, they carry on, and the work stays undelivered. So any command that talks to the API reports undelivered outcomes, and the drain attempt happens then too — the natural moment is the next time we know the network is back, because we just used it. Write that as a constraint: undelivered work must be surfaced without the user asking, on the next command that runs. All dimensions answered. Score constraints and acceptance and generate the tasks.

Where the server refused

Each of these rejected the agent's own tool call. The agent had to fix its output and try again before the spec could advance — this is the gate, not a prompt the model can talk past.

Refusedupdate_spec_metadatasummary too long

This summary is 411 characters — past the 1-3 sentence, 400-char budget. A summary states the decided scope once, like an elevator pitch; it must not re-enumerate the goals, constraints, or acceptance criteria the blocks already carry. Rewrite it as 1-3 sentences of what ships and for whom.

What the agent was told to do instead

Stop the current edit sequence. Treat this as a plan-shape failure, not a wording issue: re-plan from the acceptance criteria into the smallest set of coarse, independently shippable user slices before retrying — as few as the distinct capabilities require (commonly 1-3, hard cap 7, no minimum); one narrow regression fix is exactly 1 slice. If two slices are steps of the same capability, merge them into one. If bad tasks or blocks already exist, update/delete that bad shape before adding more. Metadata updates are atomic: nothing from this call was committed. Retry with every requested field, preserving valid fields unchanged and correcting only rejected fields.

Refusedupdate_spec_metadataunsupported scope boundary

Requirement invents high-risk non-goal(s) absent from authoritative PM evidence: failure recovery. A primary scenario does not exclude adjacent behavior; remove the boundary or ask the PM to decide it explicitly.

What the agent was told to do instead

Remove or reword the unsupported claim: keep only content the PM stated, selected, or the repository verified. If the claim is load-bearing, ask the PM instead of asserting it. This gate family will not block again this session for a rewrite without the claim — but resubmitting this same rejected content is rejected every time; retry once without the claim, then move on; do not loop on rewording. Metadata updates are atomic: nothing from this call was committed. Retry with every requested field, preserving valid fields unchanged and correcting only rejected fields.

Refusedinsert_blocksunsupported failure notification

Requirement invents a failure/error notification outcome absent from authoritative PM or repository evidence. Completion/success confirmation does not authorize failure messaging; remove it or confirm the failure recipient and outcome explicitly.

What the agent was told to do instead

Remove or reword the unsupported claim: keep only content the PM stated, selected, or the repository verified. If the claim is load-bearing, ask the PM instead of asserting it. This gate family will not block again this session for a rewrite without the claim — but resubmitting this same rejected content is rejected every time; retry once without the claim, then move on; do not loop on rewording. Block inserts are atomic: nothing from this call was committed. Retry the batch without the rejected claim.

What it had to clear

Every section had to score at or above 0.90 before create_task would run at all. This is the gate.

intent0.95

Intent is well-defined: durability of completed task outcomes via a disk-backed queue that never silently discards. Scope boundaries are explicit (only terminal outcome writes, no offline authoring). Queue location, environment scoping, idempotency key approach, and visibility requirement are all decided. No remaining ambiguities in user-facing outcomes.

constraints0.92

All seven constraints are anchored in PM decisions: queue scope (only terminal writes, not claims/heartbeats), idempotency via existing key mechanism, environment-scoped path under ~/.zenorm, no silent discards, no offline authoring, and unprompted visibility on any API-using command. Each is a concrete, observable boundary the implementation must respect.

acceptance0.92

Six measurable acceptance criteria covering all three goals. Each specifies an observable input, a concrete file-system or output behavior, and a pass/fail signal. Coverage spans the spool-on-failure path, the drain-on-reconnect path, the idempotent-redelivery edge case, the liveness/claim non-queuing boundary, the unprompted-visibility requirement, and the never-discard property.

The spec it produced

Goals
Outcome delivery survives network failure
Queued outcomes drain automatically
User sees queued outcome status
Constraints
Only terminal writes go in the queue
Liveness ops fail immediately and visibly
Duplicate outcome delivery via idempotency key
Queue lives under ~/.zenorm per environment
Queue never silently discards entries
Offline authoring out of scope
Undelivered outcomes surfaced unprompted
Acceptance criteria
Outcome spooled on unreachable API
Queue drains on connectivity restored
Liveness ops never queued
Unprompted queue status on any command
Duplicate delivery is a no-op
Queue never silently discards

How it hangs together

goal

Outcome delivery survives network failure

  • constrainsOnly terminal writes go in the queue
  • constrainsLiveness ops fail immediately and visibly
  • constrainsDuplicate outcome delivery via idempotency key
  • constrainsQueue lives under ~/.zenorm per environment
  • constrainsQueue never silently discards entries
  • constrainsOffline authoring out of scope
  • validatesOutcome spooled on unreachable API
  • validatesQueue drains on connectivity restored
  • validatesLiveness ops never queued
  • validatesDuplicate delivery is a no-op
  • validatesQueue never silently discards
goal

Queued outcomes drain automatically

  • validatesQueue drains on connectivity restored
goal

User sees queued outcome status

  • constrainsUndelivered outcomes surfaced unprompted
  • validatesUnprompted queue status on any command
acceptance

Outcome spooled on unreachable API

  • implementsSpool completed task outcomes to disk with automatic retry across CLI restarts
acceptance

Queue drains on connectivity restored

  • implementsSpool completed task outcomes to disk with automatic retry across CLI restarts
acceptance

Liveness ops never queued

  • implementsSpool completed task outcomes to disk with automatic retry across CLI restarts
acceptance

Unprompted queue status on any command

  • implementsSpool completed task outcomes to disk with automatic retry across CLI restarts
acceptance

Duplicate delivery is a no-op

  • implementsSpool completed task outcomes to disk with automatic retry across CLI restarts
acceptance

Queue never silently discards

  • implementsSpool completed task outcomes to disk with automatic retry across CLI restarts

What the gate released

Spool completed task outcomes to disk with automatic retry across CLI restarts

When `zenorm task complete` encounters a transport error or 5xx, persist the outcome payload (summary, decisions, steering, etc.) as a file under `~/.zenorm/queue/<env>/`. Retry delivery with exponential backoff any time an API-using CLI command runs or the daemon is active; remove the spool file only after server confirms receipt. Ensure `POST /v1/tasks/:id/outcome` accepts an idempotency-key so a replay after a lost response is a no-op. Report pending queue count unprompted on every API-talking command. Claims, heartbeats, and status patches must never write to the queue — they fail immediately per existing behavior. Automated assertions: - Outcome spooled on unreachable API - Queue drains on connectivity restored - Liveness ops never queued — never writes a file to ~/ — no outcome-style spooling - Unprompted queue status on any command — no queue-related output - Duplicate delivery is a no-op - Queue never silently discards

Then, in your terminal
zenorm work

Your coding agent claims the task, implements it against the full spec, and gates itself on typecheck, lint, and tests.

One dissected spec per week

A real request, the questions the agent asked, and every place the server refused its output. No product announcements.