@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,6 +1,6 @@
1
1
  ---
2
2
  name: vx
3
- description: Run, inspect and debug tasks in a vx monorepo (vx.config.ts, vx.workspace.ts). Use to build or test projects, find what a change affects, read why a task failed or re-ran, and query the cache as JSON.
3
+ description: Run, inspect and debug tasks in a vx monorepo (vx.config.ts, .vx/workspace.ts). Use to build or test projects, find what a change affects, read why a task failed or re-ran, and query the cache as JSON.
4
4
  ---
5
5
 
6
6
  # vx
@@ -27,10 +27,21 @@ A refusal under `--format json` is one line on stdout,
27
27
  Without a TTY a missing task name never prompts: vx exits 1 and lists the
28
28
  tasks it found.
29
29
 
30
+ ## After editing a config
31
+
32
+ ```sh
33
+ vx lock # after any edit to a vx.config.ts or .vx/workspace.ts
34
+ ```
35
+
36
+ A run reads every config from `.vx/projects.lock.json`, never the files:
37
+ a task you add, or an edit to one, does nothing until `vx lock`. Commit
38
+ the lock with the config.
39
+
30
40
  ## Look before running
31
41
 
32
42
  ```sh
33
- vx show --format json # projects, their tasks and dependencies
43
+ vx show --format json # projects and their tasks
44
+ vx show app --format json # one project's tasks as a run reads them
34
45
  vx run test --affected=origin/main --dry=json # what would run, cache hit or miss, and why
35
46
  ```
36
47
 
@@ -59,22 +70,20 @@ files, fix, and re-run the same command: cached tasks that passed replay from th
59
70
  vx why app#build --format json # which input changed, down to the root cause
60
71
  ```
61
72
 
62
- ## Workspace health
73
+ ## Run history
63
74
 
64
75
  ```sh
65
- vx info --format json # vx and Bun versions, projects, config errors, plugins, cache
76
+ vx last --stats --format json # per task: p50 (the live estimate's), p99, pass and hit rate, failure mode, peak RSS
77
+ vx last --stats --branch main --format json # p50 from main's timings
78
+ vx last --stats app#build --format json # one task; a task name covers every project
79
+ vx last --list --format json # recent runs, newest first
66
80
  ```
67
81
 
68
- ## MCP
82
+ ## Workspace health
69
83
 
70
- When `@vzn/vx-mcp` is a plugin in `vx.workspace.ts`, `vx mcp` serves the
71
- same facts and runs tasks. Adoption does not add it: install
72
- `@vzn/vx-mcp` and put `mcp()` (from `'@vzn/vx-mcp'`) in `plugins`. Its tools: `listTasks`, `getCacheStats`,
73
- `getRunHistory`, `explainCacheKey`, `whyDidThisRerun`, `getFailures`, `getTaskLog`, `getConfig`, `checkLock`, `pruneCache`, `planInit`, `searchDocs`,
74
- `getWorkspaceInfo`, `runTasks`, `planTasks`. `runTasks` answers what
75
- `vx run <tasks> --format json` prints, `planTasks` what
76
- `vx run <tasks> --dry=json` prints, `pruneCache` evicts only with
77
- `dryRun: false`; the others only read.
84
+ ```sh
85
+ vx info --format json # vx and Bun versions, projects, config errors, plugins, cache
86
+ ```
78
87
 
79
88
  ## Rules
80
89
 
@@ -93,3 +102,9 @@ same facts and runs tasks. Adoption does not add it: install
93
102
  `https://raw.githubusercontent.com/vznjs/vx/main/packages/vx-docs/src/content/docs/<page>.md`:
94
103
  `quickstart`, `guides/migrate`, `guides/configure`, `guides/agents`,
95
104
  `guides/troubleshooting`, `guides/ci`, `guides/plugins`.
105
+
106
+ ## Install
107
+
108
+ `npx skills add vznjs/vx` installs this skill. To match the vx a workspace
109
+ has installed, copy it from there instead:
110
+ `cp node_modules/@vzn/vx/skills/vx/SKILL.md .claude/skills/vx/`.
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
  }
@@ -31,7 +46,7 @@ async function main(): Promise<void> {
31
46
  process.exitCode = code
32
47
  } catch (err) {
33
48
  const {
34
- configErrorFrame,
49
+ detectColors,
35
50
  errorCode,
36
51
  errorDocument,
37
52
  fsRefusalHint,
@@ -40,6 +55,8 @@ async function main(): Promise<void> {
40
55
  isUserError,
41
56
  maskedLine,
42
57
  OUT_OF_FDS_HINT,
58
+ refusalNotice,
59
+ ui,
43
60
  wantsJson,
44
61
  } = await import('./util/index.js')
45
62
  // A plugin's failure quotes what it was told (a remote's reply, a
@@ -51,12 +68,16 @@ async function main(): Promise<void> {
51
68
  // on. Everything else gets the full stack so internal bugs are
52
69
  // debuggable.
53
70
  if (isUserError(err)) {
54
- // A message that already names the tool (`vx why: …`, thrown by a verb
55
- // that wants its own name in the line) is printed as it is; prefixing
56
- // it produced `vx: vx why: …` (walkthrough, 2026-09-04).
57
- // A config refusal names its line and shows it.
58
- const m = configErrorFrame(err.message, process.cwd()) ?? err.message
59
- say(m.startsWith('vx ') ? `${m}\n` : `vx: ${m}\n`)
71
+ // A message that names its verb (`vx why: …`) is that verb's; any
72
+ // other is the verb typed. A config refusal shows its line.
73
+ const named = /^(vx [a-z][\w-]*): /.exec(err.message)
74
+ const typed = /^[a-z][\w-]*$/.test(argv[0] ?? '') ? `vx ${argv[0]}` : 'vx'
75
+ const block = refusalNotice(
76
+ named?.[1] ?? typed,
77
+ named === null ? err.message : err.message.slice(named[0].length),
78
+ err.parts,
79
+ )
80
+ process.stderr.write(maskedLine(ui.render([block], detectColors(process.stderr))))
60
81
  } else if (isOutOfFds(err)) {
61
82
  say(`vx: ${err.message} — ${OUT_OF_FDS_HINT}\n`)
62
83
  } else if (isFsRefusal(err)) {
@@ -87,7 +108,7 @@ async function main(): Promise<void> {
87
108
  * config can leave a timer behind (a top-level `setInterval`, an await that
88
109
  * hit its evaluation budget), and the loop it holds never drained: vx
89
110
  * 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
111
+ * `vx watch`, a plugin's long-lived verb and a run's kept-alive tasks end when their verb
91
112
  * does. `end(cb)` delivers a large write whole on Bun 1.4.2 (4 MiB under a
92
113
  * slow reader; `write('', cb)` delivered 450 KB), the floor `engines.bun`
93
114
  * 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,18 @@ 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
+ // v37: the store's plugin_blobs (a plugin's `ctx.cache`) and one
307
+ // 'remote' task_durations row per (sha, task). The cache KEY is
308
+ // unchanged.
309
+ // v38: blob_sizes moved to the store, and the store's task_outcomes
310
+ // holds the flaky record, so worktrees share both. The cache KEY is
311
+ // unchanged.
312
+ export const SCHEMA_VERSION = 'v38'
267
313
 
268
314
  /**
269
315
  * An entry's key components as its one `entry_inputs` row (v34): JSON
@@ -288,7 +334,17 @@ function encodeEntryInputs(
288
334
  }
289
335
 
290
336
  /** 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']
337
+ const STORE_TABLES = [
338
+ 'entry_inputs',
339
+ 'output_files',
340
+ 'entry_stdout',
341
+ 'task_durations',
342
+ 'task_outcomes',
343
+ 'blob_sizes',
344
+ 'plugin_blobs',
345
+ 'store_meta',
346
+ 'entries',
347
+ ]
292
348
 
293
349
  /**
294
350
  * The inline artifacts' tables (schema.ts): dropped from a workspace index
@@ -549,13 +605,13 @@ function usageOfEntry(entry: { cpuMs?: number; peakRssBytes?: number }): ExecUsa
549
605
  * first. An existing cache costs three calls; it cost six.
550
606
  */
551
607
  function openCacheDir(cacheDir: string, workspaceRoot?: string): string | null {
552
- const ignore = path.join(cacheDir, '.gitignore')
608
+ const [ignore, ignoreText] = ignoreFileFor(cacheDir)
553
609
  try {
554
610
  accessSync(cacheDir, constants.W_OK)
555
611
  } catch (err) {
556
612
  makeCacheDir(cacheDir)
557
613
  if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return errorText(err)
558
- writeFileSync(ignore, IGNORE_ALL)
614
+ writeIgnore(ignore, ignoreText)
559
615
  return null
560
616
  }
561
617
  try {
@@ -567,11 +623,7 @@ function openCacheDir(cacheDir: string, workspaceRoot?: string): string | null {
567
623
  if (code !== 'ENOENT') return errorText(err)
568
624
  refuseManifestDir(cacheDir, workspaceRoot)
569
625
  }
570
- try {
571
- writeFileSync(ignore, IGNORE_ALL, { flag: 'wx' })
572
- } catch (err) {
573
- if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
574
- }
626
+ writeIgnore(ignore, ignoreText)
575
627
  return null
576
628
  }
577
629
 
@@ -630,6 +682,29 @@ function ensurePrivateDir(dir: string): string | null {
630
682
  const utf8 = new TextEncoder()
631
683
  const IGNORE_ALL = '*\n'
632
684
 
685
+ /**
686
+ * The ignore file that keeps `cacheDir` out of git, and its text. The
687
+ * default `.vx/cache` is named by `.vx/.gitignore` (`/cache`), committed
688
+ * beside the workspace config and the lock (owner, 2026-10-14); any other
689
+ * cache directory holds a `*` one of its own. Written only when missing:
690
+ * an existing file is the user's.
691
+ */
692
+ function writeIgnore(file: string, text: string): void {
693
+ try {
694
+ writeFileSync(file, text, { flag: 'wx' })
695
+ } catch (err) {
696
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
697
+ }
698
+ }
699
+
700
+ function ignoreFileFor(cacheDir: string): [string, string] {
701
+ const parent = path.dirname(cacheDir)
702
+ if (path.basename(cacheDir) === 'cache' && path.basename(parent) === '.vx') {
703
+ return [path.join(parent, '.gitignore'), DOT_VX_GITIGNORE]
704
+ }
705
+ return [path.join(cacheDir, '.gitignore'), IGNORE_ALL]
706
+ }
707
+
633
708
  /**
634
709
  * A first index in a directory that holds a manifest is a cache pointed at
635
710
  * the workspace or a project (`cacheDir: ''`, `'.'`, `'packages/a'`): the
@@ -675,9 +750,17 @@ function refuseManifestDir(cacheDir: string, workspaceRoot: string | undefined):
675
750
  }
676
751
  }
677
752
 
678
- const MANIFESTS = ['package.json', 'pnpm-workspace.yaml']
753
+ // What marks a project or workspace directory: a vx config (workspace
754
+ // PROJECT_CONFIG_FILENAMES) or the workspace file, and a package manifest,
755
+ // which a `discover` plugin makes a project with no config.
756
+ const MANIFESTS = [
757
+ 'package.json',
758
+ 'pnpm-workspace.yaml',
759
+ ...['ts', 'mts', 'js', 'mjs', 'cts', 'cjs'].map((ext) => `vx.config.${ext}`),
760
+ ...['ts', 'mts', 'js', 'mjs', 'cts', 'cjs'].map((ext) => `.vx/workspace.${ext}`),
761
+ ]
679
762
  const OWN_DIR_HINT =
680
- 'Point `cacheDir` in vx.workspace.ts (or --cache-dir) at a directory of its own, such as .vx/cache.'
763
+ 'Point `cache.dir` in .vx/workspace.ts (or --cache-dir) at a directory of its own, such as .vx/cache.'
681
764
 
682
765
  function makeCacheDir(cacheDir: string): void {
683
766
  try {
@@ -687,7 +770,7 @@ function makeCacheDir(cacheDir: string): void {
687
770
  // so this is the first thing any of them says there.
688
771
  throw new UserError(
689
772
  `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>`,
773
+ `the workspace writable, set \`cache.dir\` in .vx/workspace.ts, or pass --cache-dir <path>`,
691
774
  )
692
775
  }
693
776
  }
@@ -744,7 +827,6 @@ export class Cache implements CacheLayer {
744
827
  private readonly insertEntryInput: ReturnType<Database['prepare']>
745
828
  /** The per-file (mtime, size) → blob-OID memo behind `hashFile`. */
746
829
  private readonly files: FileHashStore
747
- private readonly configEvals: ConfigEvalTable
748
830
  private readonly outputs: OutputIndex
749
831
  private readonly history: RunHistory
750
832
 
@@ -832,9 +914,8 @@ export class Cache implements CacheLayer {
832
914
  // The directory exists before the DB opens — bun:sqlite won't create
833
915
  // parent dirs for us. A directory this user cannot write into is a
834
916
  // 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
917
+ // doctor, the watch sweep): the write axis goes off, so the file-hash
918
+ // memo skips its upserts instead of dying in SQLite on the first miss (an unprivileged user on
838
919
  // a root-owned `.vx`, 2026-09-16). A run wants more than a quiet
839
920
  // read-only cache — `assertWritable()`.
840
921
  // A reading verb over a directory with no index yet reads an empty one
@@ -958,7 +1039,7 @@ export class Cache implements CacheLayer {
958
1039
  // keeps it so).
959
1040
  if (found !== undefined) {
960
1041
  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;',
1042
+ '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.plugin_blobs; DROP TABLE IF EXISTS main.task_outcomes; DROP TABLE IF EXISTS main.store_meta;',
962
1043
  )
963
1044
  }
964
1045
  // The tables land under the same write lock as the stamp: made
@@ -1119,7 +1200,6 @@ export class Cache implements CacheLayer {
1119
1200
  // The slices: each owns its statements over this handle and its table(s);
1120
1201
  // the schema above is the one place every table is declared.
1121
1202
  this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
1122
- this.configEvals = new ConfigEvalTable(this.db, { read: this.read, write: this.write })
1123
1203
  this.outputs = new OutputIndex(this.db)
1124
1204
  this.history = new RunHistory(this.db, (v) => this.digestValue(v))
1125
1205
 
@@ -1148,7 +1228,7 @@ export class Cache implements CacheLayer {
1148
1228
  * corrupt past the ones the open reads — to the open's own refusal. The
1149
1229
  * open reads the header and `schema_meta` alone (item 1005), so a file
1150
1230
  * 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
1231
+ * task of a run failed as an "internal error", and `vx cache clean` printed
1152
1232
  * a stack (A-8). Every other error passes through as it was.
1153
1233
  */
1154
1234
  private guard<T>(op: () => T): T {
@@ -1331,28 +1411,6 @@ export class Cache implements CacheLayer {
1331
1411
  .immediate()
1332
1412
  }
1333
1413
 
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
1414
  // --- input file hashes: delegated to `FileHashStore` (see file-hashes.ts) ---
1357
1415
  hashFile(filePath: string): Promise<string> {
1358
1416
  return this.guard(() => this.files.hashFile(filePath))
@@ -1425,8 +1483,8 @@ export class Cache implements CacheLayer {
1425
1483
 
1426
1484
  /**
1427
1485
  * 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
1486
+ * args after `--`): xxh3 under a random salt this store keeps. `vx why`
1487
+ * and its JSON print it, and an unkeyed xxh3 in
1430
1488
  * a public CI log let anyone confirm or brute-force a short secret
1431
1489
  * (L-4). Salted, a digest still says whether a value changed, and says
1432
1490
  * nothing without this cache's `schema_meta`. Asked for on a miss only.
@@ -1462,7 +1520,7 @@ export class Cache implements CacheLayer {
1462
1520
  // the entries row. It's part of the contract so LayeredCache can
1463
1521
  // route metadata to `ingest()` on remote-hit without a separate API.
1464
1522
  async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
1465
- // Local reads disabled (e.g. `--force` / `--cache=local:w`): report a
1523
+ // Local reads disabled (e.g. `--force` / `--cache-policy=local:w`): report a
1466
1524
  // miss so the task re-executes. The artifact + index are untouched.
1467
1525
  if (!this.read) return null
1468
1526
  return this.guard(async () => {
@@ -1552,7 +1610,7 @@ export class Cache implements CacheLayer {
1552
1610
  * serve hits out of the PRE-EXISTING local cache" — it must not throw
1553
1611
  * away bytes this run just downloaded. `LayeredCache` calls this to
1554
1612
  * deliver an artifact it ingested from the remote; under
1555
- * `--cache=local:,remote:rw` the gated `get` returned null for the row
1613
+ * `--cache-policy=local:,remote:rw` the gated `get` returned null for the row
1556
1614
  * `ingest` had just written, so the task re-executed and re-uploaded on
1557
1615
  * every single run.
1558
1616
  */
@@ -1688,6 +1746,16 @@ export class Cache implements CacheLayer {
1688
1746
  return this.guard(() => this.hasEntry(hash))
1689
1747
  }
1690
1748
 
1749
+ /**
1750
+ * The subset of `hashes` this store holds, `has` for each: the prefetch
1751
+ * pass asks the remote only about the rest (M, 2026-10-15).
1752
+ */
1753
+ async localHasMany(hashes: readonly string[]): Promise<Set<string>> {
1754
+ const out = new Set<string>()
1755
+ for (const hash of hashes) if ((await this.has(hash)) === 'local') out.add(hash)
1756
+ return out
1757
+ }
1758
+
1691
1759
  private async hasEntry(hash: string): Promise<'local' | 'remote' | null> {
1692
1760
  if (!this.read) return null
1693
1761
  const row = this.entryExists.get(hash) as { inline_at: number | null } | null
@@ -1799,7 +1867,7 @@ export class Cache implements CacheLayer {
1799
1867
  // outputs, so a restore that did not fail here would report a green cache
1800
1868
  // hit over an emptied output tree. The artifact existed when `get()`
1801
1869
  // 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
1870
+ // (a `vx cache clean` in another shell, or another workspace's retention
1803
1871
  // on a shared `--cache-dir`) — `ArtifactVanishedError`, which the caller
1804
1872
  // turns into a miss: the task runs.
1805
1873
  //
@@ -1991,6 +2059,8 @@ export class Cache implements CacheLayer {
1991
2059
  workspaceOutputFiles?: string[]
1992
2060
  workspaceRoot?: string
1993
2061
  inputComponents?: readonly TaskInputRow[]
2062
+ /** See `CacheLayer.save`. */
2063
+ readable?: ((real: string) => boolean) | undefined
1994
2064
  }): Promise<void> {
1995
2065
  // Layout (v17, extended additively for workspaceFiles): one
1996
2066
  // `<hash>.tar.zst` per entry. Tar carries ONLY the things you'd
@@ -2005,7 +2075,7 @@ export class Cache implements CacheLayer {
2005
2075
  // SQLite, not the artifact. Remote-hit ingestion takes metadata
2006
2076
  // through `ingest()` arguments — the artifact stays clean bytes.
2007
2077
  //
2008
- // Local writes disabled (e.g. `--cache=local:,remote:rw`): produce
2078
+ // Local writes disabled (e.g. `--cache-policy=local:,remote:rw`): produce
2009
2079
  // no `<hash>.tar.zst` and no index row. The LayeredCache wrapping us
2010
2080
  // still uploads to remote — it calls `packArtifact` itself to get
2011
2081
  // the bytes, since there's no local artifact to read off disk.
@@ -2188,6 +2258,7 @@ export class Cache implements CacheLayer {
2188
2258
  outputFiles: string[]
2189
2259
  workspaceOutputFiles?: string[]
2190
2260
  workspaceRoot?: string
2261
+ readable?: ((real: string) => boolean) | undefined
2191
2262
  }): Promise<Uint8Array> {
2192
2263
  // stdout is ALWAYS present in the artifact, even if empty, so the
2193
2264
  // layout is predictable: a successful read finds `stdout` and
@@ -2211,6 +2282,7 @@ export class Cache implements CacheLayer {
2211
2282
  outputs: this.outputsOf(args),
2212
2283
  exec: usageOfEntry(args.entry),
2213
2284
  within: args.projectDir,
2285
+ readable: args.readable,
2214
2286
  })
2215
2287
  if (plan.size > this.artifactCeiling) {
2216
2288
  throw new Error(
@@ -2601,7 +2673,7 @@ export class Cache implements CacheLayer {
2601
2673
  * scan of the index and one read of the sweep's clock — never the
2602
2674
  * sweep's readdir. Null when nothing was due or the directory is not this
2603
2675
  * 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
2676
+ * `--cache-policy=local:r,remote:rw` a save writes nothing here, but every remote
2605
2677
  * hit is still ingested (`ingest`, ungated by design), and gated on that
2606
2678
  * axis the retention never ran while the store grew hit by hit (A-7).
2607
2679
  */
@@ -2887,20 +2959,19 @@ export class Cache implements CacheLayer {
2887
2959
  // Run-history retention: the runs table grows by one row per
2888
2960
  // executed task per invocation — a 2000-task repo accretes ~20k
2889
2961
  // rows in days, inflating insert and checkpoint cost forever.
2890
- // 30 days comfortably covers `vx stats` (24 h windows) and any
2962
+ // 30 days comfortably covers `vx info` (24 h windows) and any
2891
2963
  // CI-side analytics consumers. The `invocations` header table (one
2892
2964
  // row per `vx run`) is pruned on the SAME window — otherwise a
2893
2965
  // header would outlive its `runs` rows, so `vx info`/`vx last` would
2894
2966
  // list an invocation whose task detail is already gone, and the
2895
2967
  // table would grow unbounded on a long-lived checkout.
2896
2968
  // 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
2969
+ // month-old runs emptied the history it listed, and `vx cache clean
2898
2970
  // --dry-run` pruned it (item 1004).
2899
2971
  try {
2900
2972
  if (!this.inspecting) {
2901
2973
  const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
2902
2974
  this.history.pruneOlderThan(cutoff)
2903
- this.configEvals.pruneOlderThan(cutoff)
2904
2975
  if (this.write) this.files.pruneOlderThan(cutoff)
2905
2976
  }
2906
2977
  } catch {
@@ -108,6 +108,10 @@ export class ChainedCache implements CacheLayer {
108
108
  return false
109
109
  }
110
110
 
111
+ localHasMany(hashes: readonly string[]): Promise<Set<string>> {
112
+ return this.layers[0]!.localHasMany?.(hashes) ?? Promise.resolve(new Set())
113
+ }
114
+
111
115
  async remoteHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
112
116
  // The caller (remote-prefetch) treats a non-null answer as authoritative
113
117
  // for the WHOLE chain: complement = absent, broadcast. That is sound only