Skip to content

feat(tui): Workflowy-style links and backlinks, plus a human-scale size envelope - #3

Draft
alycda wants to merge 45 commits into
masterfrom
docs/backlinks-plan
Draft

alycda wants to merge 45 commits into
masterfrom
docs/backlinks-plan

Conversation

@alycda

@alycda alycda commented Oct 22, 2026

Copy link
Copy Markdown
Owner

Adds [[stable-id]] links between tasks and notes in the TUI, a backlinks side pane with jump-to, and documentation and tests for the human-scale size limits taskbook is designed for. The plan is in docs/plans/2026-10-21-2154-feat-backlinks-plan.md. Each commit is one unit or one review fix, so it reads best commit by commit.

Stable IDs

Numeric _ids are renumbered on archive and restore, so links use a stable ID instead.

  • The stable ID is an invertible 64-bit mix of _timestamp, written as 16 letters k–z, jj change-ID style.
  • It is computed on read and never stored, so the data format is unchanged and older binaries are unaffected.
  • Golden test vectors freeze the mapping.
  • Create paths keep _timestamp unique across active and archived items.
  • Pre-existing same-millisecond twins are marked divergent (??) and can't be linked to.

Links (TUI only)

  • On save, [[N]] and [[prefix]] references added in that edit are rewritten to full tokens.
  • A reference that matches nothing or more than one item, or points at a divergent item, rejects the save and keeps the draft. In $EDITOR, the editor reopens with a // notice.
  • Typing [[ opens autocomplete, and l links the selected item to another one.
  • Rows render link tokens as the target's title. Links to archived or missing items show as broken (✗).
  • A side pane (v toggles it, o focuses it, Enter jumps) lists outgoing links and backlinks. It's hidden below 100 columns.
  • The CLI and server are unchanged and show tokens verbatim. The MCP server also shows tokens verbatim; its one change is the size notice below.

Size envelope

taskbook is a board a person reads and keeps up with, and that sets the bound.

  • Limits: 512 active items recommended; 1,024 active and 4,096 archived supported as headroom (taskbook-common/src/limits.rs). Anything larger is unsupported, not an edge case to design for. This is also why the stable-ID collision tradeoff is acceptable.
  • Docs: CLAUDE.md design decision 6, and a new README "Limits" section with measured numbers (scripts/bench-envelope.py reproduces them).
  • Notice: past the recommendation, or past the archive limit, the TUI header and the results of the MCP create_task, create_note and restore_items tools show a notice. It blocks nothing, and it suggests /clear (tb --clear) only when there are completed tasks to archive.
  • Tests: envelope tests check correctness at the supported size. The timing check is #[ignore]d; run it with cargo test -- --ignored.

Review

  • A ce-code-review round confirmed 6 findings. Five are fixed in separate commits.
    • That includes a data-loss bug: /note in Archive view could overwrite an active item.
    • The new tests fail when the code they cover is removed.
  • The two "file over 1,000 lines" findings were rejected because the repo has no size rule.

Testing

  • cargo fmt --check and cargo clippy --workspace --all-targets -D warnings are clean.
  • cargo test: client 100 passed (1 ignored), common 92, server 3.
  • I smoke-tested the TUI in tmux: the link flow, jumping from the pane, and the size notice on a 600-item store.

Not in this PR

  • /search matches raw link tokens, not rendered titles (deferred in the plan).
  • Unclosed [[: text that already contains an unclosed [[ can't be saved after e (deferred in the plan, with a candidate fix).
  • 5-digit IDs: the CLI board view crash on 5-digit IDs is an existing bug, fixed separately on fix/render-id-padding.

Generated by Claude Code

claude added 30 commits October 21, 2026 21:57
Product contract for Workflowy-style [[id]] links in the TUI, built on
a jj-style stable item ID beside the existing numeric ID. Covers
identity, link syntax and rewrite-on-save, side-pane backlinks, archive
behaviour, and sync-safe deterministic backfill. CLI/MCP/server
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
Older taskbook-rs binaries (including a long-running tb --mcp) drop the
unknown stable-ID field on write. Random IDs for new items could never
be recovered, silently breaking links in a mixed-version window. Making
every ID a pure function of immutable creation fields lets the next
load re-derive them identically.

Review: feasibility + adversarial (independent agreement).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
The Goal Capsule promised links keep working through archive, contradicting
R16. Review: coherence.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
Otherwise a later unrelated TUI edit would turn stored plain-text [[12]]
into a permanent link to whatever holds numeric ID 12, or block the save,
contradicting R7. Review: adversarial.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
One mistyped [[99]] would otherwise discard a whole \$EDITOR session.
Review: design-lens.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
No existing TUI pattern covers an item picker. Review: design-lens.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
Enrich the requirements-only plan in place with a Planning Contract
(18 KTDs, design diagrams), ten implementation units, a verification
contract and definition of done.

Product Contract changes, confirmed in plan scoping: stable IDs are a
pure function of _timestamp (HUID-style), computed on read and never
stored; uniqueness enforced at creation (R19); same-timestamp legacy
items are divergent (R20); R8/R10 clarified.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
KTD7, U6 and the save-path diagram disagreed on when the baseline is read;
a save-time re-read could reclassify spans synced mid-edit. Review: coherence.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
R15 promised every listed entry was jumpable, contradicting R16/R20 and
KTD15: broken links have no target and divergent IDs are ambiguous.
Review: coherence.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
Config::save always writes the real config path, so App tests calling
set_view would overwrite it and race each other. Review: feasibility.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
Clearing the command line only after success would wipe pending_confirm
set by /delete and /clear. Re-activate on rejection instead.
Review: feasibility.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
…can list inbound links

U4/U5 tests forbade recording links to non-active IDs, which made the
KTD17 Archive-view backlinks impossible. Review: feasibility.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
Validating title and body together before either existing write already
prevents a half-saved note on rejection; the new method added work for no
requirement. Review: scope-guardian.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
Pre-existing error path unrelated to backlinks; the explicit post-restore
reload (kept, since set_view is a no-op inside Archive view) already removes
the stale row. Review: scope-guardian.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
The l flow opened on an empty query that suggests nothing, Enter could take
an unhighlighted row, and Enter with no match saved a stray '[[query'.
Review: design-lens, adversarial.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
The broken marker, link style and ID field width were unspecified, so
U7 tests could not assert anything concrete. Review: design-lens.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
Status messages expire after 3 s and clip at terminal width, so R9's
message naming the reference could vanish. Review: design-lens.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
In a default 80-column terminal the keys otherwise appear to do nothing.
Review: design-lens.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
…states

Unspecified states would be invented per implementer, and backlinks of a
divergent item cannot be attributed. Review: design-lens.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
R19 promised no shared IDs at all, but cross-device same-millisecond
creates can collide; the plan already treats those as divergent (R20).
Review: coherence; approved by user.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
…rewrites

A bracketed word of k-z letters silently became a link whenever exactly one
ID prefix matched. Lowercase-only matching keeps capitalized wiki text plain,
and naming each rewrite makes accidental matches visible.
Review: adversarial (FYI); adopted by user.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
Add stable_id: an invertible 64-bit mix (splitmix64 finalizer) of
_timestamp written as 16 letters k-z, jj change-ID style. Computed on
read via Item::stable_id() / StorageItem::stable_id(); nothing is
serialized, so the storage format is unchanged. Golden vectors,
computed independently, freeze the mapping.

Plan: U1 (R1-R3, R5, KTD1-KTD2).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
Stable IDs derive from _timestamp, so two items created in the same
millisecond (MCP loops, scripts) would share one. Every Taskbook create
path now bumps the timestamp past any active or archived item before
inserting; the shift is a few ms at most and only affects ordering.

Plan: U2 (R19, R1; AE7 creation half; KTD3).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
links::scan classifies closed, single-line spans as full stable-ID
tokens, numeric refs, lowercase k-z prefix refs, or plain text.
rewrite_on_save diffs the draft against the edit's baseline as a
multiset: only references added in this edit are resolved to full
tokens; unresolvable, ambiguous or divergent ones are returned as
rejections, and full tokens and plain text are never touched.
join_note/split_note let a note's title and body diff as one text.

Plan: U3 (R7-R10, R20; AE1-AE3; KTD6-KTD8).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
LinkIndex is built from the active items and answers every display
question: outgoing links per item, backlinks keyed by target stable ID
(so an archived item can still list who links to it), divergent IDs,
shortest unique display prefixes (also for IDs outside the active set),
and rendering text into plain, link, broken and divergent segments with
one level of title substitution so link cycles cannot recurse.

Plan: U4 (R6, R13, R14, R16, R20; KTD4, KTD16, KTD17).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
App now loads active items and rebuilds a LinkIndex on every refresh,
view switch, SSE data change (including while in Archive view) and
restore. refresh_items is view-aware, so refreshing in Archive view
keeps showing the archive, and restore reloads explicitly because
set_view(Archive) is a no-op there. Config writes go through one helper
so a test constructor can point them at a temp file.

Plan: U5 (R14, R16; AE5; KTD9).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
… drafts

/task, /note and /edit run the description through rewrite_on_save
against the edit-session baseline (the e prefill, or storage on first
submit), held across retries. A rejection re-opens the command line
with the draft and cursor and shows a sticky "Not saved: ..." status
that collapses later references into "+N more" to fit; submit order is
unchanged so /delete and /clear confirmations still work. The note
editor validates title and body together before either write and, on
rejection, reopens on the edited content with the reason as a leading
// comment. Successful saves name each rewritten reference and the
title it now links to.

Plan: U6 (R8-R10; AE1-AE3; KTD7, KTD8, KTD10, KTD11).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
Rows show a 6-character stable-ID field after the numeric ID (unique
prefix highlighted, rest dimmed, ?? when divergent) and render [[id]]
tokens as the underlined target title, broken links as ✗ plus the short
ID, and divergent ones as the short ID plus ??. The journal preview and
existing item suggestions use the rendered text; the preview now cuts
by characters instead of bytes.

Plan: U7 (R6, R13, R16, R20; KTD16).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
An unclosed [[ in the description of /task, /note or /edit lists
active, non-divergent items (newest first, current board ahead,
excluding the item being edited), matched by rendered title, numeric
ID or stable-ID prefix; the first row is pre-selected and accepting
inserts [[<full id>]]. Inside an unclosed [[, Enter accepts the
selection or, with nothing to accept, shows a hint instead of saving a
stray "[[query"; Esc closes only the dropdown until the cursor leaves
that [[. l opens /edit on the selected item with the dropdown showing.

Plan: U8 (R11, R12; F1, F2; KTD12-KTD14).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
On terminals at least 100 columns wide the content area splits to show
the selected item's outgoing links ("Links to") and backlinks ("Linked
from"); each entry keeps its short stable ID visible, broken and
divergent entries are marked and not jumpable, empty sections say
(none), and a divergent selection shows a notice instead of backlinks.
An archived item in Archive view lists the active items still linking
to it. v toggles the pane and o focuses it (j/k, Enter jumps, Esc or o
returns); both explain themselves when the terminal is too narrow.
Jumping switches Archive to Board view, clears search and board
filters and reveals a hidden completed target, without changing saved
settings, and names what it changed.

Plan: U9 (R14-R17; F3, AE6; KTD14, KTD15, KTD17, KTD18).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
claude added 15 commits October 21, 2026 22:59
README gains a Links and Backlinks section (the [[ picker, numeric and
prefix references, the l key, the side pane, broken and divergent
markers, and that other surfaces show raw tokens); docs/README lists the
feature; CLAUDE.md records the new modules and the stable-ID design
constraint.

Plan: U10.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
Behavior-preserving cleanup from a simplification pass:
- share item_link_targets, the unique-prefix computation, the broken
  and divergent markers and a truncate_chars helper instead of copies;
- journal view reuses should_show_item; EditBaseline, OpenLink and
  LinkCandidate replace anonymous tuples; the pane key handler matches
  on the target directly; /task and /note share rewrite_new;
- refresh derives boards from the items it just loaded (one storage
  read instead of two), the backlink sort caches positions, the unique
  timestamp check only collects candidates at or after the new one,
  plain_text skips rendering text without [[, and the link query is
  only computed for Enter and Esc;
- drop a plan-ID reference from a code comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
refresh_items now loads the archive into app.items in Archive view, so
`/note` there picked the highest archived ID as "the new note" and
edit_note_external read archived notes. Saving then wrote an archived
note's text into the active item with the same numeric ID.

Use the ID create_note_direct_with_tags returns, read the note to edit
from active_items, and answer Enter on an archived note with a status
instead of opening the editor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
The existing retry test left storage unchanged between submits, so it
passed even with the retained baseline or the `b.id == id` guard
removed. Change storage under an open draft, and retarget an `e`
prefill to another item; each new test fails under the matching
mutation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
The pane's j/k cursor, Esc, the divergent "cannot jump" status and the
fallback that hands keys back to the list once the pane is hidden had
no test that could fail. Each new assertion fails under the matching
mutation of handle_pane_key or handle_key_event. The broken-link test
now also checks its status.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
The pane gets an item's outgoing links from item_link_targets, so the
per-item map the index built on every refresh was a second, unused
source of the same answer. The dedupe test now asserts on
item_link_targets directly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
Notes are joined with NOTE_FIELD_SEPARATOR, not `\n`; that constant's
own comment explains why no reference straddles title and body.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
Found in code review: after `e`, KTD13's Enter rule blocks saving text
that already holds an unclosed `[[`, and the hint's "press Esc" cancels
the edit. Recorded with a candidate fix rather than changing KTD13 now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
Add taskbook-common::limits: 1,024 active and 4,096 archived items
supported, 512 active recommended. Features are designed for that size,
which is what makes same-millisecond stable-ID collisions acceptable.

Envelope tests:
- creates stay unique against a full store whose timestamps form one
  dense run starting now (fails if creates stop reading the archive);
- unique stable-ID prefixes fit the 6-character TUI column at 1,024
  active items;
- an #[ignore]d create latency budget (500 ms) for `--ignored` runs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
CLAUDE.md gains a Design Envelope decision (whole-file JSON, O(n) per
operation is fine, larger stores are unsupported) and ties the stable-ID
collision tradeoff to it. README gains a Limits section with measured
numbers, reproducible with scripts/bench-envelope.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
taskbook is a board a person reads and keeps up with; that sets the
bound. 512 active is the recommendation, 1,024/4,096 is headroom, and
agent use via MCP stays within it. Features handle what a person can
produce; machine-scale scenarios are unsupported rather than edge cases.
Performance numbers stay, as evidence the JSON storage fits this size.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
SizeNotice::check(active, archived) returns a notice once active items
pass the 512 recommendation or the archive passes 4,096, and summary()
names each limit crossed. Informational only; callers decide wording.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
create_task, create_note and restore_items add a second text block when
the store is past the envelope. It asks the agent to tell the user and
not to archive or delete items itself, and mentions `tb --clear` only
when there are completed tasks to archive.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
The header gains a warning-styled indicator beside the existing ones,
suggesting /clear only when completed tasks exist. The archive count is
read once at startup and whenever Archive view loads the archive, so it
can lag deletes made in the same session.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ATtYfxsHkkaRLf94U8Mzmw
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants