@vzn/vx 0.0.634 → 0.0.636

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +51 -52
  2. package/docs/caching.md +14 -2
  3. package/docs/cli.md +94 -36
  4. package/docs/execution.md +1 -1
  5. package/docs/features.md +4 -3
  6. package/docs/patterns.md +8 -7
  7. package/docs/schema.md +73 -3
  8. package/docs/security.md +7 -1
  9. package/launcher.cjs +0 -0
  10. package/launcher.sh +50 -0
  11. package/package.json +17 -8
  12. package/schemas/last.json +5 -1
  13. package/schemas/show.json +8 -0
  14. package/schemas/why.json +4 -1
  15. package/src/cache/layered-cache.ts +3 -1
  16. package/src/cli/help.ts +3 -1
  17. package/src/cli/last.ts +9 -1
  18. package/src/cli/plugin-templates.ts +1 -1
  19. package/src/cli/run.ts +35 -7
  20. package/src/cli/select.ts +17 -11
  21. package/src/cli/show.ts +2 -2
  22. package/src/config.ts +36 -3
  23. package/src/graph/scheduler.ts +14 -3
  24. package/src/graph/task-graph.ts +22 -5
  25. package/src/index.ts +7 -1
  26. package/src/orchestrator/execute-task.ts +62 -6
  27. package/src/orchestrator/framed-output.ts +27 -1
  28. package/src/orchestrator/history.ts +75 -1
  29. package/src/orchestrator/index.ts +1 -0
  30. package/src/orchestrator/options.ts +2 -0
  31. package/src/orchestrator/path-links.ts +5 -3
  32. package/src/orchestrator/plugin.ts +3 -1
  33. package/src/orchestrator/prepare.ts +17 -14
  34. package/src/orchestrator/run-failures.ts +20 -3
  35. package/src/orchestrator/run.ts +25 -2
  36. package/src/orchestrator/sandbox-fix.ts +123 -0
  37. package/src/orchestrator/task-hash.ts +7 -1
  38. package/src/workspace/affected.ts +16 -4
  39. package/src/workspace/config-schema.ts +85 -4
  40. package/src/workspace/index.ts +2 -0
  41. package/src/workspace/migration.ts +51 -4
  42. package/src/workspace/project-loader.ts +3 -1
package/README.md CHANGED
@@ -5,8 +5,9 @@
5
5
  <h1 align="center">vx</h1>
6
6
 
7
7
  <p align="center">
8
- <strong>The fastest task runner for JS monorepos.</strong><br>
9
- Measured against Turborepo and Nx, each in its own native config.
8
+ <strong>Task orchestration you own.</strong><br>
9
+ Correct, sandboxed, extensible. No cloud, no account, no paywall.<br>
10
+ <a href="https://vznjs.github.io/vx/"><strong>vznjs.github.io/vx</strong></a>
10
11
  </p>
11
12
 
12
13
  <p align="center">
@@ -16,29 +17,9 @@
16
17
  <a href="https://vznjs.github.io/vx/"><img src="https://img.shields.io/badge/docs-vznjs.github.io%2Fvx-c6f84e" alt="Documentation"></a>
17
18
  </p>
18
19
 
19
- <!-- bench:start — generated by packages/vx-bench/update-site.ts from results.json; do not hand-edit -->
20
-
21
- | 1,090 packages, 3,270 tasks | vx | Turborepo | Nx | Vite Task |
22
- | ----------------------------------------------- | -------------- | -------------------------- | ------------------------- | -------------------------- |
23
- | Cold build: total time | **3 min 40 s** | 4 min 59 s (vx 36% faster) | 3 min 49 s (vx 4% faster) | 4 min 49 s (vx 31% faster) |
24
- | Cold build: CPU burned | **17 s** | 21 s (vx 22% faster) | 52 s (vx 3× faster) | 12 s (vx 39% slower) |
25
- | Fully cached run (restored) | **650 ms** | 997 ms (vx 53% faster) | 6.25 s (vx 9.6× faster) | 2.64 s (vx 4.1× faster) |
26
- | Fully cached run (up-to-date) | **393 ms** | 463 ms (vx 18% faster) | 6.45 s (vx 16× faster) | 2.49 s (vx 6.3× faster) |
27
- | Secondary: time the runner adds to a cold build | **2 s** | 1 min 21 s | 11 s | 1 min 11 s |
28
-
29
- vx N% or N× faster: that tool takes N% longer or N times as long as vx.
30
-
31
- 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.
32
- Run 2026-10-04 on linux x64, 4 cores: vx from source, Turborepo 2.11.7, Nx 23.2.1, Vite Task (vite-plus) 1.0.0.
33
-
34
- The secondary row is the wall time over the tasks' own ideal schedule (3 min 38 s).
35
- Same graph, commands and concurrency: [how it is measured](https://vznjs.github.io/vx/benchmarks/).
36
-
37
- <!-- bench:end -->
38
-
39
20
  ## What vx is
40
21
 
41
- A task runner and build cache for JavaScript monorepos.
22
+ Task orchestration with a cache you can trust, on a core you can extend.
42
23
  Each task is one shell command with declared inputs and outputs; vx runs
43
24
  them in dependency order and replays results from a content-addressed cache.
44
25
 
@@ -95,22 +76,7 @@ More: [Quickstart](https://vznjs.github.io/vx/quickstart/) ·
95
76
  [CLI](packages/vx/docs/cli.md) · [every config field](packages/vx/docs/schema.md) ·
96
77
  [playground](https://vznjs.github.io/vx/playground/) (the planner, in your browser).
97
78
 
98
- ## Coming from Turbo or Nx
99
-
100
- Beside `turbo.json` or `nx.json`, `npx vx init` runs `@vzn/vx-migrate`:
101
- it writes a `vx.config.ts` per package from `turbo.json` or the Nx graph,
102
- and you keep those and delete the old config. `vx init --keep` writes only
103
- a `vx.workspace.ts` that maps the old config instead: a temporary start,
104
- not a way to run the repo. The numbers above are native vx config.
105
- [Migration guide](https://vznjs.github.io/vx/guides/migrate/).
106
-
107
- ## Why it is faster
108
-
109
- - **Nothing boots per task:** no daemon, no Node process; vx spawns your command.
110
- - **No source-file reads to hash:** on a clean tree, keys come from git's index; each project's `package.json` and the lockfile are still read.
111
- - **One pass:** one git walk per run; each stat and lookup happens once.
112
-
113
- ## More than speed
79
+ ## A foundation you own
114
80
 
115
81
  - **A cache you can trust:** TypeScript configs are evaluated before
116
82
  hashing, so imports and computed values are in the key. Outputs are
@@ -122,6 +88,32 @@ not a way to run the repo. The numbers above are native vx config.
122
88
  CLI verbs. Core ships none and needs none.
123
89
  - **Free:** MIT. No paywall, no cloud, no account.
124
90
 
91
+ ## Plugins
92
+
93
+ What vx can do, not what it is limited to. Declared in `vx.workspace.ts`; without any, vx runs and caches locally.
94
+
95
+ | Package | What it adds |
96
+ | ---------------------------------------------------------- | ----------------------------------------------------------------------------- |
97
+ | [`@vzn/vx-migrate`](packages/vx-migrate) | Migrate from Turbo or Nx: write native configs, keep its remote cache |
98
+ | [`@vzn/vx-reapi`](packages/vx-reapi) | Remote cache and remote execution over Bazel's REAPI (NativeLink, BuildBuddy) |
99
+ | [`@vzn/vx-lockfile`](packages/vx-lockfile) | Key each project on its own lockfile slice: pnpm, bun, npm, yarn |
100
+ | [`@vzn/vx-otel`](packages/vx-otel) | OpenTelemetry traces, metrics and logs, no SDK |
101
+ | [`@vzn/vx-ci`](packages/vx-ci) | GitHub Actions job summary and Checks API |
102
+ | [`@vzn/vx-mcp`](packages/vx-mcp) | `vx mcp`: an MCP server for AI coding agents that also runs tasks |
103
+ | [`@vzn/vx-schedule-history`](packages/vx-schedule-history) | Schedule by the critical path learned from past runs |
104
+
105
+ Write your own: [plugin guide](https://vznjs.github.io/vx/guides/plugins/) ·
106
+ [architecture](packages/vx/docs/architecture.md).
107
+
108
+ ## Coming from Turbo or Nx
109
+
110
+ Beside `turbo.json` or `nx.json`, `npx vx init` runs `@vzn/vx-migrate`:
111
+ it writes a `vx.config.ts` per package from `turbo.json` or the Nx graph,
112
+ and you keep those and delete the old config. `vx init --keep` writes only
113
+ a `vx.workspace.ts` that maps the old config instead: a temporary start,
114
+ not a way to run the repo. The benchmark below uses native vx config.
115
+ [Migration guide](https://vznjs.github.io/vx/guides/migrate/).
116
+
125
117
  ## How it compares
126
118
 
127
119
  | | vx | Turborepo | Nx |
@@ -138,22 +130,29 @@ Every gap, and when another tool is the better pick:
138
130
  [comparison](packages/vx/docs/comparison.md) ·
139
131
  [vx, Turborepo, Nx, Bazel](https://vznjs.github.io/vx/compare/).
140
132
 
141
- ## Plugins
133
+ ## Fast, without trading correctness
142
134
 
143
- Declared in `vx.workspace.ts`; without any, vx runs and caches locally.
135
+ - **Nothing boots per task:** no daemon, no Node process; vx spawns your command.
136
+ - **No source-file reads to hash:** on a clean tree, keys come from git's index; each project's `package.json` and the lockfile are still read.
137
+ - **One pass:** one git walk per run; each stat and lookup happens once.
144
138
 
145
- | Package | What it adds |
146
- | ---------------------------------------------------------- | ----------------------------------------------------------------------------- |
147
- | [`@vzn/vx-migrate`](packages/vx-migrate) | Migrate from Turbo or Nx: write native configs, keep its remote cache |
148
- | [`@vzn/vx-reapi`](packages/vx-reapi) | Remote cache and remote execution over Bazel's REAPI (NativeLink, BuildBuddy) |
149
- | [`@vzn/vx-lockfile`](packages/vx-lockfile) | Key each project on its own lockfile slice: pnpm, bun, npm, yarn |
150
- | [`@vzn/vx-otel`](packages/vx-otel) | OpenTelemetry traces, metrics and logs, no SDK |
151
- | [`@vzn/vx-ci`](packages/vx-ci) | GitHub Actions job summary and Checks API |
152
- | [`@vzn/vx-mcp`](packages/vx-mcp) | `vx mcp`: an MCP server for AI coding agents that also runs tasks |
153
- | [`@vzn/vx-schedule-history`](packages/vx-schedule-history) | Schedule by the critical path learned from past runs |
139
+ <!-- bench:start — generated by packages/vx-bench/update-site.ts from results.json; do not hand-edit -->
154
140
 
155
- Write your own: [plugin guide](https://vznjs.github.io/vx/guides/plugins/) ·
156
- [architecture](packages/vx/docs/architecture.md).
141
+ | Time added over the ideal run, 1,601 projects, 9,603 tasks | vx | Turborepo | Nx | Vite Task |
142
+ | ---------------------------------------------------------- | ----------- | --------- | ---------- | --------- |
143
+ | Nothing changed | **856 ms** | 855 ms | 23.22 s | 11.24 s |
144
+ | Nothing changed, outputs restored | **1.45 s** | 1.50 s | 21.64 s | 11.21 s |
145
+ | Cold build | **7.73 s** | 10.32 s | 3 min 23 s | 9.44 s |
146
+ | Cold build: CPU the runner burns | **36.42 s** | 1 min 3 s | 5 min 35 s | 25.64 s |
147
+
148
+ Time each tool adds over the ideal run; lower is better.
149
+
150
+ Benchmark workload: a synthetic monorepo of 1,601 projects and 9,603 tasks in 30 dependency levels, five core libraries a quarter of the projects use; build 300 ms, test and typecheck 150 ms, lint 75 ms, publish 30 ms; real repos with uneven task times will differ.
151
+ Run 2026-10-12 on linux x64, 4 cores: vx at commit 126d60a93, Turborepo 2.11.7, Nx 23.3.0, Vite Task (vite-plus) 1.1.0.
152
+
153
+ Same graph, commands and concurrency: [how it is measured](https://vznjs.github.io/vx/benchmarks/).
154
+
155
+ <!-- bench:end -->
157
156
 
158
157
  ## Status
159
158
 
package/docs/caching.md CHANGED
@@ -200,7 +200,11 @@ add` under a clean filter (`core.autocrlf=true`, a `text` rule)
200
200
 
201
201
  Ref storage is not a key input: every ref vx reads comes from a git
202
202
  command, so a repository in reftable storage (git 2.45) keys every
203
- task as its files-backend twin (`tests/git-reftable.unsafe.test.ts`).
203
+ task as its files-backend twin. A sha256 repository keys from its
204
+ 64-hex index OIDs, and a dirty file is hashed in-process as sha256,
205
+ so Git 3's defaults (reftable and sha256 together) run, hit and
206
+ select `--affected` as a sha1 repository does
207
+ (`tests/git-reftable.unsafe.test.ts`).
204
208
 
205
209
  A **submodule or an embedded repository** is enumerated by its own
206
210
  git: the workspace repository lists the nested one as a single entry
@@ -704,7 +708,15 @@ is not saved either: its write cannot be told from an edit reverted
704
708
  mid-run, so it pays a re-run each time rather than risk a stale entry;
705
709
  declare what it writes as an output, which the status line says when it
706
710
  names a file other than `package.json` (TanStack Router's committed
707
- `routeTree.gen.ts`, rewritten by every build). A file ADDED under an
711
+ `routeTree.gen.ts`, rewritten by every build). That holds on CI too: a
712
+ task of the same project running alongside can change an input and put
713
+ it back. A task that rewrites its own input to identical bytes (tsdown's
714
+ `package.json`) sets `cache.inputStability: { strategy: 'mutable' }`, and a write
715
+ during its command is then judged by its bytes, as an earlier write is;
716
+ different bytes still withhold the entry. `VX_INPUT_STABILITY=mutable`
717
+ makes that the default (owner, 2026-10-12); `'unchecked'` skips the
718
+ check, and `severity: 'error'` fails the task instead of warning
719
+ (`docs/schema.md`). A file ADDED under an
708
720
  input glob since the listing the key filtered (`addedInput`) withholds
709
721
  the save the same way, named on the same line: the listing's
710
722
  directories a glob reaches are `lstat`ed, one whose ctime moved since
package/docs/cli.md CHANGED
@@ -137,9 +137,12 @@ Exit codes:
137
137
  A task runs in its own session, so a terminal's Ctrl-C reaches vx alone,
138
138
  and each task hears it once: from vx, as SIGINT.
139
139
 
140
- Installed from npm, `vx` is a Node launcher. On Node 22.15 or later it
141
- replaces itself with the binary (`process.execve`), so every signal
142
- reaches vx directly. On older Node it runs the binary and waits for it:
140
+ Installed from npm, `vx` is a sh launcher that execs the platform
141
+ binary, so every signal reaches vx directly and no Node starts first
142
+ (a `vx --version` through npm: 14 ms, was 56 ms with the Node launcher, 2026-10-12). Where it
143
+ finds no binary beside it (musl, Yarn PnP, a platform with none) it
144
+ hands off to a Node launcher. On Node 22.15 or later that one
145
+ replaces itself with the binary (`process.execve`) too. On older Node it runs the binary and waits for it:
143
146
  a signal sent to the launcher alone (`kill`, a process manager) is
144
147
  passed to the binary, and a terminal's Ctrl-C already reaches both, so
145
148
  the launcher does not send it twice.
@@ -477,6 +480,7 @@ than run with the args unheard.
477
480
  | `--no-cache` | boolean | off | Disable caching entirely (no reads, no writes); output globs are NOT cleaned. |
478
481
  | `--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). |
479
482
  | `--cache <spec>` | 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. |
483
+ | `--cache-scope <name>` | value | `VX_CACHE_SCOPE` / workspace `cacheScope` | This run's `cacheScope` (`trusted`, `read-only`, `pr-123`), above `VX_CACHE_SCOPE`, `vx.workspace`'s and `github()`'s. A name `cacheScope` would refuse is refused. `--cache-scope=<name>` form too. Never folded into a cache key. |
480
484
  | `--cache-dir <path>` | value | workspace `cacheDir` / `~/.vx/<id>/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=<path>` 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 <path> is not writable (EACCES: …)` — every run records its history there. |
481
485
  | `--retry <n>` | 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=<n>` form too. |
482
486
  | `--continue[=<mode>]` | 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. |
@@ -568,7 +572,8 @@ The cache has four independent axes — **localRead**, **localWrite**,
568
572
  them in this precedence order:
569
573
 
570
574
  1. Start with every axis **on** (the default).
571
- 2. Apply each `--cache=<spec>` segment (the base).
575
+ 2. Apply each `--cache=<spec>` segment (the base), or, with no
576
+ `--cache`, the `VX_CACHE` environment variable's spec.
572
577
  3. If `--no-cache` was passed, force **all four off**.
573
578
  4. If `--force` was passed, force **both reads off** (writes stay
574
579
  whatever the base / `--cache` left them).
@@ -957,8 +962,10 @@ readable. Its `criticalPath` names the would-run tasks with history on
957
962
  the chain `wallMs` measures, dependencies first: the tasks to speed up
958
963
  to shorten the run.
959
964
 
960
- **Why a task is affected.** Under `--affected`, each requested task the
961
- diff kept gets a line, and its JSON object an `affected` reason:
965
+ ### Why a task is affected (`affected.kind`)
966
+
967
+ Under `--affected`, each requested task the diff kept gets a line, and
968
+ its JSON object an `affected` reason:
962
969
 
963
970
  ```
964
971
  $ vx run build lint --affected=main --dry
@@ -981,6 +988,8 @@ against: a bare `--affected` guesses it (`origin/HEAD`, a trunk branch,
981
988
  else `HEAD~1`), so a reason that names no file may come from a commit
982
989
  the base reaches, a root `package.json` edit among them.
983
990
 
991
+ ### Task graph (`--graph`)
992
+
984
993
  `--graph` prints Graphviz DOT (stdout by default; `--graph=path`
985
994
  writes a file):
986
995
 
@@ -1283,7 +1292,19 @@ package, are reported.
1283
1292
  Policy: **fail on violation.** Any task that touches a path it didn't
1284
1293
  declare either fails naturally (Linux: `ENOENT` from bwrap's
1285
1294
  mount-namespace hide) or is flagged via the macOS violation store
1286
- and forced to exit non-zero. No cache is written for a failed task.
1295
+ and forced to exit non-zero. No cache is written for a failed task. The
1296
+ task's `SANDBOX VIOLATIONS` section ends with a dimmed `fix:` line,
1297
+ the grants that would have allowed each denial, project-relative:
1298
+ `fix: exec.sandbox.allow: { read: ['tsconfig.json'], write: ['dist/'] }`.
1299
+ A write grants the top directory it landed in, so the next file there
1300
+ passes too, unless `cache.inputs.files` reads that directory: then it
1301
+ grants the file alone, so the task cannot write its own inputs; a read the output depends on is a key input as well, and
1302
+ the line says so, since a grant never adds one. A credential path
1303
+ (`~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.kube`, `~/.config/gh`,
1304
+ `~/.config/gcloud`, `~/.netrc`, `~/.npmrc`, `~/.pypirc`,
1305
+ `~/.git-credentials`, `~/.docker/config.json`, a `.env` file) is never put in the
1306
+ line: an agent applies it as given, so it is named apart as one to
1307
+ grant only on purpose.
1287
1308
  "Didn't declare" means the grants, which on Linux are wider than they
1288
1309
  read: a file-shaped write grant is bound as its whole directory, so
1289
1310
  every file beside it is readable without a violation — `schema.md`
@@ -1961,13 +1982,17 @@ install its dependencies. `--mjs` writes the same objects as `vx.config.mjs` and
1961
1982
  own `tsconfig` includes every `.ts` under it compiles a `vx.config.ts`
1962
1983
  into its dist (TanStack/query's `tsc --build`, 2026-09-11), and an
1963
1984
  `.mjs` is outside that include. `@vzn/vx-migrate` takes the same flag.
1985
+ A file it writes that `.gitignore` drops (vueuse ignores
1986
+ `packages/**/*.mjs`) gets a `note:` naming it and the `!vx.config.mjs`
1987
+ line that keeps it, since a commit would leave it behind unnoticed.
1964
1988
 
1965
1989
  Each script becomes a task with its command verbatim. `build` gets
1966
1990
  `dependsOn: ['^build']` (a `build` that only delegates, `pnpm run
1967
1991
  compile`, is a group, and the edge goes on the script that does the
1968
1992
  work) and **no cache block** — under a
1969
1993
  `TODO(vx-migrate)` showing the block to add with the package's real
1970
- inputs and outputs. A task without a cache block always runs; a block
1994
+ inputs and outputs, and the same block commented as the task's first
1995
+ line: set the real globs and delete the `// `. A task without a cache block always runs; a block
1971
1996
  with EMPTY outputs is not "uncached" but a no-output task that hits on
1972
1997
  unchanged inputs and skips the build with nothing to restore (a deleted
1973
1998
  `dist` stays deleted under a green `up-to-date` run — what `init`
@@ -2113,8 +2138,8 @@ order may matter, so it is not made a group.
2113
2138
 
2114
2139
  The report lists each TODO once per reason: tasks that share one are
2115
2140
  named together (the first five, then a count; the files carry each),
2116
- and when no task caches it says a cache block from a TODO makes the
2117
- second run a hit. Its `next:` line is a command the user can type: the
2141
+ and when no task caches it says uncommenting a TODO task's cache line
2142
+ makes the second run a hit. Its `next:` line is a command the user can type: the
2118
2143
  runner that started vx (`npx`, `pnpm`, `yarn`, `bunx`, read from
2119
2144
  `npm_config_user_agent`) with the installed `vx` bin, else the
2120
2145
  `@vzn/vx` package (`npx` under Yarn 1, which has no `dlx`); with no
@@ -2370,6 +2395,42 @@ cache) or grant the write, then re-run.
2370
2395
  Out of file descriptors. Raise the limit (`ulimit -n 4096`), or run fewer
2371
2396
  tasks at once (`--concurrency`), then re-run.
2372
2397
 
2398
+ #### `VX_E_AFFECTED_BASE`
2399
+
2400
+ `--affected` (or `--affected=<ref>`) has no base to diff against: the
2401
+ ref does not resolve, is a range (`A..B`) or an exclusion (`^A`), names
2402
+ a path inside a commit, or the repository has no history to compare
2403
+ with. Pass a branch or commit you have locally, alone:
2404
+ `--affected=origin/main`. On CI, fetch the history the base needs
2405
+ (`fetch-depth: 0` on actions/checkout, or `git fetch --unshallow`).
2406
+
2407
+ #### `VX_E_GIT`
2408
+
2409
+ A git command vx runs to read the diff failed; the message carries
2410
+ git's exit and its own words. Run that command in the workspace to see
2411
+ why (a broken index, a missing object in a partial clone).
2412
+
2413
+ #### `VX_E_DEPENDS_ON`
2414
+
2415
+ A task's `dependsOn` names a task or project that is not declared, or uses
2416
+ a form vx does not take (a bare wildcard, a negation, a pattern in
2417
+ `pkg#task`). The message names the task and the entry: declare the
2418
+ target, fix its spelling, or drop the entry.
2419
+
2420
+ #### `VX_E_OUTPUT_OVERLAP`
2421
+
2422
+ Two tasks declare overlapping outputs with no `dependsOn` edge between
2423
+ them, so whichever runs second deletes the other's files. Give each task
2424
+ its own output path, or make one depend on the other.
2425
+
2426
+ #### `VX_E_INPUT_IS_OUTPUT`
2427
+
2428
+ A task's `cache.inputs` match outputs: its own (vx cleans outputs before
2429
+ a run, so it would delete its sources) or another task's (its key must
2430
+ not read them; `dependsOn` already cascades). Exclude the outputs from
2431
+ the inputs with a `!` glob, as the message shows, or declare no outputs
2432
+ for a task that rewrites files in place.
2433
+
2373
2434
  #### `VX_E_INTERNAL`
2374
2435
 
2375
2436
  A defect in vx; stderr carries the stack. Report it with that stack at
@@ -2807,8 +2868,14 @@ failed runs. `--format json`
2807
2868
  emits `{ invocation, tasks }` for scripting; a failed task's row adds
2808
2869
  `output` (its output as plain text, the first 8 KiB and last 56 KiB,
2809
2870
  secrets masked) and `locations` (the existing files it names,
2810
- `{ file, line?, col? }`, file absolute), so an agent reads why without
2811
- the terminal. They live in `<cacheDir>/failures/<runId>.json`, the
2871
+ `{ file, line?, col? }`, file absolute; a file also named with a line
2872
+ drops its bare mention, such as bun test's `app.test.js:` header), so an
2873
+ agent reads why without the terminal. `fixedIn` names the first later run where that task
2874
+ passed, so a replayed failure that is already fixed says so. A
2875
+ sandboxed task's row adds `sandboxFix`, the `exec.sandbox.allow`
2876
+ entries (`read`, `write`, `network`, `systemInfo`) that would have
2877
+ granted its denials, the same grants the terminal prints as `fix:`;
2878
+ `withheld` lists the credential paths it denied and left out. They live in `<cacheDir>/failures/<runId>.json`, the
2812
2879
  newest 50 failed runs; past those, or when the write was refused, a
2813
2880
  failed row reads `output: ''` and `locations: []`. `--log <task>`
2814
2881
  prints one task's output instead of the summary: in the latest run that
@@ -2958,30 +3025,21 @@ REAPI endpoints, the GitHub token) are in its README; what a TASK sees
2958
3025
  is `exec.env` in `docs/schema.md`, and the two markers vx sets on every
2959
3026
  task are the last row.
2960
3027
 
2961
- | Var | Value | Default | Effect |
2962
- | --------------------------------- | --------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2963
- | `VX_TIMING` | any non-empty | off | Print the stage table to stderr after a run, a `--dry`, or each `vx watch` cycle (`docs/modules/timing.md`). |
2964
- | `VX_TASK_TIMEOUT` | positive integer (ms) | none | The default timeout for tasks without their own `exec.timeout`, one rung below `--timeout` / `RunOptions.timeout` and one above the workspace `timeout`. Empty, non-integer or non-positive is ignored; a value past the largest timer (~24.8 days) is clamped to it, never refused. |
2965
- | `VX_CACHE_DIR` | a path | none | The workspace's cache dir, relative to the workspace root, below `--cache-dir` and `vx.workspace`'s `cacheDir`. A named cache dir holds the whole cache and shares nothing; unset, entries live in the user's shared store (`docs/caching.md`). Empty is ignored; whitespace alone is refused, as `cacheDir` is. |
2966
- | `VX_CACHE_SCOPE` | a `cacheScope` value | none | The run's `cacheScope` (`trusted`, `read-only`, `pr-123`), above `vx.workspace`'s and `github()`'s. Empty is ignored; a value `cacheScope` would refuse is refused before any task. |
2967
- | `VX_KILL_GRACE_MS` | positive integer (ms) | 2000 | The SIGTERM → SIGKILL grace a child that ignores SIGTERM gets: on a timeout, on a signal, and at the end-of-run shutdown of persistent tasks. Out of range falls back to the default. |
2968
- | `VX_READY_NOTICE_MS` | positive integer (ms) | 10000 | How long a persistent task may take to match `readyWhen` before vx says once what it waits for. Out of range falls back to the default. |
2969
- | `VX_TEARDOWN_TIMEOUT_MS` | integer (ms) | 3000 | The bound on one plugin's end-of-run flush or teardown, and on its `telemetry()` setup, so a third party's I/O cannot hold the run. Out of range falls back to the default, never clamps: a bound of 24.8 days is no bound. |
2970
- | `VX_CONFIG_WORKER_TIMEOUT_MS` | integer (ms) | 30000 | How long one `vx.config.ts` evaluation may take, in process or in its worker, before the load fails naming the config (a real evaluation is ~10 ms). A synchronous loop on a first load holds the thread the deadline needs, so it hangs until killed. Out of range falls back to the default. |
2971
- | `VX_WATCH_POLL` | any non-empty | off | `vx watch` polls every 250 ms from the start instead of probing the OS watcher (§ `vx watch` › How changes are seen). |
2972
- | `VX_RUN_WORKSPACE`, `VX_RUN_TASK` | set by vx | — | Set on every task's environment (the workspace root; `project#task`). Read back by a `vx run` a task starts: one in the same workspace is refused, since a nested run is invisible to the outer graph and a loop back to its own task forks without bound (`docs/schema.md` § `env`). |
2973
- | Var | Value | Default | Effect |
2974
- | --------------------------------- | --------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2975
- | `VX_TIMING` | any non-empty | off | Print the stage table to stderr after a run, a `--dry`, or each `vx watch` cycle (`docs/modules/timing.md`). |
2976
- | `VX_TASK_TIMEOUT` | positive integer (ms) | none | The default timeout for tasks without their own `exec.timeout`, one rung below `--timeout` / `RunOptions.timeout` and one above the workspace `timeout`. Empty, non-integer or non-positive is ignored; a value past the largest timer (~24.8 days) is clamped to it, never refused. |
2977
- | `VX_CACHE_DIR` | a path | none | The workspace's cache dir, relative to the workspace root, below `--cache-dir` and `vx.workspace`'s `cacheDir`. A named cache dir holds the whole cache and shares nothing; unset, entries live in the user's shared store (`docs/caching.md`). Empty is ignored. |
2978
- | `VX_CACHE_SCOPE` | a `cacheScope` value | none | The run's `cacheScope` (`trusted`, `read-only`, `pr-123`), above `vx.workspace`'s and `github()`'s. Empty is ignored; a value `cacheScope` would refuse is refused before any task. |
2979
- | `VX_KILL_GRACE_MS` | positive integer (ms) | 2000 | The SIGTERM → SIGKILL grace a child that ignores SIGTERM gets: on a timeout, on a signal, and at the end-of-run shutdown of persistent tasks. Out of range falls back to the default. |
2980
- | `VX_READY_NOTICE_MS` | positive integer (ms) | 10000 | How long a persistent task may take to match `readyWhen` before vx says once what it waits for. Out of range falls back to the default. |
2981
- | `VX_TEARDOWN_TIMEOUT_MS` | integer (ms) | 3000 | The bound on one plugin's end-of-run flush or teardown, and on its `telemetry()` setup, so a third party's I/O cannot hold the run. Out of range falls back to the default, never clamps: a bound of 24.8 days is no bound. |
2982
- | `VX_CONFIG_WORKER_TIMEOUT_MS` | integer (ms) | 30000 | How long one `vx.config.ts` evaluation may take, in process or in its worker, before the load fails naming the config (a real evaluation is ~10 ms). A synchronous loop on a first load holds the thread the deadline needs, so it hangs until killed. Out of range (0, or past 2³¹−1) falls back to the default. |
2983
- | `VX_WATCH_POLL` | any non-empty | off | `vx watch` polls every 250 ms from the start instead of probing the OS watcher (§ `vx watch` › How changes are seen). |
2984
- | `VX_RUN_WORKSPACE`, `VX_RUN_TASK` | set by vx | — | Set on every task's environment (the workspace root; `project#task`). Read back by a `vx run` a task starts: one in the same workspace is refused, since a nested run is invisible to the outer graph and a loop back to its own task forks without bound (`docs/schema.md` § `env`). |
3028
+ | Var | Value | Default | Effect |
3029
+ | --------------------------------- | ----------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3030
+ | `VX_TIMING` | any non-empty | off | Print the stage table to stderr after a run, a `--dry`, or each `vx watch` cycle (`docs/modules/timing.md`). |
3031
+ | `VX_TASK_TIMEOUT` | positive integer (ms) | none | The default timeout for tasks without their own `exec.timeout`, one rung below `--timeout` / `RunOptions.timeout` and one above the workspace `timeout`. Empty, non-integer or non-positive is ignored; a value past the largest timer (~24.8 days) is clamped to it, never refused. |
3032
+ | `VX_CACHE_DIR` | a path | none | The workspace's cache dir, relative to the workspace root, below `--cache-dir` and `vx.workspace`'s `cacheDir`. A named cache dir holds the whole cache and shares nothing; unset, entries live in the user's shared store (`docs/caching.md`). Empty is ignored; whitespace alone is refused, as `cacheDir` is. |
3033
+ | `VX_CACHE` | a `--cache` spec | all axes on | The run's cache policy when no `--cache` is passed (`local:rw,remote:r`); `--no-cache` and `--force` still apply on top. Empty is ignored; a bad spec is refused before any task. |
3034
+ | `VX_CACHE_SCOPE` | a `cacheScope` value | none | The run's `cacheScope` (`trusted`, `read-only`, `pr-123`), above `vx.workspace`'s and `github()`'s; `--cache-scope` beats it. Empty is ignored; a value `cacheScope` would refuse is refused before any task. |
3035
+ | `VX_INPUT_STABILITY` | `immutable`, `mutable`, `unchecked` | `immutable` | The `inputStability.strategy` of every task, over its own and the workspace's, for the run: which writes to an input during the command withhold the save. `mutable` lets a rewrite to the bytes the key folded through, `unchecked` skips the check. Any other value is refused before a task runs (`docs/schema.md`). |
3036
+ | `VX_INPUT_STABILITY_SEVERITY` | `warn`, `error` | `warn` | The `inputStability.severity` of every task, over its own and the workspace's, for the run: `error` fails a task whose save a moved input withheld, so the run fails. Any other value is refused before a task runs. |
3037
+ | `VX_KILL_GRACE_MS` | positive integer (ms) | 2000 | The SIGTERM → SIGKILL grace a child that ignores SIGTERM gets: on a timeout, on a signal, and at the end-of-run shutdown of persistent tasks. Out of range falls back to the default. |
3038
+ | `VX_READY_NOTICE_MS` | positive integer (ms) | 10000 | How long a persistent task may take to match `readyWhen` before vx says once what it waits for. Out of range falls back to the default. |
3039
+ | `VX_TEARDOWN_TIMEOUT_MS` | integer (ms) | 3000 | The bound on one plugin's end-of-run flush or teardown, and on its `telemetry()` setup, so a third party's I/O cannot hold the run. Out of range falls back to the default, never clamps: a bound of 24.8 days is no bound. |
3040
+ | `VX_CONFIG_WORKER_TIMEOUT_MS` | integer (ms) | 30000 | How long one `vx.config.ts` evaluation may take, in process or in its worker, before the load fails naming the config (a real evaluation is ~10 ms). A synchronous loop on a first load holds the thread the deadline needs, so it hangs until killed. Out of range falls back to the default. |
3041
+ | `VX_WATCH_POLL` | any non-empty | off | `vx watch` polls every 250 ms from the start instead of probing the OS watcher (§ `vx watch` › How changes are seen). |
3042
+ | `VX_RUN_WORKSPACE`, `VX_RUN_TASK` | set by vx | — | Set on every task's environment (the workspace root; `project#task`). Read back by a `vx run` a task starts: one in the same workspace is refused, since a nested run is invisible to the outer graph and a loop back to its own task forks without bound (`docs/schema.md` § `env`). |
2985
3043
 
2986
3044
  Colors are the two conventions in § Output format › Colors (`NO_COLOR`,
2987
3045
  `FORCE_COLOR`). A truthy `CI` picks the CI output flow and
package/docs/execution.md CHANGED
@@ -45,7 +45,7 @@ terminal and a task succeeding or failing. Read it alongside
45
45
  │ 3. loadWorkspacePlugins — loadWorkspaceConfig reads the optional
46
46
  │ vx.workspace.{ts,mts,js,mjs,cts,cjs} at the root (concurrency /
47
47
  │ cacheDir / timeout / cacheRetention / affectedBase / cacheScope /
48
- │ rules / schedule / plugins),
48
+ │ rules / schedule / inputStability / plugins),
49
49
  │ then the plugin `config` stage runs on it.
50
50
  │ 4. listProjects — globs every workspace member's package.json,
51
51
  │ finds sibling vx.config.* files, detects duplicate package
package/docs/features.md CHANGED
@@ -81,15 +81,16 @@ config key or an environment variable is missing here.
81
81
  - **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/)
82
82
  - **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/)
83
83
  - **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/)
84
+ - **Input stability** (`cache.inputStability`, workspace `inputStability`, `VX_INPUT_STABILITY`, `VX_INPUT_STABILITY_SEVERITY`) — an input written while the command runs withholds the save, on CI too, and the saves of every task that ran over its outputs; `strategy: 'mutable'` accepts a task's identical rewrite of its own input, `'unchecked'` skips the check; `severity: 'error'` fails the run instead of warning. no post
84
85
  - **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/)
85
86
  - **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/)
86
87
  - **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/)
87
88
  - **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/)
88
89
  - **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/)
89
90
  - **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/)
90
- - **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/)
91
+ - **Cache controls** (`--no-cache`, `--force`, `--cache`, `VX_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/)
91
92
  - **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/)
92
- - **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/)
93
+ - **Cache scope** (`cacheScope`, `VX_CACHE_SCOPE`, `--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/)
93
94
  - **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/)
94
95
  - **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/)
95
96
  - **One store for every checkout** (`~/.vx/<id>/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/)
@@ -156,7 +157,7 @@ config key or an environment variable is missing here.
156
157
 
157
158
  ## Plugins
158
159
 
159
- - **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/)
160
+ - **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; an `undefined`, `null` or `false` entry is skipped. [page](https://vznjs.github.io/vx/features/plugins/) · post: [A pipeline with seams](https://vznjs.github.io/vx/blog/pipeline-with-seams/)
160
161
  - **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/)
161
162
  - **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/)
162
163
  - **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/)
package/docs/patterns.md CHANGED
@@ -183,13 +183,14 @@ Sharing the patterns doesn't mean sharing the overhead. On the
183
183
  476-package / 1,428-node synthetic workspace
184
184
  (`packages/vx-bench/compare.ts 20 25 1`, 2026-09-02):
185
185
 
186
- | Runner | Fresh (cold) | Warm (no restore) | Warm (restore) |
187
- | ------ | --------------------- | ----------------------- | ----------------------- |
188
- | **vx** | 1m 40s | **297 ms** | **416 ms** |
189
- | Turbo | 1m 40s (vx same) | 342 ms (vx 15% faster) | 612 ms (vx 47% faster) |
190
- | Nx | 3m 23s (vx 2× faster) | 1.38 s (vx 4.6× faster) | 1.33 s (vx 3.2× faster) |
191
-
192
- vx N% faster: that tool takes N% longer than vx; N× faster: N times as long.
186
+ | Runner | Warm (no restore) | Warm (restore) |
187
+ | ------ | ----------------- | -------------- |
188
+ | **vx** | **297 ms** | **416 ms** |
189
+ | Turbo | 342 ms | 612 ms |
190
+ | Nx | 1.38 s | 1.33 s |
191
+
192
+ A cached run is all runner overhead. Cold builds are compared by the
193
+ time the runner adds, on [`benchmarks.md`](./benchmarks.md).
193
194
 
194
195
  Full breakdown + methodology in [`benchmarks.md`](./benchmarks.md).
195
196