@vzn/vx 0.0.636 → 0.0.637

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 (141) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +213 -207
  3. package/docs/cli.md +699 -655
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +376 -198
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +2 -2
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -63
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +103 -149
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +169 -70
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +24 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +158 -63
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +42 -29
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +113 -72
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +121 -95
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +209 -268
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +20 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
package/docs/cli.md CHANGED
@@ -8,7 +8,8 @@ in `src/cli/index.ts` dispatches to per-subcommand handlers under
8
8
  so existing Turbo users can swap in with minimal muscle-memory churn.
9
9
 
10
10
  ```sh
11
- # Standalone binary via npm (no Bun required on target):
11
+ # Standalone binary via npm (no Bun required on target); inside a project
12
+ # that installs @vzn/vx, it runs that one (§ Which vx runs):
12
13
  npm install -g @vzn/vx
13
14
 
14
15
  # From source (Bun ≥ 1.4):
@@ -28,15 +29,15 @@ released binary carries its own Bun and the row never says it.
28
29
  ```
29
30
  # Core
30
31
  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>]
32
+ vx watch [OPTIONS] [--poll] TASK [-- forwarded-args...]
33
+ vx cache clean [--older-than <duration>] [--max-size <size>] [--global] [--dry-run] [--format pretty|json] [--cache-dir <path>]
33
34
  vx lock [--check] [--format pretty|json]
34
35
  vx init [--dry [--format pretty|json]] [--force] [--mjs] [--native|--keep] [--plugin <seam>]
35
36
  vx show [PROJECT[#TASK] | TASK] [--filter <pattern>] [--affected[=<ref>]] [--format pretty|json]
36
37
  vx info [--format pretty|json] [--cache-dir <path>]
37
38
  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
39
+ vx last [RUNID] [--list[=N]] [--failed] [--log <task>] [--stats [<task>] [--branch <name>]] [--format pretty|json] [--cache-dir <path>]
40
+ vx upgrade [TAG] # upgrade vx the way it was installed
40
41
  vx docs <query> [--limit N] [--format pretty|json]
41
42
  vx completions bash|zsh|fish
42
43
 
@@ -128,11 +129,11 @@ If no task name is given:
128
129
 
129
130
  Exit codes:
130
131
 
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). |
132
+ | Code | When |
133
+ | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
134
+ | `0` | Every task finished `success` or `cache-hit` (local or remote); or `--affected` left no project that declares the task. |
135
+ | `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. |
136
+ | `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), `run.killGrace` (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
 
137
138
  A task runs in its own session, so a terminal's Ctrl-C reaches vx alone,
138
139
  and each task hears it once: from vx, as SIGINT.
@@ -182,7 +183,7 @@ facing summary.
182
183
  A filter that matches nothing refuses the run (`no projects matched
183
184
  filter(s): …`) with `Did you mean <name>?` when a project name is within
184
185
  two edits, or when exactly one scoped project's name after its `/` is
185
- (`--filter vx-mcp` hints `@vzn/vx-mcp`), and `Projects: a, b` otherwise
186
+ (`--filter vx-otel` hints `@vzn/vx-otel`), and `Projects: a, b` otherwise
186
187
  (M-56). A list names eight, then a count. An unmatched `tag:` filter
187
188
  hints the nearest tag instead (`Did you mean tag:scope:web?`), else
188
189
  lists the tags. When every pattern matched and an exclusion took back
@@ -208,19 +209,15 @@ selected: !app excluded every project the other filters matched`).
208
209
  | `!<pattern>` | Exclude packages matching `<pattern>`, from everything the includes select, in any order. |
209
210
  | `[<git-ref>]` | Projects whose files changed since `<git-ref>` (`main`, `HEAD~5`, …). |
210
211
 
211
- An edge is a `package.json` workspace dependency (`dependencies`,
212
- `devDependencies`, `peerDependencies`, `optionalDependencies`; a peer
213
- that would close a cycle counts for selection only, see
214
- `modules/package-graph.md`) — an entry the package manager links to a
215
- workspace package, not one whose key merely names it:
216
- `"shared": "^1.0.0"` beside a local `shared@2.0.0` is a registry
217
- dependency and no edge, and `"luigi": "workspace:../waluigi"` is an
218
- edge to `waluigi` (the rule: `modules/package-graph.md` § Which entries
219
- are edges) — OR a
220
- cross-project `dependsOn` entry (`e2e`'s `test: { dependsOn:
221
- ['app#build'] }` makes `e2e` a dependent of `app`). The task graph knows
222
- both, so selection follows both: `vx run test --filter '...app'` runs
223
- `e2e#test` even though `e2e` has no manifest dependency on `app`. There
212
+ An edge is one a plugin supplies — a `discover` entry's `deps` (order
213
+ and selection) or `reach` (selection only); the lockfile plugins
214
+ (`pnpm()`, `bun()`, `npm()`, `yarn()` from `@vzn/vx-lockfile`) supply
215
+ what the package manager installed, and core reads no edge from a
216
+ `package.json` — OR a cross-project `dependsOn` entry (`e2e`'s `test: {
217
+ dependsOn: ['app#build'] }` makes `e2e` a dependent of `app`). The task
218
+ graph knows both, so selection follows both: `vx run test --filter
219
+ '...app'` runs `e2e#test` even though no plugin gives `e2e` an edge to
220
+ `app`. There
224
221
  is no `implicitDependencies` field — declare the edge where the task
225
222
  needs it.
226
223
 
@@ -244,7 +241,7 @@ vx run build --filter '[origin/main]' # projects with files changed sinc
244
241
 
245
242
  Run the task only in projects whose files changed since `<base>`.
246
243
 
247
- - `--affected` (no value) uses the workspace's `affectedBase` when it
244
+ - `--affected` (no value) uses the workspace's `run.affectedBase` when it
248
245
  names one (`nx()` and `turbo()` set it from `NX_BASE` / nx.json's
249
246
  `defaultBase` and `TURBO_SCM_BASE`, `turbo()` on GitHub Actions from
250
247
  the pull request's base or the push's `before`, as Turbo does;
@@ -301,12 +298,13 @@ change seeds tasks in the projects it touches:
301
298
  (`files`, `workspaceFiles`), or a changed submodule or embedded
302
299
  repository is one its `workspaceFiles` may reach into (git reports it
303
300
  as one path; a glob whose fixed prefix is above or inside it counts);
304
- - every task of a project whose `package.json` or `vx.config.*`
301
+ - every task of a project whose `vx.config.*`
305
302
  changed, or that holds a changed path no cached task of its declares
306
303
  (vx cannot prove that path re-keys nothing), or that holds a changed
307
304
  submodule or embedded repository (git reports it as one path, and no
308
305
  task's globs can say whether they reach inside), or that a lockfile
309
- claim, a manifest edge at the base or a config import names;
306
+ claim or a config import names (a dependency an install added or
307
+ removed reaches here as the lockfile's change);
310
308
  - an uncached task when a changed path lies in its project or the
311
309
  project is reached whole; a root file another task of its declares
312
310
  (`workspaceFiles`) is no change there. A group seeds nothing.
@@ -354,7 +352,7 @@ change to it, and every project under it is selected, the project that
354
352
  holds it included. A repository's own request to hide submodules from a diff
355
353
  (`diff.ignoreSubmodules`, `submodule.<name>.ignore`) does not apply:
356
354
  the key sees the change whatever git is told to show.
357
- `vx-lock.json` is filtered out of the changed set — a `vx lock`
355
+ `.vx/projects.lock.json` is filtered out of the changed set — a `vx lock`
358
356
  re-write never marks every project affected.
359
357
 
360
358
  **A lockfile change selects everything.** The root lockfiles,
@@ -370,7 +368,9 @@ plugin CLAIMS (`VxPlugin.fingerprint`, e.g. `pnpm-lock.yaml` under
370
368
  `@vzn/vx-lockfile`) is the exception on both sides: the key folds what the
371
369
  plugin says per project, so `--affected` asks the plugin which projects
372
370
  the change touches — given the bytes at the base ref and in the working
373
- tree — and selects those; only a plugin that cannot tell widens.
371
+ tree — and selects those; only a plugin that cannot tell widens, as
372
+ `@vzn/vx-lockfile` does for the install settings it claims (`.npmrc`,
373
+ `bunfig.toml`, `pnpm-workspace.yaml`, `.yarnrc.yml`).
374
374
 
375
375
  **A dropped edge selects its dependent.** A package the change deleted
376
376
  (its `package.json` was there at the base and is gone) is no project
@@ -393,8 +393,7 @@ too. "New" is judged at the base: no manifest there, or one with no
393
393
  `name`.
394
394
 
395
395
  **A workspace config change selects everything.** An edit to
396
- `vx.workspace.*`, or to a file it imports by relative specifier, selects
397
- every project: the `config` and `project` stages its plugins install
396
+ `.vx/workspace.*` selects every project: the `config` and `project` stages its plugins install
398
397
  shape every resolved config, so the edit can re-key any task, and
399
398
  selection cannot tell which. It is not in the fingerprint (a key moves
400
399
  only when a stage's output does). A root file a plugin's stages read
@@ -470,33 +469,37 @@ than run with the args unheard.
470
469
 
471
470
  ### Flags
472
471
 
473
- | Flag | Type | Default | Description |
474
- | ---------------------------------- | -------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
475
- | `--filter <pattern>` | repeatable | (none) | pnpm-style filter DSL (see above). `--filter=<pattern>` form too. |
476
- | `--all` | boolean | off | Select every project that declares the task. |
477
- | `--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. |
478
- | `--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). |
479
- | `--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. |
480
- | `--no-cache` | boolean | off | Disable caching entirely (no reads, no writes); output globs are NOT cleaned. |
481
- | `--force` | boolean | off | Re-execute everything (skip cache reads) but still REFRESH the cache (writes stay on). Output globs are cleaned (so the saved snapshot is clean). |
482
- | `--cache <spec>` | value | all axes on | Per-layer read/write control. See below. An EMPTY spec (`--cache=`) is a parse error — it applied nothing and left every axis on; pass `--no-cache` to disable them all. |
483
- | `--cache-scope <name>` | value | `VX_CACHE_SCOPE` / workspace `cacheScope` | This run's `cacheScope` (`trusted`, `read-only`, `pr-123`), above `VX_CACHE_SCOPE`, `vx.workspace`'s and `github()`'s. A name `cacheScope` would refuse is refused. `--cache-scope=<name>` form too. Never folded into a cache key. |
484
- | `--cache-dir <path>` | value | workspace `cacheDir` / `~/.vx/<id>/cache` | Cache directory override, resolved relative to cwd (absolute paths used as-is). Beats the `defineWorkspace({ cacheDir })` field, `VX_CACHE_DIR` and the default; like them, the directory then holds the whole cache, shared through no store, for every cache the run opens — the config-evaluation cache that `--affected` owners, the picker and the watch sweep read included, so the workspace's default dir is not created beside it. A per-run knob — never folded into a cache key. `--cache-dir=<path>` form too; the space form rejects a value starting with `-`. A directory this user cannot write into fails the run before any task with `cache directory <path> is not writable (EACCES: …)` — every run records its history there. |
485
- | `--retry <n>` | value | `0` | Re-run a failed task up to `n` more times; never a persistent one. Run-level default only: a task's own `exec.retries` wins (even an explicit `0`). Never affects cache keys. `--retry=<n>` form too. |
486
- | `--continue[=<mode>]` | value | `deps-ok` | What a failed task takes down with it. `never` stops dispatch on the first failure; `deps-ok` (default) skips only its dependents; `always` (bare `--continue`) runs dependents anyway. See § Failure propagation. |
487
- | `--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. |
488
- | `--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`. |
489
- | `--output-logs <mode>` | value | flow-derived | `full` \| `errors-only` \| `hash-only` \| `none` — explicit output override. See § `--output-logs`. `--output-logs=<mode>` form too. |
490
- | `--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. |
491
- | `--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. |
492
- | `--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. |
493
- | `--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. |
494
- | `--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. |
495
- | `--summarize[=<path>]` | optional value | off | Write per-run JSON to `<cacheDir>/runs/<run_id>.json` (or the explicit path). |
496
- | `--profile[=<path>]` | optional value | off (`profile.json` when set) | Write Chrome-trace JSON of the run's wallclock spans. |
497
- | `--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. |
498
- | `--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). |
499
- | `--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. |
472
+ | Flag | Type | Default | Description |
473
+ | ------------------------------------------ | -------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
474
+ | `--filter <pattern>` | repeatable | (none) | pnpm-style filter DSL (see above). `--filter=<pattern>` form too. |
475
+ | `--all` | boolean | off | Select every project that declares the task. |
476
+ | `--affected[=<base>]` | optional value | off | Select the tasks a change since `<base>` reaches: the ones it touches and those whose `dependsOn` closure holds one (default `run.affectedBase`, else `origin/HEAD`, else `main` or `master`, else `HEAD~1`); candidates are `--filter "...[<base>]"`'s. |
477
+ | `--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). |
478
+ | `--concurrency <n>` | int or `<n>%` | `run.concurrency`, else the cores (cgroup-capped) | Maximum parallel tasks that EXECUTE; confirmed cache-hit restores are disk work and run on their own lane, up to twice this. `1` serializes both; `50%` is half the CPUs (rounded, never below 1; over 100% is allowed for I/O-bound work). `--concurrency=<n>` form too. |
479
+ | `--schedule <rule>` | value | `run.schedule`, else `most-work` | Which ready task starts first when more are ready than workers: `most-work`, `critical-path`, `direct-dependents`, `ready-order` (`docs/schema.md` § `run.schedule`). |
480
+ | `--branch <name>` | value | `VX_RUN_BRANCH`, else git's branch, then the CI provider's | The branch the run records in its history and forecasts from. No config key: a committed branch would mislabel every other branch's runs. |
481
+ | `--no-cache` | boolean | off | Disable caching entirely (no reads, no writes); output globs are NOT cleaned. |
482
+ | `--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). |
483
+ | `--cache-policy <spec>` (`--cache-policy`) | value | `VX_CACHE_POLICY` / `cache.policy` | Per-layer read/write control. See below. An EMPTY spec (`--cache-policy=`) is a parse error — it applied nothing and left every axis on; pass `--no-cache` to disable them all. |
484
+ | `--cache-scope <name>` | value | `VX_CACHE_SCOPE` / workspace `cache.scope` | This run's `cache.scope` (`pr-123`), above `VX_CACHE_SCOPE`, `.vx/workspace`'s and `github()`'s. A name `cache.scope` would refuse is refused. `--cache-scope=<name>` form too. Never folded into a cache key. |
485
+ | `--cache-dir <path>` | value | `VX_CACHE_DIR` / `cache.dir` / `~/.vx/<id>/cache` | Cache directory override, resolved relative to cwd (absolute paths used as-is). Beats `VX_CACHE_DIR`, the workspace's `cache.dir` and the default; like them, the directory then holds the whole cache, shared through no store, for every cache the run opens, so the workspace's default dir is not created beside it. A per-run knob — never folded into a cache key. `--cache-dir=<path>` form too; the space form rejects a value starting with `-`. A directory this user cannot write into fails the run before any task with `cache directory <path> is not writable (EACCES: …)` — every run records its history there. |
486
+ | `--retries <n>` | value | `run.retries`, else `0` | Re-run a failed task up to `n` more times; never a persistent one. A task's own `exec.retries` wins (even an explicit `0`). Never affects cache keys. `--retries=<n>` form too. |
487
+ | `--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. |
488
+ | `--timeout <duration>` | duration | `run.timeout`, else none | A task's time limit (`30s`, `10m`), for tasks without their own `exec.timeout`, which always wins. A runaway task is killed + `failed`. A bare number is refused naming the forms with units. Never affects cache keys. `--timeout=<duration>` form too. |
489
+ | `--kill-grace <duration>` | duration | `run.killGrace`, else `2s` | How long a task that ignores SIGTERM gets before SIGKILL: on a timeout, on a signal, and at the end-of-run shutdown of persistent tasks. |
490
+ | `--teardown-timeout <duration>` | duration | `run.teardownTimeout`, else `3s` | The bound on each plugin's end-of-run flush or teardown, and on its `telemetry()` setup, so a third party's I/O cannot hold the run. |
491
+ | `--ready-notice <duration>` | duration | `run.readyNotice`, else `10s` | How long a persistent task may take to match `readyWhen` before vx says once what it waits for. |
492
+ | `--output-logs <mode>` | value | `VX_RUN_OUTPUT_LOGS`, else flow-derived | `full` \| `errors-only` \| `hash-only` \| `none` — explicit output override. See § `--output-logs`. `--output-logs=<mode>` form too. |
493
+ | `--cache-download <mode>` | value | `cache.download`, else `all` | `all` \| `toplevel` \| `none` — where a REMOTELY-executed task's outputs land. `none` leaves them in the remote CAS and fetches lazily, only when a locally-placed task needs them. Never affects cache keys. See § `--cache-download`. `--cache-download=<mode>` form too. |
494
+ | `--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. |
495
+ | `--dry[=text\|json]` | optional value | off | Print the task graph + predicted cache hit/miss; skip execution. `VX_DEBUG_TIMING=1` prints the stage table here as it does for a run. |
496
+ | `--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. |
497
+ | `--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. |
498
+ | `--summarize[=<path>]` | optional value | off | Write per-run JSON to `<cacheDir>/runs/<run_id>.json` (or the explicit path). |
499
+ | `--profile[=<path>]` | optional value | off (`profile.json` when set) | Write Chrome-trace JSON of the run's wallclock spans. |
500
+ | `--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. |
501
+ | `--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). |
502
+ | `--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. |
500
503
 
501
504
  Mutual exclusion:
502
505
 
@@ -551,11 +554,13 @@ shell variable), never a path or task id. Use the `=` form for a literal
551
554
  leading dash.
552
555
 
553
556
  **Numeric flags take a plain decimal integer.** `--concurrency`,
554
- `--timeout`, `--retry` and `--verbosity` reject hex (`0x10`), exponent
555
- (`1e3`), fractional (`2.7`), signed (`+4`) and space-padded forms, plus
556
- anything past `2^53` (it would parse to a number you did not type).
557
- These all used to be silently reinterpreted — `--concurrency 0x10` ran
558
- 16 workers.
557
+ `--retries` and `--verbosity` reject hex (`0x10`), exponent (`1e3`),
558
+ fractional (`2.7`), signed (`+4`) and space-padded forms, plus anything
559
+ past `2^53` (it would parse to a number you did not type). These all
560
+ used to be silently reinterpreted — `--concurrency 0x10` ran 16
561
+ workers. A duration flag (`--timeout`, `--kill-grace`,
562
+ `--teardown-timeout`, `--ready-notice`) takes its unit (`500ms`, `30s`,
563
+ `10m`): a bare `500` is refused, naming both readings.
559
564
 
560
565
  An empty `=` value means "no value" on four flags, which then act like
561
566
  their bare forms: `--profile=` writes `profile.json`, `--summarize=`
@@ -563,26 +568,34 @@ writes `<cacheDir>/runs/<run_id>.json`, `--graph=` prints to stdout and
563
568
  `--affected=` uses the default base. Every other flag refuses an empty
564
569
  value: `--dry=`, `--report=`, `--continue=` and
565
570
  `--exclude-dependencies=` as well as the ones with no bare form
566
- (`--retry=`, `--timeout=`, `--cache-dir=`, `--filter=`, `--cache=`).
571
+ (`--retries=`, `--timeout=`, `--cache-dir=`, `--filter=`, `--cache-policy=`).
567
572
 
568
- #### Cache control: `--cache`, `--no-cache`, `--force`
573
+ #### Cache control: `--cache-policy`, `--no-cache`, `--force`
569
574
 
570
575
  The cache has four independent axes — **localRead**, **localWrite**,
571
576
  **remoteRead**, **remoteWrite** — and the three flags above resolve
572
577
  them in this precedence order:
573
578
 
574
579
  1. Start with every axis **on** (the default).
575
- 2. Apply each `--cache=<spec>` segment (the base), or, with no
576
- `--cache`, the `VX_CACHE` environment variable's spec.
580
+ 2. Apply each `--cache-policy=<spec>` segment (the base), or, with no
581
+ `--cache-policy`, the `VX_CACHE_POLICY` environment variable's spec, or, with
582
+ neither, `.vx/workspace`'s `cache.policy` (`docs/schema.md`).
577
583
  3. If `--no-cache` was passed, force **all four off**.
578
584
  4. If `--force` was passed, force **both reads off** (writes stay
579
- whatever the base / `--cache` left them).
585
+ whatever the base / `--cache-policy` left them).
586
+ 5. Off CI (`CI` unset, `0` or `false`), unless `--cache-policy`,
587
+ `VX_CACHE_POLICY` or `cache.policy` names the remote, force
588
+ **remoteWrite off**: a key holds only declared inputs, so a laptop's
589
+ write could replay on CI. `cache.scope` never changes an axis.
580
590
 
581
591
  A spec that names a remote axis (`remote:r`, `remote:w`, `remote:rw`)
582
592
  in a workspace whose plugins supply no remote layer gets one status
583
593
  line saying so — the axes are inert without a layer to serve them, and
584
594
  a CI job that believes it is filling a shared cache should be told.
585
595
 
596
+ Whatever the axes, a lookup asks the local cache first, then each
597
+ remote in the order the plugins declare them.
598
+
586
599
  So `--no-cache` always wins over `--force`. The common cases:
587
600
 
588
601
  - `--no-cache` → nothing reads, nothing writes, and declared output
@@ -593,29 +606,30 @@ So `--no-cache` always wins over `--force`. The common cases:
593
606
  before each task so the saved snapshot is clean. This is the "rebuild
594
607
  and refresh the cache" flag.
595
608
 
596
- `--cache=<spec>` is a comma-separated list of `<layer>:<flags>`
609
+ `--cache-policy=<spec>` is a comma-separated list of `<layer>:<flags>`
597
610
  segments. `layer` is `local` or `remote`; `flags` is any subset of `r`
598
611
  (read) and `w` (write), order-independent and possibly empty. A
599
612
  **mentioned** layer is set EXACTLY to its flags; an **unmentioned**
600
- layer keeps its current value. Both `--cache=<spec>` and the space form
601
- `--cache <spec>` are accepted.
613
+ layer keeps its current value. Both `--cache-policy=<spec>` and the space form
614
+ `--cache-policy <spec>` are accepted.
602
615
 
603
- | Spec | Effect |
604
- | --------------------------- | ------------------------------------------------------- |
605
- | `--cache=local:rw,remote:r` | remote read-only (won't upload); local full |
606
- | `--cache=local:r` | local read-only; remote untouched (still full) |
607
- | `--cache=remote:` | remote fully off; local untouched |
608
- | `--cache=local:,remote:rw` | don't touch the local cache, but still upload to remote |
616
+ | Spec | Effect |
617
+ | ---------------------------------- | ------------------------------------------------------- |
618
+ | `--cache-policy=local:rw,remote:r` | remote read-only (won't upload); local full |
619
+ | `--cache-policy=local:r` | local read-only; remote untouched (still full) |
620
+ | `--cache-policy=remote:` | remote fully off; local untouched |
621
+ | `--cache-policy=local:,remote:rw` | don't touch the local cache, but still upload to remote |
609
622
 
610
623
  `local:` means "don't serve hits out of the pre-existing local cache" —
611
624
  a remote hit is still delivered (the artifact has to land on disk to be
612
625
  extracted), it just never short-circuits the remote read.
613
626
 
614
627
  Combine with `--force` for "re-execute and refresh only the remote":
615
- `--cache=local: --force` (local off, reads off, remote write-only).
628
+ `--cache-policy=local: --force` (local off, reads off, remote write-only).
616
629
 
617
630
  Invalid layers/flags are a parse error
618
- (`invalid --cache layer 'disk'`, `invalid --cache flag 'x'`, …).
631
+ (`--cache-policy: invalid layer 'disk'`, `--cache-policy: invalid flag 'x'`, …);
632
+ `VX_CACHE_POLICY` and `cache.policy` say their own name instead.
619
633
 
620
634
  ### Output
621
635
 
@@ -731,7 +745,7 @@ tracks the run live. Top to bottom:
731
745
  persistent tasks it depends on, until it — or, with several, the
732
746
  first of them — exits (one kept only as a dependency that exits 0,
733
747
  a daemon that forked and returned, does not end it); the rest still up are then torn
734
- down (SIGTERM, `VX_KILL_GRACE_MS`, SIGKILL), one status line names
748
+ down (SIGTERM, `run.killGrace`, SIGKILL), one status line names
735
749
  the task and its code (`vx: app#dev exited with code 1; stopping 1
736
750
  other persistent task`), and a non-zero exit makes the run exit 1
737
751
  and is what `vx last` records.
@@ -744,15 +758,18 @@ other persistent task`), and a non-zero exit makes the run exit 1
744
758
  each one's id (`app#dev │ Local: http://localhost:5173`), except
745
759
  under `--output-logs errors-only`, `hash-only` or `none`; a single
746
760
  requested server in the focused flow streams raw from its frame.
747
- 3. **Worker rows** — one per worker slot (sized
761
+ 3. **Worker rows** — one per worker slot that has been busy (at most
748
762
  `min(concurrency, 10)`), no glyph and no spinner: the live ticking
749
763
  elapsed time leads (` 568ms running <id>`). A task stays in
750
- its row for its whole life; idle rows hold their place dimmed, so
751
- nothing ever jumps; overflow shows as `+k more`. A second in, a row
764
+ its row for its whole life; a row whose task ended holds its place
765
+ dimmed (`idle`), so nothing ever jumps; overflow shows as `+k more`. A second in, a row
752
766
  says what waits on it: `critical path` (yellow) on the running task
753
767
  the longest unfinished chain waits on, by the same p50s as the
754
768
  forecast below and only while two or more run, and a dim
755
- `blocks N`, its unfinished dependents direct and transitive.
769
+ `blocks N`, its unfinished dependents direct and transitive. Where
770
+ local history knows the task, its row also shows its usual duration
771
+ (its executed p50) in the cache column, fixed while the elapsed time
772
+ counts up toward it (`3.33s running ~5.2s solid-js#types`).
756
773
  4. **The live summary section** — the SAME meters the final footer
757
774
  prints (`tasks` + `cache` bars with legends, `time`), filling in as
758
775
  the run progresses, under a bare `vx` wordmark rule. A second in,
@@ -762,7 +779,10 @@ other persistent task`), and a non-zero exit makes the run exit 1
762
779
  workers, a running task's spent time taken off its own. It says
763
780
  nothing when history knows none of what is left, or once a running
764
781
  task is 25% past its p50. A run that ends inside the second never
765
- reads history for it, and the final summary never shows it.
782
+ reads history for it, and the final summary never shows it. A p50
783
+ here is the median of the task's last five timings on this branch,
784
+ else on any branch; the timings are shared by every worktree of the
785
+ repo, so a new one has a forecast on its first run.
766
786
 
767
787
  In a terminal known to read them, the region also sends zero-width
768
788
  escapes: tab or taskbar progress (OSC 9;4: Ghostty, iTerm2 3.6+,
@@ -783,7 +803,8 @@ text is the task's output unchanged; a successful task's frame is never
783
803
  linked.
784
804
 
785
805
  The region is redrawn in place (cursor-up + clear; not a TUI — no
786
- alternate screen) and erased before the final summary prints. In the
806
+ alternate screen) and erased before the final summary prints. On a
807
+ terminal shorter than the region, it shows only the rows that fit. In the
787
808
  focused flow it only lives while dependencies run; it disappears for
788
809
  good the moment the requested task starts streaming.
789
810
 
@@ -850,7 +871,7 @@ down with it:
850
871
  The mode is the local scheduler's: a task a plugin executor runs
851
872
  elsewhere is one dispatch, failed or not, like any other.
852
873
 
853
- ### `--download <mode>`
874
+ ### `--cache-download <mode>`
854
875
 
855
876
  Where a **remotely-executed** task's outputs land. `all` (default)
856
877
  downloads every task's outputs to this machine as it completes —
@@ -864,7 +885,7 @@ bytes"). A CI job that only wants the verdict moves no output bytes at
864
885
  all.
865
886
 
866
887
  Locally-executed tasks always write in place and ignore the flag, and
867
- `exec.remote: 'only'` still means never — `--download` cannot override
888
+ `exec.remote: 'only'` still means never — `--cache-download` cannot override
868
889
  it in either direction. **It never affects a cache key**: transfer
869
890
  tuning cannot change what a command produces.
870
891
 
@@ -976,8 +997,9 @@ $ vx run build lint --affected=main --dry
976
997
  ```
977
998
 
978
999
  `kind` is `input` (a changed `file` is a declared input), `project` (its
979
- project changed and the task reads it whole: an uncached task, or no
980
- single `file` for a lockfile claim, a manifest edge or a config import),
1000
+ project changed and the task reads it whole: an uncached task, or a changed
1001
+ `file` no cached task of the project declares, often an output git does not
1002
+ ignore; no `file` for a lockfile claim or a config import),
981
1003
  `package` (a `^` edge passes a changed `project` with no such task),
982
1004
  `named` (`pkg#task` beside a bare task) or `selected` (another filter
983
1005
  selected its project). `via` is the `dependsOn` chain, nearest first, to
@@ -1089,7 +1111,7 @@ outcome bucket or `total`, but they make the run red, so they are
1089
1111
  listed separately and counted as `summary.aborted`.
1090
1112
 
1091
1113
  **`noCache: true`** marks a task no cache answered for: it declares no
1092
- `cache` block (it executes every run by design), or the run's `--cache`
1114
+ `cache` block (it executes every run by design), or the run's `--cache-policy`
1093
1115
  read and wrote nothing (`--no-cache`), so a hit rate should leave it out
1094
1116
  of the denominator. The key is present only when true; every other row is
1095
1117
  unchanged. Its `hash` is still set: dependents fold it.
@@ -1192,14 +1214,14 @@ or a timeout's reason, `failed (timed out, exit 143)`, a persistent
1192
1214
  task's `never ready: …`, or a sandboxed task's violation count, or
1193
1215
  `skipped`, naming what blocked it: `skipped (blocked by lib#build)`);
1194
1216
  `Cache` is its provenance (`miss` / `no-cache` for a task no cache
1195
- answered for — no `cache` block, or a `--no-cache` / `--cache=local:` run —
1217
+ answered for — no `cache` block, or a `--no-cache` / `--cache-policy=local:` run —
1196
1218
  which never consulted one / `local` / `remote` / `up-to-date` /
1197
1219
  `—`). A stopped run (a Ctrl-C teardown) reads `## vx run — interrupted`,
1198
1220
  and its tasks keep the terminal's words: one the signal killed is
1199
1221
  `aborted` with its time so far, one the stop reached before it ran is
1200
- `not run` with no time. Neither joins the counts; the header names them
1201
- after the total, `not counted: 1 aborted, 1 not run`, so a red report
1202
- with no failing row still says why. Group tasks (no `exec`) get neither — they are not
1222
+ `not run` with no time. Both count in the header's task total, listed
1223
+ as `1 aborted · 1 not run`, so a red report with no failing row still
1224
+ says why. Group tasks (no `exec`) get neither — they are not
1203
1225
  work, and the header's counts match the terminal summary and
1204
1226
  `--summarize` exactly.
1205
1227
 
@@ -1321,7 +1343,7 @@ sandbox run as usual, and the failure's dependents follow `--continue`.
1321
1343
  ## `vx watch`
1322
1344
 
1323
1345
  ```
1324
- vx watch [OPTIONS] TASK [-- forwarded-args...]
1346
+ vx watch [OPTIONS] [--poll] TASK [-- forwarded-args...]
1325
1347
  ```
1326
1348
 
1327
1349
  Run the named task, then re-run it on every filesystem change in the
@@ -1347,11 +1369,11 @@ run...` precedes it.
1347
1369
  `dependsOn` edges included — is watched recursively, less any project
1348
1370
  nested inside it, with a config or not (a root project's key leaves a nested
1349
1371
  project's files out, so an edit there is no cycle; X-42, X-57). The workspace root is
1350
- watched (non-recursively) for lockfile / `pnpm-workspace.yaml`
1351
- changes and for an edit to `vx.workspace.*` — the one root file that
1372
+ watched (non-recursively) for a claimed file (a lockfile), for
1373
+ `.vx/projects.lock.json` and for an edit to `.vx/workspace.*` — the one root file that
1352
1374
  shapes a run (plugins, `config` stage, concurrency) without being any
1353
1375
  task's input; the cycle after it re-evaluates the file. A `.gitignore`
1354
- there or in a package glob's directory is a cycle too: inputs are
1376
+ there or in a project's parent directory is a cycle too: inputs are
1355
1377
  gitignore-aware, so it changes what keys read (X-136). A file a
1356
1378
  config imports by relative path from outside the watched projects (a
1357
1379
  shared preset) is watched too, and its edit is a cycle that re-reads
@@ -1359,20 +1381,20 @@ run...` precedes it.
1359
1381
  config's own edit does (WD-14). A file the workspace config imports is loaded once per
1360
1382
  process, so its edit is named with the restart it needs rather than
1361
1383
  run stale (item 949). An import whose file is gone stays on the list
1362
- by the name it would have, so its return is a cycle (X-133). The directory
1363
- each `<dir>/*` package glob names (`packages/` for `packages/*`) is
1364
- watched for members coming and going: a package added while the watch
1365
- runs is a cycle that runs it, and its directory is watched from before
1366
- 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
1367
- root `package.json`'s `workspaces`, `pnpm-workspace.yaml`) re-reads
1368
- the set, so a glob added there is watched from the cycle it triggers
1369
- (item 1018). A base, or a directory a config imports from, removed and made
1370
- again (a checkout that restores `packages/`) is heard from its nearest directory that exists, and a
1371
- watch whose directory was replaced is armed again (an OS watch holds
1372
- the deleted one; `watch-recreated-dirs.test.ts`). Under `--filter` or `--affected` the scope is the one
1373
- resolved at start, and a new package joins it only as a dependency of
1374
- it; a glob of another shape (`apps/**`) has no such directory, so a
1375
- package added under it waits for a restart. A task's own declared outputs (`cache.outputs.files`,
1384
+ by the name it would have, so its return is a cycle (X-133). The
1385
+ projects are the lock's: a new `.vx/projects.lock.json` is the
1386
+ event that adds or drops one, and its directory is watched
1387
+ from before that cycle runs, so an edit made during it queues
1388
+ (X-138). A config added and not locked is no project yet. With no
1389
+ lock at start the first cycle fails saying so, and the watch waits
1390
+ for one. A project directory, or one a config imports from,
1391
+ removed and made again (a checkout that restores `packages/`) is
1392
+ heard from its nearest directory that exists and each one above it,
1393
+ and a watch whose
1394
+ directory was replaced is armed again (an OS watch holds the deleted
1395
+ one; `watch-recreated-dirs.test.ts`). Under `--filter` or
1396
+ `--affected` the scope is the one resolved at start, and a new
1397
+ project joins it only as a dependency of it. A task's own declared outputs (`cache.outputs.files`,
1376
1398
  `outputs.workspaceFiles`; a plugin's `project` stage counts, as in
1377
1399
  a run) never trigger a re-run — a cycle that writes `dist/` is not
1378
1400
  an edit, nor is the `dist` directory itself coming and going (a
@@ -1427,7 +1449,8 @@ run...` precedes it.
1427
1449
  at the root, a `coverage/` or `.turbo/` tree, an editor's scratch
1428
1450
  file are dropped before the trigger — so `vx watch … > build.log`
1429
1451
  inside the repo settles instead of feeding itself a cycle per line
1430
- of its own output. `vx watch: watching …` is
1452
+ of its own output. The opening line, `watching N projects · M tasks`
1453
+ (after the initial run's rows), is
1431
1454
  printed only after every watcher has reported a probe file written
1432
1455
  under it (`.vx-watch-probe`, re-written on a short backoff until its
1433
1456
  event arrives, then removed): on macOS a directory watcher can return
@@ -1439,10 +1462,14 @@ run...` precedes it.
1439
1462
  watcher whose probe is not heard within 2 s is replaced by a poller
1440
1463
  that checks every 250 ms, with the notice `vx watch: <dir>: no OS
1441
1464
  watch events within 2000 ms; polling every 250 ms instead`.
1442
- 3. **On change.** The triggering path is logged
1443
- (`vx watch: <project> <relpath>; re-running...`): the first changed
1444
- path that still exists, so an editor's temporary file renamed away
1445
- names nothing; a deletion names the cycle when nothing else changed. The
1465
+ 3. **On change.** A cycle opens with a blank line and
1466
+ `changed <project> <relpath>`, plus ` (+N more)` when more paths
1467
+ changed, then its task rows as `vx run` prints them, with no footer.
1468
+ The named path is the first changed one that still exists, so an
1469
+ editor's temporary file renamed away names nothing; a deletion names
1470
+ the cycle when nothing else changed. A cycle that re-read the watched
1471
+ set (a member came or went, the lock or a plugin changed) says the
1472
+ `watching` line again. Errors and refusals go to stderr. The
1446
1473
  orchestrator is invoked again with the same options, save one: the
1447
1474
  `--affected` diff is read once, for the initial run. A later cycle
1448
1475
  runs the requested task in every project of the scope fixed at start,
@@ -1457,15 +1484,15 @@ watch events within 2000 ms; polling every 250 ms instead`.
1457
1484
 
1458
1485
  Always ignored (no re-trigger):
1459
1486
 
1460
- - `node_modules/`, `.git/`, `.vx/` anywhere in the path.
1487
+ - `node_modules/`, `.git/`, `.vx/cache/` anywhere in the path.
1461
1488
  - A path git ignores (`git check-ignore`, asked once per debounce
1462
1489
  window, never per event): invisible to every cache key — inputs are
1463
1490
  tracked + untracked-not-ignored — so a cycle it started could change
1464
1491
  nothing. A tracked file that matches a pattern is not ignored, by
1465
1492
  git's own rule; outside a repository nothing is.
1466
1493
  - Files ending in `.tsbuildinfo` or `~` (editor swap files).
1467
- - The run's **resolved cache directory**, wherever it is. `.vx/` covers the
1468
- default, but `defineWorkspace({ cacheDir })` and `--cache-dir` can put it
1494
+ - The run's **resolved cache directory**, wherever it is. `.vx/cache/` covers the
1495
+ default, but `defineWorkspace({ cache: { dir } })` and `--cache-dir` can put it
1469
1496
  anywhere; watching it would let vx's own cache writes trigger the next
1470
1497
  cycle, which writes again — a loop that never settles.
1471
1498
 
@@ -1494,19 +1521,20 @@ It samples each file's later clock of mtime and ctime, so a replacement
1494
1521
  that carries the old file's mtime (`cp -p`, `rsync -a`, a `mv`) is an
1495
1522
  edit to it as it is to the OS watcher.
1496
1523
 
1497
- ### Workspace fingerprint changes
1498
-
1499
- Edits to a lockfile (`pnpm-lock.yaml`, `bun.lock`, …) or
1500
- `pnpm-workspace.yaml` at the root invalidate every task's cache key
1501
- via the [workspace fingerprint](./caching.md#cache-key-derivation).
1502
- Watch mode hears those because it watches the workspace root
1503
- (non-recursively). A lockfile a plugin claims still triggers a cycle,
1504
- and so does any other root file a plugin claims (`turbo.json` under
1505
- `turbo()`, item 961), a plugin added to the workspace config mid-watch
1506
- included from the cycle that loads it; the keys then decide which
1507
- projects actually re-run. Under `vx watch --frozen` every cycle's configs come from
1508
- `vx-lock.json`, so a config edit alone changes no command, and a re-lock
1509
- (`vx lock`) is a cycle that re-reads the watched set (item 971).
1524
+ ### Claimed root file changes
1525
+
1526
+ A root file a plugin claims — a lockfile or install setting under
1527
+ `@vzn/vx-lockfile` (`pnpm-lock.yaml`, `bun.lock`, `.npmrc`, …),
1528
+ `turbo.json` under `turbo()` (item 961) — triggers a cycle, and so does
1529
+ `.vx/projects.lock.json`. Watch mode hears them because it watches the
1530
+ workspace root (non-recursively); a plugin added to the workspace
1531
+ config mid-watch counts from the cycle that loads it. The keys then
1532
+ decide which projects actually re-run: core keys no package file, so a
1533
+ lockfile edit re-keys only through its claimant
1534
+ ([cache key derivation](./caching.md#cache-key-derivation)). Every
1535
+ cycle's configs come from `.vx/projects.lock.json`, so a config edit
1536
+ alone changes no command, and a re-lock (`vx lock`) is a cycle that
1537
+ re-reads the watched set (item 971).
1510
1538
 
1511
1539
  ### Constraints
1512
1540
 
@@ -1541,7 +1569,7 @@ re-runs itself and what it depends on.
1541
1569
  - `0` — clean Ctrl+C / SIGTERM exit. A cycle in flight is torn down
1542
1570
  first — its children, and the dev server held between cycles, get the
1543
1571
  signal watch received (a Ctrl-C as SIGINT; a SIGTERM or SIGHUP as SIGTERM),
1544
- `VX_KILL_GRACE_MS` (2 s), then SIGKILL — and the process leaves only
1572
+ `run.killGrace` (2 s), then SIGKILL — and the process leaves only
1545
1573
  once it has returned, so a CI cancellation never orphans a task.
1546
1574
  Also `0`, with no watch, when `--affected` selects nothing: watch
1547
1575
  prints `vx watch: nothing affected since <ref>` and exits as `vx run`
@@ -1560,32 +1588,34 @@ the watch loop — a failed cycle just prints the framed FAILED block
1560
1588
  and waits for the next change. This matches `turbo watch` / `nx
1561
1589
  watch`.
1562
1590
 
1563
- ## `vx cache prune`
1591
+ ## `vx cache clean`
1564
1592
 
1565
1593
  Evict old or oversized cache entries. Operates on the entries and
1566
1594
  `<hash>.tar.zst` artifacts of the store the workspace's index records
1567
1595
  (`~/.vx/<id>/cache`, shared by every checkout of the repository), or of
1568
- `<cacheDir>` itself when the workspace names one (`cacheDir`,
1596
+ `<cacheDir>` itself when the workspace names one (`cache.dir`,
1569
1597
  `VX_CACHE_DIR`, `--cache-dir`).
1570
1598
 
1571
- `prune` is the only `vx cache` subcommand: the statistics other runners
1599
+ `clean` is the only `vx cache` subcommand: the statistics other runners
1572
1600
  put under a `cache` verb — the directory, the entry count, the size —
1573
1601
  are part of [`vx info`](#vx-info), and `vx cache stats`, `list`, `ls`,
1574
- `clean`, `gc`, `purge` and their neighbours say so (`list` also names
1575
- `vx last --list`) rather than printing a bare "unknown subcommand".
1602
+ `gc` and their neighbours say so (`list` also names `vx last --list`;
1603
+ `clear`, `purge`, `reset` and `wipe` name a bare `vx cache clean`) rather than printing a bare "unknown subcommand".
1576
1604
 
1577
1605
  ```
1578
- vx cache prune --older-than <duration> # Drop entries last used before now - duration (up to 1h slack).
1579
- vx cache prune --max-size <size> # After age-based pruning, evict LRU until under <size>.
1580
- vx cache prune ... --dry-run # Say what either policy would reap; delete nothing.
1581
- vx cache prune ... --format json # { dryRun, evicted, bytesFreed, orphans, orphanBytes }
1582
- vx cache prune ... --cache-dir <path> # The cache a run with the same flag uses.
1606
+ vx cache clean --older-than <duration> # Drop entries last used before now - duration (up to 1h slack).
1607
+ vx cache clean --max-size <size> # After age-based pruning, evict LRU until under <size>.
1608
+ vx cache clean ... --dry-run # Say what either policy would reap; delete nothing.
1609
+ vx cache clean ... --format json # { dryRun, evicted, bytesFreed, orphans, orphanBytes }
1610
+ vx cache clean ... --cache-dir <path> # The cache a run with the same flag uses.
1611
+ vx cache clean # No bound: clear the whole cache (below).
1612
+ vx cache clean --global # Also clear every other repository's store.
1583
1613
  ```
1584
1614
 
1585
- At least one of `--older-than` / `--max-size` is required. Both may
1586
- be combined: age-based eviction runs first, then LRU eviction if the
1615
+ With no bound it clears the cache ([below](#with-no-bound-clear-the-cache)).
1616
+ Both bounds may be combined: age-based eviction runs first, then LRU eviction if the
1587
1617
  total is still over the size cap. The same policy runs unattended at
1588
- the end of every run when `vx.workspace.ts` declares
1618
+ the end of every run when `.vx/workspace.ts` declares
1589
1619
  `cacheRetention: { olderThan, maxSize }` (same spellings; see
1590
1620
  [schema](./schema.md)).
1591
1621
 
@@ -1601,7 +1631,7 @@ vx version sharing the store may still use it. A temp goes once it is an
1601
1631
  hour old; nothing younger than an hour is taken. Only the names vx writes are taken:
1602
1632
  `<hash>` is the key's 16 lowercase hex digits and the temp suffix is
1603
1633
  the one a save makes, so a `release.tar.zst` beside the index in a
1604
- `cacheDir` you share is never touched (item 968). The converse, an
1634
+ `cache.dir` you share is never touched (item 968). The converse, an
1605
1635
  index row whose artifact is gone (deleted by hand), is dropped by the
1606
1636
  same prune, and its bytes count toward neither `--max-size` nor the
1607
1637
  evicted total: they are on no disk, and counting them evicted real
@@ -1609,7 +1639,7 @@ entries to make room for them (item 975). A row used within the hour
1609
1639
  is kept until a later prune, but its bytes are left out all the same
1610
1640
  (item 1081). The sweep runs on every prune, under
1611
1641
  either flag, and reports separately from the policy's evictions. A
1612
- workspace's `cacheRetention` runs it too, at the end of a run, at most
1642
+ workspace's `cache.retention` runs it too, at the end of a run, at most
1613
1643
  once an hour even when nothing the index holds is due (the policy sums
1614
1644
  index rows, so orphans alone never make it due).
1615
1645
 
@@ -1626,22 +1656,20 @@ write `10G`, or `10B` when bytes really are the unit.
1626
1656
 
1627
1657
  **A zero bound is rejected.** `--max-size 0` and `--older-than 0d` would
1628
1658
  evict every entry in the cache, which is far more often a
1629
- computed-to-zero retention than an intent — and no flag combination
1630
- expresses "wipe the cache" (running with neither flag is an error, not a
1631
- full prune). Delete the cache store `vx info` names when that is really
1632
- what you want: the entries live there, not in the workspace's `.vx`.
1659
+ computed-to-zero retention than an intent. `vx cache clean` with no
1660
+ bound clears the whole cache when that is really what you want.
1633
1661
 
1634
1662
  ```
1635
- $ vx cache prune --older-than 30d
1663
+ $ vx cache clean --older-than 30d
1636
1664
  Pruned 42 entries (1.3 GB freed)
1637
1665
 
1638
- $ vx cache prune --older-than 7d --max-size 500M
1666
+ $ vx cache clean --older-than 7d --max-size 500M
1639
1667
  Pruned 18 entries (320 MB freed)
1640
1668
 
1641
- $ vx cache prune --older-than 30d # after a SCHEMA_VERSION bump
1669
+ $ vx cache clean --older-than 30d # after a SCHEMA_VERSION bump
1642
1670
  Pruned 0 entries (0 B freed), reaped 42 orphaned artifacts (1.3 GB)
1643
1671
 
1644
- $ vx cache prune --older-than 30d --dry-run
1672
+ $ vx cache clean --older-than 30d --dry-run
1645
1673
  Would prune 42 entries (1.3 GB), would reap 3 orphaned artifacts (12 MB)
1646
1674
  ```
1647
1675
 
@@ -1670,49 +1698,128 @@ Exit codes:
1670
1698
  nothing is created.
1671
1699
  - `1` — parse error, missing policy, or workspace-discovery error.
1672
1700
 
1673
- `vx cache prune` resolves the workspace root from cwd and honors a
1674
- `defineWorkspace({ cacheDir })` override — it prunes the same
1701
+ `vx cache clean` resolves the workspace root from cwd and honors a
1702
+ `defineWorkspace({ cache: { dir } })` override — it prunes the same
1675
1703
  directory a run would use. `--cache-dir <path>` (cwd-relative, as on
1676
1704
  `vx run`) names another one, so a run that wrote elsewhere can be
1677
1705
  pruned there.
1678
1706
 
1679
- ## `--frozen` (run flag)
1680
-
1681
- `vx run ... --frozen` loads configs from the committed `vx-lock.json`
1682
- instead of evaluating them — CI reproducibility mode. Plain `vx run`
1683
- always evaluates live (a byte hash can't see a config's import
1684
- closure, so silently consuming the lock locally would risk stale
1685
- freezes). `--frozen` errors only when no lock exists or a project
1686
- is missing from it — it performs NO staleness checks of its own:
1687
- run `vx lock --check` first in the pipeline; that audit re-evaluates
1688
- everything, making any per-run re-check redundant.
1707
+ ### With no bound: clear the cache
1708
+
1709
+ `vx cache clean` with neither `--older-than` nor `--max-size` clears
1710
+ the whole cache: the cache directory (`.vx/cache`, or `cache.dir` /
1711
+ `--cache-dir`: the index and this workspace's run history, so `vx last`
1712
+ and `vx why` start over) and the store its index names, where the
1713
+ entries live (`~/.vx/<id>/cache`, shared by this repository's
1714
+ checkouts, so their entries go too). `--global` also clears every other
1715
+ repository's store under `~/.vx` (`~/.vx/*/cache`); it takes no bound
1716
+ and no `--cache-dir`. `.vx/workspace.ts`, the lock and `.vx/.gitignore`
1717
+ sit outside them and stay; a `cache.dir` that holds them (`.vx`, the
1718
+ workspace root) is refused. A `cache.dir` you named may be shared
1719
+ (`build/`, `~/.cache`), so there only what vx wrote goes (the index,
1720
+ its artifacts, run history, its `*` `.gitignore`); your own files stay,
1721
+ and the directory goes only if that empties it. A directory with no
1722
+ `cache.db` is not a vx cache and is left alone, and the store an index
1723
+ names goes only if it is one (`.vx/<id>/cache` holding a `store.db`): the
1724
+ index is a file in the checkout, which a commit or a restored CI cache
1725
+ could point at any directory. A workspace that never ran says
1726
+ `Nothing to clear: no cache here`. The next run misses every task once
1727
+ and saves again.
1728
+
1729
+ ```
1730
+ $ vx cache clean
1731
+ Cleared this repository's store, shared by its checkouts, and this workspace's run history (1.4 GB freed):
1732
+ .vx/cache
1733
+ /home/me/.vx/3f2a9c01b4d5e6f7/cache
1734
+
1735
+ $ vx cache clean --global --dry-run
1736
+ Would clear every repository's store and this workspace's run history (3.1 GB):
1737
+ .vx/cache
1738
+ /home/me/.vx/3f2a9c01b4d5e6f7/cache
1739
+ /home/me/.vx/9b8e7d6c5a4f3e2d/cache
1740
+ ```
1741
+
1742
+ It waits for a `vx run` on this workspace to finish first (the run's
1743
+ lock), as a trim does. With `--format json` it prints
1744
+ `{ dryRun, cleared, bytesFreed }` (`schemas/cache.json`), `cleared`
1745
+ listing the directories.
1746
+
1747
+ ## Runs read the lock
1748
+
1749
+ Every run, `vx watch` cycle, `--affected` selection and the picker takes
1750
+ its projects and their configs only from the committed
1751
+ `.vx/projects.lock.json` (owner, 2026-10-14). A run never walks for
1752
+ configs, never evaluates one, never checks the graph rules
1753
+ (`run.rules`) and never writes the lock: only `vx lock` does. With no
1754
+ lock, a run fails with `no lock: run \`vx lock\``. A run trusts the
1755
+ lock: it performs NO staleness check, so after adding or editing a
1756
+ config, run `vx lock`. In a pipeline, `vx lock --check` first re-evaluates every
1757
+ config and checks the rules.
1689
1758
 
1690
1759
  ## `vx lock`
1691
1760
 
1692
- Freeze every project's **resolved** config into `vx-lock.json` at the
1693
- workspace root. Configs are programs; `vx lock` evaluates them in the
1694
- current environment and stores the post-evaluation objects plus a
1695
- content hash of each config file. "Every project" is the set a run
1696
- sees, a plugin's `discover` stage included (X-143).
1697
-
1698
- ```
1699
- vx lock # Evaluate all vx.config.* now; write vx-lock.json.
1700
- vx lock --check # Audit: hash checks + full re-evaluation vs the lock. Exit 1 on drift.
1761
+ Find every project, and freeze each one's **resolved** config into
1762
+ `.vx/projects.lock.json` at the workspace root. A project is a directory
1763
+ holding a `vx.config.*`, in any language: `vx lock` lists them with git
1764
+ (`git ls-files`, tracked and untracked, git's ignore rules applied; a
1765
+ nested repository's own configs too), or walks the tree outside git,
1766
+ and leaves out any under a nested `.vx/workspace.*`. Configs are
1767
+ programs; `vx lock` evaluates them in the current environment and
1768
+ stores the post-evaluation objects, by directory (version 3; a lock an
1769
+ earlier vx wrote is refused, "re-run `vx lock`"). A project is named by
1770
+ its config's `name`, else by a plugin's `discover` stage (vx-lockfile
1771
+ gives `package.json`'s `name`), else by its root-relative path (`//` for
1772
+ the root); two projects of one name are refused naming both
1773
+ directories. The plugin `discover` stage runs here as in a run, and may
1774
+ add directories (X-143). Before writing, it
1775
+ builds the graph of every task the workspace declares, `graph` stage
1776
+ included, and checks the graph rules (`run.rules`), which no run
1777
+ checks, `dependencies` (which projects a project may depend on,
1778
+ `VX_E_DEPENDENCY_RULE`) among them: a broken one prints `vx lock: .vx/projects.lock.json not written:
1779
+ <the rule's refusal>` and writes nothing.
1780
+
1781
+ ```
1782
+ vx lock # Evaluate all vx.config.* now; write .vx/projects.lock.json.
1783
+ vx lock --check # Audit: full re-evaluation vs the lock. Exit 1 on drift.
1701
1784
  vx lock --check --format json # The audit as {upToDate, audited, notAudited, problems}.
1702
1785
  ```
1703
1786
 
1704
- Plain runs ALWAYS evaluate live — the lock's existence changes
1705
- nothing. Only `vx run --frozen` consumes it: configs come from the
1706
- lock with no evaluation and no staleness checks of its own (frozen-env
1787
+ **When to run it.** Run `vx lock` and commit `.vx/projects.lock.json` after
1788
+ editing any `vx.config.*` or `.vx/workspace.*`, after updating
1789
+ dependencies or plugins, and after changing a file a config imports.
1790
+ Runs read their configs from the lock, so until then they keep the old
1791
+ ones. `vx lock --check` as CI's first step fails a stale lock.
1792
+ The full workflow: the site's CI guide and `examples/ci`.
1793
+
1794
+ `.vx/projects.lock.json` is minified: it is the one file vx reads, and it loads
1795
+ faster that way (10,000 projects: 15.6 ms against 22.5 ms pretty). For
1796
+ review, `vx lock` also writes each project's resolved config (plugins
1797
+ applied), pretty, to `vx.lock.json` beside its `vx.config`; vx never reads
1798
+ it, it is no task's input, and `lock.reviewFiles: false`
1799
+ (`VX_LOCK_REVIEW_FILES`) turns it off. `vx lock` also writes
1800
+ `.vx/.gitattributes` (`projects.lock.json linguist-generated=true`) when it is
1801
+ missing, so GitHub collapses the machine lock in a diff; `vx init` does
1802
+ too, and an existing one is never edited.
1803
+
1804
+ Every run consumes it: configs come from the
1805
+ lock with no evaluation, no staleness check and no schema validation
1806
+ of its entries, which `vx lock` validated when it wrote them (frozen-env
1707
1807
  semantics: env reads in a config keep their lock-time values; a
1708
- project absent from the lock, a locked project whose config file is
1709
- gone (X-144) or a missing lock is a hard error).
1710
-
1711
- `--check` is the audit: it reports changed config files via the
1712
- stored hashes AND re-evaluates every config in the current
1713
- environment, `Bun.deepEquals`-comparing against the frozen objects —
1714
- catching eval-time env and import-closure drift that byte hashes
1715
- cannot see. The CI recipe is `vx lock --check && vx run … --frozen`.
1808
+ locked directory whose config file is gone (X-144) or that this user
1809
+ may not search (EACCES, named as such), or a missing lock, is a hard error; a config added since the lock is no project until
1810
+ `vx lock`). An entry edited by
1811
+ hand into a shape `vx lock` could not write fails the run with
1812
+ `.vx/projects.lock.json is invalid, run \`vx lock\``, checked only once
1813
+ something has crashed, so a normal run pays nothing for it.
1814
+
1815
+ `--check` is the audit: it re-evaluates every config in the current
1816
+ environment and `Bun.deepEquals`-compares it with the frozen object. A
1817
+ project that differs gets one message, whether its config, a file it
1818
+ imports or the environment it reads changed. A lock an earlier vx wrote
1819
+ fails the shape check and asks for `vx lock`. It also checks the graph rules over the configs as they
1820
+ evaluate now, and that each `vx.lock.json` is what its project resolves to
1821
+ here, and that none is left beside a deleted `vx.config` (delete that one
1822
+ yourself: vx never deletes a committed file), unless `lock.reviewFiles` is off. The CI recipe is `vx lock --check && vx run …`.
1716
1823
  `--format json` prints the audit as one document
1717
1824
  (`schemas/lock.json`, exit code unchanged; a missing lock is one of
1718
1825
  its `problems`); it answers `--check` only.
@@ -1722,18 +1829,18 @@ Full design: `docs/design/config-lock-2026-06.md`. A lock written by a
1722
1829
  A project with no `vx.config.*` — its tasks from a plugin's `project`
1723
1830
  stage (`turbo()` from `@vzn/vx-migrate`), or none at all — has nothing
1724
1831
  to freeze: the lock records nothing for it, `--check` does not audit
1725
- it, and `--frozen` still loads its tasks live. Both verbs say how many
1726
- such projects the workspace has (`locked 0 project configs →
1727
- vx-lock.json (2 projects have no vx.config; their tasks are never
1728
- frozen)`), so an empty lock on a plugin-only workspace never reads like
1729
- an audit.
1832
+ it, and a run still loads its tasks live. Both verbs name such
1833
+ projects that have tasks (`locked 0 project configs → .vx/projects.lock.json (no
1834
+ vx.config, so not locked: app, lib)`, five names then `and N more`),
1835
+ so an empty lock on a plugin-only workspace never reads like an audit.
1836
+ One with no task (core's default `build` aside) is not named.
1730
1837
 
1731
1838
  The lock is committed, so `vx lock` refuses to write one that holds a
1732
1839
  secret: a config that evaluated to the value of a secret-named variable
1733
1840
  (or one a task lists in `exec.env.secret`), say
1734
1841
  `` `--token ${process.env.API_TOKEN}` ``. It names each place
1735
1842
  (`a: tasks.deploy.exec.command holds $API_TOKEN`) and writes nothing.
1736
- Masking it instead would freeze a `***` that `--frozen` runs. Let the
1843
+ Masking it instead would freeze a `***` that every run runs. Let the
1737
1844
  shell expand it: `$API_TOKEN` in the command, the name in
1738
1845
  `exec.env.passThrough` (L-42).
1739
1846
 
@@ -1824,8 +1931,8 @@ One-time setup, per package, on npmjs.com → package → Settings →
1824
1931
  Trusted Publisher → GitHub Actions: owner `vznjs`, repository `vx`,
1825
1932
  workflow `npm.yml`, environment left blank. Do this for `@vzn/vx`,
1826
1933
  `@vzn/vx-darwin-x64`, `@vzn/vx-darwin-arm64`, `@vzn/vx-linux-x64`,
1827
- `@vzn/vx-linux-arm64` and the seven plugins: `@vzn/vx-ci`,
1828
- `@vzn/vx-lockfile`, `@vzn/vx-mcp`, `@vzn/vx-migrate`, `@vzn/vx-otel`,
1934
+ `@vzn/vx-linux-arm64` and the six plugins: `@vzn/vx-ci`,
1935
+ `@vzn/vx-lockfile`, `@vzn/vx-migrate`, `@vzn/vx-otel`,
1829
1936
  `@vzn/vx-reapi` and `@vzn/vx-schedule-history`.
1830
1937
 
1831
1938
  Each platform package carries `LICENSE` and `THIRD_PARTY_NOTICES.txt`:
@@ -1854,308 +1961,173 @@ direct publishing (the `E401 token is invalid` that stopped v0.0.17).
1854
1961
  Every `uses:` in both workflows is pinned to a commit SHA with the
1855
1962
  version in a comment; bump the SHA and the comment together.
1856
1963
 
1964
+ ## The workspace root
1965
+
1966
+ Every verb works on one workspace: the directory whose `.vx` holds the
1967
+ nearest `.vx/workspace.ts` (or `.mts`, `.js`, `.mjs`, `.cts`, `.cjs`) from
1968
+ the working directory up. vx never looks above the git top-level (a
1969
+ worktree is its own; a submodule looks on into its superproject), and
1970
+ outside git it looks up to `/`. Nothing else marks the root: not a
1971
+ `package.json`, `pnpm-workspace.yaml` or a lockfile (owner, 2026-10-13).
1972
+ Without one, every verb but `vx init` stops with `VX_E_NO_WORKSPACE`;
1973
+ `vx init` writes one (`@vzn/vx-migrate` at the package manager's root,
1974
+ in a nested workspace the outer one does not list too). A project inside the
1975
+ root is a directory holding a `vx.config.*`, which `vx lock` finds
1976
+ (see [`vx lock`](#vx-lock)); a `discover` plugin adds others.
1977
+
1978
+ ## Which vx runs
1979
+
1980
+ A global vx (installed with a package manager, or a compiled binary
1981
+ installed by hand) hands every call to the project's own `@vzn/vx` when
1982
+ there is one, so a project runs the version its lockfile pins (owner,
1983
+ 2026-10-13). There is no opt-out. The workspace root's
1984
+ `node_modules/@vzn/vx` runs, through its
1985
+ `node_modules/.bin/vx`. One hop: the receiving vx sees `VX_HANDED_OFF`
1986
+ and does not look again. A vx never hands off to itself (the same
1987
+ package, a pnpm symlink included), nor to a `.bin/vx` another user owns
1988
+ (one planted above your checkout would run as you). An npm install's launcher does the
1989
+ walk in the shell, a test per level and no fork, and `exec`s the local
1990
+ vx; the compiled binary checks one path per level before it loads
1991
+ anything, then runs the local vx with its stdio, passes on a signal sent
1992
+ to it alone, and exits with its code. From source (`bun src/bin.ts`) vx
1993
+ never hands off.
1994
+
1995
+ `vx --version` says when it was handed the call (`vx 0.0.640
1996
+ (/repo/node_modules/@vzn/vx-linux-x64/vx, handed off from
1997
+ /usr/local/bin/vx)`), and `vx info` shows the file that ran and who handed off
1998
+ (`vxPath`, `handedOffFrom`).
1999
+
1857
2000
  ## `vx upgrade`
1858
2001
 
1859
- Self-update the compiled binary in place: asks the GitHub release API
1860
- for this platform's asset and the SHA-256 digest it publishes,
1861
- downloads the asset, verifies the digest, and atomically replaces the
1862
- running executable (`vx upgrade TAG` pins a specific release; default
1863
- latest; a second tag is refused, not dropped). A download that does not match the digest replaces nothing —
1864
- `the download did not match the release's SHA-256 … nothing replaced` —
1865
- and a release that publishes no digest for the asset is refused before
1866
- the download. The digest comes from the same API as the asset, so it
1867
- proves the transfer, not who built the bytes: every release binary also
1868
- carries a Sigstore-signed build-provenance attestation, checked with
1869
- `gh attestation verify vx-<target> --repo vznjs/vx`. Named
1870
- `upgrade` per CLI convention (`bun upgrade`, `deno upgrade`). Refuses
1871
- when running from source — use `git pull` — and when the binary is
1872
- npm's: an npm install runs the platform package's compiled binary
1873
- under `node_modules`, and a rename over that file lasts until the next
1874
- `npm install` puts the version npm knows back, so `vx upgrade` there
1875
- says `this vx was installed by npm … Update with: npm install -g
1876
- @vzn/vx@latest`. A host it cannot reach (no route, a proxy that is
1877
- down) is one line — `could not reach api.github.com to read the
1878
- release (…) — check the network or the proxy and re-run` — never a
1879
- stack; so is a release document cut after the headers arrived or not
1880
- JSON (a captive portal's page served with a 200): `could not read the
1881
- release from api.github.com (…) — nothing replaced; …`, and an asset
1882
- download cut the same way: `could not download the release asset from
1883
- github.com (…) — nothing replaced; …`. A binary whose directory this
1884
- user cannot write (a root-owned `/usr/local/bin`) is refused before the
2002
+ Brings vx to a newer release (`vx upgrade TAG` pins one, `v0.4.0` or
2003
+ `0.4.0`; default latest; a second tag is refused, not dropped), the way
2004
+ it was installed. Named `upgrade` per CLI convention (`bun upgrade`,
2005
+ `deno upgrade`). Refuses when running from source: use `git pull`.
2006
+
2007
+ **A compiled binary installed by hand** replaces itself. It reads its
2008
+ platform package (`@vzn/vx-<os>-<arch>`) from the npm registry and
2009
+ checks npm's registry signature over `<name>@<version>:<integrity>`
2010
+ against npm's signing key, pinned in vx (key id
2011
+ `SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U`), then the
2012
+ tarball's SHA-512 against that signed integrity, and only then extracts
2013
+ `package/vx`, stages it in a fresh directory beside the binary and
2014
+ renames it over. The host that serves the tarball cannot make that
2015
+ signature, so a swapped tarball, or a document with a swapped integrity,
2016
+ is refused: `the npm registry signature for … is not valid — refusing
2017
+ to install; nothing replaced`, or `the download did not match the
2018
+ integrity npm signed … nothing replaced`. A release signed with a key
2019
+ id vx does not pin says so: `… is signed with an npm key this vx does
2020
+ not know (…) — if npm rotated its signing key, this vx's installer
2021
+ needs updating`. A release with no signature or no integrity is refused
2022
+ before the download. Every refusal comes before anything is written.
2023
+ There is no fallback to GitHub's release digest: when the registry is
2024
+ unreachable, the upgrade is refused.
2025
+
2026
+ A host it cannot reach (no route, a proxy that is down) is one line,
2027
+ `could not reach registry.npmjs.org to read the release (…) — check the
2028
+ network or the proxy and re-run`, never a stack; so is a document cut
2029
+ after the headers or not JSON (a captive portal's page served with a
2030
+ 200), and a tarball cut the same way. A binary whose directory this user
2031
+ cannot write (a root-owned `/usr/local/bin`) is refused before the
1885
2032
  download: `cannot write to <dir> (EACCES), where this vx lives — nothing
1886
- downloaded; re-run as a user who can (sudo vx upgrade), …`. The new binary
1887
- keeps the old one's mode (and, as root, its owner), and must answer
1888
- `--version` before the upgrade reports it installed: one that does not
1889
- start on this machine — or does not answer within 10 s — is swapped back
1890
- for the previous vx, and the command exits 1 saying so (item 1097). On the version it already is
1891
- it says `already at <version>` and downloads nothing; a second tag is
1892
- refused rather than ignored; and GitHub's hourly API limit for an
1893
- unauthenticated address is named, with its reset time, instead of a
1894
- bare `(403)` (item 1098).
1895
-
1896
- Exit codes: `0` the new binary installed, or already at that version;
1897
- `1` anything refused or swapped back: from source, from npm, a host it
1898
- cannot reach, a directory it cannot write, a digest that does not match,
1899
- a binary that does not start.
2033
+ downloaded; re-run as a user who can (sudo vx upgrade), …`. The new
2034
+ binary keeps the old one's mode (and, as root, its owner), and must
2035
+ answer `--version` before the upgrade reports it installed: one that
2036
+ does not start on this machine, or does not answer within 10 s, is
2037
+ swapped back for the previous vx, and the command exits 1 saying so
2038
+ (item 1097). On the version it already is it says `already at
2039
+ <version>` and downloads nothing.
2040
+
2041
+ **A vx a package manager installed** (the npm package's launcher runs
2042
+ `…/node_modules/@vzn/vx-<os>-<arch>/vx`) is upgraded by that package
2043
+ manager, never by a rename over its file, which the next install would
2044
+ undo. The path names the install:
2045
+
2046
+ | Install | Detected by | Runs |
2047
+ | ---------------------- | ------------------------------------- | ----------------------------- |
2048
+ | npm global | `<prefix>/lib/node_modules` | `npm install -g @vzn/vx@<v>` |
2049
+ | pnpm global | `…/pnpm/global/…` | `pnpm add -g @vzn/vx@<v>` |
2050
+ | bun global | `~/.bun/install/global` | `bun add -g @vzn/vx@<v>` |
2051
+ | yarn global | `…/yarn/global/…` | `yarn global add @vzn/vx@<v>` |
2052
+ | a project's dependency | the package.json that lists `@vzn/vx` | its manager's add, same field |
2053
+
2054
+ A project's package.json is the tree's root, or for a hoisted workspace
2055
+ the nearest one above the current directory that lists `@vzn/vx`. Its
2056
+ manager is its lockfile (`pnpm-lock.yaml`, `bun.lock`, `yarn.lock`,
2057
+ `package-lock.json`), else `packageManager`, else npm; the field it is
2058
+ listed in is kept (`--save-dev`, `--dev`, …), and pnpm at a workspace
2059
+ root adds `--workspace-root`. The lockfile is updated by that manager.
2060
+ Run inside a project that installs `@vzn/vx`, a global vx hands
2061
+ `vx upgrade` to the project's own (§ Which vx runs), so the project's
2062
+ dependency is what moves.
2063
+ vx prints what it runs (`vx upgrade: 0.0.1 → 0.0.2: running pnpm add
2064
+ @vzn/vx@0.0.2 --save-dev (in /repo)`), runs it there, and exits with an
2065
+ error if it fails. vx never installs Node or a package manager: one not
2066
+ on `PATH` is refused with the command to run. A vx run from an npx or
2067
+ bunx cache has nothing installed to upgrade, and says to ask for
2068
+ `@vzn/vx@latest` instead.
2069
+
2070
+ Exit codes: `0` installed, or already at that version; `1` anything
2071
+ refused or swapped back: from source, a cache install, a host it cannot
2072
+ reach, a directory it cannot write, a signature or integrity that does
2073
+ not verify, a binary that does not start, a package manager that failed
2074
+ or is missing.
1900
2075
 
1901
2076
  ## `vx init`
1902
2077
 
1903
- In a Turbo or Nx repo (a `turbo.json`, `turbo.jsonc` or `nx.json` at
1904
- the root) it runs `@vzn/vx-migrate`, so `vx init` is the one command
1905
- (owner, 2026-10-08): the installed one, else this vx's version through
1906
- `bun x` (`BUN_BE_BUN=1` makes the compiled binary that runtime). It asks a
1907
- terminal native (a `vx.config.ts` per package, the default) or keep;
1908
- `--native` or `--keep` answer it, and without a terminal it is native.
1909
- `--dry`, `--format`, `--force` and `--mjs` pass through.
1910
-
1911
- `vx init --dry --format json` prints the plan as one JSON document
1912
- (`schemas/init.json`), nothing written: `files` (each path and its
1913
- whole text), `kept`, `replaced`, `todos` (a reason and its tasks),
1914
- `notes` and `next`. In a Turbo or Nx repo it is `@vzn/vx-migrate`'s
1915
- plan. `--format json` without `--dry`, or with `--plugin`, is refused
1916
- (`VX_E_USAGE`).
1917
- Run again after the native migration (a workspace file that declares
1918
- neither `turbo()` nor `nx()`, and project configs), it says vx is already
1919
- set up and prints the next step, exit 0, without starting
1920
- `@vzn/vx-migrate`; `--force` regenerates the files.
1921
-
1922
- `vx init --keep` writes `vx.workspace.ts` declaring `turbo()` or `nx()`
1923
- from `@vzn/vx-migrate` and nothing else: those read the repo's own
1924
- config live, so no task is copied — a temporary start until
1925
- `vx init --native` writes native config. `--native` or `--keep` in any
1926
- other repo is refused. The `next:` line is one command:
1927
- install what the file imports and is missing, with the manager the
1928
- lockfile names (at the workspace root: pnpm's `-w`, Yarn 1's `-W`,
1929
- which Yarn Berry lacks), then run the config's `build` (else its first task).
1930
- An existing workspace file is kept and named unless it already
1931
- declares the plugin; `--force` replaces it. When the repo shows a remote
1932
- cache — turbo.json's `remoteCache`, a `.turbo/config.json` naming a
1933
- team or token (`turbo link` writes it), or a line setting `TURBO_TOKEN` /
1934
- `NX_SELF_HOSTED_REMOTE_CACHE_SERVER` (`NAME:` or `NAME=`, not a comment)
1935
- in `.github/workflows/*`, `.gitlab-ci.yml` or `.circleci/config.yml`,
1936
- and turbo.json does not say `remoteCache.enabled: false` — it
1937
- declares `turboCache()` / `nxCache()` beside the runner and names the
1938
- file that showed it (a kept file lacking it is told to add it); the
1939
- plugin is inert where its variable is unset. An nx.json that connects
1940
- Nx Cloud (`nxCloudId`, `nxCloudAccessToken`, the `nx-cloud` runner) is
1941
- named instead: vx does not speak its wire, so runs cache locally.
1942
-
1943
- Anywhere else it scaffolds a workspace that comes from nowhere: one `vx.config.ts` per
1944
- package from its `package.json` scripts, plus a `vx.workspace.ts` of
1945
- `{ plugins: [] }`: running and caching here are the floor, so it
1946
- declares no executor or cache, and the file carries no comment (it is
1947
- the user's). `@vzn/vx-migrate` takes the
1948
- same `--dry` / `--force` flags but reads a runner's config (turbo, nx);
1949
- package.json scripts are `init`'s. A workspace with no scripts at
1950
- all still gets the workspace file, a printed example config, and the
1951
- next command to run. A root
1952
- `package.json` with no `workspaces` field is single-project mode, and
1953
- when `packages/*/package.json` files sit below it unreached, both
1954
- `init` and a run that finds no config say so instead ("package.json
1955
- declares no workspaces … Add "workspaces": ["packages/*"] to
1956
- package.json and re-run") rather than "no scripts" or "run vx init". A
1957
- workspace whose globs match no `package.json` gets a note naming them
1958
- (M-42); a nameless member, or two sharing a name, is matched and
1959
- named on its own line instead (M-46). When such skipped scripts are all there
1960
- is, the report opens `no package.json scripts became tasks.` rather than
1961
- saying there are none (M-59). When `pnpm-workspace.yaml` lists the
1962
- members and the root `package.json`'s `workspaces` lists other globs,
1963
- the note names the unread list (M-51): pnpm reads only the yaml, while
1964
- bun, npm and yarn read `package.json`. With
1965
- no `package.json` here or above, `init` says to create one (`bun init` or
1966
- `npm init -y`) first. Every
1967
- generated config is typed for the editor through
1968
- `import type { ProjectConfig } from '@vzn/vx/config'` and `satisfies
1969
- ProjectConfig` — a type-only import Bun erases, so the file loads in a
1970
- workspace that runs the `vx` binary without the package installed. The
1971
- workspace file takes the same form (`satisfies WorkspaceConfig`). A
1972
- runtime `import { defineWorkspace } from '@vzn/vx'` costs nothing extra
1973
- since 2026-09-10: the running `vx` serves its own core to that
1974
- specifier (bin.ts registers the alias), so a config or a plugin package
1975
- never loads a second copy, and a workspace that runs the binary needs
1976
- no `@vzn/vx` installed for it — the type-only form is still what the
1977
- scaffold writes, because it types the same with no import to resolve
1978
- in an editor without the package. A workspace that imports a PLUGIN
1979
- package at runtime without having installed it is told so, and to
1980
- install its dependencies. `--mjs` writes the same objects as `vx.config.mjs` and
1981
- `vx.workspace.mjs`, with no type import and no `satisfies`: a package whose
1982
- own `tsconfig` includes every `.ts` under it compiles a `vx.config.ts`
1983
- into its dist (TanStack/query's `tsc --build`, 2026-09-11), and an
1984
- `.mjs` is outside that include. `@vzn/vx-migrate` takes the same flag.
1985
- A file it writes that `.gitignore` drops (vueuse ignores
1986
- `packages/**/*.mjs`) gets a `note:` naming it and the `!vx.config.mjs`
1987
- line that keeps it, since a commit would leave it behind unnoticed.
1988
-
1989
- Each script becomes a task with its command verbatim. `build` gets
1990
- `dependsOn: ['^build']` (a `build` that only delegates, `pnpm run
1991
- compile`, is a group, and the edge goes on the script that does the
1992
- work) and **no cache block** — under a
1993
- `TODO(vx-migrate)` showing the block to add with the package's real
1994
- inputs and outputs, and the same block commented as the task's first
1995
- line: set the real globs and delete the `// `. A task without a cache block always runs; a block
1996
- with EMPTY outputs is not "uncached" but a no-output task that hits on
1997
- unchanged inputs and skips the build with nothing to restore (a deleted
1998
- `dist` stays deleted under a green `up-to-date` run — what `init`
1999
- generated until 2026-09-04), and a guessed `dist/**` would restore the
2000
- wrong tree for every package that writes elsewhere. The block the TODO
2001
- shows names `dist/**`, or the default output of the framework the command
2002
- runs: `.next/**` minus `!.next/cache/**` for `next build`, `.output/**`
2003
- for Nuxt, `build/**` for Remix, React Router, Create React App and
2004
- Docusaurus, `public/**` for Gatsby, `storybook-static/**` for Storybook;
2005
- for any other command, the directory it names with `--outDir` / `--out-dir`
2006
- / `-d`, else the ones it cleans first (`del-cli distribution`, `rimraf lib
2007
- types`), unless it makes one again with `mkdir` (D-90).
2008
- A package in a cycle of builds (nuxt's `@nuxt/nitro-server` devDepends
2009
- on `nuxt`, which depends on it; pnpm sorts it away) gets, instead of
2010
- `^build`, an edge to each build outside its cycle that `^build` would
2011
- reach, and a TODO to order the cycle: `^build` there refuses the run.
2012
- `test` / `typecheck` wait for
2013
- `build` when the package has one (`lint` reads sources and gets no
2014
- edge); `dev` / `start` / `serve` / `watch` /
2015
- `preview` become persistent tasks, with a TODO to add `readyWhen` when
2016
- another task depends on one, and so does a watcher: a `watch` segment in the script's name (`build:watch`),
2017
- a `--watch` flag, `tsc -w` / `rollup -w`, or nodemon (D-40), and a server:
2018
- `serve <dir>`, `http-server`, bare `vite`, a tool's `dev` / `serve` /
2019
- `start` / `preview` verb (`next start`, `netlify dev`), or a script that
2020
- runs such a script of its package by name (`cross-env X=1 pnpm start`) or
2021
- through a runner (`run-p web api`, `concurrently "npm:web" "npm:api"`, D-113),
2022
- outside quotes and not sent to the background with `&` (D-91), read past
2023
- a launcher's `--package <name>` / `-p <name>` (`pnpm dlx --package
2024
- netlify-cli netlify dev`, D-112). A
2025
- script whose name no task may carry (`lint#fix`, `^up`; the schema's
2026
- rule, item 1000) is left out with a TODO rather than written into a
2027
- config every later command refuses, and a `__proto__` script is written
2028
- as a computed key, since a literal `__proto__:` sets the prototype. An
2029
- existing `vx.workspace.*` in any extension the loader reads (`.mts`
2030
- included) is kept, and `--force` REPLACES a package's config of another
2031
- extension (`replaced:` in the report) rather than writing a second one
2032
- the loader would choose between by its order (item 1033). Without
2033
- `--force`, `init` never overwrites: a package that already has a vx
2034
- config keeps it, untouched, the rest get theirs, and the report lists
2035
- the kept ones under `kept`, so a half-adopted workspace adopts the rest
2036
- (M-52). `@vzn/vx-migrate` still refuses an existing config.
2037
-
2038
- A missing `vx.workspace.*` is not an error. A run where no package has
2039
- a config fails before any task, exit 1:
2040
- ``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.``
2041
-
2042
- Two npm conventions are mapped rather than copied, because copying them
2043
- loses behaviour. `pre<x>` / `post<x>` hooks, which npm runs around `x`
2044
- without being named, are folded into `x`'s command in that order, each
2045
- in its own subshell, so the chain stops at the first that fails whatever
2046
- a part holds (a `;`, an `exit`), and forwarded `--` args reach `x` alone,
2047
- as npm hands them to the script and not its hooks (item 905), appended
2048
- as text, so no part sees them as `$1` (X-60). The
2049
- command is a small shell function, `vx_script`, around the three parts;
2050
- it carries a TODO saying so; a `pre<x>` with no `x` stays a task of its own, and
2051
- npm's lifecycle hooks (`prepack`, `prepublishOnly`, …) are never tasks.
2052
- A member with no script to run (none, or only lifecycle hooks: a
2053
- types-only or config package) gets no `vx.config.ts` and is named in
2054
- the report; it is still a project, and a task declared in its own
2055
- `vx.config.ts` runs. A package with no `build` script whose `prepack`, `prepublishOnly`,
2056
- `prepublish` or `prepare` runs a builder (`bob build`, `tsc`, `tsup`, …)
2057
- is named in the report, which says to add a `build` script running it
2058
- (react-navigation's twelve packages, whose root `build` is `lerna run
2059
- prepack`, mapped with no build at all).
2060
- Where the package's manager runs no such hooks every `pre<x>` and
2061
- `post<x>` is a task of its own: Yarn 2+ (the nearest `packageManager:
2062
- yarn@2+`, or a Berry `yarn.lock`, D-31; a `packageManager` naming none of npm,
2063
- pnpm, yarn or bun defers to the lockfile, D-96), npm under `ignore-scripts=true`
2064
- in the `.npmrc` beside its lockfile, pnpm under
2065
- `enable-pre-post-scripts=false` there or `enablePrePostScripts: false` in
2066
- `pnpm-workspace.yaml` (D-33). Bun and Yarn 1 run them whatever those say.
2067
- Under Yarn 2+ a segment's `run <script>`, Yarn's shell builtin, is written
2068
- `yarn run <script>`: vx's shell has no `run` (D-92).
2069
- A Yarn 2+ repo on Plug'n'Play (no `nodeLinker: node-modules` or `pnpm` in
2070
- `.yarnrc.yml`) keeps its bins in `.pnp.cjs`, where vx's PATH finds none;
2071
- the report says so and names `nodeLinker: node-modules` or `yarn exec
2072
- '<command>'` (D-108).
2073
- A script reading `$npm_package_version`, `$npm_package_name` or
2074
- `$npm_lifecycle_event`, which every manager sets and vx does not, gets
2075
- them under `exec.env.define`, the first two read from an imported
2076
- `package.json` so a version bump reaches them; any other `$npm_*` it
2077
- reads gets a TODO (D-34). Among several packages, a workspace root
2078
- script that runs the members (`pnpm -r build`, `--filter`, `-C`, pnpm's `pn` alias
2079
- included (D-97), Yarn's
2080
- `--cwd` and `yarn workspace <name>`, npm's `--prefix`, npm's
2081
- and Yarn's workspace flags, a `cd` into or above a member, turbo, nx, lerna, `vp run`, vx itself) is not mapped
2082
- (a flag counts on the package manager, or after `node <bin> run`, and not on the program it
2083
- runs: berry's `yarn node -r ./setup.ts` is node's `--require`, D-81; bun's
2084
- `cd test && …` enters no member, D-83),
2085
- nor is one that runs such a script by name (vite's `ci-docs`: `pnpm build &&
2086
- pnpm docs-build`; through `run-s` / `run-p` / `npm-run-all` or `concurrently
2087
- "npm:x"` too, lexical's `ci-check`, D-95), and neither is one whose name a member's task carries, so `--all` never runs
2088
- a check twice (D-45). The rest check the whole repo (`lint: oxlint .`,
2089
- `test: vitest`) and become the root's own tasks in a root vx.config, when
2090
- the root has a `"name"` (vx skips a nameless root's config) and no config
2091
- of its own; one already there, hand-written or from an earlier
2092
- `vx init`, stays as written, `--force` included, and is listed under
2093
- `kept`. The report names each script left out and why
2094
- (a `pre` / `post` hook goes with its script, D-85), its examples of running
2095
- the members spelled by the repo's manager (`--workspaces` under npm, `yarn
2096
- workspaces foreach` under Yarn 2+), and says which;
2097
- with nothing mapped it names the root whenever it has a script, a member
2098
- or not (pnpm's root is not), and why: its scripts run the workspace, or
2099
- share a member's task name (listed), or both; it tells a root with no `"name"` to add one
2100
- first (vuejs/core), naming the scripts that would then map (react, D-87), and a root whose
2101
- `"name"` a member also carries to rename it, since a root config would make the
2102
- workspace refuse every run for the duplicate (insomnia, D-129). A single-package repo's root is its project and maps.
2103
- A lone root beside a `lerna.json` (Lerna-classic: packages listed there, not
2104
- in `workspaces`) gets a note naming the `workspaces` globs to add, Lerna's
2105
- `packages/*` default when it lists none (D-111).
2106
- A member whose package.json has no `name` is no project; one with a
2107
- script is named in the report, to be given a name (remix's
2108
- `packages/component/bench`, D-106).
2109
- A member's script that runs another member's work (`pnpm -C ../pinia
2110
- run build`, `yarn workspace <name>`, `-r` / `--filter`, a `cd` or `-C`
2111
- / `--cwd` / `--prefix` into another member) keeps its command and gets a
2112
- TODO naming that part: the graph runs that member's task once, so name it
2113
- under `dependsOn` and drop it from the command (pinia). A directory inside
2114
- the member or the root is no other member.
2115
- A script that is nothing but `npm run <other>` (`pnpm <other>`, `yarn
2116
- <other>`, `bun run <other>`, `npm test`, `npm start`) becomes a **group**
2117
- over `<other>` — `dependsOn` and no command — so the graph runs and
2118
- caches the target instead of a package-manager subprocess it cannot
2119
- see. When `build` is such a group, the task it reaches that runs a
2120
- command takes `^build` and the cache TODO, since a group has nothing
2121
- to cache; it and the groups on the way drop the wait on `build` a
2122
- `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`
2123
- is Bun's test runner, `bun build` its bundler, `pnpm install` and `yarn
2124
- add` the managers' verbs, so each stays a command (item 908), and so do
2125
- the names each manager reserves or hands on: `pnpm docs` and `pnpm
2126
- version` (npm's), `bun deploy`, `yarn check` (D-32); `pnpm test`, `yarn
2127
- test` and `bun lint` do run the script. Arguments, flags or a `&&`
2128
- chain make it a real command again and
2129
- it is left verbatim, and so is one whose target becomes no task (a
2130
- lifecycle script, or a hook folded into another script): a group over
2131
- it would name a task nothing defines (D-12). A verbatim chain whose
2132
- parts run this package's own scripts (`check: pnpm run build && pnpm run
2133
- lint`) gets a TODO naming those parts: each ran again inside the command,
2134
- beside its own task, so `vx run check` built twice (M-41). So does a
2135
- part that runs them through `run-s`, `run-p` or `npm-run-all`
2136
- (`build: run-p build:*`), unless the task is persistent (M-48). Its
2137
- order may matter, so it is not made a group.
2138
-
2139
- The report lists each TODO once per reason: tasks that share one are
2140
- named together (the first five, then a count; the files carry each),
2141
- and when no task caches it says uncommenting a TODO task's cache line
2142
- makes the second run a hit. Its `next:` line is a command the user can type: the
2143
- runner that started vx (`npx`, `pnpm`, `yarn`, `bunx`, read from
2144
- `npm_config_user_agent`) with the installed `vx` bin, else the
2145
- `@vzn/vx` package (`npx` under Yarn 1, which has no `dlx`); with no
2146
- runner, a bare `vx`. Outside a git work tree it starts with
2147
- `git init`, and with no script mapped with declaring a task, since the
2148
- run would refuse without either. It runs `build`, else the first task that
2149
- neither serves (`persistent`) nor changes the repo (`clean`, `release`,
2150
- `publish`, `deploy`, `version`, `format`, `fix`, …), else the first task
2151
- (react-navigation was told `vx run clean --all`).
2078
+ `vx init` reads no `package.json` (M, 2026-10-14). When the directory it
2079
+ runs in, or one above it up to the git top-level, holds a
2080
+ `package.json`, `pnpm-workspace.yaml`, `turbo.json`, `turbo.jsonc` or
2081
+ `nx.json`, it runs `@vzn/vx-migrate` there, so `vx init` is the one
2082
+ command (owner, 2026-10-08): the installed one (the nearest
2083
+ `node_modules/@vzn/vx-migrate` up from the working directory), else
2084
+ this vx's version through `bun x` (`BUN_BE_BUN=1` makes the compiled
2085
+ binary that runtime). `--dry`, `--format`, `--force`, `--mjs`,
2086
+ `--native` and `--keep` pass through, and the exit is vx-migrate's.
2087
+ vx-migrate maps a Turbo, Nx or Vite Task repo, else the `package.json`
2088
+ scripts, one `vx.config.ts` per package; writes the workspace file with
2089
+ the plugins the repo calls for (the lockfile's, a remote cache the repo
2090
+ shows); installs them; and ends with `vx lock`. Its README holds the
2091
+ rules; [`bunx @vzn/vx-migrate`](#bunx-vznvx-migrate) below sums them up.
2092
+
2093
+ With none of those files, it writes a workspace whose one project is
2094
+ the root, at the nearest workspace root up from the working directory,
2095
+ else there:
2096
+
2097
+ ```ts
2098
+ // .vx/workspace.ts
2099
+ import { defineWorkspace } from '@vzn/vx'
2100
+
2101
+ export default defineWorkspace({ plugins: [] })
2102
+ ```
2103
+
2104
+ ```ts
2105
+ // vx.config.ts
2106
+ import { defineProject } from '@vzn/vx'
2107
+
2108
+ export default defineProject({ tasks: {} })
2109
+ ```
2110
+
2111
+ plus `.vx/.gitignore` and `.vx/.gitattributes`. It then runs `vx lock`'s
2112
+ step, and `next:` says to declare a task and run `vx lock && vx run
2113
+ <task>`, preceded by `git init` outside a git work tree. The `@vzn/vx`
2114
+ import needs no install: the running vx serves its own core to that
2115
+ specifier (bin.ts registers the alias). A file already there is kept
2116
+ and named under `kept`; `--force` replaces it, a file of another
2117
+ extension included (`replaced`). `--mjs` writes `.vx/workspace.mjs` and
2118
+ `vx.config.mjs`. `--dry` prints the files and writes nothing;
2119
+ `--dry --format json` prints the plan as one JSON document
2120
+ (`schemas/init.json`): `files` (each path and its whole text), `kept`,
2121
+ `replaced`, `todos`, `notes` and `next`. `--native` and `--keep` answer
2122
+ a Turbo or Nx repo's question, so they are refused here. `--format json`
2123
+ without `--dry`, or with `--plugin`, is refused (`VX_E_USAGE`).
2152
2124
 
2153
2125
  `vx init --plugin <seam>` writes a plugin instead: `plugins/<seam>.ts`,
2154
2126
  a small runnable plugin for that seam (`executor`, `cache`,
2155
2127
  `telemetry`, `schedule`, `admit`, `commands`, `project`, `graph`,
2156
2128
  `key`), and `plugins/<seam>.test.ts`, which drives it through `run()`
2157
2129
  (`bun test`, with `@vzn/vx` installed). It prints the line that
2158
- declares it in the workspace file a run reads (`vx.workspace.ts` when
2130
+ declares it in the workspace file a run reads (`.vx/workspace.ts` when
2159
2131
  there is none). The templates are TypeScript, so `--mjs` with
2160
2132
  `--plugin` is refused. An existing file is refused without
2161
2133
  `--force`; `--dry` names the files and writes nothing. The two files are
@@ -2163,25 +2135,35 @@ there is none). The templates are TypeScript, so `--mjs` with
2163
2135
  core (`src/cli/plugin-templates.ts`) and held equal by
2164
2136
  `tests/plugin-templates.unsafe.test.ts` (H-21).
2165
2137
 
2166
- Exit codes: `0` the files written (or, with `--dry`, printed); `1` no
2167
- `package.json` here or in a parent, a file it would write already there
2168
- without `--force`, or a parse error (`--mjs` with `--plugin` among them).
2138
+ Exit codes: `0` the files written (or, with `--dry`, printed); `1` a
2139
+ refused flag, a parse error (`--mjs` with `--plugin` among them), or a
2140
+ plugin file already there without `--force`. Handed to
2141
+ `@vzn/vx-migrate`, the exit is its own.
2169
2142
 
2170
- ## `vx migrate`
2143
+ ## `bunx @vzn/vx-migrate`
2171
2144
 
2172
- Moved out of core on 2026-09-10: the Turbo and Nx mappers are
2173
- `@vzn/vx-migrate`, their own package, run without a workspace file —
2145
+ Mapping a repo's own config to vx is `@vzn/vx-migrate`, its own
2146
+ package, run before a workspace file exists (it writes one); `vx init`
2147
+ runs it in any repo with a `package.json` —
2174
2148
 
2175
2149
  ```
2176
- bunx @vzn/vx-migrate # turbo.json, an Nx graph or vite-plus run.tasks → vx.config.ts
2150
+ bunx @vzn/vx-migrate # turbo.json, an Nx graph, vite-plus run.tasks, else package.json scripts → vx.config.ts
2177
2151
  bunx @vzn/vx-migrate --dry # print the generated files instead of writing
2178
2152
  bunx @vzn/vx-migrate --force # overwrite existing vx.config.* / vx-preset.ts
2179
- bunx @vzn/vx-migrate --from nx # disambiguate when both runners are checked in
2180
- ```
2181
-
2182
- — and `package.json` scripts are `vx init` (above). Typing `vx migrate`
2183
- prints that pointer and exits 1. What the package writes reads exactly
2184
- like what `vx init` writes: both hand a plan to core's migration seam
2153
+ bunx @vzn/vx-migrate --from nx # disambiguate when several sources are checked in (turbo, nx, vite-task, scripts)
2154
+ ```
2155
+
2156
+ — In a Turbo or Nx repo it asks a terminal native (a `vx.config.ts`
2157
+ per package, the default) or keep (a workspace file declaring `turbo()`
2158
+ or `nx()`, which read the repo's config live); `--native` or `--keep`
2159
+ answer it, and without a terminal it is native. Both declare
2160
+ `turboCache()` / `nxCache()` when the repo shows a remote cache
2161
+ (turbo.json's `remoteCache`, a `turbo link`ed `.turbo/config.json`, or
2162
+ a CI file setting `TURBO_TOKEN` / `NX_SELF_HOSTED_REMOTE_CACHE_SERVER`),
2163
+ and name Nx Cloud, whose wire vx does not speak. Elsewhere each
2164
+ package's `package.json` scripts become its tasks, and the workspace
2165
+ file declares the `@vzn/vx-lockfile` plugin for the root's lockfile.
2166
+ What the package writes goes through core's migration seam
2185
2167
  (`applyMigration`, exported from `@vzn/vx`), which renders, guards
2186
2168
  against overwriting, writes and reports. The mapping rules live in the
2187
2169
  package's README.
@@ -2196,7 +2178,7 @@ none is dropped in silence. An Nx flag's camelCase spelling (`--nxBail`,
2196
2178
  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
2197
2179
  `vx run` that does the same, and the other verbs a hand types from
2198
2180
  either tool name what does it here: `graph` (`--graph`), `ls`
2199
- (`vx show`), `query` (`--dry=json`), `reset` (`vx cache prune`), and
2181
+ (`vx show`), `query` (`--dry=json`), `reset` (`vx cache clean`), and
2200
2182
  `daemon`, `login`, `logout`, `link` and `unlink`, which vx has no use
2201
2183
  for. Nx's `project:target` (`vx run web:build`,
2202
2184
  when `web` declares `build`) is answered with `vx run web#build`: from
@@ -2222,8 +2204,8 @@ copy to the source.
2222
2204
  | turbo | `--summarize[=<bool>]` | same | `--summarize[=<path>]` |
2223
2205
  | turbo | `--output-logs=new-only` | refuse | use `--output-logs=full` (a hit replays its log) or `errors-only` |
2224
2206
  | turbo | `--output-logs <v>` | same | `--output-logs full\|errors-only\|hash-only\|none` |
2225
- | turbo | `--no-cache` | same | `--no-cache` reads nothing either; Turbo's (reads, no writes) is `--cache local:r,remote:r` |
2226
- | turbo | `--cache <v>` | same | `--cache local:rw,remote:r` |
2207
+ | turbo | `--no-cache` | same | `--no-cache` reads nothing either; Turbo's (reads, no writes) is `--cache-policy local:r,remote:r` |
2208
+ | turbo | `--cache <v>` | alias | `--cache-policy`, the same spec (`local:rw,remote:r`) |
2227
2209
  | turbo | `--cache-dir <v>` | same | `--cache-dir <path>` |
2228
2210
  | turbo | `--profile` | same | `--profile[=<path>]` (Chrome trace) |
2229
2211
  | turbo | `--only` | alias | `--exclude-dependencies` |
@@ -2234,13 +2216,13 @@ copy to the source.
2234
2216
  | turbo | `--no-update-notifier` | refuse | vx prints no update notice: drop it |
2235
2217
  | turbo | `--skip-infer` | refuse | vx runs the binary it is: drop it |
2236
2218
  | turbo | `--root-turbo-json <v>` | refuse | `turbo()` reads the `turbo.json` at the workspace root: move it there |
2237
- | turbo | `--experimental-otel-*` | refuse | telemetry is a plugin: `otel()` from @vzn/vx-otel in vx.workspace.ts |
2219
+ | turbo | `--experimental-otel-*` | refuse | telemetry is a plugin: `otel()` from @vzn/vx-otel in .vx/workspace.ts |
2238
2220
  | nx | `--parallel <n>` | alias | `--concurrency <n>` (`--parallel=false` is 1) |
2239
2221
  | turbo | `--parallel` | refuse | vx always honours `dependsOn`; `--concurrency <n>` sets how many run at once |
2240
2222
  | turbo | `--scope <v>` | refuse | use `--filter <pkg>` |
2241
2223
  | turbo | `--since <v>` | refuse | use `--filter '[<ref>]'` or `--affected=<ref>` |
2242
- | turbo | `--remote-only[=<bool>]` | refuse | use `--cache local:,remote:rw` |
2243
- | turbo | `--remote-cache-read-only[=<bool>]` | refuse | use `--cache local:rw,remote:r` |
2224
+ | turbo | `--remote-only[=<bool>]` | refuse | use `--cache-policy local:,remote:rw` |
2225
+ | turbo | `--remote-cache-read-only[=<bool>]` | refuse | use `--cache-policy local:rw,remote:r` |
2244
2226
  | turbo | `--anon-profile` | refuse | use `--profile[=<path>]`; vx has no redacting variant, so read it before sharing it |
2245
2227
  | turbo | `--cache-workers <v>` | refuse | vx sizes its own cache I/O: drop it |
2246
2228
  | turbo | `--cwd <v>` | refuse | run vx from that directory: `cd <dir> && vx run …` |
@@ -2253,7 +2235,7 @@ copy to the source.
2253
2235
  | turbo | `--preflight` | refuse | set `turboCache({ preflight: true })` or `TURBO_PREFLIGHT=1` |
2254
2236
  | turbo | `--remote-cache-timeout <v>` | refuse | set `turboCache({ timeoutMs })` or `TURBO_REMOTE_CACHE_TIMEOUT` |
2255
2237
  | turbo | `--single-package` | refuse | a repo with no workspaces is one project already: drop it |
2256
- | 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 |
2238
+ | 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 |
2257
2239
  | turbo | `--no-daemon`, `--daemon` | refuse | vx has no daemon: drop it |
2258
2240
  | turbo | `--ui <v>`, `--log-order <v>`, `--log-prefix <v>` | refuse | vx frames each task’s output: `--output-logs <mode>` sets how much |
2259
2241
  | nx | `-t <v>`, `--targets <v>`, `--target <v>` | alias | the task names, positional: `vx run build test` |
@@ -2270,13 +2252,13 @@ copy to the source.
2270
2252
  | nx | `--uncommitted`, `--untracked` | refuse | use `--affected=HEAD` (the working tree against the last commit) |
2271
2253
  | nx | `--max-parallel <v>` | alias | `--concurrency <n>` |
2272
2254
  | nx | `--exclude-task-dependencies[=<bool>]` | alias | `--exclude-dependencies` |
2273
- | nx | `--skip-remote-cache[=<bool>]` | alias | `--cache local:rw,remote:` |
2255
+ | nx | `--skip-remote-cache[=<bool>]` | alias | `--cache-policy local:rw,remote:` |
2274
2256
  | nx | `--verbose` | refuse | use `--verbosity <n>` (1 adds the summary table) |
2275
2257
  | nx | `--files <v>` | refuse | vx asks git what changed: `--affected=<base>` |
2276
2258
  | nx | `--batch` | refuse | vx runs one command per task: drop it |
2277
2259
  | nx | `--dte`, `--use-agents` | refuse | vx distributes nothing: drop it |
2278
2260
  | nx | `--nx-ignore-cycles` | refuse | vx refuses a task cycle by name: break it |
2279
- | nx | `--runner <v>` | refuse | a remote cache is a plugin: `nxCache()` from @vzn/vx-migrate in vx.workspace.ts |
2261
+ | nx | `--runner <v>` | refuse | a remote cache is a plugin: `nxCache()` from @vzn/vx-migrate in .vx/workspace.ts |
2280
2262
  | nx | `--skip-sync` | refuse | vx never runs sync generators: drop it |
2281
2263
  | nx | `--tui`, `--no-tui`, `--tui-auto-exit` | refuse | vx frames each task’s output: `--output-logs <mode>` sets how much |
2282
2264
  | nx | `--no-cloud` | refuse | vx has no cloud: drop it |
@@ -2290,8 +2272,9 @@ copy to the source.
2290
2272
 
2291
2273
  ## Machine-readable output
2292
2274
 
2293
- `vx show`, `vx info`, `vx why`, `vx last` and `vx cache prune` take
2294
- `--format json`. Each prints one JSON document whose shape is a
2275
+ `vx show`, `vx info`, `vx why`, `vx last` and `vx cache clean` take
2276
+ `--format json`. Each prints one JSON document on one line, as
2277
+ `vx run --format json` does, whose shape is a
2295
2278
  checked-in JSON Schema (draft 2020-12) shipped with the package:
2296
2279
  `schemas/show.json`, `schemas/info.json`, `schemas/why.json`,
2297
2280
  `schemas/last.json`, `schemas/cache.json`
@@ -2323,15 +2306,15 @@ prose on stderr do not change. Branch on the code; the message may change.
2323
2306
 
2324
2307
  #### `VX_E_USAGE`
2325
2308
 
2326
- A flag, argument or selection the verb does not take. The message names
2327
- it, and a close spelling the verb does take when there is one;
2309
+ A flag, argument, selection or `VX_*` environment value the verb does
2310
+ not take. The message names it, and a close spelling the verb does take when there is one;
2328
2311
  `vx <verb> --help` lists every flag.
2329
2312
 
2330
2313
  #### `VX_E_UNKNOWN_COMMAND`
2331
2314
 
2332
2315
  No verb of that name here, core's or a declared plugin's. `vx --help`
2333
2316
  lists core's verbs. A plugin's verb exists once its plugin is in
2334
- `vx.workspace.ts` `plugins`; a task runs as `vx run <task>`.
2317
+ `.vx/workspace.ts` `plugins`; a task runs as `vx run <task>`.
2335
2318
 
2336
2319
  #### `VX_E_UNKNOWN_TASK`
2337
2320
 
@@ -2357,20 +2340,24 @@ the task once (`vx run <task>`); history is this checkout's own.
2357
2340
 
2358
2341
  #### `VX_E_CONFIG`
2359
2342
 
2360
- A `vx.config.*` or `vx.workspace.*` that does not load or is refused. The
2343
+ A `vx.config.*` or `.vx/workspace.*` that does not load or is refused. The
2361
2344
  message names the file and the field; fix that field
2362
2345
  (`vx docs <field>` prints its reference). `vx info --format json` lists
2363
- every config error at once.
2346
+ every config error at once. A project config's refusal is `vx lock`'s,
2347
+ not a run's: a run never evaluates one.
2364
2348
 
2365
2349
  #### `VX_E_CYCLE`
2366
2350
 
2367
2351
  The task graph has a cycle. The message names the loop; drop one of its
2368
2352
  `dependsOn` edges.
2353
+ `vx lock` refuses it, not a run: a run trusts the graph the lock holds.
2369
2354
 
2370
2355
  #### `VX_E_NO_WORKSPACE`
2371
2356
 
2372
- No workspace root above the working directory. Run vx inside the repo,
2373
- or create a `package.json` and run `vx init`.
2357
+ No `.vx/workspace.*` in the working directory or a parent, up to the git
2358
+ top-level (a file above the checkout never counts). The file marks the
2359
+ workspace root, so run `vx init` to create one. Outside git, one another
2360
+ user owns does not count (`security.md`); the message names it.
2374
2361
 
2375
2362
  #### `VX_E_CACHE_VERSION`
2376
2363
 
@@ -2387,7 +2374,7 @@ message is that plugin's or check's: do what it says.
2387
2374
  #### `VX_E_FS`
2388
2375
 
2389
2376
  The file system refused a write: permissions, a full disk. The message
2390
- names the path. Free space (`vx cache prune --max-size <size>` trims the
2377
+ names the path. Free space (`vx cache clean --max-size <size>` trims the
2391
2378
  cache) or grant the write, then re-run.
2392
2379
 
2393
2380
  #### `VX_E_FDS`
@@ -2416,12 +2403,22 @@ A task's `dependsOn` names a task or project that is not declared, or uses
2416
2403
  a form vx does not take (a bare wildcard, a negation, a pattern in
2417
2404
  `pkg#task`). The message names the task and the entry: declare the
2418
2405
  target, fix its spelling, or drop the entry.
2406
+ `vx lock` refuses it, not a run: a run trusts the graph the lock holds.
2407
+
2408
+ A `^task` with no edge source is refused too: ``<id>: ^build needs
2409
+ project edges; add pnpm()/bun()/npm()/yarn() or a plugin that returns
2410
+ `deps` ``. Core reads no edge from a manifest, so with no plugin
2411
+ returning `deps` every `^` would resolve to nothing and fold no
2412
+ upstream into the key. Declare the lockfile plugin in
2413
+ `.vx/workspace.ts` (or a plugin of your own that returns `deps`); a run
2414
+ refuses it as well when the plugin left the workspace after the lock.
2419
2415
 
2420
2416
  #### `VX_E_OUTPUT_OVERLAP`
2421
2417
 
2422
2418
  Two tasks declare overlapping outputs with no `dependsOn` edge between
2423
2419
  them, so whichever runs second deletes the other's files. Give each task
2424
2420
  its own output path, or make one depend on the other.
2421
+ `vx lock` refuses it, not a run: a run trusts the graph the lock holds.
2425
2422
 
2426
2423
  #### `VX_E_INPUT_IS_OUTPUT`
2427
2424
 
@@ -2430,6 +2427,13 @@ a run, so it would delete its sources) or another task's (its key must
2430
2427
  not read them; `dependsOn` already cascades). Exclude the outputs from
2431
2428
  the inputs with a `!` glob, as the message shows, or declare no outputs
2432
2429
  for a task that rewrites files in place.
2430
+ `vx lock` refuses it, not a run: a run trusts the graph the lock holds.
2431
+
2432
+ #### `VX_E_DEPENDENCY_RULE`
2433
+
2434
+ `vx lock` found a project depending on one a `run.rules.dependencies`
2435
+ rule denies or does not allow. A header counts the broken edges and
2436
+ each indented line under it names both projects and the rule. Drop the dependency, or change the project's tags or the rule.
2433
2437
 
2434
2438
  #### `VX_E_INTERNAL`
2435
2439
 
@@ -2449,14 +2453,13 @@ product verb to it under a notice.
2449
2453
 
2450
2454
  ## `vx show`
2451
2455
 
2452
- Introspect the workspace's **live resolved configs** — what a run
2453
- would see right now. Configs load through the same path a run uses,
2454
- plugin `config` and `project` stages included, so a package a plugin
2455
- gives tasks to (`turbo()` or `nx()` from `@vzn/vx-migrate`, a
2456
- temporary start toward native config) shows them; cached
2457
- evaluations are served from the local cache like a run's. `vx show`
2458
- never reads `vx-lock.json` (the lock is already the frozen JSON — open
2459
- it directly if you want the frozen view).
2456
+ Introspect the workspace's **resolved configs** — what a run would see
2457
+ right now. Configs load through the same path a run uses: from
2458
+ `.vx/projects.lock.json`, plugin `config` and `project` stages applied,
2459
+ so a package a plugin gives tasks to (`turbo()` or `nx()` from
2460
+ `@vzn/vx-migrate`, a temporary start toward native config) shows them.
2461
+ `vx show` evaluates no config: an edit shows after `vx lock`, and with
2462
+ no lock it refuses as a run does (`no lock: run \`vx lock\``).
2460
2463
 
2461
2464
  ```
2462
2465
  vx show # list every project
@@ -2551,7 +2554,7 @@ share `TMPDIR`: a `nix develop` shell sets its own.
2551
2554
  Every verb: a path vx must write that this user cannot (`EACCES`,
2552
2555
  `EPERM`, `EROFS` — a read-only checkout, another user's files) or that
2553
2556
  the disk has no room for (`ENOSPC`, `EDQUOT`) exits 1 with one line
2554
- naming the path, `vx: EACCES: permission denied, open '…/vx-lock.json'
2557
+ naming the path, `vx: EACCES: permission denied, open '…/.vx/projects.lock.json'
2555
2558
  — a path vx must write is not writable by this user` (or `— the disk
2556
2559
  that path is on is full`), never a stack. Inside a run the task's line
2557
2560
  says the same. A process out of file descriptors (`EMFILE`, `ENFILE`;
@@ -2562,11 +2565,10 @@ is out of file descriptors; raise the limit (ulimit -n 4096) and re-run`.
2562
2565
 
2563
2566
  Workspace doctor — one screen of facts for bug reports and sanity
2564
2567
  checks. The task count and the sandbox row's declared count come from
2565
- the same load a run uses, plugin stages included; a config that fails
2566
- to load counts as zero in both rather than failing the doctor, every
2567
- other project still counts as a run loads it, and the broken one is
2568
- named (`12 (34 tasks · 1 config did not load)`, then a `config errors`
2569
- row per config with the message `vx run` stops on):
2568
+ the same load a run uses: the lock, plugin stages included. `vx info`
2569
+ evaluates no config; with no lock, or one a run would refuse, both
2570
+ counts are zero and `projects lock` says `no` — a broken config is
2571
+ `vx lock`'s to report:
2570
2572
 
2571
2573
  ```
2572
2574
  $ vx info
@@ -2579,21 +2581,18 @@ projects: 12 (34 tasks)
2579
2581
  plugins: 2 — @vzn/vx-reapi (executor, cache); @vzn/vx-otel (telemetry)
2580
2582
  workers: 2 — cgroup CPU quota 2 of 8 cores
2581
2583
  memory: 13 GB usable — cgroup limit; the machine has 16 GB
2582
- cache dir: /work/repo/.vx/cache
2583
- cache store: /home/me/.vx/3f2a9c1e7b4d5a60/cache
2584
- cache versions: keys vx-cache-v42 · index schema v34
2584
+ cache dir: /work/repo/.vx/cache (8.2 MB, this workspace's run history)
2585
+ cache store: /home/me/.vx/3f2a9c1e7b4d5a60/cache (1.3 GB, shared by this repo's checkouts)
2586
+ cache versions: keys vx-cache-v43 · index schema v36
2585
2587
  cache entries: 42 (1.3 GB)
2586
- orphans: 3 artifacts (12 MB) the index does not know — `vx cache prune` reaps them
2588
+ orphans: 3 artifacts (12 MB) the index does not know — `vx cache clean` reaps them
2587
2589
  task runs (24h): 7 (5 cache hits)
2588
2590
  flaky tasks: 1 — web#test (3 of 11 runs failed on unchanged inputs)
2589
2591
  sandbox: available (9 tasks declare exec.sandbox)
2590
- vx-lock.json: yes
2592
+ projects lock: yes
2591
2593
  ```
2592
2594
 
2593
- - `git` shows `(not found)` when the binary is missing; a broken
2594
- project config contributes zero tasks instead of failing the
2595
- printout, and the `config errors` row (present only then) names it
2596
- with the loader's message — the same line `vx run` would stop on.
2595
+ - `git` shows `(not found)` when the binary is missing.
2597
2596
  - `git status cache`: vx runs ONE `git status` per run to find dirty
2598
2597
  and untracked files, and on a large tree that walk is the warm run's
2599
2598
  critical path. git's `core.fsmonitor` (a daemon that watches the
@@ -2613,13 +2612,13 @@ exec.sandbox and will fail` says it first: root inside a container
2613
2612
  unreported`: the sandbox still enforces, but a task that tolerates a
2614
2613
  denied read passes and caches with no word of it. The `--format json` fact is
2615
2614
  `sandbox.untraced`, the reason or `null`.
2616
- - `plugins` names every plugin `vx.workspace.*` declares and the seams
2615
+ - `plugins` names every plugin `.vx/workspace.*` declares and the seams
2617
2616
  each fills, in pipeline order (`config`, `discover`, `project`, `graph`, `key`,
2618
2617
  `fingerprint`, `schedule`, `admit`, `executor`, `cache`, `telemetry`,
2619
- `setup`, `commands`), or `none`. It reads the declarations: a plugin
2618
+ `output`, `setup`, `commands`), or `none`. It reads the declarations: a plugin
2620
2619
  that declines a task at run time still lists its seam here.
2621
2620
  - `workers` is the count a run defaults to and where it comes from:
2622
- `vx.workspace.ts`'s `concurrency` when set, else the cores this
2621
+ `.vx/workspace.ts`'s `run.concurrency` when set, else the cores this
2623
2622
  process may use — the CPU count, capped by the cgroup CPU quota a
2624
2623
  container runs under (`8 — the CPU count`, `2 — cgroup CPU quota 2 of
2625
2624
  8 cores`). `memory` is what a memory-packing policy budgets
@@ -2640,15 +2639,22 @@ unreported`: the sandbox still enforces, but a task that tolerates a
2640
2639
  the hits are a share of it: three `vx run` of two tasks are six. An
2641
2640
  invocation is what `vx last` calls a run; `vx last --list` counts
2642
2641
  those.
2643
- - `cache store` is where the entries and their artifacts live: the
2644
- store every workspace of this user shares (`docs/caching.md`), or
2645
- `none: the cache dir holds the entries` when the workspace names its
2646
- cache dir. Before a first run it is the store a run would open.
2642
+ - `cache dir` holds the index and this workspace's run history; its size
2643
+ is on disk. `cache store` is where the entries and their artifacts
2644
+ live: the store shared by this repo's checkouts (`docs/caching.md`),
2645
+ with its size on disk, or `none: the cache dir holds the entries` when
2646
+ the workspace names its cache dir. Before a first run it is the store a
2647
+ run would open. A bare `vx cache clean` frees both sizes.
2647
2648
  - `cache entries` counts the entries and their size; with a
2648
2649
  `cacheRetention.maxSize` it reads against it (`812 (3.0 GB of 10 GB,
2649
2650
  30%)`), and a `cache retention` row says what each run's end evicts.
2650
2651
  Paths under the home directory print as `~/…`; the JSON keeps them
2651
2652
  absolute.
2653
+ - `cache policy` is the policy a `vx run` takes when no flag names one,
2654
+ and where it comes from (`VX_CACHE_POLICY`, `.vx/workspace`'s `cache.policy`,
2655
+ or the default, every layer on, remote writes off off CI); `cache scope`
2656
+ is the same for the scope (`VX_CACHE_SCOPE`, `cache.scope`, which
2657
+ `github()` may set, or none: the default keys).
2652
2658
  - `cache versions` are the two constants a bug report needs and the
2653
2659
  reset notice names: the key prefix (`CACHE_VERSION`; a bump orphans
2654
2660
  every entry) and the index schema (`SCHEMA_VERSION`; a mismatch drops
@@ -2657,19 +2663,23 @@ unreported`: the sandbox still enforces, but a task that tolerates a
2657
2663
  save temps the index has no row for, older than an hour (what a
2658
2664
  `SCHEMA_VERSION` reset leaves behind; a fresh one is a save in
2659
2665
  flight). A task that asks for one's key again indexes it and hits;
2660
- the rest only `vx cache prune` reclaims, so the doctor says so.
2666
+ the rest only `vx cache clean` reclaims, so the doctor says so.
2661
2667
  - `--format json` prints the same facts as one typed object, for a
2662
- script or a bug-report template: `vx`, `bun`, `bunSupported` (false
2668
+ script or a bug-report template: `vx`, `vxPath` (the file that ran),
2669
+ `handedOffFrom` (the vx that handed the call to the project's own, else
2670
+ null), `bun`, `bunSupported` (false
2663
2671
  below Bun 1.4.0), `git` (null when not
2664
2672
  found), `gitStatusCache` (`{ fsmonitor, untrackedCache }`, null when
2665
2673
  git could not answer), `workspaceRoot`, `projects`, `tasks`,
2666
- `configErrors` (`[{ path, message }]`, the configs that did not load,
2667
- empty when all did), `plugins` (`[{ name, seams }]`), `workers` (`{ count, source, cores,
2674
+ `plugins` (`[{ name, seams }]`), `workers` (`{ count, source, cores,
2668
2675
  cpuQuota }`, the source one of `workspace` / `cgroup` / `cores`,
2669
2676
  `cpuQuota` in cores or null), `memory` (`{ usableBytes, totalBytes,
2670
- cgroupLimitBytes }`, the limit null when none binds), `cacheDir`, `cacheStore`
2671
- (null when the cache dir holds the entries), `cacheVersion`,
2672
- `schemaVersion`, `cacheEntries`, `cacheBytes`, `cacheRetention`
2677
+ cgroupLimitBytes }`, the limit null when none binds), `cacheDir`, `cacheDirBytes`
2678
+ (on disk: the index and run history), `cacheStore` (null when the cache
2679
+ dir holds the entries), `cacheStoreBytes` (on disk, null with no store), `cacheVersion`,
2680
+ `schemaVersion`, `cacheEntries`, `cacheBytes`, `settings` (every
2681
+ setting by path, `{ value, source }`, `source` one of `env` /
2682
+ `workspace` / `default`, `value` null for the default), `cacheRetention`
2673
2683
  (`{ olderThan, maxSize, maxBytes }`, each only when set, null when none is declared),
2674
2684
  `orphans` (`{ artifacts, bytes }`, always present), `runs24h`, `hits24h` (task
2675
2685
  runs, as the row), `restored24h` (of those hits, the ones that restored
@@ -2680,10 +2690,10 @@ declared, untraced }`, `declared` the count of tasks with `exec.sandbox`,
2680
2690
  pretty rows render this object;
2681
2691
  there is no second source.
2682
2692
  - `--cache-dir <path>` reads the cache a run with the same flag uses, as
2683
- `vx why`, `vx last` and `vx cache prune` do.
2693
+ `vx why`, `vx last` and `vx cache clean` do.
2684
2694
 
2685
- Exit codes: `0` the facts printed, a config that did not load included;
2686
- `1` no workspace here or above, or a parse error.
2695
+ Exit codes: `0` the facts printed, with no lock included; `1` no
2696
+ workspace here or above, or a parse error.
2687
2697
 
2688
2698
  ## `vx why`
2689
2699
 
@@ -2721,7 +2731,7 @@ cache (nothing re-ran), it re-executed on the same key, or it recorded
2721
2731
  no cache outcome at all, in which case vx says so instead of guessing.
2722
2732
  A re-execution names its cause when the index shows one: the previous
2723
2733
  run on the key failed and saved nothing, the run did not read the cache
2724
- (`--force`, or a `--cache` without read), no entry for the key was
2734
+ (`--force`, or a `--cache-policy` without read), no entry for the key was
2725
2735
  there when it ran (pruned or evicted), the previous run was not saved
2726
2736
  because it ran beside a failed task (a task run past a failed dependency
2727
2737
  under `--continue` is never cached; said ahead of a prune, and as
@@ -2767,6 +2777,18 @@ which names the `vx why` to run next; a `config` line names the
2767
2777
  `vx show` that prints the task's config now. `--format json` carries
2768
2778
  the walk as `roots` (`chain`, `entries`).
2769
2779
 
2780
+ A moved input file whose last change fell inside a recorded task's run
2781
+ window names that task: a task that writes a file another key reads (a
2782
+ build rewriting a translation file in another package) re-keys that key
2783
+ on every run, so the miss is right and only moving the write ends it.
2784
+ `--format json` carries these as `writers` (`taskId`, `file`,
2785
+ `writers`); an edit made outside a run names none.
2786
+
2787
+ ```
2788
+ changed during a run:
2789
+ packages/lib/src/gen.txt while app#build ran
2790
+ ```
2791
+
2770
2792
  A hit's line is `cache-hit · key …` (or `cache-hit-remote`): the status
2771
2793
  names the hit and its tier, so only an executed run carries the word.
2772
2794
  A skipped task never ran and derived no key: its line is `skipped · no key`.
@@ -2774,11 +2796,10 @@ A row's kind is what the key folded: `file` (an input file, by blob
2774
2796
  id), `env` (a declared variable, by digest), `runtime` and
2775
2797
  `ws-runtime` (a declared command's output, `inputs.runtime` and
2776
2798
  `inputs.workspaceRuntime`), `forward` (the argv forwarded after `--`),
2777
- `package` (the project's own `package.json`), `workspace` (the
2778
- fingerprint: the lockfile and the other root manifests), `config` (the
2779
- evaluated task config), `upstream` (a dependency's input key — a
2780
- lockfile change moves it too, so that row rides with the
2781
- fingerprint's), `plugin` (a `key` plugin's material, by name) and
2799
+ `config` (the evaluated task config), `upstream` (a dependency's input
2800
+ key — a lockfile change moves it too, so that row rides with the
2801
+ lockfile plugin's), `plugin` (a `key` plugin's material, by name; a
2802
+ lockfile plugin's dependency closure) and
2782
2803
  `format` (the key format vx itself folds: it moves only with an
2783
2804
  upgrade, and re-keys every task once, X-142).
2784
2805
  The component-level rows come from the `entry_inputs` input
@@ -2791,39 +2812,13 @@ task with no `cache` block derives a key too — it is what dependents
2791
2812
  fold — but saves no entry, so for it the verb can only report the key
2792
2813
  change and says so.
2793
2814
  `--format json` emits one machine-readable object (`{ taskId, runId,
2794
- why, diff, roots }`); when the task's recorded runs predate run ids, `{ taskId,
2815
+ why, diff, roots, writers }`); when the task's recorded runs predate run ids, `{ taskId,
2795
2816
  why, diff, explanation }`, `why` and `diff` null and `explanation` the
2796
2817
  latest cache entry's key.
2797
2818
 
2798
2819
  Exit codes: `0` the task's run explained; `1` no recorded run of the
2799
2820
  task, a `--run` with no row for it, or a parse error.
2800
2821
 
2801
- ## `vx prune`
2802
-
2803
- ```
2804
- vx prune <project...> [--out-dir <dir>] [--docker] [--production]
2805
- ```
2806
-
2807
- A plugin verb of `@vzn/vx-lockfile`: declaring `pnpm()`, `bun()`,
2808
- `npm()` or `yarn()` adds it. It copies the named projects, their
2809
- transitive workspace dependencies, the root manifests (workspace lists
2810
- rewritten to the subset), the vx configs and each lockfile pruned to what
2811
- the subset installs into `--out-dir` (default `out/`), so a Docker build
2812
- installs it with a frozen lockfile; `--docker` splits `json/` (the
2813
- install layer) from `full/`; `--production` leaves out a workspace
2814
- package only a dev dependency reaches and strikes it from the kept
2815
- manifests and lockfiles. `turbo prune`. Reference:
2816
- `packages/vx-lockfile/README.md` § `vx prune`. Without the plugin, typing
2817
- `vx prune` says where it comes from and exits 1. History: core verb
2818
- 2026-08-25, `@vzn/vx-prune` 2026-09-10, removed 2026-09-11 (it copied the
2819
- lockfile unpruned), back in `@vzn/vx-lockfile` 2026-10-03.
2820
-
2821
- ## `vx stats`
2822
-
2823
- Removed (H-19). It was an alias of `vx info`, which absorbed it, and
2824
- warned on every use. Typing `vx stats` points at `vx info` and exits 1 —
2825
- unless a declared plugin claims the verb through the `commands` seam.
2826
-
2827
2822
  ## `vx last`
2828
2823
 
2829
2824
  Replay a recorded run's summary from the local history — no
@@ -2833,7 +2828,7 @@ surface. A run a Ctrl-C (or SIGTERM, SIGHUP) stopped records nothing,
2833
2828
  so `vx last` still shows the run before it.
2834
2829
 
2835
2830
  ```
2836
- vx last [RUNID] [--list[=N]] [--failed] [--log <task>] [--format pretty|json] [--cache-dir <path>]
2831
+ vx last [RUNID] [--list[=N]] [--failed] [--log <task>] [--stats [<task>] [--branch <name>]] [--format pretty|json] [--cache-dir <path>]
2837
2832
  ```
2838
2833
 
2839
2834
  Bare `vx last` replays the most recent run: a header (verdict, command,
@@ -2875,7 +2870,12 @@ passed, so a replayed failure that is already fixed says so. A
2875
2870
  sandboxed task's row adds `sandboxFix`, the `exec.sandbox.allow`
2876
2871
  entries (`read`, `write`, `network`, `systemInfo`) that would have
2877
2872
  granted its denials, the same grants the terminal prints as `fix:`;
2878
- `withheld` lists the credential paths it denied and left out. They live in `<cacheDir>/failures/<runId>.json`, the
2873
+ `withheld` lists the credential paths it denied and left out (a `.env` file, or a store under home the sandbox denies, `security.md`), and, for
2874
+ a cached task, `inputs` the reads of its project no `cache.inputs.files`
2875
+ glob matches (the terminal's `and, if the output depends on them, …`
2876
+ line). A task
2877
+ failed by `inputStability.severity: 'error'` adds `inputMoved`, the
2878
+ input that changed during the run. They live in `<cacheDir>/failures/<runId>.json`, the
2879
2879
  newest 50 failed runs; past those, or when the write was refused, a
2880
2880
  failed row reads `output: ''` and `locations: []`. `--log <task>`
2881
2881
  prints one task's output instead of the summary: in the latest run that
@@ -2886,7 +2886,23 @@ or in the run that saved it. An uncached task that passed, or a skip,
2886
2886
  kept none: stdout is empty and stderr says so, exit 0. With
2887
2887
  `--format json` it is `{ runId, taskId, status, source, output }`,
2888
2888
  `source` `'failure'`, `'cache'` or `null`. A task no run recorded is
2889
- refused, exit 1; `--log` does not combine with `--list` or `--failed`. `--list --format json`
2889
+ refused, exit 1; `--log` does not combine with `--list` or `--failed`.
2890
+ `vx last app#build --log` reads the same as `--log app#build`: a `--log`
2891
+ with no value takes the positional as its task.
2892
+ `--stats [<task>]` prints per-task numbers over the last 50 runs, the
2893
+ window the scheduler and `--dry` learn from: runs, p50 (the forecast a
2894
+ run shows: the median of the task's last five timings on the branch,
2895
+ else on any branch), p99 of the executed successes, pass and hit rate, failure mode (`stable`,
2896
+ `flaky-recoverable`, `flaky-fatal`), the largest peak RSS and CPU
2897
+ parallelism (a hit counts what its producing run used), one table row
2898
+ per task. A task id narrows to that task, a task name to it in every
2899
+ project; one the window has no run of is refused, exit 1. The branch is
2900
+ `--branch`, else `VX_RUN_BRANCH`, else git's, then the CI provider's, as
2901
+ a run's. With `--format json` it is `{ window, branch, stats }`, each row `{ taskId, runs,
2902
+ p50DurationMs, p99DurationMs, successRate, hitRate, failureMode,
2903
+ maxPeakRssBytes, maxCpuParallelism }`, `null` where nothing was
2904
+ measured. `--stats` does not combine with `--list`, `--failed` or
2905
+ `--log`; `--branch` combines only with `--stats`. `--list --format json`
2890
2906
  an array of the same `invocation` objects, newest first. An unknown run id fails
2891
2907
  loud and points at `--list`. Before any run, every form says so
2892
2908
  (`vx last: no recorded runs yet — run something first`, exit 1; `--list`
@@ -2900,14 +2916,14 @@ line of its own without them, X-140). The
2900
2916
  replayed `$ vx run …` or `$ vx watch …` line and that one keep each
2901
2917
  argument shell-quoted (`-- 'a b'`), so either pastes back as it ran (X-49).
2902
2918
 
2903
- `vx why`, `vx last`, `vx info` and `vx cache prune` all read the cache
2919
+ `vx why`, `vx last`, `vx info` and `vx cache clean` all read the cache
2904
2920
  a run wrote, so each takes `--cache-dir <path>` with `vx run`'s rules
2905
2921
  (cwd-relative, absolute used as-is): a run that wrote its history
2906
2922
  elsewhere is replayed, explained, reported on and pruned there. Without
2907
- the flag they open the workspace's cache (`defineWorkspace({ cacheDir })`
2923
+ the flag they open the workspace's cache (`defineWorkspace({ cache: { dir } })`
2908
2924
  or `.vx/cache`). A `--cache-dir` that is not there is refused by name
2909
2925
  (`--cache-dir .vx/cahce: no such directory`). In a workspace that never
2910
- ran, `vx why`, `vx last`, `vx info`, `vx show` and `vx cache prune`
2926
+ ran, `vx why`, `vx last`, `vx info`, `vx show` and `vx cache clean`
2911
2927
  (dry or not) read it as empty and create nothing (item 900, E-7, E-38).
2912
2928
 
2913
2929
  Exit codes: `0` the run (or the list) printed, `--list` with no runs
@@ -2949,10 +2965,10 @@ of each verb, read from the same help text `vx <verb> --help` prints:
2949
2965
  the verb's own Usage line, and for `run` and `watch` the run option
2950
2966
  lines, less the ones `watch` refuses. So a flag cannot be documented
2951
2967
  and not completed, nor completed and then refused: a flag another
2952
- verb's line names in passing (`vx lock --check` beside `--frozen`) is
2968
+ verb's line names in passing (`--check` in a line about `vx lock`) is
2953
2969
  not one. After a flag with a fixed value set the script offers that
2954
2970
  set: `--format` pretty or json, `--output-logs` full, errors-only,
2955
- hash-only or none, `--download` all, toplevel or none — each value one
2971
+ hash-only or none, `--cache-download` all, toplevel or none — each value one
2956
2972
  the flag's parser takes (`tests/completions.test.ts` holds it). A plugin verb completes `--help` only. Task and project
2957
2973
  names are not completed (they are the workspace's, and a completion
2958
2974
  that evaluates configs on every Tab is the wrong price). The zsh script
@@ -2967,7 +2983,7 @@ Exit codes: `0` the script printed; `1` an unknown shell or flag.
2967
2983
 
2968
2984
  ## Plugin commands
2969
2985
 
2970
- A plugin declared in `vx.workspace.ts` can add verbs:
2986
+ A plugin declared in `.vx/workspace.ts` can add verbs:
2971
2987
 
2972
2988
  ```ts
2973
2989
  import { definePlugin, type VxPlugin } from '@vzn/vx'
@@ -2990,18 +3006,15 @@ export function mcp(): VxPlugin {
2990
3006
  The plugin's name is its package name, which `definePlugin` reads from
2991
3007
  `import.meta`; a plain object is refused when the workspace loads.
2992
3008
 
2993
- (`@vzn/vx-mcp` ships exactly this: declare `mcp()` and `vx mcp` serves
2994
- six read-only tools to AI agents — four over the cache database, one
2995
- over the resolved task catalog, one the workspace doctor's facts.
2996
- `@vzn/vx-schedule-history` adds `vx history`: what it learned per task
2997
- and the reservation its `admit` hook packs.) The dispatcher tries core's verbs
3009
+ (`@vzn/vx-schedule-history` ships exactly this: `vx history`, what it
3010
+ learned per task and the reservation its `admit` hook packs.) The dispatcher tries core's verbs
2998
3011
  first and consults plugins only for a word core does not know, loading
2999
3012
  the workspace config from the cwd to find them (outside a workspace the
3000
3013
  verb is simply unknown). A plugin verb that names a core verb, or one
3001
3014
  plugins of two packages both declare, is refused when the workspace
3002
3015
  loads — such a verb could never run, or would hide the other plugin's.
3003
- Plugins of one package are one owner (`bun()` and `pnpm()` both carry
3004
- `prune`): the first declared runs. A plugin verb's
3016
+ Plugins of one package are one owner (two of its plugins may carry the
3017
+ same verb): the first declared runs. A plugin verb's
3005
3018
  return value is the exit code, an integer 0–255 (anything else — nothing,
3006
3019
  a fraction, 256, which the OS would keep as 0 — fails naming the plugin
3007
3020
  and the verb), and a thrown `UserError` prints as
@@ -3013,33 +3026,62 @@ An unknown verb is answered with what this workspace knows: the "did you
3013
3026
  mean" set is core's verbs AND the ones its plugins declare (the lookup
3014
3027
  that just failed already loaded them, so it costs nothing), and when
3015
3028
  nothing is close enough to guess, a second line says where a verb can
3016
- come from — the verbs declared here, or that `vx.workspace.ts` is what
3029
+ come from — the verbs declared here, or that `.vx/workspace.ts` is what
3017
3030
  would declare one, or that there is no workspace here at all. Core still
3018
3031
  names no package: it lists what the workspace itself declares.
3019
3032
 
3020
3033
  ## Environment variables vx reads
3021
3034
 
3022
- The variables core reads. Each is read where it applies, per call, so a
3023
- value set for one invocation is that invocation's. A plugin's own (the
3024
- REAPI endpoints, the GitHub token) are in its README; what a TASK sees
3025
- is `exec.env` in `docs/schema.md`, and the two markers vx sets on every
3026
- task are the last row.
3027
-
3028
- | Var | Value | Default | Effect |
3029
- | --------------------------------- | ----------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3030
- | `VX_TIMING` | any non-empty | off | Print the stage table to stderr after a run, a `--dry`, or each `vx watch` cycle (`docs/modules/timing.md`). |
3031
- | `VX_TASK_TIMEOUT` | positive integer (ms) | none | The default timeout for tasks without their own `exec.timeout`, one rung below `--timeout` / `RunOptions.timeout` and one above the workspace `timeout`. Empty, non-integer or non-positive is ignored; a value past the largest timer (~24.8 days) is clamped to it, never refused. |
3032
- | `VX_CACHE_DIR` | a path | none | The workspace's cache dir, relative to the workspace root, below `--cache-dir` and `vx.workspace`'s `cacheDir`. A named cache dir holds the whole cache and shares nothing; unset, entries live in the user's shared store (`docs/caching.md`). Empty is ignored; whitespace alone is refused, as `cacheDir` is. |
3033
- | `VX_CACHE` | a `--cache` spec | all axes on | The run's cache policy when no `--cache` is passed (`local:rw,remote:r`); `--no-cache` and `--force` still apply on top. Empty is ignored; a bad spec is refused before any task. |
3034
- | `VX_CACHE_SCOPE` | a `cacheScope` value | none | The run's `cacheScope` (`trusted`, `read-only`, `pr-123`), above `vx.workspace`'s and `github()`'s; `--cache-scope` beats it. Empty is ignored; a value `cacheScope` would refuse is refused before any task. |
3035
- | `VX_INPUT_STABILITY` | `immutable`, `mutable`, `unchecked` | `immutable` | The `inputStability.strategy` of every task, over its own and the workspace's, for the run: which writes to an input during the command withhold the save. `mutable` lets a rewrite to the bytes the key folded through, `unchecked` skips the check. Any other value is refused before a task runs (`docs/schema.md`). |
3036
- | `VX_INPUT_STABILITY_SEVERITY` | `warn`, `error` | `warn` | The `inputStability.severity` of every task, over its own and the workspace's, for the run: `error` fails a task whose save a moved input withheld, so the run fails. Any other value is refused before a task runs. |
3037
- | `VX_KILL_GRACE_MS` | positive integer (ms) | 2000 | The SIGTERM → SIGKILL grace a child that ignores SIGTERM gets: on a timeout, on a signal, and at the end-of-run shutdown of persistent tasks. Out of range falls back to the default. |
3038
- | `VX_READY_NOTICE_MS` | positive integer (ms) | 10000 | How long a persistent task may take to match `readyWhen` before vx says once what it waits for. Out of range falls back to the default. |
3039
- | `VX_TEARDOWN_TIMEOUT_MS` | integer (ms) | 3000 | The bound on one plugin's end-of-run flush or teardown, and on its `telemetry()` setup, so a third party's I/O cannot hold the run. Out of range falls back to the default, never clamps: a bound of 24.8 days is no bound. |
3040
- | `VX_CONFIG_WORKER_TIMEOUT_MS` | integer (ms) | 30000 | How long one `vx.config.ts` evaluation may take, in process or in its worker, before the load fails naming the config (a real evaluation is ~10 ms). A synchronous loop on a first load holds the thread the deadline needs, so it hangs until killed. Out of range falls back to the default. |
3041
- | `VX_WATCH_POLL` | any non-empty | off | `vx watch` polls every 250 ms from the start instead of probing the OS watcher (§ `vx watch` › How changes are seen). |
3042
- | `VX_RUN_WORKSPACE`, `VX_RUN_TASK` | set by vx | — | Set on every task's environment (the workspace root; `project#task`). Read back by a `vx run` a task starts: one in the same workspace is refused, since a nested run is invisible to the outer graph and a loop back to its own task forks without bound (`docs/schema.md` § `env`). |
3035
+ Every setting has a variable derived from its path (`run.killGrace` →
3036
+ `VX_RUN_KILL_GRACE`), beating `.vx/workspace` and beaten by the flag; the
3037
+ table in `docs/schema.md` § Settings gives each value's form and
3038
+ default. Each is checked once before any verb runs: an empty one is
3039
+ unset, a value its kind does not take is refused naming the form, and a
3040
+ variable vx does not read is ignored. A
3041
+ plugin's own (the REAPI endpoints, the GitHub token) are in its README;
3042
+ what a TASK sees is `exec.env` in `docs/schema.md`.
3043
+
3044
+ <!-- variables table: generated by tests/settings.test.ts -->
3045
+
3046
+ | Variable | Setting | What it does |
3047
+ | ----------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
3048
+ | `VX_RUN_CONCURRENCY` | [`run.concurrency`](schema.md#settings) | Tasks at once: a positive integer, or a share of the cores (`'50%'`). |
3049
+ | `VX_RUN_SCHEDULE` | [`run.schedule`](schema.md#settings) | Which ready task starts first when there are more than workers. |
3050
+ | `VX_RUN_AFFECTED_BASE` | [`run.affectedBase`](schema.md#settings) | The git ref a bare `--affected` compares with. |
3051
+ | `VX_RUN_BRANCH` | [`run.branch`](schema.md#settings) | The branch a run records in its history and forecasts from. Flag and env only: a committed branch would be wrong on every other branch. |
3052
+ | `VX_RUN_TIMEOUT` | [`run.timeout`](schema.md#settings) | A task's time limit, for tasks without their own `exec.timeout` (`'30m'`). |
3053
+ | `VX_RUN_RETRIES` | [`run.retries`](schema.md#settings) | Re-runs of a failed task, for tasks without their own `exec.retries`. |
3054
+ | `VX_RUN_CONTINUE` | [`run.continue`](schema.md#settings) | What a failed task takes down: `never` stops the run, `deps-ok` skips its dependents, `always` runs them too. |
3055
+ | `VX_RUN_OUTPUT_LOGS` | [`run.outputLogs`](schema.md#settings) | Which task output the run prints. Chosen before the workspace loads, so flag and env only. |
3056
+ | `VX_RUN_KILL_GRACE` | [`run.killGrace`](schema.md#settings) | How long a task that ignores SIGTERM gets before SIGKILL. |
3057
+ | `VX_RUN_TEARDOWN_TIMEOUT` | [`run.teardownTimeout`](schema.md#settings) | The bound on each plugin's end-of-run flush and teardown. |
3058
+ | `VX_RUN_READY_NOTICE` | [`run.readyNotice`](schema.md#settings) | How long a persistent task may take to match `readyWhen` before vx says it waits. |
3059
+ | `VX_RUN_RULES_EXCLUSIVE_OUTPUTS` | [`run.rules.exclusiveOutputs`](schema.md#settings) | Refuse two tasks whose outputs overlap even when an edge orders them. |
3060
+ | `VX_RUN_RULES_UPFRONT_KEYS` | [`run.rules.upfrontKeys`](schema.md#settings) | Refuse a task whose inputs can match another task's outputs. |
3061
+ | `VX_CACHE_DIR` | [`cache.dir`](schema.md#settings) | Where the cache lives, relative to the workspace root; named, it holds the whole cache. |
3062
+ | `VX_CACHE_RETENTION_MAX_SIZE` | [`cache.retention.maxSize`](schema.md#settings) | Evict least-recently-used entries at each run's end until the cache is under this (`'10GB'`). |
3063
+ | `VX_CACHE_RETENTION_OLDER_THAN` | [`cache.retention.olderThan`](schema.md#settings) | Evict entries unused for this long at each run's end (`'30d'`). |
3064
+ | `VX_CACHE_POLICY` | [`cache.policy`](schema.md#settings) | Which layers a run reads and writes (`'local:rw,remote:r'`); `--no-cache` and `--force` apply on top. |
3065
+ | `VX_CACHE_SCOPE` | [`cache.scope`](schema.md#settings) | Where cache writes land (`'pr-123'`): reads try the default key, then the scope's. |
3066
+ | `VX_CACHE_DOWNLOAD` | [`cache.download`](schema.md#settings) | Which remote hits land on disk: every one, only the requested tasks, or none. |
3067
+ | `VX_CACHE_INPUT_STABILITY_STRATEGY` | [`cache.inputStability.strategy`](schema.md#settings) | What a write to an input during the command means for the save, for tasks without their own. |
3068
+ | `VX_CACHE_INPUT_STABILITY_SEVERITY` | [`cache.inputStability.severity`](schema.md#settings) | Whether a skipped save warns or fails the task, for tasks without their own. |
3069
+ | `VX_CACHE_OUTPUTS_CLEAN` | [`cache.outputs.clean`](schema.md#settings) | Remove a task's declared outputs before it runs on a miss, for tasks without their own. |
3070
+ | `VX_LOCK_REVIEW_FILES` | [`lock.reviewFiles`](schema.md#settings) | `vx lock` writes each project's resolved config, pretty, to `vx.lock.json` beside its `vx.config`, for review; vx never reads it. |
3071
+ | `VX_WATCH_POLL` | [`watch.poll`](schema.md#settings) | Poll for changes from the start instead of the OS watcher. |
3072
+ | `VX_CONFIG_WORKER_TIMEOUT` | [`config.workerTimeout`](schema.md#settings) | How long one config evaluation may take. |
3073
+ | `VX_DEBUG_TIMING` | [`debug.timing`](schema.md#settings) | Print the run's stage timings to stderr. |
3074
+ | `VX_DEBUG_DUMPABLE` | [`debug.dumpable`](schema.md#settings) | Linux: keep vx dumpable, so strace or a debugger can read its memory and `/proc/<pid>/fd`, and an unsandboxed task its env. |
3075
+ | `VX_HANDED_OFF` | — | Set by a global vx that hands the call to the project's own (§ Which vx runs): the path of the vx that handed off, so the receiver does not hand off again. |
3076
+ | `VX_RUN_WORKSPACE`, `VX_RUN_TASK` | — | Set by vx on every task (below); a `vx run` a task starts reads them back. |
3077
+
3078
+ <!-- /variables table -->
3079
+
3080
+ vx sets two variables on every task's environment: `VX_RUN_WORKSPACE`
3081
+ (the workspace root) and `VX_RUN_TASK` (`project#task`). A `vx run` a
3082
+ task starts reads them back: one in the same workspace is refused, since
3083
+ a nested run is invisible to the outer graph and a loop back to its own
3084
+ task forks without bound (`docs/schema.md` § `env`).
3043
3085
 
3044
3086
  Colors are the two conventions in § Output format › Colors (`NO_COLOR`,
3045
3087
  `FORCE_COLOR`). A truthy `CI` picks the CI output flow and
@@ -3109,7 +3151,9 @@ marked with a cyan `▸` after `┌─`/`└─`, and its close reads `running`
3109
3151
  died on its own before the run stopped it.
3110
3152
 
3111
3153
  There is **no top-of-run banner** — the run context lives in the
3112
- footer. A broad run looks like:
3154
+ footer. The task log opens with one blank line before its first row,
3155
+ on a terminal or not; a note vx prints before it is not part of it.
3156
+ A broad run looks like:
3113
3157
 
3114
3158
  ```
3115
3159
  ⇢ 4ms success local @vzn/vx#format-check
@@ -3117,30 +3161,29 @@ footer. A broad run looks like:
3117
3161
 
3118
3162
  ─ vx 0.0.0 ───────────────────────────────────────────────────
3119
3163
  projects ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱
3120
- 1 in run · 3 total
3164
+ 1 affected · 3 total
3121
3165
  tasks ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰
3122
3166
  2 success · 2 total
3123
3167
  cache ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰
3124
3168
  1 miss · 1 local
3125
3169
 
3126
3170
  info 8 workers · local cache
3127
- time 5.34s · max 5.20s · avg 5.20s · min 5.20s
3128
- result 2 tasks · 1 cached (50%) · 900ms saved · 5.34s
3171
+ time 5.34s · 900ms saved
3129
3172
  ```
3130
3173
 
3131
3174
  The three bars are meters: `projects` is what the run covered against
3132
3175
  the workspace, `tasks` is failed / success / skipped, `cache` is miss /
3133
3176
  no-cache / up-to-date / local / remote (a skipped task rides it too, so
3134
- the two legends sum alike). The `time` spread counts executed tasks
3135
- only — a hit's restore time never enters it — which is why one executed
3136
- task reads as its own max, avg and min. The `result` row is the run in
3137
- one line, last: tasks, cached (every hit, local or remote, over every
3138
- task with a cache that ran or hit; a skipped task asked no cache) and the wall time — `3 tasks · all cached · 40ms`
3139
- when nothing that could hit ran, with `N failed` after the count on a
3140
- red run. A task with no `cache` block, or any task of a run whose
3141
- `--cache` reads and writes nothing (`--no-cache`), could never hit, so it
3142
- is counted apart (`1 task · 1 no-cache · 37ms` for `vx run dev`), as
3143
- `--dry` labels it. A test renders this run and
3177
+ the two bars fill alike; its count is on the tasks legend only). The
3178
+ `time` row is the wall time, then what the cache saved (the hits' stored
3179
+ exec times, never the restore), then the spread over executed tasks
3180
+ only — a hit's restore time never enters it. The footer never says a
3181
+ fact twice: a spread value already on the row is left out, so one
3182
+ executed task shows no spread, and there is no closing `result` row
3183
+ repeating the tasks, cache and time rows. A task with no `cache` block,
3184
+ or any task of a run whose `--cache-policy` reads and writes nothing
3185
+ (`--no-cache`), could never hit, so it is counted apart (`1 no-cache`
3186
+ for `vx run dev`), as `--dry` labels it. A test renders this run and
3144
3187
  checks it against this page, byte for byte.
3145
3188
 
3146
3189
  Group tasks emit no framed block by design (they aren't real tasks);
@@ -3151,8 +3194,8 @@ What a run has to say about one task rides that task's row; what it
3151
3194
  says about the run (a server that died, outputs left remote, a file
3152
3195
  `--summarize` / `--profile` wrote, a history, upload, plugin or sandbox
3153
3196
  warning) prints above the footer: it prints once the run's closing work
3154
- is done, the `VX_TIMING=1` table included. Cache housekeeping
3155
- (`cacheRetention`) prints nothing. Only a kept server's own output
3197
+ is done, the `VX_DEBUG_TIMING=1` table included. Cache housekeeping
3198
+ (`cache.retention`) prints nothing. Only a kept server's own output
3156
3199
  follows it.
3157
3200
 
3158
3201
  **Skipped rows.** A red run lists every task that never started as a
@@ -3179,9 +3222,9 @@ what it reached first (`not run`). Neither reached the cache:
3179
3222
  ◌ not run web#test
3180
3223
  ```
3181
3224
 
3182
- The tasks legend names them outside the total, which history and
3183
- telemetry share: `2 success · 2 total · not counted: 1 aborted, 2 not
3184
- run`. A framed task (focused or `full`) that
3225
+ The tasks legend counts them in the total, each state its own segment
3226
+ of the bar: `2 success · 1 aborted · 2 not run · 5 total`. The total is
3227
+ always last on its line. A framed task (focused or `full`) that
3185
3228
  printed while it stopped shows its frame instead of a row.
3186
3229
 
3187
3230
  **Flaky rows.** A task the run just proved nondeterministic says so
@@ -3195,7 +3238,7 @@ run history alone, no service:
3195
3238
 
3196
3239
  A task is flaky when its exact cache key FAILED AFTER IT HAD PASSED
3197
3240
  (this run counted; a cache hit is a pass, it replayed one), or when it
3198
- needed a retry (`exec.retries` / `--retry`) this run. A failure on a
3241
+ needed a retry (`exec.retries` / `--retries`) this run. A failure on a
3199
3242
  key that never passed is a break and carries no note — a changed input
3200
3243
  that fails is what a red run usually means — and the first pass after
3201
3244
  only failures is a recovery (a missing tool installed), not a flake.
@@ -3241,7 +3284,7 @@ Only a run's own output (`vx run`, `vx watch`) is painted, and the
3241
3284
  help on a terminal: headings bold, `vx <verb>` and each flag or example
3242
3285
  in cyan; `NO_COLOR` or `FORCE_COLOR=0` turns it off
3243
3286
  (`tests/help-colors.test.ts`). Every other verb (`show`, `info`, `why`,
3244
- `last`, a `--dry` plan, `--graph`, `cache prune`), and the help off a
3287
+ `last`, a `--dry` plan, `--graph`, `cache clean`), and the help off a
3245
3288
  terminal, prints plain text whatever `FORCE_COLOR` says, so a script
3246
3289
  that forces colour for a run's log still parses them
3247
3290
  (`tests/cli-colors-e2e.test.ts`).
@@ -3249,8 +3292,9 @@ that forces colour for a run's log still parses them
3249
3292
  ## Remote cache (plugin-driven)
3250
3293
 
3251
3294
  Core ships **no remote-cache wire client** — the remote cache is a
3252
- plugin concern. A `cache` plugin composes core's `LayeredCache` over a
3253
- wire client; `@vzn/vx-reapi` provides one for any Bazel REAPI server
3295
+ plugin concern. A `cache` plugin returns a wire client (a
3296
+ `RemoteCacheLayer`) and core layers it over the local cache;
3297
+ `@vzn/vx-reapi` provides one for any Bazel REAPI server
3254
3298
  (ActionCache + CAS).
3255
3299
 
3256
3300
  Reads try local first, then remote (hydrating local on remote hit),
@@ -3264,7 +3308,7 @@ implement core's `RemoteCacheLayer` interface in a plugin's `cache`
3264
3308
  capability — the recipe lives in the plugins guide. Embedders
3265
3309
  holding a wire client can inject it per-run via
3266
3310
  `RunOptions.remoteCache` (explicit injection wins over the plugin
3267
- consult). The retired `VX_REMOTE_CACHE_*` env vars are gone.
3311
+ consult).
3268
3312
 
3269
3313
  ## Run analytics
3270
3314