[GH-ISSUE #98] (feat) Support variable substitution in config paths #40

Closed
opened 2026-02-27 10:21:54 +03:00 by kerem · 4 comments
Owner

Originally created by @cdriscol on GitHub (Jan 23, 2026).
Original GitHub issue: https://github.com/mikeyobrien/ralph-orchestrator/issues/98

I want to group scratchpads with their related work so I can check them into the repo alongside each feature. For a lot of my flows, I use the scratchpad as a task tracker and create detailed spec files as Ralph runs. It would be nice to have the option to group all of that in a common folder.

I'm also open to suggestions or being told I'm doing this wrong—maybe scratchpad should be ignored?

I group things like this:

.ralph/
├── feature-one/
│   ├── scratchpad.md
│   └── specs/
│       ├── spec-01.md
│       └── spec-02.md
└── other-feature/
    ├── scratchpad.md
    └── specs/
        └── ...

Currently, paths like .ralph/{feature}/scratchpad.md are read literally—{feature} isn't substituted. This causes scratchpad verification to fail and LOOP_COMPLETE to be rejected, resulting in infinite loops.

Proposed Solutions

Option A: Config-declared variables with CLI override

yaml
variables:
  - name: feature
    required: true
  - name: environment
    default: desktop

core:
  scratchpad: ".ralph/{feature}/scratchpad.md"
ralph run -v "feature:feature-one" -c config.yml

Missing required variables fail fast with a helpful error. This could also support other variables if needed (e.g., conditional logic based on env) and defaults.

Option B: CLI flag for scratchpad path

ralph run --core.scratchpad ".ralph/feature/scratchpad.md"

Open to other ideas too.

Originally created by @cdriscol on GitHub (Jan 23, 2026). Original GitHub issue: https://github.com/mikeyobrien/ralph-orchestrator/issues/98 I want to group scratchpads with their related work so I can check them into the repo alongside each feature. For a lot of my flows, I use the scratchpad as a task tracker and create detailed spec files as Ralph runs. It would be nice to have the option to group all of that in a common folder. I'm also open to suggestions or being told I'm doing this wrong—maybe scratchpad should be ignored? I group things like this: ``` .ralph/ ├── feature-one/ │ ├── scratchpad.md │ └── specs/ │ ├── spec-01.md │ └── spec-02.md └── other-feature/ ├── scratchpad.md └── specs/ └── ... ``` Currently, paths like `.ralph/{feature}/scratchpad.md` are read literally—`{feature}` isn't substituted. This causes scratchpad verification to fail and `LOOP_COMPLETE` to be rejected, resulting in infinite loops. ### Proposed Solutions #### Option A: Config-declared variables with CLI override ``` yaml variables: - name: feature required: true - name: environment default: desktop core: scratchpad: ".ralph/{feature}/scratchpad.md" ``` ```bash ralph run -v "feature:feature-one" -c config.yml ``` Missing required variables fail fast with a helpful error. This could also support other variables if needed (e.g., conditional logic based on env) and defaults. #### Option B: CLI flag for scratchpad path ```bash ralph run --core.scratchpad ".ralph/feature/scratchpad.md" ``` Open to other ideas too.
kerem closed this issue 2026-02-27 10:21:54 +03:00
Author
Owner

@mikeyobrien commented on GitHub (Jan 23, 2026):

Try using ralph plan on what you are thinking, share the detailed design that it spits out.

<!-- gh-comment-id:3793020026 --> @mikeyobrien commented on GitHub (Jan 23, 2026): Try using `ralph plan` on what you are thinking, share the detailed design that it spits out.
Author
Owner

@cdriscol commented on GitHub (Jan 24, 2026):

Plan mode was pretty cool, it walked me through a decent amount of questions and landed here.

Detailed Design: CLI Config Overrides for Core Fields

Overview

This document describes the implementation of CLI config overrides for core.* fields in Ralph, enabling users to specify configuration values directly from the command line using the existing -c flag with key=value syntax.

Detailed Requirements

Functional Requirements

  1. Extended -c flag syntax: The -c flag accepts both file paths and key=value overrides

    ralph run -c ralph.yml -c core.scratchpad=".ralph/feature/scratchpad.md"
    
  2. Precedence: CLI overrides take precedence over config file values

    • Default values (lowest)
    • Config file values
    • CLI overrides (highest)
  3. Scope: Only core.* fields are overridable in this implementation

    • core.scratchpad — Path to scratchpad file
    • core.specs_dir — Path to specs directory
  4. Directory auto-creation: When scratchpad path specifies non-existent parent directories, create them automatically

  5. Validation: Warn (don't error) on unknown field names to catch typos while remaining flexible

Non-Functional Requirements

  1. Backward compatibility: Existing -c file.yml usage unchanged
  2. Ergonomic CLI: Natural extension of existing patterns
  3. Minimal code changes: Leverage existing override infrastructure

Architecture Overview

flowchart TD
    CLI[CLI Args: -c values] --> Parse[Parse -c arguments]
    Parse --> Detect{Contains '='?}
    Detect -->|No| Files[Config Files List]
    Detect -->|Yes| Overrides[Overrides List]
    Files --> Load[Load & Merge Configs]
    Load --> Normalize[Normalize v1→v2]
    Normalize --> ApplyOverrides[Apply CLI Overrides]
    Overrides --> ApplyOverrides
    ApplyOverrides --> Validate[Validate Config]
    Validate --> AutoCreate[Auto-create Directories]
    AutoCreate --> Run[Run Orchestration]

Components and Interfaces

1. ConfigSource Extension

File: crates/ralph-cli/src/main.rs

Extend ConfigSource enum to handle inline overrides:

#[derive(Debug, Clone)]
pub enum ConfigSource {
    File(PathBuf),
    Builtin(String),
    Remote(String),
    Override { key: String, value: String },  // NEW
}

impl ConfigSource {
    pub fn parse(s: &str) -> Self {
        // NEW: Check for key=value pattern first
        if let Some((key, value)) = s.split_once('=') {
            return ConfigSource::Override {
                key: key.to_string(),
                value: value.to_string(),
            };
        }

        // Existing logic for files, builtins, remotes
        if s.starts_with("builtin:") {
            ConfigSource::Builtin(s.strip_prefix("builtin:").unwrap().to_string())
        } else if s.starts_with("http://") || s.starts_with("https://") {
            ConfigSource::Remote(s.to_string())
        } else {
            ConfigSource::File(PathBuf::from(s))
        }
    }

    pub fn is_override(&self) -> bool {
        matches!(self, ConfigSource::Override { .. })
    }
}

2. CLI Argument Changes

File: crates/ralph-cli/src/main.rs

Change -c from single value to multiple values:

#[derive(Parser)]
pub struct Cli {
    /// Config file path(s) or inline overrides (key=value)
    /// Can be specified multiple times. Overrides take precedence.
    #[arg(short, long, default_value = "ralph.yml", global = true, action = ArgAction::Append)]
    config: Vec<String>,

    // ... rest unchanged
}

3. Override Application

File: crates/ralph-cli/src/main.rs

New function to apply overrides:

/// Known core.* fields that can be overridden
const KNOWN_CORE_FIELDS: &[&str] = &["scratchpad", "specs_dir"];

fn apply_config_overrides(
    config: &mut RalphConfig,
    overrides: &[ConfigSource],
) -> anyhow::Result<()> {
    for source in overrides {
        if let ConfigSource::Override { key, value } = source {
            match key.as_str() {
                "core.scratchpad" => {
                    config.core.scratchpad = value.clone();
                }
                "core.specs_dir" => {
                    config.core.specs_dir = value.clone();
                }
                other => {
                    // Check if it looks like a core field typo
                    if other.starts_with("core.") {
                        let field = other.strip_prefix("core.").unwrap();
                        warn!(
                            "Unknown core field '{}'. Known fields: {}",
                            field,
                            KNOWN_CORE_FIELDS.join(", ")
                        );
                    } else {
                        warn!(
                            "Override '{}' ignored. Only core.* fields supported.",
                            other
                        );
                    }
                }
            }
        }
    }
    Ok(())
}

4. Directory Auto-Creation

File: crates/ralph-cli/src/main.rs

Add directory creation after config is finalized:

fn ensure_scratchpad_directory(config: &RalphConfig) -> anyhow::Result<()> {
    let scratchpad_path = config.core.resolve_path(&config.core.scratchpad);
    if let Some(parent) = scratchpad_path.parent() {
        if !parent.exists() {
            info!("Creating scratchpad directory: {}", parent.display());
            std::fs::create_dir_all(parent)?;
        }
    }
    Ok(())
}

5. Updated run_command Flow

File: crates/ralph-cli/src/main.rs

async fn run_command(cli: &Cli, args: RunArgs) -> anyhow::Result<()> {
    // 1. Separate config sources into files and overrides
    let sources: Vec<ConfigSource> = cli.config.iter()
        .map(|s| ConfigSource::parse(s))
        .collect();

    let (overrides, files): (Vec<_>, Vec<_>) = sources
        .into_iter()
        .partition(|s| s.is_override());

    // 2. Load config from first file source (or default)
    let file_source = files.into_iter().next()
        .unwrap_or_else(|| ConfigSource::File(PathBuf::from("ralph.yml")));

    let mut config = match file_source {
        ConfigSource::File(path) => { /* existing logic */ }
        ConfigSource::Builtin(name) => { /* existing logic */ }
        ConfigSource::Remote(url) => { /* existing logic */ }
        ConfigSource::Override { .. } => unreachable!(),
    };

    // 3. Normalize config (v1 → v2)
    config.normalize();

    // 4. Apply existing CLI arg overrides (--backend, --max-iterations, etc.)
    // ... existing override logic ...

    // 5. Apply -c key=value overrides (NEW - applied last for highest precedence)
    apply_config_overrides(&mut config, &overrides)?;

    // 6. Validate
    config.validate()?;

    // 7. Auto-create scratchpad directory if needed (NEW)
    ensure_scratchpad_directory(&config)?;

    // 8. Continue with orchestration...
}

Data Models

No new data models required. Uses existing:

  • RalphConfig — Top-level configuration
  • CoreConfig — Core settings including scratchpad
  • ConfigSource — Extended with Override variant

Error Handling

Scenario Behavior
Unknown core.* field Warn, continue
Non-core.* override Warn, continue
Invalid path characters OS-level error on directory creation
Permission denied (mkdir) Error with clear message
Multiple file configs Use first file, warn about others

Testing Strategy

Unit Tests

  1. ConfigSource parsing

    • "ralph.yml"File
    • "core.scratchpad=path"Override
    • "builtin:default"Builtin
    • "https://..."Remote
  2. Override application

    • Known field updates config
    • Unknown field logs warning
    • Non-core field logs warning
  3. Precedence

    • CLI override beats config file value

Integration Tests

  1. End-to-end override

    ralph run -c ralph.yml -c core.scratchpad=".ralph/test/scratchpad.md" --dry-run
    

    Verify scratchpad path in dry-run output.

  2. Directory creation

    • Run with non-existent directory path
    • Verify directory created

Smoke Tests

Add fixture test for config override:

  • Input: Config with override
  • Expected: Override applied correctly

Appendices

A. Technology Choices

Choice Rationale
Extend -c flag Reuses existing CLI pattern, familiar to users
key=value syntax Common convention (Helm, Docker, etc.)
Warn on unknown Catches typos without breaking workflows

B. Alternative Approaches Considered

  1. Dedicated --set flag (like Helm)

    • Pro: Clear separation from config files
    • Con: New flag to learn, -c already overloaded
    • Decision: Rejected for simplicity
  2. Full variable substitution ({feature} syntax)

    • Pro: More flexible
    • Con: Over-engineered for current need
    • Decision: Deferred (YAGNI)
  3. Strict validation (error on unknown)

    • Pro: Catches all typos
    • Con: Breaks if user has newer config than CLI
    • Decision: Warn instead

C. Research Findings Summary

  • Current -c only handles file paths
  • Override pattern exists for other CLI args (--backend, etc.)
  • CoreConfig has scratchpad and specs_dir as primary path fields
  • resolve_path() handles relative → absolute conversion
  • Scratchpad used in resume mode, termination handling, and prompt building

D. Future Extensions

If more overrides are needed later:

  1. Extend KNOWN_CORE_FIELDS constant
  2. Add match arm in apply_config_overrides()
  3. Consider event_loop.* and cli.* sections if demand exists
<!-- gh-comment-id:3794396718 --> @cdriscol commented on GitHub (Jan 24, 2026): > _Plan mode was pretty cool, it walked me through a decent amount of questions and landed here._ # Detailed Design: CLI Config Overrides for Core Fields ## Overview This document describes the implementation of CLI config overrides for `core.*` fields in Ralph, enabling users to specify configuration values directly from the command line using the existing `-c` flag with `key=value` syntax. ## Detailed Requirements ### Functional Requirements 1. **Extended `-c` flag syntax**: The `-c` flag accepts both file paths and `key=value` overrides ```bash ralph run -c ralph.yml -c core.scratchpad=".ralph/feature/scratchpad.md" ``` 2. **Precedence**: CLI overrides take precedence over config file values - Default values (lowest) - Config file values - CLI overrides (highest) 3. **Scope**: Only `core.*` fields are overridable in this implementation - `core.scratchpad` — Path to scratchpad file - `core.specs_dir` — Path to specs directory 4. **Directory auto-creation**: When scratchpad path specifies non-existent parent directories, create them automatically 5. **Validation**: Warn (don't error) on unknown field names to catch typos while remaining flexible ### Non-Functional Requirements 1. **Backward compatibility**: Existing `-c file.yml` usage unchanged 2. **Ergonomic CLI**: Natural extension of existing patterns 3. **Minimal code changes**: Leverage existing override infrastructure ## Architecture Overview ```mermaid flowchart TD CLI[CLI Args: -c values] --> Parse[Parse -c arguments] Parse --> Detect{Contains '='?} Detect -->|No| Files[Config Files List] Detect -->|Yes| Overrides[Overrides List] Files --> Load[Load & Merge Configs] Load --> Normalize[Normalize v1→v2] Normalize --> ApplyOverrides[Apply CLI Overrides] Overrides --> ApplyOverrides ApplyOverrides --> Validate[Validate Config] Validate --> AutoCreate[Auto-create Directories] AutoCreate --> Run[Run Orchestration] ``` ## Components and Interfaces ### 1. ConfigSource Extension **File:** `crates/ralph-cli/src/main.rs` Extend `ConfigSource` enum to handle inline overrides: ```rust #[derive(Debug, Clone)] pub enum ConfigSource { File(PathBuf), Builtin(String), Remote(String), Override { key: String, value: String }, // NEW } impl ConfigSource { pub fn parse(s: &str) -> Self { // NEW: Check for key=value pattern first if let Some((key, value)) = s.split_once('=') { return ConfigSource::Override { key: key.to_string(), value: value.to_string(), }; } // Existing logic for files, builtins, remotes if s.starts_with("builtin:") { ConfigSource::Builtin(s.strip_prefix("builtin:").unwrap().to_string()) } else if s.starts_with("http://") || s.starts_with("https://") { ConfigSource::Remote(s.to_string()) } else { ConfigSource::File(PathBuf::from(s)) } } pub fn is_override(&self) -> bool { matches!(self, ConfigSource::Override { .. }) } } ``` ### 2. CLI Argument Changes **File:** `crates/ralph-cli/src/main.rs` Change `-c` from single value to multiple values: ```rust #[derive(Parser)] pub struct Cli { /// Config file path(s) or inline overrides (key=value) /// Can be specified multiple times. Overrides take precedence. #[arg(short, long, default_value = "ralph.yml", global = true, action = ArgAction::Append)] config: Vec<String>, // ... rest unchanged } ``` ### 3. Override Application **File:** `crates/ralph-cli/src/main.rs` New function to apply overrides: ```rust /// Known core.* fields that can be overridden const KNOWN_CORE_FIELDS: &[&str] = &["scratchpad", "specs_dir"]; fn apply_config_overrides( config: &mut RalphConfig, overrides: &[ConfigSource], ) -> anyhow::Result<()> { for source in overrides { if let ConfigSource::Override { key, value } = source { match key.as_str() { "core.scratchpad" => { config.core.scratchpad = value.clone(); } "core.specs_dir" => { config.core.specs_dir = value.clone(); } other => { // Check if it looks like a core field typo if other.starts_with("core.") { let field = other.strip_prefix("core.").unwrap(); warn!( "Unknown core field '{}'. Known fields: {}", field, KNOWN_CORE_FIELDS.join(", ") ); } else { warn!( "Override '{}' ignored. Only core.* fields supported.", other ); } } } } } Ok(()) } ``` ### 4. Directory Auto-Creation **File:** `crates/ralph-cli/src/main.rs` Add directory creation after config is finalized: ```rust fn ensure_scratchpad_directory(config: &RalphConfig) -> anyhow::Result<()> { let scratchpad_path = config.core.resolve_path(&config.core.scratchpad); if let Some(parent) = scratchpad_path.parent() { if !parent.exists() { info!("Creating scratchpad directory: {}", parent.display()); std::fs::create_dir_all(parent)?; } } Ok(()) } ``` ### 5. Updated run_command Flow **File:** `crates/ralph-cli/src/main.rs` ```rust async fn run_command(cli: &Cli, args: RunArgs) -> anyhow::Result<()> { // 1. Separate config sources into files and overrides let sources: Vec<ConfigSource> = cli.config.iter() .map(|s| ConfigSource::parse(s)) .collect(); let (overrides, files): (Vec<_>, Vec<_>) = sources .into_iter() .partition(|s| s.is_override()); // 2. Load config from first file source (or default) let file_source = files.into_iter().next() .unwrap_or_else(|| ConfigSource::File(PathBuf::from("ralph.yml"))); let mut config = match file_source { ConfigSource::File(path) => { /* existing logic */ } ConfigSource::Builtin(name) => { /* existing logic */ } ConfigSource::Remote(url) => { /* existing logic */ } ConfigSource::Override { .. } => unreachable!(), }; // 3. Normalize config (v1 → v2) config.normalize(); // 4. Apply existing CLI arg overrides (--backend, --max-iterations, etc.) // ... existing override logic ... // 5. Apply -c key=value overrides (NEW - applied last for highest precedence) apply_config_overrides(&mut config, &overrides)?; // 6. Validate config.validate()?; // 7. Auto-create scratchpad directory if needed (NEW) ensure_scratchpad_directory(&config)?; // 8. Continue with orchestration... } ``` ## Data Models No new data models required. Uses existing: - `RalphConfig` — Top-level configuration - `CoreConfig` — Core settings including scratchpad - `ConfigSource` — Extended with `Override` variant ## Error Handling | Scenario | Behavior | |----------|----------| | Unknown `core.*` field | Warn, continue | | Non-`core.*` override | Warn, continue | | Invalid path characters | OS-level error on directory creation | | Permission denied (mkdir) | Error with clear message | | Multiple file configs | Use first file, warn about others | ## Testing Strategy ### Unit Tests 1. **ConfigSource parsing** - `"ralph.yml"` → `File` - `"core.scratchpad=path"` → `Override` - `"builtin:default"` → `Builtin` - `"https://..."` → `Remote` 2. **Override application** - Known field updates config - Unknown field logs warning - Non-core field logs warning 3. **Precedence** - CLI override beats config file value ### Integration Tests 1. **End-to-end override** ```bash ralph run -c ralph.yml -c core.scratchpad=".ralph/test/scratchpad.md" --dry-run ``` Verify scratchpad path in dry-run output. 2. **Directory creation** - Run with non-existent directory path - Verify directory created ### Smoke Tests Add fixture test for config override: - Input: Config with override - Expected: Override applied correctly ## Appendices ### A. Technology Choices | Choice | Rationale | |--------|-----------| | Extend `-c` flag | Reuses existing CLI pattern, familiar to users | | `key=value` syntax | Common convention (Helm, Docker, etc.) | | Warn on unknown | Catches typos without breaking workflows | ### B. Alternative Approaches Considered 1. **Dedicated `--set` flag** (like Helm) - Pro: Clear separation from config files - Con: New flag to learn, `-c` already overloaded - Decision: Rejected for simplicity 2. **Full variable substitution** (`{feature}` syntax) - Pro: More flexible - Con: Over-engineered for current need - Decision: Deferred (YAGNI) 3. **Strict validation (error on unknown)** - Pro: Catches all typos - Con: Breaks if user has newer config than CLI - Decision: Warn instead ### C. Research Findings Summary - Current `-c` only handles file paths - Override pattern exists for other CLI args (`--backend`, etc.) - `CoreConfig` has `scratchpad` and `specs_dir` as primary path fields - `resolve_path()` handles relative → absolute conversion - Scratchpad used in resume mode, termination handling, and prompt building ### D. Future Extensions If more overrides are needed later: 1. Extend `KNOWN_CORE_FIELDS` constant 2. Add match arm in `apply_config_overrides()` 3. Consider `event_loop.*` and `cli.*` sections if demand exists
Author
Owner

@mikeyobrien commented on GitHub (Jan 24, 2026):

LGTM, feel free to implement and raise a PR.

<!-- gh-comment-id:3795222710 --> @mikeyobrien commented on GitHub (Jan 24, 2026): LGTM, feel free to implement and raise a PR.
Author
Owner

@mikeyobrien commented on GitHub (Jan 25, 2026):

Closed by #115 - config override support for core fields has been implemented and merged.

<!-- gh-comment-id:3797358341 --> @mikeyobrien commented on GitHub (Jan 25, 2026): Closed by #115 - config override support for core fields has been implemented and merged.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/ralph-orchestrator#40
No description provided.