\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 fcntl
import json
import logging
import math
import os
import pathlib
import re
import shlex
import shutil
import signal
import subprocess
import sys
import tempfile
import typer
import typerconf as config
from typing import Annotated, List, Optional, Dict
from dataclasses import dataclass, field

from nytid import storage

<<constants>>
<<configuration>>

cli = typer.Typer(
    name="todo",
    help=(
        "Manage tasks with priorities and deadlines.\n\n"
        "Priority-based task list with working-directory capture,\n"
        "notes, and GitHub issue sync. Tasks can be started\n"
        "interactively or handed off to AI agents.\n\n"
        "Examples:\n\n\b"
        "  nytid todo add DD1310 -t \"Grade lab 3\"  # create a "
        "task\n"
        "  nytid todo ls                           # list by "
        "priority\n"
        "  nytid todo next                         # start "
        "highest-priority\n"
        "  nytid todo edit -E                      # edit active"
        " task in $EDITOR\n"
        "  nytid todo add tilkry27 prep -t \"Prep lecture 5\" --here\n"
        "                                          # add task"
        " similarly to at(1),\n"
        "                                          # spawn to "
        "same location when\n"
        "                                          # started.\n"
        "  nytid todo start 1 -W                   # start task #1"
        " in a new tmux\n"
        "                                          # window.\n"
        "  nytid todo note -m \"Waiting for reply\"  # annotate"
        " active task (#1)\n"
        "  nytid todo stop                         # pause (#1)"
        " and pop stack\n"
        "  nytid todo ls\n"
        "  nytid todo start 5                      # start task #5\n"
        "  nytid todo done                         # mark active"
        " task (#5) done\n"
        "  nytid todo import dbosk/nytid -n 236 --here\n"
        "                                          # import GitHub"
        " issue as task,\n"
        "                                          # spawn to same"
        " location when\n"
        "                                          # started."
    )
)

<<data model>>
<<effective priority>>
<<storage functions>>
<<active stack helpers>>
<<sub-item helpers>>
<<child priority helpers>>
<<editor infrastructure>>
<<binary search priority>>
<<autocompletion functions>>
<<helpers>>
<<argument and option definitions>>
<<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.

    Handles both string (``"mon,tue,wed"``) and list
    (``["mon", "tue", "wed"]``) config formats.
    """
    try:
        days_val = config.get(TODO_WORK_DAYS_CONFIG)
    except KeyError:
        days_val = DEFAULT_WORK_DAYS
    if isinstance(days_val, list):
        days_val = ",".join(str(d) for d in days_val)
    return [d.strip().lower()
            for d in days_val.split(",")]
@

Worker-specific defaults need a different treatment from the global
settings above.  A teacher may want every task for [[dan-claude]] to run
[[opencode]] in one working directory with one captured environment,
without baking those choices into each todo item.

We therefore read worker defaults lazily from paths of the form
[[todo.worker.<worker>.<attribute>]].  Missing keys simply mean
no default.  These values are consulted only when a loaded todo leaves
the corresponding attribute unset, so explicit task metadata still wins.
For [[env]] the config value should be a JSON object so it maps cleanly
to a Python dictionary.

<<configuration>>=
WORKER_DEFAULT_ATTRIBUTES = (
    "command",
    "wd",
    "env",
)


def get_worker_default(worker, attribute):
    """Return the configured default for one worker attribute."""
    if not worker or not attribute:
        return None
    try:
        value = config.get(
            f"todo.worker.{worker}.{attribute}"
        )
    except KeyError:
        return None

    if attribute == "env":
        if not isinstance(value, dict):
            return None
        return {
            str(key): str(val)
            for key, val in value.items()
        }
    return value
@

\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.

Worker defaults are runtime policy, not persisted task data.  That
distinction matters when an item is loaded, viewed, and then saved again:
we want the in-memory object to behave as if missing [[command]],
[[wd]], or [[env]] were filled from the worker's config, but we do not
want an unrelated edit to write those defaults into [[todos.json]].

We solve that tension inside [[TodoItem]] itself.  Attribute reads for a
small set of worker-scoped fields consult [[get_worker_default()]] when
the stored value is [[None]], while [[to_dict()]] serializes the raw
stored values from [[__dict__]] rather than the runtime-resolved ones.
This keeps worker defaults memory-only even when a loaded todo passes
through a save cycle.  In other words, loading applies policy, while
saving preserves only what the item explicitly stored before the load.

<<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 explicit values to a JSON-serializable dict."""
        return {
            f.name: self.__dict__.get(f.name)
            for f in self.__dataclass_fields__.values()
        }

    def __getattribute__(self, name):
        """Return explicit value or worker default.

        Only worker-scoped runtime fields participate in
        this fallback.
        """
        if name in WORKER_DEFAULT_ATTRIBUTES:
            raw = object.__getattribute__(
                self, "__dict__"
            ).get(name)
            if raw is None:
                worker = object.__getattribute__(
                    self, "__dict__"
                ).get("who") or default_username
                default = get_worker_default(
                    worker, name
                )
                if default is not None:
                    return default
        return object.__getattribute__(self, name)

    @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 still centers on [[todos.json]], but the active
hierarchy lives in a companion [[active_stack.json]].  The important
change is not the number of files but the authority relation between
them: [[active_stack.json]] owns execution state, while [[todos.json]]
stores task metadata plus a synchronized [[status]] field for display and
filtering.

That makes storage updates more subtle than a plain JSON overwrite.
Commands often need to update both files together, and a crash or
concurrent command between those writes would leave the task model in an
impossible state.  We therefore factor persistence into three layers:
raw loaders, synchronizers, and a locked mutation helper.

<<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.
    """
    next_id, todos, _ = load_todo_state()
    return next_id, todos


def save_todos(next_id: int, todos: List[TodoItem]):
    """Save todos to persistent storage."""
    def update(_next_id, _todos, stack):
        return next_id, todos, stack

    mutate_todo_state(update)
@

\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"


def test_load_applies_worker_defaults_in_memory(
    temp_todo_dir,
):
    """Load resolves worker defaults for unset runtime fields."""
    save_todos(2, [
        TodoItem(
            id=1,
            title="Agent task",
            who="dan-claude",
            created="2026-02-20T10:00:00",
        ),
    ])

    def mock_get(path):
        values = {
            "todo.worker.dan-claude.command": (
                "opencode run 'Use nytid todo.'"
            ),
            "todo.worker.dan-claude.wd": "/tmp/agent",
            "todo.worker.dan-claude.env": {
                "HOME": "/tmp/home",
                "MODE": "agent",
            },
        }
        if path in values:
            return values[path]
        raise KeyError(path)

    with patch("nytid.cli.todo.config.get", mock_get):
        _, todos = load_todos()
        item = todos[0]
        assert item.command == (
            "opencode run 'Use nytid todo.'"
        )
        assert item.wd == "/tmp/agent"
        assert item.env == {
            "HOME": "/tmp/home",
            "MODE": "agent",
        }


def test_explicit_values_override_worker_defaults(
    temp_todo_dir,
):
    """Explicit todo values win over worker defaults."""
    save_todos(2, [
        TodoItem(
            id=1,
            title="Agent task",
            who="dan-claude",
            command="claude",
            wd="/work/tree",
            env={"MODE": "manual"},
            created="2026-02-20T10:00:00",
        ),
    ])

    def mock_get(path):
        values = {
            "todo.worker.dan-claude.command": "opencode",
            "todo.worker.dan-claude.wd": "/tmp/agent",
            "todo.worker.dan-claude.env": {
                "MODE": "default"
            },
        }
        if path in values:
            return values[path]
        raise KeyError(path)

    with patch("nytid.cli.todo.config.get", mock_get):
        _, todos = load_todos()

    item = todos[0]
    assert item.command == "claude"
    assert item.wd == "/work/tree"
    assert item.env == {"MODE": "manual"}


def test_save_does_not_persist_worker_defaults(
    temp_todo_dir,
):
    """Worker defaults stay memory-only across a save cycle."""
    save_todos(2, [
        TodoItem(
            id=1,
            title="Agent task",
            who="dan-claude",
            created="2026-02-20T10:00:00",
        ),
    ])

    def mock_get(path):
        values = {
            "todo.worker.dan-claude.command": "opencode",
            "todo.worker.dan-claude.wd": "/tmp/agent",
            "todo.worker.dan-claude.env": {
                "MODE": "agent"
            },
        }
        if path in values:
            return values[path]
        raise KeyError(path)

    with patch("nytid.cli.todo.config.get", mock_get):
        next_id, todos = load_todos()
        save_todos(next_id, todos)

    with open(get_todo_file()) as handle:
        data = json.load(handle)

    stored = data["todos"][0]
    assert stored["command"] is None
    assert stored["wd"] is None
    assert stored["env"] is None
@

\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 an active stack of
todo IDs in [[active_stack.json]].

The active stack is the authority for execution state.  A todo is
[[in-progress]] iff its ID is present in the stack; otherwise it is
either [[pending]] or [[done]].  We still store [[status]] in
[[todos.json]] because many commands filter or display it, but we treat
that field as derived data and re-synchronize it from the active stack
whenever the todo state changes.

We keep the stack in a separate file because it models a different kind
of state: ephemeral execution context rather than task metadata.  That
separation is still useful, but it must not create a second source of
truth.  We therefore mutate [[todos.json]] and [[active_stack.json]]
together under a single lock.

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}

Those rules are easy to state but tricky to preserve under concurrent
commands.  The helper layer below therefore does more than basic file
I/O: it normalizes stale state, synchronizes derived fields, and applies
mutations under one lock so that both JSON files move together.

<<active stack helpers>>=
def get_todo_lock_file() -> pathlib.Path:
    """Return the todo-state lock file path."""
    return get_todo_dir() / "todo.lock"


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


def normalize_active_stack(
    todos: List[TodoItem], stack: List[int]
) -> List[int]:
    """Return a deduplicated active stack consistent with todos."""
    todo_by_id = {item.id: item for item in todos}
    seen = set()
    normalized = []
    for todo_id in stack:
        item = todo_by_id.get(todo_id)
        if item is None or item.status == "done":
            continue
        if todo_id in seen:
            continue
        normalized.append(todo_id)
        seen.add(todo_id)
    return normalized
@


Normalization performs only structural repair: remove missing or done
IDs and collapse duplicates while preserving order.  That keeps the
stack usable even after an interrupted command, without silently
inventing new active work.

<<active stack helpers>>=
def synchronize_todo_statuses(
    todos: List[TodoItem], stack: List[int]
):
    """Make [[status]] match the authoritative active stack."""
    active_ids = set(stack)
    for item in todos:
        if item.status == "done":
            continue
        if item.id in active_ids:
            item.status = "in-progress"
        else:
            item.status = "pending"
@


The stored [[status]] field is now a cache of stack membership rather
than an independent authority.  We preserve [[done]] exactly as written,
but every non-done item is reclassified from the active stack before the
rest of the CLI sees it.

<<active stack helpers>>=
def load_todos_raw() -> tuple:
    """Load todos without synchronizing derived state."""
    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 load_active_stack_raw() -> List[int]:
    """Load the active stack without consulting [[todos.json]]."""
    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 [int(todo_id) for todo_id in data]
        return []
    except (json.JSONDecodeError, OSError, ValueError):
        return []
@


The raw loaders above do not attempt to reconcile the two files.  That
job belongs to [[load_todo_state]], which combines them into one coherent
view before handing data to command logic.

<<active stack helpers>>=
def load_todo_state() -> tuple:
    """Load todos and active stack with synchronized status."""
    next_id, todos = load_todos_raw()
    stack = normalize_active_stack(
        todos, load_active_stack_raw()
    )
    synchronize_todo_statuses(todos, stack)
    return next_id, todos, stack
@


Once we have a coherent in-memory state, writes should either fully
succeed or leave the old files untouched.  We therefore use
temporary-file writes followed by [[os.replace]], which gives atomic
replacement on the local filesystem.

<<active stack helpers>>=
def write_json_atomically(
    path: pathlib.Path,
    data,
    *,
    indent: Optional[int] = None,
):
    """Write JSON data atomically to [[path]]."""
    ensure_todo_dir()
    fd, tmp_name = tempfile.mkstemp(
        prefix=f"{path.name}-",
        suffix=".tmp",
        dir=str(path.parent),
    )
    os.close(fd)
    try:
        with open(tmp_name, "w") as f:
            json.dump(data, f, indent=indent)
            f.flush()
            os.fsync(f.fileno())
        os.replace(tmp_name, path)
    finally:
        if os.path.exists(tmp_name):
            os.unlink(tmp_name)


def save_todo_state_unlocked(
    next_id: int,
    todos: List[TodoItem],
    stack: List[int],
):
    """Persist todos and active stack after synchronization."""
    normalized_stack = normalize_active_stack(todos, stack)
    synchronize_todo_statuses(todos, normalized_stack)
    write_json_atomically(
        get_todo_file(),
        {
            "next_id": next_id,
            "todos": [t.to_dict() for t in todos],
        },
        indent=2,
    )
    write_json_atomically(
        get_active_stack_file(),
        normalized_stack,
    )
@


Atomic file replacement is still not enough on its own: two processes
could each compute a valid next state from the same old snapshot and the
later writer would erase the earlier one.  [[mutate_todo_state]] solves
that by acquiring an exclusive lock around the entire read-modify-write
cycle.

<<active stack helpers>>=
def mutate_todo_state(callback):
    """Mutate todos and active stack under one lock."""
    ensure_todo_dir()
    lock_file = get_todo_lock_file()
    with open(lock_file, "w") as lock_handle:
        fcntl.flock(lock_handle, fcntl.LOCK_EX)
        try:
            next_id, todos, stack = load_todo_state()
            callback_result = callback(next_id, todos, stack)
            if callback_result is None:
                updated_next_id = next_id
                updated_todos = todos
                updated_stack = stack
                extra = ()
            else:
                updated_next_id = callback_result[0]
                updated_todos = callback_result[1]
                updated_stack = callback_result[2]
                extra = callback_result[3:]
            save_todo_state_unlocked(
                updated_next_id,
                updated_todos,
                updated_stack,
            )
            if extra:
                return (
                    updated_next_id,
                    updated_todos,
                    normalize_active_stack(
                        updated_todos, updated_stack
                    ),
                    *extra,
                )
            return (
                updated_next_id,
                updated_todos,
                normalize_active_stack(
                    updated_todos, updated_stack
                ),
            )
        finally:
            fcntl.flock(lock_handle, fcntl.LOCK_UN)


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

    Returns an empty list if the file doesn't exist.
    """
    _, _, stack = load_todo_state()
    return stack


def save_active_stack(stack: List[int]):
    """Save the active-task stack to persistent storage."""
    def update(next_id, todos, _stack):
        return next_id, todos, stack

    mutate_todo_state(update)
@

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_todos(8, [
        TodoItem(
            id=3, title="Three",
            created="2026-02-20T10:00:00",
        ),
        TodoItem(
            id=5, title="Five",
            created="2026-02-20T10:00:00",
        ),
        TodoItem(
            id=7, title="Seven",
            created="2026-02-20T10:00:00",
        ),
    ])
    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() == []


def test_active_stack_discards_unknown_ids(temp_todo_dir):
    """Stack normalization drops IDs missing from todos."""
    save_active_stack([5, 7, 3])
    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 and status reporting both need a consistent view of the
active hierarchy.  Since the active stack is authoritative, these
helpers read only the stack and never infer activity from the stored
[[status]] field:

<<sub-item helpers>>=
def get_active_todo_hierarchy(
    todos: List[TodoItem],
) -> List[TodoItem]:
    """Return the active todo chain from root to leaf."""
    by_id = {item.id: item for item in todos}
    stack = load_active_stack()
    return [by_id[todo_id] for todo_id in stack if todo_id in by_id]


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).
    """
    active = get_active_todo_hierarchy(todos)
    return active[-1] if active else 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 = 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 == -5.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.

Children are sorted by [[effective_priority]] in descending order
so the most important items appear first---matching the convention
used throughout the rest of the CLI (the [[ls]] command, task
selection in [[find_pending_by_priority]], etc.).

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=effective_priority,
        reverse=True,
    )
    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=effective_priority,
        reverse=True,
    )
    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 == "Run tests"
    assert parsed.children[1].title == "Download"


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 bottom - 1.0

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


def append_priority_after_siblings(
    siblings: List[TodoItem],
) -> float:
    """Return a base priority below all sibling bases."""
    if not siblings:
        return 1.0
    bottom = min(item.priority for item in siblings)
    return bottom - 1.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.
Priorities are unbounded floats---negative values are valid and
arise naturally when many items are appended sequentially at the
bottom.  If the numbers drift far from zero a future renumbering
pass can normalize them.

<<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()}"
        )
@

Several commands share the same convention as [[view]] and [[done]]:
an explicit ID wins, otherwise we act on the top of the active stack.
Extracting that policy into a helper keeps the user-facing error
message consistent and avoids three copies of the same stack lookup.

Commands that modify both todos and the active stack should do so under
one lock.  These helpers keep that pattern concise and make the active
hierarchy easy to render and query.

The small wrappers that follow separate three concerns.  First, generic
locked mutation plumbing via [[apply_todo_mutation]].  Second,
read-only policy helpers such as choosing the next top-level task or
rendering the current hierarchy.  Third, state-changing wrappers for the
common transitions activate, stop, and complete, so command bodies can
state their intent directly.

<<helpers>>=
def resolve_todo_id(todo_id, action):
    """Return an explicit todo ID or the active stack top.

    If ``todo_id`` is ``None``, use the top of the active stack.
    Exits with an actionable error if no active todo exists.
    """
    if todo_id is not None:
        return todo_id

    stack = load_active_stack()
    if not stack:
        typer.echo(
            "No active todo. "
            f"Specify an ID to {action}.",
            err=True,
        )
        raise typer.Exit(1)
    return stack[-1]
@

The first helper below hides the tuple-shuffling needed by
[[mutate_todo_state]]. Each command-specific callback can return its
updated state plus any extra values it still needs after the lock is
released.

<<helpers>>=
def apply_todo_mutation(callback):
    """Run a todo mutation under the todo-state lock."""
    result_holder = {}

    def update(next_id, todos, stack):
        callback_result = callback(next_id, todos, stack)
        if len(callback_result) == 3:
            result_holder["extra"] = ()
            return callback_result
        result_holder["extra"] = callback_result[3:]
        return callback_result[:3]

    next_id, todos, stack = mutate_todo_state(update)
    return (
        next_id,
        todos,
        stack,
        *result_holder.get("extra", ()),
    )
@

The next two helpers are read-only policy operations. One picks the
best top-level candidate when [[todo next]] has no active parent; the
other turns the authoritative stack into the nested text shown by
[[todo status]].

<<helpers>>=
def get_top_level_pending_by_priority(
    todos: List[TodoItem],
    who: Optional[str] = None,
) -> Optional[TodoItem]:
    """Return the highest-priority pending top-level todo."""
    candidates = [
        item for item in todos
        if item.parent_id is None and item.status == "pending"
    ]
    if who:
        candidates = [
            item for item in candidates
            if item.who and re.search(who, item.who)
        ]
    candidates.sort(
        key=effective_priority, reverse=True
    )
    return candidates[0] if candidates else None


def render_active_todo_hierarchy(
    items: List[TodoItem],
) -> List[str]:
    """Render the active hierarchy as indented status lines."""
    return [
        f"{'  ' * (depth + 1)}#{item.id} {item.title} [{item.status}]"
        for depth, item in enumerate(items)
    ]
@

The final trio are state transitions. Each one performs just one kind
of stack mutation and returns both the resolved item and the normalized
state that follows from it. This keeps the command bodies declarative:
[[start]] activates, [[stop]] pauses, and [[done]] completes.

<<helpers>>=
def activate_todo_state(todo_id):
    """Push [[todo_id]] onto the authoritative active stack."""

    def update(next_id, todos, stack):
        resolved_id = resolve_todo_id(todo_id, "start")
        item = next(
            (t for t in todos if t.id == resolved_id),
            None,
        )
        if item is None:
            typer.echo(
                f"Error: todo #{resolved_id} not found",
                err=True,
            )
            raise typer.Exit(1)
        if resolved_id not in stack:
            stack.append(resolved_id)
        return next_id, todos, stack, resolved_id, item

    next_id, todos, stack, resolved_id, item = apply_todo_mutation(update)
    return resolved_id, item, next_id, todos, stack


def stop_todo_state(todo_id):
    """Remove a todo from the authoritative active stack."""

    def update(next_id, todos, stack):
        if todo_id is None:
            if not stack:
                typer.echo(
                    "No active todo. Specify an ID to stop.",
                    err=True,
                )
                raise typer.Exit(1)
            resolved_id = stack[-1]
        else:
            resolved_id = todo_id
        item = next(
            (t for t in todos if t.id == resolved_id),
            None,
        )
        if item is None:
            typer.echo(
                f"Error: todo #{resolved_id} not found",
                err=True,
            )
            raise typer.Exit(1)
        stack = [sid for sid in stack if sid != resolved_id]
        return next_id, todos, stack, resolved_id, item

    next_id, todos, stack, resolved_id, item = apply_todo_mutation(update)
    return resolved_id, item, next_id, todos, stack


def complete_todo_state(todo_id):
    """Mark a todo done and remove it from the active stack."""

    def update(next_id, todos, stack):
        resolved_id = resolve_todo_id(todo_id, "mark done")
        item = next(
            (t for t in todos if t.id == resolved_id),
            None,
        )
        if item is None:
            typer.echo(
                f"Error: todo #{resolved_id} not found",
                err=True,
            )
            raise typer.Exit(1)
        item.status = "done"
        stack = [sid for sid in stack if sid != resolved_id]
        return next_id, todos, stack, resolved_id, item

    next_id, todos, stack, resolved_id, item = apply_todo_mutation(update)
    return resolved_id, item, next_id, todos, stack
@

When all children of a parent become done, the parent is auto-completed.
This runs as a separate locked mutation so that tracking labels can be
stopped between the child completion and the parent status change.

<<helpers>>=
def auto_complete_parent(item, todos):
    """Auto-complete the parent if all siblings are done.

    Runs a second locked mutation so tracking labels
    are stopped before the parent status changes.
    """
    if item.parent_id is None:
        return
    if not check_parent_completion(todos, item.parent_id):
        return
    parent_item = next(
        (t for t in todos if t.id == item.parent_id),
        None,
    )
    if parent_item is None or parent_item.status == "done":
        return

    stop_todo_tracking(parent_item.id, parent_item, todos)

    def mark_parent(next_id, todos, stack):
        parent = next(
            (t for t in todos
             if t.id == item.parent_id),
            None,
        )
        if parent and parent.status != "done":
            parent.status = "done"
            typer.echo(
                f"  Parent #{parent.id} "
                f"auto-completed: {parent.title}"
            )
        return next_id, todos, stack

    mutate_todo_state(mark_parent)
@

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.
The parser lives in [[track]] alongside other shared subprocess
helpers; we re-export it so that [[todo]]'s public API is unchanged.

<<helpers>>=
from nytid.cli.track import (
    parse_timeout,
    pane_opt, window_opt,
    block_opt, timeout_opt,
    wd_opt, env_opt, here_opt,
)
@

\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>>
<<status command>>
@

<<tracking commands>>=
<<start command>>
<<stop command>>
<<detached finalize 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.

<<argument and option definitions>>=
label_arg = typer.Argument(
    help="Labels",
    autocompletion=complete_labels)
@

<<add command>>=
@cli.command()
def add(
    label: Annotated[Optional[List[str]],
                     label_arg] = None,
    <<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:

<<argument and option definitions>>=
title_opt = typer.Option(
    "--title", "-t",
    help="Title (optional with --edit)")
@

<<add command options>>=
title: Annotated[Optional[str], title_opt] = None,
@

Deadlines and estimates help with scheduling and priority boosting:

<<argument and option definitions>>=
deadline_opt = typer.Option(
    "--deadline", "-d",
    help="Deadline (ISO datetime or YYYY-MM-DD)")
estimate_opt = typer.Option(
    "--estimate", "-e",
    help="Estimated hours")
description_opt = typer.Option(
    "--description",
    help="Body text of the item")
@

<<add command options>>=
deadline: Annotated[Optional[str],
                    deadline_opt] = None,
estimate: Annotated[Optional[float],
                    estimate_opt] = None,
description: Annotated[Optional[str],
                       description_opt] = "",
@

Most of the time we infer priority interactively by comparing the new
item with its siblings. Sometimes, however, the caller already knows the
numeric value or simply wants to add the item after the current lowest-
priority sibling. The [[--prio]] option handles the former case, while
[[--append]] keeps the latter non-interactive:

<<argument and option definitions>>=
prio_opt = typer.Option(
    "--prio",
    help="Set explicit priority")
append_opt = typer.Option(
    "--append",
    help="Append after lowest-priority sibling")
@

<<add command options>>=
explicit_priority: Annotated[Optional[float],
                             prio_opt] = None,
append: Annotated[bool, append_opt] = False,
@

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]]. For finer control, [[--wd]] stores only an explicit
directory while [[--env]] stores only the current environment. This
lets the user update one without silently rewriting the other. The
[[--command]] option sets a default command (such as [[claude]] or
[[opencode]]) to launch when starting the todo.  The same attributes can
also be supplied per worker in the config via
[[todo.worker.<worker>.wd]], [[todo.worker.<worker>.env]], and
[[todo.worker.<worker>.command]].

<<argument and option definitions>>=
command_opt = typer.Option(
    "--command", "-c",
    help="Default command to run on start "
         "(worker default: "
         "todo.worker.<worker>.command)")
@

<<add command options>>=
wd_path: Annotated[Optional[str], wd_opt] = None,
capture_env: Annotated[bool, env_opt] = False,
here: Annotated[bool, here_opt] = False,
run_command: Annotated[Optional[str],
                       command_opt] = None,
@

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.

<<argument and option definitions>>=
editor_opt = typer.Option(
    "--edit", "-E",
    help="Open interactive editor")
@

<<add command options>>=
edit_mode: Annotated[bool, editor_opt] = False,
@

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>>=
from nytid.cli import get_default_username
default_username = get_default_username()
@

Parent-related options control hierarchy placement:

<<argument and option definitions>>=
parent_opt = typer.Option(
    "--parent", "-p",
    help="Parent todo ID",
    autocompletion=complete_todo_ids)
top_level_opt = typer.Option(
    "--top-level",
    help="Add at root level "
         "(override auto-parenting)")
who_opt = typer.Option(
    "--who", "-w",
    help="Assign to worker (default: $USER)",
    autocompletion=complete_todo_workers)
@

Most commands use [[who_opt]] to assign work to a specific person.  The
[[next]] command, however, uses [[--who]] as a filter---it selects the next
task \emph{for} that person rather than reassigning anything.  A separate
[[who_filter_opt]] keeps the help text accurate for that context.

<<argument and option definitions>>=
who_filter_opt = typer.Option(
    "--who", "-w",
    help="Filter by assignee "
         "(default: $USER)",
    autocompletion=complete_todo_workers)
@

<<add command options>>=
parent: Annotated[Optional[int],
                  parent_opt] = None,
top_level: Annotated[bool, top_level_opt] = False,
who: Annotated[Optional[str],
               who_opt] = default_username,
@

Now the implementation. We first reject contradictory flags, then
resolve the parent ID, determine the priority, and finally save.

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

<<validate add options>>

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

Some combinations would give contradictory instructions. In
particular, [[--prio]] says 
\enquote{use exactly this numeric value} while
[[--append]] says 
\enquote{derive a value by placing the item after its
siblings}. Rather than silently picking one interpretation, we reject
the combination and ask the user to choose:

<<validate add options>>=
if explicit_priority is not None and append:
    typer.echo(
        "Error: --prio and --append cannot be used together",
        err=True,
    )
    raise typer.Exit(1)
@

The CLI-flag path is the original flow: resolve parent, determine the
priority, create a single item.  We copy [[title]] into
[[priority_title]] because the priority-resolution chunk is shared
with the editor path, where [[title]] is not yet set; both paths
provide the display name through [[priority_title]] instead.

<<add via cli flags>>=
if not title:
    typer.echo(
        "Error: --title is required "
        "(or use --edit)",
        err=True,
    )
    raise typer.Exit(1)
priority_title = title
<<resolve parent id>>
<<inherit from parent>>
<<resolve priority for new todo>>
<<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
@

Priority selection has three modes. [[--prio]] bypasses all inference.
[[--append]] keeps the operation non-interactive by placing the item at
the bottom of the current sibling list. Otherwise we fall back to the
interactive binary search, which remains the default because it
preserves the existing 
\enquote{compare against nearby tasks} workflow.

The append path works from stored base priorities, not
[[effective_priority]].  Deadline boost is intentionally temporary; if we
reused an effective score as a newly stored base priority, an urgent
sibling could donate its temporary boost to the appended item and change
the long-term ordering.

The empty-sibling case under a parent is different.  There the child
range is still completely unused, so 
placing the first child at the midpoint is not a failure to append.
Instead it preserves room on both sides for later siblings, mirroring
the same 
binary-search insertion strategy we use elsewhere: the first placement
creates a balanced starting point, and later children can still be added
above or below it without forcing an early renumbering.

<<resolve priority for new todo>>=
# Build a temporary item to find siblings
temp = TodoItem(
    id=0,
    title=priority_title,
    parent_id=resolved_parent,
)
siblings = get_active_siblings(todos, temp)
parent_ctx = (
    parent_item if resolved_parent is not None
    else None
)
if explicit_priority is not None:
    priority = explicit_priority
elif append:
    if siblings:
        priority = append_priority_after_siblings(
            siblings
        )
    elif parent_ctx is not None:
        high, low = compute_child_priority_range(
            todos, parent_ctx
        )
        priority = (high + low) / 2.0
    else:
        priority = 1.0
else:
    priority = binary_search_priority(
        siblings,
        priority_title,
        parent=parent_ctx, todos=todos,
    )
@

When [[--here]] is used, we capture the current working directory and
the full environment---just like UNIX [[at]].  The separate [[--wd]]
and [[--env]] flags let the user update only one side of that context.
If several are supplied, [[--here]] simply provides defaults that the
more specific flags can still override.

<<resolve wd and env for new todo>>=
resolved_wd = None
resolved_env = None
if here:
    resolved_wd = os.getcwd()
    resolved_env = dict(os.environ)
if wd_path:
    resolved_wd = os.path.abspath(wd_path)
if capture_env:
    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.

We intentionally do 
not render [[--env]] into the editor preamble: a captured process
environment is often large, noisy, and not something the user wants to
edit as front matter.  The flag still applies when saving, just without
dumping the whole environment into the buffer.

<<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)

priority_title = parsed.title
<<resolve parent id>>
<<inherit from parent>>
<<resolve priority for new todo>>
<<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 = None
if wd_path:
    resolved_wd = os.path.abspath(wd_path)
if here:
    resolved_env = dict(os.environ)
elif capture_env:
    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, assignee, and environment unless its own YAML block
overrides them.  We pass [[parent_who]] and [[parent_env]] 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.  Passing the environment explicitly serves
the same purpose for context restoration: if the parent captured an
environment, children created in the editor inherit it unless they say
otherwise. 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,
    parent_env=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.  Children inherit ``parent_env``
    unless they explicitly specify an ``env``
    block or request [[--here]]/[[--env]] on the
    command line before entering the editor.
    """
    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,
                child_env,
            )
@

Each child overrides its metadata (labels, deadline, working
directory, assignee, environment) 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.  Environment inheritance follows the same
logic: changing the directory should not silently replace the stored
environment, so [[wd]] and [[env]] are inherited independently.
<<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 = child_meta.get("env", parent_env)
@

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,
    parent_item.env,
)
@

\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).

<<argument and option definitions>>=
label_filter_arg = typer.Argument(
    help="Filter by label",
    autocompletion=complete_labels)
ls_done_opt = typer.Option(
    "--done", "-d",
    help="Include done items")
ls_all_opt = typer.Option(
    "--all", "-a",
    help="Show full tree including subtasks")
ls_count_opt = typer.Option(
    "-n",
    help="Limit output to top N items")
ls_who_opt = typer.Option(
    "--who", "-w",
    help="Filter by assignee regex "
         "(default: $USER, use '' for all)",
    autocompletion=complete_todo_workers)
ls_flat_opt = typer.Option(
    "--flat",
    help="Flat list (no tree); useful with --all")
ls_format_opt = typer.Option(
    "--format", "-f",
    help="Output format: table (auto-detects TTY),"
         " rich, csv, json")
ls_status_opt = typer.Option(
    "--status", "-s",
    help="Filter by status: "
         "pending, in-progress, done")
ls_prio_opt = typer.Option(
    "--prio", "-p",
    help="Show effective priority column")
ls_output_opt = typer.Option(
    "--output", "-o",
    help="Write CSV output to file")
@

<<list command>>=
@cli.command(name="ls")
def ls(
    label_filter: Annotated[Optional[List[str]],
                            label_filter_arg] = None,
    include_done: Annotated[bool,
                            ls_done_opt] = False,
    show_tree: Annotated[bool, ls_all_opt] = False,
    count: Annotated[Optional[int],
                     ls_count_opt] = 10,
    who_filter: Annotated[Optional[str],
                          ls_who_opt] = default_username,
    flat: Annotated[bool, ls_flat_opt] = False,
    output_format: Annotated[str,
                             ls_format_opt] = "table",
    status_filter: Annotated[Optional[str],
                             ls_status_opt] = None,
    show_prio: Annotated[bool, ls_prio_opt] = False,
    output: Annotated[Optional[str],
                      ls_output_opt] = None,
):
    """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",
        ),
    ])
    save_active_stack([2])
    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_rich_progress_stays_in_title_column(
    temp_todo_dir,
):
    """Rich progress summaries align with the title column.

    Without the optional priority column, the progress summary should
    stay visually under the parent title rather than under the
    assignee.
    """
    save_todos(4, [
        TodoItem(
            id=1, title="Parent task",
            created="2026-02-20T10:00:00",
            status="pending", who="me",
        ),
        TodoItem(
            id=2, title="Done child",
            created="2026-02-20T10:00:00",
            status="done", who="me",
            parent_id=1,
        ),
        TodoItem(
            id=3, title="Pending child",
            created="2026-02-20T10:00:00",
            status="pending", who="me",
            parent_id=1,
        ),
    ])
    result = runner.invoke(
        cli, ["ls", "--all", "--format", "rich", "--who", ""]
    )
    assert result.exit_code == 0
    lines = result.output.splitlines()
    parent_index = next(
        i for i, line in enumerate(lines)
        if "Parent task" in line
    )
    progress_index = next(
        i for i, line in enumerate(lines)
        if "[1/2 done]" in line
    )
    child_index = next(
        i for i, line in enumerate(lines)
        if "Pending child" in line
    )
    assert "me" not in lines[progress_index]
    assert parent_index < progress_index < child_index


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.

That progress summary must also land in the Title column when the
optional priority column is hidden. A fixed [[table.add_row("", "",
progress)]] only works when [[--prio]] is enabled; otherwise the
summary shifts into the [[Who]] column. We therefore compute the
leading empty cells from [[show_prio]] so the summary stays aligned
with the task title in both layouts. 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",
        )
        progress_row = [""]
        if show_prio:
            progress_row.append("")
        progress_row.append(progress)
        table.add_row(*progress_row)
    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?}

Because [[note]] and [[done]] follow the same convention, [[view]] now
uses the shared [[resolve_todo_id()]] helper rather than reimplementing
 the stack lookup inline.

<<argument and option definitions>>=
todo_id_arg = typer.Argument(
    help="Todo ID (default: stack top)",
    autocompletion=complete_todo_ids)
@

<<view command>>=
@cli.command()
def view(
    todo_id: Annotated[Optional[int],
                       todo_id_arg] = None,
):
    """View a todo item with full details.

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

    todo_id = resolve_todo_id(todo_id, "view")

    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}")
@

Children and grandchildren are sorted by [[effective_priority]] in
descending order, matching the convention used everywhere else in
the CLI.

<<display todo children>>=
children = sorted(
    get_children(todos, item.id),
    key=effective_priority,
    reverse=True,
)
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 = sorted(
            get_children(todos, child.id),
            key=effective_priority,
            reverse=True,
        )
        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.

Like [[view]], [[note]], and [[done]], [[edit]] treats an explicit ID
as authoritative but otherwise falls back to the active stack top.
The contrast between the two forms is the key rule:
[[todo edit 7 --title "Updated title"]] edits item~7 even if another
todo is active, while [[todo edit --title "Updated title"]] edits the
task at the top of the active stack.  This keeps the common
\enquote{resume what I was working on} workflow short without removing
the explicit escape hatch.

Without [[-E]], the command updates individual fields directly.  We
reuse the same context-capture options as [[add]]: [[--who]] changes
ownership, [[--wd]] stores an explicit working directory,
[[--env]] refreshes the stored environment,
[[--here]] captures the current directory and environment, and
[[--command]] updates the default command to launch when the task is
started.  If any of those three remain unset on the item, runtime lookup
still checks [[todo.worker.<worker>.wd]],
[[todo.worker.<worker>.env]], and
[[todo.worker.<worker>.command]].

<<argument and option definitions>>=
edit_label_opt = typer.Option(
    "--label", "-l",
    help="Replace all labels",
    autocompletion=complete_labels)
edit_who_opt = typer.Option(
    "--who", "-w",
    help="Assign to worker",
    autocompletion=complete_todo_workers)
edit_parent_opt = typer.Option(
    "--parent", "-p",
    help="New parent ID (0 = make top-level)")
@

<<edit command>>=
@cli.command()
def edit(
    todo_id: Annotated[Optional[int],
                       todo_id_arg] = None,
    edit_interactive: Annotated[bool,
                                editor_opt] = False,
    title: Annotated[Optional[str],
                     title_opt] = None,
    deadline: Annotated[Optional[str],
                        deadline_opt] = None,
    estimate: Annotated[Optional[float],
                        estimate_opt] = None,
    label: Annotated[Optional[List[str]],
                     edit_label_opt] = None,
    description: Annotated[Optional[str],
                           description_opt] = None,
    who: Annotated[Optional[str],
                   edit_who_opt] = None,
    wd_path: Annotated[Optional[str],
                       wd_opt] = None,
    capture_env: Annotated[bool, env_opt] = False,
    here: Annotated[bool, here_opt] = False,
    run_command: Annotated[Optional[str],
                           command_opt] = None,
    parent: Annotated[Optional[int],
                      edit_parent_opt] = None,
):
    """Edit an existing todo item.

    If no ID given, edits the currently active task
    (top of the active stack).
    """
    next_id, todos = load_todos()
    todo_id = resolve_todo_id(todo_id, "edit")
    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 who is not None:
    item.who = who
if here:
    item.wd = os.getcwd()
    item.env = dict(os.environ)
else:
    if wd_path:
        item.wd = os.path.abspath(wd_path)
    if capture_env:
        item.env = dict(os.environ)
if run_command is not None:
    item.command = run_command
@

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


def test_edit_defaults_to_active_todo(temp_todo_dir):
    """Edit without ID uses the active stack top."""
    save_todos(3, [
        TodoItem(
            id=1, title="Parent task",
            created="2026-02-20T10:00:00",
        ),
        TodoItem(
            id=2, title="Active task",
            created="2026-02-20T10:00:00",
        ),
    ])
    save_active_stack([1, 2])

    result = runner.invoke(
        cli,
        ["edit", "--title", "Updated active task"],
    )

    assert result.exit_code == 0
    assert "Updated todo #2" in result.output
    _, todos = load_todos()
    assert todos[0].title == "Parent task"
    assert todos[1].title == "Updated active task"


def test_edit_without_id_requires_active_todo(
    temp_todo_dir,
):
    """Edit without ID errors when no active todo exists."""
    save_todos(2, [
        TodoItem(
            id=1, title="Local task",
            created="2026-02-20T10:00:00",
        ),
    ])

    result = runner.invoke(
        cli,
        ["edit", "--title", "Updated title"],
    )

    assert result.exit_code == 1
    assert (
        "No active todo. Specify an ID to edit."
        in result.output
    )
@

\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.

Like the other single-item commands, an explicit ID overrides the
active stack, while omitting the ID means \enquote{reprioritize the task
I am currently working on}.

<<reprioritize command>>=
@cli.command()
def reprioritize(
    todo_id: Annotated[Optional[int],
                       todo_id_arg] = None,
):
    """Rerun binary search priority for a todo item.

    If no ID given, reprioritizes the currently active
    task (top of the active stack).

    Alias: reprio
    """
    next_id, todos = load_todos()
    todo_id = resolve_todo_id(todo_id, "reprioritize")
    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: Annotated[Optional[int],
                       todo_id_arg] = None,
):
    """Alias for reprioritize."""
    reprioritize(todo_id=todo_id)
@

Let's verify that [[reprioritize]] also defaults to the active stack
top:

<<test functions>>=
def test_reprioritize_defaults_to_active_todo(
    temp_todo_dir,
):
    """Reprioritize without ID uses the active stack top."""
    save_todos(3, [
        TodoItem(
            id=1, title="Later task",
            priority=2.0,
            created="2026-02-20T10:00:00",
        ),
        TodoItem(
            id=2, title="Active task",
            priority=9.0,
            created="2026-02-20T10:00:00",
        ),
    ])
    save_active_stack([1, 2])

    with patch(
        "nytid.cli.todo.binary_search_priority",
        return_value=4.5,
    ):
        result = runner.invoke(cli, ["reprioritize"])

    assert result.exit_code == 0
    assert "Reprioritized #2" in result.output


def test_reprioritize_without_id_requires_active_todo(
    temp_todo_dir,
):
    """Reprioritize without ID errors when no active todo exists."""
    save_todos(2, [
        TodoItem(
            id=1, title="Local task",
            created="2026-02-20T10:00:00",
        ),
    ])

    result = runner.invoke(cli, ["reprioritize"])

    assert result.exit_code == 1
    assert (
        "No active todo. Specify an ID to reprioritize."
        in result.output
    )
@

\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: Annotated[Optional[int],
                       todo_id_arg] = None,
):
    """Mark a todo item as done.

    If no ID given, completes the most recently
    started task (top of the active stack).
    """
    todo_id, item, next_id, todos, stack = complete_todo_state(todo_id)
    typer.echo(f"Done: #{todo_id} {item.title}")

    <<stop tracking if active>>
    <<close github issue>>
    auto_complete_parent(item, todos)
@

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(4, [
        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,
        ),
        TodoItem(
            id=3, title="Sibling",
            created="2026-02-20T10:00:00",
            status="pending",
            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]
@

Subitems inherit their parent's labels, so a child todo
typically shares labels like course codes with its parent.
The tracking session stores each label only once (dict-based),
so stopping a shared label in one todo silently removes it
from every other active todo that depends on it.

Three code paths need to stop tracking---[[done]]/[[rm]],
the post-subprocess finalizer, and the [[stop]] command---so
we factor the logic into a function rather than a reusable
chunk.  A function makes the dependencies explicit through
parameters: a missing argument is caught at call time, whereas
a chunk that implicitly references [[todo_id]] or [[todos]]
would only fail at runtime if a prerequisite chunk were
omitted.

The function collects labels that are still needed by other
todos on the active stack and excludes them from the stop
list, preventing a child from accidentally removing labels
shared with its parent.  These are the 
\enquote{protected} labels: labels that belong to the todo being
stopped, but also to some other todo that remains active.  For example,
if a parent and child both carry [[DD1310]], completing the child should
stop [[todo:child]] but leave [[DD1310]] running for the parent.

The caller is responsible for managing the active-todo stack; this
function only reads it to compute that protection set.  Once it has
decided which labels ought to stop, it delegates the actual tracking
mutation to the public helper in [[cli.track]].  This is the key
architectural boundary: [[cli.todo]] owns todo state and stack policy,
while [[cli.track]] owns all persistence and mutation of tracking data.
With that split, [[todo start]], [[todo stop]], and [[todo done]] become
wrappers around track semantics rather than a second implementation of
them.

<<helpers>>=
def stop_todo_tracking(todo_id, item, todos):
    """Stop tracking labels for a todo item.

    Protects labels still needed by other active
    todos on the stack.
    """
    try:
        from nytid.cli.track import (
            stop_tracking_labels_if_active,
        )

        worker = item.who or default_username
        todo_label = f"todo:{todo_id}"
        stack = normalize_active_stack(
            todos, load_active_stack_raw()
        )
        protected = set()
        for sid in stack:
            if sid == todo_id:
                continue
            stacked = next(
                (t for t in todos if t.id == sid),
                None,
            )
            if stacked:
                protected.update(stacked.labels)

        labels_to_stop = [todo_label] + [
            label for label in item.labels
            if label not in protected
        ]
        _, completed_entries, active_labels = (
            stop_tracking_labels_if_active(
                who=worker,
                labels=labels_to_stop,
                end_time=datetime.datetime.now(),
            )
        )
        if not completed_entries:
            return
        typer.echo(
            "  Stopped tracking: "
            f"{', '.join(active_labels)}"
        )
    except ImportError:
        pass
@

All three call sites delegate to [[stop_todo_tracking]]:

<<stop tracking if active>>=
stop_todo_tracking(todo_id, item, todos)
@

Let's verify the auto-completion case.  When the parent has only one
child, completing that child should first stop the child's tracking
labels, then auto-complete the parent and stop the parent's labels too.

<<test functions>>=
def test_done_preserves_parent_labels(temp_todo_dir):
    """Done on only child stops child and parent labels."""
    from nytid.cli.track import (
        load_active_session,
        save_active_session,
        ActiveSession,
        default_username,
    )

    worker = default_username
    save_todos(3, [
        TodoItem(
            id=1, title="Parent task",
            created="2026-02-20T10:00:00",
            status="in-progress",
            labels=["DD1310", "teaching"],
        ),
        TodoItem(
            id=2, title="Child task",
            created="2026-02-20T10:00:00",
            status="in-progress",
            parent_id=1,
            labels=["DD1310", "teaching"],
        ),
    ])
    save_active_stack([1, 2])

    # Simulate both todos being tracked
    session = ActiveSession()
    session.start_labels(
        ["DD1310", "teaching", "todo:1"],
        datetime.datetime(2026, 2, 20, 10, 0),
    )
    session.start_labels(
        ["todo:2"],
        datetime.datetime(2026, 2, 20, 10, 5),
    )
    save_active_session(session, worker)

    result = runner.invoke(cli, ["done"])
    assert result.exit_code == 0
    assert "Child" in result.output

    session = load_active_session(worker)
    active = session.get_active_labels()
    assert "todo:1" not in active
    assert "DD1310" not in active
    assert "teaching" not in active
    assert "todo:2" not in active
@

The sibling case still needs the old protection behavior.  If another
child remains pending, the parent is not auto-completed, so labels shared
between the completed child and the parent must remain active.

<<test functions>>=
def test_done_preserves_parent_labels_with_pending_sibling(
    temp_todo_dir,
):
    """Done on child preserves labels shared with active parent."""
    from nytid.cli.track import (
        load_active_session,
        save_active_session,
        ActiveSession,
        default_username,
    )

    worker = default_username
    save_todos(4, [
        TodoItem(
            id=1, title="Parent task",
            created="2026-02-20T10:00:00",
            status="in-progress",
            labels=["DD1310", "teaching"],
        ),
        TodoItem(
            id=2, title="Child task",
            created="2026-02-20T10:00:00",
            status="in-progress",
            parent_id=1,
            labels=["DD1310", "teaching"],
        ),
        TodoItem(
            id=3, title="Sibling task",
            created="2026-02-20T10:00:00",
            status="pending",
            parent_id=1,
            labels=["DD1310", "teaching"],
        ),
    ])
    save_active_stack([1, 2])

    session = ActiveSession()
    session.start_labels(
        ["DD1310", "teaching", "todo:1"],
        datetime.datetime(2026, 2, 20, 10, 0),
    )
    session.start_labels(
        ["todo:2"],
        datetime.datetime(2026, 2, 20, 10, 5),
    )
    save_active_session(session, worker)

    result = runner.invoke(cli, ["done"])
    assert result.exit_code == 0
    assert "Child" in result.output

    session = load_active_session(worker)
    active = session.get_active_labels()
    assert "todo:1" in active
    assert "DD1310" in active
    assert "teaching" in active
    assert "todo:2" not in active
@

That delegation removes the need for a separate stale-label sweep here.
If a todo wrapper decides that [[todo:N]] should stop, it should ask the
track module to stop it explicitly and immediately.  Keeping a hidden
cleanup pass in [[cli.todo]] would blur the boundary between todo state
and tracking state again.

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,
        )
@

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.

An omitted ID means \enquote{remove the currently active todo}, which mirrors
the way [[done]], [[note]], and [[edit]] already target the stack top.

<<argument and option definitions>>=
force_opt = typer.Option(
    "--force", "-f",
    help="Skip confirmation")
@

<<remove command>>=
@cli.command()
def rm(
    todo_id: Annotated[Optional[int],
                       todo_id_arg] = None,
    force: Annotated[bool, force_opt] = False,
):
    """Remove a todo item.

    If no ID given, removes the currently active task
    (top of the active stack).
    """
    next_id, todos = load_todos()
    todo_id = resolve_todo_id(todo_id, "remove")
    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>>

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

Let's verify both the explicit and active-stack forms of [[rm]]:

<<test functions>>=
def test_rm_defaults_to_active_todo(temp_todo_dir):
    """Remove without ID uses the active stack top."""
    save_todos(3, [
        TodoItem(
            id=1, title="Parent task",
            created="2026-02-20T10:00:00",
        ),
        TodoItem(
            id=2, title="Active task",
            created="2026-02-20T10:00:00",
        ),
    ])
    save_active_stack([1, 2])

    result = runner.invoke(
        cli, ["rm", "--force"]
    )

    assert result.exit_code == 0
    assert "Removed #2: Active task" in result.stdout
    _, todos = load_todos()
    assert len(todos) == 1
    assert todos[0].id == 1


def test_rm_without_id_requires_active_todo(temp_todo_dir):
    """Remove without ID errors when no active todo exists."""
    save_todos(2, [
        TodoItem(
            id=1, title="Local task",
            created="2026-02-20T10:00:00",
        ),
    ])

    result = runner.invoke(
        cli, ["rm", "--force"]
    )

    assert result.exit_code == 1
    assert (
        "No active todo. Specify an ID to remove."
        in result.output
    )
@

\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.

Because [[next]] delegates its launch step to [[start]], it also passes
through execution-mode options such as [[--headless]], [[--pane]], and
[[--window]].  This keeps the policy question 
\enquote{what should start?}
separate from the execution question \enquote{how should it run?}

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
  among top-level items only (filtered by [[--who]]).
\end{description}

If there is nothing to do (no pending task matches), the command logs a
warning and exits non-zero.  That makes failed scheduling decisions
visible to automation rather than silently returning success.

The [[block]] option defaults to [[None]] rather than a boolean so
that [[next_cmd]] can resolve it before delegating to [[start]]: tmux
launches ([[--pane]]/[[--window]]) default to non-blocking (detached),
while plain launches default to blocking.  Resolving here avoids
passing a [[None]] into [[start]], which would otherwise have to
repeat the same defaulting logic.

<<argument and option definitions>>=
headless_opt = typer.Option(
    "--headless",
    help="Non-interactive mode (no prompts)")
reassign_opt = typer.Option(
    "--reassign-to",
    help="Reassign to this user on "
         "timeout/failure")
@

<<next command>>=
@cli.command(name="next")
def next_cmd(
    who: Annotated[Optional[str],
                   who_filter_opt] = default_username,
    headless: Annotated[bool, headless_opt] = False,
    pane: Annotated[bool, pane_opt] = False,
    window: Annotated[bool, window_opt] = False,
    block: Annotated[Optional[bool],
                     block_opt] = None,
    run: Annotated[Optional[str],
                   command_opt] = None,
    timeout: Annotated[Optional[str],
                       timeout_opt] = None,
    reassign_to: Annotated[Optional[str],
                           reassign_opt] = default_username,
):
    """Start the next highest-priority task.

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

    <<find next task to start>>

    if target is None:
        logging.warning("No matching next todo found")
        raise typer.Exit(1)

    if block is None:
        block = not (pane or window)

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

<<find next task to start>>=
if active:
    target = find_pending_by_priority(
        todos, who=who, parent_id=active[-1].id
    )
else:
    target = get_top_level_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 top-level 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 warns and exits non-zero if no work matches."""
    save_todos(1, [])
    with patch("nytid.cli.todo.logging.warning") as mock_warning:
        result = runner.invoke(
            cli, ["next", "--who", ""]
        )
    assert result.exit_code == 1
    mock_warning.assert_called_once_with(
        "No matching next todo found"
    )


def test_next_silent_when_active_has_no_pending_child(
    temp_todo_dir,
):
    """next warns when active parent 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])
    with patch("nytid.cli.todo.logging.warning") as mock_warning:
        result = runner.invoke(
            cli, ["next", "--who", ""]
        )
    assert result.exit_code == 1
    mock_warning.assert_called_once_with(
        "No matching next todo found"
    )


def test_next_ignores_non_top_level_when_idle(temp_todo_dir):
    """Idle next ignores pending children without active parent."""
    save_todos(4, [
        TodoItem(
            id=1, title="Parent",
            created="2026-02-20T10:00:00",
            status="done",
        ),
        TodoItem(
            id=2, title="Child",
            created="2026-02-20T10:00:00",
            parent_id=1,
            priority=9.0,
        ),
        TodoItem(
            id=3, title="Top level",
            created="2026-02-20T10:00:00",
            priority=3.0,
        ),
    ])
    result = runner.invoke(
        cli, ["next", "--who", ""]
    )
    assert result.exit_code == 0
    assert "Top level" in result.output
@

\subsection{The [[status]] and [[start]] Commands}

The active stack is also what the user wants to inspect when asking
what is currently being worked on.  The [[status]] command mirrors
[[track status]], but instead of listing independent labels it shows the
active todo hierarchy from root to leaf.

This command intentionally trusts the stack rather than searching for
[[in-progress]] items.  That makes it a useful diagnostic tool too: if
the hierarchy looks wrong, the bug is in the authoritative execution
state rather than in a best-effort reconstruction.

<<status command>>=
@cli.command()
def status():
    """Show the currently active todo hierarchy."""
    _, todos = load_todos()
    active = get_active_todo_hierarchy(todos)
    if not active:
        typer.echo("No active todo")
        return

    typer.echo("Active todo hierarchy:")
    for line in render_active_todo_hierarchy(active):
        typer.echo(line)
@

Let's verify the two boundary cases: no active todo, and a nested
hierarchy that should be rendered in stack order.

<<test functions>>=
def test_status_reports_no_active_todo(temp_todo_dir):
    """status reports when the active stack is empty."""
    save_todos(2, [
        TodoItem(
            id=1, title="Task",
            created="2026-02-20T10:00:00",
        ),
    ])
    result = runner.invoke(cli, ["status"])
    assert result.exit_code == 0
    assert result.output.strip() == "No active todo"


def test_status_shows_active_hierarchy(temp_todo_dir):
    """status renders the active stack as a hierarchy."""
    save_todos(4, [
        TodoItem(
            id=1, title="Parent",
            created="2026-02-20T10:00:00",
        ),
        TodoItem(
            id=2, title="Child",
            created="2026-02-20T10:00:00",
            parent_id=1,
        ),
        TodoItem(
            id=3, title="Leaf",
            created="2026-02-20T10:00:00",
            parent_id=2,
        ),
    ])
    save_active_stack([1, 2, 3])
    result = runner.invoke(cli, ["status"])
    assert result.exit_code == 0
    assert "Active todo hierarchy:" in result.output
    assert "#1 Parent [in-progress]" in result.output
    assert "#2 Child [in-progress]" in result.output
    assert "#3 Leaf [in-progress]" in result.output
@

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 ordering now matters more than before.  We first update the
authoritative active stack under the todo lock, then start the matching
track labels.  This means that auto-parenting, [[todo status]], and
subsequent [[todo next]] decisions immediately see the new active
hierarchy even if the launched subprocess has not yet done anything.

The key enhancement: if the todo has a [[wd]], [[start]] automatically
spawns a subprocess---the stored [[command]], a [[--command]] override, or
[[$SHELL]]. This combines the patterns from launch-enabled
[[track start]] (tracking bookends around a subprocess) and
[[courses data]] shell (PS1 modification and [[wd]] control).
When the subprocess exits, tracking stops and blocking launches ask
whether the todo should be marked done.

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

As with [[view]] and friends, omitting the ID means \enquote{start the
task on top of the active stack}.  This is useful when tracking stopped but the
todo itself remains the current focus.

<<start command>>=
@cli.command()
def start(
    todo_id: Annotated[Optional[int],
                       todo_id_arg] = None,
    <<start command options>>
):
    """Start tracking time for a todo item.

    If no ID given, starts the currently active task
    (top of the active stack).

    If the todo has a working directory, spawns a
    shell or command in that directory with tracking.
    """
    todo_id, item, next_id, todos, stack = activate_todo_state(todo_id)

    <<start tracking via track module>>

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

    <<spawn subprocess if wd or run>>
@

Detached tmux cleanup happens after the pane process exits, so it cannot
reuse the in-memory todo item from the original [[start]] call.  We
therefore provide a hidden helper command that reloads the todo,
stops tracking, and applies the same post-run policy in a fresh process.

That helper must be robust against exactly the kinds of changes that may
happen during a long-running task.  The working directory might have been
renamed or deleted before the pane exits, so cleanup must not depend on
[[cd]] into [[wd]].  Likewise, [[nytid]] may be running inside a Poetry
environment or another non-system interpreter, so the detached helper
must reuse [[sys.executable]] rather than assuming [[python3]] points at
the correct environment.

<<helpers>>=
def finalize_detached_todo(
    todo_id, exit_code=1, exit_code_file=None
):
    """Finish a non-blocking tmux-backed todo run."""
    if exit_code_file is not None:
        exit_code = read_detached_exit_code(
            exit_code_file
        )
    next_id, todos, stack = apply_todo_mutation(
        lambda next_id, todos, stack: (
            next_id,
            todos,
            [sid for sid in stack if sid != todo_id],
        )
    )
    item = next(
        (t for t in todos if t.id == todo_id), None
    )
    if item is None:
        return

    result = type(
        "DetachedResult", (),
        {
            "returncode": exit_code,
            "stdout": None,
        },
    )()

    <<stop tracking after subprocess>>

    if item.status != "done":
        item.status = "pending"

    save_todos(next_id, todos)


def run_todo_tmux_command(*args, **kwargs):
    """Delegate tmux launches to the track module helper."""
    from nytid.cli.track import run_tmux_command
    return run_tmux_command(*args, **kwargs)


def read_detached_exit_code(exit_code_file):
    """Read a tmux-recorded exit code for detached todo cleanup."""
    try:
        from nytid.cli.track import read_tmux_exit_status
        return read_tmux_exit_status(exit_code_file)
    except ImportError:
        return 1


def build_detached_todo_finalize_command(
    todo_id, proc_wd, exit_code_file
):
    """Return shell command to finalize a detached todo run."""
    pythonpath = os.path.abspath(
        os.path.join(
            os.path.dirname(__file__), "..", "..", ".."
        )
    )
    return (
        f"PYTHONPATH={shlex.quote(pythonpath)} "
        f"{shlex.quote(sys.executable)} "
        "-c 'from nytid.cli.todo import cli; cli()' "
        f"_finalize-detached {todo_id} "
        f"--exit-code-file {shlex.quote(exit_code_file)}"
        f"; rm -f {shlex.quote(exit_code_file)}"
    )
@

<<argument and option definitions>>=
finalize_id_arg = typer.Argument(
    help="Todo ID to finalize")
finalize_exit_arg = typer.Argument(
    help="Exit code from the process")
finalize_file_opt = typer.Option(
    "--exit-code-file",
    help="File containing the exit code")
@

<<detached finalize command>>=
@cli.command(name="_finalize-detached", hidden=True)
def finalize_detached_cmd(
    todo_id: Annotated[int, finalize_id_arg],
    exit_code: Annotated[int,
                         finalize_exit_arg] = 1,
    exit_code_file: Annotated[Optional[str],
                              finalize_file_opt] = None,
):
    """Finalize a detached tmux-backed todo run."""
    finalize_detached_todo(
        todo_id, exit_code, exit_code_file
    )
@

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.
[[activate_todo_state]] checks membership before appending.

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]


def test_start_defaults_to_active_todo(temp_todo_dir):
    """Start without ID uses the active stack top."""
    save_todos(3, [
        TodoItem(
            id=1, title="Parent task",
            created="2026-02-20T10:00:00",
        ),
        TodoItem(
            id=2, title="Active task",
            created="2026-02-20T10:00:00",
        ),
    ])
    save_active_stack([1, 2])

    result = runner.invoke(cli, ["start"])

    assert result.exit_code == 0
    assert "Started: #2 Active task" in result.output
    _, todos = load_todos()
    assert todos[1].status == "in-progress"
    assert load_active_stack() == [1, 2]


def test_start_syncs_status_from_active_stack(temp_todo_dir):
    """Starting one todo clears stale in-progress status on others."""
    save_todos(3, [
        TodoItem(
            id=1, title="Old active",
            created="2026-02-20T10:00:00",
            status="in-progress",
        ),
        TodoItem(
            id=2, title="New active",
            created="2026-02-20T10:00:00",
        ),
    ])
    result = runner.invoke(cli, ["start", "2"])
    assert result.exit_code == 0
    _, todos = load_todos()
    assert todos[0].status == "pending"
    assert todos[1].status == "in-progress"
    assert load_active_stack() == [2]


def test_start_without_id_requires_active_todo(temp_todo_dir):
    """Start without ID errors when no active todo exists."""
    save_todos(2, [
        TodoItem(
            id=1, title="Local task",
            created="2026-02-20T10:00:00",
        ),
    ])

    result = runner.invoke(cli, ["start"])

    assert result.exit_code == 1
    assert (
        "No active todo. Specify an ID to start."
        in result.output
    )
@

The [[--command]] option lets the user override the todo's stored
command and any worker-config command from
[[todo.worker.<worker>.command]] at start time:

<<start command options>>=
run: Annotated[Optional[str],
               command_opt] = None,
@

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: Annotated[bool, headless_opt] = False,
@

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: Annotated[Optional[str],
                   timeout_opt] = None,
reassign_to: Annotated[Optional[str],
                       reassign_opt] = default_username,
@

Tmux gives us a middle ground between blocking the current terminal and
running fully headless.  [[--pane]] keeps the task visible beside the
current shell, while [[--window]] moves it to another tmux window in the
same session.  Because both modes rely on tmux owning the terminal, they
require [[TMUX]] to be set and cannot be combined with [[--headless]].
By default tmux launches detach immediately so the caller can keep using
other panes while the task remains tracked.  [[--block]] switches back to
the interactive completion workflow: when the pane command exits,
tracking stops and the user decides whether the task is done. Detached
tmux runs still stop tracking automatically and reset the todo to
pending unless it was already marked done from inside the pane.

<<start command options>>=
pane: Annotated[bool, pane_opt] = False,
window: Annotated[bool, window_opt] = False,
block: Annotated[Optional[bool],
                 block_opt] = None,
@

We reject impossible combinations before building the subprocess command.
This keeps the later chunks focused on one execution mode at a time and
produces clear CLI errors for mutually exclusive options.

<<validate tmux options>>=
try:
    from nytid.cli.track import validate_tmux_options
    validate_tmux_options(pane, window)
except ImportError:
    if pane and window:
        typer.echo(
            "Error: --pane and --window are mutually exclusive",
            err=True,
        )
        raise typer.Exit(1)
    if (pane or window) and not os.environ.get("TMUX"):
        typer.echo(
            "Error: --pane/--window require an active tmux session",
            err=True,
        )
        raise typer.Exit(1)
except ValueError as exc:
    typer.echo(str(exc), err=True)
    raise typer.Exit(1)
if (pane or window) and headless:
    typer.echo(
        "Error: --pane/--window cannot be used with --headless",
        err=True,
    )
    raise typer.Exit(1)
if block is None:
    block = not (pane or window)
if block and not (pane or window):
    pass
if not block and not (pane or window):
    typer.echo(
        "Error: --no-block requires --pane or --window",
        err=True,
    )
    raise typer.Exit(1)
@

We import and use the track module's start/stop helpers directly rather
than shelling out to the CLI. This keeps todo launches aligned with the
same on-disk locking and persistence semantics as the track commands.

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

    worker = item.who or default_username

    track_labels = list(item.labels) + [
        f"todo:{todo_id}"
    ]
    started, _, _ = start_tracking_labels(
        track_labels,
        start_time=datetime.datetime.now(),
        who=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]], when [[--command]] is given, or when the user
explicitly requests [[--pane]]/[[--window]], 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 should only be injected when we are actually launching an
interactive shell.  Arbitrary commands such as [[claude]], [[opencode]],
[[echo]], or [[false]] must run exactly as resolved; otherwise the shared
shell helper would incorrectly replace the requested command with the
user's login shell.

<<spawn subprocess if wd or run>>=
if item.wd or run or pane or window:
    <<validate tmux options>>
    <<prepare subprocess environment>>
    <<determine command to run>>
    if pane or window:
        <<run in tmux>>
        if block:
            <<handle subprocess exit>>
    else:
        <<adjust shell command for prompt>>
        <<run subprocess>>
        <<handle subprocess exit>>
@

Todo launches now follow the same context-building path as track launches.
We compute the textual todo context once, apply it to [[PS1]], and then reuse
the same value for tmux window names.  That keeps direct shells and [[-W]]
windows visually consistent.

<<prepare subprocess environment>>=
from nytid.cli.track import (
    apply_context_prompt,
    build_context_name,
)

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]
todo_context = build_context_name(
    "todo", [str(todo_id)]
)
apply_context_prompt(todo_context, proc_env)
proc_wd = item.wd or os.getcwd()
prompt_file = None
prompt_dir = None
@

The command resolution follows a priority chain: [[--command]] overrides
the stored [[command]], which overrides [[$SHELL]].  We parse command
strings with [[shlex.split]] so quoted arguments survive intact.
When the resolved launcher is [[claude]] or [[opencode]] and the user
specified only the bare command, we append the todo's notes or
description as a prompt argument.  If the user already supplied flags
such as [[-p]], we respect that command line exactly.

This gives two distinct workflows.  Storing plain [[claude]] means the
todo text itself is the prompt source, so later edits to notes or
description automatically change what the agent sees.  Storing
[[claude -p "..."]], by contrast, freezes the prompt in the command,
which is useful when the launcher instruction should stay stable even if
the todo text evolves.

<<determine command to run>>=
from nytid.cli.track import build_plain_shell_command

cmd_str = run or item.command
if cmd_str:
    prompt_text = item.notes or item.description
    cmd = shlex.split(cmd_str)
    if not cmd:
        cmd = build_plain_shell_command(proc_env)
    elif cmd[0] in ("claude", "opencode"):
        has_prompt = any(
            part in ("-p", "--prompt")
            for part in cmd[1:]
        )
        if prompt_text and not has_prompt:
            cmd = cmd + [prompt_text]
else:
    cmd = build_plain_shell_command(proc_env)
@

Tmux gives the subprocess its own terminal while keeping the launch
under our control.  We reuse the [[track]] module's helper so both
commands share the same pane startup handshake, exit-status recording,
and blocking semantics.  For detached todo runs we swap the blocking
wait channel for a follow-up shell command that re-enters [[todo]] and
applies the pending/done cleanup policy after the pane process exits.
Blocked launches now rejoin the same post-exit path as direct
subprocesses: once tmux reports that the pane session is over, we still
reload the todo state, stop tracking, and ask whether the item should be
marked done.

<<run in tmux>>=
try:
    exit_code_file = None
    if block:
        result = type(
            "TmuxResult", (),
            {
                "returncode": run_todo_tmux_command(
                    cmd,
                    cwd=proc_wd,
                    env=proc_env,
                    window=window,
                    window_name=(
                        todo_context
                        if window else None
                    ),
                ),
                "stdout": None,
            },
        )()
    else:
        exit_fd, exit_code_file = tempfile.mkstemp(
            prefix="nytid-todo-",
            suffix=".status",
        )
        os.close(exit_fd)
        finalize_cmd = build_detached_todo_finalize_command(
            todo_id, proc_wd, exit_code_file
        )
        run_todo_tmux_command(
            cmd,
            cwd=proc_wd,
            env=proc_env,
            window=window,
            window_name=(
                todo_context
                if window else None
            ),
            block=False,
            exit_status_file=exit_code_file,
            on_exit_shell=finalize_cmd,
        )
        result = None
except subprocess.CalledProcessError as exc:
    typer.echo(
        f"tmux failed with exit code {exc.returncode}",
        err=True,
    )
    if exit_code_file is not None:
        try:
            os.unlink(exit_code_file)
        except OSError:
            pass
    raise typer.Exit(1)
except FileNotFoundError:
    typer.echo("tmux not found", err=True)
    if exit_code_file is not None:
        try:
            os.unlink(exit_code_file)
        except OSError:
            pass
    raise typer.Exit(1)
@

Now let's verify tmux integration starts the subprocess in tmux instead
of attaching it to the current terminal:

<<test functions>>=
def test_start_pane_without_command_uses_plain_tmux_shell(
    temp_todo_dir,
):
    """A tmux todo shell launch reuses the shared plain shell helper."""
    save_todos(2, [
        TodoItem(
            id=1, title="Review PR",
            created="2026-02-20T10:00:00",
            wd="/tmp/project",
        ),
    ])
    with patch.dict(
        os.environ,
        {"TMUX": "session", "SHELL": "/bin/bash"},
        clear=True,
    ):
        with patch(
            "nytid.cli.todo.run_todo_tmux_command",
            return_value=0,
        ) as mock_run:
            result = runner.invoke(
                cli, ["start", "1", "--pane"]
            )
    assert result.exit_code == 0
    mock_run.assert_called_once()
    call_kwargs = mock_run.call_args.kwargs
    assert mock_run.call_args.args == (["/bin/bash"],)
    assert call_kwargs["cwd"] == "/tmp/project"
    assert call_kwargs["env"] == {
        "PS1": "todo:1 \\w\\n$ ",
        "SHELL": "/bin/bash",
        "TMUX": "session",
    }
    assert call_kwargs["window"] is False
    assert call_kwargs["window_name"] is None
    assert call_kwargs["block"] is False
    assert "_finalize-detached 1 --exit-code-file" in call_kwargs["on_exit_shell"]


def test_start_pane_calls_tmux_split(temp_todo_dir):
    """--pane uses tmux split-window in detached mode by default."""
    save_todos(2, [
        TodoItem(
            id=1, title="Review PR",
            created="2026-02-20T10:00:00",
            wd="/tmp/project",
            command="make test",
        ),
    ])
    with patch.dict(os.environ, {"TMUX": "session"}, clear=True):
        with patch(
            "nytid.cli.todo.run_todo_tmux_command",
            return_value=0,
        ) as mock_run:
            result = runner.invoke(
                cli, ["start", "1", "--pane"]
            )
    assert result.exit_code == 0
    mock_run.assert_called_once()
    call_kwargs = mock_run.call_args.kwargs
    assert mock_run.call_args.args == (["make", "test"],)
    assert call_kwargs["cwd"] == "/tmp/project"
    assert call_kwargs["env"] == {
        "PS1": "todo:1 \\w\\n$ ",
        "TMUX": "session",
    }
    assert call_kwargs["window"] is False
    assert call_kwargs["window_name"] is None
    assert call_kwargs["block"] is False
    assert "_finalize-detached 1 --exit-code-file" in call_kwargs["on_exit_shell"]


def test_start_window_calls_tmux_new_window(
    temp_todo_dir,
):
    """--window uses tmux new-window in detached mode by default."""
    save_todos(2, [
        TodoItem(
            id=1, title="Review PR",
            created="2026-02-20T10:00:00",
            wd="/tmp/project",
            command="python -m pytest",
        ),
    ])
    with patch.dict(os.environ, {"TMUX": "session"}, clear=True):
        with patch(
            "nytid.cli.todo.run_todo_tmux_command",
            return_value=0,
        ) as mock_run:
            result = runner.invoke(
                cli, ["start", "1", "--window"]
            )
    assert result.exit_code == 0
    mock_run.assert_called_once()
    call_kwargs = mock_run.call_args.kwargs
    assert mock_run.call_args.args == (["python", "-m", "pytest"],)
    assert call_kwargs["cwd"] == "/tmp/project"
    assert call_kwargs["env"] == {
        "PS1": "todo:1 \\w\\n$ ",
        "TMUX": "session",
    }
    assert call_kwargs["window"] is True
    assert call_kwargs["window_name"] == "todo:1"
    assert call_kwargs["block"] is False
    assert "_finalize-detached 1 --exit-code-file" in call_kwargs["on_exit_shell"]


def test_start_pane_without_tmux_fails(temp_todo_dir):
    """--pane requires an active tmux session."""
    save_todos(2, [
        TodoItem(
            id=1, title="Review PR",
            created="2026-02-20T10:00:00",
            wd="/tmp/project",
        ),
    ])
    with patch.dict(os.environ, {}, clear=True):
        result = runner.invoke(
            cli, ["start", "1", "--pane"]
        )
    assert result.exit_code == 1
    assert (
        "require an active tmux session"
        in result.output
    )


def test_start_pane_and_window_mutually_exclusive(
    temp_todo_dir,
):
    """--pane and --window cannot be combined."""
    save_todos(2, [
        TodoItem(
            id=1, title="Review PR",
            created="2026-02-20T10:00:00",
            wd="/tmp/project",
        ),
    ])
    with patch.dict(os.environ, {"TMUX": "session"}):
        result = runner.invoke(
            cli,
            ["start", "1", "--pane", "--window"],
        )
    assert result.exit_code == 1
    assert (
        "--pane and --window are mutually exclusive"
        in result.output
    )


def test_start_pane_and_headless_mutually_exclusive(
    temp_todo_dir,
):
    """--pane and --headless cannot be combined."""
    save_todos(2, [
        TodoItem(
            id=1, title="Review PR",
            created="2026-02-20T10:00:00",
            wd="/tmp/project",
        ),
    ])
    with patch.dict(os.environ, {"TMUX": "session"}):
        result = runner.invoke(
            cli,
            ["start", "1", "--pane", "--headless"],
        )
    assert result.exit_code == 1
    assert (
        "cannot be used with --headless"
        in result.output
    )


def test_start_no_block_requires_tmux_mode(
    temp_todo_dir,
):
    """--no-block requires --pane or --window."""
    save_todos(2, [
        TodoItem(
            id=1, title="Review PR",
            created="2026-02-20T10:00:00",
            wd="/tmp/project",
            command="make test",
        ),
    ])
    result = runner.invoke(
        cli,
        ["start", "1", "--no-block"],
    )
    assert result.exit_code == 1
    assert "--no-block requires --pane or --window" in result.output


def test_start_pane_no_block_uses_tmux_helper(
    temp_todo_dir,
):
    """Detached tmux start uses the shared tmux helper."""
    save_todos(2, [
        TodoItem(
            id=1, title="Review PR",
            created="2026-02-20T10:00:00",
            wd="/tmp/project",
            command="make test",
        ),
    ])
    with patch.dict(os.environ, {"TMUX": "session"}, clear=True):
        with patch(
            "nytid.cli.todo.run_todo_tmux_command",
            return_value=None,
        ) as mock_tmux:
            result = runner.invoke(
                cli,
                ["start", "1", "--pane", "--no-block"],
            )
    assert result.exit_code == 0
    assert mock_tmux.call_args.kwargs["block"] is False
    assert "on_exit_shell" in mock_tmux.call_args.kwargs
    assert "exit_status_file" in mock_tmux.call_args.kwargs
    assert "_finalize-detached 1 --exit-code-file" in (
        mock_tmux.call_args.kwargs["on_exit_shell"]
    )


def test_start_window_block_runs_exit_handling(
    temp_todo_dir,
):
    """Blocked tmux launches still prompt and save completion."""
    save_todos(2, [
        TodoItem(
            id=1,
            title="Review PR",
            created="2026-02-20T10:00:00",
            wd="/tmp/project",
            command="python -m pytest",
            status="in-progress",
        ),
    ])
    save_active_stack([1])

    with patch.dict(os.environ, {"TMUX": "session"}, clear=True):
        with patch(
            "nytid.cli.todo.run_todo_tmux_command",
            return_value=0,
        ) as mock_tmux:
            with patch(
                "nytid.cli.todo.typer.confirm",
                return_value=True,
            ) as mock_confirm:
                result = runner.invoke(
                    cli, ["start", "1", "--window", "--block"]
                )

    assert result.exit_code == 0
    mock_tmux.assert_called_once_with(
        ["python", "-m", "pytest"],
        cwd="/tmp/project",
        env={
            "PS1": "todo:1 \\w\\n$ ",
            "TMUX": "session",
        },
        window=True,
        window_name="todo:1",
    )
    mock_confirm.assert_called_once_with(
        "Mark todo #1 as done?"
    )
    _, todos = load_todos()
    assert todos[0].status == "done"
    assert load_active_stack() == []


def test_start_window_block_resets_pending_when_not_done(
    temp_todo_dir,
):
    """Blocked tmux launches reset unfinished todos to pending."""
    save_todos(2, [
        TodoItem(
            id=1,
            title="Review PR",
            created="2026-02-20T10:00:00",
            wd="/tmp/project",
            command="python -m pytest",
            status="in-progress",
        ),
    ])
    save_active_stack([1])

    with patch.dict(os.environ, {"TMUX": "session"}, clear=True):
        with patch(
            "nytid.cli.todo.run_todo_tmux_command",
            return_value=0,
        ):
            with patch(
                "nytid.cli.todo.typer.confirm",
                return_value=False,
            ):
                result = runner.invoke(
                    cli, ["start", "1", "--window", "--block"]
                )

    assert result.exit_code == 0
    _, todos = load_todos()
    assert todos[0].status == "pending"
    assert load_active_stack() == []


def test_finalize_detached_todo_resets_pending(
    temp_todo_dir,
):
    """Detached finalize resets unfinished todos to pending."""
    save_todos(2, [
        TodoItem(
            id=1,
            title="Review PR",
            created="2026-02-20T10:00:00",
            status="in-progress",
            labels=["review"],
        ),
    ])
    save_active_stack([1])
    finalize_detached_todo(1, 0)
    _, todos = load_todos()
    assert todos[0].status == "pending"
    assert load_active_stack() == []


def test_finalize_detached_todo_reads_status_file(
    temp_todo_dir,
):
    """Detached finalize can read exit code from status file."""
    save_todos(2, [
        TodoItem(
            id=1,
            title="Review PR",
            created="2026-02-20T10:00:00",
            status="in-progress",
            labels=["review"],
        ),
    ])
    save_active_stack([1])
    with tempfile.NamedTemporaryFile("w", delete=False) as f:
        f.write("status=7\nsignal=\n")
        status_path = f.name
    try:
        finalize_detached_todo(
            1, exit_code_file=status_path
        )
    finally:
        os.unlink(status_path)
    _, todos = load_todos()
    assert todos[0].status == "pending"


def test_read_detached_exit_code_parses_signal(
    temp_todo_dir,
):
    """Detached exit-code reader uses tmux signal mapping."""
    with tempfile.NamedTemporaryFile("w", delete=False) as f:
        f.write("status=\nsignal=9\n")
        status_path = f.name
    try:
        assert read_detached_exit_code(status_path) == 137
    finally:
        os.unlink(status_path)


def test_finalize_detached_todo_keeps_done(
    temp_todo_dir,
):
    """Detached finalize keeps todos already marked done."""
    save_todos(2, [
        TodoItem(
            id=1,
            title="Review PR",
            created="2026-02-20T10:00:00",
            status="done",
            labels=["review"],
        ),
    ])
    save_active_stack([1])
    finalize_detached_todo(1, 0)
    _, todos = load_todos()
    assert todos[0].status == "done"
    assert load_active_stack() == []


def test_next_pane_forwards_to_start(temp_todo_dir):
    """next forwards --pane to start."""
    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",
        ),
    ])
    with patch("nytid.cli.todo.start") as mock_start:
        result = runner.invoke(
            cli, ["next", "--who", "", "--pane"]
        )
    assert result.exit_code == 0
    call_kwargs = mock_start.call_args.kwargs
    assert call_kwargs["todo_id"] == 2
    assert call_kwargs["pane"] is True
    assert call_kwargs["window"] is False
    assert call_kwargs["block"] is False


def test_next_window_forwards_to_start(temp_todo_dir):
    """next forwards --window to start."""
    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",
        ),
    ])
    with patch("nytid.cli.todo.start") as mock_start:
        result = runner.invoke(
            cli, ["next", "--who", "", "--window"]
        )
    assert result.exit_code == 0
    call_kwargs = mock_start.call_args.kwargs
    assert call_kwargs["todo_id"] == 2
    assert call_kwargs["pane"] is False
    assert call_kwargs["window"] is True
    assert call_kwargs["block"] is False


def test_next_no_block_forwards_to_start(
    temp_todo_dir,
):
    """next forwards --no-block to start."""
    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",
        ),
    ])
    with patch("nytid.cli.todo.start") as mock_start:
        result = runner.invoke(
            cli,
            ["next", "--who", "", "--pane", "--no-block"],
        )
    assert result.exit_code == 0
    call_kwargs = mock_start.call_args.kwargs
    assert call_kwargs["block"] is False
@

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.

We now reuse the shared prompt-shell helper from [[track]] so that todo
and track windows inherit the same shell-initialisation semantics.

<<adjust shell command for prompt>>=
from nytid.cli.track import build_prompted_shell_command

if run is None and item.command is None:
    cmd, prompt_file, prompt_dir = build_prompted_shell_command(
        todo_context,
        proc_env,
    )
@


The temporary init file (or [[ZDOTDIR]] directory for zsh) must still
be removed after the subprocess exits regardless of how it terminated.

<<cleanup temp prompt files>>=
from nytid.cli.track import cleanup_prompted_shell_command

cleanup_prompted_shell_command(prompt_file, prompt_dir)
@

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, while also removing the
# finished todo from the authoritative stack in the same mutation.
next_id, todos, stack = apply_todo_mutation(
    lambda next_id, todos, stack: (
        next_id,
        todos,
        [sid for sid in stack if sid != todo_id],
    )
)
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"
else:
    item.status = "pending"

save_todos(next_id, todos)
if item.status == "done":
    auto_complete_parent(item, 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",
         "--command", "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",
         "--command", "false",
         "--reassign-to", "teacher"],
    )
    _, todos = load_todos()
    item = todos[0]
    assert item.who == "teacher"
    assert item.status == "pending"
@

<<stop tracking after subprocess>>=
stop_todo_tracking(todo_id, item, todos)
@

\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: Annotated[Optional[int],
                       todo_id_arg] = None,
):
    """Stop tracking time for a todo item.

    If no ID given, stops the currently tracked todo.
    """
    todo_id, item, next_id, todos, stack = stop_todo_state(todo_id)
    <<stop tracking via track module>>
    typer.echo(f"Stopped: #{todo_id} {item.title}")
@

<<stop tracking via track module>>=
stop_todo_tracking(todo_id, item, todos)
@

Let's verify that the [[stop]] command also protects shared labels.
The setup mirrors [[test_done_preserves_parent_labels]], but here
the child is still on the active stack when [[stop]] runs---the
[[stop]] command pops it afterwards.  The [[sid == todo_id]] guard
in [[stop_todo_tracking]] ensures the child does not protect its
own labels.

<<test functions>>=
def test_stop_preserves_parent_labels(temp_todo_dir):
    """Stop on child must not stop labels shared
    with still-active parent."""
    from nytid.cli.track import (
        load_active_session,
        save_active_session,
        ActiveSession,
        default_username,
    )

    worker = default_username
    save_todos(3, [
        TodoItem(
            id=1, title="Parent task",
            created="2026-02-20T10:00:00",
            status="in-progress",
            labels=["DD1310", "teaching"],
        ),
        TodoItem(
            id=2, title="Child task",
            created="2026-02-20T10:00:00",
            status="in-progress",
            parent_id=1,
            labels=["DD1310", "teaching"],
        ),
    ])
    save_active_stack([1, 2])

    session = ActiveSession()
    session.start_labels(
        ["DD1310", "teaching", "todo:1"],
        datetime.datetime(2026, 2, 20, 10, 0),
    )
    session.start_labels(
        ["todo:2"],
        datetime.datetime(2026, 2, 20, 10, 5),
    )
    save_active_session(session, worker)

    result = runner.invoke(cli, ["stop", "2"])
    assert result.exit_code == 0

    session = load_active_session(worker)
    active = session.get_active_labels()
    assert "todo:1" in active
    assert "DD1310" in active
    assert "teaching" in active
    assert "todo:2" not in active
@

\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}

<<argument and option definitions>>=
export_output_opt = typer.Option(
    "--output", "-o",
    help="Output file (stdout if not specified)")
export_all_opt = typer.Option(
    "--all", "-a",
    help="Include done items")
export_format_opt = typer.Option(
    "--format", "-f",
    help="Export format (vtodo)")
@

<<export command>>=
@cli.command()
def export(
    output: Annotated[Optional[str],
                      export_output_opt] = None,
    all_items: Annotated[bool,
                         export_all_opt] = False,
    format: Annotated[str,
                      export_format_opt] = "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.

As [[todo import]] grows beyond simple issue ingestion, it should feel
like the batch counterpart of [[todo add]]: imported items may belong
under the currently active parent task, carry deadlines or estimates,
and capture the working context in which we discovered them.

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

<<argument and option definitions>>=
import_repo_arg = typer.Argument(
    help="GitHub repo (owner/repo)")
import_number_opt = typer.Option(
    "--number", "-n",
    help="Import only this issue/PR number")
import_label_opt = typer.Option(
    "--label", "-l",
    help="Add label to imported items")
import_type_opt = typer.Option(
    "--type", "-t",
    help="Import type: issue, pr, or all")
import_skip_prio_opt = typer.Option(
    "--skip-priority",
    help="Use age-based priority heuristic")
import_gh_labels_opt = typer.Option(
    "--github-labels",
    help="Include GitHub labels on imported items")
@

<<import command>>=
@cli.command(name="import")
def import_github(
    repo: Annotated[str, import_repo_arg],
    number: Annotated[Optional[int],
                      import_number_opt] = None,
    label: Annotated[Optional[List[str]],
                     import_label_opt] = None,
    <<import metadata options>>
    item_type: Annotated[str,
                         import_type_opt] = "all",
    skip_priority: Annotated[bool,
                             import_skip_prio_opt] = False,
    github_labels: Annotated[bool,
                             import_gh_labels_opt] = False,
    who: Annotated[Optional[str],
                   who_opt] = default_username,
):
    """Import GitHub issues/PRs as todo items.

    \b
    Examples:
      nytid todo import dbosk/prgi24-issues
      nytid todo import dbosk/prgi24-issues --wd ~/courses/DD1310/labs
      nytid todo import dbosk/prgi24-issues --top-level --github-labels
    """
    require_gh()
    next_id, todos = load_todos()

    <<resolve import parent and inherited metadata>>
    <<fetch github items>>
    <<filter already imported>>
    <<create todos from github items>>

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

The metadata and placement options mirror [[todo add]] where they make
sense for batch imports.  [[--parent]] places imported items explicitly,
while [[--top-level]] suppresses the new auto-parenting behaviour.  When
neither is given, we reuse the currently tracked todo as the parent so a
teacher can start a broad task such as
\enquote{prepare course backlog} and import discovered GitHub work under
it naturally.

<<import metadata options>>=
deadline: Annotated[Optional[str],
                    deadline_opt] = None,
estimate: Annotated[Optional[float],
                    estimate_opt] = None,
description: Annotated[Optional[str],
                       description_opt] = "",
wd_path: Annotated[Optional[str], wd_opt] = None,
capture_env: Annotated[bool, env_opt] = False,
here: Annotated[bool, here_opt] = False,
run_command: Annotated[Optional[str],
                       command_opt] = None,
parent: Annotated[Optional[int],
                  parent_opt] = None,
top_level: Annotated[bool, top_level_opt] = False,
@

Parent inheritance works slightly differently for imports than for plain
[[todo add]].  Imported items always retain their repository label
because that is how users later find all issues from one project, but a
parent can still contribute its own labels and deadline when the user
does not specify overrides.  This lets a parent like [[DD1310]] provide
course context while [[owner/repo]] still marks the GitHub source.

<<resolve import parent and inherited metadata>>=
resolved_parent = parent
parent_item = None
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}"
        )
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)

inherited_labels = []
inherited_deadline = deadline
if resolved_parent is not None:
    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

<<resolve wd and env for new todo>>
@

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 = list(inherited_labels)
    if repo not in gh_labels:
        gh_labels.append(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:
        temp = TodoItem(
            id=0,
            title=gh_item["title"],
            parent_id=resolved_parent,
        )
        active = get_active_siblings(todos, temp)
        priority = binary_search_priority(
            active, gh_item["title"],
            parent=parent_item,
            todos=todos,
        )

    item = TodoItem(
        id=next_id,
        title=gh_item["title"],
        labels=gh_labels,
        priority=priority,
        status=status,
        who=who,
        created=(
            datetime.datetime.now().isoformat()
        ),
        deadline=inherited_deadline,
        estimated=estimate,
        description=description or "",
        parent_id=resolved_parent,
        wd=resolved_wd,
        env=resolved_env,
        command=run_command,
        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.  When we import under a parent, however,
those priorities must stay within the parent's child range or flat view
would scatter them outside their subtree.  In that case we scale the
same oldest-to-newest ordering into the interval returned by
[[compute_child_priority_range]].

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 resolved_parent is not None and parent_item is not None:
    high, low = compute_child_priority_range(
        todos, parent_item
    )
    values = distribute_priorities_in_range(
        n, high, low
    )
    priorities = {}
    for i, (dt, num) in enumerate(reversed(timestamps)):
        priorities[num] = values[i]
elif 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
@

Import should carry the same task-context metadata as [[todo add]].
Let's verify that explicit flags for parent placement and working
directory are written to the imported item:

<<test functions>>=
def test_import_applies_parent_and_wd(temp_todo_dir):
    """Import stores explicit parent and working directory."""
    save_todos(2, [
        TodoItem(
            id=1,
            title="Parent task",
            priority=5.0,
            created="2026-02-20T10:00:00",
            labels=["DD1310"],
            deadline="2026-03-01",
        ),
    ])
    mock_result = MagicMock()
    mock_result.returncode = 0
    mock_result.stdout = json.dumps([
        {
            "number": 2,
            "title": "Imported child",
            "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",
                "--parent", "1",
                "--wd", "project",
            ],
        )

    assert result.exit_code == 0
    _, todos = load_todos()
    child = todos[1]
    assert child.parent_id == 1
    assert child.wd == os.path.abspath("project")
    assert child.env is None
    assert child.deadline == "2026-03-01"
    assert child.labels == ["DD1310", "owner/repo"]
@

When [[--skip-priority]] is combined with parenting, the newest imported
item should still receive the highest child priority within the
parent's range:

<<test functions>>=
def test_import_parented_skip_priority_keeps_newest_highest(
    temp_todo_dir,
):
    """Parented age heuristic still prefers newer items."""
    save_todos(3, [
        TodoItem(
            id=1,
            title="Parent task",
            priority=5.0,
            created="2026-02-20T10:00:00",
        ),
        TodoItem(
            id=2,
            title="Lower sibling",
            priority=3.0,
            created="2026-02-20T10:00:00",
        ),
    ])
    mock_result = MagicMock()
    mock_result.returncode = 0
    mock_result.stdout = json.dumps([
        {
            "number": 2,
            "title": "Older import",
            "state": "OPEN",
            "labels": [],
            "createdAt": "2026-01-01T00:00:00Z",
        },
        {
            "number": 3,
            "title": "Newer import",
            "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",
                "--parent", "1",
            ],
        )

    assert result.exit_code == 0
    _, todos = load_todos()
    older = next(t for t in todos if t.title == "Older import")
    newer = next(t for t in todos if t.title == "Newer import")
    assert 3.0 < older.priority < 5.0
    assert 3.0 < newer.priority < 5.0
    assert newer.priority > older.priority
@

Auto-parenting should work for imports just as it does for manual
adds, but [[--top-level]] must still let users opt out when they are
collecting a batch that should remain at the root.

<<test functions>>=
def test_import_auto_parents_under_active_todo(
    temp_todo_dir,
):
    """Import inherits the active todo as parent."""
    save_todos(2, [
        TodoItem(
            id=1,
            title="Active parent",
            priority=5.0,
            status="in-progress",
            created="2026-02-20T10:00:00",
            labels=["course"],
            deadline="2026-03-02",
            who="alice",
        ),
    ])
    save_active_stack([1])
    mock_result = MagicMock()
    mock_result.returncode = 0
    mock_result.stdout = json.dumps([
        {
            "number": 3,
            "title": "Imported under active",
            "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 "Auto-parenting under: #1 Active parent" in result.stdout
    _, todos = load_todos()
    child = todos[1]
    assert child.parent_id == 1
    assert child.deadline == "2026-03-02"
    assert child.labels == ["course", "owner/repo"]


def test_import_top_level_overrides_auto_parenting(
    temp_todo_dir,
):
    """Import --top-level bypasses active-parent detection."""
    save_todos(2, [
        TodoItem(
            id=1,
            title="Active parent",
            priority=5.0,
            status="in-progress",
            created="2026-02-20T10:00:00",
        ),
    ])
    save_active_stack([1])
    mock_result = MagicMock()
    mock_result.returncode = 0
    mock_result.stdout = json.dumps([
        {
            "number": 4,
            "title": "Root import",
            "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",
                "--top-level",
            ],
        )

    assert result.exit_code == 0
    assert "Auto-parenting under:" not in result.stdout
    _, todos = load_todos()
    child = todos[1]
    assert child.parent_id is None


def test_import_here_captures_wd_and_env(temp_todo_dir):
    """Import --here stores cwd together with environment."""
    mock_result = MagicMock()
    mock_result.returncode = 0
    mock_result.stdout = json.dumps([
        {
            "number": 5,
            "title": "Context import",
            "state": "OPEN",
            "labels": [],
            "createdAt": "2026-02-01T00:00:00Z",
        },
    ])

    with patch.dict(
        os.environ,
        {"HOME": "/tmp/home", "USER": "alice"},
        clear=True,
    ):
        with patch(
            "nytid.cli.todo.subprocess.run",
            return_value=mock_result,
        ):
            result = runner.invoke(
                cli,
                [
                    "import", "owner/repo",
                    "--type", "issue",
                    "--skip-priority",
                    "--here",
                ],
            )

    assert result.exit_code == 0
    _, todos = load_todos()
    item = todos[0]
    assert item.wd == os.getcwd()
    assert item.env == {
        "HOME": "/tmp/home",
        "USER": "alice",
    }
@

\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.

<<argument and option definitions>>=
sync_repo_opt = typer.Option(
    "--repo", "-r",
    help="Only sync items from this repo")
@

<<sync command>>=
@cli.command()
def sync(
    repo: Annotated[Optional[str],
                    sync_repo_opt] = None,
):
    """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)
    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)"
    )
    stop_todo_tracking(item.id, item, todos)
@

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.

Like [[view]] and [[done]], [[note]] defaults to the currently active
todo when no ID is given.  This keeps the common workflow short:
start work, then record progress without retyping the task number.

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.

<<argument and option definitions>>=
note_message_opt = typer.Option(
    "--message", "-m",
    help="Note text (inline)")
@

<<note command>>=
@cli.command()
def note(
    todo_id: Annotated[Optional[int],
                       todo_id_arg] = None,
    message: Annotated[Optional[str],
                       note_message_opt] = None,
    edit: Annotated[bool, editor_opt] = False,
):
    """Add a note to a todo item."""
    next_id, todos = load_todos()
    todo_id = resolve_todo_id(todo_id, "add a note")
    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()
append_note_to_item(item, note_text, next_id, todos)
if item.github:
    gh = item.github
    typer.echo(
        f"Comment posted on "
        f"{gh['repo']}#{gh['number']}"
    )
else:
    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


def test_note_defaults_to_active_todo(temp_todo_dir):
    """Note without ID uses the active stack top."""
    save_todos(3, [
        TodoItem(
            id=1, title="Parent task",
            created="2026-02-20T10:00:00",
        ),
        TodoItem(
            id=2, title="Active task",
            created="2026-02-20T10:00:00",
        ),
    ])
    save_active_stack([1, 2])

    result = runner.invoke(
        cli,
        ["note", "-m", "Progress update"],
    )

    assert result.exit_code == 0
    assert "Note added to #2" in result.output
    _, todos = load_todos()
    assert todos[0].notes == ""
    assert todos[1].notes == "Progress update"


def test_note_without_id_requires_active_todo(
    temp_todo_dir,
):
    """Note without ID errors when no active todo exists."""
    save_todos(2, [
        TodoItem(
            id=1, title="Local task",
            created="2026-02-20T10:00:00",
        ),
    ])

    result = runner.invoke(
        cli,
        ["note", "-m", "Progress update"],
    )

    assert result.exit_code == 1
    assert (
        "No active todo. Specify an ID to add a note."
        in result.output
    )
@

\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,
    finalize_detached_todo,
    read_detached_exit_code,
    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_add_with_explicit_priority(temp_todo_dir):
    """--prio stores the provided priority unchanged."""
    result = runner.invoke(
        cli,
        [
            "add", "-t", "Explicit prio",
            "--prio", "7.25",
            "--top-level",
        ],
    )

    assert result.exit_code == 0
    _, todos = load_todos()
    assert todos[0].priority == 7.25
@

Priorities are unconstrained floats---negative values are valid and
sometimes necessary after multiple appends push items below zero:

<<integration tests>>=
def test_add_with_negative_priority(temp_todo_dir):
    """Negative --prio values are stored unchanged."""
    result = runner.invoke(
        cli,
        [
            "add", "-t", "Negative prio",
            "--prio", "-3.5",
            "--top-level",
        ],
    )

    assert result.exit_code == 0
    _, todos = load_todos()
    assert todos[0].priority == -3.5


def test_add_append_uses_bottom_priority(temp_todo_dir):
    """--append adds after the lowest-priority sibling."""
    save_todos(3, [
        TodoItem(
            id=1, title="Higher",
            priority=5.0,
            created="2026-02-20T10:00:00",
        ),
        TodoItem(
            id=2, title="Lower",
            priority=3.0,
            created="2026-02-20T10:00:00",
        ),
    ])

    result = runner.invoke(
        cli,
        [
            "add", "-t", "Appended",
            "--append",
            "--top-level",
        ],
    )

    assert result.exit_code == 0
    _, todos = load_todos()
    appended = next(t for t in todos if t.id == 3)
    assert appended.priority == 2.0


def test_add_append_under_parent(temp_todo_dir):
    """--append works for a parent's active children too."""
    save_todos(4, [
        TodoItem(
            id=1, title="Parent",
            priority=5.0,
            created="2026-02-20T10:00:00",
        ),
        TodoItem(
            id=2, title="Child high",
            parent_id=1,
            priority=4.0,
            created="2026-02-20T10:00:00",
        ),
        TodoItem(
            id=3, title="Child low",
            parent_id=1,
            priority=3.0,
            created="2026-02-20T10:00:00",
        ),
    ])

    result = runner.invoke(
        cli,
        [
            "add", "-t", "Child appended",
            "--parent", "1",
            "--append",
        ],
    )

    assert result.exit_code == 0
    _, todos = load_todos()
    appended = next(t for t in todos if t.id == 4)
    assert appended.parent_id == 1
    assert appended.priority == 2.0


def test_add_append_uses_base_priority_not_deadline_boost(
    temp_todo_dir,
):
    """--append stores base priority, not effective priority."""
    now = datetime.datetime.now()
    created = (
        now - datetime.timedelta(days=29)
    ).isoformat()
    deadline = (
        now + datetime.timedelta(days=1)
    ).isoformat()
    save_todos(3, [
        TodoItem(
            id=1, title="Stable",
            priority=5.0,
            created=created,
        ),
        TodoItem(
            id=2, title="Urgent soon",
            priority=1.0,
            created=created,
            deadline=deadline,
        ),
    ])

    # Precondition: deadline boost is active
    _, setup_todos = load_todos()
    urgent = next(
        t for t in setup_todos if t.id == 2
    )
    assert effective_priority(urgent) > urgent.priority, \
        "Test requires deadline boost to be active"

    result = runner.invoke(
        cli,
        [
            "add", "-t", "Appended",
            "--append",
            "--top-level",
        ],
    )

    assert result.exit_code == 0
    _, todos = load_todos()
    appended = next(t for t in todos if t.id == 3)
    assert appended.priority == 0.0
@

Without unclamped priorities, consecutive appends would collapse to
the same value; this test verifies they remain distinguishable:

<<integration tests>>=
def test_add_append_consecutive_gets_distinct_priorities(
    temp_todo_dir,
):
    """Two consecutive --append calls produce distinct
    priorities, even below zero."""
    save_todos(2, [
        TodoItem(
            id=1, title="Only sibling",
            priority=1.0,
            created="2026-02-20T10:00:00",
        ),
    ])

    runner.invoke(
        cli,
        ["add", "-t", "First append",
         "--append", "--top-level"],
    )
    runner.invoke(
        cli,
        ["add", "-t", "Second append",
         "--append", "--top-level"],
    )
    _, todos = load_todos()
    first = next(t for t in todos if t.id == 2)
    second = next(t for t in todos if t.id == 3)
    assert first.priority == 0.0
    assert second.priority == -1.0
    assert second.priority < first.priority


def test_add_rejects_prio_with_append(temp_todo_dir):
    """--prio and --append are mutually exclusive."""
    result = runner.invoke(
        cli,
        [
            "add", "-t", "Conflicting",
            "--prio", "5",
            "--append",
            "--top-level",
        ],
    )

    assert result.exit_code == 1
    assert (
        "--prio and --append cannot be used together"
        in result.output
    )


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_edit_who_flag(temp_todo_dir):
    """Edit uses --who for assignee updates."""
    save_todos(2, [
        TodoItem(
            id=1, title="Original",
            created="2026-02-20T10:00:00",
        ),
    ])

    result = runner.invoke(
        cli,
        ["edit", "1", "--who", "dan-claude"],
    )
    assert result.exit_code == 0

    _, todos = load_todos()
    assert todos[0].who == "dan-claude"


def test_edit_wd_flag_preserves_env(temp_todo_dir):
    """Edit --wd updates wd without rewriting env."""
    save_todos(2, [
        TodoItem(
            id=1, title="Original",
            env={"PATH": "/usr/local/bin"},
            created="2026-02-20T10:00:00",
        ),
    ])

    with patch.dict(
        os.environ,
        {"PATH": "/usr/bin", "HOME": "/tmp/home"},
        clear=True,
    ):
        result = runner.invoke(
            cli,
            ["edit", "1", "--wd", "project"],
        )

    assert result.exit_code == 0
    _, todos = load_todos()
    assert todos[0].wd == os.path.abspath("project")
    assert todos[0].env == {"PATH": "/usr/local/bin"}


def test_edit_env_flag_captures_environment(temp_todo_dir):
    """Edit --env refreshes the stored environment."""
    save_todos(2, [
        TodoItem(
            id=1, title="Original",
            wd="/tmp/project",
            created="2026-02-20T10:00:00",
        ),
    ])

    with patch.dict(
        os.environ,
        {"PATH": "/usr/bin", "HOME": "/tmp/home"},
        clear=True,
    ):
        result = runner.invoke(
            cli,
            ["edit", "1", "--env"],
        )

    assert result.exit_code == 0
    _, todos = load_todos()
    assert todos[0].wd == "/tmp/project"
    assert todos[0].env == {
        "PATH": "/usr/bin",
        "HOME": "/tmp/home",
    }


def test_edit_here_flag_captures_current_directory(
    temp_todo_dir,
):
    """Edit --here stores cwd and environment."""
    save_todos(2, [
        TodoItem(
            id=1, title="Original",
            created="2026-02-20T10:00:00",
        ),
    ])

    with patch.dict(
        os.environ,
        {"HOME": "/tmp/home", "USER": "alice"},
        clear=True,
    ):
        result = runner.invoke(
            cli,
            ["edit", "1", "--here"],
        )

    assert result.exit_code == 0
    _, todos = load_todos()
    assert todos[0].wd == os.getcwd()
    assert todos[0].env == {
        "HOME": "/tmp/home",
        "USER": "alice",
    }


def test_add_wd_does_not_capture_env(temp_todo_dir):
    """Add --wd stores only the working directory."""
    with patch.dict(
        os.environ,
        {"PATH": "/usr/bin", "HOME": "/tmp/home"},
        clear=True,
    ):
        result = runner.invoke(
            cli,
            [
                "add", "-t", "Context task",
                "--wd", "project",
                "--top-level",
            ],
        )

    assert result.exit_code == 0
    _, todos = load_todos()
    assert todos[0].wd == os.path.abspath("project")
    assert todos[0].env is None


def test_add_env_captures_environment(temp_todo_dir):
    """Add --env stores only the current environment."""
    with patch.dict(
        os.environ,
        {"PATH": "/usr/bin", "HOME": "/tmp/home"},
        clear=True,
    ):
        result = runner.invoke(
            cli,
            [
                "add", "-t", "Env task",
                "--env",
                "--top-level",
            ],
        )

    assert result.exit_code == 0
    _, todos = load_todos()
    assert todos[0].wd is None
    assert todos[0].env == {
        "PATH": "/usr/bin",
        "HOME": "/tmp/home",
    }


def test_add_here_captures_wd_and_env(temp_todo_dir):
    """Add --here stores cwd together with environment."""
    with patch.dict(
        os.environ,
        {"HOME": "/tmp/home", "USER": "alice"},
        clear=True,
    ):
        result = runner.invoke(
            cli,
            [
                "add", "-t", "Here task",
                "--here",
                "--top-level",
            ],
        )

    assert result.exit_code == 0
    _, todos = load_todos()
    assert todos[0].wd == os.getcwd()
    assert todos[0].env == {
        "HOME": "/tmp/home",
        "USER": "alice",
    }


def test_edit_run_flag_updates_command(temp_todo_dir):
    """Edit --run updates the stored command."""
    save_todos(2, [
        TodoItem(
            id=1, title="Original",
            command="claude",
            created="2026-02-20T10:00:00",
        ),
    ])

    result = runner.invoke(
        cli,
        ["edit", "1", "--command", "opencode"],
    )
    assert result.exit_code == 0

    _, todos = load_todos()
    assert todos[0].command == "opencode"


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]


def test_start_special_command_respects_explicit_prompt(
    temp_todo_dir,
):
    """Explicit prompt flags suppress auto-appended notes."""
    save_todos(2, [
        TodoItem(
            id=1, title="AI task",
            created="2026-02-20T10:00:00",
            wd="/tmp",
            command=(
                'claude -p "Use nytid todo to track work."'
            ),
            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 == [
            "claude",
            "-p",
            "Use nytid todo to track work.",
        ]
@
