Ccdzb168first commit
b83a2a9aAdd on 29 days agoHistory

xitkit behavior reference

Behavioral reference for re-implementing the xit CLI (xitkit 2.1.0, Python >= 3.14). It describes what the reference implementation actually does, including quirks where the code diverges from its own help text. Source modules: file_repository.py, fileparser.py, services.py, task.py, description.py, duedate.py, dateutils.py, priority.py, tags.py, status.py, location.py, formatter.py, commands.py, __main__.py, config.py.

1. Task ID assignment

IDs are global per process, not per file, and are assigned when a Task object is constructed (Task.__init__ -> FileRepository().assign_id()).

  • One monotonic counter starts at 1 for every CLI invocation and is never reused or persisted. FileRepository.reset() sets it back to 1, but a normal run is a fresh process.
  • Every constructed task consumes one ID, including tasks created internally (each recur copy, each Task.copy()). IDs of objects that are discarded are simply skipped.
  • Order: the command loads its file list as sorted(set(file_paths)) (lexicographic by the path string as given), then parses each file top-to-bottom; tasks are numbered in the order the parser creates them, so IDs follow physical line order within a file, and file order is alphabetical. Section membership does not reorder numbering.
  • Stability: IDs are only stable while the same set of files is loaded with the same paths and the same task lines. Adding/removing a task, renaming/moving a file, changing -f/-d, or adding a file that sorts earlier all shift later IDs. They are display/CLI handles, not durable identifiers.
  • Commands resolve IDs by loading files, then keeping tasks whose .id is in the requested set. So an ID means "the Nth task of this invocation's loaded set".
  • Display padding: # + zero-padded to width max(3, len(str(id))) -> #001, #099, #1000, #12345. Commands accept the bare integer without #.
  • show also moves CHECKED tasks to the end for display only; their IDs do not change.
  • Dead code: FileRepository.reload_file calls a nonexistent _assign_task_ids; nothing else calls reload_file.

2. File discovery

Default scan (no -f): Path.cwd() is globbed recursively with **/*.xit and then **/*.md (pathlib glob, so no shell-style dotfile filtering and no symlinked-directory recursion). Every hit is converted with Path(p).relative_to(Path.cwd()) and the combined list is sorted() (plain string sort). Extensions are hard-coded to .xit and .md (config.ParsingConfig.supported_extensions).

  • -f/--files (repeatable) replaces discovery entirely: if any -f is given, -d is ignored. There is no existence or extension validation; the path strings are passed straight to the parser. Command.load_files then does sorted(set(paths)), so -f b.xit -f a.xit loads a.xit first (this determines IDs). set is on raw strings, so a.xit and ./a.xit can load the same file twice.
  • -d/--directory is click-validated as an existing directory. It is only consulted when no -f is given. The value is used as typed, and results are re-relativized to the working directory, so:
    • no -d -> directory is absolute cwd -> works;
    • -d <absolute path under cwd> -> works;
    • -d <relative path> -> glob yields relative paths, relative_to(cwd) raises -> Unexpected error: '<p>' is not in the subpath of '<cwd>';
    • -d <absolute path outside cwd> -> same error.
  • Non-existent file: FileParser.parse_file creates it (path.touch()) before checking the extension, so -f missing.xit silently creates an empty file and then reports No sections selected. (no tasks -> no sections). add also calls Path(file).touch().
  • Unsupported extension via -f: the file is created, then Unsupported file type: .txt is raised and surfaces as Unexpected error: Unsupported file type: .txt.
  • .md files: only lines inside a fenced code block (opened and closed by a line beginning with three backticks) are parsed. Markdown headers are recorded but not used as sections; tasks default to section To Do. A line that is neither blank, a checkbox, nor indented by exactly 4 spaces becomes a section header (any line whose first character is not [ or whitespace). Prose lines therefore become section titles.
  • FileDiscoveryService._resolve_path_argument (existence + extension checks, and the "Supported: ..." error) exists but is never called by any command.
  • add does not use -d/--directory at all: there -d is --due. Its file option is a single -f/--file, defaulting to todo.xit in the cwd (with warning Defaulting to 'todo.xit'.).

3. Filtering (show only)

Filters live in TaskFilter and are applied in order, so they combine with logical AND. With no options, show lists everything in the selected sections (including done/obsolete; done is only moved to the end, not hidden).

  • --status/-s (repeatable, case-insensitive choices open|done|ongoing|obsolete|inquestion): maps to OPEN|CHECKED|ONGOING|OBSOLETE|IN_QUESTION; keeps tasks whose status is any of the given ones (OR). done means CHECKED.
  • --priority/-p N (int): keeps tasks with priority.level >= N (a minimum, not equality). 0 keeps everything. A negative value raises at Priority(-1) (uncaught ValueError).
  • --tag/-t (repeatable): for each tag, keeps tasks that have a tag with that name (values ignored, comparison case-sensitive). Multiple tags are ANDed. The # is not stripped, so --tag '#work' matches nothing; use --tag work.
  • --due-on EXPR: keeps tasks that have a due date whose implied_date equals the parsed date. --due-by EXPR: keeps tasks whose implied_date <= parsed date. Tasks without a due date are excluded by both.
  • If EXPR cannot be parsed, DueDate.from_string returns None, the filter is silently dropped, and no error is shown (--due-on garbage applies no date filter).
  • Date expressions accepted by --due-on, --due-by, add --due, reschedule (via DateParser.parse_date_expression, input stripped):
    • keywords, case-insensitive: today, tomorrow, yesterday (offset in days from today);
    • relative: one or more <N><unit> components in the fixed order years, months, weeks, days, using y, m, w, d. It is a prefix match, so trailing junk is ignored (3d2w = +3 days) and a bare/leading non-numeric string fails. Conversions are m = 30 days, y = 365 days, w = 7; there is no calendar arithmetic and no +/- sign (+1w fails). 1d means "today + 1 day", not "one day before the due date".
    • absolute formats passed through unchanged: YYYY-MM-DD, YYYY-MM, YYYY, YYYY-Www, YYYY-Qq, YYYY/MM/DD, YYYY/Www.
  • Section filter: before attribute filters, tasks are restricted to the sections selected by select_section. Non-interactive -> all sections that appear among the loaded tasks. If no task has a section, the command warns No sections selected. and stops.
  • --count/-c replaces the list with {n} tasks found. (green).

4. Sorting (show)

Options: --sort priority|due_date (case-insensitive) and --order asc|desc (case-insensitive, default asc when --sort is present). --order is ignored without --sort (the CLI passes (order or 'asc') if sort else None).

  • priority: sorted(key=task.priority.level, reverse=(order=='desc')). Only the exclamation-mark count matters; leading/trailing dots are ignored. asc = lowest level first, desc = highest first. Python's sort is stable, so ties keep ID/parse order.
  • due_date: key is the implied_date string (YYYY-MM-DD), compared lexicographically.
    • task has no due date -> key 9999-12-31 for asc, 0000-01-01 for desc;
    • due date present but implied_date is None -> key 9999-12-31 in both orders.
    • asc = earliest first; desc = latest first. Undated tasks sort last in both orders (the 0000-01-01 sentinel is the smallest key, so it lands last under reverse=True).
    • Ties keep their previous order (stable).
  • After sorting, tasks are unconditionally and stably partitioned: all non-CHECKED tasks first, then all CHECKED tasks, preserving their relative order. A checked task therefore always appears after every unchecked task, whatever the sort and order.
  • With no --sort, the natural order is ID order (sorted files, line order) plus that same checked-last partition.

5. Statistics (xit stats)

stats ignores status/priority/tag/date filters and section selection; it uses every task loaded. File selection is the same discovery as above; with -f the header label is specified files, otherwise str(directory) (the -d value or the cwd).

Exact output:

Task Statistics for '<label>'
========================================
Total tasks: <n>
Files with tasks: <n>

By Status:
  <Open|Done|Ongoing|Obsolete|In Question>: <n>     (only when count > 0)

By Priority:
  No priority: <n>            (level 0)
  Priority <!*level>: <n>     (level > 0)

Tasks with due dates: <n>
Tasks with tags: <n>
Overdue tasks: <n>

By File:                       (only when more than one distinct file)
  <absolute path>: <n>

Computations:

  • Total tasks = number of loaded tasks.
  • Files with tasks = number of distinct task.location.file_path values (absolute paths).
  • By Status = count per status_type.name (OPEN, CHECKED, ONGOING, OBSOLETE, IN_QUESTION), printed under the fixed display names above, in that order, skipping zeros.
  • By Priority = count per priority.level, keys ascending, so level 0 first; label uses ! repeated level times.
  • Tasks with due dates = tasks where due_date is not None (even if the date is invalid).
  • Tasks with tags = tasks with at least one tag.
  • Overdue tasks = tasks whose implied_date string is < today's YYYY-MM-DD (string comparison; current date from datetime.now()). Tasks without a due date are never overdue.
  • By File counts tasks per absolute path, sorted by path.
  • Empty selection: no files -> No task files found.; no tasks -> No tasks found in the specified files.; otherwise the blocks above.

6. Display format of one task in show

One console line per task (plus a trailing blank line after the whole list). Columns, joined by single spaces:

  1. ID # + zero-padded id, width max(3, len(id)); grey. Omitted with --no-id.
  2. Status checkbox [ ], [x], [@], [~], [?] — bold, colored by status: OPEN white, CHECKED green, ONGOING yellow, OBSOLETE red, IN_QUESTION magenta.
  3. Priority (only when level > 0): the raw token = leading dots + !*level + trailing dots (e.g. !!, ...!), bold red, followed by a space.
  4. Description: the task's raw description text with the leading priority token removed from the first line only (and only when that token contains at least one !). Inline highlighting per line: a -> plus following spaces is cyan and the date token after it is bold cyan; the date shown is the normalized form, so -> 2025-12 renders as -> 2025-12-31, 2025 -> 2025-12-31, 2025-Q4 -> 2025-12-31, 2025-W42 -> that week's Sunday, slashes accepted; #tag, #tag=value, #tag="a b" are bold blue; runs of !/dots at the start of a line or after a space and followed by a space are bold red.
  5. Location (only with --show-location/-l): four spaces + (<path>:<lines>), dim. <path> is the absolute path stored in Location; <lines> is N or N-M for multi-line tasks, empty when unknown (so the token can end with :).

Multi-line descriptions: every continuation line starts on a new line with an indent of len(id_token) + 1 + 4 spaces when IDs are shown (9 for #001), or exactly 4 spaces with --no-id. Continuation lines get the same inline highlighting.

CHECKED tasks additionally get Rich strike over the description portion of every line (starting after #001 [x] / [x] ), and that same span is then dimmed to grey.

Summary/other messages:

  • Summary line, printed only when the number shown differs from the number of tasks after section selection: dim Showing {filtered} of {total} total tasks.
  • --count: {n} tasks found. (green) instead of the list (no summary).
  • No match: No tasks match the specified criteria. (yellow).
  • No tasks at all: No tasks found in the specified files. (yellow).
  • No files: No files selected. (yellow), preceded by No task files found. when discovery found nothing.
  • display_error = red, display_warning = yellow, display_success = green; all three escape [ to \[ so task text containing brackets prints literally.

7. Subcommands and how the file text changes

Global write model (critical). Every mutation ends in File.write(), which regenerates the whole file from the parsed model: for each section in insertion order it writes <section title>\n, then each task as to_checkbox_format() + \n, then one blank line. A task line is <status symbol> <space> <description text> with continuation lines indented by 4 spaces. Consequences: comments, prose, blank lines, markdown fences and any line that was not classified as a section header are lost; sections get a trailing blank line; unknown section content is re-emitted as a bare header. Commands operate on the parsed text, not on line surgery.

All commands except add/stats take ID... (nargs=-1, ints), plus -d/--directory, -f/--files, -i/--interactive, --debug. Shared flow (UpdateCommand._execute): load files -> section filter -> keep tasks whose id is in task_ids -> resolve the new attribute via _select_new_attribute -> apply per task. If no IDs and no -i: Error: Must specify at least one task ID or use interactive mode. If IDs match nothing: No matching tasks found for the specified IDs. If the new attribute resolves to None: Attribute not specified. Per-task success messages use the pattern ✓ Updated task #NNN in <file>:\n<checkbox line>\n; when more than one task matched, a final Processed N of M tasks. is appended.

  • add — xit add DESCRIPTION [-f/--file FILE] [-p/--priority N] [-d/--due EXPR] [-t/--tag NAME]... [-i]. -d here is the due date, not a directory; there is no directory option. Target file defaults to cwd/todo.xit (warning Defaulting to 'todo.xit'.). The file is touched, parsed, and the last section is used (non-interactive). The raw description is parsed into priority/tags/due, then the explicit options are applied:
    • priority: always applied (CLI default is 0), so an inline !! in DESCRIPTION is stripped by the level 0 set; -p N replaces it with !*N (dots are not preserved);
    • tags: appended to the end of the text as #name (a leading # is stripped; a value like v=val becomes a tag whose name is literally v=val), duplicates ignored;
    • due date: if the text already contains -> ... it is removed and the parsed --due is appended at the end; an inline due date is kept when --due is absent. Final text order: [priority] [cleaned original text] [tags] [due date]. Then the file is written and it prints ✓ Added task to <relative path>: "<final description>". Empty/whitespace description -> Error: Task description cannot be empty.
  • mark — xit mark ID... --open|--done|--ongoing|--obsolete|--inquestion. Replaces only the checkbox character; the rest of the line is untouched. Requires a flag (or -i), otherwise Error: Must specify a status flag (--done, --open, --ongoing, --obsolete, --inquestion).
  • reschedule — xit reschedule ID... --new_date/-n EXPR. The date is an option (--new_date, underscore), not a positional argument, despite the examples in help. Sets the due date: the old -> ... token is removed from the text and the new one appended at the end; EXPR goes through the same expression parser as add --due. If the expression cannot be parsed, the attribute is None and the command fails with Attribute not specified.
  • rm — xit rm ID... [--force] [-i]. There is no y/n confirmation; the option is --force (there is no --yes). Behavior comes from select_deletion_method:
    • non-interactive without --force -> mark obsolete: sets [~], prints the generic ✓ Updated task #NNN ... message;
    • --force -> permanent delete: removes the task from its section, rewrites the file, prints ✓ Deleted task #NNN from <file>.;
    • interactive without --force -> a questionary single choice between mark as obsolete and delete permanently. Quirk: the delete branch overwrites the instance's confirmation lambda, so in a multi-task run a later "obsolete" task can be reported as "Deleted".
  • move — xit move ID... -t/--target-file FILE [-s/--section NAME]. Removes the task from its current section, writes the old file, sets location.file_path/section, then adds the object to the target file (created/parsed if missing) under section or "To Do". The resolved section is not written back onto the task, so with no -s the message says (section: None) even though it lands in To Do: ✓ Moved task #NNN to <path> (section: <section>):\n<checkbox line>\n. Requires --target-file or -i.
  • edit — disabled. xit edit prints The 'edit' command is currently not functional. to stderr and exits with code 1 before any parsing. (The underlying path also calls a nonexistent FileRepository.update_task_by_identity.) The intended message would have been ✓ Updated description for task #NNN in <file>: "<text>".
  • prio — xit prio ID... --priority/-p N. Removes the existing priority token from the text and prepends !*N (no dots); N=0 removes priority entirely. Requires -p or -i (Error: Must specify a priority level or use interactive mode).
  • tag — xit tag ID... --tag/-t NAME.... Each name is appended to the end of the description as #NAME (leading # stripped, duplicate names ignored). Requires at least one tag or -i.
  • untag — xit untag ID... --tag NAME.... Removes tags by exact name and value (soft=False), so it only removes valueless tags whose name matches; --tag work=home is treated as a tag named work=home and removes nothing. Removal deletes all occurrences of the token from the text and collapses runs of spaces/tabs (line breaks preserved). A non-matching removal is a silent no-op that still prints the success message. Requires at least one tag or -i.

8. Recurrence (xit recur)

xit recur ID... --interval EXPR (--count/-n N | --end-date/-e YYYY-MM-DD). Multiple IDs are accepted (each matching task gets its own series; they share one ID lookup, so they must be selected together). --interval has no short option (help text mentions -i, but -i is --interactive).

Validation (checked before touching the file):

  • both limits -> Error: Cannot specify both --end-date and --count. Choose one.
  • neither (non-interactive) -> Error: Must specify either --end-date or --count for recurrence limit or use interactive mode.
  • no interval -> Error: Must specify --interval for recurrence or use interactive mode.

Interval syntax: the string is strip().lower()ed and must be fully consumed by repeated ([+-]?\d+)([dwmy]), e.g. 1d, 7d, 2w, 1m, 3m, 1y, and composites like 1w2d, 7m3w. Spelled-out words (daily, weekly, monthly, yearly) are not accepted. Amounts must be positive (a - parses as an amount then fails with Interval amount must be positive). Units map to: d = 1 day, w = 7 days, m = 30 days, y = 365 days; all components are summed into one fixed timedelta (no calendar months).

Dates: start = the task's own implied_date; if the task has no due date the generator defaults to today.

  • --count N: the generator is called with count = N + 1 and the first (start) date is dropped, so exactly N new tasks are created at start, start+i, ..., start+N*i.
  • --end-date D: dates run from start while <= D inclusive; the start date is dropped, so new tasks are all later occurrences up to D. Requires D > start (End date must be after start date) and strict YYYY-MM-DD (Invalid end date format: '<v>'. Use YYYY-MM-DD format). --count bounds: <= 0 -> Count must be positive; > 1000 -> Count cannot exceed 1000 occurrences.

Generated tasks: each is a deep copy of the original (same description text, status, priority, tags, section) with set_due_date applied, which removes any existing -> ... token from the text and appends the new ISO YYYY-MM-DD. Because Task.copy() builds a new Task, each copy consumes a fresh global ID. The original task is left unchanged. Copies are appended to the same section and the whole file is rewritten.

Confirmation: ✓ Created N new instance(s) of task #NNN in file <path> (section: <section>): followed by one line per new date, produced by string-replacing the original's due-date text with each new date inside the original's checkbox line (a preview, not a re-serialization).

Quirk: if the original has no due date, task.recur still appends the copies and writes the file, but the confirmation path then reads task.due_date.implied_date, raising Unexpected error: 'NoneType' object has no attribute 'implied_date' — the tasks exist despite the error.

9. Validation and error behavior

  • Command.execute wraps everything: XitError -> red message via display_error; any other Exception -> red Unexpected error: {e}; with --debug the exception is re-raised instead. Command-level failures do not change the exit code (process exits 0); only click usage errors (exit 2) and the disabled edit (exit 1) do.
  • click-level validation happens before any code: -d must exist and be a directory; --status, --sort, --order are Choice (case-insensitive) and abort with a usage error on invalid values; ID arguments must be integers. show has no positional arguments (the xit show tasks.xit examples in help are wrong).
  • Message style: command validation errors start with Error: ; state problems are bare sentences. Exact strings:
    • Error: Must specify at least one task ID or use interactive mode
    • Error: Must specify a status flag (--done, --open, --ongoing, --obsolete, --inquestion)
    • Error: Must specify a priority level or use interactive mode
    • Error: Must specify at least one tag to add or use interactive mode
    • Error: Must specify at least one tag to remove or use interactive mode
    • Error: Must specify new file path and section or use interactive mode.
    • Error: Must specify --interval for recurrence or use interactive mode.
    • Error: Must specify either --end-date or --count for recurrence limit or use interactive mode.
    • Error: Cannot specify both --end-date and --count. Choose one.
    • Error: Task description cannot be empty.
    • No files selected., No task files found., No tasks found in the specified files., No sections selected., No matching tasks found for the specified IDs., Attribute not specified., Invalid status string: <v>
    • The 'edit' command is currently not functional. (stderr, exit 1)
  • Priority(level<0) raises ValueError: Priority level cannot be negative (surfaces as Unexpected error: ...); Priority also rejects leading and trailing dots.
  • Status.from_string raises XitError("Invalid status string: <v>") for unknown strings; StatusType accepts only , x, @, ~, ?, so checkboxes like [!] are skipped silently by the parser.
  • Parsing is lenient: malformed lines are skipped, invalid dates inside the text are ignored (the task simply has no due date), a checkbox must be followed by exactly one space and a valid status character, and continuation lines must be exactly 4 spaces (a continuation that itself looks like [...] is treated as a checkbox).
  • Interactive mode adds questionary prompts: file choice (Select relevant files: / Select a file:), section choice (Select section(s):), task choice (Select tasks to mark:), status, due-date text, priority level, deletion method (mark as obsolete / delete permanently), recurrence interval and limit, tag names (comma-separated), and move target file/section.