@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/README.md CHANGED
@@ -65,8 +65,8 @@ export default defineProject({
65
65
  build: {
66
66
  exec: { command: 'tsc -b' },
67
67
  dependsOn: ['^build'], // dependencies build first
68
+ inputs: { files: ['src/**', 'tsconfig.json'] },
68
69
  cache: {
69
- inputs: { files: ['src/**', 'tsconfig.json'] },
70
70
  outputs: { files: ['dist/**'] },
71
71
  },
72
72
  },
package/docs/caching.md CHANGED
@@ -8,8 +8,10 @@ invalidation_, _what's actually stored_, and _why_.
8
8
  ## Why caching is opt-in
9
9
 
10
10
  A task is cached iff its `TaskConfig` provides a `cache` block, with
11
- **both** `inputs.files` and `outputs.files`. Omit `cache` and the
12
- task always runs; no read, no write.
11
+ `cache.outputs.files` and the task's `inputs.files`. Omit `cache` and
12
+ the task always runs; no read, no write. `inputs` stands alone: a task
13
+ is keyed on it whether it caches or not, and its dependents fold that
14
+ key.
13
15
 
14
16
  The reasoning:
15
17
 
@@ -55,9 +57,9 @@ over (in order):
55
57
  `exec.remote` is dropped, because it says WHERE a task runs and not
56
58
  what it does (§ What's NOT in the key). Captures:
57
59
  - `exec` block (command, env declarations, timeout, persistent).
58
- - `dependsOn` and `cache.inputs.tasks` declarations.
59
- - `cache.outputs.files`, `cache.inputs.files`, `cache.inputs.env`,
60
- `cache.inputs.runtime` / `workspaceRuntime` declarations (the
60
+ - `dependsOn` and `inputs.tasks` declarations.
61
+ - `cache.outputs.files`, `inputs.files`, `inputs.env`,
62
+ `inputs.runtime` / `workspaceRuntime` declarations (the
61
63
  strings themselves; their resolved file content / env values /
62
64
  command output contribute separately).
63
65
  - `description` (because it's part of the resolved object — even
@@ -73,14 +75,14 @@ over (in order):
73
75
  pulled deps don't see them (their cache identity stays clean) — and
74
76
  to a command: a requested default `build` runs none and folds none
75
77
  (X-119).
76
- 5. **`cache.inputs.env` resolved values** — `[name, value]` pairs
78
+ 5. **`inputs.env` resolved values** — `[name, value]` pairs
77
79
  read from host `process.env` at hash time (delimited `name\0value`
78
80
  so boundaries are unambiguous). Listed names get their current
79
81
  values; an unset name folds its bare name, with no `\0`, so unset
80
82
  and set-to-empty are two keys (the child tells them apart:
81
83
  `passThrough` leaves an unset name out). A name holding a NUL is
82
84
  refused at load.
83
- 6. **`cache.inputs.runtime` resolved output** — `[command, output]`
85
+ 6. **`inputs.runtime` resolved output** — `[command, output]`
84
86
  pairs, where `output` is the trimmed stdout of each command run via
85
87
  `sh -c` in the **project dir** at hash time; a command with stderr
86
88
  (or a NUL in stdout) folds `\0<stdout length>\0<stdout><stderr>`,
@@ -88,12 +90,12 @@ over (in order):
88
90
  The runtime-output analog of step 5: the command _strings_ are in
89
91
  the resolved config (step 3), their _output_ is resolved live every
90
92
  run. Folded with the command count + each `command\0output` pair.
91
- 7. **`cache.inputs.workspaceRuntime` resolved output** — same as step
93
+ 7. **`inputs.workspaceRuntime` resolved output** — same as step
92
94
  8 but commands run at the **workspace root**, and the pairs fold
93
95
  into a **distinct namespace** (`ws-runtime-values:`) so an identical
94
96
  `(command, output)` never aliases the project-cwd `runtime` values.
95
97
  8. **Filtered upstream task cache hashes** — every upstream task's
96
- own cache key, filtered by `cache.inputs.tasks` (default: all of
98
+ own cache key, filtered by `inputs.tasks` (default: all of
97
99
  them). Sorted by hash before folding so the ordering of `dependsOn`
98
100
  doesn't change the key. This is the cascade mechanism: if anything
99
101
  beneath you changes, your hash changes too. The set is `dependsOn`'s,
@@ -103,7 +105,7 @@ over (in order):
103
105
  whatever is on disk, which nothing proves current, so a task whose
104
106
  key folds one, and everything built on it, may hit but does not
105
107
  save (`orchestrator/excluded-keys.ts`); a task with
106
- `cache.inputs.tasks: []` folds none and saves as usual.
108
+ `inputs.tasks: []` folds none and saves as usual.
107
109
  9. **Plugin key material** — the `{ name: value }` pairs a plugin's
108
110
  `key(task, ctx)` stage returned for this task, stored on the node as
109
111
  sorted `plugin/name` pairs and folded after the upstream keys and BEFORE the input files,
@@ -147,7 +149,7 @@ over (in order):
147
149
  [`modules/fingerprint-watch.md`](./modules/fingerprint-watch.md)).
148
150
  With nothing claimed, no root file is read or watched.
149
151
 
150
- 10. **Input files' content hashes** — `cache.inputs.files` resolved to
152
+ 10. **Input files' content hashes** — `inputs.files` resolved to
151
153
  a concrete list of project-relative paths (gitignore-aware,
152
154
  declared-outputs-excluded, nested-projects-excluded), each file
153
155
  contributing its **git blob OID** (v20). On a clean tree the OID
@@ -216,19 +218,23 @@ add` under a clean filter (`core.autocrlf=true`, a `text` rule)
216
218
  same shape — git reports the nested repository as one changed path,
217
219
  and every project under it is selected.
218
220
 
219
- A task with **no `cache` block** derives a key too — its dependents
220
- fold it (step 8) — and, declaring nothing, folds **every file in
221
- its project**: `**/*`, gitignore-aware, untracked files included,
222
- and its own outputs not excluded, since it declared none. So its
223
- key moves whenever it writes a file git does not ignore: a cached
224
- dependent misses once more after the upstream's first run and hits
225
- from the third. `vx why` on the dependent names the upstream as
226
- the moved component; on the upstream it says the file set moved
227
- and that no fingerprints say which. Ignore the outputs in git, or
228
- declare the block (`tests/uncached-upstream-key.test.ts` pins both
229
- arms).
230
-
231
- Declaring nothing, such a task may also have **written** anywhere
221
+ A task with **no `cache` block** derives a key too, from its
222
+ `inputs` — its dependents fold it (step 8), as Turbo and Nx hash
223
+ every task. That key is for its dependents only: the task always
224
+ runs, and no cache, local or remote, is asked about or handed
225
+ anything under it; its log goes to the run's own store
226
+ (`run/<id>/<task>/log`). A lookup follows `cache`, never a key
227
+ (`tests/uncached-no-lookup.test.ts`). One that also declares no
228
+ `inputs` is **keyless**: no
229
+ key can stand for what it read, so a key that folds it is this
230
+ run's alone, and every task behind it misses and saves nothing.
231
+ A task reading `**` with no `cache` folds its own outputs too,
232
+ since it declared none: its key moves whenever it writes a file
233
+ git does not ignore, so a cached dependent misses once more after
234
+ the upstream's first run. Ignore the outputs in git, narrow its
235
+ `inputs`, or declare the block (`tests/uncached-upstream-key.test.ts`).
236
+
237
+ Declaring no outputs, such a task may also have **written** anywhere
232
238
  in its project, so what the run learned about that project at its
233
239
  start — the git listing and the index OIDs — is dropped once its command exits, pass or fail, and a later key
234
240
  re-lists and hashes by content. For the same reason a task after it
@@ -260,20 +266,19 @@ add` under a clean filter (`core.autocrlf=true`, a `text` rule)
260
266
  dropping out of the key or the artifact without a word
261
267
  (turborepo#9345). Rename it, or exclude it with a negated glob.
262
268
 
263
- Your globs are a **filter over the set git reports**, so a filter
264
- can only ever remove — a gitignored file can never be filtered back
265
- in, however explicitly you name it. Naming one by hand is therefore
266
- a **hard error** rather than a silent nothing: it would leave the
267
- task ignoring a file its own config claims as an input, reporting
268
- `up-to-date` while that file changed. Turbo lets an explicit entry
269
- override gitignore; vx cannot, because the key would then depend on
270
- a change `git diff` cannot see and `--affected` would stop selecting
271
- the task. If the file is generated, depend on the task that produces
272
- it via `cache.inputs.tasks`. A **glob** matching nothing stays
273
- silent — that is legitimate — and so does a literal naming a file
274
- that does not exist. A literal naming a **directory** is judged the
275
- same way: an ignored one, or an empty one, has no file git lists
276
- under it, folds nothing, and is refused (item 576).
269
+ Your globs are a **filter over the set git reports**, so a broad
270
+ glob never reaches a gitignored file. An entry that names one
271
+ outright does: a literal (`.env.local`, `gen`), or a glob whose fixed
272
+ prefix git ignores (`dist/**`), walked on disk under that prefix
273
+ only (owner, 2026-10-16). Such a file is hashed from disk with the
274
+ blob formula an untracked file gets, and is never stored or
275
+ restored. `--affected` reflects only what git sees, so an edit to
276
+ one never selects a task on its own (as in Nx); `vx watch` re-runs
277
+ it on an edit there.
278
+ A **glob** matching nothing stays silent, and so does a literal
279
+ naming a path that does not exist, until it does. An ignored
280
+ directory named outright folds every file under it; an empty one
281
+ folds nothing.
277
282
 
278
283
  A **bracket is literal** in a task glob, and there are no character
279
284
  classes (item 667): `app/[id]/**` folds the route directory
@@ -315,7 +320,15 @@ add` under a clean filter (`core.autocrlf=true`, a `text` rule)
315
320
  second by two, since FAT32 keeps even seconds; a rewrite later in
316
321
  the same stamp keeps it (`racyWindowMs`; until 2026-09-27, A-2 and
317
322
  A-38, such a rewrite of the same size was a hit on the first bytes'
318
- output). That is the same value the index holds whenever no filter applies,
323
+ output). Every such window compares a file's stamp, on its file
324
+ system's clock, with a moment vx read from its own, so a run first
325
+ measures how far the file system runs behind (one file written and
326
+ removed in the cache dir) and widens every window by a lag past the
327
+ kernel's tick (20 ms): a WSL2 VM drifted from the Windows host that
328
+ stamps `/mnt/c`, a Docker Desktop bind mount or an NFS server dated an
329
+ edit made while a command ran before the command, and the entry
330
+ filed the edit's output under the old bytes' key (until 2026-10-16).
331
+ That is the same value the index holds whenever no filter applies,
319
332
  so a file's contribution doesn't flip across dirty↔clean
320
333
  transitions. Folded as `(relPath, identity)` pairs, sorted for
321
334
  stability across OSes and walk orders. The identity is the OID
@@ -330,19 +343,21 @@ add` under a clean filter (`core.autocrlf=true`, a `text` rule)
330
343
  there a clean file's mode comes from an lstat too, and its OID
331
344
  from the index (item 1076).
332
345
 
333
- A **symlink** folds as git folds it: the blob of its target
334
- _string_ (its mode-120000 index OID), whether it points at a file,
335
- a directory, or nothing. Retargeting a link changes the key; the
336
- bytes behind a link to a directory, or to a file outside the
337
- project, do not — `git diff` and `--affected` cannot see them
338
- either, so declare them with `workspaceFiles`. A link to a file
339
- inside the project tracks that file's content only when the file
340
- matches the task's input globs too: it is then an input of its own.
341
- A link under `src/**` to `other/data.txt` folds the link, not
342
- `other/data.txt`; name the target in the globs to track it.
343
- (Before 2026-09-09 a file link folded the
344
- bytes behind it and a directory or dangling link fell out of the
345
- input set entirely, so retargeting one was a stale hit.)
346
+ A **symlink** folds as git folds it, the blob of its target
347
+ _string_ (its mode-120000 index OID), and then the content behind
348
+ it, as Nx reads a linked file where it points: a file's identity, or
349
+ for a directory every file beneath it at any depth, tracked or not
350
+ (`.git`, `node_modules`, `.vx/cache` and `.yarn/cache` aside, as Nx's walker skips them), a link
351
+ among them followed in turn. The target may be anywhere: in the
352
+ project, elsewhere in the workspace, outside it. Retargeting a link
353
+ changes the key, to equal bytes too; an edit behind it changes the
354
+ key. A dangling link, or a loop of links, keys its target string
355
+ alone. `--affected` selects a link whose target changed (a file
356
+ the diff names, or one under a linked directory) as a changed path of
357
+ its project, and a target git cannot diff (outside the workspace,
358
+ git-ignored) on every run; `vx watch` arms each link's target and
359
+ names the link when it changes (`linked-inputs.ts`). Nx does not
360
+ enter a linked directory; vx does, since the task reads it.
346
361
 
347
362
  The composition is seed-chained (`xxh3(part, prevDigest)`) with a
348
363
  label prefix per field, so two different field layouts can't collide.
@@ -384,12 +399,26 @@ On a hit:
384
399
  clock (4 ms at HZ=250), so a same-size rewrite inside the tick of
385
400
  vx's own write, in place or as a new file that took the same inode,
386
401
  with its mtime forged back, still matches.
387
- 3. Otherwise the task's declared outputs are wiped from the project
388
- dir (`cleanOutputs`) — see
389
- [§ Strict output ownership](#strict-output-ownership) — and the
390
- `outputs/<rel>` (+ `workspace-outputs/<rel>`) entries are extracted
391
- from the artifact into place: its bytes inline in the index, read in
392
- one read transaction with the entry's rows, or `<cacheDir>/<hash>.tar.zst`.
402
+ 3. Otherwise the `outputs/<rel>` (+ `workspace-outputs/<rel>`) entries
403
+ are extracted from the artifact (its bytes inline in the index, read
404
+ in one read transaction with the entry's rows, or
405
+ `<cacheDir>/<hash>.tar.zst`) **in place**: every entry is staged in
406
+ the workspace's `.vx/tmp/` (never an output folder), and only once the
407
+ whole archive has staged is each renamed over the file it replaces. A
408
+ file that already holds the entry's bytes (compared byte for byte, up
409
+ to 4 MiB) is left in place, only its mode and mtime set. A
410
+ target on another filesystem goes through a temp beside it; a file
411
+ takes the group its directory gives new files (setgid on Linux, any
412
+ directory on macOS), ACLs are not copied. The next run removes
413
+ the staging a killed run left. A process reading the output tree during the
414
+ restore never finds a declared output missing or half-written, and a
415
+ restore that fails first (a full disk, a damaged entry) leaves the
416
+ outputs as they were. Then the declared outputs the entry does not
417
+ hold are removed — see
418
+ [§ Strict output ownership](#strict-output-ownership). Only a found
419
+ output that blocks a rename (a file where the entry needs a directory,
420
+ or the reverse) goes before. Turbo also restores in place; Nx deletes
421
+ and copies each file, and a reader meets a missing or short file.
393
422
  4. Captured stdout is replayed to the live terminal via the logger —
394
423
  the framed block looks like a fresh run. (stderr is not cached —
395
424
  only successful runs are cached and their stderr is near-always
@@ -452,10 +481,12 @@ root-anchored output can land in any project's directory — and every
452
481
  transitive dependant of one with it (their up-front keys fold a
453
482
  preliminary key); a glob with no literal prefix reaches every project.
454
483
  Before item 584 one such declaration emptied the tier graph-wide.
455
- `rules.upfrontKeys` (on by default, X-54) refuses at load a task whose
456
- input globs can match another task's declared outputs, unless git
457
- ignores those outputs whole, so the clauses on declared outputs fire
458
- only with the rule off. An output git ignores whole is no reach either:
484
+ `vx lock` refuses a task whose input globs can match another task's
485
+ declared outputs (X-54, no opt-out), unless git ignores those outputs
486
+ whole (and the entry does not start inside them) or the producer is a
487
+ dependency its key does not fold, whose bytes it then hashes after the
488
+ producer ran, so the clauses on declared outputs fire only for such a
489
+ dependency read or a pair the check cannot decide. An output git ignores whole is no reach either:
459
490
  a key reads only what git lists, so a `**/*` reader beside a gitignored
460
491
  `**/dist/**` producer keys up front, the same key before and after the
461
492
  build. The run asks git once, beside its walk and only when a reader
@@ -466,10 +497,11 @@ the glob matches (a tracked output, a directory a `!` rule takes back);
466
497
  anything git cannot answer keeps the key late. Undeclared writes, an
467
498
  `outputs.workspaceFiles` producer upstream, a rewriter the key does not
468
499
  fold, and runtime probes after a writer still leave a key preliminary.
469
- The
470
- short-circuit never runs under a `LayeredCache` (remote prefetch owns
471
- those runs — an up-front `get` there would put remote GETs on the
472
- critical path), never fires with local reads off or on a local cache
500
+ With a
501
+ remote declared the short-circuit probes the local store alone, so a
502
+ local hit still restores ahead of its dependencies and no remote GET
503
+ sits on the critical path (remote prefetch asks the remote for the
504
+ rest). It never fires with local reads off or on a local cache
473
505
  that holds no artifact (`Cache.holdsNothing`: every probe would miss,
474
506
  and a cold 1,090-package run started its first task ~90 ms sooner
475
507
  without it), and never throws —
@@ -550,9 +582,8 @@ Hard invariants of the remote prefetch:
550
582
  stat pass.
551
583
  - **Stable keys only.** A task with a _preliminary_ key (§ Local
552
584
  restore tier's stability gate: undeclared writes, an
553
- `outputs.workspaceFiles` producer upstream, or, with
554
- `rules.upfrontKeys: false`, input globs that match an upstream's
555
- declared outputs) is not prefetched: its key would name the wrong
585
+ `outputs.workspaceFiles` producer upstream, or input globs that match
586
+ the outputs of a dependency the key does not fold) is not prefetched: its key would name the wrong
556
587
  artifact, and it resolves via the lazy read-through in `execute-task`. Instability propagates: a task that folds an
557
588
  unstable upstream is itself unstable. When in doubt, skip.
558
589
  - **At most once.** The `LayeredCache` keeps an in-flight map keyed by
@@ -628,12 +659,14 @@ get/save and the file-hash memo's writes (never `recordRun` / `stats` / `prune`
628
659
  read-through (`remoteRead`), upload (`remoteWrite`), and prefetch
629
660
  (`remoteRead`). The orchestrator derives two booleans per task:
630
661
 
631
- - `willRead = task has a cache block AND it is not remote-only AND (localRead || remoteRead)`
662
+ - `willRead = task has a cache block AND it is not remote-only AND it folds no keyless task AND (localRead || remoteRead)`
632
663
  - `willWrite = task has a cache block AND it is not remote-only AND (localWrite || remoteWrite)`
633
664
 
634
665
  A task reads the cache only when `willRead`, saves only when
635
- `willWrite`, and cleans its declared outputs before exec only when
636
- `willWrite`. Remote-only is an `exec.remote: 'only'` task placed on a
666
+ `willWrite` and it folds no keyless task, and cleans its declared outputs
667
+ before exec only when `willWrite`. A keyless task runs a command and
668
+ declares no `inputs`: its key is unknown, so a task that folds it
669
+ (through `dependsOn`, directly or through a group) always misses. Remote-only is an `exec.remote: 'only'` task placed on a
637
670
  remote executor: it never touches this machine's disk (no probe, no
638
671
  restore, no output clean, no local save), and its result lives in the
639
672
  remote executor's own store.
@@ -784,7 +817,7 @@ from the snapshot's index OID for an output (or for an input the task
784
817
  rewrote) restored the bytes from before the command.
785
818
 
786
819
  A declared set that resolves to **nothing** is said on the run's
787
- status line. `cache.inputs matched no files (lib/**)` — the key would
820
+ status line. `inputs matched no files (lib/**)` — the key would
788
821
  not change when the source does — is said on every miss of a cacheable
789
822
  task, right after `describeTaskInputs` and before the command, whether
790
823
  or not it saves. `cache.outputs matched no files (build/**)` — an empty
@@ -804,7 +837,7 @@ the run (`tests/runner.test.ts`) — and the save resolves the output
804
837
  globs at once. A descendant the command detached (`setsid … &`, a
805
838
  daemon) that writes after that is not in the artifact: the entry saves
806
839
  what was there, often nothing (with the `cache.outputs matched no
807
- files` line), and the next hit's clean-before-restore removes what the
840
+ files` line), and the next hit's restore removes what the
808
841
  descendant wrote and restores the saved entry in its place (upstream
809
842
  survey, turborepo#12786). vx does not wait for it and cannot see it: a
810
843
  process that left the task's session is outside the process group vx
@@ -831,14 +864,15 @@ Declared `cache.outputs.files` are wiped in two distinct places:
831
864
  - **Before exec on a cache miss.** A leftover `dist/old.js` from a
832
865
  prior build can't survive into a fresh build that doesn't rewrite
833
866
  it.
834
- - **Before restore on a cache hit.** The post-restore tree is the
835
- cached snapshot byte-for-byte. Hand-edits to output files don't
836
- persist through a cache replay. When the hit's own check walked the
837
- output globs to find the tree stale, the clean deletes from that walk's
838
- list instead of walking again: nothing is awaited between them (X-161).
839
-
840
- Both branches use the same `cleanOutputs` helper (`src/cache/inputs.ts`)
841
- with the same boundary rules, and vx's own paths stay off the wipe
867
+ - **After restore on a cache hit.** The post-restore tree is the
868
+ cached snapshot byte-for-byte: the restore renames the entry's files
869
+ over the ones it finds, then removes what the entry does not hold.
870
+ Hand-edits to output files don't persist through a cache replay. When
871
+ the hit's own check walked the output globs to find the tree stale,
872
+ the removal uses that walk's list instead of walking again (X-161).
873
+
874
+ Both use the same helpers (`cleanOutputs`, `cleanOutputPaths` in
875
+ `src/cache/inputs.ts`) with the same boundary rules, and vx's own paths stay off the wipe
842
876
  whatever the glob: `.git`, `.vx` and each `vx.lock.json` (`OUTPUT_NEVER`;
843
877
  `.vx` holds the workspace config and the lock, which a clean deleted and a
844
878
  hit restored stale while `.vx/cache` alone was listed). `node_modules` is
@@ -871,7 +905,7 @@ Skipped when:
871
905
  `.tsbuildinfo`). You opt out of the clean-tree guarantee: the save
872
906
  stores every file the outputs match after the run, so a leftover from
873
907
  an older build is cached and restored everywhere. A retry cleans as
874
- usual; a hit still cleans before its restore.
908
+ usual; a hit's restore still removes what its entry does not hold.
875
909
 
876
910
  A declared output the process cannot remove (a `dist/` another user
877
911
  wrote, a read-only checkout) fails the task with `cannot remove declared
@@ -948,13 +982,13 @@ A task's cache becomes invalid when any of these change:
948
982
  | Any package manager updates a lockfile (`pnpm`, `npm`, `yarn`, `bun`) | step 9 — the `key` material of the plugin that claims it (`@vzn/vx-lockfile`: only the projects whose dependency closure moved); **NOT a trigger** with no such plugin, since core keys no package file |
949
983
  | Edit an install setting (`.npmrc`, `bunfig.toml`, `pnpm-workspace.yaml`, `.yarnrc.yml`) | step 9 — the claiming lockfile plugin folds it whole into every task; **NOT a trigger** without one |
950
984
  | Edit `package.json`'s `workspaces` field | membership: a project that joins or leaves changes which projects exist and which nested dirs its parent's globs exclude (step 10) |
951
- | Edit the project's `package.json` (dep / version / scripts change) | step 10 — an input file of every cached task when `@vzn/vx-lockfile` is declared (it adds `package.json` to `cache.inputs.files`), or when a task lists it; **NOT a trigger** otherwise |
985
+ | Edit the project's `package.json` (dep / version / scripts change) | step 10 — an input file of every cached task when `@vzn/vx-lockfile` is declared (it adds `package.json` to `inputs.files`), or when a task lists it; **NOT a trigger** otherwise |
952
986
  | Edit the task's `vx.config.ts` | step 3 (task config hash) |
953
987
  | Edit a config file that the task config imports | step 3 (configHash sees the resolved object after Bun evaluates imports) |
954
988
  | Change CLI `forwardArgs` (after `--`) | step 4 |
955
- | Change a `cache.inputs.env` host value | step 5 |
956
- | Change the combined stdout+stderr of a `cache.inputs.runtime` command (resolved at hash time) | step 6 |
957
- | Change the combined stdout+stderr of a `cache.inputs.workspaceRuntime` command (resolved at hash time) | step 7 |
989
+ | Change a `inputs.env` host value | step 5 |
990
+ | Change the combined stdout+stderr of a `inputs.runtime` command (resolved at hash time) | step 6 |
991
+ | Change the combined stdout+stderr of a `inputs.workspaceRuntime` command (resolved at hash time) | step 7 |
958
992
  | Upstream task's cache key changes (because its inputs changed) | step 8 |
959
993
  | Bump `CACHE_VERSION` | step 1 — orphans every entry |
960
994
  | Change `exec.env.passThrough` _values_ alone | **NOT a trigger** by design — passThrough values are host-specific |
@@ -968,7 +1002,7 @@ re-runs automatically.
968
1002
 
969
1003
  ## Cross-project boundaries
970
1004
 
971
- A project's `cache.inputs.files` globs **never** reach into another
1005
+ A project's `inputs.files` globs **never** reach into another
972
1006
  project's directory, even if a `**/*` pattern would otherwise match.
973
1007
 
974
1008
  `workspace/nested-dirs.ts` computes the set of nested project
@@ -985,21 +1019,22 @@ globs is `dependsOn` + upstream-hash propagation (step 8).
985
1019
  For a task with `exec.sandbox` and `cache`, the sandbox enforces that
986
1020
  "only". A workspace package reached through a `node_modules` link is
987
1021
  granted only when the task's key folds a task of that package, walking
988
- the same selection `cache.inputs.tasks` applies at hash time
989
- (`orchestrator/keyed-projects.ts`, over `upstream.ts`'s
1022
+ the same selection `inputs.tasks` applies at hash time
1023
+ (`orchestrator/keyed-projects.ts`, over `graph/folds.ts`'s
990
1024
  `selectFoldedDeps`); every other link target inside the root is
991
1025
  withheld, so an import of a sibling the key never sees fails and is
992
1026
  reported instead of caching a result an edit there would not re-run.
993
- Any exec task counts, cached or not: a task with no `cache` declares no
994
- `inputs.files`, so its key folds every file of its project. A persistent
995
- task counts the same way: its key folds its whole project, so a cached
996
- e2e behind a dev server re-runs when the server's sources change.
1027
+ Any exec task counts, cached or not: its key folds its project's files
1028
+ through its `inputs`, and one with no `inputs` is keyless, so a key that
1029
+ folds it is never stored or read. A persistent task counts the same way,
1030
+ so a cached e2e behind a dev server keyed on its sources re-runs when
1031
+ they change.
997
1032
  The coverage is per package, not per file: an edge to `ui#source`
998
1033
  (inputs `src/**`) also admits a read of `ui/README.md`, whose edit moves
999
1034
  no key — the same limit a grant wider than a task's own inputs already
1000
1035
  has. A task with no `cache` keeps every link, having no key to be stale.
1001
1036
 
1002
- **Exception:** `cache.inputs.workspaceFiles` /
1037
+ **Exception:** `inputs.workspaceFiles` /
1003
1038
  `cache.outputs.workspaceFiles` are workspace-root-anchored and apply
1004
1039
  NO boundary rule — a deliberate escape hatch (owner call: "they don't
1005
1040
  care about boundaries; it is bad practice but is there"). Prefer
@@ -1061,8 +1096,8 @@ the gate exists to avoid paying needlessly.
1061
1096
 
1062
1097
  ## Runtime inputs and the lock (the env parallel)
1063
1098
 
1064
- `cache.inputs.runtime` / `cache.inputs.workspaceRuntime` are modeled
1065
- exactly on `cache.inputs.env`, and they share its asymmetry with the
1099
+ `inputs.runtime` / `inputs.workspaceRuntime` are modeled
1100
+ exactly on `inputs.env`, and they share its asymmetry with the
1066
1101
  lockfile:
1067
1102
 
1068
1103
  - **The command _strings_ live in the resolved config**, so `vx lock`
@@ -1214,7 +1249,7 @@ by this user, or point cacheDir / --cache-dir at one that is.` (A-40).
1214
1249
  An input the key must hash that this user cannot read fails its task
1215
1250
  naming the read: `<path> is not readable by this user (EACCES), and vx
1216
1251
  reads it to derive a cache key. Make it readable, or, for a task input,
1217
- take it out of cache.inputs.files.` (A-50).
1252
+ take it out of inputs.files.` (A-50).
1218
1253
 
1219
1254
  By default the entries and their artifacts live in a **shared store**
1220
1255
  in `~/.vx/<id>/cache`, and each workspace keeps only its own index in
@@ -1314,8 +1349,9 @@ pax `path`/`size`, GNU long names, header checksums, truncation; an
1314
1349
  extended header (read whole) past 1 MiB or a pax `size` that is not a
1315
1350
  whole number is refused as corrupt, since a 1 GiB pax header costs 2 GiB
1316
1351
  of memory from a ~32 KB artifact — and
1317
- every regular entry is written beside its target as a short `.vx-tmp-*` sibling (never a suffix on the target's name, which pushed a legal 242–255-byte name past NAME_MAX) and
1318
- renamed into place only after the whole archive has ended cleanly and
1352
+ every regular entry is written to the workspace's `.vx/tmp/<pid>-0/`
1353
+ (vx's own, ignored by git, never an output folder) and renamed over its
1354
+ target only after the whole archive has ended cleanly and
1319
1355
  the index's recorded outputs are all present. vx itself holds one
1320
1356
  chunk of the tar at a time (measured 2026-09-03, incompressible
1321
1357
  artifacts, fresh process: 150 MiB peaked at +644 MiB through
@@ -1394,7 +1430,14 @@ them, `CacheGetContext.outputs`), and never when it holds one path as a
1394
1430
  file and a directory at once. Either used to reach the tree, an input
1395
1431
  overwritten or a `.git/hooks` file planted under a green
1396
1432
  `cache-hit-remote`, or a restore that failed every later run from the
1397
- local copy (item 942); now the read is a miss and the task runs. On the save side a
1433
+ local copy (item 942); now the read is a miss and the task runs. Every
1434
+ restore asks the same of the local copy, whatever route it reached the
1435
+ store by: an entry outside the declared outputs, one a `!` entry takes
1436
+ back, one under `.git`, `.vx` or a `vx.lock.json`, or one under a
1437
+ `node_modules` no output glob names fails the whole restore before
1438
+ anything is written; the entry is dropped and the task runs. Remote
1439
+ execution (`@vzn/vx-reapi`) writes a remote action's outputs through the
1440
+ same gate (`restorableOutput`). On the save side a
1398
1441
  **symlinked output** is captured as its target's bytes and comes back
1399
1442
  as a regular file (a hit that finds the task's own link still current
1400
1443
  leaves it); a link to a directory, or a dangling one, has no bytes to
@@ -1850,10 +1893,19 @@ moat.
1850
1893
  passthrough still bumps the key for affected tasks.
1851
1894
  - **Files outside the project directory that aren't declared.**
1852
1895
  Workspace-root configs (`tsconfig.base.json`, etc.) are not
1853
- auto-included — declare them via `cache.inputs.workspaceFiles`
1896
+ auto-included — declare them via `inputs.workspaceFiles`
1854
1897
  (root-anchored globs).
1898
+ - **Gitignored files no entry names.** Vite and Next load `.env.local`
1899
+ on their own; a broad glob skips it, so editing it replays the old
1900
+ build. Name it: `files: ['src/**', '.env.local']`. Its bytes are
1901
+ hashed, never stored, and a missing file is simply not an input.
1902
+ Machines with different files compute different keys, so they miss
1903
+ each other's shared entries. `--affected` ignores it (only what git
1904
+ sees selects a task), and `vx watch` re-runs on its edit. A probe
1905
+ (`runtime: ['cat .env.local 2>/dev/null | cksum']`) still works where
1906
+ a command, not a path, decides what to read.
1855
1907
  - **Node / Bun / OS / build-tool versions — unless you declare them.**
1856
- The canonical mechanism is `cache.inputs.runtime` /
1908
+ The canonical mechanism is `inputs.runtime` /
1857
1909
  `workspaceRuntime` (e.g. `workspaceRuntime: ['node -v']`): the
1858
1910
  command output is resolved live at hash time, so it stays correct
1859
1911
  under the lock. Avoid baking versions via
@@ -2187,11 +2239,11 @@ breaking footer).
2187
2239
  committed, so git enumerates it, but it's vx's own frozen-config
2188
2240
  metadata — never a task input. Without this, a `vx lock` re-write
2189
2241
  busts every key on a project that globs the root lockfile (a broad
2190
- `**/*` on the root project). Tasks whose `cache.inputs.files` never
2242
+ `**/*` on the root project). Tasks whose `inputs.files` never
2191
2243
  matched it derive byte-identical keys. No SCHEMA bump — only the
2192
2244
  hashed file set changed, not the key layout or on-disk format.
2193
2245
 
2194
- - **v22 → v23**: fold `cache.inputs.runtime` / `workspaceRuntime`
2246
+ - **v22 → v23**: fold `inputs.runtime` / `workspaceRuntime`
2195
2247
  command output into the key (two namespaced sections after
2196
2248
  env-values). The command _strings_ stay in the config hash (step 3);
2197
2249
  their combined trimmed stdout+stderr is resolved live at hash time
@@ -2236,7 +2288,7 @@ breaking footer).
2236
2288
  - **v11 → v12** (PR #42): project's `package.json` bytes folded into
2237
2289
  every task's cache key implicitly. Matches Turbo / Nx "implicit
2238
2290
  dependencies" behavior — a `package.json` dep change invalidates
2239
- the project's tasks even when `cache.inputs.files` is narrow and
2291
+ the project's tasks even when `inputs.files` is narrow and
2240
2292
  doesn't cover the file.
2241
2293
  - **v12 → v13** (PR #65): per-entry on-disk layout unified. Outputs
2242
2294
  moved from `<hash>/<rel>` (mixed with metadata) to