Skip to main content
TaskForge 3.0: your AI can read your tasks.
← What's new

Complete, delete, edit, reparent, move, or convert many tasks from an agent

Shipped in version 3.0.0

Ask an agent to "mark everything overdue as done" and, until now, it had to loop one task at a time. That is slow, and it is fragile: the moment two of those tasks live in the same file, completing the first shifts the line numbers of the rest, and the naive second write misses.

The taskforge CLI and the built-in MCP server now do the whole batch in one call, and they get the line-shift problem right. The same batching also covers reparenting, moving between files, and converting between inline and task file storage, so "file everything tagged #triage under the Inbox project" or "move every task in this project to archive.md" is one call too.

On the command line

The done, rm, edit, reparent, move, and convert verbs each take a --query or an --ids flag instead of a single task id. Every existing flag still works.

taskforge done --query "overdue:true"
taskforge rm --query "status:done project:archived" --yes
taskforge edit --ids abc123,def456 --add-tag reviewed
taskforge reparent --query "tag:triage" --under proj456
taskforge move --query "project:archived" --to-file archive.md
taskforge convert --ids abc123,def456 --to tasknotes

--query runs a query and operates on the matches; --ids takes a comma list. You give exactly one of them, and you cannot mix either with a positional id. rm still needs --yes, and --dry-run still previews without writing. reparent takes a single shared --under <id> (or --to-root) for the whole batch; move takes a single shared --to-file; convert takes a single shared --to tasknotes|inline. --if-hash (and, for move, --dest-if-hash) is single-task only and refused in batch mode, since one hash can't guard N writes.

The result is a per-task list plus a summary:

{
  "outcomes": [
    { "id": "abc123", "status": "applied" },
    { "id": "def456", "status": "failed", "reason": "no task with id 'def456'" }
  ],
  "applied": 1,
  "failed": 1,
  "skipped": 0
}

One unknown id, or a task that changed on disk, is reported as failed and the rest of the batch still runs. Nothing stops on the first error, and every result is keyed by the id you asked for, so an agent can retry just the failures.

From an MCP client

The server adds six bulk tools alongside the single-task ones: bulk_complete_tasks, bulk_delete_tasks, bulk_update_tasks, bulk_reparent_tasks, bulk_move_tasks, and bulk_convert_tasks. Each takes ids or query (one of the two), returns the same outcomes list, and supports dry_run for a preview.

bulk_complete_tasks runs the real completion path, so a recurring task in the batch still spawns its next occurrence and an onCompletion rule still fires. bulk_update_tasks applies one field patch to every match, the same tags, dates, priority, and properties you would set on a single task. It does not set status; completion goes through bulk_complete_tasks so recurrence is handled correctly.

bulk_reparent_tasks moves every match under one shared new parent (under, the new parent's id) or promotes them all to root (to_root: true). bulk_move_tasks relocates every match to one shared destination file (to_file). bulk_convert_tasks flips every match's storage format, inline to task file or back (to: "tasknotes" | "inline"), the same lossy-field reporting a single convert_task gives you.

All three share one rule for overlapping targets: if a batch names both a task and its own descendant, the descendant is reported skipped (it already moved with its ancestor's subtree), never applied twice and never failed. Because a bulk reparent has to identify each target task freshly after earlier writes in the same batch may have shifted things around it, a target that is a byte-for-byte duplicate of another task with the same children in one file can't be told apart safely; that one is reported failed with a reason telling you to reparent it individually. bulk_move_tasks also doesn't take a destination compare-and-swap hash (there is no single hash that can guard a shared destination file across N writes); each write still auto-guards against the destination it just read.

The four mutating bulk tools carry the same blast-radius gate as delete. When you call bulk_update_tasks, bulk_move_tasks, bulk_convert_tasks, or bulk_reparent_tasks with a query, the commit is refused unless you also pass acknowledge_count equal to the exact number of tasks that query matches. An empty query, or a broad one like overdue:false, matches most of the vault, so a model that misjudges its own selector cannot rewrite, relocate, or reparent hundreds of tasks in one unchecked call. Run with dry_run: true first; it writes nothing and reports the count, then repeat with that number as acknowledge_count. Calling by an explicit ids list needs no count, since you already named the exact tasks. These four do not ask for acknowledge: true the way delete does, because an edit, move, or reparent can be walked back and a delete cannot.

bulk_delete_tasks is the destructive one. A single delete asks the model to echo the task's title back first, but there is no single title to confirm for a set, so this tool asks for acknowledge: true instead. Run it with dry_run: true first to see the per-id blast radius, then repeat with acknowledge: true to commit.

Deleting by query carries one more gate: the tool also requires an acknowledge_count equal to the exact number of tasks the query matches. A model that skips the dry run, or misjudges how broad its own query is, gets refused before anything is deleted rather than finding out the size of the damage afterward. Deleting by an explicit ids list does not need acknowledge_count, since you already named the exact tasks.

A delete does more than remove the one line. Delete a task and any dependency reference pointing at it is stripped from the tasks that depended on it, so no task is left pointing at something that no longer exists. Delete a task file task with with_children, and the child task files linked under it are deleted too, all the way down the tree. A plain delete instead keeps those children and promotes them: an inline child moves out to the top level, a task-file child loses its parent link. The dry_run output lists every file the delete will touch, not just the named tasks, so run it first when a task has children or dependents.

Same result as the app

Whether the batch runs from the app's selection mode, the CLI, or an MCP client, the on-disk result is identical for the same input. The batch reuses the same completion, delete, and edit logic a single task goes through, so recurrence, onCompletion, archiving, and subtask cascades all behave the way they do everywhere else in TaskForge.

Each task in a batch is resolved against a fresh scan right before it is written, so the file stays correct even when several matched tasks share one file. For bulk_complete_tasks and bulk_update_tasks that resolution is scoped to each target's own file, so batching a few hundred completions or edits in a large vault costs roughly one vault-wide pass plus one small per-file check, not one vault-wide pass per task.

Delete, reparent, move, and convert still re-scan the whole vault for every item. That is deliberate: deleting has to find every cross-file dependency reference and orphaned child before it writes, and reparent/move/convert can retarget a task into a different file, so each needs the full picture, not just the file it started in. A batch of a few hundred deletes, reparents, moves, or conversions in a very large vault will take noticeably longer than the same batch of completes or edits.