@vzn/vx 0.0.641 → 0.0.642

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 (67) hide show
  1. package/package.json +5 -12
  2. package/skills/vx/SKILL.md +4 -4
  3. package/src/cache/archive.ts +525 -104
  4. package/src/cache/cache.ts +92 -12
  5. package/src/cache/fold-memo.ts +58 -0
  6. package/src/cache/git-inputs.ts +213 -31
  7. package/src/cache/index.ts +6 -2
  8. package/src/cache/inputs.ts +169 -79
  9. package/src/cache/key-fold.ts +90 -69
  10. package/src/cache/layer.ts +2 -6
  11. package/src/cache/layered-cache.ts +68 -4
  12. package/src/cache/output-index.ts +25 -4
  13. package/src/cache/remote-lane.ts +27 -10
  14. package/src/cache/schema.ts +13 -3
  15. package/src/cache/scoped-cache.ts +3 -1
  16. package/src/cache/umask.ts +29 -0
  17. package/src/cli/docs.ts +71 -7
  18. package/src/cli/help.ts +4 -4
  19. package/src/cli/plan-format.ts +4 -1
  20. package/src/cli/run.ts +10 -3
  21. package/src/cli/select.ts +2 -0
  22. package/src/exec/runner.ts +5 -0
  23. package/src/exec/sandbox-runtime.ts +81 -1
  24. package/src/graph/priorities.ts +14 -1
  25. package/src/history/history.ts +43 -33
  26. package/src/index.ts +10 -0
  27. package/src/orchestrator/deferred-outputs.ts +3 -2
  28. package/src/orchestrator/execute-task.ts +20 -14
  29. package/src/orchestrator/hit-restore.ts +10 -2
  30. package/src/orchestrator/index.ts +1 -0
  31. package/src/orchestrator/lockfile-claim.ts +54 -8
  32. package/src/orchestrator/miss-save.ts +27 -17
  33. package/src/orchestrator/output-host.ts +9 -1
  34. package/src/orchestrator/plan.ts +4 -0
  35. package/src/orchestrator/plugin-host.ts +31 -0
  36. package/src/orchestrator/plugin.ts +29 -3
  37. package/src/orchestrator/prepare.ts +74 -5
  38. package/src/orchestrator/projects.ts +6 -1
  39. package/src/orchestrator/run.ts +37 -15
  40. package/src/orchestrator/sandbox-request.ts +1 -48
  41. package/src/orchestrator/save-lane.ts +17 -10
  42. package/src/orchestrator/signals.ts +7 -10
  43. package/src/orchestrator/stable-keys.ts +83 -25
  44. package/src/orchestrator/task-hash.ts +46 -12
  45. package/src/report/events.ts +12 -0
  46. package/src/report/framed-output.ts +2 -1
  47. package/src/report/logger.ts +53 -13
  48. package/src/report/status-line.ts +45 -4
  49. package/src/report/summary.ts +5 -2
  50. package/src/util/errors.ts +1 -1
  51. package/src/util/hidden-prompt.ts +74 -0
  52. package/src/util/index.ts +12 -2
  53. package/src/util/paths.ts +33 -14
  54. package/src/util/secrets.ts +150 -0
  55. package/src/workspace/affected.ts +63 -13
  56. package/src/workspace/config-schema.ts +37 -3
  57. package/src/workspace/project-loader.ts +1 -0
  58. package/src/workspace/workspace.ts +17 -11
  59. package/docs/caching.md +0 -2376
  60. package/docs/cli.md +0 -3285
  61. package/docs/execution.md +0 -676
  62. package/docs/features.md +0 -178
  63. package/docs/patterns.md +0 -219
  64. package/docs/schema.md +0 -2468
  65. package/docs/security.md +0 -253
  66. package/src/cli/docs-corpus.ts +0 -21
  67. package/src/cli/docs-text.d.ts +0 -5
@@ -698,6 +698,8 @@ export interface CacheLayer {
698
698
  entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles' | 'exitCode'>
699
699
  projectDir: string
700
700
  outputFiles: string[]
701
+ /** The empty directories under the outputs (absolute), kept so a hit makes them. */
702
+ outputDirs?: string[]
701
703
  /**
702
704
  * Resolved `outputs.workspaceFiles` (absolute paths) + the root
703
705
  * they're relative to. Packed under `workspace-outputs/<rel>`.
@@ -714,12 +716,6 @@ export interface CacheLayer {
714
716
  * Only computed on the miss/save path — never on a hit.
715
717
  */
716
718
  inputComponents?: readonly TaskInputRow[]
717
- /**
718
- * For a sandboxed task: whether it could read a canonical path. A
719
- * symlinked output is packed as its target's bytes, outside the
720
- * sandbox, so one whose target the task could not read is refused.
721
- */
722
- readable?: ((real: string) => boolean) | undefined
723
719
  }): Promise<void>
724
720
  /**
725
721
  * Adopt an artifact produced elsewhere — the remote-hit path. Streams
@@ -36,6 +36,44 @@ import type {
36
36
  } from './cache.js'
37
37
  import type { OutputDirRow } from './cache.js'
38
38
  import { RemoteLane, type ResolvedRemote } from './remote-lane.js'
39
+ import type { PluginSecrets } from '../util/index.js'
40
+
41
+ const STOPPED: unique symbol = Symbol('stopped')
42
+
43
+ /**
44
+ * A response body that errors once the run stops, so the ingest reading it
45
+ * fails at once, removes its temp, and releases the connection; the cache
46
+ * then closes under no write.
47
+ */
48
+ function endsOnStop(body: Blob | Response, stopped: Promise<typeof STOPPED>): Blob | Response {
49
+ if (body instanceof Blob || body.body === null) return body
50
+ const reader = body.body.getReader()
51
+ let stop = false
52
+ // Once, not raced per chunk: a cancel ends the pending read as `done`,
53
+ // which the flag turns into an error rather than a short body.
54
+ void stopped.then(() => {
55
+ stop = true
56
+ void reader.cancel().catch(() => undefined)
57
+ })
58
+ const stream = new ReadableStream<Uint8Array>({
59
+ async pull(controller) {
60
+ const next = await reader.read()
61
+ if (stop) controller.error(new Error('the run was stopped'))
62
+ else if (next.done) controller.close()
63
+ else controller.enqueue(next.value)
64
+ },
65
+ cancel: (reason) => reader.cancel(reason),
66
+ })
67
+ return new Response(stream, { headers: body.headers })
68
+ }
69
+
70
+ /** What a remote's `open` is handed: the run's CI verdict and its plugin's keychain. */
71
+ export interface RemoteOpenContext extends PluginSecrets {
72
+ /** The run is on CI: nobody may be asked. */
73
+ readonly ci: boolean
74
+ /** One line naming the plugin, for a fact that leaves the remote on. */
75
+ warn(message: string): void
76
+ }
39
77
 
40
78
  /**
41
79
  * What a remote cache layer must provide — THE plugin seam for remote
@@ -62,6 +100,17 @@ export interface RemoteCacheLayer {
62
100
  * dropped before it is printed.
63
101
  */
64
102
  readonly endpoint?: string
103
+ /**
104
+ * Optional: make the layer ready (read a credential, ask for one).
105
+ * Called once per run, in declared order, before the status line and the
106
+ * first task, and only when the run's policy lets this remote read or
107
+ * write: a `--cache-policy=remote:` run, `--no-cache` and a plan
108
+ * (`--dry`, `vx show`) never call it, so the other methods may be asked
109
+ * without it. A throw is a warning naming the plugin and turns the
110
+ * remote off for the run; the run goes on. `ctx.secret` reads the
111
+ * plugin's keychain and may ask on the terminal.
112
+ */
113
+ open?(ctx: RemoteOpenContext): Promise<void>
65
114
  /** Existence probe: `--dry` and `--force` ask it per key when the remote cannot batch. */
66
115
  has(hash: string): Promise<boolean>
67
116
  /**
@@ -99,7 +148,6 @@ export class LayeredCache implements CacheLayer {
99
148
  readonly hasRemote = true
100
149
 
101
150
  private readonly lanes: readonly RemoteLane[]
102
- private readonly readable: readonly RemoteLane[]
103
151
 
104
152
  /**
105
153
  * In-flight remote pulls keyed by hash. `prefetch` and `get` both go
@@ -130,7 +178,11 @@ export class LayeredCache implements CacheLayer {
130
178
  remotes: readonly ResolvedRemote[],
131
179
  ) {
132
180
  this.lanes = remotes.map((r) => new RemoteLane(r, local.traffic))
133
- this.readable = this.lanes.filter((l) => l.read)
181
+ }
182
+
183
+ // Asked, not kept: a remote whose `open` failed leaves the run after this is built.
184
+ private get readable(): RemoteLane[] {
185
+ return this.lanes.filter((l) => l.read)
134
186
  }
135
187
 
136
188
  key(input: CacheKeyInput): Promise<string> {
@@ -280,7 +332,17 @@ export class LayeredCache implements CacheLayer {
280
332
 
281
333
  private async pullFrom(lane: RemoteLane, hash: string, ctx?: CacheGetContext): Promise<boolean> {
282
334
  const fetchStart = performance.now()
283
- const remoteBody = await lane.get(hash)
335
+ // A stopped run waits on no read, as on no upload: a lookup or download
336
+ // in flight held a Ctrl-C until its last byte (M, 2026-10-17). The
337
+ // plugin's request is left to the exit, and its body is cancelled.
338
+ const stopped = lane.whenStopped.then((): typeof STOPPED => STOPPED)
339
+ const pending = lane.get(hash)
340
+ const remoteBody = await Promise.race([pending, stopped])
341
+ if (remoteBody === STOPPED) {
342
+ // A late answer's body would hold its connection to the exit.
343
+ void pending.then((r) => (r?.body instanceof Response ? r.body.body?.cancel() : undefined))
344
+ return false
345
+ }
284
346
  if (remoteBody === null) return false
285
347
 
286
348
  // Ingest the remote body into local using the caller-supplied
@@ -301,7 +363,9 @@ export class LayeredCache implements CacheLayer {
301
363
  ...(ctx?.outputs !== undefined ? { outputs: ctx.outputs } : {}),
302
364
  }
303
365
  try {
304
- await this.local.ingest(hash, remoteBody.body, meta)
366
+ const ingest = this.local.ingest(hash, endsOnStop(remoteBody.body, stopped), meta)
367
+ ingest.catch(() => undefined)
368
+ if ((await Promise.race([ingest, stopped])) === STOPPED) return false
305
369
  } catch (err) {
306
370
  // The bytes came off the network — a corrupt/truncated remote
307
371
  // artifact must degrade to a cache miss (task re-executes), not
@@ -17,7 +17,8 @@ const FILE_PREFIX_MTIME = -2
17
17
 
18
18
  import type { Database } from 'bun:sqlite'
19
19
  import { inHashes, lazyStatement } from './schema.js'
20
- import { lstatSync, statSync } from 'node:fs'
20
+ import { modeMatches } from './umask.js'
21
+ import { lstatSync, readlinkSync, statSync } from 'node:fs'
21
22
  import { lstat, readdir } from 'node:fs/promises'
22
23
  import path from 'node:path'
23
24
  import {
@@ -30,6 +31,19 @@ import {
30
31
  WORKSPACE_OUTPUT_PREFIX,
31
32
  } from './layer.js'
32
33
 
34
+ /**
35
+ * The mode a symlink output's row carries (S_IFLNK): its size is the
36
+ * target's byte length and its mtime the target's CRC-32.
37
+ */
38
+ export const LINK_MODE = 0o120000
39
+
40
+ /** A link row is current while a link with that target stands there; never followed. */
41
+ function linkCurrent(abs: string, e: { size: number; mtimeMs: number }): boolean {
42
+ if (!lstatSync(abs).isSymbolicLink()) return false
43
+ const to = readlinkSync(abs, 'buffer')
44
+ return to.byteLength === e.size && Bun.hash.crc32(to) === e.mtimeMs
45
+ }
46
+
33
47
  export class OutputIndex {
34
48
  private readonly insertOutputFile: ReturnType<Database['prepare']>
35
49
  private readonly deleteOutputFiles: ReturnType<Database['prepare']>
@@ -206,10 +220,13 @@ export class OutputIndex {
206
220
  // graph stage 100 → 54 ms, the whole process 422 → 368 ms.
207
221
  const results = expected.map((e) => {
208
222
  try {
223
+ if (e.mode === LINK_MODE) return linkCurrent(path.join(projectDir, e.path), e)
209
224
  const s = statSync(path.join(projectDir, e.path))
225
+ // An empty output directory: current while a directory stands there.
226
+ if (e.path.endsWith('/')) return s.isDirectory()
210
227
  return (
211
228
  s.size === e.size &&
212
- (s.mode & 0o777) === (e.mode & 0o777) &&
229
+ modeMatches(s.mode, e.mode) &&
213
230
  // MILLISECOND comparison (sub-ms tolerance for the float
214
231
  // round-trip through utimes). Save rows carry stat-ms and
215
232
  // restoreOutputs re-syncs restored files to the row value,
@@ -324,6 +341,7 @@ export class OutputIndex {
324
341
  const rows = this.rowsOf(hash)
325
342
  const stamps: Array<[string, number, number]> = []
326
343
  for (const e of rows) {
344
+ if (e.path.endsWith('/') || e.mode === LINK_MODE) continue
327
345
  const ws = e.path.startsWith(WORKSPACE_OUTPUT_PREFIX)
328
346
  const abs = ws
329
347
  ? path.join(workspaceRoot, e.path.slice(WORKSPACE_OUTPUT_PREFIX.length))
@@ -332,7 +350,7 @@ export class OutputIndex {
332
350
  const s = statSync(abs)
333
351
  if (
334
352
  s.size === e.size &&
335
- (s.mode & 0o777) === (e.mode & 0o777) &&
353
+ modeMatches(s.mode, e.mode) &&
336
354
  Math.abs(s.mtimeMs - e.mtimeMs) < 1
337
355
  ) {
338
356
  stamps.push([e.path, s.ino, Math.floor(s.ctimeMs)])
@@ -415,10 +433,13 @@ export class OutputIndex {
415
433
  const results = rows.map((r) => {
416
434
  let st
417
435
  try {
418
- st = lstatSync(path.join(projectDir, r.path))
436
+ // No throw for a missing dir: every restore after a clean asks
437
+ // about one, and an exception per hit cost more than the stat.
438
+ st = lstatSync(path.join(projectDir, r.path), { throwIfNoEntry: false })
419
439
  } catch {
420
440
  return r.mtimeMs === ABSENT_DIR_MTIME
421
441
  }
442
+ if (st === undefined) return r.mtimeMs === ABSENT_DIR_MTIME
422
443
  if (r.mtimeMs === ABSENT_DIR_MTIME) return false
423
444
  if (r.mtimeMs === FILE_PREFIX_MTIME) return st.isFile()
424
445
  return st.isDirectory() && Math.abs(st.mtimeMs - r.mtimeMs) < 1
@@ -10,8 +10,11 @@ import type { RemoteCacheLayer } from './layered-cache.js'
10
10
  /** One remote, resolved: its wire and what the run's policy and its `access` let it do. */
11
11
  export interface ResolvedRemote {
12
12
  readonly remote: RemoteCacheLayer
13
- readonly read: boolean
14
- readonly write: boolean
13
+ /** The plugin that supplied it, whose keychain its `open` reads; none for an injected layer. */
14
+ readonly plugin?: string
15
+ /** Cleared, with `write`, when the remote's `open` fails: off for the rest of the run. */
16
+ read: boolean
17
+ write: boolean
15
18
  /** Told of each failure this remote degrades to a miss; the default writes stderr. */
16
19
  readonly onError?: (err: Error) => void
17
20
  }
@@ -89,8 +92,7 @@ function batchOf(hashes: readonly string[]): { batch: string } {
89
92
  }
90
93
 
91
94
  export class RemoteLane {
92
- readonly read: boolean
93
- readonly write: boolean
95
+ private readonly resolved: ResolvedRemote
94
96
  private readonly remote: RemoteCacheLayer
95
97
  private readonly endpoint: string | undefined
96
98
  private readonly onError: ((err: Error) => void) | undefined
@@ -132,13 +134,20 @@ export class RemoteLane {
132
134
  resolved: ResolvedRemote,
133
135
  private readonly traffic: RemoteTraffic,
134
136
  ) {
137
+ this.resolved = resolved
135
138
  this.remote = resolved.remote
136
- this.read = resolved.read
137
- this.write = resolved.write
138
139
  this.onError = resolved.onError
139
140
  this.endpoint = printableEndpoint(resolved.remote.endpoint)
140
141
  }
141
142
 
143
+ get read(): boolean {
144
+ return this.resolved.read
145
+ }
146
+
147
+ get write(): boolean {
148
+ return this.resolved.write
149
+ }
150
+
142
151
  /**
143
152
  * Existence probe; a failure is a miss. A key a `hasMany` this run
144
153
  * answered for is not asked again: `--dry` batches its keys first.
@@ -248,6 +257,7 @@ export class RemoteLane {
248
257
  tally: { count: number; bytes: number; ms: number; failed: number },
249
258
  ): void {
250
259
  const key = this.trafficOf(hash)
260
+ const counted = this.traffic.aliases.get(hash) ?? hash
251
261
  key.uploading++
252
262
  this.traffic.inFlight++
253
263
  this.uploadQueue.push(async () => {
@@ -274,12 +284,17 @@ export class RemoteLane {
274
284
  // Nothing sent and nothing failed (the remote held it, or the run
275
285
  // stopped first): no traffic, so the footer counts no upload.
276
286
  if (key.uploading === 0 && !key.landed && key.failures.length === 0)
277
- this.traffic.keys.delete(hash)
287
+ this.traffic.keys.delete(counted)
278
288
  }
279
289
  })
280
290
  this.pump()
281
291
  }
282
292
 
293
+ /** Settles when the run is stopped. */
294
+ get whenStopped(): Promise<void> {
295
+ return this.stopping
296
+ }
297
+
283
298
  /** Resolves once every queued and in-flight upload has settled, or the run was stopped. */
284
299
  async drain(): Promise<void> {
285
300
  // A probe no upload waited on still runs: settled before the run tears
@@ -292,7 +307,8 @@ export class RemoteLane {
292
307
  /**
293
308
  * The run was stopped (Ctrl-C, SIGTERM, an embedder's abort): queued
294
309
  * uploads never start and the drain stops waiting on the ones in flight,
295
- * which a plugin cannot be asked to cancel. A slow remote held a stopped
310
+ * which a plugin cannot be asked to cancel. Reads in flight are given up
311
+ * the same way (`LayeredCache.pullFrom`). A slow remote held a stopped
296
312
  * run past the signal's exit bound, which then exited with no footer.
297
313
  */
298
314
  stop(): void {
@@ -343,10 +359,11 @@ export class RemoteLane {
343
359
  }
344
360
 
345
361
  private trafficOf(hash: string): RemoteKeyTraffic {
346
- let key = this.traffic.keys.get(hash)
362
+ const counted = this.traffic.aliases.get(hash) ?? hash
363
+ let key = this.traffic.keys.get(counted)
347
364
  if (key === undefined) {
348
365
  key = { uploading: 0, landed: false, failures: [] }
349
- this.traffic.keys.set(hash, key)
366
+ this.traffic.keys.set(counted, key)
350
367
  }
351
368
  return key
352
369
  }
@@ -140,6 +140,14 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
140
140
  content_hash TEXT NOT NULL,
141
141
  seen_at INTEGER NOT NULL
142
142
  );
143
+ -- v42: each task's last key by its fold up to the files and a token of
144
+ -- the files' git records (fold-memo.ts). A memo: the KEY is unchanged.
145
+ CREATE TABLE IF NOT EXISTS main.fold_memo (
146
+ task_id TEXT PRIMARY KEY,
147
+ probe TEXT NOT NULL,
148
+ key TEXT NOT NULL,
149
+ seen_at INTEGER NOT NULL
150
+ );
143
151
  -- The size of each index blob the enumeration checked against the
144
152
  -- worktree size git recorded (A-60). Fixed for its OID, so a warm
145
153
  -- run asks git for none; swept with file_hashes by seen_at, the
@@ -317,8 +325,10 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
317
325
  * the artifact is the record and the entries only its inventory, so a reset
318
326
  * drops the rows and keeps these, as it keeps the files. `artifacts_meta`
319
327
  * holds the table's own layout version (`'layout'`), the one thing that may
320
- * drop it. `at` is the last use a row-less sweep judges, as a file's mtime;
321
- * it sits before `bytes` so reading it never walks a blob's overflow pages.
328
+ * drop it. `at` is the last use a row-less sweep judges, as a file's mtime.
329
+ * A rowid table, not WITHOUT ROWID: there the blob sits in the key's b-tree,
330
+ * so every probe of a hash reads overflow pages; 10 000 lookups took 312 ms
331
+ * against 8.5 (a2, 2026-10-17).
322
332
  */
323
333
  export function createArtifactTables(db: Database, store: 'main' | 'store'): void {
324
334
  db.exec(artifactTables(store))
@@ -331,7 +341,7 @@ function artifactTables(store: 'main' | 'store'): string {
331
341
  hash TEXT PRIMARY KEY,
332
342
  at INTEGER NOT NULL,
333
343
  bytes BLOB NOT NULL
334
- ) WITHOUT ROWID;
344
+ );
335
345
  CREATE TABLE IF NOT EXISTS ${store}.artifacts_meta (
336
346
  key TEXT PRIMARY KEY,
337
347
  value TEXT NOT NULL
@@ -61,7 +61,9 @@ export class ScopedCache implements CacheLayer {
61
61
  }
62
62
 
63
63
  private own(hash: string): string {
64
- return scopedKey(this.scope, hash)
64
+ const key = scopedKey(this.scope, hash)
65
+ this.local?.traffic.aliases.set(key, hash)
66
+ return key
65
67
  }
66
68
 
67
69
  private keyOf(hash: string): string {
@@ -0,0 +1,29 @@
1
+ // The process umask, read when a restore starts: Bun reads it by setting 0
2
+ // and putting it back in one synchronous call (project-loader.ts
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.
6
+ const proc = globalThis.process as { umask?: () => number } | undefined
7
+ let umask = 0
8
+
9
+ /** Read the umask again: a restore's modes follow the process's as it is now. */
10
+ export function readUmask(): void {
11
+ if (typeof proc?.umask === 'function') umask = proc.umask()
12
+ }
13
+ readUmask()
14
+
15
+ /**
16
+ * The permission bits a restore gives a stored `mode`: minus the umask, as
17
+ * tar gives a non-root user and Turbo does. A hit under `umask 077` gave
18
+ * back 0644 / 0755 where the build left 0600 / 0700, readable by every
19
+ * user on the machine (Growth's parity run, 2026-10-16).
20
+ */
21
+ export function restoredMode(mode: number): number {
22
+ return mode & 0o777 & ~umask
23
+ }
24
+
25
+ /** A file on disk holds the stored `mode`: as saved, or as a restore here leaves it. */
26
+ export function modeMatches(disk: number, stored: number): boolean {
27
+ const d = disk & 0o777
28
+ return d === (stored & 0o777) || d === restoredMode(stored)
29
+ }
package/src/cli/docs.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  // `vx docs <query> [--limit N] [--format pretty|json]` — search vx's
2
- // reference offline, for an agent that cannot reach the site: each page is
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
4
  // cut at its headings, and the sections holding every query word print
4
5
  // whole, best first.
5
6
 
7
+ import { VERSION } from '../version.js'
6
8
  import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
7
9
 
8
10
  const SITE = 'https://vznjs.github.io/vx/'
9
11
  const DEFAULT_LIMIT = 3
12
+ const REPO = 'vznjs/vx'
13
+ const DIR = 'packages/vx/docs'
10
14
 
11
15
  interface DocsArgs {
12
16
  terms: string[]
@@ -59,9 +63,55 @@ function anchor(heading: string): string {
59
63
  .replace(/ /g, '-')
60
64
  }
61
65
 
62
- /** A page's URL on the site; `features.md` is served as `all-features/`. */
66
+ /** A page's URL on the site, as packages/vx-docs/scripts/import-docs.ts serves it. */
63
67
  function pageUrl(page: string): string {
64
- return `${SITE}${page === 'features' ? 'all-features' : page}/`
68
+ const served: Record<string, string> = {
69
+ README: 'overview',
70
+ features: 'all-features',
71
+ 'turbo-nx-support': 'compare/turbo-nx-support',
72
+ }
73
+ return `${SITE}${served[page] ?? page.toLowerCase()}/`
74
+ }
75
+
76
+ /** The git ref this vx's docs are at: its release tag; a source checkout (0.0.0) reads main. */
77
+ export function docsRef(version: string = VERSION): string {
78
+ return version === '0.0.0' ? 'main' : `v${version}`
79
+ }
80
+
81
+ /**
82
+ * Every page of packages/vx/docs at `ref` (its own `.md` files, the
83
+ * maintainers' STATUS.md aside), page name → markdown. One listing, then
84
+ * the pages at once. Throws with what failed; a token in GITHUB_TOKEN or
85
+ * GH_TOKEN lifts GitHub's anonymous listing limit.
86
+ */
87
+ export async function loadPages(
88
+ ref: string,
89
+ get: (url: string, init?: RequestInit) => Promise<Response> = fetch,
90
+ env: Record<string, string | undefined> = process.env,
91
+ ): Promise<Record<string, string>> {
92
+ const token = env['GITHUB_TOKEN'] ?? env['GH_TOKEN']
93
+ const listing = await get(
94
+ `https://api.github.com/repos/${REPO}/contents/${DIR}?ref=${encodeURIComponent(ref)}`,
95
+ {
96
+ headers: {
97
+ accept: 'application/vnd.github+json',
98
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
99
+ },
100
+ },
101
+ )
102
+ if (!listing.ok) throw new Error(`GitHub answered ${listing.status} listing ${DIR} at ${ref}`)
103
+ const names = ((await listing.json()) as Array<{ name: string; type: string }>)
104
+ .filter((e) => e.type === 'file' && e.name.endsWith('.md') && e.name !== 'STATUS.md')
105
+ .map((e) => e.name)
106
+ const texts = await Promise.all(
107
+ names.map(async (name) => {
108
+ const url = `https://raw.githubusercontent.com/${REPO}/${ref}/${DIR}/${name}`
109
+ const r = await get(url)
110
+ if (!r.ok) throw new Error(`GitHub answered ${r.status} for ${url}`)
111
+ return r.text()
112
+ }),
113
+ )
114
+ return Object.fromEntries(names.map((n, i) => [n.slice(0, -3), texts[i]!]))
65
115
  }
66
116
 
67
117
  interface Section {
@@ -137,7 +187,10 @@ export function searchDocs(
137
187
  }))
138
188
  }
139
189
 
140
- export async function docsCmd(args: readonly string[]): Promise<number> {
190
+ export async function docsCmd(
191
+ args: readonly string[],
192
+ load: (ref: string) => Promise<Record<string, string>> = loadPages,
193
+ ): Promise<number> {
141
194
  const parsed = parseDocsArgs(args)
142
195
  if (parsed.error) return refuse('docs', args, parsed.error, 'VX_E_USAGE')
143
196
  if (parsed.terms.length === 0) {
@@ -148,8 +201,19 @@ export async function docsCmd(args: readonly string[]): Promise<number> {
148
201
  'VX_E_USAGE',
149
202
  )
150
203
  }
151
- const { PAGES } = await import('./docs-corpus.js')
152
- const hits = searchDocs(PAGES, parsed.terms, parsed.limit)
204
+ const ref = docsRef()
205
+ let pages: Record<string, string>
206
+ try {
207
+ pages = await load(ref)
208
+ } catch (err) {
209
+ return refuse(
210
+ 'docs',
211
+ args,
212
+ `cannot read the docs at ${ref} from GitHub (${err instanceof Error ? err.message : String(err)}); they are on the site, ${SITE}`,
213
+ 'VX_E_NETWORK',
214
+ )
215
+ }
216
+ const hits = searchDocs(pages, parsed.terms, parsed.limit)
153
217
  const query = parsed.terms.join(' ')
154
218
  if (parsed.json) {
155
219
  process.stdout.write(`${JSON.stringify({ query, hits })}\n`)
@@ -157,7 +221,7 @@ export async function docsCmd(args: readonly string[]): Promise<number> {
157
221
  }
158
222
  if (hits.length === 0) {
159
223
  process.stdout.write(
160
- `vx docs: no section of ${Object.keys(PAGES).join(', ')} holds every word of "${query}"; try fewer words, or ${SITE}llms.txt\n`,
224
+ `vx docs: no section of the docs at ${ref} holds every word of "${query}"; try fewer words, or ${SITE}llms.txt\n`,
161
225
  )
162
226
  return 0
163
227
  }
package/src/cli/help.ts CHANGED
@@ -287,10 +287,10 @@ export function helpText(pluginCommands: readonly string[] = []): string {
287
287
  ' or write it where the shell loads completions from.',
288
288
  '',
289
289
  'Docs:',
290
- ' vx docs <query> Search the reference offline (cli, schema, caching, execution,',
291
- ' patterns, security, features): the sections holding every',
292
- ' word of the query print whole, best first, with their URL;',
293
- ' an error code (vx docs VX_E_CYCLE) prints its fix alone.',
290
+ " vx docs <query> Search the reference at this version's tag on GitHub (online):",
291
+ ' the sections holding every word of the query print whole,',
292
+ ' best first, with their URL; an error code',
293
+ ' (vx docs VX_E_CYCLE) prints its fix alone.',
294
294
  ' --limit N How many sections (default 3).',
295
295
  ' --format json {query, hits: [{page, heading, url, text}]}.',
296
296
  '',
@@ -148,7 +148,10 @@ export interface PlanTaskJson {
148
148
  }
149
149
 
150
150
  /** `affectedBase`: the ref a bare `--affected` guessed (or was given), so a reader knows what the diff was against. */
151
- export function formatPlanJson(plan: RunPlan, affectedBase?: string): string {
151
+ export function formatPlanJson(
152
+ plan: Pick<RunPlan, 'tasks' | 'predicted' | 'downloadDowngrades'>,
153
+ affectedBase?: string,
154
+ ): string {
152
155
  return (
153
156
  JSON.stringify(
154
157
  {
package/src/cli/run.ts CHANGED
@@ -595,9 +595,16 @@ export async function runCmd(args: readonly string[]): Promise<number> {
595
595
  const scopeOut: { affectedBase?: string } = {}
596
596
  const resolved = await resolveRunOptions(parsed, cwd, tasks, 'run', scopeOut)
597
597
  if ('error' in resolved) return refuse('run', args, resolved.error, resolved.code ?? 'VX_E_USAGE')
598
+ // `--dry=json` prints a plan even when there is nothing to run: a script
599
+ // reading stdout broke exactly then (Growth, 2026-10-16).
600
+ const nothingToRun = (): 0 => {
601
+ if (parsed.dry === 'json')
602
+ process.stdout.write(formatPlanJson({ tasks: [] }, scopeOut.affectedBase))
603
+ return 0
604
+ }
598
605
  if ('nothingSelected' in resolved) {
599
606
  sayNotice('info', 'vx run', resolved.nothingSelected)
600
- return 0
607
+ return nothingToRun()
601
608
  }
602
609
  const opts = resolved
603
610
  // The raw invocation, recorded on the `invocations` row so dashboards
@@ -628,11 +635,11 @@ export async function runCmd(args: readonly string[]): Promise<number> {
628
635
  // said no affected project declared a task one did (X-141).
629
636
  if (plan.noneAffected !== undefined) {
630
637
  sayLine('info', plan.noneAffected, 'vx run')
631
- return 0
638
+ return nothingToRun()
632
639
  }
633
640
  if (opts.selectedByDiff === true) {
634
641
  sayNotice('info', 'vx run', `no affected project declares task(s): ${tasks.join(', ')}`)
635
- return 0
642
+ return nothingToRun()
636
643
  }
637
644
  return refuse(
638
645
  'run',
package/src/cli/select.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  } from '../util/index.js'
37
37
  import {
38
38
  claimedAffected,
39
+ claimedExtraFiles,
39
40
  fingerprintClaims,
40
41
  gitOfDiscovery,
41
42
  hasHook,
@@ -123,6 +124,7 @@ async function workspaceFingerprintClaims(
123
124
  return {
124
125
  files: new Set(claims.keys()),
125
126
  affected: (change) => claimedAffected(claims.get(change.file)!, change, ctx),
127
+ extraFiles: (file, bytes) => claimedExtraFiles(claims.get(file)!, file, bytes),
126
128
  }
127
129
  }
128
130
 
@@ -50,6 +50,10 @@ export interface RunResult {
50
50
  /** `Bun.spawn` threw: no shell ran, so its 127 is vx's and says nothing
51
51
  * about the command (A-41). */
52
52
  spawnFailed?: true
53
+ /** The shell exited while a process it started still held its
54
+ * stdout/stderr past the drain bound (`POST_EXIT_CUT_LINE`): that process
55
+ * may still write the task's outputs, so the result is not saved. */
56
+ leftRunning?: true
53
57
  }
54
58
 
55
59
  /**
@@ -1034,6 +1038,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
1034
1038
  stderr,
1035
1039
  ...(proc.signalCode ? { signal: proc.signalCode } : {}),
1036
1040
  ...(timeout.timedOut() ? { timedOut: true } : {}),
1041
+ ...(cut ? { leftRunning: true } : {}),
1037
1042
  ...resourceUsageToCpuRss(proc.resourceUsage(), rssFloorFor),
1038
1043
  }
1039
1044
  }