@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/caching.md CHANGED
@@ -31,11 +31,18 @@ opt _out_ via `cache: false`. We chose the strictest of the three.
31
31
 
32
32
  ## Cache key derivation
33
33
 
34
+ Core keys no package file: no `package.json`, lockfile or install
35
+ setting is a part of its own. A `package.json` keys a task as an input
36
+ it lists (step 10), a lockfile or install setting as a plugin's key
37
+ material (step 9); `@vzn/vx-lockfile`'s `pnpm()`, `bun()`, `npm()` and
38
+ `yarn()` add both. Without a lockfile plugin an install moves no key, so
39
+ a JS workspace declares one (`vx init` and `@vzn/vx-migrate` write it).
40
+
34
41
  The cache key for one task is a **16-hex xxHash3 digest**, seed-chained
35
42
  over (in order):
36
43
 
37
44
  1. **`CACHE_VERSION`** — the key-derivation sentinel
38
- (currently `'vx-cache-v42'`, in `src/cache/key-fold.ts`). Bumped when
45
+ (currently `'vx-cache-v43'`, in `src/cache/key-fold.ts`). Bumped when
39
46
  the key derivation or the artifact container changes, or stored bytes
40
47
  are wrong under an unchanged key. See
41
48
  [§ Bumping CACHE_VERSION](#bumping-cache_version).
@@ -43,54 +50,7 @@ over (in order):
43
50
  identical everything else still produce distinct keys — protects
44
51
  against e.g. `pkg-a#build` accidentally cache-hitting on a
45
52
  `pkg-b#build` entry.
46
- 3. **Workspace fingerprint** — xxh3 of every supported workspace
47
- marker found at the root (see
48
- [`modules/fingerprint.md`](./modules/fingerprint.md)):
49
- `pnpm-lock.yaml`, `package-lock.json`, `npm-shrinkwrap.json`,
50
- `yarn.lock`, `bun.lock`, `bun.lockb`, `pnpm-workspace.yaml`,
51
- `.yarnrc.yml` (Yarn 4 catalogs, which `yarn.lock` does not record),
52
- `.npmrc` and `bunfig.toml` (install settings no lockfile records),
53
- and the content of each patch the Bun lockfile names under
54
- patchedDependencies (it records a patch by path alone). Any
55
- install-resolved change (a `bun install` that bumps `bun.lock`) or
56
- any workspace-shape change invalidates _every_ cache entry. This is
57
- the single global "the world changed" lever — and a plugin can take
58
- one file off it: `VxPlugin.fingerprint` claims a lockfile, core
59
- leaves it out of this digest, and the plugin's `key` hook folds what
60
- the file means for each project instead (`@vzn/vx-lockfile`'s `pnpm()`
61
- folds the project's own resolved dependency closure and the root
62
- package's, so `pnpm update foo` re-keys only the projects that depend
63
- on `foo` — every project when the root does, since the root's tools
64
- run from the root `node_modules/.bin` on every task's PATH; its `bun()` is the same
65
- for `bun.lock`, and this repo declares that one). The
66
- config-evaluation cache still keys on every file: a config may import
67
- a dependency the lockfile resolved.
68
-
69
- The fingerprint is read once per run, before any task. A task in the
70
- root project may rewrite one of these files — `pnpm install` without
71
- `--frozen-lockfile` updates the lockfile and the installed tree — and
72
- every key taken before it names the old one. So a task that may
73
- (unsandboxed in the root project, or granted a write over one) tells
74
- the run when its command ran, the run re-checks the files then (one
75
- `stat` each, following a symlink as both reads do — an `lstat` missed
76
- a lockfile rewritten through a link, item 760 — the bytes compared
77
- for any written since the read), and
78
- once one moved nothing keyed on the old digest is probed or saved for
79
- the rest of the run, with one status line naming the file. Every
80
- reader after such a task is keyed late, not up front. Without it the
81
- run after a lockfile rewrite restored a build made against the old
82
- install, and a save filed a build against the new install under the
83
- old lockfile's key, replayed whenever the tree went back (item 750,
84
- [`modules/fingerprint-watch.md`](./modules/fingerprint-watch.md)).
85
-
86
- 4. **Project `package.json` hash** — the git blob OID of the project's
87
- `package.json`: the index OID when the file is tracked and clean,
88
- else `cache.hashFile`'s blob OID of the worktree bytes (empty when
89
- there is none). Folded in implicitly (Turbo / Nx parity).
90
- Covers the case where `cache.inputs.files: ['src/**']` is narrow
91
- and a `package.json` dep change would otherwise leak undetected.
92
- (Added at v12; rationale in [§ History](#history).)
93
- 5. **Task config hash** — `xxh3(JSON.stringify(hashableConfig(node.config)))`
53
+ 3. **Task config hash** — `xxh3(JSON.stringify(hashableConfig(node.config)))`
94
54
  of the _evaluated_ task config. The projection is one field wide:
95
55
  `exec.remote` is dropped, because it says WHERE a task runs and not
96
56
  what it does (§ What's NOT in the key). Captures:
@@ -107,55 +67,87 @@ over (in order):
107
67
  `process.env`-read at config-load time injected. Bun's native
108
68
  `await import()` evaluates the module and bakes those values into
109
69
  the object before we serialize.
110
- 6. **`forwardArgs`** — CLI args passed after `--`. Folded into the
70
+ 4. **`forwardArgs`** — CLI args passed after `--`. Folded into the
111
71
  key so `vx run test -- --watch` doesn't cache-hit a previous
112
72
  `vx run test`. Scoped to the user-requested tasks only — dependsOn-
113
73
  pulled deps don't see them (their cache identity stays clean) — and
114
74
  to a command: a requested default `build` runs none and folds none
115
75
  (X-119).
116
- 7. **`cache.inputs.env` resolved values** — `[name, value]` pairs
76
+ 5. **`cache.inputs.env` resolved values** — `[name, value]` pairs
117
77
  read from host `process.env` at hash time (delimited `name\0value`
118
78
  so boundaries are unambiguous). Listed names get their current
119
79
  values; an unset name folds its bare name, with no `\0`, so unset
120
80
  and set-to-empty are two keys (the child tells them apart:
121
81
  `passThrough` leaves an unset name out). A name holding a NUL is
122
82
  refused at load.
123
- 8. **`cache.inputs.runtime` resolved output** — `[command, output]`
83
+ 6. **`cache.inputs.runtime` resolved output** — `[command, output]`
124
84
  pairs, where `output` is the trimmed stdout of each command run via
125
85
  `sh -c` in the **project dir** at hash time; a command with stderr
126
86
  (or a NUL in stdout) folds `\0<stdout length>\0<stdout><stderr>`,
127
87
  so bytes moved between the streams move the key.
128
- The runtime-output analog of step 7: the command _strings_ are in
129
- the resolved config (step 5), their _output_ is resolved live every
88
+ The runtime-output analog of step 5: the command _strings_ are in
89
+ the resolved config (step 3), their _output_ is resolved live every
130
90
  run. Folded with the command count + each `command\0output` pair.
131
- 9. **`cache.inputs.workspaceRuntime` resolved output** — same as step
91
+ 7. **`cache.inputs.workspaceRuntime` resolved output** — same as step
132
92
  8 but commands run at the **workspace root**, and the pairs fold
133
93
  into a **distinct namespace** (`ws-runtime-values:`) so an identical
134
94
  `(command, output)` never aliases the project-cwd `runtime` values.
135
- 10. **Filtered upstream task cache hashes** — every upstream task's
136
- own cache key, filtered by `cache.inputs.tasks` (default: all of
137
- them). Sorted by hash before folding so the ordering of `dependsOn`
138
- doesn't change the key. This is the cascade mechanism: if anything
139
- beneath you changes, your hash changes too. The set is `dependsOn`'s,
140
- never the run's selection: a dependency `--exclude-dependencies`
141
- keeps from running is still folded, with the key a full run would
142
- derive for it (nx#35234), so the flag moves no key. Its outputs are
143
- whatever is on disk, which nothing proves current, so a task whose
144
- key folds one, and everything built on it, may hit but does not
145
- save (`orchestrator/excluded-keys.ts`); a task with
146
- `cache.inputs.tasks: []` folds none and saves as usual.
147
- 11. **Plugin key material** — the `{ name: value }` pairs a plugin's
148
- `key(task, ctx)` stage returned for this task, stored on the node as
149
- sorted `plugin/name` pairs and folded after the upstream keys and BEFORE the input files,
150
- ONLY when non-empty — so a workspace with no `key` plugin derives
151
- byte-identical keys to one before the stage existed (no
152
- `CACHE_VERSION` bump when it shipped). `vx why` names a changed pair
153
- as `plugin <plugin>/<name>`. Two plugins of one package (a plugin's
154
- name is its package's) that return one name are told apart as
155
- `<name>`, `<name>#2`, … in value order, so declaration order still
156
- keys nothing (item 1028).
157
-
158
- 12. **Input files' content hashes** — `cache.inputs.files` resolved to
95
+ 8. **Filtered upstream task cache hashes** — every upstream task's
96
+ own cache key, filtered by `cache.inputs.tasks` (default: all of
97
+ them). Sorted by hash before folding so the ordering of `dependsOn`
98
+ doesn't change the key. This is the cascade mechanism: if anything
99
+ beneath you changes, your hash changes too. The set is `dependsOn`'s,
100
+ never the run's selection: a dependency `--exclude-dependencies`
101
+ keeps from running is still folded, with the key a full run would
102
+ derive for it (nx#35234), so the flag moves no key. Its outputs are
103
+ whatever is on disk, which nothing proves current, so a task whose
104
+ key folds one, and everything built on it, may hit but does not
105
+ save (`orchestrator/excluded-keys.ts`); a task with
106
+ `cache.inputs.tasks: []` folds none and saves as usual.
107
+ 9. **Plugin key material** — the `{ name: value }` pairs a plugin's
108
+ `key(task, ctx)` stage returned for this task, stored on the node as
109
+ sorted `plugin/name` pairs and folded after the upstream keys and BEFORE the input files,
110
+ ONLY when non-empty — so a workspace with no `key` plugin derives
111
+ byte-identical keys to one before the stage existed (no
112
+ `CACHE_VERSION` bump when it shipped). `vx why` names a changed pair
113
+ as `plugin <plugin>/<name>`. Two plugins of one package (a plugin's
114
+ name is its package's) that return one name are told apart as
115
+ `<name>`, `<name>#2`, … in value order, so declaration order still
116
+ keys nothing (item 1028).
117
+
118
+ A plugin that reads a root file claims it (`VxPlugin.fingerprint`).
119
+ `@vzn/vx-lockfile`'s `pnpm()` folds the project's own resolved
120
+ dependency closure from `pnpm-lock.yaml` and the root package's, so
121
+ `pnpm update foo` re-keys only the projects that depend on `foo` —
122
+ every project when the root does, since the root's tools run from the
123
+ root `node_modules/.bin` on every task's PATH; `bun()`, `npm()` and
124
+ `yarn()` are the same for their lockfiles (`npm()` reads
125
+ `npm-shrinkwrap.json` over `package-lock.json`, as npm does; `bun()`
126
+ refuses a `bun.lockb` with no `bun.lock`, which it cannot read), and
127
+ this repo declares `bun()`. Each also claims its manager's install
128
+ settings (`pnpm()`: `pnpm-workspace.yaml`, `.npmrc`; `bun()`:
129
+ `bunfig.toml`, `.npmrc`; `npm()`: `.npmrc`; `yarn()`: `.yarnrc.yml`)
130
+ and folds each whole into every task.
131
+
132
+ The run reads each claimed root file once, before any task. A task
133
+ in the root project may rewrite one — `pnpm install` without
134
+ `--frozen-lockfile` updates the lockfile and the installed tree — and
135
+ every key taken before it names the old one. So a task that may
136
+ (unsandboxed in the root project, or granted a write over one) tells
137
+ the run when its command ran, the run re-checks the claimed files
138
+ then (one `stat` each, following a symlink as both reads do — an
139
+ `lstat` missed a lockfile rewritten through a link, item 760 — the
140
+ bytes compared for any written since the read), and once one moved
141
+ nothing keyed on the old bytes is probed or saved for the rest of the
142
+ run, with one status line naming the file. Every reader after such a
143
+ task is keyed late, not up front. Without it the run after a lockfile
144
+ rewrite restored a build made against the old install, and a save
145
+ filed a build against the new install under the old lockfile's key,
146
+ replayed whenever the tree went back (item 750,
147
+ [`modules/fingerprint-watch.md`](./modules/fingerprint-watch.md)).
148
+ With nothing claimed, no root file is read or watched.
149
+
150
+ 10. **Input files' content hashes** — `cache.inputs.files` resolved to
159
151
  a concrete list of project-relative paths (gitignore-aware,
160
152
  declared-outputs-excluded, nested-projects-excluded), each file
161
153
  contributing its **git blob OID** (v20). On a clean tree the OID
@@ -200,7 +192,11 @@ add` under a clean filter (`core.autocrlf=true`, a `text` rule)
200
192
 
201
193
  Ref storage is not a key input: every ref vx reads comes from a git
202
194
  command, so a repository in reftable storage (git 2.45) keys every
203
- task as its files-backend twin (`tests/git-reftable.unsafe.test.ts`).
195
+ task as its files-backend twin. A sha256 repository keys from its
196
+ 64-hex index OIDs, and a dirty file is hashed in-process as sha256,
197
+ so Git 3's defaults (reftable and sha256 together) run, hit and
198
+ select `--affected` as a sha1 repository does
199
+ (`tests/git-reftable.unsafe.test.ts`).
204
200
 
205
201
  A **submodule or an embedded repository** is enumerated by its own
206
202
  git: the workspace repository lists the nested one as a single entry
@@ -221,7 +217,7 @@ add` under a clean filter (`core.autocrlf=true`, a `text` rule)
221
217
  and every project under it is selected.
222
218
 
223
219
  A task with **no `cache` block** derives a key too — its dependents
224
- fold it (step 10) — and, declaring nothing, folds **every file in
220
+ fold it (step 8) — and, declaring nothing, folds **every file in
225
221
  its project**: `**/*`, gitignore-aware, untracked files included,
226
222
  and its own outputs not excluded, since it declared none. So its
227
223
  key moves whenever it writes a file git does not ignore: a cached
@@ -234,8 +230,7 @@ add` under a clean filter (`core.autocrlf=true`, a `text` rule)
234
230
 
235
231
  Declaring nothing, such a task may also have **written** anywhere
236
232
  in its project, so what the run learned about that project at its
237
- start — the git listing, the index OIDs, the `package.json` digest
238
- — is dropped once its command exits, pass or fail, and a later key
233
+ start — the git listing and the index OIDs — is dropped once its command exits, pass or fail, and a later key
239
234
  re-lists and hashes by content. For the same reason a task after it
240
235
  in the same project (`dependsOn`, even with `tasks: []`) is never
241
236
  keyed up front by the local short-circuit or the remote prefetch.
@@ -558,7 +553,7 @@ Hard invariants of the remote prefetch:
558
553
  later `get` finds it as a now-local hit — still reports
559
554
  `source: 'remote'`, so the outcome is `cache-hit-remote`.
560
555
  - **A remote-read-off policy** (`--no-cache`, `--force`, or
561
- `--cache=remote:`) fires no prefetch.
556
+ `--cache-policy=remote:`) fires no prefetch.
562
557
  - **Never fail.** Every remote path (get / put / ingest / prefetch)
563
558
  catches all errors and degrades to a cache miss. A remote 500, a
564
559
  network drop, a corrupt artifact, or a failed integrity check can
@@ -602,8 +597,7 @@ Caching is controlled by a four-axis `CachePolicy` — **localRead**,
602
597
  **localWrite**, **remoteRead**, **remoteWrite** — independent toggles,
603
598
  each enforced inside the matching cache layer at construction time. The
604
599
  local `Cache` gets a `{ read, write }` slice gating its task artifact
605
- get/save, the config-evaluation cache's reads and writes, and the
606
- file-hash memo's writes (never `recordRun` / `stats` / `prune` / ingest
600
+ get/save and the file-hash memo's writes (never `recordRun` / `stats` / `prune` / ingest
607
601
  / key derivation); the `LayeredCache` additionally gates its own remote
608
602
  read-through (`remoteRead`), upload (`remoteWrite`), and prefetch
609
603
  (`remoteRead`). The orchestrator derives two booleans per task:
@@ -619,14 +613,23 @@ restore, no output clean, no local save), and its result lives in the
619
613
  remote executor's own store.
620
614
 
621
615
  The CLI maps three flags to a policy (precedence: start all-on → apply
622
- `--cache` → `--no-cache` forces all off → `--force` forces both reads
616
+ `--cache-policy` → `--no-cache` forces all off → `--force` forces both reads
623
617
  off): `--no-cache` = everything off (no read, no write, no output
624
618
  clean); `--force` = reads off / writes on (re-execute and refresh the
625
- cache, outputs cleaned); `--cache=<spec>` = explicit per-layer control.
626
- See `docs/cli.md` § Cache control.
619
+ cache, outputs cleaned); `--cache-policy=<spec>` = explicit per-layer control.
620
+ Off CI, unless a spec names the remote, `remoteWrite` starts off: a key
621
+ holds only declared inputs, so a laptop's undeclared tool or file could
622
+ replay on CI. See `docs/cli.md` § Cache control.
623
+
624
+ The setup we recommend for a shared remote: the write token only in the
625
+ default branch's CI (a protected environment), a read-only token on
626
+ laptops and in pull request jobs, and `cache.scope` (`github()` sets it)
627
+ so a PR's own writes, when its token allows them, land apart. The token
628
+ is the boundary; the policy and the scope are the client's half
629
+ (`docs/security.md` § Cache poisoning).
627
630
 
628
631
  One subtlety: when `localWrite` is off but `remoteWrite` is on
629
- (`--cache=local:,remote:rw`), there's no on-disk artifact for the
632
+ (`--cache-policy=local:,remote:rw`), there's no on-disk artifact for the
630
633
  `LayeredCache` to read before uploading — so it packs the tar.zst bytes
631
634
  in memory (`Cache.packArtifactBytes`) and uploads those.
632
635
 
@@ -680,8 +683,8 @@ before the command ran — at the task's start, or up front by the local
680
683
  short-circuit — and the save files the outputs under it, so the inputs
681
684
  must still be what it describes. Two checks, in order. The key
682
685
  re-derived just before the command (`describeTaskInputs`, which the
683
- executor seam needs anyway) must equal it. Then each input file and the
684
- project's `package.json` is `lstat`ed once: a file whose ctime falls
686
+ executor seam needs anyway) must equal it. Then each input file is
687
+ `lstat`ed once: a file whose ctime falls
685
688
  after, or within `FILE_HASH_RACY_MS` before, the moment its digest was
686
689
  learned — the enumeration's start for an index OID, the describe for a
687
690
  hashed file — is hashed again and compared, and a file that is gone has
@@ -694,9 +697,9 @@ edit, was filed under the key and restored over the original (item
694
697
  one status line names the file (``[vx] app#format: `packages/app/a.ts`
695
698
  changed after its key was taken — …``), and the run forgets what it
696
699
  knew about the project, as after an uncached task (§ Cache key
697
- derivation, step 12). This covers a formatter rewriting its own input
700
+ derivation, step 10). This covers a formatter rewriting its own input
698
701
  (turborepo#10111) and a user's edit mid-run (turborepo#1146). A task
699
- left remote (`--download=none`) is held to the same checks: a moved
702
+ left remote (`--cache-download=none`) is held to the same checks: a moved
700
703
  key still lets a local consumer fetch its outputs, but the fetch saves
701
704
  no entry; until X-123 it saved them under the old key. A task
702
705
  that rewrites its own input to the SAME bytes (`sed -i` always writes)
@@ -707,10 +710,15 @@ names a file other than `package.json` (TanStack Router's committed
707
710
  `routeTree.gen.ts`, rewritten by every build). That holds on CI too: a
708
711
  task of the same project running alongside can change an input and put
709
712
  it back. A task that rewrites its own input to identical bytes (tsdown's
710
- `package.json`) sets `cache.rewrittenInputs: 'compare'`, and a write
713
+ `package.json`) sets `cache.inputStability: { strategy: 'mutable' }`, and a write
711
714
  during its command is then judged by its bytes, as an earlier write is;
712
- different bytes still withhold the entry. `VX_REWRITTEN_INPUTS=compare`
713
- makes that the default (owner, 2026-10-12). A file ADDED under an
715
+ different bytes still withhold the entry, and so does a write made while
716
+ another command that may write the file ran (`command-windows.ts`: one
717
+ in the same project, or one with a write grant elsewhere in the
718
+ workspace), named on the line as `while <task> ran`. `VX_CACHE_INPUT_STABILITY_STRATEGY=mutable`
719
+ makes that the default (owner, 2026-10-12); `'unchecked'` skips the
720
+ check, and `severity: 'error'` fails the task instead of warning
721
+ (`docs/schema.md`). A file ADDED under an
714
722
  input glob since the listing the key filtered (`addedInput`) withholds
715
723
  the save the same way, named on the same line: the listing's
716
724
  directories a glob reaches are `lstat`ed, one whose ctime moved since
@@ -731,14 +739,14 @@ transitively: that key does not name the bytes they built from (`[vx] app#use: r
731
739
  over app#gen's outputs, which its key no longer describes — …`). Until
732
740
  2026-09-27 (A-12) they saved, and once the input was put back they hit
733
741
  the edit's output.
734
- A workspace fingerprint a task rewrote since the run read it (§ Cache
735
- key derivation, step 3) withholds the save the same way, and no key
742
+ A claimed root file a task rewrote since the run read it (§ Cache key
743
+ derivation, step 9) withholds the save the same way, and no key
736
744
  taken before it is probed or restored: a hit the up-front probe found
737
745
  goes back to the scheduler and runs once its deps are done. Until X-124
738
746
  that hit restored the old install's bytes.
739
747
 
740
748
  A miss that ran here and saves **nothing** — it failed, the cache
741
- policy writes nothing (`--cache=local:r,remote:r`), an upstream failed
749
+ policy writes nothing (`--cache-policy=local:r,remote:r`), an upstream failed
742
750
  under `--continue` — runs the same input re-check and, when an input
743
751
  moved, forgets the project as above; otherwise its declared outputs are
744
752
  resolved and marked in the git snapshot exactly as a save marks them.
@@ -801,25 +809,40 @@ Declared `cache.outputs.files` are wiped in two distinct places:
801
809
  list instead of walking again: nothing is awaited between them (X-161).
802
810
 
803
811
  Both branches use the same `cleanOutputs` helper (`src/cache/inputs.ts`)
804
- with the same boundary rules, and two directories stay off the wipe
805
- whatever the glob: `.git` and `.vx` (`OUTPUT_NEVER`). `node_modules` is
812
+ with the same boundary rules, and vx's own paths stay off the wipe
813
+ whatever the glob: `.git`, `.vx` and each `vx.lock.json` (`OUTPUT_NEVER`;
814
+ `.vx` holds the workspace config and the lock, which a clean deleted and a
815
+ hit restored stale while `.vx/cache` alone was listed). `node_modules` is
806
816
  off it too unless a glob names it (`node_modules/**`, an install task's
807
817
  legitimate output); until 2026-09-27 (A-13) `**/*.js` cleaned every
808
818
  installed `.js`, and a `workspaceFiles` output glob reached even `.git`.
809
- A task that fails while a git-tracked file another task's clean removed
810
- is still missing gets one line naming the file and that task: vx cleans
811
- before a run where Turbo does not, so a reader with no edge to the
812
- producer of a committed output fails naming only the file (A-48,
813
- vueuse's `metadata/index.json`).
819
+ No clean deletes a file git tracks (owner, 2026-10-14). Under an output
820
+ glob it is still an output: the task may rewrite it (TanStack Router's
821
+ committed `routeTree.gen.ts`), the artifact saves the bytes the run left,
822
+ and a restore writes them. It also stays in the task's key, even under its
823
+ own output glob, so an edit moves the key and the saved bytes follow from
824
+ it; the task's rewrite is no input that moved mid-run. Until then the
825
+ clean deleted a committed file under an output, and a reader with no edge
826
+ to its producer failed naming only the file (A-48, vueuse's
827
+ `metadata/index.json`). Untracked files under the glob are cleaned as
828
+ before. Paths compare as
829
+ git compares them: case-folded under `core.ignorecase` (macOS), and a
830
+ file inside a submodule counts as committed.
814
831
  Workspace outputs take the same rules, and a path an output `!` entry
815
832
  takes back (A-44) is off the wipe, the artifact and the restore alike.
816
833
  Skipped when:
817
834
 
818
835
  - `cache.outputs.files` is empty (nothing declared as output).
819
836
  - The task's `willWrite` is false — no write axis is enabled (e.g.
820
- `--no-cache`, or a read-only `--cache=local:r`). The user is debugging
837
+ `--no-cache`, or a read-only `--cache-policy=local:r`). The user is debugging
821
838
  and managing the tree, so vx leaves it alone. `--force` keeps writes
822
839
  on, so it DOES clean (the saved snapshot must be clean).
840
+ - `cache.outputs.clean` is `false` (on the task, else the workspace's
841
+ `cache.outputs.clean`): the command sees last run's outputs (tsc's
842
+ `.tsbuildinfo`). The first attempt stamps what it finds instead;
843
+ if the run leaves any old file unchanged, nothing is cached, so no
844
+ entry holds a file this run did not write. A retry cleans as usual; a hit still
845
+ cleans before its restore.
823
846
 
824
847
  A declared output the process cannot remove (a `dist/` another user
825
848
  wrote, a read-only checkout) fails the task with `cannot remove declared
@@ -845,7 +868,7 @@ function of the cache key.
845
868
  Two cached tasks whose declared outputs overlap are refused at graph
846
869
  build. By default that holds even when one depends on the other: the
847
870
  workspace rule `rules.exclusiveOutputs` (on unless set to `false` in
848
- `vx.workspace.ts`, X-53) keeps one path to one task, and its refusal says
871
+ `.vx/workspace.ts`, X-53) keeps one path to one task, and its refusal says
849
872
  how to turn it off. The shape below is correct, only slower: a stamp
850
873
  before every run, a diff after, and a clean by rows. Give each task its
851
874
  own output path where you can (`dist-individual` beside `dist`).
@@ -889,28 +912,28 @@ the next warm run; the design note keeps that shape out of scope.
889
912
 
890
913
  A task's cache becomes invalid when any of these change:
891
914
 
892
- | Trigger | Mechanism |
893
- | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
894
- | Edit a file in the task's `inputs.files` set | step 12 of key derivation |
895
- | Edit a file in the task's `inputs.workspaceFiles` set (root-anchored; may live in ANY project's dir — the documented boundary exception) | step 12 — resolved workspace files join the same input-file list |
896
- | Any package manager updates a lockfile (`pnpm`, `npm`, `yarn`, `bun`) | step 3 (workspace fingerprint) — or, for a lockfile a plugin claims, that plugin's `key` material (`@vzn/vx-lockfile`: only the projects whose dependency closure moved) |
897
- | Edit `pnpm-workspace.yaml` | step 3 |
898
- | Edit `package.json`'s `workspaces` field | not step 3 — membership: a project that joins or leaves changes which projects exist and which nested dirs its parent's globs exclude (step 12); the file itself is hashed per project (step 4) |
899
- | Edit the project's `package.json` (dep / version / scripts change) | step 4 (project package.json hash) |
900
- | Edit the task's `vx.config.ts` | step 5 (task config hash) |
901
- | Edit a config file that the task config imports | step 5 (configHash sees the resolved object after Bun evaluates imports) |
902
- | Change CLI `forwardArgs` (after `--`) | step 6 |
903
- | Change a `cache.inputs.env` host value | step 7 |
904
- | Change the combined stdout+stderr of a `cache.inputs.runtime` command (resolved at hash time) | step 8 |
905
- | Change the combined stdout+stderr of a `cache.inputs.workspaceRuntime` command (resolved at hash time) | step 9 |
906
- | Upstream task's cache key changes (because its inputs changed) | step 10 |
907
- | Bump `CACHE_VERSION` | step 1 — orphans every entry |
908
- | Change `exec.env.passThrough` _values_ alone | **NOT a trigger** by design — passThrough values are host-specific |
909
- | Change a file not in `inputs.files` / `inputs.workspaceFiles` | **NOT a trigger** by design — declare it explicitly |
910
- | Edit `vx-lock.json` | **NOT a trigger** — globally excluded from inputs (v24); it's vx's own metadata, never a task input |
911
- | Change a file in a nested project's dir | **NOT a trigger** for the parent's `files` globs — project boundaries are hard (workspaceFiles is the explicit exception) |
912
-
913
- The cascade in step 10 is what makes monorepo caching work: edit a
915
+ | Trigger | Mechanism |
916
+ | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
917
+ | Edit a file in the task's `inputs.files` set | step 10 of key derivation |
918
+ | Edit a file in the task's `inputs.workspaceFiles` set (root-anchored; may live in ANY project's dir — the documented boundary exception) | step 10 — resolved workspace files join the same input-file list |
919
+ | 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 |
920
+ | 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 |
921
+ | 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) |
922
+ | 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 |
923
+ | Edit the task's `vx.config.ts` | step 3 (task config hash) |
924
+ | Edit a config file that the task config imports | step 3 (configHash sees the resolved object after Bun evaluates imports) |
925
+ | Change CLI `forwardArgs` (after `--`) | step 4 |
926
+ | Change a `cache.inputs.env` host value | step 5 |
927
+ | Change the combined stdout+stderr of a `cache.inputs.runtime` command (resolved at hash time) | step 6 |
928
+ | Change the combined stdout+stderr of a `cache.inputs.workspaceRuntime` command (resolved at hash time) | step 7 |
929
+ | Upstream task's cache key changes (because its inputs changed) | step 8 |
930
+ | Bump `CACHE_VERSION` | step 1 — orphans every entry |
931
+ | Change `exec.env.passThrough` _values_ alone | **NOT a trigger** by design — passThrough values are host-specific |
932
+ | Change a file not in `inputs.files` / `inputs.workspaceFiles` | **NOT a trigger** by design — declare it explicitly |
933
+ | Edit `.vx/projects.lock.json` | **NOT a trigger** — globally excluded from inputs (v24); it's vx's own metadata, never a task input |
934
+ | Change a file in a nested project's dir | **NOT a trigger** for the parent's `files` globs — project boundaries are hard (workspaceFiles is the explicit exception) |
935
+
936
+ The cascade in step 8 is what makes monorepo caching work: edit a
914
937
  file in `lib/`, and every package that depends on `lib`'s `build` task
915
938
  re-runs automatically.
916
939
 
@@ -928,7 +951,7 @@ them (X-57). The same set fences the output clean, so a root
928
951
  glob pass drops a path under one of them (an ancestor lookup,
929
952
  A-11). The only way
930
953
  for project A to depend on project B's state via project-relative
931
- globs is `dependsOn` + upstream-hash propagation (step 10).
954
+ globs is `dependsOn` + upstream-hash propagation (step 8).
932
955
 
933
956
  For a task with `exec.sandbox` and `cache`, the sandbox enforces that
934
957
  "only". A workspace package reached through a `node_modules` link is
@@ -1014,7 +1037,7 @@ exactly on `cache.inputs.env`, and they share its asymmetry with the
1014
1037
  lockfile:
1015
1038
 
1016
1039
  - **The command _strings_ live in the resolved config**, so `vx lock`
1017
- freezes them into `vx-lock.json` (just as it freezes the env _names_
1040
+ freezes them into `.vx/projects.lock.json` (just as it freezes the env _names_
1018
1041
  a task declares).
1019
1042
  - **The command _output_ is resolved live at hash time on every run**
1020
1043
  — inside `resolveInputs`, the same place env _values_ are read from
@@ -1046,10 +1069,10 @@ A probe runs in its own process group. One still running when vx exits
1046
1069
  SIGKILLed with its whole tree; until 2026-09-27 (A-9) it ran on under
1047
1070
  init. A `kill -9` of vx runs no exit hook, and leaves it.
1048
1071
 
1049
- So `vx run --frozen` loads the frozen command strings but still spawns
1050
- them and folds their current output into the key. A `node -v` that goes
1051
- from `v20` to `v22` after the lock was written busts the cache under
1052
- `--frozen`, exactly as a changed `NODE_ENV` value would — the TypeScript
1072
+ So a run loads the frozen command strings from the lock but still
1073
+ spawns them and folds their current output into the key. A `node -v`
1074
+ that goes from `v20` to `v22` after the lock was written busts the
1075
+ cache, exactly as a changed `NODE_ENV` value would — the TypeScript
1053
1076
  escape hatch (`define: { TSC_VERSION: execSync(...) }`) goes stale here
1054
1077
  because its value was baked into the config object at lock time, whereas
1055
1078
  a runtime input re-resolves.
@@ -1101,7 +1124,7 @@ has staged). Machines sharing a workspace over a network file system
1101
1124
  do not share a temp directory, so they do not share the lock.
1102
1125
 
1103
1126
  An eviction can still land between a hit's probe and its restore: a
1104
- `vx cache prune` in another shell, or the `cacheRetention` of another
1127
+ `vx cache clean` in another shell, or the `cache.retention` of another
1105
1128
  workspace that shares the `--cache-dir` (a different workspace, so a
1106
1129
  different lock), which cannot see this run's pending `accessed_at`
1107
1130
  bumps and reads its fresh hits as old. The restore then finds no
@@ -1126,16 +1149,15 @@ only read keeps the entry (`…; left it (this cache is read-only)`).
1126
1149
 
1127
1150
  The run must be able to write here — it records its history at the
1128
1151
  end of every run, hit or miss — so a directory this user cannot write
1129
- into (another user's `.vx`, a read-only checkout) fails the run before
1152
+ into (another user's `.vx/cache`, a read-only checkout) fails the run before
1130
1153
  any task: `cache directory <path> is not writable (EACCES: …)`, with
1131
- `--cache-dir <path>` as the way out. `vx cache prune` is refused the
1154
+ `--cache-dir <path>` as the way out. `vx cache clean` is refused the
1132
1155
  same way (its `--dry-run` only reads, and reads). The readers (`vx show`, `why`,
1133
- `last`, `info`) open such a directory read-only and go on: a config
1134
- that misses the evaluation cache is evaluated live and not stored.
1156
+ `last`, `info`) open such a directory read-only and go on.
1135
1157
  Where the directory cannot be created at all (a read-only checkout with
1136
1158
  no cache yet) a run says `cannot create cache directory <path>
1137
- (EACCES: …)`, naming the workspace `cacheDir` field and `--cache-dir`;
1138
- the readers and `vx cache prune` find no cache and go on.
1159
+ (EACCES: …)`, naming the workspace `cache.dir` field and `--cache-dir`;
1160
+ the readers and `vx cache clean` find no cache and go on.
1139
1161
  A full disk is the same kind of failure and is reported the same way,
1140
1162
  never as a corrupt artifact and never as a stack: a save that runs out
1141
1163
  of room is `[vx] cache save failed: ENOSPC …` (the task's work ran, the
@@ -1169,6 +1191,8 @@ By default the entries and their artifacts live in a **shared store**
1169
1191
  in `~/.vx/<id>/cache`, and each workspace keeps only its own index in
1170
1192
  its `.vx/cache`: every checkout of the repository hits what another
1171
1193
  saved (a second clone, a worktree), and none reads another's history.
1194
+ Task timings are shared too: `task_durations`, what a duration forecast
1195
+ reads, so a new worktree has one on its first run.
1172
1196
  The id is Nx 23's: 16 hex of a sha256 of the remote (`origin`, then
1173
1197
  `upstream`, `base`, the first; `host/owner/repo` in lower case, so ssh
1174
1198
  and https agree) and the workspace's path in the repository; with no
@@ -1179,7 +1203,8 @@ owner-only; vx closes one of yours that others can read (chmod 700), and one oth
1179
1203
  ```
1180
1204
  ~/.vx/<id>/cache/ the shared store
1181
1205
  ├── store.db entries, entry_stdout, output_files,
1182
- │ entry_inputs, store_meta; artifacts
1206
+ │ entry_inputs, task_durations,
1207
+ │ store_meta; artifacts
1183
1208
  │ of at most 32 KiB compressed (v33)
1184
1209
  └── <hash>.tar.zst the larger artifacts (below)
1185
1210
 
@@ -1198,7 +1223,7 @@ when its task next hits. The check, drop, re-create and stamp are one
1198
1223
  write transaction, so another version's open waits rather than landing
1199
1224
  between them. A home this user cannot write keeps the store
1200
1225
  in `<workspaceRoot>/.vx/cache/` instead, silently. Name a
1201
- cache directory (`cacheDir` in vx.workspace.ts, `--cache-dir`, or
1226
+ cache directory (`cache.dir` in .vx/workspace.ts, `--cache-dir`, or
1202
1227
  `VX_CACHE_DIR`, in that order of precedence, relative to the workspace
1203
1228
  root) and it holds everything, shared with no other workspace:
1204
1229
 
@@ -1398,7 +1423,7 @@ miss, and the save that follows replaces it. An inline artifact with no
1398
1423
  row is indexed the same way, from its bytes, in one transaction; a reset
1399
1424
  keeps the `artifacts` table, but deleting `store.db` or `cache.db` by
1400
1425
  hand takes the inline artifacts with it. A `.tmp-*` a crashed save
1401
- left is never a hit. `vx cache prune` sweeps row-less files, and so does a run whose workspace declares `cacheRetention`, at
1426
+ left is never a hit. `vx cache clean` sweeps row-less files, and so does a run whose workspace declares `cache.retention`, at
1402
1427
  most once an hour (the sweep's clock is `schema_meta.orphans_swept_at`;
1403
1428
  the policy sums index rows, so orphans alone never make it due). A
1404
1429
  row-less artifact may be in use: two vx versions share one store, and
@@ -1431,39 +1456,29 @@ hit replays it with pure SQL, never decompressing the artifact).
1431
1456
  `SCHEMA_VERSION`, earlier or newer, is reset by the first run that opens
1432
1457
  it (pre-alpha: no migrations; the index is an inventory, owner
1433
1458
  2026-10-06): every table but `schema_meta` is dropped and recreated,
1434
- so each comes back in its current shape (A-54: `config_closures` and
1435
- `output_dirs` kept an earlier vx's columns). The check, drop, re-create
1459
+ so each comes back in its current shape (A-54: `output_dirs` kept an
1460
+ earlier vx's columns). The check, drop, re-create
1436
1461
  and stamp are one write transaction, so another version's open waits
1437
1462
  rather than landing between them; an index already current is opened
1438
1463
  without the lock.
1439
1464
  A reading verb (`vx why`, `vx last`, `vx info`) leaves it untouched and
1440
- says why (item 896; `vx cache prune --dry-run` previews the reset
1465
+ says why (item 896; `vx cache clean --dry-run` previews the reset
1441
1466
  instead, item 1083).
1442
1467
  That open prints nothing (owner, 2026-10-06: the cache is vx's to
1443
1468
  keep). The artifacts stay: each is indexed again from its own bytes
1444
1469
  when its task next asks for its key (below).
1445
1470
 
1446
1471
  ```sql
1447
- -- src/cache/schema.ts (SCHEMA_VERSION = 'v34', in cache.ts)
1448
- -- With a shared store, entries, entry_stdout, output_files, entry_inputs
1449
- -- and store_meta live in its store.db, attached as `store`; the rest is
1472
+ -- src/cache/schema.ts (SCHEMA_VERSION = 'v36', in cache.ts)
1473
+ -- With a shared store, entries, entry_stdout, output_files, entry_inputs,
1474
+ -- store_meta and task_durations live in its store.db, attached as `store`; the rest is
1450
1475
  -- the workspace's cache.db. A named cache dir holds all of them.
1451
1476
 
1452
1477
  CREATE TABLE schema_meta (
1453
- key TEXT PRIMARY KEY, -- 'version', 'cache_version', 'orphans_swept_at', 'file_hashes_swept_at', 'config_evals_swept_at', 'store_dir'
1478
+ key TEXT PRIMARY KEY, -- 'version', 'cache_version', 'orphans_swept_at', 'file_hashes_swept_at', 'store_dir'
1454
1479
  value TEXT NOT NULL
1455
1480
  );
1456
1481
 
1457
- -- The config-evaluation cache (§ Config evaluation cache): the validated,
1458
- -- JSON-serialised result of a provably pure config, keyed by everything
1459
- -- the evaluation could have observed. Machine-local. Rows not written in
1460
- -- 30 days are swept at most once a day (`config_evals_swept_at`).
1461
- CREATE TABLE config_evals (
1462
- key TEXT PRIMARY KEY,
1463
- json TEXT NOT NULL,
1464
- created_at INTEGER NOT NULL
1465
- );
1466
-
1467
1482
  CREATE TABLE entries (
1468
1483
  hash TEXT PRIMARY KEY, -- the 16-hex xxh3 cache key
1469
1484
  project TEXT NOT NULL,
@@ -1544,7 +1559,7 @@ CREATE INDEX runs_failed ON runs(hash) WHERE status = 'failed';
1544
1559
 
1545
1560
  -- The file-hash memo: a content hash per input file that git could not
1546
1561
  -- answer (untracked or dirty), keyed by the stat identity that proves
1547
- -- the bytes unchanged. Machine-local; § Cache key derivation step 12.
1562
+ -- the bytes unchanged. Machine-local; § Cache key derivation step 10.
1548
1563
  -- A writing close drops rows unwritten for 30 days, at most once a day
1549
1564
  -- (a memo miss is the whole cost of a dropped row; item 1082).
1550
1565
  CREATE TABLE file_hashes (
@@ -1602,15 +1617,6 @@ CREATE TABLE output_stamps (
1602
1617
  PRIMARY KEY (entry_hash, path)
1603
1618
  );
1604
1619
 
1605
- -- Each config's ORDERED import closure (the config first), so a warm
1606
- -- load keys it by stat-hashing the list through file_hashes instead of
1607
- -- reading every file. Machine-local; pruned with config_evals.
1608
- CREATE TABLE config_closures (
1609
- config_path TEXT PRIMARY KEY,
1610
- files_json TEXT NOT NULL,
1611
- created_at INTEGER NOT NULL
1612
- );
1613
-
1614
1620
  -- Every directory under a whole-subtree output glob, with its mtime as
1615
1621
  -- of the last save or restore on THIS machine: unchanged mtimes prove
1616
1622
  -- the output SET unchanged without a glob walk. Machine-local — a remote
@@ -1687,6 +1693,20 @@ CREATE TABLE store_meta (
1687
1693
  value TEXT NOT NULL
1688
1694
  );
1689
1695
 
1696
+ -- v36: each executed success's duration, appended in the run's history
1697
+ -- transaction, in the store so every worktree forecasts from it.
1698
+ -- source: 'branch:<name>'; the forecast reads the newest five per task.
1699
+ CREATE TABLE task_durations (
1700
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1701
+ project TEXT NOT NULL,
1702
+ task TEXT NOT NULL,
1703
+ source TEXT NOT NULL,
1704
+ sha TEXT,
1705
+ duration_ms INTEGER NOT NULL,
1706
+ recorded_at INTEGER NOT NULL
1707
+ );
1708
+ CREATE INDEX task_durations_task ON task_durations(project, task, source, id);
1709
+
1690
1710
  -- v33: an artifact of at most 32 KiB compressed, its exact <hash>.tar.zst
1691
1711
  -- bytes, written in the transaction that writes its entry rows. In the
1692
1712
  -- store (or a named cache dir's cache.db), and outside every drop a
@@ -1743,28 +1763,6 @@ moat.
1743
1763
  - **One handle, one schema-meta sentinel.** An older schema drops
1744
1764
  and recreates every table but `schema_meta` (pre-alpha) — there's no migration code to maintain.
1745
1765
 
1746
- ## Config evaluation cache
1747
-
1748
- Evaluating configs is the largest fixed cost of a warm run on a big
1749
- workspace (~80 ms for 1000 synthetic configs; reading them as data is
1750
- ~12 ms). A config that is **provably pure** — every import relative or
1751
- `@vzn/vx`, and no mention of `process`, `Bun`, `Date`, `fetch`,
1752
- `import.meta`, `require`, a dynamic `import()`, `await`, … outside string
1753
- literals and comments — is served from `cache.db`'s `config_evals` table,
1754
- keyed by the git blob id of the config and of every file in its
1755
- relative import closure, the workspace fingerprint (lockfiles) and Bun's
1756
- version. Editing a shared preset moves the key. On a warm run the
1757
- closure is remembered per config, so the key comes from a stat-backed
1758
- identity per file — no read, no scan — for every config whose relative
1759
- imports name their files outright: an explicit extension, the file
1760
- itself, no symlink on the way (an extensionless import, a `.js` Bun
1761
- answers with a `.ts`, or a link could be re-resolved without touching a
1762
- listed file, so such a config keeps the scan). Anything the static check cannot prove
1763
- pure evaluates live, exactly as before, so the cache can be slower but
1764
- not wrong for a config written in good faith (the check is syntactic; one
1765
- built to defeat it can). Details and the deny-list:
1766
- [`modules/config-cache.md`](./modules/config-cache.md).
1767
-
1768
1766
  ## Performance characteristics
1769
1767
 
1770
1768
  - **Hashing cost** on a clean tree is near-zero per file: git index
@@ -1783,15 +1781,16 @@ built to defeat it can). Details and the deny-list:
1783
1781
  transaction, which holds a small artifact's bytes itself; a large one
1784
1782
  adds a temp write and an atomic rename. Hashing dominates the run; storage itself
1785
1783
  is cheap. The remote upload (if any) is backgrounded.
1786
- - **Workspace fingerprint** is computed once per `vx run` invocation
1787
- and reused for every task in that run; after a task that may rewrite
1788
- one of its files ran, one `stat` per file re-checks it (item 750).
1784
+ - **Claimed root files** (a lockfile plugin's lockfile and settings)
1785
+ are read once per `vx run` invocation; after a task that may rewrite
1786
+ one ran, one `stat` per file re-checks it (item 750). With no plugin
1787
+ claiming one, nothing is read.
1789
1788
 
1790
1789
  ## What's NOT in the key (and why)
1791
1790
 
1792
1791
  - **`exec.env.passThrough` _values_.** Would force cache misses across
1793
1792
  machines with different CI flags, regions, or shell prompts. The
1794
- _names_ are in the config hash (step 5) so adding/removing a
1793
+ _names_ are in the config hash (step 3) so adding/removing a
1795
1794
  passthrough still bumps the key for affected tasks.
1796
1795
  - **Files outside the project directory that aren't declared.**
1797
1796
  Workspace-root configs (`tsconfig.base.json`, etc.) are not
@@ -1801,11 +1800,13 @@ built to defeat it can). Details and the deny-list:
1801
1800
  The canonical mechanism is `cache.inputs.runtime` /
1802
1801
  `workspaceRuntime` (e.g. `workspaceRuntime: ['node -v']`): the
1803
1802
  command output is resolved live at hash time, so it stays correct
1804
- under `--frozen`. Avoid baking versions via
1803
+ under the lock. Avoid baking versions via
1805
1804
  `define: { X: execSync(...) }` — that value freezes into the config
1806
1805
  object at lock time and goes stale.
1807
- - **`vx-lock.json`** — globally excluded (v24); also filtered out of
1808
- `--affected` change sets.
1806
+ - **`.vx/projects.lock.json`** — globally excluded (v24); also filtered out of
1807
+ `--affected` change sets. So are the files `vx lock` and `vx init`
1808
+ write beside it: each project's `vx.lock.json` review copy,
1809
+ `.vx/.gitattributes` and `.vx/.gitignore`.
1809
1810
  - **`exec.remote`.** Pure PLACEMENT: it decides _where_ a task runs,
1810
1811
  never what it produces, so pinning a task to this machine does not
1811
1812
  bust its cache. It must not — the
@@ -1827,8 +1828,8 @@ The cache records the version it was written under
1827
1828
  (`schema_meta.cache_version`). After an upgrade every cached task
1828
1829
  misses once and re-saves, with nothing printed (owner, 2026-10-06: the
1829
1830
  cache is vx's to keep). The index survives, so the old entries stay
1830
- until they age out under `vx cache prune --older-than` or
1831
- `cacheRetention`; no key derives to them again.
1831
+ until they age out under `vx cache clean --older-than` or
1832
+ `cache.retention`; no key derives to them again.
1832
1833
 
1833
1834
  Required when:
1834
1835
 
@@ -1848,6 +1849,9 @@ Not required when:
1848
1849
  background uploads) — key derivation and artifact bytes untouched.
1849
1850
  - Doc-only updates.
1850
1851
  - Refactors that don't change the bytes fed into the hash.
1852
+ - A key part leaves the chain: every key moves, so no unchanged key
1853
+ holds wrong bytes (`v43` stayed when core stopped folding the
1854
+ `workspace:` and `pkg:` parts, 2026-10-14).
1851
1855
 
1852
1856
  `tests/contract-stored-format.test.ts` holds the layout rows: it
1853
1857
  records the index's DDL beside `SCHEMA_VERSION` and a fixture
@@ -1872,6 +1876,14 @@ breaking footer).
1872
1876
 
1873
1877
  ### History
1874
1878
 
1879
+ - **v43 kept** (2026-10-14): core stopped folding the project's
1880
+ `package.json` and the root fingerprint files; every key moved, so
1881
+ nothing replays wrong bytes and no bump was needed.
1882
+ - **v42 → v43**: stored bytes wrong under an unchanged key (Security
1883
+ F4, 2026-10-14). A scoped run stored its local entries under the
1884
+ default key, so a later default run on the same disk replayed a PR's
1885
+ bytes. A scoped run now stores under the scope's key locally as it
1886
+ does remotely; the fix cannot reach an entry already saved that way.
1875
1887
  - **v41 → v42**: the stored output became both streams in the order
1876
1888
  the run printed them (`orchestrator/output-log.ts`), so a hit replays
1877
1889
  stderr too. A v41 entry holds stdout alone.
@@ -2070,7 +2082,7 @@ breaking footer).
2070
2082
  artifact is gone or cannot produce an output the index recorded (the
2071
2083
  caller has already wiped the declared outputs by then, so a quiet
2072
2084
  return reported a green hit over an emptied tree — reachable via a
2073
- concurrent `vx cache prune`); the tar reader rejects an entry whose
2085
+ concurrent `vx cache clean`); the tar reader rejects an entry whose
2074
2086
  declared size runs past the end of the archive (`subarray` clamps, so
2075
2087
  it used to install short, NUL-padded content as a cache hit instead of
2076
2088
  degrading to a miss); and directory entries now get the same
@@ -2114,7 +2126,7 @@ breaking footer).
2114
2126
  so existing artifacts stay valid and `CACHE_VERSION` stays `v24`. The
2115
2127
  schema gate drops + recreates on the version mismatch (pre-alpha, no
2116
2128
  migration).
2117
- - **v23 → v24**: exclude `vx-lock.json` from the input file set
2129
+ - **v23 → v24**: exclude `.vx/projects.lock.json` from the input file set
2118
2130
  globally (`ALWAYS_IGNORE` in `cache/inputs.ts`). The lockfile is
2119
2131
  committed, so git enumerates it, but it's vx's own frozen-config
2120
2132
  metadata — never a task input. Without this, a `vx lock` re-write
@@ -2125,7 +2137,7 @@ breaking footer).
2125
2137
 
2126
2138
  - **v22 → v23**: fold `cache.inputs.runtime` / `workspaceRuntime`
2127
2139
  command output into the key (two namespaced sections after
2128
- env-values). The command _strings_ stay in the config hash (step 5);
2140
+ env-values). The command _strings_ stay in the config hash (step 3);
2129
2141
  their combined trimmed stdout+stderr is resolved live at hash time
2130
2142
  and folded as `runtime-values:` (project-cwd) and `ws-runtime-values:`
2131
2143
  (root-cwd) sections, each `command\0output`. No SCHEMA bump — only
@@ -2158,7 +2170,7 @@ breaking footer).
2158
2170
  - **v9 → v10** (PR #7): on-disk layout switched from per-entry
2159
2171
  `meta.json` + `outputs/` directory to a workspace-wide `cache.db`
2160
2172
  (SQLite) plus output files directly at `<hash>/` and log files at
2161
- `logs/<hash>.{stdout,stderr}`. Adds run history for `vx stats`.
2173
+ `logs/<hash>.{stdout,stderr}`. Adds run history for `vx info`.
2162
2174
  Removes the per-entry manifest.
2163
2175
  - **v10 → v11** (PR #19): analytics columns added to the `runs`
2164
2176
  table: `run_id` (UUIDv7), `cpu_ms`, `peak_rss_bytes`,
@@ -2228,7 +2240,7 @@ breaking footer).
2228
2240
  transitive-deps to nearest-holder frontier semantics (Turbo/Nx
2229
2241
  direct-deps parity, plus vx's sparse bridging through deps that
2230
2242
  don't declare the task). Task graphs lose the redundant deep edges,
2231
- so the filtered-upstream-hash set (step 10) shrinks for any task
2243
+ so the filtered-upstream-hash set (step 8) shrinks for any task
2232
2244
  whose deps chain `'^task'` themselves — same inputs now derive a
2233
2245
  different key. Reachability/ordering is unchanged whenever holders
2234
2246
  chain `'^task'` (the universal pattern); a holder that doesn't is