Deterministic task selection for LLM agents. Zero dependencies, stdlib only, Python ≥ 3.10.
Deutsche Fassung → README_de.md
Most agent task loops let the model decide what to work on next. That sounds flexible, and it fails in a specific, predictable way: the model picks whatever is most visible, cleans it up, and eventually reports "nothing left to do" — while the real backlog sits one directory level below, unread.
taskplan moves that decision out of the prompt and into code. A deterministic selector decides what comes next; the model keeps the judgment calls (is this easy? is it safe? did it pass?).
Surface sweep (all roots)
→ deep dive: EASY, in one root
→ back to the surface
→ deep dive: EASY, in the next root
→ … until NO root has easy work left
→ only then: the medium pass
Effort is the primary sort dimension; root rotation is only secondary. Easy tasks are exhausted globally before a medium one is touched anywhere.
That is not tidiness. Easy tasks are exactly what unblocks whoever is deep inside a hard problem somewhere else. Clearing a small thing in project A is worth more than going deeper in project B. That is why the easy/medium distinction exists at all.
| Effort | Meaning | Autonomous? |
|---|---|---|
easy |
one or few files, one project, reversible, mechanically verifiable | always |
medium |
several files in one project, no architectural change | only when no easy is left anywhere |
large |
architecture, cross-project, migration | never |
special |
needs domain knowledge, credentials, or an irreversible action | never |
| (empty) | unclassified | not treated as easy — better left alone than wrongly assumed harmless |
scope = "central" (shared infrastructure others build on) is never autonomous
either, regardless of effort.
When nothing is selectable, next_bundle() returns None. The loop ends as an
honest no-op instead of inventing work to fill itself.
from taskplan import api as tasks
tasks.init(agent_id="opus")
tasks.add("Fix encoding in docs", priority="high", effort="easy",
project_path="/repos/foo", root_id="OSS")
for t in tasks.list(effort="easy", scope="local"):
print(f"[{t['id']}] {t['title']}")
tasks.done(1)Ask the selector what to do next:
python -m taskplan next # mode, effort, project, task IDs, permissions
python -m taskplan doctor # which database am I actually using?
python -m taskplan projects list # what does the loop see?
python -m taskplan projects markersnext exit codes: 0 = bundle returned · 1 = nothing to do · 2 = role disabled.
agent_id used to carry three meanings at once (creator, worker, role) and was
overwritten on assignment — so the origin was lost the moment someone picked a
task up. Now they are separate:
client.add("…") # sets created_by (immutable)
client.assign(task_id, to="claude") # sets assigned_to + delegation_statusWhoever takes a task writes to assigned_to — never to the field carrying the
origin.
| Role | Does | Never does |
|---|---|---|
| TASKWRITER | finds and formalizes tasks, classifies effort/scope | execute them |
| TASKSOLVER | works a bundle, verifies it, claims it via assign() |
choose the project |
| MAINTAINER | keeps files and directories clean, curates project discovery | write or solve tasks |
The writer is upstream: an unclassified task is an invisible task, because the solver refuses to guess at its size.
Prompts ship with the package (taskplan.TASKSOLVER, .TASKWRITER, .MAINTAINER) —
as resources, not hardcoded strings, and resolvable as real files for external
launchers:
from taskplan import list_workflows, get_workflow_prompt, get_workflow_prompt_pathAll three roles exist in English and German. Default is English; the module is meant to be user-neutral.
[language]
prompts = "de" # de | enOverride for a single run with TASKPLAN_LANG=de. A missing translation falls back
to English with a warning — the prompt is the role's contract, and a silent
language switch would be worse than a loud one. Tests assert that every promise
survives translation, in both directions.
See taskplan.example.toml for the fully commented version.
SQLite is the recommended default, but the selector talks to a narrow TaskStore
protocol and knows no SQL. A files backend keeps the truth in your TODO.md
files — no database at all. Foreign systems plug in via entry point.
Resolution order: env TASKPLAN_DB → taskplan.toml [storage].path → env
RINNSAL_DB → ~/.taskplan/taskplan.db.
python -m taskplan doctorwarns when the active database is empty while another one holds data. That silent failure mode — writing into a database nobody reads, no error, no warning, just no effect — is exactly what it exists to catch.
Five marker categories, each switchable, combined with a real boolean expression:
[traversal.markers]
expression = "(dir_patterns AND files) OR git" # AND / OR / NOT, parentheses| # | Category | Detects |
|---|---|---|
| 1 | dir_patterns |
patterns in the folder name |
| 2 | files |
marker files (CLAUDE.md is more specific than TODO.md) |
| 3 | subdirs |
marker directories (.claude) |
| 4 | git |
a repository — including worktrees/submodules, where .git is a file |
| 5 | flag_file |
an explicit marker; beats every heuristic |
The expression parser is hand-written, not eval — a config file must never
execute arbitrary code. A typo in a marker name is an error, not a silent
"never matches"; otherwise the loop would quietly find nothing at all.
Not enough? discovery = "manual" uses a hand-curated registry instead of (or
alongside) the automatic scan. The MAINTAINER keeps it up to date.
A trap worth knowing — measured on a real system. Folder-name patterns are dangerous with
combine = "any"if your intermediate levels follow the same convention as your projects. Categories namedCASH,DATA,CODINGmatch an uppercase pattern just like the projects beneath them — the scan stops at the category and never descends. Result: 46 wrong "projects" instead of 91 real ones.dir_patterns AND filesfixes it. That is whydir_patternsdefaults to off.
| Action | Rule |
|---|---|
| read / analyze | always allowed — a lock protects against change, not against knowledge |
| create a new file | usually allowed (does not collide with work on existing files) |
| modify a file | only without a foreign lock in scope |
And crucially: a lock in one project locks that project — not its siblings, and not the whole pipeline.
Different system, different lock scheme? provider = "rules" evaluates nothing —
it passes your rule files through as text into the prompt. Better an agent that
reads the real rule than a parser that guesses at its meaning.
All switchable. A disabled role aborts cleanly on start instead of silently
idling. combined = true runs all active roles in one worker — so
maintainer = false + combined = true gives you a 2-in-1 without needing its own
mode. Model choice belongs in the config, not in the launcher.
Tasks (this module) and tickets (file-based systems, IDs like T-YYYYMMDD-NN) are
separate systems. Tickets can become tasks, but need not. The only bridge is
api.add_from_ticket(...), which creates a normal task tagged ticket:<id>.
taskplan never imports, mirrors, or manages tickets.
Third pillar of the .MEMORY stack — USMC (curated session memory) · GARDENER
(organic memory + cross-source index) · TASKPLAN (tasks). Extracted from
rinnsal/tasks; rinnsal imports it back through a seam with a bundled fallback.
The table name rinnsal_tasks is kept deliberately, and schema changes are
additive only — existing readers keep working without migration.
Statuses: open, active, done, cancelled · Priorities: critical, high,
medium, low · Efforts: easy, medium, large, special · Scopes: local,
central.
python -m pytest tests/ -qMIT — Lukas Geiger