Skip to main content

agent-cli-worktree-safety

Data-loss invariants for a CLI that runs an agent SDK in a git worktree then force-removes it. Use when building or reviewing such a CLI, or when an agent run reports "no changes" after writing files.

Quellinformationen

Repository
laurigates/claude-plugins
Letzte Quellaktivität
5. Oktober 2026 um 05:00
Erkannte Sprache von SKILL.md
Englisch
Sterne
60
Forks
6

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

Datei-Explorer
2 Dateien

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
created
2026-09-02T00:00:00.000Z
modified
2026-10-02T00:00:00.000Z
reviewed
2026-09-02T00:00:00.000Z
name
agent-cli-worktree-safety
description
Data-loss invariants for a CLI that runs an agent SDK in a git worktree then force-removes it. Use when building or reviewing such a CLI, or when an agent run reports "no changes" after writing files.
allowed-tools
Glob, Grep, Read, Edit, Write, Bash
model
opus
compatibility
claude-code
# Agent-CLI Worktree Safety Data-loss prevention for CLIs that create a git worktree, run an LLM orchestrator (`claude-agent-sdk` or similar) that writes files there, and then clean up with `git worktree remove --force`. ## When to Use This Skill | Use this skill when... | Use something else when... | |------------------------|----------------------------| | Building or reviewing a CLI that runs an agent SDK in a worktree it later removes | Isolating a Claude Code subagent with `isolation: "worktree"` → `.claude/rules/agent-runtime.md` § Worktree Isolation | | An agent run wrote files but reported "No changes were made" | Pruning other sessions' worktrees → `.claude/rules/agent-coworker-detection.md` § Cleanup | | Wiring the SDK's two-phase or `create_worktree` flow → [REFERENCE.md](REFERENCE.md) | Coordinating parallel agents → `agent-patterns-plugin:agent-teams` | ## The core failure mode Three independent mistakes compound into silent data loss: 1. **Driver phases write but never commit** — "the outer orchestrator handles git state". 2. **The outer orchestrator ends without committing** — it stalls on an interactive tool call that cannot render (see *Interactive tools in SDK subprocess mode* below), or simply finishes. 3. **The "has changes" check only counts commits** (`git log base..HEAD`), so it reports nothing to preserve — and cleanup force-removes the worktree. Net result: many files are written, the user is told **"No changes were made"**, and the work is destroyed. No error, no traceback. That user-visible message is the diagnostic tell — if a run wrote files and reported no changes, check invariant 1 before anything else. ## Required invariants ### 1. "Has changes" means commits OR a dirty tree Any function deciding whether a worktree is worth preserving must return true when the working tree is dirty, even with no commits beyond base: ```python def worktree_has_changes(worktree_path, base_branch): commits = subprocess.run( ["git", "log", "--oneline", f"{base_branch}..HEAD"], cwd=worktree_path, capture_output=True, text=True, ) if commits.stdout.strip(): return True dirty = subprocess.run( ["git", "status", "--porcelain"], cwd=worktree_path, capture_output=True, text=True, ) return bool(dirty.stdout.strip()) ``` A commits-only check is a data-loss bug. `--porcelain` (not `git diff`) is load-bearing: a brand-new file the agent created is **untracked**, and `git diff` cannot see it. If uncommitted files matter for the next operation — push, PR, cleanup — they matter for this check. ### 2. Safety-net commit before any destructive cleanup If the agent phase was supposed to commit and didn't, capture the work anyway: ```python def auto_commit_if_dirty(worktree_path, message): status = subprocess.run( ["git", "status", "--porcelain"], cwd=worktree_path, capture_output=True, text=True, check=True, ) if not status.stdout.strip(): return False subprocess.run(["git", "add", "-A"], cwd=worktree_path, check=True) subprocess.run( ["git", "commit", "-m", message], cwd=worktree_path, check=True, capture_output=True, text=True, ) return True ``` Call it from every post-run path — interactive and non-interactive — with a message naming the workflow, and print a warning so the user learns the safety net fired. A silent safety net hides the bug it is compensating for. ### 3. Pre-validate before git touches the path Typer's `exists=True, dir_okay=True` is not enough. Call explicit guards at the top of every command that will later run `git rev-parse`, `git worktree add`, or a domain operation: ```python def _ensure_git_repo(path): inside = subprocess.run( ["git", "rev-parse", "--is-inside-work-tree"], cwd=path, capture_output=True, text=True, ) if inside.returncode != 0 or inside.stdout.strip() != "true": raise typer.Exit(code=EXIT_CONFIG_ERROR) head = subprocess.run( ["git", "rev-parse", "--verify", "HEAD"], cwd=path, capture_output=True, text=True, ) if head.returncode != 0: # unborn HEAD — no commits yet raise typer.Exit(code=EXIT_CONFIG_ERROR) ``` **Unborn HEAD is its own failure class.** A fresh `git init` with no commits passes `--is-inside-work-tree` but fails every downstream `rev-parse HEAD` and `worktree add -b <br> <path> HEAD`. Check both, always. Add a domain check beside it — an Obsidian vault wants `.obsidian/` or any `*.md`; a code repo wants a language marker — so "pointed at the wrong directory" is a friendly config error, not a `CalledProcessError` traceback. ### 4. Branch-name collisions are not free Timestamp branch names (`%Y-%m-%dT%H-%M`) collide when two runs start in the same minute. If `create_worktree` force-removes a pre-existing worktree at the target path, the second run destroys the first run's uncommitted agent output. Either use second granularity plus a short random suffix, or refuse: ```python if worktree_path.exists(): dirty = subprocess.run( ["git", "status", "--porcelain"], cwd=worktree_path, capture_output=True, text=True, ) if dirty.returncode == 0 and dirty.stdout.strip(): raise RuntimeError( f"Refusing to overwrite worktree at {worktree_path}: has " f"uncommitted changes from a concurrent or prior run." ) ``` An advisory lock that only guards `--non-interactive` runs does not cover interactive or concurrent invocations. Those need the in-tree probe. ## Interactive tools in SDK subprocess mode When the SDK runs the CLI as a subprocess, stdin/stdout carry the SDK's JSON protocol. An interactive tool call such as `AskUserQuestion` has no terminal to reach: the call fires, nothing renders, and the model wraps up as if the user declined. This is failure mode 2 above, and it is silent. - **Do not rely on mid-pipeline interactive tool calls** in any driver flow. - Use a two-phase pattern instead: the agent emits findings and stops → the host collects input (`console.input()`) → a second `client.query()` executes the selection. - Remove the interactive tool from `allowed_tools` on paths that must not fail silently, so the attempt is an error rather than a shrug. Worked example: [git-repo-agent ADR-003](https://github.com/laurigates/git-repo-agent/blob/main/docs/adr/003-switch-to-claude-sdk-client-for-interactive-workflows.md). The `query()` vs `ClaudeSDKClient` split, the full two-phase snippet, and creating the worktree in Python (`ClaudeAgentOptions` has no `isolation` parameter) are in [REFERENCE.md](REFERENCE.md). ## Required tests | Test | Verifies | |------|----------| | `test_worktree_has_changes_detects_untracked` | Dirty-tree detection covers "wrote files, never committed" | | `test_auto_commit_if_dirty` | The safety net captures uncommitted state and leaves the tree clean | | `test_create_worktree_refuses_to_overwrite_dirty` | Collision protection for same-minute runs | | `test_cli_rejects_non_git_target` | Friendly config error, not a traceback | | `test_cli_rejects_empty_git_repo` | Unborn-HEAD detection | | `test_cli_rejects_non_domain_target` | Catches "pointed at the wrong directory" | Reference layouts: [`git-repo-agent/tests/test_worktree_changes.py`](https://github.com/laurigates/git-repo-agent/blob/main/tests/test_worktree_changes.py) and [`vault-agent/tests/test_worktree.py`](https://github.com/laurigates/vault-agent/blob/main/tests/test_worktree.py) (`TestWorktreeCollisionSafety`). ## Checklist when adding a new agent CLI - [ ] `_ensure_git_repo()` + a domain guard at the top of every write command - [ ] "Has changes" covers uncommitted **and** untracked state - [ ] Safety-net commit runs before any `cleanup_worktree(--force)` or push - [ ] `create_worktree` refuses to force-remove a dirty pre-existing worktree - [ ] A regression test for each of the six rows above - [ ] Interactive flows use the two-phase pattern, not a mid-session interactive tool call ## Related - `python-plugin:typer-cli-completion` — sibling convention for the same family of Typer CLIs - `agent-patterns-plugin:parallel-agent-dispatch` — the orchestrator-side contract for worktree-isolated agents - `git-plugin:git-coworker-check` — detecting a peer agent in a shared checkout before destructive git ops
Auf GitHub ansehen