@vzn/vx 0.0.644 → 0.0.645

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/init.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  // `vx init [--dry [--format json]] [--force] [--mjs] [--native|--keep]` — a workspace from nowhere.
2
- // Core reads no package.json (M, 2026-10-14): a repo with one (package.json
3
- // scripts, Turbo, Nx) is `@vzn/vx-migrate`'s, which maps it and installs
4
- // what it writes (`migrate`); without one, the workspace file and an empty
5
- // root project are written here, then the lock (`starter`).
2
+ // It installs vx globally and, where a package.json is, as its devDependency
3
+ // (M, 2026-10-20). A Turbo or Nx repo is `@vzn/vx-migrate`'s, which maps it
4
+ // and installs what it writes (`migrate`); any other gets the workspace file
5
+ // and an empty root project, then the lock (`starter`).
6
6
  // `vx init --plugin <seam>` writes a runnable plugin for one seam and its
7
7
  // test instead (plugin-templates.ts, the examples the gate runs).
8
8
 
@@ -13,7 +13,14 @@ import { gitTop, workspaceFileWritten } from '../handoff.js'
13
13
  import { flagHint, formatValue, refuse, seeHelp } from './help.js'
14
14
  import { lockAfterMigration } from './lock.js'
15
15
  import { PLUGIN_TEMPLATES } from './plugin-templates.js'
16
- import { isCompiledBinary, targetVersion } from './upgrade.js'
16
+ import {
17
+ isCompiledBinary,
18
+ projectManager,
19
+ startedVersion,
20
+ targetVersion,
21
+ upgradeArgv,
22
+ type ManagedInstall,
23
+ } from './upgrade.js'
17
24
  import { VERSION } from '../version.js'
18
25
  import { DOT_VX_GITATTRIBUTES, DOT_VX_GITIGNORE, ui, UserError, sayNotice } from '../util/index.js'
19
26
  import {
@@ -107,13 +114,96 @@ export async function initCmd(args: readonly string[]): Promise<number> {
107
114
  dirs.push(d)
108
115
  if (d === top || path.dirname(d) === d) break
109
116
  }
110
- const manifest = dirs.some((d) => MANIFESTS.some((f) => existsSync(path.join(d, f))))
111
- if (manifest) return migrate(dirs, parsed)
112
- return starter(marked ?? cwd, top === null, parsed)
117
+ if (dirs.some((d) => TOOL_CONFIGS.some((f) => existsSync(path.join(d, f))))) {
118
+ return migrate(dirs, parsed)
119
+ }
120
+ // The topmost package.json: a monorepo's root, wherever below it init ran.
121
+ const pkgRoot = dirs.findLast((d) => existsSync(path.join(d, 'package.json'))) ?? null
122
+ return starter(marked ?? pkgRoot ?? cwd, pkgRoot, top === null, parsed)
123
+ }
124
+
125
+ /** What makes a repo vx-migrate's to map: a Turbo or Nx config. */
126
+ const TOOL_CONFIGS = ['turbo.json', 'turbo.jsonc', 'nx.json']
127
+
128
+ /**
129
+ * The installs `vx init` runs (M, 2026-10-20): vx at `version` globally,
130
+ * with the manager that started init, so a bare `vx` is on PATH, unless
131
+ * the global vx already answers `version`; and, where `pkgRoot` has a
132
+ * package.json, as its devDependency with the repo's own manager.
133
+ */
134
+ export function initInstalls(
135
+ version: string,
136
+ userAgent: string | undefined,
137
+ pkgRoot: string | null,
138
+ globalAt: string | undefined,
139
+ execPath = process.execPath,
140
+ ): { argv: string[]; cwd: string | null }[] {
141
+ const out: { argv: string[]; cwd: string | null }[] = []
142
+ if (globalAt !== version) {
143
+ const runner = /^(npm|pnpm|yarn|bun)\//.exec(userAgent ?? '')?.[1] ?? 'npm'
144
+ const manager = runner as ManagedInstall['manager']
145
+ out.push({ argv: upgradeArgv({ manager, scope: 'global', cwd: '' }, version), cwd: null })
146
+ }
147
+ if (pkgRoot !== null) {
148
+ const install: ManagedInstall = {
149
+ manager: projectManager(pkgRoot, execPath),
150
+ scope: 'project',
151
+ cwd: pkgRoot,
152
+ field: 'devDependencies',
153
+ }
154
+ out.push({ argv: upgradeArgv(install, version), cwd: pkgRoot })
155
+ }
156
+ return out
157
+ }
158
+
159
+ /** The version the global `vx` on PATH answers: not a runner's cache, not a project's bin. */
160
+ function globalVersion(): string | undefined {
161
+ for (const dir of (process.env['PATH'] ?? '').split(path.delimiter)) {
162
+ const p = dir.split(path.sep).join('/')
163
+ if (p.includes('/_npx/') || /\/(bunx|dlx)-/.test(p) || p.includes('/node_modules/.bin'))
164
+ continue
165
+ const bin = path.join(dir, 'vx')
166
+ if (existsSync(bin)) return startedVersion(bin)?.split(' ')[1]
167
+ }
168
+ return undefined
113
169
  }
114
170
 
115
- /** What makes a repo vx-migrate's to map: a package manager's root, a Turbo or Nx config. */
116
- const MANIFESTS = ['package.json', 'pnpm-workspace.yaml', 'turbo.json', 'turbo.jsonc', 'nx.json']
171
+ /**
172
+ * Run `initInstalls`, in order. A source checkout (0.0.0, which npm never
173
+ * had) installs nothing: the release would replace the code under test.
174
+ */
175
+ async function install(pkgRoot: string | null, dry: boolean): Promise<string[]> {
176
+ if (VERSION === '0.0.0') return []
177
+ const version = await targetVersion(undefined, 'vx init')
178
+ const steps = initInstalls(
179
+ version,
180
+ process.env['npm_config_user_agent'],
181
+ pkgRoot,
182
+ globalVersion(),
183
+ )
184
+ if (dry) return steps.map((s) => s.argv.join(' '))
185
+ for (const s of steps) {
186
+ const bin = Bun.which(s.argv[0]!, { PATH: process.env['PATH'] ?? '' })
187
+ if (bin === null) {
188
+ throw new UserError(
189
+ `vx init: ${s.argv[0]} is not on PATH; run it yourself: ${s.argv.join(' ')}`,
190
+ )
191
+ }
192
+ ui.print(
193
+ 'out',
194
+ ui.text([ui.cmd(s.argv.join(' ')), ui.dim(s.cwd === null ? '' : ` in ${s.cwd}`)]),
195
+ )
196
+ const proc = Bun.spawnSync({
197
+ cmd: [bin, ...s.argv.slice(1)],
198
+ cwd: s.cwd ?? process.cwd(),
199
+ stdio: ['inherit', 'inherit', 'inherit'],
200
+ })
201
+ if (proc.exitCode !== 0) {
202
+ throw new UserError(`vx init: ${s.argv.join(' ')} failed (exit ${proc.exitCode})`)
203
+ }
204
+ }
205
+ return steps.map((s) => s.argv.join(' '))
206
+ }
117
207
 
118
208
  /** Write `plugins/<seam>.ts` and its test under `root`: the workspace root, else the cwd. */
119
209
  async function scaffoldPlugin(seam: string, root: string, args: InitArgs): Promise<number> {
@@ -151,17 +241,23 @@ const PROJECT =
151
241
  "import { defineProject } from '@vzn/vx'\n\nexport default defineProject({ tasks: {} })\n"
152
242
 
153
243
  /**
154
- * No package.json: `.vx/workspace.ts` declaring no plugin and a root
155
- * `vx.config.ts` with no task, then the lock. `@vzn/vx` resolves to the
156
- * running vx (core-alias.ts), so the two load with nothing installed.
157
- * A file already here is kept; `--force` replaces it.
244
+ * No Turbo or Nx config: the installs, then `.vx/workspace.ts` declaring
245
+ * no plugin and a root `vx.config.ts` with no task, then the lock.
246
+ * `@vzn/vx` resolves to the running vx (core-alias.ts), so the two load
247
+ * whatever got installed. A file already here is kept; `--force` replaces it.
158
248
  */
159
- async function starter(root: string, noGit: boolean, args: InitArgs): Promise<number> {
249
+ async function starter(
250
+ root: string,
251
+ pkgRoot: string | null,
252
+ noGit: boolean,
253
+ args: InitArgs,
254
+ ): Promise<number> {
160
255
  if (args.mode !== undefined) {
161
256
  throw new UserError(
162
- `vx init: --${args.mode} answers a Turbo or Nx repo's question, and there is no package.json here`,
257
+ `vx init: --${args.mode} answers a Turbo or Nx repo's question, and there is no turbo.json or nx.json here`,
163
258
  )
164
259
  }
260
+ const installs = args.json === true || args.dry ? await install(pkgRoot, true) : []
165
261
  const ext = args.mjs ? 'mjs' : 'ts'
166
262
  const config = `vx.config.${ext}`
167
263
  const files: { path: string; contents: string }[] = []
@@ -185,16 +281,17 @@ async function starter(root: string, noGit: boolean, args: InitArgs): Promise<nu
185
281
  ] as const) {
186
282
  if (!existsSync(path.join(root, name))) files.push({ path: name, contents })
187
283
  }
188
- const vx = vxInvocation(process.env['npm_config_user_agent'], false)
284
+ const vx = VERSION === '0.0.0' ? vxInvocation(process.env['npm_config_user_agent'], false) : 'vx'
189
285
  const next = `${noGit ? 'git init, ' : ''}declare a task in ${kept.find((f) => f.startsWith('vx.config.')) ?? config}, then ${vx} lock && ${vx} run <task>`
190
286
  if (args.json === true) {
191
287
  process.stdout.write(
192
- `${JSON.stringify({ dry: true, source: 'no package.json', files, kept, replaced, todos: [], notes: [], next })}\n`,
288
+ `${JSON.stringify({ dry: true, source: 'no turbo.json or nx.json', files, kept, replaced, todos: [], notes: [], next, installs })}\n`,
193
289
  )
194
290
  return 0
195
291
  }
196
- const rows: ui.FieldRow[] = [['from', 'no package.json: one project, the root']]
292
+ const rows: ui.FieldRow[] = [['from', 'no turbo.json or nx.json: one project, the root']]
197
293
  if (args.dry) rows.push(['dry run', 'nothing written'])
294
+ if (installs.length > 0) rows.push(['install', ...installs])
198
295
  if (files.length > 0) rows.push(['files', ...files.map((f) => f.path)])
199
296
  if (kept.length > 0) rows.push(['kept', ...kept])
200
297
  if (replaced.length > 0) rows.push(['replaced', ...replaced])
@@ -203,6 +300,8 @@ async function starter(root: string, noGit: boolean, args: InitArgs): Promise<nu
203
300
  ui.print('out', ui.report(`vx init ${VERSION}`, [...rows, null, ['next', next]]))
204
301
  return 0
205
302
  }
303
+ const ran = await install(pkgRoot, false)
304
+ if (ran.length > 0) rows.push(['installed', ...ran])
206
305
  for (const f of files) await Bun.write(path.join(root, f.path), f.contents)
207
306
  for (const f of replaced) await unlink(path.join(root, f))
208
307
  // The lock that follows loads the workspace file just written.
@@ -217,7 +316,7 @@ async function starter(root: string, noGit: boolean, args: InitArgs): Promise<nu
217
316
  }
218
317
 
219
318
  /**
220
- * A repo with a package.json is `@vzn/vx-migrate`'s, at the latest
319
+ * A Turbo or Nx repo is `@vzn/vx-migrate`'s, after the global install, at the latest
221
320
  * release: it installs @vzn/vx and every @vzn/vx-* plugin the root lists
222
321
  * at its own version, so init always lands on latest with all of them
223
322
  * together, as `vx upgrade` moves them (M, 2026-10-16: an installed 0.0.637
@@ -232,6 +331,8 @@ async function migrate(dirs: readonly string[], args: InitArgs): Promise<number>
232
331
  args.mode !== undefined && `--${args.mode}`,
233
332
  args.json === true && '--format=json',
234
333
  ].filter((f): f is string => typeof f === 'string')
334
+ // vx-migrate installs the project's own vx.
335
+ if (!args.dry) await install(null, false)
235
336
  // An exact version: `bun x` may answer `@latest` from its cache.
236
337
  const latest = VERSION === '0.0.0' ? 'latest' : await targetVersion(undefined, 'vx init')
237
338
  return Bun.spawn(migrateCommand(dirs, flags, latest), {
@@ -1,7 +1,7 @@
1
1
  // Formatters for `--dry` (human / JSON).
2
2
 
3
3
  import type { AffectedReason, CacheStatus, RunPlan } from '../orchestrator/index.js'
4
- import { formatElapsed, maskedCommand } from '../util/index.js'
4
+ import { formatBytes, formatElapsed, maskedCommand } from '../util/index.js'
5
5
 
6
6
  /**
7
7
  * Human-readable preview. One line per real task (groups hidden, same
@@ -65,7 +65,11 @@ export function formatPlanText(plan: RunPlan): string {
65
65
 
66
66
  const hitParts: string[] = []
67
67
  if (local > 0) hitParts.push(`${local} local`)
68
- if (remote > 0) hitParts.push(`${remote} remote`)
68
+ if (remote > 0) {
69
+ // The remote's sizes, summed: what restoring the hits would download.
70
+ const bytes = real.reduce((sum, t) => sum + (t.remoteBytes ?? 0), 0)
71
+ hitParts.push(bytes > 0 ? `${remote} remote, ${formatBytes(bytes)}` : `${remote} remote`)
72
+ }
69
73
  const hits = local + remote
70
74
  const summary: string[] = [`${real.length} task(s) planned`]
71
75
  if (hits > 0) summary.push(`${hits} cache hit${hits === 1 ? '' : 's'} (${hitParts.join(', ')})`)
@@ -138,10 +138,8 @@ async function verbCache(
138
138
  ws: Exclude<Awaited<ReturnType<typeof workspacePlugins>>, UnresolvedPluginCommand | null>,
139
139
  plugin: string,
140
140
  ): Promise<PluginCache> {
141
- const [{ Cache, pluginCache }, { resolveCache, resolveCacheScope }] = await Promise.all([
142
- import('../cache/index.js'),
143
- import('../orchestrator/index.js'),
144
- ])
141
+ const [{ Cache, pluginCache }, { cacheRefs, resolveCache, resolveCacheScope }] =
142
+ await Promise.all([import('../cache/index.js'), import('../orchestrator/index.js')])
145
143
  const scope = resolveCacheScope(
146
144
  ws.workspaceRoot,
147
145
  ws.branch.trusted === true ? null : ws.branch.scope,
@@ -162,6 +160,7 @@ async function verbCache(
162
160
  cacheDir: ws.cacheDir,
163
161
  warn,
164
162
  scope,
163
+ refs: cacheRefs(ws.workspaceRoot, configuredBaseBranch(ws.workspaceConfig)),
165
164
  localCache,
166
165
  policy: VERB_POLICY,
167
166
  remotes,
@@ -10,7 +10,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
10
10
  },
11
11
  cache: {
12
12
  plugin:
13
- "// `cache`: where artifacts live. A directory stands in for a remote store;\n// core puts it behind the local cache, so a remote error is a miss and a\n// warning, never a failed run.\nimport { link, mkdir, unlink } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, type RemoteCacheLayer, type VxPlugin } from '@vzn/vx'\n\nclass DirRemote implements RemoteCacheLayer {\n constructor(readonly endpoint: string) {}\n async has(hash: string): Promise<boolean> {\n return Bun.file(path.join(this.endpoint, hash)).exists()\n }\n async get(hash: string) {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { body: file, durationMs: undefined } : null\n }\n async put(hash: string, body: Blob): Promise<void> {\n await mkdir(this.endpoint, { recursive: true })\n // Written aside, then linked: on a shared directory a rename can swap\n // the file under a reader, while a link never replaces an existing one.\n // EEXIST is success: the same hash means the same bytes.\n const tmp = path.join(this.endpoint, `.${hash}.${process.pid}`)\n await Bun.write(tmp, body)\n try {\n await link(tmp, path.join(this.endpoint, hash))\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err\n } finally {\n await unlink(tmp)\n }\n }\n}\n\nexport function dirCache(dir: string): VxPlugin {\n return definePlugin(import.meta, {\n cache() {\n return new DirRemote(dir)\n },\n })\n}\n",
13
+ "// `cache`: where artifacts live. A directory stands in for a remote store;\n// core puts it behind the local cache, so a remote error is a miss and a\n// warning, never a failed run.\nimport { link, mkdir, unlink } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, type ArtifactInfo, type RemoteCacheLayer, type VxPlugin } from '@vzn/vx'\n\nclass DirRemote implements RemoteCacheLayer {\n constructor(readonly endpoint: string) {}\n async has(hash: string): Promise<ArtifactInfo | null> {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { size: file.size } : null\n }\n async get(hash: string) {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { body: file, durationMs: undefined } : null\n }\n async put(hash: string, body: Blob): Promise<void> {\n await mkdir(this.endpoint, { recursive: true })\n // Written aside, then linked: on a shared directory a rename can swap\n // the file under a reader, while a link never replaces an existing one.\n // EEXIST is success: the same hash means the same bytes.\n const tmp = path.join(this.endpoint, `.${hash}.${process.pid}`)\n await Bun.write(tmp, body)\n try {\n await link(tmp, path.join(this.endpoint, hash))\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err\n } finally {\n await unlink(tmp)\n }\n }\n}\n\nexport function dirCache(dir: string): VxPlugin {\n return definePlugin(import.meta, {\n cache() {\n return new DirRemote(dir)\n },\n })\n}\n",
14
14
  test: "// A test for the example `cache` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-cache-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `dirCache()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'cache.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { dirCache } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('a run on an empty local cache restores from the directory', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'mkdir -p dist && echo built > dist/out.txt' },\n inputs: { files: ['package.json'] }, cache: { outputs: { files: ['dist/**'] } },\n } } }`,\n )\n await workspace(`dirCache(${JSON.stringify(path.join(root, 'remote'))})`)\n const first = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(first.outcomes.map((o) => o.status)).toEqual(['success'])\n await rm(path.join(root, '.vx', 'cache'), { recursive: true, force: true })\n await rm(path.join(dir, 'dist'), { recursive: true, force: true })\n const second = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(second.outcomes.map((o) => o.status)).toEqual(['cache-hit-remote'])\n expect(await Bun.file(path.join(dir, 'dist', 'out.txt')).text()).toBe('built\\n')\n}, 20_000)\n",
15
15
  },
16
16
  commands: {
package/src/cli/run.ts CHANGED
@@ -518,7 +518,6 @@ export async function resolveRunOptions(
518
518
  }
519
519
  if (parsed.profile !== undefined) opts.profile = parsed.profile
520
520
  // `--affected`'s base is the branch's for the cache scope too.
521
- if (affectedFilter !== undefined) opts.scopeBase = affectedFilter.slice(4, -1)
522
521
 
523
522
  return opts
524
523
  }
@@ -475,7 +475,7 @@ function installedVersion(dir: string, pkg: string): string | undefined {
475
475
  }
476
476
 
477
477
  /** The project's package manager: its lockfile, then `packageManager`, then the tree's shape. */
478
- function projectManager(dir: string, execPath: string): ManagedInstall['manager'] {
478
+ export function projectManager(dir: string, execPath: string): ManagedInstall['manager'] {
479
479
  for (let d = dir; ; d = path.dirname(d)) {
480
480
  if (existsSync(path.join(d, 'pnpm-lock.yaml'))) return 'pnpm'
481
481
  if (existsSync(path.join(d, 'bun.lock')) || existsSync(path.join(d, 'bun.lockb'))) return 'bun'
package/src/config.ts CHANGED
@@ -102,11 +102,9 @@ export interface CacheSettings {
102
102
  * How the local cache keeps an entry. `embeddings` (default `'32KiB'`):
103
103
  * an entry up to this size inside the index; `false`, none. `decompress` (default true): a larger one as its
104
104
  * plain files, which a hit copies or clones out with no decode; off, as
105
- * one `.tar.zst`. `verify` (default `'stat'`): what a hit on plain files
106
- * checks first, each file's size and mtime, every byte (`'hash'`), or
107
- * nothing (`'none'`).
105
+ * one `.tar.zst`.
108
106
  */
109
- store?: { embeddings?: false | string; decompress?: boolean; verify?: 'stat' | 'hash' | 'none' }
107
+ store?: { embeddings?: false | string; decompress?: boolean }
110
108
  /**
111
109
  * Cache transfers at once. `download` and `upload` (per remote) default
112
110
  * to min(96, max(cores × 3, 64)); `restore`, local hits writing their
package/src/index.ts CHANGED
@@ -83,8 +83,8 @@ export { Cache } from './cache/index.js'
83
83
  export type {
84
84
  CacheLayer,
85
85
  PluginCache,
86
+ ArtifactInfo,
86
87
  RemoteCacheLayer,
87
- RemoteKey,
88
88
  RemoteOpenContext,
89
89
  RunRecord,
90
90
  InvocationRecord,
@@ -229,24 +229,6 @@ export function affectedRoots(
229
229
  return kept
230
230
  }
231
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
-
250
232
  /**
251
233
  * The requested tasks `--affected` keeps, in their order: one the user
252
234
  * named (`pkg#task`), one in a project another include selected outright
@@ -120,6 +120,7 @@ export {
120
120
  currentBranch,
121
121
  detectCi,
122
122
  localBranchScope,
123
+ cacheRefs,
123
124
  resolveCacheScope,
124
125
  } from './run-context.js'
125
126
  export type { CiContext, GitContext, HostContext, WorkspaceIdentity } from './run-context.js'
@@ -92,14 +92,6 @@ export interface RunOptions {
92
92
  remoteRequested?: boolean
93
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
103
95
  /**
104
96
  * The default policy's remote write is off (`remote:r`): the CLI passes it
105
97
  * off CI (`CI` unset, `0` or `false`) unless `--cache-policy` or
@@ -54,6 +54,8 @@ export interface PlannedTask {
54
54
  * eager task is the default and saying so on every line says nothing.
55
55
  */
56
56
  download?: 'deferred'
57
+ /** A remote hit's stored size, when the remote said it: what restoring it downloads. */
58
+ remoteBytes?: number
57
59
  /** Why `--affected` kept this requested task; absent without `--affected` and on a dependency it pulled in. */
58
60
  affected?: AffectedReason
59
61
  }
@@ -139,6 +141,7 @@ export interface PlanArgs {
139
141
  */
140
142
  export async function plan(args: PlanArgs): Promise<RunPlan> {
141
143
  const cacheStatusById = new Map<string, CacheStatus>()
144
+ const remoteBytesById = new Map<string, number>()
142
145
  // Keyed first, probed after: a remote that can batch (`hasMany`) is
143
146
  // asked once for every key, one that cannot is asked per key.
144
147
  const probed = new Map<string, string>()
@@ -191,13 +194,15 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
191
194
  },
192
195
  })
193
196
  if (probed.size > 0) {
194
- await args.cache.remoteHasMany?.([...new Set(probed.values())])
197
+ const infos = await args.cache.remoteHasMany?.([...new Set(probed.values())])
195
198
  for (const [id, hash] of probed) {
196
199
  const where = await args.cache.has(hash)
197
200
  cacheStatusById.set(
198
201
  id,
199
202
  where === null ? 'miss' : where === 'remote' ? 'hit-remote' : 'hit-local',
200
203
  )
204
+ const size = where === 'remote' ? infos?.get(hash)?.size : undefined
205
+ if (size !== undefined) remoteBytesById.set(id, size)
201
206
  }
202
207
  }
203
208
 
@@ -225,6 +230,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
225
230
  deps: node.deps,
226
231
  ...(executor !== undefined ? { executor } : {}),
227
232
  ...(args.downloadOf?.(id) === 'deferred' ? { download: 'deferred' as const } : {}),
233
+ ...(remoteBytesById.has(id) ? { remoteBytes: remoteBytesById.get(id)! } : {}),
228
234
  })
229
235
  }
230
236
 
@@ -9,7 +9,6 @@
9
9
 
10
10
  import path from 'node:path'
11
11
  import {
12
- type BranchScope,
13
12
  type Cache,
14
13
  type CacheLayer,
15
14
  type CachePolicy,
@@ -556,8 +555,6 @@ export interface CacheHost extends CacheContext {
556
555
  readonly policy: CachePolicy
557
556
  /** Filled with each remote and what its policy lets it do, for the plugins' `ctx.cache`. */
558
557
  readonly remotes?: ResolvedRemote[]
559
- /** The run's branch scope, which picks each remote key. */
560
- readonly branch?: BranchScope
561
558
  /** Uploads at once, per remote (`cache.concurrency.upload`). */
562
559
  readonly uploadConcurrency?: number
563
560
  }
@@ -574,7 +571,7 @@ export async function resolveCache(
574
571
  plugins: readonly VxPlugin[],
575
572
  host: CacheHost,
576
573
  ): Promise<CacheLayer> {
577
- const { localCache, policy, remotes, branch, uploadConcurrency, ...ctx } = host
574
+ const { localCache, policy, remotes, uploadConcurrency, ...ctx } = host
578
575
  const resolved: ResolvedRemote[] = []
579
576
  for (const plugin of plugins) {
580
577
  if (plugin.cache === undefined) continue
@@ -593,7 +590,7 @@ export async function resolveCache(
593
590
  }
594
591
  return resolved.length === 0
595
592
  ? localCache
596
- : new LayeredCache(localCache, resolved, branch, uploadConcurrency)
593
+ : new LayeredCache(localCache, resolved, uploadConcurrency)
597
594
  }
598
595
 
599
596
  /** The plugin each resolved executor came from, so a hook of it that throws is named. */
@@ -346,10 +346,9 @@ export interface WorkspaceHookContext {
346
346
  * What a CI knows of the run's branch (owner, 2026-10-17): `scope`, the
347
347
  * cache scope (`'vznjs/vx:feat-x'`; `VX_CACHE_SCOPE` beats it, and it
348
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. `trusted`: the run has no scope, whatever git
351
- * names, and reads and writes only the default keys (a merge queue's run,
352
- * owner 2026-10-18). A later plugin's field wins.
349
+ * with (a pull request's base), for a bare `--affected`. `trusted`: the
350
+ * run has no scope, whatever git names (a merge queue's run, owner
351
+ * 2026-10-18). The scope keys plugin entries only. A later plugin's field wins.
353
352
  */
354
353
  export interface BranchFacts {
355
354
  scope?: string
@@ -506,11 +505,16 @@ export interface ScheduleHookContext extends BaseContext {
506
505
  /** A `cache` hook's context: the workspace and the run's scope, never the local store or the policy, which core owns. */
507
506
  export interface CacheContext extends BaseContext {
508
507
  /**
509
- * The run's cache scope (`'vznjs/vx:feat-x'`), `undefined` when none. Core already keys a scoped run's
510
- * writes apart; a remote reads it to pick a credential or check a token's
511
- * claims (an OIDC branch) for every call it makes.
508
+ * The run's cache scope (`'vznjs/vx:feat-x'`), `undefined` when none: what
509
+ * plugins' `ctx.cache` entries and stored runs are keyed apart by.
512
510
  */
513
511
  readonly scope: string | undefined
512
+ /**
513
+ * The refs the run reads, in order (`['refs/heads/feat-x', 'refs/heads/main']`):
514
+ * its own branch, the branch it merges into, the default branch. A remote
515
+ * keys or scopes its entries by them; core keys task entries by none.
516
+ */
517
+ readonly refs: readonly string[]
514
518
  }
515
519
 
516
520
  export interface OutputContext extends BaseContext {
@@ -26,7 +26,6 @@ import {
26
26
  writesRunStore,
27
27
  type PluginCache,
28
28
  type ResolvedRemote,
29
- BranchScope,
30
29
  applyGitEnumeration,
31
30
  gitPathspecs,
32
31
  MAX_SCOPED_PATHSPECS,
@@ -34,14 +33,11 @@ import {
34
33
  lazyGitEnumeration,
35
34
  measureFsClockLag,
36
35
  DEFAULT_STORE,
37
- type StoreOptions,
38
36
  } from '../cache/index.js'
39
37
  import {
40
38
  buildPackageGraph,
41
39
  computeNestedProjectDirs,
42
40
  findWorkspaceRoot,
43
- type AffectedChanges,
44
- branchChanges,
45
41
  configuredBaseBranch,
46
42
  type LoadReads,
47
43
  PROJECT_CONFIG_FILENAMES,
@@ -81,13 +77,13 @@ import {
81
77
  lockReader,
82
78
  type LoadedProjects,
83
79
  } from './projects.js'
84
- import { keptByAffected, keyedByChange } from './affected-tasks.js'
80
+ import { keptByAffected } from './affected-tasks.js'
85
81
  import { applyGraphStage } from './graph-stage.js'
86
82
  import { keyExcludedDependencies } from './excluded-keys.js'
87
83
  import { FingerprintWatch } from './fingerprint-watch.js'
88
84
  import type { VxPlugin } from './plugin.js'
89
85
  import { askIgnoredOutputs, gatedOutputs } from './stable-keys.js'
90
- import { currentBranch, resolveCacheScope } from './run-context.js'
86
+ import { cacheRefs, currentBranch, resolveCacheScope } from './run-context.js'
91
87
  import { createHashCache, type HashCache } from './task-hash.js'
92
88
  import { type Logger, warnOf } from '../report/index.js'
93
89
  import type { RunOptions } from './options.js'
@@ -291,33 +287,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
291
287
  reused !== undefined
292
288
  ? Promise.resolve(reused.projects)
293
289
  : discoverProjects(workspaceRoot, readLock, plugins, cacheDir, warnOf(log), git)
294
- // What the branch changed decides which tasks read the scope (owner,
295
- // 2026-10-17). Only a scoped run's first remote call asks git: a run the
296
- // local cache answers makes no remote call, and diffing beside discovery
297
- // cost it 20-60 ms at 1k-5k projects.
298
- const scopeChanges: (() => Promise<AffectedChanges>) | undefined =
299
- cacheScope === undefined
300
- ? undefined
301
- : options.scopeChanges !== undefined
302
- ? async () => options.scopeChanges!
303
- : () =>
304
- branchChanges(
305
- workspaceRoot,
306
- options.scopeBase ?? branchFacts.base,
307
- baseBranch,
308
- discovering,
309
- {
310
- untracked: async () => (await git.start()).untracked,
311
- // A scoped enumeration lists only its projects: null asks git.
312
- links: async () => {
313
- const g = await git.start()
314
- if (!g.scopes.includes('.')) return null
315
- const out: string[] = []
316
- for (const [rel, oid] of g.trusted) if (oid.startsWith('120000:')) out.push(rel)
317
- return out
318
- },
319
- },
320
- )
321
290
  const projectMetas = await discovering
322
291
  mark('discover projects')
323
292
 
@@ -383,8 +352,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
383
352
  DEFAULT_STORE.embeddings) as number,
384
353
  decompress: (resolveSetting('cache.store.decompress', workspaceConfig).value ??
385
354
  DEFAULT_STORE.decompress) as boolean,
386
- verify: (resolveSetting('cache.store.verify', workspaceConfig).value ??
387
- DEFAULT_STORE.verify) as StoreOptions['verify'],
388
355
  },
389
356
  )
390
357
  const unwritable = localCache.readOnlyNotice()
@@ -519,9 +486,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
519
486
  // cache plugin.
520
487
  let cache: CacheLayer
521
488
  const remotes: ResolvedRemote[] = []
522
- // A scope only picks remote keys (owner, 2026-10-17): the local store
523
- // keeps every branch's entries under the task keys.
524
- const branchScope = cacheScope === undefined ? undefined : new BranchScope(cacheScope)
525
489
  if (options.remoteCache) {
526
490
  remotes.push({
527
491
  remote: options.remoteCache,
@@ -532,16 +496,16 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
532
496
  }
533
497
  try {
534
498
  cache = options.remoteCache
535
- ? new LayeredCache(localCache, remotes, branchScope, options.uploadConcurrency)
499
+ ? new LayeredCache(localCache, remotes, options.uploadConcurrency)
536
500
  : await resolveCache(plugins, {
537
501
  workspaceRoot,
538
502
  cacheDir,
539
503
  warn: warnOf(log),
540
504
  scope: cacheScope,
505
+ refs: cacheRefs(workspaceRoot, baseBranch),
541
506
  localCache,
542
507
  policy,
543
508
  remotes,
544
- ...(branchScope !== undefined ? { branch: branchScope } : {}),
545
509
  ...(options.uploadConcurrency !== undefined
546
510
  ? { uploadConcurrency: options.uploadConcurrency }
547
511
  : {}),
@@ -776,13 +740,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
776
740
  if (hasHook(plugins, 'key')) {
777
741
  await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: warnOf(log) })
778
742
  }
779
- // The final inputs decide which tasks the branch changed: they read
780
- // the scope, every other task the default key.
781
- if (branchScope !== undefined && scopeChanges !== undefined) {
782
- branchScope.routeTasks(async () =>
783
- keyedByChange(nodes, await scopeChanges(), projects, packageGraph),
784
- )
785
- }
786
743
  refuseFiltersThroughGroups(nodes)
787
744
  const exclude = options.excludeDependencies
788
745
  let keyOnly: ReadonlyMap<string, TaskNode> = new Map()