replay-self-healing-cli 0.1.0

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.
package/src/cli.mjs ADDED
@@ -0,0 +1,1680 @@
1
+ import { spawn } from 'node:child_process';
2
+ import crypto from 'node:crypto';
3
+ import fs, { constants as fsConstants } from 'node:fs';
4
+ import fsp from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import process from 'node:process';
7
+
8
+ const SCHEMA_VERSION = '1.0.0';
9
+ const KNOWN_COMMANDS = new Set(['capture', 'heal', 'help', 'report', 'validate']);
10
+ const DEFAULT_EVENT_ALIASES = {
11
+ completed: 'run_completed',
12
+ failed: 'run_failed',
13
+ message: 'assistant_message',
14
+ tool_end: 'tool_completed',
15
+ tool_finish: 'tool_completed',
16
+ tool_start: 'tool_started'
17
+ };
18
+ const RUNNER_DISCOVERY_CANDIDATES = [
19
+ '.replay/runner.mjs',
20
+ '.replay/runner.js',
21
+ '.replay/runner.cjs',
22
+ '.replay/runner'
23
+ ];
24
+
25
+ class ReplayCliError extends Error {
26
+ constructor({
27
+ code,
28
+ details,
29
+ example,
30
+ expected,
31
+ howToFix,
32
+ message,
33
+ path: errorPath,
34
+ received
35
+ }) {
36
+ super(message);
37
+ this.name = 'ReplayCliError';
38
+ this.code = code;
39
+ this.details = details;
40
+ this.example = example;
41
+ this.expected = expected;
42
+ this.howToFix = howToFix;
43
+ this.path = errorPath;
44
+ this.received = received;
45
+ }
46
+ }
47
+
48
+ function isObject(value) {
49
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
50
+ }
51
+
52
+ function safeJsonParse(content, contextCode = 'E_INVALID_JSON') {
53
+ try {
54
+ return JSON.parse(content);
55
+ } catch (error) {
56
+ throw new ReplayCliError({
57
+ code: contextCode,
58
+ message: 'Invalid JSON content.',
59
+ details: error?.message,
60
+ received: content.slice(0, 500),
61
+ howToFix: 'Fix the JSON syntax and try again.'
62
+ });
63
+ }
64
+ }
65
+
66
+ function compactDate() {
67
+ return new Date().toISOString().slice(0, 10);
68
+ }
69
+
70
+ function isoNow() {
71
+ return new Date().toISOString();
72
+ }
73
+
74
+ function randomShortId() {
75
+ return crypto.randomBytes(4).toString('hex');
76
+ }
77
+
78
+ function sanitizePrompt(prompt) {
79
+ return String(prompt)
80
+ .toLowerCase()
81
+ .replace(/[^a-z0-9]+/g, '-')
82
+ .replace(/^-+|-+$/g, '')
83
+ .slice(0, 40);
84
+ }
85
+
86
+ function outputJson(data) {
87
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
88
+ }
89
+
90
+ function outputError(error) {
91
+ if (error instanceof ReplayCliError) {
92
+ process.stderr.write(
93
+ `${JSON.stringify(
94
+ {
95
+ error: {
96
+ code: error.code,
97
+ details: error.details,
98
+ example: error.example,
99
+ expected: error.expected,
100
+ howToFix: error.howToFix,
101
+ message: error.message,
102
+ path: error.path,
103
+ received: error.received
104
+ }
105
+ },
106
+ null,
107
+ 2
108
+ )}\n`
109
+ );
110
+
111
+ return;
112
+ }
113
+
114
+ process.stderr.write(
115
+ `${JSON.stringify(
116
+ {
117
+ error: {
118
+ code: 'E_UNHANDLED',
119
+ message: error?.message || 'Unexpected failure',
120
+ details: error?.stack
121
+ }
122
+ },
123
+ null,
124
+ 2
125
+ )}\n`
126
+ );
127
+ }
128
+
129
+ function parseArgs(argv) {
130
+ const args = [...argv];
131
+ let command = 'capture';
132
+
133
+ if (args[0] && KNOWN_COMMANDS.has(args[0])) {
134
+ command = args.shift();
135
+ } else if (args[0] === '-h' || args[0] === '--help') {
136
+ command = 'help';
137
+ args.shift();
138
+ }
139
+
140
+ const options = {
141
+ heal: true,
142
+ json: true,
143
+ timeoutMs: 120000
144
+ };
145
+ const positionals = [];
146
+
147
+ for (let index = 0; index < args.length; index++) {
148
+ const token = args[index];
149
+
150
+ if (!token.startsWith('--')) {
151
+ positionals.push(token);
152
+ continue;
153
+ }
154
+
155
+ if (token === '--help') {
156
+ options.help = true;
157
+ continue;
158
+ }
159
+
160
+ if (token === '--no-heal') {
161
+ options.heal = false;
162
+ continue;
163
+ }
164
+
165
+ if (token === '--json') {
166
+ options.json = true;
167
+ continue;
168
+ }
169
+
170
+ if (token === '--runner') {
171
+ options.runner = args[++index];
172
+ continue;
173
+ }
174
+
175
+ if (token === '--out') {
176
+ options.out = args[++index];
177
+ continue;
178
+ }
179
+
180
+ if (token === '--context') {
181
+ options.context = args[++index];
182
+ continue;
183
+ }
184
+
185
+ if (token === '--context-file') {
186
+ options.contextFile = args[++index];
187
+ continue;
188
+ }
189
+
190
+ if (token === '--id') {
191
+ options.id = args[++index];
192
+ continue;
193
+ }
194
+
195
+ if (token === '--in') {
196
+ options.in = args[++index];
197
+ continue;
198
+ }
199
+
200
+ if (token === '--timeout-ms') {
201
+ options.timeoutMs = Number(args[++index]);
202
+ continue;
203
+ }
204
+
205
+ throw new ReplayCliError({
206
+ code: 'E_UNKNOWN_FLAG',
207
+ message: `Unknown flag: ${token}`,
208
+ howToFix: 'Use `replay help` to see supported flags.'
209
+ });
210
+ }
211
+
212
+ if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
213
+ throw new ReplayCliError({
214
+ code: 'E_TIMEOUT_INVALID',
215
+ message: '--timeout-ms must be a positive integer.',
216
+ path: '--timeout-ms',
217
+ expected: 'positive number',
218
+ received: options.timeoutMs
219
+ });
220
+ }
221
+
222
+ return { command, options, positionals };
223
+ }
224
+
225
+ function printHelp() {
226
+ outputJson({
227
+ commands: {
228
+ capture: 'Capture one prompt into raw + healed replay artifacts (default command).',
229
+ heal: 'Re-heal replay artifacts from a file or directory.',
230
+ report: 'Generate summary metrics from replay artifact files.',
231
+ validate: 'Validate replay artifact files.'
232
+ },
233
+ examples: [
234
+ 'replay "What are my top holdings?"',
235
+ 'echo "What changed today?" | replay',
236
+ 'replay capture "Summarize my risk" --timeout-ms 90000',
237
+ 'replay validate --in .replay/artifacts',
238
+ 'replay heal --in .replay/artifacts --out .replay/healed',
239
+ 'replay report --in .replay/artifacts'
240
+ ],
241
+ notes: [
242
+ 'No --runner required by default. Runner discovery order: REPLAY_RUNNER env, .replay/runner.mjs, .replay/runner.js, .replay/runner.cjs, .replay/runner, package.json script replay:runner.',
243
+ 'No --out required by default. Output defaults to .replay/artifacts/YYYY-MM-DD.',
244
+ 'This utility has zero dependencies and uses only Node.js built-in modules.'
245
+ ]
246
+ });
247
+ }
248
+
249
+ async function ensureDir(dirPath) {
250
+ await fsp.mkdir(dirPath, { recursive: true });
251
+ }
252
+
253
+ async function readPromptFromStdin() {
254
+ if (process.stdin.isTTY) {
255
+ return '';
256
+ }
257
+
258
+ const chunks = [];
259
+
260
+ for await (const chunk of process.stdin) {
261
+ chunks.push(chunk);
262
+ }
263
+
264
+ return Buffer.concat(chunks).toString('utf-8').trim();
265
+ }
266
+
267
+ async function resolvePrompt(positionals) {
268
+ const directPrompt = positionals.join(' ').trim();
269
+
270
+ if (directPrompt) {
271
+ return directPrompt;
272
+ }
273
+
274
+ const stdinPrompt = await readPromptFromStdin();
275
+
276
+ if (stdinPrompt) {
277
+ return stdinPrompt;
278
+ }
279
+
280
+ throw new ReplayCliError({
281
+ code: 'E_PROMPT_REQUIRED',
282
+ message: 'No prompt was provided.',
283
+ path: '$.prompt',
284
+ expected: 'non-empty string',
285
+ received: '',
286
+ howToFix: 'Pass a prompt as an argument or pipe one via stdin.',
287
+ example: 'replay "What are my top 5 holdings?"'
288
+ });
289
+ }
290
+
291
+ async function resolveContext({ context, contextFile }) {
292
+ if (context && contextFile) {
293
+ throw new ReplayCliError({
294
+ code: 'E_CONTEXT_CONFLICT',
295
+ message: 'Use only one of --context or --context-file.',
296
+ howToFix: 'Provide inline JSON with --context OR a file path with --context-file.'
297
+ });
298
+ }
299
+
300
+ if (context) {
301
+ const parsed = safeJsonParse(context, 'E_CONTEXT_JSON_INVALID');
302
+
303
+ if (!isObject(parsed)) {
304
+ throw new ReplayCliError({
305
+ code: 'E_CONTEXT_SHAPE_INVALID',
306
+ message: 'Context must be a JSON object.',
307
+ expected: 'object',
308
+ received: typeof parsed,
309
+ path: '--context'
310
+ });
311
+ }
312
+
313
+ return parsed;
314
+ }
315
+
316
+ if (contextFile) {
317
+ const absolutePath = path.resolve(process.cwd(), contextFile);
318
+ let content;
319
+
320
+ try {
321
+ content = await fsp.readFile(absolutePath, 'utf-8');
322
+ } catch {
323
+ throw new ReplayCliError({
324
+ code: 'E_CONTEXT_FILE_NOT_FOUND',
325
+ message: `Context file does not exist: ${absolutePath}`,
326
+ path: '--context-file',
327
+ howToFix: 'Provide an existing JSON file path.'
328
+ });
329
+ }
330
+
331
+ const parsed = safeJsonParse(content, 'E_CONTEXT_JSON_INVALID');
332
+
333
+ if (!isObject(parsed)) {
334
+ throw new ReplayCliError({
335
+ code: 'E_CONTEXT_SHAPE_INVALID',
336
+ message: 'Context file content must be a JSON object.',
337
+ expected: 'object',
338
+ received: typeof parsed,
339
+ path: '--context-file'
340
+ });
341
+ }
342
+
343
+ return parsed;
344
+ }
345
+
346
+ return {};
347
+ }
348
+
349
+ async function discoverRunner(cwd, explicitRunner) {
350
+ const sources = [];
351
+
352
+ if (explicitRunner) {
353
+ const runner = await toRunnerSpec(explicitRunner, cwd, 'flag');
354
+ return { runner, sources };
355
+ }
356
+
357
+ if (process.env.REPLAY_RUNNER) {
358
+ const runner = await toRunnerSpec(process.env.REPLAY_RUNNER, cwd, 'env');
359
+ return { runner, sources };
360
+ }
361
+
362
+ for (const candidate of RUNNER_DISCOVERY_CANDIDATES) {
363
+ const absolute = path.resolve(cwd, candidate);
364
+ sources.push(absolute);
365
+
366
+ if (await exists(absolute)) {
367
+ const runner = await toRunnerSpec(absolute, cwd, 'file');
368
+ return { runner, sources };
369
+ }
370
+ }
371
+
372
+ const packageJsonPath = path.resolve(cwd, 'package.json');
373
+ if (await exists(packageJsonPath)) {
374
+ const packageJson = safeJsonParse(
375
+ await fsp.readFile(packageJsonPath, 'utf-8'),
376
+ 'E_PACKAGE_JSON_INVALID'
377
+ );
378
+
379
+ if (isObject(packageJson?.scripts) && packageJson.scripts['replay:runner']) {
380
+ const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
381
+
382
+ return {
383
+ runner: {
384
+ args: ['run', '--silent', 'replay:runner', '--'],
385
+ command: npmCommand,
386
+ display: 'npm run --silent replay:runner --',
387
+ source: 'package.json#scripts.replay:runner',
388
+ type: 'npm-script'
389
+ },
390
+ sources
391
+ };
392
+ }
393
+ }
394
+
395
+ throw new ReplayCliError({
396
+ code: 'E_RUNNER_NOT_FOUND',
397
+ message: 'No runner was discovered.',
398
+ details: {
399
+ checked: sources,
400
+ envVar: 'REPLAY_RUNNER',
401
+ packageScript: 'replay:runner'
402
+ },
403
+ howToFix:
404
+ 'Create .replay/runner.mjs, set REPLAY_RUNNER, or add package.json script replay:runner.',
405
+ example: 'mkdir -p .replay && cp examples/runner.mjs .replay/runner.mjs'
406
+ });
407
+ }
408
+
409
+ async function toRunnerSpec(value, cwd, source) {
410
+ const resolved = path.isAbsolute(value) ? value : path.resolve(cwd, value);
411
+
412
+ if (!(await exists(resolved))) {
413
+ throw new ReplayCliError({
414
+ code: 'E_RUNNER_PATH_NOT_FOUND',
415
+ message: `Runner path does not exist: ${resolved}`,
416
+ path: '$.runner',
417
+ howToFix: 'Point --runner or REPLAY_RUNNER to an existing file.'
418
+ });
419
+ }
420
+
421
+ const ext = path.extname(resolved);
422
+
423
+ if (ext === '.ts') {
424
+ throw new ReplayCliError({
425
+ code: 'E_RUNNER_UNSUPPORTED_EXTENSION',
426
+ message: 'TypeScript runner files are not supported without a runtime wrapper.',
427
+ expected: '.mjs, .js, .cjs, or executable file',
428
+ received: ext,
429
+ howToFix:
430
+ 'Compile the runner to JavaScript, use .mjs, or wire a replay:runner package script.'
431
+ });
432
+ }
433
+
434
+ if (['.mjs', '.js', '.cjs'].includes(ext)) {
435
+ return {
436
+ args: [resolved],
437
+ command: process.execPath,
438
+ display: `${process.execPath} ${resolved}`,
439
+ source,
440
+ type: 'node-file'
441
+ };
442
+ }
443
+
444
+ try {
445
+ await fsp.access(resolved, fsConstants.X_OK);
446
+ } catch {
447
+ throw new ReplayCliError({
448
+ code: 'E_RUNNER_NOT_EXECUTABLE',
449
+ message: `Runner is not executable: ${resolved}`,
450
+ path: '$.runner',
451
+ howToFix:
452
+ 'Make the file executable (`chmod +x`) or use a .mjs/.js/.cjs runner file.'
453
+ });
454
+ }
455
+
456
+ return {
457
+ args: [],
458
+ command: resolved,
459
+ display: resolved,
460
+ source,
461
+ type: 'executable'
462
+ };
463
+ }
464
+
465
+ async function exists(targetPath) {
466
+ try {
467
+ await fsp.access(targetPath, fsConstants.F_OK);
468
+ return true;
469
+ } catch {
470
+ return false;
471
+ }
472
+ }
473
+
474
+ function runRunner({ payload, runner, timeoutMs }) {
475
+ return new Promise((resolve, reject) => {
476
+ const startedAt = Date.now();
477
+ const child = spawn(runner.command, runner.args, {
478
+ cwd: process.cwd(),
479
+ env: process.env,
480
+ stdio: ['pipe', 'pipe', 'pipe']
481
+ });
482
+
483
+ let stdout = '';
484
+ let stderr = '';
485
+ let timedOut = false;
486
+
487
+ const timeout = setTimeout(() => {
488
+ timedOut = true;
489
+ child.kill('SIGTERM');
490
+
491
+ setTimeout(() => {
492
+ if (!child.killed) {
493
+ child.kill('SIGKILL');
494
+ }
495
+ }, 3000);
496
+ }, timeoutMs);
497
+
498
+ child.stdout.on('data', (chunk) => {
499
+ stdout += chunk.toString('utf-8');
500
+ });
501
+
502
+ child.stderr.on('data', (chunk) => {
503
+ stderr += chunk.toString('utf-8');
504
+ });
505
+
506
+ child.on('error', (error) => {
507
+ clearTimeout(timeout);
508
+ reject(
509
+ new ReplayCliError({
510
+ code: 'E_RUNNER_START_FAILED',
511
+ message: `Runner process failed to start: ${runner.display}`,
512
+ details: error?.message,
513
+ howToFix: 'Verify the runner path and execution permissions.'
514
+ })
515
+ );
516
+ });
517
+
518
+ child.on('close', (exitCode, signal) => {
519
+ clearTimeout(timeout);
520
+ resolve({
521
+ durationMs: Date.now() - startedAt,
522
+ exitCode,
523
+ signal,
524
+ stderr,
525
+ stdout,
526
+ timedOut
527
+ });
528
+ });
529
+
530
+ child.stdin.write(`${JSON.stringify(payload)}\n`);
531
+ child.stdin.end();
532
+ });
533
+ }
534
+
535
+ function recoverJsonObject(text) {
536
+ const value = text.trim();
537
+
538
+ if (!value) {
539
+ return undefined;
540
+ }
541
+
542
+ const candidates = [];
543
+ let depth = 0;
544
+ let inString = false;
545
+ let escaped = false;
546
+ let start = -1;
547
+
548
+ for (let index = 0; index < value.length; index++) {
549
+ const char = value[index];
550
+
551
+ if (escaped) {
552
+ escaped = false;
553
+ continue;
554
+ }
555
+
556
+ if (char === '\\') {
557
+ escaped = true;
558
+ continue;
559
+ }
560
+
561
+ if (char === '"') {
562
+ inString = !inString;
563
+ continue;
564
+ }
565
+
566
+ if (inString) {
567
+ continue;
568
+ }
569
+
570
+ if (char === '{') {
571
+ if (depth === 0) {
572
+ start = index;
573
+ }
574
+
575
+ depth += 1;
576
+ continue;
577
+ }
578
+
579
+ if (char === '}') {
580
+ depth -= 1;
581
+
582
+ if (depth === 0 && start >= 0) {
583
+ candidates.push(value.slice(start, index + 1));
584
+ start = -1;
585
+ }
586
+ }
587
+ }
588
+
589
+ return candidates[candidates.length - 1];
590
+ }
591
+
592
+ function parseRunnerOutput(rawStdout) {
593
+ const trimmed = rawStdout.trim();
594
+
595
+ if (!trimmed) {
596
+ throw new ReplayCliError({
597
+ code: 'E_RUNNER_EMPTY_STDOUT',
598
+ message: 'Runner wrote no stdout output.',
599
+ howToFix: 'Runner must return one JSON object on stdout.'
600
+ });
601
+ }
602
+
603
+ try {
604
+ return JSON.parse(trimmed);
605
+ } catch {
606
+ const recovered = recoverJsonObject(trimmed);
607
+
608
+ if (!recovered) {
609
+ throw new ReplayCliError({
610
+ code: 'E_RUNNER_INVALID_JSON',
611
+ message: 'Runner stdout is not valid JSON.',
612
+ received: trimmed.slice(0, 1000),
613
+ howToFix: 'Print exactly one JSON object to stdout.',
614
+ example:
615
+ '{"status":"ok","response":"...","events":[{"type":"run_started"}]}'
616
+ });
617
+ }
618
+
619
+ try {
620
+ return JSON.parse(recovered);
621
+ } catch {
622
+ throw new ReplayCliError({
623
+ code: 'E_RUNNER_INVALID_JSON',
624
+ message: 'Runner stdout contains JSON-like text but parsing failed.',
625
+ received: recovered.slice(0, 1000),
626
+ howToFix: 'Ensure the JSON object is syntactically valid.'
627
+ });
628
+ }
629
+ }
630
+ }
631
+
632
+ function ensureArray(value) {
633
+ return Array.isArray(value) ? value : [];
634
+ }
635
+
636
+ function extractResponseFromEvents(events) {
637
+ const assistantEvent = [...events]
638
+ .reverse()
639
+ .find((event) => event?.type === 'assistant_message');
640
+
641
+ const payload = assistantEvent?.payload;
642
+
643
+ if (!isObject(payload)) {
644
+ return undefined;
645
+ }
646
+
647
+ if (typeof payload.response === 'string') {
648
+ return payload.response;
649
+ }
650
+
651
+ if (typeof payload.message === 'string') {
652
+ return payload.message;
653
+ }
654
+
655
+ if (typeof payload.text === 'string') {
656
+ return payload.text;
657
+ }
658
+
659
+ return undefined;
660
+ }
661
+
662
+ function normalizeRunnerResult(runnerResult, runnerStderr) {
663
+ if (!isObject(runnerResult)) {
664
+ throw new ReplayCliError({
665
+ code: 'E_RUNNER_OUTPUT_SHAPE',
666
+ message: 'Runner output must be a JSON object.',
667
+ expected: 'object',
668
+ received: typeof runnerResult
669
+ });
670
+ }
671
+
672
+ const normalizationIssues = [];
673
+
674
+ let status = runnerResult.status;
675
+
676
+ if (status !== 'ok' && status !== 'error') {
677
+ if (isObject(runnerResult.error)) {
678
+ status = 'error';
679
+ normalizationIssues.push({
680
+ code: 'W_STATUS_INFERRED_FROM_ERROR',
681
+ message: 'Status was missing/invalid and was inferred as error.'
682
+ });
683
+ } else if (typeof runnerResult.response === 'string') {
684
+ status = 'ok';
685
+ normalizationIssues.push({
686
+ code: 'W_STATUS_INFERRED_FROM_RESPONSE',
687
+ message: 'Status was missing/invalid and was inferred as ok.'
688
+ });
689
+ } else {
690
+ throw new ReplayCliError({
691
+ code: 'E_RUNNER_STATUS_INVALID',
692
+ message: 'Runner output field `status` must be `ok` or `error`.',
693
+ path: '$.status',
694
+ expected: '"ok" | "error"',
695
+ received: runnerResult.status,
696
+ howToFix:
697
+ 'Return `status: "ok"` for success or `status: "error"` for failures.'
698
+ });
699
+ }
700
+ }
701
+
702
+ let events = runnerResult.events;
703
+ if (events === undefined) {
704
+ events = [];
705
+ normalizationIssues.push({
706
+ code: 'W_EVENTS_DEFAULTED_EMPTY',
707
+ message: 'Missing events field. Defaulted to [].'
708
+ });
709
+ }
710
+
711
+ if (!Array.isArray(events)) {
712
+ throw new ReplayCliError({
713
+ code: 'E_RUNNER_EVENTS_INVALID',
714
+ message: 'Runner output field `events` must be an array.',
715
+ path: '$.events',
716
+ expected: 'array',
717
+ received: typeof events,
718
+ howToFix: 'Return `events: []` when there are no events.'
719
+ });
720
+ }
721
+
722
+ let response = runnerResult.response;
723
+
724
+ if (status === 'ok') {
725
+ if (typeof response !== 'string') {
726
+ response = extractResponseFromEvents(events);
727
+
728
+ if (typeof response === 'string') {
729
+ normalizationIssues.push({
730
+ code: 'W_RESPONSE_INFERRED_FROM_EVENTS',
731
+ message: 'Response was inferred from assistant_message event payload.'
732
+ });
733
+ } else {
734
+ throw new ReplayCliError({
735
+ code: 'E_RUNNER_RESPONSE_MISSING',
736
+ message: 'Runner output requires `response` string when status is `ok`.',
737
+ path: '$.response',
738
+ expected: 'string',
739
+ received: typeof runnerResult.response,
740
+ howToFix: 'Set `response` to the final assistant message text.'
741
+ });
742
+ }
743
+ }
744
+ }
745
+
746
+ let error = runnerResult.error;
747
+
748
+ if (status === 'error') {
749
+ if (!isObject(error)) {
750
+ error = {
751
+ code: 'RUNNER_ERROR_UNSPECIFIED',
752
+ message: runnerStderr?.trim() || 'Runner returned status=error without error details.',
753
+ retryable: false
754
+ };
755
+ normalizationIssues.push({
756
+ code: 'W_ERROR_OBJECT_DEFAULTED',
757
+ message: 'Error object missing. A default error payload was generated.'
758
+ });
759
+ }
760
+
761
+ if (typeof error.message !== 'string' || error.message.length === 0) {
762
+ error.message = 'Runner returned status=error without a message.';
763
+ normalizationIssues.push({
764
+ code: 'W_ERROR_MESSAGE_DEFAULTED',
765
+ message: 'Error message was missing. A default message was generated.'
766
+ });
767
+ }
768
+
769
+ if (typeof error.code !== 'string' || error.code.length === 0) {
770
+ error.code = 'RUNNER_ERROR';
771
+ normalizationIssues.push({
772
+ code: 'W_ERROR_CODE_DEFAULTED',
773
+ message: 'Error code was missing. Defaulted to RUNNER_ERROR.'
774
+ });
775
+ }
776
+ }
777
+
778
+ let sources = runnerResult.sources;
779
+ if (!Array.isArray(sources)) {
780
+ sources = [];
781
+ if (runnerResult.sources !== undefined) {
782
+ normalizationIssues.push({
783
+ code: 'W_SOURCES_DEFAULTED_EMPTY',
784
+ message: 'sources was not an array. Defaulted to [].'
785
+ });
786
+ }
787
+ }
788
+
789
+ let usage = runnerResult.usage;
790
+ if (!isObject(usage)) {
791
+ usage = {};
792
+ }
793
+
794
+ return {
795
+ error,
796
+ events,
797
+ framework:
798
+ typeof runnerResult.framework === 'string' ? runnerResult.framework : 'custom',
799
+ normalizationIssues,
800
+ response,
801
+ sources: sources.filter((source) => {
802
+ return typeof source === 'string' && source.length > 0;
803
+ }),
804
+ status,
805
+ usage: {
806
+ costUsd: typeof usage.costUsd === 'number' ? usage.costUsd : undefined,
807
+ inputTokens:
808
+ typeof usage.inputTokens === 'number' ? usage.inputTokens : undefined,
809
+ outputTokens:
810
+ typeof usage.outputTokens === 'number' ? usage.outputTokens : undefined
811
+ }
812
+ };
813
+ }
814
+
815
+ function buildRawArtifact({ execution, payload, runner }) {
816
+ return {
817
+ artifactType: 'replay_raw_capture',
818
+ id: payload.runId,
819
+ payload,
820
+ process: {
821
+ durationMs: execution.durationMs,
822
+ exitCode: execution.exitCode,
823
+ signal: execution.signal,
824
+ stderr: execution.stderr,
825
+ timedOut: execution.timedOut
826
+ },
827
+ runner,
828
+ schemaVersion: SCHEMA_VERSION,
829
+ stdout: execution.stdout,
830
+ timestamp: isoNow()
831
+ };
832
+ }
833
+
834
+ function baseReplayArtifact({ normalizedResult, payload, rawExecution, runner }) {
835
+ return {
836
+ artifactType: 'replay',
837
+ createdAt: isoNow(),
838
+ error: normalizedResult.status === 'error' ? normalizedResult.error : undefined,
839
+ events: normalizedResult.events,
840
+ framework: normalizedResult.framework,
841
+ id: payload.runId,
842
+ prompt: payload.prompt,
843
+ query: payload.query,
844
+ runner: {
845
+ command: runner.display,
846
+ source: runner.source,
847
+ type: runner.type
848
+ },
849
+ schemaVersion: SCHEMA_VERSION,
850
+ sources: normalizedResult.sources,
851
+ status: normalizedResult.status,
852
+ timestamp: payload.timestamp,
853
+ usage: normalizedResult.usage,
854
+ response: normalizedResult.status === 'ok' ? normalizedResult.response : '',
855
+ runtime: {
856
+ runnerDurationMs: rawExecution.durationMs
857
+ }
858
+ };
859
+ }
860
+
861
+ function normalizeEventType(type, aliasMap) {
862
+ if (typeof type !== 'string') {
863
+ return undefined;
864
+ }
865
+
866
+ return aliasMap[type] || type;
867
+ }
868
+
869
+ function normalizeEventTimestamp(value, fallback) {
870
+ if (typeof value !== 'string') {
871
+ return fallback;
872
+ }
873
+
874
+ const parsed = Date.parse(value);
875
+
876
+ if (Number.isNaN(parsed)) {
877
+ return fallback;
878
+ }
879
+
880
+ return new Date(parsed).toISOString();
881
+ }
882
+
883
+ function stableStringify(value) {
884
+ if (!isObject(value) && !Array.isArray(value)) {
885
+ return JSON.stringify(value);
886
+ }
887
+
888
+ return JSON.stringify(value, Object.keys(value).sort());
889
+ }
890
+
891
+ function healReplayArtifact(artifact, rules) {
892
+ const healed = structuredClone(artifact);
893
+ const healLog = [];
894
+ const aliasMap = {
895
+ ...DEFAULT_EVENT_ALIASES,
896
+ ...(isObject(rules?.eventAliases) ? rules.eventAliases : {})
897
+ };
898
+
899
+ if (!Array.isArray(healed.events)) {
900
+ healed.events = [];
901
+ healLog.push({
902
+ code: 'H_EVENTS_DEFAULTED_EMPTY',
903
+ message: 'Top-level events was not an array. Defaulted to [].',
904
+ path: '$.events'
905
+ });
906
+ }
907
+
908
+ const normalizedEvents = [];
909
+
910
+ for (const [index, event] of healed.events.entries()) {
911
+ if (!isObject(event)) {
912
+ healLog.push({
913
+ code: 'H_EVENT_DROPPED_NON_OBJECT',
914
+ message: `Dropped events[${index}] because it was not an object.`,
915
+ path: `$.events[${index}]`
916
+ });
917
+ continue;
918
+ }
919
+
920
+ const normalizedType = normalizeEventType(event.type, aliasMap);
921
+
922
+ if (!normalizedType) {
923
+ healLog.push({
924
+ code: 'H_EVENT_DROPPED_MISSING_TYPE',
925
+ message: `Dropped events[${index}] because type was missing/invalid.`,
926
+ path: `$.events[${index}].type`
927
+ });
928
+ continue;
929
+ }
930
+
931
+ const normalizedEvent = {
932
+ payload: isObject(event.payload) ? event.payload : {},
933
+ runId:
934
+ typeof event.runId === 'string' && event.runId.length > 0
935
+ ? event.runId
936
+ : healed.id,
937
+ seq:
938
+ Number.isInteger(event.seq) && event.seq > 0
939
+ ? event.seq
940
+ : normalizedEvents.length + 1,
941
+ timestamp: normalizeEventTimestamp(event.timestamp, healed.timestamp),
942
+ type: normalizedType
943
+ };
944
+
945
+ if (!isObject(event.payload) && event.payload !== undefined) {
946
+ healLog.push({
947
+ code: 'H_EVENT_PAYLOAD_DEFAULTED_OBJECT',
948
+ message: `events[${index}].payload was not an object. Defaulted to {}.`,
949
+ path: `$.events[${index}].payload`
950
+ });
951
+ }
952
+
953
+ if (event.type !== normalizedType) {
954
+ healLog.push({
955
+ code: 'H_EVENT_TYPE_ALIASED',
956
+ message: `Mapped event type ${event.type} -> ${normalizedType}.`,
957
+ path: `$.events[${index}].type`
958
+ });
959
+ }
960
+
961
+ normalizedEvents.push(normalizedEvent);
962
+ }
963
+
964
+ normalizedEvents.sort((left, right) => {
965
+ if (left.seq !== right.seq) {
966
+ return left.seq - right.seq;
967
+ }
968
+
969
+ return Date.parse(left.timestamp) - Date.parse(right.timestamp);
970
+ });
971
+
972
+ const dedupedEvents = [];
973
+
974
+ for (const event of normalizedEvents) {
975
+ const previous = dedupedEvents.at(-1);
976
+
977
+ if (
978
+ previous &&
979
+ previous.type === event.type &&
980
+ previous.runId === event.runId &&
981
+ previous.timestamp === event.timestamp &&
982
+ stableStringify(previous.payload) === stableStringify(event.payload)
983
+ ) {
984
+ healLog.push({
985
+ code: 'H_EVENT_DEDUPED',
986
+ message: `Deduplicated repeated event type ${event.type}.`,
987
+ path: '$.events'
988
+ });
989
+ continue;
990
+ }
991
+
992
+ dedupedEvents.push(event);
993
+ }
994
+
995
+ healed.events = dedupedEvents;
996
+
997
+ const hasRunStarted = healed.events.some((event) => {
998
+ return event.type === 'run_started';
999
+ });
1000
+
1001
+ if (!hasRunStarted) {
1002
+ healed.events.unshift({
1003
+ payload: {},
1004
+ runId: healed.id,
1005
+ seq: 1,
1006
+ timestamp: healed.timestamp,
1007
+ type: 'run_started'
1008
+ });
1009
+ healLog.push({
1010
+ code: 'H_RUN_STARTED_INSERTED',
1011
+ message: 'Inserted synthetic run_started event.',
1012
+ path: '$.events'
1013
+ });
1014
+ }
1015
+
1016
+ const hasAssistantMessage = healed.events.some((event) => {
1017
+ return event.type === 'assistant_message';
1018
+ });
1019
+
1020
+ if (!hasAssistantMessage && healed.status === 'ok' && healed.response) {
1021
+ healed.events.push({
1022
+ payload: {
1023
+ response: healed.response
1024
+ },
1025
+ runId: healed.id,
1026
+ seq: healed.events.length + 1,
1027
+ timestamp: healed.timestamp,
1028
+ type: 'assistant_message'
1029
+ });
1030
+ healLog.push({
1031
+ code: 'H_ASSISTANT_MESSAGE_INSERTED',
1032
+ message: 'Inserted synthetic assistant_message event from final response.',
1033
+ path: '$.events'
1034
+ });
1035
+ }
1036
+
1037
+ const hasTerminalEvent = healed.events.some((event) => {
1038
+ return event.type === 'run_completed' || event.type === 'run_failed';
1039
+ });
1040
+
1041
+ if (!hasTerminalEvent) {
1042
+ healed.events.push({
1043
+ payload: {},
1044
+ runId: healed.id,
1045
+ seq: healed.events.length + 1,
1046
+ timestamp: healed.timestamp,
1047
+ type: healed.status === 'error' ? 'run_failed' : 'run_completed'
1048
+ });
1049
+ healLog.push({
1050
+ code: 'H_TERMINAL_EVENT_INSERTED',
1051
+ message: 'Inserted missing terminal event.',
1052
+ path: '$.events'
1053
+ });
1054
+ }
1055
+
1056
+ for (let index = 0; index < healed.events.length; index++) {
1057
+ const nextSeq = index + 1;
1058
+
1059
+ if (healed.events[index].seq !== nextSeq) {
1060
+ healLog.push({
1061
+ code: 'H_SEQ_RENUMBERED',
1062
+ message: `Renumbered event sequence ${healed.events[index].seq} -> ${nextSeq}.`,
1063
+ path: `$.events[${index}].seq`
1064
+ });
1065
+ healed.events[index].seq = nextSeq;
1066
+ }
1067
+ }
1068
+
1069
+ healed.toolCalls = healed.events
1070
+ .filter((event) => {
1071
+ return event.type === 'tool_completed';
1072
+ })
1073
+ .map((event) => {
1074
+ const payload = event.payload || {};
1075
+
1076
+ return {
1077
+ durationMs:
1078
+ typeof payload.durationMs === 'number' && payload.durationMs >= 0
1079
+ ? payload.durationMs
1080
+ : 0,
1081
+ status: payload.status === 'error' ? 'error' : 'ok',
1082
+ toolName:
1083
+ typeof payload.toolName === 'string' && payload.toolName.length > 0
1084
+ ? payload.toolName
1085
+ : typeof payload.name === 'string' && payload.name.length > 0
1086
+ ? payload.name
1087
+ : 'unknown_tool'
1088
+ };
1089
+ });
1090
+
1091
+ const runStartedEvent = healed.events.find((event) => {
1092
+ return event.type === 'run_started';
1093
+ });
1094
+ const assistantMessageEvent = healed.events.find((event) => {
1095
+ return event.type === 'assistant_message';
1096
+ });
1097
+ const terminalEvent = [...healed.events].reverse().find((event) => {
1098
+ return event.type === 'run_completed' || event.type === 'run_failed';
1099
+ });
1100
+
1101
+ const runStartMs = Date.parse(runStartedEvent?.timestamp || healed.timestamp);
1102
+ const runEndMs = Date.parse(terminalEvent?.timestamp || healed.timestamp);
1103
+
1104
+ healed.timings = {
1105
+ assistantMessageTimestamp: assistantMessageEvent?.timestamp,
1106
+ endToEndLatencyMs:
1107
+ Number.isFinite(runStartMs) && Number.isFinite(runEndMs)
1108
+ ? Math.max(0, runEndMs - runStartMs)
1109
+ : healed.runtime?.runnerDurationMs,
1110
+ runCompletedTimestamp: terminalEvent?.timestamp,
1111
+ runStartedTimestamp: runStartedEvent?.timestamp
1112
+ };
1113
+
1114
+ healed.healing = {
1115
+ issues: healLog,
1116
+ issuesCount: healLog.length,
1117
+ rulesVersion: rules?.version || 'default'
1118
+ };
1119
+
1120
+ return {
1121
+ healLog,
1122
+ healed
1123
+ };
1124
+ }
1125
+
1126
+ function validateReplayArtifact(artifact, filePath) {
1127
+ const errors = [];
1128
+
1129
+ if (!isObject(artifact)) {
1130
+ errors.push({
1131
+ code: 'E_ARTIFACT_SHAPE_INVALID',
1132
+ message: 'Artifact must be a JSON object.',
1133
+ path: '$',
1134
+ expected: 'object',
1135
+ received: typeof artifact
1136
+ });
1137
+
1138
+ return errors;
1139
+ }
1140
+
1141
+ if (artifact.artifactType !== 'replay') {
1142
+ errors.push({
1143
+ code: 'E_ARTIFACT_TYPE_INVALID',
1144
+ message: 'artifactType must be "replay".',
1145
+ path: '$.artifactType',
1146
+ expected: '"replay"',
1147
+ received: artifact.artifactType
1148
+ });
1149
+ }
1150
+
1151
+ if (artifact.schemaVersion !== SCHEMA_VERSION) {
1152
+ errors.push({
1153
+ code: 'E_SCHEMA_VERSION_UNSUPPORTED',
1154
+ message: `schemaVersion must be ${SCHEMA_VERSION}.`,
1155
+ path: '$.schemaVersion',
1156
+ expected: SCHEMA_VERSION,
1157
+ received: artifact.schemaVersion
1158
+ });
1159
+ }
1160
+
1161
+ if (typeof artifact.id !== 'string' || artifact.id.length === 0) {
1162
+ errors.push({
1163
+ code: 'E_ARTIFACT_ID_INVALID',
1164
+ message: 'id must be a non-empty string.',
1165
+ path: '$.id',
1166
+ expected: 'non-empty string',
1167
+ received: artifact.id
1168
+ });
1169
+ }
1170
+
1171
+ if (artifact.status !== 'ok' && artifact.status !== 'error') {
1172
+ errors.push({
1173
+ code: 'E_ARTIFACT_STATUS_INVALID',
1174
+ message: 'status must be "ok" or "error".',
1175
+ path: '$.status',
1176
+ expected: '"ok" | "error"',
1177
+ received: artifact.status
1178
+ });
1179
+ }
1180
+
1181
+ if (!Array.isArray(artifact.events)) {
1182
+ errors.push({
1183
+ code: 'E_ARTIFACT_EVENTS_INVALID',
1184
+ message: 'events must be an array.',
1185
+ path: '$.events',
1186
+ expected: 'array',
1187
+ received: typeof artifact.events
1188
+ });
1189
+ } else {
1190
+ for (const [index, event] of artifact.events.entries()) {
1191
+ if (!isObject(event)) {
1192
+ errors.push({
1193
+ code: 'E_ARTIFACT_EVENT_INVALID',
1194
+ message: 'Each event must be an object.',
1195
+ path: `$.events[${index}]`,
1196
+ expected: 'object',
1197
+ received: typeof event
1198
+ });
1199
+ continue;
1200
+ }
1201
+
1202
+ if (typeof event.type !== 'string' || event.type.length === 0) {
1203
+ errors.push({
1204
+ code: 'E_ARTIFACT_EVENT_TYPE_INVALID',
1205
+ message: 'Each event.type must be a non-empty string.',
1206
+ path: `$.events[${index}].type`,
1207
+ expected: 'non-empty string',
1208
+ received: event.type
1209
+ });
1210
+ }
1211
+
1212
+ if (!Number.isInteger(event.seq) || event.seq <= 0) {
1213
+ errors.push({
1214
+ code: 'E_ARTIFACT_EVENT_SEQ_INVALID',
1215
+ message: 'Each event.seq must be a positive integer.',
1216
+ path: `$.events[${index}].seq`,
1217
+ expected: 'positive integer',
1218
+ received: event.seq
1219
+ });
1220
+ }
1221
+
1222
+ if (typeof event.timestamp !== 'string' || Number.isNaN(Date.parse(event.timestamp))) {
1223
+ errors.push({
1224
+ code: 'E_ARTIFACT_EVENT_TIMESTAMP_INVALID',
1225
+ message: 'Each event.timestamp must be a valid ISO-8601 string.',
1226
+ path: `$.events[${index}].timestamp`,
1227
+ expected: 'ISO-8601 timestamp string',
1228
+ received: event.timestamp
1229
+ });
1230
+ }
1231
+ }
1232
+ }
1233
+
1234
+ if (errors.length > 0) {
1235
+ return errors.map((error) => {
1236
+ return {
1237
+ ...error,
1238
+ file: filePath
1239
+ };
1240
+ });
1241
+ }
1242
+
1243
+ return [];
1244
+ }
1245
+
1246
+ async function writeJsonFile(targetPath, data) {
1247
+ await ensureDir(path.dirname(targetPath));
1248
+ await fsp.writeFile(targetPath, `${JSON.stringify(data, null, 2)}\n`, 'utf-8');
1249
+ }
1250
+
1251
+ function defaultOutputDirectory(cwd) {
1252
+ return path.resolve(cwd, '.replay', 'artifacts', compactDate());
1253
+ }
1254
+
1255
+ function artifactStem(runId, prompt) {
1256
+ const promptPart = sanitizePrompt(prompt) || 'prompt';
1257
+ const now = isoNow().replace(/[:.]/g, '-');
1258
+ return `${now}_${promptPart}_${runId.slice(-8)}`;
1259
+ }
1260
+
1261
+ async function readHealRules(cwd) {
1262
+ const rulesPath = path.resolve(cwd, '.replay', 'heal.rules.json');
1263
+
1264
+ if (!(await exists(rulesPath))) {
1265
+ return {
1266
+ rules: {
1267
+ eventAliases: DEFAULT_EVENT_ALIASES,
1268
+ version: 'default'
1269
+ },
1270
+ source: 'default'
1271
+ };
1272
+ }
1273
+
1274
+ const raw = await fsp.readFile(rulesPath, 'utf-8');
1275
+ const parsed = safeJsonParse(raw, 'E_HEAL_RULES_INVALID_JSON');
1276
+
1277
+ if (!isObject(parsed)) {
1278
+ throw new ReplayCliError({
1279
+ code: 'E_HEAL_RULES_SHAPE_INVALID',
1280
+ message: '.replay/heal.rules.json must be a JSON object.',
1281
+ path: '.replay/heal.rules.json'
1282
+ });
1283
+ }
1284
+
1285
+ return {
1286
+ rules: {
1287
+ eventAliases: isObject(parsed.eventAliases)
1288
+ ? parsed.eventAliases
1289
+ : DEFAULT_EVENT_ALIASES,
1290
+ version: typeof parsed.version === 'string' ? parsed.version : 'custom'
1291
+ },
1292
+ source: rulesPath
1293
+ };
1294
+ }
1295
+
1296
+ async function collectJsonFiles(inputPath) {
1297
+ const absolutePath = path.resolve(process.cwd(), inputPath);
1298
+
1299
+ if (!(await exists(absolutePath))) {
1300
+ throw new ReplayCliError({
1301
+ code: 'E_INPUT_PATH_NOT_FOUND',
1302
+ message: `Input path does not exist: ${absolutePath}`,
1303
+ path: '--in',
1304
+ howToFix: 'Provide an existing file or directory path.'
1305
+ });
1306
+ }
1307
+
1308
+ const stat = await fsp.stat(absolutePath);
1309
+
1310
+ if (stat.isFile()) {
1311
+ if (!absolutePath.endsWith('.json')) {
1312
+ throw new ReplayCliError({
1313
+ code: 'E_INPUT_FILE_NOT_JSON',
1314
+ message: 'Input file must end with .json.',
1315
+ path: '--in',
1316
+ received: absolutePath
1317
+ });
1318
+ }
1319
+
1320
+ return [absolutePath];
1321
+ }
1322
+
1323
+ if (!stat.isDirectory()) {
1324
+ throw new ReplayCliError({
1325
+ code: 'E_INPUT_NOT_FILE_OR_DIR',
1326
+ message: 'Input must be a file or directory.',
1327
+ path: '--in',
1328
+ received: absolutePath
1329
+ });
1330
+ }
1331
+
1332
+ const files = [];
1333
+
1334
+ async function walk(directory) {
1335
+ const entries = await fsp.readdir(directory, { withFileTypes: true });
1336
+
1337
+ for (const entry of entries) {
1338
+ const absolute = path.resolve(directory, entry.name);
1339
+
1340
+ if (entry.isDirectory()) {
1341
+ await walk(absolute);
1342
+ } else if (entry.isFile() && absolute.endsWith('.json')) {
1343
+ files.push(absolute);
1344
+ }
1345
+ }
1346
+ }
1347
+
1348
+ await walk(absolutePath);
1349
+
1350
+ return files.sort();
1351
+ }
1352
+
1353
+ async function captureCommand({ options, positionals }) {
1354
+ const cwd = process.cwd();
1355
+ const prompt = await resolvePrompt(positionals);
1356
+ const context = await resolveContext(options);
1357
+ const { runner } = await discoverRunner(cwd, options.runner);
1358
+ const { rules, source: healRulesSource } = await readHealRules(cwd);
1359
+
1360
+ const outputDir = options.out
1361
+ ? path.resolve(cwd, options.out)
1362
+ : defaultOutputDirectory(cwd);
1363
+
1364
+ await ensureDir(outputDir);
1365
+
1366
+ const runId = options.id || `run_${Date.now()}_${randomShortId()}`;
1367
+ const payload = {
1368
+ context,
1369
+ prompt,
1370
+ query: prompt,
1371
+ runId,
1372
+ timestamp: isoNow()
1373
+ };
1374
+
1375
+ const execution = await runRunner({
1376
+ payload,
1377
+ runner,
1378
+ timeoutMs: options.timeoutMs
1379
+ });
1380
+
1381
+ const stem = artifactStem(runId, prompt);
1382
+ const rawPath = path.resolve(outputDir, `${stem}.raw.json`);
1383
+ const rawArtifact = buildRawArtifact({
1384
+ execution,
1385
+ payload,
1386
+ runner
1387
+ });
1388
+
1389
+ await writeJsonFile(rawPath, rawArtifact);
1390
+
1391
+ if (execution.timedOut) {
1392
+ throw new ReplayCliError({
1393
+ code: 'E_RUNNER_TIMEOUT',
1394
+ message: `Runner exceeded timeout (${options.timeoutMs}ms).`,
1395
+ details: {
1396
+ rawArtifact: rawPath,
1397
+ timeoutMs: options.timeoutMs
1398
+ },
1399
+ howToFix:
1400
+ 'Increase --timeout-ms, optimize your runner, or return partial error output earlier.'
1401
+ });
1402
+ }
1403
+
1404
+ if (execution.exitCode !== 0) {
1405
+ throw new ReplayCliError({
1406
+ code: 'E_RUNNER_EXIT_NON_ZERO',
1407
+ message: `Runner exited with non-zero code: ${execution.exitCode}`,
1408
+ details: {
1409
+ exitCode: execution.exitCode,
1410
+ rawArtifact: rawPath,
1411
+ stderr: execution.stderr.slice(-2000)
1412
+ },
1413
+ howToFix: 'Fix the runner process error and retry the command.'
1414
+ });
1415
+ }
1416
+
1417
+ const parsedOutput = parseRunnerOutput(execution.stdout);
1418
+ const normalizedResult = normalizeRunnerResult(parsedOutput, execution.stderr);
1419
+ const replayArtifact = baseReplayArtifact({
1420
+ normalizedResult,
1421
+ payload,
1422
+ rawExecution: execution,
1423
+ runner
1424
+ });
1425
+
1426
+ const preHealPath = path.resolve(outputDir, `${stem}.artifact.json`);
1427
+ await writeJsonFile(preHealPath, replayArtifact);
1428
+
1429
+ let healedResult = {
1430
+ healLog: [],
1431
+ healed: replayArtifact
1432
+ };
1433
+
1434
+ if (options.heal) {
1435
+ healedResult = healReplayArtifact(replayArtifact, rules);
1436
+ }
1437
+
1438
+ const healedPath = path.resolve(outputDir, `${stem}.healed.json`);
1439
+ const healLogPath = path.resolve(outputDir, `${stem}.heal-log.json`);
1440
+ await writeJsonFile(healedPath, healedResult.healed);
1441
+ await writeJsonFile(healLogPath, {
1442
+ artifactId: runId,
1443
+ generatedAt: isoNow(),
1444
+ healRulesSource,
1445
+ issues: healedResult.healLog,
1446
+ schemaVersion: SCHEMA_VERSION
1447
+ });
1448
+
1449
+ const validationErrors = validateReplayArtifact(healedResult.healed, healedPath);
1450
+
1451
+ if (validationErrors.length > 0) {
1452
+ throw new ReplayCliError({
1453
+ code: 'E_HEALED_ARTIFACT_INVALID',
1454
+ message: 'Healed artifact failed validation.',
1455
+ details: {
1456
+ errors: validationErrors,
1457
+ healedPath
1458
+ },
1459
+ howToFix: 'Update runner output to include valid status/response/events fields.'
1460
+ });
1461
+ }
1462
+
1463
+ outputJson({
1464
+ artifactId: runId,
1465
+ command: 'capture',
1466
+ healIssues: healedResult.healLog,
1467
+ normalizationIssues: normalizedResult.normalizationIssues,
1468
+ ok: true,
1469
+ paths: {
1470
+ artifact: preHealPath,
1471
+ healLog: healLogPath,
1472
+ healed: healedPath,
1473
+ raw: rawPath
1474
+ },
1475
+ status: normalizedResult.status
1476
+ });
1477
+ }
1478
+
1479
+ async function validateCommand({ options }) {
1480
+ const inputPath = options.in || '.replay/artifacts';
1481
+ const files = await collectJsonFiles(inputPath);
1482
+ const report = {
1483
+ checked: 0,
1484
+ command: 'validate',
1485
+ errors: [],
1486
+ invalid: 0,
1487
+ ok: true,
1488
+ scanned: files.length,
1489
+ skipped: 0,
1490
+ valid: 0
1491
+ };
1492
+
1493
+ for (const file of files) {
1494
+ const content = await fsp.readFile(file, 'utf-8');
1495
+ const parsed = safeJsonParse(content, 'E_ARTIFACT_JSON_INVALID');
1496
+
1497
+ if (parsed?.artifactType !== 'replay') {
1498
+ report.skipped += 1;
1499
+ continue;
1500
+ }
1501
+
1502
+ report.checked += 1;
1503
+
1504
+ const validationErrors = validateReplayArtifact(parsed, file);
1505
+
1506
+ if (validationErrors.length > 0) {
1507
+ report.invalid += 1;
1508
+ report.errors.push(...validationErrors);
1509
+ } else {
1510
+ report.valid += 1;
1511
+ }
1512
+ }
1513
+
1514
+ report.ok = report.invalid === 0;
1515
+ outputJson(report);
1516
+
1517
+ if (!report.ok) {
1518
+ process.exitCode = 1;
1519
+ }
1520
+ }
1521
+
1522
+ async function healCommand({ options }) {
1523
+ const cwd = process.cwd();
1524
+ const inputPath = options.in || '.replay/artifacts';
1525
+ const outDir = path.resolve(cwd, options.out || '.replay/healed');
1526
+ const files = await collectJsonFiles(inputPath);
1527
+ const { rules, source: healRulesSource } = await readHealRules(cwd);
1528
+
1529
+ await ensureDir(outDir);
1530
+
1531
+ const report = {
1532
+ command: 'heal',
1533
+ files: [],
1534
+ healedCount: 0,
1535
+ ok: true,
1536
+ outDir,
1537
+ sourceCount: files.length
1538
+ };
1539
+
1540
+ for (const file of files) {
1541
+ const content = await fsp.readFile(file, 'utf-8');
1542
+ const parsed = safeJsonParse(content, 'E_ARTIFACT_JSON_INVALID');
1543
+
1544
+ if (parsed?.artifactType !== 'replay') {
1545
+ report.files.push({
1546
+ file,
1547
+ skipped: true,
1548
+ reason: 'artifactType is not replay'
1549
+ });
1550
+ continue;
1551
+ }
1552
+
1553
+ const { healed, healLog } = healReplayArtifact(parsed, rules);
1554
+ const outputFile = path.resolve(outDir, path.basename(file));
1555
+ const healLogFile = path.resolve(
1556
+ outDir,
1557
+ path.basename(file, '.json') + '.heal-log.json'
1558
+ );
1559
+
1560
+ await writeJsonFile(outputFile, healed);
1561
+ await writeJsonFile(healLogFile, {
1562
+ artifactId: healed.id,
1563
+ generatedAt: isoNow(),
1564
+ healRulesSource,
1565
+ issues: healLog,
1566
+ schemaVersion: SCHEMA_VERSION,
1567
+ sourceFile: file
1568
+ });
1569
+
1570
+ report.files.push({
1571
+ file,
1572
+ healIssues: healLog.length,
1573
+ healedFile: outputFile,
1574
+ healLogFile,
1575
+ skipped: false
1576
+ });
1577
+ report.healedCount += 1;
1578
+ }
1579
+
1580
+ outputJson(report);
1581
+ }
1582
+
1583
+ async function reportCommand({ options }) {
1584
+ const inputPath = options.in || '.replay/artifacts';
1585
+ const files = await collectJsonFiles(inputPath);
1586
+
1587
+ const replayFiles = [];
1588
+ for (const file of files) {
1589
+ const content = await fsp.readFile(file, 'utf-8');
1590
+ const parsed = safeJsonParse(content, 'E_ARTIFACT_JSON_INVALID');
1591
+
1592
+ if (parsed?.artifactType === 'replay') {
1593
+ replayFiles.push({
1594
+ artifact: parsed,
1595
+ file
1596
+ });
1597
+ }
1598
+ }
1599
+
1600
+ if (replayFiles.length === 0) {
1601
+ outputJson({
1602
+ command: 'report',
1603
+ ok: true,
1604
+ summary: {
1605
+ errors: 0,
1606
+ noData: true,
1607
+ ok: 0,
1608
+ total: 0
1609
+ }
1610
+ });
1611
+ return;
1612
+ }
1613
+
1614
+ const latencies = replayFiles
1615
+ .map(({ artifact }) => artifact.timings?.endToEndLatencyMs)
1616
+ .filter((value) => typeof value === 'number');
1617
+
1618
+ const summary = {
1619
+ averageLatencyMs:
1620
+ latencies.length > 0
1621
+ ? Math.round(
1622
+ latencies.reduce((sum, value) => {
1623
+ return sum + value;
1624
+ }, 0) / latencies.length
1625
+ )
1626
+ : null,
1627
+ error: replayFiles.filter(({ artifact }) => artifact.status === 'error').length,
1628
+ ok: replayFiles.filter(({ artifact }) => artifact.status === 'ok').length,
1629
+ total: replayFiles.length,
1630
+ totalToolCalls: replayFiles.reduce((sum, { artifact }) => {
1631
+ return sum + ensureArray(artifact.toolCalls).length;
1632
+ }, 0)
1633
+ };
1634
+
1635
+ outputJson({
1636
+ command: 'report',
1637
+ ok: true,
1638
+ summary
1639
+ });
1640
+ }
1641
+
1642
+ async function run() {
1643
+ const { command, options, positionals } = parseArgs(process.argv.slice(2));
1644
+
1645
+ if (command === 'help' || options.help) {
1646
+ printHelp();
1647
+ return;
1648
+ }
1649
+
1650
+ if (command === 'capture') {
1651
+ await captureCommand({ options, positionals });
1652
+ return;
1653
+ }
1654
+
1655
+ if (command === 'validate') {
1656
+ await validateCommand({ options });
1657
+ return;
1658
+ }
1659
+
1660
+ if (command === 'heal') {
1661
+ await healCommand({ options });
1662
+ return;
1663
+ }
1664
+
1665
+ if (command === 'report') {
1666
+ await reportCommand({ options });
1667
+ return;
1668
+ }
1669
+
1670
+ throw new ReplayCliError({
1671
+ code: 'E_COMMAND_UNKNOWN',
1672
+ message: `Unknown command: ${command}`,
1673
+ howToFix: 'Use `replay help` to see available commands.'
1674
+ });
1675
+ }
1676
+
1677
+ run().catch((error) => {
1678
+ outputError(error);
1679
+ process.exitCode = 1;
1680
+ });