@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
package/src/handoff.ts ADDED
@@ -0,0 +1,169 @@
1
+ // A global vx hands off to the project's own (owner, 2026-10-13), found
2
+ // from the workspace root, the one lookup every verb shares. Here for
3
+ // the compiled binary installed by hand; an npm install's launcher
4
+ // (npm-launcher.sh) walks the same way and `exec`s, which Bun cannot. The
5
+ // walk runs before anything else loads, through `Bun.file(…).exists()`:
6
+ // importing `node:fs` alone was ~1.1 ms of a 4 ms start (2026-10-13).
7
+
8
+ /**
9
+ * The project-local vx to run instead of this one, or null: the workspace
10
+ * root's ({@link workspaceFile}) `node_modules/@vzn/vx`, run through its
11
+ * `.bin/vx`. Null once handed
12
+ * off (one hop), for a vx that is not a standalone compiled binary (source;
13
+ * an npm install, whose launcher already walked), outside a workspace,
14
+ * when the local one is this binary, and when another user owns it.
15
+ */
16
+ export async function localVx(
17
+ cwd: string,
18
+ execPath: string,
19
+ compiled: boolean,
20
+ uid = process.getuid?.(),
21
+ ): Promise<string | null> {
22
+ if (process.env['VX_HANDED_OFF'] !== undefined || !compiled) return null
23
+ if (execPath.includes('/node_modules/')) return null
24
+ const file = await workspaceFile(cwd)
25
+ if (file === null) return null
26
+ const root = workspaceRootOf(file)
27
+ if (!(await Bun.file(`${root}/node_modules/@vzn/vx/package.json`).exists())) return null
28
+ const bin = `${root}/node_modules/.bin/vx`
29
+ const { realpathSync, statSync } = await import('node:fs')
30
+ try {
31
+ const real = realpathSync(bin)
32
+ // One another user planted would run as you.
33
+ if (statSync(real).uid !== uid) return null
34
+ return real === realpathSync(execPath) ? null : bin
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /**
41
+ * The workspace config's paths under the root, in lookup order: one marks
42
+ * the workspace root, the directory holding its `.vx` (owner, 2026-10-14).
43
+ */
44
+ export const WORKSPACE_CONFIG_FILENAMES = [
45
+ '.vx/workspace.ts',
46
+ '.vx/workspace.mts',
47
+ '.vx/workspace.js',
48
+ '.vx/workspace.mjs',
49
+ // A CommonJS config (`module.exports = …`), as Vite and TS name theirs: it
50
+ // was ignored without a word (D-86).
51
+ '.vx/workspace.cts',
52
+ '.vx/workspace.cjs',
53
+ ]
54
+
55
+ /** The workspace root a workspace config marks: the directory above its `.vx`. */
56
+ export function workspaceRootOf(file: string): string {
57
+ return file.slice(0, file.lastIndexOf('/.vx/')) || '/'
58
+ }
59
+
60
+ const files = new Map<string, Promise<string | null>>()
61
+
62
+ /**
63
+ * The workspace config that marks the root for `dir`: the first
64
+ * `.vx/workspace.*` from `dir` up to the git top-level ({@link gitTop}),
65
+ * inclusive, or to `/` outside git (owner, 2026-10-13: "Workspace file is
66
+ * the ultimate" and "never go out of git root"). Null when there is none.
67
+ * Learned once per directory per run: the hand-off and the workspace root
68
+ * share it (principle 9).
69
+ */
70
+ export function workspaceFile(dir: string): Promise<string | null> {
71
+ let file = files.get(dir)
72
+ if (file === undefined) {
73
+ file = findWorkspaceFile(dir)
74
+ files.set(dir, file)
75
+ }
76
+ return file
77
+ }
78
+
79
+ /** vx wrote a workspace config (`vx init`): what was learned before it is stale. */
80
+ export function workspaceFileWritten(): void {
81
+ files.clear()
82
+ }
83
+
84
+ async function findWorkspaceFile(start: string): Promise<string | null> {
85
+ const top = await gitTop(start)
86
+ const passed: string[] = []
87
+ let found: string | null = null
88
+ walk: for (let d = start; ; d = d.slice(0, d.lastIndexOf('/'))) {
89
+ passed.push(d || '/')
90
+ for (const name of WORKSPACE_CONFIG_FILENAMES) {
91
+ const file = `${d}/${name}`
92
+ if (await Bun.file(file).exists()) {
93
+ found = file
94
+ break walk
95
+ }
96
+ }
97
+ if (d === '' || d === top) break
98
+ }
99
+ // Every directory walked has the same answer: the loader asks for the root's.
100
+ for (const d of passed) if (!files.has(d)) files.set(d, Promise.resolve(found))
101
+ return found
102
+ }
103
+
104
+ const tops = new Map<string, Promise<string | null>>()
105
+
106
+ /**
107
+ * The git top-level holding `dir`, or null outside git: the nearest `.git`
108
+ * up from it, a directory in a clone, a file in a worktree. A submodule's
109
+ * `.git` file points into its superproject's `.git/modules`, so the walk
110
+ * goes on to the superproject's. Learned once per directory per run: the
111
+ * hand-off and the workspace root walk share it (principle 9).
112
+ */
113
+ export function gitTop(dir: string): Promise<string | null> {
114
+ let top = tops.get(dir)
115
+ if (top === undefined) {
116
+ top = findGitTop(dir)
117
+ tops.set(dir, top)
118
+ }
119
+ return top
120
+ }
121
+
122
+ async function findGitTop(start: string): Promise<string | null> {
123
+ for (let d = start; ; d = d.slice(0, d.lastIndexOf('/'))) {
124
+ // A file each: `exists()` is false for a directory.
125
+ if (await Bun.file(`${d}/.git/HEAD`).exists()) return d || '/'
126
+ const file = Bun.file(`${d}/.git`)
127
+ if (await file.exists()) {
128
+ const gitdir = await file.text().catch(() => '')
129
+ if (!/(^|[\s/])\.git\/modules\//.test(gitdir)) return d || '/'
130
+ }
131
+ if (d === '') return null
132
+ }
133
+ }
134
+
135
+ /** This vx's own file: the compiled binary, else the source entry Bun runs. */
136
+ export function vxPath(): string {
137
+ return Bun.main.startsWith('/$bunfs') ? process.execPath : Bun.main
138
+ }
139
+
140
+ /**
141
+ * Run `bin` with this process's arguments and stdio, and exit as it does.
142
+ * A signal sent to this vx alone (`kill`, a process manager) is passed on;
143
+ * one the terminal sent reached the whole foreground group already, and a
144
+ * second would read as "stop now" (npm-launcher.cjs, the same rule).
145
+ */
146
+ export async function handOff(bin: string, args: readonly string[]): Promise<never> {
147
+ const child = Bun.spawn([bin, ...args], {
148
+ stdio: ['inherit', 'inherit', 'inherit'],
149
+ env: { ...process.env, VX_HANDED_OFF: process.execPath },
150
+ })
151
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
152
+ process.on(sig, () => {
153
+ if (!inForeground()) child.kill(sig)
154
+ })
155
+ }
156
+ const code = await child.exited
157
+ const { constants } = await import('node:os')
158
+ process.exit(child.signalCode === null ? code : 128 + (constants.signals[child.signalCode] ?? 1))
159
+ }
160
+
161
+ function inForeground(): boolean {
162
+ try {
163
+ const stat = Bun.spawnSync(['ps', '-o', 'pgid=,tpgid=', '-p', String(process.pid)])
164
+ const [pgid, tpgid] = stat.stdout.toString().trim().split(/\s+/)
165
+ return pgid !== undefined && pgid === tpgid
166
+ } catch {
167
+ return false
168
+ }
169
+ }
package/src/index.ts CHANGED
@@ -9,19 +9,11 @@
9
9
  export { VERSION } from './version.js'
10
10
 
11
11
  // Clean error type — user-input failures print a message without a stack.
12
- // `clampInt` rides along for the same reason the status predicates do:
13
- // without it on the façade an integration package writes its own, and that
14
- // had already happened. A bounds helper whose floor is load-bearing (a
15
- // fractional SQL LIMIT is a datatype mismatch, not a smaller page) should
16
- // have one implementation.
17
- export { clampInt, UserError, isUserError } from './util/index.js'
12
+ export { UserError, isUserError } from './util/index.js'
18
13
  // Every hook a plugin may fill, in pipeline order — the one list the
19
14
  // loader, the host and `vx info` read; exported so a doc pin outside core
20
15
  // (the site's guides) holds its tables to the same list.
21
16
  export { PLUGIN_HOOKS, type PluginHook } from './config.js'
22
- // The workspace doctor's facts (`vx info`), for a host that answers the
23
- // same question over another surface — `@vzn/vx-mcp`'s `getWorkspaceInfo`.
24
- export { collectInfo, type CollectInfoOptions, type InfoFacts } from './orchestrator/index.js'
25
17
  // The machine as this process may use it — cores and memory capped by the
26
18
  // cgroup a container runs under. Core's default worker count reads the
27
19
  // first; a policy that budgets memory (`@vzn/vx-schedule-history`) reads
@@ -31,16 +23,15 @@ export { machineMemoryBytes, machineParallelism } from './util/index.js'
31
23
  // wildcards (`[` `]` are not; item 667) — for a plugin that turns declared
32
24
  // outputs into something else (`@vzn/vx-reapi`'s literal `output_paths`).
33
25
  export { isLiteralPattern, normalizeGlob } from './util/index.js'
34
- // A task's command as vx prints it: values of its `exec.env.secret` names
35
- // masked. `vx show` and telemetry show this; a plugin that hands a command
36
- // to someone else (`@vzn/vx-mcp`'s `listTasks`, an AI agent) does too (L-26).
37
- // `maskedLine` is the same for a line no one task owns: the error `vx mcp`
38
- // hands an agent (L-11).
39
- export { maskedCommand, maskedLine } from './util/index.js'
40
26
 
41
27
  // Schema types and helpers (used by user vx.config files and presets).
42
28
  export type {
43
29
  WorkspaceConfig,
30
+ RunSettings,
31
+ CacheSettings,
32
+ LockSettings,
33
+ WatchSettings,
34
+ Duration,
44
35
  ProjectConfig,
45
36
  TaskConfig,
46
37
  ExecConfig,
@@ -65,7 +56,7 @@ export { run, planRun, prepareRun } from './orchestrator/index.js'
65
56
  // The per-task duration history a `schedule` plugin learns from
66
57
  // (`@vzn/vx-schedule-history` does; a plugin package reaches core only
67
58
  // through this façade, which is what put these here).
68
- export { LocalHistoryProvider } from './orchestrator/index.js'
59
+ export { currentBranch, LocalHistoryProvider } from './orchestrator/index.js'
69
60
  export type {
70
61
  HistoryProvider,
71
62
  HistoryTable,
@@ -82,6 +73,7 @@ export type { AffectedChanges } from './workspace/index.js'
82
73
  export type { RunPlan, PlannedTask } from './orchestrator/index.js'
83
74
  export type { CiContext, GitContext, HostContext, WorkspaceIdentity } from './orchestrator/index.js'
84
75
  export type {
76
+ CacheFlags,
85
77
  CachePolicy,
86
78
  Logger,
87
79
  OutputView,
@@ -96,44 +88,48 @@ export { outputsOverlap, splitTaskId } from './graph/index.js'
96
88
  export type { TaskNode, TaskOutcome, TaskStatus } from './graph/index.js'
97
89
 
98
90
  // Cache classes + the layer interface (the `cache` capability's currency).
99
- export { Cache, LayeredCache } from './cache/index.js'
100
- export type { CacheLayer, RemoteCacheLayer, RunRecord, InvocationRecord } from './cache/index.js'
91
+ export { Cache } from './cache/index.js'
92
+ export type {
93
+ CacheLayer,
94
+ PluginCache,
95
+ RemoteCacheLayer,
96
+ RunRecord,
97
+ InvocationRecord,
98
+ } from './cache/index.js'
101
99
 
102
100
  // Workspace discovery + the project/config catalog surface — an
103
101
  // out-of-process service/CLI needs these. `loadProjectConfig` is the RAW
104
102
  // per-file load (`vx lock` freezes exactly that); the resolved view a run
105
103
  // or `vx show` sees — plugin stages applied — is `loadResolvedProjects`
106
- // below. Workspace's `listProjects` re-exports as `listProjectMetas` (the
107
- // bare name once belonged to a metrics query).
108
- export { findWorkspaceRoot } from './workspace/index.js'
109
- export {
110
- loadWorkspace,
111
- loadProjectConfig,
112
- listProjects as listProjectMetas,
113
- } from './workspace/index.js'
104
+ // below. `PROJECT_CONFIG_FILENAMES` are the names a project's config may
105
+ // have, in precedence order, for a tool that finds or writes one.
106
+ export { findWorkspaceRoot, PROJECT_CONFIG_FILENAMES } from './workspace/index.js'
107
+ export { loadProjectConfig } from './workspace/index.js'
114
108
  export type { ProjectMeta } from './workspace/index.js'
115
- // The package graph as a run sees it (workspace deps by manifest), for a tool
116
- // that needs a project's transitive closure the way `vx run` computes it.
117
- export { buildPackageGraph } from './workspace/index.js'
118
109
  // The run path's RESOLVED view — plugin `config` and `project` stages
119
- // applied, cached evaluations served — for a reader outside the CLI (the
120
- // MCP server's `listTasks`, an embedder's task catalog). What `vx show`
110
+ // applied — for a reader outside the CLI
111
+ // (`@vzn/vx-schedule-history`, an embedder's task catalog). What `vx show`
121
112
  // prints.
122
113
  export { loadResolvedProjects } from './orchestrator/index.js'
123
114
  export type { ProjectEntry } from './workspace/index.js'
124
115
  // The migration seam: how a generated config is planned, rendered, guarded
125
- // and written. `vx init` (package.json scripts) uses it in core;
126
- // `@vzn/vx-migrate` (Turbo, Nx) and any other adoption tool use it from here.
116
+ // and written. `@vzn/vx-migrate` (package.json scripts, Turbo, Nx) and any
117
+ // other adoption tool use it from here; `taskNameProblem` is why a name
118
+ // cannot be a task's, so a mapper leaves such a script out.
127
119
  export {
128
120
  applyMigration,
129
121
  foldScriptHooks,
130
122
  PERSISTENT_TODO,
131
123
  pruneOrphanPersistentNotes,
132
124
  quoteTsLiteral,
125
+ taskNameProblem,
133
126
  } from './workspace/index.js'
127
+ // Output components: a plugin draws its lines with core's, so they look alike.
128
+ export { ui } from './util/index.js'
134
129
  export type {
135
130
  ApplyMigrationArgs,
136
131
  MigrationFormat,
132
+ MigrationMember,
137
133
  GeneratedProject,
138
134
  GeneratedTask,
139
135
  MigrationPlan,
@@ -143,7 +139,7 @@ export type {
143
139
  // Plugin API — the run-level extension points. Behavior capabilities
144
140
  // (executor / cache) change WHAT/HOW work runs; the observe-only `telemetry`
145
141
  // capability is the canonical data-export path and cannot change behavior.
146
- // A plugin is declared in vx.workspace.ts via defineWorkspace({ plugins }).
142
+ // A plugin is declared in .vx/workspace.ts via defineWorkspace({ plugins }).
147
143
  // See docs/design/observability-architecture-2026-06.md.
148
144
  // The claimant's shell for a lockfile plugin (`@vzn/vx-lockfile`):
149
145
  // the claim, the per-project key, the memo and the `--affected` diff around
@@ -153,6 +149,7 @@ export {
153
149
  reachDigests,
154
150
  type LockfileClaimHooks,
155
151
  type LockfileClaimOptions,
152
+ type LockfileImporter,
156
153
  type ReachGraph,
157
154
  } from './orchestrator/index.js'
158
155
  export type {
@@ -162,6 +159,7 @@ export type {
162
159
  PluginOptionKinds,
163
160
  CacheContext,
164
161
  ExecutorContext,
162
+ OutputContext,
165
163
  CommandContext,
166
164
  FingerprintChange,
167
165
  FingerprintClaim,
@@ -174,8 +172,10 @@ export type {
174
172
  AdmitContext,
175
173
  WorkspaceHookContext,
176
174
  DiscoverContext,
177
- NamedProject,
175
+ DiscoveredProject,
178
176
  PluginSetupContext,
177
+ RunReport,
178
+ RunReportTask,
179
179
  PluginHookName,
180
180
  PluginHookHandlers,
181
181
  RunStartInfo,
@@ -244,26 +244,3 @@ export type {
244
244
 
245
245
  // The serializable run result and its per-task views (what `run()` returns).
246
246
  export type { RunResult, TaskView, OutcomeView } from './orchestrator/index.js'
247
-
248
- // The run-history queries a reader outside the CLI takes today: `vx why`'s
249
- // answer, a run's failures and a task's log, which `@vzn/vx-mcp` serves. The event bus /
250
- // wire form and the other history readers (`listRuns`, `getRun`, …) left the
251
- // façade 2026-09-10 with no consumer; the telemetry seam above is the
252
- // canonical export path.
253
- export {
254
- cacheKeyDiff,
255
- latestRunId,
256
- resolveRunId,
257
- rootCauses,
258
- runFailures,
259
- taskLog,
260
- whyDidThisRerunQuery,
261
- } from './orchestrator/index.js'
262
- export type {
263
- CacheKeyDiff,
264
- OutputLocation,
265
- RootCause,
266
- RunFailures,
267
- TaskFailure,
268
- TaskLog,
269
- } from './orchestrator/index.js'
@@ -29,7 +29,7 @@ import type { VxPlugin } from './plugin.js'
29
29
  import { isDefaultBuild } from './projects.js'
30
30
 
31
31
  /** Every file in this set re-keys each of its project's tasks, whatever their inputs. */
32
- const KEYED_BY_EVERY_TASK = new Set(['package.json', ...PROJECT_CONFIG_FILENAMES])
32
+ const KEYED_BY_EVERY_TASK: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
33
33
 
34
34
  /**
35
35
  * The projects every task of which the change reaches: the whole ones the
@@ -41,8 +41,9 @@ const KEYED_BY_EVERY_TASK = new Set(['package.json', ...PROJECT_CONFIG_FILENAMES
41
41
  function wholeProjects(
42
42
  changes: AffectedChanges,
43
43
  projects: ReadonlyMap<string, ProjectEntry>,
44
- ): Set<string> {
45
- const whole = new Set(changes.whole)
44
+ ): Map<string, string | undefined> {
45
+ // Each whole project, with the path no cached task claims when one did it.
46
+ const whole = new Map<string, string | undefined>([...changes.whole].map((n) => [n, undefined]))
46
47
  for (const [name, rels] of changes.paths) {
47
48
  if (whole.has(name)) continue
48
49
  const caches = Object.values(projects.get(name)?.config.tasks ?? {}).flatMap((t) =>
@@ -50,7 +51,8 @@ function wholeProjects(
50
51
  )
51
52
  const unclaimed = (rel: string): boolean =>
52
53
  KEYED_BY_EVERY_TASK.has(rel) || !caches.some((c) => declaresInput(c, rel, null))
53
- if (rels.some(unclaimed)) whole.add(name)
54
+ const rel = rels.find(unclaimed)
55
+ if (rel !== undefined) whole.set(name, rel)
54
56
  }
55
57
  return whole
56
58
  }
@@ -60,13 +62,15 @@ function wholeProjects(
60
62
  * `input`, a changed `file` is one of its declared inputs; `project`, its
61
63
  * project changed and the task reads it whole (an uncached task, a path no
62
64
  * cached task declares, or no single `file`: a lockfile claim, a manifest
63
- * edge, a config import); `package`, a `^` edge passes through `project`, a
65
+ * edge, a config import); `workspace`, a workspace-wide `file` (the
66
+ * workspace config, a root file a plugin claims) reached every task of its
67
+ * project; `package`, a `^` edge passes through `project`, a
64
68
  * changed package with no such task; `named`, asked as `pkg#task`;
65
69
  * `selected`, another filter selected its project outright. `via` is the
66
70
  * `dependsOn` chain, nearest first, to the task the change seeded.
67
71
  */
68
72
  export interface AffectedReason {
69
- kind: 'input' | 'project' | 'package' | 'named' | 'selected'
73
+ kind: 'input' | 'project' | 'workspace' | 'package' | 'named' | 'selected'
70
74
  /** Workspace-relative. */
71
75
  file?: string
72
76
  project?: string
@@ -100,7 +104,10 @@ export function affectedRoots(
100
104
  const seeds = explain === undefined ? null : new Map<string, AffectedReason>()
101
105
  const through = explain === undefined ? null : new Map<string, string>()
102
106
  const inProject = (name: string, rel: string | undefined): AffectedReason => {
103
- if (rel === undefined) return { kind: 'project' }
107
+ if (rel === undefined) {
108
+ const file = changes.wholeBy?.get(name)
109
+ return file === undefined ? { kind: 'project' } : { kind: 'workspace', file }
110
+ }
104
111
  const dir = path.relative(explain!.workspaceRoot, projects.get(name)!.dir)
105
112
  return { kind: 'project', file: dir === '' ? rel : path.posix.join(dir, rel) }
106
113
  }
@@ -157,7 +164,7 @@ export function affectedRoots(
157
164
  return hit
158
165
  }
159
166
  if (whole.has(n.projectName)) {
160
- seeds?.set(n.id, inProject(n.projectName, undefined))
167
+ seeds?.set(n.id, inProject(n.projectName, whole.get(n.projectName)))
161
168
  return true
162
169
  }
163
170
  const rel = rels.find((r) => declaresInput(cache, r, null))
@@ -0,0 +1,44 @@
1
+ // When each command of the run ran and where it may write, so
2
+ // `inputStability.strategy: 'mutable'` can tell a task's own identical
3
+ // rewrite from another task's write and restore (STATUS item 28): a file
4
+ // whose bytes match the key after a write is the task's own only if no
5
+ // other command that may write it ran between the describe and that write.
6
+
7
+ import path from 'node:path'
8
+ import type { WriteReach } from './sandbox-request.js'
9
+
10
+ interface Window {
11
+ id: string
12
+ projectDir: string
13
+ reach: WriteReach
14
+ from: number
15
+ to: number
16
+ }
17
+
18
+ export interface CommandWindows {
19
+ /** A command starts; the returned function marks its end. A server never calls it. */
20
+ open(id: string, projectDir: string, reach: WriteReach): () => void
21
+ /** Another command that may write `file` and ran at some point in [from, to]. */
22
+ writerDuring(self: string, file: string, from: number, to: number): string | undefined
23
+ }
24
+
25
+ export function commandWindows(): CommandWindows {
26
+ const windows: Window[] = []
27
+ return {
28
+ open(id, projectDir, reach) {
29
+ if (reach === 'none') return () => {}
30
+ const w: Window = { id, projectDir, reach, from: Date.now(), to: Infinity }
31
+ windows.push(w)
32
+ return () => {
33
+ w.to = Date.now()
34
+ }
35
+ },
36
+ writerDuring(self, file, from, to) {
37
+ for (const w of windows) {
38
+ if (w.id === self || w.from > to || w.to < from) continue
39
+ if (w.reach === 'workspace' || file.startsWith(w.projectDir + path.sep)) return w.id
40
+ }
41
+ return undefined
42
+ },
43
+ }
44
+ }
@@ -1,5 +1,5 @@
1
1
  // The run-scoped registry of tasks whose outputs were left in the remote
2
- // store (`--download=none`), and the lazy materialisation that fetches
2
+ // store (`--cache-download=none`), and the lazy materialisation that fetches
3
3
  // them when a locally-placed task turns out to need them.
4
4
  //
5
5
  // See docs/design/download-policy-cas-cache-2026-08.md §5. The two
@@ -9,11 +9,12 @@
9
9
  // remote store (the executor's own record) is the entry.
10
10
  // - Materialisation CONVERGES: after the bytes land, core runs the
11
11
  // ordinary `cache.save`, so the machine is indistinguishable from a
12
- // `--download=all` run and every later reader (get, prune, verify,
12
+ // `--cache-download=all` run and every later reader (get, prune, verify,
13
13
  // metrics) sees one entry shape, not two.
14
14
 
15
15
  import {
16
16
  cleanOutputs,
17
+ committedFiles,
17
18
  resolveOutputs,
18
19
  type CacheLayer,
19
20
  type GitFilesCache,
@@ -73,7 +74,7 @@ export class DeferredOutputs {
73
74
  */
74
75
  async materializeFor(node: TaskNode): Promise<void> {
75
76
  // Nothing deferred, nothing to find: the closure walk is the size of
76
- // the graph, and every task of a run without `--download=none` paid it
77
+ // the graph, and every task of a run without `--cache-download=none` paid it
77
78
  // (1.4 s of a 3,270-task cold run's main thread).
78
79
  if (this.entries.size === 0) return
79
80
  const needed: string[] = []
@@ -116,7 +117,12 @@ export class DeferredOutputs {
116
117
  // workspace-anchored can reach this path.
117
118
  const outputs = producer.config.cache?.outputs.files ?? []
118
119
  const nestedProjectDirs = this.args.nestedDirsByProject.get(producer.projectName) ?? []
119
- const cleanArgs = { projectDir: producer.projectDir, outputs, nestedProjectDirs }
120
+ const cleanArgs = {
121
+ projectDir: producer.projectDir,
122
+ outputs,
123
+ nestedProjectDirs,
124
+ committed: committedFiles(this.args.gitFilesCache, producer.projectDir),
125
+ }
120
126
 
121
127
  // Mirror `restoreHit`'s sequencing so the two paths cannot drift: wipe
122
128
  // the declared outputs, write, then tell the git snapshot what moved.
@@ -135,14 +141,10 @@ export class DeferredOutputs {
135
141
  } catch (err) {
136
142
  const msg = err instanceof Error ? err.message : String(err)
137
143
  throw new Error(
138
- `could not fetch deferred outputs of ${taskId}: ${msg} — re-run it with --force, or use --download=all`,
144
+ `could not fetch deferred outputs of ${taskId}: ${msg} — re-run it with --force, or use --cache-download=all`,
139
145
  )
140
146
  }
141
- const outputFiles = await resolveOutputs({
142
- projectDir: producer.projectDir,
143
- outputs,
144
- nestedProjectDirs,
145
- })
147
+ const outputFiles = await resolveOutputs(cleanArgs)
146
148
  this.args.gitFilesCache?.markOutputsChanged(
147
149
  producer.projectDir,
148
150
  outputFiles.map((f) => f.slice(producer.projectDir.length + 1)),