From 3b451735d192020389c4e80ebe8c907119c2d76d Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 21 Mar 2026 10:30:30 -0700 Subject: [PATCH 1/3] feat(dev): fix usability bugs - dot worktree, unique task files, --agent/-a primary - dev agent . resolves to current worktree via find_worktree_by_name - write_prompt_to_worktree uses unique TASK-{ts}-{hex}.md filenames - _create_prompt_wrapper_script uses mkstemp for unique script paths - dev agent: --agent/-a is primary flag, --with-agent deprecated alias - Updated help text, docs, skill documentation - 7 new tests covering all fixes + race condition regression --- .claude-plugin/skills/agent-cli-dev/SKILL.md | 5 +- .../skills/agent-cli-dev/examples.md | 2 +- .claude/skills/agent-cli-dev/SKILL.md | 5 +- .claude/skills/agent-cli-dev/examples.md | 2 +- agent_cli/dev/cli.py | 35 ++++- agent_cli/dev/launch.py | 22 ++- agent_cli/dev/skill/SKILL.md | 5 +- agent_cli/dev/skill/examples.md | 2 +- agent_cli/dev/worktree.py | 23 ++- docs/commands/dev.md | 4 +- tests/dev/test_cli.py | 135 ++++++++++++++++++ tests/dev/test_launch.py | 84 ++++++++++- tests/dev/test_worktree.py | 82 +++++++++++ 13 files changed, 379 insertions(+), 27 deletions(-) diff --git a/.claude-plugin/skills/agent-cli-dev/SKILL.md b/.claude-plugin/skills/agent-cli-dev/SKILL.md index 85339eba..b175fc77 100644 --- a/.claude-plugin/skills/agent-cli-dev/SKILL.md +++ b/.claude-plugin/skills/agent-cli-dev/SKILL.md @@ -56,7 +56,7 @@ agent-cli dev new --from HEAD --agent --prompt-file path/to/prompt This creates: 1. A new git worktree with its own branch 2. Runs project setup (installs dependencies) -3. Saves your prompt to `.claude/TASK.md` in the worktree (for reference) +3. Saves your prompt to a unique task file in `.claude/` in the worktree (for reference) 4. Opens a new terminal tab with an AI coding agent 5. Passes your prompt to the agent @@ -144,11 +144,12 @@ agent-cli dev agent review-auth -m tmux --prompt-file .claude/review-tests.md Key rules for same-worktree launches: - Use `dev agent`, not `dev new`, after the worktree already exists +- Use `dev agent -a ` to select a specific agent for an existing worktree; `--with-agent` remains a deprecated alias on this subcommand - Use `-m tmux` for headless or scripted launching; it works even when not already inside tmux - Each launch joins the same deterministic repo-scoped tmux session, so related agents stay grouped together - Ask each agent to write to a unique report path such as `.claude/REPORT-security-.md` or `.claude/REPORT-tests-.md` - If you rerun the same prompt repeatedly, include a timestamp or other run id in the report filename so later runs do not overwrite earlier ones -- Do not rely on `.claude/TASK.md` as per-agent state in shared worktrees; later launches overwrite it +- Each agent launch gets its own unique task file in `.claude/` (e.g., `TASK-{timestamp}-{hex}.md`), so parallel launches do not overwrite each other ### Prompt guidance for shared worktrees diff --git a/.claude-plugin/skills/agent-cli-dev/examples.md b/.claude-plugin/skills/agent-cli-dev/examples.md index dfcb22a1..505767f4 100644 --- a/.claude-plugin/skills/agent-cli-dev/examples.md +++ b/.claude-plugin/skills/agent-cli-dev/examples.md @@ -585,7 +585,7 @@ Write findings to .claude/REPORT-tests-$run_id.md: - If you rerun the same prompt often, include a timestamp or run id in the filename so reports do not get replaced - `-m tmux` works even when the caller is not already inside tmux - All three agents land in the same deterministic tmux session for that repo -- `.claude/TASK.md` is shared state and may be overwritten by later launches, so keep prompt files outside that convention +- Each agent launch gets its own unique task file in `.claude/`, so parallel launches do not conflict ## Scenario 7: Parallel test validation diff --git a/.claude/skills/agent-cli-dev/SKILL.md b/.claude/skills/agent-cli-dev/SKILL.md index 85339eba..b175fc77 100644 --- a/.claude/skills/agent-cli-dev/SKILL.md +++ b/.claude/skills/agent-cli-dev/SKILL.md @@ -56,7 +56,7 @@ agent-cli dev new --from HEAD --agent --prompt-file path/to/prompt This creates: 1. A new git worktree with its own branch 2. Runs project setup (installs dependencies) -3. Saves your prompt to `.claude/TASK.md` in the worktree (for reference) +3. Saves your prompt to a unique task file in `.claude/` in the worktree (for reference) 4. Opens a new terminal tab with an AI coding agent 5. Passes your prompt to the agent @@ -144,11 +144,12 @@ agent-cli dev agent review-auth -m tmux --prompt-file .claude/review-tests.md Key rules for same-worktree launches: - Use `dev agent`, not `dev new`, after the worktree already exists +- Use `dev agent -a ` to select a specific agent for an existing worktree; `--with-agent` remains a deprecated alias on this subcommand - Use `-m tmux` for headless or scripted launching; it works even when not already inside tmux - Each launch joins the same deterministic repo-scoped tmux session, so related agents stay grouped together - Ask each agent to write to a unique report path such as `.claude/REPORT-security-.md` or `.claude/REPORT-tests-.md` - If you rerun the same prompt repeatedly, include a timestamp or other run id in the report filename so later runs do not overwrite earlier ones -- Do not rely on `.claude/TASK.md` as per-agent state in shared worktrees; later launches overwrite it +- Each agent launch gets its own unique task file in `.claude/` (e.g., `TASK-{timestamp}-{hex}.md`), so parallel launches do not overwrite each other ### Prompt guidance for shared worktrees diff --git a/.claude/skills/agent-cli-dev/examples.md b/.claude/skills/agent-cli-dev/examples.md index dfcb22a1..505767f4 100644 --- a/.claude/skills/agent-cli-dev/examples.md +++ b/.claude/skills/agent-cli-dev/examples.md @@ -585,7 +585,7 @@ Write findings to .claude/REPORT-tests-$run_id.md: - If you rerun the same prompt often, include a timestamp or run id in the filename so reports do not get replaced - `-m tmux` works even when the caller is not already inside tmux - All three agents land in the same deterministic tmux session for that repo -- `.claude/TASK.md` is shared state and may be overwritten by later launches, so keep prompt files outside that convention +- Each agent launch gets its own unique task file in `.claude/`, so parallel launches do not conflict ## Scenario 7: Parallel test validation diff --git a/agent_cli/dev/cli.py b/agent_cli/dev/cli.py index bf0288a8..12577be0 100644 --- a/agent_cli/dev/cli.py +++ b/agent_cli/dev/cli.py @@ -373,7 +373,7 @@ def new( typer.Option( "--prompt", "-p", - help="Initial task for the AI agent. Saved to .claude/TASK.md. Implies --agent. Example: --prompt='Fix the login bug'", + help="Initial task for the AI agent. Saved to a unique file in .claude/ to avoid conflicts. Implies --agent. Example: --prompt='Fix the login bug'", ), ] = None, prompt_file: Annotated[ @@ -776,7 +776,9 @@ def status_cmd( # noqa: PLR0915 def remove( name: Annotated[ str, - typer.Argument(help="Worktree to remove. Can be branch name or directory name"), + typer.Argument( + help="Worktree to remove. Can be branch name, directory name, or '.' for current" + ), ], force: Annotated[ bool, @@ -840,7 +842,9 @@ def remove( def path_cmd( name: Annotated[ str, - typer.Argument(help="Worktree to get path for. Can be branch name or directory name"), + typer.Argument( + help="Worktree to get path for. Can be branch name, directory name, or '.' for current" + ), ], ) -> None: """Print the absolute path to a dev environment. @@ -862,7 +866,9 @@ def path_cmd( def open_editor( name: Annotated[ str, - typer.Argument(help="Worktree to open. Can be branch name or directory name"), + typer.Argument( + help="Worktree to open. Can be branch name, directory name, or '.' for current" + ), ], editor_name: Annotated[ str | None, @@ -911,7 +917,7 @@ def start_agent( name: Annotated[ str, typer.Argument( - help="Worktree to start the agent in. Can be branch name or directory name", + help="Worktree to start the agent in. Can be branch name, directory name, or '.' for current", ), ], agent_name: Annotated[ @@ -922,6 +928,14 @@ def start_agent( help="Which agent: claude, codex, gemini, aider, copilot, cn, opencode, cursor-agent. Auto-detects if omitted", ), ] = None, + agent_name_deprecated: Annotated[ + str | None, + typer.Option( + "--with-agent", + hidden=True, + help="[Deprecated: use --agent/-a] Which agent to start", + ), + ] = None, agent_args: Annotated[ list[str] | None, typer.Option( @@ -934,7 +948,7 @@ def start_agent( typer.Option( "--prompt", "-p", - help="Initial task for the agent. Saved to .claude/TASK.md. Example: --prompt='Add unit tests for auth'", + help="Initial task for the agent. Saved to a unique file in .claude/ to avoid conflicts. Example: --prompt='Add unit tests for auth'", ), ] = None, prompt_file: Annotated[ @@ -975,6 +989,11 @@ def start_agent( - `dev agent my-feature -a claude` — Start Claude specifically - `dev agent my-feature -p "Continue the auth refactor"` — Start with a task """ + # Handle deprecated --with-agent alias + if agent_name_deprecated is not None: + warn("--with-agent is deprecated for 'dev agent', use --agent/-a instead") + agent_name = agent_name or agent_name_deprecated + prompt = _resolve_prompt_text(prompt, prompt_file=prompt_file) repo_root = _ensure_git_repo() @@ -1233,7 +1252,9 @@ def _doctor_check_git() -> None: def run_cmd( name: Annotated[ str, - typer.Argument(help="Worktree to run command in. Can be branch name or directory name"), + typer.Argument( + help="Worktree to run command in. Can be branch name, directory name, or '.' for current" + ), ], command: Annotated[ list[str], diff --git a/agent_cli/dev/launch.py b/agent_cli/dev/launch.py index 6b97876e..1dcd8b96 100644 --- a/agent_cli/dev/launch.py +++ b/agent_cli/dev/launch.py @@ -168,14 +168,18 @@ def launch_editor(path: Path, editor: Editor) -> None: def write_prompt_to_worktree(worktree_path: Path, prompt: str) -> Path: - """Write the prompt to .claude/TASK.md in the worktree. + """Write the prompt to a unique file in .claude/ in the worktree. - This makes the task description available to the spawned agent - and provides a record of what was requested. + Uses a timestamp and random suffix to avoid overwrites when multiple + agents are launched in parallel on the same worktree. """ + import time # noqa: PLC0415 + claude_dir = worktree_path / ".claude" claude_dir.mkdir(parents=True, exist_ok=True) - task_file = claude_dir / "TASK.md" + timestamp = int(time.time()) + suffix = os.urandom(2).hex() + task_file = claude_dir / f"TASK-{timestamp}-{suffix}.md" task_file.write_text(prompt + "\n") return task_file @@ -201,8 +205,6 @@ def _create_prompt_wrapper_script( env: dict[str, str] | None = None, ) -> Path: """Create a wrapper script that reads prompt from file to avoid shell quoting issues.""" - script_path = Path(tempfile.gettempdir()) / f"agent-cli-{worktree_path.name}.sh" - # Build the agent command without the prompt exe = agent.get_executable() if exe is None: @@ -222,7 +224,13 @@ def _create_prompt_wrapper_script( # Reads prompt from file to avoid shell parsing issues with special characters {env_prefix}exec {agent_cmd} "$(cat {shlex.quote(str(task_file_rel))})" """ - script_path.write_text(script_content) + fd, script_path_str = tempfile.mkstemp( + prefix=f"agent-cli-{worktree_path.name}-", + suffix=".sh", + ) + os.write(fd, script_content.encode()) + os.close(fd) + script_path = Path(script_path_str) script_path.chmod(0o755) return script_path diff --git a/agent_cli/dev/skill/SKILL.md b/agent_cli/dev/skill/SKILL.md index 85339eba..b175fc77 100644 --- a/agent_cli/dev/skill/SKILL.md +++ b/agent_cli/dev/skill/SKILL.md @@ -56,7 +56,7 @@ agent-cli dev new --from HEAD --agent --prompt-file path/to/prompt This creates: 1. A new git worktree with its own branch 2. Runs project setup (installs dependencies) -3. Saves your prompt to `.claude/TASK.md` in the worktree (for reference) +3. Saves your prompt to a unique task file in `.claude/` in the worktree (for reference) 4. Opens a new terminal tab with an AI coding agent 5. Passes your prompt to the agent @@ -144,11 +144,12 @@ agent-cli dev agent review-auth -m tmux --prompt-file .claude/review-tests.md Key rules for same-worktree launches: - Use `dev agent`, not `dev new`, after the worktree already exists +- Use `dev agent -a ` to select a specific agent for an existing worktree; `--with-agent` remains a deprecated alias on this subcommand - Use `-m tmux` for headless or scripted launching; it works even when not already inside tmux - Each launch joins the same deterministic repo-scoped tmux session, so related agents stay grouped together - Ask each agent to write to a unique report path such as `.claude/REPORT-security-.md` or `.claude/REPORT-tests-.md` - If you rerun the same prompt repeatedly, include a timestamp or other run id in the report filename so later runs do not overwrite earlier ones -- Do not rely on `.claude/TASK.md` as per-agent state in shared worktrees; later launches overwrite it +- Each agent launch gets its own unique task file in `.claude/` (e.g., `TASK-{timestamp}-{hex}.md`), so parallel launches do not overwrite each other ### Prompt guidance for shared worktrees diff --git a/agent_cli/dev/skill/examples.md b/agent_cli/dev/skill/examples.md index dfcb22a1..505767f4 100644 --- a/agent_cli/dev/skill/examples.md +++ b/agent_cli/dev/skill/examples.md @@ -585,7 +585,7 @@ Write findings to .claude/REPORT-tests-$run_id.md: - If you rerun the same prompt often, include a timestamp or run id in the filename so reports do not get replaced - `-m tmux` works even when the caller is not already inside tmux - All three agents land in the same deterministic tmux session for that repo -- `.claude/TASK.md` is shared state and may be overwritten by later launches, so keep prompt files outside that convention +- Each agent launch gets its own unique task file in `.claude/`, so parallel launches do not conflict ## Scenario 7: Parallel test validation diff --git a/agent_cli/dev/worktree.py b/agent_cli/dev/worktree.py index 306c6216..5ace2fe6 100644 --- a/agent_cli/dev/worktree.py +++ b/agent_cli/dev/worktree.py @@ -285,12 +285,33 @@ def resolve_worktree_base_dir(repo_root: Path) -> Path: return repo_root.parent / f"{repo_root.name}-worktrees" +def _find_worktree_for_cwd(worktrees: list[WorktreeInfo]) -> WorktreeInfo | None: + """Find the worktree containing the current working directory.""" + cwd = Path.cwd().resolve() + for wt in worktrees: + try: + cwd.relative_to(wt.path.resolve()) + return wt + except ValueError: + continue + # Fallback: return main worktree + return next((wt for wt in worktrees if wt.is_main), None) + + def find_worktree_by_name( name: str, repo_path: Path | None = None, ) -> WorktreeInfo | None: - """Find a worktree by branch name or directory name.""" + """Find a worktree by branch name or directory name. + + Use '.' to match the worktree containing the current working directory, + or the main worktree if the CWD is not inside any worktree. + """ worktrees = list_worktrees(repo_path) + + if name == ".": + return _find_worktree_for_cwd(worktrees) + sanitized = sanitize_branch_name(name) for wt in worktrees: diff --git a/docs/commands/dev.md b/docs/commands/dev.md index f6a88bc4..d66b93f3 100644 --- a/docs/commands/dev.md +++ b/docs/commands/dev.md @@ -84,7 +84,7 @@ agent-cli dev new [BRANCH] [OPTIONS] | `--branch-name-timeout` | `20.0` | Timeout in seconds for AI branch naming command | | `--direnv/--no-direnv` | - | Generate .envrc based on project type and run 'direnv allow'. Auto-enabled if direnv is installed | | `--agent-args` | - | Extra CLI args for the agent. Can be repeated. Example: --agent-args='--dangerously-skip-permissions' | -| `--prompt, -p` | - | Initial task for the AI agent. Saved to .claude/TASK.md. Implies --agent. Example: --prompt='Fix the login bug' | +| `--prompt, -p` | - | Initial task for the AI agent. Saved to a unique file in .claude/ to avoid conflicts. Implies --agent. Example: --prompt='Fix the login bug' | | `--prompt-file, -P` | - | Read the agent prompt from a file. Useful for long prompts to avoid shell quoting. Implies --agent | | `--multiplexer, -m` | - | Launch the agent in a specific multiplexer. Currently supported: tmux. When started outside tmux, creates or reuses a detached session and reports the pane handle | | `--hooks/--no-hooks` | `true` | Run built-in agent preparation (like Codex auto-trust) and configured pre-launch hooks before starting the agent | @@ -293,7 +293,7 @@ agent-cli dev agent NAME [--agent/-a AGENT] [--agent-args ARGS] [--prompt/-p PRO |--------|---------|-------------| | `--agent, -a` | - | Which agent: claude, codex, gemini, aider, copilot, cn, opencode, cursor-agent. Auto-detects if omitted | | `--agent-args` | - | Extra CLI args for the agent. Example: --agent-args='--dangerously-skip-permissions' | -| `--prompt, -p` | - | Initial task for the agent. Saved to .claude/TASK.md. Example: --prompt='Add unit tests for auth' | +| `--prompt, -p` | - | Initial task for the agent. Saved to a unique file in .claude/ to avoid conflicts. Example: --prompt='Add unit tests for auth' | | `--prompt-file, -P` | - | Read the agent prompt from a file instead of command line | | `--multiplexer, -m` | - | Launch the agent in a specific multiplexer instead of the current terminal. Currently supported: tmux | | `--hooks/--no-hooks` | `true` | Run built-in agent preparation (like Codex auto-trust) and configured pre-launch hooks before starting the agent | diff --git a/tests/dev/test_cli.py b/tests/dev/test_cli.py index aba705c9..6b05913b 100644 --- a/tests/dev/test_cli.py +++ b/tests/dev/test_cli.py @@ -854,6 +854,141 @@ def test_agent_rejects_empty_prompt_file(self, tmp_path: Path) -> None: assert f"Prompt file is empty: {prompt_file}" in result.output mock_ensure_repo.assert_not_called() + def test_agent_with_agent_flag(self) -> None: + """`dev agent foo -a claude` selects the named agent.""" + wt = WorktreeInfo( + path=Path("/repo-worktrees/feature"), + branch="feature", + commit="abc", + is_main=False, + is_detached=False, + is_locked=False, + is_prunable=False, + ) + + with ( + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=wt), + patch("agent_cli.dev.cli.coding_agents.get_agent") as mock_get_agent, + patch("agent_cli.dev.cli.prepare_agent_launch"), + patch("agent_cli.dev.cli.merge_agent_args", return_value=None), + patch("agent_cli.dev.cli.get_agent_env", return_value={}), + patch("agent_cli.dev.cli.os.chdir"), + patch("agent_cli.dev.cli.subprocess.run"), + ): + mock_agent = mock_get_agent.return_value + mock_agent.name = "claude" + mock_agent.is_available.return_value = True + mock_agent.launch_command.return_value = ["claude"] + result = runner.invoke( + app, + ["dev", "agent", "feature", "-a", "claude"], + ) + + assert result.exit_code == 0 + mock_get_agent.assert_called_once_with("claude") + + def test_agent_deprecated_flag_warns(self) -> None: + """`dev agent foo --with-agent claude` works but prints a deprecation warning.""" + wt = WorktreeInfo( + path=Path("/repo-worktrees/feature"), + branch="feature", + commit="abc", + is_main=False, + is_detached=False, + is_locked=False, + is_prunable=False, + ) + + with ( + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=wt), + patch("agent_cli.dev.cli.coding_agents.get_agent") as mock_get_agent, + patch("agent_cli.dev.cli.prepare_agent_launch"), + patch("agent_cli.dev.cli.merge_agent_args", return_value=None), + patch("agent_cli.dev.cli.get_agent_env", return_value={}), + patch("agent_cli.dev.cli.os.chdir"), + patch("agent_cli.dev.cli.subprocess.run"), + ): + mock_agent = mock_get_agent.return_value + mock_agent.name = "claude" + mock_agent.is_available.return_value = True + mock_agent.launch_command.return_value = ["claude"] + result = runner.invoke( + app, + ["dev", "agent", "feature", "--with-agent", "claude"], + ) + + assert result.exit_code == 0 + assert "deprecated" in result.output.lower() + mock_get_agent.assert_called_once_with("claude") + + def test_agent_dot_resolves_current_worktree(self) -> None: + """`dev agent .` resolves '.' to the current worktree via find_worktree_by_name.""" + wt = WorktreeInfo( + path=Path("/repo-worktrees/feature"), + branch="feature", + commit="abc", + is_main=False, + is_detached=False, + is_locked=False, + is_prunable=False, + ) + + with ( + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=wt) as mock_find, + patch("agent_cli.dev.cli.coding_agents.detect_current_agent") as mock_detect_current, + patch("agent_cli.dev.cli.prepare_agent_launch"), + patch("agent_cli.dev.cli.merge_agent_args", return_value=None), + patch("agent_cli.dev.cli.get_agent_env", return_value={}), + patch("agent_cli.dev.cli.os.chdir"), + patch("agent_cli.dev.cli.subprocess.run"), + ): + current_agent = mock_detect_current.return_value + current_agent.name = "claude" + current_agent.is_available.return_value = True + current_agent.launch_command.return_value = ["claude"] + result = runner.invoke(app, ["dev", "agent", "."]) + + assert result.exit_code == 0 + mock_find.assert_called_once_with(".", Path("/repo")) + + def test_agent_primary_flag_overrides_deprecated_alias(self) -> None: + """Primary -a/--agent takes precedence over deprecated --with-agent.""" + wt = WorktreeInfo( + path=Path("/repo-worktrees/feature"), + branch="feature", + commit="abc", + is_main=False, + is_detached=False, + is_locked=False, + is_prunable=False, + ) + + with ( + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=wt), + patch("agent_cli.dev.cli.coding_agents.get_agent") as mock_get_agent, + patch("agent_cli.dev.cli.prepare_agent_launch"), + patch("agent_cli.dev.cli.merge_agent_args", return_value=None), + patch("agent_cli.dev.cli.get_agent_env", return_value={}), + patch("agent_cli.dev.cli.os.chdir"), + patch("agent_cli.dev.cli.subprocess.run"), + ): + mock_agent = mock_get_agent.return_value + mock_agent.name = "claude" + mock_agent.is_available.return_value = True + mock_agent.launch_command.return_value = ["claude"] + result = runner.invoke( + app, + ["dev", "agent", "feature", "--with-agent", "claude", "-a", "codex"], + ) + + assert result.exit_code == 0 + assert "deprecated" in result.output.lower() + mock_get_agent.assert_called_once_with("codex") + class TestDevAgents: """Tests for dev agents command.""" diff --git a/tests/dev/test_launch.py b/tests/dev/test_launch.py index 20d8abba..14869ee7 100644 --- a/tests/dev/test_launch.py +++ b/tests/dev/test_launch.py @@ -6,7 +6,11 @@ from pathlib import Path from unittest.mock import MagicMock, patch -from agent_cli.dev.launch import launch_agent +from agent_cli.dev.launch import ( + _create_prompt_wrapper_script, + launch_agent, + write_prompt_to_worktree, +) from agent_cli.dev.terminals import TerminalHandle from agent_cli.dev.terminals.tmux import Tmux @@ -147,3 +151,81 @@ def test_requested_tmux_unavailable_falls_back_to_manual_instructions( printed = "\n".join(call.args[0] for call in mock_print.call_args_list if call.args) assert "To start codex:" in printed assert f"cd {tmp_path}" in printed + + +class TestCreatePromptWrapperScript: + """Tests for _create_prompt_wrapper_script unique path generation.""" + + def test_concurrent_launches_produce_unique_scripts(self, tmp_path: Path) -> None: + """Two calls for the same worktree produce different script paths. + + This is a regression test for a race condition where concurrent launches + overwrote each other's wrapper script because the path was deterministic + (based only on worktree name). + """ + task_file_a = tmp_path / ".claude" / "TASK-111-aaaa.md" + task_file_b = tmp_path / ".claude" / "TASK-222-bbbb.md" + task_file_a.parent.mkdir(parents=True) + task_file_a.write_text("task A\n") + task_file_b.write_text("task B\n") + + agent = MagicMock() + agent.name = "claude" + agent.get_executable.return_value = "/usr/bin/claude" + + path_a = _create_prompt_wrapper_script(tmp_path, agent, task_file_a) + path_b = _create_prompt_wrapper_script(tmp_path, agent, task_file_b) + + assert path_a != path_b + assert "TASK-111-aaaa.md" in path_a.read_text() + assert "TASK-222-bbbb.md" in path_b.read_text() + + def test_script_is_executable(self, tmp_path: Path) -> None: + """Generated wrapper script has execute permission.""" + task_file = tmp_path / ".claude" / "TASK-111-aaaa.md" + task_file.parent.mkdir(parents=True) + task_file.write_text("task\n") + + agent = MagicMock() + agent.name = "claude" + agent.get_executable.return_value = "/usr/bin/claude" + + path = _create_prompt_wrapper_script(tmp_path, agent, task_file) + assert path.stat().st_mode & 0o755 + + +class TestWritePromptToWorktree: + """Tests for write_prompt_to_worktree unique filename generation.""" + + def test_unique_task_filenames(self, tmp_path: Path) -> None: + """Two calls produce different filenames to avoid parallel overwrites.""" + path1 = write_prompt_to_worktree(tmp_path, "task A") + path2 = write_prompt_to_worktree(tmp_path, "task B") + assert path1 != path2 + assert path1.read_text() == "task A\n" + assert path2.read_text() == "task B\n" + + def test_task_file_in_claude_dir(self, tmp_path: Path) -> None: + """File is created inside the .claude/ directory.""" + path = write_prompt_to_worktree(tmp_path, "hello") + assert path.parent == tmp_path / ".claude" + + def test_task_filename_pattern(self, tmp_path: Path) -> None: + """Filename matches TASK-{timestamp}-{hex}.md pattern.""" + import re # noqa: PLC0415 + + path = write_prompt_to_worktree(tmp_path, "hello") + assert re.match(r"TASK-\d+-[0-9a-f]{4}\.md$", path.name) + + def test_task_file_contains_prompt(self, tmp_path: Path) -> None: + """Written file contains the prompt text with trailing newline.""" + path = write_prompt_to_worktree(tmp_path, "Fix the login bug") + assert path.read_text() == "Fix the login bug\n" + + def test_creates_claude_dir_if_missing(self, tmp_path: Path) -> None: + """Creates .claude/ directory if it doesn't exist.""" + worktree = tmp_path / "fresh-worktree" + worktree.mkdir() + path = write_prompt_to_worktree(worktree, "task") + assert path.exists() + assert (worktree / ".claude").is_dir() diff --git a/tests/dev/test_worktree.py b/tests/dev/test_worktree.py index bcdd9fe7..d7952e81 100644 --- a/tests/dev/test_worktree.py +++ b/tests/dev/test_worktree.py @@ -304,6 +304,88 @@ def test_not_found(self) -> None: assert result is None + def test_dot_returns_worktree_containing_cwd(self, tmp_path: Path) -> None: + """'.' returns the worktree whose path contains the current working directory.""" + wt_path = tmp_path / "worktrees" / "feature" + wt_path.mkdir(parents=True) + worktrees = [ + WorktreeInfo( + path=tmp_path / "main-repo", + branch="main", + commit="abc", + is_main=True, + is_detached=False, + is_locked=False, + is_prunable=False, + ), + WorktreeInfo( + path=wt_path, + branch="feature", + commit="def", + is_main=False, + is_detached=False, + is_locked=False, + is_prunable=False, + ), + ] + + with ( + patch("agent_cli.dev.worktree.list_worktrees", return_value=worktrees), + patch("pathlib.Path.cwd", return_value=wt_path / "src"), + ): + result = find_worktree_by_name(".", Path("/repo")) + + assert result is not None + assert result.branch == "feature" + + def test_dot_returns_main_when_in_main_repo(self, tmp_path: Path) -> None: + """'.' returns the main worktree when CWD is inside the main repo.""" + main_path = tmp_path / "main-repo" + main_path.mkdir(parents=True) + worktrees = [ + WorktreeInfo( + path=main_path, + branch="main", + commit="abc", + is_main=True, + is_detached=False, + is_locked=False, + is_prunable=False, + ), + ] + + with ( + patch("agent_cli.dev.worktree.list_worktrees", return_value=worktrees), + patch("pathlib.Path.cwd", return_value=main_path), + ): + result = find_worktree_by_name(".", Path("/repo")) + + assert result is not None + assert result.is_main is True + + def test_dot_falls_back_to_main_when_outside_worktrees(self) -> None: + """'.' returns the main worktree when CWD doesn't match any worktree.""" + worktrees = [ + WorktreeInfo( + path=Path("/some/repo"), + branch="main", + commit="abc", + is_main=True, + is_detached=False, + is_locked=False, + is_prunable=False, + ), + ] + + with ( + patch("agent_cli.dev.worktree.list_worktrees", return_value=worktrees), + patch("pathlib.Path.cwd", return_value=Path("/completely/elsewhere")), + ): + result = find_worktree_by_name(".", Path("/repo")) + + assert result is not None + assert result.is_main is True + class TestWorktreeInfo: """Tests for WorktreeInfo dataclass.""" From 3f0fd0afb5d2e49804cd326da19838e6aa946ff8 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 23 Mar 2026 13:20:13 -0700 Subject: [PATCH 2/3] fix(dev): prefer nested current-worktree matches --- agent_cli/dev/worktree.py | 8 +++++++- docs/commands/dev.md | 2 +- tests/dev/test_worktree.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/agent_cli/dev/worktree.py b/agent_cli/dev/worktree.py index 5ace2fe6..67d14650 100644 --- a/agent_cli/dev/worktree.py +++ b/agent_cli/dev/worktree.py @@ -288,7 +288,13 @@ def resolve_worktree_base_dir(repo_root: Path) -> Path: def _find_worktree_for_cwd(worktrees: list[WorktreeInfo]) -> WorktreeInfo | None: """Find the worktree containing the current working directory.""" cwd = Path.cwd().resolve() - for wt in worktrees: + # Prefer the deepest matching path so nested layouts like .worktrees/ + # resolve to the actual worktree instead of the main repo. + for wt in sorted( + worktrees, + key=lambda worktree: len(worktree.path.resolve().parts), + reverse=True, + ): try: cwd.relative_to(wt.path.resolve()) return wt diff --git a/docs/commands/dev.md b/docs/commands/dev.md index d66b93f3..a70d806e 100644 --- a/docs/commands/dev.md +++ b/docs/commands/dev.md @@ -775,7 +775,7 @@ for section in 1 2 3 4; do done ``` -If multiple agents share one worktree, do not have them all write to `.claude/REPORT.md` because they will overwrite each other. Instead, assign unique report paths such as `.claude/REPORT-security-.md` and `.claude/REPORT-tests-.md`. If you rerun the same prompt repeatedly, use a timestamp or other run id so later runs do not replace earlier results. The same applies to `.claude/TASK.md`: it reflects the most recent launch, not stable per-agent state. +If multiple agents share one worktree, do not have them all write to `.claude/REPORT.md` because they will overwrite each other. Instead, assign unique report paths such as `.claude/REPORT-security-.md` and `.claude/REPORT-tests-.md`. If you rerun the same prompt repeatedly, use a timestamp or other run id so later runs do not replace earlier results. Each agent launch also gets its own `.claude/TASK-{timestamp}-{hex}.md` file, so prompt files no longer overwrite each other. ## Shell Integration diff --git a/tests/dev/test_worktree.py b/tests/dev/test_worktree.py index d7952e81..2ec724e7 100644 --- a/tests/dev/test_worktree.py +++ b/tests/dev/test_worktree.py @@ -338,6 +338,41 @@ def test_dot_returns_worktree_containing_cwd(self, tmp_path: Path) -> None: assert result is not None assert result.branch == "feature" + def test_dot_prefers_nested_worktree_over_main_repo(self, tmp_path: Path) -> None: + """'.' prefers the most specific matching worktree path.""" + repo_root = tmp_path / "repo" + wt_path = repo_root / ".worktrees" / "feature" + wt_path.mkdir(parents=True) + worktrees = [ + WorktreeInfo( + path=repo_root, + branch="main", + commit="abc", + is_main=True, + is_detached=False, + is_locked=False, + is_prunable=False, + ), + WorktreeInfo( + path=wt_path, + branch="feature", + commit="def", + is_main=False, + is_detached=False, + is_locked=False, + is_prunable=False, + ), + ] + + with ( + patch("agent_cli.dev.worktree.list_worktrees", return_value=worktrees), + patch("pathlib.Path.cwd", return_value=wt_path / "src"), + ): + result = find_worktree_by_name(".", repo_root) + + assert result is not None + assert result.branch == "feature" + def test_dot_returns_main_when_in_main_repo(self, tmp_path: Path) -> None: """'.' returns the main worktree when CWD is inside the main repo.""" main_path = tmp_path / "main-repo" From de50b1876f956acc36db91148857e16bd8c1989a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Mar 2026 20:22:50 +0000 Subject: [PATCH 3/3] Update auto-generated docs --- docs/commands/dev.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/commands/dev.md b/docs/commands/dev.md index a70d806e..7d6a2488 100644 --- a/docs/commands/dev.md +++ b/docs/commands/dev.md @@ -292,6 +292,7 @@ agent-cli dev agent NAME [--agent/-a AGENT] [--agent-args ARGS] [--prompt/-p PRO | Option | Default | Description | |--------|---------|-------------| | `--agent, -a` | - | Which agent: claude, codex, gemini, aider, copilot, cn, opencode, cursor-agent. Auto-detects if omitted | +| `--with-agent` | - | [Deprecated: use --agent/-a] Which agent to start | | `--agent-args` | - | Extra CLI args for the agent. Example: --agent-args='--dangerously-skip-permissions' | | `--prompt, -p` | - | Initial task for the agent. Saved to a unique file in .claude/ to avoid conflicts. Example: --prompt='Add unit tests for auth' | | `--prompt-file, -P` | - | Read the agent prompt from a file instead of command line |