Reference library (bob ref)¶
bob ref find looks up URLs, arXiv IDs, DOIs, vault paths, note stems,
titles, and frontmatter ids in the reference library under ref/. It
answers "is this already in my library?" with a verdict per query, in
human, Markdown, or versioned JSON output. bob ref list renders
filtered library views, defaulting to the reading queue (queued and
started notes). bob ref show resolves one or more exact references
into their metadata, annotations, notes, and tasks. bob ref today
links a reference's v2 reading task under today's Pomodoro. find,
list, and show are read-only; find -i also reads intake PDF
markers. bob ref today --dry-run plans without writing. bob ref rename renames
an unannotated modern reference's files and updates links (preview with -d).
bob ref migrate-zorg
plans copies of zorg-era status:: reading records into legacy notes under
ref/zorg/. It is a dry run by default; -w/--write applies the plan.
Choose a command¶
| Goal | Command | Effect |
|---|---|---|
| Check whether a reference is known | bob ref find <URL-or-identifier> |
Read library notes; --include-intake also checks queued PDFs |
| See what remains to read | bob ref list |
Read queued and started reference notes |
| Read annotations and your notes | bob ref show <note-path> |
Read one exact reference; -c selects commented annotations |
| Read a reference today | bob ref today <REF> [-p NAME] |
Set its reading task Next and link it under today's Pomodoro |
| Fix a reference's file name | bob ref rename <REF> <NAME> -d, then without -d |
Move the note, PDF, and companions, update links, and make the reference id follow its stem while keeping the reading task |
| Import a URL, local PDF, or Markdown file now | bob ref create <TARGET> -P <parent> |
File the PDF, note, and reading task inline; use -k to queue it in xlib/ |
| Save a bare public link quickly | bob capture '<URL>' |
Queue a background job when the routing policy admits the URL; the job files the note when it runs, and a queued clip appears in ref list after the host's intake drain (bob ref scan -i) |
| Inspect or process background imports | bob ref jobs, bob ref jobs run |
List jobs, or fetch pending links; see ref jobs |
| Drain queued PDFs | bob ref scan -i (--intake-only) |
File only the PDFs and late audio queued in xlib/ (pull, note, reading task, one commit each, push); exits 1 while anything stays queued |
| Repair references or sync annotations | bob ref scan --dry-run, then bob ref scan |
Preview, then process queued PDFs and sync notes/annotations |
| Diagnose prerequisites and library health | bob ref doctor |
Check paths, tools, routing policy, jobs, and index diagnostics |
| Bring old reading records into the index | bob ref migrate-zorg, then --write |
Preview, then create and commit legacy notes; see migration |
When bob ref create records a reference id, the id matches the final output
basename, which is also the reference note stem. Default targets add _2,
_3, and so on when that candidate is occupied by a file or sidecar under
xlib/, lib/, or ref/, or by an id already recorded anywhere under
ref/. Id collisions are compared case-insensitively. With --output, Bob
keeps the exact path and derives the id from its basename; if that id is
already used under ref/, create refuses and asks for a different output
filename. Existing references are left unchanged; use bob ref rename to
repair an existing id/stem mismatch.
bob ref create --dry-run skips installing the final PDF and audio. A local
PDF or Markdown file stays local: Markdown returns before pandoc runs. A PDF
URL is downloaded, and an arXiv target downloads both the metadata and the
PDF, before the dry-run report. A web article, including --html, launches
the browser and loads the page, then skips image localization and the PDF
write. For an offline link preview, use bob capture --dry-run '<URL>'.
The reading queue is derived from notes under ref/. A pending capture job or
an intake PDF under xlib/ does not appear in ref list until a scan creates
its note; successful bob ref create calls file the note inline by default.
bob ref scan -f json reports the same scan as a versioned JSON
envelope (see JSON output); its client
is the Refs panel in Bob Mac Capture, which runs it when Bryan presses βS
there or when Bob Mac Capture observes βS in Highlights after a library PDF
finishes saving.
In Obsidian, Cmd+Enter on a reading task (or a Task Link or managed embed pointing at one) opens its source_pdf in Highlights through bob-navigation-hotkeys, without writing the vault.
Lookup commands require the configured reference directory to exist;
a missing directory is an error rather than an empty library. Directory
overrides are described in the pipeline guide.
Coverage¶
Only notes under <ref-dir>/**/*.md are indexed (default ref/).
Skipped: hidden directories, *.assets/ directories, and conflict
copies. A note that cannot be read or parsed still yields a row with a
diagnostic; nothing is silently dropped.
Absence from this index is never proof of not having read something:
zorg-era status:: reading records outside ref/ are not indexed
until bob ref migrate-zorg --write copies them into ref/zorg/ (bob ref
doctor counts any that remain), and annotations are the snapshot
written by the last Highlights scan, not live PDF state.
Reading state¶
Reading state is derived, never stored. Current-format (v2) reading tasks
are #task #ref lines linking to the reference note; older (v1) trackers
live inside that note and end in ^ref. One open v2 task outside done/
wins; several make Bob fall back to frontmatter. With none, Bob tries the
newest closed v2 task, then in-note v1 trackers, then frontmatter. A selected
v2 task supplies the row parent from its location: the area/project/inbox
stem, or the done/ archive's parent:. The effective status is decided
per note:
- One selected reading task with a known mark maps through the
checkbox marks (
[ ]ready,[*]next,[/]wip,[x]read,[-]abandoned). A single[?]tracker defers to the frontmatterstatus: with an open frontmatter status the tracker agrees (sourceref_task:[?], no diagnostic); with a terminal frontmatter status it targetsready. Against the frontmatterstatus(withunread/donenormalized) and the storedhighlights_marker_base: agreement isok; a tracker move with unchanged frontmatter ispending(the tracker wins); both sides moving isconflict. - No usable tracker (none, several, an unknown mark, or
[?]without a usable frontmatter status): the normalized frontmatterstatus, plus a diagnostic for several trackers or an unknown mark (unknown_ref_marknow covers other custom marks only). - Neither:
status: nullandunknownreading state.
reading_state |
From modern status | From legacy_status |
|---|---|---|
queued |
ready, next |
unread |
started |
wip |
collect_fleeting_notes |
finished |
read |
read, review_fleeting_notes, review_lit_notes |
dropped |
abandoned |
abandoned |
unknown |
none, or conflict |
book, missing, unrecognized |
reading_state_source names the evidence (ref_task:[x],
frontmatter:read, legacy_status:review_lit_notes,
conflict:β¦, none).
blocked is a boolean overlay on the reading lane, always present on
every list, show, and find row: true when the reading task selected
by the rules above is Blocked [?], false otherwise.
The boolean is separate from status and reading_state β a blocked
next row still reads queued in its lane β it only says the note is
waiting on something else. The Obsidian Reading Board reads Blocked from
this field and never parses reading_state_source. Bob Mac Capture's Refs
panel reads the row state, but its current inspector does not yet show the
blocked reasons or task priority/schedule fields.
blocked_by follows blocked on every row. It is always present, and
it is [] when blocked is false or no reason remains on the selected
reading-task line (for example a [scheduled::] date that is today).
Reasons are read from that line only and never run reconcile. They
appear in effective_block_reasons order:
local_dependencylists, in[dependsOn::]order, every id outside inline code that names an open task. Open means some task outsidedone/carries[id:: <id>]and has an open Tasks status type. Closed, archived-only, and unresolved ids are omitted, and the entry itself is omitted when none qualify. Each prerequisite carriesid,title,path,block_id,link,mark, andref_path(the indexed reference path when the prerequisite is itself a reading task, elsenull).local_scheduleappears whenscheduledis strictly after today (env::current_datetime(), soBOB_NOWpins it).projectappears when the line carries the exact#prj_is_blockedtag outside inline code.projectis the reading task's residence note path;linkis that note's wikilink.
"blocked": true,
"blocked_by": [
{
"kind": "local_dependency",
"prerequisites": [
{
"id": "sase__ref-prereq-open",
"title": "Open Prerequisite Paper",
"path": "sase.md",
"block_id": "ref-prereq-open",
"link": "[[sase#^ref-prereq-open]]",
"mark": "*",
"ref_path": "ref/papers/prereq_open.md"
}
]
},
{ "kind": "local_schedule", "date": "2026-10-23" },
{ "kind": "project", "project": "sase_post_blog.md", "link": "[[sase_post_blog]]" }
]
A legacy note whose legacy_status is book and that carries a
non-empty legacy_chapter_statuses list derives its reading state
from its chapters instead: each chapter status maps through the
legacy_status column above (unknown and dropped chapters set
aside), then finished wins when every remaining chapter is finished,
started when any remaining chapter is started or finished, queued
when chapters still remain, dropped when any chapter was dropped,
and unknown otherwise. Its reading_state_source is
legacy_chapters:<finished>/<total>; a book without chapters stays
unknown.
Identity¶
Stored values come from source_url then url (scalars or lists).
HTTP(S) values key on the cleaned dedupe key (already arXiv-aware),
plus arxiv:<id> for arXiv URLs (including 10.48550/arXiv.<id> DOIs)
and doi:<doi> for doi.org URLs. Values that fail validation use
opaque raw: keys and earn an opaque_url diagnostic.
Queries classify in order: URL, arXiv ID (bare or arxiv:-prefixed),
DOI (bare or doi:-prefixed), path (contains / or ends in
.md/.pdf), single token (note stem or frontmatter id), anything
else (title). URL queries derive the same stored-side keys (the dedupe
key plus doi:/arxiv: through the same DOI helpers, so a
percent-encoded DOI URL matches too); bare and doi:-prefixed DOI
queries derive the doi: key plus the arxiv: key for arXiv DOIs, so
one paper's twin notes both match in primary-match order. Title scoring
is Dice over normalized token sets (0-100, normalized equality is 100
with kind title_exact); URL misses fall back to scoring their slug
words as slug_title. At most 5 candidates are kept.
When a PDF-backed note shares an identity key with a note without a
PDF, the PDF-less note is superseded_by the PDF-backed one. find
still reaches superseded notes and prints them as also companions.
Import deduplication and library lookup both index every URL in scalar or
list-valued source_url and url metadata. This applies to ref notes and
queued intake markers, so any URL in a collection can prevent a duplicate
import and can be found with ref find -i.
For a multi-URL bob ref create, the marker and ref note store
source_url as an ordered list in the order the URLs were supplied. The
collection yields one PDF, one note, and one reading task inline by default.
Every listed URL resolves to that same reference.
Usage¶
bob ref find https://arxiv.org/abs/1706.03762
printf '%s\n' URL1 URL2 | bob ref find - -f json
bob ref find 1706.03762 -i
Options: -b/--bob-dir, -f/--format human|json|markdown (default
human), -i/--include-intake, -m/--min-score 1-100 (default 60),
-r/--ref-dir, -x/--xlib-dir. Queries run in order with duplicates
kept; - reads one query per line from stdin.
Verdicts per query: in_library (an exact match), in_intake (only a
queued intake PDF matched, with -i), possible (only title
candidates at or above --min-score), not_found (nothing matched).
A title match is only ever a candidate, never proof; not found means
only "not under ref/".
bob ref list¶
bob ref list
bob ref list -R finished -S 30d -g
bob ref list -o external -R finished -f json
bob ref list -s legacy -R queued
Options: -A/--all, -b/--bob-dir, -f/--format human|json|markdown
(default human), -g/--git-dates, -n/--limit N (default 50),
-o/--origin external|agent-report, -P/--parent NOTE,
-R/--reading-state (comma-separated, or all), -r/--ref-dir,
-S/--since DATE, -s/--status (comma-separated),
-t/--ref-type (comma-separated). -A conflicts with -n.
With no filter option at all, list shows the reading queue (queued
and started notes) in every format. Any filter option searches every
reading state unless -R narrows it. Values within one option are ORed;
different options are ANDed. Superseded notes are always excluded and
counted in hidden.superseded (find and show still reach them).
-P/--parent NOTE resolves through area/project routes and
project_name_aliases (bob-cli matches bob); any other name matches
the frontmatter parent literally (ASCII case-insensitive, never an
error). filters.parent echoes the canonical route when the input
resolved, else the normalized input.
The row date is finished for the finished and dropped states, added
otherwise. --since accepts YYYY-MM-DD or <N>d|w|m|y relative to
today (calendar months); rows without a date are excluded and counted in
filters.undated_excluded. Rows order by reading state (started,
queued, finished, dropped, unknown), modern before legacy, next before
ready within queued, row date newest first with undated last, then
title. --limit applies after filtering and ordering.
The added date uses this precedence: frontmatter captured, the date
part of frontmatter created, a zorg source_block date, then (with
-g) the Git birth date. added_source is captured, created,
zorg_block, or git. added_at is the most precise instant known on
that added day: a full offset-bearing created value supplies it when
its own-offset date matches added, including when captured wins the
date precedence. Date-only created values, mismatched dates, and zorg
dates have no added_at.
-g/--git-dates uses a rename-following file-birth pass to fill missing
added and added_at for every modern row, including v2 rows. A separate
^ref tracker pass fills missing finished dates for v1 rows only; it
never backfills a v2 row's finished. If Git is missing, the vault is not
a repo, or either pass fails, coverage.git_dates is "unavailable"
with one stderr warning; this is never fatal. Git birth follows file
renames and reports the oldest commit that added the note.
Human output groups rows under one heading per reading state with its
count (pre-cap totals), with the status chip, row date (or β), title,
β« when audio is bound, type, an optional dim blocked reason, and a dim
path. The path drops first on a narrow terminal, then the reason, then
titles truncate. A blocked row's reason is one of π€ until YYYY-MM-DD,
β after <title> (or +N more), π§± <project stem> blocked, or
βΈ blocked when no reason remains; several reasons join with Β·.
A pending-sync * (dimmed) gets a footnote. Without -s, legacy-era
rows collapse to one dim summary line,
+ N zorg-era legacy notes hidden (β¦) Β· show them with -s legacy, instead
of listing. Any -s value turns that collapse off and also filters to the
named statuses, so -s legacy is the view that lists those notes. JSON and
Markdown include the matching rows either way. The cap applies to the rows
that are listed;
truncation prints β¦ N more Β· -n N or -A to show more. An empty default
view prints Nothing queued β. After just install on apollo,
bob ref list -s ready,next,wip -A -f json measured 0.32 s warm on the
live vault (five runs 0.30β0.35 s with the vault cache warm; 0.78 s
cold).
Markdown output is a table with the columns State | Status | Date |
Title | Type | Note (six header cells with a six-cell separator row),
followed by a blank line and the matched/returned and coverage lines.
The JSON envelope carries filters (the effective reading_state plus
reading_state_defaulted, every active filter, the limit, and
undated_excluded), matched, returned, truncated, and
hidden.superseded alongside the shared coverage and library
counts. Every row includes marker_id, the frontmatter id, or null
when that id is absent. Each task object also carries priority
(highest | high | medium | low | lowest, else null) and scheduled
(a strict calendar date, past or future, else null), both read from
the selected reading-task line. The Obsidian Reading Board
consumes blocked_by, task.priority, and task.scheduled to draw these
rows as lanes. Bob Mac Capture's Refs panel currently ignores those keys;
inspector support is tracked separately in bob-cli-9f.
Reading Board in Obsidian¶
dash_references opens on a read-only kanban of the reading queue. It is a
```bob-reading-board fence that Bob Ledger Tools draws from
bob ref list -s ready,next,wip -A -f json (plus -P and -t when set), so
the board, the terminal, and Bob Mac Capture agree on what can be read now.
The board never writes: acting on a reference stays with bob ref today and
the ^ref task keymaps.
The fence body is optional YAML:
parent: <note>passes-P, so the board shows one area or project's queue (routes andproject_name_aliasesapply). The block works on any note, for example a project note withparent: sase.type: <type or list>passes-t.- Unknown keys and bad values become one muted lint line each, never an error.
Open Reading Board (open-reading-board) opens dash_references.
Lanes¶
Lanes come from each row's status and blocked fields; only modern rows
with status wip, next, or ready appear.
| Lane | Rows |
|---|---|
| π Reading | wip, not blocked |
| β Next | next, not blocked |
| π₯ Ready | ready, not blocked |
| π€ Snoozed | blocked, any status |
The header reads N awake Β· M snoozed (awake is Reading + Next + Ready), the
data time, and a β» refresh button. An empty board says Nothing queued β.
The four lanes sit in a row on wide panes, two by two at readable line width,
and in one column below that.
Awake cards show the title, a kind and topic line (kind from the refs.base
category vocabulary, topic from parent), the added date, β« for audio, β N
for annotations, and the task priority mark. A TODAY pill appears only when
the reading task is linked under today's Pomodoros and the Today cache is
ready. A dim * marks a pending frontmatter sync. Cards linked today sort
first; otherwise the order is bob ref list order.
Snoozed reasons¶
Snoozed rows are compact and grouped, in this order:
- Waking:
blocked_byis empty; the next reconcile returns the reference to its lane (β° now). - This week, This month, Later: the only reason is a schedule, sorted by wake date (the same buckets as the note snapshot's Scheduled lane).
- Waiting on: everything else, with a second line
β after <first prerequisite>(+Nfor more) orπ§± in blocked project <stem>. Hovering a row outlines the prerequisite card when it is on the board.
Rows whose underlying status is not ready show where they return
(β β Next, β π Reading). The lane header toggles collapse for the
session. These are the same reasons the human bob ref list output prints.
Keys and states¶
| Gesture | Effect |
|---|---|
| Click / Enter | Open the reference note |
| β/Ctrl-click, middle-click | Open it in a new tab |
| β/Ctrl+Enter, PDF button | Open the PDF in Highlights (new tab for the note without a PDF or off macOS) |
| β₯/Alt+Enter, Task button | Go to the reading task |
| β/β, β/β (h/l, k/j), Home/End | Move focus across and within lanes |
β» or r |
Refresh now |
The first paint shows skeleton lanes. Background revalidation is silent and
repaints only when the rows change. If bob ref list fails, the last frame
stays with a muted "Showing the board from HH:MM" line and Retry; with no
frame the board shows an actionable error. When bob is not found, set
stats.bob_path in ~/.config/bob/config.yml, as for the Stats page.
Under an older bob without blocked_by, blocked rows sit in Waiting on as
βΈ blocked with one hint to update bob-cli. The board needs the desktop app.
bob ref show¶
bob ref show ea_graph
bob ref show ea_graph -c
bob ref show ref/papers/ea_graph.md -f json
Options: -b/--bob-dir, -c/--comments-only, -f/--format
human|json|markdown (default human), -N/--no-annotations, -r/--ref-dir.
-c conflicts with -N.
Each REF is a vault path, note stem or id, URL, arXiv ID, DOI, or
exact title. Resolution is exact (the exact match kinds plus a unique
title_exact): several matches collapse to the one note that is not
superseded, with the superseded companions listed under also (a dim
also: <path> (superseded legacy note) line in human output); any other
multi-match fails as ambiguous reference and names its candidates (it
never picks the first stem); a miss fails as no reference note matches
<REF> with up to three title candidates as hint: lines. Every REF
resolves before anything prints, so one failure exits 1 and prints
nothing else.
Each shown row extends the base index row with the note's content:
The base row's marker_id is the note's frontmatter id, or null when
it is absent.
annotations_status:parsed,absent(no managed region, as on legacy notes), orunparsed(withraw_region).annotations: one entry per live block βpage_label,kind(highlight,note, orimage),quote,comment,asset,images,unresolved_images,pieces,block_id,link([[ref/x#^h-β¦]]), andsection(the sectionid, omitted for ungrouped blocks). Quote and comment stay in separate fields; standalone notes carry their text ascomment.imagesis the list of vault-relative image-note paths (without any|widthhint);unresolved_imagesis the list of source texts from warning callouts. Both arrays are always present.piecesis present only on<groups: one entry per tucked piece, head first, each with its ownpage_label,quote,comment,images, andunresolved_images. The top-levelquote/comment/images/unresolved_imageskeep the flattened group view (piece quotes joined with a blank line), so current consumers still see every word. A note with unresolved image notes carries the row diagnosticunresolved_image_note. Annotations follow region order, so sectioned blocks come first.sections: live annotation sections when the managed region has at least one[!summary|section]card. Each entry hasid,title,tag,block_id,link([[ref/x#^s-β¦]]),count, and optionaldescription,pages, andimages(omitted when empty).countandpagesare computed from the parsed member blocks, never by parsing the card caption. The array is omitted when the region has no sections.excluded: counts ofmarker_mirrors,preamble, andremoved(tombstoned) blocks. Tombstones carry no text, so they are counted instead of shown.own_notes: the user's own text (a legacy note's whole migrated body).tasks: the parsed## Taskslines (checked,mark,text,block_id, pluspathonly on follow-ups found elsewhere).also: superseded companion paths.
-c/--comments-only keeps only annotations with a comment, standalone
notes, or image notes (embedded or unresolved), with their quotes. A <
group is kept when any piece has a comment or image note, and all of its
pieces are shown.
-N/--no-annotations keeps
metadata and notes only. The annotations array honors the flags; -N
also omits sections. -c keeps the full sections list, because like
the status and excluded counts it describes the whole region.
Human output prints the title, a reading-state header (chip, date,
type, origin, path), the reading-task line, the metadata rows that have
a value, then the annotations grouped by page with wrapped quotes
(ββ¦β), [image: <vault path>] lines for image notes,
yellow [image not found: <source>] (or unreadable / unsupported)
lines, and cyan β³ comments, the exclusion parenthetical, and the
NOTES and TASKS sections. When the region has sections, annotations
group under magenta β headers (βMemory @memory Β· 2 highlights Β·
Pages 4β5): the bar is magenta, the title bold, the caption dim; the
description is dim, wrapped, and every line carries the bar. A blank
line separates groups, and the page-run rule resets per group. Empty
scaffolds print @tag Β· nothing tagged yet. Ungrouped blocks follow
under Other highlights Β· N. With -c, a section header prints only
when one of its members is kept. Unsectioned rows print exactly as
today. A < group prints each piece's wrapped
quote, image lines, and cyan β³ comment in order, with each follower
preceded by a dim seam line at the quote indent (β Page 12, or a bare
β without a label). Single annotations print exactly as before. The reading-task line is π Reading task
<residence> Β· <lane> β <link> for open v2, π Read/Dropped Β· <date> Β·
<residence> [(archived)] for closed v2, π Reading task in this note
(v1) Β· <lane> for v1, and π Reading task β N open tasks claim this
ref Β· run bob ref doctor for ambiguity. Each TASKS row carries its
linked annotation's page label as a dim suffix, or Β§ <Title> when
the task's block_id is a section's, plus a dim Β· <path>
for follow-ups found elsewhere. Empty sections stay omitted; several
REFs are separated by a dim rule.
Paired **β¦** runs in highlight quotes render as bold underline (ANSI SGR
1;4) in color-enabled human output. Plain human output, Markdown, and JSON
retain the literal ** markers. The emphasis stays paired across wrapped
lines; unmatched markers remain literal. See
Underline emphasis for how PDF
underlines become these quote runs.
Markdown output is a quotable digest per note: ## <title>, bullets for
the note link, reading state with evidence and date, a - **Reading
task:** β¦ bullet with the same text as the human line, source URL and
arXiv/DOI, origin and type, annotation counts and snapshot date (plus
Research report: research:β¦ when set, and Also: β¦ companions), then
### Annotations (one **Page N** label per page, quotes as >
blockquotes, image notes as Image: <vault path> or
Image not found: <source> lines, comments as Comment: β¦ lines,
standalone notes as Note: β¦), ### Notes, and ### Tasks (keeping each task's real
mark, so a [-] task stays [-]). When the region has sections, each
section is a #### <Title> with an italic caption
(*@memory Β· 2 highlights Β· Pages 4β5*), the description as > lines,
members with today's **Page N** captions (reset per group), and
_Nothing tagged yet._ for empty scaffolds; ungrouped blocks follow
under #### Other highlights. Unsectioned digests stay byte-identical.
A < group prints each piece as its
own > quote with its own image and Comment: lines, with each follower
preceded by an italic *β Page 12* seam line.
Agent usage: resolve candidates with bob ref find first, then read
exact notes with bob ref show <path>. Prefer -c -f markdown for
Bryan's own thoughts on a reference, and never claim a not_found
result means he has not read something β it only means it is not under
ref/.
bob ref today¶
bob ref today ea_graph
bob ref today ea_graph -p reading
bob ref today ea_graph -d -f json
Options: -b/--bob-dir, -d/--dry-run, -e/--exact (requires -p),
-f/--format human|json (default human), -p/--pomodoro NAME,
-P/--pomodoro-ref LINE:DIGEST, -r/--ref-dir. -p conflicts with
-P.
REF uses the exact resolver from bob ref show: note path, stem,
frontmatter id, URL, arXiv ID, DOI, or a unique exact title. The
command then locates the reference's v2 reading task and runs the same
planner as bob capture '^route:block-id[#name]' (see Linking and
starting existing tasks).
It never starts a Pomodoro and never commits or syncs; vault-sync picks
the change up, as with capture. The tracker change reads
status_sync: pending until the next ref scan -w (the Refs panel's
βS), exactly as an Obsidian Ctrl+Shift+Enter on a ref task does today.
| Flags | Destination |
|---|---|
| none | Capture's no-name rule. If the task already has a dedicated live link under an open Pomodoro (Q), it stays there (already_current). Otherwise the implicit current/next entry: the single open timed entry, else the first open entry |
-p NAME |
Capture's #name rule: whole-slug match among open entries, then prefix match, else create the canonical named future Pomodoro. Q's subtree moves |
-p NAME -e |
Whole-slug match among open entries only, else create. Never a prefix match |
-P LINE:DIGEST |
Exactly that entry, resolved stale-safe through the ref values the dry run lists. A completed entry gives pomodoro_closed; stale or ambiguous gives stale_pomodoro |
Human output prints β linked π <title> (or would link on a dry
run), then capture's pomodoro-link details. --dry-run also lists
open Pomodoros (β running, β default, β linked) with -P
LINE:DIGEST so unnamed entries can be targeted. An unnamed row prints
its derived homes label (from homes) instead of (untitled) when its
chip is non-null.
JSON is schema 1 with command: "ref today". A success envelope
carries mode (dry_run or write), changed, ref, task,
pomodoros (every open top-level entry, with running / default /
linked flags), and capture (the same item object bob capture -f
json reports for ^route:block-id). Each unnamed pomodoros row also
carries the derived R9 homes object (forecast: open rows never carry
minutes); named rows do no resolution and omit it. A guarded gesture is
ok: true with capture.kind == "link_guard": it is a real write that
promotes prerequisites, as in capture.
{"ok":true,"schema_version":1,"command":"ref today","mode":"dry_run","changed":true,"ref":{"path":"ref/papers/attention.md","link":"[[ref/papers/attention]]","title":"Attention Is All You Need","marker_id":"attention"},"task":{"path":"sase.md","block_id":"ref-attention","link":"[[sase#^ref-attention]]","status_symbol":" ","status_name":"Ready"},"pomodoros":[{"ref":"41:0b1c2d3e","line":41,"name":"BUGS","running":true,"default":true,"linked":false}],"capture":{"kind":"pomodoro_link"}}
Failures print
{"ok":false,"schema_version":1,"command":"ref today","error":{"code","message","hint"}}
and add candidates for resolution failures, plus ref, task, and
pomodoros whenever resolution got that far. Exit 1 for failures,
exit 2 for usage (including invalid_pomodoro_name).
| Code | When |
|---|---|
unknown_reference |
REF matches nothing |
ambiguous_reference |
REF matches several notes |
no_reading_task |
The reference has no reading task |
ambiguous_reading_task |
Several open reading tasks claim the reference |
v1_reading_task |
The task is an in-note v1 ^ref tracker |
reading_task_closed |
The task is archived, read, or abandoned |
reading_task_unroutable |
The task's residence is not a root note |
no_daily_note |
Today's daily note does not exist |
no_pomodoros_section |
The daily note has no Pomodoros section |
no_open_pomodoro |
There is no open entry and no selector |
multiple_running |
Implicit selection or named creation with several open timed entries |
invalid_pomodoro_name |
bob's Pomodoro name usage text |
stale_pomodoro |
The -P ref no longer matches an entry |
pomodoro_closed |
The -P ref names a completed entry |
ledger_invariant |
For example, several movable links |
note_changed |
A pre-image mismatch at write time; retry |
io |
Any other I/O failure |
bob ref rename¶
bob ref rename <REF> <NAME> -d
REF uses the exact resolver from bob ref show: note path, stem,
frontmatter id, URL, arXiv ID, DOI, or a unique exact title. NAME is a
same-directory file stem matching [A-Za-z0-9][A-Za-z0-9_.-]*; one trailing
.pdf is ignored. For example, the motivating gist can be previewed with:
bob ref rename 442a6bf555914893e9891c11519de94f karpathy_llm_wiki -d
The dry-run inventories the note, PDF, sidecar, audio, and inbound links, then
reports the frontmatter paths that would change. It does not write files, take
locks, sync, or commit. Live mode applies the same plan as one scoped commit.
The JSON form (-d -f json) uses schema version 1 and includes from, to,
id, moves, links, frontmatter, kept.task, and the current ref row;
id is {from, to, changed} with nullable ids. commit is null and both
sync values are skipped in a dry run.
V1 full renames accept only modern, PDF-backed, unannotated references in their
current ref/<type>/ directory. They refuse legacy and intake references,
annotated references, and notes whose PDF sync plan is not clean. A rename
changes the file stem for the note, PDF, and any same-stem sidecar or audio
companions; it updates inbound vault links and the note's source_pdf,
highlights_sidecar, and audio paths. The frontmatter id and PDF marker id
follow the new stem. If another reference already records that id, Bob picks
the first free <stem>_2, <stem>_3, and so on; the bare stem is checked
against other ids, and suffixed ids are checked against other ids and stems.
Id-less references stay id-less. An id change rewrites the PDF marker, so close
the PDF in Highlights first. The title, source_url, dates, and reading task
block ID stay. Same-stem bob ref rename <REF> <current-stem> repairs a
mismatched id, including on annotated references, and reports
outcome: "id_updated"; an already-correct id returns unchanged. After an
id change, bob ref find <old id> no longer resolves. Links in browser
bookmarks and copied obsidian:// or highlights:// URIs are outside the
vault and cannot be updated.
Machine-readable refusal codes are unknown_reference,
ambiguous_reference, legacy_reference, intake_reference, invalid_name,
name_taken, has_highlights, listen_card_link, not_in_sync,
dirty_paths, not_a_worktree, lock_busy, scan_busy, sync_failed, and
verify_failed. JSON errors use the shared schema-version-1 envelope with an
error object containing code, message, and optional hint.
Live write flow¶
The live command takes the shared bob_sync.lock first (60 second wait), then
the reference scan writer lock (30 second wait). It requires a Git worktree so
the rename can be reverted. Unless --offline is set, it runs vault-sync while
holding both locks and replans from the synced files. Offline runs refuse when
any source, destination, or rewritten path is dirty.
The writer snapshots every Markdown file it will rewrite, moves the bundle with
no-clobber semantics, and atomically writes the planned link and frontmatter
changes. When the id changes, it then runs the sync engine's bob ref sync -w
write path inside the transaction to update the PDF marker and refreshed sync
metadata. It verifies that a dry-run bob ref sync on the new PDF plans no
work, and that the rebuilt index and PDF marker both have the planned id while
the reading-task (path, block_id) stays the same, with no duplicate identity
or orphaned task introduced. A write or verification failure moves files back,
restores the saved Markdown before-images, and atomically restores the original
PDF bytes unless the PDF changed concurrently.
On success, Bob commits only the old and new bundle paths and rewritten notes
with subject bob ref rename: <old> β <new>. The JSON commit object contains
the commit sha and subject. Unless --offline is set, Bob then runs
vault-sync again. If that post-sync fails, the local rename commit stays valid:
the command exits successfully, reports sync.post: "failed", and tells you to
run bob vault-sync. --offline skips both sync cycles and commits locally.
A rerun using the old stem or note path succeeds with
outcome: "already_renamed" when the latest commit touching the new note has
the matching rename subject. Passing the current stem as both REF and NAME
returns outcome: "unchanged" when the id is allocated correctly. If the id
needs repair, the same-stem run commits the id-only update with subject
bob ref rename: <stem> (id <old> β <new>) and reports no file moves or link
rewrites.
Renaming while the PDF is open in Highlights¶
Acceptance (Mac, 2026-10-15): a scratch-vault script renamed a PDF while it was
open in Highlights. Result: PASS β Highlights did not recreate the old PDF or
note path, so the confirm fallback never triggers. The motivating live rename
(442a6bf555914893e9891c11519de94f β karpathy_llm_wiki) then committed
cleanly in the live vault with pre/post sync OK; verification confirmed the old
paths absent, the new paths present, and a follow-up dry run unchanged with the
frontmatter id and reading-task ^ref preserved. Recorded from
bob-cli-7x.6.
Rollback runbook¶
Each rename is one Git commit. To undo it, revert that commit and publish the revert through vault-sync:
git -C ~/bob log --format=%h --grep='^bob ref rename' -1
bob vault-sync status --json
git -C ~/bob revert --no-edit <sha>
bob vault-sync
Migrating zorg-era records (bob ref migrate-zorg)¶
bob ref migrate-zorg
bob ref migrate-zorg -f json
bob ref migrate-zorg --write --offline
Options: -b/--bob-dir, -f/--format human|json (default
human), -o/--offline, -r/--ref-dir, -w/--write. The bare
command is a read-only dry run: no lock, no sync, no writes. It
plans one legacy note per unmirrored zorg-era status:: record and
prints the plan as a human or JSON report. With -w/--write the
same plan is applied as one scoped commit between two vault-sync
cycles. A rerun afterwards reports nothing to migrate.
The write flow, mirroring bob task reroll:
- Take
bob_sync.lock(60 s budget) and require a git worktree: reversibility depends on git. - Pre-sync, unless
--offline, so the migration commit holds only the new files. A failed pre-sync aborts with an--offlinehint. - Re-plan from disk under the lock. An empty plan prints
nothing to migrateand exits 0 without committing or post-syncing. - Refuse before writing anything when a planned target already
exists, or when
ref/zorg/holds uncommitted changes. - Write each note with create-new semantics (temp file, fsync, no-clobber rename, byte-compare re-read).
- Verify through the rebuilt index and coverage: every planned path
yields a row with no
invalid_yaml,opaque_url, ormissing_typediagnostic, each row carries its planned reading state, and coverage drops by exactlynotes + chapters. Any failure deletes every file the run created (plus its emptied directories) and exits 1 without committing. - Commit exactly the written paths as
bob ref migrate-zorg: <records> records into <notes> notes under ref/zorg, then post-sync unless--offline.
The JSON report carries mode: "write" with the created commit
(sha, subject, paths); the human report ends with the
committed <sha> <subject> line.
Rollback runbook¶
The migration is one commit, so one git revert undoes it:
git -C ~/bob log --format=%h --grep='^bob ref migrate-zorg' -1
bob vault-sync status --json # check that no sync is running
git -C ~/bob revert --no-edit <sha>
bob vault-sync
If the revert hits conflicts because migrated notes were edited
since, resolve them by hand: keep the pre-migration side for every
ref/zorg/ path to restore the unmigrated tree.
Every parsed record lands in exactly one bucket: already_migrated
(an indexed ref note carries the same source_path plus the same
source_block with a matching source_id, or the block in its
source_blocks), skipped (no_owner, no_id, or
unassigned_chapter), chapter (folded into a book note), or note.
- Layout. Each note goes to
<ref-dir>/zorg/<hub>/<stem>.md, where the hub is the source file's stem. The stem is the record'sID::value unless that stem already names a Markdown file anywhere in the vault (including_generated/andref/) or another note planned in the same run; collisions become<ID>_ref,<ID>_ref_2, and so on, planned in source-path then owner-line order. The report lists every rename and the file it avoided. - Note shape. Frontmatter in fixed key order (
parent,type,tags,ref_type,status,legacy_status,title,url,source_note,source_block,source_id,source_path,source_line_start,source_line_end, plussource_blocksandlegacy_chapter_statuseson books). Tags arezorg/referenceplus the#tagson the owner line.ref_typeis thelib/<kind>/of the firstfile::wikilink, elsezorg(chatnotes read asagent-reportorigin). The title is the record'stitle::field when present, else the humanized ID (words split on_/-and capitalized, with ai, api, cli, gtd, html, http, json, llm, lsp, mcp, pdf, prd, sase, sql, ui, url, ux, and yaml uppercased;bobrenders asBob). The body carries## Files,## Related,## Chapters(books), and an escaped## Original Recordfence. The original source records are never edited or deleted. No^reftracker is written, so these notes do not become actionable tracker tasks. Theirlegacy_status(or chapter states) still determines their reading state.unreadbecomesqueuedandcollect_fleeting_notesbecomesstarted, so those notes match the default reading-queue filter. Humanliststill hides them behind+ N zorg-era legacy notes hidden (β¦) Β· show them with -s legacyuntil you pass-s. Any-svalue turns the collapse off and filters to that status. JSON and Markdown include the matching rows either way. - Books. A source file holding exactly one BOOK record is a book;
its
LID::records (plus anID::record tied by a| BOOK:line) fold into the book's note as## Chapterslines with their blocks appended to the fence. The book's reading state derives from its chapters. AnLID::record with no owning book is skipped asunassigned_chapter. - URLs. The first token of the inline
url::value plus the first token of each nested item under it; only values that pass URL validation are stored (one scalar, a list for several, omitted for none), so no migrated note earns anopaque_urldiagnostic. URL, arXiv, and DOI matches against existing notes are reported as identity hits, and each record still migrates as its own note.
The human report prints a headline (N records in F files β M notes
under ref/zorg/), counts by status and by file, books with chapter
counts and derived states, renamed stems, notes without a URL,
identity hits, the already-migrated count, skipped records with
reasons, and coverage after --write: K unindexed. The JSON
envelope carries the same data with mode: "dry_run" and
commit: null; each note entry shows its path, title, ref_type,
provenance, reading_state (computed by running the rendered note
through the index row builder), chapter count, and rename.
Migrating open ref tasks (bob ref migrate-tasks)¶
bob ref migrate-tasks
bob ref migrate-tasks -f tsv
bob ref migrate-tasks -m map.tsv --write --offline
Options: -b/--bob-dir, -f/--format human|json|tsv (default
human), -m/--map FILE, -o/--offline, -r/--ref-dir,
-w/--write. The bare command is a read-only dry run: no lock, no
sync, no writes. It plans every open v1 ref task's move into its
parent note and prints the plan as a human, JSON, or TSV report.
With -w/--write the same plan is applied as one revertible commit
between two vault-sync cycles. A rerun afterwards reports
nothing to migrate.
Parents resolve in order: the -m map row, else the note's
frontmatter parent wikilink, else the PDF marker parent. An
unmapped ref is listed and --write refuses the whole run before
any write, as does any note with two or more open trackers.
What is preserved and dropped: the mark stays, [fresh::],
[refresh::], [keeps::], priority, schedule, close fields,
[id::], [dependsOn::], and every other tag stay; whole-token
#hide and the PDF wikilink are dropped. The new line carries
#task #ref, a path-qualified [[ref/<type>/<stem>|<title>]] link,
[created::YYYY-MM-DD] when missing, and ^ref-<slug> at the end.
The write flow, mirroring bob task reroll:
- Take
bob_sync.lock(60 s budget) and require a git worktree. - Pre-sync, unless
--offline. - Re-plan from disk under the lock. An empty plan prints
nothing to migrateand exits 0 without committing or post-syncing. - Refuse before any write when any ref is unmapped, any note has
multiple open trackers, or any changing path differs from
HEAD. - Snapshot every changing path, write sequentially (reading task, follow-ups, ref-note edit, graph rewrite), and verify through the rebuilt index.
- Commit exactly the written paths as
bob ref migrate-tasks: <N> ref tasks into <M> notes, then post-sync unless--offline.
The JSON report carries mode: "write" with the created commit
(sha, subject, paths); -f tsv prints
ref_note<TAB>parent<TAB>status<TAB>title for map round trips.
Rollback runbook¶
The migration is one commit, so one git revert undoes it:
git -C ~/bob log --format=%h --grep='^bob ref migrate-tasks' -1
bob vault-sync status --json # check that no sync is running
git -C ~/bob revert --no-edit <sha>
bob vault-sync
JSON envelope¶
REF_SCHEMA_VERSION is 1. Compact one-line JSON on stdout, no ANSI:
{"ok":true,"schema_version":1,"command":"ref find","generated_at":"2026-10-06T12:00:00",
"coverage":{"ref_dir":"ref","notes":32,"skipped":3,"intake":"not_checked",
"scope":"Only notes under ref/ are indexed. β¦",
"annotations":"Annotations are the snapshot written by the last Highlights scan, not live PDF state."},
"library":{"notes":31,"finished":10,"started":2,"queued":12,"dropped":3,"unknown":4},
"summary":{"queries":1,"in_library":1,"finished":1,"in_intake":0,"possible":0,"not_found":0},
"results":[{"query":"https://example.com/blog/capture-flows","query_kind":"url",
"keys":["https://example.com/blog/capture-flows"],
"verdict":"in_library","reading_state":"finished",
"matches":[{"match_kind":"identity","matched_key":"https://example.com/blog/capture-flows","ref":{}}],
"candidates":[],
"intake":[]}]}
coverage.intake is not_checked, checked, or unavailable (no
intake directory). library counts exclude superseded notes.
generated_at is local YYYY-MM-DDTHH:MM:SS (pin with BOB_NOW).
Each ref object is the full index row: path, link, title, origin,
marker_id (the frontmatter id, or null when absent), ref_type, era,
status, status_sync, legacy_status,
reading_state and its source, blocked, blocked_by, parent, task, urls, identity
keys, author/published/captured, added date/source/instant and finished dates/sources,
source_pdf, audio, annotation and comment counts, snapshot,
research_ref, superseded_by, and diagnostics. task is always present
(null when there is no task): v2 carries path (the residence file),
block_id, link ([[path#^id]] or [[path]]), mark,
archived, priority, and scheduled; v1 carries the ref note path,
block_id: "ref", link: [[ref/β¦#^ref]], archived: false, and the
same priority/scheduled fields from the tracker line. One
(path, block_id) join works for both eras. blocked, blocked_by,
task, marker_id, and added_at are additive under schema_version 1.
Diagnostics: multiple_open_ref_tasks (several open v2 claim the ref;
detail lists path:line), open_ref_task_in_done (open task under
done/, report-only), open_ref_without_task (v2 note with a
ready/next/wip status but no task), orphan_ref_task (a #ref line
resolving to no note), ref_task_outside_area (live task outside an
area/project/inbox), parent_mismatch (frontmatter parent differs from
residence), and open_v1_tracker (open in-note ^ref; run bob ref
migrate-tasks).
Errors and exit codes¶
Exit 0 whenever the lookup ran, including not_found; exit 1 for
vault failures (a missing ref dir) with
bob ref: error: reference directory not found: <path> plus the hint
pass -b/--bob-dir or -r/--ref-dir, or set BOB_DIR; exit 2 for usage
errors. In JSON mode, failures print
{"ok":false,"schema_version":1,"command":"ref find","error":{"code":"β¦","message":"β¦","hint":"β¦"}}
to stdout and exit 1. A missing intake directory under -i is not a
failure: coverage reports unavailable and the lookup continues.
show and today resolution failures (ambiguous_reference,
unknown_reference) exit 1 as well; in JSON mode they add
"candidates":[{"path":β¦,"title":β¦}] to the envelope. today uses
the same envelope with command: "ref today" and the error codes in
bob ref today.
URL routing¶
Bare public links captured with bob capture, shared to Google Keep
and pulled with bob gkeep pull, or previewed with bob capture-parse
are classified as references instead of inbox tasks. Capture saves a
background job and prints β reading queue before any note exists.
Keep clips an intake PDF during pull. bob ref scan writes the note
that bob ref list shows. capture-parse only classifies. The
classifier, policy, and offline verdict below are shared by every entry
point; nothing in this section fetches the URL.
A token is a reference URL only when it has no whitespace, carries an
optional single <β¦> wrapper, uses http/https, passes
validate_and_clean, and has a host that is not an IP literal and
contains at least one . with non-empty labels. Corporate short links
(http://go/x), localhost, and IP literals stay tasks.
Policy lives under highlights.url_routing in ~/.config/bob/config.yml
(BOB_CONFIG_FILE overrides the file; XDG_CONFIG_HOME overrides its base):
highlights:
url_routing:
capture: true # bare-URL capture items queue a background reference job
gkeep: true # URL-only Keep notes are clipped during bob gkeep pull
exclude_hosts: # replaces the defaults; each entry matches the host and its subdomains
[google.com, googleplex.com, youtube.com, youtu.be, github.com, x.com, twitter.com]
Missing keys take the defaults shown. A configured exclude_hosts
list replaces the defaults; an empty list excludes nothing. Entries
are normalized (lowercased, with any scheme, leading www., trailing
., trailing /, path, or port stripped). A missing config file uses
the defaults. An invalid url_routing block turns routing off: bob
capture, bob gkeep list, and bob gkeep pull warn with
URL routing is off: β¦, and bob capture-parse stays silent. In every
case the bare URL stays a task. capture: false and gkeep: false turn
off that entry point with no warning. bob ref doctor prints the
effective policy as url routing: capture on Β· gkeep on Β· excludes
google.com, β¦, or url routing: warn (off: β¦) when the file is invalid.
bob capture --no-ref '<URL>' and bob gkeep pull --no-ref bypass routing
for one invocation. Capture also keeps an item as a task when it has extra
prose, markers, child lines, a global destination, or forced destination flags.
bob capture-parse reads this policy from the config file. It does not open
the vault or fetch the URL. -R, capture: false, and an invalid config
all keep its links classified as tasks.
Capture queues links for background clipping through durable ref jobs; a
failed clip falls back to exactly the inbox task capture would have written,
plus a β οΈ bullet. Google Keep imports clip inline during pull: retryable
failures stay in Keep, while permanent failures become tasks with a warning.
See ref jobs and Keep URL imports.