# Every feature > Every user-facing feature of vx, one line each, grouped by what it is Every user-facing feature of vx, one line each, grouped by what it is for. Each line names its surface (verb, flag, config key, plugin), links its page on the site's [Features](https://vznjs.github.io/vx/features/) hub when it has one, and its blog post, or says "no post" when the story is still owed. **Rule:** a change that adds or changes a user-facing feature updates this file, and its post, in the same commit (`CLAUDE.md`). `tests/features-inventory.unsafe.test.ts` fails when a CLI verb, a flag, a config key or an environment variable is missing here. ## Run - **Run a task** (`vx run`) — run a task in the cwd's project, `pkg#task` directly, or several at once. [page](https://vznjs.github.io/vx/features/quickstart/) · post: [The basics, done carefully](https://vznjs.github.io/vx/blog/the-basics/) - **Every project** (`--all`) — run the task in every project that declares it. [page](https://vznjs.github.io/vx/features/quickstart/) · post: [The basics, done carefully](https://vznjs.github.io/vx/blog/the-basics/) - **pnpm-style filters** (`--filter`) — select projects by name, glob, path, dependencies (`foo...`), dependents (`...foo`), negation or a git range. [page](https://vznjs.github.io/vx/features/affected/) · post: [Say exactly which tasks to run](https://vznjs.github.io/vx/blog/pick-your-tasks/) - **Affected only** (`--affected`, `affectedBase`) — run what a change reaches, following task edges from a git base. [page](https://vznjs.github.io/vx/features/affected/) · post: [Run only what a change reaches](https://vznjs.github.io/vx/blog/affected/) - **Concurrency** (`--concurrency`, `concurrency`) — a count or a share of the CPUs this process may use (`50%`). [page](https://vznjs.github.io/vx/features/concurrency/) · post: [One failure, and exactly what it takes down](https://vznjs.github.io/vx/blog/when-a-build-fails/) - **Skip dependencies** (`--exclude-dependencies`) — skip all `dependsOn` edges, or named ones. [page](https://vznjs.github.io/vx/features/skip-dependencies/) · post: [Say exactly which tasks to run](https://vznjs.github.io/vx/blog/pick-your-tasks/) - **Failure policy** (`--continue`) — never, deps-ok (default: dependents skip) or always. [page](https://vznjs.github.io/vx/features/skipped-blockers/) · post: [One failure, and exactly what it takes down](https://vznjs.github.io/vx/blog/when-a-build-fails/) - **Every skip names its blocker** — a skipped task says which failure blocked it. [page](https://vznjs.github.io/vx/features/skipped-blockers/) · post: [One failure, and exactly what it takes down](https://vznjs.github.io/vx/blog/when-a-build-fails/) - **Retries** (`--retry`, `exec.retries`) — re-run a failed task; a pass after a failure marks it flaky. [page](https://vznjs.github.io/vx/features/flaky-detection/) · post: [Flaky is a claim only declared inputs can back](https://vznjs.github.io/vx/blog/flaky-tasks/) - **Timeouts** (`--timeout`, `exec.timeout`, `timeout`, `VX_TASK_TIMEOUT`) — kill and fail a runaway task. [page](https://vznjs.github.io/vx/features/timeouts/) · post: [When a task misbehaves](https://vznjs.github.io/vx/blog/tasks-that-misbehave/) - **Argument forwarding** (`--`) — args after `--` reach the task's command and fold into its key. [page](https://vznjs.github.io/vx/features/forward-args/) · post: [When a task misbehaves](https://vznjs.github.io/vx/blog/tasks-that-misbehave/) - **Task picker** — `vx run` with no task in a terminal lists tasks to pick. [page](https://vznjs.github.io/vx/features/task-picker/) · post: [The small things](https://vznjs.github.io/vx/blog/the-small-things/) - **Typo hints** — an unknown task, project or filter suggests the nearest name. [page](https://vznjs.github.io/vx/features/filter-hints/) · post: [The small things](https://vznjs.github.io/vx/blog/the-small-things/) - **Turbo and Nx spellings** (`-t`, `-p`, `--exclude`, `--parallel`, `--base`, `--dry-run`, `--skip-nx-cache`) — accepted, or refused naming the vx spelling. [page](https://vznjs.github.io/vx/features/turbo-nx-flags/) · post: [Flags you already know](https://vznjs.github.io/vx/blog/flags-you-already-know/) - **Ctrl-C leaves nothing running** (`VX_KILL_GRACE_MS`, `VX_TEARDOWN_TIMEOUT_MS`) — the whole process tree stops, then teardown runs. [page](https://vznjs.github.io/vx/features/ctrl-c/) · post: [Ctrl-C leaves nothing running](https://vznjs.github.io/vx/blog/ctrl-c/) - **Longest chain first** — the scheduler starts the critical path first; `@vzn/vx-schedule-history` learns it from past runs. [page](https://vznjs.github.io/vx/features/critical-path/) · post: [Bitsets, popcount, and a scheduler tick](https://vznjs.github.io/vx/blog/bitsets-and-the-scheduler/) - **No daemon** — every run starts cold and still answers in milliseconds. [page](https://vznjs.github.io/vx/features/no-daemon/) · post: [No daemon, on purpose](https://vznjs.github.io/vx/blog/no-daemon/) - **Group tasks** (`dependsOn` with no `exec`) — a task that only runs its dependencies; `dependsOn: []` is a named no-op. [page](https://vznjs.github.io/vx/features/group-tasks/) · post: [Say exactly which tasks to run](https://vznjs.github.io/vx/blog/pick-your-tasks/) - **Keyed groups** (`cache` with no `exec`) — a key a dependant folds: it re-runs on those inputs, and nothing spawns or counts for the group. [page](https://vznjs.github.io/vx/features/group-tasks/) · post: [A key with nothing to run](https://vznjs.github.io/vx/blog/keyed-groups/) - **Implicit keyed `build`** — a project with no `build` gets a `^build` group keyed on its files, so source-only packages still move their dependants' keys. [page](https://vznjs.github.io/vx/features/implicit-build/) · post: [The basics, done carefully](https://vznjs.github.io/vx/blog/the-basics/) - **dependsOn syntax** (`^name`, `pkg#name`, `name.*`, `^name.*`) — upstream, cross-project and pattern edges. [page](https://vznjs.github.io/vx/features/depends-on-syntax/) · post: [Say exactly which tasks to run](https://vznjs.github.io/vx/blog/pick-your-tasks/) - **Every name must resolve** — `vx run lint test typecheck` refuses to start if one name matches no project. [page](https://vznjs.github.io/vx/features/names-must-resolve/) · post: [Say exactly which tasks to run](https://vznjs.github.io/vx/blog/pick-your-tasks/) - **Tag filters** (`--filter tag:`) — select projects by their config `tags`. [page](https://vznjs.github.io/vx/features/tag-filters/) · post: [Say exactly which tasks to run](https://vznjs.github.io/vx/blog/pick-your-tasks/) - **Directory and root filters** (`./`, `{}`, `.`, `//`) — select projects by path, or the root project. [page](https://vznjs.github.io/vx/features/dir-filters/) · post: [Say exactly which tasks to run](https://vznjs.github.io/vx/blog/pick-your-tasks/) - **Executor pools** (executor `capacity`) — a remote pool is admitted against its own width, not the laptop's cores. [page](https://vznjs.github.io/vx/features/executor-pools/) · post: [Remote execution without moving the scheduler](https://vznjs.github.io/vx/blog/remote-execution/) - **Runs take turns** — two vx runs on one workspace wait on a lock and name who they wait for. [page](https://vznjs.github.io/vx/features/run-lock/) · post: [When a task misbehaves](https://vznjs.github.io/vx/blog/tasks-that-misbehave/) ## Output - **Framed output** — each task's log in its own frame, never interleaved. [page](https://vznjs.github.io/vx/features/framed-output/) · post: [A run you can read](https://vznjs.github.io/vx/blog/a-run-you-can-read/) - **Output modes** (`--output-logs`) — full, errors-only, hash-only or none; the default follows the flow. [page](https://vznjs.github.io/vx/features/output-modes/) · post: [Output that fits the run](https://vznjs.github.io/vx/blog/output-that-fits-the-run/) - **Run summary** — one block: projects, tasks, cache, time; nothing prints below it. [page](https://vznjs.github.io/vx/features/run-summary/) · post: [A run you can read](https://vznjs.github.io/vx/blog/a-run-you-can-read/) - **Per-task table** (`--verbosity`) — a per-task summary after the run. [page](https://vznjs.github.io/vx/features/per-task-table/) · post: [One failure, and exactly what it takes down](https://vznjs.github.io/vx/blog/when-a-build-fails/) - **Failed output kept for agents** — a failure's full log is saved and pointed to. [page](https://vznjs.github.io/vx/features/failed-output-kept/) · post: [Built for the agent at the keyboard](https://vznjs.github.io/vx/blog/built-for-agents/) - **Markdown report** (`--report`, `--report-file`) — a run report for a PR or `$GITHUB_STEP_SUMMARY`. [page](https://vznjs.github.io/vx/features/markdown-report/) · post: [Output that fits the run](https://vznjs.github.io/vx/blog/output-that-fits-the-run/) - **Run JSON** (`--summarize`) — per-run JSON for scripts, with the time the cache saved (`savedMs`). [page](https://vznjs.github.io/vx/features/run-json/) · post: [See inside a run](https://vznjs.github.io/vx/blog/see-inside-a-run/) - **Trace profile** (`--profile`) — Chrome-trace JSON of the run. [page](https://vznjs.github.io/vx/features/profile/) · post: [Output that fits the run](https://vznjs.github.io/vx/blog/output-that-fits-the-run/) - **Run tags** (`--tag`) — label a run; recorded in history. [page](https://vznjs.github.io/vx/features/run-tags/) · post: [See inside a run](https://vznjs.github.io/vx/blog/see-inside-a-run/) - **Stage timing** (`VX_TIMING`) — vx's own stage table, for performance work. [page](https://vznjs.github.io/vx/features/stage-timing/) · post: [See inside a run](https://vznjs.github.io/vx/blog/see-inside-a-run/) - **Output flows** — what is printed follows the run's intent (focused, broad or CI); a truthy `CI` wins. [page](https://vznjs.github.io/vx/features/output-flows/) · post: [Output that fits the run](https://vznjs.github.io/vx/blog/output-that-fits-the-run/) - **Cache-aware glyphs** — each task line's glyph shows ran, fresh, restored locally or remotely, failed, skipped or persistent. [page](https://vznjs.github.io/vx/features/cache-glyphs/) · post: [Output that fits the run](https://vznjs.github.io/vx/blog/output-that-fits-the-run/) - **GitHub Actions log groups** — on Actions, each task's block folds in a `::group::` with its outcome and time. [page](https://vznjs.github.io/vx/features/actions-log-groups/) · post: [The basics, done carefully](https://vznjs.github.io/vx/blog/the-basics/) - **Colors** (`NO_COLOR`, `FORCE_COLOR`) — truecolor output, forced on or off by env. [page](https://vznjs.github.io/vx/features/colors/) · post: [The basics, done carefully](https://vznjs.github.io/vx/blog/the-basics/) - **Signal-named exits** — a failure reads `exit 137, 128 + SIGKILL`. [page](https://vznjs.github.io/vx/features/signal-exits/) · post: [When a task misbehaves](https://vznjs.github.io/vx/blog/tasks-that-misbehave/) - **Plain output off a TTY** — no live region, and a missing task lists tasks instead of opening the picker. [page](https://vznjs.github.io/vx/features/plain-off-tty/) · post: [The basics, done carefully](https://vznjs.github.io/vx/blog/the-basics/) ## Plan and explain - **Dry run** (`--dry`) — the task graph and the predicted hits and misses, text or JSON (with the predicted wall time and its critical path), nothing runs. [page](https://vznjs.github.io/vx/features/dry-run/) · post: [See the plan before you run it](https://vznjs.github.io/vx/blog/dry-run/) - **Graph** (`--graph`) — the task graph as Graphviz DOT. [page](https://vznjs.github.io/vx/features/task-graph/) · post: [See inside a run](https://vznjs.github.io/vx/blog/see-inside-a-run/) - **vx why** (`vx why`, `--run`) — why a task re-ran, down to the file, env var or config that changed. [page](https://vznjs.github.io/vx/features/vx-why/) · post: [Why did this re-run?](https://vznjs.github.io/vx/blog/why-did-this-rerun/) - **vx show** (`vx show`) — every project, a project's or a task's live resolved config. [page](https://vznjs.github.io/vx/features/vx-show/) · post: [See what a task really is](https://vznjs.github.io/vx/blog/vx-show/) - **vx last** (`vx last`, `--list`, `--failed`, `--log`) — replay a recorded run's summary, list recent runs, or print one task's output. [page](https://vznjs.github.io/vx/features/vx-last/) · post: [The last run, on request](https://vznjs.github.io/vx/blog/vx-last/) - **vx info** (`vx info`) — workspace doctor: versions, projects, cache size against `cacheRetention.maxSize`. [page](https://vznjs.github.io/vx/features/vx-info/) · post: [One command to know your workspace](https://vznjs.github.io/vx/blog/know-your-workspace/) - **JSON everywhere** (`--format`) — `show`, `info`, `why`, `last` and `cache` print JSON for agents. [page](https://vznjs.github.io/vx/features/json-everywhere/) · post: [Built for the agent at the keyboard](https://vznjs.github.io/vx/blog/built-for-agents/) - **Run analytics in SQLite** — every task's time, CPU, peak memory and cache result, queryable with `sqlite3`. [page](https://vznjs.github.io/vx/features/run-analytics/) · post: [Built for the agent at the keyboard](https://vznjs.github.io/vx/blog/built-for-agents/) - **Stable error codes** (`VX_E_…`) — under `--format json` a refusal is a JSON line on stdout with a code an agent branches on and a `docs` link to its fix (`vx docs ` offline). [page](https://vznjs.github.io/vx/features/error-codes/) · post: [A refusal an agent can read](https://vznjs.github.io/vx/blog/error-codes/) - **Shipped JSON Schemas** (`@vzn/vx/schemas/`) — every `--format json` shape has a schema, and stdout always parses. [page](https://vznjs.github.io/vx/features/json-schemas/) · post: [Built for the agent at the keyboard](https://vznjs.github.io/vx/blog/built-for-agents/) - **Docs for agents** (`llms.txt`, `llms-full.txt`) — the whole site as markdown for coding agents. [page](https://vznjs.github.io/vx/features/llms-txt/) · post: [Built for the agent at the keyboard](https://vznjs.github.io/vx/blog/built-for-agents/) - **vx docs** (`vx docs`, `--limit`) — search the CLI, config and cache reference offline; it ships inside vx. [page](https://vznjs.github.io/vx/features/vx-docs/) · post: [The reference, offline](https://vznjs.github.io/vx/blog/vx-docs-offline/) - **Affected reasons** (`--affected --dry`) — each kept task says the changed file or the dependency chain that reached it, text or JSON. [page](https://vznjs.github.io/vx/features/affected-reasons/) · post: [See inside a run](https://vznjs.github.io/vx/blog/see-inside-a-run/) - **Agent skill** (`skills/vx/SKILL.md`) — an installable skill that teaches a coding agent to run, debug and query vx. [page](https://vznjs.github.io/vx/features/agent-skill/) · post: [Built for the agent at the keyboard](https://vznjs.github.io/vx/blog/built-for-agents/) ## Cache - **Opt-in, explicit inputs** (`cache.inputs.files`) — a task caches only when it names its inputs. [page](https://vznjs.github.io/vx/features/explicit-inputs/) · post: [Explicit over magical](https://vznjs.github.io/vx/blog/explicit-over-magical/) - **Inputs** (`cache.inputs.env`, `cache.inputs.runtime`, `cache.inputs.tasks`, `cache.inputs.workspaceFiles`, `cache.inputs.workspaceRuntime`) — env vars, tool versions, upstream tasks and workspace files in the key. [page](https://vznjs.github.io/vx/features/key-inputs/) · post: [What goes into a key, and what comes back](https://vznjs.github.io/vx/blog/inside-a-cache-hit/) - **Outputs** (`cache.outputs.files`, `cache.outputs.workspaceFiles`) — what a hit restores. [page](https://vznjs.github.io/vx/features/cache-outputs/) · post: [What goes into a key, and what comes back](https://vznjs.github.io/vx/blog/inside-a-cache-hit/) - **Outputs are exactly the snapshot** (`exclusiveOutputs`) — a hit leaves the tree as the run did; two tasks may not own one output without an edge. [page](https://vznjs.github.io/vx/features/strict-outputs/) · post: [The tree is exactly the snapshot](https://vznjs.github.io/vx/blog/strict-output-ownership/) - **Keys from git's index** — tracked clean files hash by their blob id, no read. [page](https://vznjs.github.io/vx/features/keys-from-git/) · post: [Your cache key is already in git's index](https://vznjs.github.io/vx/blog/keys-from-git/) - **Config keyed as evaluated** — the key sees the resolved config object. [page](https://vznjs.github.io/vx/features/typescript-config/) · post: [Configs are programs](https://vznjs.github.io/vx/blog/resolved-config-hashing/) - **Cascade through inputs** — a task's key folds its upstream input keys, never outputs. [page](https://vznjs.github.io/vx/features/cascade/) · post: [Cascade through dependencies](https://vznjs.github.io/vx/blog/cascade-through-inputs/) - **Lockfile-aware keys** (`@vzn/vx-lockfile`: `pnpm()`, `bun()`, `npm()`, `yarn()`) — a bump re-keys only the projects whose closure changed. [page](https://vznjs.github.io/vx/features/lockfile-keys/) · post: [A lockfile bump should re-key two tasks](https://vznjs.github.io/vx/blog/lockfile-aware-keys/) - **Upfront keys** (`upfrontKeys`) — refuses an input glob a same-project task's outputs could match, so every key is known before anything runs. [page](https://vznjs.github.io/vx/features/upfront-keys/) · post: [Cascade through dependencies by folding input keys, never outputs](https://vznjs.github.io/vx/blog/cascade-through-inputs/) - **Cache controls** (`--no-cache`, `--force`, `--cache`) — off, refresh, or per-layer read/write. [page](https://vznjs.github.io/vx/features/cache-controls/) · post: [The cache on your terms](https://vznjs.github.io/vx/blog/the-cache-on-your-terms/) - **Cache location** (`--cache-dir`, `cacheDir`, `VX_CACHE_DIR`) — where the cache lives. [page](https://vznjs.github.io/vx/features/cache-location/) · post: [The cache on your terms](https://vznjs.github.io/vx/blog/the-cache-on-your-terms/) - **Cache scope** (`cacheScope`, `VX_CACHE_SCOPE`) — trusted CI writes the remote cache; a laptop reads it. [page](https://vznjs.github.io/vx/features/cache-scope/) · post: [The cache on your terms](https://vznjs.github.io/vx/blog/the-cache-on-your-terms/) - **Cache pruning** (`vx cache prune`, `--older-than`, `--max-size`, `--dry-run`, `cacheRetention`, `maxSize`, `olderThan`) — evict by age or size, LRU. [page](https://vznjs.github.io/vx/features/cache-prune/) · post: [One command to know your workspace](https://vznjs.github.io/vx/blog/know-your-workspace/) - **Remote outputs** (`--download`) — all, top-level only, or none. [page](https://vznjs.github.io/vx/features/remote-downloads/) · post: [A remote server you can trust in production](https://vznjs.github.io/vx/blog/reapi-in-production/) - **One store for every checkout** (`~/.vx//cache`) — clones and worktrees of a repo share cache entries. [page](https://vznjs.github.io/vx/features/shared-store/) · post: [The cache on your terms](https://vznjs.github.io/vx/blog/the-cache-on-your-terms/) - **Warm hits restore nothing** — when outputs on disk already match, a hit costs a few stats. [page](https://vznjs.github.io/vx/features/warm-hits/) · post: [What goes into a key, and what comes back](https://vznjs.github.io/vx/blog/inside-a-cache-hit/) - **Hits replay both streams** — stdout and stderr come back in the order the run printed them. [page](https://vznjs.github.io/vx/features/both-streams/) · post: [What goes into a key, and what comes back](https://vznjs.github.io/vx/blog/inside-a-cache-hit/) - **Restore lane** — cache restores run on their own lane, up to twice `--concurrency`. [page](https://vznjs.github.io/vx/features/restore-lane/) · post: [What goes into a key, and what comes back](https://vznjs.github.io/vx/blog/inside-a-cache-hit/) - **Config evaluation cache** — provably pure `vx.config.ts` files are read back as data, not evaluated again. [page](https://vznjs.github.io/vx/features/config-cache/) · post: [Guard rails that tell you the fix](https://vznjs.github.io/vx/blog/guard-rails/) - **Line-ending-correct keys** — files git filters (`eol`, `core.autocrlf`) key on the bytes the build sees. [page](https://vznjs.github.io/vx/features/line-endings/) · post: [Your cache key is already in git's index](https://vznjs.github.io/vx/blog/keys-from-git/) - **Background remote uploads** — remote writes drain at the end of the run and never fail the build. [page](https://vznjs.github.io/vx/features/background-uploads/) · post: [What goes into a key, and what comes back](https://vznjs.github.io/vx/blog/inside-a-cache-hit/) - **Bring your own remote cache** (plugin `cache`) — plug any cache server in through one interface. [page](https://vznjs.github.io/vx/features/custom-remote-cache/) · post: [Extend vx in an afternoon](https://vznjs.github.io/vx/blog/extend-vx/) ## Correctness - **Sandboxed tasks** (`exec.sandbox`: `allow`, `deny`, `ignore`, `weakerNetworkIsolation`, `weakerWhenNested`) — a task reads and writes only what it declares; `allow` names `read`, `write`, `network`, `unixSockets`, `localBinding`, `pty`, `systemInfo`, `gitConfig`, `machLookup`. [page](https://vznjs.github.io/vx/features/sandbox/) · post: [The sandbox](https://vznjs.github.io/vx/blog/the-sandbox/) - **Env isolation** (`exec.env`: `define`, `passThrough`, `secret`) — a task sees only the env it names; secrets are masked. [page](https://vznjs.github.io/vx/features/env-isolation/) · post: [A task sees only the env it names](https://vznjs.github.io/vx/blog/env-isolation/) - **vx lock** (`vx lock`, `--check`, `--format json`, `--frozen`) — freeze what configs evaluate to; CI and agents check it. [page](https://vznjs.github.io/vx/features/vx-lock/) · post: [vx lock: freezing what the key sees](https://vznjs.github.io/vx/blog/lock-and-frozen/) - **Flaky detection** — a task that fails then passes is reported flaky. [page](https://vznjs.github.io/vx/features/flaky-detection/) · post: [Flaky is a claim](https://vznjs.github.io/vx/blog/flaky-tasks/) - **Project boundaries** — globs never cross into another project. [page](https://vznjs.github.io/vx/features/project-boundaries/) · post: [What goes into a key, and what comes back](https://vznjs.github.io/vx/blog/inside-a-cache-hit/) - **Artifact integrity checks** — a CRC-32, a key match and an outputs-only check on every artifact; damage is a miss. [page](https://vznjs.github.io/vx/features/artifact-integrity/) · post: [What goes into a key, and what comes back](https://vznjs.github.io/vx/blog/inside-a-cache-hit/) - **Sandbox names what to grant** — a refused write is named beside the failed task with the path to allow. [page](https://vznjs.github.io/vx/features/sandbox-grants/) · post: [Guard rails that tell you the fix](https://vznjs.github.io/vx/blog/guard-rails/) - **Strict numeric flags** — `0x10`, `1e3` and `2.7` are refused, never reinterpreted. [page](https://vznjs.github.io/vx/features/strict-numbers/) · post: [An upgrade you can trust](https://vznjs.github.io/vx/blog/upgrade-you-can-trust/) - **Verified releases** — binaries carry provenance, `vx upgrade` checks SHA-256, npm publishes with provenance. [page](https://vznjs.github.io/vx/features/verified-releases/) · post: [An upgrade you can trust](https://vznjs.github.io/vx/blog/upgrade-you-can-trust/) ## Daily work - **Watch mode** (`vx watch`, `VX_WATCH_POLL`) — re-run what a change affects, on content, not events. [page](https://vznjs.github.io/vx/features/watch/) · post: [Watch: a content gate](https://vznjs.github.io/vx/blog/watch-mode/) - **Dev servers in the graph** (`exec.persistent`, `readyWhen`, `VX_READY_NOTICE_MS`) — a server is a node; dependents start when it is ready. [page](https://vznjs.github.io/vx/features/dev-servers/) · post: [Dev servers as graph nodes](https://vznjs.github.io/vx/blog/dev-servers-in-the-graph/) - **Interactive tasks** (`exec.interactive`) — a task that owns the terminal. [page](https://vznjs.github.io/vx/features/interactive-tasks/) · post: [When a task misbehaves](https://vznjs.github.io/vx/blog/tasks-that-misbehave/) - **Shell completions** (`vx completions`) — bash, zsh, fish. [page](https://vznjs.github.io/vx/features/completions/) · post: [The small things](https://vznjs.github.io/vx/blog/the-small-things/) - **vx upgrade** (`vx upgrade`) — replace the binary with a release. [page](https://vznjs.github.io/vx/features/upgrade/) · post: [An upgrade you can trust](https://vznjs.github.io/vx/blog/upgrade-you-can-trust/) - **Help and version** (`vx help`, `vx version`) — every verb's reference. [page](https://vznjs.github.io/vx/features/help-and-version/) · post: [An upgrade you can trust](https://vznjs.github.io/vx/blog/upgrade-you-can-trust/) - **Did-you-mean** — a mistyped flag or verb gets the nearest valid spelling. [page](https://vznjs.github.io/vx/features/did-you-mean/) · post: [The small things](https://vznjs.github.io/vx/blog/the-small-things/) - **Task typed as a verb** — `vx build app` answers with the exact `vx run` command that does it. [page](https://vznjs.github.io/vx/features/task-as-verb/) · post: [The small things](https://vznjs.github.io/vx/blog/the-small-things/) ## Config - **Config in TypeScript** (`vx.config.ts`, `defineProject`, `vx.workspace.ts`, `defineWorkspace`) — typed, composable; no named inputs. [page](https://vznjs.github.io/vx/features/typescript-config/) · post: [Config in TypeScript](https://vznjs.github.io/vx/blog/config-in-typescript/) - **Tasks** (`tasks`, `exec.command`, `dependsOn`, `description`, `tags`) — one command per task; the shell is the API. [page](https://vznjs.github.io/vx/features/one-command-tasks/) · post: [One command per task](https://vznjs.github.io/vx/blog/one-command-per-task/) - **Workspace rules** (`rules`) — speed-only checks, on by default, configurable. [page](https://vznjs.github.io/vx/features/workspace-rules/) · post: [Guard rails that tell you the fix](https://vznjs.github.io/vx/blog/guard-rails/) - **Config worker timeout** (`VX_CONFIG_WORKER_TIMEOUT_MS`) — bound a config's evaluation. [page](https://vznjs.github.io/vx/features/config-timeout/) · post: [Guard rails that tell you the fix](https://vznjs.github.io/vx/blog/guard-rails/) - **No nested runs** (`VX_RUN_TASK`, `VX_RUN_WORKSPACE`) — set on every task; a `vx run` inside a task of the same workspace is refused. [page](https://vznjs.github.io/vx/features/no-nested-runs/) · post: [When a task misbehaves](https://vznjs.github.io/vx/blog/tasks-that-misbehave/) - **Typed config helpers** (`defineProject`) — autocomplete for task names in `dependsOn`, errors while you edit. [page](https://vznjs.github.io/vx/features/typed-helpers/) · post: [Config in TypeScript, and why there are no named inputs](https://vznjs.github.io/vx/blog/config-in-typescript/) - **Presets** — a TypeScript function returning a task config, shared across projects. [page](https://vznjs.github.io/vx/features/presets/) · post: [Config in TypeScript, and why there are no named inputs](https://vznjs.github.io/vx/blog/config-in-typescript/) - **Clean config errors** — a bad config fails at load naming the key, with no stack trace. [page](https://vznjs.github.io/vx/features/config-errors/) · post: [When a task misbehaves](https://vznjs.github.io/vx/blog/tasks-that-misbehave/) ## CI - **Results on GitHub** (`@vzn/vx-ci`: `github()`) — job summary and Checks API annotations. [page](https://vznjs.github.io/vx/features/github-ci/) · post: [Your run, on the pull request](https://vznjs.github.io/vx/blog/results-on-github/) - **PR check run** (`github({ checks })`) — a check run on the commit with the run summary as its output. [page](https://vznjs.github.io/vx/features/pr-check-run/) · post: [Your run, on the pull request](https://vznjs.github.io/vx/blog/results-on-github/) - **Cache scope from the ref** (`github({ cacheScope })`) — main writes trusted keys; a PR writes only its own scope. [page](https://vznjs.github.io/vx/features/ref-cache-scope/) · post: [The cache on your terms](https://vznjs.github.io/vx/blog/the-cache-on-your-terms/) ## Adoption - **Start in a minute** (`vx init`, `--dry`, `--format json`, `--force`, `--mjs`) — write `vx.workspace.ts` and one `vx.config.ts` per package; `--dry --format json` hands an agent the plan as data. [page](https://vznjs.github.io/vx/features/quickstart/) · post: [Hello, vx](https://vznjs.github.io/vx/blog/hello-vx/) - **Migrate from Turborepo or Nx** (`vx init`, `--native`, `--keep`, `@vzn/vx-migrate`) — native config, or keep `turbo()` / `nx()` as a start. [page](https://vznjs.github.io/vx/features/migrate/) · posts: [From Turborepo](https://vznjs.github.io/vx/blog/from-turborepo/), [From Nx](https://vznjs.github.io/vx/blog/from-nx/) - **Keep a Turbo or Nx remote cache** (`turboCache()`, `nxCache()`) — reuse the cache server you have. [page](https://vznjs.github.io/vx/features/keep-remote-cache/) · post: [From Nx: keep the graph, drop the platform](https://vznjs.github.io/vx/blog/from-nx/) - **One binary** — one file, nothing to install underneath. [page](https://vznjs.github.io/vx/features/one-binary/) · post: [One binary](https://vznjs.github.io/vx/blog/one-binary/) - **The playground** — vx's planner in the browser. [page](https://vznjs.github.io/vx/features/playground/) · post: [Try the planner in your browser](https://vznjs.github.io/vx/blog/the-playground/) - **Benchmarks you can re-run** (`@vzn/vx-bench`) — vx against Turborepo and Nx. [page](https://vznjs.github.io/vx/features/fastest/) · posts: [Benchmarks you can re-run](https://vznjs.github.io/vx/blog/honest-benchmarks/), [Why vx is fast](https://vznjs.github.io/vx/blog/why-vx-is-fast/) - **npm pre/post scripts** — `pre` and `post` hooks fold into `x`'s command when `vx init` maps scripts. [page](https://vznjs.github.io/vx/features/npm-hooks/) · post: [From npm scripts or Vite Task](https://vznjs.github.io/vx/blog/from-scripts-and-vite-task/) - **Vite Task adoption** (`bunx @vzn/vx-migrate`) — writes configs from vite-plus `run.tasks` as well as Turbo and Nx. [page](https://vznjs.github.io/vx/features/vite-task-adoption/) · post: [From npm scripts or Vite Task](https://vznjs.github.io/vx/blog/from-scripts-and-vite-task/) - **Nx executors as one process** (`nx-exec`) — any Nx executor runs as one vx task with its Nx env set. [page](https://vznjs.github.io/vx/features/nx-exec/) · post: [From Nx: keep the graph, drop the platform](https://vznjs.github.io/vx/blog/from-nx/) - **Programmatic API** (`run`, `planRun`) — run or plan from your own scripts via `@vzn/vx`. [page](https://vznjs.github.io/vx/features/programmatic-api/) · post: [Extend vx in an afternoon](https://vznjs.github.io/vx/blog/extend-vx/) ## Plugins - **A pipeline with seams** (`plugins`, `definePlugin`; stages `config` `discover` `project` `graph` `key` `fingerprint` `schedule` `admit` `executor` `cache` `telemetry` `commands`) — every stage is a plugin seam. [page](https://vznjs.github.io/vx/features/plugins/) · post: [A pipeline with seams](https://vznjs.github.io/vx/blog/pipeline-with-seams/) - **Write a plugin** (`vx init --plugin`) — scaffold a runnable plugin for a seam. [page](https://vznjs.github.io/vx/features/plugins/) · post: [Extend vx in an afternoon](https://vznjs.github.io/vx/blog/extend-vx/) - **The local floor** — running and caching here are core's, not plugins. [page](https://vznjs.github.io/vx/features/local-floor/) · post: [The local floor](https://vznjs.github.io/vx/blog/the-local-floor/) - **Remote cache and execution** (`@vzn/vx-reapi`, `exec.remote`) — Bazel REAPI cache and workers; the scheduler stays here. [page](https://vznjs.github.io/vx/features/remote-execution/) · post: [Remote execution without moving the scheduler](https://vznjs.github.io/vx/blog/remote-execution/) - **OpenTelemetry** (`@vzn/vx-otel`) — each run exported to your OpenTelemetry backend, never breaking it. [page](https://vznjs.github.io/vx/features/opentelemetry/) · post: [Observability that cannot break a run](https://vznjs.github.io/vx/blog/telemetry-never-breaks-a-run/) - **vx mcp** (`@vzn/vx-mcp`, `vx mcp`) — cache stats, run history, `vx why`'s full answer, a run tool (`mcp({ run })` limits it to named tasks or turns it off), a plan tool, a task's resolved config, the lock audit, cache pruning, the `vx init` plan, an offline docs search and a task list narrowed by `--filter` / `--affected` for coding agents. [page](https://vznjs.github.io/vx/features/mcp/) · post: [Give your coding agent the build's memory](https://vznjs.github.io/vx/blog/agents-and-mcp/) - **vx prune** (`@vzn/vx-lockfile`, `vx prune`, `--production`) — copy projects and their deps, lockfile pruned, for a Docker build; `--production` leaves out what only dev dependencies need. [page](https://vznjs.github.io/vx/features/vx-prune/) · post: [Ship one app, not the whole monorepo](https://vznjs.github.io/vx/blog/vx-prune/), [A runtime image without the dev tools](https://vznjs.github.io/vx/blog/prune-production/) - **vx history** (`@vzn/vx-schedule-history`, `vx history`) — what the scheduler learned per task. [page](https://vznjs.github.io/vx/features/vx-history/) · post: [A scheduler that learns from your runs](https://vznjs.github.io/vx/blog/a-scheduler-that-learns/) - **Setup and teardown hooks** (`setup`, `teardown`) — plugin code around the run, bounded by a timeout. [page](https://vznjs.github.io/vx/features/setup-teardown/) · post: [Extend vx in an afternoon](https://vznjs.github.io/vx/blog/extend-vx/) - **REAPI TLS, mTLS and headers** — connect to hosted servers such as BuildBuddy the way Bazel does. [page](https://vznjs.github.io/vx/features/reapi-tls/) · post: [A remote server you can trust in production](https://vznjs.github.io/vx/blog/reapi-in-production/) - **REAPI execution records** — a repeat remote execution skips the worker and replays outputs and stdout. [page](https://vznjs.github.io/vx/features/reapi-action-cache/) · post: [A remote server you can trust in production](https://vznjs.github.io/vx/blog/reapi-in-production/) - **REAPI verified downloads and deadlines** — a corrupt blob or a wedged server degrades to a miss, never a hang. [page](https://vznjs.github.io/vx/features/reapi-safety/) · post: [A remote server you can trust in production](https://vznjs.github.io/vx/blog/reapi-in-production/) - **Install as a remote action** (`exec.remote: 'only'`) — `node_modules` is built by an action, so stateless workers have it. [page](https://vznjs.github.io/vx/features/remote-install/) · post: [Remote execution without moving the scheduler](https://vznjs.github.io/vx/blog/remote-execution/) - **OTel live export** (`otel({ live })`) — spans and metrics stream as tasks end, so a dashboard follows a CI run live. [page](https://vznjs.github.io/vx/features/otel-live/) · post: [Watch a CI run while it runs](https://vznjs.github.io/vx/blog/otel-live/) - **Memory-aware admission** (`@vzn/vx-schedule-history`) — tasks are packed by the peak memory learned from past runs. [page](https://vznjs.github.io/vx/features/memory-admission/) · post: [A scheduler that learns from your runs](https://vznjs.github.io/vx/blog/a-scheduler-that-learns/) --- # API reference > Every export of @vzn/vx, generated from src/index.ts by Every export of `@vzn/vx`, generated from `src/index.ts` by `tests/api-reference.test.ts`: the declaration (comments dropped, a function to its signature, a class to its public members) and the doc comment above it. [The public surface](../modules/public-surface/) groups the same names by what they are for; the plugins guide shows them in use. ## `AdmitContext` type · `src/orchestrator/plugin.ts` ```ts export interface AdmitContext { readonly running: readonly TaskNode[] readonly concurrency: number } ``` ## `AffectedChanges` type · `src/workspace/affected.ts` What `--affected` seeds its tasks from (owner, 2026-10-04): the changed projects, each one's changed paths relative to it, and the projects a change reaches as a whole — every task in one is affected whatever its inputs say, because the reason was no path of its own (a lockfile claim, a manifest edge at the base, a config import, a nested repository, a workspace-wide file). ```ts export interface AffectedChanges { projects: Set changed: readonly string[] paths: ReadonlyMap whole: ReadonlySet nested?: readonly string[] } ``` ## `applyMigration` function · `src/workspace/migration.ts` Render a plan to files, refuse to overwrite without `force`, write (or print, under `dry`), and report. Returns the process exit code. ```ts export async function applyMigration(args: ApplyMigrationArgs): Promise ``` ## `ApplyMigrationArgs` type · `src/workspace/migration.ts` ```ts export interface ApplyMigrationArgs { root: string metas: readonly ProjectMeta[] plan: MigrationPlan source: string verb: string dry: boolean force: boolean init?: boolean notes?: readonly string[] unmapped?: boolean format?: MigrationFormat json?: boolean } ``` ## `buildPackageGraph` function · `src/workspace/package-graph.ts` `taskEdges`: project → the projects its tasks name in a cross-project `dependsOn` (`e2e` → `app` from `dependsOn: ['app#build']`). A dependent the manifest does not know but the task graph does; without it `--filter '...app'` never selected `e2e`, and a CI that runs "what changed and everything depending on it" silently left it out (2026-09-10). The `^task` walk still reads `directDeps`, which carries both — a task edge IS a dependency. ```ts export function buildPackageGraph( projects: ProjectMeta[], taskEdges?: ReadonlyMap, ): PackageGraph ``` ## `Cache` class · `src/cache/cache.ts` ```ts export class Cache implements CacheLayer { readonly hasRemote readonly uploads: UploadTally readonly storeDir: string | undefined readonly schemaReset: SchemaReset | null readonly formatChange: SchemaReset | null readonly storeReset: SchemaReset | null static inspect(cacheDir: string): Cache constructor( private readonly cacheDir: string, localPolicy: { read: boolean; write: boolean } = { read: true, write: true }, repoDir?: string, private readonly artifactCeiling: number = MAX_DECOMPRESSED_ARTIFACT_BYTES, mode: 'open' | 'inspect' | 'preview' = 'open', storeRoot?: string | null, ) getConfigEval(key: string): string | null getConfigClosures(configPaths: readonly string[]): Map putConfigClosure(configPath: string, files: readonly string[]): void getConfigEvals(keys: readonly string[]): Map putConfigEval(key: string, json: string): void putConfigEvals(entries: ReadonlyArray): void putConfigClosures(entries: ReadonlyArray): void hashFile(filePath: string): Promise hashBytes(bytes: Uint8Array, nearPath: string): string hashFiles(paths: readonly string[]): Promise> knownBlobSizes(oids: readonly string[]): Map rememberBlobSizes(sizes: ReadonlyMap): void blobVerdict(digest: string): string[] | undefined rememberBlobVerdict(digest: string, paths: readonly string[]): void key(input: CacheKeyInput): Promise async get(hash: string, ctx?: CacheGetContext): Promise getIngested(hash: string): Promise async getMany( hashes: readonly string[], ctx?: (hash: string) => CacheGetContext, ): Promise> async has(hash: string): Promise<'local' | 'remote' | null> async prefetch(_hash: string, _ctx?: CacheGetContext): Promise loadOutputFilesBatch(hashes: readonly string[]): Map isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise outputsPath(hash: string): string artifactSize(hash: string): number | undefined async recordOutputDirs( hash: string, projectDir: string, prefixes: readonly string[], holds?: (files: readonly string[]) => boolean, ): Promise recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void loadOutputDirsBatch(hashes: readonly string[]): Map outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise async restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise assertWritable(): void async save(args: { hash: string entry: Omit projectDir: string outputFiles: string[] skipLocalWrite?: boolean workspaceOutputFiles?: string[] workspaceRoot?: string inputComponents?: readonly TaskInputRow[] }): Promise get localWritesEnabled(): boolean packArtifactBytes(args: SaveArgs): Promise pinArtifact(hash: string): { body: Blob; release: () => Promise } async ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise dbHandle(): Database recordRun(run: RunRecord): void recordRuns(runs: readonly RunRecord[]): void recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void stats(opts: CacheStatsOptions = {}): CacheStats async evictIfDue( policy: { maxAgeMs?: number; maxBytes?: number }, now: number = Date.now(), ): Promise async prune(options: PruneOptions): Promise async orphanStats(): Promise<{ orphans: number; orphanBytes: number }> close(): void } ``` ## `CacheConfig` type · `src/config.ts` ```ts export interface CacheConfig { inputs: CacheInputs outputs: CacheOutputs } ``` ## `CacheContext` type · `src/orchestrator/plugin.ts` ```ts export interface CacheContext extends BaseContext { readonly localCache: Cache readonly policy: CachePolicy } ``` ## `CacheInputs` type · `src/config.ts` ```ts export interface CacheInputs { files: readonly string[] workspaceFiles?: readonly string[] env?: readonly string[] tasks?: readonly string[] runtime?: readonly string[] workspaceRuntime?: readonly string[] } ``` ## `cacheKeyDiff` function · `src/orchestrator/metrics.ts` The moat: name the exact cache-key components (files / env / runtime / upstream …) that differ between this run of a task and its immediately- previous run. Resolves each run to its task hash via `runs.hash`, then full-outer-joins the two runs' `entry_inputs` rows (keyed by the entry hash) over `(kind, name)`: - present in both with a different hash → `changed` - only in this run → `added` - only in the previous run → `removed` - equal → counted as unchanged Pure SQL + an app-side set join (`diffKeyComponents`) — no config re-evaluation, no re-hash. Always returns a value (never throws); `found:false` when the run/task pair has no row, `entries:[]` for the first run of a task. ```ts export function cacheKeyDiff(db: Database, runId: string, taskId: string): CacheKeyDiff ``` ## `CacheKeyDiff` type · `src/orchestrator/metrics.ts` ```ts export interface CacheKeyDiff { runId: string taskId: string found: boolean previousRunId: string | null entries: InputDiffEntry[] unchangedCount: number note: string } ``` ## `CacheLayer` type · `src/cache/layer.ts` The shape every cache implementation honors. `Cache` (the local store) and `LayeredCache` both `implements` this so the orchestrator's `executeTask` can take either without a discriminated union and we get a compile-time guarantee the surfaces stay congruent. ```ts export interface CacheLayer { readonly local?: Cache | undefined readonly hasRemote?: boolean remoteHasMany?(hashes: readonly string[]): Promise | null> markRemoteAbsent?(hashes: Iterable): void drainUploads?(): Promise key(input: CacheKeyInput): Promise get(hash: string, ctx?: CacheGetContext): Promise getMany?( hashes: readonly string[], ctx?: (hash: string) => CacheGetContext, ): Promise> has(hash: string): Promise<'local' | 'remote' | null> prefetch(hash: string, ctx?: CacheGetContext): Promise loadOutputFilesBatch(hashes: readonly string[]): Map isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise recordOutputDirs?( hash: string, projectDir: string, prefixes: readonly string[], holds?: (files: readonly string[]) => boolean, ): Promise recordOutputStamps?(hash: string, projectDir: string, workspaceRoot: string): void loadOutputDirsBatch?(hashes: readonly string[]): Map outputDirsCurrent?(projectDir: string, rows: readonly OutputDirRow[]): Promise restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise save(args: { hash: string entry: Omit projectDir: string outputFiles: string[] skipLocalWrite?: boolean workspaceOutputFiles?: string[] workspaceRoot?: string inputComponents?: readonly TaskInputRow[] }): Promise ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void stats(opts?: CacheStatsOptions): CacheStats hashFile(filePath: string): Promise outputsPath(hash: string): string artifactSize?(hash: string): number | undefined prune(options: PruneOptions): Promise close(): void } ``` ## `CacheOutputs` type · `src/config.ts` ```ts export interface CacheOutputs { files: readonly string[] workspaceFiles?: readonly string[] } ``` ## `CachePolicy` type · `src/cache/policy.ts` Independent read/write control over the two cache layers (local + remote). Replaces the old single `noCache` boolean: each axis can be toggled on its own so `--force` (re-execute but still refresh the cache) is distinct from `--no-cache` (disable everything). The task-artifact get/save path is gated, and the local axes also gate the local store's config-evaluation reads and writes and its file-hash writes (`cache.ts`). `recordRun`, `stats`, `prune`, key derivation, and prefetch-ingest are never affected — they are bookkeeping/analytics that a run policy has no business disabling. ```ts export interface CachePolicy { localRead: boolean localWrite: boolean remoteRead: boolean remoteWrite: boolean remoteScope?: string } ``` ## `CacheSource` type · `src/orchestrator/telemetry.ts` Where a task's result came from, derived ONCE in core from the status. ```ts export type CacheSource = 'miss' | 'local' | 'remote' | 'none' ``` ## `CiContext` type · `src/orchestrator/run-context.ts` ```ts export interface CiContext { ci: boolean provider: string | null runUrl?: string change?: string pipeline?: string job?: string attempt?: number } ``` ## `clampInt` function · `src/util/num.ts` Clamp to an INTEGER in `[min, max]`; a non-finite value collapses to `min`. The floor is load-bearing wherever the result reaches SQL: a fractional `LIMIT` is a `datatype mismatch` error, not a smaller page. ```ts export function clampInt(n: number, min: number, max: number): number ``` ## `collectInfo` function · `src/orchestrator/doctor.ts` ```ts export async function collectInfo(cwd: string, opts: CollectInfoOptions = {}): Promise ``` ## `CollectInfoOptions` type · `src/orchestrator/doctor.ts` ```ts export interface CollectInfoOptions { readonly cacheDir?: string readonly warn?: (message: string) => void } ``` ## `CommandContext` type · `src/orchestrator/plugin.ts` ```ts export interface CommandContext extends BaseContext { readonly concurrency: number readonly vx: readonly string[] } ``` ## `definePlugin` function · `src/orchestrator/plugin.ts` The one way to make a plugin: `definePlugin(import.meta, { ...hooks })`. The name is read from the package the calling module belongs to — the nearest `package.json` above it — and stamped where the workspace loader checks for it, so a plugin cannot be named anything but its package. ```ts export function definePlugin(origin: PluginOrigin, hooks: PluginHooks): VxPlugin ``` ## `defineProject` function · `src/config.ts` Identity function — exists only so TypeScript narrows literal types and, crucially, **validates `dependsOn` against this project's own task names**. A bare entry that isn't a declared task key is a compile error; `^name` / `pkg#name` forms reference other projects and stay free strings. Runtime behavior is unchanged (it returns its input). ```ts export function defineProject( config: T & KnownExecs & Known & { tasks?: { [K in keyof NonNullable]?: Known[K], TaskConfig> & { dependsOn?: readonly DependsOnEntry, string>>[] exec?: Known[K], 'exec'>, ExecConfig> cache?: Known[K], 'cache'>, CacheConfig> & { inputs?: Known[K], 'cache'>, 'inputs'>, CacheInputs> outputs?: Known[K], 'cache'>, 'outputs'>, CacheOutputs> } } } }, ): T ``` ## `defineWorkspace` function · `src/config.ts` ```ts export function defineWorkspace( config: T & Known & { cacheRetention?: Known< At, NonNullable > rules?: Known, WorkspaceRules> }, ): T ``` ## `DiscoverContext` type · `src/orchestrator/plugin.ts` ```ts export interface DiscoverContext extends WorkspaceHookContext { readonly cacheDir: string readonly projects: readonly ProjectMeta[] worktreeChanges(): Promise } ``` ## `escapeMarkdownCell` function · `src/orchestrator/run-report.ts` Make a value safe inside a GFM table cell. Task names are arbitrary TS object keys and the loader accepts `|` and newlines, either of which silently breaks the table on the consumer (`>> $GITHUB_STEP_SUMMARY`): a bare pipe adds a column, a newline splits the row. Exported because this file is not the only markdown table describing a run: the cloud plugin's GitHub job summary renders the same data from the same unvalidated names and shipped WITHOUT this escape, so a `|` in a task name or an output path shifted its columns. One definition, so the two cannot disagree about what a cell may contain. A pipe splits the row unless an ODD run of backslashes precedes it (GFM reads `\\` as one escaped backslash), so a pipe after an odd run is already escaped and one more backslash would free it: `a\|b` became `a\\|b`, two cells. A lone `\r` ends a line too. ```ts export function escapeMarkdownCell(value: string): string ``` ## `ExecConfig` type · `src/config.ts` ```ts export interface ExecConfig { command: string remote?: boolean | 'only' env?: ExecEnv timeout?: number retries?: number persistent?: PersistentConfig interactive?: boolean sandbox?: SandboxConfig } ``` ## `ExecEnv` type · `src/config.ts` ```ts export interface ExecEnv { passThrough?: readonly string[] define?: Record secret?: readonly string[] } ``` ## `ExecuteRequest` type · `src/exec/executor.ts` ```ts export interface ExecuteRequest { readonly taskId: string readonly workspaceRoot: string readonly inputs?: TaskInputs readonly cacheKey?: string readonly refresh?: boolean readonly remoteOnly?: boolean readonly download?: 'eager' | 'deferred' readonly outputs: { readonly files: readonly string[] readonly workspaceFiles: readonly string[] } readonly command: string readonly forwardArgs: readonly string[] readonly cwd: string readonly env: NodeJS.ProcessEnv readonly envDefine: Readonly> readonly capture: CaptureConfig readonly timeoutMs?: number readonly onStdout: (chunk: string) => void readonly onStderr: (chunk: string) => void readonly signal?: AbortSignal readonly liveChildren?: Set> readonly onSpawn?: (pid: number) => void readonly sandbox?: ExecuteSandbox readonly terminal?: true } ``` ## `ExecuteResult` type · `src/exec/executor.ts` ```ts export interface ExecuteResult extends RunResult { readonly violations: readonly SandboxViolation[] readonly outputs?: { kind: 'disk' } | { kind: 'deferred'; materialize: () => Promise } readonly where?: string } ``` ## `ExecuteSandbox` type · `src/exec/executor.ts` Sandbox baselines + the user's resolved sandbox block, when the task is sandboxed. ```ts export interface ExecuteSandbox { readonly baseAllowRead: readonly string[] readonly baseDenyRead: readonly string[] readonly reportWithin: string readonly reportLinked: readonly string[] readonly config: ResolvedSandboxConfig } ``` ## `ExecutorContext` type · `src/orchestrator/plugin.ts` ```ts export interface ExecutorContext extends BaseContext { readonly concurrency: number } ``` ## `executorFallback` function · `src/exec/executor.ts` What a remote executor rejects with when it gives a task back: core runs the same request on the local floor and says `reason` once (a remote that never started it, B-100). A task placed `remote: 'only'` must not run here, so it fails naming `reason` instead. Matched by name, as `isUserError` is: a plugin's `@vzn/vx` can be another copy of this class. ```ts export function executorFallback(reason: string): Error ``` ## `exitSignal` function · `src/exec/runner.ts` The reverse: the signal an exit above 128 stands for (137 → SIGKILL), by the platform's numbering; undefined for a plain exit. The shell reports 128 + n for a death by signal n, so the read is the shell's convention, not proof — a command may exit 137 on its own. ```ts export function exitSignal(code: number): string | undefined ``` ## `findWorkspaceRoot` function · `src/workspace/workspace.ts` Walk up from `start` to find the workspace root. A directory is a root CANDIDATE when it contains `pnpm-workspace.yaml` or a `package.json`. The nearest candidate that CLAIMS `start` wins — one of the directories between it and `start` matches one of its package globs. Every workspace member has its own `package.json`, so stopping at the first candidate would make a run from inside a package treat that package as the whole workspace: `^task` edges vanish, upstream hashes drop out of the cache key (stale hits), and a second cache dir appears under the member. Claiming is decided with the same globs `loadWorkspace` applies, so "the root that claims me" and "the root that lists me as a project" cannot diverge. An outer root that lists both the claimer and the claimed member outranks the claimer: from the claimer's own directory the walk reaches the outer root too. When no candidate claims `start` — a standalone package, or a subdirectory of a single-project repo — the nearest candidate wins (the root itself IS the project). Throws a `UserError` when there is no candidate before `/`. A load that goes on to `loadWorkspace` passes its `reads`, so the root's manifest is read once for both. ```ts export async function findWorkspaceRoot( start: string, reads: LoadReads = new Map(), ): Promise ``` ## `FingerprintChange` type · `src/orchestrator/plugin.ts` ```ts export interface FingerprintChange { readonly file: string readonly before: Uint8Array | null readonly after: Uint8Array | null } ``` ## `FingerprintClaim` type · `src/orchestrator/plugin.ts` A plugin's claim on workspace fingerprint files — see `VxPlugin.fingerprint`. ```ts export interface FingerprintClaim { readonly files: readonly string[] affected( change: FingerprintChange, ctx: FingerprintContext, ): Iterable | undefined | Promise | undefined> } ``` ## `FingerprintContext` type · `src/orchestrator/plugin.ts` ```ts export interface FingerprintContext extends BaseContext { readonly projects: ReadonlyArray<{ readonly name: string; readonly dir: string }> } ``` ## `foldScriptHooks` function · `src/workspace/migration.ts` A package.json script with the `pre` / `post` hooks npm runs around it, as ONE sh command. Each part runs in its own subshell, so a `;` or an `exit` in one ends that part alone, and the chain stops at the first that fails, as npm stops. The parts sit in a function the forwarded `--` args are appended to, and only the body takes them, as npm appends them to the script and never to its hooks. A plain ` && ` join handed them to the post hook, and `test -f x && echo A; echo B` ran `echo B` after a failed pre hook and went green (item 905). npm appends them as TEXT: no part sees them as `$1`…, so the function quotes them into `vx_a`, clears its positional parameters, and evals the body with `vx_a` after it; `"$@"` on the body made a script's `$1` the first forwarded arg and its `$*` print them twice. Each part ends on its own line, so a trailing `# comment` cannot swallow the paren. A script with no hooks is its body, verbatim. ```ts export function foldScriptHooks( pre: string | undefined, body: string, post: string | undefined, ): string ``` ## `GeneratedProject` type · `src/workspace/migration.ts` ```ts export interface GeneratedProject { name: string dir: string importLines: string[] tags?: readonly string[] tasks: GeneratedTask[] } ``` ## `GeneratedTask` type · `src/workspace/migration.ts` ```ts export interface GeneratedTask { name: string todos: string[] task: Record | null } ``` ## `GitContext` type · `src/orchestrator/run-context.ts` ```ts export interface GitContext { commitSha: string | null branch: string | null dirty: boolean | null } ``` ## `GraphHookContext` type · `src/orchestrator/plugin.ts` ```ts export interface GraphHookContext extends BaseContext { readonly requested: readonly string[] } ``` ## `HistoryProvider` type · `src/orchestrator/history.ts` ```ts export interface HistoryProvider { loadFor(taskIds: readonly string[]): Promise p50sFor?(taskIds: readonly string[]): Promise> } ``` ## `HistoryTable` type · `src/orchestrator/history.ts` Map keyed by `project#task`. ```ts export type HistoryTable = ReadonlyMap ``` ## `HostContext` type · `src/orchestrator/run-context.ts` ```ts export interface HostContext { host: string | null os: string arch: string } ``` ## `InfoFacts` type · `src/orchestrator/doctor.ts` The doctor's facts, typed: what `--format json` prints and the pretty rows render. ```ts export interface InfoFacts { vx: string bun: string bunSupported: boolean git: string | null gitStatusCache: { fsmonitor: boolean; untrackedCache: boolean } | null workspaceRoot: string projects: number tasks: number configErrors: Array<{ path: string; message: string }> plugins: Array<{ name: string; seams: string[] }> workers: { count: number source: 'workspace' | 'cgroup' | 'cores' cores: number cpuQuota: number | null } memory: { usableBytes: number; totalBytes: number; cgroupLimitBytes: number | null } cacheDir: string cacheStore: string | null cacheVersion: string schemaVersion: string cacheEntries: number cacheBytes: number cacheRetention: { olderThan?: string; maxSize?: string; maxBytes?: number } | null orphans: { artifacts: number; bytes: number } runs24h: number hits24h: number restored24h: number flakyTasks: FlakyTask[] lockfile: boolean sandbox: { available: boolean; reason: string; declared: number; untraced: string | null } } ``` ## `InvocationRecord` type · `src/cache/layer.ts` One header row per `vx run` invocation (the `invocations` table). All fields mirror the columns; nullable VCS/host columns are `null` when the probe failed (not a git repo, hostname unavailable). Recorded once per run inside the same transaction as the per-task `runs` rows. ```ts export interface InvocationRecord { runId: string command: string requestedTasks: string cachePolicy: string concurrency: number flow: 'focused' | 'broad' | null startedAt: number endedAt: number totalDurationMs: number taskCount: number failedCount: number hitCount: number hitLocalCount: number hitRemoteCount: number upToDateCount: number restoredLocalCount: number restoredRemoteCount: number exitOk: boolean commitSha: string | null branch: string | null dirty: boolean | null ci: boolean ciProvider: string | null host: string | null os: string | null arch: string | null vxVersion: string tags: string } ``` ## `isCacheHit` function · `src/orchestrator/telemetry.ts` Did the task's result come out of the cache (either layer)? Derived from `deriveCacheSource` rather than re-listing the two hit statuses, so the two cannot disagree about what a hit is. Unknown strings read as not-a-hit. ```ts export function isCacheHit(status: string): boolean ``` ## `isLiteralPattern` function · `src/util/paths.ts` True when a pattern carries no wildcard — it names exactly one path. The character SET is the whole content: in a task glob `*`, `?` and a brace alternation are wildcards, so a pattern holding any of them must be MATCHED, never compared as a string. It lives here, exported, because four places asked the same question and one of them asked it with a smaller set: `graph/task-graph.ts` omitted `{}`, so `dist/{a,b}.txt` counted as a literal and the overlapping-output refusal compared it to `dist/a.txt` as two unequal strings — the two tasks were accepted and then deleted each other's outputs, green, every run (item 495). That is the same divergence `asTrees` was moved here to end in item 442, and the same one that removed `@vzn/vx-migrate`'s copy of `outputsOverlap` in item 445. ```ts export function isLiteralPattern(glob: string): boolean ``` ## `isPassStatus` function · `src/orchestrator/telemetry.ts` Did the task pass? A cache hit counts — it produced the same result without spending the time, which is the whole point. `skipped` and `aborted` do NOT: neither finished on its own terms, so neither can vouch for anything. Takes `string`, not `TaskStatus`, because most callers hold a status that arrived over a wire or out of a database column. An unrecognised string reads as NOT passing — the safe direction, since the alternative is calling a run green on a status this build has never heard of. ```ts export function isPassStatus(status: string): boolean ``` ## `isUserError` function · `src/util/errors.ts` `instanceof UserError`, plus the same class arriving from ANOTHER COPY of core. A compiled `vx` binary carries core inside it while a plugin in the workspace imports `@vzn/vx` from node_modules, so a plugin's `UserError` is a different class object and `instanceof` is false — a plugin verb's "bad flag --x" printed as `UserError: bad flag --x` with a stack, and a REAPI refusal would have read as an "internal error" (reproduced through the real binary, 2026-09-03). The name is the contract that survives the copy boundary. ```ts export function isUserError(err: unknown): err is UserError ``` ## `KeyHookContext` type · `src/orchestrator/plugin.ts` ```ts export interface KeyHookContext extends BaseContext {} ``` ## `latestRunId` function · `src/orchestrator/metrics.ts` The latest recorded run of a task (run_id may be NULL on very old rows). One query for `vx why` and `vx mcp`: the CLI defaulted to it while the tool demanded a run id an agent had to fetch first (2026-09-16). ```ts export function latestRunId(db: Database, taskId: string): string | null ``` ## `LayeredCache` class · `src/cache/layered-cache.ts` ```ts export class LayeredCache implements CacheLayer { readonly hasRemote constructor( readonly local: Cache, private readonly remote: RemoteCacheLayer, private readonly options: LayeredCacheOptions = {}, ) key(input: CacheKeyInput): Promise async prefetch(hash: string, ctx?: CacheGetContext): Promise async remoteHasMany(hashes: readonly string[]): Promise | null> markRemoteAbsent(hashes: Iterable): void async get(hash: string, ctx?: CacheGetContext): Promise async has(hash: string): Promise<'local' | 'remote' | null> outputsPath(hash: string): string artifactSize(hash: string): number | undefined hashFile(filePath: string): Promise async restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise async save(args: SaveArgs): Promise async drainUploads(): Promise async ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise loadOutputFilesBatch(hashes: readonly string[]): Map async isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise recordOutputDirs( hash: string, projectDir: string, prefixes: readonly string[], holds?: (files: readonly string[]) => boolean, ): Promise recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void loadOutputDirsBatch(hashes: readonly string[]): Map outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void stats(opts?: CacheStatsOptions): CacheStats prune(options: PruneOptions): Promise close(): void } ``` ## `listProjectMetas` function · `src/workspace/workspace.ts` ```ts export async function listProjects(workspace: Workspace): Promise ``` ## `loadProjectConfig` function · `src/workspace/project-loader.ts` ```ts export async function loadProjectConfig( configPath: string, opts?: LoadProjectConfigOptions, ): Promise ``` ## `loadResolvedProjects` function · `src/orchestrator/projects.ts` The run path's view of a workspace's projects for a reader — `vx show`, the MCP server, an embedder: discovery, the plugin `config` and `project` stages, and the local cache opened only to serve cached evaluations, so a pure config costs a stat, not an evaluation. `scope` is every project or a list of names; no closure, no lock (a reader reads live, as a default run does). Plugin warnings go to `warn`. ```ts export async function loadResolvedProjects( workspaceRoot: string, opts: { scope?: 'all' | readonly string[]; warn?: (message: string) => void } = {}, ): Promise> ``` ## `loadWorkspace` function · `src/workspace/workspace.ts` Read the workspace's package-glob list, supporting all common package managers: - `pnpm-workspace.yaml` (pnpm) - `package.json` `workspaces` array (npm / yarn / bun) - `package.json` `workspaces.packages` array (yarn legacy) If a `package.json` exists with no `workspaces` field, the root itself is treated as a single-project workspace. ```ts export async function loadWorkspace(root: string, reads?: LoadReads): Promise ``` ## `LocalHistoryProvider` class · `src/orchestrator/history.ts` Reads from the orchestrator's local SQLite cache.db. ```ts export class LocalHistoryProvider implements HistoryProvider { constructor( private readonly db: Database, private readonly recent: number = DEFAULT_RECENT, ) {} async loadFor(taskIds: readonly string[]): Promise async p50sFor(taskIds: readonly string[]): Promise> } ``` ## `lockfileClaim` function · `src/orchestrator/lockfile-claim.ts` ```ts export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks ``` ## `LockfileClaimHooks` type · `src/orchestrator/lockfile-claim.ts` The two hooks a lockfile plugin spreads into `definePlugin`. ```ts export interface LockfileClaimHooks { readonly fingerprint: FingerprintClaim key(task: TaskNode, ctx: KeyHookContext): Promise> | undefined> } ``` ## `LockfileClaimOptions` type · `src/orchestrator/lockfile-claim.ts` ```ts export interface LockfileClaimOptions { readonly file: string readonly digest: (text: string, files: ReadonlyMap) => ReadonlyMap readonly extraFiles?: (text: string) => readonly string[] readonly version: number readonly scope?: 'project' | 'workspace' readonly part?: string } ``` ## `LOG_WIRE_VERSION` const · `src/orchestrator/task-log-buffer.ts` Version of the drained-bundle shape below. It is the canonical drained logs format, not one transport's: every sink ships the same object. ```ts export const LOG_WIRE_VERSION = 1 ``` ## `Logger` type · `src/orchestrator/logger.ts` ```ts export interface Logger { status(line: string): void taskStdout(node: TaskNode, chunk: string): void taskStderr(node: TaskNode, chunk: string): void taskComplete(node: TaskNode, outcome: TaskOutcome): void runStart?(info: { total: number concurrency?: number requestedCount?: number context?: RunContext startedAtMs?: number }): void taskStart?(node: TaskNode): void runEnd?(): void } ``` ## `machineMemoryBytes` function · `src/util/cgroup.ts` The machine's total memory, capped by its cgroup limit. ```ts export function machineMemoryBytes(probe: CgroupProbe = {}): number ``` ## `machineParallelism` function · `src/util/cgroup.ts` The cores this process may run on, capped by its cgroup CPU quota, rounded up (a 1.5-core quota is two workers, not one) and never below one. The default worker count. ```ts export function machineParallelism(probe: CgroupProbe = {}): number ``` ## `maskedCommand` function · `src/util/secret-mask.ts` A task's command as vx shows it: its secret values masked. ```ts export function maskedCommand(command: string, env?: TaskEnvSecrets): string ``` ## `maskedLine` function · `src/util/secret-mask.ts` A line vx prints for no one task (a plugin's warning): this process's secrets masked. ```ts export function maskedLine(line: string): string ``` ## `MigrationFormat` type · `src/workspace/migration.ts` ```ts export type MigrationFormat = 'ts' | 'mjs' ``` ## `MigrationPlan` type · `src/workspace/migration.ts` ```ts export interface MigrationPlan { headerNotes: string[] projects: GeneratedProject[] extraFiles: { relPath: string; contents: string }[] notes: string[] } ``` ## `NamedProject` type · `src/orchestrator/plugin.ts` ```ts export interface NamedProject { readonly dir: string readonly name: string } ``` ## `normalizeGlob` function · `src/util/paths.ts` The spellings a reader, Turbo and `.gitignore` all accept but a matcher fed the raw string turns into NOTHING — and a task keyed on nothing replays old outputs as a green hit (2026-09-10, probed one by one): a leading `./`, an inner `/./` segment, a doubled `//`, and a trailing `/` on a pattern (`src/*\/` means the trees under `src`, so it becomes `src/*\/**`; a trailing slash on a LITERAL is `asTrees`' job). Applied after an optional `!`; a bare `.` is the empty entry the schema refuses. ```ts export function normalizeGlob(glob: string): string ``` ## `OutcomeView` type · `src/orchestrator/events.ts` Serializable projection of a TaskOutcome (no node ref, ns as strings). ```ts export interface OutcomeView { taskId: string status: TaskOutcome['status'] exitCode: number durationMs: number isGroup?: boolean noCache?: boolean storedDurationMs?: number storedCpuMs?: number storedPeakRssBytes?: number hash?: string cpuMs?: number peakRssBytes?: number timedOut?: true notReady?: 'timeout' | 'exited' | 'spawn' blockedBy?: string admissionHeldMs?: number queuedMs?: number inputFiles?: number inputChanges?: InputChanges artifactBytes?: number fetchMs?: number saveMs?: number restored?: boolean sandboxViolations?: number sandboxViolationLines?: string[] wallclockStartNs?: string wallclockEndNs?: string } ``` ## `OutputLocation` type · `src/orchestrator/path-links.ts` A file a task's output names; `file` absolute. ```ts export interface OutputLocation { file: string line?: number col?: number } ``` ## `outputsOverlap` function · `src/graph/task-graph.ts` True only when two output globs PROVABLY select an overlapping set. Deliberately conservative, because the caller REFUSES the run: a false positive breaks a build that works today, which is worse than the defect being caught. So the three cases are exactly the ones that can be decided without a general glob-intersection algorithm: both literal — equal paths literal vs glob — ask the glob whether it matches the literal (exact) both globs — identical strings, or one a whole subtree `P/**` and the other's literal prefix P or under it: every path the second matches is under P, so the first covers it (item 941). Anything else is undecided here and deliberately allowed through The rejected alternative was comparing each glob's static prefix. It is cheaper and catches more, but it is UNSOUND for a refusal — measured: `dist/vx-*` and `dist/other.txt` share the prefix `dist` while matching disjoint sets, so a prefix check refuses a legitimate config. (vx's own `build.bun.*` tasks escape only because they declare distinct literals.) All three cases compare SPELLINGS, so each side is run through `asTrees` first — the same rule the resolver and `cleanOutputs` read, and `cleanOutputs` is what actually does the deleting. That folds two things this check used to miss, both of them the data loss it exists to prevent: - the SPELLING: `./dist/**` and `dist/**` are one tree to every matcher in vx, and `Bun.Glob('dist/**')` does not match the literal `./dist/app.js` either (item 441, probed one spelling at a time); - the literal DIRECTORY: `outputs: ['dist']` means everything under `dist` — `asTrees` compiles it to `dist` + `dist/**` — while this compared it to `dist/app.js` as two unequal literals. Measured end to end: the task declaring `dist` wiped the other's `dist/app.js` and the run reported success (item 442). Neither is a widening. Both read the declaration the way the code that deletes reads it, which is the only reading that decides the hazard. Exported through the façade because `@vzn/vx-migrate` asks the same question at MIGRATION time — it uncaches the losers so the generated config loads — and it used to ask it with a copy of this function. The copy did not get items 441 and 442, so it reported clean on configs core then refused, including `outputs: ['dist']` against `dist/app.js`, which is the commonest turbo.json shape there is (item 445). One rule, one place: the copy is gone. ```ts export function outputsOverlap(rawA: string, rawB: string): boolean ``` ## `OutputView` type · `src/orchestrator/logger.ts` The default logger's per-task output policy. Resolved once per run from (in priority order) the explicit `--output-logs` override, a truthy `CI` env, and the CLI-detected flow: full — frames for executed work, one-liners for quiet hits. Today's CI behavior; also the programmatic default. errors-only — only failed tasks print. none — no per-task output at all. hash-only — one line per task: outcome word, task id, cache key. No frames, no log replay (Turbo `--output-logs hash-only` parity); the end-of-run summary still renders. The line is the run's audit trail: which key each task resolved to, without any build output. focused — requested nodes stream raw output live (running the task should feel like running the command directly); dependency-pulled nodes are silent unless they fail. broad — news only: one `success` line per executed task, full frames for failures, silence for cache hits. `gha` (on GitHub Actions, any mode): task output is fenced from workflow commands. In full mode each task's block is also wrapped in `::group::` / `::endgroup::` so tasks collapse in the log viewer — except failed tasks, which stay pre-expanded and emit an `::error` annotation instead. `ci`: a truthy CI env was detected. Suppresses the dynamic status line even if stdout happens to be a TTY. ```ts export interface OutputView { mode: 'full' | 'errors-only' | 'none' | 'focused' | 'broad' | 'hash-only' gha?: boolean ci?: boolean } ``` ## `PERSISTENT_TODO` const · `src/workspace/migration.ts` The one wording every mapper emits for a task it made persistent. ```ts export const PERSISTENT_TODO = 'persistent task — set persistent.readyWhen (regex matched against output) so ' + 'dependents unblock on readiness, and consider exec.timeout to bound the wait' ``` ## `PlannedTask` type · `src/orchestrator/plan.ts` ```ts export interface PlannedTask { node: TaskNode hash: string cacheStatus: CacheStatus deps: readonly string[] p50Ms?: number executor?: string download?: 'deferred' affected?: AffectedReason } ``` ## `planRun` function · `src/orchestrator/run.ts` Planning mode. Same setup as `run()` — workspace discovery, config load, package graph, task graph — but stops short of execution. Returns a `RunPlan` predicting the cache hit/miss outcome of every task. Used by `--dry-run` and `--graph`. Side-effects are limited to opening + closing the local Cache handle (and running `cache.inputs.runtime` probe commands, which key derivation requires). Cache probing is the byte-free `cache.has()` existence check — no artifact download, no ingest, no accessed_at bump. ```ts export async function planRun(options: RunOptions): Promise ``` ## `PLUGIN_HOOKS` const · `src/config.ts` Every hook a plugin may fill, in pipeline order — THE list. The loader's "must be a function" and "at least one of" checks, the host's stage gate and `vx info`'s seam column all read it, so a stage added here is a stage everywhere: `admit` reached `vx info` a day late (2026-09-12) because that column kept its own copy, and the loader kept a third. The type pin below refuses a list that drifts from `Plugin`'s keys either way. ```ts export const PLUGIN_HOOKS = [ 'config', 'discover', 'project', 'graph', 'key', 'fingerprint', 'schedule', 'admit', 'executor', 'cache', 'telemetry', 'setup', 'commands', 'teardown', ] as const ``` ## `PluginCommand` type · `src/orchestrator/plugin.ts` One CLI verb contributed by a plugin. ```ts export interface PluginCommand { readonly description: string run(argv: readonly string[], ctx: CommandContext): number | Promise } ``` ## `PluginHook` type · `src/config.ts` ```ts export type PluginHook = (typeof PLUGIN_HOOKS)[number] ``` ## `PluginHookHandlers` type · `src/orchestrator/plugin.ts` ```ts export interface PluginHookHandlers { onRunStart: (info: RunStartInfo) => void | Promise onTaskStart: (node: TaskNode) => void | Promise onTaskStdout: (node: TaskNode, chunk: string) => void | Promise onTaskStderr: (node: TaskNode, chunk: string) => void | Promise onTaskComplete: (node: TaskNode, outcome: TaskOutcome) => void | Promise onRunStatus: (line: string) => void | Promise onRunEnd: () => void | Promise } ``` ## `PluginHookName` type · `src/orchestrator/plugin.ts` ```ts export type PluginHookName = | 'onRunStart' | 'onTaskStart' | 'onTaskStdout' | 'onTaskStderr' | 'onTaskComplete' | 'onRunStatus' | 'onRunEnd' ``` ## `PluginHooks` type · `src/orchestrator/plugin.ts` What a plugin author writes: every hook, and no name. ```ts export type PluginHooks = Omit ``` ## `PluginOptionKinds` type · `src/orchestrator/plugin.ts` Every option a factory takes, each with the one kind its type allows (`'any'` for a union of kinds, such as `false | { … }`). Derived from the options interface, so the type checker refuses a missing option, an extra one or a wrong kind. ```ts export type PluginOptionKinds = { readonly [K in keyof Required]-?: OptionKind[K]> } ``` ## `PluginOrigin` type · `src/orchestrator/plugin.ts` Where a plugin is defined — `import.meta` of its module. `dir` is Bun's field; `url` is the standard one, for a module evaluated elsewhere. ```ts export interface PluginOrigin { readonly dir?: string readonly url?: string } ``` ## `PluginSetupContext` type · `src/orchestrator/plugin.ts` What `setup` receives: the run's lifecycle, observe-only. ```ts export interface PluginSetupContext extends BaseContext { on(hook: K, handler: PluginHookHandlers[K]): void } ``` ## `PreparedRun` type · `src/orchestrator/prepare.ts` ```ts export interface PreparedRun { workspaceRoot: string workspaceConfig: WorkspaceConfig | null plugins: readonly VxPlugin[] cacheDir: string cache: CacheLayer localCache: Cache hasRemoteLayer: boolean cachePolicy: CachePolicy priorities: ReadonlyMap nodes: Map keyOnly: ReadonlyMap unresolvedTasks: readonly string[] declaredElsewhere: readonly string[] projects: ReadonlyMap hintProjects: ReadonlyMap anyProjectConfig: boolean workspaceFingerprint: string fingerprintWatch: FingerprintWatch nestedDirsByProject: Map gitFilesCache: GitFilesCache workspaceProjectCount: number hashCache: HashCache empty: null | 'no-tasks-declared' | 'none-affected' | 'empty-graph' } ``` ## `prepareRun` function · `src/orchestrator/prepare.ts` Build the prepared-run context: workspace discovery, project-config load, package + task graph, cache handle (local, optionally wrapped in a remote layer). Caller owns `cache.close()`. Returns even when nothing can run — the `empty` field tells the caller why. We never throw on "no tasks"; behavior on that case is caller-specific (run logs + returns NOT-ok; planRun returns an empty plan). ```ts export async function prepareRun(options: RunOptions, log: Logger): Promise ``` ## `ProjectConfig` type · `src/config.ts` ```ts export interface ProjectConfig { tags?: readonly string[] tasks?: Record } ``` ## `ProjectEntry` type · `src/workspace/workspace.ts` A discovered project joined with its loaded vx config. ```ts export interface ProjectEntry { name: string dir: string config: ProjectConfig } ``` ## `ProjectHookContext` type · `src/orchestrator/plugin.ts` ```ts export interface ProjectHookContext extends BaseContext { readonly name: string readonly dir: string readonly packageJson: Readonly> readonly projects: readonly ProjectMeta[] } ``` ## `ProjectMeta` type · `src/workspace/workspace.ts` ```ts export interface ProjectMeta { name: string dir: string packageJson: PackageJson configPath: string | null catalogs?: Catalogs } ``` ## `pruneOrphanPersistentNotes` function · `src/workspace/migration.ts` Strips the note from every persistent task no `dependsOn` in the mapping names. ```ts export function pruneOrphanPersistentNotes( projects: readonly { readonly tasks: readonly { readonly name: string readonly todos: string[] readonly task: Record | null }[] }[], note: string, ): void ``` ## `quoteTsLiteral` function · `src/workspace/migration.ts` Escape an arbitrary string into a single-quoted TS literal. Escapes backslash + quote AND raw newlines/CR — a value with an embedded newline (legal JSON, e.g. a script `"echo a\necho b"`, or a glob with a `'`) would otherwise splice into a single-quoted literal as an unterminated / malformed string that fails to load (generated files must round-trip through the loader). ```ts export function quoteTsLiteral(s: string): string ``` ## `RawExpr` type · `src/workspace/migration.ts` Verbatim TS expression spliced into a generated array (preset spreads). ```ts export interface RawExpr { readonly raw: string } ``` ## `reachDigests` function · `src/orchestrator/lockfile-claim.ts` One digest per node over everything the node reaches, Merkle-style: a change anywhere in a node's reach moves its digest, a change elsewhere does not. Lockfiles carry dependency cycles, so the unit is the strongly connected component: Tarjan's walk (iterative — a dependency chain can be thousands deep) emits components children-first, and each folds its members (sorted), each as its material and the materials its edges land on, and its child components' digests (sorted). O(nodes + edges): 1000 importers over 3000 packages digest in ~20 ms where one traversal per importer took 400. ```ts export function reachDigests(g: ReachGraph): string[] ``` ## `ReachGraph` type · `src/orchestrator/lockfile-claim.ts` A dependency graph: one material string per node and its out-edges by index. ```ts export interface ReachGraph { readonly material: readonly string[] readonly edges: ReadonlyArray } ``` ## `refuseUnknownOptions` function · `src/orchestrator/plugin.ts` Refuse an option a plugin factory does not take, or a value of the wrong kind, as core refuses an unknown config field. Bun strips a config's types, so a misspelt option (`reapi({ endpont })`) reached the factory, which read it as unset and quietly declined, and a string where a number or a boolean belongs (`process.env.X`) was misread or threw a bare TypeError. `factory` names the call in the message (`reapi()`). ```ts export function refuseUnknownOptions( factory: string, options: unknown, kinds: PluginOptionKinds, ): void ``` ## `RemoteCacheLayer` type · `src/cache/layered-cache.ts` What a remote cache layer must provide — THE plugin seam for remote caching. Core ships no wire client; a plugin's `cache` capability (or an embedder via `RunOptions.remoteCache`) supplies an implementation speaking whatever protocol it wants, and `LayeredCache` owns everything else: policy gating, in-flight dedup, remote provenance, and the never-fail contract (implementations THROW on failure; LayeredCache degrades every throw to a cache miss via `onRemoteError`). The artifact bytes are the local `.tar.zst` verbatim. The wires live in plugin packages (`@vzn/vx-migrate`'s `turboCache()` and `nxCache()`, `@vzn/vx-reapi`); see docs/modules/layered-cache.md. Core awaits every call and bounds none: a `get` that never settles holds its task and a `put` the run's upload drain, so each request carries the layer's own deadline (every first-party layer has one; the plugin guide's example shows it). ```ts export interface RemoteCacheLayer { readonly endpoint?: string has(hash: string): Promise hasMany?(hashes: readonly string[]): Promise | null> get(hash: string): Promise<{ body: Blob | Response; durationMs: number | undefined } | null> put(hash: string, body: Blob, meta: { durationMs: number }): Promise } ``` ## `ResolvedSandboxConfig` type · `src/exec/sandbox-runtime.ts` Sandbox config with all path fields resolved to absolute paths. Produced by `resolveSandboxConfig`. The shape mirrors `SandboxConfig` but every string in a path list is guaranteed absolute. ```ts export interface ResolvedSandboxConfig { allowRead: readonly string[] allowWrite: readonly string[] pendingWrites?: readonly string[] network?: true | readonly string[] denyNetwork?: readonly string[] systemInfo?: readonly string[] unixSockets?: true | readonly string[] localBinding?: boolean | readonly number[] machLookup?: readonly string[] pty?: boolean gitConfig?: boolean weakerWhenNested?: boolean weakerNetworkIsolation?: boolean wallsReached?: { read: readonly string[]; write: readonly string[] } ignore?: { read?: readonly string[] write?: readonly string[] systemInfo?: readonly string[] network?: readonly string[] } } ``` ## `resolveRunId` function · `src/orchestrator/run-id.ts` The recorded run `raw` names: itself when recorded, else the one run whose id starts with it. Null when none does; a prefix several runs share is refused with those runs, since picking one would replay the wrong run. ```ts export function resolveRunId(db: Database, raw: string, verb: string): string | null ``` ## `RootCause` type · `src/orchestrator/metrics.ts` ```ts export interface RootCause { chain: string[] entries: CacheKeyDiff['entries'] } ``` ## `rootCauses` function · `src/orchestrator/metrics.ts` The tasks under `taskId`'s moved upstreams whose OWN key components moved in the same run, each with the chain that carried it up. A task visited once is not walked again (a diamond names its root once). ```ts export function rootCauses( db: Database, runId: string, taskId: string, entries: CacheKeyDiff['entries'], ): RootCause[] ``` ## `run` function · `src/orchestrator/run.ts` ```ts export async function run(options: RunOptions): Promise ``` ## `RunContextRecord` type · `src/orchestrator/telemetry.ts` Identifies which run a record belongs to + its captured context. Maps cleanly onto OTel CI/CD + VCS resource attributes. ```ts export interface RunContextRecord { runId: string vxVersion: string command: string requestedTasks: readonly string[] cachePolicy: string concurrency: number flow: 'focused' | 'broad' | null workspaceId: string workspaceName: string repository?: string workspacePath?: string commitSha: string | null branch: string | null defaultBranch: string | null dirty: boolean | null ci: boolean ciProvider: string | null ciRunUrl?: string ciChange?: string ciPipeline?: string ciJob?: string ciAttempt?: number host: string | null os: string arch: string tags: Readonly> } ``` ## `runFailures` function · `src/orchestrator/run-failures.ts` The failed tasks of `runId`, or of the latest failed run when omitted; null when there is no such run. A failed task the file lacks (an older run, a write the disk refused) reads `output: ''`, `locations: []`. ```ts export function runFailures(cacheDir: string, db: Database, runId?: string): RunFailures | null ``` ## `RunFailures` type · `src/orchestrator/run-failures.ts` ```ts export interface RunFailures { runId: string tasks: TaskFailure[] } ``` ## `RunOptions` type · `src/orchestrator/options.ts` ```ts export interface RunOptions { cwd: string tasks: readonly string[] projects?: string[] selectedByDiff?: boolean affected?: AffectedChanges selectedOutright?: readonly string[] affectedReasons?: Map staged?: ReadonlyMap discovered?: { root: string; projects: ProjectMeta[] } concurrency?: number cacheDir?: string cache?: CachePolicy remoteRequested?: boolean defaultCacheScope?: string frozen?: boolean outputLogs?: 'full' | 'errors-only' | 'none' | 'hash-only' download?: 'all' | 'toplevel' | 'none' flow?: 'focused' | 'broad' retries?: number timeout?: number continueMode?: ContinueMode excludeDependencies?: 'all' | readonly string[] forwardArgs?: readonly string[] summarize?: string json?: boolean beforeFooter?: (outcomes: readonly TaskOutcome[], ok: boolean) => string profile?: string handleSignals?: boolean signal?: AbortSignal holdPersistent?: boolean keep?: HeldPersistent summaryTable?: boolean tty?: boolean log?: Logger bus?: EventBus inflight?: Map> tags?: Record telemetrySinks?: readonly TelemetrySink[] command?: string remoteCache?: RemoteCacheLayer artifactCeiling?: number } ``` ## `RunPlan` type · `src/orchestrator/plan.ts` ```ts export interface RunPlan { tasks: PlannedTask[] predicted?: PlanPrediction unresolvedTasks?: readonly string[] unresolvedHint?: string noneAffected?: string downloadDowngrades?: ReadonlyArray<{ taskId: string; reason: string }> } ``` ## `RunRecord` type · `src/cache/layer.ts` ```ts export interface RunRecord { hash?: string project: string task: string status: 'success' | 'failed' | 'cache-hit' | 'cache-hit-remote' | 'skipped' exitCode: number durationMs: number forwardArgs?: readonly string[] startedAt: number endedAt: number runId?: string cpuMs?: number peakRssBytes?: number wallclockStartNs?: bigint wallclockEndNs?: bigint cacheHit?: boolean restored?: boolean attempts?: number cached?: boolean blockedBy?: string timedOut?: true sandboxViolations?: number notReady?: 'timeout' | 'exited' | 'spawn' } ``` ## `RunResult` type · `src/orchestrator/run-report.ts` One finished run, reduced to what a report needs. Used to live in `protocol.ts` as the return type of the whole-run `backend` seam; that seam is gone (a run always executes in-process — see `docs/modules/executor.md`), so the shape lives with its only consumer. ```ts export interface RunResult { ok: boolean outcomes: OutcomeView[] } ``` ## `RunStartInfo` type · `src/orchestrator/events.ts` Payload of the `run:start` event — mirrors the Logger.runStart hook. ```ts export interface RunStartInfo { total: number concurrency?: number requestedCount?: number context?: RunContext startedAtMs?: number } ``` ## `RunSummary` type · `src/orchestrator/options.ts` ```ts export interface RunSummary { ok: boolean outcomes: TaskOutcome[] persistent?: HeldPersistent refused?: string json?: RunSummaryJson } ``` ## `RunSummaryRecord` type · `src/orchestrator/telemetry.ts` A per-run SUMMARY record — the denormalized invocation header plus the per-task outcome list, emitted once at run:end. An ingesting store can persist a whole run in one write without replaying the stream. The manual-API exporter + a service's ingest endpoint primarily speak this shape. ```ts export interface RunSummaryRecord { v: number run: RunContextRecord startedAt: number endedAt: number totalDurationMs: number taskCount: number failedCount: number abortedCount: number hitCount: number hitLocalCount: number hitRemoteCount: number upToDateCount: number restoredLocalCount: number restoredRemoteCount: number exitOk: boolean tasks: readonly TaskTelemetry[] stages?: readonly RunStage[] uploads?: { count: number; bytes: number; ms: number; failed: number } } ``` ## `SandboxConfig` type · `src/config.ts` What a sandboxed task may do, declared as CAPABILITIES. One shape for the whole policy. You say what the task is allowed to touch; vx decides how each capability is realised — some become sandbox runtime config, some become rules in the OS policy, and one the platform cannot express is an error rather than a silent no-op. None of that reaches the config. The baseline (`sandbox: {}`) grants almost nothing: the task reads nothing, writes nothing and reaches no network — NOT EVEN ITS OWN PROJECT DIRECTORY, which is why `allow: { read: ['.'] }` opens almost every real block. The one grant vx makes for you is dependencies: `node_modules`, and through it the real path of every workspace package linked there. `cache` grants no access in either direction: `cache.inputs` says what INVALIDATES a task, `allow` says what it may TOUCH, and deriving one from the other coupled them both ways — a declaration added for caching silently widened the sandbox, and a path the task needed had to be laundered through the cache key to get it (owner, 2026-09-05). A declared `cache.outputs` is NOT a write grant; the request `sandbox-request.ts` builds carries no write of its own and binds `allow.write` alone. This comment claimed the opposite — here and on both grant fields — while the file beside it and `schema.md` both said the truth, and no test read the bare baseline it described (item 443). That case has a test of its own now. Enforcement anchors at the WORKSPACE ROOT, so a task cannot leave its project whatever it declares here. The full account, including what a write grant costs on Linux, is in `docs/schema.md` under `exec.sandbox`. Paths are project-relative, absolute, or `~`-expanded, and may be globs: macOS matches the pattern in the policy, Linux expands it when the task starts (a mount cannot hold a pattern), so a file created later is not covered there — grant its directory. ```ts export interface SandboxConfig { allow?: SandboxGrants deny?: SandboxDenials ignore?: SandboxIgnore weakerWhenNested?: boolean weakerNetworkIsolation?: boolean } ``` ## `SandboxDenials` type · `src/config.ts` ```ts export interface SandboxDenials { network?: readonly string[] } ``` ## `SandboxGrants` type · `src/config.ts` ```ts export interface SandboxGrants { read?: readonly string[] write?: readonly string[] network?: true | readonly string[] systemInfo?: readonly string[] unixSockets?: true | readonly string[] localBinding?: boolean | readonly number[] machLookup?: readonly string[] pty?: boolean gitConfig?: boolean } ``` ## `ScheduleHookContext` type · `src/orchestrator/plugin.ts` ```ts export interface ScheduleHookContext extends BaseContext { readonly localCache: Cache } ``` ## `splitTaskId` function · `src/util/task-id.ts` The inverse of `taskId` (`graph/task-graph.ts`). Splits on the FIRST `#`, so a task name that itself contains one round-trips: `taskId('a', 'b#c')` → `'a#b#c'` → `['a', 'b#c']`. This exists because it kept being written by hand as `id.split('#', 2)`, which is NOT the inverse — it discards everything after the second segment, so `'a#b#c'` reads back as task `'b'`. Six call sites had that form while `parseDependencySpec` (the surface that decides what actually runs) has always split on the first `#`, so the query layer and the graph disagreed about the identity of the same task: a lookup either found nothing or, worse, answered with a different task's history. A config's task name may not hold `#` (`taskNameProblem`), but an id also arrives from run history and plugins, so the first-`#` rule is the one the graph uses. The cache's run history read the same rule from a private copy until item 646; one rule, one place. ```ts export function splitTaskId(id: string): [project: string, task: string] ``` ## `TaskConfig` type · `src/config.ts` ```ts export interface TaskConfig { description?: string exec?: ExecConfig dependsOn?: readonly string[] cache?: CacheConfig } ``` ## `TaskExecutor` type · `src/exec/executor.ts` ```ts export interface TaskExecutor { readonly name: string readonly remote?: boolean readonly capacity?: number accepts?(task: TaskPlacement): boolean demand?(remaining: ReadonlySet): void execute(req: ExecuteRequest): Promise } ``` ## `TaskFailure` type · `src/orchestrator/run-failures.ts` A failed task of a run and what it said. ```ts export interface TaskFailure { taskId: string exitCode: number timedOut?: true output: string locations: OutputLocation[] } ``` ## `TaskHistory` type · `src/orchestrator/history.ts` Per (project#task) — last RECENT runs collapsed into a summary. ```ts export interface TaskHistory { runs: number p50DurationMs: number | undefined p99DurationMs: number | undefined successRate: number hitRate: number failureMode: FailureMode maxPeakRssBytes?: number maxCpuParallelism?: number } ``` ## `taskLog` function · `src/orchestrator/run-failures.ts` `taskId`'s output in `runId`, or in the latest run that recorded the task when omitted; null when no such run recorded it. ```ts export function taskLog( cacheDir: string, db: Database, taskId: string, runId?: string, ): TaskLog | null ``` ## `TaskLog` type · `src/orchestrator/run-failures.ts` One task's output in a recorded run, as `vx last --log` and getTaskLog read it. ```ts export interface TaskLog { runId: string taskId: string status: string source: 'failure' | 'cache' | null output: string } ``` ## `TaskLogBuffer` class · `src/orchestrator/task-log-buffer.ts` Bounded per-run capture. `append` keeps a chunk LIST + running char count per task, evicting whole chunks from the head past `TASK_LOG_TAIL_CHARS` — no string concatenation until `drain`, so a cache-hit replay (one big chunk) is one array push, zero copies. `finish` decides retention; `drain` emits the bundle, failures first. ```ts export class TaskLogBuffer { append(taskId: string, chunk: string): void finish(taskId: string, status: TaskStatus, cacheSource: CacheSource, hash?: string): void takeEntry(taskId: string): TaskLogEntry | undefined drain(runId: string, workspaceId: string): TaskLogBundle size(): number budgetUsed(): number } ``` ## `TaskLogBundle` type · `src/orchestrator/task-log-buffer.ts` ```ts export interface TaskLogBundle { v: typeof LOG_WIRE_VERSION runId: string workspaceId: string tasks: TaskLogEntry[] } ``` ## `TaskLogEntry` type · `src/orchestrator/task-log-buffer.ts` ```ts export interface TaskLogEntry { taskId: string hash?: string status: 'success' | 'failed' content: string charsFull: number truncatedHeadChars: number } ``` ## `TaskNode` type · `src/graph/task-graph.ts` ```ts export interface TaskNode { id: string projectName: string projectDir: string taskName: string config: TaskConfig deps: string[] orderOnly?: string[] requested: boolean surfaced?: boolean keyParts?: ReadonlyArray addsToOutputsOf?: string[] outputsAddedToBy?: string[] excludedUpstream?: TaskOutcome[] } ``` ## `TaskOutcome` type · `src/graph/scheduler.ts` ```ts export interface TaskOutcome { node: TaskNode status: TaskStatus exitCode: number durationMs: number hash?: string storedDurationMs?: number storedCpuMs?: number storedPeakRssBytes?: number admissionHeldMs?: number queuedMs?: number inputFiles?: number artifactBytes?: number fetchMs?: number saveMs?: number inputChanges?: InputChanges cpuMs?: number peakRssBytes?: number groupUpstream?: readonly TaskOutcome[] unkeyed?: true cacheOff?: true blockedBy?: string timedOut?: true failedOutput?: string notReady?: 'timeout' | 'exited' | 'spawn' where?: string outputs?: 'deferred' wallclockStartNs?: bigint wallclockEndNs?: bigint restored?: boolean attempts?: number failedAttempts?: readonly { endedAt: number; exitCode: number; timedOut?: true }[] flaky?: { passes: number; failures: number } sandboxViolations?: number sandboxViolationLines?: string[] } ``` ## `TaskPlacement` type · `src/exec/executor.ts` What an executor sees when a task is PLACED — once per task, before scheduling. ```ts export interface TaskPlacement { readonly taskId: string readonly projectName: string readonly projectDir: string readonly command: string readonly pinnedLocal: boolean readonly cacheable: boolean } ``` ## `TaskStatus` type · `src/graph/scheduler.ts` ```ts export type TaskStatus = | 'success' | 'cache-hit' | 'cache-hit-remote' | 'failed' | 'skipped' | 'aborted' ``` ## `TaskTelemetry` type · `src/orchestrator/telemetry.ts` Denormalized per-task analytics — shared by the streaming `task.end` record and the per-run summary's `tasks[]`. ```ts export interface TaskTelemetry { taskId: string project: string task: string status: TaskStatus cacheSource: CacheSource exitCode: number durationMs: number hash?: string cpuMs?: number peakRssBytes?: number where?: string outputs?: 'deferred' attempts?: number blockedBy?: string timedOut?: true sandboxViolations?: number notReady?: 'timeout' | 'exited' | 'spawn' failedAttempts?: readonly FailedAttempt[] flaky?: { passes: number; failures: number } sandboxViolationLines?: readonly string[] storedDurationMs?: number storedCpuMs?: number storedPeakRssBytes?: number admissionHeldMs?: number queuedMs?: number inputFiles?: number inputChanges?: InputChanges artifactBytes?: number fetchMs?: number saveMs?: number restored?: boolean wallclockStartNs?: string wallclockEndNs?: string } ``` ## `TaskView` type · `src/orchestrator/events.ts` Display projection of a TaskNode — exactly the fields renderers read. ```ts export interface TaskView { id: string project: string task: string isGroup: boolean requested: boolean surfaced: boolean persistent: boolean command?: string } ``` ## `TELEMETRY_SCHEMA_VERSION` const · `src/orchestrator/telemetry.ts` Bumped when the record shape changes. Readers MUST check `v`. ```ts export const TELEMETRY_SCHEMA_VERSION = 3 ``` ## `TelemetryContext` type · `src/orchestrator/telemetry.ts` Read-only context a sink is created with. No mutable run handle — the isolation guarantee is structural. ```ts export interface TelemetryContext { readonly workspaceRoot: string readonly cacheDir: string warn(message: string): void } ``` ## `TelemetryRecord` type · `src/orchestrator/telemetry.ts` A streaming telemetry record — one per lifecycle event. A superset of the rendering-oriented `WireEvent`: it carries the run context + the per-task analytics fields a consumer needs WITHOUT re-deriving from the stream. `task.log` records are large and OPT-IN (see `TelemetrySink.wants`). ```ts export type TelemetryRecord = | { v: number kind: 'run.start' run: RunContextRecord total: number ts: number startedAt: number } | { v: number kind: 'task.start' runId: string taskId: string project: string task: string command?: string dependsOn?: readonly string[] ts: number } | { v: number kind: 'task.log' runId: string taskId: string stream: 'stdout' | 'stderr' chunk: string ts: number } | { v: number kind: 'task.sample' runId: string taskId: string ts: number cpuMs: number rssBytes: number } | ({ v: number; kind: 'task.end'; runId: string; ts: number } & TaskTelemetry) | { v: number; kind: 'run.end'; runId: string; ts: number } ``` ## `TelemetrySink` type · `src/orchestrator/telemetry.ts` A telemetry consumer. Observe-only: receives records, holds no run handle. ```ts export interface TelemetrySink { readonly name?: string readonly wants?: ReadonlyArray onRecord?(record: TelemetryRecord): void onRunSummary?(summary: RunSummaryRecord): void flush?(signal: AbortSignal): Promise } ``` ## `UserError` class · `src/util/errors.ts` ```ts export class UserError extends Error { readonly code: string constructor(message: string) constructor(message: string, code: string) constructor(message: string, code = 'VX_E_REFUSED') } ``` ## `VERSION` const · `src/version.ts` ```ts export const VERSION: string = pkg.version ``` ## `VxPlugin` type · `src/orchestrator/plugin.ts` A vx plugin. Contributes any subset of the run-level capabilities — where work runs (executor), which cache is used (cache), who observes the run (telemetry). It never changes WHAT a task is (the command string — principle #3), only where and how that command is executed. Registered explicitly in vx.workspace.ts via defineWorkspace({ plugins: [...] }). No auto-discovery. Made by `definePlugin(import.meta, hooks)` only: the loader refuses a plain object. The capabilities are consulted by `plugin-host.ts`. Core names no plugin; the local executor and the local cache are the floor under the list, taking what every plugin declines. ```ts export interface VxPlugin { readonly name: string config?(workspace: WorkspaceConfig, ctx: WorkspaceHookContext): void | Promise discover?(ctx: DiscoverContext): readonly NamedProject[] | Promise project?(config: ProjectConfig, ctx: ProjectHookContext): void | Promise graph?(nodes: Map, ctx: GraphHookContext): void | Promise key?( task: TaskNode, ctx: KeyHookContext, ): | Readonly> | undefined | Promise> | undefined> readonly fingerprint?: FingerprintClaim schedule?( nodes: ReadonlyMap, ctx: ScheduleHookContext, ): ReadonlyMap | undefined | Promise | undefined> admit?(task: TaskNode, ctx: AdmitContext): boolean readonly commands?: Readonly> cache?(ctx: CacheContext): CacheLayer | undefined | Promise executor?(ctx: ExecutorContext): TaskExecutor | undefined | Promise telemetry?( ctx: TelemetryContext, ): | TelemetrySink | TelemetrySink[] | undefined | Promise setup?(ctx: PluginSetupContext): void | Promise teardown?(): void | Promise } ``` ## `whyDidThisRerunQuery` function · `src/orchestrator/metrics.ts` ```ts export function whyDidThisRerun(db: Database, runId: string, taskId: string): WhyDidThisRerun ``` ## `withForwardArgs` function · `src/exec/runner.ts` The command a task runs with the args after `--` appended, shell-quoted. They go before a trailing comment: appended after it, `echo args: # show` ran without them and said nothing (item 1060). Trailing blanks go first. ```ts export function withForwardArgs(command: string, args: readonly string[] | undefined): string ``` ## `WorkspaceConfig` type · `src/config.ts` ```ts export interface WorkspaceConfig { concurrency?: number cacheDir?: string timeout?: number cacheRetention?: { olderThan?: string; maxSize?: string } affectedBase?: string cacheScope?: string rules?: WorkspaceRules plugins?: readonly Plugin[] } ``` ## `WorkspaceHookContext` type · `src/orchestrator/plugin.ts` `config` runs before the cache dir is known — it may be what the hook changes. ```ts export interface WorkspaceHookContext { readonly workspaceRoot: string warn(message: string): void } ``` ## `WorkspaceIdentity` type · `src/orchestrator/run-context.ts` ```ts export interface WorkspaceIdentity { id: string name: string repository?: string path?: string } ``` --- # Benchmarks > Empirical overhead numbers vs. Turborepo and Nx on synthetic workspaces. Empirical overhead numbers vs. Turborepo and Nx on synthetic workspaces. Updated as the runners evolve. Every harness (`compare.ts`, `run.ts`, `ab.ts`, `real/turbo-repo.sh`, `real/nx-repo.sh`) runs vx from a `vx lock` snapshot (`--frozen`), the lock taken once per workspace before the timed reps, as CI runs vx (2026-09-29). A section dated before that ran vx without a lock, evaluating every config per run, except rows marked `(frozen)`; the 2026-09-03 stress run measured both, and its frozen row is now the headline `vx`. The harness never re-locks: a config edited after the lock is re-locked by running `vx lock` again, and `vx lock --check` is what fails loudly on a stale lock (a `--frozen` run trusts it, owner 2026-06-13, `docs/design/config-lock-2026-06.md`); only a project the lock lacks fails the frozen run itself. Only the native-config runs (each runner on the same graph from its own config: the stress run, the head-to-heads, the scaling table) measure vx. The real-repo runs went through `turbo()` or `nx()` on the repo's own `turbo.json` or Nx graph, a migration bridge rather than a claim (owner, 2026-10-02), and are removed (§ Real repos). Those repos are to be rerun on the native config `bunx @vzn/vx-migrate` writes. ## Warm-run overhead (2026-09-02) The number that matters most to a developer is the warm no-op run: every task a cache hit, nothing to restore. `packages/vx-bench/generate.ts` workspaces, `vx run build --all`, this machine (macOS arm64, Bun 1.4.0), best of 5: | Projects | Before (2026-09-02 morning) | Wave 1 | Wave 2 | Wave 5 | Wave 6 | Wave 7 | What changed | | -------- | --------------------------- | ------ | ------ | ------ | ------ | ------ | ---------------------------------------------------------------------------------------------------------- | | 100 | 105 ms | 92 ms | 79 ms | 78 ms | 77 ms | 74 ms | git overlaps config load; `.git/HEAD` read replaces a git spawn; batched probe; no output walk; git first | | 1000 | 380–450 ms | 270 ms | 242 ms | 237 ms | 204 ms | 172 ms | + cached pure-config evals; one worktree walk; readdir discovery; batched probe; no output walk; git first | Wave 7 (2026-09-03) starts the unscoped git enumeration the moment the workspace root is known — ahead of the workspace config, discovery and the cache open, ~25 ms earlier than before — so the `status` walk that is the warm run's wall floor overlaps everything. Interleaved A/B against a worktree at the previous commit, three rounds of three: 1000 projects min 195 → 172 ms. Wave 6 (2026-09-03) removed the per-hit output walk: for `dist/**`-shaped globs, the mtimes of every directory under the prefix, recorded at the last save or restore, prove the output set unchanged (`docs/caching.md` § A current tree). Interleaved A/B against a worktree at the previous commit, three rounds of three: 1000 projects min 224 → 204 ms. Wave 3 (batched short-circuit probe, output rows carried on the entry, memoised `Bun.Glob`s) measured on the graph WITH dependencies — `vx run test --all`, 2000 tasks, interleaved arms against an immutable worktree of the previous commit: 327–329 ms → 308–314 ms. Where Wave 2's 242 ms at 1000 projects went (`VX_TIMING=1`, see below; Waves 6 and 7 took 70 ms off it, mostly the run-graph phase and the git overlap): discovery 22 ms, config load 31 ms (all cache hits) overlapped with git's one worktree walk (`status -uall`, ~57 ms, the critical path), the run-graph phase 78 ms (1000 hits: probe, output glob, stat check), history recording 12 ms, cache open 9 ms, and ~50 ms of process start + module load + exit outside the table. The 1.7 s cold run is the 1000 `cp` commands. Reproduce: `bun packages/vx-bench/run.ts 1000 5` (it prints the median and every rep; the best is the min). Its last row, `one edited`, changes one package's source per rep: one miss, every other task a hit. ### A second machine, same shape (2026-09-20) The table above is one machine's. A four-core Linux container — sharing nothing with it — reads, medians with the full spread: | Projects | Warm | Restore | Cold | | -------- | ---------------- | ------------------- | ------------------------- | | 1000 | 271 ms (243–315) | 1031 ms (1023–1051) | 3147 ms (2905–3388) | | 5000 | 807 ms (760–809) | 3931 ms (3462–4172) | 14 181 ms (13 798–14 986) | Slower in absolute terms, as a shared container should be, and those numbers are **not** comparable to the ones above — different hardware. What does compare is the SCALING: 5× the projects costs **2.98×** the warm run here against **2.97×** on the other machine (restore 3.81× against 3.97×, cold 4.51× against 4.99×). The sub-linear warm curve is the code's, not one box's. One number to take from this before optimising against the harness: the warm arm spreads ±13 % about its median on identical code, because every rep is a whole CLI invocation. An A/B here needs an effect bigger than that, and a control arm beside it. The same container class on 2026-09-23, after the cold-path work of items 615 (a config round's evaluations written once per table) and 622 (output-directory snapshots landed in one transaction), medians with the full spread, five reps at 1,000 and three at 5,000: | Projects | Warm | Restore | Cold | | -------- | ---------------- | ------------------- | ------------------------- | | 1000 | 239 ms (231–265) | 906 ms (831–1009) | 2634 ms (2418–2814) | | 5000 | 711 ms (702–733) | 3253 ms (2993–3630) | 10 950 ms (10 896–11 547) | Against the 2026-09-20 rows: cold −16 % at 1,000 and −23 % at 5,000, restore −12 % and −17 %; the warm rows moved within the ±13 % spread and claim nothing — the warm path is unchanged since item 589. Scaling 5× the projects: warm 2.97×, restore 3.59×, cold 4.16×. ### Profiling a run Two tools, and they answer different questions: - **`VX_TIMING=1 vx run …`** prints a stage table to stderr at the end of the run — `startup`, `workspace config`, `discover projects`, `package graph`, `open cache`, `load configs`, `build graph`, `git enumeration`, `plugin stages`, `classify + probe`, `run graph`, `record history`, `output dir snapshots`, `close`, and in a dry run `plan` — with each stage's own and cumulative time, plus accumulated per-task spans (`cache.get`, `output glob`, `output stat` and `task hash` among them; [`modules/timing.md`](../modules/timing/) lists every one). This is the first thing to read: it says WHICH stage moved. The per-task spans run under the scheduler's concurrency, so they over-count (a span's wall includes time yielded to other tasks); compare them to each other, not to the stage total. - **`bun --cpu-prof --cpu-prof-dir=/tmp/prof packages/vx/src/bin.ts run …`** then `bun packages/vx-bench/profile-summary.ts /tmp/prof/*.cpuprofile` gives self time by function and by file. Good for finding a hot loop; unreliable about where an `await` waited (it attributes the wait to whatever frame was on the stack). - **`strace -f -o `** around a run, then `bun packages/vx-bench/strace-vx.ts []`: the count of vx's OWN syscalls (the tasks' shells are told apart by PID), as a diff against the same run on a `git worktree` of the base. A syscall count is deterministic where this container's wall time is not: the restore path's three spare round trips per artifact (item 627) were three rows of that table — `readlink` 1,003 → 3, `mkdir` 2,002 → 1,002, `newfstatat` −2,000 — and the save's two `mkdir`s of the cache directory (630) one row, before either was a number. A `write` per round trip is the thread pool's wake, so that row counts round trips. `bun --preload ./packages/vx-bench/sqlite-tally.ts …` is the same idea for SQLite statements; `restore-bench.ts` and `save-bench.ts` in the same package restore or save every artifact sequentially, where a per-artifact change of tens of microseconds shows above the four-worker run's noise (and one of a few microseconds does not: 630). Three measurement lessons from this wave, recorded so they are not re-learned. A compiled Bun 1.4.0 binary resolves on-disk packages by `/index.ts` only and ignores `exports`, so `packages/vx-bench/compare.ts` measured nothing ("vx skipped") until the packages gained root shims — if the vx row ever reads `n/a` again, read the skip line first. A micro-benchmark of a sync call in isolation (`statSync` 2 µs vs `stat` 13 µs) does not predict the run — the async forms run in parallel on the thread pool under the scheduler's concurrency, and switching the warm-hit path to sync calls made the 1000-project run 40 ms SLOWER. And Bun 1.4.0's `--compile` binaries carry a signature this macOS rejects (SIGKILL on launch); an ad-hoc `codesign -s - --force` repairs it, which the release workflow now does on a macOS runner. ### A/B two builds `packages/vx-bench/ab.ts` is the method the numbers here use: one workspace copy per arm, warmed by that arm, rounds that run every arm once in a rotated order, min and median per arm, and an A/A arm (the same vx twice) as the noise floor. Every arm after the first prints its min against the first arm's as a signed percent. An arm is a compiled binary or a checkout; the runs see git's defaults and no `BUN_OPTIONS`. A `run` is timed `--frozen`: each arm runs `vx lock` in its own copy with its own vx once, before the warm-up. ```bash bun packages/vx-bench/ab.ts 15 base=/tmp/vx-old@/tmp/w1 main=/tmp/vx-new@/tmp/w2 \ aa=/tmp/vx-new2@/tmp/w3 -- run build --all ``` A real repo's copies each link `node_modules/@vzn/vx-migrate` (or the plugin under test) to their arm's checkout, so a plugin change is measured with its own core. ### The per-PR guard `packages/vx-bench/perf-guard.ts` runs in every PR's gate as `@vzn/vx-bench#check.perf`. It builds a 25- and a 100-package workspace and drives core in-process through five phases: cold run, up-to-date run, restore run, plan, one-edit run. Two kinds of number, against `packages/vx-bench/perf-baseline.json`: - **Counts**: processes spawned, xxh3 calls, blob-hash updates, SQLite statements. The same code does the same work on any box, so a count that grows fails; one that shrinks passes. Statements gated by the 50 ms racy-mtime windows (the directory snapshots) are left out; they move with load. - **`--frozen` never does more**: every phase also runs `--frozen` from a `vx lock` (` --frozen @n`), and any count of it above the same phase's with no lock fails, whatever the baseline says. Time is not held, so the work is the pin of the rule that the lock is never slower. Measured on the 1,090-package bench (2026-10-09): first task cold 320 against 460 ms, warm 337 against 365, restore 745 against 761 (min of 5 to 15 interleaved). - **Time**, printed only: each timed phase's min over 7 reps at 100 packages, divided by a calibration loop run between reps, flagged past 1.5× the baseline, and each `--frozen` phase's ratio to no lock. It fails nothing: beside the test shards it read 1.97× on a PR that slowed no phase. Each measure runs in a child with a hermetic environment, since vx hashes `BUN_OPTIONS`, a HOME bunfig and its parent run's variables. A change meant to grow a count records it (on a conflict in the file, take main's and run it again): ```bash vx run @vzn/vx-bench#perf.update # then commit perf-baseline.json ``` Run it through `vx run`: the baseline's times are taken under the task sandbox, as the check is. ## Head-to-head, 2026-09-03 (46 packages, `packages/vx-bench/compare.ts 10 5 1`) Same workspace, identical commands, every runner pinned to concurrency 10, Turbo with no daemon (it uses none for `turbo run` since 2.8.11), vx as its compiled binary. Median of 1, this machine (macOS arm64, Bun 1.4.0). The harness then gave Nx npm where Turbo had bun (§ Why Nx is slower), so the Nx row is slower than a fair one. Every runner runs as in CI (`CI=1`), so Nx's daemon is off: | Runner | Version | Fresh (cold) | Warm (no restore) | Warm (restore) | | ----------- | ------- | ----------------------- | ------------------------ | ----------------------- | | vx | 0.0.0 | 10.45 s | 76 ms | 83 ms | | vx (frozen) | 0.0.0 | 10.49 s | 83 ms | 88 ms | | turbo | 2.10.12 | 10.58 s (vx 1% faster) | **71 ms** (vx 7% slower) | 97 ms (vx 17% faster) | | nx | 23.2.0 | 19.66 s (vx 88% faster) | 540 ms (vx 7.1× faster) | 531 ms (vx 6.4× faster) | Read it honestly: at 46 packages Turborepo 2.10 and vx are within a few milliseconds of each other on a fully-cached run, and neither keeps a process between runs: Turbo 2.10 uses no daemon for `turbo run` (its docs say so from 2.9), so both work out what changed on every invocation. vx wins the restore case and ties the cold one; vx is 7.1× faster than Nx warm. The remaining fixed cost at this size is process start + git, not the pipeline. The same 46-package run on the four-core Linux container (2026-09-24, item 735, the fixed harness: Nx runs its scripts with bun as Turbo does; `CI=1`, so Nx's daemon is off; a different machine, so only the ratios compare with the table above). Turbo 2.11.3 (no daemon for `turbo run`) and Nx 23.2.1, vx as its compiled binary, median of 3. The CPU column is user + system of the invocation and every child it waited for; a daemon that outlives the invocation would not be counted, and none runs here: | Runner | Version | Fresh (cold) | Warm (no restore) | Warm (restore) | CPU, cold | | ----------- | ------- | ------------------------ | ---------------------- | ----------------------- | ----------------------- | | vx | 0.0.0 | 10.29 s | 79 ms | 104 ms | 846 ms | | vx (frozen) | 0.0.0 | 10.27 s | **74 ms** | **95 ms** | 826 ms | | turbo | 2.11.3 | 10.43 s (vx 1% faster) | 86 ms (vx 9% faster) | 136 ms (vx 31% faster) | 1.33 s (vx 57% faster) | | nx | 23.2.1 | 22.08 s (vx 2.1× faster) | 844 ms (vx 11× faster) | 862 ms (vx 8.3× faster) | 43.79 s (vx 52× faster) | The ideal schedule is 10.00 s, so vx and Turbo both sit on the critical path cold, and warm they are within a few milliseconds at this size (the 2026-09-23 run of the old harness read Turbo at 112 ms). The same box under the old harness read Nx at 27.21 s cold and 1m 2s of CPU. The same harness at **476 packages / 1,428 graph nodes** (`packages/vx-bench/compare.ts 20 25 1`, 2026-09-02, same machine; a mid-size data point — the committed `packages/vx-bench/RESULTS.md` is the 3,270-task run below): | Runner | Fresh (cold) | Warm (no restore) | Warm (restore) | | ----------- | --------------------- | ----------------------- | ----------------------- | | vx | 1m 40s | **297 ms** | **416 ms** | | vx (frozen) | 1m 40s | 285 ms | 399 ms | | turbo | 1m 40s (vx same) | 342 ms (vx 15% faster) | 612 ms (vx 47% faster) | | nx | 3m 23s (vx 2× faster) | 1.38 s (vx 4.6× faster) | 1.33 s (vx 3.2× faster) | The same size on the four-core Linux container (2026-09-25, after items 744, 753 and 754, the fixed harness, median of 1; ideal schedule 1m 36s): | Runner | Fresh (cold) | Warm (no restore) | Warm (restore) | CPU, cold | | ----------- | ---------------------- | ----------------------- | ----------------------- | ----------------------- | | vx | 1m 37s | **225 ms** | **367 ms** | 7.06 s | | vx (frozen) | 1m 37s | 209 ms | 377 ms | 6.94 s | | turbo | 1m 39s (vx 2% faster) | 247 ms (vx 10% faster) | 392 ms (vx 7% faster) | 13.84 s (vx 96% faster) | | nx | 2m 27s (vx 52% faster) | 1.84 s (vx 8.2× faster) | 1.89 s (vx 5.1× faster) | 7m 23s (vx 63× faster) | Read it honestly: the 2026-09-24 run on this box had Turbo 2.11 winning both warm columns (303 and 446 ms against vx's 376 and 478). Item 744 found the cost in vx's stable-key pass (a string set copied per task per dep, now a bitset), item 753 coalesced the 952 task lines into a write per turn of the event loop, and item 754 ranked a warm run's tasks over the work that actually waits; vx now leads both warm columns, by 10% and 7% in one rep each, which this container's noise can close. The min-of-N interleaved read of the warm no-op on the same workspace, with compiled binaries, is vx 165–175 ms against Turbo 226 ms (item 753's profile). ### Why Nx is slower Two things, both per task, and both measured on the 46-package workspace (cold, 138 tasks, min of 3 interleaved arms, 2026-09-24, item 735): | Arm | Wall | CPU | | ----------------------------------------------- | ------- | ------- | | the old harness: `nx:run-script`, Nx picked npm | 28.82 s | 69.80 s | | `nx:run-script` with bun (the fixed harness) | 21.37 s | 41.18 s | | `nx:run-commands`: the same commands, no fork | 11.65 s | 3.05 s | 1. **A Node process per task.** `nx:run-script` runs every task in a freshly forked Node process (`nx/bin/run-executor.js`: 138 of them in one cold run, counted with `strace -f -e execve`) that loads Nx before it runs the script. That is ~270 ms of CPU per task. On four cores at concurrency 10 it saturates the CPU, so every task on the 20-task critical path waits for a core: 9.7 s of wall and 38 s of CPU, the gap between the last two rows. `nx:run-commands` runs its command from the Nx process itself (`NX_RUN_COMMANDS_DIRECTLY`), and with it Nx lands at 11.65 s against an ideal of 10.00. A package-based Nx repo gets `nx:run-script` for every inferred `package.json` script. Since 2026-10-04 (owner: "use run commands") the harness gives Nx `nx:run-commands` targets, the command line vx runs; the 46-package shape then read Nx 11.17 s cold and 3.26 s of CPU against vx's 10.40 s and 0.91 s (median of 3). The two hand-written Linux tables on this page predate it. `installDeps`, vx's group task, is a cached `nx:noop` for Nx and a script-less task for Turbo: on one 1,090-package workspace (no sleeps, min of 3) an uncached `nx:noop`, or a target with no executor (Nx makes it `nx:noop`), held Nx's warm run at 16.5 s against 5.9 s cached. 2. **npm, which the harness chose by accident.** `nx:run-script` runs the script through the package manager Nx detects from a lockfile. The generated workspace had none, so Nx fell back to npm, while Turbo read `packageManager` and ran `bun run`. `npm run` costs 202 ms of CPU per task against `bun run`'s 4 ms (min of 10, one task alone): 7.5 s of wall and 29 s of CPU, the gap between the first two rows. This one was the harness's fault, not Nx's. `nx.json` now says `cli.packageManager: 'bun'`. Warm is spread thin, with no single cause. With no daemon (CI), each invocation starts three plugin workers to build the project graph (~280 ms each, in parallel; `nx show projects` alone is 0.56 s), then hashes and replays the cached tasks. Turning the daemon on moves the graph into it and saves 40–160 ms (0.81 s against 0.85, min of 5, in a probe; 682 ms against 844, median of 3, in the harness), a fifth of the warm gap at most. The npm share grows with the graph. At 1,090 packages (3,270 tasks, `compare.ts 100 11`, same box, one cold run each) Nx took 20m 39s and 76 min of CPU with npm, and 7m 22s and 23 min of CPU with bun: npm was two thirds of the old harness's Nx number there, which is the run the site quoted (34m 44s cold, on the macOS machine). § A real monorepo is now this box's run with `nx:run-commands`. The whole 3,270-task shape on this box with `nx:run-script` and bun (2026-09-25, after items 744, 753 and 754, median of 1; ideal schedule 3m 38s): | Runner | Fresh (cold) | Warm (no restore) | Warm (restore) | CPU, cold | | ----------- | ---------------------- | ---------------------- | ---------------------- | ------------------------ | | vx | **3m 40s** | **359 ms** | **653 ms** | **16.05 s** | | vx (frozen) | 3m 41s | 292 ms | 651 ms | 16.46 s | | turbo | 5m 4s (vx 38% faster) | 431 ms (vx 20% faster) | 722 ms (vx 11% faster) | 33.27 s (vx 2.1× faster) | | nx | 6m 59s (vx 90% faster) | 4.50 s (vx 13× faster) | 4.60 s (vx 7× faster) | 20m 55s (vx 78× faster) | The day before, Turbo 2.11 won both warm columns here (496 and 856 ms against vx's 678 and 971). Item 744 found why: vx's stable-key pass copied a string set per task per dep, now a bitset; items 753 and 754 then took the per-line stdout writes and the whole-graph priority closure off the warm path. vx now leads every column at this size, in one rep each. Refuted, each within ±0.3 s of the fixed harness's 21.2 s cold: the per-task pseudo-terminal (`NX_NATIVE_COMMAND_RUNNER=false`) and the output style (`NX_TUI=false`, `--outputStyle=stream`). So is the daemon: the fixed harness read Nx cold at 22.25 s with `NX_DAEMON=true` and 22.08 s without. Parallelism is honoured: with cheap tasks, the `nx:run-commands` arm sits 1.65 s over the ideal schedule. The ~4 git probes each forked task runs cost ~8 ms of it. These tables used to say Nx's daemon was on; `CI=1` had always turned it off, and the harness keeps it off on purpose: it simulates CI. ### Why Turborepo is slower cold The order it starts ready tasks in, not its CPU (21 s cold against vx's 17 s at 3,270 tasks). Turborepo has no ranking: its walker hands out tasks as they become ready and each waits for a semaphore slot (`crates/turborepo-engine/src/execute.rs`). When a layer's builds finish, the next layer's builds become ready together with that layer's tests, and in ready order the tests go first, so every build on the 100-layer critical path waits about a second. `listSchedule(nodes, 10, 'fifo')` replays that order on the benchmark's graph: 4m 58s, against Turborepo's measured 4m 59s and 3m 38s ranked (`packages/vx-bench/tests/ideal.test.ts`). No `turbo.json` key changes the order. vx ranks ready tasks by remaining critical path, Nx by how many tasks wait on each. ## A real monorepo: 3,270 tasks, 100 layers (2026-10-04) The shape that actually stresses a task runner: **100 dependency layers**, ~11 packages per layer, ~30 deps per package, three tasks each (`build` + `installDeps` + `test`, `sleep 1` for build and test) — **3,270 task nodes**, 1,090 packages. Same repo, same hardware, same task commands; every runner pinned to concurrency 10. `bun packages/vx-bench/compare.ts 100 11 1`, this machine (linux x64, 4 cores), Turbo 2.11.7, Nx 23.2.1, every Nx task an `nx:run-commands` target, Vite Task (`vp run`, vite-plus 1.0.0), its tasks in each package's `vite.config.ts`. vx runs from a `vx lock` snapshot (`--frozen`), taken once before the reps, as a CI pipeline runs it; _vx, no lock_ is the same run evaluating every config per run. The committed `packages/vx-bench/RESULTS.md` / `packages/vx-bench/results.json` are this run. | | vx | vx, no lock | Turborepo | Nx | Vite Task | | ------------------------------- | -------------------------------------------------------- | ----------- | ---------------------- | ---------------------- | ---------------------- | | **Cold** (nothing cached) | **3m 40s** | 3m 41s | 4m 59s (vx 36% faster) | 3m 49s (vx 4% faster) | 4m 49s (vx 31% faster) | | **Warm**, nothing to rebuild | **393ms** | 473ms | 463ms (vx 18% faster) | 6.45s (vx 16× faster) | 2.49s (vx 6.3× faster) | | **Warm**, restore outputs | **650ms** | 780ms | 997ms (vx 53% faster) | 6.25s (vx 9.6× faster) | 2.64s (vx 4.1× faster) | | **CPU burned**, cold (user+sys) | **17.27s** | 18.79s | 21.04s (vx 22% faster) | 52.19s (vx 3× faster) | 12.46s (vx 39% slower) | | **CPU burned**, warm (user+sys) | **745ms** | 894ms | 897ms (vx 21% faster) | 7.52s (vx 10× faster) | 2.48s (vx 3.3× faster) | | _Baseline_ (theoretical best) | 3m 38s cold; 0 warm, restore, CPU | — | — | — | — | | _Measured floors_ (context) | git walk 24ms · walk + raw copy 93ms · task shells 9.09s | — | — | — | — | vx N% or N× faster: that tool takes N% longer or N times as long as vx. Benchmark workload: a synthetic monorepo of 1,090 packages and 3,270 tasks in 100 dependency layers, every build and test taking 1 s; real repos with uneven task times will differ. **Baseline** is the theoretical best case, so each row shows its overhead: cold is the tasks' own durations list-scheduled on 10 workers along the exact dependency graph (critical path 1m 40s, total work ÷ workers 3m 38s); a cached run, a restore and the CPU a runner burns are 0 in theory, so every measured number in those rows is the runner. The cold row's total time is the headline. Secondary, the cold overhead over the ideal schedule: vx's is 2.33s on 3,270 tasks (2 ms per package), 2.52s with no lock; Turborepo's is 1m 21s (74 ms per package), Nx's 10.98s (10 ms per package) and Vite Task's 1m 11s (65 ms per package), in one unit for every runner. For context, the **measured floors** row gives what the cheapest possible implementation of each step costs on this machine: one `git status -uall` walk (the cost of asking what changed), that walk plus a raw copy of every output file, and the task shells themselves under `xargs -P 10` (which vary by about two seconds between runs). **CPU** is user + system time of the invocation and every child it waited for. The tasks are `sleep`, so this is the runner's own work; a daemon that outlives the invocation (Nx's) is not counted, so Nx's CPU is a floor. > Methodology note: a synthetic graph with `sleep`-based tasks isolates > _runner_ overhead from real compilation. All four runners are > configured **identically** — same commands, the same `src/**` inputs and > `dist/**` outputs, the same concurrency. (Hashing `**/*` instead would > include each task's own output in its inputs and break caching for > everyone.) An earlier run of this shape (June 2026, a 4-core Linux box) > read cold 3m 48s / 8m 18s / 8m 27s and CPU 22.7 s / 1,250 s / 2,038 s; > cold wall time depends on how many cores the runners' overhead competes > with the tasks for, which is why the CPU row is the one that travels. ## Reproducible head-to-head (vx vs Turborepo vs Nx) `packages/vx-bench/compare.ts` scaffolds **one** shared monorepo matching the shape above — `layers` × `perLayer` packages, ~30 deps each, three tasks (`build` + `installDeps` + `test`) with the **identical** shell command, `src/**` inputs, and `dist/**` outputs for every runner — then runs vx, Turbo, and Nx across three cache states, then times one edit to the top package (its `build` and `test` run, every other task hits). Fairness is deliberate: vx runs as the **compiled binary** real users install (not TS source), from a `vx lock` taken once before the reps (`--frozen`, as CI runs it); the workspace is git-committed with `node_modules`/`.turbo`/`.nx` ignored; **every runner is pinned to the same concurrency**; and runners are measured **strictly one at a time**, daemons stopped between them, so they never fight for CPU. `build`/`test` `sleep 1 s` so a warm hit visibly skips the work. ```bash bun packages/vx-bench/compare.ts # 100 layers × 11 (3,270 nodes) — the full shape (slow) bun packages/vx-bench/compare.ts 10 5 1 # 46 packages, 10 layers — quick BASELINE_ONLY=1 bun packages/vx-bench/compare.ts # recompute only the baseline floors against the committed rows (~9 min) bun packages/vx-bench/update-site.ts # rewrite the landing page, the README's bench sentence and chart, and this doc's stress section from results.json (--check to verify) BUILD_SLEEP=0 bun packages/vx-bench/compare.ts 20 11 2 # deep graph, pure framework overhead ``` It writes [`packages/vx-bench/RESULTS.md`](https://github.com/vznjs/vx/blob/main/packages/vx-bench/RESULTS.md) (committed, so the numbers can be referenced from a commit). On the same workspace shape as the committed run, it then prints every re-measured timing more than 10% slower than the committed one. Since 2026-09-03 the table also carries **CPU** columns and a **baseline** row — the theoretical best case (an ideal schedule of the tasks, one git walk, a raw copy of the outputs, the commands under `xargs`), so each runner's row reads as overhead above it. The definitions live in the generated `packages/vx-bench/RESULTS.md`. The 46-package quick run is the head-to-head table above (2026-09-03); an earlier run of that shape used to sit here with a different verdict on the warm row, and one page carrying both was a contradiction, so it is gone. **`vx lock` + `--frozen`** is the headline `vx` row (since 2026-09-29; before, it was its own `vx (frozen)` row): it executes the frozen `vx-lock.json` graph with **zero per-run config evaluation**, and `vx (no lock)` keeps the per-run evaluation's cost in view. Read the difference as a tie, not a win: since the config-evaluation cache (2026-09-02) the plain warm run evaluates nothing either for a config the purity gate can prove pure, and it serves the same validated object from `cache.db` without re-validating it, while `--frozen` parses the whole lock and reads one remembered verdict for it (the lock is hand-editable, so it is a boundary; X-181). What frozen still skips is the per-config identity stat; what it still pays is the lock's own parse. Measured 2026-09-12 on the 1,000-project bench, compiled binary, 12 interleaved reps: plain min 154 / median 177 ms, frozen 148 / 165 — ~5%, the identity stats; the `load configs` stage reads 20–25 ms plain against 6–10 ms of lock read plus 12–14 ms of load frozen. The 83 vs 76 ms above (2026-09-03, median of 1) is the same tie under a laptop's noise. `--frozen` is for what it guarantees — what runs is what was locked, whatever a config would read from the environment — and for configs the gate cannot prove pure, which evaluate live on every plain run and come from the lock under `--frozen`. In your repo: `vx lock`, then commit `vx-lock.json`. ## How the overhead scales with the workspace (2026-09-10) The per-package figure above is one size. This is vx alone at three sizes of the same synthetic shape (`packages/vx-bench/run.ts`, the generator's workspace, one `build` per package whose command is a `mkdir` and a `cp`, so the clock is the runner and almost nothing else), the compiled Linux binary at commit 1a35ec3, median of 3 on a 4-core Intel Xeon container: | Packages | Cold (nothing cached) | per package | Warm, nothing to rebuild | per package | Warm, restore outputs | per package | | -------- | --------------------- | ----------- | ------------------------ | ----------- | --------------------- | ----------- | | 100 | 310 ms | 3.1 ms | 56 ms | 0.56 ms | 126 ms | 1.26 ms | | 300 | 762 ms | 2.5 ms | 100 ms | 0.33 ms | 269 ms | 0.90 ms | | 1,000 | 2,091 ms | 2.1 ms | 178 ms | 0.18 ms | 808 ms | 0.81 ms | Ten times the packages costs 6.7× the cold time and 3.2× the warm time: the per-package cost falls as the fixed cost (process start, the workspace read, the cache open) is spread over more of them, and nothing in the run grows faster than the graph. A cold run at 1,000 packages is two seconds; a warm one is under two hundred milliseconds. These are the runner's own costs on a trivial task; the 3,270-task table at the top, where each task sleeps a second and every runner is scheduled the same way, is where the same shape is compared against Turborepo and Nx. ## Scheduling order (2026-10-09) Which ready task starts first, measured inside vx: the default order (most transitive dependents), the `@vzn/vx-schedule-history` plugin, and three other rules written as measurement-only `schedule` plugins. One cold run per row (`--force`), linux x64, 4 cores, Bun 1.4.2, vx from source at `main` a85223a. Every task is a `sleep`. Each history arm starts from an empty history in its own copy of the workspace. The post: [Which task runs first?](https://vznjs.github.io/vx/blog/scheduling-strategies/). A slow task with nothing after it: one 60 s task, two chains of a 29.75 s parent and a 0.25 s child, 2 workers. | Rule | Wall time | | ----------------------------------------- | --------- | | Ready order | 60.2 s | | Most transitive dependents (vx's default) | 90.0 s | | Critical path by step count | 89.9 s | | Recorded time, run 1 (no history yet) | 90.0 s | Most direct dependents ties and falls back to ready order (60.2 s); recorded time runs 2 and 3 take 60.2 s each. The mirror: 100 independent tasks and a chain A then B, 2 s each, 10 workers. | Rule | Wall time | | ----------------------------------------- | --------- | | Ready order | 24.3 s | | Most transitive dependents (vx's default) | 22.3 s | | Critical path by step count | 22.4 s | Most direct dependents ties and falls back to ready order (24.3 s); recorded time runs 1 to 3 take 22.3 s, 22.2 s and 22.3 s. The headline graph: 1,090 packages, 3,270 tasks, 100 layers, 1 s tasks, 10 workers. The best possible run is 218 s. | Rule | Wall time | | ----------------------------------------- | --------- | | Ready order | 301.7 s | | Most direct dependents | 220.5 s | | Most transitive dependents (vx's default) | 220.4 s | | Critical path by step count | 220.6 s | Recorded time runs 1 to 3 take 220.6 s, 220.5 s and 220.5 s. ## Real repos Public repos, each moved to native vx config with `vx init --native` and timed against the tool it ships with, through `packages/vx-bench/real/nx-repo.sh` (Nx) or `turbo-repo.sh` (Turbo): both tools run the same tasks with the same worker count, three interleaved reps per case, best of three. `restore` wipes the outputs and keeps both caches; `noop` wipes nothing. Nx runs with `NX_DAEMON=false` and no Nx Cloud. ### TanStack/query (2026-10-09) Commit `817bd02`, scope `build` without examples and integrations: 25 tasks each, 5 workers. vx 0.0.633, Nx 23.2.1, pnpm 12.4.2, linux x64, 4 cores. | Case | vx | Nx | vx is | | ----------- | ------ | ------ | ----------- | | Cold build | 34.6 s | 39.4 s | 14% faster | | Restore | 0.39 s | 1.26 s | 3.2× faster | | Nothing new | 0.22 s | 1.43 s | 6.5× faster | Each rep in order: vx cold 40.2 / 35.0 / 34.6 s, Nx cold 41.0 / 39.4 / 39.7 s; vx restore 0.39 / 0.50 / 0.50 s, Nx 1.26 / 1.49 / 1.57 s; vx noop 0.22 / 0.24 / 0.23 s, Nx 1.43 / 1.59 / 1.49 s. "N% faster" means the other tool takes N% longer. ## Performance history Where vx's own headroom went, on the same 1090-package / 3,270-node graph, fully cached (`vx run build test --all`): | Milestone | No-restore | Restore | | ---------------------------------------------------- | ---------- | ------- | | Set-closure scheduler priority (before) | 10.2 s | — | | + bitset scheduler closure | 1.27 s | 1.59 s | | + discovery / package-graph fixes | 1.03 s | 1.34 s | | + frontier `^task` expansion (v19, 8.5× fewer edges) | 0.62 s | 0.87 s | Input hashing then moved to git blob OIDs (v20, `git ls-files -s`): clean files cost zero reads/stats, dropping the warm run-phase from ~245 ms to **~76 ms (3.2×)** at 500 projects × 30 files, and cold runs never read committed file contents at all. The decision history lives in git (the log was retired 2026-09-02); the shipped-optimization catalog with invariants is [`optimizations.md`](../optimizations/), and the engineering tour is [`comparison.md` § Where vx is ahead](../comparison/#where-vx-is-ahead). ## Known headroom Config evaluation was the largest fixed cost of a warm run (`loadProjectConfig` ~199 ms of a ~517 ms warm wall at 1,000 projects, 2026-09-02, before the day's work). The resolved-config evaluation cache that was first rejected here shipped the same day behind the static purity gate it needed (`caching.md` § Config evaluation cache): a config whose import closure is provably pure is served validated from `cache.db`, keyed on the blob id of every file in the closure; anything the gate cannot prove evaluates live. `load configs` is 16–25 ms per 1,000 configs since. `vx run --frozen` is not a faster version of that path (see the head-to-head above, 2026-09-12): it is the env-independent one, and the eval-free one for impure configs. **Source vs binary.** The runner invokes `bun packages/vx/src/bin.ts` by default, which pays ~40 ms of transpile per run that the `--bytecode` release binary does not (2026-09-09: 114 vs 71 ms on a two-package workspace; 20 projects warm 109 vs 64 ms). Set `VX_BIN=` to time the shipped binary instead. --- # Caching > cascade through the dependency graph the same way Turborepo's does. `@vzn/vx`'s cache is content-addressed, opt-in per task, and shaped to cascade through the dependency graph the same way Turborepo's does. This page explains _what's in the cache key_, _what triggers invalidation_, _what's actually stored_, and _why_. ## Why caching is opt-in A task is cached iff its `TaskConfig` provides a `cache` block, with **both** `inputs.files` and `outputs.files`. Omit `cache` and the task always runs; no read, no write. The reasoning: - **Defaulting caching ON with implicit globs leads to silently stale builds.** The first time a user forgets to revisit their config to add an input, the cache returns a hit for an out-of-date snapshot. Stale hits are the worst failure mode of a task runner — they erode trust in the cache itself. - **Forcing declaration makes "what does this task read?" and "what does it produce?" conscious choices.** The user pays a one-time cost (write the globs) for a permanent gain (the cache key actually reflects reality). - **The cost of a forgotten cache miss is small.** A task re-runs. The cost of a stale cache hit is large. Asymmetric risk justifies asymmetric defaults. Turbo defaults caching ON for `outputs: []` tasks; Nx requires you to opt _out_ via `cache: false`. We chose the strictest of the three. ## Cache key derivation The cache key for one task is a **16-hex xxHash3 digest**, seed-chained over (in order): 1. **`CACHE_VERSION`** — the key-derivation sentinel (currently `'vx-cache-v42'`, in `src/cache/key-fold.ts`). Bumped when the key derivation or the artifact container changes, or stored bytes are wrong under an unchanged key. See [§ Bumping CACHE_VERSION](#bumping-cache_version). 2. **`taskId`** — `${projectName}#${taskName}`. Two tasks with identical everything else still produce distinct keys — protects against e.g. `pkg-a#build` accidentally cache-hitting on a `pkg-b#build` entry. 3. **Workspace fingerprint** — xxh3 of every supported workspace marker found at the root (see [`modules/fingerprint.md`](../modules/fingerprint/)): `pnpm-lock.yaml`, `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock`, `bun.lockb`, `pnpm-workspace.yaml`, `.yarnrc.yml` (Yarn 4 catalogs, which `yarn.lock` does not record), `.npmrc` and `bunfig.toml` (install settings no lockfile records), and the content of each patch the Bun lockfile names under patchedDependencies (it records a patch by path alone). Any install-resolved change (a `bun install` that bumps `bun.lock`) or any workspace-shape change invalidates _every_ cache entry. This is the single global "the world changed" lever — and a plugin can take one file off it: `VxPlugin.fingerprint` claims a lockfile, core leaves it out of this digest, and the plugin's `key` hook folds what the file means for each project instead (`@vzn/vx-lockfile`'s `pnpm()` folds the project's own resolved dependency closure and the root package's, so `pnpm update foo` re-keys only the projects that depend on `foo` — every project when the root does, since the root's tools run from the root `node_modules/.bin` on every task's PATH; its `bun()` is the same for `bun.lock`, and this repo declares that one). The config-evaluation cache still keys on every file: a config may import a dependency the lockfile resolved. The fingerprint is read once per run, before any task. A task in the root project may rewrite one of these files — `pnpm install` without `--frozen-lockfile` updates the lockfile and the installed tree — and every key taken before it names the old one. So a task that may (unsandboxed in the root project, or granted a write over one) tells the run when its command ran, the run re-checks the files then (one `stat` each, following a symlink as both reads do — an `lstat` missed a lockfile rewritten through a link, item 760 — the bytes compared for any written since the read), and once one moved nothing keyed on the old digest is probed or saved for the rest of the run, with one status line naming the file. Every reader after such a task is keyed late, not up front. Without it the run after a lockfile rewrite restored a build made against the old install, and a save filed a build against the new install under the old lockfile's key, replayed whenever the tree went back (item 750, [`modules/fingerprint-watch.md`](../modules/fingerprint-watch/)). 4. **Project `package.json` hash** — the git blob OID of the project's `package.json`: the index OID when the file is tracked and clean, else `cache.hashFile`'s blob OID of the worktree bytes (empty when there is none). Folded in implicitly (Turbo / Nx parity). Covers the case where `cache.inputs.files: ['src/**']` is narrow and a `package.json` dep change would otherwise leak undetected. (Added at v12; rationale in [§ History](#history).) 5. **Task config hash** — `xxh3(JSON.stringify(hashableConfig(node.config)))` of the _evaluated_ task config. The projection is one field wide: `exec.remote` is dropped, because it says WHERE a task runs and not what it does (§ What's NOT in the key). Captures: - `exec` block (command, env declarations, timeout, persistent). - `dependsOn` and `cache.inputs.tasks` declarations. - `cache.outputs.files`, `cache.inputs.files`, `cache.inputs.env`, `cache.inputs.runtime` / `workspaceRuntime` declarations (the strings themselves; their resolved file content / env values / command output contribute separately). - `description` (because it's part of the resolved object — even though it has no behavioural effect; a description change isn't a correctness change but the cost of a re-run is low). - **Imported / computed values** — anything a preset or `process.env`-read at config-load time injected. Bun's native `await import()` evaluates the module and bakes those values into the object before we serialize. 6. **`forwardArgs`** — CLI args passed after `--`. Folded into the key so `vx run test -- --watch` doesn't cache-hit a previous `vx run test`. Scoped to the user-requested tasks only — dependsOn- pulled deps don't see them (their cache identity stays clean) — and to a command: a requested default `build` runs none and folds none (X-119). 7. **`cache.inputs.env` resolved values** — `[name, value]` pairs read from host `process.env` at hash time (delimited `name\0value` so boundaries are unambiguous). Listed names get their current values; an unset name folds its bare name, with no `\0`, so unset and set-to-empty are two keys (the child tells them apart: `passThrough` leaves an unset name out). A name holding a NUL is refused at load. 8. **`cache.inputs.runtime` resolved output** — `[command, output]` pairs, where `output` is the trimmed stdout of each command run via `sh -c` in the **project dir** at hash time; a command with stderr (or a NUL in stdout) folds `\0\0`, so bytes moved between the streams move the key. The runtime-output analog of step 7: the command _strings_ are in the resolved config (step 5), their _output_ is resolved live every run. Folded with the command count + each `command\0output` pair. 9. **`cache.inputs.workspaceRuntime` resolved output** — same as step 8 but commands run at the **workspace root**, and the pairs fold into a **distinct namespace** (`ws-runtime-values:`) so an identical `(command, output)` never aliases the project-cwd `runtime` values. 10. **Filtered upstream task cache hashes** — every upstream task's own cache key, filtered by `cache.inputs.tasks` (default: all of them). Sorted by hash before folding so the ordering of `dependsOn` doesn't change the key. This is the cascade mechanism: if anything beneath you changes, your hash changes too. The set is `dependsOn`'s, never the run's selection: a dependency `--exclude-dependencies` keeps from running is still folded, with the key a full run would derive for it (nx#35234), so the flag moves no key. Its outputs are whatever is on disk, which nothing proves current, so a task whose key folds one, and everything built on it, may hit but does not save (`orchestrator/excluded-keys.ts`); a task with `cache.inputs.tasks: []` folds none and saves as usual. 11. **Plugin key material** — the `{ name: value }` pairs a plugin's `key(task, ctx)` stage returned for this task, stored on the node as sorted `plugin/name` pairs and folded after the upstream keys and BEFORE the input files, ONLY when non-empty — so a workspace with no `key` plugin derives byte-identical keys to one before the stage existed (no `CACHE_VERSION` bump when it shipped). `vx why` names a changed pair as `plugin /`. Two plugins of one package (a plugin's name is its package's) that return one name are told apart as ``, `#2`, … in value order, so declaration order still keys nothing (item 1028). 12. **Input files' content hashes** — `cache.inputs.files` resolved to a concrete list of project-relative paths (gitignore-aware, declared-outputs-excluded, nested-projects-excluded), each file contributing its **git blob OID** (v20). On a clean tree the OID comes straight from the index — the run's up-front enumeration is three concurrent spawns, `git ls-files -s -v -z` (every tracked path, its OID and its cache-state flag), `git status --porcelain -z -uall --ignored=matching --no-renames` (dirty tracked paths, the untracked files and the ignored ones) and `git var -l` (the clean-filter gate's config) — so deriving these hashes costs zero file reads and zero per-file stats. Each trusted OID's blob must be the size the index recorded for the file (A-60): `git add` under a clean filter (`core.autocrlf=true`, a `text` rule) stores the LF blob of a CRLF file, and once the filter is gone git holds that stat-clean entry clean without re-reading it, so status and the filter gate (today's config) both let the LF blob key the CRLF bytes. Which paths an index distrusts is a function of its entries, so the verdict is kept in `blob_verdicts` by a hash of the index file and the pathspecs: a warm run reads the file and one row (the `--debug` listing and a lookup per entry cost 550 ms at 100,000 files). The key stands only for an index written before the run began and still in place after its listing, so a `git add` between the two cannot pair one index's verdict with the other's entries; any other run checks every blob. A changed index spawns `git ls-files -s -v -z --debug` for the recorded sizes, takes each blob's size from `blob_sizes` (fixed for its OID), and asks one `git cat-file --batch-check` for the ones not yet known (65 ms over 3,000 loose objects, 10 ms packed). The check is by size, so a filter that keeps the size (a `filter` driver such as `tr a-z A-Z`), removed after an add, still leaves a blob that stands for other bytes: probed, the run hit the build of the filtered bytes. Only a read of every trusted file would catch it, the cost the index OIDs exist to avoid. After changing a filter, `git add --renormalize .` makes the index describe the worktree again. A re-listing mid-run, or a nested repository's project, spawns `git ls-files -s --others --exclude-standard -z .` in the project dir instead, and its OIDs are not trusted: those files hash by content. `--no-renames`, because status pairs a deleted file with a similar unmerged path (a conflict mid-resolve) as its rename source and prints only `UU `: the deletion went unsaid, the file kept its index OID, and the run hit the output built with it (A-59). Ref storage is not a key input: every ref vx reads comes from a git command, so a repository in reftable storage (git 2.45) keys every task as its files-backend twin (`tests/git-reftable.unsafe.test.ts`). A **submodule or an embedded repository** is enumerated by its own git: the workspace repository lists the nested one as a single entry (a gitlink, or `dir/` when untracked) and none of its files, so vx replaces that entry with the files `git ls-files` lists inside it (one spawn per nested repository per run, nested ones recursively), and those files hash by content rather than by index OID. That holds for a nested repository inside a project (`vendor/lib` under a `**` glob — until 2026-09-27 its files never reached the key, and an edit there was a hit on the old output), for a project inside one, and for a `workspaceFiles` glob. A gitlink whose directory has no `.git` (a submodule never initialised, or one whose `.git` was removed to vendor its files, the gitlink left in the index) has no repository to ask and `git status` says nothing of it: its files are listed by a walk and hash by content (A-61), so an empty one folds nothing. `--affected` follows the same shape — git reports the nested repository as one changed path, and every project under it is selected. A task with **no `cache` block** derives a key too — its dependents fold it (step 10) — and, declaring nothing, folds **every file in its project**: `**/*`, gitignore-aware, untracked files included, and its own outputs not excluded, since it declared none. So its key moves whenever it writes a file git does not ignore: a cached dependent misses once more after the upstream's first run and hits from the third. `vx why` on the dependent names the upstream as the moved component; on the upstream it says the file set moved and that no fingerprints say which. Ignore the outputs in git, or declare the block (`tests/uncached-upstream-key.test.ts` pins both arms). Declaring nothing, such a task may also have **written** anywhere in its project, so what the run learned about that project at its start — the git listing, the index OIDs, the `package.json` digest — is dropped once its command exits, pass or fail, and a later key re-lists and hashes by content. For the same reason a task after it in the same project (`dependsOn`, even with `tasks: []`) is never keyed up front by the local short-circuit or the remote prefetch. Without both, an uncached `gen` that copies a seed into a `build`'s declared `config.json` replayed seed B's build under seed A (turborepo#13788, item 743). A sandbox narrows the reach to its write grants: none reaches nothing, and a grant elsewhere in the workspace reaches every project. An unsandboxed write into another project crosses a project boundary and is not tracked, as for a cached task's undeclared write (`tests/undeclared-writes.test.ts`). A task **with** a `cache` block may still rewrite its own inputs in place — a formatter declaring `outputs: []` — and a same-project reader after it whose key does not fold its key (`tasks: []`, or a filter that leaves it out, on every path) is not keyed up front either: its key would be taken over the bytes before the rewrite, and seeds A,B,B,A replayed B on the fourth run (item 750). A reader that folds the rewriter's key keeps its up-front probe — that key names the rewriter's inputs, which are what it may rewrite. A cached task writing a file that is neither a declared output nor its own input is out of contract: a hit restores only what it declared. A file whose name is **not valid UTF-8** (Linux allows any byte but `/` and NUL) cannot be opened from a string, so it cannot be hashed: a task whose globs select one is refused by name, and so is a task whose declared outputs hold one, rather than the file dropping out of the key or the artifact without a word (turborepo#9345). Rename it, or exclude it with a negated glob. Your globs are a **filter over the set git reports**, so a filter can only ever remove — a gitignored file can never be filtered back in, however explicitly you name it. Naming one by hand is therefore a **hard error** rather than a silent nothing: it would leave the task ignoring a file its own config claims as an input, reporting `up-to-date` while that file changed. Turbo lets an explicit entry override gitignore; vx cannot, because the key would then depend on a change `git diff` cannot see and `--affected` would stop selecting the task. If the file is generated, depend on the task that produces it via `cache.inputs.tasks`. A **glob** matching nothing stays silent — that is legitimate — and so does a literal naming a file that does not exist. A literal naming a **directory** is judged the same way: an ignored one, or an empty one, has no file git lists under it, folds nothing, and is refused (item 576). A **bracket is literal** in a task glob, and there are no character classes (item 667): `app/[id]/**` folds the route directory `app/[id]`, and `app/\[id\]/**` is the same path. Read as a class — what `Bun.Glob`, Turbo and Nx do — it matched `app/i/…` and never the route, so the route's files keyed nothing (an edit replayed the old output, green) and an output declared under it cleaned an unrelated `app/i/page.js` before every run while the artifact saved nothing. An index OID is only trusted where git stores the worktree bytes **verbatim**, so the enumeration prunes it three ways: `git status --porcelain` drops paths whose working tree diverges; the `-v` flag on the same `ls-files` spawn drops `skip-worktree` / `assume-unchanged` entries, whose OID says nothing about what is (or isn't) on disk; and, once those spawns return, a clean-filter gate (a `git check-attr` spawn only when the gate needs one) drops paths where `text` / `eol` / `ident`, a `filter` driver, `working-tree-encoding` or `core.autocrlf` can rewrite bytes between index and worktree (the blob would be the LF-normalized form while the task reads the CRLF file). The gate costs nothing in a repo with no attributes and no `core.autocrlf` — see "Clean filters" below. And when the repository's config weakens the stat `git status` judges by — `core.trustctime=false`, or `core.checkStat=minimal` (whole-second mtime and size) — no index OID is trusted at all: a same-size rewrite that keeps its mtime (`cp -p`, `tar -x`) reads clean to git, and until 2026-09-27 (A-6) kept the old bytes' key. Every file is then hashed through the memo below, which keys on ctime and inode. Every pruned path, plus untracked files, falls back to an in-process `HASH("blob " + len + "\0" + content)` over the **worktree bytes** (sha1, or sha256 in `--object-format=sha256` repos), memoized in `file_hashes` on `(mtime, size, ctime, ino)`. A file changed within `FILE_HASH_RACY_MS` (50 ms) of its stat is hashed but not memoised; a ctime with no sub-second part, as a file system that keeps whole seconds writes it (ext3, HFS+, FAT/exFAT, some NFS), widens that window by a second, and an even second by two, since FAT32 keeps even seconds; a rewrite later in the same stamp keeps it (`racyWindowMs`; until 2026-09-27, A-2 and A-38, such a rewrite of the same size was a hit on the first bytes' output). That is the same value the index holds whenever no filter applies, so a file's contribution doesn't flip across dirty↔clean transitions. Folded as `(relPath, identity)` pairs, sorted for stability across OSes and walk orders. The identity is the OID prefixed by the file's git mode unless that mode is a plain file's: `100755:` for an executable, `120000:` for a symlink, the bare OID for 100644. The index gives the mode for a clean file, and the stat gives it for any other file (owner execute bit). A blob OID holds no mode, so until item 887 a `chmod +x`, or a symlink swapped for a file holding its target string, kept the key that `git status` and `--affected` saw move. Under `core.fileMode=false` (WSL's DrvFs) git reports no chmod, so there a clean file's mode comes from an lstat too, and its OID from the index (item 1076). A **symlink** folds as git folds it: the blob of its target _string_ (its mode-120000 index OID), whether it points at a file, a directory, or nothing. Retargeting a link changes the key; the bytes behind a link to a directory, or to a file outside the project, do not — `git diff` and `--affected` cannot see them either, so declare them with `workspaceFiles`. A link to a file inside the project tracks that file's content only when the file matches the task's input globs too: it is then an input of its own. A link under `src/**` to `other/data.txt` folds the link, not `other/data.txt`; name the target in the globs to track it. (Before 2026-09-09 a file link folded the bytes behind it and a directory or dangling link fell out of the input set entirely, so retargeting one was a stale hit.) The composition is seed-chained (`xxh3(part, prevDigest)`) with a label prefix per field, so two different field layouts can't collide. Bun's xxHash3 reads only the low 32 bits of a seed, so `xxh3` feeds the seed forward (`xxHash3(part, seed) ^ seed`) and the chain carries all 64 bits of state from step to step (item 682; `tests/hash-chain.test.ts`). xxHash3 is non-cryptographic by design — cache keys need uniqueness across honest inputs, not adversarial collision resistance. ## Cache lookup → restore On a hit: 1. `cache.get(hash)` is **pure SQL**: the `entries` row carries the metadata and the captured stdout; the `output_files` rows carry the output fingerprints. The tar artifact is not touched by the probe (its existence is verified with one stat), so hit cost doesn't scale with artifact size. 2. If the on-disk output tree already matches the cached snapshot (size + mode + **millisecond**-mtime check against `output_files`), extraction is skipped entirely — the outcome reports **up-to-date** (`restored: false`). Mode and millisecond mtime ride the artifact's own `.vx-meta.json` sidecar — recorded from a stat while packing — so the index rows and the restored tree carry the identical values whether the entry was saved locally or ingested from a remote, and the comparison is exact in steady state. The check also requires the file's inode and ctime to equal the ones this machine recorded after the save or restore that last wrote it (`recordOutputStamps`). Size, mode and mtime alone cannot tell two entries apart when their outputs carry one fixed mtime (`tar -x`, `cp -p`, `SOURCE_DATE_EPOCH`) and one size: a v1 → v2 → v1 round trip reported up-to-date with v2's bytes on disk (item 886). No task can set a ctime, and a forged mtime (`touch -r`) moves the ctime too. The inode proves less than it seems: a restore unlinks the file and renames a new one in, and ext4 hands the new one the freed inode (measured, item 941), so ctime is the guard. A row with no stamp (an ingest from a remote, or a file that had changed when the stamp was taken) is never current: the hit restores and stamps. The residual: ctime ticks on the kernel's coarse clock (4 ms at HZ=250), so a same-size rewrite inside the tick of vx's own write, in place or as a new file that took the same inode, with its mtime forged back, still matches. 3. Otherwise the task's declared outputs are wiped from the project dir (`cleanOutputs`) — see [§ Strict output ownership](#strict-output-ownership) — and the `outputs/` (+ `workspace-outputs/`) entries are extracted from the artifact into place: its bytes inline in the index, read in one read transaction with the entry's rows, or `/.tar.zst`. 4. Captured stdout is replayed to the live terminal via the logger — the framed block looks like a fresh run. (stderr is not cached — only successful runs are cached and their stderr is near-always empty; live runs still stream stderr normally.) 5. The task is marked `cache-hit` (or `cache-hit-remote` when the `LayeredCache` hydrated from the remote layer this run); `durationMs` is the wallclock for the restore op, not the original exec time. The cached `exitCode` is preserved. A cached non-zero exit is impossible by construction — see [§ Cache write](#cache-write) — and "by construction" is literal: neither `save` nor `ingest` accepts an `exitCode` at all, so the stored value is pinned to `0` rather than supplied. The restore path still checks it, because the column outlives this process: a row from a hand-edited or foreign `cache.db` with a non-zero exit classifies the hit `failed` instead of restoring a broken build's outputs under a green run. ### Local restore tier (two-tier scheduler) `dependsOn` is an ordering gate, so a dependent normally can't even probe the cache until its upstream finishes running. But a **stable-key** task's key is provably independent of any upstream's _outputs_ — its hit/miss status is knowable up front, and restoring it needs none of its deps' output. So on local-only runs, `run()` performs an up-front CLASSIFY (`orchestrator/local-shortcircuit.ts`): 1. Derive every stable, cacheable, local-read task's key (reusing the run's `hashCache` memo) and probe local `cache.get` **once**, building a `preProbed` map covering stable hits AND stable misses. 2. Confirmed hits become the **restore tier**: the scheduler makes them ready immediately (no dep gate, and no failed-dep→skip check — their key is dep-success-independent) but at LOW priority, so cache **misses own the worker pool and restores only backfill idle capacity**. 3. `execute-task` consumes `preProbed`, so the up-front probes ARE the probes it would have done, hoisted — no double work, and every task still flows through `execute()` so output is unchanged. Stability gate (shared with remote prefetch via `stable-keys.ts:deriveStableKeys`): a task whose input globs could match a same-project upstream's declared `outputs.files` (compared by literal prefix, less what its own `!` inputs take back whole; one with undeclared writes reaches every input), whose own `outputs.files` meet another same-project task's (it would restore into that tree beside the producer's restore), or whose `inputs.workspaceFiles` could reach any upstream's outputs, has a _preliminary_ key and stays exec-tier / dep-gated. **Any** `outputs.workspaceFiles` producer upstream also makes a dependent's key preliminary, whatever that dependent reads: a root-anchored output is boundary-ignoring by design, so it can land inside the dependent's own project dir, where an ordinary project-relative glob reads it. Such a dependent is therefore in NEITHER tier — it is excluded from probe reuse as well as from the restore tier, because `execute-task` reuses a `preProbed` hash verbatim, so probe reuse is itself a stale-hit path when the key is preliminary. On top of that, an `outputs.workspaceFiles` declaration keeps every task whose project directory its static prefix can reach out of the restore tier — edge or no edge, because a root-anchored output can land in any project's directory — and every transitive dependant of one with it (their up-front keys fold a preliminary key); a glob with no literal prefix reaches every project. Before item 584 one such declaration emptied the tier graph-wide. `rules.upfrontKeys` (on by default, X-54) refuses at load a task whose input globs can match another task's declared outputs, so the clauses on declared outputs fire only with the rule off; undeclared writes, an `outputs.workspaceFiles` producer upstream, a rewriter the key does not fold, and runtime probes after a writer still leave a key preliminary. The short-circuit never runs under a `LayeredCache` (remote prefetch owns those runs — an up-front `get` there would put remote GETs on the critical path), never fires with local reads off, and never throws — any error degrades to the normal lazy schedule. Measured: −6.6% on a mixed slow-upstream/warm-downstream workload; parity on all-hit warm runs. ### Remote prefetch (async, remote-only) When a run is backed by a remote cache — a plugin's `cache` capability or an injected `RunOptions.remoteCache` layer — the network latency of every remote GET would otherwise sit on the critical path of the task that needs it. So before execution starts, `run()` kicks off **background prefetches**: 1. Every cacheable task's key is derived once, up front, in topological order (reusing the run's `hashCache` memo, so `execute-task`'s later `computeTaskHash` for the same task hits the memo — no double hashing). This derivation touches **no cache layer** — keys only. 2. Each **stable-key** task's remote GET is fired concurrently under a bounded pool (the run's concurrency). The prefetch ingests a hit into the _local_ cache; misses/errors degrade to `false`. 3. Execution starts immediately — the prefetches race alongside it, so remote latency overlaps real work instead of blocking it. 4. When `execute-task` later calls `cache.get(hash)`, the `LayeredCache` **awaits the already-in-flight (resolved-or-pending) prefetch** for that key rather than starting a fresh round-trip: **at most ONE remote GET per key**, whether it was served by the prefetch, the lazy read-through, or both. ## A current tree: when a warm hit restores nothing A hit whose outputs are already on disk should cost a few stats, not an extraction. Two checks decide, both against rows the cache recorded when the tree last matched the entry (`output_files`, and since 2026-09-03 `output_dirs`): 1. **The set.** The files under the declared output globs must be exactly the entry's files — no strays, nothing missing — because a restore wipes and rewrites the declared outputs, and a stray left in place would be a stale file the hit silently kept. Until Wave 6 this was a glob walk on every hit (0.36 ms each; 365 ms of CPU on a warm 1000-project run). Now, for globs of the shape `/**` or a bare literal `` (a whole subtree — `wholeSubtreePrefixes`; a literal that names a file is recorded as a file, `mtime_ms` -2, and holds while a regular file stands there, its bytes being the per-file check's), the cache records every directory under `` with its mtime after each save and restore; on the next hit, unchanged mtimes on all of them prove the set unchanged, since a file added or removed anywhere the glob could see bumps its parent directory, and a new directory bumps the recorded directory that contains it. The rows are taken per task and written together — one transaction at the first read, at prune, at stats or at close, since item 622; a thousand commits at run end were the whole snapshot stage before — and a reader in the same process flushes them first. A save's or restore's snapshot is taken at run end, once the directories are past the racy window, and by then a later task or another process may have written into them: the walk collects the files it passes and records nothing unless they are the entry's rows (item 1087). Any other glob shape (`**/*.js`, `dist/*`), a missing row set (a remote ingest records none), a moved directory, or more than 8,192 directories (`OUTPUT_DIRS_CAP`) keeps the walk — and a walk that proves the tree current records the directories so the following hit can skip it. 2. **The files.** Every recorded file's `(size, mode, mtime-ms)` must match. This never left: the directory rule only replaces the enumeration, not the fingerprint. The trade is the one every mtime-based skip accepts, already documented for files: a deliberately forged directory mtime (`touch -r`) hides a stray. Both directions are pinned in `tests/output-dirs.test.ts`. Hard invariants of the remote prefetch: - **Remote-only.** This entire path is gated on a `LayeredCache` being configured. A local-only run never prefetches; its up-front keys and local probes are the short-circuit's (§ Local restore tier). The prefetch never adds an upfront _local_ `get` / `isOutputsCurrent` / stat pass. - **Stable keys only.** A task with a _preliminary_ key (§ Local restore tier's stability gate: undeclared writes, an `outputs.workspaceFiles` producer upstream, or, with `rules.upfrontKeys: false`, input globs that match an upstream's declared outputs) is not prefetched: its key would name the wrong artifact, and it resolves via the lazy read-through in `execute-task`. Instability propagates: a task that folds an unstable upstream is itself unstable. When in doubt, skip. - **At most once.** The `LayeredCache` keeps an in-flight map keyed by hash; `prefetch` and `get` share it, and a settled `false` (remote miss) prevents a second lazy probe of the same dead key. - **Provenance preserved.** A hash pulled from remote — even when a later `get` finds it as a now-local hit — still reports `source: 'remote'`, so the outcome is `cache-hit-remote`. - **A remote-read-off policy** (`--no-cache`, `--force`, or `--cache=remote:`) fires no prefetch. - **Never fail.** Every remote path (get / put / ingest / prefetch) catches all errors and degrades to a cache miss. A remote 500, a network drop, a corrupt artifact, or a failed integrity check can never fail a run. - **Said once, and said whole.** The warning names the operation (`probe`, `download`, `upload`), the artifact and the layer's `endpoint`, with a URL's credentials, query and fragment dropped: `upload to failed: HTTP 413`. One failure class (the error's `code` when it has one, else its message with the hash taken out) is said once per run, and the repeats are counted at close: `2 more requests failed the same way: `. ### Remote uploads (background, drained at run end) Writes go to the local cache synchronously; the **remote PUT is a fire-and-forget background upload**. The task's outcome (and its dependents) never wait on upload latency — the uploads race alongside the rest of the run and are drained before `cache.close()`, so a short run still ships every artifact before the process exits. Upload failures log via `onRemoteError` and are otherwise ignored (the task already succeeded; the only loss is the remote entry). An upload is the outputs as the task wrote them; secret masking does not reach file contents, so a task whose outputs embed a secret is one to leave uncached ([security](../security/)). ### Planning probes (`--dry` / `--graph`) The planning paths (`vx run --dry`, `--graph`) predict hits without side effects: against a remote cache they use a **lightweight existence probe** — no artifact download, no local ingest. A predicted `hit-remote` means the artifact exists remotely; the bytes move only when a real run needs them. Locally the probe reads whether the entry's row is there and stats the artifact, never the row itself: the whole row, its stored stdout included, made a 200-task plan over 1 MB outputs 230 ms against 28 (min of 11, 2026-10-02). ## Cache policy (read/write axes) Caching is controlled by a four-axis `CachePolicy` — **localRead**, **localWrite**, **remoteRead**, **remoteWrite** — independent toggles, each enforced inside the matching cache layer at construction time. The local `Cache` gets a `{ read, write }` slice gating its task artifact get/save, the config-evaluation cache's reads and writes, and the file-hash memo's writes (never `recordRun` / `stats` / `prune` / ingest / key derivation); the `LayeredCache` additionally gates its own remote read-through (`remoteRead`), upload (`remoteWrite`), and prefetch (`remoteRead`). The orchestrator derives two booleans per task: - `willRead = task has a cache block AND it is not remote-only AND (localRead || remoteRead)` - `willWrite = task has a cache block AND it is not remote-only AND (localWrite || remoteWrite)` A task reads the cache only when `willRead`, saves only when `willWrite`, and cleans its declared outputs before exec only when `willWrite`. Remote-only is an `exec.remote: 'only'` task placed on a remote executor: it never touches this machine's disk (no probe, no restore, no output clean, no local save), and its result lives in the remote executor's own store. The CLI maps three flags to a policy (precedence: start all-on → apply `--cache` → `--no-cache` forces all off → `--force` forces both reads off): `--no-cache` = everything off (no read, no write, no output clean); `--force` = reads off / writes on (re-execute and refresh the cache, outputs cleaned); `--cache=` = explicit per-layer control. See `docs/cli.md` § Cache control. One subtlety: when `localWrite` is off but `remoteWrite` is on (`--cache=local:,remote:rw`), there's no on-disk artifact for the `LayeredCache` to read before uploading — so it packs the tar.zst bytes in memory (`Cache.packArtifactBytes`) and uploads those. ## Cache write A miss runs the task. If the final exit code is `0` and the task's `willWrite` is true (it has a `cache` block AND at least one write axis is on): 1. `cache.outputs.files` (and `outputs.workspaceFiles`) are resolved against the project dir / workspace root. 2. The per-component input fingerprint is the one captured before the command ran: on a miss `describeTaskInputs` fills `captureInto` once (the same call yields the key re-checked below), so no second `computeTaskHash` runs. 3. The artifact — one `stdout` entry (bounded: the first and last 8 Mi characters of the task's output, the dropped middle named where it was), the `outputs/` (+ `workspace-outputs/`) entries, the `.vx-meta.json` sidecar and the `.vx-sum` checksum — is packed in-process (no staging dir, no subprocess) into a single `.tar.zst` and validated. One of at most 32 KiB compressed (`INLINE_MAX`, almost every artifact: a one-file `dist/` is a few hundred bytes) stays in memory; a larger one is written to a temp name. 4. One `BEGIN IMMEDIATE` SQLite transaction upserts a small artifact's bytes into `artifacts` (v33), or renames a large one into place and deletes the key's inline bytes, and upserts the `entries` row (taskId, command, exit code, duration, size, stdout, timestamps), the `output_files` fingerprint rows, and the entry's one `entry_inputs` row (`INSERT OR IGNORE`). Concurrent readers see either no entry or a complete entry — never a partial one — and bytes and rows go live together: the rename waits for the write lock, so no other writer's rows land beside it, and a commit that fails takes the artifact back out (the key then misses). Until 2026-09-27 (A-3) the rename came first, and a commit refused past the busy timeout left the new bytes beside the old rows: every later hit on the key failed the task as a corrupt artifact. A re-save (`--force`) first moves the previous artifact aside under a temp name and unlinks it after the commit: ext4 flushes the incoming file when a rename replaces one, 0.55 ms a save on the main thread against 0.04 (a forced 1,000-task run 4.01 s → 3.46 s, 2026-10-02). A reader probing between the two renames misses. An inline save moves aside whatever file its key has, the same way (a rename that finds nothing on a first save, in place of a statement asking the index). As of every commit a key's bytes are in one place, and a reader takes the inline bytes first, so a file an older vx wrote beside them is never read and the sweep takes it. An inline save writes no file at all: no temp, no rename, and a crash leaves nothing behind (the bytes and the rows are one transaction in one database file). **The key is re-checked before the save** (item 743). It was taken before the command ran — at the task's start, or up front by the local short-circuit — and the save files the outputs under it, so the inputs must still be what it describes. Two checks, in order. The key re-derived just before the command (`describeTaskInputs`, which the executor seam needs anyway) must equal it. Then each input file and the project's `package.json` is `lstat`ed once: a file whose ctime falls after, or within `FILE_HASH_RACY_MS` before, the moment its digest was learned — the enumeration's start for an index OID, the describe for a hashed file — is hashed again and compared, and a file that is gone has moved. A file written at or after the describe (just before the command) has moved whatever it holds (a whole-second stamp counts as any moment of its second, an even one of two, in both checks): an input changed and changed BACK while the command ran matched its digest again, and its output, built from the edit, was filed under the key and restored over the original (item 1015). When one moved, the task's result stands but no entry is saved, one status line names the file (``[vx] app#format: `packages/app/a.ts` changed after its key was taken — …``), and the run forgets what it knew about the project, as after an uncached task (§ Cache key derivation, step 12). This covers a formatter rewriting its own input (turborepo#10111) and a user's edit mid-run (turborepo#1146). A task left remote (`--download=none`) is held to the same checks: a moved key still lets a local consumer fetch its outputs, but the fetch saves no entry; until X-123 it saved them under the old key. A task that rewrites its own input to the SAME bytes (`sed -i` always writes) is not saved either: its write cannot be told from an edit reverted mid-run, so it pays a re-run each time rather than risk a stale entry; declare what it writes as an output, which the status line says when it names a file other than `package.json` (TanStack Router's committed `routeTree.gen.ts`, rewritten by every build). A file ADDED under an input glob since the listing the key filtered (`addedInput`) withholds the save the same way, named on the same line: the listing's directories a glob reaches are `lstat`ed, one whose ctime moved since the listing is read, and a new name the declaration matches (or an unlisted directory) is asked of git, which alone knows what is ignored; a literal input absent then and present now is asked too. Not seen: a file added inside a directory that held no listed file before (only ignored ones, or none) and was not itself created mid-run. Cost: one `lstat` per input and per reached directory on a miss that saves, a `readdir` per directory that changed, and a `git ls-files` only when a candidate turned up; a hit runs no command and checks nothing. A file listed but gone before the key hashes it (an upstream that deletes a file its dependant's globs match) is keyed as absent, not read: the dependant failed as `internal error … ENOENT` on every run until A-55. Absent stays unmoved while it stays gone. Its dependants whose keys fold its key save nothing either, transitively: that key does not name the bytes they built from (`[vx] app#use: ran over app#gen's outputs, which its key no longer describes — …`). Until 2026-09-27 (A-12) they saved, and once the input was put back they hit the edit's output. A workspace fingerprint a task rewrote since the run read it (§ Cache key derivation, step 3) withholds the save the same way, and no key taken before it is probed or restored: a hit the up-front probe found goes back to the scheduler and runs once its deps are done. Until X-124 that hit restored the old install's bytes. A miss that ran here and saves **nothing** — it failed, the cache policy writes nothing (`--cache=local:r,remote:r`), an upstream failed under `--continue` — runs the same input re-check and, when an input moved, forgets the project as above; otherwise its declared outputs are resolved and marked in the git snapshot exactly as a save marks them. Before item 750 only a save marked them, so a same-run reader keyed from the snapshot's index OID for an output (or for an input the task rewrote) restored the bytes from before the command. A declared set that resolves to **nothing** is said on the run's status line. `cache.inputs matched no files (lib/**)` — the key would not change when the source does — is said on every miss of a cacheable task, right after `describeTaskInputs` and before the command, whether or not it saves. `cache.outputs matched no files (build/**)` — an empty artifact was saved and a later hit restores nothing — is said on the save path. Both are almost always a glob against the wrong directory; the output line names one other cause when it applies: a sandboxed task with no `exec.sandbox.allow.write`, whose writes never reached disk, or a `workspaceFiles` directory that is a symlink out of the workspace, whose files vx drops as outside (a project output directory linked out of the project refuses the task instead, X-88). `outputs.files: []` is a deliberate cached no-op and says nothing; a task with no `cache` block is never checked. **The outputs are what exists when the task's command exits.** The run waits for the command's own process, not for everything it started — a backgrounded grandchild that still holds the output pipe does not hold the run (`tests/runner.test.ts`) — and the save resolves the output globs at once. A descendant the command detached (`setsid … &`, a daemon) that writes after that is not in the artifact: the entry saves what was there, often nothing (with the `cache.outputs matched no files` line), and the next hit's clean-before-restore removes what the descendant wrote and restores the saved entry in its place (upstream survey, turborepo#12786). vx does not wait for it and cannot see it: a process that left the task's session is outside the process group vx tracks and signals ([kill-tree](../modules/kill-tree/)). So a command finishes writing its outputs before it exits — `wait` for what it backgrounds, or do not detach it. If the task exits non-zero, **nothing is cached.** This is deliberate: - Caching a failure prevents retry flows. The next run gets the same failure even after the user fixes the underlying cause (the inputs haven't changed, so the cache key matches). - Failures should be transient by default — flaky tests, network blips, transient resource exhaustion shouldn't bake into the cache. Failed-task stdout / stderr still reach the user via the live stream and the framed failure block replayed at run end. The `runs` table records the failure (status + exit code) for analytics. ## Strict output ownership Declared `cache.outputs.files` are wiped in two distinct places: - **Before exec on a cache miss.** A leftover `dist/old.js` from a prior build can't survive into a fresh build that doesn't rewrite it. - **Before restore on a cache hit.** The post-restore tree is the cached snapshot byte-for-byte. Hand-edits to output files don't persist through a cache replay. When the hit's own check walked the output globs to find the tree stale, the clean deletes from that walk's list instead of walking again: nothing is awaited between them (X-161). Both branches use the same `cleanOutputs` helper (`src/cache/inputs.ts`) with the same boundary rules, and two directories stay off the wipe whatever the glob: `.git` and `.vx` (`OUTPUT_NEVER`). `node_modules` is off it too unless a glob names it (`node_modules/**`, an install task's legitimate output); until 2026-09-27 (A-13) `**/*.js` cleaned every installed `.js`, and a `workspaceFiles` output glob reached even `.git`. A task that fails while a git-tracked file another task's clean removed is still missing gets one line naming the file and that task: vx cleans before a run where Turbo does not, so a reader with no edge to the producer of a committed output fails naming only the file (A-48, vueuse's `metadata/index.json`). Workspace outputs take the same rules, and a path an output `!` entry takes back (A-44) is off the wipe, the artifact and the restore alike. Skipped when: - `cache.outputs.files` is empty (nothing declared as output). - The task's `willWrite` is false — no write axis is enabled (e.g. `--no-cache`, or a read-only `--cache=local:r`). The user is debugging and managing the tree, so vx leaves it alone. `--force` keeps writes on, so it DOES clean (the saved snapshot must be clean). A declared output the process cannot remove (a `dist/` another user wrote, a read-only checkout) fails the task with `cannot remove declared output <path>: EACCES — …`, and a restore that cannot write into such a directory with `restore of <hash> into <dir> could not write its outputs (EACCES: …)`: the environment's failure, reported plainly, never as an internal error or a corrupt artifact. Why so strict? Turbo and Nx restore additively — files from a prior state can survive. We've seen this cause: - Wrong test runs (a deleted-but-resurrected snapshot file from a cache miss survives a hit and now your test passes against the wrong baseline). - Wrong shipped artifacts (a deleted source-mapped file from a prior build sits in `dist/` alongside the new bundle). The strict-ownership behavior makes the project dir post-run a pure function of the cache key. ### Additive outputs: two tasks, one tree, an edge between them Two cached tasks whose declared outputs overlap are refused at graph build. By default that holds even when one depends on the other: the workspace rule `rules.exclusiveOutputs` (on unless set to `false` in `vx.workspace.ts`, X-53) keeps one path to one task, and its refusal says how to turn it off. The shape below is correct, only slower: a stamp before every run, a diff after, and a clean by rows. Give each task its own output path where you can (`dist-individual` beside `dist`). With the rule off, an edge fixes the order, and the dependant is **additive** (item 588): twenty's `build` fills `dist` and `build:individual` depends on it and writes `dist/individual`; strapi's `build:types` runs `tsc` into the same `dist` as `build`. For the dependant: - its **own output set** is what its run added or changed under its declared outputs — the outputs are stamped (size, mtime, inode, ctime) before the run and diffed after, the same proof a hit's "already current" check trusts — and only that set is saved; `outputs.workspaceFiles` are stamped the same way (until A-43 its miss cleaned them by glob, deleting a same-tree upstream's root-anchored files before it read them); - a run that **removes** a file it found (a bundler deleting the upstream's intermediate) saves nothing: an artifact holds what a run wrote, never what it took away, and the upstream's restore puts the file back, so the dependant runs again on every warm run (X-32); - it **cleans by recorded rows**, never by glob, before a run (nothing: stale files of its own are its command's to clean, as under Turbo) and before a restore (its rows only, pruning emptied directories only inside its declared trees, so a sibling's fresh directory stays); - its "already current" check requires its rows present and current and ignores everything else under the glob; - it is **never restore-tier**: it restores or runs after its upstream, by the edge. The upstream keeps strict ownership of its glob — a miss or a restore wipes the whole tree, the dependant's additions included, and the dependant restores or runs after — and its "already current" check ignores strays a dependant's glob could have added, so a warm run stays a no-op for both. A file the dependant rewrites in place (refine's `types` regenerating `build`'s `.d.ts`) counts as the dependant's own (its ctime moved, even where a tool stamps the size and mtime back; X-33), which is correct and costs the upstream a restore on the next warm run; the design note keeps that shape out of scope. ## Invalidation paths A task's cache becomes invalid when any of these change: | Trigger | Mechanism | | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Edit a file in the task's `inputs.files` set | step 12 of key derivation | | Edit a file in the task's `inputs.workspaceFiles` set (root-anchored; may live in ANY project's dir — the documented boundary exception) | step 12 — resolved workspace files join the same input-file list | | Any package manager updates a lockfile (`pnpm`, `npm`, `yarn`, `bun`) | step 3 (workspace fingerprint) — or, for a lockfile a plugin claims, that plugin's `key` material (`@vzn/vx-lockfile`: only the projects whose dependency closure moved) | | Edit `pnpm-workspace.yaml` | step 3 | | Edit `package.json`'s `workspaces` field | not step 3 — membership: a project that joins or leaves changes which projects exist and which nested dirs its parent's globs exclude (step 12); the file itself is hashed per project (step 4) | | Edit the project's `package.json` (dep / version / scripts change) | step 4 (project package.json hash) | | Edit the task's `vx.config.ts` | step 5 (task config hash) | | Edit a config file that the task config imports | step 5 (configHash sees the resolved object after Bun evaluates imports) | | Change CLI `forwardArgs` (after `--`) | step 6 | | Change a `cache.inputs.env` host value | step 7 | | Change the combined stdout+stderr of a `cache.inputs.runtime` command (resolved at hash time) | step 8 | | Change the combined stdout+stderr of a `cache.inputs.workspaceRuntime` command (resolved at hash time) | step 9 | | Upstream task's cache key changes (because its inputs changed) | step 10 | | Bump `CACHE_VERSION` | step 1 — orphans every entry | | Change `exec.env.passThrough` _values_ alone | **NOT a trigger** by design — passThrough values are host-specific | | Change a file not in `inputs.files` / `inputs.workspaceFiles` | **NOT a trigger** by design — declare it explicitly | | Edit `vx-lock.json` | **NOT a trigger** — globally excluded from inputs (v24); it's vx's own metadata, never a task input | | Change a file in a nested project's dir | **NOT a trigger** for the parent's `files` globs — project boundaries are hard (workspaceFiles is the explicit exception) | The cascade in step 10 is what makes monorepo caching work: edit a file in `lib/`, and every package that depends on `lib`'s `build` task re-runs automatically. ## Cross-project boundaries A project's `cache.inputs.files` globs **never** reach into another project's directory, even if a `**/*` pattern would otherwise match. `workspace/nested-dirs.ts` computes the set of nested project directories (projects rooted inside this one) once per `vx run`. A workspace package counts whether or not it has a vx config: it is the project `--affected` gives its files to, and its default `build` keys them (X-57). The same set fences the output clean, so a root `outputs.files: ['**/*.js']` never removes a member's source. Every glob pass drops a path under one of them (an ancestor lookup, A-11). The only way for project A to depend on project B's state via project-relative globs is `dependsOn` + upstream-hash propagation (step 10). For a task with `exec.sandbox` and `cache`, the sandbox enforces that "only". A workspace package reached through a `node_modules` link is granted only when the task's key folds a task of that package, walking the same selection `cache.inputs.tasks` applies at hash time (`orchestrator/keyed-projects.ts`, over `upstream.ts`'s `selectFoldedDeps`); every other link target inside the root is withheld, so an import of a sibling the key never sees fails and is reported instead of caching a result an edit there would not re-run. Any exec task counts, cached or not: a task with no `cache` declares no `inputs.files`, so its key folds every file of its project. A persistent task counts the same way: its key folds its whole project, so a cached e2e behind a dev server re-runs when the server's sources change. The coverage is per package, not per file: an edge to `ui#source` (inputs `src/**`) also admits a read of `ui/README.md`, whose edit moves no key — the same limit a grant wider than a task's own inputs already has. A task with no `cache` keeps every link, having no key to be stale. **Exception:** `cache.inputs.workspaceFiles` / `cache.outputs.workspaceFiles` are workspace-root-anchored and apply NO boundary rule — a deliberate escape hatch (owner call: "they don't care about boundaries; it is bad practice but is there"). Prefer project-relative declarations; reach for workspaceFiles only for genuinely root-anchored files. ## Clean filters (`text` / `eol` / `ident`, `core.autocrlf`) Git can store a file's blob in a different form than the bytes in your working tree. Under a `text`, `eol` or `ident` attribute — or with `core.autocrlf` set to `true`/`input` — the index holds the LF-normalized (or ident-collapsed) blob while your editor and your build see the CRLF (or expanded) file. That matters because `git status` compares **after** applying the filter, so such a file reports _clean_: git considers it unmodified even though the blob and the worktree file are different byte sequences. Trusting the index OID as the file's content hash would then let two genuinely different worktree contents fold the **same** cache key, and a real change would be invisible. So vx does not trust an index OID where a filter can apply. The check is gated in three steps, and the common case pays nothing: 1. `core.autocrlf` is `true`/`input` — conversion applies to every auto-detected text file with no attribute needed, so no OID is trusted. 2. Otherwise, if no attributes source exists anywhere (no in-tree `.gitattributes`, an ignored one included, which `git status --ignored=matching` names from the walk it already does, no `$GIT_DIR/info/attributes`, and none of the files git reads outside the tree: the global one, which is `core.attributesFile` or by default `$XDG_CONFIG_HOME/git/attributes` or `~/.config/git/attributes`, and the system one), no rule can name a filter and vx does **no** extra work. This is the default `git init` repo. The gate reads `git var -l`, one spawn, which from git 2.42 names the global and system files itself (`GIT_ATTR_GLOBAL`, `GIT_ATTR_SYSTEM`); for an older git the global lookup is mirrored and the system file is looked for at `/etc/gitattributes` and `/etc/gitattributes` beside the binary, where a standard build puts it. Before 2026-09-24 the gate missed the default global file, so `* text` there left a CRLF file keyed on its LF blob: a CRLF→LF edit was a stale hit. 3. Otherwise `git check-attr` resolves `text`, `eol`, `ident`, `filter` and `working-tree-encoding` from the index — without reading worktree content — and only the paths actually carrying one lose their OID. A `filter` driver was missing from the list until item 978: a clean driver that drops comment lines (`sed '/^#/d'`, nbstripout's shape) stores one blob for two worktree files, so an edit git calls clean replayed the old output. Git LFS files are a `filter` too, and are hashed from disk. `-text` (explicitly unset) and unspecified paths keep their OIDs: both leave the blob byte-identical to the worktree file. Losing an OID is not over-invalidation. It routes that path to the content hasher, which hashes the worktree bytes — the correct source either way. The only cost is reading the file, which is exactly what the gate exists to avoid paying needlessly. ## Runtime inputs and the lock (the env parallel) `cache.inputs.runtime` / `cache.inputs.workspaceRuntime` are modeled exactly on `cache.inputs.env`, and they share its asymmetry with the lockfile: - **The command _strings_ live in the resolved config**, so `vx lock` freezes them into `vx-lock.json` (just as it freezes the env _names_ a task declares). - **The command _output_ is resolved live at hash time on every run** — inside `resolveInputs`, the same place env _values_ are read from the host `process.env`. The lock never stores it. A runtime command executes **once per run, per project** (memoized by `projectDir + command`), at key-derivation time — which, with the up-front classify/prefetch pass, is before any task runs. It is a run-level reading of the ENVIRONMENT (toolchain versions, resolved config), not a per-task probe: it is not asked again before a save, as input files are, because that would be one spawn per command per miss where a file costs one `lstat`. A command that reads another task's OUTPUT reads whatever is on disk when it is asked. When the reader's key folds that task's key, the upstream key covers it: an answer taken before the upstream ran costs a spurious miss on the run after the output changes, never a stale hit, and the answer stays shared. When it does not (`tasks: []`, a filter that leaves it out, transitively), nothing covered it until X-34: the save filed bytes built from the new state under the old answer, and a later run that started from the old state hit them. Now such a task (`probesAfterWrites`, stable-keys.ts) takes no key up front and is never restore-tier, and its probes run for it alone, after its upstream finished — one spawn per such task per run, not one per project (`tests/in-run-writes.test.ts`). Declaring the producing task's output as an input (`dependsOn` + files) is still the cheaper spelling: files are re-checked, answers are not. A probe runs in its own process group. One still running when vx exits — a Ctrl-C, or a refusal, while a hung `git` or `node -e …` answers — is SIGKILLed with its whole tree; until 2026-09-27 (A-9) it ran on under init. A `kill -9` of vx runs no exit hook, and leaves it. So `vx run --frozen` loads the frozen command strings but still spawns them and folds their current output into the key. A `node -v` that goes from `v20` to `v22` after the lock was written busts the cache under `--frozen`, exactly as a changed `NODE_ENV` value would — the TypeScript escape hatch (`define: { TSC_VERSION: execSync(...) }`) goes stale here because its value was baked into the config object at lock time, whereas a runtime input re-resolves. Consequently **`vx lock --check` does not — and need not — flag runtime-output drift.** `lock --check` audits that the frozen config object still matches a fresh evaluation; the command output was never part of that object (only the strings are), so a changed probe output is correct, expected, live behavior rather than lock drift. This is the same reason `lock --check` ignores `inputs.env` value changes. ## Concurrent runs Two vx processes on one workspace (a `vx watch` beside a `vx run`, two CI jobs on one checkout) take turns: a run takes the workspace's run lock before it schedules and releases it with its cache handle — before a persistent task's wait, so a dev server never holds it — and the second run waits, saying after a second whom it waits for: ``` [vx] waiting for another vx run (pid 4821) on this workspace to finish… ``` The cache itself was always safe (SQLite waits on its lock, artifacts land by rename; a transaction that reads before it writes takes the lock at BEGIN, since SQLite answers its later write `database is locked` at once: a run's history was lost so in 13 of 48 runs on one shared cache dir, X-105); a task's OUTPUT TREE was not — both runs cleaned and restored the same `dist/`, and a clean landing while the other run's restore was staging took its files out from under it. The lock is an atomic directory under the temp directory, keyed by the workspace root (`--cache-dir` does not make two runs strangers) and holding the holder's pid, so a lock a killed run left behind is reclaimed once its pid is gone. The temp directory is the one each process sees, `TMPDIR` included: two shells that set different ones (a `nix develop` shell sets its own, as can direnv or `sudo`) hold two locks and do not take turns (item 970). Run them under one `TMPDIR`. A lock under the workspace would survive a reboot that `/tmp` does not, and where a holder's start time is not readable (off Linux) a pid reused since would read as a live holder forever. Where the directory cannot be made at all (another user's lock, a temp directory this user cannot write) the run says so once and proceeds unlocked — a courtesy between cooperating runs, never a refusal — and a restore that then loses its staged files to the other run's clean fails with `restore of <hash> into <dir> was interrupted: a file it had just written vanished (ENOENT: …). Another vx run is using this workspace — re-run once it is done.`; the artifact is intact either way (a restore renames into place only once the whole archive has staged). Machines sharing a workspace over a network file system do not share a temp directory, so they do not share the lock. An eviction can still land between a hit's probe and its restore: a `vx cache prune` in another shell, or the `cacheRetention` of another workspace that shares the `--cache-dir` (a different workspace, so a different lock), which cannot see this run's pending `accessed_at` bumps and reads its fresh hits as old. The restore then finds no artifact, and that entry is a **miss**: the run says `[vx] <id>: its cache artifact <hash> vanished before the restore … — running it`, runs the task, and saves it again. It failed every such task as an internal error until 2026-09-24 (upstream survey: nx#36688, nx#34032). A hit the local short-circuit was restoring ahead of its dependencies goes back to the schedule and runs once they are done, never in the restore's slot: built before them, its bytes would be saved under the healthy key (`tests/vanished-artifact.test.ts`). A local artifact whose bytes are wrong (a failed checksum, a torn write, one past the artifact ceiling, an entry missing a recorded output, a name that now reads unsafe, X-115) is a miss the same way: `[vx] <id>: cache: corrupt artifact for <hash>: …; dropped it — running it`. The entry is dropped, so the task's save stores the key again; it failed the task as an internal error on every run until `--force` before A-52. A cache the run may only read keeps the entry (`…; left it (this cache is read-only)`). ## Storage layout The run must be able to write here — it records its history at the end of every run, hit or miss — so a directory this user cannot write into (another user's `.vx`, a read-only checkout) fails the run before any task: `cache directory is not writable (EACCES: …)`, with `--cache-dir ` as the way out. `vx cache prune` is refused the same way (its `--dry-run` only reads, and reads). The readers (`vx show`, `why`, `last`, `info`) open such a directory read-only and go on: a config that misses the evaluation cache is evaluated live and not stored. Where the directory cannot be created at all (a read-only checkout with no cache yet) a run says `cannot create cache directory <path> (EACCES: …)`, naming the workspace `cacheDir` field and `--cache-dir`; the readers and `vx cache prune` find no cache and go on. A full disk is the same kind of failure and is reported the same way, never as a corrupt artifact and never as a stack: a save that runs out of room is `[vx] cache save failed: ENOSPC …` (the task's work ran, the next run misses), a restore that does is the task's failure line `could not write its outputs (ENOSPC: …). Free space on that disk and re-run.`, and a run record that does is `[vx] run history not recorded: … — the verdict above stands` (the run exits by its tasks; `vx last` will not know that one). A full index (`SQLITE_FULL`) gives way where the write is only a memo: the file-hash memo, the output stamps and the access times are skipped and the run goes on; a prune unlinks the artifacts first, so the rows it then deletes have room to go, and a save's temp file is removed when its write fails. A process out of file descriptors (`EMFILE`, `ENFILE`) is named the same way: a save is `[vx] cache save failed: save of <hash> could not open a file (EMFILE: …) — … raise the limit (ulimit -n 4096) and re-run`, and a restore fails the task with that hint, never as a corrupt artifact (A-39). Opening the index is named the same way: SQLite reports a descriptor it could not get as `unable to open database file`, and vx says `the cache index could not be opened (…) — …raise the limit` (A-56). A restore that cannot READ its artifact (a cache directory another user owns) names the cache, not the outputs: `restore of <hash> could not read its artifact (EACCES: …). Make the cache directory readable by this user, or point cacheDir / --cache-dir at one that is.` (A-40). An input the key must hash that this user cannot read fails its task naming the read: `<path> is not readable by this user (EACCES), and vx reads it to derive a cache key. Make it readable, or, for a task input, take it out of cache.inputs.files.` (A-50). By default the entries and their artifacts live in a **shared store** in `~/.vx//cache`, and each workspace keeps only its own index in its `.vx/cache`: every checkout of the repository hits what another saved (a second clone, a worktree), and none reads another's history. The id is Nx 23's: 16 hex of a sha256 of the remote (`origin`, then `upstream`, `base`, the first; `host/owner/repo` in lower case, so ssh and https agree) and the workspace's path in the repository; with no remote, the first commit. A repository with neither (no commit yet, a shallow clone with no remote) shares nothing. Each level of `~/.vx` is owner-only; vx closes one of yours that others can read (chmod 700), and one others can write, or owned by another user, is not used. Design: [`design/shared-store-2026-10.md`](../design/shared-store-2026-10/). ``` ~/.vx//cache/ the shared store ├── store.db entries, entry_stdout, output_files, │ entry_inputs, store_meta; artifacts │ of at most 32 KiB compressed (v33) └── .tar.zst the larger artifacts (below) /.vx/cache/ this workspace's own index └── cache.db run history, memos, output stamps; attaches store.db as `store` ``` The store's directory carries no version: every key is seeded with `CACHE_VERSION`, which moves when hashing or the artifact layout does, so two vx versions never read each other's artifacts. `store.db` is the artifacts' inventory and records its schema (`store_meta.schema`): a vx of another `SCHEMA_VERSION` drops its tables, prints nothing (the cache is vx's to keep), and keeps every artifact, each indexed again when its task next hits. The check, drop, re-create and stamp are one write transaction, so another version's open waits rather than landing between them. A home this user cannot write keeps the store in `/.vx/cache/` instead, silently. Name a cache directory (`cacheDir` in vx.workspace.ts, `--cache-dir`, or `VX_CACHE_DIR`, in that order of precedence, relative to the workspace root) and it holds everything, shared with no other workspace: ``` / (default .vx/cache when one is named) ├── .gitignore `*` — written when the dir is created, or into an │ existing dir that lacks one, so the cache is │ never committed and never enumerated as an │ input (a user's own file is left alone) ├── cache.db SQLite metadata + run history, and the │ artifacts of at most 32 KiB compressed, │ the same bytes as a file below (v33) ├── cache.db-wal write-ahead log ├── cache.db-shm shared memory ├── failures/.json a failed run's task output and the files it │ names (`vx last --format json`, getFailures); │ the newest 50 kept └── .tar.zst one artifact per cache entry, the larger ones: ├── stdout captured stdout (always present, may be empty) ├── outputs/ declared output files, project-relative (when any) ├── workspace-outputs/ declared outputs.workspaceFiles, │ WORKSPACE-ROOT-relative (when any) ├── .vx-meta.json per-output [mode, mtimeMs], the key it was │ packed under (v35), the miss's CPU and RSS └── .vx-sum CRC-32 of every entry above (v36) ``` `failures/` sits outside `cache.db` so the index layout, and every shared store under it, stays as it was. Each file is `{ runId, tasks: [{ taskId, project, task, exitCode, timedOut?, output, locations }] }`, written at run end (temp file + rename) only when a task failed with output; `output` is plain text (ANSI stripped, secrets masked, the first 8 KiB and last 56 KiB). A refused write (`EACCES`, `ENOSPC`) costs only that output, never the run's verdict. `` is the 16-hex xxh3 key. The `workspace-outputs/` namespace is additive: tasks that don't declare `outputs.workspaceFiles` produce byte-identical artifacts to the plain v17 format (which is why the field needed no `CACHE_VERSION` bump). `output_files` rows mirror the two namespaces — project rows store the bare rel, workspace rows store the full `workspace-outputs/` name as the discriminator. The container is written and read by vx's own streaming tar code (`src/cache/tar-stream.ts`): ustar with the name/prefix split and a pax `path` record past it, header checksums, and nothing else — there is no `tar` subprocess, no libarchive, and no staging copy. **Save streams it**: each output is stat'd once, then read from disk as it is written and compressed straight into the temp file (measured 2026-09-03 on a 150 MiB output: peak +705 MiB → +241–269 MiB; the streamed compressor is ~2.4× the one-call cost per byte, so a small artifact is packed in memory and compressed in one call — tiny saves unchanged within noise). Ingest lists entries through the reader without materialising a byte, and **restore streams it**: the zstd frame is decoded and the tar read as it arrives — ustar name/prefix (the prefix under POSIX `ustar\0` magic only: old GNU's `ustar ` keeps atime there, A-29), pax `path`/`size`, GNU long names, header checksums, truncation; an extended header (read whole) past 1 MiB or a pax `size` that is not a whole number is refused as corrupt, since a 1 GiB pax header costs 2 GiB of memory from a ~32 KB artifact — and every regular entry is written beside its target as a short `.vx-tmp-*` sibling (never a suffix on the target's name, which pushed a legal 242–255-byte name past NAME_MAX) and renamed into place only after the whole archive has ended cleanly and the index's recorded outputs are all present. vx itself holds one chunk of the tar at a time (measured 2026-09-03, incompressible artifacts, fresh process: 150 MiB peaked at +644 MiB through `Bun.Archive` and +49 MiB streamed at the same wall time; a single 400 MiB entry restores at +30 MiB, the same as a 150 MiB one), while the per-entry buffers of many mid-sized entries are garbage the collector reclaims at its own pace (200 × 2 MiB measured +90–180 MiB, never the artifact's size). An artifact up to 4 MiB compressed is decoded in one call first — the stream setup costs ~35 µs each, 4% of the headline restore row when every artifact is a one-file `dist/` — and then fed to the same reader and extractor, so there is one extraction path. The reader reads a header in place when it lies within one chunk and its numeric fields off the bytes when they are plain octal (anything else takes the full parse): a 4-entry artifact's read 50–57 µs → 22–23 (min of 15), and a 300-artifact, 20-file restore run's reader 224 → 92 ms of main thread (2026-10-02). The 2 GiB decompression ceiling applies to both: declared size and output length for the one-call decode, a running count for the stream. An ingest bounds the compressed body first: a remote body past the ceiling's zstd bound (a length header, a Blob's size, or a running count on a chunked stream) is refused before it can fill the disk. A frame that declares no content size (what a streaming compressor writes — vx's own saves above 4 MiB) is always decoded as a stream, so a sizeless bomb has nowhere to expand and vx's artifacts ingest anywhere. So is a body that is not exactly one frame: the declaration is the first frame's alone, while the one-call decoder decodes every frame, so until 2026-09-27 (A-5) a 100-byte frame with a large one appended expanded whole in memory (2 GiB from a 32 KB body) before its length was checked. The frame's blocks are walked by their own sizes to tell. A save meets the same ceiling first: the pack plans the tar from one stat per output, and a tar past 2 GiB is refused before a byte is compressed — the run stays green, one status line names the task (`[vx] cache save failed: <task> is not cached: its outputs pack to …, past the 2.0 GB artifact ceiling a restore enforces`), and nothing is stored for a later run to hit and fail to restore. Left to the scan, a 2.2 GB output paid the whole compress and a decode (6 to 14 s) to learn the same thing and called the task's outputs a corrupt artifact. The last entry, `.vx-sum`, is a CRC-32 over every entry before it (each its name, a NUL, then its body) as 8 hex digits. Tar sums its headers only and neither zstd writer asks for a frame checksum, so a byte flipped in a raw zstd block (incompressible output: an image, a wasm, a tarball) decoded clean and a hit replayed the wrong bytes: 1,141 of 1,141 flips in a random 8 KB body went unseen (L-19). Scan and restore hash every entry as they read it and refuse an artifact whose sum is absent, wrong, or followed by another entry, before anything it holds lands; the refusal is a corrupt artifact, so a restore degrades to a miss and an ingest stores nothing. It catches damage in transit or at rest, not a store that forges artifacts: nothing a key carries can tell those from honest ones. Its cost is the CRC's (~10 GB/s): a 32 MB restore 11.3 → 14.4 ms and 1,000 small files 16.2 → 18.9 ms on tmpfs, min of 15. Tar headers carry mode and second mtimes. vx needs both permission bits (a lost executable bit builds cold and breaks warm) and millisecond mtimes (the skip-restore probe compares them) exactly, so the pack stats each output once and writes `.vx-meta.json` — `{ version, key, files: { : [mode, mtimeMs] }, exec? }` — into the archive. Restore applies both, at their edges too: a mode of 000, an mtime of 0 (`SOURCE_DATE_EPOCH=0`), one before 1970, whose tar header carries 0 since ustar's field holds no sign, and one past March 2242, whose header carries the field's 11-digit maximum (X-114: the save failed `value … does not fit a 12-byte field`). Until 2026-09-27 (A-4) the first two were skipped, so the file came back 0644 and stamped now and every later hit restored it again, and the third wrote a header the reader refused, so its task never saved. `exec` (`{ cpuMs?, peakRssBytes? }`, 2026-09-12) is what the PRODUCING execution used: it rides the artifact so a machine that never ran the task — a fresh runner on a remote hit — still learns what the task needs (`@vzn/vx-schedule-history` packs on it), every wire ships the bytes verbatim, and an artifact without it reads as before. The ingest side takes the numbers only as plain non-negative numbers; anything else in a foreign sidecar is dropped. Entries that are not regular files (symlinks, hardlinks, devices) are never materialised — the reader reports them only to be skipped — so a poisoned artifact cannot smuggle one onto disk. Nor can it name a file the task did not declare: a remote artifact is ingested only when every file it carries matches the task's `cache.outputs` (the lookup passes them, `CacheGetContext.outputs`), and never when it holds one path as a file and a directory at once. Either used to reach the tree, an input overwritten or a `.git/hooks` file planted under a green `cache-hit-remote`, or a restore that failed every later run from the local copy (item 942); now the read is a miss and the task runs. On the save side a **symlinked output** is captured as its target's bytes and comes back as a regular file (a hit that finds the task's own link still current leaves it); a link to a directory, or a dangling one, has no bytes to store, so the save refuses it by name rather than cache an entry that restores to nothing. So does a link whose target is outside the project: vx reads outputs outside the task's sandbox, and a planted link packed a file the task could not read (L-23). Each refusal names the path as the config spells it (`workspaceFiles output gen/latest`). The clean before exec and restore removes every file AND symlink the output globs cover (a link is unlinked, never followed). An output directory that is a symlink (`dist -> real-out`) is followed by the clean as by the save and restore, so its target is the output and an entry holds only what its run wrote (X-88); one that resolves outside the project refuses the task, naming the link, and nothing is deleted through it (X-5). The clean prunes the directories it emptied (before a miss it keeps the directory a wildcard glob is rooted at, `dist` for `dist/**`, as the task writes there), so a task whose output changed shape — `dist/out` a directory one run and a file the next — restores either entry over the other's tree; a stray the globs do not cover, or an empty directory where the entry holds a file, that stands in an entry's way fails the restore naming it, not as a corrupt artifact (item 1094). A directory on the way that is a symbolic link OUT of the project (a `dist` made a link after the entry was saved) is never written through and never replaced — the link is the user's, and replacing it is the bug nx#37061 reports — so the restore refuses, naming it: `<dir>/dist is a symbolic link to <target>, outside <dir> — a cache restore never writes through a link that leaves its directory. Remove the link and re-run (the restore puts a real directory there), or stop declaring outputs under it.` A link that stays inside the project is written through, and so is one whose target is gone: the restore creates the directory it names — following a chain of links to its end — and writes through it, the link kept. Whether a link stays inside is decided on where it resolves, so a dangling link to `../elsewhere` or to an absolute path out of the project is the same refusal, nothing created outside, and a cycle of links is refused by name. (A link the output globs themselves cover, `dist/sub` under `dist/**`, is an output: the clean unlinks it.) Entry NAMES are validated by vx before anything decides where to write, and a bad entry anywhere, even the last, rejects the WHOLE archive: the temps are unlinked and the empty directories the extraction created are pruned (`tests/archive-security.test.ts`). A backslash in a name is a name character (vx runs on Linux and macOS; Windows through WSL), so an output like `dist/back\slash` caches and restores as written. **Key properties:** one entry is one artifact — a row of `artifacts` when small, else one file, so eviction is one delete in the rows' transaction or a single unlink; no per-entry manifest, no separate `logs/` tree; and local + remote layers transport the exact same tar.zst bytes end-to-end, wherever the local copy lives. The artifact is the record and the index its inventory (owner, 2026-10-06): a lookup reads the row first, and a key with no row whose `.tar.zst` is on disk (a `SCHEMA_VERSION` drop, a deleted `cache.db` or `store.db`) has the artifact indexed again from its own bytes, checked as a remote's are (its recorded key, its names against the task's declared outputs), and hits; one that fails the check is a miss, and the save that follows replaces it. An inline artifact with no row is indexed the same way, from its bytes, in one transaction; a reset keeps the `artifacts` table, but deleting `store.db` or `cache.db` by hand takes the inline artifacts with it. A `.tmp-*` a crashed save left is never a hit. `vx cache prune` sweeps row-less files, and so does a run whose workspace declares `cacheRetention`, at most once an hour (the sweep's clock is `schema_meta.orphans_swept_at`; the policy sums index rows, so orphans alone never make it due). A row-less artifact may be in use: two vx versions share one store, and each open drops the other's rows. So the policy judges it as it judges a row, its file time standing for `accessed_at`: past `olderThan` it goes, and under `maxSize` it counts, oldest use first with the rows. A hit renews a file time over an hour old, so the last use is read as the file time plus an hour. An inline artifact with no row is judged the same way, its `at` standing for the file time, and `vx info` counts it with the files. A temp a crashed save left goes once it is an hour old, and so does a file an inline artifact of its key shadows; nothing younger than an hour is taken. Re-indexing touches the artifact's mtime before it links its temp, so the sweep sees that one fresh too. A row's `accessed_at` is renewed the same way: a hit renews it only once it is over an hour old (owner, 2026-10-08). Renewing every hit rewrote 2,180 rows at a warm 1,090-package run's close, ~30 ms. So the policy reads a row's last use as `accessed_at` plus an hour too: an entry used within `olderThan` is never pruned, and one may stay up to an hour past it (`tests/cache.test.ts` › "an entry used within the age limit is never pruned"). Captured stdout is stored twice on purpose: in the artifact (so it survives the remote round-trip) and in the `entries` row (so a local hit replays it with pure SQL, never decompressing the artifact). ### SQLite tables `schema_meta.version` is the gate: an index written by any other `SCHEMA_VERSION`, earlier or newer, is reset by the first run that opens it (pre-alpha: no migrations; the index is an inventory, owner 2026-10-06): every table but `schema_meta` is dropped and recreated, so each comes back in its current shape (A-54: `config_closures` and `output_dirs` kept an earlier vx's columns). The check, drop, re-create and stamp are one write transaction, so another version's open waits rather than landing between them; an index already current is opened without the lock. A reading verb (`vx why`, `vx last`, `vx info`) leaves it untouched and says why (item 896; `vx cache prune --dry-run` previews the reset instead, item 1083). That open prints nothing (owner, 2026-10-06: the cache is vx's to keep). The artifacts stay: each is indexed again from its own bytes when its task next asks for its key (below). ```sql -- src/cache/schema.ts (SCHEMA_VERSION = 'v34', in cache.ts) -- With a shared store, entries, entry_stdout, output_files, entry_inputs -- and store_meta live in its store.db, attached as `store`; the rest is -- the workspace's cache.db. A named cache dir holds all of them. CREATE TABLE schema_meta ( key TEXT PRIMARY KEY, -- 'version', 'cache_version', 'orphans_swept_at', 'file_hashes_swept_at', 'config_evals_swept_at', 'store_dir' value TEXT NOT NULL ); -- The config-evaluation cache (§ Config evaluation cache): the validated, -- JSON-serialised result of a provably pure config, keyed by everything -- the evaluation could have observed. Machine-local. Rows not written in -- 30 days are swept at most once a day (`config_evals_swept_at`). CREATE TABLE config_evals ( key TEXT PRIMARY KEY, json TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE entries ( hash TEXT PRIMARY KEY, -- the 16-hex xxh3 cache key project TEXT NOT NULL, task TEXT NOT NULL, command TEXT NOT NULL, exit_code INTEGER NOT NULL, duration_ms INTEGER NOT NULL, size_bytes INTEGER NOT NULL, -- artifact size created_at INTEGER NOT NULL, -- ms-epoch accessed_at INTEGER NOT NULL, -- ms-epoch; a hit renews it once over an hour old (LRU) cpu_ms INTEGER, -- v26: the producing execution's usage, from peak_rss_bytes INTEGER -- the artifact's sidecar (save + ingest) ); -- v29: stdout apart from its entry. An UPDATE rewrites a whole record, so -- the accessed_at bump rewrote each hit's stdout (up to 16 MB): 200 hits -- of 1 MB cost the run's close 125-150 ms. CREATE TABLE entry_stdout ( hash TEXT PRIMARY KEY, -- FK entries(hash) ON DELETE CASCADE stdout TEXT NOT NULL -- captured stdout (pure-SQL hit replay); no row = '' ); CREATE TABLE runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, hash TEXT NOT NULL, -- '' when the outcome derived no key (see below) project TEXT NOT NULL, task TEXT NOT NULL, status TEXT NOT NULL, -- success | failed | cache-hit | cache-hit-remote | skipped exit_code INTEGER NOT NULL, duration_ms INTEGER NOT NULL, forward_args TEXT, -- salted xxh3 of the JSON-encoded `--` args; null when none started_at INTEGER NOT NULL, -- ms-epoch ended_at INTEGER NOT NULL, run_id TEXT, -- UUIDv7 shared across all tasks in one invocation cpu_ms INTEGER, peak_rss_bytes INTEGER, wallclock_start_ns INTEGER, -- bigint; serialized as SQLite INTEGER (signed 64-bit) wallclock_end_ns INTEGER, cache_hit INTEGER, -- 0/1; convenience for flamegraph color attempts INTEGER, -- v23: attempts a retried task took (>1) cached INTEGER, -- v25: 1 = declared a cache block; 0 = runs every time -- v27: why the task failed or was skipped, as the run's footer said it -- (items 267–270); NULL where the reason does not apply blocked_by TEXT, -- a skip's root blocker, or what a task ran behind (a task id) timed_out INTEGER, -- 1 when vx's own timeout killed it sandbox_violations INTEGER, -- the sandbox's violation count not_ready TEXT, -- 'timeout' | 'exited' | 'spawn' (persistent task) restored INTEGER -- v31, on a hit: 1 = outputs restored, 0 = up to date ); -- Two indexes, both append-only under a run's inserts: every row of a run -- carries the same run_id and a started_at newer than everything before it. -- There is deliberately NO (project, task) index — it scattered every run's -- rows over one B-tree leaf per pair (a 1,000-hit warm run's record stage -- went 57–79 ms → 14–19 ms without it) while its readers gained nothing; -- the history reader bounds its scan by rowid instead (see history.md). CREATE INDEX runs_started_at ON runs(started_at); CREATE INDEX runs_run_id ON runs(run_id); -- The one keyed index, PARTIAL over failed rows: a green run's inserts only -- evaluate its predicate, and the flakiness probe after a miss -- (failure-mode.ts, "did this key ever fail?") reads a few leaves. CREATE INDEX runs_failed ON runs(hash) WHERE status = 'failed'; -- Every non-group, non-aborted outcome of a run gets a row, so -- `invocations.task_count` always equals `COUNT(*)` here for that run_id -- and the terminal summary's "N total". Two of those outcomes never derive -- a cache key — a `skipped` task (its upstream failed, so it never probed) -- and a `persistent` one (a dev server is not cacheable) — and they store -- `hash = ''`. `''` is impossible for a real key (16 hex chars), so it reads -- unambiguously as "no key recorded"; the key-diff surfaces (`vx why`'s -- whyDidThisRerun, the cache-key diff) guard it rather than reporting -- "inputs unchanged" from two rows that never had inputs to compare. -- -- A `skipped` row is a task of the run but NOT an execution, so the rate and -- average aggregates in metrics.ts exclude it: counting a zero-duration -- non-event would dilute success rate, hit rate and mean duration. The -- completeness reads (listRuns / getRun / the run-detail timeline) include it. -- The file-hash memo: a content hash per input file that git could not -- answer (untracked or dirty), keyed by the stat identity that proves -- the bytes unchanged. Machine-local; § Cache key derivation step 12. -- A writing close drops rows unwritten for 30 days, at most once a day -- (a memo miss is the whole cost of a dropped row; item 1082). CREATE TABLE file_hashes ( path TEXT PRIMARY KEY, mtime_ms INTEGER NOT NULL, size_bytes INTEGER NOT NULL, ctime_ms INTEGER NOT NULL, ino INTEGER NOT NULL, content_hash TEXT NOT NULL, seen_at INTEGER NOT NULL ); -- The size of each index blob the enumeration checked against the -- worktree size git recorded (§ Cache key derivation, A-60): fixed for -- its OID, so a warm run asks git for none. Swept with file_hashes by -- seen_at, the time the row was written. CREATE TABLE blob_sizes ( oid TEXT PRIMARY KEY, size INTEGER NOT NULL, seen_at INTEGER NOT NULL ); -- v30: the paths an index distrusts, by a hash of the index file and the -- pathspecs (A-60): a warm run reads one row, not one per blob. Swept -- with file_hashes. CREATE TABLE blob_verdicts ( digest TEXT PRIMARY KEY, paths TEXT NOT NULL, seen_at INTEGER NOT NULL ); -- v16: per-output-file fingerprints, scoped by the entry that produced -- them — what a hit stats to skip the restore when the tree is already -- current (§ A current tree). ON DELETE CASCADE follows a prune. CREATE TABLE output_files ( entry_hash TEXT NOT NULL, path TEXT NOT NULL, size_bytes INTEGER NOT NULL, mode INTEGER NOT NULL, mtime_ms INTEGER NOT NULL, PRIMARY KEY (entry_hash, path), FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE ); -- v28, its own table since v32: inode + ctime THIS workspace saw after -- the save/restore that last wrote the file (item 886); no row is never -- current. Apart from the shared entry: two worktrees overwrote each -- other's stamps and every switch restored. No foreign key (the entry -- may be the store's); prune and re-save delete what they orphan. CREATE TABLE output_stamps ( entry_hash TEXT NOT NULL, path TEXT NOT NULL, ino INTEGER NOT NULL, ctime_ms INTEGER NOT NULL, PRIMARY KEY (entry_hash, path) ); -- Each config's ORDERED import closure (the config first), so a warm -- load keys it by stat-hashing the list through file_hashes instead of -- reading every file. Machine-local; pruned with config_evals. CREATE TABLE config_closures ( config_path TEXT PRIMARY KEY, files_json TEXT NOT NULL, created_at INTEGER NOT NULL ); -- Every directory under a whole-subtree output glob, with its mtime as -- of the last save or restore on THIS machine: unchanged mtimes prove -- the output SET unchanged without a glob walk. Machine-local — a remote -- ingest writes none, and the first hit after it walks and records. CREATE TABLE output_dirs ( entry_hash TEXT NOT NULL, path TEXT NOT NULL, mtime_ms INTEGER NOT NULL, PRIMARY KEY (entry_hash, path) -- no foreign key since v32, as output_stamps ); -- v22 (Tier 3): one header row per `vx run` invocation. The `runs` -- table is per-task; this is the per-invocation record carrying the -- command, git/CI/host context, tags, and run-level counts. Recorded -- atomically alongside `runs` via recordRunBundle (one transaction). CREATE TABLE invocations ( run_id TEXT PRIMARY KEY, -- UUIDv7, == runs.run_id command TEXT NOT NULL, -- full argv, e.g. "vx run build --all" requested_tasks TEXT NOT NULL, -- JSON string[] of options.tasks cache_policy TEXT NOT NULL, -- compact flags, e.g. "lR,lW,rR,rW" concurrency INTEGER NOT NULL, flow TEXT, -- 'focused' | 'broad' | NULL (programmatic) started_at INTEGER NOT NULL, -- ms-epoch ended_at INTEGER NOT NULL, total_duration_ms INTEGER NOT NULL, -- wall clock of the whole run task_count INTEGER NOT NULL, -- non-group, non-aborted tasks recorded failed_count INTEGER NOT NULL, hit_count INTEGER NOT NULL, -- cache-hit + cache-hit-remote hit_local_count INTEGER NOT NULL, -- cache-hit hit_remote_count INTEGER NOT NULL, -- cache-hit-remote up_to_date_count INTEGER NOT NULL DEFAULT 0, -- v31: hits that restored nothing restored_local_count INTEGER NOT NULL DEFAULT 0, -- v31: hits that restored, local layer restored_remote_count INTEGER NOT NULL DEFAULT 0, -- v31: hits that restored, remote layer exit_ok INTEGER NOT NULL, -- 1 if the run's `ok` commit_sha TEXT, -- nullable: not a git repo / probe failed branch TEXT, dirty INTEGER, -- 1 if worktree had uncommitted changes ci INTEGER NOT NULL, -- 1 if a CI env was detected ci_provider TEXT, -- 'github' | 'gitlab' | 'buildkite' | 'circleci' | 'generic' host TEXT, -- os.hostname() os TEXT, -- process.platform arch TEXT, -- process.arch vx_version TEXT NOT NULL, tags TEXT NOT NULL DEFAULT '{}' -- JSON object {k:v} from --tag ); CREATE INDEX invocations_started ON invocations(started_at); CREATE INDEX invocations_branch ON invocations(branch); CREATE INDEX invocations_ci ON invocations(ci); -- v22 (Tier 3): the input-fingerprint moat. One row per cache-key -- component, keyed by the cache-ENTRY hash it belongs to (NOT a run -- id). Written INSIDE the entry-save transaction — only on a cache -- MISS, never on a hit — via INSERT OR IGNORE. A warm all-cache-hit -- run writes nothing here. The "why did this re-run?" diff resolves a -- run to its task hash (runs.hash), then joins two entries' components -- over (kind, name), no recompute. v34: one row per entry, the -- components one JSON array (a row per component cost a b-tree insert -- each: ~0.5 ms a save at 50 input files). ON DELETE CASCADE sweeps the -- row when a prune drops the entry. CREATE TABLE entry_inputs ( entry_hash TEXT PRIMARY KEY, -- == entries.hash / runs.hash components TEXT NOT NULL, -- JSON [[kind, name, hash], …] in (kind, name) code-unit order FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE ); -- kind: file|env|runtime|ws-runtime|upstream|package|config|forward|workspace|plugin|format -- name: file: workspace-rel path; env: var name; upstream: task id; … -- hash: env|runtime|ws-runtime|forward|plugin: xxh3hex(salt + value); an unset env var: 'unset' -- v32: what belongs to the entries, not to one workspace: 'value_salt', -- the salt entry_inputs digests are taken under, so they compare with -- another workspace's run. CREATE TABLE store_meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); -- v33: an artifact of at most 32 KiB compressed, its exact .tar.zst -- bytes, written in the transaction that writes its entry rows. In the -- store (or a named cache dir's cache.db), and outside every drop a -- SCHEMA_VERSION reset makes: the artifact is the record. CREATE TABLE artifacts ( hash TEXT PRIMARY KEY, at INTEGER NOT NULL, -- last use (ms): a row-less sweep's file time bytes BLOB NOT NULL ) WITHOUT ROWID; -- 'layout' = 'a1': the artifacts table's own version; another value -- drops the table, silently. Nothing else drops it. Read only where -- the schema was not this vx's, so a layout change bumps the schema. CREATE TABLE artifacts_meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); ``` WAL mode is on; readers don't block writers. `PRAGMA busy_timeout = 5000` makes concurrent `vx run` invocations queue instead of failing with `SQLITE_BUSY`. > **Trust boundary (Tier 3):** `entry_inputs` stores a digest of each > value-bearing component — `env`, `runtime`, `ws-runtime`, `forward` > and `plugin` rows hold `xxh3hex(salt + value)`, an unset env var the > literal `'unset'` — never the value, so a secret read as a cache input > does not land in `cache.db` as plaintext. The salt is 128 random bits > the store draws once (`store_meta` key `value_salt`): `vx why` prints > these digests, and an unkeyed xxh3 in a public CI log let anyone > confirm or brute-force a short secret. The "why did this re-run?" diff > only needs to know a component changed, which the digest tells it. > `cache.db` still records commands and captured stdout; it is a local, > gitignored, single-user file. The Tier-3 tables persist components that were **already fed to `Cache.key()`** — the cache key derivation is unchanged, so the `CACHE_VERSION` is NOT bumped (only `SCHEMA_VERSION` rolls to `v22`). Capture happens as a pure side-channel inside the existing `key()` fold (`CacheKeyInput.captureInto`), **only on a cache miss** (the warm path captures nothing), and the rows are persisted with the entry — so a warm all-cache-hit run does no extra hashing, I/O, or DB writes for the moat. ### Why SQLite + a single artifact per entry - **Index queries are fast.** Stats (`SELECT COUNT(*) FROM entries`), TTL pruning (`WHERE accessed_at < ?`), per-task lookup (`WHERE hash = ?`) all hit a B-tree. - **A hit costs SQL, not decompression.** Metadata + stdout live in the row; the artifact is only opened when outputs actually restore. - **One artifact = one wire payload.** The same tar.zst bytes serve local storage and the remote round-trip — no repacking. - **One handle, one schema-meta sentinel.** An older schema drops and recreates every table but `schema_meta` (pre-alpha) — there's no migration code to maintain. ## Config evaluation cache Evaluating configs is the largest fixed cost of a warm run on a big workspace (~80 ms for 1000 synthetic configs; reading them as data is ~12 ms). A config that is **provably pure** — every import relative or `@vzn/vx`, and no mention of `process`, `Bun`, `Date`, `fetch`, `import.meta`, `require`, a dynamic `import()`, `await`, … outside string literals and comments — is served from `cache.db`'s `config_evals` table, keyed by the git blob id of the config and of every file in its relative import closure, the workspace fingerprint (lockfiles) and Bun's version. Editing a shared preset moves the key. On a warm run the closure is remembered per config, so the key comes from a stat-backed identity per file — no read, no scan — for every config whose relative imports name their files outright: an explicit extension, the file itself, no symlink on the way (an extensionless import, a `.js` Bun answers with a `.ts`, or a link could be re-resolved without touching a listed file, so such a config keeps the scan). Anything the static check cannot prove pure evaluates live, exactly as before, so the cache can be slower but not wrong for a config written in good faith (the check is syntactic; one built to defeat it can). Details and the deny-list: [`modules/config-cache.md`](../modules/config-cache/). ## Performance characteristics - **Hashing cost** on a clean tree is near-zero per file: git index OIDs come from the bulk enumeration spawn, so key derivation does no file reads. Dirty/untracked files — and any path whose OID is not trustworthy (see "Clean filters") — hash in-process (whole-file read, behind a `(mtime, size, ctime, ino)` memo). Narrow `inputs.files` still helps on heavily dirty trees. - **Cache read** is three indexed `SELECT`s (the `entries` row, which joins whether the artifact is inline, its `output_files` and its `output_dirs`), plus a `stat` of the artifact when it is a file. Restore is a tar.zst extract, skipped entirely when the on-disk tree already matches. `accessed_at` bumps (only rows over an hour old) are batched into one UPDATE at flush time. - **Cache write** is one in-process tar.zst pack + one SQLite transaction, which holds a small artifact's bytes itself; a large one adds a temp write and an atomic rename. Hashing dominates the run; storage itself is cheap. The remote upload (if any) is backgrounded. - **Workspace fingerprint** is computed once per `vx run` invocation and reused for every task in that run; after a task that may rewrite one of its files ran, one `stat` per file re-checks it (item 750). ## What's NOT in the key (and why) - **`exec.env.passThrough` _values_.** Would force cache misses across machines with different CI flags, regions, or shell prompts. The _names_ are in the config hash (step 5) so adding/removing a passthrough still bumps the key for affected tasks. - **Files outside the project directory that aren't declared.** Workspace-root configs (`tsconfig.base.json`, etc.) are not auto-included — declare them via `cache.inputs.workspaceFiles` (root-anchored globs). - **Node / Bun / OS / build-tool versions — unless you declare them.** The canonical mechanism is `cache.inputs.runtime` / `workspaceRuntime` (e.g. `workspaceRuntime: ['node -v']`): the command output is resolved live at hash time, so it stays correct under `--frozen`. Avoid baking versions via `define: { X: execSync(...) }` — that value freezes into the config object at lock time and goes stale. - **`vx-lock.json`** — globally excluded (v24); also filtered out of `--affected` change sets. - **`exec.remote`.** Pure PLACEMENT: it decides _where_ a task runs, never what it produces, so pinning a task to this machine does not bust its cache. It must not — the whole contract of a remote executor is that the same command over the same inputs yields the same outputs, so a key that moved with placement would split your laptop from the worker pool over nothing. **`exec.timeout` and `exec.retries` are the deliberate exception** — they stay in the key. That asymmetry is easy to misread as an oversight, so: a timeout or a retry budget can change _whether the task completes at all_, which is a different question from where it ran. They were folded in before the placement fields existed, and stripping them now would be a `CACHE_VERSION` bump for no correctness gain. ## Bumping `CACHE_VERSION` The cache records the version it was written under (`schema_meta.cache_version`). After an upgrade every cached task misses once and re-saves, with nothing printed (owner, 2026-10-06: the cache is vx's to keep). The index survives, so the old entries stay until they age out under `vx cache prune --older-than` or `cacheRetention`; no key derives to them again. Required when: - A new field is added to the cache key derivation (step list above). - The order or framing of existing key fields changes. - The on-disk layout changes (artifact format, entry naming). - The `CacheEntry` shape changes in a way that affects restore. - The SQLite schema changes in a way that affects existing rows (`SCHEMA_VERSION` also bumps in that case). Not required when: - Behavioural changes that adjust _which_ values flow into existing key components — those naturally produce different keys for affected tasks. - Changes to WHEN reads/writes fire (policy, prefetch, restore tier, background uploads) — key derivation and artifact bytes untouched. - Doc-only updates. - Refactors that don't change the bytes fed into the hash. `tests/contract-stored-format.test.ts` holds the layout rows: it records the index's DDL beside `SCHEMA_VERSION` and a fixture artifact's entries, sidecar and digest beside `CACHE_VERSION` (`tests/contract/stored-format.json`), and fails when either layout moves under its recorded version. Every bump of either version regenerates the record (`VX_UPDATE_CONTRACT=1 bun test tests/contract-stored-format.test.ts`), which refuses a layout that moved without one. The bump procedure has a dedicated skill at `.claude/skills/bump-cache-version/` (used as `/bump-cache-version`). Files touched, in the skill's order: `src/cache/key-fold.ts` (the constant), this doc (history), `docs/modules/cache.md` (the quoted version, and the key/entry shape if it changed), `CLAUDE.md` § Live invariants (the quoted version — the decision log it once named was retired 2026-09-02), `docs/STATUS.md` (the entry that says why the bump was needed, or why it was not), the cache tests, `tests/contract/stored-format.json` (regenerated, above), and `packages/vx-docs/src/content/docs/guides/upgrading.md` (the bump's breaking footer). ### History - **v41 → v42**: the stored output became both streams in the order the run printed them (`orchestrator/output-log.ts`), so a hit replays stderr too. A v41 entry holds stdout alone. - **v40 → v41**: stored bytes wrong under an unchanged key (X-88). An output directory linked inside its project was never cleaned, so an entry could hold files a run of another key left there. The fix cannot reach an entry already saved that way. - **v39 → v40**: stored bytes wrong under an unchanged key (X-32, X-33, X-34). An additive task's entry a hit replayed over a file the task had removed, one that missed a same-size rewrite, and a runtime probe answered before its upstream wrote. - **v38 → v39**: stored bytes wrong under an unchanged key (A-61). A gitlink whose directory had lost its `.git` but held files listed none of them, so an entry built from them sits under the key the same directory empty derives. - **v37 → v38**: stored bytes wrong under an unchanged key (A-60). A file added under a clean filter that was later removed kept its LF index blob, git held it clean by its stat, and the key folded that blob while the task read the CRLF bytes. The blob-size check cannot reach an entry already saved that way. - **v36 → v37**: stored bytes wrong under an unchanged key (A-59). `git status` paired a deleted file with a similar unmerged path as its rename source and printed only `UU `, so the file kept its index OID and the key folded it while the task ran without it. The fix (`--no-renames`) cannot reach an entry already saved that way. - **v35 → v36**: the container changes (L-19). Every artifact ends in a `.vx-sum` entry, a CRC-32 over the entries before it, and scan and restore refuse one whose sum is absent or wrong: a byte flipped in a raw zstd block restored as a hit, undetected. A v35 artifact carries no sum, so the bump retires them rather than refusing each on read. - **v34 → v35**: the container changes (item 943). The sidecar records the cache key the artifact was packed under, and ingest refuses bytes whose key is not the one it asked for, or that record none. Nothing tied an artifact to its key before: a remote layer that answered one key with another's bytes (a truncated or colliding key mapping, two namespaces mixed) replayed the other task's outputs under a green `cache-hit-remote`, probed with project `b` restoring project `a`'s `out.txt`. An artifact from v34 records no key, so the bump retires them rather than refusing each one on read. - **v33 → v34**: stored bytes wrong under a key a CORRECT derivation now produces (item 750). A root task that rewrote the lockfile mid-run (`pnpm install` without `--frozen-lockfile`) let a reader after it save a build made against the new install under the key of the lockfile the run started with, and that key is exactly what the fixed code derives when the tree is back on that lockfile. The fix stops new entries, not old ones. Probed, not argued: seeds A then B through the previous commit, then A twice through the fixed code under v33 — the last run started on lockfile A, installed A, and restored the build made against B; under v34 it built A. The item's other two edges poisoned nothing: their stale restores happened in runs that saved nothing (a read-only policy, a tainted upstream) or saved under a key the 743 re-check already guarded. - **v32 → v33**: stored bytes wrong under a key a CORRECT derivation now produces (item 743). The three stale-hit fixes of that item stop new poisoned entries, but not the ones already saved: a formatter's entry sits under the key of its unformatted input, an uncached `gen`'s consumer's under the key of the seed before `gen` ran, an edit-mid-run's under the key of the file before the edit — and each of those keys is exactly what the fixed code derives when the tree is back in that state. Not self-healing, unlike a fix whose old key no correct run derives. Probed, not argued: an entry saved by the previous commit's formatter, the input checked out unformatted, and the fixed code under v32 reported `up-to-date` and left it unformatted; under v33 it runs. - **v31 → v32**: stored bytes wrong under a key the fix does not change (item 739). A declared output whose name is not UTF-8 (`x\xffy`) came back from `Bun.Glob` decoded lossily as `x�y`, which names no file, so the save packed the tree without it and every hit restored a tree missing that file under an unchanged key. vx now refuses such a name by name; an artifact saved before the fix is the wrong bytes, so v31 entries are not trusted. - **v30 → v31**: stored bytes wrong under a key the fix does not change (item 726), v30's shape for a sibling. A sandboxed task that declared `cache` was granted every workspace package linked in its `node_modules`, so it could import a sibling its key never folded and save what it built. The fix withholds that link unless the key answers for the package (§ Cross-project boundaries), so the next miss fails on the read; but an entry saved before it still hits when only the sibling changes. Probed, not argued: an entry saved by the previous commit, `@x/ui` edited, then this commit's run under v30 reported `up-to-date` and left the old `ui` in `dist/`; under v31 the same run misses and fails on the read, with the hint. - **v29 → v30**: stored bytes wrong under a key the fix does not change (item 720). npm and Yarn link every workspace package at the root, the task's own included, and the sandbox granted every link target whole, so a sandboxed task was handed its own project back whatever its `allow.read` said. An undeclared read of its own file ran unreported and the result was saved under a key that never saw that file. The fix withholds the self-link, so the next miss fails on the read; but an entry saved before it hits for as long as only that file changes. - **v28 → v29**: every key moves, and the bump is for the notice, not for wrong bytes (item 682). The key is a seed-chained xxHash3 fold, one step per field and per input file, and Bun's xxHash3 reads only the low 32 bits of its seed: a bare chain carried 32 bits of state, so two input sets whose running digests shared their low halves merged at the next step, a stale hit at about 2^-32 per step rather than 2^-64. A birthday search over 2^17 values of one env variable found two that gave one `Cache.key` in under a second. `xxh3` now feeds the seed forward (`xxHash3(part, seed) ^ seed`): states that share their low half keep their high-half difference through every later step. The old keys were wrong but their stored bytes are not, so the fix alone is self-healing; without the bump every entry would miss silently. - **v27 → v28**: stored bytes wrong under a key the fix does not change — the v25/v26 shape (item 667). A bracket in a task glob became a literal character; before, `Bun.Glob` read it as a character class. An INPUT glob over a route directory folded the wrong files, and that fix is self-healing: the file set, and so the key, moves. An OUTPUT glob is not: `outputs: ['app/[id]/page.js']` folds as its text, which the fix leaves unchanged, and the entries under it hold nothing (or the class's namesake `app/i/page.js`). Proven through a real run: an entry written by v27 code, read by the fixed code, was a `cache-hit` that cleaned `app/[id]/page.js` and restored nothing; under v28 it is a miss, and the next hit restores the route. - **v26 → v27**: the artifact CONTAINER changed, so the stored bytes under an unchanged key are no longer readable the same way — the layout case, not the wrong-bytes case. Packing moved from a `tar` subprocess (with a staged copy of every output and a per-host `--format=gnu` / `--format=gnutar` probe) to `Bun.Archive`, and the per-entry mode + millisecond mtime that tar headers carried natively now ride a `.vx-meta.json` sidecar. Measured on the real `Cache.save` path (300 outputs / 12 MB, min-of-5, interleaved arms against a `git worktree` of the previous commit): **158 ms → 11 ms** to pack, the artifact grows ~7 compressed bytes per output for the sidecar. Restore is indistinguishable up to ~12 MB, but on a 150 MB incompressible artifact it costs +28% time and +19% peak RSS (74 → 95 ms, 575 → 683 MB peak, fresh process per arm): `files()` returns entries that OWN their bytes, so they coexist with the decompressed tar where the old reader returned views into it. Peak is ~4.5× artifact size, up from ~3.8×. The bump is mandatory rather than self-healing: a v26 artifact has no sidecar, so a v27 reader would restore its outputs mode-0644 and mtime-now — silently wrong on disk rather than a miss. Since 2026-09-03 the same layout is written and read by vx's own streaming tar code (no bump: the bytes are readable either way), and the peak above is history — save, ingest and restore hold one chunk now; see § Storage layout. - **v25 → v26**: the same shape as v25 — stored bytes that are wrong under a key nothing about the fix changes — reached by a different producer. A task whose child was killed by a shutdown signal reports `aborted`, but `aborted` did not propagate to dependents the way `failed` and `skipped` do. So a dependent ran against the aborted task's PARTIAL outputs, succeeded, and cached what it had built. Because a dependent's key folds its upstream's INPUT key — and a signal changes no input — that entry sits under exactly the key a healthy run derives, and the next run replays it as a green hit with exit 0. Reproduced end to end: run 1 killed mid-write leaves `PARTIAL`, run 2 is fully healthy and still serves `PARTIAL` from cache. Making `aborted` propagate stops new poison but cannot reach entries already written, and a `LayeredCache` uploads them — so the reach is a whole team's shared cache, not one developer's disk. That is what makes the trade worth it: one cold rebuild against a class of silently-wrong output. The interactive Ctrl-C path was never the vector (vx's handler exits before a dependent can cache); the reachable ones are an external `kill`, a supervisor, `docker stop`, a self-terminating script, and every `handleSignals: false` embedder — which includes `vx watch` and the distributed agent loop. - **v24 → v25**: the ARTIFACT BYTES in every existing entry are wrong while the key addressing them is unchanged — the one situation a version bump exists for, and the opposite of the recent self-healing no-bump cases. Two defects on the pack/restore path, both silent data loss on an ordinary cache hit with no attacker involved: - **The executable bit was stripped from every cached output.** `packArtifact` staged each output with `Bun.write`, which creates the destination under the process umask and does NOT carry the source's mode, so the tar header recorded 0644 and the restore faithfully reproduced 0644. Any build emitting a CLI shim, a compiled binary or a generated script worked cold and broke warm — including this repo's own `build.bun.*` release binaries. Fixed by chmod-ing each staged copy to the source's mode. - **Outputs whose archive entry name exceeded 100 bytes were dropped on every restore.** POSIX ustar splits such a name into `prefix` + `name`; the reader read only `name`, which no longer starts with `outputs/`, so the file was neither restored nor indexed. Not self-healing: with no `output_files` row, the skip-restore check compared a truncated expectation against a truncated tree and agreed forever. Threshold is a project-relative output path of ~93 chars — ordinary for a modern bundler. Fixed by reading `prefix` (gated on the POSIX magic, since GNU headers reuse those bytes for atime) AND by packing `--format=gnu`, which carries long names in an `L` record. The format switch also fixes a working build being reported as FAILED: ustar cannot split a single path COMPONENT over 100 bytes and exits non-zero ("file name is too long (cannot be split)"), which `packArtifact` raised _after_ the task had already succeeded. 120-char filenames are legal everywhere and routine in snapshot/fixture trees. Shipped with three defence-in-depth fixes that needed no bump of their own: `restoreOutputs` now throws instead of returning quietly when the artifact is gone or cannot produce an output the index recorded (the caller has already wiped the declared outputs by then, so a quiet return reported a green hit over an emptied tree — reachable via a concurrent `vx cache prune`); the tar reader rejects an entry whose declared size runs past the end of the archive (`subarray` clamps, so it used to install short, NUL-padded content as a cache hit instead of degrading to a miss); and directory entries now get the same containment + realpath checks as file entries (`mkdir` follows a pre-existing symlink, so directories could be created outside the destination). No `SCHEMA_VERSION` bump — no table changed. - **SCHEMA v23 → v24 (no `CACHE_VERSION` bump)**: `file_hashes` gains `ctime_ms` + `ino`. The memo keyed on `(mtime, size)` alone, and its row persists across runs, so any producer that preserves mtime — `tar -x`, `unzip`, `cp -p`, `rsync --times`, a `SOURCE_DATE_EPOCH` generator — got the previous run's digest for genuinely different bytes: a stale cache hit. `utimes` cannot suppress ctime unprivileged and an atomic write-then-rename changes the inode, so the two together close it (git's index keys on ctime+ino+dev for the same reason); both come free from the stat already taken. The key DERIVATION is unchanged — the memo simply stops answering wrongly — so an affected task's key moves from a wrong value to the right one: it misses once, re-runs, re-caches. Self-healing, never a wrong hit. Landed alongside two other stale-hit fixes that needed no schema change: the cache-miss path now marks the outputs `cleanOutputs` wiped (it was the only one of four sibling call sites that dropped the return, so a deleted output kept a live index OID and stayed in a consumer's input set), and `skip-worktree` / `assume-unchanged` entries no longer keep a trusted OID (they sit at stage 0 and `git status` reports nothing for them, so a sparse-checkout path that was absent from disk still counted as an input). The schema gate drops + recreates on the version mismatch (pre-alpha, no migration), so this costs one cold rebuild. - **SCHEMA v21 → v22 (no `CACHE_VERSION` bump)**: Tier-3 dashboard tables — `invocations` (one header row per `vx run` with command, git/CI/host context, tags, and run-level counts, recorded with `runs` via `Cache.recordRunBundle`) and `entry_inputs` (one row per cache-key component, keyed by the cache-ENTRY hash — the input-fingerprint moat). `entry_inputs` is written **inside the entry-save transaction, only on a cache miss** (`INSERT OR IGNORE`), so a warm all-cache-hit run writes nothing for the moat — Tier 3 has **zero warm-run cost**. The cache KEY derivation is unchanged: these tables persist components already fed to `Cache.key()` (captured via a pure side-channel, `CacheKeyInput.captureInto`, at the same fold sites — only on a miss), so existing artifacts stay valid and `CACHE_VERSION` stays `v24`. The schema gate drops + recreates on the version mismatch (pre-alpha, no migration). - **v23 → v24**: exclude `vx-lock.json` from the input file set globally (`ALWAYS_IGNORE` in `cache/inputs.ts`). The lockfile is committed, so git enumerates it, but it's vx's own frozen-config metadata — never a task input. Without this, a `vx lock` re-write busts every key on a project that globs the root lockfile (a broad `**/*` on the root project). Tasks whose `cache.inputs.files` never matched it derive byte-identical keys. No SCHEMA bump — only the hashed file set changed, not the key layout or on-disk format. - **v22 → v23**: fold `cache.inputs.runtime` / `workspaceRuntime` command output into the key (two namespaced sections after env-values). The command _strings_ stay in the config hash (step 5); their combined trimmed stdout+stderr is resolved live at hash time and folded as `runtime-values:` (project-cwd) and `ws-runtime-values:` (root-cwd) sections, each `command\0output`. No SCHEMA bump — only `Cache.key` derivation gained two sections; the on-disk format is unchanged. Tasks declaring neither field fold a `:0` count for both and derive byte-identical keys to before the bump. - **v21 → v22: pure-input transitive** (+ SCHEMA v21): reverted the v21 output-fold. Downstream keys fold the upstream's **input key** (its own task hash) — a pure function of the filesystem, like Turbo/Nx. No output content participates in any cache key. **Early cutoff is gone**: an upstream that re-executes (comment edit, env change) but reproduces byte-identical output now still re-runs its dependents. This was a deliberate simplification — cutoff is rare in practice and not worth the cascade complexity (it forced output content into keys, which blocks any upfront/batched probe). **Multi-state is preserved**: branch ping-pong A→B→A still re-hits, because the upstream's _input_ differs per state and folds transitively into every dependent key. SCHEMA v21 drops the now-unused `outputs_hash` column; `CacheLayer.save` returns `void`. - **v20 → v21: early cutoff** (+ SCHEMA v19, **reverted in v22**): downstream keys folded the upstream's output content identity (`outputsHash`) instead of its task hash. Removed — see v22. - **v7 → v8** (PR #2): folded `forwardArgs` into the key for CLI argument-forwarding alignment. - **v8 → v9** (PR #3): `TaskConfig` shape changed — `exec` collapsed from an array to a single command, `tasks` nested under `run`. - **v9 → v10** (PR #7): on-disk layout switched from per-entry `meta.json` + `outputs/` directory to a workspace-wide `cache.db` (SQLite) plus output files directly at `/` and log files at `logs/.{stdout,stderr}`. Adds run history for `vx stats`. Removes the per-entry manifest. - **v10 → v11** (PR #19): analytics columns added to the `runs` table: `run_id` (UUIDv7), `cpu_ms`, `peak_rss_bytes`, `wallclock_start_ns` / `wallclock_end_ns`, `cache_hit`. All nullable; directly queryable via `sqlite3 cache.db`. The on-disk `/` layout itself was unchanged. - **v11 → v12** (PR #42): project's `package.json` bytes folded into every task's cache key implicitly. Matches Turbo / Nx "implicit dependencies" behavior — a `package.json` dep change invalidates the project's tasks even when `cache.inputs.files` is narrow and doesn't cover the file. - **v12 → v13** (PR #65): per-entry on-disk layout unified. Outputs moved from `/` (mixed with metadata) to `/outputs/`; stdout / stderr moved from the sibling `logs/.{stdout,stderr}` into `/stdout` and `/stderr`. Eviction collapses to a single `rm -rf /`. Also dropped the runner's `logs//__.{stdout,stderr}` dump — output is already streamed live, surfaced on the outcome object, and the cache entry covers successful runs; CI captures parent stdout natively. The duplicate sibling dump was pure redundancy. - **v13 → v14**: file enumeration switched from a `Bun.Glob` walker with our own `ignore`-library filter to `git ls-files --cached --others --exclude-standard`. Matches what Turborepo and Nx both do at the bottom of their hash pipelines. Side-effects user-visible: (a) nested `.gitignore` patterns are anchored to the gitignore's own directory, fixing the v13 footgun where `pkg/.gitignore: src/skip.ts` was misinterpreted as `/src/skip.ts`; (b) `.git/info/exclude` and global excludes participate; (c) untracked-but-not-ignored files enter inputs immediately (no `git add` required). (The non-git fallback walker was later removed entirely — vx hard-requires git; a non-repo workspace gets a clean `UserError` telling the user to `git init`.) - **v14 → v15**: cache-key hash swapped from SHA-256 (via `Bun.CryptoHasher`) to xxHash3 (via `Bun.hash.xxHash3`). Key strings shrink from 64 hex chars to 16, matching Turbo's xxh64 output width; derivation is ~5× faster, dominating the cache-warm path that hashes hundreds of input files. xxHash3 has no streaming Hasher API, so `Cache.key()` chains via the seed parameter (each `xxh3(part, prevDigest)` folds one field into the running digest) and `hashFileFromDisk` reads the whole file before hashing — fine for source files (typically < 1MB each); the throughput win outweighs the memory hit. `SCHEMA_VERSION` bumps to `v15` at the same time (PR #86 already took `v14` for the tar.zst artifact layout): the `file_hashes.sha256` column is renamed to `content_hash`, and the schema-mismatch path now `DROP`s the stale tables before `CREATE TABLE IF NOT EXISTS` runs so the rename actually takes effect on existing DBs. Non-cryptographic by design — cache keys never need collision resistance against an adversary, just uniqueness across honest inputs. - **v15 → v16** (PR #86 series): artifact storage moved to a single compressed `.tar.zst` per entry; the manifest.json entry was dropped (file fingerprints live in the `output_files` table). - **v16 → v17**: artifact narrowed to exactly `stdout` + `outputs/` — no `meta.json`, no stderr (only successful runs are cached and their stderr is near-always empty noise). Local and remote layers transport the same bytes end-to-end; entry metadata lives solely in SQLite. - **v17 → v18**: env-value folding in `Cache.key()` switched its name/value delimiter from `=` to `\0`. `${n}=${v}` was ambiguous — `("A", "B=C")` and `("A=B", "C")` folded identical bytes. Env names containing `=` are unreachable from a real POSIX environ, so this is contract hardening rather than a field bug, but the key derivation's stated invariant is unambiguous part boundaries — now it holds everywhere (file inputs already used `\0`). - **v18 → v19**: `'^task'` dependsOn expansion switched from transitive-deps to nearest-holder frontier semantics (Turbo/Nx direct-deps parity, plus vx's sparse bridging through deps that don't declare the task). Task graphs lose the redundant deep edges, so the filtered-upstream-hash set (step 10) shrinks for any task whose deps chain `'^task'` themselves — same inputs now derive a different key. Reachability/ordering is unchanged whenever holders chain `'^task'` (the universal pattern); a holder that doesn't is now the documented stopping point. No on-disk format change. - **v19 → v20**: input-file content hashes switched from xxh3 to **git blob OIDs** (Turbo's technique). The bulk enumeration spawn became `git ls-files -s --others --exclude-standard` — `-s` lines carry ` \t` for tracked files, so one spawn yields the file list AND the index OIDs; a second `git status --porcelain -z` spawn prunes OIDs for paths whose working tree diverges from the index (renames drop both sides; stage>0 conflict entries never get one; symlinks did not either until 2026-09-09, when the fallback started hashing a link the way the index does). A clean tree's key derivation does zero reads / stats / SQLite per file. Everything else falls back to `Cache.hashFile`, which now computes the identical blob OID in-process (object format auto-detected via `git rev-parse --show-object-format`, sha1 default) behind the existing mtime+size memo. `SCHEMA_VERSION` bumps to `v18` in the same change: pre-v20 `file_hashes.content_hash` rows store 16-hex xxh3 digests that must not leak into the OID domain through the memo. File-set visibility semantics are unchanged (verified: the `-s --others` path set is identical to `--cached --others`, including staged-but-deleted files and per-stage conflict duplicates). --- # CLI reference > The vx binary is the user-facing entry point. The implementation is The `vx` binary is the user-facing entry point. The implementation is intentionally simple: a hand-rolled argv parser (no commander / yargs) in `src/cli/index.ts` dispatches to per-subcommand handlers under `src/cli/.ts`. The flag surface is aligned with [Turborepo's `turbo run`](https://turborepo.com/docs/reference/run) so existing Turbo users can swap in with minimal muscle-memory churn. ```sh # Standalone binary via npm (no Bun required on target): npm install -g @vzn/vx # From source (Bun ≥ 1.4): bun src/bin.ts --version ``` Below that floor vx still runs, and `vx info`'s `bun` row says so, because what an older Bun breaks is the ANSWER, not the start: a large `--format json` write is truncated mid-stream, no task reports what it used, and a config syntax error surfaces as an internal error rather than the usual message. [`modules/util-bun-version.md`](../modules/util-bun-version/) has the measurements and why the verdict lives on that row rather than on stderr. The released binary carries its own Bun and the row never says it. ## Top-level shape ``` # Core vx run [OPTIONS] [TASK | PKG#TASK ...] [-- forwarded-args...] vx watch [OPTIONS] TASK [-- forwarded-args...] vx cache prune [--older-than ] [--max-size ] [--dry-run] [--format pretty|json] [--cache-dir ] vx lock [--check] [--format pretty|json] vx init [--dry [--format pretty|json]] [--force] [--mjs] [--native|--keep] [--plugin ] vx show [PROJECT[#TASK] | TASK] [--filter ] [--affected[=]] [--format pretty|json] vx info [--format pretty|json] [--cache-dir ] vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir ] vx last [RUNID] [--list[=N]] [--failed] [--log ] [--format pretty|json] [--cache-dir ] vx upgrade [TAG] # self-update a compiled binary vx docs [--limit N] [--format pretty|json] vx completions bash|zsh|fish # Meta vx # in a workspace, vx --help; outside one, says so and exits 1 vx help [VERB] vx --help, -h vx version vx --version ``` Each verb's lines are the usage lines `vx --help` prints, word for word (`tests/cli-help-synopsis.test.ts`); the flags a verb accepts are read from that line. Multiple positional tasks run in one orchestrator invocation with a shared task graph: `vx run build lint test` fans out all three across the resolved project scope. Anchored entries (`pkg#task`) target a specific project; bare entries follow the usual scope rules (default = the cwd project; broaden with `--all` / `--filter` / `--affected`). The cwd project is the deepest member holding the directory, a member reached through a link (`packages/b -> ../ext/b`) included. **Every requested name must resolve.** If any positional matches no project in scope, the run refuses to start — `vx run: no projects declare task(s): <name>.` on stderr, a run and `--dry` / `--graph` alike (a run printed it to stdout until 2026-10-03), exit 1, with `Did you mean ?` when a declared task (or, for `pkg#task`, a runnable spec) is within two edits, and with no name that near, what exists: `Tasks: build, test.`, `No project is named zzz; projects: a, b.`, or `a's tasks: build, test.` (M-56), and with `Only projects outside the selection declare <name> — pass --all, or --filter to pick them.` when the run was scoped (the cwd's project, a `--filter`) and a project outside the scope declares it — even when the other names resolved fine. A bare name declared by only SOME projects is normal and stays green; the guard fires only when a name matched nowhere. So a CI job running `vx run lint test typecheck` goes red the day `typecheck` is renamed, instead of silently running two of three. Under a scope a git diff chose (`--affected`, a `[ref]` filter) a bare name is judged against the whole workspace instead, since which projects hold it depends on what changed: `vx run test --affected` after a commit that changed only a project without `test` exits 0 with `No affected project declares task(s): test.`, and a name no project declares is still refused (item 1024). A change that reaches no requested task exits 0 with `Nothing affected: the change reaches no test task.` A diff that touched no project stops before that check: `nothing affected since ` on stderr, exit 0, a typo unseen. (No `-V` for version; `vx --version` only — matches Turbo.) `vx --help` (and `-h`, and `vx help `) prints this reference cut to that core verb — its usage lines and sections, then `Full reference: vx help` — and every argument error points at it. `vx help ` for a name that is no verb here (core, a plugin's, or a moved one) is refused as `vx ` is: one line, a guess when one is close, exit 1. Past a `--` the flag belongs to the command being run, so `vx run build -- --help` forwards it to the task instead. A plugin verb owns its own arguments, `--help` included. ## `vx run` ``` vx run [OPTIONS] [TASK | PKG#TASK ...] [-- forwarded-args...] ``` Run the named task(s). By default only the project containing the current working directory is selected — `dependsOn` still expands so the project's upstream workspace deps run too. Override with `--all`, `--filter`, `--affected`, or an explicit `pkg#task`. If no task name is given: - **In a TTY** — an interactive picker lists every `pkg#task` entry across the workspace (only the selected projects' under `--filter` / `--affected`; `--affected` selecting nothing exits `0` as a run does), prints `description` next to each, prompts for a number, runs the chosen one. The menu and prompt go to stderr, so `vx run > out.txt` still asks on the terminal. Ctrl-C at the prompt exits `130` as an interrupted run does; Ctrl-D exits `1` with `no task picked`. A workspace with no task exits `1` naming how to declare one (under `tasks` in a vx.config, or `vx init`). - **Not a TTY** — exits `1` with `missing task name (stdin is not a TTY, so no picker; tasks here: build, test)`, naming the cwd project's tasks, else every project's (twelve, then `and N more`); outside a workspace it reads `vx run , e.g. vx run build`. - **Under `--format json` or `--dry=json`**, on a terminal too — the same refusal (`a JSON answer opens no picker; …`), with the `VX_E_USAGE` error document on stdout: a program cannot pick. Exit codes: | Code | When | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | Every task finished `success` or `cache-hit` (local or remote); or `--affected` left no project that declares the task. | | `1` | At least one task ended `failed` or `skipped`; a persistent task exited non-zero after it was ready; a task name no project declares; or parse/setup error. | | `130` / `143` / `129` | Interrupted (SIGINT / SIGTERM / SIGHUP): each task's process group (the task and what it forked) gets vx's signal once (a SIGHUP as a SIGTERM), `VX_KILL_GRACE_MS` (2 s) to go, then SIGKILL; a second signal skips the grace. vx then dies of the signal, so a shell script running it stops too (a run that handed a task the terminal exits with the code, so the terminal is restored). | A task runs in its own session, so a terminal's Ctrl-C reaches vx alone, and each task hears it once: from vx, as SIGINT. Installed from npm, `vx` is a Node launcher. On Node 22.15 or later it replaces itself with the binary (`process.execve`), so every signal reaches vx directly. On older Node it runs the binary and waits for it: a signal sent to the launcher alone (`kill`, a process manager) is passed to the binary, and a terminal's Ctrl-C already reaches both, so the launcher does not send it twice. A reader that leaves does not change the code. `vx run build | head -1` closes the pipe after one line; the run still finishes, saves what it built and releases its lock, and exits with its own verdict — the output after that point goes nowhere (`EPIPE`, on stdout or stderr, is not an error vx reports). Before 2026-09-16 the same pipeline died with a stack and exit 1 after its task had succeeded. ### Selection | Form | Effect | | ----------------------------- | -------------------------------------------------------------------------------------------------- | | (default) | The project that contains cwd. Errors if cwd is not inside a project. | | `pkg#task` | Just that project. | | `//#task` | The root project's task (Turbo's spelling; the root is a project, D-39). | | `--all` | Every project that declares the task. | | `--filter ` (repeatable) | pnpm-style filter DSL (see below). | | `--affected[=]` | The tasks a git change reaches: the ones it touches and those whose `dependsOn` closure holds one. | Combining: every include (`--filter `, `--affected`) is taken first and every `!` exclude after them all, as pnpm does, so an exclude removes what any include added, whichever side of it it sits (items 955, 979). The union holds for tasks too: `--affected --filter other` runs other's tasks whether or not the change reaches them (X-10). `--all` with a filter is the filter's selection: the filters refine it rather than being overridden by it (`--all --filter '!docs'` is everything but docs). ### Filter DSL (`--filter`) The full DSL lives in `src/workspace/filter.ts`; this is the user- facing summary. A filter that matches nothing refuses the run (`no projects matched filter(s): …`) with `Did you mean <name>?` when a project name is within two edits, or when exactly one scoped project's name after its `/` is (`--filter vx-mcp` hints `@vzn/vx-mcp`), and `Projects: a, b` otherwise (M-56). A list names eight, then a count. An unmatched `tag:` filter hints the nearest tag instead (`Did you mean tag:scope:web?`), else lists the tags. When every pattern matched and an exclusion took back all of it, the refusal names the exclusion instead (`no projects selected: !app excluded every project the other filters matched`). | Form | Meaning | | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `` | Match by package name. `*` matches any characters, including `/`. A pattern matching no package may leave out the scope, as pnpm reads it (`cart` is `@nx-example/cart` when one package carries it). One holding a `/` outside a scope that matches no package is a directory, as Nx's `--projects` reads it (`apps/*` is `./apps/*`). | | `./` | The package at `` alone, as Turbo and pnpm read it (`.` is the root project); a `` that is no package matches the packages under it (relative to workspace root; D-43). | | `{}` | Same as `./`. | | `./` | A glob over root-relative project dirs: `./packages/*` (direct children), `{apps/**}` (nested too; a trailing `**` matches zero dirs, so `./packages/kit/**` holds kit itself, as pnpm and Turbo read it). A path that names a project dir literally is read literally first, so `./packages/[abc]` is that directory. | | `.` | The root project alone, when the root is a project (D-39); otherwise the packages under the root, i.e. every package, not the one you are standing in. | | `//` | The root project alone, Turbo's name for it; matches nothing when the root is no project (D-46). | | `tag:` | The projects whose config `tags` hold a match, as Nx's `tag:` reads them (`*` as in a name). Takes every operator a name takes: `!tag:x`, `...tag:x`, `tag:x^...`, `tag:x[main]`. Nx's `--projects tag:x` aliases it. | | `...` | Match + all transitive dependencies (see below what an edge is). | | `...` | Match + all transitive dependents. | | `^...` | Only the transitive dependencies, excluding the matched package itself. | | `...^` | Only the transitive dependents, excluding the matched package itself. | | `......` | Match + its dependents + the dependencies of all of them, as Turbo selects (`...db...` takes the packages the apps that use db build on). | | `[]` | The packages `` (a name pattern or `{}`) selects that changed since ``, as Turbo and pnpm read `@scope/*[main]` (D-44). | | `...[]` | The packages `` selects that changed since `` or depend on one that did; no dependency is added (Turbo: `@acme/api...[HEAD]` is api when only its dependency changed). | | `!` | Exclude packages matching ``, from everything the includes select, in any order. | | `[]` | Projects whose files changed since `` (`main`, `HEAD~5`, …). | An edge is a `package.json` workspace dependency (`dependencies`, `devDependencies`, `peerDependencies`, `optionalDependencies`; a peer that would close a cycle counts for selection only, see `modules/package-graph.md`) — an entry the package manager links to a workspace package, not one whose key merely names it: `"shared": "^1.0.0"` beside a local `shared@2.0.0` is a registry dependency and no edge, and `"luigi": "workspace:../waluigi"` is an edge to `waluigi` (the rule: `modules/package-graph.md` § Which entries are edges) — OR a cross-project `dependsOn` entry (`e2e`'s `test: { dependsOn: ['app#build'] }` makes `e2e` a dependent of `app`). The task graph knows both, so selection follows both: `vx run test --filter '...app'` runs `e2e#test` even though `e2e` has no manifest dependency on `app`. There is no `implicitDependencies` field — declare the edge where the task needs it. A filter that names no project (`...`, a bare `!`) is refused, and one whose pattern matched but whose walk selected nothing says what it matched: `no projects selected: filter "...^core" matched core, and no project depends on it` (item 1030). Examples: ```sh vx run build --filter @scope/* # all packages under @scope vx run build --filter app... # app and its transitive deps vx run build --filter ...util # util and everything depending on it vx run build --filter app^... # only app's deps (not app) vx run build --filter '*' --filter '!docs' # everything except docs vx run build --filter '[origin/main]' # projects with files changed since main ``` ### `--affected[=]` Run the task only in projects whose files changed since ``. - `--affected` (no value) uses the workspace's `affectedBase` when it names one (`nx()` and `turbo()` set it from `NX_BASE` / nx.json's `defaultBase` and `TURBO_SCM_BASE`, `turbo()` on GitHub Actions from the pull request's base or the push's `before`, as Turbo does; [schema](../schema/)), else `origin/HEAD`; without one (actions/checkout sets none, nor does a repo with no remote), the first of `origin/main`, `origin/master`, `main`, `master` that is not HEAD itself, as Turbo and Nx compare with `main` (D-93); else `HEAD~1`. A clone with neither — a CI checkout at `fetch-depth: 1` — has no base at all, and vx says so (`--affected has no base here … a shallow clone?`) instead of failing on a `HEAD~1` nobody typed. And when the base IS the commit you are on (a single-branch clone whose `origin/HEAD` is the branch under test), the `nothing affected since ` note says the ref is HEAD itself and names the two bases you probably meant (`--affected=origin/main`, `--affected=HEAD~1`). - Without git on PATH, every shape is one line — `vx requires git: failed to spawn 'git' … Install git and re-run` — the same the input enumeration prints; a minimal image met a stack here before (2026-09-16). Outside a git work tree every shape says `vx requires git: <root> is not inside a git work tree`, as a plain run does, and a repository with no commit yet says so, not "a shallow clone?" (X-52). - `--affected=` uses the given git ref. A value that is empty or starts with `-` is refused before git sees it: the ref is an argument, never a shell command, and an option-like one (`--output=`) would be a real `git diff` option. A range (`HEAD~1..HEAD`, `main...feature`) is refused there too, naming the base to pass alone — `ranges are not supported — pass the base alone ("HEAD~1")` — because the other end is always the working tree; `...HEAD`, Turbo's CI spelling, is read as ``, since vx diffs from the merge base to a working tree that holds HEAD (D-117). Its two-dot `..HEAD` diffs from `` itself, not the merge base. An exclusion (`^main`) is refused the same way, naming `main`. A ref that does not exist is `git ref "" did not resolve`; in a shallow clone (CI's one-commit checkout) it adds that the clone is shallow and how to fetch the history (`git fetch --unshallow`, `fetch-depth: 0`). A ref naming a path inside a commit (`main:packages`) is refused, naming the commit to pass: its paths lack the prefix, so the diff would select the wrong projects. A root tree (`main^{tree}`) is still a base. - A member whose directory is a symlink to a place elsewhere under the workspace root (`packages/b -> ../ext/b`) is selected by a change at that real place too: git names the files where they live, not by the link (item 1079). A link to a directory outside the root is outside git's view, and a change there selects nothing. - The diff runs from the **merge base** of the ref and `HEAD`, not from the ref itself, so a branch whose base has moved on sees only its own changes — never the files other people landed on `main` since it forked (Turbo and Nx do the same). Refs with no common ancestor diff from the ref. **It selects the tasks the change reaches (owner, 2026-10-04).** A change seeds tasks in the projects it touches: - a cached task when a changed path is one of its declared inputs (`files`, `workspaceFiles`), or a changed submodule or embedded repository is one its `workspaceFiles` may reach into (git reports it as one path; a glob whose fixed prefix is above or inside it counts); - every task of a project whose `package.json` or `vx.config.*` changed, or that holds a changed path no cached task of its declares (vx cannot prove that path re-keys nothing), or that holds a changed submodule or embedded repository (git reports it as one path, and no task's globs can say whether they reach inside), or that a lockfile claim, a manifest edge at the base or a config import names; - an uncached task when a changed path lies in its project or the project is reached whole; a root file another task of its declares (`workspaceFiles`) is no change there. A group seeds nothing. A requested task runs when its `dependsOn` closure holds a seeded task, so a change reaches another project only along a task edge. With `app#test` behind `^build`, an edit to `ui/src/button.ts` that `ui#build`'s inputs read runs `ui`'s tasks and `app#test`; an edit to `ui/src/button.test.ts` that only `ui#test` reads runs `ui#test` alone, and `app#lint`, which depends on nothing, never runs for a change in `ui`. A `pkg#task` you name runs whatever the diff. This is the edge the cache key folds (a cached `app#test` with no edge to `ui` never sees `ui`'s files either). A project that declares no `build` gets one keyed on all its files (`schema.md`), so any change there reaches a dependant behind `^build`; a package `^name` passes through for want of a config reaches it the same way. The graph is the one a `graph` plugin leaves: an edge or an input it adds reaches a task as a declared one does, and a task it marks requested runs whatever the diff. With such a plugin every project is a candidate once the diff touched anything, and its hook sees every candidate task before the selection prunes the graph. Nx 23.3 (`NX_LEGACY_AFFECTED=false`) and Turbo (`affectedUsingTaskInputs`) select tasks the same way, behind flags. Until 2026-09-16 the sugar was the changed-only `[]` form (item 287), and until 2026-10-04 it took every task of every manifest dependent, which the filter forms still do: ```bash vx run test --affected # the tasks the change reaches vx run test --filter '...[main]' # what changed + every dependent project vx run test --filter '[main]' # only the projects that changed ``` Its candidate projects are `--filter '...[]'`'s; both are resolved by `src/workspace/affected.ts`, which unions `git diff` against `` with `git ls-files --others` so a brand-new untracked source file counts as a change (input hashing sees it, so `--affected` must too). A project inside a submodule or an embedded repository is selected when git reports that repository changed — a dirty or moved submodule (`vendor/sub`), an untracked embedded repository (`vendor/nested/`): the workspace repository sees the nested one as a single path (`git diff --raw`'s gitlink mode, 160000, on either side names one), so a change inside is a change to it, and every project under it is selected, the project that holds it included. A repository's own request to hide submodules from a diff (`diff.ignoreSubmodules`, `submodule..ignore`) does not apply: the key sees the change whatever git is told to show. `vx-lock.json` is filtered out of the changed set — a `vx lock` re-write never marks every project affected. **A lockfile change selects everything.** The root lockfiles, `pnpm-workspace.yaml`, `.yarnrc.yml`, `.npmrc`, `bunfig.toml` and the patches `bun.lock` names are folded into the [workspace fingerprint](../caching/), which is part of _every_ task's cache key — so a `bun install` / `pnpm update` invalidates the whole cache. Those files sit at the workspace root and belong to no project, so mapping changed paths to project directories would select nothing; `--affected` widens to every project instead, for the same reason it unions in untracked files. Only the ROOT copies count: a lockfile vendored inside a package is not hashed and selects just that package. A lockfile a plugin CLAIMS (`VxPlugin.fingerprint`, e.g. `pnpm-lock.yaml` under `@vzn/vx-lockfile`) is the exception on both sides: the key folds what the plugin says per project, so `--affected` asks the plugin which projects the change touches — given the bytes at the base ref and in the working tree — and selects those; only a plugin that cannot tell widens. **A dropped edge selects its dependent.** A package the change deleted (its `package.json` was there at the base and is gone) is no project now, so its paths map to nothing; and one whose `version` or `name` moved may no longer satisfy what a dependent declares (`lib: ^1.0.0` after a bump to 2.0.0). Either drops an edge the dependent's key folded, while today's graph shows no dependent to walk to. So the package graph is built again over the changed manifests as the base had them, and every project whose workspace dependencies differ is selected, and so is a project whose task names a removed or renamed package in `dependsOn: ['lib#build']`, an edge the package graph cannot see (item 1085). An edit to the root manifest's `workspaces` selects every project: which packages left the workspace is a discovery at the base. **A new nested project selects the project above it.** A project's inputs stop at every project below it, so a `package.json` that makes an existing directory a project re-keys the project that held it; the change maps to the new project alone, so the one above it is selected too. "New" is judged at the base: no manifest there, or one with no `name`. **A workspace config change selects everything.** An edit to `vx.workspace.*`, or to a file it imports by relative specifier, selects every project: the `config` and `project` stages its plugins install shape every resolved config, so the edit can re-key any task, and selection cannot tell which. It is not in the fingerprint (a key moves only when a stage's output does). A root file a plugin's stages read without importing it is seen when the plugin CLAIMS it (`VxPlugin.fingerprint`): `turbo()` claims `turbo.json` and `turbo.jsonc`, `nx()` claims `nx.json`, and an edit asks the claimant, which answers every project (item 961). **A file your config IMPORTS selects that project.** vx hashes the resolved config, so a shared preset a `vx.config.*` imports is part of the cache key — and selection follows the same rule. Editing `shared/preset.ts` selects every project whose config imports it, directly or through another shared file, even though the file belongs to no project and no `workspaceFiles` glob names it. The scan is STATIC (nothing is evaluated) and follows RELATIVE specifiers, and a bare one the nearest tsconfig maps through `paths` or `baseUrl`; any other bare specifier is a package, and a lockfile change already selects everything. It crosses project boundaries as the evaluation does: a config importing `../../packages/lib/preset.ts` is selected when `preset.ts` or a file it imports inside `lib` changes. Import your helpers by bare specifier to opt out. See [`docs/modules/config-imports.md`](../modules/config-imports/). **Nothing changed exits 0.** When the selection comes only from `--affected` / `[]` and resolves to zero projects, vx prints `nothing affected since ` and exits 0 — a docs-only commit must not fail `vx run lint test build --affected=origin/main`. When the diff did match and an exclusion or an empty walk took it all back, the exit-0 note says that instead (`no projects selected: !app excluded every project the other filters matched`), never that nothing changed. A name or path pattern that matches nothing is still an error (a probable typo), and a pattern that matches nothing alongside one that matched is warned about on stderr. **An empty selection never cancels an anchored task.** Project scope applies to bare names only, so `vx run app#deploy build --affected=origin/main` with nothing changed still runs `app#deploy` (vx notes `nothing affected since — running app#deploy only` on stderr). Only a bare-name-only invocation short-circuits to exit 0. With only `pkg#task` names the scope goes unused, but `--filter` and `--affected` are still resolved: a pattern that matches nothing or a ref git does not know refuses the run as it does beside a bare name. ### Argument forwarding (`--`) Anything after `--` is forwarded (shell-quoted) to the task's `exec.command`: ```sh vx run test -- --watch # underlying test runner sees "--watch" vx run build -- --sourcemap # build command gets "--sourcemap" ``` They are appended to the command's last line (trailing blank lines are dropped first, so a multi-line command's closing newline does not run them as a command of their own), or before a `#` comment still open there (`echo args: # show` gets them; with comment-only lines below a commented line, before the earliest). A heredoc's body and terminator are not command lines: `cat <` line shows the command as it ran, so a requested task's carries the args and a dependency's does not. A group (a task with no command) takes none, so a request that names only groups is refused (`args after \`--\` reach no task`) rather than run with the args unheard. ### Flags | Flag | Type | Default | Description | | ---------------------------------- | -------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--filter ` | repeatable | (none) | pnpm-style filter DSL (see above). `--filter=` form too. | | `--all` | boolean | off | Select every project that declares the task. | | `--affected[=]` | optional value | off | Select the tasks a change since `` reaches: the ones it touches and those whose `dependsOn` closure holds one (default `affectedBase`, else `origin/HEAD`, else `main` or `master`, else `HEAD~1`); candidates are `--filter "...[]"`'s. | | `--exclude-dependencies[=]` | optional value | off | Drop `dependsOn` edges. No value = all (just the requested task runs; a group's members run as the group); comma-list = drop only those names, each of which some project must declare (a typo is refused with the nearest name, item 1026). An edge to a task the run schedules anyway (`--all` requests it) stays, so the two still run in order, and so does the order through a dropped task: with `gen` dropped from `test → gen → build` and `build` requested, `test` still waits for `build`. An empty `=` value is a parse error (ambiguous — see below). A dropped dependency does not run but is still keyed, so every key is the one a full run derives; a task keyed on one may hit but does not save (`caching.md` step 10). | | `--concurrency ` | int or `%` | cores, capped by the cgroup quota | Maximum parallel tasks that EXECUTE; confirmed cache-hit restores are disk work and run on their own lane, up to twice this. `1` serializes both; `50%` is half the CPUs (rounded, never below 1; over 100% is allowed for I/O-bound work). `--concurrency=` form too. | | `--no-cache` | boolean | off | Disable caching entirely (no reads, no writes); output globs are NOT cleaned. | | `--force` | boolean | off | Re-execute everything (skip cache reads) but still REFRESH the cache (writes stay on). Output globs are cleaned (so the saved snapshot is clean). | | `--cache ` | value | all axes on | Per-layer read/write control. See below. An EMPTY spec (`--cache=`) is a parse error — it applied nothing and left every axis on; pass `--no-cache` to disable them all. | | `--cache-dir ` | value | workspace `cacheDir` / `~/.vx//cache` | Cache directory override, resolved relative to cwd (absolute paths used as-is). Beats the `defineWorkspace({ cacheDir })` field, `VX_CACHE_DIR` and the default; like them, the directory then holds the whole cache, shared through no store, for every cache the run opens — the config-evaluation cache that `--affected` owners, the picker and the watch sweep read included, so the workspace's default dir is not created beside it. A per-run knob — never folded into a cache key. `--cache-dir=` form too; the space form rejects a value starting with `-`. A directory this user cannot write into fails the run before any task with `cache directory is not writable (EACCES: …)` — every run records its history there. | | `--retry ` | value | `0` | Re-run a failed task up to `n` more times; never a persistent one. Run-level default only: a task's own `exec.retries` wins (even an explicit `0`). Never affects cache keys. `--retry=` form too. | | `--continue[=]` | value | `deps-ok` | What a failed task takes down with it. `never` stops dispatch on the first failure; `deps-ok` (default) skips only its dependents; `always` (bare `--continue`) runs dependents anyway. See § Failure propagation. | | `--timeout ` | positive int | none | Default per-task timeout for tasks without their own `exec.timeout`. Sits above `VX_TASK_TIMEOUT` + workspace `timeout`; per-task `exec.timeout` always wins. A runaway task is killed + `failed`. Never affects cache keys. `--timeout=` form too. | | `--frozen` | boolean | off | Load configs from `vx-lock.json` instead of evaluating (CI) — the run's, and the ones `--affected` owners and the picker select from. See § `--frozen`. | | `--output-logs ` | value | flow-derived | `full` \| `errors-only` \| `hash-only` \| `none` — explicit output override. See § `--output-logs`. `--output-logs=` form too. | | `--download ` | value | `all` | `all` \| `toplevel` \| `none` — where a REMOTELY-executed task's outputs land. `none` leaves them in the remote CAS and fetches lazily, only when a locally-placed task needs them. Never affects cache keys. See § `--download`. `--download=` form too. | | `--verbosity ` | int (0+) | `0` | `1` or more prints a per-task table (groups left out) after the framed blocks, above the footer. `--verbosity=` form too. | | `--dry[=text\|json]` | optional value | off | Print the task graph + predicted cache hit/miss; skip execution. `VX_TIMING=1` prints the stage table here as it does for a run. | | `--graph[=]` | optional value | off | Emit Graphviz DOT (stdout if no path, its directory made if missing); skip execution. A path it cannot write is one line and exit 1. | | `--format ` | value | `pretty` | `json` prints the `--summarize` document on stdout when the run ends and moves every other line to stderr ([Machine-readable output](#machine-readable-output)). `--format=` form too. | | `--summarize[=]` | optional value | off | Write per-run JSON to `/runs/.json` (or the explicit path). | | `--profile[=]` | optional value | off (`profile.json` when set) | Write Chrome-trace JSON of the run's wallclock spans. | | `--tag ` | repeatable | (none) | Label this invocation. Recorded on the run's `invocations` row so dashboards can filter runs. `--tag=k=v` form too. | | `--report[=markdown]` | optional value | off | At the end of the run, print a markdown run report to stdout, above the footer. Only `markdown` is supported (`json` is reserved). | | `--report-file ` | value | off | After the run, APPEND the same markdown report to ``, making its directory as the other output paths do. Use this for `$GITHUB_STEP_SUMMARY` — redirecting stdout captures the whole run log too. `--report-file=` form too. | Mutual exclusion: - `--dry` and `--graph` — both skip execution; pick one. - `--dry` or `--graph` with `--summarize`, `--profile`, `--report` or `--report-file` — each needs a real run to write about. `--report` and `--report-file` were accepted and silently wrote nothing until item 992. Unknown flags are a parse error (`unknown flag: --foo`), naming the nearest flag the verb accepts when one is within two edits (`unknown flag: --concurency (did you mean --concurrency?)`). Every verb does this against its own usage line and `--help`: `vx info --formt` hints `--format`, `vx lock --chek` hints `--check`, `vx upgrade --hlp` hints `--help`, and `--json` on a verb that takes `--format` hints `--format json`. `vx version` takes no word, so `vx version --hlp` is refused (exit 1) where it once printed the version. A task typed where the verb goes (`turbo build`, `nx build app`) is refused with the `vx run` that runs it: `vx build` names `vx run build --all` from the root and `vx run build` inside a project, `vx build app` names `vx run build --filter app`, and `vx app#build` names `vx run app#build`. A typo of a task (`vx biuld`) names the task and the same `vx run`, unless a verb is as close (`vx rnu` hints `run`). The default `build` is no declared task, so it is no hint: where it is the only `build`, `vx build` is an unknown command. It stays a refusal: a plugin verb of the same name is the verb, and would change what `vx build` means the day one was declared. **Optional-value flags take their value with `=` only.** `--affected`, `--exclude-dependencies`, `--dry`, `--graph`, `--summarize`, `--profile`, and `--report` are all valid bare, so a following word is read as a task name (a `--graph` word ending `.svg`, `.png`, `.json` and the like is refused, see § Turbo and Nx flags) — `vx run --affected build` means "run `build`, affected scope", and there is no way to tell that apart from "`build` is the git base". Write `--graph=out.dot` or `--affected=origin/main`. Getting it wrong is loud, not silent: the value becomes a positional that matches no project, so the run refuses to start (see "Every requested name must resolve" above). `--exclude-dependencies=` with an EMPTY value is rejected rather than guessed — "drop every edge" and "drop none" are both plausible readings. Pass bare `--exclude-dependencies` for the first, omit the flag for the second. Value flags (`--filter`, `--concurrency`, `--output-logs`, `--verbosity`, `--cache-dir`, `--report-file`, …) accept both `--flag value` and `--flag=value`. In the space form, `--cache-dir` and `--report-file` reject a value starting with `-`: that is always a swallowed flag (an unquoted empty shell variable), never a path or task id. Use the `=` form for a literal leading dash. **Numeric flags take a plain decimal integer.** `--concurrency`, `--timeout`, `--retry` and `--verbosity` reject hex (`0x10`), exponent (`1e3`), fractional (`2.7`), signed (`+4`) and space-padded forms, plus anything past `2^53` (it would parse to a number you did not type). These all used to be silently reinterpreted — `--concurrency 0x10` ran 16 workers. An empty `=` value means "no value" on four flags, which then act like their bare forms: `--profile=` writes `profile.json`, `--summarize=` writes `/runs/.json`, `--graph=` prints to stdout and `--affected=` uses the default base. Every other flag refuses an empty value: `--dry=`, `--report=`, `--continue=` and `--exclude-dependencies=` as well as the ones with no bare form (`--retry=`, `--timeout=`, `--cache-dir=`, `--filter=`, `--cache=`). #### Cache control: `--cache`, `--no-cache`, `--force` The cache has four independent axes — **localRead**, **localWrite**, **remoteRead**, **remoteWrite** — and the three flags above resolve them in this precedence order: 1. Start with every axis **on** (the default). 2. Apply each `--cache=` segment (the base). 3. If `--no-cache` was passed, force **all four off**. 4. If `--force` was passed, force **both reads off** (writes stay whatever the base / `--cache` left them). A spec that names a remote axis (`remote:r`, `remote:w`, `remote:rw`) in a workspace whose plugins supply no remote layer gets one status line saying so — the axes are inert without a layer to serve them, and a CI job that believes it is filling a shared cache should be told. So `--no-cache` always wins over `--force`. The common cases: - `--no-cache` → nothing reads, nothing writes, and declared output globs are left untouched (you're debugging; vx won't manage your tree). - `--force` → re-execute every task (reads off) but still write fresh artifacts to both layers (writes on). Output globs ARE cleaned before each task so the saved snapshot is clean. This is the "rebuild and refresh the cache" flag. `--cache=` is a comma-separated list of `:` segments. `layer` is `local` or `remote`; `flags` is any subset of `r` (read) and `w` (write), order-independent and possibly empty. A **mentioned** layer is set EXACTLY to its flags; an **unmentioned** layer keeps its current value. Both `--cache=` and the space form `--cache ` are accepted. | Spec | Effect | | --------------------------- | ------------------------------------------------------- | | `--cache=local:rw,remote:r` | remote read-only (won't upload); local full | | `--cache=local:r` | local read-only; remote untouched (still full) | | `--cache=remote:` | remote fully off; local untouched | | `--cache=local:,remote:rw` | don't touch the local cache, but still upload to remote | `local:` means "don't serve hits out of the pre-existing local cache" — a remote hit is still delivered (the artifact has to land on disk to be extracted), it just never short-circuits the remote read. Combine with `--force` for "re-execute and refresh only the remote": `--cache=local: --force` (local off, reads off, remote write-only). Invalid layers/flags are a parse error (`invalid --cache layer 'disk'`, `invalid --cache flag 'x'`, …). ### Output What a run prints is derived from the run's intent (its "flow"), unless explicitly overridden: - **FOCUSED** — no selection flag was passed. The user is running "their" task; cwd and task count are irrelevant to the classification. - **BROAD** — the invocation used `--all`, `--filter`, or `--affected`. The user asked about a swath of the workspace and wants news, not output. - **CI** — the `CI` env var is truthy (`CI=0` / `CI=false` don't count). Wins over the flow. Reported task lines share one column grid — `