Skip to content

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 fresh equals 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 freshness exits 2; ledger-tools falls back to the defaults and marks them invalid.
  • Unknown keys are ignored, and a mistyped freshness: must not break any other config loader (it is read as a raw serde_yaml::Value, mirroring how plan: 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/tickler stamps 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 keepLine uncounted 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 decide and 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 clears keeps.
  • A keep on a returned_by: unblocked TICKLER row neither increments nor resets [keeps::], and decide_for never 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) clears keeps;
  • 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_change stamps through the private preserve-mode primitive, so the cutover never silently resets a streak;
  • capture's existing stamp_fresh consumers clear keeps through 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::].

  1. Scope. The helpers handle Tasks' Dataview format only, which is the vault's format. bob freshness refuses 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.
  2. The Tasks suffix. Scan from the end of the line, the way both parsers do:
  3. first an optional trailing ^id (the BLOCK_LINK grammar);
  4. 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, following trailing_inline_field's grammar;
    • a fresh, refresh, keeps, or unblocked field;
    • a trailing tag (the HASH_TAG_AT_END grammar).
  5. 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, and fresh count 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_tier counts 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, and recurring_due each equal their tier count, for symmetry.
  • refreshed_today keeps its meaning.
  • New count upkeep_today: tasks of any status outside _templates / _conflicts whose fresh equals 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 &note: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:

  1. [S, or ]s from outside the queue when no review task is current, starts at PRE.
  2. 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 with PRE done and 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 → POST hint (Ctrl+Alt+F crosses). Keep import is the PRE chore "Import inbox tasks from Google Keep".
  3. 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 ]s resumes 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 ]s skip rows or jump ahead. Alt+F is the one answer that stays on the row; ]s skips 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+P 1–4 remains available to choose a specific priority. Clear CROWDED to 0 (split, sequence, defer, drop via bob ready) and choose a highlight (≤3 themes, highlight first). CROWDED does not depend on how far ROTTEN review got.
  4. ROTTEN is optional upkeep until 0, the budget, or you stop. The commitments → ROTTEN boundary notice includes ]S closes the review when 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.
  5. ]S jumps 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 due total is walk.
  • Human vocabulary says "tickler"; the machine state stays resurfaced. A TICKLER row reads unblocked YYYY-MM-DD when returned_by is unblocked, else scheduled YYYY-MM-DD. There is no reason suffix: the reason is not stored.
  • A disabled lane shows pending off in the header.
  • The header always shows the keep threshold: keeps 3 when enabled, keeps 3 · decay off with decay off, and keeps 0 · asks every review for a zero threshold. Only a capable installed card promises next review asks.
  • Human rows show kept N× when the streak is nonzero and · decide where 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 ≥ 1 days read {n}d overdue.
  • With a budget, the meter reads ✓ 12/15 today.
  • --limit truncates 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 reports fresh_misplaced on 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 reports fresh_duplicate and 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], and set_refresh(…, none) removes it
  • P13 recurring refused: - [ ] #task Water plants [repeat:: every week] [created::2026-09-01] → unchanged, refused as recurring
  • 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 as closed
  • 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], note task_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 note task_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 future scheduled; [*]; [/]; #hide; _templates/x.md; daily note 2026/20261008.md; a Ready task linked under today's open Pomodoro; a [ ] whose dependsOn names an open task. Note that [*] and [/] keep a null state and bucket but are in tier scope (see L1).
  • S14 queue order (rewritten). Given NEW b.md:3 and NEW a.md:9; ROTTEN due 2026-10-01 at c.md:2; RESURFACED due 2026-10-07 at a.md:4; ROTTEN due 2026-10-07 at a.md:2. The order is a.md:9, b.md:3, a.md:4 (tickler), c.md:2, a.md:2.
  • S15 counts (updated): refreshed_today counts a [*] and an [x] stamped 2026-10-08, but not a Ready task stamped 2026-10-07; upkeep_today counts 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:1 fresh 09-28, created 09-04;
  • b.md:1 fresh 09-28, created 09-01;
  • a.md:1 fresh 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:1 fresh 10-07; [*] c.md:1 fresh 10-07; TICKLER b.md:1 fresh 10-05 scheduled 10-07; ROTTEN a.md:1 fresh 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 tier next, due_on 10-08, days_overdue 0, interval 1 from source next, and null state/bucket.
  • L2 (lane overrides refresh). A [/] with [refresh:: 30] stamped 10-07 has tier pending and interval 1 from source pending.
  • L3. An undated recurring [*], a Today-linked [*], a [*] in 2026/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:9 never stamped, created 09-01;
  • b.md:1 fresh 10-01, created 09-15;
  • a.md:5 fresh 10-07, created 09-10;
  • a.md:2 fresh 10-07, created 09-20;
  • a.md:1 fresh 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 ROTTEN a.md:1 (fresh 09-20, due 09-27).
  • R2 (tickler order). x.md:1 scheduled 10-06 created 09-01, w.md:1 scheduled 10-07 created 09-05, y.md:1 scheduled 10-07 created 09-01. Order: x, w, y.
  • B1. 20 lane stamps plus 5 Ready stamps today, budget 15 → upkeep_today 5, refreshed_today 25, budget_met: false. Adding a Blocked [?] and an [x] stamped today → upkeep_today 7.

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] → tier pre, lane ready, state and bucket null, due, due_on null.
  • CL2. - [?] #task #gtd #pre Check weather [repeat:: every day when done] [scheduled:: 2026-10-08] → tier pre, 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 still pre.
  • CL5.
  • #pre without #gtd → none, lint checklist_tag_incomplete.
  • #gtd #pressed_juice → none, no lint.
  • #gtd/pre → none, no lint.
  • #GTD #Pre → pre.
  • CL6. #gtd #pre #post → pre, lint checklist_tag_conflict.
  • CL7. One-off, unstamped - [ ] #task #gtd #post Write retro → tier post, state new, bucket new. It counts in new and post_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, and due/new/rotten equal 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, JS stampLine/keepLine). keeps, upkeep_today, and refreshed_today are unchanged, and decide is 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 (lane next). #gtd #post … ^ref → post. Checklist beats lane tiers and trackers.
  • CL12. A #gtd #pre row with [repeat:: every day] and no when done → pre, lint checklist_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] → tier recurring, lane ready, state/bucket null, due_on 2026-10-01, days_overdue 7, decide false.
  • RC2 arrives today: the same with [scheduled:: 2026-10-08] → recurring, due_on 2026-10-08, days_overdue 0.
  • RC3 not yet: [scheduled:: 2026-10-09] → none (not lane-visible).
  • RC4 due only, past: [repeat:: every year] [due:: 2026-10-05] with no scheduled → recurring, due_on 2026-10-05, days_overdue 3.
  • RC5 due only, future: [due:: 2026-10-20] with no scheduled → none.
  • RC6 earliest date wins: [start:: 2026-10-02] [due:: 2026-10-20] → recurring, due_on 2026-10-02, days_overdue 6.
  • RC7 undated: - [ ] #task Water plants [repeat:: every week] → none. Rust bob freshness list reports warning recurring_undated for 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 lane next / pending and null state. Neither is ever next or pending tier.
  • 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, never recurring. CL1–CL12 are unchanged.
  • RC11 a stray stamp is ignored: [fresh:: 2026-10-08] [scheduled:: 2026-10-01] on a recurring row → still recurring, with null state. Stamping that line is still refused.
  • RC12 order and counts: the RECURRING rows are:
  • b.md:3 scheduled 10-05
  • a.md:9 scheduled 10-05
  • a.md:2 scheduled 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 to fresh, 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 with refresh and keeps writes [fresh:: D] [refresh:: 30] with no keeps.
  • K7 stale stamp clears: [fresh:: 2026-10-01] [keeps:: 1] stamped generically → [fresh:: 2026-10-08], no keeps.
  • K8 misplaced: [created::…] [keeps:: 2] reads keeps 2 with fresh_misplaced; the next stamp repairs it to canonical order.
  • K9 duplicates and invalid values: two keeps fields → keeps_duplicate, first valid wins; 0, negative, fractional, 1000, and non-numeric values → keeps_invalid and report 0; an invalid value beside a valid one lints keeps_invalid and 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 reads keeps 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 not keeps; 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 ⟳ with data-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, the Alt+F to decide hint, and counting-only/off wording otherwise (Kept N reviews in a row alone without the card capability, · decay off with decay off, · Bob asks every review for 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 given shows 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, and rotten.md results never show it.

Live verification (Bryan, in Obsidian).

  • Task lines in Live Preview show ✓ today, the ring with Nd, and ⟳ Nd in an orange capsule; no Dataview FRESH pill 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 ✓ today within about a second.
  • Ctrl+Shift+P f → 14 shows /14d from the next day.
  • dash.md and freshness.md Tasks 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; tone today, glyph check, label today, intervalLabel null, remaining 1. Tooltip: Confirmed today ⏎ Next review Thu, Oct 15 · every 7 days.
  • M2 aging: [fresh:: 2026-10-05] → age 3, label 3d, remaining 0.5714, tone aging, glyph ring. Tooltip: Confirmed Mon, Oct 5 · 3 days ago ⏎ Next review Mon, Oct 12 · every 7 days.
  • M3 boundary: [fresh:: 2026-10-01] → ROTTEN; tone due, glyph refresh, label 7d, 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] → label 1d, 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]; label 3d, 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] → tone today, label today, 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] → label 2d, 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] → tone due, glyph refresh, label 3d, 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 tone due, glyph refresh, label 18d, 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 tone today. 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] → tone resting, glyph ring, label today. 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 → tone aging, label 18d, 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 → tone resting. Line 2: Not in the review queue: linked today.
  • M15 other year: [fresh:: 2025-12-30], unresolved → label 282d. 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: false gives tone resting, line 2 Not in the review queue: Next.
  • M20. - [/] #task Land it [fresh:: 2026-10-07] [refresh:: 30] folds the refresh into the mark but shows no /30d suffix, because the effective source is pending. Tone due, label 1d. 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.md rows with source [fresh:: 2026-10-01], both Ready and ROTTEN → the due model.
  • 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 no keeps → keeps 0, dots null, tooltip as M2 with no keeps line.
  • MK2 aging with 2: [fresh:: 2026-10-05] [keeps:: 2] (FRESH) → dots ••, tone aging; 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 ••, glyph refresh (not a leaf), line 3 Alt+F to confirm.
  • MK4 at-limit leaf: ROTTEN [fresh:: 2026-09-30] [keeps:: 3] with decide, the nav card capability, and decay on → glyph leaf, data-decide="true", dots •••, keeps line Kept 3 reviews in a row · Bob asks at 3, line 3 Alt+F to decide.
  • MK5 overflow: keeps 4 at limit 3 → dots •••, overflow +1 (rendered •••+1), keeps line Kept 4 reviews in a row · Bob asks at 3.
  • MK6 repair: duplicate [keeps:: 2] [keeps:: 3] or paren (keeps:: 2) never folds (keeps null); 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 / [s away 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 ^ref block ID to the #ref tag (schema 12, ledger 1.37.0 / namespace v10 with refTagIdentity): Ready #ref rows keep REFERENCES on the reference cadence (never NEW, never decide); [*]/[/] refs walk PENDING/NEXT as ordinary lane tasks; the #hide bypass survives only for exact ^ref rows 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 🥀 N chip; bob-cli managed-log registry with agenda log: "rotten" and snapshot KEPT rows).

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.

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