@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
@@ -1,5 +1,5 @@
1
- // The workspace doctor's facts — what `vx info` prints and `vx mcp`'s
2
- // `getWorkspaceInfo` returns: versions, the git status cache, the
1
+ // The workspace doctor's facts — what `vx info` prints and its JSON
2
+ // returns: versions, the git status cache, the
3
3
  // workspace's shape, the plugins and the seams each fills, what a run
4
4
  // will use (workers, memory) and where those come from, the cache's
5
5
  // versions and state, the flaky tasks, the lock. One collector, so the
@@ -13,32 +13,27 @@ import {
13
13
  cgroupCpuQuota,
14
14
  cgroupMemoryLimitBytes,
15
15
  executablePath,
16
+ gitEnv,
16
17
  isUnsupportedBun,
18
+ isUserError,
17
19
  machineMemoryBytes,
18
20
  machineParallelism,
19
- maskedLine,
20
21
  parseSize,
21
- relPosix,
22
+ treeBytes,
22
23
  } from '../util/index.js'
24
+ import { vxPath } from '../handoff.js'
23
25
  import { VERSION } from '../version.js'
24
26
  import { probeSandbox, resetSandbox, untracedReason } from '../exec/index.js'
25
27
  import {
26
- computeWorkspaceFingerprint,
27
28
  findWorkspaceRoot,
28
- type LoadReads,
29
- loadWorkspace,
30
29
  lockfilePath,
31
30
  resolveCacheDir,
32
31
  resolveStoreRoot,
33
32
  } from '../workspace/index.js'
34
33
  import { flakyTasks, type FlakyTask } from './failure-mode.js'
35
34
  import type { VxPlugin } from './plugin.js'
36
- import {
37
- discoverProjects,
38
- type LoadProjectsArgs,
39
- loadProjects,
40
- loadWorkspacePlugins,
41
- } from './projects.js'
35
+ import { discoverProjects, loadProjects, loadWorkspacePlugins, lockReader } from './projects.js'
36
+ import { atPath, envName, resolveSetting, SETTINGS, type SettingSource } from '../settings.js'
42
37
 
43
38
  const warnToStderr = (message: string): void => {
44
39
  process.stderr.write(`${message}\n`)
@@ -47,6 +42,10 @@ const warnToStderr = (message: string): void => {
47
42
  /** The doctor's facts, typed: what `--format json` prints and the pretty rows render. */
48
43
  export interface InfoFacts {
49
44
  vx: string
45
+ /** The file that ran: the compiled binary, or the source entry. */
46
+ vxPath: string
47
+ /** The vx that handed this call to the project's own (`VX_HANDED_OFF`), else null. */
48
+ handedOffFrom: string | null
50
49
  bun: string
51
50
  /**
52
51
  * False when `bun` is below `MIN_BUN`. A separate field on purpose: `bun`
@@ -62,23 +61,16 @@ export interface InfoFacts {
62
61
  workspaceRoot: string
63
62
  projects: number
64
63
  tasks: number
65
- /**
66
- * The project configs that did not load, each with the loader's own
67
- * message (the path stripped). A broken config counts as zero tasks
68
- * rather than failing the doctor — and the doctor says which, since a
69
- * `0 tasks` that hides a typo is the one fact a bug report needs.
70
- */
71
- configErrors: Array<{ path: string; message: string }>
72
64
  plugins: Array<{ name: string; seams: string[] }>
73
65
  /**
74
- * The worker count a run defaults to and where it comes from: the
75
- * workspace's `concurrency`, else the cores this process may use — the
66
+ * The worker count a run defaults to and where it comes from:
67
+ * `run.concurrency` (variable, then workspace), else the cores this process may use — the
76
68
  * CPU count capped by a cgroup quota (`cpuQuota`, in cores, null when
77
69
  * none binds).
78
70
  */
79
71
  workers: {
80
72
  count: number
81
- source: 'workspace' | 'cgroup' | 'cores'
73
+ source: 'env' | 'workspace' | 'cgroup' | 'cores'
82
74
  cores: number
83
75
  cpuQuota: number | null
84
76
  }
@@ -88,13 +80,26 @@ export interface InfoFacts {
88
80
  */
89
81
  memory: { usableBytes: number; totalBytes: number; cgroupLimitBytes: number | null }
90
82
  cacheDir: string
83
+ /** Bytes on disk under `cacheDir`: the index and run history, and the entries when no store holds them. */
84
+ cacheDirBytes: number
91
85
  /** The shared store holding the entries, or null when `cacheDir` holds them. */
92
86
  cacheStore: string | null
87
+ /** Bytes on disk under `cacheStore`, shared by the repo's checkouts; null with no store. */
88
+ cacheStoreBytes: number | null
93
89
  cacheVersion: string
94
90
  schemaVersion: string
95
91
  cacheEntries: number
96
92
  cacheBytes: number
97
- /** The workspace's `cacheRetention`, with `maxSize` in bytes; null when none is declared. */
93
+ /**
94
+ * Every setting (src/settings.ts) as a run with no flags takes it: its
95
+ * variable's value, else the workspace's (after the `config` stage),
96
+ * else null with source `default` (docs/schema.md names each default).
97
+ */
98
+ settings: Record<
99
+ string,
100
+ { value: string | number | boolean | null; source: 'env' | 'workspace' | 'default' }
101
+ >
102
+ /** `cache.retention`, with `maxSize` in bytes; null when none is set. */
98
103
  cacheRetention: { olderThan?: string; maxSize?: string; maxBytes?: number } | null
99
104
  orphans: { artifacts: number; bytes: number }
100
105
  runs24h: number
@@ -126,10 +131,9 @@ export interface CollectInfoOptions {
126
131
 
127
132
  export async function collectInfo(cwd: string, opts: CollectInfoOptions = {}): Promise<InfoFacts> {
128
133
  const warn = opts.warn ?? warnToStderr
129
- const reads: LoadReads = new Map()
130
134
  // Both first: outside a workspace, or under a workspace config that does
131
135
  // not load, the doctor refuses at once, with no probe (~180 ms) ahead.
132
- const root = await findWorkspaceRoot(cwd, reads)
136
+ const root = await findWorkspaceRoot(cwd)
133
137
  const workspace = await loadWorkspacePlugins(root, warn)
134
138
  // The sandbox probe asks nothing of the workspace, so it runs under the
135
139
  // loads below instead of after them. Awaited on every path: it resets
@@ -138,7 +142,7 @@ export async function collectInfo(cwd: string, opts: CollectInfoOptions = {}): P
138
142
  // Rejects only if the reset does; awaited below, never unhandled.
139
143
  probing.catch(() => {})
140
144
  try {
141
- return await collectWorkspaceInfo(cwd, root, reads, workspace, opts, probing)
145
+ return await collectWorkspaceInfo(cwd, root, workspace, opts, probing)
142
146
  } catch (err) {
143
147
  // The workspace's refusal is the error worth reporting.
144
148
  await probing.catch(() => {})
@@ -149,7 +153,6 @@ export async function collectInfo(cwd: string, opts: CollectInfoOptions = {}): P
149
153
  async function collectWorkspaceInfo(
150
154
  cwd: string,
151
155
  root: string,
152
- reads: LoadReads,
153
156
  { workspaceConfig, plugins }: Awaited<ReturnType<typeof loadWorkspacePlugins>>,
154
157
  opts: CollectInfoOptions,
155
158
  probing: Promise<Omit<InfoFacts['sandbox'], 'declared'>>,
@@ -159,7 +162,17 @@ async function collectWorkspaceInfo(
159
162
  opts.cacheDir === undefined
160
163
  ? resolveCacheDir(root, workspaceConfig)
161
164
  : path.resolve(cwd, opts.cacheDir)
162
- const metas = await discoverProjects(await loadWorkspace(root, reads), plugins, cacheDir, warn)
165
+ // The lock's projects: none without a lock, or with one a run would
166
+ // refuse, which is `vx lock`'s to report.
167
+ const lock = lockReader(root)
168
+ const held = await lock().then(
169
+ () => true,
170
+ (err: unknown) => {
171
+ if (!isUserError(err)) throw err
172
+ return false
173
+ },
174
+ )
175
+ const metas = held ? await discoverProjects(root, lock, plugins, cacheDir, warn) : []
163
176
  const storeRoot =
164
177
  opts.cacheDir === undefined ? await resolveStoreRoot(root, workspaceConfig) : null
165
178
  const cache = new Cache(cacheDir, undefined, undefined, undefined, 'inspect', storeRoot)
@@ -168,45 +181,34 @@ async function collectWorkspaceInfo(
168
181
  let flaky: FlakyTask[]
169
182
  let taskCount = 0
170
183
  let sandboxed = 0
171
- let configErrors: InfoFacts['configErrors'] = []
172
184
  try {
173
185
  stats = cache.stats()
174
186
  orphans = await cache.orphanStats()
175
187
  flaky = flakyTasks(cache.dbHandle())
176
- // The run path's load — a plugin's `project` stage counts — so the
177
- // doctor's task count is the number a run would see. A broken config
178
- // must not take the doctor down with it: the count then falls back to
179
- // the same load, one project at a time, the broken ones as zero.
180
- const load = {
181
- workspaceRoot: root,
182
- cacheDir,
183
- plugins,
184
- projectMetas: metas,
185
- closure: false,
186
- lock: null,
187
- evalCache: {
188
- store: cache,
189
- workspaceRoot: root,
190
- workspaceFingerprint: await computeWorkspaceFingerprint(root, reads),
191
- },
192
- warn,
193
- } as const
188
+ // The run path's load — the lock, a plugin's `project` stage counted —
189
+ // so the doctor's task count is the number a run would see. No lock, or
190
+ // one a run would refuse, counts zero: broken configs are `vx lock`'s
191
+ // to report.
194
192
  try {
195
- const loaded = await loadProjects({ ...load, seeds: 'all' })
193
+ const loaded = await loadProjects({
194
+ workspaceRoot: root,
195
+ cacheDir,
196
+ plugins,
197
+ projectMetas: metas,
198
+ seeds: 'all',
199
+ closure: false,
200
+ configs: lock,
201
+ warn,
202
+ })
196
203
  for (const p of loaded.projects.values()) {
197
204
  const tasks = p.config.tasks ?? {}
198
205
  taskCount += Object.keys(tasks).length
199
206
  for (const t of Object.values(tasks)) if (t?.exec?.sandbox !== undefined) sandboxed++
200
207
  }
201
- } catch {
202
- // A config that will not load counts as zero, for both numbers: the
203
- // sandbox row's "N tasks declare" must not read 0 because one other
204
- // project's config is broken while this one's declares a sandbox.
205
- ;({
206
- tasks: taskCount,
207
- sandboxed,
208
- errors: configErrors,
209
- } = await countLoadableTasks(load, root))
208
+ } catch (err) {
209
+ if (!isUserError(err)) throw err
210
+ taskCount = 0
211
+ sandboxed = 0
210
212
  }
211
213
  } finally {
212
214
  cache.close()
@@ -217,8 +219,11 @@ async function collectWorkspaceInfo(
217
219
  const git = gitVersion()
218
220
  const statusCache = gitStatusCache(root)
219
221
  const sandbox = { ...(await probing), declared: sandboxed }
222
+ const cacheStore = cache.storeDir ?? storeRoot
220
223
  return {
221
224
  vx: VERSION,
225
+ vxPath: vxPath(),
226
+ handedOffFrom: process.env['VX_HANDED_OFF'] ?? null,
222
227
  // An unsupported Bun does not stop a run, it makes the run's ANSWERS
223
228
  // wrong (util/bun-version.ts measures which). The row a reader already
224
229
  // consults for "is my setup sane" is where that belongs: a warning on
@@ -235,17 +240,19 @@ async function collectWorkspaceInfo(
235
240
  workspaceRoot: root,
236
241
  projects: metas.length,
237
242
  tasks: taskCount,
238
- configErrors,
239
243
  // Which plugins loaded and which seams each fills, in pipeline order —
240
244
  // the answer to "why did this task run there / cache there / not at
241
245
  // all" before reading any config. A declined seam still costs nothing;
242
246
  // this names the declarations, not what a run consulted.
243
247
  plugins: plugins.map((p) => ({ name: p.name, seams: filledSeams(p) })),
244
- workers: workersFact(workspaceConfig?.concurrency),
248
+ workers: workersFact(resolveSetting('run.concurrency', workspaceConfig)),
245
249
  memory: memoryFact(),
246
250
  cacheDir,
251
+ // On disk, not the index's sum: the size `vx cache clean` would free.
252
+ cacheDirBytes: treeBytes(cacheDir),
247
253
  // The store the index records, else the one a first run would open.
248
- cacheStore: cache.storeDir ?? storeRoot,
254
+ cacheStore,
255
+ cacheStoreBytes: cacheStore === null ? null : treeBytes(cacheStore),
249
256
  // The two versions a bug report needs and the reset notice names: the
250
257
  // key prefix (a bump orphans every entry) and the index schema (a
251
258
  // mismatch drops every table).
@@ -253,9 +260,10 @@ async function collectWorkspaceInfo(
253
260
  schemaVersion: SCHEMA_VERSION,
254
261
  cacheEntries: stats.entryCount,
255
262
  cacheBytes: stats.totalBytes,
256
- cacheRetention: retentionFact(workspaceConfig?.cacheRetention),
263
+ settings: settingsFact(workspaceConfig),
264
+ cacheRetention: retentionFact(workspaceConfig),
257
265
  // A row-less artifact is indexed again only when its task asks for its
258
- // key; the rest only `vx cache prune` reclaims (after an upgrade's
266
+ // key; the rest only `vx cache clean` reclaims (after an upgrade's
259
267
  // schema reset, most often).
260
268
  orphans: { artifacts: orphans.orphans, bytes: orphans.orphanBytes },
261
269
  runs24h: stats.runCountLast24h,
@@ -313,11 +321,11 @@ function filledSeams(p: VxPlugin): string[] {
313
321
  * numbers would be the OOM killer's. The doctor says which one a run
314
322
  * reads, because nothing else would.
315
323
  */
316
- function workersFact(workspaceConcurrency: number | undefined): InfoFacts['workers'] {
324
+ function workersFact(set: { value: unknown; source: SettingSource }): InfoFacts['workers'] {
317
325
  const cores = Math.max(1, navigator.hardwareConcurrency)
318
326
  const quota = process.platform === 'linux' ? (cgroupCpuQuota() ?? null) : null
319
- if (workspaceConcurrency !== undefined) {
320
- return { count: workspaceConcurrency, source: 'workspace', cores, cpuQuota: quota }
327
+ if (set.source === 'env' || set.source === 'workspace') {
328
+ return { count: set.value as number, source: set.source, cores, cpuQuota: quota }
321
329
  }
322
330
  const machine = machineParallelism()
323
331
  return { count: machine, source: machine < cores ? 'cgroup' : 'cores', cores, cpuQuota: quota }
@@ -339,6 +347,7 @@ function gitStatusCache(root: string): InfoFacts['gitStatusCache'] {
339
347
  try {
340
348
  const p = Bun.spawnSync({
341
349
  cmd: [executablePath('git'), 'config', '--get-regexp', '^core\\.(fsmonitor|untrackedcache)$'],
350
+ env: gitEnv(),
342
351
  cwd: root,
343
352
  stdout: 'pipe',
344
353
  stderr: 'pipe',
@@ -358,6 +367,7 @@ function gitVersion(): string | null {
358
367
  try {
359
368
  const p = Bun.spawnSync({
360
369
  cmd: [executablePath('git'), '--version'],
370
+ env: gitEnv(),
361
371
  stdout: 'pipe',
362
372
  stderr: 'pipe',
363
373
  })
@@ -371,52 +381,30 @@ function gitVersion(): string | null {
371
381
  }
372
382
  }
373
383
 
374
- async function countLoadableTasks(
375
- load: Omit<LoadProjectsArgs, 'seeds'> & { closure: false },
376
- root: string,
377
- ): Promise<{ tasks: number; sandboxed: number; errors: InfoFacts['configErrors'] }> {
378
- let tasks = 0
379
- let sandboxed = 0
380
- const errors: InfoFacts['configErrors'] = []
381
- // No seeds loads nothing and answers which projects an 'all' load seeds.
382
- const { configured } = await loadProjects({ ...load, seeds: [] })
383
- await Promise.all(
384
- configured.map(async (meta) => {
385
- try {
386
- const loaded = await loadProjects({ ...load, seeds: [meta.name] })
387
- const declared = Object.values(loaded.projects.get(meta.name)?.config.tasks ?? {})
388
- tasks += declared.length
389
- for (const t of declared) if (t?.exec?.sandbox !== undefined) sandboxed++
390
- } catch (err) {
391
- // Counted as zero, and named: the loader's message opens with the
392
- // absolute path — bare for a schema refusal, behind `Project config`
393
- // for an import that failed, and with `:line:col` after it for a
394
- // syntax error — which the row carries workspace-relative instead,
395
- // once (the second form named the file twice, 2026-09-16; the third
396
- // still did).
397
- const raw = err instanceof Error ? err.message : String(err)
398
- const bare = raw.startsWith('Project config ') ? raw.slice('Project config '.length) : raw
399
- const where = meta.configPath ?? meta.dir
400
- const message = bare.startsWith(`${where}:`)
401
- ? bare.slice(where.length + 1).trimStart()
402
- : raw
403
- errors.push({
404
- path: relPosix(root, where),
405
- // A config's own throw is its author's text, a secret too (L-11).
406
- message: maskedLine(message),
407
- })
408
- }
409
- }),
410
- )
411
- errors.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
412
- return { tasks, sandboxed, errors }
384
+ function settingsFact(config: WorkspaceConfig | null): InfoFacts['settings'] {
385
+ const out: InfoFacts['settings'] = {}
386
+ for (const s of SETTINGS) {
387
+ const env = process.env[envName(s.path)]
388
+ const declared = s.config === false ? undefined : atPath(config, s.path)
389
+ out[s.path] =
390
+ env !== undefined && env !== ''
391
+ ? { value: env, source: 'env' }
392
+ : declared !== undefined
393
+ ? { value: declared as string | number | boolean, source: 'workspace' }
394
+ : { value: null, source: 'default' }
395
+ }
396
+ return out
413
397
  }
414
398
 
415
- function retentionFact(r: WorkspaceConfig['cacheRetention']): InfoFacts['cacheRetention'] {
416
- if (r === undefined) return null
399
+ function retentionFact(config: WorkspaceConfig | null): InfoFacts['cacheRetention'] {
400
+ const raw = (path: string): string | undefined =>
401
+ process.env[envName(path)] || (atPath(config, path) as string | undefined)
402
+ const olderThan = raw('cache.retention.olderThan')
403
+ const maxSize = raw('cache.retention.maxSize')
404
+ if (olderThan === undefined && maxSize === undefined) return null
417
405
  return {
418
- ...(r.olderThan !== undefined ? { olderThan: r.olderThan } : {}),
419
- // Validated at load, so it parses.
420
- ...(r.maxSize !== undefined ? { maxSize: r.maxSize, maxBytes: parseSize(r.maxSize)! } : {}),
406
+ ...(olderThan !== undefined ? { olderThan } : {}),
407
+ // Validated before the run (env) or at load (workspace), so it parses.
408
+ ...(maxSize !== undefined ? { maxSize, maxBytes: parseSize(maxSize)! } : {}),
421
409
  }
422
410
  }
@@ -1,4 +1,4 @@
1
- // `--download` policy: which tasks may leave their outputs in the remote
1
+ // `--cache-download` policy: which tasks may leave their outputs in the remote
2
2
  // store instead of landing them on this machine, and which must not.
3
3
  //
4
4
  // See docs/design/download-policy-cas-cache-2026-08.md. Two decisions live
@@ -27,7 +27,7 @@ export type DownloadMode = 'eager' | 'deferred' | 'never'
27
27
  * (the shape the design doc first sketched): that rule fires whenever a
28
28
  * sibling reads the project at all, which is every ordinary workspace —
29
29
  * `test` reads `src/**` while `build` writes `dist/**` — and would leave
30
- * `--download=none` with nothing to defer. Comparing the globs' static
30
+ * `--cache-download=none` with nothing to defer. Comparing the globs' static
31
31
  * prefixes answers the question actually being asked, and stays
32
32
  * conservative in every direction that matters: a leading wildcard yields
33
33
  * `.` and reaches everything, a cacheable task with no declared `files`
@@ -176,7 +176,7 @@ export function resolveDownloadModes(args: {
176
176
  //
177
177
  // SURFACED counts as asked-for. A requested GROUP has no outputs of its
178
178
  // own — `markSurfacedDeps` marks the real tasks it chains — so keying on
179
- // `requested` alone made `vx run ci --download=toplevel` (ci being a
179
+ // `requested` alone made `vx run ci --cache-download=toplevel` (ci being a
180
180
  // group) bring home NOTHING, which is the one thing the mode exists to
181
181
  // avoid. run.ts already pairs the two flags for the same reason when it
182
182
  // decides which persistent children to keep alive.
@@ -254,6 +254,8 @@ export interface OutcomeView {
254
254
  timedOut?: true
255
255
  /** A persistent task that never became ready, and why (see `TaskOutcome`). */
256
256
  notReady?: 'timeout' | 'exited' | 'spawn'
257
+ /** The input that changed while the command ran (see `TaskOutcome`). */
258
+ inputMoved?: string
257
259
  /** A skipped task: the failed (or aborted) task at the root of the block
258
260
  * (see `TaskOutcome`); absent for fail-fast's skip, which nothing blocked. */
259
261
  blockedBy?: string
@@ -301,11 +303,18 @@ export function outcomeWord(o: Pick<OutcomeView, 'status' | 'restored'>): string
301
303
  export function outcomeLabel(
302
304
  o: Pick<
303
305
  OutcomeView,
304
- 'status' | 'restored' | 'exitCode' | 'timedOut' | 'sandboxViolations' | 'notReady' | 'blockedBy'
306
+ | 'status'
307
+ | 'restored'
308
+ | 'exitCode'
309
+ | 'timedOut'
310
+ | 'sandboxViolations'
311
+ | 'notReady'
312
+ | 'inputMoved'
313
+ | 'blockedBy'
305
314
  >,
306
315
  ): string {
307
316
  if (o.status === 'failed')
308
- return failedLabel(o.exitCode, o.timedOut, o.sandboxViolations, o.notReady)
317
+ return failedLabel(o.exitCode, o.timedOut, o.sandboxViolations, o.notReady, o.inputMoved)
309
318
  if (o.status === 'skipped') return skippedLabel(o.blockedBy)
310
319
  return outcomeWord(o)
311
320
  }
@@ -330,7 +339,9 @@ export function skippedLabel(blockedBy: string | undefined): string {
330
339
  * status line, the run report, the Actions annotation): the exit code, and
331
340
  * above 128 the signal it stands for — `failed (exit 137, 128 + SIGKILL)` —
332
341
  * or, for vx's own timeout, `failed (timed out, exit 143)`, or a persistent
333
- * task that never became ready, `failed (never ready: timed out, exit 1)`; a
342
+ * task that never became ready, `failed (never ready: timed out, exit 1)`,
343
+ * or an input changed under `inputStability.severity: 'error'`,
344
+ * `failed (src/a.ts changed during the run, exit 1)`; a
334
345
  * sandboxed task's violations are counted after: `failed (exit 1, 2 sandbox
335
346
  * violations)`.
336
347
  */
@@ -339,6 +350,7 @@ export function failedLabel(
339
350
  timedOut?: true,
340
351
  sandboxViolations?: number,
341
352
  notReady?: 'timeout' | 'exited' | 'spawn',
353
+ inputMoved?: string,
342
354
  ): string {
343
355
  // A sandboxed task that touched what it never declared fails on that
344
356
  // alone (its exit is forced to 1 when it was 0): the count is the reason.
@@ -353,6 +365,9 @@ export function failedLabel(
353
365
  notReady === 'timeout' ? 'timed out' : notReady === 'exited' ? 'exited' : 'spawn failed'
354
366
  return `failed (never ready: ${why}, exit ${exitCode}${violations})`
355
367
  }
368
+ // `inputStability.severity: 'error'` forced the 1: the input is the reason.
369
+ if (inputMoved !== undefined)
370
+ return `failed (${inputMoved === '' ? 'an input' : inputMoved} changed during the run, exit ${exitCode}${violations})`
356
371
  // A timeout's 143 is vx's own SIGTERM: the reason, not the signal, reads.
357
372
  if (timedOut === true) return `failed (timed out, exit ${exitCode}${violations})`
358
373
  const signal = exitSignal(exitCode)
@@ -398,6 +413,7 @@ export function projectOutcome(outcome: TaskOutcome): OutcomeView {
398
413
  if (outcome.saveMs !== undefined) view.saveMs = outcome.saveMs
399
414
  if (outcome.timedOut === true) view.timedOut = true
400
415
  if (outcome.notReady !== undefined) view.notReady = outcome.notReady
416
+ if (outcome.inputMoved !== undefined) view.inputMoved = outcome.inputMoved
401
417
  if (outcome.blockedBy !== undefined) view.blockedBy = outcome.blockedBy
402
418
  if (outcome.restored !== undefined) view.restored = outcome.restored
403
419
  if (outcome.sandboxViolations !== undefined) view.sandboxViolations = outcome.sandboxViolations
@@ -25,7 +25,6 @@ export interface KeyExcludedArgs {
25
25
  dropped: ReadonlyMap<string, readonly string[]>
26
26
  cache: CacheLayer
27
27
  workspaceRoot: string
28
- workspaceFingerprint: string
29
28
  forwardArgs?: readonly string[] | undefined
30
29
  nestedDirsByProject: ReadonlyMap<string, string[]>
31
30
  gitFilesCache: GitFilesCache
@@ -57,7 +56,6 @@ export async function keyExcludedDependencies(args: KeyExcludedArgs): Promise<vo
57
56
  node,
58
57
  upstream,
59
58
  workspaceRoot: args.workspaceRoot,
60
- workspaceFingerprint: args.workspaceFingerprint,
61
59
  cache: args.cache,
62
60
  forwardArgs: args.forwardArgs,
63
61
  nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],