@vzn/vx 0.0.641 → 0.0.642

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 (67) hide show
  1. package/package.json +5 -12
  2. package/skills/vx/SKILL.md +4 -4
  3. package/src/cache/archive.ts +525 -104
  4. package/src/cache/cache.ts +92 -12
  5. package/src/cache/fold-memo.ts +58 -0
  6. package/src/cache/git-inputs.ts +213 -31
  7. package/src/cache/index.ts +6 -2
  8. package/src/cache/inputs.ts +169 -79
  9. package/src/cache/key-fold.ts +90 -69
  10. package/src/cache/layer.ts +2 -6
  11. package/src/cache/layered-cache.ts +68 -4
  12. package/src/cache/output-index.ts +25 -4
  13. package/src/cache/remote-lane.ts +27 -10
  14. package/src/cache/schema.ts +13 -3
  15. package/src/cache/scoped-cache.ts +3 -1
  16. package/src/cache/umask.ts +29 -0
  17. package/src/cli/docs.ts +71 -7
  18. package/src/cli/help.ts +4 -4
  19. package/src/cli/plan-format.ts +4 -1
  20. package/src/cli/run.ts +10 -3
  21. package/src/cli/select.ts +2 -0
  22. package/src/exec/runner.ts +5 -0
  23. package/src/exec/sandbox-runtime.ts +81 -1
  24. package/src/graph/priorities.ts +14 -1
  25. package/src/history/history.ts +43 -33
  26. package/src/index.ts +10 -0
  27. package/src/orchestrator/deferred-outputs.ts +3 -2
  28. package/src/orchestrator/execute-task.ts +20 -14
  29. package/src/orchestrator/hit-restore.ts +10 -2
  30. package/src/orchestrator/index.ts +1 -0
  31. package/src/orchestrator/lockfile-claim.ts +54 -8
  32. package/src/orchestrator/miss-save.ts +27 -17
  33. package/src/orchestrator/output-host.ts +9 -1
  34. package/src/orchestrator/plan.ts +4 -0
  35. package/src/orchestrator/plugin-host.ts +31 -0
  36. package/src/orchestrator/plugin.ts +29 -3
  37. package/src/orchestrator/prepare.ts +74 -5
  38. package/src/orchestrator/projects.ts +6 -1
  39. package/src/orchestrator/run.ts +37 -15
  40. package/src/orchestrator/sandbox-request.ts +1 -48
  41. package/src/orchestrator/save-lane.ts +17 -10
  42. package/src/orchestrator/signals.ts +7 -10
  43. package/src/orchestrator/stable-keys.ts +83 -25
  44. package/src/orchestrator/task-hash.ts +46 -12
  45. package/src/report/events.ts +12 -0
  46. package/src/report/framed-output.ts +2 -1
  47. package/src/report/logger.ts +53 -13
  48. package/src/report/status-line.ts +45 -4
  49. package/src/report/summary.ts +5 -2
  50. package/src/util/errors.ts +1 -1
  51. package/src/util/hidden-prompt.ts +74 -0
  52. package/src/util/index.ts +12 -2
  53. package/src/util/paths.ts +33 -14
  54. package/src/util/secrets.ts +150 -0
  55. package/src/workspace/affected.ts +63 -13
  56. package/src/workspace/config-schema.ts +37 -3
  57. package/src/workspace/project-loader.ts +1 -0
  58. package/src/workspace/workspace.ts +17 -11
  59. package/docs/caching.md +0 -2376
  60. package/docs/cli.md +0 -3285
  61. package/docs/execution.md +0 -676
  62. package/docs/features.md +0 -178
  63. package/docs/patterns.md +0 -219
  64. package/docs/schema.md +0 -2468
  65. package/docs/security.md +0 -253
  66. package/src/cli/docs-corpus.ts +0 -21
  67. package/src/cli/docs-text.d.ts +0 -5
package/docs/cli.md DELETED
@@ -1,3285 +0,0 @@
1
- # CLI reference
2
-
3
- The `vx` binary is the user-facing entry point. The implementation is
4
- intentionally simple: a hand-rolled argv parser (no commander / yargs)
5
- in `src/cli/index.ts` dispatches to per-subcommand handlers under
6
- `src/cli/<name>.ts`. The flag surface is aligned with
7
- [Turborepo's `turbo run`](https://turborepo.com/docs/reference/run)
8
- so existing Turbo users can swap in with minimal muscle-memory churn.
9
-
10
- ```sh
11
- # Standalone binary via npm (no Bun required on target); inside a project
12
- # that installs @vzn/vx, it runs that one (§ Which vx runs):
13
- npm install -g @vzn/vx
14
-
15
- # From source (Bun ≥ 1.4):
16
- bun src/bin.ts --version
17
- ```
18
-
19
- Below that floor vx still runs, and `vx info`'s `bun` row says so, because
20
- what an older Bun breaks is the ANSWER, not the start: a large `--format json`
21
- write is truncated mid-stream, no task reports what it used, and a config
22
- syntax error surfaces as an internal error rather than the usual message.
23
- [`modules/util-bun-version.md`](./modules/util-bun-version.md) has the
24
- measurements and why the verdict lives on that row rather than on stderr. The
25
- released binary carries its own Bun and the row never says it.
26
-
27
- ## Top-level shape
28
-
29
- ```
30
- # Core
31
- vx run [OPTIONS] [TASK | PKG#TASK ...] [-- forwarded-args...]
32
- vx watch [OPTIONS] [--poll] TASK [-- forwarded-args...]
33
- vx cache clean [--older-than <duration>] [--max-size <size>] [--global] [--dry-run] [--format pretty|json] [--cache-dir <path>]
34
- vx lock [--check] [--format pretty|json]
35
- vx init [--dry [--format pretty|json]] [--force] [--mjs] [--native|--keep] [--plugin <seam>]
36
- vx show [PROJECT[#TASK] | TASK] [--filter <pattern>] [--affected[=<ref>]] [--format pretty|json]
37
- vx info [--format pretty|json] [--cache-dir <path>]
38
- vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]
39
- vx last [RUNID] [--list[=N]] [--failed] [--log <task>] [--stats [<task>] [--branch <name>]] [--format pretty|json] [--cache-dir <path>]
40
- vx upgrade [TAG] [-g|--global]
41
- vx docs <query> [--limit N] [--format pretty|json]
42
- vx completions bash|zsh|fish
43
-
44
- # Meta
45
- vx # in a workspace, vx --help; outside one, says so and exits 1
46
- vx help [VERB]
47
- vx --help, -h
48
- vx version
49
- vx --version
50
- ```
51
-
52
- Each verb's lines are the usage lines `vx <verb> --help` prints, word
53
- for word (`tests/cli-help-synopsis.test.ts`); the flags a verb accepts
54
- are read from that line.
55
-
56
- Multiple positional tasks run in one orchestrator invocation with a
57
- shared task graph: `vx run build lint test` fans out all three across
58
- the resolved project scope. Anchored entries (`pkg#task`) target a
59
- specific project; bare entries follow the usual scope rules
60
- (default = the cwd project; broaden with `--all` / `--filter` /
61
- `--affected`). The cwd project is the deepest member holding the
62
- directory, a member reached through a link (`packages/b -> ../ext/b`)
63
- included.
64
-
65
- **Every requested name must resolve.** If any positional matches no
66
- project in scope, the run refuses to start — `vx run: no projects
67
- declare task(s): <name>.` on stderr, a run and `--dry` alike
68
- (a run printed it to stdout until 2026-10-03), exit 1, with `Did you mean <task>?` when a
69
- declared task (or, for `pkg#task`, a runnable spec) is within two edits,
70
- and with no name that near, what exists: `Tasks: build, test.`, `No
71
- project is named zzz; projects: a, b.`, or `a's tasks: build, test.`
72
- (M-56), each followed by ``A run reads the lock: after editing a
73
- vx.config, run `vx lock`.`` (a task just added is not in the lock yet),
74
- and with `Only projects outside the selection declare <name> — pass
75
- --all, or --filter to pick them.` when the run was scoped (the cwd's
76
- project, a `--filter`) and a project outside the scope declares it
77
- — even when the other names resolved fine. A bare name declared by only SOME projects is normal and stays
78
- green; the guard fires only when a name matched nowhere. So a CI job
79
- running `vx run lint test typecheck` goes red the day `typecheck` is
80
- renamed, instead of silently running two of three. Under a scope a git
81
- diff chose (`--affected`, a `[ref]` filter) a bare name is judged against
82
- the whole workspace instead, since which projects hold it depends on what
83
- changed: `vx run test --affected` after a commit that changed only a
84
- project without `test` exits 0 with `No affected project declares
85
- task(s): test.`, and a name no project declares is still refused (item
86
- 1024). A change that reaches no requested task exits 0 with `Nothing
87
- affected: the change reaches no test task.` A diff that touched no project stops before that check:
88
- `nothing affected since <ref>` on stderr, exit 0, a typo unseen.
89
-
90
- (No `-V` for version; `vx --version` only — matches Turbo.)
91
-
92
- `vx <verb> --help` (and `-h`, and `vx help <verb>`) prints this reference
93
- cut to that core verb — its usage lines and sections, then
94
- `Full reference: vx help` —
95
- and every argument error points at it. `vx help <name>` for a name that
96
- is no verb here (core, a plugin's, or a moved one) is refused as
97
- `vx <name>` is: one line, a guess when one is close, exit 1.
98
- Past a `--` the flag belongs to the command being run, so
99
- `vx run build -- --help` forwards it to the task instead. A plugin verb
100
- owns its own arguments, `--help` included.
101
-
102
- ## `vx run`
103
-
104
- ```
105
- vx run [OPTIONS] [TASK | PKG#TASK ...] [-- forwarded-args...]
106
- ```
107
-
108
- Run the named task(s). By default only the project containing the
109
- current working directory is selected — `dependsOn` still expands so
110
- the project's upstream workspace deps run too. Override with `--all`,
111
- `--filter`, `--affected`, or an explicit `pkg#task`.
112
-
113
- If no task name is given:
114
-
115
- - **In a TTY** — an interactive picker lists every `pkg#task` entry
116
- across the workspace (only the selected projects' under `--filter` /
117
- `--affected`; `--affected` selecting nothing exits `0` as a run does), prints `description` next to each, prompts
118
- for a number, runs the chosen one. The menu and prompt go to stderr,
119
- so `vx run > out.txt` still asks on the terminal. Ctrl-C at the prompt exits `130`
120
- as an interrupted run does; Ctrl-D exits `1` with `no task picked`.
121
- A workspace with no task exits `1` naming how to declare one (under
122
- `tasks` in a vx.config, or `vx init`).
123
- - **Not a TTY** — exits `1` with
124
- `missing task name (stdin is not a TTY, so no picker; tasks here: build, test)`,
125
- naming the cwd project's tasks, else every project's (twelve, then
126
- `and N more`); outside a workspace it reads `vx run <task>, e.g. vx run build`.
127
- - **Under `--dry=json`**, on a terminal too — the
128
- same refusal (`a JSON answer opens no picker; …`), with the
129
- `VX_E_USAGE` error document on stdout: a program cannot pick.
130
-
131
- Exit codes:
132
-
133
- | Code | When |
134
- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
135
- | `0` | Every task finished `success` or `cache-hit` (local or remote); or `--affected` left no project that declares the task. |
136
- | `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. |
137
- | `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), a 2 s grace 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). |
138
-
139
- A task runs in its own session, so a terminal's Ctrl-C reaches vx alone,
140
- and each task hears it once: from vx, as SIGINT. A stop starts no more remote
141
- cache uploads and does not wait on one in flight.
142
-
143
- Installed from npm, `vx` is a sh launcher that execs the platform
144
- binary, so every signal reaches vx directly and no Node starts first
145
- (a `vx --version` through npm: 14 ms, was 56 ms with the Node launcher, 2026-10-12). Where it
146
- finds no binary beside it (musl, Yarn PnP, a platform with none) it
147
- hands off to a Node launcher. On Node 22.15 or later that one
148
- replaces itself with the binary (`process.execve`) too. On older Node it runs the binary and waits for it:
149
- a signal sent to the launcher alone (`kill`, a process manager) is
150
- passed to the binary, and a terminal's Ctrl-C already reaches both, so
151
- the launcher does not send it twice.
152
-
153
- A reader that leaves does not change the code. `vx run build | head -1`
154
- closes the pipe after one line; the run still finishes, saves what it
155
- built and releases its lock, and exits with its own verdict — the
156
- output after that point goes nowhere (`EPIPE`, on stdout or stderr, is
157
- not an error vx reports). Before 2026-09-16 the same pipeline died with
158
- a stack and exit 1 after its task had succeeded.
159
-
160
- ### Selection
161
-
162
- | Form | Effect |
163
- | ----------------------------- | -------------------------------------------------------------------------------------------------- |
164
- | (default) | The project that contains cwd. Errors if cwd is not inside a project. |
165
- | `pkg#task` | Just that project. |
166
- | `//#task` | The root project's task (Turbo's spelling; the root is a project, D-39). |
167
- | `--all` | Every project that declares the task. |
168
- | `--filter <pat>` (repeatable) | pnpm-style filter DSL (see below). |
169
- | `--affected[=<base>]` | The tasks a git change reaches: the ones it touches and those whose `dependsOn` closure holds one. |
170
-
171
- Combining: every include (`--filter <pat>`, `--affected`) is taken
172
- first and every `!` exclude after them all, as pnpm does, so an
173
- exclude removes what any include added, whichever side of it it sits
174
- (items 955, 979). The union holds for tasks too: `--affected --filter
175
- other` runs other's tasks whether or not the change reaches them
176
- (X-10). `--all` with a filter is the filter's selection:
177
- the filters refine it rather than being overridden by it
178
- (`--all --filter '!docs'` is everything but docs).
179
-
180
- ### Filter DSL (`--filter`)
181
-
182
- The full DSL lives in `src/workspace/filter.ts`; this is the user-
183
- facing summary.
184
-
185
- A filter that matches nothing refuses the run (`no projects matched
186
- filter(s): …`) with `Did you mean <name>?` when a project name is within
187
- two edits, or when exactly one scoped project's name after its `/` is
188
- (`--filter vx-otel` hints `@vzn/vx-otel`), and `Projects: a, b` otherwise
189
- (M-56). A list names eight, then a count. An unmatched `tag:` filter
190
- hints the nearest tag instead (`Did you mean tag:scope:web?`), else
191
- lists the tags. When every pattern matched and an exclusion took back
192
- all of it, the refusal names the exclusion instead (`no projects
193
- selected: !app excluded every project the other filters matched`).
194
-
195
- | Form | Meaning |
196
- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
197
- | `<pattern>` | 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/*`). |
198
- | `./<dir>` | The package at `<dir>` alone, as Turbo and pnpm read it (`.` is the root project); a `<dir>` that is no package matches the packages under it (relative to workspace root; D-43). |
199
- | `{<dir>}` | Same as `./<dir>`. |
200
- | `./<glob>` | 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. |
201
- | `.` | 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. |
202
- | `//` | The root project alone, Turbo's name for it; matches nothing when the root is no project (D-46). |
203
- | `tag:<pattern>` | 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. |
204
- | `<pattern>...` | Match + all transitive dependencies (see below what an edge is). |
205
- | `...<pattern>` | Match + all transitive dependents. |
206
- | `<pattern>^...` | Only the transitive dependencies, excluding the matched package itself. |
207
- | `...^<pattern>` | Only the transitive dependents, excluding the matched package itself. |
208
- | `...<pattern>...` | Match + its dependents + the dependencies of all of them, as Turbo selects (`...db...` takes the packages the apps that use db build on). |
209
- | `<sel>[<ref>]` | The packages `<sel>` (a name pattern or `{<dir>}`) selects that changed since `<ref>`, as Turbo and pnpm read `@scope/*[main]` (D-44). |
210
- | `<sel>...[<ref>]` | The packages `<sel>` selects that changed since `<ref>` or depend on one that did; no dependency is added (Turbo: `@acme/api...[HEAD]` is api when only its dependency changed). |
211
- | `!<pattern>` | Exclude packages matching `<pattern>`, from everything the includes select, in any order. |
212
- | `[<git-ref>]` | Projects whose files changed since `<git-ref>` (`main`, `HEAD~5`, …). |
213
-
214
- An edge is one a plugin supplies — a `discover` entry's `deps` (order
215
- and selection) or `reach` (selection only); the lockfile plugins
216
- (`pnpm()`, `bun()`, `npm()`, `yarn()` from `@vzn/vx-lockfile`) supply
217
- what the package manager installed, and core reads no edge from a
218
- `package.json` — OR a cross-project `dependsOn` entry (`e2e`'s `test: {
219
- dependsOn: ['app#build'] }` makes `e2e` a dependent of `app`). The task
220
- graph knows both, so selection follows both: `vx run test --filter
221
- '...app'` runs `e2e#test` even though no plugin gives `e2e` an edge to
222
- `app`. There
223
- is no `implicitDependencies` field — declare the edge where the task
224
- needs it.
225
-
226
- A filter that names no project (`...`, a bare `!`) is refused, and one
227
- whose pattern matched but whose walk selected nothing says what it
228
- matched: `no projects selected: filter "...^core" matched core, and no
229
- project depends on it` (item 1030).
230
-
231
- Examples:
232
-
233
- ```sh
234
- vx run build --filter @scope/* # all packages under @scope
235
- vx run build --filter app... # app and its transitive deps
236
- vx run build --filter ...util # util and everything depending on it
237
- vx run build --filter app^... # only app's deps (not app)
238
- vx run build --filter '*' --filter '!docs' # everything except docs
239
- vx run build --filter '[origin/main]' # projects with files changed since main
240
- ```
241
-
242
- ### `--affected[=<base>]`
243
-
244
- Run the task only in projects whose files changed since `<base>`.
245
-
246
- - `--affected` (no value) uses the workspace's `run.affectedBase` when it
247
- names one (`nx()` and `turbo()` set it from `NX_BASE` / nx.json's
248
- `defaultBase` and `TURBO_SCM_BASE`, `turbo()` on GitHub Actions from
249
- the pull request's base or the push's `before`, as Turbo does;
250
- [schema](schema.md)), else
251
- `origin/HEAD`; without one (actions/checkout sets none, nor does a
252
- repo with no remote), the first of `origin/main`, `origin/master`,
253
- `main`, `master` that is not HEAD itself, as Turbo and Nx compare with
254
- `main` (D-93); else `HEAD~1`. A clone with neither — a
255
- CI checkout at `fetch-depth: 1` — has no base at all, and vx says so
256
- (`--affected has no base here … a shallow clone?`) instead of failing
257
- on a `HEAD~1` nobody typed. And when the base IS the commit you are
258
- on (a single-branch clone whose `origin/HEAD` is the branch under
259
- test), the `nothing affected since <ref>` note says the ref is HEAD
260
- itself and names the two bases you probably meant
261
- (`--affected=origin/main`, `--affected=HEAD~1`).
262
- - Without git on PATH, every shape is one line — `vx requires git:
263
- failed to spawn 'git' … Install git and re-run` — the same the input
264
- enumeration prints; a minimal image met a stack here before
265
- (2026-09-16). Outside a git work tree every shape says `vx requires
266
- git: <root> is not inside a git work tree`, as a plain run does, and a
267
- repository with no commit yet says so, not "a shallow clone?" (X-52).
268
- - `--affected=<ref>` uses the given git ref. A value that is empty or
269
- starts with `-` is refused before git sees it: the ref is an argument,
270
- never a shell command, and an option-like one (`--output=<path>`)
271
- would be a real `git diff` option. A range (`HEAD~1..HEAD`,
272
- `main...feature`) is refused there too, naming the base to pass
273
- alone — `ranges are not supported — pass the base alone ("HEAD~1")`
274
- — because the other end is always the working tree; `<base>...HEAD`,
275
- Turbo's CI spelling, is read as `<base>`, since vx diffs from the merge
276
- base to a working tree that holds HEAD (D-117). Its two-dot `<base>..HEAD` diffs from
277
- `<base>` itself, not the merge base. An exclusion (`^main`) is refused
278
- the same way, naming `main`. A ref that does
279
- not exist is `git ref "<ref>" did not resolve`; in a shallow clone (CI's
280
- one-commit checkout) it adds that the clone is shallow and how to fetch
281
- the history (`git fetch --unshallow`, `fetch-depth: 0`). A ref naming
282
- a path inside a commit (`main:packages`) is refused, naming the commit
283
- to pass: its paths lack the prefix, so the diff would select the wrong
284
- projects. A root tree (`main^{tree}`) is still a base.
285
- - A member whose directory is a symlink to a place elsewhere under the
286
- workspace root (`packages/b -> ../ext/b`) is selected by a change at
287
- that real place too: git names the files where they live, not by the
288
- link (item 1079). A link to a directory outside the root is outside
289
- git's view, and a change there selects nothing.
290
- - The diff runs from the **merge base** of the ref and `HEAD`, not from
291
- the ref itself, so a branch whose base has moved on sees only its own
292
- changes — never the files other people landed on `main` since it
293
- forked (Turbo and Nx do the same). Refs with no common ancestor diff
294
- from the ref.
295
-
296
- **It selects the tasks the change reaches (owner, 2026-10-04).** A
297
- change seeds tasks in the projects it touches:
298
-
299
- - a cached task when a changed path is one of its declared inputs
300
- (`files`, `workspaceFiles`), or a changed submodule or embedded
301
- repository is one its `workspaceFiles` may reach into (git reports it
302
- as one path; a glob whose fixed prefix is above or inside it counts);
303
- - every task of a project whose `vx.config.*`
304
- changed, or that holds a changed path no task of its declares
305
- (vx cannot prove that path re-keys nothing), or that holds a changed
306
- submodule or embedded repository (git reports it as one path, and no
307
- task's globs can say whether they reach inside), or that a lockfile
308
- claim or a config import names (a dependency an install added or
309
- removed reaches here as the lockfile's change);
310
- - a task with no `inputs` when a changed path lies in its project or the
311
- project is reached whole; a root file another task of its declares
312
- (`workspaceFiles`) is no change there. A group seeds nothing.
313
-
314
- A requested task runs when its `dependsOn` closure holds a seeded task,
315
- so a change reaches another project only along a task edge. With
316
- `app#test` behind `^build`, an edit to `ui/src/button.ts` that
317
- `ui#build`'s inputs read runs `ui`'s tasks and `app#test`; an edit to
318
- `ui/src/button.test.ts` that only `ui#test` reads runs `ui#test` alone,
319
- and `app#lint`, which depends on nothing, never runs for a change in
320
- `ui`. A `pkg#task` you name runs whatever the diff. This is the edge
321
- the cache key folds (a cached `app#test` with no edge to `ui` never
322
- sees `ui`'s files either). A project that declares no `build` gets one
323
- keyed on all its files (`schema.md`), so any change there reaches a
324
- dependant behind `^build`; a package `^name` passes through for want of a
325
- config reaches it the same way.
326
- The graph is the one a `graph` plugin leaves: an edge or an input it
327
- adds reaches a task as a declared one does, and a task it marks
328
- requested runs whatever the diff. With such a plugin every project is a
329
- candidate once the diff touched anything, and its hook sees every
330
- candidate task before the selection prunes the graph.
331
- Nx 23.3 (`NX_LEGACY_AFFECTED=false`) and Turbo
332
- (`affectedUsingTaskInputs`) select tasks the same way, behind flags.
333
-
334
- Until 2026-09-16 the sugar was the changed-only `[<base>]` form (item
335
- 287), and until 2026-10-04 it took every task of every manifest
336
- dependent, which the filter forms still do:
337
-
338
- ```bash
339
- vx run test --affected # the tasks the change reaches
340
- vx run test --filter '...[main]' # what changed + every dependent project
341
- vx run test --filter '[main]' # only the projects that changed
342
- ```
343
-
344
- Its candidate projects are `--filter '...[<base>]'`'s; both are resolved by
345
- `src/workspace/affected.ts`, which unions `git diff` against `<base>`
346
- with `git ls-files --others` so a brand-new untracked source file counts
347
- as a change (input hashing sees it, so `--affected` must too). A
348
- project inside a submodule or an embedded repository is selected when
349
- git reports that repository changed — a dirty or moved submodule
350
- (`vendor/sub`), an untracked embedded repository (`vendor/nested/`):
351
- the workspace repository sees the nested one as a single path (`git
352
- diff --raw`'s gitlink mode, 160000, on either side names one), so a change inside is a
353
- change to it, and every project under it is selected, the project that
354
- holds it included. A repository's own request to hide submodules from a diff
355
- (`diff.ignoreSubmodules`, `submodule.<name>.ignore`) does not apply:
356
- the key sees the change whatever git is told to show.
357
- A symlinked input keys what it points to, so a link whose target
358
- changed (a file the diff names, or one under a linked directory) is a
359
- changed path of its project, and a link to a target git cannot diff
360
- (outside the workspace, or git-ignored) is one on every run.
361
- A gitignored file a task's inputs name outright (`.env.local`,
362
- `dist/**`) never selects the task on its own, as in Nx: `--affected`
363
- reflects only what git sees. The key still hashes it, so a run of the
364
- task misses when it changed.
365
- `.vx/projects.lock.json` is filtered out of the changed set — a `vx lock`
366
- re-write never marks every project affected.
367
-
368
- **A lockfile change selects everything.** The root lockfiles,
369
- `pnpm-workspace.yaml`, `.yarnrc.yml`, `.npmrc`, `bunfig.toml` and the patches `bun.lock` names are folded into the [workspace
370
- fingerprint](./caching.md), which is part of _every_ task's cache key —
371
- so a `bun install` / `pnpm update` invalidates the whole cache. Those
372
- files sit at the workspace root and belong to no project, so mapping
373
- changed paths to project directories would select nothing; `--affected`
374
- widens to every project instead, for the same reason it unions in
375
- untracked files. Only the ROOT copies count: a lockfile vendored inside
376
- a package is not hashed and selects just that package. A lockfile a
377
- plugin CLAIMS (`VxPlugin.fingerprint`, e.g. `pnpm-lock.yaml` under
378
- `@vzn/vx-lockfile`) is the exception on both sides: the key folds what the
379
- plugin says per project, so `--affected` asks the plugin which projects
380
- the change touches — given the bytes at the base ref and in the working
381
- tree — and selects those; only a plugin that cannot tell widens, as
382
- `@vzn/vx-lockfile` does for the install settings it claims (`.npmrc`,
383
- `bunfig.toml`, `pnpm-workspace.yaml`, `.yarnrc.yml`).
384
-
385
- **A dropped edge selects its dependent.** A package the change deleted
386
- (its `package.json` was there at the base and is gone) is no project
387
- now, so its paths map to nothing; and one whose `version` or `name`
388
- moved may no longer satisfy what a dependent declares (`lib: ^1.0.0`
389
- after a bump to 2.0.0). Either drops an edge the dependent's key folded,
390
- while today's graph shows no dependent to walk to. So the package graph
391
- is built again over the changed manifests as the base had them, and
392
- every project whose workspace dependencies differ is selected, and so
393
- is a project whose task names a removed or renamed package in
394
- `dependsOn: ['lib#build']`, an edge the package graph cannot see
395
- (item 1085). An edit to the root manifest's `workspaces` selects every project:
396
- which packages left the workspace is a discovery at the base.
397
-
398
- **A new nested project selects the project above it.** A project's
399
- inputs stop at every project below it, so a `package.json` that makes
400
- an existing directory a project re-keys the project that held it; the
401
- change maps to the new project alone, so the one above it is selected
402
- too. "New" is judged at the base: no manifest there, or one with no
403
- `name`.
404
-
405
- **A workspace config change selects everything.** An edit to
406
- `.vx/workspace.*` selects every project: the `config` and `project` stages its plugins install
407
- shape every resolved config, so the edit can re-key any task, and
408
- selection cannot tell which. It is not in the fingerprint (a key moves
409
- only when a stage's output does). A root file a plugin's stages read
410
- without importing it is seen when the plugin CLAIMS it
411
- (`VxPlugin.fingerprint`): `turbo()` claims `turbo.json` and
412
- `turbo.jsonc`, `nx()` claims `nx.json`, and an edit asks the claimant,
413
- which answers every project (item 961).
414
-
415
- **A file your config IMPORTS selects that project.** vx hashes the
416
- resolved config, so a shared preset a `vx.config.*` imports is part of
417
- the cache key — and selection follows the same rule. Editing
418
- `shared/preset.ts` selects every project whose config imports it,
419
- directly or through another shared file, even though the file belongs
420
- to no project and no `workspaceFiles` glob names it. The scan is
421
- STATIC (nothing is evaluated) and follows RELATIVE specifiers, and a
422
- bare one the nearest tsconfig maps through `paths` or `baseUrl`; any
423
- other bare specifier is a package, and a lockfile change already selects
424
- everything. It crosses project boundaries as the evaluation does: a
425
- config importing `../../packages/lib/preset.ts` is selected when
426
- `preset.ts` or a file it imports inside `lib` changes. Import your
427
- helpers by bare specifier to opt out. See
428
- [`docs/modules/config-imports.md`](./modules/config-imports.md).
429
-
430
- **Nothing changed exits 0.** When the selection comes only from
431
- `--affected` / `[<ref>]` and resolves to zero projects, vx prints
432
- `nothing affected since <ref>` and exits 0 — a docs-only commit must not
433
- fail `vx run lint test build --affected=origin/main`. When the diff
434
- did match and an exclusion or an empty walk took it all back, the exit-0
435
- note says that instead (`no projects selected: !app excluded every
436
- project the other filters matched`), never that nothing changed. A name or path
437
- pattern that matches nothing is still an error (a probable typo), and a
438
- pattern that matches nothing alongside one that matched is warned about
439
- on stderr.
440
-
441
- **An empty selection never cancels an anchored task.** Project scope
442
- applies to bare names only, so `vx run app#deploy build
443
- --affected=origin/main` with nothing changed still runs `app#deploy`
444
- (vx notes `nothing affected since <ref> — running app#deploy only` on
445
- stderr). Only a bare-name-only invocation short-circuits to exit 0.
446
- With only `pkg#task` names the scope goes unused, but `--filter` and
447
- `--affected` are still resolved: a pattern that matches nothing or a ref
448
- git does not know refuses the run as it does beside a bare name.
449
-
450
- ### Argument forwarding (`--`)
451
-
452
- Anything after `--` is forwarded (shell-quoted) to the task's
453
- `exec.command`:
454
-
455
- ```sh
456
- vx run test -- --watch # underlying test runner sees "--watch"
457
- vx run build -- --sourcemap # build command gets "--sourcemap"
458
- ```
459
-
460
- They are appended to the command's last line (trailing blank lines are
461
- dropped first, so a multi-line command's closing newline does not run
462
- them as a command of their own), or before a `#` comment still
463
- open there (`echo args: # show` gets them; with comment-only lines
464
- below a commented line, before the earliest). A heredoc's body and
465
- terminator are not command lines: `cat <<X … X` gets them on the
466
- `cat <<X` line (X-12). A `<<X` in a comment or in quotes opens no
467
- heredoc, and a quote in a body opens no string (X-110). A persistent task gets
468
- them too, with or without a `readyWhen`.
469
-
470
- Forwarded args are folded into the cache key — different args produce
471
- different cache entries. They scope to user-requested tasks only;
472
- dependsOn-pulled deps don't see them (so upstream cache identity
473
- stays clean). A frame's `$ <command>` line shows the command as it
474
- ran, so a requested task's carries the args and a dependency's does
475
- not.
476
- A group (a task with no command) takes none, so a request that names
477
- only groups is refused (`args after \`--\` reach no task`) rather
478
- than run with the args unheard.
479
-
480
- ### Flags
481
-
482
- | Flag | Type | Default | Description |
483
- | ------------------------------------------ | -------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
484
- | `--filter <pattern>` | repeatable | (none) | pnpm-style filter DSL (see above). `--filter=<pattern>` form too. |
485
- | `--all` | boolean | off | Select every project that declares the task. |
486
- | `--affected[=<base>]` | optional value | off | Select the tasks a change since `<base>` reaches: the ones it touches and those whose `dependsOn` closure holds one (default `run.affectedBase`, else `origin/HEAD`, else `main` or `master`, else `HEAD~1`); candidates are `--filter "...[<base>]"`'s. |
487
- | `--exclude-dependencies[=<names>]` | 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). |
488
- | `--concurrency <n>` | int or `<n>%` | `run.concurrency`, else the cores (cgroup-capped) | 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=<n>` form too. |
489
- | `--schedule <rule>` | value | `run.schedule`, else `most-work` | Which ready task starts first when more are ready than workers: `most-work`, `critical-path`, `direct-dependents`, `ready-order` (`docs/schema.md` § `run.schedule`). |
490
- | `--branch <name>` | value | `VX_RUN_BRANCH`, else git's branch, then the CI provider's | The branch the run records in its history and forecasts from. No config key: a committed branch would mislabel every other branch's runs. |
491
- | `--no-cache` | boolean | off | Disable caching entirely (no reads, no writes); output globs are NOT cleaned. |
492
- | `--force` | boolean | off | Re-execute everything (skip cache reads); writes stay on. A key already stored is kept, and a remote that holds it is not sent it again. Output globs are cleaned (so the saved snapshot is clean). |
493
- | `--cache-policy <spec>` (`--cache-policy`) | value | `VX_CACHE_POLICY` / `cache.policy` | Per-layer read/write control. See below. An EMPTY spec (`--cache-policy=`) is a parse error — it applied nothing and left every axis on; pass `--no-cache` to disable them all. |
494
- | `--cache-scope <name>` | value | `VX_CACHE_SCOPE` / workspace `cache.scope` | This run's `cache.scope` (`pr-123`), above `VX_CACHE_SCOPE`, `.vx/workspace`'s and `github()`'s. A name `cache.scope` would refuse is refused. `--cache-scope=<name>` form too. Never folded into a cache key. |
495
- | `--cache-dir <path>` | value | `VX_CACHE_DIR` / `cache.dir` / `~/.vx/<id>/cache` | Cache directory override, resolved relative to cwd (absolute paths used as-is). Beats `VX_CACHE_DIR`, the workspace's `cache.dir` and the default; like them, the directory then holds the whole cache, shared through no store, for every cache the run opens, 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. |
496
- | `--retries <n>` | value | `run.retries`, else `0` | Re-run a failed task up to `n` more times; never a persistent one. A task's own `exec.retries` wins (even an explicit `0`). Never affects cache keys. `--retries=<n>` form too. |
497
- | `--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. |
498
- | `--timeout <duration>` | duration | `run.timeout`, else none | A task's time limit (`30s`, `10m`), for tasks without their own `exec.timeout`, which always wins. A runaway task is killed + `failed`. A bare number is refused naming the forms with units. Never affects cache keys. `--timeout=<duration>` form too. |
499
- | `--output-logs <mode>` | value | `VX_RUN_OUTPUT_LOGS`, else flow-derived | `full` \| `errors-only` \| `hash-only` \| `none` — explicit output override. See § `--output-logs`. `--output-logs=<mode>` form too. |
500
- | `--cache-download <mode>` | value | `cache.download`, else `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 § `--cache-download`. `--cache-download=<mode>` form too. |
501
- | `--verbosity <n>` | int (0+) | `0` | `1` or more prints a per-task table (groups left out) after the framed blocks, above the footer. `--verbosity=<n>` form too. |
502
- | `--dry[=text\|json]` | optional value | off | Print the task graph + predicted cache hit/miss; skip execution. `VX_DEBUG_TIMING=1` prints the stage table here as it does for a run. |
503
- | `--profile[=<path>]` | optional value | off (`profile.json` when set) | Write Chrome-trace JSON of the run's wallclock spans. |
504
-
505
- Mutual exclusion:
506
-
507
- - `--dry` with `--profile` — the profile needs a real run to write
508
- about.
509
-
510
- Unknown flags are a parse error (`unknown flag: --foo`), naming the
511
- nearest flag the verb accepts when one is within two edits
512
- (`unknown flag: --concurency (did you mean --concurrency?)`). Every verb
513
- does this against its own usage line and `--help`: `vx info --formt`
514
- hints `--format`, `vx lock --chek` hints `--check`, `vx upgrade --hlp`
515
- hints `--help`, and `--json` on a verb that takes `--format` hints
516
- `--format json`. `vx version` takes no word, so `vx version --hlp` is
517
- refused (exit 1) where it once printed the version.
518
-
519
- A task typed where the verb goes (`turbo build`, `nx build app`) is
520
- refused with the `vx run` that runs it: `vx build` names
521
- `vx run build --all` from the root and `vx run build` inside a project,
522
- `vx build app` names `vx run build --filter app`, and `vx app#build`
523
- names `vx run app#build`. A typo of a task (`vx biuld`) names the task
524
- and the same `vx run`, unless a verb is as close (`vx rnu` hints `run`).
525
- The default `build` is no declared task, so it is no hint: where it is
526
- the only `build`, `vx build` is an unknown command.
527
- It stays a refusal: a plugin verb of the same
528
- name is the verb, and would change what `vx build` means the day one
529
- was declared.
530
-
531
- **Optional-value flags take their value with `=` only.** `--affected`,
532
- `--exclude-dependencies`, `--dry` and `--profile` are all valid bare,
533
- so a following word is read as a task name — `vx run --affected build`
534
- means "run `build`, affected scope", and there is no way to tell that
535
- apart from "`build` is the git base". Write `--affected=origin/main`. Getting it wrong is loud, not silent: the value
536
- becomes a positional that matches no project, so the run refuses to
537
- start (see "Every requested name must resolve" above).
538
-
539
- `--exclude-dependencies=` with an EMPTY value is rejected rather than
540
- guessed — "drop every edge" and "drop none" are both plausible readings.
541
- Pass bare `--exclude-dependencies` for the first, omit the flag for the
542
- second.
543
-
544
- Value flags (`--filter`, `--concurrency`, `--output-logs`,
545
- `--verbosity`, `--cache-dir`, …)
546
- accept both `--flag value` and `--flag=value`. In the space form,
547
- `--cache-dir` rejects a value
548
- starting with `-`: that is always a swallowed flag (an unquoted empty
549
- shell variable), never a path or task id. Use the `=` form for a literal
550
- leading dash.
551
-
552
- **Numeric flags take a plain decimal integer.** `--concurrency`,
553
- `--retries` and `--verbosity` reject hex (`0x10`), exponent (`1e3`),
554
- fractional (`2.7`), signed (`+4`) and space-padded forms, plus anything
555
- past `2^53` (it would parse to a number you did not type). These all
556
- used to be silently reinterpreted — `--concurrency 0x10` ran 16
557
- workers. A duration flag (`--timeout`) takes its unit (`500ms`, `30s`,
558
- `10m`): a bare `500` is refused, naming both readings.
559
-
560
- An empty `=` value means "no value" on two flags, which then act like
561
- their bare forms: `--profile=` writes `profile.json` and
562
- `--affected=` uses the default base. Every other flag refuses an empty
563
- value: `--dry=`, `--continue=` and
564
- `--exclude-dependencies=` as well as the ones with no bare form
565
- (`--retries=`, `--timeout=`, `--cache-dir=`, `--filter=`, `--cache-policy=`).
566
-
567
- #### Cache control: `--cache-policy`, `--no-cache`, `--force`
568
-
569
- The cache has four independent axes — **localRead**, **localWrite**,
570
- **remoteRead**, **remoteWrite** — and the three flags above resolve
571
- them in this precedence order:
572
-
573
- 1. Start with every axis **on** (the default).
574
- 2. Apply each `--cache-policy=<spec>` segment (the base), or, with no
575
- `--cache-policy`, the `VX_CACHE_POLICY` environment variable's spec, or, with
576
- neither, `.vx/workspace`'s `cache.policy` (`docs/schema.md`).
577
- 3. If `--no-cache` was passed, force **all four off**.
578
- 4. If `--force` was passed, force **both reads off** (writes stay
579
- whatever the base / `--cache-policy` left them).
580
- 5. Off CI (`CI` unset, `0` or `false`), unless `--cache-policy`,
581
- `VX_CACHE_POLICY` or `cache.policy` names the remote, force
582
- **remoteWrite off**: a key holds only declared inputs, so a laptop's
583
- write could replay on CI. `cache.scope` never changes an axis.
584
-
585
- A spec that names a remote axis (`remote:r`, `remote:w`, `remote:rw`)
586
- in a workspace whose plugins supply no remote layer gets one status
587
- line saying so — the axes are inert without a layer to serve them, and
588
- a CI job that believes it is filling a shared cache should be told.
589
-
590
- Whatever the axes, a lookup asks the local cache first, then each
591
- remote in the order the plugins declare them.
592
-
593
- So `--no-cache` always wins over `--force`. The common cases:
594
-
595
- - `--no-cache` → nothing reads, nothing writes, and declared output
596
- globs are left untouched (you're debugging; vx won't manage your
597
- tree).
598
- - `--force` → re-execute every task (reads off) with writes on. An
599
- entry is immutable: a key the local cache already holds is kept, and
600
- each remote that can batch (`hasMany`) is asked once, in the
601
- background, which keys it holds; one that cannot is asked `has` per
602
- key before its upload. Held keys are not uploaded again (a probe that
603
- fails uploads, as before). Output globs ARE cleaned before each task,
604
- so the disk holds this run's outputs.
605
-
606
- `--cache-policy=<spec>` is a comma-separated list of `<layer>:<flags>`
607
- segments. `layer` is `local` or `remote`; `flags` is any subset of `r`
608
- (read) and `w` (write), order-independent and possibly empty. A
609
- **mentioned** layer is set EXACTLY to its flags; an **unmentioned**
610
- layer keeps its current value. Both `--cache-policy=<spec>` and the space form
611
- `--cache-policy <spec>` are accepted.
612
-
613
- | Spec | Effect |
614
- | ---------------------------------- | ------------------------------------------------------- |
615
- | `--cache-policy=local:rw,remote:r` | remote read-only (won't upload); local full |
616
- | `--cache-policy=local:r` | local read-only; remote untouched (still full) |
617
- | `--cache-policy=remote:` | remote fully off; local untouched |
618
- | `--cache-policy=local:,remote:rw` | don't touch the local cache, but still upload to remote |
619
-
620
- `local:` means "don't serve hits out of the pre-existing local cache" —
621
- a remote hit is still delivered (the artifact has to land on disk to be
622
- extracted), it just never short-circuits the remote read.
623
-
624
- Combine with `--force` for "re-execute and fill only the remote":
625
- `--cache-policy=local: --force` (local off, reads off, remote write-only;
626
- keys the remote already holds are not sent).
627
-
628
- Invalid layers/flags are a parse error
629
- (`--cache-policy: invalid layer 'disk'`, `--cache-policy: invalid flag 'x'`, …);
630
- `VX_CACHE_POLICY` and `cache.policy` say their own name instead.
631
-
632
- ### Output
633
-
634
- What a run prints is derived from the run's intent (its "flow"),
635
- unless explicitly overridden:
636
-
637
- - **FOCUSED** — no selection flag was passed. The user is running
638
- "their" task; cwd and task count are irrelevant to the
639
- classification.
640
- - **BROAD** — the invocation used `--all`, `--filter`, or
641
- `--affected`. The user asked about a swath of the workspace and
642
- wants news, not output.
643
- - **CI** — the `CI` env var is truthy (`CI=0` / `CI=false` don't
644
- count). Wins over the flow.
645
-
646
- Reported task lines share one column grid —
647
- `<glyph> <time> <status> <cache> <name>` — with two orthogonal axes:
648
- the glyph SHAPE encodes the cache axis, the glyph COLOR (and the
649
- status word) the task axis.
650
-
651
- | Glyph | Cache axis | Status word |
652
- | ----- | -------------------------- | ----------- |
653
- | `⏺` | miss — the task ran | success |
654
- | `►` | fresh (up-to-date) | success |
655
- | `⇢` | restored from local cache | success |
656
- | `⇣` | restored from remote | success |
657
- | `◼` | failed | failed |
658
- | `⊘` | skipped (blocked upstream) | skipped |
659
- | `▸` | persistent (dev server) | running |
660
-
661
- A live WORKER row carries no glyph: the ticking elapsed time leads it,
662
- which is the motion the run has instead of a spinner.
663
-
664
- Per-task visibility by outcome. Each cell is the SHAPE of what prints —
665
- `silent`, `one-liner`, `frame`, or a conditional; the table is pinned to
666
- the renderer by `tests/output-doc-drift.test.ts`, so the vocabulary is
667
- fixed:
668
-
669
- | Outcome | focused (requested task) | focused (dependency) | broad | CI / `full` |
670
- | ---------------------- | ------------------------ | ------------------------- | ------------------------- | ---------------------------- |
671
- | executed | frame | silent | one-liner | frame |
672
- | restored-local/-remote | frame | silent | silent | frame, or one-liner if quiet |
673
- | up-to-date | frame | silent | silent | frame, or one-liner if quiet |
674
- | failed | frame | one-liner + frame replays | one-liner + frame replays | frame |
675
- | skipped | one-liner | one-liner | one-liner | one-liner |
676
-
677
- What the shapes mean in each column:
678
-
679
- - **focused, requested task.** The frame is LIVE when it is the only
680
- requested task: `┌─` prints at task start, the command's output (or a
681
- hit's replayed stdout) streams raw between the brackets, `└─` closes
682
- it. With several requested tasks the same frame is buffered and
683
- emitted atomically (see below). Every outcome that ran or was cached
684
- gets one — including an `up-to-date` hit, whose frame carries the
685
- `$ cmd` line so a requested task looks the same whether it ran or not.
686
- - **skipped** is the exception: it never started, so no frame was opened,
687
- and it produced nothing a frame could hold. The one-liner says
688
- everything, the blocker included (`⊘ skipped app#deploy • blocked by
689
- lib#build`; a fail-fast skip, which nothing blocked, carries no
690
- suffix). Every view that lists tasks prints it, a dependency's
691
- included: nothing prints below the footer.
692
- - **`frame, or one-liner if quiet`** — a cache hit with stored stdout is
693
- worth a frame (the output is the point); a hit with nothing to replay
694
- compresses to one line, which is what keeps a 2000-task warm run
695
- readable.
696
- - **`one-liner + frame replays`** — the `◼ … failed` line prints
697
- immediately; the full frame is held and replayed at run end.
698
-
699
- When a dependency fails mid-run, the stream gets ONE permanent
700
- `◼ … failed miss <id>` line and the run continues; **all full failure
701
- frames replay together at run end**, right above the summary, and are
702
- never capped.
703
-
704
- The end-of-run summary always prints; cache-hit counts that broad
705
- mode silences per-task surface there. A focused `vx run test` is meant
706
- to feel like running the test command directly — same output, just
707
- faster.
708
-
709
- **Groups are transparent folders.** A group task (no `exec`, just
710
- `dependsOn`) has no output of its own, so running one focused —
711
- `vx run build` where `build` chains `build.bun` which chains
712
- `build.bun.darwin-arm64` … — surfaces the **real tasks** it stands for
713
- and shows them like requested tasks. The walk descends through nested
714
- groups but never leaves the requested project (`^`/cross-project deps
715
- aren't surfaced) and never goes past a real task into its own deps.
716
- The requested count for the live-vs-buffered decision counts the
717
- surfaced tasks: one real task streams live, several buffer into atomic
718
- blocks. (Surfacing is display-only — it does not make those tasks
719
- "requested", so `--` `forwardArgs` still go only to what you named.)
720
-
721
- Live streaming applies only when there is exactly **one** requested
722
- task. Live open/close framing assumes a single task owns the terminal
723
- between its open (`┌─`) and close (`└─`) lines — with two requested
724
- tasks running concurrently their frames would interleave into garbage.
725
- So when more than one task is requested (`vx run build test`), each
726
- requested task instead **buffers** its output and renders as a single
727
- atomic block at completion. The shapes are the ones in the table above —
728
- anything that ran or was cached gets a full frame, `up-to-date`
729
- included; only `skipped` is a one-liner. The blocks are blank-line
730
- separated and never interleave. A single `vx run test` keeps the
731
- live-stream experience unchanged.
732
-
733
- On an interactive terminal (TTY stdout, not CI) a status region
734
- tracks the run live. Top to bottom:
735
-
736
- 1. **A blank separator line** — keeps the live region visually apart
737
- from the completed-task scrollback above it.
738
- 2. **Pinned persistent tasks** — `▸ <id> running` for every persistent
739
- task that became ready. The pin lives until run end, so it is the
740
- visible evidence the dev server is still alive. After the summary,
741
- a requested persistent task keeps vx in the foreground, with the
742
- persistent tasks it depends on, until it — or, with several, the
743
- first of them — exits (one kept only as a dependency that exits 0,
744
- a daemon that forked and returned, does not end it); the rest still up are then torn
745
- down (SIGTERM, a 2 s grace, SIGKILL), one status line names
746
- the task and its code (`vx: app#dev exited with code 1; stopping 1
747
- other persistent task`), and a non-zero exit makes the run exit 1
748
- and is what `vx last` records.
749
- A Ctrl-C prints no such line: the server ended because it was
750
- stopped, and vx exits 130. A run with a failure elsewhere (a task
751
- failed or skipped, a server never ready or crashed) holds nothing: it
752
- stops its servers and exits 1, unless `--continue=always`; `vx watch`
753
- keeps its server through a failed cycle. While vx holds them, what
754
- the servers write streams below the summary a line at a time under
755
- each one's id (`app#dev │ Local: http://localhost:5173`), except
756
- under `--output-logs errors-only`, `hash-only` or `none`; a single
757
- requested server in the focused flow streams raw from its frame.
758
- 3. **Worker rows** — one per worker slot that has been busy (at most
759
- `min(concurrency, 10)`), no glyph and no spinner: the live ticking
760
- elapsed time leads (` 568ms running <id>`). A task stays in
761
- its row for its whole life, so nothing jumps; overflow shows as
762
- `+k more`. A second in, an idle worker row holds a task an
763
- unfinished dependency blocks, one per row, in the order the
764
- scheduler starts them (most dependents first):
765
- `blocked solid-js#build by solid-js#types` (dim `blocked`, the id,
766
- then the running task that holds it back). The task starts in the
767
- row that held it. A row with no blocked task left to hold leaves the
768
- region and the rows below close up; blocked tasks only dwindle, so
769
- it never comes back. A running row says what waits on it:
770
- `critical path` (yellow) on the running task
771
- the longest unfinished chain waits on, by the same p50s as the
772
- forecast below and only while two or more run, and a dim
773
- `blocks N`, its unfinished dependents direct and transitive. Where
774
- local history knows the task, its row also shows its usual duration
775
- (its executed p50) in the cache column, fixed while the elapsed time
776
- counts up toward it (`3.33s running ~5.2s solid-js#types`).
777
- 4. **vx's own steps** — what vx does around the tasks that the run
778
- waits on, before its first task starts or before it ends, as rows of
779
- their own below the worker rows: `vx:load` (configs and graph from
780
- the lock), `vx:run-lock`, `<plugin>:setup`, `vx:keys` (keys and local
781
- probes), `<plugin>:schedule`, then after the graph `vx:uploads`,
782
- `vx:history`, `<plugin>:telemetry`, `<plugin>:teardown`,
783
- `vx:snapshots`, `vx:retention`, `vx:close` and `vx:sandbox`. A step
784
- shows once it has run 150 ms, ticking like a task
785
- (` 1.2s running internal vx:uploads`); no worker slot or
786
- `--concurrency` holds it back. When it ends it leaves a list row
787
- like a task's, `internal` in the cache column
788
- (`⏺︎ 4.60s success internal vx:uploads`); a quicker one shows
789
- nowhere. A step that runs beside the tasks, `vx:prefetch` (a
790
- remote's downloads), never shows: `vx last` lists it. `:` is no task
791
- id's separator, so a step never reads as a task: a project named
792
- `vx` keeps `vx#build`. No step is a task: no count, bar, history
793
- row, forecast, flaky verdict or telemetry record holds one;
794
- `--profile` and `VX_DEBUG_TIMING` time vx's stages their own way.
795
- Steps show only beside a live region; a log (CI, a pipe) prints
796
- none.
797
- 5. **The live summary section** — the SAME meters the final footer
798
- prints (`tasks` + `cache` bars with legends, `time`), filling in as
799
- the run progresses, under a bare `vx` wordmark rule. A second in,
800
- the `time` row adds what is left (`time 1.41s · ~3s left`): each
801
- unfinished task's executed p50 from local history, the longer of
802
- the longest unfinished chain and that work spread over the
803
- workers, a running task's spent time taken off its own. It says
804
- nothing when history knows none of what is left, or once a running
805
- task is 25% past its p50. A run that ends inside the second never
806
- reads history for it, and the final summary never shows it. A p50
807
- here is the median of the task's last five timings on this branch,
808
- else on any branch; the timings are shared by every worktree of the
809
- repo, so a new one has a forecast on its first run.
810
-
811
- In a terminal known to read them, the region also sends zero-width
812
- escapes: tab or taskbar progress (OSC 9;4: Ghostty, iTerm2 3.6+,
813
- Windows Terminal, ConEmu), filling by the forecast or else by tasks
814
- done, red once a task fails, and cleared when the region goes; and a
815
- run of 10 s or more ends with a desktop notification (OSC 9: Ghostty,
816
- iTerm2, WezTerm), `vx: 12 tasks done` or `vx: 1 of 12 tasks failed`.
817
- The terminal is named by `TERM_PROGRAM` (or `WT_SESSION`,
818
- `ConEmuPID`); any other, tmux included, gets neither, since there
819
- OSC 9 may itself be a notification.
820
-
821
- In a terminal known to open OSC 8 links (Ghostty, iTerm2 3.1+, WezTerm,
822
- VS Code, Windows Terminal, kitty, Konsole, VTE terminals; never inside
823
- tmux or screen), a failed task's frame links each path that names a
824
- file in the task's project, `src/a.ts:12:5` or `src/a.ts(12,5)`, to
825
- that file, so a click from the workspace root opens the right one. The
826
- text is the task's output unchanged; a successful task's frame is never
827
- linked.
828
-
829
- The region is redrawn in place (cursor-up + clear; not a TUI — no
830
- alternate screen) and erased before the final summary prints. On a
831
- terminal shorter than the region, it shows only the rows that fit. In the
832
- focused flow it only lives while dependencies run; it disappears for
833
- good the moment the requested task starts streaming.
834
-
835
- Redraw cost is bounded: task events force a redraw, but forced
836
- redraws within 30 ms of the last draw coalesce into a single
837
- trailing draw when the floor expires (the final state always lands).
838
- On a 3,270-task warm run this cuts ~6.7 MB of redraw ANSI to ~20 KB.
839
-
840
- Identity coloring: each half of a `project#task` renders in a stable
841
- hue hashed from its own name (same project = same color, same target
842
- name = same color, in every run and every surface: every `#build`
843
- matches), with a dim `#` between, all outside the status palette, so
844
- an id can never read as an outcome.
845
-
846
- On GitHub Actions (`GITHUB_ACTIONS` truthy, full output mode), each
847
- task's block is wrapped in `::group::<id> (<outcome> <duration>)` /
848
- `::endgroup::` so it collapses in the log viewer. Failed tasks stay
849
- pre-expanded and emit an `::error title=<id>::failed (exit N)`
850
- annotation instead (above 128 the label names the signal:
851
- `failed (exit 137, 128 + SIGKILL)`, a timeout its reason,
852
- `failed (timed out, exit 143)`, a persistent task that never became
853
- ready its reason, and a sandboxed task its violation count, as every
854
- surface labels a failure). In every output mode there, a task's own
855
- text (a frame, a server's output since ready) is fenced in
856
- `::stop-commands::`, so a line it prints never becomes a workflow
857
- command.
858
-
859
- ### `--output-logs <mode>`
860
-
861
- Explicit override; always beats the flow and CI defaults. `full`
862
- (frames for executed work, one-liners for quiet cache hits),
863
- `errors-only` (only failed tasks print, a server that died on its
864
- own after ready included, with what it wrote since; the CI noise
865
- budget),
866
- `hash-only` (one line per task — outcome word, task id, cache key — and
867
- no log output at all; the run's audit trail of which key each task
868
- resolved to, Turbo parity), `none` (no per-task output). The
869
- end-of-run summary always prints.
870
-
871
- ### Failure propagation — `--continue`
872
-
873
- `--continue[=never|deps-ok|always]` controls what a failed task takes
874
- down with it:
875
-
876
- - **`deps-ok`** (default): the failure's transitive dependents are
877
- skipped; independent siblings keep running. A server that dies after
878
- it became ready is a failure to its dependents not yet started,
879
- including those that reach it through a group.
880
- - **`never`**: fail fast — the first failure stops dispatch. In-flight
881
- tasks finish their attempt and start no retry; everything not yet started (cache restores
882
- included) completes as skipped. A server that dies after it became
883
- ready stops dispatch the same way.
884
- - **`always`** (bare `--continue`): dependents run even when an
885
- upstream failed — to surface every failure in one pass. A task
886
- downstream of a failure (directly, or through successes built on it)
887
- runs and cleans its outputs as usual but is **never saved**: under
888
- pure-input hashing its key is the one a healthy run derives, while its
889
- bytes were built on a partial tree, so caching it would hand the next
890
- clean run a stale hit. A server that died after it became ready is
891
- such a failure to what starts after its death. A cache hit still
892
- restores (that artifact came from a healthy run), and the next run
893
- without the failure rebuilds the rest.
894
-
895
- The mode is the local scheduler's: a task a plugin executor runs
896
- elsewhere is one dispatch, failed or not, like any other.
897
-
898
- ### `--cache-download <mode>`
899
-
900
- Where a **remotely-executed** task's outputs land. `all` (default)
901
- downloads every task's outputs to this machine as it completes —
902
- today's behaviour, byte for byte. `toplevel` brings home only the tasks
903
- you actually asked for — including the real tasks behind a requested
904
- group — and leaves intermediates remote. `none` leaves
905
- them in the remote CAS
906
- and fetches them **lazily**: only when a locally-placed task in the
907
- same run actually needs them (Bazel calls this "build without the
908
- bytes"). A CI job that only wants the verdict moves no output bytes at
909
- all.
910
-
911
- Locally-executed tasks always write in place and ignore the flag, and
912
- `exec.remote: 'only'` still means never — `--cache-download` cannot override
913
- it in either direction. **It never affects a cache key**: transfer
914
- tuning cannot change what a command produces.
915
-
916
- One safety gate: a task whose outputs another task's `inputs`
917
- globs could read on disk is silently kept eager, because deferring it
918
- would make that key depend on whether the bytes arrived. A task with no
919
- `cache` that a cached task depends on counts as reading its whole
920
- project (C-16). `--dry` reports
921
- how many tasks would keep their outputs remote and names each downgrade,
922
- so a run that defers nothing says why. A run in which any task declares a
923
- `inputs.runtime` / `workspaceRuntime` command defers **nothing**:
924
- a shell command's reads cannot be bounded, so vx cannot prove it will
925
- not read a deferred output (the same reason vx refuses to infer inputs
926
- by tracing). When bytes are fetched later, vx saves an ordinary
927
- cache entry for them, so the next run is a plain local hit.
928
-
929
- ## Planning mode (`--dry`)
930
-
931
- `--dry` short-circuits execution. It builds the full task graph,
932
- compute every task's cache key, and probe the cache to predict the
933
- hit/miss outcome. Against a remote cache the probe is a lightweight
934
- existence check — planning never downloads or ingests artifacts.
935
-
936
- ```
937
- $ vx run ci --dry
938
- would run:
939
- ✓ @vzn/vx#format-check cache hit (local) 02bfe8a9
940
- ✓ @vzn/vx#lint cache hit (local) d66cfed2
941
- ▶ @vzn/vx#test cache miss — would exec 68595e49 ~72.64s
942
-
943
- 3 task(s) planned, 2 cache hits (2 local), 1 would run.
944
- predicted: ~72.64s wall · ~72.64s total execution
945
- ```
946
-
947
- **Time prediction.** A would-run task with recorded history shows its
948
- typical executed duration (`~p50` over its recent non-hit runs in the
949
- local `cache.db`). The footer predicts the run: `wall` is the longest dependency
950
- chain of would-run cost (cache hits restore near-instantly and count
951
- as 0), `total execution` is the sum across would-run tasks. Tasks with
952
- no history count as 0 and are called out (`N tasks without history
953
- (+?)`) — the totals are honest lower bounds. The footer is omitted
954
- when nothing would run or when no would-run task has history.
955
-
956
- **Placement.** When the workspace's plugins supply more than one
957
- executor, each row carries the one the task would land on (`@spy-remote`;
958
- `@local` for the floor; `@noop` for an `exec.remote: 'only'` task no
959
- remote accepts, which the run would skip rather than run here) and the
960
- JSON object carries it as `executor`. With one executor there is
961
- nothing to choose and the column is absent, except `@noop`, which a
962
- remote-only task carries whatever the count; it costs nothing in the
963
- prediction. If an executor hook throws
964
- or returns something that is not an executor, the plan still prints —
965
- `--dry` never fails over a label — but says so on the status line, in
966
- the plugin's name: `[vx] placement not shown — plugin 'x' … (the run
967
- would refuse on it)`.
968
-
969
- Status legend:
970
-
971
- | Symbol | Meaning |
972
- | ------ | ------------------------------------------------------------------------------------------ |
973
- | `✓` | cache hit (local) — entry already in `<cacheDir>/` |
974
- | `↓` | cache hit (remote) — entry would be fetched from the layer |
975
- | `▶` | cache miss — task would execute (under `--force` too: nothing is read, what runs is saved) |
976
- | `∅` | no-cache — task opts out (no `cache` block, or `--no-cache`) |
977
- | `·` | `@noop` — task would not run anywhere |
978
- | `−` | not started — a server you did not request whose every dependant restores a local hit |
979
- | `○` | group task (suppressed in human view; in DOT + JSON) |
980
-
981
- `--dry=json` emits the same data as a structured object, alone on stdout
982
- (a stage's warnings go to stderr); `schemas/plan.json` is its JSON
983
- Schema, held like the read verbs' (see Machine-readable output):
984
-
985
- ```json
986
- {
987
- "tasks": [
988
- {
989
- "id": "@vzn/vx#lint",
990
- "project": "@vzn/vx",
991
- "task": "lint",
992
- "description": "oxlint with tsgolint-backed type-aware checks",
993
- "hash": "d66cfed2...",
994
- "cacheStatus": "hit-local",
995
- "deps": [],
996
- "p50Ms": 72640
997
- }
998
- ],
999
- "predicted": { "wallMs": 0, "workMs": 0, "unknownCount": 0, "criticalPath": [] }
1000
- }
1001
- ```
1002
-
1003
- Every task with history carries `p50Ms`, hits included (only the text
1004
- view's `~p50` is limited to tasks that would run); `predicted` counts
1005
- would-run tasks only and is present whenever local history was
1006
- readable. Its `criticalPath` names the would-run tasks with history on
1007
- the chain `wallMs` measures, dependencies first: the tasks to speed up
1008
- to shorten the run.
1009
-
1010
- ### Why a task is affected (`affected.kind`)
1011
-
1012
- Under `--affected`, each requested task the diff kept gets a line, and
1013
- its JSON object an `affected` reason:
1014
-
1015
- ```
1016
- $ vx run build lint --affected=main --dry
1017
- ▶ app#build cache miss — would exec …
1018
- affected: packages/lib/src/a.ts changed (an input), via lib#build
1019
- · lib#lint no-cache …
1020
- affected: packages/lib/src/a.ts changed (in its project)
1021
- ```
1022
-
1023
- `kind` is `input` (a changed `file` is a declared input), `project` (its
1024
- project changed and the task reads it whole: a task with no `inputs`, or a
1025
- changed `file` no task of the project declares, often an output git does not
1026
- ignore; no `file` for a config import), `workspace` (a workspace-wide
1027
- `file` changed: the workspace config, or a root file a plugin claims such
1028
- as a lockfile, reached every task of the project),
1029
- `package` (a `^` edge passes a changed `project` with no such task),
1030
- `named` (`pkg#task` beside a bare task) or `selected` (another filter
1031
- selected its project). `via` is the `dependsOn` chain, nearest first, to
1032
- the task the change reached. A dependency the kept tasks pull in carries
1033
- none. The reasons are gathered only for a plan; a run pays nothing.
1034
- `--dry=json` also states `affectedBase`, the ref the diff was taken
1035
- against: a bare `--affected` guesses it (`origin/HEAD`, a trunk branch,
1036
- else `HEAD~1`), so a reason that names no file may come from a commit
1037
- the base reaches, a root `package.json` edit among them.
1038
-
1039
- ## Run artifacts (`--profile`)
1040
-
1041
- `--profile` adds a side-effect after a real run completes. Errors
1042
- writing the artifact are surfaced via `vx: failed to write …` but
1043
- don't change the run's exit code — the run already happened.
1044
-
1045
- ### `--profile[=<path>]`
1046
-
1047
- Writes a Chrome-trace JSON of every task's wallclock span. Open in
1048
- `chrome://tracing` or https://ui.perfetto.dev.
1049
-
1050
- ```json
1051
- {
1052
- "traceEvents": [
1053
- {
1054
- "name": "@vzn/vx#lint",
1055
- "cat": "success",
1056
- "ph": "X",
1057
- "ts": 12345,
1058
- "dur": 4321,
1059
- "pid": 1,
1060
- "tid": 1,
1061
- "args": {
1062
- "exitCode": 0,
1063
- "hash": "...",
1064
- "cpuMs": 123,
1065
- "peakRssBytes": 45678
1066
- }
1067
- }
1068
- ]
1069
- }
1070
- ```
1071
-
1072
- Each project gets a distinct `tid` so concurrent tasks across packages
1073
- render on separate lanes. `ts` and `dur` are microseconds derived
1074
- from the per-task `hrtime.bigint()` spans the runner captures.
1075
- `cat` carries the task's final status (`success`, `cache-hit`,
1076
- `cache-hit-remote`, `failed`).
1077
-
1078
- Default path: `profile.json` (cwd-relative).
1079
-
1080
- ## Sandbox
1081
-
1082
- Sandbox isolation is opt-in **per task** via an `exec.sandbox` block in
1083
- the task's config — there is no `--sandbox` CLI flag. See
1084
- [`modules/sandbox-runtime.md`](./modules/sandbox-runtime.md) for the
1085
- full reference.
1086
-
1087
- ```ts
1088
- // vx.config.ts
1089
- export default {
1090
- tasks: {
1091
- build: {
1092
- exec: {
1093
- command: 'tsc',
1094
- sandbox: {
1095
- allow: {
1096
- read: ['.'],
1097
- write: ['dist/**'],
1098
- },
1099
- },
1100
- },
1101
- inputs: { files: ['src/**'] },
1102
- cache: { outputs: { files: ['dist/**'] } },
1103
- },
1104
- },
1105
- }
1106
- ```
1107
-
1108
- The grants are the task's whole permission surface — nothing is derived
1109
- from `cache`, and the only thing core adds is `node_modules` plus the
1110
- workspace packages linked there, never a link back to the task's own
1111
- project (npm and Yarn link that too). For a task that declares `cache`
1112
- a linked package is granted only when its key folds a task of that
1113
- package; declaring `cache` narrows core's grant and widens nothing.
1114
- Enforcement anchors at the workspace root (a task never leaves its
1115
- project); only denials inside the project, or under a withheld linked
1116
- package, are reported.
1117
-
1118
- Policy: **fail on violation.** Any task that touches a path it didn't
1119
- declare either fails naturally (Linux: `ENOENT` from bwrap's
1120
- mount-namespace hide) or is flagged via the macOS violation store
1121
- and forced to exit non-zero. No cache is written for a failed task. The
1122
- task's `SANDBOX VIOLATIONS` section ends with a dimmed `fix:` line,
1123
- the grants that would have allowed each denial, project-relative:
1124
- `fix: exec.sandbox.allow: { read: ['tsconfig.json'], write: ['dist/'] }`.
1125
- A write grants the top directory it landed in, so the next file there
1126
- passes too, unless `inputs.files` reads that directory: then it
1127
- 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
1128
- the line says so, since a grant never adds one. A credential path
1129
- (`~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.kube`, `~/.config/gh`,
1130
- `~/.config/gcloud`, `~/.netrc`, `~/.npmrc`, `~/.pypirc`,
1131
- `~/.git-credentials`, `~/.docker/config.json`, a `.env` file) is never put in the
1132
- line: an agent applies it as given, so it is named apart as one to
1133
- grant only on purpose.
1134
- "Didn't declare" means the grants, which on Linux are wider than they
1135
- read: a file-shaped write grant is bound as its whole directory, so
1136
- every file beside it is readable without a violation — `schema.md`
1137
- § `exec.sandbox` has the shape and the remedy (outputs in a
1138
- subdirectory).
1139
-
1140
- `vx run` arms the sandbox runtime when a task that declares
1141
- `exec.sandbox` is about to execute; a cache hit needs none. If the
1142
- platform cannot host it (Linux needs `bwrap`, `socat` and `rg` on PATH;
1143
- macOS, `sandbox-exec`), that task fails with one line, `sandbox not
1144
- available: <reason>`, and never runs unsandboxed. Tasks without a
1145
- sandbox run as usual, and the failure's dependents follow `--continue`.
1146
-
1147
- ## `vx watch`
1148
-
1149
- ```
1150
- vx watch [OPTIONS] [--poll] TASK [-- forwarded-args...]
1151
- ```
1152
-
1153
- Run the named task, then re-run it on every filesystem change in the
1154
- projects in scope. Press `Ctrl+C` to stop.
1155
-
1156
- ```sh
1157
- vx watch test # cwd project; re-test on changes
1158
- vx watch test --all # every project that declares `test`
1159
- vx watch lint --filter '@scope/*' # filtered scope
1160
- vx watch build -- --sourcemap # forwarded args carry through every cycle
1161
- ```
1162
-
1163
- ### Lifecycle
1164
-
1165
- 1. **Initial run.** Same code path as `vx run` — same scope resolution,
1166
- same task graph, same cache behaviour. The line `vx watch: initial
1167
- run...` precedes it.
1168
- 2. **Watch loop.** After the initial run finishes (or once it waits only
1169
- on a server's readiness, X-139), the directory of
1170
- every project a cycle can run — the scope (the bare tasks' scope
1171
- plus each `pkg#task`'s own project; only those when every task is
1172
- anchored) plus its transitive dependencies, the closure `--filter 'app...'` walks, cross-project
1173
- `dependsOn` edges included — is watched recursively, less any project
1174
- nested inside it, with a config or not (a root project's key leaves a nested
1175
- project's files out, so an edit there is no cycle; X-42, X-57). The workspace root is
1176
- watched (non-recursively) for a claimed file (a lockfile), for
1177
- `.vx/projects.lock.json` and for an edit to `.vx/workspace.*` — the one root file that
1178
- shapes a run (plugins, `config` stage, concurrency) without being any
1179
- task's input; the cycle after it re-evaluates the file. A `.gitignore`
1180
- there or in a project's parent directory is a cycle too: inputs are
1181
- gitignore-aware, so it changes what keys read (X-136). A file a
1182
- config imports by relative path from outside the watched projects (a
1183
- shared preset) is watched too, and its edit is a cycle that re-reads
1184
- the configs; so is one inside a project, which re-reads the set as the
1185
- config's own edit does (WD-14). A file the workspace config imports is loaded once per
1186
- process, so its edit is named with the restart it needs rather than
1187
- run stale (item 949). An import whose file is gone stays on the list
1188
- by the name it would have, so its return is a cycle (X-133). The
1189
- projects are the lock's: a new `.vx/projects.lock.json` is the
1190
- event that adds or drops one, and its directory is watched
1191
- from before that cycle runs, so an edit made during it queues
1192
- (X-138). A config added and not locked is no project yet. With no
1193
- lock at start the first cycle fails saying so, and the watch waits
1194
- for one. A project directory, or one a config imports from,
1195
- removed and made again (a checkout that restores `packages/`) is
1196
- heard from its nearest directory that exists and each one above it,
1197
- and a watch whose
1198
- directory was replaced is armed again (an OS watch holds the deleted
1199
- one; `watch-recreated-dirs.test.ts`). There, a project directory
1200
- that is still the same one starts no cycle: a task writing at its
1201
- top level moved its mtime, which macOS reports to the parent
1202
- (`watch-project-dir-write.test.ts`). Under `--filter` or
1203
- `--affected` the scope is the one resolved at start, and a new
1204
- project joins it only as a dependency of it. A task's own declared outputs (`cache.outputs.files`,
1205
- `outputs.workspaceFiles`; a plugin's `project` stage counts, as in
1206
- a run) never trigger a re-run — a cycle that writes `dist/` is not
1207
- an edit, nor is the `dist` directory itself coming and going (a
1208
- literal entry covers its whole tree, as in the schema), unless some
1209
- task reads the path as an input (an in-place formatter's `src/**`
1210
- output still leaves a `build` reading `src/**` watching it, item 946;
1211
- a task's own outputs are no input of it, so a `turbo()` task reading
1212
- `**/*` does not re-run on its own `dist/`; only the tasks the run
1213
- reaches through `dependsOn` are asked, so an unwatched `lint` reading
1214
- `**/*` beside `vx watch build` asks nothing) — and neither do `node_modules`,
1215
- `.git` or the cache directory. A write the task did NOT declare (a
1216
- task with no `cache` block declares nothing) is caught by state,
1217
- judged once the bytes have settled: a file whose bytes and mode did
1218
- not change since the loop last saw it is not an edit (a `chmod` is one,
1219
- as the key reads the executable bit), nor is a directory
1220
- whose entries (names and sizes) did not, nor a path that stayed
1221
- gone; a symlink is its target string and what it points to, as the
1222
- key folds them, so a retarget is an edit, and so is an edit behind
1223
- it: watch arms each link's target, wherever it lies, and names the
1224
- link; a path the loop has never judged is an edit only if it was
1225
- modified after the watchers went live (macOS delivers the initial
1226
- run's own writes after the arm; the later of the path's mtime and
1227
- ctime says which side of it a path belongs to, so a file moved in
1228
- with an old mtime by `mv`, `cp -p` or `tar x` still counts, and a
1229
- symlink's own times are read, never its target's), and a
1230
- path git did not list at the arm that is gone again (vim's `4913`
1231
- write probe, a lock file) is no edit; and
1232
- nothing is judged while a cycle runs — its
1233
- own writes are mid-flight, a `dist` deleted and not yet rebuilt is
1234
- a state the tree will not keep — so paths that land mid-run are
1235
- judged together one debounce window after it ends, an edit made
1236
- meanwhile included. A task that writes into its own project,
1237
- `rm -rf dist && tsc` included, costs one extra cycle instead of
1238
- re-running forever — when the bytes it leaves are the same. A file
1239
- it rewrites with DIFFERENT bytes every run (a pid file, a
1240
- timestamped log) is either git-ignored — a git-ignored path never
1241
- starts a cycle, since no cache key can see it (a user's edit to one
1242
- still does in a project with a task that has a command and no cache,
1243
- which reads what it likes, item 947, outside any project nested
1244
- in it; a write the last cycle made is not, read off the later of
1245
- its mtime and ctime, so an output a cache hit restores with its
1246
- saved mtime is the run's, `watch-restored-output.test.ts`) — or declared an
1247
- output, or the loop re-runs on it; after three such cycles in a row
1248
- watch names the path and the remedy, once, and keeps going. A dev
1249
- server the last cycle left running counts as that cycle for as long
1250
- as it runs, so a log it rewrites in its project is named too, with
1251
- `.gitignore` as the remedy (a persistent task declares no outputs;
1252
- item 948). A file git tracks is never blamed on the server: three
1253
- saves of a source file are the user's (WD-7). When any project's config declares
1254
- `inputs.workspaceFiles`, the per-project watchers are swapped
1255
- for ONE recursive root watcher (boundaries are off for those globs,
1256
- so a root-relative glob can name a file anywhere). That watcher
1257
- hears every write in the tree and keeps only the ones a key can
1258
- see: a path inside a project's directory, a fingerprint file at the
1259
- root, or a match of a declared `workspaceFiles` glob. A log written
1260
- at the root, a `coverage/` or `.turbo/` tree, an editor's scratch
1261
- file are dropped before the trigger — so `vx watch … > build.log`
1262
- inside the repo settles instead of feeding itself a cycle per line
1263
- of its own output. The opening line, `watching N projects · M tasks`
1264
- (after the initial run's rows), is
1265
- printed only after every watcher has reported a probe file written
1266
- under it (`.vx-watch-probe`, re-written on a short backoff until its
1267
- event arrives, then removed): on macOS a directory watcher can return
1268
- before its event stream is live, and an edit in that gap is silently
1269
- lost — so the line is a promise, not a hope. A probe is never an
1270
- edit, at any depth: another watcher's, seen under a nested project,
1271
- ran a cycle (and restarted a dev server) with no edit made (item
1272
- 1016). A
1273
- watcher whose probe is not heard within 2 s is replaced by a poller
1274
- that checks every 250 ms, with the notice `vx watch: <dir>: no OS
1275
- watch events within 2000 ms; polling every 250 ms instead`.
1276
- 3. **On change.** A cycle opens with a blank line and
1277
- `changed <project> <relpath>`, plus ` (+N more)` when more paths
1278
- changed, then its task rows as `vx run` prints them, with no footer.
1279
- The named path is the first changed one that still exists, so an
1280
- editor's temporary file renamed away names nothing; a deletion names
1281
- the cycle when nothing else changed. The `watching` line is said
1282
- once: a cycle that re-reads the watched set (a member came or went,
1283
- the lock or a plugin changed) says nothing more; `VX_DEBUG_WATCH=1`
1284
- says each arm on stderr as `vx watch: armed N projects`. Errors and
1285
- refusals go to stderr. The
1286
- orchestrator is invoked again with the same options, save one: the
1287
- `--affected` diff is read once, for the initial run. A later cycle
1288
- runs the requested task in every project of the scope fixed at start,
1289
- and the cache keys decide what executes (the startup diff, asked
1290
- again, kept an edit made since out of every cycle). Events arriving
1291
- while a run is in flight queue and drain after the current cycle.
1292
- Re-runs are debounced ~150ms after the last event, and wait at most
1293
- 1 s after the first, so a writer that never pauses holds no edit back.
1294
- 4. **Exit.** `SIGINT` (Ctrl+C) exits 0; once the cycle in flight is down, `vx watch: stopped` is the last line.
1295
-
1296
- ### Path filtering
1297
-
1298
- Always ignored (no re-trigger):
1299
-
1300
- - `node_modules/`, `.git/`, `.vx/cache/` anywhere in the path.
1301
- - A path git ignores (`git check-ignore`, asked once per debounce
1302
- window, never per event), unless a task's inputs name it outright
1303
- (`.env.local`, `dist/**`): invisible to every other cache key — inputs are
1304
- tracked + untracked-not-ignored — so a cycle it started could change
1305
- nothing. A tracked file that matches a pattern is not ignored, by
1306
- git's own rule; outside a repository nothing is.
1307
- - Files ending in `~` (editor swap files), and `.vx/tmp/`, where
1308
- restores stage.
1309
- - The run's **resolved cache directory**, wherever it is. `.vx/cache/` covers the
1310
- default, but `defineWorkspace({ cache: { dir } })` and `--cache-dir` can put it
1311
- anywhere; watching it would let vx's own cache writes trigger the next
1312
- cycle, which writes again — a loop that never settles.
1313
-
1314
- Everything else triggers a cycle. We deliberately don't filter events
1315
- against per-task `inputs.files` — the cache hash is the source
1316
- of truth. A change to an irrelevant file produces a cache-hit run
1317
- (typically tens of ms); the cost is much smaller than the engineering
1318
- cost of a per-event glob match.
1319
-
1320
- ### How changes are seen
1321
-
1322
- One recursive OS watcher (`fs.watch`) per project directory in scope,
1323
- plus the workspace root for the fingerprint files. Each watcher must
1324
- prove it delivers: a directory whose watcher reports nothing within
1325
- 2 s of the probe is polled every 250 ms instead, with a one-line
1326
- notice on stderr (`no OS watch events within 2000 ms; polling every
1327
- 250 ms instead`). Where the watcher is known not to deliver — a sandbox
1328
- without FSEvents access on macOS, a network mount, a container bind —
1329
- `VX_WATCH_POLL=1` polls from the start and skips the 2 s probe; the loop
1330
- says so once (`vx watch: polling every 250 ms (VX_WATCH_POLL)`). A
1331
- watcher the OS refuses for its watch limit (`ENOSPC`, `EMFILE`) is
1332
- polled too, with a notice naming the limit to raise. The
1333
- poller walks the same tree the event filter keeps: the always-ignored
1334
- segments and the run's declared output containers are never sampled.
1335
- It samples each file's later clock of mtime and ctime, so a replacement
1336
- that carries the old file's mtime (`cp -p`, `rsync -a`, a `mv`) is an
1337
- edit to it as it is to the OS watcher.
1338
-
1339
- ### Claimed root file changes
1340
-
1341
- A root file a plugin claims — a lockfile or install setting under
1342
- `@vzn/vx-lockfile` (`pnpm-lock.yaml`, `bun.lock`, `.npmrc`, …),
1343
- `turbo.json` under `turbo()` (item 961) — triggers a cycle, and so does
1344
- `.vx/projects.lock.json`. Watch mode hears them because it watches the
1345
- workspace root (non-recursively); a plugin added to the workspace
1346
- config mid-watch counts from the cycle that loads it. The keys then
1347
- decide which projects actually re-run: core keys no package file, so a
1348
- lockfile edit re-keys only through its claimant
1349
- ([cache key derivation](./caching.md#cache-key-derivation)). Every
1350
- cycle's configs come from `.vx/projects.lock.json`, so a config edit
1351
- alone changes no command, and a re-lock (`vx lock`) is a cycle that
1352
- re-reads the watched set (item 971).
1353
-
1354
- ### Constraints
1355
-
1356
- The following flags are rejected (parser exits 1 before the initial
1357
- run):
1358
-
1359
- - `--dry` — it skips execution; nothing to watch.
1360
- - `--profile` — would overwrite its target per cycle.
1361
- - `--verbosity <n>` (n > 0) — formats
1362
- ONE run's result; a watch loop has no single run to report. (`--verbosity 0`
1363
- is accepted: it asks for what watch already prints.)
1364
-
1365
- A requested persistent task (`exec.persistent`, a dev server) starts
1366
- once and stays up across cycles; each cycle re-runs what it depends on,
1367
- as the cache keys decide. So `vx watch dev`, with `dev` depending on
1368
- `^build`, rebuilds only the libraries an edit reaches on the way to the
1369
- app and leaves the server running; the dev tool's own reload picks up
1370
- what was rebuilt. What the server writes keeps printing. It restarts
1371
- only when its own task config (or the args forwarded to it) changed, or
1372
- when it died: the old one is stopped first (the kill grace, then
1373
- SIGKILL), so the two never hold one port. One that dies while watch
1374
- idles is said (`vx: app#dev exited with code 3`); the next change
1375
- starts it again. A cycle in which a task the server depends on fails
1376
- stops it. An edit while a cycle waits only on a server that has not
1377
- printed its `readyWhen` line stops that cycle and starts the next, so a
1378
- fix reaches a server stuck before ready, in the initial run too.
1379
- Stopping watch stops the server too. A one-shot task (`vx watch build`)
1380
- re-runs itself and what it depends on.
1381
-
1382
- ### Exit codes
1383
-
1384
- - `0` — clean Ctrl+C / SIGTERM exit. A cycle in flight is torn down
1385
- first — its children, and the dev server held between cycles, get the
1386
- signal watch received (a Ctrl-C as SIGINT; a SIGTERM or SIGHUP as SIGTERM),
1387
- a 2 s grace, then SIGKILL — and the process leaves only
1388
- once it has returned, so a CI cancellation never orphans a task.
1389
- Also `0`, with no watch, when `--affected` selects nothing: watch
1390
- prints `vx watch: nothing affected since <ref>` and exits as `vx run`
1391
- does.
1392
- - `1` — parser error, missing scope, or a task name no project in
1393
- scope declares: the initial run refuses it as `vx run` does, with the
1394
- same `Did you mean` hint, and watch exits rather than re-run the
1395
- refusal on every change.
1396
-
1397
- A cycle that throws — a `vx.config.*` that does not parse — prints
1398
- `vx watch: cycle failed: <reason>` and watch keeps watching, the initial
1399
- run included, so saving the fix re-runs (item 1017).
1400
-
1401
- Re-run cycles whose orchestrator returns `{ ok: false }` do NOT exit
1402
- the watch loop — a failed cycle just prints the framed FAILED block
1403
- and waits for the next change. This matches `turbo watch` / `nx
1404
- watch`.
1405
-
1406
- ## `vx cache clean`
1407
-
1408
- Evict old or oversized cache entries. Operates on the entries and
1409
- `<hash>.tar.zst` artifacts of the store the workspace's index records
1410
- (`~/.vx/<id>/cache`, shared by every checkout of the repository), or of
1411
- `<cacheDir>` itself when the workspace names one (`cache.dir`,
1412
- `VX_CACHE_DIR`, `--cache-dir`).
1413
-
1414
- `clean` is the only `vx cache` subcommand: the statistics other runners
1415
- put under a `cache` verb — the directory, the entry count, the size —
1416
- are part of [`vx info`](#vx-info), and `vx cache stats`, `list`, `ls`,
1417
- `gc` and their neighbours say so (`list` also names `vx last --list`;
1418
- `clear`, `purge`, `reset` and `wipe` name a bare `vx cache clean`) rather than printing a bare "unknown subcommand".
1419
-
1420
- ```
1421
- vx cache clean --older-than <duration> # Drop entries last used before now - duration (up to 1h slack).
1422
- vx cache clean --max-size <size> # After age-based pruning, evict LRU until under <size>.
1423
- vx cache clean ... --dry-run # Say what either policy would reap; delete nothing.
1424
- vx cache clean ... --format json # { dryRun, evicted, bytesFreed, orphans, orphanBytes }
1425
- vx cache clean ... --cache-dir <path> # The cache a run with the same flag uses.
1426
- vx cache clean # No bound: clear the whole cache (below).
1427
- vx cache clean --global # Also clear every other repository's store.
1428
- ```
1429
-
1430
- With no bound it clears the cache ([below](#with-no-bound-clear-the-cache)).
1431
- Both bounds may be combined: age-based eviction runs first, then LRU eviction if the
1432
- total is still over the size cap. The same policy runs unattended at
1433
- the end of every run when `.vx/workspace.ts` declares
1434
- `cacheRetention: { olderThan, maxSize }` (same spellings; see
1435
- [schema](./schema.md)).
1436
-
1437
- After eviction, prune sweeps the cache directory for **orphans**: a
1438
- `<hash>.tar.zst` the index has no row for (a `SCHEMA_VERSION` bump
1439
- drops every table and leaves the artifacts behind, as a deleted
1440
- `cache.db` does) and a `<hash>.tar.zst.tmp-*` a save that crashed never
1441
- renamed. A run that asks for an orphan's key indexes it again from its
1442
- bytes and hits; one no task asks for again is only reclaimed here. The
1443
- policy judges a row-less artifact as it judges an entry, by its file
1444
- time plus an hour (a hit renews a file time over an hour old): another
1445
- vx version sharing the store may still use it. A temp goes once it is an
1446
- hour old; nothing younger than an hour is taken. Only the names vx writes are taken:
1447
- `<hash>` is the key's 16 lowercase hex digits and the temp suffix is
1448
- the one a save makes, so a `release.tar.zst` beside the index in a
1449
- `cache.dir` you share is never touched (item 968). The converse, an
1450
- index row whose artifact is gone (deleted by hand), is dropped by the
1451
- same prune, and its bytes count toward neither `--max-size` nor the
1452
- evicted total: they are on no disk, and counting them evicted real
1453
- entries to make room for them (item 975). A row used within the hour
1454
- is kept until a later prune, but its bytes are left out all the same
1455
- (item 1081). The sweep runs on every prune, under
1456
- either flag, and reports separately from the policy's evictions. A
1457
- workspace's `cache.retention` runs it too, at the end of a run, at most
1458
- once an hour even when nothing the index holds is due (the policy sums
1459
- index rows, so orphans alone never make it due).
1460
-
1461
- Both flags take either form: `--older-than 30d` or `--older-than=30d`.
1462
-
1463
- **Duration units**: `s`, `m`, `h`, `d`, case-insensitive. Examples:
1464
- `30d`, `24h`, `60m`, `30s`, `30D`.
1465
-
1466
- **Size units**: `K`, `M`, `G`, `T` (powers of 1024), case-insensitive.
1467
- Optional `B` suffix is accepted. Examples: `500M`, `1G`, `100K`, `2T`,
1468
- `500MB`, `1gb`. A bare number is refused here: `--max-size 10` would
1469
- read as ten bytes and evict nearly everything, and nobody means that —
1470
- write `10G`, or `10B` when bytes really are the unit.
1471
-
1472
- **A zero bound is rejected.** `--max-size 0` and `--older-than 0d` would
1473
- evict every entry in the cache, which is far more often a
1474
- computed-to-zero retention than an intent. `vx cache clean` with no
1475
- bound clears the whole cache when that is really what you want.
1476
-
1477
- ```
1478
- $ vx cache clean --older-than 30d
1479
- ✓ vx cache clean freed 1.3 GB
1480
-
1481
- 42 entries
1482
-
1483
- $ vx cache clean --older-than 30d # after a SCHEMA_VERSION bump
1484
- ✓ vx cache clean freed 1.3 GB
1485
-
1486
- 42 orphaned artifacts
1487
-
1488
- $ vx cache clean --older-than 30d --dry-run
1489
- › vx cache clean would free 1.3 GB (dry run)
1490
-
1491
- 42 entries · 3 orphaned artifacts
1492
- ```
1493
-
1494
- `--dry-run` picks the victims under the same policy and counts the
1495
- orphans the sweep would take, then returns without touching the index
1496
- or the directory; the real prune with the same flags reaps exactly what
1497
- it named (an in-flight save aside). On an index an earlier vx wrote,
1498
- which the real prune resets first, it reads the index as that reset
1499
- leaves it: the shared store's entries still face the policy, and every
1500
- artifact in the workspace's own directory is row-less, judged by the
1501
- policy on its file time (item 1083).
1502
-
1503
- A prune that deletes waits for a `vx run` on the same workspace to
1504
- finish first (the run's lock; it says `[vx] waiting for another vx run
1505
- (pid N) on this workspace to finish…` after a second), so it never
1506
- evicts what that run is restoring. One started by a task of that run
1507
- is refused instead: the run holds the lock until the task ends. A run it cannot see — another
1508
- workspace sharing the `--cache-dir` — survives a prune anyway: an
1509
- artifact that vanishes before its restore is a miss, and the task runs
1510
- ([caching](./caching.md#concurrent-runs)).
1511
-
1512
- Exit codes:
1513
-
1514
- - `0` — pruning completed (zero or more entries evicted). A workspace
1515
- that never ran has no cache: `› vx cache clean nothing to free`,
1516
- and nothing is created.
1517
- - `1` — parse error, missing policy, or workspace-discovery error.
1518
-
1519
- `vx cache clean` resolves the workspace root from cwd and honors a
1520
- `defineWorkspace({ cache: { dir } })` override — it prunes the same
1521
- directory a run would use. `--cache-dir <path>` (cwd-relative, as on
1522
- `vx run`) names another one, so a run that wrote elsewhere can be
1523
- pruned there.
1524
-
1525
- ### With no bound: clear the cache
1526
-
1527
- `vx cache clean` with neither `--older-than` nor `--max-size` clears
1528
- the whole cache: the cache directory (`.vx/cache`, or `cache.dir` /
1529
- `--cache-dir`: the index and this workspace's run history, so `vx last`
1530
- and `vx why` start over) and the store its index names, where the
1531
- entries live (`~/.vx/<id>/cache`, shared by this repository's
1532
- checkouts, so their entries go too). `--global` also clears every other
1533
- repository's store under `~/.vx` (`~/.vx/*/cache`); it takes no bound
1534
- and no `--cache-dir`. `.vx/workspace.ts`, the lock and `.vx/.gitignore`
1535
- sit outside them and stay; a `cache.dir` that holds them (`.vx`, the
1536
- workspace root) is refused. A `cache.dir` you named may be shared
1537
- (`build/`, `~/.cache`), so there only what vx wrote goes (the index,
1538
- its artifacts, run history, its `*` `.gitignore`); your own files stay,
1539
- and the directory goes only if that empties it. A directory with no
1540
- `cache.db` is not a vx cache and is left alone, and the store an index
1541
- names goes only if it is one (`.vx/<id>/cache` holding a `store.db`): the
1542
- index is a file in the checkout, which a commit or a restored CI cache
1543
- could point at any directory. A workspace that never ran says
1544
- `› vx cache clean nothing to free`. The next run misses every task once
1545
- and saves again.
1546
-
1547
- ```
1548
- $ vx cache clean
1549
- ✓ vx cache clean freed 1.4 GB
1550
-
1551
- .vx/cache
1552
- /home/me/.vx/3f2a9c01b4d5e6f7/cache
1553
-
1554
- $ vx cache clean --global --dry-run
1555
- › vx cache clean would free 3.1 GB (dry run)
1556
-
1557
- .vx/cache
1558
- /home/me/.vx/3f2a9c01b4d5e6f7/cache
1559
- /home/me/.vx/9b8e7d6c5a4f3e2d/cache
1560
- ```
1561
-
1562
- It waits for a `vx run` on this workspace to finish first (the run's
1563
- lock), as a trim does. With `--format json` it prints
1564
- `{ dryRun, cleared, bytesFreed }` (`schemas/cache.json`), `cleared`
1565
- listing the directories.
1566
-
1567
- ## Runs read the lock
1568
-
1569
- Every run, `vx watch` cycle, `--affected` selection and the picker takes
1570
- its projects and their configs only from the committed
1571
- `.vx/projects.lock.json` (owner, 2026-10-14). A run never walks for
1572
- configs, never evaluates one, never checks the graph rules
1573
- (`run.rules`) and never writes the lock: only `vx lock` does. With no
1574
- lock, a run fails with `no lock: run \`vx lock\``. A run trusts the
1575
- lock: it performs NO staleness check, so after adding or editing a
1576
- config, run `vx lock`. In a pipeline, `vx lock --check` first re-evaluates every
1577
- config and checks the rules.
1578
-
1579
- ## `vx lock`
1580
-
1581
- Find every project, and freeze each one's **resolved** config into
1582
- `.vx/projects.lock.json` at the workspace root. A project is a directory
1583
- holding a `vx.config.*`, in any language: `vx lock` lists them with git
1584
- (`git ls-files`, tracked and untracked, git's ignore rules applied; a
1585
- nested repository's own configs too), or walks the tree outside git,
1586
- and leaves out any under a nested `.vx/workspace.*`. Configs are
1587
- programs; `vx lock` evaluates them in the current environment and
1588
- stores the post-evaluation objects, by directory (version 3; a lock an
1589
- earlier vx wrote is refused, "re-run `vx lock`"). A project is named by
1590
- its config's `name`, else by a plugin's `discover` stage (vx-lockfile
1591
- gives `package.json`'s `name`), else by its root-relative path (`//` for
1592
- the root); two projects of one name are refused naming both
1593
- directories. The plugin `discover` stage runs here as in a run, and may
1594
- add directories (X-143). Before writing, it
1595
- builds the graph of every task the workspace declares, `graph` stage
1596
- included, and checks the graph rules (`run.rules`), which no run
1597
- checks, `dependencies` (which projects a project may depend on,
1598
- `VX_E_DEPENDENCY_RULE`) among them: a broken one prints `✗ vx lock lock not written`
1599
- with the rule's refusal under it and writes nothing.
1600
-
1601
- ```
1602
- vx lock # Evaluate all vx.config.* now; write .vx/projects.lock.json.
1603
- vx lock --check # Audit: full re-evaluation vs the lock. Exit 1 on drift.
1604
- vx lock --check --format json # The audit as {upToDate, audited, notAudited, problems}.
1605
- ```
1606
-
1607
- **When to run it.** Run `vx lock` and commit `.vx/projects.lock.json` after
1608
- editing any `vx.config.*` or `.vx/workspace.*`, after updating
1609
- dependencies or plugins, and after changing a file a config imports.
1610
- Runs read their configs from the lock, so until then they keep the old
1611
- ones. `vx lock --check` as CI's first step fails a stale lock.
1612
- The full workflow: the site's CI guide and `examples/ci`.
1613
-
1614
- `.vx/projects.lock.json` is minified: it is the one file vx reads, and it loads
1615
- faster that way (10,000 projects: 15.6 ms against 22.5 ms pretty). For
1616
- review, `vx lock` also writes each project's resolved config (plugins
1617
- applied), pretty, to `vx.lock.json` beside its `vx.config`; vx never reads
1618
- it, it is no task's input, and `lock.reviewFiles: false`
1619
- (`VX_LOCK_REVIEW_FILES`) turns it off. `vx lock` also writes
1620
- `.vx/.gitattributes` (`projects.lock.json linguist-generated=true`) when it is
1621
- missing, so GitHub collapses the machine lock in a diff; `vx init` does
1622
- too, and an existing one is never edited.
1623
-
1624
- Every run consumes it: configs come from the
1625
- lock with no evaluation, no staleness check and no schema validation
1626
- of its entries, which `vx lock` validated when it wrote them (frozen-env
1627
- semantics: env reads in a config keep their lock-time values; a
1628
- locked directory whose config file is gone (X-144) or that this user
1629
- may not search (EACCES, named as such), or a missing lock, is a hard error; a config added since the lock is no project until
1630
- `vx lock`). An entry edited by
1631
- hand into a shape `vx lock` could not write fails the run with
1632
- `.vx/projects.lock.json is invalid, run \`vx lock\``, checked only once
1633
- something has crashed, so a normal run pays nothing for it.
1634
-
1635
- `--check` is the audit: it re-evaluates every config in the current
1636
- environment and `Bun.deepEquals`-compares it with the frozen object. A
1637
- project that differs gets one message, whether its config, a file it
1638
- imports or the environment it reads changed. A lock an earlier vx wrote
1639
- fails the shape check and asks for `vx lock`. It also checks the graph rules over the configs as they
1640
- evaluate now, and that each `vx.lock.json` is what its project resolves to
1641
- here, and that none is left beside a deleted `vx.config` (delete that one
1642
- yourself: vx never deletes a committed file), unless `lock.reviewFiles` is off. The CI recipe is `vx lock --check && vx run …`.
1643
- `--format json` prints the audit as one document
1644
- (`schemas/lock.json`, exit code unchanged; a missing lock is one of
1645
- its `problems`); it answers `--check` only.
1646
- Full design: `docs/design/config-lock-2026-06.md`. A lock written by a
1647
- 1.x vx stays valid for every later 1.x (`tests/contract-lockfile.test.ts`).
1648
-
1649
- A project with no `vx.config.*` — its tasks from a plugin's `project`
1650
- stage (`turbo()` from `@vzn/vx-migrate`), or none at all — has nothing
1651
- to freeze: the lock records nothing for it, `--check` does not audit
1652
- it, and a run still loads its tasks live. Both verbs name such
1653
- projects that have tasks (`0 project configs → .vx/projects.lock.json (no
1654
- vx.config, so not locked: app, lib)`, five names then `and N more`),
1655
- so an empty lock on a plugin-only workspace never reads like an audit.
1656
- One with no task (core's default `build` aside) is not named.
1657
-
1658
- The lock is committed, so `vx lock` refuses to write one that holds a
1659
- secret: a config that evaluated to the value of a secret-named variable
1660
- (or one a task lists in `exec.env.secret`), say
1661
- `` `--token ${process.env.API_TOKEN}` ``. It names each place
1662
- (`a: tasks.deploy.exec.command holds $API_TOKEN`) and writes nothing.
1663
- Masking it instead would freeze a `***` that every run runs. Let the
1664
- shell expand it: `$API_TOKEN` in the command, the name in
1665
- `exec.env.passThrough` (L-42).
1666
-
1667
- Exit codes:
1668
-
1669
- - `0` — lock written / lock is up to date.
1670
- - `1` — parse error, workspace-discovery error, missing lock
1671
- (`--check` without one), any drift (every mismatched project is
1672
- listed on stderr), or a secret value the lock would hold.
1673
-
1674
- ## Releasing (maintainers)
1675
-
1676
- Releases are on demand. Dispatching `auto-release.yml` releases a
1677
- commit on `main` whose CI went green: the `sha` input, or by default the
1678
- newest such commit; one CI did not pass on is refused. Claude dispatches
1679
- it at least once a day when `main` has changes, after merging that
1680
- release's blog post (`.claude/skills/release/SKILL.md`), and shares the
1681
- post on Bluesky. The workflow tags the commit with the next version and
1682
- creates the GitHub release, both from the Conventional Commits since the
1683
- last tag (`scripts/release-notes.ts`, run by the `release.auto` task,
1684
- `scripts/auto-release.ts`). The release is created as a draft: this
1685
- repo's releases are immutable and take assets only before they are
1686
- published, so `release.yml` publishes it after its last upload. The version: below 0.1.0 always a
1687
- patch (cutting 0.1.0 is the owner's, by hand); then before 1.0 a `feat`
1688
- or a breaking change (`type!:`, a `BREAKING CHANGE:` footer) is a minor
1689
- (`v0.4.2` → `v0.5.0`) and anything else a patch (`v0.4.3`); from 1.0 a
1690
- breaking change is a major. The notes: breaking changes first, then
1691
- `feat`, `fix` and `perf`, the rest counted. It then
1692
- dispatches `release.yml` (with `tag`) and
1693
- `npm.yml` (with `version` and `ref`). A release made with the workflow
1694
- token fires no `release` event in other workflows, which is why the two
1695
- are dispatched rather than triggered. A commit is released only when the
1696
- last release is its ancestor, so an older tree never gets a higher
1697
- version, and one that already carries a `v*` tag is a no-op.
1698
-
1699
- A version can still be cut by hand: push its tag (say `v1.0.0`),
1700
- create a draft release for it, and dispatch `release.yml` (`tag`) and
1701
- `npm.yml` (`version`, `ref`); the next auto-release continues from it.
1702
-
1703
- A GitHub release publishes everything: `release.yml` builds the four
1704
- binaries, proves the darwin ones on macOS (re-signed ad hoc only where
1705
- macOS refuses one) and attaches them; `npm.yml`
1706
- builds the twelve npm packages (`@vzn/vx`, one per platform, and the
1707
- seven plugin packages) and publishes them with **npm trusted
1708
- publishing** — the job's OIDC token
1709
- is exchanged for a short-lived credential and provenance is attached,
1710
- so no long-lived npm token exists anywhere. Both publish loops skip a
1711
- package already on the registry, so a re-run (`npm publish` →
1712
- _Run workflow_ with the version) resumes where it stopped. That dispatch
1713
- also takes a `ref`: it runs the workflow from the default branch against
1714
- the code that ref names, so a release whose publish died on a workflow
1715
- bug is completed by the FIXED workflow building the tag's own source
1716
- (`version: 0.0.20`, `ref: v0.0.20`) — re-running the failed run itself
1717
- would replay the broken file, which is pinned to the tag.
1718
-
1719
- Every step of the three workflows past the setup (checkout, Bun, Node,
1720
- `bun install`, the runner action) is one vx task: CI runs no command of
1721
- its own (owner, 2026-10-03), and `tests/workflow-runner.unsafe.test.ts`
1722
- holds it. The tasks are `@vzn/vx`'s `release.*`, all uncached and in no
1723
- `ci` graph:
1724
-
1725
- | Task | Does |
1726
- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
1727
- | `release.stamp` | writes `VX_RELEASE_VERSION` into `packages/vx/package.json`, the manifest the binary inlines |
1728
- | `release.npm` | checks npm can publish with provenance (>= 11.5.1, `sigstore` loads); else installs npm into `dist/npm-cli/` |
1729
- | `release.prove.<os>` | launches the host's binary (re-signed only if macOS refuses it) and asserts `vx <version>`; on darwin, launches both (x64 under Rosetta) |
1730
- | `release.assemble.<os>` | emits the platform packages under `dist/npm` (linux: also `@vzn/vx` and the plugins) |
1731
- | `release.publish.<os>` | `npm publish --provenance` each in order, skipping one the registry holds |
1732
- | `release.upload.<os>` | attaches `dist/vx-<os>-*` the draft release lacks; darwin's then publishes it |
1733
- | `release.auto` | decides the version, creates the draft release with its notes, dispatches the two workflows |
1734
-
1735
- The version reaches them as `VX_RELEASE_VERSION` (the tag or the
1736
- dispatch input) and each refuses one that is not a version. Every one
1737
- is sandboxed: the publish is granted the registry, the OIDC token host
1738
- and Sigstore, the GitHub-Actions variables npm reads, and `~/.npm`;
1739
- `release.auto` reads `.git` and reaches `api.github.com` with `GH_TOKEN`;
1740
- `release.upload.<os>` reaches it and `uploads.github.com`.
1741
- A dispatch with `ref` builds that ref's code, so it must hold these tasks.
1742
-
1743
- All Linux jobs go through `.github/actions/vx-runner` before any
1744
- `vx run`: every task in this repo declares `exec.sandbox`, and a
1745
- declared sandbox whose runtime is missing is a hard error, not a
1746
- downgrade — the compile tasks fail in 0 ms saying which of bubblewrap,
1747
- socat and ripgrep is absent. v0.0.20 shipped its darwin packages and
1748
- then stopped exactly there.
1749
-
1750
- One-time setup, per package, on npmjs.com → package → Settings →
1751
- Trusted Publisher → GitHub Actions: owner `vznjs`, repository `vx`,
1752
- workflow `npm.yml`, environment left blank. Do this for `@vzn/vx`,
1753
- `@vzn/vx-darwin-x64`, `@vzn/vx-darwin-arm64`, `@vzn/vx-linux-x64`,
1754
- `@vzn/vx-linux-arm64` and the six plugins: `@vzn/vx-ci`,
1755
- `@vzn/vx-lockfile`, `@vzn/vx-migrate`, `@vzn/vx-otel`,
1756
- `@vzn/vx-reapi` and `@vzn/vx-schedule-history`.
1757
-
1758
- Each platform package carries `LICENSE` and `THIRD_PARTY_NOTICES.txt`:
1759
- the binary embeds Bun and npm code whose licenses must travel with it.
1760
- The GitHub release carries the same file as an asset beside the binaries.
1761
- `tests/dependency-licenses.unsafe.test.ts` holds every published package's
1762
- production dependency closure to permissive licenses (MIT, ISC, BSD,
1763
- Apache-2.0, 0BSD, or a dual license offering one).
1764
- After a dependency or Bun bump, `bun packages/vx/scripts/third-party-notices.ts`
1765
- regenerates the notices; `tests/third-party-notices.unsafe.test.ts`
1766
- fails until it does.
1767
-
1768
- The plugins ship as the TypeScript source Bun runs, at the release's
1769
- version, with `@vzn/vx` as a peer on the same minor (`^<version>`).
1770
- `scripts/build-npm.ts --only=plugins` emits every public workspace
1771
- package other than `@vzn/vx`, so a new plugin package is published
1772
- without a workflow edit, and `tests/build-npm.unsafe.test.ts` holds the
1773
- set. If npm will not add a trusted publisher to a name that has never
1774
- been published, publish that plugin once by hand from an owner's
1775
- account (`npm publish dist/npm-plugins/plugins/<dir> --access public`
1776
- after a `build-npm.ts <version> --only=plugins --out=dist/npm-plugins`
1777
- build), then add the publisher. Then
1778
- delete the `NPM_TOKEN` repository secret:
1779
- the workflow no longer reads it, and npm restricts classic tokens for
1780
- direct publishing (the `E401 token is invalid` that stopped v0.0.17).
1781
- Every `uses:` in both workflows is pinned to a commit SHA with the
1782
- version in a comment; bump the SHA and the comment together.
1783
-
1784
- ## The workspace root
1785
-
1786
- Every verb works on one workspace: the directory whose `.vx` holds the
1787
- nearest `.vx/workspace.ts` (or `.mts`, `.js`, `.mjs`, `.cts`, `.cjs`) from
1788
- the working directory up. vx never looks above the git top-level (a
1789
- worktree is its own; a submodule looks on into its superproject), and
1790
- outside git it looks up to `/`. Nothing else marks the root: not a
1791
- `package.json`, `pnpm-workspace.yaml` or a lockfile (owner, 2026-10-13).
1792
- Without one, every verb but `vx init` stops with `VX_E_NO_WORKSPACE`;
1793
- `vx init` writes one (`@vzn/vx-migrate` at the package manager's root,
1794
- in a nested workspace the outer one does not list too). A project inside the
1795
- root is a directory holding a `vx.config.*`, which `vx lock` finds
1796
- (see [`vx lock`](#vx-lock)); a `discover` plugin adds others.
1797
-
1798
- ## Which vx runs
1799
-
1800
- A global vx (installed with a package manager, or a compiled binary
1801
- installed by hand) hands every call to the project's own `@vzn/vx` when
1802
- there is one, so a project runs the version its lockfile pins (owner,
1803
- 2026-10-13). There is no opt-out. The workspace root's
1804
- `node_modules/@vzn/vx` runs, through its
1805
- `node_modules/.bin/vx`. One hop: the receiving vx sees `VX_HANDED_OFF`
1806
- and does not look again. A vx never hands off to itself (the same
1807
- package, a pnpm symlink included), nor to a `.bin/vx` another user owns
1808
- (one planted above your checkout would run as you). An npm install's launcher does the
1809
- walk in the shell, a test per level and no fork, and `exec`s the local
1810
- vx; the compiled binary checks one path per level before it loads
1811
- anything, then runs the local vx with its stdio, passes on a signal sent
1812
- to it alone, and exits with its code. From source (`bun src/bin.ts`) vx
1813
- never hands off.
1814
-
1815
- `vx --version` says when it was handed the call (`vx 0.0.640
1816
- (/repo/node_modules/@vzn/vx-linux-x64/vx, handed off from
1817
- /usr/local/bin/vx)`), and `vx info` shows the file that ran and who handed off
1818
- (`vxPath`, `handedOffFrom`).
1819
-
1820
- ## `vx upgrade`
1821
-
1822
- Brings vx to a newer release (`vx upgrade TAG` pins one, `v0.4.0` or
1823
- `0.4.0`; default latest; a second tag is refused, not dropped), the way
1824
- it was installed. Named `upgrade` per CLI convention (`bun upgrade`,
1825
- `deno upgrade`). Refuses when running from source: use `git pull`.
1826
-
1827
- **A compiled binary installed by hand** replaces itself. It reads its
1828
- platform package (`@vzn/vx-<os>-<arch>`) from the npm registry and
1829
- checks npm's registry signature over `<name>@<version>:<integrity>`
1830
- against npm's signing key, pinned in vx (key id
1831
- `SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U`), then the
1832
- tarball's SHA-512 against that signed integrity, and only then extracts
1833
- `package/vx`, stages it in a fresh directory beside the binary and
1834
- renames it over. The host that serves the tarball cannot make that
1835
- signature, so a swapped tarball, or a document with a swapped integrity,
1836
- is refused: `the npm registry signature for … is not valid — refusing
1837
- to install; nothing replaced`, or `the download did not match the
1838
- integrity npm signed … nothing replaced`. A release signed with a key
1839
- id vx does not pin says so: `… is signed with an npm key this vx does
1840
- not know (…) — if npm rotated its signing key, this vx's installer
1841
- needs updating`. A release with no signature or no integrity is refused
1842
- before the download. Every refusal comes before anything is written.
1843
- There is no fallback to GitHub's release digest: when the registry is
1844
- unreachable, the upgrade is refused.
1845
-
1846
- A host it cannot reach (no route, a proxy that is down) is one line,
1847
- `could not reach registry.npmjs.org to read the release (…) — check the
1848
- network or the proxy and re-run`, never a stack; so is a document cut
1849
- after the headers or not JSON (a captive portal's page served with a
1850
- 200), and a tarball cut the same way. A binary whose directory this user
1851
- cannot write (a root-owned `/usr/local/bin`) is refused before the
1852
- download: `cannot write to <dir> (EACCES), where this vx lives — nothing
1853
- downloaded; re-run as a user who can (sudo vx upgrade), …`. The new
1854
- binary keeps the old one's mode (and, as root, its owner), and must
1855
- answer `--version` before the upgrade reports it installed: one that
1856
- does not start on this machine, or does not answer within 10 s, is
1857
- swapped back for the previous vx, and the command exits 1 saying so
1858
- (item 1097): `✓ vx upgrade installed 0.0.2`. On the version it
1859
- already is it says `› vx upgrade already at <version>` and downloads
1860
- nothing.
1861
-
1862
- **A vx a package manager installed** (the npm package's launcher runs
1863
- `…/node_modules/@vzn/vx-<os>-<arch>/vx`) is upgraded by that package
1864
- manager, never by a rename over its file, which the next install would
1865
- undo. The path names the install:
1866
-
1867
- | Install | Detected by | Runs |
1868
- | ---------------------- | ------------------------------------- | ----------------------------- |
1869
- | npm global | `<prefix>/lib/node_modules` | `npm install -g @vzn/vx@<v>` |
1870
- | pnpm global | `…/pnpm/global/…` | `pnpm add -g @vzn/vx@<v>` |
1871
- | bun global | `~/.bun/install/global` | `bun add -g @vzn/vx@<v>` |
1872
- | yarn global | `…/yarn/global/…` | `yarn global add @vzn/vx@<v>` |
1873
- | a project's dependency | the package.json that lists `@vzn/vx` | its manager's add, same field |
1874
-
1875
- A project's package.json is the tree's root, or for a hoisted workspace
1876
- the nearest one above the current directory that lists `@vzn/vx`. Its
1877
- manager is its lockfile (`pnpm-lock.yaml`, `bun.lock`, `yarn.lock`,
1878
- `package-lock.json`), else `packageManager`, else npm; the field it is
1879
- listed in is kept (`--save-dev`, `--dev`, …), and pnpm at a workspace
1880
- root adds `--workspace-root`. The lockfile is updated by that manager.
1881
- Every `@vzn/vx-*` plugin the same package.json lists from the registry
1882
- moves in the same install, to the same release, since every @vzn
1883
- package releases together: `pnpm add @vzn/vx@0.0.2 @vzn/vx-otel@0.0.2
1884
- --save-dev`. A plugin left behind is installed even when core is
1885
- already at the release (`› vx upgrade plugins → 0.0.2`); a plugin in
1886
- another field than core is refused, so no install moves a dependency's
1887
- field. The versions are pinned, so pnpm's `minimumReleaseAge` refuses a
1888
- release too young rather than mixing releases, and the error names
1889
- `minimumReleaseAgeExclude`. vx prints what it runs, runs it there, and
1890
- exits with an error if it fails:
1891
-
1892
- ```
1893
- › vx upgrade 0.0.1 → 0.0.2
1894
-
1895
- bun add @vzn/vx@0.0.2 in /repo
1896
- ```
1897
-
1898
- **Inside a project with its own vx**, a global vx hands `vx upgrade` to
1899
- the project's (§ Which vx runs), and one run moves both to the same
1900
- version (`TAG`, or latest): first the project's dependency, then the
1901
- global vx that started the run, each the way it was installed. Each
1902
- line names its install, and a refusal names the one that failed
1903
- (`vx upgrade: global vx /usr/local/bin/vx: cannot write to …`); a
1904
- project that fails leaves the global as it was. The project's vx run
1905
- directly (a package script, `node_modules/.bin/vx`) has no global behind it, so only
1906
- the project moves.
1907
-
1908
- **`vx upgrade -g`** (`--global`) moves only the global vx, wherever it
1909
- runs: it never hands the call to a project's vx, and it never touches a
1910
- `package.json` or a plugin. Run as the project's own vx, it refuses:
1911
- `vx upgrade -g: no global vx ran — <path> is the project's own. Install
1912
- one (npm install -g @vzn/vx), or run \`vx upgrade\` to move the project's`.
1913
-
1914
- ```
1915
- › vx upgrade project 0.0.1 → 0.0.2
1916
-
1917
- bun add @vzn/vx@0.0.2 in /repo
1918
-
1919
- › vx upgrade global 0.0.1 → 0.0.2
1920
-
1921
- /usr/local/bin/vx
1922
-
1923
- ✓ vx upgrade global installed 0.0.2
1924
- ```
1925
-
1926
- vx never installs Node or a package manager: one not on `PATH` is refused with the command to run. A vx run from an npx or
1927
- bunx cache has nothing installed to upgrade, and says to ask for
1928
- `@vzn/vx@latest` instead.
1929
-
1930
- Exit codes: `0` installed, or already at that version; `1` anything
1931
- refused or swapped back: from source, a cache install, a host it cannot
1932
- reach, a directory it cannot write, a signature or integrity that does
1933
- not verify, a binary that does not start, a package manager that failed
1934
- or is missing.
1935
-
1936
- ## `vx init`
1937
-
1938
- `vx init` reads no `package.json` (M, 2026-10-14). When the directory it
1939
- runs in, or one above it up to the git top-level, holds a
1940
- `package.json`, `pnpm-workspace.yaml`, `turbo.json`, `turbo.jsonc` or
1941
- `nx.json`, it runs `@vzn/vx-migrate` there, so `vx init` is the one
1942
- command (owner, 2026-10-08): the latest release through `bun x`,
1943
- never an installed one, so init always installs the latest `@vzn/vx`
1944
- and moves every `@vzn/vx-*` plugin the root lists to it (M,
1945
- 2026-10-16; a source checkout runs the nearest installed
1946
- `node_modules/@vzn/vx-migrate` instead) (`BUN_BE_BUN=1` makes the compiled
1947
- binary that runtime). `--dry`, `--format`, `--force`, `--mjs`,
1948
- `--native` and `--keep` pass through, and the exit is vx-migrate's.
1949
- vx-migrate maps a Turbo, Nx or Vite Task repo, else the `package.json`
1950
- scripts, one `vx.config.ts` per package; writes the workspace file with
1951
- the plugins the repo calls for (the lockfile's, a remote cache the repo
1952
- shows); installs them; and ends with `vx lock`. Its README holds the
1953
- rules; [`bunx @vzn/vx-migrate`](#bunx-vznvx-migrate) below sums them up.
1954
-
1955
- With none of those files, it writes a workspace whose one project is
1956
- the root, at the nearest workspace root up from the working directory,
1957
- else there:
1958
-
1959
- ```ts
1960
- // .vx/workspace.ts
1961
- import { defineWorkspace } from '@vzn/vx'
1962
-
1963
- export default defineWorkspace({ plugins: [] })
1964
- ```
1965
-
1966
- ```ts
1967
- // vx.config.ts
1968
- import { defineProject } from '@vzn/vx'
1969
-
1970
- export default defineProject({ tasks: {} })
1971
- ```
1972
-
1973
- plus `.vx/.gitignore` and `.vx/.gitattributes`. It then runs `vx lock`'s
1974
- step, and `next:` says to declare a task and run `vx lock && vx run
1975
- <task>`, preceded by `git init` outside a git work tree. The `@vzn/vx`
1976
- import needs no install: the running vx serves its own core to that
1977
- specifier (bin.ts registers the alias). A file already there is kept
1978
- and named under `kept`; `--force` replaces it, a file of another
1979
- extension included (`replaced`). `--mjs` writes `.vx/workspace.mjs` and
1980
- `vx.config.mjs`. `--dry` prints the files and writes nothing;
1981
- `--dry --format json` prints the plan as one JSON document
1982
- (`schemas/init.json`): `files` (each path and its whole text), `kept`,
1983
- `replaced`, `todos`, `notes` and `next`. `--native` and `--keep` answer
1984
- a Turbo or Nx repo's question, so they are refused here. `--format json`
1985
- without `--dry`, or with `--plugin`, is refused (`VX_E_USAGE`).
1986
-
1987
- `vx init --plugin <seam>` writes a plugin instead: `plugins/<seam>.ts`,
1988
- a small runnable plugin for that seam (`executor`, `cache`,
1989
- `telemetry`, `schedule`, `admit`, `commands`, `project`, `graph`,
1990
- `key`), and `plugins/<seam>.test.ts`, which drives it through `run()`
1991
- (`bun test`, with `@vzn/vx` installed). It prints the line that
1992
- declares it in the workspace file a run reads (`.vx/workspace.ts` when
1993
- there is none). The templates are TypeScript, so `--mjs` with
1994
- `--plugin` is refused. An existing file is refused without
1995
- `--force`; `--dry` names the files and writes nothing. The two files are
1996
- `packages/vx-plugin-examples/plugins`, which the gate runs, copied into
1997
- core (`src/cli/plugin-templates.ts`) and held equal by
1998
- `tests/plugin-templates.unsafe.test.ts` (H-21).
1999
-
2000
- Exit codes: `0` the files written (or, with `--dry`, printed); `1` a
2001
- refused flag, a parse error (`--mjs` with `--plugin` among them), or a
2002
- plugin file already there without `--force`. Handed to
2003
- `@vzn/vx-migrate`, the exit is its own.
2004
-
2005
- ## `bunx @vzn/vx-migrate`
2006
-
2007
- Mapping a repo's own config to vx is `@vzn/vx-migrate`, its own
2008
- package, run before a workspace file exists (it writes one); `vx init`
2009
- runs it in any repo with a `package.json` —
2010
-
2011
- ```
2012
- bunx @vzn/vx-migrate # turbo.json, an Nx graph, vite-plus run.tasks, else package.json scripts → vx.config.ts
2013
- bunx @vzn/vx-migrate --dry # print the generated files instead of writing
2014
- bunx @vzn/vx-migrate --force # overwrite existing vx.config.* / vx-preset.ts
2015
- bunx @vzn/vx-migrate --from nx # disambiguate when several sources are checked in (turbo, nx, vite-task, scripts)
2016
- ```
2017
-
2018
- — In a Turbo or Nx repo it asks a terminal native (a `vx.config.ts`
2019
- per package, the default) or keep (a workspace file declaring `turbo()`
2020
- or `nx()`, which read the repo's config live); `--native` or `--keep`
2021
- answer it, and without a terminal it is native. Both declare
2022
- `turboCache()` / `nxCache()` when the repo shows a remote cache
2023
- (turbo.json's `remoteCache`, a `turbo link`ed `.turbo/config.json`, or
2024
- a CI file setting `TURBO_TOKEN` / `NX_SELF_HOSTED_REMOTE_CACHE_SERVER`),
2025
- and name Nx Cloud, whose wire vx does not speak. Elsewhere each
2026
- package's `package.json` scripts become its tasks, and the workspace
2027
- file declares the `@vzn/vx-lockfile` plugin for the root's lockfile.
2028
- What the package writes goes through core's migration seam
2029
- (`applyMigration`, exported from `@vzn/vx`), which renders, guards
2030
- against overwriting, writes and reports. The mapping rules live in the
2031
- package's README.
2032
-
2033
- ## Turbo and Nx flags
2034
-
2035
- What a Turbo, Nx or Vite Task (`vp run`) user types into `vx run` (and `vx watch`): each flag
2036
- vx takes as it is (`same`), rewrites to its own spelling before the
2037
- parse (`alias`), or refuses with the vx way to say it (`refuse`) —
2038
- none is dropped in silence. An Nx flag's camelCase spelling (`--nxBail`,
2039
- `--skipNxCache`), which Nx's parser takes and its docs print, is its
2040
- kebab-case row. A `[=<bool>]` row takes `=true` (the flag bare) and `=false` (left out), as Turbo's and Nx's parsers do. `vx run-many` and `vx affected` name the
2041
- `vx run` that does the same, and the other verbs a hand types from
2042
- either tool name what does it here: `graph` and `query` (`--dry=json`), `ls`
2043
- (`vx show`), `reset` (`vx cache clean`), and
2044
- `daemon`, `login`, `logout`, `link` and `unlink`, which vx has no use
2045
- for. Nx's `project:target` (`vx run web:build`,
2046
- when `web` declares `build`) is answered with `vx run web#build`: from
2047
- outside a project in place of "not inside a project", in scope as the
2048
- unresolved name's `Did you mean`. A name that is no project but ends
2049
- the one scoped package (`cart` for `@nx-example/cart`, as Nx names it)
2050
- means that package there and in `vx build cart`. The table is `cli/foreign-flags.ts`,
2051
- rendered; `tests/foreign-flags.test.ts` drives every row and holds this
2052
- copy to the source.
2053
-
2054
- | runner | flag | outcome | in vx |
2055
- | ------ | ------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
2056
- | turbo | `--filter <v>` | same | `--filter`, the same grammar (`...[ref]`, `{dir}`, `^`, `!`) |
2057
- | turbo | `-F <v>` | alias | `--filter <v>` |
2058
- | turbo | `--concurrency <v>` | same | `--concurrency <n\|n%>` |
2059
- | turbo | `--continue=dependencies-successful` | alias | `--continue=deps-ok` |
2060
- | turbo | `--continue` | same | `--continue[=never\|deps-ok\|always]` |
2061
- | turbo | `--dry-run` | alias | `--dry[=text\|json]` |
2062
- | turbo | `--graph` | refuse | `--dry=json` prints the planned task graph, each task with its deps |
2063
- | turbo | `--force[=<bool>]` | same | `--force`: skip cache reads, keep writes |
2064
- | turbo | `--affected` | same | `--affected[=<base>]` |
2065
- | turbo | `--summarize` | refuse | use `vx last --format json` for the run's JSON record |
2066
- | turbo | `--output-logs=new-only` | refuse | use `--output-logs=full` (a hit replays its log) or `errors-only` |
2067
- | turbo | `--output-logs <v>` | same | `--output-logs full\|errors-only\|hash-only\|none` |
2068
- | turbo | `--no-cache` | same | `--no-cache` reads nothing either; Turbo's (reads, no writes) is `--cache-policy local:r,remote:r` |
2069
- | turbo | `--cache <v>` | alias | `--cache-policy`, the same spec (`local:rw,remote:r`) |
2070
- | turbo | `--cache-dir <v>` | same | `--cache-dir <path>` |
2071
- | turbo | `--profile` | same | `--profile[=<path>]` (Chrome trace) |
2072
- | turbo | `--only` | alias | `--exclude-dependencies` |
2073
- | turbo | `--color` | refuse | set `FORCE_COLOR=1` |
2074
- | turbo | `--no-color` | refuse | set `NO_COLOR=1` |
2075
- | turbo | `--heap <v>`, `--trace <v>` | refuse | use `--profile[=<path>]` for vx's own trace |
2076
- | turbo | `--login <v>` | refuse | vx has no login: a remote cache is a plugin (`turboCache()` from @vzn/vx-migrate) |
2077
- | turbo | `--no-update-notifier` | refuse | vx prints no update notice: drop it |
2078
- | turbo | `--skip-infer` | refuse | vx runs the binary it is: drop it |
2079
- | turbo | `--root-turbo-json <v>` | refuse | `turbo()` reads the `turbo.json` at the workspace root: move it there |
2080
- | turbo | `--experimental-otel-*` | refuse | telemetry is a plugin: `otel()` from @vzn/vx-otel in .vx/workspace.ts |
2081
- | nx | `--parallel <n>` | alias | `--concurrency <n>` (`--parallel=false` is 1) |
2082
- | turbo | `--parallel` | refuse | vx always honours `dependsOn`; `--concurrency <n>` sets how many run at once |
2083
- | turbo | `--scope <v>` | refuse | use `--filter <pkg>` |
2084
- | turbo | `--since <v>` | refuse | use `--filter '[<ref>]'` or `--affected=<ref>` |
2085
- | turbo | `--remote-only[=<bool>]` | refuse | use `--cache-policy local:,remote:rw` |
2086
- | turbo | `--remote-cache-read-only[=<bool>]` | refuse | use `--cache-policy local:rw,remote:r` |
2087
- | turbo | `--anon-profile` | refuse | use `--profile[=<path>]`; vx has no redacting variant, so read it before sharing it |
2088
- | turbo | `--cache-workers <v>` | refuse | vx sizes its own cache I/O: drop it |
2089
- | turbo | `--cwd <v>` | refuse | run vx from that directory: `cd <dir> && vx run …` |
2090
- | turbo | `--dangerously-disable-package-manager-check` | refuse | vx reads no `packageManager` field: drop it |
2091
- | turbo | `--env-mode <v>` | refuse | vx passes only the variables a task declares (strict): list the rest in `exec.env.passThrough` |
2092
- | turbo | `--framework-inference <v>` | refuse | under `turbo()` inference is Turbo's; take a name back with a `!` entry in the task's `env` |
2093
- | turbo | `--global-deps <v>` | refuse | declare them in `inputs.workspaceFiles` (under `turbo()`, turbo.json's `globalDependencies`) |
2094
- | turbo | `--json` | refuse | use `vx last --format json` for the run's result, `--dry=json` for the plan |
2095
- | turbo | `--log-file` | refuse | use `vx last --format json` for the run's JSON record |
2096
- | turbo | `--preflight` | refuse | set `turboCache({ preflight: true })` or `TURBO_PREFLIGHT=1` |
2097
- | turbo | `--remote-cache-timeout <v>` | refuse | set `turboCache({ timeoutMs })` or `TURBO_REMOTE_CACHE_TIMEOUT` |
2098
- | turbo | `--single-package` | refuse | a repo with no workspaces is one project already: drop it |
2099
- | turbo | `--token <v>`, `--team <v>`, `--api <v>` | refuse | a remote cache is a plugin: `turboCache()` from @vzn/vx-migrate in .vx/workspace.ts reads TURBO_TOKEN / TURBO_TEAM / TURBO_API |
2100
- | turbo | `--no-daemon`, `--daemon` | refuse | vx has no daemon: drop it |
2101
- | turbo | `--ui <v>`, `--log-order <v>`, `--log-prefix <v>` | refuse | vx frames each task’s output: `--output-logs <mode>` sets how much |
2102
- | nx | `-t <v>`, `--targets <v>`, `--target <v>` | alias | the task names, positional: `vx run build test` |
2103
- | nx | `-p <v>`, `--projects <v>` | alias | `--filter <pattern>`, one per project (`directory:<d>` is `./<d>`, `name:<n>` is `<n>`); a list opening with `!` starts from all (`--filter '*'`) |
2104
- | nx | `--exclude <v>` | alias | `--filter '!<pattern>'`, one per project |
2105
- | nx | `--base <v>` | alias | `--affected=<ref>` |
2106
- | nx | `--head HEAD` | alias | nothing: vx compares `--affected=<base>` with the working tree |
2107
- | nx | `--head <v>` | refuse | vx compares `--affected=<base>` with the working tree: check out the head first |
2108
- | nx | `--skip-nx-cache[=<bool>]` | alias | `--force` |
2109
- | nx | `--all` | same | `--all` |
2110
- | nx | `--nx-bail[=<bool>]` | alias | `--continue=never` |
2111
- | nx | `-c <v>`, `--configuration <v>` | refuse | a configuration is its own task: `vx run <target>:<configuration>` |
2112
- | nx | `--output-style <v>` | refuse | use `--output-logs <mode>` |
2113
- | nx | `--uncommitted`, `--untracked` | refuse | use `--affected=HEAD` (the working tree against the last commit) |
2114
- | nx | `--max-parallel <v>` | alias | `--concurrency <n>` |
2115
- | nx | `--exclude-task-dependencies[=<bool>]` | alias | `--exclude-dependencies` |
2116
- | nx | `--skip-remote-cache[=<bool>]` | alias | `--cache-policy local:rw,remote:` |
2117
- | nx | `--verbose` | refuse | use `--verbosity <n>` (1 adds the summary table) |
2118
- | nx | `--files <v>` | refuse | vx asks git what changed: `--affected=<base>` |
2119
- | nx | `--batch` | refuse | vx runs one command per task: drop it |
2120
- | nx | `--dte`, `--use-agents` | refuse | vx distributes nothing: drop it |
2121
- | nx | `--nx-ignore-cycles` | refuse | vx refuses a task cycle by name: break it |
2122
- | nx | `--runner <v>` | refuse | a remote cache is a plugin: `nxCache()` from @vzn/vx-migrate in .vx/workspace.ts |
2123
- | nx | `--skip-sync` | refuse | vx never runs sync generators: drop it |
2124
- | nx | `--tui`, `--no-tui`, `--tui-auto-exit` | refuse | vx frames each task’s output: `--output-logs <mode>` sets how much |
2125
- | nx | `--no-cloud` | refuse | vx has no cloud: drop it |
2126
- | vp | `-r`, `--recursive` | alias | `--all` |
2127
- | vp | `-w`, `--workspace-root` | alias | `--filter //` (the root project) |
2128
- | vp | `--concurrency-limit <v>` | alias | `--concurrency <n>` |
2129
- | vp | `--ignore-depends-on` | alias | `--exclude-dependencies` |
2130
- | vp | `--fail-if-no-match` | alias | nothing: a filter that matches nothing already fails the run |
2131
- | vp | `--log <v>` | refuse | vx frames each task’s output: `--output-logs <mode>` sets how much |
2132
- | vp | `--last-details` | refuse | `vx last` prints the last run |
2133
-
2134
- ## Machine-readable output
2135
-
2136
- `vx show`, `vx info`, `vx why`, `vx last` and `vx cache clean` take
2137
- `--format json`. Each prints one JSON document on one line, whose shape
2138
- is a checked-in JSON Schema (draft 2020-12) shipped with the package:
2139
- `schemas/show.json`, `schemas/info.json`, `schemas/why.json`,
2140
- `schemas/last.json`, `schemas/cache.json`
2141
- (`node_modules/@vzn/vx/schemas/` in an install). Every object closes its
2142
- key set, so a field vx adds is a schema change, never a surprise.
2143
- `tests/cli-json-schemas.test.ts` holds each verb's output to its schema,
2144
- each declared field to some output, and each object's keys to the
2145
- source type. `tests/cli-json-doc.test.ts` holds this page to the
2146
- schemas: each `{ … }` a verb's section shows is an object its schema
2147
- closes with those keys, each object the verb prints whole is shown, and
2148
- `vx info`'s field list is its schema's top level.
2149
-
2150
- A run's result as JSON is `vx last --format json`, read after the run
2151
- from the run history: one row per task with its status, key and cache
2152
- result. `vx run --dry=json` prints the plan.
2153
-
2154
- ### Error codes
2155
-
2156
- A verb asked for JSON (`--format json`, `--format=json`, `--dry=json`,
2157
- before any `--`) answers a refusal on stdout too, as one line that
2158
- `schemas/error.json` describes: `{"ok":false,"error":{"code","message","docs"}}`.
2159
- `docs` links the code's section below, which says what to do;
2160
- `vx docs <code>` prints that section offline. The exit code (1) and the
2161
- prose on stderr do not change. Branch on the code; the message may change.
2162
-
2163
- #### `VX_E_USAGE`
2164
-
2165
- A flag, argument, selection or `VX_*` environment value the verb does
2166
- not take. The message names it, and a close spelling the verb does take when there is one;
2167
- `vx <verb> --help` lists every flag.
2168
-
2169
- #### `VX_E_UNKNOWN_COMMAND`
2170
-
2171
- No verb of that name here, core's or a declared plugin's. `vx --help`
2172
- lists core's verbs. A plugin's verb exists once its plugin is in
2173
- `.vx/workspace.ts` `plugins`; a task runs as `vx run <task>`.
2174
-
2175
- #### `VX_E_UNKNOWN_TASK`
2176
-
2177
- No project in scope declares a requested task. The message lists the
2178
- tasks it found; `vx show --format json` lists each project's. Check the
2179
- spelling, and the scope: `--filter`, `--all`, or the project the working
2180
- directory is in. A run reads its configs from the lock, so a task
2181
- added to a `vx.config` since the last `vx lock` is not there yet: run
2182
- `vx lock`.
2183
-
2184
- #### `VX_E_UNKNOWN_PROJECT`
2185
-
2186
- `vx show` names a project the workspace does not have. A project is named
2187
- by its config's `name`, else by a plugin (a lockfile plugin names it from
2188
- its `package.json`), else by its directory; `vx show --format json` lists them.
2189
-
2190
- #### `VX_E_NO_HISTORY`
2191
-
2192
- `vx why` / `vx last` find no recorded run for what they were asked. Run
2193
- the task once (`vx run <task>`); history is this checkout's own.
2194
-
2195
- #### `VX_E_AMBIGUOUS`
2196
-
2197
- `vx why <task>` matches the task in several projects. Name one:
2198
- `vx why <project>#<task>`.
2199
-
2200
- #### `VX_E_CONFIG`
2201
-
2202
- A `vx.config.*` or `.vx/workspace.*` that does not load or is refused. The
2203
- message names the file and the field; fix that field
2204
- (`vx docs <field>` prints its reference). `vx info --format json` lists
2205
- every config error at once. A project config's refusal is `vx lock`'s,
2206
- not a run's: a run never evaluates one.
2207
-
2208
- #### `VX_E_CYCLE`
2209
-
2210
- The task graph has a cycle. The message names the loop; drop one of its
2211
- `dependsOn` edges.
2212
- `vx lock` refuses it, not a run: a run trusts the graph the lock holds.
2213
-
2214
- #### `VX_E_NO_WORKSPACE`
2215
-
2216
- No `.vx/workspace.*` in the working directory or a parent, up to the git
2217
- top-level (a file above the checkout never counts). The file marks the
2218
- workspace root, so run `vx init` to create one. Outside git, one another
2219
- user owns does not count (`security.md`); the message names it.
2220
-
2221
- #### `VX_E_CACHE_VERSION`
2222
-
2223
- A reading verb met a cache index from another vx version. It reads
2224
- nothing from it and leaves it be; the next `vx run` resets it. Or run the
2225
- vx version that wrote it.
2226
-
2227
- #### `VX_E_REFUSED`
2228
-
2229
- Any other refusal (a plugin's, a check's) with no code of its own. The
2230
- message is that plugin's or check's: do what it says.
2231
- `vx info --format json` lists the plugins.
2232
-
2233
- #### `VX_E_FS`
2234
-
2235
- The file system refused a write: permissions, a full disk. The message
2236
- names the path. Free space (`vx cache clean --max-size <size>` trims the
2237
- cache) or grant the write, then re-run.
2238
-
2239
- #### `VX_E_FDS`
2240
-
2241
- Out of file descriptors. Raise the limit (`ulimit -n 4096`), or run fewer
2242
- tasks at once (`--concurrency`), then re-run.
2243
-
2244
- #### `VX_E_AFFECTED_BASE`
2245
-
2246
- `--affected` (or `--affected=<ref>`) has no base to diff against: the
2247
- ref does not resolve, is a range (`A..B`) or an exclusion (`^A`), names
2248
- a path inside a commit, or the repository has no history to compare
2249
- with. Pass a branch or commit you have locally, alone:
2250
- `--affected=origin/main`. On CI, fetch the history the base needs
2251
- (`fetch-depth: 0` on actions/checkout, or `git fetch --unshallow`).
2252
-
2253
- #### `VX_E_GIT`
2254
-
2255
- A git command vx runs to read the diff failed; the message carries
2256
- git's exit and its own words. Run that command in the workspace to see
2257
- why (a broken index, a missing object in a partial clone).
2258
-
2259
- #### `VX_E_DEPENDS_ON`
2260
-
2261
- A task's `dependsOn` names a task or project that is not declared, or uses
2262
- a form vx does not take (a bare wildcard, a negation, a pattern in
2263
- `pkg#task`). The message names the task and the entry: declare the
2264
- target, fix its spelling, or drop the entry.
2265
- `vx lock` refuses it, not a run: a run trusts the graph the lock holds.
2266
-
2267
- A `^task` with no edge source is refused too: ``<id>: ^build needs
2268
- project edges; add pnpm()/bun()/npm()/yarn() and install so its
2269
- lockfile exists, or a plugin that returns `deps` ``. A lockfile plugin
2270
- whose lockfile is absent gives no edges. Core reads no edge from a manifest, so with no plugin
2271
- returning `deps` every `^` would resolve to nothing and fold no
2272
- upstream into the key. Declare the lockfile plugin in
2273
- `.vx/workspace.ts` (or a plugin of your own that returns `deps`); a run
2274
- refuses it as well when the plugin left the workspace after the lock.
2275
-
2276
- #### `VX_E_OUTPUT_OVERLAP`
2277
-
2278
- Two tasks declare overlapping outputs with no `dependsOn` edge between
2279
- them, so whichever runs second deletes the other's files. Give each task
2280
- its own output path, or make one depend on the other.
2281
- `vx lock` refuses it, not a run: a run trusts the graph the lock holds.
2282
-
2283
- #### `VX_E_INPUT_IS_OUTPUT`
2284
-
2285
- A task's `inputs` match outputs: its own (vx cleans outputs before
2286
- a run, so it would delete its sources) or another task's (its key must
2287
- not read them; `dependsOn` already cascades). Exclude the outputs from
2288
- the inputs with a `!` glob, as the message shows, or declare no outputs
2289
- for a task that rewrites files in place.
2290
- `vx lock` refuses it, not a run: a run trusts the graph the lock holds.
2291
-
2292
- #### `VX_E_DEPENDENCY_RULE`
2293
-
2294
- `vx lock` found a project depending on one a `run.rules.dependencies`
2295
- rule denies or does not allow. A header counts the broken edges and
2296
- each indented line under it names both projects and the rule. Drop the dependency, or change the project's tags or the rule.
2297
-
2298
- #### `VX_E_INTERNAL`
2299
-
2300
- A defect in vx; stderr carries the stack. Report it with that stack at
2301
- https://github.com/vznjs/vx/issues.
2302
-
2303
- A plugin can give its refusal a code: `new UserError(message, 'MY_CODE')`.
2304
-
2305
- **Streams.** A verb whose stdout is a product (a `--format json`
2306
- document, `--dry` / `--dry=json`, a completion
2307
- script) writes that product alone there; a notice or warning it meets
2308
- on the way (a cache index from another vx version, a plugin's warning) goes
2309
- to stderr, as every refusal does. `vx run`'s stdout is the run's frame,
2310
- the tasks' output it carries. `tests/cli-streams.test.ts` holds each
2311
- product verb to it under a notice.
2312
-
2313
- ## `vx show`
2314
-
2315
- Introspect the workspace's **resolved configs** — what a run would see
2316
- right now. Configs load through the same path a run uses: from
2317
- `.vx/projects.lock.json`, plugin `config` and `project` stages applied,
2318
- so a package a plugin gives tasks to (`turbo()` or `nx()` from
2319
- `@vzn/vx-migrate`, a temporary start toward native config) shows them.
2320
- `vx show` evaluates no config: an edit shows after `vx lock`, and with
2321
- no lock it refuses as a run does (`no lock: run \`vx lock\``).
2322
-
2323
- ```
2324
- vx show # list every project
2325
- vx show <project> # one project's resolved config (`//`: the root project's)
2326
- vx show <pkg>#<task> # a single task (`//#<task>`: the root project's)
2327
- vx show <task> # that task in every project declaring it
2328
- vx show [<task>] --filter <p> # only the projects `vx run --filter <p>` selects
2329
- vx show <task> --affected # only the projects whose <task> `vx run <task> --affected` runs
2330
- vx show --affected # the run's candidates: the changed projects and their dependents
2331
- vx show ... --format json # machine-readable (default: pretty)
2332
- ```
2333
-
2334
- `--filter` and `--affected[=<ref>]` narrow the list, or a task's
2335
- projects, as `vx run` selects: `turbo ls --affected` is
2336
- `vx show --affected`, and `nx show projects --affected --with-target t`
2337
- is `vx show t --affected`. Beside a task, `--affected` lists the
2338
- projects whose task the run keeps: a change reaches another project
2339
- only along a task edge, so a dependent whose `test` has no `^test` is
2340
- not listed (X-147). With no task there is no edge to follow, and the
2341
- list is the run's candidates, `--filter "...[<base>]"`'s: the changed
2342
- projects and their package dependents. Beside one project or
2343
- `<pkg>#<task>` they are refused.
2344
-
2345
- Nx's spellings name these: `vx show projects` (when no project or task
2346
- has that name) and `vx show project <name>` say `vx show` and
2347
- `vx show <name>`.
2348
-
2349
- No target: a table, one row per project — name, root-relative dir,
2350
- task count, and `no vx config` for config-less packages; one whose
2351
- tasks all come from plugins reads `N tasks from plugins (no vx
2352
- config)`, and one a configured project depends on reads `1 task,
2353
- default build (no vx config)`: a run loads it for that dependant's
2354
- `^build` (X-48). A `tags` column appears when any project has tags. With `--format json`
2355
- it's an array of `{ name, dir, tags: string[], tasks: string[] }`.
2356
-
2357
- ```
2358
- $ vx show
2359
- project dir tasks tags
2360
- app packages/app 3 tasks scope:web
2361
- bare packages/bare no vx config
2362
- ```
2363
-
2364
- A report naming the project opens it: its `dir`, its `tags`, and
2365
- `config none` for a package with no config file. Then
2366
- `vx show <project>` prints a block per task, headed by its
2367
- `project#task`, with every field the run reads: description, command (`(group)` for group tasks), `dependsOn`,
2368
- `timeout`, `retries`, `env.passThrough` / `env.define` / `env.secret`,
2369
- `remote`, `sandbox`, `persistent`, `interactive` (`yes` / `no`), and the
2370
- cache block
2371
- (`inputs.files` / `.workspaceFiles` / `.env` / `.tasks` / `.runtime` /
2372
- `.workspaceRuntime`, `outputs.files` / `.workspaceFiles`). Fields the
2373
- task does not set are not printed; a value that spans lines (a
2374
- multi-line command) continues under its first line. `--format json` emits `{ name, dir,
2375
- config }` with the config exactly as resolved. `vx show <pkg>#<task>`
2376
- narrows to one task (`{ name, dir, task, config }` in JSON). A bare
2377
- name that is no project is a task: `vx show build` prints the block
2378
- from every project declaring `build` (an array of the one-task shape
2379
- in JSON). The default `build` is declared by none: it counts in the
2380
- list and `vx show <project>`, but `vx show build`, like
2381
- `vx run build --all`, does not name it (X-145).
2382
-
2383
- ```
2384
- $ vx show app#build
2385
-
2386
- ─ app#build ──────────────────────────────────────────────────
2387
- dir packages/app
2388
- description compile the app
2389
- command tsc -b
2390
- dependsOn ^build
2391
- inputs.files src/**
2392
- inputs.env NODE_ENV
2393
- outputs.files dist/**
2394
- ```
2395
-
2396
- Unknown project / task names exit `1` with the same near-miss hint
2397
- every verb gives (two edits, or a partial name); a bare name that is
2398
- neither reads `unknown project or task: "buidl" — did you mean build?`,
2399
- and a `pkg#task` hints whole specs (`unknown task: "app#bui" — did you
2400
- mean app#build?`). With nothing near, it lists what exists instead: `unknown
2401
- project: "zzz"; projects: app, lib`, `; its tasks: app#build, app#test`,
2402
- or `; projects and tasks: …` (M-57).
2403
-
2404
- An empty target (`vx show ''`) is refused: omit it to list every project.
2405
-
2406
- In a workspace with no project, the list is empty and `vx show` says so
2407
- on stderr (`no projects`) and names `vx init`.
2408
-
2409
- Exit codes: `0` success; `1` parse error or unknown target.
2410
-
2411
- Two runs on one workspace take turns: the second waits for the first's
2412
- run lock and, after a second, says `[vx] waiting for another vx run
2413
- (pid N) on this workspace to finish…` (see caching.md § Concurrent
2414
- runs). The lock lives in this user's own directory in the temp
2415
- directory, so two runs take turns only when they are one user's and
2416
- share `TMPDIR`: a `nix develop` shell sets its own.
2417
-
2418
- Every verb: a path vx must write that this user cannot (`EACCES`,
2419
- `EPERM`, `EROFS` — a read-only checkout, another user's files) or that
2420
- the disk has no room for (`ENOSPC`, `EDQUOT`) exits 1 with one line
2421
- naming the path, `vx: EACCES: permission denied, open '…/.vx/projects.lock.json'
2422
- — a path vx must write is not writable by this user` (or `— the disk
2423
- that path is on is full`), never a stack. Inside a run the task's line
2424
- says the same. A process out of file descriptors (`EMFILE`, `ENFILE`;
2425
- macOS starts a shell at 256) exits 1 with the error and `— the process
2426
- is out of file descriptors; raise the limit (ulimit -n 4096) and re-run`.
2427
-
2428
- ## `vx info`
2429
-
2430
- Workspace doctor — one screen of facts for bug reports and sanity
2431
- checks. The task count and the sandbox row's declared count come from
2432
- the same load a run uses: the lock, plugin stages included. `vx info`
2433
- evaluates no config; with no lock, or one a run would refuse, both
2434
- counts are zero and `projects lock` says `no` — a broken config is
2435
- `vx lock`'s to report:
2436
-
2437
- ```
2438
- $ vx info
2439
-
2440
- ─ vx info ────────────────────────────────────────────────────
2441
- vx 0.0.0
2442
- bun 1.4.2
2443
- git 2.53.0
2444
- git status cache core.fsmonitor, core.untrackedCache off
2445
- workspace root /work/repo
2446
- projects 12 (34 tasks)
2447
- plugins 2 — @vzn/vx-reapi (executor, cache); @vzn/vx-otel (telemetry)
2448
- workers 2 — cgroup CPU quota 2 of 8 cores
2449
- memory 13 GB usable — cgroup limit; the machine has 16 GB
2450
- cache dir /work/repo/.vx/cache (8.2 MB, this workspace's run history)
2451
- cache store /home/me/.vx/3f2a9c1e7b4d5a60/cache (1.3 GB, shared by this repo's checkouts)
2452
- cache versions keys vx-cache-v43 · index schema v40
2453
- cache entries 42 (1.3 GB)
2454
- orphans 3 artifacts (12 MB) the index does not know — `vx cache clean` reaps them
2455
- task runs (24h) 7 (5 cache hits)
2456
- flaky tasks 1 — web#test (3 of 11 runs failed on unchanged inputs)
2457
- sandbox available (9 tasks declare exec.sandbox)
2458
- projects lock yes
2459
- ```
2460
-
2461
- - `git` shows `(not found)` when the binary is missing.
2462
- - `git status cache`: vx runs ONE `git status` per run to find dirty
2463
- and untracked files, and on a large tree that walk is the warm run's
2464
- critical path. git's `core.fsmonitor` (a daemon that watches the
2465
- worktree) and `core.untrackedCache` make it near-free after the first
2466
- run; both are off by default, so `vx info` says when they are.
2467
- - `sandbox` is the runtime probe's verdict for THIS host — one sandboxed
2468
- `true`, memoized — and how many loaded tasks declare `exec.sandbox`. A
2469
- declared sandbox whose runtime cannot start fails the task at run
2470
- time rather than downgrading, so `unavailable — <why>; 3 tasks declare
2471
- exec.sandbox and will fail` says it first: root inside a container
2472
- (the runtime's seccomp helper cannot create its nested user namespace;
2473
- run as a non-root user or set `sandbox.weakerWhenNested: true`), a
2474
- missing bubblewrap, socat or ripgrep, a nested seatbelt on macOS.
2475
- On Linux an available sandbox that cannot report the reads it denies
2476
- (no `strace` on PATH, one whose `--version` fails, or one that may not
2477
- attach) adds `, untraced — <why>, so the reads it denies go
2478
- unreported`: the sandbox still enforces, but a task that tolerates a
2479
- denied read passes and caches with no word of it. The `--format json` fact is
2480
- `sandbox.untraced`, the reason or `null`.
2481
- - `plugins` names every plugin `.vx/workspace.*` declares and the seams
2482
- each fills, in pipeline order (`config`, `discover`, `project`, `graph`, `key`,
2483
- `fingerprint`, `schedule`, `admit`, `executor`, `cache`, `telemetry`,
2484
- `output`, `setup`, `commands`), or `none`. It reads the declarations: a plugin
2485
- that declines a task at run time still lists its seam here.
2486
- - `workers` is the count a run defaults to and where it comes from:
2487
- `.vx/workspace.ts`'s `run.concurrency` when set, else the cores this
2488
- process may use — the CPU count, capped by the cgroup CPU quota a
2489
- container runs under (`8 — the CPU count`, `2 — cgroup CPU quota 2 of
2490
- 8 cores`). `memory` is what a memory-packing policy budgets
2491
- (`@vzn/vx-schedule-history`): the machine's total, capped by the
2492
- cgroup limit — inside a container the raw numbers are the host's,
2493
- and the doctor is where to see which one a run reads.
2494
- - `flaky tasks` is the standing list a run's flaky notes add to:
2495
- every task whose history (30 days, what the cache keeps) holds a
2496
- cache key that failed after it had passed, most failures first, with
2497
- the outcomes over those keys. A cache hit counts as a pass (it
2498
- replayed one), and a pass that took a retry counts too, its failed
2499
- attempts counted as failures. A task with no cache block keys on its
2500
- config alone, so its key says nothing about its inputs: it is never
2501
- listed. A pass or a failure behind a failed dependency, in a run that
2502
- continues past failures, is not its key's and counts on no key. `none` when
2503
- no key did.
2504
- - `task runs (24h)` counts task runs, executed and replayed alike, so
2505
- the hits are a share of it: three `vx run` of two tasks are six. An
2506
- invocation is what `vx last` calls a run; `vx last --list` counts
2507
- those.
2508
- - `cache dir` holds the index and this workspace's run history; its size
2509
- is on disk. `cache store` is where the entries and their artifacts
2510
- live: the store shared by this repo's checkouts (`docs/caching.md`),
2511
- with its size on disk, or `none: the cache dir holds the entries` when
2512
- the workspace names its cache dir. Before a first run it is the store a
2513
- run would open. A bare `vx cache clean` frees both sizes.
2514
- - `cache entries` counts the entries and their size; with a
2515
- `cacheRetention.maxSize` it reads against it (`812 (3.0 GB of 10 GB,
2516
- 30%)`), and a `cache retention` row says what each run's end evicts.
2517
- Paths under the home directory print as `~/…`; the JSON keeps them
2518
- absolute.
2519
- - `cache policy` is the policy a `vx run` takes when no flag names one,
2520
- and where it comes from (`VX_CACHE_POLICY`, `.vx/workspace`'s `cache.policy`,
2521
- or the default, every layer on, remote writes off off CI); `cache scope`
2522
- is the same for the scope (`VX_CACHE_SCOPE`, `cache.scope`, which
2523
- `github()` may set, or none: the default keys).
2524
- - `cache versions` are the two constants a bug report needs and the
2525
- reset notice names: the key prefix (`CACHE_VERSION`; a bump orphans
2526
- every entry) and the index schema (`SCHEMA_VERSION`; a mismatch drops
2527
- every table on the next open, which the run then says once).
2528
- - `orphans` appears only when the cache directory holds artifacts or
2529
- save temps the index has no row for, older than an hour (what a
2530
- `SCHEMA_VERSION` reset leaves behind; a fresh one is a save in
2531
- flight). A task that asks for one's key again indexes it and hits;
2532
- the rest only `vx cache clean` reclaims, so the doctor says so.
2533
- - `--format json` prints the same facts as one typed object, for a
2534
- script or a bug-report template: `vx`, `vxPath` (the file that ran),
2535
- `handedOffFrom` (the vx that handed the call to the project's own, else
2536
- null), `bun`, `bunSupported` (false
2537
- below Bun 1.4.0), `git` (null when not
2538
- found), `gitStatusCache` (`{ fsmonitor, untrackedCache }`, null when
2539
- git could not answer), `workspaceRoot`, `projects`, `tasks`,
2540
- `plugins` (`[{ name, seams }]`), `workers` (`{ count, source, cores,
2541
- cpuQuota }`, the source one of `workspace` / `cgroup` / `cores`,
2542
- `cpuQuota` in cores or null), `memory` (`{ usableBytes, totalBytes,
2543
- cgroupLimitBytes }`, the limit null when none binds), `cacheDir`, `cacheDirBytes`
2544
- (on disk: the index and run history), `cacheStore` (null when the cache
2545
- dir holds the entries), `cacheStoreBytes` (on disk, null with no store), `cacheVersion`,
2546
- `schemaVersion`, `cacheEntries`, `cacheBytes`, `settings` (every
2547
- setting by path, `{ value, source }`, `source` one of `env` /
2548
- `workspace` / `default`, `value` null for the default), `cacheRetention`
2549
- (`{ olderThan, maxSize, maxBytes }`, each only when set, null when none is declared),
2550
- `orphans` (`{ artifacts, bytes }`, always present), `runs24h`, `hits24h` (task
2551
- runs, as the row), `restored24h` (of those hits, the ones that restored
2552
- outputs; the rest found them up to date), `flakyTasks` (`[{ taskId, project, task, keys, passes, failures }]`,
2553
- empty when none), `lockfile`, `sandbox` (`{ available, reason,
2554
- declared, untraced }`, `declared` the count of tasks with `exec.sandbox`,
2555
- `untraced` the reason denied reads go unreported or null). The
2556
- pretty rows render this object;
2557
- there is no second source.
2558
- - `--cache-dir <path>` reads the cache a run with the same flag uses, as
2559
- `vx why`, `vx last` and `vx cache clean` do.
2560
-
2561
- Exit codes: `0` the facts printed, with no lock included; `1` no
2562
- workspace here or above, or a parse error.
2563
-
2564
- ## `vx why`
2565
-
2566
- Answer "why did this task re-run?" from the terminal, from the
2567
- per-component input fingerprints core persists on every miss. Read-only over the local
2568
- `cache.db`: no config evaluation, no re-hash.
2569
-
2570
- ```
2571
- vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]
2572
- ```
2573
-
2574
- By default it compares the task's **latest** recorded run against its
2575
- immediately-previous run; `--run RUNID` pins a specific run (a unique
2576
- prefix of the id is enough; a task that run did not run is refused,
2577
- pointing at `vx last --list`, and so is an id no run carries). History is
2578
- the cache directory's, not the checkout's: worktrees that share one
2579
- `--cache-dir` share one history, so the previous run may be another
2580
- worktree's (its branch is in `vx last`), and its edits read as changes. Latest and
2581
- previous are the order runs were recorded, not their clock: a clock that
2582
- stepped back once swapped the two and diffed the edit backwards. A bare task
2583
- name resolves when exactly one project ran it (several → the project
2584
- the current directory is in, as `vx run` picks, else an error listing
2585
- the candidates; unknown → include-match suggestions, or, with
2586
- none near, a pointer to `vx last --list`, or word that nothing has run
2587
- yet; after runs of only groups, which record no task row, that no run
2588
- executed a task and to ask about one the group depends on, X-50).
2589
-
2590
- A control character in a component's name (a file named with an
2591
- escape or a carriage return) prints as `\xNN`, so no file name drives
2592
- the terminal; `--format json` carries the name as it is.
2593
-
2594
- An **unchanged** key has three endings, and the verdict distinguishes
2595
- them rather than calling all three a re-run: the run was served from
2596
- cache (nothing re-ran), it re-executed on the same key, or it recorded
2597
- no cache outcome at all, in which case vx says so instead of guessing.
2598
- A re-execution names its cause when the index shows one: the previous
2599
- run on the key failed and saved nothing, the run did not read the cache
2600
- (`--force`, or a `--cache-policy` without read), no entry for the key was
2601
- there when it ran (pruned or evicted), the previous run was not saved
2602
- because it ran beside a failed task (a task run past a failed dependency
2603
- under `--continue` is never cached; said ahead of a prune, and as
2604
- "neither run saved it" when this run ran beside one too), or the previous run executed and no entry
2605
- holds the key (its save failed, or a prune took it; a previous run whose
2606
- policy wrote no cache says that instead). Otherwise, with this run's
2607
- policy recorded as reading the cache, it says it cannot name the cause;
2608
- only a run with no recorded policy names `--no-cache` / `--force`.
2609
-
2610
- ```
2611
- $ vx why app#build
2612
- app#build — run 01a1193a-7b39-75a9-870b-5c03e26d7104
2613
- this run 2026-10-08T01:57:05.486Z · success · executed · key 9ef9806a9c69209e
2614
- previous 2026-10-08T01:57:05.325Z · success · key e240348b20e79fd6
2615
- verdict cache key changed: file packages/app/src/input.txt
2616
-
2617
- what changed (1 component, 6 unchanged):
2618
- changed file packages/app/src/input.txt 78981922613b2afb6025042ff6bd878ac1994e85 → 61780798228d17af2d34fce4cfbdf35556832472
2619
-
2620
- what to do:
2621
- file an edit re-runs by design; a file the task does not read belongs out of inputs.files
2622
- ```
2623
-
2624
- A changed key's verdict names what moved, kind and name, three at most
2625
- and then a count (`cache key changed: env MODE`), so a key that moved
2626
- only by an environment variable says so in one line; when neither
2627
- entry kept its components it reads `(inputs differ)`.
2628
-
2629
- Under the rows, `what to do` gives one line per changed kind: what
2630
- moves it and how to stop a move the task does not need. An upstream
2631
- row is only the carrier, so `vx why` follows each dependency that moved
2632
- in the same run down to the tasks whose OWN inputs moved and prints
2633
- them under `root cause`, one chain per root (five at most, then a
2634
- count), and `what to do` speaks to those kinds instead:
2635
-
2636
- ```
2637
- root cause:
2638
- app#build ← lib#build ← file packages/lib/src/index.ts
2639
- ```
2640
-
2641
- A dependency the walk cannot find in the run keeps the `upstream` line,
2642
- which names the `vx why` to run next; a `config` line names the
2643
- `vx show` that prints the task's config now. `--format json` carries
2644
- the walk as `roots` (`chain`, `entries`).
2645
-
2646
- A moved input file whose last change fell inside a recorded task's run
2647
- window names that task: a task that writes a file another key reads (a
2648
- build rewriting a translation file in another package) re-keys that key
2649
- on every run, so the miss is right and only moving the write ends it.
2650
- `--format json` carries these as `writers` (`taskId`, `file`,
2651
- `writers`); an edit made outside a run names none.
2652
-
2653
- ```
2654
- changed during a run:
2655
- packages/lib/src/gen.txt while app#build ran
2656
- ```
2657
-
2658
- A hit's line is `cache-hit · key …` (or `cache-hit-remote`): the status
2659
- names the hit and its tier, so only an executed run carries the word.
2660
- A skipped task never ran and derived no key: its line is `skipped · no key`.
2661
- A row's kind is what the key folded: `file` (an input file, by blob
2662
- id), `linked` (the content behind a `file` that is a symlink), `env` (a declared variable, by digest), `runtime` and
2663
- `ws-runtime` (a declared command's output, `inputs.runtime` and
2664
- `inputs.workspaceRuntime`), `forward` (the argv forwarded after `--`),
2665
- `config` (the evaluated task config), `upstream` (a dependency's input
2666
- key — a lockfile change moves it too, so that row rides with the
2667
- lockfile plugin's), `plugin` (a `key` plugin's material, by name; a
2668
- lockfile plugin's dependency closure) and
2669
- `format` (the key format vx itself folds: it moves only with an
2670
- upgrade, and re-keys every task once, X-142).
2671
- The component-level rows come from the `entry_inputs` input
2672
- fingerprints persisted with each cache entry; when either side's entry
2673
- is gone (pruned, or the run failed and never saved one) the verb still
2674
- names the hash change and says the component diff is unavailable. When
2675
- only the previous run failed, the diff is taken against the last run
2676
- that saved an entry instead, and the detail line names that run. A
2677
- task with `inputs` and no `cache` block derives a key too — it is what
2678
- dependents fold — but saves no entry, so for it the verb can only report
2679
- the key change and says so.
2680
- `--format json` emits one machine-readable object (`{ taskId, runId,
2681
- why, diff, roots, writers }`); when the task's recorded runs predate run ids, `{ taskId,
2682
- why, diff, explanation }`, `why` and `diff` null and `explanation` the
2683
- latest cache entry's key.
2684
-
2685
- Exit codes: `0` the task's run explained; `1` no recorded run of the
2686
- task, a `--run` with no row for it, or a parse error.
2687
-
2688
- ## `vx last`
2689
-
2690
- Replay a recorded run's summary from the local history — no
2691
- re-execution, no cache probe, no config evaluation. With the
2692
- self-hosted dashboard gone (2026-08-23), this is THE run-replay
2693
- surface. A run a Ctrl-C (or SIGTERM, SIGHUP) stopped records nothing,
2694
- so `vx last` still shows the run before it.
2695
-
2696
- ```
2697
- vx last [RUNID] [--list[=N]] [--failed] [--log <task>] [--stats [<task>] [--branch <name>]] [--format pretty|json] [--cache-dir <path>]
2698
- ```
2699
-
2700
- Bare `vx last` replays the most recent run: a header (verdict, command,
2701
- when, duration, branch @ sha, CI, task/hit/failure counts) and a
2702
- per-task table — status, id, duration, cache key, and for a task that
2703
- executed, what it used (peak RSS and CPU parallelism, `312 MB · 1.4×
2704
- cpu`: the runner's own record, and the number `@vzn/vx-schedule-history`
2705
- reserves from; a hit spent nothing and shows nothing, and a task lighter
2706
- than vx itself shows its CPU only) — failures first, each as the frame
2707
- read it, `failed (exit 137)`, with the signal an exit above 128 stands
2708
- for at the row's end (`128 + SIGKILL`; the shell's convention, so a
2709
- command that exits 137 on its own reads the same). Where the run's
2710
- footer gave a reason, the row ends with it instead: `timed out`,
2711
- `never ready: exited`, `2 sandbox violations`, and for a skipped task
2712
- `after lib#build failed` (the v27 columns; rows older than them read
2713
- as before). A task whose key met a remote failure ends with it, read or
2714
- write alike: `remote ✗ download: connection refused; upload: 503` (the
2715
- v40 column, `remoteError` in `--format json`). Rows run failures first, then what executed or was
2716
- skipped, then hits; past sixteen hits the rest fold into one line with
2717
- their count (`… +980 more cache hits`) — a thousand-task warm run is a
2718
- thousand rows otherwise, with the one failure a screen above the
2719
- prompt — and the sixteen shown are the slowest restores, the one thing
2720
- a hit's row tells. `--format json` lists every row.
2721
- Below the tasks, the steps the run did not wait on (`vx:prefetch`, a
2722
- remote's downloads beside the tasks), which its own output never shows:
2723
- `success vx:prefetch 1.20s internal`. They are kept per run, the
2724
- newest 50, in `<cache dir>/steps/<run id>.json`, written only by a run
2725
- that had one; `--format json` adds them as `steps`.
2726
- `vx last --list` prints the N most recent runs (default 10), one per
2727
- line in aligned columns (verdict, start, run id, counts, duration,
2728
- command), each run id cut to the shortest prefix no other run shares, 13 characters at least (`--list 5`
2729
- and `--list=5` alike; the space form takes the next argument as the
2730
- count unless it is a flag or a run id, eight hex digits at least, so
2731
- `--list 1.5` is refused as a count); `vx last RUNID` replays a
2732
- specific one, and the two do not combine (a word beside `--list` no run
2733
- id could be is refused as an unexpected argument). `--failed` replays the latest
2734
- run that failed, past any green one since, and with `--list` lists only
2735
- failed runs. `--format json`
2736
- emits `{ invocation, tasks }` for scripting; a failed task's row adds
2737
- `output` (its output as plain text, the first 8 KiB and last 56 KiB,
2738
- secrets masked) and `locations` (the existing files it names,
2739
- `{ file, line?, col? }`, file absolute; a file also named with a line
2740
- drops its bare mention, such as bun test's `app.test.js:` header), so an
2741
- agent reads why without the terminal. `fixedIn` names the first later run where that task
2742
- passed, so a replayed failure that is already fixed says so. A
2743
- sandboxed task's row adds `sandboxFix`, the `exec.sandbox.allow`
2744
- entries (`read`, `write`, `network`, `systemInfo`) that would have
2745
- granted its denials, the same grants the terminal prints as `fix:`;
2746
- `withheld` lists the credential paths it denied and left out (a `.env` file, or a store under home the sandbox denies, `security.md`), and, for
2747
- a cached task, `inputs` the reads of its project no `inputs.files`
2748
- glob matches (the terminal's `and, if the output depends on them, …`
2749
- line). A task
2750
- failed by `inputStability.severity: 'error'` adds `inputMoved`, the
2751
- input that changed during the run. They live in `<cacheDir>/failures/<runId>.json`, the
2752
- newest 50 failed runs; past those, or when the write was refused, a
2753
- failed row reads `output: ''` and `locations: []`. `--log <task>`
2754
- prints one task's output instead of the summary: in the latest run that
2755
- recorded the task, or in RUNID's. A failed task's is the kept failure
2756
- output; any other task's is its cache entry's log (the one a hit
2757
- replays, masked as it was), so a passing task's log reads too, on a hit
2758
- or in the run that saved it. An uncached task that passed, or a skip,
2759
- kept none: stdout is empty and stderr says so, exit 0. With
2760
- `--format json` it is `{ runId, taskId, status, source, output }`,
2761
- `source` `'failure'`, `'cache'` or `null`. A task no run recorded is
2762
- refused, exit 1; `--log` does not combine with `--list` or `--failed`.
2763
- `vx last app#build --log` reads the same as `--log app#build`: a `--log`
2764
- with no value takes the positional as its task.
2765
- `--stats [<task>]` prints per-task numbers over the last 50 runs, the
2766
- window the scheduler and `--dry` learn from: runs, p50 (the forecast a
2767
- run shows: the median of the task's last five timings on the branch,
2768
- else on any branch), p99 of the executed successes, pass and hit rate, failure mode (`stable`,
2769
- `flaky-recoverable`, `flaky-fatal`), the largest peak RSS and CPU
2770
- parallelism (a hit counts what its producing run used), one table row
2771
- per task. A task id narrows to that task, a task name to it in every
2772
- project; one the window has no run of is refused, exit 1. The branch is
2773
- `--branch`, else `VX_RUN_BRANCH`, else git's, then the CI provider's, as
2774
- a run's. With `--format json` it is `{ window, branch, stats }`, each row `{ taskId, runs,
2775
- p50DurationMs, p99DurationMs, successRate, hitRate, failureMode,
2776
- maxPeakRssBytes, maxCpuParallelism }`, `null` where nothing was
2777
- measured. `--stats` does not combine with `--list`, `--failed` or
2778
- `--log`; `--branch` combines only with `--stats`. `--list --format json`
2779
- an array of the same `invocation` objects, newest first. An unknown run id fails
2780
- loud and points at `--list`. Before any run, every form says so
2781
- (`› vx last no runs yet` with `fix vx run`, exit 1; `--list` prints
2782
- `› vx last no runs yet`, exit 0), and past green runs only,
2783
- `--list --failed` prints `› vx last no failed runs`. A run id may be typed as a unique prefix; a
2784
- prefix several runs share fails and lists them. A replayed run with
2785
- failures ends with the command that re-runs them (`re-run what failed:
2786
- vx run app#test -- …`, with the arguments the run forwarded to the
2787
- tasks that got them; a failed dependency, which never got them, gets a
2788
- line of its own without them, X-140). The
2789
- replayed `$ vx run …` or `$ vx watch …` line and that one keep each
2790
- argument shell-quoted (`-- 'a b'`), so either pastes back as it ran (X-49).
2791
-
2792
- `vx why`, `vx last`, `vx info` and `vx cache clean` all read the cache
2793
- a run wrote, so each takes `--cache-dir <path>` with `vx run`'s rules
2794
- (cwd-relative, absolute used as-is): a run that wrote its history
2795
- elsewhere is replayed, explained, reported on and pruned there. Without
2796
- the flag they open the workspace's cache (`defineWorkspace({ cache: { dir } })`
2797
- or `.vx/cache`). A `--cache-dir` that is not there is refused by name
2798
- (`--cache-dir .vx/cahce: no such directory`). In a workspace that never
2799
- ran, `vx why`, `vx last`, `vx info`, `vx show` and `vx cache clean`
2800
- (dry or not) read it as empty and create nothing (item 900, E-7, E-38).
2801
-
2802
- Exit codes: `0` the run (or the list) printed, `--list` with no runs
2803
- included; `1` no recorded run yet, a run id it has no record of, or a
2804
- parse error.
2805
-
2806
- ## `vx docs`
2807
-
2808
- `vx docs <query>` searches vx's reference with no network: `cli`,
2809
- `schema`, `caching`, `execution`, `patterns`, `security` and `features`,
2810
- which ship inside the package and the compiled binary. Each page is cut
2811
- at its headings; a section that holds every word of the query matches,
2812
- and a word in its heading ranks it above any count in its text; a
2813
- one-word query that titles a section, an error code
2814
- (`vx docs VX_E_CYCLE`), prints that section alone. The best
2815
- `--limit` sections (default 3) print whole, each under its page,
2816
- heading and URL on the site. `--format json` prints
2817
- `{query, hits: [{page, heading, url, text}]}` (`schemas/docs.json`).
2818
- The guides live on the site: `https://vznjs.github.io/vx/llms.txt`
2819
- indexes them.
2820
-
2821
- Exit codes: `0` the search ran, a search with no match included (it
2822
- names the pages searched); `1` a missing query or a bad flag
2823
- (`VX_E_USAGE`).
2824
-
2825
- ## `vx completions`
2826
-
2827
- Print a completion script for `bash`, `zsh` or `fish`:
2828
-
2829
- ```
2830
- eval "$(vx completions bash)" # this shell
2831
- vx completions zsh > ~/.zfunc/_vx # zsh, with ~/.zfunc on $fpath
2832
- vx completions fish > ~/.config/fish/completions/vx.fish
2833
- ```
2834
-
2835
- The script completes the verbs — core's, and the plugin verbs the
2836
- workspace around the cwd declares at generation time — and every flag
2837
- of each verb, read from the same help text `vx <verb> --help` prints:
2838
- the verb's own Usage line, and for `run` and `watch` the run option
2839
- lines, less the ones `watch` refuses. So a flag cannot be documented
2840
- and not completed, nor completed and then refused: a flag another
2841
- verb's line names in passing (`--check` in a line about `vx lock`) is
2842
- not one. After a flag with a fixed value set the script offers that
2843
- set: `--format` pretty or json, `--output-logs` full, errors-only,
2844
- hash-only or none, `--cache-download` all, toplevel or none — each value one
2845
- the flag's parser takes (`tests/completions.test.ts` holds it). A plugin verb completes `--help` only. Task and project
2846
- names are not completed (they are the workspace's, and a completion
2847
- that evaluates configs on every Tab is the wrong price). The zsh script
2848
- works both ways zsh loads one: from `$fpath` it completes on the first
2849
- Tab (it rang the bell there until 2026-10-03 and completed on the
2850
- second), and sourced it registers itself; `tests/completions-shells.test.ts`
2851
- loads the script in zsh both ways and in fish where they are installed. An unknown
2852
- shell is an error naming the three, and the nearest of them when one is
2853
- within two edits (`got bsh). Did you mean bash?`).
2854
-
2855
- Exit codes: `0` the script printed; `1` an unknown shell or flag.
2856
-
2857
- ## Plugin commands
2858
-
2859
- A plugin declared in `.vx/workspace.ts` can add verbs:
2860
-
2861
- ```ts
2862
- import { definePlugin, type VxPlugin } from '@vzn/vx'
2863
-
2864
- export function mcp(): VxPlugin {
2865
- return definePlugin(import.meta, {
2866
- commands: {
2867
- mcp: {
2868
- description: 'serve the run history to an AI agent over stdio',
2869
- async run(argv, ctx) {
2870
- // ctx.workspaceRoot, ctx.cacheDir, ctx.concurrency, ctx.cache, ctx.warn(...)
2871
- return 0 // the process exit code
2872
- },
2873
- },
2874
- },
2875
- })
2876
- }
2877
- ```
2878
-
2879
- The plugin's name is its package name, which `definePlugin` reads from
2880
- `import.meta`; a plain object is refused when the workspace loads.
2881
- `ctx.cache` reads the plugin's own entries as `setup` writes them (the
2882
- local store, then each remote the workspace's cache plugins supply,
2883
- under the run's scope), read-only, opened on first use.
2884
-
2885
- (`@vzn/vx-schedule-history` ships exactly this: `vx history`, what it
2886
- learned per task and the reservation its `admit` hook packs.) The dispatcher tries core's verbs
2887
- first and consults plugins only for a word core does not know, loading
2888
- the workspace config from the cwd to find them (outside a workspace the
2889
- verb is simply unknown). A plugin verb that names a core verb, or one
2890
- plugins of two packages both declare, is refused when the workspace
2891
- loads — such a verb could never run, or would hide the other plugin's.
2892
- Plugins of one package are one owner (two of its plugins may carry the
2893
- same verb): the first declared runs. A plugin verb's
2894
- return value is the exit code, an integer 0–255 (anything else — nothing,
2895
- a fraction, 256, which the OS would keep as 0 — fails naming the plugin
2896
- and the verb), and a thrown `UserError` prints as
2897
- cleanly as core's own. Anything else thrown fails in one line, no stack:
2898
- `plugin '<p>' failed in command '<verb>': <msg>`. `vx help` lists every plugin verb under "Plugin
2899
- commands", with the plugin's name.
2900
-
2901
- An unknown verb is answered with what this workspace knows: the "did you
2902
- mean" set is core's verbs AND the ones its plugins declare (the lookup
2903
- that just failed already loaded them, so it costs nothing), and when
2904
- nothing is close enough to guess, a second line says where a verb can
2905
- come from — the verbs declared here, or that `.vx/workspace.ts` is what
2906
- would declare one, or that there is no workspace here at all. Core still
2907
- names no package: it lists what the workspace itself declares.
2908
-
2909
- ## Environment variables vx reads
2910
-
2911
- Every setting has a variable derived from its path (`run.timeout` →
2912
- `VX_RUN_TIMEOUT`), beating `.vx/workspace` and beaten by the flag; the
2913
- table in `docs/schema.md` § Settings gives each value's form and
2914
- default. Each is checked once before any verb runs: an empty one is
2915
- unset, a value its kind does not take is refused naming the form, and a
2916
- variable vx does not read is ignored. A
2917
- plugin's own (the REAPI endpoints, the GitHub token) are in its README;
2918
- what a TASK sees is `exec.env` in `docs/schema.md`.
2919
-
2920
- <!-- variables table: generated by tests/settings.test.ts -->
2921
-
2922
- | Variable | Setting | What it does |
2923
- | ----------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
2924
- | `VX_RUN_CONCURRENCY` | [`run.concurrency`](schema.md#settings) | Tasks at once: a positive integer, or a share of the cores (`'50%'`). |
2925
- | `VX_RUN_SCHEDULE` | [`run.schedule`](schema.md#settings) | Which ready task starts first when there are more than workers. |
2926
- | `VX_RUN_AFFECTED_BASE` | [`run.affectedBase`](schema.md#settings) | The git ref a bare `--affected` compares with. |
2927
- | `VX_RUN_BRANCH` | [`run.branch`](schema.md#settings) | The branch a run records in its history and forecasts from. Flag and env only: a committed branch would be wrong on every other branch. |
2928
- | `VX_RUN_TIMEOUT` | [`run.timeout`](schema.md#settings) | A task's time limit, for tasks without their own `exec.timeout` (`'30m'`). |
2929
- | `VX_RUN_RETRIES` | [`run.retries`](schema.md#settings) | Re-runs of a failed task, for tasks without their own `exec.retries`. |
2930
- | `VX_RUN_CONTINUE` | [`run.continue`](schema.md#settings) | What a failed task takes down: `never` stops the run, `deps-ok` skips its dependents, `always` runs them too. |
2931
- | `VX_RUN_OUTPUT_LOGS` | [`run.outputLogs`](schema.md#settings) | Which task output the run prints. Chosen before the workspace loads, so flag and env only. |
2932
- | `VX_RUN_RULES_EXCLUSIVE_OUTPUTS` | [`run.rules.exclusiveOutputs`](schema.md#settings) | Refuse two tasks whose outputs overlap even when an edge orders them. |
2933
- | `VX_CACHE_DIR` | [`cache.dir`](schema.md#settings) | Where the cache lives, relative to the workspace root; named, it holds the whole cache. |
2934
- | `VX_CACHE_RETENTION_MAX_SIZE` | [`cache.retention.maxSize`](schema.md#settings) | Evict least-recently-used entries at each run's end until the cache is under this (`'10GB'`). |
2935
- | `VX_CACHE_RETENTION_OLDER_THAN` | [`cache.retention.olderThan`](schema.md#settings) | Evict entries unused for this long at each run's end (`'30d'`). |
2936
- | `VX_CACHE_POLICY` | [`cache.policy`](schema.md#settings) | Which layers a run reads and writes (`'local:rw,remote:r'`); `--no-cache` and `--force` apply on top. |
2937
- | `VX_CACHE_SCOPE` | [`cache.scope`](schema.md#settings) | Where cache writes land (`'pr-123'`): reads try the default key, then the scope's. |
2938
- | `VX_CACHE_DOWNLOAD` | [`cache.download`](schema.md#settings) | Which remote hits land on disk: every one, only the requested tasks, or none. |
2939
- | `VX_CACHE_INPUT_STABILITY_STRATEGY` | [`cache.inputStability.strategy`](schema.md#settings) | What a write to an input during the command means for the save, for tasks without their own. |
2940
- | `VX_CACHE_INPUT_STABILITY_SEVERITY` | [`cache.inputStability.severity`](schema.md#settings) | Whether a skipped save warns or fails the task, for tasks without their own. |
2941
- | `VX_LOCK_REVIEW_FILES` | [`lock.reviewFiles`](schema.md#settings) | `vx lock` writes each project's resolved config, pretty, to `vx.lock.json` beside its `vx.config`, for review; vx never reads it. |
2942
- | `VX_WATCH_POLL` | [`watch.poll`](schema.md#settings) | Poll for changes from the start instead of the OS watcher. |
2943
- | `VX_CONFIG_WORKER_TIMEOUT` | [`config.workerTimeout`](schema.md#settings) | How long one config evaluation may take. |
2944
- | `VX_DEBUG_TIMING` | [`debug.timing`](schema.md#settings) | Print the run's stage timings to stderr. |
2945
- | `VX_DEBUG_WATCH` | [`debug.watch`](schema.md#settings) | `vx watch`: say on stderr each time the loop arms, with the projects it watches. |
2946
- | `VX_DEBUG_DUMPABLE` | [`debug.dumpable`](schema.md#settings) | Linux: keep vx dumpable, so strace or a debugger can read its memory and `/proc/<pid>/fd`, and an unsandboxed task its env. |
2947
- | `VX_HANDED_OFF` | — | Set by a global vx that hands the call to the project's own (§ Which vx runs): the path of the vx that handed off, so the receiver does not hand off again. |
2948
- | `VX_RUN_WORKSPACE`, `VX_RUN_TASK` | — | Set by vx on every task (below); a `vx run` a task starts reads them back. |
2949
-
2950
- <!-- /variables table -->
2951
-
2952
- vx sets two variables on every task's environment: `VX_RUN_WORKSPACE`
2953
- (the workspace root) and `VX_RUN_TASK` (`project#task`). A `vx run` a
2954
- task starts reads them back: one in the same workspace is refused, since
2955
- a nested run is invisible to the outer graph and a loop back to its own
2956
- task forks without bound (`docs/schema.md` § `env`).
2957
-
2958
- Colors are the two conventions in § Output format › Colors (`NO_COLOR`,
2959
- `FORCE_COLOR`). A truthy `CI` picks the CI output flow and
2960
- `GITHUB_ACTIONS` its `::group::` framing (§ Output); the run's
2961
- `invocations` row records the provider, the first truthy of
2962
- `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, `CIRCLECI`, then `CI`. Core never reads `GITHUB_STEP_SUMMARY`; the job summary is
2963
- `@vzn/vx-ci`'s. A sandboxed
2964
- task's temp dir sits under `CLAUDE_CODE_TMPDIR` (else `CLAUDE_TMPDIR`,
2965
- else `/tmp/claude`), the sandbox runtime's own convention
2966
- (`docs/modules/sandbox-runtime.md`).
2967
-
2968
- ## Output format
2969
-
2970
- `vx run` emits framed blocks. Stdout/stderr from each task is
2971
- buffered until completion, then dumped inside the block — so
2972
- concurrent tasks never interleave their lines. A lone requested task
2973
- streams live instead (§ Output).
2974
-
2975
- Frame anatomy:
2976
-
2977
- ```
2978
- ┌─ app#test > failed (exit 1, 1 sandbox violation)
2979
-
2980
- $ bun test
2981
-
2982
- ├─ OUTPUT ──────────────────────────────────────────────────
2983
-
2984
- 2 pass
2985
- error: expected 3, got 2
2986
- 1 fail
2987
-
2988
- ├─ SANDBOX VIOLATIONS (1) ──────────────────────────────────
2989
-
2990
- write ../shared/notes.txt
2991
-
2992
- └─ app#test ── (2.10s) failed (exit 1, 1 sandbox violation)
2993
- ```
2994
-
2995
- Every section is conditional: the `$ <command>` line only for an
2996
- executed task (success or failed — a hit replays its stored output and
2997
- shows none), `OUTPUT` only when the task wrote something,
2998
- `SANDBOX VIOLATIONS (N)` only when the sandbox recorded some (`N`
2999
- counts them) or `SANDBOX` when vx has only a note on a failure. The
3000
- header carries the outcome (`restored-local • abc12345`, `failed (exit
3001
- N)`, …) and the footer repeats it after the duration. A test renders
3002
- this block and checks it against this page, byte for byte.
3003
-
3004
- `OUTPUT` holds what the task wrote in the order vx read it, so an
3005
- error stays beside the test that printed it: a stderr line renders
3006
- red when colour is on (a line the task coloured itself keeps its own
3007
- colours, and a line takes the stream that began it); without colour
3008
- the lines are the task's bytes.
3009
- The two streams are two pipes, so lines written within the same
3010
- instant may read stdout first; a cache hit replays the same order. A
3011
- persistent task's tail after ready keeps `STDOUT (since ready)` and
3012
- `STDERR (since ready)` apart: its bounded tails hold no order.
3013
-
3014
- Frame corners and rules render dim, section labels (`├─ …`) bold in
3015
- their state colour; the id keeps its identity coloring. Content lines are **raw** — no left border, no
3016
- indent — so long lines wrap without colliding with frame glyphs and
3017
- copy/paste yields the verbatim output. Every block (and every live
3018
- frame close in focused flow) is followed by a blank line so frames
3019
- never collide with the next one-liner. A persistent task's frame is
3020
- marked with a cyan `▸` after `┌─`/`└─`, and its close reads `running`
3021
- (the child is still alive), or `failed (exit <n>)` for a server that
3022
- died on its own before the run stopped it.
3023
-
3024
- There is **no top-of-run banner** — the run context lives in the
3025
- footer. The task log opens with one blank line before its first row,
3026
- on a terminal or not; a note vx prints before it is not part of it.
3027
- A broad run looks like:
3028
-
3029
- ```
3030
- ⇢ 4ms success local @vzn/vx#format-check
3031
- ⏺︎ 5.20s success miss @vzn/vx#test
3032
-
3033
- ─ vx 0.0.0 ───────────────────────────────────────────────────
3034
- projects ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱
3035
- 1 affected · 3 total
3036
- tasks ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰
3037
- 2 success · 2 total
3038
- cache ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰
3039
- local 1↓ 1↑
3040
-
3041
- info 8 workers · local cache
3042
- time 5.34s · 900ms saved
3043
- ```
3044
-
3045
- The three bars are meters: `projects` is what the run covered against
3046
- the workspace, `tasks` is failed / success / skipped, `cache` has a
3047
- segment per legend state (a skipped task rides it too, so the two bars
3048
- fill alike; its count is on the tasks legend only). Its legend reads
3049
- per layer: `local 1✓ 1∅ 2↓ 1↑ · remote 3↓ 4↑ 2✗ 1⇡`. `✓` is a fresh
3050
- hit (outputs already on disk, whichever layer answered, so it counts
3051
- under local), `∅` a task with no cache, `↓` is restored from that
3052
- layer and `↑` saved to it, `✗` a task a remote read or write failed
3053
- for, and `⇡` an upload still running (live only, always last). The
3054
- region stays through vx's closing steps, worker rows gone, and the
3055
- final footer replaces it in place. Each task counts once, under its strongest state: a
3056
- failed remote read or write, then an upload running, then saved to a
3057
- remote, then saved here, then restored, then fresh, then no cache; so
3058
- a task saved here and uploaded is a remote `↑`. A key the remote already
3059
- held is not sent, so it is no remote `↑`. Each count is painted
3060
- in its segment's colour, number and glyph alike: `✓` green, `∅` dim,
3061
- local blue, remote dark blue, `✗` and a miss red, `⇡` cyan; the
3062
- `local` and `remote` words are dim. A layer shows only when the run's
3063
- `--cache-policy` uses it. `N not saved` (ran, succeeded, saved
3064
- nothing), `N not downloaded` (outputs left in the remote store) and
3065
- every count show only when non-zero. `vx last` names each remote
3066
- failure on its task's row. The
3067
- `time` row is the wall time, then what the cache saved (the hits' stored
3068
- exec times, never the restore), then the spread over executed tasks
3069
- only — a hit's restore time never enters it. The footer never says a
3070
- fact twice: a spread value already on the row is left out, so one
3071
- executed task shows no spread, and there is no closing `result` row
3072
- repeating the tasks, cache and time rows. A task with no `cache` block,
3073
- or any task of a run whose `--cache-policy` reads and writes nothing
3074
- (`--no-cache`), could never hit, so it is counted apart (`1 no-cache`
3075
- for `vx run dev`), as `--dry` labels it. A test renders this run and
3076
- checks it against this page, byte for byte.
3077
-
3078
- Group tasks emit no framed block by design (they aren't real tasks);
3079
- running a group focused surfaces its real member tasks instead.
3080
-
3081
- **Nothing below the footer.** The footer is the run's last word.
3082
- What a run has to say about one task rides that task's row; what it
3083
- says about the run (a server that died, outputs left remote, a file
3084
- `--profile` wrote, a history, upload, plugin or sandbox
3085
- warning) prints above the footer: it prints once the run's closing work
3086
- is done, the `VX_DEBUG_TIMING=1` table included. Cache housekeeping
3087
- (`cache.retention`) prints nothing. Only a kept server's own output
3088
- follows it.
3089
-
3090
- **Narrow terminals.** The footer is 62 columns: its rule and 50-cell
3091
- bars. On a terminal narrower than that the bars shrink and the rule
3092
- with them, as do a frame's section rules, so none wraps onto a second
3093
- row. Off a terminal they keep their width.
3094
-
3095
- **Warnings on stderr.** A run's warnings, such as `[vx] app#build:
3096
- cache.outputs matched no files …` or a plugin's, go to stderr. Stdout
3097
- carries only the rows, the frames and the footer, so `vx run build >
3098
- log.txt` keeps them out of the log.
3099
-
3100
- **Skipped rows.** A red run lists every task that never started as a
3101
- row naming the failure that blocked it, so the footer's `1 skipped`
3102
- always has a name above it:
3103
-
3104
- ```
3105
- ⊘ skipped app#build • blocked by lib#build
3106
- ⊘ skipped web#build • blocked by lib#build
3107
- ```
3108
-
3109
- A skip's cause is followed through a chain of skips to the failure at
3110
- its root; a skip with no failed upstream is fail-fast's and carries no
3111
- suffix. A blocked group (a task with no command) has no row: it never
3112
- starts by definition and no counter counts it.
3113
-
3114
- **Aborted and not-run rows.** After a stop (a shutdown signal), the
3115
- task list names what it cut short (`aborted`, with its time so far) and
3116
- what it reached first (`not run`). Neither reached the cache:
3117
-
3118
- ```
3119
- ✗ 2.41s aborted app#dev
3120
- ◌ not run web#build
3121
- ◌ not run web#test
3122
- ```
3123
-
3124
- The tasks legend counts them in the total, each state its own segment
3125
- of the bar: `2 success · 1 aborted · 2 not run · 5 total`. The total is
3126
- always last on its line. A framed task (focused or `full`) that
3127
- printed while it stopped shows its frame instead of a row.
3128
-
3129
- **Flaky rows.** A task the run just proved nondeterministic says so
3130
- in a dim note on its own row (and its frame's footer) — from the local
3131
- run history alone, no service:
3132
-
3133
- ```
3134
- ◼︎ 4.21s failed miss app#test flaky - passed 3× before
3135
- ⏺︎ 12.84s success miss api#e2e flaky - failed 1× before · 2 attempts
3136
- ```
3137
-
3138
- A task is flaky when its exact cache key FAILED AFTER IT HAD PASSED
3139
- (this run counted; a cache hit is a pass, it replayed one), or when it
3140
- needed a retry (`exec.retries` / `--retries`) this run. A failure on a
3141
- key that never passed is a break and carries no note — a changed input
3142
- that fails is what a red run usually means — and the first pass after
3143
- only failures is a recovery (a missing tool installed), not a flake.
3144
- Only tasks with a `cache` block are judged: "same inputs, red after
3145
- green" is a claim
3146
- only declared inputs can back, and a task without them keys on its
3147
- config alone. It is judged as the task finishes, so the row carries
3148
- it: zero cost for a hit or a skip, one probe of the failed-row index
3149
- for a green miss; `vx info` keeps the standing list.
3150
-
3151
- ### Colors
3152
-
3153
- ANSI truecolor (`ansi-16m`) sequences, gated by env:
3154
-
3155
- | Var | Effect |
3156
- | --------------------------- | -------------------------------------------------------- |
3157
- | `NO_COLOR=…` (non-empty) | Force off. Overrides `FORCE_COLOR`. |
3158
- | `FORCE_COLOR=0` / `=false` | Force off, on a TTY too. |
3159
- | `FORCE_COLOR=…` (any other) | Force on — `1`, `true`, `2`, `3`, the empty string, etc. |
3160
- | (neither) | On iff `stdout.isTTY`. |
3161
- | `TERM=dumb` | Off unless `FORCE_COLOR` forces it, and no live region. |
3162
-
3163
- `0` and `false` are `FORCE_COLOR`'s "off" by the convention
3164
- supports-color, chalk and Node follow; until 2026-09-24 vx read every
3165
- non-empty value as "on", so a CI setting `FORCE_COLOR=0` got escape
3166
- sequences in its log (the bug Nx has as nx#35292). An empty `NO_COLOR`
3167
- has no effect (no-color.org).
3168
-
3169
- A task's output is a pipe, so a tool would print plain; vx sets
3170
- `FORCE_COLOR=1` on every task, as Nx and Turbo do, unless the task
3171
- already sees a `FORCE_COLOR` (any value, `0` included) or a non-empty
3172
- `NO_COLOR`. The cache stores the coloured bytes, and wherever vx's own
3173
- output is plain (a pipe, CI without `FORCE_COLOR`, `NO_COLOR`) it
3174
- strips the escapes from task output, live and replayed alike, an
3175
- escape cut across two chunks included (`tests/plain-output.test.ts`,
3176
- `tests/cli-colors-e2e.test.ts`). A custom `log` passed to `run()` gets
3177
- task output as written. A task that writes its own output to a file
3178
- and wants it plain sets `env: { define: { FORCE_COLOR: '0' } }`.
3179
-
3180
- Programmatic callers passing a custom `log` to the run options always
3181
- see plain text.
3182
-
3183
- Only a run's own output (`vx run`, `vx watch`) is painted, and the
3184
- help on a terminal: headings bold, `vx <verb>` and each flag or example
3185
- in cyan; `NO_COLOR` or `FORCE_COLOR=0` turns it off
3186
- (`tests/help-colors.test.ts`). Every other verb (`show`, `info`, `why`,
3187
- `last`, a `--dry` plan, `cache clean`), and the help off a
3188
- terminal, prints plain text whatever `FORCE_COLOR` says, so a script
3189
- that forces colour for a run's log still parses them
3190
- (`tests/cli-colors-e2e.test.ts`).
3191
-
3192
- ## Remote cache (plugin-driven)
3193
-
3194
- Core ships **no remote-cache wire client** — the remote cache is a
3195
- plugin concern. A `cache` plugin returns a wire client (a
3196
- `RemoteCacheLayer`) and core layers it over the local cache;
3197
- `@vzn/vx-reapi` provides one for any Bazel REAPI server
3198
- (ActionCache + CAS).
3199
-
3200
- Reads try local first, then remote (hydrating local on remote hit),
3201
- with a background prefetch pass overlapping remote GETs with
3202
- execution. Writes go to local immediately; the remote upload is a
3203
- fire-and-forget background task drained at end of run — failures are
3204
- warned but never fail the build.
3205
-
3206
- For any OTHER cache server (a Turbo-wire deployment, S3-direct, …),
3207
- implement core's `RemoteCacheLayer` interface in a plugin's `cache`
3208
- capability — the recipe lives in the plugins guide. Embedders
3209
- holding a wire client can inject it per-run via
3210
- `RunOptions.remoteCache` (explicit injection wins over the plugin
3211
- consult).
3212
-
3213
- ## Run analytics
3214
-
3215
- `vx info` surfaces the aggregate cache stats (entry count, total
3216
- size, runs + hits in the last 24 h). For anything deeper, vx records
3217
- every task to a `runs` table in `cache.db` (UUIDv7 `run_id`, hrtime
3218
- wallclock spans, cpu_ms, peak RSS, status, cache_hit flag) plus one
3219
- `invocations` header row per run (command, git/CI context,
3220
- counts). The SQLite file IS the API:
3221
-
3222
- ```sh
3223
- sqlite3 .vx/cache/cache.db "
3224
- SELECT project, task, status, duration_ms
3225
- FROM runs
3226
- WHERE run_id = (SELECT run_id FROM runs ORDER BY id DESC LIMIT 1)
3227
- ORDER BY duration_ms DESC;
3228
- "
3229
- ```
3230
-
3231
- The schema is documented in
3232
- [`caching.md` § SQLite tables](./caching.md#sqlite-tables).
3233
-
3234
- ## What's still missing vs Turbo
3235
-
3236
- Tracked in [`comparison.md`](./comparison.md). Nothing visible from the
3237
- CLI is open: `--output-logs hash-only`, `--continue=<mode>`
3238
- and `--cache-dir <path>` all shipped and are documented above.
3239
- Remote-cache credentials are not core CLI flags at all: core carries no
3240
- HTTP cache client — a remote cache arrives through a plugin's `cache`
3241
- capability, which owns its own configuration.
3242
-
3243
- ## Programmatic API
3244
-
3245
- ```ts
3246
- import { run, planRun, defineProject, defineWorkspace } from '@vzn/vx'
3247
-
3248
- const summary = await run({
3249
- cwd: process.cwd(),
3250
- tasks: ['build', 'test'],
3251
- concurrency: 4,
3252
- // Optional 4-axis cache control; omit for everything-on.
3253
- cache: { localRead: true, localWrite: true, remoteRead: true, remoteWrite: true },
3254
- })
3255
- // summary.ok: boolean; summary.outcomes: TaskOutcome[]
3256
-
3257
- const plan = await planRun({
3258
- cwd: process.cwd(),
3259
- tasks: ['build'],
3260
- })
3261
- // plan.tasks: PlannedTask[]
3262
- ```
3263
-
3264
- Surface:
3265
-
3266
- - `run(options)` — execute. Returns `Promise<RunSummary>`.
3267
- - `planRun(options)` — predict, no execute. Returns
3268
- `Promise<RunPlan>`. Used by `--dry`.
3269
- - `prepareRun(options, log)` — the shared setup (discovery → configs →
3270
- graph → cache). What an embedder builds on.
3271
- - `defineProject` / `defineWorkspace` — identity helpers for type
3272
- inference in user configs.
3273
- - Every one of the three returns a type the façade exports, so an
3274
- embedder can hold it: `RunOptions` / `RunSummary` / `TaskOutcome`,
3275
- `RunPlan` / `PlannedTask`, `PreparedRun` — alongside the plugin
3276
- (`VxPlugin`, `TaskExecutor`, `CacheLayer`) and telemetry
3277
- (`TelemetrySink`, `RunSummaryRecord`) surfaces. See `src/index.ts`.
3278
-
3279
- A `log: Logger` option lets embedders swap the default framed-block
3280
- logger for a custom one (e.g. JSON-line emission). Custom loggers
3281
- always see plain text (colors are off when a non-default logger is
3282
- provided).
3283
-
3284
- The CLI dispatcher (`run(argv)` in `src/cli/index.ts`) is not part of
3285
- the public package exports; `bin.ts` calls it directly.