@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
@@ -1,5 +1,5 @@
1
1
  // What a config may SAY — the schema validators for `vx.config.*` and
2
- // `vx.workspace.*`, split from the loader (which decides HOW a file is
2
+ // `.vx/workspace.*`, split from the loader (which decides HOW a file is
3
3
  // evaluated) on 2026-09-10. Every object level refuses a key it does not
4
4
  // know (tests/schema-unknown-keys), every message names the level and what
5
5
  // it accepts, and `validateProjectConfig` is the one boundary the loader,
@@ -17,7 +17,6 @@ import {
17
17
  } from '../config.js'
18
18
  import {
19
19
  CORE_VERBS,
20
- MAX_TIMEOUT_MS,
21
20
  nearest,
22
21
  normalizeGlob,
23
22
  parseDuration,
@@ -29,6 +28,14 @@ import {
29
28
  asTrees,
30
29
  isLiteralPattern,
31
30
  } from '../util/index.js'
31
+ import {
32
+ parseSettingValue,
33
+ setting,
34
+ SETTING_GROUPS,
35
+ SETTINGS,
36
+ settingEnv,
37
+ settingRefusal,
38
+ } from '../settings.js'
32
39
  import { compileNameGlob, parseFilter } from './filter.js'
33
40
  import { nonJsonMessage, nonJsonPaths } from './json-data.js'
34
41
 
@@ -37,14 +44,14 @@ import { nonJsonMessage, nonJsonPaths } from './json-data.js'
37
44
  // declared no plugins and ran the workspace bare, `cacheDirectory` left the
38
45
  // cache where it was — a config that loads and quietly does nothing it says.
39
46
  const CACHE_SCOPE_RULE =
40
- "must be 'trusted', 'read-only', or a scope name of letters, digits and . _ - / @ (at most 128), like 'pr-123'"
47
+ "must be a scope name of letters, digits and . _ - / @ (at most 128), like 'pr-123'"
41
48
 
42
- /** `cacheScope`'s rule, shared with `VX_CACHE_SCOPE`. */
49
+ /** `cache.scope`'s rule, shared with `VX_CACHE_SCOPE`. */
43
50
  export function isCacheScope(v: unknown): v is string {
44
51
  return typeof v === 'string' && /^[\w.@/-]{1,128}$/.test(v)
45
52
  }
46
53
 
47
- /** Why a `VX_CACHE_SCOPE` or `--cache-scope` value is refused, in `cacheScope`'s words. */
54
+ /** Why a `VX_CACHE_SCOPE` or `--cache-scope` value is refused, in `cache.scope`'s words. */
48
55
  export function cacheScopeError(source = 'VX_CACHE_SCOPE'): string {
49
56
  return `${source} ${CACHE_SCOPE_RULE}`
50
57
  }
@@ -63,16 +70,6 @@ const oneOf = (values: readonly string[]): string =>
63
70
  .join(', ')
64
71
  .replace(/, ([^,]*)$/, ' or $1')}`
65
72
 
66
- function envOneOf<T extends string>(
67
- name: string,
68
- raw: string | undefined,
69
- values: readonly T[],
70
- ): T | undefined {
71
- if (raw === undefined || raw === '') return undefined
72
- if (!(values as readonly string[]).includes(raw)) throw new UserError(`${name} ${oneOf(values)}`)
73
- return raw as T
74
- }
75
-
76
73
  function validateInputStability(stability: unknown, at: string): void {
77
74
  if (typeof stability !== 'object' || stability === null || Array.isArray(stability)) {
78
75
  throw new UserError(`${at} must be an object — \`inputStability: { strategy: 'mutable' }\``)
@@ -88,60 +85,132 @@ function validateInputStability(stability: unknown, at: string): void {
88
85
  }
89
86
 
90
87
  /**
91
- * `VX_INPUT_STABILITY` and `VX_INPUT_STABILITY_SEVERITY`, which override
92
- * every task's `cache.inputStability` for the run: each undefined when
93
- * unset or empty, refused when it names no value.
88
+ * `VX_CACHE_INPUT_STABILITY_STRATEGY` and `_SEVERITY`, which override every
89
+ * task's `cache.inputStability` for the run: each undefined when unset or
90
+ * empty, refused when it names no value.
94
91
  */
95
92
  export function inputStabilityEnv(): InputStability {
96
- const strategy = envOneOf('VX_INPUT_STABILITY', process.env['VX_INPUT_STABILITY'], STRATEGIES)
97
- const severity = envOneOf(
98
- 'VX_INPUT_STABILITY_SEVERITY',
99
- process.env['VX_INPUT_STABILITY_SEVERITY'],
100
- SEVERITIES,
101
- )
93
+ const strategy = settingEnv('cache.inputStability.strategy') as InputStability['strategy']
94
+ const severity = settingEnv('cache.inputStability.severity') as InputStability['severity']
102
95
  return {
103
96
  ...(strategy !== undefined ? { strategy } : {}),
104
97
  ...(severity !== undefined ? { severity } : {}),
105
98
  }
106
99
  }
107
100
 
108
- const WORKSPACE_FIELDS = new Set([
109
- 'concurrency',
110
- 'cacheDir',
111
- 'timeout',
112
- 'cacheRetention',
113
- 'affectedBase',
114
- 'cacheScope',
115
- 'rules',
116
- 'schedule',
117
- 'inputStability',
118
- 'plugins',
119
- ])
120
-
121
- type Schedule = NonNullable<WorkspaceConfig['schedule']>
122
- const SCHEDULES = {
123
- 'most-work': true,
124
- 'critical-path': true,
125
- 'direct-dependents': true,
126
- 'ready-order': true,
127
- } satisfies Record<Schedule, true>
101
+ const WORKSPACE_FIELDS = new Set<string>([...SETTING_GROUPS, 'plugins'])
128
102
 
129
- const RULE_FIELDS = new Set(['exclusiveOutputs', 'upfrontKeys'])
103
+ /**
104
+ * `.vx/workspace`'s settings, as `src/settings.ts` lists them: each group's
105
+ * fields, and the nested objects (`run.rules`) with theirs.
106
+ */
107
+ const GROUP_FIELDS = new Map<string, Set<string>>()
108
+ for (const s of SETTINGS) {
109
+ if (s.config === false) continue
110
+ const parts = s.path.split('.')
111
+ for (let i = 1; i < parts.length; i++) {
112
+ const at = parts.slice(0, i).join('.')
113
+ if (!GROUP_FIELDS.has(at)) GROUP_FIELDS.set(at, new Set())
114
+ GROUP_FIELDS.get(at)!.add(parts[i]!)
115
+ }
116
+ }
117
+ // A list of objects, so no setting: no variable or flag can say it.
118
+ GROUP_FIELDS.get('run.rules')!.add('dependencies')
130
119
 
131
- function validateRules(rules: unknown, configPath: string): void {
132
- const where = `${configPath}: \`rules\``
133
- if (rules === null || typeof rules !== 'object' || Array.isArray(rules)) {
134
- throw new UserError(`${where} must be { exclusiveOutputs?: boolean; upfrontKeys?: boolean }`)
120
+ /** A setting's value or the level holding it, held to the schema. */
121
+ function validateSettingsAt(value: unknown, at: string, configPath: string): void {
122
+ const where = `${configPath}: \`${at}\``
123
+ const fields = GROUP_FIELDS.get(at)
124
+ if (fields !== undefined) {
125
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
126
+ if (at === 'cache')
127
+ // Turbo's and Nx's workspace `cache: true` is a task's own `cache` in vx (D-98).
128
+ throw new UserError(
129
+ `${where} must be an object like { dir, policy, scope }; a task caches through its own \`cache\` in its project's vx.config`,
130
+ )
131
+ throw new UserError(`${where} must be an object (fields: ${[...fields].join(', ')})`)
132
+ }
133
+ assertKnownFields(value, fields, where)
134
+ for (const [k, v] of Object.entries(value)) {
135
+ if (v !== undefined) validateSettingsAt(v, `${at}.${k}`, configPath)
136
+ }
137
+ return
135
138
  }
136
- assertKnownFields(rules as Record<string, unknown>, RULE_FIELDS, where)
137
- for (const [name, value] of Object.entries(rules)) {
138
- if (value !== undefined && typeof value !== 'boolean') {
139
- throw new UserError(`${where}.${name} must be true or false`)
139
+ if (at === 'run.rules.dependencies') validateDependencyRules(value, where)
140
+ else validateSettingValue(value, at, where)
141
+ }
142
+
143
+ const RULE_FIELDS = new Set(['from', 'allow', 'deny'])
144
+
145
+ function validateDependencyRules(value: unknown, where: string): void {
146
+ const shape = `an array of { from, allow?, deny? }, each a selector like 'tag:app' or '@acme/*'`
147
+ if (!Array.isArray(value)) throw new UserError(`${where} must be ${shape}`)
148
+ const selector = (v: unknown) => typeof v === 'string' && v.trim() !== '' && v !== 'tag:'
149
+ for (const [i, rule] of (value as unknown[]).entries()) {
150
+ const at = `${where}[${i}]`
151
+ if (typeof rule !== 'object' || rule === null || Array.isArray(rule))
152
+ throw new UserError(`${at} must be { from, allow?, deny? }`)
153
+ assertKnownFields(rule, RULE_FIELDS, at)
154
+ const { from, allow, deny } = rule as Record<string, unknown>
155
+ if (!selector(from)) throw new UserError(`${at}.from must be a selector like 'tag:app'`)
156
+ for (const [k, list] of [
157
+ ['allow', allow],
158
+ ['deny', deny],
159
+ ] as const) {
160
+ if (list !== undefined && (!Array.isArray(list) || !list.every(selector)))
161
+ throw new UserError(`${at}.${k} must be an array of selectors like 'tag:lib'`)
140
162
  }
163
+ // A rule that says nothing reads as a constraint and checks nothing.
164
+ if (allow === undefined && deny === undefined)
165
+ throw new UserError(`${at} names neither \`allow\` nor \`deny\``)
141
166
  }
142
167
  }
143
168
 
144
- const RETENTION_FIELDS = new Set(['olderThan', 'maxSize'])
169
+ /** `value` as the setting at `path` takes it, refused in `where`'s words. */
170
+ function validateSettingValue(value: unknown, path: string, where: string): void {
171
+ const { kind } = setting(path)
172
+ // The bound `vx cache clean` refuses too: each run would empty the cache,
173
+ // the entries it just saved included, so nothing ever hits (item 969).
174
+ if (
175
+ path === 'cache.retention.olderThan' &&
176
+ typeof value === 'string' &&
177
+ parseDuration(value) === 0
178
+ ) {
179
+ throw new UserError(`${where} of 0 evicts every entry after every run`)
180
+ }
181
+ // A file's values are typed: a count or a switch is no string (only a
182
+ // share of the cores is), as a variable's or a flag's must be.
183
+ const untyped =
184
+ (kind.type === 'concurrency' && typeof value === 'string' && !value.endsWith('%')) ||
185
+ (kind.type === 'int' && typeof value !== 'number') ||
186
+ (kind.type === 'bool' && typeof value !== 'boolean')
187
+ if (untyped || parseSettingValue(kind, value) === null) {
188
+ throw new UserError(settingRefusal(path, where, value, shown(value)))
189
+ }
190
+ }
191
+
192
+ /** The rules a setting's kind cannot say. */
193
+ function validateSettingsExtra(config: WorkspaceConfig, configPath: string): void {
194
+ const dir = config.cache?.dir
195
+ if (dir !== undefined && dir.includes('\0')) {
196
+ // No path can carry one: the mkdir's argument error was reported as an
197
+ // unwritable workspace.
198
+ throw new UserError(`${configPath}: \`cache.dir\` holds a NUL, which no path can carry`)
199
+ }
200
+ const base = config.run?.affectedBase
201
+ // A leading '-' would reach git as an option, not a ref; a NUL fails the
202
+ // spawn with an argument error naming nothing in this file.
203
+ if (base !== undefined && !/^[^-\s\0][^\s\0]*$/.test(base)) {
204
+ throw new UserError(`${configPath}: \`run.affectedBase\` must be a git ref like 'origin/main'`)
205
+ }
206
+ // `policy`'s grammar is the cache module's (`parseCachePolicy`), which
207
+ // this module may not import: the run parses it before any task (prepare.ts).
208
+ const scope = config.cache?.scope
209
+ if (scope !== undefined && !isCacheScope(scope)) {
210
+ throw new UserError(`${configPath}: \`cache.scope\` ${CACHE_SCOPE_RULE}`)
211
+ }
212
+ if (config.cache?.retention !== undefined) validateRetention(config.cache.retention, configPath)
213
+ }
145
214
 
146
215
  /**
147
216
  * A refused value as a message quotes it. A workspace file is evaluated
@@ -153,43 +222,26 @@ function shown(v: unknown): string {
153
222
  return typeof v === 'bigint' ? `${v}n` : (JSON.stringify(v) ?? String(v))
154
223
  }
155
224
 
156
- function validateRetention(retention: unknown, configPath: string): void {
157
- const where = `${configPath}: \`cacheRetention\``
158
- const field = (key: string) => `${configPath}: \`cacheRetention.${key}\``
159
- if (retention === null || typeof retention !== 'object' || Array.isArray(retention)) {
160
- throw new UserError(`${where} must be { olderThan?: '30d', maxSize?: '10G' }`)
161
- }
162
- assertKnownFields(retention as Record<string, unknown>, RETENTION_FIELDS, where)
163
- const { olderThan, maxSize } = retention as { olderThan?: unknown; maxSize?: unknown }
225
+ function validateRetention(
226
+ retention: NonNullable<NonNullable<WorkspaceConfig['cache']>['retention']>,
227
+ configPath: string,
228
+ ): void {
229
+ const { olderThan, maxSize } = retention
164
230
  // An empty object would load and evict nothing, which reads as a policy.
165
231
  if (olderThan === undefined && maxSize === undefined) {
166
- throw new UserError(`${where} names neither \`olderThan\` nor \`maxSize\``)
167
- }
168
- if (
169
- olderThan !== undefined &&
170
- (typeof olderThan !== 'string' || parseDuration(olderThan) === null)
171
- ) {
172
232
  throw new UserError(
173
- `${field('olderThan')} must be a duration like '30d', '12h', '90m' or '45s' (got ${shown(olderThan)})`,
233
+ `${configPath}: \`cache.retention\` names neither \`olderThan\` nor \`maxSize\``,
174
234
  )
175
235
  }
176
- if (maxSize !== undefined && (typeof maxSize !== 'string' || parseSize(maxSize) === null)) {
236
+ // As for olderThan (item 969).
237
+ if (maxSize !== undefined && parseSize(maxSize) === 0) {
177
238
  throw new UserError(
178
- `${field('maxSize')} must be a size like '10G', '500MB' or '64KB' (got ${shown(maxSize)})`,
239
+ `${configPath}: \`cache.retention.maxSize\` of 0 evicts every entry after every run`,
179
240
  )
180
241
  }
181
- // The bounds `vx cache prune` refuses, for the same reason: each evicts
182
- // every entry, and here it would do so at the end of every run, the
183
- // entries that run just saved included, so nothing ever hits (item 969).
184
- if (olderThan !== undefined && parseDuration(olderThan) === 0) {
185
- throw new UserError(`${field('olderThan')} of 0 evicts every entry after every run`)
186
- }
187
- if (maxSize !== undefined && parseSize(maxSize) === 0) {
188
- throw new UserError(`${field('maxSize')} of 0 evicts every entry after every run`)
189
- }
190
242
  if (maxSize !== undefined && /^\d+$/.test(maxSize)) {
191
243
  throw new UserError(
192
- `${field('maxSize')} '${maxSize}' reads as ${maxSize} bytes — give a unit (e.g. '${maxSize}M', '${maxSize}G')`,
244
+ `${configPath}: \`cache.retention.maxSize\` '${maxSize}' reads as ${maxSize} bytes — give a unit (e.g. '${maxSize}MB', '${maxSize}GB')`,
193
245
  )
194
246
  }
195
247
  }
@@ -204,68 +256,10 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
204
256
  // objects of functions by design, the file is evaluated in-process on
205
257
  // every load (never through the config worker), and no key folds its JSON.
206
258
  assertKnownFields(config, WORKSPACE_FIELDS, configPath)
207
- if (config.concurrency !== undefined) {
208
- if (
209
- typeof config.concurrency !== 'number' ||
210
- !Number.isFinite(config.concurrency) ||
211
- config.concurrency < 1 ||
212
- !Number.isInteger(config.concurrency)
213
- ) {
214
- throw new UserError(`${configPath}: \`concurrency\` must be a positive integer`)
215
- }
216
- }
217
- if (config.cacheDir !== undefined && typeof config.cacheDir !== 'string') {
218
- throw new UserError(`${configPath}: \`cacheDir\` must be a string`)
219
- }
220
- // `' '` made a directory named three spaces at the root, hidden by the
221
- // cache's own `.gitignore` (X-22).
222
- if (
223
- typeof config.cacheDir === 'string' &&
224
- config.cacheDir !== '' &&
225
- config.cacheDir.trim() === ''
226
- ) {
227
- throw new UserError(`${configPath}: \`cacheDir\` is only whitespace — name a directory`)
228
- }
229
- // No path can carry one: the mkdir's argument error was reported as an
230
- // unwritable workspace.
231
- if (typeof config.cacheDir === 'string' && config.cacheDir.includes('\0')) {
232
- throw new UserError(`${configPath}: \`cacheDir\` holds a NUL, which no path can carry`)
233
- }
234
- if (config.timeout !== undefined) {
235
- if (
236
- typeof config.timeout !== 'number' ||
237
- !Number.isInteger(config.timeout) ||
238
- config.timeout <= 0
239
- ) {
240
- throw new UserError(`${configPath}: \`timeout\` must be a positive integer (milliseconds)`)
241
- }
242
- assertTimeoutInRange(config.timeout, `${configPath}: \`timeout\``)
243
- }
244
- if (config.cacheRetention !== undefined) validateRetention(config.cacheRetention, configPath)
245
- if (config.affectedBase !== undefined) {
246
- // A leading '-' would reach git as an option, not a ref; a NUL fails the
247
- // spawn with an argument error naming nothing in this file.
248
- if (
249
- typeof config.affectedBase !== 'string' ||
250
- !/^[^-\s\0][^\s\0]*$/.test(config.affectedBase)
251
- ) {
252
- throw new UserError(`${configPath}: \`affectedBase\` must be a git ref like 'origin/main'`)
253
- }
254
- }
255
- if (config.cacheScope !== undefined && !isCacheScope(config.cacheScope)) {
256
- throw new UserError(`${configPath}: \`cacheScope\` ${CACHE_SCOPE_RULE}`)
257
- }
258
- if (config.rules !== undefined) validateRules(config.rules, configPath)
259
- if (config.inputStability !== undefined) {
260
- validateInputStability(config.inputStability, `${configPath}: \`inputStability\``)
261
- }
262
- if (config.schedule !== undefined && !Object.hasOwn(SCHEDULES, config.schedule)) {
263
- throw new UserError(
264
- `${configPath}: \`schedule\` must be one of ${Object.keys(SCHEDULES)
265
- .map((k) => `'${k}'`)
266
- .join(', ')}`,
267
- )
259
+ for (const group of SETTING_GROUPS) {
260
+ if (config[group] !== undefined) validateSettingsAt(config[group], group, configPath)
268
261
  }
262
+ validateSettingsExtra(config, configPath)
269
263
  if (config.plugins !== undefined) {
270
264
  if (!Array.isArray(config.plugins)) {
271
265
  throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
@@ -288,7 +282,7 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
288
282
  }
289
283
  // The loose schema's shape (unknown-typed hooks) plus the one retired
290
284
  // key this loader still refuses by name.
291
- const plug = p as Partial<Plugin> & { name?: unknown; backend?: unknown }
285
+ const plug = p as Partial<Plugin> & { name?: unknown }
292
286
  // A plugin's name is its package name and nothing else. `definePlugin`
293
287
  // reads it and stamps it under a registry symbol; a plain object, or
294
288
  // one whose `name` was overwritten after the stamp, is refused here —
@@ -311,21 +305,6 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
311
305
  `${configPath}: \`plugins[${i}].name\` overrides the package name ('${String(plug.name)}' over '${pkg}') — a plugin's name is its package name; drop the field`,
312
306
  )
313
307
  }
314
- // The whole-run `backend` seam was REMOVED on 2026-08-23 with vx
315
- // cloud: a run always executes in the `vx run` process. Nothing
316
- // consults `.backend` any more, so leaving it in `caps` below meant a
317
- // plugin declaring ONLY `backend` — a third-party one written against
318
- // the old API, say — validated as "contributes a capability" and was
319
- // then silently ignored. Refuse it by name and point at the seam that
320
- // replaced it; a silent no-op is the failure this validation exists
321
- // to prevent.
322
- if (plug.backend !== undefined) {
323
- throw new UserError(
324
- `${configPath}: \`plugins[${i}].backend\` is no longer a capability — the whole-run ` +
325
- `backend seam was removed in 2026-08. Use \`executor\` to change where a single ` +
326
- `task's command runs (see docs/architecture.md § plugin capabilities).`,
327
- )
328
- }
329
308
  // A misspelled hook (`excutor`, `setUp`) was never called and never
330
309
  // said: tasks ran here with the plugin's executor unheard (X-19).
331
310
  for (const key of Object.keys(plug)) {
@@ -412,11 +391,10 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
412
391
  )
413
392
  }
414
393
  for (const file of claim.files as unknown[]) {
415
- // A bare name at the workspace root: a lockfile core folds (taken
416
- // out of the fingerprint), or a root file the plugin's stages read
417
- // (`turbo.json`: nothing to take out; `--affected` and `vx watch`
418
- // ask the claimant about it, item 961). A path is refused: the
419
- // root arm `vx watch` keeps is not recursive.
394
+ // A bare name at the workspace root the plugin reads: a lockfile it
395
+ // folds, or a file its stages read (`turbo.json`; `--affected` and
396
+ // `vx watch` ask the claimant about it, item 961). A path is
397
+ // refused: the root arm `vx watch` keeps is not recursive.
420
398
  if (
421
399
  typeof file !== 'string' ||
422
400
  file === '' ||
@@ -461,9 +439,15 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
461
439
  * typechecked (plain .js, or TS with errors ignored). Catch the worst
462
440
  * shape problems early with a clear message rather than letting them
463
441
  * crash deeper in the orchestrator. Also applied to configs loaded
464
- * back from `vx-lock.json` (a hand-editable file — same boundary).
442
+ * back from `.vx/projects.lock.json` (a hand-editable file — same boundary).
443
+ * `ownConfig` is the config file outputs may not cover (null: none), apart
444
+ * from `configPath` when that is a label (`<path> (after plugin 'x')`).
465
445
  */
466
- export function validateProjectConfig(config: ProjectConfig, configPath: string): void {
446
+ export function validateProjectConfig(
447
+ config: ProjectConfig,
448
+ configPath: string,
449
+ ownConfig: string | null = configPath,
450
+ ): void {
467
451
  // Before the schema, on the object as it stands: the key folds its JSON,
468
452
  // so a value JSON cannot carry is refused here and in the config worker
469
453
  // alike (json-data.ts). A plugin's `project` edit and a lock entry cross
@@ -473,6 +457,15 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
473
457
  // The top level too: `task:` (singular) loaded as a project with no
474
458
  // tasks and every request against it said "no projects declare".
475
459
  assertKnownFields(config, PROJECT_FIELDS, configPath)
460
+ const name: unknown = config.name
461
+ if (name !== undefined) {
462
+ // A task is `<project>#<task>`, split at the first `#`.
463
+ if (typeof name !== 'string' || name === '' || name.trim() !== name || name.includes('#')) {
464
+ throw new UserError(
465
+ `${configPath}: \`name\` must be a non-empty string with no surrounding whitespace and no "#" (a task is <name>#<task>)`,
466
+ )
467
+ }
468
+ }
476
469
  const tags: unknown = config.tags
477
470
  if (
478
471
  tags !== undefined &&
@@ -534,10 +527,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
534
527
  }
535
528
  const timeout = (exec as { timeout?: unknown }).timeout
536
529
  if (timeout !== undefined) {
537
- if (typeof timeout !== 'number' || !Number.isInteger(timeout) || timeout <= 0) {
538
- throw new UserError(`${where}.exec.timeout must be a positive integer (milliseconds)`)
539
- }
540
- assertTimeoutInRange(timeout, `${where}.exec.timeout`)
530
+ validateSettingValue(timeout, 'run.timeout', `${where}.exec.timeout`)
541
531
  }
542
532
  const retries = (exec as { retries?: unknown }).retries
543
533
  if (retries !== undefined) {
@@ -786,6 +776,10 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
786
776
  )
787
777
  }
788
778
  assertKnownFields(outputs, CACHE_OUTPUT_FIELDS, `${where}.cache.outputs`)
779
+ const clean = (outputs as { clean?: unknown }).clean
780
+ if (clean !== undefined && typeof clean !== 'boolean') {
781
+ throw new UserError(`${where}.cache.outputs.clean must be true or false`)
782
+ }
789
783
  const outFiles = (outputs as { files?: unknown }).files
790
784
  if (!Array.isArray(outFiles)) {
791
785
  throw new UserError(`${where}.cache.outputs.files must be an array of glob strings`)
@@ -834,12 +828,12 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
834
828
  // Taken back by a `!`, the file is never cleaned or restored.
835
829
  const own = g.startsWith('!')
836
830
  ? undefined
837
- : ownFilesCovered(g, configPath).find((f) => !takenBack(outFiles as unknown[], f))
831
+ : ownFilesCovered(g, ownConfig).find((f) => !takenBack(outFiles as unknown[], f))
838
832
  if (own !== undefined) {
839
833
  throw new UserError(
840
834
  `${where}.cache.outputs.files: "${g}" covers the project's own ${own} — vx deletes a ` +
841
835
  `task's outputs before it runs and restores them on a hit, so the project would lose ` +
842
- `its manifest and config. Name the directory the task writes, such as "dist/**", ` +
836
+ `its config. Name the directory the task writes, such as "dist/**", ` +
843
837
  `or take the file back with "!${own}".`,
844
838
  )
845
839
  }
@@ -954,7 +948,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
954
948
  // encodes. A new field must be added here deliberately.
955
949
  // `tasks` first: `nearest` breaks a tie by order, and `taks` is one edit
956
950
  // from both.
957
- const PROJECT_FIELDS = new Set(['tasks', 'tags'])
951
+ const PROJECT_FIELDS = new Set(['name', 'tasks', 'tags'])
958
952
  const TASK_FIELDS = new Set(['description', 'exec', 'dependsOn', 'cache'])
959
953
  const EXEC_FIELDS = new Set([
960
954
  'command',
@@ -977,50 +971,7 @@ const CACHE_INPUT_FIELDS = new Set([
977
971
  'runtime',
978
972
  'workspaceRuntime',
979
973
  ])
980
- const CACHE_OUTPUT_FIELDS = new Set(['files', 'workspaceFiles'])
981
-
982
- /** A field a release took out of the schema, and what took its place. */
983
- interface Removal {
984
- /** The vx version whose schema first refused it. */
985
- removedIn: string
986
- /** What to write instead, as a sentence fragment after "use". */
987
- use: string
988
- }
989
-
990
- /**
991
- * Fields the schema once accepted, by the level that held them (keyed by
992
- * that level's field set). A removed field is refused naming its
993
- * replacement and the version that removed it, never as an unknown field a
994
- * reader must research (docs/design/versioning-1.0.md § Deprecation). An
995
- * entry stays for good: a config written against an old release meets it
996
- * whenever it upgrades.
997
- */
998
- export const REMOVED_FIELDS: ReadonlyMap<
999
- ReadonlySet<string>,
1000
- Readonly<Record<string, Removal>>
1001
- > = new Map([
1002
- [
1003
- EXEC_FIELDS,
1004
- {
1005
- // Reservations left core with the `admit` seam (item 157).
1006
- resources: {
1007
- removedIn: '0.0.19',
1008
- use:
1009
- "`@vzn/vx-schedule-history`, which learns each task's reservation from its run " +
1010
- "history (declare one by hand with its `reservations: { 'pkg#task': { cpus, memory } }`)",
1011
- },
1012
- },
1013
- ],
1014
- [
1015
- CACHE_FIELDS,
1016
- {
1017
- rewrittenInputs: {
1018
- removedIn: '0.0.636',
1019
- use: "`inputStability: { strategy: 'immutable' | 'mutable' }` ('withhold', 'compare')",
1020
- },
1021
- },
1022
- ],
1023
- ])
974
+ const CACHE_OUTPUT_FIELDS = new Set(['files', 'workspaceFiles', 'clean'])
1024
975
 
1025
976
  /**
1026
977
  * Another runner's spelling of a field vx has under another name, by the
@@ -1052,7 +1003,7 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1052
1003
  with: '`dependsOn` on each task it runs beside: a persistent one stays up, and this task starts once it is ready',
1053
1004
  cwd: '`cd <dir> && …` in `exec.command`: a task runs in its project directory',
1054
1005
  parallelism:
1055
- '`concurrency: 1` in vx.workspace or `--concurrency 1` on the run: no task runs alone beside others',
1006
+ '`run.concurrency: 1` in .vx/workspace or `--concurrency 1` on the run: no task runs alone beside others',
1056
1007
  configurations:
1057
1008
  'one task per configuration (`build:production`, with its own `exec.command`)',
1058
1009
  },
@@ -1109,14 +1060,13 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1109
1060
  targets: '`tasks` (a target is a task: `exec.command`, `dependsOn`, `cache`)',
1110
1061
  extends: 'an imported module spread into `tasks` (a vx.config is code; nothing is inherited)',
1111
1062
  implicitDependencies: 'a `dependsOn` entry `pkg#task`, or a package.json dependency',
1112
- name: 'the package.json `name` (a project is named by its package)',
1113
1063
  // A package.json's, turbo.json's and project.json's keys, and a
1114
1064
  // task's fields one level too high (D-99).
1115
1065
  scripts:
1116
1066
  "`tasks` (a script is a task's `exec.command`; `vx init` writes them from package.json)",
1117
1067
  pipeline: '`tasks` (`bunx @vzn/vx-migrate` writes them from turbo.json)',
1118
1068
  namedInputs: 'a module the vx.config imports, spread into `cache.inputs.files`',
1119
- root: "no field: a project's directory is where its package.json sits",
1069
+ root: "no field: a project's directory is where its vx.config sits",
1120
1070
  sourceRoot: "a task's `cache.inputs.files`",
1121
1071
  projectType: 'no field: vx runs every project alike',
1122
1072
  ...Object.fromEntries(
@@ -1130,7 +1080,7 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1130
1080
  env: '`tasks.<name>.cache.inputs.env` (to key a task on it) or `tasks.<name>.exec.env`',
1131
1081
  },
1132
1082
  ],
1133
- // Turbo's turbo.json and Nx's nx.json keys, written into vx.workspace
1083
+ // Turbo's turbo.json and Nx's nx.json keys, written into .vx/workspace
1134
1084
  // (D-38).
1135
1085
  [
1136
1086
  WORKSPACE_FIELDS,
@@ -1146,33 +1096,47 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1146
1096
  globalDependencies: '`cache.inputs.workspaceFiles` on the tasks it keys',
1147
1097
  remoteCache:
1148
1098
  'a cache plugin in `plugins` (`turboCache()` or `nxCache()` from `@vzn/vx-migrate`)',
1149
- parallel: '`concurrency`',
1150
- cacheDirectory: '`cacheDir`',
1151
- defaultBase: '`affectedBase`',
1099
+ parallel: '`run.concurrency`',
1100
+ cacheDirectory: '`cache.dir`',
1101
+ defaultBase: '`run.affectedBase`',
1152
1102
  tasksRunnerOptions: 'a cache plugin in `plugins` (`nxCache()` from `@vzn/vx-migrate`)',
1153
1103
  globalPassThroughEnv: '`exec.env.passThrough` on the tasks it passes to',
1154
1104
  // pnpm-workspace.yaml's, Lerna's and the package.json's own keys
1155
- // pasted into vx.workspace: refused with no word on where vx reads
1105
+ // pasted into .vx/workspace: refused with no word on where vx reads
1156
1106
  // them (D-98).
1157
- packages:
1158
- "`workspaces` in package.json or pnpm-workspace.yaml's `packages`, where vx reads the members",
1159
- workspaces:
1160
- "`workspaces` in package.json or pnpm-workspace.yaml's `packages`, where vx reads the members",
1161
- projects:
1162
- "`workspaces` in package.json or pnpm-workspace.yaml's `packages`, where vx reads the members",
1107
+ packages: 'a `vx.config` in each project directory: a project is a directory holding one',
1108
+ workspaces: 'a `vx.config` in each project directory: a project is a directory holding one',
1109
+ projects: 'a `vx.config` in each project directory: a project is a directory holding one',
1163
1110
  ignore:
1164
- 'a `!` pattern in the workspace globs (package.json `workspaces` or pnpm-workspace.yaml)',
1111
+ "no field: a directory with no `vx.config` is no project, and git's ignore rules apply",
1165
1112
  catalog:
1166
1113
  "pnpm-workspace.yaml's or package.json's `catalog`, where the package manager reads it",
1167
1114
  npmClient: "no field: a task's command runs as written, with no package manager in between",
1168
- cache:
1169
- "each task's `cache` in its project's vx.config (a task caches only when it declares one)",
1170
1115
  remote: 'a cache plugin in `plugins`',
1171
1116
  env: '`cache.inputs.env` on the tasks it keys, or `exec.env` on the tasks it passes to',
1172
1117
  },
1173
1118
  ],
1174
1119
  ])
1175
1120
 
1121
+ /**
1122
+ * A top-level typo of a grouped setting, as its path: by its last name
1123
+ * (`concurency`) or its path run together (`cachedir`).
1124
+ */
1125
+ function nearestSetting(key: string): string | undefined {
1126
+ const byName = new Map<string, string>()
1127
+ for (const s of SETTINGS) {
1128
+ if (s.config === false) continue
1129
+ const parts = s.path.split('.')
1130
+ byName.set(parts.at(-1)!, s.path)
1131
+ byName.set(
1132
+ parts.map((p, i) => (i === 0 ? p : p[0]!.toUpperCase() + p.slice(1))).join(''),
1133
+ s.path,
1134
+ )
1135
+ }
1136
+ const name = nearest(key, byName.keys())
1137
+ return name === undefined ? undefined : `\`${byName.get(name)}\``
1138
+ }
1139
+
1176
1140
  function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: string): void {
1177
1141
  // `typeof [] === 'object'`, so an array reaches here and its indices read
1178
1142
  // as fields: `outputs: ['dist/**']` (Turbo's spelling) was refused as
@@ -1185,16 +1149,11 @@ function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: s
1185
1149
  }
1186
1150
  for (const key of Object.keys(value)) {
1187
1151
  if (!allowed.has(key)) {
1188
- const removal = REMOVED_FIELDS.get(allowed)?.[key]
1189
- if (removal !== undefined) {
1190
- throw new UserError(
1191
- `${where} has field "${key}", which vx ${removal.removedIn} removed — use ${removal.use}`,
1192
- )
1193
- }
1194
1152
  // The nearest accepted spelling first: the list says what the level
1195
1153
  // takes, the hint says which one was meant.
1196
1154
  const foreign = FOREIGN_FIELDS.get(allowed)?.[key]
1197
- const near = nearest(key, allowed)
1155
+ const near =
1156
+ nearest(key, allowed) ?? (allowed === WORKSPACE_FIELDS ? nearestSetting(key) : undefined)
1198
1157
  throw new UserError(
1199
1158
  `${where} has unknown field "${key}" (allowed: ${[...allowed].sort().join(', ')})` +
1200
1159
  (foreign !== undefined
@@ -1208,35 +1167,17 @@ function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: s
1208
1167
  }
1209
1168
 
1210
1169
  /**
1211
- * A millisecond delay past `MAX_TIMEOUT_MS` does not mean "effectively never" —
1212
- * `setTimeout` silently reduces it to 1 ms, so the task is killed the instant it
1213
- * spawns and reported `failed`. That is the exact inverse of the declaration,
1214
- * and the only clue is a `TimeoutOverflowWarning` on stderr.
1215
- *
1216
- * Refused rather than clamped, because this is a value the user WROTE and reads
1217
- * back: silently substituting ~24.8 days for the 317 years they asked for would
1218
- * trade one surprise for a quieter one. Costs nothing — such a config never
1219
- * worked, it killed the task in milliseconds, so refusing reports a state that
1220
- * already existed.
1221
- */
1222
- function assertTimeoutInRange(ms: number, where: string): void {
1223
- if (ms <= MAX_TIMEOUT_MS) return
1224
- throw new UserError(
1225
- `${where}: ${ms} ms exceeds the maximum timer delay (${MAX_TIMEOUT_MS} ms, ~24.8 days). ` +
1226
- `Timers larger than this do NOT mean "no limit" — the platform reduces them to 1 ms, so ` +
1227
- `the task would be killed the moment it starts. Omit \`timeout\` for no limit.`,
1228
- )
1229
- }
1230
-
1231
- /**
1232
- * The project file an output glob would take, or null. `outputs: ['**']`
1233
- * loaded, and the clean before the run deleted the project's source,
1234
- * `package.json` and `vx.config` while the run reported success (item
1235
- * 1002); the message for `'.'` had even suggested `**`.
1170
+ * The project's config file if an output glob would take it. `outputs:
1171
+ * ['**']` loaded, and the clean before the run deleted the project's
1172
+ * source and `vx.config` while the run reported success (item 1002); the
1173
+ * message for `'.'` had even suggested `**`. Core names no other project
1174
+ * file: a file a plugin keys on is an input, and an output takes it out of
1175
+ * the input set like any other.
1236
1176
  */
1237
- function ownFilesCovered(glob: string, configPath: string): string[] {
1238
- const g = new Bun.Glob(normalizeGlob(glob))
1239
- return ['package.json', path.basename(configPath)].filter((f) => g.match(f))
1177
+ function ownFilesCovered(glob: string, ownConfig: string | null): string[] {
1178
+ if (ownConfig === null) return []
1179
+ const own = path.basename(ownConfig)
1180
+ return new Bun.Glob(normalizeGlob(glob)).match(own) ? [own] : []
1240
1181
  }
1241
1182
 
1242
1183
  function takenBack(globs: readonly unknown[], file: string): boolean {