@pellux/goodvibes-tui 0.19.62 → 0.19.64

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/main.ts CHANGED
@@ -25,9 +25,7 @@ import { GitStatusProvider } from './renderer/git-status.ts';
25
25
  import type { GitHeaderInfo } from './renderer/git-status.ts';
26
26
  import { createShellLayout } from './renderer/layout-engine.ts';
27
27
  import { buildShellFooter, estimateShellFooterHeight } from './renderer/shell-surface.ts';
28
- import {
29
- buildConversationViewport,
30
- } from './renderer/conversation-layout.ts';
28
+ import { buildConversationViewport } from './renderer/conversation-layout.ts';
31
29
  import { applyConversationOverlays } from './renderer/conversation-overlays.ts';
32
30
  import { buildPanelCompositeData } from './renderer/panel-composite.ts';
33
31
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
@@ -52,10 +50,7 @@ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
52
50
  import { prepareShellCliRuntime } from './cli/entrypoint.ts';
53
51
  import { applyInitialTuiCliState } from './cli/tui-startup.ts';
54
52
  import { wireSpokenTurnRuntime } from './audio/spoken-turn-wiring.ts';
55
- import {
56
- attachSpokenTurnModelRouting,
57
- createSpokenTurnInputOptions,
58
- } from './audio/spoken-turn-model-routing.ts';
53
+ import { attachSpokenTurnModelRouting, createSpokenTurnInputOptions } from './audio/spoken-turn-model-routing.ts';
59
54
  import { allowTerminalWrite, installTuiTerminalOutputGuard } from './runtime/terminal-output-guard.ts';
60
55
  import { ProjectPlanningCoordinator } from './planning/project-planning-coordinator.ts';
61
56
  import { buildCommandArgsHint } from './input/command-args-hint.ts';
@@ -394,7 +389,6 @@ async function main() {
394
389
  render();
395
390
  });
396
391
 
397
- // ── InputHandler — created here so getViewportHeight can reference it ──────
398
392
  const input: InputHandler = new InputHandler(
399
393
  () => render(),
400
394
  selection,
@@ -449,7 +443,6 @@ async function main() {
449
443
  },
450
444
  );
451
445
 
452
- // Wire orchestratorRefs now that InputHandler is created
453
446
  orchestratorRefs.getViewportHeight = getViewportHeight;
454
447
  orchestratorRefs.scrollToEnd = scrollToEnd;
455
448
 
@@ -460,13 +453,9 @@ async function main() {
460
453
  input.agentDetailModal.setOnRefresh(() => render());
461
454
  input.processModal.setOnRefresh(() => render());
462
455
 
463
- // --- Model picker wiring ---
464
- // Model picker callback is handled in bootstrap.ts — do not duplicate here
465
-
466
- // inputHistory comes from bootstrap, already set up — wire it to the input handler
456
+ // Model picker callback is handled in bootstrap.ts — do not duplicate here.
467
457
  input.setHistory(inputHistory);
468
458
 
469
- // --- Splash options ---
470
459
  const toolCount = toolRegistry.list().length;
471
460
  conversation.splashOptions = {
472
461
  workingDir,
@@ -475,8 +464,6 @@ async function main() {
475
464
  toolCount,
476
465
  };
477
466
 
478
-
479
- // --- Render function ---
480
467
  const render = () => {
481
468
  const width = stdout.columns || 80;
482
469
  const height = stdout.rows || 24;
@@ -486,7 +473,6 @@ async function main() {
486
473
  const sessionSnapshot = uiServices.readModels.session.getSnapshot();
487
474
  const agentSnapshot = uiServices.readModels.agents.getSnapshot();
488
475
 
489
- // Build header and footer FIRST so we know the exact viewport height
490
476
  const headerLines = UIFactory.createHeader(width, currentModel.id, currentModel.provider, conversation.title || undefined, lastGitInfoRef.value);
491
477
  const managerAgents = agentManager.list().filter(
492
478
  (a) => a.status === 'running' || a.status === 'pending',
@@ -569,6 +555,16 @@ async function main() {
569
555
  footerHeight: shellFooterLines.length,
570
556
  panelWidth,
571
557
  });
558
+ input.setPanelMouseLayout(shellLayout.panel
559
+ ? {
560
+ x: shellLayout.panel.x,
561
+ y: shellLayout.panel.y,
562
+ width: shellLayout.panel.width,
563
+ height: shellLayout.panel.height,
564
+ hasBottomPane: panelManager.isBottomPaneVisible() && panelManager.getBottomPane().panels.length > 0,
565
+ verticalSplitRatio: panelManager.getVerticalSplitRatio(),
566
+ }
567
+ : null);
572
568
  const vHeight = shellLayout.body.height;
573
569
  const conversationWidth = shellLayout.conversation.width;
574
570
  activeConversationWidth = conversationWidth;
@@ -196,6 +196,14 @@ export class FileExplorerPanel extends BasePanel {
196
196
  }
197
197
  }
198
198
 
199
+ handleScroll(deltaRows: number): boolean {
200
+ const rows = Math.trunc(deltaRows);
201
+ if (this.flat.length === 0 || rows === 0) return false;
202
+ const previous = this.cursor;
203
+ this._setCursor(this.cursor + rows);
204
+ return this.cursor !== previous;
205
+ }
206
+
199
207
  // ── Render ─────────────────────────────────────────────────────────────────
200
208
 
201
209
  render(width: number, height: number): Line[] {
@@ -200,6 +200,18 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
200
200
  }
201
201
  }
202
202
 
203
+ handleScroll(deltaRows: number): boolean {
204
+ if (this.lastError !== null) this.clearError();
205
+ const total = this.getItems().length;
206
+ const rows = Math.trunc(deltaRows);
207
+ if (total === 0 || rows === 0) return false;
208
+ const next = Math.max(0, Math.min(total - 1, this.selectedIndex + rows));
209
+ if (next === this.selectedIndex) return false;
210
+ this.selectedIndex = next;
211
+ this.needsRender = true;
212
+ return true;
213
+ }
214
+
203
215
  // -------------------------------------------------------------------------
204
216
  // Scroll state helpers
205
217
  // -------------------------------------------------------------------------
@@ -36,6 +36,10 @@ export interface Panel {
36
36
 
37
37
  // Input (optional)
38
38
  handleInput?(key: string): boolean;
39
+
40
+ // Scroll input (optional)
41
+ // Positive delta scrolls down; negative delta scrolls up.
42
+ handleScroll?(deltaRows: number): boolean;
39
43
  }
40
44
 
41
45
  export interface PanelRegistration extends Pick<Panel, 'id' | 'name' | 'icon' | 'category'> {
@@ -202,7 +202,7 @@ export function renderShortcutsOverlay(
202
202
  row('PageUp / PageDn', 'Scroll by full page'),
203
203
  row('Home / End', 'Jump to start / end of line'),
204
204
  row(kb('search'), 'Search conversation'),
205
- row('Mouse wheel', 'Scroll conversation'),
205
+ row('Mouse wheel', 'Scroll conversation or hovered panel'),
206
206
  '',
207
207
  ' Editing',
208
208
  ' ' + '\u2500'.repeat(40),
@@ -0,0 +1,430 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { auditGoodVibesHome } from '../config/goodvibes-home-audit.ts';
5
+ import { buildVerificationLedger } from './verification-ledger.ts';
6
+
7
+ export type LiveVerificationStatus = 'pass' | 'warn' | 'fail' | 'skip';
8
+
9
+ export interface LiveVerificationCheck {
10
+ id: string;
11
+ title: string;
12
+ status: LiveVerificationStatus;
13
+ summary: string;
14
+ detail?: string;
15
+ }
16
+
17
+ export interface LiveVerificationOptions {
18
+ homeDir: string;
19
+ binaryPath: string;
20
+ projectRoot: string;
21
+ daemonBaseUrl?: string;
22
+ token?: string;
23
+ strict?: boolean;
24
+ }
25
+
26
+ export interface LiveVerificationReport {
27
+ generatedAt: string;
28
+ homeDir: string;
29
+ binaryPath: string;
30
+ daemonBaseUrl: string;
31
+ strict: boolean;
32
+ checks: LiveVerificationCheck[];
33
+ counts: Record<LiveVerificationStatus, number>;
34
+ ok: boolean;
35
+ }
36
+
37
+ interface CommandResult {
38
+ exitCode: number | null;
39
+ stdout: string;
40
+ stderr: string;
41
+ timedOut: boolean;
42
+ }
43
+
44
+ function readJsonFile(path: string): unknown {
45
+ return JSON.parse(readFileSync(path, 'utf8'));
46
+ }
47
+
48
+ function redact(text: string): string {
49
+ return text
50
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, 'Bearer [redacted]')
51
+ .replace(/"token"\s*:\s*"[^"]+"/g, '"token":"[redacted]"');
52
+ }
53
+
54
+ function compact(text: string, maxLength = 900): string {
55
+ const trimmed = redact(text.trim());
56
+ if (trimmed.length <= maxLength) return trimmed;
57
+ return `${trimmed.slice(0, maxLength - 16)}... [truncated]`;
58
+ }
59
+
60
+ function readDaemonToken(homeDir: string): string | undefined {
61
+ if (process.env.GOODVIBES_DAEMON_TOKEN) return process.env.GOODVIBES_DAEMON_TOKEN;
62
+ const tokenPath = join(homeDir, 'daemon', 'operator-tokens.json');
63
+ if (!existsSync(tokenPath)) return undefined;
64
+ try {
65
+ const data = readJsonFile(tokenPath);
66
+ if (data && typeof data === 'object' && typeof (data as { token?: unknown }).token === 'string') {
67
+ return (data as { token: string }).token;
68
+ }
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ function resolveDaemonBaseUrl(homeDir: string, explicit?: string): string {
76
+ if (explicit) return explicit.replace(/\/+$/, '');
77
+ if (process.env.GOODVIBES_DAEMON_URL) return process.env.GOODVIBES_DAEMON_URL.replace(/\/+$/, '');
78
+ const settingsPath = join(homeDir, 'tui', 'settings.json');
79
+ let port = 3421;
80
+ if (existsSync(settingsPath)) {
81
+ try {
82
+ const settings = readJsonFile(settingsPath);
83
+ const configuredPort = (settings as { controlPlane?: { port?: unknown } })?.controlPlane?.port;
84
+ if (typeof configuredPort === 'number' && Number.isFinite(configuredPort)) port = configuredPort;
85
+ } catch {
86
+ // Keep the default; this verifier should report daemon state, not fail before checks run.
87
+ }
88
+ }
89
+ return `http://127.0.0.1:${port}`;
90
+ }
91
+
92
+ function runCommand(command: string, args: string[], cwd: string, timeoutMs = 15_000): Promise<CommandResult> {
93
+ return new Promise((resolveCommand) => {
94
+ const child = spawn(command, args, {
95
+ cwd,
96
+ env: { ...process.env, NO_COLOR: '1' },
97
+ stdio: ['ignore', 'pipe', 'pipe'],
98
+ });
99
+ const stdout: Buffer[] = [];
100
+ const stderr: Buffer[] = [];
101
+ let timedOut = false;
102
+ const timeout = setTimeout(() => {
103
+ timedOut = true;
104
+ child.kill('SIGTERM');
105
+ setTimeout(() => child.kill('SIGKILL'), 1000).unref();
106
+ }, timeoutMs);
107
+ child.stdout?.on('data', (chunk) => stdout.push(Buffer.from(chunk)));
108
+ child.stderr?.on('data', (chunk) => stderr.push(Buffer.from(chunk)));
109
+ child.on('error', (error) => {
110
+ clearTimeout(timeout);
111
+ resolveCommand({
112
+ exitCode: -1,
113
+ stdout: '',
114
+ stderr: error instanceof Error ? error.message : String(error),
115
+ timedOut,
116
+ });
117
+ });
118
+ child.on('exit', (exitCode) => {
119
+ clearTimeout(timeout);
120
+ resolveCommand({
121
+ exitCode,
122
+ stdout: Buffer.concat(stdout).toString('utf8'),
123
+ stderr: Buffer.concat(stderr).toString('utf8'),
124
+ timedOut,
125
+ });
126
+ });
127
+ });
128
+ }
129
+
130
+ function commandCheck(
131
+ id: string,
132
+ title: string,
133
+ result: CommandResult,
134
+ passSummary: string,
135
+ options?: { warnOnNonZero?: boolean; parseJson?: boolean },
136
+ ): LiveVerificationCheck {
137
+ if (result.timedOut) {
138
+ return {
139
+ id,
140
+ title,
141
+ status: options?.warnOnNonZero ? 'warn' : 'fail',
142
+ summary: 'Command timed out.',
143
+ detail: compact(`${result.stdout}\n${result.stderr}`),
144
+ };
145
+ }
146
+ if (result.exitCode !== 0) {
147
+ return {
148
+ id,
149
+ title,
150
+ status: options?.warnOnNonZero ? 'warn' : 'fail',
151
+ summary: `Command exited ${result.exitCode}.`,
152
+ detail: compact(`${result.stdout}\n${result.stderr}`),
153
+ };
154
+ }
155
+ if (options?.parseJson) {
156
+ try {
157
+ JSON.parse(result.stdout);
158
+ } catch (error) {
159
+ return {
160
+ id,
161
+ title,
162
+ status: 'fail',
163
+ summary: 'Command succeeded but did not return valid JSON.',
164
+ detail: error instanceof Error ? error.message : String(error),
165
+ };
166
+ }
167
+ }
168
+ return {
169
+ id,
170
+ title,
171
+ status: 'pass',
172
+ summary: passSummary,
173
+ detail: compact(result.stdout || result.stderr),
174
+ };
175
+ }
176
+
177
+ async function fetchCheck(
178
+ id: string,
179
+ title: string,
180
+ url: string,
181
+ token: string | undefined,
182
+ validate: (status: number, body: string) => { status: LiveVerificationStatus; summary: string; detail?: string },
183
+ ): Promise<LiveVerificationCheck> {
184
+ if (!token) {
185
+ return {
186
+ id,
187
+ title,
188
+ status: 'skip',
189
+ summary: 'No daemon bearer token was available.',
190
+ };
191
+ }
192
+ try {
193
+ const response = await fetch(url, {
194
+ headers: { Authorization: `Bearer ${token}` },
195
+ signal: AbortSignal.timeout(5000),
196
+ });
197
+ const body = await response.text();
198
+ const validated = validate(response.status, body);
199
+ return {
200
+ id,
201
+ title,
202
+ ...validated,
203
+ detail: validated.detail ?? compact(body),
204
+ };
205
+ } catch (error) {
206
+ return {
207
+ id,
208
+ title,
209
+ status: 'fail',
210
+ summary: 'Request failed.',
211
+ detail: error instanceof Error ? error.message : String(error),
212
+ };
213
+ }
214
+ }
215
+
216
+ function countStatuses(checks: readonly LiveVerificationCheck[]): Record<LiveVerificationStatus, number> {
217
+ return checks.reduce<Record<LiveVerificationStatus, number>>(
218
+ (counts, check) => {
219
+ counts[check.status] += 1;
220
+ return counts;
221
+ },
222
+ { pass: 0, warn: 0, fail: 0, skip: 0 },
223
+ );
224
+ }
225
+
226
+ export async function buildLiveVerificationReport(options: LiveVerificationOptions): Promise<LiveVerificationReport> {
227
+ const homeDir = resolve(options.homeDir);
228
+ const projectRoot = resolve(options.projectRoot);
229
+ const binaryPath = resolve(options.binaryPath);
230
+ const daemonBaseUrl = resolveDaemonBaseUrl(homeDir, options.daemonBaseUrl);
231
+ const token = options.token ?? readDaemonToken(homeDir);
232
+ const checks: LiveVerificationCheck[] = [];
233
+
234
+ const ledger = buildVerificationLedger(projectRoot);
235
+ checks.push({
236
+ id: 'verification-ledger',
237
+ title: 'Verification inventory ledger',
238
+ status: ledger.totals.localSignalPercent >= 90 ? 'pass' : 'fail',
239
+ summary: `${ledger.totals.localSignalPercent}% local verification signal across ${ledger.totals.total} inventory items.`,
240
+ detail: `${ledger.totals.localBehaviorPercent}% local behavior verified; ${ledger.totals.externalOutcomeRequired} item(s) require external outcomes.`,
241
+ });
242
+
243
+ const audit = await auditGoodVibesHome({ homeDir });
244
+ const staleCandidates = audit.settings?.staleCandidates?.length ?? 0;
245
+ checks.push({
246
+ id: 'goodvibes-home-audit',
247
+ title: 'GoodVibes home ownership/settings audit',
248
+ status: audit.findings.length === 0 && staleCandidates === 0 ? 'pass' : 'warn',
249
+ summary: audit.findings.length === 0
250
+ ? 'No ownership, stale-setting, or secret-permission findings.'
251
+ : `${audit.findings.length} audit finding(s).`,
252
+ detail: audit.findings.length === 0
253
+ ? `${audit.settings?.recognizedKeyCount ?? 0} current schema key(s), ${staleCandidates} stale candidate(s).`
254
+ : audit.findings.map((finding) => `${finding.severity}: ${finding.message}`).join('\n'),
255
+ });
256
+
257
+ checks.push({
258
+ id: 'compiled-cli-present',
259
+ title: 'Compiled GoodVibes CLI binary',
260
+ status: existsSync(binaryPath) ? 'pass' : 'fail',
261
+ summary: existsSync(binaryPath) ? `Found ${binaryPath}.` : `Missing ${binaryPath}.`,
262
+ });
263
+
264
+ if (existsSync(binaryPath)) {
265
+ checks.push(commandCheck(
266
+ 'cli-version',
267
+ 'CLI version command',
268
+ await runCommand(binaryPath, ['version'], projectRoot),
269
+ 'CLI version returned successfully.',
270
+ ));
271
+ checks.push(commandCheck(
272
+ 'cli-status-json',
273
+ 'CLI status JSON command',
274
+ await runCommand(binaryPath, ['status', '--output', 'json'], projectRoot),
275
+ 'CLI status returned parseable JSON.',
276
+ { parseJson: true },
277
+ ));
278
+ checks.push(commandCheck(
279
+ 'cli-providers',
280
+ 'CLI providers command',
281
+ await runCommand(binaryPath, ['providers'], projectRoot),
282
+ 'Provider inventory rendered successfully.',
283
+ ));
284
+ checks.push(commandCheck(
285
+ 'cli-control-plane-status',
286
+ 'CLI control-plane status command',
287
+ await runCommand(binaryPath, ['control-plane', 'status'], projectRoot),
288
+ 'Control-plane status rendered successfully.',
289
+ { warnOnNonZero: true },
290
+ ));
291
+ checks.push(commandCheck(
292
+ 'cli-listener-test',
293
+ 'CLI listener readiness command',
294
+ await runCommand(binaryPath, ['listener', 'test'], projectRoot),
295
+ 'HTTP listener readiness rendered successfully.',
296
+ { warnOnNonZero: true },
297
+ ));
298
+ checks.push(commandCheck(
299
+ 'cli-surfaces-check',
300
+ 'CLI surfaces readiness command',
301
+ await runCommand(binaryPath, ['surfaces', 'check'], projectRoot),
302
+ 'Surface readiness rendered successfully.',
303
+ { warnOnNonZero: true },
304
+ ));
305
+ checks.push(commandCheck(
306
+ 'cli-service-check',
307
+ 'CLI service posture command',
308
+ await runCommand(binaryPath, ['service', 'check'], projectRoot),
309
+ 'Service posture rendered successfully.',
310
+ { warnOnNonZero: true },
311
+ ));
312
+ checks.push(commandCheck(
313
+ 'cli-doctor',
314
+ 'CLI doctor command',
315
+ await runCommand(binaryPath, ['doctor', '--output', 'text'], projectRoot),
316
+ 'Doctor completed without findings.',
317
+ { warnOnNonZero: true },
318
+ ));
319
+ }
320
+
321
+ checks.push(await fetchCheck(
322
+ 'daemon-status',
323
+ 'Authenticated daemon /status',
324
+ `${daemonBaseUrl}/status`,
325
+ token,
326
+ (status, body) => {
327
+ if (status !== 200) return { status: 'fail', summary: `/status returned ${status}.` };
328
+ try {
329
+ const parsed = JSON.parse(body) as { version?: unknown; sdkVersion?: unknown };
330
+ const version = typeof parsed.sdkVersion === 'string'
331
+ ? parsed.sdkVersion
332
+ : typeof parsed.version === 'string' ? parsed.version : 'unknown';
333
+ return { status: 'pass', summary: `/status returned 200, version ${version}.` };
334
+ } catch {
335
+ return { status: 'warn', summary: '/status returned 200 but was not parseable JSON.' };
336
+ }
337
+ },
338
+ ));
339
+
340
+ checks.push(await fetchCheck(
341
+ 'daemon-health',
342
+ 'Authenticated daemon /api/health',
343
+ `${daemonBaseUrl}/api/health`,
344
+ token,
345
+ (status, body) => {
346
+ if (status !== 200) return { status: 'fail', summary: `/api/health returned ${status}.` };
347
+ try {
348
+ const parsed = JSON.parse(body) as { overall?: unknown };
349
+ return {
350
+ status: parsed.overall === 'healthy' ? 'pass' : 'warn',
351
+ summary: `Health overall=${String(parsed.overall ?? 'unknown')}.`,
352
+ };
353
+ } catch {
354
+ return { status: 'warn', summary: '/api/health returned 200 but was not parseable JSON.' };
355
+ }
356
+ },
357
+ ));
358
+
359
+ checks.push(await fetchCheck(
360
+ 'openai-compatible-models',
361
+ 'OpenAI-compatible /v1/models route',
362
+ `${daemonBaseUrl}/v1/models`,
363
+ token,
364
+ (status, body) => {
365
+ if (status !== 200) return { status: 'fail', summary: `/v1/models returned ${status}.` };
366
+ try {
367
+ const parsed = JSON.parse(body) as { data?: unknown };
368
+ const models = Array.isArray(parsed.data) ? parsed.data.length : 0;
369
+ return {
370
+ status: models > 0 ? 'pass' : 'warn',
371
+ summary: `/v1/models returned ${models} model(s).`,
372
+ };
373
+ } catch {
374
+ return { status: 'warn', summary: '/v1/models returned 200 but was not parseable JSON.' };
375
+ }
376
+ },
377
+ ));
378
+
379
+ const counts = countStatuses(checks);
380
+ const ok = counts.fail === 0 && (!options.strict || counts.warn === 0);
381
+ return {
382
+ generatedAt: new Date().toISOString(),
383
+ homeDir,
384
+ binaryPath,
385
+ daemonBaseUrl,
386
+ strict: options.strict ?? false,
387
+ checks,
388
+ counts,
389
+ ok,
390
+ };
391
+ }
392
+
393
+ export function renderLiveVerificationReportMarkdown(report: LiveVerificationReport): string {
394
+ const lines: string[] = [
395
+ '# GoodVibes Live Verification',
396
+ '',
397
+ `Generated: ${report.generatedAt}`,
398
+ `Home: \`${report.homeDir}\``,
399
+ `Binary: \`${report.binaryPath}\``,
400
+ `Daemon: \`${report.daemonBaseUrl}\``,
401
+ '',
402
+ '| Status | Count |',
403
+ '|---|---:|',
404
+ `| pass | ${report.counts.pass} |`,
405
+ `| warn | ${report.counts.warn} |`,
406
+ `| fail | ${report.counts.fail} |`,
407
+ `| skip | ${report.counts.skip} |`,
408
+ '',
409
+ '| Check | Status | Summary |',
410
+ '|---|---|---|',
411
+ ];
412
+ for (const check of report.checks) {
413
+ lines.push(`| ${check.title} | ${check.status} | ${check.summary.replace(/\|/g, '\\|')} |`);
414
+ }
415
+ const detailed = report.checks.filter((check) => check.detail?.trim());
416
+ if (detailed.length > 0) {
417
+ lines.push('', '## Details', '');
418
+ for (const check of detailed) {
419
+ lines.push(`### ${check.title}`, '', '```text', check.detail?.trim() ?? '', '```', '');
420
+ }
421
+ }
422
+ lines.push(report.ok ? 'Result: PASS' : 'Result: FAIL', '');
423
+ return lines.join('\n');
424
+ }
425
+
426
+ export function writeLiveVerificationReportFiles(report: LiveVerificationReport, outputDir: string): void {
427
+ mkdirSync(outputDir, { recursive: true });
428
+ writeFileSync(join(outputDir, 'live-verification.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
429
+ writeFileSync(join(outputDir, 'live-verification.md'), renderLiveVerificationReportMarkdown(report), 'utf8');
430
+ }