claude-dev-env 2.7.1 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/_shared/CLAUDE.md +1 -0
  2. package/_shared/advisor/advisor-protocol.md +19 -9
  3. package/_shared/pr-loop/audit-contract.md +4 -4
  4. package/_shared/pr-loop/precatch-rubric.md +2 -2
  5. package/_shared/process-tree/CLAUDE.md +41 -0
  6. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/__init__.py +1 -0
  7. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +27 -0
  8. package/_shared/process-tree/scripts/process_tree_kill.py +141 -0
  9. package/_shared/process-tree/scripts/pyproject.toml +16 -0
  10. package/_shared/process-tree/scripts/test_process_tree_kill.py +278 -0
  11. package/agents/code-quality-agent.md +6 -5
  12. package/agents/deep-research.md +7 -24
  13. package/agents/docs-agent.md +1 -27
  14. package/agents/issue-tracker.md +1 -7
  15. package/agents/skill-writer-agent.md +1 -2
  16. package/agents/test_agent_frontmatter.py +309 -12
  17. package/hooks/blocking/CLAUDE.md +2 -0
  18. package/hooks/blocking/fable_spawn_gate.py +187 -0
  19. package/hooks/blocking/piped_pytest_blocker.py +1223 -0
  20. package/hooks/blocking/plain_language_blocker.py +287 -15
  21. package/hooks/blocking/test_fable_spawn_gate.py +374 -0
  22. package/hooks/blocking/test_piped_pytest_blocker.py +587 -0
  23. package/hooks/blocking/test_plain_language_blocker.py +277 -2
  24. package/hooks/blocking/test_pre_tool_use_dispatcher.py +34 -2
  25. package/hooks/git-hooks/CLAUDE.md +2 -2
  26. package/hooks/git-hooks/git_hooks_constants/__init__.py +28 -0
  27. package/hooks/git-hooks/pre_push.py +343 -54
  28. package/hooks/git-hooks/test_pre_push.py +852 -6
  29. package/hooks/hooks.json +9 -19
  30. package/hooks/hooks_constants/CLAUDE.md +2 -0
  31. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -0
  32. package/hooks/hooks_constants/fable_spawn_gate_constants.py +62 -0
  33. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +360 -0
  34. package/hooks/hooks_constants/plain_language_blocker_constants.py +64 -1
  35. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +10 -0
  36. package/hooks/hooks_constants/shell_command_segments.py +1 -1
  37. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -0
  38. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +27 -0
  39. package/hooks/hooks_constants/test_prose_metrics_parity.py +124 -0
  40. package/package.json +1 -1
  41. package/rules/CLAUDE.md +1 -0
  42. package/rules/ask-user-question-required.md +26 -0
  43. package/rules/claims-as-quotes.md +65 -0
  44. package/scripts/CLAUDE.md +1 -1
  45. package/scripts/_code_review_test_support.py +6 -0
  46. package/scripts/check.ps1 +18 -5
  47. package/scripts/claude_chain_runner.py +203 -31
  48. package/scripts/codec_forwarding_test_support.py +2 -0
  49. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -4
  50. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +38 -0
  51. package/scripts/dev_env_scripts_constants/code_review_constants.py +403 -2
  52. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +28 -24
  53. package/scripts/grok_headless_runner.py +16 -83
  54. package/scripts/spawn_grok_batch.py +38 -1
  55. package/scripts/test_claude_chain_runner.py +358 -0
  56. package/scripts/test_grok_headless_runner.py +18 -63
  57. package/scripts/test_invoke_code_review.py +298 -0
  58. package/scripts/test_resolve_worker_spawn.py +6 -0
  59. package/scripts/test_spawn_grok_batch.py +101 -0
  60. package/scripts/tests/CLAUDE.md +1 -0
  61. package/scripts/tests/test_grok_worker_constants.py +59 -0
  62. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +46 -0
  63. package/skills/autoconverge/workflow/converge.contract.test.mjs +105 -2
  64. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +34 -1
  65. package/skills/autoconverge/workflow/converge.mjs +66 -33
  66. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -4
  67. package/skills/codex-review/scripts/codex_usage_probe.py +20 -33
  68. package/skills/codex-review/scripts/run_codex_review.py +16 -64
  69. package/skills/codex-review/scripts/test_codex_usage_probe.py +46 -41
  70. package/skills/codex-review/scripts/test_run_codex_review.py +1 -33
  71. package/skills/e-code-review/SKILL.md +9 -8
  72. package/skills/e-code-review/reference/fix.md +29 -7
  73. package/skills/e-code-review/reference/loop.md +230 -14
  74. package/skills/e-code-review/reference/low.md +33 -15
  75. package/skills/e-code-review/reference/medium.md +55 -21
  76. package/skills/e-code-review/reference/xhigh.md +30 -12
  77. package/skills/fresh-branch/CLAUDE.md +5 -5
  78. package/skills/fresh-branch/SKILL.md +14 -6
  79. package/skills/fresh-branch/scripts/create_fresh_branch.py +122 -39
  80. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +10 -3
  81. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +251 -0
  82. package/skills/grok-spawn/SKILL.md +5 -0
  83. package/skills/orchestrator/SKILL.md +4 -1
  84. package/skills/orchestrator-refresh/SKILL.md +5 -1
  85. package/skills/team-advisor/SKILL.md +4 -1
@@ -9,8 +9,15 @@ Run `git diff @{upstream}...HEAD` (or `git diff main...HEAD` / `git diff HEAD~1`
9
9
  if there's no upstream) to get the unified diff under review. If there are
10
10
  uncommitted changes, or the range diff is empty, also run `git diff HEAD` and
11
11
  include the working-tree changes in scope — the review often runs before the
12
- commit. If a PR number, branch name, or file path was passed as an argument,
13
- review that target instead. Treat this diff as the review scope.
12
+ commit. If a target was passed as an argument, review that target instead. A
13
+ target names one or more items, each a PR number, a branch name, a file path, or
14
+ `default-range` — the diff this phase gathers when no target is passed — and it
15
+ may mix those forms. A loop round widens a target by adding a path to whatever
16
+ it started as, and it names `default-range` as an item whenever the round it
17
+ widened was given no target argument, so the original scope stays under review.
18
+ When a target names more than one item, gather each item's diff and take their
19
+ union — a shared hunk counted once, an empty one adding nothing — as the
20
+ target's diff. Treat this diff as the review scope.
14
21
 
15
22
  ## Phase 1 — Find candidates (3 correctness angles + 3 cleanup angles + 1 altitude angle + 1 conventions angle)
16
23
 
@@ -116,23 +123,41 @@ Keep candidates where the vote is CONFIRMED or PLAUSIBLE.
116
123
 
117
124
  Report this review's results — `{level, findings}` — through the structured
118
125
  findings-report call: the mechanism that renders a review's results as a typed
119
- list in the host UI, ranked most-severe first. Each entry has `file`, `line`,
120
- `summary`, `short_summary` the claim compressed to ≤60 characters, no
121
- rationale or consequence clause — `failure_scenario`, and `category` — a short
122
- kebab-case slug for the angle that produced it (`correctness`,
123
- `simplification`, `efficiency`, `reuse`, `altitude`, `conventions`, or a more
124
- specific slug like `test-coverage` when one fits better) — plus `verdict` when
125
- a verify pass produced one. If nothing survives verification, make that call
126
- with an empty array. Do not also print the findings as text, and do not create
127
- or publish an artifact of the review — the structured call is the report.
126
+ list in the host UI, ranked most-severe first. Each **retained** entry carries
127
+ every field below. Drop REFUTED candidates before this report; do not emit a
128
+ finding that lacks `severity` or `verdict`.
129
+
130
+ | Field | Required | Value |
131
+ |---|---|---|
132
+ | `file` | yes | path under review |
133
+ | `line` | yes | 1-based line number |
134
+ | `summary` | yes | full claim |
135
+ | `short_summary` | yes | claim compressed to ≤60 characters, no rationale or consequence clause |
136
+ | `failure_scenario` | yes | concrete trigger and wrong outcome, or concrete cost for cleanup |
137
+ | `category` | yes | short kebab-case slug for the angle (`correctness`, `simplification`, `efficiency`, `reuse`, `altitude`, `conventions`, or a tighter slug like `test-coverage`) |
138
+ | `severity` | yes | one of `blocker`, `high`, `medium`, `low`, `nit` |
139
+ | `verdict` | yes | `CONFIRMED` or `PLAUSIBLE` from Phase 2 |
140
+
141
+ **Severity rules.** Assign exactly one token from the frozen set above. Use
142
+ `nit` only when all five hold: the change is localized clarity, formatting, or
143
+ a typo; correctness, security, data, and API behavior stay identical; test
144
+ coverage and operability stay identical; dependencies and rollout stay
145
+ identical; the fix is mechanical and bounded. Any other retained finding is
146
+ `low` or higher. Correctness bugs outrank cleanup, altitude, and conventions.
147
+
148
+ If nothing survives verification, make that call with an empty array. Do not
149
+ also print the findings as text, and do not create or publish an artifact of
150
+ the review — the structured call is the report.
128
151
 
129
152
  ## Applying fixes (--fix)
130
153
 
131
- The `--fix` flag was passed. Follow `reference\fix.md` (relative to this
132
- skill's folder) for the exact fix, commit-gate, and skip-handling behavior —
133
- it governs which agent applies each fix, how a fix gets committed, how a skip
134
- is logged, and how outcomes get reported. Do not repeat the findings as text;
135
- follow that document's reporting rules once fixes land.
154
+ The `--fix` flag was passed. Follow `reference\fix.md` (relative
155
+ to this skill's folder) for the exact fix, code-rules-gate, and skip-handling
156
+ behavior — it governs which agent applies each fix, how the code-rules gate
157
+ runs, how a skip is logged, and how outcomes get reported. Do not repeat the
158
+ findings as text; follow that document's reporting rules once fixes land.
159
+
160
+ When `loop` is also set, skip this section.
136
161
 
137
162
  ## If findings are fixed later
138
163
 
@@ -146,8 +171,17 @@ summary; the host UI's per-finding status updates only from that call.
146
171
  ## Looping (`loop`)
147
172
 
148
173
  The `loop` arg was passed. Follow `reference\loop.md` (relative to this
149
- skill's folder) for how to re-run Phases 0–2, Output, and (if `--fix` is also
150
- present) `reference\fix.md`'s fix pass, repeatedly including its exit
151
- condition, iteration cap, and re-invocation rules. Do not treat a single pass
152
- through this document as complete while `loop` is active; hand control to that
153
- document instead of stopping at Output.
174
+ skill's folder) for how to re-run Phases 0–2 and Output repeatedly including
175
+ its exit condition and re-invocation rules. Schedule no fix pass of your own
176
+ here: when `--fix` is also present, `reference\loop.md`'s gate sequence owns the
177
+ round's fixing and loads `reference\fix.md` for the mechanics. Do not treat a
178
+ single pass through this document as complete while `loop` is active; hand
179
+ control to that document, and do not stop at Output.
180
+
181
+ That hand-off applies when this document is entered directly. When a loop round
182
+ is already running and has handed this document its target, the round owns the
183
+ loop: end at Output with the findings report and return those findings to
184
+ `reference\loop.md`'s gate sequence, rather than handing control to that
185
+ document again from here.
186
+
187
+ When `loop` was not passed, skip this section.
@@ -10,8 +10,15 @@ Run `git diff @{upstream}...HEAD` (or `git diff main...HEAD` / `git diff HEAD~1`
10
10
  if there's no upstream) to get the unified diff under review. If there are
11
11
  uncommitted changes, or the range diff is empty, also run `git diff HEAD` and
12
12
  include the working-tree changes in scope — the review often runs before the
13
- commit. If a PR number, branch name, or file path was passed as an argument,
14
- review that target instead. Treat this diff as the review scope.
13
+ commit. If a target was passed as an argument, review that target instead. A
14
+ target names one or more items, each a PR number, a branch name, a file path, or
15
+ `default-range` — the diff this phase gathers when no target is passed — and it
16
+ may mix those forms. A loop round widens a target by adding a path to whatever
17
+ it started as, and it names `default-range` as an item whenever the round it
18
+ widened was given no target argument, so the original scope stays under review.
19
+ When a target names more than one item, gather each item's diff and take their
20
+ union — a shared hunk counted once, an empty one adding nothing — as the
21
+ target's diff. Treat this diff as the review scope.
15
22
 
16
23
  ## Phase 1 — Find candidates (5 correctness angles + 3 cleanup angles + 1 altitude angle + 1 conventions angle)
17
24
 
@@ -157,11 +164,13 @@ or publish an artifact of the review — the structured call is the report.
157
164
 
158
165
  ## Applying fixes (--fix)
159
166
 
160
- The `--fix` flag was passed. Follow `reference\fix.md` (relative to this
161
- skill's folder) for the exact fix, commit-gate, and skip-handling behavior —
162
- it governs which agent applies each fix, how a fix gets committed, how a skip
163
- is logged, and how outcomes get reported. Do not repeat the findings as text;
164
- follow that document's reporting rules once fixes land.
167
+ The `--fix` flag was passed. Follow `reference\fix.md` (relative
168
+ to this skill's folder) for the exact fix, code-rules-gate, and skip-handling
169
+ behavior — it governs which agent applies each fix, how the code-rules gate
170
+ runs, how a skip is logged, and how outcomes get reported. Do not repeat the
171
+ findings as text; follow that document's reporting rules once fixes land.
172
+
173
+ When `loop` is also set, skip this section.
165
174
 
166
175
  ## If findings are fixed later
167
176
 
@@ -175,8 +184,17 @@ summary; the host UI's per-finding status updates only from that call.
175
184
  ## Looping (`loop`)
176
185
 
177
186
  The `loop` arg was passed. Follow `reference\loop.md` (relative to this
178
- skill's folder) for how to re-run Phases 0–3, Output, and (if `--fix` is also
179
- present) `reference\fix.md`'s fix pass, repeatedly including its exit
180
- condition, iteration cap, and re-invocation rules. Do not treat a single pass
181
- through this document as complete while `loop` is active; hand control to that
182
- document instead of stopping at Output.
187
+ skill's folder) for how to re-run Phases 0–3 and Output repeatedly including
188
+ its exit condition and re-invocation rules. Schedule no fix pass of your own
189
+ here: when `--fix` is also present, `reference\loop.md`'s gate sequence owns the
190
+ round's fixing and loads `reference\fix.md` for the mechanics. Do not treat a
191
+ single pass through this document as complete while `loop` is active; hand
192
+ control to that document, and do not stop at Output.
193
+
194
+ That hand-off applies when this document is entered directly. When a loop round
195
+ is already running and has handed this document its target, the round owns the
196
+ loop: end at Output with the findings report and return those findings to
197
+ `reference\loop.md`'s gate sequence, rather than handing control to that
198
+ document again from here.
199
+
200
+ When `loop` was not passed, skip this section.
@@ -1,14 +1,14 @@
1
1
  # fresh-branch
2
2
 
3
- Creates a new branch from fresh-fetched `origin/main` inside an isolated worktree under `<repo-root>/.claude/worktrees/<agent>/<branch-name>`. Does not push, open a PR, or run `checkout -b` in the caller tree.
3
+ Creates a new branch from fresh-fetched `origin/main` inside an isolated worktree under a configured root. Default: `<repo-root>/.claude/worktrees/<agent>/<branch-name>`. Optional absolute `--worktree-root` relocates the root (agent and branch still nest under it). Relative roots and path escape fail closed. Does not push, open a PR, or run `checkout -b` in the caller tree.
4
4
 
5
5
  ## Key files
6
6
 
7
7
  | File | Purpose |
8
8
  |---|---|
9
- | `SKILL.md` | Phases, checklist, execute-vs-read for the CLI, gotchas |
10
- | `scripts/create_fresh_branch.py` | Deterministic CLI: fetch base, `git worktree add -b`, JSON stdout |
9
+ | `SKILL.md` | Phases, checklist, configured-root contract, execute-vs-read for the CLI, gotchas |
10
+ | `scripts/create_fresh_branch.py` | Deterministic CLI: resolve configured root (fail closed before fetch), fetch base, `git worktree add -b`, JSON stdout |
11
11
  | `scripts/fresh_branch_git_commands.py` | Git command helpers: fetch, ref checks, `git worktree add -b --no-track` |
12
- | `scripts/test_create_fresh_branch.py` | Behavioral tests with temporary git repos |
12
+ | `scripts/test_create_fresh_branch.py` | Behavioral tests with temporary git repos (default root, explicit root, escape, collisions) |
13
13
  | `scripts/test_fresh_branch_git_commands.py` | Behavioral tests for the git command helpers |
14
- | `scripts/fresh_branch_scripts_constants/` | Constants package (`fresh_branch_cli_constants`) for the CLI |
14
+ | `scripts/fresh_branch_scripts_constants/` | Constants package (`fresh_branch_cli_constants`) for CLI flags and error strings |
@@ -1,14 +1,14 @@
1
1
  ---
2
2
  name: fresh-branch
3
3
  description: >-
4
- Fresh git branch from origin/main in an isolated worktree under the repo's .claude/worktrees/ (never checkout -b in the caller tree).
4
+ Fresh git branch from origin/main in an isolated worktree under a configured root (default: <repo>/.claude/worktrees/<agent>/; never checkout -b in the caller tree).
5
5
  Triggers: fresh branch, new branch from main, /fresh-branch, start fresh, clean branch off main,
6
6
  worktree branch, branch in temp.
7
7
  ---
8
8
 
9
9
  # fresh-branch
10
10
 
11
- Creates a new branch from a fresh-fetched `origin/main` inside an isolated git worktree under the repository's `.claude/worktrees/<agent>/` root. Shared primitive: other skills invoke `/fresh-branch` when they need a clean branch without touching the caller's dirty tree.
11
+ Creates a new branch from a fresh-fetched `origin/main` inside an isolated git worktree under a **configured isolated root**. Default root is the repository's `.claude/worktrees/`; pass `--worktree-root` for an absolute alternate root (including outside the repo). Every worktree path must resolve under that root. Shared primitive: other skills invoke `/fresh-branch` when they need a clean branch without touching the caller's dirty tree.
12
12
 
13
13
  **Announce at start:** "Creating a fresh branch from origin/main."
14
14
 
@@ -62,15 +62,21 @@ Optional flags:
62
62
  | Flag | Role |
63
63
  |------|------|
64
64
  | `--repo <path>` | Source repo (default: current directory) |
65
- | `--agent <slug>` | Worktree-root segment: `claude`, `grok`, `cursor`, `codex`, … |
65
+ | `--agent <slug>` | Agent segment under the configured root: `claude`, `grok`, `cursor`, `codex`, … |
66
66
  | `--base <ref>` | Base ref (default: `origin/main`) |
67
+ | `--worktree-root <abs-path>` | Absolute isolated root for all worktrees (default: `<repo-root>/.claude/worktrees`) |
67
68
 
68
69
  Agent resolution inside the script: `--agent` → `FRESH_BRANCH_AGENT` env → host markers → `claude`.
69
70
 
70
- Worktree path:
71
+ Configured-root contract:
71
72
 
72
- - `<repo-root>/.claude/worktrees/<agent>/<branch-name>`, on every platform
73
+ - Default configured root: `<repo-root>/.claude/worktrees`
74
+ - Explicit `--worktree-root` must be absolute; a relative value fails closed with a deterministic error before any git fetch
75
+ - Absolute roots outside the repository are legal; the agent and branch still nest under that root
76
+ - Worktree path: `<configured-root>/<agent>/<branch-name>`, on every platform
77
+ - Every allocated path must resolve under the configured root; traversal that escapes fails closed
73
78
  - If the path exists, the script suffixes `-2`, `-3`, …
79
+ - Permission-rule cleanup (`stale_worktree_rule_sweep`) only walks `~/.claude/worktrees`; external `--worktree-root` trees are outside that sweep until a follow-up ships
74
80
 
75
81
  On exit 0, stdout is one JSON object:
76
82
 
@@ -100,10 +106,12 @@ Further edits for the new branch belong in `worktree_path`, not in the caller's
100
106
 
101
107
  ## Gotchas
102
108
 
103
- - **Dirty caller cwd blocks `checkout -b` and pollutes the tree.** Phase 3 always uses `git worktree add -b` into `<repo-root>/.claude/worktrees/<agent>/…`. If you reconstruct Phase 3 by hand with `checkout -b` in the session cwd, local modifications block the checkout and leave the user on a half-switched branch.
109
+ - **Dirty caller cwd blocks `checkout -b` and pollutes the tree.** Phase 3 always uses `git worktree add -b` into `<configured-root>/<agent>/…`. If you reconstruct Phase 3 by hand with `checkout -b` in the session cwd, local modifications block the checkout and leave the user on a half-switched branch.
104
110
  - **Caller HEAD must stay put.** After success, the original repo's checked-out branch and dirty files are unchanged; only the new worktree has the new branch.
105
111
  - **Branch name collision.** If the branch already exists, the script exits non-zero with `{"error":...}`. Pick a new name; do not delete remote branches unless the user asks.
106
112
  - **Path already occupied.** A leftover folder at the preferred worktree path gets a numeric suffix (`-2`, …); report the path from JSON, not the path you assumed.
113
+ - **Relative `--worktree-root` is refused before fetch.** Only an absolute path is accepted; the default root applies when the flag is omitted. Validation runs before `git fetch`, so a bad root never touches the network or remote-tracking refs.
114
+ - **External roots and stale permission rules.** `stale_worktree_rule_sweep` only walks `~/.claude/worktrees`. Edit rules granted under an external `--worktree-root` are not swept when that tree is deleted.
107
115
 
108
116
  ## File index
109
117
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Create a fresh branch in an isolated git worktree under the repository.
2
+ """Create a fresh branch in an isolated git worktree under a configured root.
3
3
 
4
4
  ::
5
5
 
@@ -9,8 +9,9 @@
9
9
 
10
10
  Never runs ``git checkout -b`` in the caller's working tree. Fetches the base
11
11
  ref, then ``git worktree add -b --no-track`` into
12
- ``<repo-root>/.claude/worktrees/<agent>/<branch>``. Exit 0 prints success JSON;
13
- any failure prints ``{"error": ...}`` and exits non-zero.
12
+ ``<configured-root>/<agent>/<branch>`` (default configured root:
13
+ ``<repo-root>/.claude/worktrees``). Exit 0 prints success JSON; any failure
14
+ prints ``{"error": ...}`` and exits non-zero.
14
15
  """
15
16
 
16
17
  from __future__ import annotations
@@ -33,6 +34,8 @@ from fresh_branch_scripts_constants.fresh_branch_cli_constants import (
33
34
  AGENT_SLUG_PATTERN,
34
35
  ALL_AGENT_DETECTION_MARKERS,
35
36
  ALL_REPOSITORY_WORKTREE_ROOT_PARTS,
37
+ CLI_FLAG_WORKTREE_ROOT,
38
+ CLI_HELP_WORKTREE_ROOT,
36
39
  DEFAULT_AGENT_SLUG,
37
40
  DEFAULT_BASE_REF,
38
41
  ERROR_AGENT_SLUG_INVALID,
@@ -41,6 +44,7 @@ from fresh_branch_scripts_constants.fresh_branch_cli_constants import (
41
44
  ERROR_CLI_ARGUMENTS,
42
45
  ERROR_UNIQUE_PATH_EXHAUSTED,
43
46
  ERROR_WORKTREE_PATH_OUTSIDE_ROOT,
47
+ ERROR_WORKTREE_ROOT_NOT_ABSOLUTE,
44
48
  EXIT_CODE_FAILURE,
45
49
  EXIT_CODE_SUCCESS,
46
50
  FRESH_BRANCH_AGENT_ENV_VAR,
@@ -91,26 +95,79 @@ def _detect_agent_slug_from_environment() -> str:
91
95
  return DEFAULT_AGENT_SLUG
92
96
 
93
97
 
94
- def resolve_agent_worktree_root(repo_root: Path, agent_slug: str) -> Path:
95
- """Return the repository's per-agent worktree root.
98
+ def resolve_configured_worktree_root(
99
+ repo_root: Path,
100
+ maybe_worktree_root: str | None = None,
101
+ ) -> Path:
102
+ """Return the isolated root that every worktree path must stay under.
103
+
104
+ ::
105
+
106
+ resolve_configured_worktree_root(Path("/srv/app"))
107
+ # ok: /srv/app/.claude/worktrees
108
+ resolve_configured_worktree_root(Path("/srv/app"), "/tmp/isolated")
109
+ # ok: /tmp/isolated
110
+ resolve_configured_worktree_root(Path("/srv/app"), "relative/path")
111
+ # flag: ValueError --worktree-root must be an absolute path
112
+
113
+ When ``maybe_worktree_root`` is omitted, the default root is the repository's
114
+ ``.claude/worktrees`` directory. An explicit root must be absolute; it may
115
+ sit outside the repository.
116
+
117
+ Args:
118
+ repo_root: Absolute path of the repository the branch comes from.
119
+ maybe_worktree_root: Optional absolute configured root, or None.
120
+
121
+ Returns:
122
+ Resolved absolute configured root directory.
123
+
124
+ Raises:
125
+ ValueError: When ``maybe_worktree_root`` is set and is not absolute.
126
+ """
127
+ if maybe_worktree_root is None:
128
+ return repo_root.joinpath(*ALL_REPOSITORY_WORKTREE_ROOT_PARTS).resolve()
129
+ cleaned_worktree_root = maybe_worktree_root.strip()
130
+ if not cleaned_worktree_root:
131
+ return repo_root.joinpath(*ALL_REPOSITORY_WORKTREE_ROOT_PARTS).resolve()
132
+ configured_root = Path(cleaned_worktree_root)
133
+ if not configured_root.is_absolute():
134
+ raise ValueError(ERROR_WORKTREE_ROOT_NOT_ABSOLUTE)
135
+ return configured_root.resolve()
136
+
137
+
138
+ def resolve_agent_worktree_root(
139
+ repo_root: Path,
140
+ agent_slug: str,
141
+ maybe_worktree_root: str | None = None,
142
+ ) -> Path:
143
+ """Return the per-agent directory under the configured worktree root.
96
144
 
97
145
  ::
98
146
 
99
147
  # repo_root=/srv/app, agent_slug=grok
100
148
  resolve_agent_worktree_root(Path("/srv/app"), "grok")
101
149
  # -> /srv/app/.claude/worktrees/grok
150
+ resolve_agent_worktree_root(
151
+ Path("/srv/app"), "grok", maybe_worktree_root="/tmp/isolated",
152
+ )
153
+ # -> /tmp/isolated/grok
102
154
 
103
- Every worktree sits under the repository it branches from, so a worktree
104
- travels with its repository and each agent keeps its own subdirectory.
155
+ Each agent keeps its own subdirectory under the configured root so concurrent
156
+ hosts do not share worktree folders.
105
157
 
106
158
  Args:
107
159
  repo_root: Absolute path of the repository the branch comes from.
108
160
  agent_slug: Short host label (one path segment).
161
+ maybe_worktree_root: Optional absolute configured root, or None.
109
162
 
110
163
  Returns:
111
164
  Directory that should hold per-branch worktree folders.
112
165
  """
113
- return repo_root.joinpath(*ALL_REPOSITORY_WORKTREE_ROOT_PARTS, agent_slug)
166
+ configured_root = resolve_configured_worktree_root(
167
+ repo_root,
168
+ maybe_worktree_root=maybe_worktree_root,
169
+ )
170
+ return configured_root / agent_slug
114
171
 
115
172
 
116
173
  def resolve_unique_worktree_path(preferred_path: Path) -> Path:
@@ -170,38 +227,55 @@ def create_fresh_branch(
170
227
  repo_path: Path,
171
228
  agent_slug: str,
172
229
  base_ref: str,
230
+ maybe_worktree_root: str | None = None,
173
231
  ) -> dict[str, str]:
174
232
  """Fetch base_ref and create an isolated worktree branch with no upstream.
175
233
 
176
234
  ::
177
235
 
178
236
  create_fresh_branch("fix/x", Path("."), "grok", "origin/main")
237
+ # worktree under <repo>/.claude/worktrees/grok/fix/x
238
+ create_fresh_branch(
239
+ "fix/x", Path("."), "grok", "origin/main",
240
+ maybe_worktree_root="/tmp/isolated",
241
+ )
242
+ # worktree under /tmp/isolated/grok/fix/x
179
243
 
180
- The caller's working tree is never checked out. The worktree lands under
181
- the repository's ``.claude/worktrees/<agent>/<branch>``, suffixed ``-2``,
182
- ``-3``, when that path is already taken.
244
+ The caller's working tree is never checked out. The configured root is
245
+ validated before any git fetch so a relative ``--worktree-root`` fails
246
+ closed with no network I/O. The worktree lands under
247
+ ``<configured-root>/<agent>/<branch>``, suffixed ``-2``, ``-3``, … when
248
+ that path is already taken. Every allocated path must resolve beneath the
249
+ configured root.
183
250
 
184
251
  Args:
185
252
  branch_name: Branch to create; must be a safe relative path.
186
253
  repo_path: Any path inside the repository the branch comes from.
187
254
  agent_slug: Short host label naming the worktree-root subdirectory.
188
255
  base_ref: Ref the branch starts from, such as ``origin/main``.
256
+ maybe_worktree_root: Optional absolute configured root, or None for
257
+ the default ``<repo>/.claude/worktrees``.
189
258
 
190
259
  Returns:
191
260
  The success payload: branch, worktree_path, base_ref, base_commit,
192
261
  agent, and repo_root.
193
262
 
194
263
  Raises:
195
- ValueError: When the branch name or the agent slug is unsafe.
264
+ ValueError: When the branch name, agent slug, or worktree root is unsafe.
196
265
  RuntimeError: When git refuses the fetch or the worktree add.
197
266
  """
198
267
  cleaned_branch = _require_safe_branch_name(branch_name)
199
268
  normalized_agent_slug = _normalize_agent_slug(agent_slug)
200
- resolved_base_ref, repo_root, base_commit = _resolve_branch_base(
201
- repo_path, base_ref,
269
+ repo_root = resolve_repo_root(repo_path)
270
+ configured_root = resolve_configured_worktree_root(
271
+ repo_root,
272
+ maybe_worktree_root=maybe_worktree_root,
202
273
  )
274
+ resolved_base_ref, base_commit = _fetch_resolved_base(repo_root, base_ref)
203
275
  worktree_path = _allocate_worktree_path(
204
- cleaned_branch, normalized_agent_slug, repo_root,
276
+ cleaned_branch,
277
+ normalized_agent_slug,
278
+ configured_root,
205
279
  )
206
280
  create_worktree_branch(
207
281
  repo_root,
@@ -215,15 +289,11 @@ def create_fresh_branch(
215
289
  )
216
290
 
217
291
 
218
- def _resolve_branch_base(
219
- repo_path: Path,
220
- base_ref: str,
221
- ) -> tuple[str, Path, str]:
292
+ def _fetch_resolved_base(repo_root: Path, base_ref: str) -> tuple[str, str]:
222
293
  resolved_base_ref = normalize_base_ref(base_ref)
223
- repo_root = resolve_repo_root(repo_path)
224
294
  fetch_base_ref(repo_root, resolved_base_ref)
225
295
  base_commit = resolve_base_commit(repo_root, resolved_base_ref)
226
- return resolved_base_ref, repo_root, base_commit
296
+ return resolved_base_ref, base_commit
227
297
 
228
298
 
229
299
  def main() -> int:
@@ -233,14 +303,7 @@ def main() -> int:
233
303
  Process exit code (0 success, 1 failure).
234
304
  """
235
305
  try:
236
- parsed_arguments = _parse_arguments()
237
- agent_slug = resolve_agent_slug(parsed_arguments.agent)
238
- success_payload = create_fresh_branch(
239
- branch_name=parsed_arguments.branch_name,
240
- repo_path=Path(parsed_arguments.repo).resolve(),
241
- agent_slug=agent_slug,
242
- base_ref=parsed_arguments.base,
243
- )
306
+ success_payload = _create_fresh_branch_from_cli_arguments()
244
307
  print(json.dumps(success_payload))
245
308
  return EXIT_CODE_SUCCESS
246
309
  except (ValueError, RuntimeError, OSError) as error:
@@ -248,6 +311,18 @@ def main() -> int:
248
311
  return EXIT_CODE_FAILURE
249
312
 
250
313
 
314
+ def _create_fresh_branch_from_cli_arguments() -> dict[str, str]:
315
+ parsed_arguments = _parse_arguments()
316
+ agent_slug = resolve_agent_slug(parsed_arguments.agent)
317
+ return create_fresh_branch(
318
+ branch_name=parsed_arguments.branch_name,
319
+ repo_path=Path(parsed_arguments.repo).resolve(),
320
+ agent_slug=agent_slug,
321
+ base_ref=parsed_arguments.base,
322
+ maybe_worktree_root=parsed_arguments.worktree_root,
323
+ )
324
+
325
+
251
326
  def _require_safe_branch_name(branch_name: str) -> str:
252
327
  cleaned_branch = branch_name.strip()
253
328
  if not cleaned_branch:
@@ -257,19 +332,21 @@ def _require_safe_branch_name(branch_name: str) -> str:
257
332
 
258
333
 
259
334
  def _allocate_worktree_path(
260
- branch_name: str, agent_slug: str, repo_root: Path,
335
+ branch_name: str,
336
+ agent_slug: str,
337
+ configured_root: Path,
261
338
  ) -> Path:
262
- agent_worktree_root = resolve_agent_worktree_root(repo_root, agent_slug)
339
+ agent_worktree_root = configured_root / agent_slug
263
340
  agent_worktree_root.mkdir(parents=True, exist_ok=True)
264
341
  preferred_path = agent_worktree_root / branch_name
265
- _assert_path_is_under_agent_root(
342
+ _assert_path_is_under_configured_root(
266
343
  candidate_path=preferred_path,
267
- agent_worktree_root=agent_worktree_root,
344
+ configured_root=configured_root,
268
345
  )
269
346
  worktree_path = resolve_unique_worktree_path(preferred_path)
270
- _assert_path_is_under_agent_root(
347
+ _assert_path_is_under_configured_root(
271
348
  candidate_path=worktree_path,
272
- agent_worktree_root=agent_worktree_root,
349
+ configured_root=configured_root,
273
350
  )
274
351
  return worktree_path
275
352
 
@@ -309,12 +386,12 @@ def _validate_branch_name_for_worktree_path(branch_name: str) -> None:
309
386
  assert_git_accepts_branch_name(branch_name)
310
387
 
311
388
 
312
- def _assert_path_is_under_agent_root(
389
+ def _assert_path_is_under_configured_root(
313
390
  candidate_path: Path,
314
- agent_worktree_root: Path,
391
+ configured_root: Path,
315
392
  ) -> None:
316
393
  resolved_candidate = candidate_path.resolve()
317
- resolved_root = agent_worktree_root.resolve()
394
+ resolved_root = configured_root.resolve()
318
395
  if resolved_candidate == resolved_root:
319
396
  return
320
397
  try:
@@ -342,13 +419,19 @@ def _parse_arguments() -> argparse.Namespace:
342
419
  parser.add_argument(
343
420
  "--agent",
344
421
  default=None,
345
- help="Host label for .claude/worktrees/<agent>/ (default: detect from environment).",
422
+ help="Host label for <configured-root>/<agent>/ (default: detect from environment).",
346
423
  )
347
424
  parser.add_argument(
348
425
  "--base",
349
426
  default=DEFAULT_BASE_REF,
350
427
  help=f"Base ref to fetch and branch from (default: {DEFAULT_BASE_REF}).",
351
428
  )
429
+ parser.add_argument(
430
+ CLI_FLAG_WORKTREE_ROOT,
431
+ default=None,
432
+ dest="worktree_root",
433
+ help=CLI_HELP_WORKTREE_ROOT,
434
+ )
352
435
  try:
353
436
  return parser.parse_args()
354
437
  except SystemExit as exit_error:
@@ -1,7 +1,7 @@
1
1
  """Constants for the fresh-branch worktree creator.
2
2
 
3
- Groups: default refs, agent detection, path layout, JSON payload keys,
4
- exit codes, and unique-path suffix limits.
3
+ Groups: default refs, agent detection, path layout, CLI flags, JSON payload
4
+ keys, exit codes, and unique-path suffix limits.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
@@ -25,6 +25,12 @@ PATH_SEGMENT_PARENT = ".."
25
25
 
26
26
  ALL_REPOSITORY_WORKTREE_ROOT_PARTS = (".claude", "worktrees")
27
27
 
28
+ CLI_FLAG_WORKTREE_ROOT = "--worktree-root"
29
+ CLI_HELP_WORKTREE_ROOT = (
30
+ "Absolute isolated root for worktrees "
31
+ "(default: <repo>/.claude/worktrees). Agent and branch nest under it."
32
+ )
33
+
28
34
  MAXIMUM_UNIQUE_PATH_ATTEMPTS = 100
29
35
  UNIQUE_PATH_SUFFIX_START = 2
30
36
 
@@ -60,7 +66,8 @@ ERROR_BRANCH_NAME_REQUIRED = "branch name is required"
60
66
  ERROR_BRANCH_NAME_UNSAFE = (
61
67
  "branch name must be a relative path without '.' or '..' segments"
62
68
  )
63
- ERROR_WORKTREE_PATH_OUTSIDE_ROOT = "worktree path escapes agent root: %s"
69
+ ERROR_WORKTREE_PATH_OUTSIDE_ROOT = "worktree path escapes configured root: %s"
70
+ ERROR_WORKTREE_ROOT_NOT_ABSOLUTE = "--worktree-root must be an absolute path"
64
71
  ERROR_CLI_ARGUMENTS = "invalid or missing command-line arguments"
65
72
  ERROR_AGENT_SLUG_INVALID = "agent slug must be lowercase letters, digits, or hyphens"
66
73
  ERROR_REPO_NOT_GIT = "path is not inside a git repository: %s"