diff --git a/README.md b/README.md index e82c8dc..81befae 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,27 @@ The main entry point is: The plugin deliberately keeps Claude as the implementation orchestrator and uses fresh, read-only Codex executions for independent planning and review. It does not push, merge, deploy, rotate credentials, alter remote infrastructure, or apply irreversible production migrations. +### Three entry-point workflows + +| Slash command | Worktree | Branch | When to use | +|---|---|---|---| +| `/autonomous-development:autonomous-feature` | Isolated `.claude/worktrees/*` (default safe) | New worktree branch | Default. Edits land in a disposable worktree; your checkout is untouched. | +| `/autonomous-development:autonomous-current` | Current checkout | Your already-created non-`main`/`master` feature branch | You created and switched to a clean feature branch yourself and want changes to land there. | +| `/autonomous-development:autonomous-main` | Current checkout | `main` / `master` (explicit opt-in via `--allow-main`) | You explicitly want direct edits on `main`/`master`. Still requires a clean tree. | + +Both current-checkout workflows are opt-in. They do not create `.claude/worktrees/*`, do not enter a worktree, and never commit — the user reviews with normal `git diff` and commits manually. They require a clean working tree (`git status --porcelain` must be empty). `autonomous-current` additionally refuses `main`/`master`; `autonomous-main` passes `--allow-main` to bypass that guard. + +## Upstream + +This plugin is based on [quaat/autonomous-development](https://github.com/quaat/autonomous-development). This fork keeps the original workflow and adds the current-checkout mode and related documentation updates. + ## Included skills | Skill | Purpose | |---|---| -| `/autonomous-development:autonomous-feature` | Run the complete workflow from a high-level idea | +| `/autonomous-development:autonomous-feature` | Safe default: run the complete workflow inside a disposable worktree | +| `/autonomous-development:autonomous-current` | Run the complete workflow directly on the user's already-created feature branch (current checkout, never commits) | +| `/autonomous-development:autonomous-main` | Run the complete workflow directly on `main`/`master` (explicit opt-in via `--allow-main`, never commits) | | `/autonomous-development:enhance-idea` | Ask Codex to turn a rough feature idea into a structured proposal | | `/autonomous-development:implementation-plan` | Ask a fresh Codex execution for a repository-grounded plan | | `/autonomous-development:implement-plan` | Implement the accepted plan with Claude | @@ -286,20 +302,50 @@ Archiving is a metadata flag; no files are deleted. - Do not share state directories across users or store them on world-readable paths. - Remote URLs are stored with credentials stripped (the `user:pass@` portion is removed). -## Worktree support +## Worktree modes -All commands work correctly from any linked worktree. The repository identity is derived from -the shared git object store so runs created in different worktrees belong to the same -repository and are visible to `list-runs`. +The controller supports two explicit execution modes: ```bash -# Create a linked worktree and run the workflow there -git worktree add ../experiment feature-branch -cd ../experiment -controller.py init --feature "Experiment feature" -# State stored under the same repository ID, new run ID +# Default safe workflow: isolated worktree +# Claude Code's autonomous-feature skill enters a disposable worktree before init. +controller.py init --feature "Experiment feature" --mode auto --worktree-mode isolated ``` +```bash +# Current-branch workflow: use the branch you created manually +git checkout -b experiment +controller.py init --feature "Experiment feature" --mode auto --worktree-mode current +# Review the result with normal `git diff` in your checkout. +``` + +```bash +# Current-checkout on main/master (explicit opt-in): +controller.py init --feature "Hotfix" --mode standard --worktree-mode current --allow-main +``` + +Two slash commands wrap these invocations so the user does not have to call `controller.py` +directly: + +```text +/autonomous-development:autonomous-current "Experiment feature" # already on a feature branch +/autonomous-development:autonomous-main "Hotfix" # explicit edits on main/master +``` + +Both commands run `controller.py init --mode standard --worktree-mode current`; the `main` +variant additionally passes `--allow-main`. Neither creates `.claude/worktrees/*`, neither +enters a worktree, and neither commits. The agent's changes land directly in your checkout so +normal `git diff`, local testing, and manual commit flow continue to work. + +All commands still work correctly from any linked worktree. The repository identity is derived +from the shared git object store so runs created in different worktrees belong to the same +repository and are visible to `list-runs`. Current-checkout mode is opt-in for people who create +their own feature branches first and want the agent's changes to land directly in that checkout. +In current mode the controller uses the current project root for both +`repository.canonical_root` and `repository.worktree_path`, records the current branch in +baseline metadata, does not create a `.claude/worktrees/*` worktree or `worktree-*` branch, +refuses `main`/`master` unless you pass `--allow-main`, and refuses a dirty tree. + ## Completion rules A run succeeds only when: @@ -323,7 +369,7 @@ The skill uses Codex with `--sandbox read-only`. Claude performs repository edit - deleting unrelated user changes; - weakening tests or security controls to obtain a passing result. -Run autonomous development in a disposable branch or worktree. The orchestrator asks Claude Code to use an isolated worktree whenever supported. +By default, run autonomous development in an isolated worktree. This fork also supports an explicit current-checkout mode when you want changes to land directly in your own feature branch. ## Customization diff --git a/examples/invocations.md b/examples/invocations.md index 34c72d6..3d0f6c4 100644 --- a/examples/invocations.md +++ b/examples/invocations.md @@ -1,9 +1,23 @@ # Example invocations ```text +# Safe default: edits land in a disposable isolated worktree. /autonomous-development:autonomous-feature "Add resumable multipart uploads with checksum validation and backward-compatible API behavior" ``` +```text +# Direct edits on an already-created feature branch. +# Refuses main/master and a dirty tree. Never commits — review with `git diff`. +git checkout -b feature/uploads +/autonomous-development:autonomous-current "Add resumable multipart uploads" +``` + +```text +# Explicit opt-in: direct edits on main/master. +# Still requires a clean tree. Never commits — review with `git diff`. +/autonomous-development:autonomous-main "Patch a security hotfix in place" +``` + ```text /autonomous-development:enhance-idea "Add organization-level API usage limits" /autonomous-development:implementation-plan "Prioritize backward compatibility and migration safety" @@ -31,6 +45,13 @@ controller.py init --feature "Rename a button label" --mode auto # Force the full workflow with mandatory adversarial review. controller.py init --feature "Add tenant-scoped billing" --mode rigorous + +# Use your manually created feature branch instead of a disposable worktree. +git switch -c experiment +controller.py init --feature "Experiment feature" --mode auto --worktree-mode current + +# Same mode, but explicitly authorized to land on main/master. +controller.py init --feature "Hotfix" --mode standard --worktree-mode current --allow-main ``` Drive phases and inspect token usage: diff --git a/scripts/controller.py b/scripts/controller.py index e420653..ec6d253 100755 --- a/scripts/controller.py +++ b/scripts/controller.py @@ -93,6 +93,7 @@ _DEFAULT_PROFILE = {"reasoning": "high", "verbosity": "low", "reasoning_summary": "none"} WORKFLOW_MODES = ("auto", "lean", "standard", "rigorous") +WORKTREE_MODES = ("isolated", "current") # Conservative risk categories used by `--mode auto` escalation. Matching any # category escalates an `auto` run to rigorous. @@ -514,6 +515,32 @@ def select_mode(requested: str, feature: str) -> tuple[str, list[str]]: return requested, [f"explicit mode requested: {requested}"] +def worktree_mode_label(worktree_mode: object) -> str: + """Human-readable label for the init worktree mode.""" + if worktree_mode == "current": + return "current checkout" + if worktree_mode == "isolated": + return "isolated worktree" + if isinstance(worktree_mode, str) and worktree_mode.strip(): + return worktree_mode.strip() + return "(unknown)" + + +def repository_state_block(repo: RepoInfo, *, worktree_mode: str | None = None) -> dict[str, str]: + """Return the repository block written into run-state.json.""" + block = { + "id": repo.id, + "canonical_root": str(repo.canonical_root), + "git_common_dir": str(repo.git_common_dir), + "worktree_path": str(repo.worktree_path), + "display_name": repo.display_name, + "remote_display": repo.remote_display, + } + if worktree_mode: + block["worktree_mode"] = worktree_mode + return block + + # --------------------------------------------------------------------------- # Compact prompt context helpers # --------------------------------------------------------------------------- @@ -1362,17 +1389,29 @@ def cmd_init(args: argparse.Namespace) -> int: ) run_dir.mkdir(parents=True, exist_ok=True, mode=0o700) - ctx_text = repository_context(repo) - (run_dir / "feature-request.md").write_text( - feature + "\n", encoding="utf-8" - ) - (run_dir / "repository-context.txt").write_text( - ctx_text, encoding="utf-8" - ) + worktree_mode = getattr(args, "worktree_mode", "isolated") + dirty = git(repo.canonical_root, "status", "--short", check=False).splitlines() + if worktree_mode == "current": + if repo.branch in {"main", "master"} and not getattr(args, "allow_main", False): + raise WorkflowError( + "Current-checkout mode refuses to initialize on " + f"branch {repo.branch!r}. Create and check out a feature " + "branch first, or pass --allow-main to override this guard " + "on main/master." + ) + if dirty: + preview = ", ".join(dirty[:5]) + suffix = "" if len(dirty) <= 5 else f" (+{len(dirty) - 5} more)" + raise WorkflowError( + "Current-checkout mode requires a clean working tree. " + f"Dirty entries: {preview}{suffix}" + ) - dirty = git( - repo.canonical_root, "status", "--short", check=False - ).splitlines() + ctx_text = repository_context(repo) + ( + f"\nWorktree mode: {worktree_mode_label(worktree_mode)}\n" + ) + (run_dir / "feature-request.md").write_text(feature + "\n", encoding="utf-8") + (run_dir / "repository-context.txt").write_text(ctx_text, encoding="utf-8") requested_mode = getattr(args, "mode", "auto") effective_mode, mode_reasons = select_mode(requested_mode, feature) @@ -1393,14 +1432,7 @@ def cmd_init(args: argparse.Namespace) -> int: "phase": "initialized", "created_at": utc_now(), "updated_at": utc_now(), - "repository": { - "id": repo.id, - "canonical_root": str(repo.canonical_root), - "git_common_dir": str(repo.git_common_dir), - "worktree_path": str(repo.worktree_path), - "display_name": repo.display_name, - "remote_display": repo.remote_display, - }, + "repository": repository_state_block(repo, worktree_mode=worktree_mode), "baseline": { "commit": repo.head_commit, "branch": repo.branch, @@ -2635,6 +2667,10 @@ def cmd_status(args: argparse.Namespace) -> int: print(f"Status: {state.get('status')}") print(f"Phase: {state.get('phase')}") print(f"Feature: {state.get('feature')}") + repo_block = state.get("repository", {}) + worktree_mode = repo_block.get("worktree_mode") if isinstance(repo_block, dict) else None + if worktree_mode: + print(f"Worktree mode: {worktree_mode_label(worktree_mode)}") print(f"Baseline: {state.get('baseline', {}).get('commit')}") print(f"Verification: {passed}/{len(checks)} passing") print( @@ -2959,15 +2995,11 @@ def cmd_accept_drift(args: argparse.Namespace) -> int: require_active_run_state(state, run_ref.run_id, "accept-drift") old_baseline = dict(state.get("baseline", {})) old_repo_block = dict(state.get("repository", {})) + old_worktree_mode = old_repo_block.get("worktree_mode") - state["repository"] = { - "id": repo.id, - "canonical_root": str(repo.canonical_root), - "git_common_dir": str(repo.git_common_dir), - "worktree_path": str(repo.worktree_path), - "display_name": repo.display_name, - "remote_display": repo.remote_display, - } + state["repository"] = repository_state_block( + repo, worktree_mode=old_worktree_mode if isinstance(old_worktree_mode, str) else None + ) state["baseline"] = { "commit": repo.head_commit, "branch": repo.branch, @@ -3240,6 +3272,17 @@ def build_parser() -> argparse.ArgumentParser: default="auto", help="Workflow rigor mode; auto escalates conservatively by risk", ) + init.add_argument( + "--worktree-mode", + choices=WORKTREE_MODES, + default="isolated", + help="Repository execution mode; isolated stays in a disposable worktree, current uses the current checkout", + ) + init.add_argument( + "--allow-main", + action="store_true", + help="Allow current-checkout mode on main/master (still requires a clean tree)", + ) init.add_argument("--max-review-rounds", type=int, default=3, choices=range(1, 6)) init.add_argument("--reuse", action="store_true") init.add_argument("--force", action="store_true") diff --git a/scripts/state.py b/scripts/state.py index 8378589..193369d 100644 --- a/scripts/state.py +++ b/scripts/state.py @@ -622,6 +622,7 @@ def migrate_v1_to_v2( "id": repo.id, "display_name": repo.display_name, "canonical_root": str(repo.canonical_root), + "worktree_mode": "isolated", "remote_display": repo.remote_display, } diff --git a/skills/autonomous-current/SKILL.md b/skills/autonomous-current/SKILL.md new file mode 100644 index 0000000..2ef6266 --- /dev/null +++ b/skills/autonomous-current/SKILL.md @@ -0,0 +1,110 @@ +--- +name: autonomous-current +description: Autonomously develop a repository feature directly in the current Git checkout. Use when the user has already created and switched to a clean non-main feature branch and wants the agent's edits to land in that branch without a disposable worktree. Codex independently enhances the idea, proposes a plan, and reviews the work while Claude reconciles, implements, verifies, and triages findings. +argument-hint: "[feature idea]" +disable-model-invocation: true +effort: high +allowed-tools: + - Read + - Grep + - Glob + - Edit + - Write + - LSP + - Agent + - Bash(git *) + - Bash(python3 *) + - Bash(codex *) +disallowed-tools: + - AskUserQuestion + - EnterWorktree + - ExitWorktree +hooks: + Stop: + - hooks: + - type: command + command: 'python3 "${CLAUDE_PLUGIN_ROOT}/scripts/stop_gate.py"' + timeout: 10 +--- + +# Autonomous feature development — current checkout + +Implement this feature idea directly in the user's current Git checkout: + +> $ARGUMENTS + +This skill is the current-checkout counterpart to `autonomous-feature`. The user has already +created a clean non-`main`/non-`master` feature branch and wants the agent's edits to land +directly in that branch — no disposable worktree, no `.claude/worktrees/*` clone, no extra +`worktree-*` branch. Use ultrathink for architecture, compatibility, and review triage. + +## Non-negotiable boundaries + +- This skill must NOT call `EnterWorktree` / `ExitWorktree`. All edits land in the current + checkout. Do not create or enter `.claude/worktrees/*`. +- Refuse to run on `main` or `master`. If the current branch is one of those, stop and ask the + user to create a feature branch first (or use `/autonomous-development:autonomous-main` when + they have explicitly authorized direct edits on main). +- Refuse to run on a dirty working tree. `git status --porcelain` must be empty before + initializing. If it is not, stop and report which entries are dirty. +- Do not create commits. The user will review and commit with their normal `git diff`/commit + flow. +- Preserve unrelated user changes. +- Never push, merge, publish, deploy, rotate credentials, or modify remote infrastructure. +- Never use `danger-full-access`, `--yolo`, `bypassPermissions`, or equivalent unrestricted modes. +- Never apply an irreversible database migration or delete user data. +- Do not weaken authorization, validation, tests, or static checks to make the workflow pass. +- Codex planning and review executions must remain read-only. +- Use no more than the configured review-round budget. +- Treat every Codex finding as a proposal requiring evidence-based triage. + +## Driver loop + +1. Confirm this is a Git repository. Inspect `CLAUDE.md`, repository instructions, architecture, + status, and tests. Verify the current branch is NOT `main`/`master` and that the working tree + is clean before initializing — the controller will fail closed on both, but checking up front + produces a clearer message for the user. + +2. Initialize in current-checkout mode (no worktree): + + ```bash + python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" doctor + python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" init \ + --feature "$ARGUMENTS" \ + --mode standard \ + --worktree-mode current + ``` + + `init` prints the `run-state.json` path and run ID. With multiple concurrent runs, pass + `--run-id ` to all subsequent commands. If `doctor` reports a missing prerequisite, + mark the run blocked and report it rather than bypassing it. + +3. Repeatedly ask the controller for the next phase, then execute it: + + ```bash + python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" next-action --json + ``` + + The response gives `phase`, `required_action`, `completion_condition`, and `references`. + Read the referenced file under `${CLAUDE_PLUGIN_ROOT}/skills/autonomous-feature/references/` + for that phase and follow it until the completion condition holds. + +4. Do not declare success until `evaluate` succeeds: + + ```bash + python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" evaluate + python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" status + python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" usage-report + ``` + +## Final report + +- the implemented behavior; +- principal files changed (visible to the user via plain `git diff`); +- verification commands and results; +- Codex review rounds and disposition of findings; +- adversarial review result when one was required; +- per-phase usage table from `usage-report`; +- remaining risks or explicit blocked reason; +- a suggested conventional commit message (the user commits manually — this skill must not + commit). diff --git a/skills/autonomous-feature/SKILL.md b/skills/autonomous-feature/SKILL.md index 7733ca6..cc3d28f 100644 --- a/skills/autonomous-feature/SKILL.md +++ b/skills/autonomous-feature/SKILL.md @@ -53,18 +53,30 @@ lives in `references/` and is loaded only when a phase needs it. 1. Confirm this is a Git repository. Inspect `CLAUDE.md`, repository instructions, architecture, status, and tests. Use `EnterWorktree` for an isolated worktree whenever available — mandatory - when the starting worktree has uncommitted changes. + when the starting worktree has uncommitted changes. If the user explicitly asks for + current-checkout mode, stay in the current branch instead of entering a worktree, and require a + clean feature branch before proceeding. 2. Initialize: ```bash python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" doctor - python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" init --feature "$ARGUMENTS" --mode auto + python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" init --feature "$ARGUMENTS" --mode auto --worktree-mode isolated ``` `init` prints the `run-state.json` path and run ID. With multiple concurrent runs, pass `--run-id ` to all subsequent commands. If `doctor` reports a missing prerequisite, mark the run blocked and report it rather than bypassing it. + For current-checkout mode, do not call `EnterWorktree`. Instead, keep the current branch checked + out and run: + + ```bash + python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" init --feature "$ARGUMENTS" --mode auto --worktree-mode current + ``` + + The controller refuses `main`/`master` unless the user also passes `--allow-main`, and it + refuses a dirty tree in current-checkout mode. + 3. Repeatedly ask the controller for the next phase, then execute it: ```bash diff --git a/skills/autonomous-main/SKILL.md b/skills/autonomous-main/SKILL.md new file mode 100644 index 0000000..fb5af8d --- /dev/null +++ b/skills/autonomous-main/SKILL.md @@ -0,0 +1,108 @@ +--- +name: autonomous-main +description: Autonomously develop a repository feature directly in the current main/master checkout. Use only when the user has explicitly authorized direct edits on main/master. Same workflow as autonomous-current but passes --allow-main to bypass the main/master refusal. Still requires a clean working tree and never commits. +argument-hint: "[feature idea]" +disable-model-invocation: true +effort: high +allowed-tools: + - Read + - Grep + - Glob + - Edit + - Write + - LSP + - Agent + - Bash(git *) + - Bash(python3 *) + - Bash(codex *) +disallowed-tools: + - AskUserQuestion + - EnterWorktree + - ExitWorktree +hooks: + Stop: + - hooks: + - type: command + command: 'python3 "${CLAUDE_PLUGIN_ROOT}/scripts/stop_gate.py"' + timeout: 10 +--- + +# Autonomous feature development — current checkout on main + +Implement this feature idea directly in the user's current checkout, including when the +current branch is `main` or `master`: + +> $ARGUMENTS + +This is the explicit-opt-in variant of `autonomous-current`. By invoking this skill the user has +acknowledged that the agent's edits will land directly in `main`/`master`. Prefer +`autonomous-feature` (isolated worktree) or `autonomous-current` (clean feature branch) when +either is appropriate; this skill exists for cases where the user really does want changes in +the current `main`/`master` checkout. + +## Non-negotiable boundaries + +- This skill must NOT call `EnterWorktree` / `ExitWorktree`. All edits land in the current + checkout. Do not create or enter `.claude/worktrees/*`. +- Require a clean working tree. `git status --porcelain` must be empty before initializing. If + it is not, stop and report which entries are dirty. +- Do not create commits. The user will review and commit with their normal `git diff`/commit + flow. +- Preserve unrelated user changes. +- Never push, merge, publish, deploy, rotate credentials, or modify remote infrastructure. +- Never use `danger-full-access`, `--yolo`, `bypassPermissions`, or equivalent unrestricted modes. +- Never apply an irreversible database migration or delete user data. +- Do not weaken authorization, validation, tests, or static checks to make the workflow pass. +- Codex planning and review executions must remain read-only. +- Use no more than the configured review-round budget. +- Treat every Codex finding as a proposal requiring evidence-based triage. + +## Driver loop + +1. Confirm this is a Git repository. Inspect `CLAUDE.md`, repository instructions, architecture, + status, and tests. Verify the working tree is clean before initializing. + +2. Initialize in current-checkout mode on main/master: + + ```bash + python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" doctor + python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" init \ + --feature "$ARGUMENTS" \ + --mode standard \ + --worktree-mode current \ + --allow-main + ``` + + `init` prints the `run-state.json` path and run ID. With multiple concurrent runs, pass + `--run-id ` to all subsequent commands. If `doctor` reports a missing prerequisite, + mark the run blocked and report it rather than bypassing it. + +3. Repeatedly ask the controller for the next phase, then execute it: + + ```bash + python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" next-action --json + ``` + + The response gives `phase`, `required_action`, `completion_condition`, and `references`. + Read the referenced file under `${CLAUDE_PLUGIN_ROOT}/skills/autonomous-feature/references/` + for that phase and follow it until the completion condition holds. + +4. Do not declare success until `evaluate` succeeds: + + ```bash + python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" evaluate + python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" status + python3 "${CLAUDE_PLUGIN_ROOT}/scripts/controller.py" usage-report + ``` + +## Final report + +- the implemented behavior; +- principal files changed (visible to the user via plain `git diff`); +- verification commands and results; +- Codex review rounds and disposition of findings; +- adversarial review result when one was required; +- per-phase usage table from `usage-report`; +- remaining risks or explicit blocked reason; +- a suggested conventional commit message (the user commits manually — this skill must not + commit). diff --git a/tests/test_controller.py b/tests/test_controller.py index 1e665a3..11513b0 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -81,6 +81,22 @@ def _find_state_path(self, repo: Path, state_home: Path) -> Path: ) return active[0].run_dir / "run-state.json" + def _current_branch(self, repo: Path) -> str: + result = subprocess.run( + ["git", "-C", str(repo), "branch", "--show-current"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + return result.stdout.strip() + + def _checkout_feature_branch(self, repo: Path, name: str = "feature-branch") -> None: + subprocess.run( + ["git", "-C", str(repo), "checkout", "-q", "-b", name], + check=True, + ) + def test_init_and_status(self) -> None: repo = self.make_repo() state_home = self.make_state_home() @@ -99,6 +115,157 @@ def test_init_and_status(self) -> None: status = self.run_controller(repo, "status", state_home=state_home) self.assertEqual(status.returncode, 0, status.stderr) self.assertIn("Phase: initialized", status.stdout) + self.assertIn("Worktree mode: isolated worktree", status.stdout) + self.assertEqual(state["repository"]["worktree_mode"], "isolated") + + def test_init_current_mode_records_current_checkout_path(self) -> None: + repo = self.make_repo() + state_home = self.make_state_home() + self._checkout_feature_branch(repo) + + result = self.run_controller( + repo, + "init", + "--feature", + "Feature", + "--worktree-mode", + "current", + state_home=state_home, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + state_path = self._find_state_path(repo, state_home) + state = json.loads(state_path.read_text(encoding="utf-8")) + self.assertEqual(state["repository"]["canonical_root"], str(repo.resolve())) + self.assertEqual(state["repository"]["worktree_path"], str(repo.resolve())) + self.assertEqual(state["repository"]["worktree_mode"], "current") + self.assertEqual(state["baseline"]["branch"], self._current_branch(repo)) + + status = self.run_controller(repo, "status", state_home=state_home) + self.assertEqual(status.returncode, 0, status.stderr) + self.assertIn("Worktree mode: current checkout", status.stdout) + + def test_current_mode_refuses_main_and_master(self) -> None: + for branch_name in ("main", "master"): + with self.subTest(branch=branch_name): + repo = self.make_repo() + state_home = self.make_state_home() + subprocess.run( + ["git", "-C", str(repo), "branch", "-m", branch_name], + check=True, + ) + result = self.run_controller( + repo, + "init", + "--feature", + "Feature", + "--worktree-mode", + "current", + state_home=state_home, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn(branch_name, result.stderr) + + def test_current_mode_allows_main_and_master_with_override(self) -> None: + for branch_name in ("main", "master"): + with self.subTest(branch=branch_name): + repo = self.make_repo() + state_home = self.make_state_home() + subprocess.run( + ["git", "-C", str(repo), "branch", "-m", branch_name], + check=True, + ) + result = self.run_controller( + repo, + "init", + "--feature", + "Feature", + "--worktree-mode", + "current", + "--allow-main", + state_home=state_home, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_current_mode_refuses_dirty_tree(self) -> None: + repo = self.make_repo() + state_home = self.make_state_home() + self._checkout_feature_branch(repo) + (repo / "README.md").write_text("# dirty\n", encoding="utf-8") + + result = self.run_controller( + repo, + "init", + "--feature", + "Feature", + "--worktree-mode", + "current", + state_home=state_home, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("clean working tree", result.stderr) + + def test_current_mode_does_not_create_claude_worktrees(self) -> None: + repo = self.make_repo() + state_home = self.make_state_home() + self._checkout_feature_branch(repo) + worktrees_dir = repo / ".claude" / "worktrees" + + result = self.run_controller( + repo, + "init", + "--feature", + "Feature", + "--worktree-mode", + "current", + state_home=state_home, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(worktrees_dir.exists()) + + def test_current_mode_with_allow_main_does_not_create_claude_worktrees(self) -> None: + """The /autonomous-development:autonomous-main wrapper passes --allow-main; it + must still skip the disposable-worktree path.""" + for branch_name in ("main", "master"): + with self.subTest(branch=branch_name): + repo = self.make_repo() + state_home = self.make_state_home() + subprocess.run( + ["git", "-C", str(repo), "branch", "-m", branch_name], + check=True, + ) + worktrees_dir = repo / ".claude" / "worktrees" + + result = self.run_controller( + repo, + "init", + "--feature", + "Feature", + "--worktree-mode", + "current", + "--allow-main", + state_home=state_home, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(worktrees_dir.exists()) + + def test_isolated_default_unchanged_for_autonomous_feature(self) -> None: + """The /autonomous-development:autonomous-feature wrapper relies on the default + worktree-mode staying `isolated` and on the run state recording it as such.""" + repo = self.make_repo() + state_home = self.make_state_home() + + # No --worktree-mode → relies on the default. + result = self.run_controller( + repo, + "init", + "--feature", + "Feature", + state_home=state_home, + ) + self.assertEqual(result.returncode, 0, result.stderr) + state = json.loads(self._find_state_path(repo, state_home).read_text(encoding="utf-8")) + self.assertEqual(state["repository"]["worktree_mode"], "isolated") def test_record_passing_check(self) -> None: repo = self.make_repo() diff --git a/tests/test_project_layout.py b/tests/test_project_layout.py index 110202a..098441b 100644 --- a/tests/test_project_layout.py +++ b/tests/test_project_layout.py @@ -15,6 +15,8 @@ def test_manifest_and_components(self) -> None: self.assertEqual(manifest["name"], "autonomous-development") expected = { "autonomous-feature", + "autonomous-current", + "autonomous-main", "enhance-idea", "implementation-plan", "implement-plan", @@ -85,6 +87,66 @@ def test_prompt_placeholders_are_known(self) -> None: ) self.assertTrue(placeholders <= known, f"{path}: {placeholders - known}") + def test_autonomous_feature_skill_mentions_worktree_modes(self) -> None: + text = (ROOT / "skills" / "autonomous-feature" / "SKILL.md").read_text( + encoding="utf-8" + ) + self.assertIn("--worktree-mode isolated", text) + self.assertIn("--worktree-mode current", text) + self.assertIn("EnterWorktree", text) + self.assertIn("current-checkout mode", text) + + def test_autonomous_current_skill_uses_current_mode_without_worktree(self) -> None: + text = (ROOT / "skills" / "autonomous-current" / "SKILL.md").read_text( + encoding="utf-8" + ) + self.assertIn("--mode standard", text) + self.assertIn("--worktree-mode current", text) + self.assertNotIn("--allow-main", text) + # Must NOT enter a disposable worktree. + self.assertIn("EnterWorktree", text) # mentioned only to disallow it + # The frontmatter explicitly disallows the worktree tools. + head = text.split("---", 2)[1] + self.assertIn("EnterWorktree", head) + self.assertIn("ExitWorktree", head) + # Disallows main/master. + self.assertIn("main", text) + self.assertIn("master", text) + # Never commits. + self.assertIn("Do not create commits", text) + # Does not create .claude/worktrees/*. + self.assertIn(".claude/worktrees", text) + + def test_autonomous_main_skill_passes_allow_main(self) -> None: + text = (ROOT / "skills" / "autonomous-main" / "SKILL.md").read_text( + encoding="utf-8" + ) + self.assertIn("--mode standard", text) + self.assertIn("--worktree-mode current", text) + self.assertIn("--allow-main", text) + # Must NOT enter a disposable worktree. + head = text.split("---", 2)[1] + self.assertIn("EnterWorktree", head) + self.assertIn("ExitWorktree", head) + # Never commits. + self.assertIn("Do not create commits", text) + # Still requires a clean tree. + self.assertIn("clean working tree", text) + # Does not create .claude/worktrees/*. + self.assertIn(".claude/worktrees", text) + + def test_autonomous_feature_skill_remains_isolated_default(self) -> None: + text = (ROOT / "skills" / "autonomous-feature" / "SKILL.md").read_text( + encoding="utf-8" + ) + # The default invocation must keep the isolated worktree default. + self.assertIn("--worktree-mode isolated", text) + # And the frontmatter must still allow EnterWorktree/ExitWorktree so the + # default workflow can enter a disposable worktree. + head = text.split("---", 2)[1] + self.assertIn("EnterWorktree", head) + self.assertIn("ExitWorktree", head) + if __name__ == "__main__": unittest.main()