Task types¶
A task type is the kind of work a task represents, named by one
ALL-CAPS word such as BUILD or READ. A ledger entry's full — NAME
tail is a Pomodoro name. It may contain several +-separated
components. A type component names a task type; a label is any
other component. Bob derives the Pomodoro-name catalog from canonical daily
notes, folds hide and color pins from a per-host journal, and assigns each
component a stable hue. Clients render what Bob reports; they never scan the
vault or compute a hue of their own.
This is the shared contract for Rust, the Obsidian ledger mirror, Bob Mac
Capture, and the CLI. Rust owns the meaning. JSON grows additively at
schema_version 1. PT1–PT13 describe behavior across the catalog, capture
menus, CLI, Obsidian plugins, Mac Capture, and menu-bar indicator. PT numbering
is historical.
PT1 — Vocabulary and identity¶
- Task type: the kind of work a task represents, named by one ALL-CAPS word. The effective type can be computed from the task field, reading rule, note default, or configured default; see PT13.
- Pomodoro name: the whole tail.
— BOB + DECKSis one name with two components, not two Pomodoro names. - Component: one
+-separated part of a Pomodoro name. Its key isnormalize_component: whitespace collapsed, lowercased. The display name is the most recent authored spelling for that key. - Type component and label: a type component names a task type; a label is any other component. When a configured vocabulary exists, its names identify type components and the remaining names are labels. Without a configured vocabulary, every component except an inventory label is a type component. A name with no type components is a container.
- Not task types: an unnamed entry, the budget's
(unnamed)placeholder, a Pomodoro home, or an Obsidian appearance theme. - Naming in code:
pomodoro_themes(Rust),bob-pomo-theme-*(CSS),PomodoroTheme*(Swift),api.themes(JS). - Compatibility names:
bob themesis the permanent hidden CLI alias forbob types. The canonical configured vocabulary and task field usetypes:and[type::];themes:and[theme::]are permanent legacy aliases. New person-facing copy uses task type, Recent, Hide/Unhide, Pin color, and Rename type. Hiding changes suggestions only.
PT2 — Catalog¶
- Source: every canonical daily note,
YYYY/YYYYMMDD.mdand nothing else. Not_templates/,done/,_conflicts/, or*_done.md/*_poms.mdsiblings. Read column-0 entries in## Pomodoros, open and closed, throughcapture_pomodoros::scan. Fenced lines are skipped. - Per-type fields:
key,name,first_used,last_used,days_used, andentries. Only days up to and including today count. Future-dated notes feed rename, not recency. today:open,closed, or absent, for the type's presence in today's ledger. Open wins when both occur.- History is derived only from ledger entries. Never infer it from task text or homes.
PT3 — Cache¶
- Location:
bob_cli_cache_dir()/themes/catalog-v1.jsonandknown-keys-v1.json, each keyed by vault root. Machine-local, never synced, disposable. - Contents: a per-file
(mtime_ns, size)fingerprint and that file's type summary. - Completion also keeps a disposable per-vault
themes/completion-v1-<vault-hash>.jsonview containing precomputed past-type totals and the days on which those types occurred. It checks the source cache fingerprint, date, year-directory fingerprints, and sweep age before reuse; corruption, rename, and a fresh catalog rebuild invalidate it. - Today's note is always read live. Completion reuses its current ledger scan when it is the canonical daily note.
- A full sweep of past days runs at most once per 60 s, or when a year directory's mtime changes.
- Rebuild on a corrupt or missing cache, after a rename, and with
--fresh. The rename preview always rescans from source. - Writes are atomic (temp file + rename).
- Stores prune entries for vault roots that no longer exist. When the catalog prunes a root, it also removes completion views not associated with a remaining vault and atomic-write temp files older than ten minutes, keeping the machine-local cache bounded by live vaults.
- Budget: warm
capture-completeandcapture-parsestay within +5 ms p95 of today's baseline on apollo against the live vault.bob types listuses the same source cache.
PT4 — Preferences journal¶
- Location:
.bob/themes/<host>.jsonl, one append-only file per host, inside the vault so it syncs. host: the same host label vault-sync uses in its commit subjects.- Events (
v: 1):theme.hide,theme.unhide,theme.color(hueis a slot name, ornullto unpin), andtheme.rename.vianames the client:bob-cli,bob-mac-capture, or a plugin id. - Fold: read every
*.jsonlfile; skip malformed lines with a bounded warning; sort by(ts instant, host, line number); last writer wins per key for the hidden state and for the pin. A rename moves the source key's explicit state to the target unless the target already has explicit state. Events after the rename on the old key apply to the old key. - Writers: bob is the only writer.
bob types hide,unhide,color, andrenameappend underbob_sync.lock(wait at most 3 s; a busy lock islock_busy).bob types splitrewrites daily notes under the same lock and writes no journal event. Each event is oneO_APPENDwrite. The directory is created on first use. - Allowlist: the vault tracks
.bob/themes/*.jsonl(!/.bob/themes/*.jsonlin.gitignore) and unions concurrent appends (.bob/themes/*.jsonl merge=union). A host that has not synced the allowlist keeps its hides local until it does. See Vault Git sync.
PT5 — Hue rule¶
In order:
- A configured color from the
themes:block wins (hue_source: "config"). - Else a journal pin wins (
hue_source: "pin"). - Else a key in normalized
plan.exemptis muted:hue: null,hue_source: "exempt"(PLAN by default). - Else, when a
themes:vocabulary is configured and the key is not in it (and is not an inventory label), the type is muted:hue: null,hue_source: "unconfigured". - Else a key equal to a top-level area note's stem (an area whose
parentis[[area]]) wears that area's resolved hue (hue_source: "area"): a frontmattercolor:slot, else the stem hash. - Else
Hue::ALL[fnv1a32(key) % 8](hue_source: "hash").
Explicit choices (config, pin) beat mutes, and mutes beat implicit sources (area, hash). Config beats pin because the user-owned file is the source of truth: editing it must always take effect. A config color also beats the exempt mute. Pins shadowed by a config color stay in the journal untouched and apply again if that config color is removed.
Results: JOB → aqua, DEV → blue, LOVE → yellow, PLAN → muted. Palette: the
eight existing slots (blue, orange, aqua, yellow, magenta, green, violet,
red), with their light/dark hex values and xterm-256 codes. Colors are
stable, not unique; pins fix recurring collisions. Rust computes the hue.
JS mirrors it under shared vectors. Swift and Lua only consume the hue
Bob reports.
PT6 — Visual language¶
Names wear a tint; places wear a dot.
- Pill (Obsidian, Mac): a soft tint of the hue behind normal-colored
text (16 % light / 24 % dark; hover 24 % / 32 %). Corner radius 4 px,
horizontal padding about 0.3 em, no border,
box-decoration-break: clone. The hue is never the text color. - Muted pill: exempt types such as PLAN: a neutral gray tint (no hue) behind muted text, rising on hover, so a configured exempt name still reads as a type.
- Unconfigured pill: a type outside the configured vocabulary wears a
1 px dashed outline in the platform warning color (Obsidian
--text-warning, macOSsystemOrange) at about 70 % opacity, over muted text without the gray fill, with a "not one of your types" tooltip/AX suffix. The Mac keeps its muted fill. Like the typing rule, the mark appears only once the name is committed or the cursor leaves it. - Swatch: in dense rows, an 8 pt rounded square (radius 2) before the name. The Day strip type legend uses the same rounded square at the legend-dot footprint. Area homes keep their ● circle. The Day strip and the Stats window bars shade smaller same-hue types; every other surface uses the exact hue.
- Composites: each component gets its own pill; the
+stays neutral. - CLI: an xterm-256
■before a default-color name when color is enabled (TTY and noNO_COLOR). An exempt type gets□; an unconfigured type gets◌(dim).bob types listalready draws these swatches;bob plan, capture destinations, and the Stats type dimension use the same squares, and tmux uses its native format color before each named component. UnderNO_COLORthe glyph and thenot configuredtext still carry the meaning. - Typing: a name the catalog does not know stays neutral while you type it; it takes its hue once it is committed or the cursor leaves the line.
- Color is never the only signal. The name text, Today/Recent labels, and
hidden,pinned, andexemptbadges carry the meaning underNO_COLOR.
PT7 — Menu composition¶
Implemented in both capture-complete Pomodoro-name contexts and the Obsidian
Pomodoro-name pickers through api.themes.
Filter first, then group:
- A: Today rows that match the query, in today's order.
- B: Recent exact and prefix matches. Sort exact first, then
last_useddescending,days_useddescending, then name. - C: The create row, only when the query is creatable and no A or B row is an exact match.
- D: Recent substring matches, in B's order.
- E: Today's query-independent utility rows.
Recent rows exclude hidden types and any type that is a component of a
named Today row in the same menu. Only rows the menu actually shows count. A
type used today only by a done or cancelled Pomodoro is not a Today row in a
link or move picker, so it stays in Recent. In the start context it shows as
an "again" row. When a themes: vocabulary is configured, Recent rows come
only from that set: never-used configured types rank after used ones, in
config order, with no last_used/days_used, and similar_to draws only
from visible configured types. An empty query shows at most 5 Recent
rows, or 12 when types are configured so the whole vocabulary can show; a
non-empty query shows at most 12. Matching is ASCII
case-insensitive on slugs. A partial quoted literal matches on the
normalized key prefix.
bob types list -q ranks B then D over the catalog. Capture completion
adds Today rows, applies the caps, and folds an exact Recent match into the
create action. Recent rows carry origin: "history", last_used, and
days_used; they create a Pomodoro, report the plan-type preview, and omit
ref and line. Every named row carries component themes.
A create row carries hidden: true when its exact key is hidden, and
similar_to for at most two visible known names, most recent first. A query
needs at least three characters. Similarity means equality ignoring spaces
and punctuation, a singular/plural pair (s or es), or edit distance one
when both keys have at least five characters. This badge never changes
ranking or Return. When types are configured, an unconfigured create row's
themes refs carry configured: false. Zsh descriptions show
recent · Oct 21 · 3 days for used Recent rows, your type · never used
for never-used configured Recent rows, and new · not one of your types on
unconfigured create rows.
Fetching, filtering, and accepting suggestions never touch the journal.
PT8 — Lossless acceptance¶
A history or “again” row emits its plain slug only when the slug round-trips to the
displayed name and does not prefix-select a different today entry.
Otherwise it emits the exact-name literal #"NAME". Compare by key
equality, never by slug or prefix. In a bare start (=name), a literal
replacement includes the leading #; in a # field it replaces just the
name. Partial straight or curly quotes match normalized key prefixes, and
acceptance replaces the whole partial literal. Bob emits straight quotes.
A prefix-shadowed “again” name also uses a literal: with SASE V18 open,
accepting completed SASE creates SASE instead of starting SASE V18.
PT9 — Hide¶
Hiding removes a type from Recent rows only. Today's rows, typing the
name, history, Stats, and colors are unaffected. Using a hidden name never
unhides it. bob types hide NAME… and bob types unhide NAME… append
journal events; bob types list -H lists hidden types. Ctrl+D in a
Pomodoro-name menu hides a Recent row in place; the ghost remains selectable
and Ctrl+D again restores it. Exact hidden create rows can be unhidden, while
Today and ordinary create rows consume Ctrl+D without writing.
PT10 — Global rename¶
bob types rename OLD NEW [-d/--dry-run] [-f/--format human|json] [-m/--merge] [-o/--offline]
One previewed, verified, single-commit rewrite of the type component in
— NAME tails of canonical daily notes (past, today, and future, open and
closed) plus _templates/daily.md, area and project task_type: / theme:
defaults, and [type::] / (type::) plus legacy [theme::] / (theme::)
task fields (including done/), modeled on bob ref rename.
- Scope: Pomodoro tails, note defaults on area/project notes, and task
type fields. Prose, fenced examples,
.bob/, and_conflicts/are never touched. - Byte preservation: separators keep their
+spelling. A resulting duplicate collapses (FIX + FIXES→FIX). Quote style on default scalars is preserved. Task fields and defaults keep their key spelling; task fields keep their bracket or paren form and take NEW's spelling; with--merge, fields simply take NEW. Every other byte is preserved. - Counts: the preview (human and
-f json) reportssessions,defaults, andtask_themesseparately and lists the default-bearing notes. JSON is additive;entriesremains the session count. - NEW must be a valid single type. A NEW containing
+isinvalid_name. - Vault selection: before taking a lock, an online write requires
-b/--bob-dirandBOB_DIR(default~/bob) to resolve to the same directory. A mismatch or unresolved path refuses withvault_mismatch. SetBOB_DIRto the selected vault or use--offline. Previews and offline writes do not require this match; symlink aliases work. - Flow: take
bob_sync.lock, then pre-sync unless--offline, then replan from the synced files. Snapshot each target and compare again right before writing (a change aborts withstale_note). Write each file atomically, then appendtheme.rename. Verify by rescanning: OLD is absent from tails, defaults, and task fields, entry counts are unchanged, only name tails differ on session files, and every changed non-daily line differs only in the type value span. On failure, restore the before-images. Make one commit,bob types rename: FIXES → FIX, then post-sync. Undo isgit revert. - Refusals:
unknown_theme(OLD never used),invalid_name(NEW contains+or is blank),unchanged(OLD and NEW share a key),theme_exists(NEW already used without--merge),config_reference(OLD is inplan.exempt,plan.inventory_labels,plan.reading_theme, a Stats metric filter, or thethemes:vocabulary, and NEW is not — edit the chezmoi config to add NEW first; a configured OLD reads "OLD is a configured type; add NEW under types: first"; aplan.reading_themeOLD reads "OLD is plan.reading_theme; set plan.reading_theme to NEW first"), pluslock_busy,dirty_paths(offline with dirty targets),not_a_worktree,vault_mismatch,sync_failed,verify_failed, andstale_note. - Color and state: the color follows the new name, and the preview says so. Hidden state and pins move with the rename per PT4.
- Unconfigured NEW: a NEW outside the vocabulary adds a
warningsentry (theme_not_configuredmessage) to the preview and result; human output marks it◌ NEW not one of your types. A--mergeof two configured types notes that OLD stays inthemes:until you remove it. - Reappearance: if OLD's spelling reappears after a rename event (an
offline host, muscle memory),
bob types listreports it inrenamed_reappeared. It only reports; it never rewrites on its own. The latest rename from each key is the candidate, using the fold's(ts, host, path, line)writer order. A later event in that order that re-adopts the key clears the report: atheme.renamewhosetois the key, or atheme.color(pin or unpin) ortheme.unhidenaming the key. A latertheme.hidedoes not re-adopt a name.
Runbook: git log --oneline -3, confirm the bob types rename: OLD → NEW
commit, then git revert <sha> and bob vault-sync to restore every byte.
Migrating theme spellings¶
bob types migrate [-b/--bob-dir DIR] [-d/--dry-run] [-f/--format human|json] [-o/--offline]
One previewed, verified, single-commit rewrite that moves open-task
[theme::] / (theme::) fields to [type::] / (type::) and
area/project theme: defaults to task_type:.
- Fields: every open task's single valid legacy field becomes the
canonical key in the same bracket or paren form, with spacing around
::and the value bytes kept. Canonical[type::]/(type::)fields are left alone. - Defaults: every area/project legacy
theme:scalar becomestask_type:, keeping value bytes and quote style (includingoff). Whentask_type:already exists, the legacy line is removed only if its value matches; otherwise the note is reported asconflictand left alone. - Never touched: closed tasks,
done/, repair-state lines, prose, fenced examples,.bob/, and_conflicts/. Repair lines are listed asneeds_repair(duplicateorinvalid). - Vault selection: the same online identity check as rename. A
mismatch refuses with
vault_mismatch. Previews and--offlinewrites do not require this match. - Flow: take
bob_sync.lock, then pre-sync unless--offline, then replan from the synced files. Snapshot each target and compare again right before writing (a change aborts withstale_note). Write each file atomically. Verify that every changed line differs only in the key span, or is a removed matchingtheme:default. Make one commit,bob types migrate: N fields, M defaults, then post-sync. Undo isgit revert. When nothing is legacy, refuse withunchanged(exit 1, no commit). - Output: the preview lists per-file counts. JSON is
{schema_version: 1, ok, fields, defaults, files: [{path, fields, defaults}], needs_repair, conflicts}. Writes also includemode,commit, andsync.
Runbook: run bob types migrate -d, then the write; undo with
git revert <sha> and bob vault-sync.
PT11 — Split by entry¶
bob types split OLD (-m/--map FILE | -t/--template) [-b/--bob-dir DIR] [-d/--dry-run]
[-f/--format human|json] [-o/--offline]
Rewrite selected task type components in — NAME tails of canonical
daily notes (past, today, and future, open and closed). _templates/daily.md
is out of scope. Exactly one of -m and -t is required. -m - reads the
map from stdin. OLD must be a known type.
- Template (
-t): read-only; never takes the lock. Lists every entry where some+component normalizes to OLD's key, ordered by date then line. Human output is TSV the map parser accepts unchanged:#comment lines naming the columns and the fill-in rule, thenDATE<TAB>REF<TAB>TYPE<TAB>NOTE. DATE isYYYY-MM-DD. REF isLINE:DIGEST. TYPE is prefilled with OLD's display name (an untouched row is a keep). NOTE is[x]/[ ], the time range or(), the full current name, and the Task Link targets from the entry's child lines, joined with,. Plain-text children with no link appear as their first 60 characters.-f jsonemits{schema_version: 1, ok: true, mode: "template", theme: {key, name}, entries: [{date, path, ref, line, state, time_range, name, links, notes}]}. - Map (
-m FILE): rows areDATE<TAB>REF<TAB>TYPE[<TAB>NOTE]. Blank lines and lines starting with#are skipped; NOTE is ignored. Refuse withinvalid_map(a usage error), citing the map line number, when a row has fewer than three columns, the date is malformed or has no canonical daily note, the ref is malformed, or two rows resolve to the same entry. A blank TYPE or one containing+isinvalid_name. Resolve each row withPomodoroScan::by_ref.StaleorAmbiguous, or an entry with no OLD component, isstale_entry(hint: regenerate with-t). A row whose TYPE key equals OLD's key is a keep. Otherwise replace the OLD component with TYPE's trimmed spelling; separators keep their+spelling and a resulting duplicate collapses. OLD entries with no row are left alone and counted asunmapped. If every row is a keep, refuse withunchanged. TYPE may not exist yet (newin the preview). Targets in normalizedplan.exemptareexempt. An unconfigured TYPE adds awarningsentry and human output marks it◌ not one of your types. - Writing:
-dprints the plan and changes nothing. A write runs through the shared vault-rewrite transaction (command_namebob types split, worktree subject "type splits"). Split writes no journal event. OLD keeps its hidden state and pin, and every target keeps its own. Note defaults and[theme::]/(theme::)task fields are left unchanged. When OLD still appears in either, the preview and result add asplit_leaves_task_themeswarning with those leftover counts. The commit subject isbob types split: OLD → T1, T2, …with the changed target display names in alphabetical order. Verify: reread contents equal the planned after-image, line count unchanged, the set of changed lines equals the planned set, every changed line differs only in its name tail, each planned line now carries its planned name, and the scanned entry count is unchanged. Undo isgit revert. - Refusals:
unknown_theme,invalid_name,unchanged,invalid_map,stale_entry, pluslock_busy,dirty_paths,not_a_worktree,vault_mismatch,sync_failed,verify_failed, andstale_note.
Runbook: bob types split JOB -t > job.tsv, edit TYPE, preview with
bob types split JOB -m job.tsv -d, apply with bob types split JOB -m
job.tsv. Undo with git revert <sha> and bob vault-sync.
PT12 — Type vocabulary and display¶
A types: block in the config file declares the task type vocabulary
and each type's palette color. themes: is a permanent legacy alias with
the same shape. Bob reads the file and never writes it.
types:
PLAN: blue
BUILD: yellow
READ: green
CHORE: magenta
WRITE: violet
FIX: red
- Shape. A mapping from type name to palette slot (
blue,orange,aqua,yellow,magenta,green,violet,red; case-insensitive, trimmed),auto, or empty/null.autoand null both mean "configured, but bob picks the color" (pin, then exempt, then area, then hash). No hex values: every surface already has a light/dark pair and an xterm-256 code for each slot. - "Configured" means the block is present, valid, and has at least one
entry. Missing,
null, or{}means no configured types, which is exactly today's behavior. - Validation is strict, and a failure drops the whole block, so behavior
is as if no types were configured:
typesmust be a mapping; each key must be a YAML string (a number or boolean key fails with "quote type names YAML reads as numbers or booleans"); each name must be a valid single type (not blank, no+); two names with the same normalized key fail, naming both spellings; a value that is not a slot,auto, or null fails, listing the valid colors. Every message names the config path and the offendingtypes.NAME. Setting bothtypes:andthemes:is also invalid and drops the vocabulary. - Where an invalid block is reported. Never fatal:
bob types listcarries it as awarningsentry (yellow in human output),bob planreports thethemes_config_invalidlint, andbob captureprints a plain warning string.bob types listnever exits 2 over it. - Identity and display. A configured type's key is its normalized name. The history catalog itself does not change: it is still derived only from daily notes plus the journal. Reports combine the catalog with the configured set. A configured type with no history displays its config spelling; otherwise the most recently authored spelling wins.
- Configured flag and
known. When types are configured, everyThemeRefand everythemes listrow carriesconfigured: true|false. When none are configured, the field is absent.knownis also true for configured types with no history. Inventory labels are storage, not types: they never warn, are never muted as unconfigured, and reportconfigured: true.(unnamed)is not a type. - Warning code. One code everywhere:
theme_not_configured(SNACK isn't one of your types, plus; did you mean CHORE?when the similarity rule matches a configured type). Warnings never block anything; there is no strict mode. Surfaces: type-name menus scope Recent rows to configured types (never-used configured types rank after used ones in config order, the empty-query cap rises from 5 to 12,similar_todraws only from visible configured types, create rows warn),bob capture(structured top-level warnings),capture-pomodoro-name(Name-it), Obsidian pickers that write names,bob types rename/split,bob plan(theme_not_configuredlint), pills and swatches everywhere, andbob types listhints. bob types list. The default view always includes visible configured types, even never-used ones (first_used: null,last_used: null,days_used: 0,entries: 0, notoday; human output showsneverand0 days, sorted last). The header reads6 configured · 76 known · …. Human meta labels readblue · configfor config colors, today's labels (aqua,red · pinned,muted · exempt,aqua · area) for configuredautotypes, andmuted · not one of your typeswith the◌swatch for the rest. JSON addscounts.configured, top-levelconfigured_themes([{key, name, color}], color a slot or null, in config order), per-rowconfigured, andhue_sourcevaluesconfigandunconfigured.theme_hue_collisionskips config-colored and muted types.theme_not_configuredhints cover visible unconfigured, non-inventory types used today or in the last 7 days, one JSON object per type ({code, theme: {key, name}, last_used, days_used, similar_to?}). Human output prints at most 3 of those hint lines, then… and N more · bob types -a.bob types color. Pinning a type whose config sets a color refuses withconfig_color(exit 1, no journal write):BUILD's color comes from themes: in ~/.config/bob/config.yml (yellow), hintedit themes.BUILD there, or set it to auto to pin colors with bob themes color. Configuredautotypes pin normally.
PT13 — Task type suggestions¶
Rust suggests one task type per task and remembers deliberate
exceptions as task fields. bob types suggest explains the suggestion;
nothing else in this phase writes. Capture pickers and commits, listing
rows, and other clients follow this same contract in later phases; this
section does not claim they ship yet.
Note defaults¶
An area or project note declares its default with scalar frontmatter:
task_type: WRITE
task_type: is canonical; theme: is a legacy alias still read.
Quotes are honored. When both keys are present, task_type: wins. A
different legacy value adds one type_default_conflict warning through
the same channel as theme_default_invalid. An absent or blank value
inherits from further up the residence chain. ASCII-case-insensitive
off (either key) stops the walk, but only when declared by an area or
project. Lists (flow and multiline), composites, mappings, and bad names
are invalid: skipped, with one theme_default_invalid warning per
visited declaring note. A default value on any other note kind is
ignored, while that note's scalar parent: links still carry the walk.
Daily notes have no default walk. The walk reuses the R1 residence chain
with its 16-note and cycle limits. A set [root:: X] field starts that
walk at X, including when the task lives in a daily note.
Task fields¶
A task line carries none, one set field, or repair state:
- Set: exactly one valid field,
[type:: X]or(type:: X)(legacy[theme:: X]/(theme:: X)still read). - Repair: two or more fields across both keys mean
duplicate; one invalid field meansinvalid. Repair falls through to inheritance, carriesrepair: "duplicate" | "invalid"whenever a suggestion exists, and is replaced by the next eligible writer. - Closed tasks keep their field as history and have no suggestion.
A no-suggestion result still returns warnings separately; no repair marker is fabricated to carry them.
Precedence and inheritance¶
First match wins:
- Task field. Source
task, labelthis task. - Reading task. An exact
#reftoken, or a trailing block IDreforref-*(rereads included). Ordinary annotation follow-up tasks do not qualify. Usesplan.reading_type(defaultREAD);offskips this step.plan.reading_themeis a legacy alias. Sourcereading, labelreading task. - Note default. The first valid default on the residence chain.
Source
note, labelfrom <lowercase declaring stem>. A set[root:: X]field starts this walk at X, so a daily^gtdrow carrying[root:: gtd_daily]suggestsgtd_daily's default (task_type: PLAN). - Global default.
plan.default_type, when set to a type name.off,null, or unset skips this step. Sourcedefault, labeldefault type. A name outsidetypes:still applies and adds adefault_type_not_configuredlint tobob plan. - Otherwise none:
No suggested type.
The inherited suggestion runs the same rule with step 1 skipped. The
serialized suggestion holds theme (a ThemeRef), source, and
label. Only source note adds
from: {path, stem, kind, hue, chain}: vault-relative path, chain from
residence through declaring note, and the declaring note's root-area hue.
Only source task adds inherited, serialized as a suggestion without
a further inherited member, or explicit null. Other optional fields
are omitted.
Known, unknown, and hidden¶
A type is known when the catalog knows it or when it appears among
today's live ledger components, even when the catalog cache is stale.
Unknown names stay reportable (known: false, ◌ swatch); downstream
menus must never preselect them. Hidden types remain eligible.
Type defaults never affect hue, history, or Stats.
themes suggest¶
bob types suggest TARGET [-b/--bob-dir DIR] [-f/--format human|json].
route:block-id explains an existing task (capture-route validation,
no path traversal, one-based line); a bare route explains a new task's
default. Report closed tasks successfully with no suggestion and one
closed_task warning. Exit 0 explains (including no suggestion or a
closed task); 1 is a target lookup or I/O failure; 2 is usage or
config. The vault is never written; byte-identical before and after.
JSON is
{schema_version: 1, ok, target: {route, block_id, path, line, new_task}, theme_suggestion: <object or null>, warnings}.
Null block ID and line mark new tasks. Warnings are closed_task,
theme_default_invalid, and type_default_conflict (with the declaring
path).
Human output shows the swatch and name plus suggested · <label>;
note defaults add the declaring path and chain, and task overrides add
the inherited reason:
■ WRITE suggested · from sase
declaring note: sase.md
chain: sase.md
Task overrides render without it: ■ WRITE from sase_blog (or the
equivalent inherited reason, or without it: none (no default here)).
Warnings render as warning: <code> plus · <path> when present.
Color follows NO_COLOR; broken pipes exit silently.
Placement¶
set_task_theme removes every type and theme field in either form
plus one adjacent separating space, then inserts [type:: NAME] before the
maximal trailing run of inline fields of any key, tags, and a trailing
^id. With no run it inserts at the trimmed end. clear_task_theme
only removes the fields and the adjacent separator. Both preserve every
other byte: indentation, blockquote prefixes, tabs, trailing whitespace,
CRLF, and absence of a final newline. The checkbox and title are never
consumed as trailing metadata, and overlapping removals from adjacent
fields stay safe. Catalog spelling is used for X; a semantic no-op
(exactly one valid field already holding X's key) never rewrites bytes.
Named captures retype the task¶
Capture #NAME, #"NAME", and =name always take the retype Enter
path. Bob resolves the destination Pomodoro name D, then chooses one
type component X of D: the authored selector's slug or prefix match,
else the first non-exempt type component, else the first type
component.
- D is a container (no type components): the task keeps its type, and
capture warns
LATER is a label · tasks keep their own typesorSNACK isn't one of your types; tasks here keep their own types. - The task's type is already a component of D: consistent, nothing written.
- X equals the inherited type: clear every type field (no-op if absent).
- Otherwise set catalog-spelled
[type:: X]. PLAN is a legal result; there is no suggestion-shown guard and no single-non-exempt-component guard.
Forms: a new Pomodoro-linked task (<text> @route:id#NAME), a
whole-item existing-task link (@route:id#NAME or ^route:id#NAME),
Ensure Next (@route+id#NAME), the equivalent =name shorthands, and
project-note :<id> bullets under a named +#NAME. Link guards,
unnamed links, ordinary toggles, close gestures, and bob ref today
never write this field. A batch re-evaluates suggestions against its
staged note snapshot, and the task field is committed with that
capture's task and ledger edits.
Capture JSON omits task_theme unless the field changes. When present
it is {action, theme, previous, inherited}: action is set,
cleared, or kept (a marker always reports, even when nothing is
written), theme is the selected ThemeRef (null on clear),
previous is the prior valid task field (null when absent or needing
repair), and inherited is the suggestion without the task's own
field (or null). Human output adds a type row
type → FIX · <stem> suggests BUILD /
type cleared · <stem> suggests BUILD /
type ■ FIX · from <stem> (kept); dry-run reports the same
post-image without writing.
Typed captures (t:)¶
A t:<type> marker on a new task writes the resolved type X before
placement, following the field table: X equals the inherited suggestion I
with no field means kept; with an authored exception means cleared;
otherwise a field already equal to X is kept, else set. The marker is the
authority over any #NAME retype: a container destination accepts X, a
matching component accepts that explicit pick, otherwise the capture
refuses with type_marker_conflict. Type-only changes stamp no freshness,
change no lane, and consume no dependency guard.
The explicitly named capture choice and the t: marker are the writer
boundary in this phase. Close, roll, typed ^^, move, rollover,
reconcile, ordinary successor placement, and ref-today remain
never-writers. Existing-target markers are parsed but refused until a
later phase.
Bare captures place by type¶
Every bare link-creating capture form routes to the Pomodoro the task's
effective type picks instead of today's implicit current/next entry. Bob
resolves the type from the staged task line (an authored [type::]
field, the reading rule, the note default, then plan.default_type),
then runs the shared placement: the running exact entry, else the first
open exact entry in ledger order, else the first open entry whose name
contains the type, else a created — T placeholder at the named-creation
insertion point. Creation reuses the named-creation insertion, so a batch
sees earlier staged creations and two captures that both need the same
type share one placeholder. A queued task keeps its queue — Ensure Next
is the one exception and relocates its link to the placed entry, which is
a no-op when already there. With no effective type (no default
configured) the legacy implicit selection is used unchanged, and
multiple open timed entries without a running exact match still fail the
same way. A creation that adds a type past plan.max_themes under
plan.strict is refused before any write with code strict_cap, naming
the type, the count and cap, and the #NAME escape; otherwise creation
appends the existing budget warnings.
Forms: a new task (<text> @route:id) and project-note :<id> bullets
(each links by its own type), whole-item @route:id / ^route:id, Ensure
Next (@route+id), the @route+id! link direction, and unnamed =
starts with no queue (the first untimed open entry the type places, else
a started — T entry; a queued task still starts its queue, and a pure
= with no task keeps the legacy rule). Bare placement never edits the
task's type fields.
The destination reports the placement additively. PomodoroLinkEndpoint
gains a type object {theme, source, label} when Bob placed by type
(legacy and named destinations omit it; role values are unchanged),
and the human destination row appends · <label>
(→ new Pomodoro CHORE · default type,
→ under BUILD (running) · from dev). Dry-run previews carry the same
data, so the Mac shows it without placement logic of its own.
Preservation¶
Task move, archive, Highlights reading-status checkbox edits, ref
rename's alias overwrite, task-status-hooks reconcile, complete and
cancel, and freshness stamping keep a distinctive authored field
(either form, any spacing) byte-identical: only each operation's
intended bytes change. Freshness stamps land after the type and after
a [root::] field, still before the fresh/Tasks suffix. Closed and
history fields are preserved, not stripped. The same writers keep a
distinctive [root::] / (root::) field byte-identical.
Rename coverage¶
bob types rename rewrites matching note defaults (task_type: and
theme:, keeping each key's spelling) and task fields ([type::] and
[theme::], keeping each field's spelling and bracket/paren form) in the
same previewed, verified commit as the session tails. Invalid defaults,
off, non-area/project default lines, fenced examples, and prose stay
untouched. bob types split never rewrites defaults or fields; its
split_leaves_task_themes leftover counts cover both spellings.
bob types migrate is the spelling rewrite: it turns open-task
[theme::] fields and area/project theme: defaults into the
canonical keys, and leaves closed tasks, done/, and repair lines
alone.
List hints: unknown defaults¶
theme_default_unknown fires once per typed area/project note whose
valid default is unknown to the current catalog:
{code: "theme_default_unknown", path, name, similar_to}. Human
output renders
<path> suggests unknown type <NAME>[; did you mean <X>?] → edit theme: in that note or add <NAME> under themes: in <config>.
No hint for absent, blank, off, invalid, ignored note kinds, or known
defaults. Collision hints keep their exact bytes and shape; hint order
is deterministic. A --query, --limit, or hidden filter never hides
this vault-wide diagnostic. The scan follows project discovery
exclusions, never writes, and knownness re-evaluates from the current
catalog and config, so new ledger use or vocabulary changes clear hints
without rewriting defaults.
The shared menu contract¶
Menus in every client follow this rule over the suggested type S.
Exact entries contain only S after removing exempt keys other than S.
Prefer a running exact entry, else the first exact entry in ledger
order, else the first containing entry; keep Today order. Otherwise a
known S may create a top New S Pomodoro row when a Pomodoros section
exists, at most one timed open entry exists, and strict type caps
permit it; remove its Recent duplicate. Otherwise preserve today's
default and report unknown_theme, creation_blocked, or
strict_cap; ref-today additionally uses already_queued. A nonempty
query keeps PT7 ranking and first-row behavior while annotating at most
one best matching row. Ctrl+D on suggested creation is consumed without
writing. The later completion phase owns implementation and the
preselect vectors.
Pinned copy: suggested · this task, suggested · reading task,
suggested · from <stem> (root-area dot
where supported); reading tasks suggest READ,
<stem> suggests WRITE, and
no default here. Consequences: this task becomes FIX · <stem>
suggests WRITE and task type cleared · <stem> suggests WRITE;
notices: Linked under FIX · task type FIX and Linked under WRITE ·
task type cleared. Accessible example: Pomodoro WRITE, suggested,
from sase_blog. Explicit task fields alone get PT6 pills; inherited
values never become ghost pills. Unknown and repair styling follows the
established pill rules.
Capture completion emits a top-level theme_suggestion in
pomodoro_name context (null when there is no suggestion). At most one
candidate adds suggested: true and suggestion_label. With an empty
query, a known suggestion with no matching open entry becomes a suggested
create row at the top when the daily note and type cap allow it; the row
uses the normal Recent/create shape and is removed from Recent to avoid a
duplicate. A hidden type can appear in this row. With a nonempty query,
PT7 order and first-row behavior stay fixed and a matching row only gets the
suggested marker. theme_suggestion.fallback is
unknown_theme, creation_blocked, or strict_cap when no row can be
preselected. Zsh descriptions use suggested · <label>.
bob ref today adds the same row marker and label to its pomodoros
listing, plus a top-level theme_suggestion. Its fallback also includes
already_queued when the task already has a live link; that queue takes
priority. When a known suggestion has no matching open bucket and a new
entry is allowed, theme_suggestion.create: true identifies the canonical
entry the shared D7 planner will create on a write. An explicit -p or
-P destination uses D6 retyping, reported additively as
capture.task_theme; implicit typed placement never changes the task's
type field.
The capture command and bob ref today consume the shared preselect
vectors. task_theme: {action: "set" | "cleared", theme, previous,
inherited} remains the additive capture-write result, omitted on no
change.
Dataview caveat¶
Dataview merges list-item inline fields into page fields, so page type
on a note with typed tasks can become a list. Bob reads note kinds from
frontmatter and Bases read note.type, so neither is affected. No vault
Dataview query filters on page type today (verified 2026-10-29). Raw
Dataview task.type / task.theme is not an effective-type API; point
readers at bob types suggest.
Routing: agreement, retype, placement, relocation¶
Pure planners in src/native/pomodoro_themes/routing.rs, next to
preselect.rs. Named capture #NAME / =name forms call retype.
place now serves bare capture, existing-task links, ref-today, and
successor offers; note moves relocate the link when inherited types
change, and Pomodoro naming and renames retype linked tasks (D6 Enter).
Task Card integration remains later work. The agree,
retype, place, and relocate vector sections are the Ledger Tools
JS mirror's contract.
| Gesture | Changes | Bob restores agreement by |
|---|---|---|
| Task Card Type stage, Use default, Ctrl+D on Type | the type | relocating the live link (D8) |
| Task move to another note | the inherited type, if no field | relocating the live link (D8) |
| Naming or renaming an entry that holds links | the entry's types | retyping its tasks, count previewed (D6) |
- Type components. A component of a Pomodoro name is a type component
iff its key is in the configured
types:vocabulary; without a vocabulary, every component except inventory labels is one. Unnamed entries and(unnamed)have none. An entry with no type components is a container: it holds any task without typing it. Ad-hoc names stay fully supported as names, with the warningSNACK isn't one of your types; tasks here keep their own types. - Agreement. Every live Task Link sits under a Pomodoro whose name
includes its task's type, or under a container.
agrees(task_type, entry)is true when the entry is a container, the task has no effective type, or the task type's key equals any component key of the entry (labels included, so a SNACK-typed task under— SNACKagrees). Drift that no gesture caused (hand edits, frontmatter or config edits, sync merges) is flagged and never silently repaired: atype_mismatchlint inbob planplus a ledger mark in Obsidian. Closed Pomodoros are history. Type-only edits never stamp freshness, change lanes, consume dependency guards, or advance the review walk. - Retype. A link landing in Pomodoro D retypes its task: a container
D means no change (
keeps BUILD); the task's type already among D's components means consistent; Obsidian Alt+Enter merges (Merge, D's name gains+ TYPE, no field change); otherwise retype to one type component X of D — the authored selector's match (by slug or prefix), else the Obsidian Tab-cycled choice, else the first non-exempt type component, else the first type component. When X equals the inherited key the field is cleared, otherwise[type:: X]is set, catalog-spelled. There is no suggestion-shown guard and no single-non-exempt-component guard, so PLAN is a legal result. Capture#NAMEand=namealways take the Enter path. - Placement. Bob picks the Pomodoro for type T: an already-queued
task keeps its queue (Ensure Next
@route+idis the one exception, handled bycapture-placement); otherwise the first running exact entry, else the first open exact entry in ledger order (exact means the components, after removing exempt keys other than T, are exactly{T}), else the first open entry containing T, else a new— Tplaceholder at the existing named-creation insertion point. Unlike menus, placement may create entries for unknown or unconfigured names (with thetheme_not_configuredwarning). Withplan.strict: true, a creation that adds a type pastplan.max_themesis refused before any write. With no effective type, today's implicit current/next selection is unchanged. - Relocation. When a task's type becomes X: no live link, or the
link's entry already agreeing with X, means ledger unchanged (a
backlog task never gains a link). A running sole-link entry replaces
its type components with X (labels kept, X in the first type
component's position:
— FIXbecomes— BUILD,— BOB + FIXbecomes— BOB + BUILD); a running shared entry gains+ X(the timespan never changes). Otherwise the first planned entryplace(X)would choose (skipping running entries) takes the move; else a sole-link planned entry renames in place; else a— Xplaceholder is created directly below the source entry. Strict cap refusal applies to the creation case only. Among duplicates, ledger order decides. - Lint.
BUILD task under FIX · sase#^fix-x · Ctrl+Shift+M to move or Ctrl+Shift+P t to retype.
JSON contract¶
Additive; schema_version stays 1.
ThemeRef: {key, name, hue, hue_source}, plus known: false only for
names the catalog does not know, plus configured: true|false when a
themes: vocabulary is configured (absent otherwise). Where offsets matter,
it also carries start / end byte offsets into the draft.
capture-parse attaches themes to each Pomodoro-name span; offsets
identify component text and exclude quotes and +. capture-pomodoros
attaches themes to every entry. bob plan returns theme_refs beside
theme_names and themes on each entry; bob capture returns
added_theme_refs beside added_themes; task reconcile uses the same
plan-budget fields. These arrays preserve source order. hue_source is
one of config, pin, exempt, unconfigured, area, hash.
Stats reports include theme_hues beside homes, keyed by normalized type
key, for type-dimension values present in the report. Exempt types have
hue: null and hue_source: "exempt". Unknown names carry known: false;
while typing, clients keep them neutral.
bob types list -f json:
{
"schema_version": 1,
"today": "2026-10-24",
"window_days": 90,
"counts": { "known": 81, "listed": 40, "today": 4, "hidden": 2 },
"themes": [
{
"key": "bob",
"name": "BOB",
"hue": "magenta",
"hue_source": "hash",
"pinned": false,
"hidden": false,
"exempt": false,
"first_used": "2026-08-26",
"last_used": "2026-10-24",
"days_used": 26,
"entries": 49,
"today": "open"
}
],
"hints": [
{
"code": "theme_hue_collision",
"themes": ["SASE", "BLOG"],
"hue": "orange",
"days_together": 6,
"suggest": { "name": "BLOG", "hue": "red" }
},
{
"code": "theme_not_configured",
"theme": { "key": "snack", "name": "SNACK" },
"last_used": "2026-10-27",
"days_used": 1,
"similar_to": "CHORE"
}
],
"renamed_reappeared": [],
"warnings": []
}
theme_hue_collision pairs are unpinned, non-exempt types that share a hue
and met on at least 2 of the last 30 days. The type with fewer days_used
is the one to move. The suggested slot is the least used by the last 30
days' types, excluding hues of types that met the moved type in that
window; ties follow palette order. renamed_reappeared lists every rename
source key that still has occurrences after its latest rename-from event
and that a later rename-to, color, or unhide has not re-adopted, as
{key, name, renamed_to, renamed_to_name}.
Writers (hide, unhide, color, rename, split) emit
{schema_version, ok, ...} on success and {ok:false, code, message, hint?}
on refusal, even on a nonzero exit. Refusal codes: unknown_theme,
invalid_hue, invalid_name, unchanged (no journal write), theme_exists,
config_reference, invalid_map, stale_entry, lock_busy, dirty_paths,
not_a_worktree, vault_mismatch, sync_failed, verify_failed,
stale_note, plus config_color for color on a type whose color comes
from the themes: block. The Obsidian manager previews with rename -d -f
json (files, entries, before/after tails, state and color change) and
confirms with --merge when needed.
bob types CLI¶
The group is bob types, a top-level catalog noun in Daily workflow after
stats. Bare bob types is list. Members, alphabetical in help:
color NAME HUE|autohide NAME…list [-a/--all] [-F/--fresh] [-f/--format human|json] [-H/--hidden] [-n/--limit N] [-q/--query Q]rename OLD NEW [-d/--dry-run] [-f/--format human|json] [-m/--merge] [-o/--offline]split OLD (-m/--map FILE | -t/--template) [-d/--dry-run] [-f/--format human|json] [-o/--offline]suggest TARGET [-b/--bob-dir DIR] [-f/--format human|json]unhide NAME…
List defaults: visible types used in the last 90 days or today, newest
first, plus every visible configured type even when never used (sorted
last, showing never and 0 days). -a shows every type and flags
hidden ones. -H shows only hidden types. -q ranks per PT7 groups B
then D.
Human list:
TYPES 81 known · 4 today · 2 hidden · last 90 days
■ BOB today 26 days magenta
■ JOB Oct 23 24 days aqua · area
■ BLOG Oct 23 14 days red · pinned
□ PLAN today 51 days muted · exempt
hint SASE and BLOG share orange and met on 6 days → bob types color BLOG red
With a vocabulary configured, the header reads
6 configured · 81 known · …, configured rows show blue · config, and
unconfigured rows show ◌ SNACK … muted · not one of your types.
color on a type whose config sets a color refuses with config_color
and writes nothing to the journal.
Shared vectors¶
tests/fixtures/pomodoro_themes/vectors.json is the single source. Sections:
components, hue (including the areas and configured inputs), fold,
rank, similar, menu, literal, suggest, task_field,
preselect, agree, retype, place, and relocate.
CLI tests consume the menu and literal sections, including
capture dry-run round trips; Rust tests consume the routing sections,
including mini-vault suggest cases with expected JSON and warnings.
bob-plugins copies the file byte-for-byte and runs the same hue, fold, and
ranking vectors in JavaScript; the suggest and routing vectors are reserved
for the later JavaScript mirror.