@vzn/vx 0.0.640 → 0.0.642

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 (76) hide show
  1. package/launcher.sh +9 -2
  2. package/package.json +5 -12
  3. package/skills/vx/SKILL.md +4 -4
  4. package/src/bin.ts +8 -6
  5. package/src/cache/archive.ts +735 -107
  6. package/src/cache/cache.ts +133 -29
  7. package/src/cache/fold-memo.ts +58 -0
  8. package/src/cache/git-inputs.ts +213 -31
  9. package/src/cache/index.ts +9 -2
  10. package/src/cache/inputs.ts +244 -108
  11. package/src/cache/key-fold.ts +90 -69
  12. package/src/cache/layer.ts +9 -7
  13. package/src/cache/layered-cache.ts +74 -9
  14. package/src/cache/output-index.ts +25 -4
  15. package/src/cache/remote-lane.ts +27 -10
  16. package/src/cache/schema.ts +13 -3
  17. package/src/cache/scoped-cache.ts +11 -3
  18. package/src/cache/umask.ts +29 -0
  19. package/src/cli/docs.ts +71 -7
  20. package/src/cli/help.ts +11 -5
  21. package/src/cli/plan-format.ts +4 -1
  22. package/src/cli/run.ts +10 -3
  23. package/src/cli/select.ts +2 -0
  24. package/src/cli/upgrade.ts +37 -2
  25. package/src/cli/watch-filter.ts +1 -6
  26. package/src/cli/watch-fs.ts +2 -1
  27. package/src/exec/runner.ts +5 -0
  28. package/src/exec/sandbox-runtime.ts +81 -1
  29. package/src/graph/priorities.ts +14 -1
  30. package/src/handoff.ts +5 -0
  31. package/src/history/history.ts +43 -33
  32. package/src/index.ts +11 -1
  33. package/src/orchestrator/deferred-outputs.ts +3 -2
  34. package/src/orchestrator/execute-task.ts +20 -14
  35. package/src/orchestrator/hit-restore.ts +111 -29
  36. package/src/orchestrator/index.ts +1 -0
  37. package/src/orchestrator/local-shortcircuit.ts +8 -0
  38. package/src/orchestrator/lockfile-claim.ts +54 -8
  39. package/src/orchestrator/miss-save.ts +27 -17
  40. package/src/orchestrator/output-host.ts +9 -1
  41. package/src/orchestrator/plan.ts +4 -0
  42. package/src/orchestrator/plugin-host.ts +35 -5
  43. package/src/orchestrator/plugin.ts +29 -3
  44. package/src/orchestrator/prepare.ts +74 -5
  45. package/src/orchestrator/projects.ts +6 -1
  46. package/src/orchestrator/run-report.ts +30 -9
  47. package/src/orchestrator/run.ts +62 -38
  48. package/src/orchestrator/sandbox-request.ts +1 -48
  49. package/src/orchestrator/save-lane.ts +17 -10
  50. package/src/orchestrator/signals.ts +7 -10
  51. package/src/orchestrator/stable-keys.ts +83 -25
  52. package/src/orchestrator/task-hash.ts +46 -12
  53. package/src/report/events.ts +12 -0
  54. package/src/report/framed-output.ts +2 -1
  55. package/src/report/logger.ts +58 -15
  56. package/src/report/status-line.ts +45 -4
  57. package/src/report/summary.ts +11 -6
  58. package/src/util/errors.ts +1 -1
  59. package/src/util/hidden-prompt.ts +74 -0
  60. package/src/util/index.ts +13 -0
  61. package/src/util/paths.ts +58 -5
  62. package/src/util/secrets.ts +150 -0
  63. package/src/workspace/affected.ts +63 -13
  64. package/src/workspace/config-eval.ts +2 -2
  65. package/src/workspace/config-schema.ts +37 -3
  66. package/src/workspace/project-loader.ts +6 -4
  67. package/src/workspace/workspace.ts +17 -11
  68. package/docs/caching.md +0 -2352
  69. package/docs/cli.md +0 -3280
  70. package/docs/execution.md +0 -675
  71. package/docs/features.md +0 -178
  72. package/docs/patterns.md +0 -219
  73. package/docs/schema.md +0 -2461
  74. package/docs/security.md +0 -252
  75. package/src/cli/docs-corpus.ts +0 -21
  76. package/src/cli/docs-text.d.ts +0 -5
@@ -28,10 +28,12 @@ import {
28
28
  isLiteralPattern,
29
29
  normalizeGlob,
30
30
  outputMatcher,
31
+ outputNever,
31
32
  realpathOf,
32
33
  relPosix,
33
34
  shellArgv,
34
35
  slashBraceExpansions,
36
+ braceAlternatives,
35
37
  splitNegations,
36
38
  staticPrefix,
37
39
  stripTrailingSlash,
@@ -64,6 +66,11 @@ export {
64
66
  // would. (Literal pattern, not the workspace `LOCKFILE_NAME` constant:
65
67
  // cache is a leaf module and must not import from workspace.)
66
68
  //
69
+ // Relative to the project, so only where vx writes them: its root's `.vx`
70
+ // and the `vx.lock.json` beside its config. Matched at any depth, a
71
+ // project's own `src/.vx/tmp/*` or `src/sub/vx.lock.json` keyed nothing,
72
+ // and an edit to one was a hit (2026-10-17).
73
+ //
67
74
  // `node_modules` is not here: the enumeration drops an untracked file
68
75
  // under one (`isInstalledPath`), and a tracked one is a source.
69
76
  const ALWAYS_IGNORE = [
@@ -78,34 +85,27 @@ const ALWAYS_IGNORE = [
78
85
  // member of this list is load-bearing and has a row that fails without
79
86
  // it. Kept: the day an enumeration stops going through git, this is
80
87
  // what keeps a repository's object store out of a cache key.
81
- '**/.git/**',
88
+ '.git/**',
82
89
  // vx's machine-local state; the workspace config beside it is a source.
83
- '**/.vx/cache/**',
84
- '**/.vx/projects.lock.json',
90
+ '.vx/cache/**',
91
+ '.vx/projects.lock.json',
85
92
  // `vx lock`'s review copy of a project's resolved config: the config is
86
93
  // keyed already, and a re-lock must not move every `**` reader's key.
87
- '**/vx.lock.json',
94
+ 'vx.lock.json',
88
95
  // What `vx lock` and `vx init` write beside the lock: a re-lock or init
89
96
  // must not select every `**` reader under --affected.
90
- '**/.vx/.gitattributes',
91
- '**/.vx/.gitignore',
92
- // `bun build --compile` writes a transient `.<hash>-<n>.bun-build` intermediate
93
- // in the cwd. It never rests on disk, so it can't be a real input — but a
94
- // broad `inputs.files: ['**/*']` on a compile task would try to hash the temp
95
- // file that a CONCURRENT compile is mid-write, racing to EACCES/ENOENT. Always
96
- // exclude it (vx is Bun-native; compiling standalone binaries is a common task).
97
- '**/*.bun-build',
98
- // …and, cross-compiling for a target its cache lacks, the directory it
99
- // extracts the downloaded runtime into, `<cwd>/.<16 hex>-<8 hex>.tmp/`,
100
- // 60 MB mid-write under the same race (2026-09-29).
101
- '**/.????????????????-????????.tmp/**',
102
- // vx's own restore temp (archive.ts), live only while an artifact lands
103
- // beside its target. A key taken meanwhile listed it, the rename removed
104
- // it, and every task reading that directory was withheld its save
105
- // (withastro/astro: 25 of 33 builds re-ran on a restore, X-209).
106
- '**/.vx-tmp-*',
97
+ '.vx/.gitattributes',
98
+ '.vx/.gitignore',
99
+ // Where restores stage (archive.ts): vx's own, never an input.
100
+ '.vx/tmp/**',
107
101
  ]
108
102
 
103
+ /**
104
+ * `ALWAYS_IGNORE` relative to the workspace root, for `inputs.workspaceFiles`:
105
+ * those globs cross into other projects, and each one's `vx.lock.json` is vx's.
106
+ */
107
+ const WORKSPACE_ALWAYS_IGNORE = [...ALWAYS_IGNORE, '**/vx.lock.json']
108
+
109
109
  const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
110
110
 
111
111
  export interface ResolvedInputs {
@@ -169,6 +169,8 @@ export interface ResolveInputsArgs {
169
169
  workspaceFilesCache?: WorkspaceFilesCache
170
170
  /** Per-run memo for `inputs.files`; see `ProjectFilesCache`. */
171
171
  projectFilesCache?: ProjectFilesCache
172
+ /** Resolve no files: the caller folds them from a `filesToken` memo. */
173
+ skipFiles?: boolean
172
174
  }
173
175
 
174
176
  export type WorkspaceFilesCache = Map<
@@ -195,23 +197,67 @@ export type ProjectFilesCache = Map<
195
197
  { snapshot: readonly string[]; result: readonly string[] }
196
198
  >
197
199
 
200
+ /**
201
+ * What a task's input files fold to is a function of this token when it is
202
+ * one of the git records alone: the project's partition holds only files
203
+ * the enumeration trusted an index OID for (`GitFilesCache.filesDigest`),
204
+ * and the declaration reaches no gitignored path and no workspace file.
205
+ * The token is the digest, the workspace root (the fold folds paths
206
+ * relative to it) and everything `resolveFiles` keys its memo on.
207
+ * Undefined when only resolving the files can say.
208
+ */
209
+ export function filesToken(args: ResolveInputsArgs): string | undefined {
210
+ if ((args.inputs?.workspaceFiles?.length ?? 0) > 0) return undefined
211
+ const plan = filesPlan(args.inputs?.files, args.ownOutputs)
212
+ const cache = args.gitFilesCache
213
+ if (plan === null || cache === undefined) return undefined
214
+ const snapshot = cache.snapshotFor(args.projectDir, plan.positiveGlobs)
215
+ if (snapshot === undefined) return undefined
216
+ const digest = cache.filesDigest(args.projectDir, snapshot)
217
+ if (
218
+ digest === undefined ||
219
+ ignoredReach(plan, snapshot, { dir: args.projectDir, cache }).length > 0
220
+ )
221
+ return undefined
222
+ return `${args.workspaceRoot}\0${filesMemoKey(args.projectDir, plan, args.ownOutputs, args.ownWorkspaceOutputs ?? [], args.nestedProjectDirs)}\0${digest}`
223
+ }
224
+
225
+ /** Everything below the git snapshot that decides `resolveFiles`' result. */
226
+ function filesMemoKey(
227
+ projectDir: string,
228
+ plan: FilesPlan,
229
+ ownOutputs: readonly string[],
230
+ ownWorkspaceOutputs: readonly string[],
231
+ nestedProjectDirs: readonly string[],
232
+ ): string {
233
+ return `${projectDir}\0${plan.positive.join('\u0001')}\0${plan.negative.join('\u0001')}\0${ownOutputs.join('\u0001')}\0${ownWorkspaceOutputs.join('\u0001')}\0${nestedProjectDirs.join('\u0001')}`
234
+ }
235
+
198
236
  export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedInputs> {
199
- const projectRel = path.relative(args.workspaceRoot, args.projectDir).split(path.sep).join('/')
200
- const { files: projectFiles, listing } = await resolveFiles({
201
- projectDir: args.projectDir,
202
- workspaceRoot: args.workspaceRoot,
203
- files: args.inputs?.files,
204
- ownOutputs: args.ownOutputs,
205
- ownWorkspaceOutputs: args.ownWorkspaceOutputs ?? [],
206
- projectRel,
207
- nestedProjectDirs: args.nestedProjectDirs,
208
- ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
209
- ...(args.projectFilesCache !== undefined ? { projectFilesCache: args.projectFilesCache } : {}),
210
- })
237
+ const projectRel =
238
+ args.skipFiles === true
239
+ ? ''
240
+ : path.relative(args.workspaceRoot, args.projectDir).split(path.sep).join('/')
241
+ const { files: projectFiles, listing } =
242
+ args.skipFiles === true
243
+ ? { files: [], listing: undefined }
244
+ : await resolveFiles({
245
+ projectDir: args.projectDir,
246
+ workspaceRoot: args.workspaceRoot,
247
+ files: args.inputs?.files,
248
+ ownOutputs: args.ownOutputs,
249
+ ownWorkspaceOutputs: args.ownWorkspaceOutputs ?? [],
250
+ projectRel,
251
+ nestedProjectDirs: args.nestedProjectDirs,
252
+ ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
253
+ ...(args.projectFilesCache !== undefined
254
+ ? { projectFilesCache: args.projectFilesCache }
255
+ : {}),
256
+ })
211
257
  let files = projectFiles
212
258
  const listings = listing === undefined ? [] : [listing]
213
259
  const wsDecl = args.inputs?.workspaceFiles
214
- if (wsDecl !== undefined && wsDecl.length > 0) {
260
+ if (wsDecl !== undefined && wsDecl.length > 0 && args.skipFiles !== true) {
215
261
  const ws = resolveWorkspaceFiles({
216
262
  workspaceRoot: args.workspaceRoot,
217
263
  workspaceFiles: wsDecl,
@@ -285,7 +331,6 @@ function resolveWorkspaceFiles(args: {
285
331
  }): { files: Promise<string[]>; listing: InputListing } | undefined {
286
332
  const positive: string[] = []
287
333
  const negative: string[] = []
288
- refuseOneAlternativeBrace(args.workspaceFiles, 'workspaceFiles')
289
334
  for (const entry of args.workspaceFiles) {
290
335
  if (entry.startsWith('!')) negative.push(entry.slice(1))
291
336
  else positive.push(entry)
@@ -319,7 +364,7 @@ function resolveWorkspaceFiles(args: {
319
364
  let isExcluded: ((rel: string) => boolean) | undefined
320
365
  let isPositive: ((rel: string) => boolean) | undefined
321
366
  const excludedByDeclaration = (rel: string): boolean =>
322
- (isExcluded ??= anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)]))(rel)
367
+ (isExcluded ??= anyTaskGlob([...WORKSPACE_ALWAYS_IGNORE, ...asTrees(negative)]))(rel)
323
368
  // A committed file stays an input whatever output reaches it (`committedFiles`).
324
369
  let committed: ((abs: string) => boolean) | undefined
325
370
  const own = (rel: string): boolean =>
@@ -354,7 +399,10 @@ function resolveWorkspaceFiles(args: {
354
399
  const ownsAny =
355
400
  splitNegations(args.ownWorkspaceOutputs).positive.length > 0 ||
356
401
  splitNegations(args.ownOutputs).positive.length > 0
357
- const reach = ignoredReach(reachOf(positive), gitFiles)
402
+ const reach = ignoredReach(reachOf(positive), gitFiles, {
403
+ dir: args.workspaceRoot,
404
+ cache: args.gitFilesCache,
405
+ })
358
406
  const files = base.then((all) => {
359
407
  const kept = ownsAny ? all.filter((abs) => !own(relPosix(args.workspaceRoot, abs))) : all
360
408
  refuseUndecodable(kept, undecodable, args.workspaceRoot, 'workspaceFiles')
@@ -678,27 +726,13 @@ async function resolveRuntimeValues(
678
726
  }
679
727
 
680
728
  /**
681
- * What no output glob reaches. An output is otherwise taken as written —
682
- * `node_modules/**` IS an install task's output, so `ALWAYS_IGNORE` does not
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.
688
- */
689
- const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**', '**/vx.lock.json']
690
-
691
- /**
692
- * What a set of output globs may not reach: `OUTPUT_NEVER`, and every
693
- * `node_modules` unless a glob names one. `**\/*.js` meant the build's
694
- * files, and the clean before each run deleted every installed `.js`
695
- * under `node_modules` with them (A-13); an install task declares
696
- * `node_modules/**` and keeps it. Workspace outputs took none of this, not
697
- * even `.git`.
729
+ * What a set of output globs may not reach (`outputNever`). `**\/*.js`
730
+ * meant the build's files, and the clean before each run deleted every
731
+ * installed `.js` under `node_modules` with them (A-13); an install task
732
+ * declares `node_modules/**` and keeps it.
698
733
  */
699
- function outputExcludes(outputs: readonly string[]): Bun.Glob[] {
700
- const namesNodeModules = outputs.some((o) => normalizeGlob(o).split('/').includes('node_modules'))
701
- return (namesNodeModules ? OUTPUT_NEVER : [...OUTPUT_NEVER, '**/node_modules/**']).map(globFor)
734
+ function outputExcludes(outputs: readonly string[], scope: 'project' | 'workspace'): Bun.Glob[] {
735
+ return outputNever(outputs, scope).map(globFor)
702
736
  }
703
737
 
704
738
  /**
@@ -711,9 +745,31 @@ export async function resolveOutputs(args: {
711
745
  outputs: readonly string[]
712
746
  nestedProjectDirs: string[]
713
747
  }): Promise<string[]> {
748
+ return (await scanOutputs(args)).files
749
+ }
750
+
751
+ /**
752
+ * `resolveOutputs`, plus the empty directories the globs match: a save
753
+ * keeps them, so a hit leaves the tree a miss left (Turbo and Nx restore
754
+ * them too). Only a save asks; a hit's walk compares files.
755
+ */
756
+ export async function resolveOutputTree(args: {
757
+ projectDir: string
758
+ outputs: readonly string[]
759
+ nestedProjectDirs: string[]
760
+ }): Promise<{ files: string[]; emptyDirs: string[] }> {
761
+ const emptyDirs = new Set<string>()
762
+ const { files } = await scanOutputs(args, emptyDirs)
763
+ return { files, emptyDirs: containedIn(args.projectDir, [...emptyDirs]).sort() }
764
+ }
765
+
766
+ async function scanOutputs(
767
+ args: { projectDir: string; outputs: readonly string[]; nestedProjectDirs: string[] },
768
+ emptyDirs?: Set<string>,
769
+ ): Promise<{ files: string[] }> {
714
770
  const { positive, negative } = splitNegations(args.outputs)
715
- if (positive.length === 0) return []
716
- const excludeGlobs = [...outputExcludes(positive), ...asTrees(negative).map(globFor)]
771
+ if (positive.length === 0) return { files: [] }
772
+ const excludeGlobs = [...outputExcludes(positive, 'project'), ...asTrees(negative).map(globFor)]
717
773
  const trees = asTrees(positive)
718
774
  refuseDirLinkedOut(args.projectDir, trees)
719
775
  const scanned = [
@@ -722,6 +778,7 @@ export async function resolveOutputs(args: {
722
778
  excludeGlobs,
723
779
  args.projectDir,
724
780
  inNestedProject(args.projectDir, args.nestedProjectDirs),
781
+ emptyDirs,
725
782
  )),
726
783
  ]
727
784
  // Containment, enforced HERE and not only at the loader. `cleanOutputs`
@@ -731,7 +788,7 @@ export async function resolveOutputs(args: {
731
788
  // outside the project, so any future caller reaching it by another route (a
732
789
  // programmatic embedder, a config source that skips the loader) is contained
733
790
  // by construction.
734
- return containedIn(args.projectDir, scanned).sort()
791
+ return { files: containedIn(args.projectDir, scanned).sort() }
735
792
  }
736
793
 
737
794
  /**
@@ -750,8 +807,8 @@ export function committedFiles(
750
807
 
751
808
  /**
752
809
  * The literal head of each output glob, walked: the scan follows a link
753
- * there (`dist -> ../elsewhere` for `dist/**`) and only there. A link to a
754
- * FILE is the archive's to judge (L-23); a dangling one the restore's.
810
+ * there (`dist -> ../elsewhere` for `dist/**`) and only there. A link
811
+ * below it is an output, kept as the link (`archive.ts`).
755
812
  */
756
813
  function refuseDirLinkedOut(projectDir: string, trees: readonly string[]): void {
757
814
  let realRoot: string | undefined
@@ -763,10 +820,11 @@ function refuseDirLinkedOut(projectDir: string, trees: readonly string[]): void
763
820
  if (!isInside(projectDir, at)) break
764
821
  let st
765
822
  try {
766
- st = lstatSync(at)
823
+ st = lstatSync(at, { throwIfNoEntry: false })
767
824
  } catch {
768
825
  break
769
826
  }
827
+ if (st === undefined) break
770
828
  if (!st.isSymbolicLink()) continue
771
829
  let real: string
772
830
  try {
@@ -877,6 +935,7 @@ export async function cleanOutputs(args: {
877
935
  resolved?: readonly string[] | undefined
878
936
  }): Promise<string[]> {
879
937
  const committed = args.committed ?? committedFiles(undefined, args.projectDir)
938
+ sweepRestoreTemps(args.projectDir, args.outputs)
880
939
  const matched = args.resolved ?? (await resolveOutputs(args))
881
940
  // `force: true` makes rm tolerate ENOENT (e.g. when two output
882
941
  // globs overlap and a sibling already deleted a path mid-iteration).
@@ -892,6 +951,70 @@ export async function cleanOutputs(args: {
892
951
  return matched.map((f) => relPosix(args.projectDir, f))
893
952
  }
894
953
 
954
+ /**
955
+ * Remove the restore temps (`.vx-tmp-<pid>-<n>`, archive.ts) a killed run
956
+ * left beside an exact-file output. A restore stages in `.vx/tmp/` and
957
+ * writes one beside its target only where the target is on another
958
+ * filesystem (EXDEV); a glob over the directory takes it with the outputs,
959
+ * but `out/a.txt` reaches no sibling, and a partial temp stayed in `out/`
960
+ * through every later run (Growth's SIGKILL probe, 2026-10-16). A temp
961
+ * whose writer is alive, this process included, is left: it may be
962
+ * mid-restore.
963
+ */
964
+ export function sweepRestoreTemps(base: string, outputs: readonly string[]): void {
965
+ const dirs = new Set<string>()
966
+ for (const g of outputs) {
967
+ if (g.startsWith('!') || !isLiteralPattern(g)) continue
968
+ dirs.add(path.dirname(path.resolve(base, stripTrailingSlash(normalizeGlob(g)))))
969
+ }
970
+ for (const dir of dirs) {
971
+ let names: string[]
972
+ try {
973
+ names = readdirSync(dir)
974
+ } catch {
975
+ continue
976
+ }
977
+ for (const name of names) {
978
+ const m = /^\.vx-tmp-([0-9a-z]+)-[0-9a-z]+$/.exec(name)
979
+ if (m === null) continue
980
+ const pid = Number.parseInt(m[1]!, 36)
981
+ if (pid === process.pid || writerAlive(pid)) continue
982
+ rmSync(path.join(dir, name), { force: true })
983
+ }
984
+ }
985
+ }
986
+
987
+ /**
988
+ * Empty `.vx/tmp/` (archive.ts `STAGING`) of the staging directories
989
+ * (`<pid>-<n>`) a killed run left, once per run. One whose writer is alive,
990
+ * another run's or this one's, is left: it may be mid-restore.
991
+ */
992
+ export function sweepStaging(workspaceRoot: string): void {
993
+ const top = path.join(workspaceRoot, '.vx', 'tmp')
994
+ let names: string[]
995
+ try {
996
+ names = readdirSync(top)
997
+ } catch {
998
+ return
999
+ }
1000
+ for (const name of names) {
1001
+ const m = /^([0-9a-z]+)-[0-9a-z]+$/.exec(name)
1002
+ if (m === null) continue
1003
+ const pid = Number.parseInt(m[1]!, 36)
1004
+ if (pid === process.pid || writerAlive(pid)) continue
1005
+ rmSync(path.join(top, name), { recursive: true, force: true })
1006
+ }
1007
+ }
1008
+
1009
+ function writerAlive(pid: number): boolean {
1010
+ try {
1011
+ process.kill(pid, 0)
1012
+ return true
1013
+ } catch (err) {
1014
+ return (err as NodeJS.ErrnoException).code === 'EPERM'
1015
+ }
1016
+ }
1017
+
895
1018
  /**
896
1019
  * Remove exactly these project-relative paths — the recorded rows of an
897
1020
  * ADDITIVE task's own artifact (item 588), never a glob: the glob would
@@ -904,6 +1027,8 @@ export async function cleanOutputPaths(args: {
904
1027
  rels: readonly string[]
905
1028
  outputs: readonly string[]
906
1029
  committed?: (abs: string) => boolean
1030
+ /** Directories the prune leaves standing even when emptied (absolute). */
1031
+ keep?: ReadonlySet<string>
907
1032
  }): Promise<void> {
908
1033
  const committed = args.committed ?? committedFiles(undefined, args.projectDir)
909
1034
  const files = args.rels.map((r) => path.resolve(args.projectDir, r)).filter((f) => !committed(f))
@@ -911,6 +1036,7 @@ export async function cleanOutputPaths(args: {
911
1036
  args.projectDir,
912
1037
  await removeAll(files, args.projectDir),
913
1038
  pruneScope(args.projectDir, args.outputs),
1039
+ args.keep,
914
1040
  )
915
1041
  }
916
1042
 
@@ -1103,6 +1229,7 @@ async function pruneEmptiedDirs(
1103
1229
  root: string,
1104
1230
  removed: readonly string[],
1105
1231
  scope?: PruneScope,
1232
+ keep?: ReadonlySet<string>,
1106
1233
  ): Promise<void> {
1107
1234
  const rootResolved = path.resolve(root)
1108
1235
  // LEVEL ORDER, not a walk-up per directory. A parent is attempted only
@@ -1122,7 +1249,8 @@ async function pruneEmptiedDirs(
1122
1249
  (dir) =>
1123
1250
  dir !== rootResolved &&
1124
1251
  dir.startsWith(rootResolved + path.sep) &&
1125
- (scope === undefined || inScope(dir, scope)),
1252
+ (scope === undefined || inScope(dir, scope)) &&
1253
+ keep?.has(dir) !== true,
1126
1254
  )
1127
1255
  const gone = (err: NodeJS.ErrnoException): boolean => err.code === 'ENOENT'
1128
1256
  if (dirs.length <= SYNC_CLEAN_MAX) {
@@ -1196,7 +1324,7 @@ export async function resolveWorkspaceOutputs(args: {
1196
1324
  }): Promise<string[]> {
1197
1325
  const { positive, negative } = splitNegations(args.outputs)
1198
1326
  if (positive.length === 0) return []
1199
- const excludeGlobs = [...outputExcludes(positive), ...asTrees(negative).map(globFor)]
1327
+ const excludeGlobs = [...outputExcludes(positive, 'workspace'), ...asTrees(negative).map(globFor)]
1200
1328
  const scanned = [...(await scanUnion(asTrees(positive), excludeGlobs, args.workspaceRoot))]
1201
1329
  // Same containment as the project twin, anchored one level out. These globs
1202
1330
  // deliberately ignore PROJECT boundaries — that is the escape hatch — but
@@ -1221,6 +1349,7 @@ export async function cleanWorkspaceOutputs(args: {
1221
1349
  resolved?: readonly string[] | undefined
1222
1350
  }): Promise<string[]> {
1223
1351
  const committed = args.committed ?? committedFiles(undefined, args.workspaceRoot)
1352
+ sweepRestoreTemps(args.workspaceRoot, args.outputs)
1224
1353
  const matched = args.resolved ?? (await resolveWorkspaceOutputs(args))
1225
1354
  await pruneEmptiedDirs(
1226
1355
  args.workspaceRoot,
@@ -1256,7 +1385,11 @@ export { asTrees } from '../util/index.js'
1256
1385
  * unanswered. Testing every literal against every file was ~1.5 ms of a
1257
1386
  * 1,800-file all-cached run (2026-10-06).
1258
1387
  */
1259
- function unanswered(literals: readonly string[], files: readonly string[]): Set<string> {
1388
+ function unanswered(
1389
+ literals: readonly string[],
1390
+ files: readonly string[],
1391
+ newIgnored?: (lit: string) => boolean,
1392
+ ): Set<string> {
1260
1393
  const left = new Set<string>()
1261
1394
  for (const lit of literals) {
1262
1395
  const under = `${lit}/`
@@ -1264,7 +1397,7 @@ function unanswered(literals: readonly string[], files: readonly string[]): Set<
1264
1397
  files[lowerBound(files, lit)] === lit ||
1265
1398
  files[lowerBound(files, under)]?.startsWith(under) === true ||
1266
1399
  files.some((rel) => rel === lit || rel.startsWith(under))
1267
- if (!answered) left.add(lit)
1400
+ if (!answered || newIgnored?.(lit) === true) left.add(lit)
1268
1401
  }
1269
1402
  return left
1270
1403
  }
@@ -1292,19 +1425,33 @@ function lowerBound(files: readonly string[], key: string): number {
1292
1425
  function ignoredReach(
1293
1426
  reach: { prefixes: readonly string[]; literals: readonly string[] },
1294
1427
  listed: readonly string[],
1428
+ ignoredIn?: { dir: string; cache: GitFilesCache | undefined },
1295
1429
  ): readonly string[] {
1296
- const named = outright(reach)
1430
+ let named = outrightMemo.get(reach)
1431
+ if (named === undefined) outrightMemo.set(reach, (named = outright(reach)))
1297
1432
  if (named.length === 0) return []
1298
1433
  // Per listing and entries: thousands of tasks share a few declarations.
1299
1434
  let memo = reachMemo.get(listed)
1300
1435
  if (memo === undefined) reachMemo.set(listed, (memo = new Map()))
1301
1436
  const key = named.join('\0')
1302
1437
  let left = memo.get(key)
1303
- if (left === undefined) memo.set(key, (left = [...unanswered(named, listed)]))
1438
+ if (left === undefined) {
1439
+ // A listed path under a named directory git otherwise ignores (a kept
1440
+ // `gen/.gitkeep`) answered for the whole of it, and the untracked files
1441
+ // beside it never entered the key.
1442
+ const cache = ignoredIn?.cache
1443
+ const newIgnored =
1444
+ cache === undefined
1445
+ ? undefined
1446
+ : (lit: string) => cache.newFilesIgnored(path.resolve(ignoredIn!.dir, lit))
1447
+ memo.set(key, (left = [...unanswered(named, listed, newIgnored)]))
1448
+ }
1304
1449
  return left
1305
1450
  }
1306
1451
 
1307
1452
  const reachMemo = new WeakMap<readonly string[], Map<string, readonly string[]>>()
1453
+ // A declaration's plan is memoized, so its entries are reckoned once.
1454
+ const outrightMemo = new WeakMap<object, string[]>()
1308
1455
 
1309
1456
  /**
1310
1457
  * A declaration's literals and globs' fixed prefixes, each once: none for a
@@ -1324,10 +1471,12 @@ function outright(reach: { prefixes: readonly string[]; literals: readonly strin
1324
1471
  /**
1325
1472
  * The files on disk under the {@link ignoredReach} entries that `accept`
1326
1473
  * keeps, sorted and absolute. A file is hashed from disk like any untracked
1327
- * one; nothing here is stored or restored. An install (`node_modules`)
1328
- * stays out, as in the enumeration: lockfile plugins key it. lstat, not
1329
- * `exists()`: that answers false for a directory. Never memoized: a dependency writes these
1330
- * mid-run.
1474
+ * one; nothing here is stored or restored. `node_modules` is no special
1475
+ * case (owner, 2026-10-16): an ignored path named outright is an input,
1476
+ * a generated client (`node_modules/.prisma/client/**`) too; skipping
1477
+ * installs here folded such an entry to nothing, silently. lstat, not
1478
+ * `exists()`: that answers false for a directory. Never memoized: a
1479
+ * dependency writes these mid-run.
1331
1480
  */
1332
1481
  function ignoredInputs(
1333
1482
  root: string,
@@ -1340,8 +1489,7 @@ function ignoredInputs(
1340
1489
  const st = lstatSync(abs, { throwIfNoEntry: false })
1341
1490
  if (st === undefined) continue
1342
1491
  if (!st.isDirectory()) {
1343
- if ((st.isFile() || st.isSymbolicLink()) && !isInstalledPath(entry) && accept(entry))
1344
- found.add(abs)
1492
+ if ((st.isFile() || st.isSymbolicLink()) && accept(entry)) found.add(abs)
1345
1493
  continue
1346
1494
  }
1347
1495
  for (const rel of WALK.scanSync({
@@ -1351,7 +1499,7 @@ function ignoredInputs(
1351
1499
  dot: true,
1352
1500
  })) {
1353
1501
  const full = `${entry}/${rel.split(path.sep).join('/')}`
1354
- if (isInstalledPath(full) || !accept(full)) continue
1502
+ if (!accept(full)) continue
1355
1503
  const file = path.join(abs, rel)
1356
1504
  if (isInputOnDisk(file)) found.add(file)
1357
1505
  }
@@ -1412,30 +1560,6 @@ function refuseUndecodable(
1412
1560
  )
1413
1561
  }
1414
1562
 
1415
- const ONE_ALTERNATIVE_BRACE = /(?<!\\)\{[^{},]*\}/
1416
-
1417
- /**
1418
- * `Bun.Glob` reads `{b}` as a brace of one alternative, so `src/{b}.ts`
1419
- * matches `src/b.ts` and never a file named `{b}.ts`: that file stays out
1420
- * of the key and an edit to it is a hit. Neither reading is sure to be
1421
- * the one meant, so the entry is refused with both spellings.
1422
- */
1423
- function refuseOneAlternativeBrace(
1424
- entries: readonly string[],
1425
- field: 'files' | 'workspaceFiles',
1426
- ): void {
1427
- for (const entry of entries) {
1428
- const m = ONE_ALTERNATIVE_BRACE.exec(entry)
1429
- if (m === null) continue
1430
- const inner = m[0].slice(1, -1)
1431
- throw new UserError(
1432
- `inputs.${field}: "${entry}" holds a brace with one alternative, which matches ` +
1433
- `"${inner}" and never a name holding "${m[0]}". Write "${entry.replace(m[0], inner)}", ` +
1434
- `or "${entry.replace(m[0], `\\{${inner}\\}`)}" for the braces themselves.`,
1435
- )
1436
- }
1437
- }
1438
-
1439
1563
  /** What a `files` declaration and its task's outputs compile to, whatever the project. */
1440
1564
  interface FilesPlan {
1441
1565
  positive: string[]
@@ -1484,7 +1608,6 @@ function filesPlan(
1484
1608
  if (files === undefined) {
1485
1609
  positive.push(...DEFAULT_FILE_GLOBS)
1486
1610
  } else {
1487
- refuseOneAlternativeBrace(files, 'files')
1488
1611
  for (const entry of files) {
1489
1612
  if (entry.startsWith('!')) negative.push(entry.slice(1))
1490
1613
  else positive.push(entry)
@@ -1591,7 +1714,7 @@ function workspaceMatcher(decl: readonly string[]): (rel: string) => boolean {
1591
1714
  if (m !== undefined) return m
1592
1715
  const { positive, negative } = splitNegations(decl)
1593
1716
  const isPositive = anyTaskGlob(asTrees(positive))
1594
- const isExcluded = anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)])
1717
+ const isExcluded = anyTaskGlob([...WORKSPACE_ALWAYS_IGNORE, ...asTrees(negative)])
1595
1718
  m = (rel) => isPositive(rel) && !isExcluded(rel)
1596
1719
  workspaceMatchers.set(key, m)
1597
1720
  return m
@@ -1602,7 +1725,7 @@ async function resolveFiles(
1602
1725
  ): Promise<{ files: string[]; listing?: InputListing }> {
1603
1726
  const plan = filesPlan(args.files, args.ownOutputs)
1604
1727
  if (plan === null) return { files: [] }
1605
- const { positive, negative, isExcluded, ownOutput, positiveGlobs, isPositive } = plan
1728
+ const { isExcluded, ownOutput, positiveGlobs, isPositive } = plan
1606
1729
 
1607
1730
  const nested = inNestedProject(args.projectDir, args.nestedProjectDirs)
1608
1731
  // Its root-anchored outputs that land in the project, as `files` sees them.
@@ -1618,7 +1741,13 @@ async function resolveFiles(
1618
1741
  // duration of one orchestrator run.
1619
1742
  // Everything below the snapshot that decides the result: the project, what
1620
1743
  // it declares, what it excludes as its own outputs, and the boundaries.
1621
- const memoKey = `${args.projectDir}\0${positive.join('\u0001')}\0${negative.join('\u0001')}\0${args.ownOutputs.join('\u0001')}\0${args.ownWorkspaceOutputs.join('\u0001')}\0${args.nestedProjectDirs.join('\u0001')}`
1744
+ const memoKey = filesMemoKey(
1745
+ args.projectDir,
1746
+ plan,
1747
+ args.ownOutputs,
1748
+ args.ownWorkspaceOutputs,
1749
+ args.nestedProjectDirs,
1750
+ )
1622
1751
  let gitFiles = args.gitFilesCache?.snapshotFor(args.projectDir, positiveGlobs)
1623
1752
  let undecodable = args.gitFilesCache?.undecodableNames
1624
1753
  if (gitFiles !== undefined) {
@@ -1631,7 +1760,7 @@ async function resolveFiles(
1631
1760
  files: withIgnored(
1632
1761
  args.projectDir,
1633
1762
  memo.result,
1634
- ignoredReach(plan, gitFiles),
1763
+ ignoredReach(plan, gitFiles, { dir: args.projectDir, cache: args.gitFilesCache }),
1635
1764
  plan,
1636
1765
  nested,
1637
1766
  ownWsOutput,
@@ -1693,7 +1822,7 @@ async function resolveFiles(
1693
1822
  if (!input && !committed(abs)) continue
1694
1823
  candidates.push(abs)
1695
1824
  }
1696
- const reach = ignoredReach(plan, gitFiles)
1825
+ const reach = ignoredReach(plan, gitFiles, { dir: args.projectDir, cache: args.gitFilesCache })
1697
1826
  refuseUndecodable(candidates, undecodable, args.projectDir, 'files')
1698
1827
  // Second pass: existence check — but ONLY for paths without a
1699
1828
  // trusted index OID. A clean-per-status tracked file necessarily
@@ -1824,7 +1953,11 @@ export interface InputListing {
1824
1953
  literals: readonly string[]
1825
1954
  }
1826
1955
 
1827
- function reachOf(positive: readonly string[]): { prefixes: string[]; literals: string[] } {
1956
+ function reachOf(entries: readonly string[]): { prefixes: string[]; literals: string[] } {
1957
+ // Each alternative names what it would alone: `{gen/a.txt,gen/b.txt}`
1958
+ // names both files, as `gen/{a,b}.txt` does; whole, it had no fixed
1959
+ // prefix and named nothing (Growth's parity, 2026-10-17).
1960
+ const positive = entries.flatMap(braceAlternatives)
1828
1961
  const prefixes = new Set<string>()
1829
1962
  for (const p of asTrees(positive)) {
1830
1963
  if (isLiteralPattern(p)) continue
@@ -1967,6 +2100,7 @@ async function scanUnion(
1967
2100
  excludeGlobs: readonly Bun.Glob[],
1968
2101
  cwd: string,
1969
2102
  nested: (rel: string) => boolean = () => false,
2103
+ emptyDirs?: Set<string>,
1970
2104
  ): Promise<Set<string>> {
1971
2105
  const matches = new Set<string>()
1972
2106
  // `Bun.Glob`'s scan finds nothing for a brace whose alternatives hold a
@@ -1980,7 +2114,9 @@ async function scanUnion(
1980
2114
  const abs = path.resolve(cwd, rel)
1981
2115
  const st = lstatSync(abs, { throwIfNoEntry: false })
1982
2116
  if (st !== undefined && (st.isFile() || st.isSymbolicLink())) matches.add(abs)
1983
- else if (st === undefined && rel.includes('\ufffd')) refuseUndecodableOutput(abs)
2117
+ else if (emptyDirs !== undefined && st?.isDirectory() === true) {
2118
+ if (readdirSync(abs).length === 0) emptyDirs.add(abs)
2119
+ } else if (st === undefined && rel.includes('\ufffd')) refuseUndecodableOutput(abs)
1984
2120
  }
1985
2121
  }
1986
2122
  return matches