Skip to content

Instantly share code, notes, and snippets.

@anon987654321
Last active January 26, 2026 23:23
Show Gist options
  • Select an option

  • Save anon987654321/a298a9635c219b0a9ee2b1abe0379513 to your computer and use it in GitHub Desktop.

Select an option

Save anon987654321/a298a9635c219b0a9ee2b1abe0379513 to your computer and use it in GitHub Desktop.

1

# `master.yml` v0.3.0 (`claude-sonnet-4.5`, `grok-code-fast-1`)

meta:
  name: "master.yml"
  version: "0.3.0"
  purpose: "Constitutional AI governance for development"
  frozen_sections: [meta, evidence, security]
  modification_requires: "express_written_permission"

# CRITICAL: Core Principles (20 essential + 367 referenced)
# Ordered by criticality: Security → Architecture → Code Quality → Style
principles:
  # Security (Most Critical)
  validate_inputs: "Validate all inputs at system boundaries"
  least_privilege: "Grant minimum access required for task"
  no_secrets: "Never hardcode credentials or API keys"
  sanitize_output: "Sanitize error messages to prevent token exposure"
  
  # SOLID (Architecture Foundation)
  single_responsibility: "Each component has one reason to change"
  open_closed: "Open for extension, closed for modification"
  liskov_substitution: "Subtypes must be substitutable for base types"
  interface_segregation: "Many specific interfaces over one general"
  dependency_inversion: "Depend on abstractions, not concretions"
  
  # Design Fundamentals
  dry: "Don't repeat yourself - single source of truth"
  kiss: "Keep it simple - simplest working solution wins"
  yagni: "You aren't gonna need it - no speculative features"
  pola: "Principle of least astonishment - be predictable"
  
  # UNIX Philosophy
  do_one_thing: "Each tool does one thing well"
  composable: "Tools compose via text streams"
  silent_success: "Successful operations produce no output"
  loud_failure: "Errors are explicit and actionable"
  
  # Clean Code
  meaningful_names: "Names reveal intent without comments"
  small_functions: "Functions under 20 lines, ideally under 10"
  minimal_params: "Functions take 3 or fewer parameters"
  no_magic: "No magic numbers or strings - use named constants"
  
  # Rails Doctrine
  convention_over_config: "Follow framework conventions"
  restful_design: "Resources and standard HTTP verbs"
  
  # Extended principles (referenced, not duplicated)
  # See: Robert C. Martin - Clean Code (150 principles)
  # See: Martin Fowler - Refactoring (89 principles)
  # See: Pragmatic Programmer (78 principles)
  # See: Gang of Four - Design Patterns (50 principles)
  # Total: 387 principles maintained in lineage

# CRITICAL: Evidence-Based Validation
evidence:
  formula: "(tests × 0.50) + (static × 0.30) + (complexity × 0.20)"
  
  weights:
    tests: 0.50        # Test suite passing (highest priority)
    static: 0.30       # Linter clean (security + style)
    complexity: 0.20   # Cyclomatic complexity (maintainability)
  
  thresholds:
    strict: 1.00       # All checks pass
    production: 0.95   # 95% compliance (allows minor warnings)
    development: 0.90  # 90% compliance (iterative improvement)
  
  tolerance: 0.02      # 2% variance allowed for floating point
  
  complexity_normalization: "1.0 - min(1.0, avg_complexity / 20.0)"
  explanation: "Complexity 20+ = 0% score, 0 = 100% score, linear scale"
  
  required_checks:
    - tests_pass           # Exit code 0 from test suite
    - rubocop_clean        # Zero critical offenses
    - no_critical_vulnerabilities  # Brakeman/scanner clean
    - complexity_acceptable  # Avg flog < 20

# CRITICAL: Security Configuration
security:
  platform:
    openbsd:
      unveil:
        sandbox:
          - path: "."
            permissions: "r"
            rationale: "Read-only current directory"
          - path: "/tmp"
            permissions: "rwc"
            rationale: "Temp files need read/write/create"
        
        user:
          - path: "~"
            permissions: "rwc"
            rationale: "User home directory full access"
          - path: "."
            permissions: "rwc"
            rationale: "Project directory full access"
          - path: "/tmp"
            permissions: "rwc"
            rationale: "Temp files"
          - path: "~/.convergence"
            permissions: "rwc"
            rationale: "Config and backups"
        
        admin:
          - path: "/"
            permissions: "r"
            rationale: "Read system files"
          - path: "~"
            permissions: "rwc"
            rationale: "User home"
          - path: "/tmp"
            permissions: "rwc"
            rationale: "Temp files"
          - path: "/usr/local"
            permissions: "rx"
            rationale: "Execute binaries"
          - path: "/var"
            permissions: "rwc"
            rationale: "Logs and runtime data"
      
      pledge:
        sandbox: "stdio rpath"
        user: "stdio rpath wpath cpath"
        admin: "stdio rpath wpath cpath inet dns proc exec"
        rationale: "Irreversible restrictions - order matters (unveil then pledge)"
    
    linux:
      seccomp: "strict_mode"
      apparmor: "enforce"
    
    cygwin:
      acl: "restrictive"
  
  token_sanitization:
    patterns:
      - pattern: "Token [a-zA-Z0-9_-]{32,}"
        replacement: "Token [REDACTED]"
      - pattern: "Bearer [a-zA-Z0-9_-]{32,}"
        replacement: "Bearer [REDACTED]"
      - pattern: "sk-[a-zA-Z0-9]{32,}"
        replacement: "sk-[REDACTED]"
    
    apply_to:
      - error_messages
      - log_files
      - stdout_stderr
    
    never_sanitize:
      - internal_debug_logs  # Only when DEBUG=1 and writing to secure file

openrouter:
  api_url: "https://openrouter.ai/api/v1/chat/completions"
  
  models:
    fast:
      name: "openai/gpt-4o-mini"
      cost_per_1m_prompt: 0.15
      cost_per_1m_completion: 0.60
      cost_per_1m_cached: 0.015  # 90% savings
      use_case: "Quick scans, simple fixes"
    
    balanced:
      name: "deepseek/deepseek-r1"
      cost_per_1m_prompt: 0.55
      cost_per_1m_completion: 2.19
      cost_per_1m_cached: 0.055
      use_case: "Default reasoning, good balance"
    
    deep:
      name: "anthropic/claude-3.5-sonnet"
      cost_per_1m_prompt: 3.00
      cost_per_1m_completion: 15.00
      cost_per_1m_cached: 0.30
      use_case: "Complex refactoring, architectural decisions"
    
    experimental:
      name: "google/gemini-flash-1.5"
      cost_per_1m_prompt: 0.00  # Free tier
      cost_per_1m_completion: 0.00
      use_case: "Testing, development"
  
  prompt_caching:
    enabled: true
    min_tokens: 2048
    ttl_seconds: 300  # 5 minutes
    cache_master_yml: true
    savings_percentage: 90
    
    strategy: |
      Cache master.yml (30k+ chars) as ephemeral context.
      First call: Full cost + cache creation overhead.
      Subsequent calls (within 5min): 10% cost.
      
      Implementation:
        messages: [
          {
            role: "user",
            content: [
              {
                type: "text",
                text: "Constitutional Governance:\n\n#{master_yml_content}",
                cache_control: { type: "ephemeral" }
              },
              {
                type: "text", 
                text: "Analyze this code: #{code}"
              }
            ]
          }
        ]
  
  headers:
    required:
      Authorization: "Bearer ${OPENROUTER_API_KEY}"
      Content-Type: "application/json"
    
    recommended:
      HTTP-Referer: "https://github.com/anon987654321/pub4"
      X-Title: "Convergence CLI v0.3"
  
  timeout:
    connect: 10
    read: 60
    write: 30
  
  retry:
    max_attempts: 3
    backoff: "exponential"
    initial_delay: 1
    max_delay: 30
  
  error_handling:
    rate_limit:
      wait_and_retry: true
      max_wait: 120
    
    token_limit_exceeded:
      action: "truncate_context"
      keep_master_yml: true
    
    network_error:
      action: "fallback_to_local"

# Multi-Perspective Decision Making
personas:
  decision_method: "weighted_consensus"
  consensus_threshold: 0.70
  veto_enabled: true
  
  roles:
    - name: "security_auditor"
      weight: 0.30
      veto: true
      temperature: 0.1
      focus: "vulnerabilities, access_control, secrets, token_exposure"
      activation: "always"
    
    - name: "architect"
      weight: 0.25
      veto: true
      temperature: 0.3
      focus: "structure, patterns, maintainability, solid_principles"
      activation: "on complexity > 15 or file > 200 lines"
    
    - name: "ux_designer"
      weight: 0.20
      veto: false
      temperature: 0.5
      focus: "usability, clarity, accessibility, error_messages"
      activation: "on CLI command or user-facing code"
    
    - name: "performance_engineer"
      weight: 0.15
      veto: false
      temperature: 0.3
      focus: "efficiency, resource_usage, scalability, caching"
      activation: "on hot_path or n_plus_one"
    
    - name: "pragmatist"
      weight: 0.05
      veto: false
      temperature: 0.7
      focus: "deadlines, technical_debt, trade_offs"
      activation: "on deadline pressure"
    
    - name: "maintainer"
      weight: 0.05
      veto: false
      temperature: 0.5
      focus: "documentation, simplicity, onboarding, comments"
      activation: "on public_api or complex_logic"

# NEW in v0.3: Dynamic Profile Switching
profiles:
  architect:
    inherits: ["architect", "maintainer"]
    temperature: 0.3
    focus: ["structure", "patterns", "documentation"]
    activation: "manual or on scan with >10 defects"
    model: "anthropic/claude-3.5-sonnet"
    prompt_prefix: "You are a senior software architect. Focus on SOLID principles and long-term maintainability."
  
  security_auditor:
    inherits: ["security_auditor"]
    temperature: 0.1
    focus: ["vulnerabilities", "exploits", "attack_vectors"]
    activation: "on critical severity or manual request"
    model: "anthropic/claude-3.5-sonnet"
    prompt_prefix: "You are a paranoid security auditor. Find every possible vulnerability."
  
  pragmatist:
    inherits: ["pragmatist", "performance_engineer"]
    temperature: 0.7
    focus: ["quick_fixes", "trade_offs", "deadlines"]
    activation: "on user request 'quick fix' or 'fast'"
    model: "openai/gpt-4o-mini"
    prompt_prefix: "You are a pragmatic developer. Suggest the fastest working solution."
  
  refactorer:
    inherits: ["architect", "maintainer"]
    temperature: 0.4
    focus: ["extract_method", "rename_variable", "simplify"]
    activation: "on complexity > 20 or long_method"
    model: "deepseek/deepseek-r1"
    prompt_prefix: "You are a refactoring specialist. Make code simpler and clearer."
  
  tester:
    inherits: ["maintainer"]
    temperature: 0.3
    focus: ["test_coverage", "edge_cases", "assertions"]
    activation: "on coverage < 80% or new public method"
    model: "openai/gpt-4o-mini"
    prompt_prefix: "You are a test engineer. Write comprehensive test cases."

# Adversarial Testing Personas
adversarial:
  method: "multi_temperature_synthesis"
  purpose: "Challenge assumptions and find edge cases"
  
  personas:
    - name: "skeptic"
      temperature: 0.1
      asks: "What evidence contradicts this?"
      focus: "false_assumptions"
    
    - name: "paranoid_security"
      temperature: 0.1
      asks: "How can this be exploited?"
      focus: "attack_vectors"
    
    - name: "perfectionist"
      temperature: 0.3
      asks: "What's the ideal solution regardless of cost?"
      focus: "best_practices"
    
    - name: "pragmatist"
      temperature: 0.5
      asks: "What's the minimum viable solution?"
      focus: "time_constraints"
    
    - name: "junior_dev"
      temperature: 0.7
      asks: "Will I understand this in 6 months?"
      focus: "clarity"
    
    - name: "chaos_monkey"
      temperature: 0.9
      asks: "What if everything that can fail, does?"
      focus: "failure_modes"
    
    - name: "user_advocate"
      temperature: 0.7
      asks: "Is this actually solving a user problem?"
      focus: "user_value"
    
    - name: "ops_engineer"
      temperature: 0.3
      asks: "Can I debug this at 3am?"
      focus: "debuggability"
    
    - name: "business_analyst"
      temperature: 0.5
      asks: "Does this deliver value?"
      focus: "roi"
    
    - name: "accessibility_specialist"
      temperature: 0.5
      asks: "Can everyone use this?"
      focus: "universal_design"

# Cognitive Bias Mitigation
biases:
  - name: "anchoring"
    question: "What if we started from scratch?"
    mitigation: "Consider alternatives before evaluating"
    detection: "First solution proposed becomes default"
  
  - name: "confirmation"
    question: "What evidence contradicts this?"
    mitigation: "Actively seek disconfirming evidence"
    detection: "Only citing supporting examples"
  
  - name: "sunk_cost"
    question: "Would we choose this today?"
    mitigation: "Evaluate based on current state only"
    detection: "Justifying with past investment"
  
  - name: "availability"
    question: "How common is this really?"
    mitigation: "Use systematic data not recent examples"
    detection: "Recent event driving decision"
  
  - name: "recency"
    question: "What does long-term data show?"
    mitigation: "Review 12-month patterns"
    detection: "Only considering last week"
  
  - name: "bandwagon"
    question: "Why is everyone doing this?"
    mitigation: "Evaluate independently"
    detection: "Because everyone else does"
  
  - name: "dunning_kruger"
    question: "What's our actual expertise level?"
    mitigation: "State explicit uncertainty"
    detection: "Overconfident in unfamiliar domain"
  
  - name: "framing"
    question: "How else could we frame this?"
    mitigation: "Reframe multiple ways"
    detection: "Presentation affecting judgment"
  
  - name: "optimism"
    question: "What could go wrong?"
    mitigation: "Plan for failure modes"
    detection: "No contingency plans"

# Defect Catalog with Auto-Fix Remedies
# Ordered by severity: Critical → High → Medium → Low
catalog:
  system:
    injection_vulnerability:
      severity: "critical"
      remedy: "sanitize_input"
      auto_fix: true
      implementation: "wrap_with_shellwords_escape"
      pattern: 'system\(|exec\(|`[^`]*\$|eval\(|IO\.popen'
      replacement: 'Shellwords.escape(\1)'
      explanation: "Shell injection via unescaped input"
      cwe: "CWE-78"
    
    token_exposure:
      severity: "critical"
      remedy: "sanitize_error_messages"
      auto_fix: true
      implementation: "redact_tokens_in_output"
      pattern: 'Token [a-zA-Z0-9_-]{32,}|Bearer [a-zA-Z0-9_-]{32,}|sk-[a-zA-Z0-9]{32,}'
      replacement: '[REDACTED]'
      explanation: "API tokens exposed in error messages"
      cwe: "CWE-532"
    
    hardcoded_secret:
      severity: "critical"
      remedy: "use_env_var"
      auto_fix: true
      implementation: "replace_with_env_fetch"
      pattern: 'password\s*=\s*["'][^"']+["']|api_key\s*=\s*["'][^"']+["']'
      replacement: 'ENV.fetch("\1") { raise "Set \1 env var" }'
      explanation: "Hardcoded credentials in source code"
      cwe: "CWE-798"
    
    race_condition:
      severity: "high"
      remedy: "add_mutex_or_file_lock"
      auto_fix: true
      implementation: "wrap_with_mutex"
      pattern: '@@\w+|Thread\.new(?!\s*\{)'
      explanation: "Concurrent access to shared state"
      cwe: "CWE-362"
    
    sql_injection:
      severity: "high"
      remedy: "use_parameterized_queries"
      auto_fix: false
      reason: "Requires understanding query structure"
      pattern: '["']\s*\+\s*\w+|execute\([^?]*\#{|where\([^?]*\#{'
      explanation: "SQL injection via string interpolation"
      cwe: "CWE-89"
    
    xss_vulnerability:
      severity: "high"
      remedy: "sanitize_html"
      auto_fix: true
      implementation: "remove_html_safe_or_wrap"
      pattern: 'html_safe|raw\(|<%=\s*@\w+\s*%>'
      explanation: "XSS via unsanitized user input"
      cwe: "CWE-79"
  
  logic:
    yaml_in_ruby_file:
      severity: "high"
      remedy: "rename_file_or_wrap_heredoc"
      auto_fix: true
      implementation: "detect_yaml_syntax_in_rb"
      pattern: '^[a-z_]+:\s+.+$'
      explanation: "YAML syntax in .rb file - Ruby cannot parse"
      fix_options:
        - "Rename to .yml extension"
        - "Wrap in CONFIG = <<~YAML ... YAML heredoc"
        - "Convert to Ruby hash syntax"
    
    hardcoded_path:
      severity: "medium"
      remedy: "use_paths_module"
      auto_fix: true
      implementation: "replace_with_paths_for"
      pattern: '["''](?:/sdcard|/home/[a-z]+|C:/Users|/var/www)'
      replacement: 'Paths.for(:key)'
      explanation: "Platform-specific path breaks cross-platform compatibility"
    
    type_mismatch:
      severity: "medium"
      remedy: "add_type_annotation"
      auto_fix: true
      implementation: "insert_type_comment"
      explanation: "Unexpected type in operation"
    
    unsafe_cast:
      severity: "medium"
      remedy: "add_guard_clause"
      auto_fix: true
      implementation: "insert_nil_check"
      explanation: "Potential nil dereference"
    
    missing_file_lock:
      severity: "medium"
      remedy: "add_flock"
      auto_fix: true
      implementation: "wrap_with_lock_file"
      pattern: '^(?!.*flock\s+)'
      explanation: "Concurrent script execution without locking"
  
  philosophy:
    magic_number:
      severity: "medium"
      remedy: "extract_constant"
      auto_fix: true
      implementation: "extract_to_constant"
      pattern: '(?<!\d)(\d{3,})(?!\d)'
      constant_name_strategy: "prompt_user_or_infer"
      explanation: "Literal number without explanation"
    
    duplicated_logic:
      severity: "medium"
      remedy: "extract_method"
      auto_fix: true
      implementation: "refactor_extract_method"
      min_duplication_threshold: 3
      explanation: "Same code in multiple places"
    
    long_method:
      severity: "low"
      remedy: "decompose_function"
      auto_fix: false
      reason: "Requires understanding of logical boundaries"
      threshold: 20
      explanation: "Method exceeds 20 lines"
    
    god_class:
      severity: "low"
      remedy: "split_responsibilities"
      auto_fix: false
      reason: "Requires architectural decisions"
      threshold: 300
      explanation: "Class exceeds 300 lines"

# NEW in v0.3: Domain-Specific Constants
# Extracted from pub4 codebase with explanations
domain_constants:
  audio_production:
    dilla_swing:
      value: 0.542
      unit: "ratio"
      explanation: "54.2% swing - J Dilla's signature timing offset"
      source: "Fantastic Vol 2 quantization analysis"
      reference: "https://www.attackmagazine.com/technique/passing-notes/dilla-swing/"
    
    tempo_sweet_spot:
      min: 82
      max: 92
      unit: "bpm"
      explanation: "Tempo range for classic boom-bap hip-hop"
      source: "Donuts album tempo analysis (avg 88 BPM)"
    
    kick_nudge:
      value: -8
      unit: "milliseconds"
      explanation: "Kick drum pushed 8ms early for punch"
      source: "SP-1200 timing analysis"
    
    snare_nudge:
      value: 12
      unit: "milliseconds"
      explanation: "Snare pushed 12ms late for laid-back feel"
      source: "Dilla's MPC timing patterns"
  
  film_emulation:
    log_offset:
      value: 3
      unit: "stops"
      explanation: "Offset for log-to-linear conversion (Cineon standard)"
      source: "Kodak Cineon Format Specification v4.5"
      reference: "https://www.kodak.com/motion/support/technical_information/"
    
    grain_sizes:
      kodak_portra: 15
      kodak_vision3_50d: 8
      kodak_vision3_500t: 20
      tri_x: 25
      unit: "grain_intensity"
      explanation: "Film stock grain characteristics"
      source: "Kodak technical datasheets"
  
  rails_ports:
    base: 10000
    max: 19999
    strategy: "sequential_allocation"
    explanation: "Port range for Rails apps on OpenBSD"
    rationale: "Avoids system ports (<1024) and common services"

paths:
  strategy: "detect_platform_then_resolve"
  fallback: "user_home_relative"
  
  platform_defaults:
    termux:
      samples: "/sdcard/music/samples"
      rails: "/data/data/com.termux/files/home/rails"
      logs: "/data/data/com.termux/files/home/.convergence/logs"
      config: "/data/data/com.termux/files/home/.convergence"
    
    cygwin:
      samples: "/cygdrive/c/Users/${USER}/Music/samples"
      rails: "/home/${USER}/rails"
      logs: "${HOME}/.convergence/logs"
      config: "${HOME}/.convergence"
    
    openbsd:
      samples: "/home/${USER}/music/samples"
      rails: "/var/www"
      logs: "/home/${USER}/.convergence/logs"
      config: "/home/${USER}/.convergence"
    
    linux:
      samples: "${HOME}/music/samples"
      rails: "${HOME}/rails"
      logs: "${HOME}/.convergence/logs"
      config: "${HOME}/.convergence"
    
    macos:
      samples: "${HOME}/Music/samples"
      rails: "${HOME}/rails"
      logs: "${HOME}/.convergence/logs"
      config: "${HOME}/.convergence"
  
  validation:
    - check_exists: true
    - create_if_missing: true
    - permissions: "0700"

dependencies:
  convergence:
    required:
      - ruby: ">= 3.0.0"
      - git: ">= 2.0"
    
    optional:
      - rubocop: "for static analysis"
      - flog: "for complexity metrics"
      - brakeman: "for security scanning"
    
    install:
      openbsd: "pkg_add ruby git"
      termux: "pkg install ruby git"
      cygwin: "apt-cyg install ruby git"
      linux: "apt install ruby git"
      macos: "brew install ruby git"
  
  dilla:
    required:
      - ffmpeg: ">= 4.0"
      - sox: ">= 14.4"
    
    optional:
      - lame: "for MP3 encoding"
      - flac: "for FLAC encoding"
    
    install:
      openbsd: "pkg_add ffmpeg sox"
      termux: "pkg install ffmpeg sox"
  
  postpro:
    required:
      - ffmpeg: ">= 4.0"
      - ruby-vips: ">= 2.0"
    
    gems:
      - ruby-vips
    
    install:
      openbsd: "pkg_add ffmpeg vips"
      termux: "pkg install ffmpeg libvips"
  
  repligen:
    required:
      - curl: ">= 7.0"
    
    env_vars:
      - REPLICATE_API_TOKEN
    
    install:
      openbsd: "pkg_add curl"
      termux: "pkg install curl"

# Severity Levels
severity:
  critical:
    - injection_vulnerability
    - token_exposure
    - hardcoded_secret
    - authentication_bypass
    - data_loss
  
  high:
    - yaml_in_ruby_file
    - race_condition
    - sql_injection
    - xss_vulnerability
    - n_plus_one_query
    - security_misconfiguration
    - unhandled_exception
  
  medium:
    - hardcoded_path
    - type_mismatch
    - unsafe_cast
    - missing_file_lock
    - magic_number
    - duplicated_logic
    - missing_validation
    - inefficient_algorithm
  
  low:
    - long_method
    - god_class
    - unclear_variable_name
    - missing_comment
    - inconsistent_formatting

# Auto-Fix Configuration
auto_fix:
  enabled: true
  
  safety_rules:
    - backup_before_fix: true
    - run_tests_after_fix: true
    - interactive_approval: true
    - max_fixes_per_run: 10
    - require_git_clean: false
  
  approval:
    mode: "interactive"  # interactive, batch, auto
    show_diff: true
    show_context_lines: 3
    default_action: "skip"  # apply, skip
    confirmation_required_for:
      - critical
      - high
  
  backup:
    enabled: true
    location: "~/.convergence/backups"
    retention_days: 7
    naming: "{file}_{timestamp}.bak"
    max_backups_per_file: 10
  
  git:
    enabled: true
    commit_per_fix: false
    batch_commit: true
    commit_message_template: "Fix {count} defects: {types}\n\nConvergence v{version} auto-fix\n\n{details}"
    require_clean_working_tree: false
    auto_push: false

# CLI Configuration
cli:
  modes:
    api:
      name: "API Mode"
      requires: "OPENROUTER_API_KEY"
      provider: "openrouter.ai"
      capabilities:
        - llm_reasoning
        - deep_analysis
        - tool_looping
        - context_retention
        - prompt_caching
      limitations:
        - api_cost
        - rate_limits
        - requires_internet
    
    free:
      name: "Free Mode"
      requires: ["chrome_or_chromium", "internet"]
      provider: "ferrum"
      capabilities:
        - browser_automation
        - page_scraping
        - screenshot_analysis
      limitations:
        - slower_than_api
        - no_deep_reasoning
        - manual_prompting
        - requires_chrome
    
    local:
      name: "Local Mode"
      requires: []
      provider: "pattern_matching"
      capabilities:
        - basic_linting
        - pattern_detection
        - offline_operation
        - auto_fix
        - fast_execution
      limitations:
        - no_llm_reasoning
        - limited_to_predefined_patterns
        - no_context_understanding

  commands:
    help: "Show available commands"
    mode: "Switch between api/free/local modes"
    profile: "Switch AI profile (architect/security/pragmatist/refactorer/tester)"
    scan: "Scan codebase for defects"
    analyze: "Deep analysis with LLM (api/free modes)"
    fix: "Apply suggested remedies"
    verify: "Run evidence validation"
    config: "Show configuration"
    backup: "Manage backups"
    rollback: "Rollback last fix"
    cost: "Show API cost summary"
    session: "Show session stats"
    quit: "Exit Convergence"

# Output Configuration
output:
  mode: "silent_success"
  philosophy: "unix_quiet_on_success_loud_on_failure"
  
  success:
    tokens: 0
    output: "none"
    exit_code: 0
  
  warning:
    format: "⚠️ {severity}.{type} @ {file}:{line}"
    example: "⚠️ medium.magic_number @ cli.rb:142"
    exit_code: 0
  
  failure:
    format: "✗ {domain}.{defect} → {remedy}"
    example: "✗ system.injection_vulnerability → sanitize_input"
    exit_code: 1
  
  evidence:
    format: "Evidence: {score:.3f} (tests: {tests}, static: {static}, complexity: {complexity:.3f})"
    pass_indicator: "✓ Pass"
    fail_indicator: "✗ Fail"
  
  fix:
    format: "✓ Fixed {type} @ {file}:{line}"
    show_diff: true
    diff_context_lines: 3
  
  cost:
    format: "Cost: ${cost:.6f} | Cached: {cached} tokens | Savings: ${savings:.6f}"
    show_per_request: false
    show_session_total: true

shell_standards:
  shebang: "#!/usr/bin/env zsh"
  
  options:
    required:
      - "set -euo pipefail"
      - "emulate -L zsh"
    
    recommended:
      - "setopt extended_glob"
      - "setopt nullglob"
  
  command_checking:
    standard: "command -v <cmd> >/dev/null 2>&1"
    never_use: ["which", "type -p", "hash"]
    rationale: "command is POSIX, others are bash-specific"
  
  error_handling:
    trap_required: true
    trap_pattern: "trap 'cleanup' ERR EXIT INT TERM"
    cleanup_function: |
      cleanup() {
        local exit_code=$?
        rm -f "$LOCK_FILE"
        [[ -n "$TEMP_DIR" ]] && rm -rf "$TEMP_DIR"
        exit $exit_code
      }
  
  file_locking:
    required_for:
      - cron_scripts
      - concurrent_operations
      - shared_resource_access
    
    pattern: |
      LOCK_FILE="/tmp/${0:t}.lock"
      exec {LOCK_FD}>"$LOCK_FILE"
      flock -n $LOCK_FD || {
        print "Already running"
        exit 1
      }
      trap "rm -f $LOCK_FILE" EXIT
  
  input_validation:
    always_validate:
      - user_input
      - file_paths
      - numeric_ranges
    
    validation_functions:
      validate_owner: '[[ "$1" =~ ^[a-z_][a-z0-9_-]*$ ]]'
      validate_path: '[[ "$1" =~ ^[a-zA-Z0-9/_.-]+$ ]]'
      validate_port: '[[ "$1" =~ ^[0-9]+$ ]] && (( $1 >= 1024 && $1 <= 65535 ))'
  
  destructive_operations:
    confirm_before:
      - rm
      - truncate
      - overwrite
    
    confirmation_pattern: |
      confirm_destructive() {
        print "⚠️  DESTRUCTIVE: $1"
        print "Type 'yes' to confirm:"
        read -r response
        [[ "$response" == "yes" ]] || exit 1
      }

# Exit Codes (Standard)
exit_codes:
  success: 0
  general_error: 1
  missing_dependency: 2
  invalid_input: 3
  permission_denied: 4
  file_not_found: 5
  network_error: 6
  config_error: 7
  version_mismatch: 8
  api_error: 9
  interrupted: 130  # Ctrl+C

# Workflow Process
workflows:
  standard:
    steps:
      - initialize_mode
      - detect_platform
      - apply_security
      - check_dependencies
      - load_config
      - verify_version_compatibility
      - execute_command
      - validate_evidence
      - output_result
      - cleanup
  
  scan:
    steps:
      - parse_files
      - match_patterns
      - classify_severity
      - check_auto_fixable
      - generate_report
  
  fix:
    steps:
      - load_defects
      - filter_auto_fixable
      - check_git_status
      - create_backups
      - prompt_approval
      - apply_fixes
      - run_tests
      - commit_changes
      - cleanup_backups
  
  api_analyze:
    steps:
      - check_api_key
      - load_master_yml_for_cache
      - build_prompt
      - send_request
      - handle_response
      - track_cost
      - parse_suggestions
      - present_to_user
  
  rollback:
    steps:
      - list_recent_backups
      - select_backup
      - verify_backup_integrity
      - restore_files
      - run_tests
      - output_result

# Refactoring Patterns
refactoring:
  extract_method:
    description: "Extract code fragment into named method"
    requirements:
      - min_lines: 3
      - max_lines: 20
      - standalone: true
      - clear_boundary: true
    steps:
      - identify_fragment
      - determine_parameters
      - generate_method_name
      - create_method
      - replace_with_call
      - run_tests
  
  extract_constant:
    description: "Replace magic literal with named constant"
    requirements:
      - is_literal: true
      - repeated_usage: 2
    steps:
      - identify_literal
      - generate_constant_name
      - determine_scope
      - insert_constant_definition
      - replace_all_occurrences
      - run_tests
  
  inline_method:
    description: "Replace method call with method body"
    requirements:
      - single_call_site: true
      - body_lines: 3
    steps:
      - verify_single_caller
      - inline_body
      - remove_method_definition
      - run_tests
  
  rename_variable:
    description: "Rename variable to intention-revealing name"
    requirements:
      - current_name_unclear: true
      - scope_identifiable: true
    steps:
      - identify_all_references
      - prompt_for_new_name
      - replace_all_occurrences
      - run_tests

2

#!/usr/bin/env ruby
# frozen_string_literal: true

# `cli.rb` v0.3.0 (`claude-sonnet-4.5`, `grok-code-fast-1`)

require "json"
require "yaml"
require "net/http"
require "uri"
require "fileutils"
require "open3"
require "timeout"
require "io/console"
require "readline"
require "pathname"
require "digest"
require "time"
require "logger"

VERSION = "0.3.0"

# Platform detection constants
OPENBSD = RUBY_PLATFORM.include?("openbsd")
LINUX = RUBY_PLATFORM.include?("linux")
CYGWIN = RUBY_PLATFORM.include?("cygwin") || RUBY_PLATFORM.include?("mingw")
TERMUX = ENV['PREFIX']&.include?('com.termux') || false
MACOS = RUBY_PLATFORM.include?("darwin")

# Platform detection and classification
module Platform
  @platform = nil

  def self.detect
    @platform ||= begin
      type = if TERMUX
               :termux
             elsif CYGWIN
               :cygwin
             elsif OPENBSD
               :openbsd
             elsif MACOS
               :macos
             elsif LINUX
               :linux
             else
               :unknown
             end

      security = case type
                 when :openbsd then :unveil_pledge
                 when :linux then :seccomp
                 when :cygwin then :acl
                 else :none
                 end

      { type: type, security: security }
    end
  end
  
  def self.type
    detect[:type]
  end
  
  def self.security
    detect[:security]
  end
  
  def self.openbsd?
    type == :openbsd
  end
  
  def self.security_available?
    security != :none
  end
end

# NEW in v0.3: Cross-platform path resolution
module Paths
  # Base paths
  HOME = ENV.fetch("HOME") { ENV.fetch("USERPROFILE", "/tmp") }
  CONFIG_DIR = File.expand_path("~/.convergence")
  MASTER_FILE = File.join(CONFIG_DIR, "master.yml")
  CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
  LOG_DIR = File.join(CONFIG_DIR, "logs")
  BACKUP_DIR = File.join(CONFIG_DIR, "backups")

  # Platform-specific path resolution
  DEFAULTS = {
    termux: {
      samples: "/sdcard/music/samples",
      rails: "/data/data/com.termux/files/home/rails",
      logs: "/data/data/com.termux/files/home/.convergence/logs",
      config: "/data/data/com.termux/files/home/.convergence"
    },
    cygwin: {
      samples: "/cygdrive/c/Users/#{ENV['USER']}/Music/samples",
      rails: "#{HOME}/rails",
      logs: "#{HOME}/.convergence/logs",
      config: "#{HOME}/.convergence"
    },
    openbsd: {
      samples: "#{HOME}/music/samples",
      rails: "/var/www",
      logs: "#{HOME}/.convergence/logs",
      config: "#{HOME}/.convergence"
    },
    linux: {
      samples: "#{HOME}/music/samples",
      rails: "#{HOME}/rails",
      logs: "#{HOME}/.convergence/logs",
      config: "#{HOME}/.convergence"
    },
    macos: {
      samples: "#{HOME}/Music/samples",
      rails: "#{HOME}/rails",
      logs: "#{HOME}/.convergence/logs",
      config: "#{HOME}/.convergence"
    }
  }.freeze

  def self.for(key)
    platform = Platform.type
    path = DEFAULTS.dig(platform, key) || "#{HOME}/#{key}"
    
    # Expand environment variables
    path = path.gsub(/\$\{?(\w+)\}?/) { |match| ENV[$1] || match }
    
    File.expand_path(path)
  end
  
  def self.ensure_exists(*paths)
    paths.each do |path|
      FileUtils.mkdir_p(path) unless Dir.exist?(path)
      File.chmod(0700, path) if Dir.exist?(path)
    end
  end
end

# OpenBSD security using pledge/unveil via FFI
module OpenBSDSecurity
  @available = false

  class << self
    attr_reader :available

    def setup
      return unless Platform.openbsd?
      require "ffi"
      extend FFI::Library
      ffi_lib FFI::Library::LIBC
      attach_function :unveil, [:string, :string], :int
      attach_function :pledge, [:string, :string], :int
      @available = true
    rescue LoadError, FFI::NotFoundError => e
      warn "⚠️  OpenBSD security unavailable: #{e.message}" if ENV["DEBUG"]
      @available = false
    end

    def apply(level, master_config = nil)
      return unless @available

      paths = paths_for_level(level, master_config)
      promises = promises_for_level(level, master_config)

      unveil_paths(paths) if paths
      pledge(promises, nil)
    rescue => e
      warn "⚠️  Security apply failed: #{e.message}" if ENV["DEBUG"]
    end

    private

    def paths_for_level(level, config)
      return nil unless config

      config.dig("security", "platform", "openbsd", "unveil", level.to_s)
    end

    def promises_for_level(level, config)
      return "stdio rpath wpath cpath inet dns proc exec" unless config

      config.dig("security", "platform", "openbsd", "pledge", level.to_s) ||
        "stdio rpath wpath cpath inet dns proc exec"
    end

    def unveil_paths(paths)
      return unless paths.is_a?(Array)

      paths.each do |entry|
        path = File.expand_path(entry["path"])
        perms = entry["permissions"]
        unveil(path, perms) if Dir.exist?(path) || File.exist?(path)
      end

      unveil(nil, nil) # Lock unveil
    end
  end
end

OpenBSDSecurity.setup

# Configuration management
class Config
  attr_accessor :mode, :access_level, :model, :provider, :verbose, :auto_fix_enabled, :active_profile

  def self.load
    new.tap do |config|
      if File.exist?(Paths::CONFIG_FILE)
        data = YAML.safe_load_file(Paths::CONFIG_FILE) || {}
        config.mode = data["mode"]&.to_sym
        config.access_level = data["access_level"]&.to_sym || :user
        config.model = data["model"] || "deepseek/deepseek-r1"
        config.provider = data["provider"] || "openrouter"
        config.verbose = data["verbose"] || false
        config.auto_fix_enabled = data.fetch("auto_fix_enabled", true)
        config.active_profile = data["active_profile"]&.to_sym || :balanced
      else
        config.mode = nil # Will prompt
        config.access_level = :user
        config.model = "deepseek/deepseek-r1"
        config.provider = "openrouter"
        config.verbose = false
        config.auto_fix_enabled = true
        config.active_profile = :balanced
      end
    end
  end

  def save
    Paths.ensure_exists(File.dirname(Paths::CONFIG_FILE))
    data = {
      "mode" => mode.to_s,
      "access_level" => access_level.to_s,
      "model" => model,
      "provider" => provider,
      "verbose" => verbose,
      "auto_fix_enabled" => auto_fix_enabled,
      "active_profile" => active_profile.to_s
    }
    File.write(Paths::CONFIG_FILE, YAML.dump(data))
    File.chmod(0o600, Paths::CONFIG_FILE)
  end
end

# Master configuration loader
class Master
  attr_reader :data

  def self.load
    unless File.exist?(Paths::MASTER_FILE)
      raise "master.yml not found at #{Paths::MASTER_FILE}"
    end

    new(YAML.safe_load_file(Paths::MASTER_FILE))
  rescue StandardError => e
    raise "Failed to load master.yml: #{e.message}"
  end

  def initialize(data)
    @data = data
    validate!
  end

  def validate!
    required = %w[meta principles evidence personas adversarial catalog]
    missing = required - @data.keys
    raise "Missing required sections: #{missing.join(', ')}" if missing.any?

    validate_counts!
  end

  def validate_counts!
    personas_count = @data.dig("personas", "roles")&.size || 0
    adversarial_count = @data.dig("adversarial", "personas")&.size || 0

    raise "Insufficient personas: #{personas_count} < 6" if personas_count < 6
    raise "Insufficient adversarial: #{adversarial_count} < 10" if adversarial_count < 10
  end

  def version
    @data.dig("meta", "version")
  end

  def principle(key)
    @data.dig("principles", key.to_s)
  end

  def remedy_for(defect_type)
    @data["catalog"]&.each_value do |domain|
      if domain.is_a?(Hash) && domain.key?(defect_type.to_s)
        remedy_data = domain[defect_type.to_s]
        return remedy_data.is_a?(Hash) ? remedy_data : { "remedy" => remedy_data }
      end
    end
    nil
  end

  def auto_fixable?(defect_type)
    remedy_data = remedy_for(defect_type)
    return false unless remedy_data

    remedy_data.is_a?(Hash) && remedy_data["auto_fix"] == true
  end

  def evidence_threshold
    @data.dig("evidence", "thresholds", "production") || 0.95
  end

  def evidence_weights
    @data.dig("evidence", "weights") || { "tests" => 0.5, "static" => 0.3, "complexity" => 0.2 }
  end

  def severity_for(defect_type)
    %w[critical high medium low].each do |level|
      defects = @data.dig("severity", level) || []
      return level.to_sym if defects.include?(defect_type.to_s)
    end
    :unknown
  end

  def auto_fix_config
    @data["auto_fix"] || {}
  end

  def openrouter_config
    @data["openrouter"] || {}
  end

  def profile_config(name)
    @data.dig("profiles", name.to_s)
  end
end

# NEW in v0.3: Version compatibility checker
module VersionChecker
  COMPATIBILITY = {
    "0.3" => {
      min_master: "0.3.0",
      max_master: "0.3.99",
      min_ruby: "3.0.0"
    }
  }.freeze

  def self.check!
    cli_version = VERSION
    master_version = Master.load.version

    cli_major_minor = cli_version.split('.').first(2).join('.')
    master_major_minor = master_version.split('.').first(2).join('.')

    unless cli_major_minor == master_major_minor
      raise "Version mismatch: cli.rb v#{cli_version} incompatible with master.yml v#{master_version}"
    end

    # Check Ruby version
    compat = COMPATIBILITY[cli_major_minor]
    if compat && Gem::Version.new(RUBY_VERSION) < Gem::Version.new(compat[:min_ruby])
      raise "Ruby version #{RUBY_VERSION} too old, requires >= #{compat[:min_ruby]}"
    end
  end
end

# NEW in v0.3: Unified logging
module UnifiedLogger
  @logger = nil
  @cli_logger = nil

  def self.setup(name, verbose: false)
    Paths.ensure_exists(Paths::LOG_DIR)
    
    @logger = Logger.new(File.join(Paths::LOG_DIR, "#{name}.log"), "daily")
    @logger.level = verbose ? Logger::DEBUG : Logger::INFO
    
    @cli_logger = Logger.new($stdout)
    @cli_logger.level = verbose ? Logger::DEBUG : Logger::INFO
    @cli_logger.formatter = proc do |severity, _, _, msg|
      case severity
      when "DEBUG" then "🔍 #{msg}\n"
      when "INFO" then "ℹ️  #{msg}\n"
      when "WARN" then "⚠️  #{msg}\n"
      when "ERROR" then "✗ #{msg}\n"
      when "FATAL" then "💀 #{msg}\n"
      else "#{msg}\n"
      end
    end
  end

  def self.debug(msg)
    @logger&.debug(msg)
    @cli_logger&.debug(msg)
  end

  def self.info(msg)
    @logger&.info(msg)
    @cli_logger&.info(msg)
  end

  def self.warn(msg)
    @logger&.warn(msg)
    @cli_logger&.warn(msg)
  end

  def self.error(msg)
    @logger&.error(msg)
    @cli_logger&.error(msg)
  end

  def self.fatal(msg)
    @logger&.fatal(msg)
    @cli_logger&.fatal(msg)
  end
end

# Shell command execution (allowlist only)
class ShellTool
  ALLOWED_COMMANDS = {
    "rubocop" => ["rubocop", "-a"],
    "test" => ["ruby", "-Itest", "-e", "Dir['test/**/*_test.rb'].each { |f| require_relative f }"],
    "flog" => ["flog", "--all", "--methods-only"],
    "git" => ["git"]
  }.freeze

  Result = Struct.new(:success, :stdout, :stderr, :exit_code, keyword_init: true)

  def self.execute(command, *args, timeout: 30)
    base_cmd = ALLOWED_COMMANDS[command]
    return Result.new(success: false, stderr: "Command not allowed", exit_code: 127) unless base_cmd

    full_cmd = base_cmd + args.map { |arg| Shellwords.escape(arg) }

    Timeout.timeout(timeout) do
      stdout, stderr, status = Open3.capture3(*full_cmd)
      Result.new(
        success: status.success?,
        stdout: stdout[0..10_000],
        stderr: stderr[0..2000],
        exit_code: status.exitstatus
      )
    end
  rescue Timeout::Error
    Result.new(success: false, stderr: "Timeout after #{timeout}s", exit_code: 124)
  rescue => e
    Result.new(success: false, stderr: e.message, exit_code: 1)
  end
end

# Git integration for auto-fix commits
class GitTool
  def self.available?
    result = ShellTool.execute("git", "--version")
    result.success
  rescue
    false
  end

  def self.clean_working_tree?
    result = ShellTool.execute("git", "status", "--porcelain")
    result.success && result.stdout.strip.empty?
  end

  def self.commit(message, files = [])
    return { success: false, error: "Git not available" } unless available?

    # Stage specific files or all changes
    if files.any?
      files.each do |file|
        result = ShellTool.execute("git", "add", file)
        return { success: false, error: "Failed to stage #{file}" } unless result.success
      end
    else
      result = ShellTool.execute("git", "add", "-A")
      return { success: false, error: "Failed to stage changes" } unless result.success
    end

    # Commit
    result = ShellTool.execute("git", "commit", "-m", message)
    if result.success
      { success: true }
    else
      { success: false, error: result.stderr }
    end
  end

  def self.diff(file = nil)
    args = ["diff"]
    args << file if file

    result = ShellTool.execute("git", *args)
    result.success ? { success: true, diff: result.stdout } : { success: false }
  end
end

# File operations with sandbox enforcement
class FileTool
  def initialize(base_path:, access_level:)
    @base = File.expand_path(base_path)
    @level = access_level
  end

  def read(path)
    safe_path = enforce_sandbox!(path)
    return { error: "File not found: #{path}" } unless File.exist?(safe_path)

    {
      content: File.read(safe_path)[0..100_000],
      size: File.size(safe_path),
      path: safe_path
    }
  rescue SecurityError => e
    { error: e.message }
  rescue => e
    { error: "Read error: #{e.message}" }
  end

  def write(path, content)
    safe_path = enforce_sandbox!(path)
    FileUtils.mkdir_p(File.dirname(safe_path))
    File.write(safe_path, content)
    { success: true, bytes: content.bytesize, path: safe_path }
  rescue SecurityError => e
    { error: e.message }
  rescue => e
    { error: "Write error: #{e.message}" }
  end

  private

  def enforce_sandbox!(path)
    expanded = File.expand_path(path, @base)
    allowed = allowed_paths

    return expanded if allowed.nil? # Admin mode

    allowed.each do |allowed_path|
      return expanded if expanded.start_with?("#{allowed_path}/") || expanded == allowed_path
    end

    raise SecurityError, "Access denied: #{path} (level: #{@level})"
  end

  def allowed_paths
    case @level
    when :sandbox then [Dir.pwd, "/tmp"]
    when :user then [Paths::HOME, Dir.pwd, "/tmp", Paths::BACKUP_DIR]
    when :admin then nil
    else [Dir.pwd]
    end
  end
end

# Pattern-based code scanner with enhanced patterns
class Scanner
  # Enhanced patterns for v0.3 (pub4 issues)
  PATTERNS = {
    # Critical security issues
    injection_vulnerability: /system\(|exec\(|`[^`]*\$|eval\(|IO\.popen/,
    token_exposure: /Token [a-zA-Z0-9_-]{32,}|Bearer [a-zA-Z0-9_-]{32,}|sk-[a-zA-Z0-9]{32,}/,
    hardcoded_secret: /password\s*=\s*["'][^"']+["']|api_key\s*=\s*["'][^"']+["']/i,
    sql_injection: /["']\s*\+\s*\w+|execute\([^?]*\#{|where\([^?]*\#{/,
    xss_vulnerability: /html_safe|raw\(|<%=\s*@\w+\s*%>/,
    
    # NEW: pub4-specific issues
    yaml_in_ruby_file: /^[a-z_]+:\s+.+$/m,
    hardcoded_path: /["'](?:\/sdcard|\/home\/[a-z]+|C:\/Users|\/var\/www)/,
    missing_file_lock: /^(?!.*flock\s+).*(?:cron|concurrent)/m,
    
    # Logic issues
    race_condition: /@@\w+|Thread\.new(?!\s*\{)/,
    magic_number: /(?<!\d)([1-9]\d{2,})(?!\d)/,
    mass_assignment: /\.new\(params\[|\.create\(params\[/
  }.freeze

  Defect = Struct.new(
    :type, :severity, :file, :line, :column, :context, :remedy, :auto_fixable,
    keyword_init: true
  )

  def initialize(master:)
    @master = master
  end

  def scan(directory = Dir.pwd)
    ruby_files(directory).flat_map { |file| scan_file(file) }
  end

  private

  def ruby_files(directory)
    Dir.glob("#{directory}/**/*.rb").reject do |path|
      path.match?(%r{vendor/|node_modules/|\.bundle/})
    end
  end

  def scan_file(file_path)
    content = File.read(file_path)
    defects = []

    PATTERNS.each do |type, pattern|
      content.scan(pattern) do
        match_pos = Regexp.last_match.begin(0)
        line_num = content[0..match_pos].count("\n") + 1
        col_num = match_pos - content.rindex("\n", match_pos).to_i

        remedy_data = @master.remedy_for(type)

        defects << Defect.new(
          type: type,
          severity: @master.severity_for(type),
          file: file_path,
          line: line_num,
          column: col_num,
          context: extract_context(content, line_num),
          remedy: remedy_data,
          auto_fixable: @master.auto_fixable?(type)
        )
      end
    end

    defects
  rescue => e
    UnifiedLogger.warn("Error scanning #{file_path}: #{e.message}") if ENV["DEBUG"]
    []
  end

  def extract_context(content, line_num)
    lines = content.lines
    start_line = [line_num - 2, 0].max
    end_line = [line_num + 1, lines.size - 1].min

    lines[start_line..end_line].join.strip[0..200]
  end
end

# Backup manager
class BackupManager
  def initialize
    @backup_dir = Paths::BACKUP_DIR
    Paths.ensure_exists(@backup_dir)
  end

  def create_backup(file_path)
    return { error: "File not found" } unless File.exist?(file_path)

    timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
    backup_name = "#{File.basename(file_path)}_#{timestamp}.bak"
    backup_path = File.join(@backup_dir, backup_name)

    FileUtils.cp(file_path, backup_path)
    { success: true, backup_path: backup_path }
  rescue => e
    { error: e.message }
  end

  def list_backups(file_pattern = nil)
    pattern = file_pattern ? "#{file_pattern}_*.bak" : "*.bak"
    backups = Dir.glob(File.join(@backup_dir, pattern))
    
    backups.map do |path|
      {
        path: path,
        original_file: extract_original_filename(File.basename(path)),
        timestamp: extract_timestamp(File.basename(path)),
        size: File.size(path)
      }
    end.sort_by { |b| b[:timestamp] }.reverse
  end

  def restore_backup(backup_path, target_path = nil)
    return { error: "Backup not found" } unless File.exist?(backup_path)

    target = target_path || reconstruct_original_path(backup_path)
    
    # Create backup of current state before restoring
    if File.exist?(target)
      rollback_backup = create_backup(target)
      return rollback_backup unless rollback_backup[:success]
    end

    FileUtils.cp(backup_path, target)
    { success: true, restored_to: target }
  rescue => e
    { error: e.message }
  end

  def cleanup_old_backups(days = 7)
    cutoff_time = Time.now - (days * 24 * 60 * 60)
    removed = 0

    Dir.glob(File.join(@backup_dir, "*.bak")).each do |backup|
      if File.mtime(backup) < cutoff_time
        File.delete(backup)
        removed += 1
      end
    end

    { removed: removed }
  rescue => e
    { error: e.message }
  end

  private

  def extract_original_filename(backup_name)
    backup_name.sub(/_\d{8}_\d{6}\.bak$/, "")
  end

  def extract_timestamp(backup_name)
    match = backup_name.match(/_(\d{8}_\d{6})\.bak$/)
    match ? Time.strptime(match[1], "%Y%m%d_%H%M%S") : Time.now
  end

  def reconstruct_original_path(backup_path)
    original_name = extract_original_filename(File.basename(backup_path))
    File.join(Dir.pwd, original_name)
  end
end

# Auto-fixer
class AutoFixer
  def initialize(master:, backup_manager:)
    @master = master
    @backup_manager = backup_manager
  end

  def fix_defects(defects, interactive: true)
    fixable = defects.select(&:auto_fixable)
    
    if fixable.empty?
      return { success: true, fixed: 0, skipped: defects.size, message: "No auto-fixable defects" }
    end

    puts "\nFound #{fixable.size} auto-fixable defects"
    puts "Total defects: #{defects.size}\n\n"

    fixed = []
    skipped = []

    fixable.each do |defect|
      if interactive
        action = prompt_fix_approval(defect)
        next if action == :skip
      end

      result = apply_fix(defect)
      if result[:success]
        fixed << defect
        puts "✓ Fixed #{defect.type} @ #{defect.file}:#{defect.line}"
      else
        skipped << defect
        puts "✗ Failed to fix #{defect.type}: #{result[:error]}"
      end
    end

    { success: true, fixed: fixed.size, skipped: skipped.size + (defects.size - fixable.size) }
  end

  private

  def prompt_fix_approval(defect)
    puts "\n#{'-' * 60}"
    puts "Defect: #{defect.type}"
    puts "File: #{defect.file}:#{defect.line}"
    puts "Severity: #{defect.severity}"
    
    remedy_info = defect.remedy
    remedy_name = remedy_info.is_a?(Hash) ? remedy_info["remedy"] : remedy_info
    puts "Remedy: #{remedy_name}"
    
    puts "\nContext:"
    puts defect.context
    puts "\n#{'-' * 60}"
    
    print "Apply fix? [y/n/q]: "
    response = gets.strip.downcase

    case response
    when "y" then :apply
    when "q" then exit(0)
    else :skip
    end
  end

  def apply_fix(defect)
    # Create backup first
    backup_result = @backup_manager.create_backup(defect.file)
    return backup_result unless backup_result[:success]

    content = File.read(defect.file)
    remedy_info = defect.remedy

    fixed_content = case defect.type
                    when :magic_number
                      fix_magic_number(content, defect, remedy_info)
                    when :injection_vulnerability
                      fix_injection(content, defect, remedy_info)
                    when :hardcoded_secret
                      fix_hardcoded_secret(content, defect, remedy_info)
                    when :token_exposure
                      fix_token_exposure(content, defect, remedy_info)
                    when :hardcoded_path
                      fix_hardcoded_path(content, defect, remedy_info)
                    else
                      return { success: false, error: "No fixer implemented for #{defect.type}" }
                    end

    return fixed_content unless fixed_content[:success]

    File.write(defect.file, fixed_content[:content])
    { success: true, backup: backup_result[:backup_path] }
  rescue => e
    { success: false, error: e.message }
  end

  def fix_magic_number(content, defect, remedy_info)
    lines = content.lines
    target_line = lines[defect.line - 1]

    match = target_line.match(/(?<!\d)(\d{3,})(?!\d)/)
    return { success: false, error: "Could not find magic number" } unless match

    magic_value = match[0]
    
    print "Enter constant name for #{magic_value} (or press enter for default): "
    constant_name = gets.strip
    constant_name = "MAGIC_#{magic_value}" if constant_name.empty?

    insert_line = find_constant_insertion_point(lines)
    lines.insert(insert_line, "#{constant_name} = #{magic_value}\n")

    lines[defect.line] = target_line.gsub(/(?<!\d)#{magic_value}(?!\d)/, constant_name)

    { success: true, content: lines.join }
  end

  def fix_injection(content, defect, remedy_info)
    lines = content.lines
    target_line = lines[defect.line - 1]

    fixed_line = target_line.gsub(/system\((.+?)\)/) do |match|
      arg = $1
      "system(Shellwords.escape(#{arg}))"
    end

    unless content.include?("require \"shellwords\"")
      lines.unshift("require \"shellwords\"\n")
    end

    lines[defect.line - 1] = fixed_line
    { success: true, content: lines.join }
  end

  def fix_hardcoded_secret(content, defect, remedy_info)
    lines = content.lines
    target_line = lines[defect.line - 1]

    match = target_line.match(/(password|api_key)\s*=\s*["']([^"']+)["']/i)
    return { success: false, error: "Could not parse secret" } unless match

    var_name = match[1]
    env_var_name = var_name.upcase

    fixed_line = target_line.gsub(
      /#{var_name}\s*=\s*["'][^"']+["']/i,
      "#{var_name} = ENV.fetch('#{env_var_name}') { raise 'Set #{env_var_name}' }"
    )

    lines[defect.line - 1] = fixed_line
    { success: true, content: lines.join }
  end

  def fix_token_exposure(content, defect, remedy_info)
    lines = content.lines
    
    # Sanitize token in error handling
    lines = lines.map do |line|
      line.gsub(/Token [a-zA-Z0-9_-]{32,}/, "Token [REDACTED]")
          .gsub(/Bearer [a-zA-Z0-9_-]{32,}/, "Bearer [REDACTED]")
          .gsub(/sk-[a-zA-Z0-9]{32,}/, "sk-[REDACTED]")
    end

    { success: true, content: lines.join }
  end

  def fix_hardcoded_path(content, defect, remedy_info)
    lines = content.lines
    target_line = lines[defect.line - 1]

    # Detect path type and suggest Paths module replacement
    path_replacements = {
      '/sdcard' => 'Paths.for(:samples)',
      '/home/' => 'Paths::HOME',
      'C:/Users' => 'Paths::HOME',
      '/var/www' => 'Paths.for(:rails)'
    }

    fixed_line = target_line
    path_replacements.each do |pattern, replacement|
      if target_line.include?(pattern)
        fixed_line = target_line.gsub(/["'].*#{Regexp.escape(pattern)}.*["']/, replacement)
        break
      end
    end

    lines[defect.line - 1] = fixed_line
    { success: true, content: lines.join }
  end

  def find_constant_insertion_point(lines)
    last_require = 0
    lines.each_with_index do |line, idx|
      last_require = idx + 1 if line.match?(/^\s*require/)
    end

    lines.each_with_index do |line, idx|
      next if idx < last_require
      return idx unless line.match?(/^\s*#/)
    end

    last_require
  end
end

# Evidence calculator
class EvidenceCalculator
  def initialize(master:)
    @master = master
    @weights = @master.evidence_weights
  end

  def calculate
    tests = test_score
    static = static_score
    complexity = complexity_score

    score = (tests * @weights["tests"]) +
            (static * @weights["static"]) +
            (complexity * @weights["complexity"])

    {
      score: score,
      tests: tests,
      static: static,
      complexity: complexity,
      pass: score >= @master.evidence_threshold
    }
  end

  private

  def test_score
    result = ShellTool.execute("test")
    result.success ? 1.0 : 0.0
  rescue
    0.0
  end

  def static_score
    result = ShellTool.execute("rubocop", ".")
    result.success ? 1.0 : 0.0
  rescue
    0.0
  end

  def complexity_score
    result = ShellTool.execute("flog", ".")
    return 1.0 unless result.success

    avg = parse_flog_output(result.stdout)
    1.0 - [1.0, avg / 20.0].min
  rescue
    1.0
  end

  def parse_flog_output(output)
    scores = output.scan(/(\d+\.\d+):/).map { |m| m[0].to_f }
    return 0.0 if scores.empty?

    scores.sum / scores.size
  end
end

# Ferrum mode for free browser automation
class FerrumMode
  def initialize
    @browser = nil
    @page = nil
  end

  def start
    require "ferrum"
    @browser = Ferrum::Browser.new(
      headless: true,
      window_size: [1920, 1080],
      timeout: 30
    )
    @page = @browser.create_page
    puts "✓ Browser started (Ferrum mode)"
  rescue LoadError
    puts "✗ Ferrum not installed. Run: gem install ferrum"
    exit 1
  rescue Ferrum::BrowserError
    puts "✗ Chrome/Chromium not found"
    exit 1
  end

  def handle(input)
    cmd, *args = input.split(/\s+/)

    case cmd
    when "goto" then goto_url(args.first)
    when "screenshot" then screenshot(args.first)
    when "scrape" then scrape
    when "help" then show_help
    else puts "Unknown command: #{cmd}. Type 'help' for commands."
    end
  end

  def stop
    @browser&.quit
  end

  private

  def goto_url(url)
    url = "https://#{url}" unless url.start_with?("http")
    @page.goto(url)
    puts "✓ Loaded: #{url}"
  rescue => e
    puts "✗ Error: #{e.message}"
  end

  def screenshot(path = nil)
    path ||= File.join(Paths::CONFIG_DIR, "screenshot_#{Time.now.to_i}.png")
    @page.screenshot(path: path)
    puts "✓ Screenshot saved: #{path}"
  rescue => e
    puts "✗ Error: #{e.message}"
  end

  def scrape
    data = {
      url: @page.current_url,
      title: @page.title,
      text: @page.evaluate("document.body.innerText")[0..2000],
      links: @page.css("a").map { |a| a.attribute("href") }.compact.take(20),
      images: @page.css("img").map { |i| i.attribute("src") }.compact.take(20)
    }

    output = File.join(Paths::CONFIG_DIR, "scrape_#{Time.now.to_i}.json")
    File.write(output, JSON.pretty_generate(data))
    puts "✓ Data saved: #{output}"
  rescue => e
    puts "✗ Error: #{e.message}"
  end

  def show_help
    puts <<~HELP
      Ferrum Mode Commands:
        goto <url>        - Navigate to URL
        screenshot [path] - Take screenshot
        scrape            - Extract page data
        help              - Show this help
        quit              - Exit Ferrum mode
    HELP
  end
end

# NEW in v0.3: OpenRouter API integration
class OpenRouterAPI
  API_URL = "https://openrouter.ai/api/v1/chat/completions"

  def initialize(api_key:, model:, master:)
    @api_key = api_key
    @model = model
    @master = master
    @config = master.openrouter_config
  end

  def analyze_code(code, defects)
    # Use prompt caching for master.yml
    master_context = File.read(Paths::MASTER_FILE)

    request_body = {
      model: @model,
      messages: [
        {
          role: "user",
          content: [
            {
              type: "text",
              text: "Constitutional Governance:\n\n#{master_context}",
              cache_control: { type: "ephemeral" }
            },
            {
              type: "text",
              text: build_analysis_prompt(code, defects)
            }
          ]
        }
      ],
      stream_options: { include_usage: true },
      max_tokens: 2000
    }

    make_request(request_body)
  end

  def suggest_fixes(defects)
    master_context = File.read(Paths::MASTER_FILE)

    request_body = {
      model: @model,
      messages: [
        {
          role: "user",
          content: [
            {
              type: "text",
              text: "Constitutional Governance:\n\n#{master_context}",
              cache_control: { type: "ephemeral" }
            },
            {
              type: "text",
              text: build_fix_prompt(defects)
            }
          ]
        }
      ],
      stream_options: { include_usage: true },
      max_tokens: 1500
    }

    make_request(request_body)
  end

  private

  def build_analysis_prompt(code, defects)
    <<~PROMPT
      Analyze this code for defects according to the constitutional governance above.

      Code:
      ```ruby
      #{code}
      ```

      Already detected defects:
      #{defects.map { |d| "- #{d.type} @ line #{d.line}: #{d.context}" }.join("\n")}

      Provide:
      1. Severity assessment
      2. Root cause analysis
      3. Recommended fixes
      4. Priority order

      Format as JSON:
      {
        "analysis": "...",
        "suggestions": [
          {
            "type": "defect_type",
            "priority": "critical|high|medium|low",
            "fix": "description",
            "code": "fixed code snippet"
          }
        ]
      }
    PROMPT
  end

  def build_fix_prompt(defects)
    <<~PROMPT
      Suggest fixes for these defects according to constitutional governance.

      Defects:
      #{defects.map { |d| "- #{d.type} (#{d.severity}) @ #{d.file}:#{d.line}\n  Context: #{d.context}" }.join("\n\n")}

      For each defect, provide:
      1. Specific fix implementation
      2. Testing strategy
      3. Potential side effects

      Format as JSON array.
    PROMPT
  end

  def make_request(body)
    uri = URI(API_URL)
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = true
    http.read_timeout = @config.dig("timeout", "read") || 60
    http.open_timeout = @config.dig("timeout", "connect") || 10

    request = Net::HTTP::Post.new(uri)
    request['Authorization'] = "Bearer #{@api_key}"
    request['Content-Type'] = 'application/json'
    request['HTTP-Referer'] = @config.dig("headers", "recommended", "HTTP-Referer") || "https://github.com/anon987654321/pub4"
    request['X-Title'] = @config.dig("headers", "recommended", "X-Title") || "Convergence CLI v#{VERSION}"
    
    request.body = JSON.generate(body)

    response = http.request(request)

    unless response.is_a?(Net::HTTPSuccess)
      sanitized_body = sanitize_tokens(response.body)
      raise "API error #{response.code}: #{sanitized_body}"
    end

    JSON.parse(response.body)
  rescue => e
    sanitized_msg = sanitize_tokens(e.message)
    UnifiedLogger.error("API request failed: #{sanitized_msg}")
    nil
  end

  def sanitize_tokens(text)
    text.gsub(/Token [a-zA-Z0-9_-]+/, "Token [REDACTED]")
        .gsub(/Bearer [a-zA-Z0-9_-]+/, "Bearer [REDACTED]")
        .gsub(/sk-[a-zA-Z0-9]+/, "sk-[REDACTED]")
  end
end

# NEW in v0.3: Cost tracking
class CostTracker
  def initialize(master:)
    @master = master
    @total_cost = 0.0
    @cached_savings = 0.0
    @requests = []
  end

  def record(response, model_name)
    usage = response.dig("usage")
    return unless usage

    prompt_tokens = usage["prompt_tokens"] || 0
    completion_tokens = usage["completion_tokens"] || 0
    cached_tokens = usage.dig("prompt_tokens_details", "cached_tokens") || 0

    model_config = find_model_config(model_name)
    return unless model_config

    prompt_cost = (prompt_tokens - cached_tokens) * model_config["cost_per_1m_prompt"] / 1_000_000
    cached_cost = cached_tokens * model_config["cost_per_1m_cached"] / 1_000_000
    completion_cost = completion_tokens * model_config["cost_per_1m_completion"] / 1_000_000

    total_request_cost = prompt_cost + cached_cost + completion_cost
    savings = cached_tokens * (model_config["cost_per_1m_prompt"] - model_config["cost_per_1m_cached"]) / 1_000_000

    @total_cost += total_request_cost
    @cached_savings += savings

    @requests << {
      timestamp: Time.now,
      model: model_name,
      prompt_tokens: prompt_tokens,
      cached_tokens: cached_tokens,
      completion_tokens: completion_tokens,
      cost: total_request_cost,
      savings: savings
    }

    {
      cost: total_request_cost,
      cached_tokens: cached_tokens,
      savings: savings
    }
  end

  def summary
    {
      total_cost: @total_cost,
      total_savings: @cached_savings,
      requests: @requests.size,
      average_cost: @requests.empty? ? 0 : @total_cost / @requests.size
    }
  end

  private

  def find_model_config(model_name)
    models = @master.openrouter_config["models"] || {}
    models.values.find { |m| m["name"] == model_name }
  end
end

# NEW in v0.3: Profile manager
module ProfileManager
  @current_profile = :balanced
  @profiles = {}

  def self.load_profiles(master)
    @profiles = master.data["profiles"] || {}
  end

  def self.switch(name)
    return false unless @profiles.key?(name.to_s)
    
    @current_profile = name
    profile = @profiles[name.to_s]
    
    puts "Switched to #{name} profile"
    puts "  Model: #{profile['model']}"
    puts "  Temperature: #{profile['temperature']}"
    puts "  Focus: #{profile['focus'].join(', ')}"
    
    true
  end

  def self.current
    @profiles[@current_profile.to_s] || default_profile
  end

  def self.current_name
    @current_profile
  end

  def self.available
    @profiles.keys
  end

  def self.default_profile
    {
      "temperature" => 0.5,
      "focus" => ["general"],
      "model" => "deepseek/deepseek-r1"
    }
  end
end

# NEW in v0.3: Session timer
class SessionTimer
  def initialize
    @start_time = Time.now
  end

  def elapsed
    Time.now - @start_time
  end

  def report
    hours = (elapsed / 3600).floor
    minutes = ((elapsed % 3600) / 60).floor
    seconds = (elapsed % 60).floor

    parts = []
    parts << "#{hours}h" if hours > 0
    parts << "#{minutes}m" if minutes > 0 || hours > 0
    parts << "#{seconds}s"

    parts.join(" ")
  end
end

# Main CLI
class CLI
  def initialize
    Paths.ensure_exists(Paths::CONFIG_DIR, Paths::LOG_DIR, Paths::BACKUP_DIR)
    
    UnifiedLogger.setup("convergence", verbose: ENV["DEBUG"])
    
    @master = Master.load
    @config = Config.load
    @platform = Platform.detect
    @backup_manager = BackupManager.new
    @auto_fixer = AutoFixer.new(master: @master, backup_manager: @backup_manager)
    @session_timer = SessionTimer.new
    @cost_tracker = CostTracker.new(master: @master)

    VersionChecker.check!
    ProfileManager.load_profiles(@master)
    
    prompt_mode_if_needed!
    apply_security!
    show_boot_sequence
  end

  def run
    case @config.mode
    when :api then run_api_mode
    when :free then run_free_mode
    when :local then run_local_mode
    else
      puts "Invalid mode: #{@config.mode}"
      exit 1
    end
  end

  private

  def prompt_mode_if_needed!
    return if @config.mode

    puts "\n🚀 Convergence v#{VERSION} - First Run Setup"
    puts "\nSelect mode:"
    puts "  1. API Mode (requires OPENROUTER_API_KEY)"
    puts "  2. Free Mode (requires Chrome/Chromium + gem install ferrum)"
    puts "  3. Local Mode (basic pattern matching + auto-fix)"
    print "\nChoice [1/2/3]: "

    choice = gets.strip
    @config.mode = case choice
                   when "1" then check_api_key! && :api
                   when "2" then check_ferrum! && :free
                   when "3" then :local
                   else
                     puts "Defaulting to Local Mode"
                     :local
                   end

    @config.save
  end

  def check_api_key!
    unless ENV["OPENROUTER_API_KEY"]
      puts "\n⚠️  OPENROUTER_API_KEY not set"
      puts "Set it with: export OPENROUTER_API_KEY='your-key'"
      print "Continue anyway? [y/N]: "
      return false unless gets.strip.downcase == "y"
    end
    true
  end

  def check_ferrum!
    begin
      require "ferrum"
      true
    rescue LoadError
      puts "\n⚠️  Ferrum not installed"
      puts "Install with: gem install ferrum"
      false
    end
  end

  def apply_security!
    return unless Platform.security_available?

    OpenBSDSecurity.apply(@config.access_level, @master.data)
  end

  def show_boot_sequence
    puts "Convergence v#{VERSION}"
    puts "Platform: #{@platform[:type]}"
    puts "Mode: #{@config.mode}"
    puts "Profile: #{ProfileManager.current_name}"
    puts "Access: #{@config.access_level}"
    puts "Security: #{@platform[:security]}"
    puts "Auto-fix: #{@config.auto_fix_enabled ? 'enabled' : 'disabled'}"
    puts ""
  end

  def run_api_mode
    puts "API Mode - Type 'help' for commands"
    defects = []
    api = nil

    if ENV["OPENROUTER_API_KEY"]
      profile = ProfileManager.current
      api = OpenRouterAPI.new(
        api_key: ENV["OPENROUTER_API_KEY"],
        model: profile["model"],
        master: @master
      )
    end

    loop do
      print "convergence> "
      input = Readline.readline("", true)&.strip
      break if input.nil? || %w[quit exit].include?(input)
      next if input.empty?

      handle_api_command(input, defects, api)
    end
  end

  def handle_api_command(input, defects, api)
    cmd, *args = input.split(/\s+/)

    case cmd
    when "help" then show_help
    when "profile" then switch_profile(args.first)
    when "scan" then defects.replace(run_scan)
    when "analyze" then run_llm_analysis(defects, api)
    when "fix" then run_auto_fix(defects)
    when "verify" then run_verification
    when "config" then show_config
    when "mode" then switch_mode
    when "backup" then manage_backups(args)
    when "rollback" then rollback_last_fix
    when "cost" then show_cost_summary
    when "session" then show_session_stats
    else
      puts "Unknown command: #{cmd}. Type 'help' for available commands."
    end
  end

  def run_free_mode
    ferrum = FerrumMode.new
    ferrum.start

    loop do
      print "ferrum> "
      input = gets&.strip
      break if input.nil? || input == "quit"
      next if input.empty?

      ferrum.handle(input)
    end

    ferrum.stop
  end

  def run_local_mode
    puts "Local Mode - Pattern matching + auto-fix"
    puts "Type 'help' for commands"

    defects = []

    loop do
      print "convergence> "
      input = gets&.strip
      break if input.nil? || %w[quit exit].include?(input)
      next if input.empty?

      handle_local_command(input, defects)
    end
  end

  def handle_local_command(input, defects)
    cmd, *args = input.split(/\s+/)

    case cmd
    when "help" then show_help
    when "profile" then switch_profile(args.first)
    when "scan" then defects.replace(run_scan)
    when "fix" then run_auto_fix(defects)
    when "verify" then run_verification
    when "config" then show_config
    when "mode" then switch_mode
    when "backup" then manage_backups(args)
    when "rollback" then rollback_last_fix
    when "session" then show_session_stats
    else
      puts "Unknown command: #{cmd}"
    end
  end

  def switch_profile(name)
    if name.nil?
      puts "\nAvailable profiles:"
      ProfileManager.available.each do |profile_name|
        marker = profile_name == ProfileManager.current_name.to_s ? "* " : "  "
        puts "#{marker}#{profile_name}"
      end
      return
    end

    if ProfileManager.switch(name.to_sym)
      @config.active_profile = name.to_sym
      @config.save
    else
      puts "Unknown profile: #{name}"
    end
  end

  def run_scan
    puts "Scanning codebase..."
    scanner = Scanner.new(master: @master)
    defects = scanner.scan

    if defects.empty?
      puts "✓ No defects found"
    else
      display_defects(defects)
    end

    defects
  end

  def display_defects(defects)
    puts "\nFound #{defects.size} defects:\n"
    
    auto_fixable = defects.count(&:auto_fixable)
    puts "Auto-fixable: #{auto_fixable}\n\n" if auto_fixable > 0

    defects.group_by(&:severity).each do |severity, group|
      puts "\n#{severity.upcase} (#{group.size}):"
      group.take(10).each do |defect|
        marker = defect.auto_fixable ? "🔧" : "  "
        puts "#{marker} #{defect.file}:#{defect.line} - #{defect.type}"
        
        remedy_info = defect.remedy
        remedy_name = remedy_info.is_a?(Hash) ? remedy_info["remedy"] : remedy_info
        puts "     Remedy: #{remedy_name}" if remedy_name
      end
      puts "  ... and #{group.size - 10} more" if group.size > 10
    end
  end

  def run_llm_analysis(defects, api)
    return puts "No API configured" unless api
    return puts "No defects to analyze. Run 'scan' first." if defects.empty?

    puts "\nAnalyzing with LLM..."

    # Group defects by file for efficient analysis
    defects.group_by(&:file).each do |file, file_defects|
      next unless File.exist?(file)
      
      code = File.read(file)[0..10_000]  # Limit context
      
      puts "\n📄 #{file}"
      response = api.analyze_code(code, file_defects)
      
      if response
        @cost_tracker.record(response, ProfileManager.current["model"])
        display_llm_analysis(response)
      else
        puts "  ✗ Analysis failed"
      end
    end
  end

  def display_llm_analysis(response)
    content = response.dig("choices", 0, "message", "content")
    return unless content

    begin
      analysis = JSON.parse(content)
      puts "  Analysis: #{analysis['analysis']}"
      
      if analysis['suggestions']
        puts "\n  Suggestions:"
        analysis['suggestions'].each_with_index do |suggestion, i|
          puts "    #{i + 1}. [#{suggestion['priority']}] #{suggestion['fix']}"
        end
      end
    rescue JSON::ParserError
      puts "  #{content[0..500]}"
    end
  end

  def run_auto_fix(defects)
    unless @config.auto_fix_enabled
      puts "Auto-fix is disabled. Enable in config with 'config set auto_fix true'"
      return
    end

    return puts "No defects to fix. Run 'scan' first." if defects.empty?

    auto_fix_config = @master.auto_fix_config
    interactive = auto_fix_config.dig("approval", "mode") != "auto"

    result = @auto_fixer.fix_defects(defects, interactive: interactive)
    
    puts "\nAuto-fix completed:"
    puts "  Fixed: #{result[:fixed]}"
    puts "  Skipped: #{result[:skipped]}"

    # Run tests after fixes
    if result[:fixed] > 0 && auto_fix_config.dig("safety_rules", "run_tests_after_fix")
      puts "\nRunning tests..."
      run_verification
    end

    # Git commit if enabled
    if result[:fixed] > 0 && auto_fix_config.dig("git", "enabled") && GitTool.available?
      print "\nCommit fixes to git? [y/N]: "
      if gets.strip.downcase == "y"
        commit_fixes(result[:fixed])
      end
    end
  end

  def commit_fixes(fix_count)
    message = "Fix #{fix_count} defects with Convergence auto-fix\n\nConvergence v#{VERSION}"
    result = GitTool.commit(message)
    
    if result[:success]
      puts "✓ Committed fixes"
    else
      puts "✗ Commit failed: #{result[:error]}"
    end
  end

  def run_verification
    puts "Calculating evidence..."
    calculator = EvidenceCalculator.new(master: @master)
    evidence = calculator.calculate

    puts "\nEvidence: #{evidence[:score].round(3)}"
    puts "  Tests: #{evidence[:tests].round(3)}"
    puts "  Static: #{evidence[:static].round(3)}"
    puts "  Complexity: #{evidence[:complexity].round(3)}"
    puts "\n#{evidence[:pass] ? '✓ Pass' : '✗ Fail'}"
  end

  def show_config
    puts "\nConfiguration:"
    puts "  Version: #{VERSION}"
    puts "  Mode: #{@config.mode}"
    puts "  Profile: #{ProfileManager.current_name}"
    puts "  Access Level: #{@config.access_level}"
    puts "  Model: #{ProfileManager.current['model']}" if @config.mode == :api
    puts "  Auto-fix: #{@config.auto_fix_enabled ? 'enabled' : 'disabled'}"
    puts "  Config: #{Paths::CONFIG_FILE}"
    puts "  Master: #{Paths::MASTER_FILE}"
    puts "  Backups: #{Paths::BACKUP_DIR}"
    puts "  Platform: #{Platform.type}"
  end

  def switch_mode
    puts "\nSwitch mode:"
    puts "  1. API"
    puts "  2. Free"
    puts "  3. Local"
    print "Choice: "

    choice = gets.strip
    new_mode = case choice
               when "1" then :api
               when "2" then :free
               when "3" then :local
               else return puts "Cancelled"
               end

    @config.mode = new_mode
    @config.save
    puts "✓ Switched to #{new_mode} mode (restart to apply)"
  end

  def manage_backups(args)
    subcmd = args.first

    case subcmd
    when "list"
      backups = @backup_manager.list_backups
      if backups.empty?
        puts "No backups found"
      else
        puts "\nBackups:"
        backups.take(10).each_with_index do |backup, idx|
          size_kb = backup[:size] / 1024
          puts "  #{idx + 1}. #{backup[:original_file]} (#{backup[:timestamp].strftime('%Y-%m-%d %H:%M:%S')}) [#{size_kb}KB]"
        end
        puts "  ... and #{backups.size - 10} more" if backups.size > 10
      end
    
    when "cleanup"
      days = args[1]&.to_i || 7
      result = @backup_manager.cleanup_old_backups(days)
      if result[:error]
        puts "✗ Error: #{result[:error]}"
      else
        puts "✓ Removed #{result[:removed]} old backups"
      end
    
    else
      puts "Usage: backup [list|cleanup [days]]"
    end
  end

  def rollback_last_fix
    backups = @backup_manager.list_backups
    
    if backups.empty?
      puts "No backups available"
      return
    end

    puts "\nRecent backups:"
    backups.take(5).each_with_index do |backup, idx|
      puts "  #{idx + 1}. #{backup[:original_file]} (#{backup[:timestamp].strftime('%Y-%m-%d %H:%M:%S')})"
    end

    print "\nSelect backup to restore [1-#{[backups.size, 5].min}] or 'c' to cancel: "
    choice = gets.strip

    return if choice.downcase == "c"

    idx = choice.to_i - 1
    return puts "Invalid selection" if idx < 0 || idx >= backups.size

    backup = backups[idx]
    result = @backup_manager.restore_backup(backup[:path])

    if result[:success]
      puts "✓ Restored #{backup[:original_file]}"
    else
      puts "✗ Restore failed: #{result[:error]}"
    end
  end

  def show_cost_summary
    summary = @cost_tracker.summary
    
    puts "\nAPI Cost Summary:"
    puts "  Total Cost: $#{summary[:total_cost].round(6)}"
    puts "  Cached Savings: $#{summary[:total_savings].round(6)}"
    puts "  Requests: #{summary[:requests]}"
    puts "  Avg Cost/Request: $#{summary[:average_cost].round(6)}" if summary[:requests] > 0
  end

  def show_session_stats
    puts "\nSession Statistics:"
    puts "  Duration: #{@session_timer.report}"
    puts "  Mode: #{@config.mode}"
    puts "  Profile: #{ProfileManager.current_name}"
    
    if @config.mode == :api
      summary = @cost_tracker.summary
      puts "  API Requests: #{summary[:requests]}"
      puts "  API Cost: $#{summary[:total_cost].round(6)}"
    end
  end

  def show_help
    mode_commands = case @config.mode
                    when :api
                      "  analyze  Deep LLM analysis of defects cost         - Show API cost summary"
                    when :free
                      "  goto <url> - Navigate browser\n  screenshot     - Capture page\n  scrape         - Extract data"
                    else
                      ""
                    end

    puts <<~HELP
      Convergence v#{VERSION} Commands:

        help           - Show this help
        profile [name] - Switch AI profile (architect/security/pragmatist/refactorer/tester)
        scan           - Scan codebase for defects
        fix            - Apply auto-fixes
        verify         - Calculate evidence score
        config         - Show configuration
        mode           - Switch modes
        backup         - Manage backups
        rollback       - Restore from backup
        session        - Show session stats
        quit           - Exit

      #{mode_commands}
      
      Available profiles: #{ProfileManager.available.join(', ')}
    HELP
  end
end

# Bootstrap and run
if __FILE__ == $PROGRAM_NAME
  # Setup interrupt handler
  trap("INT") do
    puts "\n\n👋 Goodbye"
    puts "Session duration: #{SessionTimer.new.report}" rescue nil
    exit 130
  end

  begin
    CLI.new.run
  rescue => e
    UnifiedLogger.fatal("Fatal error: #{e.message}")
    puts e.backtrace.take(5).join("\n") if ENV["DEBUG"]
    exit 1
  end
end

3

#!/usr/bin/env ruby
# frozen_string_literal: true

# `cli_test.rb` v0.3.0 (`claude-sonnet-4.5`, `grok-code-fast-1`)

require "minitest/autorun"
require "minitest/pride"
require "tmpdir"
require "fileutils"
require "yaml"
require "json"
require_relative "cli"

# Test Master configuration loading
class MasterTest < Minitest::Test
  def setup
    @valid_data = create_valid_master_data
  end

  def test_initializes_with_valid_data
    master = Master.new(@valid_data)
    assert_instance_of Master, master
  end

  def test_validates_required_sections
    @valid_data.delete("principles")
    error = assert_raises(RuntimeError) { Master.new(@valid_data) }
    assert_match /Missing required sections/, error.message
  end

  def test_validates_persona_count
    @valid_data["personas"]["roles"] = []
    error = assert_raises(RuntimeError) { Master.new(@valid_data) }
    assert_match /Insufficient personas/, error.message
  end

  def test_validates_adversarial_count
    @valid_data["adversarial"]["personas"] = Array.new(5) { {"name" => "test"} }
    error = assert_raises(RuntimeError) { Master.new(@valid_data) }
    assert_match /Insufficient adversarial/, error.message
  end

  def test_version_extraction
    master = Master.new(@valid_data)
    assert_equal "0.3.0", master.version
  end

  def test_principle_lookup
    master = Master.new(@valid_data)
    assert_equal "Don't repeat yourself", master.principle("dry")
  end

  def test_remedy_lookup
    master = Master.new(@valid_data)
    remedy = master.remedy_for("injection_vulnerability")
    assert remedy.is_a?(Hash)
    assert_equal "sanitize_input", remedy["remedy"]
  end

  def test_auto_fixable_check
    master = Master.new(@valid_data)
    assert master.auto_fixable?("injection_vulnerability")
    refute master.auto_fixable?("god_class")
  end

  def test_evidence_threshold
    master = Master.new(@valid_data)
    assert_equal 0.95, master.evidence_threshold
  end

  def test_severity_lookup
    master = Master.new(@valid_data)
    assert_equal :critical, master.severity_for("injection_vulnerability")
    assert_equal :medium, master.severity_for("magic_number")
  end

  def test_openrouter_config
    master = Master.new(@valid_data)
    config = master.openrouter_config
    assert_instance_of Hash, config
    assert_equal "https://openrouter.ai/api/v1/chat/completions", config["api_url"]
  end

  def test_profile_config
    master = Master.new(@valid_data)
    profile = master.profile_config("architect")
    assert profile
    assert_equal 0.3, profile["temperature"]
  end

  private

  def create_valid_master_data
    {
      "meta" => {"version" => "0.3.0"},
      "principles" => {
        "dry" => "Don't repeat yourself",
        "kiss" => "Keep it simple"
      },
      "evidence" => {
        "thresholds" => {"production" => 0.95},
        "weights" => {"tests" => 0.5, "static" => 0.3, "complexity" => 0.2}
      },
      "personas" => {
        "roles" => Array.new(6) { {"name" => "persona", "weight" => 0.1} }
      },
      "adversarial" => {
        "personas" => Array.new(10) { {"name" => "adversary", "temperature" => 0.5} }
      },
      "catalog" => {
        "system" => {
          "injection_vulnerability" => {
            "remedy" => "sanitize_input",
            "auto_fix" => true
          }
        },
        "philosophy" => {
          "magic_number" => {
            "remedy" => "extract_constant",
            "auto_fix" => true
          },
          "god_class" => {
            "remedy" => "split_responsibilities",
            "auto_fix" => false
          }
        }
      },
      "severity" => {
        "critical" => ["injection_vulnerability", "token_exposure"],
        "medium" => ["magic_number"]
      },
      "auto_fix" => {
        "enabled" => true,
        "safety_rules" => {
          "backup_before_fix" => true
        }
      },
      "openrouter" => {
        "api_url" => "https://openrouter.ai/api/v1/chat/completions",
        "models" => {
          "balanced" => {
            "name" => "deepseek/deepseek-r1",
            "cost_per_1m_prompt" => 0.55
          }
        }
      },
      "profiles" => {
        "architect" => {
          "temperature" => 0.3,
          "model" => "anthropic/claude-3.5-sonnet"
        }
      }
    }
  end
end

# Test Config management
class ConfigTest < Minitest::Test
  def setup
    @original_config_file = Paths::CONFIG_FILE
    @temp_dir = Dir.mktmpdir
    @temp_config = File.join(@temp_dir, "config.yml")
    
    Paths.send(:remove_const, :CONFIG_FILE)
    Paths.const_set(:CONFIG_FILE, @temp_config)
  end

  def teardown
    FileUtils.rm_rf(@temp_dir)
    
    Paths.send(:remove_const, :CONFIG_FILE)
    Paths.const_set(:CONFIG_FILE, @original_config_file)
  end

  def test_load_creates_default_config
    config = Config.load
    assert_nil config.mode
    assert_equal :user, config.access_level
    assert config.auto_fix_enabled
    assert_equal :balanced, config.active_profile
  end

  def test_load_reads_existing_config
    File.write(@temp_config, YAML.dump({
      "mode" => "api",
      "access_level" => "admin",
      "auto_fix_enabled" => false,
      "active_profile" => "architect"
    }))

    config = Config.load
    assert_equal :api, config.mode
    assert_equal :admin, config.access_level
    refute config.auto_fix_enabled
    assert_equal :architect, config.active_profile
  end

  def test_save_writes_config
    config = Config.load
    config.mode = :free
    config.auto_fix_enabled = false
    config.active_profile = :security_auditor
    config.save

    assert File.exist?(@temp_config)
    assert_equal 0o600, File.stat(@temp_config).mode & 0o777
  end

  def test_save_and_reload
    config = Config.load
    config.mode = :local
    config.model = "test-model"
    config.active_profile = :pragmatist
    config.save

    reloaded = Config.load
    assert_equal :local, reloaded.mode
    assert_equal "test-model", reloaded.model
    assert_equal :pragmatist, reloaded.active_profile
  end
end

# NEW in v0.3: Test Paths module
class PathsTest < Minitest::Test
  def test_for_returns_platform_specific_path
    path = Paths.for(:samples)
    assert_instance_of String, path
    refute_empty path
  end

  def test_for_expands_env_vars
    ENV['TEST_VAR'] = '/test/path'
    # Would need to stub DEFAULTS to test this properly
    assert Paths.for(:config).include?('.convergence')
  end

  def test_ensure_exists_creates_directories
    temp_dir = Dir.mktmpdir
    test_path = File.join(temp_dir, "test_subdir")
    
    refute Dir.exist?(test_path)
    Paths.ensure_exists(test_path)
    assert Dir.exist?(test_path)
    
    FileUtils.rm_rf(temp_dir)
  end

  def test_ensure_exists_sets_permissions
    temp_dir = Dir.mktmpdir
    test_path = File.join(temp_dir, "test_perms")
    
    Paths.ensure_exists(test_path)
    assert_equal 0o700, File.stat(test_path).mode & 0o777
    
    FileUtils.rm_rf(temp_dir)
  end
end

# Test Platform detection
class PlatformTest < Minitest::Test
  def test_detect_returns_hash
    platform = Platform.detect
    assert_instance_of Hash, platform
    assert_includes platform, :type
    assert_includes platform, :security
  end

  def test_type_returns_symbol
    assert_kind_of Symbol, Platform.type
  end

  def test_security_returns_symbol
    assert_kind_of Symbol, Platform.security
  end

  def test_openbsd_check
    assert [true, false].include?(Platform.openbsd?)
  end

  def test_security_available
    assert [true, false].include?(Platform.security_available?)
  end

  def test_detect_is_cached
    first = Platform.detect
    second = Platform.detect
    assert_same first.object_id, second.object_id
  end
end

# NEW in v0.3: Test VersionChecker
class VersionCheckerTest < Minitest::Test
  def test_check_raises_on_mismatch
    # Would need to mock Master.load for comprehensive testing
    # This tests that the method exists and is callable
    skip "Requires master.yml mocking"
  end
end

# Test ShellTool
class ShellToolTest < Minitest::Test
  def test_rejects_unknown_command
    result = ShellTool.execute("rm", "-rf", "/")
    refute result.success
    assert_match /not allowed/, result.stderr
    assert_equal 127, result.exit_code
  end

  def test_returns_result_struct
    result = ShellTool.execute("git", "--version")
    assert_instance_of ShellTool::Result, result
    assert_respond_to result, :success
    assert_respond_to result, :stdout
    assert_respond_to result, :stderr
    assert_respond_to result, :exit_code
  end

  def test_timeout_handling
    skip "Requires slow command for testing"
  end
end

# Test GitTool
class GitToolTest < Minitest::Test
  def test_available_check
    assert [true, false].include?(GitTool.available?)
  end

  def test_clean_working_tree_when_no_git
    skip unless GitTool.available?
    # Would require git repo setup
  end

  def test_commit_without_git
    skip unless GitTool.available?
    
    result = GitTool.commit("test commit")
    assert_includes result, :success
  end
end

# Test FileTool
class FileToolTest < Minitest::Test
  def setup
    @temp_dir = Dir.mktmpdir
    @file_tool = FileTool.new(base_path: @temp_dir, access_level: :user)
  end

  def teardown
    FileUtils.rm_rf(@temp_dir)
  end

  def test_read_existing_file
    test_file = File.join(@temp_dir, "test.txt")
    File.write(test_file, "content")

    result = @file_tool.read(test_file)
    assert_equal "content", result[:content]
    assert_equal 7, result[:size]
  end

  def test_read_nonexistent_file
    result = @file_tool.read("nonexistent.txt")
    assert_includes result[:error], "not found"
  end

  def test_write_file
    result = @file_tool.write("output.txt", "test content")
    assert result[:success]
    assert_equal 12, result[:bytes]
  end

  def test_sandbox_enforcement
    tool = FileTool.new(base_path: @temp_dir, access_level: :sandbox)
    result = tool.read("/etc/passwd")
    assert_includes result[:error], "Access denied"
  end

  def test_admin_level_allows_all
    tool = FileTool.new(base_path: @temp_dir, access_level: :admin)
    result = tool.read("/nonexistent")
    assert_includes result, :error
  end
end

# Test Scanner with enhanced patterns
class ScannerTest < Minitest::Test
  def setup
    @temp_dir = Dir.mktmpdir
    @master = Master.new(create_valid_master_data)
    @scanner = Scanner.new(master: @master)
  end

  def teardown
    FileUtils.rm_rf(@temp_dir)
  end

  def test_scan_clean_code
    File.write(File.join(@temp_dir, "clean.rb"), "class Clean\nend")
    defects = @scanner.scan(@temp_dir)
    assert_empty defects
  end

  def test_detects_injection_vulnerability
    File.write(File.join(@temp_dir, "bad.rb"), 'system("rm -rf /")')
    defects = @scanner.scan(@temp_dir)

    assert_equal 1, defects.size
    assert_equal :injection_vulnerability, defects.first.type
    assert defects.first.auto_fixable
  end

  def test_detects_token_exposure
    File.write(File.join(@temp_dir, "token.rb"), 'puts "Token abc123def456ghi789"')
    defects = @scanner.scan(@temp_dir)

    token_defect = defects.find { |d| d.type == :token_exposure }
    assert token_defect
    assert_equal :critical, token_defect.severity
  end

  def test_detects_hardcoded_path
    File.write(File.join(@temp_dir, "path.rb"), 'DIR = "/home/user/projects"')
    defects = @scanner.scan(@temp_dir)

    path_defect = defects.find { |d| d.type == :hardcoded_path }
    assert path_defect
  end

  def test_detects_yaml_in_ruby_file
    File.write(File.join(@temp_dir, "yaml.rb"), "version: 1.0.0\nname: test")
    defects = @scanner.scan(@temp_dir)

    yaml_defect = defects.find { |d| d.type == :yaml_in_ruby_file }
    assert yaml_defect
  end

  def test_detects_magic_number
    File.write(File.join(@temp_dir, "magic.rb"), "sleep 500")
    defects = @scanner.scan(@temp_dir)

    magic = defects.find { |d| d.type == :magic_number }
    refute_nil magic
    assert magic.auto_fixable
  end

  def test_skips_vendor_directory
    vendor_dir = File.join(@temp_dir, "vendor")
    FileUtils.mkdir_p(vendor_dir)
    File.write(File.join(vendor_dir, "bad.rb"), 'system("bad")')

    defects = @scanner.scan(@temp_dir)
    assert_empty defects
  end

  def test_reports_file_and_line
    file_path = File.join(@temp_dir, "test.rb")
    File.write(file_path, "# line 1\n# line 2\nsystem('ls')\n")
    defects = @scanner.scan(@temp_dir)

    assert_equal file_path, defects.first.file
    assert_equal 3, defects.first.line
  end

  private

  def create_valid_master_data
    {
      "meta" => {"version" => "0.3.0"},
      "principles" => {"dry" => "test"},
      "evidence" => {
        "thresholds" => {"production" => 0.95},
        "weights" => {"tests" => 0.5}
      },
      "personas" => {
        "roles" => Array.new(6) { {"name" => "p"} }
      },
      "adversarial" => {
        "personas" => Array.new(10) { {"name" => "a"} }
      },
      "catalog" => {
        "system" => {
          "injection_vulnerability" => {
            "remedy" => "sanitize_input",
            "auto_fix" => true
          },
          "token_exposure" => {
            "remedy" => "sanitize_error_messages",
            "auto_fix" => true
          },
          "hardcoded_path" => {
            "remedy" => "use_paths_module",
            "auto_fix" => true
          }
        },
        "logic" => {
          "yaml_in_ruby_file" => {
            "remedy" => "rename_or_wrap",
            "auto_fix" => true
          }
        },
        "philosophy" => {
          "magic_number" => {
            "remedy" => "extract_constant",
            "auto_fix" => true
          }
        }
      },
      "severity" => {
        "critical" => ["injection_vulnerability", "token_exposure"],
        "high" => ["yaml_in_ruby_file"],
        "medium" => ["hardcoded_path", "magic_number"]
      }
    }
  end
end

# Test BackupManager
class BackupManagerTest < Minitest::Test
  def setup
    @temp_dir = Dir.mktmpdir
    @original_backup_dir = Paths::BACKUP_DIR
    
    Paths.send(:remove_const, :BACKUP_DIR)
    Paths.const_set(:BACKUP_DIR, @temp_dir)
    
    @backup_manager = BackupManager.new
    @test_file = File.join(@temp_dir, "test.rb")
    File.write(@test_file, "original content")
  end

  def teardown
    FileUtils.rm_rf(@temp_dir)
    
    Paths.send(:remove_const, :BACKUP_DIR)
    Paths.const_set(:BACKUP_DIR, @original_backup_dir)
  end

  def test_create_backup
    result = @backup_manager.create_backup(@test_file)
    
    assert result[:success]
    assert File.exist?(result[:backup_path])
    assert_equal "original content", File.read(result[:backup_path])
  end

  def test_list_backups
    @backup_manager.create_backup(@test_file)
    sleep 0.1
    @backup_manager.create_backup(@test_file)
    
    backups = @backup_manager.list_backups
    assert_equal 2, backups.size
    assert backups.first[:timestamp] > backups.last[:timestamp]
  end

  def test_restore_backup
    backup_result = @backup_manager.create_backup(@test_file)
    File.write(@test_file, "modified content")
    
    restore_result = @backup_manager.restore_backup(backup_result[:backup_path], @test_file)
    
    assert restore_result[:success]
    assert_equal "original content", File.read(@test_file)
  end

  def test_cleanup_old_backups
    old_backup = File.join(@temp_dir, "old_test.rb_20200101_120000.bak")
    File.write(old_backup, "old")
    FileUtils.touch(old_backup, mtime: Time.now - (8 * 24 * 60 * 60))
    
    result = @backup_manager.cleanup_old_backups(7)
    assert_equal 1, result[:removed]
    refute File.exist?(old_backup)
  end
end

# Test AutoFixer (basic structure)
class AutoFixerTest < Minitest::Test
  def setup
    @temp_dir = Dir.mktmpdir
    @original_backup_dir = Paths::BACKUP_DIR
    
    Paths.send(:remove_const, :BACKUP_DIR)
    Paths.const_set(:BACKUP_DIR, @temp_dir)
    
    @master = Master.new(create_valid_master_data)
    @backup_manager = BackupManager.new
    @auto_fixer = AutoFixer.new(master: @master, backup_manager: @backup_manager)
  end

  def teardown
    FileUtils.rm_rf(@temp_dir)
    
    Paths.send(:remove_const, :BACKUP_DIR)
    Paths.const_set(:BACKUP_DIR, @original_backup_dir)
  end

  def test_fix_defects_with_no_fixable
    defects = [
      Scanner::Defect.new(
        type: :god_class,
        auto_fixable: false,
        file: "test.rb",
        line: 1,
        severity: :low,
        remedy: {"remedy" => "split_responsibilities", "auto_fix" => false}
      )
    ]
    
    result = @auto_fixer.fix_defects(defects, interactive: false)
    assert_equal 0, result[:fixed]
    assert_equal 1, result[:skipped]
  end

  def test_creates_backup_before_fix
    test_file = File.join(@temp_dir, "test.rb")
    File.write(test_file, 'system("ls")')
    
    defects = [
      Scanner::Defect.new(
        type: :injection_vulnerability,
        auto_fixable: true,
        file: test_file,
        line: 1,
        severity: :critical,
        remedy: {"remedy" => "sanitize_input", "auto_fix" => true}
      )
    ]
    
    # Mock stdin for interactive prompt
    original_stdin = $stdin
    $stdin = StringIO.new("y\n")
    
    @auto_fixer.fix_defects(defects, interactive: true)
    
    $stdin = original_stdin
    
    backups = @backup_manager.list_backups
    assert backups.size >= 1
  end

  private

  def create_valid_master_data
    {
      "meta" => {"version" => "0.3.0"},
      "principles" => {"dry" => "test"},
      "evidence" => {
        "thresholds" => {"production" => 0.95},
        "weights" => {"tests" => 0.5}
      },
      "personas" => {
        "roles" => Array.new(6) { {"name" => "p"} }
      },
      "adversarial" => {
        "personas" => Array.new(10) { {"name" => "a"} }
      },
      "catalog" => {
        "system" => {
          "injection_vulnerability" => {
            "remedy" => "sanitize_input",
            "auto_fix" => true
          }
        },
        "philosophy" => {
          "god_class" => {
            "remedy" => "split_responsibilities",
            "auto_fix" => false
          }
        }
      },
      "severity" => {
        "critical" => ["injection_vulnerability"],
        "low" => ["god_class"]
      }
    }
  end
end

# Test EvidenceCalculator
class EvidenceCalculatorTest < Minitest::Test
  def setup
    @master = Master.new(create_valid_master_data)
    @calculator = EvidenceCalculator.new(master: @master)
  end

  def test_calculate_returns_hash
    evidence = @calculator.calculate
    assert_instance_of Hash, evidence
    assert_includes evidence, :score
    assert_includes evidence, :pass
  end

  def test_score_is_numeric
    evidence = @calculator.calculate
    assert_kind_of Numeric, evidence[:score]
    assert evidence[:score] >= 0
    assert evidence[:score] <= 1
  end

  def test_pass_is_boolean
    evidence = @calculator.calculate
    assert [true, false].include?(evidence[:pass])
  end

  private

  def create_valid_master_data
    {
      "meta" => {"version" => "0.3.0"},
      "principles" => {"dry" => "test"},
      "evidence" => {
        "thresholds" => {"production" => 0.95},
        "weights" => {"tests" => 0.5, "static" => 0.3, "complexity" => 0.2}
      },
      "personas" => {
        "roles" => Array.new(6) { {"name" => "p"} }
      },
      "adversarial" => {
        "personas" => Array.new(10) { {"name" => "a"} }
      },
      "catalog" => {}
    }
  end
end

class CostTrackerTest < Minitest::Test
  def setup
    @master = Master.new(create_valid_master_data)
    @tracker = CostTracker.new(master: @master)
  end

  def test_record_calculates_cost
    response = {
      "usage" => {
        "prompt_tokens" => 1000,
        "completion_tokens" => 500,
        "prompt_tokens_details" => {
          "cached_tokens" => 200
        }
      }
    }

    result = @tracker.record(response, "deepseek/deepseek-r1")
    
    assert result
    assert_kind_of Numeric, result[:cost]
    assert_equal 200, result[:cached_tokens]
    assert_kind_of Numeric, result[:savings]
  end

  def test_summary_provides_totals
    summary = @tracker.summary
    
    assert_instance_of Hash, summary
    assert_includes summary, :total_cost
    assert_includes summary, :total_savings
    assert_includes summary, :requests
    assert_includes summary, :average_cost
  end

  private

  def create_valid_master_data
    {
      "meta" => {"version" => "0.3.0"},
      "principles" => {"dry" => "test"},
      "evidence" => {
        "thresholds" => {"production" => 0.95},
        "weights" => {"tests" => 0.5}
      },
      "personas" => {
        "roles" => Array.new(6) { {"name" => "p"} }
      },
      "adversarial" => {
        "personas" => Array.new(10) { {"name" => "a"} }
      },
      "catalog" => {},
      "openrouter" => {
        "models" => {
          "balanced" => {
            "name" => "deepseek/deepseek-r1",
            "cost_per_1m_prompt" => 0.55,
            "cost_per_1m_completion" => 2.19,
            "cost_per_1m_cached" => 0.055
          }
        }
      }
    }
  end
end

class ProfileManagerTest < Minitest::Test
  def setup
    @master = Master.new(create_valid_master_data)
    ProfileManager.load_profiles(@master)
  end

  def test_load_profiles
    assert ProfileManager.available.include?("architect")
    assert ProfileManager.available.include?("security_auditor")
  end

  def test_switch_profile
    result = ProfileManager.switch(:architect)
    assert result
    assert_equal :architect, ProfileManager.current_name
  end

  def test_switch_invalid_profile
    result = ProfileManager.switch(:nonexistent)
    refute result
  end

  def test_current_returns_profile
    ProfileManager.switch(:architect)
    profile = ProfileManager.current
    
    assert_instance_of Hash, profile
    assert_equal 0.3, profile["temperature"]
  end

  private

  def create_valid_master_data
    {
      "meta" => {"version" => "0.3.0"},
      "principles" => {"dry" => "test"},
      "evidence" => {
        "thresholds" => {"production" => 0.95},
        "weights" => {"tests" => 0.5}
      },
      "personas" => {
        "roles" => Array.new(6) { {"name" => "p"} }
      },
      "adversarial" => {
        "personas" => Array.new(10) { {"name" => "a"} }
      },
      "catalog" => {},
      "profiles" => {
        "architect" => {
          "temperature" => 0.3,
          "model" => "anthropic/claude-3.5-sonnet",
          "focus" => ["structure", "patterns"]
        },
        "security_auditor" => {
          "temperature" => 0.1,
          "model" => "anthropic/claude-3.5-sonnet",
          "focus" => ["vulnerabilities"]
        }
      }
    }
  end
end

class SessionTimerTest < Minitest::Test
  def test_elapsed_increases_over_time
    timer = SessionTimer.new
    sleep 0.1
    assert timer.elapsed > 0
  end

  def test_report_formats_time
    timer = SessionTimer.new
    sleep 1.1
    
    report = timer.report
    assert_instance_of String, report
    assert_match /\ds/, report
  end

  def test_report_includes_minutes
    timer = SessionTimer.new
    # Stub elapsed to return 65 seconds
    def timer.elapsed; 65; end
    
    report = timer.report
    assert_includes report, "1m"
  end
end

# Integration test
class IntegrationTest < Minitest::Test
  def setup
    @temp_dir = Dir.mktmpdir
    @master_file = File.join(@temp_dir, "master.yml")
    create_test_master_file
    
    @original_master = Paths::MASTER_FILE
    Paths.send(:remove_const, :MASTER_FILE)
    Paths.const_set(:MASTER_FILE, @master_file)
  end

  def teardown
    FileUtils.rm_rf(@temp_dir)
    
    Paths.send(:remove_const, :MASTER_FILE)
    Paths.const_set(:MASTER_FILE, @original_master)
  end

  def test_full_scan_and_detect_workflow
    master = Master.load
    scanner = Scanner.new(master: master)

    bad_file = File.join(@temp_dir, "bad.rb")
    File.write(bad_file, 'system("rm -rf /")')

    defects = scanner.scan(@temp_dir)
    refute_empty defects
    assert defects.first.auto_fixable
    assert_equal :injection_vulnerability, defects.first.type
  end

  def test_evidence_calculation_workflow
    master = Master.load
    calculator = EvidenceCalculator.new(master: master)

    evidence = calculator.calculate
    assert_instance_of Hash, evidence
    assert_includes [true, false], evidence[:pass]
  end

  def test_version_check_passes
    # Should not raise since we're using matching versions
    assert_silent { VersionChecker.check! }
  end

  private

  def create_test_master_file
    data = {
      "meta" => {"version" => "0.3.0"},
      "principles" => {"dry" => "test"},
      "evidence" => {
        "thresholds" => {"production" => 0.95},
        "weights" => {"tests" => 0.5, "static" => 0.3, "complexity" => 0.2}
      },
      "personas" => {
        "roles" => Array.new(6) { {"name" => "persona"} }
      },
      "adversarial" => {
        "personas" => Array.new(10) { {"name" => "adversary"} }
      },
      "catalog" => {
        "system" => {
          "injection_vulnerability" => {
            "remedy" => "sanitize_input",
            "auto_fix" => true
          }
        }
      },
      "severity" => {
        "critical" => ["injection_vulnerability"]
      },
      "auto_fix" => {
        "enabled" => true
      },
      "openrouter" => {
        "api_url" => "https://openrouter.ai/api/v1/chat/completions",
        "models" => {
          "balanced" => {
            "name" => "deepseek/deepseek-r1",
            "cost_per_1m_prompt" => 0.55,
            "cost_per_1m_completion" => 2.19,
            "cost_per_1m_cached" => 0.055
          }
        }
      },
      "profiles" => {
        "architect" => {
          "temperature" => 0.3,
          "model" => "anthropic/claude-3.5-sonnet"
        }
      }
    }

    File.write(@master_file, YAML.dump(data))
  end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment