@vibe-validate/cli 0.14.3 → 0.15.0-rc.1

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 (83) hide show
  1. package/README.md +26 -6
  2. package/bin/vibe-validate +131 -0
  3. package/config-templates/minimal.yaml +1 -1
  4. package/config-templates/typescript-library.yaml +1 -1
  5. package/config-templates/typescript-nodejs.yaml +1 -1
  6. package/config-templates/typescript-react.yaml +1 -1
  7. package/dist/bin.js +18 -4
  8. package/dist/bin.js.map +1 -1
  9. package/dist/commands/cleanup.d.ts +1 -1
  10. package/dist/commands/cleanup.d.ts.map +1 -1
  11. package/dist/commands/cleanup.js +108 -2
  12. package/dist/commands/cleanup.js.map +1 -1
  13. package/dist/commands/config.js +1 -1
  14. package/dist/commands/doctor.d.ts.map +1 -1
  15. package/dist/commands/doctor.js +45 -6
  16. package/dist/commands/doctor.js.map +1 -1
  17. package/dist/commands/generate-workflow.d.ts.map +1 -1
  18. package/dist/commands/generate-workflow.js +3 -11
  19. package/dist/commands/generate-workflow.js.map +1 -1
  20. package/dist/commands/history.d.ts.map +1 -1
  21. package/dist/commands/history.js +263 -68
  22. package/dist/commands/history.js.map +1 -1
  23. package/dist/commands/init.d.ts.map +1 -1
  24. package/dist/commands/init.js +7 -0
  25. package/dist/commands/init.js.map +1 -1
  26. package/dist/commands/pre-commit.d.ts.map +1 -1
  27. package/dist/commands/pre-commit.js +6 -2
  28. package/dist/commands/pre-commit.js.map +1 -1
  29. package/dist/commands/run.d.ts.map +1 -1
  30. package/dist/commands/run.js +618 -214
  31. package/dist/commands/run.js.map +1 -1
  32. package/dist/commands/state.d.ts.map +1 -1
  33. package/dist/commands/state.js +4 -7
  34. package/dist/commands/state.js.map +1 -1
  35. package/dist/commands/validate.d.ts.map +1 -1
  36. package/dist/commands/validate.js +5 -6
  37. package/dist/commands/validate.js.map +1 -1
  38. package/dist/commands/watch-pr.d.ts.map +1 -1
  39. package/dist/commands/watch-pr.js +33 -16
  40. package/dist/commands/watch-pr.js.map +1 -1
  41. package/dist/schemas/run-result-schema-export.d.ts +32 -0
  42. package/dist/schemas/run-result-schema-export.d.ts.map +1 -0
  43. package/dist/schemas/run-result-schema-export.js +40 -0
  44. package/dist/schemas/run-result-schema-export.js.map +1 -0
  45. package/dist/schemas/run-result-schema.d.ts +850 -0
  46. package/dist/schemas/run-result-schema.d.ts.map +1 -0
  47. package/dist/schemas/run-result-schema.js +67 -0
  48. package/dist/schemas/run-result-schema.js.map +1 -0
  49. package/dist/schemas/watch-pr-schema.d.ts +420 -22
  50. package/dist/schemas/watch-pr-schema.d.ts.map +1 -1
  51. package/dist/schemas/watch-pr-schema.js +2 -2
  52. package/dist/schemas/watch-pr-schema.js.map +1 -1
  53. package/dist/scripts/generate-run-result-schema.d.ts +10 -0
  54. package/dist/scripts/generate-run-result-schema.d.ts.map +1 -0
  55. package/dist/scripts/generate-run-result-schema.js +20 -0
  56. package/dist/scripts/generate-run-result-schema.js.map +1 -0
  57. package/dist/services/ci-provider.d.ts +21 -6
  58. package/dist/services/ci-provider.d.ts.map +1 -1
  59. package/dist/services/ci-providers/github-actions.d.ts +1 -5
  60. package/dist/services/ci-providers/github-actions.d.ts.map +1 -1
  61. package/dist/services/ci-providers/github-actions.js +9 -30
  62. package/dist/services/ci-providers/github-actions.js.map +1 -1
  63. package/dist/utils/config-error-reporter.js +1 -1
  64. package/dist/utils/config-error-reporter.js.map +1 -1
  65. package/dist/utils/pid-lock.d.ts.map +1 -1
  66. package/dist/utils/pid-lock.js +3 -6
  67. package/dist/utils/pid-lock.js.map +1 -1
  68. package/dist/utils/project-id.d.ts.map +1 -1
  69. package/dist/utils/project-id.js +3 -4
  70. package/dist/utils/project-id.js.map +1 -1
  71. package/dist/utils/runner-adapter.js +3 -2
  72. package/dist/utils/runner-adapter.js.map +1 -1
  73. package/dist/utils/temp-files.d.ts +67 -0
  74. package/dist/utils/temp-files.d.ts.map +1 -0
  75. package/dist/utils/temp-files.js +202 -0
  76. package/dist/utils/temp-files.js.map +1 -0
  77. package/dist/utils/validate-workflow.d.ts.map +1 -1
  78. package/dist/utils/validate-workflow.js +17 -12
  79. package/dist/utils/validate-workflow.js.map +1 -1
  80. package/dist/vibe-validate +131 -0
  81. package/package.json +11 -9
  82. package/run-result.schema.json +186 -0
  83. package/watch-pr-result.schema.json +128 -6
@@ -4,23 +4,142 @@
4
4
  * Executes a command and extracts LLM-friendly error output using vibe-validate extractors.
5
5
  * Provides concise, structured error information to save AI agent context windows.
6
6
  */
7
- import { spawn } from 'node:child_process';
7
+ import { execSync } from 'node:child_process';
8
+ import { writeFile, readFile } from 'node:fs/promises';
9
+ import { join } from 'node:path';
8
10
  import { autoDetectAndExtract } from '@vibe-validate/extractors';
11
+ import { getRunOutputDir, ensureDir } from '../utils/temp-files.js';
12
+ import { getGitTreeHash, encodeRunCacheKey, extractYamlWithPreamble } from '@vibe-validate/git';
13
+ import { spawnCommand, parseVibeValidateOutput } from '@vibe-validate/core';
9
14
  import yaml from 'yaml';
15
+ import chalk from 'chalk';
10
16
  export function runCommand(program) {
11
17
  program
12
18
  .command('run')
13
- .description('Run a command and extract LLM-friendly errors from output')
14
- .argument('<command>', 'Command to execute (quoted if it contains spaces)')
15
- .action(async (commandString) => {
19
+ .description('Run a command and extract LLM-friendly errors (with smart caching)')
20
+ .argument('<command...>', 'Command to execute (multiple words supported)')
21
+ .option('--check', 'Check if cached result exists without executing')
22
+ .option('--force', 'Force execution and update cache (bypass cache read)')
23
+ .option('--head <lines>', 'Display first N lines of output after YAML (on stderr)', Number.parseInt)
24
+ .option('--tail <lines>', 'Display last N lines of output after YAML (on stderr)', Number.parseInt)
25
+ .option('--verbose', 'Display all output after YAML (on stderr)')
26
+ .helpOption(false) // Disable automatic help to avoid conflicts with commands that use --help
27
+ .allowUnknownOption() // Allow unknown options in the command
28
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- Complex command handling logic, refactoring would reduce readability
29
+ .action(async (commandParts, options) => {
30
+ // WORKAROUND: Commander.js with allowUnknownOption() strips ALL options from commandParts
31
+ // Parse directly from process.argv to get the actual command with our options parsed correctly
32
+ const argv = process.argv;
33
+ const runIndex = argv.findIndex(arg => arg === 'run' || arg.endsWith('/vv') || arg.endsWith('/vibe-validate'));
34
+ let actualOptions;
35
+ let commandString;
36
+ if (runIndex === -1) {
37
+ // Test environment or non-standard invocation - fallback to Commander's parsing
38
+ actualOptions = options;
39
+ commandString = commandParts.join(' ');
40
+ }
41
+ else {
42
+ // Real CLI environment - manually parse argv to get correct options
43
+ actualOptions = {};
44
+ const actualCommand = [];
45
+ let i = runIndex + 1;
46
+ while (i < argv.length) {
47
+ const arg = argv[i];
48
+ if (arg === '--verbose') {
49
+ actualOptions.verbose = true;
50
+ i++;
51
+ }
52
+ else if (arg === '--check') {
53
+ actualOptions.check = true;
54
+ i++;
55
+ }
56
+ else if (arg === '--force') {
57
+ actualOptions.force = true;
58
+ i++;
59
+ }
60
+ else if (arg === '--head' && i + 1 < argv.length) {
61
+ actualOptions.head = Number.parseInt(argv[i + 1], 10);
62
+ i += 2;
63
+ }
64
+ else if (arg === '--tail' && i + 1 < argv.length) {
65
+ actualOptions.tail = Number.parseInt(argv[i + 1], 10);
66
+ i += 2;
67
+ }
68
+ else {
69
+ // Not a known option - rest is the command
70
+ actualCommand.push(...argv.slice(i));
71
+ break;
72
+ }
73
+ }
74
+ commandString = actualCommand.join(' ');
75
+ }
76
+ // If command is empty or starts with a flag (like --help), show help for run command
77
+ // This handles cases like: vv run --help, vv run --verbose --help, vv run --help bob
78
+ const trimmedCommand = commandString.trim();
79
+ if (!trimmedCommand || trimmedCommand.startsWith('-')) {
80
+ // No command or command starts with a flag - show run command help
81
+ showRunHelp();
82
+ process.exit(0);
83
+ }
16
84
  try {
17
- const { result, context } = await executeAndExtract(commandString);
85
+ // Handle --check flag (cache status check only)
86
+ if (actualOptions.check) {
87
+ const cachedResult = await tryGetCachedResult(commandString);
88
+ if (cachedResult) {
89
+ // Cache hit - output cached result and exit with code 0
90
+ process.stdout.write('---\n');
91
+ process.stdout.write(yaml.stringify(cachedResult));
92
+ process.stdout.write('\n');
93
+ process.exit(0);
94
+ }
95
+ else {
96
+ // Cache miss - output message and exit with code 1
97
+ process.stderr.write('No cached result found for command.\n');
98
+ process.exit(1);
99
+ }
100
+ return;
101
+ }
102
+ // Try to get cached result (unless --force)
103
+ let result;
104
+ let context = { preamble: '', stderr: '' };
105
+ if (!actualOptions.force) {
106
+ const cachedResult = await tryGetCachedResult(commandString);
107
+ if (cachedResult) {
108
+ result = cachedResult;
109
+ }
110
+ else {
111
+ // Cache miss - execute command
112
+ const executeResult = await executeAndExtract(commandString);
113
+ result = executeResult.result;
114
+ context = executeResult.context;
115
+ // Store result in cache
116
+ await storeCacheResult(commandString, result);
117
+ }
118
+ }
119
+ else {
120
+ // Force flag - bypass cache and execute
121
+ const executeResult = await executeAndExtract(commandString);
122
+ result = executeResult.result;
123
+ context = executeResult.context;
124
+ // Update cache with fresh result
125
+ await storeCacheResult(commandString, result);
126
+ }
18
127
  // CRITICAL: Write complete YAML to stdout and flush BEFORE any stderr
19
128
  // This ensures even if callers use 2>&1, YAML completes first
129
+ // Format as YAML front matter with opening delimiter
20
130
  process.stdout.write('---\n');
21
- process.stdout.write(yaml.stringify(result));
22
- // Add final newline to ensure YAML terminates cleanly
23
- process.stdout.write('\n');
131
+ // Add YAML comment for non-git repositories to inform LLMs
132
+ let yamlOutput = yaml.stringify(result);
133
+ if (result.treeHash === 'unknown') {
134
+ yamlOutput = yamlOutput.replace(/^treeHash: unknown$/m, 'treeHash: unknown # Not in git repository - caching disabled');
135
+ }
136
+ process.stdout.write(yamlOutput);
137
+ // Only write closing delimiter if there will be output displayed
138
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Using || to check truthy values (0 is falsy, which is correct)
139
+ const willDisplayOutput = !!(actualOptions.head || actualOptions.tail || actualOptions.verbose);
140
+ if (willDisplayOutput) {
141
+ process.stdout.write('---\n');
142
+ }
24
143
  // Flush stdout to guarantee all YAML is written before any stderr
25
144
  // This prevents interleaving when streams are combined with 2>&1
26
145
  await new Promise((resolve) => {
@@ -38,6 +157,10 @@ export function runCommand(program) {
38
157
  if (context.stderr) {
39
158
  process.stderr.write(context.stderr);
40
159
  }
160
+ // Display output based on --head, --tail, or --verbose flags
161
+ if (willDisplayOutput) {
162
+ await displayCommandOutput(result, actualOptions);
163
+ }
41
164
  // Exit with same code as the command
42
165
  process.exit(result.exitCode);
43
166
  }
@@ -56,38 +179,197 @@ export function runCommand(program) {
56
179
  }
57
180
  });
58
181
  }
182
+ /**
183
+ * Get working directory relative to git root
184
+ * Returns empty string for root, "packages/cli" for subdirectory
185
+ */
186
+ function getWorkingDirectory() {
187
+ try {
188
+ const gitRoot = execSync('git rev-parse --show-toplevel', {
189
+ encoding: 'utf8',
190
+ stdio: ['ignore', 'pipe', 'ignore'],
191
+ }).trim();
192
+ const cwd = process.cwd();
193
+ // If cwd is git root, return empty string
194
+ if (cwd === gitRoot) {
195
+ return '';
196
+ }
197
+ // Return relative path from git root
198
+ return cwd.substring(gitRoot.length + 1); // +1 to remove leading slash
199
+ }
200
+ catch {
201
+ // Not in a git repository - return empty string
202
+ return '';
203
+ }
204
+ }
205
+ /**
206
+ * Try to get cached result for a command
207
+ * Returns null if no cache hit or if not in a git repository
208
+ */
209
+ async function tryGetCachedResult(commandString) {
210
+ try {
211
+ // Get tree hash
212
+ const treeHash = await getGitTreeHash();
213
+ // Skip caching if not in git repository
214
+ if (treeHash === 'unknown') {
215
+ return null;
216
+ }
217
+ // Get working directory
218
+ const workdir = getWorkingDirectory();
219
+ // Encode cache key
220
+ const cacheKey = encodeRunCacheKey(commandString, workdir);
221
+ // Construct git notes ref path: refs/notes/vibe-validate/run/{treeHash}/{cacheKey}
222
+ const refPath = `vibe-validate/run/${treeHash}/${cacheKey}`;
223
+ // Try to read git note
224
+ const noteContent = execSync(`git notes --ref=${refPath} show HEAD 2>/dev/null || true`, {
225
+ encoding: 'utf8',
226
+ stdio: ['ignore', 'pipe', 'ignore'],
227
+ }).trim();
228
+ if (!noteContent) {
229
+ // Cache miss
230
+ return null;
231
+ }
232
+ // Parse cached note
233
+ const cachedNote = yaml.parse(noteContent);
234
+ // Migration: v0.14.x cached notes used 'duration' (ms), v0.15.0+ uses 'durationSecs' (s)
235
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
236
+ const durationSecs = cachedNote.durationSecs ?? (cachedNote.duration ? cachedNote.duration / 1000 : 0);
237
+ // Convert to RunResult format (mark as cached)
238
+ const result = {
239
+ command: cachedNote.command, // Use command from cache (may be unwrapped)
240
+ exitCode: cachedNote.exitCode,
241
+ durationSecs,
242
+ timestamp: cachedNote.timestamp,
243
+ treeHash: cachedNote.treeHash,
244
+ extraction: cachedNote.extraction,
245
+ ...(cachedNote.outputFiles ? { outputFiles: cachedNote.outputFiles } : {}),
246
+ isCachedResult: true, // Mark as cache hit
247
+ };
248
+ return result;
249
+ // eslint-disable-next-line sonarjs/no-ignored-exceptions -- Cache lookup failure is non-critical, proceed with execution
250
+ }
251
+ catch (_error) {
252
+ // Cache lookup failed - proceed with execution
253
+ return null;
254
+ }
255
+ }
256
+ /**
257
+ * Store result in cache (only successful runs - exitCode === 0)
258
+ */
259
+ async function storeCacheResult(commandString, result) {
260
+ try {
261
+ // Only cache successful runs (v0.15.0+)
262
+ // Failed runs may be transient or environment-specific
263
+ if (result.exitCode !== 0) {
264
+ return;
265
+ }
266
+ // Get tree hash
267
+ const treeHash = await getGitTreeHash();
268
+ // Skip caching if not in git repository
269
+ if (treeHash === 'unknown') {
270
+ return;
271
+ }
272
+ // Get working directory
273
+ const workdir = getWorkingDirectory();
274
+ // Encode cache key
275
+ const cacheKey = encodeRunCacheKey(commandString, workdir);
276
+ // Construct git notes ref path
277
+ const refPath = `vibe-validate/run/${treeHash}/${cacheKey}`;
278
+ // Build cache note (extraction already cleaned in runner)
279
+ // Token optimization: Only include extraction when exitCode !== 0 OR there are actual errors
280
+ // Note: result.command may be unwrapped (actual command) if nested vibe-validate was detected
281
+ const cacheNote = {
282
+ treeHash,
283
+ command: result.command, // Store unwrapped command (e.g., "eslint ..." not "pnpm lint")
284
+ workdir,
285
+ timestamp: result.timestamp,
286
+ exitCode: result.exitCode,
287
+ durationSecs: result.durationSecs,
288
+ ...(result.extraction ? { extraction: result.extraction } : {}), // Conditionally include extraction
289
+ ...(result.outputFiles ? { outputFiles: result.outputFiles } : {}),
290
+ };
291
+ // Store in git notes using heredoc to avoid quote escaping issues
292
+ const noteYaml = yaml.stringify(cacheNote);
293
+ // Use heredoc format for multi-line YAML
294
+ try {
295
+ execSync(`cat <<'EOF' | git notes --ref=${refPath} add -f -F - HEAD\n${noteYaml}\nEOF`, {
296
+ stdio: 'ignore',
297
+ shell: '/bin/bash', // Ensure bash for heredoc support
298
+ });
299
+ // eslint-disable-next-line sonarjs/no-ignored-exceptions -- Cache storage failure is non-critical
300
+ }
301
+ catch (_error) {
302
+ // Cache storage failed - not critical, continue
303
+ }
304
+ // eslint-disable-next-line sonarjs/no-ignored-exceptions -- Cache storage failure is non-critical, continue execution
305
+ }
306
+ catch (_error) {
307
+ // Cache storage failed - not critical, continue
308
+ }
309
+ }
310
+ /**
311
+ * Strip ANSI escape codes from text
312
+ */
313
+ function stripAnsiCodes(text) {
314
+ // Control character \x1b is intentionally used to match ANSI escape codes
315
+ // eslint-disable-next-line no-control-regex, sonarjs/no-control-regex
316
+ return text.replace(/\x1b\[[0-9;]*m/g, '');
317
+ }
59
318
  /**
60
319
  * Execute a command and extract errors from its output
61
320
  */
62
321
  async function executeAndExtract(commandString) {
63
322
  return new Promise((resolve, reject) => {
64
- // SECURITY: shell: true required for shell operators (&&, ||, |) and cross-platform compatibility.
65
- // Commands from user config files only (same trust as npm scripts). See SECURITY.md for full threat model.
66
- // NOSONAR - Intentional shell execution of user-defined commands
67
- const child = spawn(commandString, {
68
- shell: true,
69
- stdio: ['inherit', 'pipe', 'pipe'], // inherit stdin, pipe stdout/stderr
70
- });
323
+ const startTime = Date.now();
324
+ const child = spawnCommand(commandString);
71
325
  let stdout = '';
72
326
  let stderr = '';
73
- // Capture stdout (stdio: 'pipe' configuration guarantees these are Readable streams)
327
+ const combinedLines = [];
328
+ // Capture stdout (spawnCommand always sets stdio: ['ignore', 'pipe', 'pipe'], so stdout/stderr are guaranteed non-null)
329
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- spawnCommand always pipes stdout/stderr
74
330
  child.stdout.on('data', (data) => {
75
- stdout += data.toString();
331
+ const chunk = data.toString();
332
+ stdout += chunk;
333
+ // Add to combined output (ANSI-stripped)
334
+ const lines = chunk.split('\n');
335
+ for (const line of lines) {
336
+ if (line) {
337
+ combinedLines.push({
338
+ ts: new Date().toISOString(),
339
+ stream: 'stdout',
340
+ line: stripAnsiCodes(line),
341
+ });
342
+ }
343
+ }
76
344
  });
77
- // Capture stderr (stdio: 'pipe' configuration guarantees these are Readable streams)
345
+ // Capture stderr
346
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- spawnCommand always pipes stdout/stderr
78
347
  child.stderr.on('data', (data) => {
79
- stderr += data.toString();
348
+ const chunk = data.toString();
349
+ stderr += chunk;
350
+ // Add to combined output (ANSI-stripped)
351
+ const lines = chunk.split('\n');
352
+ for (const line of lines) {
353
+ if (line) {
354
+ combinedLines.push({
355
+ ts: new Date().toISOString(),
356
+ stream: 'stderr',
357
+ line: stripAnsiCodes(line),
358
+ });
359
+ }
360
+ }
80
361
  });
81
362
  // Handle process exit
82
363
  child.on('close', (exitCode = 1) => {
364
+ const durationSecs = (Date.now() - startTime) / 1000;
83
365
  // CRITICAL: Check ONLY stdout for YAML (not stderr)
84
366
  // This prevents stderr warnings from corrupting nested YAML output
85
- if (isYamlOutput(stdout)) {
86
- const { yaml, preamble } = extractYamlAndPreamble(stdout);
87
- const mergedResult = mergeNestedYaml(commandString, yaml, exitCode);
367
+ const yamlResult = extractYamlWithPreamble(stdout);
368
+ if (yamlResult) {
369
+ const mergedResult = mergeNestedYaml(commandString, yamlResult.yaml, exitCode, durationSecs);
88
370
  // Include preamble and stderr for context
89
371
  const contextOutput = {
90
- preamble: preamble.trim(),
372
+ preamble: yamlResult.preamble,
91
373
  stderr: stderr.trim(),
92
374
  };
93
375
  resolve({ result: mergedResult, context: contextOutput });
@@ -95,20 +377,101 @@ async function executeAndExtract(commandString) {
95
377
  }
96
378
  // For extraction, combine both streams (stderr has useful error context)
97
379
  const combinedOutput = stdout + stderr;
98
- // Infer step name from command for smart extraction
99
- const stepName = inferStepName(commandString);
100
- // Extract errors using smart extractor
101
- const extraction = autoDetectAndExtract(stepName, combinedOutput);
102
- const result = {
103
- command: commandString,
104
- exitCode,
105
- extraction,
106
- // Include truncated raw output for reference (if needed for debugging)
107
- rawOutput: combinedOutput.length > 1000
108
- ? combinedOutput.substring(0, 1000) + '... (truncated)'
109
- : combinedOutput,
110
- };
111
- resolve({ result, context: { preamble: '', stderr: '' } });
380
+ // Extract errors using smart extractor (output-based detection)
381
+ // Token optimization: Only extract when exitCode !== 0 OR there are actual errors
382
+ const rawExtraction = autoDetectAndExtract({
383
+ stdout,
384
+ stderr,
385
+ combined: combinedOutput,
386
+ });
387
+ const extraction = (exitCode !== 0 || rawExtraction.totalErrors > 0) ? rawExtraction : undefined;
388
+ // Get tree hash for result (async operation needs to be awaited)
389
+ getGitTreeHash()
390
+ .then(async (treeHash) => {
391
+ // Write output files to organized temp directory
392
+ const outputDir = getRunOutputDir(treeHash);
393
+ await ensureDir(outputDir);
394
+ const writePromises = [];
395
+ // Write stdout.log (only if non-empty)
396
+ let stdoutFile;
397
+ if (stdout.trim()) {
398
+ stdoutFile = join(outputDir, 'stdout.log');
399
+ writePromises.push(writeFile(stdoutFile, stdout, 'utf-8'));
400
+ }
401
+ // Write stderr.log (only if non-empty)
402
+ let stderrFile;
403
+ if (stderr.trim()) {
404
+ stderrFile = join(outputDir, 'stderr.log');
405
+ writePromises.push(writeFile(stderrFile, stderr, 'utf-8'));
406
+ }
407
+ // Write combined.jsonl (always)
408
+ const combinedFile = join(outputDir, 'combined.jsonl');
409
+ const combinedContent = combinedLines
410
+ // eslint-disable-next-line sonarjs/no-nested-functions -- Array.map callback is standard functional programming pattern
411
+ .map(line => JSON.stringify(line))
412
+ .join('\n');
413
+ writePromises.push(writeFile(combinedFile, combinedContent, 'utf-8'));
414
+ // Wait for all writes to complete
415
+ await Promise.all(writePromises);
416
+ const result = {
417
+ command: commandString,
418
+ exitCode,
419
+ durationSecs,
420
+ timestamp: new Date().toISOString(),
421
+ treeHash,
422
+ ...(extraction ? { extraction } : {}), // Only include extraction if needed
423
+ outputFiles: {
424
+ ...(stdoutFile ? { stdout: stdoutFile } : {}),
425
+ ...(stderrFile ? { stderr: stderrFile } : {}),
426
+ combined: combinedFile,
427
+ },
428
+ };
429
+ resolve({ result, context: { preamble: '', stderr: '' } });
430
+ })
431
+ .catch(async () => {
432
+ // If tree hash fails, use timestamp-based fallback
433
+ const timestamp = new Date().toISOString();
434
+ const fallbackHash = `nogit-${Date.now()}`;
435
+ // Write output files even without git
436
+ const outputDir = getRunOutputDir(fallbackHash);
437
+ await ensureDir(outputDir);
438
+ const writePromises = [];
439
+ // Write stdout.log (only if non-empty)
440
+ let stdoutFile;
441
+ if (stdout.trim()) {
442
+ stdoutFile = join(outputDir, 'stdout.log');
443
+ writePromises.push(writeFile(stdoutFile, stdout, 'utf-8'));
444
+ }
445
+ // Write stderr.log (only if non-empty)
446
+ let stderrFile;
447
+ if (stderr.trim()) {
448
+ stderrFile = join(outputDir, 'stderr.log');
449
+ writePromises.push(writeFile(stderrFile, stderr, 'utf-8'));
450
+ }
451
+ // Write combined.jsonl (always)
452
+ const combinedFile = join(outputDir, 'combined.jsonl');
453
+ const combinedContent = combinedLines
454
+ // eslint-disable-next-line sonarjs/no-nested-functions -- Array.map callback is standard functional programming pattern
455
+ .map(line => JSON.stringify(line))
456
+ .join('\n');
457
+ writePromises.push(writeFile(combinedFile, combinedContent, 'utf-8'));
458
+ // Wait for all writes to complete
459
+ await Promise.all(writePromises);
460
+ const result = {
461
+ command: commandString,
462
+ exitCode,
463
+ durationSecs,
464
+ timestamp,
465
+ treeHash: fallbackHash,
466
+ ...(extraction ? { extraction } : {}), // Only include extraction if needed
467
+ outputFiles: {
468
+ ...(stdoutFile ? { stdout: stdoutFile } : {}),
469
+ ...(stderrFile ? { stderr: stderrFile } : {}),
470
+ combined: combinedFile,
471
+ },
472
+ };
473
+ resolve({ result, context: { preamble: '', stderr: '' } });
474
+ });
112
475
  });
113
476
  // Handle spawn errors (e.g., command not found)
114
477
  child.on('error', (error) => {
@@ -125,188 +488,146 @@ async function executeAndExtract(commandString) {
125
488
  * - "pnpm lint" → "lint"
126
489
  * - "pnpm --filter @pkg test" → "test"
127
490
  */
128
- function inferStepName(commandString) {
129
- const lower = commandString.toLowerCase();
130
- // TypeScript/tsc
131
- if (lower.includes('tsc') || lower.includes('typecheck')) {
132
- return 'typecheck';
133
- }
134
- // Linting
135
- if (lower.includes('eslint') || lower.includes('lint')) {
136
- return 'lint';
137
- }
138
- // Testing (vitest, jest, mocha, etc.)
139
- if (lower.includes('vitest') || lower.includes('jest') ||
140
- lower.includes('mocha') || lower.includes('test') ||
141
- lower.includes('jasmine')) {
142
- return 'test';
143
- }
144
- // OpenAPI
145
- if (lower.includes('openapi')) {
146
- return 'openapi';
147
- }
148
- // Generic fallback
149
- return 'run';
150
- }
151
- /**
152
- * Check if output contains YAML format (may have preamble before ---)
153
- *
154
- * IMPORTANT: This function detects YAML anywhere in the output, not just at the start.
155
- * This allows us to handle package manager preambles (pnpm, npm, yarn) that appear
156
- * before the actual YAML content.
157
- *
158
- * Example with preamble:
159
- * ```
160
- * > vibe-validate@0.13.0 validate
161
- * > node packages/cli/dist/bin.js validate
162
- *
163
- * ---
164
- * command: "npm test"
165
- * exitCode: 0
166
- * ```
167
- *
168
- * The preamble will be extracted and routed to stderr, keeping stdout clean.
169
- */
170
- function isYamlOutput(output) {
171
- const trimmed = output.trim();
172
- // Check if starts with --- (no preamble)
173
- if (trimmed.startsWith('---\n') || trimmed.startsWith('---\r\n')) {
174
- return true;
175
- }
176
- // Check if contains --- with newlines (has preamble)
177
- return output.includes('\n---\n') || output.includes('\n---\r\n');
178
- }
179
- /**
180
- * Extract YAML content and separate preamble/postamble
181
- *
182
- * STREAM ROUTING STRATEGY:
183
- * - stdout (returned 'yaml'): Clean YAML for piping and LLM consumption
184
- * - stderr (returned 'preamble'): Package manager noise, preserved for human context
185
- *
186
- * This separation follows Unix philosophy: stdout = data, stderr = human messages.
187
- *
188
- * Example input:
189
- * ```
190
- * > package@1.0.0 test ← preamble (goes to stderr)
191
- * > vitest run ← preamble (goes to stderr)
192
- *
193
- * --- ← yaml (goes to stdout)
194
- * command: "vitest run"
195
- * exitCode: 0
196
- * extraction: {...}
197
- * ```
198
- *
199
- * Benefits:
200
- * 1. `run "pnpm test" > file.yaml` writes pure YAML
201
- * 2. `run "pnpm test" 2>/dev/null` suppresses noise
202
- * 3. Terminal shows both streams (full context)
203
- *
204
- * @param stdout - Raw stdout from the executed command
205
- * @returns Object with separated yaml, preamble, and postamble
206
- */
207
- function extractYamlAndPreamble(stdout) {
208
- // Check if it starts with --- (no preamble)
209
- const trimmed = stdout.trim();
210
- if (trimmed.startsWith('---\n') || trimmed.startsWith('---\r\n')) {
211
- return { yaml: trimmed, preamble: '', postamble: '' };
212
- }
213
- // Find first YAML separator with newline before it
214
- const patterns = [
215
- { pattern: '\n---\n', offset: 1 },
216
- { pattern: '\n---\r\n', offset: 1 },
217
- ];
218
- let earliestIndex = -1;
219
- let selectedOffset = 0;
220
- for (const { pattern, offset } of patterns) {
221
- const idx = stdout.indexOf(pattern);
222
- if (idx !== -1 && (earliestIndex === -1 || idx < earliestIndex)) {
223
- earliestIndex = idx;
224
- selectedOffset = offset;
225
- }
226
- }
227
- if (earliestIndex === -1) {
228
- // No YAML found
229
- return { yaml: '', preamble: stdout, postamble: '' };
230
- }
231
- // Extract preamble (everything before ---)
232
- const preamble = stdout.substring(0, earliestIndex).trim();
233
- // Extract YAML content (from --- onward)
234
- const yamlContent = stdout.substring(earliestIndex + selectedOffset).trim();
235
- return { yaml: yamlContent, preamble, postamble: '' };
236
- }
237
491
  /**
238
492
  * Merge nested YAML output with outer run metadata
239
493
  *
240
494
  * When vibe-validate run wraps another vibe-validate command (run or validate),
241
495
  * we merge the inner YAML with outer metadata instead of double-extracting.
242
496
  */
243
- function mergeNestedYaml(outerCommand, yamlOutput, outerExitCode) {
497
+ function mergeNestedYaml(outerCommand, yamlOutput, outerExitCode, outerDurationSecs) {
244
498
  try {
245
- // Parse the inner YAML
499
+ // Always parse the raw YAML first to preserve all fields
246
500
  const innerResult = yaml.parse(yamlOutput);
247
- // Extract the innermost command for suggestedDirectCommand
248
- const innermostCommand = extractInnermostCommand(innerResult);
249
- // Merge: preserve ALL inner fields, add outer metadata
250
- const mergedResult = {
251
- ...innerResult, // Spread ALL inner fields (errors, phases, tree_hash, etc.)
252
- command: outerCommand, // Override with outer command
253
- exitCode: outerExitCode, // Use outer exit code (should match inner)
254
- suggestedDirectCommand: innermostCommand, // Add suggestion
501
+ // Try parsing as vibe-validate output using shared parser
502
+ const parsed = parseVibeValidateOutput(yamlOutput);
503
+ if (parsed) {
504
+ // Successfully parsed nested vibe-validate output
505
+ // Spread all inner fields to preserve custom fields (rawOutput, customField, etc.)
506
+ // Then override with outer metadata and parsed information
507
+ // Use command from inner result (already unwrapped by nested vibe-validate call)
508
+ const unwrappedCommand = innerResult.command ?? outerCommand;
509
+ return {
510
+ ...innerResult, // Preserve ALL inner fields
511
+ command: unwrappedCommand, // Use unwrapped command (e.g., "eslint ..." instead of "pnpm lint")
512
+ exitCode: outerExitCode, // Override with outer exit code
513
+ durationSecs: outerDurationSecs, // Override with outer duration
514
+ timestamp: parsed.timestamp ?? innerResult.timestamp ?? new Date().toISOString(),
515
+ treeHash: parsed.treeHash ?? innerResult.treeHash ?? 'unknown',
516
+ extraction: parsed.extraction, // Use parsed extraction
517
+ ...(parsed.isCachedResult !== undefined ? { isCachedResult: parsed.isCachedResult } : {}),
518
+ ...(parsed.fullOutputFile ? { fullOutputFile: parsed.fullOutputFile } : {}),
519
+ };
520
+ }
521
+ // Not a recognized vibe-validate format - use inner command if available
522
+ // This handles cases where the wrapper executes a command that produces non-YAML output
523
+ const unwrappedCommand = (innerResult.command && typeof innerResult.command === 'string')
524
+ ? innerResult.command
525
+ : outerCommand;
526
+ return {
527
+ ...innerResult,
528
+ command: unwrappedCommand, // Use inner command (unwrapped)
529
+ exitCode: outerExitCode,
530
+ durationSecs: outerDurationSecs,
531
+ treeHash: innerResult.treeHash ?? 'unknown', // Use inner treeHash or fallback to unknown
255
532
  };
256
- return mergedResult;
257
533
  }
258
534
  catch (error) {
259
- // If YAML parsing fails, treat as regular output
535
+ // YAML parsing completely failed - treat as regular output
260
536
  console.error('Warning: Failed to parse nested YAML output:', error);
261
- const stepName = inferStepName(outerCommand);
262
- const extraction = autoDetectAndExtract(stepName, yamlOutput);
537
+ // Token optimization: Only extract when exitCode !== 0 OR there are actual errors
538
+ const rawExtraction = autoDetectAndExtract(yamlOutput);
539
+ const extraction = (outerExitCode !== 0 || rawExtraction.totalErrors > 0) ? rawExtraction : undefined;
263
540
  return {
264
541
  command: outerCommand,
265
542
  exitCode: outerExitCode,
266
- extraction,
267
- rawOutput: yamlOutput.substring(0, 1000),
543
+ durationSecs: outerDurationSecs,
544
+ timestamp: new Date().toISOString(),
545
+ treeHash: 'unknown',
546
+ ...(extraction ? { extraction } : {}), // Only include extraction if needed
268
547
  };
269
548
  }
270
549
  }
271
- /**
272
- * Extract the innermost command from nested run results
273
- *
274
- * Examples:
275
- * - { command: "npm test" } → "npm test"
276
- * - { command: "...", suggestedDirectCommand: "npm test" } → "npm test"
277
- * - { command: "vibe-validate validate" } → "vibe-validate validate"
278
- */
279
- function extractInnermostCommand(result) {
280
- // If already has suggestedDirectCommand, use it (handles 3+ levels)
281
- if (result.suggestedDirectCommand && typeof result.suggestedDirectCommand === 'string') {
282
- return result.suggestedDirectCommand;
283
- }
284
- // Otherwise, use the command from the inner result
285
- if (result.command && typeof result.command === 'string') {
286
- return result.command;
287
- }
288
- return 'unknown';
289
- }
290
550
  /**
291
551
  * Show verbose help with detailed documentation
292
552
  */
293
553
  export function showRunVerboseHelp() {
294
554
  console.log(`# run Command Reference
295
555
 
296
- > Run a command and extract LLM-friendly errors
556
+ > Run a command and extract LLM-friendly errors (with smart caching)
297
557
 
298
558
  ## Overview
299
559
 
300
560
  The \`run\` command executes any shell command and extracts errors using vibe-validate's smart extractors. This provides concise, structured error information to save AI agent context windows.
301
561
 
562
+ **NEW in v0.15.0**: Automatic caching based on git tree hash - repeat commands are instant (<200ms) when code hasn't changed.
563
+
302
564
  ## How It Works
303
565
 
304
- 1. **Executes command** in a shell subprocess
305
- 2. **Captures output** (stdout + stderr)
306
- 3. **Auto-detects format** (vitest, jest, tsc, eslint, etc.)
307
- 4. **Extracts errors** using appropriate extractor
308
- 5. **Outputs YAML** with structured error information
309
- 6. **Passes through exit code** from original command
566
+ 1. **Checks cache** - If git tree unchanged, returns cached result instantly
567
+ 2. **Executes command** (on cache miss) in a shell subprocess
568
+ 3. **Captures output** (stdout + stderr)
569
+ 4. **Auto-detects format** (vitest, jest, tsc, eslint, etc.)
570
+ 5. **Extracts errors** using appropriate extractor
571
+ 6. **Stores in cache** - Future runs with same tree hash are instant
572
+ 7. **Outputs YAML** with structured error information
573
+ 8. **Passes through exit code** from original command
574
+
575
+ ## Caching
576
+
577
+ The \`run\` command automatically caches results based on:
578
+ - **Git tree hash** - Content-based identifier (same code = same hash)
579
+ - **Command string** - Different commands have separate caches
580
+ - **Working directory** - Subdirectory runs are tracked separately
581
+
582
+ ### Cache Behavior
583
+
584
+ **First run** (cache miss):
585
+ \`\`\`bash
586
+ $ vibe-validate run "pnpm test"
587
+ # Executes test suite, extracts errors, stores in cache
588
+ # Duration: ~30 seconds
589
+ \`\`\`
590
+
591
+ **Repeat run** (cache hit):
592
+ \`\`\`bash
593
+ $ vibe-validate run "pnpm test"
594
+ # Returns cached result instantly (no execution)
595
+ # Duration: <200ms
596
+ \`\`\`
597
+
598
+ **After code change**:
599
+ \`\`\`bash
600
+ # Edit a file, tree hash changes
601
+ $ vibe-validate run "pnpm test"
602
+ # Cache miss - executes and caches with new tree hash
603
+ \`\`\`
604
+
605
+ ### Cache Flags
606
+
607
+ **Check cache status** (--check):
608
+ \`\`\`bash
609
+ $ vibe-validate run --check "pnpm test"
610
+ # Exit 0 if cached (outputs cached result)
611
+ # Exit 1 if not cached (no execution)
612
+ \`\`\`
613
+
614
+ **Force execution** (--force):
615
+ \`\`\`bash
616
+ $ vibe-validate run --force "pnpm test"
617
+ # Always executes, updates cache (ignores existing cache)
618
+ # Useful for flaky tests or time-sensitive commands
619
+ \`\`\`
620
+
621
+ ### Cache Storage
622
+
623
+ Cache is stored in git notes at:
624
+ \`\`\`
625
+ refs/notes/vibe-validate/run/{treeHash}/{encodedCommand}
626
+ \`\`\`
627
+
628
+ - **Local only** - Not pushed with git (each dev has their own cache)
629
+ - **Automatic cleanup** - Run \`vibe-validate doctor\` to check cache health
630
+ - **No configuration required** - Works out of the box
310
631
 
311
632
  ## Use Cases
312
633
 
@@ -316,29 +637,33 @@ Instead of parsing verbose test output:
316
637
  # Verbose (wastes context window)
317
638
  npx vitest packages/extractors/test/vitest-extractor.test.ts
318
639
 
319
- # Concise (LLM-friendly)
320
- vibe-validate run "npx vitest packages/extractors/test/vitest-extractor.test.ts"
640
+ # Concise (LLM-friendly) - NEW: No quotes needed!
641
+ vibe-validate run npx vitest packages/extractors/test/vitest-extractor.test.ts
321
642
  \`\`\`
322
643
 
323
644
  ### Debugging Specific Tests
324
645
  \`\`\`bash
325
- # Run single test file with extraction
326
- vibe-validate run "npx vitest -t 'should extract failed tests'"
646
+ # Run single test file with extraction (NEW: natural syntax)
647
+ vibe-validate run npx vitest -t 'should extract failed tests'
327
648
 
328
649
  # Run package tests with extraction
329
- vibe-validate run "pnpm --filter @vibe-validate/extractors test"
650
+ vibe-validate run pnpm --filter @vibe-validate/extractors test
651
+
652
+ # Quoted syntax still works for compatibility
653
+ vibe-validate run "npx vitest test.ts"
330
654
  \`\`\`
331
655
 
332
656
  ### Type Checking
333
657
  \`\`\`bash
334
658
  # Extract TypeScript errors
335
- vibe-validate run "npx tsc --noEmit"
659
+ vibe-validate run npx tsc --noEmit
336
660
  \`\`\`
337
661
 
338
662
  ### Linting
339
663
  \`\`\`bash
340
- # Extract ESLint errors
341
- vibe-validate run "pnpm lint"
664
+ # Extract ESLint errors (options pass through correctly)
665
+ vibe-validate run pnpm lint
666
+ vibe-validate run eslint --max-warnings 0 src/
342
667
  \`\`\`
343
668
 
344
669
  ## Output Format
@@ -355,7 +680,7 @@ extraction:
355
680
  message: "expected 5 to equal 3"
356
681
  summary: "1 test failed"
357
682
  guidance: "Review test assertions and expected values"
358
- cleanOutput: |
683
+ errorSummary: |
359
684
  test.ts:42 - expected 5 to equal 3
360
685
  rawOutput: "... (truncated)"
361
686
  \`\`\`
@@ -403,28 +728,27 @@ The \`run\` command automatically detects and handles package manager preambles:
403
728
 
404
729
  This means you can safely use:
405
730
  \`\`\`bash
406
- vibe-validate run "pnpm validate --yaml" # Works!
407
- vibe-validate run "npm test" # Works!
408
- vibe-validate run "yarn build" # Works!
731
+ vibe-validate run pnpm validate --yaml # Works!
732
+ vibe-validate run npm test # Works!
733
+ vibe-validate run yarn build # Works!
409
734
  \`\`\`
410
735
 
411
736
  The YAML output on stdout remains clean and parseable, while the preamble is preserved on stderr for debugging.
412
737
 
413
738
  ## Nested Run Detection
414
739
 
415
- When \`run\` wraps another vibe-validate command that outputs YAML, it intelligently merges the results:
740
+ When \`run\` wraps another vibe-validate command that outputs YAML, it automatically unwraps to show the actual command:
416
741
 
417
742
  \`\`\`bash
418
743
  # 2-level nesting
419
744
  $ vibe-validate run "vibe-validate run 'npm test'"
420
745
  ---
421
- command: vibe-validate run "npm test"
746
+ command: npm test # Automatically unwrapped!
422
747
  exitCode: 0
423
748
  extraction: {...}
424
- suggestedDirectCommand: npm test # ← Unwrapped!
425
749
  \`\`\`
426
750
 
427
- The \`suggestedDirectCommand\` field shows the innermost command, helping you avoid unnecessary nesting.
751
+ The \`command\` field shows the innermost command that actually executed, helping you avoid unnecessary nesting.
428
752
 
429
753
  ## Exit Codes
430
754
 
@@ -434,29 +758,40 @@ The \`run\` command passes through the exit code from the executed command:
434
758
 
435
759
  ## Examples
436
760
 
437
- ### Run Single Test File
761
+ ### Python Testing
438
762
  \`\`\`bash
439
- vibe-validate run "npx vitest packages/cli/test/commands/run.test.ts"
763
+ vv run pytest tests/ --cov=src
764
+ vv run pytest -k test_auth --verbose
765
+ vv run python -m unittest discover
440
766
  \`\`\`
441
767
 
442
- ### Run Specific Test Case
768
+ ### Rust Testing
443
769
  \`\`\`bash
444
- vibe-validate run "npx vitest -t 'should extract errors'"
770
+ vv run cargo test
771
+ vv run cargo test --all-features
772
+ vv run cargo clippy -- -D warnings
445
773
  \`\`\`
446
774
 
447
- ### Run Package Tests
775
+ ### Go Testing
448
776
  \`\`\`bash
449
- vibe-validate run "pnpm --filter @vibe-validate/core test"
777
+ vv run go test ./...
778
+ vv run go test -v -race ./pkg/...
779
+ vv run go vet ./...
450
780
  \`\`\`
451
781
 
452
- ### Type Check
782
+ ### Ruby Testing
453
783
  \`\`\`bash
454
- vibe-validate run "npx tsc --noEmit"
784
+ vv run bundle exec rspec
785
+ vv run bundle exec rspec spec/models/
786
+ vv run bundle exec rubocop
455
787
  \`\`\`
456
788
 
457
- ### Lint
789
+ ### Node.js/TypeScript
458
790
  \`\`\`bash
459
- vibe-validate run "pnpm lint"
791
+ vv run npm test
792
+ vv run npx vitest packages/cli/test/commands/run.test.ts
793
+ vv run npx tsc --noEmit
794
+ vv run pnpm lint
460
795
  \`\`\`
461
796
 
462
797
  ## Supported Extractors
@@ -505,4 +840,73 @@ extraction:
505
840
  **Result**: Same information, 90% smaller!
506
841
  `);
507
842
  }
843
+ /**
844
+ * Show help for the run command
845
+ */
846
+ function showRunHelp() {
847
+ console.log(`
848
+ Usage: vibe-validate run [options] <command...>
849
+
850
+ Run a command and extract LLM-friendly errors (with smart caching)
851
+
852
+ Arguments:
853
+ command... Command to execute (multiple words supported)
854
+
855
+ Options:
856
+ --check Check if cached result exists without executing
857
+ --force Force execution and update cache (bypass cache read)
858
+ --head <lines> Display first N lines of output after YAML (on stderr)
859
+ --tail <lines> Display last N lines of output after YAML (on stderr)
860
+ --verbose Display all output after YAML (on stderr)
861
+ -h, --help Display this help message
862
+
863
+ Examples:
864
+ vv run pytest tests/ --cov=src # Python
865
+ vv run cargo test --all-features # Rust
866
+ vv run go test ./... # Go
867
+ vv run npm test # Node.js
868
+ vv run --verbose npm test # With output display
869
+
870
+ For detailed documentation, use: vibe-validate run --help --verbose
871
+ `.trim());
872
+ }
873
+ /**
874
+ * Display command output based on --head, --tail, or --verbose flags
875
+ */
876
+ async function displayCommandOutput(result, options) {
877
+ if (!result.outputFiles?.combined) {
878
+ return;
879
+ }
880
+ try {
881
+ const combinedContent = await readFile(result.outputFiles.combined, 'utf-8');
882
+ const lines = combinedContent.trim().split('\n');
883
+ const outputLines = lines
884
+ .filter(line => line.trim())
885
+ .map(line => JSON.parse(line));
886
+ let linesToDisplay;
887
+ if (options.verbose) {
888
+ linesToDisplay = outputLines;
889
+ }
890
+ else if (options.head) {
891
+ linesToDisplay = outputLines.slice(0, options.head);
892
+ }
893
+ else if (options.tail) {
894
+ linesToDisplay = outputLines.slice(-options.tail);
895
+ }
896
+ else {
897
+ return;
898
+ }
899
+ // Display lines with formatting (no timestamp - available in JSONL if needed)
900
+ // No header - YAML front matter delimiter serves as separator
901
+ for (const line of linesToDisplay) {
902
+ const streamColor = line.stream === 'stdout' ? chalk.gray : chalk.yellow;
903
+ const stream = streamColor(`[${line.stream}]`);
904
+ process.stderr.write(`${stream} ${line.line}\n`);
905
+ }
906
+ // eslint-disable-next-line sonarjs/no-ignored-exceptions -- Display errors are non-critical, YAML already written
907
+ }
908
+ catch (_err) {
909
+ // Silently ignore errors in display - YAML output is already written
910
+ }
911
+ }
508
912
  //# sourceMappingURL=run.js.map