@vzn/vx 0.0.635 → 0.0.637

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 (141) hide show
  1. package/README.md +47 -19
  2. package/docs/caching.md +222 -210
  3. package/docs/cli.md +699 -664
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +31 -32
  7. package/docs/schema.md +418 -202
  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 +2 -2
  16. package/schemas/show.json +13 -4
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -61
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +123 -144
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +191 -71
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +25 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +192 -70
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +43 -28
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +114 -73
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +127 -88
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +251 -241
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +22 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -1,4 +1,5 @@
1
1
  import type { TaskNode, TaskOutcome } from '../graph/index.js'
2
+ import { settingEnv } from '../settings.js'
2
3
  import { detectColors, type ColorSupport } from './colors.js'
3
4
  import {
4
5
  formatFrameClose,
@@ -177,7 +178,10 @@ export function resolveOutputView(
177
178
  ...(gha ? { gha: true } : {}),
178
179
  ...(ci ? { ci: true } : {}),
179
180
  })
180
- if (options.outputLogs !== undefined) return mk(options.outputLogs)
181
+ // `run.outputLogs`: chosen before the workspace loads, so flag, then variable.
182
+ const outputLogs =
183
+ options.outputLogs ?? (settingEnv('run.outputLogs', env) as OutputView['mode'] | undefined)
184
+ if (outputLogs !== undefined) return mk(outputLogs)
181
185
  if (ci) return mk('full')
182
186
  if (options.flow !== undefined) return mk(options.flow)
183
187
  return mk('full')
@@ -198,6 +202,8 @@ export function defaultLogger(
198
202
  tty?: boolean
199
203
  /** The args after `--`: a requested task's `$ <command>` line shows them, as it ran. */
200
204
  forwardArgs?: readonly string[]
205
+ /** `false`: no summary in the live region and no blank line opening the log (`RunOptions.footer`). */
206
+ footer?: boolean
201
207
  /** The environment that names the terminal (`TERM_PROGRAM`, …); `process.env` by default. */
202
208
  env?: Readonly<Record<string, string | undefined>>
203
209
  } = {},
@@ -295,6 +301,7 @@ export function defaultLogger(
295
301
  let etaMs: number | undefined
296
302
  let critical: string | undefined
297
303
  let blocks: ReadonlyMap<string, number> | undefined
304
+ let expected: ReadonlyMap<string, number> | undefined
298
305
  let etaAtMs = 0
299
306
  const signals = writer.enabled
300
307
  ? terminalSignals(opts.env ?? process.env)
@@ -311,6 +318,10 @@ export function defaultLogger(
311
318
  // as `+k more` on the stats line. Focused flow keeps its lifecycle:
312
319
  // the region dies the moment a requested node starts streaming.
313
320
  let slots: (WorkerSlot | null)[] = []
321
+ // Rows shown: the most workers busy at once so far. An idle row past it
322
+ // was noise (a 10-core machine drew 9 on a one-chain run); it never
323
+ // shrinks, so a row never jumps.
324
+ let shownSlots = 0
314
325
  const slotQueue: WorkerSlot[] = []
315
326
  let succeeded = 0
316
327
  // Executed with no `cache` block: never a miss, counted apart.
@@ -375,16 +386,16 @@ export function defaultLogger(
375
386
  if (text.length < KEPT_LINE_CAP) held.rest = text
376
387
  else {
377
388
  held.rest = ''
378
- writer.write(fenced(formatKeptLines(node, text, colors)))
389
+ taskWrite(fenced(formatKeptLines(node, text, colors)))
379
390
  }
380
391
  return
381
392
  }
382
393
  held.rest = text.slice(cut + 1)
383
- writer.write(fenced(formatKeptLines(node, text.slice(0, cut), colors)))
394
+ taskWrite(fenced(formatKeptLines(node, text.slice(0, cut), colors)))
384
395
  }
385
396
  const flushKept = (): void => {
386
397
  for (const held of keptLines.values()) {
387
- if (held.rest.length > 0) writer.write(fenced(formatKeptLines(held.node, held.rest, colors)))
398
+ if (held.rest.length > 0) taskWrite(fenced(formatKeptLines(held.node, held.rest, colors)))
388
399
  held.rest = ''
389
400
  }
390
401
  }
@@ -404,6 +415,7 @@ export function defaultLogger(
404
415
  etaMs = f.etaMs
405
416
  critical = f.critical
406
417
  blocks = f.blocks
418
+ expected = f.expected
407
419
  }
408
420
  if (signals.progress && total > 0) {
409
421
  const elapsed = now - startedAtMs
@@ -421,38 +433,42 @@ export function defaultLogger(
421
433
  }
422
434
  // The live summary IS the final summary's section, built from the
423
435
  // same formatter so the region visually becomes the printout.
424
- const summaryLines = formatSummarySection(
425
- {
426
- failed,
427
- successful: succeeded + upToDate + restoredLocal + restoredRemote,
428
- skipped: skippedCount,
429
- total,
430
- upToDate,
431
- restoredLocal,
432
- restoredRemote,
433
- miss: succeeded + failed - noCache,
434
- noCache,
435
- savedMs,
436
- left: total - done,
437
- ...(etaMs !== undefined ? { etaMs } : {}),
438
- spread:
439
- spreadCount > 0
440
- ? { maxMs: spreadMax, minMs: spreadMin, sumMs: spreadSum, count: spreadCount }
441
- : null,
442
- },
443
- Date.now() - startedAtMs,
444
- colors,
445
- runContext,
446
- )
436
+ const summaryLines =
437
+ opts.footer === false
438
+ ? []
439
+ : formatSummarySection(
440
+ {
441
+ failed,
442
+ successful: succeeded + upToDate + restoredLocal + restoredRemote,
443
+ skipped: skippedCount,
444
+ total,
445
+ upToDate,
446
+ restoredLocal,
447
+ restoredRemote,
448
+ miss: succeeded + failed - noCache,
449
+ noCache,
450
+ savedMs,
451
+ left: total - done,
452
+ ...(etaMs !== undefined ? { etaMs } : {}),
453
+ spread:
454
+ spreadCount > 0
455
+ ? { maxMs: spreadMax, minMs: spreadMin, sumMs: spreadSum, count: spreadCount }
456
+ : null,
457
+ },
458
+ Date.now() - startedAtMs,
459
+ colors,
460
+ runContext,
461
+ )
447
462
  writer.setRegion(
448
463
  formatStatusRegion(
449
464
  {
450
465
  pinnedPersistent,
451
- slots,
466
+ slots: slots.slice(0, shownSlots),
452
467
  overflow: slotQueue.length,
453
468
  nowMs: Date.now(),
454
469
  summaryLines,
455
470
  ...(blocks !== undefined ? { blocks } : {}),
471
+ ...(expected !== undefined ? { expected } : {}),
456
472
  ...(critical !== undefined ? { critical } : {}),
457
473
  },
458
474
  colors,
@@ -478,8 +494,21 @@ export function defaultLogger(
478
494
  writer.clearStatus()
479
495
  }
480
496
 
497
+ // The task log opens with one blank line before its first row, on a
498
+ // terminal and off one (M, 2026-10-14). A note vx prints before it is
499
+ // no task row (`status`). Without a footer, the caller frames the log
500
+ // (`vx watch` opens each cycle with its own line).
501
+ let logOpened = opts.footer === false
502
+ const taskWrite = (chunk: string): void => {
503
+ if (!logOpened && chunk.length > 0) {
504
+ logOpened = true
505
+ writer.write(`\n${chunk}`)
506
+ return
507
+ }
508
+ writer.write(chunk)
509
+ }
481
510
  const emitLine = (line: string): void => {
482
- writer.write(`${line}\n`)
511
+ taskWrite(`${line}\n`)
483
512
  lineEmitted = true
484
513
  }
485
514
  // formatTaskBlock returns '' for group tasks (no exec) — skip the
@@ -487,14 +516,14 @@ export function defaultLogger(
487
516
  const emitBlock = (block: string): void => {
488
517
  if (block.length === 0) return
489
518
  const lead = lineEmitted || streamedSinceBlock ? '\n' : ''
490
- writer.write(`${lead}${block}\n`)
519
+ taskWrite(`${lead}${block}\n`)
491
520
  lineEmitted = false
492
521
  streamedSinceBlock = false
493
522
  }
494
523
  // Live-frame close lines end a frame the same way emitBlock does:
495
524
  // with a trailing blank line, resetting the separator state.
496
525
  const emitFrameClose = (line: string): void => {
497
- writer.write(`${line}\n\n`)
526
+ taskWrite(`${line}\n\n`)
498
527
  lineEmitted = false
499
528
  streamedSinceBlock = false
500
529
  }
@@ -549,6 +578,7 @@ export function defaultLogger(
549
578
  startedAtMs = Date.now()
550
579
  const cap = Math.max(1, Math.min(info.concurrency ?? 10, 10))
551
580
  slots = Array.from({ length: cap }, () => null)
581
+ shownSlots = 0
552
582
  if (writer.enabled && !statusDead && ticker === null) {
553
583
  // Keeps the per-worker elapsed time ticking between task events
554
584
  // (the time IS the motion — there's no spinner). The writer
@@ -585,8 +615,10 @@ export function defaultLogger(
585
615
  }
586
616
  const slot: WorkerSlot = { id: node.id, startedMs: Date.now() }
587
617
  const free = slots.indexOf(null)
588
- if (free >= 0) slots[free] = slot
589
- else slotQueue.push(slot)
618
+ if (free >= 0) {
619
+ slots[free] = slot
620
+ shownSlots = Math.max(shownSlots, free + 1)
621
+ } else slotQueue.push(slot)
590
622
  refresh(true)
591
623
  },
592
624
  forecast(load) {
@@ -675,7 +707,7 @@ export function defaultLogger(
675
707
  if (streamsLive(node)) {
676
708
  streamedSinceBlock = true
677
709
  if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
678
- writer.write(chunk)
710
+ taskWrite(chunk)
679
711
  return
680
712
  }
681
713
  const tail = persistentTails.get(node.id)
@@ -691,7 +723,7 @@ export function defaultLogger(
691
723
  if (streamsLive(node)) {
692
724
  streamedSinceBlock = true
693
725
  if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
694
- writer.write(chunk)
726
+ taskWrite(chunk)
695
727
  return
696
728
  }
697
729
  const tail = persistentTails.get(node.id)
@@ -747,7 +779,7 @@ export function defaultLogger(
747
779
  (framesTerminal && holdsTerminal(node)))
748
780
  if (framed && streamsLive(node)) {
749
781
  if (streamMidLine) {
750
- writer.write('\n')
782
+ taskWrite('\n')
751
783
  streamMidLine = false
752
784
  }
753
785
  emitFrameClose(formatFrameClose(node, outcome, colors))
@@ -830,7 +862,7 @@ export function defaultLogger(
830
862
  if (isGroupTask(node)) return
831
863
  if (holdsTerminal(node) && streamsLive(node) && outcome.status !== 'skipped') {
832
864
  if (streamMidLine) {
833
- writer.write('\n')
865
+ taskWrite('\n')
834
866
  streamMidLine = false
835
867
  }
836
868
  emitFrameClose(formatFrameClose(node, outcome, colors))
@@ -908,7 +940,7 @@ export function defaultLogger(
908
940
  // and up-to-date included (owner: "always full frame
909
941
  // for a single task").
910
942
  if (streamMidLine) {
911
- writer.write('\n')
943
+ taskWrite('\n')
912
944
  streamMidLine = false
913
945
  }
914
946
  emitFrameClose(formatFrameClose(node, outcome, colors))
@@ -1003,7 +1035,7 @@ export function defaultLogger(
1003
1035
  // The message is ours (a literal, an integer and a signal
1004
1036
  // name); only the title carries user input.
1005
1037
  emitBlock(
1006
- `::error title=${ghaProperty(node.id)}::${failedLabel(outcome.exitCode, outcome.timedOut, outcome.sandboxViolations, outcome.notReady)}\n${body}`,
1038
+ `::error title=${ghaProperty(node.id)}::${failedLabel(outcome.exitCode, outcome.timedOut, outcome.sandboxViolations, outcome.notReady, outcome.inputMoved)}\n${body}`,
1007
1039
  )
1008
1040
  } else {
1009
1041
  emitBlock(
@@ -1,15 +1,18 @@
1
1
  // Run-history queries — pure functions over a `bun:sqlite` Database.
2
2
  //
3
3
  // `vx why` and `vx last` read through them, and so can any out-of-process
4
- // surface (an MCP server plugin, a dashboard). One canonical home for every
4
+ // surface. One canonical home for every
5
5
  // aggregate over the runs / entries tables. The schema itself is owned by
6
6
  // src/cache/cache.ts.
7
7
  //
8
- // Pure SQL + JSON-safe return shapes. No Cache lifecycle here; the
8
+ // Pure SQL + JSON-safe return shapes (`inputWriters` also stats the
9
+ // files it names). No Cache lifecycle here; the
9
10
  // caller opens and closes. bigints are serialized as decimal strings
10
11
  // for JSON compatibility (matches the WireEvent timeUnixNano rule).
11
12
 
12
13
  import type { Database } from 'bun:sqlite'
14
+ import { statSync } from 'node:fs'
15
+ import path from 'node:path'
13
16
  // The keyed-run predicate lives beside the schema in cache/cache.ts, so a
14
17
  // rule written once cannot drift.
15
18
  import { KEYED_RUNS_SQL } from '../cache/index.js'
@@ -410,7 +413,7 @@ function unchangedKeyNote(
410
413
  } | null
411
414
  )?.p
412
415
  if (policy !== undefined && !policy.split(',').some((axis) => axis === 'lR' || axis === 'rR')) {
413
- return 'cache key unchanged — re-executed because this run did not read the cache (--force, or a --cache without read)'
416
+ return 'cache key unchanged — re-executed because this run did not read the cache (--force, or a --cache-policy without read)'
414
417
  }
415
418
  // `get` answers null, not undefined, for no row.
416
419
  const createdAt = (
@@ -470,7 +473,7 @@ function unchangedKeyNote(
470
473
  } | null
471
474
  )?.p
472
475
  if (prevPolicy !== undefined && !prevPolicy.split(',').some((a) => a === 'lW' || a === 'rW')) {
473
- return 'cache key unchanged — the previous run on this key did not write the cache (--no-cache, or a --cache without write), so there was nothing to hit'
476
+ return 'cache key unchanged — the previous run on this key did not write the cache (--no-cache, or a --cache-policy without write), so there was nothing to hit'
474
477
  }
475
478
  return 'cache key unchanged — the previous run on this key executed but no entry for it is in the cache (its save failed, or it was pruned since), so there was nothing to hit'
476
479
  }
@@ -479,8 +482,7 @@ function unchangedKeyNote(
479
482
 
480
483
  /**
481
484
  * The latest recorded run of a task (run_id may be NULL on very old rows).
482
- * One query for `vx why` and `vx mcp`: the CLI defaulted to it while the
483
- * tool demanded a run id an agent had to fetch first (2026-09-16).
485
+ * `vx why`'s default when no run id is given.
484
486
  */
485
487
  export function latestRunId(db: Database, taskId: string): string | null {
486
488
  const [project, task] = splitTaskId(taskId)
@@ -875,6 +877,69 @@ export function rootCauses(
875
877
  return roots
876
878
  }
877
879
 
880
+ export interface InputWriter {
881
+ /** The task whose key read the file. */
882
+ taskId: string
883
+ /** Workspace-relative, as the key names it. */
884
+ file: string
885
+ /** The tasks that were running when the file last changed. */
886
+ writers: string[]
887
+ }
888
+
889
+ /**
890
+ * Slack around a task's recorded window: its edges are rounded to the
891
+ * millisecond and a write lands just before the process exits.
892
+ */
893
+ const WRITE_SLACK_MS = 5
894
+
895
+ /**
896
+ * For each moved input file of `taskId` and of each root cause, the
897
+ * recorded tasks that were running when the file last changed: a task
898
+ * that writes a file another key reads re-keys that task on every run.
899
+ * The file's mtime against the recorded windows, so
900
+ * a writer that runs each time is named from its latest run, and an
901
+ * edit made outside a run names none.
902
+ */
903
+ export function inputWriters(
904
+ db: Database,
905
+ workspaceRoot: string,
906
+ taskId: string,
907
+ entries: CacheKeyDiff['entries'],
908
+ roots: readonly RootCause[],
909
+ ): InputWriter[] {
910
+ const during = db.query(
911
+ `SELECT project, task FROM runs
912
+ WHERE started_at <= ?1 + ${WRITE_SLACK_MS} AND ended_at >= ?1 - ${WRITE_SLACK_MS}
913
+ AND status != 'skipped'
914
+ GROUP BY project, task ORDER BY MIN(started_at) LIMIT 3`,
915
+ )
916
+ const out: InputWriter[] = []
917
+ const owners = [
918
+ { taskId, entries },
919
+ ...roots.map((r) => ({ taskId: r.chain.at(-1)!, entries: r.entries })),
920
+ ]
921
+ for (const owner of owners) {
922
+ for (const e of owner.entries) {
923
+ if (e.kind !== 'file' || e.change === 'removed') continue
924
+ let mtime: number
925
+ try {
926
+ mtime = statSync(path.join(workspaceRoot, e.name)).mtimeMs
927
+ } catch {
928
+ continue
929
+ }
930
+ const rows = during.all(mtime) as { project: string; task: string }[]
931
+ if (rows.length > 0) {
932
+ out.push({
933
+ taskId: owner.taskId,
934
+ file: e.name,
935
+ writers: rows.map((r) => `${r.project}#${r.task}`),
936
+ })
937
+ }
938
+ }
939
+ }
940
+ return out
941
+ }
942
+
878
943
  // ---------------------------------------------------------------------------
879
944
  // Run comparison — diff a run against the immediately-previous invocation
880
945
  // ---------------------------------------------------------------------------
@@ -81,6 +81,8 @@ export interface SaveMissArgs {
81
81
  ownOutputFiles?: string[] | undefined
82
82
  /** `ownOutputFiles` for `cache.outputs.workspaceFiles` (A-43). */
83
83
  ownWsOutputFiles?: string[] | undefined
84
+ /** A sandboxed task's reach: a symlinked output it could not read is refused (`sandboxReadable`). */
85
+ readable?: ((real: string) => boolean) | undefined
84
86
  /** The Tier-3 input fingerprint rows captured by the pre-exec describe. */
85
87
  captured: readonly TaskInputComponent[]
86
88
  command: string
@@ -176,6 +178,7 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
176
178
  ? { workspaceOutputFiles: wsOutputFiles, workspaceRoot: a.workspaceRoot }
177
179
  : {}),
178
180
  inputComponents: a.captured.map((c) => ({ entryHash: a.hash, ...c })),
181
+ readable: a.readable,
179
182
  // No `exitCode`: the save only runs under `effectiveExitCode === 0`, and
180
183
  // the contract no longer accepts one — so the invariant is enforced by
181
184
  // the type rather than by every call site remembering the gate.
@@ -2,7 +2,7 @@
2
2
  // so internals like prepare.ts can import them without an upward
3
3
  // import of index.ts — the module entry must stay cycle-free.
4
4
 
5
- import type { CachePolicy, RemoteCacheLayer } from '../cache/index.js'
5
+ import type { CacheFlags, CachePolicy, RemoteCacheLayer } from '../cache/index.js'
6
6
  import type { ContinueMode } from '../graph/index.js'
7
7
  import type { TaskOutcome } from '../graph/index.js'
8
8
  import type { AffectedReason } from './affected-tasks.js'
@@ -11,6 +11,7 @@ import type { TelemetrySink } from './telemetry.js'
11
11
  import type { Logger } from './logger.js'
12
12
  import type { RunSummaryJson } from './run-artifacts.js'
13
13
  import type { AffectedChanges, ProjectEntry, ProjectMeta } from '../workspace/index.js'
14
+ import type { RunSettings } from '../config.js'
14
15
 
15
16
  export interface RunOptions {
16
17
  cwd: string
@@ -47,7 +48,7 @@ export interface RunOptions {
47
48
  affectedReasons?: Map<string, AffectedReason>
48
49
  /**
49
50
  * Configs a selection pass in this same process already loaded and
50
- * staged with the same `cacheDir` and `frozen` (the CLI walks every
51
+ * staged with the same `cacheDir` (the CLI walks every
51
52
  * `pkg#task` edge for `--filter 'app...'` and `--affected`). The run
52
53
  * seeds and scopes as it always does and reuses these entries instead
53
54
  * of evaluating and staging them again. One run only — never carry a
@@ -67,40 +68,39 @@ export interface RunOptions {
67
68
  concurrency?: number
68
69
  /**
69
70
  * Cache directory override (`--cache-dir <path>`). Absolute, or relative
70
- * to `cwd`. Takes precedence over the workspace `cacheDir` field and the
71
+ * to `cwd`. Takes precedence over the workspace `cache.dir` field and the
71
72
  * `.vx/cache` default. A per-run knob — never folded into a cache key.
72
73
  */
73
74
  cacheDir?: string
74
75
  /**
75
76
  * Granular cache read/write control across the local + remote layers.
76
77
  * Each of the four axes (localRead / localWrite / remoteRead /
77
- * remoteWrite) is independent. Undefined → {@link FULL_CACHE_POLICY}
78
- * (everything on). `--no-cache` sets all four false; `--force` sets
79
- * both reads false (re-execute, still refresh the cache).
78
+ * remoteWrite) is independent. Undefined → the workspace's
79
+ * `cache.policy` with `cacheFlags` applied, else {@link FULL_CACHE_POLICY}
80
+ * (everything on).
80
81
  */
81
82
  cache?: CachePolicy
82
83
  /**
83
- * The caller asked for a remote axis by name (`--cache=remote:rw`). With
84
+ * With no `cache`: `--no-cache` (all four off) and `--force` (both reads
85
+ * off), applied over the workspace's `cache.policy`.
86
+ */
87
+ cacheFlags?: CacheFlags
88
+ /**
89
+ * The caller asked for a remote axis by name (`--cache-policy=remote:rw`). With
84
90
  * no cache plugin supplying a remote layer the axes are inert, and a CI
85
91
  * job that believes it is filling a shared cache deserves one line
86
92
  * saying it is not.
87
93
  */
88
94
  remoteRequested?: boolean
95
+ /** This run's `cache.scope` (the CLI's `--cache-scope`), above `VX_CACHE_SCOPE` and `.vx/workspace`. */
96
+ cacheScope?: string
89
97
  /**
90
- * The `cacheScope` a run takes when `vx.workspace` names none. The CLI
91
- * passes `'read-only'` off CI (`CI` unset, `0` or `false`) unless
92
- * `--cache` names the remote; omitted → trusted.
93
- */
94
- defaultCacheScope?: string
95
- /**
96
- * CI mode: load configs FROM the committed vx-lock.json instead of
97
- * evaluating them (frozen-env reproducibility). Requires the lock
98
- * to exist and pass its content-hash tripwire. Local runs default
99
- * to live evaluation — byte hashes can't see a config's import
100
- * closure, so a strict local mode would give false confidence.
101
- * Pair with `vx lock --check` in CI for the full evaluation audit.
98
+ * The default policy's remote write is off (`remote:r`): the CLI passes it
99
+ * off CI (`CI` unset, `0` or `false`) unless `--cache-policy` or
100
+ * `VX_CACHE_POLICY` names the remote. Dropped when the workspace's
101
+ * `cache.policy` names the remote.
102
102
  */
103
- frozen?: boolean
103
+ remoteReadOnlyByDefault?: boolean
104
104
  /**
105
105
  * Explicit per-task output volume for the default logger. ALWAYS
106
106
  * overrides the flow default. 'full': frames for work + one-liners
@@ -133,19 +133,18 @@ export interface RunOptions {
133
133
  */
134
134
  flow?: 'focused' | 'broad'
135
135
  /**
136
- * Run-level retry default (`--retry <n>`): max additional attempts
136
+ * `run.retries` from its flag (`--retries <n>`): max additional attempts
137
137
  * after a failed attempt, for tasks that don't declare their own
138
- * `exec.retries` (explicit config wins, including `retries: 0`).
139
- * Threaded as an option only — never folded into any cache key, so
140
- * the same run with and without `--retry` derives identical keys.
138
+ * `exec.retries` (explicit config wins, including `retries: 0`); unset,
139
+ * `VX_RUN_RETRIES`, then the workspace's. Never folded into any cache
140
+ * key, so the same run with and without `--retries` derives identical keys.
141
141
  * A non-negative integer; run() refuses anything else (C-61).
142
142
  */
143
143
  retries?: number
144
144
  /**
145
- * Run-level default task timeout (ms), for tasks that declare no
146
- * `exec.timeout`. Highest of the run-level defaults: `--timeout` /
147
- * this option → `VX_TASK_TIMEOUT` env → workspace `timeout`. Per-task
148
- * `exec.timeout` always wins. Threaded as an option only — never
145
+ * `run.timeout` in ms from its flag (`--timeout 30m`), for tasks that
146
+ * declare no `exec.timeout`; unset, `VX_RUN_TIMEOUT`, then the
147
+ * workspace's. Per-task `exec.timeout` always wins. Never
149
148
  * folded into any cache key (a timed-out task fails and is never
150
149
  * cached), so a `--timeout` run cache-hits a plain run's entry.
151
150
  * A positive integer of ms up to `2 ** 31 - 1`; run() refuses anything
@@ -161,6 +160,16 @@ export interface RunOptions {
161
160
  * `--continue[=never|deps-ok|always]`; bare `--continue` = 'always'.
162
161
  */
163
162
  continueMode?: ContinueMode
163
+ /** `--branch`; unset, `VX_RUN_BRANCH`, then git's, then the CI provider's. */
164
+ branch?: string
165
+ /** `run.schedule` from its flag; unset, `VX_RUN_SCHEDULE`, then the workspace's. */
166
+ schedule?: NonNullable<RunSettings['schedule']>
167
+ /** `run.killGrace` in ms from its flag; unset, `VX_RUN_KILL_GRACE`, then the workspace's. */
168
+ killGrace?: number
169
+ /** `run.teardownTimeout` in ms from its flag; unset, `VX_RUN_TEARDOWN_TIMEOUT`, then the workspace's. */
170
+ teardownTimeout?: number
171
+ /** `run.readyNotice` in ms from its flag; unset, `VX_RUN_READY_NOTICE`, then the workspace's. */
172
+ readyNotice?: number
164
173
  /**
165
174
  * Filter `dependsOn` expansion. `'all'` drops every edge (just the
166
175
  * requested task runs). A string array drops only those task names
@@ -188,6 +197,12 @@ export interface RunOptions {
188
197
  * here. Not called on a refused run.
189
198
  */
190
199
  beforeFooter?: (outcomes: readonly TaskOutcome[], ok: boolean) => string
200
+ /**
201
+ * `false`: no footer, live or printed, and no blank line opening the
202
+ * task log; the run is its task rows (`vx watch`, which frames each
203
+ * cycle with its own line).
204
+ */
205
+ footer?: boolean
191
206
  /**
192
207
  * If set, write a Chrome-trace JSON profile of the run's wallclock
193
208
  * spans. Path is cwd-relative. Default `profile.json` is selected
@@ -0,0 +1,72 @@
1
+ // The `output` capability: a plugin may take the terminal for a run. Sibling
2
+ // to telemetry-host.ts. With no plugin declaring the hook nothing here runs,
3
+ // and core's renderer is the run's, byte for byte.
4
+
5
+ import { terminalSubscriber, type RunEventSubscriber } from './events.js'
6
+ import type { Logger } from './logger.js'
7
+ import type { OutputContext, VxPlugin } from './plugin.js'
8
+
9
+ const REQUIRED = ['status', 'taskStdout', 'taskStderr', 'taskComplete'] as const
10
+ const OPTIONAL = ['runStart', 'taskStart', 'runEnd'] as const
11
+
12
+ /** Reject anything that is not Logger-shaped, naming what is missing. */
13
+ function checkLogger(value: unknown): Logger {
14
+ if (typeof value !== 'object' || value === null) {
15
+ throw new Error(`output must be a Logger object, got ${value === null ? 'null' : typeof value}`)
16
+ }
17
+ const o = value as Record<string, unknown>
18
+ const missing = REQUIRED.filter((k) => typeof o[k] !== 'function')
19
+ if (missing.length > 0) throw new Error(`output Logger has no ${missing.join(', ')}`)
20
+ const wrong = OPTIONAL.filter((k) => o[k] !== undefined && typeof o[k] !== 'function')
21
+ if (wrong.length > 0) throw new Error(`output Logger's ${wrong.join(', ')} must be functions`)
22
+ return value as Logger
23
+ }
24
+
25
+ /**
26
+ * The first plugin, in declaration order, whose `output` returns a Logger.
27
+ * A hook that throws or returns something else is warned about and passed
28
+ * over: a renderer must never fail a run.
29
+ */
30
+ export async function claimOutput(
31
+ plugins: readonly VxPlugin[],
32
+ ctx: OutputContext,
33
+ ): Promise<{ logger: Logger; owner: string } | undefined> {
34
+ for (const plugin of plugins) {
35
+ if (plugin.output === undefined) continue
36
+ try {
37
+ const logger = await plugin.output(ctx)
38
+ if (logger !== undefined) return { logger: checkLogger(logger), owner: plugin.name }
39
+ } catch (err) {
40
+ ctx.warn(
41
+ `[vx] plugin '${plugin.name}' output: ${err instanceof Error ? err.message : String(err)}; vx renders the run`,
42
+ )
43
+ }
44
+ }
45
+ return undefined
46
+ }
47
+
48
+ /**
49
+ * The bus subscriber for a plugin's Logger. The first throw hands that
50
+ * event and every later one to `fallback` (core's renderer), after one
51
+ * status line naming the plugin.
52
+ */
53
+ export function guardedOutput(
54
+ claimed: { logger: Logger; owner: string },
55
+ fallback: RunEventSubscriber,
56
+ ): RunEventSubscriber {
57
+ const render = terminalSubscriber(claimed.logger)
58
+ let failed = false
59
+ return (event) => {
60
+ if (failed) return fallback(event)
61
+ try {
62
+ render(event)
63
+ } catch (err) {
64
+ failed = true
65
+ fallback({
66
+ kind: 'run:status',
67
+ line: `[vx] plugin '${claimed.owner}' output failed: ${err instanceof Error ? err.message : String(err)}; vx renders the rest of the run`,
68
+ })
69
+ fallback(event)
70
+ }
71
+ }
72
+ }
@@ -50,7 +50,7 @@ export interface PlannedTask {
50
50
  executor?: string
51
51
  /**
52
52
  * `'deferred'` when this task's outputs would stay in the remote store
53
- * (`--download=none` / `toplevel`). Attached only when it is TRUE — an
53
+ * (`--cache-download=none` / `toplevel`). Attached only when it is TRUE — an
54
54
  * eager task is the default and saying so on every line says nothing.
55
55
  */
56
56
  download?: 'deferred'
@@ -91,22 +91,21 @@ export interface RunPlan {
91
91
  /** Set when `--affected` left nothing to run: the line the run would print, and why. */
92
92
  noneAffected?: string
93
93
  /**
94
- * Producers `--download` WANTED to defer but could not, mapped to why.
95
- * Without this the gate is invisible: a user asks for `--download=none`,
94
+ * Producers `--cache-download` WANTED to defer but could not, mapped to why.
95
+ * Without this the gate is invisible: a user asks for `--cache-download=none`,
96
96
  * gets no deferral, and has nothing to read. Empty/absent under
97
- * `--download=all`, which defers nothing by definition.
97
+ * `--cache-download=all`, which defers nothing by definition.
98
98
  */
99
99
  downloadDowngrades?: ReadonlyArray<{ taskId: string; reason: string }>
100
100
  }
101
101
 
102
102
  export interface PlanArgs {
103
103
  nodes: Map<string, TaskNode>
104
- /** Per-task `--download` decision, when the policy is not `all`. */
104
+ /** Per-task `--cache-download` decision, when the policy is not `all`. */
105
105
  downloadOf?: (id: string) => 'eager' | 'deferred' | 'never' | undefined
106
106
  /** See `RunPlan.downloadDowngrades`. */
107
107
  downloadDowngrades?: ReadonlyArray<{ taskId: string; reason: string }>
108
108
  workspaceRoot: string
109
- workspaceFingerprint: string
110
109
  cache: CacheLayer
111
110
  cachePolicy?: CachePolicy
112
111
  forwardArgs?: readonly string[] | undefined
@@ -150,7 +149,6 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
150
149
  node,
151
150
  upstream,
152
151
  workspaceRoot: args.workspaceRoot,
153
- workspaceFingerprint: args.workspaceFingerprint,
154
152
  cache: args.cache,
155
153
  forwardArgs: args.forwardArgs,
156
154
  nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
@@ -166,7 +164,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
166
164
 
167
165
  // Prediction keys off READS: a no-read policy re-executes every task,
168
166
  // so nothing is probed. Whether that run is a miss or no cache at all
169
- // is the WRITE axes': `--force` and `--cache=local:w` save what they
167
+ // is the WRITE axes': `--force` and `--cache-policy=local:w` save what they
170
168
  // run, and the plan called them `no-cache`, the label a task with no
171
169
  // `cache` block gets (item 991). The probe below routes through the
172
170
  // policy-aware cache layer, which itself respects read gating.