@vzn/vx 0.0.637 → 0.0.639

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 (126) hide show
  1. package/docs/caching.md +84 -28
  2. package/docs/cli.md +273 -408
  3. package/docs/execution.md +19 -44
  4. package/docs/features.md +13 -15
  5. package/docs/patterns.md +5 -7
  6. package/docs/schema.md +68 -61
  7. package/package.json +5 -5
  8. package/schemas/last.json +7 -4
  9. package/schemas/plan.json +2 -2
  10. package/skills/vx/SKILL.md +19 -6
  11. package/src/bin.ts +13 -7
  12. package/src/cache/cache.ts +60 -11
  13. package/src/cache/git-inputs.ts +6 -0
  14. package/src/cache/index.ts +10 -2
  15. package/src/cache/inputs.ts +0 -24
  16. package/src/cache/layer.ts +32 -12
  17. package/src/cache/layered-cache.ts +112 -315
  18. package/src/cache/plugin-cache.ts +240 -0
  19. package/src/cache/policy.ts +9 -0
  20. package/src/cache/remote-lane.ts +383 -0
  21. package/src/cache/run-history.ts +92 -8
  22. package/src/cache/run-store.ts +77 -0
  23. package/src/cache/schema.ts +44 -13
  24. package/src/cache/scoped-cache.ts +22 -2
  25. package/src/cli/cache.ts +33 -16
  26. package/src/cli/completions.ts +11 -7
  27. package/src/cli/foreign-flags.ts +10 -22
  28. package/src/cli/help.ts +22 -38
  29. package/src/cli/index.ts +25 -11
  30. package/src/cli/info.ts +8 -6
  31. package/src/cli/init.ts +14 -15
  32. package/src/cli/last.ts +20 -10
  33. package/src/cli/lock.ts +39 -27
  34. package/src/cli/plan-format.ts +10 -61
  35. package/src/cli/plugin-commands.ts +77 -6
  36. package/src/cli/plugin-templates.ts +1 -1
  37. package/src/cli/run.ts +31 -191
  38. package/src/cli/select.ts +18 -9
  39. package/src/cli/show.ts +72 -40
  40. package/src/cli/task-verb.ts +4 -4
  41. package/src/cli/upgrade.ts +132 -39
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +7 -2
  44. package/src/cli/watch-judge.ts +6 -1
  45. package/src/cli/watch.ts +73 -34
  46. package/src/cli/why.ts +14 -5
  47. package/src/cli/workspace-config.ts +2 -2
  48. package/src/config.ts +0 -6
  49. package/src/exec/executor.ts +1 -1
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/index.ts +9 -1
  53. package/src/graph/priorities.ts +1 -1
  54. package/src/graph/scheduler.ts +12 -16
  55. package/src/graph/task-graph.ts +119 -21
  56. package/src/graph/task-status.ts +95 -0
  57. package/src/{orchestrator → history}/failure-mode.ts +20 -11
  58. package/src/{orchestrator → history}/history.ts +61 -5
  59. package/src/history/index.ts +48 -0
  60. package/src/{orchestrator → history}/metrics.ts +14 -23
  61. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  62. package/src/index.ts +21 -29
  63. package/src/orchestrator/affected-tasks.ts +8 -3
  64. package/src/orchestrator/doctor.ts +2 -2
  65. package/src/orchestrator/execute-args.ts +80 -0
  66. package/src/orchestrator/execute-task.ts +47 -429
  67. package/src/orchestrator/fingerprint-watch.ts +2 -2
  68. package/src/orchestrator/index.ts +3 -74
  69. package/src/orchestrator/miss-save.ts +33 -3
  70. package/src/orchestrator/options.ts +1 -36
  71. package/src/orchestrator/output-host.ts +4 -3
  72. package/src/orchestrator/persistent-task.ts +321 -0
  73. package/src/orchestrator/persistent.ts +130 -3
  74. package/src/orchestrator/placement.ts +3 -3
  75. package/src/orchestrator/plan.ts +18 -5
  76. package/src/orchestrator/plugin-host.ts +77 -47
  77. package/src/orchestrator/plugin.ts +86 -14
  78. package/src/orchestrator/prepare.ts +74 -31
  79. package/src/orchestrator/projects.ts +3 -0
  80. package/src/orchestrator/remote-prefetch.ts +23 -6
  81. package/src/orchestrator/run-artifacts.ts +5 -177
  82. package/src/orchestrator/run-context.ts +55 -0
  83. package/src/orchestrator/run-failures.ts +15 -3
  84. package/src/orchestrator/run-records.ts +36 -4
  85. package/src/orchestrator/run-report.ts +81 -179
  86. package/src/orchestrator/run-settings.ts +1 -16
  87. package/src/orchestrator/run-stop.ts +153 -0
  88. package/src/orchestrator/run.ts +313 -555
  89. package/src/orchestrator/sandbox-fix.ts +0 -16
  90. package/src/orchestrator/signals.ts +14 -6
  91. package/src/orchestrator/stable-keys.ts +107 -3
  92. package/src/orchestrator/steps.ts +50 -0
  93. package/src/orchestrator/task-hash.ts +3 -16
  94. package/src/orchestrator/task-log-buffer.ts +1 -1
  95. package/src/orchestrator/task-process.ts +94 -0
  96. package/src/orchestrator/telemetry-host.ts +1 -1
  97. package/src/orchestrator/telemetry.ts +6 -83
  98. package/src/{orchestrator → report}/events.ts +41 -8
  99. package/src/{orchestrator → report}/forecast.ts +35 -2
  100. package/src/{orchestrator → report}/framed-output.ts +39 -34
  101. package/src/report/index.ts +36 -0
  102. package/src/{orchestrator → report}/logger.ts +166 -21
  103. package/src/{orchestrator → report}/status-line.ts +84 -7
  104. package/src/{orchestrator → report}/summary.ts +179 -63
  105. package/src/report/tally.ts +83 -0
  106. package/src/settings.ts +13 -23
  107. package/src/{orchestrator → util}/colors.ts +49 -4
  108. package/src/util/config-frame.ts +31 -2
  109. package/src/util/errors.ts +32 -0
  110. package/src/util/git-ignored.ts +143 -0
  111. package/src/util/index.ts +25 -3
  112. package/src/util/refusal-notice.ts +111 -0
  113. package/src/util/report.ts +39 -0
  114. package/src/util/settle.ts +14 -39
  115. package/src/util/tail.ts +1 -1
  116. package/src/util/ui.ts +197 -0
  117. package/src/workspace/affected.ts +20 -23
  118. package/src/workspace/dependency-rules.ts +12 -4
  119. package/src/workspace/migration.ts +87 -91
  120. package/src/workspace/workspace.ts +5 -4
  121. package/schemas/summary.json +0 -133
  122. package/src/cache/chained-cache.ts +0 -276
  123. package/src/orchestrator/tally.ts +0 -112
  124. /package/src/{orchestrator → report}/path-links.ts +0 -0
  125. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  126. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
package/docs/cli.md CHANGED
@@ -64,12 +64,13 @@ 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
71
71
  project is named zzz; projects: a, b.`, or `a's tasks: build, test.`
72
- (M-56),
72
+ (M-56), each followed by ``A run reads the lock: after editing a
73
+ vx.config, run `vx lock`.`` (a task just added is not in the lock yet),
73
74
  and with `Only projects outside the selection declare <name> — pass
74
75
  --all, or --filter to pick them.` when the run was scoped (the cwd's
75
76
  project, a `--filter`) and a project outside the scope declares it
@@ -123,20 +124,21 @@ If no task name is given:
123
124
  `missing task name (stdin is not a TTY, so no picker; tasks here: build, test)`,
124
125
  naming the cwd project's tasks, else every project's (twelve, then
125
126
  `and N more`); outside a workspace it reads `vx run <task>, e.g. vx run build`.
126
- - **Under `--format json` or `--dry=json`**, on a terminal too — the
127
+ - **Under `--dry=json`**, on a terminal too — the
127
128
  same refusal (`a JSON answer opens no picker; …`), with the
128
129
  `VX_E_USAGE` error document on stdout: a program cannot pick.
129
130
 
130
131
  Exit codes:
131
132
 
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). |
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). |
137
138
 
138
139
  A task runs in its own session, so a terminal's Ctrl-C reaches vx alone,
139
- 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.
140
142
 
141
143
  Installed from npm, `vx` is a sh launcher that execs the platform
142
144
  binary, so every signal reaches vx directly and no Node starts first
@@ -479,34 +481,23 @@ than run with the args unheard.
479
481
  | `--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
482
  | `--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
483
  | `--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). |
484
+ | `--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). |
483
485
  | `--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
486
  | `--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
487
  | `--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
488
  | `--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
489
  | `--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
490
  | `--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
491
  | `--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
492
  | `--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
493
  | `--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
494
  | `--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
495
  | `--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. |
503
496
 
504
497
  Mutual exclusion:
505
498
 
506
- - `--dry` and `--graph` — both skip execution; pick one.
507
- - `--dry` or `--graph` with `--summarize`, `--profile`, `--report` or
508
- `--report-file` — each needs a real run to write about. `--report` and
509
- `--report-file` were accepted and silently wrote nothing until item 992.
499
+ - `--dry` with `--profile` — the profile needs a real run to write
500
+ about.
510
501
 
511
502
  Unknown flags are a parse error (`unknown flag: --foo`), naming the
512
503
  nearest flag the verb accepts when one is within two edits
@@ -530,13 +521,10 @@ name is the verb, and would change what `vx build` means the day one
530
521
  was declared.
531
522
 
532
523
  **Optional-value flags take their value with `=` only.** `--affected`,
533
- `--exclude-dependencies`, `--dry`, `--graph`, `--summarize`, `--profile`,
534
- and `--report` are all valid bare, so a following word is
535
- read as a task name (a `--graph` word ending `.svg`, `.png`, `.json`
536
- and the like is refused, see § Turbo and Nx flags) — `vx run --affected build` means "run
537
- `build`, affected scope", and there is no way to tell that apart from
538
- "`build` is the git base". Write `--graph=out.dot` or
539
- `--affected=origin/main`. Getting it wrong is loud, not silent: the value
524
+ `--exclude-dependencies`, `--dry` and `--profile` are all valid bare,
525
+ so a following word is read as a task name — `vx run --affected build`
526
+ means "run `build`, affected scope", and there is no way to tell that
527
+ apart from "`build` is the git base". Write `--affected=origin/main`. Getting it wrong is loud, not silent: the value
540
528
  becomes a positional that matches no project, so the run refuses to
541
529
  start (see "Every requested name must resolve" above).
542
530
 
@@ -546,9 +534,9 @@ Pass bare `--exclude-dependencies` for the first, omit the flag for the
546
534
  second.
547
535
 
548
536
  Value flags (`--filter`, `--concurrency`, `--output-logs`,
549
- `--verbosity`, `--cache-dir`, `--report-file`, …)
537
+ `--verbosity`, `--cache-dir`, …)
550
538
  accept both `--flag value` and `--flag=value`. In the space form,
551
- `--cache-dir` and `--report-file` reject a value
539
+ `--cache-dir` rejects a value
552
540
  starting with `-`: that is always a swallowed flag (an unquoted empty
553
541
  shell variable), never a path or task id. Use the `=` form for a literal
554
542
  leading dash.
@@ -558,15 +546,13 @@ leading dash.
558
546
  fractional (`2.7`), signed (`+4`) and space-padded forms, plus anything
559
547
  past `2^53` (it would parse to a number you did not type). These all
560
548
  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`,
549
+ workers. A duration flag (`--timeout`) takes its unit (`500ms`, `30s`,
563
550
  `10m`): a bare `500` is refused, naming both readings.
564
551
 
565
- An empty `=` value means "no value" on four flags, which then act like
566
- their bare forms: `--profile=` writes `profile.json`, `--summarize=`
567
- writes `<cacheDir>/runs/<run_id>.json`, `--graph=` prints to stdout and
552
+ An empty `=` value means "no value" on two flags, which then act like
553
+ their bare forms: `--profile=` writes `profile.json` and
568
554
  `--affected=` uses the default base. Every other flag refuses an empty
569
- value: `--dry=`, `--report=`, `--continue=` and
555
+ value: `--dry=`, `--continue=` and
570
556
  `--exclude-dependencies=` as well as the ones with no bare form
571
557
  (`--retries=`, `--timeout=`, `--cache-dir=`, `--filter=`, `--cache-policy=`).
572
558
 
@@ -601,10 +587,13 @@ So `--no-cache` always wins over `--force`. The common cases:
601
587
  - `--no-cache` → nothing reads, nothing writes, and declared output
602
588
  globs are left untouched (you're debugging; vx won't manage your
603
589
  tree).
604
- - `--force` → re-execute every task (reads off) but still write fresh
605
- artifacts to both layers (writes on). Output globs ARE cleaned
606
- before each task so the saved snapshot is clean. This is the "rebuild
607
- and refresh the cache" flag.
590
+ - `--force` → re-execute every task (reads off) with writes on. An
591
+ entry is immutable: a key the local cache already holds is kept, and
592
+ each remote that can batch (`hasMany`) is asked once, in the
593
+ background, which keys it holds; one that cannot is asked `has` per
594
+ key before its upload. Held keys are not uploaded again (a probe that
595
+ fails uploads, as before). Output globs ARE cleaned before each task,
596
+ so the disk holds this run's outputs.
608
597
 
609
598
  `--cache-policy=<spec>` is a comma-separated list of `<layer>:<flags>`
610
599
  segments. `layer` is `local` or `remote`; `flags` is any subset of `r`
@@ -624,8 +613,9 @@ layer keeps its current value. Both `--cache-policy=<spec>` and the space form
624
613
  a remote hit is still delivered (the artifact has to land on disk to be
625
614
  extracted), it just never short-circuits the remote read.
626
615
 
627
- Combine with `--force` for "re-execute and refresh only the remote":
628
- `--cache-policy=local: --force` (local off, reads off, remote write-only).
616
+ Combine with `--force` for "re-execute and fill only the remote":
617
+ `--cache-policy=local: --force` (local off, reads off, remote write-only;
618
+ keys the remote already holds are not sent).
629
619
 
630
620
  Invalid layers/flags are a parse error
631
621
  (`--cache-policy: invalid layer 'disk'`, `--cache-policy: invalid flag 'x'`, …);
@@ -690,8 +680,7 @@ What the shapes mean in each column:
690
680
  everything, the blocker included (`⊘ skipped app#deploy • blocked by
691
681
  lib#build`; a fail-fast skip, which nothing blocked, carries no
692
682
  suffix). Every view that lists tasks prints it, a dependency's
693
- included: nothing prints below the footer. `--report` reads the same fact into the status cell,
694
- `skipped (blocked by lib#build)`.
683
+ included: nothing prints below the footer.
695
684
  - **`frame, or one-liner if quiet`** — a cache hit with stored stdout is
696
685
  worth a frame (the output is the point); a hit with nothing to replay
697
686
  compresses to one line, which is what keeps a 2000-task warm run
@@ -745,7 +734,7 @@ tracks the run live. Top to bottom:
745
734
  persistent tasks it depends on, until it — or, with several, the
746
735
  first of them — exits (one kept only as a dependency that exits 0,
747
736
  a daemon that forked and returned, does not end it); the rest still up are then torn
748
- down (SIGTERM, `run.killGrace`, SIGKILL), one status line names
737
+ down (SIGTERM, a 2 s grace, SIGKILL), one status line names
749
738
  the task and its code (`vx: app#dev exited with code 1; stopping 1
750
739
  other persistent task`), and a non-zero exit makes the run exit 1
751
740
  and is what `vx last` records.
@@ -761,16 +750,39 @@ other persistent task`), and a non-zero exit makes the run exit 1
761
750
  3. **Worker rows** — one per worker slot that has been busy (at most
762
751
  `min(concurrency, 10)`), no glyph and no spinner: the live ticking
763
752
  elapsed time leads (` 568ms running <id>`). A task stays in
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
766
- says what waits on it: `critical path` (yellow) on the running task
753
+ its row for its whole life, so nothing jumps; overflow shows as
754
+ `+k more`. A second in, an idle worker row holds a task an
755
+ unfinished dependency blocks, one per row, in the order the
756
+ scheduler starts them (most dependents first):
757
+ `blocked solid-js#build by solid-js#types` (dim `blocked`, the id,
758
+ then the running task that holds it back). The task starts in the
759
+ row that held it. A row with no blocked task left to hold leaves the
760
+ region and the rows below close up; blocked tasks only dwindle, so
761
+ it never comes back. A running row says what waits on it:
762
+ `critical path` (yellow) on the running task
767
763
  the longest unfinished chain waits on, by the same p50s as the
768
764
  forecast below and only while two or more run, and a dim
769
765
  `blocks N`, its unfinished dependents direct and transitive. Where
770
766
  local history knows the task, its row also shows its usual duration
771
767
  (its executed p50) in the cache column, fixed while the elapsed time
772
768
  counts up toward it (`3.33s running ~5.2s solid-js#types`).
773
- 4. **The live summary section** — the SAME meters the final footer
769
+ 4. **vx's own steps** — what vx does around the tasks, as rows of
770
+ their own below the worker rows: `vx:load` (configs and graph from
771
+ the lock), `vx:run-lock`, `<plugin>:setup`, `vx:keys` (keys and local
772
+ probes), `vx:prefetch`, `vx:schedule`, then after the graph
773
+ `vx:uploads`, `vx:history`, `vx:keep-run`, `vx:telemetry`,
774
+ `<plugin>:teardown`, `vx:snapshots`, `vx:retention`, `vx:close` and
775
+ `vx:sandbox`. A step shows once it has run 150 ms, ticking like a
776
+ task (` 1.2s running vx:uploads`); no worker slot or
777
+ `--concurrency` holds it back. When it ends it leaves a list row
778
+ like a task's, with no cache column (`⏺︎ 1.2s success vx:uploads`);
779
+ a quicker one shows nowhere. `:` is no task id's separator, so a
780
+ step never reads as a task: a project named `vx` keeps `vx#build`.
781
+ No step is a task: no count, bar, history row, forecast, flaky
782
+ verdict, `vx last` row or telemetry record holds one; `--profile`
783
+ and `VX_DEBUG_TIMING` time vx's stages their own way. Steps show only
784
+ beside a live region; a log (CI, a pipe) prints none.
785
+ 5. **The live summary section** — the SAME meters the final footer
774
786
  prints (`tasks` + `cache` bars with legends, `time`), filling in as
775
787
  the run progresses, under a bare `vx` wordmark rule. A second in,
776
788
  the `time` row adds what is left (`time 1.41s · ~3s left`): each
@@ -813,11 +825,11 @@ redraws within 30 ms of the last draw coalesce into a single
813
825
  trailing draw when the floor expires (the final state always lands).
814
826
  On a 3,270-task warm run this cuts ~6.7 MB of redraw ANSI to ~20 KB.
815
827
 
816
- Identity coloring: every `project#task` renders its project half in
817
- a stable hue hashed from the project name (same project = same color
818
- in every run and every surface) and its task half in a fixed pink —
819
- both deliberately outside the status palette, so an id can never
820
- read as an outcome.
828
+ Identity coloring: each half of a `project#task` renders in a stable
829
+ hue hashed from its own name (same project = same color, same target
830
+ name = same color, in every run and every surface: every `#build`
831
+ matches), with a dim `#` between, all outside the status palette, so
832
+ an id can never read as an outcome.
821
833
 
822
834
  On GitHub Actions (`GITHUB_ACTIONS` truthy, full output mode), each
823
835
  task's block is wrapped in `::group::<id> (<outcome> <duration>)` /
@@ -902,9 +914,9 @@ not read a deferred output (the same reason vx refuses to infer inputs
902
914
  by tracing). When bytes are fetched later, vx saves an ordinary
903
915
  cache entry for them, so the next run is a plain local hit.
904
916
 
905
- ## Planning mode (`--dry`, `--graph`)
917
+ ## Planning mode (`--dry`)
906
918
 
907
- Both flags short-circuit execution. They build the full task graph,
919
+ `--dry` short-circuits execution. It builds the full task graph,
908
920
  compute every task's cache key, and probe the cache to predict the
909
921
  hit/miss outcome. Against a remote cache the probe is a lightweight
910
922
  existence check — planning never downloads or ingests artifacts.
@@ -912,8 +924,8 @@ existence check — planning never downloads or ingests artifacts.
912
924
  ```
913
925
  $ vx run ci --dry
914
926
  would run:
915
- ◉ @vzn/vx#format-check cache hit (local) 02bfe8a9
916
- ◉ @vzn/vx#lint cache hit (local) d66cfed2
927
+ ✓ @vzn/vx#format-check cache hit (local) 02bfe8a9
928
+ ✓ @vzn/vx#lint cache hit (local) d66cfed2
917
929
  ▶ @vzn/vx#test cache miss — would exec 68595e49 ~72.64s
918
930
 
919
931
  3 task(s) planned, 2 cache hits (2 local), 1 would run.
@@ -946,11 +958,11 @@ Status legend:
946
958
 
947
959
  | Symbol | Meaning |
948
960
  | ------ | ------------------------------------------------------------------------------------------ |
949
- | `◉` | cache hit (local) — entry already in `<cacheDir>/` |
961
+ | `✓` | cache hit (local) — entry already in `<cacheDir>/` |
950
962
  | `↓` | cache hit (remote) — entry would be fetched from the layer |
951
963
  | `▶` | cache miss — task would execute (under `--force` too: nothing is read, what runs is saved) |
952
- | `·` | no-cache — task opts out (no `cache` block, or `--no-cache`) |
953
- | `∅` | `@noop` — task would not run anywhere |
964
+ | `∅` | no-cache — task opts out (no `cache` block, or `--no-cache`) |
965
+ | `·` | `@noop` — task would not run anywhere |
954
966
  | `−` | not started — a server you did not request whose every dependant restores a local hit |
955
967
  | `○` | group task (suppressed in human view; in DOT + JSON) |
956
968
 
@@ -999,7 +1011,9 @@ $ vx run build lint --affected=main --dry
999
1011
  `kind` is `input` (a changed `file` is a declared input), `project` (its
1000
1012
  project changed and the task reads it whole: an uncached task, or a changed
1001
1013
  `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),
1014
+ ignore; no `file` for a config import), `workspace` (a workspace-wide
1015
+ `file` changed: the workspace config, or a root file a plugin claims such
1016
+ as a lockfile, reached every task of the project),
1003
1017
  `package` (a `^` edge passes a changed `project` with no such task),
1004
1018
  `named` (`pkg#task` beside a bare task) or `selected` (another filter
1005
1019
  selected its project). `via` is the `dependsOn` chain, nearest first, to
@@ -1010,150 +1024,12 @@ against: a bare `--affected` guesses it (`origin/HEAD`, a trunk branch,
1010
1024
  else `HEAD~1`), so a reason that names no file may come from a commit
1011
1025
  the base reaches, a root `package.json` edit among them.
1012
1026
 
1013
- ### Task graph (`--graph`)
1027
+ ## Run artifacts (`--profile`)
1014
1028
 
1015
- `--graph` prints Graphviz DOT (stdout by default; `--graph=path`
1016
- writes a file):
1017
-
1018
- ```
1019
- vx run ci --graph | dot -Tsvg > graph.svg
1020
- vx run ci --graph=graph.dot
1021
- ```
1022
-
1023
- Node `fillcolor` varies by predicted status (green = local hit,
1024
- sky-blue = remote hit, orange = miss, gray = no-cache, fuchsia =
1025
- group, white = not started). Edges are unstyled.
1026
-
1027
- ## Run artifacts (`--summarize`, `--profile`)
1028
-
1029
- Both flags add a side-effect after a real run completes. Errors
1029
+ `--profile` adds a side-effect after a real run completes. Errors
1030
1030
  writing the artifact are surfaced via `vx: failed to write …` but
1031
1031
  don't change the run's exit code — the run already happened.
1032
1032
 
1033
- ### `--summarize[=<path>]`
1034
-
1035
- Writes a per-run JSON file; `schemas/summary.json` is its JSON Schema:
1036
-
1037
- ```json
1038
- {
1039
- "runId": "01a0e3ef-206c-708d-95bf-c2b07211adf6",
1040
- "ok": true,
1041
- "exitCode": 0,
1042
- "startedAt": "2026-09-27T17:34:54.572Z",
1043
- "endedAt": "2026-09-27T17:34:54.586Z",
1044
- "totalMs": 14.123643,
1045
- "savedMs": 9,
1046
- "tasks": [
1047
- {
1048
- "id": "a#build",
1049
- "project": "a",
1050
- "task": "build",
1051
- "status": "cache-hit",
1052
- "exitCode": 0,
1053
- "durationMs": 3,
1054
- "hash": "48007ccadd42ed7d",
1055
- "storedDurationMs": 9,
1056
- "storedCpuMs": 2.059,
1057
- "wallclockStartNs": "9456491",
1058
- "wallclockEndNs": "12902888"
1059
- }
1060
- ],
1061
- "aborted": [],
1062
- "summary": {
1063
- "successful": 1,
1064
- "failed": 0,
1065
- "skipped": 0,
1066
- "cachedLocal": 1,
1067
- "restoredLocal": 0,
1068
- "restoredRemote": 0,
1069
- "upToDate": 1,
1070
- "cachedRemote": 0,
1071
- "aborted": 0,
1072
- "total": 1
1073
- }
1074
- }
1075
- ```
1076
-
1077
- `runId` is a UUIDv7. Default path: `<cacheDir>/runs/<run_id>.json`. hrtime fields are
1078
- strings (bigints serialized as strings) to preserve ns precision
1079
- through JSON. `cpuMs` / `peakRssBytes` are what the task's own
1080
- execution used and appear on executed rows only (`peakRssBytes` only
1081
- when the task's peak rose above vx's own footprint — a lighter task's
1082
- figure would be vx's, handed back by the kernel; a Linux sandboxed task
1083
- reports neither, since what bwrap's pid namespace used never reaches vx);
1084
- `restored` appears on a hit only: `true` when its outputs were restored,
1085
- `false` when they were already up to date. A hit's `durationMs`
1086
- is the restore it cost, and what the PRODUCING execution used rides the
1087
- artifact and appears under its own keys, `storedDurationMs` /
1088
- `storedCpuMs` / `storedPeakRssBytes` (the work the hit skipped) — a
1089
- remote worker's peak is never presented as this run's. `savedMs` is
1090
- their sum over the hits, the footer's `saved` as a number. `admissionHeldMs` appears on a row
1091
- only when an `admit` policy (a plugin's — `@vzn/vx-schedule-history`
1092
- packs learned reservations) refused the task while a worker was free:
1093
- the wait from that first refusal to its dispatch, the plugin's hand on
1094
- the run. The footer's `info` row sums the waits, task-seconds:
1095
- `admit held 3 tasks, 4.2s in all` (85 held tasks can read 4783s beside
1096
- a 160 s run).
1097
-
1098
- **`ok` / `exitCode`** are the run's verdict — the same value the CLI
1099
- exits with: a stopping signal's code (130 for Ctrl-C), and with a kept
1100
- server, its exit (the file is written again when the server ends). Gate on these rather than re-deriving a pass from the
1101
- buckets: a run can be red without a single failed task (see `aborted`).
1102
-
1103
- **`tasks[]` and `summary` describe the same population**, so
1104
- `tasks.length === summary.total` always holds. Group tasks (no `exec`)
1105
- are in neither — they do no work.
1106
-
1107
- **`aborted[]`** lists tasks whose attempt ended while the run was
1108
- stopping (Ctrl-C, a signal to vx), with their exit code, and tasks the stop reached before they started, with
1109
- exit 1, `durationMs: 0` and no `wallclockStartNs`. Neither joins an
1110
- outcome bucket or `total`, but they make the run red, so they are
1111
- listed separately and counted as `summary.aborted`.
1112
-
1113
- **`noCache: true`** marks a task no cache answered for: it declares no
1114
- `cache` block (it executes every run by design), or the run's `--cache-policy`
1115
- read and wrote nothing (`--no-cache`), so a hit rate should leave it out
1116
- of the denominator. The key is present only when true; every other row is
1117
- unchanged. Its `hash` is still set: dependents fold it.
1118
-
1119
- **`notReady`** is present only on a failed persistent task: why it never
1120
- became ready — `timeout` (the readiness deadline fired; `exitCode` is
1121
- the kill's, 143 or 137 after the grace, and 143 for a server that
1122
- traps the TERM and exits 0, as an ordinary timeout's),
1123
- `exited` (the child exited first; `exitCode` is then its own) or `spawn`
1124
- (the spawn itself failed). Every label reads it, `failed (never ready:
1125
- timed out, exit 143)`. A server the run's stop (a Ctrl-C) killed while it started is
1126
- `aborted`, not failed, as any task the stop kills; so is one that
1127
- became ready only after the stop.
1128
-
1129
- **`sandboxViolations`** is present only on a sandboxed task with a
1130
- SANDBOX VIOLATIONS section — the count of its denials (vx's own notes
1131
- there count none), and the reason the task failed (its exit is forced to 1
1132
- when it was 0); every label counts it, `failed (exit 1, 2 sandbox
1133
- violations)`.
1134
-
1135
- **`timedOut: true`** is present only on a `failed` row vx's own
1136
- `timeout` killed: its exit is the shell's 143, and every label reads
1137
- `failed (timed out, exit 143)` rather than a signal.
1138
-
1139
- **`blockedBy`** is present only on a `skipped` row: the id of the failed
1140
- (or aborted) task at the root of what blocked it, through any chain of
1141
- skips between — what the skipped row prints, for a script.
1142
- A fail-fast skip has none.
1143
-
1144
- **`flaky: { passes, failures, attempts }`** is present only on a task
1145
- this run proved flaky (its row's `flaky` note, typed): `passes` and
1146
- `failures` count the outcomes on record for this exact `hash`, this run
1147
- included, and `attempts` is what this run took. A consumer gating on
1148
- `status: "failed"` can tell a break (no `flaky` key) from a flake
1149
- without reading the history. Every other row is byte-identical.
1150
-
1151
- **`durationMs` is always what THIS run spent on the task.** For a cache
1152
- hit that is the probe + restore, not the exec time the entry was stored
1153
- with — so it is small even for an expensive task. The work a hit
1154
- _skipped_ is a different number; `--report`'s "N saved" is the surface
1155
- that reports it.
1156
-
1157
1033
  ### `--profile[=<path>]`
1158
1034
 
1159
1035
  Writes a Chrome-trace JSON of every task's wallclock span. Open in
@@ -1189,91 +1065,6 @@ from the per-task `hrtime.bigint()` spans the runner captures.
1189
1065
 
1190
1066
  Default path: `profile.json` (cwd-relative).
1191
1067
 
1192
- ### `--report[=markdown]`
1193
-
1194
- At the end of a real run, prints a markdown run report to **stdout**,
1195
- just above the footer: nothing prints below the footer. The report
1196
- itself is machine-clean. One header line of
1197
- totals plus a table, one row per task:
1198
-
1199
- ```markdown
1200
- ## vx run — passed
1201
-
1202
- **3 tasks** · 3 success · 0 failed · 2 cached (1 up-to-date, 1 restored) · 1.23s total · 2.65s saved
1203
-
1204
- | Task | Status | Cache | Duration |
1205
- | --------- | ------- | ---------- | -------- |
1206
- | web#build | success | miss | 1.23s |
1207
- | web#test | success | local | 5ms |
1208
- | api#test | success | up-to-date | 3ms |
1209
- ```
1210
-
1211
- `Status` is the task outcome (`success` / `failed (exit N)`, with the
1212
- signal an exit above 128 stands for, `failed (exit 137, 128 + SIGKILL)`,
1213
- or a timeout's reason, `failed (timed out, exit 143)`, a persistent
1214
- task's `never ready: …`, or a sandboxed task's violation count, or
1215
- `skipped`, naming what blocked it: `skipped (blocked by lib#build)`);
1216
- `Cache` is its provenance (`miss` / `no-cache` for a task no cache
1217
- answered for — no `cache` block, or a `--no-cache` / `--cache-policy=local:` run —
1218
- which never consulted one / `local` / `remote` / `up-to-date` /
1219
- `—`). A stopped run (a Ctrl-C teardown) reads `## vx run — interrupted`,
1220
- and its tasks keep the terminal's words: one the signal killed is
1221
- `aborted` with its time so far, one the stop reached before it ran is
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
1225
- work, and the header's counts match the terminal summary and
1226
- `--summarize` exactly.
1227
-
1228
- The two durations in the header mean different things, and the
1229
- distinction is the point:
1230
-
1231
- - **`N total`** sums `Duration` over the tasks that actually EXECUTED,
1232
- an aborted task's time so far included — the time this run spent.
1233
- - **`N saved`** sums the exec times the cache hits SKIPPED, read from
1234
- each entry as it was stored (above, 2.01s and 640ms). It is
1235
- deliberately not the hits' `Duration` column, which is the restore
1236
- they cost this run: summing that reported a task taking 2.01s cold
1237
- as "6ms saved".
1238
-
1239
- Only `markdown` is supported today (`json` is reserved; a bad value is a
1240
- parse error). Built purely from the run's outcomes after it returns — it
1241
- adds zero cost when both flags are absent.
1242
-
1243
- ### `--report-file <path>`
1244
-
1245
- Writes the same markdown report to a file instead of (or as well as)
1246
- stdout, and is the form to use for a CI step summary:
1247
-
1248
- ```sh
1249
- vx run ci --report-file="$GITHUB_STEP_SUMMARY"
1250
- ```
1251
-
1252
- **Do not redirect stdout for this.** The report itself is machine-clean,
1253
- but stdout is not vx's alone — the status logger writes frames, meter
1254
- bars and `::group::` workflow commands to the same stream, so
1255
- `--report=markdown >> "$GITHUB_STEP_SUMMARY"` puts the entire run log
1256
- into the step summary above the table.
1257
-
1258
- The report is **appended**, never truncated: `$GITHUB_STEP_SUMMARY` is a
1259
- shared, append-only file that other steps in the same job also write to,
1260
- so overwriting it would silently discard their content. Passing both
1261
- flags writes the report to stdout AND appends it to the file. A run
1262
- stopped by Ctrl-C or SIGTERM still writes both, its stopped tasks
1263
- `aborted`. A write
1264
- failure is reported (`vx: failed to write report to …`) but does not
1265
- change the exit code — the run already happened, the same contract
1266
- `--summarize` and `--profile` follow.
1267
-
1268
- ### `--tag <k=v>`
1269
-
1270
- Labels the invocation. Repeatable; `--tag=k=v` form too. The pair is
1271
- split on the **first** `=`, so values may contain `=` (e.g. a URL). An
1272
- empty key is a parse error. Tags are recorded on the run's
1273
- `invocations` row so dashboards can filter runs by label, and reach
1274
- telemetry as `vx.tag.<key>`; a secret value in one is masked there as
1275
- in the task's output (see [Masking](./schema.md)).
1276
-
1277
1068
  ## Sandbox
1278
1069
 
1279
1070
  Sandbox isolation is opt-in **per task** via an `exec.sandbox` block in
@@ -1392,7 +1183,10 @@ run...` precedes it.
1392
1183
  heard from its nearest directory that exists and each one above it,
1393
1184
  and a watch whose
1394
1185
  directory was replaced is armed again (an OS watch holds the deleted
1395
- one; `watch-recreated-dirs.test.ts`). Under `--filter` or
1186
+ one; `watch-recreated-dirs.test.ts`). There, a project directory
1187
+ that is still the same one starts no cycle: a task writing at its
1188
+ top level moved its mtime, which macOS reports to the parent
1189
+ (`watch-project-dir-write.test.ts`). Under `--filter` or
1396
1190
  `--affected` the scope is the one resolved at start, and a new
1397
1191
  project joins it only as a dependency of it. A task's own declared outputs (`cache.outputs.files`,
1398
1192
  `outputs.workspaceFiles`; a plugin's `project` stage counts, as in
@@ -1432,7 +1226,10 @@ run...` precedes it.
1432
1226
  starts a cycle, since no cache key can see it (a user's edit to one
1433
1227
  still does in a project with a task that has a command and no cache,
1434
1228
  which reads what it likes, item 947, outside any project nested
1435
- in it) — or declared an
1229
+ in it; a write the last cycle made is not, read off the later of
1230
+ its mtime and ctime, so an output a cache hit restores with its
1231
+ saved mtime is the run's, and so is the restore's own `.vx-tmp-*`
1232
+ file, `watch-restored-output.test.ts`) — or declared an
1436
1233
  output, or the loop re-runs on it; after three such cycles in a row
1437
1234
  watch names the path and the remedy, once, and keeps going. A dev
1438
1235
  server the last cycle left running counts as that cycle for as long
@@ -1467,9 +1264,11 @@ watch events within 2000 ms; polling every 250 ms instead`.
1467
1264
  changed, then its task rows as `vx run` prints them, with no footer.
1468
1265
  The named path is the first changed one that still exists, so an
1469
1266
  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
1267
+ the cycle when nothing else changed. The `watching` line is said
1268
+ once: a cycle that re-reads the watched set (a member came or went,
1269
+ the lock or a plugin changed) says nothing more; `VX_DEBUG_WATCH=1`
1270
+ says each arm on stderr as `vx watch: armed N projects`. Errors and
1271
+ refusals go to stderr. The
1473
1272
  orchestrator is invoked again with the same options, save one: the
1474
1273
  `--affected` diff is read once, for the initial run. A later cycle
1475
1274
  runs the requested task in every project of the scope fixed at start,
@@ -1490,7 +1289,8 @@ Always ignored (no re-trigger):
1490
1289
  tracked + untracked-not-ignored — so a cycle it started could change
1491
1290
  nothing. A tracked file that matches a pattern is not ignored, by
1492
1291
  git's own rule; outside a repository nothing is.
1493
- - Files ending in `.tsbuildinfo` or `~` (editor swap files).
1292
+ - Files ending in `~` (editor swap files), and a restore's own
1293
+ `.vx-tmp-*` file.
1494
1294
  - The run's **resolved cache directory**, wherever it is. `.vx/cache/` covers the
1495
1295
  default, but `defineWorkspace({ cache: { dir } })` and `--cache-dir` can put it
1496
1296
  anywhere; watching it would let vx's own cache writes trigger the next
@@ -1541,10 +1341,10 @@ re-reads the watched set (item 971).
1541
1341
  The following flags are rejected (parser exits 1 before the initial
1542
1342
  run):
1543
1343
 
1544
- - `--dry` / `--graph` — those skip execution; nothing to watch.
1545
- - `--summarize` / `--profile` — would overwrite their target per cycle.
1546
- - `--report` / `--report-file` / `--verbosity <n>` (n > 0) / `--format json` — all
1547
- format ONE run's result; a watch loop has no single run to report. (`--verbosity 0`
1344
+ - `--dry` — it skips execution; nothing to watch.
1345
+ - `--profile` — would overwrite its target per cycle.
1346
+ - `--verbosity <n>` (n > 0) — formats
1347
+ ONE run's result; a watch loop has no single run to report. (`--verbosity 0`
1548
1348
  is accepted: it asks for what watch already prints.)
1549
1349
 
1550
1350
  A requested persistent task (`exec.persistent`, a dev server) starts
@@ -1569,7 +1369,7 @@ re-runs itself and what it depends on.
1569
1369
  - `0` — clean Ctrl+C / SIGTERM exit. A cycle in flight is torn down
1570
1370
  first — its children, and the dev server held between cycles, get the
1571
1371
  signal watch received (a Ctrl-C as SIGINT; a SIGTERM or SIGHUP as SIGTERM),
1572
- `run.killGrace` (2 s), then SIGKILL — and the process leaves only
1372
+ a 2 s grace, then SIGKILL — and the process leaves only
1573
1373
  once it has returned, so a CI cancellation never orphans a task.
1574
1374
  Also `0`, with no watch, when `--affected` selects nothing: watch
1575
1375
  prints `vx watch: nothing affected since <ref>` and exits as `vx run`
@@ -1661,16 +1461,19 @@ bound clears the whole cache when that is really what you want.
1661
1461
 
1662
1462
  ```
1663
1463
  $ vx cache clean --older-than 30d
1664
- Pruned 42 entries (1.3 GB freed)
1464
+ ✓ vx cache clean freed 1.3 GB
1665
1465
 
1666
- $ vx cache clean --older-than 7d --max-size 500M
1667
- Pruned 18 entries (320 MB freed)
1466
+ 42 entries
1668
1467
 
1669
1468
  $ vx cache clean --older-than 30d # after a SCHEMA_VERSION bump
1670
- Pruned 0 entries (0 B freed), reaped 42 orphaned artifacts (1.3 GB)
1469
+ ✓ vx cache clean freed 1.3 GB
1470
+
1471
+ 42 orphaned artifacts
1671
1472
 
1672
1473
  $ vx cache clean --older-than 30d --dry-run
1673
- Would prune 42 entries (1.3 GB), would reap 3 orphaned artifacts (12 MB)
1474
+ › vx cache clean would free 1.3 GB (dry run)
1475
+
1476
+ 42 entries · 3 orphaned artifacts
1674
1477
  ```
1675
1478
 
1676
1479
  `--dry-run` picks the victims under the same policy and counts the
@@ -1694,8 +1497,8 @@ artifact that vanishes before its restore is a miss, and the task runs
1694
1497
  Exit codes:
1695
1498
 
1696
1499
  - `0` — pruning completed (zero or more entries evicted). A workspace
1697
- that never ran has no cache: `Pruned 0 entries (0 B freed)`, and
1698
- nothing is created.
1500
+ that never ran has no cache: `› vx cache clean nothing to free`,
1501
+ and nothing is created.
1699
1502
  - `1` — parse error, missing policy, or workspace-discovery error.
1700
1503
 
1701
1504
  `vx cache clean` resolves the workspace root from cwd and honors a
@@ -1723,17 +1526,19 @@ and the directory goes only if that empties it. A directory with no
1723
1526
  names goes only if it is one (`.vx/<id>/cache` holding a `store.db`): the
1724
1527
  index is a file in the checkout, which a commit or a restored CI cache
1725
1528
  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
1529
+ `› vx cache clean nothing to free`. The next run misses every task once
1727
1530
  and saves again.
1728
1531
 
1729
1532
  ```
1730
1533
  $ vx cache clean
1731
- Cleared this repository's store, shared by its checkouts, and this workspace's run history (1.4 GB freed):
1534
+ ✓ vx cache clean freed 1.4 GB
1535
+
1732
1536
  .vx/cache
1733
1537
  /home/me/.vx/3f2a9c01b4d5e6f7/cache
1734
1538
 
1735
1539
  $ vx cache clean --global --dry-run
1736
- Would clear every repository's store and this workspace's run history (3.1 GB):
1540
+ › vx cache clean would free 3.1 GB (dry run)
1541
+
1737
1542
  .vx/cache
1738
1543
  /home/me/.vx/3f2a9c01b4d5e6f7/cache
1739
1544
  /home/me/.vx/9b8e7d6c5a4f3e2d/cache
@@ -1775,8 +1580,8 @@ add directories (X-143). Before writing, it
1775
1580
  builds the graph of every task the workspace declares, `graph` stage
1776
1581
  included, and checks the graph rules (`run.rules`), which no run
1777
1582
  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.
1583
+ `VX_E_DEPENDENCY_RULE`) among them: a broken one prints `✗ vx lock lock not written`
1584
+ with the rule's refusal under it and writes nothing.
1780
1585
 
1781
1586
  ```
1782
1587
  vx lock # Evaluate all vx.config.* now; write .vx/projects.lock.json.
@@ -1830,7 +1635,7 @@ A project with no `vx.config.*` — its tasks from a plugin's `project`
1830
1635
  stage (`turbo()` from `@vzn/vx-migrate`), or none at all — has nothing
1831
1636
  to freeze: the lock records nothing for it, `--check` does not audit
1832
1637
  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
1638
+ projects that have tasks (`0 project configs → .vx/projects.lock.json (no
1834
1639
  vx.config, so not locked: app, lib)`, five names then `and N more`),
1835
1640
  so an empty lock on a plugin-only workspace never reads like an audit.
1836
1641
  One with no task (core's default `build` aside) is not named.
@@ -2035,8 +1840,9 @@ binary keeps the old one's mode (and, as root, its owner), and must
2035
1840
  answer `--version` before the upgrade reports it installed: one that
2036
1841
  does not start on this machine, or does not answer within 10 s, is
2037
1842
  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.
1843
+ (item 1097): `✓ vx upgrade installed 0.0.2`. On the version it
1844
+ already is it says `› vx upgrade already at <version>` and downloads
1845
+ nothing.
2040
1846
 
2041
1847
  **A vx a package manager installed** (the npm package's launcher runs
2042
1848
  `…/node_modules/@vzn/vx-<os>-<arch>/vx`) is upgraded by that package
@@ -2057,13 +1863,38 @@ manager is its lockfile (`pnpm-lock.yaml`, `bun.lock`, `yarn.lock`,
2057
1863
  `package-lock.json`), else `packageManager`, else npm; the field it is
2058
1864
  listed in is kept (`--save-dev`, `--dev`, …), and pnpm at a workspace
2059
1865
  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
1866
+ vx prints what it runs, runs it there, and exits with an error if it
1867
+ fails:
1868
+
1869
+ ```
1870
+ › vx upgrade 0.0.1 → 0.0.2
1871
+
1872
+ bun add @vzn/vx@0.0.2 in /repo
1873
+ ```
1874
+
1875
+ **Inside a project with its own vx**, a global vx hands `vx upgrade` to
1876
+ the project's (§ Which vx runs), and one run moves both to the same
1877
+ version (`TAG`, or latest): first the project's dependency, then the
1878
+ global vx that started the run, each the way it was installed. Each
1879
+ line names its install, and a refusal names the one that failed
1880
+ (`vx upgrade: global vx /usr/local/bin/vx: cannot write to …`); a
1881
+ project that fails leaves the global as it was. The project's vx run
1882
+ directly (a package script, `node_modules/.bin/vx`) has no global behind it, so only
1883
+ the project moves.
1884
+
1885
+ ```
1886
+ › vx upgrade project 0.0.1 → 0.0.2
1887
+
1888
+ bun add @vzn/vx@0.0.2 in /repo
1889
+
1890
+ › vx upgrade global 0.0.1 → 0.0.2
1891
+
1892
+ /usr/local/bin/vx
1893
+
1894
+ ✓ vx upgrade global installed 0.0.2
1895
+ ```
1896
+
1897
+ 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
2067
1898
  bunx cache has nothing installed to upgrade, and says to ask for
2068
1899
  `@vzn/vx@latest` instead.
2069
1900
 
@@ -2177,8 +2008,8 @@ none is dropped in silence. An Nx flag's camelCase spelling (`--nxBail`,
2177
2008
  `--skipNxCache`), which Nx's parser takes and its docs print, is its
2178
2009
  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
2179
2010
  `vx run` that does the same, and the other verbs a hand types from
2180
- either tool name what does it here: `graph` (`--graph`), `ls`
2181
- (`vx show`), `query` (`--dry=json`), `reset` (`vx cache clean`), and
2011
+ either tool name what does it here: `graph` and `query` (`--dry=json`), `ls`
2012
+ (`vx show`), `reset` (`vx cache clean`), and
2182
2013
  `daemon`, `login`, `logout`, `link` and `unlink`, which vx has no use
2183
2014
  for. Nx's `project:target` (`vx run web:build`,
2184
2015
  when `web` declares `build`) is answered with `vx run web#build`: from
@@ -2197,11 +2028,10 @@ copy to the source.
2197
2028
  | turbo | `--continue=dependencies-successful` | alias | `--continue=deps-ok` |
2198
2029
  | turbo | `--continue` | same | `--continue[=never\|deps-ok\|always]` |
2199
2030
  | turbo | `--dry-run` | alias | `--dry[=text\|json]` |
2200
- | turbo | `--graph=<file>.svg\|png\|json\|html\|…` | refuse | vx writes Graphviz DOT only: `--graph=<file>.dot`, then `dot -Tsvg` |
2201
- | turbo | `--graph` | same | `--graph[=<file>.dot]` |
2031
+ | turbo | `--graph` | refuse | `--dry=json` prints the planned task graph, each task with its deps |
2202
2032
  | turbo | `--force[=<bool>]` | same | `--force`: skip cache reads, keep writes |
2203
2033
  | turbo | `--affected` | same | `--affected[=<base>]` |
2204
- | turbo | `--summarize[=<bool>]` | same | `--summarize[=<path>]` |
2034
+ | turbo | `--summarize` | refuse | use `vx last --format json` for the run's JSON record |
2205
2035
  | turbo | `--output-logs=new-only` | refuse | use `--output-logs=full` (a hit replays its log) or `errors-only` |
2206
2036
  | turbo | `--output-logs <v>` | same | `--output-logs full\|errors-only\|hash-only\|none` |
2207
2037
  | turbo | `--no-cache` | same | `--no-cache` reads nothing either; Turbo's (reads, no writes) is `--cache-policy local:r,remote:r` |
@@ -2230,8 +2060,8 @@ copy to the source.
2230
2060
  | turbo | `--env-mode <v>` | refuse | vx passes only the variables a task declares (strict): list the rest in `exec.env.passThrough` |
2231
2061
  | turbo | `--framework-inference <v>` | refuse | under `turbo()` inference is Turbo's; take a name back with a `!` entry in the task's `env` |
2232
2062
  | turbo | `--global-deps <v>` | refuse | declare them in `cache.inputs.workspaceFiles` (under `turbo()`, turbo.json's `globalDependencies`) |
2233
- | turbo | `--json` | refuse | use `--format json` for the run's result on stdout, `--dry=json` for the plan |
2234
- | turbo | `--log-file` | refuse | use `--summarize[=<path>]` for the run's JSON record |
2063
+ | turbo | `--json` | refuse | use `vx last --format json` for the run's result, `--dry=json` for the plan |
2064
+ | turbo | `--log-file` | refuse | use `vx last --format json` for the run's JSON record |
2235
2065
  | turbo | `--preflight` | refuse | set `turboCache({ preflight: true })` or `TURBO_PREFLIGHT=1` |
2236
2066
  | turbo | `--remote-cache-timeout <v>` | refuse | set `turboCache({ timeoutMs })` or `TURBO_REMOTE_CACHE_TIMEOUT` |
2237
2067
  | turbo | `--single-package` | refuse | a repo with no workspaces is one project already: drop it |
@@ -2273,9 +2103,8 @@ copy to the source.
2273
2103
  ## Machine-readable output
2274
2104
 
2275
2105
  `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
2278
- checked-in JSON Schema (draft 2020-12) shipped with the package:
2106
+ `--format json`. Each prints one JSON document on one line, whose shape
2107
+ is a checked-in JSON Schema (draft 2020-12) shipped with the package:
2279
2108
  `schemas/show.json`, `schemas/info.json`, `schemas/why.json`,
2280
2109
  `schemas/last.json`, `schemas/cache.json`
2281
2110
  (`node_modules/@vzn/vx/schemas/` in an install). Every object closes its
@@ -2287,13 +2116,9 @@ schemas: each `{ … }` a verb's section shows is an object its schema
2287
2116
  closes with those keys, each object the verb prints whole is shown, and
2288
2117
  `vx info`'s field list is its schema's top level.
2289
2118
 
2290
- `vx run --format json` prints, when the run ends, the `--summarize`
2291
- document (`schemas/summary.json`: `runId`, `ok`, `exitCode`, one row per
2292
- task with its status, exit code and key). Everything else the run writes,
2293
- the frame and the tasks' output with it, goes to stderr, so
2294
- `vx run build --all --format json | jq .ok` reads only the result. It
2295
- refuses `--dry` (use `--dry=json`), a bare `--graph` and
2296
- `--report=markdown`, which print on stdout too.
2119
+ A run's result as JSON is `vx last --format json`, read after the run
2120
+ from the run history: one row per task with its status, key and cache
2121
+ result. `vx run --dry=json` prints the plan.
2297
2122
 
2298
2123
  ### Error codes
2299
2124
 
@@ -2321,12 +2146,15 @@ lists core's verbs. A plugin's verb exists once its plugin is in
2321
2146
  No project in scope declares a requested task. The message lists the
2322
2147
  tasks it found; `vx show --format json` lists each project's. Check the
2323
2148
  spelling, and the scope: `--filter`, `--all`, or the project the working
2324
- directory is in.
2149
+ directory is in. A run reads its configs from the lock, so a task
2150
+ added to a `vx.config` since the last `vx lock` is not there yet: run
2151
+ `vx lock`.
2325
2152
 
2326
2153
  #### `VX_E_UNKNOWN_PROJECT`
2327
2154
 
2328
2155
  `vx show` names a project the workspace does not have. A project is named
2329
- by its `package.json` `name`; `vx show --format json` lists them.
2156
+ by its config's `name`, else by a plugin (a lockfile plugin names it from
2157
+ its `package.json`), else by its directory; `vx show --format json` lists them.
2330
2158
 
2331
2159
  #### `VX_E_NO_HISTORY`
2332
2160
 
@@ -2406,8 +2234,9 @@ target, fix its spelling, or drop the entry.
2406
2234
  `vx lock` refuses it, not a run: a run trusts the graph the lock holds.
2407
2235
 
2408
2236
  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
2237
+ project edges; add pnpm()/bun()/npm()/yarn() and install so its
2238
+ lockfile exists, or a plugin that returns `deps` ``. A lockfile plugin
2239
+ whose lockfile is absent gives no edges. Core reads no edge from a manifest, so with no plugin
2411
2240
  returning `deps` every `^` would resolve to nothing and fold no
2412
2241
  upstream into the key. Declare the lockfile plugin in
2413
2242
  `.vx/workspace.ts` (or a plugin of your own that returns `deps`); a run
@@ -2443,12 +2272,11 @@ https://github.com/vznjs/vx/issues.
2443
2272
  A plugin can give its refusal a code: `new UserError(message, 'MY_CODE')`.
2444
2273
 
2445
2274
  **Streams.** A verb whose stdout is a product (a `--format json`
2446
- document, `--dry` / `--dry=json`, `--graph`'s DOT, a completion
2275
+ document, `--dry` / `--dry=json`, a completion
2447
2276
  script) writes that product alone there; a notice or warning it meets
2448
2277
  on the way (a cache index from another vx version, a plugin's warning) goes
2449
2278
  to stderr, as every refusal does. `vx run`'s stdout is the run's frame,
2450
- the tasks' output it carries, except under `--format json`, where that
2451
- moves to stderr. `tests/cli-streams.test.ts` holds each
2279
+ the tasks' output it carries. `tests/cli-streams.test.ts` holds each
2452
2280
  product verb to it under a notice.
2453
2281
 
2454
2282
  ## `vx show`
@@ -2487,23 +2315,25 @@ Nx's spellings name these: `vx show projects` (when no project or task
2487
2315
  has that name) and `vx show project <name>` say `vx show` and
2488
2316
  `vx show <name>`.
2489
2317
 
2490
- No target: one line per project — name, root-relative dir, task count,
2491
- and a `(no vx config)` marker for config-less packages; one whose
2492
- tasks all come from plugins reads `N tasks (no vx config; from
2493
- plugins)`, and one a configured project depends on reads `1 task (no
2494
- vx config; default build)`: a run loads it for that dependant's
2495
- `^build` (X-48). A project's `tags` follow in brackets. With `--format json`
2318
+ No target: a table, one row per project — name, root-relative dir,
2319
+ task count, and `no vx config` for config-less packages; one whose
2320
+ tasks all come from plugins reads `N tasks from plugins (no vx
2321
+ config)`, and one a configured project depends on reads `1 task,
2322
+ default build (no vx config)`: a run loads it for that dependant's
2323
+ `^build` (X-48). A `tags` column appears when any project has tags. With `--format json`
2496
2324
  it's an array of `{ name, dir, tags: string[], tasks: string[] }`.
2497
2325
 
2498
2326
  ```
2499
2327
  $ vx show
2500
- app packages/app 3 tasks [scope:web]
2501
- bare packages/bare (no vx config)
2328
+ project dir tasks tags
2329
+ app packages/app 3 tasks scope:web
2330
+ bare packages/bare no vx config
2502
2331
  ```
2503
2332
 
2504
- Under its header `vx show <project>` prints its `tags`.
2505
- `vx show <project>` prints a block per task with every field the run
2506
- reads: description, command (`(group)` for group tasks), `dependsOn`,
2333
+ A report naming the project opens it: its `dir`, its `tags`, and
2334
+ `config none` for a package with no config file. Then
2335
+ `vx show <project>` prints a block per task, headed by its
2336
+ `project#task`, with every field the run reads: description, command (`(group)` for group tasks), `dependsOn`,
2507
2337
  `timeout`, `retries`, `env.passThrough` / `env.define` / `env.secret`,
2508
2338
  `remote`, `sandbox`, `persistent`, `interactive` (`yes` / `no`), and the
2509
2339
  cache block
@@ -2521,15 +2351,15 @@ list and `vx show <project>`, but `vx show build`, like
2521
2351
 
2522
2352
  ```
2523
2353
  $ vx show app#build
2524
- app — packages/app
2525
2354
 
2526
- build
2527
- description: compile the app
2528
- command: tsc -b
2529
- dependsOn: ^build
2530
- inputs.files: src/**
2531
- inputs.env: NODE_ENV
2532
- outputs.files: dist/**
2355
+ ─ app#build ──────────────────────────────────────────────────
2356
+ dir packages/app
2357
+ description compile the app
2358
+ command tsc -b
2359
+ dependsOn ^build
2360
+ inputs.files src/**
2361
+ inputs.env NODE_ENV
2362
+ outputs.files dist/**
2533
2363
  ```
2534
2364
 
2535
2365
  Unknown project / task names exit `1` with the same near-miss hint
@@ -2542,6 +2372,9 @@ or `; projects and tasks: …` (M-57).
2542
2372
 
2543
2373
  An empty target (`vx show ''`) is refused: omit it to list every project.
2544
2374
 
2375
+ In a workspace with no project, the list is empty and `vx show` says so
2376
+ on stderr (`no projects`) and names `vx init`.
2377
+
2545
2378
  Exit codes: `0` success; `1` parse error or unknown target.
2546
2379
 
2547
2380
  Two runs on one workspace take turns: the second waits for the first's
@@ -2572,24 +2405,26 @@ counts are zero and `projects lock` says `no` — a broken config is
2572
2405
 
2573
2406
  ```
2574
2407
  $ vx info
2575
- vx: 0.0.0
2576
- bun: 1.4.2
2577
- git: 2.53.0
2578
- git status cache: core.fsmonitor, core.untrackedCache off
2579
- workspace root: /work/repo
2580
- projects: 12 (34 tasks)
2581
- plugins: 2 — @vzn/vx-reapi (executor, cache); @vzn/vx-otel (telemetry)
2582
- workers: 2 — cgroup CPU quota 2 of 8 cores
2583
- memory: 13 GB usable — cgroup limit; the machine has 16 GB
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
2587
- cache entries: 42 (1.3 GB)
2588
- orphans: 3 artifacts (12 MB) the index does not know — `vx cache clean` reaps them
2589
- task runs (24h): 7 (5 cache hits)
2590
- flaky tasks: 1 — web#test (3 of 11 runs failed on unchanged inputs)
2591
- sandbox: available (9 tasks declare exec.sandbox)
2592
- projects lock: yes
2408
+
2409
+ ─ vx info ────────────────────────────────────────────────────
2410
+ vx 0.0.0
2411
+ bun 1.4.2
2412
+ git 2.53.0
2413
+ git status cache core.fsmonitor, core.untrackedCache off
2414
+ workspace root /work/repo
2415
+ projects 12 (34 tasks)
2416
+ plugins 2 — @vzn/vx-reapi (executor, cache); @vzn/vx-otel (telemetry)
2417
+ workers 2 — cgroup CPU quota 2 of 8 cores
2418
+ memory 13 GB usable — cgroup limit; the machine has 16 GB
2419
+ cache dir /work/repo/.vx/cache (8.2 MB, this workspace's run history)
2420
+ cache store /home/me/.vx/3f2a9c1e7b4d5a60/cache (1.3 GB, shared by this repo's checkouts)
2421
+ cache versions keys vx-cache-v43 · index schema v40
2422
+ cache entries 42 (1.3 GB)
2423
+ orphans 3 artifacts (12 MB) the index does not know — `vx cache clean` reaps them
2424
+ task runs (24h) 7 (5 cache hits)
2425
+ flaky tasks 1 — web#test (3 of 11 runs failed on unchanged inputs)
2426
+ sandbox available (9 tasks declare exec.sandbox)
2427
+ projects lock yes
2593
2428
  ```
2594
2429
 
2595
2430
  - `git` shows `(not found)` when the binary is missing.
@@ -2844,7 +2679,9 @@ command that exits 137 on its own reads the same). Where the run's
2844
2679
  footer gave a reason, the row ends with it instead: `timed out`,
2845
2680
  `never ready: exited`, `2 sandbox violations`, and for a skipped task
2846
2681
  `after lib#build failed` (the v27 columns; rows older than them read
2847
- as before). Rows run failures first, then what executed or was
2682
+ as before). A task whose key met a remote failure ends with it, read or
2683
+ write alike: `remote ✗ download: connection refused; upload: 503` (the
2684
+ v40 column, `remoteError` in `--format json`). Rows run failures first, then what executed or was
2848
2685
  skipped, then hits; past sixteen hits the rest fold into one line with
2849
2686
  their count (`… +980 more cache hits`) — a thousand-task warm run is a
2850
2687
  thousand rows otherwise, with the one failure a screen above the
@@ -2905,9 +2742,9 @@ measured. `--stats` does not combine with `--list`, `--failed` or
2905
2742
  `--log`; `--branch` combines only with `--stats`. `--list --format json`
2906
2743
  an array of the same `invocation` objects, newest first. An unknown run id fails
2907
2744
  loud and points at `--list`. Before any run, every form says so
2908
- (`vx last: no recorded runs yet — run something first`, exit 1; `--list`
2909
- prints `no recorded runs`, exit 0), and past green runs only,
2910
- `--list --failed` prints `no recorded run failed`. A run id may be typed as a unique prefix; a
2745
+ (`› vx last no runs yet` with `fix vx run`, exit 1; `--list` prints
2746
+ `› vx last no runs yet`, exit 0), and past green runs only,
2747
+ `--list --failed` prints `› vx last no failed runs`. A run id may be typed as a unique prefix; a
2911
2748
  prefix several runs share fails and lists them. A replayed run with
2912
2749
  failures ends with the command that re-runs them (`re-run what failed:
2913
2750
  vx run app#test -- …`, with the arguments the run forwarded to the
@@ -2994,7 +2831,7 @@ export function mcp(): VxPlugin {
2994
2831
  mcp: {
2995
2832
  description: 'serve the run history to an AI agent over stdio',
2996
2833
  async run(argv, ctx) {
2997
- // ctx.workspaceRoot, ctx.cacheDir, ctx.concurrency, ctx.warn(...)
2834
+ // ctx.workspaceRoot, ctx.cacheDir, ctx.concurrency, ctx.cache, ctx.warn(...)
2998
2835
  return 0 // the process exit code
2999
2836
  },
3000
2837
  },
@@ -3005,6 +2842,9 @@ export function mcp(): VxPlugin {
3005
2842
 
3006
2843
  The plugin's name is its package name, which `definePlugin` reads from
3007
2844
  `import.meta`; a plain object is refused when the workspace loads.
2845
+ `ctx.cache` reads the plugin's own entries as `setup` writes them (the
2846
+ local store, then each remote the workspace's cache plugins supply,
2847
+ under the run's scope), read-only, opened on first use.
3008
2848
 
3009
2849
  (`@vzn/vx-schedule-history` ships exactly this: `vx history`, what it
3010
2850
  learned per task and the reservation its `admit` hook packs.) The dispatcher tries core's verbs
@@ -3032,8 +2872,8 @@ names no package: it lists what the workspace itself declares.
3032
2872
 
3033
2873
  ## Environment variables vx reads
3034
2874
 
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
2875
+ Every setting has a variable derived from its path (`run.timeout` →
2876
+ `VX_RUN_TIMEOUT`), beating `.vx/workspace` and beaten by the flag; the
3037
2877
  table in `docs/schema.md` § Settings gives each value's form and
3038
2878
  default. Each is checked once before any verb runs: an empty one is
3039
2879
  unset, a value its kind does not take is refused naming the form, and a
@@ -3053,9 +2893,6 @@ what a TASK sees is `exec.env` in `docs/schema.md`.
3053
2893
  | `VX_RUN_RETRIES` | [`run.retries`](schema.md#settings) | Re-runs of a failed task, for tasks without their own `exec.retries`. |
3054
2894
  | `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
2895
  | `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
2896
  | `VX_RUN_RULES_EXCLUSIVE_OUTPUTS` | [`run.rules.exclusiveOutputs`](schema.md#settings) | Refuse two tasks whose outputs overlap even when an edge orders them. |
3060
2897
  | `VX_RUN_RULES_UPFRONT_KEYS` | [`run.rules.upfrontKeys`](schema.md#settings) | Refuse a task whose inputs can match another task's outputs. |
3061
2898
  | `VX_CACHE_DIR` | [`cache.dir`](schema.md#settings) | Where the cache lives, relative to the workspace root; named, it holds the whole cache. |
@@ -3066,11 +2903,11 @@ what a TASK sees is `exec.env` in `docs/schema.md`.
3066
2903
  | `VX_CACHE_DOWNLOAD` | [`cache.download`](schema.md#settings) | Which remote hits land on disk: every one, only the requested tasks, or none. |
3067
2904
  | `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
2905
  | `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
2906
  | `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
2907
  | `VX_WATCH_POLL` | [`watch.poll`](schema.md#settings) | Poll for changes from the start instead of the OS watcher. |
3072
2908
  | `VX_CONFIG_WORKER_TIMEOUT` | [`config.workerTimeout`](schema.md#settings) | How long one config evaluation may take. |
3073
2909
  | `VX_DEBUG_TIMING` | [`debug.timing`](schema.md#settings) | Print the run's stage timings to stderr. |
2910
+ | `VX_DEBUG_WATCH` | [`debug.watch`](schema.md#settings) | `vx watch`: say on stderr each time the loop arms, with the projects it watches. |
3074
2911
  | `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
2912
  | `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
2913
  | `VX_RUN_WORKSPACE`, `VX_RUN_TASK` | — | Set by vx on every task (below); a `vx run` a task starts reads them back. |
@@ -3087,9 +2924,8 @@ Colors are the two conventions in § Output format › Colors (`NO_COLOR`,
3087
2924
  `FORCE_COLOR`). A truthy `CI` picks the CI output flow and
3088
2925
  `GITHUB_ACTIONS` its `::group::` framing (§ Output); the run's
3089
2926
  `invocations` row records the provider, the first truthy of
3090
- `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, `CIRCLECI`, then `CI`. Core never reads `GITHUB_STEP_SUMMARY`: `--report`
3091
- prints to stdout, and `--report-file=<path>` appends to a file, so on
3092
- Actions pass `--report-file="$GITHUB_STEP_SUMMARY"`. A sandboxed
2927
+ `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, `CIRCLECI`, then `CI`. Core never reads `GITHUB_STEP_SUMMARY`; the job summary is
2928
+ `@vzn/vx-ci`'s. A sandboxed
3093
2929
  task's temp dir sits under `CLAUDE_CODE_TMPDIR` (else `CLAUDE_TMPDIR`,
3094
2930
  else `/tmp/claude`), the sandbox runtime's own convention
3095
2931
  (`docs/modules/sandbox-runtime.md`).
@@ -3124,8 +2960,8 @@ write ../shared/notes.txt
3124
2960
  Every section is conditional: the `$ <command>` line only for an
3125
2961
  executed task (success or failed — a hit replays its stored output and
3126
2962
  shows none), `OUTPUT` only when the task wrote something,
3127
- `SANDBOX VIOLATIONS (N)` only when the sandbox recorded some or vx
3128
- has a note on a failure. The
2963
+ `SANDBOX VIOLATIONS (N)` only when the sandbox recorded some (`N`
2964
+ counts them) or `SANDBOX` when vx has only a note on a failure. The
3129
2965
  header carries the outcome (`restored-local • abc12345`, `failed (exit
3130
2966
  N)`, …) and the footer repeats it after the duration. A test renders
3131
2967
  this block and checks it against this page, byte for byte.
@@ -3165,16 +3001,34 @@ A broad run looks like:
3165
3001
  tasks ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰
3166
3002
  2 success · 2 total
3167
3003
  cache ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰
3168
- 1 miss · 1 local
3004
+ local 1↓ 1↑
3169
3005
 
3170
3006
  info 8 workers · local cache
3171
3007
  time 5.34s · 900ms saved
3172
3008
  ```
3173
3009
 
3174
3010
  The three bars are meters: `projects` is what the run covered against
3175
- the workspace, `tasks` is failed / success / skipped, `cache` is miss /
3176
- no-cache / up-to-date / local / remote (a skipped task rides it too, so
3177
- the two bars fill alike; its count is on the tasks legend only). The
3011
+ the workspace, `tasks` is failed / success / skipped, `cache` has a
3012
+ segment per legend state (a skipped task rides it too, so the two bars
3013
+ fill alike; its count is on the tasks legend only). Its legend reads
3014
+ per layer: `local 1✓ 1∅ 2↓ 1↑ · remote 3↓ 4↑ 2✗ 1⇡`. `✓` is a fresh
3015
+ hit (outputs already on disk, whichever layer answered, so it counts
3016
+ under local), `∅` a task with no cache, `↓` is restored from that
3017
+ layer and `↑` saved to it, `✗` a task a remote read or write failed
3018
+ for, and `⇡` an upload still running (live only, always last). The
3019
+ region stays through vx's closing steps, worker rows gone, and the
3020
+ final footer replaces it in place. Each task counts once, under its strongest state: a
3021
+ failed remote read or write, then an upload running, then saved to a
3022
+ remote, then saved here, then restored, then fresh, then no cache; so
3023
+ a task saved here and uploaded is a remote `↑`. A key the remote already
3024
+ held is not sent, so it is no remote `↑`. Each count is painted
3025
+ in its segment's colour, number and glyph alike: `✓` green, `∅` dim,
3026
+ local blue, remote dark blue, `✗` and a miss red, `⇡` cyan; the
3027
+ `local` and `remote` words are dim. A layer shows only when the run's
3028
+ `--cache-policy` uses it. `N not saved` (ran, succeeded, saved
3029
+ nothing), `N not downloaded` (outputs left in the remote store) and
3030
+ every count show only when non-zero. `vx last` names each remote
3031
+ failure on its task's row. The
3178
3032
  `time` row is the wall time, then what the cache saved (the hits' stored
3179
3033
  exec times, never the restore), then the spread over executed tasks
3180
3034
  only — a hit's restore time never enters it. The footer never says a
@@ -3192,12 +3046,22 @@ running a group focused surfaces its real member tasks instead.
3192
3046
  **Nothing below the footer.** The footer is the run's last word.
3193
3047
  What a run has to say about one task rides that task's row; what it
3194
3048
  says about the run (a server that died, outputs left remote, a file
3195
- `--summarize` / `--profile` wrote, a history, upload, plugin or sandbox
3049
+ `--profile` wrote, a history, upload, plugin or sandbox
3196
3050
  warning) prints above the footer: it prints once the run's closing work
3197
3051
  is done, the `VX_DEBUG_TIMING=1` table included. Cache housekeeping
3198
3052
  (`cache.retention`) prints nothing. Only a kept server's own output
3199
3053
  follows it.
3200
3054
 
3055
+ **Narrow terminals.** The footer is 62 columns: its rule and 50-cell
3056
+ bars. On a terminal narrower than that the bars shrink and the rule
3057
+ with them, as do a frame's section rules, so none wraps onto a second
3058
+ row. Off a terminal they keep their width.
3059
+
3060
+ **Warnings on stderr.** A run's warnings, such as `[vx] app#build:
3061
+ cache.outputs matched no files …` or a plugin's, go to stderr. Stdout
3062
+ carries only the rows, the frames and the footer, so `vx run build >
3063
+ log.txt` keeps them out of the log.
3064
+
3201
3065
  **Skipped rows.** A red run lists every task that never started as a
3202
3066
  row naming the failure that blocked it, so the footer's `1 skipped`
3203
3067
  always has a name above it:
@@ -3259,6 +3123,7 @@ ANSI truecolor (`ansi-16m`) sequences, gated by env:
3259
3123
  | `FORCE_COLOR=0` / `=false` | Force off, on a TTY too. |
3260
3124
  | `FORCE_COLOR=…` (any other) | Force on — `1`, `true`, `2`, `3`, the empty string, etc. |
3261
3125
  | (neither) | On iff `stdout.isTTY`. |
3126
+ | `TERM=dumb` | Off unless `FORCE_COLOR` forces it, and no live region. |
3262
3127
 
3263
3128
  `0` and `false` are `FORCE_COLOR`'s "off" by the convention
3264
3129
  supports-color, chalk and Node follow; until 2026-09-24 vx read every
@@ -3284,7 +3149,7 @@ Only a run's own output (`vx run`, `vx watch`) is painted, and the
3284
3149
  help on a terminal: headings bold, `vx <verb>` and each flag or example
3285
3150
  in cyan; `NO_COLOR` or `FORCE_COLOR=0` turns it off
3286
3151
  (`tests/help-colors.test.ts`). Every other verb (`show`, `info`, `why`,
3287
- `last`, a `--dry` plan, `--graph`, `cache clean`), and the help off a
3152
+ `last`, a `--dry` plan, `cache clean`), and the help off a
3288
3153
  terminal, prints plain text whatever `FORCE_COLOR` says, so a script
3289
3154
  that forces colour for a run's log still parses them
3290
3155
  (`tests/cli-colors-e2e.test.ts`).
@@ -3301,7 +3166,7 @@ Reads try local first, then remote (hydrating local on remote hit),
3301
3166
  with a background prefetch pass overlapping remote GETs with
3302
3167
  execution. Writes go to local immediately; the remote upload is a
3303
3168
  fire-and-forget background task drained at end of run — failures are
3304
- logged via `onRemoteError` but never fail the build.
3169
+ warned but never fail the build.
3305
3170
 
3306
3171
  For any OTHER cache server (a Turbo-wire deployment, S3-direct, …),
3307
3172
  implement core's `RemoteCacheLayer` interface in a plugin's `cache`
@@ -3316,7 +3181,7 @@ consult).
3316
3181
  size, runs + hits in the last 24 h). For anything deeper, vx records
3317
3182
  every task to a `runs` table in `cache.db` (UUIDv7 `run_id`, hrtime
3318
3183
  wallclock spans, cpu_ms, peak RSS, status, cache_hit flag) plus one
3319
- `invocations` header row per run (command, git/CI context, tags,
3184
+ `invocations` header row per run (command, git/CI context,
3320
3185
  counts). The SQLite file IS the API:
3321
3186
 
3322
3187
  ```sh
@@ -3365,7 +3230,7 @@ Surface:
3365
3230
 
3366
3231
  - `run(options)` — execute. Returns `Promise<RunSummary>`.
3367
3232
  - `planRun(options)` — predict, no execute. Returns
3368
- `Promise<RunPlan>`. Used by `--dry` / `--graph`.
3233
+ `Promise<RunPlan>`. Used by `--dry`.
3369
3234
  - `prepareRun(options, log)` — the shared setup (discovery → configs →
3370
3235
  graph → cache). What an embedder builds on.
3371
3236
  - `defineProject` / `defineWorkspace` — identity helpers for type