@vzn/vx 0.0.635 → 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 +47 -19
  2. package/docs/caching.md +222 -210
  3. package/docs/cli.md +699 -664
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +31 -32
  7. package/docs/schema.md +418 -202
  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 +13 -4
  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 -61
  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 +123 -144
  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 +191 -71
  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 +25 -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 +192 -70
  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 +43 -28
  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 +114 -73
  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 +127 -88
  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 +251 -241
  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 +22 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
package/src/cli/run.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { appendFileSync, mkdirSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { isatty } from 'node:tty'
4
- import { findWorkspaceRoot } from '../workspace/index.js'
4
+ import { cacheScopeError, findWorkspaceRoot, isCacheScope } from '../workspace/index.js'
5
5
  import { translateForeign } from './foreign-flags.js'
6
6
  import { flagHint, formatValue, refuse, seeHelp } from './help.js'
7
7
  import {
@@ -14,6 +14,8 @@ import {
14
14
  } from '../orchestrator/index.js'
15
15
  import type { ContinueMode, TaskOutcome } from '../graph/index.js'
16
16
  import {
17
+ applyCacheFlags,
18
+ type CacheFlags,
17
19
  type CachePolicy,
18
20
  FULL_CACHE_POLICY,
19
21
  gitRefusal,
@@ -21,7 +23,8 @@ import {
21
23
  } from '../cache/index.js'
22
24
  import { affectedFilterFor, findCwdSelection, pickTask, resolveFilters } from './select.js'
23
25
  import { nxTargetHint, taskNamesHere } from './task-verb.js'
24
- import { MAX_TIMEOUT_MS, parseDecimalInt, machineParallelism } from '../util/index.js'
26
+ import { parseDecimalInt } from '../util/index.js'
27
+ import { kindHint, parseSettingValue, type Setting, SETTINGS, settingRefusal } from '../settings.js'
25
28
  import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
26
29
 
27
30
  export interface RunArgs {
@@ -42,30 +45,29 @@ export interface RunArgs {
42
45
  * `[...names]` → drop only these specific dep names.
43
46
  */
44
47
  excludeDependencies: 'all' | string[]
45
- concurrency: number | undefined
46
48
  /**
47
- * Resolved 4-axis cache policy after applying `--cache` / `--no-cache`
49
+ * The settings a flag set, by path (`run.timeout` → ms): each flag
50
+ * src/settings.ts names for the verb, parsed by its kind.
51
+ */
52
+ settings: Record<string, unknown>
53
+ /**
54
+ * Resolved 4-axis cache policy after applying `--cache-policy` / `--no-cache`
48
55
  * / `--force` in precedence order. Defaults to all-on.
49
56
  */
50
57
  cache: CachePolicy
51
- /** `--cache` named a remote axis (`remote:r`, `:w`, `:rw`): the run says so when no plugin serves one. */
58
+ /**
59
+ * Neither `--cache-policy` nor `VX_CACHE_POLICY` named a spec: the run starts from the
60
+ * workspace's `cache.policy` and applies these on top.
61
+ */
62
+ cacheFlags?: CacheFlags
63
+ /** The policy named a remote axis (`remote:r`, `:w`, `:rw`): the run says so when no plugin serves one. */
52
64
  remoteRequested?: boolean
53
65
  /** `--cache-dir <path>`: override the cache directory (cwd-relative). */
54
66
  cacheDir: string | undefined
55
- frozen: boolean
56
- /**
57
- * `--retry <n>` / `--retry=<n>`: run-level retry default for tasks
58
- * without their own `exec.retries`. Undefined when not passed.
59
- */
60
- retries: number | undefined
61
- /**
62
- * `--timeout <ms>` / `--timeout=<ms>`: run-level default task timeout for
63
- * tasks without their own `exec.timeout`. Sits above the `VX_TASK_TIMEOUT`
64
- * env and workspace `timeout` defaults. Undefined when not passed.
65
- */
66
- timeout: number | undefined
67
- outputLogs?: 'full' | 'errors-only' | 'none' | 'hash-only'
68
- download?: 'all' | 'toplevel' | 'none'
67
+ /** `--cache-scope <name>`: this run's `cache.scope`, above `VX_CACHE_SCOPE` and the workspace's. */
68
+ cacheScope?: string
69
+ /** `vx watch --poll` (`watch.poll`). */
70
+ poll?: true
69
71
  forwardArgs: string[]
70
72
  verbosity: number
71
73
  dry: 'text' | 'json' | undefined
@@ -98,25 +100,42 @@ export interface RunArgs {
98
100
  // quoted literal, so the doc-drift test does not count it as an accepted flag.
99
101
  const RETIRED_EXCLUDE_DEPENDENCIES = /^--excludeDependencies(=|$)/
100
102
 
101
- /**
102
- * `--concurrency <n>` is a positive decimal integer, or `<n>%` of the
103
- * cores this process may use (`50%` on eight cores is 4, never below 1;
104
- * over 100% is allowed for I/O-bound work; a container's CPU quota caps
105
- * the count the way it caps the default). The percent form resolves
106
- * here, at parse time, so the run sees one number and the summary says
107
- * what it ran with.
108
- */
109
- export function parseConcurrency(v: string, cpus = machineParallelism()): number | null {
110
- const pct = /^(\d+)%$/.exec(v)
111
- if (pct) {
112
- const n = parseDecimalInt(pct[1]!)
113
- if (n === null || n < 1) return null
114
- return Math.max(1, Math.round((Math.max(1, cpus) * n) / 100))
103
+ /** `name` when `a` is it, bare or with `=`. */
104
+ function flagOf(a: string | undefined, name: string): string | undefined {
105
+ return a === name || a?.startsWith(`${name}=`) ? name : undefined
106
+ }
107
+
108
+ // The settings parsed by their kind alone; the cache flags, `--continue`
109
+ // and `--cache-dir` keep their own branches above.
110
+ const GENERIC_FLAGS = SETTINGS.filter(
111
+ (s) =>
112
+ s.flag?.verb === 'run' &&
113
+ !['run.continue', 'cache.dir', 'cache.policy', 'cache.scope'].includes(s.path),
114
+ )
115
+
116
+ /** The setting a flag names. */
117
+ function settingFlagOf(a: string): { s: Setting; name: string } | undefined {
118
+ for (const s of GENERIC_FLAGS) {
119
+ const name = s.flag!.name
120
+ if (flagOf(a, name) !== undefined) return { s, name }
115
121
  }
116
- const n = parseDecimalInt(v)
117
- return n === null || n < 1 ? null : n
122
+ return undefined
118
123
  }
119
124
 
125
+ /** Each setting a flag sets, and the RunOptions field it lands in. */
126
+ const SETTING_OPTIONS = [
127
+ ['run.concurrency', 'concurrency'],
128
+ ['run.schedule', 'schedule'],
129
+ ['run.branch', 'branch'],
130
+ ['run.timeout', 'timeout'],
131
+ ['run.retries', 'retries'],
132
+ ['run.outputLogs', 'outputLogs'],
133
+ ['run.killGrace', 'killGrace'],
134
+ ['run.teardownTimeout', 'teardownTimeout'],
135
+ ['run.readyNotice', 'readyNotice'],
136
+ ['cache.download', 'download'],
137
+ ] as const satisfies readonly (readonly [string, keyof RunOptions])[]
138
+
120
139
  /** `verb` is the one being parsed for: `vx watch` reads `vx run`'s flags, and
121
140
  * its flag hints named `vx run` (M-58). The caller appends the help pointer. */
122
141
  export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' = 'run'): RunArgs {
@@ -125,12 +144,9 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
125
144
  filters: [],
126
145
  all: false,
127
146
  excludeDependencies: [],
128
- concurrency: undefined,
147
+ settings: {},
129
148
  cache: { ...FULL_CACHE_POLICY },
130
149
  cacheDir: undefined,
131
- frozen: false,
132
- retries: undefined,
133
- timeout: undefined,
134
150
  forwardArgs: [],
135
151
  verbosity: 0,
136
152
  dry: undefined,
@@ -153,11 +169,12 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
153
169
  out.forwardArgs = sepIdx === -1 ? [] : args.slice(sepIdx + 1)
154
170
 
155
171
  // Cache policy is resolved AFTER the loop in precedence order:
156
- // start all-true → apply each `--cache` spec → `--no-cache` forces
172
+ // start all-true → apply each `--cache-policy` spec → `--no-cache` forces
157
173
  // all false → `--force` forces both reads false. So `--no-cache`
158
- // beats `--force`, and `--cache` is the base both may override.
174
+ // beats `--force`, and `--cache-policy` is the base both may override.
159
175
  let cachePolicy: CachePolicy = { ...FULL_CACHE_POLICY }
160
176
  let cacheSpecError: string | undefined
177
+ let cacheFlag = false
161
178
  let noCacheFlag = false
162
179
  let forceFlag = false
163
180
 
@@ -172,20 +189,6 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
172
189
  error: `--filter requires a value (a project name, glob or path, e.g. --filter app)`,
173
190
  }
174
191
  out.filters.push(v)
175
- } else if (a === '--concurrency' || a?.startsWith('--concurrency=')) {
176
- const v = a === '--concurrency' ? before[++i] : a.slice('--concurrency='.length)
177
- if (v === undefined)
178
- return {
179
- ...out,
180
- error: `--concurrency requires a value (a positive integer, or a share of the cores such as 50%)`,
181
- }
182
- const n = parseConcurrency(v)
183
- if (n === null)
184
- return {
185
- ...out,
186
- error: `--concurrency must be a positive integer, or a share of the cores such as 50% (got ${v})`,
187
- }
188
- out.concurrency = n
189
192
  } else if (a === '--all') {
190
193
  out.all = true
191
194
  } else if (RETIRED_EXCLUDE_DEPENDENCIES.test(a ?? '')) {
@@ -201,7 +204,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
201
204
  // bare flag) and "drop none" (an empty list) are equally defensible
202
205
  // readings, and silently picking either does the opposite of what
203
206
  // half the callers mean. Reject and name both explicit forms; the
204
- // sibling value flags (--retry=, --timeout=, --cache-dir=)
207
+ // sibling value flags (--retries=, --timeout=, --cache-dir=)
205
208
  // reject an empty `=` value the same way.
206
209
  if (raw === '') {
207
210
  return {
@@ -213,60 +216,6 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
213
216
  .split(',')
214
217
  .map((s) => s.trim())
215
218
  .filter((s) => s.length > 0)
216
- } else if (a === '--frozen') {
217
- out.frozen = true
218
- } else if (a === '--retry' || a?.startsWith('--retry=')) {
219
- const v = a === '--retry' ? before[++i] : a.slice('--retry='.length)
220
- if (v === undefined)
221
- return { ...out, error: `--retry requires a value (a non-negative integer)` }
222
- const n = parseDecimalInt(v)
223
- if (n === null) {
224
- return { ...out, error: `--retry must be a non-negative integer (got ${v})` }
225
- }
226
- out.retries = n
227
- } else if (a === '--timeout' || a?.startsWith('--timeout=')) {
228
- const v = a === '--timeout' ? before[++i] : a.slice('--timeout='.length)
229
- if (v === undefined)
230
- return { ...out, error: `--timeout requires a value (a positive integer, in ms)` }
231
- const n = parseDecimalInt(v)
232
- if (n !== null && n > MAX_TIMEOUT_MS) {
233
- // Not "effectively no limit" — setTimeout reduces a delay past 2^31-1
234
- // to 1 ms, so this would kill every task the moment it spawned.
235
- return {
236
- ...out,
237
- error:
238
- `--timeout ${n} exceeds the maximum timer delay (${MAX_TIMEOUT_MS} ms, ~24.8 days); ` +
239
- `larger values are silently reduced to 1 ms. Omit --timeout for no limit.`,
240
- }
241
- }
242
- if (n === null || n <= 0) {
243
- return { ...out, error: `--timeout must be a positive integer in ms (got ${v})` }
244
- }
245
- out.timeout = n
246
- } else if (a === '--output-logs' || a?.startsWith('--output-logs=')) {
247
- const v = a === '--output-logs' ? before[++i] : a.slice('--output-logs='.length)
248
- if (v === undefined || v === '') {
249
- return {
250
- ...out,
251
- error: `--output-logs requires a value (full, errors-only, hash-only, or none)`,
252
- }
253
- }
254
- if (v !== 'full' && v !== 'errors-only' && v !== 'none' && v !== 'hash-only') {
255
- return {
256
- ...out,
257
- error: `--output-logs must be full, errors-only, hash-only, or none (got ${v})`,
258
- }
259
- }
260
- out.outputLogs = v
261
- } else if (a === '--download' || a?.startsWith('--download=')) {
262
- const v = a === '--download' ? before[++i] : a.slice('--download='.length)
263
- if (v === undefined || v === '') {
264
- return { ...out, error: `--download requires a value (all, toplevel, or none)` }
265
- }
266
- if (v !== 'all' && v !== 'toplevel' && v !== 'none') {
267
- return { ...out, error: `--download must be all, toplevel, or none (got ${v})` }
268
- }
269
- out.download = v
270
219
  } else if (a === '--cache-dir' || a?.startsWith('--cache-dir=')) {
271
220
  const v = a === '--cache-dir' ? before[++i] : a.slice('--cache-dir='.length)
272
221
  if (v === undefined || v === '') return { ...out, error: `--cache-dir requires a path` }
@@ -282,14 +231,21 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
282
231
  }
283
232
  }
284
233
  out.cacheDir = v
285
- } else if (a === '--cache' || a?.startsWith('--cache=')) {
286
- const v = a === '--cache' ? before[++i] : a.slice('--cache='.length)
234
+ } else if (a === '--cache-scope' || a?.startsWith('--cache-scope=')) {
235
+ const v = a === '--cache-scope' ? before[++i] : a.slice('--cache-scope='.length)
236
+ if (v === undefined || !isCacheScope(v))
237
+ return { ...out, error: cacheScopeError('--cache-scope') }
238
+ out.cacheScope = v
239
+ } else if (flagOf(a, '--cache-policy')) {
240
+ cacheFlag = true
241
+ const name = flagOf(a, '--cache-policy')!
242
+ const v = a === name ? before[++i] : a!.slice(name.length + 1)
287
243
  if (v === undefined)
288
244
  return {
289
245
  ...out,
290
- error: `--cache requires a value (a spec like local:r, local:rw, remote:, or local:,remote:rw)`,
246
+ error: `${name} requires a value (a spec like local:r, local:rw, remote:, or local:,remote:rw)`,
291
247
  }
292
- // A spec with no segment at all applies NOTHING, so `--cache=`
248
+ // A spec with no segment at all applies NOTHING, so `--cache-policy=`
293
249
  // (an empty shell var, or someone reaching for "no cache") left
294
250
  // all four axes ON — the opposite of the intent. Reject it; an
295
251
  // empty flag list for a NAMED layer (`local:`) stays valid and
@@ -297,14 +253,14 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
297
253
  if (!v.split(',').some((seg) => seg.trim().length > 0)) {
298
254
  return {
299
255
  ...out,
300
- error: `--cache needs a spec like local:r, local:rw, remote:, or local:,remote:rw — pass --no-cache to disable every axis`,
256
+ error: `${name} needs a spec like local:r, local:rw, remote:, or local:,remote:rw — pass --no-cache to disable every axis`,
301
257
  }
302
258
  }
303
259
  try {
304
260
  cachePolicy = parseCachePolicy(v, cachePolicy)
305
261
  if (/remote:[rw]/.test(v)) out.remoteRequested = true
306
262
  } catch (err) {
307
- cacheSpecError = err instanceof Error ? err.message : String(err)
263
+ cacheSpecError = `--cache-policy: ${err instanceof Error ? err.message : String(err)}`
308
264
  }
309
265
  } else if (a === '--no-cache') {
310
266
  noCacheFlag = true
@@ -397,6 +353,16 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
397
353
  return { ...out, error: `--report must be markdown (got ${fmt})` }
398
354
  }
399
355
  out.report = fmt
356
+ } else if (verb === 'watch' && a === '--poll') {
357
+ out.poll = true
358
+ } else if (a !== undefined && settingFlagOf(a) !== undefined) {
359
+ const { s: set, name } = settingFlagOf(a)!
360
+ const v = a === name ? before[++i] : a.slice(name.length + 1)
361
+ if (v === undefined || v === '')
362
+ return { ...out, error: `${name} requires a value (${kindHint(set.kind)})` }
363
+ const value = parseSettingValue(set.kind, v)
364
+ if (value === null) return { ...out, error: settingRefusal(set.path, name, v) }
365
+ out.settings[set.path] = value
400
366
  } else if (a !== undefined && a.startsWith('-')) {
401
367
  return { ...out, error: `unknown flag: ${a}${flagHint(verb, a)}` }
402
368
  } else if (a !== undefined) {
@@ -407,15 +373,32 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
407
373
  if (cacheSpecError !== undefined) {
408
374
  return { ...out, error: cacheSpecError }
409
375
  }
410
- // Precedence: --no-cache (all off) beats --force (reads off, writes
411
- // kept), both layered on top of any --cache spec.
412
- if (noCacheFlag) {
413
- cachePolicy = { localRead: false, localWrite: false, remoteRead: false, remoteWrite: false }
376
+ // `VX_CACHE_POLICY` is the spec a run takes when no flag names one.
377
+ const envSpec = process.env['VX_CACHE_POLICY'] || undefined
378
+ if (!cacheFlag && envSpec !== undefined) {
379
+ if (!envSpec.split(',').some((seg) => seg.trim().length > 0))
380
+ return {
381
+ ...out,
382
+ error: `VX_CACHE_POLICY needs a spec like local:r, local:rw, remote:, or local:,remote:rw`,
383
+ }
384
+ try {
385
+ cachePolicy = parseCachePolicy(envSpec, cachePolicy)
386
+ } catch (err) {
387
+ return {
388
+ ...out,
389
+ error: `VX_CACHE_POLICY: ${err instanceof Error ? err.message : String(err)}`,
390
+ }
391
+ }
392
+ if (/remote:[rw]/.test(envSpec)) out.remoteRequested = true
414
393
  }
415
- if (forceFlag) {
416
- cachePolicy = { ...cachePolicy, localRead: false, remoteRead: false }
394
+ // --no-cache (all off) beats --force (reads off, writes kept), both
395
+ // layered on top of the spec; with none named, on the workspace's.
396
+ const flags: CacheFlags = {
397
+ ...(noCacheFlag ? { noCache: true } : {}),
398
+ ...(forceFlag ? { force: true } : {}),
417
399
  }
418
- out.cache = cachePolicy
400
+ out.cache = applyCacheFlags(cachePolicy, flags)
401
+ if (!cacheFlag && envSpec === undefined) out.cacheFlags = flags
419
402
 
420
403
  if (out.dry !== undefined && out.graph !== undefined) {
421
404
  return { ...out, error: '--dry and --graph are mutually exclusive' }
@@ -471,9 +454,6 @@ export function detectFlow(
471
454
  }
472
455
 
473
456
  /** `CI=0` / `CI=false` are not CI, as the output flow reads them. */
474
- function onCI(v: string | undefined): boolean {
475
- return v !== undefined && v !== '' && v !== '0' && v !== 'false'
476
- }
477
457
 
478
458
  /** The filters a run's scope comes from: `--filter`s, with `--affected` as its `...[<base>]`. */
479
459
  async function scopeFilters(
@@ -561,10 +541,7 @@ export async function resolveRunOptions(
561
541
  ? await resolveFilters(
562
542
  cwd,
563
543
  filterStrings,
564
- {
565
- ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
566
- ...(parsed.frozen ? { frozen: true } : {}),
567
- },
544
+ parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {},
568
545
  affectedFilter,
569
546
  )
570
547
  : undefined
@@ -609,18 +586,18 @@ export async function resolveRunOptions(
609
586
  const opts: RunOptions = {
610
587
  cwd,
611
588
  tasks: [...tasks],
612
- cache: parsed.cache,
589
+ ...(parsed.cacheFlags !== undefined
590
+ ? { cacheFlags: parsed.cacheFlags }
591
+ : { cache: parsed.cache }),
613
592
  ...(parsed.remoteRequested ? { remoteRequested: true } : {}),
593
+ ...(parsed.cacheScope !== undefined ? { cacheScope: parsed.cacheScope } : {}),
614
594
  // A laptop reads the shared cache and never writes it (owner,
615
- // 2026-10-03); naming the remote in `--cache` is asking to.
595
+ // 2026-10-03); naming the remote in `--cache-policy` is asking to.
616
596
  ...(!parsed.remoteRequested && !onCI(process.env['CI'])
617
- ? { defaultCacheScope: 'read-only' }
597
+ ? { remoteReadOnlyByDefault: true }
618
598
  : {}),
619
599
  flow: detectFlow(parsed),
620
- ...(parsed.frozen ? { frozen: true } : {}),
621
- ...(parsed.outputLogs !== undefined ? { outputLogs: parsed.outputLogs } : {}),
622
600
  ...(parsed.verbosity > 0 ? { summaryTable: true } : {}),
623
- ...(parsed.download !== undefined ? { download: parsed.download } : {}),
624
601
  ...(parsed.continueMode !== undefined ? { continueMode: parsed.continueMode } : {}),
625
602
  forwardArgs: parsed.forwardArgs,
626
603
  // isatty, not process.stdin: touching the stream opens it, and a config
@@ -638,10 +615,10 @@ export async function resolveRunOptions(
638
615
  if (outright !== undefined) opts.selectedOutright = outright
639
616
  if (staged !== undefined) opts.staged = staged
640
617
  if (discovered !== undefined) opts.discovered = discovered
641
- if (parsed.retries !== undefined) opts.retries = parsed.retries
642
- if (parsed.timeout !== undefined) opts.timeout = parsed.timeout
643
618
  if (parsed.cacheDir !== undefined) opts.cacheDir = parsed.cacheDir
644
- if (parsed.concurrency !== undefined) opts.concurrency = parsed.concurrency
619
+ for (const [path, field] of SETTING_OPTIONS) {
620
+ if (parsed.settings[path] !== undefined) Object.assign(opts, { [field]: parsed.settings[path] })
621
+ }
645
622
  if (parsed.summarize !== undefined) opts.summarize = parsed.summarize
646
623
  if (parsed.format === 'json') opts.json = true
647
624
  if (parsed.profile !== undefined) opts.profile = parsed.profile
@@ -691,10 +668,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
691
668
  process.stderr.write(`${refusal.message}\n`)
692
669
  return 1
693
670
  }
694
- const load = {
695
- ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
696
- ...(parsed.frozen ? { frozen: true } : {}),
697
- }
671
+ const load = parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}
698
672
  // `--filter` / `--affected` scope the menu, as they scope a bare task:
699
673
  // the menu listed every project and the anchored pick ran outside it.
700
674
  const scope = await scopeFilters(parsed, cwd)
@@ -843,6 +817,11 @@ export async function runCmd(args: readonly string[]): Promise<number> {
843
817
  }
844
818
  }
845
819
  }
846
- if (summary.json !== undefined) process.stdout.write(`${JSON.stringify(summary.json, null, 2)}\n`)
820
+ if (summary.json !== undefined) process.stdout.write(`${JSON.stringify(summary.json)}\n`)
847
821
  return summary.ok ? 0 : 1
848
822
  }
823
+
824
+ /** CI=0 and CI=false are "not CI": several vendors set CI=true. */
825
+ function onCI(v: string | undefined): boolean {
826
+ return v !== undefined && v !== '' && v !== '0' && v !== 'false'
827
+ }
package/src/cli/select.ts CHANGED
@@ -14,11 +14,9 @@ import {
14
14
  buildPackageGraph,
15
15
  defaultAffectedBase,
16
16
  findWorkspaceRoot,
17
- type LoadReads,
18
17
  type FingerprintClaims,
19
- FROZEN_WITHOUT_LOCK,
20
- loadProjectConfig,
21
- loadWorkspace,
18
+ lockKey,
19
+ NO_LOCK,
22
20
  parseFilter,
23
21
  type ProjectMeta,
24
22
  readLockfile,
@@ -49,15 +47,15 @@ import {
49
47
  loadCliProjects,
50
48
  loadCliWorkspace,
51
49
  } from './workspace-config.js'
50
+ import { resolveSetting } from '../settings.js'
52
51
 
53
52
  /**
54
53
  * The projects whose tasks declare a `cache.inputs.workspaceFiles` glob
55
54
  * matching a changed path, one inside another project included (item
56
55
  * 954): `--affected` selects them, since the glob is that task's input. Through the run
57
56
  * path's staged load, so a glob a `project` plugin gave a config-less
58
- * package counts; evaluated live as a default run does, or read from the
59
- * lock as a `--frozen` run does. A load that fails drops to the config files that
60
- * do load, one by one — a broken out-of-scope config does not fail a
57
+ * package counts; read from the lock as a run does. A load that fails drops to the lock's
58
+ * entries, one by one — a project the lock does not hold does not fail a
61
59
  * scoped run, and must not fail its selection either.
62
60
  */
63
61
  export async function workspaceGlobOwners(
@@ -85,26 +83,23 @@ export async function workspaceGlobOwners(
85
83
  } catch {
86
84
  // Fall through to the per-file sweep.
87
85
  }
88
- // A frozen run with no lock is refused here, before the tolerant sweep
89
- // below could answer "nothing affected" and exit 0 without ever reaching
90
- // the run's own refusal. Asked only once the staged load failed: a
91
- // frozen load that succeeded read the lock, and a second read parsed it
92
- // again (1,000 projects: a 1.1 MB lock, ~10 ms).
93
- if (load.frozen === true && (await readLockfile(root)) === null) {
94
- throw new UserError(FROZEN_WITHOUT_LOCK)
95
- }
86
+ // No lock is refused here, before the tolerant sweep below could answer
87
+ // "nothing affected" and exit 0 without ever reaching the run's own
88
+ // refusal. The sweep reads each entry from the lock: it never evaluates.
89
+ const lock = await readLockfile(root)
90
+ if (lock === null) throw new UserError(NO_LOCK)
96
91
  const owners: string[] = []
97
- await Promise.all(
98
- projects.map(async (meta) => {
99
- if (meta.configPath === null || meta.configPath === '') return
100
- try {
101
- if (declaresMatch(await loadProjectConfig(meta.configPath))) owners.push(meta.name)
102
- } catch {
103
- // A config that will not load cannot be shown to declare a matching
104
- // glob; surfacing it here would fail a run the loader itself tolerates.
105
- }
106
- }),
107
- )
92
+ for (const meta of projects) {
93
+ if (meta.configPath === null || meta.configPath === '') continue
94
+ const dir = lockKey(root, meta.dir)
95
+ if (!Object.hasOwn(lock.projects, dir)) continue
96
+ try {
97
+ if (declaresMatch(lock.projects[dir]!.config)) owners.push(meta.name)
98
+ } catch {
99
+ // A hand-edited entry that will not read cannot be shown to declare a
100
+ // matching glob; surfacing it here would fail a run the load tolerates.
101
+ }
102
+ }
108
103
  return owners
109
104
  }
110
105
 
@@ -133,9 +128,7 @@ async function workspaceFingerprintClaims(
133
128
  }
134
129
 
135
130
  async function loadWorkspaceProjects(cwd: string): Promise<ProjectMeta[]> {
136
- const reads: LoadReads = new Map()
137
- const root = await findWorkspaceRoot(cwd, reads)
138
- return await discoverCliProjects(await loadWorkspace(root, reads))
131
+ return await discoverCliProjects(await findWorkspaceRoot(cwd))
139
132
  }
140
133
 
141
134
  export async function findCwdProject(cwd: string): Promise<string | null> {
@@ -150,9 +143,8 @@ export async function findCwdProject(cwd: string): Promise<string | null> {
150
143
  export async function findCwdSelection(
151
144
  cwd: string,
152
145
  ): Promise<{ name: string; discovered: { root: string; projects: ProjectMeta[] } } | null> {
153
- const reads: LoadReads = new Map()
154
- const root = await findWorkspaceRoot(cwd, reads)
155
- const projects = await discoverCliProjects(await loadWorkspace(root, reads))
146
+ const root = await findWorkspaceRoot(cwd)
147
+ const projects = await discoverCliProjects(root)
156
148
  const abs = path.resolve(cwd)
157
149
  const within = (dirOf: (p: ProjectMeta) => string): string | null => {
158
150
  let best: string | null = null
@@ -251,14 +243,17 @@ function refusalOf(err: unknown): Refusal {
251
243
 
252
244
  /**
253
245
  * The filter `--affected[=<base>]` stands for: `...[<base>]`, the changed
254
- * projects and their dependents. An empty base is the workspace's
255
- * `affectedBase` (or a plugin's `config` stage, from nx.json's
246
+ * projects and their dependents. An empty base is `run.affectedBase`
247
+ * (its variable, then the workspace's, which a plugin's `config` stage may set from nx.json's
256
248
  * `defaultBase` or `TURBO_SCM_BASE`), then the guess.
257
249
  */
258
250
  export async function affectedFilterFor(cwd: string, affected: string): Promise<string | Refusal> {
259
251
  const root = await findWorkspaceRoot(cwd)
260
252
  let base = affected
261
- if (base === '') base = (await loadCliWorkspace(root)).workspaceConfig?.affectedBase ?? ''
253
+ if (base === '') {
254
+ const ws = (await loadCliWorkspace(root)).workspaceConfig
255
+ base = (resolveSetting('run.affectedBase', ws).value as string | undefined) ?? ''
256
+ }
262
257
  if (base === '') {
263
258
  try {
264
259
  base = await defaultAffectedBase(root)
@@ -339,7 +334,6 @@ export async function resolveFilters(
339
334
  workspaceGlobOwners: (changed: readonly string[], nested: readonly string[]) =>
340
335
  workspaceGlobOwners(root, projects, changed, load, stagedOnce, nested),
341
336
  fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
342
- taskEdges: async () => edges ?? taskEdgesFrom(await stagedOnce()),
343
337
  ...(git !== undefined ? { untracked: async () => (await git.start()).untracked } : {}),
344
338
  }
345
339
  let names: Set<string>
@@ -524,7 +518,7 @@ export async function pickTask(
524
518
  }
525
519
  if (entries.length === 0) {
526
520
  process.stderr.write(
527
- 'vx run: no tasks declared in any project; declare one under `tasks` in a vx.config, or run `vx init` to write them from package.json scripts\n',
521
+ 'vx run: no tasks declared in any project; declare one under `tasks` in a vx.config, or run `vx init` to write them from package.json scripts, then `vx lock`\n',
528
522
  )
529
523
  return null
530
524
  }
@@ -604,7 +598,7 @@ function didYouMeanProject(
604
598
  projects: Iterable<{ name: string }>,
605
599
  ): string {
606
600
  const names = [...projects].map((p) => p.name)
607
- // A scoped name typed without its scope (`vx-mcp` for `@vzn/vx-mcp`) is
601
+ // A scoped name typed without its scope (`vx-otel` for `@vzn/vx-otel`) is
608
602
  // many edits from the whole name and none from the part after the `/`.
609
603
  const byBare = new Map<string, string[]>()
610
604
  for (const n of names) {
@@ -615,7 +609,7 @@ function didYouMeanProject(
615
609
  const typed = pattern.replace(/^!|\.\.\.$|^\.\.\.|\^/g, '')
616
610
  // Two edits from any two-letter name: `--filter //` hinted `ui` (X-13).
617
611
  if (typed === '//') {
618
- return ". `//` is Turbo's root package, but the workspace root is no project here: give it a package.json name and a vx.config"
612
+ return '. `//` is the workspace root, which is no project here: add a vx.config there and run `vx lock`'
619
613
  }
620
614
  const best = nearest(typed, names)
621
615
  if (best !== undefined) return `. Did you mean ${best}?`