@vzn/vx 0.0.636 → 0.0.638

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +246 -213
  3. package/docs/cli.md +791 -705
  4. package/docs/execution.md +68 -76
  5. package/docs/features.md +47 -42
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +392 -205
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +4 -4
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +28 -13
  19. package/src/bin.ts +30 -9
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +124 -53
  22. package/src/cache/chained-cache.ts +4 -0
  23. package/src/cache/git-inputs.ts +277 -63
  24. package/src/cache/index.ts +15 -1
  25. package/src/cache/inputs.ts +102 -36
  26. package/src/cache/key-fold.ts +8 -7
  27. package/src/cache/layer.ts +15 -18
  28. package/src/cache/layered-cache.ts +20 -102
  29. package/src/cache/plugin-cache.ts +248 -0
  30. package/src/cache/policy.ts +32 -27
  31. package/src/cache/run-history.ts +104 -14
  32. package/src/cache/run-store.ts +77 -0
  33. package/src/cache/schema.ts +58 -31
  34. package/src/cache/scoped-cache.ts +237 -0
  35. package/src/cli/cache.ts +173 -34
  36. package/src/cli/completions.ts +11 -7
  37. package/src/cli/core-alias.ts +1 -1
  38. package/src/cli/foreign-flags.ts +13 -12
  39. package/src/cli/help.ts +110 -70
  40. package/src/cli/index.ts +67 -24
  41. package/src/cli/info.ts +43 -27
  42. package/src/cli/init.ts +115 -407
  43. package/src/cli/last.ts +157 -9
  44. package/src/cli/lock.ts +356 -122
  45. package/src/cli/plan-format.ts +7 -5
  46. package/src/cli/plugin-commands.ts +81 -14
  47. package/src/cli/plugin-templates.ts +11 -11
  48. package/src/cli/run.ts +116 -160
  49. package/src/cli/select.ts +50 -47
  50. package/src/cli/show.ts +83 -59
  51. package/src/cli/task-verb.ts +6 -13
  52. package/src/cli/upgrade.ts +415 -149
  53. package/src/cli/watch-cycle.ts +9 -2
  54. package/src/cli/watch-filter.ts +39 -61
  55. package/src/cli/watch-fs.ts +29 -16
  56. package/src/cli/watch-judge.ts +18 -4
  57. package/src/cli/watch-set.ts +16 -42
  58. package/src/cli/watch.ts +227 -230
  59. package/src/cli/why.ts +38 -18
  60. package/src/cli/workspace-config.ts +33 -70
  61. package/src/config.ts +169 -70
  62. package/src/exec/env.ts +1 -31
  63. package/src/exec/executor.ts +1 -5
  64. package/src/exec/kill-tree.ts +2 -1
  65. package/src/exec/sandbox-runtime.ts +12 -1
  66. package/src/exec/sandbox-violations.ts +9 -1
  67. package/src/graph/dependency-spec.ts +1 -1
  68. package/src/graph/index.ts +1 -0
  69. package/src/graph/priorities.ts +1 -1
  70. package/src/graph/scheduler.ts +27 -7
  71. package/src/graph/task-graph.ts +76 -24
  72. package/src/handoff.ts +169 -0
  73. package/src/index.ts +35 -58
  74. package/src/orchestrator/affected-tasks.ts +15 -8
  75. package/src/orchestrator/command-windows.ts +44 -0
  76. package/src/orchestrator/deferred-outputs.ts +12 -10
  77. package/src/orchestrator/doctor.ts +98 -110
  78. package/src/orchestrator/download-policy.ts +3 -3
  79. package/src/orchestrator/events.ts +19 -3
  80. package/src/orchestrator/excluded-keys.ts +0 -2
  81. package/src/orchestrator/execute-task.ts +147 -69
  82. package/src/orchestrator/failure-mode.ts +19 -10
  83. package/src/orchestrator/fingerprint-watch.ts +18 -15
  84. package/src/orchestrator/forecast.ts +10 -2
  85. package/src/orchestrator/framed-output.ts +9 -5
  86. package/src/orchestrator/graph-stage.ts +5 -5
  87. package/src/orchestrator/history.ts +126 -30
  88. package/src/orchestrator/hit-restore.ts +15 -3
  89. package/src/orchestrator/index.ts +20 -5
  90. package/src/orchestrator/local-shortcircuit.ts +2 -1
  91. package/src/orchestrator/lockfile-claim.ts +79 -20
  92. package/src/orchestrator/logger.ts +80 -41
  93. package/src/orchestrator/metrics.ts +78 -9
  94. package/src/orchestrator/miss-save.ts +3 -0
  95. package/src/orchestrator/options.ts +42 -29
  96. package/src/orchestrator/output-host.ts +72 -0
  97. package/src/orchestrator/plan.ts +6 -8
  98. package/src/orchestrator/plugin-host.ts +100 -66
  99. package/src/orchestrator/plugin.ts +173 -67
  100. package/src/orchestrator/prepare.ts +163 -78
  101. package/src/orchestrator/projects.ts +105 -99
  102. package/src/orchestrator/remote-prefetch.ts +11 -6
  103. package/src/orchestrator/run-context.ts +25 -6
  104. package/src/orchestrator/run-failures.ts +17 -8
  105. package/src/orchestrator/run-lock.ts +1 -1
  106. package/src/orchestrator/run-records.ts +1 -1
  107. package/src/orchestrator/run-report.ts +9 -9
  108. package/src/orchestrator/run-settings.ts +48 -0
  109. package/src/orchestrator/run.ts +244 -119
  110. package/src/orchestrator/sandbox-fix.ts +39 -20
  111. package/src/orchestrator/sandbox-request.ts +84 -13
  112. package/src/orchestrator/signals.ts +1 -1
  113. package/src/orchestrator/stable-keys.ts +13 -11
  114. package/src/orchestrator/status-line.ts +49 -7
  115. package/src/orchestrator/summary.ts +115 -106
  116. package/src/orchestrator/task-hash.ts +12 -83
  117. package/src/orchestrator/telemetry.ts +4 -4
  118. package/src/settings.ts +428 -0
  119. package/src/{orchestrator → util}/colors.ts +5 -0
  120. package/src/util/config-frame.ts +31 -2
  121. package/src/util/errors.ts +33 -1
  122. package/src/util/essential-env.ts +31 -0
  123. package/src/util/hide-env.ts +107 -0
  124. package/src/util/index.ts +16 -3
  125. package/src/util/paths.ts +10 -0
  126. package/src/util/refusal-notice.ts +111 -0
  127. package/src/util/report.ts +31 -0
  128. package/src/util/settle.ts +49 -30
  129. package/src/util/size.ts +23 -4
  130. package/src/util/timing.ts +3 -2
  131. package/src/util/ui.ts +186 -0
  132. package/src/util/ulid.ts +1 -1
  133. package/src/util/verbs.ts +0 -15
  134. package/src/workspace/affected.ts +84 -232
  135. package/src/workspace/config-eval.ts +15 -20
  136. package/src/workspace/config-imports.ts +78 -3
  137. package/src/workspace/config-schema.ts +209 -268
  138. package/src/workspace/dependency-rules.ts +65 -0
  139. package/src/workspace/filter.ts +2 -2
  140. package/src/workspace/fingerprint.ts +11 -121
  141. package/src/workspace/index.ts +20 -27
  142. package/src/workspace/load-reads.ts +4 -4
  143. package/src/workspace/lockfile.ts +96 -140
  144. package/src/workspace/migration.ts +307 -215
  145. package/src/workspace/package-graph.ts +30 -216
  146. package/src/workspace/project-loader.ts +80 -285
  147. package/src/workspace/repo-id.ts +2 -1
  148. package/src/workspace/workspace.ts +363 -831
  149. package/src/cache/config-evals.ts +0 -130
  150. package/src/workspace/config-cache.ts +0 -661
  151. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -3,12 +3,7 @@
3
3
  // so the layers can be swapped without touching the others.
4
4
 
5
5
  import type { ProjectEntry } from '../workspace/index.js'
6
- import {
7
- inputStabilityEnv,
8
- loadWorkspace,
9
- unreachedHint,
10
- unreachedPackages,
11
- } from '../workspace/index.js'
6
+ import { inputStabilityEnv, LOCK_INVALID, lockEntryBroken } from '../workspace/index.js'
12
7
  import { realpathSync } from 'node:fs'
13
8
  import path from 'node:path'
14
9
  import {
@@ -16,6 +11,11 @@ import {
16
11
  type CacheLayer,
17
12
  type CachePolicy,
18
13
  cachesNothing,
14
+ formatCachePolicy,
15
+ packRunOutputs,
16
+ resolveOutputs,
17
+ resolveWorkspaceOutputs,
18
+ runEntryName,
19
19
  stopRuntimeProbes,
20
20
  } from '../cache/index.js'
21
21
  import { VERSION } from '../version.js'
@@ -42,8 +42,6 @@ import {
42
42
  stageTimes,
43
43
  killGraceMs,
44
44
  MAX_TIMEOUT_MS,
45
- parseDuration,
46
- parseSize,
47
45
  printTimings,
48
46
  ulid,
49
47
  isLiteralPattern,
@@ -54,23 +52,28 @@ import {
54
52
  teardownTimeoutMs,
55
53
  secretMask,
56
54
  isFsRefusal,
55
+ isUserError,
56
+ detectColors,
57
+ relPosix,
58
+ type ColorSupport,
57
59
  } from '../util/index.js'
58
60
  import { keyedProjects } from './keyed-projects.js'
59
61
  import { isDefaultBuild } from './projects.js'
60
- import { prepareSandbox } from './sandbox-request.js'
62
+ import { commandWriteReach, prepareSandbox } from './sandbox-request.js'
61
63
  import type { OutputDirSnapshot, SaveFacts } from './miss-save.js'
62
64
  import { admitTasks, taintTracker } from './admission.js'
63
65
  import { unkeyedGroupOutcome, type ExecuteArgs } from './execute-task.js'
64
66
  import { excludedTaint } from './excluded-keys.js'
65
67
  import { keyUpstream } from './upstream.js'
66
68
  import { busLogger, createEventBus, terminalSubscriber, type EventBus } from './events.js'
67
- import { installPlugins, PluginSetupError } from './plugin.js'
69
+ import { installPlugins, PluginSetupError, type RunReport, type RunReportTask } from './plugin.js'
70
+ import { decodeOutputLog } from './output-log.js'
68
71
  import { buildAdmission, executorLabel, resolveExecutors, teardownPlugins } from './plugin-host.js'
69
72
  import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
73
+ import { claimOutput, guardedOutput } from './output-host.js'
70
74
  import { assembleRunSummary, isPassStatus } from './telemetry.js'
71
75
  import type { RunContextRecord } from './telemetry.js'
72
76
  import { defaultLogger, type DefaultLogger, resolveOutputView, type Logger } from './logger.js'
73
- import { detectColors, type ColorSupport } from './colors.js'
74
77
  import { plainOutput } from './plain-output.js'
75
78
  import { formatPersistentList } from './framed-output.js'
76
79
  import { createForecast } from './forecast.js'
@@ -126,10 +129,13 @@ import {
126
129
  type RunSummaryJson,
127
130
  } from './run-artifacts.js'
128
131
  import { createSaveLane } from './save-lane.js'
132
+ import { commandWindows } from './command-windows.js'
129
133
  import { formatOutcomeTable, formatRunSummary } from './summary.js'
130
134
  import { detectFlaky, type FlakyCandidate, type FlakyFinding } from './failure-mode.js'
131
135
  import { createMissExplainer } from './miss-reason.js'
132
136
  import type { HeldServer, RunOptions, RunSummary } from './options.js'
137
+ import { withRunSettings } from './run-settings.js'
138
+ import { resolveSetting } from '../settings.js'
133
139
 
134
140
  // Per run, never shared: a `vx watch` process runs many, and a shared map
135
141
  // is one `preProbed.set` away from leaking a hit across cycles.
@@ -140,40 +146,19 @@ const emptyShortCircuit = (): ShortCircuit => ({
140
146
  groupKeys: new Map(),
141
147
  })
142
148
 
143
- /**
144
- * Parse the `VX_TASK_TIMEOUT` env var (ms) — the "global" run-level task
145
- * timeout default. A missing/empty/non-positive-integer value yields
146
- * `undefined` (ignored), so a typo never silently disables a task's own
147
- * `exec.timeout`. A value past `MAX_TIMEOUT_MS` is clamped to it — see below.
148
- *
149
- * Exported for `tests/options-resolve.test.ts`, which pins every accepted
150
- * and ignored form of this rung; it has no other caller outside this file.
151
- */
152
- export function readTaskTimeoutEnv(): number | undefined {
153
- const raw = process.env['VX_TASK_TIMEOUT']
154
- if (raw === undefined || raw === '') return undefined
155
- const n = Number(raw)
156
- if (!Number.isInteger(n) || n <= 0) return undefined
157
- // CLAMPED, not refused — unlike `exec.timeout` and `--timeout`, this rung's
158
- // contract is already "unusable value → fall back", so it never throws. But
159
- // falling back is wrong here: someone who typed a huge number wants a long
160
- // timeout, and the largest expressible one IS long (~24.8 days). Handing it
161
- // to `setTimeout` unbounded would mean 1 ms — killing every task instantly.
162
- return Math.min(n, MAX_TIMEOUT_MS)
163
- }
164
-
165
- /**
166
- * Compact the 4-axis cache policy into the `invocations.cache_policy`
167
- * column string. Each enabled axis contributes its flag; a fully-on
168
- * policy reads `'lR,lW,rR,rW'`. Pure presentation — never affects the key.
169
- */
170
- function compactCachePolicy(p: CachePolicy): string {
171
- const parts: string[] = []
172
- if (p.localRead) parts.push('lR')
173
- if (p.localWrite) parts.push('lW')
174
- if (p.remoteRead) parts.push('rR')
175
- if (p.remoteWrite) parts.push('rW')
176
- return parts.join(',')
149
+ /** The files `node`'s declared outputs match on disk now, workspace-relative and sorted. */
150
+ async function outputsLeft(
151
+ node: TaskNode,
152
+ workspaceRoot: string,
153
+ nestedProjectDirs: string[],
154
+ ): Promise<string[]> {
155
+ const declared = node.config.cache?.outputs
156
+ if (declared === undefined) return []
157
+ const [own, ws] = await Promise.all([
158
+ resolveOutputs({ projectDir: node.projectDir, outputs: declared.files, nestedProjectDirs }),
159
+ resolveWorkspaceOutputs({ workspaceRoot, outputs: declared.workspaceFiles ?? [] }),
160
+ ])
161
+ return [...new Set([...own, ...ws].map((p) => relPosix(workspaceRoot, p)))].sort()
177
162
  }
178
163
 
179
164
  /**
@@ -182,7 +167,7 @@ function compactCachePolicy(p: CachePolicy): string {
182
167
  * local cache writes NOWHERE, yet the write axis reads as on. Normalising
183
168
  * here — the ONE place both `run()` and `planRun()` derive it from — is
184
169
  * what keeps `--dry` describing the run you are about to get: reading the
185
- * raw request made the plan label `--cache=local:,remote:rw` "cache miss —
170
+ * raw request made the plan label `--cache-policy=local:,remote:rw` "cache miss —
186
171
  * would exec" (a result that will be stored) for a run in which caching is
187
172
  * entirely off.
188
173
  */
@@ -201,7 +186,7 @@ function effectiveCachePolicy(requested: CachePolicy, hasRemoteLayer: boolean):
201
186
  * ingests hits into local for execute-task's lazy probe. The gate asks
202
187
  * the layer (`hasRemote`), not `instanceof LayeredCache` — a
203
188
  * third-party remote layer stalls on the classify just as hard.
204
- * - the policy reads locally (a `--no-cache`/`--force`/`--cache=local:`
189
+ * - the policy reads locally (a `--no-cache`/`--force`/`--cache-policy=local:`
205
190
  * run reads nothing locally → nothing to restore → skip);
206
191
  * - there is at least one task.
207
192
  * A flat graph has no ordering to bypass, but the classify pass is worth
@@ -294,6 +279,12 @@ function refuseRunOptions(options: RunOptions): void {
294
279
  oneOf('outputLogs', options.outputLogs, ['full', 'errors-only', 'none', 'hash-only'])
295
280
  oneOf('download', options.download, ['all', 'toplevel', 'none'])
296
281
  oneOf('flow', options.flow, ['focused', 'broad'])
282
+ oneOf('schedule', options.schedule, [
283
+ 'most-work',
284
+ 'critical-path',
285
+ 'direct-dependents',
286
+ 'ready-order',
287
+ ])
297
288
  const exclude = options.excludeDependencies
298
289
  if (exclude !== undefined && exclude !== 'all' && !names(exclude))
299
290
  refuse('excludeDependencies', exclude, "'all' or an array of task names")
@@ -304,11 +295,15 @@ function refuseRunOptions(options: RunOptions): void {
304
295
  refuse('concurrency', concurrency, 'a positive integer')
305
296
  if (retries !== undefined && !(Number.isInteger(retries) && retries >= 0))
306
297
  refuse('retries', retries, 'a non-negative integer')
307
- if (
308
- timeout !== undefined &&
309
- !(Number.isInteger(timeout) && timeout > 0 && timeout <= MAX_TIMEOUT_MS)
310
- )
311
- refuse('timeout', timeout, `a positive integer of ms, at most ${MAX_TIMEOUT_MS}`)
298
+ for (const [name, ms] of [
299
+ ['timeout', timeout],
300
+ ['killGrace', options.killGrace],
301
+ ['teardownTimeout', options.teardownTimeout],
302
+ ['readyNotice', options.readyNotice],
303
+ ] as const) {
304
+ if (ms !== undefined && !(Number.isInteger(ms) && ms > 0 && ms <= MAX_TIMEOUT_MS))
305
+ refuse(name, ms, `a positive integer of ms, at most ${MAX_TIMEOUT_MS}`)
306
+ }
312
307
  }
313
308
 
314
309
  export async function run(options: RunOptions): Promise<RunSummary> {
@@ -337,6 +332,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
337
332
  coalesce: true,
338
333
  ...(options.tty === true ? { tty: true } : {}),
339
334
  ...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
335
+ ...(options.footer === false ? { footer: false } : {}),
340
336
  },
341
337
  )
342
338
  : null
@@ -349,16 +345,26 @@ export async function run(options: RunOptions): Promise<RunSummary> {
349
345
  // Tasks run with colour forced (exec/env.ts); where the terminal prints
350
346
  // plain, their escapes are stripped. A custom logger gets the task's
351
347
  // bytes as written, as the cache holds them: it formats its own output.
352
- const unsubscribeTerminal = bus.subscribe(
353
- terminalSubscriber(terminal === null || colors.enabled ? sink : plainOutput(sink)),
354
- )
348
+ const render = terminalSubscriber(terminal === null || colors.enabled ? sink : plainOutput(sink))
349
+ let unsubscribeTerminal = bus.subscribe(render)
355
350
  const detach = (): void => {
356
351
  unsubscribeTerminal()
357
352
  terminal?.settle()
358
353
  }
354
+ // A plugin's `output` renders the run from its start; core's renderer
355
+ // stays the fallback. Only core's own renderer is replaceable: an
356
+ // embedder's `log` is never consulted away.
357
+ const takeOutput =
358
+ terminal === null
359
+ ? undefined
360
+ : (claimed: { logger: Logger; owner: string }): void => {
361
+ terminal.settle()
362
+ unsubscribeTerminal()
363
+ unsubscribeTerminal = bus.subscribe(guardedOutput(claimed, render))
364
+ }
359
365
  let held = false
360
366
  try {
361
- const summary = await runOnBus(options, bus, colors, terminal)
367
+ const summary = await runOnBus(options, bus, colors, terminal, takeOutput)
362
368
  if (summary.persistent === undefined) return summary
363
369
  // Servers handed back still running still write: `vx watch dev` printed
364
370
  // none of its server's log while it idled, the renderer gone with this
@@ -382,6 +388,7 @@ async function runOnBus(
382
388
  bus: EventBus,
383
389
  colors: ColorSupport,
384
390
  terminal: DefaultLogger | null,
391
+ takeOutput?: (claimed: { logger: Logger; owner: string }) => void,
385
392
  ): Promise<RunSummary> {
386
393
  const log = busLogger(bus)
387
394
  // A server's output goes through a cell of its own: a later watch cycle
@@ -398,6 +405,7 @@ async function runOnBus(
398
405
  }
399
406
 
400
407
  const prepared = await prepareRun(options, log)
408
+ options = withRunSettings(options, prepared.workspaceConfig)
401
409
  mark('plugin stages')
402
410
  // Every exit from here tears the plugins down: their capability
403
411
  // factories ran in prepareRun, and teardown is what releases what those
@@ -406,11 +414,11 @@ async function runOnBus(
406
414
  // per cycle under `vx watch`. A plugin whose own setup threw set up
407
415
  // nothing, and is the one left out.
408
416
  let tornDown = false
409
- const teardown = async (except?: unknown): Promise<void> => {
417
+ const teardown = async (except?: unknown, report?: RunReport): Promise<void> => {
410
418
  if (tornDown) return
411
419
  tornDown = true
412
420
  const plugins = prepared.plugins.filter((p) => p !== except)
413
- await teardownPlugins(plugins, (m) => log.status(m))
421
+ await teardownPlugins(plugins, (m) => log.status(m), report)
414
422
  }
415
423
  // A task whose command re-enters `vx run` in the workspace running it
416
424
  // is refused. When the inner run reaches this task again it forks a run
@@ -438,7 +446,7 @@ async function runOnBus(
438
446
  // A refusal is the caller's to print (on stderr, as `vx run:`): as a
439
447
  // status line it went to stdout, into whatever a script piped there.
440
448
  if (prepared.unresolvedTasks.length > 0) {
441
- const refused = `no projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${elsewhereHint(prepared.declaredElsewhere)}${didYouMean(prepared.unresolvedTasks, prepared.hintProjects, prepared.declaredElsewhere)}${await initHint(prepared)}`
449
+ const refused = `no projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${await unresolvedHint(prepared)}`
442
450
  await teardown()
443
451
  prepared.cache.close()
444
452
  return { ok: false, outcomes: [], refused }
@@ -468,6 +476,13 @@ async function runOnBus(
468
476
  // the plugin. A plugin without `setup` subscribes nothing.
469
477
  let disposePlugins: (() => void) | undefined
470
478
  let telemetry: TelemetryHandle | undefined
479
+ // One run-id per `vx run` invocation. Every task in the resulting graph
480
+ // carries it so analytics queries can group by invocation; plugins get it
481
+ // in their teardown's `RunReport`.
482
+ const runId = ulid()
483
+ const head = captureGitContext(prepared.workspaceRoot)
484
+ const ciContext = detectCi(process.env)
485
+ const keepRunLog = prepared.keepRunEntry !== undefined
471
486
  try {
472
487
  disposePlugins = await installPlugins({
473
488
  plugins: prepared.plugins,
@@ -475,6 +490,9 @@ async function runOnBus(
475
490
  workspaceRoot: prepared.workspaceRoot,
476
491
  cacheDir: prepared.cacheDir,
477
492
  warn: (m) => log.status(m),
493
+ cache: prepared.cacheFor,
494
+ ci: ciContext.ci,
495
+ branch: prepared.branch(),
478
496
  })
479
497
  } catch (err) {
480
498
  disposePlugins?.()
@@ -499,14 +517,13 @@ async function runOnBus(
499
517
  cacheDir,
500
518
  cache,
501
519
  nodes,
502
- workspaceFingerprint,
503
520
  fingerprintWatch,
504
521
  nestedDirsByProject,
505
522
  gitFilesCache,
506
523
  hashCache,
507
524
  workspaceProjectCount,
508
525
  } = prepared
509
- const concurrency = options.concurrency ?? workspaceConfig?.concurrency ?? machineParallelism()
526
+ const concurrency = options.concurrency ?? machineParallelism()
510
527
  // An earlier cycle's servers: the ones this graph starts again unchanged
511
528
  // stay up; the others go now, before anything binds their ports. A stop
512
529
  // that lands meanwhile ends the run here, having run nothing: an aborted
@@ -598,7 +615,7 @@ async function runOnBus(
598
615
  }
599
616
  }
600
617
 
601
- // `--download` (default `all`) decides ONCE per task, here, whether a
618
+ // `cache.download` (default `all`) decides ONCE per task, here, whether a
602
619
  // remote execution's outputs come home.
603
620
  const downloadPolicy = options.download ?? 'all'
604
621
  const localPlaced = locallyPlaced(placements, nodes.keys())
@@ -609,20 +626,20 @@ async function runOnBus(
609
626
  remoteOnly: placements.remoteOnly,
610
627
  })
611
628
 
612
- // Run-level default task timeout (ms), applied to any task WITHOUT its own
613
- // `exec.timeout`. Precedence, highest first: `--timeout`/RunOptions.timeout
614
- // → `VX_TASK_TIMEOUT` env → workspace `timeout`. A malformed env value is
615
- // ignored (a bad timeout must not silently disable a task's own limit).
616
- const taskTimeoutDefault =
617
- options.timeout ?? readTaskTimeoutEnv() ?? workspaceConfig?.timeout ?? undefined
629
+ // `run.timeout` (ms), for any task WITHOUT its own `exec.timeout`.
630
+ const taskTimeoutDefault = options.timeout
618
631
  // Read once, before any task: a bad value is refused here, not mid-run.
619
632
  const inputStability = {
620
633
  run: inputStabilityEnv(),
621
- ...(workspaceConfig?.inputStability !== undefined
622
- ? { workspace: workspaceConfig.inputStability }
634
+ ...(workspaceConfig?.cache?.inputStability !== undefined
635
+ ? { workspace: workspaceConfig.cache.inputStability }
623
636
  : {}),
624
637
  }
625
638
 
639
+ const cleanOutputs = resolveSetting('cache.outputs.clean', workspaceConfig).value as
640
+ | boolean
641
+ | undefined
642
+
626
643
  // Run-scoped registries of live subprocesses:
627
644
  // - `liveChildren`: in-flight children. The runner adds/removes
628
645
  // each child around its spawn (persistent children stay until
@@ -710,7 +727,7 @@ async function runOnBus(
710
727
  // bumps are flushed, so a throw between opening the cache and the
711
728
  // normal close leaked the SQLite handle (it matters in a long-lived
712
729
  // host) AND lost this run's touch record, after which an LRU
713
- // `vx cache prune` can evict entries the run just hit. Once-only
730
+ // `vx cache clean` can evict entries the run just hit. Once-only
714
731
  // because the normal path closes before the persistent-task wait —
715
732
  // holding the handle open for a dev server's whole lifetime would be
716
733
  // worse — and because close() re-runs its retention DELETEs.
@@ -737,9 +754,6 @@ async function runOnBus(
737
754
  }),
738
755
  )
739
756
  try {
740
- // One run-id per `vx run` invocation. Every task in the resulting
741
- // graph carries it so analytics queries can group by invocation.
742
- const runId = ulid()
743
757
  const runStartHrTimeNs = process.hrtime.bigint()
744
758
  const endedAtMsAtStart = Date.now()
745
759
  const remoteCacheEnabled = prepared.hasRemoteLayer
@@ -751,7 +765,7 @@ async function runOnBus(
751
765
  const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer)
752
766
  if (options.remoteRequested === true && !prepared.hasRemoteLayer) {
753
767
  log.status(
754
- 'vx: --cache named the remote cache, but no cache plugin in vx.workspace.* supplies one — the remote axes are off for this run',
768
+ 'vx: --cache-policy named the remote cache, but no cache plugin in .vx/workspace.* supplies one — the remote axes are off for this run',
755
769
  )
756
770
  }
757
771
  const deferredOutputs = new DeferredOutputs({
@@ -768,8 +782,12 @@ async function runOnBus(
768
782
  // fails a run). `dirty` reuses the `git status --porcelain` the
769
783
  // GitFilesCache populate already ran for input enumeration — no
770
784
  // second status spawn.
771
- const gitContext = captureGitContext(workspaceRoot, gitFilesCache.worktreeDirty)
772
- const ciContext = detectCi(process.env)
785
+ // `run.branch` names the branch over git's (`prepared.branch`).
786
+ const gitContext = {
787
+ ...head,
788
+ dirty: gitFilesCache.worktreeDirty,
789
+ branch: prepared.branch(),
790
+ }
773
791
  const hostContext = captureHostContext()
774
792
 
775
793
  // The canonical run-context record — the same git/CI/host data the
@@ -784,7 +802,7 @@ async function runOnBus(
784
802
  let runContextRecord: RunContextRecord | undefined
785
803
  // Gate on a plugin that can actually CONTRIBUTE a sink, not on declaring
786
804
  // any plugin at all: a cache-only plugin has no telemetry hook to
787
- // consult, so paying for the record (2 git spawns + a `.vx/workspace-id`
805
+ // consult, so paying for the record (2 git spawns + a `.vx/cache/workspace-id`
788
806
  // write on a remote-less repo) buys nothing. A plugin that has the hook
789
807
  // but declines still pays — its answer is only knowable by asking.
790
808
  const hasTelemetryPlugin = prepared.plugins.some((p) => p.telemetry !== undefined)
@@ -807,7 +825,7 @@ async function runOnBus(
807
825
  invocationCommand(options.command?.split(' ') ?? process.argv.slice(1)),
808
826
  ),
809
827
  requestedTasks: [...options.tasks],
810
- cachePolicy: compactCachePolicy(policy),
828
+ cachePolicy: formatCachePolicy(policy),
811
829
  concurrency,
812
830
  flow: options.flow ?? null,
813
831
  commitSha: gitContext.commitSha,
@@ -869,6 +887,13 @@ async function runOnBus(
869
887
  log.status(`[vx] cache save failed: ${err instanceof Error ? err.message : String(err)}`),
870
888
  )
871
889
  const deferredSaves = new Map<string, Promise<SaveFacts>>()
890
+ const windows = commandWindows()
891
+ // A server held from an earlier run (`vx watch`) runs through this one
892
+ // too, so it may write a mutable input while it is described.
893
+ for (const id of heldBefore.kept.keys()) {
894
+ const node = nodes.get(id)!
895
+ windows.open(id, node.projectDir, commandWriteReach(node, workspaceRoot))
896
+ }
872
897
 
873
898
  // Focused flow: a requested GROUP has no output of its own, so
874
899
  // surface the same-project, non-group tasks it chains (one level)
@@ -903,10 +928,31 @@ async function runOnBus(
903
928
  version: VERSION,
904
929
  packageCount: packagesInScope.size,
905
930
  remoteCacheEnabled,
931
+ cache: {
932
+ local: policy.localRead || policy.localWrite,
933
+ localWrite: policy.localWrite,
934
+ remote: remoteCacheEnabled && (policy.remoteRead || policy.remoteWrite),
935
+ uploads: prepared.localCache.uploads,
936
+ notDownloaded: () => deferredOutputs.pending().length,
937
+ },
906
938
  concurrency,
907
939
  workspaceProjectCount,
908
940
  }
909
941
 
942
+ // Asked only when a plugin declares `output`: a plain run pays nothing.
943
+ if (takeOutput !== undefined && prepared.plugins.some((p) => p.output !== undefined)) {
944
+ const json = options.json === true
945
+ const claimed = await claimOutput(prepared.plugins, {
946
+ workspaceRoot,
947
+ cacheDir,
948
+ warn: (m: string) => log.status(m),
949
+ isTTY: (json ? process.stderr : process.stdout).isTTY === true,
950
+ ci: ciContext.ci,
951
+ json,
952
+ })
953
+ if (claimed !== undefined) takeOutput(claimed)
954
+ }
955
+
910
956
  // Lifecycle hooks drive the default logger's dynamic status line
911
957
  // (TTY-only); custom loggers may ignore them.
912
958
  log.runStart?.({
@@ -921,7 +967,11 @@ async function runOnBus(
921
967
  terminal?.forecast(async () => {
922
968
  const ids: string[] = []
923
969
  for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
924
- const p50s = await new LocalHistoryProvider(prepared.localCache.dbHandle()).p50sFor(ids)
970
+ const p50s = await new LocalHistoryProvider(
971
+ prepared.localCache.dbHandle(),
972
+ undefined,
973
+ prepared.branch(),
974
+ ).p50sFor(ids)
925
975
  return createForecast(nodes, p50s, concurrency)
926
976
  })
927
977
 
@@ -938,7 +988,7 @@ async function runOnBus(
938
988
  nodes,
939
989
  cache,
940
990
  workspaceRoot,
941
- workspaceFingerprint,
991
+ claimedFiles: fingerprintWatch.names,
942
992
  forwardArgs: options.forwardArgs,
943
993
  nestedDirsByProject,
944
994
  gitFilesCache,
@@ -970,7 +1020,7 @@ async function runOnBus(
970
1020
  nodes,
971
1021
  cache,
972
1022
  workspaceRoot,
973
- workspaceFingerprint,
1023
+ claimedFiles: fingerprintWatch.names,
974
1024
  forwardArgs: options.forwardArgs,
975
1025
  nestedDirsByProject,
976
1026
  gitFilesCache,
@@ -1043,7 +1093,6 @@ async function runOnBus(
1043
1093
  node,
1044
1094
  upstream,
1045
1095
  workspaceRoot,
1046
- workspaceFingerprint,
1047
1096
  cache,
1048
1097
  cachePolicy: policy,
1049
1098
  forwardArgs: options.forwardArgs,
@@ -1063,12 +1112,15 @@ async function runOnBus(
1063
1112
  outputDirSnapshots,
1064
1113
  deferSave: saveLane.defer,
1065
1114
  deferredSaves,
1115
+ commandWindows: windows,
1066
1116
  stopSignal: stopRun.signal,
1067
1117
  failFast: failFast.signal,
1068
1118
  }
1069
1119
  if (options.retries !== undefined) a.retries = options.retries
1070
1120
  if (taskTimeoutDefault !== undefined) a.timeout = taskTimeoutDefault
1121
+ if (options.readyNotice !== undefined) a.readyNotice = options.readyNotice
1071
1122
  a.inputStability = inputStability
1123
+ if (cleanOutputs !== undefined) a.cleanOutputs = cleanOutputs
1072
1124
  if (download.modeOf.get(node.id) === 'deferred') a.download = 'deferred'
1073
1125
  if (placements.remoteOnlyNoop.has(node.id)) a.remoteOnlyNoop = true
1074
1126
  if (placements.remoteOnly.has(node.id)) a.remoteOnly = true
@@ -1078,6 +1130,7 @@ async function runOnBus(
1078
1130
  if (upfrontKey !== undefined) a.upfrontKey = upfrontKey
1079
1131
  if (upfrontGroupKey !== undefined) a.upfrontGroupKey = upfrontGroupKey
1080
1132
  if (tainted) a.taintedUpstream = true
1133
+ if (keepRunLog) a.keepRunLog = true
1081
1134
  if (!dependedOn.has(node.id)) a.noDependants = true
1082
1135
  if (holders.has(node.id)) a.terminal = true
1083
1136
  if (armSandbox !== undefined) a.armSandbox = armSandbox
@@ -1090,7 +1143,6 @@ async function runOnBus(
1090
1143
  shortCircuit,
1091
1144
  hashArgs: {
1092
1145
  workspaceRoot,
1093
- workspaceFingerprint,
1094
1146
  cache,
1095
1147
  forwardArgs: options.forwardArgs,
1096
1148
  nestedDirsByProject,
@@ -1148,6 +1200,7 @@ async function runOnBus(
1148
1200
  wallclockEndNs: at,
1149
1201
  }
1150
1202
  }
1203
+ let lockBroken: boolean | undefined
1151
1204
  const outcomes = await runGraph({
1152
1205
  nodes,
1153
1206
  concurrency,
@@ -1169,7 +1222,15 @@ async function runOnBus(
1169
1222
  log.taskComplete(o.node, o)
1170
1223
  narrowDemand(o.node.id)
1171
1224
  },
1172
- onError: (node, line) => log.taskStderr(node, line),
1225
+ // A crash a hand-broken lock entry caused is named as that; asked
1226
+ // only here, so a run that does not crash pays nothing.
1227
+ onError: (node, line, err) =>
1228
+ log.taskStderr(
1229
+ node,
1230
+ !isUserError(err) && !isFsRefusal(err) && (lockBroken ??= lockEntryBroken(workspaceRoot))
1231
+ ? `[vx] ${node.id}: ${LOCK_INVALID}\n`
1232
+ : line,
1233
+ ),
1173
1234
  execute: (node, upstream) => {
1174
1235
  const kept = keepServer(node)
1175
1236
  return kept !== undefined
@@ -1195,9 +1256,7 @@ async function runOnBus(
1195
1256
  // baseline as the tie-break. Empty map → baseline only.
1196
1257
  ...(priorities.size > 0 ? { priorities } : {}),
1197
1258
  // The baseline under them; `most-work` when the workspace names none.
1198
- ...(prepared.workspaceConfig?.schedule !== undefined
1199
- ? { strategy: prepared.workspaceConfig.schedule }
1200
- : {}),
1259
+ ...(options.schedule !== undefined ? { strategy: options.schedule } : {}),
1201
1260
  // Local short-circuit: confirmed stable local hits the scheduler
1202
1261
  // runs ahead of their deps as low-priority worker-slot backfill.
1203
1262
  // Empty when the short-circuit didn't fire → byte-identical.
@@ -1289,7 +1348,7 @@ async function runOnBus(
1289
1348
  const stillDeferred = deferredOutputs.pending()
1290
1349
  if (stillDeferred.length > 0) {
1291
1350
  log.status(
1292
- ` Deferred: ${stillDeferred.length} task(s) left outputs remote (--download=none): ${stillDeferred.join(', ')}`,
1351
+ ` Deferred: left outputs remote (--cache-download=none): ${stillDeferred.join(', ')}`,
1293
1352
  )
1294
1353
  }
1295
1354
 
@@ -1297,6 +1356,55 @@ async function runOnBus(
1297
1356
  // change the run's exit code — the run already happened.
1298
1357
  // Written again after the keep-alive wait: a kept server's crash or a
1299
1358
  // Ctrl-C there is the process's exit, and the first write said ok.
1359
+ // The run's own bytes, kept in a remote that takes its writes (owner
1360
+ // 2026-10-15): a failed task's log and the files its outputs left, an
1361
+ // uncached task's log. Then the report every plugin's teardown reads.
1362
+ const keepRunBytes = async (final: readonly TaskOutcome[]): Promise<RunReport> => {
1363
+ const keep = prepared.keepRunEntry
1364
+ const tasks: RunReportTask[] = []
1365
+ const work: (() => Promise<void>)[] = []
1366
+ for (const o of final) {
1367
+ if (isGroupTask(o.node)) continue
1368
+ const task: { -readonly [K in keyof RunReportTask]: RunReportTask[K] } = {
1369
+ id: o.node.id,
1370
+ status: o.status,
1371
+ durationMs: Math.round(o.durationMs),
1372
+ ...(o.hash !== undefined ? { key: o.hash } : {}),
1373
+ }
1374
+ tasks.push(task)
1375
+ if (keep === undefined) continue
1376
+ const runLog = o.runLog
1377
+ if (runLog !== undefined) {
1378
+ work.push(async () => {
1379
+ const text = decodeOutputLog(runLog)
1380
+ .map((c) => c.text)
1381
+ .join('')
1382
+ if (text.length === 0) return
1383
+ const name = runEntryName(runId, o.node.id, 'log')
1384
+ if (await keep(name, new TextEncoder().encode(text))) task.log = name
1385
+ })
1386
+ }
1387
+ if (o.status === 'failed' && o.where === undefined) {
1388
+ work.push(async () => {
1389
+ const bytes = await packRunOutputs(
1390
+ prepared.workspaceRoot,
1391
+ await outputsLeft(
1392
+ o.node,
1393
+ prepared.workspaceRoot,
1394
+ prepared.nestedDirsByProject.get(o.node.projectName) ?? [],
1395
+ ),
1396
+ )
1397
+ if (bytes === null) return
1398
+ const name = runEntryName(runId, o.node.id, 'outputs')
1399
+ if (await keep(name, bytes)) task.outputs = name
1400
+ })
1401
+ }
1402
+ }
1403
+ for (let i = 0; i < work.length; i += 8) {
1404
+ await Promise.all(work.slice(i, i + 8).map((w) => w()))
1405
+ }
1406
+ return { runId, commit: head.commitSha, ciRun: ciContext, ok, tasks }
1407
+ }
1300
1408
  let json: RunSummaryJson | undefined
1301
1409
  const summarize = async (runOk: boolean, final = list): Promise<void> => {
1302
1410
  if (options.json === true) {
@@ -1370,7 +1478,7 @@ async function runOnBus(
1370
1478
  // own output masks it, so cache.db holds no plaintext value.
1371
1479
  command: maskInvocation(options.command ?? process.argv.slice(1).join(' ')),
1372
1480
  requestedTasks: options.tasks,
1373
- cachePolicy: compactCachePolicy(policy),
1481
+ cachePolicy: formatCachePolicy(policy),
1374
1482
  concurrency,
1375
1483
  flow: options.flow ?? null,
1376
1484
  forwardArgs: options.forwardArgs,
@@ -1435,6 +1543,7 @@ async function runOnBus(
1435
1543
  // drain of the lane stood here until item 634; deleting it changed
1436
1544
  // nothing the suite could see, because this gate is the rule.
1437
1545
  await cache.drainUploads?.()
1546
+ const report = await keepRunBytes(list)
1438
1547
  // Hand the per-run summary to the telemetry sinks + drain them. Only
1439
1548
  // when a sink is active (telemetry !== undefined) — otherwise this
1440
1549
  // whole block is skipped and the run is byte-identical to before.
@@ -1493,7 +1602,7 @@ async function runOnBus(
1493
1602
  // whole keep-alive wait below (C-66). Idempotent; the finally's stay.
1494
1603
  disposePlugins?.()
1495
1604
  telemetry?.dispose()
1496
- await teardown()
1605
+ await teardown(undefined, report)
1497
1606
  await closeCache()
1498
1607
  mark('close')
1499
1608
  printTimings()
@@ -1519,7 +1628,8 @@ async function runOnBus(
1519
1628
  if (options.summaryTable === true) for (const line of formatOutcomeTable(list)) log.status(line)
1520
1629
  const above = options.beforeFooter?.(list, ok)
1521
1630
  if (above) log.status(above.replace(/\n$/, ''))
1522
- for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
1631
+ if (options.footer !== false)
1632
+ for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
1523
1633
 
1524
1634
  // Edge case the summary already reported: the user requested a
1525
1635
  // persistent task (dev server / watcher). The run is "done" in every
@@ -1665,18 +1775,16 @@ async function runOnBus(
1665
1775
  }
1666
1776
 
1667
1777
  /**
1668
- * The workspace's `cacheRetention`, after every save and upload of this run
1778
+ * `cache.retention`, after every save and upload of this run
1669
1779
  * has landed (so nothing this run wrote is mid-flight) and before the cache
1670
1780
  * closes. Housekeeping, not the run's work: silent, and a failure is never
1671
1781
  * a failed run. Declared nowhere → one property read.
1672
1782
  */
1673
1783
  async function applyCacheRetention(prepared: PreparedRun): Promise<void> {
1674
- const retention = prepared.workspaceConfig?.cacheRetention
1675
- if (retention === undefined) return
1676
- // Validated at load (`validateRetention`), so both parse.
1677
- const maxAgeMs =
1678
- retention.olderThan === undefined ? undefined : parseDuration(retention.olderThan)!
1679
- const maxBytes = retention.maxSize === undefined ? undefined : parseSize(retention.maxSize)!
1784
+ const ws = prepared.workspaceConfig
1785
+ const maxAgeMs = resolveSetting('cache.retention.olderThan', ws).value as number | undefined
1786
+ const maxBytes = resolveSetting('cache.retention.maxSize', ws).value as number | undefined
1787
+ if (maxAgeMs === undefined && maxBytes === undefined) return
1680
1788
  try {
1681
1789
  await prepared.localCache.evictIfDue({
1682
1790
  ...(maxAgeMs !== undefined ? { maxAgeMs } : {}),
@@ -1721,12 +1829,13 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1721
1829
  const log = options.log ?? defaultLogger(undefined, undefined, process.stderr)
1722
1830
  const reasons = new Map<string, AffectedReason>()
1723
1831
  const prepared = await prepareRun({ ...options, affectedReasons: reasons }, log)
1832
+ options = withRunSettings(options, prepared.workspaceConfig)
1724
1833
  try {
1725
1834
  if (prepared.unresolvedTasks.length > 0) {
1726
1835
  return {
1727
1836
  tasks: [],
1728
1837
  unresolvedTasks: prepared.unresolvedTasks,
1729
- unresolvedHint: `${elsewhereHint(prepared.declaredElsewhere)}${didYouMean(prepared.unresolvedTasks, prepared.hintProjects, prepared.declaredElsewhere)}${await initHint(prepared)}`,
1838
+ unresolvedHint: await unresolvedHint(prepared),
1730
1839
  }
1731
1840
  }
1732
1841
  if (prepared.empty === 'no-tasks-declared' && prepared.declaredElsewhere.length > 0) {
@@ -1748,7 +1857,6 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1748
1857
  const planned = await plan({
1749
1858
  nodes: prepared.nodes,
1750
1859
  workspaceRoot: prepared.workspaceRoot,
1751
- workspaceFingerprint: prepared.workspaceFingerprint,
1752
1860
  cache: prepared.cache,
1753
1861
  cachePolicy: policy,
1754
1862
  forwardArgs: options.forwardArgs,
@@ -1760,7 +1868,11 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1760
1868
  // per-task p50s + a predicted wall-clock. `--dry` is an explicit
1761
1869
  // inspection command, so the history read's cost is fine here even
1762
1870
  // though it stays OFF the default run path.
1763
- history: new LocalHistoryProvider(prepared.localCache.dbHandle()),
1871
+ history: new LocalHistoryProvider(
1872
+ prepared.localCache.dbHandle(),
1873
+ undefined,
1874
+ prepared.branch(),
1875
+ ),
1764
1876
  // Placement, by the same rules the run applies. Only worth showing
1765
1877
  // when there is a choice to show — with one declared executor every
1766
1878
  // line would carry the same label. Resolving the executors here is
@@ -1810,7 +1922,7 @@ async function planRestorable(
1810
1922
  nodes,
1811
1923
  cache: prepared.cache,
1812
1924
  workspaceRoot: prepared.workspaceRoot,
1813
- workspaceFingerprint: prepared.workspaceFingerprint,
1925
+ claimedFiles: prepared.fingerprintWatch.names,
1814
1926
  forwardArgs,
1815
1927
  nestedDirsByProject: prepared.nestedDirsByProject,
1816
1928
  gitFilesCache: prepared.gitFilesCache,
@@ -1828,25 +1940,38 @@ function elsewhereHint(names: readonly string[]): string {
1828
1940
  }
1829
1941
 
1830
1942
  /**
1831
- * The first-run case, told apart from a typo: no package in the workspace
1832
- * has a `vx.config.*` at all, so no name could have resolved. Reached only
1833
- * on the error path. A workspace whose package globs match NOTHING is
1834
- * told that instead — `vx init` would find no package to write for.
1943
+ * What follows "no projects declare task(s): …": where the name is, its
1944
+ * nearest spelling, and, with neither, the lock a run reads its configs
1945
+ * from: a task just added to a vx.config is not in it until `vx lock`.
1835
1946
  */
1836
- async function initHint(prepared: {
1947
+ async function unresolvedHint(prepared: {
1948
+ unresolvedTasks: readonly string[]
1949
+ hintProjects: ReadonlyMap<string, ProjectEntry>
1950
+ declaredElsewhere: readonly string[]
1837
1951
  anyProjectConfig: boolean
1838
- workspaceProjectCount: number
1839
- workspaceRoot: string
1840
1952
  }): Promise<string> {
1953
+ const elsewhere = elsewhereHint(prepared.declaredElsewhere)
1954
+ const near = didYouMean(
1955
+ prepared.unresolvedTasks,
1956
+ prepared.hintProjects,
1957
+ prepared.declaredElsewhere,
1958
+ )
1959
+ const init = await initHint(prepared)
1960
+ const lock =
1961
+ init === '' && elsewhere === '' && !near.startsWith(' Did you mean')
1962
+ ? ' A run reads the lock: after editing a vx.config, run `vx lock`.'
1963
+ : ''
1964
+ return `${elsewhere}${near}${init}${lock}`
1965
+ }
1966
+
1967
+ /**
1968
+ * The first-run case, told apart from a typo: no project in the workspace
1969
+ * has a `vx.config.*` at all, so no name could have resolved. Reached only
1970
+ * on the error path.
1971
+ */
1972
+ async function initHint(prepared: { anyProjectConfig: boolean }): Promise<string> {
1841
1973
  if (prepared.anyProjectConfig) return ''
1842
- if (prepared.workspaceProjectCount === 0) {
1843
- return " No package matched the workspace's package globs — check `workspaces` in package.json (or pnpm-workspace.yaml)."
1844
- }
1845
- // Single-project mode with packages the root's missing `workspaces`
1846
- // never reaches: `vx init` would find no scripts either (item 248).
1847
- const unreached = await unreachedPackages(await loadWorkspace(prepared.workspaceRoot))
1848
- if (unreached.length > 0) return ` ${unreachedHint(unreached, prepared.workspaceRoot)}`
1849
- return ' No package declares a vx.config — run `vx init` to write one per package from its package.json scripts.'
1974
+ return ' No directory holds a vx.config — run `vx init` to write them, then `vx lock`.'
1850
1975
  }
1851
1976
 
1852
1977
  /**
@@ -1892,7 +2017,7 @@ function didYouMean(
1892
2017
  // `//` is two edits from any two-letter name: it hinted `ui#build` (X-13).
1893
2018
  if (proj === '//') {
1894
2019
  lists.add(
1895
- "`//` is Turbo's root package, but the workspace root is no project here: give it a package.json name and a vx.config with that task.",
2020
+ '`//` is the workspace root, which is no project here: add a vx.config there with that task and run `vx lock`.',
1896
2021
  )
1897
2022
  continue
1898
2023
  }