diff --git a/.golangci.yml b/.golangci.yml index 3e51aa00..ea1f429d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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" diff --git a/CLAUDE.md b/CLAUDE.md index 902afdb9..5324b7f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/) @@ -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. diff --git a/README.md b/README.md index 273eb247..add57e07 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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:** @@ -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 | diff --git a/cmd/ralphex/main.go b/cmd/ralphex/main.go index 2bbec06b..a42fef1f 100644 --- a/cmd/ralphex/main.go +++ b/cmd/ralphex/main.go @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/docs/plans/20260318-git-config-discovery.md b/docs/plans/20260318-git-config-discovery.md new file mode 100644 index 00000000..3573c028 --- /dev/null +++ b/docs/plans/20260318-git-config-discovery.md @@ -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/` 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/ 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. diff --git a/docs/plans/completed/20260427-task-header-patterns-config.md b/docs/plans/completed/20260427-task-header-patterns-config.md new file mode 100644 index 00000000..2bdbd5e0 --- /dev/null +++ b/docs/plans/completed/20260427-task-header-patterns-config.md @@ -0,0 +1,311 @@ +# Add configurable `task_header_patterns` config option + +## Overview + +Add a string-list config option `task_header_patterns` (default preserves today's behavior) that controls which markdown headers the plan parser recognizes as task sections. Users write templates with `{N}` (task identifier) and `{title}` (rest of line) placeholders; ralphex compiles them to regex internally. + +Motivation: spec-driven workflows (OpenSpec, etc.) use different header conventions (e.g. `## 1. Phase Name` with nested `- [ ] 1.2 ...` checkboxes). The current parser hard-codes `### Task N:` and `### Iteration N:` at `pkg/plan/parse.go:46`, so OpenSpec-style `tasks.md` silently parses as zero tasks. This change makes the header shape configurable without coupling ralphex to any specific tool. + +Default behavior is unchanged: users who don't set the option get `### Task {N}: {title}, ### Iteration {N}: {title}` compiled identically to today's hard-coded regex. + +Scope: +- Config-only change (INI, per-project) +- No CLI flag +- No env var +- Prompt coupling: `task.txt` currently references `### Task N:` / `### Iteration N:` by literal string; we add a `{{TASK_HEADER_PATTERNS}}` template variable and rewrite the prompt to use it +- Documentation updates (CLAUDE.md, llms.txt, README.md) are part of this change + +Part 2 of 2. Part 1 (`move_plan_on_completion`) is at `docs/plans/20260427-move-plan-on-completion-config.md` (commit 5f1241d). Upstream discussion: https://github.com/umputun/ralphex/issues/306 + +## Context (from discovery) + +- `pkg/plan/parse.go:46` — current hard-coded regex `^###\s+(?:Task|Iteration)\s+([^:]+?):\s*(.*)$` +- `pkg/plan/parse.go:54-137` — `ParsePlan(content string)` and `ParsePlanFile(path string)` signatures; neither takes patterns today +- `pkg/plan/parse.go:97-115` — checkbox scoping (only captured inside a current task); h2 closes current task +- `pkg/plan/parse_test.go` — existing 18+ table-driven test cases; must all continue to pass under default patterns +- Production callers of `ParsePlanFile` / `ParsePlan`: + - `pkg/processor/runner.go:808, 834` — has access to config via receiver `r` + - `pkg/web/plan.go:15, 18` — web dashboard; **no config plumbed today** — needs decision on how to get patterns +- `pkg/config/values.go` — existing `ClaudeErrorPatterns []string` pattern (lines ~81-82 in Values, loader near line ~330, merge near line ~460) to mirror for the new field +- `pkg/config/config.go:291` — values→Config mapping block +- `pkg/config/defaults/config:83-86` — `finalize_enabled` block, reference for commented-out template style +- `pkg/config/defaults/prompts/task.txt:10, 19, 42, 48` — hardcoded references to `### Task N:` and `### Iteration N:` +- `pkg/processor/prompts.go` — `replacePromptVariables()` function, home for `{{TASK_HEADER_PATTERNS}}` expansion +- Existing template variables (`{{PLAN_FILE}}`, `{{DEFAULT_BRANCH}}`, etc.) documented in `llms.txt` and CLAUDE.md +- Project CLAUDE.md: 80%+ coverage, table-driven tests with testify, one `_test.go` per source file + +## Development Approach + +- **testing approach**: TDD — write failing tests first for each new unit (template compiler, config loader, parser behavior with custom patterns, prompt expansion), then the minimal code to pass +- complete each task fully before moving to the next +- make small, focused changes +- every task includes new/updated tests for code changes in that task +- all tests pass before starting next task +- run `make test` and `make lint` after each change +- **maintain backward compatibility**: default patterns compile to regex equivalent to today's hard-coded form; existing plans, tests, and prompt behavior must be unchanged when the option is absent + +## Testing Strategy + +- **unit tests**: required for every task. Table-driven with testify. +- **e2e tests**: not applicable — no UI change. Web dashboard uses the parser but for display only, not execution. +- **toy-project smoke test** (manual): verify an OpenSpec-style plan with `## N. Phase` headers executes end-to-end after configuring the option. + +## Progress Tracking + +- mark completed items with `[x]` immediately when done +- add newly discovered tasks with ➕ prefix +- document blockers with ⚠️ prefix +- update plan if implementation deviates + +## Solution Overview + +Split into four concerns, each addressable in its own task: + +1. **Template → regex compiler** (`pkg/plan/patterns.go` — new file): pure function from `"### Task {N}: {title}"` to a `*regexp.Regexp` with `{N}` captured. Placeholder validator surfaces errors as config-load failures with pattern string and offending placeholder name. + +2. **Config plumbing** (`pkg/config/values.go`, `pkg/config/config.go`, `pkg/config/defaults/config`): standard string-list field mirroring `ClaudeErrorPatterns`. When unset, runtime default applied in `Config` builder (same pattern as part 1's `move_plan_on_completion`). + +3. **Parser integration** (`pkg/plan/parse.go`, `pkg/web/plan.go`, `pkg/processor/runner.go`): change `ParsePlan`/`ParsePlanFile` to accept a patterns slice. Nil/empty → fall back to built-in default patterns (same regex as today, compiled from the default templates). All callers updated. + +4. **Prompt template variable** (`pkg/processor/prompts.go`, `pkg/config/defaults/prompts/task.txt`): `{{TASK_HEADER_PATTERNS}}` expands to a quoted, `or`-joined string (e.g. `'### Task {N}: {title}' or '### Iteration {N}: {title}'`). Rewrite `task.txt` to use the variable instead of hard-coded strings. + +Key design decisions: +- **Templates over raw regex**: friendlier API, constrained surface, fail-fast validation. +- **`{N}` required, `{title}` optional**: every header needs an identifier; some plan flavors may omit a trailing title (though our defaults have one). +- **Built-in defaults compile from the SAME templates the user would write** — one code path, no "legacy regex" branch. Zero chance of drift between default behavior and custom-pattern behavior. +- **Web dashboard calls with `nil` patterns** → gets defaults. Avoids threading config through `pkg/web/` just for parsing. +- **Prompt variable value uses raw templates**, not substituted examples or generated prose. Claude handles `{N}`/`{title}` fine and the prompt already has other `{{VAR}}` placeholders. + +## Technical Details + +### Field definitions + +```go +// pkg/config/values.go (Values struct, near ClaudeErrorPatterns) +TaskHeaderPatterns []string +TaskHeaderPatternsSet bool // tracks if task_header_patterns was explicitly set + +// pkg/config/config.go (Config struct) +TaskHeaderPatterns []string `json:"task_header_patterns"` +TaskHeaderPatternsSet bool `json:"-"` +``` + +### Default templates + +```go +// pkg/plan/patterns.go +var DefaultTaskHeaderPatterns = []string{ + "### Task {N}: {title}", + "### Iteration {N}: {title}", +} +``` + +### Template → regex algorithm + +For each template string: +1. Scan for `{X}` tokens. Allowed: `{N}` exactly once, `{title}` at most once and only after `{N}`. Any other `{...}` → error. +2. Split template into (literal, placeholder, literal, …) segments. +3. Build regex: `^` + for each segment: `regexp.QuoteMeta(literal)` OR placeholder replacement (`{N}` → `([^\s:.]+)`, `{title}` → `(.*)`) + `\s*$`. +4. `regexp.Compile`. On failure (should be rare given controlled inputs) wrap with pattern context. + +### Runtime default in Config builder + +Mirrors part 1's `move_plan_on_completion` idiom: + +```go +headerPatterns := values.TaskHeaderPatterns +if !values.TaskHeaderPatternsSet || len(strings.TrimSpace(strings.Join(headerPatterns, ""))) == 0 { + headerPatterns = plan.DefaultTaskHeaderPatterns +} +c := &Config{ + // ... + TaskHeaderPatterns: headerPatterns, + TaskHeaderPatternsSet: values.TaskHeaderPatternsSet, + // ... +} +``` + +Note: `pkg/plan` is a leaf package (stdlib-only), so `pkg/config` could import it directly without creating a cycle. We still inline the default strings in `pkg/config` and add a drift-prevention test that asserts the two literal slices match — reason: keeping `pkg/config` free of domain-package imports matches the existing layering (config reads values, domain packages consume them), and the test is cheap insurance. + +### Parser signature change + +```go +// new signature — variadic for clean call sites at defaults +func ParsePlan(content string, patterns ...string) (*Plan, error) +func ParsePlanFile(path string, patterns ...string) (*Plan, error) +``` + +Rules: +- `len(patterns) == 0` → use `DefaultTaskHeaderPatterns` (callers that want defaults just omit the argument; nil/empty slice behaves identically) +- Compile once up front; walk the file; every line checks against all compiled patterns in order +- First match wins (multiple patterns could in principle match; deterministic via slice order) +- On any header match, close the current task (if any) and open a new one with the captured `{N}` as task number and `{title}` (if present) as title +- **Closing rule** (unchanged from today): h2 headers and h1 headers close the current task. An h3 header that does NOT match any configured pattern does NOT close a task — matches today's parser at `pkg/plan/parse.go:97-115`. This preserves existing semantics for free-form h3 notes inside a task section. + +### Prompt template variable + +In `pkg/processor/prompts.go`, `replacePromptVariables()` gains: + +```go +// build human-readable header-pattern hint: 'p1' or 'p2' or 'p3' +var hint string +if len(cfg.TaskHeaderPatterns) > 0 { + quoted := make([]string, len(cfg.TaskHeaderPatterns)) + for i, p := range cfg.TaskHeaderPatterns { + quoted[i] = "'" + p + "'" + } + hint = strings.Join(quoted, " or ") +} +s = strings.ReplaceAll(s, "{{TASK_HEADER_PATTERNS}}", hint) +``` + +`task.txt` rewrite — replace the four hardcoded strings. Example change at line 10: + +Before: `Read the plan file at {{PLAN_FILE}}. Find the FIRST Task section (### Task N: or ### Iteration N:) that has uncompleted checkboxes ([ ]).` + +After: `Read the plan file at {{PLAN_FILE}}. Find the FIRST Task section (matching {{TASK_HEADER_PATTERNS}}) that has uncompleted checkboxes ([ ]).` + +With defaults in play, `{{TASK_HEADER_PATTERNS}}` expands to `'### Task {N}: {title}' or '### Iteration {N}: {title}'`, which reads naturally. + +### INI template entry + +``` +# task_header_patterns: comma-separated list of markdown header templates +# used to recognize task sections in plan files. templates use {N} for +# the task identifier and {title} for the optional title (rest of line). +# defaults match the ralphex plan format. +# task_header_patterns = ### Task {N}: {title}, ### Iteration {N}: {title} +``` + +## What Goes Where + +- **Implementation Steps** (`[ ]`): all code changes, prompt rewrite, config template update, documentation (CLAUDE.md, llms.txt, README.md), tests, manual toy-project verification +- **Post-Completion** (no checkboxes): PR open, CHANGELOG (release-only), further OpenSpec integration work if it lands + +## Implementation Steps + +### Task 1: Add template→regex compiler and validator in `pkg/plan/patterns.go` + +**Files:** +- Create: `pkg/plan/patterns.go` +- Create: `pkg/plan/patterns_test.go` + +- [x] write failing table-driven tests for `CompileTaskHeaderPattern(template string) (*regexp.Regexp, error)`: valid templates (`### Task {N}: {title}`, `## {N}. {title}`, `### Task {N}:` with no title, `##{N}` with no literals), invalid (missing `{N}`, `{N}` appearing twice, `{title}` before `{N}`, unknown `{foo}` placeholder) +- [x] write failing test for `CompileTaskHeaderPatterns(templates []string) ([]*regexp.Regexp, error)`: nil/empty returns compiled defaults, good list compiles all, one bad pattern fails the whole call with an error identifying the offending template +- [x] write failing test asserting the compiled DEFAULT patterns match the same strings as today's hard-coded regex (semantic equivalence on e.g. `### Task 1: Foo`, `### Iteration 2: Bar`, `### Task 1.2: Foo`) — use table-driven inputs +- [x] run tests — confirm they fail +- [x] implement `CompileTaskHeaderPattern` with segment scanner (loop over `{X}` tokens, validate, `regexp.QuoteMeta` literals, substitute placeholders, anchor `^...\s*$`) +- [x] implement `CompileTaskHeaderPatterns` plural helper, with nil/empty falling back to `DefaultTaskHeaderPatterns` +- [x] export `var DefaultTaskHeaderPatterns = []string{"### Task {N}: {title}", "### Iteration {N}: {title}"}` +- [x] run `go test ./pkg/plan/...` — all tests must pass before task 2 + +### Task 2: Add `TaskHeaderPatterns` config field and loader + +**Files:** +- Modify: `pkg/config/values.go` +- Modify: `pkg/config/values_test.go` + +- [x] write failing table-driven test cases for load: key absent (Set=false), explicit list, explicit empty string (Set=true, empty slice), single-pattern list, comma-separated list with whitespace, whitespace-only entries (e.g. `, ,`), duplicate entries preserved in order, entries containing regex meta chars (`.`, `*`, `[`) as literals in surrounding text +- [x] write failing table-driven test cases for merge: src set overrides dst (including `src=[]` clearing dst), src unset preserves dst +- [x] run tests — confirm they fail +- [x] add `TaskHeaderPatterns []string` and `TaskHeaderPatternsSet bool` to `Values` struct, mirroring `ClaudeErrorPatterns` +- [x] add INI loader block for `task_header_patterns` (treat as comma-separated; trim each entry; drop empty entries after trim; reuse the same splitter as `claude_error_patterns` — audit and reuse) +- [x] add merge block in `mergeExtraFrom` (or the appropriate helper) — **guard on `src.TaskHeaderPatternsSet`, NOT on `len(src.TaskHeaderPatterns) > 0`**. The `ClaudeErrorPatterns` precedent at `pkg/config/values.go:463-464` uses `len(...) > 0` but that precedent is a latent bug: it cannot express "explicitly set to empty". Since we want fallback-to-default on empty (handled in Task 3's Config builder), the `Set` guard here is the semantically correct form. Add a brief code comment noting the deliberate deviation from the `ClaudeErrorPatterns` precedent. +- [x] run `go test ./pkg/config/...` — all tests must pass before task 3 + +### Task 3: Propagate `TaskHeaderPatterns` to `Config` with runtime default + +**Files:** +- Modify: `pkg/config/config.go` +- Modify: `pkg/config/config_test.go` + +- [x] write failing table-driven test: default (not set) yields the built-in defaults slice, explicit list yields that list, explicit empty yields the defaults (fallback), set flag reflects whether user configured it +- [x] write a drift-prevention test that asserts the `pkg/config` inline default list equals `plan.DefaultTaskHeaderPatterns` element-for-element (both must stay in sync; this test catches future divergence) +- [x] run tests — confirm they fail +- [x] add `TaskHeaderPatterns []string` (with `json:"task_header_patterns"`) and `TaskHeaderPatternsSet bool` (`json:"-"`) fields to `Config` struct +- [x] in the `Config` builder at line ~270, precompute `headerPatterns` into a local: if `!values.TaskHeaderPatternsSet` OR all-entries-empty-after-trim → use inlined default list; else use `values.TaskHeaderPatterns` +- [x] assign both fields in the struct literal +- [x] run `go test ./pkg/config/...` — all tests must pass before task 4 + +### Task 4: Change parser signatures to accept patterns; update all callers + +**Files:** +- Modify: `pkg/plan/parse.go` +- Modify: `pkg/plan/parse_test.go` +- Modify: `pkg/processor/runner.go` +- Modify: `pkg/web/plan.go` + +- [x] write failing tests in `parse_test.go` for the new signature: no patterns arg → defaults (existing test cases continue to pass), custom pattern `## {N}. {title}` matches OpenSpec-style headers and captures `- [ ]` checkboxes beneath, mixed-pattern plan (both `### Task 1:` and `## 2. Phase`) parses all tasks in document order +- [x] write failing test for **closing behavior** (explicit, to prevent regressions): (a) h2 header that does NOT match any configured pattern closes the current task (unchanged from today); (b) h1 header closes the current task (unchanged); (c) h3 header that does NOT match any configured pattern does NOT close the current task (unchanged — today's parser at `pkg/plan/parse.go:97-115` only closes on h2/h1); (d) a matching custom h2 pattern (`## {N}. ...`) closes a preceding `### Task 1:` section AND opens a new task +- [x] write failing test: custom pattern with malformed template surfaces compile error from `ParsePlan` +- [x] write failing test: plan with `## 1. Phase` headers but zero `- [ ]` checkboxes produces a Plan with zero tasks (expected — matches today's "no executable tasks" behavior) +- [x] run tests — confirm they fail +- [x] change `ParsePlan(content string)` → `ParsePlan(content string, patterns ...string)`; compile patterns once at the top (empty → defaults); replace the hardcoded `taskHeaderPattern` usage with a loop over compiled patterns (first match wins) +- [x] change `ParsePlanFile(path string)` → `ParsePlanFile(path string, patterns ...string)` accordingly +- [x] remove the package-level `taskHeaderPattern` var (now compiled per-call) +- [x] existing test call sites in `parse_test.go` do NOT need changes — variadic makes `ParsePlan(content)` continue to compile and default +- [x] update `pkg/processor/runner.go:808, 834` to pass `r.cfg.TaskHeaderPatterns...` (exact field name per Task 3) +- [x] update `pkg/web/plan.go:15, 18` — no arg needed (variadic, falls back to defaults; web dashboard renders plans for display only) +- [x] run `go test ./...` and `go build ./...` — must pass before task 5 + +### Task 5: Add `{{TASK_HEADER_PATTERNS}}` template variable and rewrite `task.txt` + +**Files:** +- Modify: `pkg/processor/prompts.go` +- Modify: `pkg/processor/prompts_test.go` +- Modify: `pkg/config/defaults/prompts/task.txt` + +- [x] write failing test in `prompts_test.go` for `replacePromptVariables()`: `{{TASK_HEADER_PATTERNS}}` expands to `'p1' or 'p2'` style for multi-pattern config, single pattern yields `'p1'`, empty config yields an empty string (edge case — should not happen since runtime default always populates, but defensive) +- [x] write failing test covering back-compat: given default patterns, a known existing phrase in task.txt (e.g. the `{{PLAN_FILE}}` reference) still expands correctly alongside the new variable +- [x] run tests — confirm they fail +- [x] add `{{TASK_HEADER_PATTERNS}}` expansion block in `replacePromptVariables()` in `pkg/processor/prompts.go` — quote each pattern with single quotes, join with ` or `, replace all occurrences +- [x] rewrite `pkg/config/defaults/prompts/task.txt` to use `{{TASK_HEADER_PATTERNS}}` at lines 10, 19, 42, 48 — preserve surrounding sentence structure so the default expansion reads naturally (e.g. `"Find the FIRST Task section (matching {{TASK_HEADER_PATTERNS}}) that has uncompleted checkboxes ([ ])."`) +- [x] for each of lines 10, 19, 42, 48 in the rewritten file, verify the full sentence still reads naturally after expansion — no orphaned punctuation, no doubled articles, no broken parens +- [x] manually diff the rendered output under default patterns against current task.txt content to verify no semantic regression +- [x] run `go test ./pkg/processor/...` — all tests must pass before task 6 + +### Task 6: Update embedded INI template + +**Files:** +- Modify: `pkg/config/defaults/config` + +- [x] add commented-out `task_header_patterns` block near related sections (plan-parsing area; if none, alongside `finalize_enabled` group), following the style: three-line header comment (what, when to change, default), then commented option line +- [x] existing `defaults_test.go` all-commented coverage (`TestShouldOverwrite/all_commented`) already protects the fallback path — no new regression test needed +- [x] run `go test ./pkg/config/...` — must pass before task 7 + +### Task 7: Verify acceptance criteria + +- [x] **safety check**: confirm no test writes to `~/.config/ralphex/` — all tests must use `t.TempDir()` per CLAUDE.md "Testing Safety Rules"; MD5-checksum `~/.config/ralphex/config` before and after `go test ./...` to verify +- [x] `make test` passes (unit tests with coverage) +- [x] `make lint` passes (no new golangci-lint issues) +- [x] `make fmt` — code is formatted +- [x] coverage on touched files ≥ 80% per CLAUDE.md +- [x] `GOOS=windows GOARCH=amd64 go build ./...` succeeds +- [x] toy-project smoke test #1 — **back-compat**: run `./scripts/internal/prep-toy-test.sh` with no config override; execute the default `docs/plans/fix-issues.md`; verify parsing and execution identical to previous behavior +- [x] toy-project smoke test #2 — **OpenSpec shape**: create a plan file with OpenSpec-style headers (`## 1. Phase X` + `- [ ] 1.1 ...`) in `/tmp/ralphex-test`, add `task_header_patterns = ### Task {N}: {title}, ### Iteration {N}: {title}, ## {N}. {title}` to `.ralphex/config`, run ralphex against it, confirm tasks are detected, executed, and completion signals fire +- [x] toy-project smoke test #3 — **bad pattern**: configure `task_header_patterns = ### Task {foo}: {title}`, run ralphex, confirm it exits cleanly with an error that names the offending template and placeholder + +### Task 8: Final — update documentation and move plan + +**Files:** +- Modify: `CLAUDE.md` +- Modify: `llms.txt` +- Modify: `README.md` + +- [x] `CLAUDE.md` Configuration section: add a line for `task_header_patterns` — comma-separated list of markdown header templates; placeholders `{N}` (identifier) and `{title}` (optional, must come after `{N}`); defaults `### Task {N}: {title}, ### Iteration {N}: {title}`; use for spec-driven workflows (OpenSpec etc.) that use `## N. Phase` headers +- [x] `llms.txt` config-options list: add same option in the existing alphabetical-ish ordering; also add `{{TASK_HEADER_PATTERNS}}` to the template-variables list +- [x] `README.md`: add a new "Plan Header Patterns (optional)" subsection after "Plan Move Behavior (optional)" (added in part 1) — one-line description, INI example, and one sentence on when to use +- [x] do NOT update CHANGELOG (per CLAUDE.md workflow rule: CHANGELOG updates are release-process only) +- [x] move this plan to `docs/plans/completed/` + +## Post-Completion + +**PR submission** (after plan complete): +- open PR against `umputun/ralphex` referencing issue #306 +- PR description: link to issue, summary of the option and defaults, note that this is part 2 of 2, back-compat guaranteed by default-template test and toy-project smoke test #1 +- if part 1 PR has not landed yet, rebase after it merges to avoid documentation conflicts in README.md + +**Follow-up items** (not in this PR): +- CHANGELOG entry (release process, per CLAUDE.md) +- Optional: `{{DIFF_INSTRUCTION}}`-style per-iteration variations for other prompts (`review_first.txt` etc.) if they accumulate hardcoded header references — not currently needed +- Optional: per-plan pattern override via plan frontmatter — YAGNI for now, add if real demand emerges diff --git a/docs/plans/completed/20260429-task-header-patterns-rework.md b/docs/plans/completed/20260429-task-header-patterns-rework.md new file mode 100644 index 00000000..d0b7b3ee --- /dev/null +++ b/docs/plans/completed/20260429-task-header-patterns-rework.md @@ -0,0 +1,244 @@ +# Task Header Patterns: Rework to Raw Regex + Preset Registry + +## Overview + +PR #309 introduced configurable task header patterns via a `{N}/{title}` template DSL (+2403/-177 +across 30 files). The project owner reviewed and asked for a dramatically simpler design: raw regex +in config, a tiny named-preset registry, and no progress-log transport layer. This plan reworks the +PR to land at ~+200/-50 across ~8 files. + +**What changes:** +- Template compiler (`pkg/plan/patterns.go`, 208 LOC + 394 LOC tests) → deleted +- Progress-log transport (`TaskHeaderPatterns:` in progress files, dashboard re-emission, full-file + scan in `ParseProgressHeader`, `loadSessionPlanWithFallback`) → deleted (~250-400 LOC) +- Replaced by a `map[string]string` preset registry in `pkg/plan` with two entries (`default`, + `openspec`) and direct `regexp.Compile` for raw regex values + +**All three Copilot review comments are addressed as side-effects:** +- O(file) scan on every refresh → dropped with transport layer +- Misleading `TaskHeaderPatternsSet` comment → removed with lowercasing +- Test comment "scans file tail" vs whole-file impl → removed with transport layer + +## Context + +- `pkg/plan/patterns.go` / `patterns_test.go` — template compiler to delete +- `pkg/plan/parse.go` — keep `closesTask`/`headingLevel` refactor; change variadic `...string` to `[]*regexp.Regexp` +- `pkg/plan/presets.go` (new) — preset registry + resolver +- `pkg/config/config.go` + `values.go` — simplify defaulting, lowercase internal fields +- `pkg/progress/progress.go` — remove `TaskHeaderPatterns` field and writes +- `pkg/web/session_progress.go` — remove full-file scan for override +- `pkg/web/session.go`, `server.go`, `dashboard.go`, `plan.go` — remove TaskHeaderPatterns plumbing +- `pkg/processor/runner.go` + `prompts.go` — update callers, update `{{TASK_HEADER_PATTERNS}}` hint +- `cmd/ralphex/main.go` — remove progress/dashboard `TaskHeaderPatterns` wiring + +## Development Approach + +- **testing approach**: Regular (code first, then tests) +- Complete each task before moving to the next; tree must compile and `make test` must pass at each gate +- The parse.go refactor (`closesTask`/`headingLevel`) is already in the branch — preserve it + +## Solution Overview + +**Preset registry** (`pkg/plan/presets.go`): +```go +var headerPresets = map[string]string{ + "default": `^### (?:Task|Iteration) ([^:]+?):\s*(.*)$`, + "openspec": `^## (\d+)\.?\s*(.*)$`, +} +``` + +Note: `openspec` uses `(\d+)` (integer only, not `\d+(?:\.\d+)?`) because `parseTaskNum` in +`parse.go` parses `Task.Number` as an `int`. Nested headings like `2.3` are out of scope. + +`ResolveHeaderPattern(s string) (*regexp.Regexp, error)` checks if `s` is a preset key; if so, +compiles the preset regex; otherwise compiles `s` as a raw regex directly. + +**Preset descriptions** (for LLM-readable `{{TASK_HEADER_PATTERNS}}` hint): +```go +var headerPresetDescriptions = map[string]string{ + "default": "### Task N: title or ### Iteration N: title", + "openspec": "## N. title", +} +``` +`getTaskHeaderPatternsHint` uses the description when available; falls back to the raw regex string +for user-supplied patterns so the prompt remains human-readable. + +**Config values:** +- `task_header_patterns` is still a comma-separated list of items +- Each item is either a preset name OR a raw regex; resolved by `ResolveHeaderPattern` +- Empty/unset → resolved to `DefaultHeaderPatterns()` (compiles `headerPresets["default"]`) at runtime +- Compile errors surfaced as-is to the user + +**`ParsePlan` / `ParsePlanFile` signature change:** +```go +// before (variadic template strings) +func ParsePlan(content string, patterns ...string) (*Plan, error) + +// after (pre-compiled regexes, resolved by caller) +func ParsePlan(content string, patterns []*regexp.Regexp) (*Plan, error) +``` + +All call sites (`pkg/web/plan.go`, `pkg/processor/runner.go`) must update in the same commit as +the signature change to keep the tree compilable. + +**SSE skip logic for `TaskHeaderPatterns:` lines** is kept in `pkg/web/parse.go` for backward +compatibility with progress files written by older ralphex versions. + +**No progress-log transport:** the dashboard reads its own config (or falls back to defaults). +A dashboard watching sessions from other repos with custom patterns is not a supported use case. + +## Implementation Steps + +### Task 1: Add preset registry in pkg/plan + +**Files:** +- Create: `pkg/plan/presets.go` +- Create: `pkg/plan/presets_test.go` + +- [x] create `pkg/plan/presets.go` with: + - `headerPresets map[string]string` (`default`, `openspec` — integer-only openspec, see note above) + - `headerPresetDescriptions map[string]string` — human-readable descriptions for prompt hint + - `ResolveHeaderPattern(s string) (*regexp.Regexp, error)`: preset lookup → raw compile + - `ResolveHeaderPatterns(patterns []string) ([]*regexp.Regexp, error)`: resolves a slice + - `PresetDescription(s string) string`: returns human description if `s` is a preset, else returns `s` (the raw regex) +- [x] write table-driven tests in `pkg/plan/presets_test.go`: + - preset names `"default"` and `"openspec"` resolve correctly + - raw regex compiles and returns correct `*regexp.Regexp` + - invalid regex surfaces `regexp.Compile` error + - typo/unknown name is treated as raw regex (not an error) + - `PresetDescription` returns description for known presets, raw string for unknowns +- [x] run `make test` — must pass before task 2 + +### Task 2: Delete patterns.go and rework parse.go + all call sites atomically + +All signature changes and their call sites **must land in this single task** to keep the build green. + +**Files:** +- Delete: `pkg/plan/patterns.go` +- Delete: `pkg/plan/patterns_test.go` +- Modify: `pkg/plan/parse.go` +- Modify: `pkg/plan/parse_test.go` +- Modify: `pkg/web/plan.go` ← call site, must update here +- Modify: `pkg/processor/runner.go` ← call site, must update here + +- [x] delete `pkg/plan/patterns.go` and `pkg/plan/patterns_test.go` +- [x] add `DefaultHeaderPatterns() []*regexp.Regexp` to `pkg/plan/presets.go`: compiles + `headerPresets["default"]`; panics on bad regex (it's a hardcoded constant) +- [x] add `TestDefaultHeaderPatternsCompiles` in `presets_test.go` to lock the invariant +- [x] change `ParsePlan(content string, patterns ...string)` → `ParsePlan(content string, patterns []*regexp.Regexp)` +- [x] change `ParsePlanFile(path string, patterns ...string)` → `ParsePlanFile(path string, patterns []*regexp.Regexp)` +- [x] update internal pattern matching in `parse.go` to use pre-compiled `[]*regexp.Regexp`; no + compilation inside `parse.go` itself +- [x] preserve `closesTask`/`headingLevel` refactor already in branch +- [x] update `pkg/plan/parse_test.go`: replace spread calls with slice literals; add test case using + `openspec` preset pattern (call `ResolveHeaderPatterns([]string{"openspec"})`) +- [x] update `pkg/web/plan.go` call sites to pass `[]*regexp.Regexp` slice (not spread strings) +- [x] update `pkg/processor/runner.go` call sites to pass `[]*regexp.Regexp` slice (not spread strings); + update `taskHeaderPatterns()` helper to return `[]*regexp.Regexp` resolved via `ResolveHeaderPatterns` +- [x] run `make test` and `go build ./...` — both must pass before task 3 + +### Task 3: Rework config layer + +**Files:** +- Modify: `pkg/config/config.go` +- Modify: `pkg/config/values.go` +- Modify: `pkg/config/values_test.go` +- Modify: `pkg/config/config_test.go` + +- [x] remove `defaultTaskHeaderPatterns` slice from `config.go` (replaced by `plan.DefaultHeaderPatterns()`) +- [x] remove `TestDefaultTaskHeaderPatterns_MatchesConfigDefaults` cross-package sync test (no longer needed) +- [x] rename `Config.TaskHeaderPatternsSet` → unexported in `values.go` (no external callers); + update all references in `values_test.go` (search for `TaskHeaderPatternsSet` — ~10 lines) +- [x] rename exported `CompileTaskHeaderPatterns` → unexported `compileTaskHeaderPatterns` +- [x] update the field comment (was: "allows empty to disable"; now: "distinguishes explicit set + from unset; empty resets to preset default") +- [x] runtime defaulting in `config.go`: when unset or empty, resolve to `plan.DefaultHeaderPatterns()` +- [x] update `pkg/config/values_test.go` and `config_test.go` for new field visibility and defaulting + semantics; grep for `TaskHeaderPatternsSet` and `CompileTaskHeaderPatterns` to find all references +- [x] run `make test` — must pass before task 4 + +### Task 4: Drop progress-log transport layer + +**Files:** +- Modify: `pkg/progress/progress.go` +- Modify: `pkg/progress/progress_test.go` +- Modify: `pkg/web/session_progress.go` +- Modify: `pkg/web/session_progress_test.go` +- Modify: `pkg/web/session.go` +- Modify: `pkg/web/plan.go` +- Modify: `pkg/web/server.go` +- Modify: `pkg/web/server_test.go` +- Modify: `pkg/web/dashboard.go` +- Modify: `pkg/web/dashboard_test.go` +- Modify: `pkg/web/parse.go` (keep skip logic — see note below) +- Modify: `pkg/web/parse_test.go` + +- [x] `progress.go`: remove `TaskHeaderPatterns []string` from `LogConfig`; remove writes of + `TaskHeaderPatterns:` header line and restart re-emission; update `progress_test.go` +- [x] `session_progress.go`: remove full-file scan loop for `TaskHeaderPatterns:` override (the + entire `for` loop after the separator in `ParseProgressHeader`); remove `TaskHeaderPatterns` + from `SessionMetadata`; remove from `applyHeaderField`; update `session_progress_test.go` +- [x] `session.go`: remove `TaskHeaderPatterns []string` field from `Session` struct +- [x] `plan.go`: delete `loadSessionPlanWithFallback`; update call site in `server.go` to use + `loadPlanWithFallback` with dashboard config patterns only +- [x] `server.go`: remove `TaskHeaderPatterns` from `ServerConfig`; update plan endpoint to use + config patterns directly (no per-session pattern merge); update `server_test.go` +- [x] `dashboard.go`: remove `TaskHeaderPatterns` from `DashboardConfig` and `taskHeaderPatterns` + from the dashboard struct; update `dashboard_test.go` +- [x] `parse.go`: **keep** the `TaskHeaderPatterns:` skip logic for backward compat with old + progress files on disk; just add a comment explaining it's kept for old-file compat +- [x] run `make test` — must pass before task 5 + +### Task 5: Update processor prompts and main.go wiring + +**Files:** +- Modify: `pkg/processor/prompts.go` +- Modify: `pkg/processor/prompts_test.go` +- Modify: `cmd/ralphex/main.go` + +- [x] `prompts.go`: update `getTaskHeaderPatternsHint()` to call `plan.PresetDescription(s)` for + each configured pattern item: shows human-readable description for presets (e.g. + `'### Task N: title'`), raw regex string for user-supplied patterns +- [x] `prompts.go`: update `{{TASK_HEADER_PATTERNS}}` expansion formatting accordingly +- [x] `cmd/ralphex/main.go`: remove all `TaskHeaderPatterns` wiring to `progress.LogConfig` and + `dashboard.DashboardConfig` (both dropped in task 4) +- [x] update `prompts_test.go` for new hint format +- [x] run `make test` — must pass before task 6 + +### Task 6: Update embedded config template and docs + +**Files:** +- Modify: `pkg/config/defaults/config` +- Modify: `pkg/config/defaults/prompts/task.txt` +- Modify: `CLAUDE.md` +- Modify: `README.md` +- Modify: `llms.txt` + +- [x] `defaults/config`: update `task_header_patterns` comment to show both preset name + (`openspec`) and raw regex examples +- [x] `task.txt`: update `{{TASK_HEADER_PATTERNS}}` prose to reflect regex-based config +- [x] `CLAUDE.md`: update plan-format guidance and config docs for new regex/preset semantics +- [x] `README.md`: update `task_header_patterns` docs with preset and raw regex examples +- [x] `llms.txt`: update `{{TASK_HEADER_PATTERNS}}` entry and config docs + +### Task 7: Verify and clean up + +- [x] run full test suite: `make test` — all tests must pass +- [x] cross-compile for Windows: `GOOS=windows GOARCH=amd64 go build ./...` +- [x] grep for any remaining references: `patterns.go`, `DefaultTaskHeaderPatterns`, + `loadSessionPlanWithFallback`, `sanitizePatterns`, `TaskHeaderPatternsSet` (exported), + `CompileTaskHeaderPatterns` (exported) — should be zero +- [x] verify `git diff --stat` shows ~+200/-50 net (not 2400+) +- [x] run end-to-end toy project test per CLAUDE.md workflow requirements: + `./scripts/internal/prep-toy-test.sh` then execute the toy plan +- [x] move this plan to `docs/plans/completed/` + +## Post-Completion + +**PR update:** +- Force-push the reworked branch to replace the existing +2403 PR with the simplified version +- Update PR description to reference umputun's review and summarize the simplification + +**Web dashboard e2e tests:** +- Consider running `go test -tags=e2e ./e2e/...` after the PR lands if dashboard plan-parsing + changes warrant it (playwright tests cover SSE streaming and plan panel rendering) diff --git a/llms.txt b/llms.txt index 59e111fb..5ed9e6b1 100644 --- a/llms.txt +++ b/llms.txt @@ -109,6 +109,7 @@ Configuration directory: `~/.config/ralphex/` (override with `--config-dir` or ` - `{{agent:name}}` - expands to Task tool instructions for named agent - `{{DIFF_INSTRUCTION}}` - git diff command for current iteration (in codex_review.txt and custom_review.txt) - `{{PREVIOUS_REVIEW_CONTEXT}}` - previous review context for external review iterations (in codex_review.txt and custom_review.txt) +- `{{TASK_HEADER_PATTERNS}}` - human-readable descriptions of configured task header patterns (preset descriptions or raw regexes, quoted, `or`-joined; in task.txt); expands to e.g. `'### Task N: title or ### Iteration N: title'` under defaults **External review iterations:** By default, external review runs up to `max(3, max_iterations/5)` iterations. Override with `max_external_iterations` config option or `--max-external-iterations` CLI flag (0 = auto). @@ -140,6 +141,8 @@ review_model = sonnet:medium **Plan move behavior:** `move_plan_on_completion` config option controls whether completed plans move to `docs/plans/completed/` on success. Default `true` (existing behavior). Set to `false` for workflows that manage plan file lifecycle externally, such as spec-driven tooling with separate archive steps. +**Plan header patterns:** `task_header_patterns` config option is a comma-separated list of preset names (`default`, `openspec`) or raw Go regexes that controls 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` for spec-driven workflows (OpenSpec etc.) that use `## N. Phase` headers, or provide a raw regex for custom formats. + **Notifications** (`notify_*` fields in config): Optional alerts on completion/failure via `telegram`, `email`, `slack`, `webhook`, or `custom` script. Disabled by default. See `docs/notifications.md` for setup. Run `ralphex --init` to create local `.ralphex/` project config with commented-out defaults. diff --git a/pkg/config/config.go b/pkg/config/config.go index fbae5755..5e920b15 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "time" "github.com/umputun/ralphex/pkg/notify" @@ -14,6 +15,11 @@ import ( //go:embed defaults/config defaults/prompts/* defaults/agents/* var defaultsFS embed.FS +// defaultTaskHeaderPatterns is the built-in fallback used when the user +// has not configured task_header_patterns (or set it to all-empty entries). +// "default" resolves to the default preset in pkg/plan.headerPresets. +var defaultTaskHeaderPatterns = []string{"default"} + // prompt file names const ( taskPromptFile = "task.txt" @@ -87,6 +93,11 @@ type Config struct { ClaudeErrorPatterns []string `json:"claude_error_patterns"` CodexErrorPatterns []string `json:"codex_error_patterns"` + // task header patterns used to recognize task sections in plan files. + // each entry is a preset name ("default", "openspec") or a raw Go regex. + // empty/unset falls back to defaultTaskHeaderPatterns (["default"]). + TaskHeaderPatterns []string `json:"task_header_patterns"` + // limit patterns for wait+retry behavior (overlap with error patterns is intentional) ClaudeLimitPatterns []string `json:"claude_limit_patterns"` CodexLimitPatterns []string `json:"codex_limit_patterns"` @@ -276,50 +287,76 @@ func loadConfigFromDirs(globalDir, localDir string) (*Config, error) { return nil, fmt.Errorf("load agents: %w", err) } + // apply runtime default for task_header_patterns: fall back to defaults when + // unset or when all user-supplied entries were empty after trim (parseCommaSeparated + // drops empties, so len == 0 covers both "explicit empty" and "all whitespace" cases). + headerPatterns := values.TaskHeaderPatterns + if !values.taskHeaderPatternsSet || len(headerPatterns) == 0 { + headerPatterns = defaultTaskHeaderPatterns + } + + // validate user-supplied patterns compile early so startup fails fast with a clear + // error instead of silently falling back to defaults at plan-parse time. + // known preset names are always valid; anything else is treated as a raw Go regex. + if values.taskHeaderPatternsSet && len(values.TaskHeaderPatterns) > 0 { + for _, p := range values.TaskHeaderPatterns { + if !isKnownHeaderPreset(p) { + re, err := regexp.Compile(p) + if err != nil { + return nil, fmt.Errorf("invalid task_header_patterns %q: %w", p, err) + } + if re.NumSubexp() < 1 { + return nil, fmt.Errorf("invalid task_header_patterns %q: pattern must have at least one capture group for task ID", p) + } + } + } + } + // assemble config c := &Config{ - ClaudeCommand: values.ClaudeCommand, - ClaudeArgs: values.ClaudeArgs, - TaskModel: values.TaskModel, - ReviewModel: values.ReviewModel, - CodexEnabled: values.CodexEnabled, - CodexEnabledSet: values.CodexEnabledSet, - CodexCommand: values.CodexCommand, - CodexModel: values.CodexModel, - CodexReasoningEffort: values.CodexReasoningEffort, - CodexTimeoutMs: values.CodexTimeoutMs, - CodexTimeoutMsSet: values.CodexTimeoutMsSet, - CodexSandbox: values.CodexSandbox, - ExternalReviewTool: values.ExternalReviewTool, - CustomReviewScript: values.CustomReviewScript, - IterationDelayMs: values.IterationDelayMs, - IterationDelayMsSet: values.IterationDelayMsSet, - TaskRetryCount: values.TaskRetryCount, - TaskRetryCountSet: values.TaskRetryCountSet, - MaxIterations: values.MaxIterations, - MaxIterationsSet: values.MaxIterationsSet, - MaxExternalIterations: values.MaxExternalIterations, - ReviewPatience: values.ReviewPatience, - FinalizeEnabled: values.FinalizeEnabled, - FinalizeEnabledSet: values.FinalizeEnabledSet, - MovePlanOnCompletion: values.MovePlanOnCompletion, - WorktreeEnabled: values.WorktreeEnabled, - WorktreeEnabledSet: values.WorktreeEnabledSet, - PlansDir: values.PlansDir, - DefaultBranch: values.DefaultBranch, - VcsCommand: values.VcsCommand, - CommitTrailer: values.CommitTrailer, - WatchDirs: values.WatchDirs, - ClaudeErrorPatterns: values.ClaudeErrorPatterns, - CodexErrorPatterns: values.CodexErrorPatterns, - ClaudeLimitPatterns: values.ClaudeLimitPatterns, - CodexLimitPatterns: values.CodexLimitPatterns, - WaitOnLimit: values.WaitOnLimit, - WaitOnLimitSet: values.WaitOnLimitSet, - SessionTimeout: values.SessionTimeout, - SessionTimeoutSet: values.SessionTimeoutSet, - IdleTimeout: values.IdleTimeout, - IdleTimeoutSet: values.IdleTimeoutSet, + ClaudeCommand: values.ClaudeCommand, + ClaudeArgs: values.ClaudeArgs, + TaskModel: values.TaskModel, + ReviewModel: values.ReviewModel, + CodexEnabled: values.CodexEnabled, + CodexEnabledSet: values.CodexEnabledSet, + CodexCommand: values.CodexCommand, + CodexModel: values.CodexModel, + CodexReasoningEffort: values.CodexReasoningEffort, + CodexTimeoutMs: values.CodexTimeoutMs, + CodexTimeoutMsSet: values.CodexTimeoutMsSet, + CodexSandbox: values.CodexSandbox, + ExternalReviewTool: values.ExternalReviewTool, + CustomReviewScript: values.CustomReviewScript, + IterationDelayMs: values.IterationDelayMs, + IterationDelayMsSet: values.IterationDelayMsSet, + TaskRetryCount: values.TaskRetryCount, + TaskRetryCountSet: values.TaskRetryCountSet, + MaxIterations: values.MaxIterations, + MaxIterationsSet: values.MaxIterationsSet, + MaxExternalIterations: values.MaxExternalIterations, + ReviewPatience: values.ReviewPatience, + FinalizeEnabled: values.FinalizeEnabled, + FinalizeEnabledSet: values.FinalizeEnabledSet, + MovePlanOnCompletion: values.MovePlanOnCompletion, + WorktreeEnabled: values.WorktreeEnabled, + WorktreeEnabledSet: values.WorktreeEnabledSet, + PlansDir: values.PlansDir, + DefaultBranch: values.DefaultBranch, + VcsCommand: values.VcsCommand, + CommitTrailer: values.CommitTrailer, + WatchDirs: values.WatchDirs, + ClaudeErrorPatterns: values.ClaudeErrorPatterns, + CodexErrorPatterns: values.CodexErrorPatterns, + TaskHeaderPatterns: headerPatterns, + ClaudeLimitPatterns: values.ClaudeLimitPatterns, + CodexLimitPatterns: values.CodexLimitPatterns, + WaitOnLimit: values.WaitOnLimit, + WaitOnLimitSet: values.WaitOnLimitSet, + SessionTimeout: values.SessionTimeout, + SessionTimeoutSet: values.SessionTimeoutSet, + IdleTimeout: values.IdleTimeout, + IdleTimeoutSet: values.IdleTimeoutSet, NotifyParams: notify.Params{ Channels: values.NotifyChannels, OnError: values.NotifyOnError, @@ -382,3 +419,14 @@ func DefaultConfigDir() string { func (c *Config) LocalDir() string { return c.localDir } + +// isKnownHeaderPreset reports whether s is a built-in preset name that +// pkg/plan/presets.go resolves without regexp.Compile. Kept in sync with +// headerPresets in presets.go; these names are stable API surface. +func isKnownHeaderPreset(s string) bool { + switch s { + case "default", "openspec": + return true + } + return false +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index ed77d039..a033b575 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -29,7 +29,7 @@ func Test_defaultsFS_PromptFiles(t *testing.T) { file string contains []string }{ - {file: "defaults/prompts/task.txt", contains: []string{"{{PLAN_FILE}}", "{{PROGRESS_FILE}}", "RALPHEX:ALL_TASKS_DONE", "RALPHEX:TASK_FAILED", "Success criteria", "Task sections", "### Task N:", "mark them [x]", "do not loop indefinitely"}}, + {file: "defaults/prompts/task.txt", contains: []string{"{{PLAN_FILE}}", "{{PROGRESS_FILE}}", "RALPHEX:ALL_TASKS_DONE", "RALPHEX:TASK_FAILED", "Success criteria", "Task sections", "{{TASK_HEADER_PATTERNS}}", "mark them [x]", "do not loop indefinitely"}}, {file: "defaults/prompts/review_first.txt", contains: []string{"{{GOAL}}", "{{PROGRESS_FILE}}", "RALPHEX:REVIEW_DONE", "{{agent:quality}}", "{{agent:testing}}"}}, {file: "defaults/prompts/review_second.txt", contains: []string{"{{GOAL}}", "{{PROGRESS_FILE}}", "RALPHEX:REVIEW_DONE", "{{agent:quality}}", "{{agent:implementation}}"}}, {file: "defaults/prompts/codex.txt", contains: []string{"{{CODEX_OUTPUT}}", "RALPHEX:CODEX_REVIEW_DONE", "Codex reviewed"}}, @@ -384,6 +384,81 @@ func TestLoad_MovePlanOnCompletion(t *testing.T) { } } +func TestLoad_TaskHeaderPatterns(t *testing.T) { + // default resolves to the "default" preset name (which the plan package maps + // to the built-in Task/Iteration regex). no longer mirrors template strings. + expectedDefaults := []string{"default"} + + testCases := []struct { + name string + configBody string + wantList []string + wantErrPart string + }{ + { + name: "default not set yields built-in defaults", + configBody: "", + wantList: expectedDefaults, + }, + { + name: "explicit list yields that list (preset + raw regex)", + configBody: `task_header_patterns = openspec, ^# (\d+)\.\s*(.*)$`, + wantList: []string{"openspec", `^# (\d+)\.\s*(.*)$`}, + }, + { + name: "explicit empty yields built-in defaults (fallback)", + configBody: "task_header_patterns = ", + wantList: expectedDefaults, + }, + { + name: "whitespace-only entries yield defaults (fallback)", + configBody: "task_header_patterns = , ,", + wantList: expectedDefaults, + }, + { + name: "single preset name yields single-element list", + configBody: "task_header_patterns = openspec", + wantList: []string{"openspec"}, + }, + { + name: "invalid raw regex fails at config load time", + configBody: "task_header_patterns = [unclosed", + wantErrPart: "invalid task_header_patterns", + }, + { + name: "raw regex without capture group fails at config load time", + configBody: `task_header_patterns = ^## .+$`, + wantErrPart: "invalid task_header_patterns", + }, + { + name: "raw regex with quantifier comma parsed as single pattern", + configBody: `task_header_patterns = ^#{1,3} Task (\d+):\s*(.*)$`, + wantList: []string{`^#{1,3} Task (\d+):\s*(.*)$`}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, "ralphex") + require.NoError(t, os.MkdirAll(configDir, 0o700)) + require.NoError(t, os.MkdirAll(filepath.Join(configDir, "prompts"), 0o700)) + require.NoError(t, os.MkdirAll(filepath.Join(configDir, "agents"), 0o700)) + + require.NoError(t, os.WriteFile(filepath.Join(configDir, "config"), []byte(tc.configBody), 0o600)) + + cfg, err := Load(configDir) + if tc.wantErrPart != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErrPart) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantList, cfg.TaskHeaderPatterns) + }) + } +} + func TestLoad_AllUserValues(t *testing.T) { tmpDir := t.TempDir() configDir := filepath.Join(tmpDir, "ralphex") diff --git a/pkg/config/defaults/config b/pkg/config/defaults/config index 733d96ef..271456ab 100644 --- a/pkg/config/defaults/config +++ b/pkg/config/defaults/config @@ -109,6 +109,20 @@ external_review_tool = codex # default: true move_plan_on_completion = true +# ------------------------------------------------------------------------------ +# plan parsing +# ------------------------------------------------------------------------------ + +# task_header_patterns: comma-separated list of preset names or raw Go regexes +# used to recognize task sections in plan files. each entry is either a known +# preset name ("default", "openspec") or a raw Go regexp. capture group 1 must +# be the task id and capture group 2 (optional) the title. +# presets: "default" matches "### Task N: title" and "### Iteration N: title"; +# "openspec" matches "## N. title". +# example raw regex: ^### (?:Task|Iteration) ([^:]+?):\s*(.*)$ +# default: default +# task_header_patterns = default + # ------------------------------------------------------------------------------ # worktree isolation # ------------------------------------------------------------------------------ diff --git a/pkg/config/defaults/prompts/task.txt b/pkg/config/defaults/prompts/task.txt index beb4be0e..e727aff7 100644 --- a/pkg/config/defaults/prompts/task.txt +++ b/pkg/config/defaults/prompts/task.txt @@ -6,8 +6,9 @@ # {{PROGRESS_FILE}} - path to the progress log file # {{GOAL}} - human-readable goal description # {{DEFAULT_BRANCH}} - default branch name (main, master, trunk, etc.) +# {{TASK_HEADER_PATTERNS}} - configured task header patterns (preset descriptions or regex, quoted, "or"-joined) -Read the plan file at {{PLAN_FILE}}. Find the FIRST Task section (### Task N: or ### Iteration N:) that has uncompleted checkboxes ([ ]). +Read the plan file at {{PLAN_FILE}}. Find the FIRST Task section (matching {{TASK_HEADER_PATTERNS}}) that has uncompleted checkboxes ([ ]). If NO Task section has [ ] but ## Success criteria, ## Overview, or ## Context still has [ ]: either satisfy those items and mark them [x] if actionable, or output <<>> if they are verification-only (manual testing, deployment, etc.) — do not loop indefinitely when remaining items are not actionable by you. @@ -16,7 +17,7 @@ If a Task section has [ ] checkboxes you cannot complete (manual testing, deploy NOTE: Progress is logged to {{PROGRESS_FILE}} - this file contains detailed execution steps and can be reviewed for debugging. CRITICAL CONSTRAINT: Complete ONE Task section per iteration. -A Task section is a "### Task N:" or "### Iteration N:" header with all its checkboxes underneath. +A Task section is a header matching {{TASK_HEADER_PATTERNS}} with all its checkboxes underneath. Complete ALL checkboxes in that section, then STOP. Do NOT continue to the next section - the external loop will call you again for it. @@ -39,12 +40,12 @@ STEP 2 - VALIDATE: STEP 3 - COMPLETE (after validation passes): - Update progress: edit {{PLAN_FILE}} and change [ ] to [x] for each checkbox you implemented in the current Task section. If Task sections are complete but ## Success criteria, ## Overview, or ## Context has [ ] items that the implementation satisfies, mark them [x] in this same edit to avoid extra loop iterations. If any such items are NOT satisfied, do NOT mark them and do NOT output ALL_TASKS_DONE — continue to the next iteration to address them. - Commit all changes (code + updated plan) with message: feat: -- Check if any [ ] checkboxes remain in Task sections (### Task N: or ### Iteration N:) +- Check if any [ ] checkboxes remain in Task sections (headers matching {{TASK_HEADER_PATTERNS}}) - If NO more [ ] checkboxes in the entire plan, output exactly: <<>> - If more Task sections have [ ] checkboxes, STOP HERE - do not continue If any phase fails after reasonable fix attempts, output exactly: <<>> -REMINDER: ONE section (Task/Iteration) per loop cycle. After commit, STOP and let the loop handle the next section. +REMINDER: ONE section per loop cycle. After commit, STOP and let the loop handle the next section. OUTPUT FORMAT: No markdown formatting (no **bold**, `code`, # headers). Plain text and - lists are fine. Do not echo phase names or step numbers - just do the work. diff --git a/pkg/config/values.go b/pkg/config/values.go index c01c282a..9d9a72be 100644 --- a/pkg/config/values.go +++ b/pkg/config/values.go @@ -20,6 +20,8 @@ type Values struct { TaskModel string // model for task execution (e.g., "opus", "sonnet", "haiku") ReviewModel string // model for review phases (falls back to TaskModel if empty) ClaudeErrorPatterns []string // patterns to detect in claude output (e.g., rate limit messages) + TaskHeaderPatterns []string // preset names or raw regexes used to recognize task section headers + taskHeaderPatternsSet bool // tracks if task_header_patterns was explicitly set (allows empty to disable) CodexEnabled bool CodexEnabledSet bool // tracks if codex_enabled was explicitly set CodexCommand string @@ -342,6 +344,15 @@ func (vl *valuesLoader) parseValuesFromBytes(data []byte) (Values, error) { values.ClaudeErrorPatterns = vl.parseCommaSeparated(section, "claude_error_patterns") values.CodexErrorPatterns = vl.parseCommaSeparated(section, "codex_error_patterns") + // task header patterns: track explicit presence so callers can distinguish + // "unset, use defaults" from "explicitly set to empty". uses a regex-aware + // splitter so raw patterns containing commas (e.g. {1,3} quantifiers) are + // not incorrectly split at those commas. + if section.HasKey("task_header_patterns") { + values.taskHeaderPatternsSet = true + values.TaskHeaderPatterns = vl.parseTaskHeaderPatternsList(section, "task_header_patterns") + } + // limit patterns (comma-separated, same format as error patterns) values.ClaudeLimitPatterns = vl.parseCommaSeparated(section, "claude_limit_patterns") values.CodexLimitPatterns = vl.parseCommaSeparated(section, "codex_limit_patterns") @@ -528,6 +539,13 @@ func (dst *Values) mergeExtraFrom(src *Values) { if len(src.ClaudeErrorPatterns) > 0 { dst.ClaudeErrorPatterns = src.ClaudeErrorPatterns } + // deliberately guard on taskHeaderPatternsSet (not len > 0) so an explicit + // empty value can clear a parent config; runtime default fallback is applied + // later in the Config builder, not here. + if src.taskHeaderPatternsSet { + dst.TaskHeaderPatterns = src.TaskHeaderPatterns + dst.taskHeaderPatternsSet = true + } if len(src.CodexErrorPatterns) > 0 { dst.CodexErrorPatterns = src.CodexErrorPatterns } @@ -745,6 +763,81 @@ func (vl *valuesLoader) parseCommaSeparated(section *ini.Section, key string) [] return result } +// parseTaskHeaderPatternsList reads the task_header_patterns key and splits it +// using a regex-aware splitter that does not break on commas inside quantifiers +// ({n,m}) or character classes ([a,b]). +func (vl *valuesLoader) parseTaskHeaderPatternsList(section *ini.Section, key string) []string { + k, err := section.GetKey(key) + if err != nil { + return nil + } + val := strings.TrimSpace(k.String()) + if val == "" { + return nil + } + return splitTaskHeaderPatterns(val) +} + +// splitTaskHeaderPatterns splits a comma-separated list of task header pattern +// strings while treating commas inside regex quantifier braces ({n,m}) and +// character classes ([...]) as part of the pattern rather than separators. +// This allows raw Go regexes like `^#{1,3} Task (\d+)` to be used without +// escaping. +func splitTaskHeaderPatterns(val string) []string { + var result []string + var cur strings.Builder + braceDepth := 0 + charClass := false + escaped := false + + for i := 0; i < len(val); i++ { + ch := val[i] + if escaped { + cur.WriteByte(ch) + escaped = false + continue + } + switch ch { + case '\\': + escaped = true + cur.WriteByte(ch) + case '[': + if !charClass { + charClass = true + } + cur.WriteByte(ch) + case ']': + charClass = false + cur.WriteByte(ch) + case '{': + if !charClass { + braceDepth++ + } + cur.WriteByte(ch) + case '}': + if !charClass && braceDepth > 0 { + braceDepth-- + } + cur.WriteByte(ch) + case ',': + if braceDepth == 0 && !charClass { + if t := strings.TrimSpace(cur.String()); t != "" { + result = append(result, t) + } + cur.Reset() + } else { + cur.WriteByte(ch) + } + default: + cur.WriteByte(ch) + } + } + if t := strings.TrimSpace(cur.String()); t != "" { + result = append(result, t) + } + return result +} + // expandTilde expands a leading ~ in a path to the user's home directory. // returns the original path if it doesn't start with ~/ or if home dir is unavailable. func expandTilde(path string) string { diff --git a/pkg/config/values_test.go b/pkg/config/values_test.go index 0f988fbc..94ed6b98 100644 --- a/pkg/config/values_test.go +++ b/pkg/config/values_test.go @@ -888,6 +888,140 @@ func TestValuesLoader_Load_ErrorPatternsOverride(t *testing.T) { assert.Equal(t, []string{"local pattern"}, values.ClaudeErrorPatterns) } +func TestValuesLoader_parseValuesFromBytes_TaskHeaderPatterns(t *testing.T) { + vl := &valuesLoader{embedFS: defaultsFS} + + tests := []struct { + name string + input string + expected []string + expectedSet bool + }{ + { + name: "key absent", + input: "", + expected: nil, + expectedSet: false, + }, + { + name: "explicit preset names list", + input: "task_header_patterns = default, openspec", + expected: []string{"default", "openspec"}, + expectedSet: true, + }, + { + name: "explicit empty string", + input: "task_header_patterns = ", + expected: nil, + expectedSet: true, + }, + { + name: "single preset name", + input: "task_header_patterns = openspec", + expected: []string{"openspec"}, + expectedSet: true, + }, + { + name: "whitespace trimmed around commas", + input: `task_header_patterns = default , ^## (\d+)\.\s*(.*)$ `, + expected: []string{"default", `^## (\d+)\.\s*(.*)$`}, + expectedSet: true, + }, + { + name: "whitespace-only entries dropped", + input: "task_header_patterns = , ,", + expected: nil, + expectedSet: true, + }, + { + name: "duplicate entries preserved in order", + input: "task_header_patterns = default, default", + expected: []string{"default", "default"}, + expectedSet: true, + }, + { + name: "raw regex with special chars stored as-is", + input: `task_header_patterns = ^# Phase (\d+):\s*(.*)$`, + expected: []string{`^# Phase (\d+):\s*(.*)$`}, + expectedSet: true, + }, + { + name: "raw regex with comma inside quantifier kept intact", + input: `task_header_patterns = ^#{1,3} Task (\d+):\s*(.*)$`, + expected: []string{`^#{1,3} Task (\d+):\s*(.*)$`}, + expectedSet: true, + }, + { + name: "two patterns where first has quantifier comma", + input: `task_header_patterns = ^#{1,3} Task (\d+):\s*(.*)$, openspec`, + expected: []string{`^#{1,3} Task (\d+):\s*(.*)$`, "openspec"}, + expectedSet: true, + }, + { + name: "raw regex with comma inside character class kept intact", + input: `task_header_patterns = ^[A-Z,a-z]+ (\d+):\s*(.*)$`, + expected: []string{`^[A-Z,a-z]+ (\d+):\s*(.*)$`}, + expectedSet: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + values, err := vl.parseValuesFromBytes([]byte(tc.input)) + require.NoError(t, err) + assert.Equal(t, tc.expected, values.TaskHeaderPatterns) + assert.Equal(t, tc.expectedSet, values.taskHeaderPatternsSet) + }) + } +} + +func TestValues_mergeFrom_TaskHeaderPatterns(t *testing.T) { + t.Run("src set overrides dst", func(t *testing.T) { + dst := Values{ + TaskHeaderPatterns: []string{"dst pattern"}, + taskHeaderPatternsSet: true, + } + src := Values{ + TaskHeaderPatterns: []string{"src pattern 1", "src pattern 2"}, + taskHeaderPatternsSet: true, + } + dst.mergeFrom(&src) + + assert.Equal(t, []string{"src pattern 1", "src pattern 2"}, dst.TaskHeaderPatterns) + assert.True(t, dst.taskHeaderPatternsSet) + }) + + t.Run("src set to empty clears dst", func(t *testing.T) { + dst := Values{ + TaskHeaderPatterns: []string{"dst pattern"}, + taskHeaderPatternsSet: true, + } + src := Values{ + TaskHeaderPatterns: nil, + taskHeaderPatternsSet: true, + } + dst.mergeFrom(&src) + + assert.Nil(t, dst.TaskHeaderPatterns) + assert.True(t, dst.taskHeaderPatternsSet) + }) + + t.Run("src unset preserves dst", func(t *testing.T) { + dst := Values{ + TaskHeaderPatterns: []string{"dst pattern"}, + taskHeaderPatternsSet: true, + } + src := Values{ + TaskHeaderPatterns: nil, + taskHeaderPatternsSet: false, + } + dst.mergeFrom(&src) + + assert.Equal(t, []string{"dst pattern"}, dst.TaskHeaderPatterns) + assert.True(t, dst.taskHeaderPatternsSet) + }) +} + func TestValuesLoader_Load_AllCommentedConfigFallsBackToEmbedded(t *testing.T) { tmpDir := t.TempDir() globalConfig := filepath.Join(tmpDir, "config") diff --git a/pkg/plan/parse.go b/pkg/plan/parse.go index ce33018b..43fce651 100644 --- a/pkg/plan/parse.go +++ b/pkg/plan/parse.go @@ -43,7 +43,6 @@ type Plan struct { // patterns for parsing plan markdown. var ( - taskHeaderPattern = regexp.MustCompile(`^###\s+(?:Task|Iteration)\s+([^:]+?):\s*(.*)$`) // allow leading whitespace for indented sub-items (e.g. " - [ ] Unit tests") checkboxPattern = regexp.MustCompile(`^\s*-\s+\[([ xX])\]\s*(.*)$`) titlePattern = regexp.MustCompile(`^#\s+(.*)$`) @@ -51,58 +50,122 @@ var ( formatInText = regexp.MustCompile(`\[\s*[ xX]?\s*\]`) ) +// matchTaskHeader tries each compiled pattern in order and returns the +// (taskNum, title) capture groups for the first match. ok=false if no pattern matched. +func matchTaskHeader(line string, compiled []*regexp.Regexp) (taskID, title string, ok bool) { + for _, re := range compiled { + matches := re.FindStringSubmatch(line) + if matches == nil { + continue + } + id := "" + if len(matches) > 1 { + id = matches[1] + } + t := "" + if len(matches) > 2 { + t = matches[2] + } + return id, t, true + } + return "", "", false +} + +// headingLevel returns the number of leading '#' characters on a line, or 0 if +// the line is not a markdown heading. a line starting with '#' is considered a +// heading regardless of whether a space follows (matching the legacy behavior +// that used strings.HasPrefix without whitespace checks). +func headingLevel(line string) int { + i := 0 + for i < len(line) && line[i] == '#' { + i++ + } + return i +} + +// closesTask reports whether a non-task-matching heading at lineLevel should +// close a task opened at taskLevel. strictly shallower headings always close +// (they start a new top-level section). same-level headings close only when +// the task lives at the top of the document tree (level 1 or 2), because at +// those levels a same-level heading is a sibling section, not a sub-note; at +// deeper levels a same-level non-matching heading is treated as a note inside +// the task so its checkboxes remain attached. see parse_test.go cases +// "non-matching h3 does NOT close current task" and +// "H1 task template closes preceding task on later non-task H1". +func closesTask(lineLevel, taskLevel int) bool { + if lineLevel <= 0 || taskLevel <= 0 { + return false + } + if lineLevel < taskLevel { + return true + } + return lineLevel == taskLevel && taskLevel <= 2 +} + // ParsePlan parses plan markdown content into a structured Plan. -func ParsePlan(content string) (*Plan, error) { +// patterns is a slice of pre-compiled task-header regexes. If empty, +// DefaultHeaderPatterns() is used. +func ParsePlan(content string, patterns []*regexp.Regexp) (*Plan, error) { + compiled := patterns + if len(compiled) == 0 { + compiled = DefaultHeaderPatterns() + } + p := &Plan{ Tasks: make([]Task, 0), } scanner := bufio.NewScanner(strings.NewReader(content)) var currentTask *Task + currentTaskLevel := 0 // heading level (# count) of the currently open task for scanner.Scan() { line := scanner.Text() + level := headingLevel(line) - // check for plan title (first h1) - if p.Title == "" { - if matches := titlePattern.FindStringSubmatch(line); matches != nil { - p.Title = strings.TrimSpace(matches[1]) - continue - } - } - - // check for task header - if matches := taskHeaderPattern.FindStringSubmatch(line); matches != nil { + // check for task header first (first match wins across configured patterns). + // runs before the H1 title capture so a custom H1 task regex like + // "^# (\d+)\. (.*)$" is not silently consumed as the plan title. + if id, title, matched := matchTaskHeader(line, compiled); matched { // save previous task if exists if currentTask != nil { currentTask.Status = DetermineTaskStatus(currentTask.Checkboxes) p.Tasks = append(p.Tasks, *currentTask) } - taskNum := parseTaskNum(matches[1]) - currentTask = &Task{ - Number: taskNum, - Title: strings.TrimSpace(matches[2]), + Number: parseTaskNum(id), + Title: strings.TrimSpace(title), Status: TaskStatusPending, Checkboxes: make([]Checkbox, 0), } + currentTaskLevel = level continue } - // non-Task section header (e.g. ## Success criteria, ## Overview, ## Context): - // close current task so checkboxes below are not attached to it. - // only ## (h2) closes; ### and #### are subsections and must not orphan checkboxes. - // also close on # (h1) when title already set, e.g. # Overview in plans using single hash for sections. - isH2 := strings.HasPrefix(line, "##") && !strings.HasPrefix(line, "###") - isH1AfterTitle := strings.HasPrefix(line, "#") && p.Title != "" && !strings.HasPrefix(line, "##") - if currentTask != nil && (isH2 || isH1AfterTitle) && !taskHeaderPattern.MatchString(line) { + // non-task heading: close the current task when it starts a new section at + // a shallower or sibling-top-level position (see closesTask). this lets + // deeper headings (e.g. #### inside a ### task) stay attached to the task + // as sub-notes, while a ## Success criteria or # Overview still closes a + // ### task, and a ## Phase task is not prematurely closed by a ### note. + if currentTask != nil && closesTask(level, currentTaskLevel) { currentTask.Status = DetermineTaskStatus(currentTask.Checkboxes) p.Tasks = append(p.Tasks, *currentTask) currentTask = nil + currentTaskLevel = 0 continue } + // check for plan title (first h1) — only when no task header matched above + // and no task is open, so H1-style task templates aren't swallowed here and + // a later # Section doesn't retroactively become the plan title. + if p.Title == "" && level == 1 { + if matches := titlePattern.FindStringSubmatch(line); matches != nil { + p.Title = strings.TrimSpace(matches[1]) + continue + } + } + // check for checkbox (only if inside a task) if currentTask != nil { if matches := checkboxPattern.FindStringSubmatch(line); matches != nil { @@ -129,12 +192,14 @@ func ParsePlan(content string) (*Plan, error) { } // ParsePlanFile reads and parses a plan file from disk. -func ParsePlanFile(path string) (*Plan, error) { +// patterns is a slice of pre-compiled task-header regexes. If empty, +// DefaultHeaderPatterns() is used. +func ParsePlanFile(path string, patterns []*regexp.Regexp) (*Plan, error) { content, err := os.ReadFile(path) //nolint:gosec // path is internally resolved, not from user input if err != nil { return nil, fmt.Errorf("read plan file: %w", err) } - return ParsePlan(string(content)) + return ParsePlan(string(content), patterns) } // FileHasUncompletedCheckbox returns true if the file contains any uncompleted actionable checkbox (- [ ]). diff --git a/pkg/plan/parse_test.go b/pkg/plan/parse_test.go index 8fa540d4..90aa69d9 100644 --- a/pkg/plan/parse_test.go +++ b/pkg/plan/parse_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "regexp" "testing" "github.com/stretchr/testify/assert" @@ -12,6 +13,15 @@ import ( "github.com/umputun/ralphex/pkg/plan" ) +// mustResolve is a test helper that resolves pattern strings (preset names or raw regexes) +// to compiled regexes, failing the test on error. +func mustResolve(t *testing.T, patterns ...string) []*regexp.Regexp { + t.Helper() + res, err := plan.ResolveHeaderPatterns(patterns) + require.NoError(t, err) + return res +} + func TestParsePlan(t *testing.T) { t.Run("parses plan with title and tasks", func(t *testing.T) { content := `# My Test Plan @@ -29,7 +39,7 @@ Some description here. - [ ] Task 2 item 1 - [ ] Task 2 item 2 ` - p, err := plan.ParsePlan(content) + p, err := plan.ParsePlan(content, nil) require.NoError(t, err) assert.Equal(t, "My Test Plan", p.Title) @@ -61,7 +71,7 @@ Some description here. - [x] Item 2 ` - p, err := plan.ParsePlan(content) + p, err := plan.ParsePlan(content, nil) require.NoError(t, err) require.Len(t, p.Tasks, 2) @@ -82,7 +92,7 @@ Some description here. - [x] Item 1 - [x] Item 2 ` - p, err := plan.ParsePlan(content) + p, err := plan.ParsePlan(content, nil) require.NoError(t, err) require.Len(t, p.Tasks, 1) @@ -100,7 +110,7 @@ Just some text, no checkboxes. - [ ] One item ` - p, err := plan.ParsePlan(content) + p, err := plan.ParsePlan(content, nil) require.NoError(t, err) require.Len(t, p.Tasks, 2) @@ -116,7 +126,7 @@ Just some text, no checkboxes. - [X] Uppercase checked - [x] Lowercase checked ` - p, err := plan.ParsePlan(content) + p, err := plan.ParsePlan(content, nil) require.NoError(t, err) require.Len(t, p.Tasks[0].Checkboxes, 2) @@ -129,7 +139,7 @@ Just some text, no checkboxes. - [ ] Item ` - p, err := plan.ParsePlan(content) + p, err := plan.ParsePlan(content, nil) require.NoError(t, err) assert.Empty(t, p.Title) @@ -137,7 +147,7 @@ Just some text, no checkboxes. }) t.Run("handles empty content", func(t *testing.T) { - p, err := plan.ParsePlan("") + p, err := plan.ParsePlan("", nil) require.NoError(t, err) assert.Empty(t, p.Title) @@ -153,7 +163,7 @@ Just some text, no checkboxes. - [ ] Inside task ` - p, err := plan.ParsePlan(content) + p, err := plan.ParsePlan(content, nil) require.NoError(t, err) require.Len(t, p.Tasks, 1) @@ -172,7 +182,7 @@ Just some text, no checkboxes. - [ ] Manual: run e2e test ` - p, err := plan.ParsePlan(content) + p, err := plan.ParsePlan(content, nil) require.NoError(t, err) require.Len(t, p.Tasks, 1) @@ -192,7 +202,7 @@ Just some text, no checkboxes. - [ ] Manual: verify ` - p, err := plan.ParsePlan(content) + p, err := plan.ParsePlan(content, nil) require.NoError(t, err) require.Len(t, p.Tasks, 1) @@ -212,7 +222,7 @@ Just some text, no checkboxes. - [ ] sub item ` - p, err := plan.ParsePlan(content) + p, err := plan.ParsePlan(content, nil) require.NoError(t, err) require.Len(t, p.Tasks, 1) @@ -240,7 +250,7 @@ Just some text, no checkboxes. - [ ] Item ` - p, err := plan.ParsePlan(content) + p, err := plan.ParsePlan(content, nil) require.NoError(t, err) require.Len(t, p.Tasks, 4) @@ -266,7 +276,7 @@ Just some text, no checkboxes. - [ ] Item ` - p, err := plan.ParsePlan(content) + p, err := plan.ParsePlan(content, nil) require.NoError(t, err) require.Len(t, p.Tasks, 1) @@ -286,7 +296,7 @@ Just some text, no checkboxes. ### Task 3: Third - [ ] C ` - p, err := plan.ParsePlan(content) + p, err := plan.ParsePlan(content, nil) require.NoError(t, err) require.Len(t, p.Tasks, 3) @@ -304,7 +314,7 @@ Just some text, no checkboxes. - [ ] Unit tests for handler - [ ] Integration tests ` - p, err := plan.ParsePlan(content) + p, err := plan.ParsePlan(content, nil) require.NoError(t, err) require.Len(t, p.Tasks, 1) @@ -322,7 +332,7 @@ Just some text, no checkboxes. - [x] Faulti format - [ ] use this format for [ ] unchecked items ` - p, err := plan.ParsePlan(content) + p, err := plan.ParsePlan(content, nil) require.NoError(t, err) require.Len(t, p.Tasks, 1) @@ -339,7 +349,7 @@ Just some text, no checkboxes. - [ ] Create HashPassword - [ ] use [ ] for format example ` - p, err := plan.ParsePlan(content) + p, err := plan.ParsePlan(content, nil) require.NoError(t, err) require.Len(t, p.Tasks, 1) @@ -347,6 +357,331 @@ Just some text, no checkboxes. }) } +func TestParsePlan_CustomPatterns(t *testing.T) { + t.Run("OpenSpec-style ## N. Phase headers", func(t *testing.T) { + content := `# OpenSpec Plan + +## 1. Phase One + +- [ ] 1.1 First item +- [x] 1.2 Second item + +## 2. Phase Two + +- [ ] 2.1 Another item +` + p, err := plan.ParsePlan(content, mustResolve(t, "openspec")) + require.NoError(t, err) + + require.Len(t, p.Tasks, 2) + assert.Equal(t, 1, p.Tasks[0].Number) + assert.Equal(t, "Phase One", p.Tasks[0].Title) + require.Len(t, p.Tasks[0].Checkboxes, 2) + assert.Equal(t, "1.1 First item", p.Tasks[0].Checkboxes[0].Text) + assert.False(t, p.Tasks[0].Checkboxes[0].Checked) + assert.True(t, p.Tasks[0].Checkboxes[1].Checked) + assert.Equal(t, plan.TaskStatusActive, p.Tasks[0].Status) + + assert.Equal(t, 2, p.Tasks[1].Number) + assert.Equal(t, "Phase Two", p.Tasks[1].Title) + require.Len(t, p.Tasks[1].Checkboxes, 1) + }) + + t.Run("mixed patterns parse in document order", func(t *testing.T) { + content := `# Mixed + +### Task 1: First + +- [ ] A + +## 2. Phase Two + +- [ ] B + +### Iteration 3: Third + +- [x] C +` + p, err := plan.ParsePlan(content, mustResolve(t, "default", "openspec")) + require.NoError(t, err) + + require.Len(t, p.Tasks, 3) + assert.Equal(t, 1, p.Tasks[0].Number) + assert.Equal(t, "First", p.Tasks[0].Title) + assert.Equal(t, 2, p.Tasks[1].Number) + assert.Equal(t, "Phase Two", p.Tasks[1].Title) + assert.Equal(t, 3, p.Tasks[2].Number) + assert.Equal(t, "Third", p.Tasks[2].Title) + }) + + t.Run("non-matching h2 closes current task (unchanged)", func(t *testing.T) { + // with only ### Task patterns configured, a plain ## header + // that doesn't match closes the current task. + content := `# Plan + +### Task 1: First + +- [x] done + +## Success criteria + +- [ ] outside +` + p, err := plan.ParsePlan(content, mustResolve(t, "default")) + require.NoError(t, err) + + require.Len(t, p.Tasks, 1) + assert.Equal(t, plan.TaskStatusDone, p.Tasks[0].Status) + require.Len(t, p.Tasks[0].Checkboxes, 1) + }) + + t.Run("non-matching h1 after title closes current task (unchanged)", func(t *testing.T) { + content := `# Plan + +### Task 1: First + +- [x] done + +# Overview + +- [ ] outside +` + p, err := plan.ParsePlan(content, mustResolve(t, "default")) + require.NoError(t, err) + + require.Len(t, p.Tasks, 1) + assert.Equal(t, plan.TaskStatusDone, p.Tasks[0].Status) + require.Len(t, p.Tasks[0].Checkboxes, 1) + }) + + t.Run("non-matching h3 does NOT close current task (unchanged)", func(t *testing.T) { + // free-form ### sub-note inside a task should not orphan checkboxes. + content := `# Plan + +### Task 1: First + +- [ ] main + +### A note (not a task) + +- [ ] still inside task +` + p, err := plan.ParsePlan(content, mustResolve(t, "default")) + require.NoError(t, err) + + require.Len(t, p.Tasks, 1) + require.Len(t, p.Tasks[0].Checkboxes, 2) + assert.Equal(t, "main", p.Tasks[0].Checkboxes[0].Text) + assert.Equal(t, "still inside task", p.Tasks[0].Checkboxes[1].Text) + }) + + t.Run("matching custom h2 closes preceding h3 task and opens new task", func(t *testing.T) { + content := `# Plan + +### Task 1: First + +- [x] done + +## 2. Phase Two + +- [ ] phase item +` + p, err := plan.ParsePlan(content, mustResolve(t, "default", "openspec")) + require.NoError(t, err) + + require.Len(t, p.Tasks, 2) + assert.Equal(t, 1, p.Tasks[0].Number) + assert.Equal(t, "First", p.Tasks[0].Title) + assert.Equal(t, plan.TaskStatusDone, p.Tasks[0].Status) + + assert.Equal(t, 2, p.Tasks[1].Number) + assert.Equal(t, "Phase Two", p.Tasks[1].Title) + require.Len(t, p.Tasks[1].Checkboxes, 1) + assert.Equal(t, "phase item", p.Tasks[1].Checkboxes[0].Text) + }) + + t.Run("invalid raw regex surfaces compile error via resolver", func(t *testing.T) { + _, err := plan.ResolveHeaderPattern(`^(unclosed`) + require.Error(t, err) + assert.Contains(t, err.Error(), "compile task header pattern") + }) + + t.Run("plan with matching headers but no checkboxes yields zero-checkbox tasks", func(t *testing.T) { + content := `# Plan + +## 1. Phase One + +just prose, no checkboxes. + +## 2. Phase Two + +also no checkboxes. +` + p, err := plan.ParsePlan(content, mustResolve(t, "openspec")) + require.NoError(t, err) + + require.Len(t, p.Tasks, 2) + assert.Empty(t, p.Tasks[0].Checkboxes) + assert.Empty(t, p.Tasks[1].Checkboxes) + assert.Equal(t, plan.TaskStatusPending, p.Tasks[0].Status) + }) + + t.Run("H1 task template does not lose first task to title capture", func(t *testing.T) { + // custom template using a single hash header; the first line must be + // captured as a task, not silently consumed as the plan title. + content := `# 1. First Phase + +- [ ] first item + +# 2. Second Phase + +- [ ] second item +` + p, err := plan.ParsePlan(content, mustResolve(t, `^# (\d+)\.\s*(.*)$`)) + require.NoError(t, err) + assert.Empty(t, p.Title, "first H1 must not be consumed as plan title when it matches a task template") + require.Len(t, p.Tasks, 2) + assert.Equal(t, 1, p.Tasks[0].Number) + assert.Equal(t, "First Phase", p.Tasks[0].Title) + require.Len(t, p.Tasks[0].Checkboxes, 1) + assert.Equal(t, 2, p.Tasks[1].Number) + assert.Equal(t, "Second Phase", p.Tasks[1].Title) + }) + + t.Run("H1 task template closes preceding task on later non-task H1", func(t *testing.T) { + // with a custom H1 task template and no separate plan title, + // a later non-task "# Section" must close the current task + // instead of being swallowed as the plan title while checkboxes + // below silently attach to the preceding task. + content := `# 1. First Phase + +- [ ] first item + +# Overview + +- [ ] outside +` + p, err := plan.ParsePlan(content, mustResolve(t, `^# (\d+)\.\s*(.*)$`)) + require.NoError(t, err) + require.Len(t, p.Tasks, 1) + assert.Equal(t, 1, p.Tasks[0].Number) + assert.Equal(t, "First Phase", p.Tasks[0].Title) + require.Len(t, p.Tasks[0].Checkboxes, 1) + assert.Equal(t, "first item", p.Tasks[0].Checkboxes[0].Text) + }) + + t.Run("default patterns still capture H1 plan title", func(t *testing.T) { + // default templates (### Task/Iteration) do not match "# Title", so the + // first H1 must still be captured as the plan title. + content := `# Plan Title + +### Task 1: Do Stuff + +- [ ] item +` + p, err := plan.ParsePlan(content, nil) + require.NoError(t, err) + assert.Equal(t, "Plan Title", p.Title) + require.Len(t, p.Tasks, 1) + assert.Equal(t, "Do Stuff", p.Tasks[0].Title) + }) + + t.Run("H1 task template: later ## subsection does NOT close task", func(t *testing.T) { + // with H1-level task headers, a ## subsection inside the task must remain + // attached (it's deeper than the task heading, so it's a sub-note). + content := `# 1. First Phase + +- [ ] main item + +## Details + +- [ ] sub item + +# 2. Second Phase + +- [ ] second main +` + p, err := plan.ParsePlan(content, mustResolve(t, `^# (\d+)\.\s*(.*)$`)) + require.NoError(t, err) + + require.Len(t, p.Tasks, 2) + assert.Equal(t, 1, p.Tasks[0].Number) + require.Len(t, p.Tasks[0].Checkboxes, 2) + assert.Equal(t, "main item", p.Tasks[0].Checkboxes[0].Text) + assert.Equal(t, "sub item", p.Tasks[0].Checkboxes[1].Text) + + assert.Equal(t, 2, p.Tasks[1].Number) + require.Len(t, p.Tasks[1].Checkboxes, 1) + }) + + t.Run("H4 task template: higher-level ### section closes task", func(t *testing.T) { + // with H4-level task headers (deeper than the default), a shallower ### + // section must still close the task so its checkboxes don't leak. + content := `# Plan + +#### 1. Deep Task + +- [ ] item inside task + +### Sibling Section + +- [ ] outside task +` + p, err := plan.ParsePlan(content, mustResolve(t, `^#### (\d+)\.\s*(.*)$`)) + require.NoError(t, err) + + require.Len(t, p.Tasks, 1) + require.Len(t, p.Tasks[0].Checkboxes, 1) + assert.Equal(t, "item inside task", p.Tasks[0].Checkboxes[0].Text) + }) + + t.Run("H2 task template: ### sub-note stays inside task", func(t *testing.T) { + // for a ## task, a deeper ### heading is a sub-note and must NOT close + // the task (regression guard: any sibling-same-level logic must not fire here). + content := `# Plan + +## 1. Phase One + +- [ ] main item + +### Notes + +- [ ] still inside task + +## 2. Phase Two + +- [ ] second +` + p, err := plan.ParsePlan(content, mustResolve(t, "openspec")) + require.NoError(t, err) + + require.Len(t, p.Tasks, 2) + require.Len(t, p.Tasks[0].Checkboxes, 2) + assert.Equal(t, "main item", p.Tasks[0].Checkboxes[0].Text) + assert.Equal(t, "still inside task", p.Tasks[0].Checkboxes[1].Text) + require.Len(t, p.Tasks[1].Checkboxes, 1) + }) + + t.Run("ParsePlanFile accepts openspec preset patterns", func(t *testing.T) { + content := `# Plan + +## 1. Phase One + +- [ ] 1.1 item +` + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "plan.md") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + p, err := plan.ParsePlanFile(path, mustResolve(t, "openspec")) + require.NoError(t, err) + + require.Len(t, p.Tasks, 1) + assert.Equal(t, "Phase One", p.Tasks[0].Title) + require.Len(t, p.Tasks[0].Checkboxes, 1) + }) +} + func TestParsePlanFile(t *testing.T) { t.Run("reads and parses file", func(t *testing.T) { content := `# File Plan @@ -359,7 +694,7 @@ func TestParsePlanFile(t *testing.T) { path := filepath.Join(tmpDir, "test-plan.md") require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) - p, err := plan.ParsePlanFile(path) + p, err := plan.ParsePlanFile(path, nil) require.NoError(t, err) assert.Equal(t, "File Plan", p.Title) @@ -367,7 +702,7 @@ func TestParsePlanFile(t *testing.T) { }) t.Run("returns error for missing file", func(t *testing.T) { - _, err := plan.ParsePlanFile("/nonexistent/file.md") + _, err := plan.ParsePlanFile("/nonexistent/file.md", nil) assert.Error(t, err) }) } diff --git a/pkg/plan/presets.go b/pkg/plan/presets.go new file mode 100644 index 00000000..58c117e4 --- /dev/null +++ b/pkg/plan/presets.go @@ -0,0 +1,74 @@ +package plan + +import ( + "fmt" + "regexp" +) + +// headerPresets maps preset names to their compiled regex patterns. +// Capture group 1 = task id, capture group 2 = title (optional). +var headerPresets = map[string]string{ + "default": `^### (?:Task|Iteration) ([^:]+?):\s*(.*)$`, + "openspec": `^## (\d+)\.?\s*(.*)$`, +} + +// headerPresetDescriptions maps preset names to human-readable format examples +// used in the {{TASK_HEADER_PATTERNS}} prompt hint. +var headerPresetDescriptions = map[string]string{ + "default": "### Task N: title or ### Iteration N: title", + "openspec": "## N. title", +} + +// ResolveHeaderPattern resolves a single pattern string: if it matches a known +// preset name, the preset's regex is compiled and returned; otherwise the string +// is compiled directly as a raw regex. raw regexes must have at least one capture +// group for the task ID. +func ResolveHeaderPattern(s string) (*regexp.Regexp, error) { + orig := s + _, isPreset := headerPresets[s] + if isPreset { + s = headerPresets[s] + } + re, err := regexp.Compile(s) + if err != nil { + return nil, fmt.Errorf("compile task header pattern %q: %w", orig, err) + } + if !isPreset && re.NumSubexp() < 1 { + return nil, fmt.Errorf("task header pattern %q requires at least one capture group for task ID", orig) + } + return re, nil +} + +// ResolveHeaderPatterns resolves a slice of pattern strings, returning compiled +// regexes. The first error encountered terminates resolution. +func ResolveHeaderPatterns(patterns []string) ([]*regexp.Regexp, error) { + out := make([]*regexp.Regexp, 0, len(patterns)) + for _, p := range patterns { + re, err := ResolveHeaderPattern(p) + if err != nil { + return nil, err + } + out = append(out, re) + } + return out, nil +} + +// DefaultHeaderPatterns returns the compiled default task header patterns. +// Panics if the built-in regex is invalid (programming error, not user error). +func DefaultHeaderPatterns() []*regexp.Regexp { + re, err := ResolveHeaderPatterns([]string{"default"}) + if err != nil { + panic("default header pattern failed to compile (" + headerPresets["default"] + "): " + err.Error()) + } + return re +} + +// PresetDescription returns a human-readable description of the pattern for use +// in LLM prompts. For known preset names it returns a format example; for raw +// regex strings it returns the regex itself. +func PresetDescription(s string) string { + if desc, ok := headerPresetDescriptions[s]; ok { + return desc + } + return s +} diff --git a/pkg/plan/presets_test.go b/pkg/plan/presets_test.go new file mode 100644 index 00000000..c9428003 --- /dev/null +++ b/pkg/plan/presets_test.go @@ -0,0 +1,125 @@ +package plan + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveHeaderPattern(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + matchLine string + wantMatch bool + }{ + { + name: "default preset resolves", + input: "default", + matchLine: "### Task 1: implement thing", + wantMatch: true, + }, + { + name: "default preset matches iteration", + input: "default", + matchLine: "### Iteration 2: refine output", + wantMatch: true, + }, + { + name: "openspec preset resolves", + input: "openspec", + matchLine: "## 3. Add authentication", + wantMatch: true, + }, + { + name: "openspec preset no dot", + input: "openspec", + matchLine: "## 7 Do something", + wantMatch: true, + }, + { + name: "raw regex compiles", + input: `^# Phase (\d+):\s*(.*)$`, + matchLine: "# Phase 2: cleanup", + wantMatch: true, + }, + { + name: "unknown name treated as raw regex with capture group", + input: `^(defualt)$`, + matchLine: "defualt", + wantMatch: true, + }, + { + name: "invalid regex returns error", + input: `^(unclosed`, + wantErr: true, + }, + { + name: "raw regex without capture group returns error", + input: `^## .+$`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + re, err := ResolveHeaderPattern(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, re) + if tt.matchLine != "" { + assert.Equal(t, tt.wantMatch, re.MatchString(tt.matchLine)) + } + }) + } +} + +func TestResolveHeaderPatterns(t *testing.T) { + t.Run("resolves multiple patterns", func(t *testing.T) { + patterns, err := ResolveHeaderPatterns([]string{"default", "openspec"}) + require.NoError(t, err) + assert.Len(t, patterns, 2) + }) + + t.Run("empty slice returns empty result", func(t *testing.T) { + patterns, err := ResolveHeaderPatterns([]string{}) + require.NoError(t, err) + assert.Empty(t, patterns) + }) + + t.Run("stops on first invalid regex", func(t *testing.T) { + _, err := ResolveHeaderPatterns([]string{"default", `^(bad`, "openspec"}) + require.Error(t, err) + }) +} + +func TestDefaultHeaderPatternsCompiles(t *testing.T) { + patterns := DefaultHeaderPatterns() + require.Len(t, patterns, 1) + assert.True(t, patterns[0].MatchString("### Task 1: something")) + assert.True(t, patterns[0].MatchString("### Iteration 3: refine")) + assert.False(t, patterns[0].MatchString("## 1. openspec style")) +} + +func TestPresetDescription(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"default", "### Task N: title or ### Iteration N: title"}, + {"openspec", "## N. title"}, + {`^# Phase (\d+):`, `^# Phase (\d+):`}, + {"unknown", "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.want, PresetDescription(tt.input)) + }) + } +} diff --git a/pkg/processor/prompts.go b/pkg/processor/prompts.go index 6b7fb10c..4f6b58e6 100644 --- a/pkg/processor/prompts.go +++ b/pkg/processor/prompts.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/umputun/ralphex/pkg/config" + "github.com/umputun/ralphex/pkg/plan" ) // agentRefPattern matches {{agent:name}} template syntax @@ -65,7 +66,7 @@ func (r *Runner) getProgressFileRef() string { } // replaceBaseVariables replaces common template variables in prompts. -// supported: {{PLAN_FILE}}, {{PROGRESS_FILE}}, {{GOAL}}, {{DEFAULT_BRANCH}}, {{PLANS_DIR}} +// supported: {{PLAN_FILE}}, {{PROGRESS_FILE}}, {{GOAL}}, {{DEFAULT_BRANCH}}, {{PLANS_DIR}}, {{TASK_HEADER_PATTERNS}} // this is the core replacement function used by all prompt builders. // replaces common template variables shared across all prompt types. // does not append trailer instruction — callers are responsible for calling appendCommitTrailerInstruction @@ -77,9 +78,27 @@ func (r *Runner) replaceBaseVariables(prompt string) string { result = strings.ReplaceAll(result, "{{GOAL}}", r.getGoal()) result = strings.ReplaceAll(result, "{{DEFAULT_BRANCH}}", r.getDefaultBranch()) result = strings.ReplaceAll(result, "{{PLANS_DIR}}", r.getPlansDir()) + result = strings.ReplaceAll(result, "{{TASK_HEADER_PATTERNS}}", r.getTaskHeaderPatternsHint()) return result } +// getTaskHeaderPatternsHint returns the configured task header patterns as a +// human-readable, single-quoted, " or "-joined string for use in prompts and +// error messages. preset names are expanded to their format description (e.g. +// "default" → "'### Task N: title' or '### Iteration N: title'"). raw regex +// strings are shown as-is. returns empty string when no patterns are configured. +func (r *Runner) getTaskHeaderPatternsHint() string { + if r.cfg.AppConfig == nil || len(r.cfg.AppConfig.TaskHeaderPatterns) == 0 { + return "" + } + patterns := r.cfg.AppConfig.TaskHeaderPatterns + quoted := make([]string, len(patterns)) + for i, p := range patterns { + quoted[i] = "'" + plan.PresetDescription(p) + "'" + } + return strings.Join(quoted, " or ") +} + // appendCommitTrailerInstruction appends trailer instruction to prompt when commit_trailer is configured. // returns prompt unchanged when commit_trailer is empty or AppConfig is nil. func (r *Runner) appendCommitTrailerInstruction(prompt string) string { @@ -119,7 +138,7 @@ If Claude's arguments are invalid, explain why the issues still exist.`, claudeR // replaceVariablesWithIteration replaces all template variables including iteration-aware ones. // supported: {{PLAN_FILE}}, {{PROGRESS_FILE}}, {{GOAL}}, {{DEFAULT_BRANCH}}, {{PLANS_DIR}}, -// {{DIFF_INSTRUCTION}}, {{PREVIOUS_REVIEW_CONTEXT}}, {{agent:name}} +// {{TASK_HEADER_PATTERNS}}, {{DIFF_INSTRUCTION}}, {{PREVIOUS_REVIEW_CONTEXT}}, {{agent:name}} // this variant is used when iteration context is needed (e.g., external review prompts). func (r *Runner) replaceVariablesWithIteration(prompt string, isFirstIteration bool, claudeResponse string) string { result := r.replaceBaseVariables(prompt) @@ -185,7 +204,8 @@ func (r *Runner) expandAgentReferences(prompt string) string { } // replacePromptVariables replaces all template variables including agent references. -// supported: {{PLAN_FILE}}, {{PROGRESS_FILE}}, {{GOAL}}, {{DEFAULT_BRANCH}}, {{PLANS_DIR}}, {{agent:name}} +// supported: {{PLAN_FILE}}, {{PROGRESS_FILE}}, {{GOAL}}, {{DEFAULT_BRANCH}}, {{PLANS_DIR}}, +// {{TASK_HEADER_PATTERNS}}, {{agent:name}} // note: {{CODEX_OUTPUT}} and {{PLAN_DESCRIPTION}} are handled by specific build functions. func (r *Runner) replacePromptVariables(prompt string) string { result := r.replaceBaseVariables(prompt) diff --git a/pkg/processor/prompts_test.go b/pkg/processor/prompts_test.go index ac2f2192..1a7db2d8 100644 --- a/pkg/processor/prompts_test.go +++ b/pkg/processor/prompts_test.go @@ -1145,3 +1145,103 @@ func TestRunner_replaceBaseVariables_CommitTrailer(t *testing.T) { assert.Contains(t, result, "Co-authored-by: test ") }) } + +func TestRunner_replacePromptVariables_TaskHeaderPatterns(t *testing.T) { + tests := []struct { + name string + patterns []string + input string + expected string + }{ + { + name: "default preset shows human-readable description", + patterns: []string{"default"}, + input: "match {{TASK_HEADER_PATTERNS}} only", + expected: "match '### Task N: title or ### Iteration N: title' only", + }, + { + name: "openspec preset shows human-readable description", + patterns: []string{"openspec"}, + input: "headers: {{TASK_HEADER_PATTERNS}}", + expected: "headers: '## N. title'", + }, + { + name: "raw regex shown as-is", + patterns: []string{`^# Phase (\d+):\s*(.*)$`}, + input: "[{{TASK_HEADER_PATTERNS}}]", + expected: "['^# Phase (\\d+):\\s*(.*)$']", + }, + { + name: "empty patterns slice yields empty string", + patterns: nil, + input: "before|{{TASK_HEADER_PATTERNS}}|after", + expected: "before||after", + }, + { + name: "multiple occurrences replaced", + patterns: []string{"default"}, + input: "{{TASK_HEADER_PATTERNS}} and {{TASK_HEADER_PATTERNS}}", + expected: "'### Task N: title or ### Iteration N: title' and '### Task N: title or ### Iteration N: title'", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + appCfg := &config.Config{TaskHeaderPatterns: tc.patterns} + r := &Runner{cfg: Config{AppConfig: appCfg}, log: newMockLogger("")} + result := r.replacePromptVariables(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestRunner_replacePromptVariables_TaskHeaderPatterns_NilAppConfig(t *testing.T) { + // defensive: nil AppConfig should not panic, variable should be cleared to empty + r := &Runner{cfg: Config{AppConfig: nil}, log: newMockLogger("")} + result := r.replacePromptVariables("hdr: {{TASK_HEADER_PATTERNS}}") + assert.Equal(t, "hdr: ", result) +} + +func TestRunner_replacePromptVariables_TaskHeaderPatterns_WithOtherVars(t *testing.T) { + // back-compat: the new variable expands alongside existing variables + appCfg := &config.Config{ + TaskHeaderPatterns: []string{"default"}, + } + r := &Runner{cfg: Config{ + PlanFile: "docs/plans/test.md", + ProgressPath: "progress-test.txt", + AppConfig: appCfg, + }, log: newMockLogger("")} + + prompt := "Read {{PLAN_FILE}} for a {{TASK_HEADER_PATTERNS}} section. Log: {{PROGRESS_FILE}}" + result := r.replacePromptVariables(prompt) + + assert.Contains(t, result, "Read docs/plans/test.md") + assert.Contains(t, result, "### Task N: title") // from default preset description + assert.Contains(t, result, "Log: progress-test.txt") + assert.NotContains(t, result, "{{TASK_HEADER_PATTERNS}}") + assert.NotContains(t, result, "{{PLAN_FILE}}") + assert.NotContains(t, result, "{{PROGRESS_FILE}}") +} + +func TestRunner_replacePromptVariables_TaskHeaderPatterns_DefaultTaskPromptExpansion(t *testing.T) { + // verify the default task.txt prompt expands TaskHeaderPatterns naturally under default config + appCfg := testAppConfig(t) + r := &Runner{cfg: Config{ + PlanFile: "docs/plans/test.md", + ProgressPath: "progress-test.txt", + AppConfig: appCfg, + }, log: newMockLogger("")} + + prompt := r.replacePromptVariables(appCfg.TaskPrompt) + + // no unsubstituted template variables + assert.NotContains(t, prompt, "{{TASK_HEADER_PATTERNS}}") + // default preset rendered as human-readable description + assert.Contains(t, prompt, "### Task N: title") + assert.Contains(t, prompt, "### Iteration N: title") + // core task.txt structural markers still present (back-compat) + assert.Contains(t, prompt, "docs/plans/test.md") + assert.Contains(t, prompt, "<<>>") + assert.Contains(t, prompt, "<<>>") +} diff --git a/pkg/processor/runner.go b/pkg/processor/runner.go index d073c7a7..0cc17e0f 100644 --- a/pkg/processor/runner.go +++ b/pkg/processor/runner.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "os/exec" + "regexp" "strings" "time" @@ -907,24 +908,43 @@ func (r *Runner) buildCodexPrompt(isFirst bool, claudeResponse string) string { return r.replaceVariablesWithIteration(r.cfg.AppConfig.CodexReviewPrompt, isFirst, claudeResponse) } +// taskHeaderPatterns returns compiled task-header regexes for plan parsing. +// returns nil when AppConfig is absent (tests), letting plan.ParsePlan fall back to defaults. +func (r *Runner) taskHeaderPatterns() []*regexp.Regexp { + if r.cfg.AppConfig == nil { + return nil + } + patterns, err := plan.ResolveHeaderPatterns(r.cfg.AppConfig.TaskHeaderPatterns) + if err != nil { + r.log.Print("[WARN] failed to compile task header patterns: %v", err) + return nil + } + return patterns +} + // validatePlanHasTasks returns an error if the plan file has no executable task sections. -// guards against spec/reference docs that lack ### Task N: / ### Iteration N: headers, -// which would otherwise cause the task loop to retry TASK_FAILED until exhaustion. -// callers must ensure r.cfg.PlanFile is non-empty before invoking. +// guards against spec/reference docs that lack headers matching the configured +// task_header_patterns, which would otherwise cause the task loop to retry +// TASK_FAILED until exhaustion. callers must ensure r.cfg.PlanFile is non-empty +// before invoking. func (r *Runner) validatePlanHasTasks() error { path := r.resolvePlanFilePath() - p, err := plan.ParsePlanFile(path) + p, err := plan.ParsePlanFile(path, r.taskHeaderPatterns()) if err != nil { return fmt.Errorf("parse plan for validation: %w", err) } if len(p.Tasks) == 0 { - return fmt.Errorf("plan file %q has no executable task sections (### Task N: or ### Iteration N:); add task sections or pass a different plan file", path) + hint := r.getTaskHeaderPatternsHint() + if hint == "" { + return fmt.Errorf("plan file %q has no executable task sections; add task sections or pass a different plan file", path) + } + return fmt.Errorf("plan file %q has no executable task sections (expected headers matching %s); add task sections or pass a different plan file", path, hint) } return nil } // hasUncompletedTasks checks if any Task section has uncompleted checkboxes. -// only Task sections (### Task N: or ### Iteration N:) are considered. +// only sections matching the configured task_header_patterns are considered. // checkboxes in Success criteria, Overview, or Context are ignored for this check, // so the agent can output ALL_TASKS_DONE when those are verification-only. // for malformed plans (checkboxes without task headers), returns true if any [ ] exists. @@ -933,7 +953,7 @@ func (r *Runner) hasUncompletedTasks() bool { if path == "" { return false // no plan file, nothing to complete } - p, err := plan.ParsePlanFile(path) + p, err := plan.ParsePlanFile(path, r.taskHeaderPatterns()) if err != nil { r.log.Print("[WARN] failed to parse plan file for completion check: %v", err) return true // assume incomplete if can't read @@ -959,7 +979,7 @@ func (r *Runner) hasUncompletedTasks() bool { // nextPlanTaskPosition returns the 1-indexed position of the first uncompleted task in the plan. // returns 0 if the plan file can't be read/parsed or no uncompleted tasks exist (caller falls back to loop counter). func (r *Runner) nextPlanTaskPosition() int { - p, err := plan.ParsePlanFile(r.resolvePlanFilePath()) + p, err := plan.ParsePlanFile(r.resolvePlanFilePath(), r.taskHeaderPatterns()) if err != nil { r.log.Print("[WARN] failed to parse plan file for task position: %v", err) return 0 diff --git a/pkg/progress/progress.go b/pkg/progress/progress.go index ca5ed8aa..afccfc07 100644 --- a/pkg/progress/progress.go +++ b/pkg/progress/progress.go @@ -131,11 +131,12 @@ type Logger struct { // Config holds logger configuration. type Config struct { - PlanFile string // plan filename (used to derive progress filename) - PlanDescription string // plan description for plan mode (used for filename) - Mode string // execution mode: full, review, codex-only, plan - Branch string // current git branch - NoColor bool // disable color output (sets color.NoColor globally) + PlanFile string // plan filename (used to derive progress filename) + PlanDescription string // plan description for plan mode (used for filename) + Mode string // execution mode: full, review, codex-only, plan + Branch string // current git branch + TaskHeaderPatterns []string // task header pattern strings (preset names or raw regexes) + NoColor bool // disable color output (sets color.NoColor globally) } // NewLogger creates a logger writing to both a progress file and stdout. @@ -219,8 +220,16 @@ func NewLogger(cfg Config, colors *Colors, holder *status.PhaseHolder) (*Logger, } if restart { - // write restart separator (matches sectionRegex in web parser) - l.writeFile("\n\n--- restarted at %s ---\n\n", time.Now().Format("2006-01-02 15:04:05")) + // build the entire restart block as a single string so the marker and + // the pattern lines land in one write syscall. a watcher event between + // the marker write and pattern writes would clear patterns (on seeing + // the restart marker) and never restore them until the next event. + var sb strings.Builder + fmt.Fprintf(&sb, "\n\n--- restarted at %s ---\n\n", time.Now().Format("2006-01-02 15:04:05")) + for _, pat := range cfg.TaskHeaderPatterns { + fmt.Fprintf(&sb, "TaskHeaderPattern: %s\n", pat) + } + l.writeFile("%s", sb.String()) } else { l.writeHeader(cfg) } @@ -239,6 +248,9 @@ func (l *Logger) writeHeader(cfg Config) { l.writeFile("Branch: %s\n", cfg.Branch) l.writeFile("Mode: %s\n", cfg.Mode) l.writeFile("Started: %s\n", time.Now().Format("2006-01-02 15:04:05")) + for _, pat := range cfg.TaskHeaderPatterns { + l.writeFile("TaskHeaderPattern: %s\n", pat) + } l.writeFile("%s\n\n", separatorLine) } diff --git a/pkg/progress/progress_test.go b/pkg/progress/progress_test.go index 124ee790..db3292bd 100644 --- a/pkg/progress/progress_test.go +++ b/pkg/progress/progress_test.go @@ -75,6 +75,7 @@ func TestNewLogger(t *testing.T) { } } + func TestNewLogger_AppendOnRestart(t *testing.T) { tmpDir := t.TempDir() origDir, _ := os.Getwd() @@ -129,6 +130,41 @@ func TestNewLogger_AppendOnRestart(t *testing.T) { assert.Equal(t, 1, strings.Count(contentStr, "# Ralphex Progress Log")) } +func TestNewLogger_RestartWritesTaskHeaderPatterns(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(tmpDir)) + defer func() { _ = os.Chdir(origDir) }() + + colors := testColors() + holder := &status.PhaseHolder{} + + cfg1 := Config{PlanFile: "docs/plans/feature.md", Mode: "full", Branch: "main", TaskHeaderPatterns: []string{"default"}} + l1, err := NewLogger(cfg1, colors, holder) + require.NoError(t, err) + l1.Print("first session") + require.NoError(t, unlockFile(l1.file)) + unregisterActiveLock(l1.file.Name()) + require.NoError(t, l1.file.Close()) + l1.file = nil + + // restart with different patterns + cfg2 := Config{PlanFile: "docs/plans/feature.md", Mode: "full", Branch: "main", TaskHeaderPatterns: []string{"openspec"}} + l2, err := NewLogger(cfg2, colors, holder) + require.NoError(t, err) + l2.Print("second session") + require.NoError(t, l2.Close()) + + content, err := os.ReadFile(l2.Path()) + require.NoError(t, err) + s := string(content) + + assert.Contains(t, s, "--- restarted at") + // new pattern written after restart marker + idx := strings.Index(s, "--- restarted at") + assert.Contains(t, s[idx:], "TaskHeaderPattern: openspec") +} + func TestNewLogger_EmptyFileWritesHeader(t *testing.T) { tmpDir := t.TempDir() origDir, _ := os.Getwd() diff --git a/pkg/web/dashboard.go b/pkg/web/dashboard.go index 732832eb..210cd657 100644 --- a/pkg/web/dashboard.go +++ b/pkg/web/dashboard.go @@ -26,41 +26,44 @@ func ConnectHost(host string) string { // DashboardConfig holds configuration for dashboard initialization. type DashboardConfig struct { - BaseLog Logger // base progress logger - Port int // web server port - Host string // host/IP to bind to (default "127.0.0.1") - PlanFile string // path to plan file (empty for watch-only mode) - Branch string // current git branch - WatchDirs []string // CLI watch directories - ConfigWatchDirs []string // config file watch directories - Colors *progress.Colors // colors for output + BaseLog Logger // base progress logger + Port int // web server port + Host string // host/IP to bind to (default "127.0.0.1") + PlanFile string // path to plan file (empty for watch-only mode) + Branch string // current git branch + WatchDirs []string // CLI watch directories + ConfigWatchDirs []string // config file watch directories + Colors *progress.Colors // colors for output + TaskHeaderPatterns []string // task-header templates used to parse plans (empty = plan defaults) } // Dashboard manages web server and file watching for progress monitoring. type Dashboard struct { - port int - host string - planFile string - branch string - baseLog Logger - watchDirs []string - configWatchDirs []string - colors *progress.Colors - holder *status.PhaseHolder + port int + host string + planFile string + branch string + baseLog Logger + watchDirs []string + configWatchDirs []string + colors *progress.Colors + holder *status.PhaseHolder + taskHeaderPatterns []string } // NewDashboard creates a new dashboard with the given configuration. func NewDashboard(cfg DashboardConfig, holder *status.PhaseHolder) *Dashboard { return &Dashboard{ - port: cfg.Port, - host: cfg.Host, - planFile: cfg.PlanFile, - branch: cfg.Branch, - baseLog: cfg.BaseLog, - watchDirs: cfg.WatchDirs, - configWatchDirs: cfg.ConfigWatchDirs, - colors: cfg.Colors, - holder: holder, + port: cfg.Port, + host: cfg.Host, + planFile: cfg.PlanFile, + branch: cfg.Branch, + baseLog: cfg.BaseLog, + watchDirs: cfg.WatchDirs, + configWatchDirs: cfg.ConfigWatchDirs, + colors: cfg.Colors, + holder: holder, + taskHeaderPatterns: cfg.TaskHeaderPatterns, } } @@ -79,11 +82,12 @@ func (d *Dashboard) Start(ctx context.Context) (*BroadcastLogger, error) { } cfg := ServerConfig{ - Port: d.port, - Host: d.host, - PlanName: planName, - Branch: d.branch, - PlanFile: d.planFile, + Port: d.port, + Host: d.host, + PlanName: planName, + Branch: d.branch, + PlanFile: d.planFile, + TaskHeaderPatterns: d.taskHeaderPatterns, } // determine if we should use multi-session mode @@ -182,11 +186,12 @@ func (d *Dashboard) setupWatchMode(ctx context.Context, dirs []string) (chan err } serverCfg := ServerConfig{ - Port: d.port, - Host: d.host, - PlanName: "(watch mode)", - Branch: "", - PlanFile: "", + Port: d.port, + Host: d.host, + PlanName: "(watch mode)", + Branch: "", + PlanFile: "", + TaskHeaderPatterns: d.taskHeaderPatterns, } srv, err := NewServerWithSessions(serverCfg, sm) diff --git a/pkg/web/parse.go b/pkg/web/parse.go index ba8f9ae1..42a99158 100644 --- a/pkg/web/parse.go +++ b/pkg/web/parse.go @@ -45,6 +45,15 @@ func parseProgressLine(line string, inHeader bool) (ParsedLine, bool) { return ParsedLine{Type: ParsedLineSkip}, true } + // suppress metadata lines that carry task header pattern config. + // "TaskHeaderPatterns: " is the old plural form from prior versions; + // "TaskHeaderPattern: " is the current singular form written in the + // initial header and after restart markers. neither should appear as + // plain output in the SSE event stream. + if strings.HasPrefix(line, "TaskHeaderPattern: ") || strings.HasPrefix(line, "TaskHeaderPatterns: ") { + return ParsedLine{Type: ParsedLineSkip}, false + } + // check for section header (--- section name ---) if matches := sectionRegex.FindStringSubmatch(line); matches != nil { sectionName := matches[1] diff --git a/pkg/web/parse_test.go b/pkg/web/parse_test.go index 8ac12a75..47df4ab9 100644 --- a/pkg/web/parse_test.go +++ b/pkg/web/parse_test.go @@ -63,6 +63,15 @@ func TestParseProgressLine(t *testing.T) { assert.False(t, isHeaderSeparator("--- restarted at 2026-02-18 15:30:00 ---")) }) + t.Run("TaskHeaderPatterns line at restart marker is skipped", func(t *testing.T) { + // the writer re-emits TaskHeaderPatterns next to a restart marker so + // dashboards pick up the latest patterns; the event stream should + // suppress the line rather than surface it as plain output. + parsed, inHeader := parseProgressLine("TaskHeaderPatterns: ## {N}. {title}", false) + assert.False(t, inHeader) + assert.Equal(t, ParsedLineSkip, parsed.Type) + }) + t.Run("error line", func(t *testing.T) { parsed, inHeader := parseProgressLine("[26-01-22 10:30:45] ERROR: something failed", false) assert.False(t, inHeader) diff --git a/pkg/web/plan.go b/pkg/web/plan.go index adeb3d8a..4c681d68 100644 --- a/pkg/web/plan.go +++ b/pkg/web/plan.go @@ -5,17 +5,20 @@ import ( "fmt" "io/fs" "path/filepath" + "regexp" "github.com/umputun/ralphex/pkg/plan" ) // loadPlanWithFallback loads a plan from disk with completed/ directory fallback. // does not cache - each call reads from disk. -func loadPlanWithFallback(path string) (*plan.Plan, error) { - p, err := plan.ParsePlanFile(path) +// patterns forwards the configured task_header_patterns to the plan parser so the +// dashboard recognizes the same task sections as the executor. +func loadPlanWithFallback(path string, patterns []*regexp.Regexp) (*plan.Plan, error) { + p, err := plan.ParsePlanFile(path, patterns) if err != nil && errors.Is(err, fs.ErrNotExist) { completedPath := filepath.Join(filepath.Dir(path), "completed", filepath.Base(path)) - p, err = plan.ParsePlanFile(completedPath) + p, err = plan.ParsePlanFile(completedPath, patterns) } if err != nil { return nil, fmt.Errorf("load plan with fallback: %w", err) diff --git a/pkg/web/server.go b/pkg/web/server.go index 78390ab6..34cbf2a4 100644 --- a/pkg/web/server.go +++ b/pkg/web/server.go @@ -24,11 +24,12 @@ var embeddedFS embed.FS // ServerConfig holds configuration for the web server. type ServerConfig struct { - Port int // port to listen on - Host string // host/IP to bind to (default "127.0.0.1") - PlanName string // plan name to display in dashboard - Branch string // git branch name - PlanFile string // path to plan file for /api/plan endpoint + Port int // port to listen on + Host string // host/IP to bind to (default "127.0.0.1") + PlanName string // plan name to display in dashboard + Branch string // git branch name + PlanFile string // path to plan file for /api/plan endpoint + TaskHeaderPatterns []string // task-header templates used to parse plans (empty = plan defaults) } // host returns the bind address, defaulting to "127.0.0.1" if not set. @@ -227,7 +228,19 @@ func (s *Server) handleSessionPlan(w http.ResponseWriter, sessionID string) { planPath = filepath.Join(sessionDir, meta.PlanPath) } - p, err := loadPlanWithFallback(planPath) + // prefer per-session patterns recorded in the progress header; fall back to + // the dashboard server's own config for sessions from the same repo or for + // old progress files that pre-date per-session header storage. + patterns := meta.TaskHeaderPatterns + if len(patterns) == 0 { + patterns = s.cfg.TaskHeaderPatterns + } + compiledPatterns, err := plan.ResolveHeaderPatterns(patterns) + if err != nil { + log.Printf("[WARN] failed to compile task header patterns: %v", err) + compiledPatterns = plan.DefaultHeaderPatterns() + } + p, err := loadPlanWithFallback(planPath, compiledPatterns) if err != nil { log.Printf("[WARN] failed to load plan file %s: %v", meta.PlanPath, err) http.Error(w, "unable to load plan", http.StatusInternalServerError) @@ -247,7 +260,11 @@ func (s *Server) handleSessionPlan(w http.ResponseWriter, sessionID string) { // loadPlan loads a plan from disk (with completed/ fallback). func (s *Server) loadPlan() (*plan.Plan, error) { - return loadPlanWithFallback(s.cfg.PlanFile) + patterns, err := plan.ResolveHeaderPatterns(s.cfg.TaskHeaderPatterns) + if err != nil { + return nil, fmt.Errorf("compile task header patterns: %w", err) + } + return loadPlanWithFallback(s.cfg.PlanFile, patterns) } // handleEvents serves the SSE stream. diff --git a/pkg/web/server_test.go b/pkg/web/server_test.go index dc29f55f..435660c6 100644 --- a/pkg/web/server_test.go +++ b/pkg/web/server_test.go @@ -326,6 +326,49 @@ func TestServer_HandlePlan(t *testing.T) { assert.Contains(t, string(body), "Completed Plan") }) + t.Run("uses configured task_header_patterns to parse plan", func(t *testing.T) { + // plan with custom openspec-style headers: without threading the + // configured patterns into the server the /api/plan endpoint would + // return an empty task list. + session := NewSession("test", "/tmp/test.txt") + defer session.Close() + + tmpDir := t.TempDir() + planFile := filepath.Join(tmpDir, "plan.md") + planContent := `# Custom Plan + +## 1. First Phase + +- [ ] phase one item + +## 2. Second Phase + +- [x] phase two item +` + require.NoError(t, os.WriteFile(planFile, []byte(planContent), 0o600)) + + srv, err := NewServer(ServerConfig{ + Port: 8080, + PlanFile: planFile, + TaskHeaderPatterns: []string{"openspec"}, + }, session) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/api/plan", http.NoBody) + w := httptest.NewRecorder() + + srv.handlePlan(w, req) + + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(body), "First Phase") + assert.Contains(t, string(body), "Second Phase") + }) + t.Run("rejects non-GET methods", func(t *testing.T) { session := NewSession("test", "/tmp/test.txt") defer session.Close() @@ -680,6 +723,117 @@ Started: 2026-01-22 10:30:00 require.NoError(t, err) assert.Contains(t, string(body), "Completed Session Plan") }) + + t.Run("uses dashboard config patterns to parse session plan", func(t *testing.T) { + // the dashboard uses its own configured task_header_patterns for all sessions; + // per-session pattern transport was removed in favor of simpler config-only approach. + tmpDir := t.TempDir() + + origDir, err := os.Getwd() + require.NoError(t, err) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + require.NoError(t, os.Chdir(tmpDir)) + + plansDir := filepath.Join(tmpDir, "plans") + require.NoError(t, os.MkdirAll(plansDir, 0o750)) + + // plan uses openspec-style header + planContent := `# Session Plan + +## 1. Session Phase + +- [ ] Item 1 +` + planPath := filepath.Join(plansDir, "session-plan.md") + require.NoError(t, os.WriteFile(planPath, []byte(planContent), 0o600)) + + progressPath := filepath.Join(tmpDir, "progress-session.txt") + progressContent := "# Ralphex Progress Log\n" + + "Plan: plans/session-plan.md\n" + + "Branch: main\n" + + "Mode: full\n" + + "Started: 2026-01-22 10:30:00\n" + + "------------------------------------------------------------\n" + require.NoError(t, os.WriteFile(progressPath, []byte(progressContent), 0o600)) + + sm := NewSessionManager() + defer sm.Close() + _, err = sm.Discover(tmpDir) + require.NoError(t, err) + + // dashboard config uses openspec preset to match ## N. header + srv, err := NewServerWithSessions(ServerConfig{ + Port: 8080, + TaskHeaderPatterns: []string{"openspec"}, + }, sm) + require.NoError(t, err) + + sessionID := sessionIDFromPath(progressPath) + req := httptest.NewRequest(http.MethodGet, "/api/plan?session="+sessionID, http.NoBody) + w := httptest.NewRecorder() + + srv.handlePlan(w, req) + + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(body), "Session Phase", "dashboard config patterns parse the plan correctly") + }) + + t.Run("uses default preset to parse watched sessions with Task headers", func(t *testing.T) { + // dashboard configured with "default" preset correctly parses plans using + // the built-in ### Task / ### Iteration header format. + tmpDir := t.TempDir() + + origDir, err := os.Getwd() + require.NoError(t, err) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + require.NoError(t, os.Chdir(tmpDir)) + + plansDir := filepath.Join(tmpDir, "plans") + require.NoError(t, os.MkdirAll(plansDir, 0o750)) + + planContent := `# Default-Format Plan + +### Task 1: Watched Task + +- [ ] Item 1 +` + planPath := filepath.Join(plansDir, "watched.md") + require.NoError(t, os.WriteFile(planPath, []byte(planContent), 0o600)) + + progressPath := filepath.Join(tmpDir, "progress-watched.txt") + progressContent := "# Ralphex Progress Log\nPlan: plans/watched.md\nBranch: main\nMode: full\nStarted: 2026-01-22 10:30:00\n------------------------------------------------------------\n" + require.NoError(t, os.WriteFile(progressPath, []byte(progressContent), 0o600)) + + sm := NewSessionManager() + defer sm.Close() + _, err = sm.Discover(tmpDir) + require.NoError(t, err) + + srv, err := NewServerWithSessions(ServerConfig{ + Port: 8080, + TaskHeaderPatterns: []string{"default"}, + }, sm) + require.NoError(t, err) + + sessionID := sessionIDFromPath(progressPath) + req := httptest.NewRequest(http.MethodGet, "/api/plan?session="+sessionID, http.NoBody) + w := httptest.NewRecorder() + + srv.handlePlan(w, req) + + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(body), "Watched Task") + }) } func TestLoadPlanWithFallback(t *testing.T) { @@ -694,7 +848,7 @@ func TestLoadPlanWithFallback(t *testing.T) { ` require.NoError(t, os.WriteFile(planPath, []byte(planContent), 0o600)) - plan, err := loadPlanWithFallback(planPath) + plan, err := loadPlanWithFallback(planPath, nil) require.NoError(t, err) require.NotNil(t, plan) assert.Equal(t, "Test Plan", plan.Title) @@ -717,7 +871,7 @@ func TestLoadPlanWithFallback(t *testing.T) { // request the non-existent original path originalPath := filepath.Join(tmpDir, "test-plan.md") - plan, err := loadPlanWithFallback(originalPath) + plan, err := loadPlanWithFallback(originalPath, nil) require.NoError(t, err) require.NotNil(t, plan) assert.Equal(t, "Completed Plan", plan.Title) @@ -727,7 +881,7 @@ func TestLoadPlanWithFallback(t *testing.T) { tmpDir := t.TempDir() nonexistentPath := filepath.Join(tmpDir, "nonexistent.md") - _, err := loadPlanWithFallback(nonexistentPath) + _, err := loadPlanWithFallback(nonexistentPath, nil) require.Error(t, err) }) } diff --git a/pkg/web/session.go b/pkg/web/session.go index faa4d12f..8d3fcd5f 100644 --- a/pkg/web/session.go +++ b/pkg/web/session.go @@ -54,10 +54,11 @@ const ( // SessionMetadata holds parsed information from progress file header. type SessionMetadata struct { - PlanPath string // path to plan file (from "Plan:" header line) - Branch string // git branch (from "Branch:" header line) - Mode string // execution mode: full, review, codex-only (from "Mode:" header line) - StartTime time.Time // start time (from "Started:" header line) + PlanPath string // path to plan file (from "Plan:" header line) + Branch string // git branch (from "Branch:" header line) + Mode string // execution mode: full, review, codex-only (from "Mode:" header line) + StartTime time.Time // start time (from "Started:" header line) + TaskHeaderPatterns []string // task header pattern strings (from "TaskHeaderPattern:" lines, one per entry) } // defaultTopic is the SSE topic used for all events within a session. diff --git a/pkg/web/session_progress.go b/pkg/web/session_progress.go index fa88ef3c..c5549624 100644 --- a/pkg/web/session_progress.go +++ b/pkg/web/session_progress.go @@ -53,30 +53,64 @@ func ParseProgressHeader(path string) (meta SessionMetadata, complete bool, err // parse key-value pairs (process line before checking error, // as ReadString may return partial data alongside an error) - if val, found := strings.CutPrefix(line, "Plan: "); found { - meta.PlanPath = val - } else if val, found := strings.CutPrefix(line, "Branch: "); found { - meta.Branch = val - } else if val, found := strings.CutPrefix(line, "Mode: "); found { - meta.Mode = val - } else if val, found := strings.CutPrefix(line, "Started: "); found { - // header timestamps are written in local time without a zone offset - if t, parseErr := time.ParseInLocation("2006-01-02 15:04:05", val, time.Local); parseErr == nil { - meta.StartTime = t - } - } + applyHeaderField(line, &meta) if readErr != nil { if !errors.Is(readErr, io.EOF) { return SessionMetadata{}, false, fmt.Errorf("read file: %w", readErr) } - break // EOF after processing final line + return meta, complete, nil // EOF before separator + } + } + + // scan the rest of the file for restart sections that may carry updated patterns. + // each restart marker resets the pattern slice; the last written set wins. + for { + line, readErr := reader.ReadString('\n') + line = trimLineEnding(line) + + if strings.HasPrefix(line, "--- restarted at ") { + meta.TaskHeaderPatterns = nil + } else if val, found := strings.CutPrefix(line, "TaskHeaderPattern: "); found { + meta.TaskHeaderPatterns = append(meta.TaskHeaderPatterns, val) + } + + if readErr != nil { + break } } return meta, complete, nil } +// applyHeaderField parses a single header line (e.g. "Plan: ...") and updates +// the metadata in place. unknown or unmatched lines are ignored. +func applyHeaderField(line string, meta *SessionMetadata) { + if val, found := strings.CutPrefix(line, "Plan: "); found { + meta.PlanPath = val + return + } + if val, found := strings.CutPrefix(line, "Branch: "); found { + meta.Branch = val + return + } + if val, found := strings.CutPrefix(line, "Mode: "); found { + meta.Mode = val + return + } + if val, found := strings.CutPrefix(line, "Started: "); found { + // header timestamps are written in local time without a zone offset + if t, parseErr := time.ParseInLocation("2006-01-02 15:04:05", val, time.Local); parseErr == nil { + meta.StartTime = t + } + return + } + if val, found := strings.CutPrefix(line, "TaskHeaderPattern: "); found { + meta.TaskHeaderPatterns = append(meta.TaskHeaderPatterns, val) + return + } +} + // loadProgressFileIntoSession reads a progress file and publishes events to the session's SSE server. // used for completed sessions that were discovered after they finished. // errors are silently ignored since this is best-effort loading. diff --git a/pkg/web/session_progress_test.go b/pkg/web/session_progress_test.go index 20250f3c..f196ebc7 100644 --- a/pkg/web/session_progress_test.go +++ b/pkg/web/session_progress_test.go @@ -85,6 +85,130 @@ Branch: main assert.Error(t, err) }) + t.Run("parses TaskHeaderPattern lines into slice", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "progress-test.txt") + + content := `# Ralphex Progress Log +Plan: docs/plans/my-plan.md +Branch: feature-branch +Mode: full +Started: 2026-01-22 10:30:00 +TaskHeaderPattern: default +TaskHeaderPattern: openspec +------------------------------------------------------------ +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + meta, complete, err := ParseProgressHeader(path) + require.NoError(t, err) + assert.True(t, complete) + assert.Equal(t, []string{"default", "openspec"}, meta.TaskHeaderPatterns) + assert.Equal(t, "docs/plans/my-plan.md", meta.PlanPath) + }) + + t.Run("picks up updated patterns from restart section", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "progress-restart.txt") + + content := `# Ralphex Progress Log +Plan: docs/plans/my-plan.md +Branch: feature-branch +Mode: full +Started: 2026-01-22 10:30:00 +TaskHeaderPattern: default +------------------------------------------------------------ +[26-01-22 10:30:01] some log line + +--- restarted at 2026-01-22 11:00:00 --- +TaskHeaderPattern: openspec +[26-01-22 11:00:01] second run log line +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + meta, complete, err := ParseProgressHeader(path) + require.NoError(t, err) + assert.True(t, complete) + assert.Equal(t, []string{"openspec"}, meta.TaskHeaderPatterns, "should use patterns from the latest restart section") + }) + + t.Run("picks up patterns from multiple restarts, last wins", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "progress-multi-restart.txt") + + content := `# Ralphex Progress Log +Plan: docs/plans/my-plan.md +Branch: feature-branch +Mode: full +Started: 2026-01-22 10:30:00 +TaskHeaderPattern: default +------------------------------------------------------------ +[26-01-22 10:30:01] first run log + +--- restarted at 2026-01-22 11:00:00 --- +TaskHeaderPattern: openspec +[26-01-22 11:00:01] second run log + +--- restarted at 2026-01-22 12:00:00 --- +TaskHeaderPattern: default +TaskHeaderPattern: openspec +[26-01-22 12:00:01] third run log +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + meta, complete, err := ParseProgressHeader(path) + require.NoError(t, err) + assert.True(t, complete) + assert.Equal(t, []string{"default", "openspec"}, meta.TaskHeaderPatterns, "should use patterns from the last restart section") + }) + + t.Run("no patterns in restart section falls back to empty", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "progress-no-restart-patterns.txt") + + content := `# Ralphex Progress Log +Plan: docs/plans/my-plan.md +Branch: feature-branch +Mode: full +Started: 2026-01-22 10:30:00 +TaskHeaderPattern: default +------------------------------------------------------------ +[26-01-22 10:30:01] some log line + +--- restarted at 2026-01-22 11:00:00 --- +[26-01-22 11:00:01] second run log +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + meta, complete, err := ParseProgressHeader(path) + require.NoError(t, err) + assert.True(t, complete) + assert.Empty(t, meta.TaskHeaderPatterns, "restart with no patterns should clear the slice (old file written without pattern re-emit)") + }) + + t.Run("ignores TaskHeaderPatterns line from old progress files", func(t *testing.T) { + // backward compat: old progress files may contain TaskHeaderPatterns: lines; + // ParseProgressHeader should parse other fields correctly and ignore the line. + dir := t.TempDir() + path := filepath.Join(dir, "progress-test.txt") + + content := `# Ralphex Progress Log +Plan: docs/plans/my-plan.md +Branch: feature-branch +Mode: full +Started: 2026-01-22 10:30:00 +TaskHeaderPatterns: ### Task {N}: {title},## {N}. {title} +------------------------------------------------------------ +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + meta, complete, err := ParseProgressHeader(path) + require.NoError(t, err) + assert.True(t, complete) + assert.Equal(t, "docs/plans/my-plan.md", meta.PlanPath) + assert.Equal(t, "feature-branch", meta.Branch) + }) + t.Run("reports incomplete when separator not yet written", func(t *testing.T) { // models a mid-write observation: header lines written but terminating // separator still pending. updateSession must not clobber previously