@vzn/vx 0.0.632 → 0.0.634

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