# Task dependency links

A task's prerequisites live as plain task dependency links on one managed `⛓️ **DEPENDS ON:**` first-child line. That line is the source of truth; the `[dependsOn::]` / `[id::]` fields are derived from it. Each open prerequisite that other open tasks depend on carries a generated `🧱 **BLOCKS:**` child listing those dependents (§14). Bob maintains that line from Depends-On edges and never reads it back. A Project Task (`^prj`) uses the same Depends-On line. While `^prj` has its own reason to be Blocked, every open task in its note is Blocked; that inheritance, `#prj_is_blocked`, and blocked-link consumption are in §13.

Directed, optionally labelled task relations live on `🪢 **LINKS:**` / `🪢 **LINKED FROM:**` lines beside these dependency lines. They are inert and never block anything; see [Task relations](<https://bobs-org.github.io/task-relations/index.md>).

This file is the contract every implementation cites. The Rust side is `src/native/task_dependencies/` (parser, formatter, link form, legacy children) plus the reconciliation step in `bob task reconcile` (formerly `bob task-status-hooks`, still accepted); the JavaScript mirrors are the Depends-On grammar in bob-navigation-hotkeys, the chip model in bob-ledger-tools, and the small recognisers in task-status-cycler and block-id-prompt. Each implementation copies the vector tables it needs from this file into its tests, citing the section in a comment. If an implementation disagrees with a vector, fix this doc first in the same change.

Navigation has two symmetric writers: the Depends on stage (§6) and the reverse Blocks stage (§15). Both write the dependent's Depends-On line; neither reads a BLOCKS line as input.

## 1\. Vocabulary

- **Task Dependency Link** (aka **task dep link**, **dep link**): a plain, never transcluded Task Link to a prerequisite, placed on its dependent's Depends-On line, including a Project Task's.
- **Depends-On line**: the single managed first-child line that holds a task's dep links.
- **Dependent**: the task that owns the line.
- **Prerequisite**: a link target.
- **Blocked** stays the name of the derived `[?]` state.
- **Project dependency**, **project reasons**, **resident task**, and **consumption** are defined in §13. Ordinary tasks, including ordinary tasks inside project notes, keep the terms above.
- **Blocks line**, **owner**, and **frozen line** are defined in §14.

## 2\. Grammar

Example:

```
- [?] #task Make appt w/ Rahway Hospital for CT scan! [dependsOn:: body__hospital-swarm,
  sase_bug_bash__e2e-sase-8v] ^rahway
  - ⛓️ **DEPENDS ON:** [[#^hospital-swarm]] • [[sase_bug_bash#^e2e-sase-8v]]
  - 🗓️ **SCHEDULE LOG**
```

### 2.1 Writer form

- `⛓️` (U+26D3 U+FE0F), a space, `**DEPENDS ON:**`, a space, then links joined by `•` (U+2022, with one space on each side).
- **No aliases, embeds, or strikes.**
- Existing links keep their order. New links are appended, so removing a link and adding it again moves it to the end. Never re-sort.
- **Position:** the first direct child, or the second if a `❌ **CANCEL LOG**` child exists.
- Reuse the task's existing child indent; otherwise use the parent's indent plus one tab.
- Preserve the note's line endings and its final-newline state.
- On `^prj`, the machine-owned `🧩 **Sub-projects:**` line follows the Depends-On line (§13.2); a BLOCKS line, when present, sits between them (§14.5).
- **Empty list:** delete the line and remove the field. There is no placeholder.

### 2.2 Reader tolerance

Writers canonicalise all of these:

- the emoji is missing, or is the legacy `🔗`; VS16 is optional;
- the label is `DEPENDENCIES` instead of `DEPENDS ON`;
- the separator is `·`, `,`, or whitespace instead of `•`;
- a link is aliased, struck, or `!`-embedded;
- the line is a direct child other than the first.

### 2.3 Parse algorithm

1. Find the block links first (the shared link scanner, which already handles aliases, `!`, `~~`, and `[[#^id]]`), then check what surrounds them. Never split on separators, because an alias can contain one.
2. After removing the block links and the separators between them, the line must hold exactly the emoji (or nothing), the label, and whitespace. Anything else on the line makes it **malformed**.
3. Only a direct child of a `#task` line counts. Fenced code, nested lines, and blockquoted lines never count: a blockquoted task cannot own a Depends-On line (DP29; nav refuses dependency gestures there).

### 2.4 Managed child lines

Machine-owned direct children of a `#task` are a closed registry. Every engine's Task Link, Pomodoro, section-title, tree-close, strike/restore, R8 legacy-child, and managed-line parsers must reject every other row. The MC vectors in §11.8 pin that isolation.

Neither managed dependency line is ever a Task Link. That covers Pomodoro promotion, consumption, successor anchors, the plan budget (Rust and JS), completable sightings, Pomodoro close / cleanup, and Task Link opening. Neither line is a tree-close or strike/restore target.

| Line | Owner | Writer | Slot | Recognisers that reject it |
| --- | --- | --- | --- | --- |
| `❌ **CANCEL LOG**` | any `#task` with a cancel history | nav cancel / decay cancel | first direct child | Task Link and Pomodoro scanners; section titles; Depends-On, BLOCKS, Sub-projects, Schedule Log, Work Log, and Rotten Log parsers; R8 legacy-child |
| `⛓️ **DEPENDS ON:**` | any `#task` with prerequisites | nav Depends on stage and Blocks stage (§15), capture `&` / `*` and §13.5.1 drops, reconcile R1–R10 | first direct child, or second after a first-child Cancel Log | Task Link and Pomodoro scanners; section titles; BLOCKS, Sub-projects, Schedule Log, Work Log, Rotten Log, and Cancel Log parsers; tree-close and strike/restore; R8 legacy-child |
| `🧱 **BLOCKS:**` | any open `#task` that other open tasks depend on (§14) | reconcile (authority); nav Depends-On writers; capture `&` / `*` and §13.5.1 drops | after the Depends-On subtree, else the Depends-On slot | same as Depends-On, plus the Depends-On parser (BK20) |
| `🧩 **Sub-projects:**` | an active project's `^prj` | `bob projects sync` | after the Depends-On and BLOCKS subtrees | Task Link and Pomodoro scanners; section titles; Depends-On, BLOCKS, Schedule Log, Work Log, Rotten Log, and Cancel Log parsers |
| `🪢 **LINKS:**` | any `#task` with outgoing relations | nav Links stage, capture `~` / `~~` ([task-relations.md](<https://bobs-org.github.io/task-relations/index.md>) §8) | after the last direct-child subtree of Cancel Log, Depends-On, BLOCKS, or Sub-projects, else first | Task Link and Pomodoro scanners; section titles; Depends-On, BLOCKS, Sub-projects, Schedule Log, Work Log, Rotten Log, and Cancel Log parsers; tree-close and strike/restore; R8 legacy-child |
| `🪢 **LINKED FROM:**` | any writable `#task` that other tasks relate to | reconcile (authority); nav and capture courtesy writes ([task-relations.md](<https://bobs-org.github.io/task-relations/index.md>) §6–§8) | after the LINKS subtree, else in the LINKS slot | same as LINKS, plus the LINKS parser |
| `🗓️ **SCHEDULE LOG**` | any `#task` with a schedule history | nav Task Card / Schedule, capture `$<N>` / `s:`, `bob task reroll` | after the managed-dependency lines (and Sub-projects on `^prj`) | Task Link and Pomodoro scanners; section titles; Depends-On, BLOCKS, Sub-projects, Cancel Log, Work Log, and Rotten Log parsers |
| `🛠️ **WORK LOG**` and its dated entries | any `#task` with work history | capture `x`, Alt+N, Ctrl+Shift+Enter unlink | after the managed-dependency lines (and Sub-projects on `^prj`) | Task Link and Pomodoro scanners; section titles; Depends-On, BLOCKS, Sub-projects, Cancel Log, Schedule Log, and Rotten Log parsers |
| `🥀 **ROTTEN LOG**` and its dated entries | any `#task` with a keep history | nav counted keep and decay-card Keep | appended as the last direct child on creation, then reused | Task Link and Pomodoro scanners; section titles; Depends-On, BLOCKS, Sub-projects, LINKS/LINKED FROM, Schedule Log, Work Log, and Cancel Log parsers; R8 legacy-child |

## 3\. Identity and link form

- **Prerequisite id.** Use the target's existing valid `[id::]` if it has one; it is never rewritten. Otherwise use the canonical `dependency_id(path, blockId)` (`note__path__blockid`), which the writer adds to the target. A target whose path cannot be encoded (spaces, dots) is refused **only when it has no `[id::]` yet**. Such a link is kept verbatim and warns (`unencodable_dependency_target`); it is never projected.
- **Link form.** Use the shortest form that is unambiguous under the hooks' resolver:
- `[[#^id]]` for a target in the same note;
- `[[basename#^id]]` when the basename is unique in the vault (case-insensitive);
- otherwise `[[dir/note#^id]]`, the full vault-relative path without `.md`. Links into `done/` always keep this explicit path form even when the basename is unique: basename links do not search `done/`, so the short form would stop resolving.
- **Field placement.** `[dependsOn:: a, b]` (comma-space) and `[id:: x]` go inside the Tasks suffix:
- to the right of the freshness group (`[fresh::] [refresh::] [keeps::] [unblocked::]`), because both Rust Tasks parsers stop at the first unknown key, and those four are run-extending non-Tasks keys;
- before a trailing `^block-id`;
- in Tasks key order (`id` before `dependsOn`).
- **Field order.** The field mirrors the line's order.

## 4\. Reconciliation (the hooks)

### 4.1 Scope and timing

- Only *open* dependents are reconciled: status types Todo, In Progress, and On Hold, which includes `[?]`. Closed tasks are never touched.
- The step runs on the whole vault on every run, before edges and Blocked derivation, inside the existing guarded write.
- The previous daily note snapshot is never written. A target that lives in the previous daily note and has no `[id::]` keeps its link verbatim and warns (`previous_daily_target`); it is never stamped and never projected.

### 4.2 The dependency set

The dependency set is the line's links followed by any **legacy children** (R8) not already on the line, deduplicated by (path, block id). `^prj` uses this same table: R1–R10, with no R0, no D3, and no R2 exception.

| \# | Situation | Resolution |
| --- | --- | --- |
| R1 | Well-formed line | `[dependsOn::]` := the ids of the set's resolved task targets, in set order. Targets without `[id::]` get one. Field ids not accounted for are dropped, reported in the projection detail, and warned about (`dependency_field_ids_dropped`), except breadcrumbs (R4). |
| R2 | No line, field present | **Adopt** the field ids not covered by legacy children: write a canonical line linking to the tasks that carry each id and have a `^block-id`. Ids that can't be adopted stay in the field and are warned about (`unadoptable_dependency_id`). |
| R3 | A link doesn't resolve | **Heal** it when exactly one scanned task has both the link's block id and one of the dependent's unaccounted field ids. Rewrite the link to that task's location in the shortest form, then apply R1. |
| R4 | A link doesn't resolve and can't be healed | Keep it verbatim and warn (`unresolved_dependency_link`). It never blocks. While any unresolved link remains, keep the dependent's unaccounted field ids as **heal breadcrumbs**, so a task that is cut now and pasted later still heals. |
| R5 | Resolves to a non-task block | Keep it and warn (`non_task_dependency`). It is not projected. |
| R6 | Points to its own task | Not projected; warn (`self_dependency`). |
| R7 | Cycle | Keep it (every member stays Blocked) and warn (`dependency_cycle`) with the path. |
| R8 | **Legacy child**: a direct child bullet whose only content is one block link (plain, `![[…]]`, `~~[[…]]~~`, or `~~![[…]]~~`) and whose resolved target's id (its `[id::]`, canonical id, or same-note bare block id) is in the field | Counts as a dep link during the legacy window. The hooks never rewrite it; the migration converts it. Plain sole links count too, so un-embedding a legacy child with `!` doesn't silently drop the dependency. Any other sole embed (for example a `#^ref`) is content, not an edge. |
| R9 | Label but no links, and no legacy children | Delete the line and remove the field. |
| R10 | Malformed line | No change for that dependent; warn (`malformed_dependency_line`). Projection outputs count as structural, so notes modified less than 2 s ago are deferred by the quiet interval. |

### 4.3 Refinements beyond the table

- **Canonicalise.** A well-formed but non-canonical line is rewritten to the writer form with the same targets: label and emoji variants, other separators, `!`, `~~`, and aliases.
- **Archive targets.** A link that resolves into `done/` through the existing archive catalog is a resolved, *closed* prerequisite. Its id is kept, it is never warned about, and it never blocks. Archive links keep the explicit `done/` path form from contract §3; it is the only form the hooks re-resolve, and it binds the JS canonicalisers too.
- **Removals.** Never infer a removal from a *missing* line (R2 adopts instead). Do infer removals from a *present* line.
- **No moves.** The hooks never move or reorder lines.
- **Output.** Report every change:
- `dependency_projection_updates`, entries of kind `dependent_field`, `target_id`, or `line_removed`;
- `adopted_dependency_lines`;
- `healed_dependency_links`;
- `canonicalized_dependency_lines`;
- `legacy_dependency_children` (a count, for deciding when to retire legacy reading);
- `dependency_warnings`, entries of `{kind, path, line, detail}`. Kinds are `unresolved_dependency_link`, `unadoptable_dependency_id`, `non_task_dependency`, `self_dependency`, `dependency_cycle`, `malformed_dependency_line`, `previous_daily_target`, `unencodable_dependency_target`, and `dependency_field_ids_dropped`.

## 5\. Dependency semantics

- **Blocked.** A task is `[?]` while any prerequisite is open (AND, finish-to-start), while its `scheduled` date is in the future, or while it lives in a blocked project (§13). Done and Cancelled prerequisites stop blocking.
- **Promotion.** Pomodoro roots promote their prerequisites to Next/In Progress along the reconciled set: line links plus R8 legacy children, plus a resident's inherited `^prj` Depends-On links (§13). Strongest rank wins, the walk is cycle-safe, and recovery uses the same edges. Dependency promotion never raises a `^prj` target into a lane; a direct Task Link raises `^prj` to Next like any other task. The walk continues through it to that project's own Depends-On links. Lanes are sticky, and they do not survive Blocked.
- **Consumption.** A Blocked task keeps no live Task Link under today's open Pomodoros. A gesture that would create or keep such a link promotes the task's open prerequisites (own plus inherited) along those edges and then drops the link, except the own-schedule pull-forward in §13 and capture's own-prerequisite drop in §13.5. `bob task reconcile` does the same for stale links. Capture consumption (`&`, `*`, and the link-guard Consume branches) matches by identity, keeps authored notes under a consumed link-only bullet, deletes only the Pomodoro entries this capture emptied, and reports `consumed_links[]` plus `removed` blocks (PDL8–PDL11, §13.5). Reconcile and plugins keep PDL7 (whole subtree removed).
- **Not edges:** `#^ref` embeds and archived targets.
- **Visibility.** An open prerequisite that other open tasks depend on carries a generated `🧱 **BLOCKS:**` child listing those dependents (§14). The line is never a dependency edge: deleting every BLOCKS line changes nothing about blocking, promotion, scheduling, successor offers, fields, or authored edges. Chips on it show what finishing the task would free.
- **Closing.** Closing a dependent **never** closes its prerequisites. Embedded-tree closes in capture `x` and the cycler ignore Depends-On lines.
- **History.** Closed prerequisites stay on the line as history and show as muted chips. Prerequisites never prevent closing a dependent by hand.
- **Adding a dependency** sets the dependent to `[?]` if the target is open. On `^prj`, every open resident becomes `[?]` too. If the dependent was `[*]` or `[/]`, an open target rises to at least that lane (`getDependencyPromotionStatus`); a `^prj` target is never raised. An add that leaves the dependent `[?]` also consumes the dependent's own live links in today's open Pomodoros, together with any emptied entry.
- **Adding a dependency from the prerequisite side.** Naming T from the blocker N writes the same Depends-On edge as adding N from T: T's `⛓️ **DEPENDS ON:**` line gains N, N's `🧱 **BLOCKS:**` line is the generated mirror, and T becomes `[?]` with the usual promotion, freshness, and consumption side effects. Capture `*note:block-id` is the CLI writer (`docs/capture.md`); the reverse Blocks stage (§15) is its Obsidian twin. The stage works from an existing N. Its lane follows the ordinary transfer and may become `[/]`, but N is never stamped for its target-ID, lane, or BLOCKS edits. A `^prj` N is never raised. Capture caps only a newly captured N at Next `[*]`. A `^prj` T whose project contains N (or a descendant of that project) is refused.
- **Removing a dependency** recovers the dependent to its derived rank *immediately* when no open prerequisite, no future `scheduled` date, and no remaining project reason remain. Otherwise it stays `[?]`. Removing the last project reason recovers residents that have no own reason. The hooks keep the final word.
- **Closing.** Closing a planned prerequisite links the dependents it fully unblocked into its slot (§12).

## 6\. The Depends on stage (navigation-hotkeys)

### 6.1 Entry points

| Cursor | Gesture | Edits |
| --- | --- | --- |
| A task line | Ctrl+Shift+P then `⇧B` on the Task Card, or Ctrl+Shift+Alt+B | this task's Blocks stage (§15) |
| A dedicated Task Link | Ctrl+Shift+P then `⇧B`, or Ctrl+Shift+Alt+B | focuses the linked note before opening its Blocks stage on the linked task (§15) |
| A task line, counted | `N<Ctrl+Shift+P>` then `⇧B`, or `N<Ctrl+Shift+Alt+B>` | these sources in order; each picked task gains every source as a prerequisite (§15) |
| A `#task` line | Ctrl+Shift+P then `b` on the Task Card (Blocked by), or Ctrl+Shift+B | this task; on `^prj`, the line plus resident side effects (§13) |
| Anywhere on a Depends-On line, including inside a link | Ctrl+Shift+P (skips the card and the property step) | the **owning** task, never the link under the cursor |
| A dedicated Task Link | Ctrl+Shift+P then `b`, or Ctrl+Shift+B | focuses the linked note's tab (opening it if needed) before opening the stage on the **linked** task, named in the title |
| A task line, counted | `N<Ctrl+Shift+P>` then `b`, or `N<Ctrl+Shift+B>` | this task plus the next N (existing add-to-all/remove-from-all, `k/n` badges, no Tab marks) |
| A chip | `＋` / hover `×` | opens the stage / removes that prerequisite |
| Anywhere | palette command **Edit task dependencies** (`edit-task-dependencies`, no default hotkey) | the task under the cursor |
| Prose with several links, or a selection spanning tasks | any | refused with a short reason |

Layout reuses the `bob-cnp` modal styling, with `CURRENT`, `RESULTS`, and `BLOCKED` sections, a search row, and a footer of `↑↓ navigate · ⇥ mark · ↵ toggle · esc dismiss`.

### 6.2 Pool

- Source: the Tasks plugin cache (`getTasks()` when `getState()` is `"Warm"`; Tasks 8.4.0 exposes `id` and `dependsOn` on each task). An authoritative Warm cache with zero tasks still counts as ready; open-buffer tasks participate and no vault fallback runs merely because the task array is empty.
- Open editor buffers override the cache for their notes, so unsaved edits count.
- If the cache isn't ready, the stage opens immediately from open buffers, then falls back to a vault scan that reads and prepares note snapshots in bounded chunks, yielding to the event loop between batches. The refresh is tied to a request generation: dismissal, leaving the dependency stage, or a newer request abandons obsolete work before painting, preserving the query, valid marks, and the complete parent set. Read failures are skipped without an unhandled rejection.
- Never read from disk on a keystroke; search and navigation perform no disk reads.

RESULTS rows include open tasks that pass the `#task` global filter, including `ref/`, inbox, Blocked, and `#hide` (`#hide` tasks are muted and ranked last). Excluded: daily notes (`YYYY/YYYYMMDD.md`), `done/`, `_templates`, `_generated`, `_conflicts` (matched as a path segment), dot-dirs, fenced code, and the dependent itself. CURRENT rows resolve against everything, including closed, archived, hidden, and missing targets, so any of them can still be removed.

### 6.3 Ranking

The stage ports `capture_link_tasks.rs::rank`:

- Every term must match. Each term scores its best tier across the cleaned description, `route:blockId`, block id, note route, and section heading: field prefix 3, word prefix 2, substring 1, in-order subsequence 0. Scores are summed.
- Ties keep canonical order: same note in document order, then In Progress, Next, Ready (by path, then line), then `#hide`.
- Blocked candidates render in their own BLOCKED section beneath RESULTS.
- BLOCKED badges name what blocks the candidate, counted from its own Depends-On line whether or not it carries a `^blockId`: `🔒 waits on N` only when N \>= 1 open prerequisites remain (the stage never shows `waits on 0`); with no open prerequisite, a future `[scheduled:: YYYY-MM-DD]` reads `🔒 scheduled YYYY-MM-DD`, and otherwise the row reads `🔒 blocked`.
- An empty query shows CURRENT, then open tasks in the same note, then the In Progress and Next lanes.
- At most about 60 rows render. Typing reaches the rest.
- Matched characters are highlighted.
- The row badge shows the block id, never the path-encoded id.

### 6.4 Keys and guards

| Key | Action |
| --- | --- |
| type | fuzzy search |
| ↑ / ↓, ^N / ^P | move |
| ↵ | **toggle** the highlighted row (add if absent, remove if present) and close; with marks, apply every mark |
| ⇥ | mark or unmark the row (`＋ add` / `− remove` / `＋ id`) and move down |
| Esc | cancel; writes nothing and allocates no ids |

Guarded rows are disabled and show their reason: the dependent itself; a cycle (`⟲`), checked on the graph *after* the whole batch, with the path in a tooltip (the graph includes implicit resident → `^prj` edges, and trialing a `^prj` add also trials resident → target for every open resident, §13); a `^prj` target inside the same project note (`inside this project`); a target with no `[id::]` whose path can't be encoded; a target or dependent that changed since the stage opened (refuse, then reopen fresh). Removing a link is always allowed.

### 6.5 Block ids

A `＋ id` row opens the existing block-ID stage when the change is applied. It is pre-filled from the prompt policy (`suggestPromptBlockId` in both prompt-owning plugins): the raw target headline seeds a compact slug (named phrases first, then the first three meaningful prose words, accents normalized, at most 32 characters), with uniqueness checked against the **target's** note (all blocks, plus pending batch reservations), so ↵ accepts it. A valid, free legacy `[id::]` is reused as-is (for example `flights`); an invalid or taken value — including a path-qualified value such as `Tasks__target` — falls through to generation. Collisions walk `stem-2`, `stem-3`, … past nine. A batch prompts for one target at a time. Highlighting a row never writes. Typing replaces the suggestion and Escape writes nothing. Project conversion's automatic IDs still use the existing `suggestBlockIdFromTask`; capture and successor minting (§11.7, §12) are unchanged.

### 6.6 One gesture writes

1. **Prepare cross-note targets first.** Add the `^id` and `[id::]` through the open editor if the note is open, otherwise with a preimage-checked `vault.process`.
2. **Commit the dependent's note** in **one editor transaction** (one Ctrl+Z): the line, the field, same-note target ids, status effects, folding of legacy children, and `[fresh:: today]` via `api.freshness.stampLine`. On `^prj` the same transaction also sets every open resident `[?]` with `#prj_is_blocked` when any added target is open, and transfers the strongest lane among `^prj` and its residents onto each open prerequisite (never onto a `^prj` target, §13). Nav never regroups lines between sections; reconcile does that within one interval.
3. **Consume Pomodoro links after a newly Blocked dependent.** The live links of every newly Blocked dependent — including `^prj` and its newly Blocked residents — are removed from today's open Pomodoros; any entry emptied by that cleanup is deleted in the same write when the daily note is the edited note and in a follow-up write otherwise. Counted writers are included. A cleanup failure shows the `Pomodoro    links not removed` chip and is never rolled back; reconcile repairs it.
4. **If preparation fails**, the dependent is untouched. An unused target id is acceptable; a link to an unprepared target is not.

Nav applies editor writes as line-level edits, never a whole-note replacement, so the cursor stays on the task line the gesture started on and the viewport does not move; the review-walk advance on a `]s` landing is the one intended exception.

Removing `^prj`'s last reason (the stage, Ctrl+D on the row, or api v3) recovers `^prj` and every resident without its own remaining reason through the gate in the same write: the tag is stripped and `[unblocked::]` is written on Ready recoveries. Hand edits to `^prj`'s Depends-On line keep the mirror's field behavior; resident side effects of hand edits are reconcile's.

### 6.7 Notices

- `⛓ Now waits on "File for unemployment" · Blocked`
- `⛓ Now waits on "File for unemployment" · Blocked · removed 1 Pomodoro link`
- `⛓ No longer waits on "Launch swarm…" · Ready again`
- `⛓ 2 added · 1 removed · Blocked (1 open)`
- `⛓ Project waits on "Fix LinkedIn now" · 2 tasks Blocked · removed 2 Pomodoro links`
- `⛓ Project no longer waits on "Fix LinkedIn now" · 2 tasks Ready again`

## 7\. Chips (bob-ledger-tools)

```
[?] Make appt w/ Rahway Hospital for CT scan!
    ⛓ depends on  ( ○ Launch swarm to find hospital )  ( ○ Run e2e on sase-8v ↗ sase_bug_bash )  ＋   waiting on 2
```

### 7.1 Chip anatomy

- a miniature checkbox showing the target's status symbol, coloured with the existing `--task-status-*` tokens (no new palette);
- the cleaned description, cut at about 40 characters;
- `↗ note` for a target in another note;
- a tooltip with the full text, note path, status name, and `scheduled` date.

### 7.2 States

| State | Look |
| --- | --- |
| Open, Next, In Progress, Blocked | coloured by status |
| Done | ✓, dimmed, struck text; more than three collapse to `✓×N` |
| Cancelled | muted ✕, visibly different from Done |
| Broken | dashed, `⚠ ^id not found` |
| Not a task | `⚠ not a task` |

**Summary:** `waiting on N` or `✓ all clear`. It is never written to Markdown. `N` counts open prerequisites only: a broken link (DC7) or a non-task block (DC8) never blocks per R4/R5 and never counts toward `N`, so a Ready task with only such links reads `✓ all clear` beside its ⚠ chips. The ⚠ chip is the signal for those; the summary tracks blocking.

### 7.3 Interaction

- Click opens the target (Mod-click opens a new tab).
- Hover shows the native page preview.
- Hovering a chip shows a `×` that removes that prerequisite. A trailing `＋` opens the stage. Both go through nav api v1 and are hidden when it is absent.
- Putting the cursor on the line reveals the raw Markdown. Source mode stays raw.

### 7.4 Implementation requirements

- Chips never change line height.
- Only visible ranges are processed, after a cheap `DEPENDS ON` prefilter. Ownership walks ancestors with line lookups; the note is never copied wholesale.
- Reading rows follow the same owning-line rules as Live Preview: the row's Depends-On line is owned by a `#task` list item (its parent item is the task, per DP30), outside blockquotes, and the row parses as `accept`/`empty` (never chips on DP16, DP19, DP20, DP29, or DP31).
- Each rendered list item maps by order to its own line: the k-th `li` in the rendered section is the k-th list-item line in the section range, with fenced lines skipped. Chip actions carry that order-mapped 0-based line (contract §9).
- Actions stay hidden when the item and line counts disagree, or when the mapped line is not the expected owned Depends-On line.
- Data comes only from the in-memory Tasks memo.
- `aria-label`s, visible focus, colour never the only signal, and reduced-motion support.
- Wraps between chips, never inside one, including at phone width.
- Without the plugin, the line still reads: `⛓️ DEPENDS ON: ^hospital-swarm • sase_bug_bash > ^e2e-sase-8v`.

BLOCKS rows reuse this chip family with a reverse index, a 🔑 last-blocker badge, and `frees N` summaries (§14.9).

## 8\. Other gestures

| Gesture | Behaviour |
| --- | --- |
| Ctrl+Shift+Alt+B or Task Card ⇧B | Opens the reverse Blocks stage on the source task; see §15 for entries, guards, previews, writes, and outcomes. |
| `!` / `N!` | Only toggles transclusion. Refused on a Depends-On line, with a notice pointing to Ctrl+Shift+P. |
| Ctrl+Enter on a link on the line | Closes or reopens the **target** only: root-only, no strike, no re-embed, no tree close. Dependents recover immediately. |
| Cycler strike/restore when a target closes or reopens elsewhere | Skips Depends-On lines. |
| Alt+\] / Alt+\[ on the line | Cycles the target of the link under the cursor. Never reformats the bullet. |
| Ctrl+Shift+Enter on the line | Refused, with a notice pointing to Ctrl+Shift+P. |
| Ctrl+D on the Depends on row | Deletes the line, the field, and any legacy children, with immediate recovery. |
| Hand edits in Obsidian | Once the cursor leaves the edited line (short debounce), nav applies R1/R9 to that task. Deleting the whole line clears the field. Malformed lines are left alone. |
| `bob capture` `&note:id` (and the Bob Mac Capture `&` picker) | Adds prerequisites to a new task or an explicit `@note+id` dependent through the staged capture writer: new links append in typed order (repeats are no-ops), legacy children fold and field-only dependencies are adopted, fields are derived, and §5 Blocked/promotion applies in the same batch. On `^prj`, `&` writes the Depends-On line and applies resident side effects (§13). It never removes or reorders links; removal stays with Ctrl+Shift+P and Ctrl+D. See `docs/capture.md`. |
| `bob capture` `*note:id` (and the Bob Mac Capture `*` picker) | Adds the captured (or explicitly selected) task as a prerequisite of an existing open task T: the edge is written on T's Depends-On line, N's BLOCKS line is the generated mirror, and T's live Task Links are consumed (§13.5, PDL8–PDL11). A new N is capped at Next. Existing owners, sub-bullets, and forced `--task`/`--route` mirror `&`. See `docs/capture.md`. |
| Ctrl+Shift+M, `task archive` | The line moves with its task. Same-note links inside a moved block whose target stayed behind gain the source note path. Moving open tasks **into** a blocked project sets them `[?]`, tags them `#prj_is_blocked`, promotes verifiably open prerequisites to the strongest moved lane, and removes their live links from today's open Pomodoros in the move write. Moving them **out** strips the tag and recovers those without own reasons, writing `[unblocked::]` when [freshness.md §5](<https://bobs-org.github.io/freshness/#5-who-stamps>) says to. Closed lines are never edited. |
| Ctrl+Alt+Shift+N (`create-project-note-from-task`) | Forward: the source's Depends-On line becomes the first child of `^prj`, and its inline `scheduled` stays on `^prj` (rebase same-note links to the source note; never seed that line as a Tasks row; skip the machine-owned Sub-projects child); the project lands blocked only for a carried open prerequisite or a future inline date. Reverse: the line becomes the restored task's first child and the date stays inline on its root task (links re-formed relative to the destination), with `#^prj` links repointed before Pomodoro cleanup so live links are consumed. `[?]` sources are accepted; blocked residents are tagged/recovered through the shared gate and live links are consumed when they enter a blocked project. Details in §13. |
| "Rewrite dependency navigation links" command, `migrate-dependency-bullets.mjs` | Deleted. They emit embeds. |

Gestures on a BLOCKS line — including the generated-line notice, refused `!` / Ctrl+Shift+Enter, and Ctrl+Shift+P acting on the owning task — are in §14.8.

## 9\. Plugin api v3 (navigation-hotkeys)

```
app.plugins.plugins["bob-navigation-hotkeys"].api = Object.freeze({
  version: 3,
  openDependencyStage(ref),            // ref: { path, line } — any line of the task block or its Depends-On line
  removeDependency(parentRef, target), // target: { path, blockId }
  claimReviewWalkCompletion(editor),   // task-status-cycler's landed checklist Ctrl+Enter hook
  reviewWalk: Object.freeze({
    version: 1,
    capture(editor),          // sync, never throws: null (not on a landing) | { busy: true } | frozen origin
    continue(origin, outcome), // async, never throws: resolves { ok, advanced, stopped }
  }),
  notice: Object.freeze({
    version: 1,
    showUnblocked(model),     // additive: renders the Unblocked notice card (§12.6); returns true when shown
  }),
  quickRoll: Object.freeze({
    version: 1,
    keyLabel,
    preview({ content | lines, line, tier }), // sync, never throws: frozen preview result
  }),
  successors: Object.freeze({
    version: 1,
    pick(model),              // Promise<{picks: [{key, destination?}]} | null>; never rejects; null = none (§12.13)
  }),
});
```

- `openDependencyStage` and `removeDependency` return Promises resolving to `{ ok, reason? }` and never throw.
- `ref.line` and `parentRef.line` are 0-based line indexes into the note's lines (the same indexing the editor and `findOwningTaskLine` use): the Depends-On line itself, or any line of the owning task block. Non-integer or out-of-range lines refuse with `invalid-ref` / `line-out-of-range`.
- `removeDependency` re-reads the dependent and refuses with a notice when it is stale, and refuses with `not-on-line` (never a silent `ok`) when the target is not on the dependent's line.
- `claimReviewWalkCompletion(editor)` is task-status-cycler's Ctrl+Enter hook. It synchronously returns `null` unless the cursor is on the PRE/POST row the review walk just landed on; otherwise it returns a Promise of `{ ok, reason? }` and never throws. See [freshness.md §6](<https://bobs-org.github.io/freshness/#6-review-ritual>).
- `reviewWalk.capture(editor)` returns `null` when the cursor is not on the row the walk just landed on (callers behave normally), `{ busy: true }` while a review gesture is in flight or settling (callers swallow the key and write nothing), and otherwise a frozen origin holding the gesture lock. `reviewWalk.continue(origin, outcome)` advances the walk exactly once when the origin is current and the outcome resolves the row, shows `outcome.notice` exactly once either way, and settles the lock. An `outcome` of `null` settles without advancing. Every captured origin is settled exactly once on every path. Callers feature-detect `api.version >= 3` with `reviewWalk.version >= 1` and keep today's behavior otherwise.
- `notice.showUnblocked(model)` renders the shared successor-offer notice model (§12.5) as the Unblocked card (§12.6) and returns `true` when shown. The member is additive: the api stays `version: 3` and callers feature-detect `api.notice?.version >= 1`, falling back to a plain text notice when it is absent.
- `successors.pick(model)` opens the successor-offer picker (§12.13) and resolves `{picks}` or `null`. The member is additive: the api stays `version: 3` and callers feature-detect `api.successors?.version >= 1`. Without it the cycler lists offers as `not pulled in` and writes no links. A second call while one picker is open resolves `null`. The null-plugin variant (after unload) resolves `null`.
- Additive `quickRoll` v1 namespace: `keyLabel` is the recommended-roll key label (`Alt+P`), and `preview({ content | lines, line, tier })` synchronously returns the frozen preview `{ ok: true, kind, line,   compact, detail }` or `{ ok: false, reason }`. `line` is a 0-based task line index; pass either note `content` or its `lines`, plus the review `tier`. PRE and POST checklist rows have no preview. The method never throws; a null plugin (after unload) returns `{ ok: false, reason: "unavailable" }`. This additive namespace leaves root `api.version` at 3; consumers feature-detect `api.quickRoll?.version >= 1` and keep today's behavior otherwise.
- Plugins never import each other's `main.js`. bob-ledger-tools feature-detects `api?.version >= 1`.
- Additive `inboxRoute` v1 namespace (nav 2.9.0+; `api.version` stays 3, consumers feature-detect `api.inboxRoute?.version >= 1`): `isInboxNote(path)` (sync, never throws: boolean), `prompt(request)` (Promise of `{ kind: "move", path, name } |   { kind: "stay" } | { kind: "cancel" }`, never rejects), `commit(request)` (Promise of `{ ok, name, count, notice, handledRefs, reason? }`, never rejects), where `request` carries `{ editor, path, line, additionalTaskCount?, actionLabel, expected?,   reservedBlockIds?, destinationPath? }`. With a null plugin (after unload), `isInboxNote` returns false, `prompt` resolves `{ kind: "stay" }`, and `commit` resolves `{ ok: false, reason: "unavailable" }`. block-id-prompt's Ctrl+Shift+Enter routes through this namespace and falls back to today's behavior when it is absent.

Additive blocks v1 namespace (api.version stays 3; consumers feature-detect api.blocks?.version \>= 1):

- blocks.openStage(ref) opens the Blocks stage for the owning task. ref is { path, line }, where line is any line of the task block, its Depends-On line, or its BLOCKS line.
- blocks.removeDependent(ownerRef, target) removes dependent T through the Blocks writer. target is { path, blockId }.
- Both return Promises resolving to { ok, reason? } and never throw. The refusal set that ships is:
- Both calls: invalid-ref, line-out-of-range, or note-not-open unless the active view is ref.path / ownerRef.path; no-owning-task when the line is not inside a task block.
- openStage only: picker-not-opened (the Task Card refused the entry) and open-failed.
- removeDependent only: not-a-dependent when T does not list the owner or the owner has no block id (it never silently succeeds); target-not-found when T's note cannot be read; the Depends-On parse reason of a malformed T line; the writer's own reason (`changed — reopen`, `guard`, or a planner reason such as `invalid-target` or `unexpected-preparation`); and remove-failed for an unexpected throw.
- The wrapper: unexpected-result when a call resolves something that is not { ok }, and api-failed when a call throws.
- With a null plugin, both resolve { ok: false, reason: "unavailable" }.

## 10\. Legacy window and rollout

- **Legacy readers.** Rust and nav read R8 legacy children for one release.
- **Touch migrates.** Any nav dependency write folds the dependent's legacy children into its line in the same transaction.
- **Chips and compat ship before any writer emits lines.** That is why `nav-model` depends on `chips` and `compat`.
- **Fleet before migration.** `fleet-rollout` installs everywhere before `vault-migrate`.
- **The MacBook.** Its cron is the only hooks runner. Until it is updated, it keeps Blocked correct from the fields but loses dependency promotion for parents that already have a line. Project-blocking rollout is in §13.11.

## 11\. Conformance vectors

Implementations copy the tables they need into their tests, citing the section in a comment. `line` below is the candidate Depends-On line on its own; `context` names what surrounds it. `verdict` is one of `accept(n)` (a line with n targets), `empty` (label but no links), `malformed`, or `not-a-line`. Project-dependency vectors (PDR, PDB, PDL, PDG, PDC, PDF, PDT) live in §13. Unblock (`[unblocked::]`) vectors (UB) live in [freshness.md §10b](<https://bobs-org.github.io/freshness/#10b-ub-unblocked-vectors>). BLOCKS parse, reconcile, write, and chip vectors (BK, BR, BW, BC) live in §14. Managed-child-line vectors (MC) live in §11.8.

### 11.1 DP — parse vectors

| \# | line | context | verdict |
| --- | --- | --- | --- |
| DP1 | `⛓️ **DEPENDS ON:** [[#^hospital-swarm]]` | first direct child of a `#task` | accept(1) |
| DP2 | `⛓️ **DEPENDS ON:** [[cash#^unemployment]]` | first direct child | accept(1) |
| DP3 | `⛓️ **DEPENDS ON:** [[money/cash#^unemployment]]` | first direct child, `cash.md` exists in two dirs | accept(1) |
| DP4 | `⛓️ **DEPENDS ON:** [[#^a]] • [[#^b]]` | first direct child | accept(2) |
| DP5 | `⛓️ **DEPENDS ON:** [[#^a\\\|b • c]]` | alias contains `•`; never split on separators | accept(1) |
| DP6 | `⛓️ **DEPENDS ON:** ~~[[#^a]]~~` | first direct child | accept(1), canonicalise |
| DP7 | `⛓️ **DEPENDS ON:** ![[#^a]]` | first direct child | accept(1), canonicalise |
| DP8 | `🔗 **DEPENDS ON:** [[#^a]]` | legacy emoji | accept(1), canonicalise |
| DP9 | `**DEPENDS ON:** [[#^a]]` | emoji missing | accept(1), canonicalise |
| DP10 | `⛓️ **DEPENDENCIES:** [[#^a]]` | legacy label | accept(1), canonicalise |
| DP11 | `⛓ **DEPENDS ON:** [[#^a]]` | missing VS16 | accept(1), canonicalise |
| DP12 | `⛓️ **DEPENDS ON:** [[#^a]] · [[#^b]]` | `·` separator | accept(2), canonicalise |
| DP13 | `⛓️ **DEPENDS ON:** [[#^a]], [[#^b]]` | `,` separator | accept(2), canonicalise |
| DP14 | `⛓️ **DEPENDS ON:** [[#^a]] [[#^b]]` | whitespace separator | accept(2), canonicalise |
| DP15 | `⛓️ **DEPENDS ON:** [[#^a` | half-typed link | malformed |
| DP16 | `⛓️ **DEPENDS ON:** [[#^a]] needs review` | trailing prose | malformed |
| DP17 | `⛓️ **DEPENDS ON:**` | label only | empty (R9) |
| DP18 | `⛓️ **DEPENDS ON:** [[#^a]]` | inside fenced code | not-a-line |
| DP19 | `⛓️ **DEPENDS ON:** [[#^a]]` | grandchild (nested two levels) | not-a-line |
| DP20 | `⛓️ **DEPENDS ON:** [[#^a]]` | the line itself hangs directly off a Work Log entry (its parent is the entry, not a `#task`) | not-a-line |
| DP21 | `⛓️ **DEPENDS ON:** [[#^a]]` | third direct child, after two prose children | accept(1) |
| DP22 | `⛓️ **DEPENDS ON:** [[#^a\\\|swarm]]` | aliased link | accept(1), canonicalise |
| DP23 | `⛓️ **DEPENDS ON:** [[note]]` | bare note link, no block id | malformed |
| DP24 | `🔗️ **DEPENDS ON:** [[#^a]]` | link emoji with VS16 | not-a-line |
| DP25 | `⛓️ **DEPENDS ON:** [[note#Heading]]` | heading link, no block id | malformed |
| DP26 | `⛓️ **DEPENDS ON:** • ,` | label followed only by separators | malformed |
| DP27 | `⛓️ **depends on:** [[#^a]]` | lowercase label | not-a-line |
| DP28 | `⛓️ **DEPENDS ON:** [[#^a]]` | no list marker | not-a-line |
| DP29 | `> - ⛓️ **DEPENDS ON:** [[#^a]]` | blockquoted line: every recogniser rejects it and no writer round-trips it | not-a-line |
| DP30 | `⛓️ **DEPENDS ON:** [[#^a]]` | first direct child of a `#task` which is itself nested under a Work Log entry (unlike DP20, the line's owner is a real task) | accept(1) |
| DP31 | `⛓️ **DEPENDS ON:** needs review` | prose-only line: the label is followed by prose with no link | malformed |

### 11.2 DW — write vectors

`→` separates before and after. `field` is the task line's `[dependsOn::]` value (`—` means absent).

| \# | operation | before | after |
| --- | --- | --- | --- |
| DW1 | create as first child | task with a `🗓️ **SCHEDULE LOG**` child, no line, field `—` | line inserted before the log child; field set |
| DW2 | create after Cancel Log | task with `❌ **CANCEL LOG**` then a prose child | line inserted second, after the Cancel Log |
| DW3 | append | line `[[#^a]]`, field `id-a` | line `[[#^a]] • [[#^b]]`, field `id-a, id-b` |
| DW4 | remove the middle link | line `a • b • c` | line `a • c`, field drops `id-b` |
| DW5 | remove the last link | line `[[#^a]]`, field `id-a` | line deleted, field removed |
| DW6 | re-add moves to the end | line `a • b`, re-add `a` | line `b • a`, field `id-b, id-a` |
| DW7 | field order mirrors the line | line `b • a` | field `id-b, id-a` |
| DW8 | tab indent | parent indented with a tab | child line uses one deeper tab |
| DW9 | space indent | parent indented with two spaces | child line reuses the task's existing child indent |
| DW10 | CRLF preserved | note uses CRLF | inserted line uses CRLF |
| DW11 | no final newline preserved | note has no trailing newline | edit adds none |
| DW12 | same-note link form | target in the same note | `[[#^block-id]]` |
| DW13 | unique basename link form | target in `cash.md`, unique in the vault | `[[cash#^block-id]]`, no path |
| DW14 | ambiguous basename link form | `cash.md` exists in two dirs | `[[money/cash#^block-id]]` |
| DW15 | existing `[id::]` preferred | target has `[id:: custom]` and an encodable path | field uses `custom`, never rewritten |
| DW16 | unencodable path refused | target path has a space, target has no `[id::]` | write refused with a reason |
| DW17 | field placement | task line has `[fresh:: 2026-10-02]` and `^task-id` | `[dependsOn:: …]` lands right of `fresh`, before `^task-id`, `id` before `dependsOn` |
| DW18 | fold legacy children | dependent has a line plus a legacy `![[…]]` child | one transaction folds the child into the line |
| DW19 | canonicalise variants | any of DP6–DP14 | writer form with the same targets |

### 11.3 DR — reconcile vectors

Each vector names the rule under test. Vectors marked `warn` also produce a `dependency_warnings` entry of the named kind.

| \# | rule | situation | outcome |
| --- | --- | --- | --- |
| DR1 | R1 | well-formed line, two resolved open targets | field set to both ids in line order; warn-free |
| DR2 | R1 | well-formed line, one target missing `[id::]` | target gains `[id::]`; field set (`target_id`) |
| DR3 | R1 | well-formed line, field holds a stale third id | stale id dropped (`dependency_field_ids_dropped`), warn-free otherwise |
| DR4 | R2 | no line, field with two adoptable ids | canonical line written (`adopted_dependency_lines`) |
| DR5 | R2 | no line, field holds an id with no `^block-id` anywhere | id stays in the field; warn `unadoptable_dependency_id` |
| DR6 | R3 | link `[[old#^x]]` unresolvable; exactly one scanned task has block id `x` and an unaccounted field id | link rewritten to the shortest form (`healed_dependency_links`), then R1 |
| DR7 | R4 | link unresolvable and unhealable | link kept verbatim, never blocks; warn `unresolved_dependency_link` |
| DR8 | R4 | DR7's note, second run with the target still absent | unaccounted field ids kept as heal breadcrumbs |
| DR9 | R4 | DR8's note after the target is pasted back | link heals per R3 |
| DR10 | R5 | link resolves to a non-task block | link kept, not projected; warn `non_task_dependency` |
| DR11 | R6 | link points to its own task | not projected; warn `self_dependency` |
| DR12 | R7 | two tasks link to each other | both stay Blocked; warn `dependency_cycle` with the path |
| DR13 | R8 | direct child `![[cash#^unemployment]]`, id in the field | counts as a dep link; child never rewritten |
| DR14 | R8 | direct child plain `[[cash#^unemployment]]` (un-embedded), id in the field | still counts as a dep link |
| DR15 | R8 | direct child `![[#^ref]]` reading embed | content, not an edge |
| DR16 | R9 | label-only line, no legacy children | line deleted, field removed (`line_removed`) |
| DR17 | R10 | half-typed line (DP15) | no change; warn `malformed_dependency_line` |
| DR18 | scope | closed (`[x]`) dependent with a stale line | untouched |
| DR19 | archive | link resolves into `done/` | id kept, never warned, never blocks |
| DR20 | R2+R8 | field-only dependent with a legacy child covering one of two field ids | adoption writes a line for the uncovered id only |
| DR21 | edges | `#^ref` embed under a Pomodoro root | not a promotion edge |
| DR22 | idempotence | second run over a reconciled vault | zero changes |

### 11.4 DK — ranking vectors

Abstract candidates, in canonical order:

| key | text | route | blockId | section |
| --- | --- | --- | --- | --- |
| A | File for unemployment | cash | unemployment | Money |
| B | Call unemployment office | cash | — | Money |
| C | Dispute North Face jacket | cash | — | — |
| D | Launch swarm to find hospital | body | hospital-swarm | Health |
| E | Run e2e on sase-8v | sase\_bug\_bash | e2e-sase-8v | Bugs |

Each query's terms all match (AND); a term scores its best tier over description, `route:blockId`, block id, route, and section (3 / 2 / 1 / 0); scores sum; ties keep canonical order; an empty query keeps canonical order. `rank` in `src/native/capture_link_tasks.rs` runs these vectors verbatim.

| \# | query | result | exercises |
| --- | --- | --- | --- |
| DK1 | `unemp` | A, B | field prefix (3) outranks word prefix (2) |
| DK2 | `cash:un` | A | `route:blockId` field prefix |
| DK3 | `face` | C | word prefix (2) |
| DK4 | `pit` | D | substring (1) inside `hospital` |
| DK5 | `uof` | B, C, D | in-order subsequence (0); ties keep canonical order |
| DK6 | `e2e` | E | block-id field prefix (3) |
| DK7 | `cash unemp` | A, B | AND across terms; 3+3 outranks 3+2 |
| DK8 | *(empty)* | A, B, C, D, E | empty query keeps canonical order |

### 11.5 DC — chip model vectors

`lookup` maps `(path, blockId)` to a task or to nothing. `noteLabel` is `↗ note` for a cross-note target, else absent. Text cuts at about 40 characters.

| \# | target | chips | summary |
| --- | --- | --- | --- |
| DC1 | open Todo | `○` coloured Todo, full text | `waiting on 1` |
| DC2 | Next | `*` coloured Next | `waiting on 1` |
| DC3 | In Progress | `/` coloured In Progress | `waiting on 1` |
| DC4 | Blocked | `?` coloured Blocked | `waiting on 1` |
| DC5 | Done | `✓`, dimmed, struck text | `✓ all clear` (with DC1 also present: `waiting on 1`) |
| DC6 | Cancelled | muted `✕`, visibly different from Done | per remaining open count |
| DC7 | missing `(path, blockId)` | dashed, `⚠ ^id not found` | never blocks, never counts as waiting |
| DC8 | resolves to a non-task block | `⚠ not a task` | never blocks, never counts as waiting |
| DC9 | cross-note target | chip plus `↗ sase_bug_bash` | `waiting on N` |
| DC10 | four Done targets | `✓×4` collapsed | `✓ all clear` |
| DC11 | no open targets, one Done | `✓` chip | `✓ all clear` |
| DC12 | 60-character description | text cut at about 40 characters, full text in the tooltip | `waiting on 1` |

### 11.6 SL — successor-link vectors

Outcomes of the shared successor-offer rule (§12). A row that expects a link means the task is **offered** and **linked when picked**. Tests pass a select-all selection unless a row says otherwise. Rust tests run them through capture (`!` and close); cycler tests run them through the pure helpers. New offer-only cases live in the SO table (§12.12).

| \# | Situation | Outcome |
| --- | --- | --- |
| SL1 | P linked in the running entry; D `[?]` depends only on P | offered (`hand_off`); when picked, link right after P's bullet subtree; D `[?]`→`[*]`; `unblocked_by: [P]` |
| SL2 | P linked in queued FIX while BOB runs; `!` retirement moves P's struck link into BOB | when picked, D at P's vacated FIX position; FIX not removed as empty |
| SL3 | D also depends on open Q | no offer; `still_blocked{waits_on, 1}`; D stays `[?]` |
| SL4 | D's only remaining blocker is a future `scheduled` | no offer; `still_blocked{scheduled}`; schedule untouched |
| SL5 | D already has a live link under another open entry | no offer (`already_planned`); `[?]`→`[*]`; existing link not moved |
| SL6 | D's only links today are struck or under closed entries | offered as in SL1 |
| SL7 | `x!` closes BOB; nothing carried; FIX follows | when picked, `- [ ] () — BOB` created right after BOB holding only D; `entry_created`, `next_up`; `next_pomodoro` names it |
| SL8 | `x` close with carried links; an embed P completes | when picked, D appended after the carried lines in the continuation |
| SL9 | P has no live link today | offered at the implicit destination (today's single open timed entry, else the first open entry). Two open timed entries, no day file, no `## Pomodoros`, or no open entry → recover, `no_destination`, no offer |
| SL10 | D has no `^block-id` | when picked, `^<mint>` appended (SB rule); `block_id_created: true`; `block_id` is the mint. Unpicked rows never mint |
| SL11 | D is `^prj` / carries `#hide` | recover only; `project_task` / `hidden` (except §12.10) |
| SL12 | One gesture closes P1 and P2; D depends on both | offered once; when picked, linked after the earlier anchor; `unblocked_by: [P1, P2]` |
| SL13 | Chain P → D → E | D offered; E untouched and not reported (picking D never completes D) |
| SL14 | D's basename is ambiguous | when picked, `[[dir/note#^id]]` |
| SL15 | D is a stale `[ ]` whose only prerequisite was P | offered even if status is unchanged; when picked, `[ ]`→`[*]` |
| SL16 | No task in C carries `[id::]` | gate: no lookup, no extra reads; empty arrays; `checked` |
| SL17 | Six successors from one gesture | **retired.** The breaker is deleted; all six are offered with nothing pre-checked (SO2) |
| SL18 | `plan.link_unblocked: false` | recovery to derived rank; no offers, links, or mints; `disabled` |
| SL19 | The same close re-applied (already Done) / a double press | no writes, no rows |
| SL20 | Draft `!P` then `!D` | the first item's offer for D is dropped (net) |
| SL21 | Cancel, reopen, raw checkbox, hooks run, `x*` park | never offer, never link |
| SL22 | Ctrl+Enter on a Depends-On line | unchanged (nothing) |
| SL23 | P closes as a subtask of an embedded root R planned today | P anchors at R's link (`inherit`) |
| SL24 | Close creates no continuation; a later open entry named BOB exists | when picked, D appended to that BOB entry; `entry_created: false` |
| SL25 | D already `[*]` and already planned | nothing written, no row |
| SL26 | D lives in an inbox file | offered; when picked, linked; `inbox: true` |
| SL27 | `x~K` drops D's link in the same close that completes P | D's offer is removed (`already_planned`); recovers to `[ ]` |
| SL28 | D lives in today's day file | when picked, the link names the note (`[[20261009#^id]]`), never `[[#^id]]` |

### 11.7 SB — successor block-ID vectors

Each row feeds the raw task line through the successor mint pipeline — the body after the status box, cleaned with the default `#task` global filter — then through `mint_block_id` with the note's used IDs (`src/native/capture_block_ids.rs`, `successor_mints_follow_the_pinned_vectors`).

| \# | raw task line | used IDs in the note | mint |
| --- | --- | --- | --- |
| SB1 | `- [?] #task Renew the library books` | — | `renew-library-books` |
| SB2 | `- [?] #task Fix apollo machine` | — | `fix-apollo-machine` |
| SB3 | ``- [?] #task Add support for new `%hold` directive`` | — | `hold` |
| SB4 | `- [?] #task Review [[sase#^fix-apollo\\|apollo fix]] notes` | — | `apollo-fix` |
| SB5 | `- [?] #task Book flights [scheduled:: 2026-10-13] #task` | — | `book-flights` |
| SB6 | `- [?] #task Book flights [scheduled:: 2026-10-13] #task` | `book-flights` | `book-flights-2` |
| SB7 | `- [?] #task Fix it` | `fix`, `fix-2` … `fix-9` | `task` |
| SB8 | `- [?] #task é🚀` | — | `task` |
| SB9 | `- [?] #task é🚀` | `task`, `task-2` | `task-3` |

SB1 is plain prose. SB2 drops the leading verb into the second suggestion (`apollo-machine`) and mints the first. SB3 mints the backticked phrase. SB4 mints the wikilink alias, not the target. SB5 strips the inline field and both `#task` tags. SB6 takes the `-2` suffix on collision. SB7 falls back to `task` when every suggestion and every `-2`…`-9` suffix is taken. SB8 falls back on non-ASCII text with no words. SB9 walks the `task-N` fallback past taken IDs.

### 11.8 MC — managed child lines

Outcomes of the registry in §2.4. Copy these into every engine's tests.

| \# | situation | outcome |
| --- | --- | --- |
| MC1 | a task nested under an open Pomodoro entry has a BLOCKS line | no Task Link: no promotion, consumption, successor anchor, plan-budget link, completable sighting, or close / cleanup target (Rust and JS) |
| MC2 | the same with a Depends-On line | same (fixes the latent miscount) |
| MC3 | `🧱 **BLOCKS:** ![[#^d]]` under T; T closes by capture `x` or Ctrl+Enter | D is never closed (tree close skips both lines) |
| MC4 | T closes or reopens elsewhere | cycler strike/restore never touches a BLOCKS line |
| MC5 | `!`, Ctrl+Shift+Enter on a BLOCKS line | refused with the generated-line notice |
| MC6 | R8 legacy-child recogniser on a BLOCKS line | not a legacy child |
| MC7 | section titles, Schedule / Cancel / Work / Rotten Log, and Sub-projects recognisers | never claim a BLOCKS line; the BLOCKS parser never claims theirs |
| MC8 | `bob projects sync` on `^prj` with Depends-On and BLOCKS | marker after both subtrees; a marker before BLOCKS moves after it |
| MC9 | `bob task archive` of a closed T with a frozen `[[#^d]]` | link qualified to `[[note#^d]]` |
| MC10 | a task under an open Pomodoro entry has LINKS / LINKED FROM lines | no Task Link effects: promotion, consumption, successor anchor, plan budget, completable sighting, close / cleanup (Rust and JS) |
| MC11 | `🪢 **LINKS:** ![[#^t]]` under S; S closes via capture `x` or Ctrl+Enter | T never closed (tree close skips relation lines) |
| MC12 | T closes or reopens elsewhere | strike / restore never touches relation lines |
| MC13 | `!` / Ctrl+Shift+Enter on either line | refused with the [task-relations.md](<https://bobs-org.github.io/task-relations/#9-gestures-on-relation-lines>) §9 notices |
| MC14 | Ctrl+Enter / Alt+\] on a link in either line | acts on the linked task root-only, without reformatting the bullet |
| MC15 | R8 and the section-title, Schedule / Cancel / Work / Rotten Log, Sub-projects, Depends-On, and BLOCKS recognisers | never claim relation lines; relation parsers never claim theirs |
| MC16 | `bob projects sync`; nav project conversion | never moves or anchors on relation lines; conversion keeps LINKS with its task and drops LINKED FROM |
| MC17 | `bob task archive`, `bob task move`, Ctrl+Shift+M | relation-line links rebased; LINKS moves with its task; never two LINKS lines |
| MC18 | a task with a `🥀 **ROTTEN LOG**` under an open Pomodoro entry | never a Task Link or section: no promotion, consumption, successor anchor, plan-budget link, completable sighting, or close / cleanup target (Rust and JS) |
| MC19 | section-title, Schedule / Cancel / Work Log, Sub-projects, Depends-On, BLOCKS, LINKS/LINKED FROM, and R8 legacy-child recognisers on a Rotten Log | never claim it; the Rotten Log parser never claims their lines |
| MC20 | `bob randomize` / `bob task reroll` on a task with only a Work Log or only a Rotten Log | never files a schedule entry under it: creates a Schedule Log as the last direct child |
| MC21 | nav project conversion carrying a `^prj` task with a Rotten Log | carries the log with the task as a log, not a task |

## 12\. Successor offers

When a Bob close gesture completes a task, every managed Done recovery still runs immediately: Blocked → Ready (derived rank), `#prj_is_blocked` stripped, `[unblocked::]` written per [freshness.md §5](<https://bobs-org.github.io/freshness/#5-who-stamps>)'s WRITE rule, and `#hide` removed from an emptied project's `^prj` — whether or not anything is picked. What used to be auto-linked becomes a **successor offer**. One gesture produces one offer set. Only picked offers are linked, raised to Next, and stamped fresh. Picking nothing never undoes an unblock; the NEW and TICKLER review tiers resurface unpicked work.

- **Gesture.** One Obsidian Ctrl+Enter, or one capture **item**. A multi-item draft is several gestures, planned in order against the staged batch. A batch gesture (Pomodoro line, counted Alt+\], multi-item capture draft) gets **one** picker over the union of its offers.
- **Predecessor (P).** A task this gesture moved from open to Done. That covers the root and every embedded subtask closed with it.
- **Offer (D).** A task this gesture fully unblocked that would have been auto-linked under the old successor-link rule, plus every available open resident of a project whose `^prj` this gesture recovered, plus each §12.10 pull-in. One row per task identity.
- **Pick / selection.** The input that chooses which offers to link. Nothing is pre-checked. ↵ applies the marked rows, or the highlighted row when none are marked; Esc means none.
- **Groups.** `hand_off` (direct dependents), `project` (residents of an unblocked project), `project_finished` (§12.10 pull-ins).
- **Destination.** Where a picked link is inserted. Hand-offs and pull-ins use the §12.3 anchor (slot / closing / inherit). Project residents use the earliest anchor among `preds(R)` (the tasks that unblocked R; in closing mode the closing target), falling back to the implicit destination. An unplanned predecessor uses the implicit destination.
- **Offer key.** Opaque, stable identity string (§12.2 KEYS).
- **Anchor.** Where a predecessor was planned today. It is read from today's day text **before** this gesture strikes or moves anything.
- **Successor link.** The plain Task Link bullet inserted for a **picked** offer, for example `\t- [[sase#^relaunch-agents]]`. It is never an embed and never annotated.
- **Live link.** An unstruck Task Link sub-bullet (plain, embed, or `#`-deferred; 🍅 markers ignored) under an **open** `[ ]` entry of today's day file's `## Pomodoros` section. Struck links and links under closed entries are history, not plans.

The Obsidian picker API is §12.13. CLI selection (`-p, --pull-in`) is §12.14. SO vectors are §12.12.

### 12.1 Trigger

Successor offers are planned inside these **managed Done** gestures, and only when they **complete** a task. A picker appears only when the offer set is non-empty. `bob capture` **never prompts and never auto-links**: without a pick flag it recovers and reports numbered offers.

| Gesture | Where |
| --- | --- |
| Ctrl+Enter on a Task Link (plain or embed) under any Pomodoro, on the task line in its own note, or through the fallback link branch | task-status-cycler |
| Ctrl+Enter on a Pomodoro line (its closed embeds) | task-status-cycler |
| nav's PRE/POST walk Ctrl+Enter (`completeTaskAtCursor`) | task-status-cycler + nav |
| `bob capture '!note:id'` | bob-cli |
| `bob capture` closes that complete links: `x` with embeds, `x!M`, `x!`, `^route:id=x…` | bob-cli |
| Alt+\] / Alt+\[ status cycling into Done | task-status-cycler |

The same complete gestures produce project offers and project-finished pull-in offers (§12.10).

It **never** prompts, offers, or links on any of the following:

- cancel (the nav Ctrl+Shift+P cancel, Alt+\[ into Cancelled);
- reopen;
- raw checkbox clicks;
- `bob task reconcile` / hooks;
- `x*` parks;
- Ctrl+Enter on a Depends-On line.

Cancels still recover dependents exactly as today. Cancel never offers or links a successor or a pull-in; it still un-hides an emptied project's `^prj` (surfaces only, §12.10).

### 12.2 Rule

One definition, two engines (Rust capture and the JS cycler):

```
C        = tasks this gesture moved open → Done (roots + closed embedded subtasks),
           read from the staged post-close text
GATE     : if no c ∈ C carries an [id::] field → skip the dependents lookup and extra
           vault reads. unblocked_check = "checked". Project pull-in (§12.10) and
           PROJECT OFFERS still run from the staged text of C's notes.
match(c) = c's [id::] value

ANCHORS (computed on today's day text BEFORE this gesture's strikes, moves, or retirement,
         but AFTER any explicit link step of the same item, e.g. ^route:id=x…)
  closing    : c's link sits under the Pomodoro this gesture closes
  slot(E, b) : else the first live link to c in ledger order, under open entry E
               (b = that bullet)
  inherit    : a closed subtask with no live link of its own takes its root's anchor
  none       : otherwise (no today day file, no ## Pomodoros, or not planned today)

IMPLICIT DESTINATION = today's single open timed entry, else the first open entry
           in document order (the same resolution as the plain `@route:id`
           Ensure-Next form). Fails when there is no day file, no ## Pomodoros,
           no open entry, or several open timed entries.

CANDIDATES = open tasks D (status type Todo/InProgress/OnHold: ' ', '?', '*', '/'),
             outside done/, whose [dependsOn::] contains match(c) for some c ∈ C

FOR EACH D, in this order:
  post_open = D's open prerequisites in the staged post-close snapshot
              (exactly task_dependency_states)
  1. post_open ≠ ∅                → still_blocked{reason: waits_on, waits_on: |post_open|}
  2. D.scheduled > today          → still_blocked{reason: scheduled, scheduled}
  3. D is a resident of a still-blocked project
                                  → still_blocked{reason: project}  (PDC1; D stays [?])
  4. D's block id is prj          → recover; not_linked: project_task
                                    (except §12.10: a ^prj of an emptied
                                    project is a project_finished offer,
                                    not project_task)
  5. D carries #hide              → recover; not_linked: hidden
  6. D had a live link before this gesture
                                  → recover; not_linked: already_planned
  7. no predecessor of D in C has an anchor
                                  → OFFER at IMPLICIT DESTINATION, group hand_off;
                                    if implicit resolution fails → recover;
                                    not_linked: no_destination; no offer
  8. plan.link_unblocked is false → recover; not_linked: disabled; no offers
                                    (the config keeps its boolean spelling;
                                    true, the default, now means "offer")
  9. cancelled close (cycler)     → recover only; never offer
 10. otherwise                    → OFFER, group hand_off, destination = the
                                    earliest (ledger order) §12.3 anchor among
                                    D's predecessors in C

PROJECT OFFERS: when a `^prj` R recovers (step 4) and R has no own or
           inherited reason left, enumerate every **open resident T of
           N** (`[?]` or `[ ]`, including `#ref` and inbox tasks) on
           the staged post-gesture text:
           - own open prerequisite or future date → still blocked: a
             muted, unselectable row with its reason (PDC3);
           - `#hide` → recover only, folded into a count line;
           - live-linked today → recover to derived rank, folded into
             a count line;
           - otherwise → recover **and** offer, group project.
           R itself is the **group header, not a choice**, when N still
           has open work. Descendant (sub-)projects are untouched
           (`close_recovers_subprojects = no`, PDC5); the group shows
           one muted line counting N's open sub-projects from R's
           `🧩 **Sub-projects:**` marker ("N sub-projects stay blocked
           until the next reconcile"). Residents still lose
           `#prj_is_blocked` immediately. Unpicked recoveries receive
           `[unblocked::]` when freshness.md §5's WRITE rule applies.
           Resident destination = earliest_anchor(preds(R)), falling
           back to IMPLICIT DESTINATION.
DEDUP    : one row per task identity; causes are merged. A resident
           that is also a direct dependent appears once, in hand_off.
           In a multi-item capture draft, a later item that links,
           completes, re-blocks, or explicitly drops (`x~K`) a task
           removes its offer; a later item that adds open work to N
           removes N's project_finished offer.
BREAKER  : deleted. No replacement cap. SL17, PP15, and the breaker
           notice tests are retired. Keep the unrelated
           embedded-completion safeguards (25 levels / 250 targets).
KEYS     : row with a block ID → `<note_path>#^<block_id>`;
           otherwise → `<note_path>@<first 8 hex of SHA-256 of
           note_tasks::clean_description>`, and the k-th (k ≥ 2) row
           in the same note with the same digest, in document order,
           gets a `~k` suffix. Rust pins this format (the Mac and the
           CLI round-trip it). The cycler may use any deterministic
           synchronous digest in the same shape; it must never key by
           line number.
SELECTION: an input to each engine's existing single write pass
           (plan → choose → apply). Unpicked offers recover with
           not_linked: not_selected. Nothing is pre-checked.
ORDER    : offers by group (hand_off, then project, then
           project_finished), then anchor ledger position, then note
           path, then line
recover  : '?' → derived rank on the POST-gesture day text: '*' if D has a live link
           there, else ' '. ' ', '*', '/' are unchanged.
PICK     : '?' or ' ' → '*'; '*' and '/' are unchanged. Mint a missing ^block-id,
           stamp [fresh:: today] (clearing [unblocked::] and [keeps::]), then
           insert the successor link (Placement). Unpicked rows never mint.
```

- **Identity.** Matching uses only c's `[id::]` value. That is the one identity under which `task_dependency_states` resolves blocking: its identity map is built from explicit `[id::]` fields. A dependent that names only the canonical `note__block-id` of a target **without** `[id::]` was never blocked by that target under the derived rule, so it cannot be "unblocked" by closing it. The hooks heal that rare stale case on their next run, because they write the target's `[id::]`.
- This drops the canonical-id match the Rust `!` recovery uses today. The JS cycler already matches only `[id::]` values.
- The gate is therefore exact: about 96% of open tasks carry no `[id::]`, and closing one of them can never unblock anyone.
- **Graph transition, not checkbox.** Eligibility depends on `post_open` and on D depending on something this gesture closed. Every c in C was open before the gesture, so "pre\_open ∩ C ≠ ∅" holds automatically. A stale `[ ]` dependent therefore qualifies (SL15), and a `[?]` with another open prerequisite never does.
- **The already-planned baseline** is the day text **before** the gesture. Three consequences:
- A dependent whose link this same close carries forward stays planned and is not offered.
- A dependent whose link this same close **drops** (`x~K`) is never re-offered. The drop wins, and the dependent recovers to Ready.
- The recovered rank is derived from the **post**-gesture text, because that is what the hooks will see.
- **Rows report what the gesture did.** An offered task appears in `unblocked[]` even when its status did not change (a stale `[ ]` resident). An already-Next dependent that is already planned produces no row. `still_blocked[]` lists every open dependent of C that stays blocked, plus muted still-blocked project residents (with their `project`).
- **No recursion.** Picking D is not completing D, so D's own dependents wait (SL13).
- **No minting for unpicked rows.** A picked row that became Blocked or was linked meanwhile is refused per row and never satisfied by linking its prerequisites instead.
- **No managed-line guard in v1.** The field is the single identity, exactly as recovery and the hooks use it. Staleness is the same window Blocked derivation already has.

### 12.3 Anchors and placement

Placement runs only for **picked** offers. The inserted line is always `<indent>- <link>`. The indent is the anchor bullet's indent for slot anchors, and one tab for entry children (including implicit-destination inserts). Nothing else goes on the line: prose would stop it counting as a Task Link. A lone `\t-` stub is replaced.

**Implicit destination.** When no predecessor of D in C has an anchor (step 7), or when a project resident's `earliest_anchor(preds(R))` is empty, the destination is today's single open timed entry, else the first open entry in document order — the same resolution as the plain `@route:id` Ensure-Next form. If that resolution fails (no day file, no `## Pomodoros`, no open entry, or several open timed entries) the task recovers with `not_linked: no_destination` and is not offered. `destination.entry_role` is `"implicit"`; `fallback` is true when this path was used because no §12.3 anchor existed.

**Resident destination.** A project-group offer's destination is the earliest (ledger order) anchor among `preds(R)` — the tasks that unblocked R; in closing mode the closing target — falling back to the implicit destination.

**Slot anchors** (`slot(E, b)`): insert immediately after bullet `b`'s subtree (`b` plus its deeper-indented children), in successor order.

- **Rust (`!`).** Insert **before** retirement runs. Retirement then strikes `b` in place, or moves `b`'s subtree into the running entry, while the successor stays at the vacated position in E. Because E still has a child, it is never removed as an emptied placeholder (SL2).
- **Obsidian.** Insert before the cycler strikes the cursor link. The insertion is below the cursor line, so the strike target is unaffected.

**Closing anchors.** The target entry is the first that applies:

1. the continuation this close created (`next_pomodoro.created`);
2. else the first open entry **after** the closed one with the same non-empty name;
3. else a new placeholder, `- [ ] () — NAME` (or `- [ ] ()` when the closed entry was unnamed). It goes immediately after the closed entry's sub-bullet range, exactly where the close would have created its continuation. Picked successors count as carried.

Picked successors are appended after the target's existing children; a lone `\t-` stub is replaced. Implicit-destination inserts are entry children (one tab, after existing children, replacing a lone stub). `link.entry_created` is true when the entry did not exist before this gesture, in case 1 or 3.

**Next up.** `link.next_up` is true when, after the gesture, the target entry is the first open untimed placeholder in the ledger: the one a bare `=` would start. A created continuation becomes next up, exactly like carried work today. The notice always says so, so it is never a surprise.

### 12.4 Writes

Each gesture makes one write set: one staged batch in capture, one planned pass in Obsidian (plan → choose → apply). Selection is an **input** to that pass. Each picked offer behaves like a manual link (Ctrl+Shift+Enter):

- **Picked row's note.** Status `' '`/`'?'` → `'*'` (`'*'` and `'/'` unchanged; a Pending task is never demoted). Mint a missing `^block-id` (existing deterministic mint, below). Strip `#prj_is_blocked`. Stamp `[fresh:: today]`, clearing `[unblocked::]` and `[keeps::]` (Rust `freshness::placement::stamp_fresh`; JS `applyFreshStampLine` + `getFreshnessStampLine`). Removing whole-token `#hide` from a pulled-in `^prj` is unconditional (§12.10), picked or not. `#hidden` and `#hideaway` stay.
- **Unpicked row's note.** Recovers exactly as today's recover rule (including the `[unblocked::]` WRITE rule) and reports `not_linked: not_selected`. **No mint.** Status follows `recover`.
- **Day file.** Picked successor bullets are inserted, plus the created placeholder when one is needed. Unpicked offers insert nothing.
- **Freshness.** Picked rows stamp like a manual link. Unpicked recoveries never stamp; they keep the WRITE rule ([freshness.md §5](<https://bobs-org.github.io/freshness/#5-who-stamps>)). An unpicked Ready recovery can still surface as TICKLER tomorrow.
- **Schedule.** Never touched: no `scheduled` change and no Schedule Log line. Eligible offers have no future date by definition.
- **Fields.** No `[id::]` / `[dependsOn::]` writes; the hooks own those.
- **Do not call `plan_task_link`** (Rust) or block-id-prompt's `planTargetTaskUpdate`: both stamp freshness **and** retire schedules. Picks stamp freshness through `stamp_fresh` / `applyFreshStampLine` and never retire a schedule. Use the lower-level status writer (`set_task_line_status`) and the placement helper.

**Block-ID minting.** Minting runs only for picked offers. `mint(D)` is the first entry of bob's `capture_block_ids::suggest_ids_with_used(description, '^', used)`.

- `description` is `note_tasks::clean_description` of D's body: the trailing block ID, inline fields, and the Tasks global filter are removed, and whitespace is collapsed.
- `used` is every block ID in D's note (staged text), plus IDs this gesture already minted in that note.
- When the suggester returns nothing, mint `task`, `task-2`, `task-3`… (first free), via `capture_block_ids::mint_block_id`.
- The ID is deterministic and readable. Capture and Ctrl+Enter mint byte-identical IDs; §11.7 SB vectors pin both.

**Link form.** Same rules as dependency links (§3):

1. `[[basename#^id]]` when the basename is unique;
2. otherwise `[[dir/note#^id]]`.

The one exception: a successor that lives in the day file itself still names the note (`[[20261009#^id]]`), never the bare `[[#^id]]`. Rust uses `task_dependencies::format::canonical_link` with the same `NoteIndex` capture uses for `&` dependency links. That index covers every eligible Markdown note the discovery walks — task-bearing or prose-only, including untyped root notes, nested folders, ref notes, terminal projects, daily notes, hidden tasks, and completed/cancelled/archive history, excluding dot-directories, `_templates`, `_generated`, `_conflicts`, and the other always-excluded names — unioned with the batch's staged `.md` files. The JS port counts the same set without reading note bodies.

### 12.5 Reporting model

The capture result contract is additive under schema version 1, so old clients keep working. Dry-run JSON equals real-run JSON with the same flags (including `--pull-in`). Serde omits `not_linked` when the row was linked; clients must not expect `"not_linked": null`. `breaker` and `not_planned_today` are never emitted again (clients keep their copy for older bobs). CLI selection is `-p, --pull-in` (§12.14).

Both `task_complete` and `pomodoro_close` objects carry the same three fields. Offered rows are emitted even when the status did not change (a stale `[ ]` resident).

```
"unblocked": [{
  "note_path": "sase.md", "block_id": "relaunch-failed-agents", "line": 82,
  "text": "Re-launch all failed agents on apollo!",
  "previous_status_symbol": "?", "previous_status_name": "Blocked",
  "status_symbol": "*", "status_name": "Next",
  "inbox": false,
  "trigger": "dependency",
  "hide_removed": false,
  "unblocked_by": [{"note_path": "sase.md", "block_id": "fix-apollo",
                    "text": "Fix apollo machine!"}],
  "link": {"day_file": "2026/20261009.md", "entry_name": "FIX", "entry_line": 37,
           "entry_created": false, "next_up": false, "line": 40,
           "block_link": "[[sase#^relaunch-failed-agents]]",
           "block_id_created": true},
  "offer": {
    "key": "sase.md#^relaunch-failed-agents", "n": 1, "group": "hand_off",
    "destination": {"day_file": "2026/20261009.md", "entry_name": "FIX",
                    "entry_role": "slot", "fallback": false},
    "selected": true, "block_id_needed": false
  }
}],
"still_blocked": [{"note_path": "sase_agents_repo.md", "block_id": "badges", "line": 21,
  "text": "Start adding sase--<name> badges", "status_symbol": "?",
  "reason": "scheduled", "waits_on": 0, "scheduled": "2026-10-13",
  "trigger": "dependency"}],
"unblocked_check": "checked"
```

**`unblocked[]` rows**

- The existing fields keep their meaning; `unblocked` stays on `task_complete` and is new on `pomodoro_close`.
- `trigger` is `"dependency"` or `"project_empty"`. Every existing successor row says `"dependency"`; a missing key means an older bob. Project pull-in rows say `"project_empty"` (§12.10).
- `hide_removed` is true when this gesture removed a whole-token `#hide` from the row's task. Existing successor rows send `false`.
- The row `text` of a project (`^prj`) row is the post-write description with `#prj` and `#hide` dropped (display only).
- Human output uses `surfaced` / `would surface` for a pull-in that removed `#hide` without linking or changing status (§12.6, §12.10).
- `block_id` holds the minted ID when one was minted, and `""` when D has none and none was minted.
- `link` is non-null exactly when this gesture linked D (the offer was picked).
- Line numbers are 1-based in the day text after this item.
- `day_file` is vault-relative.
- `entry_name` is `""` when the entry is unnamed.
- `offer` is additive on every offered row:
- `key`: the offer key (§12.2 KEYS);
- `n`: 1-based index in the numbered offer list this command reports without a selection;
- `group`: `"hand_off"` | `"project"` | `"project_finished"`;
- `project`: vault-relative path of N, present on the `project` group;
- `destination`: `{day_file, entry_name, entry_role, fallback}` where `entry_role` is `"slot"` | `"closing"` | `"implicit"` | `"created"` and `fallback` is true when the implicit destination was used because no §12.3 anchor existed;
- `selected`: true when this offer was picked;
- `block_id_needed`: true when a pick would mint.
- `not_linked` is one of `already_planned`, `project_task`, `project_member`, `hidden`, `disabled`, `not_selected`, `no_destination`. Serde omits the key when the row was linked.
- The cycler-only model adds `failed` and `cancelled`; Rust never emits them.
- `breaker` and `not_planned_today` are never emitted again.

**`still_blocked[]` rows**

- `reason` is `waits_on`, `scheduled`, or `project`.
- `waits_on` counts the open prerequisites left.
- `scheduled` is set for `scheduled`, and on a `project` row when the project date blocks; otherwise `null`.
- `project` (the reason) is a resident whose project still has an open prerequisite or a future `scheduled` date after this gesture (§13). The optional `project` field is that note's vault-relative path. Muted still-blocked project-offer residents carry `project` too. Pull-in still-blocked rows carry `project` (N's path) for every reason. `^prj` reports only its own reasons, never a `project_*` reason.
- `trigger` is `"dependency"` or `"project_empty"`, same rule as `unblocked[]`.

**`unblocked_check`**

- `"checked"`: the lookup ran, or the gate proved it unnecessary.
- `"unavailable"`: the dependents snapshot could not be built. The close still succeeds and nothing is offered or linked.
- A missing key means an older `bob`.

**Top-level `pull_in`**

Present on **every** successful capture JSON (dry-run too):

```
"pull_in": {
  "supported": true,
  "offer_count": 2,
  "choice": null,
  "picked_count": 0,
  "groups": [
    {"group": "project", "project": "sase_foo.md",
     "folded": {"hidden": 0, "already_planned": 0, "sub_projects": 0}}
  ]
}
```

- `supported`: always `true` on a bob that implements this contract.
- `offer_count`: selectable offers in this result (muted/folded rows are not offers).
- `choice`: `null` when no `--pull-in` was given; otherwise `"all"`, `"none"`, or `"subset"`.
- `picked_count`: how many offers were linked.
- `groups`: folded counts (hidden, already planned, sub-projects) per project group. Omitted when every folded count is zero and there is no project group.

**Elsewhere in the result**

- **`task_complete` top-level `day_file`.** Set to the absolute day file path whenever the gesture changed the day file (`ledger` present or a successor linked). Previously it was always `null`.
- **`pomodoro_close.next_pomodoro`** names the successor's continuation when the successor logic created it.
- **`pomodoro_blocks[].lines[].reason`.** Optional `"unblocked"` on added lines that are surviving picked successor links.
- **Net batch reporting (SL20).** After all items are planned, an offer whose task is no longer open, or whose later item linked, completed, re-blocked, or dropped it, is dropped from its item's `unblocked[]` and loses its block-line `reason`. A later item that adds open work to N removes N's `project_finished` offer.
- **`task_blocks`.** Picked successors keep role `unblocked`.

**Shared notice model** (cycler → nav `api.notice.showUnblocked(model)`). It is the JSON row shape above, kept in snake\_case on purpose so one vocabulary serves Rust, JS, Swift, and the vectors:

```
{
  version: 1,
  predecessors: [{ note_path, block_id, text }],
  unblocked: [/* rows exactly as JSON, plus not_linked "failed" | "cancelled" */],
  still_blocked: [/* rows exactly as JSON */],
  daily_path: "2026/20261009.md",
  daily_content: "<post-write day text, for the plan chip>" | null,
  failure: null | { count, reason },
}
```

### 12.6 Copy

**Copy rules (shared by every surface).**

- One notice per gesture, and silence when nothing was unblocked.
- Each row reads successor text first, then the destination, then the cause.
- Offer lists use group headers: `Hand-offs`, `Project ‹note›`, `Project finished`. The project group's `^prj` is the header, not a choice, when N still has open work.
- Use only existing glyphs: 🔓 unblocked (Mac `lock.open.fill`), 🔒 still blocked (Mac `lock.fill`), ✓ done.
- Use only existing `--task-status-*` colour tokens, with no new hex values.
- Show at most 3 rows (linked, then not linked, then still blocked), then `+N more`. This is a display limit, not an insertion limit. The picker itself has no row cap: grouping and a filter handle large lists.
- Never lead with a block ID.
- Truncate task text to 48 characters with `…`.
- Drop breaker copy from new output. Older payloads may still render `not linked · more than 5`.

**Notice text** (`successorNoticeText(model)` in the cycler, and the Mac notification line). This is the plain fallback and walk-toast form:

| Situation | Text |
| --- | --- |
| One linked | `🔓 Next in FIX: Re-launch all failed agents on apollo!` |
| Several linked, one entry | `🔓 3 linked → SASE: Review memory beads, Ship AGENTS.md…, +1` |
| Several entries | `🔓 3 linked · FIX, SASE` |
| Created continuation | `🔓 Next in new BOB session (next up): Re-launch…` |
| Not pulled in | `🔓 7 unblocked · not pulled in` |
| No destination | `🔓 Unblocked: Book flights (Ready) · no open Pomodoro` |
| Recovered only | `🔓 Unblocked: Book flights (Ready)` |
| Project member | `🔓 Unblocked: Draft the pitch (Ready) · project member` |
| One linked pull-in | `🔓 Next in FIX: Ship the outcome! · project has no open tasks` |
| Surfaced only | `🔓 Surfaced: Ship the outcome! (Ready) · project has no open tasks` |
| Pulled-in summary | `🔓 Pulled in 2 of 7` |
| Partial failure (Obsidian) | `⚠ Closed Fix apollo machine! — couldn't link 1 successor (daily note changed)` |
| No nav picker | offers listed as `not pulled in` |

**Obsidian: the `Unblocked` card** (nav's notice family, `bob-nh-notice is-unblock`).

```
┌──────────────────────────────────────────────────────────────────┐
│ 🔓  Unblocked   1 → FIX                     ✓ Fix apollo machine! │
│  ＋ Re-launch all failed agents on apollo!               [ Next ] │
│  🔒 Start adding sase--<name> badges · waits until Oct 13         │
│ ──────────────────────────────────────────────────────────────── │
│  (plan 3/3 · 10/10)  (added ^relaunch-failed-agents)  (Alt+N releases) │
└──────────────────────────────────────────────────────────────────┘
```

- **Header.**
- 🔓 sits in the existing glyph slot.
- The title is `Project empty` when every row is a project pull-in row (`trigger: "project_empty"`), otherwise `Unblocked`.
- The count chip reads `n → NAME` / `n → new NAME session` / `n linked` / `n not pulled in`, plus `· next up` when it applies.
- The right-aligned muted receipt reads `✓ <predecessor>`, or `✓ N tasks`.
- **Rows.**
- **Linked rows** show `＋ text`, a lane pill (`Next`), `↗ <note>` when D lives in a different note than its first predecessor, and `inbox` when D lives in an inbox file. Project pull-in rows add a muted `no open tasks` chip after the lane pill.
- **Not-linked rows** show the text, a lane pill (`Ready`/`Next`), and a muted reason: `already planned`, `not pulled in`, `no open Pomodoro`, `project task`, `project member`, `hidden`, `linking off`, `couldn't link`, or `cancelled`. Project pull-in rows add the same `no open tasks` chip.
- **Still-blocked rows** are muted: `🔒 text · waits on N more` or `· waits until Oct 13`. A `reason: "project"` row reads `· blocked by project` (`· project until <date>` when `scheduled` is set). Nav renders the legacy `project_dependency` / `project_schedule` reasons from older cycler bundles the same way. Still-blocked pull-in rows add `· no open tasks`.
- Clicking a row opens that task (`app.workspace.openLinkText`).
- **Footer chips.**
- The plan meter on the post-write daily content, which turns warn/🔴 when over.
- `added ^id`, or `added N block IDs`.
- `Alt+N releases` (muted), shown when anything was linked.
- **Duration.** 6 s plus 1 s per extra visible row, capped at 10 s.
- **No nav.** Without nav (or with `api.notice` missing, or `api.successors?.version < 1`), show a plain `new Notice(successorNoticeText(model))` listing offers as `not pulled in`. No links.
- **Walk landings.** The gesture keeps its **single** walk toast. The successor picker holds the review-walk gesture lock while it is open; after it settles (including Esc) the walk advances exactly once and the pull-in result composes into `outcome.notice` as the line after `✓ Done · <task>`. No card is shown. Inserted rows never steal focus or cause a second advance. Ctrl+Enter never crosses the PRE/POST boundary. A double-press guard ignores Enter carrying modifiers, key repeats, and presses within \~350 ms of opening.

**Bob Mac Capture: preview first, choice step, notification second.** Preview is the primary surface because it explains before anything is written. The app stays a thin client: bob computes offers and does the write; the app adds an in-panel multi-select choice step on Return when the latest preview has offers, then passes `--pull-in` on the one atomic `bob capture`. Numbered offer rows appear on the `!` and close cards under group headers, with the hint `↩ choose what to pull in`.

- **`!` card unblocked rows.**
- **Offered.** Numbered `offer N` rows with group headers. Destination capsule (`→ FIX` / `→ new BOB · next up` / `→ implicit`).
- **Linked (after a pick).** `lock.open.fill` in the Next tint, monospaced `[?] → [*]  Re-launch…`, and a trailing destination capsule. A caption reads `added ^id` when an ID was minted. An `inbox` capsule appears when it applies.
- **Not linked.** `lock.open.fill` in secondary colour, `[?] → [ ]  text`, and a caption with the reason (`Ready · not pulled in` / `Ready · no open Pomodoro` / `Ready · project member`).
- **Still blocked.** `lock.fill` in tertiary colour, `text`, and a caption reading `stays Blocked · waits on 1 more` / `· until Oct 13`.
- **`x` close card.** Gains an **Unblocked** section under its task rows, with the same row views.
- **Block diff.** An added line with `reason == "unblocked"` gets a small trailing `lock.open.fill` badge with `.help("Unblocked successor")`, so the successor visibly sits under its struck predecessor.
- **Notification.** One extra body line, taken from the notice-text table above, reporting what was pulled in. There is never a second notification. With only recovered rows, keep today's `· unblocked A, B`.
- **Open Note(s)** includes the daily note whenever anything was linked.
- **Esc** on the Mac choice step returns to the draft with nothing written (the close has not happened yet). ⇧↩ submits with none.

**Human output** follows the existing word-led rows (`dry run`: `would link`). Numbered `offer N` rows print under group headers after the `ledger` line for `!`, and after the task rows and before `next:` for closes. A dry-run hint shows `-p 1,2 · -p all`. A real run without a pick says the offers were not pulled in and names Obsidian / Bob Mac Capture / Ctrl+Shift+Enter as the way to link later:

```
✓ completed [*] → [x] Fix apollo machine!  sase.md ^fix-apollo
  ledger  Task Link struck in FIX · 2026/20261009.md
  Hand-offs
  offer 1  [?] Re-launch all failed agents on apollo!  sase.md ^relaunch-failed-agents → FIX
  offer 2  [?] Book flights  travel.md ^book-flights → FIX
  linked [?] → [*] Re-launch all failed agents on apollo!  sase.md ^relaunch-failed-agents → FIX · added ^relaunch-failed-agents
  unblocked [?] → [ ] Book flights  travel.md ^book-flights · not pulled in
  linked [ ] → [*] Ship the outcome!  proj.md ^prj → FIX · project has no open tasks
  surfaced Ship the outcome!  proj.md ^prj · project has no open tasks
  still blocked  Start adding sase--<name> badges  sase_agents_repo.md ^badges · until 2026-10-13
  still blocked  Ship the outcome!  proj.md ^prj · project has no open tasks · waits on 1 more
plan 3/3 themes · 10/10 links
```

- **Destinations:**
- `→ FIX`
- `→ FIX (next up)`
- `→ new BOB session (next up)`
- `→ new session` for an unnamed created entry
- `→ line 37` for an unnamed existing entry
- **Reason suffixes:**
- `· already planned`
- `· not pulled in`
- `· no open Pomodoro`
- `· project task`
- `· project member`
- `· hidden`
- `· linking off`
- **Project pull-in.** A pull-in that linked R uses `linked` / `would   link`. A pull-in with no link and no status change uses `surfaced` / `would surface`. Every project row puts `· project has no open tasks` before any reason suffix.
- **Still-blocked suffixes (Rust human):** `· waits on N more`, `· until YYYY-MM-DD`, `· project <path>`. The nav card keeps `· blocked by project` and `· project until <Mon D>`.
- A row with no block ID omits `^`, as today.

### 12.7 Performance

| Layer | What | Effect |
| --- | --- | --- |
| 0\. `[id::]` gate | Before any lookup, check the **staged** post-close lines of C for an `[id::]` field. | Zero extra I/O for about 96% of closes, in Rust and JS. It also speeds up today's Ctrl+Enter recovery. |
| 1\. Lazy context | `DependencyContext` builds `discover` only for items that need the dependency pool. `!` resolves notes from a walk-only index. | `hello` / `x` previews go from about 270 ms to about 40 ms on apollo. |
| 2\. Prefiltered parallel snapshot | One batch-scoped dependents snapshot. Byte reads run in parallel, and a prefilter keeps only notes containing `dependsOn` or `id::`. Staged files always overlay disk. Shared by every `!` and close item in the batch, never cloned per item. | About 30 ms per batch, paid only when the gate passes. |
| 3\. Obsidian Tasks cache | When Tasks reports Warm, build a reverse index `id → dependents` from `getTasks()`. Open editor buffers override the cache. Read only the candidate notes and today's daily note. A cold cache falls back to the full scan. | The notice lands about 50 ms after the keypress. |
| 4\. Project offers | Enumerate residents from N's already-staged text plus the dependents snapshot the gate already allows. No extra vault walk for descendant notes. | The `^bar` → `sase_foo` case costs one staged-note read. |
| No daemon, no persistent cache | A validated cache needs a stat walk (32–55 ms) that costs as much as layer 2. | Respects the thin-client rule: no Swift-side dependency or placement logic. |

**Targets.** Measured on apollo with a release build, against a temporary **copy** of `~/bob` (never the live vault):

- `hello` and `x` dry runs take ≤ 40 ms;
- a `!` or `x!` that closes a real prerequisite takes ≤ 70 ms;
- `capture-parse` does no new work;
- no extra spawn is needed to report results.

### 12.8 Kill switch

`plan.link_unblocked: true` lives in `~/.config/bob/config.yml` and is read by Rust and the cycler. The config keeps its boolean spelling; `true`, the default, now means **offer**. Setting it to `false` turns off **offering, linking, and minting**:

- close-time recovery still runs and still uses the derived rank;
- each unblocked row reports `not_linked: "disabled"`;
- no picker, no `--pull-in` effect, no offers in JSON;
- project pull-ins obey the same kill switch: no offer, no link, status unchanged, `not_linked: "disabled"`; un-hiding still happens (§12.10).

Recovery is the honest half of the feature and costs nothing extra once the dependents are found. An invalid value falls back to `true` and surfaces through the existing invalid-plan-config warning paths (`docs/plan.md` "Invalid values").

### 12.9 Undo

- **One pick.** Alt+N on its link releases it, as the inverse of what was written. The card's chip teaches this.
- **The whole gesture, in Obsidian.** Reopening the predecessor the same day takes back its picked successor links if they are untouched, and restores the pre-pass lines it owns, including picked rows' freshness fields (`[fresh::]`, `[unblocked::]`, `[keeps::]`). This uses an in-memory receipt; nothing is stored in Markdown. For a project pull-in, the receipt also stores R's full pre-edit line and restores that line byte-for-byte (status, `#hide`, and freshness) when R's current line is still the line the pass wrote (§12.10).
- **Rust.** No reopen take-back, as today.
- **Not undo.** Deleting the bullet or pressing `u` in the editor leaves the sticky Next. That is the general sticky-lane cost.

### 12.10 Project pull-in

When a close gesture empties a project, do immediately and visibly what `bob projects sync` would do within 15 minutes: remove `#hide` from `^prj`. The link step becomes a `project_finished` offer. "Empties" uses sync's own predicate, so sync never re-hides a pulled-in `^prj`. Implementations copy the PP table (§12.11) into their tests, citing the vector id in a comment. If an implementation disagrees with a vector, fix this section first in the same change.

A pulled-in `^prj` is never Blocked when linked (Rule steps 1–3), so §13.5 consumption and "never link a Blocked task" hold. Dependency promotion never raises a `^prj` target; a picked Task Link raises `^prj` to Next like any other task, including this close-gesture pull-in.

**Terms**

```
C           = tasks this gesture moved open → Done (§12.2)
N           = a project note: outside done/, not a canonical daily note,
              frontmatter type project, non-terminal status, exactly one
              open ^prj task R (bob projects sync's recognition), AND
              sync can plan N: its scan reports no issue for the file
              (e.g. exactly one ^prj-anchored line, at most one valid
              inline [scheduled::] on ^prj). The effective status is
              non-terminal as sync computes it.
REMAINING(N)= sync's effective-unhidden count on the staged text: open
              #task lines other than R, outside fenced code, whose mark
              is not x / X / -, carrying no whole-token #hide
              (= sync's open_unhidden_count: [?], custom marks, nested,
              #ref and blockquoted lines count) …
              … except when a project schedule applies (see SCHED). Then
              a hidden ordinary open task with fewer than two scheduled
              fields also counts, because sync strips its #hide.
SCHED(N)    = sync's project schedule: a leftover frontmatter
              `scheduled` on an open ^prj, else R's inline
              [scheduled::]. Rule step 2 compares SCHED(N), not only
              R's inline field, with today.
SUBOPEN(N)  = R's machine-owned "🧩 **Sub-projects:**" marker line
              lists a child that sync's marker reading treats as open,
              i.e. anything but ~~[[child]]~~ ✅ (Done) or
              ~~[[child]]~~ ❌ (Canceled). A link struck without ✅/❌
              is still open.
EMPTY(N)    = REMAINING(N) = ∅ and not SUBOPEN(N), on the staged
              post-gesture text
```

SUBOPEN reads the marker line instead of child notes' `parent:` frontmatter, which would need a vault-wide walk. The marker is sync-maintained (rewritten at most every 15 minutes), so it can be stale. Both stale outcomes are benign: a missed pull-in (sync surfaces R on its next run instead), or an extra one (sync re-hides R, and R stays Next until Alt+N or a close).

**Trigger**

A pull-in candidate is the open `^prj` R of a project N such that EMPTY(N), R ∉ C, and one of:

- (a) C contains a task in N other than R: the gesture closed N's last task.
- (b) R is a dependent candidate of C (its `[dependsOn::]` names a task in C). This gesture removed R's last blocker.
- (c) C contains the `^prj` of a project K with `parent(K)` = N, R ∉ C, and EMPTY(N) holds with N's marker entry for K read as closed. `preds(R)` = {K's `^prj`}, anchored by its slot, the closing entry, or inherit (§12.3). Reuses `trigger:   "project_empty"`, its copy, and its rows; `unblocked_by` names K's `^prj`. Cost: one parent resolution plus one read of N, and only when C contains a closed `^prj` whose note has `parent:`. Every other close is unchanged.

`preds(R)` is the set of tasks in C that triggered R: N's closed tasks, R's closed prerequisites, or — under trigger (c) — K's `^prj`.

**Rule** (both engines)

```
FOR EACH candidate R (one row per R; a pull-in replaces R's SL11
project_task row):
  1. R has open prerequisites after the gesture
                                  → still_blocked{reason: waits_on}
  2. SCHED(N) > today             → still_blocked{reason: scheduled}
  3. R carries `#prj_is_blocked`
                                  → still_blocked{reason: project,
                                     project: parent(N)}
     (the tag means N inherits a reason from an ancestor, so a
     pull-in still never links a Blocked ^prj. this applies to
     every trigger. steps 1–3 leave R's line byte-identical,
     #hide included)
  4. From here on, remove every whole-token #hide from R
     (unconditional; picked or not).
  5. R had a live link before the gesture
                                  → not_linked: already_planned;
                                    ' ' / '?' → '*'
  6. no task in preds(R) has an anchor
                                  → OFFER at IMPLICIT DESTINATION, group
                                    project_finished; if implicit
                                    resolution fails → not_linked:
                                    no_destination; recover rank
  7. cancelled close (cycler only)
                                  → not_linked: cancelled; recover rank
                                    (never offer)
  8. plan.link_unblocked is false → not_linked: disabled; recover rank
                                    (no offer)
  9. otherwise                    → project_finished OFFER at the
                                    earliest anchor among preds(R)
                                    (§12.3 slot / closing / inherit);
                                    linked when picked; unpicked
                                    recovers with not_linked:
                                    not_selected
recover rank : '?' → '*' if R has a live link in the post-gesture day
               text, else ' '; other statuses unchanged. A Ready
               landing (`' '`) writes `[unblocked::]` when
               freshness.md §5's WRITE rule applies; a Next landing
               (a pick) stamps [fresh:: today] instead and skips
               `[unblocked::]` (UB4). The WRITE rule is evaluated on
               R's line AFTER step 4 removed #hide, so the hidden skip
               never applies to a pulled-in R. When R was `[?]` and
               leaves Blocked through steps 5–9, §12.2 PROJECT OFFERS
               run for N exactly as for a `project_task` recover
               (PDC2/PDC3).
BREAKER      : deleted. Pull-ins are ordinary offers.
ORDER        : pull-ins sort with offers (§12.2 ORDER: group, anchor,
               note path, line)
ROWS         : unblocked[] gets a row when the gesture changed R's
               status, removed #hide, offered R, or linked R.
               still_blocked[] always gets a row for steps 1–3. An
               already-Next, already-linked, visible R produces
               nothing.
               A surfaced row removed #hide without linking or changing
               status: `previous_status_symbol` == `status_symbol`,
               `hide_removed: true`, `link: null`, and its `not_linked`
               reason (`not_selected` when offered and unpicked).
```

Status follows the pick rule: and `?` become `*` when picked; `*` and `/` are unchanged. A Pending `^prj` is never demoted (`decisions:task-lanes-are-sticky`). An unpicked `^prj` stays at its recovered rank.

**Gate.** The `[id::]` gate still guards the dependents lookup only.

- Detecting (a) reads only the staged post-gesture text of C's own notes. Both engines already hold that text.
- The vault-wide dependents snapshot is built only when needed:
- some task in C carries `[id::]` (as today); or
- a candidate R carries `[dependsOn::]`. Steps 1 and 3 need vault-wide openness in that case; or
- a candidate R has status `?` and some open resident of N carries a non-empty `[dependsOn::]`. The §12.2 PROJECT OFFERS cascade checks residents' own prerequisites vault-wide in that case.
- If the snapshot is unavailable, set `unblocked_check: "unavailable"` and write no pull-in or project offer.
- Closes outside project notes cost nothing extra. The cycler's zero-read wiring test must keep passing.

**Writes**

- **R's line.**
- The status changes per the Rule.
- Every whole-token `#hide` is removed. `#hidden` and `#hideaway` stay.
- Whitespace is byte-identical to sync: Rust calls `projects::edits::normalize_task_hide_tag(line, false, true)`. The cycler mirrors it. PP22 pins the bytes.
- Nothing else changes except a Ready recover-rank landing, which writes `[unblocked::]` when freshness.md §5's WRITE rule applies, and a pick, which stamps `[fresh:: today]` and clears `[unblocked::]` / `[keeps::]`. No schedule, no `[id::]` / `[dependsOn::]`. `^prj` already has its block ID, so nothing is minted.
- **Day file.** When picked, one `<indent>- [[N#^prj]]` bullet, placed per §12.3. The link form follows §12.4, so it is never `[[#^prj]]`. Unpicked pull-ins insert nothing.

**Reporting** (additive, capture schema v1)

- Every `unblocked[]` and `still_blocked[]` row carries `trigger: "dependency" | "project_empty"`. Every existing row says `"dependency"`; a missing key means an older bob. Pull-in rows say `"project_empty"`.
- `unblocked[]` rows carry `hide_removed` (bool).
- The row text of a project row is the post-write description with `#prj`, `#hide`, and `#prj_is_blocked` dropped (display only, token-bounded).
- `unblocked_by` lists `preds(R)`.
- **Human output (Rust).**
- A pull-in that linked R uses `linked` / `would link`.
- A pull-in with no link and no status change uses `surfaced` / `would surface`.
- Every project row puts `· project has no open tasks` before any reason suffix.
- Offered pull-ins appear as numbered `offer N` rows under `Project finished`.
- **Notice text** (cycler `successorNoticeText`, nav fallback, Mac notification line).
- One linked pull-in: `🔓 Next in FIX: Ship the outcome! · project has no open tasks`.
- Surfaced only: `🔓 Surfaced: Ship the outcome! (Ready) · project has no open tasks`.
- Mixed notices keep §12.6's count forms, and a picked pull-in counts as linked.
- Only existing glyphs are used.
- **Nav `Unblocked` card.**
- Project rows get a muted `no open tasks` chip after the lane pill.
- The title is `Project empty` when every row is a project row, otherwise `Unblocked`.
- Still-blocked project rows add `· no open tasks`.
- **Bob Mac Capture** numbers pull-in offers on the preview cards and includes them in the in-panel choice step. The app stays a thin client: bob computes the offer and writes the pick.

**Undo (Obsidian)**

The successor receipt also stores R's full pre-edit line. A same-day Ctrl+Enter reopen of a predecessor that owns the row does two things, as long as R's current line is still the line the pass wrote:

- takes back R's picked link, as §12.9 does today;
- restores R's whole line (status, `#hide`, and freshness fields).

Rust has no reopen take-back, as today.

**Interplay**

- **`bob projects sync`.** After a pull-in it changes nothing for N (same predicate). When Bryan then adds a task to N, sync re-adds `#hide`. R stays Next with its link under sticky lanes, and Alt+N releases it, or Bryan closes R.
- **`bob task reconcile`.** A linked `[*]` R is stable. If N later becomes Blocked, §13.5 consumption removes R's link.
- **Freshness / walk.** Unpicked recover-rank landings write `[unblocked::]` per §12.4. Picks stamp `[fresh:: today]`. A visible R walks in the PROJECTS tier as today.
- **Multi-item drafts.** SL20's net pass drops any pull-in row, linked or not, whose R is no longer open in the final staged text.

### 12.11 PP — project pull-in vectors

Outcomes of the shared project pull-in rule (§12.10). A row that expects a link means the `^prj` is **offered** (`project_finished`) and **linked when picked**. Tests pass a select-all selection unless a row says otherwise. Rust tests run them through capture (`!` and close); cycler tests run them through the pure helpers. Copy the rows you implement into tests, citing this section in a comment.

| \# | Situation | Outcome |
| --- | --- | --- |
| PP1 | N has one open task A, linked in FIX; R is with `#hide`; close A | R is offered (`project_finished`); when picked, linked right after A's bullet; →`*`; `#hide` removed regardless; `trigger: project_empty`; `hide_removed: true`; `unblocked_by: [A]` |
| PP2 | A and B are open; close A | nothing |
| PP3 | B is `[?]` | nothing (Blocked tasks are remaining work) |
| PP4 | B carries `#hide` | pull-in |
| PP5 | B is fenced | pull-in. B is blockquoted or custom `[>]` → nothing |
| PP6 | Sub-projects line with an unstruck child, or `~~[[child]]~~` struck without ✅/❌ | nothing (both read as open). Only ✅/❌ children → pull-in |
| PP7 | future project `scheduled` on `^prj` | `still_blocked{reason: scheduled}` with the date; `trigger: project_empty`; R byte-identical |
| PP8 | R has an open prerequisite on its Depends-On line | `still_blocked`; R byte-identical |
| PP9 | R already linked today | no new link; →`*`; `#hide` removed; `already_planned` |
| PP10 | A not planned today | offered at the implicit destination; `#hide` removed regardless; linked when picked. No implicit destination → `no_destination`, surfaced, no offer |
| PP11 | `plan.link_unblocked: false` | no offer; `#hide` removed; `disabled`; status unchanged |
| PP12 | R is `[/]` | linked; stays `[/]` |
| PP13 | R is `[*]`, visible and already linked | no write, no row |
| PP14 | A has no `[id::]` | the gate does not suppress the pull-in, and the dependents snapshot is not built |
| PP15 | a dependency successor D and R in one gesture | **retired.** The breaker is deleted. D is a `hand_off` offer and R is a `project_finished` offer; both offered, linked when picked (SO) |
| PP16 | `x` completes embedded A | R joins the closing target (continuation, same-name entry, or created placeholder) |
| PP17 | R depends on A through a legacy internal Depends-On line | one row, and the pull-in wins over `project_task` |
| PP18 | cycler cancel of A | no link; `#hide` removed; `cancelled` |
| PP19 | same-day reopen of A (Obsidian) | R's link taken back and R's line restored byte-for-byte |
| PP20 | terminal project, two `^prj`, N under `done/`, a canonical daily note, or N with a sync scan issue (e.g. two inline dates on `^prj`) | nothing |
| PP21 | draft `!N:a !N:b` | only the second item reports R. Draft `!N:b !N:prj` → the net pass drops R's row |
| PP22 | `#hide` bytes via `normalize_task_hide_tag(line, false, true)` | `- [ ] #task #prj Ship it! #hide ^prj` → `- [ ] #task #prj Ship it! ^prj` (pull-in then writes `[*]`); `- [ ] #task #prj Ship it! #hide [priority:: high] ^prj` → `- [ ] #task #prj Ship it! [priority:: high] ^prj`; `- [ ] #task #prj Ship it! #hidden #hide ^prj` → `- [ ] #task #prj Ship it! #hidden ^prj` (`#hidden` untouched) |
| PP23 | after a pull-in, `bob projects sync --dry-run` | reports no change for N |
| PP24 | closing P in another note when R depended only on P and N is empty | R offered (`project_finished`); when picked, linked after P's bullet. If N is not empty → `project_task` (unchanged) |
| PP25 | R is `[?] … #hide` and depends only on P in another note. N's only other open task is `[?] #prj_is_blocked #hide`. Close P (planned today) | R offered; when picked, linked after P's bullet: `[?]`→`[*]`, R's `#hide` removed regardless. The hidden resident is folded (recover only, no offer) and loses `#prj_is_blocked`. It gets no `[unblocked::]` (it is still hidden, UB6) |
| PP26 | R carries `[scheduled:: <today or past>]`; N's only other open task B carries `#hide`. Close A | nothing (B is remaining work under the dated recount). With no project date → pull-in |
| PP27 | R `[?]` with `[fresh::]`, not planned today, last blocker closed | unpicked: `' '`, `#hide` removed, `[unblocked:: today]` written, offered at implicit destination (`not_selected` if unpicked). Picked → `[*]`, `[fresh:: today]`, no `[unblocked::]` (UB4) |
| PP28 | leftover frontmatter future `scheduled` on N with an open `^prj` | `still_blocked{reason: scheduled}`, R byte-identical |
| PP29 | K (`parent: "[[N]]"`) is the only open sub-project on N's marker line, and N has no other remaining work. K's `^prj` is linked in FIX; close it via its link | N's R is offered (`project_finished`); when picked, linked right after K's bullet: →`*`, `#hide` removed regardless, `trigger: project_empty`, `unblocked_by: [K ^prj]` |
| PP30 | N's marker lists another open child, or N has a remaining task | nothing |
| PP31 | K has no `parent:`, or it is unresolvable, ambiguous, or not a project | nothing |
| PP32 | R carries `#prj_is_blocked` (N inherits), under any trigger | `still_blocked{reason: project, project: parent(N)}`, `trigger: project_empty`; R byte-identical |
| PP33 | K's `^prj` not planned today; cycler cancel (Alt+\[) of K's `^prj` | offered at implicit destination (or `no_destination`); cancel → `cancelled`, surfaced only, never offered |
| PP34 | after a PP29 pull-in, `bob projects sync --dry-run` | changes only N's marker entry for K (→ `~~[[K]]~~ ✅`), never N's `#hide` |
| PP35 | draft `!K:prj !N:prj` | the net pass drops R's row |
| PP36 | row text of a project row whose R carries `#prj_is_blocked` | drops `#prj_is_blocked` with `#prj` and `#hide` (token-bounded) |

### 12.12 SO — successor offer vectors

Outcomes of the successor-offer rule (§12) that the SL and PP tables do not already pin. Implementations copy the rows they implement into tests, citing the vector id in a comment. Select-all is the default selection unless a row names another.

| \# | Situation | Outcome |
| --- | --- | --- |
| SO1 | close `sase.md` `^bar`; `sase_foo.md` `^prj` depended only on `^bar` and still has two open residents (`[?]` or `[ ]`, `#ref` included) | both residents offered under the `project` group; `^prj` is the group header, not a choice; pick one → minted id if missing, `[*]`, `[fresh:: today]`, `[unblocked::]` cleared, linked under `^bar`'s entry; the other recovers, `not_selected`, no mint |
| SO2 | 11-way fan-out from one gesture | 11 `hand_off` offers, zero pre-checked, no breaker, no cap |
| SO3 | pick all | every selectable offer linked, raised to Next, minted as needed, stamped |
| SO4 | pick none / Esc | recovery stands; no minted IDs; no successor-only placeholders; each offer `not_selected` |
| SO5 | pick a subset | only those rows linked; the rest `not_selected` |
| SO6 | stale, unknown, or out-of-range pick (`--pull-in` key/number) | teaching error, write-free; lists current offers |
| SO7 | a resident that is also a direct dependent | one row, `hand_off` |
| SO8 | stale `[ ]` resident with no own remaining reason | offered even though status is unchanged until picked |
| SO9 | resident still has an open prerequisite or future date | muted unselectable row in `still_blocked[]` with `project`; `#prj_is_blocked` stripped (PDC3) |
| SO10 | resident carries `#hide` | recover only; folded into the project group's hidden count |
| SO11 | resident already live-linked today | recover to derived rank; folded into the already-planned count |
| SO12 | `x~K` drops D's link in the same close that completes P | D's offer removed (`already_planned`); recovers to `[ ]` |
| SO13 | later item in a draft completes, links, re-blocks, or drops an offered task | that offer is dropped (net). Later item adds open work to N → N's `project_finished` offer is dropped |
| SO14 | unplanned predecessor; one open timed entry (else first open entry) | offered at the implicit destination; `entry_role: implicit`, `fallback: true` |
| SO15 | unplanned predecessor; no day file, no `## Pomodoros`, or no open entry | recover, `no_destination`, no offer |
| SO16 | unplanned predecessor; two open timed entries | recover, `no_destination`, no offer |
| SO17 | emptied-project pull-in, unpicked | `#hide` removed; no link; `not_selected`; `project_finished` |
| SO18 | parent-project pull-in (trigger c), unpicked | `#hide` removed; no link; `not_selected`; `project_finished`. Picked → linked after K's `^prj` bullet |
| SO19 | `plan.link_unblocked: false` | recover only; `disabled`; no offers; emptied `^prj` still loses `#hide` |
| SO20 | pick freshness bytes | `stamp_fresh` / `applyFreshStampLine`; `[fresh:: today]`; `[unblocked::]` and `[keeps::]` cleared. Unpicked Ready recover keeps the WRITE rule |
| SO21 | walk-landing close (cycler/nav) | picker holds the gesture lock; after it settles (including Esc) the walk advances exactly once; PRE/POST boundary unchanged |
| SO22 | double Ctrl+Enter (cycler/nav) | ignore Enter carrying modifiers, key repeats, and presses within \~350 ms of opening; the picker is not applied |
| SO23 | per-row destination override (Obsidian only) | Ctrl+Shift+Enter opens the Pomodoro link picker; pick returns `{kind: "existing", entryLine, entryText} \\| {kind: "new", name}` with the row; cycler inserts there |
| SO24 | N has open sub-projects on R's marker | one muted count line; descendants untouched (PDC5) |
| SO25 | empty selection on ↵ | applies the highlighted row when none are marked; Esc means none |
| SO26 | `bob capture` without `--pull-in` | recover; numbered `offer N` rows; `not_selected`; never prompts; never auto-links |
| SO27 | `--require-pull-in-choice` with offers and no `--pull-in` | write-free; `{"ok": false, "code": "pull_in_choice_required", "error": "…", "pull_in": {…}}`; exit 1 |
| SO28 | cancelled close (cycler) | recover only; never offer |
| SO29 | `all` / `none` with zero offers | no-op |
| SO30 | picked row became Blocked or was linked meanwhile | refused per row; never satisfied by linking its prerequisites |
| SO31 | `#ref` and inbox residents of an unblocked project | offered (SO1 includes them) |
| SO32 | 20-way fan-out | all 20 reachable; grouping and a filter; nothing pre-checked |

### 12.13 Obsidian picker API

Nav exposes `api.successors` on the frozen navigation-hotkeys api object (§9). Feature-detect `api.successors?.version >= 1`.

```
successors: {
  version: 1,
  pick(model) → Promise<{picks: [{key, destination?}]} | null>
}
```

- `pick` never rejects. `null` means none (Esc, click outside, unload, or a second call while one picker is already open).
- One picker at a time: a second call while one is open resolves `null`.
- `destination` on a pick is the per-row override: `Ctrl+Shift+Enter` on the marked (or highlighted) rows opens block-id-prompt's Pomodoro link picker (`api.pomodoroLinkPicker` v1: `pick()` resolves `{kind: "existing", entryLine, entryText, title} | {kind: "new", name} |   null`, never rejects, one picker at a time) and records the choice on those rows, shown as `→ NAME`; a cancelled pick changes nothing, and the hint is hidden when the picker is unavailable. The cycler inserts an overridden pick at the existing open entry verified by entry line + text preimage (a moved or closed entry fails that row loudly) or, for a new name, at a created `- [ ] () — NAME` placeholder in the same position block-id-prompt uses for new entries (an open same-name entry wins the race); created entries join the reopen receipt.
- The cycler plans without a selection, awaits `pick(model)` when offers exist, re-reads the daily note and affected notes, re-plans with the selection, and applies. The re-plan still anchors on the pre-gesture day: the fresh text with a closed Pomodoro's headline restored, so a Pomodoro-line close keeps its `closing` anchor.

**Model shape**

```
{
  title: string,
  predecessors: [{ notePath, blockId, text }],
  groups: [{
    group: "hand_off" | "project" | "project_finished",
    header: string,           // "Hand-offs" / project ^prj text / "Project finished"
    project?: string,         // vault-relative path of N
    rows: [{
      key: string,
      text: string,
      notePath: string,
      status: string,
      destinationLabel: string,
      needsBlockId: boolean,
      selectable: boolean,
      mutedReason?: string,
    }],
    folded?: { hidden: number, alreadyPlanned: number, subProjects: number },
  }],
  budget?: { links: number, max: number },  // plan meter; warns, never refuses
}
```

### 12.14 CLI selection

`-p, --pull-in <PICKS>` on `bob capture` (alphabetical between `--no-ref` and `--route`; short alias `-p`). `PICKS` is `all`, `none`, or a comma list of offer numbers and/or keys. Numbers index the numbered offers the same command reports without a selection. An unknown, stale, or out-of-range pick is a teaching error that writes nothing and lists current offers. `all` / `none` with zero offers is a no-op.

Hidden internal `--require-pull-in-choice` (no short alias; internal subprocess argument, absent from `--help`): when offers exist and no `--pull-in` was given, write nothing and fail with

```
{"ok": false, "code": "pull_in_choice_required",
 "error": "this capture has successor offers; pass --pull-in",
 "pull_in": {"supported": true, "offer_count": 2, "choice": null,
             "picked_count": 0}}
```

Same exit status (1) and shape conventions as the `plan_theme_cap_exceeded` refusal. `code` appears on these two refusals; every other capture failure omits it.

Human output: numbered `offer N` rows under group headers. A dry-run hint shows `-p 1,2 · -p all`. A real run without a pick says the offers were not pulled in and names Obsidian / Bob Mac Capture / Ctrl+Shift+Enter as the way to link later.

## 13\. Project dependencies

A project's (`^prj`) prerequisites live on its ordinary Depends-On first-child line, and its date as an inline `[scheduled::]` on `^prj`. While `^prj` has its own reason to be Blocked — an open projected prerequisite, or a future inline `scheduled` — every open task in its note is Blocked `[?]` and tagged `#prj_is_blocked`. No Blocked task keeps a Task Link under today's open Pomodoros: the link is consumed (prerequisites promoted, link removed, emptied Pomodoro deleted). Ordinary tasks, including ordinary tasks inside project notes, keep the Depends-On line and R1–R10 unchanged. `^prj` uses those same R-rules: no R0, no D3, and no R2 exception. Blocking also cascades to sub-projects: a blocked project's descendant project notes inherit its `^prj` reasons through `parent:` (§13.1, §13.3).

Implementations copy the PD tables in this section into their tests, citing the vector id in a comment. If an implementation disagrees with a vector, fix this section first in the same change. `[unblocked::]` vectors live in [freshness.md §10b](<https://bobs-org.github.io/freshness/#10b-ub-unblocked-vectors>).

### 13.1 Vocabulary

- **Project dependency:** a dep link on a Project Task's (`^prj`) Depends-On line. Project reasons are `^prj`'s own reasons.
- **Project (`project(T)`):** the note T lives in, when that note is `type: project`, has a non-terminal status, has exactly one open `^prj`, and is not a canonical daily note (`YYYY/YYYYMMDD.md`).
- **Resident task:** every recognised open `#task` line physically in the project note except `^prj`, including nested and `#ref` tasks. Fenced, blockquoted, and custom-status lines are excluded. Residence is physical; the cascade through `parent:` below is the one exception.
- **Parent (`parent(K)`):** the note named by K's frontmatter `parent:`, read as one scalar wikilink (alias and heading dropped, any path kept) and resolved like the note index (exact vault-relative path, else the unique case-insensitive basename among scanned notes). Unresolved, ambiguous, list-form, or K itself → no parent.
- **Ancestors (`ancestors(K)`):** `parent(K)`, `parent(parent(K))`, … while each is `project(·)`, stopping at the first note that is not, at a repeat (cycle), or at depth 16.
- **Descendants (`descendants(N)`):** every project K with N ∈ `ancestors(K)`.
- **Inherited reasons (`inherited(K)`):** for each A in `ancestors(K)`, nearest first, with `own_reasons(^prj` of A`) ≠ ∅`: the `project_dependency` / `project_schedule` reasons that name A.
- **Project reasons (`project_reasons` / `blocked(P)`):** `own_reasons` of P's `^prj` union `inherited(P)`. Internal targets are never projected, so they never contribute.
- **Own reasons (`own_reasons(T)`):** open prerequisites projected from T's Depends-On line (`local_dependency`) union `{ T.scheduled > today }` (`local_schedule`).
- **Consumption:** promoting a Blocked task's open prerequisites along `edges(T)`, then removing its live Task Links under today's open Pomodoros, then running the empty-Pomodoro rule.
- **`#prj_is_blocked`:** machine-owned tag on every open resident of a blocked project, and on the `^prj` of a project that inherits a reason from an ancestor (§13.7). A `^prj` carries the tag ⇔ `inherited(K) ≠ ∅`.

### 13.2 Storage

The `^prj` line is an ordinary task:

```
---
type: "[[project]]"
status: wip
---

- [?] #task #prj Apply to DataBricks NYC role(s)! [fresh:: 2026-10-12] [scheduled:: 2026-10-20] #hide [dependsOn:: job__update-linkedin] ^prj
  - ⛓️ **DEPENDS ON:** [[job#^update-linkedin]]
  - 🧩 **Sub-projects:** …

## Tasks

### Blocked

- [?] #task #ref [[…|…]] #prj_is_blocked ^ref-databricks-nyc-cv-and-role-pitches
```

- **Prerequisites.** `^prj`'s prerequisites sit on its ordinary Depends-On first-child line, exactly as on any task. They are reconciled by R1–R10. That line is first, or second after a `❌ **CANCEL LOG**` child. A BLOCKS line, when present, sits after the Depends-On subtree. The machine-owned `🧩 **Sub-projects:**` line follows both (§14.5).
- **Date.** The project date is `^prj`'s inline `[scheduled:: YYYY-MM-DD]`, placed like any task's schedule. Neither field lives in frontmatter.
- **Hoist.** `bob projects sync` hoists any leftover frontmatter `scheduled` onto `^prj` and deletes the key:
- frontmatter wins when both exist;
- terminal projects are left alone;
- an invalid value stays in place with a warning.

The hoist is permanent, which also makes it a shim for old nav builds and Properties edits. Report it as `hoisted_project_schedules`. - **Internal targets.** A `^prj` link to a task in its own note is a guaranteed deadlock: pickers refuse it; reconcile keeps the link on the line but does not project it into `[dependsOn::]`, and warns `project_internal_dependency`; JS project tests already ignore the project's own ids.

### 13.3 Effective blocking

```
own_reasons(T)  = open prerequisites projected from T's Depends-On line   (local_dependency)
                ∪ { T.scheduled > today }                                 (local_schedule)
project(T)      = the note T lives in when: type project, non-terminal status, exactly
                  one open ^prj, not a canonical daily note
blocked(P)      = own_reasons(^prj of P) ≠ ∅ OR inherited(P) ≠ ∅
                  (internal targets never projected)
^prj of K is Blocked ⇔ own_reasons ≠ ∅ OR inherited(K) ≠ ∅
                  (it reports inherited reasons naming A, never itself)
resident T of K is Blocked ⇔ own_reasons(T) ≠ ∅ OR blocked(K)
                  → project_dependency / project_schedule
edges(T)        = T's own dep links ∪ (T is a resident ? ^prj's projected links : ∅)
                  ∪ (T is ^prj(K) or a resident of K ? projected links of every
                     ^prj(A), A ∈ ancestors(K) : ∅)
internal(^prj N)= targets in N's own note OR in any descendant of N: kept on the
                  line, never projected into [dependsOn::], warn
                  project_internal_dependency, refused by pickers and capture &
```

- **Residents** are every recognised open `#task` line physically in the note except `^prj`, nested and `#ref` tasks included. Fenced, blockquoted, and custom-status lines are excluded.
- **The cascade.** A sub-project inherits every ancestor's `^prj` reasons through `parent:`, recursively: the inheriting `^prj` and every open resident are `[?]` and tagged. Each cycle member inherits from the others' own reasons, never from itself; reconcile warns `project_parent_cycle` once per cycle.
- **`^prj` reports own plus inherited reasons.** It never gets a `project_*` reason that points at itself.
- **Derive from reasons, never from the `[?]` cache.** A hand-set `[?]` on `^prj` blocks nobody.
- JS immediate paths may use `^prj`'s `[?]` as the "will the project still be blocked" shortcut.
- JS paths use the resident's own `#prj_is_blocked` tag as the "this resident is project-blocked now" signal.
- Engines that do not walk the tree read a tagged `^prj` as an extra project reason naming `parent(K)` (the tag proxy).
- **Recovery** happens only when every reason, own and inherited, is gone. The task returns to its existing derived rank. No previous status is stored. Lanes do not survive Blocked, now project-wide.
- **Reasons are reported, not stored.** The kinds are `local_dependency`, `local_schedule`, `project_dependency`, and `project_schedule`, with project path, open ids, and date. A `project_*` path may now name an ancestor that owns the reason. They appear in reconcile JSON, capture JSON, and plugin notices.
- **ADJ-11.** Dependency promotion never raises a `^prj` target into a lane, but the walk continues through it to that project's prerequisites. A direct Task Link raises `^prj` to Next like any other task. Residents of a depended-on project are not promoted.
- A terminal project (`status: done` or `canceled`) is not `project(T)`, so the chain stops at the first non-project note: its descendants inherit nothing from above it. A note with more than one open `^prj` is not a project under this rule. A canonical daily note typed `project` is ignored.

### 13.4 Promotion

`edges(T)` is the promotion, recovery, and R7 graph.

- Dependency promotion never raises a `^prj` target into a lane. ADJ-11 governs that rule. A direct Task Link raises a `^prj` to Next like any other task, including the §12.10 close-gesture pull-in.
- The walk continues through that `^prj` to the depended-on project's own Depends-On links.
- Ancestor edges: a `^prj` and its residents also link to every ancestor `^prj`'s projected targets. This feeds promotion, consumption seeds, and R7 cycle detection.
- Residents of a depended-on project are not promoted.
- Adding a project dependency from the keymap **transfers commitment:** each open prerequisite rises to at least the strongest lane among `^prj` and its residents. That transfer never lands on a `^prj` target.

### 13.5 Consumption

**Scope.** Every effectively Blocked task, not only project residents. Only live (unstruck) Task Links under **today's open** Pomodoros. Closed entries, `🍅` history, other days, and dependency links are never touched.

**Removal (reconcile and plugins).** A link-only bullet is removed with its subtree (PDL7); otherwise only the link token is removed (PDL6). Then the existing empty-Pomodoro rule runs; it may delete a running timed entry and every already-childless placeholder (accepted; matches cancel). Match is by the literal `[[route#^id]]` text.

**Removal (capture).** Capture matches live links by resolved identity — vault-relative path plus block id — so aliases, `[[#^local]]`, basename, path, and case spellings all match (PDL10). A childless link-only bullet is deleted. A link-only bullet with children keeps the marker, indentation, and children, replacing the link token with the task's clean display text: checkbox, tags, inline fields, and `^id` stripped, and only `[[…#^…]]` block links flattened (PDL8). A prose bullet loses only the token (PDL6). An entry is deleted only when this capture emptied it: it had a child list item before and none after, judged after all of the item's edits, including a running timed entry (PDL9). Unrelated childless placeholders stay. Every consumed link is a `consumed_links[]` row; every deleted entry appears in `pomodoro_blocks` with `removed: true` (PDL11). Reconcile keeps passing today's PDL7 behaviour.

**Promotion seed.** Before removal, the link seeds promotion exactly as a surviving link would, along `edges(T)`, so the task's open prerequisites (own plus inherited) rise to at least Next.

**Final-state test.** A link whose target's last reason clears in the same run is kept.

**Gesture rule.** Any gesture that would create or keep a live link to a task that is Blocked after the gesture follows one rule:

- only the task's **own** future `scheduled` blocks it → pull forward as today (retire the schedule, link it);
- a dependency or project-dependency reason → capture may drop the dependent's own open prerequisites under §13.5.1; otherwise do not link, promote the open prerequisites (own plus inherited), and report it;
- a `project_schedule` reason without a dependency reason → do not link; report the project date;
- **refinement: a `^prj` target whose only reason is its own date** → refuse, with "move the project date from the `^prj` Schedule action". This is never a pull-forward, which would silently unblock the project and strand residents' copies.

Capture never creates a linked `[?]` task: it creates it unlinked and reports `not_linked` with the reasons. Links under a Pomodoro the same gesture closes are history, not live links.

### 13.5.1 Capture drops its own prerequisites before linking

Capture link gestures may link an existing task that is Blocked only by its own open prerequisites by removing those prerequisites from its Depends-On line and `[dependsOn::]` field, then running the ordinary link gesture. The task is unblocked before its Task Link is added. This applies to solo `@route:id` / `^route:id`, `@route+id!`, Ensure Next, and link-then-close. A single own future `[scheduled::]` may accompany the dependencies and is retired by the existing pull-forward path.

The drop is capture-only. It requires a local dependency reason, no project or inherited project reason, a target other than a recognised project's `^prj`, no unsupported multiple future schedule fields, and exactly one accepted managed Depends-On child. If any gate fails, today's consumption or project-task refusal remains in force. When a local dependency reason prevents a drop, `link_guard.drop_refused` reports `project_blocked`, `project_task`, `schedule`, or `dependency_line`, and the human detail says why the prerequisites were kept.

The writer removes every link to each open own prerequisite, including duplicate links. Kept links remain verbatim and in order; prerequisite task lines are never edited; the drop removes the dependent from each prerequisite's BLOCKS line (§14). Their corresponding IDs are removed from the dependent's `[dependsOn::]` field, while unrelated and historical IDs remain. An empty Depends-On line and field are removed. The dependent is stamped fresh, then the ordinary capture link write runs in the same staged batch; a later failure leaves the drop unapplied. No `[unblocked::]` is written because a successful link puts the task in Next. Dropped links are reported in `dropped_dependencies` and human output; the task itself does not keep a history edge. Plugins retain the PDG1 consumption behavior.

**Pipeline.** Compute the final Blocked set (with project reasons), then plan the ledger in this order: dedupe, remove canceled links, **consume blocked links**, retire completed links and repair markers, remove empty Pomodoros. A second rewrite pass after transitions is an accepted alternative. Either way: dry-run output equals the live write, and a second run changes nothing (except the daily-note exception below).

**Known exception.** A prerequisite in a canonical daily note promoted only by consumption follows the sticky-lanes R9 carve-out and may clear on the next run.

### 13.5.2 Capture star-blocks execution vectors (ST)

Capture `*note:block-id` makes N a prerequisite of T. The grammar, wire names, and human lines live in `docs/capture.md`. Existing-task owner behavior and its RX/RXC vectors are documented in [Relating existing tasks with `&` and `*`](<https://bobs-org.github.io/capture/#relating-existing-tasks>). Parse vectors **STP** are the grammar-table rows there. These **ST** rows are the execution contract later phases test.

| \# | Situation | Outcome |
| --- | --- | --- |
| ST1 | `N *T`, T Ready and unlinked | N `[ ]` with a minted `^id` and a BLOCKS line; T's Depends-On gains N; T `[?]` and fresh |
| ST2 | T `[*]`, linked under the running entry, which has other children | link removed; entry kept; N `[*]` |
| ST3 | T `[/]`, the running entry's only child | link removed; entry deleted and reported (`removed: true`, `(running)`); N capped at `[*]` |
| ST4 | T's link bullet has authored child notes | bullet becomes T's plain text; children and entry kept (`notes_kept`) |
| ST5 | nested T; the ledger link is a basename or alias spelling | consumed by identity |
| ST6 | `N @mac_inbox:id *T` | N linked into the session first; T's link consumed; session kept |
| ST7 | an unrelated childless placeholder entry | untouched by capture |
| ST8 | `^prj` T | residents `[?]` plus `#prj_is_blocked`; their links consumed; `blocked_residents` reported |
| ST9 | `^prj` T while N lands in that project or a descendant | refused; vault byte-identical |
| ST10 | `N *T &T`; an existing owner that already depends on T; transitive and cross-item cycles | refused; vault byte-identical |
| ST11 | T closed, in a daily note, in `done/`/`_templates`/fenced code, self, or with two or malformed Depends-On lines | refused with a teaching error |
| ST12 | repeat `*T` on an existing owner already on T's line; a second live run | `already_present`; no-op |
| ST13 | `N *T1 *T2 *T1` | T1 then T2; the duplicate is a no-op |
| ST14 | `N &P *T` | T → N → P; N `[?]`; T `[?]` |
| ST15 | `dependencies.blocks_lines: false` | N still gets an ID; no BLOCKS line |
| ST16 | `N *T` then a later item `@T-route:T-id` | N dropped from T's line (capture-link-drops); final preview accurate |
| ST17 | `N *T` vs the two-item `N @mac_inbox^id` + `&mac_inbox:id @T-route+T-id`, T Ready or Next | identical vault diffs apart from the ID |
| ST18 | any ST vector, dry run vs live run | identical JSON apart from `dry_run` |
| ST19 | `*T @route+id`, and `Note text @route+id *T` | owner gets the normal §5 transfer (may become `[/]`); sub-bullet added |

### 13.6 Guards

- Pickers refuse `^prj` targets inside the same project note, and targets inside any descendant of the project ("inside a sub-project of this project"). A `^prj` that depends on its own sub-project's task would block that task forever — the same guaranteed deadlock §13.6 already prevents for same-note targets. A hand-authored internal target stays on the line, is not projected onto `[dependsOn::]`, and warns `project_internal_dependency`, naming the sub-project for descendant targets.
- Cycle detection (reconcile R7, capture, nav stage) includes the implicit resident → `^prj` edges. Longer cycles keep the R7 behavior (keep and warn).

### 13.7 `#prj_is_blocked`

**Rule.** An open resident of a blocked project carries exactly one `#prj_is_blocked`, and a `^prj` carries it ⇔ it inherits a reason from an ancestor. No other open task carries it.

- Closed lines are never edited; a tag left on a closed line is accurate history.
- Every open resident is tagged, including residents that also have their own reasons (`tag_scope = every_resident`).
- An inheriting `^prj` is tagged exactly once; placement is as PDT9.

**It is machine-owned derived state,** like `[?]` and `#hide`.

- **Reconcile is the authority.** It adds the tag, and strips it from open lines that no longer qualify (moved out, project unblocked, project terminal, hand-copied, or a `^prj` whose ancestors cleared).
- **The Task Card subtree pass is the immediate cascade writer.** After a Depends on edit — or a Schedule edit — to N's `^prj`, it blocks and tags N's descendants top-down in the same gesture, and recovers them when N's last reason clears (§13.10). The Blocks stage (§15) runs the same pass for each project it picks as T.
- **Same-note writers keep their own note current in the same write** wherever they flip a resident: capture (`&` on `^prj`, new tasks into a blocked project); nav (the Depends on stage on `^prj`, the Blocks stage when a `^prj` is the picked T, Ctrl+Shift+M, conversion); and cycler/capture recovery of residents.
- **Every other cascade write defers to reconcile.** Capture `&` on `^prj`, close recovery, Alt+`]`/Alt+`[` on a `[?]` `^prj`, Ctrl+Shift+M, conversion, and new child projects respect the tag proxy but write no descendant note: a close that unblocks a project leaves its descendants tagged until the next reconcile (≤ 15 minutes on the MacBook cron), which re-derives them.
- A missed immediate write is repaired within one reconcile interval.

**Placement.** Use `projects/edits.rs` `task_metadata_insertion_offset`, which puts it after the fields and before `^id`. Both Rust parsers and the freshness placement rule already treat trailing tags as part of the Tasks suffix. In reconcile, tag edits are line rewrites and count as structural, so the quiet period applies.

**Matching hazards.**

- Tasks and Dataview `tags include #prj` is a substring match. Document it; no vault query depends on it today.
- Every engine's own `#prj` / `#prj_is_blocked` matching must be token-bounded. Each phase audits its engine.
- Add the tag to `projects/tags.rs`' description strip list.

### 13.8 Project schedule side effects

- **Propagation stays residence-only.** Sync and nav still copy the date onto residents, raise-only, but now read it from `^prj` inline. Project dates are never propagated to descendant notes.
- **Move-earlier fix (nav only):** when a gesture changes `^prj`'s date from P\_old to P\_new, also replace residents' copies that are exactly P\_old. Ctrl+D already removes copies by exact value.
- Rust sync cannot know P\_old. A hand edit that moves the date earlier keeps the old copies; that is a known limit.
- **`#hide` forcing** for a future date stays in sync.
- **Stamps:** `^prj` follows the normal stamp rule. The "project-frontmatter edits never stamp" carve-out is gone.
- **Due or deleted dates follow `^prj` status.** Residents lose `#prj_is_blocked` when `^prj` leaves Blocked. A closed Depends-On history line does not keep the tag. Residents keep it while `^prj` stays `[?]` (an open prerequisite or a still-future date). Reconcile repairs the tag once the last remaining reason clears.

### 13.9 Outputs and warning kinds

Additive reconcile output:

- `consumed_blocked_references`: `{path, line, pomodoro, task, reasons, promoted[]}`
- reason kinds on `marked_blocked` / `unblocked`: `local_dependency`, `local_schedule`, `project_dependency`, `project_schedule`
- `prj_is_blocked_tags_added`, `prj_is_blocked_tags_stripped`
- `unblocked_fields_written`, `unblocked_fields_pruned`

Additive `bob projects sync` output:

- `hoisted_project_schedules`

A `project_*` reason's project path may name an ancestor that owns the reason, not the note the blocked task lives in.

Warning kinds:

- `project_internal_dependency`
- `project_parent_cycle` (once per parent cycle, naming every member)

### 13.10 Gestures on `^prj`

Ctrl+Shift+P → Depends on on `^prj` (and a Task Link to `^prj`) edits its Depends-On line plus resident side effects. One write: the line, the `^prj` `[dependsOn::]` field, set `^prj` and every open resident to `[?]` when any target is open, tag residents `#prj_is_blocked`, consume their live links, transfer commitment, and — only when `^prj` itself was `[?]` before the gesture — recover the tagged residents with no remaining reason when the last reason clears (strip the tag; write `[unblocked::]` when freshness.md §5 says to). Removing a link from an unblocked `^prj` leaves untagged residents and their own reasons alone. Ctrl+D on the Depends on row deletes the line and the field.

After planning an edit to N's `^prj` through Depends on, the Task Card runs the subtree pass: when N is blocked before or after the gesture (own reasons, or N's `^prj` carrying the tag), it walks N's descendants top-down — `blockedAfter(K)` is K's own reasons or its parent's blocked-after state — and per descendant `^prj` writes the tag ⇔ inherited, `[?]` while blocked (a tagged `[?]` `^prj` with no remaining reason recovers to its derived rank), and per resident the tag ⇔ blocked, `[?]` while blocked (tagged residents with no own reason recover with `[unblocked::]` per freshness.md §5); live Task Links of newly Blocked tasks under today's open Pomodoros are consumed, and commitment transfers the strongest newly-blocked lane (never onto a `^prj` target). Write order is N's note, then each descendant note preimage-checked (a stale note is skipped and reported), then the daily Pomodoro cleanup. A gesture on a tagged N's `^prj` never recovers anything: it only re-asserts the descendants' tags. Ctrl+Z undoes N's edit only; the next reconcile re-derives the descendants.

Schedule on `^prj` writes the inline `[scheduled::]` field and runs the same subtree pass for a future date, a due date, and a delete; a tagged `^prj` keeps its tag through every Schedule write. Conversion is line ↔ line both ways. Capture `&` on `^prj` writes the line. `Alt+]` / `Alt+[` on a `[?]` `^prj` retires its own future date; when that leaves the project with no remaining reason, the same gesture strips `#prj_is_blocked` from every open resident and recovers those whose only reason was the project, writing `[unblocked::]` when freshness.md §5 says to. A remaining reason is a future inline date or a Depends-On id that resolves to an open task; a missing id never blocks (DC7). Cycling `^prj` out of `[?]` to any non-Blocked status — including Alt+\[ to Cancelled, which leaves the note no longer `project(T)` — recovers the same way, and a transcluded `^prj` recovers residents in its source note. Closed lines are never edited.

`create-project-note-from-task` (Ctrl+Alt+Shift+N) keeps the source's Depends-On line as the first child of `^prj` and its inline date on `^prj` on the way forward (rebase `[[#^a]]` → `[[source#^a]]`; never seed that line as a Tasks row; skip the machine-owned Sub-projects child). The new project lands blocked only for a carried open prerequisite or a future inline date — a `[?]` source alone seeds Ready. Commitment transfers the residents' pre-seed lanes. On reverse, the line becomes the restored task's first child and the date stays inline on the root task (links re-formed relative to the destination). `[?]` sources are accepted. Residents blocked by the project are tagged or recovered through the shared recovery gate; entering a blocked project consumes their live Pomodoro links in today's open entries. Reversal writes the parent first, planning `#^prj` rewrites and today's Pomodoro cleanup against both the project's `^prj` link and the restored block id in memory before any write. A rewrite that throws or finds changed content after the parent write keeps the project note and is reported (`... was kept`, with the Pomodoro suffix when the daily rewrite failed).

Ctrl+Shift+M into a blocked project sets the moved open tasks `[?]`, tags them `#prj_is_blocked`, promotes only open tasks' Depends-On targets (each to that task's own lane; the destination `^prj`'s prerequisites to the strongest moved lane), walking through a `^prj` target to its open prerequisites without raising it, and removes their live links from today's open Pomodoros in the same move write; moving them out strips the tag and recovers those without own reasons, writing `[unblocked::]` when required by the freshness gate. Move, inbox-route, and reversal notices report the blocked and recovered counts (`· N Blocked · M Ready again`).

Capture `&` aimed at `^prj` writes the Depends-On line. Capture of a new task into a blocked project lands `[?]`, `#prj_is_blocked`, and unlinked. `@route:id s:N` with N ≥ 1 creates the task unlinked.

Writers other than the Task Card never cascade: capture `&` on `^prj`, close recovery, Alt+`]`/Alt+`[` on a `[?]` `^prj`, Ctrl+Shift+M, conversion, and new child projects (capture `+`, Ctrl+Shift+Alt+N). Each one still respects the tag proxy, so it never contradicts the cascade, and descendants catch up at the next reconcile. Closes and Alt+`]`/Alt+`[` that unblock a project do not recover its sub-projects at once: descendants keep their tags until reconcile re-derives them.

### 13.11 Rollout

An old `bob projects sync` on the MacBook cron (`:05/:20/:35/:50`) **deletes** inline `^prj` dates. So:

1. bob-cli on the MacBook first;
2. then this host;
3. then plugins on the MacBook.

Obsidian does not run on this host, so plugin phases deploy here as usual. Phases never run a live `bob task reconcile` or a live `bob projects sync`. Dry runs are always fine.

### 13.12 PDR — reconcile vectors

Each vector names the rule under test. `^prj` uses plain R1–R10.

| \# | rule | situation | outcome |
| --- | --- | --- | --- |
| PDR4 | R1 | well-formed Depends-On line on open `^prj`, two resolved open targets | `[dependsOn::]` set to both ids in line order; targets stamped; same as DR1 |
| PDR7 | internal | `^prj` Depends-On link to a task in its own note | kept on the line; not projected into `[dependsOn::]`; warn `project_internal_dependency` |
| PDR8 | R7 | inherited cycle via a resident → `^prj` edge | keep; warn `dependency_cycle` with the path |
| PDR9 | scope | closed (`[x]`) `^prj` with a stale Depends-On line | untouched (DR18) |
| PDR10 | idempotence | second run over a reconciled vault | zero changes (except the daily-note exception in §13.5) |

### 13.13 PDB — blocking vectors

| \# | situation | outcome |
| --- | --- | --- |
| PDB1 | `^prj` has one open Depends-On target; note holds a `#ref` task, a nested `#task`, and a fenced `#task` example | `#ref` and nested residents `[?]` with `#prj_is_blocked`; fenced line unchanged |
| PDB2 | same project | `^prj` is `[?]` with `local_dependency` only (never `project_dependency`) |
| PDB3 | last open target closes; a resident still has its own future `scheduled` | that resident stays `[?]` (own reason); others recover to derived rank; `#prj_is_blocked` stripped from all |
| PDB4 | `status: done` (or `canceled`) project with a still-open `^prj` prerequisite | not `project(T)`; residents are not inherited-Blocked; tag stripped |
| PDB5 | child project `parent: "[[blocked-parent]]"`; child has no own project reasons | child `^prj` and residents inherit: Blocked and tagged; see PDS1 |
| PDB6 | two open `^prj` lines in one note | not `project(T)` |
| PDB7 | `^prj` `[scheduled::]` strictly later than today; no open Depends-On target | `^prj` `[?]` with `local_schedule`; residents `[?]` with `project_schedule` and `#prj_is_blocked` |
| PDB8 | canonical daily note typed `project` with a `^prj` Depends-On line | ignored; not `project(T)` |
| PDB9 | ADJ-11: a dependent's open prerequisite is another project's `^prj`, which itself depends on an ordinary task | the `^prj` target is never raised into a lane; the walk continues and raises that project's open Depends-On targets; residents of the depended-on project are not promoted |

### 13.14 PDL — ledger vectors

Only today's open Pomodoros. `promoted[]` is the consumption seed.

| \# | situation | outcome |
| --- | --- | --- |
| PDL1 | live link to a Blocked task under an open entry that has other children | link consumed; prerequisites promoted; entry kept (`consumed_blocked_references`) |
| PDL2 | that consumed link was the entry's last child | entry deleted (empty-Pomodoro rule; a running timed entry may go) |
| PDL3 | the target's last reason clears in the same run | link kept (final-state test) |
| PDL4 | closed entries in the same day file | byte-identical |
| PDL5 | struck link under an open entry | untouched |
| PDL6 | prose bullet whose only Task Link token is the Blocked target | token removed; the rest of the bullet kept |
| PDL7 | planned link-only bullet with authored child notes | whole subtree removed. Reconcile and plugin consumption keep this row |
| PDL8 | capture: planned link-only bullet with authored child notes | bullet becomes the task's clean display text; children and entry kept (`notes_kept`) |
| PDL9 | capture: an entry that had children before this consumption and none after | that entry deleted (including a running timed entry); already-childless placeholders stay |
| PDL10 | capture: ledger link is an alias, basename, path, case, or `[[#^local]]` spelling of the Blocked target | consumed by resolved identity |
| PDL11 | capture: any consumed link and any entry this capture emptied | `consumed_links[]` row (`removed` / `token_removed` / `notes_kept`); deleted entry in `pomodoro_blocks` with `removed: true` |

### 13.15 PDG — gesture vectors

| \# | gesture | outcome |
| --- | --- | --- |
| PDG1 | plugin link gesture (Ctrl+Shift+Enter or `^^`) on a dependency-blocked target, or capture when §13.5.1 refuses a drop | not linked; open prerequisites (own plus inherited) promoted; reported |
| PDG2 | same link intent through `!`, Pomodoro-link insertion, Ensure Next, or link-then-close; only the target's own future `scheduled` blocks it | pulled forward as today and linked |
| PDG3 | same toggle; only a project schedule blocks a resident | refused; project date reported; not linked |
| PDG4 | `@route:id s:N` with N ≥ 1 | created unlinked |
| PDG5 | new task captured into a blocked project | `[?]`, `#prj_is_blocked`, unlinked, `not_linked` with the project reasons |
| PDG6 | link toggle on a `^prj` whose only reason is its own future `scheduled` | refused; "move the project date from the `^prj` Schedule action"; never a pull-forward |
| PDG7 | capture `&` on `^prj` adding an open target | residents `[?]` + `#prj_is_blocked`; their live links consumed; commitment transferred onto the open prerequisite (never onto a `^prj` target) |
| PDG8 | Task Card Depends on adds an open target to an unblocked, untagged N's `^prj`. N has child K (2 residents) and grandchild G | N's residents, plus K's and G's `^prj` and residents, become `[?]` + tag. Their live links in today's open Pomodoros are consumed. The target rises to the strongest of their lanes. Notice `· 2 sub-projects Blocked` |
| PDG9 | removing N's last open target (N untagged, `^prj` `[?]` before) | K and G recover: tags stripped, and tagged lines with no own reason return to their derived rank with `[unblocked::]` per §5. Notice `· 2 sub-projects Ready again` |
| PDG10 | as PDG9, but K's `^prj` has its own open target | K's `^prj` loses the tag and stays `[?]` (`local_dependency`). K's residents keep `[?]` + tag (reason names K). G keeps its tags (reasons name K) |
| PDG11 | any Depends on gesture on a tagged N's `^prj` (N inherits from M) | nothing recovers. Descendants' tags are only re-asserted |
| PDG12 | Task Card Schedule writes a future date on an unblocked N's `^prj`; then a due date, or Ctrl+D on the date | like PDG8 (with `project_schedule`); then like PDG9. A tagged `^prj` keeps its tag through every Schedule write |
| PDG13 | Depends on stage on N's `^prj`, or capture `&` on it, targeting a task in a descendant | refused: "inside a sub-project of this project" |
| PDG14 | a descendant note changed after the pass read it | that note is skipped and N's write stands. Notice suffix `· 1 sub-project not updated`. The next reconcile repairs it |
| PDG15 | a link gesture (capture link guard, `^^` / Ctrl+Shift+Enter in block-id-prompt, Pomodoro link insertion) on K's `^prj` or a K resident while K's `^prj` is tagged | not linked. The `project` reason names the owning ancestor (capture) or `parent(K)` (plugins). Capture promotes the open prerequisites of each ancestor up to and including the first untagged one |
| PDG16 | a new task captured into K while K's `^prj` is tagged | lands `[?]`, `#prj_is_blocked`, unlinked, `not_linked` with the project reason |
| PDG17 | Alt+\] / Alt+\[ on K's tagged `[?]` `^prj` | refused, as for a tagged resident: `Project blocked by parent project · <parent(K)>` |
| PDG18 | capture `@route:id` (also `^route:id`, `#name`, `=<X>`) on a target whose only reason is `local_dependency` | open prerequisite links dropped from the line and field; `[?]` → `[*]`; linked (and started for `=<X>`); `dropped_dependencies` listed; prerequisite notes byte-identical |
| PDG19 | the same target through `@route+id!`, `@route+id` / `@route+id#name` Ensure Next, and `@route:id=x…` | dropped, then linked, relocated, or linked-and-closed exactly like an unblocked target (`x1` leaves it `[/]`, `x!1` completes it) |
| PDG20 | the dropped links were the line's only links | line deleted; `[dependsOn::]` removed |
| PDG21 | own open prerequisites plus a single own future `scheduled` | both retired in one write (PDG2 pull-forward plus the drop); linked |
| PDG22 | the line also holds a Done prerequisite and an unresolved link | the Done and unresolved links are kept verbatim in order; their field ids are kept |
| PDG23 | own open prerequisites, and the task is a resident of a blocked project (own or inherited reason) | nothing dropped; PDG1/PDG15 consumption; `drop_refused: project_blocked` |
| PDG24 | a `^prj` target with an open prerequisite | nothing dropped; PDG1 consumption; `drop_refused: project_task` |
| PDG25 | two managed Depends-On lines, or several `scheduled` fields | nothing dropped; consumption; `drop_refused: dependency_line` or `schedule` |
| PDG26 | `^route:id=` while a timed Pomodoro runs, or a later item in the batch fails | the batch fails; nothing is dropped; the dry run reports the same failure |
| PDG27 | plugin Ctrl+Shift+Enter or `^^` on a dependency-blocked target | unchanged PDG1; plugins never drop |
| PDG28 | Ctrl+Shift+P then `b` or Ctrl+Shift+B Depends on add (single, marked, counted, or Task Link mode) of an open target to an ordinary task that has a live link in today's open Pomodoro | the task is `[?]`; its live links are removed and an emptied open entry is deleted; closed entries are untouched; commitment is transferred; the notice gets `· removed N Pomodoro link(s)` |
| PDG29 | the same add when the dependent lives in today's daily note; or a stale daily note after the snapshot | same-file: those effects land in one undo group. Stale daily: the dependent write stands, the notice adds `; Pomodoro links not removed`, and reconcile repairs it |

### 13.16 PDC — close vectors

Outcomes of the successor-offer rule (§12.2) once project reasons exist.

| \# | situation | outcome |
| --- | --- | --- |
| PDC1 | dependent D lives in a still-blocked project; this close removed D's last *own* prerequisite | D stays `[?]`; `still_blocked{reason: project}`; no offer |
| PDC2 | this close fully unblocks a `^prj`; residents have no own remaining reason | `^prj` is the project group header (not a choice) while N has open work; every open resident (`[?]` or `[ ]`) is offered under the `project` group, or muted/folded; `#prj_is_blocked` stripped; unpicked recover to derived rank and receive `[unblocked::]` when freshness.md §5's WRITE rule applies (SO1) |
| PDC3 | same as PDC2, but one resident still has an open prerequisite of its own | that resident stays `[?]`; muted unselectable row with its reason; `#prj_is_blocked` is still stripped |
| PDC4 | dependent D is a resident of K (K's `^prj` tagged); this close removes D's last own prerequisite | D stays `[?]`; `still_blocked{reason: project, project: parent(K)}`; no offer |
| PDC5 | K's tagged `^prj` loses its last own prerequisite | stays `[?]`; `still_blocked{reason: project}`; no `project_task` recover; no PDC2 / PDC3 for K's residents |
| PDC6 | a close fully unblocks an untagged N that has tagged descendants | descendants keep their tags until the next reconcile, which recovers them (PDS4 / PDS5 outcomes). PDC2 covers only N's own residents |

### 13.17 PDF — Databricks fixtures

Use these two vault states as end-to-end fixtures. Adapt descriptions if the notes have been edited since. The Depends-On child stays; there is no R0 hoist.

**PDF1 — pre-06:57 (links still in the ledger).** `job_apply_to_databricks.md` has a Depends-On child `[[job#^update-linkedin]]` on a `[?]` `^prj` with `[dependsOn:: job__update-linkedin]`, and two `#ref` tasks `[*]` under `### Next & In Progress`. Both `#ref` Task Links sit in the open timed `0645-0710 — JOB` Pomodoro, which has other children.

After one reconcile:

- the Depends-On child stays;
- `[dependsOn:: job__update-linkedin]` remains on a `[?]` `^prj`;
- both `#ref` tasks are `[?]` with `#prj_is_blocked` (PDB1);
- exactly those two links are consumed (PDL1); the JOB entry is kept because other children remain;
- LinkedIn (the prerequisite) is promoted;
- a second run changes nothing (PDR10).

A sibling fixture of PDF1 whose blocked links are the last children of their entry expects that entry deleted (PDL2).

**PDF2 — current (links already removed by hand).** Same project note and field, no live `#ref` links under today's open Pomodoros.

After one reconcile: the inherited Blocked and tags of PDF1, and **no** ledger change (`consumed_blocked_references` empty for those two tasks). `job.md`'s LinkedIn task stays `[?]` from its own dependency. `job_update_cv`'s `^prj` is not raised (PDB9).

### 13.18 PDT — `#prj_is_blocked` tag vectors

`tag_scope = every_resident`: every open resident of a blocked project carries the tag.

| \# | situation | outcome |
| --- | --- | --- |
| PDT1 | project becomes blocked (open projected prereq or future `^prj` scheduled); note holds open residents | each open resident gains exactly one `#prj_is_blocked`; `^prj` does not |
| PDT2 | project recovers (last reason gone) | tag stripped from every open resident |
| PDT3 | resident moved out of a blocked project | tag stripped from the moved task |
| PDT4 | project becomes terminal (`status: done` / `canceled`) | tag stripped from every open resident |
| PDT5 | hand-copied `#prj_is_blocked` on an open task that is not a resident of a blocked project | stripped |
| PDT6 | `#prj_is_blocked` on an open `^prj` that inherits no reason | never added; if present, stripped |
| PDT7 | closed (`[x]` / `[-]`) line carrying `#prj_is_blocked` | untouched (history) |
| PDT8 | resident already tagged, project still blocked | exactly one tag (no duplicate) |
| PDT9 | placement | tag lands after the fields and `#hide`, before `^id` (`task_metadata_insertion_offset`) |
| PDT10 | CRLF preserved | tag edit uses the note's line endings |
| PDT11 | resident with its own open prerequisite, in a blocked project | still tagged `#prj_is_blocked` (and stays `[?]` for both reasons) |
| PDT12 | token-bounded matching | `#prj_is_blocked` is never matched as `#prj`; `#prj` is never matched as `#prj_is_blocked` |
| PDT13 | an open `^prj` that inherits a reason | tagged exactly once; placement as PDT9 |

### 13.19 PDS — sub-project cascade

Outcomes of the cascade rule (§13.3) in reconcile. Reconcile resolves `parent:` for every recognised project from the scan it already holds, so the cascade costs no extra file reads.

| \# | Situation | Outcome |
| --- | --- | --- |
| PDS1 | N's `^prj` has one open target X. Child K (`parent: "[[N]]"`) has no own reason; its residents are a nested `#task` and a `#ref` task | K's `^prj` → `[?]` + `#prj_is_blocked`, with reason `project_dependency{project: N, open_ids: [X]}`. Both residents → `[?]` + tag, with the same reason. N's `^prj` stays untagged |
| PDS2 | grandchild G of K (3 levels) | G's `^prj` and residents are Blocked and tagged; their reasons name N |
| PDS3 | N's `^prj` has a future inline date and no open target | descendants get `project_schedule{project: N, date}` |
| PDS4 | X closes | K and G lose every tag. Each line returns to its derived rank. `[unblocked::]` is written per freshness.md §5 (a `#hide` `^prj` is skipped) |
| PDS5 | as PDS4, but K's `^prj` also has its own open target Y | K's `^prj` loses the tag but stays `[?]` (`local_dependency`). K's residents keep `[?]` + tag (reason names K). G keeps its tags (reasons name K) |
| PDS6 | K's parent is an area note, a terminal project, a two-`^prj` note, a canonical daily note, unresolvable, an ambiguous basename, a list-form `parent:`, or K itself | no inheritance. A hand-copied tag on K's `^prj` is stripped |
| PDS7 | N → K → G with K `status: done` | G inherits nothing from N (the chain stops at the first non-project) |
| PDS8 | N's `^prj` Depends-On line links a task in K or G | the link is kept on the line, never projected into `[dependsOn::]`, and warns `project_internal_dependency`; it blocks nobody |
| PDS9 | parent cycle A ↔ B; A's `^prj` has an open target | B inherits from A; A gets no reason from itself; `project_parent_cycle` warns once, naming both |
| PDS10 | live Task Links to K's `^prj` and to a K resident under today's open Pomodoro | both are consumed. X is promoted through the ancestor edges (ADJ-11 still never raises a `^prj` target). `consumed_blocked_references` lists the reasons |
| PDS11 | Y depends on a K resident while N's `^prj` depends on Y | R7 warns `dependency_cycle` through the implicit ancestor edge |
| PDS12 | second run over a reconciled tree | zero changes |
| PDS13 | K's `^prj` was `[*]` and linked today when N became blocked | `[?]`; the link is consumed; the lane is lost |
| PDS14 | `bob projects sync --dry-run` on a cascade-blocked tree | no change caused by the tags: descriptions drop the tag, marker states are unchanged, and a `[?]` child still counts as an open sub-project |
| PDS15 | `parent: "[[N\\|Alias]]"`, `"[[dir/N]]"`, `"[[N#Heading]]"` | all resolve to N |

## 14\. Blocks lines

Every open task that other open tasks depend on carries one machine-owned `🧱 **BLOCKS:**` child listing those dependents. The line is a projection of Depends-On: `bob task reconcile` is the authority, every forward-edge writer keeps it current, a closed owner's line freezes as history, and nothing reads it back as dependency input.

```
- [?] #task Did enrolling for unemployment work! [id:: cash__unemployment] [scheduled::
  2026-10-14] ^unemployment
  - 🧱 **BLOCKS:** [[#^audit-subscriptions]] • [[#^call-north-face]] •
    [[#^get-access-money]] • [[#^save-verizon-router-pwd]]
  - Kelly just needs 2 pay stubs and/or my W2!
```

A chain node reads upstream, then downstream:

```
- [?] #task Make appt w/ Rahway Hospital for CT scan! ^rahway
  - ⛓️ **DEPENDS ON:** [[#^hospital-swarm]]
  - 🧱 **BLOCKS:** [[#^appt-7d-after]]
  - 🗓️ **SCHEDULE LOG**
```

In Obsidian the line renders as chips from the same family as the Depends-On chips. A 🔑 marks each dependent for which this task is the last thing in the way:

```
[?] Did enrolling for unemployment work!
    🧱 blocks  ( ? Audit monthly subscriptions 🔑 )  ( ? Dispute North Face… 🔑 )  ( ? Get access… 🔑 )  ( ? Save Verizon… 🔑 )   frees 4
[ ] The first SASE launch post: introduction
    🧱 blocks  ( ? Review outline and intro proposal )                                       frees 0 of 1
[x] (a closed prerequisite: the frozen line)
    🧱 was blocking  ( ✓ … )  ( ? … )                                                         ✓ freed 2
```

### 14.1 Vocabulary

- **Blocks line:** the single managed child `🧱 **BLOCKS:**` that lists the owner's open direct dependents as plain Task Links.
- **Owner:** the prerequisite task that carries the line.
- **Frozen line:** a Blocks line on a closed owner. Reconcile and writers never touch it.

`⛓️ **DEPENDS ON:**` on the dependent remains the only place a dependency is stored; it is edited from the dependent's Depends on stage or from the owner's Blocks stage (§15), which writes the same line. No engine reads a Blocks line as dependency input: not Blocked derivation, promotion, consumption, successor offers, fields, R2 adoption, or R8 legacy children. A hand edit of a Blocks line never adds or removes an edge; reconcile restores the canonical line.

**Key invariant (BR26, BR27):** deleting every BLOCKS line in the vault changes nothing about blocking, promotion, scheduling, successor offers, fields, or authored edges. The next reconcile rebuilds identical bytes, and a second run makes zero changes.

### 14.2 Grammar

```
<indent>- 🧱 **BLOCKS:** <link> • <link> • …
```

- **Writer form.** `🧱` (U+1F9F1, one code point, no VS16), one space, `**BLOCKS:**`, one space, then links joined by `•` (U+2022 with one space on each side).
- No aliases, embeds, strikes, or duplicate entries.
- The line is deleted when it would be empty; there is no placeholder.
- **Link form.** Same as §3: the shortest unambiguous form from the owner's note (`canonical_link` / nav `canonicalDependencyLink`), so `[[#^id]]`, then `[[basename#^id]]`, then `[[dir/note#^id]]`.
- **Reader tolerance.** All of these canonicalise:
- the emoji is missing, or is `🚧`, `⛓️`, or `⛓`;
- the label is `BLOCKING`;
- the separator is `·`, `,`, or whitespace;
- a link is aliased, struck, or embedded;
- the order is not canonical;
- an entry is duplicated;
- the line is a direct child in any slot.
- **Not a line:**
- a lowercase label;
- the label `BLOCKED BY` (it names the opposite direction);
- a fenced, nested, blockquoted, or marker-less line.
- **Parse algorithm.** Use the §2.3 algorithm, keyed on its own label: link scanner first, never split on separators, and only a direct child of a `#task` counts. The Depends-On and BLOCKS parsers each return not-a-line for the other's line (BK20).
- **Malformed** means prose after the links, a half-typed link, a bare note link, a heading link, or separators only. A malformed line is left alone and warns `malformed_blocks_line`. Writers insert no second line beside it.

### 14.3 Membership

```
open(t)    = status type Todo | In Progress | On Hold (includes [?])            (§4.1)
edges      = the accepted explicit edges R1–R10 project: resolved scanned-task targets
             of the line, plus R8 legacy-child targets during the legacy window, plus
             R2-adopted links. Excluded: R4 unresolved, R5 non-task, R6 self, archive
             (done/) targets, previous-daily or unencodable targets kept verbatim, and
             ^prj internal or descendant targets. Implicit resident → ^prj edges and
             transitive chains are never edges here.
blocks(T)  = { D : (D → T) ∈ edges ∧ open(D) ∧ D not in the previous daily note }
T owns a line ⇔ open(T) ∧ blocks(T) ≠ ∅ ∧ T is writable (not the previous daily note)
closed T   → its line is never touched: frozen history
```

- A `^prj` dependent is listed once, as its own `^prj` line. Its residents are never listed.
- A cycle A ↔ B lists each task on the other's line. R7 already warns about the cycle.
- `#hide`, `ref/`, inbox, and today's daily-note tasks are in scope on both sides.

### 14.4 Identity and minting

- A dependent with no `^block-id` gets one appended, minted with `mint_block_id(description, used)`. This is the SB pipeline of §11.7: the body after the status box, cleaned with the `#task` global filter. `used` is every block id in the dependent's note plus this gesture's or run's reservations.
- The mint happens once per dependent. Existing ids stay stable.
- Minting never adds `[id::]`, because `[id::]` means "is a prerequisite".
- BLOCKS writes never stamp `[fresh::]`, never change a status, lane, tag, or field, and never touch the owner's task line. They write only the child line, plus the dependent's minted `^id`.
- **Never emit a link to an id that will not exist on disk after the write.**
- nav mints inside the dependent's own transaction, before any cross-note follow-up.
- capture mints inside its atomic staged batch.
- Reconcile skips listing a dependent whose mint lands in a note that the quiet interval will defer this run (same predicate as the guarded write). The next run lists it.
- Dry-run and Escape mint nothing.

### 14.5 Order and position

- **Order.** Sort entries by canonical link text after ASCII case-folding, comparing by Unicode code point. Raw code-point order breaks ties.
- Same-note `[[#^…]]` entries therefore come first, then cross-note entries grouped by note.
- JS must compare code points, never UTF-16 units, so nav, ledger, and Rust emit identical bytes (BK21, BW17).
- Because the order is a projection, it is independent of document order, which reconcile regroups every run.
- **Position.** Insert after the Depends-On subtree when there is one. Otherwise use the Depends-On slot: the first direct child, or the second after a first-child `❌ **CANCEL LOG**`.

```
- [ ] #task T ^t
  - ❌ **CANCEL LOG**        (if present; stays first)
  - ⛓️ **DEPENDS ON:** …     upstream: what T waits on
  - 🧱 **BLOCKS:** …         downstream: what waits on T
  - 🧩 **Sub-projects:** …   structure (^prj only)
  - user children, 🗓️ SCHEDULE LOG, Work Log …
```

- Reuse the task's existing child indent; otherwise use the parent indent plus one tab.
- Preserve CRLF and the final-newline state.
- **Never move an existing BLOCKS line.** Rewrite it in place and delete duplicates after the first.
- A task that later gains a Depends-On line gets it in the first slot, so the order still reads upstream, then downstream.
- **`bob projects sync`.** Sub-projects anchors after the Depends-On and BLOCKS subtrees. Its move rule is: a marker before a direct Depends-On or BLOCKS child moves after them (MC8).

### 14.6 Reconciliation

Reconcile is the authority. Gestures are early courtesy writes, and closes, vim edits, agent edits, and moves are repaired only by reconcile.

1. **Export the edge set.** `ReconcileWorker` records each open dependent's accepted explicit targets (§14.3) in `ReconcileOutcome`.
2. Record at the same points as `record_graph`, but before its block-id early return, so id-less dependents are included.
3. Record before `add_resident_implicit_edges`.
4. Also record the dependents whose Depends-On line is malformed (R10), as the uncertain set.
5. Key dependents by `(file_index, task_index)`. Reconcile never adds or removes task lines, so those keys survive its edits; debug-assert this after the re-parse.
6. **New pass `reconcile_blocks_lines`.** It runs right after the R1–R10 re-parse loop and before `task_blocks` / `dependency_edges`, over the re-parsed text. It is a separate pass, so a task that gains an R2 Depends-On line and a BLOCKS line in one run cannot have two inserts at one index flip their order.
7. **Owners to check.** These are every T in `blocks(T)`, plus every open, writable task that already has a BLOCKS child. To find the latter, byte-prefilter notes for `BLOCKS` / `BLOCKING`. Never walk every task's children.
8. **Steps for each owner.** Mint, then render against the post-mint state, then call the shared planner with the desired link set:

| Existing line | Result |
| --- | --- |
| missing | insert |
| different entries | rewrite in place |
| same entries, non-canonical | canonicalise |
| not desired | delete |
| duplicates | keep the first, delete the rest |
| any malformed BLOCKS child | leave every BLOCKS child of that owner alone and warn |

1. **Never prune on uncertainty.** An existing entry that resolves to an uncertain dependent is kept, but an uncertain dependent is never newly added.
2. **Guarded output.** Edits are structural, so they join the reconciled set: the 2 s quiet interval, preimage checks, and recovery snapshots apply. Originals merge into `pre_reconcile`, keeping the earliest copy.
3. **Preconditions.** The pass inherits today's preconditions: the daily note exists and has a Pomodoros section. Live gestures do not depend on reconcile.

### 14.7 Immediate writers

| Writer | BLOCKS behaviour |
| --- | --- |
| nav Blocks stage (Task Card ⇧B, Ctrl+Shift+Alt+B, palette, line entries, counted, marked batches) | Applies the same Depends-On, status, freshness, lane, Pomodoro, and project effects as the equivalent forward edits (§15.6). Same-note writes use one transaction; cross-note sources are prepared before dependent notes, then BLOCKS mirrors follow only committed dependents. |
| nav Depends on stage (Task Card `b`, Ctrl+Shift+B, Ctrl+Shift+P on the line, counted, palette, marked batches) | **Same-note targets:** the owner's BLOCKS edit and any minted dependent id go into the dependent's one transaction, so one Ctrl+Z undoes both. **Cross-note targets:** after the commit, a best-effort follow-up per target note, for old ∪ new targets. It uses the open editor (its own undo entry), otherwise a preimage-checked `vault.process`. On failure the notice gains `· 🧱 BLOCKS not updated`; nothing rolls back. Preparation never writes a reverse entry. |
| nav removals (chip ×, api v3 `removeDependency`, Ctrl+D on the Depends on row, counted Ctrl+D) | Remove the dependent from each removed target's line, deleting the line when it empties. |
| nav hand-edit mirror (cursor leaves an edited Depends-On line; undo re-arms it) | Diff the burst baseline's targets against the final targets and mirror the difference. Clearing the whole line removes every old target. This keeps cross-note undo honest. |
| capture `&` (and Bob Mac Capture through bob) | Stage each added target's BLOCKS entry and the dependent's minted `^id` in the same batch, using the shared Rust planner. The target's block is reported as a `dependency_target` task block. |
| capture `*` (and Bob Mac Capture through bob) | Stage N's BLOCKS entry on T in the same batch when T gains N as a prerequisite. N is reported as a `dependency_target` task block; T is reported as a `dependency` task block. A minted N id is `minted_blocker_block_id`, not `minted_dependent_block_id`. |
| capture dependency drop (§13.5.1) | Stage the dependent's removal from each dropped prerequisite's line. Prerequisite task lines are still never edited. |
| Ctrl+Shift+M, `bob task move`, `bob task archive`, project conversion | The moved owner's line moves with it; generic link rebasing applies. `bob task move` repairs the moved owner's dependency identity and inbound references, then leaves BLOCKS/status reconciliation explicit. Conversion never copies a BLOCKS line as a user child. Links into a moved or converted dependent are left to reconcile. |
| Closes, cancels, reopens (capture `x` / `!`, Ctrl+Enter, Alt+\], Mac Capture, hand edits) | **None.** The owner's line freezes; a closed dependent drops off at the next reconcile. Chips hide the lag. |

All writers share one planner, implemented as a Rust function with a JS twin and pinned by the same vectors.

- A writer computes the desired entries and the planner turns them into edits:
- reconcile computes the full projection;
- gestures compute (existing entries resolved to identities) ∪ added − removed.
- Writers compare canonical output before writing, so no-ops write nothing and add no undo entries.
- Machine writes are annotated, so nav's listeners ignore them.

### 14.8 Gestures

| Gesture | Behaviour |
| --- | --- |
| Ctrl+Shift+P on a BLOCKS line | Opens the owning task's Blocks stage directly, without the Task Card. |
| Ctrl+Shift+Alt+B on a Depends-On or BLOCKS line | Opens the owning task's Blocks stage directly, never the link under the cursor. |
| Hand edit (typing, deleting an entry, deleting the line) | Never an edge change. Once per burst, nav shows “🧱 BLOCKS is generated from DEPENDS ON lines · edit it with Ctrl+Shift+Alt+B”. Reconcile restores the line. |
| `!` / `N!`, Ctrl+Shift+Enter | Refused with the same notice. |
| Ctrl+Enter on a link | Closes or reopens the linked dependent: root-only, with no strike, no re-embed, and no tree close (as §8 for Depends-On links). |
| Alt+\] / Alt+\[ on the line | Cycles the dependent under the cursor; never reformats the bullet. |
| Ctrl+Shift+P on a Depends-On line, palette Edit task dependencies | Acts on the owning task (its Task Card or Depends on stage), never on the chip under the cursor. |

### 14.9 Chips

- **Data.** A reverse index `id → open dependents`, built from the in-memory Tasks memo (`dependsOn` / `id`, the same source as the Depends-On chips).
- It is shared infrastructure, so later features can reuse it: review cards and the close preview.
- Chips render the **live** set in the stored line's order. A live dependent missing from the text is appended in canonical order and counted.
- A stored entry that is no longer live renders dashed, with the tooltip `No longer waiting on this task · refreshes on the next reconcile`, and is not counted.
- **Anatomy.** Reuse the `bob-dep-row` / `bob-dep-chip` family and the `--task-status-*` tokens:
- miniature checkbox, a \~40-character description, `↗ note` for cross-note entries, hover preview, click / Mod-click;
- a `^prj` chip shows 🧩 and the project note name;
- `#hide` chips are muted.

There is no new palette. Chips never change line height and wrap between chips, never inside one.

- **🔑 badge.** T is D's only remaining reason to be Blocked: no other open prerequisite (by D's `dependsOn` ids), no future `scheduled`, and no `#prj_is_blocked`.
- The tooltip lists the other reasons: `also waits on "Outline" · 2026-11-02 · project sase`.
- Each badge has `aria-label` text, and colour is never the only signal. Every live dependent is `[?]`, so status colour cannot tell chips apart; the badge carries that signal.
- **Label and summary.** The summary is never written to Markdown.

| Owner | Label | Summary |
| --- | --- | --- |
| open | `🧱 blocks` | `frees N` when every live chip has 🔑, else `frees N of M` |
| closed (frozen line) | `🧱 was blocking`, muted, no badges | `✓ freed M` |

- **Editing controls (decision chips = yes).** An open owner row with nav api.blocks?.version \>= 1 gets a trailing ＋ with aria-label “Edit what this task blocks”; it opens Blocks… at the row's 0-based line. Each live chip gets a hover × with aria-label “Stop blocking ”; it calls api.blocks.removeDependent. Dashed stale chips and frozen rows get no controls. Without api.blocks, rows stay navigation-only. The tooltip is “Generated from DEPENDS ON lines · ＋ / × edit through Blocks… (Ctrl+Shift+Alt+B)”. Controls do not change line height; Live Preview and Reading view behave the same.
- **Toggle.** `toggle-dependency-chips` toggles Depends-On and BLOCKS rows together.
- **Without the plugin**, the line still reads as plain links.
- **Implementation.** Write new fragments with an explicit relation direction. Never reuse the incoming row's `waiting on` copy, action callbacks, or aria labels from 140, 150, or 280.

### 14.10 Kill switch

```
dependencies:
  blocks_lines: true # write 🧱 BLOCKS lines on prerequisites (docs/task-dependencies.md §14)
```

The key lives in `~/.config/bob/config.yml` (`BOB_CONFIG_FILE` / `XDG_CONFIG_HOME` apply). Rust and nav read it.

- An invalid value falls back to `true` and warns through the existing paths: reconcile stderr, and a capture warning string.
- With `false`, reconcile's desired set is empty: it removes every BLOCKS line on open tasks, mints nothing, and leaves frozen lines alone. Gestures write no BLOCKS edits.

### 14.11 Outputs and warning kinds

**Reconcile JSON (additive).** These use the same line convention as `adopted_dependency_lines`:

- `blocks_line_updates`: `{path, line, task, kind, added[], removed[]}`, where `kind` is one of `inserted`, `rewritten`, `canonicalized`, `removed`, or `deduplicated`;
- `minted_dependent_block_ids`: `{path, line, block_id}`;
- the new warning kind `malformed_blocks_line` in `dependency_warnings`.

Human output gets a `🧱` group in the existing dependency style. `--dry-run` previews all of it.

**Capture JSON (additive, omitted when empty).** Each item gets `blocks_line_updates` rows of the same shape, and `minted_dependent_block_id`. Human output stays quiet when the mirror works.

No new subcommand, flag, or Tasks field. `bob query --tasks "(not done) AND (is blocking)"` already answers read-only questions.

### 14.12 Rollout

- **Guards and chips ship before any writer emits lines.** That is why `cli-reconcile` depends on `plugin-guards` and `chips`.
- **Reconcile is the first vault-wide writer.** Gestures are courtesy writes on top of it.
- **Kill switch.** `dependencies.blocks_lines: false` removes every BLOCKS line on an open task losslessly; frozen lines stay.
- **The MacBook.** Its cron is the only hooks runner. Until it is updated, live Obsidian gestures still write courtesy lines, and the next reconcile on an updated bob repairs the rest. Plugin guards must land before writers so an old recogniser never treats a BLOCKS line as a Task Link.
- **Phase 2.** Editing from the prerequisite is the Blocks stage and its BLOCKS chip controls (§15).

### 14.13 BK — parse vectors

`line` is the candidate BLOCKS line on its own; `context` names what surrounds it. `verdict` is one of `accept(n)` (a line with n entries), `empty` (label but no links), `malformed`, or `not-a-line`. `canonical` also requires sorted, duplicate-free writer-form entries.

| \# | line | context | verdict |
| --- | --- | --- | --- |
| BK1 | `🧱 **BLOCKS:** [[#^a]]` | direct child of a `#task` | accept(1), canonical |
| BK2 | `🧱 **BLOCKS:** [[#^a]] • [[cash#^b]] • [[money/cash#^c]]` | direct child | accept(3), canonical |
| BK3 | `🧱 **BLOCKS:** [[#^a\\\|b • c]]` | alias contains `•` | accept(1), canonicalise |
| BK4 | `**BLOCKS:** [[#^a]]` | emoji missing | accept(1), canonicalise |
| BK5 | `🚧 **BLOCKS:** [[#^a]]` | WIP emoji | accept(1), canonicalise |
| BK6 | `⛓️ **BLOCKS:** [[#^a]]` and `⛓ **BLOCKS:** [[#^a]]` | chain emoji | accept(1), canonicalise |
| BK7 | `🧱 **BLOCKING:** [[#^a]]` | legacy label | accept(1), canonicalise |
| BK8 | `🧱 **BLOCKS:** [[#^a]] · [[#^b]]` / `[[#^a]], [[#^b]]` / `[[#^a]] [[#^b]]` | separators | accept(2), canonicalise |
| BK9 | `🧱 **BLOCKS:** ~~[[#^a]]~~` / `![[#^a]]` | struck / embedded | accept(1), canonicalise |
| BK10 | `🧱 **BLOCKS:** [[cash#^b]] • [[#^a]]` | out of order | accept(2), canonicalise to `[[#^a]] • [[cash#^b]]` |
| BK11 | `🧱 **BLOCKS:** [[#^a]] • [[#^a]]` | duplicate entry | accept(2), canonicalise to one entry |
| BK12 | `🧱 **BLOCKS:**` | label only | empty |
| BK13 | `🧱 **BLOCKS:** [[#^a` | half-typed | malformed |
| BK14 | `🧱 **BLOCKS:** [[#^a]] until Friday` | trailing prose | malformed |
| BK15 | `🧱 **BLOCKS:** [[note]]` / `[[note#Heading]]` / `• ,` | no block id / separators only | malformed |
| BK16 | `🧱 **blocks:** [[#^a]]` | lowercase | not-a-line |
| BK17 | `🧱 **BLOCKED BY:** [[#^a]]` | inverse label | not-a-line |
| BK18 | `🧱 **BLOCKS:** [[#^a]]` | fenced / grandchild / hangs off a Work Log entry / blockquoted / no list marker | not-a-line |
| BK19 | `🧱 **BLOCKS:** [[#^a]]` | third direct child after prose | accept(1) (no slot is enforced on read) |
| BK20 | BK1 through the Depends-On parser; DP1 through the BLOCKS parser | cross-parse | not-a-line both ways |
| BK21 | entries `[[zeta#^e]]`, `[[élan#^d]]`, `[[cash#^c]]`, `[[Cash#^a]]`, `[[#^b]]` | sort | `[[#^b]] • [[Cash#^a]] • [[cash#^c]] • [[zeta#^e]] • [[élan#^d]]` |

### 14.14 BR — reconcile vectors

| \# | situation | outcome |
| --- | --- | --- |
| BR1 | open D `^d` depends on open T, same note | T gains `🧱 **BLOCKS:** [[#^d]]` (`inserted`) |
| BR2 | D in `cash.md` (unique basename), T elsewhere | entry `[[cash#^d]]`; ambiguous basename → `[[money/cash#^d]]` |
| BR3 | three dependents, same-note and cross-note | one line, sorted per BK21 |
| BR4 | D has no `^block-id` | D gains `^<mint>` (SB); no `[id::]` and no `[fresh::]`; T lists it; a `minted_dependent_block_ids` row |
| BR5 | D's note is inside the quiet interval and D needs a mint | D not listed this run; next run mints and lists it; then zero changes |
| BR6 | T has Depends-On (with a nested child) and a Schedule Log | BLOCKS inserted right after the Depends-On subtree |
| BR7 | T has a first-child Cancel Log and no Depends-On | BLOCKS inserted second |
| BR8 | T is `^prj` with Depends-On and Sub-projects | order Depends-On, BLOCKS, Sub-projects; `bob projects sync --dry-run` then changes nothing |
| BR9 | T's only listed D closes | entry dropped; line deleted (`removed`) |
| BR10 | T closed, line lists open D | untouched (frozen) |
| BR11 | T reopens | line re-projected |
| BR12 | T or D in the previous daily note | T never written; D never listed or minted |
| BR13 | hand-added entry with no forward edge | removed (`rewritten`) |
| BR14 | any BK3–BK11 variant with the right entries | `canonicalized` |
| BR15 | two BLOCKS lines under T | first rewritten, the rest deleted (`deduplicated`); copy-paste under a task with no dependents → deleted |
| BR16 | malformed BLOCKS line under T | every BLOCKS child of T untouched; warn `malformed_blocks_line`; no second line |
| BR17 | D's Depends-On line malformed (R10) | existing entries naming D on any T kept; D never newly added |
| BR18 | D's link is R4 / R5 / R6, an archive target, or a previous-daily / unencodable target kept verbatim | no entry for that link |
| BR19 | `^prj` P depends on T; P has residents | T lists `[[p#^prj]]` only |
| BR20 | `^prj` internal or descendant target | no entry |
| BR21 | R8 legacy-child edge | listed |
| BR22 | cycle A ↔ B | each lists the other |
| BR23 | `#hide`, `ref/`, inbox, and today's daily-note tasks on either side | listed normally |
| BR24 | `dependencies.blocks_lines: false` | every BLOCKS line on an open task removed; frozen lines stay; no mints |
| BR25 | invalid `dependencies.blocks_lines` | behaves as `true`; stderr warning |
| BR26 | delete every BLOCKS line in a reconciled vault | one run rebuilds identical bytes; fields, statuses, edges, promotion unchanged |
| BR27 | second run over a reconciled vault | zero changes |
| BR28 | owner's note modified \< 2 s ago | edits deferred (structural) |
| BR29 | `--dry-run -f json` | identical rows; nothing written; no mints persisted |

### 14.15 BW — write vectors

| \# | gesture | outcome |
| --- | --- | --- |
| BW1 | stage adds same-note T to D | D's line and T's BLOCKS change in one transaction; one Ctrl+Z restores both |
| BW2 | stage adds cross-note T | D commits; then T's note gains or updates its line (open editor, else preimage-checked `vault.process`) |
| BW3 | the cross-note follow-up finds T changed or unreadable | D's write stands; notice suffix `· 🧱 BLOCKS not updated`; no rollback |
| BW4 | D lacks `^block-id` | mint in D's own transaction, before any follow-up; no `[id::]` |
| BW5 | removal via stage, chip ×, api `removeDependency`, or Ctrl+D on the Depends on row | D's entry leaves T's line; empty line deleted |
| BW6 | add to a T that already lists D | byte-identical; no write; no undo entry |
| BW7 | T closed, or in the previous daily note | T not written |
| BW8 | T's BLOCKS line malformed | left alone; D's write stands; suffix `· 🧱 BLOCKS not updated` |
| BW9 | counted `N<Ctrl+Shift+P>` add-to-all on k dependents | T's single line lists all k, sorted |
| BW10 | hand edit adds / removes a link on D's line (cursor leaves) | T lines updated from the diff |
| BW11 | Ctrl+Z of a cross-note add in D's editor | the mirror re-fires and removes D from T's line |
| BW12 | hand edit confined to a BLOCKS line | no graph change; the generated-line notice once |
| BW13 | capture `&cash:t` on a new task or an explicit dependent | T's line updated in the same staged batch; row reported; T reported as a `dependency_target` task block |
| BW14 | capture dependency drop of T1 and T2 | D removed from both lines in the same batch |
| BW15 | kill switch off | no writer writes BLOCKS or mints for it |
| BW16 | capture `--dry-run` | rows reported; nothing written |
| BW17 | the same input rendered by Rust and nav | byte-identical line (BK21 included) |
| BW18 | project conversion forward / reverse | a BLOCKS line is never copied as a user child |
| BW19 | Ctrl+Shift+M of T | T's line moves with rebased links; never two BLOCKS lines |

### 14.16 BC — chip vectors

| \# | situation | render |
| --- | --- | --- |
| BC1 | open T; one open D whose only reason is T | `🧱 blocks ( ? D 🔑 ) frees 1` |
| BC2 | D also waits on open U | no 🔑; `frees 0 of 1`; tooltip `also waits on "U"` |
| BC3 | D's only other reason is a future `scheduled` | no 🔑; the tooltip names the date |
| BC4 | D carries `#prj_is_blocked` | no 🔑; the tooltip names the project |
| BC5 | four dependents, all 🔑 | `frees 4` |
| BC6 | cross-note D | `↗ note` |
| BC7 | `^prj` D | 🧩 plus the project note name |
| BC8 | stored entry no longer live (open owner) | dashed; the refresh tooltip; not counted; with no live chip left, the summary is omitted |
| BC9 | live D missing from the stored line | appended in canonical order; counted |
| BC10 | closed owner | `🧱 was blocking`, muted, no 🔑, `✓ freed M` (M = stored entries); entries show their current status and are never dashed as stale; a missing target shows `⚠ ^id not found` |
| BC11 | D carries `#hide` | muted chip |
| BC12 | interaction | click opens D, Mod-click opens a new tab, hover previews; on open owners with api.blocks, ＋ opens Blocks… and × removes a live dependent; no controls on stale/frozen rows or without the API |
| BC13 | 60-character description | cut at about 40; full text in the tooltip |
| BC14 | `toggle-dependency-chips` | toggles Depends-On and BLOCKS rows together |
| BC15 | malformed or not-a-line BLOCKS row | no chips |

## 15\. The Blocks stage (navigation-hotkeys)

The Blocks stage is the reverse writer for §6's Depends on stage. Let N be the source task where the gesture starts, and T a task chosen in the stage. Arrows mean “depends on”: blocking T from N stores T → N on T's Depends-On line. The stage never reads a BLOCKS line as input.

### 15.1 Vocabulary and parity

- **Source N:** the task where the gesture starts; counted gestures have sources N1, N2, … in cursor order.
- **Picked T:** an open task that N will block, by adding N to T's Depends-On line.
- **Reverse CURRENT:** N's open direct dependents, derived by inverting the stage dependency edges and merging warm Tasks-cache rows whose dependsOn contains N's id. It is never read from N's BLOCKS line.
- **Parity invariant:** for each reverse gesture, the final bytes of every note equal the bytes written by the corresponding forward Depends on gesture or gestures.

Consequences follow the same dependency semantics (§5): T becomes Blocked, its live links under today's open Pomodoros are consumed, and its open prerequisites may promote. A project T blocks its residents and descendants. Existing N follows the ordinary lane transfer, may become In Progress, and is never stamped for its target-ID, lane, or BLOCKS edits. A project N is never raised. A T whose Depends-On line changes is stamped fresh on add and remove. The stage never auto-links N into T's vacated Pomodoro slot.

### 15.2 Entry points

| Cursor | Gesture | Opens |
| --- | --- | --- |
| A task line | Ctrl+Shift+Alt+B, or Ctrl+Shift+P then ⇧B | The source task's Blocks stage; Back and empty Backspace return to the Task Card |
| A dedicated Task Link | Same | Focuses or opens the linked task's note, then opens its Blocks stage |
| A task line, counted | N, or N then ⇧B | Counted sources: this task and the next N; each picked T gains every source |
| A Depends-On line or a BLOCKS line | Ctrl+Shift+Alt+B | The owning task's Blocks stage directly, never the link under the cursor |
| A BLOCKS line | Ctrl+Shift+P | The owning task's Blocks stage directly, without a Task Card |
| An open-owner BLOCKS chip row | ＋ / hover × | Opens the stage / removes that dependent (§15.8) |
| Anywhere | Palette command Task card: Blocks (reverse Depends on), id task-card-blocks | Same as the chord |

Entry refusals close the Task Card and show a short Notice:

- A non-task bullet: Blocks requires a \#task checkbox.
- A closed N: 🧱 A finished task can't block another task · reopen it first.
- N in done/ or the previous daily note: the existing read-only refusal.
- Fenced N, prose with several links, or a selection spanning tasks: the existing Ctrl+Shift+P refusal.
- A blockquoted N: 🧱 Blocks can't be edited inside a blockquote.
- N has no id and its path cannot be encoded: 🧱 This note's path can't be a dependency id · add \[id::\] or rename the note.

### 15.3 Pool and ranking

The pool is the forward stage's pool (§6.2): Tasks cache, open buffers, then generation-guarded chunked vault fallback when the cache is cold. Sources are excluded. Search and navigation do no disk reads.

Reverse CURRENT is read from the dependency graph and merged with warm Tasks-cache dependents. Today's live Task Links are read once when the stage opens, from the open daily-note buffer or one generation-guarded async read. The model records each linked task's Pomodoro entry label, time range, running state, and whether the link has child notes.

With an empty query, rows rank as follows:

1. CURRENT.
2. RESULTS: tasks linked today first (running entry first, then entry and link order), then In Progress, Next, tasks in N's note in document order, then the remaining tasks in canonical order.
3. BLOCKED last. Hidden tasks are muted and last within each group.

For a typed query, match score leads and this order breaks ties. The stage renders about 60 rows and then a stage-more row.

### 15.4 Task Card row and stage rows

The Task Card has a Blocks… row after Blocked by, with keycap ⇧B. Its detail is “N tasks”, plus “· frees K” when K \> 0; K counts open dependents for which N is the last remaining reason. It comes from the warm Tasks cache by querying open tasks whose dependsOn contains N's id. It is omitted when the cache is cold or N has no id. The unavailable reasons are “Blocks requires a \#task checkbox” and “A finished task can't block another task · reopen it first”, shown on the card without the 🧱; the entry Notice for a finished N keeps it (§15.2).

The stage reuses the wide bob-cnp modal and the Depends on stage's CURRENT / RESULTS / BLOCKED sections:

```
🧱 Blocks · Ask Kelly for references
Blocks 1 · frees 1 · searching 412 open tasks · ⇥ to mark several
[ Filter tasks ]
CURRENT
  [?] Do cleanup tasks!             ↗ job            🔑    −
RESULTS
  [/] Send batch applications       ↗ job  🍅 running       ＋
  [*] Draft cover letter template   ↗ job                   ＋
  [ ] Update LinkedIn headline      Line 14                 ＋
BLOCKED
  [?] Call references               ↗ job  🔒 waits on 2    ＋
Becomes Blocked until this is done · this task takes In Progress ·
⚠ removes its link from JOB 10:10–10:35 (running)
↑↓ navigate   ⇥ mark   ↵ block   esc dismiss
```

The title is 🧱 Blocks · , or 🧱 Blocks · N tasks for counted mode. Its icon is lucide brick-wall, falling back to link. The results label is “Tasks this could block”; empty results say “No matching open tasks in the vault”.

At rest, the subtitle is “Blocks K · frees F · searching P open tasks · ⇥ to mark several”, or “Blocks nothing yet · …” when K is zero. With marks it is “2 to block · 1 to remove · ↵ to apply”, adding “· names this task first” when N needs an id. Counted badges show k/n block.

CURRENT means N's open direct dependents. BLOCKED candidates remain valid picks. The stage uses the existing badges: ＋ / −; 🔑 when N is T's last remaining reason; 🍅 for a live Task Link today, labeled “🍅 running” for the running entry; and 🔒 waits on N / 🔒 scheduled D as in the forward stage. The 🔑 tooltip is “Last thing in its way · removing makes it Ready again”. The 🍅 tooltip names the Pomodoro entry and says blocking it removes that link. Disabled reasons use text-warning.

A fixed-height, ellipsized consequence strip sits between the list and footer. It describes the highlighted row, or the marked batch, and announces changes with aria-live="polite". The footer action is block on RESULTS or BLOCKED, remove on CURRENT, and apply N with marks.

### 15.5 Keys, guards, ids, counted sources, and review

The stage uses the forward keys: Enter toggles the highlighted row and closes; with marks it applies each mark. Tab marks or unmarks and moves down. Escape writes nothing and allocates no ids. Back and empty Backspace return to the Task Card.

A candidate is disabled when the forward stage opened on T would disable N, or any counted source:

- N itself is excluded from RESULTS.
- A cycle is checked on the trial graph after the whole batch, including implicit resident-to-project edges and every open resident of a project T. The tooltip gives the cycle path.
- A project T is refused when N is in that project note or a descendant.
- A malformed or second Depends-On line on T disables it with “its Depends-On line is malformed”.
- If T or N changed after the stage opened, the commit is refused and the stage reopens fresh in the Blocks direction.
- Removing a current dependency is always allowed.

The stage disables a row only from content it holds. Under a warm Tasks cache a closed note's rows carry no note content, so the malformed Depends-On, project-descendant, project-resident cycle, and cycle checks cannot run there. Those rows are checked when the gesture is applied (§15.6 step 1): the writer reads each touched note live, rechecks the guards on the trial graph after the whole batch, and refuses the entire gesture, writing nothing, with a notice such as “🧱 Can't block “Bad” · its Depends-On line is malformed”.

A blockquoted N is refused at entry (§15.2) with “🧱 Blocks can't be edited inside a blockquote”, the reverse twin of forward's “⛓ Dependencies can't be edited inside a blockquote”.

When N lacks a block id, the existing block-ID stage opens once when applying the change. It suggests an id against N's note; a valid free legacy \[id::\] is reused. Counted sources prompt one at a time, reserving ids across the batch. Escape cancels the whole gesture.

For counted sources, a T already blocked by every source is removed from all of them; otherwise missing sources are added, in cursor order. This matches forward's add-to-all/remove-from-all rule.

For an inbox N, the inbox-route prompt runs first. After a move, T's links and dependsOn ids follow N to its destination. The review-walk origin continues only if the commit takes N out of today's walk; N is not stamped, so this is uncommon.

### 15.6 One gesture writes and undo

The reverse writer is applyBlocksEdit({ sources, add, remove, today?, stamper?, dateText? }) and returns { ok, reason?, notice, committed\[\], failed\[\], pomodoro, blocksFailed }.

1. **Preflight:** read every source note, picked T note, and today's daily note live (open buffers win, else the vault) and compare each with the stage's open-time snapshot of the touched notes; a picked row also carries its staged line text, so a closed note the stage never read is checked by that line. Recheck the guards on the trial graph and refuse the entire gesture if a task changed since stage open or a guard fails. The writer returns the refusal notice and its caller shows it exactly once.
2. **Plan:** plan each T note's edits bottom-up with planDependencyEdit, threading one working copy per note. Prepare each source once with its id and the strongest pre-gesture lane among newly blocked Ts; for project T, include the project and resident lanes. Never raise a project source. Stamp each changed T once and never stamp a source. Aggregate the Pomodoro cleanup for newly Blocked tasks and project residents.
3. **Prepare sources first:** write each source note once. Open notes get one editor transaction; closed notes use a preimage-checked vault.process. If a source note fails, abort before any other write and report changed — reopen. When source and T share a note, fold both edits and same-note BLOCKS edits into one transaction, so one Ctrl+Z undoes a same-note gesture.
4. **Commit other T notes:** one write per note, with a minted T id in T's own write. Collect failures without rolling back other notes.
5. **Mirror:** plan each source's BLOCKS edit once from its post-prepare content and only Ts that committed. Never list an id not on disk.
6. **Consume:** consume Pomodoro links only for T's that committed. Today's daily note, when it is also written here, takes the cleanup of its own T's in that same transaction; every other committed T is cleaned by one cleanup after the writes (so a daily note that is only N's note may take one extra undo entry). The notice counts the links actually removed. Cleanup failures add “Pomodoro links not removed” and are not rolled back.
7. **Projects:** run the existing sub-project passes for each committed project T.
8. **Finish:** show one notice (§15.7) and continue the review origin.

A same-note gesture has one Ctrl+Z entry. Across notes, each open T note has one entry; an open source can have a prepare entry and a later mirror entry. Closed notes written by vault.process have no undo entry. The toggle from a reverse edge back to its prior state is the Blocks stage followed by ↵ on the CURRENT row.

Nav applies editor writes as line-level edits, never a whole-note replacement, so the cursor stays on the task line the gesture started on and the viewport does not move; the review-walk advance on a `]s` landing is the one intended exception.

The forward writer and its existing tests remain byte-identical, besides the shared duplicate-preparation fix covered by RBW6.

### 15.7 Notices and consequence-strip copy

Task names in notices are cleaned and cut at about 40 characters. Every notice names T, the task that gained or lost a prerequisite, never the source N (including the counted forms and the chip ×, which share the writer). | Outcome | Notice | | --- | --- | | One add; T was Ready | 🧱 Now blocks “Do cleanup tasks” · Ready → Blocked | | One add; T was Next and N rose | 🧱 Now blocks “Do cleanup tasks” · Next → Blocked · takes Next | | Add consumed a link | Append · removed 1 Pomodoro link | | One add; T already Blocked | 🧱 Now blocks “Call references” · still Blocked (waits on 3) | | Project T | 🧱 Now blocks project “sase” · 4 tasks Blocked · removed 2 Pomodoro links | | One removal; T freed | 🧱 No longer blocks “Do cleanup tasks” · Ready again | | One removal; T not freed | 🧱 No longer blocks “Call references” · still Blocked | | Several | 🧱 Now blocks 3 tasks · 1 removed · takes In Progress | | Counted | 🧱 2 tasks now block “Do cleanup tasks” / 🧱 2 tasks now block 3 tasks | | Partial | Append · 1 task not updated (changed — reopen) | | Mirror failure | Append · 🧱 BLOCKS not updated |

| Highlight or batch | Consequence strip |
| --- | --- |
| A RESULTS row | Becomes Blocked until this is done |
| T is Next or In Progress | Append · this task takes Next / · this task takes In Progress |
| T is linked today | Append · ⚠ removes its link from JOB 10:10–10:35 (running) |
| The link has child notes | Append and the 2 notes under it |
| A BLOCKED row | Also waits on this (3 open) |
| A project T | Project becomes Blocked · 4 tasks wait · ⚠ removes 2 Pomodoro links |
| A CURRENT row | Stops waiting on this · Ready again (🔑) / Stops waiting on this · still Blocked |
| A disabled row | ⟲ would create a cycle: Ask Kelly → Cleanup → Ask Kelly, or the guard reason |
| Marks | ↵ blocks 2 · removes 1 · this task takes In Progress · ⚠ removes 1 Pomodoro link |

The generated-line hand-edit notice is “🧱 BLOCKS is generated from DEPENDS ON lines · edit it with Ctrl+Shift+Alt+B”.

### 15.8 BLOCKS chip controls

When the chips decision is yes and nav exposes api.blocks?.version \>= 1, an open owner's BLOCKS row gets a trailing ＋ with aria-label “Edit what this task blocks”. It calls api.blocks.openStage({ path, line }) with the row's 0-based line. Each live chip gets a hover × with aria-label “Stop blocking ” and calls api.blocks.removeDependent. Dashed stale chips and frozen “🧱 was blocking” rows get neither control. Without api.blocks, rows remain navigation-only; those rows and frozen rows (whose owner is closed) keep the fallback tooltip “Generated from DEPENDS ON lines · edit on the dependent”, since the dependent is then the only end that can edit the edge. Clicking, Mod-clicking, and hovering chips work as before; controls do not change line height, in Live Preview or Reading view.

### 15.9 RB — reverse Blocks vectors

Each parity vector compares final vault bytes with the corresponding forward gesture or gestures.

**RBP — parity**

| \# | Situation | Outcome |
| --- | --- | --- |
| RBP1 | N and T share a note; T Ready; N lacks an id | N gets the prompted id and \[id::\]. T gains its line and field, becomes Blocked and fresh. N's BLOCKS lists T. One transaction; bytes equal forward add |
| RBP2 | T is in another note open in a background leaf | N's note is prepared, T gets one transaction, and N's BLOCKS is a follow-up; bytes equal forward |
| RBP3 | T is in a closed note | T is written through preimage-checked vault.process; bytes equal forward |
| RBP4 | T \[*\] → N \[*\]; T \[/\] → N \[/\]; project N with T \[\*\] | Ordinary transfer; project N is never raised |
| RBP5 | T is linked under the running entry as its only child | Link is consumed and emptied entry deleted; notice says removed 1 Pomodoro link; bytes equal forward |
| RBP6 | T is a project with residents and a sub-project | Residents become Blocked and tagged \#prj\_is\_blocked, their links are consumed, and cascade runs; bytes equal forward |
| RBP7 | T1 \[\*\] in note A, T2 \[/\] in note B | N prepared once and ends \[/\]; bytes equal forward(T1+N) then forward(T2+N) |
| RBP8 | Counted sources N1 and N2; pick T | T's line gains N1 then N2; bytes equal forward(T + N1, N2) |
| RBP9 | dependencies.blocks\_lines is false | No BLOCKS edits; N still gets an id |
| RBP10 | ↵ on CURRENT T whose only reason is N | T becomes Ready and fresh, with no \[unblocked::\]. N's BLOCKS drops T and is deleted when empty; bytes equal forward removal |
| RBP11 | N is an inbox task | Route prompt comes first; after the move, T's link and dependsOn id point to N's destination |

**RBG — guards and refusals**

| \# | Situation | Outcome |
| --- | --- | --- |
| RBG1 | N itself | Excluded from RESULTS |
| RBG2 | N already depends on T directly, transitively, through a project resident, or via a batch-induced cycle | Disabled with would create a cycle and path tooltip |
| RBG3 | Project T; N is in its note or descendant | inside this project / inside a sub-project of this project |
| RBG4 | T has a malformed or second Depends-On line | its Depends-On line is malformed |
| RBG5 | T or N changed after stage open | Refused and reopened fresh in Blocks direction; nothing written |
| RBG6 | N is closed, a plain bullet, in done/ or previous daily note, fenced, or blockquoted | Refused at entry with §15.2 copy; no card remains open |
| RBG7 | N's path cannot be encoded and it has no \[id::\] | Refused at entry |
| RBG8 | Escape from stage or block-ID prompt | Vault is byte-identical; no ids allocated |

**RBW — writes and failures**

| \# | Situation | Outcome |
| --- | --- | --- |
| RBW1 | N's note changes between preflight and source write | Nothing written; changed — reopen |
| RBW2 | One T note changes between preflight and its write | Other notes land; notice says 1 task not updated (changed — reopen); N's BLOCKS omits T |
| RBW3 | Every pick is already present or absent | No write and no undo entry |
| RBW4 | N is in today's daily note and T is linked today | After T commits, the link is removed (one extra undo entry); N's BLOCKS line lands under N |
| RBW5 | Stage opened from review-walk landing on N | Walk advances only if commit takes N out of today's walk |
| RBW6 | Existing forward counted gesture with two dependents in different lanes | One preparation; no target-changed |

**RBU — stage and entry UI**

| \# | Situation | Outcome |
| --- | --- | --- |
| RBU1 | Empty query | Uses §15.3 order |
| RBU2 | Today-linked rows | 🍅 / 🍅 running; strip names running entry and notes under link |
| RBU3 | CURRENT rows | 🔑 exactly when N is the last reason |
| RBU4 | Footer | block / remove / apply N |
| RBU5 | Subtitle | Correct at rest, with marks, and with name-first suffix |
| RBU6 | Task Card | Row order, ⇧B, “2 tasks · frees 1”, and unavailable reasons |
| RBU7 | Ctrl+Shift+P and Ctrl+Shift+Alt+B on BLOCKS; Ctrl+Shift+Alt+B on Depends-On | Owner's Blocks stage directly |
| RBU8 | Hand edit on a BLOCKS line | New generated-line notice |

**RBC — chip controls (chips decision)**

| \# | Situation | Outcome |
| --- | --- | --- |
| RBC1 | Open owner with api.blocks | ＋ shown and calls openStage with 0-based line |
| RBC2 | Hover × on a live chip | Calls removeDependent(ownerRef, { path, blockId }); notice comes from nav |
| RBC3 | Frozen line, dashed stale chip, or no api.blocks | No ＋ / × |
| RBC4 | Reading view | Same behavior as Live Preview |
