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
recurcopy, eachTask.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
.idis in the requested set. So an ID means "the Nth task of this invocation's loaded set". - Display padding:
#+ zero-padded to widthmax(3, len(str(id)))->#001,#099,#1000,#12345. Commands accept the bare integer without#. showalso moves CHECKED tasks to the end for display only; their IDs do not change.- Dead code:
FileRepository.reload_filecalls a nonexistent_assign_task_ids; nothing else callsreload_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-fis given,-dis ignored. There is no existence or extension validation; the path strings are passed straight to the parser.Command.load_filesthen doessorted(set(paths)), so-f b.xit -f a.xitloadsa.xitfirst (this determines IDs).setis on raw strings, soa.xitand./a.xitcan load the same file twice.-d/--directoryis click-validated as an existing directory. It is only consulted when no-fis given. The value is used as typed, and results are re-relativized to the working directory, so:- no
-d-> directory is absolutecwd-> 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.
- no
- Non-existent file:
FileParser.parse_filecreates it (path.touch()) before checking the extension, so-f missing.xitsilently creates an empty file and then reportsNo sections selected.(no tasks -> no sections).addalso callsPath(file).touch(). - Unsupported extension via
-f: the file is created, thenUnsupported file type: .txtis raised and surfaces asUnexpected error: Unsupported file type: .txt. .mdfiles: 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 sectionTo 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.adddoes not use-d/--directoryat all: there-dis--due. Its file option is a single-f/--file, defaulting totodo.xitin the cwd (with warningDefaulting 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 choicesopen|done|ongoing|obsolete|inquestion): maps toOPEN|CHECKED|ONGOING|OBSOLETE|IN_QUESTION; keeps tasks whose status is any of the given ones (OR).donemeans CHECKED.--priority/-p N(int): keeps tasks withpriority.level >= N(a minimum, not equality).0keeps everything. A negative value raises atPriority(-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 whoseimplied_dateequals the parsed date.--due-by EXPR: keeps tasks whoseimplied_date <= parsed date. Tasks without a due date are excluded by both.- If
EXPRcannot be parsed,DueDate.from_stringreturnsNone, the filter is silently dropped, and no error is shown (--due-on garbageapplies no date filter). - Date expressions accepted by
--due-on,--due-by,add --due,reschedule(viaDateParser.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, usingy,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 arem = 30 days,y = 365 days,w = 7; there is no calendar arithmetic and no +/- sign (+1wfails).1dmeans "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.
- keywords, case-insensitive:
- 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 warnsNo sections selected.and stops. --count/-creplaces 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 theimplied_datestring (YYYY-MM-DD), compared lexicographically.- task has no due date -> key
9999-12-31forasc,0000-01-01fordesc; - due date present but
implied_dateisNone-> key9999-12-31in both orders. asc= earliest first;desc= latest first. Undated tasks sort last in both orders (the0000-01-01sentinel is the smallest key, so it lands last underreverse=True).- Ties keep their previous order (stable).
- task has no due date -> key
- 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 distincttask.location.file_pathvalues (absolute paths).By Status= count perstatus_type.name(OPEN, CHECKED, ONGOING, OBSOLETE, IN_QUESTION), printed under the fixed display names above, in that order, skipping zeros.By Priority= count perpriority.level, keys ascending, so level 0 first; label uses!repeatedleveltimes.Tasks with due dates= tasks wheredue_date is not None(even if the date is invalid).Tasks with tags= tasks with at least one tag.Overdue tasks= tasks whoseimplied_datestring is< today's YYYY-MM-DD(string comparison; current date fromdatetime.now()). Tasks without a due date are never overdue.By Filecounts 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:
- ID
#+ zero-padded id, widthmax(3, len(id)); grey. Omitted with--no-id. - Status checkbox
[ ],[x],[@],[~],[?]— bold, colored by status: OPEN white, CHECKED green, ONGOING yellow, OBSOLETE red, IN_QUESTION magenta. - Priority (only when
level > 0): the raw token = leading dots +!*level + trailing dots (e.g.!!,...!), bold red, followed by a space. - 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-12renders 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. - Location (only with
--show-location/-l): four spaces +(<path>:<lines>), dim.<path>is the absolute path stored inLocation;<lines>isNorN-Mfor 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 byNo 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].-dhere is the due date, not a directory; there is no directory option. Target file defaults tocwd/todo.xit(warningDefaulting 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 thelevel 0set;-p Nreplaces it with!*N (dots are not preserved); - tags: appended to the end of the text as
#name(a leading#is stripped; a value likev=valbecomes a tag whose name is literallyv=val), duplicates ignored; - due date: if the text already contains
-> ...it is removed and the parsed--dueis appended at the end; an inline due date is kept when--dueis 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.
- priority: always applied (CLI default is 0), so an inline
- 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), otherwiseError: 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;EXPRgoes through the same expression parser asadd --due. If the expression cannot be parsed, the attribute isNoneand the command fails withAttribute not specified. - rm —
xit rm ID... [--force] [-i]. There is no y/n confirmation; the option is--force(there is no--yes). Behavior comes fromselect_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 betweenmark as obsoleteanddelete 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".
- non-interactive without
- move —
xit move ID... -t/--target-file FILE [-s/--section NAME]. Removes the task from its current section, writes the old file, setslocation.file_path/section, then adds the object to the target file (created/parsed if missing) undersection or "To Do". The resolved section is not written back onto the task, so with no-sthe message says(section: None)even though it lands inTo Do:✓ Moved task #NNN to <path> (section: <section>):\n<checkbox line>\n. Requires--target-fileor-i. - edit — disabled.
xit editprintsThe 'edit' command is currently not functional.to stderr and exits with code 1 before any parsing. (The underlying path also calls a nonexistentFileRepository.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=0removes priority entirely. Requires-por-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=homeis treated as a tag namedwork=homeand 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 withcount = N + 1and 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<= Dinclusive; the start date is dropped, so new tasks are all later occurrences up to D. RequiresD > start(End date must be after start date) and strictYYYY-MM-DD(Invalid end date format: '<v>'. Use YYYY-MM-DD format).--countbounds:<= 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.executewraps everything:XitError-> red message viadisplay_error; any otherException-> redUnexpected error: {e}; with--debugthe 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 disablededit(exit 1) do.- click-level validation happens before any code:
-dmust exist and be a directory;--status,--sort,--orderareChoice(case-insensitive) and abort with a usage error on invalid values; ID arguments must be integers.showhas no positional arguments (thexit show tasks.xitexamples 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 modeError: Must specify a status flag (--done, --open, --ongoing, --obsolete, --inquestion)Error: Must specify a priority level or use interactive modeError: Must specify at least one tag to add or use interactive modeError: Must specify at least one tag to remove or use interactive modeError: 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)raisesValueError: Priority level cannot be negative(surfaces asUnexpected error: ...);Priorityalso rejects leading and trailing dots.Status.from_stringraisesXitError("Invalid status string: <v>")for unknown strings;StatusTypeaccepts 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.