@vzn/vx 0.0.638 → 0.0.640

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 (134) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +152 -95
  3. package/docs/cli.md +225 -372
  4. package/docs/execution.md +22 -47
  5. package/docs/features.md +14 -16
  6. package/docs/patterns.md +6 -8
  7. package/docs/schema.md +268 -215
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/last.json +7 -4
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +10 -7
  13. package/src/cache/cache.ts +44 -11
  14. package/src/cache/file-hashes.ts +7 -12
  15. package/src/cache/fs-clock.ts +62 -0
  16. package/src/cache/git-inputs.ts +8 -2
  17. package/src/cache/index.ts +5 -2
  18. package/src/cache/inputs.ts +154 -83
  19. package/src/cache/key-fold.ts +23 -3
  20. package/src/cache/layer.ts +33 -18
  21. package/src/cache/layered-cache.ts +108 -315
  22. package/src/cache/linked-inputs.ts +87 -0
  23. package/src/cache/plugin-cache.ts +2 -10
  24. package/src/cache/remote-lane.ts +383 -0
  25. package/src/cache/run-history.ts +24 -8
  26. package/src/cache/run-store.ts +4 -4
  27. package/src/cache/schema.ts +6 -4
  28. package/src/cache/scoped-cache.ts +7 -0
  29. package/src/cli/completions.ts +2 -2
  30. package/src/cli/foreign-flags.ts +11 -23
  31. package/src/cli/help.ts +6 -31
  32. package/src/cli/info.ts +2 -1
  33. package/src/cli/init.ts +23 -11
  34. package/src/cli/last.ts +30 -4
  35. package/src/cli/plan-format.ts +5 -58
  36. package/src/cli/plugin-commands.ts +2 -2
  37. package/src/cli/plugin-templates.ts +2 -2
  38. package/src/cli/run.ts +23 -185
  39. package/src/cli/select.ts +16 -6
  40. package/src/cli/show.ts +15 -6
  41. package/src/cli/upgrade.ts +215 -55
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +22 -6
  44. package/src/cli/watch-judge.ts +49 -5
  45. package/src/cli/watch-set.ts +18 -8
  46. package/src/cli/watch.ts +103 -16
  47. package/src/cli/why.ts +9 -7
  48. package/src/config.ts +19 -21
  49. package/src/exec/executor.ts +5 -5
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/dependency-spec.ts +2 -2
  53. package/src/graph/folds.ts +236 -0
  54. package/src/graph/index.ts +17 -2
  55. package/src/graph/priorities.ts +1 -1
  56. package/src/graph/scheduler.ts +45 -31
  57. package/src/graph/task-graph.ts +190 -58
  58. package/src/graph/task-status.ts +95 -0
  59. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  60. package/src/{orchestrator → history}/history.ts +1 -1
  61. package/src/history/index.ts +48 -0
  62. package/src/{orchestrator → history}/metrics.ts +7 -20
  63. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  64. package/src/index.ts +11 -29
  65. package/src/orchestrator/affected-tasks.ts +20 -19
  66. package/src/orchestrator/doctor.ts +1 -1
  67. package/src/orchestrator/download-policy.ts +16 -22
  68. package/src/orchestrator/excluded-keys.ts +4 -5
  69. package/src/orchestrator/execute-args.ts +80 -0
  70. package/src/orchestrator/execute-task.ts +50 -411
  71. package/src/orchestrator/fingerprint-watch.ts +2 -2
  72. package/src/orchestrator/index.ts +2 -73
  73. package/src/orchestrator/keyed-projects.ts +12 -51
  74. package/src/orchestrator/local-shortcircuit.ts +1 -1
  75. package/src/orchestrator/miss-save.ts +33 -3
  76. package/src/orchestrator/options.ts +1 -36
  77. package/src/orchestrator/output-host.ts +4 -3
  78. package/src/orchestrator/persistent-task.ts +321 -0
  79. package/src/orchestrator/persistent.ts +130 -3
  80. package/src/orchestrator/placement.ts +5 -5
  81. package/src/orchestrator/plan.ts +19 -6
  82. package/src/orchestrator/plugin-host.ts +129 -69
  83. package/src/orchestrator/plugin.ts +18 -8
  84. package/src/orchestrator/prepare.ts +40 -34
  85. package/src/orchestrator/projects.ts +7 -10
  86. package/src/orchestrator/remote-prefetch.ts +14 -1
  87. package/src/orchestrator/run-artifacts.ts +5 -177
  88. package/src/orchestrator/run-context.ts +55 -0
  89. package/src/orchestrator/run-failures.ts +15 -3
  90. package/src/orchestrator/run-records.ts +35 -3
  91. package/src/orchestrator/run-report.ts +151 -171
  92. package/src/orchestrator/run-settings.ts +1 -16
  93. package/src/orchestrator/run-steps.ts +57 -0
  94. package/src/orchestrator/run-stop.ts +153 -0
  95. package/src/orchestrator/run.ts +293 -604
  96. package/src/orchestrator/sandbox-fix.ts +1 -17
  97. package/src/orchestrator/sandbox-request.ts +2 -2
  98. package/src/orchestrator/signals.ts +14 -6
  99. package/src/orchestrator/stable-keys.ts +164 -27
  100. package/src/orchestrator/steps.ts +70 -0
  101. package/src/orchestrator/task-hash.ts +34 -25
  102. package/src/orchestrator/task-log-buffer.ts +1 -1
  103. package/src/orchestrator/task-process.ts +94 -0
  104. package/src/orchestrator/telemetry-host.ts +4 -4
  105. package/src/orchestrator/telemetry.ts +14 -86
  106. package/src/orchestrator/upstream.ts +4 -181
  107. package/src/{orchestrator → report}/events.ts +41 -8
  108. package/src/{orchestrator → report}/forecast.ts +35 -2
  109. package/src/{orchestrator → report}/framed-output.ts +40 -34
  110. package/src/report/index.ts +36 -0
  111. package/src/{orchestrator → report}/logger.ts +158 -20
  112. package/src/{orchestrator → report}/status-line.ts +88 -7
  113. package/src/{orchestrator → report}/summary.ts +126 -35
  114. package/src/report/tally.ts +83 -0
  115. package/src/settings.ts +12 -39
  116. package/src/util/colors.ts +44 -4
  117. package/src/util/git-ignored.ts +143 -0
  118. package/src/util/index.ts +19 -4
  119. package/src/util/paths.ts +1 -1
  120. package/src/util/report.ts +13 -5
  121. package/src/util/settle.ts +14 -39
  122. package/src/util/tail.ts +1 -1
  123. package/src/util/ui.ts +17 -6
  124. package/src/workspace/affected.ts +81 -2
  125. package/src/workspace/config-imports.ts +1 -1
  126. package/src/workspace/config-schema.ts +142 -143
  127. package/src/workspace/lockfile.ts +1 -1
  128. package/src/workspace/migration.ts +19 -3
  129. package/schemas/summary.json +0 -133
  130. package/src/cache/chained-cache.ts +0 -280
  131. package/src/orchestrator/tally.ts +0 -112
  132. /package/src/{orchestrator → report}/path-links.ts +0 -0
  133. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  134. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -10,6 +10,8 @@
10
10
  // package manager (npm, pnpm, bun, yarn; global or a project's
11
11
  // dependency) is upgraded by that package manager, never by a rename over
12
12
  // a file it owns. vx never installs Node or a package manager.
13
+ // Handed a call by a global vx, the project's vx moves that global too,
14
+ // to the same version (`upgradeGlobal`).
13
15
  // Named `upgrade` (not `update`) per CLI convention: bun upgrade,
14
16
  // deno upgrade — "update" is what package managers do to indexes.
15
17
 
@@ -110,13 +112,18 @@ export function versionSpec(tag: string | undefined): string {
110
112
  * as an internal error with a stack (a network namespace without a
111
113
  * route, 2026-09-16). One line, the host and the reason.
112
114
  */
113
- async function fetchOrRefuse(url: string, init: RequestInit, what: string): Promise<Response> {
115
+ async function fetchOrRefuse(
116
+ url: string,
117
+ init: RequestInit,
118
+ what: string,
119
+ verb = 'vx upgrade',
120
+ ): Promise<Response> {
114
121
  try {
115
122
  return await fetch(url, init)
116
123
  } catch (err) {
117
124
  const message = err instanceof Error ? err.message : String(err)
118
125
  throw new UserError(
119
- `vx upgrade: could not reach ${new URL(url).host} to ${what} (${message}) — check the network or the proxy and re-run`,
126
+ `${verb}: could not reach ${new URL(url).host} to ${what} (${message}) — check the network or the proxy and re-run`,
120
127
  )
121
128
  }
122
129
  }
@@ -128,33 +135,43 @@ async function fetchOrRefuse(url: string, init: RequestInit, what: string): Prom
128
135
  * connection was closed unexpectedly"), which `fetchOrRefuse` never sees
129
136
  * and the CLI printed as an internal error with a stack.
130
137
  */
131
- async function readOrRefuse<T>(read: () => Promise<T>, url: string, what: string): Promise<T> {
138
+ async function readOrRefuse<T>(
139
+ read: () => Promise<T>,
140
+ url: string,
141
+ what: string,
142
+ verb = 'vx upgrade',
143
+ ): Promise<T> {
132
144
  try {
133
145
  return await read()
134
146
  } catch (err) {
135
147
  const message = err instanceof Error ? err.message : String(err)
136
148
  throw new UserError(
137
- `vx upgrade: could not ${what} from ${new URL(url).host} (${message}) — nothing replaced; check the network or the proxy and re-run`,
149
+ `${verb}: could not ${what} from ${new URL(url).host} (${message}) — nothing replaced; check the network or the proxy and re-run`,
138
150
  )
139
151
  }
140
152
  }
141
153
 
142
154
  /** The registry's document for `pkg@spec` (`latest` or a version). */
143
- export async function fetchManifest(pkg: string, spec: string): Promise<unknown> {
155
+ export async function fetchManifest(
156
+ pkg: string,
157
+ spec: string,
158
+ verb = 'vx upgrade',
159
+ ): Promise<unknown> {
144
160
  const url = `${REGISTRY}/${pkg}/${spec}`
145
161
  const res = await fetchOrRefuse(
146
162
  url,
147
163
  { redirect: 'follow', headers: { Accept: 'application/json', 'User-Agent': `vx/${VERSION}` } },
148
164
  'read the release',
165
+ verb,
149
166
  )
150
167
  if (!res.ok) {
151
168
  throw new UserError(
152
- `vx upgrade: could not read ${pkg}@${spec} from the npm registry (${res.status})${res.status === 404 && spec !== 'latest' ? ` — is ${spec} a published version?` : ''}`,
169
+ `${verb}: could not read ${pkg}@${spec} from the npm registry (${res.status})${res.status === 404 && spec !== 'latest' ? ` — is ${spec} a published version?` : ''}`,
153
170
  )
154
171
  }
155
172
  // A body that is not JSON (a captive portal's page, a proxy's error
156
173
  // page served with a 200) is the same refusal as a cut one.
157
- return await readOrRefuse(() => res.json(), url, 'read the release')
174
+ return await readOrRefuse(() => res.json(), url, 'read the release', verb)
158
175
  }
159
176
 
160
177
  /** What a verified registry document names: the version, its tarball and the signed integrity. */
@@ -290,17 +307,19 @@ export function binaryFromTarball(tgz: Uint8Array): Uint8Array {
290
307
  /**
291
308
  * The binary of `pkg@spec`, verified: the registry document's signature,
292
309
  * then the tarball's integrity, then the extraction, each refusing before
293
- * the next step runs. Null when the release is this vx's own version;
294
- * `announce` hears the version before the download starts.
310
+ * the next step runs. Null when the release is `current`, the version of
311
+ * the vx being replaced; `announce` hears the version before the download
312
+ * starts.
295
313
  */
296
314
  export async function releaseBinary(
297
315
  pkg: string,
298
316
  spec: string,
299
317
  announce: (version: string) => void,
300
318
  key: SigningKey = NPM_SIGNING_KEY,
319
+ current = VERSION,
301
320
  ): Promise<Uint8Array | null> {
302
321
  const release = verifiedRelease(await fetchManifest(pkg, spec), pkg, key)
303
- if (release.version === VERSION) return null
322
+ if (release.version === current) return null
304
323
  announce(release.version)
305
324
  return binaryFromTarball(await downloadVerified(release))
306
325
  }
@@ -391,6 +410,8 @@ export interface ManagedInstall {
391
410
  cwd: string
392
411
  /** The project field that lists `@vzn/vx`. */
393
412
  field?: 'dependencies' | 'devDependencies' | 'optionalDependencies'
413
+ /** The `@vzn/vx-*` plugins the same package.json lists from the registry, moved with core. */
414
+ plugins?: string[]
394
415
  }
395
416
 
396
417
  const FIELDS = ['devDependencies', 'dependencies', 'optionalDependencies'] as const
@@ -413,6 +434,45 @@ function listingField(dir: string): ManagedInstall['field'] | undefined {
413
434
  })
414
435
  }
415
436
 
437
+ /** A release's platform binary, which the launcher pulls in itself: no plugin. */
438
+ const PLATFORM = /^@vzn\/vx-(?:darwin|linux)-(?:x64|arm64)$/
439
+
440
+ /**
441
+ * The `@vzn/vx-*` plugins `dir`'s package.json lists from the registry
442
+ * (a `workspace:` or `file:` link is the repo's own). Every @vzn package
443
+ * releases together, so they move with core in its one install (M,
444
+ * 2026-10-16); one in another field would move fields under core's flag,
445
+ * so it is refused.
446
+ */
447
+ function pluginsBeside(dir: string, field: NonNullable<ManagedInstall['field']>): string[] {
448
+ const pkg = readJson(path.join(dir, 'package.json'))
449
+ const plugins: string[] = []
450
+ for (const f of FIELDS) {
451
+ const deps = pkg?.[f]
452
+ if (typeof deps !== 'object' || deps === null) continue
453
+ for (const [name, spec] of Object.entries(deps as Record<string, unknown>)) {
454
+ if (!name.startsWith(`${PACKAGE}-`) || PLATFORM.test(name)) continue
455
+ if (typeof spec !== 'string' || /^(?:workspace|file|link|portal):/.test(spec)) continue
456
+ if (f !== field) {
457
+ throw new UserError(
458
+ `vx upgrade: ${name} is in ${f} and ${PACKAGE} in ${field}; list them in one field so one install moves them together`,
459
+ )
460
+ }
461
+ plugins.push(name)
462
+ }
463
+ }
464
+ return plugins.sort()
465
+ }
466
+
467
+ /** The version of `pkg` installed where `dir` resolves it, if any. */
468
+ function installedVersion(dir: string, pkg: string): string | undefined {
469
+ for (let d = dir; ; d = path.dirname(d)) {
470
+ const v = readJson(path.join(d, 'node_modules', pkg, 'package.json'))?.['version']
471
+ if (typeof v === 'string') return v
472
+ if (path.dirname(d) === d) return undefined
473
+ }
474
+ }
475
+
416
476
  /** The project's package manager: its lockfile, then `packageManager`, then the tree's shape. */
417
477
  function projectManager(dir: string, execPath: string): ManagedInstall['manager'] {
418
478
  for (let d = dir; ; d = path.dirname(d)) {
@@ -459,12 +519,15 @@ export function managedInstall(execPath: string, cwd: string): ManagedInstall |
459
519
  if (/\/yarn\/global\//.test(p)) return { manager: 'yarn', scope: 'global', cwd: root }
460
520
  const field = listingField(root)
461
521
  if (field !== undefined) {
462
- return { manager: projectManager(root, execPath), scope: 'project', cwd: root, field }
522
+ const plugins = pluginsBeside(root, field)
523
+ return { manager: projectManager(root, execPath), scope: 'project', cwd: root, field, plugins }
463
524
  }
464
525
  for (let d = path.resolve(cwd); d.startsWith(root); d = path.dirname(d)) {
465
526
  const f = listingField(d)
466
- if (f !== undefined)
467
- return { manager: projectManager(d, execPath), scope: 'project', cwd: d, field: f }
527
+ if (f !== undefined) {
528
+ const plugins = pluginsBeside(d, f)
529
+ return { manager: projectManager(d, execPath), scope: 'project', cwd: d, field: f, plugins }
530
+ }
468
531
  if (path.dirname(d) === d) break
469
532
  }
470
533
  // npm's global tree: `<prefix>/lib/node_modules` (nvm, volta, Homebrew alike).
@@ -499,7 +562,8 @@ export function upgradeArgv(install: ManagedInstall, version: string): string[]
499
562
  bun: { devDependencies: '--dev', dependencies: '', optionalDependencies: '--optional' },
500
563
  yarn: { devDependencies: '--dev', dependencies: '', optionalDependencies: '--optional' },
501
564
  }[manager][field]
502
- const argv = manager === 'npm' ? ['npm', 'install', spec] : [manager, 'add', spec]
565
+ const specs = [spec, ...(install.plugins ?? []).map((p) => `${p}@${version}`)]
566
+ const argv = manager === 'npm' ? ['npm', 'install', ...specs] : [manager, 'add', ...specs]
503
567
  if (flag !== '') argv.push(flag)
504
568
  // pnpm refuses an `add` at a workspace's root without saying it means the root.
505
569
  if (manager === 'pnpm' && existsSync(path.join(install.cwd, 'pnpm-workspace.yaml')))
@@ -507,26 +571,40 @@ export function upgradeArgv(install: ManagedInstall, version: string): string[]
507
571
  return argv
508
572
  }
509
573
 
510
- /** The version `tag` names, or the registry's latest, read for the "already at" check. */
511
- async function targetVersion(tag: string | undefined): Promise<string> {
574
+ /**
575
+ * The version `tag` names, or the registry's latest: read for the "already
576
+ * at" check, and by `vx init`, which always installs the latest release.
577
+ */
578
+ export async function targetVersion(tag: string | undefined, verb = 'vx upgrade'): Promise<string> {
512
579
  const spec = versionSpec(tag)
513
580
  if (spec !== 'latest') return spec
514
- const v = ((await fetchManifest(PACKAGE, 'latest')) as { version?: unknown } | null)?.version
581
+ const v = ((await fetchManifest(PACKAGE, 'latest', verb)) as { version?: unknown } | null)
582
+ ?.version
515
583
  if (typeof v !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(v)) {
516
- throw new UserError(`vx upgrade: the npm registry named no latest version of ${PACKAGE}`)
584
+ throw new UserError(`${verb}: the npm registry named no latest version of ${PACKAGE}`)
517
585
  }
518
586
  return v
519
587
  }
520
588
 
521
- /** Run the package manager that owns this vx; vx never installs one. */
522
- async function upgradeManaged(install: ManagedInstall, tag: string | undefined): Promise<number> {
523
- const version = await targetVersion(tag)
524
- if (version === VERSION) {
525
- ui.print('out', ui.status('info', 'vx upgrade', `already at ${VERSION}`))
526
- return 0
589
+ /**
590
+ * Run the package manager that owns a vx at `current` to bring it to
591
+ * `version`; vx never installs one. `step` names the install when a run
592
+ * moves two (`project `, `global `).
593
+ */
594
+ export async function upgradeManaged(
595
+ install: ManagedInstall,
596
+ version: string,
597
+ current = VERSION,
598
+ step = '',
599
+ ): Promise<void> {
600
+ const plugins = install.plugins ?? []
601
+ if (version === current && plugins.every((p) => installedVersion(install.cwd, p) === version)) {
602
+ ui.print('out', ui.status('info', 'vx upgrade', `${step}already at ${current}`))
603
+ return
527
604
  }
528
605
  const argv = upgradeArgv(install, version)
529
- const bin = Bun.which(argv[0]!)
606
+ // PATH as it is now: `Bun.which` alone reads the one the process started with.
607
+ const bin = Bun.which(argv[0]!, { PATH: process.env['PATH'] ?? '' })
530
608
  if (bin === null) {
531
609
  throw new UserError(
532
610
  `vx upgrade: this vx was installed by ${argv[0]}, which is not on PATH — vx does not install package managers; run it yourself: ${argv.join(' ')} (in ${install.cwd})`,
@@ -534,7 +612,11 @@ async function upgradeManaged(install: ManagedInstall, tag: string | undefined):
534
612
  }
535
613
  ui.print(
536
614
  'out',
537
- ui.status('info', 'vx upgrade', `${VERSION} → ${version}`),
615
+ ui.status(
616
+ 'info',
617
+ 'vx upgrade',
618
+ `${step}${version === current ? 'plugins' : current} → ${version}`,
619
+ ),
538
620
  ui.text([ui.cmd(argv.join(' ')), ui.dim(` in ${install.cwd}`)]),
539
621
  )
540
622
  const proc = Bun.spawnSync({
@@ -544,10 +626,100 @@ async function upgradeManaged(install: ManagedInstall, tag: string | undefined):
544
626
  })
545
627
  if (proc.exitCode !== 0) {
546
628
  throw new UserError(
547
- `vx upgrade: ${argv.join(' ')} failed (exit ${proc.exitCode}) — vx changed nothing itself`,
629
+ `vx upgrade: ${argv.join(' ')} failed (exit ${proc.exitCode}) — vx changed nothing itself${
630
+ // The install is pinned, so pnpm refuses a release younger than
631
+ // its minimumReleaseAge instead of mixing releases; its words
632
+ // print above, so the way out is named (as vx-migrate does).
633
+ install.manager === 'pnpm'
634
+ ? `; if pnpm refused a version published within its minimumReleaseAge, list ${[PACKAGE, ...plugins].join(', ')} under minimumReleaseAgeExclude in pnpm-workspace.yaml`
635
+ : ''
636
+ }`,
548
637
  )
549
638
  }
550
- return 0
639
+ }
640
+
641
+ /**
642
+ * Replace the binary at `dest`, at `current`, with the verified release
643
+ * `spec` names (`latest` or a version). `step` as in `upgradeManaged`.
644
+ */
645
+ async function upgradeBinary(
646
+ dest: string,
647
+ spec: string,
648
+ current = VERSION,
649
+ step = '',
650
+ key: SigningKey = NPM_SIGNING_KEY,
651
+ ): Promise<void> {
652
+ const pkg = platformPackage()
653
+ let installed: string | undefined
654
+ const replaced = await replaceBinary(
655
+ dest,
656
+ () =>
657
+ releaseBinary(
658
+ pkg,
659
+ spec,
660
+ (version) =>
661
+ ui.print(
662
+ 'out',
663
+ ui.status('info', 'vx upgrade', `${step}${current} → ${version}`),
664
+ ui.text(ui.dim(dest)),
665
+ ),
666
+ key,
667
+ current,
668
+ ),
669
+ // The replaced binary's own version: the new build speaks for itself
670
+ // rather than this process guessing, and one that cannot is rolled back.
671
+ (bin) => {
672
+ installed = answeredVersion(bin)
673
+ return installed !== undefined
674
+ },
675
+ )
676
+ ui.print(
677
+ 'out',
678
+ replaced
679
+ ? ui.status('ok', 'vx upgrade', `${step}installed ${installed}`)
680
+ : ui.status('info', 'vx upgrade', `${step}already at ${current}`),
681
+ )
682
+ }
683
+
684
+ /** The version `bin --version` answers, without the hand-off note a handed-off run adds. */
685
+ function answeredVersion(bin: string): string | undefined {
686
+ return startedVersion(bin)?.split(' ')[1]
687
+ }
688
+
689
+ /**
690
+ * Bring `from`, the global vx that handed this run to the project's own
691
+ * (`VX_HANDED_OFF`), to `version`, the way it was installed: a binary
692
+ * installed by hand is replaced, a package manager's global install is
693
+ * updated by that manager. A refusal names it.
694
+ */
695
+ export async function upgradeGlobal(
696
+ from: string,
697
+ version: string,
698
+ key: SigningKey = NPM_SIGNING_KEY,
699
+ ): Promise<void> {
700
+ await named(`global vx ${from}`, async () => {
701
+ const current = answeredVersion(from)
702
+ if (current === undefined) {
703
+ throw new UserError('vx upgrade: it did not answer `--version`, so it is left as it is')
704
+ }
705
+ if (current === version) {
706
+ ui.print('out', ui.status('info', 'vx upgrade', `global already at ${current}`))
707
+ return
708
+ }
709
+ const install = managedInstall(from, path.dirname(from))
710
+ if (install === null) await upgradeBinary(from, version, current, 'global ', key)
711
+ else await upgradeManaged(install, version, current, 'global ')
712
+ })
713
+ }
714
+
715
+ /** `run`, its refusal naming `what` after the verb: a run that moves two installs says which failed. */
716
+ async function named(what: string, run: () => Promise<void>): Promise<void> {
717
+ try {
718
+ await run()
719
+ } catch (err) {
720
+ if (!(err instanceof UserError)) throw err
721
+ throw new UserError(err.message.replace(/^vx upgrade: /, `vx upgrade: ${what}: `), err.code)
722
+ }
551
723
  }
552
724
 
553
725
  export async function upgradeCmd(args: readonly string[]): Promise<number> {
@@ -575,33 +747,21 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
575
747
  }
576
748
  const dest = process.execPath
577
749
  const install = managedInstall(dest, process.cwd())
578
- if (install !== null) return await upgradeManaged(install, tag)
579
- const pkg = platformPackage()
580
- const spec = versionSpec(tag)
581
- let installed = ''
582
- const replaced = await replaceBinary(
583
- dest,
584
- () =>
585
- releaseBinary(pkg, spec, (version) =>
586
- ui.print(
587
- 'out',
588
- ui.status('info', 'vx upgrade', `${VERSION} → ${version}`),
589
- ui.text(ui.dim(dest)),
590
- ),
591
- ),
592
- // The replaced binary's own version: the new build speaks for itself
593
- // rather than this process guessing, and one that cannot is rolled back.
594
- (bin) => {
595
- installed = startedVersion(bin) ?? ''
596
- return installed !== ''
597
- },
598
- )
599
- ui.print(
600
- 'out',
601
- replaced
602
- ? ui.status('ok', 'vx upgrade', `installed ${installed.replace(/^vx /, '')}`)
603
- : ui.status('info', 'vx upgrade', `already at ${VERSION}`),
604
- )
750
+ // A global vx handed this run to the project's own: both move, to one
751
+ // version, the project first (owner, 2026-10-15).
752
+ const from = process.env['VX_HANDED_OFF']
753
+ if (from !== undefined) {
754
+ const version = await targetVersion(tag)
755
+ await named('project vx', () =>
756
+ install === null
757
+ ? upgradeBinary(dest, version, VERSION, 'project ')
758
+ : upgradeManaged(install, version, VERSION, 'project '),
759
+ )
760
+ await upgradeGlobal(from, version)
761
+ return 0
762
+ }
763
+ if (install !== null) await upgradeManaged(install, await targetVersion(tag))
764
+ else await upgradeBinary(dest, versionSpec(tag))
605
765
  return 0
606
766
  }
607
767
 
@@ -5,7 +5,8 @@
5
5
  // as Ctrl-C would and the loop starts the next one; a cycle running any
6
6
  // other work is never stopped.
7
7
 
8
- import { createEventBus, type RunOptions } from '../orchestrator/index.js'
8
+ import { type RunOptions } from '../orchestrator/index.js'
9
+ import { createEventBus } from '../report/index.js'
9
10
 
10
11
  export interface WatchCycle {
11
12
  /** The options for this cycle's run: its own bus and a stop of its own beside watch's. */
@@ -2,6 +2,7 @@
2
2
  // rules, git-ignored paths, the root arm's filter) and which files reshape
3
3
  // the watched set. Pure decisions over paths; the loop acts on them.
4
4
 
5
+ import fs from 'node:fs'
5
6
  import path from 'node:path'
6
7
  import {
7
8
  executablePath,
@@ -369,12 +370,14 @@ export function gitSpeller(root: string): (p: string) => string {
369
370
  * a project moved away whole is one event on its directory): what existed
370
371
  * when watch armed, so a path born and gone since is told from a deletion
371
372
  * (watch-judge.ts). `tracked` is the files git tracks: a user edits those,
372
- * so a server cannot be blamed for one. Undefined when git cannot answer:
373
+ * so a server cannot be blamed for one. `links` is the symlinks among the
374
+ * files: a key reads what each points to (cache/linked-inputs.ts), so watch
375
+ * arms that too. Undefined when git cannot answer:
373
376
  * no inventory, every gone path is a deletion as before.
374
377
  */
375
378
  export function gitFiles(
376
379
  workspaceRoot: string,
377
- ): { listed: Set<string>; tracked: Set<string> } | undefined {
380
+ ): { listed: Set<string>; tracked: Set<string>; links: Set<string> } | undefined {
378
381
  let proc: ReturnType<typeof Bun.spawnSync>
379
382
  try {
380
383
  proc = Bun.spawnSync({
@@ -383,6 +386,7 @@ export function gitFiles(
383
386
  'ls-files',
384
387
  '-z',
385
388
  '-t',
389
+ '-s',
386
390
  '--cached',
387
391
  '--others',
388
392
  '--exclude-standard',
@@ -398,18 +402,30 @@ export function gitFiles(
398
402
  if (proc.exitCode !== 0) return undefined
399
403
  const files = new Set<string>()
400
404
  const tracked = new Set<string>()
405
+ const links = new Set<string>()
401
406
  for (const entry of new TextDecoder('utf-8', { ignoreBOM: true })
402
407
  .decode(proc.stdout)
403
408
  .split('\0')) {
404
409
  if (entry.length === 0) continue
405
- // `-t` prefixes a tag and a space; `?` is untracked.
406
- const p = entry.slice(2)
410
+ // `-t` prefixes a tag and a space; `?` is untracked, a bare path. A
411
+ // tracked entry is `-s`'s `<mode> <oid> <stage>\t<path>` after the tag.
412
+ const untracked = entry[0] === '?'
413
+ const p = untracked ? entry.slice(2) : entry.slice(entry.indexOf('\t') + 1)
407
414
  // An untracked nested repository is listed as `dir/`.
408
415
  let abs = path.join(workspaceRoot, p.endsWith('/') ? p.slice(0, -1) : p)
409
416
  files.add(abs)
410
- if (entry[0] !== '?') tracked.add(abs)
417
+ if (!untracked) tracked.add(abs)
418
+ if (untracked ? isLink(abs) : entry.startsWith('120000 ', 2)) links.add(abs)
411
419
  for (abs = path.dirname(abs); abs !== workspaceRoot && !files.has(abs); abs = path.dirname(abs))
412
420
  files.add(abs)
413
421
  }
414
- return { listed: files, tracked }
422
+ return { listed: files, tracked, links }
423
+ }
424
+
425
+ function isLink(abs: string): boolean {
426
+ try {
427
+ return fs.lstatSync(abs).isSymbolicLink()
428
+ } catch {
429
+ return false
430
+ }
415
431
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import fs from 'node:fs'
6
6
  import path from 'node:path'
7
+ import { filesBehind } from '../cache/index.js'
7
8
  import { xxh3 } from '../util/index.js'
8
9
  import { gitIgnored, gitSpeller } from './watch-filter.js'
9
10
  import { modifiedBefore } from './watch-fs.js'
@@ -13,9 +14,10 @@ const ABSENT = -1n
13
14
  function settledState(abs: string): bigint {
14
15
  let st: fs.Stats
15
16
  try {
16
- // A link is its target string, as the key folds it: following it saw
17
- // a retarget to equal bytes as "the same" and ran nothing.
18
- if (fs.lstatSync(abs).isSymbolicLink()) return xxh3(`link:${fs.readlinkSync(abs)}`)
17
+ // A link is its target string and what it points to, as the key folds
18
+ // it: following it alone saw a retarget to equal bytes as "the same".
19
+ if (fs.lstatSync(abs).isSymbolicLink())
20
+ return xxh3(`link:${fs.readlinkSync(abs)}\0${behindState(abs)}`)
19
21
  st = fs.statSync(abs)
20
22
  } catch {
21
23
  return ABSENT
@@ -50,6 +52,34 @@ function settledState(abs: string): bigint {
50
52
  return xxh3(Buffer.from(entries.join('\n')))
51
53
  }
52
54
 
55
+ /**
56
+ * What a link points to, as change detection reads it: a file's bytes and
57
+ * mode, a directory's files by size and times (cache/linked-inputs.ts keys
58
+ * their bytes), nothing for a dangling link.
59
+ */
60
+ function behindState(link: string): string {
61
+ let real: string
62
+ let st: fs.Stats
63
+ try {
64
+ real = fs.realpathSync(link)
65
+ st = fs.statSync(real)
66
+ } catch {
67
+ return ''
68
+ }
69
+ if (st.isFile()) return `${xxh3(fs.readFileSync(real))}:${st.mode & 0o7777}`
70
+ if (!st.isDirectory()) return ''
71
+ return filesBehind(real)
72
+ .map((rel) => {
73
+ try {
74
+ const f = fs.statSync(path.join(real, rel))
75
+ return `${rel}\0${f.size}\0${f.mtimeMs}\0${f.ctimeMs}`
76
+ } catch {
77
+ return rel
78
+ }
79
+ })
80
+ .join('\n')
81
+ }
82
+
53
83
  /** What the judgement reads from the loop, which owns it and may replace it. */
54
84
  export interface JudgeContext {
55
85
  workspaceRoot: string
@@ -59,6 +89,8 @@ export interface JudgeContext {
59
89
  held(): boolean
60
90
  /** Projects whose uncached task may read a git-ignored file. */
61
91
  uncached(): ReadonlySet<string>
92
+ /** Paths a task's inputs name outright: a git-ignored file at or under one is an input. */
93
+ namedIgnored?(): readonly string[]
62
94
  /** Whether `abs`, under the project at `ownDir`, lies in a project nested there (`makeFence`). */
63
95
  fenced?(ownDir: string, abs: string): boolean
64
96
  /** The files git listed at the arm; absent when git could not answer. */
@@ -113,6 +145,15 @@ export class ChangeJudge {
113
145
 
114
146
  constructor(private readonly ctx: JudgeContext) {}
115
147
 
148
+ /**
149
+ * Record a path's state as it is, if this loop has not: a link armed for
150
+ * what it points to, whose own times say "before the arm" whatever
151
+ * changed behind it, so its first sighting compares against this.
152
+ */
153
+ seed(abs: string): void {
154
+ if (!this.lastState.has(abs)) this.lastState.set(abs, settledState(abs))
155
+ }
156
+
116
157
  // A path this loop has never judged is a change only if it moved since
117
158
  // the watchers went live (its mtime or ctime, `modifiedBefore`). macOS delivers the initial run's own writes
118
159
  // AFTER the arm (CI, 2026-09-11: `app dist; re-running...` with no edit
@@ -192,10 +233,13 @@ export class ChangeJudge {
192
233
  // user wrote it; one the last cycle wrote (the pid file an uncached
193
234
  // task rewrites every run, the loop the filter exists for) stays out,
194
235
  // as does one in a project nested under it, which it does not own.
236
+ // So is one a task's inputs name outright (`.env.local`, `dist/**`).
195
237
  const editForUncached = (p: string): boolean =>
196
- [...this.ctx.uncached()].some(
238
+ ([...this.ctx.uncached()].some(
197
239
  (dir) => p.startsWith(dir + path.sep) && this.ctx.fenced?.(dir, p) !== true,
198
- ) && !this.writtenDuringLastCycle(p)
240
+ ) ||
241
+ (this.ctx.namedIgnored?.() ?? []).some((n) => p === n || p.startsWith(n + path.sep))) &&
242
+ !this.writtenDuringLastCycle(p)
199
243
  // A path still there names the cycle before a gone one: an editor or
200
244
  // `sed -i` saving through a temporary file fires that file first, and
201
245
  // the cycle was announced by a name already renamed away.
@@ -5,6 +5,7 @@
5
5
 
6
6
  import path from 'node:path'
7
7
  import type { ProjectConfig } from '../config.js'
8
+ import { namedOutright } from '../cache/index.js'
8
9
  import {
9
10
  buildPackageGraph,
10
11
  lockKey,
@@ -64,6 +65,8 @@ export interface ConfigSweep {
64
65
  inputs: Map<string, string[][]>
65
66
  /** Projects with a task that has a command and no cache: it reads what it likes, git-ignored files included. */
66
67
  uncached: Set<string>
68
+ /** Paths a task's inputs name outright (`namedOutright`), absolute: a git-ignored one is an input. */
69
+ namedIgnored: string[]
67
70
  /** Files the project configs import by relative path (item 949). */
68
71
  configImports: string[]
69
72
  /** Files the workspace config imports by relative path: loaded once per process (item 949). */
@@ -115,6 +118,7 @@ export async function sweepConfigs(
115
118
  addTo(outputs, dir, globs)
116
119
  const workspaceInputs = new Set<string>()
117
120
  const uncached = new Set<string>()
121
+ const namedIgnored = new Set<string>()
118
122
  const folded: [string, ProjectConfig][] = []
119
123
  const fold = (dir: string, config: ProjectConfig): void => {
120
124
  folded.push([dir, config])
@@ -131,15 +135,20 @@ export async function sweepConfigs(
131
135
  add(dir, task.cache?.outputs?.files)
132
136
  add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
133
137
  if (names !== null && !names.has(name)) continue
134
- if (task.cache === undefined && task.exec !== undefined && task.exec.persistent === undefined)
135
- uncached.add(dir)
136
- for (const g of task.cache?.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
137
- addInputs(dir, task.cache?.inputs?.files, task.cache?.outputs?.files)
138
- addInputs(
139
- workspaceRoot,
140
- task.cache?.inputs?.workspaceFiles,
141
- task.cache?.outputs?.workspaceFiles,
138
+ if (
139
+ task.inputs === undefined &&
140
+ task.exec !== undefined &&
141
+ task.exec.persistent === undefined
142
142
  )
143
+ uncached.add(dir)
144
+ if (task.inputs !== undefined && task.exec !== undefined) {
145
+ const named = namedOutright(task)
146
+ for (const rel of named.files) namedIgnored.add(path.join(dir, rel))
147
+ for (const rel of named.workspaceFiles) namedIgnored.add(path.join(workspaceRoot, rel))
148
+ }
149
+ for (const g of task.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
150
+ addInputs(dir, task.inputs?.files, task.cache?.outputs?.files)
151
+ addInputs(workspaceRoot, task.inputs?.workspaceFiles, task.cache?.outputs?.workspaceFiles)
143
152
  }
144
153
  }
145
154
  const result = (staged: Map<string, ProjectEntry> | null): ConfigSweep => ({
@@ -148,6 +157,7 @@ export async function sweepConfigs(
148
157
  outputs,
149
158
  inputs,
150
159
  uncached,
160
+ namedIgnored: [...namedIgnored],
151
161
  configImports: [...projectImports],
152
162
  workspaceConfigImports: [...wsImports],
153
163
  staged,