Skip to content

Index

remedies

Remedy implementations for self-healing.

This package contains all built-in remedies:

Automatic remedies (safe, apply without confirmation): - CreateMissingWorkspaceRemedy: Creates missing workspace directories - CreateMissingParentDirsRemedy: Creates missing parent directories - FixPathSeparatorsRemedy: Fixes Windows path separators on Unix

Suggested remedies (require user confirmation): - SuggestJinjaFixRemedy: Suggests fixes for Jinja template errors

Diagnostic remedies (provide guidance only): - DiagnoseAuthErrorRemedy: Diagnoses authentication failures - DiagnoseMissingCLIRemedy: Diagnoses missing Claude CLI

Classes

Remedy

Bases: Protocol

Protocol for remediation actions.

Remedies diagnose specific error patterns and can optionally apply fixes. Each remedy must implement: - name: Unique identifier - category: AUTOMATIC, SUGGESTED, or DIAGNOSTIC - risk_level: LOW, MEDIUM, or HIGH - description: Human-readable explanation - diagnose(): Check if this remedy applies - preview(): Show what would change - apply(): Make the actual changes - rollback(): Undo changes if needed - generate_diagnostic(): Provide guidance for manual fix

Attributes
name property
name

Unique identifier for this remedy.

category property
category

How this remedy should be applied.

risk_level property
risk_level

Risk level of applying this remedy.

description property
description

Human-readable description of what this remedy does.

Functions
diagnose
diagnose(context)

Check if this remedy applies to the error.

Returns Diagnosis if applicable, None otherwise. The diagnosis includes confidence score and fix suggestion.

Parameters:

Name Type Description Default
context ErrorContext

Error context with diagnostic information.

required

Returns:

Type Description
Diagnosis | None

Diagnosis if this remedy can help, None otherwise.

Source code in src/marianne/healing/remedies/base.py
def diagnose(self, context: "ErrorContext") -> "Diagnosis | None":
    """Check if this remedy applies to the error.

    Returns Diagnosis if applicable, None otherwise.
    The diagnosis includes confidence score and fix suggestion.

    Args:
        context: Error context with diagnostic information.

    Returns:
        Diagnosis if this remedy can help, None otherwise.
    """
    ...
preview
preview(context)

Show what would be changed without making changes.

Parameters:

Name Type Description Default
context ErrorContext

Error context.

required

Returns:

Type Description
str

Human-readable description of planned changes.

Source code in src/marianne/healing/remedies/base.py
def preview(self, context: "ErrorContext") -> str:
    """Show what would be changed without making changes.

    Args:
        context: Error context.

    Returns:
        Human-readable description of planned changes.
    """
    ...
apply
apply(context)

Apply the remedy.

Only called if diagnose() returned a Diagnosis and: - category == AUTOMATIC, or - category == SUGGESTED and user confirmed

Parameters:

Name Type Description Default
context ErrorContext

Error context.

required

Returns:

Type Description
RemedyResult

Result with success status and details.

Source code in src/marianne/healing/remedies/base.py
def apply(self, context: "ErrorContext") -> RemedyResult:
    """Apply the remedy.

    Only called if diagnose() returned a Diagnosis and:
    - category == AUTOMATIC, or
    - category == SUGGESTED and user confirmed

    Args:
        context: Error context.

    Returns:
        Result with success status and details.
    """
    ...
rollback
rollback(result)

Undo the remedy if possible.

Called if remedy was applied but subsequent validation failed.

Parameters:

Name Type Description Default
result RemedyResult

The result from apply().

required

Returns:

Type Description
bool

True if rollback succeeded, False otherwise.

Source code in src/marianne/healing/remedies/base.py
def rollback(self, result: RemedyResult) -> bool:
    """Undo the remedy if possible.

    Called if remedy was applied but subsequent validation failed.

    Args:
        result: The result from apply().

    Returns:
        True if rollback succeeded, False otherwise.
    """
    ...
generate_diagnostic
generate_diagnostic(context)

Generate detailed diagnostic message.

Called for DIAGNOSTIC category, or when user declines SUGGESTED.

Parameters:

Name Type Description Default
context ErrorContext

Error context.

required

Returns:

Type Description
str

Formatted guidance for manual fix.

Source code in src/marianne/healing/remedies/base.py
def generate_diagnostic(self, context: "ErrorContext") -> str:
    """Generate detailed diagnostic message.

    Called for DIAGNOSTIC category, or when user declines SUGGESTED.

    Args:
        context: Error context.

    Returns:
        Formatted guidance for manual fix.
    """
    ...

RemedyCategory

Bases: str, Enum

Determines how a remedy is applied.

AUTOMATIC: Applied without asking (safe, reversible operations) SUGGESTED: Requires user confirmation (modifies files) DIAGNOSTIC: Cannot auto-fix, provides guidance only

RemedyResult dataclass

RemedyResult(success, message, action_taken, rollback_command=None, created_paths=list(), modified_files=list(), backup_paths=list())

Result of applying a remedy.

Tracks what was done and how to undo it if needed.

Attributes
success instance-attribute
success

Whether the remedy was applied successfully.

message instance-attribute
message

Human-readable description of what happened.

action_taken instance-attribute
action_taken

Brief description of the action taken.

rollback_command class-attribute instance-attribute
rollback_command = None

Shell command to undo the remedy (if possible).

created_paths class-attribute instance-attribute
created_paths = field(default_factory=list)

Paths that were created by the remedy.

modified_files class-attribute instance-attribute
modified_files = field(default_factory=list)

Files that were modified by the remedy.

backup_paths class-attribute instance-attribute
backup_paths = field(default_factory=list)

Backup files created before modification.

Functions
__str__
__str__()

Human-readable string representation.

Source code in src/marianne/healing/remedies/base.py
def __str__(self) -> str:
    """Human-readable string representation."""
    status = "✓" if self.success else "✗"
    return f"[{status}] {self.action_taken}: {self.message}"

RiskLevel

Bases: str, Enum

Risk level of applying the remedy.

Used to inform users about the potential impact of the fix.

DiagnoseAuthErrorRemedy

Bases: BaseRemedy

Diagnoses authentication failures.

Triggers when: - Error relates to API key or authentication - Error code indicates auth failure

This is DIAGNOSTIC only because: - We cannot create API keys - User must configure authentication themselves

Functions
diagnose
diagnose(context)

Check for authentication-related errors.

Source code in src/marianne/healing/remedies/diagnostics.py
def diagnose(self, context: "ErrorContext") -> Diagnosis | None:
    """Check for authentication-related errors."""
    # Check for auth-related error codes
    auth_codes = ("E101", "E102", "E401", "E403")  # Rate limit / auth codes
    if context.error_code in auth_codes:
        return self._diagnose_from_code(context)

    # Check message patterns
    auth_patterns = [
        r"api.?key",
        r"auth.*failed",
        r"authentication",
        r"unauthorized",
        r"invalid.*key",
        r"missing.*key",
        r"401",
        r"403",
        r"ANTHROPIC_API_KEY",
    ]

    message_lower = context.error_message.lower()
    if any(re.search(p, message_lower, re.IGNORECASE) for p in auth_patterns):
        return self._diagnose_from_message(context)

    return None
apply
apply(context)

Diagnostic only - no automatic fix.

Source code in src/marianne/healing/remedies/diagnostics.py
def apply(self, context: "ErrorContext") -> RemedyResult:
    """Diagnostic only - no automatic fix."""
    self.diagnose(context)
    return RemedyResult(
        success=True,
        message="Diagnostic information provided",
        action_taken="diagnosis only",
    )

DiagnoseMissingCLIRemedy

Bases: BaseRemedy

Diagnoses missing Claude CLI.

Triggers when: - Error indicates Claude CLI not found - Backend is configured for CLI but binary missing

This is DIAGNOSTIC only because: - Installation requires system-level changes - User should verify installation method for their system

Functions
diagnose
diagnose(context)

Check for missing CLI errors.

Source code in src/marianne/healing/remedies/diagnostics.py
def diagnose(self, context: "ErrorContext") -> Diagnosis | None:
    """Check for missing CLI errors."""
    # Check for CLI-related error codes
    # Check if it's specifically about CLI
    if context.error_code in ("E601", "E901") and self._is_cli_error(context.error_message):
        return self._create_diagnosis(context)

    # Check message patterns
    cli_patterns = [
        r"claude.*not found",
        r"command.*claude.*not found",
        r"executable.*claude.*not found",
        r"cannot find.*claude",
        r"cli.*not.*installed",
    ]

    message_lower = context.error_message.lower()
    if any(re.search(p, message_lower, re.IGNORECASE) for p in cli_patterns):
        return self._create_diagnosis(context)

    return None
apply
apply(context)

Diagnostic only - no automatic fix.

Source code in src/marianne/healing/remedies/diagnostics.py
def apply(self, context: "ErrorContext") -> RemedyResult:
    """Diagnostic only - no automatic fix."""
    return RemedyResult(
        success=True,
        message="Diagnostic information provided",
        action_taken="diagnosis only",
    )

SuggestJinjaFixRemedy

Bases: BaseRemedy

Suggests fixes for Jinja template errors.

Triggers when: - Error relates to Jinja template rendering - Error message contains template syntax or undefined variable info

This is a SUGGESTED remedy because: - Fixing templates requires modifying config files - User should verify the suggested fix is correct

Functions
diagnose
diagnose(context)

Check for Jinja template issues.

Source code in src/marianne/healing/remedies/jinja.py
def diagnose(self, context: "ErrorContext") -> Diagnosis | None:
    """Check for Jinja template issues."""
    # Check for Jinja-related error codes
    jinja_codes = ("E304", "E305", "E201")  # Template errors
    # Also check message patterns
    if (
        context.error_code not in jinja_codes
        and not self._is_jinja_error(context.error_message)
    ):
        return None

    # Try to identify specific Jinja issues
    diagnosis = self._diagnose_syntax_error(context)
    if diagnosis:
        return diagnosis

    diagnosis = self._diagnose_undefined_variable(context)
    if diagnosis:
        return diagnosis

    diagnosis = self._diagnose_unclosed_block(context)
    if diagnosis:
        return diagnosis

    return None
apply
apply(context)

This remedy only suggests - user must manually fix.

The actual fix requires modifying the config file, which should be done by the user or with their explicit approval.

Source code in src/marianne/healing/remedies/jinja.py
def apply(self, context: "ErrorContext") -> RemedyResult:
    """This remedy only suggests - user must manually fix.

    The actual fix requires modifying the config file, which
    should be done by the user or with their explicit approval.
    """
    diagnosis = self.diagnose(context)
    if not diagnosis:
        return RemedyResult(
            success=False,
            message="Could not diagnose Jinja error",
            action_taken="nothing",
        )

    # For typo fixes with high confidence, we could offer to fix
    # But for now, this is guidance-only
    return RemedyResult(
        success=True,
        message=f"Suggested fix: {diagnosis.suggestion}",
        action_taken="suggestion provided",
    )

CreateMissingParentDirsRemedy

Bases: BaseRemedy

Creates missing parent directories for validation paths.

Triggers when: - Error relates to a file path that doesn't exist - The missing path is for an output/validation file - Multiple directories need to be created

Slightly lower confidence than workspace remedy since it creates potentially multiple directories.

Functions
diagnose
diagnose(context)

Check for missing parent directories in validation paths.

Source code in src/marianne/healing/remedies/paths.py
def diagnose(self, context: "ErrorContext") -> Diagnosis | None:
    """Check for missing parent directories in validation paths."""
    # Look for path-related error codes
    if context.error_code not in ("E601", "E201", "E302"):
        return None

    # Try to extract path from error message
    path_patterns = [
        r"directory.*'([^']+)'.*does not exist",
        r"parent.*'([^']+)'.*missing",
        r"cannot create.*'([^']+)'",
        r"path '([^']+)' not found",
    ]

    missing_path = None
    for pattern in path_patterns:
        match = re.search(pattern, context.error_message, re.IGNORECASE)
        if match:
            missing_path = Path(match.group(1))
            break

    if missing_path is None:
        return None

    # Skip if path already exists
    if missing_path.exists():
        return None

    # Find the highest non-existent parent
    dirs_to_create: list[Path] = []
    current = missing_path if missing_path.suffix == "" else missing_path.parent

    while current and not current.exists():
        dirs_to_create.insert(0, current)
        current = current.parent

    if not dirs_to_create:
        return None

    return Diagnosis(
        error_code=context.error_code,
        issue=f"Parent directories missing for: {missing_path}",
        explanation=(
            f"Need to create {len(dirs_to_create)} director(ies): "
            f"{', '.join(str(d) for d in dirs_to_create)}"
        ),
        suggestion=f"Create parent directories: mkdir -p {dirs_to_create[-1]}",
        confidence=0.85,  # Good confidence
        remedy_name=self.name,
        requires_confirmation=False,
        context={"paths_to_create": [str(p) for p in dirs_to_create]},
    )
apply
apply(context)

Create missing parent directories.

Source code in src/marianne/healing/remedies/paths.py
def apply(self, context: "ErrorContext") -> RemedyResult:
    """Create missing parent directories."""
    diagnosis = self.diagnose(context)
    if not diagnosis or not diagnosis.context.get("paths_to_create"):
        return RemedyResult(
            success=False,
            message="Could not determine directories to create",
            action_taken="nothing",
        )

    paths_to_create = [Path(p) for p in diagnosis.context["paths_to_create"]]
    created: list[Path] = []

    try:
        for path in paths_to_create:
            if not path.exists():
                path.mkdir(parents=True, exist_ok=True)
                created.append(path)

        return RemedyResult(
            success=True,
            message=f"Created {len(created)} director(ies)",
            action_taken=f"mkdir -p {paths_to_create[-1]}",
            rollback_command=f"rmdir {' '.join(str(p) for p in reversed(created))}",
            created_paths=created,
        )
    except OSError as e:
        return RemedyResult(
            success=False,
            message=f"Failed to create directories: {e}",
            action_taken="mkdir failed",
            created_paths=created,
        )

CreateMissingWorkspaceRemedy

Bases: BaseRemedy

Creates missing workspace directories.

Triggers when: - Error code E601 (PREFLIGHT_PATH_MISSING) - Error message mentions workspace directory - Parent directory exists (so we're not creating deep trees)

This is the highest-confidence, lowest-risk remedy - creating a single directory is always safe and reversible.

Functions
diagnose
diagnose(context)

Check if workspace is missing but parent exists.

Source code in src/marianne/healing/remedies/paths.py
def diagnose(self, context: "ErrorContext") -> Diagnosis | None:
    """Check if workspace is missing but parent exists."""
    # Check for workspace-related error codes
    if context.error_code not in ("E601", "E201"):
        return None

    # Check for workspace-related message patterns
    workspace_patterns = [
        r"workspace.*does not exist",
        r"workspace.*not found",
        r"missing.*workspace",
        r"directory.*does not exist.*workspace",
    ]

    message_lower = context.error_message.lower()
    if not any(re.search(p, message_lower) for p in workspace_patterns):
        return None

    # Get the workspace path
    workspace = context.workspace
    if workspace is None:
        return None

    # Already exists - no fix needed
    if workspace.exists():
        return None

    # Parent must exist for this simple fix
    if not workspace.parent.exists():
        return None

    return Diagnosis(
        error_code=context.error_code,
        issue=f"Workspace directory does not exist: {workspace}",
        explanation="The configured workspace directory hasn't been created yet. "
        "This is common on first run or when using a new workspace path.",
        suggestion=f"Create directory: {workspace}",
        confidence=0.95,  # Very high confidence for this pattern
        remedy_name=self.name,
        requires_confirmation=False,
        context={"workspace_path": str(workspace)},
    )
apply
apply(context)

Create the workspace directory.

Source code in src/marianne/healing/remedies/paths.py
def apply(self, context: "ErrorContext") -> RemedyResult:
    """Create the workspace directory."""
    workspace = context.workspace
    if workspace is None:
        return RemedyResult(
            success=False,
            message="No workspace path in context",
            action_taken="nothing",
        )

    try:
        workspace.mkdir(parents=False, exist_ok=False)
        return RemedyResult(
            success=True,
            message=f"Created workspace directory: {workspace}",
            action_taken=f"mkdir {workspace}",
            rollback_command=f"rmdir {workspace}",
            created_paths=[workspace],
        )
    except FileExistsError:
        return RemedyResult(
            success=True,
            message=f"Workspace already exists: {workspace}",
            action_taken="no change needed",
        )
    except OSError as e:
        return RemedyResult(
            success=False,
            message=f"Failed to create workspace: {e}",
            action_taken="mkdir failed",
        )

FixPathSeparatorsRemedy

Bases: BaseRemedy

Fixes Windows path separators on Unix systems.

Triggers when: - Running on Unix (not Windows) - Paths in config contain backslashes - Error relates to file not found

This is an automatic fix because it's non-destructive (only affects in-memory config, not files on disk).

Functions
diagnose
diagnose(context)

Check for Windows path separators on Unix.

Source code in src/marianne/healing/remedies/paths.py
def diagnose(self, context: "ErrorContext") -> Diagnosis | None:
    """Check for Windows path separators on Unix."""
    import sys

    # Only relevant on Unix
    if sys.platform == "win32":
        return None

    # Look for path-related errors
    if context.error_code not in ("E601", "E201", "E302", "E303"):
        return None

    # Check if error message contains backslashes
    if "\\" not in context.error_message:
        return None

    # Extract the problematic path
    path_match = re.search(r"[A-Za-z]?[:\\][^\s'\"]+", context.error_message)
    if not path_match:
        return None

    bad_path = path_match.group(0)
    fixed_path = bad_path.replace("\\", "/")

    return Diagnosis(
        error_code=context.error_code,
        issue=f"Windows-style path separators detected: {bad_path}",
        explanation="Backslash path separators don't work on Unix systems.",
        suggestion=f"Convert to Unix path: {fixed_path}",
        confidence=0.90,
        remedy_name=self.name,
        requires_confirmation=False,
        context={
            "original_path": bad_path,
            "fixed_path": fixed_path,
        },
    )
apply
apply(context)

Note: This remedy doesn't modify files - it's informational.

The actual fix needs to be applied in the config loader. This remedy serves to diagnose and suggest the fix.

Source code in src/marianne/healing/remedies/paths.py
def apply(self, context: "ErrorContext") -> RemedyResult:
    """Note: This remedy doesn't modify files - it's informational.

    The actual fix needs to be applied in the config loader.
    This remedy serves to diagnose and suggest the fix.
    """
    diagnosis = self.diagnose(context)
    if not diagnosis:
        return RemedyResult(
            success=False,
            message="No Windows paths detected",
            action_taken="nothing",
        )

    # This remedy is informational - the actual fix needs to happen
    # in the config file, not at runtime
    return RemedyResult(
        success=True,
        message=f"Detected Windows path: {diagnosis.context['original_path']}. "
        f"Update config file to use: {diagnosis.context['fixed_path']}",
        action_taken="diagnosis provided",
    )