@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
@@ -0,0 +1,65 @@
1
+ import type { DependencyRule } from '../config.js'
2
+ import { refusal } from '../util/index.js'
3
+ import { compileNameGlob } from './filter.js'
4
+ import type { PackageGraph } from './package-graph.js'
5
+
6
+ /**
7
+ * `run.rules.dependencies`, held to each project's direct workspace
8
+ * dependencies (`PackageGraph.directDeps`: the manifest's and the
9
+ * cross-project `dependsOn` edges). A rule constrains the projects its
10
+ * `from` selects: each dependency must match an `allow` selector when
11
+ * `allow` is given, and no `deny` one. Every rule that selects a project
12
+ * applies, so a deny beats any allow. Checked by `vx lock` only (owner,
13
+ * 2026-10-13); every broken edge is named in one refusal.
14
+ */
15
+ export function checkDependencyRules(
16
+ rules: readonly DependencyRule[],
17
+ projects: readonly string[],
18
+ tags: ReadonlyMap<string, readonly string[]>,
19
+ graph: PackageGraph,
20
+ ): void {
21
+ if (rules.length === 0) return
22
+ const compiled = rules.map((r) => ({
23
+ from: selector(r.from),
24
+ allow: r.allow?.map(selector),
25
+ deny: r.deny?.map(selector),
26
+ }))
27
+ const broken: string[] = []
28
+ for (const name of projects) {
29
+ const own = tags.get(name) ?? []
30
+ for (const [i, rule] of compiled.entries()) {
31
+ if (!rule.from(name, own)) continue
32
+ for (const dep of graph.directDeps(name)) {
33
+ const theirs = tags.get(dep) ?? []
34
+ const denied = rule.deny?.some((s) => s(dep, theirs)) === true
35
+ if (denied || (rule.allow !== undefined && !rule.allow.some((s) => s(dep, theirs))))
36
+ broken.push(
37
+ `${name} depends on ${dep}, which run.rules.dependencies[${i}] (from '${rules[i]!.from}') ${denied ? 'denies' : 'does not allow'}`,
38
+ )
39
+ }
40
+ }
41
+ }
42
+ if (broken.length === 0) return
43
+ const headline = `${broken.length === 1 ? '1 dependency breaks' : `${broken.length} dependencies break`} run.rules.dependencies`
44
+ throw refusal(
45
+ {
46
+ headline,
47
+ what: broken,
48
+ fix: "drop the dependency, or change the project's tags or the rule",
49
+ },
50
+ {
51
+ code: 'VX_E_DEPENDENCY_RULE',
52
+ message: `${headline}:\n${broken.map((b) => ` ${b}`).join('\n')}\nDrop the dependency, or change the project's tags or the rule.`,
53
+ },
54
+ )
55
+ }
56
+
57
+ /** `tag:<glob>` matches a project carrying a matching tag; anything else, its name. */
58
+ function selector(s: string): (name: string, tags: readonly string[]) => boolean {
59
+ if (s.startsWith('tag:')) {
60
+ const re = compileNameGlob(s.slice(4))
61
+ return (_, tags) => tags.some((t) => re.test(t))
62
+ }
63
+ const re = compileNameGlob(s)
64
+ return (name) => re.test(name)
65
+ }
@@ -131,8 +131,8 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
131
131
  if (s === '') throw new UserError(`filter "${raw}" names no project`)
132
132
 
133
133
  // `//` is Turbo's name for the root package (`--filter=//`, `//...`,
134
- // `!//`, probed on 2.8.17). vx names the root project by its
135
- // package.json name, so `//` is its directory, and only a project
134
+ // `!//`, probed on 2.8.17), and vx's for the root project unless
135
+ // something names it, so `//` is its directory, and only a project
136
136
  // there: read as `.`, it would select every project when the root is
137
137
  // none.
138
138
  if (s === '//') {
@@ -1,129 +1,23 @@
1
1
  import path from 'node:path'
2
- import { xxh3 } from '../util/index.js'
3
2
  import { type LoadReads, readOnce } from './load-reads.js'
4
3
 
5
- // Every package-manager lockfile we know about, plus the workspace
6
- // definition files. Whichever ones exist get folded into the
7
- // fingerprint; missing ones are skipped. Hashed in declaration order
8
- // for determinism — adding a new entry here changes the fingerprint
9
- // for projects that have that file present, which is fine: that's the
10
- // whole point of bumping CACHE_VERSION when the fingerprint surface
11
- // expands.
12
- // Exported so `--affected` can consult the SAME list. A change to any of
13
- // these re-keys every task in the workspace, so selection has to widen to
14
- // match — two copies of this list would silently drift into a run that
15
- // rebuilds everything while selecting nothing.
16
- export const WORKSPACE_FINGERPRINT_FILES = [
17
- // Lockfiles
18
- 'pnpm-lock.yaml',
19
- 'package-lock.json',
20
- 'npm-shrinkwrap.json',
21
- 'yarn.lock',
22
- 'bun.lock',
23
- 'bun.lockb',
24
- // Workspace definition (pnpm); package.json is hashed per-project
25
- // via projectPackageJsonHash, not here.
26
- 'pnpm-workspace.yaml',
27
- // Yarn 4 catalogs: yarn.lock records a `catalog:` dependency as that
28
- // literal, so a catalog flip to a range the file already resolves
29
- // leaves it byte-identical while the workspace's install moves
30
- // (turborepo#12635). pnpm's catalogs live in pnpm-workspace.yaml, and
31
- // its lockfile records each importer's resolved version besides.
32
- '.yarnrc.yml',
33
- // Install settings no lockfile records: bun's `linker = "isolated"` and
34
- // pnpm's `node-linker` / hoisting lay `node_modules` out anew under a
35
- // byte-identical lockfile, and a build that imported an undeclared
36
- // hoisted package kept its green hit where it now fails (D-7).
37
- '.npmrc',
38
- 'bunfig.toml',
39
- ]
40
-
41
- // DELIBERATELY ABSENT: `vx.workspace.{ts,mts,js,mjs,cts,cjs}`. Everything it can
42
- // declare — `concurrency`, `cacheDir`, `timeout`, and the
43
- // plugin list — is placement, storage or observability, never what a
44
- // command produces; core's three seams may change WHERE a task runs, not
45
- // WHAT it runs (architecture principle #3). Folding it in would also do
46
- // active harm: a laptop declaring only the local plugins and a CI runner
47
- // declaring `reapi()` would compute different fingerprints and share not
48
- // one cache entry — the same split the rejected `NODE_OPTIONS` non-goal
49
- // describes. A per-task input that genuinely varies belongs in
50
- // `cache.inputs`, which is the surface built for it.
51
-
52
- /**
53
- * One hash for the workspace as a whole, derived from whichever
54
- * lockfile + workspace-definition files exist at the root. Folded
55
- * into every task's cache key so any lockfile bump (`pnpm update`,
56
- * `npm install`, `bun install`, …) or a workspace-shape change
57
- * invalidates every cached entry. Coarse but correct.
58
- */
59
- export async function computeWorkspaceFingerprint(
60
- workspaceRoot: string,
61
- reads?: LoadReads,
62
- ): Promise<string> {
63
- return (await computeWorkspaceFingerprints(workspaceRoot, NONE, reads)).all
64
- }
65
-
66
- const NONE: ReadonlySet<string> = new Set()
67
-
68
- export interface WorkspaceFingerprints {
69
- /** Every file folded — what the config-evaluation cache keys on. */
70
- readonly all: string
71
- /**
72
- * The same fold minus the files a plugin claims (`VxPlugin.fingerprint`)
73
- * — what every task key folds. Identical to `all` with nothing claimed.
74
- */
75
- readonly unclaimed: string
76
- /**
77
- * The bytes folded, by file name — what a mid-run check compares against
78
- * when a task may have rewritten one (orchestrator/fingerprint-watch.ts).
79
- * Already in memory for the fold; kept, not copied.
80
- */
81
- readonly files: ReadonlyMap<string, Uint8Array>
82
- }
83
-
84
4
  /**
85
- * Both digests from one read of each file — none at all for the
86
- * `pnpm-workspace.yaml` discovery already read into the load's `reads`. A
87
- * claimed file leaves the key digest entirely (its name too): the claimant
88
- * folds what it means per project, and a fold of "present" would still
89
- * re-key the workspace when the file appeared.
5
+ * The bytes of each named root file that exists: the files the plugins
6
+ * claim (`VxPlugin.fingerprint`), read once at run start so a task that
7
+ * rewrites one is caught (orchestrator/fingerprint-watch.ts). Core folds
8
+ * none of them into a key; the claimant does.
90
9
  */
91
- export async function computeWorkspaceFingerprints(
10
+ export async function readClaimedFiles(
92
11
  workspaceRoot: string,
93
- claimed: ReadonlySet<string>,
12
+ names: Iterable<string>,
94
13
  reads?: LoadReads,
95
- ): Promise<WorkspaceFingerprints> {
96
- let all = 0n
97
- let unclaimed = 0n
14
+ ): Promise<ReadonlyMap<string, Uint8Array>> {
98
15
  const files = new Map<string, Uint8Array>()
99
- for (const f of WORKSPACE_FINGERPRINT_FILES) {
16
+ for (const f of names) {
100
17
  const bytes = await readOnce(reads, path.join(workspaceRoot, f))
101
- if (bytes === null) continue
102
- files.set(f, bytes)
103
- all = xxh3(`${f}\0`, all)
104
- all = xxh3(bytes, all)
105
- if (claimed.has(f)) continue
106
- unclaimed = xxh3(`${f}\0`, unclaimed)
107
- unclaimed = xxh3(bytes, unclaimed)
18
+ if (bytes !== null) files.set(f, bytes)
108
19
  }
109
- // bun.lock names a patch by PATH, never by its content: an edited patch
110
- // left the lockfile byte-identical and every key unmoved while the
111
- // install applied the new one (item 1014, where only `bun()`'s claim
112
- // learned it). Each file it names folds here; a claimed bun.lock's
113
- // plugin folds them itself.
114
- const bunLock = files.get('bun.lock')
115
- if (bunLock !== undefined) {
116
- for (const rel of bunPatchFiles(bunLock)) {
117
- const bytes = await readOnce(reads, path.join(workspaceRoot, rel))
118
- const mark = `patch\0${rel}\0${bytes === null ? 'gone' : 'present'}`
119
- all = xxh3(mark, all)
120
- if (bytes !== null) all = xxh3(bytes, all)
121
- if (claimed.has('bun.lock')) continue
122
- unclaimed = xxh3(mark, unclaimed)
123
- if (bytes !== null) unclaimed = xxh3(bytes, unclaimed)
124
- }
125
- }
126
- return { all: hex(all), unclaimed: hex(unclaimed), files }
20
+ return files
127
21
  }
128
22
 
129
23
  const PATCHED = new TextEncoder().encode('"patchedDependencies"')
@@ -132,7 +26,7 @@ const PATCHED = new TextEncoder().encode('"patchedDependencies"')
132
26
  * The workspace-relative patch files a `bun.lock` names, sorted; none
133
27
  * outside the workspace. A lockfile without the key is not parsed (the
134
28
  * common case costs one byte search), and one that will not parse names
135
- * none: its bytes still fold whole.
29
+ * none.
136
30
  */
137
31
  export function bunPatchFiles(bytes: Uint8Array): string[] {
138
32
  if (Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).indexOf(PATCHED) < 0) return []
@@ -153,7 +47,3 @@ export function bunPatchFiles(bytes: Uint8Array): string[] {
153
47
  }
154
48
  return [...out].sort()
155
49
  }
156
-
157
- function hex(h: bigint): string {
158
- return h.toString(16).padStart(16, '0')
159
- }
@@ -10,24 +10,23 @@ export {
10
10
  refIsHead,
11
11
  } from './affected.js'
12
12
  export { applyFilters, parseFilter } from './filter.js'
13
- export {
14
- computeWorkspaceFingerprint,
15
- computeWorkspaceFingerprints,
16
- WORKSPACE_FINGERPRINT_FILES,
17
- type WorkspaceFingerprints,
18
- } from './fingerprint.js'
13
+ export { readClaimedFiles } from './fingerprint.js'
14
+ export { checkDependencyRules } from './dependency-rules.js'
19
15
  export { computeNestedProjectDirs } from './nested-dirs.js'
20
16
  export { buildPackageGraph, type PackageGraph } from './package-graph.js'
21
17
  export {
22
18
  frozenProjectConfigs,
23
19
  LOCKFILE_NAME,
24
20
  LOCKFILE_VERSION,
21
+ LOCK_INVALID,
22
+ lockEntryBroken,
25
23
  lockfilePath,
26
- FROZEN_WITHOUT_LOCK,
24
+ lockKey,
25
+ NO_LOCK,
27
26
  readLockfile,
28
27
  writeLockfile,
29
28
  type Lockfile,
30
- type LockfileEntry,
29
+ type LockedProject,
31
30
  } from './lockfile.js'
32
31
  export {
33
32
  loadProjectConfig,
@@ -40,47 +39,41 @@ export {
40
39
  cacheScopeError,
41
40
  inputStabilityEnv,
42
41
  isCacheScope,
42
+ taskNameProblem,
43
43
  validateProjectConfig,
44
44
  validateWorkspace,
45
45
  } from './config-schema.js'
46
- export type { LoadProjectConfigOptions } from './project-loader.js'
47
- export {
48
- blobOidOf,
49
- configEvalKey,
50
- configEvalKeyFromClosure,
51
- configImports,
52
- type ConfigEvalStore,
53
- } from './config-cache.js'
46
+ export { configImports } from './config-imports.js'
54
47
  export {
48
+ checkProjectNames,
55
49
  findWorkspaceRoot,
56
- discoverProjects,
57
- listProjects,
58
- namedProject,
59
- loadWorkspace,
60
- unreachedHint,
61
- unreachedPackages,
62
- memberBaseDirs,
50
+ findProjectConfigs,
51
+ discoveredProject,
52
+ linkDiscoveredEdges,
53
+ lockedProjects,
54
+ pathName,
63
55
  PROJECT_CONFIG_FILENAMES,
64
56
  resolveCacheDir,
65
57
  resolveStoreRoot,
58
+ ROOT_NAME,
59
+ type ConfigDir,
66
60
  type ProjectEntry,
67
61
  type ProjectMeta,
68
- type Workspace,
69
62
  } from './workspace.js'
70
63
  export type { LoadReads } from './load-reads.js'
71
- // The migration seam (see migration.ts) and core's own mapper, the scripts one.
64
+ // The migration seam (see migration.ts).
72
65
  export {
73
66
  applyMigration,
74
67
  foldScriptHooks,
75
- PERSISTENT_TASK_NAMES,
76
68
  PERSISTENT_TODO,
77
69
  pruneOrphanPersistentNotes,
78
70
  quoteTsLiteral,
79
71
  type ApplyMigrationArgs,
80
72
  type MigrationFormat,
73
+ type MigrationMember,
81
74
  type GeneratedProject,
82
75
  type GeneratedTask,
83
76
  type MigrationPlan,
84
77
  type RawExpr,
78
+ vxInvocation,
85
79
  } from './migration.js'
86
- export { delegatedScript, migrateScripts } from './migrate-scripts.js'
@@ -1,6 +1,6 @@
1
1
  // A load reads each root file once. Finding the root reads its manifest,
2
- // listing the package globs read it again and the workspace fingerprint a
3
- // third time. Every such read goes through a `LoadReads` the load owns, so
2
+ // listing the package globs read it again, and the claimed root files are
3
+ // read for the run (`readClaimedFiles`). Every such read goes through a `LoadReads` the load owns, so
4
4
  // a file costs one probe and one read per load however many stages ask for
5
5
  // it. The map lives exactly as long as the load: a `vx watch` cycle is a
6
6
  // new load and reads fresh bytes.
@@ -14,8 +14,8 @@ export type LoadReads = Map<string, Promise<Uint8Array | null>>
14
14
  * `file`'s bytes, read at most once per `reads`; null when there is no file
15
15
  * there (`Bun.file(<dir>).exists()` is false, so a directory is none).
16
16
  *
17
- * The probe stays ahead of the read: most names asked about are absent (six
18
- * of the seven fingerprint files, three of the four workspace config names),
17
+ * The probe stays ahead of the read: most names asked about are absent
18
+ * (three of the four workspace config names, a manager's settings files),
19
19
  * and a failed read costs 90–200 µs building its error where `exists()`
20
20
  * answers in 15–40 µs (measured 2026-09-24) — so one stat per present file
21
21
  * is cheaper than letting the open answer both questions.