@vzn/vx 0.0.636 → 0.0.638

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +246 -213
  3. package/docs/cli.md +791 -705
  4. package/docs/execution.md +68 -76
  5. package/docs/features.md +47 -42
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +392 -205
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +4 -4
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +28 -13
  19. package/src/bin.ts +30 -9
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +124 -53
  22. package/src/cache/chained-cache.ts +4 -0
  23. package/src/cache/git-inputs.ts +277 -63
  24. package/src/cache/index.ts +15 -1
  25. package/src/cache/inputs.ts +102 -36
  26. package/src/cache/key-fold.ts +8 -7
  27. package/src/cache/layer.ts +15 -18
  28. package/src/cache/layered-cache.ts +20 -102
  29. package/src/cache/plugin-cache.ts +248 -0
  30. package/src/cache/policy.ts +32 -27
  31. package/src/cache/run-history.ts +104 -14
  32. package/src/cache/run-store.ts +77 -0
  33. package/src/cache/schema.ts +58 -31
  34. package/src/cache/scoped-cache.ts +237 -0
  35. package/src/cli/cache.ts +173 -34
  36. package/src/cli/completions.ts +11 -7
  37. package/src/cli/core-alias.ts +1 -1
  38. package/src/cli/foreign-flags.ts +13 -12
  39. package/src/cli/help.ts +110 -70
  40. package/src/cli/index.ts +67 -24
  41. package/src/cli/info.ts +43 -27
  42. package/src/cli/init.ts +115 -407
  43. package/src/cli/last.ts +157 -9
  44. package/src/cli/lock.ts +356 -122
  45. package/src/cli/plan-format.ts +7 -5
  46. package/src/cli/plugin-commands.ts +81 -14
  47. package/src/cli/plugin-templates.ts +11 -11
  48. package/src/cli/run.ts +116 -160
  49. package/src/cli/select.ts +50 -47
  50. package/src/cli/show.ts +83 -59
  51. package/src/cli/task-verb.ts +6 -13
  52. package/src/cli/upgrade.ts +415 -149
  53. package/src/cli/watch-cycle.ts +9 -2
  54. package/src/cli/watch-filter.ts +39 -61
  55. package/src/cli/watch-fs.ts +29 -16
  56. package/src/cli/watch-judge.ts +18 -4
  57. package/src/cli/watch-set.ts +16 -42
  58. package/src/cli/watch.ts +227 -230
  59. package/src/cli/why.ts +38 -18
  60. package/src/cli/workspace-config.ts +33 -70
  61. package/src/config.ts +169 -70
  62. package/src/exec/env.ts +1 -31
  63. package/src/exec/executor.ts +1 -5
  64. package/src/exec/kill-tree.ts +2 -1
  65. package/src/exec/sandbox-runtime.ts +12 -1
  66. package/src/exec/sandbox-violations.ts +9 -1
  67. package/src/graph/dependency-spec.ts +1 -1
  68. package/src/graph/index.ts +1 -0
  69. package/src/graph/priorities.ts +1 -1
  70. package/src/graph/scheduler.ts +27 -7
  71. package/src/graph/task-graph.ts +76 -24
  72. package/src/handoff.ts +169 -0
  73. package/src/index.ts +35 -58
  74. package/src/orchestrator/affected-tasks.ts +15 -8
  75. package/src/orchestrator/command-windows.ts +44 -0
  76. package/src/orchestrator/deferred-outputs.ts +12 -10
  77. package/src/orchestrator/doctor.ts +98 -110
  78. package/src/orchestrator/download-policy.ts +3 -3
  79. package/src/orchestrator/events.ts +19 -3
  80. package/src/orchestrator/excluded-keys.ts +0 -2
  81. package/src/orchestrator/execute-task.ts +147 -69
  82. package/src/orchestrator/failure-mode.ts +19 -10
  83. package/src/orchestrator/fingerprint-watch.ts +18 -15
  84. package/src/orchestrator/forecast.ts +10 -2
  85. package/src/orchestrator/framed-output.ts +9 -5
  86. package/src/orchestrator/graph-stage.ts +5 -5
  87. package/src/orchestrator/history.ts +126 -30
  88. package/src/orchestrator/hit-restore.ts +15 -3
  89. package/src/orchestrator/index.ts +20 -5
  90. package/src/orchestrator/local-shortcircuit.ts +2 -1
  91. package/src/orchestrator/lockfile-claim.ts +79 -20
  92. package/src/orchestrator/logger.ts +80 -41
  93. package/src/orchestrator/metrics.ts +78 -9
  94. package/src/orchestrator/miss-save.ts +3 -0
  95. package/src/orchestrator/options.ts +42 -29
  96. package/src/orchestrator/output-host.ts +72 -0
  97. package/src/orchestrator/plan.ts +6 -8
  98. package/src/orchestrator/plugin-host.ts +100 -66
  99. package/src/orchestrator/plugin.ts +173 -67
  100. package/src/orchestrator/prepare.ts +163 -78
  101. package/src/orchestrator/projects.ts +105 -99
  102. package/src/orchestrator/remote-prefetch.ts +11 -6
  103. package/src/orchestrator/run-context.ts +25 -6
  104. package/src/orchestrator/run-failures.ts +17 -8
  105. package/src/orchestrator/run-lock.ts +1 -1
  106. package/src/orchestrator/run-records.ts +1 -1
  107. package/src/orchestrator/run-report.ts +9 -9
  108. package/src/orchestrator/run-settings.ts +48 -0
  109. package/src/orchestrator/run.ts +244 -119
  110. package/src/orchestrator/sandbox-fix.ts +39 -20
  111. package/src/orchestrator/sandbox-request.ts +84 -13
  112. package/src/orchestrator/signals.ts +1 -1
  113. package/src/orchestrator/stable-keys.ts +13 -11
  114. package/src/orchestrator/status-line.ts +49 -7
  115. package/src/orchestrator/summary.ts +115 -106
  116. package/src/orchestrator/task-hash.ts +12 -83
  117. package/src/orchestrator/telemetry.ts +4 -4
  118. package/src/settings.ts +428 -0
  119. package/src/{orchestrator → util}/colors.ts +5 -0
  120. package/src/util/config-frame.ts +31 -2
  121. package/src/util/errors.ts +33 -1
  122. package/src/util/essential-env.ts +31 -0
  123. package/src/util/hide-env.ts +107 -0
  124. package/src/util/index.ts +16 -3
  125. package/src/util/paths.ts +10 -0
  126. package/src/util/refusal-notice.ts +111 -0
  127. package/src/util/report.ts +31 -0
  128. package/src/util/settle.ts +49 -30
  129. package/src/util/size.ts +23 -4
  130. package/src/util/timing.ts +3 -2
  131. package/src/util/ui.ts +186 -0
  132. package/src/util/ulid.ts +1 -1
  133. package/src/util/verbs.ts +0 -15
  134. package/src/workspace/affected.ts +84 -232
  135. package/src/workspace/config-eval.ts +15 -20
  136. package/src/workspace/config-imports.ts +78 -3
  137. package/src/workspace/config-schema.ts +209 -268
  138. package/src/workspace/dependency-rules.ts +65 -0
  139. package/src/workspace/filter.ts +2 -2
  140. package/src/workspace/fingerprint.ts +11 -121
  141. package/src/workspace/index.ts +20 -27
  142. package/src/workspace/load-reads.ts +4 -4
  143. package/src/workspace/lockfile.ts +96 -140
  144. package/src/workspace/migration.ts +307 -215
  145. package/src/workspace/package-graph.ts +30 -216
  146. package/src/workspace/project-loader.ts +80 -285
  147. package/src/workspace/repo-id.ts +2 -1
  148. package/src/workspace/workspace.ts +363 -831
  149. package/src/cache/config-evals.ts +0 -130
  150. package/src/workspace/config-cache.ts +0 -661
  151. package/src/workspace/migrate-scripts.ts +0 -1121
package/src/cli/run.ts CHANGED
@@ -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, sayNotice, sayLine } 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,32 +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
- /** `--cache-scope <name>`: this run's `cacheScope`, above `VX_CACHE_SCOPE` and the workspace's. */
67
+ /** `--cache-scope <name>`: this run's `cache.scope`, above `VX_CACHE_SCOPE` and the workspace's. */
56
68
  cacheScope?: string
57
- frozen: boolean
58
- /**
59
- * `--retry <n>` / `--retry=<n>`: run-level retry default for tasks
60
- * without their own `exec.retries`. Undefined when not passed.
61
- */
62
- retries: number | undefined
63
- /**
64
- * `--timeout <ms>` / `--timeout=<ms>`: run-level default task timeout for
65
- * tasks without their own `exec.timeout`. Sits above the `VX_TASK_TIMEOUT`
66
- * env and workspace `timeout` defaults. Undefined when not passed.
67
- */
68
- timeout: number | undefined
69
- outputLogs?: 'full' | 'errors-only' | 'none' | 'hash-only'
70
- download?: 'all' | 'toplevel' | 'none'
69
+ /** `vx watch --poll` (`watch.poll`). */
70
+ poll?: true
71
71
  forwardArgs: string[]
72
72
  verbosity: number
73
73
  dry: 'text' | 'json' | undefined
@@ -100,25 +100,42 @@ export interface RunArgs {
100
100
  // quoted literal, so the doc-drift test does not count it as an accepted flag.
101
101
  const RETIRED_EXCLUDE_DEPENDENCIES = /^--excludeDependencies(=|$)/
102
102
 
103
- /**
104
- * `--concurrency <n>` is a positive decimal integer, or `<n>%` of the
105
- * cores this process may use (`50%` on eight cores is 4, never below 1;
106
- * over 100% is allowed for I/O-bound work; a container's CPU quota caps
107
- * the count the way it caps the default). The percent form resolves
108
- * here, at parse time, so the run sees one number and the summary says
109
- * what it ran with.
110
- */
111
- export function parseConcurrency(v: string, cpus = machineParallelism()): number | null {
112
- const pct = /^(\d+)%$/.exec(v)
113
- if (pct) {
114
- const n = parseDecimalInt(pct[1]!)
115
- if (n === null || n < 1) return null
116
- 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 }
117
121
  }
118
- const n = parseDecimalInt(v)
119
- return n === null || n < 1 ? null : n
122
+ return undefined
120
123
  }
121
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
+
122
139
  /** `verb` is the one being parsed for: `vx watch` reads `vx run`'s flags, and
123
140
  * its flag hints named `vx run` (M-58). The caller appends the help pointer. */
124
141
  export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' = 'run'): RunArgs {
@@ -127,12 +144,9 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
127
144
  filters: [],
128
145
  all: false,
129
146
  excludeDependencies: [],
130
- concurrency: undefined,
147
+ settings: {},
131
148
  cache: { ...FULL_CACHE_POLICY },
132
149
  cacheDir: undefined,
133
- frozen: false,
134
- retries: undefined,
135
- timeout: undefined,
136
150
  forwardArgs: [],
137
151
  verbosity: 0,
138
152
  dry: undefined,
@@ -155,9 +169,9 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
155
169
  out.forwardArgs = sepIdx === -1 ? [] : args.slice(sepIdx + 1)
156
170
 
157
171
  // Cache policy is resolved AFTER the loop in precedence order:
158
- // start all-true → apply each `--cache` spec → `--no-cache` forces
172
+ // start all-true → apply each `--cache-policy` spec → `--no-cache` forces
159
173
  // all false → `--force` forces both reads false. So `--no-cache`
160
- // beats `--force`, and `--cache` is the base both may override.
174
+ // beats `--force`, and `--cache-policy` is the base both may override.
161
175
  let cachePolicy: CachePolicy = { ...FULL_CACHE_POLICY }
162
176
  let cacheSpecError: string | undefined
163
177
  let cacheFlag = false
@@ -175,20 +189,6 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
175
189
  error: `--filter requires a value (a project name, glob or path, e.g. --filter app)`,
176
190
  }
177
191
  out.filters.push(v)
178
- } else if (a === '--concurrency' || a?.startsWith('--concurrency=')) {
179
- const v = a === '--concurrency' ? before[++i] : a.slice('--concurrency='.length)
180
- if (v === undefined)
181
- return {
182
- ...out,
183
- error: `--concurrency requires a value (a positive integer, or a share of the cores such as 50%)`,
184
- }
185
- const n = parseConcurrency(v)
186
- if (n === null)
187
- return {
188
- ...out,
189
- error: `--concurrency must be a positive integer, or a share of the cores such as 50% (got ${v})`,
190
- }
191
- out.concurrency = n
192
192
  } else if (a === '--all') {
193
193
  out.all = true
194
194
  } else if (RETIRED_EXCLUDE_DEPENDENCIES.test(a ?? '')) {
@@ -204,7 +204,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
204
204
  // bare flag) and "drop none" (an empty list) are equally defensible
205
205
  // readings, and silently picking either does the opposite of what
206
206
  // half the callers mean. Reject and name both explicit forms; the
207
- // sibling value flags (--retry=, --timeout=, --cache-dir=)
207
+ // sibling value flags (--retries=, --timeout=, --cache-dir=)
208
208
  // reject an empty `=` value the same way.
209
209
  if (raw === '') {
210
210
  return {
@@ -216,60 +216,6 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
216
216
  .split(',')
217
217
  .map((s) => s.trim())
218
218
  .filter((s) => s.length > 0)
219
- } else if (a === '--frozen') {
220
- out.frozen = true
221
- } else if (a === '--retry' || a?.startsWith('--retry=')) {
222
- const v = a === '--retry' ? before[++i] : a.slice('--retry='.length)
223
- if (v === undefined)
224
- return { ...out, error: `--retry requires a value (a non-negative integer)` }
225
- const n = parseDecimalInt(v)
226
- if (n === null) {
227
- return { ...out, error: `--retry must be a non-negative integer (got ${v})` }
228
- }
229
- out.retries = n
230
- } else if (a === '--timeout' || a?.startsWith('--timeout=')) {
231
- const v = a === '--timeout' ? before[++i] : a.slice('--timeout='.length)
232
- if (v === undefined)
233
- return { ...out, error: `--timeout requires a value (a positive integer, in ms)` }
234
- const n = parseDecimalInt(v)
235
- if (n !== null && n > MAX_TIMEOUT_MS) {
236
- // Not "effectively no limit" — setTimeout reduces a delay past 2^31-1
237
- // to 1 ms, so this would kill every task the moment it spawned.
238
- return {
239
- ...out,
240
- error:
241
- `--timeout ${n} exceeds the maximum timer delay (${MAX_TIMEOUT_MS} ms, ~24.8 days); ` +
242
- `larger values are silently reduced to 1 ms. Omit --timeout for no limit.`,
243
- }
244
- }
245
- if (n === null || n <= 0) {
246
- return { ...out, error: `--timeout must be a positive integer in ms (got ${v})` }
247
- }
248
- out.timeout = n
249
- } else if (a === '--output-logs' || a?.startsWith('--output-logs=')) {
250
- const v = a === '--output-logs' ? before[++i] : a.slice('--output-logs='.length)
251
- if (v === undefined || v === '') {
252
- return {
253
- ...out,
254
- error: `--output-logs requires a value (full, errors-only, hash-only, or none)`,
255
- }
256
- }
257
- if (v !== 'full' && v !== 'errors-only' && v !== 'none' && v !== 'hash-only') {
258
- return {
259
- ...out,
260
- error: `--output-logs must be full, errors-only, hash-only, or none (got ${v})`,
261
- }
262
- }
263
- out.outputLogs = v
264
- } else if (a === '--download' || a?.startsWith('--download=')) {
265
- const v = a === '--download' ? before[++i] : a.slice('--download='.length)
266
- if (v === undefined || v === '') {
267
- return { ...out, error: `--download requires a value (all, toplevel, or none)` }
268
- }
269
- if (v !== 'all' && v !== 'toplevel' && v !== 'none') {
270
- return { ...out, error: `--download must be all, toplevel, or none (got ${v})` }
271
- }
272
- out.download = v
273
219
  } else if (a === '--cache-dir' || a?.startsWith('--cache-dir=')) {
274
220
  const v = a === '--cache-dir' ? before[++i] : a.slice('--cache-dir='.length)
275
221
  if (v === undefined || v === '') return { ...out, error: `--cache-dir requires a path` }
@@ -290,15 +236,16 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
290
236
  if (v === undefined || !isCacheScope(v))
291
237
  return { ...out, error: cacheScopeError('--cache-scope') }
292
238
  out.cacheScope = v
293
- } else if (a === '--cache' || a?.startsWith('--cache=')) {
239
+ } else if (flagOf(a, '--cache-policy')) {
294
240
  cacheFlag = true
295
- const v = a === '--cache' ? before[++i] : a.slice('--cache='.length)
241
+ const name = flagOf(a, '--cache-policy')!
242
+ const v = a === name ? before[++i] : a!.slice(name.length + 1)
296
243
  if (v === undefined)
297
244
  return {
298
245
  ...out,
299
- 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)`,
300
247
  }
301
- // A spec with no segment at all applies NOTHING, so `--cache=`
248
+ // A spec with no segment at all applies NOTHING, so `--cache-policy=`
302
249
  // (an empty shell var, or someone reaching for "no cache") left
303
250
  // all four axes ON — the opposite of the intent. Reject it; an
304
251
  // empty flag list for a NAMED layer (`local:`) stays valid and
@@ -306,14 +253,14 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
306
253
  if (!v.split(',').some((seg) => seg.trim().length > 0)) {
307
254
  return {
308
255
  ...out,
309
- 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`,
310
257
  }
311
258
  }
312
259
  try {
313
260
  cachePolicy = parseCachePolicy(v, cachePolicy)
314
261
  if (/remote:[rw]/.test(v)) out.remoteRequested = true
315
262
  } catch (err) {
316
- cacheSpecError = err instanceof Error ? err.message : String(err)
263
+ cacheSpecError = `--cache-policy: ${err instanceof Error ? err.message : String(err)}`
317
264
  }
318
265
  } else if (a === '--no-cache') {
319
266
  noCacheFlag = true
@@ -406,6 +353,16 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
406
353
  return { ...out, error: `--report must be markdown (got ${fmt})` }
407
354
  }
408
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
409
366
  } else if (a !== undefined && a.startsWith('-')) {
410
367
  return { ...out, error: `unknown flag: ${a}${flagHint(verb, a)}` }
411
368
  } else if (a !== undefined) {
@@ -416,30 +373,32 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
416
373
  if (cacheSpecError !== undefined) {
417
374
  return { ...out, error: cacheSpecError }
418
375
  }
419
- // `VX_CACHE` is the spec a run takes when no `--cache` names one.
420
- const envSpec = process.env['VX_CACHE'] || undefined
376
+ // `VX_CACHE_POLICY` is the spec a run takes when no flag names one.
377
+ const envSpec = process.env['VX_CACHE_POLICY'] || undefined
421
378
  if (!cacheFlag && envSpec !== undefined) {
422
379
  if (!envSpec.split(',').some((seg) => seg.trim().length > 0))
423
380
  return {
424
381
  ...out,
425
- error: `VX_CACHE needs a spec like local:r, local:rw, remote:, or local:,remote:rw`,
382
+ error: `VX_CACHE_POLICY needs a spec like local:r, local:rw, remote:, or local:,remote:rw`,
426
383
  }
427
384
  try {
428
385
  cachePolicy = parseCachePolicy(envSpec, cachePolicy)
429
386
  } catch (err) {
430
- return { ...out, error: `VX_CACHE: ${err instanceof Error ? err.message : String(err)}` }
387
+ return {
388
+ ...out,
389
+ error: `VX_CACHE_POLICY: ${err instanceof Error ? err.message : String(err)}`,
390
+ }
431
391
  }
432
392
  if (/remote:[rw]/.test(envSpec)) out.remoteRequested = true
433
393
  }
434
- // Precedence: --no-cache (all off) beats --force (reads off, writes
435
- // kept), both layered on top of any --cache spec.
436
- if (noCacheFlag) {
437
- cachePolicy = { localRead: false, localWrite: false, remoteRead: false, remoteWrite: false }
438
- }
439
- if (forceFlag) {
440
- 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 } : {}),
441
399
  }
442
- out.cache = cachePolicy
400
+ out.cache = applyCacheFlags(cachePolicy, flags)
401
+ if (!cacheFlag && envSpec === undefined) out.cacheFlags = flags
443
402
 
444
403
  if (out.dry !== undefined && out.graph !== undefined) {
445
404
  return { ...out, error: '--dry and --graph are mutually exclusive' }
@@ -495,9 +454,6 @@ export function detectFlow(
495
454
  }
496
455
 
497
456
  /** `CI=0` / `CI=false` are not CI, as the output flow reads them. */
498
- function onCI(v: string | undefined): boolean {
499
- return v !== undefined && v !== '' && v !== '0' && v !== 'false'
500
- }
501
457
 
502
458
  /** The filters a run's scope comes from: `--filter`s, with `--affected` as its `...[<base>]`. */
503
459
  async function scopeFilters(
@@ -585,10 +541,7 @@ export async function resolveRunOptions(
585
541
  ? await resolveFilters(
586
542
  cwd,
587
543
  filterStrings,
588
- {
589
- ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
590
- ...(parsed.frozen ? { frozen: true } : {}),
591
- },
544
+ parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {},
592
545
  affectedFilter,
593
546
  )
594
547
  : undefined
@@ -603,7 +556,7 @@ export async function resolveRunOptions(
603
556
  // scope. An empty project list keeps the bare tasks unselected
604
557
  // while the anchored ones still run.
605
558
  if (anchoredTasks.length === 0) return { nothingSelected: resolved.empty }
606
- process.stderr.write(`vx: ${resolved.empty} — running ${anchoredTasks.join(', ')} only\n`)
559
+ sayNotice('info', 'vx run', `${resolved.empty} — running ${anchoredTasks.join(', ')} only`)
607
560
  projects = []
608
561
  } else {
609
562
  projects = resolved.names
@@ -633,19 +586,18 @@ export async function resolveRunOptions(
633
586
  const opts: RunOptions = {
634
587
  cwd,
635
588
  tasks: [...tasks],
636
- cache: parsed.cache,
589
+ ...(parsed.cacheFlags !== undefined
590
+ ? { cacheFlags: parsed.cacheFlags }
591
+ : { cache: parsed.cache }),
637
592
  ...(parsed.remoteRequested ? { remoteRequested: true } : {}),
638
593
  ...(parsed.cacheScope !== undefined ? { cacheScope: parsed.cacheScope } : {}),
639
594
  // A laptop reads the shared cache and never writes it (owner,
640
- // 2026-10-03); naming the remote in `--cache` is asking to.
595
+ // 2026-10-03); naming the remote in `--cache-policy` is asking to.
641
596
  ...(!parsed.remoteRequested && !onCI(process.env['CI'])
642
- ? { defaultCacheScope: 'read-only' }
597
+ ? { remoteReadOnlyByDefault: true }
643
598
  : {}),
644
599
  flow: detectFlow(parsed),
645
- ...(parsed.frozen ? { frozen: true } : {}),
646
- ...(parsed.outputLogs !== undefined ? { outputLogs: parsed.outputLogs } : {}),
647
600
  ...(parsed.verbosity > 0 ? { summaryTable: true } : {}),
648
- ...(parsed.download !== undefined ? { download: parsed.download } : {}),
649
601
  ...(parsed.continueMode !== undefined ? { continueMode: parsed.continueMode } : {}),
650
602
  forwardArgs: parsed.forwardArgs,
651
603
  // isatty, not process.stdin: touching the stream opens it, and a config
@@ -663,10 +615,10 @@ export async function resolveRunOptions(
663
615
  if (outright !== undefined) opts.selectedOutright = outright
664
616
  if (staged !== undefined) opts.staged = staged
665
617
  if (discovered !== undefined) opts.discovered = discovered
666
- if (parsed.retries !== undefined) opts.retries = parsed.retries
667
- if (parsed.timeout !== undefined) opts.timeout = parsed.timeout
668
618
  if (parsed.cacheDir !== undefined) opts.cacheDir = parsed.cacheDir
669
- 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
+ }
670
622
  if (parsed.summarize !== undefined) opts.summarize = parsed.summarize
671
623
  if (parsed.format === 'json') opts.json = true
672
624
  if (parsed.profile !== undefined) opts.profile = parsed.profile
@@ -713,13 +665,10 @@ export async function runCmd(args: readonly string[]): Promise<number> {
713
665
  // Every run needs git: refused here, not after the user has chosen.
714
666
  const refusal = gitRefusal(await findWorkspaceRoot(cwd))
715
667
  if (refusal !== undefined) {
716
- process.stderr.write(`${refusal.message}\n`)
668
+ sayLine('error', refusal.message, 'vx run')
717
669
  return 1
718
670
  }
719
- const load = {
720
- ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
721
- ...(parsed.frozen ? { frozen: true } : {}),
722
- }
671
+ const load = parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}
723
672
  // `--filter` / `--affected` scope the menu, as they scope a bare task:
724
673
  // the menu listed every project and the anchored pick ran outside it.
725
674
  const scope = await scopeFilters(parsed, cwd)
@@ -730,7 +679,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
730
679
  if ('error' in selected)
731
680
  return refuse('run', args, selected.error, selected.code ?? 'VX_E_USAGE')
732
681
  if ('empty' in selected) {
733
- process.stderr.write(`vx run: ${selected.empty}\n`)
682
+ sayNotice('info', 'vx run', selected.empty)
734
683
  return 0
735
684
  }
736
685
  only = new Set(selected.names)
@@ -746,7 +695,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
746
695
  const resolved = await resolveRunOptions(parsed, cwd, tasks, 'run', scopeOut)
747
696
  if ('error' in resolved) return refuse('run', args, resolved.error, resolved.code ?? 'VX_E_USAGE')
748
697
  if ('nothingSelected' in resolved) {
749
- process.stderr.write(`vx run: ${resolved.nothingSelected}\n`)
698
+ sayNotice('info', 'vx run', resolved.nothingSelected)
750
699
  return 0
751
700
  }
752
701
  const opts = resolved
@@ -766,8 +715,10 @@ export async function runCmd(args: readonly string[]): Promise<number> {
766
715
  if (parsed.dry !== undefined || parsed.graph !== undefined) {
767
716
  const plan = await planRun(opts)
768
717
  if (plan.unresolvedTasks !== undefined && plan.unresolvedTasks.length > 0) {
769
- process.stderr.write(
770
- `vx run: no projects declare task(s): ${plan.unresolvedTasks.join(', ')}.${plan.unresolvedHint ?? ''}\n`,
718
+ sayNotice(
719
+ 'error',
720
+ 'vx run',
721
+ `no projects declare task(s): ${plan.unresolvedTasks.join(', ')}.${plan.unresolvedHint ?? ''}`,
771
722
  )
772
723
  return 1
773
724
  }
@@ -775,14 +726,14 @@ export async function runCmd(args: readonly string[]): Promise<number> {
775
726
  // The run's own line: a dry run that guessed from the scope alone
776
727
  // said no affected project declared a task one did (X-141).
777
728
  if (plan.noneAffected !== undefined) {
778
- process.stderr.write(`${plan.noneAffected}\n`)
729
+ sayLine('info', plan.noneAffected, 'vx run')
779
730
  return 0
780
731
  }
781
732
  if (opts.selectedByDiff === true) {
782
- process.stderr.write(`vx run: no affected project declares task(s): ${tasks.join(', ')}.\n`)
733
+ sayNotice('info', 'vx run', `no affected project declares task(s): ${tasks.join(', ')}`)
783
734
  return 0
784
735
  }
785
- process.stderr.write(`vx run: no projects declare task(s): ${tasks.join(', ')}.\n`)
736
+ sayNotice('error', 'vx run', `no projects declare task(s): ${tasks.join(', ')}`)
786
737
  return 1
787
738
  }
788
739
  if (parsed.graph !== undefined) {
@@ -798,7 +749,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
798
749
  await Bun.write(target, out)
799
750
  } catch (err) {
800
751
  const message = err instanceof Error ? err.message : String(err)
801
- process.stderr.write(`vx run: failed to write graph to ${target}: ${message}\n`)
752
+ sayNotice('error', 'vx run', `failed to write graph to ${target}: ${message}`)
802
753
  return 1
803
754
  }
804
755
  }
@@ -864,10 +815,15 @@ export async function runCmd(args: readonly string[]): Promise<number> {
864
815
  // Same contract as --summarize / --profile: the run already
865
816
  // happened, so a write failure is reported, not fatal.
866
817
  const message = err instanceof Error ? err.message : String(err)
867
- process.stderr.write(`vx: failed to write report to ${target}: ${message}\n`)
818
+ sayNotice('warn', 'vx run', `failed to write report to ${target}: ${message}`)
868
819
  }
869
820
  }
870
821
  }
871
- if (summary.json !== undefined) process.stdout.write(`${JSON.stringify(summary.json, null, 2)}\n`)
822
+ if (summary.json !== undefined) process.stdout.write(`${JSON.stringify(summary.json)}\n`)
872
823
  return summary.ok ? 0 : 1
873
824
  }
825
+
826
+ /** CI=0 and CI=false are "not CI": several vendors set CI=true. */
827
+ function onCI(v: string | undefined): boolean {
828
+ return v !== undefined && v !== '' && v !== '0' && v !== 'false'
829
+ }