Skip to main content
Task dependencies form a graph. TaskGraph reads that graph to pick the next task, to show what holds up the most work, and to explain why a task can’t start. The analysis (bottlenecks, critical path, parallel waves, blockers, cycles) lives in @tm/core (packages/tm-core/src/modules/dependencies/), and taskgraph graph, taskgraph why, taskgraph next and the dependency_analysis MCP tool only display it. Some older code paths still do their own thing; see Open issues.

How next orders tasks

taskgraph next (and the recommendation in taskgraph list) considers tasks that are pending or in-progress and whose dependencies are all done. Subtasks of an in-progress parent are considered first, when both their own dependencies and their parent’s are done (see Parents and subtasks). A subtask whose parent is not in-progress is not offered on its own: next offers the parent, and once you start it, next offers its subtasks. Among the candidates it picks by:
  1. Priority: critical, then high, medium, low.
  2. Unblocks the most: the task with the most unfinished tasks waiting on it, directly or through other tasks. Between two subtasks that tie, the one more of its own task waits on.
  3. Fewer dependencies: the task that lists fewer dependencies.
  4. ID: the lowest ID.
Step 2 counts every unfinished task, including deferred, blocked and review ones, because they still wait on their dependencies. A task is “unfinished” unless it is done or cancelled. It counts top-level tasks: a parent and its subtasks are one piece of work, however finely the task was expanded. A task in front of a parent with ten subtasks counts that parent once, the same as before the parent was expanded, and subtasks that wait through their parent count toward that parent. A subtask doesn’t count its own parent or siblings, so one that holds up only its own task counts 0 (step 2 then compares how many of its siblings wait on it).

How a subtask’s dependencies are read

A subtask’s dependencies can name a sibling, another task’s subtask or a top-level task. The stored value decides which:
  • A number below 100 (3) is a sibling subtask: 3 under task 2 is 2.3.
  • A dotted string ("4.1") is that subtask.
  • Anything else (a string without a dot, like "3", or a number of 100 or more) is a top-level task.
add-dependency and add-subtask --dependencies (and their MCP tools) take the IDs you see in list: 3 is task 3 and 2.1 is that subtask. They store a task as a string ("3"), so it is never read as a sibling. To depend on a sibling, give its dotted ID (2.1 under task 2). add-subtask rejects an entry that is not a task or subtask ID. When expand, update-task or update has the AI write subtasks, the AI uses the same forms: a number for an earlier subtask in its list, a string for a task ("3") or another task’s subtask ("4.1"). Entries that are none of these, and a dependency on the subtask’s own parent, are dropped. update-task also drops a sibling number that is not earlier than the subtask itself. On a top-level task there is no sibling to name: a number or a plain string is a task, and a dotted string is a subtask. add-dependency and add-task --dependencies store a task there as "3" and a subtask as "3.2", the form tm-core saves, and add-dependency rewrites a list that mixes 3 and "3" into that form when it adds to it. next, why, graph, the dependency commands and the MCP tools all read dependencies by this rule (resolveSubtaskDependency in @tm/core).

Parents and subtasks

A subtask is part of its parent, so the analysis links them both ways:
  • A subtask waits on what its parent waits on. If task 3 depends on task 2, subtask 3.1 can’t start before task 2 is done either, even though 3.1 lists nothing. why, next, the critical path, bottleneck counts and loop checks all follow this. A parent’s dependency on one of its own subtasks is not passed down.
  • A parent isn’t finished until its subtasks are. The critical path orders a parent after its open subtasks, so a chain can run 1 → 3.1 → 3.2 → 3 → 5. A subtask that lists its own parent keeps that dependency, and the parent is then not ordered after it.
validate-dependencies and fix-dependencies check only the dependencies written on each task and subtask, so they don’t report a loop that only exists through a parent (task 2 needs 4, and 4 needs 2.1). why and the cycles of dependency_analysis do.

taskgraph graph

Each task is drawn once, under the dependency it waits on longest. Its other dependencies follow the title (needs 4). The task next would pick is marked ← next; when next picks a subtask, its parent is marked ← next: 3.2. Tasks in a dependency loop and dependency IDs that match no task are called out.
  • blocks N marks a bottleneck: an unfinished task that N unfinished top-level tasks wait on, directly, through other tasks, or through a subtask. A parent and its subtasks count as one, as in next. The list shows the top five, most first, ties to the lowest ID. Tasks that block nothing are left out.
  • wave N is the task’s parallel wave. Wave 1 is every task that can run now: pending or in-progress with all its dependencies finished. Wave N holds the tasks whose dependencies are all finished or in earlier waves, so everything in one wave can run side by side. The line under the critical path counts the waves and names wave 1; N can't be scheduled counts the unfinished tasks with no wave (see Parallel waves).
  • ◆ marks the critical path: the longest chain of unfinished work, dependencies first, through parents and subtasks as described above. Ties go to the lowest IDs.
  • needs 2.1: a dependency on a subtask places the task under the subtask’s parent (task 2), and the subtask is always listed after the title, since the parent is not what the task waits on.
  • * marks a dependency the AI chose rather than you (see Dependency provenance). After an ID in a needs list, it marks that dependency. After a task’s own ID (2*), it marks that task’s edge to the task drawn above it.

JSON output

--format json returns the tree plus the analysis:

Known limits

  • Tasks in a dependency cycle, and tasks that depend on one, are left out of the critical path, since they can’t be started. Use taskgraph why to find and break the loop.
  • The tree shows top-level tasks only. bottlenecks and criticalPath can name subtasks such as 2.1; they appear in those lists but not as tree nodes.
  • The tree reads only top-level tasks’ dependencies, so a loop formed by a subtask’s own dependencies (task 4 needs 2.1, and 2.1 needs 4) is not called out in the tree; why, validate-dependencies and the cycles of dependency_analysis report it. A loop through a parent’s dependencies (task 2 needs 4, and 4 needs 2.1) is called out.
  • A loop that only exists because a parent finishes after its subtasks (subtask 2.1 needs task 3, and 3 needs task 2) keeps those tasks off the critical path, but is not reported as a cycle: each task can still start.
  • A dependency on an archived task (see taskgraph archive) counts as done and isn’t drawn: the task sits at the top level, or under its other dependencies.
  • --id draws the task and the tasks placed under it. Each task is drawn under only one dependency (the one it waits on longest), so a task that also needs the chosen task, but is drawn under another dependency, does not appear. The chosen task’s own parent edge is not shown or marked, and bottlenecks and the critical path still cover the whole tag.

Parallel waves

parallelWaves(tasks) in @tm/core (parallel-waves.ts) groups unfinished tasks into waves. It is what graph shows and what a runner that starts several tasks at once should read.
  • Top-level tasks only. A task is the unit of parallel work; its subtasks run inside it. This matches the tree graph draws.
  • Finished tasks (done, cancelled) get no wave and satisfy whatever waits on them.
  • A dependency on a subtask (2.1) is satisfied once that subtask or its parent is finished. Until then the task goes in a wave after the parent (task 2).
  • Unfinished tasks with no wave are listed in unscheduled with a reason:
    • cycle: in a dependency loop (a task that lists itself counts). blockers names the loop members it depends on.
    • missing: names a dependency ID that matches no task. next never picks such a task, so it gets no wave either. blockers lists the IDs.
    • held: review, blocked or deferred. Only pending and in-progress tasks go in a wave.
    • waiting: depends on a task that has no wave. blockers lists those tasks.

taskgraph why

why follows a task’s unfinished dependencies down to the root causes and prints the tree of blockers, then what to do:
Each step wraps to the terminal width, continuing under a deeper indent. A chain ends at one of four root causes: ready (unfinished, nothing in its way), stalled (deferred or blocked), missing (the ID matches no task) or cycle (it depends on itself through the chain). The JSON has id, title, status, blocked, tag, a nested blockers tree and rootCauses; see why for the full shape. A subtask also waits on what its parent waits on. why draws those blockers under the parent, marked ← parent:
In the JSON, that node has parentOf set to the subtask’s ID. The parent itself is not something to finish first; only its blockers are.

Exit codes

why and graph exit 0 on success, whether or not the task is blocked. They exit 1 for a missing or malformed ID (why), an unknown task or tag, or an unsupported --format.

Dependency provenance

Every dependency is either explicit (you added it, or it was already there) or inferred (the AI chose it in parse-prd, add-task, expand, update, update-task, or scope-up / scope-down). A dependency you asked for in an update prompt is still recorded as inferred, because the model decides which IDs to write. The other AI commands (research, analyze-complexity, update-subtask) never write dependencies.

Where it is stored

Provenance lives in the task’s metadata.dependencySources, a map from dependency ID to "inferred":
  • Only inferred dependencies are stored. A dependency with no entry is explicit, so existing files need no migration.
  • Keys are full IDs: the ID the dependency names by the rule above. A task’s keys are plain IDs ("4"). On a subtask of task 2, a sibling is keyed "2.1" whether it is stored as 1 or "2.1", and a task stored as "3" is keyed "3". Code reads marks through getDependencySource in @tm/core, never from raw keys.
  • dependencySources is a reserved metadata key. The metadata parameter of the MCP update_task and update_subtask tools (available only with TASKGRAPH_ALLOW_METADATA_UPDATES=true) drops it, so a hand-written value can’t overwrite provenance.
  • Marks for a dependency that is removed are dropped with it, and a moved task carries its marks to the new ID.
  • Running add-dependency for a dependency that is already inferred makes it explicit.
  • Known limit: when update-task regenerates subtasks and the AI reorders them, subtasks are renumbered without remapping their sibling dependencies, so a sibling dependency can change source.
A dependency means “wait for this task”. Sometimes two tasks are only connected: they touch the same code, or you found one while doing the other. Link them without making either wait:
There are three link types: Only blocks counts anywhere a task waits: next, why, graph (blocked counts and the critical path), loop checks, and validate-dependencies / fix-dependencies. show lists the other two after what the task depends on:
  • Changing the type. Running add-dependency --type=related on a task that already depends on the other turns the dependency into a link, so the task stops waiting. Running plain add-dependency on a linked pair turns the link back into a dependency.
  • Removing a link. remove-dependency removes a link the same way it removes a dependency.
  • Cleanup. Removing a task removes the links to it (and to its subtasks), as it does dependencies. Moving a task within a tag carries the links to its new ID. Moving it to another tag drops links that would cross tags. validate-dependencies reports a link to a task that doesn’t exist and fix-dependencies removes it.
  • MCP. add_dependency takes type (blocks, related or discovered-from) and add_task takes discoveredFrom.
Non-blocking links live in the task’s metadata.taskLinks, a map from full ID to link type, not in dependencies:
Everything in dependencies is a blocks link, so files written before link types existed load unchanged. Keeping the other links out of dependencies means no code that reads dependencies can mistake them for blockers. Keys are always full IDs ("2.1" for a subtask), on a task or a subtask. If the same task is both a dependency and a link, the dependency wins. Like dependencySources, taskLinks is a reserved metadata key that the metadata parameter of update_task and update_subtask drops.

Schema version

Each tag in .taskgraph/tasks/tasks.json records metadata.schemaVersion (currently 1), stamped whenever TaskGraph saves the tag, from any command, and in the empty file init creates. A missing or malformed value is read as version 1. A tag written by a newer version, or with a malformed schemaVersion, prints a warning to stderr once per file and tag, and is still read. The stamp never lowers a version already on disk.

MCP

The dependency_analysis tool, in the standard and all tiers, returns the bottlenecks, critical path and cycles for a tag. Pass id to also get an explanation with the blockers and root causes for one task (what taskgraph why shows). limit sets how many bottlenecks come back (default 5). A tag the project doesn’t have is an error, not an empty result. See MCP tool loading for the tiers.

Open issues

These are known and not yet fixed:
  • What counts as finished differs for subtasks. next, why and graph all treat done and cancelled as finished, but next only looks for finished work under parents that are pending, in-progress, done or cancelled. A done subtask under a parent in review, blocked or deferred satisfies a dependency in why and graph, but never in next.
  • Older writers don’t stamp a schema version. Tags created by legacy code paths have no schemaVersion until the next @tm/core save; a missing value means version 1.
  • Graph limits: cycle members and tasks behind a loop are left out of the critical path, and the tree doesn’t call out a loop formed only by a subtask’s own dependencies (see taskgraph graph).