Introduction
taskdb is a zero-config task tracker that lives entirely in plain Markdown files. No database, no server, no account required — just a .tasks/ folder sitting right inside your project.
It’s designed to be fast to pick up, impossible to vendor-lock, and friendly to both humans and LLMs.
Why taskdb?
- Human-readable files: Every task is a
.mdfile with YAML frontmatter. Open it in any editor, read it in any diff tool, commit it to git. - Statuses as directories: A task’s status is determined by which directory holds its symlink — no magic fields, no migrations.
- LLM-native: Agents can create, update, comment on, and complete tasks using a dead-simple CLI. Great for planning and tracking work mid-session.
- Truly zero-config: Run
taskdb initonce and you’re done. No config file needed, though there are plenty of override capabilities built-in w/ environment variables.
Quickstart
1. Install
Option A: release binary install (recommended)
curl -fsSL https://raw.githubusercontent.com/toastdriven/taskdb/main/scripts/install.sh | bash
Option B: global package install
bun add -g taskdb
See the Installation page for full details.
2. Initialise a project
Run this once inside your project directory:
$ taskdb init
Initialised project at: .tasks
This creates a .tasks/ directory with the standard status folders (ready, in-progress, done, complete).
3. Create your first task
$ taskdb create "Write the README"
#1: Write the README - (Ready)
4. Move it along
$ taskdb update 1 --status=in-progress
#1: Write the README - (In-progress)
$ taskdb comment 1 "Drafted the installation section"
$ taskdb complete 1
#1: Write the README - (Complete)
5. See what’s going on
# list everything
$ taskdb list
# filter by status
$ taskdb list --status=in-progress
# full-text search
$ taskdb search "README"
That’s the core loop. Head over to the Guides for more realistic walkthroughs, or jump straight to the CLI Reference for every flag and option.
Installation
The goal is to install once, then run taskdb ... directly from your shell.
Option A (recommended): install the release binary
curl -fsSL https://raw.githubusercontent.com/toastdriven/taskdb/main/scripts/install.sh | bash
This installs taskdb to /usr/local/bin by default (or $INSTALL_DIR if set).
Option B: global install with Bun
Requires Bun:
# install Bun (if needed)
curl -fsSL https://bun.sh/install | bash
# install taskdb globally
bun add -g taskdb
Verify installation
taskdb --help
# or
taskdb --version
Initialize a project
Run once inside the project you want to track:
taskdb init
This creates the .tasks/ directory structure.
Tip: override the project path with
--project=<path>orTASKDB_PROJECT_PATH.
Guides
Use these guides when you want taskdb patterns, not just command syntax.
In this section
- Use
taskdbas a simple TODO tracker- Best for personal/project-local task management.
- LLM/agent planning workflow
- How an agent can decompose and track feature work with
taskdb.
- How an agent can decompose and track feature work with
taskdbas a lightweight GitHub Issues replacement- Team workflow conventions for using task files in git.
Suggested reading order
- Start with the TODO tracker guide.
- Read the LLM/agent workflow if you automate development tasks.
- Use the GitHub Issues replacement guide for team process setup.
Guide: Use taskdb as a simple TODO tracker
If you just want a clean personal TODO workflow, taskdb works well with a tiny status flow:
readyin-progressdonecomplete
Typical daily loop
# See what's ready
taskdb list --status=ready
# Create a task
taskdb create "Refactor auth middleware" --labels='["chore"]'
# Pick it up
taskdb update 3 --status=in-progress
# Add progress notes
taskdb comment 3 "Extracted shared validator"
# Finish
taskdb update 3 --status=done
taskdb complete 3
Tips
- Keep titles short and action-oriented.
- Put details in
--description. - Use labels (
feat,bug,docs,chore) for quick filtering. - Run
taskdb search "keyword"when you remember text, not IDs.
Guide: LLM/agent planning workflow with taskdb
taskdb is a good fit for agentic workflows because each task is a file and every status change is explicit.
Example feature request
“I’d like to add a React-based contact form to my website.”
An agent can break this into tasks:
- Define fields + validation rules
- Create React form component
- Add server endpoint
- Add spam protection and tests
- Document usage
Then track each step via CLI updates/comments.
Suggested AGENTS.md snippet
## Task Tracking
Before starting multi-step work, create tasks in `taskdb`.
- Use `taskdb create` for each subtask.
- Move active work to `in-progress`.
- Add comments as progress notes and decision logs.
- Mark completed work with `taskdb complete <task-identifier>`.
- Prefer small, reviewable tasks over large umbrella tasks.
Minimal command sequence
taskdb create "Define contact form schema" --labels='["feat","frontend"]'
taskdb create "Implement React contact form UI" --labels='["feat","frontend"]'
taskdb create "Add POST /contact handler" --labels='["feat","backend"]'
taskdb update 12 --status=in-progress
taskdb comment 12 "Schema agreed: name/email/message"
taskdb complete 12
Guide: taskdb as a lightweight GitHub Issues replacement
For small teams/projects, taskdb can replace Issues with local, versioned task files.
Why this works
- Tasks are plain Markdown + YAML frontmatter.
- You can review task changes in pull requests.
- Status is visible via symlink directories (
ready/,in-progress/,done/,complete/). - No external service required.
Team workflow
- Keep
.tasks/committed to git. - Create tasks for bugs/features.
- Reference task IDs in commits/PRs.
- Update status/comments during implementation.
- Complete tasks when merged.
Conventions to adopt
- Labels:
bug,feat,docs,chore,priority:high - Comments should include decisions and blockers.
- Use
taskdb list --labels='["bug"]'for triage views.
Tradeoffs
- No hosted UI out of the box.
- No built-in assignees/milestones.
- Best for low/medium scale projects where git is already the source of truth.
CLI Reference
Usage
All commands support shared global options:
--project <path>: override project root path (default:.tasks, orTASKDB_PROJECT_PATH)--help: show usage and options
Many commands also support --format <output-format>.
Valid formats:
quiet: suppress non-error outputplain: human-readable output (terse one-line summaries for most commands; full multi-line details forview)json: machine-readable JSON output
Task identifiers
Commands that accept <task-identifier> support:
- integer id (
1,00001) - basename (
00001-my-task) - path fragment (
00000/00001-my-task.md), with or without project/status prefix
Commands
init
Initialise a project directory structure.
taskdb init [--format quiet|plain|json]
Options:
--format <quiet|plain|json>: output style
Example:
$ taskdb init
Initialised project at: .tasks
create <title>
Create a new task.
taskdb create "Title" [--description <text>] [--status <status>] [--labels <json-array>] [--format quiet|plain|json]
Options:
--description <text>: initial description body--status <status>: initial status directory (default:ready)--labels <json-array>: labels as JSON array string (example:'["feat","p1"]')--format <quiet|plain|json>: output style
Example:
$ taskdb create "Write README" --status in-progress --labels='["docs","p1"]'
#1: Write README - (In-progress) - [docs, p1]
update <task-identifier>
Update one or more task fields.
taskdb update <task-identifier> [--title <text>] [--description <text>] [--status <status>] [--labels <json-array>] [--format quiet|plain|json]
Options:
--title <text>: replace title--description <text>: replace description body--status <status>: transition to another status--labels <json-array>: replace labels with JSON array string--format <quiet|plain|json>: output style
Example:
$ taskdb update 1 --status done --labels='["docs"]'
#1: Write README - (Done) - [docs]
view <task-identifier>
View a task in plain, raw markdown, or JSON format.
taskdb view <task-identifier> [--format plain|raw|json]
Options:
--format <plain|raw|json>:plain: full multi-line task detailsraw: markdown file content (frontmatter + body)json: serialized task object
Example (plain):
$ taskdb view 1
Id: #1
Title: Write README
Slug: write-readme
Status: done
Labels: docs
Created: 2026-05-16T10:00:00.000-05:00
Updated: 2026-05-16T10:05:00.000-05:00
Full Path: .tasks/all/00000/00001-write-readme.md
complete <task-identifier>
Transition a task to complete status and append a status-change comment.
taskdb complete <task-identifier> [--format quiet|plain|json]
Options:
--format <quiet|plain|json>: output style
Example:
$ taskdb complete 1
#1: Write README - (Complete) - [docs]
delete <task-identifier>
Permanently delete task file and status symlinks.
taskdb delete <task-identifier> [--format quiet|plain|json]
Options:
--format <quiet|plain|json>: output style
Example:
$ taskdb delete 1
Deleted task [00001] Write README
comment <task-identifier> <comment>
Append a task comment.
taskdb comment <task-identifier> "comment text" [--format quiet|plain|json]
Options:
<comment>: comment text to append--format <quiet|plain|json>: output style
Example:
$ taskdb comment 1 "Added installation notes"
#1: Write README - (In-progress) - [docs]
list
List tasks, optionally filtered.
taskdb list [--status <status>] [--labels <json-array>] [--updated-before <date>] [--updated-after <date>] [--format plain|json]
Options:
--status <status>: only include tasks in this status--labels <json-array>: include tasks containing all listed labels--updated-before <date>: include tasks updated strictly before this date--updated-after <date>: include tasks updated strictly after this date--format <plain|json>: output style
Example:
$ taskdb list --status in-progress
#2: Implement auth - (In-progress) - [feat, backend]
#4: Write README - (In-progress) - [docs]
search <query>
Full-text search tasks (rg preferred, grep fallback).
taskdb search "query" [--format plain|json]
Options:
<query>: text to search for in task files--format <plain|json>: output style
Example:
$ taskdb search "README"
#4: Write README - (In-progress) - [docs]
Reference
This section documents taskdb internals and implementation-facing behavior.
Use this when you need architecture details, data contracts, or code-level orientation.
In this section
- API Overview
- Constants API (
src/constants.ts) - Task Model API (
src/models/task.ts) - Project Model API (
src/models/project.ts) - Datetime Utility API (
src/utils/datetime.ts) - Env Utility API (
src/utils/env.ts) - Slug Utility API (
src/utils/slug.ts) - Strings Utility API (
src/utils/strings.ts)
Related docs
- CLI Reference for end-user command usage
- Specifications for filesystem and task format contracts
API Overview
This page summarizes internal module boundaries. Detailed API docs are split per source file.
Entry points
taskdb.ts: executable entrypoint (#!/usr/bin/env bun), callsrun(process.argv.slice(2)).src/cli.ts: creates Commander program and runs argument parsing.
CLI layer
createProgram(output?): builds configured Commander instance and registers commands.run(args, output?): runs CLI and returns process-like exit code.
Output formatting behavior
Command output rendering is centralized in src/commands/helpers.ts:
formatTask(task, output): terse single-line output (#<id>: <title> - (<Status>) - [labels]).formatFullTask(task, output): multi-line task details (used byview --format plain).formatTaskJSON(task, output): single task JSON output.formatTaskList(tasks, output): terse list output.formatTaskListJSON(tasks, output): list JSON output.
Command registration
src/commands/index.ts:registerAllCommands(program, output)- Commands:
init,create,update,view,complete,delete,comment,list,search.
Detailed module API docs
- Constants API (
src/constants.ts) - Task Model API (
src/models/task.ts) - Project Model API (
src/models/project.ts) - Datetime Utility API (
src/utils/datetime.ts) - Env Utility API (
src/utils/env.ts) - Slug Utility API (
src/utils/slug.ts) - Strings Utility API (
src/utils/strings.ts)
src/constants.ts
Shared constants used across taskdb, with optional environment-variable overrides.
Exports
DEFAULT_PROJECT_PATH: string
- Default:
.tasks - Env override:
TASKDB_DEFAULT_PROJECT_PATH - Used when
--projectis not provided.
ALL_TASKS_DIR: string
- Default:
all - Env override:
TASKDB_ALL_TASKS_DIR - Canonical task file directory.
COMPLETE_TASKS_DIR: string
- Default:
complete - Env override:
TASKDB_DONE_TASKS_DIR - Conventional completed-task status directory.
DEFAULT_STATUSES: string[]
- Default:
["ready", "in-progress", "done"] - Env override:
TASKDB_DEFAULT_STATUSES(CSV) - Parsed via
parseCsvEnv.
NON_STATUS_DIRS: Set<string>
- Default contents:
ALL_TASKS_DIR - Env override:
TASKDB_NON_STATUS_DIRS(CSV) - Directories excluded from status detection.
DESCRIPTION_HEADER: string
- Default:
## Description - Env override:
TASKDB_DESCRIPTION_HEADER - Markdown heading used when serializing/parsing description sections.
COMMENTS_HEADER: string
- Default:
## Task Comments - Env override:
TASKDB_COMMENTS_HEADER - Markdown heading used when serializing/parsing comments sections.
MAX_FILES_PER_DIR: number
- Value:
32768 - Used for grouped task directory computation (
Task.groupDir).
src/models/task.ts (Task)
Represents one canonical task markdown file.
Constructor
new Task(params)
Creates an in-memory task instance.
params fields:
id: numberslug: stringtitle: stringlabels: string[]created: stringupdated: stringdescription: stringcomments: TaskComment[]projectPath: stringstatus?: string
Static helpers
Task.idString(id: number): string
Returns 10-digit id string used in frontmatter (example: 1 -> 0000000001).
Task.idPad(id: number): string
Returns 5-digit id string used in filenames/group paths (example: 1 -> 00001).
Task.groupDir(id: number): string
Returns group directory name by bucket (Math.floor((id - 1) / MAX_FILES_PER_DIR), zero-padded to 5).
Task.buildCommentsSeparator(): string
Returns computed markdown separator between description/comments:
\n---\n\n${COMMENTS_HEADER}\n\n
Task.parseBody(content: string): { description: string; comments: TaskComment[] }
Parses markdown body (post-frontmatter) into normalized description + comments.
Task.parseComments(tableText: string): TaskComment[]
Parses comments markdown table rows into TaskComment[].
Task.read(filePath: string, projectPath: string, status?: string): Promise<Task>
Reads markdown file, parses frontmatter/body, and returns hydrated Task.
Instance properties/getters
filename: string (getter)
<idPad>-<slug>.md
filePath: string (getter)
Absolute path: <projectPath>/<ALL_TASKS_DIR>/<groupDir>/<filename>.
groupDirPath: string (getter)
Absolute group directory path for this task.
Instance methods
buildBody(): string
Builds markdown body (Description section + comments table).
buildFileContent(): string
Builds full file text via gray-matter.stringify (frontmatter + body).
write(): Promise<void>
Ensures group dir exists and writes file content.
create(): Promise<void>
First-write lifecycle:
- slug from title (
toSlug) - set
createdandupdated(makeRfc3339) - write file
addComment(comment: string): Promise<void>
Appends comment row, bumps updated, writes.
updateTitle(title: string): Promise<void>
Updates title, bumps updated, writes.
updateDescription(description: string): Promise<void>
Updates description, bumps updated, writes.
updateLabels(labels: string[]): Promise<void>
Replaces labels, bumps updated, writes.
deleteFile(): Promise<void>
Deletes canonical task file only (does not remove status symlinks).
toJSON(): object
Returns plain object for CLI JSON output.
src/models/project.ts (Project)
Represents a taskdb project root (.tasks) and manages filesystem/status operations.
Types
ListFilter
status?: stringlabels?: string[]updatedBefore?: DateupdatedAfter?: Date
Used by listTasks.
Constructor
new Project({ path })
path: stringabsolute project path.
Directory/status lifecycle
scaffold(): Promise<void>
Creates grouped root directories for:
ALL_TASKS_DIRCOMPLETE_TASKS_DIR- each entry in
DEFAULT_STATUSES
isInitialized(): Promise<boolean>
Returns true if canonical tasks directory exists.
getStatusDirs(): Promise<string[]>
Lists project subdirs treated as status directories (directories not in NON_STATUS_DIRS).
ensureStatusDir(status: string, groupDir: string): Promise<boolean>
Ensures <status>/<groupDir> exists. Returns true when the status directory is new.
Task ID and symlink operations
maxTaskId(): Promise<number>
Scans canonical task files and returns max numeric task id, or 0 if none.
createStatusSymlink(status: string, id: number, filename: string): Promise<void>
Creates status symlink pointing to canonical task file.
removeStatusSymlink(status: string, id: number, filename: string): Promise<void>
Removes status symlink; ignores missing link.
getTaskStatus(id: number, filename: string): Promise<string | null>
Finds which status directory currently contains the symlink.
transitionStatus(task: Task, newStatus: string, warn?: OutputFn): Promise<void>
Moves task symlink to newStatus, optionally warning when status dir is newly introduced.
Also updates task.status in memory.
Task resolution and listing
resolveTask(identifier: string): Promise<Task | null>
Supports:
- numeric id (
1,00001) - basename (
00001-my-task) - path fragment (
00000/00001-my-task.md)
findTaskById(id: number): Promise<Task | null>
Finds task by numeric id in canonical storage and hydrates Task.
listTasks(filters?: ListFilter): Promise<Task[]>
Returns tasks sorted by id asc.
- If
filters.statusis set, scans that status directory. - Always reads canonical files from
ALL_TASKS_DIR. - Applies label + updated-before/after filters.
Search
_isRipgrepAvailable(): Promise<boolean>
Checks if rg is available on PATH.
searchTasks(query: string): Promise<Task[]>
Full-text search over task files:
rg -lwhen available- fallback:
grep -rl
Returns hydrated tasks sorted by id asc.
High-level mutations
createTask(title: string, description = "", status = "ready", labels: string[] = [], warn?: OutputFn): Promise<Task>
Creates task with next id, writes canonical file, and creates status symlink.
deleteTask(task: Task): Promise<void>
Removes all status symlinks and deletes canonical task file.
src/utils/datetime.ts
makeRfc3339(date?: Date): string
Formats a date as RFC 3339 with:
- millisecond precision
- local timezone offset (
±HH:MM)
Parameters
date(optional):Dateto format. Defaults tonew Date().
Returns
- RFC 3339 datetime string.
Example
makeRfc3339();
// "2026-05-14T18:26:13.246-05:00"
src/utils/env.ts
parseCsvEnv(value: string | undefined, fallback: string[]): string[]
Parses a comma-separated env var string into a trimmed string array.
Behavior:
undefined/empty -> returnsfallback- trims whitespace around each entry
- drops empty entries
- if parsed list becomes empty, returns
fallback
Parameters
value: raw env var valuefallback: fallback array
Returns
- parsed list or fallback
Examples
parseCsvEnv("a, b, c", ["x"]); // ["a", "b", "c"]
parseCsvEnv(" , ", ["x"]); // ["x"]
src/utils/slug.ts
toSlug(name: string): string
Converts human-readable text into a filesystem-safe slug.
Rules (in order):
- trim
- lowercase
- remove non
[a-z0-9\s-] - replace whitespace runs with
- - collapse repeated
- - strip leading/trailing
-
Parameters
name: input string
Returns
- normalized slug
Examples
toSlug("My Cool Project!"); // "my-cool-project"
toSlug(" Hello World "); // "hello-world"
src/utils/strings.ts
capitalizeFirst(value: string): string
Capitalizes the first character in a string using locale-aware uppercasing.
Behavior:
- Empty string returns unchanged.
- First character uses
toLocaleUpperCase(). - Remaining string is unchanged.
Parameters
value: input string
Returns
- input with first character capitalized
Example
capitalizeFirst("in-progress"); // "In-progress"
Specifications
This page defines the behavioral/data contract for taskdb.
Filestructure Layout
The tasks for the project are stored by convention under .tasks/ in the current working directory. This is overridable using either the --project=... or TASKDB_PROJECT_PATH environment variable to specify a different path.
Within this directory are two mandatory subdirectories: all & complete.
The all directory holds all of the task files within set of zero-padded subdirectories. Each task file gets an auto-incrementing/unique integer, which is zero-padded to retain ordering under normal lexicographical conditions (e.g. 0001 -> 0009, then 0010).
Note: This structure of nested subdirectories was chosen due to common filesystem limitations, preventing more than ~32,768 files in a single directory. By computing the integer, splitting up the numbering across multiple subdirectories (
<NNNNN>/<NNNNN>-<slug>.md, e.g.00000/00001-initial-setup.md), this allows for32768 * 32768, or over 1 BILLION tasks.If you need more than 1B tasks for a single project, you may want to investigate a different solution…
The complete directory holds symlinks to all of the completed task files. It mirrors the all layout/filepaths.
The filestructure layout ends up looking like:
.tasks/
| # Required, where all the task files actually live
├── all/
| └── <NNNNN>/
| ├── <NNNNN-task-slug>.md
| ├── <NNNNN-task-slug>.md
| └── <NNNNN-task-slug>.md
| # Required, symlinks to all the completed tasks
├── complete/
| └── <NNNNN>/
| | # ... & are just symlinks back to `all/`.
| └── <NNNNN-task-slug>.md
| # Task statuses are sibling subdirectories, ...
├── needs-definition/
| └── <NNNNN>/
| | # ... & are just symlinks back to `all/`.
| └── <NNNNN-task-slug>.md
├── ready/
├── in-progress/
├── on-hold/
├── done/
└── wontfix/
Other subdirectories live alongside all/complete, and make up task statuses. These status subdirectories have an identical internal structure to the all/ directory. Inside each are symlinks back to the task file’s original location in all/.
Status directory names are arbitrary. You can rename these directories, remove them, or add others. In this way, taskdb adapts to your desired set of statuses without code changes/configuration.
Status Model
Status is not stored in task frontmatter.
A task’s current status is determined by which status directory contains its symlink. Status transitions remove the old symlink and create a new one in the target status directory.
Task File Format
Each task is stored as a Markdown file, with YAML frontmatter fields for structured metadata.
Required frontmatter fields:
id- The task id. Integer, but expressed as a zero-padded string, for easy splitting. Auto-incrementing & unique.slug- The slug is computed from thetitleone-time (at creation). It’s immutable, & is non-unique.title- The human-readable task title. What needs to be done.labels- A YAML array of string labels/tags (e.g.['feat'], or['chore', 'easy']). Can be empty. Labels are arbitrary.created- An RFC 3339-formatted datetime string of when the task was created. Computed one-time (at creation) & immutable.updated- An RFC 3339-formatted datetime string of when the task was last updated. Computed every time there’s an update made to the task.
The Markdown body contains:
## Descriptionsection (free-form task details)- horizontal rule (
---) ## Task Commentstable with columnsCommented AtandComment
Example Task File
For example, the first task of a project (e.g. “Initial Setup”) would live at .tasks/all/00000/00001-initial-setup.md, & might look like:
---
id: "0000000001"
slug: "initial-setup"
title: "Initial Setup"
labels: ["feat"]
created: "2026-05-14T18:26:13.246-05:00"
updated: "2026-05-14T18:28:54.123-05:00"
---
## Description
Perform the initial setup steps on the codebase. This includes scaffolding out a `src/` directory, a `tests/` directory, creating all the project files, adding a `.gitignore` & a `README.md`, etc. Also run `git init .`.
---
## Task Comments
| Commented At | Comment |
| ----------------------------- | ---------------------------------------- |
| 2026-05-14T18:27:10.246-05:00 | Status changed from ready to in-progress |
Slug Generation
Task slugs are generated from the task title at creation time, then treated as immutable.
Rules (in order):
- Trim surrounding whitespace.
- Lowercase the string.
- Remove any character that is not
a-z,0-9, whitespace, or-. - Replace runs of whitespace with a single
-. - Collapse repeated hyphens (
---→-). - Remove leading/trailing hyphens.
Examples:
"My Cool Project!"→"my-cool-project"" Hello World "→"hello-world"
Notes:
- Slugs are not unique; task identity comes from the numeric
id. - Updating a task title does not change its slug or filename.
Task Identifiers
Commands that accept <task-identifier> support:
- integer ID (
1or00001) - full basename (
00001-my-task) - path fragment (
00000/00001-my-task.md), with or without status/project prefix