@vzn/vx 0.0.639 → 0.0.641

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 (88) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +158 -106
  3. package/docs/cli.md +80 -45
  4. package/docs/execution.md +11 -10
  5. package/docs/features.md +9 -8
  6. package/docs/patterns.md +1 -1
  7. package/docs/schema.md +252 -190
  8. package/docs/security.md +5 -4
  9. package/launcher.sh +9 -2
  10. package/package.json +5 -5
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +4 -3
  13. package/src/bin.ts +8 -6
  14. package/src/cache/archive.ts +248 -41
  15. package/src/cache/cache.ts +46 -19
  16. package/src/cache/file-hashes.ts +7 -12
  17. package/src/cache/fs-clock.ts +62 -0
  18. package/src/cache/git-inputs.ts +2 -2
  19. package/src/cache/index.ts +6 -0
  20. package/src/cache/inputs.ts +228 -111
  21. package/src/cache/key-fold.ts +23 -3
  22. package/src/cache/layer.ts +15 -8
  23. package/src/cache/layered-cache.ts +8 -7
  24. package/src/cache/linked-inputs.ts +87 -0
  25. package/src/cache/scoped-cache.ts +8 -2
  26. package/src/cli/foreign-flags.ts +1 -1
  27. package/src/cli/help.ts +7 -1
  28. package/src/cli/init.ts +23 -11
  29. package/src/cli/last.ts +24 -1
  30. package/src/cli/plugin-templates.ts +2 -2
  31. package/src/cli/select.ts +16 -6
  32. package/src/cli/show.ts +9 -6
  33. package/src/cli/upgrade.ts +132 -21
  34. package/src/cli/watch-filter.ts +23 -12
  35. package/src/cli/watch-fs.ts +2 -1
  36. package/src/cli/watch-judge.ts +49 -5
  37. package/src/cli/watch-set.ts +18 -8
  38. package/src/cli/watch.ts +74 -2
  39. package/src/cli/why.ts +5 -3
  40. package/src/config.ts +19 -15
  41. package/src/exec/executor.ts +4 -4
  42. package/src/graph/dependency-spec.ts +2 -2
  43. package/src/graph/folds.ts +236 -0
  44. package/src/graph/index.ts +8 -1
  45. package/src/graph/scheduler.ts +41 -15
  46. package/src/graph/task-graph.ts +135 -73
  47. package/src/handoff.ts +5 -0
  48. package/src/index.ts +2 -2
  49. package/src/orchestrator/affected-tasks.ts +20 -19
  50. package/src/orchestrator/download-policy.ts +16 -22
  51. package/src/orchestrator/excluded-keys.ts +4 -5
  52. package/src/orchestrator/execute-task.ts +29 -25
  53. package/src/orchestrator/hit-restore.ts +101 -27
  54. package/src/orchestrator/index.ts +1 -0
  55. package/src/orchestrator/keyed-projects.ts +12 -51
  56. package/src/orchestrator/local-shortcircuit.ts +9 -1
  57. package/src/orchestrator/persistent-task.ts +3 -3
  58. package/src/orchestrator/placement.ts +2 -2
  59. package/src/orchestrator/plan.ts +1 -1
  60. package/src/orchestrator/plugin-host.ts +66 -27
  61. package/src/orchestrator/plugin.ts +12 -2
  62. package/src/orchestrator/prepare.ts +12 -5
  63. package/src/orchestrator/projects.ts +7 -10
  64. package/src/orchestrator/run-report.ts +145 -46
  65. package/src/orchestrator/run-steps.ts +57 -0
  66. package/src/orchestrator/run.ts +76 -44
  67. package/src/orchestrator/sandbox-fix.ts +1 -1
  68. package/src/orchestrator/sandbox-request.ts +2 -2
  69. package/src/orchestrator/stable-keys.ts +59 -26
  70. package/src/orchestrator/steps.ts +26 -6
  71. package/src/orchestrator/task-hash.ts +31 -9
  72. package/src/orchestrator/telemetry-host.ts +3 -3
  73. package/src/orchestrator/telemetry.ts +9 -4
  74. package/src/orchestrator/upstream.ts +4 -181
  75. package/src/report/framed-output.ts +1 -1
  76. package/src/report/logger.ts +5 -2
  77. package/src/report/status-line.ts +7 -3
  78. package/src/report/summary.ts +6 -4
  79. package/src/settings.ts +11 -17
  80. package/src/util/index.ts +3 -0
  81. package/src/util/paths.ts +39 -5
  82. package/src/workspace/affected.ts +81 -2
  83. package/src/workspace/config-eval.ts +2 -2
  84. package/src/workspace/config-imports.ts +1 -1
  85. package/src/workspace/config-schema.ts +142 -143
  86. package/src/workspace/lockfile.ts +1 -1
  87. package/src/workspace/migration.ts +19 -3
  88. package/src/workspace/project-loader.ts +5 -4
package/docs/schema.md CHANGED
@@ -87,7 +87,7 @@ absent, which is what a conditional spread
87
87
  held to this: its plugins are objects of functions.
88
88
 
89
89
  `tasks` is a `Record<string, TaskConfig>`. Task names are strings
90
- referenced by `dependsOn`, by `cache.inputs.tasks`, and by the CLI
90
+ referenced by `dependsOn`, by `inputs.tasks`, and by the CLI
91
91
  (`vx run <taskName>` or `vx run <pkg>#<taskName>`), so a name those
92
92
  could not spell is refused at load: an empty one, one with surrounding
93
93
  whitespace, one holding `#` (the project separator) or `*` (a pattern),
@@ -109,12 +109,13 @@ interface TaskConfig {
109
109
  description?: string // one-line blurb for the picker / --dry view
110
110
  exec?: ExecConfig // omit to declare a group task
111
111
  dependsOn?: readonly string[] // Turbo/Nx micro-syntax
112
- cache?: CacheConfig // caching is opt-in; on a group, a key with no outputs
112
+ inputs?: InputsConfig // what the task reads: its key, cached or not
113
+ cache?: CacheConfig // caching is opt-in; needs `exec` and `inputs`
113
114
  }
114
115
  ```
115
116
 
116
117
  A task either has an `exec` (it does work) or omits `exec` and declares
117
- `dependsOn` or `cache` (it's a **group task**: an aggregator, or a key).
118
+ `dependsOn` or `inputs` (it's a **group task**: an aggregator, or a key).
118
119
  The loader rejects a task that has none of them — a no-op standalone
119
120
  task is almost always a config mistake. `dependsOn: []` is the deliberate form: an
120
121
  explicit empty group, for a package that wants a task by that name to
@@ -122,7 +123,7 @@ exist and do nothing.
122
123
 
123
124
  A project whose config and plugins declare no `build` gets one (owner,
124
125
  2026-10-04): a group with `dependsOn: ['^build']` keyed on every file of
125
- the project (`cache.inputs.files: ['**']`, no outputs). It runs nothing,
126
+ the project (`inputs.files: ['**']`, no outputs). It runs nothing,
126
127
  but a dependant behind `^build` folds its key, so a package consumed as
127
128
  source moves its dependants' keys and reaches them under `--affected`.
128
129
  A config can declare a keyed group too (Group tasks, below). A
@@ -156,8 +157,8 @@ places:
156
157
  test: {
157
158
  description: 'bun test against the tests/ tree',
158
159
  exec: { command: 'bun test' },
160
+ inputs: { files: ['src/**', 'tests/**'] },
159
161
  cache: {
160
- inputs: { files: ['src/**', 'tests/**'] },
161
162
  outputs: { files: [] },
162
163
  },
163
164
  }
@@ -319,7 +320,7 @@ daemon, a Docker socket, a device, a VPN-only host.
319
320
  declares — a worker cannot reach a port served on the submitter. So is
320
321
  a task with a `sandbox` block, and its dependants: the sandbox is this
321
322
  machine's machinery, and a boundary a worker does not enforce would
322
- pass vacuously. So is a task whose key folds `cache.inputs.runtime` or
323
+ pass vacuously. So is a task whose key folds `inputs.runtime` or
323
324
  `workspaceRuntime`: the key holds this machine's answer, which a worker
324
325
  cannot prove it shares. Its dependants are not pinned.
325
326
  - **Placement is decided once per task**, before scheduling, so the
@@ -341,8 +342,8 @@ daemon, a Docker socket, a device, a VPN-only host.
341
342
  ```ts
342
343
  install: {
343
344
  exec: { command: 'pnpm install --frozen-lockfile', remote: 'only' },
345
+ inputs: { files: ['package.json', 'pnpm-lock.yaml'] },
344
346
  cache: {
345
- inputs: { files: ['package.json', 'pnpm-lock.yaml'] },
346
347
  outputs: { files: ['node_modules/**'] },
347
348
  },
348
349
  },
@@ -539,11 +540,11 @@ highest priority:
539
540
  build; if one does, say so explicitly:
540
541
 
541
542
  ```ts
542
- cache: { inputs: { files: ['src/**'], env: ['NODE_OPTIONS'] }, outputs: { files: [] } }
543
+ inputs: { files: ['src/**'], env: ['NODE_OPTIONS'] }, cache: { outputs: { files: [] } }
543
544
  ```
544
545
 
545
546
  That folds its value into the key. The two axes are orthogonal on
546
- purpose: `cache.inputs.env` decides what the key _sees_,
547
+ purpose: `inputs.env` decides what the key _sees_,
547
548
  `exec.env.passThrough` decides what the child _gets_.
548
549
 
549
550
  2. **`passThrough`** names → value taken from host `process.env` at
@@ -564,14 +565,14 @@ exec: {
564
565
  }
565
566
  ```
566
567
 
567
- Every name in these lists (and in `cache.inputs.env`) must be one an
568
+ Every name in these lists (and in `inputs.env`) must be one an
568
569
  environment can hold: non-empty, with no `=` and no NUL, and a `define`
569
570
  value holds no NUL. Such a name is refused at load; it used to reach the
570
571
  child split at its `=` or not at all. A `passThrough` or `define` name is
571
572
  also a shell variable name, `[A-Za-z_][A-Za-z0-9_]*`: the task runs under
572
573
  `sh -c`, and dash (Linux's `sh`) drops any other name from the environment
573
574
  it hands the command, so `my.var` reached a task on macOS and never on
574
- Linux. `cache.inputs.env` takes any such name: the key reads it from vx's
575
+ Linux. `inputs.env` takes any such name: the key reads it from vx's
575
576
  own environment.
576
577
 
577
578
  vx also sets `npm_execpath` to the workspace's package manager (the root
@@ -608,7 +609,7 @@ shows it: the task's output and the line vx adds under a shell's 127 or
608
609
  replays, the command a cache entry stores (what `vx why` prints and a
609
610
  remote cache receives), the `$ command` line, telemetry records,
610
611
  `vx show`, the hashes `vx why` gives for such a variable in
611
- `cache.inputs.env` (its value, unsalted: the row names it and its change), an executor's error, a plugin's warning or failure (a remote's reply; the failure masked in the error `run()` rejects with), a failed `cache.inputs` runtime probe's error (its command and output), and the run's own invocation line that `vx last` prints (a
612
+ `inputs.env` (its value, unsalted: the row names it and its change), an executor's error, a plugin's warning or failure (a remote's reply; the failure masked in the error `run()` rejects with), a failed `inputs` runtime probe's error (its command and output), and the run's own invocation line that `vx last` prints (a
612
613
  secret passed after `--`). So are every string of a
613
614
  task `vx show` prints (a runtime probe, `readyWhen`, the description),
614
615
  the description `--dry` and the task picker print, a runtime probe's
@@ -701,7 +702,7 @@ Semantics:
701
702
  nothing anywhere stays legal, as `'name.*'` does.
702
703
  - **`'pkg#name'`** — missing pkg or task is a hard error (you named
703
704
  them explicitly). `'//#name'`, Turbo's spelling of the root package,
704
- names the root project (D-39) here and in `cache.inputs.tasks`; with no
705
+ names the root project (D-39) here and in `inputs.tasks`; with no
705
706
  root project it is refused as such.
706
707
  - **Patterns (`'name.*'` / `'^name.*'`)** — `*` matches any run of
707
708
  characters in a task NAME; everything else is literal (the dotted
@@ -714,102 +715,37 @@ Semantics:
714
715
  ALL its matches and stops the walk. Patterns are not supported in
715
716
  the `'pkg#task'` form. (Nx 19.5 `build-*` parity.)
716
717
  - **No bare wildcards or negation here.** `'*'` / `'^*'` / `'!form'`
717
- belong in `cache.inputs.tasks` (filtering which upstream hashes
718
+ belong in `inputs.tasks` (filtering which upstream hashes
718
719
  participate in this task's cache key), not in `dependsOn` (declaring
719
720
  graph edges). The micro-syntax parser rejects them in this position.
720
721
  - **Cycle detection** runs across the resolved graph at the end of
721
722
  `buildTaskGraph`. Cycles throw with a path-formatted message.
722
723
 
723
- ### `cache` (optional)
724
-
725
- ```ts
726
- interface CacheConfig {
727
- inputs: CacheInputs // required when `cache` is provided
728
- outputs: CacheOutputs // required when `cache` is provided
729
- inputStability?: InputStability // optional
730
- }
731
-
732
- interface InputStability {
733
- strategy?: 'immutable' | 'mutable' | 'unchecked' // default 'immutable'
734
- severity?: 'warn' | 'error' // default 'warn'
735
- }
736
- ```
737
-
738
- `inputStability` says what a write to an input while the command runs
739
- means. `strategy` says which writes count:
740
-
741
- - `'immutable'` (the default, on CI too): any write. An edit undone
742
- mid-run, or another task changing the file and putting it back, cannot
743
- be told from no edit.
744
- - `'mutable'`: a file rewritten to the bytes the key folded counts as
745
- unchanged, for a task that rewrites its own input identically
746
- (tsdown's `package.json`); different bytes still count, and so does
747
- any write while another task that may write the file was running (one
748
- of the same project, or one whose sandbox grants writes elsewhere in
749
- the workspace; a server `vx watch` keeps from an earlier run counts),
750
- since it may have changed the file and put it back.
751
- The line names that task; order the two with `dependsOn` to save.
752
- - `'unchecked'`: none. The entry is saved under the key taken before the
753
- command, whatever the command did to its inputs; `severity` is ignored.
754
- A key that no longer held before the command started still withholds
755
- it.
756
-
757
- A write that counts withholds the entry, and `severity` says what else
758
- happens: `'warn'` (the default) keeps the result, says why on the status
759
- line, and the run goes on; `'error'` fails the task, so the run fails,
760
- and its label names the file: `failed (src/a.ts changed during the run, exit 1)`
761
- (`inputMoved` in `vx last --format json`).
762
- Either way, every task that ran over the task's outputs, down the graph,
763
- withholds its save too, and a task of any project that declares the
764
- moved file as an input withholds its own.
765
-
766
- Each field is resolved on its own, highest first:
767
-
768
- 1. `VX_CACHE_INPUT_STABILITY_STRATEGY` (`immutable`, `mutable`,
769
- `unchecked`) and `VX_CACHE_INPUT_STABILITY_SEVERITY` (`warn`, `error`), for the whole run
770
- (a CI job that wants `error` sets it without editing configs); any
771
- other value is refused before a task runs;
772
- 2. the task's own `cache.inputStability`;
773
- 3. the workspace's `inputStability` (`.vx/workspace.ts`);
774
- 4. the default, `'immutable'` and `'warn'`.
775
-
776
- Never folded into a cache key.
724
+ ### `inputs` (optional)
777
725
 
778
726
  ```ts
779
- import { defineProject } from '@vzn/vx/config'
780
-
781
- export default defineProject({
782
- tasks: {
783
- build: {
784
- // tsdown rewrites package.json with the same bytes on every build;
785
- // different bytes fail the run rather than rebuild next time.
786
- exec: { command: 'tsdown' },
787
- cache: {
788
- inputs: { files: ['src/**', 'package.json'] },
789
- outputs: { files: ['dist/**'] },
790
- inputStability: { strategy: 'mutable', severity: 'error' },
791
- },
792
- },
793
- },
794
- })
727
+ inputs?: InputsConfig
795
728
  ```
796
729
 
797
- **Caching is opt-in.** Omit `cache` and the task always runs (no read,
798
- no write). Provide it and caching is active for that task. The
799
- project loader requires _both_ `inputs` and `outputs` when `cache` is
800
- set — declaring "what does this read?" and "what does it produce?" is
801
- a deliberate, conscious choice.
730
+ What the task reads, and so what its key folds. A task is keyed on its
731
+ inputs whether it caches or not, as Turbo and Nx hash every task: a
732
+ dependant folds that key, so an edit to an uncached generator's inputs
733
+ re-runs the cached task behind it. A group with `inputs` is a keyed
734
+ group (Group tasks, below). The default `build` is
735
+ `{ dependsOn: ['^build'], inputs: { files: ['**'] } }`.
802
736
 
803
- **Why opt-in?** Defaulting caching to ON with implicit "all files / no
804
- outputs" silently produces stale builds the moment a user forgets
805
- to revisit the config. The cost of a single forgotten cache miss
806
- (re-run a task) is much less than the cost of a stale cache hit
807
- (ship broken artifacts).
737
+ A task that runs a command and declares no `inputs` has no key: nothing
738
+ says what it read. A key that folds it is this run's alone, so every
739
+ task behind it misses and saves nothing, never a stale hit. Declare
740
+ `inputs` on an uncached task a cached one depends on;
741
+ `inputs: { files: [] }` is a declaration too: it keys the task on its
742
+ config and its dependencies' keys, and a task behind it can hit. An
743
+ absent `inputs` and an empty one differ.
808
744
 
809
- #### `CacheInputs`
745
+ #### `InputsConfig`
810
746
 
811
747
  ```ts
812
- interface CacheInputs {
748
+ interface InputsConfig {
813
749
  files: readonly string[] // required
814
750
  workspaceFiles?: readonly string[] // optional; workspace-root-relative
815
751
  env?: readonly string[] // optional
@@ -880,20 +816,39 @@ lockfile change when a lockfile plugin (`@vzn/vx-lockfile`) is declared.
880
816
 
881
817
  Always applied to every glob pass (regardless of what you wrote):
882
818
 
883
- - **gitignore filter** — workspace-root + project `.gitignore`.
819
+ - **gitignore filter** — workspace-root + project `.gitignore`, unless
820
+ an entry names the ignored path outright: a literal (`.env.local`), or
821
+ a glob whose fixed prefix git ignores (`dist/**`, `dist/*.js`,
822
+ `.cache/gen/**`), which is then walked on disk under that prefix only.
823
+ Such a file is hashed from disk and never stored or restored; a
824
+ missing one is not an input until it exists. A broad glob (`**`,
825
+ `src/**` over a listed `src`) still skips ignored files, so it never
826
+ walks `node_modules`.
827
+ Its bytes differ per machine, so machines with different files miss
828
+ each other's shared entries. An ignored file never selects a task
829
+ under `--affected` on its own, as in Nx (only what git sees does); `vx
830
+ watch` re-runs it on an edit there.
884
831
  - **Always-ignored** — `.git/**`, `.vx/cache/**`,
885
- `.vx/projects.lock.json`, `vx.lock.json`, `*.bun-build`, and Bun's cross-compile extraction
886
- directory `.<16 hex>-<8 hex>.tmp/**`, and vx's own restore temp
887
- `.vx-tmp-*`, at any depth.
888
- - **Installs** — an untracked file under any `node_modules/` (ignored or
889
- not). A file git TRACKS there, such as a committed test fixture, is an
890
- input like any other.
832
+ `.vx/projects.lock.json`, `vx.lock.json`, and `.vx/tmp/**`, where vx's
833
+ restores stage, at any depth. Nothing else: a tool's temp files are the
834
+ task's to exclude. A broad glob on a `bun build --compile` task, or on
835
+ one that runs beside it, excludes Bun's intermediate and its
836
+ cross-compile extraction directory itself:
837
+ `['**/*', '!**/*.bun-build', '!**/.????????????????-????????.tmp/**']`.
838
+ - **Installs** — an untracked file under any `node_modules/` that git
839
+ does not ignore. An ignored one follows the gitignore filter above
840
+ like any other ignored path: named outright, it is an input
841
+ (`node_modules/.prisma/client/**`, a generated client); a broad glob
842
+ skips it because it is ignored. A file git TRACKS there, such as a committed test fixture, is an input
843
+ like any other.
891
844
  - **Declared `outputs.files`** are excluded — a task never invalidates
892
845
  itself via its own output. A path an output `!` entry takes back is
893
846
  no output, so it stays an input (A-44). Another task's outputs are
894
847
  not excluded for you: an input glob that can match them is refused
895
- while `rules.upfrontKeys` is on (X-54), unless git ignores them
896
- whole. Write the exclusion, `['**/*', '!dist/**']`, or ignore `dist/`. An input entry the task's OWN outputs take
848
+ (X-54), unless git ignores them whole, or the producer is a
849
+ dependency the key does not fold (see Inputs over outputs in §
850
+ Settings). Write the exclusion,
851
+ `['**/*', '!dist/**']`, or ignore `dist/`. An input entry the task's OWN outputs take
897
852
  back whole is always refused (X-55): vx removes outputs before a run,
898
853
  so a formatter declaring `src/**` as both would delete its sources. A
899
854
  task that rewrites files in place declares no outputs.
@@ -908,8 +863,9 @@ Workspace-root-relative globs — the Turbo `$TURBO_ROOT$` / Nx
908
863
  `{workspaceRoot}` equivalent, for inputs that live outside the project
909
864
  dir (a root `tsconfig.base.json`, shared codegen output, …). Same
910
865
  syntax as `files` (`!` negation subtracts, in any order), same git-aware
911
- resolution (tracked + untracked-not-ignored; gitignored files are
912
- invisible), and the resolved paths join the same cache-key file list.
866
+ resolution (tracked + untracked-not-ignored; a gitignored file only when
867
+ named outright, as in `files`), and the resolved paths join the same
868
+ cache-key file list.
913
869
 
914
870
  ```ts
915
871
  inputs: {
@@ -937,7 +893,7 @@ backslash separator (`src\**`, `src\*.ts`) or a drive (`C:\src\**`,
937
893
  backslash stays an escape.
938
894
 
939
895
  Still applied: the always-ignored set (`.git/**`, `.vx/cache/**`,
940
- `.vx/projects.lock.json`, `vx.lock.json`, `*.bun-build`, `.<16 hex>-<8 hex>.tmp/**`, `.vx-tmp-*`),
896
+ `.vx/projects.lock.json`, `vx.lock.json`, `.vx/tmp/**`),
941
897
  untracked files under `node_modules/`, and the task's own declared
942
898
  outputs, `outputs.workspaceFiles` and the `outputs.files` its globs reach
943
899
  in its own project (a task never invalidates itself).
@@ -1133,6 +1089,92 @@ task's outputs. Typical case: an integration-test task `dependsOn`s
1133
1089
  `build` for ordering, but its inputs are only the test source — so
1134
1090
  `inputs.tasks: []` to keep `build` from invalidating it.
1135
1091
 
1092
+ ### `cache` (optional)
1093
+
1094
+ ```ts
1095
+ interface CacheConfig {
1096
+ outputs: CacheOutputs // required when `cache` is provided
1097
+ inputStability?: InputStability // optional
1098
+ }
1099
+
1100
+ interface InputStability {
1101
+ strategy?: 'immutable' | 'mutable' | 'unchecked' // default 'immutable'
1102
+ severity?: 'warn' | 'error' // default 'warn'
1103
+ }
1104
+ ```
1105
+
1106
+ `inputStability` says what a write to an input while the command runs
1107
+ means. `strategy` says which writes count:
1108
+
1109
+ - `'immutable'` (the default, on CI too): any write. An edit undone
1110
+ mid-run, or another task changing the file and putting it back, cannot
1111
+ be told from no edit.
1112
+ - `'mutable'`: a file rewritten to the bytes the key folded counts as
1113
+ unchanged, for a task that rewrites its own input identically
1114
+ (tsdown's `package.json`); different bytes still count, and so does
1115
+ any write while another task that may write the file was running (one
1116
+ of the same project, or one whose sandbox grants writes elsewhere in
1117
+ the workspace; a server `vx watch` keeps from an earlier run counts),
1118
+ since it may have changed the file and put it back.
1119
+ The line names that task; order the two with `dependsOn` to save.
1120
+ - `'unchecked'`: none. The entry is saved under the key taken before the
1121
+ command, whatever the command did to its inputs; `severity` is ignored.
1122
+ A key that no longer held before the command started still withholds
1123
+ it.
1124
+
1125
+ A write that counts withholds the entry, and `severity` says what else
1126
+ happens: `'warn'` (the default) keeps the result, says why on the status
1127
+ line, and the run goes on; `'error'` fails the task, so the run fails,
1128
+ and its label names the file: `failed (src/a.ts changed during the run, exit 1)`
1129
+ (`inputMoved` in `vx last --format json`).
1130
+ Either way, every task that ran over the task's outputs, down the graph,
1131
+ withholds its save too, and a task of any project that declares the
1132
+ moved file as an input withholds its own.
1133
+
1134
+ Each field is resolved on its own, highest first:
1135
+
1136
+ 1. `VX_CACHE_INPUT_STABILITY_STRATEGY` (`immutable`, `mutable`,
1137
+ `unchecked`) and `VX_CACHE_INPUT_STABILITY_SEVERITY` (`warn`, `error`), for the whole run
1138
+ (a CI job that wants `error` sets it without editing configs); any
1139
+ other value is refused before a task runs;
1140
+ 2. the task's own `cache.inputStability`;
1141
+ 3. the workspace's `inputStability` (`.vx/workspace.ts`);
1142
+ 4. the default, `'immutable'` and `'warn'`.
1143
+
1144
+ Never folded into a cache key.
1145
+
1146
+ ```ts
1147
+ import { defineProject } from '@vzn/vx/config'
1148
+
1149
+ export default defineProject({
1150
+ tasks: {
1151
+ build: {
1152
+ // tsdown rewrites package.json with the same bytes on every build;
1153
+ // different bytes fail the run rather than rebuild next time.
1154
+ exec: { command: 'tsdown' },
1155
+ inputs: { files: ['src/**', 'package.json'] },
1156
+ cache: {
1157
+ outputs: { files: ['dist/**'] },
1158
+ inputStability: { strategy: 'mutable', severity: 'error' },
1159
+ },
1160
+ },
1161
+ },
1162
+ })
1163
+ ```
1164
+
1165
+ **Caching is opt-in.** Omit `cache` and the task always runs (no read,
1166
+ no write). Provide it and caching is active for that task. The
1167
+ project loader requires `exec`, the task's `inputs` and `cache.outputs`
1168
+ when `cache` is set: declaring "what does this read?" and "what does
1169
+ it produce?" is a deliberate, conscious choice, and a task with no
1170
+ command produces nothing to cache.
1171
+
1172
+ **Why opt-in?** Defaulting caching to ON with implicit "all files / no
1173
+ outputs" silently produces stale builds the moment a user forgets
1174
+ to revisit the config. The cost of a single forgotten cache miss
1175
+ (re-run a task) is much less than the cost of a stale cache hit
1176
+ (ship broken artifacts).
1177
+
1136
1178
  #### `CacheOutputs`
1137
1179
 
1138
1180
  ```ts
@@ -1470,7 +1512,7 @@ shares (L-41): `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.azure`, `~/.kube`,
1470
1512
  `~/.codex/auth.json`.
1471
1513
  What it grants from there is the union of the read grants and, on Linux,
1472
1514
  the DIRECTORY holding each file-shaped write grant (above).
1473
- Nothing is inherited from `cache` — `cache.inputs` says what INVALIDATES a task, `sandbox.allow`
1515
+ Nothing is inherited from `cache` — `inputs` says what INVALIDATES a task, `sandbox.allow`
1474
1516
  says what it may TOUCH, and deriving one from the other made a
1475
1517
  declaration added for caching silently widen the sandbox. The one grant
1476
1518
  vx makes for you is dependencies: `node_modules` and, through it, the
@@ -1484,7 +1526,7 @@ past its `allow.read`.
1484
1526
 
1485
1527
  A task that declares `cache` gets a linked workspace package only when
1486
1528
  its key already answers for it: some task of that package is folded into
1487
- the key through `dependsOn` (as `cache.inputs.tasks` selects it, groups
1529
+ the key through `dependsOn` (as `inputs.tasks` selects it, groups
1488
1530
  passing everything through). Every other link inside the workspace root
1489
1531
  is withheld, and a read through one fails the task with the denial plus
1490
1532
  one line naming the package and the edge that would key it. Declaring
@@ -1576,17 +1618,16 @@ dependant's `^build`, by `vx run build --all` — and runs nothing. A
1576
1618
  project with no `build` gets a keyed one (above), which a bare name
1577
1619
  does not select.
1578
1620
 
1579
- A group with `cache` is a **keyed group**: its `cache.inputs` are a key
1621
+ A group with `inputs` is a **keyed group**: its `inputs` are a key
1580
1622
  and nothing runs. A task that depends on it folds that key, so an edit
1581
- to those inputs re-runs the dependant, and `--affected` reaches it. Its
1582
- `cache.outputs` must be `{ files: [] }` (a group writes nothing), and it
1583
- needs no `dependsOn`. It spawns nothing, writes no cache entry, and the
1623
+ to those inputs re-runs the dependant, and `--affected` reaches it. It
1624
+ takes no `cache` (a group writes nothing), and it needs no `dependsOn`. It spawns nothing, writes no cache entry, and the
1584
1625
  run summary does not count it. `@vzn/vx-migrate` writes one for each
1585
1626
  Nx `^production`-style input and each Turbo transit node.
1586
1627
 
1587
1628
  ```ts
1588
1629
  // `test` re-runs when a file under fixtures/ changes; nothing spawns for it
1589
- fixtures: { cache: { inputs: { files: ['fixtures/**'] }, outputs: { files: [] } } },
1630
+ fixtures: { inputs: { files: ['fixtures/**'] } },
1590
1631
  test: { exec: { command: 'bun test' }, dependsOn: ['fixtures'], cache: { /* … */ } },
1591
1632
  ```
1592
1633
 
@@ -1624,15 +1665,16 @@ Group tasks:
1624
1665
 
1625
1666
  The loader rejects:
1626
1667
 
1627
- - A task with no `exec`, no `dependsOn` and no `cache` (literal no-op).
1628
- - A group whose `cache.outputs` names a file (a group writes nothing).
1668
+ - A task with no `exec`, no `dependsOn` and no `inputs` (literal no-op).
1669
+ - A group with `cache` (a group writes nothing).
1629
1670
 
1630
1671
  ## Workspace config (`.vx/workspace.ts`)
1631
1672
 
1632
1673
  Loaded from `.vx/workspace.{ts,mts,js,mjs,cts,cjs}`: the directory holding
1633
1674
  `.vx` is the workspace root. `.vx` also holds the lock (`.vx/projects.lock.json`)
1634
- and `.vx/cache`, the machine-local state (the cache, the workspace id),
1635
- which is the only part git ignores: `.vx/.gitignore` says `/cache`. Every field falls back to its
1675
+ `.vx/cache`, the machine-local state (the cache, the workspace id), and
1676
+ `.vx/tmp`, where a restore stages files; git ignores only those two:
1677
+ `.vx/.gitignore` says `/cache` and `/tmp`. Every field falls back to its
1636
1678
  built-in default.
1637
1679
 
1638
1680
  ```ts
@@ -1648,7 +1690,6 @@ export default defineWorkspace({
1648
1690
  continue: 'deps-ok',
1649
1691
  rules: {
1650
1692
  exclusiveOutputs: true,
1651
- upfrontKeys: true,
1652
1693
  dependencies: [{ from: 'tag:lib', deny: ['tag:app'] }],
1653
1694
  },
1654
1695
  },
@@ -1660,6 +1701,7 @@ export default defineWorkspace({
1660
1701
  scope: 'main',
1661
1702
  inputStability: { strategy: 'immutable', severity: 'warn' },
1662
1703
  outputs: { clean: true },
1704
+ upload: { failures: 'ci' },
1663
1705
  },
1664
1706
  lock: { reviewFiles: true },
1665
1707
  watch: { poll: false },
@@ -1697,7 +1739,6 @@ default and where it came from.
1697
1739
  | `run.continue` | `VX_RUN_CONTINUE` | `--continue` | one of 'never', 'deps-ok', 'always' | deps-ok | What a failed task takes down: `never` stops the run, `deps-ok` skips its dependents, `always` runs them too. |
1698
1740
  | `run.outputLogs` (variable or flag only) | `VX_RUN_OUTPUT_LOGS` | `--output-logs` | one of 'full', 'errors-only', 'hash-only', 'none' | `full` on CI, else by what the run selects | Which task output the run prints. Chosen before the workspace loads, so flag and env only. |
1699
1741
  | `run.rules.exclusiveOutputs` | `VX_RUN_RULES_EXCLUSIVE_OUTPUTS` | — | true or false | true | Refuse two tasks whose outputs overlap even when an edge orders them. |
1700
- | `run.rules.upfrontKeys` | `VX_RUN_RULES_UPFRONT_KEYS` | — | true or false | true | Refuse a task whose inputs can match another task's outputs. |
1701
1742
  | `cache.dir` | `VX_CACHE_DIR` | `--cache-dir` | a non-blank string | .vx/cache, entries in ~/.vx/<repo>/cache | Where the cache lives, relative to the workspace root; named, it holds the whole cache. |
1702
1743
  | `cache.retention.maxSize` | `VX_CACHE_RETENTION_MAX_SIZE` | — | a size like '500MB' or '10GB' | none | Evict least-recently-used entries at each run's end until the cache is under this (`'10GB'`). |
1703
1744
  | `cache.retention.olderThan` | `VX_CACHE_RETENTION_OLDER_THAN` | — | a duration like '45s', '12h' or '30d' | none | Evict entries unused for this long at each run's end (`'30d'`). |
@@ -1707,6 +1748,7 @@ default and where it came from.
1707
1748
  | `cache.inputStability.strategy` | `VX_CACHE_INPUT_STABILITY_STRATEGY` | — | one of 'immutable', 'mutable', 'unchecked' | immutable | What a write to an input during the command means for the save, for tasks without their own. A task's own `cache.inputStability.strategy` beats it. |
1708
1749
  | `cache.inputStability.severity` | `VX_CACHE_INPUT_STABILITY_SEVERITY` | — | one of 'warn', 'error' | warn | Whether a skipped save warns or fails the task, for tasks without their own. A task's own `cache.inputStability.severity` beats it. |
1709
1750
  | `cache.outputs.clean` | — | — | true or false | true | Remove a task's declared outputs before it runs on a miss, for tasks without their own. A task's own `cache.outputs.clean` beats it. |
1751
+ | `cache.upload.failures` | — | — | one of 'ci', 'local', 'always' | ci | Where a failed task's log and outputs are kept in the remote: CI runs, local runs, or both. |
1710
1752
  | `lock.reviewFiles` | `VX_LOCK_REVIEW_FILES` | — | true or false | true | `vx lock` writes each project's resolved config, pretty, to `vx.lock.json` beside its `vx.config`, for review; vx never reads it. |
1711
1753
  | `watch.poll` | `VX_WATCH_POLL` | `vx watch --poll` | true or false | false | Poll for changes from the start instead of the OS watcher. |
1712
1754
  | `config.workerTimeout` (variable or flag only) | `VX_CONFIG_WORKER_TIMEOUT` | — | a duration like '500ms', '30s' or '5m', at most 24 days | 30s | How long one config evaluation may take. |
@@ -1734,7 +1776,7 @@ interface RunSettings {
1734
1776
  retries?: number
1735
1777
  continue?: 'never' | 'deps-ok' | 'always'
1736
1778
  /** Graph rules `vx lock` checks, never a run; each on unless set to false. */
1737
- rules?: { exclusiveOutputs?: boolean; upfrontKeys?: boolean; dependencies?: DependencyRule[] }
1779
+ rules?: { exclusiveOutputs?: boolean; dependencies?: DependencyRule[] }
1738
1780
  }
1739
1781
 
1740
1782
  /** `run.rules.dependencies`, checked by `vx lock` only. */
@@ -1753,6 +1795,7 @@ interface CacheSettings {
1753
1795
  download?: 'all' | 'toplevel' | 'none'
1754
1796
  inputStability?: InputStability
1755
1797
  outputs?: { clean?: boolean }
1798
+ upload?: { failures?: 'ci' | 'local' | 'always' }
1756
1799
  }
1757
1800
  ```
1758
1801
 
@@ -1791,7 +1834,7 @@ interface CacheSettings {
1791
1834
  workspace (`'..'`, `'/'`) is refused before anything is written, since its `*`
1792
1835
  `.gitignore` would hide the sources from git and the cache keys. A
1793
1836
  directory other than the default `.vx/cache` gets that `*` `.gitignore`;
1794
- the default is ignored by `.vx/.gitignore` (`/cache`) instead, written
1837
+ the default is ignored by `.vx/.gitignore` (`/cache`, `/tmp`) instead, written
1795
1838
  when missing and never edited.
1796
1839
  - **`cache.retention`** — the `vx cache clean` policy, applied at the
1797
1840
  end of every run: entries unused for
@@ -1835,6 +1878,13 @@ interface CacheSettings {
1835
1878
  - **`cache.download`** — which remote hits land on this machine:
1836
1879
  `all` (the default), only the requested tasks' (`toplevel`), or
1837
1880
  `none` (`docs/cli.md` § `--cache-download`).
1881
+ - **`cache.upload.failures`** — which runs keep a failed task's log and
1882
+ the files its outputs left in the remote: CI runs (`'ci'`, the
1883
+ default), runs off CI (`'local'`), or both (`'always'`). Only a run
1884
+ whose policy writes a remote keeps any, and it always keeps the log of
1885
+ a task that saved no entry. `ctx.cache.getRun` reads them
1886
+ (`docs/modules/plugin.md` § Run report and run bytes).
1887
+
1838
1888
  - **`cache.scope`** — where this run's cache writes land (2026-10-14).
1839
1889
  Omitted, a run reads and writes the default keys. A name (`'pr-123'`;
1840
1890
  letters, digits, `. _ - / @`, at most 128) reads the default key first,
@@ -1880,34 +1930,6 @@ own output path, or set run.rules.exclusiveOutputs: false in
1880
1930
  Off, the dependant adds to its upstream's tree (item 588;
1881
1931
  `caching.md` § Additive outputs). Two overlapping tasks with no
1882
1932
  edge are refused either way.
1883
- - **`upfrontKeys`** (X-54) — a cached task whose `inputs.files` can
1884
- match a same-project task's `outputs.files`, or whose
1885
- `inputs.workspaceFiles` can match any task's `outputs.workspaceFiles`
1886
- or another project's `outputs.files` read from the root, or whose
1887
- `inputs.files` can match another task's `outputs.workspaceFiles`
1888
- landing in its project (X-135), is refused,
1889
- edge or no edge: `<reader> reads "<glob>" in cache.inputs.<field>,
1890
- which matches <writer>'s output "<glob>" … Exclude it: add "!<glob>"
1891
- to <reader>'s cache.inputs.<field>, or set run.rules.upfrontKeys:
1892
- false in .vx/workspace.ts to let it wait for its producer.` Such a key
1893
- reads what the producer writes this run, so vx cannot know it before
1894
- the producer ran: it is not probed, prefetched or restored ahead of
1895
- the schedule (`caching.md` § Local restore tier). The upstream's key
1896
- already reaches the reader through `dependsOn`. A `!` entry that
1897
- takes the whole output back clears it, and an output the reader's
1898
- `!` entries take back no longer holds its key back either. Nor does
1899
- an output git ignores whole: a key reads only what git lists, so
1900
- `**/*` beside a gitignored `**/dist/**` passes and keys up front. Git
1901
- is asked, never a `.gitignore` read: `git check-ignore` on the
1902
- output's directory (for `**/dist/**`, `dist/` and a fresh
1903
- `__vx_probe__/dist/`), and no tracked or listed file may match it,
1904
- so a tracked output is still refused. A glob no few paths stand for
1905
- (`*.js`) is refused as before. `vx lock` asks once for every
1906
- overlapped output; a run asks once beside its git walk for the
1907
- stability gate, so a rule changed after the lock keys the reader
1908
- late until the next `vx lock` refuses it. A task's
1909
- own outputs, a task with no `cache`, and a group are exempt. Off,
1910
- the key waits for its producer, as before.
1911
1933
  - **`dependencies`** — which workspace projects a project may depend
1912
1934
  on, checked by `vx lock` and `vx lock --check` only, over each
1913
1935
  project's direct dependencies (its `package.json` buckets and its
@@ -1923,6 +1945,41 @@ false in .vx/workspace.ts to let it wait for its producer.` Such a key
1923
1945
  `lib depends on app, which run.rules.dependencies[0] (from 'tag:lib') denies`,
1924
1946
  every broken edge on its own line. A rule with neither `allow` nor
1925
1947
  `deny` is refused.
1948
+ - **Inputs over outputs** (X-54, always checked, no setting) — a cached task whose `inputs.files` can
1949
+ match a same-project task's `outputs.files`, or whose
1950
+ `inputs.workspaceFiles` can match any task's `outputs.workspaceFiles`
1951
+ or another project's `outputs.files` read from the root, or whose
1952
+ `inputs.files` can match another task's `outputs.workspaceFiles`
1953
+ landing in its project (X-135), is refused by `vx lock` unless one of
1954
+ the exceptions below applies: `<reader> reads "<glob>" in inputs.<field>,
1955
+ which matches <writer>'s output "<glob>" … Exclude it: add "!<glob>"
1956
+ to <reader>'s inputs.<field>, or depend on <writer> and leave it out of
1957
+ <reader>'s inputs.tasks, so its key hashes those bytes instead.` Such a key
1958
+ reads what the producer writes this run, so vx cannot know it before
1959
+ the producer ran: it is not probed, prefetched or restored ahead of
1960
+ the schedule (`caching.md` § Local restore tier). The upstream's key
1961
+ already reaches the reader through `dependsOn`. A `!` entry that
1962
+ takes the whole output back clears it, and an output the reader's
1963
+ `!` entries take back no longer holds its key back either. Nor does
1964
+ an output git ignores whole: a key reads only what git lists, so
1965
+ `**/*` beside a gitignored `**/dist/**` passes and keys up front,
1966
+ unless the reader's entry starts in the ignored path (`dist/**`):
1967
+ naming it reads it, so the check applies. A reader may read the
1968
+ outputs of a task in its `dependsOn` closure that its key does not
1969
+ fold: it sets `inputs.tasks` and leaves the producer out, also
1970
+ through the tasks it does fold (a producer folded by a folded task
1971
+ counts as folded). Its key then waits for the producer and hashes
1972
+ the bytes it wrote, so a producer rerun with identical output still
1973
+ hits. `vx lock` checks all of it; a run reads only the lock. Git
1974
+ is asked, never a `.gitignore` read: `git check-ignore` on the
1975
+ output's directory (for `**/dist/**`, `dist/` and a fresh
1976
+ `__vx_probe__/dist/`), and no tracked or listed file may match it,
1977
+ so a tracked output is still refused. A glob no few paths stand for
1978
+ (`*.js`) is refused as before. `vx lock` asks once for every
1979
+ overlapped output; a run asks once beside its git walk for the
1980
+ stability gate, so a rule changed after the lock keys the reader
1981
+ late until the next `vx lock` refuses it. A task's
1982
+ own outputs, a task with no `cache`, and a group are exempt.
1926
1983
  - **`plugins`** — the run-level extension points. Optional: core
1927
1984
  applies no plugin by default, and the local executor and the local
1928
1985
  cache are its floor — the tail of every executor list, and the first
@@ -1966,8 +2023,8 @@ malformed input.
1966
2023
 
1967
2024
  There are no workspace-level `globalInputs` / task-default fields:
1968
2025
  root-anchored file inputs are declared per task via
1969
- `cache.inputs.workspaceFiles`, root-anchored probes via
1970
- `cache.inputs.workspaceRuntime`, and shared task shapes compose
2026
+ `inputs.workspaceFiles`, root-anchored probes via
2027
+ `inputs.workspaceRuntime`, and shared task shapes compose
1971
2028
  through plain TypeScript (import a preset and spread it) — the
1972
2029
  language replaces Nx-style `namedInputs` / `targetDefaults` schema
1973
2030
  machinery by design.
@@ -2017,11 +2074,11 @@ export default defineProject({
2017
2074
  // too — the exec env is isolated, not inherited.
2018
2075
  exec: { command: 'tsc -b', env: { passThrough: ['NODE_ENV'] } },
2019
2076
  dependsOn: ['^build'],
2077
+ inputs: {
2078
+ files: ['src/**', '!**/*.test.ts', 'tsconfig.json'],
2079
+ env: ['NODE_ENV'],
2080
+ },
2020
2081
  cache: {
2021
- inputs: {
2022
- files: ['src/**', '!**/*.test.ts', 'tsconfig.json'],
2023
- env: ['NODE_ENV'],
2024
- },
2025
2082
  outputs: { files: ['dist/**'] },
2026
2083
  },
2027
2084
  },
@@ -2032,8 +2089,8 @@ export default defineProject({
2032
2089
  description: 'run unit tests',
2033
2090
  exec: { command: 'bun test', env: { passThrough: ['CI'] } },
2034
2091
  dependsOn: ['build'],
2092
+ inputs: { files: ['src/**', 'tests/**'] },
2035
2093
  cache: {
2036
- inputs: { files: ['src/**', 'tests/**'] },
2037
2094
  outputs: { files: [] },
2038
2095
  },
2039
2096
  },
@@ -2044,11 +2101,11 @@ export default defineProject({
2044
2101
  description: 'pack the npm tarball',
2045
2102
  exec: { command: 'rm -rf pkg && npm pack --pack-destination ./pkg' },
2046
2103
  dependsOn: ['build', 'test', '^build'],
2104
+ inputs: {
2105
+ files: ['package.json'],
2106
+ tasks: ['build', '^build'], // hash these upstream too
2107
+ },
2047
2108
  cache: {
2048
- inputs: {
2049
- files: ['package.json'],
2050
- tasks: ['build', '^build'], // hash these upstream too
2051
- },
2052
2109
  outputs: { files: ['pkg/*.tgz'] },
2053
2110
  },
2054
2111
  },
@@ -2078,12 +2135,14 @@ export default defineProject({
2078
2135
  lint: {
2079
2136
  description: 'oxlint',
2080
2137
  exec: { command: 'oxlint .' },
2081
- cache: { inputs: { files: ['src/**', '.oxlintrc.json'] }, outputs: { files: [] } },
2138
+ inputs: { files: ['src/**', '.oxlintrc.json'] },
2139
+ cache: { outputs: { files: [] } },
2082
2140
  },
2083
2141
  'format-check': {
2084
2142
  description: 'oxfmt --check',
2085
2143
  exec: { command: 'oxfmt --check .' },
2086
- cache: { inputs: { files: ['src/**', '.oxfmtrc.json'] }, outputs: { files: [] } },
2144
+ inputs: { files: ['src/**', '.oxfmtrc.json'] },
2145
+ cache: { outputs: { files: [] } },
2087
2146
  },
2088
2147
 
2089
2148
  // Pure group task — `vx run ci` fans out, the group itself is
@@ -2132,12 +2191,12 @@ export default defineProject({
2132
2191
  weakerWhenNested: false,
2133
2192
  },
2134
2193
  },
2194
+ inputs: {
2195
+ files: ['e2e/**'],
2196
+ runtime: ['npx playwright --version'],
2197
+ workspaceRuntime: ['node -v'],
2198
+ },
2135
2199
  cache: {
2136
- inputs: {
2137
- files: ['e2e/**'],
2138
- runtime: ['npx playwright --version'],
2139
- workspaceRuntime: ['node -v'],
2140
- },
2141
2200
  outputs: { files: ['test-results/**'] },
2142
2201
  },
2143
2202
  },
@@ -2162,11 +2221,13 @@ export default defineProject({
2162
2221
  tasks: {
2163
2222
  build: {
2164
2223
  exec: { command: 'tsc' },
2165
- cache: { inputs: { files: srcInputs }, outputs: { files: ['dist/**'] } },
2224
+ inputs: { files: srcInputs },
2225
+ cache: { outputs: { files: ['dist/**'] } },
2166
2226
  },
2167
2227
  test: {
2168
2228
  exec: { command: 'bun test' },
2169
- cache: { inputs: { files: [...srcInputs, 'tests/**'] }, outputs: { files: [] } },
2229
+ inputs: { files: [...srcInputs, 'tests/**'] },
2230
+ cache: { outputs: { files: [] } },
2170
2231
  },
2171
2232
  },
2172
2233
  })
@@ -2184,8 +2245,8 @@ export function tsBuild(opts?: { tsconfig?: string }): TaskConfig {
2184
2245
  return {
2185
2246
  description: 'compile TypeScript',
2186
2247
  exec: { command: `tsc -b ${opts?.tsconfig ?? ''}`.trim() },
2248
+ inputs: { files: ['src/**', opts?.tsconfig ?? 'tsconfig.json'] },
2187
2249
  cache: {
2188
- inputs: { files: ['src/**', opts?.tsconfig ?? 'tsconfig.json'] },
2189
2250
  outputs: { files: ['dist/**'] },
2190
2251
  },
2191
2252
  }
@@ -2203,7 +2264,8 @@ export default defineProject({
2203
2264
  test: {
2204
2265
  exec: { command: 'bun test' },
2205
2266
  dependsOn: ['build'],
2206
- cache: { inputs: { files: ['src/**'] }, outputs: { files: [] } },
2267
+ inputs: { files: ['src/**'] },
2268
+ cache: { outputs: { files: [] } },
2207
2269
  },
2208
2270
  },
2209
2271
  })
@@ -2214,7 +2276,7 @@ to `.ts` source — see
2214
2276
  [`architecture.md` § Config-time imports](./architecture.md#config-time-imports--the-bootstrap-problem)
2215
2277
  for the bootstrap-cycle tradeoff.
2216
2278
 
2217
- ### When to use `cache.inputs.tasks: []`
2279
+ ### When to use `inputs.tasks: []`
2218
2280
 
2219
2281
  When a `dependsOn` exists for ordering only, and the upstream's
2220
2282
  output doesn't influence this task's output. Common case: an
@@ -2225,11 +2287,11 @@ own inputs are just the test source.
2225
2287
  e2e: {
2226
2288
  dependsOn: ['dev'],
2227
2289
  exec: { command: 'playwright test' },
2290
+ inputs: {
2291
+ files: ['e2e/**'],
2292
+ tasks: [], // dev's hash does not affect e2e's cache identity
2293
+ },
2228
2294
  cache: {
2229
- inputs: {
2230
- files: ['e2e/**'],
2231
- tasks: [], // dev's hash does not affect e2e's cache identity
2232
- },
2233
2295
  outputs: { files: ['playwright-report/**'] },
2234
2296
  },
2235
2297
  }
@@ -2304,27 +2366,27 @@ a user meets most:
2304
2366
  | `sandbox is not allowed on an interactive task` | interactive + sandbox combined. |
2305
2367
  | `persistent.readyWhen is not allowed on an interactive task` | vx reads none of an interactive task's output. |
2306
2368
  | `a task with no exec must declare dependsOn` | Group task with no edges. |
2307
- | `a task with no exec writes nothing` | Group task with `cache.outputs` that names a file. |
2369
+ | `cache needs exec — a task with no command produces nothing to cache` | `cache` on a group: a task with no command writes nothing to cache. |
2308
2370
  | `dependsOn must be an array of strings` | Wrong shape. |
2309
- | `cache.inputs is required when cache is set` | Forgot `inputs`. |
2310
- | `cache.inputs must be an object` | Present but not an object: a string (`outputs: 'dist'`). An array is the row above. |
2311
- | `cache.inputs.files must be an array` | Wrong shape. |
2312
- | `cache.inputs.runtime must be an array of non-empty shell command strings with no NUL` | Non-string / blank entry (whitespace alone runs as a no-op), or one holding a NUL. |
2313
- | `cache.inputs.workspaceRuntime must be an array of non-empty shell command strings with no NUL` | Non-string / blank entry (whitespace alone runs as a no-op), or one holding a NUL. |
2314
- | `cache.inputs.tasks must be an array of non-empty strings` | Non-string / empty entry, or a bare string. |
2315
- | `cache.inputs.tasks: "<name>" names no task in <task>.dependsOn` | An exact entry no `dependsOn` entry of its form names. |
2371
+ | `cache needs inputs — the key is the task's inputs` | Forgot `inputs` on a cached task. |
2372
+ | `inputs must be an object` | Present but not an object: a string (`outputs: 'dist'`). An array is the row above. |
2373
+ | `inputs.files must be an array` | Wrong shape. |
2374
+ | `inputs.runtime must be an array of non-empty shell command strings with no NUL` | Non-string / blank entry (whitespace alone runs as a no-op), or one holding a NUL. |
2375
+ | `inputs.workspaceRuntime must be an array of non-empty shell command strings with no NUL` | Non-string / blank entry (whitespace alone runs as a no-op), or one holding a NUL. |
2376
+ | `inputs.tasks must be an array of non-empty strings` | Non-string / empty entry, or a bare string. |
2377
+ | `inputs.tasks: "<name>" names no task in <task>.dependsOn` | An exact entry no `dependsOn` entry of its form names. |
2316
2378
  | `cache.outputs is required when cache is set` | Forgot `outputs`. |
2317
2379
  | `cache.inputStability must be an object` | A bare string (`inputStability: 'mutable'`). |
2318
2380
  | `cache.inputStability.strategy must be 'immutable', 'mutable' or 'unchecked'` | Any other value (`'content'`, `false`). |
2319
2381
  | `cache.inputStability.severity must be 'warn' or 'error'` | Any other value (`'fail'`, `true`). |
2320
2382
  | `cache.outputs must be an object` | Present but not an object: a string (`outputs: 'dist'`). An array is the row above. |
2321
2383
  | `cache.outputs.files must be an array` | Wrong shape. |
2322
- | `cache.inputs.files: every entry is a negation, which selects NOTHING` | Only `!` globs — nothing to subtract from. |
2323
- | `cache.inputs.files: "<file>" is taken back by "!<glob>"` | A literal input a `!` entry covers: the negation subtracts wherever it sits, so the file never entered the key. |
2384
+ | `inputs.files: every entry is a negation, which selects NOTHING` | Only `!` globs — nothing to subtract from. |
2385
+ | `inputs.files: "<file>" is taken back by "!<glob>"` | A literal input a `!` entry covers: the negation subtracts wherever it sits, so the file never entered the key. |
2324
2386
  | `cache.outputs.files: every entry is a negation, which selects NOTHING` | Only `!` globs: a `!` entry only takes back what a positive glob selected (A-44). |
2325
2387
  | `cache.outputs.files: "<glob>" covers the project's own <file>` | An output glob that matches the project's own `vx.config.*` (`**`, `*.ts`): the clean before a run would delete it. A `!` entry that takes the file back (`!vx.config.ts`) lets it load. |
2326
2388
  | `cache.outputs.files: "<glob>" is under workspace-outputs/` | A project output named into the artifact's namespace for `outputs.workspaceFiles`: every hit read it back as a workspace output. |
2327
- | `cache.inputs.files: '!!' is not a double negation` | `!!x` inverts the set — it folds only `x`. |
2389
+ | `inputs.files: '!!' is not a double negation` | `!!x` inverts the set — it folds only `x`. |
2328
2390
  | `exec.timeout of <value> exceeds the maximum timer delay` | Past 2^31-1 ms a timer fires at once, not never. |
2329
2391
  | `description must be a string` | Non-string description. |
2330
2392
  | `tag "<tag>" <why> — --filter tag:<name> could not name it` | A tag with surrounding whitespace or a `*`, or one ending in `...` or `[<ref>]`, which a filter reads as a pattern, a dependency walk or a git range. |
@@ -2332,7 +2394,7 @@ a user meets most:
2332
2394
  **Unknown fields are rejected**, not ignored, at every object level —
2333
2395
  the project's top level (`tasks`), the task itself, `exec`, `exec.env`,
2334
2396
  `exec.persistent`, `exec.sandbox` and its `allow` /
2335
- `deny` / `ignore` blocks, `cache`, `cache.inputs`, and `cache.outputs`
2397
+ `deny` / `ignore` blocks, `cache`, `inputs`, and `cache.outputs`
2336
2398
  (`tests/schema-unknown-keys.test.ts` walks every one) — and at the top
2337
2399
  of `.vx/workspace.ts`,
2338
2400
  where `plugin:` (singular) would otherwise declare no plugins and run