@vzn/vx 0.0.638 → 0.0.640

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 (134) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +152 -95
  3. package/docs/cli.md +225 -372
  4. package/docs/execution.md +22 -47
  5. package/docs/features.md +14 -16
  6. package/docs/patterns.md +6 -8
  7. package/docs/schema.md +268 -215
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/last.json +7 -4
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +10 -7
  13. package/src/cache/cache.ts +44 -11
  14. package/src/cache/file-hashes.ts +7 -12
  15. package/src/cache/fs-clock.ts +62 -0
  16. package/src/cache/git-inputs.ts +8 -2
  17. package/src/cache/index.ts +5 -2
  18. package/src/cache/inputs.ts +154 -83
  19. package/src/cache/key-fold.ts +23 -3
  20. package/src/cache/layer.ts +33 -18
  21. package/src/cache/layered-cache.ts +108 -315
  22. package/src/cache/linked-inputs.ts +87 -0
  23. package/src/cache/plugin-cache.ts +2 -10
  24. package/src/cache/remote-lane.ts +383 -0
  25. package/src/cache/run-history.ts +24 -8
  26. package/src/cache/run-store.ts +4 -4
  27. package/src/cache/schema.ts +6 -4
  28. package/src/cache/scoped-cache.ts +7 -0
  29. package/src/cli/completions.ts +2 -2
  30. package/src/cli/foreign-flags.ts +11 -23
  31. package/src/cli/help.ts +6 -31
  32. package/src/cli/info.ts +2 -1
  33. package/src/cli/init.ts +23 -11
  34. package/src/cli/last.ts +30 -4
  35. package/src/cli/plan-format.ts +5 -58
  36. package/src/cli/plugin-commands.ts +2 -2
  37. package/src/cli/plugin-templates.ts +2 -2
  38. package/src/cli/run.ts +23 -185
  39. package/src/cli/select.ts +16 -6
  40. package/src/cli/show.ts +15 -6
  41. package/src/cli/upgrade.ts +215 -55
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +22 -6
  44. package/src/cli/watch-judge.ts +49 -5
  45. package/src/cli/watch-set.ts +18 -8
  46. package/src/cli/watch.ts +103 -16
  47. package/src/cli/why.ts +9 -7
  48. package/src/config.ts +19 -21
  49. package/src/exec/executor.ts +5 -5
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/dependency-spec.ts +2 -2
  53. package/src/graph/folds.ts +236 -0
  54. package/src/graph/index.ts +17 -2
  55. package/src/graph/priorities.ts +1 -1
  56. package/src/graph/scheduler.ts +45 -31
  57. package/src/graph/task-graph.ts +190 -58
  58. package/src/graph/task-status.ts +95 -0
  59. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  60. package/src/{orchestrator → history}/history.ts +1 -1
  61. package/src/history/index.ts +48 -0
  62. package/src/{orchestrator → history}/metrics.ts +7 -20
  63. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  64. package/src/index.ts +11 -29
  65. package/src/orchestrator/affected-tasks.ts +20 -19
  66. package/src/orchestrator/doctor.ts +1 -1
  67. package/src/orchestrator/download-policy.ts +16 -22
  68. package/src/orchestrator/excluded-keys.ts +4 -5
  69. package/src/orchestrator/execute-args.ts +80 -0
  70. package/src/orchestrator/execute-task.ts +50 -411
  71. package/src/orchestrator/fingerprint-watch.ts +2 -2
  72. package/src/orchestrator/index.ts +2 -73
  73. package/src/orchestrator/keyed-projects.ts +12 -51
  74. package/src/orchestrator/local-shortcircuit.ts +1 -1
  75. package/src/orchestrator/miss-save.ts +33 -3
  76. package/src/orchestrator/options.ts +1 -36
  77. package/src/orchestrator/output-host.ts +4 -3
  78. package/src/orchestrator/persistent-task.ts +321 -0
  79. package/src/orchestrator/persistent.ts +130 -3
  80. package/src/orchestrator/placement.ts +5 -5
  81. package/src/orchestrator/plan.ts +19 -6
  82. package/src/orchestrator/plugin-host.ts +129 -69
  83. package/src/orchestrator/plugin.ts +18 -8
  84. package/src/orchestrator/prepare.ts +40 -34
  85. package/src/orchestrator/projects.ts +7 -10
  86. package/src/orchestrator/remote-prefetch.ts +14 -1
  87. package/src/orchestrator/run-artifacts.ts +5 -177
  88. package/src/orchestrator/run-context.ts +55 -0
  89. package/src/orchestrator/run-failures.ts +15 -3
  90. package/src/orchestrator/run-records.ts +35 -3
  91. package/src/orchestrator/run-report.ts +151 -171
  92. package/src/orchestrator/run-settings.ts +1 -16
  93. package/src/orchestrator/run-steps.ts +57 -0
  94. package/src/orchestrator/run-stop.ts +153 -0
  95. package/src/orchestrator/run.ts +293 -604
  96. package/src/orchestrator/sandbox-fix.ts +1 -17
  97. package/src/orchestrator/sandbox-request.ts +2 -2
  98. package/src/orchestrator/signals.ts +14 -6
  99. package/src/orchestrator/stable-keys.ts +164 -27
  100. package/src/orchestrator/steps.ts +70 -0
  101. package/src/orchestrator/task-hash.ts +34 -25
  102. package/src/orchestrator/task-log-buffer.ts +1 -1
  103. package/src/orchestrator/task-process.ts +94 -0
  104. package/src/orchestrator/telemetry-host.ts +4 -4
  105. package/src/orchestrator/telemetry.ts +14 -86
  106. package/src/orchestrator/upstream.ts +4 -181
  107. package/src/{orchestrator → report}/events.ts +41 -8
  108. package/src/{orchestrator → report}/forecast.ts +35 -2
  109. package/src/{orchestrator → report}/framed-output.ts +40 -34
  110. package/src/report/index.ts +36 -0
  111. package/src/{orchestrator → report}/logger.ts +158 -20
  112. package/src/{orchestrator → report}/status-line.ts +88 -7
  113. package/src/{orchestrator → report}/summary.ts +126 -35
  114. package/src/report/tally.ts +83 -0
  115. package/src/settings.ts +12 -39
  116. package/src/util/colors.ts +44 -4
  117. package/src/util/git-ignored.ts +143 -0
  118. package/src/util/index.ts +19 -4
  119. package/src/util/paths.ts +1 -1
  120. package/src/util/report.ts +13 -5
  121. package/src/util/settle.ts +14 -39
  122. package/src/util/tail.ts +1 -1
  123. package/src/util/ui.ts +17 -6
  124. package/src/workspace/affected.ts +81 -2
  125. package/src/workspace/config-imports.ts +1 -1
  126. package/src/workspace/config-schema.ts +142 -143
  127. package/src/workspace/lockfile.ts +1 -1
  128. package/src/workspace/migration.ts +19 -3
  129. package/schemas/summary.json +0 -133
  130. package/src/cache/chained-cache.ts +0 -280
  131. package/src/orchestrator/tally.ts +0 -112
  132. /package/src/{orchestrator → report}/path-links.ts +0 -0
  133. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  134. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
package/docs/cli.md CHANGED
@@ -64,7 +64,7 @@ included.
64
64
 
65
65
  **Every requested name must resolve.** If any positional matches no
66
66
  project in scope, the run refuses to start — `vx run: no projects
67
- declare task(s): <name>.` on stderr, a run and `--dry` / `--graph` alike
67
+ declare task(s): <name>.` on stderr, a run and `--dry` alike
68
68
  (a run printed it to stdout until 2026-10-03), exit 1, with `Did you mean <task>?` when a
69
69
  declared task (or, for `pkg#task`, a runnable spec) is within two edits,
70
70
  and with no name that near, what exists: `Tasks: build, test.`, `No
@@ -124,20 +124,21 @@ If no task name is given:
124
124
  `missing task name (stdin is not a TTY, so no picker; tasks here: build, test)`,
125
125
  naming the cwd project's tasks, else every project's (twelve, then
126
126
  `and N more`); outside a workspace it reads `vx run <task>, e.g. vx run build`.
127
- - **Under `--format json` or `--dry=json`**, on a terminal too — the
127
+ - **Under `--dry=json`**, on a terminal too — the
128
128
  same refusal (`a JSON answer opens no picker; …`), with the
129
129
  `VX_E_USAGE` error document on stdout: a program cannot pick.
130
130
 
131
131
  Exit codes:
132
132
 
133
- | Code | When |
134
- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
135
- | `0` | Every task finished `success` or `cache-hit` (local or remote); or `--affected` left no project that declares the task. |
136
- | `1` | At least one task ended `failed` or `skipped`; a persistent task exited non-zero after it was ready; a task name no project declares; or parse/setup error. |
137
- | `130` / `143` / `129` | Interrupted (SIGINT / SIGTERM / SIGHUP): each task's process group (the task and what it forked) gets vx's signal once (a SIGHUP as a SIGTERM), `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). |
133
+ | Code | When |
134
+ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
135
+ | `0` | Every task finished `success` or `cache-hit` (local or remote); or `--affected` left no project that declares the task. |
136
+ | `1` | At least one task ended `failed` or `skipped`; a persistent task exited non-zero after it was ready; a task name no project declares; or parse/setup error. |
137
+ | `130` / `143` / `129` | Interrupted (SIGINT / SIGTERM / SIGHUP): each task's process group (the task and what it forked) gets vx's signal once (a SIGHUP as a SIGTERM), a 2 s grace to go, then SIGKILL; a second signal skips the grace. vx then dies of the signal, so a shell script running it stops too (a run that handed a task the terminal exits with the code, so the terminal is restored). |
138
138
 
139
139
  A task runs in its own session, so a terminal's Ctrl-C reaches vx alone,
140
- and each task hears it once: from vx, as SIGINT.
140
+ and each task hears it once: from vx, as SIGINT. A stop starts no more remote
141
+ cache uploads and does not wait on one in flight.
141
142
 
142
143
  Installed from npm, `vx` is a sh launcher that execs the platform
143
144
  binary, so every signal reaches vx directly and no Node starts first
@@ -300,13 +301,13 @@ change seeds tasks in the projects it touches:
300
301
  repository is one its `workspaceFiles` may reach into (git reports it
301
302
  as one path; a glob whose fixed prefix is above or inside it counts);
302
303
  - every task of a project whose `vx.config.*`
303
- changed, or that holds a changed path no cached task of its declares
304
+ changed, or that holds a changed path no task of its declares
304
305
  (vx cannot prove that path re-keys nothing), or that holds a changed
305
306
  submodule or embedded repository (git reports it as one path, and no
306
307
  task's globs can say whether they reach inside), or that a lockfile
307
308
  claim or a config import names (a dependency an install added or
308
309
  removed reaches here as the lockfile's change);
309
- - an uncached task when a changed path lies in its project or the
310
+ - a task with no `inputs` when a changed path lies in its project or the
310
311
  project is reached whole; a root file another task of its declares
311
312
  (`workspaceFiles`) is no change there. A group seeds nothing.
312
313
 
@@ -353,6 +354,14 @@ change to it, and every project under it is selected, the project that
353
354
  holds it included. A repository's own request to hide submodules from a diff
354
355
  (`diff.ignoreSubmodules`, `submodule.<name>.ignore`) does not apply:
355
356
  the key sees the change whatever git is told to show.
357
+ A symlinked input keys what it points to, so a link whose target
358
+ changed (a file the diff names, or one under a linked directory) is a
359
+ changed path of its project, and a link to a target git cannot diff
360
+ (outside the workspace, or git-ignored) is one on every run.
361
+ A gitignored file a task's inputs name outright (`.env.local`,
362
+ `dist/**`) never selects the task on its own, as in Nx: `--affected`
363
+ reflects only what git sees. The key still hashes it, so a run of the
364
+ task misses when it changed.
356
365
  `.vx/projects.lock.json` is filtered out of the changed set — a `vx lock`
357
366
  re-write never marks every project affected.
358
367
 
@@ -480,34 +489,23 @@ than run with the args unheard.
480
489
  | `--schedule <rule>` | value | `run.schedule`, else `most-work` | Which ready task starts first when more are ready than workers: `most-work`, `critical-path`, `direct-dependents`, `ready-order` (`docs/schema.md` § `run.schedule`). |
481
490
  | `--branch <name>` | value | `VX_RUN_BRANCH`, else git's branch, then the CI provider's | The branch the run records in its history and forecasts from. No config key: a committed branch would mislabel every other branch's runs. |
482
491
  | `--no-cache` | boolean | off | Disable caching entirely (no reads, no writes); output globs are NOT cleaned. |
483
- | `--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). |
492
+ | `--force` | boolean | off | Re-execute everything (skip cache reads); writes stay on. A key already stored is kept, and a remote that holds it is not sent it again. Output globs are cleaned (so the saved snapshot is clean). |
484
493
  | `--cache-policy <spec>` (`--cache-policy`) | value | `VX_CACHE_POLICY` / `cache.policy` | Per-layer read/write control. See below. An EMPTY spec (`--cache-policy=`) is a parse error — it applied nothing and left every axis on; pass `--no-cache` to disable them all. |
485
494
  | `--cache-scope <name>` | value | `VX_CACHE_SCOPE` / workspace `cache.scope` | This run's `cache.scope` (`pr-123`), above `VX_CACHE_SCOPE`, `.vx/workspace`'s and `github()`'s. A name `cache.scope` would refuse is refused. `--cache-scope=<name>` form too. Never folded into a cache key. |
486
495
  | `--cache-dir <path>` | value | `VX_CACHE_DIR` / `cache.dir` / `~/.vx/<id>/cache` | Cache directory override, resolved relative to cwd (absolute paths used as-is). Beats `VX_CACHE_DIR`, the workspace's `cache.dir` and the default; like them, the directory then holds the whole cache, shared through no store, for every cache the run opens, so the workspace's default dir is not created beside it. A per-run knob — never folded into a cache key. `--cache-dir=<path>` form too; the space form rejects a value starting with `-`. A directory this user cannot write into fails the run before any task with `cache directory <path> is not writable (EACCES: …)` — every run records its history there. |
487
496
  | `--retries <n>` | value | `run.retries`, else `0` | Re-run a failed task up to `n` more times; never a persistent one. A task's own `exec.retries` wins (even an explicit `0`). Never affects cache keys. `--retries=<n>` form too. |
488
497
  | `--continue[=<mode>]` | value | `deps-ok` | What a failed task takes down with it. `never` stops dispatch on the first failure; `deps-ok` (default) skips only its dependents; `always` (bare `--continue`) runs dependents anyway. See § Failure propagation. |
489
498
  | `--timeout <duration>` | duration | `run.timeout`, else none | A task's time limit (`30s`, `10m`), for tasks without their own `exec.timeout`, which always wins. A runaway task is killed + `failed`. A bare number is refused naming the forms with units. Never affects cache keys. `--timeout=<duration>` form too. |
490
- | `--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. |
491
- | `--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. |
492
- | `--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. |
493
499
  | `--output-logs <mode>` | value | `VX_RUN_OUTPUT_LOGS`, else flow-derived | `full` \| `errors-only` \| `hash-only` \| `none` — explicit output override. See § `--output-logs`. `--output-logs=<mode>` form too. |
494
500
  | `--cache-download <mode>` | value | `cache.download`, else `all` | `all` \| `toplevel` \| `none` — where a REMOTELY-executed task's outputs land. `none` leaves them in the remote CAS and fetches lazily, only when a locally-placed task needs them. Never affects cache keys. See § `--cache-download`. `--cache-download=<mode>` form too. |
495
501
  | `--verbosity <n>` | int (0+) | `0` | `1` or more prints a per-task table (groups left out) after the framed blocks, above the footer. `--verbosity=<n>` form too. |
496
502
  | `--dry[=text\|json]` | optional value | off | Print the task graph + predicted cache hit/miss; skip execution. `VX_DEBUG_TIMING=1` prints the stage table here as it does for a run. |
497
- | `--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. |
498
- | `--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. |
499
- | `--summarize[=<path>]` | optional value | off | Write per-run JSON to `<cacheDir>/runs/<run_id>.json` (or the explicit path). |
500
503
  | `--profile[=<path>]` | optional value | off (`profile.json` when set) | Write Chrome-trace JSON of the run's wallclock spans. |
501
- | `--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. |
502
- | `--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). |
503
- | `--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. |
504
504
 
505
505
  Mutual exclusion:
506
506
 
507
- - `--dry` and `--graph` — both skip execution; pick one.
508
- - `--dry` or `--graph` with `--summarize`, `--profile`, `--report` or
509
- `--report-file` — each needs a real run to write about. `--report` and
510
- `--report-file` were accepted and silently wrote nothing until item 992.
507
+ - `--dry` with `--profile` — the profile needs a real run to write
508
+ about.
511
509
 
512
510
  Unknown flags are a parse error (`unknown flag: --foo`), naming the
513
511
  nearest flag the verb accepts when one is within two edits
@@ -531,13 +529,10 @@ name is the verb, and would change what `vx build` means the day one
531
529
  was declared.
532
530
 
533
531
  **Optional-value flags take their value with `=` only.** `--affected`,
534
- `--exclude-dependencies`, `--dry`, `--graph`, `--summarize`, `--profile`,
535
- and `--report` are all valid bare, so a following word is
536
- read as a task name (a `--graph` word ending `.svg`, `.png`, `.json`
537
- and the like is refused, see § Turbo and Nx flags) — `vx run --affected build` means "run
538
- `build`, affected scope", and there is no way to tell that apart from
539
- "`build` is the git base". Write `--graph=out.dot` or
540
- `--affected=origin/main`. Getting it wrong is loud, not silent: the value
532
+ `--exclude-dependencies`, `--dry` and `--profile` are all valid bare,
533
+ so a following word is read as a task name — `vx run --affected build`
534
+ means "run `build`, affected scope", and there is no way to tell that
535
+ apart from "`build` is the git base". Write `--affected=origin/main`. Getting it wrong is loud, not silent: the value
541
536
  becomes a positional that matches no project, so the run refuses to
542
537
  start (see "Every requested name must resolve" above).
543
538
 
@@ -547,9 +542,9 @@ Pass bare `--exclude-dependencies` for the first, omit the flag for the
547
542
  second.
548
543
 
549
544
  Value flags (`--filter`, `--concurrency`, `--output-logs`,
550
- `--verbosity`, `--cache-dir`, `--report-file`, …)
545
+ `--verbosity`, `--cache-dir`, …)
551
546
  accept both `--flag value` and `--flag=value`. In the space form,
552
- `--cache-dir` and `--report-file` reject a value
547
+ `--cache-dir` rejects a value
553
548
  starting with `-`: that is always a swallowed flag (an unquoted empty
554
549
  shell variable), never a path or task id. Use the `=` form for a literal
555
550
  leading dash.
@@ -559,15 +554,13 @@ leading dash.
559
554
  fractional (`2.7`), signed (`+4`) and space-padded forms, plus anything
560
555
  past `2^53` (it would parse to a number you did not type). These all
561
556
  used to be silently reinterpreted — `--concurrency 0x10` ran 16
562
- workers. A duration flag (`--timeout`, `--kill-grace`,
563
- `--teardown-timeout`, `--ready-notice`) takes its unit (`500ms`, `30s`,
557
+ workers. A duration flag (`--timeout`) takes its unit (`500ms`, `30s`,
564
558
  `10m`): a bare `500` is refused, naming both readings.
565
559
 
566
- An empty `=` value means "no value" on four flags, which then act like
567
- their bare forms: `--profile=` writes `profile.json`, `--summarize=`
568
- writes `<cacheDir>/runs/<run_id>.json`, `--graph=` prints to stdout and
560
+ An empty `=` value means "no value" on two flags, which then act like
561
+ their bare forms: `--profile=` writes `profile.json` and
569
562
  `--affected=` uses the default base. Every other flag refuses an empty
570
- value: `--dry=`, `--report=`, `--continue=` and
563
+ value: `--dry=`, `--continue=` and
571
564
  `--exclude-dependencies=` as well as the ones with no bare form
572
565
  (`--retries=`, `--timeout=`, `--cache-dir=`, `--filter=`, `--cache-policy=`).
573
566
 
@@ -602,10 +595,13 @@ So `--no-cache` always wins over `--force`. The common cases:
602
595
  - `--no-cache` → nothing reads, nothing writes, and declared output
603
596
  globs are left untouched (you're debugging; vx won't manage your
604
597
  tree).
605
- - `--force` → re-execute every task (reads off) but still write fresh
606
- artifacts to both layers (writes on). Output globs ARE cleaned
607
- before each task so the saved snapshot is clean. This is the "rebuild
608
- and refresh the cache" flag.
598
+ - `--force` → re-execute every task (reads off) with writes on. An
599
+ entry is immutable: a key the local cache already holds is kept, and
600
+ each remote that can batch (`hasMany`) is asked once, in the
601
+ background, which keys it holds; one that cannot is asked `has` per
602
+ key before its upload. Held keys are not uploaded again (a probe that
603
+ fails uploads, as before). Output globs ARE cleaned before each task,
604
+ so the disk holds this run's outputs.
609
605
 
610
606
  `--cache-policy=<spec>` is a comma-separated list of `<layer>:<flags>`
611
607
  segments. `layer` is `local` or `remote`; `flags` is any subset of `r`
@@ -625,8 +621,9 @@ layer keeps its current value. Both `--cache-policy=<spec>` and the space form
625
621
  a remote hit is still delivered (the artifact has to land on disk to be
626
622
  extracted), it just never short-circuits the remote read.
627
623
 
628
- Combine with `--force` for "re-execute and refresh only the remote":
629
- `--cache-policy=local: --force` (local off, reads off, remote write-only).
624
+ Combine with `--force` for "re-execute and fill only the remote":
625
+ `--cache-policy=local: --force` (local off, reads off, remote write-only;
626
+ keys the remote already holds are not sent).
630
627
 
631
628
  Invalid layers/flags are a parse error
632
629
  (`--cache-policy: invalid layer 'disk'`, `--cache-policy: invalid flag 'x'`, …);
@@ -691,8 +688,7 @@ What the shapes mean in each column:
691
688
  everything, the blocker included (`⊘ skipped app#deploy • blocked by
692
689
  lib#build`; a fail-fast skip, which nothing blocked, carries no
693
690
  suffix). Every view that lists tasks prints it, a dependency's
694
- included: nothing prints below the footer. `--report` reads the same fact into the status cell,
695
- `skipped (blocked by lib#build)`.
691
+ included: nothing prints below the footer.
696
692
  - **`frame, or one-liner if quiet`** — a cache hit with stored stdout is
697
693
  worth a frame (the output is the point); a hit with nothing to replay
698
694
  compresses to one line, which is what keeps a 2000-task warm run
@@ -746,7 +742,7 @@ tracks the run live. Top to bottom:
746
742
  persistent tasks it depends on, until it — or, with several, the
747
743
  first of them — exits (one kept only as a dependency that exits 0,
748
744
  a daemon that forked and returned, does not end it); the rest still up are then torn
749
- down (SIGTERM, `run.killGrace`, SIGKILL), one status line names
745
+ down (SIGTERM, a 2 s grace, SIGKILL), one status line names
750
746
  the task and its code (`vx: app#dev exited with code 1; stopping 1
751
747
  other persistent task`), and a non-zero exit makes the run exit 1
752
748
  and is what `vx last` records.
@@ -762,16 +758,43 @@ other persistent task`), and a non-zero exit makes the run exit 1
762
758
  3. **Worker rows** — one per worker slot that has been busy (at most
763
759
  `min(concurrency, 10)`), no glyph and no spinner: the live ticking
764
760
  elapsed time leads (` 568ms running <id>`). A task stays in
765
- its row for its whole life; a row whose task ended holds its place
766
- dimmed (`idle`), so nothing ever jumps; overflow shows as `+k more`. A second in, a row
767
- says what waits on it: `critical path` (yellow) on the running task
761
+ its row for its whole life, so nothing jumps; overflow shows as
762
+ `+k more`. A second in, an idle worker row holds a task an
763
+ unfinished dependency blocks, one per row, in the order the
764
+ scheduler starts them (most dependents first):
765
+ `blocked solid-js#build by solid-js#types` (dim `blocked`, the id,
766
+ then the running task that holds it back). The task starts in the
767
+ row that held it. A row with no blocked task left to hold leaves the
768
+ region and the rows below close up; blocked tasks only dwindle, so
769
+ it never comes back. A running row says what waits on it:
770
+ `critical path` (yellow) on the running task
768
771
  the longest unfinished chain waits on, by the same p50s as the
769
772
  forecast below and only while two or more run, and a dim
770
773
  `blocks N`, its unfinished dependents direct and transitive. Where
771
774
  local history knows the task, its row also shows its usual duration
772
775
  (its executed p50) in the cache column, fixed while the elapsed time
773
776
  counts up toward it (`3.33s running ~5.2s solid-js#types`).
774
- 4. **The live summary section** — the SAME meters the final footer
777
+ 4. **vx's own steps** — what vx does around the tasks that the run
778
+ waits on, before its first task starts or before it ends, as rows of
779
+ their own below the worker rows: `vx:load` (configs and graph from
780
+ the lock), `vx:run-lock`, `<plugin>:setup`, `vx:keys` (keys and local
781
+ probes), `<plugin>:schedule`, then after the graph `vx:uploads`,
782
+ `vx:history`, `<plugin>:telemetry`, `<plugin>:teardown`,
783
+ `vx:snapshots`, `vx:retention`, `vx:close` and `vx:sandbox`. A step
784
+ shows once it has run 150 ms, ticking like a task
785
+ (` 1.2s running internal vx:uploads`); no worker slot or
786
+ `--concurrency` holds it back. When it ends it leaves a list row
787
+ like a task's, `internal` in the cache column
788
+ (`⏺︎ 4.60s success internal vx:uploads`); a quicker one shows
789
+ nowhere. A step that runs beside the tasks, `vx:prefetch` (a
790
+ remote's downloads), never shows: `vx last` lists it. `:` is no task
791
+ id's separator, so a step never reads as a task: a project named
792
+ `vx` keeps `vx#build`. No step is a task: no count, bar, history
793
+ row, forecast, flaky verdict or telemetry record holds one;
794
+ `--profile` and `VX_DEBUG_TIMING` time vx's stages their own way.
795
+ Steps show only beside a live region; a log (CI, a pipe) prints
796
+ none.
797
+ 5. **The live summary section** — the SAME meters the final footer
775
798
  prints (`tasks` + `cache` bars with legends, `time`), filling in as
776
799
  the run progresses, under a bare `vx` wordmark rule. A second in,
777
800
  the `time` row adds what is left (`time 1.41s · ~3s left`): each
@@ -814,11 +837,11 @@ redraws within 30 ms of the last draw coalesce into a single
814
837
  trailing draw when the floor expires (the final state always lands).
815
838
  On a 3,270-task warm run this cuts ~6.7 MB of redraw ANSI to ~20 KB.
816
839
 
817
- Identity coloring: every `project#task` renders its project half in
818
- a stable hue hashed from the project name (same project = same color
819
- in every run and every surface) and its task half in a fixed pink —
820
- both deliberately outside the status palette, so an id can never
821
- read as an outcome.
840
+ Identity coloring: each half of a `project#task` renders in a stable
841
+ hue hashed from its own name (same project = same color, same target
842
+ name = same color, in every run and every surface: every `#build`
843
+ matches), with a dim `#` between, all outside the status palette, so
844
+ an id can never read as an outcome.
822
845
 
823
846
  On GitHub Actions (`GITHUB_ACTIONS` truthy, full output mode), each
824
847
  task's block is wrapped in `::group::<id> (<outcome> <duration>)` /
@@ -890,22 +913,22 @@ Locally-executed tasks always write in place and ignore the flag, and
890
913
  it in either direction. **It never affects a cache key**: transfer
891
914
  tuning cannot change what a command produces.
892
915
 
893
- One safety gate: a task whose outputs another task's `cache.inputs`
916
+ One safety gate: a task whose outputs another task's `inputs`
894
917
  globs could read on disk is silently kept eager, because deferring it
895
918
  would make that key depend on whether the bytes arrived. A task with no
896
919
  `cache` that a cached task depends on counts as reading its whole
897
920
  project (C-16). `--dry` reports
898
921
  how many tasks would keep their outputs remote and names each downgrade,
899
922
  so a run that defers nothing says why. A run in which any task declares a
900
- `cache.inputs.runtime` / `workspaceRuntime` command defers **nothing**:
923
+ `inputs.runtime` / `workspaceRuntime` command defers **nothing**:
901
924
  a shell command's reads cannot be bounded, so vx cannot prove it will
902
925
  not read a deferred output (the same reason vx refuses to infer inputs
903
926
  by tracing). When bytes are fetched later, vx saves an ordinary
904
927
  cache entry for them, so the next run is a plain local hit.
905
928
 
906
- ## Planning mode (`--dry`, `--graph`)
929
+ ## Planning mode (`--dry`)
907
930
 
908
- Both flags short-circuit execution. They build the full task graph,
931
+ `--dry` short-circuits execution. It builds the full task graph,
909
932
  compute every task's cache key, and probe the cache to predict the
910
933
  hit/miss outcome. Against a remote cache the probe is a lightweight
911
934
  existence check — planning never downloads or ingests artifacts.
@@ -913,8 +936,8 @@ existence check — planning never downloads or ingests artifacts.
913
936
  ```
914
937
  $ vx run ci --dry
915
938
  would run:
916
- ◉ @vzn/vx#format-check cache hit (local) 02bfe8a9
917
- ◉ @vzn/vx#lint cache hit (local) d66cfed2
939
+ ✓ @vzn/vx#format-check cache hit (local) 02bfe8a9
940
+ ✓ @vzn/vx#lint cache hit (local) d66cfed2
918
941
  ▶ @vzn/vx#test cache miss — would exec 68595e49 ~72.64s
919
942
 
920
943
  3 task(s) planned, 2 cache hits (2 local), 1 would run.
@@ -947,11 +970,11 @@ Status legend:
947
970
 
948
971
  | Symbol | Meaning |
949
972
  | ------ | ------------------------------------------------------------------------------------------ |
950
- | `◉` | cache hit (local) — entry already in `<cacheDir>/` |
973
+ | `✓` | cache hit (local) — entry already in `<cacheDir>/` |
951
974
  | `↓` | cache hit (remote) — entry would be fetched from the layer |
952
975
  | `▶` | cache miss — task would execute (under `--force` too: nothing is read, what runs is saved) |
953
- | `·` | no-cache — task opts out (no `cache` block, or `--no-cache`) |
954
- | `∅` | `@noop` — task would not run anywhere |
976
+ | `∅` | no-cache — task opts out (no `cache` block, or `--no-cache`) |
977
+ | `·` | `@noop` — task would not run anywhere |
955
978
  | `−` | not started — a server you did not request whose every dependant restores a local hit |
956
979
  | `○` | group task (suppressed in human view; in DOT + JSON) |
957
980
 
@@ -998,8 +1021,8 @@ $ vx run build lint --affected=main --dry
998
1021
  ```
999
1022
 
1000
1023
  `kind` is `input` (a changed `file` is a declared input), `project` (its
1001
- project changed and the task reads it whole: an uncached task, or a changed
1002
- `file` no cached task of the project declares, often an output git does not
1024
+ project changed and the task reads it whole: a task with no `inputs`, or a
1025
+ changed `file` no task of the project declares, often an output git does not
1003
1026
  ignore; no `file` for a config import), `workspace` (a workspace-wide
1004
1027
  `file` changed: the workspace config, or a root file a plugin claims such
1005
1028
  as a lockfile, reached every task of the project),
@@ -1013,150 +1036,12 @@ against: a bare `--affected` guesses it (`origin/HEAD`, a trunk branch,
1013
1036
  else `HEAD~1`), so a reason that names no file may come from a commit
1014
1037
  the base reaches, a root `package.json` edit among them.
1015
1038
 
1016
- ### Task graph (`--graph`)
1039
+ ## Run artifacts (`--profile`)
1017
1040
 
1018
- `--graph` prints Graphviz DOT (stdout by default; `--graph=path`
1019
- writes a file):
1020
-
1021
- ```
1022
- vx run ci --graph | dot -Tsvg > graph.svg
1023
- vx run ci --graph=graph.dot
1024
- ```
1025
-
1026
- Node `fillcolor` varies by predicted status (green = local hit,
1027
- sky-blue = remote hit, orange = miss, gray = no-cache, fuchsia =
1028
- group, white = not started). Edges are unstyled.
1029
-
1030
- ## Run artifacts (`--summarize`, `--profile`)
1031
-
1032
- Both flags add a side-effect after a real run completes. Errors
1041
+ `--profile` adds a side-effect after a real run completes. Errors
1033
1042
  writing the artifact are surfaced via `vx: failed to write …` but
1034
1043
  don't change the run's exit code — the run already happened.
1035
1044
 
1036
- ### `--summarize[=<path>]`
1037
-
1038
- Writes a per-run JSON file; `schemas/summary.json` is its JSON Schema:
1039
-
1040
- ```json
1041
- {
1042
- "runId": "01a0e3ef-206c-708d-95bf-c2b07211adf6",
1043
- "ok": true,
1044
- "exitCode": 0,
1045
- "startedAt": "2026-09-27T17:34:54.572Z",
1046
- "endedAt": "2026-09-27T17:34:54.586Z",
1047
- "totalMs": 14.123643,
1048
- "savedMs": 9,
1049
- "tasks": [
1050
- {
1051
- "id": "a#build",
1052
- "project": "a",
1053
- "task": "build",
1054
- "status": "cache-hit",
1055
- "exitCode": 0,
1056
- "durationMs": 3,
1057
- "hash": "48007ccadd42ed7d",
1058
- "storedDurationMs": 9,
1059
- "storedCpuMs": 2.059,
1060
- "wallclockStartNs": "9456491",
1061
- "wallclockEndNs": "12902888"
1062
- }
1063
- ],
1064
- "aborted": [],
1065
- "summary": {
1066
- "successful": 1,
1067
- "failed": 0,
1068
- "skipped": 0,
1069
- "cachedLocal": 1,
1070
- "restoredLocal": 0,
1071
- "restoredRemote": 0,
1072
- "upToDate": 1,
1073
- "cachedRemote": 0,
1074
- "aborted": 0,
1075
- "total": 1
1076
- }
1077
- }
1078
- ```
1079
-
1080
- `runId` is a UUIDv7. Default path: `<cacheDir>/runs/<run_id>.json`. hrtime fields are
1081
- strings (bigints serialized as strings) to preserve ns precision
1082
- through JSON. `cpuMs` / `peakRssBytes` are what the task's own
1083
- execution used and appear on executed rows only (`peakRssBytes` only
1084
- when the task's peak rose above vx's own footprint — a lighter task's
1085
- figure would be vx's, handed back by the kernel; a Linux sandboxed task
1086
- reports neither, since what bwrap's pid namespace used never reaches vx);
1087
- `restored` appears on a hit only: `true` when its outputs were restored,
1088
- `false` when they were already up to date. A hit's `durationMs`
1089
- is the restore it cost, and what the PRODUCING execution used rides the
1090
- artifact and appears under its own keys, `storedDurationMs` /
1091
- `storedCpuMs` / `storedPeakRssBytes` (the work the hit skipped) — a
1092
- remote worker's peak is never presented as this run's. `savedMs` is
1093
- their sum over the hits, the footer's `saved` as a number. `admissionHeldMs` appears on a row
1094
- only when an `admit` policy (a plugin's — `@vzn/vx-schedule-history`
1095
- packs learned reservations) refused the task while a worker was free:
1096
- the wait from that first refusal to its dispatch, the plugin's hand on
1097
- the run. The footer's `info` row sums the waits, task-seconds:
1098
- `admit held 3 tasks, 4.2s in all` (85 held tasks can read 4783s beside
1099
- a 160 s run).
1100
-
1101
- **`ok` / `exitCode`** are the run's verdict — the same value the CLI
1102
- exits with: a stopping signal's code (130 for Ctrl-C), and with a kept
1103
- server, its exit (the file is written again when the server ends). Gate on these rather than re-deriving a pass from the
1104
- buckets: a run can be red without a single failed task (see `aborted`).
1105
-
1106
- **`tasks[]` and `summary` describe the same population**, so
1107
- `tasks.length === summary.total` always holds. Group tasks (no `exec`)
1108
- are in neither — they do no work.
1109
-
1110
- **`aborted[]`** lists tasks whose attempt ended while the run was
1111
- stopping (Ctrl-C, a signal to vx), with their exit code, and tasks the stop reached before they started, with
1112
- exit 1, `durationMs: 0` and no `wallclockStartNs`. Neither joins an
1113
- outcome bucket or `total`, but they make the run red, so they are
1114
- listed separately and counted as `summary.aborted`.
1115
-
1116
- **`noCache: true`** marks a task no cache answered for: it declares no
1117
- `cache` block (it executes every run by design), or the run's `--cache-policy`
1118
- read and wrote nothing (`--no-cache`), so a hit rate should leave it out
1119
- of the denominator. The key is present only when true; every other row is
1120
- unchanged. Its `hash` is still set: dependents fold it.
1121
-
1122
- **`notReady`** is present only on a failed persistent task: why it never
1123
- became ready — `timeout` (the readiness deadline fired; `exitCode` is
1124
- the kill's, 143 or 137 after the grace, and 143 for a server that
1125
- traps the TERM and exits 0, as an ordinary timeout's),
1126
- `exited` (the child exited first; `exitCode` is then its own) or `spawn`
1127
- (the spawn itself failed). Every label reads it, `failed (never ready:
1128
- timed out, exit 143)`. A server the run's stop (a Ctrl-C) killed while it started is
1129
- `aborted`, not failed, as any task the stop kills; so is one that
1130
- became ready only after the stop.
1131
-
1132
- **`sandboxViolations`** is present only on a sandboxed task with a
1133
- SANDBOX VIOLATIONS section — the count of its denials (vx's own notes
1134
- there count none), and the reason the task failed (its exit is forced to 1
1135
- when it was 0); every label counts it, `failed (exit 1, 2 sandbox
1136
- violations)`.
1137
-
1138
- **`timedOut: true`** is present only on a `failed` row vx's own
1139
- `timeout` killed: its exit is the shell's 143, and every label reads
1140
- `failed (timed out, exit 143)` rather than a signal.
1141
-
1142
- **`blockedBy`** is present only on a `skipped` row: the id of the failed
1143
- (or aborted) task at the root of what blocked it, through any chain of
1144
- skips between — what the skipped row prints, for a script.
1145
- A fail-fast skip has none.
1146
-
1147
- **`flaky: { passes, failures, attempts }`** is present only on a task
1148
- this run proved flaky (its row's `flaky` note, typed): `passes` and
1149
- `failures` count the outcomes on record for this exact `hash`, this run
1150
- included, and `attempts` is what this run took. A consumer gating on
1151
- `status: "failed"` can tell a break (no `flaky` key) from a flake
1152
- without reading the history. Every other row is byte-identical.
1153
-
1154
- **`durationMs` is always what THIS run spent on the task.** For a cache
1155
- hit that is the probe + restore, not the exec time the entry was stored
1156
- with — so it is small even for an expensive task. The work a hit
1157
- _skipped_ is a different number; `--report`'s "N saved" is the surface
1158
- that reports it.
1159
-
1160
1045
  ### `--profile[=<path>]`
1161
1046
 
1162
1047
  Writes a Chrome-trace JSON of every task's wallclock span. Open in
@@ -1192,91 +1077,6 @@ from the per-task `hrtime.bigint()` spans the runner captures.
1192
1077
 
1193
1078
  Default path: `profile.json` (cwd-relative).
1194
1079
 
1195
- ### `--report[=markdown]`
1196
-
1197
- At the end of a real run, prints a markdown run report to **stdout**,
1198
- just above the footer: nothing prints below the footer. The report
1199
- itself is machine-clean. One header line of
1200
- totals plus a table, one row per task:
1201
-
1202
- ```markdown
1203
- ## vx run — passed
1204
-
1205
- **3 tasks** · 3 success · 0 failed · 2 cached (1 up-to-date, 1 restored) · 1.23s total · 2.65s saved
1206
-
1207
- | Task | Status | Cache | Duration |
1208
- | --------- | ------- | ---------- | -------- |
1209
- | web#build | success | miss | 1.23s |
1210
- | web#test | success | local | 5ms |
1211
- | api#test | success | up-to-date | 3ms |
1212
- ```
1213
-
1214
- `Status` is the task outcome (`success` / `failed (exit N)`, with the
1215
- signal an exit above 128 stands for, `failed (exit 137, 128 + SIGKILL)`,
1216
- or a timeout's reason, `failed (timed out, exit 143)`, a persistent
1217
- task's `never ready: …`, or a sandboxed task's violation count, or
1218
- `skipped`, naming what blocked it: `skipped (blocked by lib#build)`);
1219
- `Cache` is its provenance (`miss` / `no-cache` for a task no cache
1220
- answered for — no `cache` block, or a `--no-cache` / `--cache-policy=local:` run —
1221
- which never consulted one / `local` / `remote` / `up-to-date` /
1222
- `—`). A stopped run (a Ctrl-C teardown) reads `## vx run — interrupted`,
1223
- and its tasks keep the terminal's words: one the signal killed is
1224
- `aborted` with its time so far, one the stop reached before it ran is
1225
- `not run` with no time. Both count in the header's task total, listed
1226
- as `1 aborted · 1 not run`, so a red report with no failing row still
1227
- says why. Group tasks (no `exec`) get neither — they are not
1228
- work, and the header's counts match the terminal summary and
1229
- `--summarize` exactly.
1230
-
1231
- The two durations in the header mean different things, and the
1232
- distinction is the point:
1233
-
1234
- - **`N total`** sums `Duration` over the tasks that actually EXECUTED,
1235
- an aborted task's time so far included — the time this run spent.
1236
- - **`N saved`** sums the exec times the cache hits SKIPPED, read from
1237
- each entry as it was stored (above, 2.01s and 640ms). It is
1238
- deliberately not the hits' `Duration` column, which is the restore
1239
- they cost this run: summing that reported a task taking 2.01s cold
1240
- as "6ms saved".
1241
-
1242
- Only `markdown` is supported today (`json` is reserved; a bad value is a
1243
- parse error). Built purely from the run's outcomes after it returns — it
1244
- adds zero cost when both flags are absent.
1245
-
1246
- ### `--report-file <path>`
1247
-
1248
- Writes the same markdown report to a file instead of (or as well as)
1249
- stdout, and is the form to use for a CI step summary:
1250
-
1251
- ```sh
1252
- vx run ci --report-file="$GITHUB_STEP_SUMMARY"
1253
- ```
1254
-
1255
- **Do not redirect stdout for this.** The report itself is machine-clean,
1256
- but stdout is not vx's alone — the status logger writes frames, meter
1257
- bars and `::group::` workflow commands to the same stream, so
1258
- `--report=markdown >> "$GITHUB_STEP_SUMMARY"` puts the entire run log
1259
- into the step summary above the table.
1260
-
1261
- The report is **appended**, never truncated: `$GITHUB_STEP_SUMMARY` is a
1262
- shared, append-only file that other steps in the same job also write to,
1263
- so overwriting it would silently discard their content. Passing both
1264
- flags writes the report to stdout AND appends it to the file. A run
1265
- stopped by Ctrl-C or SIGTERM still writes both, its stopped tasks
1266
- `aborted`. A write
1267
- failure is reported (`vx: failed to write report to …`) but does not
1268
- change the exit code — the run already happened, the same contract
1269
- `--summarize` and `--profile` follow.
1270
-
1271
- ### `--tag <k=v>`
1272
-
1273
- Labels the invocation. Repeatable; `--tag=k=v` form too. The pair is
1274
- split on the **first** `=`, so values may contain `=` (e.g. a URL). An
1275
- empty key is a parse error. Tags are recorded on the run's
1276
- `invocations` row so dashboards can filter runs by label, and reach
1277
- telemetry as `vx.tag.<key>`; a secret value in one is masked there as
1278
- in the task's output (see [Masking](./schema.md)).
1279
-
1280
1080
  ## Sandbox
1281
1081
 
1282
1082
  Sandbox isolation is opt-in **per task** via an `exec.sandbox` block in
@@ -1298,7 +1098,8 @@ export default {
1298
1098
  },
1299
1099
  },
1300
1100
  },
1301
- cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } },
1101
+ inputs: { files: ['src/**'] },
1102
+ cache: { outputs: { files: ['dist/**'] } },
1302
1103
  },
1303
1104
  },
1304
1105
  }
@@ -1322,7 +1123,7 @@ task's `SANDBOX VIOLATIONS` section ends with a dimmed `fix:` line,
1322
1123
  the grants that would have allowed each denial, project-relative:
1323
1124
  `fix: exec.sandbox.allow: { read: ['tsconfig.json'], write: ['dist/'] }`.
1324
1125
  A write grants the top directory it landed in, so the next file there
1325
- passes too, unless `cache.inputs.files` reads that directory: then it
1126
+ passes too, unless `inputs.files` reads that directory: then it
1326
1127
  grants the file alone, so the task cannot write its own inputs; a read the output depends on is a key input as well, and
1327
1128
  the line says so, since a grant never adds one. A credential path
1328
1129
  (`~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.kube`, `~/.config/gh`,
@@ -1417,8 +1218,10 @@ run...` precedes it.
1417
1218
  not change since the loop last saw it is not an edit (a `chmod` is one,
1418
1219
  as the key reads the executable bit), nor is a directory
1419
1220
  whose entries (names and sizes) did not, nor a path that stayed
1420
- gone; a symlink is its target string, as the key folds it, so a
1421
- retarget is an edit; a path the loop has never judged is an edit only if it was
1221
+ gone; a symlink is its target string and what it points to, as the
1222
+ key folds them, so a retarget is an edit, and so is an edit behind
1223
+ it: watch arms each link's target, wherever it lies, and names the
1224
+ link; a path the loop has never judged is an edit only if it was
1422
1225
  modified after the watchers went live (macOS delivers the initial
1423
1226
  run's own writes after the arm; the later of the path's mtime and
1424
1227
  ctime says which side of it a path belongs to, so a file moved in
@@ -1449,7 +1252,7 @@ run...` precedes it.
1449
1252
  `.gitignore` as the remedy (a persistent task declares no outputs;
1450
1253
  item 948). A file git tracks is never blamed on the server: three
1451
1254
  saves of a source file are the user's (WD-7). When any project's config declares
1452
- `cache.inputs.workspaceFiles`, the per-project watchers are swapped
1255
+ `inputs.workspaceFiles`, the per-project watchers are swapped
1453
1256
  for ONE recursive root watcher (boundaries are off for those globs,
1454
1257
  so a root-relative glob can name a file anywhere). That watcher
1455
1258
  hears every write in the tree and keeps only the ones a key can
@@ -1497,7 +1300,8 @@ Always ignored (no re-trigger):
1497
1300
 
1498
1301
  - `node_modules/`, `.git/`, `.vx/cache/` anywhere in the path.
1499
1302
  - A path git ignores (`git check-ignore`, asked once per debounce
1500
- window, never per event): invisible to every cache key — inputs are
1303
+ window, never per event), unless a task's inputs name it outright
1304
+ (`.env.local`, `dist/**`): invisible to every other cache key — inputs are
1501
1305
  tracked + untracked-not-ignored — so a cycle it started could change
1502
1306
  nothing. A tracked file that matches a pattern is not ignored, by
1503
1307
  git's own rule; outside a repository nothing is.
@@ -1509,7 +1313,7 @@ Always ignored (no re-trigger):
1509
1313
  cycle, which writes again — a loop that never settles.
1510
1314
 
1511
1315
  Everything else triggers a cycle. We deliberately don't filter events
1512
- against per-task `cache.inputs.files` — the cache hash is the source
1316
+ against per-task `inputs.files` — the cache hash is the source
1513
1317
  of truth. A change to an irrelevant file produces a cache-hit run
1514
1318
  (typically tens of ms); the cost is much smaller than the engineering
1515
1319
  cost of a per-event glob match.
@@ -1553,10 +1357,10 @@ re-reads the watched set (item 971).
1553
1357
  The following flags are rejected (parser exits 1 before the initial
1554
1358
  run):
1555
1359
 
1556
- - `--dry` / `--graph` — those skip execution; nothing to watch.
1557
- - `--summarize` / `--profile` — would overwrite their target per cycle.
1558
- - `--report` / `--report-file` / `--verbosity <n>` (n > 0) / `--format json` — all
1559
- format ONE run's result; a watch loop has no single run to report. (`--verbosity 0`
1360
+ - `--dry` — it skips execution; nothing to watch.
1361
+ - `--profile` — would overwrite its target per cycle.
1362
+ - `--verbosity <n>` (n > 0) — formats
1363
+ ONE run's result; a watch loop has no single run to report. (`--verbosity 0`
1560
1364
  is accepted: it asks for what watch already prints.)
1561
1365
 
1562
1366
  A requested persistent task (`exec.persistent`, a dev server) starts
@@ -1581,7 +1385,7 @@ re-runs itself and what it depends on.
1581
1385
  - `0` — clean Ctrl+C / SIGTERM exit. A cycle in flight is torn down
1582
1386
  first — its children, and the dev server held between cycles, get the
1583
1387
  signal watch received (a Ctrl-C as SIGINT; a SIGTERM or SIGHUP as SIGTERM),
1584
- `run.killGrace` (2 s), then SIGKILL — and the process leaves only
1388
+ a 2 s grace, then SIGKILL — and the process leaves only
1585
1389
  once it has returned, so a CI cancellation never orphans a task.
1586
1390
  Also `0`, with no watch, when `--affected` selects nothing: watch
1587
1391
  prints `vx watch: nothing affected since <ref>` and exits as `vx run`
@@ -2075,11 +1879,16 @@ manager is its lockfile (`pnpm-lock.yaml`, `bun.lock`, `yarn.lock`,
2075
1879
  `package-lock.json`), else `packageManager`, else npm; the field it is
2076
1880
  listed in is kept (`--save-dev`, `--dev`, …), and pnpm at a workspace
2077
1881
  root adds `--workspace-root`. The lockfile is updated by that manager.
2078
- Run inside a project that installs `@vzn/vx`, a global vx hands
2079
- `vx upgrade` to the project's own (§ Which vx runs), so the project's
2080
- dependency is what moves.
2081
- vx prints what it runs, runs it there, and exits with an error if it
2082
- fails:
1882
+ Every `@vzn/vx-*` plugin the same package.json lists from the registry
1883
+ moves in the same install, to the same release, since every @vzn
1884
+ package releases together: `pnpm add @vzn/vx@0.0.2 @vzn/vx-otel@0.0.2
1885
+ --save-dev`. A plugin left behind is installed even when core is
1886
+ already at the release (`› vx upgrade plugins → 0.0.2`); a plugin in
1887
+ another field than core is refused, so no install moves a dependency's
1888
+ field. The versions are pinned, so pnpm's `minimumReleaseAge` refuses a
1889
+ release too young rather than mixing releases, and the error names
1890
+ `minimumReleaseAgeExclude`. vx prints what it runs, runs it there, and
1891
+ exits with an error if it fails:
2083
1892
 
2084
1893
  ```
2085
1894
  › vx upgrade 0.0.1 → 0.0.2
@@ -2087,6 +1896,28 @@ fails:
2087
1896
  bun add @vzn/vx@0.0.2 in /repo
2088
1897
  ```
2089
1898
 
1899
+ **Inside a project with its own vx**, a global vx hands `vx upgrade` to
1900
+ the project's (§ Which vx runs), and one run moves both to the same
1901
+ version (`TAG`, or latest): first the project's dependency, then the
1902
+ global vx that started the run, each the way it was installed. Each
1903
+ line names its install, and a refusal names the one that failed
1904
+ (`vx upgrade: global vx /usr/local/bin/vx: cannot write to …`); a
1905
+ project that fails leaves the global as it was. The project's vx run
1906
+ directly (a package script, `node_modules/.bin/vx`) has no global behind it, so only
1907
+ the project moves.
1908
+
1909
+ ```
1910
+ › vx upgrade project 0.0.1 → 0.0.2
1911
+
1912
+ bun add @vzn/vx@0.0.2 in /repo
1913
+
1914
+ › vx upgrade global 0.0.1 → 0.0.2
1915
+
1916
+ /usr/local/bin/vx
1917
+
1918
+ ✓ vx upgrade global installed 0.0.2
1919
+ ```
1920
+
2090
1921
  vx never installs Node or a package manager: one not on `PATH` is refused with the command to run. A vx run from an npx or
2091
1922
  bunx cache has nothing installed to upgrade, and says to ask for
2092
1923
  `@vzn/vx@latest` instead.
@@ -2103,9 +1934,11 @@ or is missing.
2103
1934
  runs in, or one above it up to the git top-level, holds a
2104
1935
  `package.json`, `pnpm-workspace.yaml`, `turbo.json`, `turbo.jsonc` or
2105
1936
  `nx.json`, it runs `@vzn/vx-migrate` there, so `vx init` is the one
2106
- command (owner, 2026-10-08): the installed one (the nearest
2107
- `node_modules/@vzn/vx-migrate` up from the working directory), else
2108
- this vx's version through `bun x` (`BUN_BE_BUN=1` makes the compiled
1937
+ command (owner, 2026-10-08): the latest release through `bun x`,
1938
+ never an installed one, so init always installs the latest `@vzn/vx`
1939
+ and moves every `@vzn/vx-*` plugin the root lists to it (M,
1940
+ 2026-10-16; a source checkout runs the nearest installed
1941
+ `node_modules/@vzn/vx-migrate` instead) (`BUN_BE_BUN=1` makes the compiled
2109
1942
  binary that runtime). `--dry`, `--format`, `--force`, `--mjs`,
2110
1943
  `--native` and `--keep` pass through, and the exit is vx-migrate's.
2111
1944
  vx-migrate maps a Turbo, Nx or Vite Task repo, else the `package.json`
@@ -2201,8 +2034,8 @@ none is dropped in silence. An Nx flag's camelCase spelling (`--nxBail`,
2201
2034
  `--skipNxCache`), which Nx's parser takes and its docs print, is its
2202
2035
  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
2203
2036
  `vx run` that does the same, and the other verbs a hand types from
2204
- either tool name what does it here: `graph` (`--graph`), `ls`
2205
- (`vx show`), `query` (`--dry=json`), `reset` (`vx cache clean`), and
2037
+ either tool name what does it here: `graph` and `query` (`--dry=json`), `ls`
2038
+ (`vx show`), `reset` (`vx cache clean`), and
2206
2039
  `daemon`, `login`, `logout`, `link` and `unlink`, which vx has no use
2207
2040
  for. Nx's `project:target` (`vx run web:build`,
2208
2041
  when `web` declares `build`) is answered with `vx run web#build`: from
@@ -2221,11 +2054,10 @@ copy to the source.
2221
2054
  | turbo | `--continue=dependencies-successful` | alias | `--continue=deps-ok` |
2222
2055
  | turbo | `--continue` | same | `--continue[=never\|deps-ok\|always]` |
2223
2056
  | turbo | `--dry-run` | alias | `--dry[=text\|json]` |
2224
- | turbo | `--graph=<file>.svg\|png\|json\|html\|…` | refuse | vx writes Graphviz DOT only: `--graph=<file>.dot`, then `dot -Tsvg` |
2225
- | turbo | `--graph` | same | `--graph[=<file>.dot]` |
2057
+ | turbo | `--graph` | refuse | `--dry=json` prints the planned task graph, each task with its deps |
2226
2058
  | turbo | `--force[=<bool>]` | same | `--force`: skip cache reads, keep writes |
2227
2059
  | turbo | `--affected` | same | `--affected[=<base>]` |
2228
- | turbo | `--summarize[=<bool>]` | same | `--summarize[=<path>]` |
2060
+ | turbo | `--summarize` | refuse | use `vx last --format json` for the run's JSON record |
2229
2061
  | turbo | `--output-logs=new-only` | refuse | use `--output-logs=full` (a hit replays its log) or `errors-only` |
2230
2062
  | turbo | `--output-logs <v>` | same | `--output-logs full\|errors-only\|hash-only\|none` |
2231
2063
  | turbo | `--no-cache` | same | `--no-cache` reads nothing either; Turbo's (reads, no writes) is `--cache-policy local:r,remote:r` |
@@ -2253,9 +2085,9 @@ copy to the source.
2253
2085
  | turbo | `--dangerously-disable-package-manager-check` | refuse | vx reads no `packageManager` field: drop it |
2254
2086
  | turbo | `--env-mode <v>` | refuse | vx passes only the variables a task declares (strict): list the rest in `exec.env.passThrough` |
2255
2087
  | turbo | `--framework-inference <v>` | refuse | under `turbo()` inference is Turbo's; take a name back with a `!` entry in the task's `env` |
2256
- | turbo | `--global-deps <v>` | refuse | declare them in `cache.inputs.workspaceFiles` (under `turbo()`, turbo.json's `globalDependencies`) |
2257
- | turbo | `--json` | refuse | use `--format json` for the run's result on stdout, `--dry=json` for the plan |
2258
- | turbo | `--log-file` | refuse | use `--summarize[=<path>]` for the run's JSON record |
2088
+ | turbo | `--global-deps <v>` | refuse | declare them in `inputs.workspaceFiles` (under `turbo()`, turbo.json's `globalDependencies`) |
2089
+ | turbo | `--json` | refuse | use `vx last --format json` for the run's result, `--dry=json` for the plan |
2090
+ | turbo | `--log-file` | refuse | use `vx last --format json` for the run's JSON record |
2259
2091
  | turbo | `--preflight` | refuse | set `turboCache({ preflight: true })` or `TURBO_PREFLIGHT=1` |
2260
2092
  | turbo | `--remote-cache-timeout <v>` | refuse | set `turboCache({ timeoutMs })` or `TURBO_REMOTE_CACHE_TIMEOUT` |
2261
2093
  | turbo | `--single-package` | refuse | a repo with no workspaces is one project already: drop it |
@@ -2297,9 +2129,8 @@ copy to the source.
2297
2129
  ## Machine-readable output
2298
2130
 
2299
2131
  `vx show`, `vx info`, `vx why`, `vx last` and `vx cache clean` take
2300
- `--format json`. Each prints one JSON document on one line, as
2301
- `vx run --format json` does, whose shape is a
2302
- checked-in JSON Schema (draft 2020-12) shipped with the package:
2132
+ `--format json`. Each prints one JSON document on one line, whose shape
2133
+ is a checked-in JSON Schema (draft 2020-12) shipped with the package:
2303
2134
  `schemas/show.json`, `schemas/info.json`, `schemas/why.json`,
2304
2135
  `schemas/last.json`, `schemas/cache.json`
2305
2136
  (`node_modules/@vzn/vx/schemas/` in an install). Every object closes its
@@ -2311,13 +2142,9 @@ schemas: each `{ … }` a verb's section shows is an object its schema
2311
2142
  closes with those keys, each object the verb prints whole is shown, and
2312
2143
  `vx info`'s field list is its schema's top level.
2313
2144
 
2314
- `vx run --format json` prints, when the run ends, the `--summarize`
2315
- document (`schemas/summary.json`: `runId`, `ok`, `exitCode`, one row per
2316
- task with its status, exit code and key). Everything else the run writes,
2317
- the frame and the tasks' output with it, goes to stderr, so
2318
- `vx run build --all --format json | jq .ok` reads only the result. It
2319
- refuses `--dry` (use `--dry=json`), a bare `--graph` and
2320
- `--report=markdown`, which print on stdout too.
2145
+ A run's result as JSON is `vx last --format json`, read after the run
2146
+ from the run history: one row per task with its status, key and cache
2147
+ result. `vx run --dry=json` prints the plan.
2321
2148
 
2322
2149
  ### Error codes
2323
2150
 
@@ -2433,8 +2260,9 @@ target, fix its spelling, or drop the entry.
2433
2260
  `vx lock` refuses it, not a run: a run trusts the graph the lock holds.
2434
2261
 
2435
2262
  A `^task` with no edge source is refused too: ``<id>: ^build needs
2436
- project edges; add pnpm()/bun()/npm()/yarn() or a plugin that returns
2437
- `deps` ``. Core reads no edge from a manifest, so with no plugin
2263
+ project edges; add pnpm()/bun()/npm()/yarn() and install so its
2264
+ lockfile exists, or a plugin that returns `deps` ``. A lockfile plugin
2265
+ whose lockfile is absent gives no edges. Core reads no edge from a manifest, so with no plugin
2438
2266
  returning `deps` every `^` would resolve to nothing and fold no
2439
2267
  upstream into the key. Declare the lockfile plugin in
2440
2268
  `.vx/workspace.ts` (or a plugin of your own that returns `deps`); a run
@@ -2449,7 +2277,7 @@ its own output path, or make one depend on the other.
2449
2277
 
2450
2278
  #### `VX_E_INPUT_IS_OUTPUT`
2451
2279
 
2452
- A task's `cache.inputs` match outputs: its own (vx cleans outputs before
2280
+ A task's `inputs` match outputs: its own (vx cleans outputs before
2453
2281
  a run, so it would delete its sources) or another task's (its key must
2454
2282
  not read them; `dependsOn` already cascades). Exclude the outputs from
2455
2283
  the inputs with a `!` glob, as the message shows, or declare no outputs
@@ -2470,12 +2298,11 @@ https://github.com/vznjs/vx/issues.
2470
2298
  A plugin can give its refusal a code: `new UserError(message, 'MY_CODE')`.
2471
2299
 
2472
2300
  **Streams.** A verb whose stdout is a product (a `--format json`
2473
- document, `--dry` / `--dry=json`, `--graph`'s DOT, a completion
2301
+ document, `--dry` / `--dry=json`, a completion
2474
2302
  script) writes that product alone there; a notice or warning it meets
2475
2303
  on the way (a cache index from another vx version, a plugin's warning) goes
2476
2304
  to stderr, as every refusal does. `vx run`'s stdout is the run's frame,
2477
- the tasks' output it carries, except under `--format json`, where that
2478
- moves to stderr. `tests/cli-streams.test.ts` holds each
2305
+ the tasks' output it carries. `tests/cli-streams.test.ts` holds each
2479
2306
  product verb to it under a notice.
2480
2307
 
2481
2308
  ## `vx show`
@@ -2571,6 +2398,9 @@ or `; projects and tasks: …` (M-57).
2571
2398
 
2572
2399
  An empty target (`vx show ''`) is refused: omit it to list every project.
2573
2400
 
2401
+ In a workspace with no project, the list is empty and `vx show` says so
2402
+ on stderr (`no projects`) and names `vx init`.
2403
+
2574
2404
  Exit codes: `0` success; `1` parse error or unknown target.
2575
2405
 
2576
2406
  Two runs on one workspace take turns: the second waits for the first's
@@ -2614,7 +2444,7 @@ $ vx info
2614
2444
  memory 13 GB usable — cgroup limit; the machine has 16 GB
2615
2445
  cache dir /work/repo/.vx/cache (8.2 MB, this workspace's run history)
2616
2446
  cache store /home/me/.vx/3f2a9c1e7b4d5a60/cache (1.3 GB, shared by this repo's checkouts)
2617
- cache versions keys vx-cache-v43 · index schema v38
2447
+ cache versions keys vx-cache-v43 · index schema v40
2618
2448
  cache entries 42 (1.3 GB)
2619
2449
  orphans 3 artifacts (12 MB) the index does not know — `vx cache clean` reaps them
2620
2450
  task runs (24h) 7 (5 cache hits)
@@ -2783,7 +2613,7 @@ app#build — run 01a1193a-7b39-75a9-870b-5c03e26d7104
2783
2613
  changed file packages/app/src/input.txt 78981922613b2afb6025042ff6bd878ac1994e85 → 61780798228d17af2d34fce4cfbdf35556832472
2784
2614
 
2785
2615
  what to do:
2786
- file an edit re-runs by design; a file the task does not read belongs out of cache.inputs.files
2616
+ file an edit re-runs by design; a file the task does not read belongs out of inputs.files
2787
2617
  ```
2788
2618
 
2789
2619
  A changed key's verdict names what moved, kind and name, three at most
@@ -2824,7 +2654,7 @@ A hit's line is `cache-hit · key …` (or `cache-hit-remote`): the status
2824
2654
  names the hit and its tier, so only an executed run carries the word.
2825
2655
  A skipped task never ran and derived no key: its line is `skipped · no key`.
2826
2656
  A row's kind is what the key folded: `file` (an input file, by blob
2827
- id), `env` (a declared variable, by digest), `runtime` and
2657
+ id), `linked` (the content behind a `file` that is a symlink), `env` (a declared variable, by digest), `runtime` and
2828
2658
  `ws-runtime` (a declared command's output, `inputs.runtime` and
2829
2659
  `inputs.workspaceRuntime`), `forward` (the argv forwarded after `--`),
2830
2660
  `config` (the evaluated task config), `upstream` (a dependency's input
@@ -2839,9 +2669,9 @@ is gone (pruned, or the run failed and never saved one) the verb still
2839
2669
  names the hash change and says the component diff is unavailable. When
2840
2670
  only the previous run failed, the diff is taken against the last run
2841
2671
  that saved an entry instead, and the detail line names that run. A
2842
- task with no `cache` block derives a key too — it is what dependents
2843
- fold — but saves no entry, so for it the verb can only report the key
2844
- change and says so.
2672
+ task with `inputs` and no `cache` block derives a key too — it is what
2673
+ dependents fold — but saves no entry, so for it the verb can only report
2674
+ the key change and says so.
2845
2675
  `--format json` emits one machine-readable object (`{ taskId, runId,
2846
2676
  why, diff, roots, writers }`); when the task's recorded runs predate run ids, `{ taskId,
2847
2677
  why, diff, explanation }`, `why` and `diff` null and `explanation` the
@@ -2875,12 +2705,19 @@ command that exits 137 on its own reads the same). Where the run's
2875
2705
  footer gave a reason, the row ends with it instead: `timed out`,
2876
2706
  `never ready: exited`, `2 sandbox violations`, and for a skipped task
2877
2707
  `after lib#build failed` (the v27 columns; rows older than them read
2878
- as before). Rows run failures first, then what executed or was
2708
+ as before). A task whose key met a remote failure ends with it, read or
2709
+ write alike: `remote ✗ download: connection refused; upload: 503` (the
2710
+ v40 column, `remoteError` in `--format json`). Rows run failures first, then what executed or was
2879
2711
  skipped, then hits; past sixteen hits the rest fold into one line with
2880
2712
  their count (`… +980 more cache hits`) — a thousand-task warm run is a
2881
2713
  thousand rows otherwise, with the one failure a screen above the
2882
2714
  prompt — and the sixteen shown are the slowest restores, the one thing
2883
2715
  a hit's row tells. `--format json` lists every row.
2716
+ Below the tasks, the steps the run did not wait on (`vx:prefetch`, a
2717
+ remote's downloads beside the tasks), which its own output never shows:
2718
+ `success vx:prefetch 1.20s internal`. They are kept per run, the
2719
+ newest 50, in `<cache dir>/steps/<run id>.json`, written only by a run
2720
+ that had one; `--format json` adds them as `steps`.
2884
2721
  `vx last --list` prints the N most recent runs (default 10), one per
2885
2722
  line in aligned columns (verdict, start, run id, counts, duration,
2886
2723
  command), each run id cut to the shortest prefix no other run shares, 13 characters at least (`--list 5`
@@ -2902,7 +2739,7 @@ sandboxed task's row adds `sandboxFix`, the `exec.sandbox.allow`
2902
2739
  entries (`read`, `write`, `network`, `systemInfo`) that would have
2903
2740
  granted its denials, the same grants the terminal prints as `fix:`;
2904
2741
  `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
2905
- a cached task, `inputs` the reads of its project no `cache.inputs.files`
2742
+ a cached task, `inputs` the reads of its project no `inputs.files`
2906
2743
  glob matches (the terminal's `and, if the output depends on them, …`
2907
2744
  line). A task
2908
2745
  failed by `inputStability.severity: 'error'` adds `inputMoved`, the
@@ -3066,8 +2903,8 @@ names no package: it lists what the workspace itself declares.
3066
2903
 
3067
2904
  ## Environment variables vx reads
3068
2905
 
3069
- Every setting has a variable derived from its path (`run.killGrace` →
3070
- `VX_RUN_KILL_GRACE`), beating `.vx/workspace` and beaten by the flag; the
2906
+ Every setting has a variable derived from its path (`run.timeout` →
2907
+ `VX_RUN_TIMEOUT`), beating `.vx/workspace` and beaten by the flag; the
3071
2908
  table in `docs/schema.md` § Settings gives each value's form and
3072
2909
  default. Each is checked once before any verb runs: an empty one is
3073
2910
  unset, a value its kind does not take is refused naming the form, and a
@@ -3087,11 +2924,7 @@ what a TASK sees is `exec.env` in `docs/schema.md`.
3087
2924
  | `VX_RUN_RETRIES` | [`run.retries`](schema.md#settings) | Re-runs of a failed task, for tasks without their own `exec.retries`. |
3088
2925
  | `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. |
3089
2926
  | `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. |
3090
- | `VX_RUN_KILL_GRACE` | [`run.killGrace`](schema.md#settings) | How long a task that ignores SIGTERM gets before SIGKILL. |
3091
- | `VX_RUN_TEARDOWN_TIMEOUT` | [`run.teardownTimeout`](schema.md#settings) | The bound on each plugin's end-of-run flush and teardown. |
3092
- | `VX_RUN_READY_NOTICE` | [`run.readyNotice`](schema.md#settings) | How long a persistent task may take to match `readyWhen` before vx says it waits. |
3093
2927
  | `VX_RUN_RULES_EXCLUSIVE_OUTPUTS` | [`run.rules.exclusiveOutputs`](schema.md#settings) | Refuse two tasks whose outputs overlap even when an edge orders them. |
3094
- | `VX_RUN_RULES_UPFRONT_KEYS` | [`run.rules.upfrontKeys`](schema.md#settings) | Refuse a task whose inputs can match another task's outputs. |
3095
2928
  | `VX_CACHE_DIR` | [`cache.dir`](schema.md#settings) | Where the cache lives, relative to the workspace root; named, it holds the whole cache. |
3096
2929
  | `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'`). |
3097
2930
  | `VX_CACHE_RETENTION_OLDER_THAN` | [`cache.retention.olderThan`](schema.md#settings) | Evict entries unused for this long at each run's end (`'30d'`). |
@@ -3121,9 +2954,8 @@ Colors are the two conventions in § Output format › Colors (`NO_COLOR`,
3121
2954
  `FORCE_COLOR`). A truthy `CI` picks the CI output flow and
3122
2955
  `GITHUB_ACTIONS` its `::group::` framing (§ Output); the run's
3123
2956
  `invocations` row records the provider, the first truthy of
3124
- `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, `CIRCLECI`, then `CI`. Core never reads `GITHUB_STEP_SUMMARY`: `--report`
3125
- prints to stdout, and `--report-file=<path>` appends to a file, so on
3126
- Actions pass `--report-file="$GITHUB_STEP_SUMMARY"`. A sandboxed
2957
+ `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, `CIRCLECI`, then `CI`. Core never reads `GITHUB_STEP_SUMMARY`; the job summary is
2958
+ `@vzn/vx-ci`'s. A sandboxed
3127
2959
  task's temp dir sits under `CLAUDE_CODE_TMPDIR` (else `CLAUDE_TMPDIR`,
3128
2960
  else `/tmp/claude`), the sandbox runtime's own convention
3129
2961
  (`docs/modules/sandbox-runtime.md`).
@@ -3158,8 +2990,8 @@ write ../shared/notes.txt
3158
2990
  Every section is conditional: the `$ <command>` line only for an
3159
2991
  executed task (success or failed — a hit replays its stored output and
3160
2992
  shows none), `OUTPUT` only when the task wrote something,
3161
- `SANDBOX VIOLATIONS (N)` only when the sandbox recorded some or vx
3162
- has a note on a failure. The
2993
+ `SANDBOX VIOLATIONS (N)` only when the sandbox recorded some (`N`
2994
+ counts them) or `SANDBOX` when vx has only a note on a failure. The
3163
2995
  header carries the outcome (`restored-local • abc12345`, `failed (exit
3164
2996
  N)`, …) and the footer repeats it after the duration. A test renders
3165
2997
  this block and checks it against this page, byte for byte.
@@ -3206,17 +3038,27 @@ A broad run looks like:
3206
3038
  ```
3207
3039
 
3208
3040
  The three bars are meters: `projects` is what the run covered against
3209
- the workspace, `tasks` is failed / success / skipped, `cache` is miss /
3210
- no-cache / up-to-date / local / remote (a skipped task rides it too, so
3211
- the two bars fill alike; its count is on the tasks legend only). Its
3212
- legend reads per layer: `local 1● 1○ 2↓ 4↑ · remote 3↓ 4↑`.
3213
- `●` is a fresh hit (outputs already on disk, whichever layer answered,
3214
- so it counts under local), `○` a task with no cache, `↓` is restored
3215
- from that layer and `↑` saved to it (local `↑` counts only tasks that ran here; remote `↑` counts
3216
- uploads that landed). A layer shows only when the run's
3217
- `--cache-policy` uses it. `N not saved` (ran, succeeded, saved nothing),
3218
- `N not downloaded` (outputs left in the remote store) and `N ↑ failed`
3219
- show only when non-zero. The
3041
+ the workspace, `tasks` is failed / success / skipped, `cache` has a
3042
+ segment per legend state (a skipped task rides it too, so the two bars
3043
+ fill alike; its count is on the tasks legend only). Its legend reads
3044
+ per layer: `local 1✓ 1∅ 2↓ 1↑ · remote 3↓ 4↑ 2✗ 1⇡`. `✓` is a fresh
3045
+ hit (outputs already on disk, whichever layer answered, so it counts
3046
+ under local), `∅` a task with no cache, `↓` is restored from that
3047
+ layer and `↑` saved to it, `✗` a task a remote read or write failed
3048
+ for, and `⇡` an upload still running (live only, always last). The
3049
+ region stays through vx's closing steps, worker rows gone, and the
3050
+ final footer replaces it in place. Each task counts once, under its strongest state: a
3051
+ failed remote read or write, then an upload running, then saved to a
3052
+ remote, then saved here, then restored, then fresh, then no cache; so
3053
+ a task saved here and uploaded is a remote `↑`. A key the remote already
3054
+ held is not sent, so it is no remote `↑`. Each count is painted
3055
+ in its segment's colour, number and glyph alike: `✓` green, `∅` dim,
3056
+ local blue, remote dark blue, `✗` and a miss red, `⇡` cyan; the
3057
+ `local` and `remote` words are dim. A layer shows only when the run's
3058
+ `--cache-policy` uses it. `N not saved` (ran, succeeded, saved
3059
+ nothing), `N not downloaded` (outputs left in the remote store) and
3060
+ every count show only when non-zero. `vx last` names each remote
3061
+ failure on its task's row. The
3220
3062
  `time` row is the wall time, then what the cache saved (the hits' stored
3221
3063
  exec times, never the restore), then the spread over executed tasks
3222
3064
  only — a hit's restore time never enters it. The footer never says a
@@ -3234,12 +3076,22 @@ running a group focused surfaces its real member tasks instead.
3234
3076
  **Nothing below the footer.** The footer is the run's last word.
3235
3077
  What a run has to say about one task rides that task's row; what it
3236
3078
  says about the run (a server that died, outputs left remote, a file
3237
- `--summarize` / `--profile` wrote, a history, upload, plugin or sandbox
3079
+ `--profile` wrote, a history, upload, plugin or sandbox
3238
3080
  warning) prints above the footer: it prints once the run's closing work
3239
3081
  is done, the `VX_DEBUG_TIMING=1` table included. Cache housekeeping
3240
3082
  (`cache.retention`) prints nothing. Only a kept server's own output
3241
3083
  follows it.
3242
3084
 
3085
+ **Narrow terminals.** The footer is 62 columns: its rule and 50-cell
3086
+ bars. On a terminal narrower than that the bars shrink and the rule
3087
+ with them, as do a frame's section rules, so none wraps onto a second
3088
+ row. Off a terminal they keep their width.
3089
+
3090
+ **Warnings on stderr.** A run's warnings, such as `[vx] app#build:
3091
+ cache.outputs matched no files …` or a plugin's, go to stderr. Stdout
3092
+ carries only the rows, the frames and the footer, so `vx run build >
3093
+ log.txt` keeps them out of the log.
3094
+
3243
3095
  **Skipped rows.** A red run lists every task that never started as a
3244
3096
  row naming the failure that blocked it, so the footer's `1 skipped`
3245
3097
  always has a name above it:
@@ -3301,6 +3153,7 @@ ANSI truecolor (`ansi-16m`) sequences, gated by env:
3301
3153
  | `FORCE_COLOR=0` / `=false` | Force off, on a TTY too. |
3302
3154
  | `FORCE_COLOR=…` (any other) | Force on — `1`, `true`, `2`, `3`, the empty string, etc. |
3303
3155
  | (neither) | On iff `stdout.isTTY`. |
3156
+ | `TERM=dumb` | Off unless `FORCE_COLOR` forces it, and no live region. |
3304
3157
 
3305
3158
  `0` and `false` are `FORCE_COLOR`'s "off" by the convention
3306
3159
  supports-color, chalk and Node follow; until 2026-09-24 vx read every
@@ -3326,7 +3179,7 @@ Only a run's own output (`vx run`, `vx watch`) is painted, and the
3326
3179
  help on a terminal: headings bold, `vx <verb>` and each flag or example
3327
3180
  in cyan; `NO_COLOR` or `FORCE_COLOR=0` turns it off
3328
3181
  (`tests/help-colors.test.ts`). Every other verb (`show`, `info`, `why`,
3329
- `last`, a `--dry` plan, `--graph`, `cache clean`), and the help off a
3182
+ `last`, a `--dry` plan, `cache clean`), and the help off a
3330
3183
  terminal, prints plain text whatever `FORCE_COLOR` says, so a script
3331
3184
  that forces colour for a run's log still parses them
3332
3185
  (`tests/cli-colors-e2e.test.ts`).
@@ -3343,7 +3196,7 @@ Reads try local first, then remote (hydrating local on remote hit),
3343
3196
  with a background prefetch pass overlapping remote GETs with
3344
3197
  execution. Writes go to local immediately; the remote upload is a
3345
3198
  fire-and-forget background task drained at end of run — failures are
3346
- logged via `onRemoteError` but never fail the build.
3199
+ warned but never fail the build.
3347
3200
 
3348
3201
  For any OTHER cache server (a Turbo-wire deployment, S3-direct, …),
3349
3202
  implement core's `RemoteCacheLayer` interface in a plugin's `cache`
@@ -3358,7 +3211,7 @@ consult).
3358
3211
  size, runs + hits in the last 24 h). For anything deeper, vx records
3359
3212
  every task to a `runs` table in `cache.db` (UUIDv7 `run_id`, hrtime
3360
3213
  wallclock spans, cpu_ms, peak RSS, status, cache_hit flag) plus one
3361
- `invocations` header row per run (command, git/CI context, tags,
3214
+ `invocations` header row per run (command, git/CI context,
3362
3215
  counts). The SQLite file IS the API:
3363
3216
 
3364
3217
  ```sh
@@ -3407,7 +3260,7 @@ Surface:
3407
3260
 
3408
3261
  - `run(options)` — execute. Returns `Promise<RunSummary>`.
3409
3262
  - `planRun(options)` — predict, no execute. Returns
3410
- `Promise<RunPlan>`. Used by `--dry` / `--graph`.
3263
+ `Promise<RunPlan>`. Used by `--dry`.
3411
3264
  - `prepareRun(options, log)` — the shared setup (discovery → configs →
3412
3265
  graph → cache). What an embedder builds on.
3413
3266
  - `defineProject` / `defineWorkspace` — identity helpers for type