@vzn/vx 0.0.642 → 0.0.643

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.
package/src/cli/help.ts CHANGED
@@ -134,8 +134,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
134
134
  ' foo, @scope/*, ./packages/foo, foo..., ...foo,',
135
135
  ' foo^..., !foo, [<since>]',
136
136
  ' --affected[=<base>] Projects changed since <base>, and dependents',
137
- ' (--filter "...[<base>]"); default run.affectedBase,',
138
- ' else origin/HEAD, main, master, HEAD~1.',
137
+ ' (--filter "...[<base>]"); default the merge base',
138
+ ' with origin/<baseBranch>; none on that branch.',
139
139
  " pkg#task Run a specific project's task directly.",
140
140
  '',
141
141
  'Execution (for run):',
@@ -159,8 +159,6 @@ export function helpText(pluginCommands: readonly string[] = []): string {
159
159
  ' flags; unnamed layers keep theirs. Examples:',
160
160
  ' local:rw,remote:r (remote read-only), remote:',
161
161
  ' (remote off), local:r (local read-only).',
162
- " --cache-scope <name> This run's cache.scope (pr-123): write there,",
163
- ' read the default keys first.',
164
162
  ' --cache-dir <path> Cache directory (cwd-relative); default',
165
163
  ' .vx/cache, entries in ~/.vx/<id>/cache.',
166
164
  ' --cache-download <mode> all (default) | toplevel | none:',
@@ -9,8 +9,8 @@
9
9
 
10
10
  import type { CachePolicy, PluginCache, ResolvedRemote } from '../cache/index.js'
11
11
  import type { WorkspaceConfig } from '../config.js'
12
- import type { VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
13
- import { cacheScopeEnvError, findWorkspaceRoot, isCacheScope } from '../workspace/index.js'
12
+ import type { BranchFacts, VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
13
+ import { configuredBaseBranch, findWorkspaceRoot } from '../workspace/index.js'
14
14
  import { machineParallelism, maskedLine } from '../util/index.js'
15
15
  import { resolveSetting } from '../settings.js'
16
16
 
@@ -49,6 +49,7 @@ async function workspacePlugins(cwd: string): Promise<
49
49
  workspaceConfig: WorkspaceConfig | null
50
50
  concurrency: number
51
51
  plugins: readonly VxPlugin[]
52
+ branch: BranchFacts
52
53
  }
53
54
  | UnresolvedPluginCommand
54
55
  | null
@@ -76,6 +77,7 @@ async function workspacePlugins(cwd: string): Promise<
76
77
  (resolveSetting('run.concurrency', ws.workspaceConfig).value as number | undefined) ??
77
78
  machineParallelism(),
78
79
  plugins: ws.plugins,
80
+ branch: ws.branch,
79
81
  }
80
82
  }
81
83
 
@@ -136,13 +138,16 @@ async function verbCache(
136
138
  ws: Exclude<Awaited<ReturnType<typeof workspacePlugins>>, UnresolvedPluginCommand | null>,
137
139
  plugin: string,
138
140
  ): Promise<PluginCache> {
139
- const [{ Cache, pluginCache }, { resolveCache }] = await Promise.all([
141
+ const [{ Cache, pluginCache }, { resolveCache, resolveCacheScope }] = await Promise.all([
140
142
  import('../cache/index.js'),
141
143
  import('../orchestrator/index.js'),
142
144
  ])
143
- const envScope = process.env['VX_CACHE_SCOPE'] || undefined
144
- if (envScope !== undefined && !isCacheScope(envScope)) throw cacheScopeEnvError()
145
- const scope = envScope ?? ws.workspaceConfig?.cache?.scope
145
+ const scope = resolveCacheScope(
146
+ ws.workspaceRoot,
147
+ ws.branch.scope,
148
+ ws.plugins.some((p) => p.cache !== undefined),
149
+ configuredBaseBranch(ws.workspaceConfig),
150
+ )
146
151
  const localCache = new Cache(
147
152
  ws.cacheDir,
148
153
  { read: true, write: false },
package/src/cli/run.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isatty } from 'node:tty'
2
- import { cacheScopeError, findWorkspaceRoot, isCacheScope } from '../workspace/index.js'
2
+ import { findWorkspaceRoot } from '../workspace/index.js'
3
3
  import { translateForeign } from './foreign-flags.js'
4
4
  import { flagHint, refuse, seeHelp } from './help.js'
5
5
  import {
@@ -60,8 +60,6 @@ export interface RunArgs {
60
60
  remoteRequested?: boolean
61
61
  /** `--cache-dir <path>`: override the cache directory (cwd-relative). */
62
62
  cacheDir: string | undefined
63
- /** `--cache-scope <name>`: this run's `cache.scope`, above `VX_CACHE_SCOPE` and the workspace's. */
64
- cacheScope?: string
65
63
  /** `vx watch --poll` (`watch.poll`). */
66
64
  poll?: true
67
65
  forwardArgs: string[]
@@ -90,9 +88,7 @@ function flagOf(a: string | undefined, name: string): string | undefined {
90
88
  // The settings parsed by their kind alone; the cache flags, `--continue`
91
89
  // and `--cache-dir` keep their own branches above.
92
90
  const GENERIC_FLAGS = SETTINGS.filter(
93
- (s) =>
94
- s.flag?.verb === 'run' &&
95
- !['run.continue', 'cache.dir', 'cache.policy', 'cache.scope'].includes(s.path),
91
+ (s) => s.flag?.verb === 'run' && !['run.continue', 'cache.dir', 'cache.policy'].includes(s.path),
96
92
  )
97
93
 
98
94
  /** The setting a flag names. */
@@ -204,11 +200,6 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
204
200
  }
205
201
  }
206
202
  out.cacheDir = v
207
- } else if (a === '--cache-scope' || a?.startsWith('--cache-scope=')) {
208
- const v = a === '--cache-scope' ? before[++i] : a.slice('--cache-scope='.length)
209
- if (v === undefined || !isCacheScope(v))
210
- return { ...out, error: cacheScopeError('--cache-scope') }
211
- out.cacheScope = v
212
203
  } else if (flagOf(a, '--cache-policy')) {
213
204
  cacheFlag = true
214
205
  const name = flagOf(a, '--cache-policy')!
@@ -494,7 +485,6 @@ export async function resolveRunOptions(
494
485
  ? { cacheFlags: parsed.cacheFlags }
495
486
  : { cache: parsed.cache }),
496
487
  ...(parsed.remoteRequested ? { remoteRequested: true } : {}),
497
- ...(parsed.cacheScope !== undefined ? { cacheScope: parsed.cacheScope } : {}),
498
488
  // A laptop reads the shared cache and never writes it (owner,
499
489
  // 2026-10-03); naming the remote in `--cache-policy` is asking to.
500
490
  ...(!parsed.remoteRequested && !onCI(process.env['CI'])
@@ -524,6 +514,8 @@ export async function resolveRunOptions(
524
514
  if (parsed.settings[path] !== undefined) Object.assign(opts, { [field]: parsed.settings[path] })
525
515
  }
526
516
  if (parsed.profile !== undefined) opts.profile = parsed.profile
517
+ // `--affected`'s base is the branch's for the cache scope too.
518
+ if (affectedFilter !== undefined) opts.scopeBase = affectedFilter.slice(4, -1)
527
519
 
528
520
  return opts
529
521
  }
package/src/cli/select.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  refIsHead,
13
13
  applyFilters,
14
14
  buildPackageGraph,
15
+ configuredBaseBranch,
15
16
  defaultAffectedBase,
16
17
  findWorkspaceRoot,
17
18
  type FingerprintClaims,
@@ -49,7 +50,6 @@ import {
49
50
  loadCliProjects,
50
51
  loadCliWorkspace,
51
52
  } from './workspace-config.js'
52
- import { resolveSetting } from '../settings.js'
53
53
 
54
54
  /**
55
55
  * The projects whose tasks declare an `inputs.workspaceFiles` glob
@@ -244,20 +244,18 @@ function refusalOf(err: unknown): Refusal {
244
244
 
245
245
  /**
246
246
  * The filter `--affected[=<base>]` stands for: `...[<base>]`, the changed
247
- * projects and their dependents. An empty base is `run.affectedBase`
248
- * (its variable, then the workspace's, which a plugin's `config` stage may set from nx.json's
249
- * `defaultBase` or `TURBO_SCM_BASE`), then the guess.
247
+ * projects and their dependents. An empty base is a plugin's (`setBranch`:
248
+ * a pull request's base commit), else the merge base with `baseBranch`.
250
249
  */
251
250
  export async function affectedFilterFor(cwd: string, affected: string): Promise<string | Refusal> {
252
251
  const root = await findWorkspaceRoot(cwd)
253
252
  let base = affected
254
253
  if (base === '') {
255
- const ws = (await loadCliWorkspace(root)).workspaceConfig
256
- base = (resolveSetting('run.affectedBase', ws).value as string | undefined) ?? ''
257
- }
258
- if (base === '') {
254
+ const ws = await loadCliWorkspace(root)
259
255
  try {
260
- base = await defaultAffectedBase(root)
256
+ base =
257
+ ws.branch.base ??
258
+ (await defaultAffectedBase(root, configuredBaseBranch(ws.workspaceConfig)))
261
259
  } catch (err) {
262
260
  if (!isUserError(err)) throw err
263
261
  return refusalOf(err)
@@ -15,7 +15,7 @@ import {
15
15
  lockOfDiscovery,
16
16
  lockReader,
17
17
  } from '../orchestrator/index.js'
18
- import type { VxPlugin } from '../orchestrator/index.js'
18
+ import type { BranchFacts, VxPlugin } from '../orchestrator/index.js'
19
19
  import {
20
20
  buildPackageGraph,
21
21
  type ProjectEntry,
@@ -27,6 +27,8 @@ import {
27
27
  interface CliWorkspace {
28
28
  workspaceConfig: WorkspaceConfig | null
29
29
  plugins: readonly VxPlugin[]
30
+ /** What a plugin's `config` stage said of the branch: its cache scope and base commit. */
31
+ branch: BranchFacts
30
32
  cacheDir: string
31
33
  /** The shared store a run opens `cacheDir` with (`resolveStoreRoot`), asked on load. */
32
34
  storeRoot: Promise<string | null>
@@ -37,10 +39,14 @@ export const warnToStderr = (message: string): void => {
37
39
  }
38
40
 
39
41
  export async function loadCliWorkspace(workspaceRoot: string): Promise<CliWorkspace> {
40
- const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, warnToStderr)
42
+ const { workspaceConfig, plugins, branch } = await loadWorkspacePlugins(
43
+ workspaceRoot,
44
+ warnToStderr,
45
+ )
41
46
  return {
42
47
  workspaceConfig,
43
48
  plugins,
49
+ branch,
44
50
  cacheDir: resolveCacheDir(workspaceRoot, workspaceConfig),
45
51
  storeRoot: resolveStoreRoot(workspaceRoot, workspaceConfig),
46
52
  }
package/src/config.ts CHANGED
@@ -14,6 +14,13 @@ export type Duration = `${number}${'ms' | 's' | 'm' | 'h' | 'd'}`
14
14
  * Nothing here is folded into a cache key.
15
15
  */
16
16
  export interface WorkspaceConfig {
17
+ /**
18
+ * The repository's default branch (`'develop'`). A run on it has no
19
+ * cache scope and a bare `--affected` has no default; elsewhere both
20
+ * compare with the merge base of `origin/<baseBranch>` and HEAD.
21
+ * Omitted → `origin/HEAD`'s branch, then `main`, then `master`.
22
+ */
23
+ baseBranch?: string
17
24
  run?: RunSettings
18
25
  cache?: CacheSettings
19
26
  lock?: LockSettings
@@ -42,13 +49,6 @@ export interface RunSettings {
42
49
  * `schedule` plugin's weights still come first; this breaks their ties.
43
50
  */
44
51
  schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
45
- /**
46
- * The git ref a bare `--affected` compares with (`origin/develop`).
47
- * `--affected=<base>` overrides it. Omitted → `origin/HEAD`, then the
48
- * first of `origin/main`, `origin/master`, `main`, `master` that is not
49
- * HEAD, else `HEAD~1`.
50
- */
51
- affectedBase?: string
52
52
  /**
53
53
  * A task's time limit, for tasks without their own `exec.timeout` (which
54
54
  * beats every run-level source). A runaway-process guard: a timed-out
@@ -95,15 +95,6 @@ export interface CacheSettings {
95
95
  * first, then each remote in declared order.
96
96
  */
97
97
  policy?: string
98
- /**
99
- * Where this run's cache writes land. Omitted, a run reads and writes the
100
- * default keys. A name (`'pr-123'`) reads the default key first, then the
101
- * scope's, and writes only the scope's, so a PR never writes what the
102
- * default branch reads. Whether a run writes at all is `policy`'s call.
103
- * A client-side convention, not a security boundary: the boundary is a
104
- * cache token the server limits.
105
- */
106
- scope?: string
107
98
  /** Which remote hits land on disk: `all` (the default), only the requested tasks' (`toplevel`), or `none`. */
108
99
  download?: 'all' | 'toplevel' | 'none'
109
100
  /** The `cache.inputStability` of every task that does not set its own. */
package/src/index.ts CHANGED
@@ -46,6 +46,7 @@ export type {
46
46
  } from './config.js'
47
47
  export { defineProject, defineWorkspace } from './config.js'
48
48
  export { definePlugin, refuseUnknownOptions } from './orchestrator/index.js'
49
+ export { branchCacheScope } from './workspace/index.js'
49
50
 
50
51
  // Programmatic engine API: run / plan / prepare (docs/cli.md § Programmatic
51
52
  // API). The graph primitives, the cache-key hashing seam and the git / host
@@ -83,6 +84,7 @@ export type {
83
84
  CacheLayer,
84
85
  PluginCache,
85
86
  RemoteCacheLayer,
87
+ RemoteKey,
86
88
  RemoteOpenContext,
87
89
  RunRecord,
88
90
  InvocationRecord,
@@ -171,6 +173,8 @@ export type {
171
173
  ScheduleHookContext,
172
174
  AdmitContext,
173
175
  WorkspaceHookContext,
176
+ ConfigContext,
177
+ BranchFacts,
174
178
  DiscoverContext,
175
179
  DiscoveredProject,
176
180
  PluginSetupContext,
@@ -41,6 +41,7 @@ const KEYED_BY_EVERY_TASK: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAME
41
41
  function wholeProjects(
42
42
  changes: AffectedChanges,
43
43
  projects: ReadonlyMap<string, ProjectEntry>,
44
+ byKeys: boolean,
44
45
  ): Map<string, string | undefined> {
45
46
  // Each whole project, with the path no task claims when one did it.
46
47
  const whole = new Map<string, string | undefined>([...changes.whole].map((n) => [n, undefined]))
@@ -50,7 +51,7 @@ function wholeProjects(
50
51
  (t) => t.exec !== undefined && t.inputs !== undefined,
51
52
  )
52
53
  const unclaimed = (rel: string): boolean =>
53
- KEYED_BY_EVERY_TASK.has(rel) || !keyed.some((t) => declaresInput(t, rel, null))
54
+ KEYED_BY_EVERY_TASK.has(rel) || (!byKeys && !keyed.some((t) => declaresInput(t, rel, null)))
54
55
  const rel = rels.find(unclaimed)
55
56
  if (rel !== undefined) whole.set(name, rel)
56
57
  }
@@ -99,8 +100,10 @@ export function affectedRoots(
99
100
  projects: ReadonlyMap<string, ProjectEntry>,
100
101
  packageGraph: PackageGraph,
101
102
  explain?: AffectedExplain,
103
+ /** Only what moves a key: a changed path no task declares reaches no task. */
104
+ byKeys = false,
102
105
  ): string[] {
103
- const whole = wholeProjects(changes, projects)
106
+ const whole = wholeProjects(changes, projects, byKeys)
104
107
  const seeds = explain === undefined ? null : new Map<string, AffectedReason>()
105
108
  const through = explain === undefined ? null : new Map<string, string>()
106
109
  const inProject = (name: string, rel: string | undefined): AffectedReason => {
@@ -226,6 +229,24 @@ export function affectedRoots(
226
229
  return kept
227
230
  }
228
231
 
232
+ /**
233
+ * The tasks of `nodes` whose key the change can move (a branch's cache
234
+ * scope, owner 2026-10-17): a changed path one of its inputs declares, a
235
+ * whole project, or a task its closure holds. Unlike `--affected`, a path
236
+ * no task declares (a README) reaches none: its key is the default
237
+ * branch's, so it reads the default key.
238
+ */
239
+ export function keyedByChange(
240
+ nodes: ReadonlyMap<string, TaskNode>,
241
+ changes: AffectedChanges,
242
+ projects: ReadonlyMap<string, ProjectEntry>,
243
+ packageGraph: PackageGraph,
244
+ ): Set<string> {
245
+ return new Set(
246
+ affectedRoots(nodes, [...nodes.keys()], changes, projects, packageGraph, undefined, true),
247
+ )
248
+ }
249
+
229
250
  /**
230
251
  * The requested tasks `--affected` keeps, in their order: one the user
231
252
  * named (`pkg#task`), one in a project another include selected outright
@@ -50,6 +50,8 @@ export {
50
50
  type ScheduleHookContext,
51
51
  type AdmitContext,
52
52
  type WorkspaceHookContext,
53
+ type ConfigContext,
54
+ type BranchFacts,
53
55
  type DiscoverContext,
54
56
  type DiscoveredProject,
55
57
  type InstallPluginsArgs,
@@ -117,6 +119,8 @@ export {
117
119
  captureWorkspaceIdentity,
118
120
  currentBranch,
119
121
  detectCi,
122
+ localBranchScope,
123
+ resolveCacheScope,
120
124
  } from './run-context.js'
121
125
  export type { CiContext, GitContext, HostContext, WorkspaceIdentity } from './run-context.js'
122
126
  export {
@@ -90,8 +90,16 @@ export interface RunOptions {
90
90
  * saying it is not.
91
91
  */
92
92
  remoteRequested?: boolean
93
- /** This run's `cache.scope` (the CLI's `--cache-scope`), above `VX_CACHE_SCOPE` and `.vx/workspace`. */
93
+ /** This run's cache scope, above `VX_CACHE_SCOPE`, a plugin's and the branch's (an embedder's). */
94
94
  cacheScope?: string
95
+ /**
96
+ * What the branch changed, for a scoped run: the tasks whose key it can
97
+ * move read the scope's key, every other task the default key (one
98
+ * lookup each). Absent, the run diffs the branch from `scopeBase`.
99
+ */
100
+ scopeChanges?: AffectedChanges
101
+ /** The commit a scoped run diffs the branch from (`--affected`'s base); absent, a plugin's, else the merge base with the default branch. */
102
+ scopeBase?: string
95
103
  /**
96
104
  * The default policy's remote write is off (`remote:r`): the CLI passes it
97
105
  * off CI (`CI` unset, `0` or `false`) unless `--cache-policy` or
@@ -9,6 +9,7 @@
9
9
 
10
10
  import path from 'node:path'
11
11
  import {
12
+ type BranchScope,
12
13
  type Cache,
13
14
  type CacheLayer,
14
15
  type CachePolicy,
@@ -34,7 +35,7 @@ import type {
34
35
  RunReport,
35
36
  ScheduleHookContext,
36
37
  VxPlugin,
37
- WorkspaceHookContext,
38
+ ConfigContext,
38
39
  } from './plugin.js'
39
40
  import type { ProjectMeta } from '../workspace/index.js'
40
41
 
@@ -137,7 +138,7 @@ function assertEditedInPlace(
137
138
  export async function applyConfigHooks(
138
139
  plugins: readonly VxPlugin[],
139
140
  workspace: WorkspaceConfig,
140
- ctx: WorkspaceHookContext,
141
+ ctx: ConfigContext,
141
142
  /** Runs after EACH plugin's edit, so a refusal can name the plugin that made it. */
142
143
  afterEach?: (plugin: VxPlugin) => void,
143
144
  ): Promise<void> {
@@ -555,6 +556,8 @@ export interface CacheHost extends CacheContext {
555
556
  readonly policy: CachePolicy
556
557
  /** Filled with each remote and what its policy lets it do, for the plugins' `ctx.cache`. */
557
558
  readonly remotes?: ResolvedRemote[]
559
+ /** The run's branch scope, which picks each remote key. */
560
+ readonly branch?: BranchScope
558
561
  }
559
562
 
560
563
  /**
@@ -569,7 +572,7 @@ export async function resolveCache(
569
572
  plugins: readonly VxPlugin[],
570
573
  host: CacheHost,
571
574
  ): Promise<CacheLayer> {
572
- const { localCache, policy, remotes, ...ctx } = host
575
+ const { localCache, policy, remotes, branch, ...ctx } = host
573
576
  const resolved: ResolvedRemote[] = []
574
577
  for (const plugin of plugins) {
575
578
  if (plugin.cache === undefined) continue
@@ -586,7 +589,7 @@ export async function resolveCache(
586
589
  resolved.push(entry)
587
590
  remotes?.push(entry)
588
591
  }
589
- return resolved.length === 0 ? localCache : new LayeredCache(localCache, resolved)
592
+ return resolved.length === 0 ? localCache : new LayeredCache(localCache, resolved, branch)
590
593
  }
591
594
 
592
595
  /** The plugin each resolved executor came from, so a hook of it that throws is named. */
@@ -61,8 +61,10 @@ export interface VxPlugin {
61
61
  * The workspace config, before anything is derived from it (concurrency,
62
62
  * cacheDir, timeout, …). `plugins` is already fixed by the time this runs.
63
63
  * Core re-validates after each plugin, as it does a user's file.
64
+ * `ctx.setBranch` names what the CI knows of the branch: its cache scope
65
+ * and the commit it is compared with.
64
66
  */
65
- config?(workspace: WorkspaceConfig, ctx: WorkspaceHookContext): void | Promise<void>
67
+ config?(workspace: WorkspaceConfig, ctx: ConfigContext): void | Promise<void>
66
68
 
67
69
  /**
68
70
  * Projects beyond the directories holding a vx.config, their names, and
@@ -340,6 +342,22 @@ export interface WorkspaceHookContext {
340
342
  warn(message: string): void
341
343
  }
342
344
 
345
+ /**
346
+ * What a CI knows of the run's branch (owner, 2026-10-17): `scope`, the
347
+ * cache scope (`'vznjs/vx:feat-x'`; `VX_CACHE_SCOPE` beats it, and it
348
+ * beats the one git names), and `base`, the commit the branch is compared
349
+ * with (a pull request's base), for a bare `--affected` and for which
350
+ * tasks read the scope. A later plugin's field wins.
351
+ */
352
+ export interface BranchFacts {
353
+ scope?: string
354
+ base?: string
355
+ }
356
+
357
+ export interface ConfigContext extends WorkspaceHookContext {
358
+ setBranch(facts: BranchFacts): void
359
+ }
360
+
343
361
  export interface DiscoverContext extends WorkspaceHookContext {
344
362
  /** Where vx's cache lives: a plugin may keep what it read to answer here. */
345
363
  readonly cacheDir: string
@@ -485,7 +503,7 @@ export interface ScheduleHookContext extends BaseContext {
485
503
  /** A `cache` hook's context: the workspace and the run's scope, never the local store or the policy, which core owns. */
486
504
  export interface CacheContext extends BaseContext {
487
505
  /**
488
- * The run's `cache.scope` (`'pr-123'`), `undefined` when none. Core already keys a scoped run's
506
+ * The run's cache scope (`'vznjs/vx:feat-x'`), `undefined` when none. Core already keys a scoped run's
489
507
  * writes apart; a remote reads it to pick a credential or check a token's
490
508
  * claims (an OIDC branch) for every call it makes.
491
509
  */
@@ -26,7 +26,7 @@ import {
26
26
  writesRunStore,
27
27
  type PluginCache,
28
28
  type ResolvedRemote,
29
- ScopedCache,
29
+ BranchScope,
30
30
  applyGitEnumeration,
31
31
  gitPathspecs,
32
32
  MAX_SCOPED_PATHSPECS,
@@ -38,8 +38,9 @@ import {
38
38
  buildPackageGraph,
39
39
  computeNestedProjectDirs,
40
40
  findWorkspaceRoot,
41
- cacheScopeEnvError,
42
- isCacheScope,
41
+ type AffectedChanges,
42
+ branchChanges,
43
+ configuredBaseBranch,
43
44
  type LoadReads,
44
45
  PROJECT_CONFIG_FILENAMES,
45
46
  readClaimedFiles,
@@ -78,13 +79,13 @@ import {
78
79
  lockReader,
79
80
  type LoadedProjects,
80
81
  } from './projects.js'
81
- import { keptByAffected } from './affected-tasks.js'
82
+ import { keptByAffected, keyedByChange } from './affected-tasks.js'
82
83
  import { applyGraphStage } from './graph-stage.js'
83
84
  import { keyExcludedDependencies } from './excluded-keys.js'
84
85
  import { FingerprintWatch } from './fingerprint-watch.js'
85
86
  import type { VxPlugin } from './plugin.js'
86
87
  import { askIgnoredOutputs, gatedOutputs } from './stable-keys.js'
87
- import { currentBranch } from './run-context.js'
88
+ import { currentBranch, resolveCacheScope } from './run-context.js'
88
89
  import { createHashCache, type HashCache } from './task-hash.js'
89
90
  import { type Logger, warnOf } from '../report/index.js'
90
91
  import type { RunOptions } from './options.js'
@@ -113,6 +114,8 @@ export interface PreparedRun {
113
114
  hasRemoteLayer: boolean
114
115
  /** `--cache-policy` with the workspace's `cacheScope` applied: what the layers were built under. */
115
116
  cachePolicy: CachePolicy
117
+ /** The scope this run's cache reads and writes, when one is set; named on the summary's `info` row. */
118
+ cacheScope: string | undefined
116
119
  /** The run's remotes in declared order, as the policy gates each; `openRemotes` turns one off. */
117
120
  remotes: ResolvedRemote[]
118
121
  /**
@@ -249,8 +252,21 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
249
252
  (reused !== undefined ? gitOfDiscovery(reused.projects) : undefined) ??
250
253
  lazyGitEnumeration(workspaceRoot)
251
254
  if (wholeTree) void git.start()
252
- const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, warnOf(log))
255
+ const {
256
+ workspaceConfig,
257
+ plugins,
258
+ branch: branchFacts,
259
+ } = await loadWorkspacePlugins(workspaceRoot, warnOf(log))
253
260
  mark('workspace config')
261
+ const baseBranch = configuredBaseBranch(workspaceConfig)
262
+ const cacheScope =
263
+ options.cacheScope ??
264
+ resolveCacheScope(
265
+ workspaceRoot,
266
+ branchFacts.scope,
267
+ plugins.some((p) => p.cache !== undefined),
268
+ baseBranch,
269
+ )
254
270
  let learnedBranch: string | null | undefined
255
271
  const branch = (): string | null =>
256
272
  learnedBranch === undefined
@@ -269,10 +285,38 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
269
285
  // Asked now, read when the cache opens: a repository with no remote is
270
286
  // named by its root commit, a git spawn the discovery below overlaps.
271
287
  const storeRoot = options.cacheDir ? null : resolveStoreRoot(workspaceRoot, workspaceConfig)
272
- const projectMetas =
288
+ const discovering =
273
289
  reused !== undefined
274
- ? reused.projects
275
- : await discoverProjects(workspaceRoot, readLock, plugins, cacheDir, warnOf(log), git)
290
+ ? Promise.resolve(reused.projects)
291
+ : discoverProjects(workspaceRoot, readLock, plugins, cacheDir, warnOf(log), git)
292
+ // What the branch changed decides which tasks read the scope (owner,
293
+ // 2026-10-17). Only a scoped run's first remote call asks git: a run the
294
+ // local cache answers makes no remote call, and diffing beside discovery
295
+ // cost it 20-60 ms at 1k-5k projects.
296
+ const scopeChanges: (() => Promise<AffectedChanges>) | undefined =
297
+ cacheScope === undefined
298
+ ? undefined
299
+ : options.scopeChanges !== undefined
300
+ ? async () => options.scopeChanges!
301
+ : () =>
302
+ branchChanges(
303
+ workspaceRoot,
304
+ options.scopeBase ?? branchFacts.base,
305
+ baseBranch,
306
+ discovering,
307
+ {
308
+ untracked: async () => (await git.start()).untracked,
309
+ // A scoped enumeration lists only its projects: null asks git.
310
+ links: async () => {
311
+ const g = await git.start()
312
+ if (!g.scopes.includes('.')) return null
313
+ const out: string[] = []
314
+ for (const [rel, oid] of g.trusted) if (oid.startsWith('120000:')) out.push(rel)
315
+ return out
316
+ },
317
+ },
318
+ )
319
+ const projectMetas = await discovering
276
320
  mark('discover projects')
277
321
 
278
322
  // SCOPED config loading: configs are programs, and evaluating 1090
@@ -314,12 +358,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
314
358
  // so a reader profiling that stage was sent to the wrong code.
315
359
  mark('package graph')
316
360
 
317
- // `--cache-scope`, then `VX_CACHE_SCOPE`, win over the workspace (and `github()`'s guess):
318
- // a CI job that knows its trust level says so without editing config.
319
- const envScope = process.env['VX_CACHE_SCOPE'] || undefined
320
- if (envScope !== undefined && !isCacheScope(envScope)) throw cacheScopeEnvError()
321
361
  const workspacePolicy = workspaceCachePolicy(workspaceConfig)
322
- const cacheScope = options.cacheScope ?? envScope ?? workspaceConfig?.cache?.scope
323
362
  const asked =
324
363
  options.cache ?? applyCacheFlags(workspacePolicy ?? FULL_CACHE_POLICY, options.cacheFlags)
325
364
  // A laptop reads the shared cache and does not write it unless a policy
@@ -470,6 +509,9 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
470
509
  // cache plugin.
471
510
  let cache: CacheLayer
472
511
  const remotes: ResolvedRemote[] = []
512
+ // A scope only picks remote keys (owner, 2026-10-17): the local store
513
+ // keeps every branch's entries under the task keys.
514
+ const branchScope = cacheScope === undefined ? undefined : new BranchScope(cacheScope)
473
515
  if (options.remoteCache) {
474
516
  remotes.push({
475
517
  remote: options.remoteCache,
@@ -480,7 +522,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
480
522
  }
481
523
  try {
482
524
  cache = options.remoteCache
483
- ? new LayeredCache(localCache, remotes)
525
+ ? new LayeredCache(localCache, remotes, branchScope)
484
526
  : await resolveCache(plugins, {
485
527
  workspaceRoot,
486
528
  cacheDir,
@@ -489,6 +531,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
489
531
  localCache,
490
532
  policy,
491
533
  remotes,
534
+ ...(branchScope !== undefined ? { branch: branchScope } : {}),
492
535
  })
493
536
  } catch (err) {
494
537
  // A cache plugin that throws or returns something off-contract is
@@ -499,8 +542,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
499
542
  localCache.close()
500
543
  throw err
501
544
  }
502
- // One scoping rule above every layer, local and remote alike.
503
- if (cacheScope !== undefined) cache = new ScopedCache(cache, cacheScope)
504
545
  // Ask the LAYER, don't infer. Identity against `localCache` answers a
505
546
  // DIFFERENT question — "did the plugin hand back something other than the
506
547
  // handle I passed in?" — which an ordinary pass-through decorator (a
@@ -534,6 +575,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
534
575
  localCache,
535
576
  hasRemoteLayer,
536
577
  cachePolicy: policy,
578
+ cacheScope,
537
579
  remotes,
538
580
  keepRunEntry: undefined,
539
581
  priorities: () => Promise.resolve(new Map()),
@@ -721,6 +763,13 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
721
763
  if (hasHook(plugins, 'key')) {
722
764
  await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: warnOf(log) })
723
765
  }
766
+ // The final inputs decide which tasks the branch changed: they read
767
+ // the scope, every other task the default key.
768
+ if (branchScope !== undefined && scopeChanges !== undefined) {
769
+ branchScope.routeTasks(async () =>
770
+ keyedByChange(nodes, await scopeChanges(), projects, packageGraph),
771
+ )
772
+ }
724
773
  refuseFiltersThroughGroups(nodes)
725
774
  const exclude = options.excludeDependencies
726
775
  let keyOnly: ReadonlyMap<string, TaskNode> = new Map()
@@ -783,6 +832,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
783
832
  localCache,
784
833
  hasRemoteLayer,
785
834
  cachePolicy: policy,
835
+ cacheScope,
786
836
  remotes,
787
837
  priorities,
788
838
  branch,