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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ docs/plans/ # plan files location
## Key Patterns

- Plan format: Checkboxes (`- [ ]` / `- [x]`) belong only in Task sections (`### Task N:` or `### Iteration N:`). The `Task` / `Iteration` keywords are structural tokens matched by `pkg/plan/parse.go` (`taskHeaderPattern`) and MUST stay in English even when plan content is written in another language — task titles and body text may be localized, but the section header keyword is fixed. Success criteria, Overview, and Context should not use checkboxes — they cause extra loop iterations. The task prompt handles them when present, but plan authors should avoid them.
- Plan file rename tolerance (issue #341): two layers protect against the task phase looping when a plan file is renamed mid-run. (a) `pkg/config/defaults/prompts/make_plan.txt` no longer asks the LLM to `git mv` the plan into `docs/plans/completed/` because the framework already calls `MovePlanToCompleted` at end-of-run idempotently using `r.cfg.PlanFile`'s exact basename, so the LLM never touches the plan path. (b) For in-flight plans generated by older templates and for users with customized prompts that still include the move checkbox, `pkg/processor/prompts.go:resolvePlanFilePath` and `pkg/git/service.go:MovePlanToCompleted` probe an alternate-date-format basename (`YYYY-MM-DD-<slug>` ↔ `YYYYMMDD-<slug>`) both alongside the original path (in-place rename) and under `completed/` (moved with rename); the in-place alternate is probed before the `completed/` paths so a current renamed file wins over a stale completed/ copy. `MovePlanToCompleted` additionally treats an alternate-named file in the original directory as the move source so it can complete with its current basename. `pkg/processor/runner.go:hasUncompletedTasks` treats `fs.ErrNotExist` from `ParsePlanFile` as "no uncompleted tasks" rather than the conservative "assume incomplete". Rationale: claude's `git mv` line in a generated plan used a different date format than the file's actual basename (e.g. `2026-05-12-extract-env-variable.md` → `20260512-extract-env-variable.md`), causing `resolvePlanFilePath` to miss, `hasUncompletedTasks` to return `true` on parse error, and every `ALL_TASKS_DONE` signal to be rejected until `MaxIterations`.
- Signal-based completion detection (COMPLETED, FAILED, REVIEW_DONE signals) — constants in `pkg/status/`
- Plan creation signals: QUESTION (with JSON payload) and PLAN_READY
- Streaming output with timestamps
Expand Down
174 changes: 174 additions & 0 deletions docs/plans/completed/20260512-resolve-renamed-plan.md

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion pkg/config/defaults/prompts/make_plan.txt
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,6 @@ Write the accepted plan to disk:

- [ ] update README.md if user-facing changes
- [ ] update CLAUDE.md if internal patterns changed
- [ ] move this plan to `docs/plans/completed/`
---

## Step 4.5: Validate Plan Before Draft
Expand Down
19 changes: 19 additions & 0 deletions pkg/config/prompts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -784,3 +784,22 @@ func TestPromptLoader_Load_CustomEvalPrompt_LocalOverridesGlobal(t *testing.T) {

assert.Equal(t, "local custom eval", prompts.CustomEval)
}

// tripwire regression test: the embedded make_plan.txt must not instruct claude to relocate
// the plan file at end of run. the framework's MovePlanToCompleted (pkg/git/service.go) already
// handles that move idempotently using the resolved plan-file basename, so any LLM-driven git mv
// can rename the file out from under the runtime and trigger the infinite warning loop described
// in umputun/ralphex#341. if a template change legitimately re-introduces an LLM-driven plan-file
// relocation, this test must be updated deliberately, not silently.
func TestMakePlanPrompt_NoLLMDrivenPlanMove(t *testing.T) {
loader := newPromptLoader(defaultsFS)
content, err := loader.loadPromptFromEmbedFS("defaults/prompts/make_plan.txt")
require.NoError(t, err)
require.NotEmpty(t, content)

forbidden := []string{"move this plan", "completed/", "mv ", "relocate"}
for _, sub := range forbidden {
assert.NotContains(t, content, sub,
"embedded make_plan.txt must not contain %q (would re-introduce LLM-driven plan-file relocation)", sub)
}
}
85 changes: 73 additions & 12 deletions pkg/git/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,28 +448,30 @@ func (s *Service) RemoveWorktree(path string) error {
// Creates the completed/ directory if it doesn't exist.
// Uses git mv if the file is tracked, falls back to os.Rename for untracked files.
// If the source file doesn't exist but the destination does, logs a message and returns nil.
// Also returns nil when the source is missing and a basename with the alternate date-prefix
// convention (YYYY-MM-DD ↔ YYYYMMDD) exists in completed/, treating an LLM-driven
// date-format rename as already-moved.
// When the source is missing but a file with the alternate date-prefix exists alongside it
// (in-place rename, e.g. git mv 2026-05-12-foo.md 20260512-foo.md), the renamed file is
// used as the source so the move can complete with its current name. If an alternate-date
// copy already exists at the destination (e.g. from a prior same-day run with the same slug),
// the move is skipped so neither file is clobbered.
func (s *Service) MovePlanToCompleted(planFile string) error {
// create completed directory
completedDir := filepath.Join(filepath.Dir(planFile), "completed")
if err := os.MkdirAll(completedDir, 0o750); err != nil {
return fmt.Errorf("create completed dir: %w", err)
}

// destination path
destPath := filepath.Join(completedDir, filepath.Base(planFile))

// check if already moved (source missing, dest exists)
if _, err := os.Stat(planFile); os.IsNotExist(err) {
if _, destErr := os.Stat(destPath); destErr == nil {
s.log.Printf("plan already in completed/\n")
return nil
}
sourceFile, destPath, done := s.resolvePlanMoveTargets(planFile, completedDir)
if done {
return nil
}

// use git mv
if err := s.repo.moveFile(planFile, destPath); err != nil {
if err := s.repo.moveFile(sourceFile, destPath); err != nil {
// fallback to regular move for untracked files
if renameErr := os.Rename(planFile, destPath); renameErr != nil {
if renameErr := os.Rename(sourceFile, destPath); renameErr != nil {
return fmt.Errorf("move plan: %w", renameErr)
}
// stage the new location - log if fails but continue
Expand All @@ -479,7 +481,7 @@ func (s *Service) MovePlanToCompleted(planFile string) error {
}

// commit the move
commitMsg := "move completed plan: " + filepath.Base(planFile)
commitMsg := "move completed plan: " + filepath.Base(sourceFile)
if err := s.repo.commit(s.appendTrailer(commitMsg)); err != nil {
return fmt.Errorf("commit plan move: %w", err)
}
Expand All @@ -488,6 +490,65 @@ func (s *Service) MovePlanToCompleted(planFile string) error {
return nil
}

// resolvePlanMoveTargets determines the source and destination for MovePlanToCompleted,
// accounting for files already moved to completed/ or renamed between the dashed
// (YYYY-MM-DD) and compact (YYYYMMDD) date-prefix conventions. Returns done=true in
// two cases: the file is already in completed/ (with either basename), or there is a
// collision between an active in-place rename and a stale completed/<altBase> copy
// that the move should not clobber.
// Probe order mirrors resolvePlanFilePath in pkg/processor/prompts.go: the in-place
// alternate source is checked before any completed/ probe so a current renamed file
// wins over a stale completed/ copy left from a prior run.
func (s *Service) resolvePlanMoveTargets(planFile, completedDir string) (sourceFile, destPath string, done bool) {
Comment on lines +493 to +502
destPath = filepath.Join(completedDir, filepath.Base(planFile))
sourceFile = planFile

if _, err := os.Stat(planFile); !os.IsNotExist(err) {
return sourceFile, destPath, false
}

altBase := plan.AltDateBasename(filepath.Base(planFile))

// file may have been renamed in place (same dir, alt basename) — use it as source.
// checked before completed/ probes so a current renamed file wins over a stale
// completed/<original> copy from a prior run.
if altBase != "" {
altSourcePath := filepath.Join(filepath.Dir(planFile), altBase)
if _, altSrcErr := os.Stat(altSourcePath); altSrcErr == nil {
altDestPath := filepath.Join(completedDir, altBase)
// collision: a stale completed/<altBase> exists alongside the active in-place
// renamed source (e.g. same slug ran twice on the same day). git mv would refuse
// because dest exists, and the os.Rename fallback would clobber the stale copy
// while leaving the source's deletion unstaged — repo ends up dirty or commit
// fails entirely. surface as already-completed instead and preserve both files
// for manual resolution.
if _, altDestErr := os.Stat(altDestPath); altDestErr == nil {
s.log.Printf("plan already in completed/ (renamed: %s); active copy at %s left in place for manual cleanup\n",
altBase, altSourcePath)
return altSourcePath, altDestPath, true
}
return altSourcePath, altDestPath, false
}
}

if _, destErr := os.Stat(destPath); destErr == nil {
s.log.Printf("plan already in completed/\n")
return sourceFile, destPath, true
}

if altBase == "" {
return sourceFile, destPath, false
}

altDestPath := filepath.Join(completedDir, altBase)
if _, altErr := os.Stat(altDestPath); altErr == nil {
s.log.Printf("plan already in completed/ (renamed: %s)\n", altBase)
return sourceFile, destPath, true
}

return sourceFile, destPath, false
}

// EnsureHasCommits checks that the repository has at least one commit.
// If the repository is empty, calls promptFn to ask user whether to create initial commit.
// promptFn should return true to create the commit, false to abort.
Expand Down
218 changes: 218 additions & 0 deletions pkg/git/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,223 @@ func TestService_MovePlanToCompleted(t *testing.T) {
require.Len(t, log.logs, 1)
assert.Contains(t, log.logs[0], "already in completed")
})

t.Run("returns nil if renamed to compact date in completed", func(t *testing.T) {
dir := setupExternalTestRepo(t)
log := &mockLogger{}
svc, err := NewService(dir, log)
require.NoError(t, err)

// simulate prior move that also renamed dashed → compact date prefix
plansDir := filepath.Join(dir, "docs", "plans")
completedDir := filepath.Join(plansDir, "completed")
require.NoError(t, os.MkdirAll(completedDir, 0o750))
completedPath := filepath.Join(completedDir, "20260512-foo.md")
require.NoError(t, os.WriteFile(completedPath, []byte("# Plan"), 0o600))

// caller still references the original dashed-format path
planFile := filepath.Join(plansDir, "2026-05-12-foo.md")
_, err = os.Stat(planFile)
require.True(t, os.IsNotExist(err))

err = svc.MovePlanToCompleted(planFile)
require.NoError(t, err)

require.Len(t, log.logs, 1)
assert.Contains(t, log.logs[0], "already in completed")
assert.Contains(t, log.logs[0], "renamed")
assert.Contains(t, log.logs[0], "20260512-foo.md")
})

t.Run("returns nil if renamed to dashed date in completed", func(t *testing.T) {
dir := setupExternalTestRepo(t)
log := &mockLogger{}
svc, err := NewService(dir, log)
require.NoError(t, err)

// simulate prior move that also renamed compact → dashed date prefix
plansDir := filepath.Join(dir, "docs", "plans")
completedDir := filepath.Join(plansDir, "completed")
require.NoError(t, os.MkdirAll(completedDir, 0o750))
completedPath := filepath.Join(completedDir, "2026-05-12-foo.md")
require.NoError(t, os.WriteFile(completedPath, []byte("# Plan"), 0o600))

// caller still references the original compact-format path
planFile := filepath.Join(plansDir, "20260512-foo.md")
_, err = os.Stat(planFile)
require.True(t, os.IsNotExist(err))

err = svc.MovePlanToCompleted(planFile)
require.NoError(t, err)

require.Len(t, log.logs, 1)
assert.Contains(t, log.logs[0], "already in completed")
assert.Contains(t, log.logs[0], "renamed")
assert.Contains(t, log.logs[0], "2026-05-12-foo.md")
})

t.Run("moves file renamed in place to compact date", func(t *testing.T) {
// caller references original dashed path, file was renamed in place to compact
// e.g. git mv docs/plans/2026-05-12-foo.md docs/plans/20260512-foo.md
dir := setupExternalTestRepo(t)
svc, err := NewService(dir, noopServiceLogger())
require.NoError(t, err)

plansDir := filepath.Join(dir, "docs", "plans")
require.NoError(t, os.MkdirAll(plansDir, 0o750))
renamedPath := filepath.Join(plansDir, "20260512-foo.md")
require.NoError(t, os.WriteFile(renamedPath, []byte("# Plan"), 0o600))
require.NoError(t, svc.repo.add(renamedPath))
require.NoError(t, svc.repo.commit("add plan with renamed basename"))

// caller still passes the original dashed path
planFile := filepath.Join(plansDir, "2026-05-12-foo.md")
_, err = os.Stat(planFile)
require.True(t, os.IsNotExist(err))

err = svc.MovePlanToCompleted(planFile)
require.NoError(t, err)

// renamed source should be gone
_, err = os.Stat(renamedPath)
assert.True(t, os.IsNotExist(err))

// destination uses the renamed basename
completedPath := filepath.Join(plansDir, "completed", "20260512-foo.md")
_, err = os.Stat(completedPath)
require.NoError(t, err)
})

t.Run("moves file renamed in place to dashed date", func(t *testing.T) {
// mirror: caller references compact, file renamed in place to dashed
dir := setupExternalTestRepo(t)
svc, err := NewService(dir, noopServiceLogger())
require.NoError(t, err)

plansDir := filepath.Join(dir, "docs", "plans")
require.NoError(t, os.MkdirAll(plansDir, 0o750))
renamedPath := filepath.Join(plansDir, "2026-05-12-foo.md")
require.NoError(t, os.WriteFile(renamedPath, []byte("# Plan"), 0o600))
require.NoError(t, svc.repo.add(renamedPath))
require.NoError(t, svc.repo.commit("add plan with renamed basename"))

planFile := filepath.Join(plansDir, "20260512-foo.md")
_, err = os.Stat(planFile)
require.True(t, os.IsNotExist(err))

err = svc.MovePlanToCompleted(planFile)
require.NoError(t, err)

_, err = os.Stat(renamedPath)
assert.True(t, os.IsNotExist(err))

completedPath := filepath.Join(plansDir, "completed", "2026-05-12-foo.md")
_, err = os.Stat(completedPath)
require.NoError(t, err)
})

t.Run("in-place rename wins over stale completed copy", func(t *testing.T) {
// caller passes original dashed path. file was renamed in place to compact AND a stale
// completed/<original-basename> exists from a prior run. the in-place rename is the
// active plan and must be moved; the stale completed/ copy must not short-circuit.
dir := setupExternalTestRepo(t)
svc, err := NewService(dir, noopServiceLogger())
require.NoError(t, err)

plansDir := filepath.Join(dir, "docs", "plans")
require.NoError(t, os.MkdirAll(plansDir, 0o750))

// active plan at compact basename (renamed in place)
renamedPath := filepath.Join(plansDir, "20260512-foo.md")
require.NoError(t, os.WriteFile(renamedPath, []byte("# Plan (current)"), 0o600))
require.NoError(t, svc.repo.add(renamedPath))
require.NoError(t, svc.repo.commit("add plan with renamed basename"))

// stale completed copy at original (dashed) basename from a prior run
completedDir := filepath.Join(plansDir, "completed")
require.NoError(t, os.MkdirAll(completedDir, 0o750))
stalePath := filepath.Join(completedDir, "2026-05-12-foo.md")
require.NoError(t, os.WriteFile(stalePath, []byte("# Plan (stale)"), 0o600))

// caller still references the original dashed path
planFile := filepath.Join(plansDir, "2026-05-12-foo.md")
_, err = os.Stat(planFile)
require.True(t, os.IsNotExist(err))

err = svc.MovePlanToCompleted(planFile)
require.NoError(t, err)

// renamed source should be gone (was moved, not abandoned)
_, err = os.Stat(renamedPath)
assert.True(t, os.IsNotExist(err), "active in-place renamed file should have been moved")

// destination uses the renamed basename
movedPath := filepath.Join(completedDir, "20260512-foo.md")
movedContent, err := os.ReadFile(movedPath) //nolint:gosec // test file
require.NoError(t, err)
assert.Equal(t, "# Plan (current)", string(movedContent), "moved file should contain current content")

// stale completed copy is left in place (not our responsibility to clean up)
staleContent, err := os.ReadFile(stalePath) //nolint:gosec // test file
require.NoError(t, err)
assert.Equal(t, "# Plan (stale)", string(staleContent))
})

t.Run("collision between in-place rename and stale completed/<altBase> is left untouched", func(t *testing.T) {
// caller passes original dashed path. file was renamed in place to compact AND a stale
// completed/<altBase> copy already exists with the same basename (e.g. same slug ran
// twice on the same day). git mv would refuse, os.Rename fallback would clobber the
// stale copy and leave the source's deletion unstaged. verify we surface this as
// already-completed and preserve both files for manual resolution.
dir := setupExternalTestRepo(t)
log := &mockLogger{}
svc, err := NewService(dir, log)
require.NoError(t, err)

plansDir := filepath.Join(dir, "docs", "plans")
require.NoError(t, os.MkdirAll(plansDir, 0o750))

// active plan at compact basename (renamed in place, tracked)
renamedPath := filepath.Join(plansDir, "20260512-foo.md")
require.NoError(t, os.WriteFile(renamedPath, []byte("# Plan (current)"), 0o600))
require.NoError(t, svc.repo.add(renamedPath))
require.NoError(t, svc.repo.commit("add plan with renamed basename"))

// stale completed copy at compact basename from a prior run with same slug+date
completedDir := filepath.Join(plansDir, "completed")
require.NoError(t, os.MkdirAll(completedDir, 0o750))
stalePath := filepath.Join(completedDir, "20260512-foo.md")
require.NoError(t, os.WriteFile(stalePath, []byte("# Plan (stale)"), 0o600))

// caller references the original dashed path
planFile := filepath.Join(plansDir, "2026-05-12-foo.md")
_, err = os.Stat(planFile)
require.True(t, os.IsNotExist(err))

err = svc.MovePlanToCompleted(planFile)
require.NoError(t, err)

// active source must be preserved (NOT clobbered, NOT moved)
activeContent, err := os.ReadFile(renamedPath) //nolint:gosec // test file
require.NoError(t, err)
assert.Equal(t, "# Plan (current)", string(activeContent), "active in-place renamed file must be preserved")

// stale completed copy must also be preserved (not overwritten)
staleContent, err := os.ReadFile(stalePath) //nolint:gosec // test file
require.NoError(t, err)
assert.Equal(t, "# Plan (stale)", string(staleContent), "stale completed copy must be preserved")

// repo must be clean — no dangling deletion of the active source
dirty, err := svc.repo.isDirty()
require.NoError(t, err)
assert.False(t, dirty, "repo must be clean after collision-skip")

// should have logged that the move was skipped due to the collision
require.Len(t, log.logs, 1)
assert.Contains(t, log.logs[0], "already in completed")
assert.Contains(t, log.logs[0], "20260512-foo.md")
assert.Contains(t, log.logs[0], "manual cleanup")
})
}

func TestService_EnsureHasCommits(t *testing.T) {
Expand Down Expand Up @@ -1540,3 +1757,4 @@ func TestService_resolveFilesystemCase(t *testing.T) {
})
}
}

Loading