\chapter{The [[cli.todo]] module and
         the [[todo]] subcommands}%
\label{cli.todo}

In this chapter we introduce the subcommands found under [[nytid todo]].
The module provides task management with priority-based ordering,
optional deadlines, estimated durations, and ICS calendar export.

\section{Design Overview and Rationale}

Teachers juggle many tasks: grading, preparing lectures, meeting
students, answering email. A todo system embedded in nytid can leverage
the existing time-tracking infrastructure (see \cref{cli.track}) to
connect \emph{what needs doing} with \emph{how long it actually takes}.

\subsection{Why Binary-Search Priority?}

Numeric priority scales (1--5, 1--10) suffer from two problems: users
cluster items at the top, and absolute numbers are hard to assign
consistently. Instead, we use \emph{relative comparison}: when adding a
new todo, the system asks \enquote{Is this more important than X?} using
a binary search over existing items. This produces a float priority
that reflects the user's true ranking without requiring them to think in
numbers.

\subsection{Effective Priority with Deadline Boost}

Items with approaching deadlines should float upward even if their base
priority is modest. We define an \emph{effective priority} that adds a
deadline-based boost using a quadratic urgency curve: gentle when the
deadline is far away, sharply increasing as it approaches. This means a
low-priority grading task due tomorrow will correctly outrank a
high-priority lecture-prep task due next month.

\subsection{Sub-items and Hierarchy}

Any todo can have children (sub-items). A sub-item inherits the
parent's deadline and labels by default, tracks priority among its
siblings, and can be started/stopped independently via the track
system. When all children of a parent are done, the parent is
automatically marked done.

\subsection{Track Integration}

Rather than building a separate time-tracking mechanism, [[todo start]]
delegates to [[nytid track start]] with a special [[todo:<id>]] label.
This means all existing track infrastructure---persistence, export,
statistics---works with todo items out of the box.

\section{Code Outline}

<<[[todo.py]]>>=
import csv
import datetime
import json
import logging
import math
import os
import pathlib
import re
import shutil
import signal
import subprocess
import sys
import tempfile
import typer
import typerconf as config
from typing import List, Optional, Dict
from dataclasses import dataclass, field, asdict

from nytid import storage

<<constants>>
<<configuration>>

cli = typer.Typer(
    name="todo",
    help="Manage tasks with priorities and deadlines"
)

<<data model>>
<<effective priority>>
<<storage functions>>
<<active stack helpers>>
<<sub-item helpers>>
<<child priority helpers>>
<<editor infrastructure>>
<<binary search priority>>
<<autocompletion functions>>
<<helpers>>
<<subcommands>>
@

\section{Configuration}

The todo system stores its data in a configurable directory, defaulting
to [[~/.nytid/todos]]. We also allow configuring the maximum deadline
boost and work hours used when scheduling todos through
[[nytid schedule show]].

Why a separate directory from track data? Because todos have a different
lifecycle---they are created, prioritized, and completed, whereas track
entries are append-only historical records. Keeping them separate avoids
coupling the two storage formats.

<<configuration>>=
TODO_DIR_CONFIG = "todo.data_dir"
TODO_MAX_BOOST_CONFIG = "todo.max_boost"
TODO_WORK_START_CONFIG = "todo.work_start"
TODO_WORK_END_CONFIG = "todo.work_end"
TODO_WORK_DAYS_CONFIG = "todo.work_days"


def get_todo_dir() -> pathlib.Path:
    """Get the todo data directory path."""
    try:
        return pathlib.Path(
            config.get(TODO_DIR_CONFIG)
        )
    except KeyError:
        todo_dir = (
            pathlib.Path.home() / ".nytid" / "todos"
        )
        config.set(TODO_DIR_CONFIG, str(todo_dir))
        return todo_dir


def get_todo_file() -> pathlib.Path:
    """Get the path to the todos JSON file."""
    return get_todo_dir() / "todos.json"


DEFAULT_MAX_BOOST = 10.0


def get_max_boost() -> float:
    """Get the maximum deadline boost value."""
    try:
        return float(
            config.get(TODO_MAX_BOOST_CONFIG)
        )
    except (KeyError, ValueError):
        return DEFAULT_MAX_BOOST


DEFAULT_WORK_START = "08:00"


def get_work_start() -> str:
    """Get configured work day start time."""
    try:
        return config.get(TODO_WORK_START_CONFIG)
    except KeyError:
        return DEFAULT_WORK_START


DEFAULT_WORK_END = "17:00"


def get_work_end() -> str:
    """Get configured work day end time."""
    try:
        return config.get(TODO_WORK_END_CONFIG)
    except KeyError:
        return DEFAULT_WORK_END


DEFAULT_WORK_DAYS = "mon,tue,wed,thu,fri"


def get_work_days() -> List[str]:
    """Get configured work days as list."""
    try:
        days_str = config.get(TODO_WORK_DAYS_CONFIG)
    except KeyError:
        days_str = DEFAULT_WORK_DAYS
    return [d.strip().lower() for d in days_str.split(",")]
@

\section{Data Model}

\subsection{The TodoItem Dataclass}

Each todo item carries an integer ID, a title, labels that connect to
the track system, a float priority determined by binary search, a
status, timestamps, an optional deadline, an estimated duration in
hours, a longer description, an optional parent ID for sub-items, and
an optional worker assignment.

To support work-session management (inspired by the UNIX [[at]]
command), each item can also capture the working directory and
environment at creation time, extended notes for the editor, and a
default command to run when starting the task. The [[wd]] and [[env]]
fields together let [[todo start]] restore the exact context in which
the task was conceived---solving the common problem of \enquote{where
was I working on this?} The [[command]] field defaults to [[$SHELL]]
but can be set to tools like [[claude]] or [[opencode]].

The optional [[github]] field links a todo to a GitHub issue or pull
request, enabling bidirectional synchronization. We store
the repository, item type, and number as a dictionary rather than
a URL string because the [[gh]] CLI needs these components
individually---parsing them back from a URL would be fragile and
redundant.

We use a dataclass rather than a plain class because the fields are
pure data with no complex invariants---unlike [[ActiveSession]] in
track, which manages mutable state with batch semantics.

<<data model>>=
@dataclass
class TodoItem:
    """A single todo item with priority and metadata.

    Items are stored in a JSON file and sorted by
    effective priority for display and scheduling.
    """

    id: int
    title: str
    labels: List[str] = field(default_factory=list)
    priority: float = 0.0
    status: str = "pending"
    created: str = ""
    deadline: Optional[str] = None
    estimated: Optional[float] = None
    description: str = ""
    parent_id: Optional[int] = None
    who: Optional[str] = None
    notes: str = ""
    wd: Optional[str] = None
    env: Optional[Dict[str, str]] = None
    command: Optional[str] = None
    github: Optional[Dict[str, str]] = None

    def to_dict(self) -> dict:
        """Convert to dictionary for JSON serialization."""
        return asdict(self)

    @classmethod
    def from_dict(cls, data: dict) -> "TodoItem":
        """Create from dictionary (JSON deserialization).

        Unknown keys are silently ignored for forward
        compatibility.
        """
        known = {f.name for f in cls.__dataclass_fields__.values()}
        filtered = {k: v for k, v in data.items() if k in known}
        return cls(**filtered)
@

\subsection{Testing the Data Model}

Let's verify round-trip serialization preserves all fields:

<<test functions>>=
def test_todo_item_roundtrip():
    """Test TodoItem serialization round-trip."""
    item = TodoItem(
        id=1,
        title="Grade labs",
        labels=["DD1310", "grading"],
        priority=5.0,
        status="pending",
        created="2026-02-20T10:00:00",
        deadline="2026-02-28T23:59:00",
        estimated=3.0,
        description="Grade lab 3",
        parent_id=None,
        who="me",
        notes="Check Canvas for submissions",
        wd="/home/user/courses/DD1310",
        env={"COURSE": "DD1310"},
        command="claude",
    )
    data = item.to_dict()
    restored = TodoItem.from_dict(data)
    assert restored.id == 1
    assert restored.title == "Grade labs"
    assert restored.labels == ["DD1310", "grading"]
    assert restored.priority == 5.0
    assert restored.deadline == "2026-02-28T23:59:00"
    assert restored.who == "me"
    assert restored.notes == "Check Canvas for submissions"
    assert restored.wd == "/home/user/courses/DD1310"
    assert restored.env == {"COURSE": "DD1310"}
    assert restored.command == "claude"


def test_todo_item_from_dict_ignores_unknown():
    """Unknown keys should not cause errors."""
    data = {
        "id": 1,
        "title": "Test",
        "future_field": "ignored",
    }
    item = TodoItem.from_dict(data)
    assert item.id == 1
    assert item.title == "Test"
@

\section{Effective Priority}

The effective priority determines display and scheduling order.
Without a deadline, it equals the base priority. With a deadline, we
add a boost that increases quadratically as the deadline approaches.

Why quadratic rather than linear? A linear curve would start boosting
immediately, which is unhelpful for items due in three months. The
quadratic curve stays near zero until roughly the last third of the
time span, then ramps up sharply---matching the human experience of
\enquote{it's not urgent yet} followed by \enquote{this is due soon!}

<<effective priority>>=
def effective_priority(
    item: TodoItem,
    now: Optional[datetime.datetime] = None,
) -> float:
    """Compute effective priority including deadline boost.

    Returns base priority plus a deadline-based urgency
    boost that increases quadratically as the deadline
    approaches.
    """
    if now is None:
        now = datetime.datetime.now()

    base = item.priority

    if not item.deadline:
        return base

    deadline_dt = datetime.datetime.fromisoformat(
        item.deadline
    )
    created_dt = datetime.datetime.fromisoformat(
        item.created
    )

    max_boost = get_max_boost()

    days_remaining = (deadline_dt - now).total_seconds() / 86400
    total_span = max(
        (deadline_dt - created_dt).total_seconds() / 86400,
        1.0,
    )

    if days_remaining <= 0:
        return base + max_boost

    urgency = max(0.0, 1.0 - days_remaining / total_span)
    deadline_boost = max_boost * (urgency ** 2)

    return base + deadline_boost
@

\subsection{Testing Effective Priority}

We verify the key properties: no boost without deadline, zero boost
when deadline is far away, maximum boost when deadline is past, and
quadratic growth in between.

<<test functions>>=
def test_effective_priority_no_deadline():
    """Without a deadline, effective equals base priority."""
    item = TodoItem(
        id=1, title="Test", priority=5.0,
        created="2026-01-01T00:00:00"
    )
    assert effective_priority(item) == 5.0


def test_effective_priority_past_deadline():
    """Past-deadline items get maximum boost."""
    item = TodoItem(
        id=1, title="Test", priority=5.0,
        created="2026-01-01T00:00:00",
        deadline="2026-01-15T00:00:00",
    )
    now = datetime.datetime(2026, 1, 20)
    result = effective_priority(item, now)
    assert result == 5.0 + DEFAULT_MAX_BOOST


def test_effective_priority_quadratic_growth():
    """Boost should be small early and large near deadline."""
    item = TodoItem(
        id=1, title="Test", priority=0.0,
        created="2026-01-01T00:00:00",
        deadline="2026-01-31T00:00:00",
    )
    early = datetime.datetime(2026, 1, 5)
    mid = datetime.datetime(2026, 1, 20)
    late = datetime.datetime(2026, 1, 29)

    p_early = effective_priority(item, early)
    p_mid = effective_priority(item, mid)
    p_late = effective_priority(item, late)

    assert p_early < p_mid < p_late
    # Early boost should be small (quadratic)
    assert p_early < 1.0
@

\section{Storage}

The storage format uses a single JSON file with a [[next_id]] counter
and an array of todo items. This is simpler than the track system's
dual-file approach because todos don't have the active/historical
split---all state lives in one place.

<<storage functions>>=
def ensure_todo_dir():
    """Create the todo data directory if needed.

    Uses [[get_todo_dir()]] to resolve the path, then calls
    [[mkdir(parents=True, exist_ok=True)]] so that intermediate
    directories are also created and a pre-existing directory
    does not raise an error.
    """
    get_todo_dir().mkdir(parents=True, exist_ok=True)


def load_todos() -> tuple:
    """Load todos from persistent storage.

    Returns (next_id, todos_list). Returns (1, []) if
    the file doesn't exist or contains invalid data.
    """
    todo_file = get_todo_file()
    if not todo_file.exists():
        return 1, []

    try:
        with open(todo_file, "r") as f:
            data = json.load(f)
            todos = [
                TodoItem.from_dict(t)
                for t in data.get("todos", [])
            ]
            return data.get("next_id", 1), todos
    except (
        json.JSONDecodeError,
        KeyError,
        ValueError,
    ) as e:
        logging.warning(
            f"Error loading todos from {todo_file}: {e}"
        )
        return 1, []


def save_todos(next_id: int, todos: List[TodoItem]):
    """Save todos to persistent storage."""
    ensure_todo_dir()
    todo_file = get_todo_file()
    data = {
        "next_id": next_id,
        "todos": [t.to_dict() for t in todos],
    }
    with open(todo_file, "w") as f:
        json.dump(data, f, indent=2)
@

\subsection{Testing Storage}

<<test functions>>=
def test_load_empty(temp_todo_dir):
    """Loading from nonexistent file returns empty state."""
    next_id, todos = load_todos()
    assert next_id == 1
    assert todos == []


def test_save_and_load(temp_todo_dir):
    """Round-trip save/load preserves data."""
    item = TodoItem(
        id=1, title="Test",
        created="2026-02-20T10:00:00"
    )
    save_todos(2, [item])
    next_id, todos = load_todos()
    assert next_id == 2
    assert len(todos) == 1
    assert todos[0].title == "Test"
@

\section{Active Task Stack}

The todo system supports nested work: start a parent task, decompose
it into sub-tasks, work on children one at a time.  To track
\emph{which} task is innermost-active, we maintain a stack of
in-progress todo IDs stored separately from [[todos.json]].

Why a separate file rather than a field in [[todos.json]]?  Because
the stack tracks ephemeral execution state (what is being worked on
\emph{right now}), while [[todos.json]] tracks persistent data state
(the tasks themselves).  Keeping them separate avoids changing the
[[load_todos]]/[[save_todos]] signature, which has many call sites,
and allows atomic reads/writes without locking the main todo file.

The stack obeys LIFO discipline:
\begin{description}
\item[Push] [[todo start <id>]] appends to the stack.
\item[Pop] [[todo done]] (no argument) pops the top and marks
  it done.
\item[Remove] [[todo done <id>]] removes that specific ID from
  anywhere in the stack.
\end{description}

<<active stack helpers>>=
def get_active_stack_file() -> pathlib.Path:
    """Return path to the active-task stack file."""
    return get_todo_dir() / "active_stack.json"


def load_active_stack() -> List[int]:
    """Load the stack of active todo IDs.

    Returns an empty list if the file doesn't exist.
    """
    stack_file = get_active_stack_file()
    if not stack_file.exists():
        return []
    try:
        with open(stack_file) as f:
            data = json.load(f)
        if isinstance(data, list):
            return data
        return []
    except (json.JSONDecodeError, OSError):
        return []


def save_active_stack(stack: List[int]):
    """Save the active-task stack to persistent storage."""
    ensure_todo_dir()
    stack_file = get_active_stack_file()
    with open(stack_file, "w") as f:
        json.dump(stack, f)
@

Let's verify the stack round-trips correctly:

<<test functions>>=
def test_active_stack_roundtrip(temp_todo_dir):
    """Stack persists and loads in order."""
    save_active_stack([5, 7, 3])
    assert load_active_stack() == [5, 7, 3]


def test_active_stack_empty(temp_todo_dir):
    """Empty stack returns empty list."""
    assert load_active_stack() == []
@

\section{Sub-item Helpers}

Sub-items create a tree structure within the flat todo list. We need
helpers to navigate this tree: finding children, finding siblings (for
priority comparison), and checking whether a parent's children are all
done.

Why store sub-items flat with [[parent_id]] rather than nesting them?
Because flat storage simplifies JSON serialization, avoids recursive
data structures, and lets commands like [[todo ls --flat]] work
without flattening logic.

<<sub-item helpers>>=
def get_children(
    todos: List[TodoItem], parent_id: int
) -> List[TodoItem]:
    """Get all direct children of a todo item."""
    return [t for t in todos if t.parent_id == parent_id]


def get_siblings(
    todos: List[TodoItem], item: TodoItem
) -> List[TodoItem]:
    """Get siblings of an item (same parent level).

    For top-level items, siblings are all other top-level
    items. For sub-items, siblings share the same parent.
    """
    return [
        t
        for t in todos
        if t.parent_id == item.parent_id and t.id != item.id
    ]


def get_active_siblings(
    todos: List[TodoItem], item: TodoItem
) -> List[TodoItem]:
    """Get pending/in-progress siblings, sorted by
    effective priority (highest first)."""
    siblings = get_siblings(todos, item)
    active = [
        s for s in siblings if s.status != "done"
    ]
    active.sort(key=effective_priority, reverse=True)
    return active


def check_parent_completion(
    todos: List[TodoItem], parent_id: int
) -> bool:
    """Check if all children of a parent are done.

    Returns True if the parent has children and all are
    done. Returns False if there are no children or any
    child is not done.
    """
    children = get_children(todos, parent_id)
    if not children:
        return False
    return all(c.status == "done" for c in children)
@

The [[next]] command and the cron script both need to find the
highest-priority pending task, optionally scoped to a specific
parent.  Extracting this into a function (rather than a reusable
chunk) makes the dependency on [[todos]] and [[who]] explicit
through parameters:

<<sub-item helpers>>=
def find_pending_by_priority(
    todos: List[TodoItem],
    who: Optional[str] = None,
    parent_id: Optional[int] = None,
) -> Optional[TodoItem]:
    """Find highest-priority pending task.

    Optionally filtered by assignee regex and parent.
    Returns None if no matching task is found.
    """
    candidates = [
        t for t in todos
        if t.status == "pending"
        and (parent_id is None
             or t.parent_id == parent_id)
    ]
    if who:
        candidates = [
            t for t in candidates
            if t.who and re.search(who, t.who)
        ]
    candidates.sort(
        key=effective_priority, reverse=True
    )
    return candidates[0] if candidates else None
@

Auto-parenting needs to know which task is \emph{innermost}-active.
A linear scan for the first in-progress item would always return
the parent in a nested workflow; the active stack gives us the
correct answer (the most recently started child):

<<sub-item helpers>>=
def get_currently_tracked_todo(
    todos: List[TodoItem],
) -> Optional[TodoItem]:
    """Find the innermost active todo for auto-parenting.

    Uses the active stack (top = most recently started).
    Falls back to scanning for in-progress items when the
    stack is empty, for backward compatibility.
    """
    stack = load_active_stack()
    if stack:
        top_id = stack[-1]
        for t in todos:
            if t.id == top_id:
                return t
    for t in todos:
        if t.status == "in-progress":
            return t
    return None
@

\subsection{Testing Sub-item Helpers}

<<test functions>>=
def test_get_children():
    """Children are items with matching parent_id."""
    todos = [
        TodoItem(id=1, title="Parent"),
        TodoItem(id=2, title="Child 1", parent_id=1),
        TodoItem(id=3, title="Child 2", parent_id=1),
        TodoItem(id=4, title="Other"),
    ]
    children = get_children(todos, 1)
    assert len(children) == 2
    assert {c.id for c in children} == {2, 3}


def test_check_parent_completion():
    """Parent is complete when all children are done."""
    todos = [
        TodoItem(id=1, title="Parent"),
        TodoItem(
            id=2, title="C1",
            parent_id=1, status="done"
        ),
        TodoItem(
            id=3, title="C2",
            parent_id=1, status="pending"
        ),
    ]
    assert not check_parent_completion(todos, 1)
    todos[2].status = "done"
    assert check_parent_completion(todos, 1)
@

\subsection{Context-Aware Child Priorities}

When children are created via the editor or reconciled during edits,
they need priorities that place them \emph{between} their parent and
the parent's next-lower-priority sibling. Without this, children
receive absolute sequential priorities (1.0, 2.0, \ldots) and can
sort below unrelated lower-priority items in flat view.

Consider: parent~A at priority~5.0, parent~B at priority~3.0. If
children of~A get priorities 1.0 and~2.0, a flat sort places them
\emph{below}~B---the opposite of what the user intended. Instead,
children of~A should receive priorities in the range $(3.0, 5.0)$,
so they always appear between~A and~B.

The first helper finds the available priority range for children of
a given parent. We look at the parent's siblings (same
[[parent_id]] level) to find the next-lower-priority item. The range
runs from the parent's priority down to that sibling's priority. If
no sibling exists below, we use a generous default floor.

<<child priority helpers>>=
def compute_child_priority_range(
    todos: List[TodoItem],
    parent: TodoItem,
) -> tuple:
    """Find the priority range for children.

    Returns ``(high, low)`` where high is the parent's
    priority and low is the next-lower sibling's priority
    (or a default floor if no lower sibling exists).
    """
    high = parent.priority
    siblings = [
        t for t in todos
        if t.parent_id == parent.parent_id
        and t.id != parent.id
        and t.status != "done"
    ]
    lower = [
        s for s in siblings
        if effective_priority(s) < high
    ]
    if lower:
        closest = max(
            lower, key=effective_priority
        )
        low = effective_priority(closest)
    else:
        low = max(0.0, high - 10.0)
    return (high, low)
@

The second helper distributes [[count]] priorities evenly within a
range, highest first. We divide the range into [[count + 1]] equal
segments and place one priority at each interior division point.
This leaves room at both ends for future insertions---the same
\enquote{leave room for midpoints} philosophy used by
[[assign_priority_at_position]].

<<child priority helpers>>=
def distribute_priorities_in_range(
    count: int, high: float, low: float,
) -> List[float]:
    """Distribute priorities evenly in (low, high).

    Returns a list of ``count`` values, highest first.
    For example, 3 items in (3.0, 5.0) gives
    [4.5, 4.0, 3.5].
    """
    if count == 0:
        return []
    step = (high - low) / (count + 1)
    return [high - step * (i + 1) for i in range(count)]
@

\subsection{Testing Child Priority Helpers}

<<test functions>>=
def test_compute_child_priority_range_with_sibling():
    """Range is bounded by next-lower sibling."""
    todos = [
        TodoItem(
            id=1, title="Parent A", priority=5.0,
            created="2026-01-01T00:00:00",
        ),
        TodoItem(
            id=2, title="Parent B", priority=3.0,
            created="2026-01-01T00:00:00",
        ),
    ]
    high, low = compute_child_priority_range(
        todos, todos[0]
    )
    assert high == 5.0
    assert low == 3.0


def test_compute_child_priority_range_no_sibling():
    """No lower sibling uses default floor."""
    todos = [
        TodoItem(
            id=1, title="Only parent", priority=5.0,
            created="2026-01-01T00:00:00",
        ),
    ]
    high, low = compute_child_priority_range(
        todos, todos[0]
    )
    assert high == 5.0
    assert low == 0.0


def test_distribute_priorities_in_range():
    """Priorities are evenly spaced, highest first."""
    result = distribute_priorities_in_range(
        3, 5.0, 3.0
    )
    assert len(result) == 3
    assert result == [4.5, 4.0, 3.5]


def test_distribute_priorities_single():
    """Single item gets the midpoint."""
    result = distribute_priorities_in_range(
        1, 5.0, 3.0
    )
    assert result == [4.0]


def test_children_priorities_between_parent_siblings():
    """Integration: children sort between parents."""
    parent_a = TodoItem(
        id=1, title="High priority",
        priority=5.0,
        created="2026-01-01T00:00:00",
    )
    parent_b = TodoItem(
        id=2, title="Lower priority",
        priority=3.0,
        created="2026-01-01T00:00:00",
    )
    todos = [parent_a, parent_b]
    high, low = compute_child_priority_range(
        todos, parent_a
    )
    child_prios = distribute_priorities_in_range(
        2, high, low
    )
    # All children must sort between parents
    for p in child_prios:
        assert p > 3.0
        assert p < 5.0
@

\section{Editor Infrastructure}
\label{editor-infrastructure}

The editor system lets users create and edit todos using a markdown
document with YAML front matter---a format familiar from static site
generators and note-taking tools. This serves two purposes: creating
hierarchical todos with sub-items in a single editor session, and
providing a natural format for viewing and editing existing todos.

The document format uses the first non-empty line as the title,
an optional [[---]]-delimited YAML block for metadata, free text
for notes, and markdown headings for sub-items. Each heading can have
its own YAML block and notes.

\subsection{Parsed Document Structure}

We represent the parsed editor output as a tree of items. Each item
has a title, notes, metadata from YAML front matter, and children
from nested headings. This mirrors [[TodoItem]] but is independent
of it---parsing produces a [[ParsedTodo]], which the caller converts
to [[TodoItem]] instances.

Why separate from [[TodoItem]]? Because the parsed structure is
transient (exists only during editor interaction) and doesn't carry
IDs, timestamps, or status. Keeping them separate avoids polluting
the domain model with editor concerns.

<<editor infrastructure>>=
@dataclass
class ParsedItem:
    """A single item parsed from editor content.

    Represents either a heading-delimited sub-item or
    the top-level item in an editor document.
    """

    title: str
    notes: str = ""
    metadata: dict = field(default_factory=dict)
    children: list = field(default_factory=list)


@dataclass
class ParsedTodo:
    """Complete parsed result from editor content.

    The top-level item plus its tree of children from
    markdown headings.
    """

    title: str
    notes: str = ""
    metadata: dict = field(default_factory=dict)
    children: list = field(default_factory=list)
@

\subsection{Minimal YAML Parser}

We need to parse YAML front matter blocks, but only a small subset:
flat key-value pairs with strings, numbers, and bracketed lists.
Rather than adding a PyYAML dependency for this narrow use case, we
implement a minimal parser. This keeps the dependency footprint small
and avoids YAML's many surprising edge cases (the \enquote{Norway
problem}, implicit type coercion, etc.).

<<editor infrastructure>>=
def parse_yaml_block(text):
    """Parse a minimal YAML block into a dictionary.

    Handles only flat key-value pairs with string,
    number, and bracket-list values. This is sufficient
    for our front matter format.
    """
    result = {}
    for line in text.strip().splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        if ":" not in line:
            continue
        key, _, value = line.partition(":")
        key = key.strip()
        value = value.strip()
        if not key:
            continue
        result[key] = parse_yaml_value(value)
    return result


def parse_yaml_value(value):
    """Parse a single YAML value.

    Recognizes: bracketed lists, brace-delimited dicts,
    floats, integers, and plain strings. Quoted strings
    have quotes stripped.
    """
    if not value:
        return ""
    if value.startswith("[") and value.endswith("]"):
        inner = value[1:-1]
        if not inner.strip():
            return []
        return [
            v.strip().strip("'\"")
            for v in inner.split(",")
        ]
    if value.startswith("{") and value.endswith("}"):
        inner = value[1:-1]
        if not inner.strip():
            return {}
        result = {}
        for pair in inner.split(","):
            if ":" not in pair:
                continue
            k, _, v = pair.partition(":")
            result[k.strip()] = v.strip()
        return result
    if value.startswith(("'", '"')):
        return value.strip("'\"")
    try:
        return int(value)
    except ValueError:
        pass
    try:
        return float(value)
    except ValueError:
        pass
    return value
@

\subsection{Rendering YAML}

We need the inverse of parsing to support round-trip editing: render
a todo back to the same document format so the user can re-edit it.
We only emit keys with non-default values so the editor document
stays uncluttered.

<<editor infrastructure>>=
def render_yaml_block(metadata):
    """Render a dictionary as minimal YAML front matter.

    Produces the ``---``-delimited block. Returns empty
    string if metadata is empty.
    """
    if not metadata:
        return ""
    lines = ["---"]
    for key, value in metadata.items():
        if value is None or value == "" or value == []:
            continue
        if isinstance(value, list):
            items = ", ".join(str(v) for v in value)
            lines.append(f"{key}: [{items}]")
        elif isinstance(value, dict):
            items = ", ".join(
                f"{k}: {v}" for k, v in value.items()
            )
            lines.append(f"{key}: {{{items}}}")
        else:
            lines.append(f"{key}: {value}")
    lines.append("---")
    if len(lines) == 2:
        return ""
    return "\n".join(lines)
@

\subsection{Parsing Editor Content}

The parser walks the document line by line, building a tree from
headings. The algorithm uses a stack to track the current nesting
level: a [[# Heading]] starts a level-1 child, [[## Heading]]
starts a level-2 child (nested under the preceding level-1), and
so on.

The key insight is that each heading level maps directly to tree
depth, so we maintain a stack of the current path from root to the
active item. When we encounter a heading at level~\(n\), we pop the
stack back to depth~$n-1$ and push a new child there.

<<editor infrastructure>>=
def parse_editor_content(text):
    """Parse editor content into a ParsedTodo tree.

    Format:
    - First non-empty line = title
    - Optional ``---``...``---`` = YAML front matter
    - Text until first heading = parent notes
    - ``# Heading`` = level-1 child
    - ``## Heading`` = level-2 child (of preceding #)
    - Each heading may have its own YAML block + notes
    """
    lines = text.splitlines()
    <<parse title from first line>>
    <<parse optional yaml block>>
    <<parse notes and headings>>
    return ParsedTodo(
        title=title,
        notes=parent_notes.strip(),
        metadata=parent_metadata,
        children=top_children,
    )
@

The title is simply the first non-empty line, analogous to how
[[git commit]] treats the first line of the commit message:

<<parse title from first line>>=
title = ""
idx = 0
while idx < len(lines):
    stripped = lines[idx].strip()
    if stripped:
        title = stripped
        idx += 1
        break
    idx += 1
@

After the title, an optional YAML front matter block provides
metadata. We detect it by looking for a line that is exactly
[[---]]:

<<parse optional yaml block>>=
parent_metadata = {}
while idx < len(lines) and not lines[idx].strip():
    idx += 1
if idx < len(lines) and lines[idx].strip() == "---":
    yaml_start = idx + 1
    idx = yaml_start
    while idx < len(lines):
        if lines[idx].strip() == "---":
            yaml_text = "\n".join(
                lines[yaml_start:idx]
            )
            parent_metadata = parse_yaml_block(
                yaml_text
            )
            idx += 1
            break
        idx += 1
@

The remaining content splits into parent notes (text before any
heading) and children (created by headings). We use the heading
level to maintain the tree structure via a stack.

<<parse notes and headings>>=
parent_notes_lines = []
top_children = []
heading_re = re.compile(r"^(#+)\s+(.*)")

<<initialize heading parser state>>

while idx < len(lines):
    line = lines[idx]
    heading_match = heading_re.match(line)
    if heading_match:
        <<handle heading line>>
    else:
        <<handle content line>>
    idx += 1

<<finalize last item notes>>
@

We use a stack of (level, children-list) pairs to handle arbitrarily
nested headings without recursion.  When a heading at level \(n\)
appears, we pop entries deeper than \(n\)---automatically closing any
open sub-items---then push the new item onto the stack at level \(n\).
This mirrors how markdown heading depth implies document structure.

<<initialize heading parser state>>=
current_item = None
current_notes_lines = []
current_yaml_lines = None
in_yaml = False
# Stack: list of (level, children_list) pairs
# Level 0 = top-level children
stack = [(0, top_children)]
@

When we encounter a heading, we finalize the previous item's
notes, determine where in the tree the new item belongs, and
push it onto the appropriate children list:

<<handle heading line>>=
<<finalize current item>>
level = len(heading_match.group(1))
item_title = heading_match.group(2).strip()
current_item = ParsedItem(title=item_title)
current_notes_lines = []
current_yaml_lines = None
in_yaml = False
# Pop stack to find parent at level-1
while len(stack) > 1 and stack[-1][0] >= level:
    stack.pop()
# Add to parent's children
stack[-1][1].append(current_item)
# Push this item's children list
stack.append((level, current_item.children))
@

Content lines go to either the parent notes (if no heading seen
yet) or the current item's notes. We also detect YAML blocks
within heading sections:

<<handle content line>>=
if current_item is None:
    parent_notes_lines.append(line)
else:
    stripped = line.strip()
    if stripped == "---":
        if in_yaml:
            in_yaml = False
        elif current_yaml_lines is None:
            current_yaml_lines = []
            in_yaml = True
        else:
            current_notes_lines.append(line)
    elif in_yaml:
        current_yaml_lines.append(line)
    else:
        current_notes_lines.append(line)
@

<<finalize current item>>=
if current_item is not None:
    if current_yaml_lines is not None:
        current_item.metadata = parse_yaml_block(
            "\n".join(current_yaml_lines)
        )
    current_item.notes = "\n".join(
        current_notes_lines
    ).strip()
@

<<finalize last item notes>>=
if current_item is not None:
    <<finalize current item>>
parent_notes = "\n".join(parent_notes_lines)
@

\subsection{Testing the Editor Parser}

Let's verify the parser handles the key cases: title extraction,
YAML front matter, notes, and heading hierarchy.

<<test functions>>=
def test_parse_editor_title_from_first_line():
    """First non-empty line becomes the title."""
    text = "\n  My Task Title\n\nSome notes here."
    result = parse_editor_content(text)
    assert result.title == "My Task Title"
    assert "Some notes" in result.notes


def test_parse_editor_headings():
    """Headings create children with notes."""
    text = """Parent Task
---
deadline: 2026-03-01
---

Parent notes here.

# Sub-item one

Notes for sub-item one.

# Sub-item two

Notes for sub-item two.
"""
    result = parse_editor_content(text)
    assert result.title == "Parent Task"
    assert result.metadata.get("deadline") == "2026-03-01"
    assert "Parent notes" in result.notes
    assert len(result.children) == 2
    assert result.children[0].title == "Sub-item one"
    assert "sub-item one" in result.children[0].notes
    assert result.children[1].title == "Sub-item two"


def test_parse_editor_nested():
    """## headings create children of preceding #."""
    text = """Parent

# Level 1

## Level 2a

## Level 2b

# Another level 1
"""
    result = parse_editor_content(text)
    assert len(result.children) == 2
    assert result.children[0].title == "Level 1"
    assert len(result.children[0].children) == 2
    assert (
        result.children[0].children[0].title
        == "Level 2a"
    )
    assert result.children[1].title == "Another level 1"


def test_parse_editor_child_yaml():
    """Children can have their own YAML front matter."""
    text = """Parent

# Child
---
command: claude
wd: /tmp
---

Child notes.
"""
    result = parse_editor_content(text)
    child = result.children[0]
    assert child.metadata.get("command") == "claude"
    assert child.metadata.get("wd") == "/tmp"
    assert "Child notes" in child.notes
@

\subsection{Rendering Todo as Markdown}

The renderer is the inverse of the parser: given a [[TodoItem]]
and its children (looked up from the full todo list), it produces
the same markdown format that the editor expects. This enables
round-tripping: render an existing todo, edit it, parse it back.

We only include metadata fields that differ from defaults to keep
the output clean. The [[wd]], [[command]], [[deadline]],
[[estimated]], [[who]], and [[labels]] fields are emitted when set.

<<editor infrastructure>>=
def render_todo_markdown(
    item, todos,
):
    """Render a todo item as editor markdown.

    Produces the same format that parse_editor_content
    expects, enabling round-trip editing.
    """
    lines = [item.title]
    meta = build_item_metadata(item)
    yaml_block = render_yaml_block(meta)
    if yaml_block:
        lines.append(yaml_block)
    lines.append("")
    if item.notes:
        lines.append(item.notes)
        lines.append("")
    children = sorted(
        get_children(todos, item.id),
        key=lambda c: c.priority,
    )
    for child in children:
        lines.extend(
            render_child_markdown(child, todos, 1)
        )
    return "\n".join(lines)
@

We extract only non-default metadata fields so the YAML block stays
minimal---an empty dict produces no block at all, keeping the editor
document uncluttered for simple todos.

<<editor infrastructure>>=
def build_item_metadata(item):
    """Extract non-default metadata from a TodoItem.

    Returns a dict suitable for YAML front matter.
    """
    meta = {}
    if item.wd:
        meta["wd"] = item.wd
    if item.command:
        meta["command"] = item.command
    if item.labels:
        meta["labels"] = item.labels
    if item.deadline:
        meta["deadline"] = item.deadline
    if item.estimated:
        meta["estimate"] = item.estimated
    if item.who:
        meta["who"] = item.who
    if item.github:
        meta["github"] = item.github
    return meta
@

Children are rendered recursively, with the heading level increasing
at each depth.  This mirrors the parser's stack-based approach: a
level-1 child becomes [[#]], a level-2 grandchild becomes [[##]],
and so on.

<<editor infrastructure>>=
def render_child_markdown(item, todos, level):
    """Render a child item as markdown heading section.

    Returns a list of lines. Level determines the
    heading depth (1 = #, 2 = ##, etc.).
    """
    prefix = "#" * level
    lines = [f"{prefix} {item.title}"]
    meta = build_item_metadata(item)
    yaml_block = render_yaml_block(meta)
    if yaml_block:
        lines.append(yaml_block)
    lines.append("")
    if item.notes:
        lines.append(item.notes)
        lines.append("")
    grandchildren = sorted(
        get_children(todos, item.id),
        key=lambda c: c.priority,
    )
    for child in grandchildren:
        lines.extend(
            render_child_markdown(
                child, todos, level + 1
            )
        )
    return lines
@

\subsection{Testing the Renderer}

We verify round-tripping: rendering a todo then parsing the output
should produce equivalent structure.

<<test functions>>=
def test_render_todo_markdown():
    """Rendering then parsing produces equivalent data."""
    parent = TodoItem(
        id=1, title="Grade labs",
        labels=["DD1310"], deadline="2026-03-01",
        notes="Check submissions",
        wd="/home/user/project",
        command="claude",
    )
    child1 = TodoItem(
        id=2, title="Download",
        parent_id=1, priority=1.0,
        notes="From Canvas",
    )
    child2 = TodoItem(
        id=3, title="Run tests",
        parent_id=1, priority=2.0,
    )
    todos = [parent, child1, child2]

    md = render_todo_markdown(parent, todos)
    parsed = parse_editor_content(md)

    assert parsed.title == "Grade labs"
    assert "Check submissions" in parsed.notes
    assert parsed.metadata.get("wd") == (
        "/home/user/project"
    )
    assert len(parsed.children) == 2
    assert parsed.children[0].title == "Download"
    assert parsed.children[1].title == "Run tests"


def test_github_field_roundtrip():
    """GitHub field survives editor roundtrip."""
    item = TodoItem(
        id=1, title="GH issue",
        github={
            "repo": "owner/repo",
            "type": "issue",
            "number": "42",
        },
    )
    meta = build_item_metadata(item)
    assert "github" in meta
    yaml_str = render_yaml_block(meta)
    parsed = parse_yaml_block(
        yaml_str.replace("---\n", "").rstrip("---")
    )
    assert parsed["github"]["repo"] == "owner/repo"
    assert parsed["github"]["number"] == "42"
@

\subsection{Editor Invocation}

Opening the user's preferred editor follows the standard UNIX
pattern: write content to a temporary file, launch [[$EDITOR]]
(falling back to [[vi]]), and read back the result.  We use
the [[.md]] suffix so editors enable markdown syntax highlighting.

The same \textsc{sigint}-ignore pattern used for subprocess spawns
applies here: pressing Ctrl+C inside the editor should terminate
the editor, not crash nytid.  Without ignoring \textsc{sigint},
Python would raise [[KeyboardInterrupt]] in nytid at the same
instant the editor receives the signal, bypassing the [[finally]]
cleanup that deletes the temporary file.  Ignoring \textsc{sigint}
in the parent lets the editor handle Ctrl+C itself; nytid resumes
when the editor exits and the temporary file is removed cleanly.

<<editor infrastructure>>=
def open_editor(initial_content=""):
    """Open the user's editor with initial content.

    Returns the edited content as a string. Raises
    SystemExit if the editor exits with an error.
    """
    with tempfile.NamedTemporaryFile(
        suffix=".md",
        mode="w",
        delete=False,
    ) as f:
        f.write(initial_content)
        temp_path = f.name

    editor = os.environ.get("EDITOR", "vi")
    old_sigint = signal.signal(
        signal.SIGINT, signal.SIG_IGN
    )
    try:
        result = subprocess.run(
            [editor, temp_path],
            stdin=sys.stdin,
            stdout=sys.stdout,
            stderr=sys.stderr,
        )
        if result.returncode != 0:
            typer.echo(
                "Editor exited with error.",
                err=True,
            )
            raise typer.Exit(1)

        with open(temp_path, "r") as f:
            return f.read()
    finally:
        signal.signal(signal.SIGINT, old_sigint)
        try:
            os.unlink(temp_path)
        except OSError:
            pass
@

\section{Binary Search Priority}

When a user adds a new todo, we need to determine where it falls in
the priority order relative to existing items. Rather than asking the
user to pick a number, we show them existing items and ask
\enquote{Is your new task more important than this?}

The algorithm works like a standard binary search over the sorted
siblings: we show the middle item and ask if the new item is higher or
lower priority. After $O(\log n)$ questions, we've narrowed down to
a position and assign a priority value that's the midpoint between
the two neighbors.

When a child item has no siblings yet, the default priority of 1.0
would cause it to sort incorrectly in flat view (below unrelated
lower-priority items). The optional [[parent]] parameter provides
context: when present, the first child gets a priority within the
parent's range from [[compute_child_priority_range]] instead.

Why midpoint? Because it leaves room for future insertions without
needing to renumber. If we have items at priorities 3.0 and 5.0 and
insert between them at 4.0, there's still room for items at 3.5 and
4.5 later.

<<binary search priority>>=
def format_todo_for_comparison(item: TodoItem) -> str:
    """Format a todo item for display during comparison.

    Shows title, deadline, estimate, and labels to give
    the user full context for prioritization decisions.
    """
    parts = [item.title]
    if item.deadline:
        try:
            dl = datetime.datetime.fromisoformat(
                item.deadline
            )
            parts.append(f"(due {dl.strftime('%b %d')})")
        except ValueError:
            pass
    if item.estimated:
        parts.append(f"~{item.estimated}h")
    if item.labels:
        parts.append(
            f"[{', '.join(item.labels)}]"
        )
    return " ".join(parts)


def binary_search_priority(
    siblings: List[TodoItem],
    new_title: str,
    parent: Optional[TodoItem] = None,
    todos: Optional[List[TodoItem]] = None,
) -> float:
    """Determine priority via interactive binary search.

    Presents existing items and asks the user to compare.
    Returns the computed priority value.

    When ``parent`` and ``todos`` are provided and there
    are no siblings, the priority is placed within the
    parent's context range instead of defaulting to 1.0.
    """
    if not siblings:
        if parent is not None and todos is not None:
            high, low = compute_child_priority_range(
                todos, parent
            )
            return (high + low) / 2.0
        return 1.0

    sorted_items = sorted(
        siblings, key=effective_priority, reverse=True
    )

    low = 0
    high = len(sorted_items)

    while low < high:
        mid = (low + high) // 2
        mid_item = sorted_items[mid]

        display = format_todo_for_comparison(mid_item)
        prompt = (
            f"Higher priority than:"
            f"\n  {display}\n[y/n] "
        )

        answer = typer.prompt(prompt, type=str).strip().lower()
        if answer in ("y", "yes"):
            high = mid
        else:
            low = mid + 1

    <<compute midpoint priority>>
    return new_priority


def assign_priority_at_position(
    sorted_items: List[TodoItem],
    position: int,
) -> float:
    """Compute a priority value at the given insertion
    position in a sorted (descending) list.

    The priority is the midpoint between neighbors,
    or offset by 1.0 at the edges.
    """
    if not sorted_items:
        return 1.0

    if position == 0:
        top = effective_priority(sorted_items[0])
        return top + 1.0

    if position >= len(sorted_items):
        bottom = effective_priority(sorted_items[-1])
        return max(0.0, bottom - 1.0)

    above = effective_priority(sorted_items[position - 1])
    below = effective_priority(sorted_items[position])
    return (above + below) / 2.0
@

After the binary search narrows down the position, we compute the
midpoint between the neighboring items' priorities. Edge cases
(inserting at the top or bottom) use a fixed offset of 1.0 instead.

<<compute midpoint priority>>=
new_priority = assign_priority_at_position(
    sorted_items, low
)
@

\subsection{Testing Priority Assignment}

We test the position-based priority computation (the non-interactive
part) since the binary search itself requires user input.

<<test functions>>=
def test_assign_priority_empty():
    """Empty list gives default priority."""
    assert assign_priority_at_position([], 0) == 1.0


def test_assign_priority_at_top():
    """Inserting at top gives priority above highest."""
    items = [
        TodoItem(id=1, title="A", priority=5.0,
                 created="2026-01-01T00:00:00"),
        TodoItem(id=2, title="B", priority=3.0,
                 created="2026-01-01T00:00:00"),
    ]
    p = assign_priority_at_position(items, 0)
    assert p == 6.0


def test_assign_priority_at_bottom():
    """Inserting at bottom gives priority below lowest."""
    items = [
        TodoItem(id=1, title="A", priority=5.0,
                 created="2026-01-01T00:00:00"),
        TodoItem(id=2, title="B", priority=3.0,
                 created="2026-01-01T00:00:00"),
    ]
    p = assign_priority_at_position(items, 2)
    assert p == 2.0


def test_assign_priority_between():
    """Inserting between items gives the midpoint."""
    items = [
        TodoItem(id=1, title="A", priority=6.0,
                 created="2026-01-01T00:00:00"),
        TodoItem(id=2, title="B", priority=2.0,
                 created="2026-01-01T00:00:00"),
    ]
    p = assign_priority_at_position(items, 1)
    assert p == 4.0
@

\section{Shell Autocompletion}

Following the patterns established in [[track.nw]], we provide
completion functions for labels, worker names, and todo IDs. We
import [[complete_workers]] and [[complete_historical_labels]] from
the track module where possible, and extend them with todo-specific
data.

<<autocompletion functions>>=
def complete_labels(ctx, args, incomplete):
    """Complete labels from todos and track history.

    Combines labels from existing todos with historical
    track labels for comprehensive completion.
    """
    try:
        labels = set()
        _, todos = load_todos()
        for t in todos:
            labels.update(t.labels)

        try:
            from nytid.cli.track import (
                complete_historical_labels,
            )
            labels.update(
                complete_historical_labels(
                    ctx, args, ""
                )
            )
        except ImportError:
            pass

        return sorted(
            l for l in labels
            if l.startswith(incomplete)
        )
    except Exception:
        return []


def complete_todo_ids(ctx, args, incomplete):
    """Complete todo IDs with titles for context.

    Shows pending and in-progress items as completion
    candidates.
    """
    try:
        _, todos = load_todos()
        results = []
        for t in todos:
            if t.status == "done":
                continue
            id_str = str(t.id)
            if id_str.startswith(incomplete):
                results.append(id_str)
        return results
    except Exception:
        return []


def complete_todo_workers(ctx, args, incomplete):
    """Complete worker names from todos and track data."""
    try:
        workers = set()
        _, todos = load_todos()
        for t in todos:
            if t.who:
                workers.add(t.who)

        try:
            from nytid.cli.track import (
                complete_workers as track_workers,
            )
            workers.update(
                track_workers(ctx, args, "")
            )
        except ImportError:
            workers.add("me")

        return sorted(
            w for w in workers
            if w.startswith(incomplete)
        )
    except Exception:
        return []
@

\section{GitHub Integration}

Users often track work across both local todos and GitHub issues or
pull requests. Rather than building a full GitHub API client, we use
the [[gh]] CLI tool---consistent with how the project uses external
tools like AFS commands and editors. The [[gh]] CLI handles
authentication out of the box and provides JSON output suitable for
parsing.

Why not use the GitHub REST API directly? Because [[gh]] manages
OAuth tokens, handles rate limiting, and supports both
github.com and GitHub Enterprise without extra configuration. The
subprocess overhead is negligible for interactive use.

\subsection{Helper Functions}

These utilities are used by multiple commands---[[next]],
[[start]], and [[note]]---and extracted here rather than inlined
at each call site so that the logic can be tested independently
and mocked in integration tests.  The GitHub wrappers are grouped
with note management and duration parsing because all three
support the headless-agent workflow.

\paragraph{GitHub CLI wrappers}
Why wrap every [[gh]] call in its own function rather than calling
[[subprocess.run]] directly? Because the wrappers handle error
checking and JSON parsing uniformly, and they make the commands
testable---tests can mock the wrapper functions rather than
inspecting [[subprocess]] call arguments. Errors produce
actionable messages (install [[gh]], run [[gh auth login]]).

<<helpers>>=
def gh_available():
    """Check if gh CLI is installed and authenticated.

    Returns True if ``gh auth status`` exits successfully.
    """
    try:
        result = subprocess.run(
            ["gh", "auth", "status"],
            capture_output=True,
            text=True,
        )
        return result.returncode == 0
    except FileNotFoundError:
        return False
@

Before running any GitHub command we check availability and give
a helpful error message pointing users to the right fix:

<<helpers>>=
def require_gh():
    """Ensure gh CLI is available, exit with help if not."""
    if not gh_available():
        typer.echo(
            "Error: gh CLI not available. "
            "Install with: sudo apt install gh "
            "or brew install gh\n"
            "Then authenticate with: gh auth login",
            err=True,
        )
        raise typer.Exit(1)
@

Fetching a single issue or pull request returns the full JSON
object including comments, which we use for the [[view]] command:

<<helpers>>=
def gh_fetch_issue(repo, number):
    """Fetch a GitHub issue with comments.

    Returns parsed JSON with title, body, state,
    comments, and labels.
    """
    result = subprocess.run(
        [
            "gh", "issue", "view",
            str(number), "-R", repo,
            "--json",
            "title,body,state,comments,labels",
        ],
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        raise RuntimeError(
            f"gh issue view failed: "
            f"{result.stderr.strip()}"
        )
    return json.loads(result.stdout)
@

The PR variant is structurally identical but calls
[[gh pr view]] instead of [[gh issue view]]:

<<helpers>>=
def gh_fetch_pr(repo, number):
    """Fetch a GitHub pull request with comments.

    Returns parsed JSON with title, body, state,
    comments, and labels.
    """
    result = subprocess.run(
        [
            "gh", "pr", "view",
            str(number), "-R", repo,
            "--json",
            "title,body,state,comments,labels",
        ],
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        raise RuntimeError(
            f"gh pr view failed: "
            f"{result.stderr.strip()}"
        )
    return json.loads(result.stdout)
@

Closing requires separate functions for issues and PRs because
[[gh]] uses distinct subcommands ([[gh issue close]] vs
[[gh pr close]])---unlike commenting, which uses a shared
endpoint:

<<helpers>>=
def gh_close_issue(repo, number):
    """Close a GitHub issue."""
    result = subprocess.run(
        [
            "gh", "issue", "close",
            str(number), "-R", repo,
        ],
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        raise RuntimeError(
            f"gh issue close failed: "
            f"{result.stderr.strip()}"
        )
@

The PR variant uses [[gh pr close]] in place of
[[gh issue close]]:

<<helpers>>=
def gh_close_pr(repo, number):
    """Close a GitHub pull request."""
    result = subprocess.run(
        [
            "gh", "pr", "close",
            str(number), "-R", repo,
        ],
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        raise RuntimeError(
            f"gh pr close failed: "
            f"{result.stderr.strip()}"
        )
@

Listing issues and PRs returns summaries for bulk import. We
include [[createdAt]] because the age-based priority heuristic
in the [[import]] command needs timestamps to rank items:

<<helpers>>=
def gh_list_issues(repo):
    """List open issues from a GitHub repository.

    Returns a list of dicts with number, title, body,
    state, labels, and createdAt fields.
    """
    result = subprocess.run(
        [
            "gh", "issue", "list",
            "-R", repo,
            "--json",
            "number,title,body,state,"
            "labels,createdAt",
            "--limit", "100",
        ],
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        raise RuntimeError(
            f"gh issue list failed: "
            f"{result.stderr.strip()}"
        )
    return json.loads(result.stdout)
@

The PR variant uses [[gh pr list]]; the returned JSON fields
are identical, so callers need not distinguish the source:

<<helpers>>=
def gh_list_prs(repo):
    """List open pull requests from a repository.

    Returns a list of dicts with number, title, body,
    state, labels, and createdAt fields.
    """
    result = subprocess.run(
        [
            "gh", "pr", "list",
            "-R", repo,
            "--json",
            "number,title,body,state,"
            "labels,createdAt",
            "--limit", "100",
        ],
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        raise RuntimeError(
            f"gh pr list failed: "
            f"{result.stderr.strip()}"
        )
    return json.loads(result.stdout)
@

Adding a comment works identically for issues and PRs because
GitHub's API treats them as the same comment endpoint. We use
[[gh issue comment]] which handles both:

<<helpers>>=
def gh_add_comment(repo, number, body):
    """Add a comment to a GitHub issue or PR.

    Works for both issues and pull requests since
    GitHub uses a shared comment endpoint.
    """
    result = subprocess.run(
        [
            "gh", "issue", "comment",
            str(number), "-R", repo,
            "--body", body,
        ],
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        raise RuntimeError(
            f"gh comment failed: "
            f"{result.stderr.strip()}"
        )
@

\paragraph{Appending notes to items}
Several places need to add a note to a todo item---the [[note]]
command, headless output capture, and timeout handling.  Rather
than duplicating the GitHub-vs-local branching logic, we extract
a shared helper.  For GitHub-linked items we post a comment
\emph{and} store locally (belt-and-suspenders), because in
headless mode there is no human to notice a failed API call.

<<helpers>>=
def append_note_to_item(item, note_text, next_id, todos):
    """Append a note to a todo item.

    Posts to GitHub if linked, always stores locally.
    """
    if item.github:
        try:
            gh_add_comment(
                item.github["repo"],
                item.github["number"],
                note_text,
            )
        except Exception:
            logging.warning(
                "Failed to post GitHub comment "
                f"for #{item.id}"
            )
    if item.notes:
        item.notes += "\n" + note_text
    else:
        item.notes = note_text
    save_todos(next_id, todos)
@

\paragraph{Parsing timeout durations}
The [[--timeout]] option (see \cref{sec:headless-lifecycle})
accepts GNU [[timeout]]-style duration strings: a bare number
means seconds, or append [[s]], [[m]], [[h]], [[d]] for units.
This matches the syntax users already know from
[[timeout 50m cmd]], reducing cognitive overhead.

<<helpers>>=
def parse_timeout(duration_str):
    """Parse a GNU timeout-style duration to seconds.

    Accepts bare numbers (seconds) or suffixed strings:
    ``s`` (seconds), ``m`` (minutes), ``h`` (hours),
    ``d`` (days).  Fractional values are allowed,
    e.g. ``1.5h`` for 90 minutes.
    """
    suffixes = {
        "s": 1, "m": 60,
        "h": 3600, "d": 86400,
    }
    if duration_str[-1] in suffixes:
        return float(duration_str[:-1]) \
            * suffixes[duration_str[-1]]
    return float(duration_str)
@

Let's verify the timeout parser handles the common formats:

<<test functions>>=
def test_parse_timeout_seconds():
    """Bare number treated as seconds."""
    assert parse_timeout("30") == 30.0


def test_parse_timeout_minutes():
    """Suffix m means minutes."""
    assert parse_timeout("50m") == 3000.0


def test_parse_timeout_hours():
    """Suffix h means hours."""
    assert parse_timeout("1.5h") == 5400.0


def test_parse_timeout_days():
    """Suffix d means days."""
    assert parse_timeout("1d") == 86400.0
@

\subsection{Testing GitHub Helpers}

We test the helper functions using mocked [[subprocess.run]]
calls. This verifies our argument construction and JSON parsing
without requiring a live [[gh]] installation or network access.

<<test functions>>=
def test_gh_available_when_installed():
    """gh_available returns True when gh auth succeeds."""
    mock_result = MagicMock()
    mock_result.returncode = 0
    with patch(
        "nytid.cli.todo.subprocess.run",
        return_value=mock_result,
    ) as mock_run:
        assert gh_available() is True
        mock_run.assert_called_once()
        args = mock_run.call_args[0][0]
        assert args == ["gh", "auth", "status"]


def test_gh_available_when_not_installed():
    """gh_available returns False on FileNotFoundError."""
    with patch(
        "nytid.cli.todo.subprocess.run",
        side_effect=FileNotFoundError,
    ):
        assert gh_available() is False


def test_gh_fetch_issue():
    """gh_fetch_issue parses JSON response correctly."""
    mock_result = MagicMock()
    mock_result.returncode = 0
    mock_result.stdout = json.dumps({
        "title": "Bug report",
        "body": "Something is broken",
        "state": "OPEN",
        "comments": [],
        "labels": [{"name": "bug"}],
    })
    with patch(
        "nytid.cli.todo.subprocess.run",
        return_value=mock_result,
    ):
        data = gh_fetch_issue("owner/repo", 42)
        assert data["title"] == "Bug report"
        assert data["state"] == "OPEN"


def test_gh_list_issues():
    """gh_list_issues returns list of issue dicts."""
    mock_result = MagicMock()
    mock_result.returncode = 0
    mock_result.stdout = json.dumps([
        {
            "number": 1,
            "title": "First issue",
            "createdAt": "2026-01-01T00:00:00Z",
        },
        {
            "number": 2,
            "title": "Second issue",
            "createdAt": "2026-02-01T00:00:00Z",
        },
    ])
    with patch(
        "nytid.cli.todo.subprocess.run",
        return_value=mock_result,
    ):
        items = gh_list_issues("owner/repo")
        assert len(items) == 2
        assert items[0]["number"] == 1


def test_gh_add_comment():
    """gh_add_comment calls gh with correct args."""
    mock_result = MagicMock()
    mock_result.returncode = 0
    with patch(
        "nytid.cli.todo.subprocess.run",
        return_value=mock_result,
    ) as mock_run:
        gh_add_comment("owner/repo", 42, "Hello")
        args = mock_run.call_args[0][0]
        assert "comment" in args
        assert "42" in args
        assert "--body" in args
@

\section{Subcommands}

We organize commands by workflow: adding and listing items, completing
and removing them, editing and reprioritizing, tracking time, and
exporting to calendars.

<<subcommands>>=
<<item management commands>>
<<status change commands>>
<<tracking commands>>
<<export commands>>
<<github commands>>
@

<<item management commands>>=
<<add command>>
<<list command>>
<<view command>>
<<edit command>>
<<reprioritize command>>
@

<<status change commands>>=
<<done command>>
<<remove command>>
<<next command>>
@

<<tracking commands>>=
<<start command>>
<<stop command>>
@

<<export commands>>=
<<export command>>
<<vtodo export function>>
@

\subsection{The [[add]] Command}

Adding a todo involves three steps: determining parentage (auto, explicit,
or top-level), running the binary-search priority insertion among siblings,
and persisting the result.

Auto-parenting is a convenience feature: if you're currently tracking a
todo via [[todo start]], new todos are automatically added as children
of that active todo. This supports a natural workflow where working on
a task reveals sub-tasks.

<<add command>>=
@cli.command()
def add(
    label: Optional[List[str]] = typer.Argument(
        None, help="Labels",
        autocompletion=complete_labels,
    ),
    <<add command options>>
):
    """Add a new todo item.

    Priority is determined by interactive binary search
    comparison with existing items at the same level.

    \b
    Examples:
      nytid todo add tilkry26 prep -t "Grade labs" -e 3
      nytid todo add -t "Review section 2" -p 1
    """
    <<add command implementation>>
@

Labels are the primary noun of every todo command---you type them most
often and they naturally group related work. Making them positional
eliminates repetitive [[-l]] flags (compare
[[todo add tilkry26 prep -t "Grade labs"]] with the old
[[todo add "Grade labs" -l tilkry26 -l prep]]). The title moves to
[[--title]]/[[-t]] since it is usually a single quoted string either
way:

<<add command options>>=
title: Optional[str] = typer.Option(
    None, "--title", "-t",
    help="Title (optional with --edit)",
),
@

Deadlines and estimates help with scheduling and priority boosting:

<<add command options>>=
deadline: Optional[str] = typer.Option(
    None, "--deadline", "-d",
    help="Deadline (ISO datetime or YYYY-MM-DD)",
),
estimate: Optional[float] = typer.Option(
    None, "--estimate", "-e",
    help="Estimated hours",
),
description: Optional[str] = typer.Option(
    "", "--description",
    help="Longer description",
),
@

Like the UNIX [[at]] command, we can capture the current working
directory and environment at creation time. The [[--here]] flag is a
convenient shorthand that captures both [[os.getcwd()]] and
[[os.environ]], while [[--wd]] allows specifying an explicit
directory. When either is used, the environment is also captured so
that [[todo start]] can restore the full context later. The [[--run]]
option sets a default command (such as [[claude]] or [[opencode]]) to
launch when starting the todo.

<<add command options>>=
wd_path: Optional[str] = typer.Option(
    None, "--wd", "-C",
    help="Working directory to capture",
),
here: bool = typer.Option(
    False, "--here",
    help="Capture current directory and environment",
),
run_command: Optional[str] = typer.Option(
    None, "--run", "-r",
    help="Default command to run on start",
),
@

The [[--edit]] flag opens an interactive editor where the user can
write a full markdown document with YAML front matter and headings.
This is the most powerful way to create todos: one editor session can
define a parent with metadata, notes, and an arbitrary hierarchy of
sub-items.

<<add command options>>=
edit_mode: bool = typer.Option(
    False, "--edit", "-E",
    help="Open editor for rich input",
),
@

We default [[--who]] to the logged-in user's username, so that todos
are scoped to the current user unless overridden.  This matches the
convention in [[schedule show --user]], where an empty string means
\enquote{all workers.}%
\label{todo:default-username}

<<constants>>=
try:
    default_username = os.environ["USER"]
except KeyError:
    default_username = None
@

Parent-related options control hierarchy placement:

<<add command options>>=
parent: Optional[int] = typer.Option(
    None, "--parent", "-p",
    help="Parent todo ID",
    autocompletion=complete_todo_ids,
),
top_level: bool = typer.Option(
    False, "--top-level",
    help="Add at root level (override auto-parenting)",
),
who: Optional[str] = typer.Option(
    default_username, "--who", "-w",
    help="Assign to worker (default: $USER)",
    autocompletion=complete_todo_workers,
),
@

Now the implementation. We first resolve the parent ID, then run the
binary search among siblings, and finally save.

<<add command implementation>>=
next_id, todos = load_todos()

if edit_mode:
    <<add via editor>>
else:
    <<add via cli flags>>
@

The CLI-flag path is the original flow: resolve parent, run binary
search, create a single item.

<<add via cli flags>>=
if not title:
    typer.echo(
        "Error: --title is required "
        "(or use --edit)",
        err=True,
    )
    raise typer.Exit(1)
<<resolve parent id>>
<<inherit from parent>>
<<run binary search for priority>>
<<create and save new todo>>
@

Auto-parenting: if no explicit parent is given and we're not forcing
top-level, check for a currently tracked (in-progress) todo:

<<resolve parent id>>=
resolved_parent = parent
if parent is None and not top_level:
    active = get_currently_tracked_todo(todos)
    if active is not None:
        resolved_parent = active.id
        typer.echo(
            f"Auto-parenting under: "
            f"#{active.id} {active.title}"
        )
@

When a parent is set, inherit its deadline and labels unless the user
explicitly provided overrides:

<<inherit from parent>>=
inherited_labels = list(label) if label else []
inherited_deadline = deadline
if resolved_parent is not None:
    parent_item = next(
        (t for t in todos if t.id == resolved_parent),
        None,
    )
    if parent_item is None:
        typer.echo(
            f"Error: parent #{resolved_parent} not found",
            err=True,
        )
        raise typer.Exit(1)
    if not label and parent_item.labels:
        inherited_labels = list(parent_item.labels)
    if deadline is None and parent_item.deadline:
        inherited_deadline = parent_item.deadline
@

<<run binary search for priority>>=
# Build a temporary item to find siblings
temp = TodoItem(
    id=0, title=title, parent_id=resolved_parent
)
siblings = get_active_siblings(todos, temp)
parent_ctx = (
    parent_item if resolved_parent is not None
    else None
)
priority = binary_search_priority(
    siblings, title,
    parent=parent_ctx, todos=todos,
)
@

When [[--here]] is used, we capture the current working directory and
the full environment---just like UNIX [[at]]. When [[--wd]] is given
explicitly, we also capture the environment since the user intends to
restore context later. The two flags are mutually exclusive in
practice, but [[--here]] takes precedence.

<<resolve wd and env for new todo>>=
resolved_wd = None
resolved_env = None
if here:
    resolved_wd = os.getcwd()
    resolved_env = dict(os.environ)
elif wd_path:
    resolved_wd = os.path.abspath(wd_path)
    resolved_env = dict(os.environ)
@

<<create and save new todo>>=
now = datetime.datetime.now().isoformat()
<<resolve wd and env for new todo>>
item = TodoItem(
    id=next_id,
    title=title,
    labels=inherited_labels,
    priority=priority,
    status="pending",
    created=now,
    deadline=inherited_deadline,
    estimated=estimate,
    description=description or "",
    parent_id=resolved_parent,
    who=who,
    wd=resolved_wd,
    env=resolved_env,
    command=run_command,
)
todos.append(item)
save_todos(next_id + 1, todos)

typer.echo(f"Added todo #{item.id}: {item.title}")
if inherited_labels:
    typer.echo(f"  Labels: {', '.join(inherited_labels)}")
if inherited_deadline:
    typer.echo(f"  Deadline: {inherited_deadline}")
if estimate:
    typer.echo(f"  Estimate: {estimate}h")
if who:
    typer.echo(f"  Assigned: {who}")
if resolved_wd:
    typer.echo(f"  WD: {resolved_wd}")
if run_command:
    typer.echo(f"  Command: {run_command}")
@

\subsubsection{Adding via Editor}

When [[--edit]] is used, the editor opens with the title pre-filled
(if given on the command line). Any CLI flags such as [[--deadline]],
[[--estimate]], and [[--wd]], along with positional labels, are
rendered as a YAML
front\-matter block so the user can review and adjust them before
saving. Without this pre-population the flags would still take effect
(they are merged in [[<<create parent from parsed>>]]), but the user
would never see them in the editor---a confusing silent override.

<<add via editor>>=
<<build initial editor metadata>>
initial = title if title else ""
yaml_header = render_yaml_block(initial_meta)
if yaml_header:
    initial = initial + "\n" + yaml_header
content = open_editor(initial + "\n")
parsed = parse_editor_content(content)
if not parsed.title:
    typer.echo("No title provided.", err=True)
    raise typer.Exit(1)

<<resolve parent id>>
<<inherit from parent>>
<<run binary search for priority>>
<<create parent from parsed>>
<<create children from parsed>>
save_todos(next_id, todos)
@

We build a metadata dictionary from CLI flags so the editor opens
with a pre-populated YAML block. The key names match those used by
[[parse_yaml_block]] and [[build_item_metadata]], ensuring
round-trip consistency. We omit keys whose values are empty to keep
the editor uncluttered.

We always emit [[who]] even when it matches [[default_username]].
This serves two purposes: it makes the assignment explicit so the
user can see who will own the task, and --- crucially --- it
reminds the user of the exact attribute name when they want to
assign a sub-item to someone else.  Without a visible [[who:]]
key in the parent block, a user adding children has no obvious
cue that children can carry their own [[who]] field.

<<build initial editor metadata>>=
initial_meta = {}
if label:
    initial_meta["labels"] = list(label)
if deadline:
    initial_meta["deadline"] = deadline
if estimate:
    initial_meta["estimate"] = estimate
if who:
    initial_meta["who"] = who
if wd_path:
    initial_meta["wd"] = os.path.abspath(wd_path)
elif here:
    initial_meta["wd"] = os.getcwd()
if run_command:
    initial_meta["command"] = run_command
@

We apply metadata from the YAML front matter to the parent item,
with CLI flags taking precedence where provided:

<<create parent from parsed>>=
now = datetime.datetime.now().isoformat()
meta = parsed.metadata
resolved_wd = meta.get("wd") or (
    os.getcwd() if here else None
)
resolved_env = (
    dict(os.environ)
    if resolved_wd or here
    else None
)
if wd_path:
    resolved_wd = os.path.abspath(wd_path)
    resolved_env = dict(os.environ)
parent_item = TodoItem(
    id=next_id,
    title=parsed.title,
    labels=(
        list(label) if label
        else meta.get("labels", [])
    ),
    priority=priority,
    status="pending",
    created=now,
    deadline=deadline or meta.get("deadline"),
    estimated=(
        estimate
        or meta.get("estimate")
    ),
    description=description or "",
    parent_id=resolved_parent,
    who=who or meta.get("who"),
    notes=parsed.notes,
    wd=resolved_wd,
    env=resolved_env,
    command=(
        run_command or meta.get("command")
    ),
)
todos.append(parent_item)
parent_id_for_children = next_id
next_id += 1
typer.echo(
    f"Added todo #{parent_item.id}: "
    f"{parent_item.title}"
)
@

Children are created recursively. Each child inherits the parent's
labels, deadline, and assignee unless its own YAML block overrides
them.  We pass [[parent_who]] explicitly rather than reading
[[default_username]] again inside the function: the caller already
knows the resolved assignee (which may be a custom [[--who]] value),
so passing it through avoids a second lookup and keeps the function
independent of global state.  Priorities are distributed within the
range computed by [[compute_child_priority_range]] so that children
sort between their parent and the parent's next-lower sibling in
flat view:

<<create children from parsed>>=
def create_children_from_parsed(
    parsed_children, parent_id,
    parent_labels, parent_deadline,
    priority_high, priority_low,
    parent_who=None,
):
    """Recursively create TodoItems from parsed
    children.

    Priorities are distributed evenly in the range
    (priority_low, priority_high).  Each child
    inherits ``parent_who`` as its assignee unless
    the child's own YAML block specifies a
    ``who`` key.
    """
    nonlocal next_id, todos
    priorities = distribute_priorities_in_range(
        len(parsed_children),
        priority_high, priority_low,
    )
    for i, parsed_child in enumerate(
        parsed_children
    ):
        prio = priorities[i]
        <<resolve child metadata>>
        <<create and append child item>>
        if parsed_child.children:
            <<nested child priority range>>
            create_children_from_parsed(
                parsed_child.children,
                child_id,
                child_labels,
                child_deadline,
                nested_high, nested_low,
                child_who,
            )
@

Each child overrides its metadata (labels, deadline, working
directory, assignee) from the YAML block in the editor, falling
back to the parent's values when not specified.  The [[who]]
inheritance is especially important: without it, children typed
in the editor without an explicit [[who:]] key would be created
unassigned, even though the user intended the whole task tree to
belong to the same person.  We also capture [[os.environ]]
at creation time when the child has an explicit working directory,
so [[todo start]] can restore the environment where it makes sense.
<<resolve child metadata>>=
child_meta = parsed_child.metadata
child_labels = child_meta.get(
    "labels", list(parent_labels)
)
child_deadline = child_meta.get(
    "deadline", parent_deadline
)
child_who = child_meta.get("who", parent_who)
child_wd = child_meta.get("wd")
child_env = (
    dict(os.environ) if child_wd else None
)
@

We append the new item to [[todos]] immediately so that recursive
calls can see it as a parent when processing grandchildren.
<<create and append child item>>=
child = TodoItem(
    id=next_id,
    title=parsed_child.title,
    labels=child_labels,
    priority=prio,
    status="pending",
    created=now,
    deadline=child_deadline,
    estimated=child_meta.get("estimate"),
    parent_id=parent_id,
    who=child_who,
    notes=parsed_child.notes,
    wd=child_wd,
    env=child_env,
    command=child_meta.get("command"),
)
todos.append(child)
child_id = next_id
next_id += 1
typer.echo(
    f"  Added sub-item #{child.id}: "
    f"{child.title}"
)
@

For nested children (grandchildren), the sub-range falls between the
current child's priority and the next child's priority. If this is the
last child, we use the range's lower bound instead:

<<nested child priority range>>=
nested_high = prio
if i + 1 < len(priorities):
    nested_low = priorities[i + 1]
else:
    nested_low = priority_low
@

The call site computes the parent's priority range from its sibling
context and passes the parent's [[who]] so that any child lacking
an explicit [[who:]] in its YAML block inherits the same assignee:

<<create children from parsed>>=
prio_high, prio_low = compute_child_priority_range(
    todos, parent_item
)
create_children_from_parsed(
    parsed.children,
    parent_id_for_children,
    parent_item.labels,
    parent_item.deadline,
    prio_high, prio_low,
    parent_item.who,
)
@

\subsection{The [[ls]] Command}

The list command shows todos sorted by effective priority. By default
it shows only top-level items (those without a parent), keeping the
list scannable without drowning the user in sub-tasks. The [[--all]]
option enables the full tree view: top-level items with their children
indented below. The [[--flat]] option shows a flat priority-sorted
list instead of the indented tree.

We query tracked time by looking for entries with [[todo:<id>]] labels
in the track data, connecting the todo system to actual time spent.
Label filters are positional arguments, matching the [[add]] command's
convention ([[todo ls tilkry26 prep]] filters to items with either
label).

<<list command>>=
@cli.command(name="ls")
def ls(
    label_filter: Optional[List[str]] = typer.Argument(
        None, help="Filter by label",
        autocompletion=complete_labels,
    ),
    include_done: bool = typer.Option(
        False, "--done", "-d",
        help="Include done items",
    ),
    show_tree: bool = typer.Option(
        False, "--all", "-a",
        help="Show full tree including subtasks",
    ),
    count: Optional[int] = typer.Option(
        10, "-n",
        help="Limit output to top N items",
    ),
    who_filter: Optional[str] = typer.Option(
        default_username, "--who", "-w",
        help="Filter by assignee regex "
             "(default: $USER, use '' for all)",
        autocompletion=complete_todo_workers,
    ),
    flat: bool = typer.Option(
        False, "--flat",
        help="Flat list (no tree); useful with --all",
    ),
    output_format: str = typer.Option(
        "table", "--format", "-f",
        help=(
            "Output format: table (auto-detects TTY),"
            " rich, csv, json"
        ),
    ),
    status_filter: Optional[str] = typer.Option(
        None, "--status", "-s",
        help="Filter by status: "
             "pending, in-progress, done",
    ),
    show_prio: bool = typer.Option(
        False, "--prio", "-p",
        help="Show effective priority column",
    ),
    output: Optional[str] = typer.Option(
        None, "--output", "-o",
        help="Write CSV output to file",
    ),
):
    """List todo items sorted by effective priority.

    By default shows only top-level items assigned to the current
    user. Pass [[--all]] to see the full tree including subtasks,
    or [[-n N]] to cap the output at N items.

    [[--who]] defaults to [[\\$USER]]; pass an empty string
    ([[--who '']]) to show all assignees.

    Output format defaults to [[table]], which auto-detects the
    terminal: rich-formatted when stdout is a TTY, plain CSV
    otherwise (suitable for piping).
    """
    _, todos = load_todos()

    <<filter todos>>
    <<query tracked time>>
    <<restrict to top-level when not [[show_tree]]>>

    <<determine effective format>>

    if effective_format == "json":
        <<output json>>
    elif effective_format == "rich":
        <<output rich table>>
    else:
        <<output csv>>
@

The [[--status]] option enables exact status filtering, which the
agent workflow needs to query only pending tasks without fragile
[[awk]] filtering in shell scripts.  When [[--status]] is given, it
takes precedence over [[--done]] since the user has expressed a
more specific intent.

<<filter todos>>=
filtered = todos
if status_filter:
    filtered = [
        t for t in filtered
        if t.status == status_filter
    ]
elif not include_done:
    filtered = [
        t for t in filtered if t.status != "done"
    ]
if label_filter:
    filtered = [
        t for t in filtered
        if any(l in t.labels for l in label_filter)
    ]
if who_filter:
    filtered = [
        t for t in filtered
        if t.who and re.search(who_filter, t.who)
    ]
@

Let's verify that the [[--status]] filter correctly isolates items
by their current state:

<<test functions>>=
def test_ls_status_filter(temp_todo_dir):
    """The --status flag filters to exact status."""
    save_todos(4, [
        TodoItem(
            id=1, title="Pending task",
            created="2026-02-20T10:00:00",
            status="pending", who="me",
        ),
        TodoItem(
            id=2, title="Active task",
            created="2026-02-20T10:00:00",
            status="in-progress", who="me",
        ),
        TodoItem(
            id=3, title="Done task",
            created="2026-02-20T10:00:00",
            status="done", who="me",
        ),
    ])
    result = runner.invoke(
        cli, ["ls", "--status", "pending",
              "--who", ""]
    )
    assert "Pending task" in result.output
    assert "Active task" not in result.output
    assert "Done task" not in result.output

    result = runner.invoke(
        cli, ["ls", "--status", "in-progress",
              "--who", ""]
    )
    assert "Active task" in result.output
    assert "Pending task" not in result.output
@

To show tracked time, we load track data and sum durations for
entries matching [[todo:<id>]] labels. This avoids duplicating the
track system's storage logic.

<<query tracked time>>=
tracked_times: Dict[int, float] = {}
try:
    from nytid.cli.track import load_tracking_data

    entries = load_tracking_data()
    for entry in entries:
        for lbl in entry.labels:
            if lbl.startswith("todo:"):
                try:
                    tid = int(lbl.split(":")[1])
                    hours = (
                        entry.duration().total_seconds()
                        / 3600
                    )
                    tracked_times[tid] = (
                        tracked_times.get(tid, 0) + hours
                    )
                except (ValueError, IndexError):
                    pass
except ImportError:
    pass
@

Filters such as [[--who]] narrow [[filtered]] to a subset without
regard to hierarchy.  A subitem assigned to one person may have a
parent assigned to someone else; after filtering by [[--who]], the
parent is gone but the child remains.  Treating that child as
invisible because its [[parent_id is not None]] would silently hide
an item the filtered user is responsible for.

The right behaviour is to treat any item as a root when its parent
is absent from the filtered set---either because it is a true
top-level item, or because its parent was removed by a filter.  We
factor this into a helper so that both the non-tree restriction step
and the tree renderer share exactly the same logic, with no
duplication:

<<sub-item helpers>>=
def get_visible_roots(
    items: List[TodoItem],
) -> List[TodoItem]:
    """Return items whose parent is absent from items.

    An item is a visible root when it is a true top-level item
    (parent_id is None) or when its parent is not present in
    items---e.g.\ because a --who filter removed it.  Pass a
    pre-filtered list, not the full todos.
    """
    ids = {t.id for t in items}
    return [
        t for t in items
        if t.parent_id is None
        or t.parent_id not in ids
    ]
@

When [[--all]] is not given, we restrict [[filtered]] to its
visible roots.  This keeps the list scannable by default: subitems
of a visible parent are subordinate detail the user can reveal on
demand with [[--all]].  Applying the restriction before the
format-selection branch keeps JSON, CSV, and rich table output
consistent.

<<restrict to top-level when not [[show_tree]]>>=
if not show_tree:
    filtered = get_visible_roots(filtered)
@

When [[--all]] is given the tree renderer already handles building
the top-level list itself, so we leave [[filtered]] untouched.

Let's verify the three new behaviours: the default top-level-only
view, the [[--all]] tree expansion, and the [[-n]] limit.

<<test functions>>=
def test_ls_default_hides_children(temp_todo_dir):
    """Default ls shows only top-level items."""
    save_todos(3, [
        TodoItem(
            id=1, title="Parent task",
            created="2026-02-20T10:00:00",
            status="pending", who="me",
        ),
        TodoItem(
            id=2, title="Child task",
            created="2026-02-20T10:00:00",
            status="pending", who="me",
            parent_id=1,
        ),
    ])
    result = runner.invoke(
        cli, ["ls", "--who", ""]
    )
    assert "Parent task" in result.output
    assert "Child task" not in result.output


def test_ls_all_shows_children(temp_todo_dir):
    """The --all flag reveals the full tree."""
    save_todos(3, [
        TodoItem(
            id=1, title="Parent task",
            created="2026-02-20T10:00:00",
            status="pending", who="me",
        ),
        TodoItem(
            id=2, title="Child task",
            created="2026-02-20T10:00:00",
            status="pending", who="me",
            parent_id=1,
        ),
    ])
    result = runner.invoke(
        cli, ["ls", "--all", "--who", ""]
    )
    assert "Parent task" in result.output
    assert "Child task" in result.output


def test_ls_who_promotes_orphaned_child(temp_todo_dir):
    """A child assigned to the filtered user appears as a root
    when its parent is assigned to someone else."""
    save_todos(3, [
        TodoItem(
            id=1, title="Parent task",
            created="2026-02-20T10:00:00",
            status="pending", who="me",
        ),
        TodoItem(
            id=2, title="Child task",
            created="2026-02-20T10:00:00",
            status="pending", who="dan-claude",
            parent_id=1,
        ),
    ])
    result = runner.invoke(
        cli, ["ls", "--who", "dan-claude"]
    )
    assert "Child task" in result.output


def test_ls_all_who_promotes_orphaned_child(temp_todo_dir):
    """With --all, a child whose parent is filtered out still
    appears---promoted to root position, not indented."""
    save_todos(3, [
        TodoItem(
            id=1, title="Parent task",
            created="2026-02-20T10:00:00",
            status="pending", who="me",
        ),
        TodoItem(
            id=2, title="Child task",
            created="2026-02-20T10:00:00",
            status="pending", who="dan-claude",
            parent_id=1,
        ),
    ])
    result = runner.invoke(
        cli, ["ls", "--all", "--who", "dan-claude"]
    )
    assert "Child task" in result.output
    assert "\u2514" not in result.output


def test_ls_n_limits_output(temp_todo_dir):
    """The -n flag caps the number of items shown."""
    save_todos(4, [
        TodoItem(
            id=1, title="High prio",
            created="2026-02-20T10:00:00",
            status="pending", who="me",
            priority=10.0,
        ),
        TodoItem(
            id=2, title="Medium prio",
            created="2026-02-20T10:00:00",
            status="pending", who="me",
            priority=5.0,
        ),
        TodoItem(
            id=3, title="Low prio",
            created="2026-02-20T10:00:00",
            status="pending", who="me",
            priority=1.0,
        ),
    ])
    result = runner.invoke(
        cli, ["ls", "-n", "2", "--who", ""]
    )
    assert "High prio" in result.output
    assert "Medium prio" in result.output
    assert "Low prio" not in result.output


def test_ls_done_flag_includes_done(temp_todo_dir):
    """The --done flag includes completed items."""
    save_todos(3, [
        TodoItem(
            id=1, title="Active task",
            created="2026-02-20T10:00:00",
            status="pending", who="me",
        ),
        TodoItem(
            id=2, title="Finished task",
            created="2026-02-20T10:00:00",
            status="done", who="me",
        ),
    ])
    result = runner.invoke(
        cli, ["ls", "--who", ""]
    )
    assert "Finished task" not in result.output

    result = runner.invoke(
        cli, ["ls", "--done", "--who", ""]
    )
    assert "Active task" in result.output
    assert "Finished task" in result.output
@
When [[--format]] is [[table]] (the default) we auto-detect the
output context: a rich table when writing to a terminal, and
tab-separated CSV otherwise. This lets shell pipelines and
redirections receive machine-readable data without an explicit
flag, while interactive use stays human-readable. Forcing
[[--format rich]] or [[--format csv]] overrides the detection.
Specifying [[--output]] is treated as a non-interactive destination
and also selects CSV in auto-detect mode.

<<determine effective format>>=
if output_format == "json":
    effective_format = "json"
elif output_format == "rich":
    effective_format = "rich"
elif output_format == "csv":
    effective_format = "csv"
else:
    effective_format = (
        "csv"
        if (output or not sys.stdout.isatty())
        else "rich"
    )
@

JSON output sorts by effective priority and respects the [[-n]] limit
just like the rich and CSV paths.  We sort here because JSON consumers
(scripts, agents) should receive items in the same priority order the
user would see interactively, and slicing with [[[:count]]] is a
no-op when [[count]] is [[None]].
Scripts that need the numeric score can request it with [[--prio]];
omitting it by default keeps the JSON payload small.

<<output json>>=
import json as json_mod

now = datetime.datetime.now()
sorted_filtered = sorted(
    filtered,
    key=lambda t: effective_priority(t, now),
    reverse=True,
)[:count]
output = []
for t in sorted_filtered:
    d = t.to_dict()
    if show_prio:
        d["effective_priority"] = effective_priority(t)
    d["tracked_hours"] = tracked_times.get(t.id, 0)
    output.append(d)
print(json_mod.dumps(output, indent=2))
@

For rich table output we use [[rich.table.Table]] so the
terminal gets nicely aligned, coloured columns. The tree layout
is preserved: children are indented with a \enquote{└─} prefix
in the Title column, and a dimmed progress summary follows each
parent that has children. We use a single [[Console]] to respect
the terminal width automatically.

Effective priority is the primary sort key but is not surfaced
by default in any output format. The numeric score is an
internal detail: in table output it clutters the view, and in
CSV/JSON it adds a field that most scripts do not need.
Passing [[--prio]] opts in across all three formats, which is
useful when debugging why an item ranked unexpectedly or when
writing a script that needs a comparable score.

<<output rich table>>=
from rich.console import Console
from rich import box
from rich.table import Table
from rich.text import Text

if not filtered:
    typer.echo("No todos found.")
    return

now = datetime.datetime.now()
console = Console()
table = Table(show_header=True, header_style="bold",
              box=box.SIMPLE, show_edge=False,
              pad_edge=False)
<<add rich table columns>>

if flat:
    <<populate flat rows into rich table>>
else:
    <<populate tree rows into rich table>>

console.print(table)
@

<<add rich table columns>>=
table.add_column("ID", justify="right", style="cyan")
if show_prio:
    table.add_column("Pri", justify="right")
table.add_column("Title")
table.add_column("Who")
table.add_column("Deadline")
table.add_column("Est", justify="right")
table.add_column("Tracked", justify="right")
table.add_column("Status")
@

<<populate flat rows into rich table>>=
sorted_items = sorted(
    filtered,
    key=lambda t: effective_priority(t, now),
    reverse=True,
)[:count]
for item in sorted_items:
    <<add item row to rich table>>
@

<<populate tree rows into rich table>>=
top_level = get_visible_roots(filtered)
top_level.sort(
    key=lambda t: effective_priority(t, now),
    reverse=True,
)
top_level = top_level[:count]
for item in top_level:
    <<add item row to rich table>>
    children = sorted(
        [
            t for t in filtered
            if t.parent_id == item.id
        ],
        key=lambda t: effective_priority(t, now),
        reverse=True,
    )
    all_children = get_children(todos, item.id)
    if all_children:
        done_count = sum(
            1 for c in all_children
            if c.status == "done"
        )
        total = len(all_children)
        progress = Text(
            f"[{done_count}/{total} done]",
            style="dim",
        )
        table.add_row("", "", progress)
    for child in children:
        <<add child row to rich table>>
@

We share a small helper to format the deadline and estimate
strings consistently across the row-building chunks.

<<add item row to rich table>>=
ep = effective_priority(item, now)
dl_str = ""
if item.deadline:
    try:
        dl = datetime.datetime.fromisoformat(
            item.deadline
        )
        dl_str = dl.strftime("%b %d")
    except ValueError:
        dl_str = item.deadline[:10]
est_str = (
    f"{item.estimated:.1f}h"
    if item.estimated
    else ""
)
tracked = tracked_times.get(item.id, 0)
tracked_str = (
    f"{tracked:.1f}h" if tracked > 0 else ""
)
table.add_row(
    str(item.id),
    *([f"{ep:.1f}"] if show_prio else []),
    item.title,
    item.who or "",
    dl_str,
    est_str,
    tracked_str,
    item.status,
)
@

<<add child row to rich table>>=
ep = effective_priority(child, now)
dl_str = ""
if child.deadline:
    try:
        dl = datetime.datetime.fromisoformat(
            child.deadline
        )
        dl_str = dl.strftime("%b %d")
    except ValueError:
        dl_str = child.deadline[:10]
est_str = (
    f"{child.estimated:.1f}h"
    if child.estimated
    else ""
)
tracked = tracked_times.get(child.id, 0)
tracked_str = (
    f"{tracked:.1f}h" if tracked > 0 else ""
)
table.add_row(
    str(child.id),
    *([f"{ep:.1f}"] if show_prio else []),
    f"  \u2514\u2500 {child.title}",
    child.who or "",
    dl_str,
    est_str,
    tracked_str,
    child.status,
)
@

CSV output is intended for pipelines and scripts. We write
tab-separated values so that titles containing commas remain
unambiguous. A [[parent_id]] column preserves hierarchy
information that the tree view shows visually. Using
[[csv.writer]] handles quoting of any fields that happen to
contain tabs or newlines.

When writing to a file (via [[--output]]) we always include the
header row so the file is self-describing. When writing to a
terminal the header is shown for readability. When piping to
another process the header is omitted so that tools like [[awk]]
and [[cut]] receive only data rows.
Like the table and JSON paths, the priority field is omitted
by default and included when [[--prio]] is passed.

<<output csv>>=
if not filtered:
    return

now = datetime.datetime.now()
sorted_items = sorted(
    filtered,
    key=lambda t: effective_priority(t, now),
    reverse=True,
)[:count]
dest = open(output, "w", newline="") if output else sys.stdout
writer = csv.writer(dest, delimiter="\t")
if output or sys.stdout.isatty():
    writer.writerow([
        "id",
        *( ["priority"] if show_prio else [] ),
        "title", "who",
        "deadline", "estimated_h", "tracked_h",
        "status", "parent_id",
    ])
for item in sorted_items:
    ep = effective_priority(item, now)
    tracked = tracked_times.get(item.id, 0)
    writer.writerow([
        item.id,
        *( [f"{ep:.1f}"] if show_prio else [] ),
        item.title,
        item.who or "",
        item.deadline or "",
        (
            f"{item.estimated:.1f}"
            if item.estimated
            else ""
        ),
        f"{tracked:.1f}" if tracked > 0 else "",
        item.status,
        item.parent_id if item.parent_id else "",
    ])
if output:
    dest.close()
    typer.echo(f"Wrote todos to {output}")
@

\subsection{The [[view]] Command}

The [[view]] command displays a single todo with all its details and
children in a readable format. This is the read-only counterpart to
the editor---you can see exactly what [[--edit]] would show, without
opening an editor.

When no ID is given, [[view]] shows the task at the top of the
active stack---the one you're currently working on.  This is the
natural question a returning user asks: \enquote{What was I doing?}

<<view command>>=
@cli.command()
def view(
    todo_id: Optional[int] = typer.Argument(
        None,
        help="Todo ID to view (default: stack top)",
        autocompletion=complete_todo_ids,
    ),
):
    """View a todo item with full details.

    If no ID given, shows the currently active task
    (top of the active stack).
    """
    _, todos = load_todos()

    if todo_id is None:
        stack = load_active_stack()
        if not stack:
            typer.echo(
                "No active todo. "
                "Specify an ID to view.",
                err=True,
            )
            raise typer.Exit(1)
        todo_id = stack[-1]

    item = next(
        (t for t in todos if t.id == todo_id), None
    )
    if item is None:
        typer.echo(
            f"Error: todo #{todo_id} not found",
            err=True,
        )
        raise typer.Exit(1)

    <<display todo details>>
    <<display todo children>>
@

<<display todo details>>=
typer.echo(
    f"#{item.id} {item.title} [{item.status}]"
)
parts = []
if item.priority:
    parts.append(f"Priority: {item.priority:.1f}")
if item.deadline:
    parts.append(f"Deadline: {item.deadline}")
if item.estimated:
    parts.append(f"Est: {item.estimated:.1f}h")
if parts:
    typer.echo("  " + "  ".join(parts))
meta_parts = []
if item.labels:
    meta_parts.append(
        f"Labels: {', '.join(item.labels)}"
    )
if item.who:
    meta_parts.append(f"Worker: {item.who}")
if meta_parts:
    typer.echo("  " + "  ".join(meta_parts))
if item.wd:
    typer.echo(f"  WD: {item.wd}")
if item.command:
    typer.echo(f"  Command: {item.command}")
if item.github:
    gh = item.github
    typer.echo(
        f"  GitHub: {gh['repo']}"
        f"#{gh['number']} ({gh.get('type', 'issue')})"
    )
if item.description:
    typer.echo(f"  Description: {item.description}")
<<display notes>>
@

For GitHub-linked items, we fetch the issue body and comments
directly from GitHub so the user always sees the latest content.
For local-only items, we display the stored [[notes]] field. This
design means GitHub is the source of truth for notes on linked
items---we never cache them locally, avoiding stale data.

<<display notes>>=
if item.github:
    try:
        gh = item.github
        repo = gh["repo"]
        number = gh["number"]
        item_type = gh.get("type", "issue")
        if item_type == "pr":
            data = gh_fetch_pr(repo, number)
        else:
            data = gh_fetch_issue(repo, number)
        if data.get("body"):
            typer.echo("")
            typer.echo("Description:")
            for line in data["body"].splitlines():
                typer.echo(f"  {line}")
        comments = data.get("comments", [])
        if comments:
            typer.echo("")
            typer.echo("Comments:")
            for comment in comments:
                author = comment.get(
                    "author", {}
                ).get("login", "unknown")
                created = comment.get(
                    "createdAt", ""
                )[:10]
                typer.echo(
                    f"  [{author} {created}]"
                )
                body = comment.get("body", "")
                for line in body.splitlines():
                    typer.echo(f"    {line}")
                typer.echo("")
    except Exception as e:
        typer.echo(
            f"  (Could not fetch GitHub data: {e})",
            err=True,
        )
elif item.notes:
    typer.echo("")
    typer.echo("Notes:")
    for line in item.notes.splitlines():
        typer.echo(f"  {line}")
@

<<display todo children>>=
children = sorted(
    get_children(todos, item.id),
    key=lambda c: c.priority,
)
if children:
    typer.echo("")
    typer.echo("Sub-items:")
    for child in children:
        status_mark = {
            "done": "done",
            "in-progress": "active",
            "pending": "pending",
        }.get(child.status, child.status)
        typer.echo(
            f"  #{child.id} [{status_mark}] "
            f"{child.title}"
        )
        if child.notes:
            first_line = child.notes.splitlines()[0]
            typer.echo(f"    {first_line}")
        grandchildren = get_children(
            todos, child.id
        )
        for gc in grandchildren:
            gc_mark = {
                "done": "done",
                "in-progress": "active",
                "pending": "pending",
            }.get(gc.status, gc.status)
            typer.echo(
                f"    #{gc.id} [{gc_mark}] "
                f"{gc.title}"
            )
@

\subsection{Testing the [[view]] Command}

<<integration tests>>=
def test_view_command(temp_todo_dir):
    """Test viewing a todo with details."""
    save_todos(3, [
        TodoItem(
            id=1, title="Parent task",
            created="2026-02-20T10:00:00",
            notes="Some notes here",
            wd="/tmp/project",
            command="claude",
        ),
        TodoItem(
            id=2, title="Child task",
            parent_id=1, priority=1.0,
            created="2026-02-20T10:00:00",
        ),
    ])

    result = runner.invoke(cli, ["view", "1"])
    assert result.exit_code == 0
    assert "Parent task" in result.stdout
    assert "/tmp/project" in result.stdout
    assert "claude" in result.stdout
    assert "Some notes" in result.stdout
    assert "Child task" in result.stdout
@

\subsection{The [[edit]] Command}

Editing updates specific fields of an existing todo. The [[--edit]]
/ [[-E]] flag opens the interactive editor with the todo rendered as
markdown (using [[render_todo_markdown]]). After editing, the parser
reconciles changes: existing children are matched by title to preserve
their IDs and status, new headings become new children, and removed
headings are deleted.

Without [[-E]], the command works as before: CLI flags update
individual fields.

<<edit command>>=
@cli.command()
def edit(
    todo_id: int = typer.Argument(
        ..., help="Todo ID to edit",
        autocompletion=complete_todo_ids,
    ),
    edit_interactive: bool = typer.Option(
        False, "--edit", "-E",
        help="Open in editor",
    ),
    title: Optional[str] = typer.Option(
        None, "--title", "-t",
        help="New title",
    ),
    deadline: Optional[str] = typer.Option(
        None, "--deadline", "-d",
        help="New deadline (ISO datetime)",
    ),
    estimate: Optional[float] = typer.Option(
        None, "--estimate", "-e",
        help="New estimated hours",
    ),
    label: Optional[List[str]] = typer.Option(
        None, "--label", "-l",
        help="Replace all labels",
        autocompletion=complete_labels,
    ),
    description: Optional[str] = typer.Option(
        None, "--description",
        help="New description",
    ),
    reassign: Optional[str] = typer.Option(
        None, "--reassign",
        help="Reassign to worker",
        autocompletion=complete_todo_workers,
    ),
    parent: Optional[int] = typer.Option(
        None, "--parent", "-p",
        help="New parent ID (0 = make top-level)",
    ),
):
    """Edit an existing todo item."""
    next_id, todos = load_todos()
    item = next(
        (t for t in todos if t.id == todo_id), None
    )
    if item is None:
        typer.echo(
            f"Error: todo #{todo_id} not found",
            err=True,
        )
        raise typer.Exit(1)

    if edit_interactive:
        <<edit via editor>>
    else:
        <<edit via cli flags>>
@

Each flag is optional and defaults to [[None]]. We only overwrite a
field when the user explicitly supplied a value --- [[None]] means
\enquote{leave this field alone}. This lets you update a single
attribute (e.g.\ just the deadline) without touching the rest of the
item:

<<edit via cli flags>>=
if title is not None:
    item.title = title
if deadline is not None:
    item.deadline = deadline
if estimate is not None:
    item.estimated = estimate
if label is not None:
    item.labels = list(label)
if description is not None:
    item.description = description
if reassign is not None:
    item.who = reassign
@

The [[--parent]] flag moves an item in the hierarchy.  Items are
accidentally created as children when the user runs [[todo add]]
while another item is on the active stack --- the add command
automatically parents new items to the active one.  Without a way to
reparent, the only fix would be to delete and re-add the item,
losing its creation timestamp and any notes.

We use [[0]] as a sentinel for \enquote{promote to top-level}
because valid IDs always start from [[1]], so [[0]] is unambiguous.
Passing a positive integer instead reassigns the item under a
different parent.  Because the item's existing priority was computed
relative to its old siblings, it may sort oddly after a reparent ---
the user can run [[reprioritize]] afterwards to restore the intended
ordering:

<<edit via cli flags>>=
if parent is not None:
    if parent == 0:
        item.parent_id = None
    else:
        new_parent = next(
            (t for t in todos if t.id == parent),
            None,
        )
        if new_parent is None:
            typer.echo(
                f"Error: parent #{parent} not found",
                err=True,
            )
            raise typer.Exit(1)
        item.parent_id = parent

save_todos(next_id, todos)
typer.echo(f"Updated todo #{todo_id}: {item.title}")
@

\subsubsection{Editing via the Editor}

We render the todo as markdown, open the editor, parse the result,
and reconcile. Reconciliation matches existing children by title
(case-insensitive, whitespace-stripped) to preserve their IDs,
status, and tracked time. New headings create new children; removed
headings delete children.

<<edit via editor>>=
md = render_todo_markdown(item, todos)
content = open_editor(md)
parsed = parse_editor_content(content)
if not parsed.title:
    typer.echo("No title provided.", err=True)
    raise typer.Exit(1)

<<update parent from parsed>>
<<reconcile children>>
save_todos(next_id, todos)
typer.echo(f"Updated todo #{todo_id}: {item.title}")
@

<<update parent from parsed>>=
item.title = parsed.title
item.notes = parsed.notes
meta = parsed.metadata
if "wd" in meta:
    item.wd = meta["wd"]
if "command" in meta:
    item.command = meta["command"]
if "labels" in meta:
    item.labels = meta["labels"]
if "deadline" in meta:
    item.deadline = meta["deadline"]
if "estimate" in meta:
    item.estimated = meta["estimate"]
if "who" in meta:
    item.who = meta["who"]
if "github" in meta:
    item.github = meta["github"]
@

Reconciliation matches children by normalized title. Existing
children keep their ID, status, and tracked time. New headings get
fresh IDs. Priorities are distributed within the parent's context
range (using [[compute_child_priority_range]]) so that children
sort correctly in flat view, rather than using absolute sequential
values.

<<reconcile children>>=
<<build existing children index>>
<<distribute child priorities>>
<<update or create each parsed child>>
<<remove deleted children>>
@

We normalize titles to lowercase with stripped whitespace so that
minor formatting changes in the editor do not accidentally create
duplicate children.
<<build existing children index>>=
existing_children = get_children(todos, todo_id)
existing_by_title = {
    c.title.strip().lower(): c
    for c in existing_children
}
@

Priority distribution uses the parent's sibling-context range so
that children sort correctly relative to other top-level items when
the list is shown flat.  Without this, all children of every parent
would receive priorities in [0, 1] and would interleave arbitrarily.
<<distribute child priorities>>=
prio_high, prio_low = compute_child_priority_range(
    todos, item
)
priorities = distribute_priorities_in_range(
    len(parsed.children), prio_high, prio_low,
)
@

For each child in the parsed document we either update the matching
existing child (preserving its ID and status) or create a new one.
New children inherit [[who]] from the parent ([[item]]) when their
YAML block does not specify one --- the same rule as in
[[create_children_from_parsed]] --- so that adding a sub-task in
the editor never silently leaves it unassigned.
We use a set to track which existing children were seen; any that
remain unseen after this loop were removed in the editor.
<<update or create each parsed child>>=
new_child_ids = set()
for i, parsed_child in enumerate(parsed.children):
    prio = priorities[i] if i < len(priorities) else 0.0
    norm_title = parsed_child.title.strip().lower()
    if norm_title in existing_by_title:
        child = existing_by_title.pop(norm_title)
        child.title = parsed_child.title
        child.notes = parsed_child.notes
        child.priority = prio
        child_meta = parsed_child.metadata
        if "wd" in child_meta:
            child.wd = child_meta["wd"]
        if "command" in child_meta:
            child.command = child_meta["command"]
        if "labels" in child_meta:
            child.labels = child_meta["labels"]
        if "deadline" in child_meta:
            child.deadline = child_meta["deadline"]
        if "estimate" in child_meta:
            child.estimated = child_meta["estimate"]
        if "who" in child_meta:
            child.who = child_meta["who"]
        new_child_ids.add(child.id)
    else:
        now = datetime.datetime.now().isoformat()
        child_meta = parsed_child.metadata
        new_child = TodoItem(
            id=next_id,
            title=parsed_child.title,
            labels=child_meta.get(
                "labels", list(item.labels)
            ),
            priority=prio,
            status="pending",
            created=now,
            deadline=child_meta.get(
                "deadline", item.deadline
            ),
            parent_id=todo_id,
            who=child_meta.get("who", item.who),
            notes=parsed_child.notes,
            wd=child_meta.get("wd"),
            command=child_meta.get("command"),
        )
        todos.append(new_child)
        new_child_ids.add(next_id)
        next_id += 1
        typer.echo(
            f"  Added sub-item #{new_child.id}: "
            f"{new_child.title}"
        )
@

Children remaining in [[existing_by_title]] were not present in the
parsed document, meaning the user deleted their headings.  We remove
them from the list rather than silently keeping orphaned sub-items.
<<remove deleted children>>=
for removed in existing_by_title.values():
    todos = [t for t in todos if t.id != removed.id]
    typer.echo(
        f"  Removed sub-item #{removed.id}: "
        f"{removed.title}"
    )
@


\subsection{Testing Editor Reconciliation}

<<test functions>>=
def test_edit_interactive_reconcile():
    """Editing preserves existing child IDs/status."""
    parent = TodoItem(
        id=1, title="Parent",
        created="2026-02-20T10:00:00",
    )
    child = TodoItem(
        id=2, title="Existing child",
        parent_id=1, priority=1.0,
        status="done",
        created="2026-02-20T10:00:00",
    )
    todos = [parent, child]
    md = render_todo_markdown(parent, todos)
    parsed = parse_editor_content(md)

    # Verify the existing child is found in parsed
    assert len(parsed.children) == 1
    assert (
        parsed.children[0].title == "Existing child"
    )

def test_child_who_appears_in_editor():
    """Child who field survives render/parse roundtrip.

    Once a child carries a who value the editor must
    show it so the user can inspect or change it.
    Without this, the attribute would be invisible
    during interactive editing and could never be
    corrected by the user.
    """
    parent = TodoItem(
        id=1, title="Parent",
        who="alice",
        created="2026-02-20T10:00:00",
    )
    child = TodoItem(
        id=2, title="Sub-task",
        parent_id=1, priority=1.0,
        who="alice",
        created="2026-02-20T10:00:00",
    )
    todos = [parent, child]
    md = render_todo_markdown(parent, todos)
    parsed = parse_editor_content(md)

    assert len(parsed.children) == 1
    assert (
        parsed.children[0].metadata.get("who")
        == "alice"
    )

def test_child_who_override():
    """A child can override the parent's who field.

    Inheritance is the fallback, not a forced
    assignment.  A child with an explicit who in
    its YAML block must keep that value so work can
    be delegated to someone other than the owner of
    the parent task.
    """
    parent = TodoItem(
        id=1, title="Parent",
        who="alice",
        created="2026-02-20T10:00:00",
    )
    child = TodoItem(
        id=2, title="Delegated sub-task",
        parent_id=1, priority=1.0,
        who="bob",
        created="2026-02-20T10:00:00",
    )
    todos = [parent, child]
    md = render_todo_markdown(parent, todos)
    parsed = parse_editor_content(md)

    assert (
        parsed.children[0].metadata.get("who")
        == "bob"
    )
@

\subsection{Testing the [[--parent]] Flag}

Reparenting addresses the common accident of creating a todo as a
child of an active item when a top-level item was intended.  We
verify both directions: promoting a child to the top level and
moving it under a different parent.  We also confirm that supplying
an invalid parent ID is rejected with a non-zero exit code rather
than silently storing a dangling reference.

<<test functions>>=
def test_edit_reparent_toplevel(temp_todo_dir):
    """--parent 0 promotes a child to top-level."""
    save_todos(3, [
        TodoItem(
            id=1, title="Parent",
            created="2026-02-20T10:00:00",
        ),
        TodoItem(
            id=2, title="Child", parent_id=1,
            priority=0.5,
            created="2026-02-20T10:00:00",
        ),
    ])

    result = runner.invoke(
        cli, ["edit", "2", "--parent", "0"],
    )
    assert result.exit_code == 0
    _, todos = load_todos()
    child = next(t for t in todos if t.id == 2)
    assert child.parent_id is None

def test_edit_reparent_to_other(temp_todo_dir):
    """--parent N moves a child under a new parent."""
    save_todos(4, [
        TodoItem(
            id=1, title="Parent A",
            created="2026-02-20T10:00:00",
        ),
        TodoItem(
            id=2, title="Parent B",
            created="2026-02-20T10:00:00",
        ),
        TodoItem(
            id=3, title="Child", parent_id=1,
            priority=0.5,
            created="2026-02-20T10:00:00",
        ),
    ])

    result = runner.invoke(
        cli, ["edit", "3", "--parent", "2"],
    )
    assert result.exit_code == 0
    _, todos = load_todos()
    child = next(t for t in todos if t.id == 3)
    assert child.parent_id == 2

def test_edit_reparent_invalid(temp_todo_dir):
    """--parent with a non-existent ID is an error."""
    save_todos(2, [
        TodoItem(
            id=1, title="Orphan",
            created="2026-02-20T10:00:00",
        ),
    ])

    result = runner.invoke(
        cli, ["edit", "1", "--parent", "99"],
    )
    assert result.exit_code != 0
@

\subsection{The [[reprioritize]] Command}

When priorities shift, a todo may need repositioning among its
siblings. This reruns the same binary search algorithm used during
[[add]], but for an existing item.

<<reprioritize command>>=
@cli.command()
def reprioritize(
    todo_id: int = typer.Argument(
        ..., help="Todo ID to reprioritize",
        autocompletion=complete_todo_ids,
    ),
):
    """Rerun binary search priority for a todo item.

    Alias: reprio
    """
    next_id, todos = load_todos()
    item = next(
        (t for t in todos if t.id == todo_id), None
    )
    if item is None:
        typer.echo(
            f"Error: todo #{todo_id} not found",
            err=True,
        )
        raise typer.Exit(1)

    siblings = get_active_siblings(todos, item)
    new_priority = binary_search_priority(
        siblings, item.title
    )
    old_priority = item.priority
    item.priority = new_priority

    save_todos(next_id, todos)
    typer.echo(
        f"Reprioritized #{todo_id}: "
        f"{old_priority:.1f} -> {new_priority:.1f}"
    )
@

We also register the [[reprio]] alias:

<<subcommands>>=
@cli.command(name="reprio", hidden=True)
def reprio(
    todo_id: int = typer.Argument(
        ..., help="Todo ID to reprioritize",
        autocompletion=complete_todo_ids,
    ),
):
    """Alias for reprioritize."""
    reprioritize(todo_id=todo_id)
@

\subsection{The [[done]] Command}

Marking a todo as done also stops any active tracking for that item.
If the item has a parent, we check whether all siblings are now done
and auto-complete the parent.

When no ID is given, [[done]] pops the top of the active stack---the
most recently started task.  This enables a natural nested workflow:
[[todo start 5]], [[todo start 7]], [[todo done]] (pops~7),
[[todo done]] (pops~5).  When an explicit ID is given, that ID is
removed from wherever it appears in the stack.

<<done command>>=
@cli.command()
def done(
    todo_id: Optional[int] = typer.Argument(
        None, help="Todo ID to mark done "
        "(default: stack top)",
        autocompletion=complete_todo_ids,
    ),
):
    """Mark a todo item as done.

    If no ID given, completes the most recently
    started task (top of the active stack).
    """
    next_id, todos = load_todos()

    <<resolve done todo from stack>>

    item.status = "done"
    typer.echo(f"Done: #{todo_id} {item.title}")

    <<remove from active stack>>
    <<stop tracking if active>>
    <<clean stale todo labels>>
    <<close github issue>>
    <<check parent auto-completion>>

    save_todos(next_id, todos)
@

<<resolve done todo from stack>>=
if todo_id is None:
    stack = load_active_stack()
    if not stack:
        typer.echo(
            "No active todo on the stack.",
            err=True,
        )
        raise typer.Exit(1)
    todo_id = stack[-1]
item = next(
    (t for t in todos if t.id == todo_id), None
)
if item is None:
    typer.echo(
        f"Error: todo #{todo_id} not found",
        err=True,
    )
    raise typer.Exit(1)
@

Once the item is marked done, we remove it from the active stack.
When an explicit ID was given, [[remove]] finds it anywhere in the
stack (not just the top), since the user may complete tasks out of
order:

<<remove from active stack>>=
stack = load_active_stack()
if todo_id in stack:
    stack.remove(todo_id)
    save_active_stack(stack)
@

Let's verify that [[done]] without an argument pops the stack
correctly in a nested workflow:

<<test functions>>=
def test_done_pops_stack(temp_todo_dir):
    """Done without ID pops top of active stack."""
    save_todos(3, [
        TodoItem(
            id=1, title="Parent",
            created="2026-02-20T10:00:00",
            status="in-progress",
        ),
        TodoItem(
            id=2, title="Child",
            created="2026-02-20T10:00:00",
            status="in-progress",
            parent_id=1,
        ),
    ])
    save_active_stack([1, 2])
    result = runner.invoke(cli, ["done"])
    assert result.exit_code == 0
    assert "Child" in result.output
    assert load_active_stack() == [1]
@

<<stop tracking if active>>=
try:
    from nytid.cli.track import (
        load_active_session,
        save_active_session,
        add_completed_entries,
    )

    worker = item.who or default_username
    session = load_active_session(worker)
    todo_label = f"todo:{todo_id}"
    if todo_label in session.get_active_labels():
        labels_to_stop = [todo_label] + [
            l for l in item.labels
            if l in session.get_active_labels()
        ]
        entries = session.stop_labels(
            labels_to_stop
        )
        if entries:
            add_completed_entries(entries)
        save_active_session(session, worker)
        typer.echo(
            f"  Stopped tracking todo:{todo_id}"
        )
except ImportError:
    pass
@

Stopping the specific todo's labels is not enough: labels from
previously removed or completed todos may linger in the session
as \emph{stale labels}.  Every time a new todo is started, these
stale labels get restarted alongside the real ones, creating
phantom zero-duration entries in the tracking log.  To prevent
this, we scan the session for any [[todo:N]] labels where [[N]]
no longer corresponds to an active (non-done) todo, and stop
those as well.

We only clean the current worker's session, since [[done]] and
[[rm]] operate on a single item with a known worker.  The
[[sync]] command uses a broader variant that sweeps all workers.

<<clean stale todo labels>>=
try:
    from nytid.cli.track import (
        load_active_session,
        save_active_session,
        add_completed_entries,
    )
    import re

    worker = item.who or default_username
    session = load_active_session(worker)
    todo_ids = {t.id for t in todos}
    stale = [
        l for l in session.get_active_labels()
        if re.match(r"^todo:(\d+)$", l)
        and int(l.split(":")[1]) not in todo_ids
    ]
    if stale:
        entries = session.stop_labels(stale)
        if entries:
            add_completed_entries(entries)
        save_active_session(session, worker)
        typer.echo(
            f"  Cleaned {len(stale)} stale "
            f"label(s): {', '.join(stale)}"
        )
except ImportError:
    pass
@

The [[sync]] command may touch multiple workers' sessions during
a single run, so we sweep all session files rather than just the
current worker's.  We reuse the same stale-detection logic:

<<clean stale todo labels from all workers>>=
try:
    from nytid.cli.track import (
        load_active_session,
        save_active_session,
        add_completed_entries,
        get_tracking_dir,
    )
    import re

    todo_ids = {t.id for t in todos}
    tracking_dir = get_tracking_dir()
    if tracking_dir.exists():
        for f in sorted(
            tracking_dir.glob(
                "current_session*.json"
            )
        ):
            name = f.stem.replace(
                "current_session_", ""
            )
            if name == "current_session":
                name = default_username
            session = load_active_session(name)
            stale = [
                l
                for l in (
                    session.get_active_labels()
                )
                if re.match(r"^todo:(\d+)$", l)
                and int(l.split(":")[1])
                not in todo_ids
            ]
            if stale:
                entries = session.stop_labels(
                    stale
                )
                if entries:
                    add_completed_entries(entries)
                save_active_session(
                    session, name
                )
                typer.echo(
                    f"  Cleaned {len(stale)} "
                    f"stale label(s) for "
                    f"{name}: "
                    f"{', '.join(stale)}"
                )
except ImportError:
    pass
@

When a todo linked to a GitHub issue or PR is marked done, we also
close the corresponding item on GitHub. This keeps the two systems
in sync without requiring a separate [[sync]] step. We catch
errors gracefully---network issues should not prevent the local
state change from completing.

<<close github issue>>=
if item.github:
    try:
        repo = item.github["repo"]
        number = item.github["number"]
        item_type = item.github.get("type", "issue")
        if item_type == "pr":
            gh_close_pr(repo, number)
        else:
            gh_close_issue(repo, number)
        typer.echo(
            f"  Closed {item_type} #{number} "
            f"on {repo}"
        )
    except Exception as e:
        typer.echo(
            f"  Warning: could not close "
            f"GitHub {item_type}: {e}",
            err=True,
        )
@

<<check parent auto-completion>>=
if item.parent_id is not None:
    if check_parent_completion(todos, item.parent_id):
        parent_item = next(
            (t for t in todos if t.id == item.parent_id),
            None,
        )
        if parent_item and parent_item.status != "done":
            parent_item.status = "done"
            typer.echo(
                f"  Parent #{parent_item.id} "
                f"auto-completed: {parent_item.title}"
            )
@

Let's verify that completing a GitHub-linked todo also closes the
issue on GitHub:

<<test functions>>=
def test_done_closes_github_issue(temp_todo_dir):
    """Completing a GitHub-linked todo closes the issue."""
    save_todos(2, [
        TodoItem(
            id=1, title="Fix bug",
            created="2026-02-20T10:00:00",
            github={
                "repo": "owner/repo",
                "type": "issue",
                "number": "42",
            },
        ),
    ])
    mock_result = MagicMock()
    mock_result.returncode = 0
    with patch(
        "nytid.cli.todo.subprocess.run",
        return_value=mock_result,
    ) as mock_run:
        result = runner.invoke(
            cli, ["done", "1"],
        )
        assert result.exit_code == 0
        assert "Closed issue #42" in result.output
        # Verify gh issue close was called
        calls = mock_run.call_args_list
        close_call = [
            c for c in calls
            if "close" in c[0][0]
        ]
        assert len(close_call) >= 1
@

\subsection{The [[rm]] Command}

Removing a todo requires confirmation. If the item has children,
those are removed as well.  We stop any active tracking for the
removed item and clean up stale labels left by previously removed
todos---otherwise orphaned [[todo:N]] labels accumulate in the
session and generate phantom tracking entries.

<<remove command>>=
@cli.command()
def rm(
    todo_id: int = typer.Argument(
        ..., help="Todo ID to remove",
        autocompletion=complete_todo_ids,
    ),
    force: bool = typer.Option(
        False, "--force", "-f",
        help="Skip confirmation",
    ),
):
    """Remove a todo item."""
    next_id, todos = load_todos()
    item = next(
        (t for t in todos if t.id == todo_id), None
    )
    if item is None:
        typer.echo(
            f"Error: todo #{todo_id} not found",
            err=True,
        )
        raise typer.Exit(1)

    children = get_children(todos, todo_id)
    if not force:
        msg = f"Remove #{todo_id}: {item.title}?"
        if children:
            msg += f" ({len(children)} sub-items)"
        if not typer.confirm(msg):
            typer.echo("Cancelled.")
            raise typer.Exit(0)

    # Remove item and its children
    ids_to_remove = {todo_id}
    ids_to_remove.update(c.id for c in children)
    todos = [t for t in todos if t.id not in ids_to_remove]

    <<stop tracking if active>>
    <<clean stale todo labels>>

    save_todos(next_id, todos)
    typer.echo(f"Removed #{todo_id}: {item.title}")
    if children:
        typer.echo(
            f"  Also removed {len(children)} sub-items"
        )
@

\subsection{The [[next]] Command}

The [[next]] command answers the question \enquote{What should I work
on now?}  It encapsulates the agent's decision loop into a single
CLI call, replacing fragile shell-script logic with a first-class
command.

The behaviour depends on whether a task is currently active:
\begin{description}
\item[Stack non-empty] Start the highest-priority pending
  \emph{child} of the stack-top task.  This is the decomposition
  workflow: you started a parent, created sub-tasks, and now
  [[next]] picks the most urgent child.
\item[Stack empty] Start the highest-priority pending task
  overall (filtered by [[--who]]).
\end{description}

If there is nothing to do (no pending tasks match), the command
exits with code~0 and emits no output. This keeps cron runs quiet in
the idle case while preserving non-zero exits for actual failures.

<<next command>>=
@cli.command(name="next")
def next_cmd(
    who: Optional[str] = typer.Option(
        default_username, "--who", "-w",
        help="Filter by assignee",
    ),
    headless: bool = typer.Option(
        False, "--headless",
        help="Non-interactive mode (no prompts)",
    ),
    run: Optional[str] = typer.Option(
        None, "--run", "-r",
        help="Override command to run",
    ),
    timeout: Optional[str] = typer.Option(
        None, "--timeout",
        help="Kill subprocess after duration "
        "(GNU format: 50m, 1.5h, 30s)",
    ),
    reassign_to: Optional[str] = typer.Option(
        default_username, "--reassign-to",
        help="Reassign to this user on "
        "timeout/failure",
    ),
):
    """Start the next highest-priority task.

    If a task is in-progress, starts its next pending
    child. Otherwise starts the highest-priority
    pending task.
    """
    _, todos = load_todos()
    stack = load_active_stack()

    <<find next task to start>>

    if target is None:
        raise typer.Exit(0)

    start(
        todo_id=target.id,
        headless=headless,
        run=run,
        timeout=timeout,
        reassign_to=reassign_to,
    )
@

<<find next task to start>>=
if stack:
    target = find_pending_by_priority(
        todos, who=who, parent_id=stack[-1]
    )
else:
    target = find_pending_by_priority(
        todos, who=who
    )
@

Let's verify the [[next]] command picks the right task:

<<test functions>>=
def test_next_picks_highest_priority(temp_todo_dir):
    """next starts the highest-priority pending task."""
    save_todos(3, [
        TodoItem(
            id=1, title="Low",
            created="2026-02-20T10:00:00",
            priority=1.0, who="me",
        ),
        TodoItem(
            id=2, title="High",
            created="2026-02-20T10:00:00",
            priority=5.0, who="me",
        ),
    ])
    result = runner.invoke(
        cli, ["next", "--who", ""]
    )
    assert result.exit_code == 0
    assert "High" in result.output


def test_next_picks_child(temp_todo_dir):
    """next picks pending child of stack-top task."""
    save_todos(4, [
        TodoItem(
            id=1, title="Parent",
            created="2026-02-20T10:00:00",
            status="in-progress",
        ),
        TodoItem(
            id=2, title="Child A",
            created="2026-02-20T10:00:00",
            priority=1.0, parent_id=1,
        ),
        TodoItem(
            id=3, title="Child B",
            created="2026-02-20T10:00:00",
            priority=5.0, parent_id=1,
        ),
    ])
    save_active_stack([1])
    result = runner.invoke(
        cli, ["next", "--who", ""]
    )
    assert result.exit_code == 0
    assert "Child B" in result.output


def test_next_silent_when_nothing_pending(temp_todo_dir):
    """next exits successfully and stays silent if idle."""
    save_todos(1, [])
    result = runner.invoke(
        cli, ["next", "--who", ""]
    )
    assert result.exit_code == 0
    assert result.output == ""


def test_next_silent_when_active_has_no_pending_child(
    temp_todo_dir,
):
    """next is silent when stack top has no pending child."""
    save_todos(3, [
        TodoItem(
            id=1, title="Parent",
            created="2026-02-20T10:00:00",
            status="in-progress",
        ),
        TodoItem(
            id=2, title="Done child",
            created="2026-02-20T10:00:00",
            status="done",
            parent_id=1,
        ),
    ])
    save_active_stack([1])
    result = runner.invoke(
        cli, ["next", "--who", ""]
    )
    assert result.exit_code == 0
    assert result.output == ""
@

\subsection{The [[start]] Command}

Starting a todo delegates to the track system. We start tracking with
the todo's labels plus a special [[todo:<id>]] label, and set the
todo's status to \enquote{in-progress}. The track worker is the todo's
assigned worker, defaulting to \enquote{me}.

The key enhancement: if the todo has a [[wd]], [[start]] automatically
spawns a subprocess---the stored [[command]], a [[--run]] override, or
[[$SHELL]]. This combines the patterns from [[track run]] (tracking
bookends around a subprocess) and [[courses data]] shell (PS1
modification and [[wd]] control). When the subprocess exits,
tracking stops and the user is prompted to mark the todo as done.

Without a [[wd]], the command behaves as before: it starts tracking
without spawning anything.

<<start command>>=
@cli.command()
def start(
    todo_id: int = typer.Argument(
        ..., help="Todo ID to start tracking",
        autocompletion=complete_todo_ids,
    ),
    <<start command options>>
):
    """Start tracking time for a todo item.

    If the todo has a working directory, spawns a
    shell or command in that directory with tracking.
    """
    next_id, todos = load_todos()
    item = next(
        (t for t in todos if t.id == todo_id), None
    )
    if item is None:
        typer.echo(
            f"Error: todo #{todo_id} not found",
            err=True,
        )
        raise typer.Exit(1)

    <<start tracking via track module>>

    item.status = "in-progress"
    save_todos(next_id, todos)

    <<push onto active stack>>

    typer.echo(
        f"Started: #{todo_id} {item.title}"
    )

    <<spawn subprocess if wd or run>>
@

Restarting an already-active task (e.g.\ after a failed run) must
not push a duplicate onto the stack, otherwise [[todo done]] would
only remove one copy and leave a stale entry.  We check membership
before appending:

<<push onto active stack>>=
stack = load_active_stack()
if todo_id not in stack:
    stack.append(todo_id)
    save_active_stack(stack)
@

Let's verify that starting a task pushes it onto the stack:

<<test functions>>=
def test_start_pushes_stack(temp_todo_dir):
    """Start pushes todo ID onto active stack."""
    save_todos(2, [
        TodoItem(
            id=1, title="Task",
            created="2026-02-20T10:00:00",
        ),
    ])
    result = runner.invoke(cli, ["start", "1"])
    assert result.exit_code == 0
    assert load_active_stack() == [1]
@

The [[--run]] option lets the user override the todo's stored command
at start time:

<<start command options>>=
run: Optional[str] = typer.Option(
    None, "--run", "-r",
    help="Override command to run",
),
@

For cron and agent workflows, we need a non-interactive mode that
skips the [[typer.confirm]] prompt after subprocess exit.  In headless
mode, the logic is: if the subprocess (e.g.\ [[claude]]) already called
[[todo done]] during its run, the task is done; otherwise we reset to
pending so the next invocation can retry.

\paragraph{Full lifecycle in headless mode}
\label{sec:headless-lifecycle}
When [[--headless]] is combined with [[--timeout]], the [[start]]
command manages the full lifecycle internally: capturing output,
posting it as a note, handling timeout, and reassigning timed-out
tasks.  This eliminates the race condition where an external
shell script queries for the active task after the subprocess has
already reset the status (see \cref{sec:agent-automation}).

<<start command options>>=
headless: bool = typer.Option(
    False, "--headless",
    help="Non-interactive mode (no prompts)",
),
@

The [[--timeout]] and [[--reassign-to]] options are orthogonal to
[[--headless]]: timeout specifies when to kill a runaway subprocess
(using GNU [[timeout]] format like [[50m]] or [[1.5h]]), and
[[--reassign-to]] specifies who receives timed-out or failed tasks.
Keeping them separate makes each parameter's purpose clear and
allows independent configuration.

<<start command options>>=
timeout: Optional[str] = typer.Option(
    None, "--timeout",
    help="Kill subprocess after duration "
    "(GNU format: 50m, 1.5h, 30s)",
),
reassign_to: Optional[str] = typer.Option(
    default_username, "--reassign-to",
    help="Reassign to this user on "
    "timeout/failure",
),
@

We import and use the track module's session management directly
rather than shelling out to the CLI. This is more reliable and avoids
subprocess overhead.

<<start tracking via track module>>=
try:
    from nytid.cli.track import (
        load_active_session,
        save_active_session,
    )

    worker = item.who or default_username
    session = load_active_session(worker)

    track_labels = list(item.labels) + [
        f"todo:{todo_id}"
    ]
    started = session.start_labels(
        track_labels, datetime.datetime.now()
    )
    save_active_session(session, worker)

    if started:
        typer.echo(
            f"  Tracking: {', '.join(sorted(started))}"
        )
        if worker != "me":
            typer.echo(f"  Worker: {worker}")
except ImportError:
    typer.echo(
        "Warning: track module not available, "
        "time tracking disabled",
        err=True,
    )
@

\subsubsection{Spawning a Subprocess}

When the todo has a [[wd]] (or [[--run]] is given), we spawn a
subprocess with the stored environment restored. The environment
restoration starts from the stored [[env]] (if available) and
overlays terminal-related variables ([[TERM]], [[DISPLAY]],
[[SSH_*]]) from the current environment to ensure the terminal
works correctly.

The PS1 prefix shows the todo ID so the user always knows which
task they're working on---following the same pattern as the
[[courses data]] shell command.

<<spawn subprocess if wd or run>>=
if item.wd or run:
    <<prepare subprocess environment>>
    <<determine command to run>>
    <<adjust shell command for prompt>>
    <<run subprocess>>
    <<handle subprocess exit>>
@

<<prepare subprocess environment>>=
proc_env = os.environ.copy()
if item.env:
    proc_env = dict(item.env)
    # Overlay terminal variables from current env
    for key in os.environ:
        if key in (
            "TERM", "DISPLAY", "COLORTERM",
            "TERM_PROGRAM",
        ) or key.startswith("SSH_"):
            proc_env[key] = os.environ[key]
if "PS1" in proc_env:
    proc_env["PS1"] = (
        f"todo:{todo_id} {proc_env['PS1']}"
    )
else:
    proc_env["PS1"] = (
        f"todo:{todo_id} \\w\\n$ "
    )
proc_wd = item.wd or os.getcwd()
@

The command resolution follows a priority chain: [[--run]] overrides
the stored [[command]], which overrides [[$SHELL]]. Special commands
like [[claude]] and [[opencode]] receive the todo's notes or
description as a prompt argument, so the AI tool starts with context.

<<determine command to run>>=
cmd_str = run or item.command
if cmd_str:
    prompt_text = item.notes or item.description
    if cmd_str in ("claude", "opencode"):
        if prompt_text:
            cmd = [cmd_str, prompt_text]
        else:
            cmd = [cmd_str]
    else:
        cmd = cmd_str.split()
else:
    cmd = [os.environ.get("SHELL", "/bin/sh")]
@

Shell init files ([[~/.bashrc]] for bash, [[~/.zshrc]] for zsh) run
when an interactive shell starts and reassign [[PS1]], discarding the
value we set in [[proc_env]]. Setting [[PS1]] in the environment alone
is therefore not enough to make the prefix stick.

For bash, [[bash --init-file FILE]] reads [[FILE]] instead of
[[~/.bashrc]], so we write a temporary script that sources
[[~/.bashrc]] first---preserving the user's custom prompt---then
prepends the todo prefix to whatever [[PS1]] ends up being. For zsh
we point [[ZDOTDIR]] to a temporary directory whose [[.zshrc]] does
the same thing. For any other shell (fish, dash, \ldots) we leave
[[PS1]] as [[prepare subprocess environment]] set it; whether it
survives the shell's own init sequence is shell-dependent.

The path of the temp file (or directory for zsh) is stored in
[[_tmp_prompt_path]] so that [[<<cleanup temp prompt files>>]] can
remove it unconditionally after the subprocess exits.

<<adjust shell command for prompt>>=
_tmp_prompt_path = None
_shell_name = os.path.basename(cmd[0])
if _shell_name == "bash":
    with tempfile.NamedTemporaryFile(
        mode="w", suffix=".sh", delete=False
    ) as _f:
        _f.write(
            "[ -f ~/.bashrc ]"
            " && source ~/.bashrc\n"
            f'PS1="todo:{todo_id} $PS1"\n'
        )
        _tmp_prompt_path = _f.name
    cmd = [cmd[0], "--init-file",
           _tmp_prompt_path]
elif _shell_name == "zsh":
    _tmp_zdotdir = tempfile.mkdtemp()
    _tmp_prompt_path = _tmp_zdotdir
    with open(
        os.path.join(_tmp_zdotdir, ".zshrc"), "w"
    ) as _f:
        _f.write(
            "[ -f ~/.zshrc ]"
            " && source ~/.zshrc\n"
            f'PS1="todo:{todo_id} $PS1"\n'
        )
    proc_env["ZDOTDIR"] = _tmp_zdotdir
@


The temporary init file (or [[ZDOTDIR]] directory for zsh) must be
removed after the subprocess exits regardless of how it terminated.
Placing the removal in a [[finally]] clause guarantees this even
when [[subprocess.TimeoutExpired]] or [[FileNotFoundError]] is
raised.  We distinguish the zsh case (a directory) from the bash
case (a plain file) because [[shutil.rmtree]] is required to remove
a directory tree, while [[os.unlink]] handles a single file; using
the wrong one would silently leave temp artefacts on disk.

<<cleanup temp prompt files>>=
if _tmp_prompt_path:
    if os.path.isdir(_tmp_prompt_path):
        shutil.rmtree(
            _tmp_prompt_path,
            ignore_errors=True,
        )
    else:
        try:
            os.unlink(_tmp_prompt_path)
        except OSError:
            pass
@

The subprocess must behave differently depending on context.
Interactive users need a live terminal connection (pass-through
stdin/stdout/stderr), while headless agents have no terminal at
all---trying to read from a closed stdin would hang.  Redirecting
to [[subprocess.DEVNULL]] and [[subprocess.PIPE]] solves both
problems: no hang risk, and we capture output for the note.

Python's [[subprocess.run]] accepts a [[timeout]] parameter
natively, so we can retire the external [[timeout]] command
that wrapped the shell invocation
(see \cref{sec:agent-automation}).

As with all interactive child-process spawns, we ignore
\textsc{sigint} in the parent while the child is running.
[[subprocess.run]] resets [[SIG_IGN]] signals to [[SIG_DFL]] in
the child before [[exec]] (via [[restore_signals=True]]), so
Ctrl+C still reaches the child normally.  The parent resumes after
the child exits and can record the outcome.  We restore the
original handler at the start of the [[finally]] clause, before
the prompt-file cleanup, so the handler is reinstated even when
the cleanup itself fails.

<<run subprocess>>=
result = None
old_sigint = signal.signal(
    signal.SIGINT, signal.SIG_IGN
)
try:
    typer.echo(
        f"  Spawning in {proc_wd}: "
        f"{' '.join(cmd)}"
    )
    timeout_secs = (
        parse_timeout(timeout)
        if timeout else None
    )
    if headless:
        result = subprocess.run(
            cmd,
            cwd=proc_wd,
            env=proc_env,
            stdin=subprocess.DEVNULL,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            timeout=timeout_secs,
        )
    else:
        result = subprocess.run(
            cmd,
            cwd=proc_wd,
            env=proc_env,
            stdin=sys.stdin,
            stdout=sys.stdout,
            stderr=sys.stderr,
            timeout=timeout_secs,
        )
except subprocess.TimeoutExpired as exc:
    <<handle subprocess timeout>>
except FileNotFoundError:
    typer.echo(
        f"Command not found: {cmd[0]}",
        err=True,
    )
finally:
    signal.signal(signal.SIGINT, old_sigint)
    <<cleanup temp prompt files>>
@

When a [[--timeout]] fires, [[subprocess.TimeoutExpired]] carries
any partial output captured before the kill signal.  We post that
partial output as a note (so the teacher sees what the agent
managed to do), reassign the task to [[--reassign-to]] (defaulting
to [[$USER]]), and reset to pending.  Setting [[result = None]]
signals to the exit handler that timeout was already handled.

<<handle subprocess timeout>>=
partial = (
    exc.stdout if exc.stdout else ""
)
# Reload and post timeout note
next_id, todos = load_todos()
item = next(
    (t for t in todos if t.id == todo_id),
    None,
)
if item is not None:
    note = (
        f"**Timed out** after {timeout}\n\n"
    )
    if partial.strip():
        note += (
            f"Partial output:\n"
            f"```\n{partial.strip()}\n```"
        )
    append_note_to_item(
        item, note, next_id, todos
    )
    if reassign_to:
        item.who = reassign_to
    item.status = "pending"
    save_todos(next_id, todos)
typer.echo(
    f"  Timed out after {timeout}",
    err=True,
)
@

After the subprocess exits, we reload todos (the file may have
changed during the session), stop tracking, and prompt the user
about completion status.  In headless mode we skip the prompt: if
the subprocess already marked the todo done (via [[todo done]]),
we respect that; otherwise we reset to pending for retry.

When headless mode captured output (via [[subprocess.PIPE]]),
we post it as a note before updating the status.  This ensures
the teacher can review what the agent did, even if the task
failed or was reset.

<<handle subprocess exit>>=
# Reload in case file changed during session
next_id, todos = load_todos()
item = next(
    (t for t in todos if t.id == todo_id), None
)
if item is None:
    return

<<stop tracking after subprocess>>
<<post headless output as note>>

if item.status == "done":
    pass
elif headless:
    <<handle headless exit status>>
elif typer.confirm(
    f"Mark todo #{todo_id} as done?"
):
    item.status = "done"
    <<check parent auto-completion>>
else:
    item.status = "pending"

# Remove from stack regardless of outcome
stack = load_active_stack()
if todo_id in stack:
    stack.remove(todo_id)
    save_active_stack(stack)

save_todos(next_id, todos)
@

The exit code and captured output form a diagnostic record---the
teacher can review what the agent did without needing to check
system logs or reproduce the run.

<<post headless output as note>>=
if headless and result is not None:
    output = getattr(result, "stdout", None)
    code = result.returncode
    if output and output.strip():
        note = (
            f"Exit code: {code}\n\n"
            f"```\n{output.strip()}\n```"
        )
    else:
        note = f"Exit code: {code} (no output)"
    append_note_to_item(
        item, note, next_id, todos
    )
@

In headless mode, a non-zero exit code indicates failure.
If [[--reassign-to]] is set, we hand the task back to a human
so they can investigate.  A zero exit
code where the subprocess did not mark the task done means the
agent finished without completing the task---we reset to
pending for the next retry.

<<handle headless exit status>>=
if (
    result is not None
    and result.returncode != 0
    and reassign_to
):
    item.who = reassign_to
item.status = "pending"
@

Let's verify that [[append_note_to_item]] stores notes locally:

<<test functions>>=
def test_append_note_to_item_local(temp_todo_dir):
    """append_note_to_item stores note on local item."""
    save_todos(2, [
        TodoItem(
            id=1, title="Task",
            created="2026-02-20T10:00:00",
        ),
    ])
    next_id, todos = load_todos()
    item = todos[0]
    append_note_to_item(
        item, "First note", next_id, todos
    )
    _, reloaded = load_todos()
    assert reloaded[0].notes == "First note"


def test_append_note_to_item_appends(temp_todo_dir):
    """append_note_to_item appends to existing notes."""
    save_todos(2, [
        TodoItem(
            id=1, title="Task",
            created="2026-02-20T10:00:00",
            notes="Existing",
        ),
    ])
    next_id, todos = load_todos()
    item = todos[0]
    append_note_to_item(
        item, "Second", next_id, todos
    )
    _, reloaded = load_todos()
    assert "Existing\nSecond" == reloaded[0].notes
@

Now let's verify headless mode captures output as a note.  We use
[[echo hello]] as a trivial subprocess whose output we can check:

<<test functions>>=
def test_start_headless_captures_output(temp_todo_dir):
    """Headless start posts subprocess output as note."""
    save_todos(2, [
        TodoItem(
            id=1, title="Task",
            created="2026-02-20T10:00:00",
        ),
    ])
    result = runner.invoke(
        cli,
        ["start", "1", "--headless",
         "--run", "echo hello"],
    )
    assert result.exit_code == 0
    _, todos = load_todos()
    item = todos[0]
    assert item.notes is not None
    assert "hello" in item.notes
    assert "Exit code: 0" in item.notes
@

A failing subprocess in headless mode should reassign to the
[[--reassign-to]] user:

<<test functions>>=
def test_start_headless_reassigns_on_failure(
    temp_todo_dir,
):
    """Headless start reassigns on non-zero exit."""
    save_todos(2, [
        TodoItem(
            id=1, title="Task",
            created="2026-02-20T10:00:00",
            who="agent",
        ),
    ])
    result = runner.invoke(
        cli,
        ["start", "1", "--headless",
         "--run", "false",
         "--reassign-to", "teacher"],
    )
    _, todos = load_todos()
    item = todos[0]
    assert item.who == "teacher"
    assert item.status == "pending"
@

<<stop tracking after subprocess>>=
try:
    from nytid.cli.track import (
        load_active_session,
        save_active_session,
        add_completed_entries,
    )

    worker = item.who or default_username
    session = load_active_session(worker)
    todo_label = f"todo:{todo_id}"
    if todo_label in session.get_active_labels():
        labels_to_stop = [todo_label] + [
            l for l in item.labels
            if l in session.get_active_labels()
        ]
        entries = session.stop_labels(
            labels_to_stop
        )
        if entries:
            add_completed_entries(entries)
        save_active_session(session, worker)
        typer.echo(
            f"  Stopped tracking todo:{todo_id}"
        )
except ImportError:
    pass
@

\subsection{The [[stop]] Command}

Stopping can target a specific todo by ID, or if no ID is given, find
whichever todo is currently being tracked and stop it.

<<stop command>>=
@cli.command()
def stop(
    todo_id: Optional[int] = typer.Argument(
        None,
        help="Todo ID to stop (or auto-detect)",
        autocompletion=complete_todo_ids,
    ),
):
    """Stop tracking time for a todo item.

    If no ID given, stops the currently tracked todo.
    """
    next_id, todos = load_todos()

    <<resolve which todo to stop>>
    <<stop tracking via track module>>
    <<update todo status after stop>>

    save_todos(next_id, todos)
@

When auto-detecting which todo to stop, we prefer the active stack
(which tracks the most recently started task) over a linear scan of
in-progress items.  A stale stack entry---where the ID was deleted
from [[todos.json]] but not yet cleaned from the stack---must be
caught, otherwise downstream code would dereference [[None]]:

<<resolve which todo to stop>>=
if todo_id is None:
    stack = load_active_stack()
    if stack:
        todo_id = stack[-1]
        item = next(
            (t for t in todos if t.id == todo_id),
            None,
        )
        if item is None:
            typer.echo(
                f"Error: todo #{todo_id} not found",
                err=True,
            )
            raise typer.Exit(1)
    else:
        in_progress = [
            t for t in todos
            if t.status == "in-progress"
        ]
        if not in_progress:
            typer.echo(
                "No todo is currently in-progress."
            )
            raise typer.Exit(0)
        item = in_progress[0]
        todo_id = item.id
else:
    item = next(
        (t for t in todos if t.id == todo_id), None
    )
    if item is None:
        typer.echo(
            f"Error: todo #{todo_id} not found",
            err=True,
        )
        raise typer.Exit(1)
@

<<stop tracking via track module>>=
try:
    from nytid.cli.track import (
        load_active_session,
        save_active_session,
        add_completed_entries,
    )

    worker = item.who or default_username
    session = load_active_session(worker)
    todo_label = f"todo:{todo_id}"

    if todo_label in session.get_active_labels():
        labels_to_stop = [todo_label] + [
            l for l in item.labels
            if l in session.get_active_labels()
        ]
        entries = session.stop_labels(labels_to_stop)
        if entries:
            add_completed_entries(entries)
        save_active_session(session, worker)
except ImportError:
    pass
@

We reset the status to [[pending]] rather than leaving it as
[[in-progress]]. An item is only [[in-progress]] while a timer is
running; once we stop the clock the work is paused, not ongoing. This
keeps the status field a faithful reflection of the current tracking
state:

<<update todo status after stop>>=
item.status = "pending"
stack = load_active_stack()
if todo_id in stack:
    stack.remove(todo_id)
    save_active_stack(stack)
typer.echo(f"Stopped: #{todo_id} {item.title}")
@

\section{ICS Export}

iCalendar (\textsc{ics}) defines two relevant component types: [[VEVENT]]
for time-anchored occurrences and [[VTODO]] for tasks. We use [[VTODO]]
because todos have a deadline and a duration but no fixed start
time---they express \emph{when something must be done by} and
\emph{how long it will take}, not \emph{when you will do it}. Most
calendar apps (Thunderbird, Apple Calendar, Nextcloud) can import
[[VTODO]] components from a [[.ics]] file and display them as tasks
in a separate task list.

\subsection{The [[export]] Command}

<<export command>>=
@cli.command()
def export(
    output: Optional[str] = typer.Option(
        None, "--output", "-o",
        help="Output file (stdout if not specified)",
    ),
    all_items: bool = typer.Option(
        False, "--all", "-a",
        help="Include done items",
    ),
    format: str = typer.Option(
        "vtodo", "--format", "-f",
        help="Export format (vtodo)",
    ),
):
    """Export todos as ICS (VTODO)."""
    _, todos = load_todos()

    if not all_items:
        todos = [t for t in todos if t.status != "done"]

    if not todos:
        typer.echo("No todos to export.", err=True)
        return

    if format == "vtodo":
        ics_data = export_vtodo(todos)
    else:
        typer.echo(
            f"Unknown format: {format}", err=True
        )
        raise typer.Exit(1)

    if output:
        with open(output, "w") as f:
            f.write(ics_data)
        typer.echo(
            f"Exported {len(todos)} todos to {output}"
        )
    else:
        print(ics_data)
@

\subsection{VTODO Export}

The VTODO format maps naturally to our data model. ICS priority uses
a 1--9 scale (1 = highest, 9 = lowest), so we need to invert and
clamp our float priority.

<<vtodo export function>>=
def export_vtodo(todos: List[TodoItem]) -> str:
    """Export todos as VTODO components in ICS format.

    Maps our float priority to the ICS 1-9 scale and
    includes deadline, duration, status, and labels.
    """
    try:
        from ics import Calendar
        from ics.todo import Todo
    except ImportError:
        typer.echo(
            "ICS export requires the 'ics' package. "
            "Install with: pip install ics",
            err=True,
        )
        raise typer.Exit(1)

    cal = Calendar()

    max_prio = max(
        (effective_priority(t) for t in todos),
        default=1.0,
    )

    for item in todos:
        todo = Todo()
        todo.name = item.title
        if item.description:
            todo.description = item.description

        <<map priority to ics scale>>
        <<set vtodo deadline and duration>>
        <<set vtodo status>>

        cal.todos.add(todo)

    return str(cal)
@

ICS priority 1 is highest, 9 is lowest. We normalize our effective
priority into this range.

<<map priority to ics scale>>=
ep = effective_priority(item)
if max_prio > 0:
    normalized = ep / max_prio
    ics_prio = max(1, min(9, round(
        9 - normalized * 8
    )))
else:
    ics_prio = 5
todo.priority = ics_prio
@

<<set vtodo deadline and duration>>=
if item.deadline:
    try:
        todo.due = datetime.datetime.fromisoformat(
            item.deadline
        )
    except ValueError:
        pass
if item.estimated:
    todo.duration = datetime.timedelta(
        hours=item.estimated
    )
@

<<set vtodo status>>=
status_map = {
    "pending": "NEEDS-ACTION",
    "in-progress": "IN-PROCESS",
    "done": "COMPLETED",
}
todo.status = status_map.get(
    item.status, "NEEDS-ACTION"
)
@

\subsection{The [[import]] Command}

The [[import]] command fetches GitHub issues and/or pull requests
and creates local todo items for them. This is the primary
onboarding path for GitHub-tracked work into the local priority
system.

The [[--skip-priority]] flag deserves special attention. When
importing many items, running interactive binary search for each
would be tedious. Instead, we offer an age-based heuristic:
older issues get lower priority, since items that have lingered
are implicitly less urgent than recent ones. We scale
[[created_at]] timestamps linearly across the batch so the
newest item gets the highest priority.

Imported items are assigned to the current user by default
(via [[--who]]), matching the behavior of [[todo add]] and
[[todo ls]]. This ensures that imported items appear when
filtering by worker, without requiring an extra flag.

<<github commands>>=
<<import command>>
<<sync command>>
<<note command>>
@

<<import command>>=
@cli.command(name="import")
def import_github(
    repo: str = typer.Argument(
        ..., help="GitHub repo (owner/repo)",
    ),
    number: Optional[int] = typer.Option(
        None, "--number", "-n",
        help="Import only this issue/PR number",
    ),
    label: Optional[List[str]] = typer.Option(
        None, "--label", "-l",
        help="Add label to imported items",
    ),
    item_type: str = typer.Option(
        "all", "--type", "-t",
        help="Import type: issue, pr, or all",
    ),
    skip_priority: bool = typer.Option(
        False, "--skip-priority",
        help="Use age-based priority heuristic",
    ),
    github_labels: bool = typer.Option(
        False, "--github-labels",
        help="Include GitHub labels on imported items",
    ),
    who: Optional[str] = typer.Option(
        default_username, "--who", "-w",
        help="Assign to worker (default: $USER)",
        autocompletion=complete_todo_workers,
    ),
):
    """Import GitHub issues/PRs as todo items."""
    require_gh()
    next_id, todos = load_todos()

    <<fetch github items>>
    <<filter already imported>>
    <<create todos from github items>>

    save_todos(next_id, todos)
    typer.echo(f"Imported {imported_count} items.")
@

When [[--number]] is given we bypass the list API entirely and
call [[gh_fetch_issue]] or [[gh_fetch_pr]] directly---fetching
100 items just to extract one is wasteful, especially in large
repos where pagination would be needed. The fetch functions do
not include [[number]] or [[createdAt]] in their JSON fields
(those were omitted because the view commands use them for
display, not import), so we inject both manually: [[number]]
comes from the CLI argument, and [[createdAt]] falls back to
[[GH_FALLBACK_CREATED]] so the age heuristic places the item
at the bottom if [[--skip-priority]] is used.

Issues and PRs share the same number sequence in a
repository---GitHub's internal model treats PRs as a
specialisation of issues---so [[#5]] is \emph{either} an issue
or a PR, never both. When [[--type]] is [[all]] and [[--number]]
is given, we cannot determine the type from the number alone. We
default to [[issue]] (by far the common case); the user must
pass [[--type pr]] explicitly to import a pull request.

<<fetch github items>>=
gh_items = []
if number is not None:
    <<fetch single github item>>
else:
    <<fetch all github items>>
@

<<fetch single github item>>=
effective_type = (
    item_type if item_type != "all" else "issue"
)
if effective_type == "pr":
    data = gh_fetch_pr(repo, number)
else:
    data = gh_fetch_issue(repo, number)
data["_type"] = effective_type
data["number"] = number
data["createdAt"] = GH_FALLBACK_CREATED
gh_items.append(data)
@

We fetch issues, PRs, or both depending on the [[--type]] flag:

<<fetch all github items>>=
if item_type in ("all", "issue"):
    for issue in gh_list_issues(repo):
        issue["_type"] = "issue"
        gh_items.append(issue)
if item_type in ("all", "pr"):
    for pr in gh_list_prs(repo):
        pr["_type"] = "pr"
        gh_items.append(pr)
@

We skip items that already exist locally by matching on repo,
type, and number---preventing duplicate imports:

<<filter already imported>>=
existing = set()
for t in todos:
    if t.github:
        key = (
            t.github["repo"],
            t.github.get("type", "issue"),
            t.github["number"],
        )
        existing.add(key)

new_items = []
for gh_item in gh_items:
    key = (
        repo,
        gh_item["_type"],
        str(gh_item["number"]),
    )
    if key not in existing:
        new_items.append(gh_item)
@

The age-based priority heuristic scales creation timestamps
linearly so that the newest item in the batch gets the highest
priority. If the user prefers interactive ranking, we fall back
to binary search.

When importing from multiple repositories, filtering by
repository is the most common need---but requiring users to
remember [[--label owner/repo]] on every import is error-prone.
We therefore always include the repository name as a label.
GitHub's own labels (such as [[bug]] or [[enhancement]]) tend
to be project-specific and would clutter a cross-repository
task list, so they are only included when [[--github-labels]]
is passed. Any additional labels supplied via [[--label]] on
the command line are appended last.

<<create todos from github items>>=
if skip_priority and new_items:
    <<compute age-based priorities>>
else:
    priorities = {}

imported_count = 0
for gh_item in new_items:
    gh_labels = [repo]
    if github_labels:
        gh_labels.extend(
            lb["name"]
            for lb in gh_item.get("labels", [])
        )
    if label:
        gh_labels.extend(label)

    gh_state = gh_item.get("state", "OPEN")
    status = (
        "done"
        if gh_state in ("CLOSED", "MERGED")
        else "pending"
    )

    if skip_priority:
        priority = priorities.get(
            gh_item["number"], 0.0
        )
    else:
        active = [
            t for t in todos
            if t.status != "done"
            and t.parent_id is None
        ]
        active.sort(
            key=effective_priority, reverse=True
        )
        priority = binary_search_priority(
            active, gh_item["title"]
        )

    item = TodoItem(
        id=next_id,
        title=gh_item["title"],
        labels=gh_labels,
        priority=priority,
        status=status,
        who=who,
        created=(
            datetime.datetime.now().isoformat()
        ),
        github={
            "repo": repo,
            "type": gh_item["_type"],
            "number": str(gh_item["number"]),
        },
    )
    todos.append(item)
    next_id += 1
    imported_count += 1
    typer.echo(
        f"  #{item.id} {item.title} "
        f"({gh_item['_type']} "
        f"#{gh_item['number']})"
    )
@

The age heuristic maps creation dates to priorities in the
range~$[0, 1]$, with the oldest item at~0 and the newest at~1.
Users can later reprioritize individual items to interleave
them with existing work.

Items missing a [[createdAt]] field (malformed API responses) get a
far-past fallback so they sort to the bottom of the batch:

<<constants>>=
GH_FALLBACK_CREATED = "2020-01-01T00:00:00Z"
@

<<compute age-based priorities>>=
timestamps = []
for gh_item in new_items:
    created = gh_item.get(
        "createdAt", GH_FALLBACK_CREATED
    )
    dt = datetime.datetime.fromisoformat(
        created.replace("Z", "+00:00")
    )
    timestamps.append((dt, gh_item["number"]))

timestamps.sort()
n = len(timestamps)
if n == 1:
    priorities = {timestamps[0][1]: 0.0}
else:
    priorities = {}
    for i, (dt, num) in enumerate(timestamps):
        priorities[num] = float(i) / (n - 1)
@

Let's verify that importing skips items already present locally,
preventing duplicates on repeated imports:

<<test functions>>=
def test_import_skips_duplicates(temp_todo_dir):
    """Import should not create duplicates."""
    save_todos(2, [
        TodoItem(
            id=1, title="Existing issue",
            created="2026-02-20T10:00:00",
            github={
                "repo": "owner/repo",
                "type": "issue",
                "number": "1",
            },
        ),
    ])
    mock_result = MagicMock()
    mock_result.returncode = 0
    mock_result.stdout = json.dumps([
        {
            "number": 1,
            "title": "Existing issue",
            "state": "OPEN",
            "labels": [],
            "createdAt": "2026-01-01T00:00:00Z",
        },
        {
            "number": 2,
            "title": "New issue",
            "state": "OPEN",
            "labels": [],
            "createdAt": "2026-02-01T00:00:00Z",
        },
    ])
    with patch(
        "nytid.cli.todo.subprocess.run",
        return_value=mock_result,
    ):
        result = runner.invoke(
            cli,
            [
                "import", "owner/repo",
                "--type", "issue",
                "--skip-priority",
            ],
        )
        assert result.exit_code == 0
        assert "1 items" in result.output
        _, todos = load_todos()
        assert len(todos) == 2
        assert todos[1].title == "New issue"
        assert todos[1].github["number"] == "2"
@

Using [[--number]] bypasses the list API and calls the view
command directly. Let's verify it creates exactly one item and
uses [[gh issue view]] rather than [[gh issue list]]:

<<test functions>>=
def test_import_single_issue(temp_todo_dir):
    """--number imports exactly one issue via view."""
    save_todos(1, [])
    mock_result = MagicMock()
    mock_result.returncode = 0
    mock_result.stdout = json.dumps({
        "title": "Specific bug",
        "body": "Repro steps here",
        "state": "OPEN",
        "comments": [],
        "labels": [{"name": "bug"}],
    })
    with patch(
        "nytid.cli.todo.subprocess.run",
        return_value=mock_result,
    ) as mock_run:
        result = runner.invoke(
            cli,
            [
                "import", "owner/repo",
                "--number", "7",
                "--type", "issue",
            ],
        )
        assert result.exit_code == 0
        assert "1 items" in result.output
        _, todos = load_todos()
        assert len(todos) == 1
        assert todos[0].title == "Specific bug"
        assert todos[0].github["number"] == "7"
        assert todos[0].github["type"] == "issue"
        # Verify view was called, not list
        calls = mock_run.call_args_list
        view_calls = [
            c for c in calls
            if "view" in c[0][0]
        ]
        assert len(view_calls) >= 1
@

\subsection{The [[sync]] Command}

The [[sync]] command implements bidirectional synchronization:
local done status propagates to GitHub (close the issue), and
GitHub closed status propagates locally (mark as done). Title
changes on GitHub are also pulled in.

We intentionally continue on individual item failures---a
network blip for one issue should not prevent syncing the rest.

<<sync command>>=
@cli.command()
def sync(
    repo: Optional[str] = typer.Option(
        None, "--repo", "-r",
        help="Only sync items from this repo",
    ),
):
    """Sync todo items with GitHub issues/PRs."""
    require_gh()
    next_id, todos = load_todos()
    changes = 0

    for item in todos:
        if not item.github:
            continue
        if repo and item.github["repo"] != repo:
            continue

        gh = item.github
        item_type = gh.get("type", "issue")
        try:
            if item_type == "pr":
                data = gh_fetch_pr(
                    gh["repo"], gh["number"]
                )
            else:
                data = gh_fetch_issue(
                    gh["repo"], gh["number"]
                )
        except Exception as e:
            typer.echo(
                f"  Warning: #{item.id} "
                f"fetch failed: {e}",
                err=True,
            )
            continue

        gh_state = data.get("state", "OPEN")
        <<sync state from github>>
        <<sync state to github>>
        <<sync title from github>>

    save_todos(next_id, todos)
    <<clean stale todo labels from all workers>>
    typer.echo(f"Sync complete. {changes} changes.")
@

GitHub is the authoritative source for issue state: a
collaborator may have closed the issue, or a linked pull request
may have been merged and triggered an auto-close. If GitHub
shows the item as closed but our local copy is still open, we
mark it done locally and stop any active time-tracking session:

<<sync state from github>>=
if (
    gh_state in ("CLOSED", "MERGED")
    and item.status != "done"
):
    item.status = "done"
    changes += 1
    typer.echo(
        f"  #{item.id} marked done "
        f"(closed on GitHub)"
    )
    try:
        from nytid.cli.track import (
            load_active_session,
            save_active_session,
            add_completed_entries,
        )
        worker = item.who or default_username
        session = load_active_session(worker)
        todo_label = f"todo:{item.id}"
        if todo_label in (
            session.get_active_labels()
        ):
            labels_to_stop = [todo_label] + [
                l for l in item.labels
                if l in (
                    session.get_active_labels()
                )
            ]
            entries = session.stop_labels(
                labels_to_stop
            )
            if entries:
                add_completed_entries(entries)
            save_active_session(
                session, worker
            )
    except ImportError:
        pass
@

Conversely, if our local item is done but GitHub still shows
it as open, we close it on GitHub:

<<sync state to github>>=
if (
    item.status == "done"
    and gh_state == "OPEN"
):
    try:
        if item_type == "pr":
            gh_close_pr(
                gh["repo"], gh["number"]
            )
        else:
            gh_close_issue(
                gh["repo"], gh["number"]
            )
        changes += 1
        typer.echo(
            f"  #{item.id} closed on GitHub"
        )
    except Exception as e:
        typer.echo(
            f"  Warning: #{item.id} "
            f"close failed: {e}",
            err=True,
        )
@

Title drift is common---someone edits the issue title on GitHub
and the local copy gets stale. We silently update to match:

<<sync title from github>>=
gh_title = data.get("title", "")
if gh_title and gh_title != item.title:
    old_title = item.title
    item.title = gh_title
    changes += 1
    typer.echo(
        f"  #{item.id} title updated: "
        f"{old_title} -> {gh_title}"
    )
@

We verify that sync correctly marks a local item as done when
the corresponding GitHub issue has been closed:

<<test functions>>=
def test_sync_marks_done_from_github(
    temp_todo_dir,
):
    """Sync marks local item done when GitHub is closed."""
    save_todos(2, [
        TodoItem(
            id=1, title="Old issue",
            created="2026-02-20T10:00:00",
            github={
                "repo": "owner/repo",
                "type": "issue",
                "number": "10",
            },
        ),
    ])
    auth_result = MagicMock()
    auth_result.returncode = 0
    view_result = MagicMock()
    view_result.returncode = 0
    view_result.stdout = json.dumps({
        "title": "Old issue",
        "body": "",
        "state": "CLOSED",
        "comments": [],
        "labels": [],
    })

    def mock_subprocess(cmd, **kwargs):
        if "auth" in cmd:
            return auth_result
        return view_result

    with patch(
        "nytid.cli.todo.subprocess.run",
        side_effect=mock_subprocess,
    ):
        result = runner.invoke(
            cli, ["sync"],
        )
        assert result.exit_code == 0
        assert "marked done" in result.output
        _, todos = load_todos()
        assert todos[0].status == "done"
@

\subsection{The [[note]] Command}

The [[note]] command adds a note to a todo item. For
GitHub-linked items, it posts a comment on the issue or PR. For
local items, it appends to the [[notes]] field.

Message source follows a priority chain: [[--message]] flag
first, [[--edit]] opens an editor, and if neither is given, we
read from standard input. This mirrors [[git commit]]'s
[[--message]] vs editor workflow.

<<note command>>=
@cli.command()
def note(
    todo_id: int = typer.Argument(
        ..., help="Todo ID to add note to",
        autocompletion=complete_todo_ids,
    ),
    message: Optional[str] = typer.Option(
        None, "--message", "-m",
        help="Note text (inline)",
    ),
    edit: bool = typer.Option(
        False, "--edit", "-E",
        help="Open editor for note",
    ),
):
    """Add a note to a todo item."""
    next_id, todos = load_todos()
    item = next(
        (t for t in todos if t.id == todo_id), None
    )
    if item is None:
        typer.echo(
            f"Error: todo #{todo_id} not found",
            err=True,
        )
        raise typer.Exit(1)

    <<resolve note text>>
    <<post or append note>>
@

<<resolve note text>>=
if message:
    note_text = message
elif edit:
    initial = ""
    if not item.github and item.notes:
        initial = item.notes
    note_text = open_editor(initial)
    if not note_text.strip():
        typer.echo("Empty note, cancelled.")
        raise typer.Exit(0)
else:
    note_text = sys.stdin.read()
    if not note_text.strip():
        typer.echo("Empty note, cancelled.")
        raise typer.Exit(0)
@

<<post or append note>>=
if item.github:
    require_gh()
    gh = item.github
    gh_add_comment(
        gh["repo"], gh["number"], note_text
    )
    typer.echo(
        f"Comment posted on "
        f"{gh['repo']}#{gh['number']}"
    )
else:
    if item.notes:
        item.notes += "\n" + note_text
    else:
        item.notes = note_text
    save_todos(next_id, todos)
    typer.echo(f"Note added to #{todo_id}")
@

The [[note]] command routes through two paths: GitHub comments for
linked items, or local append for unlinked items. Let's verify
both:

<<test functions>>=
def test_note_posts_github_comment(temp_todo_dir):
    """Note command posts comment for GitHub items."""
    save_todos(2, [
        TodoItem(
            id=1, title="GH issue",
            created="2026-02-20T10:00:00",
            github={
                "repo": "owner/repo",
                "type": "issue",
                "number": "5",
            },
        ),
    ])
    mock_result = MagicMock()
    mock_result.returncode = 0
    with patch(
        "nytid.cli.todo.subprocess.run",
        return_value=mock_result,
    ) as mock_run:
        result = runner.invoke(
            cli,
            ["note", "1", "-m", "Test comment"],
        )
        assert result.exit_code == 0
        assert "Comment posted" in result.output


def test_note_appends_locally(temp_todo_dir):
    """Note command appends for local items."""
    save_todos(2, [
        TodoItem(
            id=1, title="Local task",
            created="2026-02-20T10:00:00",
            notes="Existing note",
        ),
    ])
    result = runner.invoke(
        cli,
        ["note", "1", "-m", "New note"],
    )
    assert result.exit_code == 0
    _, todos = load_todos()
    assert "Existing note" in todos[0].notes
    assert "New note" in todos[0].notes
@

\section{Tests}

The test file follows the same pattern as [[track.nw]]: a fixture
provides a temporary directory, and test functions are collected from
throughout the document via chunk concatenation.

Because [[todo start]] and [[todo done]] delegate to the [[track]]
module (\cref{cli.track}), the [[temp_todo_dir]] fixture depends on
[[temp_tracking_dir]] (defined in [[conftest.py]], \cref{conftest})
so that tracking I/O is also redirected into a temporary directory.
Without this, every todo test that starts or stops a task would
write session files into the user's real [[~/.nytid/tracking/]]
directory.

<<test [[clitodo.py]]>>=
import datetime
import json
import os
import pathlib
import tempfile
import pytest
from typer.testing import CliRunner
from unittest.mock import patch, MagicMock

import sys

sys.path.insert(0, "../src")

from nytid.cli.todo import (
    cli,
    TodoItem,
    ParsedItem,
    ParsedTodo,
    effective_priority,
    load_todos,
    save_todos,
    get_todo_dir,
    get_todo_file,
    get_children,
    get_siblings,
    get_active_siblings,
    check_parent_completion,
    assign_priority_at_position,
    compute_child_priority_range,
    distribute_priorities_in_range,
    parse_editor_content,
    render_todo_markdown,
    parse_yaml_block,
    render_yaml_block,
    build_item_metadata,
    DEFAULT_MAX_BOOST,
    gh_available,
    gh_fetch_issue,
    gh_fetch_pr,
    gh_close_issue,
    gh_close_pr,
    gh_list_issues,
    gh_list_prs,
    gh_add_comment,
    require_gh,
    load_active_stack,
    save_active_stack,
    get_active_stack_file,
    find_pending_by_priority,
    parse_timeout,
    append_note_to_item,
)

runner = CliRunner()


@pytest.fixture
def temp_todo_dir(temp_tracking_dir):
    """Create a temporary directory for todo data
    during tests.

    Patches storage functions so all file operations
    resolve inside the temporary directory.

    Depends on [[temp_tracking_dir]] (from
    [[conftest.py]]) so that the [[todo start]] /
    [[todo done]] integration with the track module
    also writes to a temporary directory.
    """
    with tempfile.TemporaryDirectory() as tmpdir:
        temp_dir = pathlib.Path(tmpdir) / "todos"
        temp_file = temp_dir / "todos.json"

        with patch(
            "nytid.cli.todo.get_todo_dir",
            return_value=temp_dir,
        ), patch(
            "nytid.cli.todo.get_todo_file",
            return_value=temp_file,
        ), patch(
            "nytid.cli.todo.get_max_boost",
            return_value=DEFAULT_MAX_BOOST,
        ):
            yield temp_dir


# All test functions distributed throughout the document
<<test functions>>

# Integration tests
<<integration tests>>
@

\subsection{Integration Tests}

These tests exercise the CLI commands end-to-end through the Typer
test runner.

<<integration tests>>=
def test_add_and_ls(temp_todo_dir):
    """Test adding a todo and listing it."""
    result = runner.invoke(
        cli,
        ["add", "-t", "Test task", "-e", "2",
         "--top-level"],
        input="",
    )
    assert result.exit_code == 0
    assert "Added todo #1" in result.stdout

    result = runner.invoke(cli, ["ls"])
    assert result.exit_code == 0
    assert "Test task" in result.stdout


def test_done_command(temp_todo_dir):
    """Test marking a todo as done."""
    # First add a todo
    save_todos(2, [
        TodoItem(
            id=1, title="Task 1", status="pending",
            created="2026-02-20T10:00:00",
        ),
    ])

    result = runner.invoke(cli, ["done", "1"])
    assert result.exit_code == 0
    assert "Done:" in result.stdout

    _, todos = load_todos()
    assert todos[0].status == "done"


def test_rm_command(temp_todo_dir):
    """Test removing a todo with confirmation."""
    save_todos(2, [
        TodoItem(
            id=1, title="Remove me",
            created="2026-02-20T10:00:00",
        ),
    ])

    result = runner.invoke(
        cli, ["rm", "1", "--force"]
    )
    assert result.exit_code == 0
    assert "Removed" in result.stdout

    _, todos = load_todos()
    assert len(todos) == 0


def test_edit_command(temp_todo_dir):
    """Test editing a todo."""
    save_todos(2, [
        TodoItem(
            id=1, title="Original",
            created="2026-02-20T10:00:00",
        ),
    ])

    result = runner.invoke(
        cli,
        ["edit", "1", "--title", "Updated"],
    )
    assert result.exit_code == 0
    assert "Updated" in result.stdout

    _, todos = load_todos()
    assert todos[0].title == "Updated"


def test_export_vtodo(temp_todo_dir):
    """Test VTODO export."""
    save_todos(2, [
        TodoItem(
            id=1, title="Export me",
            priority=5.0,
            created="2026-02-20T10:00:00",
            estimated=2.0,
        ),
    ])

    result = runner.invoke(
        cli, ["export", "--format", "vtodo"]
    )
    assert result.exit_code == 0
    assert "VTODO" in result.stdout
    assert "Export me" in result.stdout


def test_parent_auto_done(temp_todo_dir):
    """When all children are done, parent is auto-done."""
    save_todos(4, [
        TodoItem(
            id=1, title="Parent",
            created="2026-02-20T10:00:00",
        ),
        TodoItem(
            id=2, title="Child 1", parent_id=1,
            status="done",
            created="2026-02-20T10:00:00",
        ),
        TodoItem(
            id=3, title="Child 2", parent_id=1,
            status="pending",
            created="2026-02-20T10:00:00",
        ),
    ])

    result = runner.invoke(cli, ["done", "3"])
    assert result.exit_code == 0
    assert "auto-completed" in result.stdout

    _, todos = load_todos()
    parent = next(t for t in todos if t.id == 1)
    assert parent.status == "done"


def test_todo_item_with_wd_env(temp_todo_dir):
    """Round-trip with wd, env, and command fields."""
    item = TodoItem(
        id=1, title="With context",
        created="2026-02-20T10:00:00",
        wd="/tmp/project",
        env={"PATH": "/usr/bin", "HOME": "/tmp"},
        command="opencode",
    )
    save_todos(2, [item])
    _, todos = load_todos()
    restored = todos[0]
    assert restored.wd == "/tmp/project"
    assert restored.env["PATH"] == "/usr/bin"
    assert restored.command == "opencode"


def test_start_no_spawn_without_wd(temp_todo_dir):
    """No subprocess when wd is None."""
    save_todos(2, [
        TodoItem(
            id=1, title="Simple task",
            created="2026-02-20T10:00:00",
        ),
    ])
    with patch(
        "nytid.cli.todo.subprocess"
    ) as mock_sub:
        result = runner.invoke(
            cli, ["start", "1"]
        )
        assert result.exit_code == 0
        mock_sub.run.assert_not_called()


def test_start_spawns_shell(temp_todo_dir):
    """Subprocess spawned when wd is set."""
    save_todos(2, [
        TodoItem(
            id=1, title="Shell task",
            created="2026-02-20T10:00:00",
            wd="/tmp",
        ),
    ])
    mock_result = MagicMock()
    mock_result.returncode = 0
    with patch(
        "nytid.cli.todo.subprocess.run",
        return_value=mock_result,
    ) as mock_run:
        result = runner.invoke(
            cli, ["start", "1"],
            input="n\n",
        )
        assert result.exit_code == 0
        mock_run.assert_called_once()
        call_kwargs = mock_run.call_args
        assert call_kwargs.kwargs["cwd"] == "/tmp"


def test_start_special_command(temp_todo_dir):
    """Claude/opencode get notes as prompt arg."""
    save_todos(2, [
        TodoItem(
            id=1, title="AI task",
            created="2026-02-20T10:00:00",
            wd="/tmp",
            command="claude",
            notes="Fix the login bug",
        ),
    ])
    mock_result = MagicMock()
    mock_result.returncode = 0
    with patch(
        "nytid.cli.todo.subprocess.run",
        return_value=mock_result,
    ) as mock_run:
        result = runner.invoke(
            cli, ["start", "1"],
            input="n\n",
        )
        assert result.exit_code == 0
        mock_run.assert_called_once()
        cmd = mock_run.call_args[0][0]
        assert cmd[0] == "claude"
        assert "Fix the login bug" in cmd[1]
@
