claude-dev-env 2.7.0 → 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 (88) 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/pr-loop/worker-spawn.md +3 -1
  6. package/_shared/process-tree/CLAUDE.md +41 -0
  7. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/__init__.py +1 -0
  8. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +27 -0
  9. package/_shared/process-tree/scripts/process_tree_kill.py +141 -0
  10. package/_shared/process-tree/scripts/pyproject.toml +16 -0
  11. package/_shared/process-tree/scripts/test_process_tree_kill.py +278 -0
  12. package/agents/code-quality-agent.md +6 -5
  13. package/agents/deep-research.md +7 -24
  14. package/agents/docs-agent.md +1 -27
  15. package/agents/issue-tracker.md +1 -7
  16. package/agents/skill-writer-agent.md +1 -2
  17. package/agents/test_agent_frontmatter.py +309 -12
  18. package/hooks/blocking/CLAUDE.md +2 -0
  19. package/hooks/blocking/fable_spawn_gate.py +187 -0
  20. package/hooks/blocking/piped_pytest_blocker.py +1223 -0
  21. package/hooks/blocking/plain_language_blocker.py +287 -15
  22. package/hooks/blocking/test_fable_spawn_gate.py +374 -0
  23. package/hooks/blocking/test_piped_pytest_blocker.py +587 -0
  24. package/hooks/blocking/test_plain_language_blocker.py +277 -2
  25. package/hooks/blocking/test_pre_tool_use_dispatcher.py +34 -2
  26. package/hooks/git-hooks/CLAUDE.md +2 -2
  27. package/hooks/git-hooks/git_hooks_constants/__init__.py +28 -0
  28. package/hooks/git-hooks/pre_push.py +343 -54
  29. package/hooks/git-hooks/test_pre_push.py +852 -6
  30. package/hooks/hooks.json +9 -19
  31. package/hooks/hooks_constants/CLAUDE.md +2 -0
  32. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -0
  33. package/hooks/hooks_constants/fable_spawn_gate_constants.py +62 -0
  34. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +360 -0
  35. package/hooks/hooks_constants/plain_language_blocker_constants.py +64 -1
  36. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +10 -0
  37. package/hooks/hooks_constants/shell_command_segments.py +1 -1
  38. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -0
  39. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +27 -0
  40. package/hooks/hooks_constants/test_prose_metrics_parity.py +124 -0
  41. package/package.json +1 -1
  42. package/rules/CLAUDE.md +1 -0
  43. package/rules/ask-user-question-required.md +26 -0
  44. package/rules/claims-as-quotes.md +65 -0
  45. package/scripts/CLAUDE.md +4 -4
  46. package/scripts/_code_review_test_support.py +6 -0
  47. package/scripts/check.ps1 +18 -5
  48. package/scripts/claude_chain_runner.py +203 -31
  49. package/scripts/codec_forwarding_test_support.py +2 -0
  50. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -4
  51. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +38 -0
  52. package/scripts/dev_env_scripts_constants/code_review_constants.py +403 -2
  53. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +83 -13
  54. package/scripts/grok_headless_runner.py +148 -18
  55. package/scripts/resolve_worker_spawn.py +56 -10
  56. package/scripts/spawn_grok_batch.py +81 -23
  57. package/scripts/test_claude_chain_runner.py +358 -0
  58. package/scripts/test_grok_headless_runner.py +547 -10
  59. package/scripts/test_invoke_code_review.py +298 -0
  60. package/scripts/test_resolve_worker_spawn.py +185 -15
  61. package/scripts/test_spawn_grok_batch.py +326 -22
  62. package/scripts/tests/CLAUDE.md +1 -0
  63. package/scripts/tests/test_grok_worker_constants.py +59 -0
  64. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +46 -0
  65. package/skills/autoconverge/workflow/converge.contract.test.mjs +133 -8
  66. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +107 -1
  67. package/skills/autoconverge/workflow/converge.mjs +113 -31
  68. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -4
  69. package/skills/codex-review/scripts/codex_usage_probe.py +20 -33
  70. package/skills/codex-review/scripts/run_codex_review.py +16 -64
  71. package/skills/codex-review/scripts/test_codex_usage_probe.py +46 -41
  72. package/skills/codex-review/scripts/test_run_codex_review.py +1 -33
  73. package/skills/e-code-review/SKILL.md +9 -8
  74. package/skills/e-code-review/reference/fix.md +29 -7
  75. package/skills/e-code-review/reference/loop.md +230 -14
  76. package/skills/e-code-review/reference/low.md +33 -15
  77. package/skills/e-code-review/reference/medium.md +55 -21
  78. package/skills/e-code-review/reference/xhigh.md +30 -12
  79. package/skills/fresh-branch/CLAUDE.md +5 -5
  80. package/skills/fresh-branch/SKILL.md +14 -6
  81. package/skills/fresh-branch/scripts/create_fresh_branch.py +122 -39
  82. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +10 -3
  83. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +251 -0
  84. package/skills/grok-spawn/SKILL.md +10 -3
  85. package/skills/grok-spawn/reference/flag-profiles.md +3 -1
  86. package/skills/orchestrator/SKILL.md +4 -1
  87. package/skills/orchestrator-refresh/SKILL.md +5 -1
  88. package/skills/team-advisor/SKILL.md +4 -1
@@ -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"
@@ -17,9 +17,12 @@ if str(SCRIPTS_DIRECTORY) not in sys.path:
17
17
  sys.path.insert(0, str(SCRIPTS_DIRECTORY))
18
18
 
19
19
  from fresh_branch_scripts_constants.fresh_branch_cli_constants import (
20
+ ALL_REPOSITORY_WORKTREE_ROOT_PARTS,
21
+ CLI_FLAG_WORKTREE_ROOT,
20
22
  DEFAULT_AGENT_SLUG,
21
23
  DEFAULT_BASE_REF,
22
24
  ERROR_CLI_ARGUMENTS,
25
+ ERROR_WORKTREE_ROOT_NOT_ABSOLUTE,
23
26
  EXIT_CODE_FAILURE,
24
27
  EXIT_CODE_SUCCESS,
25
28
  FRESH_BRANCH_AGENT_ENV_VAR,
@@ -244,6 +247,40 @@ class TestResolveAgentSlug:
244
247
  module.resolve_agent_slug("../evil")
245
248
 
246
249
 
250
+ class TestResolveConfiguredWorktreeRoot:
251
+ def should_default_to_repo_claude_worktrees(
252
+ self,
253
+ tmp_path: Path,
254
+ ) -> None:
255
+ module = load_create_fresh_branch_module()
256
+ configured_root = module.resolve_configured_worktree_root(tmp_path)
257
+ expected_root = tmp_path.joinpath(*ALL_REPOSITORY_WORKTREE_ROOT_PARTS)
258
+ assert configured_root == expected_root.resolve()
259
+
260
+ def should_accept_an_absolute_root_outside_the_repo(
261
+ self,
262
+ tmp_path: Path,
263
+ ) -> None:
264
+ module = load_create_fresh_branch_module()
265
+ external_root = (tmp_path / "external-isolated").resolve()
266
+ configured_root = module.resolve_configured_worktree_root(
267
+ tmp_path / "repo",
268
+ maybe_worktree_root=str(external_root),
269
+ )
270
+ assert configured_root == external_root
271
+
272
+ def should_reject_a_relative_worktree_root(
273
+ self,
274
+ tmp_path: Path,
275
+ ) -> None:
276
+ module = load_create_fresh_branch_module()
277
+ with pytest.raises(ValueError, match="absolute path"):
278
+ module.resolve_configured_worktree_root(
279
+ tmp_path,
280
+ maybe_worktree_root="relative/worktrees",
281
+ )
282
+
283
+
247
284
  class TestResolveAgentWorktreeRoot:
248
285
  def should_nest_under_the_repository_claude_worktrees_directory(
249
286
  self,
@@ -263,6 +300,19 @@ class TestResolveAgentWorktreeRoot:
263
300
  assert grok_root != claude_root
264
301
  assert grok_root.parent == claude_root.parent
265
302
 
303
+ def should_nest_agent_under_an_explicit_configured_root(
304
+ self,
305
+ tmp_path: Path,
306
+ ) -> None:
307
+ module = load_create_fresh_branch_module()
308
+ external_root = (tmp_path / "isolated-root").resolve()
309
+ agent_root = module.resolve_agent_worktree_root(
310
+ tmp_path / "repo",
311
+ "grok",
312
+ maybe_worktree_root=str(external_root),
313
+ )
314
+ assert agent_root == external_root / "grok"
315
+
266
316
 
267
317
  class TestResolveUniqueWorktreePath:
268
318
  def should_return_preferred_when_missing(self, tmp_path: Path) -> None:
@@ -358,6 +408,146 @@ class TestBranchNamePathSafety:
358
408
  assert not (tmp_path / "unused-repo").exists()
359
409
 
360
410
 
411
+ class TestConfiguredWorktreeRootIntegration:
412
+ def should_create_worktree_under_explicit_absolute_root(
413
+ self,
414
+ tmp_path: Path,
415
+ monkeypatch: pytest.MonkeyPatch,
416
+ ) -> None:
417
+ module = load_create_fresh_branch_module()
418
+ repository_path = build_repo_with_origin(tmp_path / "repo")
419
+ configured_root = (tmp_path / "isolated-worktrees").resolve()
420
+ caller_head_before = read_head_branch(repository_path)
421
+ caller_commit_before = read_head_commit(repository_path)
422
+ success_payload = module.create_fresh_branch(
423
+ branch_name="fix/external-root",
424
+ repo_path=repository_path,
425
+ agent_slug="grok",
426
+ base_ref=DEFAULT_BASE_REF,
427
+ maybe_worktree_root=str(configured_root),
428
+ )
429
+ worktree_path = Path(success_payload[PAYLOAD_KEY_WORKTREE_PATH])
430
+ assert worktree_path == configured_root / "grok" / "fix" / "external-root"
431
+ assert worktree_path.is_dir()
432
+ assert worktree_path.resolve().is_relative_to(configured_root)
433
+ assert not (
434
+ repository_path.resolve()
435
+ / ".claude"
436
+ / "worktrees"
437
+ / "grok"
438
+ / "fix"
439
+ / "external-root"
440
+ ).exists()
441
+ assert read_head_branch(repository_path) == caller_head_before
442
+ assert read_head_commit(repository_path) == caller_commit_before
443
+
444
+ def should_suffix_when_path_exists_under_configured_root(
445
+ self,
446
+ tmp_path: Path,
447
+ monkeypatch: pytest.MonkeyPatch,
448
+ ) -> None:
449
+ module = load_create_fresh_branch_module()
450
+ repository_path = build_repo_with_origin(tmp_path / "repo")
451
+ configured_root = (tmp_path / "isolated-worktrees").resolve()
452
+ occupied_path = configured_root / "claude" / "fix" / "collision"
453
+ occupied_path.mkdir(parents=True)
454
+ success_payload = module.create_fresh_branch(
455
+ branch_name="fix/collision",
456
+ repo_path=repository_path,
457
+ agent_slug="claude",
458
+ base_ref=DEFAULT_BASE_REF,
459
+ maybe_worktree_root=str(configured_root),
460
+ )
461
+ worktree_path = Path(success_payload[PAYLOAD_KEY_WORKTREE_PATH])
462
+ assert worktree_path == configured_root / "claude" / "fix" / "collision-2"
463
+ assert worktree_path.is_dir()
464
+
465
+ def should_reject_relative_worktree_root_without_creating_worktree(
466
+ self,
467
+ tmp_path: Path,
468
+ monkeypatch: pytest.MonkeyPatch,
469
+ ) -> None:
470
+ module = load_create_fresh_branch_module()
471
+ repository_path = build_repo_with_origin(tmp_path / "repo")
472
+ with pytest.raises(ValueError, match="absolute path") as raised_error:
473
+ module.create_fresh_branch(
474
+ branch_name="fix/relative-root",
475
+ repo_path=repository_path,
476
+ agent_slug="claude",
477
+ base_ref=DEFAULT_BASE_REF,
478
+ maybe_worktree_root="relative/worktrees",
479
+ )
480
+ assert str(raised_error.value) == ERROR_WORKTREE_ROOT_NOT_ABSOLUTE
481
+ assert not (repository_path / ".claude" / "worktrees").exists()
482
+
483
+ def should_reject_relative_worktree_root_before_fetch(
484
+ self,
485
+ tmp_path: Path,
486
+ monkeypatch: pytest.MonkeyPatch,
487
+ ) -> None:
488
+ module = load_create_fresh_branch_module()
489
+ repository_path = build_repo_with_origin(tmp_path / "repo")
490
+ all_fetch_calls: list[str] = []
491
+
492
+ def record_fetch(repo_root: Path, base_ref: str) -> None:
493
+ all_fetch_calls.append(base_ref)
494
+ raise AssertionError("fetch must not run for a relative worktree root")
495
+
496
+ monkeypatch.setattr(module, "fetch_base_ref", record_fetch)
497
+ with pytest.raises(ValueError, match="absolute path") as raised_error:
498
+ module.create_fresh_branch(
499
+ branch_name="fix/relative-root-before-fetch",
500
+ repo_path=repository_path,
501
+ agent_slug="claude",
502
+ base_ref=DEFAULT_BASE_REF,
503
+ maybe_worktree_root="relative/worktrees",
504
+ )
505
+ assert str(raised_error.value) == ERROR_WORKTREE_ROOT_NOT_ABSOLUTE
506
+ assert all_fetch_calls == []
507
+
508
+ def should_fetch_base_before_creating_worktree(
509
+ self,
510
+ tmp_path: Path,
511
+ monkeypatch: pytest.MonkeyPatch,
512
+ ) -> None:
513
+ module = load_create_fresh_branch_module()
514
+ repository_path = build_repo_with_origin(tmp_path / "repo")
515
+ all_call_names: list[str] = []
516
+ real_fetch = module.fetch_base_ref
517
+ real_create = module.create_worktree_branch
518
+
519
+ def record_fetch(
520
+ repo_root: Path,
521
+ base_ref: str,
522
+ ) -> None:
523
+ all_call_names.append("fetch")
524
+ real_fetch(repo_root, base_ref)
525
+
526
+ def record_create(
527
+ repo_root: Path,
528
+ branch_name: str,
529
+ worktree_path: Path,
530
+ base_ref: str,
531
+ ) -> None:
532
+ all_call_names.append("create")
533
+ real_create(
534
+ repo_root,
535
+ branch_name=branch_name,
536
+ worktree_path=worktree_path,
537
+ base_ref=base_ref,
538
+ )
539
+
540
+ monkeypatch.setattr(module, "fetch_base_ref", record_fetch)
541
+ monkeypatch.setattr(module, "create_worktree_branch", record_create)
542
+ module.create_fresh_branch(
543
+ branch_name="fix/fetch-order",
544
+ repo_path=repository_path,
545
+ agent_slug="claude",
546
+ base_ref=DEFAULT_BASE_REF,
547
+ )
548
+ assert all_call_names == ["fetch", "create"]
549
+
550
+
361
551
  class TestCreateFreshBranchIntegration:
362
552
  def should_create_worktree_branch_without_moving_caller_head(
363
553
  self,
@@ -670,3 +860,64 @@ class TestMainCli:
670
860
  assert "relative path" in error_payload[PAYLOAD_KEY_ERROR]
671
861
  assert not outside_target.exists()
672
862
  assert not (tmp_path / "unused-repo").exists()
863
+
864
+ def should_print_error_json_for_relative_worktree_root(
865
+ self,
866
+ tmp_path: Path,
867
+ monkeypatch: pytest.MonkeyPatch,
868
+ capsys: pytest.CaptureFixture[str],
869
+ ) -> None:
870
+ module = load_create_fresh_branch_module()
871
+ repository_path = build_repo_with_origin(tmp_path / "repo")
872
+ monkeypatch.setattr(
873
+ sys,
874
+ "argv",
875
+ [
876
+ "create_fresh_branch.py",
877
+ "--branch-name",
878
+ "feat/relative-root",
879
+ "--repo",
880
+ str(repository_path),
881
+ "--agent",
882
+ "claude",
883
+ CLI_FLAG_WORKTREE_ROOT,
884
+ "relative/worktrees",
885
+ ],
886
+ )
887
+ exit_code = module.main()
888
+ captured = capsys.readouterr()
889
+ assert exit_code == EXIT_CODE_FAILURE
890
+ error_payload = json.loads(captured.out)
891
+ assert error_payload[PAYLOAD_KEY_ERROR] == ERROR_WORKTREE_ROOT_NOT_ABSOLUTE
892
+
893
+ def should_print_success_json_for_absolute_worktree_root(
894
+ self,
895
+ tmp_path: Path,
896
+ monkeypatch: pytest.MonkeyPatch,
897
+ capsys: pytest.CaptureFixture[str],
898
+ ) -> None:
899
+ module = load_create_fresh_branch_module()
900
+ repository_path = build_repo_with_origin(tmp_path / "repo")
901
+ configured_root = (tmp_path / "cli-isolated").resolve()
902
+ monkeypatch.setattr(
903
+ sys,
904
+ "argv",
905
+ [
906
+ "create_fresh_branch.py",
907
+ "--branch-name",
908
+ "feat/cli-root",
909
+ "--repo",
910
+ str(repository_path),
911
+ "--agent",
912
+ "grok",
913
+ CLI_FLAG_WORKTREE_ROOT,
914
+ str(configured_root),
915
+ ],
916
+ )
917
+ exit_code = module.main()
918
+ captured = capsys.readouterr()
919
+ assert exit_code == EXIT_CODE_SUCCESS
920
+ success_payload = json.loads(captured.out)
921
+ worktree_path = Path(success_payload[PAYLOAD_KEY_WORKTREE_PATH])
922
+ assert worktree_path == configured_root / "grok" / "feat" / "cli-root"
923
+ assert worktree_path.is_dir()
@@ -125,7 +125,6 @@ Shape:
125
125
  "tool_profile": "readonly",
126
126
  "timeout_seconds": 600,
127
127
  "is_repo_only": true,
128
- "max_turns": 8,
129
128
  "agent_name": null
130
129
  }
131
130
  ]
@@ -141,11 +140,19 @@ Shape:
141
140
  | `prompt_parts` | Ordered absolute paths to part files |
142
141
  | `cwd` | Working directory for that worker |
143
142
  | `tool_profile` | `readonly` or `build` |
144
- | `timeout_seconds` | Per-worker timeout (default 600) |
143
+ | `timeout_seconds` | Per-worker timeout (default 600, ceiling 5400). A spec asking for more is refused |
145
144
  | `is_repo_only` | Readonly only: when true, also pass `--disable-web-search` |
146
- | `max_turns` | Turn cap (default 8) |
147
145
  | `agent_name` | Optional `--agent` name, or `null` |
148
146
 
147
+ Workers run with no turn cap. The timeout is the only bound on a worker's
148
+ length, and a worker that hits it is killed with its whole process tree and
149
+ reported as `timeout`.
150
+
151
+ A worker entry accepts these fields and no others. Any other key fails the
152
+ load with an error naming the stray key and listing the accepted set, so a
153
+ misspelled field shows up at startup rather than passing for a setting that
154
+ took effect.
155
+
149
156
  Put the spec file under the run state directory (or any path you pass to
150
157
  `--spec`).
151
158
 
@@ -20,10 +20,12 @@ The headless runner always passes:
20
20
  | `--cwd` | Worker `cwd` from the batch spec |
21
21
  | `--output-format` | `json` |
22
22
  | `--always-approve` | present (auto-approve tool runs) |
23
- | `--max-turns` | Worker `max_turns` (default `8`) |
24
23
  | `--leader-socket` | Unique per-worker socket path under the run state dir |
25
24
  | `--debug-file` | Unique per-worker debug log (batch launcher adds this) |
26
25
 
26
+ The runner passes no `--max-turns`. Worker length is bounded by the per-worker
27
+ timeout alone. The preflight ping keeps its own single-turn cap.
28
+
27
29
  Optional:
28
30
 
29
31
  | Flag | When |
@@ -129,7 +129,10 @@ pending, or when the tool is `CronCreate`.
129
129
  whole lifecycle (its Lifecycle ownership section); executors only ever
130
130
  message the warm agent or report here, and an executor that finds the
131
131
  advisor unreachable reports that upward — it never spawns a
132
- replacement itself.
132
+ replacement itself. A **Fable**-tier attempt carries the exact token
133
+ `FABLE-SPAWN-AUTHORIZED` in its spawn prompt, as the protocol's
134
+ warm-up rule states; `hooks/blocking/fable_spawn_gate.py` denies a
135
+ fable spawn whose prompt lacks it.
133
136
  3. **Write the run artifacts** (next section) before the first spawn.
134
137
  4. **Activate status_gate** when the first open ledger task exists:
135
138
  `python scripts/status_gate.py set --status active`.