@vibe-validate/cli 0.14.2 → 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 (102) 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 +41 -21
  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 +135 -16
  12. package/dist/commands/cleanup.js.map +1 -1
  13. package/dist/commands/config.d.ts.map +1 -1
  14. package/dist/commands/config.js +13 -10
  15. package/dist/commands/config.js.map +1 -1
  16. package/dist/commands/doctor.d.ts.map +1 -1
  17. package/dist/commands/doctor.js +253 -189
  18. package/dist/commands/doctor.js.map +1 -1
  19. package/dist/commands/generate-workflow.d.ts.map +1 -1
  20. package/dist/commands/generate-workflow.js +15 -13
  21. package/dist/commands/generate-workflow.js.map +1 -1
  22. package/dist/commands/history.d.ts.map +1 -1
  23. package/dist/commands/history.js +305 -98
  24. package/dist/commands/history.js.map +1 -1
  25. package/dist/commands/init.d.ts.map +1 -1
  26. package/dist/commands/init.js +14 -6
  27. package/dist/commands/init.js.map +1 -1
  28. package/dist/commands/pre-commit.d.ts.map +1 -1
  29. package/dist/commands/pre-commit.js +8 -3
  30. package/dist/commands/pre-commit.js.map +1 -1
  31. package/dist/commands/run.d.ts.map +1 -1
  32. package/dist/commands/run.js +620 -217
  33. package/dist/commands/run.js.map +1 -1
  34. package/dist/commands/state.d.ts.map +1 -1
  35. package/dist/commands/state.js +4 -7
  36. package/dist/commands/state.js.map +1 -1
  37. package/dist/commands/validate.d.ts.map +1 -1
  38. package/dist/commands/validate.js +7 -7
  39. package/dist/commands/validate.js.map +1 -1
  40. package/dist/commands/watch-pr.d.ts.map +1 -1
  41. package/dist/commands/watch-pr.js +73 -49
  42. package/dist/commands/watch-pr.js.map +1 -1
  43. package/dist/schemas/run-result-schema-export.d.ts +32 -0
  44. package/dist/schemas/run-result-schema-export.d.ts.map +1 -0
  45. package/dist/schemas/run-result-schema-export.js +40 -0
  46. package/dist/schemas/run-result-schema-export.js.map +1 -0
  47. package/dist/schemas/run-result-schema.d.ts +850 -0
  48. package/dist/schemas/run-result-schema.d.ts.map +1 -0
  49. package/dist/schemas/run-result-schema.js +67 -0
  50. package/dist/schemas/run-result-schema.js.map +1 -0
  51. package/dist/schemas/watch-pr-schema.d.ts +431 -33
  52. package/dist/schemas/watch-pr-schema.d.ts.map +1 -1
  53. package/dist/schemas/watch-pr-schema.js +2 -2
  54. package/dist/schemas/watch-pr-schema.js.map +1 -1
  55. package/dist/scripts/generate-run-result-schema.d.ts +10 -0
  56. package/dist/scripts/generate-run-result-schema.d.ts.map +1 -0
  57. package/dist/scripts/generate-run-result-schema.js +20 -0
  58. package/dist/scripts/generate-run-result-schema.js.map +1 -0
  59. package/dist/scripts/generate-watch-pr-schema.js +3 -3
  60. package/dist/scripts/generate-watch-pr-schema.js.map +1 -1
  61. package/dist/services/ci-provider.d.ts +21 -6
  62. package/dist/services/ci-provider.d.ts.map +1 -1
  63. package/dist/services/ci-providers/github-actions.d.ts +21 -0
  64. package/dist/services/ci-providers/github-actions.d.ts.map +1 -1
  65. package/dist/services/ci-providers/github-actions.js +65 -49
  66. package/dist/services/ci-providers/github-actions.js.map +1 -1
  67. package/dist/utils/check-validation.d.ts.map +1 -1
  68. package/dist/utils/check-validation.js +9 -5
  69. package/dist/utils/check-validation.js.map +1 -1
  70. package/dist/utils/config-error-reporter.js +9 -7
  71. package/dist/utils/config-error-reporter.js.map +1 -1
  72. package/dist/utils/config-loader.js +5 -5
  73. package/dist/utils/config-loader.js.map +1 -1
  74. package/dist/utils/git-detection.d.ts +0 -22
  75. package/dist/utils/git-detection.d.ts.map +1 -1
  76. package/dist/utils/git-detection.js +64 -56
  77. package/dist/utils/git-detection.js.map +1 -1
  78. package/dist/utils/pid-lock.js +7 -7
  79. package/dist/utils/pid-lock.js.map +1 -1
  80. package/dist/utils/project-id.d.ts.map +1 -1
  81. package/dist/utils/project-id.js +8 -6
  82. package/dist/utils/project-id.js.map +1 -1
  83. package/dist/utils/runner-adapter.js +4 -3
  84. package/dist/utils/runner-adapter.js.map +1 -1
  85. package/dist/utils/setup-checks/hooks-check.js +3 -3
  86. package/dist/utils/setup-checks/hooks-check.js.map +1 -1
  87. package/dist/utils/setup-checks/workflow-check.js +3 -3
  88. package/dist/utils/setup-checks/workflow-check.js.map +1 -1
  89. package/dist/utils/temp-files.d.ts +67 -0
  90. package/dist/utils/temp-files.d.ts.map +1 -0
  91. package/dist/utils/temp-files.js +202 -0
  92. package/dist/utils/temp-files.js.map +1 -0
  93. package/dist/utils/template-discovery.d.ts.map +1 -1
  94. package/dist/utils/template-discovery.js +5 -4
  95. package/dist/utils/template-discovery.js.map +1 -1
  96. package/dist/utils/validate-workflow.d.ts.map +1 -1
  97. package/dist/utils/validate-workflow.js +169 -150
  98. package/dist/utils/validate-workflow.js.map +1 -1
  99. package/dist/vibe-validate +131 -0
  100. package/package.json +11 -9
  101. package/run-result.schema.json +186 -0
  102. 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 '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,39 +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
74
- child.stdout?.on('data', (data) => {
75
- stdout += data.toString();
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
330
+ child.stdout.on('data', (data) => {
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
345
  // Capture stderr
78
- child.stderr?.on('data', (data) => {
79
- stderr += data.toString();
346
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- spawnCommand always pipes stdout/stderr
347
+ child.stderr.on('data', (data) => {
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
- child.on('close', (exitCode) => {
83
- const actualExitCode = exitCode ?? 1;
363
+ child.on('close', (exitCode = 1) => {
364
+ const durationSecs = (Date.now() - startTime) / 1000;
84
365
  // CRITICAL: Check ONLY stdout for YAML (not stderr)
85
366
  // This prevents stderr warnings from corrupting nested YAML output
86
- if (isYamlOutput(stdout)) {
87
- const { yaml, preamble } = extractYamlAndPreamble(stdout);
88
- const mergedResult = mergeNestedYaml(commandString, yaml, actualExitCode);
367
+ const yamlResult = extractYamlWithPreamble(stdout);
368
+ if (yamlResult) {
369
+ const mergedResult = mergeNestedYaml(commandString, yamlResult.yaml, exitCode, durationSecs);
89
370
  // Include preamble and stderr for context
90
371
  const contextOutput = {
91
- preamble: preamble.trim(),
372
+ preamble: yamlResult.preamble,
92
373
  stderr: stderr.trim(),
93
374
  };
94
375
  resolve({ result: mergedResult, context: contextOutput });
@@ -96,20 +377,101 @@ async function executeAndExtract(commandString) {
96
377
  }
97
378
  // For extraction, combine both streams (stderr has useful error context)
98
379
  const combinedOutput = stdout + stderr;
99
- // Infer step name from command for smart extraction
100
- const stepName = inferStepName(commandString);
101
- // Extract errors using smart extractor
102
- const extraction = autoDetectAndExtract(stepName, combinedOutput);
103
- const result = {
104
- command: commandString,
105
- exitCode: actualExitCode,
106
- extraction,
107
- // Include truncated raw output for reference (if needed for debugging)
108
- rawOutput: combinedOutput.length > 1000
109
- ? combinedOutput.substring(0, 1000) + '... (truncated)'
110
- : combinedOutput,
111
- };
112
- 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
+ });
113
475
  });
114
476
  // Handle spawn errors (e.g., command not found)
115
477
  child.on('error', (error) => {
@@ -126,188 +488,146 @@ async function executeAndExtract(commandString) {
126
488
  * - "pnpm lint" → "lint"
127
489
  * - "pnpm --filter @pkg test" → "test"
128
490
  */
129
- function inferStepName(commandString) {
130
- const lower = commandString.toLowerCase();
131
- // TypeScript/tsc
132
- if (lower.includes('tsc') || lower.includes('typecheck')) {
133
- return 'typecheck';
134
- }
135
- // Linting
136
- if (lower.includes('eslint') || lower.includes('lint')) {
137
- return 'lint';
138
- }
139
- // Testing (vitest, jest, mocha, etc.)
140
- if (lower.includes('vitest') || lower.includes('jest') ||
141
- lower.includes('mocha') || lower.includes('test') ||
142
- lower.includes('jasmine')) {
143
- return 'test';
144
- }
145
- // OpenAPI
146
- if (lower.includes('openapi')) {
147
- return 'openapi';
148
- }
149
- // Generic fallback
150
- return 'run';
151
- }
152
- /**
153
- * Check if output contains YAML format (may have preamble before ---)
154
- *
155
- * IMPORTANT: This function detects YAML anywhere in the output, not just at the start.
156
- * This allows us to handle package manager preambles (pnpm, npm, yarn) that appear
157
- * before the actual YAML content.
158
- *
159
- * Example with preamble:
160
- * ```
161
- * > vibe-validate@0.13.0 validate
162
- * > node packages/cli/dist/bin.js validate
163
- *
164
- * ---
165
- * command: "npm test"
166
- * exitCode: 0
167
- * ```
168
- *
169
- * The preamble will be extracted and routed to stderr, keeping stdout clean.
170
- */
171
- function isYamlOutput(output) {
172
- const trimmed = output.trim();
173
- // Check if starts with --- (no preamble)
174
- if (trimmed.startsWith('---\n') || trimmed.startsWith('---\r\n')) {
175
- return true;
176
- }
177
- // Check if contains --- with newlines (has preamble)
178
- return output.includes('\n---\n') || output.includes('\n---\r\n');
179
- }
180
- /**
181
- * Extract YAML content and separate preamble/postamble
182
- *
183
- * STREAM ROUTING STRATEGY:
184
- * - stdout (returned 'yaml'): Clean YAML for piping and LLM consumption
185
- * - stderr (returned 'preamble'): Package manager noise, preserved for human context
186
- *
187
- * This separation follows Unix philosophy: stdout = data, stderr = human messages.
188
- *
189
- * Example input:
190
- * ```
191
- * > package@1.0.0 test ← preamble (goes to stderr)
192
- * > vitest run ← preamble (goes to stderr)
193
- *
194
- * --- ← yaml (goes to stdout)
195
- * command: "vitest run"
196
- * exitCode: 0
197
- * extraction: {...}
198
- * ```
199
- *
200
- * Benefits:
201
- * 1. `run "pnpm test" > file.yaml` writes pure YAML
202
- * 2. `run "pnpm test" 2>/dev/null` suppresses noise
203
- * 3. Terminal shows both streams (full context)
204
- *
205
- * @param stdout - Raw stdout from the executed command
206
- * @returns Object with separated yaml, preamble, and postamble
207
- */
208
- function extractYamlAndPreamble(stdout) {
209
- // Check if it starts with --- (no preamble)
210
- const trimmed = stdout.trim();
211
- if (trimmed.startsWith('---\n') || trimmed.startsWith('---\r\n')) {
212
- return { yaml: trimmed, preamble: '', postamble: '' };
213
- }
214
- // Find first YAML separator with newline before it
215
- const patterns = [
216
- { pattern: '\n---\n', offset: 1 },
217
- { pattern: '\n---\r\n', offset: 1 },
218
- ];
219
- let earliestIndex = -1;
220
- let selectedOffset = 0;
221
- for (const { pattern, offset } of patterns) {
222
- const idx = stdout.indexOf(pattern);
223
- if (idx !== -1 && (earliestIndex === -1 || idx < earliestIndex)) {
224
- earliestIndex = idx;
225
- selectedOffset = offset;
226
- }
227
- }
228
- if (earliestIndex === -1) {
229
- // No YAML found
230
- return { yaml: '', preamble: stdout, postamble: '' };
231
- }
232
- // Extract preamble (everything before ---)
233
- const preamble = stdout.substring(0, earliestIndex).trim();
234
- // Extract YAML content (from --- onward)
235
- const yamlContent = stdout.substring(earliestIndex + selectedOffset).trim();
236
- return { yaml: yamlContent, preamble, postamble: '' };
237
- }
238
491
  /**
239
492
  * Merge nested YAML output with outer run metadata
240
493
  *
241
494
  * When vibe-validate run wraps another vibe-validate command (run or validate),
242
495
  * we merge the inner YAML with outer metadata instead of double-extracting.
243
496
  */
244
- function mergeNestedYaml(outerCommand, yamlOutput, outerExitCode) {
497
+ function mergeNestedYaml(outerCommand, yamlOutput, outerExitCode, outerDurationSecs) {
245
498
  try {
246
- // Parse the inner YAML
499
+ // Always parse the raw YAML first to preserve all fields
247
500
  const innerResult = yaml.parse(yamlOutput);
248
- // Extract the innermost command for suggestedDirectCommand
249
- const innermostCommand = extractInnermostCommand(innerResult);
250
- // Merge: preserve ALL inner fields, add outer metadata
251
- const mergedResult = {
252
- ...innerResult, // Spread ALL inner fields (errors, phases, tree_hash, etc.)
253
- command: outerCommand, // Override with outer command
254
- exitCode: outerExitCode, // Use outer exit code (should match inner)
255
- 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
256
532
  };
257
- return mergedResult;
258
533
  }
259
534
  catch (error) {
260
- // If YAML parsing fails, treat as regular output
535
+ // YAML parsing completely failed - treat as regular output
261
536
  console.error('Warning: Failed to parse nested YAML output:', error);
262
- const stepName = inferStepName(outerCommand);
263
- 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;
264
540
  return {
265
541
  command: outerCommand,
266
542
  exitCode: outerExitCode,
267
- extraction,
268
- rawOutput: yamlOutput.substring(0, 1000),
543
+ durationSecs: outerDurationSecs,
544
+ timestamp: new Date().toISOString(),
545
+ treeHash: 'unknown',
546
+ ...(extraction ? { extraction } : {}), // Only include extraction if needed
269
547
  };
270
548
  }
271
549
  }
272
- /**
273
- * Extract the innermost command from nested run results
274
- *
275
- * Examples:
276
- * - { command: "npm test" } → "npm test"
277
- * - { command: "...", suggestedDirectCommand: "npm test" } → "npm test"
278
- * - { command: "vibe-validate validate" } → "vibe-validate validate"
279
- */
280
- function extractInnermostCommand(result) {
281
- // If already has suggestedDirectCommand, use it (handles 3+ levels)
282
- if (result.suggestedDirectCommand && typeof result.suggestedDirectCommand === 'string') {
283
- return result.suggestedDirectCommand;
284
- }
285
- // Otherwise, use the command from the inner result
286
- if (result.command && typeof result.command === 'string') {
287
- return result.command;
288
- }
289
- return 'unknown';
290
- }
291
550
  /**
292
551
  * Show verbose help with detailed documentation
293
552
  */
294
553
  export function showRunVerboseHelp() {
295
554
  console.log(`# run Command Reference
296
555
 
297
- > Run a command and extract LLM-friendly errors
556
+ > Run a command and extract LLM-friendly errors (with smart caching)
298
557
 
299
558
  ## Overview
300
559
 
301
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.
302
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
+
303
564
  ## How It Works
304
565
 
305
- 1. **Executes command** in a shell subprocess
306
- 2. **Captures output** (stdout + stderr)
307
- 3. **Auto-detects format** (vitest, jest, tsc, eslint, etc.)
308
- 4. **Extracts errors** using appropriate extractor
309
- 5. **Outputs YAML** with structured error information
310
- 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
311
631
 
312
632
  ## Use Cases
313
633
 
@@ -317,29 +637,33 @@ Instead of parsing verbose test output:
317
637
  # Verbose (wastes context window)
318
638
  npx vitest packages/extractors/test/vitest-extractor.test.ts
319
639
 
320
- # Concise (LLM-friendly)
321
- 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
322
642
  \`\`\`
323
643
 
324
644
  ### Debugging Specific Tests
325
645
  \`\`\`bash
326
- # Run single test file with extraction
327
- 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'
328
648
 
329
649
  # Run package tests with extraction
330
- 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"
331
654
  \`\`\`
332
655
 
333
656
  ### Type Checking
334
657
  \`\`\`bash
335
658
  # Extract TypeScript errors
336
- vibe-validate run "npx tsc --noEmit"
659
+ vibe-validate run npx tsc --noEmit
337
660
  \`\`\`
338
661
 
339
662
  ### Linting
340
663
  \`\`\`bash
341
- # Extract ESLint errors
342
- 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/
343
667
  \`\`\`
344
668
 
345
669
  ## Output Format
@@ -356,7 +680,7 @@ extraction:
356
680
  message: "expected 5 to equal 3"
357
681
  summary: "1 test failed"
358
682
  guidance: "Review test assertions and expected values"
359
- cleanOutput: |
683
+ errorSummary: |
360
684
  test.ts:42 - expected 5 to equal 3
361
685
  rawOutput: "... (truncated)"
362
686
  \`\`\`
@@ -404,28 +728,27 @@ The \`run\` command automatically detects and handles package manager preambles:
404
728
 
405
729
  This means you can safely use:
406
730
  \`\`\`bash
407
- vibe-validate run "pnpm validate --yaml" # Works!
408
- vibe-validate run "npm test" # Works!
409
- 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!
410
734
  \`\`\`
411
735
 
412
736
  The YAML output on stdout remains clean and parseable, while the preamble is preserved on stderr for debugging.
413
737
 
414
738
  ## Nested Run Detection
415
739
 
416
- 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:
417
741
 
418
742
  \`\`\`bash
419
743
  # 2-level nesting
420
744
  $ vibe-validate run "vibe-validate run 'npm test'"
421
745
  ---
422
- command: vibe-validate run "npm test"
746
+ command: npm test # Automatically unwrapped!
423
747
  exitCode: 0
424
748
  extraction: {...}
425
- suggestedDirectCommand: npm test # ← Unwrapped!
426
749
  \`\`\`
427
750
 
428
- 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.
429
752
 
430
753
  ## Exit Codes
431
754
 
@@ -435,29 +758,40 @@ The \`run\` command passes through the exit code from the executed command:
435
758
 
436
759
  ## Examples
437
760
 
438
- ### Run Single Test File
761
+ ### Python Testing
439
762
  \`\`\`bash
440
- 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
441
766
  \`\`\`
442
767
 
443
- ### Run Specific Test Case
768
+ ### Rust Testing
444
769
  \`\`\`bash
445
- 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
446
773
  \`\`\`
447
774
 
448
- ### Run Package Tests
775
+ ### Go Testing
449
776
  \`\`\`bash
450
- 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 ./...
451
780
  \`\`\`
452
781
 
453
- ### Type Check
782
+ ### Ruby Testing
454
783
  \`\`\`bash
455
- 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
456
787
  \`\`\`
457
788
 
458
- ### Lint
789
+ ### Node.js/TypeScript
459
790
  \`\`\`bash
460
- 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
461
795
  \`\`\`
462
796
 
463
797
  ## Supported Extractors
@@ -506,4 +840,73 @@ extraction:
506
840
  **Result**: Same information, 90% smaller!
507
841
  `);
508
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
+ }
509
912
  //# sourceMappingURL=run.js.map