claude-dev-env 2.0.2 → 2.2.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.
- package/CLAUDE.md +1 -1
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/code_rules_shared.py +126 -47
- package/hooks/blocking/config/CLAUDE.md +2 -0
- package/hooks/blocking/config/verified_commit_context_constants.py +21 -0
- package/hooks/blocking/config/verified_commit_gate_output_constants.py +14 -0
- package/hooks/blocking/conftest.py +36 -30
- package/hooks/blocking/convergence_gate_blocker.py +76 -8
- package/hooks/blocking/plain_language_blocker.py +8 -0
- package/hooks/blocking/state_description_blocker.py +4 -1
- package/hooks/blocking/test_code_rules_shared.py +120 -20
- package/hooks/blocking/test_convergence_gate_blocker.py +146 -0
- package/hooks/blocking/test_plain_language_blocker.py +15 -0
- package/hooks/blocking/test_state_description_blocker.py +15 -0
- package/hooks/blocking/test_verified_commit_config_bootstrap.py +18 -0
- package/hooks/blocking/test_verified_commit_gate_additional_context.py +134 -0
- package/hooks/blocking/tests/test_verified_commit_gate.py +41 -0
- package/hooks/blocking/verified_commit_config_bootstrap.py +22 -10
- package/hooks/blocking/verified_commit_gate.py +113 -568
- package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +28 -0
- package/hooks/blocking/verified_commit_gate_parts/__init__.py +1 -0
- package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +174 -0
- package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +53 -0
- package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +80 -0
- package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +170 -0
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +205 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +10 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +94 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +17 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +38 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +71 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +61 -0
- package/hooks/hooks_constants/CLAUDE.md +4 -1
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +36 -0
- package/hooks/hooks_constants/harness_scratchpad_constants.py +10 -9
- package/hooks/hooks_constants/mypy_integration_constants.py +16 -0
- package/hooks/validators/mypy_integration.py +145 -24
- package/hooks/validators/python_antipattern_checks.py +16 -0
- package/hooks/validators/run_all_validators.py +9 -3
- package/hooks/validators/test_mypy_integration.py +154 -0
- package/hooks/validators/test_python_antipattern_checks.py +112 -1
- package/hooks/validators/test_run_all_validators_pretooluse.py +16 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/verified-commit-gate-skip.md +28 -0
- package/skills/_shared/pr-loop/CLAUDE.md +18 -13
- package/skills/_shared/pr-loop/portable-driver.md +150 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +3 -0
- package/skills/_shared/pr-loop/scripts/build_converge_task_list.py +310 -0
- package/skills/_shared/pr-loop/scripts/portable_converge_driver.py +1637 -0
- package/skills/_shared/pr-loop/scripts/select_converge_pacer.py +215 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +3 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/converge_task_list_constants.py +56 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/pacer_constants.py +47 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/portable_driver_constants.py +181 -0
- package/skills/_shared/pr-loop/scripts/test_build_converge_task_list.py +140 -0
- package/skills/_shared/pr-loop/scripts/test_portable_converge_driver.py +1119 -0
- package/skills/_shared/pr-loop/scripts/test_select_converge_pacer.py +207 -0
- package/skills/auditing-claude-config/CLAUDE.md +2 -1
- package/skills/auditing-claude-config/SKILL.md +114 -176
- package/skills/auditing-claude-config/reference/probe-hook.md +74 -0
- package/skills/autoconverge/CLAUDE.md +6 -3
- package/skills/autoconverge/SKILL.md +421 -346
- package/skills/autoconverge/reference/CLAUDE.md +1 -0
- package/skills/autoconverge/reference/convergence.md +5 -5
- package/skills/autoconverge/reference/copilot-findings.md +51 -0
- package/skills/autoconverge/reference/multi-pr.md +33 -2
- package/skills/autoconverge/reference/stop-conditions.md +9 -6
- package/skills/autoconverge/test_portable_pacer_gate.py +54 -0
- package/skills/closeout/SKILL.md +7 -9
- package/skills/copilot-finding-triage/SKILL.md +21 -11
- package/skills/copilot-review/CLAUDE.md +3 -2
- package/skills/copilot-review/SKILL.md +119 -155
- package/skills/copilot-review/templates/subagent-prompt.md +49 -0
- package/skills/fresh-branch/CLAUDE.md +6 -9
- package/skills/fresh-branch/SKILL.md +84 -33
- package/skills/fresh-branch/scripts/create_fresh_branch.py +445 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/__init__.py +1 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +74 -0
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +670 -0
- package/skills/pr-converge/CLAUDE.md +4 -3
- package/skills/pr-converge/SKILL.md +469 -422
- package/skills/pr-converge/reference/CLAUDE.md +1 -0
- package/skills/pr-converge/reference/multi-pr-orchestration.md +5 -1
- package/skills/pr-converge/reference/per-tick.md +51 -24
- package/skills/pr-converge/reference/progress-checklist.md +168 -0
- package/skills/pr-converge/test_portable_pacer_gate.py +67 -0
- package/skills/pr-converge/test_step5_host_branch.py +8 -6
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -3
- package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
- package/skills/privacy-hygiene/SKILL.md +68 -115
- package/skills/privacy-hygiene/reference/sweep-procedure.md +62 -0
- package/skills/session-log/CLAUDE.md +2 -1
- package/skills/session-log/SKILL.md +4 -26
- package/skills/session-log/templates/frontmatter.md +40 -0
- package/skills/skill-builder/CLAUDE.md +8 -7
- package/skills/skill-builder/SKILL.md +26 -11
- package/skills/skill-builder/references/CLAUDE.md +3 -1
- package/skills/skill-builder/references/delegation-map.md +21 -12
- package/skills/skill-builder/references/description-field.md +9 -11
- package/skills/skill-builder/references/deterministic-elements.md +218 -0
- package/skills/skill-builder/references/self-audit-checklist.md +62 -45
- package/skills/skill-builder/references/skill-modularity.md +8 -9
- package/skills/skill-builder/templates/CLAUDE.md +2 -2
- package/skills/skill-builder/templates/gap-analysis.md +15 -0
- package/skills/skill-builder/workflows/CLAUDE.md +5 -5
- package/skills/skill-builder/workflows/improve-skill.md +18 -9
- package/skills/skill-builder/workflows/new-skill.md +23 -15
- package/skills/skill-builder/workflows/polish-skill.md +28 -21
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
"""Behavioral tests for create_fresh_branch using real temporary git repos."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import ModuleType
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
|
|
16
|
+
if str(SCRIPTS_DIRECTORY) not in sys.path:
|
|
17
|
+
sys.path.insert(0, str(SCRIPTS_DIRECTORY))
|
|
18
|
+
|
|
19
|
+
from fresh_branch_scripts_constants.fresh_branch_cli_constants import (
|
|
20
|
+
DEFAULT_AGENT_SLUG,
|
|
21
|
+
DEFAULT_BASE_REF,
|
|
22
|
+
ERROR_CLI_ARGUMENTS,
|
|
23
|
+
EXIT_CODE_FAILURE,
|
|
24
|
+
EXIT_CODE_SUCCESS,
|
|
25
|
+
FRESH_BRANCH_AGENT_ENV_VAR,
|
|
26
|
+
PAYLOAD_KEY_AGENT,
|
|
27
|
+
PAYLOAD_KEY_BASE_COMMIT,
|
|
28
|
+
PAYLOAD_KEY_BASE_REF,
|
|
29
|
+
PAYLOAD_KEY_BRANCH,
|
|
30
|
+
PAYLOAD_KEY_ERROR,
|
|
31
|
+
PAYLOAD_KEY_REPO_ROOT,
|
|
32
|
+
PAYLOAD_KEY_WORKTREE_PATH,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
SCRIPT_PATH = SCRIPTS_DIRECTORY / "create_fresh_branch.py"
|
|
36
|
+
GIT_USER_NAME = "fresh-branch-test"
|
|
37
|
+
GIT_USER_EMAIL = "fresh-branch-test@example.com"
|
|
38
|
+
INITIAL_COMMIT_MESSAGE = "initial commit"
|
|
39
|
+
MAIN_BRANCH_NAME = "main"
|
|
40
|
+
REMOTE_NAME = "origin"
|
|
41
|
+
SEED_FILE_NAME = "README.md"
|
|
42
|
+
SEED_FILE_CONTENTS = "seed\n"
|
|
43
|
+
ALL_AGENT_ENV_MARKERS = (
|
|
44
|
+
FRESH_BRANCH_AGENT_ENV_VAR,
|
|
45
|
+
"CURSOR_AGENT",
|
|
46
|
+
"CURSOR_TRACE_ID",
|
|
47
|
+
"CODEX_HOME",
|
|
48
|
+
"CODEX_CI",
|
|
49
|
+
"GROK_AGENT",
|
|
50
|
+
"CLAUDECODE",
|
|
51
|
+
"CLAUDE_CODE_ENTRYPOINT",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_create_fresh_branch_module() -> ModuleType:
|
|
56
|
+
module_name = "create_fresh_branch_under_test"
|
|
57
|
+
spec = importlib.util.spec_from_file_location(module_name, SCRIPT_PATH)
|
|
58
|
+
assert spec is not None
|
|
59
|
+
assert spec.loader is not None
|
|
60
|
+
loaded_module = importlib.util.module_from_spec(spec)
|
|
61
|
+
sys.modules[module_name] = loaded_module
|
|
62
|
+
spec.loader.exec_module(loaded_module)
|
|
63
|
+
return loaded_module
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def clear_agent_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
67
|
+
for each_env_name in ALL_AGENT_ENV_MARKERS:
|
|
68
|
+
monkeypatch.delenv(each_env_name, raising=False)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run_git(
|
|
72
|
+
all_arguments: list[str],
|
|
73
|
+
working_directory: Path,
|
|
74
|
+
empty_hooks_path: Path | None = None,
|
|
75
|
+
) -> subprocess.CompletedProcess[str]:
|
|
76
|
+
environment = os.environ.copy()
|
|
77
|
+
git_arguments = list(all_arguments)
|
|
78
|
+
if empty_hooks_path is not None:
|
|
79
|
+
git_arguments = [
|
|
80
|
+
"-c",
|
|
81
|
+
f"core.hooksPath={empty_hooks_path}",
|
|
82
|
+
*git_arguments,
|
|
83
|
+
]
|
|
84
|
+
return subprocess.run(
|
|
85
|
+
["git", *git_arguments],
|
|
86
|
+
cwd=str(working_directory),
|
|
87
|
+
check=True,
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
env=environment,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def configure_git_identity(repository_path: Path, empty_hooks_path: Path) -> None:
|
|
95
|
+
run_git(
|
|
96
|
+
["config", "user.name", GIT_USER_NAME],
|
|
97
|
+
repository_path,
|
|
98
|
+
empty_hooks_path=empty_hooks_path,
|
|
99
|
+
)
|
|
100
|
+
run_git(
|
|
101
|
+
["config", "user.email", GIT_USER_EMAIL],
|
|
102
|
+
repository_path,
|
|
103
|
+
empty_hooks_path=empty_hooks_path,
|
|
104
|
+
)
|
|
105
|
+
run_git(
|
|
106
|
+
["config", "commit.gpgsign", "false"],
|
|
107
|
+
repository_path,
|
|
108
|
+
empty_hooks_path=empty_hooks_path,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_repo_with_origin(workspace_path: Path) -> Path:
|
|
113
|
+
workspace_path.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
empty_hooks_path = workspace_path / "empty-hooks"
|
|
115
|
+
empty_hooks_path.mkdir()
|
|
116
|
+
bare_origin = workspace_path / "origin.git"
|
|
117
|
+
run_git(
|
|
118
|
+
["init", "--bare", str(bare_origin)],
|
|
119
|
+
workspace_path,
|
|
120
|
+
empty_hooks_path=empty_hooks_path,
|
|
121
|
+
)
|
|
122
|
+
clone_path = workspace_path / "clone"
|
|
123
|
+
run_git(
|
|
124
|
+
["clone", str(bare_origin), str(clone_path)],
|
|
125
|
+
workspace_path,
|
|
126
|
+
empty_hooks_path=empty_hooks_path,
|
|
127
|
+
)
|
|
128
|
+
configure_git_identity(clone_path, empty_hooks_path=empty_hooks_path)
|
|
129
|
+
run_git(
|
|
130
|
+
["checkout", "-b", MAIN_BRANCH_NAME],
|
|
131
|
+
clone_path,
|
|
132
|
+
empty_hooks_path=empty_hooks_path,
|
|
133
|
+
)
|
|
134
|
+
(clone_path / SEED_FILE_NAME).write_text(SEED_FILE_CONTENTS, encoding="utf-8")
|
|
135
|
+
run_git(["add", SEED_FILE_NAME], clone_path, empty_hooks_path=empty_hooks_path)
|
|
136
|
+
run_git(
|
|
137
|
+
["commit", "-m", INITIAL_COMMIT_MESSAGE],
|
|
138
|
+
clone_path,
|
|
139
|
+
empty_hooks_path=empty_hooks_path,
|
|
140
|
+
)
|
|
141
|
+
run_git(
|
|
142
|
+
["push", "-u", REMOTE_NAME, MAIN_BRANCH_NAME],
|
|
143
|
+
clone_path,
|
|
144
|
+
empty_hooks_path=empty_hooks_path,
|
|
145
|
+
)
|
|
146
|
+
return clone_path
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def read_head_branch(repository_path: Path) -> str:
|
|
150
|
+
completed = run_git(["rev-parse", "--abbrev-ref", "HEAD"], repository_path)
|
|
151
|
+
return completed.stdout.strip()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def read_head_commit(repository_path: Path) -> str:
|
|
155
|
+
completed = run_git(["rev-parse", "HEAD"], repository_path)
|
|
156
|
+
return completed.stdout.strip()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class TestResolveAgentSlug:
|
|
160
|
+
def should_prefer_flag_over_environment(
|
|
161
|
+
self,
|
|
162
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
163
|
+
) -> None:
|
|
164
|
+
module = load_create_fresh_branch_module()
|
|
165
|
+
clear_agent_environment(monkeypatch)
|
|
166
|
+
monkeypatch.setenv(FRESH_BRANCH_AGENT_ENV_VAR, "codex")
|
|
167
|
+
assert module.resolve_agent_slug("Cursor") == "cursor"
|
|
168
|
+
|
|
169
|
+
def should_use_fresh_branch_agent_env_before_markers(
|
|
170
|
+
self,
|
|
171
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
172
|
+
) -> None:
|
|
173
|
+
module = load_create_fresh_branch_module()
|
|
174
|
+
clear_agent_environment(monkeypatch)
|
|
175
|
+
monkeypatch.setenv(FRESH_BRANCH_AGENT_ENV_VAR, "Grok")
|
|
176
|
+
monkeypatch.setenv("CURSOR_TRACE_ID", "1")
|
|
177
|
+
assert module.resolve_agent_slug(None) == "grok"
|
|
178
|
+
|
|
179
|
+
def should_detect_cursor_from_marker(
|
|
180
|
+
self,
|
|
181
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
182
|
+
) -> None:
|
|
183
|
+
module = load_create_fresh_branch_module()
|
|
184
|
+
clear_agent_environment(monkeypatch)
|
|
185
|
+
monkeypatch.setenv("CURSOR_TRACE_ID", "abc")
|
|
186
|
+
assert module.resolve_agent_slug(None) == "cursor"
|
|
187
|
+
|
|
188
|
+
def should_detect_codex_from_marker(
|
|
189
|
+
self,
|
|
190
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
191
|
+
) -> None:
|
|
192
|
+
module = load_create_fresh_branch_module()
|
|
193
|
+
clear_agent_environment(monkeypatch)
|
|
194
|
+
monkeypatch.setenv("CODEX_HOME", "/tmp/codex")
|
|
195
|
+
assert module.resolve_agent_slug(None) == "codex"
|
|
196
|
+
|
|
197
|
+
def should_detect_grok_from_marker(
|
|
198
|
+
self,
|
|
199
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
200
|
+
) -> None:
|
|
201
|
+
module = load_create_fresh_branch_module()
|
|
202
|
+
clear_agent_environment(monkeypatch)
|
|
203
|
+
monkeypatch.setenv("GROK_AGENT", "1")
|
|
204
|
+
assert module.resolve_agent_slug(None) == "grok"
|
|
205
|
+
|
|
206
|
+
def should_default_to_claude_when_no_markers(
|
|
207
|
+
self,
|
|
208
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
209
|
+
) -> None:
|
|
210
|
+
module = load_create_fresh_branch_module()
|
|
211
|
+
clear_agent_environment(monkeypatch)
|
|
212
|
+
assert module.resolve_agent_slug(None) == DEFAULT_AGENT_SLUG
|
|
213
|
+
|
|
214
|
+
def should_reject_invalid_agent_slug(
|
|
215
|
+
self,
|
|
216
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
217
|
+
) -> None:
|
|
218
|
+
module = load_create_fresh_branch_module()
|
|
219
|
+
clear_agent_environment(monkeypatch)
|
|
220
|
+
with pytest.raises(ValueError, match="agent slug"):
|
|
221
|
+
module.resolve_agent_slug("../evil")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class TestResolveAgentWorktreeRoot:
|
|
225
|
+
def should_use_userprofile_scratch_on_windows(
|
|
226
|
+
self,
|
|
227
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
228
|
+
) -> None:
|
|
229
|
+
module = load_create_fresh_branch_module()
|
|
230
|
+
monkeypatch.setattr(module.sys, "platform", "win32")
|
|
231
|
+
monkeypatch.setenv("USERPROFILE", r"C:\Users\example")
|
|
232
|
+
worktree_root = module.resolve_agent_worktree_root("grok")
|
|
233
|
+
assert worktree_root == (
|
|
234
|
+
Path(r"C:\Users\example") / "AppData" / "Local" / "Temp" / "grok"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def should_fall_back_to_gettempdir_off_windows(
|
|
238
|
+
self,
|
|
239
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
240
|
+
tmp_path: Path,
|
|
241
|
+
) -> None:
|
|
242
|
+
module = load_create_fresh_branch_module()
|
|
243
|
+
monkeypatch.setattr(module.sys, "platform", "linux")
|
|
244
|
+
monkeypatch.setattr(module.tempfile, "gettempdir", lambda: str(tmp_path))
|
|
245
|
+
worktree_root = module.resolve_agent_worktree_root("claude")
|
|
246
|
+
assert worktree_root == tmp_path / "claude"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class TestResolveUniqueWorktreePath:
|
|
250
|
+
def should_return_preferred_when_missing(self, tmp_path: Path) -> None:
|
|
251
|
+
module = load_create_fresh_branch_module()
|
|
252
|
+
preferred_path = tmp_path / "feature-one"
|
|
253
|
+
assert module.resolve_unique_worktree_path(preferred_path) == preferred_path
|
|
254
|
+
|
|
255
|
+
def should_suffix_when_preferred_exists(self, tmp_path: Path) -> None:
|
|
256
|
+
module = load_create_fresh_branch_module()
|
|
257
|
+
preferred_path = tmp_path / "feature-one"
|
|
258
|
+
preferred_path.mkdir()
|
|
259
|
+
unique_path = module.resolve_unique_worktree_path(preferred_path)
|
|
260
|
+
assert unique_path == tmp_path / "feature-one-2"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class TestNormalizeBaseRef:
|
|
264
|
+
def should_expand_bare_branch_name_to_origin_tracking_ref(self) -> None:
|
|
265
|
+
module = load_create_fresh_branch_module()
|
|
266
|
+
assert module.normalize_base_ref(MAIN_BRANCH_NAME) == DEFAULT_BASE_REF
|
|
267
|
+
|
|
268
|
+
def should_preserve_explicit_remote_tracking_ref(self) -> None:
|
|
269
|
+
module = load_create_fresh_branch_module()
|
|
270
|
+
assert module.normalize_base_ref(DEFAULT_BASE_REF) == DEFAULT_BASE_REF
|
|
271
|
+
|
|
272
|
+
def should_preserve_non_origin_remote_ref(self) -> None:
|
|
273
|
+
module = load_create_fresh_branch_module()
|
|
274
|
+
assert module.normalize_base_ref("upstream/dev") == "upstream/dev"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class TestAgentSlugPathSafety:
|
|
278
|
+
def should_reject_parent_segment_agent_slug_without_mkdir_outside(
|
|
279
|
+
self,
|
|
280
|
+
tmp_path: Path,
|
|
281
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
282
|
+
) -> None:
|
|
283
|
+
module = load_create_fresh_branch_module()
|
|
284
|
+
agent_scratch_parent = tmp_path / "agent-scratch"
|
|
285
|
+
agent_scratch_parent.mkdir()
|
|
286
|
+
escaped_agent_root = (agent_scratch_parent / ".." / "escape-agent").resolve()
|
|
287
|
+
monkeypatch.setattr(module.sys, "platform", "linux")
|
|
288
|
+
monkeypatch.setattr(
|
|
289
|
+
module.tempfile,
|
|
290
|
+
"gettempdir",
|
|
291
|
+
lambda: str(agent_scratch_parent),
|
|
292
|
+
)
|
|
293
|
+
with pytest.raises(ValueError, match="agent slug"):
|
|
294
|
+
module.create_fresh_branch(
|
|
295
|
+
branch_name="fix/safe-branch",
|
|
296
|
+
repo_path=tmp_path / "unused-repo",
|
|
297
|
+
agent_slug="../escape-agent",
|
|
298
|
+
base_ref=DEFAULT_BASE_REF,
|
|
299
|
+
)
|
|
300
|
+
assert not escaped_agent_root.exists()
|
|
301
|
+
assert not (agent_scratch_parent / "claude").exists()
|
|
302
|
+
|
|
303
|
+
def should_reject_empty_agent_slug(
|
|
304
|
+
self,
|
|
305
|
+
tmp_path: Path,
|
|
306
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
307
|
+
) -> None:
|
|
308
|
+
module = load_create_fresh_branch_module()
|
|
309
|
+
agent_scratch_parent = tmp_path / "agent-scratch"
|
|
310
|
+
agent_scratch_parent.mkdir()
|
|
311
|
+
monkeypatch.setattr(module.sys, "platform", "linux")
|
|
312
|
+
monkeypatch.setattr(
|
|
313
|
+
module.tempfile,
|
|
314
|
+
"gettempdir",
|
|
315
|
+
lambda: str(agent_scratch_parent),
|
|
316
|
+
)
|
|
317
|
+
with pytest.raises(ValueError, match="agent slug"):
|
|
318
|
+
module.create_fresh_branch(
|
|
319
|
+
branch_name="fix/safe-branch",
|
|
320
|
+
repo_path=tmp_path / "unused-repo",
|
|
321
|
+
agent_slug="",
|
|
322
|
+
base_ref=DEFAULT_BASE_REF,
|
|
323
|
+
)
|
|
324
|
+
assert not any(agent_scratch_parent.iterdir())
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class TestBranchNamePathSafety:
|
|
328
|
+
def should_reject_parent_segments_without_mkdir_outside(
|
|
329
|
+
self,
|
|
330
|
+
tmp_path: Path,
|
|
331
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
332
|
+
) -> None:
|
|
333
|
+
module = load_create_fresh_branch_module()
|
|
334
|
+
agent_scratch_parent = tmp_path / "agent-scratch"
|
|
335
|
+
agent_scratch_parent.mkdir()
|
|
336
|
+
monkeypatch.setattr(module.sys, "platform", "linux")
|
|
337
|
+
monkeypatch.setattr(
|
|
338
|
+
module.tempfile,
|
|
339
|
+
"gettempdir",
|
|
340
|
+
lambda: str(agent_scratch_parent),
|
|
341
|
+
)
|
|
342
|
+
escaped_path = agent_scratch_parent / "escape"
|
|
343
|
+
with pytest.raises(ValueError, match="relative path"):
|
|
344
|
+
module.create_fresh_branch(
|
|
345
|
+
branch_name="fix/../../escape",
|
|
346
|
+
repo_path=tmp_path / "unused-repo",
|
|
347
|
+
agent_slug="claude",
|
|
348
|
+
base_ref=DEFAULT_BASE_REF,
|
|
349
|
+
)
|
|
350
|
+
assert not escaped_path.exists()
|
|
351
|
+
assert not (agent_scratch_parent / "claude").exists()
|
|
352
|
+
|
|
353
|
+
def should_reject_absolute_branch_without_mkdir_outside(
|
|
354
|
+
self,
|
|
355
|
+
tmp_path: Path,
|
|
356
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
357
|
+
) -> None:
|
|
358
|
+
module = load_create_fresh_branch_module()
|
|
359
|
+
agent_scratch_parent = tmp_path / "agent-scratch"
|
|
360
|
+
agent_scratch_parent.mkdir()
|
|
361
|
+
outside_target = tmp_path / "outside-evil"
|
|
362
|
+
monkeypatch.setattr(module.sys, "platform", "linux")
|
|
363
|
+
monkeypatch.setattr(
|
|
364
|
+
module.tempfile,
|
|
365
|
+
"gettempdir",
|
|
366
|
+
lambda: str(agent_scratch_parent),
|
|
367
|
+
)
|
|
368
|
+
with pytest.raises(ValueError, match="relative path"):
|
|
369
|
+
module.create_fresh_branch(
|
|
370
|
+
branch_name=str(outside_target),
|
|
371
|
+
repo_path=tmp_path / "unused-repo",
|
|
372
|
+
agent_slug="claude",
|
|
373
|
+
base_ref=DEFAULT_BASE_REF,
|
|
374
|
+
)
|
|
375
|
+
assert not outside_target.exists()
|
|
376
|
+
assert not (agent_scratch_parent / "claude").exists()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class TestCreateFreshBranchIntegration:
|
|
380
|
+
def should_create_worktree_branch_without_moving_caller_head(
|
|
381
|
+
self,
|
|
382
|
+
tmp_path: Path,
|
|
383
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
384
|
+
) -> None:
|
|
385
|
+
module = load_create_fresh_branch_module()
|
|
386
|
+
repository_path = build_repo_with_origin(tmp_path / "repo")
|
|
387
|
+
caller_head_before = read_head_branch(repository_path)
|
|
388
|
+
caller_commit_before = read_head_commit(repository_path)
|
|
389
|
+
agent_scratch_parent = tmp_path / "agent-scratch"
|
|
390
|
+
monkeypatch.setattr(module.sys, "platform", "linux")
|
|
391
|
+
monkeypatch.setattr(
|
|
392
|
+
module.tempfile,
|
|
393
|
+
"gettempdir",
|
|
394
|
+
lambda: str(agent_scratch_parent),
|
|
395
|
+
)
|
|
396
|
+
success_payload = module.create_fresh_branch(
|
|
397
|
+
branch_name="fix/example-one",
|
|
398
|
+
repo_path=repository_path,
|
|
399
|
+
agent_slug="grok",
|
|
400
|
+
base_ref=DEFAULT_BASE_REF,
|
|
401
|
+
)
|
|
402
|
+
worktree_path = Path(success_payload[PAYLOAD_KEY_WORKTREE_PATH])
|
|
403
|
+
assert worktree_path.is_dir()
|
|
404
|
+
assert worktree_path == agent_scratch_parent / "grok" / "fix" / "example-one"
|
|
405
|
+
assert read_head_branch(worktree_path) == "fix/example-one"
|
|
406
|
+
assert read_head_commit(worktree_path) == caller_commit_before
|
|
407
|
+
assert success_payload[PAYLOAD_KEY_BRANCH] == "fix/example-one"
|
|
408
|
+
assert success_payload[PAYLOAD_KEY_BASE_REF] == DEFAULT_BASE_REF
|
|
409
|
+
assert success_payload[PAYLOAD_KEY_BASE_COMMIT] == caller_commit_before
|
|
410
|
+
assert success_payload[PAYLOAD_KEY_AGENT] == "grok"
|
|
411
|
+
assert Path(success_payload[PAYLOAD_KEY_REPO_ROOT]) == repository_path.resolve()
|
|
412
|
+
assert read_head_branch(repository_path) == caller_head_before
|
|
413
|
+
assert read_head_commit(repository_path) == caller_commit_before
|
|
414
|
+
assert (worktree_path / SEED_FILE_NAME).read_text(encoding="utf-8") == (
|
|
415
|
+
SEED_FILE_CONTENTS
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
def should_allocate_suffix_when_worktree_path_exists(
|
|
419
|
+
self,
|
|
420
|
+
tmp_path: Path,
|
|
421
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
422
|
+
) -> None:
|
|
423
|
+
module = load_create_fresh_branch_module()
|
|
424
|
+
repository_path = build_repo_with_origin(tmp_path / "repo")
|
|
425
|
+
agent_scratch_parent = tmp_path / "agent-scratch"
|
|
426
|
+
monkeypatch.setattr(module.sys, "platform", "linux")
|
|
427
|
+
monkeypatch.setattr(
|
|
428
|
+
module.tempfile,
|
|
429
|
+
"gettempdir",
|
|
430
|
+
lambda: str(agent_scratch_parent),
|
|
431
|
+
)
|
|
432
|
+
occupied_path = agent_scratch_parent / "claude" / "fix" / "collision"
|
|
433
|
+
occupied_path.mkdir(parents=True)
|
|
434
|
+
success_payload = module.create_fresh_branch(
|
|
435
|
+
branch_name="fix/collision",
|
|
436
|
+
repo_path=repository_path,
|
|
437
|
+
agent_slug="claude",
|
|
438
|
+
base_ref=DEFAULT_BASE_REF,
|
|
439
|
+
)
|
|
440
|
+
worktree_path = Path(success_payload[PAYLOAD_KEY_WORKTREE_PATH])
|
|
441
|
+
assert worktree_path == agent_scratch_parent / "claude" / "fix" / "collision-2"
|
|
442
|
+
assert worktree_path.is_dir()
|
|
443
|
+
assert read_head_branch(worktree_path) == "fix/collision"
|
|
444
|
+
|
|
445
|
+
def should_fail_when_branch_already_exists(
|
|
446
|
+
self,
|
|
447
|
+
tmp_path: Path,
|
|
448
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
449
|
+
) -> None:
|
|
450
|
+
module = load_create_fresh_branch_module()
|
|
451
|
+
repository_path = build_repo_with_origin(tmp_path / "repo")
|
|
452
|
+
agent_scratch_parent = tmp_path / "agent-scratch"
|
|
453
|
+
monkeypatch.setattr(module.sys, "platform", "linux")
|
|
454
|
+
monkeypatch.setattr(
|
|
455
|
+
module.tempfile,
|
|
456
|
+
"gettempdir",
|
|
457
|
+
lambda: str(agent_scratch_parent),
|
|
458
|
+
)
|
|
459
|
+
module.create_fresh_branch(
|
|
460
|
+
branch_name="fix/dup",
|
|
461
|
+
repo_path=repository_path,
|
|
462
|
+
agent_slug="claude",
|
|
463
|
+
base_ref=DEFAULT_BASE_REF,
|
|
464
|
+
)
|
|
465
|
+
with pytest.raises(RuntimeError, match="worktree add failed"):
|
|
466
|
+
module.create_fresh_branch(
|
|
467
|
+
branch_name="fix/dup",
|
|
468
|
+
repo_path=repository_path,
|
|
469
|
+
agent_slug="claude",
|
|
470
|
+
base_ref=DEFAULT_BASE_REF,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
def should_use_remote_tracking_tip_for_bare_base_name(
|
|
474
|
+
self,
|
|
475
|
+
tmp_path: Path,
|
|
476
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
477
|
+
) -> None:
|
|
478
|
+
module = load_create_fresh_branch_module()
|
|
479
|
+
repository_path = build_repo_with_origin(tmp_path / "repo")
|
|
480
|
+
empty_hooks_path = tmp_path / "repo" / "empty-hooks"
|
|
481
|
+
origin_tip_before = read_head_commit(repository_path)
|
|
482
|
+
(repository_path / SEED_FILE_NAME).write_text("local-only\n", encoding="utf-8")
|
|
483
|
+
run_git(
|
|
484
|
+
["add", SEED_FILE_NAME],
|
|
485
|
+
repository_path,
|
|
486
|
+
empty_hooks_path=empty_hooks_path,
|
|
487
|
+
)
|
|
488
|
+
run_git(
|
|
489
|
+
["commit", "-m", "local advance"],
|
|
490
|
+
repository_path,
|
|
491
|
+
empty_hooks_path=empty_hooks_path,
|
|
492
|
+
)
|
|
493
|
+
local_main_commit = read_head_commit(repository_path)
|
|
494
|
+
assert local_main_commit != origin_tip_before
|
|
495
|
+
agent_scratch_parent = tmp_path / "agent-scratch"
|
|
496
|
+
monkeypatch.setattr(module.sys, "platform", "linux")
|
|
497
|
+
monkeypatch.setattr(
|
|
498
|
+
module.tempfile,
|
|
499
|
+
"gettempdir",
|
|
500
|
+
lambda: str(agent_scratch_parent),
|
|
501
|
+
)
|
|
502
|
+
success_payload = module.create_fresh_branch(
|
|
503
|
+
branch_name="fix/bare-base",
|
|
504
|
+
repo_path=repository_path,
|
|
505
|
+
agent_slug="claude",
|
|
506
|
+
base_ref=MAIN_BRANCH_NAME,
|
|
507
|
+
)
|
|
508
|
+
worktree_path = Path(success_payload[PAYLOAD_KEY_WORKTREE_PATH])
|
|
509
|
+
assert success_payload[PAYLOAD_KEY_BASE_REF] == DEFAULT_BASE_REF
|
|
510
|
+
assert success_payload[PAYLOAD_KEY_BASE_COMMIT] == origin_tip_before
|
|
511
|
+
assert read_head_commit(worktree_path) == origin_tip_before
|
|
512
|
+
assert read_head_commit(worktree_path) != local_main_commit
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
class TestMainCli:
|
|
516
|
+
def should_print_success_json_and_exit_zero(
|
|
517
|
+
self,
|
|
518
|
+
tmp_path: Path,
|
|
519
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
520
|
+
capsys: pytest.CaptureFixture[str],
|
|
521
|
+
) -> None:
|
|
522
|
+
module = load_create_fresh_branch_module()
|
|
523
|
+
repository_path = build_repo_with_origin(tmp_path / "repo")
|
|
524
|
+
agent_scratch_parent = tmp_path / "agent-scratch"
|
|
525
|
+
monkeypatch.setattr(module.sys, "platform", "linux")
|
|
526
|
+
monkeypatch.setattr(
|
|
527
|
+
module.tempfile,
|
|
528
|
+
"gettempdir",
|
|
529
|
+
lambda: str(agent_scratch_parent),
|
|
530
|
+
)
|
|
531
|
+
monkeypatch.setattr(
|
|
532
|
+
sys,
|
|
533
|
+
"argv",
|
|
534
|
+
[
|
|
535
|
+
"create_fresh_branch.py",
|
|
536
|
+
"--branch-name",
|
|
537
|
+
"feat/cli-ok",
|
|
538
|
+
"--agent",
|
|
539
|
+
"grok",
|
|
540
|
+
"--repo",
|
|
541
|
+
str(repository_path),
|
|
542
|
+
],
|
|
543
|
+
)
|
|
544
|
+
exit_code = module.main()
|
|
545
|
+
captured = capsys.readouterr()
|
|
546
|
+
assert exit_code == EXIT_CODE_SUCCESS
|
|
547
|
+
success_payload = json.loads(captured.out)
|
|
548
|
+
assert success_payload[PAYLOAD_KEY_BRANCH] == "feat/cli-ok"
|
|
549
|
+
assert success_payload[PAYLOAD_KEY_AGENT] == "grok"
|
|
550
|
+
assert Path(success_payload[PAYLOAD_KEY_WORKTREE_PATH]).is_dir()
|
|
551
|
+
|
|
552
|
+
def should_print_error_json_and_exit_nonzero_for_bad_repo(
|
|
553
|
+
self,
|
|
554
|
+
tmp_path: Path,
|
|
555
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
556
|
+
capsys: pytest.CaptureFixture[str],
|
|
557
|
+
) -> None:
|
|
558
|
+
module = load_create_fresh_branch_module()
|
|
559
|
+
empty_directory = tmp_path / "not-a-repo"
|
|
560
|
+
empty_directory.mkdir()
|
|
561
|
+
monkeypatch.setattr(
|
|
562
|
+
sys,
|
|
563
|
+
"argv",
|
|
564
|
+
[
|
|
565
|
+
"create_fresh_branch.py",
|
|
566
|
+
"--branch-name",
|
|
567
|
+
"feat/no-repo",
|
|
568
|
+
"--repo",
|
|
569
|
+
str(empty_directory),
|
|
570
|
+
"--agent",
|
|
571
|
+
"claude",
|
|
572
|
+
],
|
|
573
|
+
)
|
|
574
|
+
exit_code = module.main()
|
|
575
|
+
captured = capsys.readouterr()
|
|
576
|
+
assert exit_code == EXIT_CODE_FAILURE
|
|
577
|
+
error_payload = json.loads(captured.out)
|
|
578
|
+
assert PAYLOAD_KEY_ERROR in error_payload
|
|
579
|
+
assert "git repository" in error_payload[PAYLOAD_KEY_ERROR]
|
|
580
|
+
|
|
581
|
+
def should_print_error_json_when_branch_name_flag_missing(
|
|
582
|
+
self,
|
|
583
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
584
|
+
capsys: pytest.CaptureFixture[str],
|
|
585
|
+
) -> None:
|
|
586
|
+
module = load_create_fresh_branch_module()
|
|
587
|
+
monkeypatch.setattr(sys, "argv", ["create_fresh_branch.py"])
|
|
588
|
+
exit_code = module.main()
|
|
589
|
+
captured = capsys.readouterr()
|
|
590
|
+
assert exit_code == EXIT_CODE_FAILURE
|
|
591
|
+
error_payload = json.loads(captured.out)
|
|
592
|
+
assert error_payload[PAYLOAD_KEY_ERROR] == ERROR_CLI_ARGUMENTS
|
|
593
|
+
|
|
594
|
+
def should_print_error_json_for_parent_segment_branch_name(
|
|
595
|
+
self,
|
|
596
|
+
tmp_path: Path,
|
|
597
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
598
|
+
capsys: pytest.CaptureFixture[str],
|
|
599
|
+
) -> None:
|
|
600
|
+
module = load_create_fresh_branch_module()
|
|
601
|
+
repository_path = build_repo_with_origin(tmp_path / "repo")
|
|
602
|
+
agent_scratch_parent = tmp_path / "agent-scratch"
|
|
603
|
+
agent_scratch_parent.mkdir()
|
|
604
|
+
escaped_path = agent_scratch_parent / "escape"
|
|
605
|
+
monkeypatch.setattr(module.sys, "platform", "linux")
|
|
606
|
+
monkeypatch.setattr(
|
|
607
|
+
module.tempfile,
|
|
608
|
+
"gettempdir",
|
|
609
|
+
lambda: str(agent_scratch_parent),
|
|
610
|
+
)
|
|
611
|
+
monkeypatch.setattr(
|
|
612
|
+
sys,
|
|
613
|
+
"argv",
|
|
614
|
+
[
|
|
615
|
+
"create_fresh_branch.py",
|
|
616
|
+
"--branch-name",
|
|
617
|
+
"../escape",
|
|
618
|
+
"--repo",
|
|
619
|
+
str(repository_path),
|
|
620
|
+
"--agent",
|
|
621
|
+
"claude",
|
|
622
|
+
],
|
|
623
|
+
)
|
|
624
|
+
exit_code = module.main()
|
|
625
|
+
captured = capsys.readouterr()
|
|
626
|
+
assert exit_code == EXIT_CODE_FAILURE
|
|
627
|
+
error_payload = json.loads(captured.out)
|
|
628
|
+
assert PAYLOAD_KEY_ERROR in error_payload
|
|
629
|
+
assert "relative path" in error_payload[PAYLOAD_KEY_ERROR]
|
|
630
|
+
assert not escaped_path.exists()
|
|
631
|
+
assert not (agent_scratch_parent / "claude").exists()
|
|
632
|
+
|
|
633
|
+
def should_print_error_json_for_absolute_branch_name(
|
|
634
|
+
self,
|
|
635
|
+
tmp_path: Path,
|
|
636
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
637
|
+
capsys: pytest.CaptureFixture[str],
|
|
638
|
+
) -> None:
|
|
639
|
+
module = load_create_fresh_branch_module()
|
|
640
|
+
repository_path = build_repo_with_origin(tmp_path / "repo")
|
|
641
|
+
agent_scratch_parent = tmp_path / "agent-scratch"
|
|
642
|
+
agent_scratch_parent.mkdir()
|
|
643
|
+
outside_target = tmp_path / "outside-evil"
|
|
644
|
+
monkeypatch.setattr(module.sys, "platform", "linux")
|
|
645
|
+
monkeypatch.setattr(
|
|
646
|
+
module.tempfile,
|
|
647
|
+
"gettempdir",
|
|
648
|
+
lambda: str(agent_scratch_parent),
|
|
649
|
+
)
|
|
650
|
+
monkeypatch.setattr(
|
|
651
|
+
sys,
|
|
652
|
+
"argv",
|
|
653
|
+
[
|
|
654
|
+
"create_fresh_branch.py",
|
|
655
|
+
"--branch-name",
|
|
656
|
+
str(outside_target),
|
|
657
|
+
"--repo",
|
|
658
|
+
str(repository_path),
|
|
659
|
+
"--agent",
|
|
660
|
+
"claude",
|
|
661
|
+
],
|
|
662
|
+
)
|
|
663
|
+
exit_code = module.main()
|
|
664
|
+
captured = capsys.readouterr()
|
|
665
|
+
assert exit_code == EXIT_CODE_FAILURE
|
|
666
|
+
error_payload = json.loads(captured.out)
|
|
667
|
+
assert PAYLOAD_KEY_ERROR in error_payload
|
|
668
|
+
assert "relative path" in error_payload[PAYLOAD_KEY_ERROR]
|
|
669
|
+
assert not outside_target.exists()
|
|
670
|
+
assert not (agent_scratch_parent / "claude").exists()
|
|
@@ -18,13 +18,14 @@ Drives a draft PR to convergence by driving the internal passes to clean first
|
|
|
18
18
|
| `pr_converge_skill_constants/` | Importable constants module shared by skill scripts |
|
|
19
19
|
| `reference/` | Per-tick steps, convergence gates, fix protocol, obstacle runbooks, state schema, stop conditions, examples |
|
|
20
20
|
| `scripts/` | Python helpers (bugbot check, convergence check, Copilot review fetcher, fix-reply poster, reflow tool) and their tests |
|
|
21
|
-
| `workflows/` | ScheduleWakeup loop pacing specification |
|
|
21
|
+
| `workflows/` | ScheduleWakeup loop pacing specification (`pacer=schedule_wakeup`) |
|
|
22
22
|
|
|
23
23
|
## Conventions
|
|
24
24
|
|
|
25
|
-
-
|
|
25
|
+
- Pre-flight selects a pacer via `select_converge_pacer.py`: `schedule_wakeup` or `portable`. Missing `ScheduleWakeup` selects portable — it does not abort the skill.
|
|
26
|
+
- On `pacer=schedule_wakeup`, each invocation runs one tick and the next advances via `ScheduleWakeup`. On `pacer=portable`, the session runs ticks continuously (continue or in-session poll) until convergence, a stop condition, or a budget handoff.
|
|
26
27
|
- Loop state persists to `$CLAUDE_JOB_DIR/pr-converge-state.json` between ticks.
|
|
27
|
-
-
|
|
28
|
+
- Isolation uses `EnterWorktree` when present; otherwise the portable driver git worktree path, then `preflight_worktree.py`.
|
|
28
29
|
- All findings and PR reports state verified facts only — no hedging language.
|
|
29
30
|
- The GitHub MCP (`pull_request_read`, `pull_request_review_write`) is the primary path for PR inspection; `gh api` is the fallback.
|
|
30
31
|
- Three step-scoped agents (`fix_executor`, `thread_sweep`, `copilot_watch`) persist across ticks via the `persistent_agents` map in loop state; each tick resumes them with `SendMessage` and spawns a fresh named agent when a stored id is dead.
|