vx why
vx why tells you why a task re-ran: the file, variable or dependency
that moved its cache key since the run before. It reads the local
cache.db only: no config evaluation, no re-hash.
vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]Example
Section titled “Example”$ vx why app#buildapp#build — run 01a1193a-7b39-75a9-870b-5c03e26d7104 this run 2026-10-08T01:57:05.486Z · success · executed · key 9ef9806a9c69209e previous 2026-10-08T01:57:05.325Z · success · key e240348b20e79fd6 verdict cache key changed: file packages/app/src/input.txt
what changed (1 component, 6 unchanged): changed file packages/app/src/input.txt 78981922613b2afb6025042ff6bd878ac1994e85 → 61780798228d17af2d34fce4cfbdf35556832472
what to do: file an edit re-runs by design; a file the task does not read belongs out of inputs.filesArguments
Section titled “Arguments”| Argument | Meaning |
|---|---|
PKG#TASK |
one project’s task |
TASK |
a bare task name: resolves when exactly one project ran it (several → the project the current directory is in, as vx run picks, else an error listing the candidates; unknown → include-match suggestions, or, with none near, a pointer to vx last --list, or word that nothing has run yet; after runs of only groups, which record no task row, that no run executed a task and to ask about one the group depends on, X-50) |
Options
Section titled “Options”| Option | Meaning |
|---|---|
--run RUNID |
explain that run instead of the latest; a unique prefix of the id is enough. A task that run did not run is refused, pointing at vx last --list, and so is an id no run carries |
--format pretty|json |
json emits one object, below |
--cache-dir <path> |
read the cache a run with the same flag uses |
By default it compares the task’s latest recorded run against its
immediately-previous run. History is
the cache directory’s, not the checkout’s: worktrees that share one
--cache-dir share one history, so the previous run may be another
worktree’s (its branch is in vx last), and its edits read as changes. Latest and
previous are the order runs were recorded, not their clock: a clock that
stepped back once swapped the two and diffed the edit backwards.
Understanding the output
Section titled “Understanding the output”The verdict. A changed key’s verdict names what moved, kind and name, three at most
and then a count (cache key changed: env MODE), so a key that moved
only by an environment variable says so in one line; when neither
entry kept its components it reads (inputs differ). A key that folds a
task with no inputs is new every run, so the task never hits; the
verdict names that task:
verdict never cached: its key folds app#gate, which declares no `inputs`, so each run derives a new key; declare inputs on app#gateAn unchanged key has three endings, and the verdict distinguishes
them rather than calling all three a re-run: the run was served from
cache (nothing re-ran), it re-executed on the same key, or it recorded
no cache outcome at all, in which case vx says so instead of guessing.
A re-execution names its cause when the index shows one: the previous
run on the key failed and saved nothing, the run did not read the cache
(--force, or a --cache-policy without read), no entry for the key was
there when it ran (pruned or evicted), the previous run was not saved
because it ran beside a failed task (a task run past a failed dependency
under --continue is never cached; said ahead of a prune, and as
“neither run saved it” when this run ran beside one too), or the previous run executed and no entry
holds the key (its save failed, or a prune took it; a previous run whose
policy wrote no cache says that instead). Otherwise, with this run’s
policy recorded as reading the cache, it says it cannot name the cause;
only a run with no recorded policy names --no-cache / --force.
The run lines. A hit’s line is cache-hit · key … (or cache-hit-remote): the status
names the hit and its tier, so only an executed run carries the word.
A skipped task never ran and derived no key: its line is skipped · no key.
What to do and root cause. Under the rows, what to do gives one line per changed kind: what
moves it and how to stop a move the task does not need. An upstream
row is only the carrier, so vx why follows each dependency that moved
in the same run down to the tasks whose OWN inputs moved and prints
them under root cause, one chain per root (five at most, then a
count), and what to do speaks to those kinds instead:
root cause: app#build ← lib#build ← file packages/lib/src/index.tsA dependency the walk cannot find in the run keeps the upstream line,
which names the vx why to run next; a config line names the
vx show that prints the task’s config now.
Changed during a run. A moved input file whose last change fell inside a recorded task’s run window names that task: a task that writes a file another key reads (a build rewriting a translation file in another package) re-keys that key on every run, so the miss is right and only moving the write ends it. An edit made outside a run names none.
changed during a run: packages/lib/src/gen.txt while app#build ranKinds. A row’s kind is what the key folded:
| Kind | What the key folded |
|---|---|
file |
an input file, by blob id |
linked |
the content behind a file that is a symlink |
env |
a declared variable, by digest |
runtime, ws-runtime |
a declared command’s output, inputs.runtime and inputs.workspaceRuntime |
forward |
the argv forwarded after -- |
config |
the evaluated task config |
upstream |
a dependency’s input key — a lockfile change moves it too, so that row rides with the lockfile plugin’s |
plugin |
a key plugin’s material, by name; a lockfile plugin’s dependency closure |
platform |
the platform a task naming exec.platforms ran on |
format |
the key format vx itself folds: it moves only with an upgrade, and re-keys every task once, X-142 |
A control character in a component’s name (a file named with an
escape or a carriage return) prints as \xNN, so no file name drives
the terminal; --format json carries the name as it is.
When there is no diff. The component-level rows come from the entry_inputs input
fingerprints persisted with each cache entry; when either side’s entry
is gone (pruned, or the run failed and never saved one) the verb still
names the hash change and says the component diff is unavailable. When
only the previous run failed, or passed beside a failed task
(--continue, never saved), the diff is taken against the last run
that saved an entry instead, and the detail line names that run. A
task with inputs and no cache block derives a key too — it is what
dependents fold — but saves no entry, so for it the verb can only report
the key change and says so.
--format json emits one machine-readable object ({ taskId, runId, why, diff, roots, writers }); when the task’s recorded runs predate run ids, { taskId, why, diff, explanation }, why and diff null and explanation the
latest cache entry’s key. roots is the root-cause walk (chain,
entries); writers the changed-during-a-run files (taskId, file,
writers).
Exit codes
Section titled “Exit codes”| Code | When |
|---|---|
0 |
the task’s run explained |
1 |
no recorded run of the task, a --run with no row for it, or a parse error |