Skip to content

TODO Format Contract

A todo issue is a human action item an agent hands back when it hits a human-only blocker (a decision timeout, an escalation, a missing credential). For the Human Operator to act on it without reading any session log, every agent-emitted todo must be self-contained. This is the contract that makes that guarantee — and the basis for the groom compliance check (#777) and the downstream completed lifecycle (#775, see Work Tracking → TODO lifecycle).

Validator (single source of truth): ~/.claude/scripts/warp-drive/todo-contract.js — check one issue (it also warns on WS-4, WS-5, WS-6) or audit every open todo. groom and warp-drive both call it so the rule lives in exactly one place. Prose: WS-4 to WS-10.

Every agent-emitted todo body MUST contain:

# Element Requirement
1 Summary A one-sentence statement of what is blocked and why, as the first line of the body, ≤ 25 words (WS-4).
2 Instructions Numbered, self-contained steps the human can follow without external context (1. / 2. / …), each starting with a verb (WS-5).
3 Issue link At least one explicit #NN reference. Use Blocked by #NN when the todo gates a specific issue, so downstream tooling knows what to resume.
4 Why a human A Why a human is needed: <reason> — <detail> line naming why the agent could not act: login, decision, vendor UI, operator data (data only the operator has) or permission (e.g. permission denied). If none applies, the todo is not filed: the agent does the work. Required for every todo filed from #3161 on; the todo-writing lint (todo-lint.sh, E3) enforces it. todo-contract.js does not, so older todos are not flagged.

A link is either a bare #NN, an issue in the todo’s own repo, or a cross-repo owner/repo#NN, an issue in the repo it names (#2912). The contract check (todo-contract.js) accepts either form as the required link. The stale sweep (todo-consume.js stale) reads each from its own repo — a cross-repo link with gh issue view NN --repo owner/repo, never as a local #NN — and grades the todo from the real state of both. A link that names the todo’s own repo is the same as the bare #NN. The current repo is GH_REPO when set, else what gh repo view reports. A cross-repo link the forge cannot read is reported whole, as UNCHECKED #36 (… could not be read: owner/repo#NN). In the linked issue of another repo, a bare #NN means that repo’s issue, so it counts as a reference to the todo only in the full owner/repo#NN form. Blocked by, which the consume path acts on, still takes bare #NN only: the blocked label it removes belongs to an issue in the same repo.

And the issue MUST carry:

Element Requirement
Assignee --assignee paulirv (the Human Operator) so it surfaces in todo + assignee:@me discovery (#778).
Priority One of p1-critical / p2-high / p3-medium / p4-low.
Label todo (plus warp-drive when filed by the loop).

Before an ask is filed, the filing path runs one check: ~/.claude/scripts/warp-drive/ask-gate.js check --body-file <draft>. The draft is admitted only when its body states all five fields. Each of the four named fields is one line, Name: value; the name may be bold or a list item.

Field Line Admitted when
kind Kind: <kind> one of decide, do, check
minutes Minutes: <n> a whole number above zero: how long it takes the operator
place Place: <where> a fleet machine id from machines.json, or phone
steps 1. … at least one numbered step
tried Tried: <what> says what BoB attempted before asking

A field that is absent, empty or not a valid value is missing. The refusal names every missing field, not just the first:

ask-gate: refused — missing minutes, tried
minutes: no `Minutes:` line — state how many minutes it takes the operator
tried: no `Tried:` line — say what BoB attempted before asking

--json prints one ask-gate/1 document: verdict (admitted / refused), missing, problems (field, message), duplicate, duplicate_check, priority_check, guide and parsed.

Exit Meaning
0 admitted
2 usage error, or the draft could not be read
3 refused: a field is missing, the draft duplicates an open ask, p1-critical is not earned, or it is a guide to follow

When no operator repo resolves, the fleet cannot be read. The gate then checks only that the place is a single word.

A todo may also say how a machine can tell it is done, on one or more Done-check: lines. These are separate from the steps the human performs. The gate does not require the field. If the field is present, every line must parse, or the draft is refused naming the line (missing then includes done-check).

Line Passes when
Done-check: ci <owner/repo> <workflow> [<branch>] the latest run of that workflow (on that branch) succeeded
Done-check: secret <owner/repo> <NAME> the repo has a secret with that name
Done-check: label <owner/repo#N> <label> the issue carries the label
Done-check: closed <owner/repo#N> the issue is closed
Done-check: file ~/<path> the file exists on the machine that runs the check

The probes are a closed set and are parsed into arguments. Issue text names what to look at, never what to run (#3628). Every line must pass.

scripts/asks/done-check.js runs the check before a todo is listed by bob asks, BoB Control’s Now page and the briefing, and on every bob tick (bob asks verify):

Result What happens
pass the todo gets completed and is closed with a comment naming the evidence, without ever being shown
fail the reason is written into the body as a ## Latest check section (placed before ## Instructions), when it differs from the reason already recorded there
pending a CI run is in progress or was just re-run; the todo is shown as it is
unknown the forge could not say; the todo is shown as it is, never closed

When bob tick finds a failed CI run, it re-runs it once per run id (gh run rerun --failed) before reporting the failure. Results are cached in $BOB_HOME/.ask-done-checks.json for _workflow.ask_done_check_ttl_minutes (default 30). Only a failing or unknown result is reused; a pass, and a CI run that is pending or was just re-run, is probed again every time.

Pass the draft’s title and the gate also refuses a duplicate:

Terminal window
node ~/.claude/scripts/warp-drive/ask-gate.js check --body-file draft.md \
--title "TODO: log in to Cloudflare so the test Worker deploys" --repo owner/repo
ask-gate: refused — duplicate of #41
duplicate: duplicates open ask #41 (same title) — https://github.com/owner/repo/issues/41

A draft is a duplicate when its title or its first numbered step matches an open ask in the same repo. An open ask is an open todo without completed. The comparison ignores case, punctuation, markup and a leading TODO:. --repo names the repo the ask is for; without it gh uses the current one. When an open ask already covers the cause, comment on it instead of filing (one cause, one todo).

If the forge cannot be read, the duplicate check is not made and the draft is judged on its five fields. The output says so: a note: line in the text, and duplicate_check: { made: false, reason } in the document. A forge failure never refuses an ask. --no-duplicate-check skips the forge call.

p1-critical is for an ask that holds up a flight plan. The gate admits it only when the body’s Blocked by #NN line names an issue that is an unticked item (- [ ] #NN) of an open flight-plan issue in the same repo: a plan that is running, or approved to run, is waiting on the operator. Pass the labels the ask will carry with --label (ask-gate.js file already has them):

Terminal window
node ~/.claude/scripts/warp-drive/ask-gate.js check --body-file draft.md \
--title "TODO: rotate the deploy token" --label todo,p1-critical
ask-gate: refused — p1-critical not earned
priority: p1-critical is for an ask that blocks a flight plan: #123 is not an unticked item of an open flight plan — file it at p2-high or lower

A draft with no Blocked by #NN line is refused the same way and told to add one. An ask at any other priority is not checked, and the flight plans are not read for it. The document records the outcome as priority_check: { made: true, ok, blocks, plan }, or { made: false, reason }. When the flight plans cannot be read the check is not made, the output says so, and the ask is admitted.

A guide to follow is a led session (#3809)

Section titled “A guide to follow is a led session (#3809)”

A Kind: check whose steps have the operator follow a named guide is not a to-do. It is an attended session, and the agent leads those. The gate refuses it and names the command that opens the session instead:

ask-gate: refused — a guide to follow is a led session
route: step 2 has the operator follow a guide (docs/tutorials/first-product.md): that is a session the agent leads, not a to-do — open it with `bob ritual open guide-walkthrough --guide docs/tutorials/first-product.md`

A step follows a guide when it says to follow, walk through, work through, go through or run through one. The guide is named by its file anywhere in the body (under docs/tutorials/, docs/how-to/ or runbooks/), or by the step calling it a tutorial, guide, runbook, how-to or walkthrough. When the file is named, a step that has the operator do its stops, steps, parts or sections follows it too. The document records it as guide: { step, guide, route }, or null. A do or a decide is not checked: a runbook the operator has to run in person stays a to-do.

An admitted ask that lands beyond its project’s attention budget is filed by ask-gate.js file with the ask-waiting label (Asks that wait).

todo-contract.js validateIssue runs the same field check on an existing todo. Its missing list carries kind, minutes, place and tried beside the older names, and admission.missing is the gate’s own list. The gate’s steps is this contract’s instructions, reported once. So todo-contract.js audit flags a todo that would not be admitted today:

NON-CONFORMING #212 (missing kind, minutes, place, tried) — TODO: rotate the deploy token

Admission is a filing rule. A todo that lacks the fields can still be completed: the completed done-signal does not depend on them.

Every path that files an ask runs the gate before gh issue create. A refused draft files nothing.

Path How it calls the gate What a refusal looks like
A session filing by hand: a warp-drive decision timeout, a ritual finding ask-gate.js file exit 3, nothing filed, each missing field named
warp-drive preflight gate (host-preflight.js) askGate.admit the todo record is action: error with the refusal
warp-drive dry-run blockers (preflight.js emit) askGate.admit the blocker is listed under refused, and stderr names the reason
PM front-end (pm-frontend.js emit-todo) askGate.admit exit 3; state the fields with --kind, --minutes, --place, --tried
Rotation schedule (cred-check.js due) askGate.admit the row’s todo is action: error with the refusal
Promotion gate (setup-promotion/human-gate.js emit --apply) askGate.admit filed: false with the refusal in error

ask-gate.js file takes the flags of gh issue create that an ask needs:

Terminal window
node ~/.claude/scripts/warp-drive/ask-gate.js file \
--source warp-drive \
--title "TODO: {what's needed}" --label todo,warp-drive,p2-high \
--assignee paulirv --body-file draft.md

--body-file - reads the draft from stdin. Exit 0: filed, the issue URL is printed. Exit 3: refused, nothing filed. Exit 4: admitted, but gh issue create failed.

Every ask the gate files carries one marker naming the filing path (#3810):

<!-- ask:source host-preflight -->

The gate adds it as the last line of the body. ask-gate.js file writes session unless --source <name> names the path: warp-drive for a decision timeout, ritual for a ritual finding. A script passes source to askGate.admit and files the body the gate returns. A script that names no source is refused. A draft that already carries a marker keeps it.

Source Filing path
session ask-gate.js file with no --source
warp-drive a warp-drive decision timeout
ritual a ritual finding
host-preflight the warp-drive preflight gate
warp-preflight warp-drive dry-run blockers
pm-frontend pm-frontend.js emit-todo
promotion-human-gate the promotion gate

bob asks sources counts the asks of each source. See bob-asks.md.

A script that de-duplicates by its own marker (the preflight gate, the rotation schedule, the promotion roll-up) has already read the tracker, so it calls askGate.admit with duplicates: false and no second read is made.

A script that files asks builds the four lines with askGate.fieldLines({ kind, minutes, place, tried }). For an ask the operator does at the machine that filed it, askGate.selfPlace() gives the place: $BOB_ASK_PLACE when set, else this machine’s id in machines.json, else its hostname.

Terminal window
node ~/.claude/scripts/warp-drive/ask-gate.js file \
--title "TODO: {what's needed}" \
--label todo,warp-drive,p2-high \
--assignee paulirv \
--body-file - <<'EOF'
{One sentence: what is blocked and why.}
Kind: {decide | do | check}
Minutes: {how long it takes the operator}
Place: {a fleet machine id | phone}
Tried: {what BoB attempted before asking}
Why a human is needed: {login | decision | vendor UI | operator data | permission} — {detail}.
## Instructions
1. {Self-contained step.}
2. {Self-contained step.}
Blocked by #NN
EOF

The label list carries the priority: one of p1-critical, p2-high, p3-medium, p4-low.

A todo that is missing instructions, an issue link or an admission field is not actionable. The groom reconciliation pass adds the needs-clarification label to such todos rather than treating them as ready work.

A needs-clarification todo is:

  • excluded from “ready to complete” — it is not a candidate for the completed done-signal until it conforms, and downstream tooling (#779) must skip it; and
  • re-entered into a clarification loop — warp-drive’s next run surfaces needs-clarification todos so the gap (missing steps or link) is filled, after which the label is removed and the todo rejoins the normal flow.

This keeps an under-specified handback from masquerading as an actionable item or as a resolvable blocker.

The groom stale sweep (todo-consume.js stale, groom §5d) flags a todo whose linked issues are all closed, on the reasoning that the work it gated is gone. That is a proxy, and it is wrong whenever an issue closes for a reason unrelated to the human action still pending — the todo is then a live action item flagged as dead work.

Mark such a todo with a not-stale marker anywhere in its body:

<!-- not-stale: 2026-08-17 outage is still live; #466 closed for unrelated reasons -->

planStale() tests for the marker before the linked-issue-state test and before the stale label check, and reports the todo as pinned-not-stale. The sweep prints an EXEMPT #NN line naming the date and reason, so an exemption is visible in the sweep output rather than a silent skip.

Shape: <!-- not-stale: YYYY-MM-DD reason --> — an ISO date, then a one-line reason. Both are for the human reading the body later; the parser is deliberately permissive, so a malformed or undated marker still exempts the todo. A safety valve must not be disarmed by a typo.

When to use it. This is a deliberate, dated operator override, not a convenience — it suppresses a signal designed to stop dead todos accumulating. Use it when the human action is genuinely still required and the link-state heuristic cannot see that. Record why, and date it, so a later reader can judge whether the override still holds.

Why a marker and not a label. It is body-resident, so it survives label churn: removing the stale label by hand does not restore re-flag eligibility, which was the defect in #1663. It also matches the convention the warp-drive scripts already use — <!-- approved-at … -->, <!-- gated: … -->, <!-- flightplan … -->.

The sweep remains surface-only either way: it flags for a human to close, and closes nothing itself.

“Every link is closed” describes two opposite situations, and the sweep must tell them apart:

  1. The gated work was cancelled or became moot — truly stale.
  2. The human performed the action, the gated work then finished, and only the completed label was never applied.

#1999 was the second case: a p1 todo filed at 00:45Z, its three links closed between 01:00Z and 05:00Z because the restore was done, and the sweep labelled it stale at 05:22Z — 4.5 hours old, mid-action. That label contradicted the lifecycle contract above (only completed is a resume signal) by pushing a done todo toward a close-without-completed. Two guards now sit after the link-state test:

  • Age floor. A todo younger than _workflow.todo_stale_after_days (default 7, declared once in WORKFLOW_DEFAULTS; 0 disables) is never flagged — reported as too-young. The sweep’s real target is age, which it did not measure: the 2026-09-10 review found 10 of 14 open todos older than 30 days.
  • Ordering. When every link closed after the todo was created and at least one of them references the todo (body or comment), the verdict is likely-completed: the sweep prints LIKELY-COMPLETED #NN with the hint add completed if you did this and labels nothing — only the human may say completed. A reference check that was not performed counts as a reference (the safe direction). stale is kept for links that were already closed when the todo was filed (all-links-closed-at-creation) and for links that closed afterwards without any reference to the todo (links-closed-without-reference).

The summary line names likely-completed todos separately (N todo(s) look completed) and the sweep exits 3 for them, as it does for anything else a human should look at. The sweep needs createdAt on the todo and closedAt on each link to judge ordering; without them the pre-#2034 all-closed verdict stands.

A ref the sweep failed to read is not a ref it read and found open. Conflating the two is #1724: during the 2026-08-17 API wobble nine lookups returned HTTP 503, each collapsed silently into “not stale”, and the sweep signed off with “no stale todos — every open todo has at least one open linked issue”. It had not read most of the refs it was speaking for.

The sweep is forge health’s first consumer, and now reports what it could not establish:

  • A todo whose refs could not all be read prints as UNCHECKED #NN, naming the unreadable refs, distinct from a todo that was read and found to have open links.
  • Such a todo is never flagged stale — including when the readable subset is all closed. The old code flagged that case, which is a false positive, not merely a silent one: the unread ref could be wide open.
  • The closing line states how many todos could not be fully checked instead of asserting every link is open, and names the forge condition when it is not operational.
  • An incomplete sweep exits 3 — “there is something for a human to look at” — just as a flagged todo does.

An empty result may only be reported as an empty result when the lookups behind it succeeded.

The format contract governs a todo’s birth; the completed label governs its death. A conforming todo, once the human performs the action, gets completed (the canonical done-signal) and downstream work resumes. A todo closed without completed is cancelled/moot. See CLAUDE.md → Work Tracking → TODO lifecycle.

One cause is one todo, wherever it shows up:

  • Search before filing. When an open todo already covers the cause, comment on it. A second todo for the same cause is noise.
  • Roll up a fan-out. A cause that spans several projects or steps (the same gate in every project, one feature split into steps) is ONE checklist todo, with one checkbox per project or step. It is filed in the repo that owns the cause: a machine or BoB condition in bigbrain, never a copy in each project. The promotion mirror gate (scripts/setup-promotion/human-gate.js) works this way: one open roll-up in bigbrain, a row per project, and ticking a project’s row clears that project’s gate (completed on the roll-up clears them all).
  • Say why a human is needed. Every todo carries the element-4 line above. The reasons are closed: login, decision, vendor UI, operator data, permission. A todo none of them fits is work the agent does itself.

One cause, one todo: the preflight filer (#3161)

Section titled “One cause, one todo: the preflight filer (#3161)”

The preflight gate (scripts/warp-drive/host-preflight.js) files one todo per cause, not per run and not per project.

  • Fingerprint. A cause is one failing check on one machine. Its fingerprint is preflight:<machine>:<check>, e.g. preflight:farm-01:host:surfaces, carried in the body as <!-- warp-preflight: … -->. The other checks that failed in the same run are not part of it, so host:surfaces failing alone and failing alongside project:labels is the same todo.
  • Where it is filed. A machine condition (host, host:*) is filed in bigbrain only, never in a project repo, whichever project’s run tripped it. A project condition (project:*) is filed in that project’s repo. The bigbrain repo is read from the BOB_SOURCE checkout’s remote (BOB_BIGBRAIN_REPO overrides it).
  • Recurrence. When a todo with the fingerprint is open, the gate comments on it. When one was closed in the last 14 days, the gate reopens it with a comment if it was closed without completed, or comments that the fix did not hold if it was closed with completed (a reopened completed todo would be consumed again at once). In neither case is a second todo created. A cause last closed more than 14 days ago is filed afresh. When the tracker cannot be read, nothing is filed: a missing todo is safer than a duplicate.
  • Auto-resolve. When a later gate on the same machine finds a cause passing, it closes that cause’s open todo with a comment (“Resolved automatically …”) and never adds completed: nobody acted, the condition went away. Only a scope the gate actually checked is resolved (a host-only re-check never closes a project todo), only this machine’s todos, and only per-cause fingerprints. A todo from before #3161 (a set hash) is left alone.
  • Why a human. Each preflight todo says why the run could not act itself: the gate may not repair the machine or project it grades (#1923), and scripts/deploy.sh refuses while a Claude Code session is live.
  • The old todos. Todos the earlier filer wrote (one per project and per set of failing checks) were closed once, without completed, by scripts/warp-drive/preflight-todo-cleanup.js --apply. It searches the owner’s repos for open todo issues titled “TODO: warp-drive preflight blocked on …” or carrying a set-hash marker, and leaves per-cause and completed todos alone. It is a dry run without --apply and can be re-run safely.

A todo holds the full copy-paste steps. The handoff record is how Paul finds out a todo exists at the moment a run stops for it. Whenever a run stops for a human action, the engine writes one record per todo to .claude/.warp-handoff.json (warp-handoff/1, written by scripts/warp-drive/handoff.js). The phone push, the bob viz and warp viz banners and the terminal handoff all read that record. No surface re-derives or paraphrases it.

Stop Raised by
preflight: the preflight gate blocks the run the engine, on preflight_failed and on a failed mid-session re-check
skipped: an item is skipped as blocked the engine, on work_skipped; the gating todo is data.todo, else the first #NN in the reason. When that issue is not a todo (another req, bug or task), no record is written and no push is sent, because another run owns that work. The transition reports the item as waiting on other work, and the session summary lists it with the skipped items (#3209).
budget: a circuit-breaker checkpoint the engine, on entry to budget_exceeded (no todo; the record lasts while the run waits there)
decision-timeout: a decision timed out the agent, after filing the todo: handoff.js record --kind decision-timeout --todo <NN>
plan-drained: a flight plan is drained on a todo the engine, when a kickoff is refused as drained; the agent, when the session-end reconcile reports drained: handoff.js plan <N>

Each record carries:

  • what: one plain-English line saying what Paul needs to do. By default it is the todo’s own summary sentence, which is why that sentence must read as a standalone instruction.
  • why: what is waiting on it.
  • command: the command to copy, taken from the preflight fix or the first fenced code block under the todo’s ## Instructions. It is never taken from prose, so a file:line reference cannot become a command (#3209).
  • todo: the link to the todo.
  • text: what with the todo appended as a trailing link, e.g. Deploy BoB on farm-01 so the forge plan can run ([#3057](/reference/…)).

Beyond the attention budget (#3805). A record whose todo carries the ask-waiting label is stored with waiting: true. No push is sent for it, and the bob viz and warp viz banners and the terminal handoff leave it out; the terminal handoff ends with one count line, (N parked asks not shown: labelled ask-waiting), so a parked ask is never hidden silently (#4202). handoff.js sweep reads the label again: when it is gone the record is released and its push is sent, once. See Asks that wait.

Clear English. The text names the task and the project in words. An issue number may appear only as a trailing link in parentheses. A bare #NN, or a text that leads with a number, fails clearEnglishViolations(), which tests/test-handoff.js runs over every stop kind. You can run the same check from a shell with handoff.js check "<text>".

Phone push. Each record key is pushed once. Inside a Claude Code session the push goes through the Claude Code app: the engine hands the agent the text for its PushNotification tool, and the agent confirms with handoff.js pushed <key>. From a shell (bob tick, bob warp start) the push goes through bob notify. The push is sent whether or not RDB is on. A missing channel is recorded as no-channel and shown in the terminal handoff and the session summary. It is never skipped silently. Test runs never push: the suite runner exports BOB_TEST=1, and WARP_HANDOFF_PUSH=0 turns pushes off anywhere.

The push ends with one link, kept on the record as push.link. For a record with a todo it opens the todo’s Ask screen on BoB Control, at an address the phone can reach on the tailnet. When no dashboard server is reachable from the tailnet, the link is the todo’s issue.

Terminal handoff. handoff.js terminal prints the open records as a short numbered list of plain-English actions, each with a Why line and a Run line. When a run blocks or ends for a human, its last message is this list.

Clearing. A record is cleared once its todo is closed or carries completed. A record with no todo clears once its phase has moved on (handoff.js sweep). The banners check the todo’s state on every refresh, so a banner clears within one refresh of the todo getting completed. To remove one stale record by hand, run handoff.js clear <key> (the key is shown by handoff.js list --json). It exits 2 when there is no such record (#3343).

An item already satisfied raises nothing (#3343). If an item’s criteria are already true at planning, the run verifies them, closes the issue with the evidence, and leaves with work_skipped --data '{"satisfied":true,…}'. The item is counted as resolved (session.completed_issues, satisfied_issues), not as skipped, and no handoff is written.