Capture¶
bob capture writes tasks and bullets, queues bare public links as background
reference jobs, and manages Pomodoro sessions without opening desktop
Obsidian. The job confirmation says reading queue. The background job files
the note when it runs; a queued clip appears in bob ref list after the
host's intake drain (bob ref scan -i). Companion commands
parse in-progress drafts, complete markers and wikilinks, list routes and
tasks, assign block IDs, and name Pomodoros.
Bob Mac Capture is the macOS menu-bar frontend: it owns the hotkey and panel,
then delegates grammar, preview, completion, and vault writes to these bob
commands.
bob capture --help is the concise usage contract. This page is the full
workflow guide.
Contents¶
- Grammar at a glance
bob capture- Routing and insertion
- Global destination declaration
- Scheduling and priority
- Multi-item capture
- Bulk templates
- Authored sub-bullets
- Clipboard
- Saving links to your reading queue
- Task with a requested block ID
- Pomodoro-linked tasks
- Starting the session atomically
- Linking and starting existing tasks
- Picking any open task with ':'
- Going to a task with '/'
- Adding prerequisites with '&'
- Blocking existing tasks with '*'
- Relating existing tasks with '&' and '*'
- Linking tasks with '~' and '~~'
- Completing tasks with '!'
- Routing inbox tasks out
- Plan budget and strict mode
- Starting the next Pomodoro
- Starting a named Pomodoro
- Adjusting the current Pomodoro
- Shifting the current Pomodoro
- Closing the running Pomodoro
- Chaining session operators on one line
- Project notes
- Sub-bullets under existing tasks
- Task Link toggle
- Pomodoro notes
- Section bullets
- Command-line options
- Input, stdin, and JSON output
- Interactive editor markers
bob capture-parsebob capture-relatedbob capture-rewritebob capture-complete- Discovery commands
bob capture-templatesbob capture-task-idbob capture-pomodoro-name
Grammar at a glance¶
One capture item is a parent line plus optional authored child bullets. Blank physical lines split a draft into multiple items; each item is planned before anything is written, and any failure rolls the whole batch back.
| Marker | Meaning |
|---|---|
| Bare public URL | Queue a reference job under mac_inbox when the URL routing policy admits it; -R, --no-ref keeps it a task |
URL @route or @route URL |
Queue the reference under that route (aliases resolve; source explicit) |
URL % |
Queue the reference with companion audio requested (ref.listen true; same parent selection as a bare URL: default, @@route global, or -r forced); exact standalone @ remains a compatible alias |
URL % @route or @route URL % |
Queue the reference with companion audio under that explicit parent (@ also remains a compatible listen alias) |
Bare URL with -r route |
Queue the reference under that route (source explicit) |
@@route |
Shared task destination, anywhere in the draft, for otherwise-unrouted items; a plain declaration also routes default (routeless) refs (source global), while @@route+block-id keeps refs as tasks |
@@route+block-id |
Shared parent-task destination, anywhere in the draft, for otherwise-unrouted items |
@@template: |
Expand one capture item per row from the following {N} template; captures exactly like the expansion |
@route |
Write a task to <route>.md (default route is mac_inbox) |
@route#Section |
Write an ordinary bullet into a matching non-Tasks heading |
@route# |
Write an ordinary bullet into any non-Tasks heading |
@route^block-id |
Ordinary open task with a user-authored block ID |
@route:block-id |
Next-status ([*]) task plus a Pomodoro task link; a future schedule (s:<N>, N ≥ 1) starts Blocked ([?]) and unlinked |
@route:block-id#pomodoro |
Same, linked under a matching named open Pomodoro or a new named future Pomodoro |
@route:block-id=<X> |
Same, and atomically start the selected session; <X> mirrors the se<X> snippet (empty is 25 minutes) |
@route:block-id#pomodoro=<X> / @route:block-id=name |
Same under the named Pomodoro, starting that session (@sase:deep-fix=bugs links under BUGS and starts it, exactly like @sase:deep-fix#bugs=); under the shared suffix rule a link takes every spelling a lone start takes except drop lists (=bugs@3, =3#bugs, =#bugs) |
@route:block-id[#pomodoro][=<X>] with no other text |
Link the existing ^block-id task in route.md into today's ledger (no new task); a task Blocked only by its own prerequisites drops them first; =<X> starts the resolved session atomically |
^route:block-id[#pomodoro][=<X>] with no other text |
Identical execution; ^ is the active-task spelling and completes In Progress and Next tasks |
:<query> |
Incomplete: pick any open task to link (capture-parse needs task_link); accepting inserts @route:block-id, and execution never captures it |
/<query> |
Incomplete: pick any open task to open in Obsidian (capture-parse needs task_goto); accepting opens the task, and execution never captures it |
+query or terminal prose + |
Incomplete: choose a parent task (capture-parse needs task_parent); accepting inserts @route+block-id, and execution never captures the unresolved selector |
lone + |
Opens the same task picker in an editor; Escape leaves the valid +5m Pomodoro adjustment unchanged |
¬e:block-id |
Prerequisite task link: the captured (or explicitly selected) task depends on note.md's ^block-id task; nested notes use &projects/foo:bar, notes with spaces use &"Shopping List":bar |
&, &query, ¬e: |
Incomplete: the prerequisite picker is still open (capture-parse needs task_dependency); execution refuses with a teaching error |
^route:block-id ¬e:id or ¬e:id ^route:block-id |
Add a prerequisite to the existing open owner task; ^route:id is a bare owner alias here and does not create a Pomodoro link |
¬e:block-id with no owner |
Incomplete: a prerequisite is selected but no dependent is named (capture-parse needs dependency_target); type task text or + to choose an existing owner |
¬e:block-id +query |
Incomplete: choose the existing owner with the task_parent picker; execution refuses until the selection is complete |
+query ¬e:block-id |
Incomplete: choose the dependent owner with the task_parent picker |
*note:block-id |
Blocked-task link: the named open task depends on the captured (or explicitly selected) task; nested notes use *projects/foo:bar, notes with spaces use *"Shopping List":bar. Quote * on the shell (bob capture 'Ask Kelly *job:cleanup'): zsh globs it |
X *, X *query, X *note: |
Incomplete: the blocked-task picker is still open (capture-parse needs blocked_task); a line-initial bare * is never this marker |
^route:block-id *note:id or *note:id ^route:block-id |
Make the named open task depend on the existing owner; ^route:id is a bare owner alias and does not create a Pomodoro link |
*note:block-id with no owner |
Incomplete: a blocked task is selected but no owner is named (capture-parse needs dependency_target); type task text or + to choose an existing owner |
*note:block-id +query |
Incomplete: choose the existing owner with the task_parent picker; execution refuses until the selection is complete |
+query *note:block-id |
Incomplete: choose the blocking owner with the task_parent picker |
~note:block-id[\|"label"] |
Relation link: the captured (or explicitly selected) task links to note.md's ^block-id task, with an optional label; nested notes use ~projects/foo:bar, notes with spaces use ~"Shopping List":bar, labels use ~note:id\|see or ~note:id\|"follow-up of". A ~note:id\|\|see spelling stores the identical label and hides it from suggestions once the capture succeeds (a one-off). Quote ~ on shells that expand it |
~, ~query, ~note:, ~~note:block-id, ~note:id\|, ~note:id\|\| |
Incomplete: the relation picker is still open (capture-parse needs relation_target); a label position needs relation_label; execution refuses with a teaching error |
~~note:block-id[\|"label"] |
Reverse relation: the named task links to the captured (or explicitly selected) task; the captured task is the target, so it is always minted; the named task's LINKS line must be editable |
^route:block-id ~note:id, ~note:id ^route:block-id, ^route:block-id ~~note:id |
Relate an existing owner; ^route:id is a bare owner alias here and does not create a Pomodoro link |
~note:block-id with no owner |
Incomplete: a relation target is selected but no owner is named (capture-parse needs dependency_target); type task text or + to choose an existing owner |
! |
Incomplete: pick an open task to complete (capture-parse needs task_complete); accepting inserts !<note>:<block-id>, and execution never captures it |
!fix, !sase:, !"Shopping List |
Incomplete: still typing the completion query (capture-parse needs task_complete) |
!note:block-id with no other text |
Complete an existing open task (capture-parse mode task_complete); bob capture closes it exactly as x!N would, retires its Task Links, and unblocks dependents |
!note:block-id plus anything extra |
Claimed and invalid (invalid_task_complete); a completion must be the whole capture item, never a junk inbox task |
+[N] / -[N] |
Adjust today's current timed Pomodoro by N five-minute units (+5 extends by 25 minutes, - shortens by 5 minutes; the count defaults to 1); the item must contain only the signed count |
++[N] / --[N] |
Shift today's running timed Pomodoro N five-minute units later/earlier, keeping its duration (++3 moves 15 minutes later, -- moves 5 minutes earlier; the count defaults to 1); the item must contain only the operator |
= / =<X> |
Start today's next future Pomodoro now with se<X> timing (= is 25 minutes, =3 is 15 minutes, =-2 is 25 minutes with a 10-minute offset), or restart the running session with that timing; a restart prints an override warning; the item must contain only the start token |
=name / =<X>#pomodoro |
Start the named Pomodoro now with se<X> timing (=bugs is 25 minutes, =3#bugs is 15 minutes); =bugs and =#bugs are one token — write # only when a number comes first (=3#bugs) or the name starts with a digit or reads as a close (=#2fa, =#x2); an open match (whole slug, else prefix) starts in place, a completed match starts a new session with that name ("again"), otherwise a new named session is created and started; while another session runs, a different named target swaps in (an empty <X> keeps the running ledger); the item must contain only the start token |
=[<X>][#<name>]~<K> |
Start without the queued Task Links in <K> (=~2, =3~2,4, =#bugs~2, =3#bugs~1,3); ~ drops, the drop part always comes last, and the item must contain only the start token |
== / ==<X> / ==[<X>]#name |
Retired. For example, `==` is retired: `=` restarts or swaps the running Pomodoro by itself; write `=`; ==3 gives the same message and teaches =3; other claimed forms teach their one-= rewrite |
x[<N>][*<P>][!<M>][~<K>][@<N>] |
Close today's running timed Pomodoro (lowercase x only, with *, !, ~, and @ in any order; <N> may instead be typed last as @<N>, so x!1@2 means x2!1); <N> keeps only those numbered Task Links in progress, *<P> parks those links (normal work without carrying forward), !<M> completes those links, ~<K> drops those links (removed, not carried, not started), a lone 0 means none; a present-but-empty * or ! selects every remaining numbered Task Link after explicit assignments and is represented by park_all/complete_all with an empty lexical list; explicit forms such as x*1/x!1 narrow the action to task 1; one Work Log entry may sit on the close line, several use child bullets |
x [<n>] <entry text…> |
One entry on the close line: with no number it logs to the first task the close works (task 1 for plain x, the first eligible worked link for wildcard closes); a leading number names the task, so only the first token is an index (x 1 3 bugs fixed logs 3 bugs fixed to task 1) |
x + - [<n>] <text> (+ - <detail>) |
Several entries while closing: bullets are numbered all or none — a numbered bullet logs its text to worked task <n>, while unnumbered bullets log in order to the close's worked tasks (one worked task takes them all, otherwise bullet i logs to worked task i); a two-space bullet nests an undated detail under its entry; an inline entry plus bullets fails |
+2 x, x =, x =bugs, x =~2, x wired the lexer = |
Same-line session-operator chain: whitespace-separated session tokens on one line run left to right exactly like blank-line items; an inline entry sits right after its close with operators trailing (x wired the lexer = closes with the entry then starts); x =~2 closes then starts without link 2 of the next lineup, while x~2 = drops link 2 of the running session and then starts |
x = + - 1 wired the lexer |
Close with a Work Log bullet in a chain: the child lines attach to the line's x, so this logs the entry then starts the next session |
@route:block-id=x… with no other text |
Put that existing task into the running session, then close it (own-prerequisite Blocked targets drop those links first; a refused drop keeps today's history link); the = is the suffix separator, the same selection may follow the x, and numbers refer to the post-link lineup |
^route:block-id=x… with no other text |
Identical execution; ^ is the active-task spelling |
<text> @route:block-id=x… |
Create the new Pomodoro-linked task in the running session, then close it; numbers refer to the post-link lineup |
@route^block-id+ |
Create the project note <route>_<block_id>.md; the ^prj task is never linked and the daily note is untouched |
@route^block-id+#pomodoro |
Same; :<id> Task Links go under the named open Pomodoro, creating that named future Pomodoro when missing |
- <task> :<task-id> (project-note bullet) |
Name the task, make it Next ([*], or [?] when scheduled), and link [[<stem>#^<task-id>]] into the current/next Pomodoro, or the #pomodoro one |
- <task> ^<task-id> (project-note bullet) |
Name the task only: - [ ] #task <task> [created::DATE] ^<task-id> |
@route+block-id |
Ordinary child bullet under an existing task |
@route+block-id#section |
Child bullet under an ALL-CAPS section of that task |
@route+block-id with no other text |
Ensure the task is Next and relocate its existing open-Pomodoro Task Link to today's implicit current/next Pomodoro; a task Blocked only by its own prerequisites drops them first |
@route+block-id#pomodoro with no other text |
Ensure Next and move that existing Task Link subtree to the named open Pomodoro, or create that named future Pomodoro and move the subtree there; a task Blocked only by its own prerequisites drops them first |
@route+block-id! with no other text |
Toggle that task's Pomodoro Task Link: link it when unlinked (Ready and own-prerequisite Blocked rise to Next; other Blocked stay unlinked) or unlink it when linked (lane unchanged) |
trailing bare # |
Plain-text note on a Pomodoro (not a routed task) |
s:<N> |
[scheduled::] N days from today; checkbox-bearing captures start Blocked ([?]) |
$ / $<N> |
Write priority level N (bare $ = 1) and roll a scheduled date in that level's window |
%, %N, %header |
Capture clipboard content as child bullets |
# is not one marker. Read it by what it is attached to:
| You typed | Meaning |
|---|---|
remembered the timeout # |
Pomodoro note |
@notes#Ideas |
Bullet under a heading in notes.md |
@notes# |
Bullet under any non-Tasks heading in notes.md |
@cash+id#requirements |
Child under that task's REQUIREMENTS section |
@cash+id#coding with no other text |
Ensure that task is Next and move its Task Link to the CODING Pomodoro |
@sase:deep-fix#bugs |
Pomodoro-linked task under open BUGS, creating future BUGS when needed |
@sase:deep-fix#"SASE V18", =#"DEEP WORK" |
Exact-name literal: the quoted spelling is the name, matched by key equality, never by slug or prefix; straight "…" and curly “…” both lex, \" and \\ are the only escapes, and the token runs through the closing quote |
@sase:deep-fix#bugs=-2 |
Same, starting a 25-minute session with a 10-minute offset |
^sase:deep-fix#bugs |
Link the existing ^deep-fix task under open BUGS; the # names a Pomodoro, and ^ is the active-task spelling |
+5 |
Extend today's current timed Pomodoro by 25 minutes; the + is an adjustment, not a sub-bullet |
-2 |
Shorten today's current timed Pomodoro by 10 minutes |
- |
Shorten today's current timed Pomodoro by 5 minutes; a bare sign is one unit, not an incomplete state |
++3 |
Shift today's running timed Pomodoro 15 minutes later; the doubled sign moves the whole session |
-- |
Shift today's running timed Pomodoro 5 minutes earlier; a bare doubled sign is one unit |
- foo |
Ordinary task text; a bare sign run followed by more text stays prose |
+++ |
Ordinary task text; a sign run longer than two stays prose |
++3 more |
Error: a shift item must contain only the operator; remove extra text, markers, or child lines |
Plan +5 |
Ordinary task text; a count mid-body stays prose |
x |
Close today's running timed Pomodoro; lowercase x is the close token, not task text |
x1,3!2 |
Close keeping tasks 1 and 3 in progress, completing task 2, deferring the rest |
x1~2 |
Close keeping task 1 in progress, dropping task 2, deferring the rest |
x1!2~3 |
Close keeping task 1 in progress, completing task 2, dropping task 3 |
x0 |
Reset a note-free session to the first future placeholder; close (deferring every numbered Task Link) when the session owns a stand-alone note |
x* |
Close parking every numbered Task Link |
x! |
Close completing every numbered Task Link |
x*1 / x!1 |
Close parking or completing only task 1 |
x*!2 |
Close parking every link except task 2, and completing task 2 |
x1* |
Keep task 1 in progress and park every remaining link |
x~ |
Incomplete: type a task number after ~ (capture-parse needs pomodoro_close_task) |
= |
Start the next future Pomodoro (25 minutes); a bare = is a complete start, not an incomplete state |
=3 |
Start the next future Pomodoro for 15 minutes; a counted = token is a start, not prose |
=-2 |
Start the next future Pomodoro for 25 minutes with a 10-minute offset |
=3 more |
Error: a start item must contain only the token; remove extra text, markers, or child lines |
== |
Retired: `==` is retired: `=` restarts or swaps the running Pomodoro by itself; write `=` |
==3 |
Retired: `==3` is retired: `=` restarts or swaps the running Pomodoro by itself; write `=3` |
==#bugs |
Retired; Bob removes one leading = and prints the corresponding rewrite (=#bugs) |
=bugs / =3#bugs |
While another session runs, swap BUGS in and keep the running ledger / start BUGS with fresh 15-minute timing (=bugs and =#bugs are one token) |
=bugs |
Start BUGS (open whole-slug match, else prefix; completed match starts "again"; otherwise create and start), exactly like =#bugs |
=bugs@3 |
Start BUGS for 15 minutes, exactly like =#bugs@3 and =3#bugs |
=bugs~2, =bugs@3~2 |
Start BUGS without link 2, with optional timing, exactly like =#bugs~2 (@ and ~ in either order) |
=bugs more, =deep work |
Claimed: the existing named-start shape error with the multi-word hint, in the typed spelling (=deep-work) |
=bugs=3 |
Existing order error, typed spelling: =bugs@3 or =3#bugs |
=3bugs, =-2bugs, =2fa |
New teaching error: the timing runs into a bare name; write =bugs@3 or =3#bugs; a name that starts with a digit takes # (=#2fa) |
@work:outline=bugs |
Link the existing ^outline task under BUGS and start it, exactly like @work:outline#bugs= |
@work:outline#"SASE V18" |
Same link start under the literally named Pomodoro; the bare =name suffix never takes quotes (@work:outline="SASE V18" stays prose), so quote only after # |
@work:outline=bugs@3, @work:outline=3#bugs, @work:outline=#bugs |
Same link start with timing, under the shared suffix rule; a ~<K> part in a link suffix (@work:outline=~2, =bugs~2) gets a teaching error: a link start takes a timing and a name, not a drop list |
@work:outline#bugs=focus |
New error: name the Pomodoro once (@work:outline=focus or @work:outline#focus=) |
==important== thing, ==foo, == foo, === |
Ordinary task text; == is retired, and a bare == run followed by prose stays prose, which protects Obsidian highlights |
==x |
Error: `==x` is not a close: close the running Pomodoro with `x` |
= foo |
Ordinary task text; a bare = followed by prose stays prose |
=xx, =xa, =xmas, =Bugs |
Named start (=xx/=xa were documented prose; that row changes), exactly like =#xx |
=x, =X, =x2!1, =*, =!, =x wired the lexer |
Unchanged retired-close teaching errors |
=x#bugs |
Unchanged retired error, now teaching x =bugs |
xx, xa, x., x-ray, xylophone |
Ordinary prose; only a plain x or a selection-shaped x… token closes |
Plan x |
Ordinary task text; a mid-body x stays prose |
Plan =x |
Ordinary task text; a mid-body =x stays prose |
X, X2, X post about the launch |
Ordinary task text; only lowercase x closes |
X *job:cleanup |
Ordinary blocked-task capture; capitalizing the first letter is the escape for a task that must start with the word x |
x86 build |
Close claiming task 86 (x86); fails unless that Task Link exists |
x *job:cleanup |
Error: task relationships need a task owner: '*note:block-id' cannot attach to a Pomodoro operator capture (add task text or '@note+task-id' for the dependent) |
Plan =3 |
Ordinary task text; a mid-body =3 stays prose |
x more |
Close with the entry more on task 1, exactly like x plus - 1 more |
x2,3 2 foo bar baz |
Close with the entry foo bar baz on task 2; x 1 3 bugs fixed logs 3 bugs fixed to task 1 |
x 1 |
Incomplete: `x 1` is incomplete: type the Work Log text after task 1 (capture-parse needs pomodoro_close_log_text); x1 keeps only task 1 in progress |
x3,4 + - foo bar + - baz bam |
Close with foo bar on task 3 and baz bam on task 4, exactly like - 3 foo bar plus - 4 baz bam |
x + - 1 a + - b |
Error: bullets are numbered all or none (start each Work Log bullet with the number of the task it logs to; bullets are numbered all or none: `- 1 b`) |
x + - a + - 2 b |
Error: Work Log bullets are numbered all or none, and the first one has no task number; to log text that starts with `2`, number every bullet: `- 1 2 b` |
x3,4 + - a + - b + - c |
Error: `x3,4` works 2 tasks (3 and 4) but has 3 unnumbered Work Log bullets; start each bullet with the number of the task it logs to: `- 3 c` |
x + - |
Plain x; the placeholder row is ignored |
x + - 1 |
Incomplete: `- 1` is incomplete: type the Work Log text after task 1 (capture-parse needs pomodoro_close_log_text) |
+2 x |
Extend by 10 minutes, then close; exactly like +2, blank line, x |
x = |
Close the running session, then start the next future Pomodoro |
x =bugs |
Close the running session, then start the open BUGS Pomodoro; a session switch in one line |
=bugs +2 |
Swap BUGS in, then extend it by 10 minutes; session operators chain left to right exactly like blank-line items |
=~2 |
Start the next session without queued Task Link 2 |
=3~2,4 |
Start the next session for 15 minutes without links 2 and 4 |
=#bugs~2 |
Start the open BUGS Pomodoro without its link 2 |
=3#bugs~1,3 |
Start BUGS for 15 minutes without links 1 and 3 |
x =~2 |
Close the running session, then start the next one without link 2 of that session's own lineup (the links x carried into it) |
=~2 +2 |
Start without link 2, then extend by 10 minutes |
=~ |
Incomplete: type a task number after ~ (capture-parse needs pomodoro_start_task) |
=~2, |
Incomplete: type a task number after , (capture-parse needs pomodoro_start_task) |
=~0 |
Error: task numbers start at 1 |
=~2,2 |
Error: task 2 is listed twice in `=~2,2` |
=~,2 |
Error: expected a task number before `,` |
=~2~3 |
Error: use one `~` list: `=~2,3` |
=~2!3 |
Error: a start can only drop Task Links; `!` completes them when you close (`x!3`) |
=~2#bugs |
Error: write the drop list after the name: `=#bugs~2` instead of `=~2#bugs` |
=~2a |
Error: `=~2a` is not a drop list: write `~`, then comma-separated task numbers (for example `=~2,3`) |
=~ 2 |
Error: write the task numbers right after `~`, with no spaces (for example `=~2,3`) |
=#~2 |
Error: the existing empty-name error on =# |
= ~2 |
Ordinary task text; a bare = followed by separate text stays prose |
=deep-work |
Start the open DEEP WORK Pomodoro now for 25 minutes; whole-slug match wins, else prefix (=deep matches too; =#deep-work is the same token) |
=#"DEEP WORK" |
Start DEEP WORK exactly as spelled: key equality finds the open entry, a completed-only match creates a new session keeping that entry's spelling, otherwise a new session is created with the literal's canonical name; SASE V18 and SASE-V18 stay distinct |
=#"BOB" with BOB BUILD open |
Creates BOB instead of selecting BOB BUILD: a literal never prefix-matches |
=#"DEEP |
Incomplete: type through the closing quote (capture-parse needs pomodoro_name); an unterminated literal is never a mistake |
=3#bugs |
Start the open BUGS Pomodoro now for 15 minutes |
=bugs@3 |
Start the open BUGS Pomodoro now for 15 minutes (=3#bugs typed last; =#bugs@3 is the same token) |
=bugs@3~2, =bugs~2@3 |
Start BUGS for 15 minutes without task 2 (=3#bugs~2; @ and ~ in either order) |
==#bugs@3 |
Retired: `==#bugs@3` is retired: `=` restarts or swaps the running Pomodoro by itself; write `=#bugs@3` (=3#bugs is equivalent) |
=~2@3 |
Start without task 2 for 15 minutes (=3~2 typed last) |
x!1@2 |
Close keeping task 2 in progress and completing task 1 (x2!1 typed last) |
x@2 |
Close keeping only task 2 in progress (x2 typed last) |
x@ |
Incomplete: type a task number after @ (capture-parse needs pomodoro_close_task) |
x2@3 |
Error: lists the in-progress tasks twice (x2,3 or x@2,3) |
==#bugs@ |
Retired: `==#bugs@` is retired: `=` restarts or swaps the running Pomodoro by itself; write `=#bugs@` |
=3#bugs@5 |
Error: gives the timing twice (=5#bugs or =#bugs@5) |
x @2 foo |
Error: write the task numbers right after `x`, with no spaces (`x@2`); to log text that starts with `@2`, put the task number first: `x 1 @2 foo` |
=# |
Incomplete: type a Pomodoro name after # (capture-parse needs pomodoro_name) |
=# bugs |
Error: write the name right after #, with no space (=#bugs) |
=deep work / =#deep work |
Error: join multi-word Pomodoro names with - (=deep-work) |
=bugs=3 |
Error: write the duration after @ or before the name (=bugs@3 or =3#bugs) |
=bugs+2 |
Start the Pomodoro named BUGS+2; + is name charset, so the token stays one named start |
x#bugs |
Error: `x` always closes the running Pomodoro; remove `#bugs`, or write `x =bugs` to close it and then start that Pomodoro; `=bugs` swaps it in without closing the running session |
=3#bugs more |
Error: a named start item must contain only the token; remove extra text, markers, or child lines |
= -2 vs =-2 |
= -2 starts 25 minutes then shortens 10 minutes; =-2 is one 25-minute start with a 10-minute offset |
- - |
Two bare adjustments in a row; lines made only of bare operators are chains, not prose |
@cash^goog-exit+ |
New project note cash_goog_exit.md; the + is a project-note sigil, not a sub-bullet |
@cash^goog-exit+#bugs |
New project note with its :id Task Links under the Pomodoro named BUGS; the # names a Pomodoro, not a task section |
@cash:goog-exit+, @cash:goog-exit+#bugs |
Retired project-note forms; use @cash^goog-exit+ / @cash^goog-exit+#bugs and put :<id> on the task bullets instead |
@sase:deep-fix#bugs+ |
Pomodoro-linked task under the Pomodoro named BUGS+; a trailing + after a #name stays part of the Pomodoro name |
Fix it @sase #now |
Error: a trailing #tag must be appended to an @route token (@foo#bar); #now was retired |
Fix it #now @sase |
Error: a #tag before the route is a retired bullet marker, like any other #tag |
@sase:deep-fix #now |
Error: a trailing #tag must be appended to an @route token (@foo#bar) |
Fix it @sase #n |
Error: a trailing #tag must be appended to an @route token (@foo#bar) |
A # after @route+id follows the item's mode: with no body text it names a
Pomodoro for the task toggle, and once body text is present it names an
ALL-CAPS task section for the child bullet. A # in the middle of the body
stays ordinary text. @route::id is retired; use @route^id for an ordinary
task with a block ID.
Leading @route text is accepted only on an item's first physical line. Later
lines in the same item take trailing markers only. @@... has no such
restriction: a declaration token may appear on any parent or authored child
line. Terminal s:<N>, $<N>, %..., and @... markers configure the whole
item no matter which of its lines they appear on.
bob capture¶
bob capture [OPTIONS] [--] [TEXT]...
Captures tasks, ordinary Markdown bullets, task sub-bullets, or reference links
without requiring desktop Obsidian to be open. Reference links queue for
background capture as described in Saving links.
TEXT is one or more physical lines: the first nonblank line is the captured
parent, and whitespace within each line is
normalized, but line breaks are meaningful -- see "Authored sub-bullets"
below for the bounded hierarchy later lines accept. Task mode writes
- [ ] #task <text> [created::YYYY-MM-DD] when unscheduled, or [?] when a
scheduled property is resolved, and routes to mac_inbox.md by default; bullet
mode writes into a selected non-Tasks section as described below. The created
date uses the local date from BOB_NOW, DATE, or the system clock.
Routing and insertion¶
Automatic routing uses a leading @route text prefix when present; otherwise a
trailing text @route suffix is used. A draft-wide @@route or
@@route+block-id declaration, described below, supplies the same destination
to every item that has no local route or mode marker.
Route names use A-Z, a-z, 0-9, _, and -, are lower-cased, and write
to <route>.md at the vault root. Existing target files, including
mac_inbox.md, prefer a Markdown Tasks section: new captures insert after
the last top-level #task block in that section, or after one blank line below
the Tasks heading when the section has no tasks yet. When
bob task reconcile (formerly bob task-status-hooks, still accepted)
has generated a status-count badge row directly below
the heading, the empty-section insertion point is below that row. Files without
a Tasks section keep the older fallback of inserting after the last top-level
#task block and its indented continuation lines, or appending at EOF.
Global destination declaration¶
A global declaration is a whitespace-free @@<route> or
@@<route>+<block-id> token anywhere in the draft. It may sit at the end of an
item, on an authored child line, in the middle of a line, or on a line by
itself:
Buy milk @@groceries
Parent task
- child detail @@work
First task
Second task @@foo
A physical line whose tokens are all @@... declarations is metadata only. It
is removed before blank-line item splitting, so the historical top-of-draft
spelling still behaves the same:
@@foo
First task
Second task
@@foo
First task
Second task
@@<route> sends every otherwise-unrouted item to <route>.md as a task.
@@<route>+<block-id> inserts every otherwise-unrouted item as its own direct
child beneath that task, in source order; authored children stay nested under
their own capture parent. Route and block-ID validation match @route and
@route+block-id.
The declaration is metadata, never capture text: it is absent from item counts,
semantic capture text, preview bodies, and note contents. A draft that contains
only a declaration fails with an actionable "add a capture item" error. @@ is
reserved for this grammar. A second declaration fails with a duplicate global
destination error naming both lines:
@@foo
Buy milk @@bar
Unsupported forms such as @@foo#Ideas, @@foo^id, @@foo:id,
@@foo^id+, and @@foo:id+ are errors
rather than literal task text. Wrap @@... in inline code to keep it literal.
An item-local marker still wins for that item. If the same item also owns the
declaration, bob capture-parse reports a global_destination_shadowed
warning and bob capture surfaces the same warning:
Buy milk @dev @@groceries
Other task
Here Buy milk routes to dev.md; Other task inherits groceries.md.
Do not combine a textual @@ declaration with --route, --section, --task,
or --task-section. Clipboard, scheduling, priority, dry-run, formatting, and
stdin remain composable.
Each real item resolves in this order:
- An item-local route or mode marker wins. That includes
@bar,@bar+b-id,@bar#...,@bar^...,@bar:...,@bar^id+, and a trailing bare#. - Otherwise inherit the complete
@@...declaration. - Otherwise keep today's
mac_inbox.mdtask default.
A local override is not a duplicate-route diagnostic merely because a declaration exists. Later items still see earlier staged edits to the same note or parent, and any failure rolls every target back.
Scheduling and priority¶
Append a lowercase s:<N> token to schedule the capture N days from today.
It is recognized only in the terminal token region and may appear on either
side of a trailing route marker. The token is removed from the body and adds
[scheduled::YYYY-MM-DD] after the created stamp. Checkbox-bearing captures
with a resolved scheduled property start Blocked ([?]), including s:0.
Ordinary bullet and sub-bullet captures still render without a checkbox.
N is a non-negative integer. An offset that cannot be added to today
inside the representable calendar — for example s:9999999999 — fails
before any write. Human output is
bob capture: scheduled offset is out of range (exit 2). With
--format json, stdout is
{"ok": false, "error": "scheduled offset is out of range"}. A $<N>
roll that lands outside that calendar uses the same error.
Append a $ or $<N> token to write a priority level, where N selects
the Nth level in the bullet-property config file. A bare $ means $1.
Bob looks for that file at
BOB_CONFIG_FILE, then $XDG_CONFIG_HOME/bob/config.yml, then
~/.config/bob/config.yml — the same file the Obsidian picker reads. A missing
or unreadable file is an error; $<N> has no built-in default levels. The
currently deployed file uses four levels:
| N | Label | Value | Day window |
|---|---|---|---|
| 1 | P1 |
high |
2-7 |
| 2 | P2 |
medium |
8-30 |
| 3 | P3 |
low |
31-90 |
| 4 | P4 |
lowest |
91-365 |
The token writes [priority::<value>] and rolls a random
[scheduled::YYYY-MM-DD] date inside that level's day window. Each capture
rolls independently, so a --dry-run preview differs from the real capture
unless BOB_PRIORITY_ROLL_SEED is set. A task with no priority field is
implicitly P0 (do it now, no roll), so there is no $0. An explicit s:<N>
wins the scheduled date; $<N> still writes the priority. A rolled $<N>
date also writes a 🗓️ **SCHEDULE LOG** child bullet with one dated 🎲 …
entry recording why, byte-for-byte matching what the Obsidian
Ctrl+Shift+P picker writes for the same level with no reason prompt:
- [?] #task someday idea [created::2026-08-07] [priority::lowest] [scheduled::2026-11-06]
- 🗓️ **SCHEDULE LOG**
- *2026-11-06* — 🎲 P0 → P4 · in **91** (91–365) days
The bold number is the exact relative day offset selected for that scheduled date. The parenthesized range is the configured priority window.
A $<N> s:<N> capture writes no entry, since s:<N> wins the scheduled date
and no roll happened. A $<N> past the configured level count is a usage
error (exit 2). The message is
$<N> is not a configured priority level; use $ or $1 through $<count> (<labels>).
Any resolved scheduled property makes a checkbox-bearing capture start Blocked
([?]); bob task reconcile still reconciles tasks whose schedules are
edited later.
$/$<N> is recognized only in the terminal marker region, so a mid-line
amount stays text: Pay $3 to Bob. In a shell, single-quote the token
(bob capture buy milk '$2'); an unquoted or double-quoted $2 is expanded
by the shell.
To keep a trailing dollar amount as task text, escape it the same way as
\& / \*: Pay Bob \$3 writes body Pay Bob $3 with no priority, and
Pay Bob \$3 $2 @cash writes body Pay Bob $3 at level 2 routed to
cash. Elsewhere \$ stays verbatim (cost \$3 today keeps the
backslash).
Multi-item capture¶
One or more blank or whitespace-only physical lines split TEXT into ordered
capture items. Leading, trailing, and repeated separator runs are ignored, so
a draft with at least one nonempty item is valid even if it starts or ends
with blank rows. Each item uses the normal capture grammar independently:
the first nonblank line is that item's parent, later contiguous authored
bullet rows belong only to that item, and terminal s:<N>, $<N>,
%..., and @... markers configure only that item.
Bob plans the whole batch against in-memory note and daily-ledger snapshots
before writing anything. Later items see earlier planned edits to the same
target, so order, insertion points, duplicate block-ID checks, sub-bullet
lookups, and Pomodoro links match a successful sequential capture. If any
item fails to parse, read the clipboard, validate, stage, or replace, Bob
leaves notes, ledgers, and newly-created clipboard files at their original
state. --dry-run uses the same planner with the commit step disabled.
Before replacing a note, Bob checks that its on-disk contents still match the planning snapshot. The check runs before any temporary files are created, again after those files exist, and once more immediately before each replacement. A mismatch refuses the batch with exit 1 and one of these messages:
refusing to overwrite <path>: note changed on disk after planningrefusing to overwrite <path>: note was deleted on disk after planningrefusing to overwrite <path>: note was created on disk after planning
Reload the affected note and rerun the capture. The checks narrow the race
with another writer; they do not lock the file. If a later replacement fails,
Bob restores notes it already replaced in this batch. A successful rollback
appends ; rolled back earlier note writes. A failed rollback names the
file: rollback of <path> failed: ….
Reference jobs are written before note files. If the note write fails, Bob
removes the jobs it just created and appends
; removed ref jobs created by this capture. A later clip failure writes a
fallback inbox task for that link and leaves the other captured items in
place.
Same-line session-operator chains split the same way: a physical line holding
only whitespace-separated session operators (+2 x) yields one item per
token, in order, exactly like the same operators on blank-line-separated
rows. See Chaining session operators on one line.
Single-item success JSON keeps the legacy shape. A multi-item success keeps
the first result in the legacy top-level fields and adds an ordered
captures array containing every per-item result. When a @@ declaration is
present, success JSON also adds an optional top-level global_destination
object with mode, route, and optional block_id. The declaration never
creates an extra result. Human output numbers batch items as 1/N, 2/N,
and so on, and prints a compact global foo.md (or global foo.md · under
^a-id) summary when a declaration was used.
Bulk templates¶
A draft whose first nonblank line is @@template: expands one capture item per
row. The planner receives the same text that bob capture -E prints, so a
template capture has the same results, ordering, and all-or-nothing writes as
capturing that expansion directly.
Editors that run capture-rewrite, including the Mac
capture panel, turn a typed @@@ at the very start of a draft into the template
header and place the caret on the next line.
@@template:
Apply to {0}! @job^apply-{1}
- **URL**: {2}
Foobar foo http://foobar.com/career/some_foo_job
"Two Sigma" two-sigma https://www.twosigma.com/careers/some_role
This expands to two ordinary items, preserving the authored child bullet:
Apply to Foobar! @job^apply-foo
- **URL**: http://foobar.com/career/some_foo_job
Apply to Two Sigma! @job^apply-two-sigma
- **URL**: https://www.twosigma.com/careers/some_role
The template is one item: a parent line and optional authored child bullets.
It ends at the first blank line after the header. Blank lines after that
separate rows and are ignored. Each nonblank row supplies one capture, except
a template using only {0} takes one capture per space-separated value, so
values may share a line or sit one per line; rows cannot have child bullets
yet.
| Draft part | Rule |
|---|---|
| Header | @@template: alone on the first nonblank line; case-insensitive |
| Placeholder | {0} through {999}; indices must start at zero and have no gaps; repeats and any order are allowed |
| Literal braces | Write {{ for { and }} for } |
| One placeholder | Split the row on spaces and tabs; each value is its own row, numbered left to right then top to bottom; quote values containing spaces as below |
| Multiple placeholders | Separate fields with spaces or tabs; quote fields containing spaces with "..." or “...”; \" and \\ are the only escapes inside quotes |
A {0}-only template takes one capture per value:
@@template:
foo: {0}! @sase:foo-{0}
bar baz bam
This expands to three ordinary items, exactly like one value per line:
foo: bar! @sase:foo-bar
foo: baz! @sase:foo-baz
foo: bam! @sase:foo-bam
Values are inserted once and are not scanned again. Every ordinary marker in
the template applies independently to each row, including routes and sections,
s:<N>, $<N>, & and * dependencies, completion (!note:id), task
links, and reference URLs. Duplicate block IDs across rows are refused by the
normal planner. A row with an empty or incomplete picker value gets the
existing capture error, scoped to that template row.
Use @route:{1} when each row should link its task under the one selected
Pomodoro; the normal plan.max_links warning applies. Use @route^apply-{1}
when rows should create plain Ready tasks that you link as you work them.
Templates refuse other @@ declarations, clipboard capture, and Pomodoro
session operators. This includes % markers even with --no-clip, --clip,
whole-item forms such as =, x, +N, and ++N, and start/close suffixes
on task markers. URL % remains allowed when it is the reference listen
marker. In a {0}-only template, keep the marker on its URL by quoting the
row ("https://example.com/one %") or by putting % in the template
({0} % covers the all-listen case); an unquoted
https://example.com/one % row is two values and refuses naming row 2. A
template is limited to 1,000 rows and 1 MiB of
expanded text; it is refused rather than truncated when it exceeds either
bound.
Template errors use stable codes in JSON mode. Execution reports the first problem in draft order and writes nothing:
| Code | Example message |
|---|---|
template_header_misplaced |
Write @@template: alone on the draft's first line |
template_missing_rows |
Add a blank line, then one row per capture; with the blank separator present: add one row per capture (: N values separated by spaces, : one value per capture, separated by spaces or new lines, or bare) |
template_needs_separator |
Add a blank line between the template and its rows |
template_missing_placeholder |
Add at least one placeholder such as {0} |
template_invalid_placeholder |
Use {N}; write {{ for a literal brace |
template_placeholder_gap |
This template uses {0} and {2} but not {1}; number placeholders without gaps |
template_row_bullet |
Rows can't have child bullets yet; put each capture's values on one row |
template_unterminated_quote |
Quoted field has no closing quote |
template_row_arity |
Template row 3 (line 7) has 4 fields but the template uses {0}–{2} |
template_global_declaration |
A template draft can't declare @@ destinations; put the destination in the template line |
template_clipboard |
A template can't capture the clipboard: % would repeat once per row |
template_session_operator |
A template can't drive the Pomodoro session; capture =, x, +N, and ++N on their own |
template_too_large |
Expanded template exceeds the 1 MiB limit |
template_row_incomplete |
capture-parse reports an incomplete expanded item on its row |
Success JSON adds a top-level template summary (line, field_count, and
row_count) and a template_row (row, line) on each result item. Human
output prints a dim row-count and source-line summary before the numbered
items. -E, --expand prints the expanded text and exits before vault access
or planning; JSON mode returns it as expanded with the same template
summary. On drafts without the header, -E returns the original text.
Saved templates¶
Every successful real @@template: capture saves its template block to a
bob-owned shelf, so the next wave reuses it instead of retyping it. Only the
block is saved, never the rows: rows carry one-off values that must not
replay.
Saving happens after the vault write succeeds, once per invocation (not once
per row), and only for real captures: --dry-run, -E, failed or refused
captures, drafts without a header, and forced -r/-s/-t destinations
never touch the shelf. capture-parse, capture-complete, and
capture-rewrite never touch it either. Saving is best-effort and never
fails a capture: the exit code stays 0, and a shelf failure prints
bob capture: warning: template not saved: <reason> on stderr with
template.not_saved in JSON.
The shelf lives at
${XDG_STATE_HOME:-~/.local/state}/bob-cli/capture/templates.json.
BOB_CAPTURE_TEMPLATES_FILE overrides the exact file path. The directory is
0700 and the file is 0600. Entries are keyed by the first 12 hex digits
of SHA-256 over the canonical block text (trailing spaces and tabs stripped,
lines joined with \n), ordered most recently touched first, with
first_saved_at, last_used_at, and a use_count of successful captures.
capture.max_saved_templates in the Bob config caps the shelf from 0 to 36
and defaults to 36. Every write trims to the cap by dropping the least
recently used entries. 0 turns saving off: bob never writes the shelf
except for an explicit delete or clear, and existing entries stay listed
and usable. An invalid value or a config read error prints one stderr warning
and uses 36.
A save may carry the app's checkout origin: when the used block differs from the checked-out entry, the edited version replaces the original in the same locked write and keeps its history, so edits never pile up near-duplicates. Abandoning a checked-out draft leaves the shelf untouched, and the copy on disk is never deleted by picking it.
Use bob capture-templates to manage the shelf. Bare invocation and list
show the newest templates first. show TEMPLATE prints the ready-to-insert
draft (@@template: header, definition, and an empty rows area) without
styling, so it can be piped or copied directly. TEMPLATE is a 1-based list
index of one or two digits, or the content id shown by list.
bob capture-templates
bob capture-templates show 1
bob capture-templates save -f json < template-draft.txt
bob capture-templates delete 1 9c1f0e2a7b3d
bob capture-templates clear
save accepts TEXT arguments joined with spaces or a complete draft on stdin.
It checks the definition (header, separator, placeholders, and block-level
markers) while ignoring missing rows and row errors. Saving a definition does
not increment use_count. The hidden --template-origin ID supports the Mac
client's checkout flow; it replaces an edited origin atomically while keeping
its history. delete is idempotent and reports unresolved selectors in JSON's
missing array. clear also removes a corrupt quarantine copy and is allowed
when the shelf was written by a newer schema.
list -f json returns schema_version, capacity, saving, the resolved
file, and entries with their canonical text, insertion draft, UTF-8 byte
cursor, field_count, line_count, route, capture-parse spans, and usage
timestamps. The Mac client can render and insert those values without parsing
the capture language. Shelf and config warnings appear in warnings.
Authored sub-bullets¶
TEXT may carry authored bullets beneath the parent line:
Prepare the launch review
- Confirm the rollout owner
- Send the owner the final date
- Attach the final checklist @work $1
- Verify the links
Within one capture item, every physical line after the first must be a
first-level Markdown item at column zero or a nested Markdown item prefixed
by exactly two ASCII spaces. At either level, -, *, or + must be
followed by at least one space or tab. The source marker and separating
whitespace are stripped, and each item is rendered with the canonical
- <body> marker. First-level items render one indentation unit beneath the
captured parent; nested items render two units beneath the parent and attach
to the nearest preceding nonempty first-level authored item. The unit matches
the target note's dominant tab-or-two-space child indentation, with a tab for
a fresh note:
- [ ] #task Prepare the launch review [created::2026-08-14] [priority::high]
- Confirm the rollout owner
- Send the owner the final date
- Attach the final checklist
- Verify the links
A marker with nothing after it (- or - alone) is a harmless
placeholder and produces no child; this keeps interactive editors safe while
a row is only half-typed. Placeholder rows do not clear the current
first-level owner, so a later nested item still attaches to it. A true blank
row ends the item and starts the next item at the following nonblank line.
One-space, three-or-more-space, tabbed, wrapped, or ordinary continuation
prose is a usage error naming the physical line number. A nonempty nested
item before any first-level authored item is an orphaned_nested_bullet
error, and an item that becomes empty only because its whole body was a
capture marker is rejected the same way. Every recognized
terminal s:<N>, $<N>, %..., and @route/@route#/@route^block-id/
@route:block-id/@route+block-id/@route^block-id+
marker is an item-wide directive no matter
which physical line in that item it appears on -- as shown above, @work and
$1 on the last child still route and prioritize that item -- and is
stripped from the rendered line it was typed on. A second line in the same
item that resolves the same marker slot (two routes, two schedules, two
priorities, or two clipboard markers) is ambiguous and fails with a usage
error before anything is written. Only the first physical line of an item
keeps the established leading @route text form; later lines compose trailing
markers only. A sub_bullet
capture (@route+block-id) nests the newly captured line under the selected
existing task, then preserves both authored levels relative to that new line.
Authored children render before clipboard children and the priority schedule log, so the full block order is: parent line, authored children, clipboard children, then the schedule log.
Clipboard¶
Append one of these whitespace-delimited terminal markers to capture clipboard content beneath the new task or bullet:
%captures the live clipboard once without a header.%<positive integer>captures exactly that many values without headers: the live clipboard first, followed by recent history newest first. For example,bob capture research links %3captures three values. For ordinary non-reference task text,%1is equivalent to%, leading zeroes are accepted, and%0stays literal.%<nonnumeric header>captures the live clipboard once under an explicit header. Headers accept letters, digits,_, and-, render in uppercase, and replace underscores with spaces; for example,%build_logrenders**BUILD LOG:**.
The grammar is context-dependent for an eligible URL-only reference: exact
standalone URL % requests companion audio before clipboard parsing, so it
does not read the clipboard even in live preview's --no-clip mode. Use
URL %1 or --clip URL to explicitly request clipboard capture for a URL;
non-reference text %, %N, and %header keep their clipboard meanings.
The marker composes with s:<N>, $<N>, ordinary routes, bullet routes,
ID-only task routes, and Pomodoro routes in either terminal order. Invalid
%... tokens and % tokens in the middle of the body stay literal. A counted capture requires every
requested entry to read, normalize, classify, and plan successfully;
insufficient or invalid history aborts the capture instead of writing a
partial result.
Clipboard content is rendered according to its shape:
- One text line up to 1,000 characters becomes an inline child bullet.
- Two to ten flat text lines become child bullets, nested beneath an explicit header when one is present.
- One to ten top-level unordered Markdown list items using
-,*, or+become child bullets. Their source list markers and separating whitespace are removed while inline Markdown, including checkbox text, is preserved. - Absolute file paths (including quoted paths,
file://URIs, and~/...) become attachments. Images are copied toimg/and embedded at 400px; other files are copied tofile/and linked. - Long, indented, blank-line-separated, or other Markdown-structured text is
saved verbatim as
file/clip-YYYYMMDD-HHMMSS[-slug].mdand linked without the.mdsuffix. Ordered, nested, wrapped, mixed, or empty-item lists use this snippet fallback instead of being partially normalized.
Each value in a counted history capture is classified independently, so limits such as the ten-attachment maximum apply per entry. All resulting lines are flattened in source order as direct, headerless children; entries receive no index labels, container bullets, or separators.
Clipboard children use the target note's dominant tab-or-two-space indentation and fall back to a tab, matching the sub-bullet capture rule.
Without a header, one item is written as a direct child and multiple items are written as direct sibling children:
- [ ] #task Parent
- clipboard text
- [ ] #task Another parent
- first line
- second line
For example, a clipboard containing this flat Markdown list:
- first copied item
* second item with **inline Markdown**
+ [ ] third checkbox item
is normalized beneath the captured parent without doubling the source markers:
- [ ] #task Parent
- first copied item
- second item with **inline Markdown**
- [ ] third checkbox item
An explicit header stays inline for one item and owns a nested list for multiple items:
- [ ] #task Parent
- **BUILD LOG:** clipboard text
- [ ] #task Another parent
- **BUILD LOG:**
- first line
- second line
Attachment names are sanitized for Obsidian links. An existing identical file
is reused; differing content receives an eight-character SHA-256 suffix. Up to
ten attachment paths may be pasted at once. Clipboard text must be non-empty
UTF-8 without NUL bytes; binary clipboard contents should be represented by a
copied file path. Clipboard and note edits are planned before anything is
written, and newly created clipboard files are removed if the note write fails.
--dry-run performs the same planning but creates no directories or files.
Use -c, --clip[=HEADER] to force clipboard capture without a marker. Bare
--clip captures without a header, while --clip=build_log supplies an
explicit header. Both forms force a single live value and keep % tokens in
the captured text literal. A numeric header can be requested unambiguously with
--clip=20; use -n, --no-clip when a genuine trailing %N or other %...
token should remain literal. --clip and --no-clip conflict.
Saving links to your reading queue¶
A capture item that is nothing but one bare public link becomes a reference
item (kind: "ref") instead of an inbox task. bob capture
'https://example.com/essay' saves a background job under mac_inbox. Add
exactly one ordinary leading or trailing @route (URL @sase or
@sase URL), a bare URL under a plain @@sase declaration, or a bare URL
with -r sase to file it under that route instead: aliases resolve to the
canonical route (so URL @bob-cli files under bob). The body holds only
the URL; the input token and its canonical parent stay distinct, and the
parent source is explicit, global, or default. A new link prints
queued example.com/essay → sase with the detail
new to your library · reading task lands in sase.md (or → mac_inbox with
file it later for the default inbox). That line means the job was saved.
The reading queue in bob ref list is the reference note. The background job
files it when it runs; a queued clip appears after the host's intake drain
(bob ref scan -i). Quote the URL when the shell would treat # as a
comment.
Append a standalone % immediately after the URL (the next
whitespace-delimited token on the same line, one or more spaces/tabs) to
request companion audio: https://example.blog.com/somepost % clips like
bob ref create <URL> -P <resolved-parent> -L. The shipped standalone @
spelling remains a compatible alias. URL % @sase and @sase URL %
combine listen with an explicit parent; URL % alone uses the default,
@@route global, or -r forced parent. Do not attach the directive to the
URL, or put it in a query, fragment, encoded %25/%40, or userinfo — those
bytes stay part of the URL. One optional <...> wrapper around the URL is
still accepted. Exact URL % takes reference precedence over clipboard
parsing; URL %1, --clip URL, and -R 'URL %' explicitly keep the
clipboard/task path. Outside an admitted reference shape, % keeps its
clipboard meaning, and --no-clip leaves ordinary clipboard markers
literal. In live preview, --no-clip does not suppress a recognized URL
reference listen marker. A non-URL bare @ and prefixes such as @sa
remain route completion, and completing a claimed listen marker never
replaces it (a separate @sa after URL % still completes the parent). A marked item
prints queued … · Listen with · companion audio requested; it needs a
configured highlights.listen_command. Inline intake files the PDF and MP3
together, and a queued capture is paired by bob ref scan -i or the local
timer. Unlike an explicit ref create -L, the shorthand never
adds audio to existing captures: a marked duplicate, pending job, or
library hit queues nothing and shows no new listen job was queued · to
attach audio run: bob ref create <URL> -P <parent> -L.
Anything more — an extra word, #tag, s:, $<N>, a clipboard marker, an operator, a child
line, a @@route+block-id declaration, task/destination flags beyond -r
(-s -t -S -c, --task-ref), or a second @route — keeps the item a task.
-R also keeps it a task. Hosts without a dot (http://go/x, localhost),
IP literals, and excluded hosts (see the
URL routing policy) stay tasks too. An unresolvable
route fails the item with near-miss hints and the whole batch rolls back; it
never silently creates a new note. A pasted blank-line-free block in which
every line is a bare URL splits into one item per line, and each line is then
classified on its own, so mixed lists queue the links and keep the rest as
tasks. A plain @@sase routes default (routeless) refs to sase while an
explicit local @route keeps local precedence; @@sase+block-id keeps the
item a task. Ordinary task @route capture does not resolve aliases.
Submit never touches the network and returns at once: it writes a durable
ref job staged with the resolved parent and a detached background worker
clips it through the same engine as bob ref create. If the clip fails, the
link falls back to the parent note (<parent>.md) with exactly the URL task
line capture previewed, plus a ⚠️ child with the reason and a
bob ref create <url> -P <parent> retry command. bob ref jobs lists
pending, clipping, and stuck jobs plus finished jobs from the last 7 days
(--all shows older finished jobs); see ref jobs.
--dry-run is offline and writes nothing, including no job. Its headline and
one detail line follow the library verdict (with the resolved parent):
| Verdict | Headline | Detail |
|---|---|---|
not_found |
would queue <display> → <parent> |
new to your library · reading task lands in <parent>.md (· file it later for mac_inbox) |
legacy |
would queue <display> → <parent> |
in your library as a legacy note (<path>) · a fresh copy will be clipped |
unknown |
would queue <display> → <parent> |
library check unavailable: <message> · the clip still dedupes |
in_library |
already in library <path> |
title and reading state, when known |
in_intake |
already queued <path> |
waiting for bob ref scan |
clipping |
already clipping <display> |
a pending ref job has this link · bob ref jobs |
duplicate |
duplicate <display> |
same link as an earlier item |
A real submit uses queued in place of would queue. The not_found
detail becomes clipping in the background · reading task lands in
<parent>.md (· file it later for mac_inbox); legacy and unknown keep the
details above. placement is "queued" for not_found, legacy, and
unknown (a job will be written) and "unchanged" for the other verdicts
(no new job). Reference items report routed: false, route: null,
route_label: "", and task_line: "", plus an additive ref object with
the classified URL, the library verdict, the canonical parent: {route,
label, kind, source, alias} (including already-known URLs), the staged
job, and the parent fallback on queued items. ref.job stays null
until the commit, so every dry run has job: null. Marked references add
ref.listen: true; when the URL is already known, ref.listen_hint gives
the explicit bob ref create ... -P ... -L command because this shorthand
never attaches audio or upgrades an existing job.
bob capture-parse reports ref_parent: {token, source} on ref items
outside needs (explicit token, inherited global route, or mac_inbox;
source explicit|global|default), and a local route token keeps its ordinary
route span. An eligible URL listen reference also reports
ref_listen: true and a ref_listen span over the standalone % (or
compatible @) token; both additions are omitted when listen is false.
Route completion also matches project_name_aliases with
match_kind: "alias" after canonical prefix matches.
Opt out per capture with -R, --no-ref, or per entry point with
highlights.url_routing.capture: false (silent; an invalid config warns and
also keeps the link a task).
Task with a requested block ID¶
Use a leading or trailing @<route>^<block-id> marker to create an ordinary
open task with a requested Obsidian block ID, without creating or modifying a
Pomodoro task link. For example,
bob capture '@dev^foobar' 'Some ordinary task.' writes:
- [ ] #task Some ordinary task. [created::2026-07-10] ^foobar
The route is lower-cased, and the destination may be an existing note or a
missing note that can be created like any ordinary routed task. The task
remains an ordinary [ ] task when unscheduled, or starts [?] when scheduled:
priority and scheduled properties render before the final ^block-id, and the
JSON kind stays "task". The block ID uses the same validator as other Bob
task block IDs (letters, digits, and -).
Before a real capture or --dry-run reports success, Bob rejects an ID that
already appears anywhere in the destination note, leaving the note unchanged.
This form never reads, validates, creates, or writes today's daily note; an
invalid or missing BOB_DAY_FILE has no effect. The retired
@<route>::<block-id> spelling is no longer accepted; use
@<route>^<block-id> instead.
#now is retired¶
The #now weekly-bet tag was retired: the Next lane took over what it
tracked, and Today is read from the ledger at render time (see
docs/plan.md). A trailing #now is now the ordinary trailing-tag error
like any other #tag, and a #now in the body stays plain text. Existing
vault lines that still carry #now keep working — the text is ignored.
p:<N> is retired¶
The p:<N> priority marker is retired. Write $<N> instead (a bare $
means $1). A typed p:2 fails with
`p:2` is retired; write `$2` for priority level 2 (a bare `$` means `$1`)
and capture-parse reports retired_priority_marker on that token. A
malformed p:x was never a marker and stays ordinary text.
Pomodoro-linked tasks¶
Use a leading or trailing @<route>:<block-id> marker to create a
Pomodoro-linked next task. For example,
bob capture '@dev:foobar' 'Some foobar task.' writes:
- [*] #task Some foobar task. [created::2026-07-10] ^foobar
It also adds [[dev#^foobar]] as a child bullet of an eligible open Pomodoro
in today's daily note. The route is lower-cased and may contain letters,
digits, _, and -. The block ID may contain letters, digits, and -,
the same rule as @route^id. Scheduled offsets work in either
terminal order; a scheduled Pomodoro-linked task starts [?], and the block ID
remains the final task token after any [scheduled::YYYY-MM-DD] property.
A newly captured task that is Blocked after the gesture is never linked (docs/task-dependencies.md
§13.5): a future s:<N> (N ≥ 1), open & prerequisites, or a blocked
destination project lands the new task [?] (plus #prj_is_blocked in a
blocked project) with no Task Link. The open prerequisites rise along the
task's edges instead, and JSON reports link_guard (reasons with the §13.9
kinds, promoted with route#^block identities, and a one-sentence
detail); human output prints the same sentence after the task line.
The daily note is selected from BOB_DAY_FILE when set, otherwise from
<bob-dir>/YYYY/YYYYMMDD.md using BOB_NOW or the local date. Within its
Pomodoros section, capture prefers the single open top-level entry with a
recognized bold or legacy time range; when there is no timed entry, it uses the
first open top-level entry. Completed and nested entries are ignored. Multiple
open timed entries are treated as an invariant error for this implicit
selection. The link is inserted after the selected entry's existing children
and reuses their indentation when possible.
An optional #<pomodoro> component names the target: @sase:deep-fix#bugs
links under the open Pomodoro named BUGS and leaves the current Pomodoro
alone. The selector is a slug — ASCII-lowercase, internal whitespace collapsed
to - — using A-Z, a-z, 0-9, and & ' ( ) + , . / -. That is the
task-section selector character set plus +. Matching is a whole-slug match in
document order, else the first slug-prefix match, so #bugs and #bug both
reach BUGS, and #memory still reaches MEMORY when MEMORY WORK appears
first. Duplicate names resolve to the first open match; give the second a
distinct name to target it. A typed # with an empty name is incomplete — it
never falls back to "any Pomodoro". An existing open match is explicit, so it
resolves even when the ledger has more than one open timed entry. Quote the
name after # (@sase:deep-fix#"SASE V18", with straight or curly quotes) to
spell it exactly: the literal matches by key equality, never by slug or
prefix, so SASE V18 and SASE-V18 stay distinct and #"BOB" creates BOB
even with BOB BUILD open. An unterminated literal (@sase:deep-fix#"SASE V)
is incomplete, never a mistake.
When the selector has no matching open entry, capture treats the authored
component as the name of a new future Pomodoro. The visible name uses the same
canonicalization as bob capture-pomodoro-name: whitespace is collapsed,
ASCII letters are uppercased, and allowed punctuation is preserved, so
#after-tui-fix creates AFTER-TUI-FIX. The new entry renders as
- [ ] () — NAME followed by the captured task link as its child. It is
inserted after the current Pomodoro's complete block when there is one,
otherwise after the last completed Pomodoro's complete block, otherwise before
the first Pomodoro in the section. Completed-only matches create a new future
Pomodoro keeping the completed entry's spelling — reusing completed
deep-work creates DEEP WORK, never DEEP-WORK; completed history is not
modified.
Cancelled, nested, and fenced lookalikes are not anchors. Multiple open timed
entries remain an invariant error when a new named entry would need the
current-Pomodoro anchor.
The routed note and daily note are both parsed and validated before either is
replaced. A missing daily note or Pomodoros section, no eligible unnamed target,
timed ambiguity for implicit or named-creation captures, invalid Pomodoro name,
malformed marker, duplicate block ID, or duplicate Pomodoro link leaves both
notes unchanged. --dry-run and multi-item batches use the same staged
daily-note snapshot as a real capture, so a later batch item can reuse a
Pomodoro created by an earlier item without creating a duplicate.
Starting the session atomically¶
Append =<X> to a @<route>:<block-id>[#<pomodoro>] marker to start the
selected Pomodoro in the same transaction: bob capture 'Write outline
@sase:outline=3' creates the task, links it to today's next session, and
starts a 15-minute session. @sase:outline#deep-work=-2 uses the named
target and starts a default 25-minute session with a 10-minute offset.
Prefer the short name spelling: bob capture '@sase:deep-fix=bugs' links
under BUGS and starts it, exactly like @sase:deep-fix#bugs=.
<X> is exactly the suffix of the Obsidian bob-ledger-tools se<X>
snippet:
| Suffix | Duration | Offset |
|---|---|---|
| (empty) | 25 minutes | none |
3 |
3 × 5 minutes | none |
- |
25 minutes | 1 × 5 minutes |
-2 |
25 minutes | 2 × 5 minutes |
3- |
3 × 5 minutes | 1 × 5 minutes |
2-1 |
2 × 5 minutes | 1 × 5 minutes |
An omitted duration means five 5-minute units (25 minutes); an omitted
offset with - means one unit; no - means zero offset. 0 and leading
zeros are valid. At the capture clock's local hour/minute, start =
ceil((nowMinutes - 5*offsetUnits)/5)*5 and end = start +
5*durationUnits, displayed modulo 24 hours as (**HHMM-HHMM** [t:: Nm])
byte-for-byte like the snippet's range. BOB_NOW/Bob's existing clock sets
the time; numeric overflow is a useful error rather than a wrong time.
The suffix is item-local and applies only to new : task captures — not
+ ensures, project-note + forms, global @@ declarations, or ordinary
text. It cannot combine with s:<N> or $<N>: a scheduled Blocked task
cannot start its session, so those combinations fail before anything is
written. Only an untimed, open placeholder is ever started. Any open timed
Pomodoro — including one past its nominal end — is restarted by a whole-item
= start, with a visible warning before the write. Link starts keep the
existing "finish the current Pomodoro first" error because they never override
a running entry; only x closes one. A selected non-placeholder or structurally
ambiguous ledger fails with the same error. A newly created started entry uses the
same placement as named creation: after the last completed Pomodoro's complete
block, otherwise before the first Pomodoro in the section. Starting an existing
placeholder moves it, with its child block, to that same slot. JSON output keeps
every existing key and adds an additive pomodoro_start object (resolved
start/end, duration_minutes, offset_units, destination name/line,
whether the entry was created); human output names the Pomodoro and its time.
Linking and starting existing tasks¶
Capture a whole item that is only @route:block-id[#pomodoro][=<X>] (or the
^route:block-id[#pomodoro][=<X>] active-task spelling, including the short
@route:block-id=name / ^route:block-id=name suffix) to link an existing
task into today's Pomodoro ledger and optionally start that session
atomically. bob ref today reuses this planner for a reference's reading
task; its exact-name (-e) and entry-ref (-P) targets are
bob ref today-only and the capture grammar is unchanged. For example, bob capture '^sase:deep-fix=' links the existing
^deep-fix task in sase.md and starts its session, and
bob capture '@sase:deep-fix=bugs' links it under BUGS and starts that session. The marker must be the
entire capture item: no body text, no authored child bullets, no %/--clip,
no s:<N>/$<N>, and no forced destination flags. A token with the complete
^route:block-id… shape claims its item; anything else on the item fails with
an invalid_pomodoro_link error instead of becoming task text. Partial shapes
(^, ^fragment, ^route:, ^route:block-id#) are incomplete editing
states when they are the whole item and ordinary prose otherwise. Anything
else starting with ^ (^_^, ^^, ^.) stays ordinary text, and ^ is
only recognized as the first token of an item's first line. ^…+ and ^…!
are rejected with messages naming the @route^block-id+ project-note and
@route+block-id! toggle spellings. A @@ declaration never applies to a
^ item. Body-bearing <text> @route:block-id[#name][=<X>] keeps its exact
current new-task meaning.
The existing task resolves with the task-toggle lookup, so missing notes,
missing IDs (with a close-match suggestion plus a hint to add text for a new
Pomodoro-linked task), duplicate IDs, and non-task block IDs reuse those
errors. Ready [ ] and Blocked [?] tasks become Next [*]; Next stays
Next and In Progress [/] is never demoted. A single valid future
[scheduled::] is retired with the pull-forward Schedule Log entry when a log
already exists — all under the existing Ensure Next rules, plus the §13.5
link guard: a target that is Blocked after the gesture stays unlinked while
its open prerequisites (own plus inherited) rise, unless §13.5.1 permits
capture to drop its own prerequisites and link in the same staged write.
Project-blocked tasks and ^prj targets keep their prerequisites and stay
unlinked. A refused drop adds drop_refused to link_guard. The one refusal is a ^prj whose only reason is its own future
schedule: it fails with "move the project date from the ^prj Schedule
action" instead of pulling the date forward. Done, canceled, and unknown
statuses fail: only Ready, Blocked, Next, and In Progress tasks can be linked.
Let Q be the open Pomodoro whose children hold the task's dedicated
[[route#^id]] Task Link (links under completed Pomodoros are history and are
never edited; more than one movable link is the existing write-free invariant
error). With #pomodoro, the existing named selection applies: whole-slug
then prefix over open entries, else the canonical named future Pomodoro is
created, and Q's complete subtree moves there (or a new link is inserted
when there is no Q). Without a name, an already-queued task respects the
queue: the destination is Q and nothing moves. Without a name and no Q,
the implicit current/next Pomodoro is used, exactly like a new-task capture.
=<X> uses the same se<X> math, clock, and guards: any open timed Pomodoro
fails with "finish the current Pomodoro first"; #name=<X> needs an untimed
open placeholder or creates a started entry; no-name with Q starts Q
(it must be an untimed open placeholder); no-name without Q uses the
first open untimed placeholder or creates an unnamed started entry. Every
started entry moves, with its child block, to the current slot: right after
the last completed Pomodoro's complete block, otherwise before the first
other open Pomodoro.
With BOB_NOW=2026-07-10 09:02:00 and a ledger holding completed PLAN,
open BUGS (with [[sase#^deep-fix]] plus child notes), and an open unnamed
entry, while sase.md holds [*] ^deep-fix, [/] ^outline, and [ ]
^ready:
| Item | Result |
|---|---|
^sase:deep-fix |
True no-op: already Next, Task Link already in BUGS |
^sase:deep-fix= |
BUGS becomes (**0905-0930** [t:: 25m]) — BUGS; link kept |
^sase:deep-fix#focus=3 |
Started FOCUS (0905-0920) created after PLAN; link moved |
^sase:outline= |
Stays [/]; BUGS starts with a new [[sase#^outline]] |
@sase:ready |
[ ] → [*]; new link under BUGS |
@sase:ready#bugs=- |
[ ] → [*]; BUGS starts 0900-0925; link appended |
Successful JSON reports kind "pomodoro_link" with text: "", the post-image
task_line, placement: "linked", the task-toggle vocabulary (block_id,
day_file, block_link, previous_task_line, previous_status_symbol /
_name, status_symbol / _name, status_changed, removed_scheduled,
schedule_log when written, pomodoro_name, creates_pomodoro),
pomodoro_link_action ("linked", "moved", or "already_current"),
pre-image pomodoro_link_source (only when a link existed), post-image
pomodoro_link_destination, pomodoro_link_placement (when a link was
inserted or moved), and the exact pomodoro_start object pomodoro_task
uses (only with =<X>). It emits no toggle_direction/toggle_behavior.
When the guard consumes the gesture, JSON uses the additive kind
"link_guard", omits block_link, creates_pomodoro,
pomodoro_link_action and its endpoints, pomodoro_link_placement, and
pomodoro_name, and reports link_guard (reasons, promoted, detail).
When existing live links are removed, top-level removed_pomodoro_links and
day_file report the cleanup; neither field is copied into the nested
link_guard object. With no cleanup, both fields are omitted. This neutral
kind lets older clients show a generic preview instead of a false link
confirmation; newer clients can render the guard details. Any live links
removed from today's open Pomodoros appear in the batch-level pomodoro_blocks
diff. Human output says not linked and reports the guard detail without a
generic capture confirmation. Successful human output follows the Ensure Next
style: a link/would link or
start/would start header, a status line ([ ] → [*], [*] already
Next, [/] stays In Progress), a ledger line (Linked … under BUGS,
Moved Task Link BUGS → FOCUS (created FOCUS), Task Link already in
BUGS; no ledger change., or the ⛔ not linked (…) · promoted … guard
sentence), and the existing start phrasing.
Picking any open task with ':'¶
Type : at the very start of an input to fuzzy-search linkable open tasks
(Ready [ ], Blocked [?], Next [*], or In Progress [/]) in the
routable inbox, area, and non-terminal project notes listed by
bob capture-targets. Nested notes, reference notes, untyped notes other than
the inbox, and terminal projects are outside this catalog. An ID-less row
requires the explicit Add block ID action before it can be accepted.
Accepting an identified row replaces the : query with the
canonical @route:block-id link, which captures exactly like a typed link:
as-is it links the task, and with a trailing = it also starts that
session. Acceptance edits the draft; submitting it performs the vault write.
A : query is only ever a picker query — it is never executable
and never a third spelling of the link.
An item is a task-link query exactly when it has one physical line and that
line (ignoring leading and trailing whitespace) is a single
whitespace-delimited token starting with :. The token is the sigil plus a
query of any non-whitespace characters, possibly empty. Each item of a
blank-line-separated batch draft is judged alone, so bulk linking works, and
a @@ declaration never applies to a query item.
| Draft item | Reading |
|---|---|
: |
Query "": the picker opens on the full list |
:dee |
Query dee |
:sase:deep-fix |
Query; execution teaches the @ spelling (T2 below) |
:sase:deep-fix= |
Query; execution teaches the @ spelling (T2 below) |
: dee, :dee more, Buy :dee |
Prose: several tokens, unchanged |
:dee with a child line |
Prose: several lines, unchanged |
bob capture (and --dry-run) rejects a claimed item before any other item
parser, so the whole batch rolls back and forced flags change nothing. The
teaching errors keep the usual capture item K starting on line L: …
prefix. T1 reads `:dee` opens the task picker; pick a task to insert its
`@<route>:<block-id>` link, or write the link yourself (for example
`@sase:deep-fix`). T2 applies when ^ plus the query parses as a
complete caret link, and reads `:sase:deep-fix=` opens the task picker;
to link that task, write `@sase:deep-fix=`. A finished query can never
silently become a junk inbox task.
Only statuses the link path accepts are listed: Ready [ ], Blocked [?],
Next [*], and In Progress [/]. Notes are the capture-targets set: the
inbox (when the file exists), area notes A→Z, then non-terminal project
notes A→Z. Done, canceled, and unknown statuses are excluded, as are notes
in terminal projects and untyped vault-root files.
Groups, in precedence: queued (an identified task whose dedicated
[[route#^id]] link sits under an open Pomodoro today), in_progress,
next, and note (everything else). The empty query keeps the canonical
order: queued in ledger order, then in_progress and next each by
route then line, then note tasks in targets order and document order.
Ranking splits the query into lowercase
whitespace-separated terms that must all match over route:block-id, the
block ID, the text, the route, the section, and the Pomodoro name — field
prefix first, then word prefix, substring, and in-order subsequence — with
ties keeping the canonical order.
Tasks without a block ID always list, with requires_block_id: true, an
empty replacement clients must never insert, and up to 3 deterministic
block_id_suggestions that are free in that note (for example
Fix flaky gkeep test suggests fix-flaky-gkeep). Name one with
bob capture-task-id --route sase --task-ref <ref> --block-id fix-flaky-gkeep
using the candidate's route and ref, then capture the link with an
optional = as usual. Linking retires a single future
[scheduled::YYYY-MM-DD] exactly like a typed link; candidates flag that
with pulls_forward: true and the scheduled date.
Going to a task with '/'¶
Type / as the whole capture draft to fuzzy-search the same catalog : uses:
linkable open tasks (Ready [ ], Blocked [?], Next [*], or In Progress
[/]) in the routable inbox, area, and non-terminal project notes listed by
bob capture-targets, as described in Picking any open
task. Picking a row opens that task in Obsidian
at its home note — caret at column 0, line centred — instead of inserting a
link. Go To never writes: no block-ID minting, no freshness stamps, no lane
changes, no ledger edits, no walk moves, no Task Card opens, no completions.
A draft is a Go To query exactly when the whole draft is one
whitespace-delimited token starting with / and holding no second /. The
token is the sigil plus a query of any non-whitespace characters, possibly
empty. Unlike :, the claim is per draft, not per item.
| Draft | Reading |
|---|---|
/ |
Query "": the picker opens on the full list |
/fix |
Query fix |
/sase:deep-fix |
Query sase:deep-fix (a : inside the query is fine) |
/fix\n |
Query fix (surrounding whitespace is ignored) |
//, /Users/x, /r/rust |
Prose: a second / |
/a b, Buy /x |
Prose: several tokens |
/x with a child line |
Prose: several lines |
/x, a blank line, then Buy milk |
Prose: two items, both captured as usual |
@@work on one line, then /x |
Prose: /x routes to work as usual |
bob capture (and --dry-run) refuses a claimed draft before any other item
parser — before the & dependency scan, and so before the : refusal — so
the whole batch rolls back and forced flags such as -r and -s change
nothing. No vault file changes. The teaching error (T3) keeps the usual
capture item 1 starting on line L: … prefix and reads `/dee` opens the
Go To picker; pick a task to open it in Obsidian — it never captures (the
quoted token is the exact typed token, so a bare / reads `/` opens …).
bob capture-parse reports a claimed draft as mode incomplete needing
task_goto, with one interactive_placeholder span over the whole token,
sigil included, and no diagnostics. bob capture-complete serves context
task_goto: the same rows and order as task_link for the same query,
including ID-less rows (every row opens directly) and the inbox. Each
candidate carries replacement (always ""; clients never insert it),
ref (<line>:<digest>), route, note_kind, note_path (the exact
vault-relative path with extension), target (the absolute path,
bob_dir.join(note_path)), nullable block_id, status_symbol,
status_name, status_type, text, nullable section, depth, 1-based
line, group, nullable scheduled (context only), nullable pomodoro,
and task_kind (omitted when absent). There is no requires_block_id,
block_id_suggestions, or pulls_forward. The query is the token text
between the sigil and the cursor (empty at or just after the sigil); the
replacement covers the whole token, sigil included, so refetching at
replacement.start returns the unfiltered list. The response carries no
query field and no picker descriptor, exactly like task_link. Human
rows read [status] text · note_path:line, with · scheduled YYYY-MM-DD
whenever the task carries a scheduled date; the shell format yields no rows,
because nothing is insertable. The navigation itself runs through
bob-navigation-hotkeys' obsidian://bob-goto-task URI, whose contract is in
the Go to task URI reference.
Choosing a parent task with +¶
Type + at the start of an otherwise empty item, or after a space at the end
of a capture line, to search the same task catalog as the : picker. Typing
letters before the picker response arrives seeds the search, so +bank finds
matching tasks. A terminal + in prose such as Call the bank + appends the
selected task marker to that line; a leading + in a fresh item produces the
marker by itself. The selector is unresolved input: bob capture refuses it
until an editor replaces the token with an identified @route+block-id marker.
The picker searches open Ready, Blocked, Next, and In Progress tasks in the
routable inbox, area notes, and non-terminal project notes. It uses the full
capture-target catalog across notes; it does not search arbitrary nested or
unroutable notes, or closed tasks. Results keep the : picker order and
ranking, including ID-less tasks. An ID-less row has no insertable replacement;
accepting it explicitly opens the stale-safe Add block ID flow. After a
successful capture-task-id --route assignment Bob returns a
parent_replacement such as @sase+fix-flaky-gkeep for the editor to insert.
Choosing an identified row only prepares the draft: a body-bearing marker
appends a note under the task, while a solo marker keeps the existing Ensure
Next behavior.
The exact lone + is dual-use. Editors offer the task picker and show the
Escape hint to extend the running Pomodoro by five minutes. Escape preserves
the draft, so submitting + still performs that existing adjustment. Numeric
adjustments (+N), double-plus shifts (++N), and numeric near misses remain
Pomodoro operators. A prose-terminal + is always a task selector. Shell
completion lists only identified @route+block-id rows and never assigns an
ID or writes the vault.
Adding prerequisites with '&'¶
¬e:block-id means "the task I am capturing or explicitly targeting
depends on the task at note.md / ^block-id". It adds a plain task
dependency link (normally [[note#^block-id]]) to the dependent's managed
⛓️ **DEPENDS ON:** child line (see docs/task-dependencies.md). The note
is a vault-relative identity, not a capture destination: simple root notes
use &foo:bar, nested notes use &projects/foo:bar, and notes with
whitespace or reserved punctuation use a quoted component such as
&"Shopping List":bar (inside quotes only \" and \\ are escapes; case
and Unicode are preserved). Block IDs keep Bob's existing rules. The note
component is extensionless, like current routes; an explicit relative path
resolves first, otherwise a unique basename. Absolute paths, empty
components, and ./.. are rejected.
Modifiers sit at the leading or trailing marker positions of the parent
line, interleaving with destination, schedule, priority, and clipboard
markers in either order, or close an authored child line (a child holding
only modifiers contributes no empty bullet). Both runs skip the same
trailing-transparent marker shapes (@…, #…, s:, $/$<N>, schedule,
priority, and clip tokens), so @cash &foo:bar Buy milk is a dependency
on a cash.md task, not literal text. An explicit @route+task-id
selects the existing parent even with child-bullet text; a bare solo
@route:task-id alongside modifiers is a narrow alias for the same owner.
Without &, both spellings keep their existing link/toggle meanings.
| Draft item | Reading |
|---|---|
Buy Groceries! &foo:bar |
New inbox task depending on foo.md#^bar (task + new_task target) |
Buy Groceries! @home &foo:bar &cash:budget |
New home.md task with two prerequisites, in typed order |
@cash &foo:bar Buy milk |
New cash.md task depending on foo.md#^bar (leading run skips the destination marker) |
&foo:bar @body+excercise |
Add the dependency to existing body.md#^excercise (task_dependency, no new task) |
&foo:bar ^body:excercise |
Same owner via the bare ^ alias; no Task Link is written |
&foo:bar @body:excercise |
Same owner via the narrow colon alias (only when bare: no #name, =…, or !) |
Waiting for approval @body+excercise &foo:bar |
Keep the sub-bullet prose, add the dependency to its explicit parent |
&foo:bar |
Incomplete: needs dependency_target (type task text or + to choose an existing owner) |
&foo:bar + |
Incomplete: needs task_parent; the picker chooses the task that will depend on foo.md#^bar |
&, Buy Groceries! &fo, Buy Groceries! &foo: |
Incomplete: needs task_dependency (the picker stays open) |
Research & Development, R&D research |
Literal text: mid-word and between-words & is never a directive |
Buy Groceries! \&foo:bar |
Literal task text Buy Groceries! &foo:bar (only the \ is consumed) |
&foo:bar inside [[…]] or `…` |
Literal text: wikilinks and code are never scanned |
capture-parse reports each modifier in per-item dependencies (raw,
decoded note and block_id, sigil-inclusive range) with
dependency_sigil, dependency_note, and dependency_block_id spans, plus
dependency_target (new_task or existing_task ownership). A complete
dependency-only action reports mode task_dependency; new-task and
sub-bullet captures keep their mode and gain the modifier fields. Frozen
v1 examples (schema version 1, additive fields only):
// bob capture-parse -f json -- 'Buy Groceries! @home &foo:bar &cash:budget'
{
"body": "Buy Groceries!",
"mode": "task",
"route": "home",
"needs": [],
"dependencies": [
{
"raw": "&foo:bar",
"note": "foo",
"block_id": "bar",
"quoted": false,
"range": { "start": 21, "end": 29 }
},
{
"raw": "&cash:budget",
"note": "cash",
"block_id": "budget",
"quoted": false,
"range": { "start": 30, "end": 42 }
}
],
"dependency_target": { "kind": "new_task", "route": "home" }
}
// bob capture-parse -f json -- '&foo:bar @body+excercise'
{
"body": "",
"mode": "task_dependency",
"route": "body",
"block_id": "excercise",
"needs": [],
"dependencies": [
{
"raw": "&foo:bar",
"note": "foo",
"block_id": "bar",
"quoted": false,
"range": { "start": 0, "end": 8 }
}
],
"dependency_target": {
"kind": "existing_task",
"route": "body",
"block_id": "excercise"
}
}
capture-complete serves context task_dependency with a replacement range
covering exactly the active modifier (sigil and quoted note included), the
decoded query, the lexical owner, and the vault-wide candidate list:
// bob capture-complete -b VAULT -c 19 -f json -- 'Buy Groceries! &foo'
{
"cursor": 19,
"replacement": { "start": 15, "end": 19 },
"context": "task_dependency",
"candidates": [],
"query": "foo",
"owner": { "kind": "new_task", "text": "Buy Groceries!" }
}
The discovery scan lists every task-bearing vault note — untyped root
notes, nested folders, ref notes, terminal projects, daily notes, hidden
tasks, and completed/cancelled/archive history — excluding only
dot-directories, _templates, _generated, _conflicts, fenced code, and
non-task block anchors. It never requires today's ledger file. Empty-query
order is same-note open tasks, In Progress, Next, other open tasks grouped
by note, then completed/cancelled history, with hidden tasks last within
their section; queries rank with the same tiered AND-term matcher as the
: picker (task text, exact note locator, block ID, path, section) and
keep completed history findable behind open matches. Each row carries the
exact note_path (with extension, never a lowercased route), display
locator, stable group, already_dependency, and optional
disabled_reason; ID-less rows carry requires_block_id with suggestions
and no insertable replacement, and self/already-selected/duplicate-ID rows
stay visible with their guard reason. Refetching at replacement.start
returns the unfiltered snapshot.
Contract status: parsing, spans, needs, completion ranges, the candidate
wire shape, and the discovery scan are frozen above, and bob capture
executes the modifiers through the staged batch writer: the dependent
gains (or merges into) the managed ⛓️ **DEPENDS ON:** child line plus
the derived [dependsOn:: …] field, open prerequisites flip it to
[?] Blocked, existing dependents are freshness-stamped, and repeats
are idempotent (0 added). Each added prerequisite's 🧱 **BLOCKS:**
line is staged in the same batch (the dependent's minted ^id when it
has none), reported as blocks_line_updates plus
minted_dependent_block_id when a mint happens; the prerequisite is
reported as a dependency_target task block
(task-dependencies.md §14).
Multi-note batches plan every note before writing anything; a failed
preimage check or write aborts the batch and rolls back notes already
staged, and dry runs plan through the same planner without touching the
vault. A draft with prerequisites never
captures without them. Section bullets, project/note constructions,
Pomodoro ledger links, and session operators reject the modifiers with
a targeted diagnostic, as do suffixed (#name, =…) or !-toggled
dependency-only owners; missing notes, missing block IDs,
self-dependencies, and batch cycles fail with an actionable error and
leave the vault intact.
& aimed at a project's ^prj writes the same Depends-On line and
field, plus the project side effects (docs/task-dependencies.md
§13.10): targets inside the same project note refuse (a project cannot
wait on its own tasks), while pre-existing hand-authored same-note
links stay on the line unprojected. When any target is open, ^prj
and every open resident flip to [?] in the same write, residents
gain exactly one #prj_is_blocked, and their live links leave
today's open Pomodoros. Open
prerequisites rise to at least the strongest lane among ^prj and its
residents — never onto a ^prj target, which the walk passes through
to that project's own prerequisites. More generally, any & that
leaves its dependent Blocked consumes that dependent's live links in
the same write. Capture consumption is one shared path for &, *,
and the link-guard Consume branches: match by identity, keep authored
notes under a consumed link-only bullet, delete only Pomodoro entries
this capture emptied, and report consumed_links[] plus removed
Pomodoro blocks (Blocking existing tasks with '*';
task-dependencies.md §13.5,
PDL8–PDL11). Reconcile and plugins keep removing the whole subtree
(PDL7). Human output prints the structured − consumption lines and
suppresses the legacy removed N live Pomodoro link(s) to newly
Blocked task(s) warning string, which still appears in warnings[]
for older clients.
A successful & item always carries dependency_update and
dependency_updates[] (the array's first entry equals the singular
field, with role: "dependent"). See
Dependency updates and consumed links.
Blocking existing tasks with '*'¶
Obsidian's existing-task twin is the reverse Blocks stage, documented in task-dependencies.md §15.
*note:block-id is the dual of ¬e:block-id. Let N be the item's
task (new, or an existing owner) and T the task named with *. Arrows
mean "depends on":
| Draft | Stored edge | Line that changes |
|---|---|---|
N &P (today) |
N → P | N's Depends-On |
N *T |
T → N | T's Depends-On |
N &P *T |
T → N → P | both ("insert between") |
*T @route+id, @route:id *T |
T → route#^id |
T's Depends-On |
Note text @route+id *T |
T → route#^id, plus the sub-bullet |
T's Depends-On |
The edge lives only on T's ⛓️ **DEPENDS ON:** line, and N's
🧱 **BLOCKS:** line is the usual generated mirror. No new data model,
field, or tag is added. * shares &'s ownership rules
(resolve_execution_ownership, @@ inheritance, forced
--task/--route), child-line trailing runs, and rejections.
Quote * on the shell, because zsh globs it:
bob capture 'Ask Kelly *job:cleanup'.
Grammar¶
* is a directive only when all of these hold:
- Placement. It starts a whitespace-delimited token in the parent
line's leading run or in any line's trailing run, exactly where
&is allowed. A mid-line*foo:barstays body text. Both runs skip theis_trailing_transparentmarker shapes (@…,#…,s:,$/$<N>, schedule, priority, and clip tokens). - Next byte. The byte after
*is neither whitespace nor*. This keeps**bold**,* bullet, and5 * 3literal. - Rest of the token. The rest of the token contains no
*, which keeps*urgent*literal.validate_noterejects*in the note component for both sigils, since Obsidian file names cannot contain it (&no*te:idand*no*te:idare invalid). - Bare
*. A bare*is a directive (it opens the picker) only as the last token of a line that has other text before it. A line-initial*is always literal or a list marker. This differs from&, whose bare form also works as the first parent token. - Escape.
\*escapes it, but only where an unescaped token would have been a directive under rules 1–3. Elsewhere\*stays verbatim Markdown.
Rejections:
- Whole-item
x*…/x*are close park operators.==*is not a close and teachesx*. Link suffixes keep=x*…. - Session operators,
+N/-N, section bullets, project-note construction,!completions,--task-refpickers, and suffixed owners reject*exactly as they reject&. Error text names the sigil the user typed. A claimed!keeps precedence (!sase:x *foostays the bang teaching error). @route:id=x… *Tand<text> @route:id=x… *Tare also rejected: closing and blocking in one item is incoherent. Capture the close first, thenN *Tas a separate blank-line item.
| Draft item | Reading |
|---|---|
Ask Kelly *job:cleanup |
New inbox task N; job#^cleanup depends on N |
*job:cleanup Ask Kelly, @cash *job:cleanup Ask Kelly |
Same (leading run, past @cash) |
Ask Kelly @mac_inbox:ask-kelly *job:cleanup |
Same, and N is linked first so it takes T's place in the session |
Ask Kelly *job:cleanup *job:send-batch |
N blocks both, in typed order |
Call registrar &school:transcript *job:apply-x |
Insert between; cycle-checked |
*job:cleanup @bob+ask-kelly, @bob:ask-kelly *job:cleanup |
Existing task blocks T (mode task_dependency) |
^bob:ask-kelly *job:cleanup, *job:cleanup ^bob:ask-kelly |
Same relationship with the bare ^ owner alias; no Pomodoro Task Link is written |
Order parts *projects/house/kitchen:cabinets, X *"Release Plan":publish |
Nested and quoted targets |
*job:cleanup with no owner |
Incomplete: needs dependency_target (type task text or + to choose an existing owner) |
*job:cleanup + |
Incomplete: needs task_parent; the picker chooses the task that will own the relationship |
X *, X *jo, X *job: |
Incomplete: needs blocked_task (picker open) |
Fix *urgent* bug, Read **Dune**, * Buy milk, 5 * 3, R*D, Fix *foo:bar now |
Literal |
X \*job:cleanup |
Literal *job:cleanup |
Support *nix |
Partial; submit fails with a teaching error that suggests \*nix |
N *T &T |
Direct cycle; refused, vault byte-identical |
x*1, x* |
Close park operators |
==* |
Error: `==*` is not a close: close the running Pomodoro with `x*` |
@cash &foo:bar Buy milk |
& dependency (leading run skips @cash); STP twin of the * leading-run rows |
Parse vectors STP are the rows of this table plus those extras.
capture-parse reports each * modifier in per-item and top-level
blocked_tasks (raw, decoded note and block_id, quoted,
sigil-inclusive range) with blocked_task_sigil, blocked_task_note,
and blocked_task_block_id spans. Never put a direction flag on
dependencies[]. A complete existing-owner action reports mode
task_dependency; new-task and sub-bullet captures keep their mode and
gain the blocked_tasks fields. A partial * needs blocked_task. A
complete * with no owner needs dependency_target, the same need
& uses. Frozen v1 examples (schema version 1, additive fields only):
// bob capture-parse -f json -- 'Ask Kelly *job:cleanup *job:send-batch'
{
"body": "Ask Kelly",
"mode": "task",
"route": null,
"needs": [],
"blocked_tasks": [
{
"raw": "*job:cleanup",
"note": "job",
"block_id": "cleanup",
"quoted": false,
"range": { "start": 10, "end": 22 }
},
{
"raw": "*job:send-batch",
"note": "job",
"block_id": "send-batch",
"quoted": false,
"range": { "start": 23, "end": 38 }
}
],
"dependency_target": { "kind": "new_task" }
}
// bob capture-parse -f json -- '*job:cleanup @bob+ask-kelly'
{
"body": "",
"mode": "task_dependency",
"route": "bob",
"block_id": "ask-kelly",
"needs": [],
"blocked_tasks": [
{
"raw": "*job:cleanup",
"note": "job",
"block_id": "cleanup",
"quoted": false,
"range": { "start": 0, "end": 12 }
}
],
"dependency_target": {
"kind": "existing_task",
"route": "bob",
"block_id": "ask-kelly"
}
}
capture-complete serves context blocked_task with a replacement
range covering exactly the active modifier (sigil and quoted note
included), the decoded query, the lexical owner, and the
open-editable candidate list:
// bob capture-complete -b VAULT -c 13 -f json -- 'Ask Kelly *jo'
{
"cursor": 13,
"replacement": { "start": 10, "end": 13 },
"context": "blocked_task",
"candidates": [],
"query": "jo",
"owner": { "kind": "new_task", "text": "Ask Kelly" }
}
Rows reuse the DependencyCandidate shape, plus already_blocked
(omitted when false) and disabled_reason:
the owner task itselfwould create a dependency cyclea project cannot depend on its own resident- the existing duplicate-block-ID reason
The pool is open, editable tasks only: refuse daily notes, done/,
_templates, _generated, _conflicts, dot-directories, and fenced
code. With an empty query, ranking is (1) today's linked tasks (the
running entry's first), (2) Pending [/], (3) Next [*], (4) the
owner's note, (5) everything else. With a non-empty query, ranking is
match score first, then the same order. Replacements use *
(replacement_for_sigil). Shell completion adds a blocked tasks
group.
Execution¶
The whole draft plans before anything is written. For an item with
blocked_tasks:
- Resolve and check every target. Dedupe by resolved identity.
The note resolves vault-wide exactly like
¬e: path or basename, nested, quoted, plus staged notes. T must be an open, editable task. Refuse targets in daily notes,done/,_templates,_generated,_conflicts, dot-directories, or fenced code. Also refuse a self target; a T with several or malformed Depends-On lines; and a^prjT when N lives in that project's note or a descendant project (a guaranteed deadlock). A repeat is a no-op reported asalready_present. - Give N a block ID. Use the typed
^idor:id. Otherwise mint one with this batch's reservations, even whendependencies.blocks_lines: false. When the ID was minted, report it asminted_blocker_block_id. Never reuseminted_dependent_block_id, which means the opposite (&'s BLOCKS mirror for N-as-dependent). - Plan N as today. N's own
&prerequisites come first, so N may be born[?]. Insert N and stage its Task Link if:id/#pomodoroasked for one and the existing link guard allows it. This is the "link N, then consume T" order:N @mac_inbox:id *Tkeeps the session alive by putting N into T's slot before T's link is consumed. - Run one writer pass per T, in typed order. Each pass is the
existing-dependent writer with dependent = T (vault path plus block
ID) and prerequisites = [N]. It merges the line and fields, stamps
T fresh, flips T to
[?], blocks and tags^prjresidents, consumes links, and stages N's BLOCKS line. Commitment transfer: an open N rises to T's pre-capture lane (for a^prjT, the strongest resident lane), capped at Next[*]when N is new in this capture. The cap applies to the whole promotion walk that this pass seeds. An existing owner gets the normal §5 transfer and may become[/]. A^prjN is never raised. Re-locate N by block ID after every pass. - Existing owners go through the dependency-only path. The
sub-bullet path (
Note text @route+id *T) and forced--task/--routemirror&. - Cycles. Direct, transitive, cross-item, and
&+*cycles are refused; the vault stays byte-identical. - Later link gestures. A later
@T:idin the same batch drops N from T's line under capture's own-prerequisite drop. That is intended, and the final preview reports it accurately. Capture does not auto-link N into T's slot; the explicitN @mac_inbox:id *Tidiom covers it.
Capture consumption¶
Every capture path that consumes live links (plan_existing_task_dependencies,
plan_resident_block, and the link-guard Consume branches) follows
this contract. Reconcile and plugins are unchanged (PDL7).
- Scope. Only live (unstruck) Task Links under today's open
Pomodoros (running and planned), every occurrence. Closed entries,
🍅history, other days, prose outside## Pomodoros, and dependency lines are never touched. A missing day file means there is nothing to consume. - Identity. Match by resolved
(vault-relative path, block id), not by text. Aliases (|x),[[#^local]], basename, path, and case spellings all match. - Notes kept (PDL8). A consumed link-only bullet with children
is not deleted. Its link token is replaced by the task's clean
display text: the same
textcapture reports, with checkbox, tags, inline fields, and^idstripped, and any[[…#^…]]inside it flattened to plain text. The marker, indentation, and children stay unchanged, and the bullet carries no Task Link. A childless link-only bullet is deleted. A prose bullet loses only the token (PDL6). - Only entries this capture emptied (PDL9). "Pomodoro" means the
column-0 entry, never the
## Pomodorosheading. An entry is deleted only if it had a child list item before this consumption and has none after it, judged after all of the item's edits. That includes a running timed entry. Entries that were already childless stay. - Reported (PDL11). Each consumed link becomes a
consumed_links[]row. Each deleted entry appears inpomodoro_blockswithremoved: true. Human output names the session. The legacywarnings[]consumption string stays for older clients; human output prints the−lines instead, so the same fact is never printed twice.
Human output¶
These lines are from a real ST3 capture; the final plan-budget footer is omitted because its limits come from local configuration.
✓ captured mac_inbox.md
- [*] #task Ask Kelly for references! [created::2026-10-14] [id:: mac_inbox__ask-kelly-references] ^ask-kelly-references
🧱 blocks "Do cleanup tasks!" job.md ^cleanup [/] → [?] Blocked
− removed its Task Link from JOB 10:10–10:35 (running) · deleted the empty Pomodoro
Variants:
− kept its notes under JOB 10:10–10:35 as plain text· blocked 4 project tasksfor a^prjT (dry run:· would block 4 project tasks)would …in dry runs (🧱 would block "…",🧱 would now block "…")- A typed duplicate
*target is skipped; a repeat that adds nothing prints🧱 already blocks "Cleanup" job.md ^cleanupwith no status arrow, live and dry-run - Existing-owner items use the update header and owner status row, then print
one relationship row per target; for example
✓ updated body.md,[/] → [?] Exercise ^excercise, and🧱 now blocks "Do cleanup tasks" job.md ^cleanup [*] → [?] Blocked. Dry runs usewould update/would now block.
& consumption prints the same − lines.
Selected fields from the real ST3 bob capture -f json result are shown
below; the absolute target path and unrelated fields are omitted. The
created and [id::] properties are part of the emitted task line.
{
"ok": true,
"kind": "task",
"text": "Ask Kelly for references!",
"relative_target": "mac_inbox.md",
"block_id": "ask-kelly-references",
"task_line": "- [*] #task Ask Kelly for references! [created::2026-10-14] [id:: mac_inbox__ask-kelly-references] ^ask-kelly-references",
"dependency_update": {
"role": "blocked_task",
"dependent_note": "job.md",
"dependent_block_id": "cleanup",
"dependent_text": "Do cleanup tasks!",
"new_task": false,
"added": 1,
"already_present": 0,
"open_prerequisites": 1,
"prerequisites": [
{
"note": "mac_inbox.md",
"block_id": "ask-kelly-references",
"link": "[[mac_inbox#^ask-kelly-references]]",
"status_symbol": " ",
"status_name": "Todo",
"text": "Ask Kelly for references!",
"open": true
}
],
"dependent_status": "?",
"dependent_status_name": "Blocked",
"status_changed": true,
"previous_status": "/",
"previous_status_name": "In Progress"
},
"dependency_updates": [
{
"role": "blocked_task",
"dependent_note": "job.md",
"dependent_block_id": "cleanup",
"dependent_text": "Do cleanup tasks!",
"new_task": false,
"added": 1,
"already_present": 0,
"open_prerequisites": 1,
"prerequisites": [
{
"note": "mac_inbox.md",
"block_id": "ask-kelly-references",
"link": "[[mac_inbox#^ask-kelly-references]]",
"status_symbol": " ",
"status_name": "Todo",
"text": "Ask Kelly for references!",
"open": true
}
],
"dependent_status": "?",
"dependent_status_name": "Blocked",
"status_changed": true,
"previous_status": "/",
"previous_status_name": "In Progress"
}
],
"consumed_links": [
{
"day_file": "2026/20261014.md",
"line": 3,
"link": "[[job#^cleanup]]",
"note": "job.md",
"block_id": "cleanup",
"text": "Do cleanup tasks!",
"action": "removed",
"pomodoro": {
"line": 2,
"name": "JOB",
"time_range": "1010-1035",
"status": "running",
"removed": true
}
}
],
"minted_blocker_block_id": {
"path": "mac_inbox.md",
"line": 1,
"block_id": "ask-kelly-references"
},
"pomodoro_blocks": [
{
"relative_target": "2026/20261014.md",
"line": 2,
"name": "JOB",
"time_range": "1010-1035",
"status": "running",
"created": false,
"removed": true,
"roles": ["removed"],
"lines": [
{"text": "- [ ] (1010-1035) — JOB", "depth": 0, "change": "removed"},
{"text": " - [[job#^cleanup]]", "depth": 1, "change": "removed"}
]
}
],
"task_blocks": [
{
"relative_target": "job.md",
"route": "job",
"line": 1,
"block_id": "cleanup",
"text": "Do cleanup tasks!",
"status_symbol": "?",
"status_name": "Blocked",
"created": false,
"roles": ["dependency"]
},
{
"relative_target": "mac_inbox.md",
"route": "mac_inbox",
"line": 1,
"block_id": "ask-kelly-references",
"text": "Ask Kelly for references!",
"status_symbol": "*",
"status_name": "Next",
"created": true,
"roles": ["dependency_target"]
}
]
}
dependency_update equals dependency_updates[0]. For N *T that is
T's update (dependent T, prerequisites [N]), which older Mac builds
already render as a dependency on T. &-only items carry the array
too, with role: "dependent". blocked_residents is omitted when 0.
consumed_links[].action is removed, token_removed, or
notes_kept. line values are 1-based in the item's pre-state day
text. Execution vectors ST1–ST19 live in
task-dependencies.md §13.5.2.
Relating existing tasks with '&' and '*'¶
& adds a prerequisite to the item's owner; * makes the named task depend
on that owner. When the item has task text, that text creates the owner. When
it has no task text and names an existing task, that existing task is the
owner. An existing owner is edited in place and is never recreated as a new
bullet.
| Owner spelling | Meaning |
|---|---|
@route+id |
Canonical explicit owner; may lead or trail the relationship modifiers |
@@route+id |
Inherited owner for otherwise ownerless items anywhere in the draft |
@route:id |
Existing bare alias; with &/* it names the owner and never links it |
^route:id |
Bare active-task alias; may lead or trail &/*, and never links it |
The aliases stay narrow: @route:id and ^route:id name an owner only when
used bare with relationship modifiers. Without & or *, their existing
Pomodoro-link behavior remains unchanged. &, *, ~, and ~~ are all
relationship modifiers: the first two change status and lanes, while
~ / ~~ only write inert relation lines
(Linking tasks with '~' and '~~'). The
familiar two-item form still
keeps a blocked task in a session while it takes over the blocker’s slot:
@bob:ask-kelly
@bob+ask-kelly *job:cleanup
An owner must be in a location that @route can write: the inbox, an area
note, or a non-terminal project note. The prerequisite or blocked task can be
anywhere in the vault. If only one task is routable, flip the relationship so
that task owns it; for example, use
@cash+budget *projects/house/kitchen:cabinets instead of trying to own the
relationship from the unroutable project task.
Authoring flows¶
- Choose the owner first. Type
@body+for the note-scoped picker or+for the vault-wide picker, choose Exercise, then add&foo:baror*job:cleanup. In relationship mode, the picker explains which task will depend on or block the named task. Self-edges and cycles are disabled with Bob’s reason. - Choose the relationship first. Type
&foo:baror*job:cleanup, then type task text to create the owner. To use an existing owner, type+and choose it; the replacement is the canonical@route+idspelling.capture-parsereportsdependency_targetwhile the owner is missing andtask_parentwhile the+picker is open. A draft waiting for an owner shows adependency_targethint in Bob Mac Capture instead of a failed preview. - Use the current task. Pick the active task with
^, then add&or*. The resulting bare^route:idnames the owner without adding or moving a Pomodoro Task Link.
For several prerequisites or blocked tasks, the picker names the first and counts the rest, for example “Buy shoes + 1 more”. When only one relationship direction is present, the owner picker says which task will depend on or block the selected task; with both directions, it names both relationships.
Refusals¶
An unresolved + is never captured as task text. On submit it refuses with:
`+` needs a task selection; choose the task to relate before capture
Submitting a completed relationship modifier without either task text or an
existing owner also refuses rather than creating a task named after the
modifier. The ownerless & and * messages are:
task relationships need a dependent: add task text or '@note+task-id' for the task that should use the '¬e:block-id' modifier(s)
task relationships need a dependent: add task text or '@note+task-id' for the task that should use the '*note:block-id' modifier(s)
The interactive + picker is the route for selecting an existing owner.
A relationship-only item with an existing owner edits only the Depends-On and
BLOCKS lines. It cannot carry $<N>, s:<N>, %…, or authored child
bullets because those need task text. Parse emits the ranged error diagnostic
relationship_needs_text, and execution returns the same message. The
offending token is named in the message; for example:
`$2` needs task text: `@body+excercise` with no text only adds task relationships; add text to write a sub-bullet (`Note @body+excercise &foo:bar $2`)
The same template applies to s:3, a clipboard token such as %header, and
child bullets. To write a child bullet, add parent task text to the owner
line, as in Waiting @body+excercise &foo:bar followed by - detail.
An existing task that is Done or Canceled cannot be the owner of *; capture
refuses without changing the vault. The exact error is:
task ^old in foo.md is Done; a finished task cannot block another task (reopen it first)
The status word is Canceled for a canceled owner. Existing & closed-owner
refusal wording is unchanged. Suffixed owners (#name, =…, or !) keep the
existing “must be bare” refusal, since they combine relationships with a
Pomodoro action.
Terminal output¶
An existing-owner relationship uses the update layout: one header, one owner status row, then one row per relationship. Status arrows appear only when the status changes. A successful live example is:
✓ updated body.md
[/] → [?] Exercise ^excercise
⛓ now depends on "Buy shoes" foo.md ^bar [ ] → [/] In Progress
🧱 now blocks "Do cleanup tasks" job.md ^cleanup [*] → [?] Blocked
− removed its Task Link from BODY 10:10–10:35 (running) · deleted the empty Pomodoro
That live example is one capture of
@body+excercise &foo:bar *job:cleanup while Exercise is In Progress and
linked in the running Pomodoro. The final plan-budget footer is omitted
because its limits come from local configuration.
A dry run begins [dry-run] ok would update body.md; relationship rows say
would now depend on and would now block. An unchanged owner row uses the
toggle wording, for example [ ] stays Ready Exercise ^excercise.
Repeated relationships say already depends on or already blocks, with no
status arrow and no write. The existing · blocked N project tasks chip stays
on 🧱 rows.
New-task owners use the same per-relationship row shape, for example:
⛓ depends on "Buy shoes" foo.md ^bar [ ] Ready
For a sub-bullet owner, output prints the bullet and its authored children
first, then the relationship rows. The under row shows the parent’s final
status, such as under [ ] → [?] Exercise ^excercise. Consumption continues
to use the structured − rows.
Completion wire additions¶
The schema stays version 1 and these fields are additive. In
capture-complete, owner gains optional text, note_path,
status_symbol, and status_name fields. For an existing owner they describe
the resolved task; for new_task, text is the non-empty draft body.
Unresolved existing owners retain the old owner shape. Drafts without & or
* keep their current JSON byte-for-byte.
// Selected fields from bob capture-complete -b VAULT -c 17 -f json -- '@body+excercise &'
{
"context": "task_dependency",
"cursor": 17,
"replacement": { "start": 16, "end": 17 },
"query": "",
"owner": {
"kind": "existing_task",
"route": "body",
"block_id": "excercise",
"text": "Exercise",
"note_path": "body.md",
"status_symbol": "/",
"status_name": "In Progress"
}
}
When a complete &/* modifier is present in a task_parent or scoped
task picker, its picker descriptor gains a relationship object. Each
entry carries the typed raw marker, exact vault-relative note_path,
block_id, and resolved task text; unresolved entries omit only text.
Candidate rows gain disabled_reason for self-edges, cycles, and a project
depending on its own resident. A disabled row has no insertable replacement,
and shell completion omits it. These selected fields show the vault-wide +
picker in owner mode; its self row is disabled and a cycle row uses the same
guard:
// Selected fields from bob capture-complete -b VAULT -c 10 -f json -- '&foo:bar +'
{
"context": "task_parent",
"query": "",
"replacement": { "start": 9, "end": 10 },
"picker": {
"kind": "parent_task",
"scope": "vault",
"scope_token": "+",
"marker_range": { "start": 9, "end": 10 },
"trigger_removal_range": { "start": 9, "end": 10 },
"relationship": {
"dependencies": [
{ "raw": "&foo:bar", "note_path": "foo.md", "block_id": "bar", "text": "Buy shoes" }
],
"blocked_tasks": []
}
},
"candidates": [
{ "replacement": "", "text": "Buy shoes", "disabled_reason": "the prerequisite itself" },
{ "replacement": "", "text": "Cycle candidate", "disabled_reason": "would create a dependency cycle" }
]
}
Conformance vectors¶
Each RX execution vector checks parsing, exact vault bytes, JSON, human
output, dry-run/live agreement, and a second live run as a no-op where
applicable. foo.md#^bar is Buy shoes, body.md#^excercise is Exercise,
and job.md#^cleanup is Do cleanup tasks.
| # | Draft / state | Contract |
|---|---|---|
| RX1 | &foo:bar @body+excercise, @body+excercise &foo:bar |
Exercise depends on Buy shoes; Exercise becomes [?]; no new bullet; mode task_dependency |
| RX2 | @body+excercise *job:cleanup |
cleanup depends on Exercise; cleanup becomes [?]; Exercise gets the normal commitment transfer (task-dependencies.md §5) |
| RX3 | @body+excercise &foo:bar *job:cleanup |
cleanup → Exercise → Buy shoes |
| RX4 | ^body:excercise &foo:bar, &foo:bar ^body:excercise, ^body:excercise *job:cleanup |
Same relationship as RX1/RX2; parse owner is existing_task |
| RX5 | @body:excercise &foo:bar |
Existing bare @route:id alias behavior remains unchanged |
| RX6 | @@body+excercise then &foo:bar, with or without a blank line |
Edge only; no empty bullet; inherited: true |
| RX7 | @foo+old *job:cleanup, Note @foo+old *job:cleanup; owner [x] or [-] |
Refuse with the closed-owner * message; vault bytes are unchanged |
| RX8 | + &foo:bar, +ex &foo:bar, &foo:bar +, *job:cleanup +ex |
Parse needs task_parent, no diagnostics; submit refuses with the exact + message above; never create a +… task |
| RX9 | @body+excercise &foo:bar $2, s:3, or % |
relationship_needs_text in parse and the identical execution error |
| RX10 | @body+excercise &foo:bar plus child - note |
Same refusal, naming child bullets |
| RX11 | @body+excercise &foo:bar *foo:bar |
Refuse the cycle with foo.md^bar -> body.md^excercise -> foo.md^bar |
| RX12 | Waiting @body+excercise &foo:bar plus child - detail |
Print the sub-bullet and child, then ⛓; under shows [ ] → [?] |
| RX13 | @body+excercise#bugs &foo:bar, @body+excercise! *job:cleanup, ^body:excercise=x &foo:bar |
Existing “must be bare” refusals stay unchanged |
| RX14 | Owner [/] linked in the running Pomodoro; @body+excercise &foo:bar |
Existing consumption applies (− row; delete an emptied entry) with the new output layout |
| RX15 | Repeat RX1–RX4 | already depends on / already blocks; no write |
| RX16 | Buy milk &foo:bar |
Per-prerequisite row: ⛓ depends on "Buy shoes" foo.md ^bar [ ] Ready |
Completion vectors RXC check the additive wire contract:
| # | Draft (cursor at end) | Contract |
|---|---|---|
| RXC1 | @body+excercise & |
owner includes Exercise, body.md, and / / In Progress |
| RXC2 | ^body:excercise &, ^body:excercise * |
Same owner fields; Exercise is disabled as the dependent task itself / the owner task itself |
| RXC3 | Buy milk & |
owner: {kind: new_task, text: "Buy milk"} |
| RXC4 | &foo:bar + |
task_parent; picker.relationship.dependencies[0].text is Buy shoes; the owner row is disabled as the prerequisite itself, and a would-cycle row is disabled |
| RXC5 | *job:cleanup + |
picker.relationship.blocked_tasks; @job+cleanup is disabled as the blocked task itself |
| RXC6 | &foo:bar @body+ |
Scoped picker returns the same relationship object and guards |
| RXC7 | + &foo:bar, cursor after + |
task_parent; replacement covers only the + token |
| RXC8 | Any draft without relationship modifiers | Byte-identical to current output: no relationship object or new owner fields |
The RXC4 JSON excerpt above freezes the relationship descriptor and guard
strings. The RX8 parse shape for + &foo:bar keeps the incomplete parent
selection and typed modifier, without diagnostics:
// Selected fields from bob capture-parse -f json -- '+ &foo:bar'
{
"input": "+ &foo:bar",
"body": "",
"mode": "incomplete",
"needs": ["task_parent"],
"dependencies": [
{
"raw": "&foo:bar",
"note": "foo",
"block_id": "bar",
"quoted": false,
"range": { "start": 2, "end": 10 }
}
],
"diagnostics": []
}
Linking tasks with '~' and '~~'¶
~ links the item's task to another task; ~~ links another task to
the item's task. Both write entries on 🪢 **LINKS:** lines and let
reconcile maintain the 🪢 **LINKED FROM:** mirrors, exactly as
Task relations specifies. Owners use exactly the
& / * spellings: task text creates the owner, @route+id names an
existing owner, @@route+id is inherited, and bare @route:id /
^route:id alias without linking. A relation-only existing-owner item
uses mode task_dependency and reports relationship_needs_text, as
& does.
Fix flaky capture retries ~bob_done:capture-star|"follow-up of"
Write the outline ~~sase_blog:prj|"next step"
@bob+fix-retries ~sase:retry-design|see
Fix trip ~work:foo||prompted-by
Meaning. ~note:block-id means this item's task relates to that
task. ~~note:block-id means that task relates to this item's task.
A label after | is phrased from the source's side
(S — ‹label› → T); write |"follow-up of" for multi-word labels.
Labels obey the relation label rules.
A label after || is a one-off: the stored Markdown is identical to
the | form, but the label hides from suggestions once the capture
succeeds. Hiding a visible existing label warns with its link count;
hiding an already-hidden label is a silent no-op. A failed journal
append never fails the capture — it warns with the exact
bob relations hide retry command instead.
Guards. A ~ / ~~ token is a directive only when it sits in the
same leading or trailing run as & / *, whitespace-delimited. A
mid-line ~foo:bar stays body text. The byte after the sigil is not
whitespace or / (so ~/Downloads stays literal), and not a digit
when the token has no : (so ~5m, ~2h, ~5pm stay literal).
~~old~~ is strikethrough, x~~1 / =~~2 teach "use one ~ list",
and =~2 / x~1 keep their drop-list meaning. A third pipe is a
teaching error (use || to hide a label, | to name one). \~
escapes the sigil where it would otherwise be a directive; [[…]]
and backtick spans are never scanned. An unresolved bare or partial
sigil refuses on submit with `~` needs a task; choose one, or write
`\~` for a literal tilde.
Targets. A ~ target is any task with a block id: open, closed,
any active note, a daily note, or done/. A mirror is skipped for
archived and previous-daily targets. A ~~ target's LINKS line is
written, so it must be editable: the refusal set is the same as
*'s (daily notes, done/, _templates, _generated,
_conflicts, dot-directories, fenced code). Self-relations,
non-task targets, and id-less targets are refused with a teaching
error.
Refusals. A relation after x is an operator_relation_error.
Suffixed owners (#name, =…, !) keep the existing "must be bare"
refusal. A relation-only item cannot carry $<N>, s:<N>, %…, or
authored child bullets (relationship_needs_text).
Shell quoting. Quote ~ where the shell expands it
(bob capture 'Fix it ~work:foo'). In zsh, ~ at a word start can
glob; single-quote the draft.
Inertness. Relations never block, consume, schedule, stamp, or
route anything. Fix it ~work:foo leaves every status, lane, and
Pomodoro link exactly as it was, apart from a minted ^id.
Human output. Successful captures print 🪢 rows in the
dependency style, for example:
✓ captured mac_inbox.md
- [ ] #task Fix flaky capture retries [created::2026-10-14]
🪢 links to "Capture * blocks existing tasks" · follow-up of done/bob_done.md ^capture-star · archived
An existing-owner rerun of the same edge reports 🪢 unchanged; a
new label reports 🪢 relabeled. A one-off hide prints
🪢 hides "see" from suggestions (2 links). Dry runs say
would link to / would now link here / would relabel.
JSON. Every schema_version stays 1 and every field is
additive. capture-parse adds relations[] (top-level and per
item), the relation_target / relation_label needs, the five
relation_* span kinds, and top-level relation_grammar: 1 whenever
the draft contains a ~ byte. capture-complete adds the
relation_to / relation_from / relation_label contexts, with an additive
relation object (direction plus the sigil-to-| range) and owner on
relation_label responses.
capture-task-id adds relation_to_replacement and
relation_from_replacement. capture adds, per item,
relation_updates[], linked_from_line_updates[],
minted_relation_block_ids[], mirror_skipped[], and
relation_label_hides[] ({label, key, existing_count, was_hidden,
action: hide|already_hidden}, pre-capture counts, omitted when
empty), plus a hides_existing_relation_label warning object
({code, label, count, message}) when a one-off hides a visible
existing label. See Task relations.
Completing tasks with '!'¶
A capture item that is exactly !note:block-id marks that existing open
task Done, without closing a Pomodoro. The token names the task with the
same vault-wide note locator ¬e:block-id uses, so ! and & share
one lexer, one resolver, and one replacement formatter: simple root notes
use !foo:bar, nested notes use !projects/foo:bar, and notes with
whitespace or reserved punctuation use a quoted component such as
!"Shopping List":milk (inside quotes only \" and \\ are escapes).
Single-quote the draft on shells that history-expand !:
bob capture '!sase:fix-flaky'.
A complete token must be the entire capture item: no body text, children,
%, s:, $<N>, #name, =…, @@, or forced destination flags.
Anything extra is a teaching error, never a junk inbox task. Bulk means
one ! item per blank-line-separated block; every item is planned
against the same staged snapshot, and any failure rolls the whole batch
back.
| Draft item | Reading |
|---|---|
! |
Query "": incomplete, needs task_complete, the picker opens |
!fix, !sase:, !"Shopping List, !"Shopping List" |
Query: incomplete, needs task_complete |
!sase:fix-flaky |
Complete: mode task_complete; completes ^fix-flaky in the note sase resolves to |
!projects/foo:bar, !"Shopping List":milk |
Complete: nested and quoted locators, exactly as & decodes them |
!sase:fix-flaky more, !sase:fix-flaky plus a child line, !sase:fix-flaky $1 |
Claimed and invalid: invalid_task_complete |
!sase:fix-flaky=x, !sase:fix-flaky#bugs |
Claimed and invalid, with the hint "! takes no = or # suffix; to complete the running session's link while closing, use x!N" |
!sase:a !sase:b |
Claimed and invalid, with the hint "put each completion on its own item, separated by a blank line" |
!wow this works, ! foo, Wow!, Buy milk !sase:x |
Prose, unchanged: several tokens with an incomplete first token, a space after !, or a non-leading ! |
!fix plus a child line |
Prose: an incomplete token on a multi-line item, like :dee plus a child |
![[sase#^x]], , !!, !!! |
Prose: embeds, images, and ! runs are never claimed (the second byte is [ or !) |
bob capture refuses a claimed query before any other item parser, with
the usual capture item K starting on line L: prefix and exit 2:
`!fix` opens the task picker; pick a task to insert its `!<note>:<block-id>` token, or write one yourself (for example `!sase:fix-flaky`).
A claimed invalid item reads
`!sase:fix-flaky` completes an existing task and must be the whole capture item; remove `more`
(or remove its child lines), plus the targeted hints above.
@@ declarations never apply to a ! item.
capture-parse (schema 1, additive) reports query items with mode
incomplete, needs ["task_complete"], an empty body, and one
interactive_placeholder span over the whole token, sigil included. It
reports complete items with mode task_complete, block_id, three spans
(task_complete_sigil over the !, task_complete_note over the note
with quotes, task_complete_block_id over the ID), and the per-item
task_complete object (raw, decoded note, block_id, quoted, and
the sigil-inclusive range). When the draft has one item and that item is
task_complete, the same task_complete object is emitted at the top level
of the capture-parse result, mirroring how top-level dependencies is
emitted; other drafts omit it. Claimed invalid items report mode
task_complete with an invalid_task_complete diagnostic, the same mode
handling invalid_pomodoro_link uses for a padded ^route:id.
Execution completes the named task through the shared task-completion engine, in this order and all against the staged batch snapshot:
- Resolve. The note resolves exactly like
&: an explicit relative path first, else a unique basename (notes staged earlier in the batch count too). The block ID resolves through the same lookup, with the same missing-with-suggestion, duplicate, and not-a-task errors. - Validate the status. Ready
[ ], Blocked[?], Next[*], and In Progress[/]complete. An already-Done task is an idempotent no-op (action: "already_done", nothing written). Canceled and unknown statuses fail with an actionable error. - Refuse recurring tasks. A line with
[repeat:: …],(repeat:: …), or🔁fails: complete recurring tasks in Obsidian so Tasks writes the next occurrence. - Close the tree exactly as
x!Nwrites it: the root getsxplus[completion:: YYYY-MM-DD](only when the line carries the global-filter tag), and embedded![[…]]subtasks close recursively (Blocked, recurring, unknown-status, and over-cap descendants are left open and reported, never silently dropped). A Blocked descendant and everything below it stay open. Unlikex!N, the root may be Blocked. - Retire Task Links in today's daily note (when it has a
## Pomodorossection): every completed identity is struck, its bullets move to the running entry (else the last completed entry), emptied placeholders are removed, and a carried copy is dropped when the destination already links the task. - Recover dependents and offer successors. Blocked
[?]dependents that name a completed task's[id::], have no other open dependency, and have no strictly futurescheduleddate recover to their derived rank, exactly like Task Status Cycler's Ctrl+Enter recovery. Every dependent this close fully unblocked, plus every available resident of a project whose^prjthis close unblocked and each project pull-in, is offered; only--pull-inpicks are linked into the predecessor's slot and become Next in the same write, per task-dependencies.md §12. Capture never prompts and never auto-links. Picked successor links are placed before retirement runs (step 5), so retirement strikes or moves the predecessor around them and the vacated slot keeps the pick.
Status-group moves, ^prj frontmatter, #hide, and archiving stay with
their existing writers (bob task reconcile, bob projects sync, and
bob task archive); capture never writes them.
Worked example. Before (sase.md holds the task, today's daily note holds
the link under the running CAPTURE session):
- [*] #task Fix flaky gkeep test ^fix-flaky
## Pomodoros
- [ ] (**0920-0950**) — CAPTURE
- [[sase#^fix-flaky]]
bob capture '!sase:fix-flaky' writes
- [x] #task Fix flaky gkeep test [completion:: 2026-10-05] ^fix-flaky,
strikes the link in place to ~~[[sase#^fix-flaky]]~~, and prints:
✓ completed [*] → [x] Fix flaky gkeep test sase.md ^fix-flaky
ledger Task Link struck in CAPTURE · 20261005.md
After, the day file reads:
## Pomodoros
- [ ] (**0920-0950**) — CAPTURE
- ~~[[sase#^fix-flaky]]~~
Dry-run JSON equals real-run JSON except for dry_run. A dry-run excerpt
for the same capture carries the additive task_complete object with its
clean text and named ledger entries:
"task_complete": {
"raw": "!sase:fix-flaky",
"note": "sase",
"note_path": "sase.md",
"block_id": "fix-flaky",
"action": "completed",
"completion_date": "2026-10-05",
"text": "Fix flaky gkeep test",
"subtasks": [],
"subtasks_left_open": [],
"ledger": {
"day_file": "20261005.md",
"struck": 1,
"struck_in": [{"line": 2, "name": "CAPTURE", "status": "running"}],
"moved": [],
"deduplicated": 0,
"dropped": [],
"removed_placeholders": []
},
"unblocked": []
}
The batch also carries top-level pull_in on every success (supported:
true, offer_count, choice, picked_count). Dry-run JSON equals
real-run JSON with the same flags, including --pull-in. To pull in
numbered offers, pass -p 1,2, -p all, or -p none:
bob capture -p 1 -- '!sase:fix-apollo'
An unknown or stale pick is a teaching error that writes nothing and
lists current offers. Without -p, capture recovers and reports
numbered offer N rows as not pulled in.
The ledger line names entries in this order, joined with ·, and ends
with the day file: one Task Link struck in <NAME> row per entry with an
in-place strike ((completed) appended when that entry is completed),
one Task Link moved <FROM> → <TO> (struck) row per move, one
Task Link already in <TO>; dropped the <FROM> copy row per deduplicated
bullet, and one removed empty <NAME> row per removed placeholder. When
an entry has no name, JSON carries "name": "" and the human line uses
line N. Other variants from the same vocabulary:
ledger Task Link struck in PLAN (completed) · 20261005.md
ledger Task Link struck in SASE · Task Link moved SASE → CAPTURE (struck) · removed empty SASE · 20261005.md
ledger Task Link already in MORNING; dropped the SASE copy · removed empty SASE · 20261005.md
Refusals leave the vault byte-identical. Each failure prints
bob capture: capture item K starting on line L: <message>; usage errors
exit 2 and I/O-class errors exit 1:
| Refusal | Exit | Message |
|---|---|---|
Forced destination flags (--route, --section, --task, --task-section, --clip) on the ! item |
2 | `!sase:solo` completes an existing task and must be the whole capture item; remove its forced destination flags |
| Ambiguous note | 2 | ambiguous note "dup": matches a/dup.md, b/dup.md; use the full relative path |
| No such note | 2 | no such note: nosuchnote |
| Canceled task | 1 | `^cx` in sase.md is Canceled; reopen it before completing it |
| Unknown status | 1 | `^wx` in sase.md has status `[>]`; only Ready, Blocked, Next, and In Progress tasks can be completed |
| Recurring task | 1 | `^water` in sase.md repeats; complete recurring tasks in Obsidian so Tasks writes the next occurrence |
| Missing ID (with a close-match suggestion when one is close) | 1 | no task with block ID ^missing in sase.md (run 'bob capture-tasks -r sase.md' to list task block IDs) |
| Duplicate ID | 1 | block ID ^dup appears 2 times in sase.md; make it unique before capturing |
| Non-task line | 1 | ^nt1 in sase.md is not a task (line 6: plain line ^nt1) |
Routing inbox tasks out¶
When a capture changes the checkbox of an existing task in inbox.md or a
direct area child of inbox.md, bob reports it so a client can ask where the
task should go. This comparison runs after the whole batch and successor
recovery have been planned. It includes completions and other status changes
caused indirectly by the batch. Edits to fields or children alone do not
count. A nested status change reports its column-zero #task ancestor, since
that is the block a move carries. New tasks, ^prj tasks, tasks in projects
under an inbox area, and untyped notes under an inbox are not candidates.
Detection vectors (IR) run against a sandbox vault:
| ID | Transition | Expected report |
|---|---|---|
| IR1 | Link a Ready task in mac_inbox.md |
One Ready → Next candidate |
| IR2 | Link an already-Next task and create a new inbox task in the same batch | Neither task is a candidate |
| IR3 | Link a task in the direct gkeep_inbox.md child |
One candidate classified from its live parent |
| IR4 | Change a nested task, an id-less task, and a task to Done | Report the column-zero parent, stable id-less keys, and the completion |
| IR5 | Change a task in a project under mac_inbox.md |
No inbox candidate |
| IR6 | Request a route choice with the hidden guard | Exit 1 with inbox_route_choice_required; notes stay byte-identical |
| IR7 | Require both pull-in and inbox-route choices | The pull-in refusal wins |
| IR8 | Build destinations with inboxes, nested notes, blocked ^prj, projects, and excluded folders |
Sorted task-move destinations with matching kind, status, parent, and blocked fields |
Apply vectors (IRA) answer the candidates with -i/--inbox-route:
| ID | Transition | Expected report |
|---|---|---|
| IRA1 | Answer 1=health for a linked Ready task |
The task moves to the area with its Task Link repaired, fresh stamped, and task_blocks/open_notes following it |
| IRA2 | Dry-run and real-run the same answered capture | Identical JSON except for dry_run |
| IRA3 | Answer 1=health for a completed task |
The Done task moves home with no stamp |
| IRA4 | Answer an id-less recovered dependent by key | The task moves by exact line with fresh stamped and no unblocked marker |
| IRA5 | Answer a move whose block ID collides in the destination | The block is renamed and the rename is reported |
| IRA6 | Answer two candidates with two destinations in one bulk draft | Each task moves to its own destination |
| IRA7 | Answer keep and N= |
Both keep the task; kept candidates satisfy the guard |
| IRA8 | Answer one candidate with two destinations | Exit 2; nothing is written |
| IRA9 | Answer an unknown candidate | Exit 2 with inbox_route_stale listing the current candidates; nothing is written |
| IRA10 | Answer an inbox destination | Exit 1 with inbox_route_refused; nothing is written |
| IRA11 | Answer a project without ## Tasks |
Exit 1 with the engine rule; nothing is written |
| IRA12 | Answer a Blocked ^prj destination for a Next task |
Exit 1 with the engine rule; nothing is written |
| IRA13 | Answer --pull-in picks and a route together |
Both apply in one batch |
| IRA14 | Route a task a dependency touch tracks | Its task_blocks entry follows it and the engine mirror is repaired |
| IRA15 | Change a link-repaired file between planning and commit | The whole batch aborts with the NoteChanged failure; nothing is written |
Every successful capture JSON, including dry runs, carries inbox_route:
"inbox_route": {
"supported": true,
"candidate_count": 1,
"routed_count": 0,
"kept_count": 0,
"candidates": [{
"key": "mac_inbox.md#^route-task",
"n": 1,
"note_path": "mac_inbox.md",
"inbox": "mac_inbox",
"block_id": "route-task",
"line": 8,
"text": "Prepare the handoff",
"previous_status_symbol": " ",
"previous_status_name": "Ready",
"status_symbol": "*",
"status_name": "Next",
"answer": null,
"outcome": "pending"
}],
"destinations": [{
"relative_path": "areas/health.md",
"route": "areas/health",
"label": "health",
"kind": "area",
"status": null,
"parent": null,
"blocked": false
}]
}
Candidates are numbered by note path and original line. A task with a block ID
uses path#^id as its key; an id-less task uses path@<sha8> and adds a
~N suffix when the display-text digest repeats in that note. Treat keys as
opaque. Candidate status symbols and names describe the task block that would
move. block_id is an empty string when the candidate has no block ID.
destinations is omitted when there are no candidates. It lists routable area
notes and wip or waiting projects, sorted by vault-relative path. Inbox
notes and notes under done/, _templates/, and _conflicts/ are excluded.
Each row includes the resolved parent label when available and whether the
destination's ^prj task is Blocked.
bob capture never opens a picker. Bob Mac Capture uses this report to ask,
then submits the selected route with the capture. Its hidden
--require-inbox-route-choice guard returns exit 1 with
code: inbox_route_choice_required and the same inbox_route payload when a
candidate has no answer; it writes nothing. The pull-in choice refusal runs
first, so clients resolve successor offers before inbox routes.
Answer candidates with repeatable -i, --inbox-route ANSWER:
<N|KEY>=<NOTE>moves the candidate toNOTE(a stem or vault-relative path, resolved likebob task move'sDEST, and required to be in the destination set).<N|KEY>=keeps the candidate in its inbox.keepkeeps every candidate not otherwise answered.
Two different destinations for one candidate are a usage error (exit 2).
An answer naming an N or KEY that is not a current candidate fails with
exit 2 and code: inbox_route_stale, listing the current candidates exactly
as -p does for stale picks; it writes nothing. An answer whose destination
does not resolve or is refused fails with exit 1 and
code: inbox_route_refused, adding refused (key, n, destination,
message) beside inbox_route; it writes nothing. Conflicting answers,
stale answers, and refusals never write. Without the guard flag, unanswered
candidates stay where they are and the real run still succeeds with
outcome: pending.
Answered moves run as a new stage inside capture planning: after every item,
the successor net post-pass, and candidate detection, and before the task
and Pomodoro block trackers finish. The capture acts first, then moves each
answered candidate through the bob task move engine (--inbox-route is
the only caller that moves closed selections or selects by exact line) in
one atomic batch: a full vault snapshot overlaid with the staged texts,
per-group move plans, the freshness stamp on each moved root line, and the
same link repair, collision renames, and preimage guard as any other batch
write. A moved open task is stamped [fresh:: today] like a Ctrl+Shift+M
move; a moved Done task is never stamped.
The move updates the batch outputs to their final, post-route text.
answer becomes {"action": "move", "destination": "<relative_path>"} or
{"action": "keep"}; outcome becomes moved or kept. A moved
candidate gains moved (destination, line, block_id,
previous_block_id, fresh, which is the stamped date or null). Once a
move is applied, inbox_route also carries repaired_files (path,
link_count, dependency_token_count), renamed_block_ids (path,
old_block_id, new_block_id), and needs_reconcile. A routed task's
task_blocks entry follows it to the destination path and its final lines
and block ID, and an open_notes entry that would land on a routed task
opens the destination at the moved line instead.
The human output prints one inbox row per pending candidate and a route
hint, one kept <text> · still in <inbox> row per kept candidate, and one
routed <text> <inbox> → <route> row per moved candidate, with
· ^old renamed ^new and · N links repaired suffixes when they apply. In
dry-run output the rows say would route; ordinary previews never write.
The run 'bob task reconcile' to reconcile Blocked state hint prints when
needs_reconcile is true.
Plan budget and strict mode¶
When a batch changes today's Pomodoros section, the result carries a
top-level plan_budget with before/after meters (dry-run and real runs
use the same planner, so they agree). It is never per item, and it is
omitted when the ledger is unchanged:
"plan_budget": {"status": "over",
"themes": {"count": 4, "cap": 3, "over": true, "before": 3},
"links": {"count": 8, "cap": 10, "over": false, "before": 7},
"added_themes": ["BOB"],
"added_theme_refs": [{"key": "bob", "name": "BOB", "hue": "magenta",
"hue_source": "hash"}],
"warnings": [{"code": "plan_theme_cap_exceeded",
"message": "today's plan now has 4/3 themes (adds BOB); queue it with ^ or defer with $<N>"}]}
Warnings fire only for plan_theme_cap_exceeded and
plan_link_cap_exceeded, and only while the batch grows that meter past
its cap (after > cap and after > before). A capture that leaves an
over-cap plan alone stays quiet. Human output prints one meter line
after the result (plan 4/3 themes · 8/10 links (+1 theme: BOB)) and
one bob capture: warning: … stderr line per fired warning; the
warnings never join the item warnings array. An invalid plan config
skips the budget and pushes one plain string into warnings instead.
added_theme_refs is ordered with added_themes. Human Task Link
destinations add a colored square before each known, non-exempt component
when stdout is a color terminal; NO_COLOR keeps the existing destination
text unchanged.
With plan.strict: true, the whole batch is refused, atomically, when
a non-start item creates a new named Pomodoro entry past the theme cap
(<text> @r:id#NAME, @r^id+#NAME project notes, @r+id#NAME
toggles, and any other non-start creation path). Session starts and overrides
(=, =<X>#NAME, #NAME=, and link starts) are never refused. A named start
that creates a theme still reports plan_budget and the cap warning. The
refusal is an
I/O-class error (exit 1) whose JSON carries a machine-readable code:
{"ok": false, "error": "refusing capture: today's plan would grow to 4/3 themes (adds BOB); …", "code": "plan_theme_cap_exceeded"}
code appears on this refusal and on pull_in_choice_required
(§12.14 when --require-pull-in-choice is set, offers exist, and no
--pull-in was given); every other capture failure omits it.
Every pomodoro_link_destination carries a role naming how the entry
was chosen: current (the running timed entry), next_up (implicitly
chosen and not running), named (an existing entry matched by #NAME),
or created (a new entry). New <text> @route:id[#NAME]
Pomodoro-task captures report pomodoro_link_destination,
pomodoro_name, and creates_pomodoro too, and human output names the
landing spot: → into running GOALS (0945-1015), → under GOALS (next
up), → under GOALS (named), or → new Pomodoro BOB. In the
pomodoro_name completion context, creates_pomodoro rows preview the
result with plan_themes_after and plan_themes_cap, omitted when the
daily note or the plan config is unavailable. In the pomodoro_start_name
context, the new and again rows carry plan_themes_after and
plan_themes_cap too.
Starting the next Pomodoro¶
Capture a whole item =/=<X> to start today's next future Pomodoro now:
bob capture '='
bob capture '=3'
bob capture '=-2'
printf 'x\n\n=\n' | bob capture
Pomodoro sessions form a lifecycle, taught in this order:
| Item | Operator | Effect | Obsidian |
|---|---|---|---|
= / =<X> |
start / restart | Start the next future Pomodoro, or restart today's running Pomodoro with fresh se<X> timing (= is 25 minutes); an override shows a warning before the write |
se<X> + Tab inside () or on the running line |
=<X>#<pomodoro> |
start named / swap | Start that named Pomodoro (open match in place, completed match again, else created); while another session runs, swap in a different name, keeping its clock with an empty <X> or using fresh timing when <X> is given |
se<X> + Tab inside () — NAME or on the running line after picking the name |
+[N] / -[N] |
resize | Move the running session's end N × 5 min later / earlier | N\p / N\P |
++[N] / --[N] |
shift | Move the running session's start and end N × 5 min later / earlier | N\o / N\O |
x |
close | Close the running session | Ctrl+Enter |
Mnemonic: "= starts the next session — or restarts the running one; =name starts that one — or swaps it in; x stops the running one."
Recognition: let t be the item's first physical line trimmed of leading
and trailing whitespace (the token after a chain split; see
Chaining session operators on one line).
A start token is = followed by the longest run
matching the se<X> suffix grammar [0-9]*(-[0-9]*)?. A token is bare when
its suffix is empty or contains no digit (=, =-) and counted when its
suffix contains at least one digit (=3, =-2, =3-, =2-1, =0).
The whole-item close is recognized first, as a bare lowercase x token
(plain x, selection-shaped x…, or the x#… near miss), so a start token
never consumes a close. An exact start (t is exactly one start token and the item has
exactly one physical line) goes through the existing se<X> timing, so =
is 25 minutes, =3 is 15 minutes, =- is 25 minutes with a 5-minute
offset, =2-1 is 10 minutes with a 5-minute offset, 0 and leading zeros
are valid, and an oversized value fails before any write. A counted token
followed by anything else (=3 more, =-2 @work, =3s:1, =2-1-, =3x)
or an exact token with child lines fails as an invalid start, never a task.
A bare token followed by more text (= foo, =- foo, =-)), bare
x lookalikes (xx, xa,
X, X2), and mid-body tokens (Plan =3, Plan =bugs, Plan x, a=3) stay ordinary
prose. =xx and =xa are named starts now, not prose. The retired == spelling stays prose when followed by more text,
which protects Obsidian highlight syntax; a claimed == start token gets
the retirement error and teaches the matching = spelling.
Guards, checked in order on the staged daily note: a missing day file, a
missing ## Pomodoros section, more than one open timed entry, and no future
Pomodoro (a bare = never creates an entry;
=name creates and starts a named session (see "Starting a named
Pomodoro"), and ^route:block-id= starts a task's session). "Next future
Pomodoro" means the first entry
in document order that is open, a placeholder, and untimed — the same session
the x diagnostic names as "next up". If one open timed entry already
runs, a whole-item = restarts it as described in
Overriding the running Pomodoro; link
starts keep their separate refusal.
Selection, time, rewrite, and transaction mirror Obsidian's se<X> + Tab:
the () becomes the canonical (**HHMM-HHMM** [t:: Nm]) range, preserving
checkbox, name, other bytes, children, CRLF, and a missing final newline,
then the entry moves to the current slot. No entry is created, no link is
added, and no task note is written. Later items see earlier staged edits and
dry-run computes without committing; any failure rolls the whole batch back.
Composable idioms: x, blank line, = switches sessions (or x = on one
line; the x closes before = plans, so no override warning appears);
=, blank line, +2 starts then extends; =, blank line,
^bob:ready starts then links a
task into the now-running session. A @@ declaration never applies to start
items, and forced --route/--section/--task/--task-ref/
--task-section/--clip fail on them; %, s:<N>, and $<N> after a
counted token are shape errors by construction.
The started session reports its queued Task Links (direct-child Task Links
of the started entry, resolved read-only). The lineup is numbered 1..N in
ledger order on the staged pre-image, so every whole-item start shows which
number a queued link holds: JSON kind pomodoro_start keeps
schema version 1 with text = the raw token, task_line = the started
ledger line, placement: "started", and the existing pomodoro_start
object with created_pomodoro: false plus an additive tasks array where
each row carries its index (its lineup number). The
started session also appears in the batch-level pomodoro_blocks array;
see Pomodoro blocks.
Human output prints the started file, the session range and line, the
canonical ledger line, one numbered row per queued task, and nothing queued
when empty.
Quote = items in zsh, which expands a leading =word to a command path:
bob capture '=', bob capture '=3', bob capture '=-2'.
Single-quote close tokens that contain !, *, or ~ (bob capture 'x!1',
bob capture 'x*').
Dropping queued Task Links as a session starts¶
Write a trailing ~<K> drop list on a whole-item start to start lean:
=~2 starts the next session without queued Task Link 2. One-card
mnemonic: ~ drops — x~2 drops task 2 from the session you stop,
=~2 drops task 2 from the session you start, using the numbers the start
lineup shows.
<K> is comma-separated task numbers, each at least 1, with no whitespace,
in any order; JSON reports the list sorted ascending. The drop part always
comes last: after <X>, and after #name when present. A named start's
name part ends at the first ASCII whitespace or ~. Any start token with a
drop part claims its item, near misses included; a bare = followed by
separate text (= ~2, = foo) stays prose. Link and task starts
(^route:id=<X>, @route:id=<X>) do not take a drop part.
The lineup is the started entry's direct-child Task Links in ledger order,
numbered 1..N on the staged pre-image (after earlier batch items and chain
tokens have run). Every whole-item start reports these numbers, including a
plain =. A named start that creates its session has an empty lineup, so
any drop list fails before insertion.
Each dropped Task Link bullet is removed together with its nested child lines (which belong to that link in a session about to start — unlike a close, which keeps notes as history). No task note is written and no lane changes: a dropped task keeps its status. Dry-run computes without writing, and any failure rolls the whole batch back.
JSON kind pomodoro_start keeps schema version 1: text is the raw token,
pomodoro_start.drop is the typed list (omitted when empty),
pomodoro_start.tasks[] are the rows still queued (each with index),
and pomodoro_start.dropped[] are the removed rows (with index,
the pre-image ledger_line, and nested_lines, omitted when 0).
Human output numbers the queued rows, prints dropped rows inline (dropped
2 [[T]] · stays Next · +1 nested line), follows with a dim Dropped <K>
summary, and prints nothing queued when nothing is left.
Worked example (BOB_NOW=2026-09-30 09:42:00, TAB indentation;
^capture-stop, ^web-capture, and ^axe-restart are [*]):
## Pomodoros
- [x] (**0830-0855** [t:: 25m]) — PLAN
- 🍅 [[bob#^capture-stop]]
- [ ] () — CAPTURE
- [[bob#^capture-stop]]
- [[bob#^web-capture]]
- remember the URL parser
- [[sase#^axe-restart]]
- [ ] () — SASE
bob capture '=' numbers the lineup 1 ^capture-stop, 2 ^web-capture, 3
^axe-restart. bob capture '=~2' writes:
## Pomodoros
- [x] (**0830-0855** [t:: 25m]) — PLAN
- 🍅 [[bob#^capture-stop]]
- [ ] (**0945-1010** [t:: 25m]) — CAPTURE
- [[bob#^capture-stop]]
- [[sase#^axe-restart]]
- [ ] () — SASE
(bob.md and sase.md are untouched.)
Starting a named Pomodoro¶
Capture a whole item =name to start one named Pomodoro now (=#name is the same token):
bob capture '=bugs'
bob capture '=3#bugs'
bob capture 'x =bugs'
A named start token is = plus an se<X> suffix (possibly empty) plus a name: either a bare name right after = (=bugs), or # plus a name part (=#bugs). A bare name starts with an ASCII letter, is not close-shaped, and does not start with x#/X#; it ends exactly where a #name part ends (whitespace, ~, or @). The # spelling is still always valid: write # only when a number comes first (=3#bugs) or the name starts with a digit or reads as a close (=#2fa, =#x, =#x2). =bugs and =#bugs lex to one token and produce the same CaptureKind, vault writes, and capture-parse semantics; only input/body echoes and span offsets differ. The <X> goes before # or last after @ (=bugs@3 means =3#bugs). The name part is a Pomodoro
selector with the usual charset (A-Z, a-z, 0-9, & ' ( ) + , . / -);
write spaces in names as -. To spell the name exactly, quote it after #:
=#"DEEP WORK" (or =#“DEEP WORK” with curly quotes) names DEEP WORK
verbatim, matched by key equality instead of the whole-slug/else-prefix
rule, so SASE V18 and SASE-V18 stay distinct and =#"BOB" creates BOB
even with BOB BUILD open. Bob always emits straight quotes; \" and \\
are the only escapes inside the quotes. The token runs through the closing
quote, and a literal without its closing quote (=#"DEEP) is an incomplete
editing state, never a mistake. The bare =name spelling never takes quotes:
="BOB" is ordinary prose. Every #name position takes the literal —
named starts, link suffixes (@route:id#"…", @route+id#"…",
^route:id#"…"), project-note bullets, toggles, and close positions — so one
rule holds everywhere. A named start claims its item and is never
prose: an exact token (the item's only text, on exactly one physical line)
starts the session, and anything else is a precise error. Unchanged: =
without a name, every close form, = #foo and = bugs (a bare = with prose stays prose),
mid-body tokens (Plan =bugs stays prose; put any word before it to capture a task that must begin with =word, and use a child bullet for a Work Log entry that must end in =word), doubled == (which keeps protecting Obsidian highlights), and the link forms
(^route:id#name=<X> keep their #name=<X> order alongside the new =name suffix). x#… is a claimed
close near miss with a teaching error; it used to be prose.
Resolution runs against the staged daily note, so chains and batches see earlier edits. Open entries match first (whole slug, else prefix), then completed ones the same way:
| Match | Effect |
|---|---|
| Open untimed placeholder | Starts in place: the () becomes the canonical range and the entry, with its child block, moves to the current slot; created_pomodoro: false |
| Completed entry ("again") | A new session is created with that entry's canonical name and started; history is never modified; created_pomodoro: true |
| No match | A new session is created with the selector's canonical name and started; created_pomodoro: true |
Creation inserts the placeholder after the last completed entry's block,
otherwise before the first entry, otherwise at the section start — the same
placement the named link start uses — and the subsequent move is then a
no-op. An invalid name fails before anything is written, and when the name
is only a near miss of an open entry the capture still succeeds but pushes
a did you mean …? warning naming the open entry. A created entry reports
tasks: []; a started open entry reports its direct-child Task Links
through the existing queued lineup, exactly like an unnamed start, with
the same tasks[].index numbering.
Guards, checked in order: a missing day file (the message still names the
canonicalized selector), a missing ## Pomodoros section, name resolution,
then more than one open timed entry. With exactly one timed entry, a different
named target swaps in behind the same override warning as =. A counted
start naming the running Pomodoro restarts it with the requested timing;
an empty suffix naming that same session is refused because it would not
change which session runs:
`=#capture` names CAPTURE, which is already running (0920-0945, line 5): restart it with `=` or `=<X>`, close it with `x`, or name another Pomodoro to swap in
When a running session must stay untouched, Bob Mac Capture passes the hidden
--refuse-override guard until the warning has appeared in its preview. A
matched open entry that is not an untimed placeholder or the running target
fails with the existing selected-Pomodoro wording instead of starting.
With BOB_NOW=2026-07-10 09:02:00 and a ledger holding completed PLAN,
open BUGS (with [[sase#^deep-fix]]), open DEEP WORK (with
[[bob#^outline]] and [[bob#^draft]]), and an open unnamed entry:
| Item | Result |
|---|---|
=deep-work |
DEEP WORK becomes (**0905-0930** [t:: 25m]) and moves ahead of BUGS; queued: ^outline, ^draft |
=deep |
Identical (prefix match) |
=3#bugs |
BUGS becomes (**0905-0920** [t:: 15m]) and stays in place |
=bugs@3 |
Identical to =3#bugs |
=plan |
A new PLAN session is created and started (the old PLAN is completed history) |
=#"DEEP WORK" |
DEEP WORK starts exactly as spelled, by key equality rather than slug or prefix |
=#"BOB" with BOB BUILD open |
A new BOB session is created and started instead of selecting BOB BUILD |
The bytes contract matches the unnamed start: checkbox, name, children,
CRLF, and a missing final newline are preserved. Dry-run computes the same
result without writing, and any failure rolls the whole batch back. JSON
kind pomodoro_start keeps schema version 1 with text = the raw token,
task_line, placement: "started", the existing pomodoro_start object
plus pomodoro_name, created_pomodoro, and the additive tasks array.
The started session also appears in the batch-level pomodoro_blocks
array; see Pomodoro blocks.
Human output prints started (dry-run: would start), the session name,
range, and line with (created) when the entry was created, the canonical
ledger line, one numbered row per queued task, and nothing queued when
empty. A @@ declaration never applies to named starts, and forced
--route /
--section / --task / --task-ref / --task-section / --clip fail on
them with the existing start error. Quote named starts in zsh like every
other = item: zsh's EQUALS expansion rewrites a word that starts with =, so bob capture =focus fails unquoted. Always single-quote (bob capture '=bugs', bob capture 'x =bugs', bob capture '=#"DEEP WORK"').
Overriding the running Pomodoro¶
A whole-item = / =<X> restarts the single running session with fresh
se<X> timing. A whole-item =name starts or swaps in the named
session. The warning appears in the preview before the write, so the person
can see which running session will be replaced. Link starts such as
@route:id=<X> and ^route:id=<X> keep their existing refusal while a
session is running; only a whole-item start overrides.
bob capture '='
bob capture '=3'
bob capture '=-2'
bob capture '=bugs'
bob capture '=3#bugs'
For a restart, = means 25 minutes, =3 is 15 minutes, and =-2 is 25
minutes with a 10-minute offset. The running session stays the same; Bob
replaces its parenthesized time ledger and moves it to the current slot. A
trailing ~<K> drops Task Links from the session that remains running,
numbered from the start lineup. Restarting is allowed even when the running
session is overdue or has notes; plan.strict never refuses a start or
override.
The dry-run warning is printed ahead of the result. With the worked example below, Bob prints these exact lines:
⚠ overrides the running CAPTURE 0920-0945 · capture `x =` to close it first
CAPTURE 0920-0945 → 0935-1000 (25m) at line 5
A swap names the target in its close-first draft:
⚠ overrides the running CAPTURE 0920-0945 · capture `x =bugs` to close it first
When the session is overdue, the warning says so:
⚠ overrides CAPTURE 0920-0945, still open past its end · capture `x =` to close it first
For an unnamed running session with Task Links, the warning, the swap
demoted line, and the --refuse-override error name its derived homes
chip after the range:
⚠ overrides the running session 0920-0945 ○ sase · capture `x =` to close it first
session 0920-0945 ○ sase → first future at line 7 · keeps 2 Task Links
An unlinked unnamed session prints exactly as before.
A named start resolves an open whole-slug match first, then a prefix match;
a completed match starts again with that name, and no match creates it. When
a different session is running, =bugs swaps BUGS in and hands it the
running session's ledger byte-for-byte (ledger: "kept"); =3#bugs swaps
it in with fresh 15-minute timing. =5#bugs explicitly swaps in with fresh
25-minute timing. The old session returns intact to the first future slot,
including its links and notes. Drops apply to the session that ends up
running. A swap follows the same warning and guard as a restart.
Naming the running session with an empty suffix is refused because it would not change which session runs. Add timing to restart it, or choose another name to swap in. For example, Bob reports:
`=#capture` names CAPTURE, which is already running (0920-0945, line 5): restart it with `=` or `=<X>`, close it with `x`, or name another Pomodoro to swap in
With exactly one open timed entry, the pomodoro_start.override object is
present on the pomodoro_start result. Its action is restart or swap;
ledger is fresh for a restart or a timed swap, and kept for an empty-
<X> swap. previous describes the running session before the write;
pomodoro_name is omitted for an unnamed session. overdue is omitted when
false and true once the current time reaches the session end. close_first
is the draft that would close the running session first. An unnamed session
also carries the derived R9 homes object (forecast) on previous and, for
a swap, on demoted; named sessions do no resolution and omit it:
"override": {
"action": "restart",
"ledger": "fresh",
"previous": {"pomodoro_name": "CAPTURE", "pomodoro_line": 5,
"start": "0920", "end": "0945", "duration_minutes": 25,
"time_range": "0920-0945"},
"close_first": "x ="
}
An overdue session adds "overdue": true to previous. A swap adds
demoted, describing the old session in its first-future position, and
uses the matching close draft:
"override": {
"action": "swap",
"ledger": "kept",
"previous": {"pomodoro_name": "CAPTURE", "pomodoro_line": 5,
"start": "0920", "end": "0945", "duration_minutes": 25,
"time_range": "0920-0945"},
"demoted": {"pomodoro_name": "CAPTURE", "pomodoro_line": 7,
"entry_line": "- [ ] () — CAPTURE", "task_links": 2,
"has_notes": false},
"close_first": "x =bugs"
}
A plain start with nothing running has no override object. The result's
existing pomodoro_start fields describe the session now running; the
batch-level pomodoro_blocks reports it with role started and a swapped-
out session with role reset.
Bob Mac Capture passes hidden --refuse-override when the on-screen preview
for the current draft has not shown an override warning. If the state changes
before submit, Bob refuses with code: pomodoro_override_refused; the client
shows a fresh preview and requires another Return. This guards the warning
seen before a commit without adding a confirmation step. For an unnamed
running session with Task Links the refusal names the derived chip after the
range (would restart the running session (0920-0945 ○ sase) and
--refuse-override is set); named and unlinked sessions read as before.
Worked example (BOB_NOW=2026-10-09 09:32:00, TAB indentation):
## Pomodoros
- [x] (**0830-0855** [t:: 25m]) — PLAN
- 🍅 [[bob#^capture-stop]]
- [ ] (**0920-0945** [t:: 25m]) — CAPTURE
- [[bob#^capture-stop]]
- [[bob#^web-capture]]
- [ ] () — SASE
- [ ] () — BUGS
- [[sase#^fix-flaky]]
| Item | Result |
|---|---|
= |
CAPTURE becomes (**0935-1000** [t:: 25m]) in place |
=3 |
CAPTURE becomes (**0935-0950** [t:: 15m]) in place |
=-2 |
CAPTURE becomes (**0925-0950** [t:: 25m]) in place |
=~2 |
CAPTURE restarts at 0935-1000 without [[bob#^web-capture]] |
=bugs |
BUGS takes over (**0920-0945** [t:: 25m]) and moves under PLAN; CAPTURE becomes - [ ] () — CAPTURE right after BUGS, both links intact; SASE follows |
=3#bugs |
Same layout, BUGS is (**0935-0950** [t:: 15m]) |
=plan |
A new PLAN session ("again") takes over 0920-0945; CAPTURE first future |
=capture |
Refused: CAPTURE is already running; restart it with = or =<X>, close it with x, or name another Pomodoro to swap in |
=3#capture |
Identical to =3 (a restart) |
=bugs +2 |
Swap, then BUGS extends to 0920-0955 |
=bugs writes:
## Pomodoros
- [x] (**0830-0855** [t:: 25m]) — PLAN
- 🍅 [[bob#^capture-stop]]
- [ ] (**0920-0945** [t:: 25m]) — BUGS
- [[sase#^fix-flaky]]
- [ ] () — CAPTURE
- [[bob#^capture-stop]]
- [[bob#^web-capture]]
- [ ] () — SASE
A swap resolves the name against the pre-image, applies ~<K> to the
incoming session's lineup, then demotes the running session. Demoting clears
the running ledger to () and moves the whole block before the earliest
other open untimed placeholder, so the old session is first future. The
headline suffix and every child byte stay intact, including Task Links,
struck links, nested details, and stand-alone notes. There is no note gate,
history row, task-note write, or Work Log. For an empty <X>, the incoming
session receives the previous ledger bytes verbatim, including range-local
annotations; for a non-empty <X>, it receives fresh timing. A note-free
restart leaves the day file byte-identical to x0 =<X>; a kept-ledger swap
matches x0 =#name except that the target receives the old running ledger.
Adjusting the current Pomodoro¶
Capture a whole item +[N] or -[N] to adjust today's current timed
Pomodoro by N five-minute units:
bob capture +5
bob capture -- -2
bob capture +
printf '+5\n\nCall bank @Cash+\n' | bob capture
+5 extends by 25 minutes and -2 shortens by 10 minutes. The count is
optional and defaults to 1, so a bare + or - is one unit rather than an
incomplete state. The item must contain only the signed count (or share its
line only with other session operators; see
Chaining session operators on one line):
trimmed text
matching ^[+-][0-9]*$ with a positive magnitude. Leading/trailing
whitespace is fine. +0/-0 fail, and oversized magnitudes fail checked
parsing/arithmetic before any write. If a valid signed-count token starts an
item but has extra text, a marker, or a child line, the item fails as an
invalid adjustment instead of creating a task; Plan +5 stays ordinary
prose.
The adjustment selects exactly one open timed, column-zero entry in today's
## Pomodoros section. Missing/unreadable day file or section, no open
timed entry, multiple open timed entries, or an unparseable target range
fail without writing. The start stays fixed; new_duration =
max(0, old_duration + signed_units * 5) with checked arithmetic and
new_end = (start + new_duration) modulo 24 hours. Only that ledger line is
rewritten to Bob's canonical (**HHMM-HHMM** [t:: Nm]) form, preserving its
checkbox, name, non-duration metadata, child bullets, newline style, and
surrounding bytes. Subtraction clamps at zero and reports both requested
units and actual applied minutes when clamping changes the delta.
A @@route declaration still routes ordinary items in the same draft but
never turns +5 into a task or changes its destination. Forced
destination/task/section and clipboard options (--route, --section,
--task, --task-ref, --task-section, --clip, %, s:<N>, $<N>)
are rejected on adjustment items with a specific error. Later items observe
earlier staged edits to the same daily file through CaptureBatchPlanner,
so a +5 after a session-start item can adjust the session created earlier
in the draft; dry-run computes the same result without committing, and a
later invalid item rolls back all earlier staged changes. The same holds on
one line: +2 x extends then closes atomically.
JSON keeps every existing key and schema version 1 stable and reports a
distinct pomodoro_adjust kind with an additive pomodoro_adjust object
(direction/requested units, actual delta minutes, before/after
start/end/duration, resolved line/name, rendered new range). The adjusted
session also appears in the batch-level pomodoro_blocks array; see
Pomodoro blocks. Human output
names what changed and where; dry-run says what would change.
Shifting the current Pomodoro¶
Capture a whole item ++[N] or --[N] to shift today's running timed
Pomodoro N five-minute units later or earlier, keeping its duration:
bob capture ++3
bob capture -- --2
bob capture --1
printf -- '--2\n\n+\n' | bob capture
One sign moves the end; two signs move the whole session. ++3 translates
both endpoints 15 minutes later and --2 translates both 10 minutes
earlier, exactly like Obsidian's 3\o / 2\O with the running Pomodoro
selected. The count is optional and defaults to 1, so ++ and -- move one
unit. The item must contain only the operator (or share its line only with
other session operators; see
Chaining session operators on one line):
trimmed text matching
^(\+\+|--)[0-9]*$ with a positive magnitude. Leading/trailing whitespace
is fine. ++0/--0 fail ("move nothing"), oversized magnitudes fail
checked parsing/arithmetic before any write, and a counted token with extra
text, a marker, or a child line (++3 more, ++3@work, --2 s:1) fails as
an invalid shift instead of creating a task. A bare sign run followed by
more text (-- aside, ++ plan), a run longer than two or mixed (+++,
---, +-), and mid-body tokens (Plan ++3, C++) stay ordinary prose.
Target selection mirrors the adjustment: exactly one open timed,
column-zero entry in today's ## Pomodoros section. Both endpoints
translate modulo 24 hours (new_start = (start + delta) mod 1440,
new_end = (end + delta) mod 1440), so shifts wrap across midnight and
never clamp; a ++288 wraps a full day. There is no overlap check against
neighbouring entries. Only that ledger line is rewritten to Bob's canonical
(**HHMM-HHMM** [t:: Nm]) form, preserving its checkbox, name,
non-duration metadata, child bullets, newline style, and surrounding bytes.
A @@route declaration never applies to shift items, and forced
destination/task/section and clipboard options (--route, --section,
--task, --task-ref, --task-section, --clip, %, s:<N>, $<N>)
are rejected on shift items with a specific error. Later items observe
earlier staged edits, dry-run computes without committing, and any failure
rolls the whole batch back, so --2 then + in one draft composes (or
--2 + on one line).
The shell sees a leading - as a flag: spell an earlier shift as
bob capture -- --2, or bob capture --1 for one unit. A bare -- stays
the end-of-options marker and carries no text; pipe the draft
(printf -- '--\n' | bob capture) when that reads better.
JSON keeps every existing key and schema version 1 stable and reports a
distinct pomodoro_shift kind with an additive pomodoro_shift object
(direction, requested units, signed delta minutes, before/after
start/end/duration, resolved line/name, rendered new range). The shifted
session also appears in the batch-level pomodoro_blocks array; see
Pomodoro blocks. Human output
names what shifted and where (shifted 2026/20260928.md, FOCUS 0900-0925
to 0915-0940 (25m), 15m later at line 5); dry-run says what would shift.
Closing the running Pomodoro¶
Capture a whole item x (lowercase x only) to close today's running
timed Pomodoro:
bob capture 'x'
bob capture 'x2'
bob capture 'x1!2'
bob capture 'x0'
bob capture '^bob:capture-stop=x'
bob capture '^bob:capture-stop=x!1'
printf -- '-2\n\nx\n' | bob capture
printf 'x\n\n=\n' | bob capture
Single-quote tokens that contain !, *, or ~: shells glob * and
history-expand !. A bare x needs no =-word quoting.
Retired close spellings¶
Whole-item =x…, =*…, and =!… are claimed teaching errors. They write
nothing. Each message names the typed token and the exact bare-x rewrite.
Link and task suffixes keep =x…; only the short =* / =! aliases on
those suffixes retire, rewriting to =x* / =x!.
| You typed | Error |
|---|---|
=x |
`=x` is retired: close the running Pomodoro with a bare `x`, without the `=`; write `x` |
=X |
`=X` is retired: close the running Pomodoro with a bare `x`, without the `=`; write `x` |
=x2!1 |
`=x2!1` is retired: close the running Pomodoro with a bare `x`, without the `=`; write `x2!1` |
=x wired the lexer |
`=x` is retired: close the running Pomodoro with a bare `x`, without the `=`; write `x wired the lexer` |
=x#bugs |
`=x#bugs` is retired: close the running Pomodoro with a bare `x`, without the `=`; write `x =bugs` |
=* |
`=*` is retired: close the running Pomodoro with a bare `x`, without the `=`; write `x*` |
=*1 |
`=*1` is retired: close the running Pomodoro with a bare `x`, without the `=`; write `x*1` |
=*!2 |
`=*!2` is retired: close the running Pomodoro with a bare `x`, without the `=`; write `x*!2` |
=! |
`=!` is retired: close the running Pomodoro with a bare `x`, without the `=`; write `x!` |
=!2 |
`=!2` is retired: close the running Pomodoro with a bare `x`, without the `=`; write `x!2` |
=x = |
The =x token fails on its own range: `=x` is retired: close the running Pomodoro with a bare `x`, without the `=`; write `x`; the trailing = still parses as a start |
^bob:stop=* |
`=*` is retired: a link or task close spells the `x`; write `=x*` |
@r:id=!2 |
`=!2` is retired: a link or task close spells the `x`; write `=x!2` |
==x |
`==x` is not a close: close the running Pomodoro with `x` |
==X1 |
`==X1` is not a close: close the running Pomodoro with `x1` |
==* |
`==*` is not a close: close the running Pomodoro with `x*` |
==!2 |
`==!2` is not a close: close the running Pomodoro with `x!2` |
Mid-body Plan =x stays ordinary prose. =xx and =xa are named starts now, not prose. Capitalizing the
first letter (X post about the launch, X11 forwarding, X *job:cleanup)
is the escape for a task that must start with the word x.
This ports Obsidian's Ctrl+Enter Pomodoro completion (task-status-cycler's
completeActivePomodoroTask), not the Ctrl+Shift+Enter task-level pause:
the ledger ends up byte-identical to Obsidian's completion except for the
auto-decrement below, the deliberate atomicity divergences, and the
intentional capture-only x0 reset exception (a note-free x0 resets to
first future instead of completing; Obsidian completion remains separate). A session
closed before its planned end is shortened to the stop time
(BOB_NOW=2026-09-28 09:37:00 closing 0920-0950 writes 0920-0940 [t::
20m]): remaining = end − now normalized into (−720, 720] minutes, and
when remaining ≥ 5, units = floor(remaining / 5),
new_duration = max(0, duration − 5·units),
new_end = (start + new_duration) mod 1440, rewritten in the same canonical
(**HHMM-HHMM** [t:: Nm]) form +N/-N writes. Otherwise the range bytes
are untouched; Bob never extends a session, and an overrun is reported
(ran 7m over). Closed at 09:49 or later, 0920-0950 stays unchanged.
The close's child lines are Work Log bullets, and one entry may sit on the
close line itself (or it shares its line only with other session operators;
see
Chaining session operators on one line
and Logging work while closing);
xx, xa, x., x-ray, xylophone, capitalized X…, and mid-body
Plan x stay ordinary prose, as does mid-body Plan =x and Plan =bugs.
Capitalizing the first letter is the escape for a task that must start with
the word x. x more logs the inline entry more to task 1, x86 build
claims task 86, and x *job:cleanup is refused as a Pomodoro-operator
relationship. (==x is a claimed near miss teaching the x close, never
prose.) A bare
= is now a whole-item start, not an incomplete state. When no session is
running but a future Pomodoro exists, the close diagnostic names it with a
(start it with=) hint. @route:block-id=x… and
^route:block-id=x… first put that existing task into the running session
(Ready/Blocked become Next; the Task Link moves or is appended into the
session's sub-bullet range) then close it; <text>
@route:block-id=x… creates the new Next task in the running session then
closes it. When the §13.5 link guard finds the existing-task target Blocked,
§13.5.1 drops its own prerequisites first when every safety gate passes, then
the ordinary link-and-close path runs. Otherwise the status stays as-is (a
Blocked task never rises on a history link) while the link still lands under
the closing entry as history; the open prerequisites rise and the result
reports link_guard with drop_refused when an own dependency reason kept
the prerequisites. Whole-item x#name fails (`x` always closes the running Pomodoro; remove `#name`, or write `x =name` to close it and then start that Pomodoro; `=name` swaps it in without closing the running session). Link #name=x fails (`=x` always closes the running Pomodoro; remove `#name` (drop `=x` to link under a named Pomodoro instead)). So do s:<N>/$<N>, project-note + forms, and forced flags on a whole-item close. A @@ declaration never applies to
close items.
Sub-bullet classification (after stripping 🍅 markers): fenced lines stay
untouched notes; ![[…#^id]] embeds are closed recursively — except Depends-On
lines, which are never tree-closed, so closing a dependent never closes its
prerequisites — and retired to ~~[[…]]~~; a body that is exactly one plain block link plus # is
deferred (removed from the session, carried without the #, target not
started); other plain block links are worked-on (kept, given exactly one
🍅, carried); a worked-on line that is exactly one bare link is
startable ([ ]/[*] → [/], stamping [fresh:: TODAY] in the same write;
see docs/freshness.md); everything else stays as an uncarried note. Completing
a task (! selection → [x]) never stamps.
Carried lines (worked-on then deferred, in source order, at original
indentation) move into a new - [ ] () (or - [ ] () — NAME) placeholder
inserted after the session's sub-bullet range, created iff something is
carried or no later entry follows; with nothing carried the placeholder gets
a \t- stub. Direct-child qualifying links with descendants write dated
Work Log entries under a 🛠️ **WORK LOG** marker (newest on top) or append
one after the task's child block. Notes stay in the ledger (copied, not
moved). Close-time recovery plus successor offers run on every close
that completes tasks, per
task-dependencies.md §12:
picked successors go into the same-name continuation, created when
needed, and count as carried. Without --pull-in, capture recovers and
reports numbered offers (not_selected). The continuation rule is the
same-name entry search in §12.3: the created continuation first, else
the first open same-name entry after the closed one, else a new
placeholder at the continuation position. Completed-reference
retirement outside the session still stays with reconcile.
Diagnostics (all write nothing): a missing day file, a missing Pomodoros
section, no open timed entry (no running Pomodoro to close, plus
; next up is NAME at line N when an open placeholder exists), several
open timed entries, and an unparseable-range warning that still closes
without decrementing. The start guards now append (close it with `x`).
JSON keeps schema version 1 and reports kind "pomodoro_close" (link and
task forms keep their kind) with an additive pomodoro_close object
(timing, per-target transitions, carried links, notes, next session). A
whole-item x close also reports the closed session — and the next
session it names — in the batch-level pomodoro_blocks array; see
Pomodoro blocks.
Field notes:
pomodoro_close.rawis the typed token exactly as typed: the bare token (x,x1*2!3~4) for whole-item closes, and the=-prefixed suffix (=x!1) for link and task closes.pomodoro_close.in_progressis the typed<N>list sorted ascending,nullwhen no<N>was typed (x0reports[], andx*2keepsnull),pomodoro_close.parkis the typed*<P>list (possibly empty, omitted when empty),pomodoro_close.completeis the typed!<M>list (possibly empty), andpomodoro_close.dropis the typed~<K>list, omitted when empty.pomodoro_close.logis the typed Work Log entries in typed order (bullets or the one inline entry;[{ "index": 2, "text": "wired the lexer" }]), omitted when empty. Inbob captureJSONindexis always the task the entry logged to, including for unnumbered bullets, which execution resolves positionally. Each entry carriesdetails, the nested detail lines typed under it ([{ "index": 2, "text": "wired the lexer", "details": ["chose a hand-rolled lexer"] }]), omitted when the entry has none.pomodoro_close.tasks[].typed_work_logholds the dated entries (*YYYY-MM-DD* — text, a subset ofwork_log) that this capture's typed entries produced, in typed order, omitted when empty.pomodoro_close.tasks[].typed_work_log_detailsis aligned 1:1 with it: element i lists the detail lines written under typed entry i (undated sub-bullets of the dated entry, never counted in+N Work Log), with an empty list for entries without details; omitted when no typed entry on the row has details.pomodoro_close.task_linksis the numbered lineup, always present and possibly empty, in number order. Each entry carriesindex(1-based),ledger_line(the close's pre-image, after any link step),block_link(never including the!),block_id,marker(plain,deferred, orembedded: the line's marker before the selection),outcome(in_progress,parked,deferred,complete, ordropped), andsource(ledgerwhen the outcome comes from the line's own marker,listedwhen its number was typed,unlistedwhen<N>was typed without it).tasks[].indexis the number of the numbered line that produced the row, ornullfor unnumbered rows (struck, mentioned, subtask, and Work-Log-only); when one task is numbered on several lines, its single row carries the lowest number.tasks[].rolekeeps its current meaning: it is the role after the selection, so a completed row isembedded, a deferred row isdeferred, and a dropped row isdropped. A parked row staysworked(or its existing unresolved role): parking never changes task resolution or status semantics, only carry. A client joinstasks[].indextotask_links[index-1]foroutcome/source.tasks[].carriedis true only when that target's line was actually carried into the new placeholder, and agrees with the top-levelcarriedlist, the next-session count, rendered blocks, and plan budget. Parked rows reportcarried: falsewith no other independently carried reference to that target.- Link forms (
pomodoro_link,pomodoro_task) keep the placement their non-close counterpart reports; only whole-itemxusesplacement: "closed". - Inside
pomodoro_close, explicitlynull(not omitted) appears fortasks[].warning, fortasks[].relative_target,text,previous_status_symbol,previous_status_name,status_symbol, andstatus_nameon unresolved rows, fornext_pomodoro.time_range, and fornext_pomodoroitself when null — matching the top-levelroute: null/scheduled: nullconvention. - Embedded and subtask row
textomits the#tasktag, like every other row. - Whole-item
xreportscreatedas the clock's date string, as every other kind does. pomodoro_closecarries the sameunblocked[],still_blocked[], andunblocked_checkfields astask_complete(see the task-complete result contract above): the same row shapes, the sameofferobject, the samenot_linkedreasons (not_selected,no_destination,project_member; neverbreakerornot_planned_today), the sametrigger/hide_removedfields, and the same gate semantics over the close's completed set. Dry-run JSON equals real-run JSON with the same flags. The batch also carries top-levelpull_inon every success.- When the successor logic created the continuation,
next_pomodororeports it ({line, name, time_range: None, created: true}); picked successors count as carried. - Added block lines that equal a surviving picked successor bullet carry
"reason": "unblocked"in thepomodoro_blocksline objects; rows dropped by net batch reporting lose the reason.
Human output prints closed NAME old → new (Nm, −Xm) · file line N (or a
single range with no decrement, plus (ran Nm over) on overruns), always
naming the day file; one line per task as transition text route ^id
plus +N Work Log counts; dropped rows read dropped <K> [[T]]
(with a stays <status> lane caption: a dropped task keeps its status) and a
Dropped <K> summary follows the rows; the same numbered offer N /
linked / unblocked / still blocked successor rows the ! close
prints, after the task rows;
and the next session, which reads
carries 1 link in the singular. When picked successors rode along, next:
names them (next: BOB (created) at line N · carries K links ·
1 unblocked). When at least one row is numbered, every
task row is prefixed with a right-aligned index column (width = digits of
the highest number) plus one space; unnumbered rows get blanks of the same
width and Work Log preview lines indent to stay under the text. The index
is bold, colored by outcome (in progress uses the / color, complete uses
the x color, deferred is dim), and unlisted indices are dim too. With
zero numbered rows the output is byte-identical to before. The "switch tasks" batch
idiom is x, blank line, ^route:id= — close the running session,
then start the next one through the existing start rules. The session
switch idiom is x, blank line, = (or x = on one line) — close the running session, then
start the next future Pomodoro.
Worked example (BOB_NOW=2026-09-28 09:37:00, day file
2026/20260928.md, TAB indentation):
## Pomodoros
- [x] (**0830-0855** [t:: 25m]) — PLAN
- 🍅 [[bob#^capture-stop]]
- [ ] (**0920-0950** [t:: 30m]) — CAPTURE
- [[bob#^capture-stop]]
- Designed the `x` grammar
- chose `x` for done
- Wrote the plan
- [[bob#^web-capture]]#
- ~~[[sase#^axe-restart]]~~
- Restarted axe
- quick note
- [ ] () — SASE
- [[sase#^recovery-panel]]
bob capture 'x' rewrites the day file to:
## Pomodoros
- [x] (**0830-0855** [t:: 25m]) — PLAN
- 🍅 [[bob#^capture-stop]]
- [x] (**0920-0940** [t:: 20m]) — CAPTURE
- 🍅 [[bob#^capture-stop]]
- Designed the `x` grammar
- chose `x` for done
- Wrote the plan
- ~~[[sase#^axe-restart]]~~
- Restarted axe
- quick note
- [ ] () — CAPTURE
- [[bob#^capture-stop]]
- [[bob#^web-capture]]
- [ ] () — SASE
- [[sase#^recovery-panel]]
bob.md starts ^capture-stop ([*] → [/]) with a 🛠️ **WORK LOG**
of the two dated design notes; ^web-capture was deferred so it stays
[*]. sase.md prepends *2026-09-28* — Restarted axe under the
existing Work Log marker.
Other rows on the same fixture: x at 09:49 keeps 0920-0950 [t::
30m]; ^bob:ready=x appends after quick note (linked, carried
second); ^bob:ready=x3 does the same link step, then closes with the two
existing links deferred and the new [[bob#^ready]] in progress as number
3; ^sase:recovery-panel=x moves the subtree from SASE
(moved); ^bob:capture-stop=x is already_current and identical to
plain x; ^bob:capture-stop=x!1 is already_current and completes it;
Draft docs @bob:draft-docs=x creates the new task;
Draft docs @bob:draft-docs=x0 creates it deferred;
-2, blank line, x decrements once (or -2 x on one line); -2, blank line, x5 rolls back
the -2; x, blank line,
^sase:recovery-panel= switches tasks; ^bob:ready#capture=x errors
with "remove #capture"; a second x reports "no running Pomodoro to
close… next up is CAPTURE at line 13"; x more logs the entry more to
task 1; x3 is out of range on this fixture.
Choosing each Task Link's outcome¶
Close the running session with x[<N>][*<P>][!<M>][~<K>][@<N>] (lowercase
x only, with *, !, ~, and @ in any order) to decide, by number, which of its
Task Links stay in progress, which are deferred, which are completed and
struck, and which are dropped — in one capture instead of editing the
[[…]]# / ![[…]] markers by hand before Ctrl+Enter (single-quote the
argument: shells glob * and expand !):
bob capture 'x2'
bob capture 'x*2,3'
bob capture 'x!1'
bob capture 'x1*2,3!4,5'
bob capture 'x0'
bob capture 'x~4,5'
bob capture 'x1*2!3~4'
bob capture 'x0*2'
bob capture 'x*'
bob capture 'x!2'
Parking records normal In Progress work without carrying those links into
the next Pomodoro (x*2 works on and parks 2; x1*2,3!4,5 continues 1,
parks 2 and 3, completes 4 and 5). The five-link acceptance close
'x1*2,3!4,5' keeps normal 🍅 history for 1, 2, and 3, gives 1, 2, and 3
exactly the normal In Progress and Work Log effects, retires 4 and 5 as usual,
and carries only link 1 into the new placeholder. Parked rows read
Parked · not carried and preserve their actual status transition.
<N>, <P>, <M>, and <K> are comma-separated task numbers with no
whitespace. <N> omitted leaves unlisted links at their ledger outcome
unless *<P> is present, which activates selection mode like <N>;
<N> present, even as a lone 0, turns every explicitly unassigned link
that would have been in progress into deferred. A present * or ! group
without numbers is a wildcard for every numbered link left after explicit
in-progress, park, complete, and drop assignments. Thus x* parks all links,
x1* keeps task 1 in progress and parks the rest, and x*!2 parks the
rest while completing task 2. Explicit forms such as x*1 and x!1 keep
the old single-task scope. x0* parks all links because 0 assigns none
to in-progress; an empty lineup still closes successfully. Two empty
wildcards compete regardless of marker order; add task numbers to at least
one group. An absent group stays absent and ~ never
defaults. *0, !0, and ~0 are invalid. Empty comma elements never
select all (x*,2 fails, x*1, dangles). Order inside a list does not
matter, and *<P>, !<M>, and ~<K> may each appear at most once, in
any order after an optional initial <N>. * and ! introduce outcome
groups; only omitting that group's numbers makes it a wildcard. A selection
is the marker edits the user would make by hand, applied to the numbered
lines, plus explicit carry metadata for parked links (normal work without
carrying), followed by the unchanged close — so plain x works byte for
byte as it always has.
Numbering. Every line of the running session's sub-bullet range whose list-item body, after stripping 🍅 markers, is exactly one block link is numbered in ledger order starting at 1, at any depth:
| Body shape | Marker | Outcome if nothing is listed |
|---|---|---|
[[T]] |
plain | in progress |
[[T]]# |
deferred | deferred |
![[T]] |
embedded | complete |
![[T]]# |
embedded (the # is inert, as today) |
complete |
Struck links (~~[[T]]~~, already done), lines that mix a link with other
text (the "mentioned" role, never started), notes, and fenced lines are
never numbered. T is any path#^id block link with an optional |alias,
copied verbatim. On link forms (@route:block-id=x…,
^route:block-id=x…, <text> @route:block-id=x…) the numbering is taken
after the link step: a newly linked or created task lands last and gets the
highest number, while an already-current task keeps its place.
Outcomes. For numbered line i with marker m, the first matching row wins:
| Condition | Outcome | Source |
|---|---|---|
i ∈ <P> |
parked | listed |
i ∈ <M> |
complete | listed |
i ∈ <K> |
dropped | listed |
i ∈ <N> |
in progress | listed |
<N> typed or *<P> present and m = embedded |
complete (a hand transclusion is kept) | unlisted |
<N> typed or *<P> present |
deferred | unlisted |
| otherwise | the ledger outcome of m | ledger |
A number in two lists fails lexically, as does an out-of-range number.
Only numbered lines whose outcome differs from their marker's ledger outcome
are rewritten, in place, keeping indentation and list markers: in progress
becomes [[T]], deferred becomes [[T]]#, complete becomes ![[T]], and
dropped becomes ~[[T]] (a transient marker the ledger planner removes:
the line leaves the closed session, is not carried, and its task is never
started). 🍅 markers are dropped from rewritten lines (the close adds back
exactly one on worked lines); line count never changes and matching lines
stay byte-identical.
| Outcome | 🍅 in the closed entry | Carried to the next placeholder | Task effect |
|---|---|---|---|
in progress (N, or the ledger default) |
yes | yes | started [/] |
parked (*P) |
yes (same normal work history and child notes) | no | started [/] (same normal In Progress and Work Log effects) |
| deferred | no (removed) | yes | none |
complete (!M) |
embedded | no | closed |
dropped (~K) |
no (removed) | no | none — the task keeps its lane |
Parking is a close outcome, not a new task status: [/] keeps its In
Progress name, and the existing sticky-lane PENDING behavior provides the
waiting place. Carry suppression is explicit planner metadata, not a durable
Markdown marker. Unlisted plain links with <N> or *<P> present defer;
unlisted embedded links complete. x!2~3 without <N> or *<P> keeps
ledger outcomes for unlisted links.
An omitted <N> keeps the ledger default for unlisted links. A typed
<N> defers the unlisted ones, exactly as today. Dropped links count as
"not carried" when the close decides whether to create a placeholder.
Worked selections. On the fixture above, the numbered Task Links are 1 =
[[bob#^capture-stop]] (line 6, plain, with nested notes) and 2 =
[[bob#^web-capture]]# (line 10, deferred).
bob capture 'x2' defers 1 and keeps 2 in progress:
- [x] (**0920-0940** [t:: 20m]) — CAPTURE
- Designed the `x` grammar
- chose `x` for done
- Wrote the plan
- 🍅 [[bob#^web-capture]]
- ~~[[sase#^axe-restart]]~~
- Restarted axe
- quick note
- [ ] () — CAPTURE
- [[bob#^web-capture]]
- [[bob#^capture-stop]]
^capture-stop stays [*] but still gets its two-entry Work Log;
^web-capture becomes [/].
bob capture 'x1!2' keeps 1 in progress and completes 2:
- [x] (**0920-0940** [t:: 20m]) — CAPTURE
- 🍅 [[bob#^capture-stop]]
- Designed the `x` grammar
- chose `x` for done
- Wrote the plan
- ~~[[bob#^web-capture]]~~
- ~~[[sase#^axe-restart]]~~
- Restarted axe
- quick note
- [ ] () — CAPTURE
- [[bob#^capture-stop]]
^capture-stop becomes [/] with its Work Log; ^web-capture closes as
(two spaces before [completion::, as today's embedded close writes it):
- [x] #task Add capture support for web URLs! [created::2026-09-21] [completion:: 2026-09-28] ^web-capture
Human output (NO_COLOR):
✓ closed CAPTURE 0920-0950 → 0920-0940 (20m, −10m) · 2026/20260928.md line 5
1 [*] → [/] Add support for `x` syntax! bob.md ^capture-stop +2 Work Log
*2026-09-28* — Designed the `x` grammar
*2026-09-28* — Wrote the plan
2 [*] → [x] Add capture support for web URLs! bob.md ^web-capture
[x] Restart axe sase.md ^axe-restart +1 Work Log
*2026-09-28* — Restarted axe
next: CAPTURE (created) at line 14 · carries 1 link
bob capture 'x0' defers everything:
- [x] (**0920-0940** [t:: 20m]) — CAPTURE
- Designed the `x` grammar
- chose `x` for done
- Wrote the plan
- ~~[[sase#^axe-restart]]~~
- Restarted axe
- quick note
- [ ] () — CAPTURE
- [[bob#^capture-stop]]
- [[bob#^web-capture]]
Both tasks stay [*], and ^capture-stop still gets its Work Log. x1
equals plain x on this fixture (2 was already deferred); x1,2
un-defers 2 so both links get 🍅, both tasks become [/], and the
placeholder carries [[bob#^capture-stop]] then [[bob#^web-capture]].
A note-free x0 instead resets: it clears the current headline's
parenthesized session ledger to () (including [t:: ...], legacy
duration notation, and range-local annotations), keeps the headline suffix
and every child byte, and moves the whole block before the earliest other
future placeholder so it becomes first future. No history row is created,
no task is started/completed/released, and no Work Log is copied. Nested
details under a dedicated task link stay with that task; any direct prose
note (including a note after a blank, an empty bullet with authored
descendants, or meaningful indented/fenced content outside a dedicated
subtree) keeps the existing close. Explicit modifiers (x0*2, x0!2,
x0~2, wildcards) always close. After a reset, x0 = restarts the reset
session and a second x0 reports next-up.
Diagnostics (all write nothing). Out of range:
`x4` names task 4, but CAPTURE has 2 numbered Task Links (1–2) ((1)
for one link; `x1` names task 1, but CAPTURE has no numbered Task Links;
close it with `x` for none; an unnamed session reads "the running
Pomodoro"); several bad numbers are listed together. Conflicting duplicates:
tasks 1 and 3 both link `[[bob#^a]]` but get different outcomes; give them
the same one (same-outcome duplicates are fine). Malformed lists fail
lexically: task 1 is listed twice in `x1,1`,
task 1 cannot both stay in progress and park in `x1*1`,
task 1 cannot both stay in progress and complete in `x1!1`,
task 1 cannot both stay in progress and drop in `x1~1`,
task 1 cannot both park and complete in `x*1!1`,
task 1 cannot both park and drop in `x*1~1`,
task 2 cannot both complete and drop in `x!2~2`,
`0` means no task stays in progress; use it alone, as `x0`, `x0*2`, `x0!2`, or `x0~2`
(for x0,2, x0,, and x00), task numbers start at 1 (for *0, !0,
and ~0), expected a task
number before `,`, expected a task number after `,` (for x1,!2), use one `*` list: `x1*2,3`, use one `!` list: `x1!2,3`,
use one `~` list: `x1~2,3`,
`x1a` is not a task list: write `x`, then comma-separated task numbers,
then optionally `*` and the numbers to park, `!` and the numbers to complete,
and `~` and the numbers to drop (for example `x1*2!3~4`),
task number 99999999999 is too large, and
write the task numbers right after `x`, with no spaces (for example
`x1*2!3~4`). Competing empty wildcard groups report
`x*!` has competing park-all and complete-all groups; add task numbers to at least one group.
A present-but-empty */! group selects the remaining links (so x!~2
completes every link except dropped task 2, while x*!2 parks every link
except completed task 2). Explicit forms such as x*1 and x!1 keep the
single-task scope. A token ending in a dangling separator (x1,, x*1,, x~, x*~, x*2,, x!2,) is an editing state: bob capture rejects it
(`x1,` is incomplete: type a task number after `,`) while
capture-parse reports mode incomplete needing pomodoro_close_task. A trailing */! is valid, never dangling.
Warnings (shown on the row and top-level, never blocking): a listed
in-progress line whose task did not end In Progress
(task 2 `[[bob#^x]]` is Blocked, so it was not started) and a listed
complete line whose task did not end Done. In a batch, a selection failure
rolls the whole batch back.
Logging work while closing¶
One entry on the close line¶
bob capture 'x wired the lexer'
bob capture 'x1,3 3 fixed the flaky test'
bob capture 'x wired the lexer ='
The text after the close token is one Work Log entry, whitespace-normalized
like every capture line, executing byte for byte like its bullet form. With
no number it logs to the first task the close works (task 1 for plain x,
the first eligible top-level worked link for wildcard closes). A wildcard's
first eligible link depends on the resolved session lineup; one inline entry
creates one Work Log item, even when the wildcard selects several links. A
leading number names the task and
the rest is the text, so only the first token is an index and
x 1 3 bugs fixed logs 3 bugs fixed to task 1. 0 and leading zeros
report task numbers start at 1, overflow reports task number N is too
large, and x0 works no task so its entry has none to log to. The entry
sits right after the close with session operators trailing
(x wired the lexer = closes with the entry then starts); a trailing +1,
-, or -- with no start or close before it stays text (x got a +1).
Entry text follows the bullet rules below and never ends with a Task Link.
A lone number (x 2) is incomplete: capture-parse needs
pomodoro_close_log_text and bob capture asks for the text (or x2).
Several entries on bullets¶
Log work as child bullets under a whole-item close:
x[<N>][*<P>][!<M>][~<K>]
- [<n>] <entry text…>
- <detail text…>
- [<m>] <entry text…>
printf 'x\n- 1 wired the lexer\n' | bob capture
printf 'x1,2\n- 1 wired the lexer\n - chose a hand-rolled lexer\n- 2 sketched the URL parser\n' | bob capture
printf 'x\n- 1 wrote docs\n- 1 opened the PR\n' | bob capture
A whole-item close (x plus an optional selection, alone on its
parent line, or the x of a chain line) takes Work Log bullets as its
child lines, using exactly the authored-bullet rules: a first-level bullet
sits at column 0, a nested one has exactly two spaces, the marker is -,
*, or + plus a space or tab, marker-only rows are harmless
placeholders, and bad indentation or orphaned nested bullets fail with the
existing messages. A first-level bullet is numbered when its first
whitespace-separated token is all ASCII digits: the leading number is always
the task number <n>, and everything after it is the entry text. Every
other first-level bullet is unnumbered and its whole body is entry text.
Bullets are numbered all or none: the first first-level bullet fixes the
list's kind. Without wildcard intent, with <N> typed (including x0) or *<P> present,
loggable tasks are the numbers in <N>, *<P>, or !<M>; with no <N> or
*<P> every number ≥ 1 is loggable except those in ~<K>. Numbered bullets
and explicitly numbered selections are lexical with no vault access. Wildcard
numbered bullets and positional entries resolve after Bob stages the session
lineup; wildcard inline entries use the first eligible top-level worked Task
Link. Positions under a close without <N>/*<P> resolve against the running
session at execution.
Only the first token is an index, so - 1 fixed 3 bugs logs fixed 3 bugs
with no escape, and every backslash is literal. Entries keep typed order,
and the same number may appear on several numbered bullets, each a separate
entry. A two-space nested bullet is a detail of the nearest preceding
entry, numbered or not: it has no task number, so its text is entirely
literal, and it becomes an undated sub-bullet under its dated entry in the
task's Work Log (never counted in +N Work Log).
Unnumbered bullets log in order to the close's worked tasks W. In
selection mode (<N> typed, including x0, or a non-empty *<P>),
W is the lexical sorted(<N> ∪ *<P> ∪ !<M>); otherwise W is the running
session's numbered Task Links whose outcome after the selection is in
progress, parked, or complete and that sit at the session's top level
([[T]]# defers, ![[T]] completes, nested links never take entries). One
worked task takes every bullet; otherwise bullet i logs to W[i], so a
single bullet logs to the first worked task, matching the inline default.
More bullets than worked tasks is an error, as is a close that works no
task. For example x3,4 plus - foo bar plus - baz bam writes exactly
what - 3 foo bar plus - 4 baz bam writes; x2 plus two bullets logs
both to task 2; and plain x plus - wired the lexer logs to the first
worked top-level link. A leading number is always a task number (- 2 bugs
fixed names task 2), so to log text that starts with a number, number every
bullet (- 3 2 bugs fixed).
Entry and detail text are literal and whitespace-normalized like every
capture line: @route, @@route, s:<N>, $<N>, %, #, and :query
are not markers here, plain wikilinks ([[Design notes]],
[[note#Heading]]) are allowed, and the bullets never feed @@
declarations, item-wide markers, or capture-rewrite absorption. Two
things are rejected in entry and detail text alike because the close would
misread them: any block link or embed ([[path#^id]], ![[path#^id]]) and
text starting with a code fence (``` or ~~~).
The close line holds at most one inline entry, which belongs to the close
it follows. An inline entry plus any child bullet fails with the bullet to
write the inline entry on
(an inline Work Log entry can't be combined with Work Log bullets; for several entries, put this one on its own bullet too: `- 1 wired the lexer`);
details need bullets too. Only the whole-item x takes an inline entry or
Work Log bullets; link-form closes keep
their current behavior (^route:id=x and solo @route:id=x reject
children, <text> @route:id=x writes them as the new task's sub-bullets).
Worked example on the fixture above:
printf 'x1,2\n- 1 wired the lexer\n - chose a hand-rolled lexer\n- 2 sketched the URL parser\n' | bob capture
- wired the lexer (with - chose a hand-rolled lexer under it) is
appended under link 1 after Wrote the plan, - sketched the URL parser
under link 2, and ^capture-stop's Work Log ends with the dated entry plus
its undated detail. Human output prints typed entries first, all of them,
not dimmed, each followed by its details indented two more spaces; JSON
reports pomodoro_close.log
([{ "index": 2, "text": "wired the lexer", "details": ["chose a hand-rolled lexer"] }],
details omitted when empty; in bob capture JSON index is always the
task the entry logged to, including for unnumbered bullets) and each row's
typed_work_log subset plus
the aligned typed_work_log_details. Diagnostics (all
invalid_pomodoro_close, nothing written): mixed numbering
(start each Work Log bullet with the number of the task it logs to; bullets are numbered all or none: `- 1 b`
for a numbered list with a later unnumbered bullet;
Work Log bullets are numbered all or none, and the first one has no task number; …
for an unnumbered list with a later numbered bullet),
too many unnumbered bullets
(`x3,4` works 2 tasks (3 and 4) but has 3 unnumbered Work Log bullets; … lexically,
or `x` works 2 of CAPTURE's tasks (1 and 3) but has 3 unnumbered Work Log bullets; …
against the running session) and a close that works no task,
a non-loggable index
(task 3 isn't worked by `x2`; list it (`x2,3`) or complete it (`x2!3`) to log to it;
task 2 is dropped by `~2`, so it can't take a Work Log entry;
task numbers start at 1; task number N is too large),
a dangling bullet (`- 1` is incomplete: type the Work Log text after task 1;
capture-parse reports incomplete needing pomodoro_close_log_text),
a block link
(a Work Log bullet can't contain the block link `[[bob#^x]]`; the close would treat it as a Task Link),
and a fence (a Work Log bullet can't start with a code fence).
Chaining session operators on one line¶
The seven whole-item Pomodoro session operators — +[N], -[N], ++[N],
--[N], =/=<X>, =name, and x[<N>][!<M>][~<K>] — may share one physical line when
whitespace separates them:
bob capture '+2 x'
bob capture 'x ='
bob capture 'x =bugs'
bob capture 'x =~2'
bob capture '=~2 +2'
Recognition: the line must hold at least two whitespace-separated tokens and
every token must be a session token — one the whole-item session parsers
would claim as a standalone one-line item, near misses included. A single
token is never a chain, so single-token behavior is byte-identical. Otherwise
the lead (maximal session-token prefix) splits at its last whole-item close
(the owner), the trail (maximal session-token suffix trimmed to begin with a
start or close token) runs after, and the entry is every token after the
owner before the trail. A line with no close in its lead is one item (+2
more, =3 more, and ++3 plan keep their shape errors, Plan +2 x and
- foo stay prose). -2 x wired the lexer shortens then closes with the
entry, x wired the lexer = closes with the entry then starts, and x got
a +1 keeps +1 as text. x 1,3 and x1 !2 keep the no-spaces hint
because 1,3 and !2 are not chain tokens.
Tokens run left to right exactly like blank-line items: +2 x extends then
closes, x = closes then starts the next future Pomodoro (a session switch
in one line), x =bugs closes then starts the open BUGS session,
x2 =3 closes keeping task 2 in progress then starts a
15-minute session, = +2 starts then extends, =bugs +2 starts BUGS
then extends it, x =~2 closes then starts without link 2 of the next
lineup (while x~2 = drops link 2 of the running session and then
starts), =~2 +2 starts without link 2 then extends, and --2 + shifts earlier
then extends. Staging, rollback, and --dry-run match blank-line batches:
later tokens see earlier staged edits through CaptureBatchPlanner, and any
failure rolls the whole batch back. Output is per token: one JSON/human
result per operator, and capture-parse reports one items[] entry per
token with per-token ranges sharing the physical line numbers.
Child lines attach to the line's x item — or to the last x when the
line closes twice — so x = plus - 1 wired the lexer logs the entry then
starts, and -2 x plus bullets shortens then closes. When the line has an
inline entry, children attach to its owner, so the mixing error fires.
With no x on the
line, children still attach to the last token's item and fail that token's
existing shape rule, so a chain line with children never becomes a prose
task — including bare-first chains such as + x with a child bullet. A
chain whose x is not last nests its item ranges: the close item's range
runs from its token through its last bullet, so it contains the ranges of
the later tokens on the parent line. An inline owner never nests siblings:
its range runs from its token through the entry's end (or its last child).
Item ranges therefore nest or stay disjoint; they never
partially overlap.
Spacing is significant: = -2 starts a 25-minute session then shortens it
by 10 minutes, while =-2 is one start with a 10-minute offset; likewise
=bugs +2 starts BUGS then extends it, while =bugs+2 is one start of
the Pomodoro named BUGS+2. Lines made
only of bare operators are newly recognized chains (- -, + -, = =,
-- --, - - -); they used to be prose. Runtime guards apply per token in
order (x -2 closes then fails because nothing is running; = = fails on
the second start), and forced --route/--section/--task/--task-ref/
--task-section/--clip fail on the first chain token.
Single-quote the argument: zsh expands a leading =word, so write
bob capture '+2 x'. Positional args are already joined with spaces, so
bob capture +2 'x' also works. Task/link forms, markers, @@, and prose
never chain, the JSON contract is unchanged (schema_version 1, no new
keys), and no SASE memory changes.
Project notes¶
Use a leading or trailing @<route>^<block-id>+ marker to create a brand-new
sub-project note instead of a task. A #<pomodoro> name after the +
(@<route>^<block-id>+#<pomodoro>) picks the Pomodoro that :<id> Task
Links go under. Task bullets name their IDs with a trailing :<id> (link
into the Pomodoro) or ^<id> (name only). For example:
Finish the Google exit packet! @cash^goog-exit+#admin
- Draft the resignation memo :draft-memo
- keep it short
- Call Morgan Stanley about the 401k :call-ms
- Collect the equity paperwork ^equity-docs
- FUTURE WORK
- Revisit the severance terms
This creates cash_goog_exit.md:
---
parent: "[[cash]]"
template: "[[new_project]]"
type: "[[project]]"
status: wip
created: 2026-09-29T14:31:07-0400
---
- [ ] #task #prj Finish the Google exit packet! #hide ^prj
## Tasks
- [*] #task Draft the resignation memo [created::2026-09-29] ^draft-memo
- keep it short
- [*] #task Call Morgan Stanley about the 401k [created::2026-09-29] ^call-ms
- [ ] #task Collect the equity paperwork [created::2026-09-29] ^equity-docs
## Future Work
- Revisit the severance terms
In the same atomic write, it appends two links to the daily note's ADMIN
Pomodoro, in source order — creating the future placeholder
- [ ] () — ADMIN first when no open ADMIN Pomodoro exists:
- [ ] () — ADMIN
- [[cash_goog_exit#^draft-memo]]
- [[cash_goog_exit#^call-ms]]
Without #admin, the links go to today's implicit current/next Pomodoro,
the same selection rule @route:id uses.
This mirrors the Obsidian Bob Navigation Hotkeys command Create project
note from task, so CLI-created notes are indistinguishable from
hotkey-created ones — including the template: "[[new_project]]" line, which
the Obsidian flow inherits from _templates/new_project.md and never strips.
The CLI renders the note directly and never reads that template.
The + sigil sits immediately after the block ID, before any # component.
A trailing + would be ambiguous — @sase:deep-fix#bugs+ is a valid
Pomodoro-linked task naming the Pomodoro BUGS+ — while + right after the
block ID takes nothing away, because block IDs accept only letters, digits,
and -.
The filename is <route>_<block-id with every '-' replaced by '_'>.md at
the vault root. The route arrives lower-cased from the marker parser; the
block ID keeps its authored case. parent is "[[<route>]]" — every capture
route is a vault-root note, so the [[path|basename]] form never applies.
created uses the YYYY-MM-DDTHH:mm:ss±ZZZZ shape the template's
tp.file.creation_date writes, controlled by BOB_NOW like every other
capture date.
The ^prj line is - [ ] #task #prj <body> #hide ^prj, where <body> is
the item's normalized parent text, never truncated. It is [ ] normally and
[?] when a scheduled property was resolved; it is never [*] and never
linked. bob task reconcile reconciles derived Blocked state later, as it
does for every other capture.
$<N> writes [priority::<value>] inline on the ^prj line, before
#hide ^prj, and a rolled $<N> date still writes its
🗓️ **SCHEDULE LOG** child under ^prj. A resolved scheduled date itself
lands as [scheduled:: YYYY-MM-DD] on the ^prj line, with the other task
metadata, and ^prj keeps capture's documented s:<N> [?] rule.
bob projects sync never writes frontmatter scheduled.
Authored child bullets are re-routed into the new note rather than nested
under the captured line. A first-level authored bullet with at least one
nested authored bullet and an ALL-CAPS title (letters, digits, spaces,
and & ' ( ) , . / -, starting with a letter or digit) becomes a
## Title Case section appended at the end of the note, with its nested
bullets copied in verbatim as - <body> lines. Title casing lowercases the
body and uppercases the first character of every alphanumeric run, so
FUTURE WORK becomes Future Work, NON-GOALS becomes Non-Goals, and
API DESIGN becomes Api Design without preserving the acronym. Two
sections whose titles normalize equally (trimmed, whitespace-collapsed,
casefolded) merge into one in source order. A first-level bullet ending with
a task ID always takes the task branch instead — naming it states the intent,
so an ALL-CAPS bullet with nested bullets and a :<id> / ^<id> still
renders as a task. Every other first-level authored bullet becomes a
- [ ] #task <body> [created::YYYY-MM-DD] line inside ## Tasks, with its
nested authored bullets rendered one indentation unit beneath it; an authored
checkbox status is preserved, and a bare ALL-CAPS bullet with no nested
bullets stays a task. An authored TASKS section merges into the generated
## Tasks section instead of adding a duplicate header. With no authored
task children, the note keeps the template's placeholder line
- [ ] #task (REPLACE WITH TASK DESCRIPTION) [created::YYYY-MM-DD] under
## Tasks.
A task ID token is one whitespace-free token with a : or ^ sigil
followed by at least one character, where the first character after the sigil
is an ASCII letter or digit — so :), :-), and :( stay prose. It counts
only as the last word of a first-level bullet's text, after that line's
item-wide markers (s:<N>, $<N>, %…, trailing @route…) are set aside:
- Draft memo :draft-memo s:2 works, while - Draft memo s:2 :draft-memo
leaves s:2 as literal text. The token is stripped from the rendered body
and appended as the last token of the rendered task, after [created::…].
A : task renders as [*], or [?] when the project resolved a scheduled
date, and gets its [[<stem>#^<id>]] Task Link — unless that date is in the
future, in which case the note is blocked (§13.3): the task renders [?]
plus #prj_is_blocked and stays unlinked, with a not_linked
(project_schedule) report; a ^ task keeps an authored checkbox status,
or [ ] without one. The ID must satisfy the shared
block-ID rule (A-Z, a-z, 0-9, -); prj in any letter case is
reserved. A bare trailing : or ^ is an unfinished ID and fails with a
message naming the :<block-id> / ^<block-id> spelling.
A project note with at least one : task reads the daily note and needs a
## Pomodoros section; with none, the daily note is never read. Missing
file, no eligible Pomodoro, multiple open timed entries, and "ledger already
contains [[…]]" errors reuse the existing Pomodoro-task messages. The note
and the daily note are staged together, so any failure writes nothing, and
later batch items see the staged daily note. Only one named Pomodoro may be
created per item, and every link lands under it in source order.
A #pomodoro name with no : task is rejected: `#admin` picks the
Pomodoro for ` :<id>` task links, but no task bullet ends with ` :<id>`; add
one or remove `#admin`. A task ID anywhere else in a project-note item —
on the parent line, or on a nested bullet — is rejected as misplaced: the
project's own task is always ^prj and is never linked. Other violations
fail before any write: an invalid or reserved ID (task ID `draft_memo`
may use only A-Z, a-z, 0-9 or '-'), two IDs that compare equal in one item
(task ID `x` is already used on line 2; rename the task on line 4), an
empty remaining body, or an authored checkbox on a : task (`:foo` makes
the task Next and links it, so it takes no `[x]` checkbox; remove the
checkbox or write ` ^foo` to keep it).
Retired forms fail with a message that teaches the new spelling. @cash:goog-exit+ →
`@cash:goog-exit+` is retired: a project note never links its own `^prj`
task. Write `@cash^goog-exit+` and end each task bullet you want in the
Pomodoro with ` :<id>` (with #bugs, the suggestion is
@cash^goog-exit+#bugs). A misordered + also teaches the fix:
@cash^goog-exit#bugs+ →
put the project-note `+` right after the block ID:
`@cash^goog-exit+#bugs` (a `+` after `#bugs` would be part of the Pomodoro
name), and @cash^goog-exit#bugs →
`#bugs` after `@cash^goog-exit` needs the project-note `+`
(`@cash^goog-exit+#bugs`); to link a task under a Pomodoro, use
`@cash:goog-exit#bugs`.
The parent note <route>.md must already exist and be an area or
non-terminal project note — the same rule bob capture-targets lists routes
by. A missing parent fails with
cannot create a project note under <route>.md: note does not exist (run 'bob capture-targets' to list routable notes);
a parent that is neither area nor project fails with
... note is not an area or project note, and a done or canceled project
fails with ... note is a <status> project. Parent reads see earlier batch
items, so a later item can parent onto a note an earlier item created. When
<route>_<id>.md already exists on disk or was already staged earlier in the
same batch, capture fails with project note already exists: <path> and
writes nothing. The parent note itself is never modified: bob projects sync
owns the generated - 🧩 **Sub-projects:** [[...]] line, which is
machine-owned and rewritten there.
Project-note capture cannot be combined with clipboard input:
%... markers fail with
project-note capture cannot be combined with % clipboard markers and
--clip with project-note capture cannot be combined with --clip.
Authored children are re-routed into ## Tasks and ## sections, so
clipboard children under the captured parent have no unambiguous home; this
is a deliberate deferral a future change can lift. Forced destination flags
(--route, --section, --task, --task-ref, --task-section) keep
@tokens literal, so they never reach this family. s:<N> and $<N> are
allowed and behave as described above. A =<X> / =x suffix is rejected on
project notes with a message naming @<route>^<block-id>+; ! stays
reserved for the sub-bullet toggle and is not accepted on this family; a
@@<route>^<id>+ declaration fails as invalid_global_destination, since
one declaration creating the same note per item is never what was meant; and
an empty block ID (@<route>^+) fails with the family's existing "requires
a block ID" wording.
Sub-bullets under existing tasks¶
Use a leading or trailing @<route>+<block-id> marker to capture an ordinary
child bullet beneath an existing task, without creating a note or changing the
parent task. For example,
bob capture '@cash+goog-exit' 'Called Morgan Stanley today.' writes:
- [*] #task Finish Google Exit Packet! [created::2026-07-31] ^goog-exit
- Called Morgan Stanley today.
When the selected task already has a direct-child Schedule Log, Work Log, or
Rotten Log, the complete new child — including any authored children, clipboard
children, or a $<N>-generated Schedule Log nested under that child — is
inserted immediately before the earliest of those managed logs. A Schedule Log is the
🗓️ **SCHEDULE LOG** child that records schedule changes; a Work Log is the
🛠️ **WORK LOG** child that records work summaries; a Rotten Log is the
🥀 **ROTTEN LOG** child that records keep history. Nested or lookalike log
markers do not move the insertion point. Tasks with neither managed log still
append at the end of the task block.
The marker composes with terminal s:<N>, $<N>, and clipboard markers in
either order. Scheduled properties are still rendered for consistency even
though Obsidian Tasks does not read them from an ordinary bullet. Existing child
indentation is copied; otherwise capture uses the note's dominant tab-or-two-space
indentation and falls back to a tab. Line endings are preserved. The note and
task must
already exist, block IDs must be unique, and non-task block IDs are rejected.
Missing IDs include a close-match suggestion when possible and direct callers
to bob capture-tasks -r <route>.
The same marker accepts an optional trailing #<section> selector, as in
@cash+goog-exit#requirements or @foo+bar#future-work. The selector names an
ALL-CAPS child section of that task and may use A-Z, a-z, 0-9, and
& ' ( ) , . / -. Whole-slug matches beat earlier prefix matches, so
#future-work still reaches FUTURE WORK even when FUTURE WORKFLOW appears
first; #future reaches the first slug that starts with future. The captured
block is appended at the end of that section's own block — before the next
direct child of the parent task, and before a managed log nested under the
section — using the section's child indentation. A selector that matches
nothing, or a task with no sections, is an error listing the real titles and
pointing at bob capture-task-sections; capture never falls back to the end of
the task. @route+id# with an empty selector is incomplete and reports need
task_section; it does not mean "any section". A second #, or # before
+, is not this family (@foo#bar+baz remains a note-bullet).
For example, bob capture 'Postgres 17 minimum @foo+bar#requirements' against
- [ ] #task Upgrade Postgres [created::2026-07-31] ^bar
- REQUIREMENTS
- existing
- FUTURE WORK
appends the new bullet inside REQUIREMENTS rather than at the end of the
task:
- [ ] #task Upgrade Postgres [created::2026-07-31] ^bar
- REQUIREMENTS
- existing
- Postgres 17 minimum
- FUTURE WORK
Every sub-bullet batch also reports its parent tasks once, in final state,
in the batch-level task_blocks array; see
Task blocks.
Task Link toggle¶
A capture item that is exactly @route+block-id, with no body text and no
authored child bullets, updates that existing task instead of writing a child
bullet. bob capture '@cash+goog-exit' is the default Ensure Next operation:
it makes the task Next and relocates its existing Task Link. The route note and
daily note are planned together before either file is written, so a failure
leaves the whole batch unchanged.
@route+block-id#pomodoro is the same Ensure Next operation with a Pomodoro
selector. The # component follows the item's mode: with no body text it
selects a Pomodoro name, using the same slug matching, canonicalization, and
named-future-entry creation rules as @route:block-id#pomodoro; once the item
has body text, #section keeps its child-bullet meaning and selects an
ALL-CAPS task section. Whole-slug matches beat prefix matches. A valid selector
with no matching open entry, including a completed-only match, creates the
canonical named future Pomodoro and then moves the existing Task Link subtree
into it. Ensure Next never creates a missing Task Link; that remains an
explicit reason to use @route+block-id!.
The link-presence toggle, including Pomodoro-link insertion and
all-open-link removal, is reserved for the terminal @route+block-id!
spelling. ! cannot be combined with #pomodoro. The ledger decides the
direction: no matching link under an open Pomodoro links the task, and any
matching link unlinks it. No toggle path ever lowers a lane.
Link presence for @route+block-id! is:
| Linked under an open Pomodoro today? | Status | Result |
|---|---|---|
| no | Ready [ ] / Blocked [?] |
Next [*], with [[route#^block-id]] linked under the implicit current/next open Pomodoro — unless the §13.5 link guard consumes it; §13.5.1 first drops its own open prerequisites when every safety gate passes |
| no | Next [*] / In Progress [/] |
Link inserted under the implicit current/next open Pomodoro; status unchanged |
| yes | any open status | Every matching link under every open Pomodoro removed; status unchanged |
| — | done, canceled, or unknown | Error: task ^<id> is <Status Name>; only Ready, Blocked, Next, and In Progress tasks can be toggled |
When an unlinked task links with !, Bob inserts the task
link under the implicit current/next open Pomodoro unless that entry already
has the same link. In that idempotent case no duplicate is written and JSON
reports pomodoro_already_linked: true. Matching duplicate links under later
still-open Pomodoros are removed. Completed Pomodoros are not cleaned up.
When a linked task unlinks, every matching link under every open Pomodoro is
removed and the route note is left untouched. Neither unsuffixed marker-only
form can enter that insertion or removal cleanup.
If the task line has exactly one valid future [scheduled::YYYY-MM-DD] field
and the link direction sets the task to Next, Bob removes that scheduled field.
Unlinking never touches the task line, so it never retires a schedule. When
the task already owns a direct-child 🗓️ **SCHEDULE LOG**, Bob prepends a
dated entry under it:
- _2026-07-20 → 2026-07-10_ — 🍅 pulled into today's Pomodoro
No Schedule Log is created for a task that does not already have one, and past,
today, invalid, or multiple scheduled fields are left alone. When the link
direction rewrites the task line, Bob stamps [fresh:: TODAY] before the
trailing Tasks fields in the same write (see docs/freshness.md); unlinking
never touches the task line and never stamps, and a Next or In Progress link
that retires no future schedule leaves the note untouched.
Task lookup errors match sub-bullet capture: unresolved route notes, missing
block IDs, duplicate block IDs, non-task block IDs, close-match suggestions,
and the bob capture-tasks -r <route> hint are reused. Daily-ledger errors
match Pomodoro-linked capture: Bob daily note does not exist: <path>, Bob
daily note has no Pomodoros section, Bob daily note has no eligible open
Pomodoro, Bob daily note has multiple open timed Pomodoros, and invalid
Pomodoro names with Pomodoro name must contain only A-Z, 0-9 or `& ' ( ) + , . / -` and must start with a letter or digit. A task toggle also
rejects forced destination flags with task toggle capture cannot be combined
with <flags>, --clip with task toggle capture cannot be combined with
--clip, %... with task toggle capture cannot be combined with % clipboard
markers, s:<N> with task toggle capture cannot be combined with s:<N>,
$<N> with task toggle capture cannot be combined with $<N>, and authored
child bullets with task toggle capture cannot have authored child bullets.
The marker-only @route+block-id and @route+block-id#pomodoro forms are
Ensure Next operations. Ready [ ], Blocked [?], In Progress [/], and
Next [*] are eligible: Ready and a task Blocked only by its own
prerequisites rise to Next after a §13.5.1 drop, while Next and In
Progress keep their lane. Other Blocked targets stay unlinked (see the
link-guard paragraph below). Like the ! link direction, Ensure Next stamps
[fresh:: TODAY] whenever it rewrites the task line (see docs/freshness.md). Done, canceled, unknown, missing,
non-task, and duplicate-ID targets keep actionable errors. This default
reverses the initial ! implementation: use the unsuffixed forms for
idempotent relocation, and use @route+block-id! only when you intentionally
want to toggle the link itself — add a missing link, or remove every
open-Pomodoro link with the lane unchanged.
The plain form's destination is today's implicit current/next open Pomodoro:
the single open timed entry when present, otherwise the first open entry in
document order. The named form's destination is the matching open named
Pomodoro, or a newly created named future Pomodoro when no open name matches.
A missing Pomodoros section, no eligible open implicit entry, multiple open
timed entries for implicit selection or named creation, or an invalid
Pomodoro name is an atomic error. Existing named selection skips the
multiple-open-timed guard. The command never creates a missing Task Link; if
no dedicated Task Link exists under an open Pomodoro, it fails write-free and
tells the user to use @route+block-id! to add one. More than one movable
occurrence is also a write-free invariant error. Completed Pomodoros are
historical and are never edited. A link embedded in surrounding prose is not
a dedicated Task Link.
When the sole link is already under the selected destination, the daily file
is left byte-for-byte unchanged. The task-side plan is independent: a Ready
or Blocked status still becomes Next and a single future schedule is still
retired when an existing Schedule Log is present — unless the §13.5 link
guard finds the target Blocked. If §13.5.1 can safely drop its own open
prerequisites, Ensure Next proceeds and reports them; otherwise the task line
stays unchanged, any existing live links to that task are removed from today's
open ledger, the open prerequisites rise, and the result reports
link_guard (human output prints stays <Status> plus the guard sentence).
An already-Next task whose
link is already at the destination is a true no-op. When relocation is
needed, Bob moves the dedicated link bullet and its complete descendant
subtree, adapting only the root indentation to the destination's established
child indentation.
Human output says would ensure / ensured rather than would toggle /
toggled, distinguishes "set Next" from "already Next" and "stays In
Progress", and prints either
the source-to-destination Pomodoro move (naming both Pomodoros, and naming a
created destination) or Task Link already in <name>; no ledger change. /
Task Link already in current/next Pomodoro; no ledger change. JSON stays
schema version 1 and kind "task_toggle" with toggle_direction: "next".
Additive fields let new clients render the outcome precisely while old
clients ignore them:
toggle_behavior: "ensure_next"(omitted for the!toggle)status_changed: true|falsepomodoro_link_action: "moved"|"already_current"pomodoro_link_sourceandpomodoro_link_destinationobjects with one-basedlineplus optionalnameandtime_rangepomodoro_nameset to the resolved canonical destination namecreates_pomodoro: trueonly when this operation created the named entrypomodoro_already_linked: trueonly for the already-at-destination no-opremoved_pomodoro_links: 0with nopomodoro_selector_unusedclearing story
A terminal ! on the same marker-only shape, @route+block-id!, opts into
the link-presence toggle. It is accepted only as the final byte of that
exact marker: not on @route+id#name, @@route+id, body-bearing items,
authored children, clipboard, schedule, priority, or forced destination flags.
Ordinary ! in capture prose stays literal. An unlinked Ready [ ] task,
or a Blocked [?] task whose own open prerequisites §13.5.1 can drop,
becomes Next [*] and uses the existing link-insertion path; other Blocked
targets stay unlinked. An unlinked Next [*] or In Progress [/] task
links with its status unchanged; a linked task of any open status unlinks
with its status unchanged.
Done, canceled, and unknown states keep the normal toggle errors.
Human output says would toggle / toggled and prints a link summary:
linked ^id under NAME · set Next, linked ^id under NAME · stays Next,
linked ^id under NAME · stays In Progress, not linked ^id · stays
<Status> plus the guard sentence when consumption replaced the link, or
unlinked ^id from N open Pomodoros · stays <Status>, followed by the day
file and ledger lines. A successful dependency drop adds a dim
⛓️ dropped dependency on [[route#^id]] line after the status line (or
⛓️ dropped N dependencies: … for several). JSON stays schema version 1 and kind "task_toggle".
Successful link and unlink actions include toggle_direction: "link" or
"unlink", status_changed, removed_pomodoro_links for unlink, and the
existing insertion fields for link. A consumed @route+block-id! keeps kind
"task_toggle" but omits toggle_direction, block_link,
creates_pomodoro, pomodoro_already_linked, pomodoro_selector_unused,
pomodoro_link_placement, pomodoro_name, and pomodoro_link_action plus
its source/destination endpoints. It reports link_guard (reasons,
promoted, detail, and drop_refused when an own dependency reason kept
the prerequisites); successful drops report dropped_dependencies in line
order and stage the dependent's removal from each dropped prerequisite's
BLOCKS line (blocks_line_updates). removed_pomodoro_links remains present, and
day_file is present only when cleanup removed at least one live link. A
^prj whose only reason is its own future schedule is refused with "move
the project date from the ^prj Schedule action".
Compatibility fields: pomodoro_name is the resolved destination name when
one exists, creates_pomodoro is true only when this Ensure Next created
the named destination, pomodoro_already_linked is true only for the
already-current outcome, and destination placement is reported only for an
actual move. Relocation is never described as later duplicate removal through
removed_pomodoro_links.
Pomodoro notes¶
Append a bare trailing # marker to capture the item as a plain-text
sub-bullet on a Pomodoro instead of a task. For example,
bob capture 'remembered to bump the timeout #' writes:
- remembered to bump the timeout
as a child of the selected Pomodoro. It renders as - <text> with no
[created::YYYY-MM-DD] stamp, no #task marker, and no block ID. The daily
note file is selected the same way as @<route>:<block-id> captures:
BOB_DAY_FILE when set, otherwise <bob-dir>/YYYY/YYYYMMDD.md. Unlike
@<route>:<block-id>, a Pomodoro note may attach to a completed entry.
Capture prefers the single open top-level entry with a recognized time
range, otherwise the last completed top-level entry, and otherwise the
first open top-level entry. A ledger with only completed entries therefore
succeeds and attaches to the last one. Multiple open timed entries are
an invariant error. The new bullet is appended at the end of the
selected entry's child block, reusing existing child indentation when
possible.
The marker composes with %... and --clip in either terminal order, since
"capture what I just copied onto this Pomodoro" is a plausible use, but it is
rejected alongside s:<N>, $<N>, any @route token, and --route, since
a plain Pomodoro bullet has no field for a schedule, priority, or routed
destination:
| Marker | With # |
|---|---|
%, %<N>, %<header>, --clip[=HEADER] |
allowed |
s:<N> |
rejected |
$<N> |
rejected |
@route, @route#Sec, @route:id, @route:id#name, @route^id, @route+id, @route+id#sec |
rejected |
--route / --section / --task / --task-ref / --task-section |
rejected |
Only a trailing bare # is recognized; a leading # or a # in the middle
of the body stays literal text, and #<section-prefix> keeps its existing
meaning as a bullet-section marker (see below) rather than a Pomodoro note.
Section bullets¶
Append #<section-prefix> or a bare # to an @route token, as in
@notes#Ideas or @notes#, to capture an ordinary Markdown bullet instead of
a task. It renders as - <text> [created::YYYY-MM-DD] and is placed in a
non-Tasks section whose heading title starts with the prefix (compared case
insensitively), or any non-Tasks section when the marker is a bare #. The
Next & In Progress, Blocked, and Done & Canceled status-group headings
bob task reconcile generates under a Tasks heading belong to that Tasks
section. They are never bullet targets, so @note# and @note#Bl skip them. A
matching non-H1 section is preferred; a matching H1 heading is used only when no
non-H1 heading matches. If no heading matches, the bullet goes into the
pre-heading (zeroth) section. When the destination note is an article
(type: "[[article]]"), selectors also match on heading identity: the title
with bob's section-number token removed, compared case-insensitively with
whitespace collapsed, and with emphasis, inline code, and wikilink markup
stripped. A prefix selector matches when it is a prefix of that identity, so
@sase_blog_contents#agent still finds ## 1.3 Agent CLIs after numbering. An
exact --section title matches when its de-numbered identity equals the
heading's identity, so both --section "Agent CLIs" and a stale
--section "1.4 Agent CLIs" land on 1.3 Agent CLIs. Raw title matching still
runs first, so @note#1.3 prefers ## 1.3 Agent CLIs over ### 1.3.1 Nested
in document order. Notes that are not articles keep the raw-title behavior
only. Within the chosen section the bullet is inserted after the last existing
top-level bullet, otherwise just below the heading (or after any YAML
frontmatter for the zeroth section). The suffixed route token may
lead or trail the body, so @notes#Ideas jot idea and jot idea @notes#Ideas
both capture into notes.md. A standalone terminal #<section-prefix>
marker not appended to an @route token, such as note #Ideas @foo, is
still not accepted and fails with a usage error; a standalone bare #, such
as note @foo #, is instead the Pomodoro-note marker described above and
still fails, since it conflicts with the @route token on the same item.
A --route target keeps @tokens literal. Add --section TITLE with
--route to force bullet mode and place the bullet in a non-Tasks heading
whose title matches TITLE exactly, compared case insensitively. Generated
status-group headings under a Tasks heading are skipped here too. If no heading
matches, the bullet goes into the pre-heading (zeroth) section — the same
fallback typed @route#prefix uses when nothing matches. This exact
section path is intended for picker integrations; typed @route#prefix tokens
keep the prefix-matching behavior described above. Without --section,
--route captures a task.
With --route, -t, --task BLOCK-ID selects sub-bullet mode while keeping
every @token in the text literal. Add -S, --task-section TITLE to nest the
new bullet under an ALL-CAPS child section of that task whose title matches
TITLE exactly, compared case insensitively. --task-section also works with
the hidden --task-ref option below; the command requires --route and
either --task or --task-ref. This exact path is the picker
counterpart to --section; typed @route+id#prefix tokens keep the
slug/prefix matching described above, so --task-section future-work does
not match FUTURE WORK while --task-section "Future Work" does. Picker
integrations may instead use the hidden --task-ref <line>:<digest> option,
which also reaches parents without block IDs and recovers when unrelated edits
shift the selected task's line.
Command-line options¶
Useful options:
-b, --bob-dir DIR: Bob vault root; defaults toBOB_DIRor~/bob-c, --clip[=HEADER]: force clipboard capture, optionally with a header-d, --dry-run: plan and report without writing notes or clipboard files-E, --expand: print a template draft's expansion and exit before vault access or planning-f, --format human|json: human confirmation or stable JSON for callers-i, --inbox-route ANSWER: route an inbox task this capture changes:<N|KEY>=<NOTE>moves it,<N|KEY>=keeps it in its inbox,keepkeeps every otherwise unanswered candidate; repeatable. See Routing inbox tasks out-n, --no-clip: keep trailing%...clipboard markers literal-R, --no-ref: keep bare links as inbox tasks instead of queueing a background reference job-p, --pull-in PICKS: select successor offers to link (all,none, or a comma list of offer numbers and/or keys; see task-dependencies.md §12.14). Hidden--require-pull-in-choice(no short alias, absent from--help) refuses write-free withcode: pull_in_choice_requiredwhen offers exist and no--pull-inwas given--refuse-override: hidden guard (absent from--help) that refuses a whole-item restart or swap withcode: pomodoro_override_refused; Bob Mac Capture sends it when the matching on-screen preview has not shown the override warning. Example refusal:
{"code":"pomodoro_override_refused","error":"capture item 1 starting on line 1: refusing capture: `=` would restart the running CAPTURE (0920-0945) and --refuse-override is set; capture `x =` to close it first","ok":false}
-r, --route NAME: force NAME.md and keep any @tokens in the text literal
- -s, --section TITLE: with --route, force a bullet into the exact section
- -t, --task BLOCK-ID: with --route, append beneath the identified task
- --task-ref LINE:DIGEST: hidden picker option; with --route, append beneath
the task identified by that stale-safe ref from capture-tasks or
capture-complete. Conflicts with --task. Not listed in --help.
- -S, --task-section TITLE: with --route and --task or --task-ref, nest
under the exact ALL-CAPS child section; conflicts with --section
Input, stdin, and JSON output¶
If TEXT is omitted and stdin is piped, bob capture reads the complete
piped stdin stream, so a multi-line authored-bullet draft survives a pipe:
printf 'parent\n- child\n' | bob capture. Put options before text, or use
-- when the task itself starts with a hyphen; a multi-line draft passed as
an argument needs its own shell quoting, for example
bob capture -- "$(printf 'parent\n- child\n')". An unknown --flag
before -- is rejected with exit 2 (unexpected argument '--flag' found,
JSON ok: false under -f json) and writes nothing, so text starting with
-- plus a letter must follow -- (bob capture -- --anything still files
--anything); -- plus a non-letter (such as --2) and single-hyphen
starts still capture as text. Embedded newlines inside a
single TEXT argument stay intact; multiple TEXT arguments are still
joined with single spaces, never newlines. Editor clients such as Bob Mac Capture should
call bob capture --format json -- <text> and parse the JSON object, whose
stable fields include ok, dry_run, routed, route, route_label,
relative_target, target, text, task_line, kind, created,
placement, open_notes, pull_in, and inbox_route (supported: true
on every success; see Routing inbox tasks out).
open_notes is a batch-level array on every success (dry-run and real run
alike); see Open notes. The
template path additionally returns template: {line, field_count, row_count}
and template_row: {row, line} on each expanded item, including the legacy
top-level result for row 1. A successful real template capture also saves the
block to the shelf (see Saved templates): the template
object then gains saved: {id, status} (new, updated, or replaced),
or not_saved: "<reason>" when the best-effort save failed. Both are absent
on --dry-run and when saving is off. Human output appends · saved
(· saved, replacing <origin-id> for replaced, naming the checked-out
origin that was removed) or · not saved to the template summary line. -E, --expand prints the expanded draft in human
mode; with -f json it returns expanded and the same template summary. On
drafts without a header it returns the original text. The
kind field is "task", "bullet", "pomodoro_task",
"pomodoro_note", "sub_bullet", "task_toggle", "project_note",
"pomodoro_adjust", "pomodoro_shift", "pomodoro_start",
"pomodoro_close", "pomodoro_link", "task_dependency", "task_complete", or "ref", and
task_line holds the rendered line for any kind — for "project_note" it is
the rendered ^prj line, and for "pomodoro_link" it is the linked task's
post-image line. On JSON-mode failures, stdout is still a
single object with ok: false and an error string. Template framing and
refusal errors include their stable template_* code. code also appears on
plan_theme_cap_exceeded, pull_in_choice_required,
inbox_route_choice_required, inbox_route_stale, and inbox_route_refused
refusals.
A reference item (kind: "ref") uses placement: "queued" or
"unchanged", leaves task_line empty, and carries the additive ref
object: url, cleaned_url, dedupe_key, display, route_hint
("article", "pdf", or "arxiv"), library (verdict plus the known
path, title, reading_state, or message), job (id and state,
only on a real run that queued), and fallback (relative_target and the
exact task_line the failed clip writes, only when queued). A dry run's
output equals the real run's apart from dry_run and job.
A capture with authored sub-bullets additionally includes a sub_bullets
array of the exact rendered child lines, including their target-selected
indentation, in source order; it is omitted entirely for an ordinary
capture with no authored children. A project-note capture always omits
sub_bullets — authored children are re-routed into the new note's
## Tasks and ## sections, and the project_note summary below replaces
it. Human output prints those lines directly
beneath task_line, before any clipboard children and schedule log.
A $<N> capture additionally includes priority (the written value, such as
"high") and priority_label (the configured label, such as "P1"); a
capture without $<N> omits both fields.
A $<N> capture that actually rolled the scheduled date additionally
includes a schedule_log object: reason (the 🎲 … text) and lines (the
exact rendered 🗓️ **SCHEDULE LOG** marker and entry lines, in note order).
The schema is unchanged; the reason text records the exact selected day count
in bold and the configured range in parentheses. schedule_log is omitted
when $<N> was not given, or when s:<N> won the scheduled date and no roll
happened.
Clipboard captures additionally include a clip object. Single captures keep
the existing shape: header, mode ("inline", "lines", "attachments",
or "snippet"), lines (the exact rendered child lines), attachments, and
entries. Leaf clips emit entries: []. Each attachment has source,
vault-relative saved, kind ("image" or "file"), and reused fields.
Snippet results also include the vault-relative snippet path. The header
value is null when the capture omitted a header and is the rendered string
(for example, "BUILD LOG") when one was explicit.
Counted histories above one use mode: "history", header: null, flattened
lines, and attachment records aggregated in entry order. Their entries
array contains one ordinary headerless clip object per requested value, keeping
entry boundaries and any owning snippet path explicit. The aggregate omits a
singular snippet field. %1 uses the unchanged single-capture shape.
task_line remains the parent line only, and non-clipboard JSON omits clip.
ID-only task results use kind "task" and additionally include block_id.
They omit day_file, block_link, and pomodoro_link_placement.
Pomodoro-linked results use kind "pomodoro_task" and additionally include
block_id, day_file, block_link, pomodoro_link_placement,
pomodoro_name (the resolved destination name), creates_pomodoro,
and pomodoro_link_destination (with its role; see
Plan budget and strict mode). The touched
Pomodoro also appears in the batch-level pomodoro_blocks array; see
Pomodoro blocks.
Successful solo-link results use kind "pomodoro_link" with placement: "linked",
routed: true, and text: "". They carry the post-image task_line, the
block_id, day_file, block_link, previous_task_line,
previous_status_symbol / previous_status_name, status_symbol /
status_name, status_changed, removed_scheduled, schedule_log (when
written), pomodoro_name, and creates_pomodoro fields; they add
pomodoro_link_action ("linked", "moved", or "already_current"),
pre-image pomodoro_link_source (only when a link existed), post-image
pomodoro_link_destination (with its role; see
Plan budget and strict mode),
pomodoro_link_placement (when a link was
inserted or moved), and pomodoro_start (only with =<X>). They emit no
toggle_direction / toggle_behavior, so older clients degrade to a neutral
preview instead of a wrong one. Every touched Pomodoro also appears in the
batch-level pomodoro_blocks array; see
Pomodoro blocks.
When the guard consumes @route:id, the result uses kind "link_guard"
and omits block_link, creates_pomodoro, pomodoro_link_action and its
source/destination endpoints, pomodoro_link_placement, and
pomodoro_name. It includes additive removed_pomodoro_links and day_file
only when existing live links were removed. The link_guard object carries
the outcome; when a local dependency reason prevents a drop, it also carries
drop_refused (project_blocked, project_task, schedule, or
dependency_line) and detail says why the prerequisites were kept. Any
existing live-link cleanup also appears in pomodoro_blocks.
A successful dependency drop keeps kind "pomodoro_link" and adds
dropped_dependencies in Depends-On line order. Each row has the prerequisite
note_path, block_id, exact link, clean text, status_symbol, and
status_name. The same staged batch removes the dependent from each dropped
prerequisite's 🧱 **BLOCKS:** line (blocks_line_updates; see
task-dependencies.md §14). Human
output prints ⛓️ dropped dependency on [[route#^id]]
or ⛓️ dropped N dependencies: … after the status line (would drop in a
dry run). The batch-level task_blocks entry for the dependent includes role
"dependency_dropped" and shows the changed task and Depends-On lines.
Relation captures keep kind "task_dependency" and add, per item,
relation_updates[] ({direction, source, target, label,
previous_label, action: added|relabeled|unchanged}),
linked_from_line_updates[], minted_relation_block_ids[], and
mirror_skipped[] ({reason:
archived_target|previous_daily_target|malformed_line|disabled}), all
omitted when empty. An existing task whose LINKS line changed is
reported as a task block with role relation_source; mirror-only
changes report rows, not task blocks. Human output prints the 🪢
rows shown in Linking tasks with '~' and
'~~'.
Project-note results use kind "project_note" with placement: "created".
route stays the parent route (cash for @cash^goog-exit+), so
route-based clients keep working, while relative_target and target point
at the new project note (cash_goog_exit.md). route_label is the written
file name (cash_goog_exit.md) — the one capture kind where route_label
is not <route>.md, because the project note is what a notification should
show as the destination. block_id is "prj", and task_line is the
rendered ^prj line. A new project_note object reports basename,
parent_route, parent_link (for example "[[cash]]"), tasks (the count
of ## Tasks lines written, including the placeholder when it is kept),
sections (the ordered array of rendered section titles, excluding
## Tasks), and task_links — always present, possibly empty — with one
{"block_id", "block_link", "text", "task_line"} entry per : task in
source order. block_link is [[<stem>#^<id>]] and task_line is the
rendered [*]/[?] task line. When at least one link was written, the
result also reports the top-level day_file, pomodoro_link_placement
(from the first insertion), pomodoro_name (canonical, when named), and
creates_pomodoro fields with their pomodoro_task meanings.
Project-note results omit the top-level block_link, which used to be the
^prj link; block_id stays "prj". scheduled, priority,
priority_label, and schedule_log keep their existing meanings, and
sub_bullets is omitted as described above. Human output prints the
created note, its parent link, the ^prj line, a <N> <task|tasks> ·
sections <titles> line, then — when links were written — ✓ linked
<day_file> (dry-run: would link), an under <NAME> line with
(created) when a named Pomodoro was created, one dim - [[stem#^id]]
line per link, and the hint that bob projects sync adds the parent's
Sub-projects line and bob task reconcile reconciles Blocked state, since
neither is written here. When links were written, their Pomodoro also appears
in the batch-level pomodoro_blocks array; see
Pomodoro blocks.
Sub-bullet results additionally include parent_line, parent_text,
parent_status_symbol, and parent_status_name. A capture that targeted a
task section also includes parent_section (the matched original title);
plain @route+block-id captures omit it. They reuse block_id for the
parent's ID, omitting it when a task-ref selected a parent without one.
The batch-level task_blocks array carries the same parents in full; see
Task blocks.
Task-toggle results use kind "task_toggle", placement: "toggled",
routed: true, text: "", and task_line set to the resulting task line.
They additionally include toggle_direction ("link" or "unlink" for the
! toggle, "next" for Ensure Next), status_changed,
previous_task_line, status_symbol, status_name,
previous_status_symbol, previous_status_name, block_id, day_file,
block_link, removed_pomodoro_links, removed_scheduled when a future
scheduled date was retired, and schedule_log when that retirement wrote an
entry under an existing Schedule Log. Link-direction toggles also report
pomodoro_link_placement when a link was inserted, pomodoro_name when a
named entry was selected or created, creates_pomodoro,
pomodoro_already_linked, and any later-link removals. Unlinking with !
reports the all-open-Pomodoro cleanup count and leaves the route note
untouched. pomodoro_selector_unused is always false and is not part of the
Ensure Next contract. Toggle results omit sub_bullets, clip, priority,
priority_label, parent_*, and scheduled. Ensure Next results add
toggle_behavior, pomodoro_link_action, and the
source/destination endpoint objects described under
Task Link toggle. An Ensure Next destination — and
any Pomodoro a link-direction toggle insert or unlink cleanup touches — also
appears in the batch-level pomodoro_blocks array;
see Pomodoro blocks.
When the link guard consumes a link or Ensure Next gesture, link_guard
describes why the target stayed unlinked and which prerequisites rose. For
@route+block-id!, the result omits toggle_direction, block_link,
creates_pomodoro, pomodoro_already_linked, pomodoro_selector_unused,
pomodoro_link_placement, pomodoro_name, pomodoro_link_action, and the
source/destination endpoints. Ensure Next keeps
toggle_behavior: "ensure_next" and omits the same insertion-only fields,
including pomodoro_link_action and its source/destination endpoints. For
both forms, removed_pomodoro_links and day_file report any cleanup at the
top level and are absent from the nested link_guard object.
Link-then-close is different: its Task Link lands under the Pomodoro being
closed as history, so the result retains both block_link and
pomodoro_link_action even when the task-side link guard reports a blocked
target. The standalone @route:id guard uses kind "link_guard", so older
clients render it neutrally instead of claiming a link succeeded. Whenever
consumption removes live links, the top-level removed_pomodoro_links,
day_file, and batch-level pomodoro_blocks diff report that cleanup.
Task-complete results use kind "task_complete" with placement:
"completed", routed: true, route: null, and text: "".
relative_target/target/route_label name the task's note,
task_line/previous_task_line carry the post- and pre-images,
block_id, previous_status_symbol/previous_status_name,
status_symbol/status_name, and status_changed describe the root
transition, and the additive task_complete object reports raw, note,
note_path, block_id, action ("completed" or "already_done"),
completion_date (omitted when already done), text (the root task's
clean display text with the configured global filter, inline fields, and
trailing block ID removed; the same string the human output prints),
subtasks and subtasks_left_open (each with note_path, block_id,
line, text, status fields, and reason on left-open rows), ledger
(omitted when the daily note was untouched: day_file, struck count,
struck_in with one line/name/status entry per entry struck in
place, moved with from/to entry line/name/status,
deduplicated count, dropped with one from/to entry pair per
deduplicated bullet, and removed_placeholders), the extended
unblocked[] (each row adds inbox, unblocked_by with one
note_path/block_id/text entry per predecessor closed by this item,
link with day_file (vault-relative), entry_name ("" when
unnamed), 1-based entry_line and line, entry_created, next_up,
block_link, and block_id_created — non-null exactly when this item
linked a picked offer — additive offer with key, n, group
(hand_off / project / project_finished), project on the project
group, destination {day_file, entry_name, entry_role, fallback}
(entry_role is slot / closing / implicit / created),
selected, and block_id_needed, not_linked with one of
already_planned, project_task, project_member, hidden,
disabled, not_selected, or no_destination (the key is omitted
when linked; breaker and not_planned_today are never emitted
again), trigger "dependency" or "project_empty" (a missing key
means an older bob), and hide_removed true when this item
removed a whole-token #hide), still_blocked[] (one note_path/
block_id/line/text/status_symbol row per open dependent that
stays blocked, including muted project-offer residents, with reason
waits_on, scheduled, or project (project carries the note's
vault-relative project path, plus scheduled when the project date
blocks), waits_on counting the open prerequisites left, scheduled
set only for scheduled, and the same trigger field), and
unblocked_check ("checked" when the lookup ran or the [id::] gate
proved it unnecessary, "unavailable" when the dependents snapshot
could not be built). Offered rows are emitted even when the status did
not change. block_id holds the minted ID when one was minted
(SL10), and "" when the dependent has none and none was minted
(unpicked rows never mint).
struck_in and dropped are always present (possibly empty)
whenever ledger is present; struck and deduplicated keep their count
meaning. Entry status is running, queued, or completed; when an
entry has no name, name is "". The item's top-level day_file is the
absolute day file path whenever the item changed the day file (ledger
present or a successor linked); previously it was always null. After
all items are planned, net batch reporting drops any offer whose later
item linked, completed, re-blocked, or dropped it, or whose task is no
longer open (SL20). Every successful capture JSON (dry-run too) carries
top-level pull_in with supported: true, offer_count, choice
(null / "all" / "none" / "subset"), picked_count, and per
project-group folded counts. Dry-run JSON equals real-run JSON with
the same flags. The batch-level
task_blocks array gains roles "completed" (the root and each closed
subtask) and "unblocked" (each recovered or picked dependent); see
Task blocks. Human output prints
✓ completed [*] → [x] <text> <note> ^<id> (dry-run: would complete),
one indented row per closed subtask and left-open descendant, a ledger
line in struck, moved, dropped, removed order ending with the day file
(an unnamed entry prints as line N), numbered offer N rows under
group headers, one linked row per picked successor
(linked [?] → [*] <text> <note> ^<id> → <destination>,
dry-run: would link, with destinations → FIX, → FIX (next up),
→ new BOB session (next up), → new session, and → line 37, plus
· added ^<id> when an ID was minted), one unblocked row per
recovered-but-unlinked dependent (with suffixes · already planned,
· not pulled in, · no open Pomodoro, · project task,
· project member, · hidden, · linking off), one surfaced row per
project pull-in that removed #hide without linking or changing status
(dry-run: would surface; every project row puts · project has no
open tasks before any reason suffix; see
docs/task-dependencies.md §12.10), and one
still blocked row per remaining blocked dependent (· waits on N more,
· until YYYY-MM-DD, · project <path>). A dry-run hint shows
-p 1,2 · -p all; a real run without a pick says the offers were not
pulled in. A left-open descendant or unblocked dependent with no
block ID prints only its note path with no trailing ^. Already-done
tasks print ✓ already done [x] <text> <note> ^<id> · nothing to change.
Pomodoro-note results use kind "pomodoro_note" with routed: false, route:
null, and target/relative_target set to the daily note. They additionally
include day_file, parent_line, and parent_text describing the selected
Pomodoro's ledger line and text, but omit block_id, block_link,
pomodoro_link_placement, parent_status_symbol, and parent_status_name,
since the ledger checkbox is not an Obsidian task. Human output prints an
under <parent_text> line without a status marker, then the rendered
- <text> bullet. The noted Pomodoro also appears in the batch-level
pomodoro_blocks array; see Pomodoro blocks.
Pomodoro blocks¶
bob capture -f json (dry run and real run alike) gains one additive,
batch-level top-level key. It sits next to plan_budget, never appears per
item or inside captures[], and is omitted when empty:
"pomodoro_blocks": [
{
"relative_target": "2026/20260930.md",
"line": 5,
"name": "CLEANUP",
"time_range": "0620-0735",
"status": "running",
"created": false,
"roles": ["adjusted"],
"lines": [
{"text": "- [ ] (**0620-0735** [t:: 75m]) — CLEANUP", "depth": 0,
"change": "changed", "before": "- [ ] (**0620-0710** [t:: 50m]) — CLEANUP"},
{"text": "\t- [[sase#^re-launch-failed]]", "depth": 1, "change": "unchanged"}
]
}
]
Field rules:
relative_targetis the day file relative to the vault,lineis the 1-based headline line in the final staged day file, andname/time_range(plainHHMM-HHMM) come from the final ledger scan. Each is omitted when the entry has none. Whenremovedis true,lineis the 1-based headline in the item's pre-state day text, so an earlier edit in the same item (N linked above T's consumption) does not shift it.statusis"completed"for a completed entry,"running"for an open entry with a time range, and"queued"for an open entry without one.createdis true when the entry did not exist before the batch.removedis true when this capture deleted the entry; omitted when false. A deleted entry'slinesall carrychange: "removed".rolesis an informational, deduplicated list in first-touch order. The vocabulary is"adjusted","shifted","started","closed","reset"(a demoted swap session orx0reset),"next","linked","unlinked","removed"(the entry this capture emptied), and"changed"(auto-detected); clients must not depend on it.linesis the block in document order — the headline plus every following line up to the first non-blank zero-indent line or the end of the## Pomodorossection, trailing blanks trimmed — with removed lines interleaved where they used to be.textis the verbatim line with no terminator;depthis the nesting level relative to the headline (the headline is 0, its direct children are 1, a non-list continuation line takes its parent list item's depth + 1, a blank line is 0);changeis"unchanged","added","removed", or"changed"; andbeforeholds the old text only on"changed"rows. A created block's lines are all"added". A deleted block's lines are all"removed".
Blocks appear in first-touch order across the batch. A chain such as
+2 x touches the same session twice but reports it once, in its final
state, with the cumulative diff against the ledger before the capture.
Whole-item adjust, shift, start (including named starts), and close report
their sessions; link and task forms report theirs through auto-detection
until they carry explicit refs. Every PomodoroBlockRef::removed
producer reports the deleted entry this way, not just consumption.
Dry-run JSON equals real-run JSON except
for dry_run. Human output names a deleted session on the structured
− consumption lines
(Blocking existing tasks with '*').
Task blocks¶
bob capture -f json (dry run and real run alike) gains one additive,
batch-level top-level key next to pomodoro_blocks. It never appears per
item or inside captures[], and it is omitted when empty:
"task_blocks": [
{
"relative_target": "sase.md",
"route": "sase",
"line": 1,
"block_id": "capture",
"text": "Port capture to PIW sase-core",
"status_symbol": "/",
"status_name": "In Progress",
"created": false,
"roles": ["sub_bullet"],
"lines": [
{"text": "- [/] #task Port capture to PIW sase-core [created:: 2026-09-30] ^capture", "depth": 0, "change": "unchanged"},
{"text": "\t- REQUIREMENTS", "depth": 1, "change": "unchanged"},
{"text": "\t\t- existing", "depth": 2, "change": "unchanged"},
{"text": "\t- Should reuse as much of PIW sase-core code as possible!", "depth": 1, "change": "added"},
{"text": "\t- 🗓️ **SCHEDULE LOG**", "depth": 1, "change": "unchanged"},
{"text": "\t\t- 2026-10-01 moved", "depth": 2, "change": "unchanged"}
]
}
]
Field rules:
- One entry per distinct parent task the batch touched — sub-bullet
parents, dependency writers, and whole-item
!completions (roots, closed subtasks, and recovered dependents) — in first-touch order, in its final state. relative_targetandroutename the note.lineis the 1-based task line in the final staged note.block_idis the parent's trailing block ID, omitted when it has none (a picker task-ref parent).text,status_symbol, andstatus_namedescribe the parent in the final state, with the same meanings as the per-itemparent_text,parent_status_symbol, andparent_status_name.createdis true only when the parent task did not exist before the batch. This happens when an earlier item in the same draft created it, as inTask @sase^newfollowed bynote @sase+new. A created block's lines are alladded.rolesis informational and deduplicated:"sub_bullet"for captured children,"dependency"/"dependency_target"for prerequisite writes,"dependency_dropped"when a capture link drops the task's own prerequisites, and"completed"(the completed root and each closed subtask) /"unblocked"(each recovered dependent) for whole-item!completions. Clients must not depend on it. Adependency_targetblock'slinesinclude the staged BLOCKS child when that mirror changes; the item also carries additiveblocks_line_updatesandminted_dependent_block_idwhen a mint happens (task-dependencies.md §14.11).linesuses exactly thepomodoro_blocksline schema:text(verbatim, no terminator),depth(relative to the task line, computed by the same helper),change(unchanged/added/removed/changed), andbefore(old text onchangedrows only). Removed lines are interleaved where they used to be.- The diff is cumulative against the note before the capture. If the same
draft also rewrites the parent's task line (for example, a task toggle
of the same task), the task line shows as
changedwith itsbeforetext. - Dry-run JSON equals real-run JSON except for
dry_run. Human output does not change. Per-itemparent_*fields stay as they are.
Open notes¶
bob capture -f json (dry run and real run alike) always includes a
batch-level open_notes array next to task_blocks / pomodoro_blocks.
It never appears per item or inside captures[]. An empty array means
nothing to open (for example a queued link). When the key is absent, the
bob is older than this field. Clients should pass the entries through
rather than re-derive paths, lines, or digests.
"open_notes": [
{"path": "/Users/bryan/bob/mac_inbox.md", "relative_path": "mac_inbox.md",
"role": "note", "label": "inbox", "line": 42, "ref": "42:35a733bd"},
{"path": "/Users/bryan/bob/2026/20261017.md", "relative_path": "2026/20261017.md",
"role": "day", "label": "Today", "line": 18, "ref": "18:0c1d2e3f"}
]
Selection: process items in source order. For each item, open its
target (skipped when empty, as for a queued link). Then open the day
file if this batch changed it (a staged text file whose
updated_target != original_target; the day file is the one
pomodoro::day_file_for returns). Deduplicate by path; the first item
to touch a path decides
where that note lands. Incidental writes do not open: BLOCKS mirrors,
the closed tasks' notes on x, and prerequisite notes.
Landing: prefer batch-level task_blocks / pomodoro_blocks lines
(already in final coordinates) and the ^block_id task line found with
the existing task-line helpers. Otherwise fall back to the last exact
full-line match of task_line in the file's final text (the helper
behind locate_new_task_ref). Never use per-item line numbers; a later
item in the same draft can shift them. "First touched Pomodoro block"
means the first batch-level pomodoro_blocks entry for that file whose
role is not removed, in first-touch order; its line is the headline.
| Item kind | Where its note lands | Where the day note lands |
|---|---|---|
task (new task, any route or ^id) |
The new task line | First touched Pomodoro block |
bullet (@route#Section) |
The new bullet line | — |
sub_bullet |
The first added child line | — |
task_toggle, link_guard, pomodoro_link, pomodoro_task |
The task line carrying ^block_id |
First touched Pomodoro block |
pomodoro_start/_adjust/_shift/_close/_reset (target is the day file) |
First touched Pomodoro block | (same entry) |
task_complete (!) |
The completed task line (task_blocks role completed) |
First touched Pomodoro block |
task_dependency (&, * owner) |
The owner task line (task_blocks role dependency) |
— |
project_note |
The ^prj line of the new note |
First touched Pomodoro block |
ref already in the library or intake |
No line: open the note or PDF | — |
ref queued |
Nothing opens (empty target) | — |
Field rules:
pathis always absolute. A relativeBOB_DAY_FILEis joined onto the vault directory.relative_pathis vault-relative when the file is under the vault, otherwise the absolute path.roleis"day"when the path is the batch's day file, else"note". Clients must tolerate unknown roles.labelis"Today"for the day file. Otherwise the contributing item'sroute_labelwhen non-empty, elserelative_pathwithout.md.lineis optional. A 1-based line in the file's final text (staged text for touched files, on-disk text for untouched ones). Omitted when bob cannot land.refis present exactly whenlineis:<line>:<digest>, with the digest computed from that final line exactly asnote_taskscomputes it.block_idis optional. Present when the landed line carries^block_id.- Dry-run JSON equals real-run JSON except for
dry_run. Human output does not change.
Dependency updates and consumed links¶
Per-item additive fields on bob capture -f json (schema version 1).
Omitted when empty. Dry-run JSON equals live JSON apart from dry_run.
dependency_updates[]: every dependency update the item made. The item's own&update comes first (role: "dependent"), then one entry per*target in typed order (role: "blocked_task"). Present whenever at least one update exists, so&-only items carry it too. The singulardependency_updatestays and equalsdependency_updates[0]. ForN *Tthat is therefore T's update (dependent T, prerequisites [N]), which older Mac builds already render truthfully. Each entry keeps today'sdependency_updateshape (dependent_note,dependent_block_id,dependent_text,new_task,added,already_present,open_prerequisites,prerequisites[],dependent_status,dependent_status_name,status_changed) and gains:role:"dependent"or"blocked_task"previous_statusandprevious_status_nameblocked_residents, the count of^prjresidents newly blocked (omitted when 0) Eachprerequisites[]row keepsnote,block_id,link,status_symbol,status_name,text, andopen.status_symbolandstatus_nameare the post-write values. Additive fields:already_present:truewhen this prerequisite was already on the Depends-On line (omitted when false)previous_statusandprevious_status_name: present only when this write changed the prerequisite's statusconsumed_links[]: one row per live Task Link this item consumed. Fields:{day_file, line, link, note, block_id, text, action, pomodoro: {line, name?, time_range?, status, removed}}.actionis"removed","token_removed", or"notes_kept".statusis"running"or"queued".linevalues are 1-based in the item's pre-state day text.&consumption emits the same array.minted_blocker_block_id {path, line, block_id}: present only when N's ID was minted for*. Never reuseminted_dependent_block_id, which names the ID minted so an&BLOCKS mirror can point at N as a dependent.- Batch
pomodoro_blocksentries for deleted sessions carryremoved: trueand role"removed"; see Pomodoro blocks. - The legacy
warnings[]stringremoved N live Pomodoro link(s) to newly Blocked task(s)stays for older clients. Human output prints the structured−lines instead, so the same fact is never printed twice.
See Blocking existing tasks with '*' for the human lines and a full JSON example.
Interactive editor markers¶
Bob Mac Capture (Control-Shift-Command-I by default) also supports incomplete
interactive markers. Use <task> @: to choose an area or project and then enter
a block ID, <task> @route: to prompt only for the block ID, or
<task> @:block-id to prompt only for the destination. A complete
<task> @route:block-id request captures immediately. The panel validates
each supplied or prompted component, emits only the canonical colon marker,
and retains staged values when validation or capture fails. Existing @,
@#, and @route# picker flows are unchanged.
Supported terminal %, %N, %header, s:<N>, and $<N> markers may
appear on either side of these interactive @... tokens and survive the
target, section, block-ID, or task picker. For example, <task> @sase# %
opens the section picker for sase.md; the panel consumes only @sase#, and
bob capture still owns clipboard, schedule, and priority interpretation
after the section is chosen.
Sub-bullet capture has the matching four-way + family. Use <text> @+ to
choose a destination and then one of its open tasks, <text> @route+ to choose
only the task, or <text> @+block-id to choose only the destination. A complete
<text> @route+block-id request captures immediately. The task chooser shows
each task's literal checkbox with status color and searchable status, block ID,
section, and child-note details; picker selections use stale-safe task refs.
Editor clients that speak the versioned JSON interfaces also support the
ordinary task-with-ID ^ family. Use <task> @^ to choose a destination and
then author a new block ID, <task> @route^ to prompt only for the new block
ID, or <task> @^block-id to prompt only for the destination. A complete
<task> @route^block-id request captures immediately as an ordinary task.
The right-hand block ID is user-authored and must be new, so completion is
deliberately route-only and never offers existing task block IDs for that side.
Appending the + sigil carries the project-note intent through the same
pickers: <task> @^block-id+ still prompts only for the destination, while
@^<id>+ reports the project-note mode with needs: ["route"] and a bare
<task> @route^block-id+ is complete. The retired <task> @:block-id+
spelling offers nothing. Once the project note is addressed, typing : or
^ at the end of a first-level task bullet opens the New ID flow: the
panel offers a project_task_block_id completion with the project-note stem
as its scope, body-derived suggestions, and the sibling IDs (plus prj) as
its used list. Accepting a row inserts the ID after the typed sigil — :
names the task Next and links it into the Pomodoro, ^ names it only — with
no teaching line.
A solo leading ^ opens the active-task picker instead of creating a task.
Typing ^ lists In Progress and Next tasks with block IDs, ordered by
today's open-Pomodoro Task Links (queued tasks in ledger order, then
unqueued In Progress, then unqueued Next); accepting a row inserts the full
route:block-id in one step. A typed #name/=<X>/=x suffix survives the
accept. ^, ^fragment, and ^route: report incomplete with
needs: ["active_task"], ^route:block-id# needs pomodoro_name, and a
complete ^route:block-id[#pomodoro][=<X>] reports pomodoro_link and links
the existing task on capture. A solo leading : opens the task-link picker
instead of creating a task: any single-token, single-line item starting with
: — :, :dee, even :) — reports incomplete with
needs: ["task_link"], an empty body, route/section/block_id all
null, no diagnostics, and one interactive_placeholder span over the whole
token, sigil included. Multi-token (: dee), multi-line, and non-leading
(Buy :dee) shapes stay prose. A whole-item x close keeps the pomodoro_close span
out of completion gating, and typing x with nothing running surfaces the
no running Pomodoro diagnostic both in the CLI and in the Mac preview.
Bare-plus parent-task selectors report incomplete with
needs: ["task_parent"] and an interactive_placeholder over the full
+query token. This applies to a leading +query, prose-terminal +query,
and an eligible authored child line such as - note +. The exact lone +
stays pomodoro_adjust so the established five-minute action remains valid.
The editor completion endpoint still offers its dual-use parent picker for
that lone token. bob capture refuses unresolved plus selectors with a task
selection error; an accepted @route+block-id marker uses the existing
append/Ensure Next capture behavior.
bob capture-parse¶
bob capture-parse [-f|--format human|json] [-R|--no-ref] [--] [TEXT]...
Reports the authoritative capture grammar's reading of TEXT so an editor can
highlight capture syntax and Obsidian wikilinks while the user is still typing.
It shares the tokenizer, terminal-marker extraction, and @token
classification with bob capture. Parse does not validate vault targets
or execute the draft, so a successful parse does not guarantee a capture
will succeed. Wikilink highlighting is syntax-only and additive; it does
not change capture routing or diagnostics.
The command is read-only and does not read the clipboard or contact the
network; it takes no --bob-dir. Unless --no-ref is set, it reads the URL
routing policy from the config file (BOB_CONFIG_FILE, otherwise
$XDG_CONFIG_HOME/bob/config.yml, otherwise ~/.config/bob/config.yml) so a
bare link's ref/task classification matches capture. -R, --no-ref,
highlights.url_routing.capture: false, and an invalid config all keep
links as tasks. An invalid config is silent here. bob capture,
bob gkeep list, and bob gkeep pull print URL routing is off: … for
the same invalid file; capture: false and gkeep: false stay silent on
those commands too. A missing config file uses the defaults. Running
capture-parse with a Pomodoro-name span best-effort reads the configured
vault's theme catalog and adds a themes array to that span. If the catalog
is missing or unavailable, parsing still succeeds and omits those refs. The
catalog read never writes to the vault. A nonexistent BOB_DIR and a %...
clipboard marker still succeed. If TEXT is omitted and stdin is piped, it reads the
complete piped stdin stream, like bob capture. Only a missing TEXT or a
bad flag is an error (exit 2); every other input succeeds.
Template drafts¶
See Bulk templates for the @@template: grammar. For
capture-parse, the header line is a template_header span, each {N} is a
template_placeholder span with its zero-based field, and every lexed row
value is a template_field span with its field index. Field spans include
quotes when a value is quoted. The JSON template object reports the header,
template block range and line bounds, field_count, and rows. Each row has
its one-based row number, source line and range, fields, and (when expansion
succeeded) the expanded capture mode and body. Rows of a one-placeholder
template can share a line, and each row's range is its value's range.
items[0] is the template parsed through a same-length masked copy. Each
expanded row follows as an item with mode template_row, the row's typed
range and source line, its expanded body and capture markers, and
needs: []. Its template_row value mirrors the corresponding entry in
template.rows. A row that cannot expand still has an item and its field
spans, but no expanded mode or body. If the header has no template yet or
the template has no rows, the top-level needs includes template_row.
Template-language diagnostics include template_header_misplaced,
template_missing_rows, template_needs_separator,
template_missing_placeholder, template_invalid_placeholder,
template_placeholder_gap, template_row_bullet,
template_unterminated_quote, template_row_arity,
template_global_declaration, template_too_large,
template_clipboard, and template_session_operator. A validly expanded row
that still has picker needs or parses as incomplete adds
template_row_incomplete. Without a header, a first item containing {N}
followed by another item adds the template_header_missing warning; capture
continues to treat those items normally.
Template and masked-parse ranges point into the typed draft because masking
preserves byte offsets. Per-row diagnostics use the expansion source map:
literal-template errors point to the template, an error touching one row field
points to that whole field (including its quotes), and an error touching
multiple fields points to the row. Repeated literal errors are deduplicated by
code, range, and message. A placeholder that changes a token's meaning per
row, such as ${0}, is highlighted according to how the masked template
parses it; each row's expanded parse still reports any real problem.
global_destination is omitted for template drafts. Drafts without a header
keep their existing JSON shape, except for the template_header_missing
warning when that hint applies.
TEXT accepts the same batch draft bob capture does: one or more blank or
whitespace-only physical lines separate capture items, and each item keeps the
existing parent-plus-authored-bullets grammar. Within an item, the first
physical line is the parent, later column-zero -/*/+ lines become
first-level authored children, and later lines prefixed by exactly two ASCII
spaces become nested authored children. Separator rows themselves have no
marker completion or highlighting. Incomplete
interactive markers are valid input rather than errors, so @, @#,
@#Ideas, @route#, @^, @route^, @^id+, @+, @route+, @route+id#,
@:, @route:, @route:id#, @route:#name,
and the legacy @! aliases all parse on any line. A valid =<X> start
suffix on a @<route>:<block-id>[#<name>] marker (including the short =name suffix, e.g. @work:outline=bugs) parses as pomodoro_task
with a pomodoro_start object (raw plus 5-minute duration_units and
offset_units) and a pomodoro_start span covering the = and <X>
bytes; the Pomodoro-name span always ends before the =, even on incomplete
markers such as @<route>:=3. On a link item the suffix name emits pomodoro_start [13,14) plus pomodoro_name [14,18) spans (e.g. @work:outline=bugs). An invalid suffix is an
invalid_pomodoro_start diagnostic, including the name-once error (@work:outline#bugs=focus) and the link drop-list error (@work:outline=bugs~2: a link start takes a timing and a name, not a drop list). A whole-item +[N]/-[N] adjustment
(for example +5 extends by 25 minutes, - shortens by 5 minutes) parses
as pomodoro_adjust with a pomodoro_adjust object (raw plus sign and
5-minute units, defaulting to 1 when the count is omitted) and a
pomodoro_adjust span covering only the signed token; the parse stays
purely lexical and never guesses current ledger times. A whole-item
++[N]/--[N] shift (for example ++3 moves 15 minutes later, -- moves
5 minutes earlier) parses as pomodoro_shift with a pomodoro_shift
object (raw, direction as later, and 5-minute units defaulting to 1)
and a pomodoro_shift span covering only the operator token. A @@
declaration still routes ordinary items in the same draft but never turns
an operator into a task. Invalid standalone counts (+0, ++0, overflow)
and malformed operator-first items (extra text, markers, or child lines)
report an invalid_pomodoro_adjustment (one sign) or
invalid_pomodoro_shift (two signs) diagnostic. A whole-item =/=<X>
start (for example = is 25 minutes, =3 is 15 minutes) parses as
pomodoro_start with a pomodoro_start object (raw excludes =, plus
5-minute duration_units/offset_units and the additive drop list) and a
pomodoro_start span covering the whole token (only =<X> when a ~<K>
drop part is present, with a pomodoro_start_drop span covering ~<K>
including the ~); the human start line reads =3#bugs~1,3 (15m, offset
0u · drop 1, 3). A claimed token (counted or drop-carrying) with extra
text, an exact token with child lines, or an oversized suffix reports
pomodoro_start plus an invalid_pomodoro_start diagnostic (the extra
text, the child line, or the token for overflow). A trailing ~<K> drop
list parses with the sorted list in the spec; a dangling ~/, reports
mode incomplete needing pomodoro_start_task with the partial spec, the
spans typed so far, and one interactive_placeholder span over the
separator. A whole-item =name named start (for example
=bugs is 25 minutes, =3#bugs is 15 minutes, =bugs~2 starts
BUGS without link 2) parses as
pomodoro_start with the same pomodoro_start object (raw excludes =,
so =3#bugs reports "3"), section carrying the typed name, a
pomodoro_start span covering the = plus <X> bytes ([0,1) on =bugs), and a pomodoro_name span
covering the name ([1,5) on =bugs). A bare token emits pomodoro_start [0,1) and pomodoro_name [1,1+len), then the existing @<X>/~<K> spans; modes, needs, specs, and diagnostic codes are identical to the # spelling. =<X># with an empty name is an editing state, never a
mistake: mode incomplete needing pomodoro_name, with the partial spec,
the pomodoro_start span typed so far, and one interactive_placeholder
span over the #. Every other named-start near miss reports
pomodoro_start plus an invalid_pomodoro_start diagnostic reusing
bob capture's exact wording: an empty name with extra text or child
lines, a link-form order (=#bugs=3, ranged on the name), invalid name
characters (ranged on the name), an oversized suffix (ranged on =<X>), or
extra text, markers, or child lines on a well-formed token (ranged on the
extra text or the child line, with the join-with-- hint when the extra
text is all name characters; diagnostics that rewrite a token keep that token's spelling, e.g. =deep work → =deep-work vs =#deep work → =#deep-work). An exact-name literal (=#"DEEP WORK") parses the same way with section carrying the decoded name: the pomodoro_name span covers the whole literal including its quotes ([2,13) on =#"DEEP WORK"), and the same holds for literal link suffixes ([14,24) on Do work @r:id#"SASE V18"). The glued-timing teaching error (=3bugs, =2fa) fires when the byte after a non-empty <X> is an ASCII letter, suggesting =bugs@3 or =3#bugs. A @@ declaration never applies to named
starts either. A claimed == start form is retired: capture-parse reports
invalid_pomodoro_start with the same teaching message as bob capture,
including a rewrite that removes one leading =. For example, == reports
`==` is retired: `=` restarts or swaps the running Pomodoro by itself; write `=`.
A bare == followed by prose stays prose, protecting Obsidian highlights
(==important== thing, ==foo, == foo, ===, and mid-body Plan ==3).
The claimed close near misses ==x, ==X1, ==*, and ==!2 report that
they are not closes and teach the corresponding lowercase x form. In a
chain such as x ==, the == token fails on its own range with the retired-
form diagnostic. Parse output does not carry an override flag because
restarting or swapping depends on the staged daily ledger, which only
bob capture reads. A whole-item x[<N>][*<P>][!<M>][~<K>] close
(lowercase x only, with *, !, and ~ in any order) parses as
pomodoro_close with a pomodoro_close object (raw plus the additive
in_progress list, null when no <N> was typed, the park list,
the complete list, the drop list, additive park_all and complete_all booleans, and the
log entries (index, text, details) in typed order; wildcard groups keep
their lexical lists empty while the close summary resolves them to concrete
ascending task indices; index is omitted for unnumbered bullets under a close
without <N>/*<P> or with wildcard intent, which bob capture resolves
after staging the running session lineup) and
spans covering the one-byte x token (pomodoro_close), the <N>
list including its commas (pomodoro_close_in_progress), the *<P> list
including the * (pomodoro_close_park), the !<M> list
including the ! (pomodoro_close_complete), the ~<K> list including the
~ (pomodoro_close_drop), and each numbered entry index
(pomodoro_close_log_index; positional entries get no index span, and entry
and detail text render as neutral prose
but keep wikilink spans); the human close line reads
x1,3!2~4 (in progress 1, 3 · complete 2 · drop 4 · defer the rest), with
in progress none for x0, a bare x for a plain close, and
log 2 "wired the lexer" (+1 detail) for typed entries with details
(log "wired the lexer" with no number for entries resolved at execution).
One entry may sit on the close line (x wired it logs to task 1 with body
x, or to the first eligible top-level worked link under wildcard intent; an
explicit number gets a pomodoro_close_log_index span while a runtime-resolved
default gets none); a dangling inline number (x 2) reports mode
incomplete needing pomodoro_close_log_text with the partial spec and a
placeholder over the number. Several entries use child bullets below the
close (- [<n>] <text>: bullets are numbered all or none, and unnumbered
bullets log in order to the close's worked tasks); a dangling bullet (- 1)
reports mode incomplete needing pomodoro_close_log_text with the partial
spec (lists plus every complete entry with its details) and a placeholder
over each dangling number instead of its index span, while a bad bullet (mixed
numbering, too many unnumbered bullets, a close that works no task,
a non-loggable index, a bad number, a block link, a fence) reports
pomodoro_close plus an invalid_pomodoro_close diagnostic on the precise
range, as does a bad inline entry. Every malformed list (a
duplicate, an overlap, a misplaced 0, a second ! or ~, a bad
character, an oversized number, or a space inside the lists, which gets the
no-spaces hint) reports the same. A token ending
in a dangling separator (x1,, x!, x~, x1!, x!2,, x0!)
reports mode
incomplete needing pomodoro_close_task, with the partial spec typed so
far, the spans typed so far, and one interactive_placeholder span over the
separator. A same-line chain splits into one items[] entry per operator,
with child lines attaching to the line's x; a chain whose x is not
last nests its item ranges, so they never partially overlap. Other
=-prefixed tokens (= foo, = bugs)
and mid-body x/=3/=bugs stay ordinary prose. A bare token never emits the interactive_placeholder that the empty =# uses; lone = stays a complete start. On link items the
=x… suffix spans the same four span kinds instead of pomodoro_start:
@r:id=x1!2 and ^r:id=x1 stay pomodoro_link (or pomodoro_task with body
text) and carry the spec, ^r:id=x1,, ^r:id=x1!, and ^r:id=x1~ report
incomplete needing pomodoro_close_task, while #name=x…,
s:<N>/$<N>/% conflicts,
project-note =x, and malformed lists report invalid_pomodoro_close on
the conflicting component or the precise list range. A @@ declaration
never applies to close or =/=<X> items, and neither is ever rewritten.
A start token or a close token requests no completion: a cursor on that
token, including anywhere inside the task-number lists or a dangling
separator, returns an empty success. In Work Log text under or on the
close line, marker and block-link completion are suppressed and note and heading
completion keep working. The picker's in-progress
@^, @route^, @^id, @:, @route:, and @:id spellings are unchanged,
and @^id+ carries the project-note intent with needs: ["route"]. A : project-note
spelling (@:id+, @route:id+, @route:id+#name) is a
retired_project_note_marker diagnostic teaching the ^ form instead. The retired
@route::... spelling is a diagnostic directing users to @route^...;
it is not an incomplete Pomodoro marker. A trailing bare # is a complete
pomodoro_note, not an incomplete section marker. Complete and in-progress Obsidian links such as [[sase,
![[sase]], [[sase#Design|Spec]], and [[#^block-id]] also parse for
semantic highlighting. An invalid marker component, a malformed continuation
line, an orphaned nested bullet, an item emptied by marker removal, or a
duplicate item-wide marker across lines becomes a diagnostic instead of a
failure, while bob capture keeps its strict execution errors for the same
text.
JSON output is a single versioned object:
{
"ok": true,
"schema_version": 1,
"input": "Call bank @Cash+",
"body": "Call bank",
"mode": "incomplete",
"route": "cash",
"section": null,
"block_id": null,
"needs": ["task"],
"spans": [
{ "start": 10, "end": 15, "kind": "sub_bullet_route" },
{ "start": 15, "end": 16, "kind": "interactive_placeholder" }
],
"diagnostics": []
}
input is the raw text as received, before whitespace normalization. body is
the normalized capture body after terminal s:<N>, $<N>, and applicable
%... markers and the recognized @... token are removed, matching what bob capture would
write for any input it accepts. mode is task, bullet, pomodoro_task,
pomodoro_note, sub_bullet, task_toggle, project_note,
pomodoro_project_note, pomodoro_adjust, pomodoro_shift,
pomodoro_link, pomodoro_close, pomodoro_start, task_dependency, task_complete, ref, or incomplete, describing whichever line resolved a marker
first -- the parent's leading or trailing form, or else the first child line
with a trailing marker. A whole-item bare link reports mode ref with one
ref_url span covering the whole token, <> included. In an eligible URL
reference, exact standalone URL % (or compatible URL @) adds
ref_listen: true and a ref_listen span over the directive; -R keeps it a
task so parse and capture always agree. A solo @route:block-id… item reports
pomodoro_link with the pomodoro_route / pomodoro_block_id /
pomodoro_name / pomodoro_start spans (or pomodoro_close plus the two
list spans for an =x… suffix); the ^ spelling reports the same
mode with active_task_route (covering ^route) and active_task_block_id
spans plus the existing name and start/close spans. A whole-item x…
reports pomodoro_close with its spans and spec (a dangling ,/!
reports incomplete with the partial spec instead), and a
whole-item =/=<X> reports pomodoro_start with a pomodoro_start span
and spec, both with needs: [] (the incomplete close needs
["pomodoro_close_task"]). A whole-item =<X>#pomodoro reports
pomodoro_start with the pomodoro_start / pomodoro_name spans, the
spec, and section set to the typed name, also with needs: [], while
=<X># reports incomplete with needs: ["pomodoro_name"]. Lone ^, ^fragment, and
^route: report incomplete with needs: ["active_task"] and one
interactive_placeholder span over the token, while ^route:block-id# needs
pomodoro_name. Near misses and solo-link conflicts report an
invalid_pomodoro_link diagnostic; the additive pomodoro_start spec is
unchanged. The project_note / pomodoro_project_note split
mirrors the task / pomodoro_task one, so a client can tell whether the
daily note is involved: a project note with at least one : task is
pomodoro_project_note, and a ^-only one stays project_note. A lone
: or ^ ending a first-level bullet is an unfinished ID: mode
incomplete with needs: ["block_id"] and an interactive_placeholder
span over the sigil, never a diagnostic. A bare trailing # reports pomodoro_note with
route, section, and block_id all null and an empty needs list. Combining
that marker with @route, s:<N>, or $<N> on the same item still reports
mode pomodoro_note plus a pomodoro_note_conflict diagnostic; bob capture
rejects the same input. A trailing #n or #no is rejected like any other
trailing #tag. route, section, and block_id are the
resolved components, or null; block_id carries the ID-only task, Pomodoro,
sub-bullet, or project-note ID, whichever applies — for a project-note marker
it is the authored block ID the filename suffix derives from. For a Pomodoro
marker, section carries
the Pomodoro name when one was typed — the same "whichever applies" reuse
block_id already has, and mode disambiguates — and the same holds for the
^ project-note form, where section is the #pomodoro name. needs lists what a picker
still has to supply, in the
order route, section, block_id, pomodoro_id, pomodoro_name, task, task_section, active_task, task_link, task_goto, task_parent, pomodoro_close_task, pomodoro_close_log_text, pomodoro_start_task, task_dependency, blocked_task, dependency_target, task_complete; it is an independent
completion hint, so the executable @route# bullet reports mode bullet and
needs ["section"], while @route+id# with no body text reports mode
incomplete and needs ["pomodoro_name"], note @route+id# reports mode
incomplete and needs ["task_section"], and @route:id# reports mode
incomplete and needs ["pomodoro_name"]. A complete @route+id#sec
sub-bullet populates route, block_id, and section together. A complete
@route:id#name Pomodoro marker and a complete @route+id#name task toggle
populate route, block_id, and section the same way; the mode says whether
section is a Pomodoro name or a task section.
relationship_needs_text is an error diagnostic, not a picker need. It is
ranged on the $<N>, s:<N>, clipboard marker, or child bullets token
when a bare existing-task owner has relationships but no task text; the
diagnostic message is identical to the bob capture refusal. See
Relating existing tasks with & and *.
sub_bullets is an optional array, omitted when empty, of every other valid
physical line's normalized body -- its source -/*/+ marker and any
item-wide markers already removed -- in source order. These are semantic
parse bodies for an editor's own preview, not rendered Markdown: they carry
no target-selected indentation or - marker, unlike bob capture's own
sub_bullets output field. When sub_bullets is present,
sub_bullet_depths is an aligned optional array of 1 and 2 values, one
per body, so version-tolerant clients can preserve hierarchy without a
breaking schema change. Older clients may ignore the additive field; clients
talking to an older bob that omits it should treat every body as depth 1.
sub_bullets bodies exclude the ID token: - Draft the memo :draft-memo
parses as body Draft the memo. A parallel sub_bullet_task_ids array,
emitted at the top level and per item only when at least one entry is
non-null, carries null or {"block_id", "link"} per body (link is true
for :). Human output shows each sub-bullet with its ID, as ^id or
:id. This remains schema version 1.
For a multi-item draft, items is an ordered optional array, omitted for a
single item. A same-line session-operator chain reports one items[] entry
per token: each range covers its token, and all entries share the physical
line's line_start/line_end. Each entry has a one-based index, a range with global UTF-8
start/end offsets into input, line_start/line_end physical line
numbers, the item's body, mode, route, section, block_id, needs,
and optional sub_bullets/sub_bullet_depths/sub_bullet_task_ids/pomodoro_start/pomodoro_adjust/pomodoro_shift/pomodoro_close/dependencies/blocked_tasks/dependency_target/task_complete. Real item indices and ranges
exclude declaration-only @@ lines but still index the original draft. Top-level and
per-item route, mode, and block_id are the item's effective destination
after inheritance. The legacy top-level fields continue to describe the first
item so older clients retain a useful preview. blocked_tasks is the *
twin of dependencies[]: the same {raw, note, block_id, quoted, range}
shape, emitted at the top level and per item, omitted when empty. Never put
a direction flag on dependencies[]. A partial * needs blocked_task;
see Blocking existing tasks with '*'.
relations[] is the ~ / ~~ analogue: {raw, direction: "to"|"from",
note, block_id, quoted, label, label_range, range}, emitted at the top
level and per item, omitted when empty. Labels come from |label /
|"label text" suffixes and are validated against the
relation label rules. A partial ~ /
~~ needs relation_target; a label position needs relation_label.
An ownerless item reuses dependency_target, mode stays
task_dependency, and relationship_needs_text covers ~ / ~~
exactly as it covers & / *. Span kinds relation_sigil,
relation_note, relation_block_id, relation_label_separator, and
relation_label mark each part. Top-level relation_grammar: 1
appears whenever the draft contains a ~ byte; drafts without ~
are byte-identical to today, and relations never appear in
dependencies[] or blocked_tasks[]. See
Linking tasks with '~' and '~~'.
When the draft has a @@ declaration, global_destination is an optional
object with the declaration range, one-based physical line, effective
mode, route, block_id, and needs. It is omitted when no declaration is
present, so schema version 1 stays additive.
pomodoro_start is an optional object, omitted for every input without a
start suffix or whole-item start, with the typed raw <X> text (excluding
= on whole-item starts, so =3 reports "3" and = reports "") plus
5-minute duration_units and offset_units, so schema version 1 is
unchanged for older inputs. It describes only the typed start; override
state is derived by bob capture from the staged ledger and appears in its
pomodoro_start.override result object. Multi-item drafts report each
item's own pomodoro_start alongside the top-level preview of the first
item; start items never inherit a @@ declaration.
pomodoro_adjust is an optional object, omitted for every input without an
exact adjustment, with the typed signed raw token plus its sign (plus
true for +[N], false for -[N]) and 5-minute units (so +5 is 25
minutes and - is 1 unit), so schema version 1 is unchanged for older
inputs. Multi-item drafts report each item's own pomodoro_adjust
alongside the top-level preview of the first item; adjustment items never
inherit a @@ declaration.
pomodoro_shift is an optional object, omitted for every input without an
exact shift, with the typed operator raw token plus its direction
(later true for ++[N], false for --[N]) and 5-minute units (so
++3 is 15 minutes later and -- is 1 unit earlier), so schema version 1
is unchanged for older inputs. Multi-item drafts report each item's own
pomodoro_shift alongside the top-level preview of the first item; shift
items never inherit a @@ declaration.
pomodoro_close is an optional object, omitted for every input without a
close, with the typed raw close text exactly as typed (x or x1,3!2~4
for a whole-item close, =x!1 for a link suffix), the in_progress list
(null when no <N> was typed, [] for x0, otherwise the typed
numbers sorted ascending), and the complete and drop lists. The drop
field is omitted when no ~<K> list was typed, so older closes keep their
version 1 shape. In x0~2, 0 is the entire in-progress list; task 2 is
dropped. An incomplete close (x1~) reports the partial lists typed so far.
A lexically invalid close
token reports no pomodoro_close object at all, only the
invalid_pomodoro_close diagnostic. Multi-item drafts report each item's
own pomodoro_close alongside the top-level preview of the first item;
close items never inherit a @@ declaration.
pomodoro_reset is an optional object, mutually exclusive with
pomodoro_close, for a note-free plain x0 that returns
the current Pomodoro to the first future placeholder. The parser still
reports the syntactic close input (it cannot know the vault-dependent
outcome), so capture-parse keeps describing x0 while bob capture
resolves it to pomodoro_reset or pomodoro_close. Whole-item resets report
kind: "pomodoro_reset" with placement: "updated"; attached link/new-task
forms keep their link/task kind and placement and add pomodoro_reset. The
object carries raw, day_relative, optional pomodoro_name,
previous_pomodoro_line, pomodoro_line, previous_entry_line,
entry_line, previous_time_range, time_range: null,
created_pomodoro: false, and moved. Batch-level pomodoro_blocks shows
the reset block with a reset role in its final open/untimed state. Schema
version stays 1; older clients ignore the additive key and fall back to
generic capture/block presentation.
spans are UTF-8 byte offsets into input, half-open [start, end), ordered,
non-overlapping, and always on a character boundary. Each kind is one of
route, section, task_block_id_route, task_block_id,
pomodoro_route, pomodoro_block_id, pomodoro_name, pomodoro_start, pomodoro_adjust, pomodoro_shift, pomodoro_close, pomodoro_close_in_progress, pomodoro_close_park, pomodoro_close_complete, pomodoro_close_drop, active_task_route, active_task_block_id, pomodoro_note, project_note_marker, sub_bullet_route,
sub_bullet_block_id, sub_bullet_section, task_toggle_route,
task_toggle_block_id, task_toggle_pomodoro_name, task_toggle_explicit_toggle, global_route,
global_sub_bullet_route, global_sub_bullet_block_id, schedule, priority, clipboard,
ref_url, ref_listen,
interactive_placeholder, project_task_link_marker, project_task_block_id, dependency_sigil, dependency_note, dependency_block_id, blocked_task_sigil, blocked_task_note, blocked_task_block_id, task_complete_sigil, task_complete_note, task_complete_block_id, wikilink_delimiter, wikilink_target,
wikilink_heading, wikilink_block_id, or wikilink_alias. A placeholder
marks the part of a marker the user has not filled in yet: the trailing + in
@cash+ or @@cash+, the trailing # in @cash+id# or @cash:id#, the dangling , or ! in x1, or x!, a lone : or ^ ending a project-note task bullet, or the whole @+ /
@@ when the route is still empty too. For example, x1,3!2 gives
[0,2) pomodoro_close, [2,5) pomodoro_close_in_progress, and
[5,7) pomodoro_close_complete; x1,3!2~4 adds
[7,9) pomodoro_close_drop. Link forms put these spans after the route and
block-ID spans. A project-note marker adds one
project_note_marker span covering the single + byte; its route and
block-ID components keep their base-family span kinds (task_block_id_route
/ task_block_id), and its #name keeps the pomodoro_name span. An
accepted task ID on a project-note bullet adds a project_task_link_marker
span over the : sigil only, plus a project_task_block_id span over the ID
for both sigils; the ^ sigil gets no span, just as separators never do.
Wikilink spans cover syntax only; unresolved note targets are not errors.
Each entry in diagnostics has severity (error, warning, or info), a
stable snake_case code, a message reusing bob capture's exact wording,
and a nullable range given as a two-element [start, end] byte array.
Today's codes are invalid_task_block_id_route, invalid_task_block_id,
retired_task_block_id_marker, invalid_sub_bullet_route,
invalid_sub_bullet_block_id, invalid_sub_bullet_section,
invalid_pomodoro_route, invalid_pomodoro_block_id, invalid_pomodoro_name, invalid_pomodoro_start (a malformed =<X> suffix, one on a project-note + form, a whole-item start near miss such as =3 more or =3x, or a whole-item named-start near miss such as =#bugs=3, =#deep work, or =3#bugs more, with the range on the extra text, child line, name, or token for overflow), invalid_pomodoro_adjustment (a zero magnitude, an overflow, or extra text/markers/child lines on an adjustment-first item), invalid_pomodoro_shift (a zero magnitude, an overflow, or extra text/markers/child lines on a shift-first item), invalid_pomodoro_close (a leading selection-shaped token with extra text/markers/child lines, a malformed task-number list, #name=x…, a project-note =x, or an s:<N>/$<N>/% conflict on a close item, with the range on the extra text, child line, offending list part, #name, or conflicting marker), invalid_pomodoro_link (a near miss or conflict on a solo @route:block-id… / ^route:block-id… item, such as extra text, authored children, or terminal markers on a complete shape), invalid_project_note_marker (a project-note shape error: a misordered +, a #name without its +, or an = suffix), retired_project_note_marker (a : project-note spelling, teaching the ^ form), misplaced_project_task_id (a task ID on the parent line or a nested bullet), invalid_project_task_id (a malformed, reserved, empty-body, or checkbox-bearing : / ^ task ID), duplicate_project_task_id (two equal IDs in one item, naming both lines), unused_project_note_pomodoro (a #name with no : task, ranged on the name), unsupported_explicit_toggle, legacy_bullet_marker, retired_priority_marker (a typed p:<N> token, teaching $<N>),
pomodoro_note_conflict (a trailing bare # on the same item as @route,
s:<N>, or $<N>),
invalid_child_line (a later physical line is not blank, a column-zero
authored bullet, or a two-space nested authored bullet),
orphaned_nested_bullet (a nonempty nested item has no preceding first-level
authored owner), empty_child_after_markers (an authored bullet has no text
left once its capture markers are removed),
duplicate_capture_marker (a later line in the same item resolves a route,
schedule, priority, or clipboard marker a prior line already resolved),
invalid_global_destination (unsupported or malformed @@ declaration),
duplicate_global_destination (a later @@ declaration after the first),
global_destination_shadowed (warning: an item-local marker overrides the
declaration token on that same item), and missing_capture_item (a declaration
with no capture item). Human output
prints the same information without color escapes when piped — including a
start line such as =3 (15m, offset 0u) when a suffix or whole-item start is present (whole-item = reports = (25m, offset 0u)), an adjust line such as +5 (25m, 5 units) (or - (5m, 1 unit)) when an adjustment is present, a shift line such as ++3 (15m later, 3 units) (or -- (5m earlier, 1 unit)) when a shift is present, and a close line such as x1,3!2 (in progress 1, 3 · complete 2 · defer the rest) when a close is present (a plain close prints a bare x) — plus a
Sub-bullets section listing sub_bullets with indentation from
sub_bullet_depths when it is nonempty. On a missing TEXT, JSON mode prints
a single {"ok": false, "error": "..."} object on stdout and keeps stderr
clean.
bob capture-related¶
bob capture-related [-b|--bob-dir DIR] [-d|--days N] [-e|--exclude NOTE:LINE] [-f|--format human|json] [-l|--limit N] [-q|--query TEXT] [-- DRAFT...]
This read-only command shows existing tasks that a capture draft may duplicate
or relate to. Draft mode follows the capture editor grammar and examines only
items that create tasks: task, pomodoro_task, project_note, and
pomodoro_project_note. For a project-note item it matches the title. A
@@template: draft and a draft with no task-creating items return an empty
items array before Bob scans the vault.
Open candidates are tasks in routable inbox, area, and active project notes.
Closed candidates are tasks completed or canceled in the last --days days,
including recent done/ archives. Checklist rows, recurring tasks in closed
state, and daily notes are excluded. A task already named by a draft
dependency, blocked-task marker, relation, task destination, or parent-task
destination is listed under linked and omitted from the matches.
Each item is tokenized with Bob's task-title rules and ranked against the
candidate set. The bands are same_title, possible_duplicate, similar,
closed_duplicate, and hold; same-title matching is case-insensitive and
ignores punctuation, tags, fields, and emoji. Titles that name different
numbers, such as Part 1 and Part 2 or 2025 and 2026, can be
similar but are never possible_duplicate or closed_duplicate. The
exact score bars and default action mapping can change without a
schema-version bump. Draft JSON never includes scores. The separate
--query TEXT harness ranks one title and includes numeric scores for
calibration. With --query, -e|--exclude NOTE:LINE removes exactly one
candidate before scoring and ranking, for example --exclude dev.md:42. The
note is vault-relative and the line is 1-based. This allows leave-one-out
evaluation to see recently closed matches that the query task itself would
otherwise suppress. It is invalid in draft mode; no file is edited.
Draft JSON contract. Success uses schema_version: 1 and returns
ok, warnings, and items. Each item contains:
index: its 0-based position in the full capture draft;mode: its task-creating capture mode;range: the item's half-open UTF-8 byte range in the original draft;marker_at: the UTF-8 byte offset where an action marker can be inserted on the parent line before a trailing Pomodoro suffix;route_range: the item's own destination-token range, omitted when absent;discard_range: the item and one adjacent blank-line separator, omitted when it has authored children;matches: ranked candidate rows;linked: resolved candidate keys already referenced by this item, withlinkset torelation,dependency,blocked_task, orparent.
Every range is half-open and indexes UTF-8 bytes in the exact draft passed to
Bob. marker_at and ranges are splice anchors for that draft only; clients
must discard them after an edit.
A match row contains key, band, clean title, matched_ranges, kind,
status_symbol, status_name, status_type, nullable closed, route,
locator, note_path, target, line, ref, nullable block_id,
requires_block_id, block_id_suggestions, actions, and default_action.
matched_ranges are half-open [start, end] UTF-8 byte pairs in title.
closed is null for an open task or { "status": "done" | "canceled", "date": "YYYY-MM-DD",
"days_ago": N }. kind is task, project, or reading. key is the
stable note-path-and-block-ID identity, or a digest for an ID-less task.
target and line can be passed to Obsidian's task Go To URL; ref is the
stale-safe task reference.
Rows with block IDs include Bob-authored replacements for relation_to,
parent, dependency, and blocked_task. ID-less rows omit replacements,
set requires_block_id: true, and include route, note_path, ref, and
suggested IDs; the existing capture-task-id flow supplies replacements
after the user chooses or enters an ID.
actions contains only edits that Bob verified by applying the replacement
to a copy of the draft and parsing it again. Open rows may offer relate,
note, needs, and blocks, plus open and discard when their anchors
allow those actions. Closed rows offer only open and discard. The default
is discard for an exact title, note for a possible duplicate, relate
for a similar or held row, and open for a non-exact closed duplicate.
Return remains the capture submit action; these are explicit draft edits and
never create a link by themselves.
bob capture-related -b ~/bob -f json -- 'Install new UPS battery @dev'
bob capture-related --query 'Install UPS battery' --format json
The first command emits draft item anchors and actions but no score. The
second uses the evaluation harness and adds a score to each match. Human
draft output groups matches by item and shows their status, highlighted title,
band, and locator. It omits empty bands entirely; there is no “no duplicates”
row. Unreadable candidate notes appear as bounded warnings while other
matches remain available. Usage errors exit nonzero and JSON errors use the
usual {"ok": false, "error": "…"} envelope.
bob capture-rewrite¶
bob capture-rewrite [-c|--cursor N] [-f|--format human|json] [--] [TEXT]...
Applies the capture grammar's automatic draft rewrites -- template start, the
block-ID separator toggle, the relation-label pipe attach, and the bare @@
absorption rule -- and reports the
resulting edits, cursor, and a human summary. It is purely lexical and
completely read-only: it never opens the vault, never reads the clipboard
or the URL routing config, never touches the filesystem, and takes no
--bob-dir. bob capture-parse reads that config unless --no-ref is
set; see above.
If TEXT is omitted and stdin is piped, it reads the complete piped stdin
stream. Missing TEXT, invalid options, or a cursor outside TEXT or
inside a UTF-8 character return exit 2. Other draft text succeeds, with
changed: false when nothing needed to change. Exact-name #"…" literals
are never rewritten: they keep their quotes and spelling byte-for-byte.
Template drafts are never rewritten: capture-rewrite returns
changed: false, with no rule or edits, so it cannot absorb @@ or toggle a
template marker.
Template start. Typing a third @ at byte 3 when the first physical line
is exactly @@@ replaces it with @@template: and a line ending. The existing
LF or CRLF is reused, and the cursor moves to a fresh blank line. This typing
gesture requires --cursor; without it, the rule is skipped.
bob capture-rewrite -c 3 -f json -- '@@@'
The result is @@template:\n with cursor 12 and summary
Started a @@template: draft. If the draft already has following lines, they
move down so the cursor still lands on a blank line. Feeding the result back
through the command is a no-op.
Block-ID separator toggle. Typing ^ immediately after a complete
plain @route:id, or : immediately after @route^id, consumes that
character and swaps the separator. --cursor is required: only the token
ending at that caret is examined, so the command never searches the draft
for a toggle. The new key must sit against the ID with no intervening
whitespace and must end the token (end of input or a following whitespace
boundary). Inserting it inside an ID does not toggle a prefix.
Do work @file:id^
becomes Do work @file^id, with the cursor placed just past the unchanged
ID. The same gesture the other way restores the colon spelling. Solo
markers, leading markers, a trailing marker before later body text, a
trailing marker on an authored child line, and one item in a blank-line
batch are eligible wherever the shared grammar already recognizes that
token as the item's destination. Route and ID spelling, surrounding
whitespace, CRLF, schedule/priority/dependency markers, and other items
stay byte-for-byte. Suffix families (#name, +, =...), @@, @!,
standalone ^route:id, ¬e:id, !note:id, project-task :id/^id,
incomplete or malformed markers, same-separator repeats, and
marker-looking prose the contextual parser does not select are left
unchanged. This is an editor assist: bob capture and bob capture-parse
keep the literal @file:id^ text.
Relation-label pipe attach. Typing | after whitespace that follows a
relation token deletes that whitespace so the pipe glues to the token.
--cursor is required: only the | token ending at that caret is examined,
so the command never searches the draft for a pipe. The run before the pipe
must be spaces or tabs (never a newline) with a non-whitespace byte on the
same physical line before it, and the glued draft must be a place where
bob's completion grammar would open a relation_label field, so prose
pipes, mid-line relations, already-labelled tokens, partial targets,
escapes, and newlines are left unchanged.
Fix trip ~work:foo |
becomes Fix trip ~work:foo|, with the cursor placed just past the glued
|. The same gesture works for ~~ targets, quoted notes such as
~"My Note":target, leading runs, and tokens before later body text. This
is an editor assist: bob capture and bob capture-parse keep the literal
| text. Feeding the result back through the command is a no-op,
because no whitespace is left before the |.
Typing a bare @@ inside an item that already carries a local destination
marker moves that marker onto the @@ and deletes it, so the item ends up
declaring its own existing destination instead of shadowing it:
Buy milk @dev @@
becomes Buy milk @@dev, with the cursor placed just past the rewritten
token. The absorbed marker can be on any of the item's lines, not only the
one the @@ was typed on:
Buy milk @dev
- more detail @@
becomes Buy milk\n- more detail @@dev. When the item has no local marker
of its own but the draft already carries exactly one other @@ declaration,
that declaration's payload moves onto the bare token instead:
@@foo
Buy milk @@
becomes Buy milk @@foo, and the now-empty declaration-only line is
deleted, terminator included. Either way, every other @@ declaration
token still left in the draft is also deleted, so a rewritten draft never
ends up with more than one declaration. Deleting a token also consumes one
adjacent whitespace run so no double space is left behind, and deleting a
line's only token deletes the whole physical line.
Which bare @@ claims the rewrite: the one containing or ending at
--cursor, when a cursor is given; otherwise the last bare @@ in source
order. A rewrite is idempotent: running it again on its own output is a
no-op, because the claiming token is no longer bare.
An item's single local marker that cannot be expressed as a declaration --
@route#Section, @route+block-id#section, @route^block-id,
@route:block-id, @route^block-id+ as a project note,
@route+block-id as a task toggle, a solo @route:block-id… / ^route:block-id…
Pomodoro link, a x[<N>][!<M>] Pomodoro close (pomodoro_close is non-absorbable and
close items are never rewritten), or a trailing bare #
-- is left untouched; the result reports changed: false plus a
notices entry naming the marker and why, e.g.
@@ cannot take a section: leave @notes#Ideas on this item, or delete it and declare @@notes.
For a task toggle the notice is
@@ cannot take a task toggle: leave @cash+goog-exit on this item, or delete it,
for a project note it is
@@ cannot take a project note: leave @cash^goog-exit+ on this item, or delete it,
and for a Pomodoro link it is
@@ cannot take a Pomodoro link: leave ^sase:deep-fix on this item, or delete it.
^ items are never rewritten. An item with more than one local marker is also left untouched, with no
notice, because bob capture-parse already reports that duplicate as a
duplicate_capture_marker diagnostic.
An item with more than one local marker is also left untouched, with no
notice, because bob capture-parse already reports that duplicate as a
duplicate_capture_marker diagnostic.
Absorption is an editor typing assist, not a grammar rule: bob capture
still executes exactly the text it is given, so a bare @@ there is still
an incomplete declaration and still fails. Run bob capture-rewrite first
and feed its text back through bob capture (or into the draft the user
is editing) to apply the assist.
JSON output is a single versioned object:
{
"ok": true,
"schema_version": 1,
"input": "Buy milk @dev @@",
"text": "Buy milk @@dev",
"changed": true,
"cursor": 14,
"rule": "absorb_local_marker",
"edits": [
{ "range": { "start": 9, "end": 14 }, "replacement": "" },
{ "range": { "start": 14, "end": 16 }, "replacement": "@@dev" }
],
"summary": "Moved @dev into @@dev",
"notices": []
}
rule is start_template, switch_block_id_separator,
attach_relation_label, absorb_local_marker, or absorb_declaration,
omitted when nothing changed. cursor is present only when --cursor was
supplied and is mapped through every edit. For start_template, it lands at
the start of the fresh line; separator toggles land just past the unchanged
ID, pipe attaches land just past the glued |, and absorption lands just
past the rewritten @@<payload> token.
edits index input, are sorted by start, never overlap, and applying
them left-to-right yields text; text always equals input when
changed is false. summary is omitted when nothing changed; notices
is omitted when empty. Human output prints a header naming the rule, the
before/after draft, the summary, and any notices; when nothing changed it
prints a dim no rewrite line plus the notices.
bob capture-complete¶
bob capture-complete --cursor BYTE [-a|--all-tasks] [-b|--bob-dir DIR] [-f|--format human|json] [--] [TEXT]...
Returns cursor-aware completion candidates for in-progress capture TEXT. It
shares the phase-grammar tokenizer and @token classification with
bob capture-parse, so a completion can never disagree with the marker
highlighting derived from that command; it never independently reparses marker
prefixes. Shell completion is another thin client of this service: TEXT on
capture, capture-parse, and capture-rewrite completes markers through
the same extraction (see docs/completion.md Capture markers). --cursor/-c is required and must be a UTF-8 byte offset on a
character boundary within TEXT. It is not the same flag as bob capture -c /
--clip. A missing TEXT defaults to an empty draft rather than an error,
since cursor 0 against an empty draft is an ordinary interactive state, not a
mistake.
For a blank-line-separated batch draft, completion always scopes to the item
and physical line the cursor is on: only that item's first (parent) line
offers a leading marker, matching bob capture-parse's leading-wins
precedence, and a later valid column-zero or two-space nested authored line
only completes its own trailing marker. A cursor sitting on a separator row,
a later line's source indentation, -/*/+ bullet marker, or marker
separator is never completable. Orphaned nested lines do not provide
completion.
The service itself decides whether completion applies. An unrecognized
marker, a cursor sitting in plain body text, or a cursor on an @token that
is not the leading or trailing marker on its line all return a successful
empty result rather than an error. A lone leading @route fragment with no
body text yet is still completed on the parent line, even though
bob capture would leave that exact input literal.
Route completion covers a bare @, a still-typing @fragment, and the
missing route portion of @^..., @+..., @:..., and @#..., plus the route
component of a @@, @@fragment, or @@route+... declaration anywhere in the
draft. Replacement ranges for that declaration exclude both @ sigils and the
+. Parent-task
completion covers @@route+fragment the same way it covers @route+fragment,
including --all-tasks missing-ID candidates. An inherited global route also
becomes the current note for same-note wikilink heading/block completion unless
that item overrides it. Route completion is backed by the same
scan as bob capture-targets. For a template draft, completion runs against
the masked template: the header, rows, and placeholders return empty success,
while route, section, Pomodoro-name, dependency, and wikilink pickers in the
template body remain available. Block-ID contexts and replacements that
overlap a placeholder return empty success. On @@, @@te, and other
first-nonblank-line prefixes of the reserved header, route completion offers a
template: candidate (kind: "template", label
Bulk template: one capture per row below), first for a matching query and
last for an empty query. It is not offered on later lines, even when a later
line repeats the same prefix text. Section completion
covers @route#prefix,
backed by the same scan as bob capture-sections. Task-section completion
covers @route+id#prefix and a bare @route+id# once the item has body text,
backed by the same scanner as bob capture-task-sections; the candidate
replacement is the section slug (future-work for FUTURE WORK).
Pomodoro-name completion covers @route:id#prefix, a bare @route:id#,
@route:#prefix, @route:id=name (e.g. @work:outline=bu|), and @route+id#prefix or a bare @route+id# while the item
has no body text; only the route
must already resolve because the Pomodoro list does not depend on the block
ID. On a @<route>:<block-id>[#<name>]=<X> marker the =<X> suffix is never
a completion field when it is timing/close: block and name replacement ranges end before the =, a
cursor inside a timing suffix returns an empty success, and accepting a candidate
preserves the typed suffix. A cursor inside a suffix name (=name) is the pomodoro_name field described above. The =x[<N>][!<M>][~<K>] close suffix behaves the same way: it
is never a completion field, replacements still stop before #/=, and a
cursor anywhere inside the suffix, including the task-number lists and a
dangling ,/!/~ separator, returns an empty success. A whole-item +[N]/-[N]
Pomodoro adjustment, ++[N]/--[N] Pomodoro shift (a bare +, -, ++,
or -- is one unit), x[<N>][!<M>][~<K>] close, or =/=<X> start (a bare = starts
25 minutes) is an action and requests no route or
task completion candidates: a cursor on such an item returns an empty success. Work Log text completes like a bullet line whether it sits below the close or on it. A
whole-item =name named start instead completes the name as
pomodoro_start_name, per token inside chains: =bu|, x =bu|, and x wired it =bu| report pomodoro_start_name with query bu and replacement over the name only, just like =#bu; a cursor inside the name part
completes it, while a cursor on =<X> or at the # byte itself returns an
empty success. The name is located through the lexer's recorded offset and marked bare-spelled (e.g. a bare_name bool); any candidate whose slug fails can_spell_bare gets the replacement #<slug>, so accepting it yields a valid =#2fa (substring matches matter). A cursor anywhere inside a drop part, including a dangling ~/,, returns an empty success. Near misses complete the same way valid tokens do. The name replacement covers only the name part (it ends before ~), so accepting
a candidate preserves the typed =<X># and any typed ~<K> list. Candidates
follow PT7 menu composition:
matching Today rows first, Recent exact/prefix matches, a create row when no
Today or Recent name matches exactly, Recent substring matches, then utility
rows. Today rows keep their existing order: planned placeholders (with
next_up on today's next future entry), then again rows for completed
sessions, most recent first. Utility rows are the unnamed “Name it” entries
(requires_name: true) followed by the running session. They are never
filtered out, so =#zzz still offers creation before those utilities.
Recent rows exclude hidden themes and components of any Today row before
query filtering. Empty queries show at most 5 Recent rows; other queries show
at most 12. New, Recent, and again rows carry plan_themes_after and
plan_themes_cap. Recent and again replacements preserve the displayed name:
a slug that loses spaces or selects another name is replaced with an exact
quoted literal, including # when the input uses a bare start. A named-start name field keeps context pomodoro_start_name and its
candidates when exactly one timed session runs. The object is reported as soon
as the name field exists, including an empty =# or =3#, so a picker can
describe the swap before a name is typed. The additive top-level
override object then previews that state: it is present only when no earlier
session operator in the draft changes the ledger, keeps_ledger is true when
the typed <X> suffix is empty and false for fresh timing, and running
carries the current session (pomodoro_name, omitted when unnamed, the
1-based day-file line, and its time_range, such as 0920-0945). An
unnamed running session with Task Links also carries the derived R9 homes
object (forecast); a named one omits it. The whole override object is
omitted unless exactly one timed session runs. This context is state-based
and applies to ordinary =name as well as counted =3#name fields; a
preceding close in x =name means there is no running session to preview. A cursor inside a link suffix name (@work:outline=bu|, ^work:outline=bu|) yields pomodoro_name with the route and block ID, a query from the name start to the cursor, and a replacement over the name only; the same #<slug> fallback applies, so @work:outline=#2fa is valid. A partial literal (=#"DEEP|, @r:id#"SASE V|) is the same Pomodoro-name field with the query decoded (DEEP, SASE V) and the replacement covering the whole partial literal including its opening quote, so accepting a candidate keeps one shell-quotable token under the existing single-quote rule.
For example, with CAPTURE running at line 5:
"context": "pomodoro_start_name",
"override": {"keeps_ledger": true,
"running": {"pomodoro_name": "CAPTURE", "line": 5,
"time_range": "0920-0945"}}
The pomodoro_name context uses the same groups, with open named ledger
entries as Today rows and unnamed entries as utilities. Matching Today rows
keep their prefix-then-substring order. Entries with the same normalized name key collapse
to the first row with match_count reporting the number of matching entries.
Nameable rows use an empty replacement; clients prompt for a name rather
than inserting it. A theme completed today is Recent in this link context
and an “again” row in the start context.
Accepting a creation or Recent row edits the draft only; the later
bob capture transaction performs the write with its normal placement,
missing-section, multiple-running, and strict-budget checks. A new create row
requires a valid name and a ledger that can place it. Recent rows carry
origin: "history", last_used, and days_used, with no ref or line.
An exact Recent match serves as the create row. A hidden exact name remains
typable and is marked hidden: true on the create row. A new create row can
also carry up to two similar_to names as a quiet near-duplicate heads-up;
this never changes order or Return. Completion and acceptance never write
theme preferences. Shell descriptions include recent · Oct 21 · 3 days.
Pomodoro block-ID
completion covers @route:prefix and parent-task completion covers
@route+prefix; both are backed by the same open-task scan as
bob capture-tasks. By default both contexts only offer tasks that already
carry a block ID so older callers stay compatible. Pass -a/--all-tasks to
include open tasks that still need an ID, but only in the task / @route+
context. Pomodoro @route: completion stays identified-only even when
--all-tasks is set. Missing-ID discovery is therefore opt-in and
plus-context-only. For link intent, @route: candidates are further
restricted to statuses the link path accepts (Ready, Blocked, Next, In
Progress), carry an additive 1-based line and a nullable pomodoro object
with the exact active_task shape, and rank exactly as before (document order
on an empty query). For new and project_note intents, candidates is
[]: every existing ID would be a duplicate-ID error.
The right-hand side of @route^block-id completes as task_block_id once
the route resolves, with empty candidates and the additive block_id
object below. A project-note + directly after either block-ID part is the
sigil, never part of the replacement: @sase^x+ at cursor 7 returns
task_block_id with replacement {6, 7}; @sase:x+ returns no context, a cursor after the sigil (and before # for ^) returns an empty
success, and a + after #name stays part of the Pomodoro name. A solo
leading ^ token instead completes active tasks: while the
cursor is in the route:block-id part (including an empty part) the context
is active_task, offering In Progress and Next tasks with block IDs,
backed by the active-task discovery scan — queued tasks in ledger order,
then unqueued In Progress, then unqueued Next — with prefix matches before
substring matches over route:block-id, the block ID, the task text, the
section, and the Pomodoro name, so ^dee, ^sase:dee, and ^outline all
find their tasks. replacement runs from just after ^ to the end of that
part and always stops before #/=, so typed suffixes survive an accept,
and query is the text from after ^ to the cursor. A #name after
^route:block-id completes Pomodoro names exactly as it does after
@route:block-id, and a cursor inside =<X> or =x[<N>][!<M>][~<K>] returns an empty success. A solo
leading : token completes task_link: while the cursor is anywhere in
[token.start, token.end] — including just before the sigil, so clients can
refetch the full list at replacement.start — the context offers every
linkable open task (Ready, Blocked, Next, In Progress) in the routable inbox,
area, and non-terminal project notes, in canonical picker order and ranked by
the query, with ID-less tasks always included (--all-tasks does not affect
this context). The replacement covers the whole : token, sigil included,
because accepting rewrites the query into the canonical @route:block-id
link, and query is the token text between the sigil and the cursor (empty
at or just after the sigil). For example bob capture-complete -c 1 -- ':'
lists the full routable task catalog, and -c 4 -- ':dee' narrows to matching
rows with replacement {0, 4}.
A whole-item ! token completes task_complete: while the cursor is anywhere in
[token.start, token.end] — including just before the sigil, so clients can
refetch the full list at replacement.start — the context offers every open
task (Ready, Blocked, Next, In Progress) in the vault-wide completable catalog.
The replacement covers the whole ! token, sigil included, because accepting
rewrites the query into the canonical !note:block-id completion, and query
is the sigil-decoded text between the sigil and the cursor (empty at or just
before the sigil). The response carries a vault-scoped picker descriptor
(kind: task_complete, scope_token: "!", both ranges the token); a bare
whole-item ! additionally returns the ! and [ action_continuation_keys
so an editor can hand !! and ![[ straight back to prose and embeds.
Candidates carry the !loc:id replacement (empty for ID-less and guarded
rows, which resolve through capture-task-id's complete_replacement
instead), the exact note_path, the display locator, the today /
in_progress / next / open group, and — for today rows only — the today
placement: role (running, worked, queued, or noted), the entry
(line, name, time_range, status; omitted for noted), and the
distinct-session count. Any unstruck block link — plain or embedded, alias
allowed, 🍅 markers stripped — places its task when it sits outside fenced
code and Depends-On lines and resolves to an open task: links under the
running entry read running, under a completed entry worked, under any
other open entry queued, and outside ## Pomodoros noted, keeping the
strongest role. Recurring tasks and tasks another ! item in the draft
already names stay visible with recurring / already_selected and a
disabled_reason but no insertable replacement. Empty queries list today rows
first (running in ledger order, then worked most-recent-first, then queued in
ledger order, then noted in document order), then In Progress and Next by note
path and line, then all other open tasks; hidden rows sink within their
section and recurring rows sink last. A nonempty query keeps today matches on
top by score, then all other matches by score, using the shared tiered matcher
over the task text, locator, locator:block-id, block ID, note path, section,
and today Pomodoro name. For example bob capture-complete -c 1 -- '!'
lists the full completable catalog, and -c 4 -- '!fix' narrows to matching
rows with replacement {0, 4}.
A * modifier completes blocked_task with the same query, owner, and
replacement-range semantics as task_dependency: the replacement covers
exactly the active modifier (sigil and quoted note included), including
just before the sigil so clients can refetch at replacement.start.
X *, X *jo, and X *job: all open this context. The pool is open,
editable tasks only (daily notes, done/, _templates, _generated,
_conflicts, dot-directories, and fenced code are absent). Rows reuse
the DependencyCandidate shape plus already_blocked (omitted when
false) and disabled_reason (the owner task itself, would create a
dependency cycle, a project cannot depend on its own resident, or the
existing duplicate-block-ID reason). With an empty query, ranking is
today's linked tasks (the running entry's first), then Pending [/],
Next [*], the owner's note, then everything else; a non-empty query
ranks by match score first, then that order. Replacements use *
(replacement_for_sigil). ID-less and guarded rows resolve through
capture-task-id's blocked_task_replacement. Shell completion adds a
blocked tasks group. See
Blocking existing tasks with '*'.
For task_dependency, blocked_task, relation_to, relation_from,
and relation_label, owner remains the lexical
dependency_target; when it resolves to an existing task, it also carries
the additive text, note_path, status_symbol, and status_name display
fields. A new_task owner carries its non-empty draft text. These additions
are omitted when absent, and responses for drafts without relationship
modifiers remain byte-identical.
~ / ~~ add the relation_to and relation_from contexts and the
relation_label context. relation_to uses the whole discover
catalog; relation_from offers open editable tasks, with read-only
sources kept visible and disabled. Empty-query order for relation_to
is the owner's note, In Progress, Next, other open tasks, closed tasks
in active notes, then done/. For relation_from, today's linked tasks
(the running entry first) precede In Progress, Next, the owner's note,
and other editable tasks. A query ranks by match score first, then these
tiers. Rows carry already_linked, existing_label, and the information
badge also_prerequisite plus disabled_reason (the owner task
itself, the duplicate-block-ID reason, archived tasks can't get an
id, and — for relation_from only — read-only note and its LINKS
line is malformed). Label suggestions scan every LINKS line, including
done/ and lines beneath ID-less tasks, and return
suggestions[] {label, count, replacement}. A non-empty typed label adds
a first {label, typed: true, replacement} row with bob's canonical
|label / |"multi word" spelling; invalid labels carry a
disabled_reason naming the broken label rule. relation_label responses
also carry an additive relation object: direction is to for ~ and
from for ~~ (spelled like capture-parse relations[].direction), and
range runs from the sigil's first byte to the | separator, so
range.end always equals replacement.start -- the quoted-note form is
covered too. The lexical owner covers relation_label as well, with the
same resolved display fields as the relation target contexts. No other
context carries relation. picker.relationship
gains relations[] (direction, text) for the owner picker, and shell
completion gains links to / linked from groups. capture-task-id
returns relation_to_replacement and relation_from_replacement for
the Add block ID flow. See
Linking tasks with '~' and '~~'.
The analogous parent-task picker accepts a terminal +query on an item's
parent line or an eligible authored child line. It uses context task_parent,
strips + from the top-level query, and replaces the full selector token.
It lists linkable open tasks in that same routable catalog, including ID-less
tasks whose empty replacement must not be inserted; those rows include block-ID
suggestions for the Add block ID action. A lone + keeps its Pomodoro
adjustment meaning in parsing but completion returns this picker with
action_continuation_keys (0–9 and +). Scoped @route+id completion
continues to use context task and replace only the ID. When the current item
has complete & or * modifiers, the parent picker descriptor adds
relationship.dependencies[] and relationship.blocked_tasks[], each with
the typed raw token, note_path, block_id, and resolved text when
available. This metadata appears for both the vault-wide + picker and the
scoped @route+ picker. Candidate disabled_reason values identify the
prerequisite itself, blocked task itself, a dependency cycle, or a project
depending on its own resident; guarded rows have no insertable replacement
and shell completion skips them.
@route: (pomodoro_block_id) completion now carries the block_id object
and link-only filtering above; an empty-query marker-only @route: still
lists that note's linkable tasks in document order. An empty block-ID component (@route+#) returns a successful empty
task-section list. An unresolvable parent task returns a successful empty list
plus one bounded warnings entry; the warning names the route and block ID
without logging draft text or the task description.
Route, section, and wikilink candidates rank exact prefix matches before
substring matches, case-insensitively, while keeping each discovery source's
stable order. Task-section candidates rank slug-prefix matches first, then
slug-substring matches, in document order inside each tier. Task candidates in @route+ search block ID (when present),
task text, section, and status name or symbol the same way, but identified
tasks always stay ahead of unidentified tasks and prefix matches precede
substring matches inside each of those two groups. A non-matching candidate
is dropped, and an empty query keeps every eligible candidate.
When the cursor is inside a valid Obsidian wikilink component, link completion
takes precedence over marker completion so @ and % inside link text remain
ordinary link text. wikilink_note searches Markdown note paths, stems, and
frontmatter aliases. wikilink_heading searches ATX headings in a resolved
target, in the current capture destination for [[#...]], or across the vault
for [[##...]]. wikilink_block searches named block IDs in the analogous
target, current-destination, or [[^^...]] vault-wide scope, except on a
Work Log bullet line under a whole-item x, where block candidates are
suppressed (entries reject block links) while note and heading completion
keeps working. The note index is
read-only, skips hidden directories plus .git, .obsidian, _generated, and
_templates, never follows directory symlinks, and returns bounded warnings for
individual unreadable notes or malformed alias frontmatter while keeping path
completion available.
JSON output is a single versioned object:
{
"ok": true,
"schema_version": 1,
"cursor": 3,
"replacement": { "start": 1, "end": 3 },
"context": "route",
"candidates": [
{ "replacement": "cash", "route": "cash", "label": "cash.md", "kind": "area", "status": null }
]
}
replacement is the half-open UTF-8 byte range a chosen candidate replaces in
full, regardless of where the cursor sits inside it; it is always present, even
in an empty result, where it collapses to a zero-length range at the cursor.
context is route, section, pomodoro_block_id, task_block_id, project_task_block_id, pomodoro_name, task,
task_section, active_task, task_link, task_goto, task_parent, task_dependency, blocked_task, task_complete, wikilink_note, wikilink_heading, wikilink_block, or null when no completion field is
active. task_parent covers a terminal +query, strips the sigil from its
additive top-level query, and replaces the whole selector. task covers
scoped @route+id with only the ID span replaced and the typed ID query.
query is present only for task_parent, task, task_dependency, blocked_task, task_complete, relation_to, relation_from, and relation_label; it
contains the selector text after its sigil (and a decoded locator for a
dependency, blocked-task, or relation query). blocked_task is the * twin of
task_dependency: same owner and replacement-range rules, * replacements,
and the open-editable pool documented under
Blocking existing tasks with '*'. Both parent-task contexts include an
additive picker descriptor: kind is parent_task; scope is vault
with scope_token: "+" for the bare selector, or note with the canonical
@route+ / @@route+ scope token for scoped selection. note_target is
present only for note scope. marker_range covers the selector (or the
scoped marker through its ID), and trigger_removal_range covers the full
bare selector or only the + through the scoped ID. All offsets are
half-open UTF-8 byte ranges. Only the exact lone + includes
action_continuation_keys (0 through 9, then +). The task_complete
context carries its own vault-scoped picker descriptor (kind:
task_complete, scope_token: "!") instead; the descriptor is omitted from
all other contexts. task_block_id covers @route^prefix once the route resolves;
candidates is always []. project_task_block_id covers a cursor inside a
trailing : / ^ task ID token on a first-level bullet of a project-note
item with a resolved route and block ID, from just after the sigil to the
token end; the replacement is the ID span, empty at the cursor for a lone
sigil. All three block-ID contexts carry an additive
top-level block_id object with route, relative_target (for example
sase.md), note_exists (false for a missing note, which is not an error),
marker (: or ^), marker_range (the whole marker token's draft-global
UTF-8 byte range, for highlighting), intent (link, new, or
project_note), body (the item's normalized parent-line body, or ""),
allowed_character (a one-character regex: [A-Za-z0-9-] for both : and
^), allowed_description (the human wording from the
matching validator), suggestions (at most 3, deterministic, for new and
project_note intents with a non-empty body), and used (every ID the
duplicate check sees, deduplicated by first occurrence in document order with
1-based line; task lines carry task: true plus status_symbol,
status_name, and the description as text, while other lines carry
task: false, null status fields, and the line with its list marker and
trailing ^id removed, trimmed to 160 characters; [] when the note is
missing and for project_note intent). Intent is project_note when +
follows the ID part; ^ is otherwise always new; : is link exactly when
the whole-item parse would classify the item as pomodoro_link once a valid
ID fills the part (marker-only items), else new (items with text, s:/$<N>
/% markers, child-line and batch positions behave the same way). The
project_task_block_id object always carries intent new with empty
candidates: route is the project-note stem (for example cash_goog_exit
with relative_target cash_goog_exit.md), marker is the typed sigil
(: or ^), marker_range is the sigil plus the ID, body is the bullet
body without the ID or a leading checkbox, and used holds prj (with that
bullet's project body as text) plus every other task ID already typed in
the item. A used entry carries line (1-based physical draft line),
task: true, null status_symbol / status_name, and text. The object
reads no routed note except to set note_exists for the project note.
Suggestions derive from the task text: the first phrase with a word (code
spans, quoted phrases with mixed "/“ quotes, parentheticals, and
[[wikilink]] alias-or-target, in text order) as its first 4 words, then the
first 3 prose words, then — when the first prose word is a leading verb — the
next 3 words; words are ASCII alphanumeric runs, lowercased, minus stopwords;
joined with -, truncated at a word boundary to at most 32 bytes, validated,
deduplicated, and suffixed with the first free -2…-9 when taken (dropped
when none is free). For example Fix flaky gkeep test suggests
fix-flaky-gkeep and flaky-gkeep-test. Older clients without the
block_id object treat pomodoro_block_id as a link list and see no picker
for task_block_id; the app never falls back to the inline list for these
three contexts. Older clients route the unknown project_task_block_id
context to their inline list, which has no candidates, so they degrade
silently. Each candidate's replacement is the exact text to insert; wikilink
candidates also include cursor_after, the post-accept UTF-8 byte offset after
deduplicating or synthesizing the closing ]]. A route candidate has route,
label, kind (inbox, area, or project), and nullable status. A
section candidate has title and level. A task-section candidate has
title (the original ALL-CAPS body), slug, route, nullable block_id,
text (the parent task description), line, and child_count; replacement
is the slug. A Pomodoro-name candidate has nullable ref, nullable name,
requires_name, optional creates_pomodoro (absent or false on existing
rows), nullable line, state, status_symbol, nullable time_range,
placeholder, is_current, child_count, and match_count; selectable named
rows use a slug or lossless quoted literal as replacement, while nameable rows use an empty
replacement that clients must not insert. A create row omits ref and line,
sets creates_pomodoro: true, and uses the canonical selector as
replacement. Every named row adds themes (the component ThemeRefs).
Recent rows also add origin: "history", last_used, and days_used.
hidden: true and similar_to are optional create-row metadata. These
additions keep schema_version: 1; older clients can treat Recent rows as
ordinary create replacements. A task candidate
(pomodoro_block_id or task context) has ref, nullable block_id,
route, requires_block_id, status_symbol, status_name, status_type,
text, nullable section, depth, child_count, additive 1-based line,
and nullable pomodoro (line, nullable name, nullable time_range,
is_current; null when the task is not queued or outside the link
context). Identified tasks keep
their normal block-ID replacement. An active-task candidate (active_task
context) has replacement (route:block-id), ref, route, block_id,
status_symbol, status_name, status_type, text, nullable section, and
nullable pomodoro (line, nullable name, nullable time_range,
is_current; null when the task is not queued). Human rows read
sase:deep-fix [*] Fix deep bug · BUGS. A task-link candidate (task_link
context) has replacement (@route:block-id, or "" for ID-less tasks,
which clients must never insert), ref, route, note_kind (inbox,
area, or project), nullable block_id, requires_block_id,
block_id_suggestions (up to 3, [] for identified tasks),
status_symbol, status_name, status_type, text, nullable section,
depth, 1-based line, group (queued, in_progress, next, or
note), nullable scheduled, and nullable pomodoro with the exact
active_task shape (null unless the task is queued), plus
pulls_forward: true when set (omitted when false). Human rows read
@sase:deep-fix [*] Fix deep bug · BUGS, with the tail naming the queued
Pomodoro (Planned when unnamed), In Progress, Next, or the note
label, plus · needs ID (^suggestion) on ID-less rows and
· scheduled YYYY-MM-DD whenever the task carries a scheduled date. Missing-ID tasks, which appear only when
--all-tasks is set in the task context, have block_id: null,
requires_block_id: true, block_id_suggestions (up to 3 deterministic IDs),
and an empty placeholder replacement that the updated client must never
insert. A task_parent candidate has the same task metadata plus note_kind,
group, and nullable pomodoro, but no scheduled or pulls_forward fields;
identified rows use @route+block-id replacements, while ID-less rows have
an empty replacement, requires_block_id: true, and suggested IDs. Human
rows begin with @route+block-id or @route+… and describe missing IDs.
Every task-bearing candidate (task, active_task, task_link,
task_goto, task_parent, task_dependency, blocked_task, task_complete) also carries an additive
task_kind ("ref" when the row is a #task #ref reference reading task,
omitted otherwise, so existing JSON stays byte-identical). bob query
text also drops that #ref token, so #task #ref X reads as X. A
wikilink note candidate has path, name, optional alias,
and match_kind; heading and block candidates add heading/level or
block_id/optional preview metadata. Link-index warnings, when present, are
reported in a bounded top-level warnings array without logging draft text. A
missing note behind a resolved route or link target is not an error; it returns
an empty candidate list. Discovery failures never fall back to a default route
or an empty result silently; they return the same actionable error in human and
JSON forms as the underlying scan would.
Discovery commands¶
bob capture-pomodoros [-a|--all] [-b|--bob-dir DIR] [-f|--format human|json] [-t|--tasks]
bob capture-sections --route NAME [-b|--bob-dir DIR] [-f|--format human|json]
bob capture-targets [-b|--bob-dir DIR] [-f|--format human|json] [-v|--verbose]
bob capture-task-sections --route NAME (--block-id ID | --task-ref REF) [-b|--bob-dir DIR] [-f|--format human|json]
bob capture-tasks --route NAME [-b|--bob-dir DIR] [-f|--format human|json]
These read-only discovery commands support interactive capture pickers. A
route is the canonical lowercase name for a <route>.md note at the vault
root; for example, route cash selects cash.md. The command-line route
options accept ASCII uppercase too and normalize it to lowercase. A picker
normally uses the commands in this order. Pomodoro notes (text #) skip this
list and call bob capture directly:
- Run
capture-targetsand let the user choose a route. - For a bullet capture (
@route#), runcapture-sectionsfor that route and let the user choose a heading. Task, sub-bullet, and Pomodoro-note captures skip this step. - For a sub-bullet capture (
@route+), runcapture-tasksfor the route and let the user choose an open task. Other capture modes skip this step. - Optionally run
capture-task-sectionsfor that parent (--block-idor--task-ref) and let the user choose a section title, then pass--task-section TITLEto nest under that ALL-CAPS child section. - Run
bob capture --route NAME --section TITLE -- <text>for a bullet, omit--sectionfor a task, or runbob capture --route NAME --task-ref REF [--task-section TITLE] -- <text>for a sub-bullet.
On a successful scan, capture-targets returns mac_inbox first even when
mac_inbox.md does not exist, followed by top-level area notes and
non-terminal project notes, with each group sorted by route. Eligible note
filenames must already be lowercase and may contain only ASCII letters,
digits, _, and -. Area and project classification comes from YAML
frontmatter type: "[[area]]" or type: "[[project]]"; the equivalent bare
values are also accepted. Nested notes, projects whose status is done,
canceled, or cancelled (case-insensitively), and other note types are
omitted. Human output groups routes by kind. JSON output has ok, bob_dir,
count, and an ordered targets array; each target has route, name,
label, kind, is_default, status, relative_path, and
project_name_aliases (the note's external names, [] when it has none;
human output shows them as aka bob-cli). --verbose
reports top-level Markdown files omitted because their filename is not a valid
route, plus project_name_aliases problems (malformed entries, duplicate
claims, and shadowed aliases); other omissions remain silent. See
docs/projects.md §"External names (project_name_aliases)".
capture-sections lists each parsed ATX heading (H1-H6) except a heading
titled exactly Tasks and the generated Next & In Progress, Blocked, and
Done & Canceled status-group headings below a Tasks heading, in document
order. It ignores headings in YAML
frontmatter and fenced code blocks. Route input is normalized to lowercase,
and a missing note successfully returns an empty list. JSON output has ok,
the normalized route, count, and an ordered sections array whose entries
each have title and level.
capture-tasks lists open Obsidian Tasks entries in document order, including
indented sub-tasks. Done and canceled tasks are omitted; Todo, In Progress, and
On Hold statuses are included, and an unknown status symbol is treated as an
open Todo. Route input is normalized to lowercase, and a missing note
successfully returns an empty list. Human output groups tasks beneath their
nearest ATX heading and shows status, block ID, and status name without emitting
color escapes when piped. JSON output has ok, route, relative_target,
count, and an ordered tasks array. Each task has ref (<line>:<digest>),
line, nullable block_id, status_symbol, status_name, status_type,
text, nullable section, indentation depth, and child_count. The ref is
the picker-safe value accepted by bob capture --task-ref and
bob capture-task-id --task-ref and can recover when unrelated edits shift
the task's line.
capture-pomodoros lists Pomodoro ledger entries from today's daily note. The
daily note is selected exactly as bob capture selects it: BOB_DAY_FILE when
set and nonempty, otherwise <bob-dir>/YYYY/YYYYMMDD.md from BOB_NOW or the
local date. Open entries are listed by default; --all includes completed
entries. A missing daily note or a missing ## Pomodoros section returns a
successful empty list with one warning naming the file, so picker callers can
degrade to "nothing to choose" without showing an error dialog. When the day
has more than one open timed Pomodoro, no entry is marked current and the
result includes a warning.
Human output shows one row per entry with the name, selector slug, time range
or planned, and current, completed, child-link-count, or empty badges,
without emitting color escapes when piped. JSON success is a single versioned
object with ok, schema_version 1, day_file, relative_day_file,
count, warnings, and an ordered pomodoros array. Each Pomodoro has ref
(<line>:<digest>), line, state (open or completed), status_symbol,
nullable name, slug, selectable, nullable time_range, placeholder,
is_current, child_count, and task_link_count. task_link_count is the size of the numbered Task Link lineup that a plain x close indexes, the same count as pomodoro_close.task_links, present as an integer only on the is_current entry and null elsewhere. Editors use it to know whether close task numbers can be multi-digit. The ref is stale-safe: a later write command
can resolve exact line plus digest first, then a unique shifted digest match.
Named Pomodoros use the same slug rules as task sections — ASCII-lowercase,
whitespace collapsed to -, whole-slug matching before the first slug-prefix
match — with + also allowed in the selector. Entries whose names produce
untypeable slugs stay in the list with selectable: false.
With -t/--tasks, capture-pomodoros resolves each listed entry's Task Links
into agenda views. The top level adds date (YYYY-MM-DD, from the day file's
name or today) and completed_summary (count of completed ledger entries and
their summed minutes: each entry's recorded [t:: Nm] or stopwatch duration
when present, else its time-range span). Each entry adds a derived homes
object (the shared R9 shape from Pomodoro homes: state,
kind, label, nullable chip and hue, and parts). Open entries are a
forecast; completed entries are an attribution with minutes on each part.
Human output appends a dim chip with its forecast ○ or attributed ● dot
after the entry name when chip is set. The schema stays version 1. Calls
without --tasks omit homes. Each entry also adds role (current, next,
later, open, or completed), naive starts_at/ends_at datetimes
("YYYY-MM-DDTHH:MM" for timed entries, null otherwise; both keys are always
present with --tasks, and an end before the start rolls to the next day),
retired_link_count
(struck single-link lines), notes (session notes: sub-bullet lines that are
neither items nor retired links nor descendants of an item), and items (every
line the x recognizer accepts: any depth, plain, deferred [[T]]#, or
embedded, unstruck, outside fences). Each item carries its operator index, the
1-based ledger_line and entry-relative ledger_depth, the marker
(plain, deferred, or embedded), the verbatim block_link, the
resolution (resolved, missing_note, ambiguous_note, missing_block,
duplicate_block, not_a_task, or unreadable), the vault-relative
relative_target, the 1-based task line, the block_id, the clean title
text, status_symbol, status_name, status_type (the same uppercase labels
capture-tasks uses), the first 150 task block lines (lines_truncated
counts the rest), the link line's ledger_notes, and a human
warning for any non-resolved resolution. A display line has text (task
lines use the clean description, bullets the body after the list marker, other
lines the line without indentation), relative depth, kind (bullet,
task, text, code, or log_marker), status_symbol on task lines, and a
work/schedule/rotten log tag on managed log markers and their subtrees.
Numbering follows the session operators exactly: on the current entry index
is the x number, and on every other open entry it is the = lineup number
matched by ledger line (null where that rule numbers nothing, such as deferred
or nested links on a non-current entry). Later entries are numbered too, because
=#NAME~K drops by those same lineup numbers; ==#NAME~K is retired. --all --tasks
is allowed; completed entries report role: "completed" with empty notes
and items. Resolution never fails the command: each distinct note is read and
scanned once per filter mode (embedded links resolve with the global filter
cleared), empty link targets resolve to the daily note, and one bad link
becomes that item's resolution and warning while a missing daily note or
missing ## Pomodoros section keeps the successful empty list with a warning.
When the daily note has a ## Pomodoros section, the top level also adds
plan_budget with status, themes, and links. Each meter contains the saved
open-ledger count, configured cap, and over flag (count > cap). It uses
the same plan: limits loaded by capture (defaults are 3 themes and 10 links),
and the counting rules in Plan budgets; it does not include capture
growth, destination, or warning details. Empty and completed-only ledgers include
real zero counts. A missing daily note or section omits the budget. If the plan
configuration is invalid or unreadable, the agenda remains available, omits
plan_budget, and adds one bounded plan budget unavailable: ... warning. A
missing config file uses the loader's defaults. Calls without --tasks neither
load plan configuration nor include the budget. The budget depends on both the
daily-note contents and plan configuration; the rest of the agenda is derived
from its existing note inputs.
capture-task-sections lists the ALL-CAPS direct-child section bullets of one
parent task in document order. Exactly one of --block-id/-i or
--task-ref/-t is required; the parent lookup and error messages match
bob capture (missing ID with a close-match suggestion, duplicate ID, non-task
block ID, stale or ambiguous ref). A resolved task with no sections returns a
successful empty list so a picker can skip the chooser. Human output uses cyan
titles and dim slugs plus child counts, with a No task sections found. empty
state. JSON success is a single versioned object with ok, schema_version
1, route, nullable block_id (null when the parent was resolved by
--task-ref and still has no ID), ref, count, and an ordered sections
array. Each section has title, slug, line, child_count, and depth
(always 1 for a direct child). JSON failure is {"ok": false, "error": "..."}.
bob capture-templates¶
bob capture-templates [list] [-f|--format human|json]
bob capture-templates show TEMPLATE [-f|--format human|json]
bob capture-templates save [-f|--format human|json] [TEXT]...
bob capture-templates delete TEMPLATE... [-f|--format human|json]
bob capture-templates clear [-f|--format human|json]
The group is a capture-protocol sibling of bob capture; its verbs must stay
under capture-templates because free-text bob capture accepts arbitrary
text. list is the default and is read-only. An older bob reports an
unrecognized subcommand for bob capture-templates and exits 2. show
writes an unstyled draft to stdout. save reads TEXT joined with spaces, or
stdin when TEXT is absent. delete resolves numeric selectors against the
locked list at write time. All commands support -f|--format human|json.
JSON errors use {"ok":false,"error":"...","code":"..."}.
list -f json is the app protocol: each entry includes the insertable draft,
the UTF-8 byte cursor, field_count, line_count, the first row's route,
and spans computed by the same parser as capture-parse. Clients render and
insert these values directly instead of parsing template syntax. Saving is
controlled by capture.max_saved_templates; a newer schema blocks writes but
still permits reads, and clear remains available as the explicit reset.
{
"ok": true,
"schema_version": 1,
"capacity": 36,
"saving": true,
"file": "/Users/bryan/.local/state/bob-cli/capture/templates.json",
"templates": [
{
"index": 1,
"id": "9c1f0e2a7b3d",
"text": "Apply to {0}! @job^apply-{1}\n- **URL**: {2}",
"draft": "@@template:\nApply to {0}! @job^apply-{1}\n- **URL**: {2}\n\n",
"cursor": 57,
"field_count": 3,
"line_count": 2,
"route": "job",
"spans": [{ "start": 9, "end": 12, "kind": "template_placeholder", "field": 0 }],
"first_saved_at": "2026-10-16T09:12:44-04:00",
"last_used_at": "2026-10-16T09:40:02-04:00",
"use_count": 3
}
],
"warnings": []
}
show -f json returns {"ok":true,"template":...}. save -f json
returns saved: {id,status} and the new entry; status is new, updated,
or replaced. A new explicit save starts with use_count: 0. Delete returns
the deleted ids and any unresolved selector strings in missing; clear
returns the number of entries removed. Error JSON includes ok: false,
error, and code: saved_template_not_found exits 1; saved_template_not_template,
saved_templates_disabled, existing template_* definition errors, and an
unknown --flag before -- (saved_templates_unknown_flag) exit
2; saved_templates_newer_schema, saved_templates_busy, and
saved_templates_io exit 1.
bob capture-task-id¶
bob capture-task-id (--route NAME | --note-path PATH) --task-ref REF --block-id ID [-b|--bob-dir DIR] [-f|--format human|json] [-d|--dry-run] [-a|--allow-closed]
Assigns a user-authored Obsidian block ID to one open task in a Bob note.
This is the only write needed to turn a missing-ID capture-complete --all-tasks,
task_link, task_parent, task_dependency, blocked_task, or task_complete candidate into an identified task. The command validates
--route (or --note-path) and --block-id with Bob's shared grammar (A-Z, a-z, 0-9, and - for the
ID; routes also allow _), resolves --task-ref with the same stale-safe
<line>:<digest> recovery as bob capture --task-ref, and then confirms the
task is still open and still lacks an ID. An ID already used anywhere in the
target note — including a non-task ^anchor — is rejected. Success appends
^<id> to the resolved physical task line, preserves that line's ending and
every unrelated byte, and replaces the note with one same-directory temporary
file rename. The write is observable only after that rename completes.
--dry-run returns the same success shape without writing.
--route names a routable root note. --note-path names the exact
vault-relative path of any task-bearing note, so nested, case-sensitive, and
quoted paths round-trip exactly without the lowercasing route parser; the two
flags are mutually exclusive. --allow-closed additionally permits
Done/Cancelled tasks — the dependency picker's completed history — without
reopening them, and never applies to non-task lines. The previous daily
snapshot stays read-only and fails with a guarded refusal, as do traversal,
absolute, ambiguous, and symlink-escaping paths.
JSON success is a single versioned object with ok, schema_version 1,
dry_run, route (in --route mode) or note_path (in --note-path
mode), relative_target, the backend-formatted dependency_replacement
(¬e:id, quoted when the locator needs it), the backend-formatted
blocked_task_replacement (*note:id, quoted when the locator needs it),
the backend-formatted
complete_replacement (!note:id, quoted when the locator needs it, in
both modes) for splicing the new ID into the Complete picker, the
backend-formatted relation_to_replacement (~note:id) and
relation_from_replacement (~~note:id), quoted when the locator needs
it, for splicing the new ID into the relation pickers, the canonical
block_id,
the updated one-based line, the updated ref, and a task object with
the same picker metadata as capture-tasks after the assignment. In --route
mode it also returns parent_replacement (@route+block-id) for resuming a
parent-task picker; this field is omitted in --note-path mode. JSON
failure is {"ok": false, "error": "..."} and is write-free, as are stale,
ambiguous, terminal, already-identified, duplicate, missing, unreadable-note,
and read-only-snapshot errors.
bob capture-pomodoro-name¶
bob capture-pomodoro-name --pomodoro-ref REF --name NAME [-b|--bob-dir DIR] [-f|--format human|json] [-d|--dry-run]
Assigns a canonical ALL-CAPS name to one open, unnamed Pomodoro in today's
daily note. This is the only write needed to turn a nameable Pomodoro picker
candidate into a selectable named Pomodoro. The command canonicalizes --name
by trimming, collapsing internal whitespace to a single space, and
ASCII-uppercasing, then requires the task-section title grammar plus +
(A-Z, 0-9, spaces, and & ' ( ) + , . / -, starting with a letter or
digit). + is allowed in the rest of a Pomodoro name, not as the first
character.
Uppercasing is deliberate: the vault's named-Pomodoro convention is ALL-CAPS,
and case cannot affect the selector slug.
The daily note is selected exactly as bob capture selects it: BOB_DAY_FILE
when set and nonempty, otherwise <bob-dir>/YYYY/YYYYMMDD.md from BOB_NOW or
the local date. --pomodoro-ref uses the same stale-safe <line>:<digest>
recovery as bob capture-pomodoros. The selected entry must still be open. An
entry that already has a selectable name is refused so callers type #<slug>
instead of renaming. A named-but-untypeable entry is the exception: naming it
is the repair, and the command replaces the existing em-dash tail rather than
appending a second one.
Success appends — NAME to the resolved physical line after trimming that
line's trailing spaces, preserves that line's ending and every unrelated byte,
and replaces the note with one same-directory temporary file rename. The write
is observable only after that rename completes. The command re-scans the
written contents and refuses to report success unless the entry now parses with
the expected name and slug. --dry-run returns the same success shape without
writing.
JSON success is a single versioned object with ok, schema_version 1,
dry_run, day_file, relative_day_file, the canonical name, the slug to
type, the updated one-based line, the updated ref, and a pomodoro object
with the same picker metadata as capture-pomodoros after the assignment.
JSON failure is {"ok": false, "error": "..."} and is write-free, as are
stale, ambiguous, completed, already-named, missing-note, missing-section, and
unreadable-note errors.
A $<N> capture writes [?] when it rolls a scheduled date; unscheduled task
captures remain [ ], and unscheduled Pomodoro-linked tasks remain [*].
Project notes and their ^prj lifecycle tasks are covered in
projects.md. The command index lives in the
CLI reference and environment variables in
configuration.