@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
@@ -13,11 +13,13 @@ 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
+ ScopedCache,
21
23
  applyGitEnumeration,
22
24
  gitPathspecs,
23
25
  MAX_SCOPED_PATHSPECS,
@@ -27,15 +29,11 @@ import {
27
29
  import {
28
30
  buildPackageGraph,
29
31
  computeNestedProjectDirs,
30
- computeWorkspaceFingerprints,
31
32
  findWorkspaceRoot,
32
33
  cacheScopeEnvError,
33
34
  isCacheScope,
34
35
  type LoadReads,
35
- loadWorkspace,
36
- FROZEN_WITHOUT_LOCK,
37
- type Lockfile,
38
- readLockfile,
36
+ readClaimedFiles,
39
37
  resolveCacheDir,
40
38
  resolveStoreRoot,
41
39
  type ProjectEntry,
@@ -44,6 +42,7 @@ import {
44
42
  buildTaskGraph,
45
43
  excludeDependencies,
46
44
  expandRequested,
45
+ type GraphRules,
47
46
  isGroupTask,
48
47
  type TaskNode,
49
48
  undeclaredDepsError,
@@ -64,6 +63,8 @@ import {
64
63
  isDefaultBuild,
65
64
  loadProjects,
66
65
  loadWorkspacePlugins,
66
+ lockOfDiscovery,
67
+ lockReader,
67
68
  type LoadedProjects,
68
69
  } from './projects.js'
69
70
  import { keptByAffected } from './affected-tasks.js'
@@ -71,10 +72,12 @@ import { applyGraphStage } from './graph-stage.js'
71
72
  import { keyExcludedDependencies } from './excluded-keys.js'
72
73
  import { FingerprintWatch } from './fingerprint-watch.js'
73
74
  import type { VxPlugin } from './plugin.js'
75
+ import { currentBranch } from './run-context.js'
74
76
  import { createHashCache, type HashCache } from './task-hash.js'
75
77
  import type { Logger } from './logger.js'
76
78
  import type { RunOptions } from './options.js'
77
79
  import { refuseFiltersThroughGroups } from './upstream.js'
80
+ import { runRules } from '../settings.js'
78
81
 
79
82
  export interface PreparedRun {
80
83
  workspaceRoot: string
@@ -97,7 +100,7 @@ export interface PreparedRun {
97
100
  * would be saved still cleans its outputs before executing.
98
101
  */
99
102
  hasRemoteLayer: boolean
100
- /** `--cache` with the workspace's `cacheScope` applied: what the layers were built under. */
103
+ /** `--cache-policy` with the workspace's `cacheScope` applied: what the layers were built under. */
101
104
  cachePolicy: CachePolicy
102
105
  /**
103
106
  * Scheduling priorities from plugins' `schedule` stage (task id → weight,
@@ -107,6 +110,12 @@ export interface PreparedRun {
107
110
  * nothing-changed run never dispatches. Runs the stage once.
108
111
  */
109
112
  priorities: () => Promise<ReadonlyMap<string, number>>
113
+ /**
114
+ * The run's branch (`run.branch`, else git's, else the CI provider's):
115
+ * what its history records and the timings a forecast reads first.
116
+ * Learned on first ask, once per run.
117
+ */
118
+ branch: () => string | null
110
119
  nodes: Map<string, TaskNode>
111
120
  /**
112
121
  * The tasks `--exclude-dependencies` took out of the schedule: keyed as a
@@ -135,8 +144,7 @@ export interface PreparedRun {
135
144
  * mistaken for a workspace vx was never set up in.
136
145
  */
137
146
  anyProjectConfig: boolean
138
- workspaceFingerprint: string
139
- /** Whether a task has since rewritten what that fingerprint folded (fingerprint-watch.ts). */
147
+ /** The claimed root files, and whether a task has since rewritten one (fingerprint-watch.ts). */
140
148
  fingerprintWatch: FingerprintWatch
141
149
  nestedDirsByProject: Map<string, string[]>
142
150
  /**
@@ -149,11 +157,9 @@ export interface PreparedRun {
149
157
  /** Every project discovery found, in or out of scope — header scope bar. */
150
158
  workspaceProjectCount: number
151
159
  /**
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.
160
+ * Per-run memo for derived hashes — task-config hash keyed by config
161
+ * object identity, resolved file lists. Shared across every task's
162
+ * `computeTaskHash` call so the same config object isn't re-hashed.
157
163
  */
158
164
  hashCache: HashCache
159
165
  /**
@@ -188,22 +194,19 @@ export interface PreparedRun {
188
194
  export async function prepareRun(options: RunOptions, log: Logger): Promise<PreparedRun> {
189
195
  beginRun()
190
196
  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.
197
+ // A claimed root file is read once. A watch cycle is a new run and reads
198
+ // them afresh.
193
199
  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(() => {})
200
+ const workspaceRoot = await findWorkspaceRoot(options.cwd)
201
+ // The lock is where a run's projects come from, so the read (1.1 MB at
202
+ // 1,000 projects, ~10 ms to read and parse) starts here and overlaps the
203
+ // workspace config's evaluation (X-181). A missing or malformed lock
204
+ // refuses at discovery, after any earlier refusal.
205
+ const reused = options.discovered?.root === workspaceRoot ? options.discovered : undefined
206
+ const readLock =
207
+ (reused !== undefined ? lockOfDiscovery(reused.projects) : undefined) ??
208
+ lockReader(workspaceRoot)
209
+ if (reused === undefined) void readLock().catch(() => {})
207
210
  // An UNSCOPED run (no explicit scope, at least one bare task name)
208
211
  // enumerates the whole tree whatever the configs say, so git starts
209
212
  // HERE — the walk needs only the root — and overlaps the workspace
@@ -221,18 +224,21 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
221
224
  // whole-tree enumeration a scoped run would otherwise scope later, here or
222
225
  // in the CLI's selection pass; the run reuses it instead of walking the
223
226
  // tree twice (G-75).
224
- const reused = options.discovered?.root === workspaceRoot ? options.discovered : undefined
225
227
  const git =
226
228
  (reused !== undefined ? gitOfDiscovery(reused.projects) : undefined) ??
227
229
  lazyGitEnumeration(workspaceRoot)
228
230
  if (wholeTree) void git.start()
229
- const workspace = await loadWorkspace(workspaceRoot, reads)
230
231
  const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, (m) =>
231
232
  log.status(m),
232
233
  )
233
234
  mark('workspace config')
235
+ let learnedBranch: string | null | undefined
236
+ const branch = (): string | null =>
237
+ learnedBranch === undefined
238
+ ? (learnedBranch = currentBranch(workspaceRoot, workspaceConfig, options.branch))
239
+ : learnedBranch
234
240
  // `--cache-dir <path>` (RunOptions.cacheDir) overrides the workspace
235
- // `cacheDir` field + the `.vx/cache` default; resolved relative to cwd.
241
+ // `cache.dir` + the `.vx/cache` default; resolved relative to cwd.
236
242
  // A cache dir named anywhere holds everything; the default shares its
237
243
  // entries through the user's store.
238
244
  const cacheDir = options.cacheDir
@@ -244,7 +250,14 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
244
250
  const projectMetas =
245
251
  reused !== undefined
246
252
  ? reused.projects
247
- : await discoverProjects(workspace, plugins, cacheDir, (m) => log.status(m), git)
253
+ : await discoverProjects(
254
+ workspaceRoot,
255
+ readLock,
256
+ plugins,
257
+ cacheDir,
258
+ (m) => log.status(m),
259
+ git,
260
+ )
248
261
  mark('discover projects')
249
262
 
250
263
  // SCOPED config loading: configs are programs, and evaluating 1090
@@ -264,7 +277,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
264
277
  // anchors alone are the scope and nothing else needs its config
265
278
  // evaluated.
266
279
  // `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).
280
+ // task, under the name it has when something names it (D-39).
268
281
  const root = path.resolve(workspaceRoot)
269
282
  const rootName = projectMetas.find((m) => path.resolve(m.dir) === root)?.name
270
283
  const tasks =
@@ -282,20 +295,26 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
282
295
  options.projects !== undefined ? [...options.projects, ...anchored] : hasBare ? 'all' : anchored
283
296
  // Its own mark: the package graph is the bulk of what used to be charged
284
297
  // 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),
298
+ // graph 3-8 ms against the cache open's ~1 ms and the root files' ~0.3),
286
299
  // so a reader profiling that stage was sent to the wrong code.
287
300
  mark('package graph')
288
301
 
289
- // The local cache opens BEFORE the configs load: it is also where their
290
- // cached evaluations live.
291
302
  // `--cache-scope`, then `VX_CACHE_SCOPE`, win over the workspace (and `github()`'s guess):
292
303
  // a CI job that knows its trust level says so without editing config.
293
304
  const envScope = process.env['VX_CACHE_SCOPE'] || undefined
294
305
  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
- )
306
+ const workspacePolicy = workspaceCachePolicy(workspaceConfig)
307
+ const cacheScope = options.cacheScope ?? envScope ?? workspaceConfig?.cache?.scope
308
+ const asked =
309
+ options.cache ?? applyCacheFlags(workspacePolicy ?? FULL_CACHE_POLICY, options.cacheFlags)
310
+ // A laptop reads the shared cache and does not write it unless a policy
311
+ // names the remote (owner, 2026-10-03; Security F1, 2026-10-14): a key
312
+ // holds only declared inputs, so a laptop's write could be replayed on CI.
313
+ // A workspace policy naming the remote asks to write it, as `--cache-policy` does.
314
+ const readOnly =
315
+ options.remoteReadOnlyByDefault === true &&
316
+ !(options.cache === undefined && namesRemote(workspaceConfig?.cache?.policy))
317
+ const policy: CachePolicy = readOnly ? { ...asked, remoteWrite: false } : asked
299
318
  const localCache = new Cache(
300
319
  cacheDir,
301
320
  { read: policy.localRead, write: policy.localWrite },
@@ -305,29 +324,20 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
305
324
  await storeRoot,
306
325
  )
307
326
  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`).
327
+ // The root files the plugins claim (`VxPlugin.fingerprint`), whose bytes
328
+ // their claimants fold: read now so a task that rewrites one is caught.
311
329
  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
330
+ const claimed = [...fingerprintClaims(plugins).keys()]
331
+ const claimedBytes = await readClaimedFiles(workspaceRoot, claimed, reads)
318
332
  mark('open cache')
319
333
 
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.
334
+ // Configs load FROM .vx/projects.lock.json with no evaluation and no check of
335
+ // the configs (`vx lock --check` re-evaluates them; a run does not):
336
+ // only `vx lock` evaluates (owner, 2026-10-14).
327
337
  // See docs/design/config-lock-2026-06.md.
328
338
  // Read once, and only when a config is to be read from it: the CLI's
329
339
  // 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
340
+ // its load refused a run without a lock. A 1,000-project lock is
331
341
  // 1.1 MB of JSON (I-27); a run with nothing staged started its read
332
342
  // beside discovery, above.
333
343
  const staged = options.staged
@@ -336,8 +346,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
336
346
  projectMetas.every(
337
347
  (m) => typeof m.configPath !== 'string' || m.configPath === '' || staged.has(m.name),
338
348
  )
339
- if (options.frozen === true && !allStaged) await readLock()
340
- const lock = options.frozen === true ? readLock : null
349
+ if (!allStaged) await readLock()
341
350
 
342
351
  const loadArgs = {
343
352
  workspaceRoot,
@@ -345,8 +354,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
345
354
  plugins,
346
355
  projectMetas,
347
356
  packageGraph,
348
- lock,
349
- evalCache: { store: localCache, workspaceRoot, workspaceFingerprint: fingerprints.all },
357
+ configs: readLock,
350
358
  warn: (m: string) => log.status(m),
351
359
  }
352
360
  let loaded: LoadedProjects
@@ -358,8 +366,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
358
366
  ...(options.staged !== undefined ? { staged: options.staged } : {}),
359
367
  })
360
368
  } catch (err) {
361
- // The cache opened before the configs loaded (it holds their cached
362
- // evaluations); a config error must not leak the handle.
369
+ // The cache opened before the configs loaded; a config error must not
370
+ // leak the handle.
363
371
  localCache.close()
364
372
  throw err
365
373
  }
@@ -453,6 +461,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
453
461
  workspaceRoot,
454
462
  cacheDir,
455
463
  warn: (m) => log.status(m),
464
+ scope: cacheScope,
456
465
  localCache,
457
466
  policy,
458
467
  })
@@ -465,6 +474,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
465
474
  localCache.close()
466
475
  throw err
467
476
  }
477
+ // One scoping rule above every layer, local and remote alike.
478
+ if (cacheScope !== undefined) cache = new ScopedCache(cache, cacheScope)
468
479
  // Ask the LAYER, don't infer. Identity against `localCache` answers a
469
480
  // DIFFERENT question — "did the plugin hand back something other than the
470
481
  // handle I passed in?" — which an ordinary pass-through decorator (a
@@ -480,9 +491,14 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
480
491
  // returns.
481
492
  try {
482
493
  const hashCache = createHashCache()
483
- const fingerprintWatch = new FingerprintWatch(workspaceRoot, fingerprints, fingerprintsAt)
494
+ const fingerprintWatch = new FingerprintWatch(
495
+ workspaceRoot,
496
+ claimed,
497
+ claimedBytes,
498
+ fingerprintsAt,
499
+ )
484
500
 
485
- // Empty-cases bookkeeping. We still construct the cache + fingerprint
501
+ // Empty-cases bookkeeping. We still construct the cache + watch
486
502
  // so the caller's try/finally pattern can close it uniformly.
487
503
  const emptyRun = (reason?: 'none-affected'): PreparedRun => ({
488
504
  workspaceRoot,
@@ -494,6 +510,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
494
510
  hasRemoteLayer,
495
511
  cachePolicy: policy,
496
512
  priorities: () => Promise.resolve(new Map()),
513
+ branch,
497
514
  nodes: new Map(),
498
515
  keyOnly: new Map(),
499
516
  unresolvedTasks,
@@ -501,7 +518,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
501
518
  projects,
502
519
  hintProjects,
503
520
  anyProjectConfig: projectsWithConfigs.length > 0,
504
- workspaceFingerprint,
505
521
  fingerprintWatch,
506
522
  nestedDirsByProject,
507
523
  gitFilesCache: new GitFilesCache(),
@@ -521,6 +537,10 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
521
537
  // the whole workspace: a `^name` nothing loaded declares may be declared
522
538
  // by a config the scope left out, so the builder hands it back instead of
523
539
  // refusing it, and the rest decide.
540
+ // A run never checks the graph rules: `vx lock` and `vx lock --check`
541
+ // do (owner, 2026-10-14). `exclusiveOutputs: false` still asks the walk
542
+ // for its addition marks.
543
+ const graphRules: GraphRules = { ...runRules(workspaceConfig), locked: true }
524
544
  const unproven: Array<[taskId: string, name: string]> = []
525
545
  const graphOf = (req: typeof requested) =>
526
546
  buildTaskGraph({
@@ -528,7 +548,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
528
548
  packageGraph,
529
549
  requested: req,
530
550
  workspaceRoot,
531
- rules: workspaceConfig?.rules,
551
+ rules: graphRules,
532
552
  ...(partialLoad
533
553
  ? { undeclaredDeps: (id: string, name: string) => void unproven.push([id, name]) }
534
554
  : {}),
@@ -541,7 +561,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
541
561
  cacheDir,
542
562
  projectMetas,
543
563
  warn: (m) => log.status(m),
544
- rules: workspaceConfig?.rules,
564
+ rules: graphRules,
545
565
  })
546
566
  // `--affected` selects over the FINAL graph: an edge or an input a
547
567
  // `graph` hook adds moves a key, so the hooks run first, over every
@@ -681,7 +701,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
681
701
  dropped,
682
702
  cache,
683
703
  workspaceRoot,
684
- workspaceFingerprint,
685
704
  forwardArgs: options.forwardArgs,
686
705
  nestedDirsByProject,
687
706
  gitFilesCache,
@@ -697,6 +716,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
697
716
  cacheDir,
698
717
  warn: (m) => log.status(m),
699
718
  localCache,
719
+ branch: branch(),
700
720
  })
701
721
  : Promise.resolve(new Map()))
702
722
 
@@ -710,6 +730,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
710
730
  hasRemoteLayer,
711
731
  cachePolicy: policy,
712
732
  priorities,
733
+ branch,
713
734
  nodes,
714
735
  keyOnly,
715
736
  unresolvedTasks,
@@ -717,7 +738,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
717
738
  projects,
718
739
  hintProjects,
719
740
  anyProjectConfig: projectsWithConfigs.length > 0,
720
- workspaceFingerprint,
721
741
  fingerprintWatch,
722
742
  nestedDirsByProject,
723
743
  gitFilesCache,
@@ -771,7 +791,7 @@ function undeclaredIn(
771
791
 
772
792
  /**
773
793
  * Refuses the first `^name` in `unproven` that no project in the whole
774
- * workspace declares. `load` evaluates the configs a scoped run left out;
794
+ * workspace declares. `load` reads the locked configs a scoped run left out;
775
795
  * it is reached only when a `^name` found no holder and nothing loaded
776
796
  * declares it, so a run that names only declared tasks never pays for it.
777
797
  * The rest failing to load leaves the names unjudged: an out-of-scope
@@ -815,3 +835,24 @@ function pruneToClosure(nodes: Map<string, TaskNode>, roots: ReadonlySet<string>
815
835
  else if (!roots.has(id)) n.requested = false
816
836
  }
817
837
  }
838
+
839
+ const namesRemote = (spec: string | undefined): boolean =>
840
+ spec !== undefined && /remote:[rw]/.test(spec)
841
+
842
+ /**
843
+ * The workspace's `cache.policy`, parsed: the schema checks only its shape,
844
+ * since the grammar lives in the cache module the workspace module may not
845
+ * import. Undefined when it names none.
846
+ */
847
+ function workspaceCachePolicy(config: WorkspaceConfig | null | undefined): CachePolicy | undefined {
848
+ const spec = config?.cache?.policy
849
+ if (spec === undefined) return undefined
850
+ try {
851
+ return parseCachePolicy(spec)
852
+ } catch (err) {
853
+ throw new UserError(
854
+ `.vx/workspace: \`cache.policy\`: ${err instanceof Error ? err.message : String(err)}`,
855
+ 'VX_E_CONFIG',
856
+ )
857
+ }
858
+ }