@vzn/vx 0.0.635 → 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 +47 -19
  2. package/docs/caching.md +222 -210
  3. package/docs/cli.md +699 -664
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +31 -32
  7. package/docs/schema.md +418 -202
  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 +13 -4
  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 -61
  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 +123 -144
  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 +191 -71
  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 +25 -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 +192 -70
  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 +43 -28
  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 +114 -73
  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 +127 -88
  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 +251 -241
  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 +22 -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,32 +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-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. |
484
- | `--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. |
485
- | `--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. |
486
- | `--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. |
487
- | `--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`. |
488
- | `--output-logs <mode>` | value | flow-derived | `full` \| `errors-only` \| `hash-only` \| `none` — explicit output override. See § `--output-logs`. `--output-logs=<mode>` form too. |
489
- | `--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. |
490
- | `--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. |
491
- | `--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. |
492
- | `--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. |
493
- | `--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. |
494
- | `--summarize[=<path>]` | optional value | off | Write per-run JSON to `<cacheDir>/runs/<run_id>.json` (or the explicit path). |
495
- | `--profile[=<path>]` | optional value | off (`profile.json` when set) | Write Chrome-trace JSON of the run's wallclock spans. |
496
- | `--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. |
497
- | `--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). |
498
- | `--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. |
499
503
 
500
504
  Mutual exclusion:
501
505
 
@@ -550,11 +554,13 @@ shell variable), never a path or task id. Use the `=` form for a literal
550
554
  leading dash.
551
555
 
552
556
  **Numeric flags take a plain decimal integer.** `--concurrency`,
553
- `--timeout`, `--retry` and `--verbosity` reject hex (`0x10`), exponent
554
- (`1e3`), fractional (`2.7`), signed (`+4`) and space-padded forms, plus
555
- anything past `2^53` (it would parse to a number you did not type).
556
- These all used to be silently reinterpreted — `--concurrency 0x10` ran
557
- 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.
558
564
 
559
565
  An empty `=` value means "no value" on four flags, which then act like
560
566
  their bare forms: `--profile=` writes `profile.json`, `--summarize=`
@@ -562,25 +568,34 @@ writes `<cacheDir>/runs/<run_id>.json`, `--graph=` prints to stdout and
562
568
  `--affected=` uses the default base. Every other flag refuses an empty
563
569
  value: `--dry=`, `--report=`, `--continue=` and
564
570
  `--exclude-dependencies=` as well as the ones with no bare form
565
- (`--retry=`, `--timeout=`, `--cache-dir=`, `--filter=`, `--cache=`).
571
+ (`--retries=`, `--timeout=`, `--cache-dir=`, `--filter=`, `--cache-policy=`).
566
572
 
567
- #### Cache control: `--cache`, `--no-cache`, `--force`
573
+ #### Cache control: `--cache-policy`, `--no-cache`, `--force`
568
574
 
569
575
  The cache has four independent axes — **localRead**, **localWrite**,
570
576
  **remoteRead**, **remoteWrite** — and the three flags above resolve
571
577
  them in this precedence order:
572
578
 
573
579
  1. Start with every axis **on** (the default).
574
- 2. Apply each `--cache=<spec>` segment (the base).
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`).
575
583
  3. If `--no-cache` was passed, force **all four off**.
576
584
  4. If `--force` was passed, force **both reads off** (writes stay
577
- 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.
578
590
 
579
591
  A spec that names a remote axis (`remote:r`, `remote:w`, `remote:rw`)
580
592
  in a workspace whose plugins supply no remote layer gets one status
581
593
  line saying so — the axes are inert without a layer to serve them, and
582
594
  a CI job that believes it is filling a shared cache should be told.
583
595
 
596
+ Whatever the axes, a lookup asks the local cache first, then each
597
+ remote in the order the plugins declare them.
598
+
584
599
  So `--no-cache` always wins over `--force`. The common cases:
585
600
 
586
601
  - `--no-cache` → nothing reads, nothing writes, and declared output
@@ -591,29 +606,30 @@ So `--no-cache` always wins over `--force`. The common cases:
591
606
  before each task so the saved snapshot is clean. This is the "rebuild
592
607
  and refresh the cache" flag.
593
608
 
594
- `--cache=<spec>` is a comma-separated list of `<layer>:<flags>`
609
+ `--cache-policy=<spec>` is a comma-separated list of `<layer>:<flags>`
595
610
  segments. `layer` is `local` or `remote`; `flags` is any subset of `r`
596
611
  (read) and `w` (write), order-independent and possibly empty. A
597
612
  **mentioned** layer is set EXACTLY to its flags; an **unmentioned**
598
- layer keeps its current value. Both `--cache=<spec>` and the space form
599
- `--cache <spec>` are accepted.
613
+ layer keeps its current value. Both `--cache-policy=<spec>` and the space form
614
+ `--cache-policy <spec>` are accepted.
600
615
 
601
- | Spec | Effect |
602
- | --------------------------- | ------------------------------------------------------- |
603
- | `--cache=local:rw,remote:r` | remote read-only (won't upload); local full |
604
- | `--cache=local:r` | local read-only; remote untouched (still full) |
605
- | `--cache=remote:` | remote fully off; local untouched |
606
- | `--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 |
607
622
 
608
623
  `local:` means "don't serve hits out of the pre-existing local cache" —
609
624
  a remote hit is still delivered (the artifact has to land on disk to be
610
625
  extracted), it just never short-circuits the remote read.
611
626
 
612
627
  Combine with `--force` for "re-execute and refresh only the remote":
613
- `--cache=local: --force` (local off, reads off, remote write-only).
628
+ `--cache-policy=local: --force` (local off, reads off, remote write-only).
614
629
 
615
630
  Invalid layers/flags are a parse error
616
- (`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.
617
633
 
618
634
  ### Output
619
635
 
@@ -729,7 +745,7 @@ tracks the run live. Top to bottom:
729
745
  persistent tasks it depends on, until it — or, with several, the
730
746
  first of them — exits (one kept only as a dependency that exits 0,
731
747
  a daemon that forked and returned, does not end it); the rest still up are then torn
732
- down (SIGTERM, `VX_KILL_GRACE_MS`, SIGKILL), one status line names
748
+ down (SIGTERM, `run.killGrace`, SIGKILL), one status line names
733
749
  the task and its code (`vx: app#dev exited with code 1; stopping 1
734
750
  other persistent task`), and a non-zero exit makes the run exit 1
735
751
  and is what `vx last` records.
@@ -742,15 +758,18 @@ other persistent task`), and a non-zero exit makes the run exit 1
742
758
  each one's id (`app#dev │ Local: http://localhost:5173`), except
743
759
  under `--output-logs errors-only`, `hash-only` or `none`; a single
744
760
  requested server in the focused flow streams raw from its frame.
745
- 3. **Worker rows** — one per worker slot (sized
761
+ 3. **Worker rows** — one per worker slot that has been busy (at most
746
762
  `min(concurrency, 10)`), no glyph and no spinner: the live ticking
747
763
  elapsed time leads (` 568ms running <id>`). A task stays in
748
- its row for its whole life; idle rows hold their place dimmed, so
749
- 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
750
766
  says what waits on it: `critical path` (yellow) on the running task
751
767
  the longest unfinished chain waits on, by the same p50s as the
752
768
  forecast below and only while two or more run, and a dim
753
- `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`).
754
773
  4. **The live summary section** — the SAME meters the final footer
755
774
  prints (`tasks` + `cache` bars with legends, `time`), filling in as
756
775
  the run progresses, under a bare `vx` wordmark rule. A second in,
@@ -760,7 +779,10 @@ other persistent task`), and a non-zero exit makes the run exit 1
760
779
  workers, a running task's spent time taken off its own. It says
761
780
  nothing when history knows none of what is left, or once a running
762
781
  task is 25% past its p50. A run that ends inside the second never
763
- 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.
764
786
 
765
787
  In a terminal known to read them, the region also sends zero-width
766
788
  escapes: tab or taskbar progress (OSC 9;4: Ghostty, iTerm2 3.6+,
@@ -781,7 +803,8 @@ text is the task's output unchanged; a successful task's frame is never
781
803
  linked.
782
804
 
783
805
  The region is redrawn in place (cursor-up + clear; not a TUI — no
784
- 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
785
808
  focused flow it only lives while dependencies run; it disappears for
786
809
  good the moment the requested task starts streaming.
787
810
 
@@ -848,7 +871,7 @@ down with it:
848
871
  The mode is the local scheduler's: a task a plugin executor runs
849
872
  elsewhere is one dispatch, failed or not, like any other.
850
873
 
851
- ### `--download <mode>`
874
+ ### `--cache-download <mode>`
852
875
 
853
876
  Where a **remotely-executed** task's outputs land. `all` (default)
854
877
  downloads every task's outputs to this machine as it completes —
@@ -862,7 +885,7 @@ bytes"). A CI job that only wants the verdict moves no output bytes at
862
885
  all.
863
886
 
864
887
  Locally-executed tasks always write in place and ignore the flag, and
865
- `exec.remote: 'only'` still means never — `--download` cannot override
888
+ `exec.remote: 'only'` still means never — `--cache-download` cannot override
866
889
  it in either direction. **It never affects a cache key**: transfer
867
890
  tuning cannot change what a command produces.
868
891
 
@@ -974,8 +997,9 @@ $ vx run build lint --affected=main --dry
974
997
  ```
975
998
 
976
999
  `kind` is `input` (a changed `file` is a declared input), `project` (its
977
- project changed and the task reads it whole: an uncached task, or no
978
- 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),
979
1003
  `package` (a `^` edge passes a changed `project` with no such task),
980
1004
  `named` (`pkg#task` beside a bare task) or `selected` (another filter
981
1005
  selected its project). `via` is the `dependsOn` chain, nearest first, to
@@ -1087,7 +1111,7 @@ outcome bucket or `total`, but they make the run red, so they are
1087
1111
  listed separately and counted as `summary.aborted`.
1088
1112
 
1089
1113
  **`noCache: true`** marks a task no cache answered for: it declares no
1090
- `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`
1091
1115
  read and wrote nothing (`--no-cache`), so a hit rate should leave it out
1092
1116
  of the denominator. The key is present only when true; every other row is
1093
1117
  unchanged. Its `hash` is still set: dependents fold it.
@@ -1190,14 +1214,14 @@ or a timeout's reason, `failed (timed out, exit 143)`, a persistent
1190
1214
  task's `never ready: …`, or a sandboxed task's violation count, or
1191
1215
  `skipped`, naming what blocked it: `skipped (blocked by lib#build)`);
1192
1216
  `Cache` is its provenance (`miss` / `no-cache` for a task no cache
1193
- 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 —
1194
1218
  which never consulted one / `local` / `remote` / `up-to-date` /
1195
1219
  `—`). A stopped run (a Ctrl-C teardown) reads `## vx run — interrupted`,
1196
1220
  and its tasks keep the terminal's words: one the signal killed is
1197
1221
  `aborted` with its time so far, one the stop reached before it ran is
1198
- `not run` with no time. Neither joins the counts; the header names them
1199
- after the total, `not counted: 1 aborted, 1 not run`, so a red report
1200
- 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
1201
1225
  work, and the header's counts match the terminal summary and
1202
1226
  `--summarize` exactly.
1203
1227
 
@@ -1319,7 +1343,7 @@ sandbox run as usual, and the failure's dependents follow `--continue`.
1319
1343
  ## `vx watch`
1320
1344
 
1321
1345
  ```
1322
- vx watch [OPTIONS] TASK [-- forwarded-args...]
1346
+ vx watch [OPTIONS] [--poll] TASK [-- forwarded-args...]
1323
1347
  ```
1324
1348
 
1325
1349
  Run the named task, then re-run it on every filesystem change in the
@@ -1345,11 +1369,11 @@ run...` precedes it.
1345
1369
  `dependsOn` edges included — is watched recursively, less any project
1346
1370
  nested inside it, with a config or not (a root project's key leaves a nested
1347
1371
  project's files out, so an edit there is no cycle; X-42, X-57). The workspace root is
1348
- watched (non-recursively) for lockfile / `pnpm-workspace.yaml`
1349
- 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
1350
1374
  shapes a run (plugins, `config` stage, concurrency) without being any
1351
1375
  task's input; the cycle after it re-evaluates the file. A `.gitignore`
1352
- 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
1353
1377
  gitignore-aware, so it changes what keys read (X-136). A file a
1354
1378
  config imports by relative path from outside the watched projects (a
1355
1379
  shared preset) is watched too, and its edit is a cycle that re-reads
@@ -1357,20 +1381,20 @@ run...` precedes it.
1357
1381
  config's own edit does (WD-14). A file the workspace config imports is loaded once per
1358
1382
  process, so its edit is named with the restart it needs rather than
1359
1383
  run stale (item 949). An import whose file is gone stays on the list
1360
- by the name it would have, so its return is a cycle (X-133). The directory
1361
- each `<dir>/*` package glob names (`packages/` for `packages/*`) is
1362
- watched for members coming and going: a package added while the watch
1363
- runs is a cycle that runs it, and its directory is watched from before
1364
- 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
1365
- root `package.json`'s `workspaces`, `pnpm-workspace.yaml`) re-reads
1366
- the set, so a glob added there is watched from the cycle it triggers
1367
- (item 1018). A base, or a directory a config imports from, removed and made
1368
- again (a checkout that restores `packages/`) is heard from its nearest directory that exists, and a
1369
- watch whose directory was replaced is armed again (an OS watch holds
1370
- the deleted one; `watch-recreated-dirs.test.ts`). Under `--filter` or `--affected` the scope is the one
1371
- resolved at start, and a new package joins it only as a dependency of
1372
- it; a glob of another shape (`apps/**`) has no such directory, so a
1373
- 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`,
1374
1398
  `outputs.workspaceFiles`; a plugin's `project` stage counts, as in
1375
1399
  a run) never trigger a re-run — a cycle that writes `dist/` is not
1376
1400
  an edit, nor is the `dist` directory itself coming and going (a
@@ -1425,7 +1449,8 @@ run...` precedes it.
1425
1449
  at the root, a `coverage/` or `.turbo/` tree, an editor's scratch
1426
1450
  file are dropped before the trigger — so `vx watch … > build.log`
1427
1451
  inside the repo settles instead of feeding itself a cycle per line
1428
- 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
1429
1454
  printed only after every watcher has reported a probe file written
1430
1455
  under it (`.vx-watch-probe`, re-written on a short backoff until its
1431
1456
  event arrives, then removed): on macOS a directory watcher can return
@@ -1437,10 +1462,14 @@ run...` precedes it.
1437
1462
  watcher whose probe is not heard within 2 s is replaced by a poller
1438
1463
  that checks every 250 ms, with the notice `vx watch: <dir>: no OS
1439
1464
  watch events within 2000 ms; polling every 250 ms instead`.
1440
- 3. **On change.** The triggering path is logged
1441
- (`vx watch: <project> <relpath>; re-running...`): the first changed
1442
- path that still exists, so an editor's temporary file renamed away
1443
- 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
1444
1473
  orchestrator is invoked again with the same options, save one: the
1445
1474
  `--affected` diff is read once, for the initial run. A later cycle
1446
1475
  runs the requested task in every project of the scope fixed at start,
@@ -1455,15 +1484,15 @@ watch events within 2000 ms; polling every 250 ms instead`.
1455
1484
 
1456
1485
  Always ignored (no re-trigger):
1457
1486
 
1458
- - `node_modules/`, `.git/`, `.vx/` anywhere in the path.
1487
+ - `node_modules/`, `.git/`, `.vx/cache/` anywhere in the path.
1459
1488
  - A path git ignores (`git check-ignore`, asked once per debounce
1460
1489
  window, never per event): invisible to every cache key — inputs are
1461
1490
  tracked + untracked-not-ignored — so a cycle it started could change
1462
1491
  nothing. A tracked file that matches a pattern is not ignored, by
1463
1492
  git's own rule; outside a repository nothing is.
1464
1493
  - Files ending in `.tsbuildinfo` or `~` (editor swap files).
1465
- - The run's **resolved cache directory**, wherever it is. `.vx/` covers the
1466
- 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
1467
1496
  anywhere; watching it would let vx's own cache writes trigger the next
1468
1497
  cycle, which writes again — a loop that never settles.
1469
1498
 
@@ -1492,19 +1521,20 @@ It samples each file's later clock of mtime and ctime, so a replacement
1492
1521
  that carries the old file's mtime (`cp -p`, `rsync -a`, a `mv`) is an
1493
1522
  edit to it as it is to the OS watcher.
1494
1523
 
1495
- ### Workspace fingerprint changes
1496
-
1497
- Edits to a lockfile (`pnpm-lock.yaml`, `bun.lock`, …) or
1498
- `pnpm-workspace.yaml` at the root invalidate every task's cache key
1499
- via the [workspace fingerprint](./caching.md#cache-key-derivation).
1500
- Watch mode hears those because it watches the workspace root
1501
- (non-recursively). A lockfile a plugin claims still triggers a cycle,
1502
- and so does any other root file a plugin claims (`turbo.json` under
1503
- `turbo()`, item 961), a plugin added to the workspace config mid-watch
1504
- included from the cycle that loads it; the keys then decide which
1505
- projects actually re-run. Under `vx watch --frozen` every cycle's configs come from
1506
- `vx-lock.json`, so a config edit alone changes no command, and a re-lock
1507
- (`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).
1508
1538
 
1509
1539
  ### Constraints
1510
1540
 
@@ -1539,7 +1569,7 @@ re-runs itself and what it depends on.
1539
1569
  - `0` — clean Ctrl+C / SIGTERM exit. A cycle in flight is torn down
1540
1570
  first — its children, and the dev server held between cycles, get the
1541
1571
  signal watch received (a Ctrl-C as SIGINT; a SIGTERM or SIGHUP as SIGTERM),
1542
- `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
1543
1573
  once it has returned, so a CI cancellation never orphans a task.
1544
1574
  Also `0`, with no watch, when `--affected` selects nothing: watch
1545
1575
  prints `vx watch: nothing affected since <ref>` and exits as `vx run`
@@ -1558,32 +1588,34 @@ the watch loop — a failed cycle just prints the framed FAILED block
1558
1588
  and waits for the next change. This matches `turbo watch` / `nx
1559
1589
  watch`.
1560
1590
 
1561
- ## `vx cache prune`
1591
+ ## `vx cache clean`
1562
1592
 
1563
1593
  Evict old or oversized cache entries. Operates on the entries and
1564
1594
  `<hash>.tar.zst` artifacts of the store the workspace's index records
1565
1595
  (`~/.vx/<id>/cache`, shared by every checkout of the repository), or of
1566
- `<cacheDir>` itself when the workspace names one (`cacheDir`,
1596
+ `<cacheDir>` itself when the workspace names one (`cache.dir`,
1567
1597
  `VX_CACHE_DIR`, `--cache-dir`).
1568
1598
 
1569
- `prune` is the only `vx cache` subcommand: the statistics other runners
1599
+ `clean` is the only `vx cache` subcommand: the statistics other runners
1570
1600
  put under a `cache` verb — the directory, the entry count, the size —
1571
1601
  are part of [`vx info`](#vx-info), and `vx cache stats`, `list`, `ls`,
1572
- `clean`, `gc`, `purge` and their neighbours say so (`list` also names
1573
- `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".
1574
1604
 
1575
1605
  ```
1576
- vx cache prune --older-than <duration> # Drop entries last used before now - duration (up to 1h slack).
1577
- vx cache prune --max-size <size> # After age-based pruning, evict LRU until under <size>.
1578
- vx cache prune ... --dry-run # Say what either policy would reap; delete nothing.
1579
- vx cache prune ... --format json # { dryRun, evicted, bytesFreed, orphans, orphanBytes }
1580
- 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.
1581
1613
  ```
1582
1614
 
1583
- At least one of `--older-than` / `--max-size` is required. Both may
1584
- 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
1585
1617
  total is still over the size cap. The same policy runs unattended at
1586
- the end of every run when `vx.workspace.ts` declares
1618
+ the end of every run when `.vx/workspace.ts` declares
1587
1619
  `cacheRetention: { olderThan, maxSize }` (same spellings; see
1588
1620
  [schema](./schema.md)).
1589
1621
 
@@ -1599,7 +1631,7 @@ vx version sharing the store may still use it. A temp goes once it is an
1599
1631
  hour old; nothing younger than an hour is taken. Only the names vx writes are taken:
1600
1632
  `<hash>` is the key's 16 lowercase hex digits and the temp suffix is
1601
1633
  the one a save makes, so a `release.tar.zst` beside the index in a
1602
- `cacheDir` you share is never touched (item 968). The converse, an
1634
+ `cache.dir` you share is never touched (item 968). The converse, an
1603
1635
  index row whose artifact is gone (deleted by hand), is dropped by the
1604
1636
  same prune, and its bytes count toward neither `--max-size` nor the
1605
1637
  evicted total: they are on no disk, and counting them evicted real
@@ -1607,7 +1639,7 @@ entries to make room for them (item 975). A row used within the hour
1607
1639
  is kept until a later prune, but its bytes are left out all the same
1608
1640
  (item 1081). The sweep runs on every prune, under
1609
1641
  either flag, and reports separately from the policy's evictions. A
1610
- 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
1611
1643
  once an hour even when nothing the index holds is due (the policy sums
1612
1644
  index rows, so orphans alone never make it due).
1613
1645
 
@@ -1624,22 +1656,20 @@ write `10G`, or `10B` when bytes really are the unit.
1624
1656
 
1625
1657
  **A zero bound is rejected.** `--max-size 0` and `--older-than 0d` would
1626
1658
  evict every entry in the cache, which is far more often a
1627
- computed-to-zero retention than an intent — and no flag combination
1628
- expresses "wipe the cache" (running with neither flag is an error, not a
1629
- full prune). Delete the cache store `vx info` names when that is really
1630
- 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.
1631
1661
 
1632
1662
  ```
1633
- $ vx cache prune --older-than 30d
1663
+ $ vx cache clean --older-than 30d
1634
1664
  Pruned 42 entries (1.3 GB freed)
1635
1665
 
1636
- $ vx cache prune --older-than 7d --max-size 500M
1666
+ $ vx cache clean --older-than 7d --max-size 500M
1637
1667
  Pruned 18 entries (320 MB freed)
1638
1668
 
1639
- $ vx cache prune --older-than 30d # after a SCHEMA_VERSION bump
1669
+ $ vx cache clean --older-than 30d # after a SCHEMA_VERSION bump
1640
1670
  Pruned 0 entries (0 B freed), reaped 42 orphaned artifacts (1.3 GB)
1641
1671
 
1642
- $ vx cache prune --older-than 30d --dry-run
1672
+ $ vx cache clean --older-than 30d --dry-run
1643
1673
  Would prune 42 entries (1.3 GB), would reap 3 orphaned artifacts (12 MB)
1644
1674
  ```
1645
1675
 
@@ -1668,49 +1698,128 @@ Exit codes:
1668
1698
  nothing is created.
1669
1699
  - `1` — parse error, missing policy, or workspace-discovery error.
1670
1700
 
1671
- `vx cache prune` resolves the workspace root from cwd and honors a
1672
- `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
1673
1703
  directory a run would use. `--cache-dir <path>` (cwd-relative, as on
1674
1704
  `vx run`) names another one, so a run that wrote elsewhere can be
1675
1705
  pruned there.
1676
1706
 
1677
- ## `--frozen` (run flag)
1678
-
1679
- `vx run ... --frozen` loads configs from the committed `vx-lock.json`
1680
- instead of evaluating them — CI reproducibility mode. Plain `vx run`
1681
- always evaluates live (a byte hash can't see a config's import
1682
- closure, so silently consuming the lock locally would risk stale
1683
- freezes). `--frozen` errors only when no lock exists or a project
1684
- is missing from it — it performs NO staleness checks of its own:
1685
- run `vx lock --check` first in the pipeline; that audit re-evaluates
1686
- 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.
1687
1758
 
1688
1759
  ## `vx lock`
1689
1760
 
1690
- Freeze every project's **resolved** config into `vx-lock.json` at the
1691
- workspace root. Configs are programs; `vx lock` evaluates them in the
1692
- current environment and stores the post-evaluation objects plus a
1693
- content hash of each config file. "Every project" is the set a run
1694
- sees, a plugin's `discover` stage included (X-143).
1695
-
1696
- ```
1697
- vx lock # Evaluate all vx.config.* now; write vx-lock.json.
1698
- 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.
1699
1784
  vx lock --check --format json # The audit as {upToDate, audited, notAudited, problems}.
1700
1785
  ```
1701
1786
 
1702
- Plain runs ALWAYS evaluate live — the lock's existence changes
1703
- nothing. Only `vx run --frozen` consumes it: configs come from the
1704
- 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
1705
1807
  semantics: env reads in a config keep their lock-time values; a
1706
- project absent from the lock, a locked project whose config file is
1707
- gone (X-144) or a missing lock is a hard error).
1708
-
1709
- `--check` is the audit: it reports changed config files via the
1710
- stored hashes AND re-evaluates every config in the current
1711
- environment, `Bun.deepEquals`-comparing against the frozen objects —
1712
- catching eval-time env and import-closure drift that byte hashes
1713
- 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 …`.
1714
1823
  `--format json` prints the audit as one document
1715
1824
  (`schemas/lock.json`, exit code unchanged; a missing lock is one of
1716
1825
  its `problems`); it answers `--check` only.
@@ -1720,18 +1829,18 @@ Full design: `docs/design/config-lock-2026-06.md`. A lock written by a
1720
1829
  A project with no `vx.config.*` — its tasks from a plugin's `project`
1721
1830
  stage (`turbo()` from `@vzn/vx-migrate`), or none at all — has nothing
1722
1831
  to freeze: the lock records nothing for it, `--check` does not audit
1723
- it, and `--frozen` still loads its tasks live. Both verbs say how many
1724
- such projects the workspace has (`locked 0 project configs →
1725
- vx-lock.json (2 projects have no vx.config; their tasks are never
1726
- frozen)`), so an empty lock on a plugin-only workspace never reads like
1727
- 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.
1728
1837
 
1729
1838
  The lock is committed, so `vx lock` refuses to write one that holds a
1730
1839
  secret: a config that evaluated to the value of a secret-named variable
1731
1840
  (or one a task lists in `exec.env.secret`), say
1732
1841
  `` `--token ${process.env.API_TOKEN}` ``. It names each place
1733
1842
  (`a: tasks.deploy.exec.command holds $API_TOKEN`) and writes nothing.
1734
- Masking it instead would freeze a `***` that `--frozen` runs. Let the
1843
+ Masking it instead would freeze a `***` that every run runs. Let the
1735
1844
  shell expand it: `$API_TOKEN` in the command, the name in
1736
1845
  `exec.env.passThrough` (L-42).
1737
1846
 
@@ -1822,8 +1931,8 @@ One-time setup, per package, on npmjs.com → package → Settings →
1822
1931
  Trusted Publisher → GitHub Actions: owner `vznjs`, repository `vx`,
1823
1932
  workflow `npm.yml`, environment left blank. Do this for `@vzn/vx`,
1824
1933
  `@vzn/vx-darwin-x64`, `@vzn/vx-darwin-arm64`, `@vzn/vx-linux-x64`,
1825
- `@vzn/vx-linux-arm64` and the seven plugins: `@vzn/vx-ci`,
1826
- `@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`,
1827
1936
  `@vzn/vx-reapi` and `@vzn/vx-schedule-history`.
1828
1937
 
1829
1938
  Each platform package carries `LICENSE` and `THIRD_PARTY_NOTICES.txt`:
@@ -1852,308 +1961,173 @@ direct publishing (the `E401 token is invalid` that stopped v0.0.17).
1852
1961
  Every `uses:` in both workflows is pinned to a commit SHA with the
1853
1962
  version in a comment; bump the SHA and the comment together.
1854
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
+
1855
2000
  ## `vx upgrade`
1856
2001
 
1857
- Self-update the compiled binary in place: asks the GitHub release API
1858
- for this platform's asset and the SHA-256 digest it publishes,
1859
- downloads the asset, verifies the digest, and atomically replaces the
1860
- running executable (`vx upgrade TAG` pins a specific release; default
1861
- latest; a second tag is refused, not dropped). A download that does not match the digest replaces nothing —
1862
- `the download did not match the release's SHA-256 … nothing replaced` —
1863
- and a release that publishes no digest for the asset is refused before
1864
- the download. The digest comes from the same API as the asset, so it
1865
- proves the transfer, not who built the bytes: every release binary also
1866
- carries a Sigstore-signed build-provenance attestation, checked with
1867
- `gh attestation verify vx-<target> --repo vznjs/vx`. Named
1868
- `upgrade` per CLI convention (`bun upgrade`, `deno upgrade`). Refuses
1869
- when running from source — use `git pull` — and when the binary is
1870
- npm's: an npm install runs the platform package's compiled binary
1871
- under `node_modules`, and a rename over that file lasts until the next
1872
- `npm install` puts the version npm knows back, so `vx upgrade` there
1873
- says `this vx was installed by npm … Update with: npm install -g
1874
- @vzn/vx@latest`. A host it cannot reach (no route, a proxy that is
1875
- down) is one line — `could not reach api.github.com to read the
1876
- release (…) — check the network or the proxy and re-run` — never a
1877
- stack; so is a release document cut after the headers arrived or not
1878
- JSON (a captive portal's page served with a 200): `could not read the
1879
- release from api.github.com (…) — nothing replaced; …`, and an asset
1880
- download cut the same way: `could not download the release asset from
1881
- github.com (…) — nothing replaced; …`. A binary whose directory this
1882
- 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
1883
2032
  download: `cannot write to <dir> (EACCES), where this vx lives — nothing
1884
- downloaded; re-run as a user who can (sudo vx upgrade), …`. The new binary
1885
- keeps the old one's mode (and, as root, its owner), and must answer
1886
- `--version` before the upgrade reports it installed: one that does not
1887
- start on this machine — or does not answer within 10 s — is swapped back
1888
- for the previous vx, and the command exits 1 saying so (item 1097). On the version it already is
1889
- it says `already at <version>` and downloads nothing; a second tag is
1890
- refused rather than ignored; and GitHub's hourly API limit for an
1891
- unauthenticated address is named, with its reset time, instead of a
1892
- bare `(403)` (item 1098).
1893
-
1894
- Exit codes: `0` the new binary installed, or already at that version;
1895
- `1` anything refused or swapped back: from source, from npm, a host it
1896
- cannot reach, a directory it cannot write, a digest that does not match,
1897
- 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.
1898
2075
 
1899
2076
  ## `vx init`
1900
2077
 
1901
- In a Turbo or Nx repo (a `turbo.json`, `turbo.jsonc` or `nx.json` at
1902
- the root) it runs `@vzn/vx-migrate`, so `vx init` is the one command
1903
- (owner, 2026-10-08): the installed one, else this vx's version through
1904
- `bun x` (`BUN_BE_BUN=1` makes the compiled binary that runtime). It asks a
1905
- terminal native (a `vx.config.ts` per package, the default) or keep;
1906
- `--native` or `--keep` answer it, and without a terminal it is native.
1907
- `--dry`, `--format`, `--force` and `--mjs` pass through.
1908
-
1909
- `vx init --dry --format json` prints the plan as one JSON document
1910
- (`schemas/init.json`), nothing written: `files` (each path and its
1911
- whole text), `kept`, `replaced`, `todos` (a reason and its tasks),
1912
- `notes` and `next`. In a Turbo or Nx repo it is `@vzn/vx-migrate`'s
1913
- plan. `--format json` without `--dry`, or with `--plugin`, is refused
1914
- (`VX_E_USAGE`).
1915
- Run again after the native migration (a workspace file that declares
1916
- neither `turbo()` nor `nx()`, and project configs), it says vx is already
1917
- set up and prints the next step, exit 0, without starting
1918
- `@vzn/vx-migrate`; `--force` regenerates the files.
1919
-
1920
- `vx init --keep` writes `vx.workspace.ts` declaring `turbo()` or `nx()`
1921
- from `@vzn/vx-migrate` and nothing else: those read the repo's own
1922
- config live, so no task is copied — a temporary start until
1923
- `vx init --native` writes native config. `--native` or `--keep` in any
1924
- other repo is refused. The `next:` line is one command:
1925
- install what the file imports and is missing, with the manager the
1926
- lockfile names (at the workspace root: pnpm's `-w`, Yarn 1's `-W`,
1927
- which Yarn Berry lacks), then run the config's `build` (else its first task).
1928
- An existing workspace file is kept and named unless it already
1929
- declares the plugin; `--force` replaces it. When the repo shows a remote
1930
- cache — turbo.json's `remoteCache`, a `.turbo/config.json` naming a
1931
- team or token (`turbo link` writes it), or a line setting `TURBO_TOKEN` /
1932
- `NX_SELF_HOSTED_REMOTE_CACHE_SERVER` (`NAME:` or `NAME=`, not a comment)
1933
- in `.github/workflows/*`, `.gitlab-ci.yml` or `.circleci/config.yml`,
1934
- and turbo.json does not say `remoteCache.enabled: false` — it
1935
- declares `turboCache()` / `nxCache()` beside the runner and names the
1936
- file that showed it (a kept file lacking it is told to add it); the
1937
- plugin is inert where its variable is unset. An nx.json that connects
1938
- Nx Cloud (`nxCloudId`, `nxCloudAccessToken`, the `nx-cloud` runner) is
1939
- named instead: vx does not speak its wire, so runs cache locally.
1940
-
1941
- Anywhere else it scaffolds a workspace that comes from nowhere: one `vx.config.ts` per
1942
- package from its `package.json` scripts, plus a `vx.workspace.ts` of
1943
- `{ plugins: [] }`: running and caching here are the floor, so it
1944
- declares no executor or cache, and the file carries no comment (it is
1945
- the user's). `@vzn/vx-migrate` takes the
1946
- same `--dry` / `--force` flags but reads a runner's config (turbo, nx);
1947
- package.json scripts are `init`'s. A workspace with no scripts at
1948
- all still gets the workspace file, a printed example config, and the
1949
- next command to run. A root
1950
- `package.json` with no `workspaces` field is single-project mode, and
1951
- when `packages/*/package.json` files sit below it unreached, both
1952
- `init` and a run that finds no config say so instead ("package.json
1953
- declares no workspaces … Add "workspaces": ["packages/*"] to
1954
- package.json and re-run") rather than "no scripts" or "run vx init". A
1955
- workspace whose globs match no `package.json` gets a note naming them
1956
- (M-42); a nameless member, or two sharing a name, is matched and
1957
- named on its own line instead (M-46). When such skipped scripts are all there
1958
- is, the report opens `no package.json scripts became tasks.` rather than
1959
- saying there are none (M-59). When `pnpm-workspace.yaml` lists the
1960
- members and the root `package.json`'s `workspaces` lists other globs,
1961
- the note names the unread list (M-51): pnpm reads only the yaml, while
1962
- bun, npm and yarn read `package.json`. With
1963
- no `package.json` here or above, `init` says to create one (`bun init` or
1964
- `npm init -y`) first. Every
1965
- generated config is typed for the editor through
1966
- `import type { ProjectConfig } from '@vzn/vx/config'` and `satisfies
1967
- ProjectConfig` — a type-only import Bun erases, so the file loads in a
1968
- workspace that runs the `vx` binary without the package installed. The
1969
- workspace file takes the same form (`satisfies WorkspaceConfig`). A
1970
- runtime `import { defineWorkspace } from '@vzn/vx'` costs nothing extra
1971
- since 2026-09-10: the running `vx` serves its own core to that
1972
- specifier (bin.ts registers the alias), so a config or a plugin package
1973
- never loads a second copy, and a workspace that runs the binary needs
1974
- no `@vzn/vx` installed for it — the type-only form is still what the
1975
- scaffold writes, because it types the same with no import to resolve
1976
- in an editor without the package. A workspace that imports a PLUGIN
1977
- package at runtime without having installed it is told so, and to
1978
- install its dependencies. `--mjs` writes the same objects as `vx.config.mjs` and
1979
- `vx.workspace.mjs`, with no type import and no `satisfies`: a package whose
1980
- own `tsconfig` includes every `.ts` under it compiles a `vx.config.ts`
1981
- into its dist (TanStack/query's `tsc --build`, 2026-09-11), and an
1982
- `.mjs` is outside that include. `@vzn/vx-migrate` takes the same flag.
1983
- A file it writes that `.gitignore` drops (vueuse ignores
1984
- `packages/**/*.mjs`) gets a `note:` naming it and the `!vx.config.mjs`
1985
- line that keeps it, since a commit would leave it behind unnoticed.
1986
-
1987
- Each script becomes a task with its command verbatim. `build` gets
1988
- `dependsOn: ['^build']` (a `build` that only delegates, `pnpm run
1989
- compile`, is a group, and the edge goes on the script that does the
1990
- work) and **no cache block** — under a
1991
- `TODO(vx-migrate)` showing the block to add with the package's real
1992
- inputs and outputs, and the same block commented as the task's first
1993
- line: set the real globs and delete the `// `. A task without a cache block always runs; a block
1994
- with EMPTY outputs is not "uncached" but a no-output task that hits on
1995
- unchanged inputs and skips the build with nothing to restore (a deleted
1996
- `dist` stays deleted under a green `up-to-date` run — what `init`
1997
- generated until 2026-09-04), and a guessed `dist/**` would restore the
1998
- wrong tree for every package that writes elsewhere. The block the TODO
1999
- shows names `dist/**`, or the default output of the framework the command
2000
- runs: `.next/**` minus `!.next/cache/**` for `next build`, `.output/**`
2001
- for Nuxt, `build/**` for Remix, React Router, Create React App and
2002
- Docusaurus, `public/**` for Gatsby, `storybook-static/**` for Storybook;
2003
- for any other command, the directory it names with `--outDir` / `--out-dir`
2004
- / `-d`, else the ones it cleans first (`del-cli distribution`, `rimraf lib
2005
- types`), unless it makes one again with `mkdir` (D-90).
2006
- A package in a cycle of builds (nuxt's `@nuxt/nitro-server` devDepends
2007
- on `nuxt`, which depends on it; pnpm sorts it away) gets, instead of
2008
- `^build`, an edge to each build outside its cycle that `^build` would
2009
- reach, and a TODO to order the cycle: `^build` there refuses the run.
2010
- `test` / `typecheck` wait for
2011
- `build` when the package has one (`lint` reads sources and gets no
2012
- edge); `dev` / `start` / `serve` / `watch` /
2013
- `preview` become persistent tasks, with a TODO to add `readyWhen` when
2014
- another task depends on one, and so does a watcher: a `watch` segment in the script's name (`build:watch`),
2015
- a `--watch` flag, `tsc -w` / `rollup -w`, or nodemon (D-40), and a server:
2016
- `serve <dir>`, `http-server`, bare `vite`, a tool's `dev` / `serve` /
2017
- `start` / `preview` verb (`next start`, `netlify dev`), or a script that
2018
- runs such a script of its package by name (`cross-env X=1 pnpm start`) or
2019
- through a runner (`run-p web api`, `concurrently "npm:web" "npm:api"`, D-113),
2020
- outside quotes and not sent to the background with `&` (D-91), read past
2021
- a launcher's `--package <name>` / `-p <name>` (`pnpm dlx --package
2022
- netlify-cli netlify dev`, D-112). A
2023
- script whose name no task may carry (`lint#fix`, `^up`; the schema's
2024
- rule, item 1000) is left out with a TODO rather than written into a
2025
- config every later command refuses, and a `__proto__` script is written
2026
- as a computed key, since a literal `__proto__:` sets the prototype. An
2027
- existing `vx.workspace.*` in any extension the loader reads (`.mts`
2028
- included) is kept, and `--force` REPLACES a package's config of another
2029
- extension (`replaced:` in the report) rather than writing a second one
2030
- the loader would choose between by its order (item 1033). Without
2031
- `--force`, `init` never overwrites: a package that already has a vx
2032
- config keeps it, untouched, the rest get theirs, and the report lists
2033
- the kept ones under `kept`, so a half-adopted workspace adopts the rest
2034
- (M-52). `@vzn/vx-migrate` still refuses an existing config.
2035
-
2036
- A missing `vx.workspace.*` is not an error. A run where no package has
2037
- a config fails before any task, exit 1:
2038
- ``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.``
2039
-
2040
- Two npm conventions are mapped rather than copied, because copying them
2041
- loses behaviour. `pre<x>` / `post<x>` hooks, which npm runs around `x`
2042
- without being named, are folded into `x`'s command in that order, each
2043
- in its own subshell, so the chain stops at the first that fails whatever
2044
- a part holds (a `;`, an `exit`), and forwarded `--` args reach `x` alone,
2045
- as npm hands them to the script and not its hooks (item 905), appended
2046
- as text, so no part sees them as `$1` (X-60). The
2047
- command is a small shell function, `vx_script`, around the three parts;
2048
- it carries a TODO saying so; a `pre<x>` with no `x` stays a task of its own, and
2049
- npm's lifecycle hooks (`prepack`, `prepublishOnly`, …) are never tasks.
2050
- A member with no script to run (none, or only lifecycle hooks: a
2051
- types-only or config package) gets no `vx.config.ts` and is named in
2052
- the report; it is still a project, and a task declared in its own
2053
- `vx.config.ts` runs. A package with no `build` script whose `prepack`, `prepublishOnly`,
2054
- `prepublish` or `prepare` runs a builder (`bob build`, `tsc`, `tsup`, …)
2055
- is named in the report, which says to add a `build` script running it
2056
- (react-navigation's twelve packages, whose root `build` is `lerna run
2057
- prepack`, mapped with no build at all).
2058
- Where the package's manager runs no such hooks every `pre<x>` and
2059
- `post<x>` is a task of its own: Yarn 2+ (the nearest `packageManager:
2060
- yarn@2+`, or a Berry `yarn.lock`, D-31; a `packageManager` naming none of npm,
2061
- pnpm, yarn or bun defers to the lockfile, D-96), npm under `ignore-scripts=true`
2062
- in the `.npmrc` beside its lockfile, pnpm under
2063
- `enable-pre-post-scripts=false` there or `enablePrePostScripts: false` in
2064
- `pnpm-workspace.yaml` (D-33). Bun and Yarn 1 run them whatever those say.
2065
- Under Yarn 2+ a segment's `run <script>`, Yarn's shell builtin, is written
2066
- `yarn run <script>`: vx's shell has no `run` (D-92).
2067
- A Yarn 2+ repo on Plug'n'Play (no `nodeLinker: node-modules` or `pnpm` in
2068
- `.yarnrc.yml`) keeps its bins in `.pnp.cjs`, where vx's PATH finds none;
2069
- the report says so and names `nodeLinker: node-modules` or `yarn exec
2070
- '<command>'` (D-108).
2071
- A script reading `$npm_package_version`, `$npm_package_name` or
2072
- `$npm_lifecycle_event`, which every manager sets and vx does not, gets
2073
- them under `exec.env.define`, the first two read from an imported
2074
- `package.json` so a version bump reaches them; any other `$npm_*` it
2075
- reads gets a TODO (D-34). Among several packages, a workspace root
2076
- script that runs the members (`pnpm -r build`, `--filter`, `-C`, pnpm's `pn` alias
2077
- included (D-97), Yarn's
2078
- `--cwd` and `yarn workspace <name>`, npm's `--prefix`, npm's
2079
- and Yarn's workspace flags, a `cd` into or above a member, turbo, nx, lerna, `vp run`, vx itself) is not mapped
2080
- (a flag counts on the package manager, or after `node <bin> run`, and not on the program it
2081
- runs: berry's `yarn node -r ./setup.ts` is node's `--require`, D-81; bun's
2082
- `cd test && …` enters no member, D-83),
2083
- nor is one that runs such a script by name (vite's `ci-docs`: `pnpm build &&
2084
- pnpm docs-build`; through `run-s` / `run-p` / `npm-run-all` or `concurrently
2085
- "npm:x"` too, lexical's `ci-check`, D-95), and neither is one whose name a member's task carries, so `--all` never runs
2086
- a check twice (D-45). The rest check the whole repo (`lint: oxlint .`,
2087
- `test: vitest`) and become the root's own tasks in a root vx.config, when
2088
- the root has a `"name"` (vx skips a nameless root's config) and no config
2089
- of its own; one already there, hand-written or from an earlier
2090
- `vx init`, stays as written, `--force` included, and is listed under
2091
- `kept`. The report names each script left out and why
2092
- (a `pre` / `post` hook goes with its script, D-85), its examples of running
2093
- the members spelled by the repo's manager (`--workspaces` under npm, `yarn
2094
- workspaces foreach` under Yarn 2+), and says which;
2095
- with nothing mapped it names the root whenever it has a script, a member
2096
- or not (pnpm's root is not), and why: its scripts run the workspace, or
2097
- share a member's task name (listed), or both; it tells a root with no `"name"` to add one
2098
- first (vuejs/core), naming the scripts that would then map (react, D-87), and a root whose
2099
- `"name"` a member also carries to rename it, since a root config would make the
2100
- workspace refuse every run for the duplicate (insomnia, D-129). A single-package repo's root is its project and maps.
2101
- A lone root beside a `lerna.json` (Lerna-classic: packages listed there, not
2102
- in `workspaces`) gets a note naming the `workspaces` globs to add, Lerna's
2103
- `packages/*` default when it lists none (D-111).
2104
- A member whose package.json has no `name` is no project; one with a
2105
- script is named in the report, to be given a name (remix's
2106
- `packages/component/bench`, D-106).
2107
- A member's script that runs another member's work (`pnpm -C ../pinia
2108
- run build`, `yarn workspace <name>`, `-r` / `--filter`, a `cd` or `-C`
2109
- / `--cwd` / `--prefix` into another member) keeps its command and gets a
2110
- TODO naming that part: the graph runs that member's task once, so name it
2111
- under `dependsOn` and drop it from the command (pinia). A directory inside
2112
- the member or the root is no other member.
2113
- A script that is nothing but `npm run <other>` (`pnpm <other>`, `yarn
2114
- <other>`, `bun run <other>`, `npm test`, `npm start`) becomes a **group**
2115
- over `<other>` — `dependsOn` and no command — so the graph runs and
2116
- caches the target instead of a package-manager subprocess it cannot
2117
- see. When `build` is such a group, the task it reaches that runs a
2118
- command takes `^build` and the cache TODO, since a group has nothing
2119
- to cache; it and the groups on the way drop the wait on `build` a
2120
- `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`
2121
- is Bun's test runner, `bun build` its bundler, `pnpm install` and `yarn
2122
- add` the managers' verbs, so each stays a command (item 908), and so do
2123
- the names each manager reserves or hands on: `pnpm docs` and `pnpm
2124
- version` (npm's), `bun deploy`, `yarn check` (D-32); `pnpm test`, `yarn
2125
- test` and `bun lint` do run the script. Arguments, flags or a `&&`
2126
- chain make it a real command again and
2127
- it is left verbatim, and so is one whose target becomes no task (a
2128
- lifecycle script, or a hook folded into another script): a group over
2129
- it would name a task nothing defines (D-12). A verbatim chain whose
2130
- parts run this package's own scripts (`check: pnpm run build && pnpm run
2131
- lint`) gets a TODO naming those parts: each ran again inside the command,
2132
- beside its own task, so `vx run check` built twice (M-41). So does a
2133
- part that runs them through `run-s`, `run-p` or `npm-run-all`
2134
- (`build: run-p build:*`), unless the task is persistent (M-48). Its
2135
- order may matter, so it is not made a group.
2136
-
2137
- The report lists each TODO once per reason: tasks that share one are
2138
- named together (the first five, then a count; the files carry each),
2139
- and when no task caches it says uncommenting a TODO task's cache line
2140
- makes the second run a hit. Its `next:` line is a command the user can type: the
2141
- runner that started vx (`npx`, `pnpm`, `yarn`, `bunx`, read from
2142
- `npm_config_user_agent`) with the installed `vx` bin, else the
2143
- `@vzn/vx` package (`npx` under Yarn 1, which has no `dlx`); with no
2144
- runner, a bare `vx`. Outside a git work tree it starts with
2145
- `git init`, and with no script mapped with declaring a task, since the
2146
- run would refuse without either. It runs `build`, else the first task that
2147
- neither serves (`persistent`) nor changes the repo (`clean`, `release`,
2148
- `publish`, `deploy`, `version`, `format`, `fix`, …), else the first task
2149
- (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`).
2150
2124
 
2151
2125
  `vx init --plugin <seam>` writes a plugin instead: `plugins/<seam>.ts`,
2152
2126
  a small runnable plugin for that seam (`executor`, `cache`,
2153
2127
  `telemetry`, `schedule`, `admit`, `commands`, `project`, `graph`,
2154
2128
  `key`), and `plugins/<seam>.test.ts`, which drives it through `run()`
2155
2129
  (`bun test`, with `@vzn/vx` installed). It prints the line that
2156
- 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
2157
2131
  there is none). The templates are TypeScript, so `--mjs` with
2158
2132
  `--plugin` is refused. An existing file is refused without
2159
2133
  `--force`; `--dry` names the files and writes nothing. The two files are
@@ -2161,25 +2135,35 @@ there is none). The templates are TypeScript, so `--mjs` with
2161
2135
  core (`src/cli/plugin-templates.ts`) and held equal by
2162
2136
  `tests/plugin-templates.unsafe.test.ts` (H-21).
2163
2137
 
2164
- Exit codes: `0` the files written (or, with `--dry`, printed); `1` no
2165
- `package.json` here or in a parent, a file it would write already there
2166
- 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.
2167
2142
 
2168
- ## `vx migrate`
2143
+ ## `bunx @vzn/vx-migrate`
2169
2144
 
2170
- Moved out of core on 2026-09-10: the Turbo and Nx mappers are
2171
- `@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` —
2172
2148
 
2173
2149
  ```
2174
- 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
2175
2151
  bunx @vzn/vx-migrate --dry # print the generated files instead of writing
2176
2152
  bunx @vzn/vx-migrate --force # overwrite existing vx.config.* / vx-preset.ts
2177
- bunx @vzn/vx-migrate --from nx # disambiguate when both runners are checked in
2178
- ```
2179
-
2180
- — and `package.json` scripts are `vx init` (above). Typing `vx migrate`
2181
- prints that pointer and exits 1. What the package writes reads exactly
2182
- 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
2183
2167
  (`applyMigration`, exported from `@vzn/vx`), which renders, guards
2184
2168
  against overwriting, writes and reports. The mapping rules live in the
2185
2169
  package's README.
@@ -2194,7 +2178,7 @@ none is dropped in silence. An Nx flag's camelCase spelling (`--nxBail`,
2194
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
2195
2179
  `vx run` that does the same, and the other verbs a hand types from
2196
2180
  either tool name what does it here: `graph` (`--graph`), `ls`
2197
- (`vx show`), `query` (`--dry=json`), `reset` (`vx cache prune`), and
2181
+ (`vx show`), `query` (`--dry=json`), `reset` (`vx cache clean`), and
2198
2182
  `daemon`, `login`, `logout`, `link` and `unlink`, which vx has no use
2199
2183
  for. Nx's `project:target` (`vx run web:build`,
2200
2184
  when `web` declares `build`) is answered with `vx run web#build`: from
@@ -2220,8 +2204,8 @@ copy to the source.
2220
2204
  | turbo | `--summarize[=<bool>]` | same | `--summarize[=<path>]` |
2221
2205
  | turbo | `--output-logs=new-only` | refuse | use `--output-logs=full` (a hit replays its log) or `errors-only` |
2222
2206
  | turbo | `--output-logs <v>` | same | `--output-logs full\|errors-only\|hash-only\|none` |
2223
- | turbo | `--no-cache` | same | `--no-cache` reads nothing either; Turbo's (reads, no writes) is `--cache local:r,remote:r` |
2224
- | 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`) |
2225
2209
  | turbo | `--cache-dir <v>` | same | `--cache-dir <path>` |
2226
2210
  | turbo | `--profile` | same | `--profile[=<path>]` (Chrome trace) |
2227
2211
  | turbo | `--only` | alias | `--exclude-dependencies` |
@@ -2232,13 +2216,13 @@ copy to the source.
2232
2216
  | turbo | `--no-update-notifier` | refuse | vx prints no update notice: drop it |
2233
2217
  | turbo | `--skip-infer` | refuse | vx runs the binary it is: drop it |
2234
2218
  | turbo | `--root-turbo-json <v>` | refuse | `turbo()` reads the `turbo.json` at the workspace root: move it there |
2235
- | 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 |
2236
2220
  | nx | `--parallel <n>` | alias | `--concurrency <n>` (`--parallel=false` is 1) |
2237
2221
  | turbo | `--parallel` | refuse | vx always honours `dependsOn`; `--concurrency <n>` sets how many run at once |
2238
2222
  | turbo | `--scope <v>` | refuse | use `--filter <pkg>` |
2239
2223
  | turbo | `--since <v>` | refuse | use `--filter '[<ref>]'` or `--affected=<ref>` |
2240
- | turbo | `--remote-only[=<bool>]` | refuse | use `--cache local:,remote:rw` |
2241
- | 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` |
2242
2226
  | turbo | `--anon-profile` | refuse | use `--profile[=<path>]`; vx has no redacting variant, so read it before sharing it |
2243
2227
  | turbo | `--cache-workers <v>` | refuse | vx sizes its own cache I/O: drop it |
2244
2228
  | turbo | `--cwd <v>` | refuse | run vx from that directory: `cd <dir> && vx run …` |
@@ -2251,7 +2235,7 @@ copy to the source.
2251
2235
  | turbo | `--preflight` | refuse | set `turboCache({ preflight: true })` or `TURBO_PREFLIGHT=1` |
2252
2236
  | turbo | `--remote-cache-timeout <v>` | refuse | set `turboCache({ timeoutMs })` or `TURBO_REMOTE_CACHE_TIMEOUT` |
2253
2237
  | turbo | `--single-package` | refuse | a repo with no workspaces is one project already: drop it |
2254
- | 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 |
2255
2239
  | turbo | `--no-daemon`, `--daemon` | refuse | vx has no daemon: drop it |
2256
2240
  | turbo | `--ui <v>`, `--log-order <v>`, `--log-prefix <v>` | refuse | vx frames each task’s output: `--output-logs <mode>` sets how much |
2257
2241
  | nx | `-t <v>`, `--targets <v>`, `--target <v>` | alias | the task names, positional: `vx run build test` |
@@ -2268,13 +2252,13 @@ copy to the source.
2268
2252
  | nx | `--uncommitted`, `--untracked` | refuse | use `--affected=HEAD` (the working tree against the last commit) |
2269
2253
  | nx | `--max-parallel <v>` | alias | `--concurrency <n>` |
2270
2254
  | nx | `--exclude-task-dependencies[=<bool>]` | alias | `--exclude-dependencies` |
2271
- | nx | `--skip-remote-cache[=<bool>]` | alias | `--cache local:rw,remote:` |
2255
+ | nx | `--skip-remote-cache[=<bool>]` | alias | `--cache-policy local:rw,remote:` |
2272
2256
  | nx | `--verbose` | refuse | use `--verbosity <n>` (1 adds the summary table) |
2273
2257
  | nx | `--files <v>` | refuse | vx asks git what changed: `--affected=<base>` |
2274
2258
  | nx | `--batch` | refuse | vx runs one command per task: drop it |
2275
2259
  | nx | `--dte`, `--use-agents` | refuse | vx distributes nothing: drop it |
2276
2260
  | nx | `--nx-ignore-cycles` | refuse | vx refuses a task cycle by name: break it |
2277
- | 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 |
2278
2262
  | nx | `--skip-sync` | refuse | vx never runs sync generators: drop it |
2279
2263
  | nx | `--tui`, `--no-tui`, `--tui-auto-exit` | refuse | vx frames each task’s output: `--output-logs <mode>` sets how much |
2280
2264
  | nx | `--no-cloud` | refuse | vx has no cloud: drop it |
@@ -2288,8 +2272,9 @@ copy to the source.
2288
2272
 
2289
2273
  ## Machine-readable output
2290
2274
 
2291
- `vx show`, `vx info`, `vx why`, `vx last` and `vx cache prune` take
2292
- `--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
2293
2278
  checked-in JSON Schema (draft 2020-12) shipped with the package:
2294
2279
  `schemas/show.json`, `schemas/info.json`, `schemas/why.json`,
2295
2280
  `schemas/last.json`, `schemas/cache.json`
@@ -2321,15 +2306,15 @@ prose on stderr do not change. Branch on the code; the message may change.
2321
2306
 
2322
2307
  #### `VX_E_USAGE`
2323
2308
 
2324
- A flag, argument or selection the verb does not take. The message names
2325
- 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;
2326
2311
  `vx <verb> --help` lists every flag.
2327
2312
 
2328
2313
  #### `VX_E_UNKNOWN_COMMAND`
2329
2314
 
2330
2315
  No verb of that name here, core's or a declared plugin's. `vx --help`
2331
2316
  lists core's verbs. A plugin's verb exists once its plugin is in
2332
- `vx.workspace.ts` `plugins`; a task runs as `vx run <task>`.
2317
+ `.vx/workspace.ts` `plugins`; a task runs as `vx run <task>`.
2333
2318
 
2334
2319
  #### `VX_E_UNKNOWN_TASK`
2335
2320
 
@@ -2355,20 +2340,24 @@ the task once (`vx run <task>`); history is this checkout's own.
2355
2340
 
2356
2341
  #### `VX_E_CONFIG`
2357
2342
 
2358
- 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
2359
2344
  message names the file and the field; fix that field
2360
2345
  (`vx docs <field>` prints its reference). `vx info --format json` lists
2361
- 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.
2362
2348
 
2363
2349
  #### `VX_E_CYCLE`
2364
2350
 
2365
2351
  The task graph has a cycle. The message names the loop; drop one of its
2366
2352
  `dependsOn` edges.
2353
+ `vx lock` refuses it, not a run: a run trusts the graph the lock holds.
2367
2354
 
2368
2355
  #### `VX_E_NO_WORKSPACE`
2369
2356
 
2370
- No workspace root above the working directory. Run vx inside the repo,
2371
- 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.
2372
2361
 
2373
2362
  #### `VX_E_CACHE_VERSION`
2374
2363
 
@@ -2385,7 +2374,7 @@ message is that plugin's or check's: do what it says.
2385
2374
  #### `VX_E_FS`
2386
2375
 
2387
2376
  The file system refused a write: permissions, a full disk. The message
2388
- 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
2389
2378
  cache) or grant the write, then re-run.
2390
2379
 
2391
2380
  #### `VX_E_FDS`
@@ -2414,12 +2403,22 @@ A task's `dependsOn` names a task or project that is not declared, or uses
2414
2403
  a form vx does not take (a bare wildcard, a negation, a pattern in
2415
2404
  `pkg#task`). The message names the task and the entry: declare the
2416
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.
2417
2415
 
2418
2416
  #### `VX_E_OUTPUT_OVERLAP`
2419
2417
 
2420
2418
  Two tasks declare overlapping outputs with no `dependsOn` edge between
2421
2419
  them, so whichever runs second deletes the other's files. Give each task
2422
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.
2423
2422
 
2424
2423
  #### `VX_E_INPUT_IS_OUTPUT`
2425
2424
 
@@ -2428,6 +2427,13 @@ a run, so it would delete its sources) or another task's (its key must
2428
2427
  not read them; `dependsOn` already cascades). Exclude the outputs from
2429
2428
  the inputs with a `!` glob, as the message shows, or declare no outputs
2430
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.
2431
2437
 
2432
2438
  #### `VX_E_INTERNAL`
2433
2439
 
@@ -2447,14 +2453,13 @@ product verb to it under a notice.
2447
2453
 
2448
2454
  ## `vx show`
2449
2455
 
2450
- Introspect the workspace's **live resolved configs** — what a run
2451
- would see right now. Configs load through the same path a run uses,
2452
- plugin `config` and `project` stages included, so a package a plugin
2453
- gives tasks to (`turbo()` or `nx()` from `@vzn/vx-migrate`, a
2454
- temporary start toward native config) shows them; cached
2455
- evaluations are served from the local cache like a run's. `vx show`
2456
- never reads `vx-lock.json` (the lock is already the frozen JSON — open
2457
- 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\``).
2458
2463
 
2459
2464
  ```
2460
2465
  vx show # list every project
@@ -2549,7 +2554,7 @@ share `TMPDIR`: a `nix develop` shell sets its own.
2549
2554
  Every verb: a path vx must write that this user cannot (`EACCES`,
2550
2555
  `EPERM`, `EROFS` — a read-only checkout, another user's files) or that
2551
2556
  the disk has no room for (`ENOSPC`, `EDQUOT`) exits 1 with one line
2552
- naming the path, `vx: EACCES: permission denied, open '…/vx-lock.json'
2557
+ naming the path, `vx: EACCES: permission denied, open '…/.vx/projects.lock.json'
2553
2558
  — a path vx must write is not writable by this user` (or `— the disk
2554
2559
  that path is on is full`), never a stack. Inside a run the task's line
2555
2560
  says the same. A process out of file descriptors (`EMFILE`, `ENFILE`;
@@ -2560,11 +2565,10 @@ is out of file descriptors; raise the limit (ulimit -n 4096) and re-run`.
2560
2565
 
2561
2566
  Workspace doctor — one screen of facts for bug reports and sanity
2562
2567
  checks. The task count and the sandbox row's declared count come from
2563
- the same load a run uses, plugin stages included; a config that fails
2564
- to load counts as zero in both rather than failing the doctor, every
2565
- other project still counts as a run loads it, and the broken one is
2566
- named (`12 (34 tasks · 1 config did not load)`, then a `config errors`
2567
- 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:
2568
2572
 
2569
2573
  ```
2570
2574
  $ vx info
@@ -2577,21 +2581,18 @@ projects: 12 (34 tasks)
2577
2581
  plugins: 2 — @vzn/vx-reapi (executor, cache); @vzn/vx-otel (telemetry)
2578
2582
  workers: 2 — cgroup CPU quota 2 of 8 cores
2579
2583
  memory: 13 GB usable — cgroup limit; the machine has 16 GB
2580
- cache dir: /work/repo/.vx/cache
2581
- cache store: /home/me/.vx/3f2a9c1e7b4d5a60/cache
2582
- 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
2583
2587
  cache entries: 42 (1.3 GB)
2584
- 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
2585
2589
  task runs (24h): 7 (5 cache hits)
2586
2590
  flaky tasks: 1 — web#test (3 of 11 runs failed on unchanged inputs)
2587
2591
  sandbox: available (9 tasks declare exec.sandbox)
2588
- vx-lock.json: yes
2592
+ projects lock: yes
2589
2593
  ```
2590
2594
 
2591
- - `git` shows `(not found)` when the binary is missing; a broken
2592
- project config contributes zero tasks instead of failing the
2593
- printout, and the `config errors` row (present only then) names it
2594
- with the loader's message — the same line `vx run` would stop on.
2595
+ - `git` shows `(not found)` when the binary is missing.
2595
2596
  - `git status cache`: vx runs ONE `git status` per run to find dirty
2596
2597
  and untracked files, and on a large tree that walk is the warm run's
2597
2598
  critical path. git's `core.fsmonitor` (a daemon that watches the
@@ -2611,13 +2612,13 @@ exec.sandbox and will fail` says it first: root inside a container
2611
2612
  unreported`: the sandbox still enforces, but a task that tolerates a
2612
2613
  denied read passes and caches with no word of it. The `--format json` fact is
2613
2614
  `sandbox.untraced`, the reason or `null`.
2614
- - `plugins` names every plugin `vx.workspace.*` declares and the seams
2615
+ - `plugins` names every plugin `.vx/workspace.*` declares and the seams
2615
2616
  each fills, in pipeline order (`config`, `discover`, `project`, `graph`, `key`,
2616
2617
  `fingerprint`, `schedule`, `admit`, `executor`, `cache`, `telemetry`,
2617
- `setup`, `commands`), or `none`. It reads the declarations: a plugin
2618
+ `output`, `setup`, `commands`), or `none`. It reads the declarations: a plugin
2618
2619
  that declines a task at run time still lists its seam here.
2619
2620
  - `workers` is the count a run defaults to and where it comes from:
2620
- `vx.workspace.ts`'s `concurrency` when set, else the cores this
2621
+ `.vx/workspace.ts`'s `run.concurrency` when set, else the cores this
2621
2622
  process may use — the CPU count, capped by the cgroup CPU quota a
2622
2623
  container runs under (`8 — the CPU count`, `2 — cgroup CPU quota 2 of
2623
2624
  8 cores`). `memory` is what a memory-packing policy budgets
@@ -2638,15 +2639,22 @@ unreported`: the sandbox still enforces, but a task that tolerates a
2638
2639
  the hits are a share of it: three `vx run` of two tasks are six. An
2639
2640
  invocation is what `vx last` calls a run; `vx last --list` counts
2640
2641
  those.
2641
- - `cache store` is where the entries and their artifacts live: the
2642
- store every workspace of this user shares (`docs/caching.md`), or
2643
- `none: the cache dir holds the entries` when the workspace names its
2644
- 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.
2645
2648
  - `cache entries` counts the entries and their size; with a
2646
2649
  `cacheRetention.maxSize` it reads against it (`812 (3.0 GB of 10 GB,
2647
2650
  30%)`), and a `cache retention` row says what each run's end evicts.
2648
2651
  Paths under the home directory print as `~/…`; the JSON keeps them
2649
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).
2650
2658
  - `cache versions` are the two constants a bug report needs and the
2651
2659
  reset notice names: the key prefix (`CACHE_VERSION`; a bump orphans
2652
2660
  every entry) and the index schema (`SCHEMA_VERSION`; a mismatch drops
@@ -2655,19 +2663,23 @@ unreported`: the sandbox still enforces, but a task that tolerates a
2655
2663
  save temps the index has no row for, older than an hour (what a
2656
2664
  `SCHEMA_VERSION` reset leaves behind; a fresh one is a save in
2657
2665
  flight). A task that asks for one's key again indexes it and hits;
2658
- 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.
2659
2667
  - `--format json` prints the same facts as one typed object, for a
2660
- 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
2661
2671
  below Bun 1.4.0), `git` (null when not
2662
2672
  found), `gitStatusCache` (`{ fsmonitor, untrackedCache }`, null when
2663
2673
  git could not answer), `workspaceRoot`, `projects`, `tasks`,
2664
- `configErrors` (`[{ path, message }]`, the configs that did not load,
2665
- empty when all did), `plugins` (`[{ name, seams }]`), `workers` (`{ count, source, cores,
2674
+ `plugins` (`[{ name, seams }]`), `workers` (`{ count, source, cores,
2666
2675
  cpuQuota }`, the source one of `workspace` / `cgroup` / `cores`,
2667
2676
  `cpuQuota` in cores or null), `memory` (`{ usableBytes, totalBytes,
2668
- cgroupLimitBytes }`, the limit null when none binds), `cacheDir`, `cacheStore`
2669
- (null when the cache dir holds the entries), `cacheVersion`,
2670
- `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`
2671
2683
  (`{ olderThan, maxSize, maxBytes }`, each only when set, null when none is declared),
2672
2684
  `orphans` (`{ artifacts, bytes }`, always present), `runs24h`, `hits24h` (task
2673
2685
  runs, as the row), `restored24h` (of those hits, the ones that restored
@@ -2678,10 +2690,10 @@ declared, untraced }`, `declared` the count of tasks with `exec.sandbox`,
2678
2690
  pretty rows render this object;
2679
2691
  there is no second source.
2680
2692
  - `--cache-dir <path>` reads the cache a run with the same flag uses, as
2681
- `vx why`, `vx last` and `vx cache prune` do.
2693
+ `vx why`, `vx last` and `vx cache clean` do.
2682
2694
 
2683
- Exit codes: `0` the facts printed, a config that did not load included;
2684
- `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.
2685
2697
 
2686
2698
  ## `vx why`
2687
2699
 
@@ -2719,7 +2731,7 @@ cache (nothing re-ran), it re-executed on the same key, or it recorded
2719
2731
  no cache outcome at all, in which case vx says so instead of guessing.
2720
2732
  A re-execution names its cause when the index shows one: the previous
2721
2733
  run on the key failed and saved nothing, the run did not read the cache
2722
- (`--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
2723
2735
  there when it ran (pruned or evicted), the previous run was not saved
2724
2736
  because it ran beside a failed task (a task run past a failed dependency
2725
2737
  under `--continue` is never cached; said ahead of a prune, and as
@@ -2765,6 +2777,18 @@ which names the `vx why` to run next; a `config` line names the
2765
2777
  `vx show` that prints the task's config now. `--format json` carries
2766
2778
  the walk as `roots` (`chain`, `entries`).
2767
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
+
2768
2792
  A hit's line is `cache-hit · key …` (or `cache-hit-remote`): the status
2769
2793
  names the hit and its tier, so only an executed run carries the word.
2770
2794
  A skipped task never ran and derived no key: its line is `skipped · no key`.
@@ -2772,11 +2796,10 @@ A row's kind is what the key folded: `file` (an input file, by blob
2772
2796
  id), `env` (a declared variable, by digest), `runtime` and
2773
2797
  `ws-runtime` (a declared command's output, `inputs.runtime` and
2774
2798
  `inputs.workspaceRuntime`), `forward` (the argv forwarded after `--`),
2775
- `package` (the project's own `package.json`), `workspace` (the
2776
- fingerprint: the lockfile and the other root manifests), `config` (the
2777
- evaluated task config), `upstream` (a dependency's input key — a
2778
- lockfile change moves it too, so that row rides with the
2779
- 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
2780
2803
  `format` (the key format vx itself folds: it moves only with an
2781
2804
  upgrade, and re-keys every task once, X-142).
2782
2805
  The component-level rows come from the `entry_inputs` input
@@ -2789,39 +2812,13 @@ task with no `cache` block derives a key too — it is what dependents
2789
2812
  fold — but saves no entry, so for it the verb can only report the key
2790
2813
  change and says so.
2791
2814
  `--format json` emits one machine-readable object (`{ taskId, runId,
2792
- 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,
2793
2816
  why, diff, explanation }`, `why` and `diff` null and `explanation` the
2794
2817
  latest cache entry's key.
2795
2818
 
2796
2819
  Exit codes: `0` the task's run explained; `1` no recorded run of the
2797
2820
  task, a `--run` with no row for it, or a parse error.
2798
2821
 
2799
- ## `vx prune`
2800
-
2801
- ```
2802
- vx prune <project...> [--out-dir <dir>] [--docker] [--production]
2803
- ```
2804
-
2805
- A plugin verb of `@vzn/vx-lockfile`: declaring `pnpm()`, `bun()`,
2806
- `npm()` or `yarn()` adds it. It copies the named projects, their
2807
- transitive workspace dependencies, the root manifests (workspace lists
2808
- rewritten to the subset), the vx configs and each lockfile pruned to what
2809
- the subset installs into `--out-dir` (default `out/`), so a Docker build
2810
- installs it with a frozen lockfile; `--docker` splits `json/` (the
2811
- install layer) from `full/`; `--production` leaves out a workspace
2812
- package only a dev dependency reaches and strikes it from the kept
2813
- manifests and lockfiles. `turbo prune`. Reference:
2814
- `packages/vx-lockfile/README.md` § `vx prune`. Without the plugin, typing
2815
- `vx prune` says where it comes from and exits 1. History: core verb
2816
- 2026-08-25, `@vzn/vx-prune` 2026-09-10, removed 2026-09-11 (it copied the
2817
- lockfile unpruned), back in `@vzn/vx-lockfile` 2026-10-03.
2818
-
2819
- ## `vx stats`
2820
-
2821
- Removed (H-19). It was an alias of `vx info`, which absorbed it, and
2822
- warned on every use. Typing `vx stats` points at `vx info` and exits 1 —
2823
- unless a declared plugin claims the verb through the `commands` seam.
2824
-
2825
2822
  ## `vx last`
2826
2823
 
2827
2824
  Replay a recorded run's summary from the local history — no
@@ -2831,7 +2828,7 @@ surface. A run a Ctrl-C (or SIGTERM, SIGHUP) stopped records nothing,
2831
2828
  so `vx last` still shows the run before it.
2832
2829
 
2833
2830
  ```
2834
- 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>]
2835
2832
  ```
2836
2833
 
2837
2834
  Bare `vx last` replays the most recent run: a header (verdict, command,
@@ -2873,7 +2870,12 @@ passed, so a replayed failure that is already fixed says so. A
2873
2870
  sandboxed task's row adds `sandboxFix`, the `exec.sandbox.allow`
2874
2871
  entries (`read`, `write`, `network`, `systemInfo`) that would have
2875
2872
  granted its denials, the same grants the terminal prints as `fix:`;
2876
- `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
2877
2879
  newest 50 failed runs; past those, or when the write was refused, a
2878
2880
  failed row reads `output: ''` and `locations: []`. `--log <task>`
2879
2881
  prints one task's output instead of the summary: in the latest run that
@@ -2884,7 +2886,23 @@ or in the run that saved it. An uncached task that passed, or a skip,
2884
2886
  kept none: stdout is empty and stderr says so, exit 0. With
2885
2887
  `--format json` it is `{ runId, taskId, status, source, output }`,
2886
2888
  `source` `'failure'`, `'cache'` or `null`. A task no run recorded is
2887
- 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`
2888
2906
  an array of the same `invocation` objects, newest first. An unknown run id fails
2889
2907
  loud and points at `--list`. Before any run, every form says so
2890
2908
  (`vx last: no recorded runs yet — run something first`, exit 1; `--list`
@@ -2898,14 +2916,14 @@ line of its own without them, X-140). The
2898
2916
  replayed `$ vx run …` or `$ vx watch …` line and that one keep each
2899
2917
  argument shell-quoted (`-- 'a b'`), so either pastes back as it ran (X-49).
2900
2918
 
2901
- `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
2902
2920
  a run wrote, so each takes `--cache-dir <path>` with `vx run`'s rules
2903
2921
  (cwd-relative, absolute used as-is): a run that wrote its history
2904
2922
  elsewhere is replayed, explained, reported on and pruned there. Without
2905
- the flag they open the workspace's cache (`defineWorkspace({ cacheDir })`
2923
+ the flag they open the workspace's cache (`defineWorkspace({ cache: { dir } })`
2906
2924
  or `.vx/cache`). A `--cache-dir` that is not there is refused by name
2907
2925
  (`--cache-dir .vx/cahce: no such directory`). In a workspace that never
2908
- 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`
2909
2927
  (dry or not) read it as empty and create nothing (item 900, E-7, E-38).
2910
2928
 
2911
2929
  Exit codes: `0` the run (or the list) printed, `--list` with no runs
@@ -2947,10 +2965,10 @@ of each verb, read from the same help text `vx <verb> --help` prints:
2947
2965
  the verb's own Usage line, and for `run` and `watch` the run option
2948
2966
  lines, less the ones `watch` refuses. So a flag cannot be documented
2949
2967
  and not completed, nor completed and then refused: a flag another
2950
- 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
2951
2969
  not one. After a flag with a fixed value set the script offers that
2952
2970
  set: `--format` pretty or json, `--output-logs` full, errors-only,
2953
- 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
2954
2972
  the flag's parser takes (`tests/completions.test.ts` holds it). A plugin verb completes `--help` only. Task and project
2955
2973
  names are not completed (they are the workspace's, and a completion
2956
2974
  that evaluates configs on every Tab is the wrong price). The zsh script
@@ -2965,7 +2983,7 @@ Exit codes: `0` the script printed; `1` an unknown shell or flag.
2965
2983
 
2966
2984
  ## Plugin commands
2967
2985
 
2968
- A plugin declared in `vx.workspace.ts` can add verbs:
2986
+ A plugin declared in `.vx/workspace.ts` can add verbs:
2969
2987
 
2970
2988
  ```ts
2971
2989
  import { definePlugin, type VxPlugin } from '@vzn/vx'
@@ -2988,18 +3006,15 @@ export function mcp(): VxPlugin {
2988
3006
  The plugin's name is its package name, which `definePlugin` reads from
2989
3007
  `import.meta`; a plain object is refused when the workspace loads.
2990
3008
 
2991
- (`@vzn/vx-mcp` ships exactly this: declare `mcp()` and `vx mcp` serves
2992
- six read-only tools to AI agents — four over the cache database, one
2993
- over the resolved task catalog, one the workspace doctor's facts.
2994
- `@vzn/vx-schedule-history` adds `vx history`: what it learned per task
2995
- 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
2996
3011
  first and consults plugins only for a word core does not know, loading
2997
3012
  the workspace config from the cwd to find them (outside a workspace the
2998
3013
  verb is simply unknown). A plugin verb that names a core verb, or one
2999
3014
  plugins of two packages both declare, is refused when the workspace
3000
3015
  loads — such a verb could never run, or would hide the other plugin's.
3001
- Plugins of one package are one owner (`bun()` and `pnpm()` both carry
3002
- `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
3003
3018
  return value is the exit code, an integer 0–255 (anything else — nothing,
3004
3019
  a fraction, 256, which the OS would keep as 0 — fails naming the plugin
3005
3020
  and the verb), and a thrown `UserError` prints as
@@ -3011,44 +3026,62 @@ An unknown verb is answered with what this workspace knows: the "did you
3011
3026
  mean" set is core's verbs AND the ones its plugins declare (the lookup
3012
3027
  that just failed already loaded them, so it costs nothing), and when
3013
3028
  nothing is close enough to guess, a second line says where a verb can
3014
- 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
3015
3030
  would declare one, or that there is no workspace here at all. Core still
3016
3031
  names no package: it lists what the workspace itself declares.
3017
3032
 
3018
3033
  ## Environment variables vx reads
3019
3034
 
3020
- The variables core reads. Each is read where it applies, per call, so a
3021
- value set for one invocation is that invocation's. A plugin's own (the
3022
- REAPI endpoints, the GitHub token) are in its README; what a TASK sees
3023
- is `exec.env` in `docs/schema.md`, and the two markers vx sets on every
3024
- task are the last row.
3025
-
3026
- | Var | Value | Default | Effect |
3027
- | --------------------------------- | --------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3028
- | `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`). |
3029
- | `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. |
3030
- | `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. |
3031
- | `VX_CACHE_SCOPE` | a `cacheScope` value | none | The run's `cacheScope` (`trusted`, `read-only`, `pr-123`), above `vx.workspace`'s and `github()`'s. Empty is ignored; a value `cacheScope` would refuse is refused before any task. |
3032
- | `VX_REWRITTEN_INPUTS` | `withhold`, `compare` | `withhold` | What a write to an input during the command means for a task that does not set `cache.rewrittenInputs`: `compare` saves the entry when the file holds the bytes the key folded, anything else withholds it (`docs/schema.md`). |
3033
- | `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. |
3034
- | `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. |
3035
- | `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. |
3036
- | `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. |
3037
- | `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). |
3038
- | `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`). |
3039
- | Var | Value | Default | Effect |
3040
- | --------------------------------- | --------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3041
- | `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`). |
3042
- | `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. |
3043
- | `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. |
3044
- | `VX_CACHE_SCOPE` | a `cacheScope` value | none | The run's `cacheScope` (`trusted`, `read-only`, `pr-123`), above `vx.workspace`'s and `github()`'s. Empty is ignored; a value `cacheScope` would refuse is refused before any task. |
3045
- | `VX_REWRITTEN_INPUTS` | `withhold`, `compare` | `withhold` | What a write to an input during the command means for a task that does not set `cache.rewrittenInputs`: `compare` saves the entry when the file holds the bytes the key folded, anything else withholds it (`docs/schema.md`). |
3046
- | `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. |
3047
- | `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. |
3048
- | `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. |
3049
- | `VX_CONFIG_WORKER_TIMEOUT_MS` | integer (ms) | 30000 | How long one `vx.config.ts` evaluation may take, in process or in its worker, before the load fails naming the config (a real evaluation is ~10 ms). A synchronous loop on a first load holds the thread the deadline needs, so it hangs until killed. Out of range (0, or past 2³¹−1) falls back to the default. |
3050
- | `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). |
3051
- | `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`).
3052
3085
 
3053
3086
  Colors are the two conventions in § Output format › Colors (`NO_COLOR`,
3054
3087
  `FORCE_COLOR`). A truthy `CI` picks the CI output flow and
@@ -3118,7 +3151,9 @@ marked with a cyan `▸` after `┌─`/`└─`, and its close reads `running`
3118
3151
  died on its own before the run stopped it.
3119
3152
 
3120
3153
  There is **no top-of-run banner** — the run context lives in the
3121
- 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:
3122
3157
 
3123
3158
  ```
3124
3159
  ⇢ 4ms success local @vzn/vx#format-check
@@ -3126,30 +3161,29 @@ footer. A broad run looks like:
3126
3161
 
3127
3162
  ─ vx 0.0.0 ───────────────────────────────────────────────────
3128
3163
  projects ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱
3129
- 1 in run · 3 total
3164
+ 1 affected · 3 total
3130
3165
  tasks ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰
3131
3166
  2 success · 2 total
3132
3167
  cache ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰
3133
3168
  1 miss · 1 local
3134
3169
 
3135
3170
  info 8 workers · local cache
3136
- time 5.34s · max 5.20s · avg 5.20s · min 5.20s
3137
- result 2 tasks · 1 cached (50%) · 900ms saved · 5.34s
3171
+ time 5.34s · 900ms saved
3138
3172
  ```
3139
3173
 
3140
3174
  The three bars are meters: `projects` is what the run covered against
3141
3175
  the workspace, `tasks` is failed / success / skipped, `cache` is miss /
3142
3176
  no-cache / up-to-date / local / remote (a skipped task rides it too, so
3143
- the two legends sum alike). The `time` spread counts executed tasks
3144
- only — a hit's restore time never enters it — which is why one executed
3145
- task reads as its own max, avg and min. The `result` row is the run in
3146
- one line, last: tasks, cached (every hit, local or remote, over every
3147
- task with a cache that ran or hit; a skipped task asked no cache) and the wall time — `3 tasks · all cached · 40ms`
3148
- when nothing that could hit ran, with `N failed` after the count on a
3149
- red run. A task with no `cache` block, or any task of a run whose
3150
- `--cache` reads and writes nothing (`--no-cache`), could never hit, so it
3151
- is counted apart (`1 task · 1 no-cache · 37ms` for `vx run dev`), as
3152
- `--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
3153
3187
  checks it against this page, byte for byte.
3154
3188
 
3155
3189
  Group tasks emit no framed block by design (they aren't real tasks);
@@ -3160,8 +3194,8 @@ What a run has to say about one task rides that task's row; what it
3160
3194
  says about the run (a server that died, outputs left remote, a file
3161
3195
  `--summarize` / `--profile` wrote, a history, upload, plugin or sandbox
3162
3196
  warning) prints above the footer: it prints once the run's closing work
3163
- is done, the `VX_TIMING=1` table included. Cache housekeeping
3164
- (`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
3165
3199
  follows it.
3166
3200
 
3167
3201
  **Skipped rows.** A red run lists every task that never started as a
@@ -3188,9 +3222,9 @@ what it reached first (`not run`). Neither reached the cache:
3188
3222
  ◌ not run web#test
3189
3223
  ```
3190
3224
 
3191
- The tasks legend names them outside the total, which history and
3192
- telemetry share: `2 success · 2 total · not counted: 1 aborted, 2 not
3193
- 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
3194
3228
  printed while it stopped shows its frame instead of a row.
3195
3229
 
3196
3230
  **Flaky rows.** A task the run just proved nondeterministic says so
@@ -3204,7 +3238,7 @@ run history alone, no service:
3204
3238
 
3205
3239
  A task is flaky when its exact cache key FAILED AFTER IT HAD PASSED
3206
3240
  (this run counted; a cache hit is a pass, it replayed one), or when it
3207
- 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
3208
3242
  key that never passed is a break and carries no note — a changed input
3209
3243
  that fails is what a red run usually means — and the first pass after
3210
3244
  only failures is a recovery (a missing tool installed), not a flake.
@@ -3250,7 +3284,7 @@ Only a run's own output (`vx run`, `vx watch`) is painted, and the
3250
3284
  help on a terminal: headings bold, `vx <verb>` and each flag or example
3251
3285
  in cyan; `NO_COLOR` or `FORCE_COLOR=0` turns it off
3252
3286
  (`tests/help-colors.test.ts`). Every other verb (`show`, `info`, `why`,
3253
- `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
3254
3288
  terminal, prints plain text whatever `FORCE_COLOR` says, so a script
3255
3289
  that forces colour for a run's log still parses them
3256
3290
  (`tests/cli-colors-e2e.test.ts`).
@@ -3258,8 +3292,9 @@ that forces colour for a run's log still parses them
3258
3292
  ## Remote cache (plugin-driven)
3259
3293
 
3260
3294
  Core ships **no remote-cache wire client** — the remote cache is a
3261
- plugin concern. A `cache` plugin composes core's `LayeredCache` over a
3262
- 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
3263
3298
  (ActionCache + CAS).
3264
3299
 
3265
3300
  Reads try local first, then remote (hydrating local on remote hit),
@@ -3273,7 +3308,7 @@ implement core's `RemoteCacheLayer` interface in a plugin's `cache`
3273
3308
  capability — the recipe lives in the plugins guide. Embedders
3274
3309
  holding a wire client can inject it per-run via
3275
3310
  `RunOptions.remoteCache` (explicit injection wins over the plugin
3276
- consult). The retired `VX_REMOTE_CACHE_*` env vars are gone.
3311
+ consult).
3277
3312
 
3278
3313
  ## Run analytics
3279
3314