@vzn/vx 0.0.635 → 0.0.637

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +47 -19
  2. package/docs/caching.md +222 -210
  3. package/docs/cli.md +699 -664
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +31 -32
  7. package/docs/schema.md +418 -202
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +2 -2
  16. package/schemas/show.json +13 -4
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -61
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +123 -144
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +191 -71
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +25 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +192 -70
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +43 -28
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +114 -73
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +127 -88
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +251 -241
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +22 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
package/docs/schema.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # Config schema
2
2
 
3
3
  Complete reference for every field accepted by `vx.config.{ts,mts,js,mjs,cts,cjs}`
4
- and the optional workspace-level `vx.workspace.{ts,mts,js,mjs,cts,cjs}`. The
4
+ and the workspace-level `.vx/workspace.{ts,mts,js,mjs,cts,cjs}`, which is
5
+ required: it marks the workspace root (cli.md § The workspace root). The
5
6
  authoritative TypeScript definitions live in `src/config.ts` and are
6
7
  re-exported from `@vzn/vx`.
7
8
 
@@ -9,18 +10,30 @@ re-exported from `@vzn/vx`.
9
10
 
10
11
  ```
11
12
  <workspaceRoot>/
12
- ├── pnpm-workspace.yaml (or: package.json with "workspaces", or bare package.json)
13
- ├── vx.workspace.ts (optional, workspace-wide settings)
14
- └── packages/
15
- └── <pkg>/
16
- ├── package.json
17
- └── vx.config.ts (per-package task definitions)
13
+ ├── vx.config.ts (optional: the root project, named `//`)
14
+ ├── .vx/
15
+ │ ├── workspace.ts (required: marks the root; workspace-wide settings)
16
+ │ ├── projects.lock.json (`vx lock`, minified; committed)
17
+ │ ├── .gitattributes (`projects.lock.json linguist-generated=true`; written when missing)
18
+ │ ├── .gitignore (`/cache`; written by `vx init` and the cache when missing)
19
+ │ └── cache/ (machine-local state; the only part git ignores)
20
+ └── <any>/<dir>/
21
+ ├── vx.config.ts (this directory is a project; its tasks)
22
+ └── vx.lock.json (`vx lock`: its resolved config, pretty, for review)
18
23
  ```
19
24
 
20
- A project is discovered as a vx project by the **presence of a
21
- `vx.config.*` file alongside the project's `package.json`.** Projects
22
- without a config are visible in the workspace graph but contribute no
23
- tasks.
25
+ **A project is a directory holding a `vx.config.*`**, in any language;
26
+ core reads no `package.json` and no package-manager member list. `vx lock`
27
+ finds them (git's tracked and unignored files; a config at or under a
28
+ nested `.vx/workspace.*` is that workspace's) and records each by
29
+ directory; a run takes its projects from the lock, so a config added since
30
+ runs once `vx lock` records it. A `discover` plugin may add directories
31
+ with no config (`@vzn/vx-lockfile` adds the package manager's members).
32
+
33
+ A project's name is its config's `name`, else the name a `discover` plugin
34
+ gives (`@vzn/vx-lockfile` gives `package.json#name`), else its
35
+ root-relative path (`apps/web`); the root is `//`. Two projects of one
36
+ name are refused, naming both directories.
24
37
 
25
38
  ## Project config
26
39
 
@@ -28,6 +41,7 @@ tasks.
28
41
  import { defineProject } from '@vzn/vx/config'
29
42
 
30
43
  export default defineProject({
44
+ name: 'web',
31
45
  tags: ['scope:web', 'type:app'],
32
46
  tasks: {
33
47
  <taskName>: TaskConfig,
@@ -38,6 +52,7 @@ export default defineProject({
38
52
 
39
53
  ```ts
40
54
  interface ProjectConfig {
55
+ name?: string // what `<name>#<task>`, filters and `dependsOn` address; no "#"
41
56
  tags?: readonly string[] // labels for `--filter tag:<name>`
42
57
  tasks?: Record<string, TaskConfig> // keyed by task name
43
58
  }
@@ -115,7 +130,8 @@ bare `vx run build` does not select it: run where no selected project
115
130
  declares `build`, it is refused as any undeclared name is, while
116
131
  `lib#build` names it and a dependant's `^build` reaches it. `vx show`
117
132
  and `vx info` count it; `vx show build` and the `vx build` hint, which
118
- name declared tasks, do not.
133
+ name declared tasks, do not. Its `^build` needs an edge source as any
134
+ `^` does: with no plugin returning `deps`, running it is refused.
119
135
 
120
136
  ### `description` (optional)
121
137
 
@@ -195,7 +211,7 @@ Maximum time in milliseconds before vx SIGTERMs the child. Omitted →
195
211
  no limit.
196
212
 
197
213
  ```ts
198
- build: { exec: { command: 'tsc -b', timeout: 120_000 } }
214
+ build: { exec: { command: 'tsc -b', timeout: '2m' } }
199
215
  ```
200
216
 
201
217
  - For a **normal task**, `timeout` bounds the total run time, counted
@@ -221,15 +237,15 @@ limit" — the platform silently reduces it to **1 ms**, so the task would
221
237
  be killed the moment it spawns and reported `failed`. vx refuses it at
222
238
  load rather than let that happen. **Omit `timeout` entirely for no
223
239
  limit.** The same bound applies to `--timeout` and to the workspace-level
224
- `timeout`; the `VX_TASK_TIMEOUT` env rung is clamped to it instead of
240
+ `run.timeout`; the `VX_RUN_TIMEOUT` env rung is clamped to it instead of
225
241
  refused, since that rung never fails a run.
226
242
 
227
243
  A task with **no** `exec.timeout` falls back to a run-level default, if
228
244
  one is set. Precedence, highest first: **per-task `exec.timeout` →
229
- `--timeout <ms>` / `RunOptions.timeout` → `VX_TASK_TIMEOUT` env →
230
- workspace `timeout`** (see workspace config below). Per-task always
245
+ `--timeout <duration>` / `RunOptions.timeout` → `VX_RUN_TIMEOUT` →
246
+ workspace `run.timeout`** (see workspace config below). Per-task always
231
247
  wins. The run-level defaults are threaded as run options, so — like
232
- `--retry` — they never touch a cache key: a `--timeout` run cache-hits a
248
+ `--retries` — they never touch a cache key: a `--timeout` run cache-hits a
233
249
  plain run's entry.
234
250
 
235
251
  **Why no multi-step `commands: string[]`?** Per-task caching is the
@@ -272,7 +288,7 @@ test: { exec: { command: 'bun test', retries: 1 } }
272
288
  - Not allowed with `persistent` — a persistent task has no exit to
273
289
  retry (config error, like `cache` + `persistent`).
274
290
 
275
- The run-level default is `vx run --retry <n>` — it applies to tasks
291
+ The run-level default is `run.retries` (`vx run --retries <n>`, `VX_RUN_RETRIES`) — it applies to tasks
276
292
  that don't declare their own `retries`, never to a persistent one (a
277
293
  server that exits before it is ready fails at once); explicit config
278
294
  always wins, including an explicit `retries: 0`. The CLI flag never
@@ -360,14 +376,14 @@ for it to exit. Instead it considers the task "ready":
360
376
  The trailing partial line is tested too, so prompt-style banners
361
377
  without a newline (`printf 'Listening on :3000'`) count, and so does
362
378
  output read up to 250 ms after the shell exits. A server
363
- not ready after 10 s (`VX_READY_NOTICE_MS`) is said once, naming
379
+ not ready after 10 s (`run.readyNotice`) is said once, naming
364
380
  the pattern it waits for, and whether `exec.timeout` bounds the wait.
365
381
 
366
382
  ```ts
367
383
  dev: {
368
384
  exec: {
369
385
  command: 'vite',
370
- timeout: 30_000, // bound the readiness wait
386
+ timeout: '30s', // bound the readiness wait
371
387
  persistent: { readyWhen: 'Local:' },
372
388
  },
373
389
  }
@@ -565,7 +581,7 @@ does: npm-run-all calls that manager back and falls back to a global `npm`
565
581
  without it. It names a binary on this machine, so it is not in the key.
566
582
 
567
583
  Anything outside these three layers, the two variables vx sets for
568
- the run (`VX_RUN_WORKSPACE`, `VX_RUN_TASK`) and `npm_execpath`, is invisible to the child:
584
+ the run (`VX_RUN_WORKSPACE`, `VX_RUN_TASK`) and `npm_execpath`, is absent from the child's environment:
569
585
  a host credential (`SSH_AUTH_SOCK`, `GITHUB_TOKEN`) reaches a task only
570
586
  when `passThrough` names it, held end to end by `env.test.ts` (a
571
587
  sandboxed task with a restricted network also gets the sandbox's own
@@ -579,6 +595,10 @@ matches Turbo's `passThroughEnv` semantics and exists for two reasons:
579
595
  with different `FOO=bar` set would produce different outputs and
580
596
  the user would never know why.
581
597
 
598
+ This is hygiene, not a boundary: an unsandboxed task runs as you, so it
599
+ can read your files. vx hides its own environment from a task
600
+ (`modules/util-hide-env.md`), not your files. Only `exec.sandbox` keeps them out (`security.md` § What the sandbox stops).
601
+
582
602
  **Masking.** The value of a variable whose name holds `TOKEN`, `SECRET`,
583
603
  `KEY`, `PASSWORD`, `PASSWD` or `CREDENTIAL` (vx's own environment or a
584
604
  task's `define`, six characters or more; not a name ending `_FILE`,
@@ -588,13 +608,13 @@ shows it: the task's output and the line vx adds under a shell's 127 or
588
608
  replays, the command a cache entry stores (what `vx why` prints and a
589
609
  remote cache receives), the `$ command` line, telemetry records,
590
610
  `vx show`, the hashes `vx why` gives for such a variable in
591
- `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), any error `vx mcp` answers an agent 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
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
592
612
  secret passed after `--`) and its `--tag`s. So are every string of a
593
613
  task `vx show` prints (a runtime probe, `readyWhen`, the description),
594
614
  the description `--dry` and the task picker print, a runtime probe's
595
615
  command as the run history stores it and `vx why` names its row (two
596
616
  probes that mask alike are numbered, `echo *** (2)`), the readiness
597
- notice's pattern, a sandbox violation line, `vx info`'s config errors,
617
+ notice's pattern, a sandbox violation line,
598
618
  the error a verb ends on (a plugin's failure, a config's own throw) and
599
619
  `vx watch`'s `cycle failed` line. A multi-line value (a PEM
600
620
  key) is also masked line by line, each line of six characters or more,
@@ -622,13 +642,13 @@ Turbo/Nx-style micro-syntax — a flat array of strings:
622
642
  dependsOn?: readonly string[]
623
643
  ```
624
644
 
625
- | Form | Meaning |
626
- | ------------ | ------------------------------------------------------------------ |
627
- | `'name'` | Same-project task `name`. |
628
- | `'^name'` | The `name` task in the nearest workspace deps that declare it. |
629
- | `'pkg#name'` | The `name` task in a specific other package (cross-project edge). |
630
- | `'name.*'` | Every OTHER same-project task matching the pattern (`*` = any). |
631
- | `'^name.*'` | All matching tasks of the nearest deps that declare any (pattern). |
645
+ | Form | Meaning |
646
+ | ------------ | ------------------------------------------------------------------------------- |
647
+ | `'name'` | Same-project task `name`. |
648
+ | `'^name'` | The `name` task in the nearest project deps (a plugin's edges) that declare it. |
649
+ | `'pkg#name'` | The `name` task in a specific other package (cross-project edge). |
650
+ | `'name.*'` | Every OTHER same-project task matching the pattern (`*` = any). |
651
+ | `'^name.*'` | All matching tasks of the nearest deps that declare any (pattern). |
632
652
 
633
653
  Examples:
634
654
 
@@ -649,14 +669,16 @@ Semantics:
649
669
  **type-checked against this config's task keys** — a typo is a
650
670
  compile error (`'^name'` / `'pkg#name'` reference other projects
651
671
  and stay free strings).
652
- - **`'^name'`** — nearest-holder frontier. Walk the package dep graph
653
- from this project's direct deps (`dependencies`, `devDependencies`,
654
- `optionalDependencies`, and a `peerDependencies` entry on a
655
- workspace sibling unless that edge would close a cycle — then the
656
- consumer provides it and it orders nothing; an entry is a dep when
657
- the package manager links it to the workspace package, so a range the
658
- local version does not satisfy is a registry dependency, see
659
- `modules/package-graph.md` § Which entries are edges); each path stops at the first dep
672
+ - **`'^name'`** — nearest-holder frontier. Walk the project graph
673
+ from this project's direct deps: the edges a plugin's `discover`
674
+ returns as `deps` (the lockfile plugins `pnpm()` / `bun()` / `npm()` /
675
+ `yarn()` return what the package manager installed; core reads no
676
+ edge from a `package.json`) and the cross-project `pkg#task` edges.
677
+ With no plugin returning `deps` the workspace has no edge source and
678
+ a `'^name'` is refused (`VX_E_DEPENDS_ON`: "`^build` needs project
679
+ edges; add pnpm()/bun()/npm()/yarn() or a plugin that returns
680
+ `deps`"): it would resolve to nothing and fold no upstream into the
681
+ key. With one, a project with no deps is a leaf; each path stops at the first dep
660
682
  that declares the task and an edge is added to it (Turbo/Nx
661
683
  direct-deps parity). The holder's own `dependsOn` is responsible
662
684
  for anything deeper — chain `'^name'` in the holder to keep the
@@ -667,8 +689,8 @@ Semantics:
667
689
  too, as long as SOME project in the workspace declares `name`: a
668
690
  preset spreads `'^build'` over packages whose deps have no `build`.
669
691
  A `'^name'` that **no project in the whole workspace declares** is a
670
- hard error at graph-build time, since it can only be a typo: "Task
671
- app#test depends on ^biuld but no project in the workspace declares
692
+ hard error at graph-build time, since it can only be a typo: "app#test:
693
+ dependsOn ^biuld but no project in the workspace declares
672
694
  biuld". Until 2026-09-24 it resolved to no edges and the run went
673
695
  green (Nx#32779 is the same bug). A scoped run (an anchored
674
696
  `app#test`, `--filter`, a project directory) loads only its projects
@@ -704,19 +726,54 @@ Semantics:
704
726
  interface CacheConfig {
705
727
  inputs: CacheInputs // required when `cache` is provided
706
728
  outputs: CacheOutputs // required when `cache` is provided
707
- rewrittenInputs?: 'withhold' | 'compare' // optional, default 'withhold'
729
+ inputStability?: InputStability // optional
730
+ }
731
+
732
+ interface InputStability {
733
+ strategy?: 'immutable' | 'mutable' | 'unchecked' // default 'immutable'
734
+ severity?: 'warn' | 'error' // default 'warn'
708
735
  }
709
736
  ```
710
737
 
711
- `rewrittenInputs` says what a write to an input while the command runs
712
- means. `'withhold'` (the default, on CI too): the result stands but the
713
- entry is not saved, since an edit undone mid-run, or another task
714
- changing the file and putting it back, cannot be told from no edit.
715
- `'compare'`: a file rewritten to the bytes the key folded counts as
716
- unchanged and the entry is saved, for a task that rewrites its own input
717
- identically (tsdown's `package.json`); different bytes still withhold
718
- it. `VX_REWRITTEN_INPUTS=compare` sets the default for every task that
719
- does not say (owner, 2026-10-12).
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.
720
777
 
721
778
  ```ts
722
779
  import { defineProject } from '@vzn/vx/config'
@@ -724,12 +781,13 @@ import { defineProject } from '@vzn/vx/config'
724
781
  export default defineProject({
725
782
  tasks: {
726
783
  build: {
727
- // tsdown rewrites package.json with the same bytes on every build.
784
+ // tsdown rewrites package.json with the same bytes on every build;
785
+ // different bytes fail the run rather than rebuild next time.
728
786
  exec: { command: 'tsdown' },
729
787
  cache: {
730
788
  inputs: { files: ['src/**', 'package.json'] },
731
789
  outputs: { files: ['dist/**'] },
732
- rewrittenInputs: 'compare',
790
+ inputStability: { strategy: 'mutable', severity: 'error' },
733
791
  },
734
792
  },
735
793
  },
@@ -816,15 +874,15 @@ files: ['app/[id]/**'] // a bracket is literal: the route dir `[id]`
816
874
  ```
817
875
 
818
876
  Empty array is valid — the cache key still incorporates command, env,
819
- upstream hashes, workspace fingerprint, and the project's
820
- package.json. A task with no _file_ inputs (e.g. a pure
821
- "download-and-verify") still cache-misses on a lockfile change.
877
+ upstream hashes and any plugin's key material. A task with no _file_
878
+ inputs (e.g. a pure "download-and-verify") still cache-misses on a
879
+ lockfile change when a lockfile plugin (`@vzn/vx-lockfile`) is declared.
822
880
 
823
881
  Always applied to every glob pass (regardless of what you wrote):
824
882
 
825
883
  - **gitignore filter** — workspace-root + project `.gitignore`.
826
- - **Always-ignored** — `.git/**`, `.vx/**`, `*.tsbuildinfo`,
827
- `vx-lock.json`, `*.bun-build`, and Bun's cross-compile extraction
884
+ - **Always-ignored** — `.git/**`, `.vx/cache/**`, `*.tsbuildinfo`,
885
+ `.vx/projects.lock.json`, `vx.lock.json`, `*.bun-build`, and Bun's cross-compile extraction
828
886
  directory `.<16 hex>-<8 hex>.tmp/**`, and vx's own restore temp
829
887
  `.vx-tmp-*`, at any depth.
830
888
  - **Installs** — an untracked file under any `node_modules/` (ignored or
@@ -878,8 +936,8 @@ backslash separator (`src\**`, `src\*.ts`) or a drive (`C:\src\**`,
878
936
  `C:/src/**`). A backslash before a bracket, a brace, a `!` or a
879
937
  backslash stays an escape.
880
938
 
881
- Still applied: the always-ignored set (`.git/**`, `.vx/**`,
882
- `*.tsbuildinfo`, `vx-lock.json`, `*.bun-build`, `.<16 hex>-<8 hex>.tmp/**`, `.vx-tmp-*`),
939
+ Still applied: the always-ignored set (`.git/**`, `.vx/cache/**`,
940
+ `*.tsbuildinfo`, `.vx/projects.lock.json`, `vx.lock.json`, `*.bun-build`, `.<16 hex>-<8 hex>.tmp/**`, `.vx-tmp-*`),
883
941
  untracked files under `node_modules/`, and the task's own declared
884
942
  outputs, `outputs.workspaceFiles` and the `outputs.files` its globs reach
885
943
  in its own project (a task never invalidates itself).
@@ -956,8 +1014,8 @@ Semantics:
956
1014
  `resolveInputs`), exactly like `inputs.env` reads host env values
957
1015
  live. `vx lock` freezes the command _strings_ (they're in the
958
1016
  resolved config), not their output, so the field stays correct under
959
- `vx run --frozen` — the changed output of a frozen command still
960
- busts the cache.
1017
+ the lock every run reads — the changed output of a locked command
1018
+ still busts the cache.
961
1019
  - A **non-zero exit fails the run** (a hard `UserError` naming the
962
1020
  command and exit code) — fail-loud, like a missing git binary. A
963
1021
  flaky probe should not silently degrade to a stale hit.
@@ -1081,6 +1139,7 @@ task's outputs. Typical case: an integration-test task `dependsOn`s
1081
1139
  interface CacheOutputs {
1082
1140
  files: readonly string[] // required
1083
1141
  workspaceFiles?: readonly string[] // optional; workspace-root-relative
1142
+ clean?: boolean // optional; false keeps outputs on a miss
1084
1143
  }
1085
1144
  ```
1086
1145
 
@@ -1114,7 +1173,7 @@ it (never following it). An output DIRECTORY that is a symlink
1114
1173
  (`dist -> real-out`) is followed: its target is cleaned, saved and
1115
1174
  restored as the output; one that resolves outside the project refuses
1116
1175
  the task, naming the link. A link to a directory, a dangling one, or
1117
- one whose target is outside the project cannot be stored — the save refuses it by name and caches nothing, so
1176
+ one whose target is outside the project or, for a sandboxed task, a file its grants do not let it read cannot be stored — the save refuses it by name and caches nothing, so
1118
1177
  the next run executes again. A link to another output of the same task
1119
1178
  is stored wherever it is: `gen/latest -> v2.txt` under a
1120
1179
  `workspaceFiles` output caches (A-53). The clean also prunes the directories it
@@ -1125,13 +1184,21 @@ Empty `[]` is valid for tasks that produce no files (e.g. `lint`,
1125
1184
  `typecheck`, `test`); you still cache the no-op success so the next
1126
1185
  run is a no-op too.
1127
1186
 
1187
+ **`clean: false`** keeps the outputs on a miss instead of wiping them
1188
+ first, for a tool that reads its own last output (tsc's
1189
+ `.tsbuildinfo`). An entry still holds only what a clean run would
1190
+ leave: if the run leaves any old file unchanged, nothing is cached, and
1191
+ the next run executes again. A retry still starts clean, and a hit
1192
+ still restores bit-identically. Omitted → the workspace's
1193
+ `cache.outputs.clean`, else `true`.
1194
+
1128
1195
  A **`!` entry takes a path back** from what the positive globs select
1129
- (A-44; a tracked fixture and a scratch dir under `dist` stay out of
1130
- the clean this way):
1196
+ (A-44; a scratch dir under `dist` stays out of the clean this way; a
1197
+ tracked file needs no `!`, below):
1131
1198
 
1132
1199
  ```ts
1133
1200
  outputs: {
1134
- files: ['dist/**', '!dist/fixture.html', '!**/*.tmp']
1201
+ files: ['dist/**', '!**/*.tmp']
1135
1202
  }
1136
1203
  ```
1137
1204
 
@@ -1152,10 +1219,12 @@ positive globs, so a task with a `!` entry keeps it (A-46).
1152
1219
  - Before restore on a cache hit: same wipe, then files are copied
1153
1220
  from the cache entry into the project dir. The post-restore tree
1154
1221
  is the cached snapshot, byte-for-byte.
1222
+ - A file git tracks is never wiped (2026-10-14). It is still saved and
1223
+ restored, and stays in the task's key, so an edit to it moves the key.
1155
1224
 
1156
1225
  Skipped when `cache.outputs.files` is empty (nothing declared as
1157
1226
  output) and when no cache-write axis is enabled (e.g. `--no-cache`,
1158
- or a read-only `--cache=local:r`) — the user is debugging and
1227
+ or a read-only `--cache-policy=local:r`) — the user is debugging and
1159
1228
  managing the tree themselves. `--force` keeps writes on, so it does
1160
1229
  clean.
1161
1230
 
@@ -1196,7 +1265,7 @@ restore wins. A workspace output is also compared with every other
1196
1265
  project's `files` outputs, read from the root: `packages/b/dist/a.txt`
1197
1266
  collides with `b`'s `dist/**` (item 1088). An overlap
1198
1267
  between two tasks one of which depends on the other is refused too,
1199
- unless the workspace sets `rules: { exclusiveOutputs: false }` (X-53);
1268
+ unless the workspace sets `run.rules.exclusiveOutputs: false` (X-53);
1200
1269
  then the dependant is additive and owns only what its run adds to the
1201
1270
  tree (`caching.md` § Additive outputs, item 588). Across the two namespaces
1202
1271
  an upstream `files` task is told the dependant's globs in its own
@@ -1319,7 +1388,7 @@ vx removes the empty file it made (it takes back any placeholder the
1319
1388
  task never wrote, so an unwritten one is never archived as an output).
1320
1389
  A grant that leaves the project through a symlink is refused: the grant
1321
1390
  binds the path it names, and vx follows no link out of the project. So
1322
- is one whose bind would make `.git`, `.vx`, the cache directory or a
1391
+ is one whose bind would make `.git`, `.vx/cache`, the cache directory or a
1323
1392
  nested project writable:
1324
1393
  a file grant at a single-package workspace's root binds the root. A
1325
1394
  directory grant is judged as the directory, made before the judgement
@@ -1385,7 +1454,16 @@ shares (L-41): `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.azure`, `~/.kube`,
1385
1454
  `~/.bunfig.toml`, `~/.config/.bunfig.toml`, `~/.cargo/credentials`,
1386
1455
  `~/.cargo/credentials.toml`, `~/.gem/credentials`, `~/.config/hub`,
1387
1456
  `~/.config/containers/auth.json`, `~/.terraform.d/credentials.tfrc.json`,
1388
- `~/.vault-token`, `~/.pgpass`.
1457
+ `~/.vault-token`, `~/.pgpass`, `~/.config/configstore/firebase-tools.json`,
1458
+ `~/.local/share/com.vercel.cli/auth.json`,
1459
+ `~/Library/Application Support/com.vercel.cli/auth.json`,
1460
+ `~/.netlify/config.json`, `~/.config/netlify/config.json`,
1461
+ `~/Library/Preferences/netlify/config.json`, `~/.wrangler/config`,
1462
+ `~/.config/.wrangler/config`, `~/Library/Preferences/.wrangler/config`,
1463
+ `~/.fly/config.yml`, `~/.supabase/access-token`, `~/.config/doctl`,
1464
+ `~/Library/Application Support/doctl`, `~/.config/stripe/config.toml`,
1465
+ `~/.config/op`, `~/.expo/state.json`, `~/.claude/.credentials.json`,
1466
+ `~/.codex/auth.json`.
1389
1467
  What it grants from there is the union of the read grants and, on Linux,
1390
1468
  the DIRECTORY holding each file-shaped write grant (above).
1391
1469
  Nothing is inherited from `cache` — `cache.inputs` says what INVALIDATES a task, `sandbox.allow`
@@ -1545,125 +1623,245 @@ The loader rejects:
1545
1623
  - A task with no `exec`, no `dependsOn` and no `cache` (literal no-op).
1546
1624
  - A group whose `cache.outputs` names a file (a group writes nothing).
1547
1625
 
1548
- ## Workspace config (`vx.workspace.ts`)
1626
+ ## Workspace config (`.vx/workspace.ts`)
1549
1627
 
1550
- Loaded from `vx.workspace.{ts,mts,js,mjs,cts,cjs}` at the workspace root.
1551
- **Optional** — when missing, every field falls back to its built-in
1552
- default.
1628
+ Loaded from `.vx/workspace.{ts,mts,js,mjs,cts,cjs}`: the directory holding
1629
+ `.vx` is the workspace root. `.vx` also holds the lock (`.vx/projects.lock.json`)
1630
+ and `.vx/cache`, the machine-local state (the cache, the workspace id),
1631
+ which is the only part git ignores: `.vx/.gitignore` says `/cache`. Every field falls back to its
1632
+ built-in default.
1553
1633
 
1554
1634
  ```ts
1555
1635
  import { defineWorkspace } from '@vzn/vx/config'
1556
1636
  import { otel } from '@vzn/vx-otel'
1557
1637
 
1558
1638
  export default defineWorkspace({
1559
- concurrency: 8,
1560
- cacheDir: 'build/.vx-cache',
1561
- timeout: 600_000,
1562
- cacheRetention: { olderThan: '30d', maxSize: '10G' },
1563
- affectedBase: 'origin/main',
1564
- cacheScope: 'trusted',
1565
- rules: { exclusiveOutputs: true, upfrontKeys: true },
1566
- schedule: 'most-work',
1639
+ run: {
1640
+ concurrency: 8,
1641
+ timeout: '10m',
1642
+ affectedBase: 'origin/main',
1643
+ schedule: 'most-work',
1644
+ continue: 'deps-ok',
1645
+ killGrace: '2s',
1646
+ teardownTimeout: '3s',
1647
+ readyNotice: '10s',
1648
+ rules: {
1649
+ exclusiveOutputs: true,
1650
+ upfrontKeys: true,
1651
+ dependencies: [{ from: 'tag:lib', deny: ['tag:app'] }],
1652
+ },
1653
+ },
1654
+ cache: {
1655
+ dir: 'build/.vx-cache',
1656
+ download: 'all',
1657
+ retention: { olderThan: '30d', maxSize: '10GB' },
1658
+ policy: 'local:rw,remote:r',
1659
+ scope: 'main',
1660
+ inputStability: { strategy: 'immutable', severity: 'warn' },
1661
+ outputs: { clean: true },
1662
+ },
1663
+ lock: { reviewFiles: true },
1664
+ watch: { poll: false },
1567
1665
  plugins: [otel()],
1568
1666
  })
1569
1667
  ```
1570
1668
 
1669
+ The workspace holds settings in groups (`run`, `cache`, `lock`, `watch`) and the
1670
+ `plugins`. Every setting has one path, and its environment variable and
1671
+ flag derive from it: `run.timeout` is `VX_RUN_TIMEOUT` and
1672
+ `vx run --timeout`. A flag beats its variable, which beats this file,
1673
+ which beats the built-in. A setting a task can set itself
1674
+ (`exec.timeout`, `exec.retries`, `cache.inputStability`) takes the
1675
+ task's value over all of them; the input-stability variables are the one
1676
+ exception, overriding every task for the run. A duration carries its
1677
+ unit (`'500ms'`, `'30s'`, `'10m'`, `'2h'`, `'30d'`), and a bare number is
1678
+ refused naming the forms with units; a size carries one too (`'10GB'`).
1679
+ `debug.timing`, `debug.dumpable`, `config.workerTimeout` and `run.outputLogs` are read
1680
+ before the workspace loads, so they have a variable (and a flag) only.
1681
+ None is folded into a cache key. `vx info` lists every setting not at its
1682
+ default and where it came from.
1683
+
1684
+ ### Settings
1685
+
1686
+ <!-- settings table: generated by tests/settings.test.ts -->
1687
+
1688
+ | Setting | Variable | Flag | Value | Default | What it does |
1689
+ | ---------------------------------------------- | ----------------------------------- | -------------------- | ----------------------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
1690
+ | `run.concurrency` | `VX_RUN_CONCURRENCY` | `--concurrency` | a positive integer, or a share of the cores like '50%' | the cores this process may use | Tasks at once: a positive integer, or a share of the cores (`'50%'`). |
1691
+ | `run.schedule` | `VX_RUN_SCHEDULE` | `--schedule` | one of 'most-work', 'critical-path', 'direct-dependents', 'ready-order' | most-work | Which ready task starts first when there are more than workers. |
1692
+ | `run.affectedBase` | `VX_RUN_AFFECTED_BASE` | — | a non-blank string | origin/HEAD, then the trunk, else HEAD~1 | The git ref a bare `--affected` compares with. |
1693
+ | `run.branch` (variable or flag only) | `VX_RUN_BRANCH` | `--branch` | a non-blank string | git's branch, then the CI provider's | The branch a run records in its history and forecasts from. Flag and env only: a committed branch would be wrong on every other branch. |
1694
+ | `run.timeout` | `VX_RUN_TIMEOUT` | `--timeout` | a duration like '500ms', '30s' or '5m', at most 24 days | none | A task's time limit, for tasks without their own `exec.timeout` (`'30m'`). A task's own `exec.timeout` beats it. |
1695
+ | `run.retries` | `VX_RUN_RETRIES` | `--retries` | a non-negative integer | 0 | Re-runs of a failed task, for tasks without their own `exec.retries`. A task's own `exec.retries` beats it. |
1696
+ | `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. |
1697
+ | `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. |
1698
+ | `run.killGrace` | `VX_RUN_KILL_GRACE` | `--kill-grace` | a duration like '500ms', '30s' or '5m', at most 24 days | 2s | How long a task that ignores SIGTERM gets before SIGKILL. |
1699
+ | `run.teardownTimeout` | `VX_RUN_TEARDOWN_TIMEOUT` | `--teardown-timeout` | a duration like '500ms', '30s' or '5m', at most 24 days | 3s | The bound on each plugin's end-of-run flush and teardown. |
1700
+ | `run.readyNotice` | `VX_RUN_READY_NOTICE` | `--ready-notice` | a duration like '500ms', '30s' or '5m', at most 24 days | 10s | How long a persistent task may take to match `readyWhen` before vx says it waits. |
1701
+ | `run.rules.exclusiveOutputs` | `VX_RUN_RULES_EXCLUSIVE_OUTPUTS` | — | true or false | true | Refuse two tasks whose outputs overlap even when an edge orders them. |
1702
+ | `run.rules.upfrontKeys` | `VX_RUN_RULES_UPFRONT_KEYS` | — | true or false | true | Refuse a task whose inputs can match another task's outputs. |
1703
+ | `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. |
1704
+ | `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'`). |
1705
+ | `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'`). |
1706
+ | `cache.policy` | `VX_CACHE_POLICY` | `--cache-policy` | a non-blank string | every layer reads and writes | Which layers a run reads and writes (`'local:rw,remote:r'`); `--no-cache` and `--force` apply on top. |
1707
+ | `cache.scope` | `VX_CACHE_SCOPE` | `--cache-scope` | a non-blank string | none (the default keys) | Where cache writes land (`'pr-123'`): reads try the default key, then the scope's. |
1708
+ | `cache.download` | `VX_CACHE_DOWNLOAD` | `--cache-download` | one of 'all', 'toplevel', 'none' | all | Which remote hits land on disk: every one, only the requested tasks, or none. |
1709
+ | `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. |
1710
+ | `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. |
1711
+ | `cache.outputs.clean` | `VX_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. |
1712
+ | `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. |
1713
+ | `watch.poll` | `VX_WATCH_POLL` | `vx watch --poll` | true or false | false | Poll for changes from the start instead of the OS watcher. |
1714
+ | `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. |
1715
+ | `debug.timing` (variable or flag only) | `VX_DEBUG_TIMING` | — | true or false | false | Print the run's stage timings to stderr. |
1716
+ | `debug.dumpable` (variable or flag only) | `VX_DEBUG_DUMPABLE` | — | true or false | false | Linux: keep vx dumpable, so strace or a debugger can read its memory and `/proc/<pid>/fd`, and an unsandboxed task its env. |
1717
+
1718
+ <!-- /settings table -->
1719
+
1571
1720
  ```ts
1572
1721
  interface WorkspaceConfig {
1573
- /** Maximum concurrent tasks. Defaults to the cores this process may use (the CPU count, capped by a cgroup quota). */
1574
- concurrency?: number
1575
- /** Cache directory, relative to workspace root. Named, it holds the whole cache; unset, entries live in the user's shared store. */
1576
- cacheDir?: string
1577
- /** Default per-task timeout (ms) for tasks without their own exec.timeout. */
1578
- timeout?: number
1579
- /** Evict from the local cache at the end of each run (the `vx cache prune` policy). */
1580
- cacheRetention?: { olderThan?: string; maxSize?: string }
1581
- /** The git ref a bare `--affected` compares with. */
1582
- affectedBase?: string
1583
- /** Where remote writes land: 'trusted' (default), 'read-only', or an untrusted scope name. */
1584
- cacheScope?: string
1585
- /** Graph rules checked before anything runs; each on unless set to false. */
1586
- rules?: WorkspaceRules
1587
- /** Which ready task starts first with no timings: 'most-work' (default), 'critical-path', 'direct-dependents', 'ready-order'. */
1588
- schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
1722
+ run?: RunSettings
1723
+ cache?: CacheSettings
1724
+ lock?: LockSettings
1725
+ watch?: { poll?: boolean }
1589
1726
  /** Run-level plugins (cache / executor / telemetry capabilities). */
1590
1727
  plugins?: readonly (Plugin | undefined | null | false)[]
1591
1728
  }
1592
1729
 
1593
- interface WorkspaceRules {
1594
- /** Refuse overlapping outputs even across a dependsOn edge. Default true. */
1595
- exclusiveOutputs?: boolean
1596
- /** Refuse a task whose inputs can match another task's outputs. Default true. */
1597
- upfrontKeys?: boolean
1730
+ interface RunSettings {
1731
+ concurrency?: number | `${number}%`
1732
+ schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
1733
+ affectedBase?: string
1734
+ timeout?: Duration
1735
+ retries?: number
1736
+ continue?: 'never' | 'deps-ok' | 'always'
1737
+ killGrace?: Duration
1738
+ teardownTimeout?: Duration
1739
+ readyNotice?: Duration
1740
+ /** Graph rules `vx lock` checks, never a run; each on unless set to false. */
1741
+ rules?: { exclusiveOutputs?: boolean; upfrontKeys?: boolean; dependencies?: DependencyRule[] }
1742
+ }
1743
+
1744
+ /** `run.rules.dependencies`, checked by `vx lock` only. */
1745
+ type DependencyRule = { from: string; allow?: string[]; deny?: string[] }
1746
+
1747
+ interface LockSettings {
1748
+ /** A pretty vx.lock.json beside each config, for review; on unless false. */
1749
+ reviewFiles?: boolean
1750
+ }
1751
+
1752
+ interface CacheSettings {
1753
+ dir?: string
1754
+ retention?: { olderThan?: Duration; maxSize?: string }
1755
+ policy?: string
1756
+ scope?: string
1757
+ download?: 'all' | 'toplevel' | 'none'
1758
+ inputStability?: InputStability
1759
+ outputs?: { clean?: boolean }
1598
1760
  }
1599
1761
  ```
1600
1762
 
1601
- - **`concurrency`** — used as the default cap on parallel tasks; the
1602
- CLI `--concurrency <n>` still wins when passed.
1603
- - **`timeout`** — the lowest-precedence default per-task timeout (ms),
1604
- applied to any task that declares no `exec.timeout`. Precedence,
1605
- highest first: per-task `exec.timeout` → `--timeout` /
1606
- `RunOptions.timeout` → `VX_TASK_TIMEOUT` env → this. A runaway task's
1607
- process group is SIGTERMed and the task reported `failed`. Purely a safety net — never folded
1608
- into a cache key (a timed-out task fails and is never cached).
1609
- - **`cacheDir`** — unset, the workspace keeps its index and history in
1763
+ - **`run.concurrency`** — the cap on parallel tasks: a positive integer,
1764
+ or a share of the cores this process may use (`'50%'` on eight cores is
1765
+ 4, never below 1; over 100% is allowed for I/O-bound work).
1766
+ - **`run.timeout`** — a task's time limit, for any task that declares no
1767
+ `exec.timeout` (which beats it, its flag and its variable). A runaway
1768
+ task's process group is SIGTERMed and the task reported `failed`.
1769
+ Purely a safety net — never folded into a cache key (a timed-out task
1770
+ fails and is never cached).
1771
+ - **`run.retries`** — re-runs of a failed task, for tasks without their
1772
+ own `exec.retries` (which beats it, `retries: 0` included); never a
1773
+ persistent one.
1774
+ - **`run.continue`** — what a failed task takes down: `never` stops
1775
+ dispatch, `deps-ok` (the default) skips its dependents, `always` runs
1776
+ them (`docs/cli.md` § Failure propagation).
1777
+ - **`run.killGrace`**, **`run.teardownTimeout`**, **`run.readyNotice`**
1778
+ — the SIGTERM → SIGKILL grace (2 s), the bound on each plugin's
1779
+ end-of-run flush and teardown (3 s; there is no "no limit"), and how
1780
+ long a persistent task may wait for `readyWhen` before vx says what it
1781
+ waits for (10 s).
1782
+ - **`run.rules`** — checked by `vx lock` and `vx lock --check` only;
1783
+ no run checks them.
1784
+ - **`cache.dir`** — unset, the workspace keeps its index and history in
1610
1785
  `.vx/cache` and its entries and artifacts in its repository's shared
1611
1786
  store (`~/.vx/<id>/cache`), where every other checkout of the
1612
1787
  repository hits them (`docs/caching.md`). Named here, by
1613
1788
  `VX_CACHE_DIR`, or by `--cache-dir`, the directory holds the whole
1614
- cache, shared with no other workspace.
1789
+ cache, shared with no other workspace. `--cache-dir` beats
1790
+ `VX_CACHE_DIR`, which beats this.
1615
1791
  Relative paths are resolved against the workspace
1616
1792
  root, `~/` against the home directory; absolute paths are used
1617
- as-is. The home directory itself (`~`) is refused. `vx run`, `vx cache prune`,
1793
+ as-is. The home directory itself (`~`) is refused. `vx run`, `vx cache clean`,
1618
1794
  and any other reader use the same resolution
1619
1795
  (`src/workspace/workspace.ts:resolveCacheDir`). The cache is a
1620
1796
  directory of its own: a first index in one that holds a
1621
- `package.json` or `pnpm-workspace.yaml` (`''` and `'.'` name the
1797
+ `package.json`, `pnpm-workspace.yaml`, a `vx.config.*` or a
1798
+ `.vx/workspace.*` (`''` and `'.'` name the
1622
1799
  root), whose subdirectory does (`'packages'`), or that holds the
1623
1800
  workspace (`'..'`, `'/'`) is refused before anything is written, since its `*`
1624
- `.gitignore` would hide the sources from git and the cache keys.
1625
- - **`cacheRetention`** — the `vx cache prune` policy, applied at the
1801
+ `.gitignore` would hide the sources from git and the cache keys. A
1802
+ directory other than the default `.vx/cache` gets that `*` `.gitignore`;
1803
+ the default is ignored by `.vx/.gitignore` (`/cache`) instead, written
1804
+ when missing and never edited.
1805
+ - **`cache.retention`** — the `vx cache clean` policy, applied at the
1626
1806
  end of every run: entries unused for
1627
1807
  `olderThan` (`30d`, `12h`, `90m`, `45s`) go first, then the
1628
- least-recently-used until the cache is under `maxSize` (`10G`,
1808
+ least-recently-used until the cache is under `maxSize` (`10GB`,
1629
1809
  `500MB`, `64KB`; a bare number is refused as a typo, `10B` is not).
1630
- Either or both, and neither may be zero: each would evict what every
1810
+ Either or both (each also from `VX_CACHE_RETENTION_OLDER_THAN` /
1811
+ `VX_CACHE_RETENTION_MAX_SIZE`), and neither may be zero: each would evict what every
1631
1812
  run just saved (item 969). It runs after the run's saves
1632
1813
  and uploads have landed, never on a run a signal or an abort stopped
1633
1814
  (one stopped while it waited on the workspace lock never held it), only when something is due (a run with
1634
1815
  nothing to evict pays one scan of the index), and says nothing:
1635
1816
  housekeeping prints no line (owner, 2026-10-06). Under `olderThan` an entry the run just used is never due, but `maxSize` is least-recently-used first, so a bound below one run's outputs evicts that run's own. The prune's
1636
1817
  orphan sweep (artifacts no index row counts, judged by file time —
1637
- see `vx cache prune`) also runs on its own clock, at most once an
1818
+ see `vx cache clean`) also runs on its own clock, at most once an
1638
1819
  hour, so their bytes go even when nothing the index holds is due;
1639
1820
  listing the directory every run would cost 0.5 ms per 1,000
1640
1821
  entries. Housekeeping,
1641
1822
  not the run's work: a failure is silent, never a failed run. Not
1642
1823
  folded into any cache key. Omitted → the cache grows until
1643
- `vx cache prune`.
1644
- - **`affectedBase`** — the git ref a bare `--affected` compares with
1824
+ `vx cache clean`.
1825
+ - **`run.affectedBase`** — the git ref a bare `--affected` compares with
1645
1826
  (`origin/develop`); `--affected=<base>` still wins. Omitted →
1646
1827
  `origin/HEAD`, then the first of `origin/main`, `origin/master`, `main`, `master` that is not HEAD (D-93), else `HEAD~1`. A plugin's `config` stage may set it:
1647
1828
  `nx()` from `NX_BASE` or nx.json's `defaultBase`, `turbo()` from
1648
1829
  `TURBO_SCM_BASE`, else on GitHub Actions from `GITHUB_BASE_REF` or the
1649
1830
  push event's `before`, as Turbo does. Not folded into any cache key.
1650
- - **`cacheScope`** — where this run's remote cache writes land.
1651
- `'trusted'` reads and writes the task keys: the default branch's CI.
1652
- `'read-only'` reads them and writes nothing: a laptop. Omitted, `vx
1653
- run` takes `'read-only'` off CI (`CI` unset, `0` or `false`) and
1654
- `'trusted'` on CI or when `--cache` names the remote; `run()` takes
1655
- `RunOptions.defaultCacheScope`, else `'trusted'`.
1656
- Any other name (`'pr-123'`; letters, digits, `. _ - / @`, at most 128) is an untrusted scope: a remote read tries the scope's key, then
1657
- the trusted one, and a write goes to the scope's only, so a pull request
1658
- never writes what the default branch reads, and two scopes never see
1659
- each other. A scope's key is derived from the task key, so every
1660
- remote wire stores it unchanged. A clamp on `--cache`, never a
1661
- widening. `github()` sets it on Actions: `pr-<n>`, `ref-<name>` off
1662
- the default branch. `VX_CACHE_SCOPE` beats both. A client-side convention, not a security
1831
+ - **`cache.policy`** — which layers a run reads and writes, in
1832
+ `--cache-policy`'s spec (`'local:rw,remote:r'`; a layer the spec leaves out
1833
+ stays on; the grammar is in `docs/cli.md` § Cache policy). Omitted,
1834
+ every layer reads and writes on CI, and `vx run` off CI (`CI` unset,
1835
+ `0` or `false`) reads the remote and does not write it: a key holds
1836
+ only declared inputs, so a laptop's write could replay on CI. The
1837
+ policy alone decides whether a layer is written; `cache.scope` never
1838
+ does. `VX_CACHE_POLICY` beats it and
1839
+ `vx run --cache-policy` (or `--cache-policy`) beats both; `--no-cache` and `--force` still apply on top. A spec
1840
+ naming the remote (`remote:rw`) asks to write it, off CI too. Parsed
1841
+ before any task: a bad spec is refused naming `cache.policy`. Whatever
1842
+ the policy, a lookup asks the local cache first, then each remote in
1843
+ the order the plugins declare them. Not folded into any cache key.
1844
+ - **`cache.download`** — which remote hits land on this machine:
1845
+ `all` (the default), only the requested tasks' (`toplevel`), or
1846
+ `none` (`docs/cli.md` § `--cache-download`).
1847
+ - **`cache.scope`** — where this run's cache writes land (2026-10-14).
1848
+ Omitted, a run reads and writes the default keys. A name (`'pr-123'`;
1849
+ letters, digits, `. _ - / @`, at most 128) reads the default key first,
1850
+ then the scope's, and writes only the scope's, so a pull request never
1851
+ writes what the default branch reads, and two scopes never see each
1852
+ other. A default artifact the ingest refuses falls through to the
1853
+ scope's key. A scope's key is derived from the task key, so every
1854
+ remote wire stores it unchanged. The rule holds for the local cache
1855
+ as for every remote, so on one disk an entry a scope saved is a miss
1856
+ for another scope or none, and an unscoped one serves every run. The scope never decides whether a run writes; `cache.policy`
1857
+ does.
1858
+ `github()` sets it on Actions: `pr-<n>`, `ref-<name>` off
1859
+ the default branch. `VX_CACHE_SCOPE` beats both, and `vx run --cache-scope` beats all three. A client-side convention, not a security
1663
1860
  boundary: a run holding a write credential can write any key, so only
1664
1861
  a cache server that scopes writes by token can refuse one
1665
1862
  (`docs/security.md` § Cache poisoning). Not folded into any cache key.
1666
- - **`schedule`** — which ready task starts first when more are ready
1863
+ The old top-level `cacheScope` is refused, naming `cache: { scope }`.
1864
+ - **`run.schedule`** — which ready task starts first when more are ready
1667
1865
  than workers, with no timings. `most-work` (the default): the one the
1668
1866
  most tasks wait on, at any depth. `critical-path`: the head of the
1669
1867
  longest chain of tasks below it. `direct-dependents`: the one the most
@@ -1674,15 +1872,20 @@ run` takes `'read-only'` off CI (`CI` unset, `0` or `false`) and
1674
1872
  their ties. The default costs what it did; another strategy is
1675
1873
  computed only when named. Any other value is refused. Never folded
1676
1874
  into a cache key.
1677
- - **`rules`** — checks on the task graph, each on unless set to
1875
+ - **`cache.inputStability`** — the `cache.inputStability` of every task that
1876
+ does not set its own field, same shape (`{ strategy, severity }`, §
1877
+ `cache`). `VX_CACHE_INPUT_STABILITY_STRATEGY` and `_SEVERITY` still
1878
+ override every task for the run. Never folded
1879
+ into a cache key.
1880
+ - **`run.rules`** — checks on the task graph, each on unless set to
1678
1881
  `false`. Turning one off allows a shape vx runs correctly but more
1679
1882
  slowly; never folded into a cache key. A value that is not a boolean,
1680
1883
  or a rule vx does not know, is refused.
1681
1884
  - **`exclusiveOutputs`** (X-53) — two tasks whose declared outputs
1682
1885
  overlap are refused even when a `dependsOn` edge orders them:
1683
1886
  `<a> and <b> both declare the output "<glob>" … Give each task its
1684
- own output path, or set rules: { exclusiveOutputs: false } in
1685
- vx.workspace.ts to let a dependant add to its upstream's outputs.`
1887
+ own output path, or set run.rules.exclusiveOutputs: false in
1888
+ .vx/workspace.ts to let a dependant add to its upstream's outputs.`
1686
1889
  Off, the dependant adds to its upstream's tree (item 588;
1687
1890
  `caching.md` § Additive outputs). Two overlapping tasks with no
1688
1891
  edge are refused either way.
@@ -1694,8 +1897,8 @@ vx.workspace.ts to let a dependant add to its upstream's outputs.`
1694
1897
  landing in its project (X-135), is refused,
1695
1898
  edge or no edge: `<reader> reads "<glob>" in cache.inputs.<field>,
1696
1899
  which matches <writer>'s output "<glob>" … Exclude it: add "!<glob>"
1697
- to <reader>'s cache.inputs.<field>, or set rules: { upfrontKeys:
1698
- false } in vx.workspace.ts to let it wait for its producer.` Such a key
1900
+ to <reader>'s cache.inputs.<field>, or set run.rules.upfrontKeys:
1901
+ false in .vx/workspace.ts to let it wait for its producer.` Such a key
1699
1902
  reads what the producer writes this run, so vx cannot know it before
1700
1903
  the producer ran: it is not probed, prefetched or restored ahead of
1701
1904
  the schedule (`caching.md` § Local restore tier). The upstream's key
@@ -1704,17 +1907,33 @@ false } in vx.workspace.ts to let it wait for its producer.` Such a key
1704
1907
  `!` entries take back no longer holds its key back either. A task's
1705
1908
  own outputs, a task with no `cache`, and a group are exempt. Off,
1706
1909
  the key waits for its producer, as before.
1910
+ - **`dependencies`** — which workspace projects a project may depend
1911
+ on, checked by `vx lock` and `vx lock --check` only, over each
1912
+ project's direct dependencies (its `package.json` buckets and its
1913
+ tasks' cross-project `dependsOn`). Each rule is
1914
+ `{ from, allow?, deny? }`; a selector is `tag:<tag>` (a project
1915
+ carrying the tag) or a project name, and `*` matches any characters
1916
+ in either (`tag:scope:*`, `@acme/*`). Every rule whose `from`
1917
+ selects a project applies: each dependency must match an `allow`
1918
+ selector when `allow` is given, and none of `deny`, so a deny beats
1919
+ any allow. A project no rule selects is free, and an untagged
1920
+ dependency matches no `tag:` selector. A broken edge refuses the lock
1921
+ with `VX_E_DEPENDENCY_RULE`:
1922
+ `lib depends on app, which run.rules.dependencies[0] (from 'tag:lib') denies`,
1923
+ every broken edge on its own line. A rule with neither `allow` nor
1924
+ `deny` is refused.
1707
1925
  - **`plugins`** — the run-level extension points. Optional: core
1708
1926
  applies no plugin by default, and the local executor and the local
1709
- cache are its floor — the tail of every executor list and cache chain
1710
- — so a workspace that declares none runs and caches here.
1927
+ cache are its floor — the tail of every executor list, and the first
1928
+ layer of every cache chain — so a workspace that declares none runs
1929
+ and caches here.
1711
1930
  Declaration order is precedence: every `executor` is consulted in
1712
1931
  order per task (first to accept runs it; what all decline runs
1713
1932
  locally); every `cache` layer is chained (lookup walks, save reaches
1714
1933
  all, the local store last). An `undefined`, `null` or `false` entry
1715
1934
  is skipped, as Vite skips one: a factory that declines (`github()` off
1716
1935
  Actions) or `cond && plugin()` needs no filter. Each entry is a `VxPlugin` contributing
1717
- any subset of the fourteen hooks, in pipeline order: `config` (edit
1936
+ any subset of the fifteen hooks, in pipeline order: `config` (edit
1718
1937
  the workspace config before anything derives from it), `discover`
1719
1938
  (name directories to make projects beyond the members), `project`
1720
1939
  (edit a loaded project's tasks — inject, remove, rewrite;
@@ -1728,19 +1947,20 @@ false } in vx.workspace.ts to let it wait for its producer.` Such a key
1728
1947
  ready task start now beside what runs here), `executor` (return a
1729
1948
  `TaskExecutor` — where one task's command runs — or decline), `cache`
1730
1949
  (return a cache layer or decline), `telemetry` (observe-only export:
1731
- OTel, the GitHub job summary, a custom sink), `setup` and `teardown`
1950
+ OTel, the GitHub job summary, a custom sink), `output` (return a
1951
+ `Logger` that draws the run in place of vx's own, or decline), `setup`
1952
+ and `teardown`
1732
1953
  around the run, and CLI `commands` (`{ verb: { description, run } }`,
1733
1954
  consulted for a verb core does not know). First-party plugins:
1734
1955
  `@vzn/vx-otel`, `@vzn/vx-ci`, `@vzn/vx-reapi`, `@vzn/vx-lockfile`,
1735
- `@vzn/vx-schedule-history`, `@vzn/vx-mcp`, `@vzn/vx-migrate`. A
1956
+ `@vzn/vx-schedule-history`, `@vzn/vx-migrate`. A
1736
1957
  plugin that declines every capability (`otel()` with no OTLP endpoint
1737
1958
  configured) costs nothing — a run with no active plugin is
1738
1959
  byte-identical to one with none declared. Plugins observe, route and
1739
1960
  execute; they never change what a task is.
1740
1961
 
1741
- The loader validates the shape (positive integer for `concurrency`,
1742
- string for `cacheDir`, plugin objects with a name and at least one
1743
- capability) and throws a `UserError` on
1962
+ The loader validates the shape (each setting by its kind, plugin objects
1963
+ with a name and at least one capability) and throws a `UserError` on
1744
1964
  malformed input.
1745
1965
 
1746
1966
  There are no workspace-level `globalInputs` / task-default fields:
@@ -1838,7 +2058,7 @@ export default defineProject({
1838
2058
  description: 'vite dev server',
1839
2059
  exec: {
1840
2060
  command: 'vite',
1841
- timeout: 30_000,
2061
+ timeout: '30s',
1842
2062
  persistent: { readyWhen: 'Local:' },
1843
2063
  env: { passThrough: ['VITE_API_URL'] },
1844
2064
  },
@@ -2061,7 +2281,6 @@ a user meets most:
2061
2281
  | `tasks must be an object keyed by task name` | `tasks` is not an object — an ARRAY included. |
2062
2282
  | `<path> is <what> — a config must be JSON data, because the cache key folds its JSON` | A value JSON cannot carry, anywhere in the config: a function, a symbol, a bigint, `NaN` / `±Infinity`, `undefined` in an array, a cycle, a getter or an object that is not plain (`Date`, `Map`, `RegExp`, a class instance). |
2063
2283
  | `<level> has unknown field "<key>"` | Typo'd / unsupported key (see below). |
2064
- | `<level> has field "<key>", which vx <version> removed — use <replacement>` | A field an earlier release accepted (`exec.resources`, removed in 0.0.19). The message names what replaced it (`design/versioning-1.0.md` § Deprecation). |
2065
2284
  | `<level> must be an object (fields: <fields>), not an array` | An array where an object goes — `outputs: ['dist/**']` (Turbo's spelling) is `outputs: { files: ['dist/**'] }`, and the message says so. |
2066
2285
  | `cannot find '<name>' — no node_modules above the config provides it; install the workspace's dependencies first` | A bare import nothing installed serves — a fresh clone before its install, or a typo. Refused before the config is evaluated, so Bun never auto-installs it from the registry (it would, when no `node_modules` exists above). |
2067
2286
  | `cannot find '<name>' — Yarn Plug'n'Play installed the workspace's dependencies into .pnp.cjs, which Bun does not read; set <remedy>` | The same, with a `.pnp.cjs` at or above the config: the install is there and Bun cannot read it, so the remedy is `nodeLinker: node-modules` in `.yarnrc.yml` and `yarn install` (D-109). |
@@ -2088,16 +2307,18 @@ a user meets most:
2088
2307
  | `cache.inputs.tasks must be an array of non-empty strings` | Non-string / empty entry, or a bare string. |
2089
2308
  | `cache.inputs.tasks: "<name>" names no task in <task>.dependsOn` | An exact entry no `dependsOn` entry of its form names. |
2090
2309
  | `cache.outputs is required when cache is set` | Forgot `outputs`. |
2091
- | `cache.rewrittenInputs must be 'withhold' or 'compare'` | Any other value (`'bytes'`, `true`). |
2310
+ | `cache.inputStability must be an object` | A bare string (`inputStability: 'mutable'`). |
2311
+ | `cache.inputStability.strategy must be 'immutable', 'mutable' or 'unchecked'` | Any other value (`'content'`, `false`). |
2312
+ | `cache.inputStability.severity must be 'warn' or 'error'` | Any other value (`'fail'`, `true`). |
2092
2313
  | `cache.outputs must be an object` | Present but not an object: a string (`outputs: 'dist'`). An array is the row above. |
2093
2314
  | `cache.outputs.files must be an array` | Wrong shape. |
2094
2315
  | `cache.inputs.files: every entry is a negation, which selects NOTHING` | Only `!` globs — nothing to subtract from. |
2095
2316
  | `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. |
2096
2317
  | `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). |
2097
- | `cache.outputs.files: "<glob>" covers the project's own <file>` | An output glob that matches the project's `package.json` or its own `vx.config.*` (`**`, `*.json`): the clean before a run would delete them. A `!` entry that takes the file back (`!package.json`) lets it load. |
2318
+ | `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. |
2098
2319
  | `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. |
2099
2320
  | `cache.inputs.files: '!!' is not a double negation` | `!!x` inverts the set — it folds only `x`. |
2100
- | `exec.timeout: <n> ms exceeds the maximum timer delay` | Past 2^31-1 ms a timer fires at once, not never. |
2321
+ | `exec.timeout of <value> exceeds the maximum timer delay` | Past 2^31-1 ms a timer fires at once, not never. |
2101
2322
  | `description must be a string` | Non-string description. |
2102
2323
  | `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. |
2103
2324
 
@@ -2106,7 +2327,7 @@ the project's top level (`tasks`), the task itself, `exec`, `exec.env`,
2106
2327
  `exec.persistent`, `exec.sandbox` and its `allow` /
2107
2328
  `deny` / `ignore` blocks, `cache`, `cache.inputs`, and `cache.outputs`
2108
2329
  (`tests/schema-unknown-keys.test.ts` walks every one) — and at the top
2109
- of `vx.workspace.ts`,
2330
+ of `.vx/workspace.ts`,
2110
2331
  where `plugin:` (singular) would otherwise declare no plugins and run
2111
2332
  the workspace bare. A silently-dropped
2112
2333
  `workspaceFile` (singular) or `timeoutMs` would make the task hash as
@@ -2130,54 +2351,49 @@ ends `— vx spells it cache.outputs.files` in code quotes. A `cache` that
2130
2351
  is no object (Turbo's `cache: false`) and a `persistent` that is none
2131
2352
  (`true`) name the shape to write.
2132
2353
 
2133
- Workspace-discovery errors (`src/workspace/workspace.ts`):
2134
-
2135
- | Symptom | Cause |
2136
- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2137
- | `failed to parse <file>: <why>` | A `package.json` / `pnpm-workspace.yaml` is not valid. |
2138
- | `<file>: packages must be an array of glob strings` | `pnpm-workspace.yaml` `packages:` is a bare string, etc. |
2139
- | `<file>: must be a JSON object` | A `package.json` (the root's or a member's) is `null`, a list or a scalar; it crashed with a TypeError until item 988. |
2140
- | `<file>: "name" must be a string with no surrounding whitespace` | A `package.json` `name` is a number, an object, or has surrounding whitespace (npm refuses one too); `{"name":123}` planned `123#build` until item 988. |
2141
- | `<file>: "name" cannot hold "#" — vx addresses a task as <name>#<task>` | A `package.json` `name` holds `#` (npm refuses one too): `{"name":"a#b"}` planned `a#b#build` under `--all`, but `vx run a#b#build` and a `dependsOn` split at the first `#` and found nothing. |
2142
- | `<file>: must be a mapping (packages: and pnpm's settings)` | `pnpm-workspace.yaml` is a list or a scalar. A mapping with no `packages:`, or an empty one (pnpm 10 settings or catalogs in a single-package repo) is not an error: the root's `package.json` decides, as without the file (item 984). |
2143
- | `<file>: workspaces must be an array of glob strings` | `package.json` `workspaces` holds a non-string entry. |
2144
- | `workspace member <dir> (<abs>) is outside the workspace root <root>: vx keeps every project under the root` | A member glob (`../ext/*`, an absolute path) reached a package outside the root. npm and pnpm take one; `--affected` asks git from the root and missed edits there. Move the root up to a directory that holds every member. |
2145
- | `<file>: workspaces.packages must be an array of glob strings` | The yarn-legacy `workspaces: { packages: [...] }` form holds a non-string entry. |
2146
- | `<file>: <field> entry "<glob>" is an extglob, which vx's glob engine does not read` | A member glob holds `!(…)`, `@(…)`, `+(…)`, `*(…)` or `?(…)`. `Bun.Glob` has no extglob (its scan widened `packages/!(x)` to include x, turborepo#3766); a whole-segment `!(a\|b)` gets its exact rewrite, `["packages/*", "!packages/a", "!packages/b"]`. |
2354
+ Project-discovery errors (`src/workspace/workspace.ts`, `src/orchestrator/projects.ts`):
2355
+
2356
+ | Symptom | Cause |
2357
+ | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
2358
+ | `two projects are named "<name>": <dir> and <dir> — give one another \`name\` in its vx.config` | Two directories got one name: a config's `name`, a plugin's, or the path. |
2359
+ | `<dir>: a project is named by its path unless named, and this one holds "#" …` | A path no task spec can address: set `name` in its config. |
2360
+ | `<dir>: "//" names the workspace root — set another \`name\`` | A config away from the root says `name: '//'`. |
2361
+ | `.vx/projects.lock.json locks <dir>, but its <file> is gone — run \`vx lock\`` | A locked project's config was deleted or renamed (X-144). |
2362
+ | `.vx/projects.lock.json locks <dir>, but its <file> is not readable by this user (<code>)` | This user may not search the project's directory (D-132). |
2363
+ | `.vx/projects.lock.json has unsupported version <n> (this vx expects 3) — re-run \`vx lock\`` | A lock an earlier vx wrote (version 2 keyed by name). |
2364
+ | `git ls-files failed (exit <n>): <why>` | `vx lock` could not list the configs (a torn index). |
2365
+
2366
+ Core reads no `package.json` or `pnpm-workspace.yaml`; the errors reading them are `@vzn/vx-migrate`'s (its README, "Errors reading the members").
2147
2367
 
2148
2368
  Workspace-config errors:
2149
2369
 
2150
- | Symptom | Cause |
2151
- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2152
- | `concurrency must be a positive integer` | `concurrency` is negative, zero, NaN, ... |
2153
- | `timeout must be a positive integer (milliseconds)` | Workspace `timeout` is ≤ 0, NaN, or not an int. |
2154
- | `cacheDir must be a string` | Wrong shape. |
2155
- | `cacheDir is only whitespace — name a directory` | A `cacheDir` of spaces: it made a directory named so at the root, hidden by the cache's own `.gitignore`. |
2156
- | `cacheDir holds a NUL, which no path can carry` | No path can hold a NUL; the cache directory's creation failed and blamed the workspace's permissions. |
2157
- | `cacheRetention must be { olderThan?: '30d', maxSize?: '10G' }` | Not an object. |
2158
- | `cacheRetention names neither olderThan nor maxSize` | An empty policy would evict nothing and read as one. |
2159
- | `cacheRetention.olderThan must be a duration like '30d', '12h', '90m' or '45s'` | The `vx cache prune --older-than` spelling, or not a string. |
2160
- | `cacheRetention.maxSize must be a size like '10G', '500MB' or '64KB'` | The `vx cache prune --max-size` spelling (no fractions), or not a string. |
2161
- | `cacheRetention.olderThan of 0 evicts every entry after every run` | Every run would evict what it just saved; `vx cache prune --older-than 0` is refused too. |
2162
- | `cacheRetention.maxSize of 0 evicts every entry after every run` | The same, for the size bound. |
2163
- | `cacheRetention.maxSize '<n>' reads as <n> bytes — give a unit (e.g. '<n>M', '<n>G')` | A bare number is bytes; a cache capped at `10` bytes is a typo for `10G`. `10B` still loads. |
2164
- | `affectedBase must be a git ref like 'origin/main'` | Not a string, empty, holding whitespace or a NUL, or opening with `-` (git would read an option). |
2165
- | `cacheScope must be 'trusted', 'read-only', or a scope name of <rule>` | Not a string, or a scope name holding a character outside letters, digits and `. _ - / @`, or longer than 128. |
2166
- | `rules must be { exclusiveOutputs?: boolean; upfrontKeys?: boolean }` | Not an object. |
2167
- | `rules.<name> must be true or false` | A rule set to something other than a boolean (`'off'`, `0`). |
2168
- | `plugins must be an array of plugin objects` | Wrong shape. |
2169
- | `plugins[<i>] must be an object` | A non-object entry in `plugins`; a string (Nx's `'@nx/vite/plugin'`) adds that a plugin is what its package's function returns, not a module name (D-49). |
2170
- | `plugins[<i>] must come from definePlugin(import.meta, { … })` | A plain object where a plugin was expected: a plugin's name is its package name, and only `definePlugin` sets it. |
2171
- | `plugins[<i>] is a Promise — await the factory that returns the plugin` | An async plugin factory called without `await` (`plugins: [later()]`); the refusal said the plugin did not come from `definePlugin` (D-159). |
2172
- | `plugins[<i>].name overrides the package name` | A `name` set over `definePlugin`'s result; drop the field. |
2173
- | `plugins[<i>].<capability> of plugin '<name>' must be a function` | A capability key holding something that is not callable. |
2174
- | `plugins[<i>] (plugin '<name>') must contribute at least one of config/discover/project/graph/key/fingerprint/schedule/admit/executor/cache/telemetry/setup/commands/teardown` | A plugin object with no capability. |
2175
- | `plugin '<name>' declares '<key>', which is no plugin hook` | A key on a plugin that names no hook (`excutor`, `setUp`): it was never called. The nearest hook is hinted. |
2176
- | `<file> has unknown field "<key>"` | Typo'd / unsupported top-level key (`plugin`); the hint names the nearest spelling, or where vx keeps a Turbo or Nx key (`pipeline`, `remoteCache`, `parallel`, `cacheDirectory`, `defaultBase`, `tasksRunnerOptions`, `globalPassThroughEnv`; D-38, D-49; pnpm-workspace.yaml's, Lerna's and package.json's `packages`, `workspaces`, `catalog`, `npmClient`, and `ignore`, `cache`, `remote`, `env`, D-98; in a project's vx.config, `targets`, `extends`, `implicitDependencies` and `name`, D-56; `scripts`, `pipeline`, `namedInputs`, `root`, `sourceRoot`, `projectType`, and a task's `dependsOn`, `cache`, `exec`, `inputs`, `outputs`, `env` one level too high, D-99). |
2177
- | `plugin '<name>' declares command '<verb>', a core verb — core verbs cannot be shadowed` | A plugin verb the dispatcher matches first; it could never run. |
2178
- | `plugin '<name>' declares command '<verb>', which is not a word — a verb is a letter or digit, then letters, digits, ':', '.', '_' or '-'` | A verb like `--version`, `''` or `a b`: core reads a flag first, an empty word is no verb, and `vx completions` writes verbs unquoted into a shell script. |
2179
- | `plugins '<a>' and '<b>' both declare command '<verb>' — a verb has one owner` | Two packages on one verb; the first would win and hide the second. Plugins of one package are one owner, and the first declared runs. |
2180
- | `plugins[<i>].fingerprint of plugin '<name>' must be { files: [name, …], affected: function }` | A fingerprint claim without its file list or its `affected` answer. |
2181
- | `plugin '<name>' claims fingerprint file "<file>", which is not a file name at the workspace root` | A claim names a path, or no name: a claim is a bare file at the root (`vx watch`'s root arm is not recursive). |
2182
- | `plugins '<a>' and '<b>' both claim fingerprint file '<file>' — a file has one claimant` | Two plugins keying the same lockfile; the key would fold both and `--affected` could ask only one. |
2183
- | `plugin '<name>' claims fingerprint file '<file>' twice (plugins[<i>] and plugins[<j>]) — a file has one claimant; declare the plugin once` | One plugin package declared twice (`plugins: [bun(), bun()]`); the message named it as two plugins (D-158). |
2370
+ | Symptom | Cause |
2371
+ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2372
+ | `<setting> must be <what its kind takes> (got <value>)` | A setting of the wrong type or out of range: `run.concurrency: 0`, `cache.dir: 42`, `run.schedule: 'fastest'`. The [settings table](#settings) says what each takes; a count is a number and a switch a boolean, never a string. |
2373
+ | `<setting> takes a duration with its unit, like '<n>ms' or '<n>s' (got <n>)` | A bare number for a duration: `run.timeout: 30000`. Durations carry a unit, so `30000` cannot be read as seconds by mistake. |
2374
+ | `<setting> of <value> exceeds the maximum timer delay` | A timer past 2^31-1 ms fires at once, not never. Omit `run.timeout` for no limit. |
2375
+ | `<group> must be an object (fields: <fields>)` | `run`, `watch`, `run.rules` or `cache.retention` that is not an object. |
2376
+ | `cache must be an object like { dir, policy, scope }; a task caches through its own cache in its project's vx.config` | Not an object: Turbo's and Nx's workspace `cache: true` is a task's own `cache` in vx. |
2377
+ | `cache.dir holds a NUL, which no path can carry` | No path can hold a NUL; the cache directory's error blamed the workspace's permissions. |
2378
+ | `run.affectedBase must be a git ref like 'origin/main'` | Empty, holding whitespace or a NUL, or starting with `-`, which git would read as an option. |
2379
+ | `cache.scope must be a scope name of <rule>` | Not a string, or a scope name holding a character a cache key cannot carry. |
2380
+ | `cache.retention names neither olderThan nor maxSize` | An empty policy would evict nothing and read as one. |
2381
+ | `cache.retention.olderThan of 0 evicts every entry after every run` | Every run would evict what it just saved; `vx cache clean` refuses it too. |
2382
+ | `cache.retention.maxSize of 0 evicts every entry after every run` | The same, for the size bound. |
2383
+ | `cache.retention.maxSize '<n>' reads as <n> bytes — give a unit (e.g. '<n>MB', '<n>GB')` | A bare number is bytes; a cache capped at ten bytes keeps nothing. |
2384
+ | `plugins must be an array of plugin objects` | Wrong shape. |
2385
+ | `plugins[<i>] must be an object` | A non-object entry in `plugins`; a string (Nx's `'@nx/vite/plugin'`) adds that a plugin is what its package's function returns, not a module name (D-49). |
2386
+ | `plugins[<i>] must come from definePlugin(import.meta, { … })` | A plain object where a plugin was expected: a plugin's name is its package name, and only `definePlugin` sets it. |
2387
+ | `plugins[<i>] is a Promise — await the factory that returns the plugin` | An async plugin factory called without `await` (`plugins: [later()]`); the refusal said the plugin did not come from `definePlugin` (D-159). |
2388
+ | `plugins[<i>].name overrides the package name` | A `name` set over `definePlugin`'s result; drop the field. |
2389
+ | `plugins[<i>].<capability> of plugin '<name>' must be a function` | A capability key holding something that is not callable. |
2390
+ | `plugins[<i>] (plugin '<name>') must contribute at least one of config/discover/project/graph/key/fingerprint/schedule/admit/executor/cache/telemetry/output/setup/commands/teardown` | A plugin object with no capability. |
2391
+ | `plugin '<name>' declares '<key>', which is no plugin hook` | A key on a plugin that names no hook (`excutor`, `setUp`): it was never called. The nearest hook is hinted. |
2392
+ | `<file> has unknown field "<key>"` | Typo'd / unsupported top-level key (`plugin`); the hint names the nearest spelling, or where vx keeps a Turbo or Nx key (`pipeline`, `remoteCache`, `parallel`, `cacheDirectory`, `defaultBase`, `tasksRunnerOptions`, `globalPassThroughEnv`; D-38, D-49; pnpm-workspace.yaml's, Lerna's and package.json's `packages`, `workspaces`, `catalog`, `npmClient`, and `ignore`, `cache`, `remote`, `env`, D-98; in a project's vx.config, `targets`, `extends`, `implicitDependencies` and `name`, D-56; `scripts`, `pipeline`, `namedInputs`, `root`, `sourceRoot`, `projectType`, and a task's `dependsOn`, `cache`, `exec`, `inputs`, `outputs`, `env` one level too high, D-99). |
2393
+ | `plugin '<name>' declares command '<verb>', a core verb — core verbs cannot be shadowed` | A plugin verb the dispatcher matches first; it could never run. |
2394
+ | `plugin '<name>' declares command '<verb>', which is not a word — a verb is a letter or digit, then letters, digits, ':', '.', '_' or '-'` | A verb like `--version`, `''` or `a b`: core reads a flag first, an empty word is no verb, and `vx completions` writes verbs unquoted into a shell script. |
2395
+ | `plugins '<a>' and '<b>' both declare command '<verb>' — a verb has one owner` | Two packages on one verb; the first would win and hide the second. Plugins of one package are one owner, and the first declared runs. |
2396
+ | `plugins[<i>].fingerprint of plugin '<name>' must be { files: [name, …], affected: function }` | A fingerprint claim without its file list or its `affected` answer. |
2397
+ | `plugin '<name>' claims fingerprint file "<file>", which is not a file name at the workspace root` | A claim names a path, or no name: a claim is a bare file at the root (`vx watch`'s root arm is not recursive). |
2398
+ | `plugins '<a>' and '<b>' both claim fingerprint file '<file>' — a file has one claimant` | Two plugins keying the same lockfile; the key would fold both and `--affected` could ask only one. |
2399
+ | `plugin '<name>' claims fingerprint file '<file>' twice (plugins[<i>] and plugins[<j>]) — a file has one claimant; declare the plugin once` | One plugin package declared twice (`plugins: [bun(), bun()]`); the message named it as two plugins (D-158). |