@vzn/vx 0.0.636 → 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 +39 -11
  2. package/docs/caching.md +213 -207
  3. package/docs/cli.md +699 -655
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +376 -198
  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 +5 -3
  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 -63
  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 +103 -149
  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 +169 -70
  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 +24 -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 +158 -63
  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 +42 -29
  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 +113 -72
  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 +121 -95
  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 +209 -268
  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 +20 -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
@@ -225,7 +217,7 @@ add` under a clean filter (`core.autocrlf=true`, a `text` rule)
225
217
  and every project under it is selected.
226
218
 
227
219
  A task with **no `cache` block** derives a key too — its dependents
228
- fold it (step 10) — and, declaring nothing, folds **every file in
220
+ fold it (step 8) — and, declaring nothing, folds **every file in
229
221
  its project**: `**/*`, gitignore-aware, untracked files included,
230
222
  and its own outputs not excluded, since it declared none. So its
231
223
  key moves whenever it writes a file git does not ignore: a cached
@@ -238,8 +230,7 @@ add` under a clean filter (`core.autocrlf=true`, a `text` rule)
238
230
 
239
231
  Declaring nothing, such a task may also have **written** anywhere
240
232
  in its project, so what the run learned about that project at its
241
- start — the git listing, the index OIDs, the `package.json` digest
242
- — 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
243
234
  re-lists and hashes by content. For the same reason a task after it
244
235
  in the same project (`dependsOn`, even with `tasks: []`) is never
245
236
  keyed up front by the local short-circuit or the remote prefetch.
@@ -562,7 +553,7 @@ Hard invariants of the remote prefetch:
562
553
  later `get` finds it as a now-local hit — still reports
563
554
  `source: 'remote'`, so the outcome is `cache-hit-remote`.
564
555
  - **A remote-read-off policy** (`--no-cache`, `--force`, or
565
- `--cache=remote:`) fires no prefetch.
556
+ `--cache-policy=remote:`) fires no prefetch.
566
557
  - **Never fail.** Every remote path (get / put / ingest / prefetch)
567
558
  catches all errors and degrades to a cache miss. A remote 500, a
568
559
  network drop, a corrupt artifact, or a failed integrity check can
@@ -606,8 +597,7 @@ Caching is controlled by a four-axis `CachePolicy` — **localRead**,
606
597
  **localWrite**, **remoteRead**, **remoteWrite** — independent toggles,
607
598
  each enforced inside the matching cache layer at construction time. The
608
599
  local `Cache` gets a `{ read, write }` slice gating its task artifact
609
- get/save, the config-evaluation cache's reads and writes, and the
610
- 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
611
601
  / key derivation); the `LayeredCache` additionally gates its own remote
612
602
  read-through (`remoteRead`), upload (`remoteWrite`), and prefetch
613
603
  (`remoteRead`). The orchestrator derives two booleans per task:
@@ -623,14 +613,23 @@ restore, no output clean, no local save), and its result lives in the
623
613
  remote executor's own store.
624
614
 
625
615
  The CLI maps three flags to a policy (precedence: start all-on → apply
626
- `--cache` → `--no-cache` forces all off → `--force` forces both reads
616
+ `--cache-policy` → `--no-cache` forces all off → `--force` forces both reads
627
617
  off): `--no-cache` = everything off (no read, no write, no output
628
618
  clean); `--force` = reads off / writes on (re-execute and refresh the
629
- cache, outputs cleaned); `--cache=<spec>` = explicit per-layer control.
630
- 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).
631
630
 
632
631
  One subtlety: when `localWrite` is off but `remoteWrite` is on
633
- (`--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
634
633
  `LayeredCache` to read before uploading — so it packs the tar.zst bytes
635
634
  in memory (`Cache.packArtifactBytes`) and uploads those.
636
635
 
@@ -684,8 +683,8 @@ before the command ran — at the task's start, or up front by the local
684
683
  short-circuit — and the save files the outputs under it, so the inputs
685
684
  must still be what it describes. Two checks, in order. The key
686
685
  re-derived just before the command (`describeTaskInputs`, which the
687
- executor seam needs anyway) must equal it. Then each input file and the
688
- 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
689
688
  after, or within `FILE_HASH_RACY_MS` before, the moment its digest was
690
689
  learned — the enumeration's start for an index OID, the describe for a
691
690
  hashed file — is hashed again and compared, and a file that is gone has
@@ -698,9 +697,9 @@ edit, was filed under the key and restored over the original (item
698
697
  one status line names the file (``[vx] app#format: `packages/app/a.ts`
699
698
  changed after its key was taken — …``), and the run forgets what it
700
699
  knew about the project, as after an uncached task (§ Cache key
701
- derivation, step 12). This covers a formatter rewriting its own input
700
+ derivation, step 10). This covers a formatter rewriting its own input
702
701
  (turborepo#10111) and a user's edit mid-run (turborepo#1146). A task
703
- 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
704
703
  key still lets a local consumer fetch its outputs, but the fetch saves
705
704
  no entry; until X-123 it saved them under the old key. A task
706
705
  that rewrites its own input to the SAME bytes (`sed -i` always writes)
@@ -713,7 +712,10 @@ task of the same project running alongside can change an input and put
713
712
  it back. A task that rewrites its own input to identical bytes (tsdown's
714
713
  `package.json`) sets `cache.inputStability: { strategy: 'mutable' }`, and a write
715
714
  during its command is then judged by its bytes, as an earlier write is;
716
- different bytes still withhold the entry. `VX_INPUT_STABILITY=mutable`
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`
717
719
  makes that the default (owner, 2026-10-12); `'unchecked'` skips the
718
720
  check, and `severity: 'error'` fails the task instead of warning
719
721
  (`docs/schema.md`). A file ADDED under an
@@ -737,14 +739,14 @@ transitively: that key does not name the bytes they built from (`[vx] app#use: r
737
739
  over app#gen's outputs, which its key no longer describes — …`). Until
738
740
  2026-09-27 (A-12) they saved, and once the input was put back they hit
739
741
  the edit's output.
740
- A workspace fingerprint a task rewrote since the run read it (§ Cache
741
- 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
742
744
  taken before it is probed or restored: a hit the up-front probe found
743
745
  goes back to the scheduler and runs once its deps are done. Until X-124
744
746
  that hit restored the old install's bytes.
745
747
 
746
748
  A miss that ran here and saves **nothing** — it failed, the cache
747
- policy writes nothing (`--cache=local:r,remote:r`), an upstream failed
749
+ policy writes nothing (`--cache-policy=local:r,remote:r`), an upstream failed
748
750
  under `--continue` — runs the same input re-check and, when an input
749
751
  moved, forgets the project as above; otherwise its declared outputs are
750
752
  resolved and marked in the git snapshot exactly as a save marks them.
@@ -807,25 +809,40 @@ Declared `cache.outputs.files` are wiped in two distinct places:
807
809
  list instead of walking again: nothing is awaited between them (X-161).
808
810
 
809
811
  Both branches use the same `cleanOutputs` helper (`src/cache/inputs.ts`)
810
- with the same boundary rules, and two directories stay off the wipe
811
- 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
812
816
  off it too unless a glob names it (`node_modules/**`, an install task's
813
817
  legitimate output); until 2026-09-27 (A-13) `**/*.js` cleaned every
814
818
  installed `.js`, and a `workspaceFiles` output glob reached even `.git`.
815
- A task that fails while a git-tracked file another task's clean removed
816
- is still missing gets one line naming the file and that task: vx cleans
817
- before a run where Turbo does not, so a reader with no edge to the
818
- producer of a committed output fails naming only the file (A-48,
819
- 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.
820
831
  Workspace outputs take the same rules, and a path an output `!` entry
821
832
  takes back (A-44) is off the wipe, the artifact and the restore alike.
822
833
  Skipped when:
823
834
 
824
835
  - `cache.outputs.files` is empty (nothing declared as output).
825
836
  - The task's `willWrite` is false — no write axis is enabled (e.g.
826
- `--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
827
838
  and managing the tree, so vx leaves it alone. `--force` keeps writes
828
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.
829
846
 
830
847
  A declared output the process cannot remove (a `dist/` another user
831
848
  wrote, a read-only checkout) fails the task with `cannot remove declared
@@ -851,7 +868,7 @@ function of the cache key.
851
868
  Two cached tasks whose declared outputs overlap are refused at graph
852
869
  build. By default that holds even when one depends on the other: the
853
870
  workspace rule `rules.exclusiveOutputs` (on unless set to `false` in
854
- `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
855
872
  how to turn it off. The shape below is correct, only slower: a stamp
856
873
  before every run, a diff after, and a clean by rows. Give each task its
857
874
  own output path where you can (`dist-individual` beside `dist`).
@@ -895,28 +912,28 @@ the next warm run; the design note keeps that shape out of scope.
895
912
 
896
913
  A task's cache becomes invalid when any of these change:
897
914
 
898
- | Trigger | Mechanism |
899
- | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
900
- | Edit a file in the task's `inputs.files` set | step 12 of key derivation |
901
- | 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 |
902
- | 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) |
903
- | Edit `pnpm-workspace.yaml` | step 3 |
904
- | 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) |
905
- | Edit the project's `package.json` (dep / version / scripts change) | step 4 (project package.json hash) |
906
- | Edit the task's `vx.config.ts` | step 5 (task config hash) |
907
- | Edit a config file that the task config imports | step 5 (configHash sees the resolved object after Bun evaluates imports) |
908
- | Change CLI `forwardArgs` (after `--`) | step 6 |
909
- | Change a `cache.inputs.env` host value | step 7 |
910
- | Change the combined stdout+stderr of a `cache.inputs.runtime` command (resolved at hash time) | step 8 |
911
- | Change the combined stdout+stderr of a `cache.inputs.workspaceRuntime` command (resolved at hash time) | step 9 |
912
- | Upstream task's cache key changes (because its inputs changed) | step 10 |
913
- | Bump `CACHE_VERSION` | step 1 — orphans every entry |
914
- | Change `exec.env.passThrough` _values_ alone | **NOT a trigger** by design — passThrough values are host-specific |
915
- | Change a file not in `inputs.files` / `inputs.workspaceFiles` | **NOT a trigger** by design — declare it explicitly |
916
- | Edit `vx-lock.json` | **NOT a trigger** — globally excluded from inputs (v24); it's vx's own metadata, never a task input |
917
- | 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) |
918
-
919
- 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
920
937
  file in `lib/`, and every package that depends on `lib`'s `build` task
921
938
  re-runs automatically.
922
939
 
@@ -934,7 +951,7 @@ them (X-57). The same set fences the output clean, so a root
934
951
  glob pass drops a path under one of them (an ancestor lookup,
935
952
  A-11). The only way
936
953
  for project A to depend on project B's state via project-relative
937
- globs is `dependsOn` + upstream-hash propagation (step 10).
954
+ globs is `dependsOn` + upstream-hash propagation (step 8).
938
955
 
939
956
  For a task with `exec.sandbox` and `cache`, the sandbox enforces that
940
957
  "only". A workspace package reached through a `node_modules` link is
@@ -1020,7 +1037,7 @@ exactly on `cache.inputs.env`, and they share its asymmetry with the
1020
1037
  lockfile:
1021
1038
 
1022
1039
  - **The command _strings_ live in the resolved config**, so `vx lock`
1023
- 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_
1024
1041
  a task declares).
1025
1042
  - **The command _output_ is resolved live at hash time on every run**
1026
1043
  — inside `resolveInputs`, the same place env _values_ are read from
@@ -1052,10 +1069,10 @@ A probe runs in its own process group. One still running when vx exits
1052
1069
  SIGKILLed with its whole tree; until 2026-09-27 (A-9) it ran on under
1053
1070
  init. A `kill -9` of vx runs no exit hook, and leaves it.
1054
1071
 
1055
- So `vx run --frozen` loads the frozen command strings but still spawns
1056
- them and folds their current output into the key. A `node -v` that goes
1057
- from `v20` to `v22` after the lock was written busts the cache under
1058
- `--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
1059
1076
  escape hatch (`define: { TSC_VERSION: execSync(...) }`) goes stale here
1060
1077
  because its value was baked into the config object at lock time, whereas
1061
1078
  a runtime input re-resolves.
@@ -1107,7 +1124,7 @@ has staged). Machines sharing a workspace over a network file system
1107
1124
  do not share a temp directory, so they do not share the lock.
1108
1125
 
1109
1126
  An eviction can still land between a hit's probe and its restore: a
1110
- `vx cache prune` in another shell, or the `cacheRetention` of another
1127
+ `vx cache clean` in another shell, or the `cache.retention` of another
1111
1128
  workspace that shares the `--cache-dir` (a different workspace, so a
1112
1129
  different lock), which cannot see this run's pending `accessed_at`
1113
1130
  bumps and reads its fresh hits as old. The restore then finds no
@@ -1132,16 +1149,15 @@ only read keeps the entry (`…; left it (this cache is read-only)`).
1132
1149
 
1133
1150
  The run must be able to write here — it records its history at the
1134
1151
  end of every run, hit or miss — so a directory this user cannot write
1135
- 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
1136
1153
  any task: `cache directory <path> is not writable (EACCES: …)`, with
1137
- `--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
1138
1155
  same way (its `--dry-run` only reads, and reads). The readers (`vx show`, `why`,
1139
- `last`, `info`) open such a directory read-only and go on: a config
1140
- that misses the evaluation cache is evaluated live and not stored.
1156
+ `last`, `info`) open such a directory read-only and go on.
1141
1157
  Where the directory cannot be created at all (a read-only checkout with
1142
1158
  no cache yet) a run says `cannot create cache directory <path>
1143
- (EACCES: …)`, naming the workspace `cacheDir` field and `--cache-dir`;
1144
- 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.
1145
1161
  A full disk is the same kind of failure and is reported the same way,
1146
1162
  never as a corrupt artifact and never as a stack: a save that runs out
1147
1163
  of room is `[vx] cache save failed: ENOSPC …` (the task's work ran, the
@@ -1175,6 +1191,8 @@ By default the entries and their artifacts live in a **shared store**
1175
1191
  in `~/.vx/<id>/cache`, and each workspace keeps only its own index in
1176
1192
  its `.vx/cache`: every checkout of the repository hits what another
1177
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.
1178
1196
  The id is Nx 23's: 16 hex of a sha256 of the remote (`origin`, then
1179
1197
  `upstream`, `base`, the first; `host/owner/repo` in lower case, so ssh
1180
1198
  and https agree) and the workspace's path in the repository; with no
@@ -1185,7 +1203,8 @@ owner-only; vx closes one of yours that others can read (chmod 700), and one oth
1185
1203
  ```
1186
1204
  ~/.vx/<id>/cache/ the shared store
1187
1205
  ├── store.db entries, entry_stdout, output_files,
1188
- │ entry_inputs, store_meta; artifacts
1206
+ │ entry_inputs, task_durations,
1207
+ │ store_meta; artifacts
1189
1208
  │ of at most 32 KiB compressed (v33)
1190
1209
  └── <hash>.tar.zst the larger artifacts (below)
1191
1210
 
@@ -1204,7 +1223,7 @@ when its task next hits. The check, drop, re-create and stamp are one
1204
1223
  write transaction, so another version's open waits rather than landing
1205
1224
  between them. A home this user cannot write keeps the store
1206
1225
  in `<workspaceRoot>/.vx/cache/` instead, silently. Name a
1207
- cache directory (`cacheDir` in vx.workspace.ts, `--cache-dir`, or
1226
+ cache directory (`cache.dir` in .vx/workspace.ts, `--cache-dir`, or
1208
1227
  `VX_CACHE_DIR`, in that order of precedence, relative to the workspace
1209
1228
  root) and it holds everything, shared with no other workspace:
1210
1229
 
@@ -1404,7 +1423,7 @@ miss, and the save that follows replaces it. An inline artifact with no
1404
1423
  row is indexed the same way, from its bytes, in one transaction; a reset
1405
1424
  keeps the `artifacts` table, but deleting `store.db` or `cache.db` by
1406
1425
  hand takes the inline artifacts with it. A `.tmp-*` a crashed save
1407
- 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
1408
1427
  most once an hour (the sweep's clock is `schema_meta.orphans_swept_at`;
1409
1428
  the policy sums index rows, so orphans alone never make it due). A
1410
1429
  row-less artifact may be in use: two vx versions share one store, and
@@ -1437,39 +1456,29 @@ hit replays it with pure SQL, never decompressing the artifact).
1437
1456
  `SCHEMA_VERSION`, earlier or newer, is reset by the first run that opens
1438
1457
  it (pre-alpha: no migrations; the index is an inventory, owner
1439
1458
  2026-10-06): every table but `schema_meta` is dropped and recreated,
1440
- so each comes back in its current shape (A-54: `config_closures` and
1441
- `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
1442
1461
  and stamp are one write transaction, so another version's open waits
1443
1462
  rather than landing between them; an index already current is opened
1444
1463
  without the lock.
1445
1464
  A reading verb (`vx why`, `vx last`, `vx info`) leaves it untouched and
1446
- 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
1447
1466
  instead, item 1083).
1448
1467
  That open prints nothing (owner, 2026-10-06: the cache is vx's to
1449
1468
  keep). The artifacts stay: each is indexed again from its own bytes
1450
1469
  when its task next asks for its key (below).
1451
1470
 
1452
1471
  ```sql
1453
- -- src/cache/schema.ts (SCHEMA_VERSION = 'v34', in cache.ts)
1454
- -- With a shared store, entries, entry_stdout, output_files, entry_inputs
1455
- -- 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
1456
1475
  -- the workspace's cache.db. A named cache dir holds all of them.
1457
1476
 
1458
1477
  CREATE TABLE schema_meta (
1459
- 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'
1460
1479
  value TEXT NOT NULL
1461
1480
  );
1462
1481
 
1463
- -- The config-evaluation cache (§ Config evaluation cache): the validated,
1464
- -- JSON-serialised result of a provably pure config, keyed by everything
1465
- -- the evaluation could have observed. Machine-local. Rows not written in
1466
- -- 30 days are swept at most once a day (`config_evals_swept_at`).
1467
- CREATE TABLE config_evals (
1468
- key TEXT PRIMARY KEY,
1469
- json TEXT NOT NULL,
1470
- created_at INTEGER NOT NULL
1471
- );
1472
-
1473
1482
  CREATE TABLE entries (
1474
1483
  hash TEXT PRIMARY KEY, -- the 16-hex xxh3 cache key
1475
1484
  project TEXT NOT NULL,
@@ -1550,7 +1559,7 @@ CREATE INDEX runs_failed ON runs(hash) WHERE status = 'failed';
1550
1559
 
1551
1560
  -- The file-hash memo: a content hash per input file that git could not
1552
1561
  -- answer (untracked or dirty), keyed by the stat identity that proves
1553
- -- the bytes unchanged. Machine-local; § Cache key derivation step 12.
1562
+ -- the bytes unchanged. Machine-local; § Cache key derivation step 10.
1554
1563
  -- A writing close drops rows unwritten for 30 days, at most once a day
1555
1564
  -- (a memo miss is the whole cost of a dropped row; item 1082).
1556
1565
  CREATE TABLE file_hashes (
@@ -1608,15 +1617,6 @@ CREATE TABLE output_stamps (
1608
1617
  PRIMARY KEY (entry_hash, path)
1609
1618
  );
1610
1619
 
1611
- -- Each config's ORDERED import closure (the config first), so a warm
1612
- -- load keys it by stat-hashing the list through file_hashes instead of
1613
- -- reading every file. Machine-local; pruned with config_evals.
1614
- CREATE TABLE config_closures (
1615
- config_path TEXT PRIMARY KEY,
1616
- files_json TEXT NOT NULL,
1617
- created_at INTEGER NOT NULL
1618
- );
1619
-
1620
1620
  -- Every directory under a whole-subtree output glob, with its mtime as
1621
1621
  -- of the last save or restore on THIS machine: unchanged mtimes prove
1622
1622
  -- the output SET unchanged without a glob walk. Machine-local — a remote
@@ -1693,6 +1693,20 @@ CREATE TABLE store_meta (
1693
1693
  value TEXT NOT NULL
1694
1694
  );
1695
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
+
1696
1710
  -- v33: an artifact of at most 32 KiB compressed, its exact <hash>.tar.zst
1697
1711
  -- bytes, written in the transaction that writes its entry rows. In the
1698
1712
  -- store (or a named cache dir's cache.db), and outside every drop a
@@ -1749,28 +1763,6 @@ moat.
1749
1763
  - **One handle, one schema-meta sentinel.** An older schema drops
1750
1764
  and recreates every table but `schema_meta` (pre-alpha) — there's no migration code to maintain.
1751
1765
 
1752
- ## Config evaluation cache
1753
-
1754
- Evaluating configs is the largest fixed cost of a warm run on a big
1755
- workspace (~80 ms for 1000 synthetic configs; reading them as data is
1756
- ~12 ms). A config that is **provably pure** — every import relative or
1757
- `@vzn/vx`, and no mention of `process`, `Bun`, `Date`, `fetch`,
1758
- `import.meta`, `require`, a dynamic `import()`, `await`, … outside string
1759
- literals and comments — is served from `cache.db`'s `config_evals` table,
1760
- keyed by the git blob id of the config and of every file in its
1761
- relative import closure, the workspace fingerprint (lockfiles) and Bun's
1762
- version. Editing a shared preset moves the key. On a warm run the
1763
- closure is remembered per config, so the key comes from a stat-backed
1764
- identity per file — no read, no scan — for every config whose relative
1765
- imports name their files outright: an explicit extension, the file
1766
- itself, no symlink on the way (an extensionless import, a `.js` Bun
1767
- answers with a `.ts`, or a link could be re-resolved without touching a
1768
- listed file, so such a config keeps the scan). Anything the static check cannot prove
1769
- pure evaluates live, exactly as before, so the cache can be slower but
1770
- not wrong for a config written in good faith (the check is syntactic; one
1771
- built to defeat it can). Details and the deny-list:
1772
- [`modules/config-cache.md`](./modules/config-cache.md).
1773
-
1774
1766
  ## Performance characteristics
1775
1767
 
1776
1768
  - **Hashing cost** on a clean tree is near-zero per file: git index
@@ -1789,15 +1781,16 @@ built to defeat it can). Details and the deny-list:
1789
1781
  transaction, which holds a small artifact's bytes itself; a large one
1790
1782
  adds a temp write and an atomic rename. Hashing dominates the run; storage itself
1791
1783
  is cheap. The remote upload (if any) is backgrounded.
1792
- - **Workspace fingerprint** is computed once per `vx run` invocation
1793
- and reused for every task in that run; after a task that may rewrite
1794
- 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.
1795
1788
 
1796
1789
  ## What's NOT in the key (and why)
1797
1790
 
1798
1791
  - **`exec.env.passThrough` _values_.** Would force cache misses across
1799
1792
  machines with different CI flags, regions, or shell prompts. The
1800
- _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
1801
1794
  passthrough still bumps the key for affected tasks.
1802
1795
  - **Files outside the project directory that aren't declared.**
1803
1796
  Workspace-root configs (`tsconfig.base.json`, etc.) are not
@@ -1807,11 +1800,13 @@ built to defeat it can). Details and the deny-list:
1807
1800
  The canonical mechanism is `cache.inputs.runtime` /
1808
1801
  `workspaceRuntime` (e.g. `workspaceRuntime: ['node -v']`): the
1809
1802
  command output is resolved live at hash time, so it stays correct
1810
- under `--frozen`. Avoid baking versions via
1803
+ under the lock. Avoid baking versions via
1811
1804
  `define: { X: execSync(...) }` — that value freezes into the config
1812
1805
  object at lock time and goes stale.
1813
- - **`vx-lock.json`** — globally excluded (v24); also filtered out of
1814
- `--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`.
1815
1810
  - **`exec.remote`.** Pure PLACEMENT: it decides _where_ a task runs,
1816
1811
  never what it produces, so pinning a task to this machine does not
1817
1812
  bust its cache. It must not — the
@@ -1833,8 +1828,8 @@ The cache records the version it was written under
1833
1828
  (`schema_meta.cache_version`). After an upgrade every cached task
1834
1829
  misses once and re-saves, with nothing printed (owner, 2026-10-06: the
1835
1830
  cache is vx's to keep). The index survives, so the old entries stay
1836
- until they age out under `vx cache prune --older-than` or
1837
- `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.
1838
1833
 
1839
1834
  Required when:
1840
1835
 
@@ -1854,6 +1849,9 @@ Not required when:
1854
1849
  background uploads) — key derivation and artifact bytes untouched.
1855
1850
  - Doc-only updates.
1856
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).
1857
1855
 
1858
1856
  `tests/contract-stored-format.test.ts` holds the layout rows: it
1859
1857
  records the index's DDL beside `SCHEMA_VERSION` and a fixture
@@ -1878,6 +1876,14 @@ breaking footer).
1878
1876
 
1879
1877
  ### History
1880
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.
1881
1887
  - **v41 → v42**: the stored output became both streams in the order
1882
1888
  the run printed them (`orchestrator/output-log.ts`), so a hit replays
1883
1889
  stderr too. A v41 entry holds stdout alone.
@@ -2076,7 +2082,7 @@ breaking footer).
2076
2082
  artifact is gone or cannot produce an output the index recorded (the
2077
2083
  caller has already wiped the declared outputs by then, so a quiet
2078
2084
  return reported a green hit over an emptied tree — reachable via a
2079
- concurrent `vx cache prune`); the tar reader rejects an entry whose
2085
+ concurrent `vx cache clean`); the tar reader rejects an entry whose
2080
2086
  declared size runs past the end of the archive (`subarray` clamps, so
2081
2087
  it used to install short, NUL-padded content as a cache hit instead of
2082
2088
  degrading to a miss); and directory entries now get the same
@@ -2120,7 +2126,7 @@ breaking footer).
2120
2126
  so existing artifacts stay valid and `CACHE_VERSION` stays `v24`. The
2121
2127
  schema gate drops + recreates on the version mismatch (pre-alpha, no
2122
2128
  migration).
2123
- - **v23 → v24**: exclude `vx-lock.json` from the input file set
2129
+ - **v23 → v24**: exclude `.vx/projects.lock.json` from the input file set
2124
2130
  globally (`ALWAYS_IGNORE` in `cache/inputs.ts`). The lockfile is
2125
2131
  committed, so git enumerates it, but it's vx's own frozen-config
2126
2132
  metadata — never a task input. Without this, a `vx lock` re-write
@@ -2131,7 +2137,7 @@ breaking footer).
2131
2137
 
2132
2138
  - **v22 → v23**: fold `cache.inputs.runtime` / `workspaceRuntime`
2133
2139
  command output into the key (two namespaced sections after
2134
- 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);
2135
2141
  their combined trimmed stdout+stderr is resolved live at hash time
2136
2142
  and folded as `runtime-values:` (project-cwd) and `ws-runtime-values:`
2137
2143
  (root-cwd) sections, each `command\0output`. No SCHEMA bump — only
@@ -2164,7 +2170,7 @@ breaking footer).
2164
2170
  - **v9 → v10** (PR #7): on-disk layout switched from per-entry
2165
2171
  `meta.json` + `outputs/` directory to a workspace-wide `cache.db`
2166
2172
  (SQLite) plus output files directly at `<hash>/` and log files at
2167
- `logs/<hash>.{stdout,stderr}`. Adds run history for `vx stats`.
2173
+ `logs/<hash>.{stdout,stderr}`. Adds run history for `vx info`.
2168
2174
  Removes the per-entry manifest.
2169
2175
  - **v10 → v11** (PR #19): analytics columns added to the `runs`
2170
2176
  table: `run_id` (UUIDv7), `cpu_ms`, `peak_rss_bytes`,
@@ -2234,7 +2240,7 @@ breaking footer).
2234
2240
  transitive-deps to nearest-holder frontier semantics (Turbo/Nx
2235
2241
  direct-deps parity, plus vx's sparse bridging through deps that
2236
2242
  don't declare the task). Task graphs lose the redundant deep edges,
2237
- 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
2238
2244
  whose deps chain `'^task'` themselves — same inputs now derive a
2239
2245
  different key. Reachability/ordering is unchanged whenever holders
2240
2246
  chain `'^task'` (the universal pattern); a holder that doesn't is