@vzn/vx 0.0.639 → 0.0.641

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 (88) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +158 -106
  3. package/docs/cli.md +80 -45
  4. package/docs/execution.md +11 -10
  5. package/docs/features.md +9 -8
  6. package/docs/patterns.md +1 -1
  7. package/docs/schema.md +252 -190
  8. package/docs/security.md +5 -4
  9. package/launcher.sh +9 -2
  10. package/package.json +5 -5
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +4 -3
  13. package/src/bin.ts +8 -6
  14. package/src/cache/archive.ts +248 -41
  15. package/src/cache/cache.ts +46 -19
  16. package/src/cache/file-hashes.ts +7 -12
  17. package/src/cache/fs-clock.ts +62 -0
  18. package/src/cache/git-inputs.ts +2 -2
  19. package/src/cache/index.ts +6 -0
  20. package/src/cache/inputs.ts +228 -111
  21. package/src/cache/key-fold.ts +23 -3
  22. package/src/cache/layer.ts +15 -8
  23. package/src/cache/layered-cache.ts +8 -7
  24. package/src/cache/linked-inputs.ts +87 -0
  25. package/src/cache/scoped-cache.ts +8 -2
  26. package/src/cli/foreign-flags.ts +1 -1
  27. package/src/cli/help.ts +7 -1
  28. package/src/cli/init.ts +23 -11
  29. package/src/cli/last.ts +24 -1
  30. package/src/cli/plugin-templates.ts +2 -2
  31. package/src/cli/select.ts +16 -6
  32. package/src/cli/show.ts +9 -6
  33. package/src/cli/upgrade.ts +132 -21
  34. package/src/cli/watch-filter.ts +23 -12
  35. package/src/cli/watch-fs.ts +2 -1
  36. package/src/cli/watch-judge.ts +49 -5
  37. package/src/cli/watch-set.ts +18 -8
  38. package/src/cli/watch.ts +74 -2
  39. package/src/cli/why.ts +5 -3
  40. package/src/config.ts +19 -15
  41. package/src/exec/executor.ts +4 -4
  42. package/src/graph/dependency-spec.ts +2 -2
  43. package/src/graph/folds.ts +236 -0
  44. package/src/graph/index.ts +8 -1
  45. package/src/graph/scheduler.ts +41 -15
  46. package/src/graph/task-graph.ts +135 -73
  47. package/src/handoff.ts +5 -0
  48. package/src/index.ts +2 -2
  49. package/src/orchestrator/affected-tasks.ts +20 -19
  50. package/src/orchestrator/download-policy.ts +16 -22
  51. package/src/orchestrator/excluded-keys.ts +4 -5
  52. package/src/orchestrator/execute-task.ts +29 -25
  53. package/src/orchestrator/hit-restore.ts +101 -27
  54. package/src/orchestrator/index.ts +1 -0
  55. package/src/orchestrator/keyed-projects.ts +12 -51
  56. package/src/orchestrator/local-shortcircuit.ts +9 -1
  57. package/src/orchestrator/persistent-task.ts +3 -3
  58. package/src/orchestrator/placement.ts +2 -2
  59. package/src/orchestrator/plan.ts +1 -1
  60. package/src/orchestrator/plugin-host.ts +66 -27
  61. package/src/orchestrator/plugin.ts +12 -2
  62. package/src/orchestrator/prepare.ts +12 -5
  63. package/src/orchestrator/projects.ts +7 -10
  64. package/src/orchestrator/run-report.ts +145 -46
  65. package/src/orchestrator/run-steps.ts +57 -0
  66. package/src/orchestrator/run.ts +76 -44
  67. package/src/orchestrator/sandbox-fix.ts +1 -1
  68. package/src/orchestrator/sandbox-request.ts +2 -2
  69. package/src/orchestrator/stable-keys.ts +59 -26
  70. package/src/orchestrator/steps.ts +26 -6
  71. package/src/orchestrator/task-hash.ts +31 -9
  72. package/src/orchestrator/telemetry-host.ts +3 -3
  73. package/src/orchestrator/telemetry.ts +9 -4
  74. package/src/orchestrator/upstream.ts +4 -181
  75. package/src/report/framed-output.ts +1 -1
  76. package/src/report/logger.ts +5 -2
  77. package/src/report/status-line.ts +7 -3
  78. package/src/report/summary.ts +6 -4
  79. package/src/settings.ts +11 -17
  80. package/src/util/index.ts +3 -0
  81. package/src/util/paths.ts +39 -5
  82. package/src/workspace/affected.ts +81 -2
  83. package/src/workspace/config-eval.ts +2 -2
  84. package/src/workspace/config-imports.ts +1 -1
  85. package/src/workspace/config-schema.ts +142 -143
  86. package/src/workspace/lockfile.ts +1 -1
  87. package/src/workspace/migration.ts +19 -3
  88. package/src/workspace/project-loader.ts +5 -4
@@ -112,13 +112,18 @@ export function versionSpec(tag: string | undefined): string {
112
112
  * as an internal error with a stack (a network namespace without a
113
113
  * route, 2026-09-16). One line, the host and the reason.
114
114
  */
115
- 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> {
116
121
  try {
117
122
  return await fetch(url, init)
118
123
  } catch (err) {
119
124
  const message = err instanceof Error ? err.message : String(err)
120
125
  throw new UserError(
121
- `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`,
122
127
  )
123
128
  }
124
129
  }
@@ -130,33 +135,43 @@ async function fetchOrRefuse(url: string, init: RequestInit, what: string): Prom
130
135
  * connection was closed unexpectedly"), which `fetchOrRefuse` never sees
131
136
  * and the CLI printed as an internal error with a stack.
132
137
  */
133
- 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> {
134
144
  try {
135
145
  return await read()
136
146
  } catch (err) {
137
147
  const message = err instanceof Error ? err.message : String(err)
138
148
  throw new UserError(
139
- `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`,
140
150
  )
141
151
  }
142
152
  }
143
153
 
144
154
  /** The registry's document for `pkg@spec` (`latest` or a version). */
145
- 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> {
146
160
  const url = `${REGISTRY}/${pkg}/${spec}`
147
161
  const res = await fetchOrRefuse(
148
162
  url,
149
163
  { redirect: 'follow', headers: { Accept: 'application/json', 'User-Agent': `vx/${VERSION}` } },
150
164
  'read the release',
165
+ verb,
151
166
  )
152
167
  if (!res.ok) {
153
168
  throw new UserError(
154
- `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?` : ''}`,
155
170
  )
156
171
  }
157
172
  // A body that is not JSON (a captive portal's page, a proxy's error
158
173
  // page served with a 200) is the same refusal as a cut one.
159
- return await readOrRefuse(() => res.json(), url, 'read the release')
174
+ return await readOrRefuse(() => res.json(), url, 'read the release', verb)
160
175
  }
161
176
 
162
177
  /** What a verified registry document names: the version, its tarball and the signed integrity. */
@@ -395,6 +410,8 @@ export interface ManagedInstall {
395
410
  cwd: string
396
411
  /** The project field that lists `@vzn/vx`. */
397
412
  field?: 'dependencies' | 'devDependencies' | 'optionalDependencies'
413
+ /** The `@vzn/vx-*` plugins the same package.json lists from the registry, moved with core. */
414
+ plugins?: string[]
398
415
  }
399
416
 
400
417
  const FIELDS = ['devDependencies', 'dependencies', 'optionalDependencies'] as const
@@ -417,6 +434,45 @@ function listingField(dir: string): ManagedInstall['field'] | undefined {
417
434
  })
418
435
  }
419
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
+
420
476
  /** The project's package manager: its lockfile, then `packageManager`, then the tree's shape. */
421
477
  function projectManager(dir: string, execPath: string): ManagedInstall['manager'] {
422
478
  for (let d = dir; ; d = path.dirname(d)) {
@@ -463,12 +519,15 @@ export function managedInstall(execPath: string, cwd: string): ManagedInstall |
463
519
  if (/\/yarn\/global\//.test(p)) return { manager: 'yarn', scope: 'global', cwd: root }
464
520
  const field = listingField(root)
465
521
  if (field !== undefined) {
466
- 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 }
467
524
  }
468
525
  for (let d = path.resolve(cwd); d.startsWith(root); d = path.dirname(d)) {
469
526
  const f = listingField(d)
470
- if (f !== undefined)
471
- 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
+ }
472
531
  if (path.dirname(d) === d) break
473
532
  }
474
533
  // npm's global tree: `<prefix>/lib/node_modules` (nvm, volta, Homebrew alike).
@@ -503,7 +562,8 @@ export function upgradeArgv(install: ManagedInstall, version: string): string[]
503
562
  bun: { devDependencies: '--dev', dependencies: '', optionalDependencies: '--optional' },
504
563
  yarn: { devDependencies: '--dev', dependencies: '', optionalDependencies: '--optional' },
505
564
  }[manager][field]
506
- 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]
507
567
  if (flag !== '') argv.push(flag)
508
568
  // pnpm refuses an `add` at a workspace's root without saying it means the root.
509
569
  if (manager === 'pnpm' && existsSync(path.join(install.cwd, 'pnpm-workspace.yaml')))
@@ -511,13 +571,17 @@ export function upgradeArgv(install: ManagedInstall, version: string): string[]
511
571
  return argv
512
572
  }
513
573
 
514
- /** The version `tag` names, or the registry's latest, read for the "already at" check. */
515
- 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> {
516
579
  const spec = versionSpec(tag)
517
580
  if (spec !== 'latest') return spec
518
- 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
519
583
  if (typeof v !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(v)) {
520
- 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}`)
521
585
  }
522
586
  return v
523
587
  }
@@ -527,13 +591,14 @@ async function targetVersion(tag: string | undefined): Promise<string> {
527
591
  * `version`; vx never installs one. `step` names the install when a run
528
592
  * moves two (`project `, `global `).
529
593
  */
530
- async function upgradeManaged(
594
+ export async function upgradeManaged(
531
595
  install: ManagedInstall,
532
596
  version: string,
533
597
  current = VERSION,
534
598
  step = '',
535
599
  ): Promise<void> {
536
- if (version === current) {
600
+ const plugins = install.plugins ?? []
601
+ if (version === current && plugins.every((p) => installedVersion(install.cwd, p) === version)) {
537
602
  ui.print('out', ui.status('info', 'vx upgrade', `${step}already at ${current}`))
538
603
  return
539
604
  }
@@ -547,7 +612,11 @@ async function upgradeManaged(
547
612
  }
548
613
  ui.print(
549
614
  'out',
550
- ui.status('info', 'vx upgrade', `${step}${current} → ${version}`),
615
+ ui.status(
616
+ 'info',
617
+ 'vx upgrade',
618
+ `${step}${version === current ? 'plugins' : current} → ${version}`,
619
+ ),
551
620
  ui.text([ui.cmd(argv.join(' ')), ui.dim(` in ${install.cwd}`)]),
552
621
  )
553
622
  const proc = Bun.spawnSync({
@@ -557,7 +626,14 @@ async function upgradeManaged(
557
626
  })
558
627
  if (proc.exitCode !== 0) {
559
628
  throw new UserError(
560
- `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
+ }`,
561
637
  )
562
638
  }
563
639
  }
@@ -636,6 +712,31 @@ export async function upgradeGlobal(
636
712
  })
637
713
  }
638
714
 
715
+ /**
716
+ * `vx upgrade -g`: bring this vx, the global one, to `version` and nothing
717
+ * else (owner, 2026-10-16) — no project's package.json, no plugin. The
718
+ * project's own vx refuses: no global ran.
719
+ */
720
+ export async function upgradeOnlyGlobal(
721
+ self: string,
722
+ version: string,
723
+ current = VERSION,
724
+ ): Promise<void> {
725
+ if (current === version) {
726
+ ui.print('out', ui.status('info', 'vx upgrade', `global already at ${current}`))
727
+ return
728
+ }
729
+ // Read from the binary's own directory, not cwd: a project there is not this install's.
730
+ const install = managedInstall(self, path.dirname(self))
731
+ if (install?.scope === 'project') {
732
+ throw new UserError(
733
+ `vx upgrade -g: no global vx ran — ${self} is the project's own. Install one (npm install -g ${PACKAGE}), or run \`vx upgrade\` to move the project's`,
734
+ )
735
+ }
736
+ if (install === null) await upgradeBinary(self, version, current, 'global ')
737
+ else await upgradeManaged(install, version, current, 'global ')
738
+ }
739
+
639
740
  /** `run`, its refusal naming `what` after the verb: a run that moves two installs says which failed. */
640
741
  async function named(what: string, run: () => Promise<void>): Promise<void> {
641
742
  try {
@@ -646,7 +747,9 @@ async function named(what: string, run: () => Promise<void>): Promise<void> {
646
747
  }
647
748
  }
648
749
 
649
- export async function upgradeCmd(args: readonly string[]): Promise<number> {
750
+ export async function upgradeCmd(argv: readonly string[]): Promise<number> {
751
+ const global = argv.some((a) => a === '-g' || a === '--global')
752
+ const args = argv.filter((a) => a !== '-g' && a !== '--global')
650
753
  const unknown = args.find((a) => a.startsWith('-'))
651
754
  if (unknown !== undefined) {
652
755
  sayNotice(
@@ -670,10 +773,18 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
670
773
  )
671
774
  }
672
775
  const dest = process.execPath
776
+ const handedOffFrom = process.env['VX_HANDED_OFF']
777
+ if (global) {
778
+ // A launcher from before -g handed the call over: the global is the one that did.
779
+ const version = await targetVersion(tag)
780
+ if (handedOffFrom !== undefined) await upgradeGlobal(handedOffFrom, version)
781
+ else await upgradeOnlyGlobal(dest, version)
782
+ return 0
783
+ }
673
784
  const install = managedInstall(dest, process.cwd())
674
785
  // A global vx handed this run to the project's own: both move, to one
675
786
  // version, the project first (owner, 2026-10-15).
676
- const from = process.env['VX_HANDED_OFF']
787
+ const from = handedOffFrom
677
788
  if (from !== undefined) {
678
789
  const version = await targetVersion(tag)
679
790
  await named('project vx', () =>
@@ -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,
@@ -32,13 +33,8 @@ export function isIgnoredWatchPath(rel: string): boolean {
32
33
  const segments = rel.split(path.sep)
33
34
  for (let i = 1; i <= segments.length; i++) if (isIgnoredDir(segments.slice(0, i))) return true
34
35
  if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
35
- const name = segments[segments.length - 1]!
36
- // A restore's own temporary file, never an input (`ALWAYS_IGNORE`):
37
- // renamed away before the judgement, it read as a deletion and ran one
38
- // more cycle where an uncached task makes ignored paths count.
39
- if (name.startsWith('.vx-tmp-')) return true
40
36
  // `vx lock`'s review copy: no input, so a re-lock is no cycle.
41
- return name === 'vx.lock.json'
37
+ return segments[segments.length - 1] === 'vx.lock.json'
42
38
  }
43
39
 
44
40
  /**
@@ -369,12 +365,14 @@ export function gitSpeller(root: string): (p: string) => string {
369
365
  * a project moved away whole is one event on its directory): what existed
370
366
  * when watch armed, so a path born and gone since is told from a deletion
371
367
  * (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:
368
+ * so a server cannot be blamed for one. `links` is the symlinks among the
369
+ * files: a key reads what each points to (cache/linked-inputs.ts), so watch
370
+ * arms that too. Undefined when git cannot answer:
373
371
  * no inventory, every gone path is a deletion as before.
374
372
  */
375
373
  export function gitFiles(
376
374
  workspaceRoot: string,
377
- ): { listed: Set<string>; tracked: Set<string> } | undefined {
375
+ ): { listed: Set<string>; tracked: Set<string>; links: Set<string> } | undefined {
378
376
  let proc: ReturnType<typeof Bun.spawnSync>
379
377
  try {
380
378
  proc = Bun.spawnSync({
@@ -383,6 +381,7 @@ export function gitFiles(
383
381
  'ls-files',
384
382
  '-z',
385
383
  '-t',
384
+ '-s',
386
385
  '--cached',
387
386
  '--others',
388
387
  '--exclude-standard',
@@ -398,18 +397,30 @@ export function gitFiles(
398
397
  if (proc.exitCode !== 0) return undefined
399
398
  const files = new Set<string>()
400
399
  const tracked = new Set<string>()
400
+ const links = new Set<string>()
401
401
  for (const entry of new TextDecoder('utf-8', { ignoreBOM: true })
402
402
  .decode(proc.stdout)
403
403
  .split('\0')) {
404
404
  if (entry.length === 0) continue
405
- // `-t` prefixes a tag and a space; `?` is untracked.
406
- const p = entry.slice(2)
405
+ // `-t` prefixes a tag and a space; `?` is untracked, a bare path. A
406
+ // tracked entry is `-s`'s `<mode> <oid> <stage>\t<path>` after the tag.
407
+ const untracked = entry[0] === '?'
408
+ const p = untracked ? entry.slice(2) : entry.slice(entry.indexOf('\t') + 1)
407
409
  // An untracked nested repository is listed as `dir/`.
408
410
  let abs = path.join(workspaceRoot, p.endsWith('/') ? p.slice(0, -1) : p)
409
411
  files.add(abs)
410
- if (entry[0] !== '?') tracked.add(abs)
412
+ if (!untracked) tracked.add(abs)
413
+ if (untracked ? isLink(abs) : entry.startsWith('120000 ', 2)) links.add(abs)
411
414
  for (abs = path.dirname(abs); abs !== workspaceRoot && !files.has(abs); abs = path.dirname(abs))
412
415
  files.add(abs)
413
416
  }
414
- return { listed: files, tracked }
417
+ return { listed: files, tracked, links }
418
+ }
419
+
420
+ function isLink(abs: string): boolean {
421
+ try {
422
+ return fs.lstatSync(abs).isSymbolicLink()
423
+ } catch {
424
+ return false
425
+ }
415
426
  }
@@ -16,7 +16,8 @@ export const IGNORED_SEGMENTS = ['node_modules', '.git']
16
16
  export function isIgnoredDir(segments: readonly string[]): boolean {
17
17
  const last = segments.length - 1
18
18
  if (IGNORED_SEGMENTS.includes(segments[last]!)) return true
19
- return segments[last] === 'cache' && segments[last - 1] === '.vx'
19
+ // vx's cache, and where its restores stage (archive.ts).
20
+ return (segments[last] === 'cache' || segments[last] === 'tmp') && segments[last - 1] === '.vx'
20
21
  }
21
22
 
22
23
  /**
@@ -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,