@vzn/vx 0.0.636 → 0.0.637

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 (141) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +213 -207
  3. package/docs/cli.md +699 -655
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +376 -198
  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 +2 -2
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -63
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +103 -149
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +169 -70
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +24 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +158 -63
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +42 -29
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +113 -72
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +121 -95
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +209 -268
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +20 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -58,7 +58,7 @@ export {
58
58
  type GitEnumeration,
59
59
  } from './git-inputs.js'
60
60
 
61
- // vx-lock.json is committed (so git enumerates it) but it's vx's own
61
+ // .vx/projects.lock.json is committed (so git enumerates it) but it's vx's own
62
62
  // frozen-config metadata — never a task input. Excluded globally so a
63
63
  // re-lock can't bust every cache key the way a tracked source file
64
64
  // would. (Literal pattern, not the workspace `LOCKFILE_NAME` constant:
@@ -79,9 +79,17 @@ const ALWAYS_IGNORE = [
79
79
  // it. Kept: the day an enumeration stops going through git, this is
80
80
  // what keeps a repository's object store out of a cache key.
81
81
  '**/.git/**',
82
- '**/.vx/**',
82
+ // vx's machine-local state; the workspace config beside it is a source.
83
+ '**/.vx/cache/**',
83
84
  '**/*.tsbuildinfo',
84
- '**/vx-lock.json',
85
+ '**/.vx/projects.lock.json',
86
+ // `vx lock`'s review copy of a project's resolved config: the config is
87
+ // keyed already, and a re-lock must not move every `**` reader's key.
88
+ '**/vx.lock.json',
89
+ // What `vx lock` and `vx init` write beside the lock: a re-lock or init
90
+ // must not select every `**` reader under --affected.
91
+ '**/.vx/.gitattributes',
92
+ '**/.vx/.gitignore',
85
93
  // `bun build --compile` writes a transient `.<hash>-<n>.bun-build` intermediate
86
94
  // in the cwd. It never rests on disk, so it can't be a real input — but a
87
95
  // broad `inputs.files: ['**/*']` on a compile task would try to hash the temp
@@ -313,7 +321,13 @@ function resolveWorkspaceFiles(args: {
313
321
  let isPositive: ((rel: string) => boolean) | undefined
314
322
  const excludedByDeclaration = (rel: string): boolean =>
315
323
  (isExcluded ??= anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)]))(rel)
316
- const own = (rel: string): boolean => ownOutput(rel) || ownProject(rel)
324
+ // A committed file stays an input whatever output reaches it (`committedFiles`).
325
+ let committed: ((abs: string) => boolean) | undefined
326
+ const own = (rel: string): boolean =>
327
+ (ownOutput(rel) || ownProject(rel)) &&
328
+ !(committed ??= committedFiles(args.gitFilesCache, args.workspaceRoot))(
329
+ path.join(args.workspaceRoot, rel),
330
+ )
317
331
  const listing: InputListing = {
318
332
  root: args.workspaceRoot,
319
333
  listed: gitFiles,
@@ -667,11 +681,13 @@ async function resolveRuntimeValues(
667
681
  /**
668
682
  * What no output glob reaches. An output is otherwise taken as written —
669
683
  * `node_modules/**` IS an install task's output, so `ALWAYS_IGNORE` does not
670
- * apply here — but these two directories no task produces, and a root
671
- * project declaring `**` would otherwise wipe its repository and the cache
672
- * it is restoring from (2026-09-16).
684
+ * apply here — but no task produces these, and a root project declaring
685
+ * `**` would otherwise wipe its repository and the cache it is restoring
686
+ * from (2026-09-16). `.vx` whole and each `vx.lock.json` are vx's own: as
687
+ * outputs, the clean deleted an uncommitted workspace config and a hit
688
+ * restored a lock or ignore file the key does not see over the current one.
673
689
  */
674
- const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**']
690
+ const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**', '**/vx.lock.json']
675
691
 
676
692
  /**
677
693
  * What a set of output globs may not reach: `OUTPUT_NEVER`, and every
@@ -719,6 +735,20 @@ export async function resolveOutputs(args: {
719
735
  return containedIn(args.projectDir, scanned).sort()
720
736
  }
721
737
 
738
+ /**
739
+ * Whether git tracks a file under `dir`. No clean deletes a committed (or
740
+ * `git add`ed) file an output glob reaches (owner, 2026-10-14: "never
741
+ * delete committed file"); the task may rewrite it and a restore writes the
742
+ * bytes it left. It stays in the task's key, so those bytes follow from
743
+ * the key. Turbo and Nx never clean an output at all.
744
+ */
745
+ export function committedFiles(
746
+ cache: GitFilesCache | undefined,
747
+ dir: string,
748
+ ): (abs: string) => boolean {
749
+ return (cache ?? new GitFilesCache()).committedIn(dir)
750
+ }
751
+
722
752
  /**
723
753
  * The literal head of each output glob, walked: the scan follows a link
724
754
  * there (`dist -> ../elsewhere` for `dist/**`) and only there. A link to a
@@ -838,6 +868,7 @@ export async function cleanOutputs(args: {
838
868
  projectDir: string
839
869
  outputs: readonly string[]
840
870
  nestedProjectDirs: string[]
871
+ committed?: (abs: string) => boolean
841
872
  /**
842
873
  * What `resolveOutputs` returned for these same arguments, when the
843
874
  * caller walked the tree with no await since: a hit that found its tree
@@ -846,15 +877,20 @@ export async function cleanOutputs(args: {
846
877
  */
847
878
  resolved?: readonly string[] | undefined
848
879
  }): Promise<string[]> {
849
- const files = args.resolved ?? (await resolveOutputs(args))
880
+ const committed = args.committed ?? committedFiles(undefined, args.projectDir)
881
+ const matched = args.resolved ?? (await resolveOutputs(args))
850
882
  // `force: true` makes rm tolerate ENOENT (e.g. when two output
851
883
  // globs overlap and a sibling already deleted a path mid-iteration).
852
884
  // A symlink is unlinked, never followed.
853
- const removed = await removeAll(files, args.projectDir)
885
+ const removed = await removeAll(
886
+ matched.filter((f) => !committed(f)),
887
+ args.projectDir,
888
+ )
854
889
  await pruneEmptiedDirs(args.projectDir, removed, pruneScope(args.projectDir, args.outputs))
855
- // Project-relative posix paths of what was removed — the caller
856
- // feeds these to GitFilesCache.markOutputsChanged after a restore.
857
- return files.map((f) => relPosix(args.projectDir, f))
890
+ // Project-relative posix paths the outputs reach, for the caller's
891
+ // GitFilesCache.markOutputsChanged: a committed one kept here is the
892
+ // task's to rewrite or remove, so its index OID no longer vouches for it.
893
+ return matched.map((f) => relPosix(args.projectDir, f))
858
894
  }
859
895
 
860
896
  /**
@@ -868,8 +904,10 @@ export async function cleanOutputPaths(args: {
868
904
  projectDir: string
869
905
  rels: readonly string[]
870
906
  outputs: readonly string[]
907
+ committed?: (abs: string) => boolean
871
908
  }): Promise<void> {
872
- const files = args.rels.map((r) => path.resolve(args.projectDir, r))
909
+ const committed = args.committed ?? committedFiles(undefined, args.projectDir)
910
+ const files = args.rels.map((r) => path.resolve(args.projectDir, r)).filter((f) => !committed(f))
873
911
  await pruneEmptiedDirs(
874
912
  args.projectDir,
875
913
  await removeAll(files, args.projectDir),
@@ -980,6 +1018,29 @@ function changedSince(
980
1018
  return kept < before.size ? undefined : own
981
1019
  }
982
1020
 
1021
+ /**
1022
+ * Is any file `before` stamped still there, unchanged? Under
1023
+ * `cache.outputs.clean: false` such a file is one the run found and never
1024
+ * wrote, so no entry may claim it as the run's output.
1025
+ */
1026
+ export function anyUntouched(before: ReadonlyMap<string, OutputStamp>): boolean {
1027
+ for (const [f, was] of before) {
1028
+ try {
1029
+ const st = lstatSync(f)
1030
+ if (
1031
+ st.size === was.size &&
1032
+ st.mtimeMs === was.mtimeMs &&
1033
+ st.ino === was.ino &&
1034
+ st.ctimeMs === was.ctimeMs
1035
+ )
1036
+ return true
1037
+ } catch {
1038
+ // Removed by the run: nothing of it is left to claim.
1039
+ }
1040
+ }
1041
+ return false
1042
+ }
1043
+
983
1044
  /**
984
1045
  * Up to this many paths, a clean removes synchronously: the threadpool round
985
1046
  * trip per `rm`/`rmdir` cost more than the unlink itself, 0.30 ms against
@@ -1179,16 +1240,21 @@ export async function resolveWorkspaceOutputs(args: {
1179
1240
  export async function cleanWorkspaceOutputs(args: {
1180
1241
  workspaceRoot: string
1181
1242
  outputs: readonly string[]
1243
+ committed?: (abs: string) => boolean
1182
1244
  /** `cleanOutputs`'s `resolved`, from `resolveWorkspaceOutputs`. */
1183
1245
  resolved?: readonly string[] | undefined
1184
1246
  }): Promise<string[]> {
1185
- const files = args.resolved ?? (await resolveWorkspaceOutputs(args))
1247
+ const committed = args.committed ?? committedFiles(undefined, args.workspaceRoot)
1248
+ const matched = args.resolved ?? (await resolveWorkspaceOutputs(args))
1186
1249
  await pruneEmptiedDirs(
1187
1250
  args.workspaceRoot,
1188
- await removeAll(files, args.workspaceRoot),
1251
+ await removeAll(
1252
+ matched.filter((f) => !committed(f)),
1253
+ args.workspaceRoot,
1254
+ ),
1189
1255
  pruneScope(args.workspaceRoot, args.outputs),
1190
1256
  )
1191
- return files.map((f) => relPosix(args.workspaceRoot, f))
1257
+ return matched.map((f) => relPosix(args.workspaceRoot, f))
1192
1258
  }
1193
1259
 
1194
1260
  function realOrNull(p: string): string | null {
@@ -1380,6 +1446,12 @@ interface FilesPlan {
1380
1446
  * `package.json`), so each is matched once per plan.
1381
1447
  */
1382
1448
  verdicts: Map<string, boolean>
1449
+ /**
1450
+ * Whether a path is an input but for the task's own outputs: one git
1451
+ * tracks stays an input (`committedFiles`), so the key sees an edit to a
1452
+ * committed file an output glob reaches.
1453
+ */
1454
+ ownOnly: Map<string, boolean>
1383
1455
  }
1384
1456
 
1385
1457
  /**
@@ -1423,6 +1495,7 @@ function filesPlan(
1423
1495
  isPositive: anyTaskGlob(asTrees(positive)),
1424
1496
  ...reachOf(positive),
1425
1497
  verdicts: new Map<string, boolean>(),
1498
+ ownOnly: new Map<string, boolean>(),
1426
1499
  }
1427
1500
  filesPlans.set(key, plan)
1428
1501
  return plan
@@ -1432,8 +1505,8 @@ function filesPlan(
1432
1505
  * Whether a changed path is one of a cached task's declared file inputs:
1433
1506
  * `projectRel` (the path relative to the project that owns it) against
1434
1507
  * `files`, `workspaceRel` (relative to the workspace root) against
1435
- * `workspaceFiles`; null skips that half, by the globs, `!` exclusions and own-output takebacks
1436
- * the key resolves. `--affected` seeds a task with it (owner, 2026-10-04).
1508
+ * `workspaceFiles`; null skips that half, by the globs and `!` exclusions (a path under
1509
+ * the task's own outputs counts: a committed one is keyed). `--affected` seeds a task with it (owner, 2026-10-04).
1437
1510
  */
1438
1511
  export function declaresInput(
1439
1512
  cache: CacheConfig,
@@ -1441,13 +1514,15 @@ export function declaresInput(
1441
1514
  workspaceRel: string | null,
1442
1515
  ): boolean {
1443
1516
  if (projectRel !== null) {
1517
+ // A changed path under the task's own outputs is a committed file
1518
+ // (`git diff` lists only what git tracks) or a stray `git status`
1519
+ // lists: either way it may be an input, so it counts.
1444
1520
  const plan = filesPlan(cache.inputs.files, cache.outputs.files)
1445
- if (plan !== null && inPlan(plan, projectRel)) return true
1521
+ if (plan !== null && (inPlan(plan, projectRel) || ownOnly(plan, projectRel))) return true
1446
1522
  }
1447
1523
  const ws = cache.inputs.workspaceFiles
1448
1524
  if (workspaceRel === null || ws === undefined || ws.length === 0) return false
1449
- const matcher = workspaceMatcher(ws, cache.outputs.workspaceFiles ?? [])
1450
- return matcher(workspaceRel)
1525
+ return workspaceMatcher(ws)(workspaceRel)
1451
1526
  }
1452
1527
 
1453
1528
  /**
@@ -1471,20 +1546,26 @@ function inPlan(plan: FilesPlan, rel: string): boolean {
1471
1546
  return input
1472
1547
  }
1473
1548
 
1549
+ /** `FilesPlan.ownOnly`, memoised: asked only of a path `inPlan` refused. */
1550
+ function ownOnly(plan: FilesPlan, rel: string): boolean {
1551
+ let own = plan.ownOnly.get(rel)
1552
+ if (own === undefined) {
1553
+ own = plan.ownOutput(rel) && plan.isPositive(rel) && !plan.isExcluded(rel)
1554
+ plan.ownOnly.set(rel, own)
1555
+ }
1556
+ return own
1557
+ }
1558
+
1474
1559
  const workspaceMatchers = new Map<string, (rel: string) => boolean>()
1475
1560
 
1476
- function workspaceMatcher(
1477
- decl: readonly string[],
1478
- ownOutputs: readonly string[],
1479
- ): (rel: string) => boolean {
1480
- const key = JSON.stringify([decl, ownOutputs])
1561
+ function workspaceMatcher(decl: readonly string[]): (rel: string) => boolean {
1562
+ const key = JSON.stringify(decl)
1481
1563
  let m = workspaceMatchers.get(key)
1482
1564
  if (m !== undefined) return m
1483
1565
  const { positive, negative } = splitNegations(decl)
1484
1566
  const isPositive = anyTaskGlob(asTrees(positive))
1485
1567
  const isExcluded = anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)])
1486
- const ownOutput = outputMatcher(ownOutputs)
1487
- m = (rel) => isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
1568
+ m = (rel) => isPositive(rel) && !isExcluded(rel)
1488
1569
  workspaceMatchers.set(key, m)
1489
1570
  return m
1490
1571
  }
@@ -1521,7 +1602,7 @@ async function resolveFiles(
1521
1602
  if (memo !== undefined && memo.snapshot === gitFiles) {
1522
1603
  return {
1523
1604
  files: [...memo.result],
1524
- listing: listingFor(args.projectDir, gitFiles, plan, nested, ownWsOutput),
1605
+ listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
1525
1606
  }
1526
1607
  }
1527
1608
  }
@@ -1566,14 +1647,17 @@ async function resolveFiles(
1566
1647
  : undefined
1567
1648
  const candidates: string[] = []
1568
1649
  const verdicts = plan.verdicts
1650
+ const committed = committedFiles(args.gitFilesCache, args.projectDir)
1569
1651
  for (const rel of gitFiles) {
1570
1652
  let input = verdicts.get(rel)
1571
1653
  if (input === undefined) {
1572
1654
  input = isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
1573
1655
  verdicts.set(rel, input)
1574
1656
  }
1575
- if (!input || nested(rel) || ownWsOutput(rel)) continue
1576
- candidates.push(base === undefined ? path.resolve(args.projectDir, rel) : base + rel)
1657
+ if ((!input && !ownOnly(plan, rel)) || nested(rel) || ownWsOutput(rel)) continue
1658
+ const abs = base === undefined ? path.resolve(args.projectDir, rel) : base + rel
1659
+ if (!input && !committed(abs)) continue
1660
+ candidates.push(abs)
1577
1661
  }
1578
1662
  const unmatchedLiterals = unanswered(plan.literals, gitFiles)
1579
1663
  if (unmatchedLiterals.size > 0) {
@@ -1601,21 +1685,27 @@ async function resolveFiles(
1601
1685
  args.projectFilesCache?.set(memoKey, { snapshot: gitFiles, result: resolved })
1602
1686
  return {
1603
1687
  files: [...resolved],
1604
- listing: listingFor(args.projectDir, gitFiles, plan, nested, ownWsOutput),
1688
+ listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
1605
1689
  }
1606
1690
  }
1607
1691
 
1608
1692
  function listingFor(
1609
- root: string,
1693
+ args: { projectDir: string; gitFilesCache?: GitFilesCache | undefined },
1610
1694
  listed: readonly string[],
1611
1695
  plan: FilesPlan,
1612
1696
  nested: (rel: string) => boolean,
1613
1697
  ownWsOutput: (rel: string) => boolean,
1614
1698
  ): InputListing {
1699
+ const root = args.projectDir
1700
+ let committed: ((abs: string) => boolean) | undefined
1615
1701
  return {
1616
1702
  root,
1617
1703
  listed,
1618
- isInput: (rel) => inPlan(plan, rel) && !ownWsOutput(rel),
1704
+ isInput: (rel) =>
1705
+ (inPlan(plan, rel) ||
1706
+ (ownOnly(plan, rel) &&
1707
+ (committed ??= committedFiles(args.gitFilesCache, root))(path.join(root, rel)))) &&
1708
+ !ownWsOutput(rel),
1619
1709
  nested,
1620
1710
  prefixes: plan.prefixes,
1621
1711
  literals: plan.literals,
@@ -31,9 +31,9 @@ import type { CacheKeyInput } from './layer.js'
31
31
  // v23: fold cache.inputs.runtime / workspaceRuntime command output into
32
32
  // the key (two namespaced sections after env-values). Command strings
33
33
  // live in the resolved config (frozen by `vx lock`); the OUTPUT is
34
- // resolved live every run, so it stays correct under --frozen. No
34
+ // resolved live every run, so it stays correct under the lock. No
35
35
  // SCHEMA bump — only Cache.key derivation changed.
36
- // v24: vx-lock.json added to the always-ignored input set (cache/
36
+ // v24: .vx/projects.lock.json added to the always-ignored input set (cache/
37
37
  // inputs.ts ALWAYS_IGNORE). A task whose globs matched the root
38
38
  // lockfile (broad `**/*` on the root project) drops it from the hashed
39
39
  // file set, so those keys change; tasks that never matched it are
@@ -105,7 +105,12 @@ import type { CacheKeyInput } from './layer.js'
105
105
  // never listed sits under the key of that directory empty (A-61).
106
106
  // v42: the stored output is both streams in order (orchestrator/output-log.ts);
107
107
  // a v41 entry holds stdout alone and replays no stderr.
108
- export const CACHE_VERSION = 'vx-cache-v42'
108
+ // v43: a scoped run stores under the scope's key locally too (F4,
109
+ // 2026-10-14); a v42 entry a scoped run left locally sits under the default key.
110
+ // v43 stays when core stopped folding package files (2026-10-14): the
111
+ // `workspace:` and `pkg:` parts left the chain, so every key moved and no
112
+ // unchanged key holds wrong bytes.
113
+ export const CACHE_VERSION = 'vx-cache-v43'
109
114
 
110
115
  /**
111
116
  * The digest a key folds for an input gone between its enumeration and its
@@ -151,16 +156,12 @@ export async function foldKey(
151
156
  const cap = input.captureInto
152
157
  let h = xxh3(CACHE_VERSION)
153
158
  h = xxh3(`task:${input.taskId}`, h)
154
- h = xxh3(`workspace:${input.workspaceFingerprint}`, h)
155
- h = xxh3(`pkg:${input.projectPackageJsonHash}`, h)
156
159
  h = xxh3(`config:${input.taskConfigHash}`, h)
157
160
  if (cap) {
158
161
  // Recorded so `vx why` can name a key that moved only because vx
159
162
  // keys differently now (an upgrade): unrecorded, every task's miss
160
163
  // read as "inputs differ" with no input changed (X-142).
161
164
  cap.push({ kind: 'format', name: 'cache-version', hash: CACHE_VERSION })
162
- cap.push({ kind: 'workspace', name: 'fingerprint', hash: input.workspaceFingerprint })
163
- cap.push({ kind: 'package', name: 'package.json', hash: input.projectPackageJsonHash })
164
165
  cap.push({ kind: 'config', name: 'config', hash: input.taskConfigHash })
165
166
  }
166
167
 
@@ -65,27 +65,12 @@ export interface CacheKeyInput {
65
65
  readonly hash: string
66
66
  readonly projectDir: string
67
67
  }>
68
- /**
69
- * Workspace-level fingerprint — typically a hash of `pnpm-lock.yaml` +
70
- * `pnpm-workspace.yaml`. Folds resolved dep versions and workspace shape
71
- * into every task's key, so a lockfile bump invalidates everything.
72
- */
73
- workspaceFingerprint: string
74
68
  /**
75
69
  * CLI args forwarded to the task (after `--`). Folded into the key so that
76
70
  * the same command with different forwarded args is treated as a distinct
77
71
  * run, never a spurious cache hit.
78
72
  */
79
73
  forwardArgs?: readonly string[]
80
- /**
81
- * Hash of the project's `package.json` bytes. Folded into the key
82
- * implicitly (Turbo / Nx parity) so dep changes invalidate every
83
- * task in that project, even when `cache.inputs.files` doesn't
84
- * cover package.json. Empty string when the project has no
85
- * package.json (impossible in practice — workspace discovery
86
- * requires one — but we don't fail-loud here).
87
- */
88
- projectPackageJsonHash: string
89
74
  /**
90
75
  * Precomputed content hashes (git blob OIDs) keyed by absolute
91
76
  * path — typically the trusted-index OID map harvested by the
@@ -190,7 +175,7 @@ export interface RunRecord {
190
175
  endedAt: number // ms-epoch wall clock
191
176
  /**
192
177
  * Optional analytics columns. Populated by the orchestrator/runner;
193
- * stored as NULL on rows from older runs. Surfaced via `vx stats`
178
+ * stored as NULL on rows from older runs. Surfaced via `vx info`
194
179
  * and consumable from CI by reading cache.db directly.
195
180
  */
196
181
  runId?: string // ULID shared across every task in one `vx run` invocation
@@ -495,7 +480,7 @@ export class CorruptArtifactError extends Error {
495
480
 
496
481
  /**
497
482
  * `restoreOutputs` found no artifact where the probe found one: it was
498
- * removed in between — a `vx cache prune` in another shell, or the
483
+ * removed in between — a `vx cache clean` in another shell, or the
499
484
  * `cacheRetention` of another workspace sharing the cache directory. The
500
485
  * bytes are not wrong, they are gone, so the entry is a MISS and the task
501
486
  * runs (execute-task.ts); nothing about the cache needs throwing away.
@@ -702,6 +687,12 @@ export interface CacheLayer {
702
687
  * Only computed on the miss/save path — never on a hit.
703
688
  */
704
689
  inputComponents?: readonly TaskInputRow[]
690
+ /**
691
+ * For a sandboxed task: whether it could read a canonical path. A
692
+ * symlinked output is packed as its target's bytes, outside the
693
+ * sandbox, so one whose target the task could not read is refused.
694
+ */
695
+ readable?: ((real: string) => boolean) | undefined
705
696
  }): Promise<void>
706
697
  /**
707
698
  * Adopt an artifact produced elsewhere — the remote-hit path. Streams
@@ -33,12 +33,11 @@ import type {
33
33
  SaveArgs,
34
34
  } from './cache.js'
35
35
  import { FULL_CACHE_POLICY, type OutputDirRow } from './cache.js'
36
- import { xxh3hex } from '../util/index.js'
37
36
 
38
37
  /**
39
38
  * What a remote cache layer must provide — THE plugin seam for remote
40
- * caching. Core ships no wire client; a plugin's `cache` capability (or an
41
- * embedder via `RunOptions.remoteCache`) supplies an implementation speaking
39
+ * caching. Core ships no wire client; a plugin's `cache` hook returns one
40
+ * (or an embedder passes one as `RunOptions.remoteCache`), speaking
42
41
  * whatever protocol it wants, and `LayeredCache` owns everything else:
43
42
  * policy gating, in-flight dedup, remote provenance, and the never-fail
44
43
  * contract (implementations THROW on failure; LayeredCache degrades every
@@ -60,6 +59,11 @@ export interface RemoteCacheLayer {
60
59
  * dropped before it is printed.
61
60
  */
62
61
  readonly endpoint?: string
62
+ /**
63
+ * What the wire's credential allows, when less than both (a read-only
64
+ * token): core clears the run's remote axis it lacks. Omitted → both.
65
+ */
66
+ readonly access?: { readonly read?: boolean; readonly write?: boolean }
63
67
  /** Existence probe (drives the plan path's `--dry` remote prediction). */
64
68
  has(hash: string): Promise<boolean>
65
69
  /**
@@ -161,91 +165,6 @@ function batchOf(hashes: readonly string[]): { batch: string } {
161
165
  return { batch: `of ${hashes.length} artifact${hashes.length === 1 ? '' : 's'}` }
162
166
  }
163
167
 
164
- /**
165
- * The remote as an untrusted scope sees it: reads try the scope's key,
166
- * where a PR's own earlier runs left it, then the trusted one; writes go to the scope's key only. A scope's key is
167
- * derived from the task key, so every wire stores it unchanged and the
168
- * trusted keyspace never holds a scoped write. What a scoped read
169
- * returns is ingested under the TASK key, and ingest refuses an artifact
170
- * recording another key, so a derived-key collision degrades to a miss.
171
- */
172
- class ScopedRemote implements RemoteCacheLayer {
173
- readonly endpoint?: string
174
- readonly hasMany?: (hashes: readonly string[]) => Promise<Set<string> | null>
175
- /** Task keys a batch probe found under the trusted key alone: their GET skips the scope's. */
176
- private readonly trustedOnly = new Set<string>()
177
-
178
- constructor(
179
- private readonly remote: RemoteCacheLayer,
180
- private readonly scope: string,
181
- ) {
182
- if (remote.endpoint !== undefined) this.endpoint = remote.endpoint
183
- if (remote.hasMany !== undefined) this.hasMany = (hashes) => this.scopedHasMany(hashes)
184
- }
185
-
186
- private scoped(hash: string): string {
187
- return scopedKey(this.scope, hash)
188
- }
189
-
190
- async has(hash: string): Promise<boolean> {
191
- return this.fallThrough(
192
- () => this.remote.has(this.scoped(hash)),
193
- () => this.remote.has(hash),
194
- )
195
- }
196
-
197
- /**
198
- * The scope's answer, else the trusted one. A scope lookup that fails
199
- * still asks the trusted key, so a hit there is not lost; its error is
200
- * rethrown (and warned) when the trusted key fails or has nothing too.
201
- */
202
- private async fallThrough<T>(own: () => Promise<T>, trusted: () => Promise<T>): Promise<T> {
203
- let found: T
204
- try {
205
- found = await own()
206
- } catch (err) {
207
- const fallback = await trusted().catch(() => undefined)
208
- if (!fallback) throw err
209
- return fallback
210
- }
211
- return found || trusted()
212
- }
213
-
214
- private async scopedHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
215
- const scoped = hashes.map((h) => this.scoped(h))
216
- const [trusted, own] = await Promise.all([
217
- this.remote.hasMany!(hashes),
218
- this.remote.hasMany!(scoped),
219
- ])
220
- if (trusted == null || own == null) return null
221
- const found = new Set(trusted)
222
- for (let i = 0; i < hashes.length; i++) {
223
- if (own.has(scoped[i]!)) found.add(hashes[i]!)
224
- else if (trusted.has(hashes[i]!)) this.trustedOnly.add(hashes[i]!)
225
- }
226
- return found
227
- }
228
-
229
- async get(
230
- hash: string,
231
- ): Promise<{ body: Blob | Response; durationMs: number | undefined } | null> {
232
- if (this.trustedOnly.has(hash)) return this.remote.get(hash)
233
- return this.fallThrough(
234
- () => this.remote.get(this.scoped(hash)),
235
- () => this.remote.get(hash),
236
- )
237
- }
238
-
239
- put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void> {
240
- return this.remote.put(this.scoped(hash), body, meta)
241
- }
242
- }
243
-
244
- /** The key an untrusted scope stores `hash` under: task-key shaped, 16 hex. */
245
- function scopedKey(scope: string, hash: string): string {
246
- return xxh3hex(`vx-cache-scope\0${scope}\0${hash}`)
247
- }
248
-
249
168
  export interface LayeredCacheOptions {
250
169
  /**
251
170
  * Called for the remote failures the layer degrades to a miss, once per
@@ -312,17 +231,12 @@ export class LayeredCache implements CacheLayer {
312
231
  */
313
232
  private readonly reported = new Map<string, { cause: string; repeats: number }>()
314
233
 
315
- /** The remote as this run's scope sees it: `remote` itself when trusted. */
316
- private readonly wire: RemoteCacheLayer
317
-
318
234
  constructor(
319
235
  readonly local: Cache,
320
236
  private readonly remote: RemoteCacheLayer,
321
237
  private readonly options: LayeredCacheOptions = {},
322
238
  ) {
323
239
  this.policy = options.policy ?? FULL_CACHE_POLICY
324
- const scope = this.policy.remoteScope
325
- this.wire = scope === undefined ? remote : new ScopedRemote(remote, scope)
326
240
  this.endpoint = printableEndpoint(remote.endpoint)
327
241
  }
328
242
 
@@ -349,11 +263,11 @@ export class LayeredCache implements CacheLayer {
349
263
  * degrades to "no batch info" and the caller falls back to per-hash.
350
264
  */
351
265
  async remoteHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
352
- if (!this.policy.remoteRead || this.wire.hasMany === undefined) return null
266
+ if (!this.policy.remoteRead || this.remote.hasMany === undefined) return null
353
267
  try {
354
268
  // `return await`, deliberately: the catch below is the never-fail
355
269
  // contract, and a returned promise's rejection would sail past it.
356
- const found: unknown = await this.wire.hasMany(hashes)
270
+ const found: unknown = await this.remote.hasMany(hashes)
357
271
  if (found !== null && found !== undefined && !(found instanceof Set)) {
358
272
  this.reportRemoteError(
359
273
  'probe',
@@ -394,7 +308,7 @@ export class LayeredCache implements CacheLayer {
394
308
  return this.asRemote(hash, localHit)
395
309
  }
396
310
 
397
- // Remote reads disabled (e.g. `--cache=remote:`): a local miss is a
311
+ // Remote reads disabled (e.g. `--cache-policy=remote:`): a local miss is a
398
312
  // real miss; never touch the remote layer.
399
313
  if (!this.policy.remoteRead) return null
400
314
 
@@ -410,7 +324,7 @@ export class LayeredCache implements CacheLayer {
410
324
  // the artifact + index row this pull just wrote exist regardless of
411
325
  // policy, and the gate ("don't serve hits from the pre-existing local
412
326
  // cache") must not discard them. Going through the gated `get` here
413
- // made `--cache=local:,remote:rw` download the artifact, throw the hit
327
+ // made `--cache-policy=local:,remote:rw` download the artifact, throw the hit
414
328
  // away, re-execute and re-upload — on every run, forever.
415
329
  const materialized = await this.local.getIngested(hash)
416
330
  if (!materialized) return null
@@ -437,7 +351,7 @@ export class LayeredCache implements CacheLayer {
437
351
  if ((await this.local.has(hash)) === 'local') return 'local'
438
352
  if (!this.policy.remoteRead) return null
439
353
  try {
440
- return (await this.wire.has(hash)) ? 'remote' : null
354
+ return (await this.remote.has(hash)) ? 'remote' : null
441
355
  } catch (err) {
442
356
  this.reportRemoteError('probe', hash, err)
443
357
  return null
@@ -474,7 +388,7 @@ export class LayeredCache implements CacheLayer {
474
388
  const fetchStart = performance.now()
475
389
  let remoteResult: unknown
476
390
  try {
477
- remoteResult = await this.wire.get(hash)
391
+ remoteResult = await this.remote.get(hash)
478
392
  } catch (err) {
479
393
  this.reportRemoteError('download', hash, err)
480
394
  return false
@@ -560,11 +474,11 @@ export class LayeredCache implements CacheLayer {
560
474
  // queued job holds a path, not a buffer, a plugin that streams the Blob
561
475
  // never holds it whole, and every read of it sees the same bytes though
562
476
  // a re-save of the key renames others over the live name. If a
563
- // concurrent `vx cache prune` removed the artifact first the pin throws
477
+ // concurrent `vx cache clean` removed the artifact first the pin throws
564
478
  // and this upload is skipped — the never-fail contract.
565
479
  const hash = args.hash
566
480
  const durationMs = args.entry.durationMs
567
- // Local writes disabled (`--cache=local:,remote:rw`): there is no
481
+ // Local writes disabled (`--cache-policy=local:,remote:rw`): there is no
568
482
  // on-disk artifact to read later, so the bytes must be packed NOW,
569
483
  // while this task's output files are still on disk. Deferring THIS
570
484
  // read would pack whatever the tree happens to hold when the job
@@ -587,7 +501,7 @@ export class LayeredCache implements CacheLayer {
587
501
  let body: Blob
588
502
  if (packed !== undefined) body = new Blob([packed])
589
503
  else ({ body, release } = this.local.pinArtifact(hash))
590
- await this.wire.put(hash, body, { durationMs })
504
+ await this.remote.put(hash, body, { durationMs })
591
505
  tally.count++
592
506
  tally.bytes += body.size
593
507
  tally.ms += Math.round(performance.now() - start)