@vzn/vx 0.0.635 → 0.0.637

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +47 -19
  2. package/docs/caching.md +222 -210
  3. package/docs/cli.md +699 -664
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +31 -32
  7. package/docs/schema.md +418 -202
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +2 -2
  16. package/schemas/show.json +13 -4
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -61
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +123 -144
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +191 -71
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +25 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +192 -70
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +43 -28
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +114 -73
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +127 -88
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +251 -241
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +22 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
package/src/bin.ts CHANGED
@@ -1,18 +1,33 @@
1
1
  #!/usr/bin/env -S bun --no-env-file --no-install
2
2
  // Wrapped in an explicit async main so `bun build --compile` accepts
3
3
  // the file. The compile target doesn't allow top-level await.
4
+ import { handOff, localVx, vxPath } from './handoff.js'
5
+
4
6
  let settled = false
5
7
 
6
8
  async function main(): Promise<void> {
7
9
  const argv = process.argv.slice(2)
8
10
  try {
11
+ // Inside the try: `process.cwd()` throws EMFILE when out of descriptors,
12
+ // and that is a one-line UserError below, not a stack.
13
+ const local = await localVx(
14
+ process.cwd(),
15
+ process.execPath,
16
+ Bun.main.startsWith('/$bunfs') || (process.argv[1] ?? '').startsWith('/$bunfs'),
17
+ )
18
+ if (local !== null) await handOff(local, argv)
9
19
  // `vx --version` needs one leaf module; the dispatcher and the util
10
20
  // barrel it pulled in were 20 ms of its 28 (2026-10-01).
11
21
  if (argv.length === 1 && (argv[0] === '--version' || argv[0] === 'version')) {
12
22
  // `Bun.stdout`, not `process.stdout`: the Node stream's first touch is
13
23
  // ~7 ms, half of this verb (2026-10-02). A reader gone (EPIPE) is no
14
24
  // failure of the line.
15
- await Bun.write(Bun.stdout, `vx ${(await import('./version.js')).VERSION}\n`).catch(() => {})
25
+ // Handed off, it says which vx ran and which one handed it the call.
26
+ const from = process.env['VX_HANDED_OFF']
27
+ const where = from === undefined ? '' : ` (${vxPath()}, handed off from ${from})`
28
+ await Bun.write(Bun.stdout, `vx ${(await import('./version.js')).VERSION}${where}\n`).catch(
29
+ () => {},
30
+ )
16
31
  settled = true
17
32
  return
18
33
  }
@@ -87,7 +102,7 @@ async function main(): Promise<void> {
87
102
  * config can leave a timer behind (a top-level `setInterval`, an await that
88
103
  * hit its evaluation budget), and the loop it holds never drained: vx
89
104
  * printed its verdict and hung for good. Only after `main` has settled, so
90
- * `vx watch`, `vx mcp` and a run's kept-alive tasks end when their verb
105
+ * `vx watch`, a plugin's long-lived verb and a run's kept-alive tasks end when their verb
91
106
  * does. `end(cb)` delivers a large write whole on Bun 1.4.2 (4 MiB under a
92
107
  * slow reader; `write('', cb)` delivered 450 KB), the floor `engines.bun`
93
108
  * holds. A stream whose end never calls back leaves the loop to drain. A
@@ -230,6 +230,12 @@ interface PackArgs {
230
230
  * output of the same artifact is packed wherever it is.
231
231
  */
232
232
  within?: string
233
+ /**
234
+ * For a sandboxed task: whether it could read this canonical path. A
235
+ * symlinked output inside `within` whose target it could not read is
236
+ * refused like one outside it.
237
+ */
238
+ readable?: ((real: string) => boolean) | undefined
233
239
  }
234
240
 
235
241
  /** The largest second count ustar's 12-byte mtime field holds: 11 octal digits. */
@@ -321,6 +327,11 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
321
327
  )
322
328
  }
323
329
  }
330
+ if (args.readable !== undefined && !args.readable(src) && !(await ownOutputs()).has(src)) {
331
+ throw new UserError(
332
+ `${shown} is a symlink to ${src}, which the task's sandbox does not let it read: vx packs a symlinked output as its target's bytes — emit a file there, grant the target in exec.sandbox.allow.read, or take it back with a '!' entry`,
333
+ )
334
+ }
324
335
  st = await statOf(src).catch(dangling)
325
336
  }
326
337
  if (!st.isFile()) {
@@ -43,6 +43,7 @@ import {
43
43
  mkdirSync,
44
44
  openSync,
45
45
  readdirSync,
46
+ readFileSync,
46
47
  renameSync,
47
48
  statSync,
48
49
  utimesSync,
@@ -53,6 +54,7 @@ import path from 'node:path'
53
54
  import { createArtifactTables, createTables, inHashes, lazyStatement } from './schema.js'
54
55
  import {
55
56
  UserError,
57
+ DOT_VX_GITIGNORE,
56
58
  formatBytes,
57
59
  isDiskFull,
58
60
  isFsRefusal,
@@ -105,7 +107,6 @@ import {
105
107
  zstdCompress,
106
108
  zstdEncoder,
107
109
  } from './zstd.js'
108
- import { ConfigEvalTable } from './config-evals.js'
109
110
  import { FileHashStore } from './file-hashes.js'
110
111
  import { OutputIndex } from './output-index.js'
111
112
  import { RunHistory } from './run-history.js'
@@ -148,6 +149,40 @@ const ACCESS_REFRESH_MS = 60 * 60 * 1000
148
149
  */
149
150
  const VX_ARTIFACT_NAME = /^([0-9a-f]{16})\.tar\.zst(\.tmp-\d+-\d+-[0-9a-z]*)?$/
150
151
 
152
+ /** What vx writes in a cache directory besides its artifacts. */
153
+ const VX_CACHE_NAMES = new Set([
154
+ 'cache.db',
155
+ 'cache.db-wal',
156
+ 'cache.db-shm',
157
+ 'cache.db-journal',
158
+ 'runs',
159
+ 'failures',
160
+ 'lockfile-claims',
161
+ ])
162
+
163
+ /**
164
+ * The entries of `cacheDir` vx wrote: the index, its artifacts, run
165
+ * history, and the `*` ignore file unless the user changed it. A clear of
166
+ * a directory the user named deletes these and nothing else (item 968).
167
+ */
168
+ export function vxCacheEntries(cacheDir: string): string[] {
169
+ let names: string[]
170
+ try {
171
+ names = readdirSync(cacheDir)
172
+ } catch {
173
+ return []
174
+ }
175
+ return names.filter((name) => {
176
+ if (VX_CACHE_NAMES.has(name) || VX_ARTIFACT_NAME.test(name)) return true
177
+ if (name !== '.gitignore') return false
178
+ try {
179
+ return readFileSync(path.join(cacheDir, name), 'utf8') === IGNORE_ALL
180
+ } catch {
181
+ return false
182
+ }
183
+ })
184
+ }
185
+
151
186
  /** The `schema_meta` key holding when the orphan sweep last ran (ms epoch). */
152
187
  const SWEPT_AT = 'orphans_swept_at'
153
188
 
@@ -167,7 +202,7 @@ function unreadableIndexError(dbFile: string, err: unknown): UserError {
167
202
  return new UserError(
168
203
  `the cache index ${dbFile} is not a database SQLite can read (${(err as Error).message}) — ` +
169
204
  `it holds nothing a run cannot rebuild: remove it with its -wal and -shm files and ` +
170
- `re-run, and \`vx cache prune\` reclaims the artifacts it indexed`,
205
+ `re-run, and \`vx cache clean\` reclaims the artifacts it indexed`,
171
206
  )
172
207
  }
173
208
 
@@ -263,7 +298,12 @@ function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
263
298
  // unchanged (CACHE_VERSION not bumped).
264
299
  // v34: entry_inputs holds one row per entry, its key components as one
265
300
  // JSON array, not a row per component. The cache KEY is unchanged.
266
- export const SCHEMA_VERSION = 'v34'
301
+ // v35: config_evals and config_closures are gone: runs read configs from
302
+ // the lock, and `vx lock` evaluates every one. The cache KEY is
303
+ // unchanged.
304
+ // v36: the store's task_durations: each executed success's time, tagged
305
+ // by branch, read by the forecast. The cache KEY is unchanged.
306
+ export const SCHEMA_VERSION = 'v36'
267
307
 
268
308
  /**
269
309
  * An entry's key components as its one `entry_inputs` row (v34): JSON
@@ -288,7 +328,14 @@ function encodeEntryInputs(
288
328
  }
289
329
 
290
330
  /** The tables a store holds: dropped from a workspace index that held them itself. */
291
- const STORE_TABLES = ['entry_inputs', 'output_files', 'entry_stdout', 'store_meta', 'entries']
331
+ const STORE_TABLES = [
332
+ 'entry_inputs',
333
+ 'output_files',
334
+ 'entry_stdout',
335
+ 'task_durations',
336
+ 'store_meta',
337
+ 'entries',
338
+ ]
292
339
 
293
340
  /**
294
341
  * The inline artifacts' tables (schema.ts): dropped from a workspace index
@@ -549,13 +596,13 @@ function usageOfEntry(entry: { cpuMs?: number; peakRssBytes?: number }): ExecUsa
549
596
  * first. An existing cache costs three calls; it cost six.
550
597
  */
551
598
  function openCacheDir(cacheDir: string, workspaceRoot?: string): string | null {
552
- const ignore = path.join(cacheDir, '.gitignore')
599
+ const [ignore, ignoreText] = ignoreFileFor(cacheDir)
553
600
  try {
554
601
  accessSync(cacheDir, constants.W_OK)
555
602
  } catch (err) {
556
603
  makeCacheDir(cacheDir)
557
604
  if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return errorText(err)
558
- writeFileSync(ignore, IGNORE_ALL)
605
+ writeIgnore(ignore, ignoreText)
559
606
  return null
560
607
  }
561
608
  try {
@@ -567,11 +614,7 @@ function openCacheDir(cacheDir: string, workspaceRoot?: string): string | null {
567
614
  if (code !== 'ENOENT') return errorText(err)
568
615
  refuseManifestDir(cacheDir, workspaceRoot)
569
616
  }
570
- try {
571
- writeFileSync(ignore, IGNORE_ALL, { flag: 'wx' })
572
- } catch (err) {
573
- if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
574
- }
617
+ writeIgnore(ignore, ignoreText)
575
618
  return null
576
619
  }
577
620
 
@@ -630,6 +673,29 @@ function ensurePrivateDir(dir: string): string | null {
630
673
  const utf8 = new TextEncoder()
631
674
  const IGNORE_ALL = '*\n'
632
675
 
676
+ /**
677
+ * The ignore file that keeps `cacheDir` out of git, and its text. The
678
+ * default `.vx/cache` is named by `.vx/.gitignore` (`/cache`), committed
679
+ * beside the workspace config and the lock (owner, 2026-10-14); any other
680
+ * cache directory holds a `*` one of its own. Written only when missing:
681
+ * an existing file is the user's.
682
+ */
683
+ function writeIgnore(file: string, text: string): void {
684
+ try {
685
+ writeFileSync(file, text, { flag: 'wx' })
686
+ } catch (err) {
687
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
688
+ }
689
+ }
690
+
691
+ function ignoreFileFor(cacheDir: string): [string, string] {
692
+ const parent = path.dirname(cacheDir)
693
+ if (path.basename(cacheDir) === 'cache' && path.basename(parent) === '.vx') {
694
+ return [path.join(parent, '.gitignore'), DOT_VX_GITIGNORE]
695
+ }
696
+ return [path.join(cacheDir, '.gitignore'), IGNORE_ALL]
697
+ }
698
+
633
699
  /**
634
700
  * A first index in a directory that holds a manifest is a cache pointed at
635
701
  * the workspace or a project (`cacheDir: ''`, `'.'`, `'packages/a'`): the
@@ -675,9 +741,17 @@ function refuseManifestDir(cacheDir: string, workspaceRoot: string | undefined):
675
741
  }
676
742
  }
677
743
 
678
- const MANIFESTS = ['package.json', 'pnpm-workspace.yaml']
744
+ // What marks a project or workspace directory: a vx config (workspace
745
+ // PROJECT_CONFIG_FILENAMES) or the workspace file, and a package manifest,
746
+ // which a `discover` plugin makes a project with no config.
747
+ const MANIFESTS = [
748
+ 'package.json',
749
+ 'pnpm-workspace.yaml',
750
+ ...['ts', 'mts', 'js', 'mjs', 'cts', 'cjs'].map((ext) => `vx.config.${ext}`),
751
+ ...['ts', 'mts', 'js', 'mjs', 'cts', 'cjs'].map((ext) => `.vx/workspace.${ext}`),
752
+ ]
679
753
  const OWN_DIR_HINT =
680
- 'Point `cacheDir` in vx.workspace.ts (or --cache-dir) at a directory of its own, such as .vx/cache.'
754
+ 'Point `cache.dir` in .vx/workspace.ts (or --cache-dir) at a directory of its own, such as .vx/cache.'
681
755
 
682
756
  function makeCacheDir(cacheDir: string): void {
683
757
  try {
@@ -687,7 +761,7 @@ function makeCacheDir(cacheDir: string): void {
687
761
  // so this is the first thing any of them says there.
688
762
  throw new UserError(
689
763
  `cannot create cache directory ${cacheDir} (${errorText(err)}) — vx keeps its cache there; make ` +
690
- `the workspace writable, set \`cacheDir\` in vx.workspace.ts, or pass --cache-dir <path>`,
764
+ `the workspace writable, set \`cache.dir\` in .vx/workspace.ts, or pass --cache-dir <path>`,
691
765
  )
692
766
  }
693
767
  }
@@ -744,7 +818,6 @@ export class Cache implements CacheLayer {
744
818
  private readonly insertEntryInput: ReturnType<Database['prepare']>
745
819
  /** The per-file (mtime, size) → blob-OID memo behind `hashFile`. */
746
820
  private readonly files: FileHashStore
747
- private readonly configEvals: ConfigEvalTable
748
821
  private readonly outputs: OutputIndex
749
822
  private readonly history: RunHistory
750
823
 
@@ -832,9 +905,8 @@ export class Cache implements CacheLayer {
832
905
  // The directory exists before the DB opens — bun:sqlite won't create
833
906
  // parent dirs for us. A directory this user cannot write into is a
834
907
  // read-only cache for every reader (`vx show`, `why`, `last`, the
835
- // doctor, the watch sweep): the write axis goes off, so the
836
- // config-evaluation store and the file-hash memo skip their upserts
837
- // instead of dying in SQLite on the first miss (an unprivileged user on
908
+ // doctor, the watch sweep): the write axis goes off, so the file-hash
909
+ // memo skips its upserts instead of dying in SQLite on the first miss (an unprivileged user on
838
910
  // a root-owned `.vx`, 2026-09-16). A run wants more than a quiet
839
911
  // read-only cache — `assertWritable()`.
840
912
  // A reading verb over a directory with no index yet reads an empty one
@@ -958,7 +1030,7 @@ export class Cache implements CacheLayer {
958
1030
  // keeps it so).
959
1031
  if (found !== undefined) {
960
1032
  this.db.exec(
961
- 'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.store_meta;',
1033
+ 'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.task_durations; DROP TABLE IF EXISTS main.store_meta;',
962
1034
  )
963
1035
  }
964
1036
  // The tables land under the same write lock as the stamp: made
@@ -1119,7 +1191,6 @@ export class Cache implements CacheLayer {
1119
1191
  // The slices: each owns its statements over this handle and its table(s);
1120
1192
  // the schema above is the one place every table is declared.
1121
1193
  this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
1122
- this.configEvals = new ConfigEvalTable(this.db, { read: this.read, write: this.write })
1123
1194
  this.outputs = new OutputIndex(this.db)
1124
1195
  this.history = new RunHistory(this.db, (v) => this.digestValue(v))
1125
1196
 
@@ -1148,7 +1219,7 @@ export class Cache implements CacheLayer {
1148
1219
  * corrupt past the ones the open reads — to the open's own refusal. The
1149
1220
  * open reads the header and `schema_meta` alone (item 1005), so a file
1150
1221
  * corrupt deeper answered the first lookup that reached the bad page: every
1151
- * task of a run failed as an "internal error", and `vx cache prune` printed
1222
+ * task of a run failed as an "internal error", and `vx cache clean` printed
1152
1223
  * a stack (A-8). Every other error passes through as it was.
1153
1224
  */
1154
1225
  private guard<T>(op: () => T): T {
@@ -1331,28 +1402,6 @@ export class Cache implements CacheLayer {
1331
1402
  .immediate()
1332
1403
  }
1333
1404
 
1334
- // --- config evaluations: `ConfigEvalStore`, delegated to `ConfigEvalTable` ---
1335
- getConfigEval(key: string): string | null {
1336
- return this.guard(() => this.configEvals.getConfigEval(key))
1337
- }
1338
- getConfigClosures(configPaths: readonly string[]): Map<string, string[]> {
1339
- return this.guard(() => this.configEvals.getConfigClosures(configPaths))
1340
- }
1341
- putConfigClosure(configPath: string, files: readonly string[]): void {
1342
- return this.guard(() => this.configEvals.putConfigClosure(configPath, files))
1343
- }
1344
- getConfigEvals(keys: readonly string[]): Map<string, string> {
1345
- return this.guard(() => this.configEvals.getConfigEvals(keys))
1346
- }
1347
- putConfigEval(key: string, json: string): void {
1348
- return this.guard(() => this.configEvals.putConfigEval(key, json))
1349
- }
1350
- putConfigEvals(entries: ReadonlyArray<readonly [string, string]>): void {
1351
- return this.guard(() => this.configEvals.putConfigEvals(entries))
1352
- }
1353
- putConfigClosures(entries: ReadonlyArray<readonly [string, readonly string[]]>): void {
1354
- return this.guard(() => this.configEvals.putConfigClosures(entries))
1355
- }
1356
1405
  // --- input file hashes: delegated to `FileHashStore` (see file-hashes.ts) ---
1357
1406
  hashFile(filePath: string): Promise<string> {
1358
1407
  return this.guard(() => this.files.hashFile(filePath))
@@ -1425,8 +1474,8 @@ export class Cache implements CacheLayer {
1425
1474
 
1426
1475
  /**
1427
1476
  * The digest a value-bearing row holds (an env value, a runtime output,
1428
- * args after `--`): xxh3 under a random salt this store keeps. `vx why`,
1429
- * its JSON and MCP's `whyDidThisRerun` print it, and an unkeyed xxh3 in
1477
+ * args after `--`): xxh3 under a random salt this store keeps. `vx why`
1478
+ * and its JSON print it, and an unkeyed xxh3 in
1430
1479
  * a public CI log let anyone confirm or brute-force a short secret
1431
1480
  * (L-4). Salted, a digest still says whether a value changed, and says
1432
1481
  * nothing without this cache's `schema_meta`. Asked for on a miss only.
@@ -1462,7 +1511,7 @@ export class Cache implements CacheLayer {
1462
1511
  // the entries row. It's part of the contract so LayeredCache can
1463
1512
  // route metadata to `ingest()` on remote-hit without a separate API.
1464
1513
  async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
1465
- // Local reads disabled (e.g. `--force` / `--cache=local:w`): report a
1514
+ // Local reads disabled (e.g. `--force` / `--cache-policy=local:w`): report a
1466
1515
  // miss so the task re-executes. The artifact + index are untouched.
1467
1516
  if (!this.read) return null
1468
1517
  return this.guard(async () => {
@@ -1552,7 +1601,7 @@ export class Cache implements CacheLayer {
1552
1601
  * serve hits out of the PRE-EXISTING local cache" — it must not throw
1553
1602
  * away bytes this run just downloaded. `LayeredCache` calls this to
1554
1603
  * deliver an artifact it ingested from the remote; under
1555
- * `--cache=local:,remote:rw` the gated `get` returned null for the row
1604
+ * `--cache-policy=local:,remote:rw` the gated `get` returned null for the row
1556
1605
  * `ingest` had just written, so the task re-executed and re-uploaded on
1557
1606
  * every single run.
1558
1607
  */
@@ -1799,7 +1848,7 @@ export class Cache implements CacheLayer {
1799
1848
  // outputs, so a restore that did not fail here would report a green cache
1800
1849
  // hit over an emptied output tree. The artifact existed when `get()`
1801
1850
  // probed it, so its absence now means something removed it underneath us
1802
- // (a `vx cache prune` in another shell, or another workspace's retention
1851
+ // (a `vx cache clean` in another shell, or another workspace's retention
1803
1852
  // on a shared `--cache-dir`) — `ArtifactVanishedError`, which the caller
1804
1853
  // turns into a miss: the task runs.
1805
1854
  //
@@ -1991,6 +2040,8 @@ export class Cache implements CacheLayer {
1991
2040
  workspaceOutputFiles?: string[]
1992
2041
  workspaceRoot?: string
1993
2042
  inputComponents?: readonly TaskInputRow[]
2043
+ /** See `CacheLayer.save`. */
2044
+ readable?: ((real: string) => boolean) | undefined
1994
2045
  }): Promise<void> {
1995
2046
  // Layout (v17, extended additively for workspaceFiles): one
1996
2047
  // `<hash>.tar.zst` per entry. Tar carries ONLY the things you'd
@@ -2005,7 +2056,7 @@ export class Cache implements CacheLayer {
2005
2056
  // SQLite, not the artifact. Remote-hit ingestion takes metadata
2006
2057
  // through `ingest()` arguments — the artifact stays clean bytes.
2007
2058
  //
2008
- // Local writes disabled (e.g. `--cache=local:,remote:rw`): produce
2059
+ // Local writes disabled (e.g. `--cache-policy=local:,remote:rw`): produce
2009
2060
  // no `<hash>.tar.zst` and no index row. The LayeredCache wrapping us
2010
2061
  // still uploads to remote — it calls `packArtifact` itself to get
2011
2062
  // the bytes, since there's no local artifact to read off disk.
@@ -2188,6 +2239,7 @@ export class Cache implements CacheLayer {
2188
2239
  outputFiles: string[]
2189
2240
  workspaceOutputFiles?: string[]
2190
2241
  workspaceRoot?: string
2242
+ readable?: ((real: string) => boolean) | undefined
2191
2243
  }): Promise<Uint8Array> {
2192
2244
  // stdout is ALWAYS present in the artifact, even if empty, so the
2193
2245
  // layout is predictable: a successful read finds `stdout` and
@@ -2211,6 +2263,7 @@ export class Cache implements CacheLayer {
2211
2263
  outputs: this.outputsOf(args),
2212
2264
  exec: usageOfEntry(args.entry),
2213
2265
  within: args.projectDir,
2266
+ readable: args.readable,
2214
2267
  })
2215
2268
  if (plan.size > this.artifactCeiling) {
2216
2269
  throw new Error(
@@ -2601,7 +2654,7 @@ export class Cache implements CacheLayer {
2601
2654
  * scan of the index and one read of the sweep's clock — never the
2602
2655
  * sweep's readdir. Null when nothing was due or the directory is not this
2603
2656
  * handle's to write. The local WRITE axis does not gate it: under
2604
- * `--cache=local:r,remote:rw` a save writes nothing here, but every remote
2657
+ * `--cache-policy=local:r,remote:rw` a save writes nothing here, but every remote
2605
2658
  * hit is still ingested (`ingest`, ungated by design), and gated on that
2606
2659
  * axis the retention never ran while the store grew hit by hit (A-7).
2607
2660
  */
@@ -2887,20 +2940,19 @@ export class Cache implements CacheLayer {
2887
2940
  // Run-history retention: the runs table grows by one row per
2888
2941
  // executed task per invocation — a 2000-task repo accretes ~20k
2889
2942
  // rows in days, inflating insert and checkpoint cost forever.
2890
- // 30 days comfortably covers `vx stats` (24 h windows) and any
2943
+ // 30 days comfortably covers `vx info` (24 h windows) and any
2891
2944
  // CI-side analytics consumers. The `invocations` header table (one
2892
2945
  // row per `vx run`) is pruned on the SAME window — otherwise a
2893
2946
  // header would outlive its `runs` rows, so `vx info`/`vx last` would
2894
2947
  // list an invocation whose task detail is already gone, and the
2895
2948
  // table would grow unbounded on a long-lived checkout.
2896
2949
  // A reading verb and a dry run delete nothing: `vx last --list` over
2897
- // month-old runs emptied the history it listed, and `vx cache prune
2950
+ // month-old runs emptied the history it listed, and `vx cache clean
2898
2951
  // --dry-run` pruned it (item 1004).
2899
2952
  try {
2900
2953
  if (!this.inspecting) {
2901
2954
  const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
2902
2955
  this.history.pruneOlderThan(cutoff)
2903
- this.configEvals.pruneOlderThan(cutoff)
2904
2956
  if (this.write) this.files.pruneOlderThan(cutoff)
2905
2957
  }
2906
2958
  } catch {