Highlights Reference Note Sync¶
bob ref creates Highlights-ready PDFs from Markdown and turns
Highlights app PDF annotations into Obsidian reference notes in the Bob vault.
By default, create files a new PDF into lib/, writes its reference note and
reading task, and commits and pushes those paths together. xlib/ is the
fallback queue when intake is kept or cannot safely run; scan drains that
queue and repairs notes or annotations. Marker write-back to a PDF is a
separate opt-in (sync --write-pdf or scan --write-pdfs).
Code lives in this bob-cli repository. On the MacBook, use a checkout at
~/projects/bob-cli; do not install from ad hoc scripts outside that checkout.
Contents¶
- What it does
- MacBook validation notes
- Default paths
- Marker note grammar
- Required parent, type, and ref type
- Synced properties
- PDFs open in Highlights
- Scan, safety, and Git/ob behavior
- Generated body contract
- Image notes
- Continuation highlights (
<) - Underline emphasis
- Sections (
#and@) - Reading tasks in parent notes (v2)
- MacBook setup guide
- Scheduled scan
- Disable one PDF
- Backup and rollback
- Conflict resolution
- Expected failures
What it does¶
bob ref implements marker/frontmatter synchronization, Markdown/TextBundle
sidecar parsing, generated note rendering, TextBundle image selection asset
copying, image notes (a screenshot path pasted into a highlight comment),
continuation highlights (a < note groups a highlight with the one before it),
recursive library scan with xlib intake, prerequisite checks, output
collision detection, dirty-target refusal, and atomic note writes.
sync <pdf> loads one PDF with native Rust code, treats the first standalone
/Text annotation on page 1 as the marker note, parses the marker list,
creates or updates ref/<ref_type>/<pdf-basename>.md for PDFs under
lib/<ref_type>/, and rewrites only the managed Highlights body region from the
sidecar when one is present. Top-level library PDFs and explicit out-of-library
syncs keep the legacy ref/<pdf-basename>.md target. marker <pdf> inspects
the same marker without writing.
scan --intake-only (-i) is the drain for the xlib/ queue: it files only
the queued PDFs and late audio and leaves the annotation pass, and the
pre-scan hook, to the full scan; see Intake-only
scan.
scan runs the configured pre-scan hook on writing runs, then moves pending
PDFs from the configured intake directory into the mirrored library path
together with same-stem companion audio (.mp3, .m4a, .ogg, .opus),
recursively finds PDFs under the configured library directory, and processes
them in stable path order. New references file one ordinary reading task in
their area, project, or inbox note while the reference note shows a managed
embed; see Reading tasks in parent notes
(v2). Notes that still carry an in-note
^ref tracker keep the legacy behavior described below. Pass -n, --no-hooks on scan, or before the
subcommand as bob ref --no-hooks scan, to ignore the hook. Only
scan and doctor honor that flag. A parent --no-hooks before create,
marker, or sync is accepted and ignored. Per-PDF
validation or write failures are reported without stopping unrelated PDFs; the
final command status is still non-zero when any PDF fails. It refuses intake
destinations that already exist and duplicate output paths such as two PDFs
that would both write the same ref/<ref_type>/<basename>.md target.
doctor checks vault paths, library/ref/xlib directories, pending intake,
the configured pre-scan hook, sidecar presence, marker readability, PDF write
coordination, Git worktree status, the Markdown render tools (pandoc, xelatex,
LaTeX packages), and optional ob availability. The pdf_write_coordination:
row is on or off on macOS (a warning when off) and
n/a (no OS file coordination) elsewhere. Companion audio sitting in
xlib/ with no matching PDF in xlib/ or lib/ is a warning, not a
failure. The warning-level xelatex and latex_packages rows report the
Markdown render prerequisites: a missing xelatex warns that PDF creation is
unavailable, and missing LaTeX packages warn with the tlmgr install line
that provides them. It never writes files. Pass
-n, --no-hooks on doctor, or before the subcommand as
bob ref --no-hooks doctor, to skip the pre-scan hook check.
The xlib_pending: N row gains · oldest <age> (from file mtimes) while
anything is queued, and doctor warns once the oldest queued PDF is over an
hour old (xlib intake: N queued, oldest <age> — run bob ref scan -i …).
xlib_pending: 0 is unchanged. The intake_last: row reads the per-host
intake status record at bob_cli_state_dir()/ref/intake-status.json
(schema_version, last_success {at, command, filed}, and
last_failure {at, command, reason, message}) and prints
ok <age> ago (<command>, <n> filed) and failure <age> ago (<reason>:
<message>), newest first, or none recorded. bob ref create (and the
producers that call it), bob ref scan -i, and every writing full bob ref
scan update it; a queue reason counts as a failure, a dry run or an empty
queue records nothing, and a failure to write the record only warns.
After the sync-prerequisite rows, doctor appends warning-level library
health and coverage rows read through the read-only reference index. They
never fail the command:
library: ok (…)totals the indexed notes by reading state.library diagnosticsrolls up row diagnostics by code with up to 3 example paths (marker_mirror_excludedand everyREF_TASK_DIAGNOSTIC_CODEScode are skipped here; they have their own rows below).ref tasksrolls up reading tasks (N live · M archived · K open v1, plus warn-level per-code counts in code order with up to 3 example paths;open_v1_trackeris count-only until migration).parentsreports parent notes and aliases, or warns with the structuralproject_name_aliasesproblems.identityreports legacy notes superseded by a newer capture, or warns about identity keys shared by more than one PDF-backed note.annotationswarns while notes still render a leaked marker mirror; the nextbob ref scanremoves those blocks without tombstones.coveragecounts zorg-erastatus::reading records in*.mdfiles outsideref/(hidden directories,_generated/, and*.assets/excluded), minus records already mirrored into the library. A record is mirrored when some ref note carries the samesource_pathand either the samesource_blockwith a matchingsource_id(notes without asource_idstill match on block plus path), or the record's block in itssource_blocks(one book note mirrors several folded chapters). The warn form names the top 5 files by count.
Available commands:
bob ref create <TARGET>... [-A|--author NAME] [-a|--audio PATH] [-b|--bob-dir PATH] [-C|--closed] [-d|--dry-run] [-f|--force] [-H|--html FILE] [-i|--include-id] [-k|--keep-intake] [-l|--lib-dir PATH] [-L|--listen] [-M|--plain] [-N|--name STEM] [-n|--no-audio] [-o|--output PDF] [-P|--parent NOTE] [-p|--published DATE] [-r|--ref-dir PATH] [-S|--section HEADING]... [-s|--status STATUS] [-T|--title TITLE] [-t|--ref-type DIR] [-x|--xlib-dir PATH]
bob ref doctor [-b|--bob-dir PATH] [-l|--lib-dir PATH] [-n|--no-hooks] [-r|--ref-dir PATH] [-x|--xlib-dir PATH]
bob ref marker <pdf> [-b|--bob-dir PATH] [-l|--lib-dir PATH] [-r|--ref-dir PATH] [-x|--xlib-dir PATH]
bob ref scan [-b|--bob-dir PATH] [-d|--dry-run] [-f|--format human|json] [-j|--jobs N] [-l|--lib-dir PATH] [-n|--no-hooks] [-r|--ref-dir PATH] [-v|--verbose] [-w|--write-pdfs] [-x|--xlib-dir PATH]
bob ref sync <pdf> [-b|--bob-dir PATH] [-d|--dry-run] [-l|--lib-dir PATH] [-p|--prefer marker|frontmatter] [-r|--ref-dir PATH] [-w|--write-pdf] [-x|--xlib-dir PATH]
create captures a web article URL into xlib/blogs/<slug>.pdf through the
pinned web-clip adapter and stamps the same page-1 marker plus the web
provenance fields (source_url, author, published, captured); the
full contract lives in highlights-clip.md.
create accepts a vault note, a Markdown file, a local PDF, a PDF URL, an arXiv
paper URL, or a web article URL and stages <xlib-dir>/<ref-type>/<stem>.pdf before
inline intake files it to lib/ (by default
~/bob/xlib/notes/<note-stem>_<YYMMDD>.pdf for vault notes,
~/bob/xlib/chat/<basename>.pdf for other Markdown, and
~/bob/xlib/papers/<stem>.pdf for PDFs) unless -o, --output <PDF>
selects the complete output path. The full target contract lives in
highlights-create.md. --output keeps the
supplied filename and requires a nonempty name with a case-insensitive
.pdf extension. Relative output values are resolved from the current
working directory; a leading ~ is expanded the same way as other Bob path
options. --output cannot be combined with --ref-type or --name,
because those only participate in default target derivation.
When --output is omitted, the default intake target walks <stem>,
<stem>_2, … (see highlights-create.md): a name
taken by a different reference is suffixed, and the next scan moves the
final intake PDF to <lib-dir>/<ref-type>/<basename>.pdf. An exact
--output path keeps today's strict behavior, refusing to create when that
archived library PDF or sidecar already exists. An exact path inside the configured
intake directory keeps the same intake workflow: the mirrored library
destination is <lib-dir>/<intake-relative-path>, including any nested
directories, and that occupied library PDF or Markdown/TextBundle sidecar is
still refused even with --force. An exact path already inside the configured
library treats the selected PDF as the library destination: an existing file
requires --force, and there is no second mirrored-destination check. An exact
path outside both managed directories does not invent or check an unrelated
library destination.
For ordinary Markdown, it uses the frontmatter title, first
H1, or file stem in that order, asks pandoc/xelatex for a hyperlinked
three-level table of contents and PDF outline bookmarks, and embeds a page-1
/Text annotation containing:
- status: ready
- parent: sase
- title: <document title>
The marker parent is deliberately bare; generated Obsidian frontmatter turns
it into a wikilink later. Vault notes infer an eligible area/project parent
from the note itself or its parent: links; other targets require
-P, --parent. -s, --status and -t, --ref-type override defaults.
-d, --dry-run prints the plan with writes: none.
An explicit -P resolves before rendering, fetching, or writing through
the shared area/project/inbox resolver; the marker stores the canonical
route. It accepts sase, sase.md, and [[sase]]
forms (whitespace trimmed, case-insensitive); an exact stem wins, then
project_name_aliases (see
docs/projects.md §"External names (project_name_aliases)").
The dry run reports the resolution, for example
parent bob (project · bob.md · via alias bob-cli). An unknown name
fails with near-miss and alias hints; a terminal project (project 'x' is
done) and a non-area/project hub (obsidian_ref.md is not an area or
project note) report themselves. When no parent can be inferred, a missing
-P fails with a bob capture-targets hint. For --output paths, an existing
target PDF requires -f, --force; a same-stem Markdown file beside it is always
refused because Highlights would interpret it as a sidecar. Default targets
instead suffix to <stem>_2, … when taken by a different reference. Dry-run and success output show a
mirrored library_destination only for intake targets. Intake and direct
library targets recommend bob ref scan; a PDF outside both managed
directories states that recursive scan will not discover it and recommends
bob ref sync <PDF>. BOB_PANDOC_COMMAND
overrides the pandoc executable, and a render failure includes pandoc's
diagnostic output plus a hint: line for missing LaTeX packages (or a missing
xelatex).
-i, --include-id opts into a stable marker ID derived from the Markdown
filename. Bob canonicalizes the Markdown source, removes only the final .md
extension from the filename, requires the resulting stem to be nonempty valid
UTF-8, and embeds it as id:
- status: ready
- parent: sase
- title: <document title>
- id: xprompt_role_binding
The option is intended for research hook execution, for example:
bob ref create --include-id -P sase 202608/xprompt_role_binding/xprompt_role_binding.md
If the filename stem is invalid for marker output, the command fails before
running pandoc or creating output directories. Without --include-id, manual
create calls keep the previous marker shape and do not add id.
Listen cards in PDFs¶
Markdown authors can wrap a short audio summary in <div class="listen"> to
render it as a light-blue callout in the Highlights PDF. A leading ♫ becomes
a ▶ Play button when companion audio is bound and the target is in the Bob
intake or library. Intake links point to the companion's final lib/
location. Relative links inside the card, such as the narration-script link,
are omitted from the PDF because the source file is not available there.
Without a companion, the card remains with its ♫ glyph.
create now binds audio from the sase-listen library, not only from a file
that was already beside the PDF, and create --listen
narrates the target fresh instead of discovering anything. Discovery order,
first hit wins (skipped entirely under --listen, whose episode binds as
<stem>.mp3 and reports (from --listen)):
--audio PATH(missing files and non-audio extensions are errors;--no-audioconflicts with--audioand disables discovery).- Frontmatter
audio.episode_idresolved through<library>/<id>/manifest.json(audio.filemust be a bare filename present in that episode directory; an unresolvable id warns and falls through). - Sibling
<stem>_narration.md(stripping a trailing__final), hashed with SHA-256 and matched againstsource.sha256orscript.sha256in<library>/*/manifest.json(newestcreated_atwins).
The library root is BOB_HIGHLIGHTS_AUDIO_LIBRARY, then
highlights.audio_library in bob/config.yml, then
$XDG_DATA_HOME/sase-listen/library, then
~/.local/share/sase-listen/library:
highlights:
audio_library: "/data/sase-listen/library"
# `bob ref create --listen` narrates the
# target with `sase listen render ...`, which must write MP3 audio to {audio}.
# The managed setup uses the SASE plugin (`sase listen`); a direct
# `sase-listen render ...` setting migrates automatically.
# Overridden by BOB_HIGHLIGHTS_LISTEN_COMMAND. See
# docs/highlights-create.md for the full contract.
listen_command: sase listen render {target} -e full -o {audio}
The copy lands beside the PDF target with the source extension lowercased
(xlib/chat/<stem>.mp3), planned before any write: identical bytes are
reused, different bytes refuse without --force, and an existing mirrored
lib/<type>/<stem>.<ext> refuses because scan would refuse to move over it.
The PDF is rendered to a temp file, the audio is copied atomically (temp file
in the destination directory, then rename), and only then is the PDF stamped
and installed; a failed install deletes an audio file this run created and
never a reused file. Success and dry-run output show
audio: <dest> (from <origin>) after the pdf: line, or audio: none;
--include-id gains no required flag. Backfill an already-scanned PDF by
copying its MP3 into xlib/<type>/<stem>.<ext> for scan to late-pair; do not
rerun create --force on an annotated library PDF.
The URI template is selected from BOB_HIGHLIGHTS_AUDIO_LINK_TEMPLATE, then
highlights.audio_link_template in bob/config.yml, then the default:
highlights:
audio_link_template: "obsidian://open?vault={vault}&file={path}"
{vault} is the percent-encoded Bob directory name and {path} is the
percent-encoded vault-relative companion path. Set the template to an empty
string to disable the button. Only the generated PDF contains the Obsidian URI;
the report Markdown stays portable.
Companion audio on scan¶
scan moves same-stem companion audio with its PDF from xlib/<rel> to
lib/<rel>. Allowed extensions are .mp3, .m4a, .ogg, and .opus,
checked in that order when choosing which file to record on the note.
When an audio file is waiting in xlib/ with no same-stem PDF still in
intake, but lib/<rel>.pdf already exists, scan late-pairs it: the audio
moves beside that library PDF and the existing reference note gains the
player. Audio with no PDF in xlib/ or lib/ stays in intake for the next
tick; bob ref doctor reports it as a warning. Destination collisions
join the existing pre-write intake conflict report, so scan writes nothing.
The note records a bob-managed audio field from the companion's presence,
never from a marker key:
audio: "[[lib/chat/research_swarm_listen_card.mp3]]"
New notes place the Obsidian player once, directly below the generated PDF task:
- [ ] #task #ref [[lib/chat/research_swarm_listen_card.pdf]] #hide ^ref
![[lib/chat/research_swarm_listen_card.mp3]]
## Highlights
Existing notes get the same embed on the first scan that observes a companion
while the note still lacks audio. After that field exists, scan never
touches the embed again, so a deleted player stays deleted. The managed
Highlights region is unchanged. Dry-run reports the planned frontmatter and
embed like other note changes.
Backfill an already-read report by copying the MP3 to
~/bob/xlib/<ref-type>/<stem>.mp3; the next scan attaches it. Do not rerun
create --force to replace a library PDF that already has Highlights
annotations. The vault already tracks *.mp3 (about 2 MB per brief edition).
Path configuration options are -b, --bob-dir <PATH>, -l, --lib-dir <PATH>,
-r, --ref-dir <PATH>, and -x, --xlib-dir <PATH>. scan also accepts
-j, --jobs <N> and -v, --verbose (the detailed per-PDF plan report; the
default scan report is concise).
sync <pdf> --dry-run prints the resolved configuration and planned note/PDF
actions without modifying either side. Without --dry-run, the command writes
the reference note when frontmatter changes. It only writes the PDF marker when
the selected projection needs marker write-back and --write-pdf is supplied.
For recursive scans, --write-pdfs is the bulk opt-in for marker write-back;
scan --dry-run --write-pdfs remains read-only and previews the same marker
updates.
MacBook validation notes¶
Linux-side tests cover synthetic PDF markers, sidecar fixtures, conflict detection, generated highlight rendering, recursive scan preflights, and dirty target refusal. Validate the following against real Highlights-authored files on the MacBook before enabling PDF marker write-back:
- Real Highlights sidecar Markdown may vary from the fixture-backed parser contract. Keep the documented Highlights Note Format settings fixed while testing.
- PDF marker write-back is implemented with native PDF annotation writes, but
real Highlights-authored files may expose annotation shapes not covered by
Linux fixtures. Use
--write-pdfor--write-pdfsonly after backing up the target PDFs. - Scheduled
scanshould stay dry-run or note-only until the guided acceptance test passes andhighlights.coordinate_pdf_writesis on (see PDFs open in Highlights). Until then, bulk PDF marker writes need an explicit backup and a watched terminal run. - Confirm a real underline renders as underlined bold inside its host quote in
bob ref show; verify SGR1;4in a color terminal and literal**in plain output. Enableref-highlightsin Obsidian and check the gold underline in both light and dark themes.
Default Paths¶
The Bob vault root is BOB_DIR, defaulting to ~/bob.
PDFs are discovered under the Highlights library directory:
BOB_HIGHLIGHTS_LIB_DIR=lib
Freshly created PDFs are written under the Highlights intake directory:
BOB_HIGHLIGHTS_XLIB_DIR=xlib
Reference notes are written under:
BOB_HIGHLIGHTS_REF_DIR=ref
Create-time intake is enabled by default. Use -k, --keep-intake to leave a
new PDF in xlib/ for a later bob ref scan -i or local timer, or set:
highlights:
ingest_on_create: false
BOB_HIGHLIGHTS_INGEST_ON_CREATE overrides that setting. 0, false, no,
and off disable inline intake; 1, true, yes, and on enable it.
Blank means unset. The environment value is case-insensitive.
Writing scans can run a pre-scan hook before xlib intake:
highlights:
# Runs from the vault root before `bob ref scan` inspects the library.
# Use it for custom work that must happen before a full scan.
pre_scan_hook: my_custom_pre_scan
BOB_HIGHLIGHTS_PRE_SCAN_HOOK overrides the config file value. Set it to an
empty value to disable a configured hook. The hook runs as sh -c <command>
from BOB_DIR and inherits stdout/stderr, so scheduled logs include its output.
Bob exports BOB_HIGHLIGHTS_IN_PRE_SCAN_HOOK=1 to the hook child. If it exits
non-zero, scan reports the exit code and aborts before intake. A writing scan
holds bob_sync.lock before the hook runs, so a hook that itself runs
bob vault-sync is a silent no-op: vault-sync skips when that lock is already
held.
scan --dry-run reports the hook it would run without executing it. Pass
-n, --no-hooks to scan or doctor, or place it on the parent command
before that subcommand, to ignore the hook from every source. The same
parent flag before create, marker, or sync is accepted and ignored.
The legacy highlights.pre_scan_command key and
BOB_HIGHLIGHTS_PRE_SCAN_COMMAND variable are hard errors.
Relative BOB_HIGHLIGHTS_LIB_DIR, BOB_HIGHLIGHTS_XLIB_DIR, and
BOB_HIGHLIGHTS_REF_DIR values are resolved under BOB_DIR. Absolute paths
and ~/... paths are used directly after tilde expansion. lib and xlib
must be distinct, non-nested directories so intake cannot move PDFs inside the
tree being scanned.
During scan, every pending intake PDF maps from xlib/<rel> to lib/<rel>.
For example:
~/bob/xlib/chat/report.pdf
~/bob/lib/chat/report.pdf
~/bob/ref/chat/report.md
For ~/bob/lib/books/systems-performance.pdf, the default output note is:
~/bob/ref/books/systems-performance.md
The first path component below the library directory is the reference type. For the example above, generated frontmatter includes:
ref_type: books
Deeper paths preserve the full library-relative path in ref/ while keeping the
first component as ref_type:
~/bob/lib/books/performance/systems-performance.pdf
~/bob/ref/books/performance/systems-performance.md
Top-level library PDFs remain supported for existing files and do not derive a
ref_type:
~/bob/lib/systems-performance.pdf
~/bob/ref/systems-performance.md
Marker Note Grammar¶
The marker note is the first standalone /Text PDF note annotation on page 1.
It is not identified by a sentinel token, and standalone notes on later pages
are not marker candidates.
The marker note is an unordered list of key: value pairs:
- status: ready
- parent: obsidian
- title: Systems Performance
- id: systems-performance
- aliases: ["Systems Performance", "Brendan Gregg systems performance"]
- topics: [linux, performance]
- source_url: https://example.com/book
- source_note: zk/harbor_cranes
Allowed list markers:
- key: value
* key: value
status and parent are required marker keys. Marker parent must be a bare
note target such as obsidian or Systems Performance; Obsidian wikilinks such
as [[obsidian]], aliases, embeds, and block links are rejected in the PDF
marker. Generated reference notes still render parent frontmatter as an
Obsidian wikilink, for example parent: "[[obsidian]]".
source_note, when present, is a bare vault-relative note route without a
.md suffix. It is provenance for a snapshot made from a vault note; marker
sync stores it as a canonical frontmatter wikilink such as
source_note: "[[zk/harbor_cranes]]". Marker input rejects wikilinks, aliases,
embeds, and fragments for this field.
status must be a scalar string and must exactly match one of:
readynextwipreadabandonedlegacy
Existing status: unread and status: done marker/frontmatter/base inputs are
accepted only as deprecated aliases and are normalized to ready and read,
respectively, during sync.
Values should be parsed as a YAML-compatible subset when possible:
- strings
- numbers
- booleans
- nulls
- inline lists
Invalid values fall back to strings when that is unambiguous. Duplicate marker
keys, invalid marker list items, pipeline-owned marker keys, command-managed
marker keys such as type or ref_type, and missing or empty status or
parent produce clear errors. Unsupported status values are also rejected.
Required Parent, Type, and Ref Type¶
New Markdown notes under ~/bob require parent frontmatter. The PDF marker
is authoritative for parent, so the marker must include it.
Generated reference notes also always include command-managed frontmatter:
type: "[[ref]]"
Nested library PDFs also include path-derived frontmatter:
ref_type: books
type and ref_type are replaced on each successful sync, excluded from
marker/frontmatter round-trip hashes, and rejected if either appears in the PDF
marker note. ref_type is omitted for top-level library PDFs and explicit
out-of-library syncs because there is no library category to derive.
Synced Properties¶
The PDF marker note and the reference note frontmatter share a synced user-property projection.
Pipeline/provenance fields are excluded from marker sync:
source_pdf
source_pdf_sha256
highlights_sidecar
highlights_count
highlights_synced_at
highlights_marker_base
highlights_marker_replaced_hash
highlights_marker_hash
highlights_marker_fields
pipeline_version
The command-managed type, ref_type, and audio fields are also excluded
from marker sync. type is always rendered as [[ref]]; ref_type is
rendered only when the PDF path is under lib/<ref_type>/. audio is
rendered as a quoted wikilink to the library companion
("[[lib/<type>/<stem>.mp3]]") when a same-stem audio file sits beside the
PDF, and omitted otherwise. It never enters the marker projection, hash, or
base.
Standard synced user fields are status, parent, title, id, research,
aliases, topics, source_url, source_note, author, published,
captured, and return_links. source_note is the canonical source vault
route for note snapshots. id is ordinary
user frontmatter for the basename-derived source identifier and does not
require highlights_marker_fields. Existing research values remain legacy
standard fields for repository-relative Markdown source paths; they round-trip
without being renamed automatically to id. captured is the snapshot date
(YYYY-MM-DD) of a web capture; like the other provenance fields it is
omitted rather than written wrong. return_links is stamped true by
bob ref create when a Markdown render actually pairs return links, so
bob ref sync knows to keep their tag glyphs and return pills out of synced
highlight text; it never needs highlights_marker_fields.
Every new reference note also records a note-local created field stamped
from the writing invocation's clock (BOB_NOW overrides it for testing):
created: 2026-10-03T15:42:18-0400
The datetime format is YYYY-MM-DDTHH:mm:ss±ZZZZ with seconds and a numeric
timezone offset. The field is set once at creation — including sidecar-free
notes and notes created after xlib/ intake — and later syncs preserve the
authored value verbatim. It never joins the synced projection, marker hash,
base snapshot, or highlights_marker_fields (even a stale opt-in list naming
it is ignored), and setting it never requires --write-pdf / --write-pdfs
or modifies PDF bytes. It differs from captured (the YYYY-MM-DD
web-capture snapshot date synced from the marker) and from
highlights_synced_at (refreshed whenever sidecar highlights rerender).
Notes created before this field existed gain none on sync; their historical
creation times are not backfilled. A PDF marker containing created is
rejected with a diagnostic explaining that it records reference-note creation
time, before any note or PDF write.
Unknown marker keys should round-trip into frontmatter. New frontmatter keys
should sync back to the marker only when they are standard supported fields or
explicitly listed in highlights_marker_fields.
legacy_status is ordinary frontmatter used to preserve a migrated note's old
status value. It is preserved when rendering notes, but it is not a standard
marker-synced field and will not be written back to the PDF marker unless a
marker explicitly opts into it as an unknown synced field.
Implemented conflict policy:
- If only the marker changed, update frontmatter when any status edit agrees with the visible lifecycle task.
- If only frontmatter changed, update the PDF marker note when PDF writes are enabled and any status edit agrees with the visible lifecycle task.
- Treat the generated PDF task line as the reference-reading status:
[ ]meansready,[*]meansnext,[/]meanswip,[x]/[X]meansread, and[-]meansabandoned.[?]means Blocked: it agrees with any open selected status without contributing, and reopens a terminal selected status toready(to finish a blocked reference, check the^reftask[x]). - If that task status differs from the selected marker/frontmatter projection, apply it as a note-side status signal. If marker or frontmatter also changed status to a different value from the stored base, report a conflict instead of guessing.
- If marker and frontmatter changed different fields from the stored base,
auto-merge them. PDF marker writes are still opt-in with targeted
sync --write-pdfor bulkscan --write-pdfs. - If both changed the same field differently, fail without modifying either side unless
--prefer markeror--prefer frontmatteris supplied.
The last synced user-property projection is stored as highlights_marker_hash
and as compact JSON in highlights_marker_base. After a marker write, the note
also stores highlights_marker_replaced_hash: the parent-free projection hash
of the marker that write overwrote. A later scan that finds the PDF marker
equal to that replaced copy treats it as a stale editor save (see
Stale saves). The field is dropped on a later note write that
observes the PDF marker matching highlights_marker_hash. A missing field
(legacy notes) disables the guard. The hash keeps old-note compatibility; the
base snapshot lets the command prove safe field-level merges.
--prefer frontmatter, any generated-task lifecycle change, and any
auto-merge that includes frontmatter changes require --write-pdf for targeted
sync, or --write-pdfs for recursive scan, whenever the PDF marker must be
updated.
PDF marker writes are performed by saving a temporary PDF next to the original and renaming it over the target. Before first PDF writes, commit or otherwise back up the PDF library. See PDFs open in Highlights for how marker writes behave when a PDF is open.
PDFs open in Highlights¶
When highlights.coordinate_pdf_writes is on, bob ref scan --write-pdfs and
bob ref sync --write-pdf wrap each PDF whose plan needs a marker write in one
bounded macOS file-coordination write (NSFileCoordinator, .forMerging).
Highlights saves unsaved annotations first, bob re-checks the marker in those
fresh bytes, writes on top of them, and Highlights reloads the same tab in
place. Create-time ingest uses the same executor.
The key defaults to off until a guided acceptance test on the Mac passes. On Linux the key is accepted and inert: there is no OS coordination, and the reading probe always reports not reading unless an internal override is set.
Holds¶
If anything is uncertain, bob holds that PDF: nothing is written for it, the scan exits 0, and the next scan retries. A hold is not a failure.
| Reason | When | Message |
|---|---|---|
editor_active |
Unattended scan; Highlights frontmost; last input less than 30 s ago | waiting while you read in Highlights · retrying next scan |
coordination_timeout |
Access not granted within 10 s, or the scan's 30 s cap is spent | Highlights didn't answer within 10 s (a dialog may be open) · retrying next scan (cap variant: Highlights didn't answer this scan (30 s spent) · retrying next scan) |
marker_changed |
Fresh bytes carry a marker other than the planned one | marker changed in Highlights during the scan · retrying next scan |
Concise scan output prints a yellow held line and a N held summary count.
Verbose scan (-v) prints pdf_write_coordination: on and a reading_hold
line when the key is on, then pdf_marker_action: updated (coordinated in 0.42
s) or held (<reason>; retrying next scan). bob ref sync uses the same
phrases and still exits 0 on a hold.
-a, --attended¶
bob ref scan -a, --attended means a person asked for this scan right now:
never hold marker writes because Highlights is in use. It changes nothing else.
Bob Refs ⌘S and Bob Mac Capture's scan after ⌘S in Highlights pass it.
bob ref sync and create-time ingest never hold for reading. The scheduled
cron scan does not pass -a.
Budgets and overrides¶
Defaults are 10 s per PDF, a 30 s scan-wide cap, and a 30 s reading window. Once the cap is spent, remaining marker-write PDFs hold immediately. Internal overrides, for tests and the acceptance kit:
BOB_REF_PDF_WRITE_BUDGET_SECONDSBOB_REF_PDF_SCAN_BUDGET_SECONDSBOB_REF_READING_WINDOW_SECONDSBOB_REF_READING_PROBE=reading|awayBOB_REF_FRONTMOST_BUNDLE_IDandBOB_REF_HID_IDLE_SECONDS
Unknown probe results do not hold. Dry runs never coordinate and never probe.
Enabling¶
The key ships off and stays off until the guided acceptance test below passes on the Mac. To enable afterwards, set exactly this in the Mac config file:
highlights:
coordinate_pdf_writes: true
Until then, an uncoordinated write that races Highlights ends in the
"The file has been changed by another application" dialog (see
Expected failures): prefer Save Anyway so your
annotations are kept. The next -w scan restores the marker; do not edit
status: by hand.
Stale saves¶
A PDF marker that matches the copy bob's latest marker write replaced is a
stale editor save, not a status edit. The next -w scan keeps the note and
the reading-task lane and restores the marker. Concise scan output adds the
dim detail marker restored after a stale save. Verbose sync_reason (and
ref sync) appends marker reverted to the copy bob replaced (stale editor
save); restoring it. JSON notes[].marker_write.restored is true only for
that restore.
One limit: deliberately setting the marker back to the exact copy bob replaced is undone. Change status through the reading task instead.
Acceptance kit¶
scripts/dev/highlights_coordination_acceptance is the guided runbook.
It creates a throwaway vault and config under $TMPDIR (never ~/bob
or the real config), asks you to pick a small library PDF and then a
~20 MB one to copy, and walks the research's nine steps: uncoordinated
control, the main save-first case, frontmost and background tabs, a
half-typed popover, a modal sheet, a detached SSH/cron run, large-PDF
timing, sidecar import, and marker re-location after renumbering. Each
step prints what to do in Highlights, runs bob ref sync --write-pdf
or bob ref scan --write-pdfs with verbose detail, and records
pass/fail in results.md. The closing verdict prints the enable line
above when steps 2, 3, 5, and 6 pass; it keeps the key off and flags
Plan B if annotations are lost or a dialog appears.
--self-test runs the non-GUI plumbing anywhere without Highlights
(scratch vault, coordinated write, receipt, hold, -a, dry-run rule)
and is wired into just check-scripts.
Mac notification¶
Every writing bob ref scan that holds the writer lock already drops the
scan receipt. Bob Mac Capture watches the matching
Darwin notification, including scans started after ⌘S in Highlights, and
renders it: one quiet, threaded "Updated in
Highlights" notification when Bob Refs is hidden, a footer clause when
it is visible, and a "Highlights needs you" banner only for
coordination_timeout holds. The kit's last step confirms the
notification fires for a scratch scan. Keep Bob Mac Capture's
Settings › References toggle on (the default) to receive it.
Scan, Safety, and Git/ob Behavior¶
Create-time intake holds bob_sync.lock before scan.lock, matching the
reference writer lock order. It pulls first, rechecks the target against the
fresh tree, moves one PDF and its same-stem companions, writes the note and
reading task, commits only the paths for that reference, and pushes before
releasing the locks. A PDF created directly in lib/ skips the pull so a
pre-sync cannot commit that PDF without its note. When the lock, worktree,
remote, destination, or write guard prevents intake, create keeps the PDF in
xlib/ and reports a queue reason; the command still succeeds so a later
scan can drain it.
scan --dry-run reports every discovered PDF, including PDFs that are still
pending under xlib. If a pre-scan hook is configured, the dry run reports that
it would run and the exact hook command, but does not execute it. The default
report is concise. -v, --verbose prints the detailed per-PDF plan, including
each planned xlib/<rel> -> lib/<rel> intake move. Dry runs do not move
anything. Valid PDFs show their target reference note, sidecar path if present,
selected sync source, and note/PDF marker action. Invalid PDFs show a
plan_error. Scan output also reports write_pdfs: true|false so bulk
marker-write runs are auditable. Dry runs do not create directories, move
intake files, write notes, write PDFs, or run the pre-scan hook, even when
combined with --write-pdfs.
Before planning notes, writing scans run the optional pre-scan hook and then
preflight intake. Pass -n, --no-hooks to skip the hook. Intake moves each PDF under xlib to the mirrored lib path
and moves same-stem Markdown sidecars, TextBundle directories, and companion
audio with the PDF, so annotation text, image assets, and the listen-card
player are not orphaned. Late-pair audio (intake audio whose PDF is already in
lib/) moves on the same preflight. If any destination PDF, sidecar, or
audio file already exists, the whole scan aborts before moving or writing anything.
Intake runs before the dirty-target Git check because a newly rendered intake
PDF is normally untracked, and the move never overwrites an existing path; a
tracked synced PDF behaves as an ordinary Git rename.
After intake, the command rejects duplicate output paths, builds per-PDF plans,
and checks Git status for existing vault files that successfully planned PDFs
would modify. If a target ref note or PDF marker target is dirty, it fails
before any note/PDF write, except for a tracked target ref note whose only body
change is the exact generated ^ref checkbox toggle, optionally combined with
frontmatter edits. There is no force mode for scan; commit, stash, or clean
unrelated dirty files before rerunning.
Image assets copied from TextBundle sidecars are treated as note-side writes. They are planned during dry runs, checked by the same dirty-target preflight when the destination already exists, and written only during non-dry-run syncs. Scan output includes image totals when a planned PDF contains image selections.
Planning failures for one PDF do not stop valid plans from writing. Write-time
failures such as a changed note/PDF or temporary save failure are reported as
write_failure entries, and later valid PDFs continue. The scan summary reports
plan_failures, write_failures, and scan_failures; any non-zero failure
count makes the command exit non-zero even when other PDFs were processed.
Reference note writes and image asset copies are atomic temporary-file renames and are skipped when the rendered note or content-addressed asset is byte-identical to the existing file.
For PDFs under xlib, bob ref create runs vault-sync before intake and
again after its scoped commit while holding bob_sync.lock. A PDF created
directly under lib/ skips the pull so vault-sync cannot commit it without
its note. Writing scans hold the same maintenance lock through intake and
note writes, but do not sync themselves. bob nightly runs vault-sync
before and after its maintenance commands. Every sync rewrites the
source_pdf field, so a direct bob ref sync ~/bob/xlib/... dry run or
one-off sync self-heals to the library-relative path on the next scan after
intake.
Intake-only scan¶
bob ref scan -i (--intake-only) drains the local xlib/ queue. A create
that kept intake (-k, the config switch, or the environment switch) or could
not run it (lock busy, no pull, collision, and the other queue reasons) leaves
its PDF there; scan -i files it later with the same session create uses.
It is meant for a per-host timer: each host drains its own xlib/, and the
full bob ref scan stays the annotation sync and repair pass.
bob ref scan -i # file everything queued on this host
bob ref scan -i -d # preview: what would be filed or stay queued
bob ref scan -i -f json # the same drain as one JSON line
What it does, in order:
- Validates the library layout.
- Lists the queue without taking any lock: every
xlib/PDF (with its same-stem Markdown, TextBundle, and audio companions), then everyxlib/audio file whose library PDF already exists. Audio with no PDF anywhere is not drainable and is ignored. An empty queue printsintake: nothing queued, takes no lock, runs no sync, and exits 0, so an idle timer costs nothing. - With
--dry-run, printswould ingest: xlib/… → lib/… → ref/…(orwould attach audio:) per item andwould queue:for items the local checks already refuse, then exits 0. It takes no lock and does not pull, so a collision that only the pulled tree reveals still appears at drain time. - Otherwise opens one session (
bob_sync.lock, thenscan.lock) and pulls once. Failing to take a lock, to find a Git worktree, to matchBOB_DIR, or to pull is a hard error: exit 1, nothing moved. - Files each item in sorted order with its own commit, subject
bob ref scan: ingest <type>/<stem>(orbob ref scan: attach audio <type>/<stem>for late audio). Planning or write refusals roll the move back. An item that cannot be filed printsqueued: <xlib path> (<reason>: <detail>), stays inxlib/, and the drain continues. An item that stops after writing (incomplete:) ends the drain without a push, because the uncommitted writes are not safe to publish; background vault-sync publishes the commits already made. - Pushes once at the end (
sync: pushed, orsync: push pending (…)), then printsintake: filed N · queued M.
The exit code is 0 when everything was filed and 1 while any item stays queued or stopped, so a stuck item fails its timer run visibly. A library PDF that has no note is never touched; only a full scan repairs that.
--intake-only ignores highlights.ingest_on_create and its environment
switch, because running the drain is an explicit request. It skips the
pre-scan hook, so JSON reports hook.status: "skipped". It conflicts with
-w/--write-pdfs (usage error, exit 2): new references never write PDF bytes.
Typical order: bob ref create files the reference itself; bob ref scan -i
drains whatever create had to queue; the full bob ref scan is the
annotation sync (and repair) pass for PDFs that already live in lib/.
JSON output¶
bob ref scan -f json (equivalently --format json) prints the same scan as
a versioned machine-readable report instead of the human display lines:
bob ref scan -w -f json
Stdout carries exactly one compact JSON line followed by a newline, and
nothing else: no intake lines, no per-PDF lines, no summary line, and no ANSI
escapes. The pre_scan_hook: run … status line goes to stderr, and the hook
child's stdout is redirected to bob's stderr, so a chatty hook cannot corrupt
the report. --format json with -v/--verbose is a usage error (exit 2).
JSON mode works with --dry-run too and with --intake-only, which reports
filed notes in notes, completed PDF moves in intake, and anything still
queued in queued.
A successful or partially failed scan prints this envelope (field order is
fixed: ok, schema_version, command, generated_at, mode,
write_pdfs, hook, intake, summary, notes, failures,
intake_only, queued, held):
| Field | Meaning |
|---|---|
ok |
true exactly when failures is empty and there is no error. With --intake-only, a writing drain is also false when queued is nonempty; a dry run ignores queued |
schema_version |
1; clients reject any other version |
command |
"ref scan" |
generated_at |
Local-time stamp, pinned by BOB_NOW in tests |
mode |
"write" or "dry_run" |
write_pdfs |
Whether -w was given |
hook.status |
"ran", "would_run" (dry run), "skipped" (--no-hooks or an empty override), or "none" (not configured) |
hook.command |
The hook command, or null when none is configured |
intake |
PDF intake moves as vault-relative from/to pairs in move order; sidecar and audio moves are not listed |
summary |
pdfs, created, updated, unchanged, markers, tasks, failures, held: the same counts the human summary line prints (held is always last) |
notes |
One entry per PDF whose reference note was created or updated (or would be, in a dry run), in scan order |
notes[].action |
"create" or "update"; marker-only changes are counted in summary.markers and not listed |
notes[].path |
Vault-relative note path, byte-identical to the path that bob ref list -f json reports for the same note |
notes[].title |
The note title the scan wrote |
notes[].ref_type, notes[].source_pdf |
Stable plan metadata; source_pdf is vault-relative |
notes[].marker |
Whether this PDF's marker was (or would be) written back |
notes[].marker_write |
Present exactly when a marker was written in write mode (never on dry runs), right after marker: mode ("coordinated" or "direct"), editor_saved_first, status ({from, to} marker status values) only when the marker's status line changed, and restored: true only when this write restored a stale editor save |
notes[].continuations |
Absorbed follower pieces on this note; omitted when zero |
notes[].continuations_dangling |
< markers that could not form a group; omitted when zero |
notes[].underlines_merged |
Underline annotations folded into host quotes; omitted when zero |
notes[].underlines_standalone |
Underlines that stayed separate; omitted when zero |
notes[].sections |
Declared sections after duplicate declarations are merged; omitted when zero |
notes[].section_tags_unknown |
Positional tags that do not match a declared section; omitted when zero |
failures |
One entry per per-PDF failure: pdf (vault-relative), stage ("plan" or "write"), and message (the same text the human report prints) |
intake_only |
true for scan --intake-only, otherwise false |
queued |
scan --intake-only items still waiting in xlib/ (on a dry run, items that would stay queued) as {pdf, reason, message}; reason is a stable slug (lock_busy, scan_busy, other_vault, outside_vault, not_a_worktree, pre_sync_failed, collision, too_large, refused, needs_write_pdfs). Always [] for a normal scan |
held |
PDFs held instead of written (coordinated marker writes waiting on Highlights), always last and [] for dry runs: {pdf, note, title, reason, message} with vault-relative pdf/note, a stable reason slug (editor_active, coordination_timeout, marker_changed), and the same message the human held line prints. The error envelope has no held field |
A hard failure prints the error envelope instead (ok, schema_version,
command, generated_at, mode, write_pdfs, intake, error,
intake_only, queued), where intake lists any moves that already happened
before the failure. queued is always [] there.
error.hint is a string or null; error.paths is always an array of
vault-relative paths. Exit codes: 0 means ok: true; 1 means per-PDF
failures (the full envelope above, ok: false) or a hard failure (the error
envelope below, with JSON on stdout either way); 2 is a clap usage error with
no JSON on stdout.
error.code |
Raised by |
|---|---|
lock_busy |
The vault-maintenance lock stayed held past the wait (see below) |
scan_busy |
The writer lock stayed held past the wait (see below) |
pre_sync_failed |
scan --intake-only could not pull from the remote, or the pull left conflicts |
not_a_worktree, other_vault, outside_vault |
scan --intake-only refused the vault: it is not a Git worktree, differs from BOB_DIR, or has a configured directory outside it |
hook_failed |
The pre-scan hook failed to spawn or exited non-zero |
intake_collision |
An intake destination already exists (paths: the colliding destinations) |
output_collision |
Two PDFs map to one note or asset path (paths: the colliding targets) |
dirty_targets |
The dirty-target git preflight refused (paths: the dirty files) |
scan_failed |
Every other hard failure |
Writer lock¶
A writing scan (not a dry run) takes two exclusive locks after layout validation and before the pre-scan hook runs, and holds both until the scan returns:
- the shared vault-maintenance lock (
bob_sync.lock, orBOB_VAULT_SYNC_LOCK_FILEwhen set); - the scan writer lock (
<state>/bob-cli/ref/scan.lock).
The order is always bob_sync.lock then scan.lock. That serializes
writing scans with vault-sync, nightly, and rename so vault-sync cannot
push a library PDF without its ref note, and serializes two writing scans
(for example Bob Refs ⌘S, Bob Mac Capture's scan after ⌘S in Highlights,
and the 15-minute cron) so they do not race on intake moves. A dry run
takes neither lock. Writing scans never run vault-sync
themselves; vault-sync's next cycle after the scan releases the lock
commits the PDF and note together.
The scan lock file is mode 0600 and the directory 0700. While another
command holds the maintenance lock, a scan prints waiting for another
vault maintenance run… once to stderr (in both human and JSON modes)
and retries every 250 ms for up to 120 s, then fails with lock_busy.
The message names the lock path. The hint says vault-sync, nightly, or
rename held it; rerun after it finishes. While another scan holds the
scan lock, a scan prints waiting for another bob ref scan to finish…
once to stderr and retries the same way, then fails with scan_busy.
Both waits are overridable with the internal
BOB_REF_SCAN_LOCK_WAIT_SECONDS environment variable, for tests.
scan --intake-only takes the same two locks in the same order, but with
the shorter create-time waits (60 s and 30 s, overridable with
BOB_REF_INTAKE_LOCK_WAIT_SECONDS) and holds them for the whole drain.
The pre-scan hook runs under bob_sync.lock. A hook that itself runs
bob vault-sync is a silent no-op: vault-sync skips when that lock is
already held. Bob exports BOB_HIGHLIGHTS_IN_PRE_SCAN_HOOK=1 so a hook
can detect it and avoid shelling back into bob ref scan. A nested
writing scan would wait on the held locks and fail with lock_busy.
Human mode is otherwise byte-identical, except for the lock-wait lines
on stderr and the lock_busy / scan_busy failures.
Scan receipt¶
Every writing bob ref scan that acquired the scan writer lock writes the
exact envelope line JSON mode would print to
<state>/bob-cli/ref/last-scan.json ($XDG_STATE_HOME, else
~/.local/state). That covers human and JSON modes, full and
--intake-only drains, success, partial success, and hard failures after
the lock (where the receipt holds the error envelope). In JSON mode the
receipt bytes are identical to stdout. Dry runs, ref sync, and scans that
never got the lock write no receipt. The receipt is written by temp file
plus rename, mode 0600, in a 0700 directory. A receipt failure prints one
bob ref: could not write scan receipt: … line to stderr and never changes
stdout or the exit code.
On macOS, bob then posts the org.bobs.bob.ref.scan-finished Darwin
notification so Bob Mac Capture re-reads the receipt off the main thread.
Receipts are serialized by the scan writer lock, so the newest file always
describes a scan that actually ran. That also gives unattended cron scans a
readable record beyond the (usually empty) cron log.
Generated Body Contract¶
Highlights, image selections, comments on annotations, and standalone non-marker notes sync one way from the PDF/sidecar into the reference note.
For foo.pdf, sidecar discovery looks for foo.md first. If there is no
Markdown sidecar, it recognizes foo.textbundle only when the bundle contains
text.md or text.markdown; other TextBundle contents fail with an explicit
unsupported-sidecar error. Image selections require the referenced asset file to
exist relative to the sidecar text file's directory. This makes
foo.textbundle/text.md resolve  to
foo.textbundle/assets/figure.png. Plain Markdown sidecars can still reference
local image files, but Highlights' plain Markdown export does not include image
assets; re-export as TextBundle when area selections should sync. If no sidecar
exists, sync still performs marker/frontmatter sync. For a new note it creates
an empty managed region; for an existing note it preserves the existing managed
region.
Known Highlights TextBundle Creation Bug¶
For PDFs that contain image or area (rectangle) annotations, the Highlights app
sometimes fails to create the TextBundle sidecar in the first place, even with
autosave enabled. This is a Highlights app limitation, not a bob ref
behavior: Bob never creates or mutates sidecars, it only reads the PDF and
sidecar content the app owns. Because Highlights can still update a TextBundle
that already exists, the workaround is a one-time manual export per affected PDF.
After the initial export, Highlights keeps the bundle updated, so this step is
only needed once per PDF whose image annotations should sync.
Create or export the bundle beside the PDF, using the exact PDF basename, so
that example.pdf is paired with example.textbundle/. For
~/bob/lib/<ref_type>/example.pdf, the expected layout is:
~/bob/lib/<ref_type>/example.pdf
~/bob/lib/<ref_type>/example.textbundle/text.md
~/bob/lib/<ref_type>/example.textbundle/assets/figure.png
The sidecar text must live at ~/bob/lib/<ref_type>/example.textbundle/text.md
(or text.markdown), and every referenced image must live under
~/bob/lib/<ref_type>/example.textbundle/assets/.
After creating the bundle, run bob ref scan --dry-run and confirm the
affected PDF reports the expected sidecar: path and a nonzero images: count.
The user-visible symptoms of a missing or incomplete bundle are:
- A missing
.textbundlelooks like no sidecar, sobob ref scanperforms marker/frontmatter planning but generates no image annotations. - A
.textbundlewithouttext.mdortext.markdownsurfaces asunsupported textbundle sidecar. - A bundle whose
text.mdreferences images it does not actually contain underassets/surfaces asimage asset not found.
Two Markdown sidecar shapes are supported. The simple shape is:
- Page labels are Markdown headings such as
## Page 12or## p. 12. - Annotation spacers are horizontal rules such as
---. - A highlight starts with one or more blockquote lines.
- Non-heading text after a highlight is rendered as a highlight comment. A
leading
Comment:orNote:label is stripped. - A standalone note is non-heading text outside a blockquote. A leading
Note:label is stripped. - A Markdown image line such as
outside a blockquote is an image selection when the target has a supported image extension. Text after the image is treated as its comment. Multiple images in one chunk render as separate image annotations. Non-image targets such as PDFs remain ordinary standalone note text. - Text before the first page heading is document preamble (a setext title, an author line, blank lines), never an annotation. Sidecars without any page heading have no preamble.
- A standalone note whose text is a
status/parentmarker list, or a linked-page highlight whose comment is one, is the PDF marker mirrored into the sidecar — never one of your annotations — and is excluded from generated content no matter where it sits in the sidecar. Color-grouped exports with no page headings may prefix that list with a setext title (raw two-linetitle+===underline, or the already-rendered single-linetitle ===...form); both newly parsed and legacy persisted mirrors are excluded, and the next normal scan/sync removes their phantom^h-IDs without tombstones. Ordinary notes in sidecars without page headings are still retained.
Simple sidecar fragment:
## Page 12
- status: wip
- parent: obsidian
---
> Latency is not throughput.
Comment: Compare this with SLO notes.
---
Note: Keep this standalone observation.
Image selection sidecar fragment:
## Page 12

Comment: Compare this figure with the latency table on p.14.
The linked-page Highlights export shape is also supported:
- Page labels may be Markdown-link headings such as
#### [Page 1](highlights://book#page=1). The link label,Page 1, is used as the rendered page heading. - Non-page headings such as the document title and dated metadata headings are ignored by the annotation parser.
- Annotation spacers may use any Markdown horizontal rule style, including
***. - Highlights may be hard-wrapped so that only the first physical line starts
with
>. Immediate nonblank continuation lines are kept as highlight text, including heading-shaped wrap lines such as# Set default agent. Page headings, color-group headings (#### Yellow), annotation-date headers (##### 2026-06-03:), marker-list fields, and explicitComment:/Note:labels leave the quote instead. A blank line also ends the quote and starts the comment side. Color-grouped exports with no page headings share this hard-wrap rule; simple## Page Nsidecars keep treating bare lines after the quote as the comment. - User comments exported as Markdown list items after a highlight are rendered without the list marker.
- A blockquoted marker mirror title followed by marker-list fields such as
statusandparentis excluded from generated content.
Linked sidecar fragment:
# Highlights Reference Note Sync
#### [Page 1](highlights://highlights-ref-sync#page=1)
##### 2026-06-03:
> Highlights Reference Note Sync
- status: ready
- parent: obsidian
***
#### [Page 2](highlights://highlights-ref-sync#page=2)
##### 2026-06-03:
> It only writes the PDF marker when marker write-back is needed
and the matching write opt-in is supplied.
- Support sase tool call replay?
The generated region is the only body region the tool owns:
<!-- highlights:begin -->
<!-- highlights:end -->
Manual content outside those markers must be preserved. User edits inside the generated region may be overwritten.
Legacy (v1) generated notes include a title, a PDF wikilink Obsidian task line with
the #hide tag and the stable ^ref block ID, an Obsidian audio embed when a
same-stem companion exists, and ## Highlights. ## Tasks
is not part of that skeleton; the section is created later, directly below the
^ref task, when the first annotation task is imported. New v2 notes instead
show a managed embed pointing at the parent-note reading task; see
Reading tasks in parent notes (v2).
Existing notes must already contain the managed begin/end markers; otherwise
sync fails instead of guessing where generated content belongs.
Generated annotations render as Obsidian callouts. Highlight text uses a
[!quote] callout; a highlight or image comment is nested inside that quote as
a [!note] Comment callout so the annotation's trailing ^h-... block ID still
covers the quote/image and the comment together. Image selections use
[!quote] Image with a vault-relative Obsidian embed. Image notes (a local
image path on its own line in the annotation's note) render as embeds inside
the same callout, directly below the highlighted text. Standalone non-marker
notes render as [!note] callouts. Removed annotation tombstones render under
### Removed highlights as [!warning] Removed highlight callouts.
Example generated body:
### Page 12
> [!quote] Latency is not throughput.
>
> > [!note] Comment Compare this with SLO notes.
^h-2b91f0a4c7de
> [!note] Keep this standalone observation.
^h-8f42a61a90cc
Image selections copy their asset into a per-note assets directory beside the
reference note. For ref/books/example.md, the destination is
ref/books/example.assets/h-<id>.<ext>, and the generated block embeds it with a
vault-relative wikilink:
### Page 12
> [!quote] Image ![[ref/books/example.assets/h-2b91f0a4c7de.png]]
>
> > [!note] Comment Compare this figure with the latency table on p.14.
^h-2b91f0a4c7de
Asset filenames and image block IDs are content-addressed from the source PDF
path and image bytes, so re-exported or renamed TextBundle assets keep the same
^h-... block and stored filename when the image bytes are unchanged. Existing
matching assets are skipped; an existing destination with different bytes is a
write failure instead of an overwrite. When an image selection is removed from
the sidecar, the generated block is tombstoned like other removed annotations,
but the copied asset file is left in place. Asset garbage collection is future
work.
Image notes¶
An image note is a line of an annotation's note whose entire content is one local image path. Put the path on its own line in a highlight comment, a comment on a TextBundle image selection, or a standalone note.
Accepted forms, after trimming, stripping one list marker (-, *, +),
stripping a leading Comment:/Note: label, stripping one wrapper
(backticks, angle brackets, straight or smart quotes), and stripping a trailing
., ,, or ;:
~/tmp/screenshots/20261018_104022.png
/tmp/screenshots/20261018_104022.png
file:///tmp/Screenshot%202026-10-18.png
Spaces in the path are fine when the whole line is the path. Several whitespace-separated paths on one line become several images. A path in the middle of a sentence, or a relative path, stays ordinary comment text.
Supported extensions are png, jpg, jpeg, gif, webp, and svg (the
formats Obsidian renders and the vault gitignore tracks). heic, heif,
tif, tiff, bmp, and avif render an "Image type not supported" notice
instead of staying silent text.
The copy lives beside TextBundle assets in ref/<type>/<stem>.assets/, named
<safe-stem>-<hash8>.<ext>. hash8 is the first 8 hex digits of SHA-256 over
the canonical source text (~/tmp/screenshots/20261018_104022.png is never
host-expanded, so every host uses the same filename). Example:
ref/papers/foo.assets/20261018_104022-07154fc6.png
The same path on several annotations shares one copy. Images render directly
below the highlighted text, inside the [!quote] callout, each preceded by a
bare > spacer; remaining comment text follows as the usual nested Comment
callout:
> [!quote] Latency is not throughput.
>
> ![[ref/books/example.assets/20261018_104022-07154fc6.png|420]]
>
> > [!note] Comment Compare with the p99 chart.
^h-2b91f0a4c7de
| State | Condition | Rendered at the image's position |
|---|---|---|
| Embedded | vault copy exists, or source readable (copy planned) | > ![[<vault path>]] or with \|<width> |
| Not found | no vault copy and source missing on this host | > > [!warning] Image not found |
| Unreadable | no vault copy and source read failed (permission, is a directory) | > > [!warning] Image unreadable |
| Unsupported | recognized but unsupported extension | > > [!warning] Image type not supported |
These states are self-healing: the next scan that can read the file copies it and replaces the warning with the image. They never fail the PDF or change the exit status.
Retina screenshots (PNG pHYs denser than about 90 dpi) emit a |<logical>
width hint when the logical CSS-pixel width is below the pixel width and at
most 600, so a 840× region capture at 144 dpi renders at |420. A full-screen
2880 px capture gets no hint and fits the callout. Non-PNG and malformed files
get no hint.
Snapshot semantics: the vault copy is written once and never overwritten.
If the destination exists, later edits to the file at the pasted path are
ignored. To refresh an image, delete its file under <stem>.assets/ or paste a
new screenshot path; the next scan re-imports it. Once the source is gone, the
vault copy remains.
The Mac scan resolves macscrot paths (~/tmp/screenshots/YYYYMMDD_HHMMSS.png)
locally. athena and apollo hold the same uploads, so a scan there can copy a
screenshot that has already been synced across hosts.
Troubleshooting:
bob ref sync <pdf> --dry-runshowsimage_notes:andimage_note_unresolved:on the verbose plan.bob ref doctorlistsunresolved_image_noteunder library diagnostics with example paths.
Continuation highlights (<)¶
A Highlights note that starts with < groups that text highlight with the
nearest earlier text highlight when bob ref renders the reference note.
Type the marker on the later piece, while looking at the overflow. The
group keeps every piece's words, page, images, and comment together. It is
stateless and deterministic, and deleting the < removes the group. Nothing
is written to the PDF or the sidecar.
| Note typed on the later highlight | Result in the ref note |
|---|---|
< |
Groups the previous highlight; adds no comment |
< PR Merge Master?! |
Groups; comment stays on this piece |
- < keep going / Comment: < keep going |
Groups; comment keep going stays on this piece |
< then a new line - #task Check the sheriff |
Groups; the task imports normally |
- < #task Check the sheriff |
Groups; task text has no < |
< then a new line ~/tmp/screenshots/x.png |
Groups; the image stays on this piece |
<… why skip this? / <... why skip this? |
Groups; either spelling of < keeps comment why skip this? |
\< 5 ms is the target |
No group; renders < 5 ms is the target |
<div>, <-, <= 5, <3, <https://x.org>, </tmp/x.png> |
Ordinary note; < kept |
…see above, or a < on any later line |
Ordinary note |
If you still see a <, it did not form a group. A dangling marker keeps its raw
note, so the < stays visible in the generated comment.
The grammar runs only on text highlights, never on sticky notes
(StandaloneNote) or on a TextBundle image selection's caption. Those <
characters are literal text: they are not matched, escaped, or counted.
It reads the highlight's task_source after a leading Comment:/Note:
label has already been stripped:
- Take the first line whose trimmed text is non-empty. Every other line stays byte-identical.
- Set aside leading whitespace and at most one unordered-list prefix (
-,*, or+, then whitespace). - At the start of the remaining body, test these spellings in this order. Each marker must be followed by whitespace (including NBSP) or the end of the line:
- Marker:
<(ASCII),<…(U+2026), or<.... macOS smart punctuation turns...into…, so both longer spellings match. - Escape:
\immediately followed by one of those spellings that meets the same boundary rule. Remove that one backslash and treat the note as ordinary. Only a would-be marker is unescaped, so\<div>stays exactly as typed.
The effective first line is the set-aside prefix plus the text after the token, with leading whitespace trimmed. If that text is empty, the line is dropped. A dangling marker keeps the raw note; the effective note is used only when the follower actually forms a group, or for an escape.
Previous is the nearest earlier annotation, in sidecar order (Highlights'
sidebar order), that is a text highlight and not a marker mirror. The walk
skips marker mirrors, sticky notes, and image selections; those keep their
own blocks and render after the group, in sidecar order. If that nearest
text highlight is itself a follower, the new piece becomes another follower
of the same head, so A, <B, <C is one group. If no earlier text highlight exists, the marker
dangles and the highlight renders on its own, verbatim. There is no distance
limit: a wide page jump shows up through the gap and the heading.
The group renders at the head's position. Exporting Highlights sorted by color or date changes sidecar order, so "previous" is no longer the visual predecessor. That sort is unsupported.
Each highlight is beautified on its own. Text is never fused, hyphens are
never healed across pieces, and no […] is inserted. The <… and <...
spellings have the same grouping behavior as <.
Heading ranges use the members' page labels: Pages 11–12, Pages 11–13,
Pages 11, 14, Pages 11–12, 14. A p. prefix becomes pp.. A single
shared label stays as-is. A non-numeric label falls back to the head's
label.
Page-turn sidecar:
#### [Page 11](highlights://example#page=11)
> PR Sheriff Standing Orders. On every
***
#### [Page 12](highlights://example#page=12)
> session startup, the sheriff checks open PRs
< PR Merge Master?!
renders as one block with two visibly separate pieces:
### Pages 11–12
> [!quote|Page 11] PR Sheriff Standing Orders. On every
>
> > [!quote|Page 12] session startup, the sheriff checks open PRs
> >
> > > [!note] Comment PR Merge Master?!
^h-ba93bec527bb
Each piece keeps its own page, image notes, and Comment card. A longer group keeps all followers as siblings inside the head quote:
### Pages 6–8
> [!quote|Page 6] In Scion, the unit of work is **a “changeset,” an entry in a
> write-ahead log.** … along with
>
> > [!note] Comment Changesets again.
>
> > [!quote|Page 7] content-addressed pointers to the compressed file contents in blob
> > storage. …
> >
> > ![[ref/blogs/redesigning_source_control.assets/20261020_182622-dcaced15.png|568]]
>
> > [!quote|Page 8] support refs, but since neither creating nor fetching commits
> > involves them, …
> >
> > > [!note] Comment Changesets are what “patches” should be!
^h-40fb23af3c7b
A previously published follower that is later absorbed keeps a redirect stub
under ### Removed highlights:
### Removed highlights
> [!info] Grouped highlight
>
> Now shown in [[#^h-ba93bec527bb|the Pages 11–12 group]].
^h-609a556c1cb0
The head ^h- ID is unchanged with or without followers. The follower ID is
the same grouped and ungrouped. A stub appears only for a follower that was
already published. Existing tasks never have their 🔖 rewritten: a task
created before a group keeps pointing at the follower ID, whose stub points
to the group. New tasks on a grouped piece keep a per-piece [h::] processed
ID and a 🔖 link to the group's head block.
Troubleshooting:
bob ref sync <pdf> --dry-runshowscontinuations:when any follower was grouped or any marker dangles, then onecontinuation_dangling: <page label | no page> (no earlier text highlight)line per dangling marker.- Concise
bob ref scanadds1 continuation/N continuationsand1 dangling continuation/N dangling continuationsafter image-note details. - Scan JSON includes
notes[].continuationsandnotes[].continuations_danglingonly when the count is non-zero.schema_versionstays1.highlights_countcounts a group once.
Underline emphasis¶
A Highlights underline whose exported quote is fully wrapped in ** is
treated as emphasis when its cleaned text occurs exactly once in exactly one
text highlight on the same page. The underline's words become a ** run in
that highlight's quote; its comment, image notes, and #task join the host
block. A whole-quote underline still produces one block. The simple rule:
an underlined run inside a quote means an underline merged; a wholly
underlined quote means it stayed standalone; the note stores both as **.
For multi-word text, the cleaned underline key must occur in exactly one
candidate highlight. Single-word underlines additionally require word
boundaries, so proves does not match inside approves. If no host matches,
more than one host matches, or the text repeats in its only host, the underline
stays separate. Dry-run reasons are no highlight around it, inside N
highlights, and text repeats inside its highlight.
The run expands to whole words and is split at line breaks. For example,
underlining what didn’ in what didn’t change renders as **what didn’t** change.
Comments retain member comments first, then absorbed underline comments in run
order; when multiple sources or runs share the block, underline comments get a
short quoted run anchor. Image notes follow member images, and tasks retain the
underline's own processed identity while linking to the host block. A leading
< continuation marker always wins: that underline remains standalone and
renders as it does today. Escaped \\< is an ordinary note.
An absorbed underline keeps its original ID out of current_ids and is never
tombstoned. A Merged emphasis redirect stub appears only when the ID is in
the existing region and either already has a Removed highlights entry or is
linked from a Markdown file in the vault. It points to the host quote, and
remains a tombstone if the underline is later deleted.
Use bob ref sync <pdf> --dry-run to inspect underlines_merged and each
underline_standalone reason before rescanning. Concise scans report merged
and standalone counts; JSON adds notes[].underlines_merged and
notes[].underlines_standalone only when nonzero. schema_version remains 1.
In Obsidian, enable the ref-highlights CSS snippet so underlined runs in
quote callouts draw as semibold full-ink words over a thick gold rule,
matching the Bob Mac Capture Refs inspector, while leaving nested Comment
callouts alone:
/* bob ref: Highlights exports an underline as **bold**, and bob ref merges it
into its highlight's [!quote] callout. Draw it as the underline it was:
full-ink semibold words over a thick gold rule that passes under
descenders, matching the Bob Mac Capture Refs inspector. Nested Comment
callouts are not affected. */
.callout[data-callout="quote"] > .callout-title strong,
.callout[data-callout="quote"] > .callout-content > p strong {
color: var(--text-normal);
font-weight: var(--font-semibold, 600);
text-decoration-line: underline;
text-decoration-color: color-mix(
in srgb,
var(--color-yellow) 65%,
var(--color-orange)
);
text-decoration-thickness: 0.15em;
text-underline-offset: 0.12em;
text-decoration-skip-ink: none;
}
/* bob ref: a `<` group tucks each later highlight into the first
highlight's quote callout. Draw the group as one card: every tucked
highlight opens at a dashed seam, captioned at the right with the page
it was highlighted on, and keeps its own Comment callout. */
.callout[data-callout="quote"] .callout[data-callout="quote"] {
margin: 0.8em 0 0;
padding: 0.8em 0 0;
border: 0;
border-top: 1px dashed rgba(var(--callout-color), 0.6);
border-radius: 0;
background: transparent;
}
.callout[data-callout="quote"]
.callout[data-callout="quote"][data-callout-metadata]:not(
[data-callout-metadata=""]
)::before {
content: attr(data-callout-metadata);
float: right;
margin: -0.55em 0 0 1em;
font-size: var(--font-smallest);
font-variant: all-small-caps;
letter-spacing: 0.06em;
line-height: 1;
color: var(--text-faint);
}
/* bob ref: a `# Name` sticky note opens a section. Its card sits right
under the section heading: a quiet violet rule, a faint small-caps line
with the @tag, count, and pages, then your description in muted ink.
Yellow quotes and gold underlines stay the loudest marks on the page. */
.callout[data-callout="summary"][data-callout-metadata="section"] {
--callout-color: var(--color-purple-rgb);
margin: -0.35em 0 1.2em;
padding: 0.1em 0 0.1em 0.85em;
border: 0;
border-left: 3px solid rgba(var(--callout-color), 0.55);
border-radius: 0;
background: transparent;
}
.callout[data-callout-metadata="section"] > .callout-title {
font-size: var(--font-smallest);
font-variant: all-small-caps;
letter-spacing: 0.06em;
color: var(--text-faint);
}
.callout[data-callout-metadata="section"] > .callout-title .callout-icon {
display: none;
}
.callout[data-callout-metadata="section"] > .callout-content {
color: var(--text-muted);
}
The source snippet is ~/bob/.obsidian/snippets/ref-highlights.css; add
"ref-highlights" to enabledCssSnippets in appearance.json. The underline
rule applies to strong text in quote callouts. The nested quote rules turn
group pieces into one card with dashed, page-captioned seams. Absorbed
underlines share their host highlight's ^h- ID, so underlining a published
passage never changes the host ID or later blocks on that page.
Sections (# and @)¶
- A sticky note or a comment whose first line is
# Namestarts a section. For a sticky, the rest is the description. For a comment, the annotation joins the section, and the rest stays its comment. - Tag a note with
@nameor#nameat the start or end of a line to file it there. If you can still see the tag, it did not group. - The space matters:
# Memorydeclares, and#memoryjoins.
In Highlights, write the declaration in a standalone sticky note or as the
first line of a highlight, underline, or image comment. Its first non-empty
line must be one #, a space, and a title, after an optional Comment: or
Note: label. Exported date headings and an optional Note: label before a
sticky line are ignored. Add optional aliases at the end, such as
# Open questions @q or # Open questions #q; the first alias becomes the card's tag
(which always shows the @ spelling), while both the
title and aliases can match annotations. For a sticky, the remaining lines are
its description. For a comment, the declaration line (and one blank line after
it) is removed and any remaining text stays that annotation's own comment; an
emptied comment means no Comment card, and comment declarations contribute no
description. Only the first comment line declares; later # lines stay
literal, and \# escapes. Rejected declarations remain visible as sticky notes
(or literal escaped lines for comments).
A positional tag is a whole @name or #name token at the beginning or end of a line,
after an optional Note: or Comment: label and one list marker; one line may mix
both sigils. Matching
ignores case and punctuation between letters and digits. A #name must end at whitespace
or the end of the line, and must contain more than digits. #task is never a section tag,
and nested Obsidian tags never group.
Tags can be on
highlight comments, underline comments, image comments, or ordinary sticky
notes. Quoted PDF text and image paths are not scanned. Tags in the middle of
prose, code, or a #task line do not group. A backslash escapes a tag.
A matched #name is consumed, so it never becomes an Obsidian tag on the ref note.
An unmatched one stays an ordinary Obsidian tag.
\#name renders exactly as typed, which Obsidian shows as plain #name text.
| Highlights text | Result |
|---|---|
@memory |
Joins Memory; the tag is consumed. |
@Memory Compare with table 2 |
Joins Memory; the rendered comment is Compare with table 2. |
Use these as placeholders @visuals |
Joins Visuals; the trailing tag is consumed. |
#memory |
Joins Memory; the tag is consumed. |
Compare with the intro #memory |
Joins Memory; the trailing tag is consumed. |
#memory versus # Memory |
A tag versus a declaration. |
# Memory as a comment's first line |
Declares Memory; the annotation joins, the line is removed. |
Keep @chat and @agent! |
Only the final @agent! is positional; @chat stays in the prose. |
- #task Draft the intro @sase |
Routes the task to sase.md; the tag does not group this task line. |
@memory followed by - #task Draft the intro @sase |
The first line joins Memory and the second still routes its task. |
@memry |
Stays visible and reports section_tag_unknown when no section matches. |
#memry |
Stays visible, and reports section_tag_unknown with a #memory hint; a far-off hashtag like #a stays silent. |
#area/memory |
A nested Obsidian tag, never grouped. |
@memory @visuals |
Memory is the one home; Visuals stays visible and reports a conflict. |
\@memory |
Renders as @memory and never groups. |
\#memory |
Shown as plain #memory text, never grouped. |
- #task Draft #memory |
A task, not a section tag. |
Sections are real headings in the generated note, so they appear in Obsidian's Outline and can be folded, linked, or embedded. Each section card shows its tag, member count, pages, and description. For example:
### Memory
> [!summary|section] @memory · 1 highlight · Page 4
> Ideas to remember.
^s-memory
#### Page 4
> [!quote] A useful passage.
^h-496b1ca10117
### Other highlights
#### Page 2
> [!quote] A passage without a section tag.
^h-38a082b00de9
Each displayed block has one home. For a continuation group, the head's tags
are checked first, then its followers, then absorbed underlines in sidecar
order. The first matching section wins. Repeated tags for that section are
consumed; tags for another section stay visible and report section_conflict.
Removing a tag or declaration re-derives membership on the next scan.
An empty declaration still creates a section card that says “nothing tagged
yet.” Sections render in declaration order, followed by Other highlights if
any blocks remain, then Removed highlights. Stack the declaration stickies on
page 1 to set the Outline order. Within each section, annotations keep sidecar
order and page captions restart at each section heading.
Section tags do not change published ^h- IDs or recreate tasks. Section IDs
use ^s- blocks; the declaration's #task items link to that section block.
Nothing new is stored in frontmatter. Scan dry-run output reports section
diagnostics beside the existing per-PDF details, and concise scans include
section and unknown-tag counts. The JSON fields notes[].sections and
notes[].section_tags_unknown are omitted when zero; the schema version stays
1.
Install the release on every host (Mac, athena, and apollo) before writing the
first declaration; install everywhere first, then you hold the switch by
adding a # sticky. Highlights on the Mac exports a comment's # Name line
verbatim (observed in a real sidecar on 2026-10-24); sticky notes and iPad
exports are still unverified.
The accepted costs are one home per displayed block, page order is grouped by section in a sectioned note, and Highlights has no section-tag autocomplete.
The existing ref-highlights snippet also styles section cards with a quiet
violet rule and muted description. This CSS has not been tested in Obsidian.
Check reading view and Live Preview, in light and dark themes and on mobile.
Annotation text is beautified only while rendering the generated region:
- Consecutive prose lines are reflowed into one line. Blank lines remain paragraph breaks.
- Markdown unordered list lines (
-,*,+, including task checkboxes) start their own logical lines, and wrapped continuation text joins onto that list item. - Hyphenated PDF line endings are healed at join points: lowercase next words
drop the hyphen (
through-+put->throughput), while uppercase or digit next fragments keep it (Marie-+Curie->Marie-Curie). - Ligature glyphs, non-breaking spaces, soft hyphens, zero-width characters, doubled spaces, and tab runs are normalized away.
- For PDFs whose projection has
return_links: true, highlight text drops return-link tag glyphs and↩ p. Npill fragments before that cleanup.
The cleanup is render-only for display, but block identity uses the same
normalized semantic text: return-link pill/tag glyphs and underline **
emphasis are stripped, PDF text is beautified, and whitespace is collapsed,
so emphasis and return-link decoration never change an ID. Block IDs,
annotation-task source links, and processed [h:: ...] markers use that
stable content key, so unchanged sidecar annotations keep their existing
^h-... IDs when a note is regenerated with the callout format.
The legacy (v1) generated task line is the reference-reading lifecycle:
- [ ] #task #ref [[lib/books/example.pdf]] #hide ^ref
When a companion audio file is present, the native player sits directly below
that task block (after its indented child lines, if any), before ## Highlights:
- [ ] #task #ref [[lib/books/example.pdf]] #hide ^ref
![[lib/books/example.mp3]]
## Highlights
The #ref tag immediately after #task marks this as the machine-managed
reference lifecycle task so Obsidian task views can tell it apart from ordinary
annotation-derived follow-up tasks. It is additive: legacy generated lines
without #ref are still recognized.
The ^ref block ID remains part of the reference lifecycle and link contract; it does not create a freshness tracker or review group. Reference tasks use ordinary freshness and review rules (including #hide exclusion), as described in docs/freshness.md. PRE/POST checklist membership and recurring occurrence rules still take precedence. Sync never auto-confirms a task.
| Checkbox | Obsidian Tasks status | Reference status |
Meaning |
|---|---|---|---|
[ ] |
Todo | ready |
In the reading queue, not started |
[*] |
Next | next |
Queued for action |
[/] |
In Progress | wip |
Actively reading |
[?] |
Blocked | (status unchanged) | Waiting on an open dependency or future schedule; derived by bob task-status-hooks |
[x] / [X] |
Done | read |
Finished |
[-] |
Cancelled | abandoned |
Dropped |
The mapping is bidirectional. On read, the checkbox is authoritative and moves
frontmatter and the PDF marker to its status. On generation or write-back, the
final status selects the matching checkbox. legacy has no lifecycle checkbox
and falls back to [ ]; legacy notes without a generated ^ref task remain
untouched. unread is accepted only as a deprecated alias of ready, while
done remains a deprecated alias of read.
Blocked [?] is a status-neutral overlay, not a new reference status: it
agrees with any open selected status (ready, next, wip, legacy) and
contributes nothing, so the stored lane stays as is. With a terminal selected
status it acts like a reopen and targets ready through the usual conflict
check. Sync never rewrites a Blocked task to [ ], [*], or [/] when the
final status is open, because bob task-status-hooks owns that recovery; when
the hooks unblock the task, the next sync applies the new mark normally. To
finish a blocked reference, check the ^ref task [x]; the hooks leave done
tasks alone. Toggling only the checkbox to or from [?] counts as a
checkbox-only body change.
Changing [x] or [-] back to [ ] therefore reopens a reference to ready;
use [/] to resume it directly as wip. Metadata such as
[cancelled:: 2026-06-04] is preserved when only the checkbox mark changes. If
the marker or frontmatter also moved status to a different value from the stored
base, the task signal reports a conflict rather than silently choosing a side.
When editing status directly, move the visible ^ref task to the same state.
When sync itself closes the ^ref task, it stamps the close date on the line:
a generated [x] carries [completion:: YYYY-MM-DD] and a generated [-]
carries [cancelled:: YYYY-MM-DD], inserted immediately before the ^ref
token and dated from BOB_NOW (else the local date). A line that already
carries a bracket or emoji close date is never restamped, a checkbox you
closed yourself gains no stamp, and reopening never removes the date.
If a lifecycle change would update the PDF marker, sync --dry-run previews
pdf_marker_action: would-update, plain sync refuses before writes, and
targeted sync --write-pdf writes the marker. scan --dry-run previews this
work. A writing scan keeps the default note-only refusal unless --write-pdfs
is supplied.
Highlight comments and standalone non-marker notes can also create actionable
Obsidian tasks when the marker/frontmatter-selected PDF status is open
(ready, next, or wip), before the generated PDF ^ref checkbox
contributes another lifecycle status. A run that closes an open reference to
read or abandoned still imports newly added annotation tasks in that same
run, and reopening a terminal reference imports them too. Runs whose status is
terminal (read, abandoned, or legacy) both before and after the
reading-task signal skip task intake. Any unordered Markdown bullet line whose item text contains #task as a
whitespace-delimited token is copied to an unchecked task:
- #task Compare this claim with the appendix.
- [x] #task Email the citation to Alex.
- #task Send the quote to Alice @alice
Legacy (v1) created tasks go into the note's ## Tasks section. If the note has no Tasks
heading, one is created as ## Tasks directly below the generated PDF ^ref
task block (the task line plus its indented child lines, e.g. a DEPENDS ON
line), separated from it by a blank line. An existing Tasks heading at any
level is reused. New tasks are appended after the tasks already in the section:
## Tasks
- [ ] #task Compare this claim with the appendix. [[#^h-2b91f0a4c7de|🔖]] [h:: 4c0a13d2...] [created::2026-06-07]
- [ ] #task Email the citation to Alex. [[#^h-2b91f0a4c7de|🔖]] [h:: 910f6ce7...] [created::2026-06-07]
If the final whitespace-delimited token is a strict @name route suffix, the
suffix is removed from the created task text and the task is appended to the
end of existing root-level note ~/bob/name.md instead. Routed tasks keep that
append-at-end behavior; this command does not create a ## Tasks section on
the routed note. Route names must match
@([A-Za-z0-9][A-Za-z0-9_-]*); tokens with punctuation, dots, slashes, empty
names, or path traversal content are ordinary task text. Routed target notes
must already exist and must not be directories, because new root notes need
explicit frontmatter that this command cannot infer. For example, @alice
creates this line in ~/bob/alice.md:
- [ ] #task Send the quote to Alice [[ref/books/example#^h-2b91f0a4c7de|🔖]] [h:: 9b31c4a0...] [created::2026-06-07]
Each created task carries a block backlink to the annotation-level generated
source block, rendered as an aliased 🔖 link between the task prose and
processed/created properties. Same-note tasks use compact same-file targets
([[#^h-...]]). Routed tasks use full vault-relative note targets
([[ref/books/example#^h-...]]) so the source note remains recoverable after
the task moves to another note or done/. The managed Highlights region keeps
the annotation-level ^h-... block and does not attach task-specific ^ht-...
block IDs to source task lines:
> [!quote] Highlighted claim.
>
> > [!note] Comment #task Compare this claim with the appendix.
> > #task Email the citation to Alex.
^h-2b91f0a4c7de
The [h:: ...] processed ID is computed from the source reference note path,
source annotation block, and normalized task identity. Duplicate identical
#task bullets in the same annotation still create only one task. The property
is the durable processed marker that moves with the task when it is completed,
cancelled, edited, or archived by bob task archive (formerly
bob move-done-tasks, still accepted), so later syncs do not
recreate it and do not write any processed state back into the PDF or sidecar.
Older tasks that already have [highlight_task:: ...] are still recognized for
compatibility, and tasks created by the old ^ht implementation are recognized
by their #^ht-... backlinks. New tasks do not write [highlight_task:: ...]
or ^ht source anchors. If both processed properties and old source backlinks
are removed from a moved or heavily edited task, the legacy normalized text
fallback only covers unchanged text.
Annotation-created tasks are independent tasks, not subtasks of the PDF
reading-status task. Later syncs preserve existing checkbox state and task
properties such as [completion::], [cancelled::], [due::], or edited
priority fields. Completing or cancelling these annotation-created tasks does
not update the PDF marker or reference-note reading status.
Generated blocks use Obsidian block IDs beginning with ^h-. Text highlight
and standalone-note IDs are deterministic content hashes over source PDF path,
page label, annotation kind, and normalized quote/note text, with no sidecar
order. The normalized text strips return-link pill/tag glyphs and underline
** emphasis, beautifies PDF text, and collapses whitespace, so insertion
above, emphasis, and return-link decoration never change an ID. Identical text
on the same page, kind, and page shares one content key with a deterministic
collision disambiguator only within that group; the same text on different
pages or kinds hashes differently. Existing live and removed IDs are reused
one to one for provably unchanged annotations before allocating new IDs, and
are reserved against accidental reuse, so inserting annotations never renumbers
unrelated blocks. Highlight comments are not part of the hash, so editing only
a comment updates the block without changing its ID. Image IDs use the source
PDF path plus image bytes instead, so asset renames and nearby annotation order
changes do not churn the image block. If a previously generated block disappears from the sidecar,
the command keeps the old block ID under ### Removed highlights with a
tombstone message. Absorbed continuation followers never tombstone: the group
keeps the head ^h- ID, and a previously published follower gets a redirect
stub under ### Removed highlights that links to the group. Annotation tasks
keep a per-piece [h::] processed ID (so a < toggle does not recreate the
task) and a 🔖 backlink to the group's head block. Previously leaked
absorbed underlines are never tombstoned. A Merged emphasis stub is emitted
only when the underline ID is in the existing region and is already listed in
Removed highlights or linked from a Markdown file in the vault; it points to
the host block.
marker-mirror and preamble blocks are the
exception: when the existing region shows a vanished block as mirror-shaped or
sitting before the first page heading, sync drops it without a tombstone and
every genuine block ID stays stable. Editing text highlight content itself mints a new block ID; a
task created earlier keeps its original link, which then targets the tombstoned
block under ### Removed highlights — still a valid, resolvable jump.
Reading Tasks In Parent Notes (v2)¶
A newly scanned reference files one ordinary reading task in its
area, project, or inbox note; the reference note itself shows a managed
embed pointing at that task. An existing note stays on this branch when
the locator finds its task outside the reference note (live or archived)
or the body already carries the managed embed. Notes with an in-note
^ref tracker, and trackerless legacy notes, keep the v1 behavior
documented above byte-for-byte.
Birth inserts - [ ] #task #ref [[ref/books/example|Title]]
[created::YYYY-MM-DD] ^ref-example into the resolved parent: the marker
parent through the shared area/project/inbox resolver, or mac_inbox
with a ⚠️ parent '<hint>' is not an open area or project child bullet
when the hint is unresolvable, ambiguous, or terminal. A uniquely located
orphan is adopted before any insertion; ambiguous or archive-open
candidates refuse instead of guessing. IDs allocate as ref-<stem> with
numeric suffixes on destination or archive collision, honoring
BOB_NOW for the created date. Births carry no #hide or [fresh::]
tags, and a birth at a closed status stamps its close date like any
generated close. A repeated run after a successful sync is a no-op.
The reference note renders frontmatter parent from the task's
residence, while stored sync bases and hashes exclude parent, so
refiling the task repoints the parent without reading as a two-sided
edit. The body carries exactly one managed embed,
![[<task-path-without-md>#^<id>]], on its own line one blank below the
H1 (archive tasks address done/...). Sync heals a missing, stale, or
deleted embed; authored material around the managed slot is preserved,
and unrelated authored embeds elsewhere are untouched. Companion audio
anchors directly after the managed embed on new notes.
Located-task status compares the task checkbox against the stored base:
a marker- or frontmatter-only move drives a checkbox line edit, while
incompatible moves on both sides still conflict. [?] stays a
status-neutral overlay. A closed archive task supplies terminal state; a
deliberate marker/frontmatter reopen files a fresh open task in the
archive's source parent (or the inbox with a warning) and may keep the
renamed address when it is free. An open v2 note with no task anywhere
gains no replacement: sync reports open_ref_without_task with
restore-from-git/set-abandoned guidance, and duplicate live candidates
refuse status and parent writes. Scan never mutates done/.
Each PDF writes in parent-first order: destination reading-task and
routed annotation insertions first, then the explicitly authorized PDF
marker write, then the reference note with the actual final ID. The
destination write rereads immediately before touching the line, so a
moved or altered task fails with reading task changed during sync;
rerun before any marker or ref-note write for that PDF. There is no
multi-file transaction: when a later marker or ref write fails after the
parent succeeded, rerunning adopts the existing task and finishes
without duplication.
Destination notes have no git-dirty veto; capture's preimage-checked
writer rebuilds from fresh bytes on bounded retries (preimage races only),
so unrelated user additions survive. V2 default annotation destinations
share reading-task notes and accept unrelated dirty edits there; explicit
routes keep the legacy dirty refusal, and a mixed default/explicit share
keeps the legacy policy. Routed dedup commits only after its destination
write succeeds (or fresh disk proves the task exists), so a failed
destination never consumes a later PDF's follow-up. Before any write, sync
revalidates the selected reading line, the ref-note preimage, the
authorized PDF preimage, and asset preconditions; a changed, deleted, or
ambiguous line fails with the reread error before any destination, marker,
or ref write. The ref-note guard additionally allows edits confined
to the managed embed slot (including deletion and repointing) plus
residence-only frontmatter changes; unrelated body edits still refuse,
and the v1 guard is unchanged. A parent-only move never demands marker
write-back; writing runs refuse before any write when a non-parent synced
field (including lifecycle status and normalization) needs marker
write-back without --write-pdf / --write-pdfs, while dry runs preview
it. Only the opt-in refreshes a stale marker hint to the residence.
Ambiguous births and archive-open candidates refuse status, parent, task,
and embed writes with per-PDF diagnostics (partial scans continue).
Unqualified annotation follow-ups file into the reading task's residence
(or mac_inbox when there is none, never an archive) with full-path
[[ref/<type>/<stem>#^h-…|🔖]] source links, rebased against fresh
destination bytes at execution. Explicit @name routes and v1
local-note defaults are unchanged.
Concise human scans report 📖 N reading tasks created · <dest> (n)
and ↻ N reading tasks updated from actual successful writes only:
adoptions are not creations, unchanged tasks are not updates, and
failures never count. Remaining unmigrated references print as N open
v1 ref tasks · run bob ref migrate-tasks. Verbose, dry-run, and
single-PDF reports name the destination, preview or final ID, adoptions,
and planned or performed line edits. JSON output is unchanged.
MacBook Setup Guide¶
Run these steps on the MacBook. The intended checkout is
~/projects/bob-cli, and the intended vault paths are ~/bob/xlib for PDF
intake, ~/bob/lib for archived PDFs, and ~/bob/ref for generated reference
notes.
Some vaults also have a lit/ folder from an older layout. That is not the
production library path. The defaults remain ~/bob/xlib (intake), ~/bob/lib
(archived PDFs), and ~/bob/ref (generated notes). For a one-off test against
lit, pass --lib-dir lit explicitly.
Install prerequisites if needed:
xcode-select --install
command -v cargo >/dev/null || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
Clone or update bob-cli, then install the local checkout:
mkdir -p ~/projects
if [ -d ~/projects/bob-cli/.git ]; then
git -C ~/projects/bob-cli pull --ff-only
else
git clone git@github.com:bobs-org/bob-cli.git ~/projects/bob-cli
fi
cargo install --path ~/projects/bob-cli --locked --force
bob ref --help
Create or confirm the vault layout:
mkdir -p ~/bob/xlib/chat ~/bob/lib/books ~/bob/ref
git -C ~/bob status --short
In Highlights Pro on the MacBook:
- By default,
bob ref createfiles the PDF, ref note, and reading task together. A kept or queued capture remains in~/bob/xlib/<ref_type>/forbob ref scan -ior the host's local intake timer. - Keep existing PDFs that should sync under
~/bob/lib/<ref_type>/, such as~/bob/lib/books. - Enable autosaved sidecars next to each PDF. Plain Markdown creates
~/bob/lib/books/example.md; TextBundle creates~/bob/lib/books/example.textbundle/text.md. - Use TextBundle export for PDFs where Highlights area/rectangle selections should sync, because plain Markdown export does not include image assets.
- For PDFs with image or area annotations, Highlights may fail to create the
TextBundle the first time even with autosave on. Manually export/create
example.textbundle/besideexample.pdfonce, then confirm it withbob ref scan --dry-run. See Known Highlights TextBundle Creation Bug. - Lock the Highlights Note Format to the sidecar contract above: page headings,
---annotation separators, highlights as blockquote lines, highlight comments as plain paragraphs, standalone notes as plain paragraphs, and image selections as Markdown image links. - Add exactly one marker note as the first standalone
/TextPDF note annotation on page 1. - Put at least
statusandparentin that page-1 standalone note.
Use this marker as a starting point:
- status: ready
- parent: obsidian
- title: Example Title
- topics: [example]
Run the initial checks:
bob ref doctor
bob ref scan --dry-run
bob ref sync ~/bob/lib/books/example.pdf --dry-run
bob ref marker ~/bob/lib/books/example.pdf
MacBook validation checklist:
cargo install --path ~/projects/bob-cli --locked --forceinstalls the local checkout.bob ref doctorreports valid vault/library/ref paths, the xlib intake path and pending count, the pre-scan hook status, marker readability, PDF write coordination (on/offon macOS,n/aelsewhere), Git status, optionalobavailability, and the library health and coverage rows. A missing~/bob/xlibis warning-only becausecreatecreates it on demand.bob ref scan --dry-runlists the expected PDFs under~/bob/liband any pending~/bob/xlibintake moves, reports the intended~/bob/ref/<ref_type>/*.mdtargets, and printswrites: none. Ifscan_failuresis non-zero, inspect the per-PDFplan_errorlines while noting that valid PDFs were still reported.bob ref sync ~/bob/lib/books/example.pdf --dry-runshows the expected marker page/note, sync source, sidecar path, note action, and no writes.- The first real note write creates or updates
~/bob/ref/books/example.mdwithparent,type: "[[ref]]",ref_type: books, pipeline metadata, manual sections, and the managed Highlights region. - For a TextBundle image selection, the first real write also creates
~/bob/ref/books/example.assets/h-<id>.<ext>and embeds it from the generated callout with a vault-relative wikilink. - For an image note (a screenshot path on its own line in a highlight comment),
the first real write copies it into
~/bob/ref/<type>/<stem>.assets/<safe-stem>-<hash8>.<ext>and embeds it under the quote.bob ref sync <pdf> --dry-runreportsimage_notes:/image_note_unresolved:;bob ref doctorlistsunresolved_image_note. - For a continuation highlight (
<on the later piece), the first real write groups that quote with the previous text highlight.bob ref sync <pdf> --dry-runreportscontinuations:/continuation_dangling:. - A second run with unchanged inputs reports
writes: none. - If frontmatter edits or a generated lifecycle-task change require PDF marker
write-back, a dry run reports
pdf_marker_action: would-updatebefore any targeted--write-pdfor bulk--write-pdfsrun. git -C ~/bob status --shortis reviewed before and after each write pass.
The sync model is deliberately asymmetric:
- Marker/frontmatter fields are 2-way and can conflict.
- Highlights, highlight comments, and standalone non-marker notes are PDF or sidecar to reference note only.
- Annotation-created
#taskbullets land in the v1 reference note, the v2 reading task's residence (ormac_inbox), or an explicit@nameroot note, and do not sync completion state back to the PDF marker. - Edits inside the managed
<!-- highlights:begin -->region in Obsidian may be overwritten.
Enable note writes only after reviewing the dry-run output:
git -C ~/bob status --short
bob ref sync ~/bob/lib/books/example.pdf
bob ref scan
scan does not enable PDF marker write-back by default. If a dry run reports
pdf_markers_would_update, review those PDFs and back up the library before
bulk write-back:
bob ref scan --dry-run --write-pdfs
bob ref scan --write-pdfs
For a single PDF, keep using the targeted singular flag:
bob ref sync ~/bob/lib/books/example.pdf --dry-run
bob ref sync ~/bob/lib/books/example.pdf --write-pdf
The intended frontmatter edit workflow is:
$EDITOR ~/bob/ref/books/example.md
bob ref sync ~/bob/lib/books/example.pdf --dry-run
bob ref sync ~/bob/lib/books/example.pdf --write-pdf
If the ref note is tracked in Git, the write-back command may update that dirty
note only when the dirty changes are unstaged frontmatter edits and/or the exact
generated ^ref checkbox toggle, and the file still matches what the command
planned from. Other body edits, managed-region edits, staged changes, untracked
notes, and dirty PDFs are still refused.
See PDFs open in Highlights for how marker writes behave when a PDF is open.
Scheduled Scan¶
The MacBook's staggered cron job runs the full bob ref scan for annotation
sync and also processes the Mac's own xlib/ queue. It does not pull or read
athena's or apollo's queue. See
vault-git-sync.md for the
managed schedule; do not replace it with a LaunchAgent.
On athena and apollo, chezmoi enables bob-ref-intake.timer, which runs
bob ref scan --intake-only two minutes after boot and every two minutes.
Each timer drains only its host's local queue. Check or recover a queue with:
bob ref doctor
bob ref scan -i --dry-run
bob ref scan -i
The intake-only command files queued PDFs and late audio without running the annotation pass or pre-scan hook. The full scan remains the Mac's annotation sync and can also process captures queued locally on the Mac.
Disable One PDF¶
There is no marker key such as sync: false. scan processes every .pdf
under the configured library directory.
To disable one PDF, move that PDF and its sidecar out of ~/bob/lib:
mkdir -p ~/bob/lib-disabled
mv ~/bob/lib/books/example.pdf ~/bob/lib-disabled/
mv ~/bob/lib/books/example.md ~/bob/lib-disabled/ 2>/dev/null || true
mv ~/bob/lib/books/example.textbundle ~/bob/lib-disabled/ 2>/dev/null || true
git -C ~/bob status --short
The existing ~/bob/ref/books/example.md is no longer touched once the PDF is
outside the scanned library. Leave it in place for archival use, or move it
explicitly:
mkdir -p ~/bob/ref-disabled
mv ~/bob/ref/books/example.md ~/bob/ref-disabled/
Do not remove the managed Highlights markers as a disable mechanism. If a PDF is still scanned and its existing reference note lacks the managed markers, the command fails before guessing where generated content belongs.
Backup and Rollback¶
Before first writes, make the vault clean or intentionally checkpointed:
git -C ~/bob status --short
git -C ~/bob stash push -u -m "pre-highlights"
git -C ~/bob status --short
If the current vault changes should be kept as the baseline, commit them instead of stashing:
git -C ~/bob add ref lib
git -C ~/bob commit -m "Checkpoint before highlights sync"
Before enabling --write-pdf or --write-pdfs, keep a PDF backup outside the
write path:
backup_dir=~/bob/backups/highlights/$(date +%Y%m%d-%H%M%S)
mkdir -p "$backup_dir/lib/books"
cp -p ~/bob/lib/books/example.pdf "$backup_dir/lib/books/"
cp -p ~/bob/lib/books/example.md "$backup_dir/lib/books/" 2>/dev/null || true
For a full library backup before broader testing:
backup_dir=~/bob/backups/highlights/$(date +%Y%m%d-%H%M%S)
mkdir -p "$backup_dir"
rsync -a --include='*/' --include='*.pdf' --include='*.md' --include='*.textbundle/***' --exclude='*' ~/bob/lib/ "$backup_dir/lib/"
Inspect note writes:
git -C ~/bob diff -- ref/books/example.md
git -C ~/bob status --short
Rollback a generated reference note:
git -C ~/bob restore -- ref/books/example.md
Rollback a PDF marker write from the backup:
cp -p "$backup_dir/lib/books/example.pdf" ~/bob/lib/books/example.pdf
Conflict Resolution¶
When the marker and frontmatter both changed since the last synced projection,
the command compares both sides to highlights_marker_base. Non-overlapping
field edits auto-merge and dry runs report sync_source: auto-merge. Same-field
conflicts still fail and write nothing. Inspect both sides:
bob ref marker ~/bob/lib/books/example.pdf
sed -n '1,120p' ~/bob/ref/books/example.md
bob ref sync ~/bob/lib/books/example.pdf --dry-run
Choose the PDF marker as the source of truth:
bob ref sync ~/bob/lib/books/example.pdf --prefer marker
Choose the Obsidian frontmatter as the source of truth and write it back to the PDF marker:
bob ref sync ~/bob/lib/books/example.pdf --prefer frontmatter --write-pdf
If the only change is frontmatter or a generated lifecycle-task mark, or a
dry-run auto-merge reports pdf_marker_action: would-update, review the marker
first, back up the PDF, then run the targeted write:
bob ref sync ~/bob/lib/books/example.pdf --write-pdf
For reviewed bulk scan write-back, preview the library and then opt in:
bob ref scan --dry-run --write-pdfs
bob ref scan --write-pdfs
Expected Failures¶
Common failure snippets and fixes:
During scan, marker validation, sidecar validation, managed-region
validation, --write-pdf refusals, and per-PDF write races are reported for the
affected PDF while unrelated valid PDFs may still be planned or written. The
summary shows result: partial-failure and the command exits non-zero when any
PDF fails. Library discovery errors, output path collisions, and dirty target
preflight failures remain hard global failures before writes.
| Message snippet | Meaning | Fix |
|---|---|---|
library directory does not exist or is not a directory |
~/bob/lib is missing or --lib-dir points at the wrong path. |
Create ~/bob/lib or pass the intended --lib-dir. |
no standalone /Text note annotations found on page 1 |
The PDF has no page-1 standalone marker note. | Add the first standalone PDF note on page 1 in Highlights. |
missing required marker key: status |
The marker list lacks status. |
Add - status: ready to the marker. |
unsupported status |
status is not one of ready, next, wip, read, abandoned, or legacy. |
Change the marker or frontmatter status to a supported value. unread is accepted only as a deprecated alias of ready. |
missing required marker key: parent |
The marker/frontmatter projection lacks parent. |
Add a bare marker parent such as - parent: obsidian, or add frontmatter parent such as parent: "[[obsidian]]". |
wikilinks are not supported |
The PDF marker parent uses Obsidian link syntax. |
Remove the brackets in the PDF marker, e.g. use - parent: obsidian. |
'type' is command-managed |
The marker tries to set the generated note type. |
Remove type from the marker; generated notes get type: "[[ref]]" automatically. |
'ref_type' is command-managed |
The marker tries to set the path-derived reference type. | Remove ref_type from the marker; nested library paths derive it automatically. |
invalid marker item on line |
A marker line is not - key: value or * key: value. |
Rewrite the marker as a flat list. |
duplicate marker key on line |
The marker repeats a normalized key. | Keep only one value for that key. |
xlib intake collision(s) detected before writes |
A PDF or sidecar under ~/bob/xlib would move over an existing ~/bob/lib destination. |
Remove, rename, or archive one side deliberately before scanning. |
output path collision(s) detected before writes |
Multiple PDFs would write the same reference note path, such as ref/books/example.md, or the same planned image asset destination. |
Rename or move one PDF before scanning. |
image asset not found |
The sidecar references an image file that is not present relative to the sidecar text file. This usually means the PDF was exported as plain Markdown, or Highlights failed to create the TextBundle for a PDF with image/area annotations. | Manually create/export the TextBundle once, beside the PDF and with the exact PDF basename, so assets/... files are included, then rerun. See Known Highlights TextBundle Creation Bug. |
image asset destination exists with different bytes |
A content-addressed ref/.../*.assets/h-<id>.<ext> destination already exists but its bytes do not match the source asset. |
Inspect the existing asset, then remove or restore it before rerunning. |
refusing to modify dirty vault files |
Git reports dirty touched paths outside the allowed frontmatter and generated-task checkbox write-back case. | Commit, stash, or clean those paths. |
reference note changed but --write-pdf was not supplied |
Frontmatter or the generated task contributes a lifecycle status to the selected projection, so the PDF marker needs an opt-in write. | Back up the PDF, then run targeted sync --write-pdf or reviewed bulk scan --write-pdfs. |
checked PDF task conflicts |
The generated task says status: read, but marker or frontmatter changed status to another value from the stored base. |
Uncheck the task or set the marker/frontmatter status to read. |
cancelled PDF task conflicts |
The generated task says status: abandoned, but marker or frontmatter changed status to another value from the stored base. |
Uncancel the task or set the marker/frontmatter status to abandoned. |
ready, next, or in-progress PDF task conflicts |
The generated task selects ready, next, or wip, but marker or frontmatter changed status to a different value from the stored base. |
Move the task to the intended state or set marker/frontmatter status to match it. |
marker/frontmatter conflict |
Marker and frontmatter changed the same field differently, or the note has no stored base snapshot for a safe merge. | Inspect both sides, then rerun with --prefer marker or --prefer frontmatter --write-pdf. |
changed during sync; rerun |
The note or PDF changed after planning and before writing. | Rerun after closing or pausing apps that may touch the file. |
The file has been changed by another application |
Highlights opened its save-conflict dialog: an uncoordinated bob write raced the open document (the key is off or the write predates coordination). | Prefer Save Anyway so your annotations are kept. The next -w scan restores the marker with no hand edit. Enable highlights.coordinate_pdf_writes after the acceptance test passes so future writes coordinate instead. |
scan completed with ... per-PDF failure(s) |
A recursive scan finished reporting or writing valid PDFs, but at least one PDF had a plan_error or write_failure. |
Fix the named PDFs and rerun; review successful writes before assuming the scan wrote nothing. |
could not acquire the vault maintenance lock |
A writing scan found bob_sync.lock held past the 120 s wait. |
Wait for vault-sync, nightly, or rename to finish, then scan again. |
another bob ref scan is still running |
A writing scan found the scan.lock writer lock held past the 120 s wait. |
Wait for the other scan to finish, then scan again. |
existing reference note is missing the managed Highlights region |
An existing ref note lacks <!-- highlights:begin --> and <!-- highlights:end -->. |
Add both markers around the generated section or move the note aside and regenerate. |
unsupported textbundle sidecar |
The .textbundle has no text.md or text.markdown. |
Verify the bundle contains text.md or text.markdown; manually re-export/create a valid TextBundle beside the PDF if it does not. See Known Highlights TextBundle Creation Bug. |