Skip to main content

wyvern/error/
emit.rs

1//! JSON emission helpers for load, validation, host, and extension errors.
2
3use wyvern_schema::{ErrorCode, SerializeError, StderrError, ValidationError};
4
5use super::{EmitError, LoadError, UsageErrorKind};
6
7#[cfg(test)]
8thread_local! {
9    /// Scoped test seam: only the arming thread sees forced stdout emit failures.
10    static FORCE_EMIT_STDOUT_FAIL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
11}
12
13/// RAII guard that forces [`emit_stdout`] to fail on this thread.
14#[cfg(test)]
15pub(super) struct ForceEmitStdoutFailGuard;
16
17#[cfg(test)]
18impl ForceEmitStdoutFailGuard {
19    pub(super) fn arm() -> Self {
20        FORCE_EMIT_STDOUT_FAIL.with(|f| f.set(true));
21        Self
22    }
23}
24
25#[cfg(test)]
26impl Drop for ForceEmitStdoutFailGuard {
27    fn drop(&mut self) {
28        FORCE_EMIT_STDOUT_FAIL.with(|f| f.set(false));
29    }
30}
31
32/// Serialize an extension-engine error as stderr JSON.
33///
34/// # Errors
35///
36/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized.
37pub fn emit_extension_error(err: &crate::extensions::ExtensionError) -> Result<String, EmitError> {
38    use crate::extensions::ExtensionError;
39    let (code, message, cause, recovery) = match err {
40        ExtensionError::InvalidRegistry { message } => (
41            ErrorCode::ParseError,
42            message.clone(),
43            "Extension registry JSON could not be loaded".to_string(),
44            vec![
45                "Fix share/wyvern/extensions.json or .wyvern/extensions.json".into(),
46                "Registry must be version 1 JSON with an extensions array".into(),
47            ],
48        ),
49        ExtensionError::MissingArgs {
50            missing,
51            extension_id,
52            example,
53            help_command,
54            ..
55        } => (
56            ErrorCode::ValidationError,
57            format!(
58                "missing required arguments {} for '{extension_id}'",
59                missing.join(", ")
60            ),
61            format!("'{extension_id}' requires {}", missing.join(" and ")),
62            vec![
63                format!("Pass {} after the extension prefix", missing.join(" ")),
64                format!("Example: {example}"),
65                format!("Run {help_command}"),
66                "Run wyvern --help to list skills".into(),
67            ],
68        ),
69        ExtensionError::UnexpectedArg {
70            token,
71            declared,
72            extension_id,
73            help_command,
74        } => {
75            let accepted = if declared.is_empty() {
76                format!("Run {help_command}")
77            } else {
78                format!(
79                    "Accepted flags: {}",
80                    declared
81                        .iter()
82                        .map(|name| format!("--{name}"))
83                        .collect::<Vec<_>>()
84                        .join(", ")
85                )
86            };
87            (
88                ErrorCode::ValidationError,
89                format!("unexpected argument after extension match: {token}"),
90                format!("'{extension_id}' does not accept leftover token '{token}'"),
91                vec![
92                    format!("Remove unexpected argument `{token}`"),
93                    accepted,
94                    format!("Run {help_command}"),
95                    "Run wyvern --help to list skills".into(),
96                ],
97            )
98        }
99        ExtensionError::PathVarWithoutPath { var } => (
100            ErrorCode::ValidationError,
101            format!("template {{{var}}} requires a matched file path"),
102            "This extension is prefix-only and has no {{path}}".to_string(),
103            vec!["Use a suffix or prefix+suffix match when expanding path variables".into()],
104        ),
105        ExtensionError::Template { kind, message, .. } => {
106            let (cause, recovery) = match kind {
107                crate::extensions::TemplateErrorKind::UnclosedBrace => (
108                    "Template contains an unclosed `{` brace".to_string(),
109                    vec!["Close every `{variable}` in the registry expand/preexec templates".into()],
110                ),
111                crate::extensions::TemplateErrorKind::UnknownVariable => (
112                    "Template references an unknown `{variable}`".to_string(),
113                    vec!["Use only documented template variables from cli-extensions-contract.md".into()],
114                ),
115                crate::extensions::TemplateErrorKind::PhaseRestricted => (
116                    "Template variable is not allowed in this expansion phase".to_string(),
117                    vec!["Move path-only variables to expand phase-1; use phase-2 for preexec stdout vars".into()],
118                ),
119                crate::extensions::TemplateErrorKind::Unavailable => (
120                    "Template variable is not available in this match context".to_string(),
121                    vec!["Ensure the match provides path/tmpdir/preexec stdout before using this variable".into()],
122                ),
123                crate::extensions::TemplateErrorKind::InvalidSpec => (
124                    "Expand/preexec spec is incomplete or contradictory".to_string(),
125                    vec!["Check command_from_file, preexec cmd/args, and host overrides in the registry".into()],
126                ),
127            };
128            (ErrorCode::ValidationError, message.clone(), cause, recovery)
129        }
130        ExtensionError::Preexec { kind, message, .. } => {
131            use crate::extensions::PreexecFailureKind;
132            let (cause, recovery) = match kind {
133                Some(PreexecFailureKind::SpawnNotFound { cmd }) => (
134                    format!("Could not spawn preexec helper '{cmd}'"),
135                    vec![
136                        format!("Install '{cmd}' or add it to PATH"),
137                        "Run wyvern extensions list to see requires".into(),
138                        "Run wyvern --help to list skills".into(),
139                    ],
140                ),
141                Some(PreexecFailureKind::NonZeroExit { stderr_tail, code }) => {
142                    let cause = if stderr_tail.is_empty() {
143                        format!("Preexec helper exited with status {code}")
144                    } else {
145                        stderr_tail.clone()
146                    };
147                    (
148                        cause,
149                        vec![
150                            "Inspect the helper stderr in cause and fix the input path or flags"
151                                .into(),
152                            "Retry after correcting the file or arguments".into(),
153                            "Run wyvern --help to list skills".into(),
154                        ],
155                    )
156                }
157                Some(PreexecFailureKind::Timeout { cmd, timeout_secs }) => (
158                    format!("Preexec helper '{cmd}' timed out after {timeout_secs}s"),
159                    vec![
160                        format!(
161                            "Increase WYVERN_PREEXEC_TIMEOUT_SECS (current {timeout_secs}) if the helper needs more time"
162                        ),
163                        "Or fix a hung helper or blocked input path".into(),
164                        "Run wyvern --help to list skills".into(),
165                    ],
166                ),
167                None => (
168                    "Extension preexec subprocess failed".to_string(),
169                    vec![
170                        "Inspect the helper output in the error message".into(),
171                        "Retry after correcting the input path or flags".into(),
172                        "Run wyvern --help to list skills".into(),
173                    ],
174                ),
175            };
176            (ErrorCode::IoError, message.clone(), cause, recovery)
177        }
178        ExtensionError::InvalidCommand { source } => {
179            return emit_validation_error(source);
180        }
181        ExtensionError::Io { message, .. } => (
182            ErrorCode::IoError,
183            message.clone(),
184            "Extension engine filesystem operation failed".to_string(),
185            vec!["Check paths in the registry and working directory permissions".into()],
186        ),
187    };
188    let mut envelope = StderrError::new(code, message)
189        .cause(cause)
190        .docs("docs/plans/phase-F/cli-extensions-contract.md");
191    for step in recovery {
192        envelope = envelope.recovery(step);
193    }
194    envelope.to_json_string().map_err(EmitError::Serialize)
195}
196
197/// Serialize a usage / unknown-subcommand error as stderr JSON (exit 2).
198///
199/// # Errors
200///
201/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized.
202pub fn emit_usage_message(message: &str) -> Result<String, EmitError> {
203    StderrError::new(ErrorCode::ParseError, message.to_string())
204        .cause("CLI argv was not a valid command or extension invocation")
205        .recovery("Pass a JSON command, a .json file, or a path handled by an extension")
206        .recovery("Run wyvern extensions list to see file-type and prefix extensions")
207        .recovery("Run wyvern --help for host flags")
208        .docs("docs/wyvern/requirements.md (REQ-0130)")
209        .to_json_string()
210        .map_err(EmitError::Serialize)
211}
212
213/// Serialize [`LoadError::Usage`] as stderr JSON with flag-specific recovery.
214///
215/// # Errors
216///
217/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized, or
218/// when `err` is not [`LoadError::Usage`] (miswire).
219pub fn emit_usage_error(err: &LoadError) -> Result<String, EmitError> {
220    let LoadError::Usage { kind, message } = err else {
221        debug_assert!(matches!(err, LoadError::Usage { .. }));
222        return Err(EmitError::Serialize(SerializeError {
223            message: "emit_usage_error: expected Usage".into(),
224        }));
225    };
226    let (cause, recovery, docs) = match kind {
227        UsageErrorKind::Generic => (
228            "CLI argv was not a valid command or extension invocation".to_string(),
229            vec![
230                "Pass a JSON command, a .json file, or a path handled by an extension".into(),
231                "Run wyvern extensions list to see file-type and prefix extensions".into(),
232                "Run wyvern --help for host flags".into(),
233            ],
234            "docs/wyvern/requirements.md (REQ-0130)",
235        ),
236        UsageErrorKind::InvalidBind { .. } => (
237            "The --bind value is not a valid socket address".to_string(),
238            vec![
239                "Use host:port form (example: 127.0.0.1:0 for an ephemeral loopback port)".into(),
240                "For 0.0.0.0 / LAN binds, also pass --allow-non-loopback".into(),
241                "Check the address is a valid IPv4/IPv6 socket address".into(),
242            ],
243            "docs/plans/phase-F/README.md",
244        ),
245        UsageErrorKind::MissingFlagValue { flag } => (
246            format!("Host flag {flag} requires a value"),
247            vec![
248                format!("Pass {flag} VALUE on the command line"),
249                "Use --bind=ADDR:PORT or --viewer=MODE inline forms when preferred".into(),
250            ],
251            "docs/plans/phase-F/README.md",
252        ),
253        UsageErrorKind::InvalidViewer { .. } => (
254            "The --viewer value is not a supported viewer mode".to_string(),
255            vec![
256                "Use one of: embedded, none, system, chrome, safari, edge, firefox".into(),
257                "Omit --viewer to use embedded (default)".into(),
258                "Set WYVERN_VIEWER=none for headless / CI".into(),
259            ],
260            "docs/plans/phase-C/http-viewer-contract.md",
261        ),
262        UsageErrorKind::InvalidWyvernViewerEnv { .. } => (
263            "WYVERN_VIEWER is set but not a valid viewer mode".to_string(),
264            vec![
265                "Use one of: embedded, none, system, chrome, safari, edge, firefox".into(),
266                "Unset WYVERN_VIEWER to use embedded (default)".into(),
267                "Use WYVERN_VIEWER=none for headless / CI".into(),
268            ],
269            "docs/plans/phase-C/http-viewer-contract.md",
270        ),
271        UsageErrorKind::InvalidWyvernViewerUnicode => (
272            "WYVERN_VIEWER is not valid Unicode".to_string(),
273            vec![
274                "Set WYVERN_VIEWER to ASCII viewer mode names only".into(),
275                "Unset WYVERN_VIEWER to use embedded (default)".into(),
276            ],
277            "docs/plans/phase-C/http-viewer-contract.md",
278        ),
279        UsageErrorKind::UnknownSubcommand { domain, token } => (
280            format!("'{token}' is not a valid {domain} subcommand"),
281            match domain {
282                super::BuiltinDomain::Browsers => vec![
283                    "Use wyvern browsers list or wyvern browsers refresh".into(),
284                    "Run wyvern browsers --help".into(),
285                ],
286                super::BuiltinDomain::Extensions => vec![
287                    "Use wyvern extensions list or wyvern extensions show <id>".into(),
288                    "Run wyvern extensions --help".into(),
289                ],
290            },
291            "docs/wyvern/requirements.md (REQ-0134)",
292        ),
293        UsageErrorKind::MissingExtensionId => (
294            "extensions show requires a shipped or project extension id".to_string(),
295            vec![
296                "Pass an id: wyvern extensions show <id>".into(),
297                "Run wyvern extensions list to see available ids".into(),
298                "Run wyvern extensions list --json for machine-readable ids".into(),
299                "Run wyvern extensions --help".into(),
300            ],
301            "docs/wyvern/requirements.md (REQ-0132)",
302        ),
303    };
304    let mut envelope = StderrError::new(ErrorCode::ParseError, message.clone())
305        .cause(cause)
306        .docs(docs);
307    for step in recovery {
308        envelope = envelope.recovery(step);
309    }
310    envelope.to_json_string().map_err(EmitError::Serialize)
311}
312
313/// Serialize a parse load error as stderr JSON.
314///
315/// # Errors
316///
317/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized, or
318/// when `err` is not [`LoadError::Parse`] (miswire).
319pub fn emit_parse_error(err: &LoadError) -> Result<String, EmitError> {
320    let LoadError::Parse { message } = err else {
321        debug_assert!(matches!(err, LoadError::Parse { .. }));
322        return Err(EmitError::Serialize(SerializeError {
323            message: "emit_parse_error: expected Parse".into(),
324        }));
325    };
326    StderrError::new(ErrorCode::ParseError, message.clone())
327        .cause("Input was not valid JSON")
328        .recovery("Ensure input is valid JSON")
329        .recovery("Check for trailing commas, unquoted keys, or truncated input")
330        .docs("docs/wyvern-schema/requirements.md (REQ-0069)")
331        .to_json_string()
332        .map_err(EmitError::Serialize)
333}
334
335/// Serialize an I/O load error as stderr JSON.
336///
337/// # Errors
338///
339/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized, or
340/// when `err` is not [`LoadError::Io`] (miswire).
341pub fn emit_io_error(err: &LoadError) -> Result<String, EmitError> {
342    let LoadError::Io { field, message, .. } = err else {
343        debug_assert!(matches!(err, LoadError::Io { .. }));
344        return Err(EmitError::Serialize(SerializeError {
345            message: "emit_io_error: expected Io".into(),
346        }));
347    };
348    StderrError::new(ErrorCode::IoError, message.clone())
349        .field(field.clone())
350        .cause(format!("Failed to read input from '{}'", field.as_str()))
351        .recovery("Verify the file path exists and is readable")
352        .recovery("Pass JSON inline as an argv string or via stdin")
353        .docs("docs/wyvern-schema/requirements.md (REQ-0071)")
354        .to_json_string()
355        .map_err(EmitError::Serialize)
356}
357
358/// Serialize a validation/state error as stderr JSON.
359///
360/// # Errors
361///
362/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized.
363pub fn emit_validation_error(err: &ValidationError) -> Result<String, EmitError> {
364    let envelope = match err {
365        ValidationError::Validation { field, message } => {
366            let mut envelope = StderrError::new(ErrorCode::ValidationError, message.clone())
367                .field(field.clone())
368                .cause(format!("Command JSON failed schema checks on '{field}'"))
369                .docs("docs/wyvern-schema/requirements.md (REQ-0051, REQ-0070)");
370            for step in validation_recovery(field.as_str(), message) {
371                envelope = envelope.recovery(step);
372            }
373            envelope
374        }
375        ValidationError::State { field, message } => {
376            StderrError::new(ErrorCode::StateError, message.clone())
377                .field(field.clone())
378                .cause("Lifecycle action used outside interactive mode")
379                .recovery("Run with --interactive to use lifecycle actions (show/hide/exit)")
380                .recovery("Omit the action field for one-shot chrome commands")
381                .docs("docs/wyvern-schema/requirements.md (REQ-0072)")
382        }
383    };
384    envelope.to_json_string().map_err(EmitError::Serialize)
385}
386
387fn validation_recovery(field: &str, message: &str) -> Vec<String> {
388    if field == "title" && message.contains("missing required field") {
389        return vec![
390            "Add required field \"title\" with a string value".into(),
391            "Example: {\"type\":\"chrome\",\"title\":\"Foundation\"}".into(),
392        ];
393    }
394    if field == "type" && message.contains("missing required field") {
395        return vec![
396            "Add required field \"type\" with value \"chrome\"".into(),
397            "Example: {\"type\":\"chrome\",\"title\":\"Foundation\"}".into(),
398        ];
399    }
400    if field == "type" && message.contains("expected one of") {
401        return vec![
402            "Set \"type\" to one of: chrome, message, input, markdown, question, wizard".into(),
403            "Example: {\"type\":\"wizard\",\"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}}"
404                .into(),
405        ];
406    }
407    if field == "page" && message.contains("missing required field") {
408        return vec![
409            "Add required object field \"page\" with id, title, and html".into(),
410            "Example: {\"type\":\"wizard\",\"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}}"
411                .into(),
412        ];
413    }
414    if field == "page" && message.contains("expected object") {
415        return vec!["Provide \"page\" as a JSON object with id, title, and html".into()];
416    }
417    if field == "page.id" {
418        return vec![
419            "Set \"page.id\" to a non-empty string page identity".into(),
420            "Example: \"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}"
421                .into(),
422        ];
423    }
424    if field == "page.title" {
425        return vec![
426            "Set \"page.title\" to a non-empty string display title".into(),
427            "Example: \"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}"
428                .into(),
429        ];
430    }
431    if field == "page.html" {
432        return vec![
433            "Set \"page.html\" to a non-empty path relative to --ui-root".into(),
434            "Example: \"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}"
435                .into(),
436        ];
437    }
438    if field == "page.layout" {
439        return vec!["Set \"page.layout\" to one of: dialog, workspace (or omit the field)".into()];
440    }
441    if field.starts_with("page.") && message.contains("unknown field") {
442        return vec![format!(
443            "Remove unknown field \"{field}\"; page allows only id, title, html, and layout"
444        )];
445    }
446    if field == "buttons" {
447        return vec![
448            "Set \"buttons\" to one of: ok, ok_cancel, yes_no, yes_no_cancel, retry_cancel, custom"
449                .into(),
450        ];
451    }
452    if field == "level" {
453        return vec!["Set \"level\" to one of: info, warning, error, question".into()];
454    }
455    if field == "custom_buttons" {
456        return vec![
457            "Provide \"custom_buttons\" as a string array only when \"buttons\" is \"custom\""
458                .into(),
459        ];
460    }
461    if field == "default_button" {
462        return vec![
463            "Set \"default_button\" to a 0-based index within the active button list".into(),
464        ];
465    }
466    if field == "markdown" {
467        return vec!["Provide \"markdown\" as a JSON boolean (true or false)".into()];
468    }
469    if field == "file" && message.contains("exactly one of") {
470        return vec![
471            "Provide exactly one of \"file\" or \"content\" for markdown commands".into(),
472            "Example: {\"type\":\"markdown\",\"file\":\"doc.md\"}".into(),
473            "Example: {\"type\":\"markdown\",\"content\":\"# Hello\"}".into(),
474        ];
475    }
476    if message.contains("expected string") {
477        return vec![format!("Provide field \"{field}\" as a JSON string")];
478    }
479    if message.contains("unknown field") {
480        return vec![format!(
481            "Remove unknown field \"{field}\"; check the schema for this command type"
482        )];
483    }
484    if message.contains("expected JSON object") {
485        return vec!["Pass a single JSON object as the command payload".into()];
486    }
487    vec![format!(
488        "Fix field \"{field}\" to match the current phase command schema"
489    )]
490}
491
492/// Serialize a successful [`wyvern_schema::CommandResult`] for stdout.
493///
494/// # Errors
495///
496/// Returns [`EmitError::Serialize`] when `result` cannot be serialized.
497pub fn emit_stdout(result: &wyvern_schema::CommandResult) -> Result<String, EmitError> {
498    #[cfg(test)]
499    {
500        if FORCE_EMIT_STDOUT_FAIL.with(std::cell::Cell::get) {
501            return Err(EmitError::Serialize(SerializeError {
502                message: "forced".into(),
503            }));
504        }
505    }
506    serde_json::to_string(result).map_err(|e| {
507        EmitError::Serialize(SerializeError {
508            message: e.to_string(),
509        })
510    })
511}
512
513/// Serialize a [`wyvern_host::HostError`] as stderr JSON (REQ-0073).
514///
515/// # Errors
516///
517/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized.
518pub fn emit_host_error(err: &wyvern_host::HostError) -> Result<String, EmitError> {
519    use wyvern_host::HostError;
520    let (code, message, cause, recovery, docs) = match err {
521        HostError::Bind { message, source } => {
522            let message = match source {
523                Some(err) => format!("{message}: {err}"),
524                None => message.clone(),
525            };
526            (
527                ErrorCode::HostBindError,
528                message,
529                "Failed to bind the dialog HTTP server".to_string(),
530                vec![
531                    "Check that --bind is a valid address".into(),
532                    "Try --bind 127.0.0.1:0 for an ephemeral port".into(),
533                ],
534                "docs/wyvern-host/requirements.md (REQ-0091)",
535            )
536        }
537        HostError::UiNotFound { path, source } => {
538            let message = match source {
539                Some(err) => format!("UI not found at '{}': {err}", path.display()),
540                None => format!("UI not found at '{}'", path.display()),
541            };
542            (
543                ErrorCode::UiNotFound,
544                message,
545                "Packaged UI root, dialog template, or wizard page HTML is missing".to_string(),
546                vec![
547                    "Pass --ui-root pointing at a directory with message/, input/, markdown/, question/, and chrome/ templates".into(),
548                    "For wizard commands, ensure page.html exists relative to --ui-root (served under /wizard/**)".into(),
549                    "Ensure ui/{message,input,markdown,question,chrome}/ exist in the workspace for development".into(),
550                ],
551                "docs/wyvern-host/requirements.md (REQ-0093, REQ-0100)",
552            )
553        }
554        HostError::UnsupportedType { type_name } => (
555            ErrorCode::UnsupportedType,
556            format!("dialog type '{type_name}' is not implemented on the HTTP host yet"),
557            "Schema validation passed; host matrix supports chrome, message, input, markdown, question, and wizard".to_string(),
558            vec![
559                "Use one of: chrome, message, input, markdown, question, wizard".into(),
560            ],
561            "docs/plans/phase-C/http-dialog-contract.md",
562        ),
563        HostError::InvalidResult { message } => (
564            ErrorCode::HostError,
565            message.clone(),
566            "POST /api/result body was invalid for the active dialog".to_string(),
567            vec!["Submit a body matching the dialog CommandResult wire shape".into()],
568            "docs/plans/phase-C/http-post-schema.md",
569        ),
570        HostError::ViewerNotFound { id, hint } => (
571            ErrorCode::HostViewerError,
572            format!("viewer '{id}' not found"),
573            hint.clone(),
574            vec![
575                format!("Install {id} or use --viewer system"),
576                "Use --viewer none for headless / CI".into(),
577            ],
578            "docs/plans/phase-C/http-viewer-contract.md",
579        ),
580        HostError::ViewerUnsupported { mode } => (
581            ErrorCode::HostViewerError,
582            format!(
583                "viewer mode '{}' is not supported by host::run",
584                mode.as_str()
585            ),
586            "Embedded one-shot must use begin + wyvern-viewer spawn (CLI pipeline)".to_string(),
587            vec![
588                "Omit --viewer or use --viewer embedded (CLI default)".into(),
589                "Use --viewer none for headless / CI".into(),
590            ],
591            "docs/plans/phase-C/http-viewer-contract.md",
592        ),
593        HostError::Registry { message } => (
594            ErrorCode::HostError,
595            message.clone(),
596            "Browser registry cache read/write failed".to_string(),
597            vec![
598                "Run `wyvern browsers refresh` to rebuild the cache".into(),
599                "Check WYVERN_BROWSERS_FILE path and cache directory permissions".into(),
600                "Delete a corrupt browsers.json and retry".into(),
601            ],
602            "docs/plans/phase-C/http-viewer-contract.md",
603        ),
604        HostError::Internal { message } => (
605            ErrorCode::HostError,
606            message.clone(),
607            "Internal HTTP host failure".to_string(),
608            vec![
609                "Retry the command".into(),
610                "Report a bug if it persists".into(),
611            ],
612            "docs/wyvern-host/architecture.md",
613        ),
614        HostError::Wizard { source } => {
615            let subcode = source.subcode();
616            (
617                ErrorCode::HostError,
618                format!("{subcode}: {source}"),
619                format!("{subcode}: wizard session failed during host setup or state access"),
620                vec![
621                    format!("See wizard error sub-code {subcode} for the specific failure"),
622                    "Ensure the command is type: wizard with a validated page object".into(),
623                    "Retry the command; report a bug if a validated wizard has no session".into(),
624                ],
625                "docs/plans/phase-C/http-wizard-contract.md",
626            )
627        }
628    };
629
630    let mut envelope = StderrError::new(code, message).cause(cause).docs(docs);
631    for step in recovery {
632        envelope = envelope.recovery(step);
633    }
634    envelope.to_json_string().map_err(EmitError::Serialize)
635}
636
637/// Emit static internal stderr JSON and exit with code 8 (REQ-0078).
638///
639/// Uses a hand-built JSON string so a serialize failure cannot recurse.
640/// Includes `cause` / `recovery` / `docs` per the stderr contract (RBP-F004).
641pub fn emit_fatal_internal(err: &EmitError) -> ! {
642    let EmitError::Serialize(e) = err;
643    let msg_json =
644        serde_json::to_string(&e.message).unwrap_or_else(|_| "\"serialization failed\"".into());
645    eprintln!(
646        r#"{{"error":"internal","code":"INTERNAL_ERROR","message":{msg_json},"cause":"Stdout or stderr JSON serialization failed at the CLI emit boundary","recovery":["Retry the command","Report a bug if the payload is valid JSON but emit still fails"],"docs":"docs/wyvern-schema/requirements.md (REQ-0078)"}}"#
647    );
648    std::process::exit(ErrorCode::InternalError.exit_code());
649}