@vzn/vx 0.0.651 → 0.0.653

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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -26,11 +26,18 @@
26
26
  // `--ui=stream` mode.
27
27
 
28
28
  import { withForwardArgs } from '../exec/index.js'
29
- import { isCacheHit, isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
29
+ import {
30
+ isCacheHit,
31
+ isGroupTask,
32
+ isKeyFailed,
33
+ type TaskNode,
34
+ type TaskOutcome,
35
+ } from '../graph/index.js'
30
36
  import { formatElapsed, maskedCommand, printable } from '../util/index.js'
31
37
  import * as ui from '../ui/index.js'
32
38
  import {
33
39
  fileMemo,
40
+ GUTTER,
34
41
  hasEscape,
35
42
  idParts,
36
43
  lastRedraw,
@@ -99,7 +106,7 @@ export function formatTaskBlock(
99
106
  // (success and failed). Cache hits normally skip it (the stored
100
107
  // output is the interesting part), but `forceCommand` keeps it for
101
108
  // focused requested tasks so their frame is hit/miss-identical.
102
- if (forceCommand || outcome.status === 'success' || outcome.status === 'failed') {
109
+ if (forceCommand || ranCommand(outcome)) {
103
110
  // No section label for the command (owner cut it) — the dim `$ `
104
111
  // line under the header reads as the command on its own.
105
112
  lines.push('', corner(`$ ${shownCommand(node, forwardArgs)}`), '')
@@ -120,8 +127,10 @@ export function formatTaskBlock(
120
127
 
121
128
  lines.push(
122
129
  `${corner('└─')} ${mark}${idPainted}${whereNote(outcome, colors)} ${corner('──')}${running ? runningFooter(outcome, colors) : formatBlockFooter(outcome, colors)}`,
130
+ '',
123
131
  )
124
- return lines.join('\n') + '\n'
132
+ // Not `+ '\n'`: a rope over a 100 MB frame was flattened into a copy.
133
+ return lines.join('\n')
125
134
  }
126
135
 
127
136
  /** What a bounded capture dropped from its head, so a capped log never reads as whole. */
@@ -172,27 +181,41 @@ function pushOutputSection(
172
181
  if (lost > 0) {
173
182
  lines.push(droppedNote(lost, colors))
174
183
  }
175
- const body: Array<{ text: string; err: boolean }> = []
184
+ const emit = (line: string, err: boolean): void => {
185
+ const text = link === undefined ? line : link(line)
186
+ if (!err || !colors.enabled) lines.push(text)
187
+ else lines.push(hasEscape(line) ? text : paint('error', text, colors))
188
+ }
189
+ // A run of whole lines nothing restyles is pushed as one slice, its
190
+ // newlines inside: an object and a string per line peaked a 50 MB log's
191
+ // frame at ~11x its size.
176
192
  let open: { parts: string[]; err: boolean } | null = null
177
193
  for (const c of output) {
178
- let rest = c.text
179
- while (rest.length > 0) {
180
- const nl = rest.indexOf('\n')
181
- const piece = nl < 0 ? rest : rest.slice(0, nl)
182
- if (open === null) open = { parts: [], err: c.err }
183
- open.parts.push(piece)
184
- if (nl < 0) break
185
- body.push({ text: open.parts.join(''), err: open.err })
194
+ const text = c.text
195
+ let at = 0
196
+ if (open !== null) {
197
+ const nl = text.indexOf('\n')
198
+ open.parts.push(nl < 0 ? text : text.slice(0, nl))
199
+ if (nl < 0) continue
200
+ emit(open.parts.join(''), open.err)
186
201
  open = null
187
- rest = rest.slice(nl + 1)
202
+ at = nl + 1
188
203
  }
204
+ if (link === undefined && (!c.err || !colors.enabled)) {
205
+ const last = text.lastIndexOf('\n')
206
+ if (last >= at) {
207
+ lines.push(text.slice(at, last))
208
+ at = last + 1
209
+ }
210
+ } else {
211
+ for (let nl = text.indexOf('\n', at); nl >= 0; nl = text.indexOf('\n', at)) {
212
+ emit(text.slice(at, nl), c.err)
213
+ at = nl + 1
214
+ }
215
+ }
216
+ if (at < text.length) open = { parts: [text.slice(at)], err: c.err }
189
217
  }
190
- if (open !== null) body.push({ text: open.parts.join(''), err: open.err })
191
- for (const l of body) {
192
- const text = link === undefined ? l.text : link(l.text)
193
- if (!l.err || !colors.enabled) lines.push(text)
194
- else lines.push(hasEscape(l.text) ? text : paint('error', text, colors))
195
- }
218
+ if (open !== null) emit(open.parts.join(''), open.err)
196
219
  const styled = output.findLast((c) => hasEscape(c.text))
197
220
  if (styled !== undefined && leavesStyle(styled.text)) lines.push(`${lines.pop()}${STYLE_END}`)
198
221
  lines.push('')
@@ -251,14 +274,13 @@ function paintTaskId(node: TaskNode, colors: ColorSupport, opts: { bold?: boolea
251
274
  return paintId(node.projectName, node.taskName, colors, opts)
252
275
  }
253
276
 
254
- // ── Reported-line grid: glyph · time · status · cache · name ────────
255
- // glyph SHAPE encodes the cache axis (⏺ miss · ► fresh · ⇢ local · ⇣
256
- // remote; ◼ failed · ⊘ skipped · ✗ aborted · ◌ not run), glyph COLOR encodes the
257
- // task axis (green/red/yellow/cyan). The status and cache WORDS spell
258
- // the two axes out, each in its own color. Time is right-aligned in a
277
+ // ── Reported-line grid: status · cache · usual · time · … · name ────────
278
+ // No glyph (2026-11-06): the status word, first at the Summary's value
279
+ // column, is the task axis and the cache word the cache axis, each in
280
+ // its own color. Time is right-aligned in a
259
281
  // fixed cell so durations line up and a ticking elapsed never shifts
260
282
  // the row. All detail (exit code, output) lives in the framed block.
261
- export const TIME_COL = 7 // "0ms" … "999.99s", then "16m 40s" … "99m 59s"
283
+ const TIME_COL = 7 // "0ms" … "999.99s", then "16m 40s" … "99m 59s"
262
284
  const STATUS_COL = 7 // "success" / "running" / "skipped" / "aborted" / "not run"
263
285
  const CACHE_COL = 8 // "no-cache" / "remote" / "local" / "fresh" / "miss"
264
286
  const BLOCKED_COL = 7 // `blocks`: how many tasks are downstream of the row's
@@ -268,13 +290,23 @@ const MEM_COL = 6 // "412MB", "1.1GB", "1023MB"
268
290
  const OUT_COL = 7 // `outputs`: "12MB", "900B"
269
291
  const WORKER_COL = 12 // `local`, or a plugin's name for the machine, cut at 12
270
292
 
293
+ // A row's padding, built once: `' '.repeat` per cell was ~7 ms of an
294
+ // 8,002-hit warm run's rows, the largest single cost of writing them.
295
+ const SPACES = Array.from({ length: 33 }, (_, n) => ' '.repeat(n))
296
+
297
+ /** `n` spaces (none below 1). */
298
+ function pad(n: number): string {
299
+ return n <= 0 ? '' : (SPACES[n] ?? ' '.repeat(n))
300
+ }
301
+
271
302
  /** Right-align the duration in a TIME_COL cell (pad on the left). */
272
303
  function timeCell(ms: number | null, colors: ColorSupport): string {
273
- let raw = ms === null ? '' : formatElapsed(ms)
304
+ if (ms === null) return pad(TIME_COL - 1) + paint('muted', UNKNOWN, colors)
305
+ let raw = formatElapsed(ms)
274
306
  // `1000.00s` is wider than the cell; minutes fit it up to `99m 59s`.
275
307
  if (ms !== null && raw.length > TIME_COL) raw = formatElapsed(ms, true)
276
308
  // The time is the row's fact (value level); the cells around it are meta.
277
- return ' '.repeat(Math.max(0, TIME_COL - raw.length)) + paint('value', raw, colors)
309
+ return pad(TIME_COL - raw.length) + paint('value', raw, colors)
278
310
  }
279
311
 
280
312
  /**
@@ -284,25 +316,7 @@ function timeCell(ms: number | null, colors: ColorSupport): string {
284
316
  */
285
317
  function cell(text: string, color: Style | '', width: number, colors: ColorSupport): string {
286
318
  const painted = text === '' ? '' : paint(color === '' ? 'muted' : color, text, colors)
287
- return painted + ' '.repeat(Math.max(0, width - text.length))
288
- }
289
-
290
- /** The cache-shape glyph (task-shape for non-success). */
291
- function glyphShape(o: TaskOutcome): string {
292
- switch (o.status) {
293
- case 'success':
294
- return '⏺\uFE0E' // ⏺ miss (ran), text-presentation (narrow)
295
- case 'cache-hit':
296
- return o.restored === false ? '\u25ba' : '\u21e2' // ► fresh : ⇢ local
297
- case 'cache-hit-remote':
298
- return o.restored === false ? '\u25ba' : '\u21e3' // ► fresh : ⇣ remote
299
- case 'failed':
300
- return '\u25fc\uFE0E' // ◼ failed, text-presentation (narrow)
301
- case 'skipped':
302
- return '\u2298' // ⊘ skipped
303
- default:
304
- return '⏺\uFE0E'
305
- }
319
+ return painted + pad(width - text.length)
306
320
  }
307
321
 
308
322
  /** Task-axis word + color (a cache hit is a success). */
@@ -317,8 +331,19 @@ function statusOf(o: TaskOutcome): { word: string; color: Style } {
317
331
  }
318
332
  }
319
333
 
320
- /** Cache-axis word + color. Empty for states that never reached the cache. */
334
+ /** Whether the task ran its command: a hit whose restore failed did not, nor a task with no key. */
335
+ function ranCommand(o: TaskOutcome): boolean {
336
+ return (
337
+ o.status === 'success' || (o.status === 'failed' && o.restoreFailed !== true && !isKeyFailed(o))
338
+ )
339
+ }
340
+
341
+ /**
342
+ * Cache-axis word + color. Empty for states that never reached the cache,
343
+ * a hit whose restore failed (neither a miss nor a hit) and a task with no key.
344
+ */
321
345
  function cacheOf(o: TaskOutcome): { word: string; color: Style } {
346
+ if (o.restoreFailed === true || isKeyFailed(o)) return { word: '', color: 'muted' }
322
347
  switch (o.status) {
323
348
  case 'success':
324
349
  case 'failed':
@@ -337,20 +362,14 @@ function cacheOf(o: TaskOutcome): { word: string; color: Style } {
337
362
  }
338
363
  }
339
364
 
340
- /** The painted status glyph (shape = cache axis, color = task axis). */
341
- function taskGlyph(o: TaskOutcome, colors: ColorSupport): string {
342
- return paint(statusOf(o).color, glyphShape(o), colors)
343
- }
344
-
345
365
  /**
346
- * One reported task row on the list grid: `<glyph> <time> <usual>
347
- * <status> <cache> <cpu> <mem> <blocks> <worker> <name>`. `glyph` is
348
- * pre-painted; status/cache are raw text + color, padded to their
366
+ * One reported task row on the list grid: `<status> <cache> <usual>
367
+ * <time> <cpu> <mem> <outputs> <blocks> <worker> <mark> <name>`, the status at
368
+ * the Summary's value column, no glyph (2026-11-06). Status/cache are raw text + color, padded to their
349
369
  * columns; `paintedId` carries identity coloring. `ms = null` blanks the
350
370
  * time (a skip never ran); `cache = ''` and an absent cell blank theirs.
351
371
  */
352
372
  export function formatTaskRow(
353
- glyph: string,
354
373
  ms: number | null,
355
374
  status: string,
356
375
  statusColor: Style | '',
@@ -360,35 +379,43 @@ export function formatTaskRow(
360
379
  colors: ColorSupport = NO_COLOR,
361
380
  cells: RowCells = {},
362
381
  ): string {
363
- const st = cell(status, statusColor, STATUS_COL, colors)
364
- // `aborting` runs one into the cache cell, which is blank beside it.
365
- const ca = cell(cache, cacheColor, CACHE_COL - Math.max(0, status.length - STATUS_COL), colors)
366
- const usual = cells.usual ?? { text: '', painted: '' }
367
- const us = `${' '.repeat(Math.max(0, USUAL_COL - usual.text.length))}${usual.painted}`
368
- const cpu =
369
- cells.cpu === undefined ? '' : cells.cpu === null ? UNKNOWN : `${Math.round(cells.cpu * 100)}%`
370
- const mem =
371
- cells.memBytes === undefined
372
- ? ''
373
- : cells.memBytes === null
374
- ? UNKNOWN
375
- : formatSize(cells.memBytes)
376
- const out = cells.outputBytes === undefined ? '' : formatSize(cells.outputBytes)
377
- const blocks = cells.blocks ?? ''
378
- const worker = workerName(cells.worker ?? '')
382
+ // Every cell says something: one with nothing in it reads `—` (2026-11-06).
383
+ const st = cell(status || UNKNOWN, statusColor, STATUS_COL, colors)
384
+ // `aborting` runs one into the cache cell.
385
+ const ca = cell(
386
+ cache || UNKNOWN,
387
+ cache === '' ? '' : cacheColor,
388
+ CACHE_COL - Math.max(0, status.length - STATUS_COL),
389
+ colors,
390
+ )
391
+ const usual =
392
+ cells.usual === undefined || cells.usual.text === '' ? unknownUsual(colors) : cells.usual
393
+ const us = `${pad(USUAL_COL - usual.text.length)}${usual.painted}`
394
+ const cpu = cells.cpu == null ? UNKNOWN : `${Math.round(cells.cpu * 100)}%`
395
+ const mem = cells.memBytes == null ? UNKNOWN : formatSize(cells.memBytes)
396
+ const out = cells.outputBytes == null ? UNKNOWN : formatSize(cells.outputBytes)
397
+ const blocks = cells.blocks || UNKNOWN
398
+ const worker = workerName(cells.worker || UNKNOWN)
379
399
  return (
380
- ` ${glyph} ${timeCell(ms, colors)} ${us} ${st} ${ca} ` +
400
+ `${ROW_LEAD}${st} ${ca} ${us} ${timeCell(ms, colors)} ` +
381
401
  `${right(cpu, CPU_COL, colors)} ${right(mem, MEM_COL, colors)} ${right(out, OUT_COL, colors)} ` +
382
- `${' '.repeat(Math.max(0, BLOCKED_COL - blocks.length))}${paint(blocks === '0' ? 'muted' : 'value', blocks, colors)} ` +
383
- `${placeCell(worker, colors)}${' '.repeat(WORKER_COL - worker.length)} ${paintedId}`
402
+ `${pad(BLOCKED_COL - blocks.length)}${paint(blocks === '0' || blocks === UNKNOWN ? 'muted' : 'value', blocks, colors)} ` +
403
+ `${placeCell(worker, colors)}${pad(WORKER_COL - worker.length)} ` +
404
+ `${cells.critical === true ? paint('warn', CRITICAL_MARK, colors) : ' '} ${paintedId}`
384
405
  )
385
406
  }
386
407
 
408
+ /** A cell with nothing in it: unknown, or not applying to the row (a hit ran nothing). */
409
+ const UNKNOWN = '\u2014'
410
+
387
411
  /**
388
- * A cell vx cannot know: a peak no look saw, a task history never timed.
389
- * Blank is a cell that does not apply (a hit ran nothing).
412
+ * The critical path's mark, before the task's name: the row's task held the
413
+ * run's end while others ran beside it. One glyph, yellow (2026-11-06).
390
414
  */
391
- const UNKNOWN = '\u2014'
415
+ const CRITICAL_MARK = '\u25c6'
416
+
417
+ /** A grid row's lead: the gutter, so a row starts where a field's value does. */
418
+ const ROW_LEAD = ' '.repeat(GUTTER)
392
419
 
393
420
  /** A row's cells past time, status and cache; each blank when absent, `—` when null. */
394
421
  export interface RowCells {
@@ -399,15 +426,17 @@ export interface RowCells {
399
426
  /** Resident memory now (live) or at its peak (after). */
400
427
  memBytes?: number | null
401
428
  /** The bytes of the task's outputs: what its save stored, or its hit restored. */
402
- outputBytes?: number
429
+ outputBytes?: number | null
403
430
  /** How many tasks are downstream. */
404
431
  blocks?: string
405
432
  /** The machine it runs or ran on: `local`, or the executor's name for it. */
406
433
  worker?: string
434
+ /** On the critical path: `◆` before the task's name. */
435
+ critical?: boolean
407
436
  }
408
437
 
409
438
  function right(text: string, width: number, colors: ColorSupport): string {
410
- return ' '.repeat(Math.max(0, width - text.length)) + paint('muted', text, colors)
439
+ return pad(width - text.length) + paint('muted', text, colors)
411
440
  }
412
441
 
413
442
  /** `900B`, `412MB`, `1.1GB`: one width for a size, whatever it is. */
@@ -426,7 +455,7 @@ export function formatSize(bytes: number): string {
426
455
  const placeCells = new WeakMap<ColorSupport, Map<string, string>>()
427
456
 
428
457
  function placeCell(worker: string, colors: ColorSupport): string {
429
- if (worker === '') return ''
458
+ if (worker === UNKNOWN) return paint('muted', UNKNOWN, colors)
430
459
  let byName = placeCells.get(colors)
431
460
  if (byName === undefined) placeCells.set(colors, (byName = new Map()))
432
461
  let painted = byName.get(worker)
@@ -488,10 +517,10 @@ export function formatRunningHeader(colors: ColorSupport = NO_COLOR): string {
488
517
 
489
518
  function header(usual: string, colors: ColorSupport): string {
490
519
  const head =
491
- ` ${'time'.padStart(TIME_COL)} ${usual.padStart(USUAL_COL)} ` +
492
- `${'status'.padEnd(STATUS_COL)} ${'cache'.padEnd(CACHE_COL)} ${'cpu'.padStart(CPU_COL)} ` +
493
- `${'mem'.padStart(MEM_COL)} ${'outputs'.padStart(OUT_COL)} ${'blocks'.padStart(BLOCKED_COL)} ${'worker'.padEnd(WORKER_COL)} task`
494
- return paint('label', head, colors)
520
+ `${'status'.padEnd(STATUS_COL)} ${'cache'.padEnd(CACHE_COL)} ${usual.padStart(USUAL_COL)} ` +
521
+ `${'time'.padStart(TIME_COL)} ${'cpu'.padStart(CPU_COL)} ` +
522
+ `${'mem'.padStart(MEM_COL)} ${'outputs'.padStart(OUT_COL)} ${'blocks'.padStart(BLOCKED_COL)} ${'worker'.padEnd(WORKER_COL)} task`
523
+ return paint('muted', head, colors)
495
524
  }
496
525
 
497
526
  /**
@@ -499,7 +528,7 @@ function header(usual: string, colors: ColorSupport): string {
499
528
  * body, and `vx last`'s, so a replay reads as the run did.
500
529
  */
501
530
  export function taskList(rows: readonly ((colors: ColorSupport) => string)[]): ui.Block {
502
- return { lines: (c) => [formatListHeader(c), ...rows.map((row) => row(c))] }
531
+ return { head: formatListHeader, lines: (c) => rows.map((row) => row(c)) }
503
532
  }
504
533
 
505
534
  /** What history keeps of a task's row: `vx last` draws it again from this. */
@@ -508,6 +537,8 @@ export interface RecordedRow {
508
537
  task: string
509
538
  status: string
510
539
  durationMs: number
540
+ /** Null on a cached task's failure: its hit's restore failed. */
541
+ cacheHit: boolean | null
511
542
  restored: boolean | null
512
543
  cached: boolean | null
513
544
  cpuMs: number | null
@@ -532,12 +563,15 @@ export function formatRecordedRow(
532
563
  id: `${r.project}#${r.task}`,
533
564
  projectName: r.project,
534
565
  taskName: r.task,
535
- config: r.cached === false ? {} : { cache: {} },
566
+ config: r.cached === false ? {} : { cache: true },
536
567
  } as unknown as TaskNode
537
568
  const o = {
538
569
  node,
539
570
  status: r.status,
540
571
  durationMs: r.durationMs,
572
+ ...(r.status === 'failed' && r.cached === true && r.cacheHit === null
573
+ ? { restoreFailed: true }
574
+ : {}),
541
575
  ...(r.restored !== null ? { restored: r.restored } : {}),
542
576
  ...(r.cpuMs !== null ? { cpuMs: r.cpuMs } : {}),
543
577
  ...(r.peakRssBytes !== null ? { peakRssBytes: r.peakRssBytes } : {}),
@@ -573,14 +607,13 @@ export function formatExpected(ms: number): string {
573
607
  /** A task `--dry` lists: what the run would do with it, on the running list's grid. */
574
608
  export interface PlannedRow {
575
609
  node: TaskNode
576
- glyph: string
577
610
  status: string
578
- /** The glyph's and the status word's hue. */
611
+ /** The status word's hue. */
579
612
  tone: Style
580
613
  cache: string
581
614
  cacheTone: Style | ''
582
- /** Its usual time, from history. */
583
- usualMs?: number
615
+ /** Its usual time, from history; null for a task that runs and history never timed. */
616
+ usualMs?: number | null
584
617
  /** What a remote hit would download. */
585
618
  outputBytes?: number
586
619
  /** How many tasks are downstream of it. */
@@ -593,12 +626,16 @@ export interface PlannedRow {
593
626
  /** `--dry`'s list: the running list's header and grid, so a plan reads as the run would. */
594
627
  export function planList(rows: readonly PlannedRow[]): ui.Block {
595
628
  return {
596
- lines: (c) => [
597
- formatRunningHeader(c),
598
- ...rows.map((r) => {
599
- const usual = r.usualMs === undefined ? undefined : formatExpected(r.usualMs)
629
+ head: formatRunningHeader,
630
+ lines: (c) =>
631
+ rows.map((r) => {
632
+ const usual =
633
+ r.usualMs === undefined
634
+ ? undefined
635
+ : r.usualMs === null
636
+ ? UNKNOWN
637
+ : formatExpected(r.usualMs)
600
638
  const row = formatTaskRow(
601
- paint(r.tone, r.glyph, c),
602
639
  null,
603
640
  r.status,
604
641
  r.tone,
@@ -617,7 +654,6 @@ export function planList(rows: readonly PlannedRow[]): ui.Block {
617
654
  )
618
655
  return r.note === '' ? row : `${row} ${paint('muted', r.note, c)}`
619
656
  }),
620
- ],
621
657
  }
622
658
  }
623
659
 
@@ -629,9 +665,14 @@ export function formatTaskHitLine(
629
665
  onPath = false,
630
666
  blocks?: number,
631
667
  ): string {
632
- return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors, onPath, {
633
- ...ranCells(o, blocks),
634
- })
668
+ return formatOutcomeRow(
669
+ o,
670
+ paintTaskId(node, colors),
671
+ o.durationMs,
672
+ colors,
673
+ onPath,
674
+ ranCells(o, blocks),
675
+ )
635
676
  }
636
677
 
637
678
  /**
@@ -658,13 +699,10 @@ export function formatTaskExecutedLine(
658
699
  blocks?: number,
659
700
  usualMs?: number | null,
660
701
  ): string {
661
- return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors, onPath, {
662
- ...executedCells(o, blocks),
663
- // A hit's time is a restore's, not the task's: nothing to compare.
664
- ...(o.status === 'success' || o.status === 'failed'
665
- ? { usual: usualCell(o.durationMs, usualMs, colors) }
666
- : {}),
667
- })
702
+ const cells = executedCells(o, blocks)
703
+ // A hit's time is a restore's, not the task's: nothing to compare.
704
+ if (ranCommand(o)) cells.usual = usualCell(o.durationMs, usualMs, colors)
705
+ return formatOutcomeRow(o, paintTaskId(node, colors), o.durationMs, colors, onPath, cells)
668
706
  }
669
707
 
670
708
  /**
@@ -673,26 +711,28 @@ export function formatTaskExecutedLine(
673
711
  */
674
712
  export function executedCells(o: TaskOutcome, blocks: number | undefined): RowCells {
675
713
  const cores = coresOf(o)
676
- const ran = o.status === 'success' || o.status === 'failed'
677
- return {
678
- ...ranCells(o, blocks),
679
- ...(cores !== undefined ? { cpu: cores } : ran ? { cpu: null } : {}),
680
- ...(o.peakRssBytes !== undefined
681
- ? { memBytes: o.peakRssBytes }
682
- : ran
683
- ? { memBytes: null }
684
- : {}),
685
- }
714
+ const ran = ranCommand(o)
715
+ const cells = ranCells(o, blocks)
716
+ if (cores !== undefined) cells.cpu = cores
717
+ else if (ran) cells.cpu = null
718
+ if (o.peakRssBytes !== undefined) cells.memBytes = o.peakRssBytes
719
+ else if (ran) cells.memBytes = null
720
+ return cells
686
721
  }
687
722
 
688
- /** The cells every row of a task that reached a machine carries. */
723
+ /**
724
+ * The cells every row of a task that reached a machine carries. Assigned,
725
+ * not spread: a warm run builds one per hit, and the spreads, copied again
726
+ * by the caller, were ~4 ms of 8,002 hits' rows (2026-11-07).
727
+ */
689
728
  function ranCells(o: TaskOutcome, blocks: number | undefined): RowCells {
690
- return {
691
- // A skip reached no machine.
692
- ...(o.status === 'skipped' ? {} : { worker: o.where ?? 'local' }),
693
- ...(blocks !== undefined ? { blocks: String(blocks) } : {}),
694
- ...(o.outputBytes !== undefined ? { outputBytes: o.outputBytes } : {}),
695
- }
729
+ const cells: RowCells = {}
730
+ // A skip reached no machine.
731
+ if (o.status !== 'skipped') cells.worker = o.where ?? 'local'
732
+ if (blocks !== undefined) cells.blocks = String(blocks)
733
+ // A row with no outputs size says so (`—`); blank read as a column left out.
734
+ if (o.status !== 'skipped') cells.outputBytes = o.outputBytes ?? null
735
+ return cells
696
736
  }
697
737
 
698
738
  /** Shared: render any outcome on the grid. */
@@ -708,7 +748,6 @@ function formatOutcomeRow(
708
748
  const ca = cacheOf(o)
709
749
  return (
710
750
  formatTaskRow(
711
- taskGlyph(o, colors),
712
751
  ms,
713
752
  st.word,
714
753
  st.color,
@@ -716,19 +755,11 @@ function formatOutcomeRow(
716
755
  ca.color,
717
756
  paintedId,
718
757
  colors,
719
- cells,
720
- ) +
721
- (onPath ? pathNote(colors) : '') +
722
- flakyNote(o, colors)
758
+ onPath ? { ...cells, critical: true } : cells,
759
+ ) + flakyNote(o, colors)
723
760
  )
724
761
  }
725
762
 
726
- /** `critical path`: the row's task held the run's end while others ran beside it. */
727
- export function pathNote(colors: ColorSupport = NO_COLOR): string {
728
- // A fact worth knowing, not a failure: never red.
729
- return ` ${paint('info', 'critical path', colors)}`
730
- }
731
-
732
763
  /** Where an executor ran the task, when not here, right after its id: `@small#2` (2026-10-21). */
733
764
  function whereNote(o: TaskOutcome, colors: ColorSupport = NO_COLOR): string {
734
765
  return o.where === undefined ? '' : placeNote(o.where, colors)
@@ -745,10 +776,14 @@ function placeNote(where: string, colors: ColorSupport = NO_COLOR): string {
745
776
  /**
746
777
  * The dim note a flaky task's row and frame carry (`flaky - failed 1×
747
778
  * before`): nothing prints below the footer, so the task's own line is
748
- * where the reader learns it. '' when the run did not prove it flaky.
779
+ * where the reader learns it. Not proved flaky: its attempts, when more
780
+ * than one; else ''.
749
781
  */
750
782
  export function flakyNote(o: TaskOutcome, colors: ColorSupport = NO_COLOR): string {
751
- if (o.flaky === undefined) return ''
783
+ // A retry with no key to prove a flake (no `cache: true`) still ran:
784
+ // the row is the only place a live run says so (`vx last` did).
785
+ if (o.flaky === undefined)
786
+ return (o.attempts ?? 1) > 1 ? ` ${paint('muted', `${o.attempts} attempts`, colors)}` : ''
752
787
  const said: string[] = []
753
788
  if (o.status === 'failed' && o.flaky.passes > 0)
754
789
  said.push(`passed ${o.flaky.passes}\u00d7 before`)
@@ -775,7 +810,6 @@ export function formatTaskSkippedLine(
775
810
  const suffix = why === '' ? '' : ` ${paint('muted', `• ${why}`, colors)}`
776
811
  return (
777
812
  formatTaskRow(
778
- paint('warn', '\u2298', colors),
779
813
  null,
780
814
  'skipped',
781
815
  'warn',
@@ -802,29 +836,9 @@ export function formatTaskAbortedLine(
802
836
  const id = paintTaskId(node, colors)
803
837
  const bl: RowCells = blocks === undefined ? {} : { blocks: String(blocks) }
804
838
  if (neverStarted(o)) {
805
- return formatTaskRow(
806
- paint('muted', '\u25cc', colors),
807
- null,
808
- 'not run',
809
- '',
810
- '',
811
- '',
812
- id,
813
- colors,
814
- bl,
815
- )
839
+ return formatTaskRow(null, 'not run', '', '', '', id, colors, bl)
816
840
  }
817
- return formatTaskRow(
818
- paint('aborted', '\u2717', colors),
819
- o.durationMs,
820
- 'aborted',
821
- 'aborted',
822
- '',
823
- '',
824
- id,
825
- colors,
826
- ranCells(o, blocks),
827
- )
841
+ return formatTaskRow(o.durationMs, 'aborted', 'aborted', '', '', id, colors, ranCells(o, blocks))
828
842
  }
829
843
 
830
844
  /**
@@ -880,7 +894,7 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
880
894
  ...(fix.inputs !== undefined
881
895
  ? [
882
896
  dim(
883
- `and, if the output depends on them, inputs.files: [${fix.inputs.map((p) => `'${printable(p)}'`).join(', ')}]`,
897
+ `and, if the output depends on them, inputs.files: [${fix.inputs.map(jsString).join(', ')}]`,
884
898
  ),
885
899
  ]
886
900
  : []),
@@ -1053,6 +1067,9 @@ function formatStatusTag(o: TaskOutcome, colors: ColorSupport): string {
1053
1067
  }
1054
1068
  }
1055
1069
 
1070
+ /** A fix path as the single-quoted JS literal the config line pastes. */
1071
+ const jsString = (p: string): string => `'${printable(p.replace(/[\\']/g, '\\$&'))}'`
1072
+
1056
1073
  /**
1057
1074
  * The fix as the config line it is: `exec.sandbox.allow: { read: ['a'] }`,
1058
1075
  * or undefined when every denial was a withheld credential path.
@@ -1061,7 +1078,7 @@ function sandboxFixLine(f: NonNullable<TaskOutcome['sandboxFix']>): string | und
1061
1078
  const fix = Object.entries(f).filter(([k]) => k !== 'withheld' && k !== 'inputs')
1062
1079
  if (fix.length === 0) return undefined
1063
1080
  const parts = fix.map(
1064
- ([k, list]: [string, string[]]) => `${k}: [${list.map((p) => `'${printable(p)}'`).join(', ')}]`,
1081
+ ([k, list]: [string, string[]]) => `${k}: [${list.map(jsString).join(', ')}]`,
1065
1082
  )
1066
1083
  return `exec.sandbox.allow: { ${parts.join(', ')} }`
1067
1084
  }
@@ -33,6 +33,7 @@ export {
33
33
  export {
34
34
  formatPersistentList,
35
35
  formatRecordedRow,
36
+ formatSize,
36
37
  planList,
37
38
  type PlannedRow,
38
39
  taskList,
@@ -40,4 +41,4 @@ export {
40
41
  export { createForecast } from './forecast.js'
41
42
  export { plainOutput } from './plain-output.js'
42
43
  export type { LiveUsage } from './status-line.js'
43
- export { formatOutcomeTable, formatRunSummary } from './summary.js'
44
+ export { formatOutcomeTable, formatRunSummary, neverStarted } from './summary.js'