Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ linters:
- noctx
- usestdlibvars
path: _test\.go$
- linters:
- gosec
text: "G704: SSRF via taint analysis"
path: _test\.go$
- linters:
- errcheck
text: "Error return value of .*.Close.*is not checked"
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ GOOS=windows GOARCH=amd64 go build ./...
- `session_timeout` config option: per-session timeout for claude (e.g., "30m", "1h"). Kills hanging sessions and continues to next iteration. CLI flag `--session-timeout` takes precedence. Disabled by default
- `idle_timeout` config option: kills claude sessions when no output for specified duration (e.g., "5m"). Resets on each output line, only fires when session goes silent. CLI flag `--idle-timeout` takes precedence. Disabled by default
- `move_plan_on_completion` config option: controls whether completed plans move to `docs/plans/completed/` on success. Default `true`. Disable for workflows that manage plan lifecycle externally (spec-driven tooling with separate archive steps)
- `task_header_patterns` config option: comma-separated list of preset names (`default`, `openspec`) or raw Go regexes controlling which headers the plan parser recognizes as task sections. Capture group 1 = task id (required), capture group 2 = title (optional). Default: `default` (matches `### Task N: title` and `### Iteration N: title`). Use `openspec` or a raw regex for spec-driven workflows that use different header shapes. Resolved via `plan.ResolveHeaderPatterns` in `pkg/plan/presets.go`. Key files: `pkg/plan/presets.go` (preset registry, `ResolveHeaderPattern`, `ResolveHeaderPatterns`, `DefaultHeaderPatterns`, `PresetDescription`); `pkg/plan/parse.go` (`ParsePlan(content string, patterns []*regexp.Regexp)`, `ParsePlanFile(path string, patterns []*regexp.Regexp)` — callers resolve `[]string` config values via `plan.ResolveHeaderPatterns` before passing; `nil`/empty slice falls back to `DefaultHeaderPatterns()`)

### Local Project Config (.ralphex/)

Expand Down Expand Up @@ -372,6 +373,7 @@ Implementation:
- `{{DEFAULT_BRANCH}}` - detected default branch (main, master, origin/main, etc.), overridable via `--base-ref` CLI flag or `default_branch` config option
- `{{DIFF_INSTRUCTION}}` - git diff command for current iteration (first: `git diff main...HEAD`, subsequent: `git diff`)
- `{{PREVIOUS_REVIEW_CONTEXT}}` - previous review context block for external review iterations (empty on first iteration, formatted context on subsequent)
- `{{TASK_HEADER_PATTERNS}}` - human-readable descriptions of configured `task_header_patterns` (preset descriptions or raw regex patterns, quoted, `or`-joined; used in `task.txt`)
- `{{agent:name}}` - expands to Task tool instructions for the named agent

Variables are also expanded inside agent content, so custom agents can use `{{DEFAULT_BRANCH}}` etc.
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,21 @@ Set `move_plan_on_completion = false` in `~/.config/ralphex/config` or `.ralphex

**When to disable:** workflows that manage plan file lifecycle externally (e.g. spec-driven tooling where the plan lives inside a bundle that a separate archive step consumes) should opt out so ralphex doesn't fight the external tool's file layout.

### Plan Header Patterns (optional)

By default, ralphex recognizes `### Task N: ...` and `### Iteration N: ...` as task section headers. Set `task_header_patterns` to a comma-separated list of preset names or raw Go regexes to support alternative plan formats (e.g. OpenSpec-style `## 1. Phase Name`). Each entry is either a known preset (`default`, `openspec`) or a raw Go regexp where capture group 1 is the task id and capture group 2 is the optional title.

```ini
# in ~/.config/ralphex/config or .ralphex/config
# use built-in openspec preset
task_header_patterns = openspec

# or a raw regex
task_header_patterns = ^## (\d+)\.\s*(.*)$
```

**When to use:** spec-driven workflows (OpenSpec etc.) whose `tasks.md` uses different header conventions than the ralphex defaults. Leaving the option unset preserves today's behavior.

### Review-Only Mode

Review-only mode (`--review`) runs the full review pipeline (Phase 2 → Phase 3 → Phase 4) on changes already present on the current branch. This is useful when changes were made outside ralphex — via Claude Code's built-in plan mode, manual edits, other AI agents, or any other workflow.
Expand Down Expand Up @@ -681,6 +696,7 @@ Custom prompt files support variable expansion. All variables use the `{{VARIABL
| `{{PROGRESS_FILE}}` | Path to the progress log file | `.ralphex/progress/progress-feature.txt` |
| `{{GOAL}}` | Human-readable goal description | `implementation of plan at docs/plans/feature.md` |
| `{{DEFAULT_BRANCH}}` | Default branch name (overridable via `--base-ref` or `default_branch` config) | `main`, `master`, `origin/main` |
| `{{TASK_HEADER_PATTERNS}}` | Human-readable descriptions of configured task header patterns (preset descriptions or raw regexes, quoted, `or`-joined; used in `task.txt`) | `'### Task N: title or ### Iteration N: title'` |
| `{{agent:name}}` | Expands to Task tool instructions for the named agent | (see below) |

**Agent references:**
Expand Down Expand Up @@ -831,6 +847,7 @@ Use `--config-dir` or `RALPHEX_CONFIG_DIR` to override the global config locatio
| `task_retry_count` | Task retry attempts | `1` |
| `finalize_enabled` | Enable finalize step after reviews | `false` |
| `move_plan_on_completion` | Move completed plan file into `docs/plans/completed/` on success (disable for external plan-lifecycle workflows) | `true` |
| `task_header_patterns` | Comma-separated preset names (`default`, `openspec`) or raw Go regexes the plan parser uses to recognize task sections. Capture group 1 = task id, group 2 = title (optional) | `default` |
| `use_worktree` | Run each plan in an isolated git worktree (full and tasks-only modes only) | `false` |
| `plans_dir` | Plans directory | `docs/plans` |
| `default_branch` | Override auto-detected default branch for review diffs | auto-detect |
Expand Down
55 changes: 32 additions & 23 deletions cmd/ralphex/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,11 +399,16 @@ func setupProgressLogger(o opts, req executePlanRequest, branch string) (progres
baseLog = req.ProgressLog
} else {
var err error
var taskHeaderPatterns []string
if req.Config != nil {
taskHeaderPatterns = req.Config.TaskHeaderPatterns
}
baseLog, err = progress.NewLogger(progress.Config{
PlanFile: req.PlanFile,
Mode: string(req.Mode),
Branch: branch,
NoColor: o.NoColor,
PlanFile: req.PlanFile,
Mode: string(req.Mode),
Branch: branch,
TaskHeaderPatterns: taskHeaderPatterns,
NoColor: o.NoColor,
}, req.Colors, holder)
if err != nil {
return progressLogResult{}, fmt.Errorf("create progress logger: %w", err)
Expand Down Expand Up @@ -514,14 +519,15 @@ func executePlan(ctx context.Context, o opts, req executePlanRequest) error {
var runnerLog processor.Logger = plr.baseLog
if o.Serve {
dashboard := web.NewDashboard(web.DashboardConfig{
BaseLog: plr.baseLog,
Port: o.Port,
Host: o.Host,
PlanFile: req.PlanFile,
Branch: branch,
WatchDirs: o.Watch,
ConfigWatchDirs: req.Config.WatchDirs,
Colors: req.Colors,
BaseLog: plr.baseLog,
Port: o.Port,
Host: o.Host,
PlanFile: req.PlanFile,
Branch: branch,
WatchDirs: o.Watch,
ConfigWatchDirs: req.Config.WatchDirs,
Colors: req.Colors,
TaskHeaderPatterns: req.Config.TaskHeaderPatterns,
}, plr.holder)
var dashErr error
runnerLog, dashErr = dashboard.Start(ctx)
Expand Down Expand Up @@ -646,10 +652,11 @@ func runWithWorktree(ctx context.Context, o opts, req executePlanRequest) (err e
holder := &status.PhaseHolder{}
branch := plan.ExtractBranchName(req.PlanFile)
baseLog, err := progress.NewLogger(progress.Config{
PlanFile: req.PlanFile,
Mode: string(req.Mode),
Branch: branch,
NoColor: o.NoColor,
PlanFile: req.PlanFile,
Mode: string(req.Mode),
Branch: branch,
TaskHeaderPatterns: req.Config.TaskHeaderPatterns,
NoColor: o.NoColor,
}, req.Colors, holder)
if err != nil {
return fmt.Errorf("create progress logger: %w", err)
Expand Down Expand Up @@ -767,9 +774,10 @@ func isWatchOnlyMode(o opts, configWatchDirs []string) bool {
func runWatchOnly(ctx context.Context, o opts, cfg *config.Config, colors *progress.Colors) error {
dirs := web.ResolveWatchDirs(o.Watch, cfg.WatchDirs)
dashboard := web.NewDashboard(web.DashboardConfig{
Port: o.Port,
Host: o.Host,
Colors: colors,
Port: o.Port,
Host: o.Host,
Colors: colors,
TaskHeaderPatterns: cfg.TaskHeaderPatterns,
}, nil)
if watchErr := dashboard.RunWatchOnly(ctx, dirs); watchErr != nil {
return fmt.Errorf("run watch-only mode: %w", watchErr)
Expand Down Expand Up @@ -934,10 +942,11 @@ func runPlanMode(ctx context.Context, o opts, req executePlanRequest, selector *

// create progress logger for plan mode
baseLog, err := progress.NewLogger(progress.Config{
PlanDescription: o.PlanDescription,
Mode: string(processor.ModePlan),
Branch: branch,
NoColor: o.NoColor,
PlanDescription: o.PlanDescription,
Mode: string(processor.ModePlan),
Branch: branch,
TaskHeaderPatterns: req.Config.TaskHeaderPatterns,
NoColor: o.NoColor,
}, req.Colors, holder)
if err != nil {
return fmt.Errorf("create progress logger: %w", err)
Expand Down
108 changes: 108 additions & 0 deletions docs/plans/20260318-git-config-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Git Config Discovery for Docker Wrapper

## Overview
Replace hardcoded `~/.gitconfig` mount in the Docker wrapper with dynamic discovery using `git config --list --show-origin --global`. This captures all global git config files including:
- `~/.gitconfig` (traditional location)
- `~/.config/git/config` (XDG location)
- Any files referenced via `[include]` or `[includeIf]` directives

**Success Criteria:** After implementation, `--dry-run` output should show mounts for all git config files discovered via `git config --list --show-origin --global`, plus fallback mounts for standard paths if they exist.

## Context
- **File**: `scripts/ralphex-dk.sh` (Python script despite .sh extension)
- **Current behavior**: Only mounts `~/.gitconfig` explicitly (lines 404-407)
- **Related code**: `get_global_gitignore()` function for `core.excludesFile` (kept as-is)
- **Pattern to follow**: Dual-mount logic from gitignore handling (lines 409-424)

## Development Approach
- **Testing approach**: Regular (code first, then tests)
- Complete each task fully before moving to the next
- Run tests after each change: `python scripts/ralphex-dk.sh --test`

## Implementation Steps

### Task 1: Add get_global_git_config_files() function

**Files:**
- Modify: `scripts/ralphex-dk.sh`
- Modify: `scripts/ralphex-dk/ralphex_dk_test.py`

- [ ] add `get_global_git_config_files()` function after `get_global_gitignore()` (around line 276)
- [ ] run `git config --list --show-origin --global` via subprocess
- [ ] filter lines that start with `file:` prefix (skip `command line:` and other origins)
- [ ] parse output lines: split on first tab, extract path after `file:` prefix
- [ ] return unique list of `Path` objects for files that exist
- [ ] handle errors gracefully (return empty list if git fails)
- [ ] update imports in test file to include `get_global_git_config_files`
- [ ] add test for `get_global_git_config_files()` with mocked subprocess output
- [ ] add test for `get_global_git_config_files()` when git command fails
- [ ] add test for filtering non-file origins (e.g., `command line:`)
- [ ] run tests: `python scripts/ralphex-dk.sh --test` - must pass before next task

### Task 2: Update build_volumes() to use dynamic discovery

**Files:**
- Modify: `scripts/ralphex-dk.sh`
- Modify: `scripts/ralphex-dk/ralphex_dk_test.py`

- [ ] replace hardcoded `~/.gitconfig` mount block (lines 404-407) with loop over `get_global_git_config_files()`
- [ ] add fallback: also mount `~/.gitconfig` and `~/.config/git/config` if they exist but weren't in discovery output (handles minimal/empty configs)
- [ ] apply dual-mount logic for each config file (same pattern as gitignore):
- if file is under `$HOME`: mount at `/home/app/<relative>` AND original absolute path
- otherwise: mount at original path only
- [ ] deduplicate mounts (same file shouldn't be mounted twice)
- [ ] add comment explaining the discovery approach
- [ ] add test for XDG config path mounting in `build_volumes()`
- [ ] add test for dual-mount behavior (remapped + original paths)
- [ ] add test for fallback mounting of standard paths
- [ ] run tests: `python scripts/ralphex-dk.sh --test` - must pass before next task

### Task 3: Update documentation

**Files:**
- Modify: `scripts/ralphex-dk/README.md`

- [ ] review existing README structure to find appropriate location for new section
- [ ] document git config discovery behavior in README
- [ ] explain that all global git config files are automatically discovered and mounted
- [ ] mention XDG config support (`~/.config/git/config`)
- [ ] note that `[include]` and `[includeIf]` referenced files are also mounted

### Task 4: Verify and finalize

- [ ] run full test suite: `python scripts/ralphex-dk.sh --test`
- [ ] test manually with `--dry-run` to verify mounts appear correctly
- [ ] commit changes with descriptive message
- [ ] move this plan to `docs/plans/completed/`

## Technical Details

**git config output format:**
```
file:/Users/alice/.config/git/config user.name=Alice
file:/Users/alice/.gitconfig user.email=alice@example.com
file:/Users/alice/.config/git/config.d/work.conf includeIf.gitdir:~/work/.path=...
```

**Parsing approach:**
- Split each line on first tab character
- Extract path after `file:` prefix
- Deduplicate paths (same file may appear multiple times)
- Filter to existing files only

**Dual-mount logic:**
```python
if config_file.is_relative_to(home):
# mount at /home/app/<relative> for tilde refs in .gitconfig
dst = "/home/app/" + str(config_file.relative_to(home))
add(src, dst, ro=True)
# also mount at original absolute path for expanded refs
original = str(config_file)
if original != dst: # always true for home-relative paths
add(src, original, ro=True)
else:
# non-home path: mount at original location only
add(src, str(config_file), ro=True)
```

**Deduplication:** Track mounted destinations in a set to avoid duplicate mounts when the same file appears from both discovery and fallback paths.
Loading