@vzn/vx 0.0.636 → 0.0.638

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 (151) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +246 -213
  3. package/docs/cli.md +791 -705
  4. package/docs/execution.md +68 -76
  5. package/docs/features.md +47 -42
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +392 -205
  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 +4 -4
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +28 -13
  19. package/src/bin.ts +30 -9
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +124 -53
  22. package/src/cache/chained-cache.ts +4 -0
  23. package/src/cache/git-inputs.ts +277 -63
  24. package/src/cache/index.ts +15 -1
  25. package/src/cache/inputs.ts +102 -36
  26. package/src/cache/key-fold.ts +8 -7
  27. package/src/cache/layer.ts +15 -18
  28. package/src/cache/layered-cache.ts +20 -102
  29. package/src/cache/plugin-cache.ts +248 -0
  30. package/src/cache/policy.ts +32 -27
  31. package/src/cache/run-history.ts +104 -14
  32. package/src/cache/run-store.ts +77 -0
  33. package/src/cache/schema.ts +58 -31
  34. package/src/cache/scoped-cache.ts +237 -0
  35. package/src/cli/cache.ts +173 -34
  36. package/src/cli/completions.ts +11 -7
  37. package/src/cli/core-alias.ts +1 -1
  38. package/src/cli/foreign-flags.ts +13 -12
  39. package/src/cli/help.ts +110 -70
  40. package/src/cli/index.ts +67 -24
  41. package/src/cli/info.ts +43 -27
  42. package/src/cli/init.ts +115 -407
  43. package/src/cli/last.ts +157 -9
  44. package/src/cli/lock.ts +356 -122
  45. package/src/cli/plan-format.ts +7 -5
  46. package/src/cli/plugin-commands.ts +81 -14
  47. package/src/cli/plugin-templates.ts +11 -11
  48. package/src/cli/run.ts +116 -160
  49. package/src/cli/select.ts +50 -47
  50. package/src/cli/show.ts +83 -59
  51. package/src/cli/task-verb.ts +6 -13
  52. package/src/cli/upgrade.ts +415 -149
  53. package/src/cli/watch-cycle.ts +9 -2
  54. package/src/cli/watch-filter.ts +39 -61
  55. package/src/cli/watch-fs.ts +29 -16
  56. package/src/cli/watch-judge.ts +18 -4
  57. package/src/cli/watch-set.ts +16 -42
  58. package/src/cli/watch.ts +227 -230
  59. package/src/cli/why.ts +38 -18
  60. package/src/cli/workspace-config.ts +33 -70
  61. package/src/config.ts +169 -70
  62. package/src/exec/env.ts +1 -31
  63. package/src/exec/executor.ts +1 -5
  64. package/src/exec/kill-tree.ts +2 -1
  65. package/src/exec/sandbox-runtime.ts +12 -1
  66. package/src/exec/sandbox-violations.ts +9 -1
  67. package/src/graph/dependency-spec.ts +1 -1
  68. package/src/graph/index.ts +1 -0
  69. package/src/graph/priorities.ts +1 -1
  70. package/src/graph/scheduler.ts +27 -7
  71. package/src/graph/task-graph.ts +76 -24
  72. package/src/handoff.ts +169 -0
  73. package/src/index.ts +35 -58
  74. package/src/orchestrator/affected-tasks.ts +15 -8
  75. package/src/orchestrator/command-windows.ts +44 -0
  76. package/src/orchestrator/deferred-outputs.ts +12 -10
  77. package/src/orchestrator/doctor.ts +98 -110
  78. package/src/orchestrator/download-policy.ts +3 -3
  79. package/src/orchestrator/events.ts +19 -3
  80. package/src/orchestrator/excluded-keys.ts +0 -2
  81. package/src/orchestrator/execute-task.ts +147 -69
  82. package/src/orchestrator/failure-mode.ts +19 -10
  83. package/src/orchestrator/fingerprint-watch.ts +18 -15
  84. package/src/orchestrator/forecast.ts +10 -2
  85. package/src/orchestrator/framed-output.ts +9 -5
  86. package/src/orchestrator/graph-stage.ts +5 -5
  87. package/src/orchestrator/history.ts +126 -30
  88. package/src/orchestrator/hit-restore.ts +15 -3
  89. package/src/orchestrator/index.ts +20 -5
  90. package/src/orchestrator/local-shortcircuit.ts +2 -1
  91. package/src/orchestrator/lockfile-claim.ts +79 -20
  92. package/src/orchestrator/logger.ts +80 -41
  93. package/src/orchestrator/metrics.ts +78 -9
  94. package/src/orchestrator/miss-save.ts +3 -0
  95. package/src/orchestrator/options.ts +42 -29
  96. package/src/orchestrator/output-host.ts +72 -0
  97. package/src/orchestrator/plan.ts +6 -8
  98. package/src/orchestrator/plugin-host.ts +100 -66
  99. package/src/orchestrator/plugin.ts +173 -67
  100. package/src/orchestrator/prepare.ts +163 -78
  101. package/src/orchestrator/projects.ts +105 -99
  102. package/src/orchestrator/remote-prefetch.ts +11 -6
  103. package/src/orchestrator/run-context.ts +25 -6
  104. package/src/orchestrator/run-failures.ts +17 -8
  105. package/src/orchestrator/run-lock.ts +1 -1
  106. package/src/orchestrator/run-records.ts +1 -1
  107. package/src/orchestrator/run-report.ts +9 -9
  108. package/src/orchestrator/run-settings.ts +48 -0
  109. package/src/orchestrator/run.ts +244 -119
  110. package/src/orchestrator/sandbox-fix.ts +39 -20
  111. package/src/orchestrator/sandbox-request.ts +84 -13
  112. package/src/orchestrator/signals.ts +1 -1
  113. package/src/orchestrator/stable-keys.ts +13 -11
  114. package/src/orchestrator/status-line.ts +49 -7
  115. package/src/orchestrator/summary.ts +115 -106
  116. package/src/orchestrator/task-hash.ts +12 -83
  117. package/src/orchestrator/telemetry.ts +4 -4
  118. package/src/settings.ts +428 -0
  119. package/src/{orchestrator → util}/colors.ts +5 -0
  120. package/src/util/config-frame.ts +31 -2
  121. package/src/util/errors.ts +33 -1
  122. package/src/util/essential-env.ts +31 -0
  123. package/src/util/hide-env.ts +107 -0
  124. package/src/util/index.ts +16 -3
  125. package/src/util/paths.ts +10 -0
  126. package/src/util/refusal-notice.ts +111 -0
  127. package/src/util/report.ts +31 -0
  128. package/src/util/settle.ts +49 -30
  129. package/src/util/size.ts +23 -4
  130. package/src/util/timing.ts +3 -2
  131. package/src/util/ui.ts +186 -0
  132. package/src/util/ulid.ts +1 -1
  133. package/src/util/verbs.ts +0 -15
  134. package/src/workspace/affected.ts +84 -232
  135. package/src/workspace/config-eval.ts +15 -20
  136. package/src/workspace/config-imports.ts +78 -3
  137. package/src/workspace/config-schema.ts +209 -268
  138. package/src/workspace/dependency-rules.ts +65 -0
  139. package/src/workspace/filter.ts +2 -2
  140. package/src/workspace/fingerprint.ts +11 -121
  141. package/src/workspace/index.ts +20 -27
  142. package/src/workspace/load-reads.ts +4 -4
  143. package/src/workspace/lockfile.ts +96 -140
  144. package/src/workspace/migration.ts +307 -215
  145. package/src/workspace/package-graph.ts +30 -216
  146. package/src/workspace/project-loader.ts +80 -285
  147. package/src/workspace/repo-id.ts +2 -1
  148. package/src/workspace/workspace.ts +363 -831
  149. package/src/cache/config-evals.ts +0 -130
  150. package/src/workspace/config-cache.ts +0 -661
  151. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -13,11 +13,19 @@ import { beginRun, mark, nearest, UserError } from '../util/index.js'
13
13
  import {
14
14
  Cache,
15
15
  type CacheLayer,
16
+ applyCacheFlags,
16
17
  type CachePolicy,
17
18
  FULL_CACHE_POLICY,
18
- scopeCachePolicy,
19
+ parseCachePolicy,
19
20
  GitFilesCache,
20
21
  LayeredCache,
22
+ absentPluginCache,
23
+ pluginCache,
24
+ putRunEntry,
25
+ writesRunStore,
26
+ type PluginCache,
27
+ type PluginCacheRemote,
28
+ ScopedCache,
21
29
  applyGitEnumeration,
22
30
  gitPathspecs,
23
31
  MAX_SCOPED_PATHSPECS,
@@ -27,15 +35,11 @@ import {
27
35
  import {
28
36
  buildPackageGraph,
29
37
  computeNestedProjectDirs,
30
- computeWorkspaceFingerprints,
31
38
  findWorkspaceRoot,
32
39
  cacheScopeEnvError,
33
40
  isCacheScope,
34
41
  type LoadReads,
35
- loadWorkspace,
36
- FROZEN_WITHOUT_LOCK,
37
- type Lockfile,
38
- readLockfile,
42
+ readClaimedFiles,
39
43
  resolveCacheDir,
40
44
  resolveStoreRoot,
41
45
  type ProjectEntry,
@@ -44,6 +48,7 @@ import {
44
48
  buildTaskGraph,
45
49
  excludeDependencies,
46
50
  expandRequested,
51
+ type GraphRules,
47
52
  isGroupTask,
48
53
  type TaskNode,
49
54
  undeclaredDepsError,
@@ -64,6 +69,8 @@ import {
64
69
  isDefaultBuild,
65
70
  loadProjects,
66
71
  loadWorkspacePlugins,
72
+ lockOfDiscovery,
73
+ lockReader,
67
74
  type LoadedProjects,
68
75
  } from './projects.js'
69
76
  import { keptByAffected } from './affected-tasks.js'
@@ -71,10 +78,12 @@ import { applyGraphStage } from './graph-stage.js'
71
78
  import { keyExcludedDependencies } from './excluded-keys.js'
72
79
  import { FingerprintWatch } from './fingerprint-watch.js'
73
80
  import type { VxPlugin } from './plugin.js'
81
+ import { currentBranch } from './run-context.js'
74
82
  import { createHashCache, type HashCache } from './task-hash.js'
75
83
  import type { Logger } from './logger.js'
76
84
  import type { RunOptions } from './options.js'
77
85
  import { refuseFiltersThroughGroups } from './upstream.js'
86
+ import { runRules } from '../settings.js'
78
87
 
79
88
  export interface PreparedRun {
80
89
  workspaceRoot: string
@@ -97,7 +106,7 @@ export interface PreparedRun {
97
106
  * would be saved still cleans its outputs before executing.
98
107
  */
99
108
  hasRemoteLayer: boolean
100
- /** `--cache` with the workspace's `cacheScope` applied: what the layers were built under. */
109
+ /** `--cache-policy` with the workspace's `cacheScope` applied: what the layers were built under. */
101
110
  cachePolicy: CachePolicy
102
111
  /**
103
112
  * Scheduling priorities from plugins' `schedule` stage (task id → weight,
@@ -107,6 +116,19 @@ export interface PreparedRun {
107
116
  * nothing-changed run never dispatches. Runs the stage once.
108
117
  */
109
118
  priorities: () => Promise<ReadonlyMap<string, number>>
119
+ /**
120
+ * The run's branch (`run.branch`, else git's, else the CI provider's):
121
+ * what its history records and the timings a forecast reads first.
122
+ * Learned on first ask, once per run.
123
+ */
124
+ branch: () => string | null
125
+ /** Each plugin's `ctx.cache`: the run's local store and remotes, under its policy and scope. */
126
+ cacheFor: (plugin: string) => PluginCache
127
+ /**
128
+ * Keep `bytes` of this run under `name` (run-store.ts); undefined when no
129
+ * remote takes the run's writes, and so core keeps nothing.
130
+ */
131
+ keepRunEntry: ((name: string, bytes: Uint8Array) => Promise<boolean>) | undefined
110
132
  nodes: Map<string, TaskNode>
111
133
  /**
112
134
  * The tasks `--exclude-dependencies` took out of the schedule: keyed as a
@@ -135,8 +157,7 @@ export interface PreparedRun {
135
157
  * mistaken for a workspace vx was never set up in.
136
158
  */
137
159
  anyProjectConfig: boolean
138
- workspaceFingerprint: string
139
- /** Whether a task has since rewritten what that fingerprint folded (fingerprint-watch.ts). */
160
+ /** The claimed root files, and whether a task has since rewritten one (fingerprint-watch.ts). */
140
161
  fingerprintWatch: FingerprintWatch
141
162
  nestedDirsByProject: Map<string, string[]>
142
163
  /**
@@ -149,11 +170,9 @@ export interface PreparedRun {
149
170
  /** Every project discovery found, in or out of scope — header scope bar. */
150
171
  workspaceProjectCount: number
151
172
  /**
152
- * Per-run memo for derived hashes — project package.json bytes
153
- * keyed by projectDir, task-config hash keyed by config object
154
- * identity. Shared across every task's `computeTaskHash` call so
155
- * the same project's package.json (and the same task config
156
- * object) aren't re-hashed on every task in that project.
173
+ * Per-run memo for derived hashes — task-config hash keyed by config
174
+ * object identity, resolved file lists. Shared across every task's
175
+ * `computeTaskHash` call so the same config object isn't re-hashed.
157
176
  */
158
177
  hashCache: HashCache
159
178
  /**
@@ -188,22 +207,19 @@ export interface PreparedRun {
188
207
  export async function prepareRun(options: RunOptions, log: Logger): Promise<PreparedRun> {
189
208
  beginRun()
190
209
  mark('startup')
191
- // The root manifest is read once for the root, the globs and the
192
- // fingerprint. A watch cycle is a new run and reads it afresh.
210
+ // A claimed root file is read once. A watch cycle is a new run and reads
211
+ // them afresh.
193
212
  const reads: LoadReads = new Map()
194
- const workspaceRoot = await findWorkspaceRoot(options.cwd, reads)
195
- // A frozen run with nothing staged reads the lock whatever discovery
196
- // finds, so the read (1.1 MB at 1,000 projects, ~10 ms to read and
197
- // parse) starts here and overlaps discovery's I/O instead of following
198
- // the cache open (X-181). A missing or malformed lock still refuses at
199
- // the config load, after any earlier refusal.
200
- let lockRead: Promise<Lockfile> | undefined
201
- const readLock = (): Promise<Lockfile> =>
202
- (lockRead ??= readLockfile(workspaceRoot).then((read) => {
203
- if (read === null) throw new UserError(FROZEN_WITHOUT_LOCK)
204
- return read
205
- }))
206
- if (options.frozen === true && options.staged === undefined) void readLock().catch(() => {})
213
+ const workspaceRoot = await findWorkspaceRoot(options.cwd)
214
+ // The lock is where a run's projects come from, so the read (1.1 MB at
215
+ // 1,000 projects, ~10 ms to read and parse) starts here and overlaps the
216
+ // workspace config's evaluation (X-181). A missing or malformed lock
217
+ // refuses at discovery, after any earlier refusal.
218
+ const reused = options.discovered?.root === workspaceRoot ? options.discovered : undefined
219
+ const readLock =
220
+ (reused !== undefined ? lockOfDiscovery(reused.projects) : undefined) ??
221
+ lockReader(workspaceRoot)
222
+ if (reused === undefined) void readLock().catch(() => {})
207
223
  // An UNSCOPED run (no explicit scope, at least one bare task name)
208
224
  // enumerates the whole tree whatever the configs say, so git starts
209
225
  // HERE — the walk needs only the root — and overlaps the workspace
@@ -221,18 +237,21 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
221
237
  // whole-tree enumeration a scoped run would otherwise scope later, here or
222
238
  // in the CLI's selection pass; the run reuses it instead of walking the
223
239
  // tree twice (G-75).
224
- const reused = options.discovered?.root === workspaceRoot ? options.discovered : undefined
225
240
  const git =
226
241
  (reused !== undefined ? gitOfDiscovery(reused.projects) : undefined) ??
227
242
  lazyGitEnumeration(workspaceRoot)
228
243
  if (wholeTree) void git.start()
229
- const workspace = await loadWorkspace(workspaceRoot, reads)
230
244
  const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, (m) =>
231
245
  log.status(m),
232
246
  )
233
247
  mark('workspace config')
248
+ let learnedBranch: string | null | undefined
249
+ const branch = (): string | null =>
250
+ learnedBranch === undefined
251
+ ? (learnedBranch = currentBranch(workspaceRoot, workspaceConfig, options.branch))
252
+ : learnedBranch
234
253
  // `--cache-dir <path>` (RunOptions.cacheDir) overrides the workspace
235
- // `cacheDir` field + the `.vx/cache` default; resolved relative to cwd.
254
+ // `cache.dir` + the `.vx/cache` default; resolved relative to cwd.
236
255
  // A cache dir named anywhere holds everything; the default shares its
237
256
  // entries through the user's store.
238
257
  const cacheDir = options.cacheDir
@@ -244,7 +263,14 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
244
263
  const projectMetas =
245
264
  reused !== undefined
246
265
  ? reused.projects
247
- : await discoverProjects(workspace, plugins, cacheDir, (m) => log.status(m), git)
266
+ : await discoverProjects(
267
+ workspaceRoot,
268
+ readLock,
269
+ plugins,
270
+ cacheDir,
271
+ (m) => log.status(m),
272
+ git,
273
+ )
248
274
  mark('discover projects')
249
275
 
250
276
  // SCOPED config loading: configs are programs, and evaluating 1090
@@ -264,7 +290,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
264
290
  // anchors alone are the scope and nothing else needs its config
265
291
  // evaluated.
266
292
  // `vx run //#lint` is Turbo's `turbo run //#lint`: the root project's
267
- // task, the root named by its package.json name here (D-39).
293
+ // task, under the name it has when something names it (D-39).
268
294
  const root = path.resolve(workspaceRoot)
269
295
  const rootName = projectMetas.find((m) => path.resolve(m.dir) === root)?.name
270
296
  const tasks =
@@ -282,20 +308,26 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
282
308
  options.projects !== undefined ? [...options.projects, ...anchored] : hasBare ? 'all' : anchored
283
309
  // Its own mark: the package graph is the bulk of what used to be charged
284
310
  // to `open cache` (measured on a 1,000-project tree, 2026-09-20 — the
285
- // graph 3-8 ms against the cache open's ~1 ms and the fingerprints' ~0.3),
311
+ // graph 3-8 ms against the cache open's ~1 ms and the root files' ~0.3),
286
312
  // so a reader profiling that stage was sent to the wrong code.
287
313
  mark('package graph')
288
314
 
289
- // The local cache opens BEFORE the configs load: it is also where their
290
- // cached evaluations live.
291
315
  // `--cache-scope`, then `VX_CACHE_SCOPE`, win over the workspace (and `github()`'s guess):
292
316
  // a CI job that knows its trust level says so without editing config.
293
317
  const envScope = process.env['VX_CACHE_SCOPE'] || undefined
294
318
  if (envScope !== undefined && !isCacheScope(envScope)) throw cacheScopeEnvError()
295
- const policy: CachePolicy = scopeCachePolicy(
296
- options.cache ?? FULL_CACHE_POLICY,
297
- options.cacheScope ?? envScope ?? workspaceConfig?.cacheScope ?? options.defaultCacheScope,
298
- )
319
+ const workspacePolicy = workspaceCachePolicy(workspaceConfig)
320
+ const cacheScope = options.cacheScope ?? envScope ?? workspaceConfig?.cache?.scope
321
+ const asked =
322
+ options.cache ?? applyCacheFlags(workspacePolicy ?? FULL_CACHE_POLICY, options.cacheFlags)
323
+ // A laptop reads the shared cache and does not write it unless a policy
324
+ // names the remote (owner, 2026-10-03; Security F1, 2026-10-14): a key
325
+ // holds only declared inputs, so a laptop's write could be replayed on CI.
326
+ // A workspace policy naming the remote asks to write it, as `--cache-policy` does.
327
+ const readOnly =
328
+ options.remoteReadOnlyByDefault === true &&
329
+ !(options.cache === undefined && namesRemote(workspaceConfig?.cache?.policy))
330
+ const policy: CachePolicy = readOnly ? { ...asked, remoteWrite: false } : asked
299
331
  const localCache = new Cache(
300
332
  cacheDir,
301
333
  { read: policy.localRead, write: policy.localWrite },
@@ -305,29 +337,20 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
305
337
  await storeRoot,
306
338
  )
307
339
  localCache.assertWritable()
308
- // Two digests from one read: the config-evaluation cache keys on every
309
- // file (a config may import a dependency), the task keys on the files no
310
- // plugin claims (`VxPlugin.fingerprint`).
340
+ // The root files the plugins claim (`VxPlugin.fingerprint`), whose bytes
341
+ // their claimants fold: read now so a task that rewrites one is caught.
311
342
  const fingerprintsAt = Date.now()
312
- const fingerprints = await computeWorkspaceFingerprints(
313
- workspaceRoot,
314
- new Set(fingerprintClaims(plugins).keys()),
315
- reads,
316
- )
317
- const workspaceFingerprint = fingerprints.unclaimed
343
+ const claimed = [...fingerprintClaims(plugins).keys()]
344
+ const claimedBytes = await readClaimedFiles(workspaceRoot, claimed, reads)
318
345
  mark('open cache')
319
346
 
320
- // Frozen mode (--frozen, CI): configs load FROM vx-lock.json with no
321
- // evaluation and no check of the config bytes (`vx lock --check`
322
- // compares `configHash`; a run does not); env-dependent configs keep
323
- // their locked values. Default (local) runs ALWAYS evaluate live:
324
- // a byte hash can't see a config's import closure (shared presets),
325
- // so consuming the lock by default would silently serve stale
326
- // freezes. `vx lock --check` is the full re-evaluation audit.
347
+ // Configs load FROM .vx/projects.lock.json with no evaluation and no check of
348
+ // the configs (`vx lock --check` re-evaluates them; a run does not):
349
+ // only `vx lock` evaluates (owner, 2026-10-14).
327
350
  // See docs/design/config-lock-2026-06.md.
328
351
  // Read once, and only when a config is to be read from it: the CLI's
329
352
  // selection pass may have staged every config (`options.staged`), and
330
- // its load refused a frozen run without a lock. A 1,000-project lock is
353
+ // its load refused a run without a lock. A 1,000-project lock is
331
354
  // 1.1 MB of JSON (I-27); a run with nothing staged started its read
332
355
  // beside discovery, above.
333
356
  const staged = options.staged
@@ -336,8 +359,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
336
359
  projectMetas.every(
337
360
  (m) => typeof m.configPath !== 'string' || m.configPath === '' || staged.has(m.name),
338
361
  )
339
- if (options.frozen === true && !allStaged) await readLock()
340
- const lock = options.frozen === true ? readLock : null
362
+ if (!allStaged) await readLock()
341
363
 
342
364
  const loadArgs = {
343
365
  workspaceRoot,
@@ -345,8 +367,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
345
367
  plugins,
346
368
  projectMetas,
347
369
  packageGraph,
348
- lock,
349
- evalCache: { store: localCache, workspaceRoot, workspaceFingerprint: fingerprints.all },
370
+ configs: readLock,
350
371
  warn: (m: string) => log.status(m),
351
372
  }
352
373
  let loaded: LoadedProjects
@@ -358,8 +379,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
358
379
  ...(options.staged !== undefined ? { staged: options.staged } : {}),
359
380
  })
360
381
  } catch (err) {
361
- // The cache opened before the configs loaded (it holds their cached
362
- // evaluations); a config error must not leak the handle.
382
+ // The cache opened before the configs loaded; a config error must not
383
+ // leak the handle.
363
384
  localCache.close()
364
385
  throw err
365
386
  }
@@ -443,6 +464,11 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
443
464
  // winning prevents double-wrapping when the workspace also declares a
444
465
  // cache plugin.
445
466
  let cache: CacheLayer
467
+ const remotes: PluginCacheRemote[] = []
468
+ if (options.remoteCache) {
469
+ const { remoteCache } = options
470
+ remotes.push({ remote: remoteCache, read: policy.remoteRead, write: policy.remoteWrite })
471
+ }
446
472
  try {
447
473
  cache = options.remoteCache
448
474
  ? new LayeredCache(localCache, options.remoteCache, {
@@ -453,8 +479,10 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
453
479
  workspaceRoot,
454
480
  cacheDir,
455
481
  warn: (m) => log.status(m),
482
+ scope: cacheScope,
456
483
  localCache,
457
484
  policy,
485
+ remotes,
458
486
  })
459
487
  } catch (err) {
460
488
  // A cache plugin that throws or returns something off-contract is
@@ -465,6 +493,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
465
493
  localCache.close()
466
494
  throw err
467
495
  }
496
+ // One scoping rule above every layer, local and remote alike.
497
+ if (cacheScope !== undefined) cache = new ScopedCache(cache, cacheScope)
468
498
  // Ask the LAYER, don't infer. Identity against `localCache` answers a
469
499
  // DIFFERENT question — "did the plugin hand back something other than the
470
500
  // handle I passed in?" — which an ordinary pass-through decorator (a
@@ -480,9 +510,14 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
480
510
  // returns.
481
511
  try {
482
512
  const hashCache = createHashCache()
483
- const fingerprintWatch = new FingerprintWatch(workspaceRoot, fingerprints, fingerprintsAt)
513
+ const fingerprintWatch = new FingerprintWatch(
514
+ workspaceRoot,
515
+ claimed,
516
+ claimedBytes,
517
+ fingerprintsAt,
518
+ )
484
519
 
485
- // Empty-cases bookkeeping. We still construct the cache + fingerprint
520
+ // Empty-cases bookkeeping. We still construct the cache + watch
486
521
  // so the caller's try/finally pattern can close it uniformly.
487
522
  const emptyRun = (reason?: 'none-affected'): PreparedRun => ({
488
523
  workspaceRoot,
@@ -493,7 +528,10 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
493
528
  localCache,
494
529
  hasRemoteLayer,
495
530
  cachePolicy: policy,
531
+ keepRunEntry: undefined,
496
532
  priorities: () => Promise.resolve(new Map()),
533
+ branch,
534
+ cacheFor: () => absentPluginCache,
497
535
  nodes: new Map(),
498
536
  keyOnly: new Map(),
499
537
  unresolvedTasks,
@@ -501,7 +539,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
501
539
  projects,
502
540
  hintProjects,
503
541
  anyProjectConfig: projectsWithConfigs.length > 0,
504
- workspaceFingerprint,
505
542
  fingerprintWatch,
506
543
  nestedDirsByProject,
507
544
  gitFilesCache: new GitFilesCache(),
@@ -521,6 +558,10 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
521
558
  // the whole workspace: a `^name` nothing loaded declares may be declared
522
559
  // by a config the scope left out, so the builder hands it back instead of
523
560
  // refusing it, and the rest decide.
561
+ // A run never checks the graph rules: `vx lock` and `vx lock --check`
562
+ // do (owner, 2026-10-14). `exclusiveOutputs: false` still asks the walk
563
+ // for its addition marks.
564
+ const graphRules: GraphRules = { ...runRules(workspaceConfig), locked: true }
524
565
  const unproven: Array<[taskId: string, name: string]> = []
525
566
  const graphOf = (req: typeof requested) =>
526
567
  buildTaskGraph({
@@ -528,7 +569,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
528
569
  packageGraph,
529
570
  requested: req,
530
571
  workspaceRoot,
531
- rules: workspaceConfig?.rules,
572
+ rules: graphRules,
532
573
  ...(partialLoad
533
574
  ? { undeclaredDeps: (id: string, name: string) => void unproven.push([id, name]) }
534
575
  : {}),
@@ -541,7 +582,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
541
582
  cacheDir,
542
583
  projectMetas,
543
584
  warn: (m) => log.status(m),
544
- rules: workspaceConfig?.rules,
585
+ rules: graphRules,
545
586
  })
546
587
  // `--affected` selects over the FINAL graph: an edge or an input a
547
588
  // `graph` hook adds moves a key, so the hooks run first, over every
@@ -681,7 +722,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
681
722
  dropped,
682
723
  cache,
683
724
  workspaceRoot,
684
- workspaceFingerprint,
685
725
  forwardArgs: options.forwardArgs,
686
726
  nestedDirsByProject,
687
727
  gitFilesCache,
@@ -689,15 +729,35 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
689
729
  })
690
730
  }
691
731
  }
732
+ const pluginCaches = new Map<string, PluginCache>()
733
+ const cacheFor = (plugin: string): PluginCache => {
734
+ let own = pluginCaches.get(plugin)
735
+ if (own === undefined) {
736
+ own = pluginCache(plugin, {
737
+ db: localCache.dbHandle(),
738
+ policy,
739
+ remotes,
740
+ scope: cacheScope,
741
+ })
742
+ pluginCaches.set(plugin, own)
743
+ }
744
+ return own
745
+ }
692
746
  let scheduled: Promise<ReadonlyMap<string, number>> | undefined
693
747
  const priorities = (): Promise<ReadonlyMap<string, number>> =>
694
748
  (scheduled ??= hasHook(plugins, 'schedule')
695
- ? applyScheduleHooks(plugins, nodes, {
696
- workspaceRoot,
697
- cacheDir,
698
- warn: (m) => log.status(m),
699
- localCache,
700
- })
749
+ ? applyScheduleHooks(
750
+ plugins,
751
+ nodes,
752
+ {
753
+ workspaceRoot,
754
+ cacheDir,
755
+ warn: (m) => log.status(m),
756
+ localCache,
757
+ branch: branch(),
758
+ },
759
+ cacheFor,
760
+ )
701
761
  : Promise.resolve(new Map()))
702
762
 
703
763
  return {
@@ -710,6 +770,11 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
710
770
  hasRemoteLayer,
711
771
  cachePolicy: policy,
712
772
  priorities,
773
+ branch,
774
+ cacheFor,
775
+ keepRunEntry: writesRunStore(remotes)
776
+ ? (name, bytes) => putRunEntry(remotes, cacheScope, name, bytes)
777
+ : undefined,
713
778
  nodes,
714
779
  keyOnly,
715
780
  unresolvedTasks,
@@ -717,7 +782,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
717
782
  projects,
718
783
  hintProjects,
719
784
  anyProjectConfig: projectsWithConfigs.length > 0,
720
- workspaceFingerprint,
721
785
  fingerprintWatch,
722
786
  nestedDirsByProject,
723
787
  gitFilesCache,
@@ -771,7 +835,7 @@ function undeclaredIn(
771
835
 
772
836
  /**
773
837
  * Refuses the first `^name` in `unproven` that no project in the whole
774
- * workspace declares. `load` evaluates the configs a scoped run left out;
838
+ * workspace declares. `load` reads the locked configs a scoped run left out;
775
839
  * it is reached only when a `^name` found no holder and nothing loaded
776
840
  * declares it, so a run that names only declared tasks never pays for it.
777
841
  * The rest failing to load leaves the names unjudged: an out-of-scope
@@ -815,3 +879,24 @@ function pruneToClosure(nodes: Map<string, TaskNode>, roots: ReadonlySet<string>
815
879
  else if (!roots.has(id)) n.requested = false
816
880
  }
817
881
  }
882
+
883
+ const namesRemote = (spec: string | undefined): boolean =>
884
+ spec !== undefined && /remote:[rw]/.test(spec)
885
+
886
+ /**
887
+ * The workspace's `cache.policy`, parsed: the schema checks only its shape,
888
+ * since the grammar lives in the cache module the workspace module may not
889
+ * import. Undefined when it names none.
890
+ */
891
+ function workspaceCachePolicy(config: WorkspaceConfig | null | undefined): CachePolicy | undefined {
892
+ const spec = config?.cache?.policy
893
+ if (spec === undefined) return undefined
894
+ try {
895
+ return parseCachePolicy(spec)
896
+ } catch (err) {
897
+ throw new UserError(
898
+ `.vx/workspace: \`cache.policy\`: ${err instanceof Error ? err.message : String(err)}`,
899
+ 'VX_E_CONFIG',
900
+ )
901
+ }
902
+ }