claude-dev-env 4.2.0 → 4.3.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 (78) hide show
  1. package/.agents/skills/_shared/pr-loop/scripts/durable_post_lint.py +242 -0
  2. package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/durable_post_lint_constants.py +106 -0
  3. package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint.py +332 -0
  4. package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint_constants.py +25 -0
  5. package/.agents/skills/issue-tracker/SKILL.md +6 -0
  6. package/.agents/skills/pull-request/SKILL.md +218 -0
  7. package/.agents/skills/pull-request/reference/publication-tasks.md +18 -0
  8. package/.agents/skills/pull-request/scripts/github_pr_command_constants/__init__.py +1 -0
  9. package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/__init__.py +1 -0
  10. package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/constants.py +61 -0
  11. package/.agents/skills/pull-request/scripts/pull_request.py +276 -0
  12. package/.agents/skills/pull-request/scripts/recover_legacy_author.py +251 -0
  13. package/.agents/skills/pull-request/scripts/test_github_pr_command_constants.py +49 -0
  14. package/.agents/skills/pull-request/scripts/test_pull_request.py +398 -0
  15. package/.agents/skills/pull-request/scripts/test_recover_legacy_author.py +221 -0
  16. package/.agents/skills/test-runner/SKILL.md +69 -0
  17. package/.agents/skills/test-runner/scripts/preflight_checks.py +365 -0
  18. package/.agents/skills/test-runner/scripts/run_tests.py +316 -0
  19. package/.agents/skills/test-runner/scripts/test_preflight_checks.py +185 -0
  20. package/.agents/skills/test-runner/scripts/test_run_tests.py +281 -0
  21. package/.agents/skills/test-runner/scripts/test_runner_constants/__init__.py +1 -0
  22. package/.agents/skills/test-runner/scripts/test_runner_constants/config/__init__.py +1 -0
  23. package/.agents/skills/test-runner/scripts/test_runner_constants/config/constants.py +96 -0
  24. package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
  25. package/bin/ever-shipped-skills.mjs +2 -0
  26. package/bin/ever-shipped-skills.test.mjs +8 -0
  27. package/bin/install.mjs +9 -1
  28. package/bin/install.prune.test.mjs +90 -0
  29. package/bin/install.test.mjs +24 -7
  30. package/hooks/blocking/pii_prevention_blocker.py +32 -51
  31. package/hooks/blocking/test_bash_post_call_dispatcher.py +5 -28
  32. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -4
  33. package/hooks/blocking/test_code_rules_command_dispatch.py +2 -2
  34. package/hooks/blocking/test_pii_prevention_blocker.py +37 -89
  35. package/hooks/gh_post_body_texts.py +195 -0
  36. package/hooks/hooks.json +0 -15
  37. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +0 -1
  38. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +0 -8
  39. package/hooks/hooks_constants/gh_post_body_texts_constants.py +12 -0
  40. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +0 -7
  41. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +0 -7
  42. package/hooks/hooks_constants/test_failure_recorder_constants.py +3 -3
  43. package/hooks/hooks_constants/test_gh_post_body_texts_constants.py +19 -0
  44. package/hooks/observability/test_failure_recorder.py +1 -1
  45. package/hooks/pyproject.toml +1 -1
  46. package/hooks/test_gh_post_body_texts.py +79 -0
  47. package/package.json +1 -1
  48. package/rules/durable-post-artifacts.md +31 -20
  49. package/rules/gh-cli-conventions.md +23 -13
  50. package/rules/git-workflow.md +21 -8
  51. package/scripts/policy_lint/adapter_configuration.py +53 -12
  52. package/scripts/repository_checks/config/constants.py +0 -5
  53. package/scripts/tests/test_adapter_configuration.py +54 -0
  54. package/hooks/_gh_pr_author_swap_utils.py +0 -1225
  55. package/hooks/blocking/conventional_pr_title_gate.py +0 -443
  56. package/hooks/blocking/gh_body_arg_blocker.py +0 -176
  57. package/hooks/blocking/gh_pr_author_enforcer.py +0 -487
  58. package/hooks/blocking/gh_pr_author_restore.py +0 -105
  59. package/hooks/blocking/pr_description_writer_gate.py +0 -256
  60. package/hooks/blocking/test_conventional_pr_title_gate.py +0 -640
  61. package/hooks/blocking/test_gh_body_arg_blocker.py +0 -441
  62. package/hooks/blocking/test_gh_pr_author_enforcer.py +0 -1166
  63. package/hooks/blocking/test_gh_pr_author_restore.py +0 -513
  64. package/hooks/blocking/test_gh_pr_author_swap_utils.py +0 -910
  65. package/hooks/blocking/test_pr_description_writer_gate.py +0 -234
  66. package/hooks/blocking/test_preflight_check.py +0 -343
  67. package/hooks/blocking/test_test_preflight_check.py +0 -80
  68. package/hooks/blocking/test_volatile_path_in_post_blocker.py +0 -322
  69. package/hooks/blocking/volatile_path_in_post_blocker.py +0 -461
  70. package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +0 -58
  71. package/hooks/hooks_constants/gh_pr_author_swap_constants.py +0 -76
  72. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +0 -73
  73. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +0 -55
  74. package/hooks/observability/pr_description_writer_spawn_tracker.py +0 -113
  75. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +0 -150
  76. package/hooks/session/gh_pr_author_session_cleanup.py +0 -175
  77. package/hooks/session/test_gh_pr_author_session_cleanup.py +0 -576
  78. package/hooks/test__gh_pr_author_swap_utils.py +0 -333
@@ -0,0 +1,281 @@
1
+ """Focused behavior tests for the explicit test runner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import subprocess
7
+ import sys
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
14
+ if str(SCRIPTS_DIRECTORY) not in sys.path:
15
+ sys.path.insert(0, str(SCRIPTS_DIRECTORY))
16
+
17
+ run_tests = importlib.import_module("run_tests")
18
+
19
+ FRONTEND_COMMANDS = [
20
+ ["ps", "aux"],
21
+ ["npm", "run", "build"],
22
+ ["python", "manage.py", "collectstatic", "--noinput"],
23
+ [
24
+ "curl",
25
+ "-s",
26
+ "-o",
27
+ "/dev/null",
28
+ "-w",
29
+ "%{http_code}",
30
+ "--max-time",
31
+ "2",
32
+ "http://localhost:3000",
33
+ ],
34
+ ["playwright", "test"],
35
+ ]
36
+
37
+
38
+ def _create_django_project(project_root: Path, *, has_database: bool) -> None:
39
+ project_root.mkdir()
40
+ (project_root / "manage.py").write_text(
41
+ "import sys\n\nif __name__ == '__main__':\n sys.exit(0)\n",
42
+ encoding="utf-8",
43
+ )
44
+ if has_database:
45
+ (project_root / "db.sqlite3").write_bytes(b"database")
46
+
47
+
48
+ def _completed_process(
49
+ all_arguments: list[str],
50
+ return_code: int = 0,
51
+ stdout_text: str = "",
52
+ ) -> subprocess.CompletedProcess[str]:
53
+ return subprocess.CompletedProcess(
54
+ args=all_arguments,
55
+ returncode=return_code,
56
+ stdout=stdout_text,
57
+ stderr="",
58
+ )
59
+
60
+
61
+ def _build_recording_process(
62
+ all_calls: list[tuple[list[str], dict[str, object]]],
63
+ ) -> Callable[..., subprocess.CompletedProcess[str]]:
64
+ def run_process(
65
+ all_arguments: list[str],
66
+ **keyword_arguments: object,
67
+ ) -> subprocess.CompletedProcess[str]:
68
+ all_calls.append((all_arguments, keyword_arguments))
69
+ stdout_text = "200" if all_arguments[0] == "curl" else ""
70
+ return _completed_process(all_arguments, stdout_text=stdout_text)
71
+
72
+ return run_process
73
+
74
+
75
+ def test_select_runner_recognizes_supported_forms() -> None:
76
+ assert run_tests.select_runner(["pytest", "tests"]).name == "pytest"
77
+ assert run_tests.select_runner(["python", "-m", "pytest"]).name == "pytest"
78
+ assert run_tests.select_runner(["npx", "playwright", "test"]).is_playwright
79
+
80
+
81
+ def test_extract_target_url_reads_explicit_base_url() -> None:
82
+ assert (
83
+ run_tests.extract_target_url(
84
+ ["playwright", "test", "--base-url", "http://127.0.0.1:9000"], True
85
+ )
86
+ == "http://127.0.0.1:9000"
87
+ )
88
+
89
+
90
+ def test_run_preflight_skips_plain_pytest_without_django(
91
+ tmp_path: Path,
92
+ ) -> None:
93
+ selected_runner = run_tests.select_runner(["python", "-m", "pytest"])
94
+ assert (
95
+ run_tests.run_preflight(selected_runner, tmp_path, ["python", "-m", "pytest"])
96
+ is None
97
+ )
98
+
99
+
100
+ def test_run_child_process_returns_child_status(
101
+ tmp_path: Path,
102
+ monkeypatch: pytest.MonkeyPatch,
103
+ ) -> None:
104
+ child_process = _completed_process(["pytest"], return_code=6)
105
+ monkeypatch.setattr(
106
+ run_tests.subprocess, "run", lambda *_args, **_kwargs: child_process
107
+ )
108
+ assert run_tests.run_child_process(["pytest"], tmp_path) == 6
109
+
110
+
111
+ def test_main_runs_plain_pytest_without_django_preflight(
112
+ tmp_path: Path,
113
+ monkeypatch: pytest.MonkeyPatch,
114
+ ) -> None:
115
+ child_process = _completed_process(["python", "-m", "pytest", "tests"], 7)
116
+ all_calls: list[dict[str, object]] = []
117
+
118
+ def run_child(
119
+ all_arguments: list[str],
120
+ **keyword_arguments: object,
121
+ ) -> subprocess.CompletedProcess[str]:
122
+ all_calls.append({"arguments": all_arguments, **keyword_arguments})
123
+ return child_process
124
+
125
+ monkeypatch.setattr(run_tests.subprocess, "run", run_child)
126
+ exit_code = run_tests.main(
127
+ ["--project", str(tmp_path), "--", "python", "-m", "pytest", "tests"]
128
+ )
129
+
130
+ assert exit_code == 7
131
+ assert all_calls == [
132
+ {
133
+ "arguments": ["python", "-m", "pytest", "tests"],
134
+ "cwd": tmp_path.resolve(),
135
+ "check": False,
136
+ }
137
+ ]
138
+
139
+
140
+ def test_missing_django_database_stops_before_child_launch(
141
+ tmp_path: Path,
142
+ monkeypatch: pytest.MonkeyPatch,
143
+ ) -> None:
144
+ project_root = tmp_path / "django-project"
145
+ _create_django_project(project_root, has_database=False)
146
+ all_calls: list[list[str]] = []
147
+
148
+ def record_launch(all_arguments: list[str], **_keyword_arguments: object) -> None:
149
+ all_calls.append(all_arguments)
150
+
151
+ monkeypatch.setattr(run_tests.subprocess, "run", record_launch)
152
+ exit_code = run_tests.main(
153
+ ["--project", str(project_root), "--", "python", "-m", "pytest"]
154
+ )
155
+
156
+ assert exit_code != 0
157
+ assert all_calls == []
158
+
159
+
160
+ def test_django_pytest_passes_server_check_and_returns_child_status(
161
+ tmp_path: Path,
162
+ monkeypatch: pytest.MonkeyPatch,
163
+ ) -> None:
164
+ project_root = tmp_path / "django-project"
165
+ _create_django_project(project_root, has_database=True)
166
+ all_calls: list[tuple[list[str], dict[str, object]]] = []
167
+
168
+ monkeypatch.setattr(
169
+ run_tests.subprocess, "run", _build_recording_process(all_calls)
170
+ )
171
+ exit_code = run_tests.main(
172
+ ["--project", str(project_root), "--", "python", "-m", "pytest", "tests"]
173
+ )
174
+
175
+ assert exit_code == 0
176
+ assert all_calls[-1][0] == ["python", "-m", "pytest", "tests"]
177
+
178
+
179
+ def test_playwright_server_failure_stops_before_child_launch(
180
+ tmp_path: Path,
181
+ monkeypatch: pytest.MonkeyPatch,
182
+ ) -> None:
183
+ (tmp_path / "manage.py").write_text("", encoding="utf-8")
184
+ all_calls: list[list[str]] = []
185
+
186
+ def run_process(
187
+ all_arguments: list[str],
188
+ **_keyword_arguments: object,
189
+ ) -> subprocess.CompletedProcess[str]:
190
+ all_calls.append(all_arguments)
191
+ return _completed_process(all_arguments, stdout_text="000")
192
+
193
+ monkeypatch.setattr(run_tests.subprocess, "run", run_process)
194
+ exit_code = run_tests.main(
195
+ [
196
+ "--project",
197
+ str(tmp_path),
198
+ "--",
199
+ "npx",
200
+ "playwright",
201
+ "test",
202
+ "--base-url",
203
+ "http://127.0.0.1:1",
204
+ ]
205
+ )
206
+
207
+ assert exit_code != 0
208
+ assert [each_call[0] for each_call in all_calls] == ["ps", "curl"]
209
+
210
+
211
+ def test_playwright_missing_test_database_flag_stops_before_health_check(
212
+ tmp_path: Path,
213
+ monkeypatch: pytest.MonkeyPatch,
214
+ ) -> None:
215
+ (tmp_path / "manage.py").write_text("", encoding="utf-8")
216
+ process_listing = "python manage.py runserver 8000\n"
217
+ all_calls: list[list[str]] = []
218
+
219
+ def run_process(
220
+ all_arguments: list[str],
221
+ **_keyword_arguments: object,
222
+ ) -> subprocess.CompletedProcess[str]:
223
+ all_calls.append(all_arguments)
224
+ return _completed_process(all_arguments, stdout_text=process_listing)
225
+
226
+ monkeypatch.setattr(run_tests.subprocess, "run", run_process)
227
+ exit_code = run_tests.main(
228
+ [
229
+ "--project",
230
+ str(tmp_path),
231
+ "--",
232
+ "playwright",
233
+ "test",
234
+ "--base-url",
235
+ "http://localhost:8000",
236
+ ]
237
+ )
238
+
239
+ assert exit_code != 0
240
+ assert [each_call[0] for each_call in all_calls] == ["ps"]
241
+
242
+
243
+ def test_playwright_builds_frontend_and_collects_static_files(
244
+ tmp_path: Path,
245
+ monkeypatch: pytest.MonkeyPatch,
246
+ ) -> None:
247
+ project_root = tmp_path / "django-project"
248
+ _create_django_project(project_root, has_database=True)
249
+ (project_root / "frontend").mkdir()
250
+ all_calls: list[tuple[list[str], dict[str, object]]] = []
251
+ monkeypatch.setattr(run_tests, "check_runserver_port_conflicts", lambda *_: None)
252
+ monkeypatch.setattr(
253
+ run_tests.subprocess, "run", _build_recording_process(all_calls)
254
+ )
255
+
256
+ exit_code = run_tests.main(
257
+ ["--project", str(project_root), "--", "playwright", "test"]
258
+ )
259
+
260
+ assert exit_code == 0
261
+ assert [each_call[0] for each_call in all_calls] == FRONTEND_COMMANDS
262
+ assert all_calls[1][1]["cwd"] == project_root / "frontend"
263
+ assert all_calls[2][1]["cwd"] == project_root.resolve()
264
+
265
+
266
+ def test_invalid_runner_does_not_launch_a_child(
267
+ tmp_path: Path,
268
+ monkeypatch: pytest.MonkeyPatch,
269
+ ) -> None:
270
+ all_calls: list[list[str]] = []
271
+
272
+ def record_launch(all_arguments: list[str], **_keyword_arguments: object) -> None:
273
+ all_calls.append(all_arguments)
274
+
275
+ monkeypatch.setattr(run_tests.subprocess, "run", record_launch)
276
+ exit_code = run_tests.main(
277
+ ["--project", str(tmp_path), "--", "python", "-m", "unittest"]
278
+ )
279
+
280
+ assert exit_code == 2
281
+ assert all_calls == []
@@ -0,0 +1 @@
1
+ """Constants package for the test-runner command."""
@@ -0,0 +1 @@
1
+ """Configuration package for the test-runner command."""
@@ -0,0 +1,96 @@
1
+ """Names, messages, and limits used by the test-runner command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ DEFAULT_DJANGO_URL: str = "http://localhost:8000"
8
+ DEFAULT_PLAYWRIGHT_URL: str = "http://localhost:3000"
9
+ DJANGO_DATABASE_FILENAME: str = "db.sqlite3"
10
+ MANAGE_PY_FILENAME: str = "manage.py"
11
+ FRONTEND_DIRECTORY_NAME: str = "frontend"
12
+ PYTEST_RUNNER_NAME: str = "pytest"
13
+ PLAYWRIGHT_RUNNER_NAME: str = "playwright"
14
+ PYTHON_MODULE_FLAG: str = "-m"
15
+ PLAYWRIGHT_TEST_SUBCOMMAND: str = "test"
16
+ COMMAND_SEPARATOR: str = "--"
17
+ PROJECT_FLAG: str = "--project"
18
+ BASE_URL_FLAG: str = "--base-url"
19
+ ALL_EXECUTABLE_SUFFIXES: tuple[str, ...] = (".exe", ".cmd", ".bat")
20
+ ARGUMENT_ASSIGNMENT_SEPARATOR: str = "="
21
+ COMMAND_ARGUMENT_SEPARATOR: str = " "
22
+ DIRECTORY_LIST_SEPARATOR: str = ", "
23
+ PWD_ENVIRONMENT_NAME: str = "PWD"
24
+ PYTHON_EXECUTABLE_PREFIX: str = "python"
25
+ PYTEST_EXECUTABLE_PREFIX: str = "pytest"
26
+ PLAYWRIGHT_EXECUTABLE_NAME: str = "playwright"
27
+ NPX_EXECUTABLE_NAME: str = "npx"
28
+ RUNSERVER_COMMAND_NAME: str = "runserver"
29
+ TEST_DATABASE_FLAG: str = "--test-db"
30
+ ALL_RUNSERVER_OPTIONS_WITH_VALUES: frozenset[str] = frozenset(
31
+ {"--pythonpath", "--settings", "--verbosity"}
32
+ )
33
+ PROCESS_LIST_ARGUMENTS: tuple[str, ...] = ("ps", "aux")
34
+ ALL_PROCESS_INFORMATION_FIELDS: tuple[str, ...] = ("pid", "cmdline", "cwd")
35
+ ALL_FRONTEND_BUILD_ARGUMENTS: tuple[str, ...] = ("npm", "run", "build")
36
+ ALL_COLLECTSTATIC_ARGUMENTS: tuple[str, ...] = (
37
+ "python",
38
+ "manage.py",
39
+ "collectstatic",
40
+ "--noinput",
41
+ )
42
+ CURL_EXECUTABLE_NAME: str = "curl"
43
+ CURL_SILENT_FLAG: str = "-s"
44
+ CURL_OUTPUT_FLAG: str = "-o"
45
+ CURL_WRITE_OUT_FLAG: str = "-w"
46
+ CURL_STATUS_FORMAT: str = "%{http_code}"
47
+ CURL_MAX_TIME_FLAG: str = "--max-time"
48
+ NULL_DEVICE_PATH: str = "/dev/null"
49
+ ALL_LOCAL_SERVER_HOSTS: frozenset[str] = frozenset(
50
+ {"localhost", "127.0.0.1", "0.0.0.0"}
51
+ )
52
+ ALL_BLOCKED_STATUS_CODES: frozenset[int] = frozenset({500, 502, 503, 504})
53
+ SERVER_URL_PATTERN: re.Pattern[str] = re.compile(r"https?://[^\s\"']+")
54
+ ALL_URL_TRAILING_PUNCTUATION: str = ".,;:)]}"
55
+ DEFAULT_DJANGO_PORT: str = "8000"
56
+ PROCESS_CURL_TIMEOUT_SECONDS: int = 2
57
+ HEALTH_CHECK_TIMEOUT_SECONDS: int = 3
58
+ BUILD_TIMEOUT_SECONDS: int = 120
59
+ EXIT_CODE_SUCCESS: int = 0
60
+ EXIT_CODE_FAILURE: int = 1
61
+ EXIT_CODE_INVALID_ARGUMENTS: int = 2
62
+ MINIMUM_PLAYWRIGHT_ARGUMENT_COUNT: int = 2
63
+ MINIMUM_NPX_ARGUMENT_COUNT: int = 3
64
+ MINIMUM_PROCESS_ARGUMENT_COUNT: int = 3
65
+ MINIMUM_RUNSERVER_PROCESS_COUNT: int = 2
66
+ RUNSERVER_COMMAND_INDEX: int = 2
67
+ PLAYWRIGHT_TEST_COMMAND_INDEX: int = 2
68
+ ERROR_COMMAND_SEPARATOR_REQUIRED: str = "the child command must follow --"
69
+ ERROR_CHILD_COMMAND_REQUIRED: str = "a supported child test command is required"
70
+ ERROR_UNSUPPORTED_RUNNER: str = "only pytest and playwright test commands are supported"
71
+ ERROR_PREFLIGHT_FAILURE_TEMPLATE: str = "test preflight failed: {}"
72
+ ERROR_PROJECT_NOT_DIRECTORY_TEMPLATE: str = "project is not a directory: {}"
73
+ ERROR_CHILD_LAUNCH_TEMPLATE: str = "test child could not start: {}"
74
+ HEALTH_CHECK_ERROR_TEMPLATE: str = (
75
+ "BLOCKED: Server at {} is not healthy ({}). Fix the server before running tests."
76
+ )
77
+ UNREACHABLE_ERROR_TEMPLATE: str = (
78
+ "BLOCKED: Server at {} is unreachable. Start the server before running tests."
79
+ )
80
+ MISSING_DATABASE_ERROR_TEMPLATE: str = (
81
+ "BLOCKED: No database file ({}) found in {}. Run migrations before running tests."
82
+ )
83
+ FRONTEND_BUILD_FAILED_MESSAGE: str = (
84
+ "BLOCKED: Frontend build failed. Fix build errors before running e2e tests."
85
+ )
86
+ MISSING_TEST_DATABASE_FLAG_TEMPLATE: str = (
87
+ "BLOCKED: Django server on port {} is not running with --test-db. Restart "
88
+ "with: python manage.py runserver --test-db 0.0.0.0:{}"
89
+ )
90
+ PORT_CONFLICT_ERROR_TEMPLATE: str = (
91
+ "BLOCKED: Multiple Django runserver processes are bound to port {} across "
92
+ "worktrees: {}. Stop stale servers first."
93
+ )
94
+ PROCESS_LIST_RUNSERVER_TOKEN: str = "runserver"
95
+ PROCESS_LIST_SELF_FILTER_TOKEN: str = "grep"
96
+ UTF8_ENCODING: str = "utf-8"
@@ -26,7 +26,7 @@ Author-keyed logic accounts for the split: a self-PR check keys on the login tha
26
26
 
27
27
  ## Hooks that still gate a cloud run
28
28
 
29
- The commit and push gates read git commands, which a cloud run still issues through Bash, so they fire normally and the run follows them: `session_edit_stage_gate`, `block_main_commit`, `test_preflight_check`, and the pre-push base-ref check that needs `origin/HEAD` set (SKILL.md Step 2).
29
+ The commit and push gates read git commands, which a cloud run still issues through Bash, so they fire normally and the run follows them: `session_edit_stage_gate`, `block_main_commit`, and the pre-push base-ref check that needs `origin/HEAD` set (SKILL.md Step 2). Run tests through the `test-runner` skill when Django or Playwright readiness checks apply.
30
30
 
31
31
  ## Hooks that go quiet on the MCP path
32
32
 
@@ -88,6 +88,7 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
88
88
  'pr-loop-lifecycle',
89
89
  'pr-review-responder',
90
90
  'pr-scope-resolve',
91
+ 'pull-request',
91
92
  'pre-compact',
92
93
  'privacy-hygiene',
93
94
  'prototype',
@@ -122,6 +123,7 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
122
123
  'syncing-submodules',
123
124
  'task-build',
124
125
  'team-advisor',
126
+ 'test-runner',
125
127
  'update',
126
128
  'usage-pause',
127
129
  'verified-build',
@@ -28,3 +28,11 @@ test('EVER_SHIPPED_SKILL_NAMES includes every current shipped skill', () => {
28
28
 
29
29
  assert.deepEqual(allUncoveredSkillNames, [], 'every current skill must enter the fallback set');
30
30
  });
31
+
32
+ test('EVER_SHIPPED_SKILL_NAMES retains the explicit test runner', () => {
33
+ assert.ok(EVER_SHIPPED_SKILL_NAMES.has('test-runner'));
34
+ });
35
+
36
+ test('EVER_SHIPPED_SKILL_NAMES includes the pull request skill', () => {
37
+ assert.equal(EVER_SHIPPED_SKILL_NAMES.has('pull-request'), true);
38
+ });
package/bin/install.mjs CHANGED
@@ -172,6 +172,7 @@ export const CORE_SKILLS = [
172
172
  'grok-spawn',
173
173
  'pr-small-cl',
174
174
  'everything-search',
175
+ 'test-runner',
175
176
  'privacy-hygiene',
176
177
  'issue-tracker',
177
178
  'task-build',
@@ -1181,6 +1182,11 @@ export const FOLDED_HOOK_RELATIVE_PATHS = new Set([
1181
1182
  'blocking/pytest_testpaths_orphan_blocker.py',
1182
1183
  'blocking/open_questions_in_plans_blocker.py',
1183
1184
  'blocking/md_to_html_blocker.py',
1185
+ 'blocking/gh_body_arg_blocker.py',
1186
+ 'blocking/volatile_path_in_post_blocker.py',
1187
+ 'blocking/conventional_pr_title_gate.py',
1188
+ 'blocking/gh_pr_author_enforcer.py',
1189
+ 'blocking/pr_description_writer_gate.py',
1184
1190
  'session/untracked_repo_detector.py',
1185
1191
  ]);
1186
1192
 
@@ -1198,11 +1204,13 @@ export const POST_FOLDED_HOOK_RELATIVE_PATHS = new Set([
1198
1204
  'workflow/auto_formatter.py',
1199
1205
  'workflow/doc_gist_auto_publish.py',
1200
1206
  'workflow/md_to_html_companion.py',
1207
+ 'blocking/gh_pr_author_restore.py',
1201
1208
  ]);
1202
1209
 
1203
- /** Hook scripts that still ship but no longer run as registered hooks. */
1204
1210
  export const RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS = new Set([
1205
1211
  'session/untracked_repo_detector.py',
1212
+ 'session/gh_pr_author_session_cleanup.py',
1213
+ 'observability/pr_description_writer_spawn_tracker.py',
1206
1214
  ]);
1207
1215
 
1208
1216
  /**
@@ -66,6 +66,41 @@ const NESTED_SKILL_DIRECTORY = 'foo';
66
66
  const NESTED_SKILL_FILE_SEGMENTS = [NESTED_SKILL_DIRECTORY, 'scripts', 'a.py'];
67
67
  const MYPY_INI_FILE_NAME = '.mypy.ini';
68
68
  const SKIPPED_RECORD_SUMMARY_MARKER = 'manifest record(s) skipped';
69
+ const RETIRED_PULL_REQUEST_HOOK_RELATIVE_PATHS = [
70
+ '_gh_pr_author_swap_utils.py',
71
+ 'test__gh_pr_author_swap_utils.py',
72
+ 'blocking/conventional_pr_title_gate.py',
73
+ 'blocking/gh_body_arg_blocker.py',
74
+ 'blocking/gh_pr_author_enforcer.py',
75
+ 'blocking/gh_pr_author_restore.py',
76
+ 'blocking/pr_description_writer_gate.py',
77
+ 'blocking/test_conventional_pr_title_gate.py',
78
+ 'blocking/test_gh_body_arg_blocker.py',
79
+ 'blocking/test_gh_pr_author_enforcer.py',
80
+ 'blocking/test_gh_pr_author_restore.py',
81
+ 'blocking/test_gh_pr_author_swap_utils.py',
82
+ 'blocking/test_pr_description_writer_gate.py',
83
+ 'blocking/test_volatile_path_in_post_blocker.py',
84
+ 'blocking/volatile_path_in_post_blocker.py',
85
+ 'hooks_constants/conventional_pr_title_gate_constants.py',
86
+ 'hooks_constants/gh_pr_author_swap_constants.py',
87
+ 'hooks_constants/pr_description_writer_gate_constants.py',
88
+ 'hooks_constants/volatile_path_in_post_blocker_constants.py',
89
+ 'observability/pr_description_writer_spawn_tracker.py',
90
+ 'observability/test_pr_description_writer_spawn_tracker.py',
91
+ 'session/gh_pr_author_session_cleanup.py',
92
+ 'session/test_gh_pr_author_session_cleanup.py',
93
+ ];
94
+ const RETIRED_PULL_REQUEST_REGISTRATION_PATHS = [
95
+ 'blocking/conventional_pr_title_gate.py',
96
+ 'blocking/gh_body_arg_blocker.py',
97
+ 'blocking/gh_pr_author_enforcer.py',
98
+ 'blocking/gh_pr_author_restore.py',
99
+ 'blocking/pr_description_writer_gate.py',
100
+ 'blocking/volatile_path_in_post_blocker.py',
101
+ 'observability/pr_description_writer_spawn_tracker.py',
102
+ 'session/gh_pr_author_session_cleanup.py',
103
+ ];
69
104
 
70
105
  /**
71
106
  * Report whether a path under one managed root landed in a run backup.
@@ -1113,6 +1148,61 @@ test('a full reinstall removes the retired Everything path rewriter and keeps a
1113
1148
  }
1114
1149
  });
1115
1150
 
1151
+ test('a full reinstall removes retired pull request hooks and preserves foreign and recovery state', () => {
1152
+ const sandbox = createSandbox();
1153
+ try {
1154
+ runInstaller(sandbox.homeDirectory, []);
1155
+ const retiredPaths = RETIRED_PULL_REQUEST_HOOK_RELATIVE_PATHS.map(relativePath => (
1156
+ join(sandbox.claudeDirectory, HOOKS_DIRECTORY_NAME, ...relativePath.split('/'))
1157
+ ));
1158
+ for (const retiredPath of retiredPaths) {
1159
+ writeFileWithParents(retiredPath, 'an earlier install managed this file\n');
1160
+ }
1161
+ appendManifestFiles(sandbox.manifestPath, retiredPaths);
1162
+ const foreignHookPath = join(
1163
+ sandbox.claudeDirectory, HOOKS_DIRECTORY_NAME, 'blocking', 'foreign_pr_hook.py',
1164
+ );
1165
+ const foreignHookBytes = Buffer.from('a user hook the installer never wrote\n', 'utf8');
1166
+ writeFileWithParents(foreignHookPath, foreignHookBytes);
1167
+ const recoveryStatePath = join(
1168
+ sandbox.homeDirectory, 'pending', 'gh_pr_author_swap_session-a.json',
1169
+ );
1170
+ const recoveryStateBytes = Buffer.from('{"original_account":"prior"}\n', 'utf8');
1171
+ writeFileWithParents(recoveryStatePath, recoveryStateBytes);
1172
+ const settings = readSettings(sandbox.claudeDirectory);
1173
+ settings.hooks.PreToolUse.push({
1174
+ matcher: 'Bash',
1175
+ hooks: [
1176
+ ...RETIRED_PULL_REQUEST_REGISTRATION_PATHS.map(relativePath => ({
1177
+ type: 'command',
1178
+ command: `python3 "${join(sandbox.claudeDirectory, HOOKS_DIRECTORY_NAME, ...relativePath.split('/')).replace(/\\/g, '/')}"`,
1179
+ })),
1180
+ { type: 'command', command: `python3 "${foreignHookPath.replace(/\\/g, '/')}"` },
1181
+ ],
1182
+ });
1183
+ writeFileSync(
1184
+ join(sandbox.claudeDirectory, SETTINGS_FILE_NAME),
1185
+ JSON.stringify(settings, null, 4) + '\n',
1186
+ );
1187
+
1188
+ runInstaller(sandbox.homeDirectory, []);
1189
+
1190
+ const freshManifest = readManifest(sandbox.manifestPath);
1191
+ const allCommands = allHookCommands(readSettings(sandbox.claudeDirectory));
1192
+ for (const retiredPath of retiredPaths) {
1193
+ assert.equal(existsSync(retiredPath), false, `${retiredPath} leaves installed hooks`);
1194
+ assert.equal(freshManifest.files.includes(retiredPath), false);
1195
+ }
1196
+ for (const relativePath of RETIRED_PULL_REQUEST_REGISTRATION_PATHS) {
1197
+ assert.ok(allCommands.every(command => !command.includes(relativePath)));
1198
+ }
1199
+ assert.deepEqual(readFileSync(foreignHookPath), foreignHookBytes);
1200
+ assert.deepEqual(readFileSync(recoveryStatePath), recoveryStateBytes);
1201
+ } finally {
1202
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
1203
+ }
1204
+ });
1205
+
1116
1206
  test('a full reinstall removes a retired hook entry under an event type the current config leaves out', () => {
1117
1207
  const sandbox = createSandbox();
1118
1208
  try {
@@ -228,6 +228,11 @@ test('CORE_SKILLS ships ELI5 as the leaf presentation skill', () => {
228
228
  assert.equal(INSTALL_GROUPS.core.skills.includes('eli5'), true);
229
229
  });
230
230
 
231
+ test('CORE_SKILLS ships test-runner for explicit Django and Playwright readiness checks', () => {
232
+ assert.ok(CORE_SKILLS.includes('test-runner'));
233
+ assert.equal(INSTALL_GROUPS.core.skills.includes('test-runner'), true);
234
+ });
235
+
231
236
 
232
237
  test('EVER_SHIPPED_SKILL_NAMES retains imagegen so reinstall prunes the retired skill', () => {
233
238
  assert.ok(
@@ -1246,7 +1251,7 @@ const OLD_FOLDED_HOOKS_SETTINGS = {
1246
1251
 
1247
1252
 
1248
1253
  test('FOLDED_HOOK_RELATIVE_PATHS contains folded hooks plus retired entries', () => {
1249
- assert.equal(FOLDED_HOOK_RELATIVE_PATHS.size, 16);
1254
+ assert.equal(FOLDED_HOOK_RELATIVE_PATHS.size, 21);
1250
1255
  assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/write_existing_file_blocker.py'));
1251
1256
  assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/code_rules_enforcer.py'));
1252
1257
  assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/pytest_testpaths_orphan_blocker.py'));
@@ -1256,16 +1261,22 @@ test('FOLDED_HOOK_RELATIVE_PATHS contains folded hooks plus retired entries', ()
1256
1261
 
1257
1262
 
1258
1263
  test('retired hook registrations stay managed so reinstall removes them', () => {
1259
- const retiredPath = 'session/untracked_repo_detector.py';
1264
+ const retiredPaths = new Set([
1265
+ 'session/untracked_repo_detector.py',
1266
+ 'session/gh_pr_author_session_cleanup.py',
1267
+ 'observability/pr_description_writer_spawn_tracker.py',
1268
+ ]);
1260
1269
  const shippedHooks = JSON.parse(
1261
1270
  readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
1262
1271
  );
1263
1272
  const shippedCommands = Object.values(shippedHooks.hooks).flatMap(matcherGroups => (
1264
1273
  matcherGroups.flatMap(matcherGroup => matcherGroup.hooks.map(hook => hook.command))
1265
1274
  ));
1266
- assert.deepEqual(RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS, new Set([retiredPath]));
1267
- assert.ok(managedHookScriptRelativePaths({ hooks: {} }).has(retiredPath));
1268
- assert.ok(shippedCommands.every(command => !command.includes(retiredPath)));
1275
+ assert.deepEqual(RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS, retiredPaths);
1276
+ for (const retiredPath of retiredPaths) {
1277
+ assert.ok(managedHookScriptRelativePaths({ hooks: {} }).has(retiredPath));
1278
+ assert.ok(shippedCommands.every(command => !command.includes(retiredPath)));
1279
+ }
1269
1280
  });
1270
1281
 
1271
1282
 
@@ -1288,6 +1299,11 @@ test('FOLDED_HOOK_RELATIVE_PATHS lists every hook the PreToolUse dispatcher host
1288
1299
  ];
1289
1300
  const retiredHooks = [
1290
1301
  'blocking/md_to_html_blocker.py',
1302
+ 'blocking/gh_body_arg_blocker.py',
1303
+ 'blocking/volatile_path_in_post_blocker.py',
1304
+ 'blocking/conventional_pr_title_gate.py',
1305
+ 'blocking/gh_pr_author_enforcer.py',
1306
+ 'blocking/pr_description_writer_gate.py',
1291
1307
  'session/untracked_repo_detector.py',
1292
1308
  ];
1293
1309
  for (const hostedPath of dispatcherHostedHooks) {
@@ -1480,12 +1496,13 @@ const OLD_POST_FOLDED_HOOKS_SETTINGS = {
1480
1496
  };
1481
1497
 
1482
1498
 
1483
- test('POST_FOLDED_HOOK_RELATIVE_PATHS contains the after-write hooks and the retired md_to_html_companion', () => {
1484
- assert.equal(POST_FOLDED_HOOK_RELATIVE_PATHS.size, 4);
1499
+ test('POST_FOLDED_HOOK_RELATIVE_PATHS contains hosted and retired post-tool hooks', () => {
1500
+ assert.equal(POST_FOLDED_HOOK_RELATIVE_PATHS.size, 5);
1485
1501
  assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('validation/mypy_validator.py'));
1486
1502
  assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('workflow/auto_formatter.py'));
1487
1503
  assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('workflow/doc_gist_auto_publish.py'));
1488
1504
  assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('workflow/md_to_html_companion.py'));
1505
+ assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('blocking/gh_pr_author_restore.py'));
1489
1506
  });
1490
1507
 
1491
1508