Skip to content

Repository files navigation

TASKPLAN

taskplan

Python 3.10+ License: MIT Tests llms.txt

Deterministic task selection for LLM agents. Zero dependencies, stdlib only, Python ≥ 3.10.

Note

AI / LLM Integration: taskplan provides deterministic selection guards and role prompts for autonomous AI agents. For detailed system concepts and documentation overview, see llms.txt.

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?).


The rule the selector enforces

  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.

Gates that live in code, not in prose

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.


Quick start

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 markers

next writes the same human-readable designation to the console and, with --json, to exit.code, exit.name, and localized exit.meaning:

Code Stable name Meaning
0 BUNDLE_READY Bundle delivered successfully
1 NO_WORK Role is active, but no eligible bundle is currently available
2 ROLE_DISABLED Role is disabled in configuration
3 RETRYABLE_SELECTOR_ERROR Retryable selector/discovery error

Project-only MAINTAINER bundles and TASKWRITER discovery sweeps intentionally contain no task IDs. The selector stores a separate cursor for each role atomically at ~/.taskplan/rotation-state.json (configurable with [loop].rotation_state_file) and advances to the next candidate on the next run. This cursor does not rotate away from a real, still-unprocessed task bundle. A project with no evidenced maintenance work or new task can be skipped explicitly:

python -m taskplan skip --role maintainer --project "<path>"
python -m taskplan skip --role taskwriter --project "<path>"

New tasks remain the responsibility of the TASKWRITER/TASKSOLVER flow.

Who created it, who works on it

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_status

Whoever takes a task writes to assigned_tonever to the field carrying the origin.


Three roles

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.

Before its first selector call, every TASKSOLVER provider performs a one-time TASKPLAN control-plane preflight: doctor, effective runtime/profile wiring, evidence-based maintenance of TASKPLAN itself, and a current model check against official provider sources plus local CLI availability. A model is changed only when role capability, stability, latency, and cost show a clear benefit. This does not authorize general project tidying; normal project work still begins with the selector.

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_path

Prompt language

All three roles exist in English and German. Default is English; the module is meant to be user-neutral.

[language]
prompts = "de"        # de | en

Override 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.


Everything is configurable — nothing is hardcoded

See taskplan.example.toml for the fully commented version.

Storage

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_DBtaskplan.toml [storage].path → env RINNSAL_DB~/.taskplan/taskplan.db.

python -m taskplan doctor warns 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.

Project discovery

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 named CASH, DATA, CODING match 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 files fixes it. That is why dir_patterns defaults to off.

Locks — three axes, not one switch

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.

Roles, models, task sources, depth

All switchable. A disabled role aborts cleanly on start instead of silently idling. combined = true is currently parsed and exposed as configuration, but no bundled runner or launcher consumes it yet; it is therefore not a functional 3-in-1/2-in-1 mode. Model choice belongs in the config, not in the launcher.

Provider-neutral runtime and Codex goals

Launchers are intentionally thin. [execution] provider selects a default provider; [providers.<name>.models] and [providers.<name>.reasoning_effort] select values per role. The legacy [models] section remains a compatible fallback.

Codex uses continuation = "goal". TASKPLAN generates an explicit user startup prompt that authorizes a persisted goal, processes one bundle per continuation, then asks the selector again. empty_policy = "keep_goal" prevents a single empty result from being mistaken for a permanently empty queue. The generated goal contract must call python -m taskplan backoff ...; that command performs the real idle_backoff_seconds wait before polling again. python -m taskplan runtime ... exposes the profile to any shell; python -m taskplan startup-prompt ... emits the provider-specific user request. No user name, home path, or model is hardcoded in the launcher.

The wheel includes nine user-neutral Windows launchers for TASKSOLVER/TASKWRITER/MAINTAINER × Claude/Codex/Agy:

python -m taskplan starters list
python -m taskplan starters path --role tasksolver --provider codex
python -m taskplan launch --role tasksolver --provider codex

Configure model identifiers and reasoning levels in ~/.taskplan/taskplan.toml. TASKPLAN_WORKDIR optionally selects the worker directory; TASKPLAN_CLAUDE_MCP_CONFIG optionally supplies a Claude MCP profile. Packaged launchers use normal provider permission prompts by default. Only a trusted local automation should set TASKPLAN_TRUSTED_AUTOMATION=1 to request unattended write permissions. TASKPLAN_STARTER_DRY_RUN=1 prints the resolved command without starting the provider.

Project discovery has its own discovery_timeout_seconds and a portable, sectorized snapshot cache under ~/.taskplan/. cache_ttl_seconds defaults to 86400 (24 hours) and is a refresh interval, not an expiry date. Each selector process refreshes at most one configured root sector. A sector that stalls is recorded as attempted and rotates behind other due sectors on the next run.

Previously known projects remain usable as last-known-good data until their sector has been replaced by a successful refresh—even after the interval elapsed or the discovery policy changed. Model, provider, and reasoning changes no longer invalidate the project inventory. The directory walker uses one scandir operation per directory and isolates per-entry errors, mirroring the cloud-tolerant pattern used by FileCommander without depending on it.

On a cloud-filesystem timeout, next continues in this order: sector cache plus manual registry, then known project_path values from the task store. Exit 3 is returned only when discovery failed and no safe local inventory remains; otherwise the JSON discovery object identifies source, degraded, cache_age_seconds, refreshed_sector, pending_sectors, and warnings.

[traversal]
discovery_timeout_seconds = 30
cache_ttl_seconds = 86400  # per-root refresh interval; LKG survives failures

Tasks are not tickets

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.

Origin & compatibility

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.

Tests

python -m pytest tests/ -q

License

MIT — Lukas Geiger

About

Deterministic task selection for LLM agents: a code-side selector decides what comes next so the backlog cannot hide, while the model keeps the judgment calls. Zero dependencies, stdlib only

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages