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.