Skip to content

Plan budget, Today, and lanes

By default, the plan budget allows 3 themes and 10 distinct Task Links under today's open Pomodoros. GTD is exempt. The first open, non-exempt Pomodoro is the highlight. Separately, Today lists the open tasks with a dedicated Task Link under today's open Pomodoros, and the NEXT and PENDING lanes count every [*] and [/] task visible today (caps 15 and 10). Removing a Task Link never changes a task's lane: only an explicit release (Alt+N) returns Next or Pending work to Ready.

For a typical day, capture or link work into the daily Pomodoro ledger, inspect it with read-only bob plan, then run bob task reconcile (formerly bob task-status-hooks, still accepted) after capture or session close to reconcile task statuses and clean up links. Run bob plan again for counts after that cleanup: the budget included in a reconcile run describes the ledger as it was before the run.

This page is the authoritative definition. The Rust engine (src/native/plan_budget/) implements it; bob-ledger-tools mirrors it in JavaScript, and every other surface calls one of those two instead of re-implementing it.

The ledger

The ledger is the lines of today's daily note (YYYY/YYYYMMDD.md) from a ## Pomodoros… heading up to the next ## heading. Fenced code blocks are skipped.

Entries

Column-0 checkbox list items (- [c] …). An entry is open when c is not x, X or -. Only open entries count; completed and cancelled entries are history.

Names

An entry's name is the text after — (em dash) that follows the leading () placeholder or time range. A merged name (BOB + DECKS) splits on + into components. Components compare case-insensitively after collapsing whitespace. Names are optional; an unnamed entry still shows its derived homes.

Exempt entries

Components listed in plan.exempt (default [GTD]) are never themes. An entry whose components are all exempt is ignored for links too.

Themes

The number of distinct non-exempt components across open entries. An unnamed open entry counts as one theme, shown as (unnamed), but only when it holds at least one counted link. An empty () placeholder never counts. The remembered catalog, hues, hide state, and bob themes command live in Pomodoro themes.

The number of distinct (target, block_id) pairs among block links on indented lines below an open, non-exempt entry, up to the next column-0 entry, heading, or prose line. Indented prose lines count too; the budget checks link syntax but does not look up the target task. Accepted forms:

  • [[target#^id]], ![[target#^id]], and [[target#^id|alias]];
  • with or without 🍅 markers;
  • with or without a trailing # move-only marker.

Excluded:

  • links inside ~~…~~ (struck);
  • links in fenced code.

The target is compared with any .md suffix removed. An empty target means the daily note itself. A link planned under two entries counts once.

Highlight and running

Highlight is the first open non-exempt entry in ledger order. Running is the open entry whose body starts with a time range.

Status

over when themes > max_themes or links > max_links; otherwise ok. Being exactly at the cap is fine.

Lints

Each lint has a stable code, a message, and a 1-based line when one applies:

Code When
plan_theme_cap_exceeded themes are over the cap
plan_link_cap_exceeded links are over the cap
duplicate_open_pomodoro_name the same non-exempt component is on two open entries
inventory_label_open an open component is in plan.inventory_labels; it still counts as a theme
subheading_in_pomodoros a ###–###### heading sits inside the section; it splits the heading's time total
next_cap_exceeded NEXT is over the cap; never changes status, nothing is refused
pending_cap_exceeded PENDING is over the cap; never changes status, nothing is refused
ready_cap_exceeded READY is over the cap; Obsidian bob-plan block only (bob plan has no READY count); never changes status, nothing is refused
note_ready_cap_invalid a note's ready_cap is not 1–999 or off; emitted once per note, then the default cap applies
note_ready_in_terminal_project a done/canceled project still holds counted Ready tasks; not capped, listed under "not capped"
today_link_unresolved a ledger link resolves to no countable task (missing or ambiguous note, unreadable file, or no open task behind the block ID)

Today

Today is the open tasks with a dedicated Task Link under today's open Pomodoros, computed at read time. It is never a tag, a task-line field, or a file-path filter.

  1. Ledger and open entries are exactly the plan budget's: today's daily note (YYYY/YYYYMMDD.md), its ## Pomodoros… section, column-0 checkbox entries; an entry is open unless its status is x, X, or -.
  2. Every open entry counts, including exempt ones (GTD) and unnamed placeholders.
  3. Only dedicated Task Links count: a direct child bullet, at the entry's first child indentation, whose body after stripping 🍅 markers is exactly one plain [[target#^id]] or embedded ![[target#^id]] block link, not struck through and not fenced. Deeper descendants and mixed-text bullets do not count. This is exactly list_queued_links in src/native/capture_pomodoro_start.rs (the x / start lineup rule).
  4. Resolution: an empty target is the daily note itself; otherwise the hooks' rules apply (exact vault-relative path with or without .md, then a unique case-insensitive basename). Unresolved or ambiguous links are skipped; Rust reports them as the lint today_link_unresolved. JavaScript uses metadataCache.getFirstLinkpathDest(target, dailyPath); ambiguous basenames are a documented divergence, and no conformance vector uses one.
  5. Today's tasks are the resolved #task lines with that block ID whose status is open (Ready, Next, In Progress, or Blocked); done and cancelled tasks drop out. Deduplicate by (path, block ID), keeping the ledger order of first occurrence. The key is "<vault path with .md>#<block id>".
  6. Dependency targets do not inherit Today; the hooks still promote them to Next.

Today marks show this set on eligible task lines as a display-only 🍅; see the Today marks display contract.

The daily and dashboard TODAY badge shares the Work badge colors with PENDING, NEXT, and READY: it colors budget.links.count / budget.links.cap with the same grey/blue/green/yellow/orange/magenta/red bands, except budget.themes.count > budget.themes.cap forces red, including when links are zero. Themes exactly at cap keep the link-based color. Missing ledger data or a missing Pomodoros section is TODAY –, never a synthetic 0/limit; an existing empty Pomodoros section is grey.

Lanes (NEXT and PENDING)

The daily lane review in docs/freshness.md §4 walks Pending and Next tasks once a day on their lane interval, with PRE before NEW for #gtd #pre checklist chores, POST after ROTTEN for #gtd #post closeout, and the PROJECTS tier between NEW and PENDING for visible ^prj reminders on project_interval / the Ready chain. Reference tasks use ordinary freshness and enter their lane group, NEW, TICKLER, or ROTTEN as appropriate. A disabled lane walk never disables PROJECTS review.

NEXT is every [*] task and PENDING every [/] task that the dash's defaults show: not done, not dependency-blocked, not #hide (case-insensitive substring, so #hide/x and #Hide are out), not under _templates, not under _conflicts (both case-insensitive), not in dash.md itself for dashboard sections, and no scheduled date after today. Lane visibility is shared by the dashboard sections and the whole-lane budgets through one tested base predicate; status and TODAY are layered on top (PENDING uses IN_PROGRESS, NEXT uses symbol *).

A NEXT task is a #task line that bob-cli's native-internal lane query matches with this query (status.symbol is is native-internal syntax, not valid Obsidian Tasks syntax):

not done
status.symbol is *
is not blocked
tags do not include #hide
folder does not include _templates
path does not include _conflicts
(no scheduled date) OR (scheduled on or before today)

The native-internal PENDING query is identical with status.symbol is /; the dashboard PENDING block uses status.type is IN_PROGRESS instead. The dashboard NEXT block uses filter by function task.status.symbol === "*" (Tasks 8.4.0 syntax for the same [*] selection). This matches the dash's own defaults.

The dashboard and the whole lane differ on TODAY by design. The dashboard PENDING/NEXT sections exclude TODAY (plus dash.md itself); bob plan, navigation notices, native CLI parity, and other callers keep the whole lane, Today included, so those counts don't swing during the day. The dashboard badges and the daily bob-plan PENDING/NEXT badges show the section count over the cap as the primary number (for example PENDING 9/10) and share the Work badge colors with TODAY and READY: for an available section count n and cap L, n === 0 is grey, 0 < n < 2 * L / 5 blue, 2 * L / 5 <= n < 3 * L / 5 green, 3 * L / 5 <= n < 4 * L / 5 yellow, 4 * L / 5 <= n < L orange, n === L magenta, and n > L red, using exact comparisons. For example, NEXT section 10/15 with whole lane 17/15 and TODAY 7 is yellow, while section 15/15 with a larger whole lane is magenta. Whole-lane pressure remains clearly labeled in the tooltip and accessible label (for example NEXT 10/15; whole lane 17/15; 7 in TODAY; 2 over the whole-lane cap), so TODAY-driven excess does not color an at-cap section red. Orange (80% up to the cap) and magenta (exactly at the cap) are presentation states, not lints or over conditions. Magenta marks a lane that is exactly full, and no other Bob chip uses that hue. Daily badges on older notes follow today's live section, while that note's TODAY theme/link budget keeps its own ledger. Daily next_cap_exceeded / pending_cap_exceeded lints follow the badge's section count and appear only when that count is strictly over its cap. The bob plan CLI warnings and navigation notices still check the whole lane, including TODAY. An unavailable section is –, never a synthetic zero. dashboardLaneBudget("pending" | "next") in bob-ledger-tools is the versioned dashboard contract. When Tasks data or the current-day Today cache is not ready, the dashboard section is unavailable (–), never a silent zero; a plugin too old for that API keeps the inline fallback, which applies the same base visibility and whatever TODAY predicate is available.

In today's daily note, every Task Link under an open Pomodoro whose target task is In Progress ([/]) shows a rendered amber half-ring mark; a Next ([*]) target shows none. With the cursor on any Pomodoro Task Link, Alt+[ or Alt+] toggles its task between Next and In Progress, offering an optional Work Log entry when it goes back to Next. Nothing is ever written into the daily note: the mark is display-only and the toggle rewrites only the task line in its own note.

## Pomodoros
- [x] (**0900-0930** [t:: 30m])
    - 🍅 Fix the parser crash
    - 🍅 Review Ana's PR
- [ ] (**0940-1010** [t:: 30m])
    - ◐ Fix the parser crash           ← target is In Progress [/]
    - Write release notes              ← target is Next [*], no mark
- [ ] ()
    - ◐ Benchmark the cache
    - Answer support email

Hovering the mark shows a two-line tooltip: In Progress / Alt+[ or Alt+] → Next.

Principles

  • The mark is rendered, never written. Status lives on the task line; writing it into the ledger would repeat the #today-tag mistake (churn, races, staleness) and break every dedicated-Task-Link parser, which accepts only 🍅 and ! prefixes. A written ◐ prefix would drop links out of Today, x numbering, and Work Log routing, and make reconcile write ◐ 🍅 [[…]]. The text-marker alternative was rejected for this reason. Without the plugin the vault looks exactly as before.
  • One marker slot, two tenses. The mark sits immediately before the link token, the same slot 🍅 takes under closed Pomodoros: 🍅 means worked in a past session, ◐ means in progress now.
  • Truthful or neutral. A link gets the mark only when all of these hold: the open file is today's daily note; the link sits under an open Pomodoro entry (running or planned placeholder, named or unnamed); it is a dedicated plain Task Link at the entry's first child indent (exactly one [[target#^id]] with an optional alias, after stripping a leading 🍅 and an optional trailing # move-only marker); and the target resolves to a list item whose checkbox is exactly /. Everything else shows nothing: Next, Ready, Blocked, done, unresolved or non-task targets, struck ~~[[…]]~~ links, embedded ![[…]] links (the transclusion already shows its own amber checkbox), fenced lines, deeper nested bullets, links mixed with other text, other daily notes, and links outside ## Pomodoros.

Glyph anatomy

The mark is a ring with its right half filled (a Linear-style "in progress" circle: started, not finished), so the shape carries the meaning and never relies on color alone. Its ink is var(--bob-progress-mark-color, var(--task-status-in-progress, var(--color-yellow, #c69026))), the status hue the vault already gives [/] checkboxes; the --bob-progress-mark-color theme hook is unset by default. It renders as a 1.15em inline box that never changes line height, with a 160 ms entrance and 120 ms hover transitions; hovering sets full opacity with a 12% tint capsule. With prefers-reduced-motion: reduce the animation and transitions are off. The Toggle Task Link In Progress marks command flips a session toggle (default on).

Toggle scope

Alt+[ and Alt+] become a lane toggle on a Pomodoro Task Link line: a line the link picker accepts (dedicated bullet — plain, 🍅, # move-only, struck, or embedded) that sits inside a Pomodoro entry's sub-bullet block within the ## Pomodoros section of a daily-note-shaped path. The entry can be open or closed; toggling from a 🍅 history line changes the task, not the line. Both keys do the same thing, because a two-state ring has no direction, and the bullet is never reformatted. Unchanged: task and checkbox lines (including the Pomodoro entry line itself and [x] [[…]] checklist rows), Depends-On lines, embedded links outside Pomodoros, and the formatting toggle on every other plain bullet (including plain link bullets outside Pomodoros).

Only two statuses participate:

Target status Single press In a counted batch
Next [*] → In Progress [/] "start"
In Progress [/] prompt, then → Next [*] "pause"
Ready [ ] refused: Task is Ready · Alt+N commits it to Next skipped, counted
Blocked [?] refused: Blocked is derived · clear its dependency or future schedule first skipped, counted
Done [x] / Cancelled [-] (incl. struck links) refused: Task is done · Ctrl+Enter reopens it / Task is cancelled skipped, counted
Missing, duplicated, non-task, or unreadable target the existing resolver error Notice whole batch refused (as Alt+N)

Counts

A count covers the cursor link plus the next N sibling Task Links, clamped at the Pomodoro entry's end — the same rule Alt+N uses. The mode is decided once, across all note groups: if any target is Next, every Next target goes to In Progress and In Progress targets are left alone ("start wins"); otherwise, if any target is In Progress, every In Progress target goes to Next with one shared prompt and its entry logged on each task; otherwise the batch is refused with the first target's refusal. Duplicate targets are written once.

The Move to Next prompt

Going from In Progress back to Next first opens a small Move to Next prompt: the task (or N tasks with up to three names and The same entry is logged on each task for a batch), an optional Work summary (optional) field, and a live preview of the Work Log entry (🛠️ **WORK LOG** / *YYYY-MM-DD* — summary, or No work-log entry will be added. when blank). Enter commits — a blank summary moves the task without logging — and Esc cancels the whole gesture with nothing written and no Notice. The entry is prepended newest-first under an existing direct-child 🛠️ WORK LOG or a newly created one.

Notices and instant feedback

A successful toggle shows a short Notice, for example ◐ In Progress · Fix the parser crash · PENDING 8/10 · NEXT 11/15 or → Next · Fix … · logged, with skipped counts and lane-budget suffixes (🔴 when over a lane cap) as needed. Because open target notes refresh their metadata cache only after autosave (~2 s), the toggle hands the new status to bob-ledger-tools as an optimistic hint, so the mark flips on the same frame; the hint wins until the cache agrees or 4 s pass.

Hooks interplay

Sticky lanes still hold: this gesture moves a task between Next and Pending and never returns it to Ready — only Alt+N release does. Every toggle stamps [fresh:: today]. bob task reconcile never lowers [/] and leaves a directly linked [*] alone, so it keeps both directions. One edge case: a task that is a prerequisite of In Progress work reachable from today's open Pomodoros is raised back to [/] by dependency inheritance. Blocked stays derived and is never a destination. Task Links are never review-walk landings, so the gesture neither captures nor advances the walk.

Plugin APIs

Both contracts are additive namespaces; the top-level api versions stay as they are (ledger-tools v3, nav v3) and consumers feature-detect the namespace version. ledger-tools exposes api.progressMarks v1 (expect(entries), refresh(), isEnabled() — all synchronous, never throwing): optimistic { path, blockId, status } hints that override the cached status until the cache agrees or 4 s pass. Nav exposes api.taskLinkLane v1: matches({ content, line, path }) (synchronous, never throws; the single definition of "Pomodoro Task Link line") and toggle({ editor, view, countExplicit, additionalTaskCount }) (resolves { ok: true, mode: "start" | "pause", changed } or { ok: false, reason }, never rejects; a second call while one is in flight resolves { ok: false, reason: "busy" }). Without nav or its namespace, task-status-cycler keeps its legacy Alt+[ / Alt+] behavior.

Conformance vectors

Notation: D is today's daily note, and T is a project note holding - [s] #task Fix ^fix.

Marks (P)

ID Setup Mark?
P1 open timed entry, \t- [[T#^fix]], s=/ yes, before [[
P2 same, s=* no
P3 open placeholder - [ ] () entry, s=/ yes
P4 named open entry - [ ] () — GOALS, aliased [[T#^fix\|Fix]], s=/ yes
P5 closed entry - [x] (…) with \t- 🍅 [[T#^fix]], s=/ no
P6 open entry, \t- ![[T#^fix]], s=/ no (embed shows its own checkbox)
P7 open entry, \t- ~~[[T#^fix]]~~, s=/ no
P8 open entry, \t- [[T#^fix]]# and \t- [[T#^fix]] #, s=/ yes, both
P9 open entry, nested \t\t- [[T#^fix]], or \t- read [[T#^fix]] today, s=/ no
P10 [[T#^fix]] inside a fence, outside ## Pomodoros, or in yesterday's daily note no
P11 unresolved target, or a non-task block id no
P12 s is , ?, x, or - no
P13 target task lives in D itself, edited live to [/] yes, read from the live doc without waiting for the cache
P14 expect([{T, fix, "/"}]) while the cache still says * yes until the cache says / or 4 s pass; after 4 s the cache wins

Toggle (L)

ID Cursor / setup Result
L1 Alt+] on a Next link [*]→[/], [fresh:: today] stamped, no prompt, Notice ◐ In Progress · Fix …, mark appears
L2 Alt+[ on an In Progress link, blank summary prompt, [/]→[*], no Work Log entry, Notice → Next · Fix …
L3 as L2 with Finished the API review entry *YYYY-MM-DD* — Finished the API review prepended under the existing 🛠️ WORK LOG (or a new marker), Notice ends · logged
L4 as L2, Esc nothing written, no Notice
L5 Alt+[ vs Alt+] identical results
L6 Ready / Blocked / done / cancelled / struck-done target refusal Notices from the scope table, nothing written
L7 2<A-]> over [*] [/] [*] siblings both Next become In Progress, the In Progress one is untouched, no prompt
L8 2<A-]> over [/] [/] [?] one prompt, both In Progress become Next with the same entry, Notice includes 1 Blocked skipped — Blocked is derived
L9 5<A-]> with 2 siblings left clamped at the entry end, no error
L10 Alt+] on a closed-entry 🍅 [[T#^fix]] line toggles the task; the line is unchanged
L11 target edited while the prompt is open A linked note changed; no tasks were updated, nothing written
L12 embedded ![[T#^fix]] under a Pomodoro two-state toggle (no longer the full ring)
L13 Pomodoro entry line, a task line, a Depends-On line, or a plain link outside Pomodoros behavior exactly as before
L14 the same task linked twice in a counted batch written once
L15 second Alt+] while a toggle or prompt is in flight swallowed (busy), no write

Live verification (Bryan, in Obsidian)

  • [ ] Light and dark: the ◐ mark reads amber in both, with its shape (not just color) carrying the meaning.
  • [ ] The mark stays visible with the cursor on its line.
  • [ ] Toggle with the target open in a split: the mark flips on the same frame, before autosave.
  • [ ] A counted batch over mixed Next / In Progress / Blocked siblings prompts once and reports the skips.
  • [ ] Esc in the Move to Next prompt writes nothing and shows no Notice.
  • [ ] Reading view shows the same marks as Live Preview.

READY backlog (dashboard and daily badge)

READY is the freshness-gated global backlog the dash.md READY chip and the daily bob-plan READY badge share: the visible TODO pool (see docs/freshness.md §4) minus the NEW and ROTTEN review buckets, regardless of which daily file hosts the badge. It holds recently human-confirmed tasks plus the preexisting freshness-exempt Ready tasks. It excludes completed, cancelled, and non-task entries; template and _conflicts paths; dash.md itself (ordinary ready tasks living in a daily note remain eligible); #hide; dependency-blocked tasks (isBlocked against the full Tasks list); future-scheduled tasks (unscheduled and scheduled on or before the current local day count); Today tasks (isToday); and any task whose read-time bucket is new or rotten. A null bucket alone never proves a task is Ready. The count is the Obsidian dashboard backlog and its feedback is the shared badge; this feature adds no native READY count, bob plan lint, capture enforcement, or tmux meter. The daily bob-plan block shows a ready_cap_exceeded lint line beneath its chips when READY is strictly over the cap. A cap exactly met raises no lint.

The badge shows READY n/cap (for example READY 87/100) and shares the Work badge colors: 0 grey, 0 < n < 2 * L / 5 blue, 2 * L / 5 <= n < 3 * L / 5 green, 3 * L / 5 <= n < 4 * L / 5 yellow, 4 * L / 5 <= n < L orange, n === L magenta, and n > L red, using exact comparisons. Orange from 80% up to the cap and magenta exactly at the cap are presentation states, not lints or over conditions; only a strict excess raises the ready_cap_exceeded lint (READY 101/100). This is a soft limit: it communicates backlog pressure without refusing capture, changing task statuses, or removing tasks. The cap now bounds only the confirmed/exempt pullable backlog: skipping review can lower READY without lowering total lane pressure (NEW + ROTTEN + READY). The tooltip carries the whole lane, for example READY 120/100 · lane 210 = 3 new + 87 rotten + 120 ready. ROTTEN is the counterweight: ROTTEN 31 · ✓ 12 (or ✓ 12/15 with a budget). Clicking the badge opens dash#READY Tasks. The badge on an older daily file is a live current backlog, not a historical snapshot: Today exclusion and scheduled-date eligibility use the current day even when the PLAN portion describes that older file's ledger. READY – means Tasks data, required Today state, or the count is unavailable; zero is reserved for a successfully evaluated empty queue.

Ready cap per note

Every area/project note has a soft cap on its Ready lane (plan.max_ready_per_note, default 5, per-note ready_cap override). Crowded notes are named, counted, and easy to act on: in bob ready, a dash CROWDED chip, and a live chip on each note's ## Tasks heading. All surfaces share one read-time contract, implemented in Rust (src/native/note_ready/) and in bob-ledger-tools and pinned by shared vectors R1–R14.

The cap counts the whole Ready lane by residence, whatever its freshness: the lane (not the gated READY backlog) is counted because a gated per-note count would hide work behind the rot cliff and invert review (skipping review would lower the count without lowering pressure).

counted(t)  = lane(t) ∧ ¬recurring(t) ∧ block_id(t) ≠ "prj"
lane(t)     = the existing READY_QUERY / readyTaskVisible ∧ ¬planTaskIsBlocked predicate:
              TODO-type status, not done, not dependency-blocked, no #hide tag, not under
              _templates/ or _conflicts/, no scheduled date or scheduled ≤ today.
              Freshness bucket and Today are NOT filters (whole lane).
note(t)     = the vault file that holds t (residence; never parent, heading, embed, backlink)
eligible(n) = type(n) ∈ {[[area]], [[project]]} (quoted, single-quoted, bare, flow-list,
              block-list forms) ∧ (area ∨ status ∉ {done, canceled, cancelled})
              ∧ path not under .git/ .obsidian/ _templates/ _conflicts/ _generated/ done/
cap(n)      = ready_cap integer 1–999 → (cap, source "note")
              | ready_cap off / false → exempt
              | ready_cap present but invalid → lint note_ready_cap_invalid, then default
              | absent → plan.max_ready_per_note (source "config") else 5 (source "default")
count(n)    = |{ t : note(t) = n ∧ counted(t) }|   (each row counts; nested child tasks count)
make_up(n)  = { new, rotten, ready = count − new − rotten } from freshness buckets
              (rotten includes resurfaced; null when freshness is unavailable)
recurring(n)= lane rows in n excluded only because they recur (shown as ↻ k)
state(n)    = exempt | crowded (count > cap) | full (count = cap) | room (0 < count < cap)
              | empty (count = 0); surfaces add "unavailable" when no snapshot exists
totals      = notes (eligible, not exempt) with areas/projects split, crowded, full, room,
              empty, exempt, counted (Σ count over capped notes),
              excess (Σ max(0, count − cap)), recurring
lint note_ready_in_terminal_project: a done/canceled project still holding counted rows
              (not capped; listed under "not capped")

The note-entry field names are shared by CLI JSON and api.noteReady: path, name (stem), kind (area|project), status, parent, count, cap, cap_source (note|config|default|preview), state, over_by, make_up ({ready,new,rotten} or null), and recurring. Lints are {code, path, message}, emitted once per note, never once per row.

Words: a note is crowded, full, or has room. An exempt note has no cap. The verbs are always split · sequence · defer · drop. The UI never suggests "promote to Next".

States: room is dim/muted (ready 3/5); full is muted with · full text and no amber; crowded is red with +k (bob-plan-over in Obsidian); exempt is muted (ready 65 · no cap / no cap); unavailable is –, never 0; fixed is green (CROWDED 0 ✓). Color always comes with text. Numbers use tabular numerals with a stable width. Obsidian styling uses theme variables only (no hex), and motion respects prefers-reduced-motion. Chips and rows carry aria labels and tooltips that spell out the count, for example 11 ready-lane tasks = 11 ready + 0 new + 0 rotten · cap 5 (Bob config) · 6 over · split, sequence, defer, or drop.

ready_cap: a per-note frontmatter integer 1–999, or off (case-insensitive) or YAML false for exempt. YAML 1.1 parsers read a bare off as false. An invalid value emits note_ready_cap_invalid once per note and falls back to the default.

Failure behavior: an invalid global plan.max_ready_per_note exits 2 for bob plan/bob ready (with a JSON error envelope under -f json); other callers continue with defaults or an unavailable budget as described under Config. An invalid per-note ready_cap produces a lint and falls back to the default. A vault I/O failure exits 1. A non-Dataview task format exits 2. The native bob ready scan also reads freshness: config to report the NEW/ROTTEN make-up; invalid freshness config exits 2 rather than showing a count-only report. The plugin can still show counts with make_up: null when freshness is unavailable. Crowded notes never fail the report. Only --check maps them to exit 3 (in bob ready). A missing Tasks plugin or a non-Warm Tasks cache gives available: false in the plugin, and every surface shows –. Unavailable freshness gives make_up: null; counts are still shown.

Mobile: config.yml is unreadable there, so the default cap applies and the tooltip says so. Per-note ready_cap works everywhere.

Conformance vectors (copied verbatim into Rust and JS tests):

# Fixture Expected
R1 6 [ ] tasks, default cap count 6, crowded, over_by 1, excess 1
R2 5 [ ] tasks full, no lint
R3 #hide, #Hide, #hide/x, future scheduled, dependency-blocked, [?], [*], [/], [x], [-] none count
R4 3 tasks: one unstamped (NEW), one stamped 8 days ago (ROTTEN), one fresh count 3, make-up 1 ready + 1 new + 1 rotten
R5 [repeat:: every day] and 🔁 tasks not counted; recurring 2
R6 a visible ^prj task not counted
R7 tasks in a daily note, an untyped note, _templates/, done/, dash.md absent from the per-note list
R8 ready_cap: 8 / off / OFF / false / 0 / lots / 1000 cap 8 (source note) / exempt / exempt / exempt / lint then default / lint then default / lint then default
R9 a parent [ ] with 2 child [ ] tasks count 3
R10 status: done (and cancelled) project with 2 [ ] tasks not capped; lint note_ready_in_terminal_project
R11 type: "[[project]]", type: '[[area]]', bare type: [[project]] (Obsidian parses it as nested array [["project"]]), flow list ["[[area]]"], block list, nested folder x/y.md all eligible; Rust and JS agree
R12 stamping [fresh::] on a NEW task (Ctrl+Alt+F) in a note at 5 count stays 5 (full); make-up moves 1 from new to ready
R13 a Today-linked [ ] task counted (whole lane)
R14 plan.max_ready_per_note: 3; one note with ready_cap: 8 other notes cap 3 (source config); that note cap 8 (source note)

Config

# Today's plan budget (bob plan, capture, tmux, Obsidian) and the NEXT/PENDING/READY lane caps.
plan:
  max_themes: 3 # distinct open Pomodoro names besides the exempt ones
  max_links: 10 # distinct open Task Links outside exempt entries
  max_next: 15 # open Next tasks visible today (see above)
  max_pending: 10 # open In Progress tasks visible today (see above)
  max_ready: 100 # soft limit for dashboard READY tasks, excluding Today (see above)
  max_ready_per_note: 5 # soft cap per area/project note; frontmatter ready_cap overrides
  strict: false # refuse #NAME captures that would create a theme past max_themes
  link_unblocked: true # offer a closed planned task's unblocked dependents, project residents, and project pull-ins (docs/task-dependencies.md §12); false recovers only
  exempt: [GTD] # open entries that never count as themes
  inventory_labels: [LATER, MISC, NEW FEATURES, SASE] # open names that are storage, not themes

The block is optional. A missing file or a missing block means the defaults above, shared by Rust and JavaScript. Unknown keys stay ignored.

Validation. Caps are integers ≥ 1 (max_ready_per_note is 1–999), strict and link_unblocked are booleans, and the lists hold non-empty strings.

Invalid values:

  • bob plan and bob ready exit 2 with a clear message.
  • Capture skips its plan_budget report and adds a warning string; hooks return plan_budget: null with a stderr warning. Their vault writes continue, and capture's strict plan enforcement is unavailable.
  • An invalid link_unblocked falls back to true through the same warning paths: close-time recovery still runs, and offering stays on.
  • Capture completion omits the plan-creation hint.
  • The native tmux meter silently uses defaults. Obsidian budget surfaces use defaults and flag invalid config in their feedback. Invalid plan config does not stop these workflows.

bob plan

bob plan is read-only. Options: -b/--bob-dir DIR, -f/--format human|json, -h/--help. Environment: BOB_DIR, BOB_DAY_FILE, BOB_NOW, BOB_CONFIG_FILE, NO_COLOR.

JSON adds ok: true and schema_version: 2 to the report. This example shows all report fields for one open theme and one exempt entry:

{
  "date": "2026-10-01",
  "daily_file": "2026/20261001.md",
  "caps": { "max_themes": 3, "max_links": 10, "max_next": 15, "max_pending": 10, "max_ready": 100, "strict": false },
  "status": "ok",
  "themes": { "count": 1, "cap": 3, "over": false },
  "links": { "count": 3, "cap": 10, "over": false },
  "today": { "count": 2 },
  "next": { "count": 12, "cap": 15, "over": false },
  "pending": { "count": 8, "cap": 10, "over": false },
  "today_tasks": [
    {
      "path": "sase.md",
      "block_id": "fix-it",
      "line": 120,
      "status_symbol": "*",
      "status_name": "Next",
      "text": "Fix it",
      "entry_line": 55,
      "entry_name": "BOB",
      "ledger_line": 56
    }
  ],
  "theme_names": ["GOALS"],
  "theme_refs": [{"key": "goals", "name": "GOALS", "hue": "red",
    "hue_source": "hash"}],
  "entries": [
    {
      "line": 24,
      "name": "GOALS",
      "components": ["GOALS"],
      "exempt": false,
      "running": true,
      "highlight": true,
      "time_range": "0945-1015",
      "links": 3,
      "themes": [{"key": "goals", "name": "GOALS", "hue": "red",
        "hue_source": "hash"}],
      "homes": {
        "state": "forecast",
        "kind": "work",
        "label": "sase",
        "chip": "sase",
        "hue": "orange",
        "parts": [
          {
            "key": "sase.md",
            "label": "sase",
            "kind": "project",
            "area": "gtd.md",
            "area_label": "gtd",
            "hue": "orange",
            "tasks": 1,
            "share": 100
          }
        ]
      }
    },
    {
      "line": 30,
      "name": "GTD",
      "components": ["GTD"],
      "exempt": true,
      "running": false,
      "highlight": false,
      "links": 0,
      "homes": {
        "state": "forecast",
        "kind": "review",
        "label": "GTD review",
        "chip": null,
        "hue": "orange",
        "parts": [
          {
            "key": "@review:GTD",
            "label": "GTD review",
            "kind": "bucket",
            "area": "gtd.md",
            "area_label": "gtd",
            "hue": "orange",
            "tasks": 0,
            "share": 100
          }
        ]
      }
    }
  ],
  "warnings": [],
  "ok": true,
  "schema_version": 2
}

theme_names lists the ordered theme names alongside theme_refs; the refs use the same order. Each named entries[] row carries its component refs in themes. The arrays are additive and keep schema_version: 2. Lint objects omit line when none applies; entries omit time_range when untimed. Each entries[] object also carries the additive homes object from Pomodoro homes (schema 2 is unchanged). Open entries are forecasts. today_tasks is in ledger order of first occurrence; today.count is its length.

Human output:

bob plan · Thu 2026-10-01 · 2026/20261001.md

  PLAN  3/3 themes · 7/10 links      TODAY 7 · PENDING 8/10 · NEXT 12/15

  ★ ■GOALS   ▶ 0945-1015   3 links
    …

  TODAY
    [*] sase#^fix-it        Fix it
    [/] bob#^capture-stop   Better capture stop

An unnamed entry prints the same dim homes chip with its dot that capture-pomodoros --tasks uses, after its (unnamed) name:

  ★ (unnamed) ○ sase  ▶ 1020-1050   1 link

Meters are green within the cap and red when over. ★ is yellow and ▶ is cyan; exempt rows are dimmed. When lints occur, they appear below the entries in yellow, with the code dimmed. An open inventory label such as MISC appears as a row and counts as a theme even though it also raises inventory_label_open.

With no daily note the header says no daily note yet; with no Pomodoros section it says no Pomodoros section. TODAY 0 and the lanes are still shown, and the command exits 0. Exit codes: 0 for a report, 1 for an I/O failure, 2 for usage or an invalid plan config.

bob ready

bob ready is read-only. Options: [NOTE], -a/--all, -b/--bob-dir DIR, -n/--cap N, -c/--check, -f/--format human|json, -h/--help. Environment: BOB_DIR, BOB_NOW, BOB_CONFIG_FILE, NO_COLOR.

The overview lists every area/project note against its cap: crowded notes with red overflow bars, full notes up to the cap marker, room notes as a compact name count list, and a footer with the empty count, the not-capped list, and the recurring total. -a expands room notes into bar rows, lists empty names, adds terminal projects to the not-capped list, and prints a LINTS block in the freshness style. With nothing crowded the summary reads CROWDED 0 ✓ · every note has room in green, and the quickest win (Make room → bob ready <note>, the smallest excess) is omitted. --cap N previews a different default cap for the run only: note overrides and exemptions still apply, and affected entries report preview as their cap source. --check exits 3 when any note is crowded, still printing the report (JSON included).

bob ready NOTE lists one note's lane tasks in file order with path:line references, new / rotten Nd / fresh Nd labels, and ^block-id markers, plus an also here line (next, pending, blocked, recurring) and a make-room footer. NOTE resolves as a vault path (with or without .md), then an exact stem, then a case-insensitive stem. An ambiguous stem exits 2 listing every candidate; an unknown or non-area/project note exits 2 with up to three closest-name suggestions.

JSON adds ok: true, schema_version: 1, definition: "ready_lane", and the shared note-entry fields (path, name, kind, status, parent, count, cap, cap_source, state, over_by, make_up, recurring) to the report. The overview carries notes (every eligible and exempt note, regardless of -a); the worklist carries note, tasks (path, line, text, block_id, bucket, fresh_on), and also (next, pending, blocked, recurring). Errors use {ok: false, schema_version: 1, error: {code, message}}.

Human output (no ANSI without a TTY or with NO_COLOR, same glyphs and text):

bob ready · Thu 2026-10-01 · cap 5 per note

  CROWDED 1 · 2 over · 1 full · 3 notes (1 area · 2 projects)

  CROWDED
    sase_remote  7/5  ■■■■■│■■  +2   project · parent sase · 6 new
  FULL
    bob  5/5  ■■■■■│  full   project · 5 new
  ROOM
    cash 2

  1 empty · not capped: gkeep_inbox 1 (ready_cap: off) · ↻ 1 recurring   (-a for all)

  Make room → bob ready sase_remote
  split Ctrl+Shift+N · defer Ctrl+Shift+P 1–4 · drop Ctrl+Shift+P x · sequence Ctrl+Shift+P b

The last hint line always names the Task Card keys: defer Ctrl+Shift+P 1–4, drop Ctrl+Shift+P x, and sequence Ctrl+Shift+P b. The Task Card is the only Ctrl+Shift+P surface, so the hint does not depend on the date (BOB_NOW changes the scan's day, not this line) and never mentions searching.

Exit codes: 0 for a report (even when notes are crowded), 1 for a vault I/O failure, 2 for an invalid plan.max_ready_per_note, a non-Dataview task format, or an unresolvable note, and 3 with --check when at least one note is crowded.

Surfaces

Surface What it shows
bob plan The full plan report: meters, today's themes (★ highlight, ▶ running), the TODAY list, and lint messages with codes
bob ready The per-note Ready cap report: crowded/full/room bar overview, the single-note worklist, schema-1 JSON, --check (exit 3), and --cap preview
Daily note with a bob-plan code block The Bob Ledger Tools plugin (api v3 with freshness namespace v4: isToday, todayRank, nextBudget, pendingBudget, dashboardLaneBudget, renderDashboardLaneBadge, readyBudget, renderReadyBadge, renderReviewChip, freshness.reviewModel) renders TODAY, PENDING, NEXT, READY chips, a theme line, and any lints. Task Links under today's open Pomodoros carry a rendered half-ring In Progress mark (◐) when their target is [/]; Next targets show none (see ## In Progress marks and the Task Link lane toggle). TODAY is the theme/link budget (TODAY 3/3 · 7/10, or TODAY – with no Pomodoros section). PENDING, NEXT, and READY show – when their data is unavailable; the shared READY badge is the freshness-gated live current backlog (READY n/100 with a whole-lane tooltip) that opens dash#READY Tasks and never changes the PLAN status. Daily PENDING/NEXT badges show the live dashboard section counts (PENDING n/cap, NEXT n/cap, or – when Tasks/Today data is unavailable), including on older daily notes; pendingBudget/nextBudget keep whole-lane semantics for CLI and notices. Daily cap lints appear only for a strict section excess and quote the badge's section count; bob plan CLI warnings and navigation notices still use whole-lane counts, including TODAY.
dash.md Its ten badges in grouped Review / Work / Browse navigation above ## Tasks and mutually exclusive TODAY / NEW / PENDING / NEXT / READY sections (section order TODAY → NEW → PENDING → NEXT → READY) use the Bob Ledger Tools api v3 with freshness namespace v4 (dashboardLaneBudget/renderDashboardLaneBadge for PENDING/NEXT sections, readyBudget/renderReadyBadge for gated READY plus renderReviewChip/reviewModel for NEW/ROTTEN, with a guarded inline fallback when the plugin is older or unloaded) plus the noteReady namespace v1 renderCrowdedChip and the dashboardCollections namespace v1 snapshot/renderChip for PROJECTS/REFERENCES on the Browse row (each guarded with a clickable unavailable fallback when the plugin is older, unloaded, or throwing). Work is TODAY · PENDING · NEXT · READY; Review is NEW · ROTTEN · CROWDED; Browse is PROJECTS · REFERENCES · BLOCKED opening dash_projects, dash_references, and blocked.md. dashboardCollections still supplies only PROJECTS/REFERENCES; BLOCKED is the existing generic chip after that loop. PENDING/NEXT badges show and color the section count against the cap; whole-lane pressure stays labeled in tooltips and accessibility text, while non-dashboard pendingBudget/nextBudget and CLI warnings keep whole-lane semantics. Daily bob-plan PENDING/NEXT badges share the dashboard section counts and warning color. BLOCKED is informational, has no cap, and uses the same informational accent as its Browse neighbors. TODAY is that same theme/link budget and opens today's daily note. PROJECTS counts projects.base#Active & Waiting once per path and REFERENCES counts refs.base#Reading Queue once per path; a changed Base contract or pending metadata shows – with a reason instead of a stale number.
crowded.md The Crowded Notes page (parent: "[[gtd]]", aliases Crowded Notes and Crowded): a bob-ready-notes block with the ranked CROWDED/FULL bar rows plus a ### CROWDED Tasks query grouped by the noteReady group label.
bob-ready-notes code block The same model as the bob ready overview: a CROWDED summary line, CROWDED and FULL ranked-bar rows, compressed ROOM pills, dimmed exempt notes, and a remedies footer; – when unavailable.
Note ## Tasks heading chip A live per-note chip on the first ## Tasks heading of eligible and exempt notes (Live Preview widget plus Reading-view post-processor): ready n/cap, · full, · +k, · no cap, or –. It opens crowded and never edits the note.
bob pomodoro tmux Adds a tmux-colored ■ before each known, non-exempt theme component, then appends plan T/Tc · L/Lc to an available Pomodoro status (or shows the meter alone). It requires a daily note with a Pomodoros section; NO_COLOR leaves the status text unchanged, and an over-cap meter uses tmux reverse video.
bob task reconcile A plan_budget object in JSON and a human meter line such as plan 3/3 themes · 7/10 links · TODAY 7 · PENDING 8/10 · NEXT 12/15, when the daily note has a Pomodoros section and the plan config is valid. The meter describes the ledger before sync cleanup.
bob capture When a capture changes today's Pomodoros section, a before/after theme and link budget, cap warnings if the count grows over a cap, and the Task Link destination (for example → under GOALS (next up)). Strict mode can refuse a new over-cap theme.
Bob Mac Capture The same budget in Themes and Links capsules, warning captions, and shorter destination rows such as → GOALS · next up or → running GOALS 0945–1015.
Obsidian Notices A lane-aware suffix on Task Link changes. Ctrl+Shift+Enter link and unlink Notices name the destination and append the plan meter, for example Linked to CAPTURE · Next · plan 1/3 · 2/10 (🔴 when over a plan cap); Alt+N lane Notices report the lanes, for example → Ready · 2 tasks · unlinked 1 from today · NEXT 11/15 · PENDING 7/10, with 🔴 plus a prune hint when over a lane cap. Alt+[ / Alt+] Task Link lane-toggle Notices report the toggle, for example ◐ In Progress · Fix the parser crash · PENDING 8/10 · NEXT 11/15 or → Next · Fix … · logged.
Link to today picker The Ctrl+Shift+Enter picker footer shows the plan meter (plan 2/3 · 6/10, red when over) and the create row carries a +1 theme badge (red +1 theme · 4/3 when over the theme cap).

Conformance examples

The vectors below are shared with the bob-ledger-tools JavaScript mirror as its test vectors. Caps are the defaults unless noted.

  1. Merged name with duplicate. BOB + DECKS next to a separate DECKS: DECKS counts once and raises duplicate_open_pomodoro_name.
## Pomodoros

- [ ] () — BOB + DECKS
    - [[a#^one]]
- [ ] () — DECKS
    - [[b#^two]]

Themes are BOB, DECKS (2/3); links are 2/10; one duplicate_open_pomodoro_name at the second entry's line.

  1. Link forms and exclusions. A struck link, GTD's [[#^gtd]], a link planned twice, an embedded link, and a #-marked link:
## Pomodoros

- [ ] () — GOALS
    - [[task#^aaa]] and ~~[[task#^struck]]~~
    - ![[emb#^eee]]
    - [[mark#^mmm]]#
- [ ] () — DECKS
    - [[task#^aaa]]
- [ ] () — GTD
    - [[#^gtd]]

Links are (task, aaa), (emb, eee), (mark, mmm): 3/10. The struck link never counts, the twice-planned link counts once, and GTD's link is ignored with the entry.

  1. Unnamed placeholders. An unnamed entry with links counts as one (unnamed) theme; one without links never counts:
## Pomodoros

- [ ] ()
    - [[solo#^one]]
- [ ] ()
- [ ] () — GOALS

Themes are (unnamed), GOALS (2/3); links are 1/10.

  1. Subheading in the section. A ### Notes heading raises subheading_in_pomodoros and closes the entry span above it:
## Pomodoros

- [ ] () — GOALS
    - [[task#^aaa]]

### Notes

- [[task#^bbb]] is just prose, not a link under GOALS

Links are 1/10: the prose link sits outside any entry span.

  1. Open inventory label. An open LATER still counts as a theme and raises inventory_label_open:
## Pomodoros

- [ ] () — LATER
    - [[task#^aaa]]

Themes are 1/3 with one inventory_label_open.

  1. Over the cap. Four themes with the default cap raises plan_theme_cap_exceeded and sets status over:
## Pomodoros

- [ ] () — ONE
- [ ] () — TWO
- [ ] () — THREE
- [ ] () — FOUR

Themes are 4/3; being exactly at the cap is fine, going past it is not.

  1. Cancelled and completed entries. A [-] entry and a completed entry never count:
## Pomodoros

- [-] () — GONE
    - [[task#^aaa]]
- [x] () — DONE
    - [[task#^bbb]]
- [ ] () — GOALS
    - [[task#^ccc]]

Themes are GOALS (1/3); links are 1/10.

  1. Daily-note target aliases. When evaluating 2026/20260930.md, these three links count as one because they have the same block ID:
## Pomodoros

- [ ] () — GOALS
    - [[#^aaa]]
    - [[2026/20260930#^aaa]]
    - [[20260930#^aaa]]

Links are 1/10.

Today conformance examples

Each vector gives the ledger, the notes it resolves against, and the expected ordered Today keys ("<vault path with .md>#<block id>"). Caps are the defaults unless noted. The vectors are shared with the bob-ledger-tools JavaScript mirror as its test vectors.

  • T1 GTD. An exempt entry's empty-target link resolves to the daily note itself:
## Pomodoros

- [ ] () — GTD
    - [[#^gtd]]

with - [ ] #task Gtd chore ^gtd in 2026/20261001.md → 2026/20261001.md#gtd.

  • T2 markers. 🍅 [[a#^x]] counts, ~~[[a#^y]]~~ doesn't, ![[a#^z]] counts:
## Pomodoros

- [ ] () — GOALS
    - 🍅 [[a#^x]]
    - ~~[[a#^y]]~~
    - ![[a#^z]]

→ a.md#x, a.md#z.

  • T3 closed entries. Links under [x] and [-] entries don't count:
## Pomodoros

- [x] () — DONE
    - [[a#^x]]
- [-] () — GONE
    - [[a#^y]]
- [ ] () — OPEN

→ no keys.

  • T4 shapes. A mixed bullet Review [[a#^m]] and a link nested under a note bullet don't count:
## Pomodoros

- [ ] () — GOALS
    - Review [[a#^m]]
    - Note text
        - [[a#^deep]]

→ no keys.

  • T5 dedupe. One task under two open entries, and [[a#^x]] plus [[dir/a#^x]] resolving to the same note → one key at its first position:
## Pomodoros

- [ ] () — ONE
    - [[a#^x]]
- [ ] () — TWO
    - [[a#^x]]
    - [[dir/a#^x]]

with only dir/a.md holding ^x → dir/a.md#x at the first entry's position.

  • T6 fenced. A link inside a fenced block doesn't count:
## Pomodoros

- [ ] () — GOALS
    - [[a#^x]]

```
- [[a#^q]]
```

→ a.md#x.

  • T7 status. Linked [x] and [-] tasks drop out; [?] stays:
## Pomodoros

- [ ] () — GOALS
    - [[a#^x]]
    - [[a#^y]]
    - [[a#^z]]
    - [[a#^w]]

with - [x] #task Done ^x, - [-] #task Gone ^y, - [?] #task Waiting ^z, - [ ] #task Open ^w in a.md → a.md#z, a.md#w, and no lint.

  • T8 unresolved. [[missing#^q]] gives no key, plus today_link_unresolved at the link's ledger line:
## Pomodoros

- [ ] () — GOALS
    - [[missing#^q]]

→ no keys, one today_link_unresolved.

  • T9 alias. [[a#^x|alias]] counts, pinned to whatever list_queued_links does with aliases:
## Pomodoros

- [ ] () — GOALS
    - [[a#^x|alias]]

→ a.md#x.

Tested rolling snapshot bob-cli 41afa14 bob-mac-capture 556bb67 bob-plugins af1e691 Built 2026-10-24T22:34:13Z (zensical) build.json