@vzn/vx 0.0.646 → 0.0.649

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 (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -5,11 +5,13 @@
5
5
  import fs from 'node:fs'
6
6
  import path from 'node:path'
7
7
  import { filesBehind } from '../cache/index.js'
8
- import { xxh3 } from '../util/index.js'
8
+ import { printable, ui, xxh3 } from '../util/index.js'
9
9
  import { gitIgnored, gitSpeller } from './watch-filter.js'
10
10
  import { modifiedBefore } from './watch-fs.js'
11
11
 
12
12
  const ABSENT = -1n
13
+ /** `settledState` of a directory with no entries. */
14
+ const EMPTY_DIR = xxh3(Buffer.from(''))
13
15
 
14
16
  function settledState(abs: string): bigint {
15
17
  let st: fs.Stats
@@ -52,6 +54,14 @@ function settledState(abs: string): bigint {
52
54
  return xxh3(Buffer.from(entries.join('\n')))
53
55
  }
54
56
 
57
+ function isDirectory(abs: string): boolean {
58
+ try {
59
+ return fs.statSync(abs).isDirectory()
60
+ } catch {
61
+ return false
62
+ }
63
+ }
64
+
55
65
  /**
56
66
  * What a link points to, as change detection reads it: a file's bytes and
57
67
  * mode, a directory's files by size and times (cache/linked-inputs.ts keys
@@ -93,6 +103,8 @@ export interface JudgeContext {
93
103
  namedIgnored?(): readonly string[]
94
104
  /** Whether `abs`, under the project at `ownDir`, lies in a project nested there (`makeFence`). */
95
105
  fenced?(ownDir: string, abs: string): boolean
106
+ /** Whether a watch waits on `abs` itself: a project's directory, or one above it. */
107
+ awaited?(abs: string): boolean
96
108
  /** The files git listed at the arm; absent when git could not answer. */
97
109
  existedAtArm?: ReadonlySet<string>
98
110
  /** The files git tracked at the arm; absent when git could not answer. */
@@ -111,6 +123,8 @@ export class ChangeJudge {
111
123
  // are judged one window after the run ends, all together — an edit made
112
124
  // meanwhile still differs from what the loop last saw and re-runs.
113
125
  readonly pending = new Map<string, string>()
126
+ /** The paths the last judgement found changed. */
127
+ changed: readonly string[] = []
114
128
  /** The last cycle's window, its start on the mtime clock (`fsClockNow`); a write inside it is the run's own. */
115
129
  lastCycle: { start: number; end: number } | undefined
116
130
  // Declared outputs are ignored by PATH above. A task with no `cache`
@@ -172,6 +186,11 @@ export class ChangeJudge {
172
186
  const prev = this.lastState.get(abs)
173
187
  this.lastState.set(abs, state)
174
188
  if (prev !== undefined) return prev === state
189
+ // A new empty directory holds nothing a key reads, and the poller,
190
+ // sampling files, never reported one: a file made in it is the edit.
191
+ // One a watch waits on (a project directory made again) is a change:
192
+ // its cycle re-arms the project.
193
+ if (state === EMPTY_DIR && isDirectory(abs) && this.ctx.awaited?.(abs) !== true) return true
175
194
  if (state === ABSENT) {
176
195
  const listed = this.ctx.existedAtArm
177
196
  return !ignored && listed !== undefined && !listed.has(spell(abs)) && !this.inNestedRepo(abs)
@@ -189,6 +208,11 @@ export class ChangeJudge {
189
208
  return false
190
209
  }
191
210
 
211
+ /** Whether git tracked `abs` when the loop armed; false when git could not answer. */
212
+ trackedAtArm(abs: string): boolean {
213
+ return this.ctx.trackedAtArm?.has(gitSpeller(this.ctx.workspaceRoot)(abs)) === true
214
+ }
215
+
192
216
  /** `spell` (git's spelling, see `gitSpeller`) given: a held server's write counts outside a cycle too. */
193
217
  private writtenDuringLastCycle(abs: string, spell?: (p: string) => string): boolean {
194
218
  // A server the last cycle left running is still that cycle's: its
@@ -204,7 +228,7 @@ export class ChangeJudge {
204
228
  try {
205
229
  // The later of the two clocks: a cache hit restores an output with
206
230
  // the mtime it was saved with, and read by mtime alone the restore
207
- // was the user's edit and ran one more cycle (solid, M 2026-10-15).
231
+ // was the user's edit and ran one more cycle (solid, 2026-10-15).
208
232
  // The restore's write moves the ctime.
209
233
  const st = fs.statSync(abs)
210
234
  const m = Math.max(st.mtimeMs, st.ctimeMs)
@@ -220,6 +244,7 @@ export class ChangeJudge {
220
244
  const spell = gitSpeller(this.ctx.workspaceRoot)
221
245
  let first: string | undefined
222
246
  let firstAbs: string | undefined
247
+ let firstIsFile = false
223
248
  // `sameState` before the `first` test, never after it: it is what
224
249
  // RECORDS a path's settled state, and every path this judgement saw
225
250
  // must be recorded even though only the first change names the cycle.
@@ -248,25 +273,40 @@ export class ChangeJudge {
248
273
  // leaves out the editor's temporary file renamed away.
249
274
  let live = 0
250
275
  let goneCount = 0
276
+ const changed: string[] = []
251
277
  for (const [p, l] of this.pending) {
252
278
  if (ignored.has(p) && !editForUncached(p)) continue
253
279
  if (this.sameState(p, ignored.has(p), spell)) continue
280
+ changed.push(p)
254
281
  if (!fs.existsSync(p)) {
255
282
  goneCount++
256
283
  gone ??= [l, p]
257
284
  continue
258
285
  }
259
286
  live++
260
- if (first !== undefined) continue
287
+ // A file names the cycle before a directory: `mkdir -p` and a write
288
+ // in it fire the directory first, and the cycle was named by it.
289
+ if (first !== undefined && (firstIsFile || isDirectory(p))) continue
261
290
  first = l
262
291
  firstAbs = p
292
+ firstIsFile = !isDirectory(p)
263
293
  }
264
294
  if (first === undefined && gone !== undefined) [first, firstAbs] = gone
295
+ // A path is anyone's bytes, and a file named `ESC ]0;…` printed raw
296
+ // in `changed …` retitled the terminal.
297
+ if (first !== undefined) first = printable(first)
265
298
  this.pending.clear()
299
+ this.changed = changed
266
300
  const more = (live > 0 ? live : goneCount) - 1
267
301
  const named = more > 0 ? `${first} (+${more} more)` : first
268
- const byServer = firstAbs !== undefined && this.ctx.held()
269
- if (firstAbs === undefined || !this.writtenDuringLastCycle(firstAbs, spell)) {
302
+ // A judgement that found nothing starts no cycle, so it breaks no
303
+ // streak. A server's log stamped to the second rewrites the same bytes
304
+ // between its changes, and an event can land after the judgement that
305
+ // read its write; resetting on either, the storm ran on unnamed (macOS
306
+ // CI, 2026-10-29).
307
+ if (firstAbs === undefined) return named
308
+ const byServer = this.ctx.held()
309
+ if (!this.writtenDuringLastCycle(firstAbs, spell)) {
270
310
  this.streak.n = 0
271
311
  return named
272
312
  }
@@ -274,10 +314,12 @@ export class ChangeJudge {
274
314
  this.streak.abs = firstAbs
275
315
  if (this.streak.n >= 3 && !this.noticed.has(firstAbs)) {
276
316
  this.noticed.add(firstAbs)
277
- process.stdout.write(
317
+ ui.say(
318
+ 'warn',
278
319
  byServer
279
320
  ? `vx watch: ${first} has started 3 cycles in a row, written while a server was running — a persistent task rewrites it. Add it to .gitignore (a git-ignored path never starts a cycle); until then every write starts a cycle.\n`
280
321
  : `vx watch: ${first} has started 3 cycles in a row, written by the cycle before each — a task rewrites it every run. Declare it in cache.outputs (an output never starts a cycle) or add it to .gitignore (a git-ignored path never does); until then every run re-runs.\n`,
322
+ 'out',
281
323
  )
282
324
  }
283
325
  return named
package/src/cli/watch.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  import fs from 'node:fs'
16
16
  import path from 'node:path'
17
17
  import { parseRunArgs, resolveRunOptions, type RunArgs } from './run.js'
18
- import { seeHelp } from './help.js'
18
+ import { refuse, seeHelp } from './help.js'
19
19
  import {
20
20
  fingerprintClaims,
21
21
  forwardedSignal,
@@ -24,10 +24,17 @@ import {
24
24
  type RunOptions,
25
25
  shellQuote,
26
26
  } from '../orchestrator/index.js'
27
- import { findWorkspaceRoot, LOCKFILE_NAME, NO_LOCK, type ProjectMeta } from '../workspace/index.js'
27
+ import {
28
+ findWorkspaceRoot,
29
+ LOCKFILE_NAME,
30
+ NO_LOCK,
31
+ type ProjectMeta,
32
+ WORKSPACE_CONFIG_FILENAMES,
33
+ } from '../workspace/index.js'
28
34
  import { type CliLoadOptions, discoverCliProjects, loadCliWorkspace } from './workspace-config.js'
29
35
  import { resolveSetting } from '../settings.js'
30
36
  import {
37
+ isProjectConfigFile,
31
38
  isWorkspaceConfigFile,
32
39
  gitFiles,
33
40
  makeFence,
@@ -38,14 +45,36 @@ import {
38
45
  import { CLOSED, fsClockNow, type WatchHandle, WatcherPool } from './watch-fs.js'
39
46
  import { ChangeJudge } from './watch-judge.js'
40
47
  import { type WatchCycle, watchCycle } from './watch-cycle.js'
41
- import { hangupIgnored, maskedLine, restartTimings, sayNotice, sayLine } from '../util/index.js'
48
+ import {
49
+ hangupIgnored,
50
+ maskedLine,
51
+ printable,
52
+ restartTimings,
53
+ sayNotice,
54
+ ui,
55
+ } from '../util/index.js'
42
56
  import { sweepConfigs, watchedProjects } from './watch-set.js'
57
+ import { isCacheHit } from '../graph/index.js'
58
+ import { resolveOutputView } from '../report/index.js'
43
59
 
44
60
  /** One line for a watcher or re-read the OS refused; the loop goes on without it. */
45
61
  function sayCannot(what: string, err: unknown): void {
46
62
  sayNotice('warn', 'vx watch', `${what}: ${err instanceof Error ? err.message : String(err)}`)
47
63
  }
48
64
 
65
+ /**
66
+ * One line for a cycle every task of which was a cache hit, where hits
67
+ * print no row: watch prints no footer, so such a cycle said nothing, and
68
+ * a failure the cycle before stayed the last outcome on screen.
69
+ */
70
+ function sayAllCached(opts: RunOptions, outcomes: readonly { status: string }[]): void {
71
+ if (outcomes.length === 0 || !outcomes.every((o) => isCacheHit(o.status))) return
72
+ const mode = resolveOutputView(opts).mode
73
+ if (mode !== 'broad' && mode !== 'errors-only' && mode !== 'none') return
74
+ const n = outcomes.length
75
+ process.stdout.write(` ${n === 1 ? '1 task' : `${n} tasks`} cached, nothing ran\n`)
76
+ }
77
+
49
78
  /** Wait this long after the last filesystem event before re-running. */
50
79
  const DEBOUNCE_MS = 150
51
80
  /**
@@ -78,16 +107,16 @@ export function watchRefusal(parsed: RunArgs): string | null {
78
107
 
79
108
  export async function watchCmd(args: readonly string[]): Promise<number> {
80
109
  const parsed = parseRunArgs(args, 'watch')
81
- if (parsed.error) {
82
- sayNotice('error', 'vx watch', `${parsed.error}${seeHelp('watch')}`)
83
- return 1
84
- }
110
+ if (parsed.error) return refuse('watch', args, `${parsed.error}${seeHelp('watch')}`, 'VX_E_USAGE')
85
111
 
86
112
  const refused = watchRefusal(parsed)
87
- if (refused !== null) {
88
- sayLine('error', refused, 'vx watch')
89
- return 1
90
- }
113
+ if (refused !== null)
114
+ return refuse(
115
+ 'watch',
116
+ args,
117
+ `${refused.replace(/^vx watch: /, '')}${seeHelp('watch')}`,
118
+ 'VX_E_USAGE',
119
+ )
91
120
  if (parsed.tasks.length === 0) {
92
121
  sayNotice(
93
122
  'error',
@@ -136,7 +165,10 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
136
165
  // Said once the loop is down, the last line: written as the signal
137
166
  // landed, the aborted cycle's summary printed below it.
138
167
  const stopped = (): number => {
139
- if (stop.signal.reason === 'SIGINT') process.stdout.write('\nvx watch: stopped\n')
168
+ if (stop.signal.reason === 'SIGINT') {
169
+ process.stdout.write('\n')
170
+ ui.say('info', 'vx watch: stopped', 'out')
171
+ }
140
172
  return 0
141
173
  }
142
174
  process.once('SIGTERM', () => stop.abort('SIGTERM'))
@@ -158,28 +190,33 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
158
190
  // No lock yet is no project yet: the first cycle fails saying so, and
159
191
  // the lock `vx lock` writes is the event that arms the set.
160
192
  let unlocked = false
161
- const allProjects = await discoverCliProjects(workspaceRoot).catch((err: unknown) => {
193
+ // The watch's own reads say no plugin warning: each cycle's run says it.
194
+ const quiet = (): void => {}
195
+ const stageLoad: CliLoadOptions = {
196
+ warn: quiet,
197
+ ...(opts.cacheDir !== undefined ? { cacheDir: opts.cacheDir } : {}),
198
+ }
199
+ const allProjects = await discoverCliProjects(workspaceRoot, stageLoad).catch((err: unknown) => {
162
200
  if (!(err instanceof Error) || err.message !== NO_LOCK) throw err
163
201
  unlocked = true
164
202
  return []
165
203
  })
166
- // `//#check` is the root project's task, under the name the run gives
167
- // it (prepare.ts, D-39): taken literally, no project was in scope and
168
- // the watch exited 1 after a green run.
169
- const rootDir = path.resolve(workspaceRoot)
170
- const rootName = allProjects.find((p) => path.resolve(p.dir) === rootDir)?.name
171
- const anchored = opts.tasks
172
- .filter((t) => t.includes('#'))
173
- .map((t) => t.slice(0, t.indexOf('#')))
174
- .map((p) => (p === '//' && rootName !== undefined ? rootName : p))
204
+ const anchored = opts.tasks.filter((t) => t.includes('#')).map((t) => t.slice(0, t.indexOf('#')))
175
205
  const named =
176
206
  anchored.length === opts.tasks.length
177
207
  ? new Set(anchored)
178
208
  : opts.projects === undefined
179
209
  ? undefined
180
210
  : new Set([...opts.projects, ...anchored])
211
+ // `//#check` is the root project's task, under the name the run gives
212
+ // it (prepare.ts, D-39): taken literally, no project was in scope and
213
+ // the watch exited 1 after a green run. Resolved per discovery: started
214
+ // with no lock, the root has no name until `vx lock` writes one.
215
+ const rootDir = path.resolve(workspaceRoot)
181
216
  const inScope = (all: readonly ProjectMeta[]): ProjectMeta[] =>
182
- named === undefined ? [...all] : all.filter((p) => named.has(p.name))
217
+ named === undefined
218
+ ? [...all]
219
+ : all.filter((p) => named.has(p.name) || (named.has('//') && path.resolve(p.dir) === rootDir))
183
220
  const scope = inScope(allProjects)
184
221
 
185
222
  // Initial run — same code path as `vx run`.
@@ -214,6 +251,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
214
251
  initial.held = runOrchestrator(initial.cycle.opts).then(
215
252
  (run) => {
216
253
  if (run.refused !== undefined) sayNotice('error', 'vx watch', run.refused)
254
+ sayAllCached(opts, run.outcomes)
217
255
  // A run that failed having run nothing refused to start: a requested
218
256
  // name no project declares (run() says which, with a "did you mean").
219
257
  // `vx run` exits 1 on it; `vx watch buidl` watched on, re-running the
@@ -259,10 +297,10 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
259
297
  delete opts.affected
260
298
  delete opts.selectedOutright
261
299
 
262
- const load: CliLoadOptions = opts.cacheDir !== undefined ? { cacheDir: opts.cacheDir } : {}
300
+ const load = stageLoad
263
301
  const swept = await sweepConfigs(allProjects, workspaceRoot, load, opts.tasks)
264
302
  const watched = await watchedProjects(workspaceRoot, allProjects, scope, load, swept.staged)
265
- const ws = await loadCliWorkspace(workspaceRoot)
303
+ const ws = await loadCliWorkspace(workspaceRoot, quiet)
266
304
  const claimsOf = (plugins: Parameters<typeof fingerprintClaims>[0]): Set<string> =>
267
305
  new Set([...fingerprintClaims(plugins).keys(), LOCKFILE_NAME])
268
306
  await runWatchLoop({
@@ -288,7 +326,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
288
326
  // set. The scope is the one resolved at start; a new project joins
289
327
  // it only as a dependency of it.
290
328
  rediscover: async () => {
291
- const all = await discoverCliProjects(workspaceRoot)
329
+ const all = await discoverCliProjects(workspaceRoot, load)
292
330
  const sweep = await sweepConfigs(all, workspaceRoot, load, opts.tasks)
293
331
  const now = await watchedProjects(workspaceRoot, all, inScope(all), load, sweep.staged)
294
332
  return {
@@ -296,7 +334,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
296
334
  // A plugin the workspace config gained since claims its file from
297
335
  // the cycle that loaded it; read once, its edits started nothing
298
336
  // until a restart.
299
- claimedRootFiles: claimsOf((await loadCliWorkspace(workspaceRoot)).plugins),
337
+ claimedRootFiles: claimsOf((await loadCliWorkspace(workspaceRoot, quiet)).plugins),
300
338
  workspaceWide: sweep.workspaceWide,
301
339
  workspaceInputs: sweep.workspaceInputs,
302
340
  outputs: sweep.outputs,
@@ -420,6 +458,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
420
458
  /** What stopped a cycle that waited on a server's readiness; the loop runs it next. */
421
459
  let interruptedBy: string | undefined
422
460
  let interruptTimer: ReturnType<typeof setTimeout> | null = null
461
+ /** The server's own writes among the paths that stopped the last cycle waiting on readiness. */
462
+ let stoppedLast = new Set<string>()
423
463
  // Judged on settled state one window later, as an idle edit is, and only
424
464
  // if the cycle still waits on readiness alone by then.
425
465
  const interruptIfWaiting = (): void => {
@@ -431,12 +471,49 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
431
471
  if (c === undefined || c.interrupted || !c.waitsOnReadiness() || stop.aborted) return
432
472
  const label = changes.judge()
433
473
  if (label === undefined) return
474
+ // What a server writes in its project as it boots (a timestamped
475
+ // config bundle, a route tree) is an edit: it stopped the cycle, the
476
+ // next server wrote it again, and the loop restarted it for good,
477
+ // never ready. A path that stopped the last such cycle, untracked
478
+ // and in a waiting server's project, is that server's own write.
479
+ const dirs = c.serverDirs()
480
+ const own = changes.changed.filter(
481
+ (p) => !changes.trackedAtArm(p) && dirs.some((d) => p.startsWith(d + path.sep)),
482
+ )
483
+ const again = own.length === changes.changed.length && own.every((p) => stoppedLast.has(p))
484
+ stoppedLast = new Set(own)
485
+ if (again) return
434
486
  interruptedBy = label
435
487
  c.interrupt()
436
488
  }, DEBOUNCE_MS)
437
489
  }
438
490
  let windowOpened = 0
439
491
 
492
+ const hasWorkspaceFile = (): boolean =>
493
+ WORKSPACE_CONFIG_FILENAMES.some((n) => fs.existsSync(path.join(workspaceRoot, n)))
494
+ const lockAbs = path.join(workspaceRoot, LOCKFILE_NAME)
495
+ const lockStamp = (): string => {
496
+ try {
497
+ const st = fs.statSync(lockAbs)
498
+ return `${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`
499
+ } catch {
500
+ return ''
501
+ }
502
+ }
503
+ /** The lock as the last cycle found it: a config edit with no re-lock since changes no command. */
504
+ let lockSeen = lockStamp()
505
+ // Every cycle's configs are the lock's, so a config edit alone ran a cycle
506
+ // that printed `changed` and nothing else, read as the edit applied. A
507
+ // config gone is the run's own refusal to name.
508
+ const sayUnlocked = (paths: readonly string[]): void => {
509
+ for (const abs of paths)
510
+ if ((isProjectConfigFile(abs) || configImportFiles.includes(abs)) && fs.existsSync(abs))
511
+ sayNotice(
512
+ 'warn',
513
+ 'vx watch',
514
+ `${printable(path.relative(workspaceRoot, abs))} changed; runs read the lock, so run \`vx lock\` to apply it`,
515
+ )
516
+ }
440
517
  const trigger = (label: string, abs: string): void => {
441
518
  if (!changes.pending.has(abs)) changes.pending.set(abs, label)
442
519
  if (running) return interruptIfWaiting()
@@ -461,6 +538,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
461
538
  const runCycle = async (first: string | undefined, initial?: InitialCycle): Promise<void> => {
462
539
  try {
463
540
  let label: string | undefined = first
541
+ let lockAtRun = ''
464
542
  // The initial run is collected even after a stop: its server is the
465
543
  // loop's to stop.
466
544
  while (initial !== undefined || (label !== undefined && !stop.aborted)) {
@@ -473,9 +551,31 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
473
551
  // new package while its first cycle runs queues like any other; armed
474
552
  // only after, it fell in the gap and ran nothing (X-138). The re-read
475
553
  // after the run stays, for what the run itself changed.
554
+ lockAtRun = lockStamp()
555
+ const relocked = lockAtRun !== lockSeen
556
+ lockSeen = lockAtRun
557
+ // The file marks the root, and `vx run` refuses without it; a
558
+ // cycle ran on with no workspace config, its plugins gone and
559
+ // its tasks named by path.
560
+ if (!hasWorkspaceFile()) {
561
+ process.stdout.write(`\nchanged ${label}\n`)
562
+ throw new Error(
563
+ `no .vx/workspace.ts in ${workspaceRoot}; restore it, or stop vx watch`,
564
+ )
565
+ }
476
566
  if (reread) await rearm(false)
477
- process.stdout.write(`\nchanged ${label}\n`)
567
+ process.stdout.write('\n')
568
+ ui.say('info', `changed ${label}`, 'out')
569
+ if (!relocked) sayUnlocked(changes.changed)
478
570
  if (stop.aborted) break
571
+ // A file a `.gitignore` edit stops ignoring is an input from this
572
+ // cycle on, and was not on the arm's inventory: its deletion read
573
+ // as a file born and gone since, and ran nothing.
574
+ if (
575
+ atArm !== undefined &&
576
+ changes.changed.some((p) => path.basename(p) === '.gitignore')
577
+ )
578
+ for (const p of gitFiles(workspaceRoot)?.listed ?? []) atArm.listed.add(p)
479
579
  restartTimings()
480
580
  // On the mtime clock, as the arm is: from `Date.now()` a write the
481
581
  // run made within a tick of it carried an earlier mtime and read as
@@ -491,6 +591,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
491
591
  interruptIfWaiting,
492
592
  )
493
593
  const cycle = await runOrchestrator(current.opts)
594
+ sayAllCached(opts, cycle.outcomes)
494
595
  lastTasks = current.tasks ?? lastTasks
495
596
  current = undefined
496
597
  held = cycle.persistent
@@ -509,11 +610,18 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
509
610
  // A config's own throw or a plugin's failure may quote a secret (L-11).
510
611
  const message = maskedLine(err instanceof Error ? err.message : String(err))
511
612
  sayNotice('error', 'vx watch', `cycle failed: ${message}`)
613
+ } finally {
614
+ // A re-lock since this cycle began: the re-arm below can
615
+ // close the watcher its event was still on its way to (FSEvents'
616
+ // latency, a poller's tick), and the new lock ran nothing until
617
+ // the next edit (macOS CI, 2026-10-23). A cycle that read the
618
+ // lock mid-write failed on it, and is the same case.
619
+ if (lockStamp() !== lockAtRun) rootFileEvent(LOCKFILE_NAME)
512
620
  }
513
621
  // After a failed cycle too: a package added with a config that does
514
622
  // not load yet fails its cycle, and unarmed, the fix to that config
515
623
  // was never an event.
516
- if (reread && !stop.aborted) {
624
+ if (reread && !stop.aborted && hasWorkspaceFile()) {
517
625
  reread = false
518
626
  await rearm()
519
627
  }
@@ -521,6 +629,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
521
629
  // after the run, under the label of what actually arrived.
522
630
  current = undefined
523
631
  if (stop.aborted) break
632
+ if (interruptedBy === undefined) stoppedLast = new Set()
524
633
  if (interruptedBy !== undefined) {
525
634
  label = interruptedBy
526
635
  interruptedBy = undefined
@@ -561,7 +670,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
561
670
  // unconditional segments AND this run's declared output containers. Read
562
671
  // through `isIgnoredPath` rather than captured, because `rearm` replaces
563
672
  // the filter when the selection changes.
564
- const pool = new WatcherPool((dir, rel) => isIgnoredPath(dir, rel), args.poll)
673
+ const pool = new WatcherPool((dir, rel) => isIgnoredPath(dir, rel, true), args.poll)
565
674
  const arm = (dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle =>
566
675
  pool.arm(dir, recursive, onEvent)
567
676
 
@@ -575,6 +684,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
575
684
  uncached: () => uncached,
576
685
  namedIgnored: () => namedIgnored,
577
686
  fenced: (ownDir, abs) => fenced(ownDir, abs),
687
+ awaited: (abs) => ancestorNames.get(path.dirname(abs))?.has(path.basename(abs)) === true,
578
688
  ...(atArm !== undefined ? { existedAtArm: atArm.listed, trackedAtArm: atArm.tracked } : {}),
579
689
  })
580
690
  /** Per-project arms by directory, so `rearm` can add and drop them. */
@@ -651,6 +761,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
651
761
  // too: its edit can add an output or a `workspaceFiles` input.
652
762
  if (shapesWatchedSet(filename) || configImportFiles.includes(abs) || linkChanged(abs))
653
763
  reread = true
764
+ // Inside a project (any repo whose root is one) no import arm hears it.
765
+ if (wsConfigImportFiles.includes(abs)) sayStaleImport(abs)
654
766
  trigger(`${proj.name} ${filename}`, abs)
655
767
  }
656
768
  const armProject = (proj: ProjectMeta): void => {
@@ -716,6 +828,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
716
828
  configImportFiles.includes(abs)
717
829
  )
718
830
  reread = true
831
+ if (wsConfigImportFiles.includes(abs)) sayStaleImport(abs)
719
832
  const owner = ownerOf(abs)
720
833
  trigger(
721
834
  owner === undefined
@@ -776,6 +889,17 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
776
889
  const importArms = new Map<string, WatchHandle>()
777
890
  let importNames = new Map<string, Set<string>>()
778
891
  const staleNamed = new Map<string, number>()
892
+ const sayStaleImport = (abs: string): void => {
893
+ // One line per save, not per event: a save is often two.
894
+ const now = Date.now()
895
+ if (now - (staleNamed.get(abs) ?? 0) < 1_000) return
896
+ staleNamed.set(abs, now)
897
+ ui.say(
898
+ 'warn',
899
+ `vx watch: ${printable(path.relative(workspaceRoot, abs))} changed; the workspace config imports it, and this process loaded it at start — restart vx watch to apply the edit\n`,
900
+ 'out',
901
+ )
902
+ }
779
903
  const armImports = (): void => {
780
904
  const inProject = (f: string): boolean => projectDirs.some((d) => f.startsWith(d + path.sep))
781
905
  const want = new Map<string, Set<string>>()
@@ -807,13 +931,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
807
931
  trigger(`config import ${rel}`, abs)
808
932
  return
809
933
  }
810
- // One line per save, not per event: a save is often two.
811
- const now = Date.now()
812
- if (now - (staleNamed.get(abs) ?? 0) < 1_000) return
813
- staleNamed.set(abs, now)
814
- process.stdout.write(
815
- `vx watch: ${rel} changed; the workspace config imports it, and this process loaded it at start — restart vx watch to apply the edit\n`,
816
- )
934
+ sayStaleImport(abs)
817
935
  }),
818
936
  )
819
937
  armedAs.set(dir, inodeOf(dir) ?? '')
@@ -890,11 +1008,11 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
890
1008
  const plural = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`
891
1009
  const sayWatching = (count: number): void => {
892
1010
  const tasks = lastTasks === undefined ? '' : ` · ${plural(lastTasks, 'task')}`
893
- process.stdout.write(`watching ${plural(count, 'project')}${tasks}\n`)
1011
+ ui.say('info', `watching ${plural(count, 'project')}${tasks}`, 'out')
894
1012
  }
895
1013
  const debugArms = resolveSetting('debug.watch', undefined).value === true
896
1014
  const sayArmed = (count: number): void => {
897
- if (debugArms) process.stderr.write(`vx watch: armed ${plural(count, 'project')}\n`)
1015
+ if (debugArms) ui.say('info', `vx watch: armed ${plural(count, 'project')}`)
898
1016
  }
899
1017
 
900
1018
  const rearm = async (say = true): Promise<void> => {
@@ -902,7 +1020,9 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
902
1020
  try {
903
1021
  next = await args.rediscover()
904
1022
  } catch (err) {
905
- sayCannot(`cannot re-read the workspace`, err)
1023
+ // No lock is the cycle's own failure to say; said here too, it was two lines.
1024
+ if (!(err instanceof Error && err.message === NO_LOCK))
1025
+ sayCannot(`cannot re-read the workspace`, err)
906
1026
  return
907
1027
  }
908
1028
  projectDirs = next.projects.map((p) => p.dir)
@@ -931,7 +1051,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
931
1051
  dropMode()
932
1052
  workspaceWide = next.workspaceWide
933
1053
  armMode(next.projects)
934
- } else if (!workspaceWide) {
1054
+ } else if (workspaceWide) wide = next.projects
1055
+ else {
935
1056
  streamed = oneStream ? toStream(next.projects) : []
936
1057
  const inStream = new Set(streamed.map((s) => s.proj))
937
1058
  const own = next.projects.filter((p) => !inStream.has(p))
@@ -1015,7 +1136,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
1015
1136
  // A project directory still the one its arm holds is no change:
1016
1137
  // its arm judges what is in it. macOS reports the directory's
1017
1138
  // mtime here on every write at its top level, and a cycle's own
1018
- // write started the next (solid's `types`, M 2026-10-15).
1139
+ // write started the next (solid's `types`, 2026-10-15).
1019
1140
  if (heldDirs.get(abs) === (inodeOf(abs) ?? '')) return
1020
1141
  reread = true
1021
1142
  trigger(path.relative(workspaceRoot, abs), abs)