@vzn/vx 0.0.646 → 0.0.649

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 (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -24,7 +24,8 @@ export function lazyStatement(db: Database, sql: string): ReturnType<Database['p
24
24
  * Create every table. `store` names the schema holding the ENTRY tables
25
25
  * (`entries`, `entry_stdout`, `output_files`, `entry_inputs`, `store_meta`)
26
26
  * the task timings (`task_durations`), the flaky record (`task_outcomes`),
27
- * the blob sizes (`blob_sizes`) and the plugins' blobs (`plugin_blobs`):
27
+ * the blob sizes (`blob_sizes`), the plugins' blobs (`plugin_blobs`) and
28
+ * the remotes' hits (`remote_hits`):
28
29
  * `main` when one `cache.db` holds everything (a `cacheDir`), `store` when
29
30
  * they live in the shared store attached beside it (v32). The rest is the
30
31
  * workspace's own: its history, its memos, and what its disk looked like
@@ -112,7 +113,13 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
112
113
  placement_cores INTEGER,
113
114
  placement_memory INTEGER,
114
115
  -- v46: the task with no inputs its key folded (TaskOutcome.keylessBy).
115
- keyless_by TEXT
116
+ keyless_by TEXT,
117
+ -- v48: the task's definition (taskDefinition): a history reader
118
+ -- counts only the rows of the task as it is now.
119
+ def TEXT,
120
+ -- v49: the share of a core each runnable thread got where it ran
121
+ -- (ExecuteResult.machine.cpuShare); NULL = all it wanted.
122
+ cpu_share REAL
116
123
  );
117
124
  -- Two whole-table indexes only, and both APPEND: every row of a run carries the
118
125
  -- same run_id and a started_at newer than everything before it, so
@@ -163,7 +170,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
163
170
  -- worktree size git recorded (A-60). Fixed for its OID, so a warm
164
171
  -- run asks git for none; swept with file_hashes by seen_at, the
165
172
  -- time the row was written. v38: in the store, so a worktree reads
166
- -- what another learned (owner, 2026-10-14).
173
+ -- what another learned (2026-10-14).
167
174
  CREATE TABLE IF NOT EXISTS ${store}.blob_sizes (
168
175
  oid TEXT PRIMARY KEY,
169
176
  size INTEGER NOT NULL,
@@ -280,7 +287,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
280
287
  );
281
288
  -- What a duration forecast reads (orchestrator/history.ts), one row per
282
289
  -- executed success, shared by every worktree of the repo so a new one
283
- -- forecasts on its first run (owner, 2026-10-14). Append-only: never
290
+ -- forecasts on its first run (2026-10-14). Append-only: never
284
291
  -- updated, pruned by age with the run history. \`source\` tags a local
285
292
  -- run 'branch:<name>' ('branch:' detached or outside git); \`sha\` is its
286
293
  -- commit. A forecast reads a task's newest rows of one source.
@@ -297,7 +304,9 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
297
304
  cpu_parallelism REAL,
298
305
  peak_rss_bytes INTEGER,
299
306
  saturated_cores INTEGER,
300
- saturated_memory_bytes INTEGER
307
+ saturated_memory_bytes INTEGER,
308
+ -- v48: the task's definition (taskDefinition), as on \`runs\`.
309
+ def TEXT
301
310
  );
302
311
  CREATE INDEX IF NOT EXISTS ${store}.task_durations_task
303
312
  ON task_durations(project, task, source, id);
@@ -309,7 +318,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
309
318
  -- v38: the flaky record (orchestrator/failure-mode.ts): each keyed,
310
319
  -- cached, unblocked outcome that observed its key, shared by every
311
320
  -- worktree so a key that passed in one and failed in another is flaky
312
- -- in both (owner, 2026-10-14). Every executed success or failure, and
321
+ -- in both (2026-10-14). Every executed success or failure, and
313
322
  -- a key's first hit (run-history.ts): a later hit replays a pass the
314
323
  -- record holds, so a warm run on keys that passed writes no row.
315
324
  -- Append-only, ordered by id, pruned by age with the run history.
@@ -331,6 +340,16 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
331
340
  bytes BLOB NOT NULL,
332
341
  at INTEGER NOT NULL
333
342
  );
343
+ -- What a remote's has-many said it holds (remote-hits.ts), shared by
344
+ -- every worktree: a key here is a hit until a has-many or a GET says it
345
+ -- is gone, and a key not here is a miss. Only hits are kept. Pruned by
346
+ -- \`seen_at\` with the run history.
347
+ CREATE TABLE IF NOT EXISTS ${store}.remote_hits (
348
+ hash TEXT PRIMARY KEY,
349
+ size INTEGER,
350
+ duration_ms INTEGER,
351
+ seen_at INTEGER NOT NULL
352
+ );
334
353
  ${artifactTables(store)}
335
354
  `)
336
355
  }
@@ -38,6 +38,8 @@ const BLOCK = 512
38
38
  * numbers; a MiB is far past any writer's.
39
39
  */
40
40
  const MAX_EXTENDED_HEADER = 1024 * 1024
41
+ /** The largest millisecond count a Date holds (ECMA-262 §21.4.1.1). */
42
+ const DATE_MAX_MS = 8.64e15
41
43
  // `ignoreBOM`: a default decoder drops a leading U+FEFF, and a name or
42
44
  // prefix field that began with one read back without it.
43
45
  const decoder = new TextDecoder('utf-8', { ignoreBOM: true })
@@ -232,6 +234,9 @@ class TarDecoder {
232
234
  const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]!)
233
235
  const size = this.pendingSize ?? octal(h, 124, 12)
234
236
  const mtimeMs = octal(h, 136, 12) * 1000
237
+ // Only GNU base-256 reaches past it, and a restore's utimes threw on
238
+ // such a Date after the renames before it had landed.
239
+ if (mtimeMs > DATE_MAX_MS) throw new TarFormatError(`header mtime past what a Date holds`)
235
240
  const padded = Math.ceil(size / BLOCK) * BLOCK
236
241
  // POSIX `ustar\0` only: old GNU's `ustar ` keeps atime and ctime at
237
242
  // 345, and `-G` fills them, so a prefix read there named every entry
@@ -1,8 +1,7 @@
1
1
  // The process umask, read when a restore starts: Bun reads it by setting 0
2
2
  // and putting it back in one synchronous call (project-loader.ts
3
3
  // `currentUmask`), so no spawn on this thread sees the 0, and a run
4
- // evaluates no config in a worker. Through globalThis: the playground's
5
- // shim has no umask.
4
+ // evaluates no config in a worker.
6
5
  const proc = globalThis.process as { umask?: () => number } | undefined
7
6
  let umask = 0
8
7
 
package/src/cli/cache.ts CHANGED
@@ -1,10 +1,11 @@
1
- import { existsSync, readdirSync, rmSync } from 'node:fs'
1
+ import { existsSync, lstatSync, readdirSync, rmSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { Cache, vxCacheEntries } from '../cache/index.js'
5
5
  import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
6
6
  import {
7
7
  errorCode,
8
+ isUserError,
8
9
  nearest,
9
10
  parseDuration,
10
11
  parseSize,
@@ -57,11 +58,11 @@ const CACHE_ELSEWHERE: Record<string, string> = {
57
58
  }
58
59
 
59
60
  function cacheSubHint(sub: string): string {
60
- const elsewhere = CACHE_ELSEWHERE[sub]
61
- if (elsewhere !== undefined) return ` — ${elsewhere}`
61
+ // Own keys only: `vx cache constructor` printed `Object`'s source.
62
+ if (Object.hasOwn(CACHE_ELSEWHERE, sub)) return ` — ${CACHE_ELSEWHERE[sub]}`
62
63
  const best = nearest(sub, ['clean'])
63
64
  // `clean` is the one subcommand: name it when nothing is near (M-58).
64
- return best === undefined ? '. The subcommand is clean' : `. Did you mean ${best}?`
65
+ return best === undefined ? '. Its one subcommand is `vx cache clean`' : `. Did you mean ${best}?`
65
66
  }
66
67
 
67
68
  export async function cacheCmd(args: readonly string[]): Promise<number> {
@@ -78,6 +79,16 @@ export async function cacheCmd(args: readonly string[]): Promise<number> {
78
79
  'VX_E_USAGE',
79
80
  )
80
81
  default:
82
+ // `vx cache --older-than 30d` dropped the verb, not misspelled it:
83
+ // name the clean command those flags make, when they parse as one.
84
+ if (sub.startsWith('-') && parsePruneArgs(args).error === undefined) {
85
+ return refuse(
86
+ 'cache',
87
+ args,
88
+ `missing subcommand before ${sub}. Did you mean \`vx cache clean ${args.join(' ')}\`?${seeHelp('cache')}`,
89
+ 'VX_E_USAGE',
90
+ )
91
+ }
81
92
  return refuse(
82
93
  'cache',
83
94
  args,
@@ -90,7 +101,7 @@ export async function cacheCmd(args: readonly string[]): Promise<number> {
90
101
  interface PruneArgs {
91
102
  olderThanMs?: number
92
103
  maxBytes?: number
93
- dryRun?: boolean
104
+ dry?: boolean
94
105
  format?: 'pretty' | 'json'
95
106
  /** `--cache-dir`: prune the cache a run with the same flag uses. */
96
107
  cacheDir?: string
@@ -149,8 +160,8 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
149
160
  }
150
161
  }
151
162
  out.maxBytes = bytes
152
- } else if (a === '--dry-run') {
153
- out.dryRun = true
163
+ } else if (a === '--dry') {
164
+ out.dry = true
154
165
  } else if (a === '--global') {
155
166
  out.global = true
156
167
  } else if (a === '--format' || a?.startsWith('--format=')) {
@@ -205,7 +216,7 @@ async function cleanCmd(args: readonly string[]): Promise<number> {
205
216
  else {
206
217
  const ws = await loadCliWorkspace(root)
207
218
  dir = ws.cacheDir
208
- storeRoot = await ws.storeRoot
219
+ storeRoot = await ws.storeRoot()
209
220
  }
210
221
  if (parsed.olderThanMs === undefined && parsed.maxBytes === undefined) {
211
222
  return clear(args, root, dir, storeRoot, parsed)
@@ -213,15 +224,17 @@ async function cleanCmd(args: readonly string[]): Promise<number> {
213
224
  // A workspace that never ran has nothing to prune, and opening the cache
214
225
  // to find that out made it: `.vx/cache` with a database and a
215
226
  // `.gitignore`, where item 900 had held the dry run to making nothing
216
- // (and a read-only checkout was refused as unwritable instead).
217
- const dry = parsed.dryRun === true
227
+ // (and a read-only checkout was refused as unwritable instead). So has a
228
+ // directory holding nothing vx wrote (`--cache-dir build`): opened, it got
229
+ // an index and a `*` .gitignore that hid the user's files from git.
230
+ const dry = parsed.dry === true
218
231
  const json = parsed.format === 'json'
219
- if (!existsSync(dir) && (storeRoot === null || !existsSync(storeRoot))) {
232
+ if (vxCacheEntries(dir).length === 0 && (storeRoot === null || !existsSync(storeRoot))) {
220
233
  printPruned({ evicted: 0, bytesFreed: 0, orphans: 0, orphanBytes: 0 }, dry, json)
221
234
  return 0
222
235
  }
223
236
  const cache =
224
- parsed.dryRun === true
237
+ parsed.dry === true
225
238
  ? new Cache(dir, undefined, undefined, undefined, 'preview', storeRoot)
226
239
  : new Cache(dir, undefined, root, undefined, 'open', storeRoot)
227
240
  // A prune deletes rows and artifacts; a cache this user cannot write is
@@ -229,19 +242,19 @@ async function cleanCmd(args: readonly string[]): Promise<number> {
229
242
  // than dying in the first DELETE with SQLite's "readonly database" and a
230
243
  // stack (an unprivileged user on a root-owned `.vx`, 2026-09-16). A dry
231
244
  // run only reads, and reads a read-only cache fine.
232
- if (!parsed.dryRun) cache.assertWritable()
245
+ if (!parsed.dry) cache.assertWritable()
233
246
  // A prune beside a run on this workspace evicts what the run has just
234
247
  // probed as hits, whose `accessed_at` bumps it has not flushed yet; the
235
248
  // run then re-runs those tasks (upstream survey, nx#36688). So a prune
236
249
  // takes the run's lock and waits its turn. A dry run deletes nothing.
237
- const releaseRunLock = parsed.dryRun
250
+ const releaseRunLock = parsed.dry
238
251
  ? async (): Promise<void> => {}
239
252
  : await acquireRunLock(root, { log: warnToStderr })
240
253
  try {
241
- const opts: { olderThanMs?: number; maxBytes?: number; dryRun?: boolean } = {}
254
+ const opts: { olderThanMs?: number; maxBytes?: number; dry?: boolean } = {}
242
255
  if (parsed.olderThanMs !== undefined) opts.olderThanMs = parsed.olderThanMs
243
256
  if (parsed.maxBytes !== undefined) opts.maxBytes = parsed.maxBytes
244
- if (parsed.dryRun) opts.dryRun = true
257
+ if (parsed.dry) opts.dry = true
245
258
  printPruned(await cache.prune(opts), dry, json)
246
259
  } finally {
247
260
  cache.close()
@@ -258,7 +271,7 @@ function printPruned(
258
271
  if (json) {
259
272
  const { evicted, bytesFreed, orphans, orphanBytes } = result
260
273
  process.stdout.write(
261
- `${JSON.stringify({ dryRun: dry, evicted, bytesFreed, orphans, orphanBytes })}\n`,
274
+ `${JSON.stringify({ dry: dry, evicted, bytesFreed, orphans, orphanBytes })}\n`,
262
275
  )
263
276
  return
264
277
  }
@@ -286,7 +299,7 @@ function freedStatus(dry: boolean, bytes: number, any: boolean): ui.Block {
286
299
 
287
300
  /** `vx cache clean --format json` with no bound (schemas/cache.json `clear`). */
288
301
  export interface ClearResult {
289
- dryRun: boolean
302
+ dry: boolean
290
303
  /** Workspace-relative inside the workspace, else absolute. */
291
304
  cleared: string[]
292
305
  bytesFreed: number
@@ -308,17 +321,27 @@ async function clear(
308
321
  storeRoot: string | null,
309
322
  parsed: PruneArgs,
310
323
  ): Promise<number> {
311
- // The store the index recorded wins over today's (a changed HOME), as a
312
- // run reads it; a directory with no index is not a vx cache to delete.
324
+ // The store the index recorded and today's (a changed HOME, an origin
325
+ // added) both go; a directory with no index is not a vx cache to delete.
313
326
  const targets: string[] = []
314
327
  if (existsSync(path.join(dir, 'cache.db'))) {
315
- const cache = new Cache(dir, undefined, undefined, undefined, 'preview', storeRoot)
316
- const store = cache.storeDir
317
- cache.close()
328
+ // An index SQLite cannot read is what its own refusal sends here to
329
+ // clear: today's store stands in for the one it recorded.
330
+ let store: string | undefined = storeRoot ?? undefined
331
+ try {
332
+ const cache = new Cache(dir, undefined, undefined, undefined, 'preview', storeRoot)
333
+ store = cache.storeDir
334
+ cache.close()
335
+ } catch (err) {
336
+ if (!isUserError(err)) throw err
337
+ }
318
338
  targets.push(dir)
319
339
  // The index is a file in the checkout (committed, or restored from a CI
320
340
  // cache): the store it names is deleted only when it is one.
321
341
  if (store !== undefined && isStore(store)) targets.push(store)
342
+ // Today's store too: the next run reads it, whichever store the index
343
+ // last recorded (a moved HOME, an origin added).
344
+ if (storeRoot !== null) targets.push(storeRoot)
322
345
  } else if (storeRoot !== null) targets.push(storeRoot)
323
346
  if (parsed.global === true) {
324
347
  const vxHome = path.join(process.env['HOME'] || homedir(), '.vx')
@@ -338,12 +361,20 @@ async function clear(
338
361
  'VX_E_CONFIG',
339
362
  )
340
363
  }
341
- const dry = parsed.dryRun === true
364
+ const dry = parsed.dry === true
342
365
  // `.vx/cache` and the stores are vx's own; any other directory is the
343
366
  // user's, cleared entry by entry and removed only if that empties it.
344
- const named = path.resolve(dir) === path.join(dotVx, 'cache') ? undefined : path.resolve(dir)
367
+ // A `.vx/cache` linked to a volume (a CI cache mount) is emptied through
368
+ // the link: removing the link alone left the cache where it points.
369
+ const own = path.join(dotVx, 'cache')
370
+ const named = path.resolve(dir) === own ? undefined : path.resolve(dir)
371
+ const linked = named === undefined && existsSync(own) && lstatSync(own).isSymbolicLink()
345
372
  const entries = (t: string): string[] =>
346
- t === named ? vxCacheEntries(t).map((e) => path.join(t, e)) : [t]
373
+ t === named
374
+ ? vxCacheEntries(t).map((e) => path.join(t, e))
375
+ : linked && t === own
376
+ ? readdirSync(t).map((e) => path.join(t, e))
377
+ : [t]
347
378
  const bytes = doomed.reduce((n, t) => n + entries(t).reduce((m, e) => m + treeBytes(e), 0), 0)
348
379
  if (!dry && doomed.length > 0) {
349
380
  // A clear beside a run deletes what the run is restoring or saving; it
@@ -360,7 +391,7 @@ async function clear(
360
391
  }
361
392
  const shown = doomed.map((t) => (path.relative(root, t).startsWith('..') ? t : relPosix(root, t)))
362
393
  if (parsed.format === 'json') {
363
- const result: ClearResult = { dryRun: dry, cleared: shown, bytesFreed: bytes }
394
+ const result: ClearResult = { dry: dry, cleared: shown, bytesFreed: bytes }
364
395
  process.stdout.write(`${JSON.stringify(result)}\n`)
365
396
  } else
366
397
  ui.print(
@@ -5,14 +5,21 @@
5
5
  // project names are not (a completion that evaluates configs on every
6
6
  // Tab is the wrong price).
7
7
 
8
- import { acceptedFlags, CORE_VERBS, flagHint, seeHelp } from './help.js'
8
+ import { acceptedFlags, CORE_VERBS, flagHint, refuse, seeHelp } from './help.js'
9
9
  import { nearest, sayNotice } from '../util/index.js'
10
+ import { setting } from '../settings.js'
11
+
12
+ function enumValues(path: string): readonly string[] {
13
+ const kind = setting(path).kind
14
+ return kind.type === 'enum' ? kind.values : []
15
+ }
10
16
 
11
17
  /** A flag whose value is one of a fixed set; the tests hold each to its parser. */
12
18
  export const FLAG_VALUES: Readonly<Record<string, readonly string[]>> = {
13
19
  '--format': ['pretty', 'json'],
14
20
  '--output-logs': ['full', 'errors-only', 'hash-only', 'none'],
15
21
  '--cache-download': ['all', 'toplevel', 'none'],
22
+ '--schedule': enumValues('run.schedule'),
16
23
  }
17
24
 
18
25
  export type CompletionShell = 'bash' | 'zsh' | 'fish'
@@ -139,12 +146,12 @@ export async function completionsCmd(
139
146
  if (args.length !== 1 || !(SHELLS as readonly string[]).includes(shell ?? '')) {
140
147
  const flag = args.find((a) => a.startsWith('-'))
141
148
  if (flag !== undefined) {
142
- sayNotice(
143
- 'error',
144
- 'vx completions',
149
+ return refuse(
150
+ 'completions',
151
+ args,
145
152
  `unknown flag: ${flag}${flagHint('completions', flag)}${seeHelp('completions')}`,
153
+ 'VX_E_USAGE',
146
154
  )
147
- return 1
148
155
  }
149
156
  const one = shell !== undefined && args.length === 1
150
157
  // One edit: shell names are three or four letters, so two edits took
package/src/cli/docs.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  // `vx docs <query> [--limit N] [--format pretty|json]` — search vx's
2
2
  // reference as this version wrote it: packages/vx/docs at its git tag on
3
- // GitHub, read online (M, 2026-10-17: nothing is embedded). Each page is
3
+ // GitHub, read online (2026-10-17: nothing is embedded). Each page is
4
4
  // cut at its headings, and the sections holding every query word print
5
5
  // whole, best first.
6
6
 
7
7
  import { VERSION } from '../version.js'
8
+ import { detectColors, paint, parseDecimalInt, ui } from '../util/index.js'
8
9
  import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
9
10
 
10
11
  const SITE = 'https://vznjs.github.io/vx/'
@@ -19,7 +20,7 @@ interface DocsArgs {
19
20
  error?: string
20
21
  }
21
22
 
22
- function parseDocsArgs(args: readonly string[]): DocsArgs {
23
+ export function parseDocsArgs(args: readonly string[]): DocsArgs {
23
24
  const out: DocsArgs = { terms: [], limit: DEFAULT_LIMIT, json: false }
24
25
  for (let i = 0; i < args.length; i++) {
25
26
  const a = args[i]!
@@ -29,11 +30,17 @@ function parseDocsArgs(args: readonly string[]): DocsArgs {
29
30
  out.json = fv === 'json'
30
31
  } else if (a === '--limit' || a.startsWith('--limit=')) {
31
32
  const v = a === '--limit' ? args[++i] : a.slice('--limit='.length)
32
- const n = Number(v)
33
- if (!Number.isInteger(n) || n < 1) {
33
+ if (v === undefined || v === '') {
34
34
  return {
35
35
  ...out,
36
- error: `--limit takes a whole number above 0 (got ${v ?? 'nothing'})${seeHelp('docs')}`,
36
+ error: `--limit requires a value: a whole number above 0${seeHelp('docs')}`,
37
+ }
38
+ }
39
+ const n = parseDecimalInt(v)
40
+ if (n === null || n < 1) {
41
+ return {
42
+ ...out,
43
+ error: `--limit takes a whole number above 0 (got ${v})${seeHelp('docs')}`,
37
44
  }
38
45
  }
39
46
  out.limit = n
@@ -52,7 +59,7 @@ export interface DocsHit {
52
59
  }
53
60
 
54
61
  function words(s: string): string[] {
55
- return s.toLowerCase().match(/[a-z0-9][a-z0-9_.-]*/g) ?? []
62
+ return s.toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}_.-]*/gu) ?? []
56
63
  }
57
64
 
58
65
  /** GitHub's heading anchor, which Starlight shares: lowercase, punctuation dropped, spaces to `-`. */
@@ -63,7 +70,7 @@ function anchor(heading: string): string {
63
70
  .replace(/ /g, '-')
64
71
  }
65
72
 
66
- /** A page's URL on the site, as packages/vx-docs/scripts/import-docs.ts serves it. */
73
+ /** A page's URL on the site, as private/vx-docs/scripts/import-docs.ts serves it. */
67
74
  function pageUrl(page: string): string {
68
75
  const served: Record<string, string> = {
69
76
  README: 'overview',
@@ -117,6 +124,8 @@ export async function loadPages(
117
124
  interface Section {
118
125
  page: string
119
126
  heading: string
127
+ /** The heading's id on the site; absent for the page's opening. */
128
+ id?: string
120
129
  text: string
121
130
  }
122
131
 
@@ -124,18 +133,31 @@ interface Section {
124
133
  function sections(page: string, md: string): Section[] {
125
134
  const out: Section[] = []
126
135
  let heading = ''
136
+ let id: string | undefined
127
137
  let lines: string[] = []
128
138
  let fence = false
139
+ // The site numbers a repeated id (`usage`, `usage-1`) over every `##` to
140
+ // `######` heading of the page, as import-docs.ts's `headingIds` does:
141
+ // cli.md repeats Usage per verb, and each linked the first.
142
+ const seen = new Map<string, number>()
129
143
  const flush = () => {
130
144
  const text = lines.join('\n').trim()
131
- if (text !== '') out.push({ page, heading, text })
145
+ if (text !== '') out.push({ page, heading, ...(id !== undefined ? { id } : {}), text })
132
146
  }
133
147
  for (const line of md.split('\n')) {
134
148
  if (/^\s*(```|~~~)/.test(line)) fence = !fence
135
- const h = fence ? null : /^#{1,4} (.+)$/.exec(line)
136
- if (h !== null) {
149
+ const h = fence ? null : /^(#{1,6}) (.+)$/.exec(line)
150
+ let slug: string | undefined
151
+ if (h !== null && h[1]!.length > 1) {
152
+ const base = anchor(h[2]!.replace(/`/g, '').trim())
153
+ const n = seen.get(base) ?? 0
154
+ seen.set(base, n + 1)
155
+ slug = n === 0 ? base : `${base}-${n}`
156
+ }
157
+ if (h !== null && h[1]!.length <= 4) {
137
158
  flush()
138
- heading = h[1]!.replace(/`/g, '').trim()
159
+ heading = h[2]!.replace(/`/g, '').trim()
160
+ id = slug ?? anchor(heading)
139
161
  lines = [line]
140
162
  } else lines.push(line)
141
163
  }
@@ -182,7 +204,7 @@ export function searchDocs(
182
204
  return (exact.length > 0 ? exact : scored).slice(0, limit).map(({ s }) => ({
183
205
  page: s.page,
184
206
  heading: s.heading,
185
- url: s.heading === '' ? pageUrl(s.page) : `${pageUrl(s.page)}#${anchor(s.heading)}`,
207
+ url: s.id === undefined ? pageUrl(s.page) : `${pageUrl(s.page)}#${s.id}`,
186
208
  text: s.text,
187
209
  }))
188
210
  }
@@ -220,16 +242,20 @@ export async function docsCmd(
220
242
  return 0
221
243
  }
222
244
  if (hits.length === 0) {
223
- process.stdout.write(
245
+ ui.say(
246
+ 'warn',
224
247
  `vx docs: no section of the docs at ${ref} holds every word of "${query}"; try fewer words, or ${SITE}llms.txt\n`,
248
+ 'out',
225
249
  )
226
250
  return 0
227
251
  }
252
+ const c = detectColors(process.stdout)
228
253
  process.stdout.write(
229
254
  hits
230
- .map(
231
- (h) =>
232
- `── ${h.page}.md${h.heading === '' ? '' : ` § ${h.heading}`} · ${h.url}\n\n${h.text}\n`,
255
+ .map((h) =>
256
+ c.enabled
257
+ ? `${ui.render([ui.excerpt(`${h.page}.md${h.heading === '' ? '' : ` § ${h.heading}`}`, h.text)], c)}${paint('', h.url, c, { dim: true })}\n`
258
+ : `── ${h.page}.md${h.heading === '' ? '' : ` § ${h.heading}`} · ${h.url}\n\n${h.text}\n`,
233
259
  )
234
260
  .join('\n'),
235
261
  )
package/src/cli/help.ts CHANGED
@@ -114,14 +114,14 @@ export function helpText(pluginCommands: readonly string[] = []): string {
114
114
  'Usage:',
115
115
  ' vx run [OPTIONS] [TASK | PKG#TASK ...] [-- forwarded-args...]',
116
116
  ' vx watch [OPTIONS] [--poll] TASK [-- forwarded-args...]',
117
- ' vx cache clean [--older-than <duration>] [--max-size <size>] [--global] [--dry-run] [--format pretty|json] [--cache-dir <path>]',
117
+ ' vx cache clean [--older-than <duration>] [--max-size <size>] [--global] [--dry] [--format pretty|json] [--cache-dir <path>]',
118
118
  ' vx lock [--check] [--format pretty|json]',
119
- ' vx init [--dry [--format pretty|json]] [--force] [--mjs] [--native|--keep] [--plugin <seam>]',
119
+ ' vx init [--dry [--format pretty|json]] [--force] [--mjs] [--native|--keep] [--plugin <hook>]',
120
120
  ' vx upgrade [TAG] [-g|--global]',
121
121
  ' vx show [PROJECT[#TASK] | TASK] [--filter <pattern>] [--affected[=<ref>]] [--format pretty|json]',
122
122
  ' vx info [--format pretty|json] [--cache-dir <path>]',
123
123
  ' vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]',
124
- ' vx last [RUNID] [--list[=N]] [--failed] [--log <task>] [--stats [<task>] [--branch <name>]] [--format pretty|json] [--cache-dir <path>]',
124
+ ' vx last [RUNID] [--list[=N]] [--failed] [--log <task>] [--stats [<task>] [--branch <name>]] [--sql <query>] [--live [--follow]] [--format pretty|json] [--cache-dir <path>]',
125
125
  ' vx docs <query> [--limit N] [--format pretty|json]',
126
126
  ' vx completions bash|zsh|fish',
127
127
  ' vx help [VERB]',
@@ -186,7 +186,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
186
186
  ' summary table after the run.',
187
187
  '',
188
188
  'Planning (for run — skips execution):',
189
- " --dry[=text|json] Print the task graph and each task's predicted hit",
189
+ " --dry[=pretty|json] Print the task graph and each task's predicted hit",
190
190
  ' or miss.',
191
191
  '',
192
192
  'Artifacts (for run):',
@@ -197,7 +197,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
197
197
  ' cache, distributed execution, and telemetry export are provided by',
198
198
  ' PLUGINS declared in .vx/workspace.ts — first-party or your own. Core',
199
199
  ' depends on none of them; a plain `vx run` never requires a plugin.',
200
- ' See the plugin guide: https://vznjs.github.io/vx/guides/plugins/',
200
+ ' See the plugin guide: https://vznjs.github.io/vx/docs/plugins/',
201
201
  '',
202
202
  'Argument forwarding (for run):',
203
203
  " Anything after `--` is forwarded (shell-quoted) to the task's exec",
@@ -215,11 +215,13 @@ export function helpText(pluginCommands: readonly string[] = []): string {
215
215
  'Cache management:',
216
216
  ' vx cache clean --older-than 30d Evict entries last accessed > 30 days ago.',
217
217
  ' vx cache clean --max-size 1G Keep total cache under 1 GB (LRU eviction).',
218
- " vx cache clean Clear .vx/cache and this repo's store ~/.vx/<id>/cache.",
218
+ " vx cache clean Clear .vx/cache and this repo's store",
219
+ ' ~/.vx/<id>/cache.',
219
220
  " --global Clear every repo's store under ~/.vx.",
220
- ' --dry-run Say what the policy would reap; delete nothing.',
221
+ ' --dry Say what the policy would reap; delete nothing.',
221
222
  ' --format <fmt> pretty (default) | json.',
222
- ' --cache-dir <path> The cache a run with the same flag uses (why, last, info too).',
223
+ ' --cache-dir <path> The cache a run with the same flag uses (why,',
224
+ ' last, info too).',
223
225
  '',
224
226
  ' Duration units: ms, s, m, h, d. Size units: K, M, G, T (powers of 1024).',
225
227
  ' Cache statistics — directory, entry count, size — are part of `vx info`.',
@@ -253,6 +255,10 @@ export function helpText(pluginCommands: readonly string[] = []): string {
253
255
  ' --stats [<task>] Per-task p50/p99, pass and hit rate, failure mode, peak',
254
256
  ' RSS and CPU over the last 50 runs; a task id or name narrows.',
255
257
  " --branch <name> The branch --stats' p50 forecasts from (default: the run's).",
258
+ ' --sql <query> One read-only SELECT over the history (runs, invocations,',
259
+ ' entries, task_outcomes, ...): filter, group, trend.',
260
+ ' --live The run in progress: running, ended, waiting tasks, warnings.',
261
+ " --follow With --live, the run's output and events until it ends.",
256
262
  ' --format <fmt> pretty (default) | json.',
257
263
  ' info, why and last:',
258
264
  ' --cache-dir <path> Read the cache a run with the same flag uses.',
@@ -268,7 +274,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
268
274
  ' --mjs Write .mjs files (no type import) instead of .ts.',
269
275
  ' --native Turbo or Nx: write a vx.config.ts per package (no question).',
270
276
  ' --keep Turbo or Nx: only .vx/workspace.ts, declaring turbo() or nx().',
271
- ' --plugin <seam> Instead: write plugins/<seam>.ts, a runnable plugin, and its',
277
+ ' --plugin <hook> Instead: write plugins/<hook>.ts, a runnable plugin, and its',
272
278
  ' test (executor, cache, telemetry, schedule, admit, commands,',
273
279
  ' project, graph, key).',
274
280
  '',
@@ -298,11 +304,11 @@ export function helpText(pluginCommands: readonly string[] = []): string {
298
304
  ' into .vx/projects.lock.json, the only place a run reads configs',
299
305
  ' from. A run never evaluates one, never checks the rules',
300
306
  ' and never writes the lock: with none it fails.',
301
- ' vx lock --check Audit the lock: file-changed hash report PLUS a full',
302
- ' re-evaluation compared against the frozen objects, and the',
303
- ' graph rules. Exits 1 on drift (e.g. configs that read env',
304
- ' vars). Run it before the run in CI. With --format json it prints',
305
- ' {upToDate, audited, notAudited, problems}.',
307
+ ' vx lock --check Audit the lock: re-evaluate every config here and compare',
308
+ ' it with the frozen object, find a config added or gone, and',
309
+ ' check the graph rules. Exits 1 on drift (e.g. configs that',
310
+ ' read env vars). Run it before the run in CI. With --format json',
311
+ ' it prints {upToDate, audited, notAudited, problems}.',
306
312
  '',
307
313
  ...(pluginCommands.length > 0 ? ['Plugin commands:', ...pluginCommands, ''] : []),
308
314
  ].join('\n')
@@ -317,6 +323,27 @@ export function seeHelp(verb: string): string {
317
323
  return ` (see \`vx ${verb} --help\`)`
318
324
  }
319
325
 
326
+ /**
327
+ * The refusal for a `project#task` spec with an empty half, or undefined.
328
+ * An empty task wins over an empty project: `#` names no task to run in
329
+ * every project, so `bare(task)` would print a hint with no task in it.
330
+ */
331
+ export function anchoredSpecError(
332
+ spec: string,
333
+ bare: (task: string) => string,
334
+ ): string | undefined {
335
+ const idx = spec.indexOf('#')
336
+ if (idx < 0) return undefined
337
+ const project = spec.slice(0, idx)
338
+ const task = spec.slice(idx + 1)
339
+ if (!task) {
340
+ const list = project ? `vx show ${project}` : 'vx show'
341
+ return `missing task name after '#' in "${spec}" (${list} lists the tasks)`
342
+ }
343
+ if (!project) return `missing project name before '#' in "${spec}" (${bare(task)})`
344
+ return undefined
345
+ }
346
+
320
347
  /** `--format`'s value, or the one refusal every verb gives for a bad one. */
321
348
  export function formatValue(
322
349
  v: string | undefined,
@@ -337,8 +364,8 @@ export function refusedWord(arg: string): string {
337
364
  }
338
365
 
339
366
  /**
340
- * `(did you mean --concurrency?)` for a flag within two edits of one `verb`
341
- * accepts; empty for anything else (a positional included). A third edit
367
+ * `(did you mean --concurrency?)` for a flag within two edits (one, for a
368
+ * name of four letters or fewer) of one `verb` accepts; empty for anything else (a positional included). A third edit
342
369
  * is allowed only between flags that share their first five characters,
343
370
  * so `--timeouts` reaches `--timeout` while `--zzz` does not reach `--all`.
344
371
  */
@@ -354,9 +381,21 @@ export function flagHint(verb: string, arg: string): string {
354
381
  // from `--format`: a run with its cache reads turned off.
355
382
  if ((name === '--format' || name === '--json') && flags.includes('--dry'))
356
383
  return ' (the run as JSON is `vx last --format json` after it; the plan is --dry=json)'
357
- // A flag cut short at a word (`--dry` for prune's `--dry-run`, which
358
- // `vx run` spells `--dry`) is too many edits away for either budget.
359
- const cut = flags.filter((f) => f.startsWith(`${name}-`))
384
+ // Watch takes run's flags but --dry, so the branch above missed it.
385
+ if ((name === '--format' || name === '--json') && verb === 'watch')
386
+ return " (vx watch prints no JSON; each cycle's run is `vx last --format json` after it)"
387
+ // A bare flag handed a value (`--check=yes`) is no edit from itself.
388
+ if (name !== arg && flags.includes(name)) return ` (did you mean ${name}?)`
389
+ // Another verb's own flag is no typo: `vx run --poll` was told "did you
390
+ // mean --all?". Named only when one other verb takes it.
391
+ const owners = CORE_VERBS.filter(
392
+ (v) => v !== verb.split(' ')[0] && acceptedFlags(v).includes(name),
393
+ )
394
+ if (owners.length === 1)
395
+ return `. \`vx ${owners[0] === 'cache' ? 'cache clean' : owners[0]}\` takes it`
396
+ // A flag one word too long (`--dry-run` for `--dry`) is too many edits
397
+ // away for either budget, and so is one cut short at a word.
398
+ const cut = flags.filter((f) => f.startsWith(`${name}-`) || name.startsWith(`${f}-`))
360
399
  const best =
361
400
  nearest(name, flags) ??
362
401
  nearest(