Task freshness¶
Confirmed tasks carry [fresh:: YYYY-MM-DD]: the local calendar date a human
last confirmed that the task still needs doing as written — its wording,
priority, project, schedule, and dependencies. A visible, non-recurring Ready
task that was never confirmed, whose scheduled deferral returned after its
last confirmation, which was automatically unblocked after its stamp, or
whose review interval expired is due for review.
Today-linked tasks and canonical daily-note tasks are outside this review
scope. The morning review then costs roughly "pool ÷ interval +
arrivals" glances instead of the whole pool. Recurring
tasks are never stamped; once a recurring occurrence's date arrives it walks in the
RECURRING tier until it is completed, rescheduled, linked to Today, or cancelled.
The daily lane review reuses the same [fresh::] stamp for Pending
([/]) and Next ([*]) tasks: each lane has a review cadence
(freshness.pending_interval / freshness.next_interval, default 1
day), and the morning walk visits PRE → NEW → PROJECTS → PENDING → NEXT →
RECURRING → TICKLER → ROTTEN → POST in explicit tiers.
This file is the contract both implementations cite. The Rust side is
src/native/freshness/ (placement.rs, state.rs) with the
freshness: config block in src/native/config/freshness.rs; the
JavaScript mirror is api.freshness in bob-ledger-tools (top-level
api v3, freshness namespace v12 with the recurringTier capability
and markUnblockedLine). The bob-ledger-tools JavaScript
tests use the conformance vectors below verbatim.
The keep-streak contract (keeps, decay, introduced in schema 4) is specified
here and implemented in Rust in the contract-rust phase. Its
machine-readable parity vectors live in
tests/fixtures/freshness_keeps/vectors.json, which both languages
cite: Rust runs the read/reset/placement cases (it has no production
increment API); the sole increment helper is JavaScript
api.freshness.keepLine, which landed with the ledger-marks phase
alongside freshness namespace v5 counting, folded pips, and
count-truthful tooltips. The decision card landed with the
decision-card phase (bob-navigation-hotkeys 1.69.0, bob-ledger-tools
1.24.0) and is available immediately on compatible plugins
(freshness namespace v6, card capability v2): single Alt+F/Ctrl+Alt+F
presses on exact, due, at-limit tasks open the consent card and write
nothing, counted and Task Link sessions skip those targets without
changing fresh/count, and marks show the leaf with Alt+F to decide
only when the card capability is present and decay is on.
Mixed-version sessions keep counting pips with counting-only wording
and no leaf.
1. Definition¶
- Task freshness (aka freshness) is the local calendar date a
human last confirmed that an open task still needs doing as written.
It is stored as
[fresh:: YYYY-MM-DD]. - Stamp / refresh: write today's date into
fresh. - Due for review: an in-scope task in state NEW, RESURFACED, or ROTTEN (see §4).
- Refreshed today: a task whose
freshequals today.
A missing stamp — not created — means new. bob gkeep pull sets
created from the Keep note's creation time
(src/native/gkeep/render.rs), so created records when the thought
was first captured elsewhere, not when Bryan first saw it in the
vault. Every new capture therefore arrives without fresh and is due
as NEW until a human confirms it.
2. Fields, overrides, and config¶
| What | Where | Value |
|---|---|---|
[fresh:: YYYY-MM-DD] |
task line, before the trailing Tasks suffix | optional; absence means "never confirmed" |
[refresh:: N] |
task line, immediately after fresh |
optional integer days, 1–365 |
[keeps:: N] |
task line, immediately after refresh |
optional streak of due-Ready keeps, 1–999; absence means 0 (§2a) |
[unblocked:: YYYY-MM-DD] |
task line, immediately after keeps |
optional recorded date of an automatic Blocked → Ready recovery; absence means none (§4) |
task_refresh: N |
frontmatter of the note containing the task | optional integer days, 1–365 |
freshness.interval |
~/.config/bob/config.yml |
integer days, 1–365, default 7 |
freshness.pending_interval |
~/.config/bob/config.yml |
integer days, 1–365, or false; default 1 |
freshness.next_interval |
~/.config/bob/config.yml |
integer days, 1–365, or false; default 1 |
freshness.project_interval |
~/.config/bob/config.yml |
optional integer days, 1–365; absent/null inherits |
freshness.rotten_daily_budget |
~/.config/bob/config.yml |
optional integer ≥ 1, default off |
freshness.decay |
~/.config/bob/config.yml |
keep-streak policy: mapping, true, false, or null (§2a) |
Interval precedence. A configured project_interval overrides every other level for ^prj rows (source project). References have no special cadence or grouping: Ready references use the ordinary chain refresh → task_refresh → freshness.interval → 7; Pending and Next references use their lane interval, like other tasks. Otherwise interval(t) for an ordinary task in a walked lane is that lane's interval (pending_interval for [/] with source pending, next_interval for [*] with source next), overriding the Ready chain below. The note override applies by residence, not through parent links. pending_interval: false / next_interval: false turns that lane's walk off for ordinary tasks, including references, but never ^prj review — PROJECTS walks even with the lane off; an absent or null lane value means the default 1. An absent/null project key inherits the Ready chain.
Invalid values.
- An invalid task or note value is linted and falls through to the next level.
- An invalid
freshness:block is a config error.bob freshnessexits 2; ledger-tools falls back to the defaults and marks theminvalid. - Unknown keys are ignored, and a mistyped
freshness:must not break any other config loader (it is read as a rawserde_yaml::Value, mirroring howplan:is loaded).
Example:
freshness:
interval: 7 # Ready backlog review cadence
pending_interval: 1 # [/] lane daily review; false = not walked
next_interval: 1 # [*] lane daily review; false = not walked
# project_interval: 1 # ^prj review cadence; absent/null inherits
# rotten_daily_budget: 15 # counts upkeep outside the lanes; never hides tasks
Booleans (including false), zero, negatives, values above 365,
fractional numbers, strings, and containers are config errors for the
tracker keys; bob freshness exits 2 while ledger-tools uses the
complete default config and flags it invalid. Other config loaders
still ignore a malformed freshness block.
Mobile config caveat. On mobile, when the config file is
unavailable, ledger-tools uses the defaults above and marks them
invalid rather than failing; a config edit on desktop is visible
within the existing 60-second tick. bob freshness on desktop still
exits 2 for an invalid freshness: block. The mobile/missing-file
fallback carries neither tracker override and cannot see Bryan's
desktop settings.
2a. Keep streaks and approved-decay policy¶
Storage. [keeps:: N] is a streak of due-Ready bare keeps: how
many times in a row a due Ready task was confirmed without any other
change. It is a decimal integer 1–999; absence means 0 and writers
omit zero. Increment saturates at 999 and never wraps. Readers
accept existing bracket or paren field syntax, select the first
valid value, and emit keeps_invalid and keeps_duplicate as
appropriate; if none is valid, they report 0. fresh_misplaced also
covers keeps and unblocked inside the Tasks suffix. A successful keep
canonicalizes and repairs malformed/duplicate placement. Generic
stamps remove all keeps fields and all unblocked fields. An uncounted keep preserves the
valid semantic value while canonicalizing it. Do not introduce a
second lifetime counter or alias: the Rotten Log entry count below
is display-only history, counted from dated entries at read time and
never stored as a field.
Canonical form:
- [ ] #task Rename queue input [fresh:: 2026-10-08] [refresh:: 14] [keeps:: 2]
[unblocked:: 2026-10-08] [created:: 2026-09-12] [priority:: medium] ^rq
Output order is fresh, optional refresh, optional keeps,
optional unblocked, then the existing Tasks suffix, tags, and
block ID. keeps and unblocked extend the suffix scan as
run-extending non-Tasks keys; they are never added to the Tasks
key registry. unblocked must never be appended after the Tasks
suffix: both Rust Tasks parsers stop at the first unknown key, so
they would lose scheduled, dependsOn, and id. Both Rust
parsers see the same Tasks fields before and after writes. Task
body, child blocks, quote prefixes, CRLF, and final-newline state
follow the existing writer rules. No metadata leaks into clean task
descriptions or capture previews.
Counting, clearing, and storage. Exact eligibility authorizes counting
only for an exact due-Ready ROTTEN/TICKLER row: the pre-write queue holds
exactly one row with entry.path === path, entry.line === editorLine + 1,
entry.originalMarkdown === rawLine, lane === 'ready', and
tier in {'rotten','tickler'}. The sole increment helper is
api.freshness.keepLine(line, dateText, { counted }) with its valid-prior-
fresh < today guard; Rust reads/clears/reports with no increment path
(the seed preserve-mode exception aside). A v5 provider with a missing or
throwing keepLine fails without writing, while pre-v5 falls back to the
uncounted old stamper. The event table:
- Alt+F / Ctrl+Alt+F on an exact due Ready target in
rotten/ticklerstamps and increments once in the same write, unless an active decision is required. - NEW, FRESH/early, Pending, Next, Blocked, Today-linked, PROJECTS rows, other excluded targets, and
unresolved cache matches stamp through
keepLineuncounted and preserve the streak. - A repeated same-day keep preserves the streak and is byte-identical.
- Every other supported human stamp clears the streak, even same-day.
- Close/cancel preserves the streak on the closed line.
- Automation, hooks, randomize, and seed neither increment nor reset.
- Recurring and closed targets are refused.
- PROJECTS trackers never
decideand their explicit keeps are uncounted. Due Ready references use ordinary keep counting and can reach the approved-decay decision at the keep limit. Dependency capture (bob capture&) is a generic stamp that clearskeeps. - A keep on a
returned_by: unblockedTICKLER row neither increments nor resets[keeps::], anddecide_fornever opens the decision card for it (unblock_keeps). The row is still due and still stamps[fresh:: today], which clears[unblocked::].
What Rust guarantees today:
- every generic human stamp (
stamp_fresh,set_refresh, including same-day stamps) clearskeeps; - refusals (not a task, recurring, closed) write nothing, so the streak stays on the closed line;
- automation, hooks, randomize, and the seed neither increment nor
reset —
seed.rs::stamp_changestamps through the private preserve-mode primitive, so the cutover never silently resets a streak; - capture's existing
stamp_freshconsumers clearkeepsthrough the same default.
No backfill: seeded dates, captured dates, and old fresh stamps are
not evidence of keeps. Git sync may lose an increment during conflict
recovery; this scalar is not an exact global event counter. Cache
uncertainty under-counts. Hand editing remains unobserved.
Rotten Log. Every counted keep — Alt+F/Ctrl+Alt+F on an exact
due Ready ROTTEN or scheduled TICKLER row, or the decay card's Keep —
first asks "Why keep it as written?" and then writes one dated entry
under a 🥀 **ROTTEN LOG** direct child, in the same write as the
stamp. The reason is optional (a blank Enter logs 🤷 no reason
given) but the entry is not; Esc writes nothing. Each entry reads
*YYYY-MM-DD* — <reason> · kept N× with N the streak after that
keep, newest first. The entry count ("N keeps logged") is
display-only history: unlike [keeps:: N] it never resets, and
nothing reads it to change decay, buckets, tiers, lanes, Today,
schedule, priority, or freshness. Grammar lives in
the vault format. See
Rotten Keeps Are Logged.
Configuration.
freshness:
decay:
keeps: 3
# enter: P2
decay absent, null, true, or {} means enabled with 3 keeps.
false keeps counting/display but never asks or skips. keeps
accepts an integer 0–999; 0 asks on every due Ready re-confirmation,
never NEW. enter is an optional nonempty configured priority
label; absent/null uses interval-aware entry. Invalid scalar types,
fractional/negative/out-of-range values, and malformed blocks follow
the current freshness config failure contract: Rust exit 2; plugin
defaults with invalid diagnostics. Priority config validity is
resolved against the existing priority loader, not a second
hard-coded P1–P4 table.
Availability. Decay decisions are available as soon as decay is
enabled and compatible plugins are loaded. There is no calendar gate,
replacement date, or counting-only period. On an exact, due Ready
ROTTEN/TICKLER task at the keep limit, a single Alt+F/Ctrl+Alt+F
press opens the consent card and writes nothing, while counted and
Task Link sessions skip those targets (N needs a decision) without
changing fresh/count. Below threshold they keep counting normally
through exact pre-write matching into keepLine, with kept N×
tails on the Fresh notice. A card still requires an explicit gesture,
never a timer write. freshness.decay: false remains the off-switch:
count and show pips, without cards or decision skips. New navigation
with an older ledger falls back to counted keeps; new ledger with
older or missing navigation shows pips without a card promise.
Read-time decision flag. decide = enabled && lane
ready && tier rotten/tickler && keeps >= limit. The annotation
means a choice is due, not permission to execute an action. The shared
approved-decay action planner (planFreshnessDecayCard in
bob-navigation-hotkeys) turns that flag into stable previewed decisions —
P0 entry, a non-cancelling Not now, Less often steps, and dated
review-decision entries with · kept N× tails — as specified in
docs/projects.md ("Approved-decay decision planner"); the card interaction
itself (FreshnessDecayCardModal plus guarded commit adapters, batch
skipping, the leaf, and the capability guard) landed with the
decision-card phase.
3. Placement rule¶
The Rust helper is stamp_fresh / set_refresh; the JavaScript
ones are api.freshness.stampLine / setRefreshLine / keepLine /
markUnblockedLine in bob-ledger-tools (keepLine is the sole
increment helper and landed with the ledger-marks phase;
markUnblockedLine is the sole [unblocked::] writer, namespace
≥ v12). One helper per language, pinned by the shared vectors below.
Rust reads, clears, and reports keeps and unblocked; the seed's
private preserve-mode primitive is the only Rust path that keeps a
streak while stamping. Every stamp (Rust stamp helpers; JS
stampLine / keepLine) removes [unblocked::].
- Scope. The helpers handle Tasks' Dataview format only, which is
the vault's format.
bob freshnessrefuses to run when the vault's Tasks settings use another format. Task-line detection strips leading>quote markers first (up to three spaces before each>, one optional space after), matching both Rust parsers — a quoted task is still a task. - The Tasks suffix. Scan from the end of the line, the way both parsers do:
- first an optional trailing
^id(theBLOCK_LINKgrammar); - then repeatedly whitespace plus one of:
- a
[k:: v]or(k:: v)field whose key is a Tasks key (priority start created scheduled due completion cancelled repeat onCompletion id dependsOn), whatever its value, followingtrailing_inline_field's grammar; - a
fresh,refresh,keeps, orunblockedfield; - a trailing tag (the
HASH_TAG_AT_ENDgrammar).
- a
- Never scan past the start of the task body. If the body begins
with the global filter token
#task, never scan past the end of that token.
The Tasks suffix starts at the leftmost Tasks element of that
run (a Tasks-key field, a tag, or ^id). fresh / refresh /
keeps / unblocked fields at the run's left edge are not part
of it.
3. Canonical output.
- Remove every fresh, refresh, keeps, and unblocked field
on the line. Each removal also collapses the whitespace it
leaves to a single space.
- Rebuild as head.trim_end() + " " + "[fresh:: D]" + ("
[refresh:: N]" if a refresh value is kept) + (" [keeps:: K]" if
a keeps value is kept) + (" [unblocked:: U]" if an unblocked
value is kept) + (" " + suffix if there is a suffix).
- The kept refresh value is the first valid existing one;
set_refresh replaces or removes it. Generic stamps keep no
keeps value (they clear the streak) and no unblocked value
(they clear the field); the seed preserve-mode keeps the first
valid existing keeps and omits zero, and never writes
unblocked. markUnblockedLine keeps existing fresh /
refresh / keeps and sets [unblocked:: today], replacing
any older value.
- The suffix bytes themselves are never changed.
4. No churn. If the canonical output equals the input byte-for-byte
(the line is already stamped today and canonical), report changed:
false.
5. Refusals. Return the line unchanged with a reason for:
- a line that is not a task line;
- a recurring task (a repeat field in the suffix): its occurrence walks in RECURRING
once it arrives (§4), and Tasks would copy the stamp into the next occurrence;
- done and cancelled tasks, which callers must not stamp.
6. Invariance. For every vector, the Tasks fields both Rust parsers
extract are identical before and after: status, dates, priority,
recurrence, id, dependsOn, tags, block ID. So are the Obsidian
Tasks fields the JavaScript tests can check. [unblocked::] is a
run-extending non-Tasks key, so scheduled, dependsOn, and id
after it still parse (UB12).
4. Evaluation¶
Computed at read time, never stored. today is the vault's local
calendar date (BOB_NOW in tests).
tags'(t) = t's tags, lowercased, compared as whole tokens
(#gtd/pre ≠ #gtd, #pressed_juice ≠ #pre)
checklist(t) = pre if #gtd ∈ tags' ∧ #pre ∈ tags'
| post if #gtd ∈ tags' ∧ #post ∈ tags' ∧ #pre ∉ tags'
| none
checklist_scope(t)
= checklist(t) ≠ none
∧ status symbol (as written on the line) ∈ { ' ', '*', '/', '?' }
∧ ¬dependency-blocked ∧ ¬(scheduled > today)
∧ ¬hidden (the existing #hide predicate, case-insensitive substring, unchanged)
∧ path has no _templates / _conflicts segment (existing predicate)
(recurring, canonical daily note, and Today-linked are NOT exclusions here)
in_scope(t) = status type TODO ("[ ]") ∧ lane-visible ∧ ¬recurring
∧ ¬in a canonical daily note (YYYY/YYYYMMDD.md) ∧ ¬Today(t)
lane-visible = the NEXT/PENDING lane predicate on each side: not done,
not dependency-blocked, not under _templates or _conflicts,
no scheduled date after today, no #hide
(see "Tracking review" below)
identity(t) = exact trailing ^prj → prj; else none
(^prj-extra, description text, and links/embeds never qualify)
fresh(t) = latest valid fresh date on the line; none if absent
(malformed ⇒ ignored + lint; future date ⇒ none + lint)
unblocked(t) = latest valid unblocked date on the line; none if absent
(malformed ⇒ ignored + lint)
returned_on(t)= latest of
scheduled(t) if fresh(t) < scheduled(t) ≤ today
unblocked(t) if fresh(t) exists ∧ fresh(t) ≤ unblocked(t) ≤ today
returned_by(t)= scheduled | unblocked (the source of returned_on; scheduled on a tie)
state(t) = NEW if no fresh(t)
| RESURFACED if returned_on(t)
| ROTTEN if today ≥ fresh(t) + interval(t)
| FRESH otherwise
(Ready only; lane rows keep a null state)
lane(t) = pending if status symbol "/"
| next if status symbol "*"
| ready if status type TODO ("[ ]")
| none otherwise (Blocked, closed, custom non-TODO)
walk_scope(t) = lane(t) ≠ none ∧ lane-visible ∧ ¬recurring ∧ ¬canonical daily note ∧ ¬Today(t)
lane_interval = pending_interval (pending) | next_interval (next);
default 1; false = that lane is not walked
interval(t) = identity(t)=prj with project_interval: project_interval, source project
| ordinary task in a walked lane: lane interval, source pending | next
| otherwise: task refresh → note task_refresh → freshness.interval → 7
(^prj always uses the Ready chain or project interval)
lane_due(t) = walked ordinary lane ∧ (no fresh(t) ∨ today ≥ fresh(t) + lane_interval)
tracker_due(t)= ^prj lane ∧ walk_scope ∧ (no fresh(t) ∨ resurfaced
∨ today ≥ fresh(t) + interval(t)); a disabled lane walk
never disables ^prj review
due_on(t) = checklist: none
ordinary lane row: fresh(t) + lane_interval, or none when unstamped
^prj lane row: effective tracker due date, or none when unstamped
RESURFACED: returned_on(t); ROTTEN/FRESH: fresh(t) + interval(t); NEW: none
due(t) = checklist_scope(t) ∨ (in_scope(t) ∧ state(t) ≠ FRESH)
tier(t) = checklist(t) if checklist_scope(t)
| recurring if recurring_due(t)
| projects if due ^prj (Ready or lane; actual lane retained)
| new if Ready ∧ state NEW
| pending if lane pending ∧ walk_scope ∧ lane_due
| next if lane next ∧ walk_scope ∧ lane_due
| tickler if state RESURFACED
| rotten if state ROTTEN
| none otherwise
(recurring rows never reach lower rules)
recurring(t) = t carries a Tasks recurrence (`repeat` field) — the existing predicate on each side
occurs_on(t) = the earliest of t's valid `scheduled`, `due`, and `start` dates (Tasks' "happens" date);
none when it has none of them
recurring_scope(t) = recurring(t) ∧ checklist(t) = none
∧ lane(t) ≠ none (status ` `/TODO type, `*`, or `/`; `[?]` and closed rows stay out)
∧ lane-visible (the existing predicate: not dependency-blocked, no #hide,
not under _templates/_conflicts, no scheduled > today)
∧ ¬canonical daily note ∧ ¬Today(t)
recurring_due(t) = recurring_scope(t) ∧ occurs_on(t) exists ∧ occurs_on(t) ≤ today
tier(t) = checklist(t) if checklist_scope(t) (unchanged; beats everything)
| recurring if recurring_due(t) (beats every other rule; trackers included)
| the existing rules otherwise (recurring rows never reach them: in_scope/walk_scope keep ¬recurring)
due_on(t) = occurs_on(t) for recurring rows; days_overdue = today − occurs_on(t) (0 on the day)
state, bucket = unchanged: null for every recurring row (they stay out of in_scope)
decide = false for recurring rows; lane and interval fields are reported as today
The recurring tier is an overlay applied after the checklist overlay, so that no
early return in the evaluator can drop it. In Rust: evaluate =
overlay_recurring(overlay_checklist(evaluate_without_checklist(…))). The recurring
overlay is a no-op whenever the row is a checklist member. It sets tier, due_on,
days_overdue, and decide = false, and leaves state, lane, fresh, interval,
keeps, and lints alone.
The queue holds every row with a tier, in order PRE → NEW → PROJECTS → PENDING → NEXT → RECURRING → TICKLER → ROTTEN → POST. Within RECURRING the order is due_on ↑, path ↑, line ↑ (oldest occurrence first). Other tiers keep their existing comparator:
| Tier | Order within the tier |
|---|---|
| pre | path ↑, line ↑ |
| new | path ↑, line ↑ |
| projects | never-confirmed first, due_on ↑, created ↑, path ↑, line ↑ |
| pending | never-stamped first, due_on ↑, created ↑, path ↑, line ↑ |
| next | same as pending |
| recurring | due_on ↑, path ↑, line ↑ |
| tickler | due_on ↑, path ↑, line ↑ |
| rotten | interval ↑, due_on ↑, created ↓, path ↑, line ↑ |
| post | path ↑, line ↑ |
A missing created always sorts after dated peers. The commitment tiers are PRE, NEW, PROJECTS, PENDING, NEXT, RECURRING, and TICKLER. They sit before the Commitments done boundary. ROTTEN is upkeep, and POST is the closing tier after ROTTEN. A PROJECTS row never acquires a decay decision for being rotten underneath; ordinary rotten behavior applies to references. Checklist rows resolve only by completion through Tasks and are never stamped by the walk.
Counts keep state and tier distinct:
due,new,resurfaced,rotten, andfreshcount evaluated Ready states over the full review universe (due = new + resurfaced + rotten), including eligible Ready trackers once each. Pending/Next rows retain null state and never contribute.by_tiercounts the actual full queue with all nine machine tier keys (zeroes for empty tiers);walk = sum(by_tier.values()), before any--limit.pending_due,next_due,projects_due,pre_due,post_due, andrecurring_dueeach equal their tier count, for symmetry.refreshed_todaykeeps its meaning.- New count
upkeep_today: tasks of any status outside_templates/_conflictswhosefreshequals today and whose status symbol is neither/nor*. budget_met = budget set ∧ upkeep_today ≥ budget ∧ new == 0(unchanged raw formula; outstanding commitment tiers take precedence in notices/status mode).
Every ✓ meter displays upkeep_today: ✓ N today, or ✓ N/B
today with a budget.
RESURFACED beats ROTTEN when both hold: a deferral that returned, or
an automatic unblock, is due as soon as it returns. The tickler makes
a short deferral (for example a P1 roll of 2–7 days) due as soon as
it returns, without any hooks write. The fresh ≤ unblocked tie is
deliberate: a task blocked by a stamped gesture in the morning and
unblocked that afternoon still resurfaces. TICKLER rows with the same
due_on sort by path, then line, so one project's residents are
contiguous. An unblock tickler is bucket rotten like any tickler, so
the NEW ∪ TICKLER ∪ ROTTEN ∪ READY partition is unchanged. Tiers
never feed buckets or chips: state/bucket and the partition text
below are unchanged. A due ^prj whose inline date arrived after its
stamp is PROJECTS, not TICKLER (PROJECTS beats TICKLER).
Buckets. The stable read-time bucket contract for dashboard gating:
bucket(t) = new if state(t) = NEW
| rotten if state(t) = RESURFACED or ROTTEN
| null if state(t) = FRESH or null (out of scope)
READY applies the visible TODO Ready pool plus bucket !== "new"
&& bucket !== "rotten" — never state === "fresh", which would
lose freshness-exempt tasks. A null bucket alone never proves a task
is Ready. With the supported plugin and a ready cache, the visible
pool partitions as B = NEW ∪ TICKLER ∪ ROTTEN ∪ READY, pairwise
disjoint (TICKLER is bucket rotten with state resurfaced).
Bucket conformance: S1 maps to new; S3, S4, and S11 map to
rotten; S2, S5, S7, and S12 map to null; every S13 row maps to
null.
Tracking review (projects). PROJECTS remains the one special tracker group, for visible ^prj tasks. References use ordinary freshness and review groups: Ready refs can be NEW, TICKLER, or ROTTEN; Pending/Next refs use their lane groups; fresh Ready refs do not enter the walk.
- Project identity is the exact trailing block ID prj on a real task. ^prj-extra, description text, and [[x#^prj]] links/embeds are never identities; legacy lines without the cosmetic #prj tag stay supported.
- A ^prj row is reviewed when it does not carry #hide. bob projects sync owns that tag. A hidden ^prj is out of scope, like any hidden task.
- A #hide tag hides a reference task like any task. Other exclusions still apply: unsupported/closed/blocked status, dependency blocking, recurring, template/conflict path, canonical daily note, Today membership, and future inline scheduling.
- The checkbox stays authoritative: done/canceled tasks disappear. Creation, import, and sync never auto-confirm; existing confirmation dates are preserved.
- A visible ^prj reviews on project_interval when set, otherwise the Ready chain refresh → task_refresh → freshness.interval → 7. It is due in PROJECTS even when Ready state is NEW or RESURFACED. A [*]/[/] ^prj keeps its lane and the same cadence; a disabled lane walk never disables PROJECTS. Project trackers never decide and never count keeps.
- References have ordinary freshness, sorting, and keep eligibility. A due Ready reference in NEW or TICKLER is a commitment; once it is ROTTEN it is upkeep and can use the normal keep/decay decision path. Pending/Next references have null state/bucket and use the ordinary lane cadence. No tag, block ID, config field, or special sorting key creates a reference-specific group.
Reference conformance compares tag-only #ref and legacy ^ref tasks against an otherwise-identical ordinary task: interval/source, state, bucket, tier, due date, overdue days, keeps, and decision eligibility match. An unstamped Ready ref is NEW; a resurfaced ref is TICKLER; an expired Ready ref is ROTTEN and can decide at the keep limit. Lane refs use PENDING/NEXT cadence with null state/bucket; disabled lanes suppress them like ordinary lane tasks. Hidden, blocked, Today-linked, daily-note, template/conflict, and future-scheduled refs remain excluded. ^prj remains the only freshness tracker exception and keeps its project cadence and PROJECTS tier.
Machine vocabulary (schema 14). Human and machine output use the nine current groups PRE → NEW → PROJECTS → PENDING → NEXT → RECURRING → TICKLER → ROTTEN → POST. The current JSON contract is schema 14. Queue rows gain returned_by (scheduled | unblocked) and, when present, unblocked_on. It contains no reference cadence, interval source, tier, or convenience counter. The histogram has exactly these nine keys, including zero values; walk equals the sum of by_tier before --limit. References remain ordinary tasks in the existing state and tier counts.
Schema history is historical: schemas 6–12 accepted a project and/or reference cadence, and schemas 7–12 included a REFERENCES tier. Schema 14 adds returned_by / unblocked_on for the unblock tickler. Schema 13 removes the reference-specific fields and redistributes those tasks through the ordinary evaluator. Schema 12 re-keyed ref review identity from exact ^ref to the #ref tag; schema 11 added recurring, schema 10 renamed returned to tickler, schema 9 added PRE/POST, schema 8 removed the decay calendar gate, and earlier schemas introduced tracker and project fields. Those historical shapes do not describe current output. The shared seed envelope advances with the schema constant; seed behavior is unchanged.
Bob-ledger-tools keeps top-level api v3 and advances api.freshness.version to 12. The current namespace advertises trackerReview, recurringTier, checklistTiers, and markUnblockedLine; it no longer advertises referenceReview or refTagIdentity. JS writers call api.freshness.markUnblockedLine (namespace ≥ v12). When it is missing, they recover without the field and console.warn. Its queue and footer use the nine current groups, and public config/count fallbacks match warm snapshots. Navigation may present a REFERENCES entry only when explicitly marked as legacy from a freshness namespace <=10; the new producer never creates one, and the compatibility path preserves old loaded-provider rows until plugin reload.
Dashboard freshness.reviewModel NEW/ROTTEN chips still project memoized evaluated states onto the existing visible Ready pool. Hidden review-only rows never feed a badge for a section that excludes them. The status bar footer, ]s, and CLI use the full review counts. The desktop footer shows nonempty groups in walk order with short labels WIP for PENDING, RECUR for RECURRING, and TICKS for TICKLER; it splits TICKLER from ROTTEN so the commitment/upkeep boundary is visible. Dashboard ROTTEN chips still fold TICKLER plus ROTTEN. Review r/N and TIER i/M are queue positions, not completed-task counts. Jump notices and CLI output use full tier names.
One-release legacy budget key. A config that still sets
freshness.stale_daily_budget keeps working for one release: when
only the old key is present it supplies the budget, and when both
keys are present the canonical rotten_daily_budget wins (including
an explicit null, which means budget off) while the old value is
ignored. Either case emits exactly one
freshness_stale_daily_budget_deprecated diagnostic per loaded
config — in bob freshness human/JSON warnings and in the plugin
lints — without marking the config invalid. An invalid value for the
selected key is still a config error.
Line numbers are 1-based in JSON and docs. Tasks' lineNumber is
0-based, so convert it.
Lints: fresh_malformed, fresh_future, fresh_duplicate,
fresh_misplaced (a fresh/refresh/keeps/unblocked inside the Tasks
suffix; the next stamp repairs it), refresh_invalid (per task),
keeps_invalid (a keeps value that is not a decimal integer 1–999;
ignored) and keeps_duplicate (more than one keeps field; the
first valid one wins), unblocked_malformed (an [unblocked:: …]
value that is not a valid YYYY-MM-DD date; ignored) and
unblocked_duplicate (more than one unblocked field; the latest
valid one wins), and task_refresh_invalid (per note),
freshness_stale_daily_budget_deprecated (once per loaded config
when the removed stale_daily_budget key is present; see above).
today_link_unresolved passes through unchanged from the Today
engine. Checklist lints apply only to open tasks: checklist_tag_incomplete
(#pre or #post without #gtd), checklist_tag_conflict
(#gtd #pre #post; PRE wins), and checklist_repeat_not_when_done
(a checklist member whose repeat lacks when done).
recurring_undated fires for an open recurring task with no
checklist tags, a lane status, outside _templates/_conflicts, and
no valid scheduled, due, or start: "recurring task has no
scheduled, due, or start date; the review walk cannot tell when it
comes due".
5. Who stamps¶
Rule: a supported human gesture that already rewrites an open task's line stamps that task in the same write, as the last transformation of the line, if the task is still open and non-recurring afterwards. Stamps apply in every lane, because a Next, Pending, or Blocked task may come back to Ready later. A stamp dated today on a canonical line is a no-op.
| Surface | Stamps | Never stamps |
|---|---|---|
| bob-navigation-hotkeys — reverse Blocks stage | Ctrl+Shift+Alt+B, Task Card ⇧B, the palette command, the BLOCKS-line entries, and the BLOCKS chip × all use one writer and stamp each changed dependent T once when its Depends-On line changes, on add and remove, including counted and Task Link gestures | Source N's target-id, lane, and BLOCKS edits, including a minted id and generated mirror; N is never stamped |
| bob-navigation-hotkeys | Alt+F and Ctrl+Alt+F stamp through api.freshness.keepLine (counted only under exact eligibility); Alt+N commit and release; the Ctrl+Shift+P Task Card (1–4, 0, Enter/Schedule, b, f, x, Ctrl+D; Ctrl+Shift+B is the same card route as b) and the stages its actions open, in single, counted and Task Link mode; each open task moved by Ctrl+Shift+M; routed moves stamp like Ctrl+Shift+M; the Ctrl+Enter recommended roll and decay; decision-card outcomes write through the existing writers (Not now and levels through the priority writer, Less often through set-refresh, Reword through the generic stamp, Drop through the cancel row, which never stamps) |
the cancel row (including the decay cancel), create-project-note-from-task, the ! transclusion toggle (a pure toggle that never rewrites a task line) |
| task-status-cycler | Alt+[ / Alt+] (including counted and transcluded targets) when the result is an open status, including leaving Blocked by hand; Ctrl+Enter reopening a done task; picked successor offers on a Done close (Ctrl+Enter / Alt+] into Done), which stamp like Ctrl+Shift+Enter | closing the completed task itself (done or cancelled), unpicked recoveries, cancelled closes, Ctrl+Shift+] bullet → #task (that is creation), the dependency-ID normalizer, recoverBlockedDependents |
| block-id-prompt | Ctrl+Shift+Enter and ^^ when they rewrite the task line (Ready/Blocked → Next, a new block ID) |
unlink, Task Link removal, Ctrl+6 rename |
bob capture |
dependency drop on an existing dependent; plan_task_link (the link direction of @route+id!, Ensure Next, solo @route:id / ^route:id, link-then-close), the x rows that set [/], an ¬e:id dependency capture that edits an existing open dependent (its Depends-On line, derived fields, or Blocked status), a *note:id capture that edits T (its Depends-On line, derived fields, or Blocked status), picked --pull-in successor offers (same stamp as a manual link), and each open task an --inbox-route answer moves (same stamp as Ctrl+Shift+M) |
new tasks on any route (including new tasks with & or * modifiers), x complete of the closed task itself, unpicked successor recoveries, unlink, start rows, sub-bullets, prerequisite target-ID and lane promotion edits, an existing owner's target-ID and lane edits under *, an unchanged repeat & or * |
| Automation | — | hooks, projects sync, task reroll, task move, gkeep pull, ref, task archive, nightly, vault-sync, capture-task-id |
bob freshness seed |
the one-time cutover (a documented exception) | — |
| Hand editing | — | not monitored; edit, then press Alt+F |
Deferrals: scheduling a task into the future stamps it, and it becomes
Blocked. When it returns it is RESURFACED. Automatic Blocked → Ready
returns never stamp: [unblocked::] is a freshness input, not a stamp.
A picked successor offer stamps like a manual link (Ctrl+Shift+Enter):
[fresh:: today], clearing [unblocked::] and [keeps::]. Unpicked
recoveries keep the WRITE rule below and never stamp. Automation
(hooks, sync, reroll, archive) still never stamps.
Who writes [unblocked::]. On every automatic transition of an
open task from Blocked [?] to Ready [ ] — reconcile Unblock;
capture/cycler close recovery (including project_member); nav/cycler
project recovery of residents; nav recoveries of lines other than the
gesture's own line (the gesture stamps its own line) — evaluated on
the recovered line. Skip when any of:
- the row is outside freshness scope (recurring, hidden, daily note, …);
- it has no
[fresh::](NEW is already due); fresh(t) < scheduled(t) ≤ today(already a scheduled tickler);- the recovery lands in Next or Pending (the lane walk covers it).
Else set [unblocked:: today], replacing any older value. Reconcile
prunes [unblocked:: U] when fresh > U. JS writers call
api.freshness.markUnblockedLine (namespace ≥ v12).
Placement is the exception to the copy-small-helpers rule: nav,
task-status-cycler, and block-id-prompt call
api?.freshness?.stampLine?.(line, dateText) ?? line on
bob-ledger-tools (api version >= 3), and nav calls
api.freshness.keepLine (v5) for explicit keeps, with a source comment at each
call site. A missing stamp only means Bryan sees the task once more;
a misplaced stamp hides Tasks fields — so the risky part lives in one
place, and when ledger-tools is absent or old the gesture simply
doesn't stamp. Clicking a freshness mark only reveals the raw field
for editing and never stamps.
Before Alt+F or Ctrl+Alt+F stamps a validated Pending ([/]) target,
it asks once for an optional Work Log summary. In a counted or Task Link
batch, only the Pending targets receive the shared summary; every other
accepted target still refreshes. Enter on an empty summary still keeps
the task and writes no Work Log entry. Escape cancels the refresh, so the
task stays due and Ctrl+Alt+F does not advance.
Only bob-navigation-hotkeys writes the Rotten Log (single keeps,
counted batches, Task Link sessions, and the decay card's Keep
stage). bob capture, Rust, hooks, and all other automation never
write it: capture's stamps stay generic and clear keeps through the
same default as any other generic stamp.
6. Review ritual¶
Morning:
[S, or]sfrom outside the queue when no review task is current, starts at PRE.- Complete each PRE chore with Ctrl+Enter or Ctrl+Alt+F, or skip it with
]s. On the PRE/POST row the walk just landed on, Ctrl+Enter completes it through Tasks (including a still-[?]chore) and moves to the next live row of that group with a completion and landing toast. It never leaves PRE: the final chore stays put withPRE doneand a]s → …hint. Ctrl+Alt+F also completes and advances, crossing from PRE into the next tier; Alt+F completes in place. Checklist rows never stamp. Elsewhere, Ctrl+Enter on any landed row completes and advances, but it never crosses the PRE/POST boundary: on the last lane row before POST it stays with a]s → POSThint (Ctrl+Alt+F crosses). Keep import is the PRE chore "Import inbox tasks from Google Keep". - Walk commitments through NEW → PROJECTS → PENDING → NEXT → RECURRING → TICKLER with ]s / Ctrl+Alt+F until the notice says Commitments done. N]s / N[s move N entries along that queue and wrap with the existing notice, while [S / ]S stay at the first and last entries. NEW is never capped or skipped. In PROJECTS, replenish the empty project with Ctrl+Alt+F; stamping a project advances to the correct next entry with its block ID and #hide preserved. References follow the ordinary NEW, lane, TICKLER, and ROTTEN rules as other tasks. In RECURRING the occurrence date has arrived: complete it (Ctrl+Enter), do it today
(Ctrl+Shift+Enter), or move its scheduled date past today (Ctrl+Shift+P);
recurring rows are never stamped and cancel only through Obsidian Tasks.
Ctrl+Alt+F on a landed RECURRING row behaves like Alt+F: it writes nothing,
shows the recurring-tier notice, and stays on the row.
In the lanes, ask "still in this lane?": keep with Ctrl+Alt+F
(Pending asks for an optional Work Log summary; blank Enter still
keeps, while Escape leaves the task due), do it today with
Ctrl+Shift+Enter (picks a Pomodoro), release with Alt+N. Every answer on a landed row
moves on to the next remaining review item in the same keystroke —
including a resolving Task Card commit. Ctrl+Shift+M never advances: it moves the
task and follows it to its destination note,
<C-o>returns to the source, and the next]sresumes the walk where the moved row was. The walk follows rows by note and line text and moves only from the row just answered, so a move, a Work Log or SCHEDULE LOG insert, or any other line shift never makes an answer or]sskip rows or jump ahead. Alt+F is the one answer that stays on the row;]sskips without answering, and<C-o>returns to the row just answered. For a tickler, "not now" is a priority roll: press Alt+P to take the Task Card's recommended roll or decay in one key. An immediate roll or decay uses the existing Ctrl+Enter path and advances the walk; when the recommendation is cancel, the card appears and a second Alt+P confirms it. A Next or Pending task still offers the optional Work Log summary. Ctrl+Shift+P1–4remains available to choose a specific priority. Clear CROWDED to 0 (split, sequence, defer, drop viabob ready) and choose a highlight (≤3 themes, highlight first). CROWDED does not depend on how far ROTTEN review got. - ROTTEN is optional upkeep until 0, the budget, or you stop. The
commitments → ROTTEN boundary notice includes
]S closes the reviewwhen POST remains. A counted keep on a due Ready ROTTEN or scheduled TICKLER row asks "Why keep it as written?" and writes one dated Rotten Log entry with the stamp; a blank Enter logs🤷 no reason given, while Esc writes nothing. ]Sjumps to POST; complete Morning review last with Ctrl+Enter or Alt+F. On a multi-row POST checklist, Ctrl+Enter and Ctrl+Alt+F move to the next row, Alt+F stays and counts remaining rows, and the review closes on the last row. POST never wraps.
The Obsidian footer keeps a condensed version of that ]s notice
visible while the cursor is on a review task, including the compact Alt+P
roll preview in its hint slot when the row has a recommendation; the full
preview detail is in its tooltip. It shows the ]s next hint otherwise.
Its trailing group summary omits the current review group;
the full summary shows when no review row is current. Wrap and boundary preambles stay transient in the notice.
First walk: release the lanes to their caps.
Weekly: if more than about 90% of NEXT reviews end in keep,
lengthen next_interval to 2–3 and leave Pending at 1.
Review outcomes, one key each (every row except "edit" stamps by
itself): still right (Ctrl+Alt+F → next; Alt+F stays); see it less often
(Ctrl+Shift+P f, then 14 / 30 / 90 → next); not now (Alt+P takes the
recommended roll or decay → next; a cancel requires a second press; use
Ctrl+Shift+P 1–4 to choose a level); do today (Ctrl+Shift+Enter picks a Pomodoro / Alt+N → next); route an
inbox task (every non-closing Ctrl+Shift+P answer and Ctrl+Shift+Enter asks where it goes
as the last step before writing, then answers → next); route to a
project (Ctrl+Shift+M follows the task; ]s resumes); drop (Ctrl+Shift+P x → next); sequence
(Ctrl+Shift+P b → next); wording wrong (edit, then Alt+F stays);
already done (Ctrl+Enter → next, never onto a checklist row); recurring occurrence due
(Ctrl+Enter done · Ctrl+Shift+Enter today · Ctrl+Shift+P reschedule · ]s skip — never stamped).
A landing is the exact row ]s just put the cursor on, in today's walk:
the same note, the same line text, and the row still due. An answer shows
one toast — what was done, then where the walk landed — except Task Card
commits, whose rich notice cards keep their own toast with the landing
following on its own. After each advance the walk holds a ~350 ms settle
window that swallows double presses; a fast habitual ]s after the window
can still skip a row ([s or <C-o> recovers it).
The current review task is the row the walk last landed on, and it outlives
the landing (cursor moves and other notes). On a relative walk key, when a
live current review task exists and is not selected, ]s / [s (and
Ctrl+Alt+J/K) first return to it: [s returns too, the count is ignored,
there is a <C-o> jump back, the landing is re-armed, and [S / ]S are
unchanged. It ends on a new landing, an answer on its landing, a landed
Ctrl+Shift+M park, a stamp or applied decision-card choice on the row
itself, an empty queue, the day change or a reload, or when its row is no
longer due with the same path and text; a stamp or answer elsewhere does
not end it.
A due at-limit task's Alt+F opens the decision card (Not now / Less
often / Reword / Drop / Keep) as soon as compatible plugins are
loaded. Counted and Task Link sessions skip such tasks with
N needs a decision. The card's Keep swaps the rows for a reason
stage inside the same card: Enter commits stamp, increment, and one
Rotten Log entry together, while Esc returns to the choices — no
reason in mind means picking Not now, Less often, or Drop instead.
See docs/projects.md "Approved-decay decision planner".
7. bob freshness¶
Headless review queue (bob freshness list, human and JSON) and a
guarded, idempotent, staggered cutover seed (bob freshness seed)
that aborts on any parse change. seed is hidden from bob freshness
help and completion; it stays callable with all its guards and must
not be re-run.
Running bob freshness with no subcommand runs list.
bob freshness [-b|--bob-dir DIR] [-f|--format human|json] [-l|--limit N]
bob freshness list [-b|--bob-dir DIR] [-f|--format human|json] [-l|--limit N]
bob freshness seed [-b|--bob-dir DIR] [-d|--dry-run] [-F|--force] [-f|--format human|json]
-b/--bob-dir selects the vault (default BOB_DIR, then ~/bob).
list options: -f/--format human|json (default human) and
-l/--limit N (queue rows only; counts always cover the whole
vault). Human output is colored only on a TTY:
bob freshness · Thu 2026-10-08 · every 7d · pending 1d · next 1d · keeps 3
REVIEW 83 due · 7 pre · 1 new · 2 projects · 10 pending · 15 next · 4 recurring · 14 tickler · 29 rotten · 1 post · ✓ 12 today
PRE 7
gtd_daily.md:1 Check weather Checklist · complete to resolve
NEW 4
gkeep_inbox.md:14 Pick up our daughter created 2026-09-30
lib/books/paper.md:8 Read the paper created 2026-09-30
PROJECTS 2
home.md:10 Replenish home Empty project · never confirmed · every 7d (default)
work.md:44 Staff the launch Empty project · due today · fresh 2026-10-01 · every 7d (default)
PENDING 10
sase.md:40 Land the epic never confirmed · created 2026-09-20
work.md:12 Ship the report due today · fresh 2026-10-07 · every 1d (pending)
NEXT 15
…
RECURRING 4
recur.md:3 Pay salary Recurring · 7d overdue · since 2026-10-01
cash.md:9 Apply for unemployment Recurring · due today
TICKLER 14
b.md:40 Week habits tickler · scheduled 2026-10-07 · fresh 2026-10-05
p.md:12 Draft the pitch tickler · unblocked 2026-10-08 · fresh 2026-10-01
── commitments done above · upkeep below ──
ROTTEN 26
d.md:1 Water the herbs due today · fresh 2026-10-07 · every 1d (task)
a.md:2 Rename queue input rotten 3d · fresh 2026-09-28 · every 7d (note)
── review closeout ──
POST 1
gtd_daily.md:20 Morning review Closeout · complete last
- The
REVIEW N duetotal iswalk. - Human vocabulary says "tickler"; the machine
statestaysresurfaced. A TICKLER row readsunblocked YYYY-MM-DDwhenreturned_byisunblocked, elsescheduled YYYY-MM-DD. There is no reason suffix: the reason is not stored. - A disabled lane shows
pending offin the header. - The header always shows the keep threshold:
keeps 3when enabled,keeps 3 · decay offwith decay off, andkeeps 0 · asks every reviewfor a zero threshold. Only a capable installed card promisesnext review asks. - Human rows show
kept N×when the streak is nonzero and· decidewhere the read-time choice is due. - Each tier heading carries its count and is omitted when empty.
- The dim divider appears only when rows exist on both sides.
- Lane rows overdue by
n ≥ 1days read{n}d overdue. - With a budget, the meter reads
✓ 12/15 today. --limittruncates rows, never counts.- Lints go last, unchanged.
text is the clean description; queue line numbers are 1-based.
The seed was a one-time cutover and must not be re-run.
Fallback. With a missing, old, or throwing freshness API, dash
keeps legacy READY visibility and counts, NEW and both rotten groups
render empty, and NEW/ROTTEN badges show – (never zero). Native
bob query has no Obsidian app.plugins, so NEW/ROTTEN are empty
and READY is ungated there; bob freshness list is the headless
review interface.
The current JSON contract is schema_version 14. It includes config interval, pending_interval/next_interval, project_interval, rotten_daily_budget, and normalized decay; counts due/new/resurfaced/rotten/fresh, pending_due/next_due/projects_due/pre_due/post_due/recurring_due, a nine-key by_tier histogram, walk, decide, refreshed_today, upkeep_today, budget, and budget_met; queue rows with the nine current tiers, lane/state/bucket, interval_source task/note/config/default/pending/next/project, due metadata, keeps, decide, returned_by, and unblocked_on when present; and warnings. It has no reference interval, references_due, references tier, or reference interval source. Counts cover the whole vault regardless of --limit. The Obsidian footer abbreviates PENDING as WIP, RECURRING as RECUR, and TICKLER as TICKS; it omits empty groups and the current group. A NEW project counts once in PROJECTS. References follow their ordinary state and lane groups.
Seed options: -d/--dry-run, -F/--force, -f/--format human|json. Ready tasks without a valid fresh are grouped by note
and bin-packed largest-note-first into 7 buckets; a note bigger than
ceil(total / 7) splits into consecutive line-order chunks, and ties
break by path, then bucket index. Bucket k (1–7) lands on today −
7 + k, raised per task to max(bucket date, today − interval(t) +
1, scheduled(t) when due) so nothing is due on cutover day and
nothing arrives RESURFACED, and clamped to today. Every other eligible open,
non-recurring task without a valid stamp gets today, including Today-linked
tasks. Tasks tagged #hide and tasks in daily notes, _templates,
_conflicts, or dot-directories are excluded. "Without a valid stamp"
includes missing, malformed, and future-dated fresh values.
If candidates remain, the seed refuses when any eligible task already
carries a valid fresh dated before today (unless --force). --force
bypasses that refusal; it does not rewrite existing valid stamps. A rerun
with no candidates reports zero newly stamped tasks, even if bucket stamps
are older than today. Later captures remain unconfirmed and should be reviewed
normally.
The seed aborts without writing notes when any changed task line would parse
differently under either Rust parser. A writing run then re-reads all touched
files before the first write and refuses if one changed; --dry-run stops
after planning and parse validation, without that file recheck. Each file is
written through a temp file plus rename. The JSON contract is
schema_version: 14 with ok, date, dry_run,
stamped (ready, other), buckets (fresh, due_on, count,
notes), skipped (already_stamped, recurring,
out_of_scope), files, and warnings. The shared schema constant
also moves the seed envelope to 10, with seed content unchanged. Seed
candidate selection is unchanged, and list stays read-only. The buckets
dates describe the initial distribution: fresh is the unadjusted bucket
date, and due_on adds the global interval. Per-task adjustments described
above can make the written stamps and actual due dates differ from these
summary dates; the report does not list each task's final stamp.
The pre-write guards abort without note changes, but a filesystem failure during the multi-file write can leave earlier files stamped. The error reports the files already written; use the vault's Git history to recover them before retrying. This seed does not use capture's batch rollback mechanism. It does not lock the vault or recheck files between that pre-write validation and their replacement; avoid editing the affected notes during the seed.
Exit codes: 0 on success; 1 for I/O errors and seed refusals; 2 for
an invalid freshness: block or a non-Dataview task format.
8. Surfaces¶
| Surface | Phase |
|---|---|
| bob freshness | fresh-cli: schema 14 tiered walk PRE → NEW → PROJECTS → PENDING → NEXT → RECURRING → TICKLER → ROTTEN → POST; ordinary reference freshness, project cadence for ^prj, recurring occurrences, PRE/POST checklists, approved-decay policy, and [unblocked::] ticklers. |
bob capture |
capture-stamps (landed: plan_task_link + x close stamp via stamp_fresh; existing-dependent & dependency stamp via the same helper; a * edit to T stamps T the same way; capture never stamps trackers, a new N, or an existing owner's target-ID and lane edits) |
| bob-ledger-tools | ledger-freshness (landed: api v3 api.freshness + status bar in 1.8.0; tracking review under namespace v6 with the trackerReview capability; persistent review footer with additive reviewEntryView and the compact api.quickRoll preview in its hint slot) |
| bob-navigation-hotkeys | nav-review, nav-stamps (landed: Alt+N + Ctrl+Shift+P/Ctrl+Shift+M/! stamping + refresh row in 1.44.0; PROJECTS tier walk with trackerReview capability and legacy v3/v4 fallback; ]s landing notices append the Alt+P recommended-roll preview; additive api.quickRoll v1) |
| task-status-cycler | cycler-link-stamps (landed: Alt+[/Alt+] + Ctrl+Enter reopen stamping in 1.18.0) |
| block-id-prompt | cycler-link-stamps (landed: Ctrl+Shift+Enter + ^^ stamping in 1.16.0) |
rotten.md (aliases Review, Freshness review, Rotten Tasks) |
dash-gating (landed: live summary plus always-present TICKLER and ROTTEN groups; tasks stay in source notes, rows are click-through views) |
dash.md |
dash-gating (landed: TODAY → NEW → PENDING → NEXT → READY sections; grouped Review / Work / Browse navigation with NEW/PENDING/NEXT/READY/CROWDED/BLOCKED/ROTTEN/TODAY plus PROJECTS/REFERENCES chips; gated READY with whole-lane tooltip; CROWDED via noteReady v1 opening crowded.md; PROJECTS/REFERENCES via dashboardCollections v1 opening dash_projects/dash_references with Base-contract guards) |
crowded.md + bob-ready-notes + heading chips |
per-note Ready cap rollout (landed: Crowded Notes page, ranked-bar code block, live ready n/cap chips on each note's ## Tasks heading) |
| freshness mark | fresh-mark (landed: bob-ledger-tools 1.10.0 Live Preview + rendered views) |
| keep-reason prompt (single keeps, counted batches, Task Link sessions) | rotten-log (landed: nav 2.54.0 single keeps, 2.56.0 batches; one prompt asks why, one dated 🥀 **ROTTEN LOG** entry per counted target) |
| decay-card Keep stage | rotten-log (landed: nav 2.55.0; in-card reason stage, one transaction, Esc back to the choices) |
| freshness mark tooltip rotten line | rotten-log (landed: bob-ledger-tools 1.65.0; 🥀 N keeps logged · last … in source-note Live Preview + reading views only, never Tasks query rows) |
note snapshots (bob highlights/ref) |
rotten-log (open tasks: one condensed KEPT row with the newest entry; include-closed snapshots: every entry) |
| Mac idle agenda rotten chip | rotten-log (🥀 Rotten log header folding with the other logs, 🥀 N chip after ⚒ N / 🗓 N) |
9. Placement conformance examples¶
D = 2026-10-08. Each gives the input line, the expected line, and
changed. The bob-ledger-tools JavaScript tests use these verbatim.
- P1 bare:
- [ ] #task Buy milk→- [ ] #task Buy milk [fresh:: 2026-10-08](changed: true) - P2 created:
- [ ] #task Buy milk [created::2026-09-29]→- [ ] #task Buy milk [fresh:: 2026-10-08] [created::2026-09-29] - P3 block ID only:
- [ ] #task Pick up Abby ^pickup→- [ ] #task Pick up Abby [fresh:: 2026-10-08] ^pickup - P4 interleaved suffix:
- [ ] #task Plan trip [created::2026-08-26] #hide [priority:: high] ^trip→- [ ] #task Plan trip [fresh:: 2026-10-08] [created::2026-08-26] #hide [priority:: high] ^trip - P5 unknown field stays left:
- [ ] #task Read X [[#^h-8bac|🔖]] [h:: e629] [created::2026-08-28]→- [ ] #task Read X [[#^h-8bac|🔖]] [h:: e629] [fresh:: 2026-10-08] [created::2026-08-28] - P6 spacing:
- [ ] #task Rahway [created:: 2026-07-15] [scheduled:: 2026-08-10] ^rahway→- [ ] #task Rahway [fresh:: 2026-10-08] [created:: 2026-07-15] [scheduled:: 2026-08-10] ^rahway(the head collapses to one space; the suffix bytes are untouched) - P7 restamp:
- [ ] #task Buy milk [fresh:: 2026-10-01] [created::2026-09-29]→ the same line with[fresh:: 2026-10-08] - P8 same day:
- [ ] #task Buy milk [fresh:: 2026-10-08] [created::2026-09-29]→ byte-identical,changed: false - P9 misplaced:
- [ ] #task Buy milk [created::2026-09-29] [fresh:: 2026-10-01]→- [ ] #task Buy milk [fresh:: 2026-10-08] [created::2026-09-29](the reader reportsfresh_misplacedon the input) - P10 duplicates:
- [ ] #task A [fresh:: 2026-09-01] B [fresh:: 2026-09-20] [created::2026-09-01]→- [ ] #task A B [fresh:: 2026-10-08] [created::2026-09-01](the reader reportsfresh_duplicateand uses 2026-09-20) - P11 refresh follows fresh:
- [ ] #task Rename queue input [refresh:: 14] [created::2026-09-10] [priority:: low]→- [ ] #task Rename queue input [fresh:: 2026-10-08] [refresh:: 14] [created::2026-09-10] [priority:: low] - P12 set / clear refresh:
set_refresh(P2 output, 30)→- [ ] #task Buy milk [fresh:: 2026-10-08] [refresh:: 30] [created::2026-09-29], andset_refresh(…, none)removes it - P13 recurring refused:
- [ ] #task Water plants [repeat:: every week] [created::2026-09-01]→ unchanged, refused asrecurring - P14 global-filter floor:
- [ ] #task #hide ^x→- [ ] #task [fresh:: 2026-10-08] #hide ^x, and- [ ] #task #prj Ship it #hide ^prj→- [ ] #task #prj Ship it [fresh:: 2026-10-08] #hide ^prj - P15 parenthesized field:
- [ ] #task Call mom (created:: 2026-09-01)→- [ ] #task Call mom [fresh:: 2026-10-08] (created:: 2026-09-01) - P16 indented Blocked:
\t- [?] #task Deferred [created::2026-09-01] [scheduled:: 2026-10-20]→\t- [?] #task Deferred [fresh:: 2026-10-08] [created::2026-09-01] [scheduled:: 2026-10-20] - P17 done refused:
- [x] #task Old [completion:: 2026-10-01]→ unchanged, refused asclosed - P18 quoted:
> - [ ] #task Quoted [created::2026-09-01]→> - [ ] #task Quoted [fresh:: 2026-10-08] [created::2026-09-01](quote markers are stripped for task detection only, mirroring the vault scanner; the>bytes stay in place, and more than three leading spaces before>is still not a task)
10. State conformance examples¶
Today 2026-10-08, the default config (interval 7, both lanes
1), and a Ready, visible, non-recurring task in a.md unless noted.
The bob-ledger-tools JavaScript tests use these verbatim.
- S1 new: no
fresh→new - S2 fresh:
fresh 2026-10-02→fresh,due_on 2026-10-09 - S3 boundary:
fresh 2026-10-01→rotten,due_on 2026-10-08,days_overdue 0 - S4 overdue:
fresh 2026-09-20→rotten,due_on 2026-09-27,days_overdue 11 - S5 task beats note:
[refresh:: 14], notetask_refresh: 3,fresh 2026-10-01→fresh(task source, due 2026-10-15) - S6 note beats config: note
task_refresh: 3,fresh 2026-10-05→rotten(note source) - S7 config:
freshness.interval: 10,fresh 2026-10-01→fresh(config source, due 2026-10-11) - S8 invalid overrides fall through:
[refresh:: 0]→refresh_invalid, and notetask_refresh: soon→task_refresh_invalid; both fall through to config 7 - S9 malformed:
[fresh:: 2026-13-01]→new+fresh_malformed - S10 future:
[fresh:: 2026-10-09]→new+fresh_future - S11 resurfaced:
fresh 2026-10-05,scheduled 2026-10-07→resurfaced,due_on 2026-10-07 - S12 not resurfaced:
fresh 2026-10-07,scheduled 2026-10-07→fresh - S13 out of scope (state
null): a recurring task (its recurring row is undated, so it never reaches RECURRING); a[?]with a futurescheduled;[*];[/];#hide;_templates/x.md; daily note2026/20261008.md; a Ready task linked under today's open Pomodoro; a[ ]whosedependsOnnames an open task. Note that[*]and[/]keep a nullstateandbucketbut are in tier scope (see L1). - S14 queue order (rewritten). Given NEW
b.md:3and NEWa.md:9; ROTTEN due 2026-10-01 atc.md:2; RESURFACED due 2026-10-07 ata.md:4; ROTTEN due 2026-10-07 ata.md:2. The order isa.md:9,b.md:3,a.md:4(tickler),c.md:2,a.md:2. - S15 counts (updated):
refreshed_todaycounts a[*]and an[x]stamped 2026-10-08, but not a Ready task stamped 2026-10-07;upkeep_todaycounts the[x]but not the[*]. With budget 15, 15 upkeep and 0 new →budget_met: true; with 1 new →false. - Q1 (Bryan's example). All four tasks are ROTTEN:
d.md:1[refresh:: 1], fresh 10-07, created 09-01;c.md:1fresh 09-28, created 09-04;b.md:1fresh 09-28, created 09-01;a.md:1fresh 09-30, created 09-01. Order:d.md:1(A),c.md:1(D),b.md:1(C),a.md:1(B).- Q2 (tier order beats path order). NEW
e.md:1;[/]d.md:1fresh 10-07;[*]c.md:1fresh 10-07; TICKLERb.md:1fresh 10-05 scheduled 10-07; ROTTENa.md:1fresh 09-20. Order: e, d, c, b, a, with tiers new, pending, next, tickler, rotten. - L1. A
[*]stamped 10-08 is in no tier. A[*]stamped 10-07 has tiernext,due_on10-08,days_overdue0, interval 1 from sourcenext, and nullstate/bucket. - L2 (lane overrides refresh). A
[/]with[refresh:: 30]stamped 10-07 has tierpendingand interval 1 from sourcepending. - L3. An undated recurring
[*], a Today-linked[*], a[*]in2026/20261008.md, and a#hide[*]are each in no tier. - L4. With
next_interval: false, a never-stamped[*]is in no tier, and its interval falls back to the Ready chain (7, default).next_interval:null means 1. - L5 (lane order). Five
[/]tasks: z.md:9never stamped, created 09-01;b.md:1fresh 10-01, created 09-15;a.md:5fresh 10-07, created 09-10;a.md:2fresh 10-07, created 09-20;a.md:1fresh 10-07, no created. Order: z.md:9, b.md:1, a.md:5, a.md:2, a.md:1.- R1 (TICKLER beats older ROTTEN). TICKLER
b.md:1(fresh 10-05, scheduled 10-07) comes before ROTTENa.md:1(fresh 09-20, due 09-27). - R2 (tickler order).
x.md:1scheduled 10-06 created 09-01,w.md:1scheduled 10-07 created 09-05,y.md:1scheduled 10-07 created 09-01. Order: x, w, y. - B1. 20 lane stamps plus 5 Ready stamps today, budget 15 →
upkeep_today5,refreshed_today25,budget_met: false. Adding a Blocked[?]and an[x]stamped today →upkeep_today7.
Checklist tiers (CL1–CL12). Today is 2026-10-08. The prefix is
CL, because C1–C13 already name config vectors.
- CL1.
- [ ] #task #gtd #pre Brush teeth [repeat:: every day when done] [scheduled:: 2026-10-01]→ tierpre, laneready, state and bucket null, due,due_onnull. - CL2.
- [?] #task #gtd #pre Check weather [repeat:: every day when done] [scheduled:: 2026-10-08]→ tierpre, lane null, in the queue. - CL3. Each of these has no tier and is absent from the queue: CL2 with an open
dependency; CL1 with
[scheduled:: 2026-10-09]; CL1 plus#hide; CL1 under_templates/. - CL4. CL1 linked under today's open Pomodoro (Today), and CL1 in canonical daily
note
2026/20261008.md→ both stillpre. - CL5.
#prewithout#gtd→ none, lintchecklist_tag_incomplete.#gtd #pressed_juice→ none, no lint.#gtd/pre→ none, no lint.#GTD #Pre→pre.- CL6.
#gtd #pre #post→pre, lintchecklist_tag_conflict. - CL7. One-off, unstamped
- [ ] #task #gtd #post Write retro→ tierpost, statenew, bucketnew. It counts innewandpost_due. - CL8. One task per surviving tier comparator → queue order pre, new, projects, pending, next, tickler,
rotten, rotten, post when the example reference and ordinary task are both due.
walk = Σ by_tier, anddue/new/rottenequal the same vault without the checklist rows (except a CL7-style one-off member's own state). - CL9. A recurring checklist row refuses a stamp (Rust
stamp, JSstampLine/keepLine).keeps,upkeep_today, andrefreshed_todayare unchanged, anddecideis false. - CL10. After completion with the next occurrence inserted above
(
- [ ] … [scheduled:: 2026-10-09], then- [x] … [completion:: 2026-10-08]), neither line is in the queue. - CL11.
[*]with#gtd #pre→pre(lanenext).#gtd #post … ^ref→post. Checklist beats lane tiers and trackers. - CL12. A
#gtd #prerow with[repeat:: every day]and nowhen done→pre, lintchecklist_repeat_not_when_done.
Recurring tier (RC1–RC12). Today 2026-10-08, default config (interval 7, lanes 1), path a.md and Ready [ ]
unless noted. "→ none" means no tier and state null.
- RC1 arrived:
- [ ] #task Pay salary [repeat:: every month on the 1st] [scheduled:: 2026-10-01]→ tierrecurring, laneready,state/bucketnull,due_on2026-10-01,days_overdue7,decidefalse. - RC2 arrives today: the same with
[scheduled:: 2026-10-08]→recurring,due_on2026-10-08,days_overdue0. - RC3 not yet:
[scheduled:: 2026-10-09]→ none (not lane-visible). - RC4 due only, past:
[repeat:: every year] [due:: 2026-10-05]with noscheduled→recurring,due_on2026-10-05,days_overdue3. - RC5 due only, future:
[due:: 2026-10-20]with noscheduled→ none. - RC6 earliest date wins:
[start:: 2026-10-02] [due:: 2026-10-20]→recurring,due_on2026-10-02,days_overdue6. - RC7 undated:
- [ ] #task Water plants [repeat:: every week]→ none. Rustbob freshness listreports warningrecurring_undatedfor it. This is also why L3 and S13 (whose recurring rows carry no dates) stay unchanged. - RC8 lanes: a recurring
[*]and a recurring[/], each with[scheduled:: 2026-10-01]→recurring, keeping lanenext/pendingand null state. Neither is evernextorpendingtier. - RC9 exclusions: a recurring row with
[scheduled:: 2026-10-01]that is any of the following → none: - Today-linked;
- in
2026/20261008.md; #hide;- dependency-blocked;
- under
_templates/; [?](not a lane).- RC10 checklist wins:
- [ ] #task #gtd #pre Brush teeth [repeat:: every day when done] [scheduled:: 2026-10-01]→pre, neverrecurring. CL1–CL12 are unchanged. - RC11 a stray stamp is ignored:
[fresh:: 2026-10-08] [scheduled:: 2026-10-01]on a recurring row → stillrecurring, with nullstate. Stamping that line is still refused. - RC12 order and counts: the RECURRING rows are:
b.md:3scheduled 10-05a.md:9scheduled 10-05a.md:2scheduled 10-01
Add NEW w.md:1, a [*] n.md:1 stamped 10-07, and TICKLER t.md:1 (fresh 10-05,
scheduled 10-07). The RECURRING order is a.md:2, a.md:9, b.md:3. Counts:
- by_tier.recurring 3, recurring_due 3, walk 6;
- due 2 (NEW + resurfaced; recurring rows add nothing);
- upkeep_today unchanged.
RC12 full order: w.md:1 (new), n.md:1
(next), a.md:2, a.md:9, b.md:3 (recurring), t.md:1 (tickler).
Lints are Rust-only (bob freshness list LINTS). They apply only to open tasks, not to
done or cancelled lines in archives.
10a. Keep-streak conformance vectors (K1–K13)¶
D = 2026-10-08. The machine-readable form of every vector below —
plus the C config, D decide, and B boundary vectors — is
tests/fixtures/freshness_keeps/vectors.json, which both languages
cite. Rust runs the read/reset/placement cases and has no production
increment API; the JS side runs the increment vectors through
api.freshness.keepLine.
- K1 absent: no
keeps→keeps 0, no lints. - K2 first valid:
[fresh:: 2026-10-01] [keeps:: 2]preserved (seed mode) →- [ ] #task Rename queue input [fresh:: 2026-10-08] [keeps:: 2]. - K3 refresh/keeps order:
[keeps:: 2] [refresh:: 14] [fresh:: 2026-10-01]on a line with a Tasks suffix canonicalizes tofresh,refresh,keeps, then the suffix. - K4 same-day preservation: a canonical
[fresh:: 2026-10-08] [keeps:: 2]line stamped in preserve mode is byte-identical (changed: false). - K5 generic stamp clears: the same line stamped generically
loses
keeps(changed: true), even same-day. - K6 set-refresh clears:
set_refresh(…, 30)on a line withrefreshandkeepswrites[fresh:: D] [refresh:: 30]with nokeeps. - K7 stale stamp clears:
[fresh:: 2026-10-01] [keeps:: 1]stamped generically →[fresh:: 2026-10-08], nokeeps. - K8 misplaced:
[created::…] [keeps:: 2]readskeeps 2withfresh_misplaced; the next stamp repairs it to canonical order. - K9 duplicates and invalid values: two
keepsfields →keeps_duplicate, first valid wins;0, negative, fractional,1000, and non-numeric values →keeps_invalidand report 0; an invalid value beside a valid one lintskeeps_invalidand keeps the first valid value. - K10 ceiling and NEW:
[keeps:: 999]reads 999 (increments saturate at 999 on the JS side); a never-confirmed task readskeeps 0. - K11 refusals preserve: done/cancelled and recurring lines are refused unchanged — the streak stays on the closed line, and there is no write.
- K12 parser invariance: for every vector, the Tasks fields both
Rust parsers extract are identical before and after, exactly as §9
requires for
fresh/refresh/keeps/unblocked. - K13 exact keys: similarly named keys (
keep,Keep,keepsx) are notkeeps; the key match is exact and case-sensitive. Readers accept bracket and paren field syntax.
Config vectors C1–C13 pin decay normalization (absent, null,
true, {}, false, keeps: 0, fixed enter, and each invalid
shape). Decide vectors D1–D8 pin decide: at-limit Ready due rows
on every local day, below-limit Ready rows, TICKLER coverage, NEW
never, keeps: 0 asking on every due Ready re-confirmation,
decay: false never asking, and lane rows never deciding. Boundary
vectors B1–B5 pin immediate availability on 2026-10-04, 2026-10-08,
2026-10-18, 2026-10-19, and 2026-10-20. Rows have carried
keeps/decide since schema 4, and the current schema is 14; counts
carry decide; config.decay reports normalized enabled,
keeps, and enter. CLI human rows show kept N× and · decide
where true; the header explains the threshold or the off state.
Counts cover the full queue regardless of --limit.
10b. UB — unblocked vectors¶
Today 2026-10-08. Implementations copy these rows into tests, citing
the vector id in a comment. [unblocked::] is a run-extending
non-Tasks key in the freshness group
[fresh::] [refresh::] [keeps::] [unblocked::], then the Tasks suffix.
| # | situation | outcome |
|---|---|---|
| UB1 | reconcile Unblock of an in-scope Ready recovery with [fresh:: 2026-10-01] |
writes [unblocked:: 2026-10-08], replacing any older value; returned_by: unblocked; due_on 2026-10-08; tier TICKLER |
| UB2 | skip NEW: recovered line has no [fresh::] |
no [unblocked::]; already due as NEW |
| UB3 | skip scheduled tickler: fresh 2026-10-01, scheduled 2026-10-07 |
no [unblocked::]; already RESURFACED from scheduled |
| UB4 | skip Next/Pending landing: recovery lands [*] or [/] |
no [unblocked::]; the lane walk covers it |
| UB5 | skip recurring | no [unblocked::]; outside freshness scope |
| UB6 | skip hidden (#hide) |
no [unblocked::]; outside freshness scope |
| UB7 | stamp (Alt+F, stampLine, keepLine, capture &) on a line with [unblocked:: 2026-10-08] |
field removed |
| UB8 | prune: [fresh:: 2026-10-08] [unblocked:: 2026-10-07] |
reconcile drops [unblocked::] (fresh > U) |
| UB9 | tie fresh == U: [fresh:: 2026-10-08] [unblocked:: 2026-10-08] |
RESURFACED, returned_by: unblocked, due_on 2026-10-08 (the fresh ≤ unblocked rule) |
| UB10 | both sources: fresh 2026-10-01, scheduled 2026-10-06, unblocked 2026-10-07 |
due_on is the latest (2026-10-07); returned_by: unblocked |
| UB11 | both sources tie: fresh 2026-10-01, scheduled 2026-10-07, unblocked 2026-10-07 |
due_on 2026-10-07; returned_by: scheduled |
| UB12 | parser invariance | a line with [unblocked:: 2026-10-08] before [scheduled::], [dependsOn::], and [id::] still parses those Tasks fields identically before and after |
| UB13 | keep on an unblock tickler (returned_by: unblocked) |
stamps [fresh:: today]; [keeps::] unchanged (neither increment nor reset); decide stays false; [unblocked::] cleared by the stamp |
| UB14 | TICKLER tie order | two unblock ticklers with the same due_on sort by path ↑, then line ↑ (one project's residents are contiguous); created is not a key |
| UB15 | ^prj whose inline [scheduled:: 2026-10-07] arrived after [fresh:: 2026-10-01] |
RESURFACED into PROJECTS, not TICKLER |
WRITE skips that the recovered line is a canonical daily-note task follow UB5/UB6 (outside freshness scope). Capture/cycler close recovery and nav/cycler project recovery of residents follow UB1 with the same skips.
11. Display: the freshness mark¶
The freshness mark is display-only and lives only in bob-ledger-tools.
Nothing ever writes it: [fresh:: YYYY-MM-DD] stays the only stored
form, and Rust surfaces already strip inline fields
(note_tasks::clean_description). The mark is computed at render time
from the stamp, the interval, and the shared evaluator that also
drives bob freshness list, the status bar, Ctrl+Alt+J, and
freshness.md.
Principles. Store absolute, show relative. One glyph lifecycle,
borrowed from the status bar: ✓ when confirmed today, a lease ring
that drains as the task ages, ⟳ when due (Alt+F turns ⟳ back into
✓). Loud only when actionable: only tasks the evaluator says are
due get color and a capsule. Truthful or neutral: when the plugin
cannot identify the exact task, the mark shows only the neutral lease
and never a guessed "due"; non-canonical stamps keep their Dataview
pill, flagged as needing repair. Reversible and editable: the cursor
or a click reveals the raw text, source mode shows raw text, a session
toggle restores the old pills, and without bob-ledger-tools the vault
falls back to today's pills.
Anatomy and tones. A mark is [glyph][label][interval?],
rendered at 0.8em in the interface font with tabular numerals, sitting
on the text baseline.
| Tone | When | Glyph | Color |
|---|---|---|---|
today |
age 0 on an open task | circle-check (full ring + tick) | --task-status-next green |
aging |
evaluator says FRESH, or the task is unresolved | lease ring: faint track + arc for the remaining lease | --text-muted |
due |
evaluator says ROTTEN or RESURFACED | ⟳ (rotate-cw) |
--color-orange: the only loud tone (14% tinted capsule, 1px inset ring, weight 650) |
resting |
evaluator says out of scope (Next, In Progress, Blocked, linked today, …) or the task is closed | lease ring | --text-faint, quieter still |
Each tone is also distinguished by its glyph, and due by its
capsule, so the marks stay readable for color-blind users.
Label, ring, and interval. ageDays = days(fresh → today), never
negative because future stamps get no mark. The label is today at
age 0, otherwise {N}d (1d, 7d, 282d); days are always the
unit, matching bob freshness. The interval is lane-aware per §4: a
lane task in a walked lane uses its lane interval, otherwise the
existing precedence (task refresh, then the note's task_refresh,
then freshness.interval, then 7). remaining =
clamp((interval − ageDays) / interval, 0, 1), rounded to 4 decimals:
full on the day of confirmation, empty exactly when due (at 0 only
the faint track is drawn). The ring starts at 12 o'clock, drawn
clockwise for remaining. The interval suffix /{N}d appears only
when the effective interval source is task, and never on the today
tone; note, config, default, and lane intervals stay tooltip-only.
freshnessMarkEveryPhrase renders the pending/next sources as
(pending lane) / (next lane).
Tooltip. An aria-label with data-tooltip-position="top",
lines joined with \n, never containing ::. Dates use fixed English
names independent of locale (Thu, Oct 1; the year is appended only
when it differs from today's year: Tue, Dec 30, 2025). Relative
age: today, yesterday, or N days ago. every … reads every N
days (or every 1 day), plus (this task), (this note), or
(config) for those sources and nothing for the default, plus
(pending lane) / (next lane) for the lane sources and
(project) for the project tracker source. Line 1:
Confirmed today at age 0, otherwise Confirmed {date} ·
{relative}. Line 2 is picked by resolution rather than tone: closed
or out of scope → Not in the review queue: {reason}; a due lane
row → Daily {PENDING|NEXT} review due since {dueOn} · every …; a
lane row stamped today → Next review {fresh+interval} · every …
({pending|next} lane); a PROJECTS row → Empty project with its
confirmation/due detail and effective project interval (never a daily
lane review or a generic hidden-task exemption); ROTTEN →
Due for review since {dueOn} ·
every …; RESURFACED → Resurfaced {scheduled}: scheduled after it
was confirmed; FRESH or unresolved with the lease running → Next
review {fresh+interval} · every …; unresolved with the lease over →
Review lease ended {fresh+interval} · every …. Line 3, due tone
only: Alt+F to confirm (lane rows add the lane keep/release/today
keys per M9). Lane and project-tier rows with a tier get the due
tone and lane rows stamped today get the today tone. Lane tasks
outside the walk (Today, daily note, disabled lane, recurring,
hidden ordinary tasks) keep resting with the existing reasons —
but a disabled lane never takes a project tracker out of the walk. Reasons by
status symbol: * Next, / In Progress, ? Blocked, x/X Done,
- Cancelled, any other non-space symbol status [s]; for [ ],
the first that applies: linked today, in a daily note,
recurring, in _templates or _conflicts, scheduled for {date},
otherwise hidden or dependency-blocked.
Eligibility. A Live Preview task line gets a mark only when
freshnessTaskStatus(line) !== null (quote-aware), the line has
exactly one fresh field and it is square-bracketed, and
readFreshness(line, today) yields a date with none of
fresh_malformed, fresh_future, fresh_duplicate, or
fresh_misplaced. Refresh folding: when the line's only refresh
field is square-bracketed, starts exactly one space after the fresh
field, and parses (1–365), it folds into the mark; otherwise it stays
a Dataview pill. Space folding: when the character before [fresh::
is a space, the decoration range starts at that space and the widget
restores the gap, so the mark wins over Dataview's widget by
position. Lines inside code and lines in source mode never get a
mark. Rendered views (a text node) skip the task-line and misplaced
checks, because Tasks may have split off the suffix: the node must
hold exactly one square-bracketed fresh field with a strict,
non-future date, with adjacent refresh folding as above; text inside
code, pre, .bob-fresh-mark, or .dataview.inline-field is
never touched.
Resolution. Exact first: a memo row with the same path, the
same 0-based line, and rawLine === lineText (Live Preview only),
modelled via freshnessEvaluate. Then consensus: every memo row in
path whose own mark source text equals this mark's source text;
when at least one candidate exists and all models are deep-equal, use
it. Otherwise unresolved: a neutral lease with tone today or
aging, the interval from the line plus noteFreshnessRawFor(path)
plus config, and data-resolved="false". In Live Preview the line's
own status symbol always applies the closed override, even when the
memo is a moment behind.
Interaction and surfaces. Live Preview: the mark hides while any
selection range overlaps the field span (Dataview's inclusive rule),
so moving the cursor in shows the raw text; a mousedown on the mark
places the cursor at the start of the field and focuses the editor
(the mark never writes; Alt+F confirms); marks recompute on edits,
viewport and selection changes, the debounced freshness refresh, and
the date rollover, with widgets comparing a model key in eq() so an
unchanged mark never flickers. Rendered views (reading view, Tasks
query results such as dash.md and freshness.md, embeds, hover
previews) render when the host renders and refresh when it
re-renders. Repair flag: while marks are on,
body.bob-fresh-marks is set and any leftover fresh/refresh
Dataview pill in Live Preview is a non-canonical stamp, shown at full
opacity with a dashed orange border. Session toggle: the "Toggle task
freshness marks" command (default on) flips the marks and the body
class, refreshes every editor, and shows a Notice.
Folded keep display (shipped ledger-tools 1.23.0/1.24.0). From
freshnessMarkModel, the keep-pip and leaf rendering, and the tooltip
builder near the Alt+F to decide string (tests
scripts/test-ledger-tools-freshness-keeps.cjs,
scripts/test-ledger-tools-freshness-decision-card.cjs):
- Faint filled dots after the label:
--text-faint, or subdued orange inside a due capsule, never green or red. - Dot cap: 3 by default; the threshold itself for thresholds 1–2;
overflow
+N; the exact count in the accessible text. - The leaf replaces
⟳withdata-decide="true"only when the nav card capability is present and decay is on. - Folding: exactly one valid square-bracket
[keeps:: N]one space after the folded fresh/refresh run is folded. Noncanonical fields keep the dashed repair pill. - Selection or click reveals the whole raw span and never writes.
- Ambiguous consensus stays neutral, and closed or out-of-scope tasks show dots quietly without a leaf.
- Tooltip wording:
Kept N reviews in a row · Bob asks at L, theAlt+F to decidehint, and counting-only/off wording otherwise (Kept N reviews in a rowalone without the card capability,· decay offwith decay off,· Bob asks every reviewfor a zero threshold). - Rotten-log line (shipped ledger-tools 1.65.0):
🥀 3 keeps logged · last Oct 16 — waiting for the weekend…after the keeps line and before the action line (🥀 1 keep logged · …for one entry). The reason truncates to 48 characters with…;🤷 no reason givenshows as is. It appears only where the task's child block is available from the same source document (Live Preview, reading view of the source note); absent, empty, or ambiguous logs show nothing, and Tasks query rows, the dashboard, androtten.mdresults never show it.
Live verification (Bryan, in Obsidian).
- Task lines in Live Preview show
✓ today, the ring withNd, and⟳ Ndin an orange capsule; no DataviewFRESHpill appears beside a mark. - Moving the cursor into a mark, or clicking it, reveals
[fresh:: …]for editing. - Alt+F on a
⟳task flips it to✓ todaywithin about a second. - Ctrl+Shift+P
f→ 14 shows/14dfrom the next day. dash.mdandfreshness.mdTasks results and reading view show marks; the freshness.md DUE group shows⟳.- A hand-broken stamp (
[fresh:: 2026-13-01]) shows the dashed repair pill. - "Toggle task freshness marks" restores the old pills and back again.
- The marks look right in the light and dark themes.
- Metadata Menu does not double-decorate the field.
- Tooltips show their lines. If Obsidian collapses
\n, record that as a follow-up rather than changing the format.
Rejected alternatives. Stored-syntax changes (emoji, compact
codes, stored relative ages) would leave Dataview's field index,
rot overnight, and rewrite both implementations plus ~540 vault
lines. CSS-only restyles cannot compute an age, lease, or review
state. Hiding fresh entirely loses the due signal. Lane-by-CSS
accents contradict the queue for #hide, linked-today, and
RESURFACED tasks; exact-or-neutral wins.
12. Mark conformance examples¶
Today 2026-10-08 (Thursday), config interval 7. Each M vector is a
Ready, visible, non-recurring task in a.md that the evaluator
resolves, unless noted. Tooltip lines are separated by ⏎ here; the
code joins them with \n. The bob-ledger-tools JavaScript tests use
these vectors verbatim.
- M1 today:
- [ ] #task Buy milk [fresh:: 2026-10-08] [created::2026-09-29]→ text[fresh:: 2026-10-08], foldSpace true; tonetoday, glyphcheck, labeltoday, intervalLabel null, remaining 1. Tooltip:Confirmed today ⏎ Next review Thu, Oct 15 · every 7 days. - M2 aging:
[fresh:: 2026-10-05]→ age 3, label3d, remaining 0.5714, toneaging, glyphring. Tooltip:Confirmed Mon, Oct 5 · 3 days ago ⏎ Next review Mon, Oct 12 · every 7 days. - M3 boundary:
[fresh:: 2026-10-01]→ ROTTEN; tonedue, glyphrefresh, label7d, remaining 0. Tooltip:Confirmed Thu, Oct 1 · 7 days ago ⏎ Due for review since Thu, Oct 8 · every 7 days ⏎ Alt+F to confirm. - M4 yesterday:
[fresh:: 2026-10-07]→ label1d, remaining 0.8571. Tooltip:Confirmed Wed, Oct 7 · yesterday ⏎ Next review Wed, Oct 14 · every 7 days. - M5 folded refresh:
- [ ] #task Rename queue input [fresh:: 2026-10-05] [refresh:: 14] [created::2026-09-10] [priority:: low]→ text[fresh:: 2026-10-05] [refresh:: 14]; label3d, intervalLabel/14d, remaining 0.7857. Tooltip:Confirmed Mon, Oct 5 · 3 days ago ⏎ Next review Mon, Oct 19 · every 14 days (this task). - M6 refresh today:
[fresh:: 2026-10-08] [refresh:: 14]→ tonetoday, labeltoday, intervalLabel null. Tooltip:Confirmed today ⏎ Next review Thu, Oct 22 · every 14 days (this task). - M7 note interval: note
task_refresh: 3,[fresh:: 2026-10-06]→ label2d, remaining 0.3333, intervalLabel null. Tooltip:Confirmed Tue, Oct 6 · 2 days ago ⏎ Next review Fri, Oct 9 · every 3 days (this note). - M8 resurfaced:
- [ ] #task Week habits [fresh:: 2026-10-05] [scheduled:: 2026-10-07]→ tonedue, glyphrefresh, label3d, remaining 0.5714. Tooltip:Confirmed Mon, Oct 5 · 3 days ago ⏎ Resurfaced Wed, Oct 7: scheduled after it was confirmed ⏎ Alt+F to confirm. - M9 (changed).
- [*] #task Ship it [fresh:: 2026-09-20]gives tonedue, glyphrefresh, label18d, remaining 0. Tooltip:Confirmed Sun, Sep 20 · 18 days ago ⏎ Daily NEXT review due since Mon, Sep 21 · every 1 day (next lane) ⏎ Alt+F keep · Alt+N release · Ctrl+Shift+Enter today. - M10 (changed).
- [*] #task Ship it [fresh:: 2026-10-08]gives tonetoday. Tooltip:Confirmed today ⏎ Next review Fri, Oct 9 · every 1 day (next lane). - M11 closed:
- [x] #task Old [fresh:: 2026-10-08] [completion:: 2026-10-08]→ toneresting, glyphring, labeltoday. Tooltip:Confirmed today ⏎ Not in the review queue: Done. - M12 unresolved, running: as M2 with no resolution → tone
aging, resolved false, tooltip as M2. - M13 unresolved, lease over:
[fresh:: 2026-09-20]on[ ], unresolved → toneaging, label18d, remaining 0. Tooltip:Confirmed Sun, Sep 20 · 18 days ago ⏎ Review lease ended Sun, Sep 27 · every 7 days. - M14 linked today: a Ready
[fresh:: 2026-10-05]task linked under today's open Pomodoro → toneresting. Line 2:Not in the review queue: linked today. - M15 other year:
[fresh:: 2025-12-30], unresolved → label282d. Tooltip:Confirmed Tue, Dec 30, 2025 · 282 days ago ⏎ Review lease ended Tue, Jan 6 · every 7 days. - M16 quoted:
> - [ ] #task Quoted [fresh:: 2026-10-05] [created::2026-09-01]→ a mark with foldSpace true. - M17 invalid refresh is not folded:
[fresh:: 2026-10-05] [refresh:: 0]→ text[fresh:: 2026-10-05], interval 7 (default), intervalLabel null; the refresh stays a pill. - M18 non-adjacent refresh:
- [ ] #task X [refresh:: 14] [fresh:: 2026-10-05]→ text[fresh:: 2026-10-05], interval 14(this task), intervalLabel null. - M19. M9's line with
next_interval: falsegives toneresting, line 2Not in the review queue: Next. - M20.
- [/] #task Land it [fresh:: 2026-10-07] [refresh:: 30]folds the refresh into the mark but shows no/30dsuffix, because the effective source ispending. Tonedue, label1d. Line 2:Daily PENDING review due since Thu, Oct 8 · every 1 day (pending lane). - N1–N6, no mark (source null):
- N1 malformed
[fresh:: 2026-13-01]; - N2 future
[fresh:: 2026-10-09]; - N3 duplicate:
- [ ] #task A [fresh:: 2026-09-01] B [fresh:: 2026-09-20] [created::2026-09-01]; - N4 misplaced:
- [ ] #task Buy milk [created::2026-09-29] [fresh:: 2026-10-01]; - N5 parenthesized:
- [ ] #task Call mom (fresh:: 2026-10-05); - N6 not a task:
- Buy milk [fresh:: 2026-10-05]. - C1–C3, consensus:
- C1: two
a.mdrows with source[fresh:: 2026-10-01], both Ready and ROTTEN → theduemodel. - C2: the same pair, but one row is Next → null, so the mark is unresolved.
- C3: no candidate rows → null.
- MK1 no count:
[fresh:: 2026-10-05]with nokeeps→keeps 0, dots null, tooltip as M2 with no keeps line. - MK2 aging with 2:
[fresh:: 2026-10-05] [keeps:: 2](FRESH) → dots••, toneaging; without the card capability the keeps line is counting-only (Kept 2 reviews in a row). - MK3 due below threshold: ROTTEN
[fresh:: 2026-09-30] [keeps:: 2]→ dots••, glyphrefresh(not a leaf), line 3Alt+F to confirm. - MK4 at-limit leaf: ROTTEN
[fresh:: 2026-09-30] [keeps:: 3]withdecide, the nav card capability, and decay on → glyphleaf,data-decide="true", dots•••, keeps lineKept 3 reviews in a row · Bob asks at 3, line 3Alt+F to decide. - MK5 overflow:
keeps 4at limit 3 → dots•••, overflow+1(rendered•••+1), keeps lineKept 4 reviews in a row · Bob asks at 3. - MK6 repair: duplicate
[keeps:: 2] [keeps:: 3]or paren(keeps:: 2)never folds (keepsnull); the fields stay visible Dataview pills with the dashed repair styling.
13. Rollout log¶
There is no freshness trial: no ritual change, release, or tuning waits on a trial window or keep rule; tune intervals or the budget whenever the walk needs it.
- 2026-10-01: per-note CROWDED surfaces (
bob ready, the dash chip, crowded.md, the heading chips) went live; gesture notices are a separate follow-up. - 2026-10-01: the tiered morning review walk went live (schema 3, lane intervals, tier notices, walk anchor).
- 2026-10-04: PRE/POST checklist tiers went live (schema 9, ledger 1.29.0 / namespace v7, nav 2.3.0, cycler API v2).
- 2026-10-04: Ctrl+Enter walks the PRE/POST checklist (nav 2.4.0 / nav api v2, cycler 1.25.0).
- 2026-10-06: answering a landed row advances the walk (nav 2.6.1 / nav api v3, cycler 1.26.0, block-id-prompt 1.22.0, ledger 1.29.2).
- 2026-10-06: Ctrl+Shift+M from a landing follows the task to its destination and never advances; the next ]s resumes at the moved row's neighbour (nav 2.7.0).
- 2026-10-06: the walk keeps queue order across line shifts; it follows rows by note and text and moves only from the row just answered (nav 2.7.1).
- 2026-10-06: inbox routing went live — on an open inbox task, every non-closing Ctrl+Shift+P answer and Ctrl+Shift+Enter asks where the task goes before writing, acts then moves without following, and advances the walk as an answer (nav 2.10.0, block-id-prompt 1.23.0).
- 2026-10-08:
]s/[saway from the current review task first return to it; the next press steps from there (nav 2.13.0). - 2026-10-09: ref review re-keyed from the exact
^refblock ID to the#reftag (schema 12, ledger 1.37.0 / namespace v10 withrefTagIdentity): Ready#refrows keep REFERENCES on the reference cadence (never NEW, never decide);[*]/[/]refs walk PENDING/NEXT as ordinary lane tasks; the#hidebypass survives only for exact^refrows until closeout. - 2026-10-10: closeout removed the transitional hidden-tag bypass for exact reference rows (schema 12, historical shape; ledger 1.39.0 / namespace v10): hidden rows stay out of the walk like any hidden task.
- 2026-10-10: schema 13 and freshness namespace v11 removed the reference cadence and REFERENCES review group. References now use ordinary freshness, lane groups, keep/decay eligibility, and commitment/upkeep rules.
- 2026-10-12: schema 14 and freshness namespace v12 add
[unblocked::](returned_by / unblocked_on,markUnblockedLine). Automatic Blocked → Ready recoveries resurface as TICKLER. - 2026-10-23: Rotten Log went live — every counted keep asks "Why
keep it as written?" and writes one dated
🥀 **ROTTEN LOG**entry with the stamp (bob-navigation-hotkeys 2.53.0 grammar/reader, 2.54.0 single keeps, 2.55.0 decay-card Keep stage, 2.56.0 counted and Task Link batches, 2.56.1 card header fix; bob-ledger-tools 1.65.0 mark tooltip line; Mac agenda🥀 Nchip; bob-cli managed-log registry with agendalog: "rotten"and snapshotKEPTrows).
14. Keep-streak rollout, rollback, and calibration¶
Deployed 2026-10-03: bob-navigation-hotkeys 1.69.0 plus bob-ledger-tools
1.24.0 synced byte-identical from the linked source, and bob
reinstalled from this checkout (schema 5, capture resets). Counting and
folded pips went live then; cards, the leaf, decision skip, and the
'next review asks' promise were still gated behind 2026-10-19 at that
release.
This release removes that calendar gate: bob-navigation-hotkeys 2.2.0 plus bob-ledger-tools 1.28.0, freshness JSON schema 8, freshness namespace v6, and card capability v2. Decisions are available immediately after installing compatible plugins. Mixed-version sessions fall back: new nav + old ledger counts; new ledger + old/missing nav shows pips without a card promise.
Rollback is config-only and never touches task lines: set
freshness.decay: false to disable card interception while preserving
counting and pips; the session mark toggle restores raw pills. This
rollout changed no global config, note intervals, or inbox residence.
Calibration is a lightweight human tally one month after this ungated rollout: decision count, Keep/Drop outcomes, and TICKLER load for two weeks after the first card wave, from counts, decision logs, and git history — no telemetry service or automatic tuning. If most cards (>50%) are Keep, consider limit 4; if most are Drop, consider limit 2.
Human smoke checklist (no Obsidian UI was available in this headless rollout, so visual verification is still open; automated mark-surface and modal interaction tests pass): light and dark themes, dense lines, narrow widths, Live Preview and reading/Tasks/embeds views, dots and leaf, raw reveal on selection and click, card focus order and key hints, explicit cancellation, and one undo step — on fixture tasks only, never by forcing live task dates or counters.
Rotten Log rollback: the logs remain as plain bullets — no cleanup write exists and none is planned. There is no off-switch; at about 0.6 prompts a morning a plugin rollback is enough. An older nav without the registered grammar would convert the marker into a task during project conversion, so roll forward rather than back.