# 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 + DECKS` is one name with two components, not two Pomodoro names.
- **Component:** one `+`-separated part of a Pomodoro name. Its key is `normalize_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 themes` is the permanent hidden CLI alias for `bob types`. The canonical configured vocabulary and task field use `types:` 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.md` and nothing else. Not `_templates/`, `done/`, `_conflicts/`, or `*_done.md` / `*_poms.md` siblings. Read column-0 entries in `## Pomodoros`, open and closed, through `capture_pomodoros::scan`. Fenced lines are skipped.
- **Per-type fields:** `key`, `name`, `first_used`, `last_used`, `days_used`, and `entries`. 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.json` and `known-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>.json` view 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-complete` and `capture-parse` stay within +5 ms p95 of today's baseline on apollo against the live vault. `bob types list` uses 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` (`hue` is a slot name, or `null` to unpin), and `theme.rename`. `via` names the client: `bob-cli`, `bob-mac-capture`, or a plugin id.
- **Fold:** read every `*.jsonl` file; 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`, and `rename` append under `bob_sync.lock` (wait at most 3 s; a busy lock is `lock_busy`). `bob types split` rewrites daily notes under the same lock and writes no journal event. Each event is one `O_APPEND` write. The directory is created on first use.
- **Allowlist:** the vault tracks `.bob/themes/*.jsonl` (`!/.bob/themes/*.jsonl` in `.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](<https://bobs-org.github.io/vault-git-sync/#task-type-journal>).

## PT5 — Hue rule

In order:

1. A configured color from the `themes:` block wins (`hue_source: "config"`).
2. Else a journal pin wins (`hue_source: "pin"`).
3. Else a key in normalized `plan.exempt` is muted: `hue: null`, `hue_source: "exempt"` (PLAN by default).
4. 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"`.
5. Else a key equal to a **top-level area note's stem** (an area whose `parent` is `[[area]]`) wears that area's resolved hue (`hue_source: "area"`): a frontmatter `color:` slot, else the stem hash.
6. 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`, macOS `systemOrange`) 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 no `NO_COLOR`). An exempt type gets `□`; an unconfigured type gets `◌` (dim). `bob types list` already 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. Under `NO_COLOR` the glyph and the `not configured` text 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`, and `exempt` badges carry the meaning under `NO_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:

1. **A:** Today rows that match the query, in today's order.
2. **B:** Recent exact and prefix matches. Sort exact first, then `last_used` descending, `days_used` descending, then name.
3. **C:** The create row, only when the query is creatable and no A or B row is an exact match.
4. **D:** Recent substring matches, in B's order.
5. **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`) reports `sessions`, `defaults`, and `task_themes` separately and lists the default-bearing notes. JSON is additive; `entries` remains the session count.
- **NEW** must be a valid single type. A NEW containing `+` is `invalid_name`.
- **Vault selection:** before taking a lock, an online write requires `-b/--bob-dir` and `BOB_DIR` (default `~/bob`) to resolve to the same directory. A mismatch or unresolved path refuses with `vault_mismatch`. Set `BOB_DIR` to 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 with `stale_note`). Write each file atomically, then append `theme.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 is `git 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 in `plan.exempt`, `plan.inventory_labels`, `plan.reading_theme`, a Stats metric filter, or the `themes:` 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"; a `plan.reading_theme` OLD reads "OLD is plan.reading\_theme; set plan.reading\_theme to NEW first"), plus `lock_busy`, `dirty_paths` (offline with dirty targets), `not_a_worktree`, `vault_mismatch`, `sync_failed`, `verify_failed`, and `stale_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 `warnings` entry (`theme_not_configured` message) to the preview and result; human output marks it `◌ NEW not one of your types`. A `--merge` of two configured types notes that OLD stays in `themes:` until you remove it.
- **Reappearance:** if OLD's spelling reappears after a rename event (an offline host, muscle memory), `bob types list` reports it in `renamed_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: a `theme.rename` whose `to` is the key, or a `theme.color` (pin or unpin) or `theme.unhide` naming the key. A later `theme.hide` does 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 becomes `task_type:`, keeping value bytes and quote style (including `off`). When `task_type:` already exists, the legacy line is removed only if its value matches; otherwise the note is reported as `conflict` and left alone.
- **Never touched:** closed tasks, `done/`, repair-state lines, prose, fenced examples, `.bob/`, and `_conflicts/`. Repair lines are listed as `needs_repair` (`duplicate` or `invalid`).
- **Vault selection:** the same online identity check as rename. A mismatch refuses with `vault_mismatch`. Previews and `--offline` writes 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 with `stale_note`). Write each file atomically. Verify that every changed line differs only in the key span, or is a removed matching `theme:` default. Make one commit, `bob types migrate: N fields, M defaults`, then post-sync. Undo is `git revert`. When nothing is legacy, refuse with `unchanged` (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 include `mode`, `commit`, and `sync`.

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, then `DATE<TAB>REF<TAB>TYPE<TAB>NOTE`. DATE is `YYYY-MM-DD`. REF is `LINE: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 json` emits `{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 are `DATE<TAB>REF<TAB>TYPE[<TAB>NOTE]`. Blank lines and lines starting with `#` are skipped; NOTE is ignored. Refuse with `invalid_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 `+` is `invalid_name`. Resolve each row with `PomodoroScan::by_ref`. `Stale` or `Ambiguous`, or an entry with no OLD component, is `stale_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 as `unmapped`. If every row is a keep, refuse with `unchanged`. TYPE may not exist yet (`new` in the preview). Targets in normalized `plan.exempt` are `exempt`. An unconfigured TYPE adds a `warnings` entry and human output marks it `◌ not one of your types`.
- **Writing:** `-d` prints the plan and changes nothing. A write runs through the shared vault-rewrite transaction (`command_name` `bob 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 a `split_leaves_task_themes` warning with those leftover counts. The commit subject is `bob 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 is `git revert`.
- **Refusals:** `unknown_theme`, `invalid_name`, `unchanged`, `invalid_map`, `stale_entry`, plus `lock_busy`, `dirty_paths`, `not_a_worktree`, `vault_mismatch`, `sync_failed`, `verify_failed`, and `stale_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. `auto` and 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: `types` must 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 offending `types.NAME`. Setting both `types:` and `themes:` is also invalid and drops the vocabulary.
- **Where an invalid block is reported.** Never fatal: `bob types list` carries it as a `warnings` entry (yellow in human output), `bob plan` reports the `themes_config_invalid` lint, and `bob capture` prints a plain warning string. `bob types list` never 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, every `ThemeRef` and every `themes list` row carries `configured: true|false`. When none are configured, the field is absent. `known` is also true for configured types with no history. Inventory labels are storage, not types: they never warn, are never muted as unconfigured, and report `configured: 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_to` draws 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_configured` lint), pills and swatches everywhere, and `bob types list` hints.
- **`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`, no `today`; human output shows `never` and `0 days`, sorted last). The header reads `6 configured · 76 known · …`. Human meta labels read `blue · config` for config colors, today's labels (`aqua`, `red · pinned`, `muted · exempt`, `aqua · area`) for configured `auto` types, and `muted · not one of your types` with the `◌` swatch for the rest. JSON adds `counts.configured`, top-level `configured_themes` (`[{key, name,   color}]`, color a slot or null, in config order), per-row `configured`, and `hue_source` values `config` and `unconfigured`. `theme_hue_collision` skips config-colored and muted types. `theme_not_configured` hints 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 with `config_color` (exit 1, no journal write): `BUILD's color comes from themes: in ~/.config/bob/config.yml (yellow)`, hint `edit themes.BUILD there, or set it to auto to pin colors with bob   themes color`. Configured `auto` types 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 means `invalid`. Repair falls through to inheritance, carries `repair: "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:

1. **Task field.** Source `task`, label `this task`.
2. **Reading task.** An exact `#ref` token, or a trailing block ID `ref` or `ref-*` (rereads included). Ordinary annotation follow-up tasks do not qualify. Uses `plan.reading_type` (default `READ`); `off` skips this step. `plan.reading_theme` is a legacy alias. Source `reading`, label `reading task`.
3. **Note default.** The first valid default on the residence chain. Source `note`, label `from <lowercase declaring stem>`. A set `[root:: X]` field starts this walk at X, so a daily `^gtd` row carrying `[root:: gtd_daily]` suggests `gtd_daily`'s default (`task_type: PLAN`).
4. **Global default.** `plan.default_type`, when set to a type name. `off`, `null`, or unset skips this step. Source `default`, label `default type`. A name outside `types:` still applies and adds a `default_type_not_configured` lint to `bob plan`.
5. 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.

1. D is a container (no type components): the task keeps its type, and capture warns `LATER is a label · tasks keep their own types` or `SNACK isn't one of your types; tasks here keep their own types`.
2. The task's type is already a component of D: consistent, nothing written.
3. X equals the inherited type: clear every type field (no-op if absent).
4. 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 warning `SNACK 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 `— SNACK` agrees). Drift that no gesture caused (hand edits, frontmatter or config edits, sync merges) is flagged and never silently repaired: a `type_mismatch` lint in `bob plan` plus 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 `#NAME` and `=name` always take the Enter path.
- **Placement.** Bob picks the Pomodoro for type T: an already-queued task keeps its queue (Ensure Next `@route+id` is the one exception, handled by `capture-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 `— T` placeholder at the existing named-creation insertion point. Unlike menus, placement may create entries for unknown or unconfigured names (with the `theme_not_configured` warning). With `plan.strict: true`, a creation that adds a type past `plan.max_themes` is 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: `— FIX` becomes `— BUILD`, `— BOB + FIX` becomes `— BOB + BUILD`); a running shared entry gains `+ X` (the timespan never changes). Otherwise the first planned entry `place(X)` would choose (skipping running entries) takes the move; else a sole-link planned entry renames in place; else a `— X` placeholder 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|auto`
- `hide 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.

## Related

- [Plan budget](<https://bobs-org.github.io/plan/#types>)
- [Daily Stats](<https://bobs-org.github.io/stats/index.md>)
- [Pomodoro homes](<https://bobs-org.github.io/pomodoro-homes/index.md>)
- [Vault Git sync](<https://bobs-org.github.io/vault-git-sync/index.md>)
- [Capture](<https://bobs-org.github.io/capture/index.md>)
