@vzn/vx 0.0.641 → 0.0.643

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 (77) 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 +85 -11
  5. package/src/cache/fold-memo.ts +58 -0
  6. package/src/cache/git-inputs.ts +213 -31
  7. package/src/cache/index.ts +12 -3
  8. package/src/cache/inputs.ts +169 -79
  9. package/src/cache/key-fold.ts +90 -69
  10. package/src/cache/layer.ts +4 -6
  11. package/src/cache/layered-cache.ts +96 -10
  12. package/src/cache/output-index.ts +25 -4
  13. package/src/cache/plugin-cache.ts +23 -16
  14. package/src/cache/remote-lane.ts +25 -8
  15. package/src/cache/run-store.ts +4 -1
  16. package/src/cache/schema.ts +13 -3
  17. package/src/cache/scoped-cache.ts +74 -237
  18. package/src/cache/umask.ts +29 -0
  19. package/src/cli/docs.ts +71 -7
  20. package/src/cli/help.ts +6 -8
  21. package/src/cli/plan-format.ts +4 -1
  22. package/src/cli/plugin-commands.ts +11 -6
  23. package/src/cli/run.ts +14 -15
  24. package/src/cli/select.ts +9 -9
  25. package/src/cli/workspace-config.ts +8 -2
  26. package/src/config.ts +7 -16
  27. package/src/exec/runner.ts +5 -0
  28. package/src/exec/sandbox-runtime.ts +81 -1
  29. package/src/graph/priorities.ts +14 -1
  30. package/src/history/history.ts +43 -33
  31. package/src/index.ts +14 -0
  32. package/src/orchestrator/affected-tasks.ts +23 -2
  33. package/src/orchestrator/deferred-outputs.ts +3 -2
  34. package/src/orchestrator/execute-task.ts +20 -14
  35. package/src/orchestrator/hit-restore.ts +10 -2
  36. package/src/orchestrator/index.ts +5 -0
  37. package/src/orchestrator/lockfile-claim.ts +54 -8
  38. package/src/orchestrator/miss-save.ts +27 -17
  39. package/src/orchestrator/options.ts +9 -1
  40. package/src/orchestrator/output-host.ts +9 -1
  41. package/src/orchestrator/plan.ts +4 -0
  42. package/src/orchestrator/plugin-host.ts +38 -4
  43. package/src/orchestrator/plugin.ts +49 -5
  44. package/src/orchestrator/prepare.ts +141 -22
  45. package/src/orchestrator/projects.ts +25 -6
  46. package/src/orchestrator/run-context.ts +80 -0
  47. package/src/orchestrator/run.ts +38 -15
  48. package/src/orchestrator/sandbox-request.ts +1 -48
  49. package/src/orchestrator/save-lane.ts +17 -10
  50. package/src/orchestrator/signals.ts +7 -10
  51. package/src/orchestrator/stable-keys.ts +83 -25
  52. package/src/orchestrator/task-hash.ts +52 -12
  53. package/src/report/events.ts +12 -0
  54. package/src/report/framed-output.ts +2 -1
  55. package/src/report/logger.ts +53 -13
  56. package/src/report/status-line.ts +45 -4
  57. package/src/report/summary.ts +8 -2
  58. package/src/settings.ts +11 -11
  59. package/src/util/errors.ts +1 -1
  60. package/src/util/hidden-prompt.ts +74 -0
  61. package/src/util/index.ts +12 -2
  62. package/src/util/paths.ts +33 -14
  63. package/src/util/secrets.ts +150 -0
  64. package/src/workspace/affected.ts +182 -75
  65. package/src/workspace/config-schema.ts +81 -19
  66. package/src/workspace/index.ts +3 -0
  67. package/src/workspace/project-loader.ts +1 -0
  68. package/src/workspace/workspace.ts +17 -11
  69. package/docs/caching.md +0 -2376
  70. package/docs/cli.md +0 -3285
  71. package/docs/execution.md +0 -676
  72. package/docs/features.md +0 -178
  73. package/docs/patterns.md +0 -219
  74. package/docs/schema.md +0 -2468
  75. package/docs/security.md +0 -253
  76. package/src/cli/docs-corpus.ts +0 -21
  77. package/src/cli/docs-text.d.ts +0 -5
@@ -1,20 +1,12 @@
1
- // The run's `cache.scope` over every layer at once (owner, 2026-10-14): a
2
- // scoped run reads the default key, then the scope's, and writes only the
3
- // scope's, in the local store and every remote alike. Below this wrapper no
4
- // layer knows a scope exists: the scope's key is a key like any other.
1
+ // A branch's cache scope (owner, 2026-10-17): the local store never knows
2
+ // one, so every branch shares one local pool under the task keys. Scopes
3
+ // decide only which remote entry a task asks for: a task the branch changed
4
+ // reads the scope's key, every other task the default key, and a scoped run
5
+ // writes every remote under the scope's key. An artifact is packed under its
6
+ // task key wherever it is stored, so a remote entry ingests under that key.
5
7
 
6
8
  import { xxh3hex } from '../util/index.js'
7
- import type {
8
- CacheEntry,
9
- CacheGetContext,
10
- CacheLayer,
11
- DeclaredOutputs,
12
- IngestMeta,
13
- InvocationRecord,
14
- OutputDirRow,
15
- RunRecord,
16
- SaveArgs,
17
- } from './layer.js'
9
+ import type { RemoteCacheLayer, RemoteKey } from './layered-cache.js'
18
10
 
19
11
  /** The key a scope stores `hash` under: task-key shaped, 16 hex, so every wire stores it unchanged. */
20
12
  export function scopedKey(scope: string, hash: string): string {
@@ -22,229 +14,74 @@ export function scopedKey(scope: string, hash: string): string {
22
14
  }
23
15
 
24
16
  /**
25
- * A layer as a scoped run sees it. Callers speak task keys; each answer is
26
- * remembered under the key that served it (the default key on a default hit,
27
- * the scope's on a scope hit or a save), so the restore, the output index and
28
- * the run's history all reach the entry that answered. An artifact records
29
- * the key it was packed under and ingest refuses another, so a derived-key
30
- * collision degrades to a miss. A default artifact a remote ingest refuses is
31
- * a miss there, and the scope's key is asked next (Security F6).
17
+ * Which remote key each task asks for, in a run with a scope. The routes
18
+ * come from the branch's diff, which only a remote call awaits: a run the
19
+ * local cache answers never waits on git.
32
20
  */
33
- export class ScopedCache implements CacheLayer {
34
- readonly local?: CacheLayer['local']
35
- readonly hasRemote?: boolean
36
- readonly drainUploads?: () => Promise<void>
37
- readonly stopUploads?: () => void
38
- readonly remoteHasMany?: (hashes: readonly string[]) => Promise<Set<string> | null>
39
- /** A scoped run saves under the scope's key, so that is the key asked. */
40
- readonly probeBeforeUpload?: (hashes: Promise<readonly string[]>) => void
41
- readonly getMany?: (
42
- hashes: readonly string[],
43
- ctx?: (hash: string) => CacheGetContext,
44
- ) => Promise<Map<string, CacheEntry>>
45
- /** Task key → the key that answers for it this run. */
46
- private readonly served = new Map<string, string>()
47
-
48
- constructor(
49
- private readonly inner: CacheLayer,
50
- private readonly scope: string,
51
- ) {
52
- if (inner.local !== undefined) this.local = inner.local
53
- if (inner.hasRemote !== undefined) this.hasRemote = inner.hasRemote
54
- if (inner.drainUploads !== undefined) this.drainUploads = () => inner.drainUploads!()
55
- if (inner.stopUploads !== undefined) this.stopUploads = () => inner.stopUploads!()
56
- if (inner.remoteHasMany !== undefined) this.remoteHasMany = (h) => this.scopedHasMany(h)
57
- if (inner.probeBeforeUpload !== undefined)
58
- this.probeBeforeUpload = (h) =>
59
- inner.probeBeforeUpload!(h.then((keys) => keys.map((k) => this.own(k))))
60
- if (inner.getMany !== undefined) this.getMany = (h, ctx) => this.scopedGetMany(h, ctx)
61
- }
62
-
63
- private own(hash: string): string {
64
- return scopedKey(this.scope, hash)
65
- }
66
-
67
- private keyOf(hash: string): string {
68
- return this.served.get(hash) ?? hash
69
- }
70
-
71
- /** A batch keyed by served keys, keyed back by task key. */
72
- private batch<T>(
73
- hashes: readonly string[],
74
- read: (keys: string[]) => Map<string, T>,
75
- ): Map<string, T> {
76
- const keys = hashes.map((h) => this.keyOf(h))
77
- const found = read(keys)
78
- const out = new Map<string, T>()
79
- for (let i = 0; i < hashes.length; i++) {
80
- const v = found.get(keys[i]!)
81
- if (v !== undefined) out.set(hashes[i]!, v)
21
+ export class BranchScope {
22
+ /** Finds the tasks that read the scope, at the first remote call; null: every task does. */
23
+ private route: (() => Promise<ReadonlySet<string>>) | null = null
24
+ private routes: Promise<ReadonlySet<string>> | null = null
25
+ /** Task key → its task. */
26
+ private readonly taskOf = new Map<string, string>()
27
+
28
+ constructor(readonly scope: string) {}
29
+
30
+ /** Only the tasks `ids` finds read the scope; set before any remote call. */
31
+ routeTasks(ids: () => Promise<ReadonlySet<string>>): void {
32
+ this.route = ids
33
+ }
34
+
35
+ noteKey(taskId: string, hash: string): void {
36
+ this.taskOf.set(hash, taskId)
37
+ }
38
+
39
+ /** The remote key `hash` is read under, and the scope it is read in (none: the default key). */
40
+ private async readKey(hash: string): Promise<{ key: string; at: RemoteKey }> {
41
+ const id = this.taskOf.get(hash)
42
+ if (this.route !== null) this.routes ??= this.route()
43
+ const tasks = this.routes === null ? null : await this.routes
44
+ return tasks !== null && id !== undefined && !tasks.has(id)
45
+ ? { key: hash, at: {} }
46
+ : { key: scopedKey(this.scope, hash), at: { scope: this.scope } }
47
+ }
48
+
49
+ /**
50
+ * `inner` as this run asks it: each task key under its route's key; every
51
+ * write under the scope's. Each call also names its scope, for a backend
52
+ * that keeps one store per scope; no wire vx ships sends it.
53
+ */
54
+ remote(inner: RemoteCacheLayer): RemoteCacheLayer {
55
+ const read = (h: string) => this.readKey(h)
56
+ const at: RemoteKey = { scope: this.scope }
57
+ const hasMany = inner.hasMany
58
+ return {
59
+ ...(inner.endpoint !== undefined ? { endpoint: inner.endpoint } : {}),
60
+ ...(inner.open !== undefined ? { open: (ctx) => inner.open!(ctx) } : {}),
61
+ has: async (h) => {
62
+ const r = await read(h)
63
+ return inner.has(r.key, r.at)
64
+ },
65
+ ...(hasMany !== undefined
66
+ ? {
67
+ hasMany: async (hashes) => {
68
+ const reads = await Promise.all(hashes.map(read))
69
+ const keys = reads.map((r) => r.key)
70
+ const found = await hasMany.call(
71
+ inner,
72
+ keys,
73
+ reads.map((r) => r.at),
74
+ )
75
+ if (!(found instanceof Set)) return found
76
+ return new Set(hashes.filter((_, i) => found.has(keys[i]!)))
77
+ },
78
+ }
79
+ : {}),
80
+ get: async (h) => {
81
+ const r = await read(h)
82
+ return inner.get(r.key, r.at)
83
+ },
84
+ put: (h, body, meta) => inner.put(scopedKey(this.scope, h), body, { ...meta, ...at }),
82
85
  }
83
- return out
84
- }
85
-
86
- /** A task key the store holds under the default key or the scope's. */
87
- async localHasMany(hashes: readonly string[]): Promise<Set<string>> {
88
- const held = await this.inner.localHasMany?.([...hashes, ...hashes.map((h) => this.own(h))])
89
- if (held === undefined) return new Set()
90
- return new Set(hashes.filter((h) => held.has(h) || held.has(this.own(h))))
91
- }
92
-
93
- private async scopedHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
94
- const scoped = hashes.map((h) => this.own(h))
95
- const [plain, own] = await Promise.all([
96
- this.inner.remoteHasMany!(hashes),
97
- this.inner.remoteHasMany!(scoped),
98
- ])
99
- if (plain == null || own == null) return null
100
- const found = new Set(plain)
101
- const scopeOnly: string[] = []
102
- for (let i = 0; i < hashes.length; i++) {
103
- if (plain.has(hashes[i]!) || !own.has(scoped[i]!)) continue
104
- found.add(hashes[i]!)
105
- scopeOnly.push(hashes[i]!)
106
- }
107
- // Their default key is known absent: its GET is skipped.
108
- this.inner.markRemoteAbsent?.(scopeOnly)
109
- return found
110
- }
111
-
112
- markRemoteAbsent(hashes: Iterable<string>): void {
113
- const all: string[] = []
114
- for (const h of hashes) all.push(h, this.own(h))
115
- this.inner.markRemoteAbsent?.(all)
116
- }
117
-
118
- key(input: Parameters<CacheLayer['key']>[0]): Promise<string> {
119
- return this.inner.key(input)
120
- }
121
-
122
- async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
123
- // The scope's key first when only it is local: the default key's
124
- // remote GET would cost a round-trip for the same inputs.
125
- const own = this.own(hash)
126
- const keys = (await this.inner.localHasMany?.([hash, own]))?.has(own)
127
- ? [own, hash]
128
- : [hash, own]
129
- for (const key of keys) {
130
- const entry = await this.inner.get(key, ctx)
131
- if (entry === null) continue
132
- this.served.set(hash, key)
133
- return key === hash ? entry : { ...entry, hash }
134
- }
135
- return null
136
- }
137
-
138
- private async scopedGetMany(
139
- hashes: readonly string[],
140
- ctx?: (hash: string) => CacheGetContext,
141
- ): Promise<Map<string, CacheEntry>> {
142
- const out = await this.inner.getMany!(hashes, ctx)
143
- for (const h of out.keys()) this.served.set(h, h)
144
- const rest = hashes.filter((h) => !out.has(h))
145
- if (rest.length === 0) return out
146
- const byOwn = new Map(rest.map((h) => [this.own(h), h]))
147
- const scoped = await this.inner.getMany!(
148
- [...byOwn.keys()],
149
- ctx === undefined ? undefined : (key) => ctx(byOwn.get(key)!),
150
- )
151
- for (const [key, entry] of scoped) {
152
- const hash = byOwn.get(key)!
153
- this.served.set(hash, key)
154
- out.set(hash, { ...entry, hash })
155
- }
156
- return out
157
- }
158
-
159
- async has(hash: string): Promise<'local' | 'remote' | null> {
160
- return (await this.inner.has(hash)) ?? (await this.inner.has(this.own(hash)))
161
- }
162
-
163
- async prefetch(hash: string, ctx?: CacheGetContext): Promise<boolean> {
164
- return (await this.inner.prefetch(hash, ctx)) || this.inner.prefetch(this.own(hash), ctx)
165
- }
166
-
167
- loadOutputFilesBatch(hashes: readonly string[]): ReturnType<CacheLayer['loadOutputFilesBatch']> {
168
- return this.batch(hashes, (keys) => this.inner.loadOutputFilesBatch(keys))
169
- }
170
-
171
- isOutputsCurrent(
172
- ...args: Parameters<CacheLayer['isOutputsCurrent']>
173
- ): ReturnType<CacheLayer['isOutputsCurrent']> {
174
- return this.inner.isOutputsCurrent(...args)
175
- }
176
-
177
- async recordOutputDirs(
178
- hash: string,
179
- projectDir: string,
180
- prefixes: readonly string[],
181
- holds?: (files: readonly string[]) => boolean,
182
- ): Promise<void> {
183
- await this.inner.recordOutputDirs?.(this.keyOf(hash), projectDir, prefixes, holds)
184
- }
185
-
186
- recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
187
- this.inner.recordOutputStamps?.(this.keyOf(hash), projectDir, workspaceRoot)
188
- }
189
-
190
- loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
191
- return this.batch(hashes, (keys) => this.inner.loadOutputDirsBatch?.(keys) ?? new Map())
192
- }
193
-
194
- async outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
195
- return (await this.inner.outputDirsCurrent?.(projectDir, rows)) ?? false
196
- }
197
-
198
- restoreOutputs(
199
- hash: string,
200
- projectDir: string,
201
- workspaceRoot?: string,
202
- outputs?: DeclaredOutputs,
203
- ): Promise<void> {
204
- return this.inner.restoreOutputs(this.keyOf(hash), projectDir, workspaceRoot, outputs)
205
- }
206
-
207
- save(args: SaveArgs): Promise<void> {
208
- const key = this.own(args.hash)
209
- this.served.set(args.hash, key)
210
- return this.inner.save({ ...args, hash: key })
211
- }
212
-
213
- ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
214
- const key = this.own(hash)
215
- this.served.set(hash, key)
216
- return this.inner.ingest(key, body, meta)
217
- }
218
-
219
- /** History names the key that answered, so `vx why` reads that entry's components. */
220
- recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
221
- this.inner.recordRunBundle({
222
- ...bundle,
223
- runs: bundle.runs.map((r) => (r.hash === undefined ? r : { ...r, hash: this.keyOf(r.hash) })),
224
- })
225
- }
226
-
227
- stats(...args: Parameters<CacheLayer['stats']>): ReturnType<CacheLayer['stats']> {
228
- return this.inner.stats(...args)
229
- }
230
-
231
- hashFile(filePath: string): Promise<string> {
232
- return this.inner.hashFile(filePath)
233
- }
234
-
235
- outputsPath(hash: string): string {
236
- return this.inner.outputsPath(this.keyOf(hash))
237
- }
238
-
239
- artifactSize(hash: string): number | undefined {
240
- return this.inner.artifactSize?.(this.keyOf(hash))
241
- }
242
-
243
- prune(...args: Parameters<CacheLayer['prune']>): ReturnType<CacheLayer['prune']> {
244
- return this.inner.prune(...args)
245
- }
246
-
247
- close(): void {
248
- this.inner.close()
249
86
  }
250
87
  }
@@ -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
@@ -134,8 +134,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
134
134
  ' foo, @scope/*, ./packages/foo, foo..., ...foo,',
135
135
  ' foo^..., !foo, [<since>]',
136
136
  ' --affected[=<base>] Projects changed since <base>, and dependents',
137
- ' (--filter "...[<base>]"); default run.affectedBase,',
138
- ' else origin/HEAD, main, master, HEAD~1.',
137
+ ' (--filter "...[<base>]"); default the merge base',
138
+ ' with origin/<baseBranch>; none on that branch.',
139
139
  " pkg#task Run a specific project's task directly.",
140
140
  '',
141
141
  'Execution (for run):',
@@ -159,8 +159,6 @@ export function helpText(pluginCommands: readonly string[] = []): string {
159
159
  ' flags; unnamed layers keep theirs. Examples:',
160
160
  ' local:rw,remote:r (remote read-only), remote:',
161
161
  ' (remote off), local:r (local read-only).',
162
- " --cache-scope <name> This run's cache.scope (pr-123): write there,",
163
- ' read the default keys first.',
164
162
  ' --cache-dir <path> Cache directory (cwd-relative); default',
165
163
  ' .vx/cache, entries in ~/.vx/<id>/cache.',
166
164
  ' --cache-download <mode> all (default) | toplevel | none:',
@@ -287,10 +285,10 @@ export function helpText(pluginCommands: readonly string[] = []): string {
287
285
  ' or write it where the shell loads completions from.',
288
286
  '',
289
287
  '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.',
288
+ " vx docs <query> Search the reference at this version's tag on GitHub (online):",
289
+ ' the sections holding every word of the query print whole,',
290
+ ' best first, with their URL; an error code',
291
+ ' (vx docs VX_E_CYCLE) prints its fix alone.',
294
292
  ' --limit N How many sections (default 3).',
295
293
  ' --format json {query, hits: [{page, heading, url, text}]}.',
296
294
  '',
@@ -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
  {
@@ -9,8 +9,8 @@
9
9
 
10
10
  import type { CachePolicy, PluginCache, ResolvedRemote } from '../cache/index.js'
11
11
  import type { WorkspaceConfig } from '../config.js'
12
- import type { VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
13
- import { cacheScopeEnvError, findWorkspaceRoot, isCacheScope } from '../workspace/index.js'
12
+ import type { BranchFacts, VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
13
+ import { configuredBaseBranch, findWorkspaceRoot } from '../workspace/index.js'
14
14
  import { machineParallelism, maskedLine } from '../util/index.js'
15
15
  import { resolveSetting } from '../settings.js'
16
16
 
@@ -49,6 +49,7 @@ async function workspacePlugins(cwd: string): Promise<
49
49
  workspaceConfig: WorkspaceConfig | null
50
50
  concurrency: number
51
51
  plugins: readonly VxPlugin[]
52
+ branch: BranchFacts
52
53
  }
53
54
  | UnresolvedPluginCommand
54
55
  | null
@@ -76,6 +77,7 @@ async function workspacePlugins(cwd: string): Promise<
76
77
  (resolveSetting('run.concurrency', ws.workspaceConfig).value as number | undefined) ??
77
78
  machineParallelism(),
78
79
  plugins: ws.plugins,
80
+ branch: ws.branch,
79
81
  }
80
82
  }
81
83
 
@@ -136,13 +138,16 @@ async function verbCache(
136
138
  ws: Exclude<Awaited<ReturnType<typeof workspacePlugins>>, UnresolvedPluginCommand | null>,
137
139
  plugin: string,
138
140
  ): Promise<PluginCache> {
139
- const [{ Cache, pluginCache }, { resolveCache }] = await Promise.all([
141
+ const [{ Cache, pluginCache }, { resolveCache, resolveCacheScope }] = await Promise.all([
140
142
  import('../cache/index.js'),
141
143
  import('../orchestrator/index.js'),
142
144
  ])
143
- const envScope = process.env['VX_CACHE_SCOPE'] || undefined
144
- if (envScope !== undefined && !isCacheScope(envScope)) throw cacheScopeEnvError()
145
- const scope = envScope ?? ws.workspaceConfig?.cache?.scope
145
+ const scope = resolveCacheScope(
146
+ ws.workspaceRoot,
147
+ ws.branch.scope,
148
+ ws.plugins.some((p) => p.cache !== undefined),
149
+ configuredBaseBranch(ws.workspaceConfig),
150
+ )
146
151
  const localCache = new Cache(
147
152
  ws.cacheDir,
148
153
  { read: true, write: false },
package/src/cli/run.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isatty } from 'node:tty'
2
- import { cacheScopeError, findWorkspaceRoot, isCacheScope } from '../workspace/index.js'
2
+ import { findWorkspaceRoot } from '../workspace/index.js'
3
3
  import { translateForeign } from './foreign-flags.js'
4
4
  import { flagHint, refuse, seeHelp } from './help.js'
5
5
  import {
@@ -60,8 +60,6 @@ export interface RunArgs {
60
60
  remoteRequested?: boolean
61
61
  /** `--cache-dir <path>`: override the cache directory (cwd-relative). */
62
62
  cacheDir: string | undefined
63
- /** `--cache-scope <name>`: this run's `cache.scope`, above `VX_CACHE_SCOPE` and the workspace's. */
64
- cacheScope?: string
65
63
  /** `vx watch --poll` (`watch.poll`). */
66
64
  poll?: true
67
65
  forwardArgs: string[]
@@ -90,9 +88,7 @@ function flagOf(a: string | undefined, name: string): string | undefined {
90
88
  // The settings parsed by their kind alone; the cache flags, `--continue`
91
89
  // and `--cache-dir` keep their own branches above.
92
90
  const GENERIC_FLAGS = SETTINGS.filter(
93
- (s) =>
94
- s.flag?.verb === 'run' &&
95
- !['run.continue', 'cache.dir', 'cache.policy', 'cache.scope'].includes(s.path),
91
+ (s) => s.flag?.verb === 'run' && !['run.continue', 'cache.dir', 'cache.policy'].includes(s.path),
96
92
  )
97
93
 
98
94
  /** The setting a flag names. */
@@ -204,11 +200,6 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
204
200
  }
205
201
  }
206
202
  out.cacheDir = v
207
- } else if (a === '--cache-scope' || a?.startsWith('--cache-scope=')) {
208
- const v = a === '--cache-scope' ? before[++i] : a.slice('--cache-scope='.length)
209
- if (v === undefined || !isCacheScope(v))
210
- return { ...out, error: cacheScopeError('--cache-scope') }
211
- out.cacheScope = v
212
203
  } else if (flagOf(a, '--cache-policy')) {
213
204
  cacheFlag = true
214
205
  const name = flagOf(a, '--cache-policy')!
@@ -494,7 +485,6 @@ export async function resolveRunOptions(
494
485
  ? { cacheFlags: parsed.cacheFlags }
495
486
  : { cache: parsed.cache }),
496
487
  ...(parsed.remoteRequested ? { remoteRequested: true } : {}),
497
- ...(parsed.cacheScope !== undefined ? { cacheScope: parsed.cacheScope } : {}),
498
488
  // A laptop reads the shared cache and never writes it (owner,
499
489
  // 2026-10-03); naming the remote in `--cache-policy` is asking to.
500
490
  ...(!parsed.remoteRequested && !onCI(process.env['CI'])
@@ -524,6 +514,8 @@ export async function resolveRunOptions(
524
514
  if (parsed.settings[path] !== undefined) Object.assign(opts, { [field]: parsed.settings[path] })
525
515
  }
526
516
  if (parsed.profile !== undefined) opts.profile = parsed.profile
517
+ // `--affected`'s base is the branch's for the cache scope too.
518
+ if (affectedFilter !== undefined) opts.scopeBase = affectedFilter.slice(4, -1)
527
519
 
528
520
  return opts
529
521
  }
@@ -595,9 +587,16 @@ export async function runCmd(args: readonly string[]): Promise<number> {
595
587
  const scopeOut: { affectedBase?: string } = {}
596
588
  const resolved = await resolveRunOptions(parsed, cwd, tasks, 'run', scopeOut)
597
589
  if ('error' in resolved) return refuse('run', args, resolved.error, resolved.code ?? 'VX_E_USAGE')
590
+ // `--dry=json` prints a plan even when there is nothing to run: a script
591
+ // reading stdout broke exactly then (Growth, 2026-10-16).
592
+ const nothingToRun = (): 0 => {
593
+ if (parsed.dry === 'json')
594
+ process.stdout.write(formatPlanJson({ tasks: [] }, scopeOut.affectedBase))
595
+ return 0
596
+ }
598
597
  if ('nothingSelected' in resolved) {
599
598
  sayNotice('info', 'vx run', resolved.nothingSelected)
600
- return 0
599
+ return nothingToRun()
601
600
  }
602
601
  const opts = resolved
603
602
  // The raw invocation, recorded on the `invocations` row so dashboards
@@ -628,11 +627,11 @@ export async function runCmd(args: readonly string[]): Promise<number> {
628
627
  // said no affected project declared a task one did (X-141).
629
628
  if (plan.noneAffected !== undefined) {
630
629
  sayLine('info', plan.noneAffected, 'vx run')
631
- return 0
630
+ return nothingToRun()
632
631
  }
633
632
  if (opts.selectedByDiff === true) {
634
633
  sayNotice('info', 'vx run', `no affected project declares task(s): ${tasks.join(', ')}`)
635
- return 0
634
+ return nothingToRun()
636
635
  }
637
636
  return refuse(
638
637
  'run',