taskman

Task/todo manager

Taskman

A TODO manager whose logic lives in a server, with any number of clients on top. The first client is a Mutt-style TUI on Crysterm (with Pine elements where they fit); the second is a plain command line so everything can be scripted. Scheduling is VirtualTime / VirtualDate. Tasks are stored in a Maildir as ordinary mail messages (mail-rfc5322-mime), so mail clients can read, search and sync them, and mail saved into a task folder becomes a task. When a task falls due, a notification mail is delivered to a mailbox or a command of your choice.

Architecture

 taskman-tui (Crysterm)   taskman (CLI)        other clients
        │                     │                     │
        └──────── Taskman::Client::Base ────────────┘
               Remote (JSON lines over a socket)  |  Embedded (in-process)
                          │                               │
               taskmand: Server → Dispatcher              │
                          └──────── Taskman::Service ─────┘
                                       │
          model · schedule (VirtualDate) · query · store (Maildir) · notifier
  • Taskman::Service is the whole business logic; every operation a client can perform is a public method on it. It knows nothing about terminals or sockets.
  • taskmand exposes the service over a unix and/or inet socket using newline-delimited JSON (Taskman::Protocol), and sends the due notifications on a timer.
  • Front ends only ever see Taskman::Client::Base. Client::Remote talks to a daemon; Client::Embedded wraps a Service in-process, so the CLI and the TUI also work with no daemon running. Both pick the daemon when one answers on the configured socket and fall back to embedded otherwise; --embedded forces the fallback.

Building

shards install
shards build                      # bin/taskmand, bin/taskman, bin/taskman-tui
crystal spec
bin/ameba && crystal tool format --check

shard.yml points at sibling checkouts (../crysterm2, ../virtualtime, ../virtualdate, ../maildir.cr, ../mail-rfc5322-mime.cr), the way the shards are developed together. A clone of taskman alone builds with the GitHub sources in shard.standalone.yml, a shards override file:

export SHARDS_OVERRIDE=shard.standalone.yml
shards install
shards build

It follows the sibling shards' main branches where taskman needs changes newer than their last release; the comments in the file name the versions to pin once they are tagged.

To run taskmand as a service, contrib/ has a launchd agent (com.crystallabs.taskmand.plist, macOS) and a systemd user unit (taskmand.service, Linux); each starts with the install commands. The daemon logs to stderr (what it listens on, sources reloaded, notifications sent) and exits cleanly on SIGTERM. examples/config.yml is a complete, commented configuration.

Configuration

Everything lives under one home directory: --home DIR, else $TASKMAN_HOME, else ~/.taskman. Without a config.yml the defaults apply: one maildir source named local in HOME/tasks, the four default views, and a unix socket at HOME/taskmand.sock, so every home directory has a daemon of its own. A full config.yml:

sources:                          # task stores, in listing order
  - name: local
    driver: maildir
    path: ~/.taskman/tasks        # driver-specific keys ride along
  - name: work
    driver: maildir
    path: ~/Mail/work-tasks

views:                            # named queries, shown in the sidebar
  - name: All
    query: status:all
  - name: Open
    query: ""
  - name: By priority
    query: sort:prio
  - name: By date
    query: sort:due
  - name: This week
    query: due:7d sort:due

listen:
  unix: taskmand.sock             # under the home directory unless absolute
                                  # (or ~); null disables the unix listener
  inet: 127.0.0.1:7788            # host:port; omit to disable

notify:                           # where occurrence notifications go
  maildir: ~/Mail/inbox           # a Maildir the mail is written into, or
  # command: sendmail -t          # a command the mail is piped to (one of the two)
  to: me@example.org
  from: taskman <taskman@localhost>
  interval: 15                    # minutes between the daemon's checks; 0 = manual only
  ahead: 0                        # days before a due day to announce it as well

New tasks go to the first source unless --source says otherwise. Every key is optional: a file that names no sources gets the default local one, a file that names no views gets the default views. taskman view add and the TUI's a write views back into this file.

The daemon:

taskmand                              # the configured listeners
taskmand --home DIR --today 2026-10-09 # pin the day (also for taskman, taskman-tui)
taskmand --unix PATH | --no-unix | --inet HOST:PORT

Storage

Each task is one message in a Maildir++ tree; the folder it sits in is the task's folder and the file's unique name is its id. Headers carry the scalar fields (Subject title, Date created, Keywords tags, In-Reply-To and X-Taskman-Parent for subtasks, X-Taskman-Due, X-Taskman-Priority, X-Taskman-Status), the body is the main note, a taskman.yaml part holds the schedule, completions and log, and note items are attachments. Starred and deleted are the maildir F and T flags. X-Taskman: task marks a task definition.

A source folder holds only tasks: any message saved into it is adopted as one, keeping its own headers (From, Received, …), with an imported line in its log. Subtasks thread under their parent in a mail client, since In-Reply-To names the parent's Message-ID.

Sources change behind the service's back: a mail client syncs into a task folder, taskman --embedded writes while taskmand runs. Every maildir write renames a file into or out of a cur/ or new/ directory, so the driver remembers those directories' modification times (and the root's, where folders appear) and compares them before each request and each notification check; a source that changed is read again, the others are kept. The daemon logs reloaded SOURCE (changed on disk), a response names the sources in reloaded, and the TUI shows (reloaded HH:MM) on its status line with a message naming the source; r reloads everything by hand. An edit made in place without a rename is not noticed until then.

Notifications

A notification is a separate, plain message about one task falling due on one day. It carries X-Taskman: occurrence (so it is never read back as a task), X-Taskman-Task, X-Taskman-Source, X-Taskman-Due, the task's priority and tags, and threads under the task through In-Reply-To/References. The subject is Due today: TITLE or Overdue since DAY: TITLE; the body lists the fields, why the day is due (the same text as taskman explain) and the main note.

The rule is one notification per pending due day: an open task is announced when its due day is today or earlier and it has not been announced for that day yet, which the task's log records as notified DAY. An overdue task is not nagged about daily; its day moves (and earns a new notification) when it is postponed or, for a recurring task, when the pending occurrence is completed and due rolls to the next one. Done tasks and tasks marked for deletion are skipped.

With ahead: N a due day is also announced once in advance, as soon as it is within N days: Due tomorrow (Sat 2026-10-10): TITLE or Due in 3 days (…): TITLE, logged as notified ahead DAY and carrying its own Message-ID, so the due-day notification still follows. A postponed day earns a new advance notice when it comes within reach again.

Sending: taskmand checks every notify.interval minutes, starting at launch; taskman notify sends from the command line (through the daemon if one runs, else in-process), taskman notify --dry-run lists what would be sent; in the TUI, N on the index asks and sends. Without a notify: block all of them report that no target is configured.

Views and queries

A view is a named query from config.yml, so the same set is served to every front end through Service#views. The same language drives taskman list, the TUI's limit prompt (l) and the saved views. A query is a list of terms that must all hold; double quotes group words.

status:open|done|all          open tasks only unless given
due:today|tomorrow|overdue    due exactly that day; before today
due:7d|2w|1m                  overdue or due within 7 days, 2 weeks, 1 month
due:none|any                  without, with a due day
prio:1|2|3|4|5|none           1 is the highest, 5 the lowest
tag:X                         exact, case-insensitive
source:X  folder:X            a source; a folder (work, work.clients)
id:X                          the full or the short id
star  recurring  root         starred; repeating; without a parent
sort:due|prio|title|created
-term                         negates any term (not sort:)
anything else                 searches title, notes and tags

A query also selects the tasks an operation acts on: Service#apply (the apply command, the apply protocol command) takes a query and an ordered list of typed actions (Taskman::Action: done, undone, delete, undelete, star, unstar, postpone, prio, tag, untag, folder), runs them on every selected task and returns the tasks as they are afterwards. Selecting one task is id:SHORTID; selecting all is status:all.

Tasks marked for deletion stay listed until purged. The default views are All (status:all), Open (empty query), By priority (sort:prio) and By date (sort:due).

Scheduling

A task's schedule is a VirtualDate: a list of due entries, on any of which the task is on; a list of omit entries, on which it is not; a shift rule for an occurrence that lands on an omitted day; and a begin and end day. Input is exactly what the two shards define, nothing of taskman's own.

An entry is a VirtualTime, typed as its fields in VirtualTime's notation, name: value pairs:

day: 15                        the 15th of every month
day: -1                        the last day of every month
day: 1,15,-1                   those days of every month
day_of_week: 1..5              Monday to Friday
day_of_week: 6,7               weekends
month: 3 day: 10..20/2         every other day from 10 to 20 March
year: 2026 month: 10 day: 15   one day
week: 1..53/2 day_of_week: 5   every other Friday
day: -7..-1 day_of_week: 6     the last Saturday of the month
hour: 0                        every day

The fields are year, month, day, week, day_of_week, day_of_year, hour, minute, second, millisecond, nanosecond; a value is a number (negative counts from the end), a list 1,15,-1, a range 10..20, a stepped range 10..20/2, true or false. An entry is kept exactly as typed; taskman adds nothing to it. VirtualDate decides what it means: an entry naming no time of day is due for the whole day, and consecutive due days run together into one occurrence, so a rule meant to recur every day names a time of day too (hour: 0). Entries are shown as their fields with the words they mean (day: -1 (the last day of every month)).

The shift is what VirtualDate takes: false (an occurrence on an omitted day is dropped; the default), true (it stays due), nil (it is not scheduled) or a number of seconds it moves by, again until it lands on an allowed day (86400, -86400). Begin and end are days, YYYY-MM-DD, or nil.

The pending due day (due, shown as Next in the editor) is computed from the schedule: the first occurrence after the last completion or from begin on, else from today on (a dated entry in the past stays pending, overdue). It is what lists, flags, the calendar and notifications use; postpone moves it alone. Completing a recurring task records the pending occurrence and moves due to the next occurrence after today, so missed ones are skipped; undone brings the last completion back. A task with a one-day schedule is simply done.

taskman --today, postpone, calendar and explain take human dates: today, tomorrow, +3d, 2w, fri, next fri, 2026-10-01, 10-01, 1 oct, 15 (the next 15th).

Command line

taskman [options] <command> [args]

REF is a task id, its short id (the M…P…Q… middle part), a substring of the id, or an exact or partial title; an ambiguous REF is an error that lists the candidates.

add TITLE...              new task; --due ENTRY (repeatable), --omit ENTRY
                          (repeatable), --shift RULE, --begin DATE, --end DATE,
                          --prio, --tag (repeatable), --parent,
                          --notes, --folder, --star, --source
list [QUERY...]           open tasks, or the query's (alias: ls)
show REF                  every field
edit REF                  change fields: the add options plus --title,
                          --untag, --no-due, --no-omit, --unstar; --due and
                          --omit replace the entries they stand for
done | undone REF...      complete (an occurrence, for recurring tasks), reopen
star | unstar REF...
prio REF LEVEL            1 (highest) to 5 (lowest) or none
postpone REF DATE         move the pending due day
rm | unrm REF...          mark for deletion, unmark (aliases: delete, undelete)
purge                     delete the marked tasks for good
apply QUERY... do ACTION...  run the actions, in order, on every task the
                          query selects, then list them; actions are done,
                          undone, delete, undelete, star, unstar,
                          postpone:DATE, prio:LEVEL, tag:NAME, untag:NAME
                          and folder:NAME (empty for the root)
notes REF [TEXT|-]        show, set (or read from stdin) the main note
note REF FILE             attach a file as a note item
log REF                   the management log
explain REF [DATE]        why the task is or is not due that day
calendar [MONTH]          due days of the month (2026-10, oct, a date; alias: cal)
views                     the saved views
view NAME                 list the view's tasks
view add NAME QUERY...    save a view;  view rm NAME
sources                   the configured sources
notify [--dry-run]        send (or list) the due occurrence notifications
help
version

Connection options: --home DIR, --today DATE, --embedded. Listing lines are SHORTID FLAGS PRIO DUE TITLE +tag; the flags are * starred, ~ recurring, x done, ! overdue, D marked for deletion. Exit status is 0, 1 for an error from the service, 2 for a usage error.

taskman add Pay rent --due "day: 1" --omit "day_of_week: 6,7" --shift 86400 --prio 1 --tag bills
taskman list due:7d sort:due
taskman done rent
taskman apply due:overdue tag:bills do postpone:tomorrow prio:1
taskman explain rent 2026-11-01
taskman view add "This week" due:7d sort:due

The TUI

taskman-tui [--home DIR] [--today DATE] [--embedded]. The screen is Mutt's: a key hint line on top, the sidebar on the left (views first, then each source and its folders, with task counts), the current page in the center, and a dashed status line over a command line where prompts and messages appear. Lists move with the arrow keys or j/k, PageUp/PageDown page. Prompts take the same dates, rules and queries as the CLI; Escape cancels a prompt. Ctrl-Q quits from anywhere.

Index (the task list; ? shows every table on a help page):

q      Quit (asks)             m  New task (asks the title, opens the editor)
Enter  View the task           e  Edit the task
x      Done / reopen           d  Mark for deletion      u  Unmark
$      Purge the marked        p  Postpone to a day      *  Star / unstar
l      Limit by a query        s  Sort by due, prio, title or created
C      Calendar                Tab  To the sidebar       r  Reload    ?  Help
N      Send the pending notifications (asks first)

Rows read N FLAGS DUE~ PRIO TITLE +tags [notes]: the flags are D deleted, X done, ! overdue, N due today, and * starred; ~ marks a recurring task; subtasks hang under their parent with tree glyphs.

Sidebar (reached with Tab; it keeps the keyboard until Tab is pressed again):

Enter  Open the view or folder    a  Save the current limit as a view
D  Remove the highlighted view    Tab  Back to the list        q  Quit

Task view (Enter on a task): the fields, the scheduling explanation and the log (timestamps to the second). i/q/Esc back, e edit, x done, d delete, p postpone, n/b next and previous task.

Editor (m or e): Mutt's compose menu with, beside the header rows, help for the highlighted row: the inputs it takes, one per line, then more for whoever scrolls down with > and <. The rows are Title; one Due row per entry (Due, Due 2, …); the Omit rows the same way; Shift, Begin, End; Priority, Tags, Parent, Folder; then the Notes block with the main note and the note items. + on a Due or Omit row adds another row of that kind, empty, with its form open (the row stays if the form is cancelled; d and o open a new entry's form from anywhere), - removes the highlighted entry or empty row (the only row of a kind is cleared and stays). Enter edits the highlighted row: on Due, Omit, Shift, Begin, End or Priority it opens a form with one labelled row per value (a Due or Omit form has a row per VirtualTime field, each in VirtualTime notation: a number, a list 1,15,-1, a range 10..20, a stepped range 10..20/2, true or false; all rows empty removes the entry); on Parent it opens a picker that lists the tasks and narrows the list as you type; Title, Tags and Folder prompt on the command line; a text note opens the text editor. The Shift form has the rule, the seconds (typing any makes the occurrence move) and Max shifts, VirtualDate's cap on how many times it moves. In a form, Tab, Shift-Tab, Down and Up move between the rows, a choice such as the shift rule or the priority cycles with Space, the arrows, the mouse wheel or a click and jumps to the word a typed character starts, Enter applies (a refused value says why and keeps the form open) and Esc cancels. Or jump with t title, s shift, b begin, e end, p priority (1 to 5 or none), g tags, f folder, n main note. a attaches a file as a note item, D removes the highlighted item. y saves, q aborts. In the text editor Ctrl-X keeps the text and Ctrl-C discards it.

Calendar (C): the month grid with the days that have tasks marked and the highlighted day's tasks beside it. Arrows move, Tab switches between the grid and the day's list, Enter views a task, t jumps to today, i/q back.

Layout

src/
  taskman.cr            library root (core only)
  taskman/
    core.cr             requires everything below that is UI- and socket-free
    errors.cr
    model/              Task, Note, LogEntry, Priority
    schedule/           Dates (human input), Schedule (entries, shift, pending day), Explain
    query/              Query language, Filter, Sort
    store/              Driver registry, message Serializer, MaildirDriver
    config.cr           config.yml: sources, views, listen, notify
    notifier.cr         notification delivery: a Maildir or a command
    service.cr          the business-logic facade
    protocol/           Request/Response envelope, command names, Codec
    server.cr, server/  Server (listeners), Connection, Dispatcher
    client.cr, client/  Base, Transport, Remote, Embedded
    cli.cr, cli/        Runner (subcommands), Format
    tui.cr, tui/        UI (frame), App (flows/keys), Rows, Keys, pages/
  bin/
    taskmand.cr  taskman.cr  taskman-tui.cr
spec/                   mirrors src/taskman; spec/tui drives the TUI headlessly
examples/config.yml     a complete, commented configuration
contrib/                launchd agent and systemd user unit for taskmand
shard.standalone.yml    GitHub dependency sources for a build without the sibling checkouts

License

Copyright (C) 2026 Davor Ocelic.

This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. See LICENSE for the full text.

Repository

taskman

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 1
  • 16 days ago
  • October 9, 2026
License

GNU Affero General Public License v3.0

Links
Synced at

Sun, 18 Oct 2026 16:56:18 GMT

Languages