@vzn/vx 0.0.636 → 0.0.638

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +246 -213
  3. package/docs/cli.md +791 -705
  4. package/docs/execution.md +68 -76
  5. package/docs/features.md +47 -42
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +392 -205
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +4 -4
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +28 -13
  19. package/src/bin.ts +30 -9
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +124 -53
  22. package/src/cache/chained-cache.ts +4 -0
  23. package/src/cache/git-inputs.ts +277 -63
  24. package/src/cache/index.ts +15 -1
  25. package/src/cache/inputs.ts +102 -36
  26. package/src/cache/key-fold.ts +8 -7
  27. package/src/cache/layer.ts +15 -18
  28. package/src/cache/layered-cache.ts +20 -102
  29. package/src/cache/plugin-cache.ts +248 -0
  30. package/src/cache/policy.ts +32 -27
  31. package/src/cache/run-history.ts +104 -14
  32. package/src/cache/run-store.ts +77 -0
  33. package/src/cache/schema.ts +58 -31
  34. package/src/cache/scoped-cache.ts +237 -0
  35. package/src/cli/cache.ts +173 -34
  36. package/src/cli/completions.ts +11 -7
  37. package/src/cli/core-alias.ts +1 -1
  38. package/src/cli/foreign-flags.ts +13 -12
  39. package/src/cli/help.ts +110 -70
  40. package/src/cli/index.ts +67 -24
  41. package/src/cli/info.ts +43 -27
  42. package/src/cli/init.ts +115 -407
  43. package/src/cli/last.ts +157 -9
  44. package/src/cli/lock.ts +356 -122
  45. package/src/cli/plan-format.ts +7 -5
  46. package/src/cli/plugin-commands.ts +81 -14
  47. package/src/cli/plugin-templates.ts +11 -11
  48. package/src/cli/run.ts +116 -160
  49. package/src/cli/select.ts +50 -47
  50. package/src/cli/show.ts +83 -59
  51. package/src/cli/task-verb.ts +6 -13
  52. package/src/cli/upgrade.ts +415 -149
  53. package/src/cli/watch-cycle.ts +9 -2
  54. package/src/cli/watch-filter.ts +39 -61
  55. package/src/cli/watch-fs.ts +29 -16
  56. package/src/cli/watch-judge.ts +18 -4
  57. package/src/cli/watch-set.ts +16 -42
  58. package/src/cli/watch.ts +227 -230
  59. package/src/cli/why.ts +38 -18
  60. package/src/cli/workspace-config.ts +33 -70
  61. package/src/config.ts +169 -70
  62. package/src/exec/env.ts +1 -31
  63. package/src/exec/executor.ts +1 -5
  64. package/src/exec/kill-tree.ts +2 -1
  65. package/src/exec/sandbox-runtime.ts +12 -1
  66. package/src/exec/sandbox-violations.ts +9 -1
  67. package/src/graph/dependency-spec.ts +1 -1
  68. package/src/graph/index.ts +1 -0
  69. package/src/graph/priorities.ts +1 -1
  70. package/src/graph/scheduler.ts +27 -7
  71. package/src/graph/task-graph.ts +76 -24
  72. package/src/handoff.ts +169 -0
  73. package/src/index.ts +35 -58
  74. package/src/orchestrator/affected-tasks.ts +15 -8
  75. package/src/orchestrator/command-windows.ts +44 -0
  76. package/src/orchestrator/deferred-outputs.ts +12 -10
  77. package/src/orchestrator/doctor.ts +98 -110
  78. package/src/orchestrator/download-policy.ts +3 -3
  79. package/src/orchestrator/events.ts +19 -3
  80. package/src/orchestrator/excluded-keys.ts +0 -2
  81. package/src/orchestrator/execute-task.ts +147 -69
  82. package/src/orchestrator/failure-mode.ts +19 -10
  83. package/src/orchestrator/fingerprint-watch.ts +18 -15
  84. package/src/orchestrator/forecast.ts +10 -2
  85. package/src/orchestrator/framed-output.ts +9 -5
  86. package/src/orchestrator/graph-stage.ts +5 -5
  87. package/src/orchestrator/history.ts +126 -30
  88. package/src/orchestrator/hit-restore.ts +15 -3
  89. package/src/orchestrator/index.ts +20 -5
  90. package/src/orchestrator/local-shortcircuit.ts +2 -1
  91. package/src/orchestrator/lockfile-claim.ts +79 -20
  92. package/src/orchestrator/logger.ts +80 -41
  93. package/src/orchestrator/metrics.ts +78 -9
  94. package/src/orchestrator/miss-save.ts +3 -0
  95. package/src/orchestrator/options.ts +42 -29
  96. package/src/orchestrator/output-host.ts +72 -0
  97. package/src/orchestrator/plan.ts +6 -8
  98. package/src/orchestrator/plugin-host.ts +100 -66
  99. package/src/orchestrator/plugin.ts +173 -67
  100. package/src/orchestrator/prepare.ts +163 -78
  101. package/src/orchestrator/projects.ts +105 -99
  102. package/src/orchestrator/remote-prefetch.ts +11 -6
  103. package/src/orchestrator/run-context.ts +25 -6
  104. package/src/orchestrator/run-failures.ts +17 -8
  105. package/src/orchestrator/run-lock.ts +1 -1
  106. package/src/orchestrator/run-records.ts +1 -1
  107. package/src/orchestrator/run-report.ts +9 -9
  108. package/src/orchestrator/run-settings.ts +48 -0
  109. package/src/orchestrator/run.ts +244 -119
  110. package/src/orchestrator/sandbox-fix.ts +39 -20
  111. package/src/orchestrator/sandbox-request.ts +84 -13
  112. package/src/orchestrator/signals.ts +1 -1
  113. package/src/orchestrator/stable-keys.ts +13 -11
  114. package/src/orchestrator/status-line.ts +49 -7
  115. package/src/orchestrator/summary.ts +115 -106
  116. package/src/orchestrator/task-hash.ts +12 -83
  117. package/src/orchestrator/telemetry.ts +4 -4
  118. package/src/settings.ts +428 -0
  119. package/src/{orchestrator → util}/colors.ts +5 -0
  120. package/src/util/config-frame.ts +31 -2
  121. package/src/util/errors.ts +33 -1
  122. package/src/util/essential-env.ts +31 -0
  123. package/src/util/hide-env.ts +107 -0
  124. package/src/util/index.ts +16 -3
  125. package/src/util/paths.ts +10 -0
  126. package/src/util/refusal-notice.ts +111 -0
  127. package/src/util/report.ts +31 -0
  128. package/src/util/settle.ts +49 -30
  129. package/src/util/size.ts +23 -4
  130. package/src/util/timing.ts +3 -2
  131. package/src/util/ui.ts +186 -0
  132. package/src/util/ulid.ts +1 -1
  133. package/src/util/verbs.ts +0 -15
  134. package/src/workspace/affected.ts +84 -232
  135. package/src/workspace/config-eval.ts +15 -20
  136. package/src/workspace/config-imports.ts +78 -3
  137. package/src/workspace/config-schema.ts +209 -268
  138. package/src/workspace/dependency-rules.ts +65 -0
  139. package/src/workspace/filter.ts +2 -2
  140. package/src/workspace/fingerprint.ts +11 -121
  141. package/src/workspace/index.ts +20 -27
  142. package/src/workspace/load-reads.ts +4 -4
  143. package/src/workspace/lockfile.ts +96 -140
  144. package/src/workspace/migration.ts +307 -215
  145. package/src/workspace/package-graph.ts +30 -216
  146. package/src/workspace/project-loader.ts +80 -285
  147. package/src/workspace/repo-id.ts +2 -1
  148. package/src/workspace/workspace.ts +363 -831
  149. package/src/cache/config-evals.ts +0 -130
  150. package/src/workspace/config-cache.ts +0 -661
  151. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -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,16 @@ 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/**',
83
- '**/*.tsbuildinfo',
84
- '**/vx-lock.json',
82
+ // vx's machine-local state; the workspace config beside it is a source.
83
+ '**/.vx/cache/**',
84
+ '**/.vx/projects.lock.json',
85
+ // `vx lock`'s review copy of a project's resolved config: the config is
86
+ // keyed already, and a re-lock must not move every `**` reader's key.
87
+ '**/vx.lock.json',
88
+ // What `vx lock` and `vx init` write beside the lock: a re-lock or init
89
+ // must not select every `**` reader under --affected.
90
+ '**/.vx/.gitattributes',
91
+ '**/.vx/.gitignore',
85
92
  // `bun build --compile` writes a transient `.<hash>-<n>.bun-build` intermediate
86
93
  // in the cwd. It never rests on disk, so it can't be a real input — but a
87
94
  // broad `inputs.files: ['**/*']` on a compile task would try to hash the temp
@@ -313,7 +320,13 @@ function resolveWorkspaceFiles(args: {
313
320
  let isPositive: ((rel: string) => boolean) | undefined
314
321
  const excludedByDeclaration = (rel: string): boolean =>
315
322
  (isExcluded ??= anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)]))(rel)
316
- const own = (rel: string): boolean => ownOutput(rel) || ownProject(rel)
323
+ // A committed file stays an input whatever output reaches it (`committedFiles`).
324
+ let committed: ((abs: string) => boolean) | undefined
325
+ const own = (rel: string): boolean =>
326
+ (ownOutput(rel) || ownProject(rel)) &&
327
+ !(committed ??= committedFiles(args.gitFilesCache, args.workspaceRoot))(
328
+ path.join(args.workspaceRoot, rel),
329
+ )
317
330
  const listing: InputListing = {
318
331
  root: args.workspaceRoot,
319
332
  listed: gitFiles,
@@ -667,11 +680,13 @@ async function resolveRuntimeValues(
667
680
  /**
668
681
  * What no output glob reaches. An output is otherwise taken as written —
669
682
  * `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).
683
+ * apply here — but no task produces these, and a root project declaring
684
+ * `**` would otherwise wipe its repository and the cache it is restoring
685
+ * from (2026-09-16). `.vx` whole and each `vx.lock.json` are vx's own: as
686
+ * outputs, the clean deleted an uncommitted workspace config and a hit
687
+ * restored a lock or ignore file the key does not see over the current one.
673
688
  */
674
- const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**']
689
+ const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**', '**/vx.lock.json']
675
690
 
676
691
  /**
677
692
  * What a set of output globs may not reach: `OUTPUT_NEVER`, and every
@@ -719,6 +734,20 @@ export async function resolveOutputs(args: {
719
734
  return containedIn(args.projectDir, scanned).sort()
720
735
  }
721
736
 
737
+ /**
738
+ * Whether git tracks a file under `dir`. No clean deletes a committed (or
739
+ * `git add`ed) file an output glob reaches (owner, 2026-10-14: "never
740
+ * delete committed file"); the task may rewrite it and a restore writes the
741
+ * bytes it left. It stays in the task's key, so those bytes follow from
742
+ * the key. Turbo and Nx never clean an output at all.
743
+ */
744
+ export function committedFiles(
745
+ cache: GitFilesCache | undefined,
746
+ dir: string,
747
+ ): (abs: string) => boolean {
748
+ return (cache ?? new GitFilesCache()).committedIn(dir)
749
+ }
750
+
722
751
  /**
723
752
  * The literal head of each output glob, walked: the scan follows a link
724
753
  * there (`dist -> ../elsewhere` for `dist/**`) and only there. A link to a
@@ -838,6 +867,7 @@ export async function cleanOutputs(args: {
838
867
  projectDir: string
839
868
  outputs: readonly string[]
840
869
  nestedProjectDirs: string[]
870
+ committed?: (abs: string) => boolean
841
871
  /**
842
872
  * What `resolveOutputs` returned for these same arguments, when the
843
873
  * caller walked the tree with no await since: a hit that found its tree
@@ -846,15 +876,20 @@ export async function cleanOutputs(args: {
846
876
  */
847
877
  resolved?: readonly string[] | undefined
848
878
  }): Promise<string[]> {
849
- const files = args.resolved ?? (await resolveOutputs(args))
879
+ const committed = args.committed ?? committedFiles(undefined, args.projectDir)
880
+ const matched = args.resolved ?? (await resolveOutputs(args))
850
881
  // `force: true` makes rm tolerate ENOENT (e.g. when two output
851
882
  // globs overlap and a sibling already deleted a path mid-iteration).
852
883
  // A symlink is unlinked, never followed.
853
- const removed = await removeAll(files, args.projectDir)
884
+ const removed = await removeAll(
885
+ matched.filter((f) => !committed(f)),
886
+ args.projectDir,
887
+ )
854
888
  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))
889
+ // Project-relative posix paths the outputs reach, for the caller's
890
+ // GitFilesCache.markOutputsChanged: a committed one kept here is the
891
+ // task's to rewrite or remove, so its index OID no longer vouches for it.
892
+ return matched.map((f) => relPosix(args.projectDir, f))
858
893
  }
859
894
 
860
895
  /**
@@ -868,8 +903,10 @@ export async function cleanOutputPaths(args: {
868
903
  projectDir: string
869
904
  rels: readonly string[]
870
905
  outputs: readonly string[]
906
+ committed?: (abs: string) => boolean
871
907
  }): Promise<void> {
872
- const files = args.rels.map((r) => path.resolve(args.projectDir, r))
908
+ const committed = args.committed ?? committedFiles(undefined, args.projectDir)
909
+ const files = args.rels.map((r) => path.resolve(args.projectDir, r)).filter((f) => !committed(f))
873
910
  await pruneEmptiedDirs(
874
911
  args.projectDir,
875
912
  await removeAll(files, args.projectDir),
@@ -1179,16 +1216,21 @@ export async function resolveWorkspaceOutputs(args: {
1179
1216
  export async function cleanWorkspaceOutputs(args: {
1180
1217
  workspaceRoot: string
1181
1218
  outputs: readonly string[]
1219
+ committed?: (abs: string) => boolean
1182
1220
  /** `cleanOutputs`'s `resolved`, from `resolveWorkspaceOutputs`. */
1183
1221
  resolved?: readonly string[] | undefined
1184
1222
  }): Promise<string[]> {
1185
- const files = args.resolved ?? (await resolveWorkspaceOutputs(args))
1223
+ const committed = args.committed ?? committedFiles(undefined, args.workspaceRoot)
1224
+ const matched = args.resolved ?? (await resolveWorkspaceOutputs(args))
1186
1225
  await pruneEmptiedDirs(
1187
1226
  args.workspaceRoot,
1188
- await removeAll(files, args.workspaceRoot),
1227
+ await removeAll(
1228
+ matched.filter((f) => !committed(f)),
1229
+ args.workspaceRoot,
1230
+ ),
1189
1231
  pruneScope(args.workspaceRoot, args.outputs),
1190
1232
  )
1191
- return files.map((f) => relPosix(args.workspaceRoot, f))
1233
+ return matched.map((f) => relPosix(args.workspaceRoot, f))
1192
1234
  }
1193
1235
 
1194
1236
  function realOrNull(p: string): string | null {
@@ -1380,6 +1422,12 @@ interface FilesPlan {
1380
1422
  * `package.json`), so each is matched once per plan.
1381
1423
  */
1382
1424
  verdicts: Map<string, boolean>
1425
+ /**
1426
+ * Whether a path is an input but for the task's own outputs: one git
1427
+ * tracks stays an input (`committedFiles`), so the key sees an edit to a
1428
+ * committed file an output glob reaches.
1429
+ */
1430
+ ownOnly: Map<string, boolean>
1383
1431
  }
1384
1432
 
1385
1433
  /**
@@ -1423,6 +1471,7 @@ function filesPlan(
1423
1471
  isPositive: anyTaskGlob(asTrees(positive)),
1424
1472
  ...reachOf(positive),
1425
1473
  verdicts: new Map<string, boolean>(),
1474
+ ownOnly: new Map<string, boolean>(),
1426
1475
  }
1427
1476
  filesPlans.set(key, plan)
1428
1477
  return plan
@@ -1432,8 +1481,8 @@ function filesPlan(
1432
1481
  * Whether a changed path is one of a cached task's declared file inputs:
1433
1482
  * `projectRel` (the path relative to the project that owns it) against
1434
1483
  * `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).
1484
+ * `workspaceFiles`; null skips that half, by the globs and `!` exclusions (a path under
1485
+ * the task's own outputs counts: a committed one is keyed). `--affected` seeds a task with it (owner, 2026-10-04).
1437
1486
  */
1438
1487
  export function declaresInput(
1439
1488
  cache: CacheConfig,
@@ -1441,13 +1490,15 @@ export function declaresInput(
1441
1490
  workspaceRel: string | null,
1442
1491
  ): boolean {
1443
1492
  if (projectRel !== null) {
1493
+ // A changed path under the task's own outputs is a committed file
1494
+ // (`git diff` lists only what git tracks) or a stray `git status`
1495
+ // lists: either way it may be an input, so it counts.
1444
1496
  const plan = filesPlan(cache.inputs.files, cache.outputs.files)
1445
- if (plan !== null && inPlan(plan, projectRel)) return true
1497
+ if (plan !== null && (inPlan(plan, projectRel) || ownOnly(plan, projectRel))) return true
1446
1498
  }
1447
1499
  const ws = cache.inputs.workspaceFiles
1448
1500
  if (workspaceRel === null || ws === undefined || ws.length === 0) return false
1449
- const matcher = workspaceMatcher(ws, cache.outputs.workspaceFiles ?? [])
1450
- return matcher(workspaceRel)
1501
+ return workspaceMatcher(ws)(workspaceRel)
1451
1502
  }
1452
1503
 
1453
1504
  /**
@@ -1471,20 +1522,26 @@ function inPlan(plan: FilesPlan, rel: string): boolean {
1471
1522
  return input
1472
1523
  }
1473
1524
 
1525
+ /** `FilesPlan.ownOnly`, memoised: asked only of a path `inPlan` refused. */
1526
+ function ownOnly(plan: FilesPlan, rel: string): boolean {
1527
+ let own = plan.ownOnly.get(rel)
1528
+ if (own === undefined) {
1529
+ own = plan.ownOutput(rel) && plan.isPositive(rel) && !plan.isExcluded(rel)
1530
+ plan.ownOnly.set(rel, own)
1531
+ }
1532
+ return own
1533
+ }
1534
+
1474
1535
  const workspaceMatchers = new Map<string, (rel: string) => boolean>()
1475
1536
 
1476
- function workspaceMatcher(
1477
- decl: readonly string[],
1478
- ownOutputs: readonly string[],
1479
- ): (rel: string) => boolean {
1480
- const key = JSON.stringify([decl, ownOutputs])
1537
+ function workspaceMatcher(decl: readonly string[]): (rel: string) => boolean {
1538
+ const key = JSON.stringify(decl)
1481
1539
  let m = workspaceMatchers.get(key)
1482
1540
  if (m !== undefined) return m
1483
1541
  const { positive, negative } = splitNegations(decl)
1484
1542
  const isPositive = anyTaskGlob(asTrees(positive))
1485
1543
  const isExcluded = anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)])
1486
- const ownOutput = outputMatcher(ownOutputs)
1487
- m = (rel) => isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
1544
+ m = (rel) => isPositive(rel) && !isExcluded(rel)
1488
1545
  workspaceMatchers.set(key, m)
1489
1546
  return m
1490
1547
  }
@@ -1521,7 +1578,7 @@ async function resolveFiles(
1521
1578
  if (memo !== undefined && memo.snapshot === gitFiles) {
1522
1579
  return {
1523
1580
  files: [...memo.result],
1524
- listing: listingFor(args.projectDir, gitFiles, plan, nested, ownWsOutput),
1581
+ listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
1525
1582
  }
1526
1583
  }
1527
1584
  }
@@ -1566,14 +1623,17 @@ async function resolveFiles(
1566
1623
  : undefined
1567
1624
  const candidates: string[] = []
1568
1625
  const verdicts = plan.verdicts
1626
+ const committed = committedFiles(args.gitFilesCache, args.projectDir)
1569
1627
  for (const rel of gitFiles) {
1570
1628
  let input = verdicts.get(rel)
1571
1629
  if (input === undefined) {
1572
1630
  input = isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
1573
1631
  verdicts.set(rel, input)
1574
1632
  }
1575
- if (!input || nested(rel) || ownWsOutput(rel)) continue
1576
- candidates.push(base === undefined ? path.resolve(args.projectDir, rel) : base + rel)
1633
+ if ((!input && !ownOnly(plan, rel)) || nested(rel) || ownWsOutput(rel)) continue
1634
+ const abs = base === undefined ? path.resolve(args.projectDir, rel) : base + rel
1635
+ if (!input && !committed(abs)) continue
1636
+ candidates.push(abs)
1577
1637
  }
1578
1638
  const unmatchedLiterals = unanswered(plan.literals, gitFiles)
1579
1639
  if (unmatchedLiterals.size > 0) {
@@ -1601,21 +1661,27 @@ async function resolveFiles(
1601
1661
  args.projectFilesCache?.set(memoKey, { snapshot: gitFiles, result: resolved })
1602
1662
  return {
1603
1663
  files: [...resolved],
1604
- listing: listingFor(args.projectDir, gitFiles, plan, nested, ownWsOutput),
1664
+ listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
1605
1665
  }
1606
1666
  }
1607
1667
 
1608
1668
  function listingFor(
1609
- root: string,
1669
+ args: { projectDir: string; gitFilesCache?: GitFilesCache | undefined },
1610
1670
  listed: readonly string[],
1611
1671
  plan: FilesPlan,
1612
1672
  nested: (rel: string) => boolean,
1613
1673
  ownWsOutput: (rel: string) => boolean,
1614
1674
  ): InputListing {
1675
+ const root = args.projectDir
1676
+ let committed: ((abs: string) => boolean) | undefined
1615
1677
  return {
1616
1678
  root,
1617
1679
  listed,
1618
- isInput: (rel) => inPlan(plan, rel) && !ownWsOutput(rel),
1680
+ isInput: (rel) =>
1681
+ (inPlan(plan, rel) ||
1682
+ (ownOnly(plan, rel) &&
1683
+ (committed ??= committedFiles(args.gitFilesCache, root))(path.join(root, rel)))) &&
1684
+ !ownWsOutput(rel),
1619
1685
  nested,
1620
1686
  prefixes: plan.prefixes,
1621
1687
  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
@@ -236,7 +221,7 @@ export interface InvocationRecord {
236
221
  command: string
237
222
  /** JSON-serialized `string[]` of the requested task names. */
238
223
  requestedTasks: string
239
- /** Compact policy flags, e.g. `'lR,lW,rR,rW'` (an axis omitted = off). */
224
+ /** The `--cache-policy` spec, e.g. `'local:rw,remote:r'` (a flag omitted = off). */
240
225
  cachePolicy: string
241
226
  concurrency: number
242
227
  flow: 'focused' | 'broad' | null
@@ -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.
@@ -559,6 +544,12 @@ export interface CacheLayer {
559
544
  * batch omits it and the prefetch pass falls back to per-hash GETs.
560
545
  */
561
546
  remoteHasMany?(hashes: readonly string[]): Promise<Set<string> | null>
547
+ /**
548
+ * The subset of `hashes` the local store already holds. The prefetch
549
+ * pass probes the remote only for the rest, so a run the local cache
550
+ * answers makes no remote call. Absent reads as "none held".
551
+ */
552
+ localHasMany?(hashes: readonly string[]): Promise<Set<string>>
562
553
  /**
563
554
  * Record that the remote has NO artifact for each of `hashes` (from a
564
555
  * batch probe), so a later `get`/`prefetch` short-circuits to a miss with
@@ -702,6 +693,12 @@ export interface CacheLayer {
702
693
  * Only computed on the miss/save path — never on a hit.
703
694
  */
704
695
  inputComponents?: readonly TaskInputRow[]
696
+ /**
697
+ * For a sandboxed task: whether it could read a canonical path. A
698
+ * symlinked output is packed as its target's bytes, outside the
699
+ * sandbox, so one whose target the task could not read is refused.
700
+ */
701
+ readable?: ((real: string) => boolean) | undefined
705
702
  }): Promise<void>
706
703
  /**
707
704
  * 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
 
@@ -340,6 +254,10 @@ export class LayeredCache implements CacheLayer {
340
254
  return this.pullFromRemote(hash, ctx)
341
255
  }
342
256
 
257
+ localHasMany(hashes: readonly string[]): Promise<Set<string>> {
258
+ return this.local.localHasMany(hashes)
259
+ }
260
+
343
261
  /**
344
262
  * Batch existence probe over the remote layer — the subset of `hashes`
345
263
  * present remotely, in one round-trip, or `null` when the remote can't
@@ -349,11 +267,11 @@ export class LayeredCache implements CacheLayer {
349
267
  * degrades to "no batch info" and the caller falls back to per-hash.
350
268
  */
351
269
  async remoteHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
352
- if (!this.policy.remoteRead || this.wire.hasMany === undefined) return null
270
+ if (!this.policy.remoteRead || this.remote.hasMany === undefined) return null
353
271
  try {
354
272
  // `return await`, deliberately: the catch below is the never-fail
355
273
  // contract, and a returned promise's rejection would sail past it.
356
- const found: unknown = await this.wire.hasMany(hashes)
274
+ const found: unknown = await this.remote.hasMany(hashes)
357
275
  if (found !== null && found !== undefined && !(found instanceof Set)) {
358
276
  this.reportRemoteError(
359
277
  'probe',
@@ -394,7 +312,7 @@ export class LayeredCache implements CacheLayer {
394
312
  return this.asRemote(hash, localHit)
395
313
  }
396
314
 
397
- // Remote reads disabled (e.g. `--cache=remote:`): a local miss is a
315
+ // Remote reads disabled (e.g. `--cache-policy=remote:`): a local miss is a
398
316
  // real miss; never touch the remote layer.
399
317
  if (!this.policy.remoteRead) return null
400
318
 
@@ -410,7 +328,7 @@ export class LayeredCache implements CacheLayer {
410
328
  // the artifact + index row this pull just wrote exist regardless of
411
329
  // policy, and the gate ("don't serve hits from the pre-existing local
412
330
  // cache") must not discard them. Going through the gated `get` here
413
- // made `--cache=local:,remote:rw` download the artifact, throw the hit
331
+ // made `--cache-policy=local:,remote:rw` download the artifact, throw the hit
414
332
  // away, re-execute and re-upload — on every run, forever.
415
333
  const materialized = await this.local.getIngested(hash)
416
334
  if (!materialized) return null
@@ -437,7 +355,7 @@ export class LayeredCache implements CacheLayer {
437
355
  if ((await this.local.has(hash)) === 'local') return 'local'
438
356
  if (!this.policy.remoteRead) return null
439
357
  try {
440
- return (await this.wire.has(hash)) ? 'remote' : null
358
+ return (await this.remote.has(hash)) ? 'remote' : null
441
359
  } catch (err) {
442
360
  this.reportRemoteError('probe', hash, err)
443
361
  return null
@@ -474,7 +392,7 @@ export class LayeredCache implements CacheLayer {
474
392
  const fetchStart = performance.now()
475
393
  let remoteResult: unknown
476
394
  try {
477
- remoteResult = await this.wire.get(hash)
395
+ remoteResult = await this.remote.get(hash)
478
396
  } catch (err) {
479
397
  this.reportRemoteError('download', hash, err)
480
398
  return false
@@ -560,11 +478,11 @@ export class LayeredCache implements CacheLayer {
560
478
  // queued job holds a path, not a buffer, a plugin that streams the Blob
561
479
  // never holds it whole, and every read of it sees the same bytes though
562
480
  // 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
481
+ // concurrent `vx cache clean` removed the artifact first the pin throws
564
482
  // and this upload is skipped — the never-fail contract.
565
483
  const hash = args.hash
566
484
  const durationMs = args.entry.durationMs
567
- // Local writes disabled (`--cache=local:,remote:rw`): there is no
485
+ // Local writes disabled (`--cache-policy=local:,remote:rw`): there is no
568
486
  // on-disk artifact to read later, so the bytes must be packed NOW,
569
487
  // while this task's output files are still on disk. Deferring THIS
570
488
  // read would pack whatever the tree happens to hold when the job
@@ -587,7 +505,7 @@ export class LayeredCache implements CacheLayer {
587
505
  let body: Blob
588
506
  if (packed !== undefined) body = new Blob([packed])
589
507
  else ({ body, release } = this.local.pinArtifact(hash))
590
- await this.wire.put(hash, body, { durationMs })
508
+ await this.remote.put(hash, body, { durationMs })
591
509
  tally.count++
592
510
  tally.bytes += body.size
593
511
  tally.ms += Math.round(performance.now() - start)