claude-dev-env 1.89.0 → 1.92.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 +0 -13
- package/_shared/pr-loop/scripts/code_rules_gate.py +106 -14
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +26 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +160 -0
- package/agents/clean-coder.md +2 -2
- package/agents/code-verifier.md +0 -1
- package/agents/test_agent_frontmatter.py +78 -0
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +1 -1
- package/bin/install.mjs +1 -0
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/CLAUDE.md +6 -2
- package/hooks/blocking/code_rules_comments.py +2 -2
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +44 -0
- package/hooks/blocking/config/verified_commit_constants.py +2 -0
- package/hooks/blocking/conftest.py +115 -0
- package/hooks/blocking/nas_ssh_binary_enforcer.py +191 -0
- package/hooks/blocking/pr_description_enforcer.py +46 -22
- package/hooks/blocking/pr_description_pr_number.py +5 -3
- package/hooks/blocking/pr_description_proof_of_work.py +367 -0
- package/hooks/blocking/precommit_code_rules_gate.py +5 -1
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +8 -2
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +71 -0
- package/hooks/blocking/test_nas_ssh_binary_enforcer.py +168 -0
- package/hooks/blocking/test_pr_description_enforcer_proof_gate.py +175 -0
- package/hooks/blocking/test_pr_description_proof_of_work.py +162 -0
- package/hooks/blocking/test_precommit_code_rules_gate.py +89 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -0
- package/hooks/blocking/test_verification_verdict_store.py +11 -0
- package/hooks/blocking/test_verified_commit_config_bootstrap.py +49 -0
- package/hooks/blocking/test_verified_commit_gate.py +11 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +11 -0
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +210 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +6 -0
- package/hooks/blocking/verification_verdict_store.py +73 -5
- package/hooks/blocking/verified_commit_config_bootstrap.py +51 -0
- package/hooks/blocking/verified_commit_gate.py +6 -0
- package/hooks/blocking/verifier_verdict_minter.py +6 -0
- package/hooks/blocking/volatile_path_in_post_blocker.py +351 -0
- package/hooks/hooks.json +32 -2
- package/hooks/hooks_constants/CLAUDE.md +4 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +3 -0
- package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -0
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +59 -0
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +2 -0
- package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +111 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +48 -0
- package/hooks/lifecycle/CLAUDE.md +3 -1
- package/hooks/lifecycle/enter_worktree_origin_prefetch.py +146 -0
- package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +178 -0
- package/hooks/validators/run_all_validators.py +216 -4
- package/hooks/validators/test_run_all_validators_pretooluse.py +102 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +3 -0
- package/rules/durable-post-artifacts.md +35 -0
- package/rules/nas-ssh-invocation.md +21 -0
- package/rules/proof-of-work-pr-comments.md +26 -0
- package/scripts/CLAUDE.md +2 -0
- package/scripts/claude-chain.example.json +8 -0
- package/scripts/claude_chain_runner.py +400 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -0
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +124 -0
- package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +43 -0
- package/scripts/gh_artifact_upload.py +256 -0
- package/scripts/sync_to_cursor/rules.py +1 -1
- package/scripts/test_claude_chain_runner.py +472 -0
- package/scripts/tests/test_gh_artifact_upload.py +205 -0
- package/skills/CLAUDE.md +3 -0
- package/skills/team-advisor/SKILL.md +188 -0
- package/skills/team-advisor-refresh/SKILL.md +25 -0
- package/skills/usage-pause/SKILL.md +108 -0
- package/skills/usage-pause/scripts/resolve_usage_window.py +462 -0
- package/skills/usage-pause/scripts/test_resolve_usage_window.py +278 -0
- package/skills/usage-pause/scripts/usage_pause_constants/__init__.py +1 -0
- package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +65 -0
- package/system-prompts/software-engineer.xml +3 -2
package/CLAUDE.md
CHANGED
|
@@ -4,19 +4,6 @@ The user is short on time and appreciates brevity in replies. When you reply, al
|
|
|
4
4
|
|
|
5
5
|
The user delegates execution to you and expects zero manual steps unless strictly necessary. Execute every command you can directly. Only instruct the user to do something manually when you are technically unable to do it yourself. When a task involves credentials or other sensitive input, display a minimal secure UI (e.g., a password dialog) to collect it rather than asking the user to paste it into chat or run the command themselves. When direction is ambiguous, use AskUserQuestion to clarify before acting.
|
|
6
6
|
|
|
7
|
-
You have access to an `advisor` tool backed by a stronger reviewer model. It takes NO parameters — when you call advisor(), your entire conversation history is automatically forwarded. They see the task, every tool call you've made, every result you've seen.
|
|
8
|
-
|
|
9
|
-
Call advisor BEFORE substantive work — before writing, before committing to an interpretation, before building on an assumption. If the task requires orientation first (finding files, fetching a source, seeing what's there), do that, then call advisor. Orientation is not substantive work. Writing, editing, and declaring an answer are.
|
|
10
|
-
|
|
11
|
-
Also call advisor:
|
|
12
|
-
- When you believe the task is complete. BEFORE this call, make your deliverable durable: write the file, save the result, commit the change. The advisor call takes time; if the session ends during it, a durable result persists and an unwritten one doesn't.
|
|
13
|
-
- When stuck — errors recurring, approach not converging, results that don't fit.
|
|
14
|
-
- When considering a change of approach.
|
|
15
|
-
|
|
16
|
-
On tasks longer than a few steps, call advisor at least once before committing to an approach and once before declaring done. On short reactive tasks where the next action is dictated by tool output you just read, you don't need to keep calling — the advisor adds most of its value on the first call, before the approach crystallizes.
|
|
17
|
-
|
|
18
|
-
ALWAYS call the AskUserQuestion tool if you have a question for the user. Provide content-appropriate default options, with a flag for the recommended one.
|
|
19
|
-
|
|
20
7
|
## Timeless Documentation (all `.md` files)
|
|
21
8
|
|
|
22
9
|
Every Markdown file I write or edit describes the system's **current** state only. The test: a reader a year out, with zero prior context, finds every sentence true and complete without knowing what came before. State what **is**, not what changed — git history records change; docs record the contract.
|
|
@@ -15,6 +15,7 @@ from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
|
|
|
15
15
|
ALL_CODE_FILE_EXTENSIONS,
|
|
16
16
|
ALL_GIT_DIFF_CACHED_NAME_ONLY_NULL_TERMINATED_COMMAND,
|
|
17
17
|
ALL_GIT_DIFF_NAME_ONLY_NULL_TERMINATED_COMMAND_PREFIX,
|
|
18
|
+
ALL_PYTEST_CONFIG_FILE_SECTIONS,
|
|
18
19
|
ALL_PYTEST_MODULE_INVOCATION,
|
|
19
20
|
ALL_TEST_FILENAME_GLOB_SUFFIXES,
|
|
20
21
|
ALL_TEST_FILENAME_SUFFIXES,
|
|
@@ -38,6 +39,7 @@ from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
|
|
|
38
39
|
PYTHON_FILE_EXTENSION,
|
|
39
40
|
STAGED_PYTEST_TIMEOUT_SECONDS,
|
|
40
41
|
STAGED_TEST_FAILURE_HEADER,
|
|
42
|
+
STAGED_TEST_GROUP_FAILURE_MESSAGE,
|
|
41
43
|
TEST_CONFTEST_FILENAME,
|
|
42
44
|
TEST_FILENAME_PREFIX,
|
|
43
45
|
TESTS_PATH_SEGMENT,
|
|
@@ -1559,39 +1561,129 @@ def _staged_test_file_paths(repository_root: Path) -> list[Path]:
|
|
|
1559
1561
|
return all_test_paths
|
|
1560
1562
|
|
|
1561
1563
|
|
|
1562
|
-
def
|
|
1563
|
-
"""
|
|
1564
|
+
def _directory_holds_pytest_config(directory: Path) -> bool:
|
|
1565
|
+
"""Return True when *directory* holds a recognized pytest configuration file."""
|
|
1566
|
+
for each_filename, each_required_section in ALL_PYTEST_CONFIG_FILE_SECTIONS:
|
|
1567
|
+
config_path = directory / each_filename
|
|
1568
|
+
if not config_path.is_file():
|
|
1569
|
+
continue
|
|
1570
|
+
if each_required_section is None:
|
|
1571
|
+
return True
|
|
1572
|
+
try:
|
|
1573
|
+
config_text = config_path.read_text(encoding="utf-8", errors="replace")
|
|
1574
|
+
except OSError:
|
|
1575
|
+
continue
|
|
1576
|
+
if each_required_section in config_text:
|
|
1577
|
+
return True
|
|
1578
|
+
return False
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def _resolve_owning_test_root(test_file_path: Path, repository_root: Path) -> Path:
|
|
1582
|
+
"""Return the nearest ancestor of *test_file_path* that owns a pytest config.
|
|
1583
|
+
|
|
1584
|
+
Walks up from the test file to the first directory holding a pytest config,
|
|
1585
|
+
stopping at *repository_root*, which is the fallback when none is found.
|
|
1564
1586
|
|
|
1565
1587
|
Args:
|
|
1566
|
-
|
|
1588
|
+
test_file_path: The staged test file to resolve a root for.
|
|
1589
|
+
repository_root: The repository root that bounds the upward walk.
|
|
1567
1590
|
|
|
1568
1591
|
Returns:
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1592
|
+
The resolved owning test root directory.
|
|
1593
|
+
"""
|
|
1594
|
+
resolved_repository_root = repository_root.resolve()
|
|
1595
|
+
for each_ancestor in test_file_path.resolve().parents:
|
|
1596
|
+
if _directory_holds_pytest_config(each_ancestor):
|
|
1597
|
+
return each_ancestor
|
|
1598
|
+
if each_ancestor == resolved_repository_root:
|
|
1599
|
+
return resolved_repository_root
|
|
1600
|
+
return resolved_repository_root
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def _group_staged_tests_by_root(
|
|
1604
|
+
all_test_paths: list[Path],
|
|
1605
|
+
repository_root: Path,
|
|
1606
|
+
) -> dict[Path, list[Path]]:
|
|
1607
|
+
"""Group *all_test_paths* by their owning pytest-config root.
|
|
1608
|
+
|
|
1609
|
+
Args:
|
|
1610
|
+
all_test_paths: The staged test files to partition.
|
|
1611
|
+
repository_root: The repository root that bounds each upward walk.
|
|
1612
|
+
|
|
1613
|
+
Returns:
|
|
1614
|
+
A mapping from owning test root to the staged test files under it.
|
|
1615
|
+
"""
|
|
1616
|
+
tests_by_root: dict[Path, list[Path]] = {}
|
|
1617
|
+
for each_test_path in all_test_paths:
|
|
1618
|
+
owning_root = _resolve_owning_test_root(each_test_path, repository_root)
|
|
1619
|
+
tests_by_root.setdefault(owning_root, []).append(each_test_path)
|
|
1620
|
+
return tests_by_root
|
|
1621
|
+
|
|
1622
|
+
|
|
1623
|
+
def _run_pytest_for_group(group_root: Path, all_group_test_paths: list[Path]) -> int:
|
|
1624
|
+
"""Run pytest over one group's test files with the working directory at its root.
|
|
1625
|
+
|
|
1626
|
+
Args:
|
|
1627
|
+
group_root: The owning test root used as the pytest working directory.
|
|
1628
|
+
all_group_test_paths: The staged test files that share *group_root*.
|
|
1629
|
+
|
|
1630
|
+
Returns:
|
|
1631
|
+
0 when the group's tests pass or collect nothing; pytest's non-zero
|
|
1632
|
+
exit code otherwise.
|
|
1573
1633
|
"""
|
|
1574
|
-
all_test_paths = _staged_test_file_paths(repository_root)
|
|
1575
|
-
if not all_test_paths:
|
|
1576
|
-
return 0
|
|
1577
1634
|
pytest_process = subprocess.run(
|
|
1578
1635
|
[
|
|
1579
1636
|
sys.executable,
|
|
1580
1637
|
*ALL_PYTEST_MODULE_INVOCATION,
|
|
1581
|
-
*[str(each_path) for each_path in
|
|
1638
|
+
*[str(each_path) for each_path in all_group_test_paths],
|
|
1582
1639
|
],
|
|
1583
|
-
cwd=str(
|
|
1640
|
+
cwd=str(group_root),
|
|
1584
1641
|
timeout=STAGED_PYTEST_TIMEOUT_SECONDS,
|
|
1585
1642
|
check=False,
|
|
1586
1643
|
env=repository_environment(),
|
|
1587
1644
|
)
|
|
1588
1645
|
if pytest_process.returncode == PYTEST_NO_TESTS_COLLECTED_EXIT_CODE:
|
|
1589
1646
|
return 0
|
|
1590
|
-
if pytest_process.returncode != 0:
|
|
1591
|
-
print(STAGED_TEST_FAILURE_HEADER, file=sys.stderr)
|
|
1592
1647
|
return pytest_process.returncode
|
|
1593
1648
|
|
|
1594
1649
|
|
|
1650
|
+
def run_staged_test_files(repository_root: Path) -> int:
|
|
1651
|
+
"""Run pytest over the staged test files and return the gate exit code.
|
|
1652
|
+
|
|
1653
|
+
Groups the staged test files by their owning pytest-config root and runs one
|
|
1654
|
+
pytest session per group with the working directory at that root, so two
|
|
1655
|
+
packages that expose a same-named top-level package never share one session
|
|
1656
|
+
and shadow each other's imports.
|
|
1657
|
+
|
|
1658
|
+
Args:
|
|
1659
|
+
repository_root: The repository root the staged test files belong to.
|
|
1660
|
+
|
|
1661
|
+
Returns:
|
|
1662
|
+
0 when no test file is staged, when every group collects no tests, or
|
|
1663
|
+
when every group passes. The first failing group's non-zero pytest exit
|
|
1664
|
+
code otherwise, which blocks the commit.
|
|
1665
|
+
"""
|
|
1666
|
+
all_test_paths = _staged_test_file_paths(repository_root)
|
|
1667
|
+
if not all_test_paths:
|
|
1668
|
+
return 0
|
|
1669
|
+
tests_by_root = _group_staged_tests_by_root(all_test_paths, repository_root)
|
|
1670
|
+
first_failing_exit_code = 0
|
|
1671
|
+
for each_group_root in sorted(tests_by_root):
|
|
1672
|
+
group_exit_code = _run_pytest_for_group(
|
|
1673
|
+
each_group_root, tests_by_root[each_group_root]
|
|
1674
|
+
)
|
|
1675
|
+
if group_exit_code != 0:
|
|
1676
|
+
print(
|
|
1677
|
+
STAGED_TEST_GROUP_FAILURE_MESSAGE.format(group_root=each_group_root),
|
|
1678
|
+
file=sys.stderr,
|
|
1679
|
+
)
|
|
1680
|
+
if first_failing_exit_code == 0:
|
|
1681
|
+
first_failing_exit_code = group_exit_code
|
|
1682
|
+
if first_failing_exit_code != 0:
|
|
1683
|
+
print(STAGED_TEST_FAILURE_HEADER, file=sys.stderr)
|
|
1684
|
+
return first_failing_exit_code
|
|
1685
|
+
|
|
1686
|
+
|
|
1595
1687
|
def _report_terminology_findings(all_findings: list[str]) -> None:
|
|
1596
1688
|
"""Print the terminology-sweep findings, when any, to standard error.
|
|
1597
1689
|
|
|
@@ -93,3 +93,29 @@ STAGED_PYTEST_TIMEOUT_SECONDS: int = 600
|
|
|
93
93
|
STAGED_TEST_FAILURE_HEADER: str = (
|
|
94
94
|
"code_rules_gate: staged test file(s) failed under pytest; commit blocked."
|
|
95
95
|
)
|
|
96
|
+
|
|
97
|
+
PYTEST_INI_FILENAME: str = "pytest.ini"
|
|
98
|
+
|
|
99
|
+
PYPROJECT_TOML_FILENAME: str = "pyproject.toml"
|
|
100
|
+
|
|
101
|
+
SETUP_CFG_FILENAME: str = "setup.cfg"
|
|
102
|
+
|
|
103
|
+
TOX_INI_FILENAME: str = "tox.ini"
|
|
104
|
+
|
|
105
|
+
PYPROJECT_PYTEST_CONFIG_SECTION: str = "[tool.pytest.ini_options]"
|
|
106
|
+
|
|
107
|
+
SETUP_CFG_PYTEST_CONFIG_SECTION: str = "[tool:pytest]"
|
|
108
|
+
|
|
109
|
+
TOX_INI_PYTEST_CONFIG_SECTION: str = "[pytest]"
|
|
110
|
+
|
|
111
|
+
ALL_PYTEST_CONFIG_FILE_SECTIONS: tuple[tuple[str, str | None], ...] = (
|
|
112
|
+
(PYTEST_INI_FILENAME, None),
|
|
113
|
+
(PYPROJECT_TOML_FILENAME, PYPROJECT_PYTEST_CONFIG_SECTION),
|
|
114
|
+
(SETUP_CFG_FILENAME, SETUP_CFG_PYTEST_CONFIG_SECTION),
|
|
115
|
+
(TOX_INI_FILENAME, TOX_INI_PYTEST_CONFIG_SECTION),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
STAGED_TEST_GROUP_FAILURE_MESSAGE: str = (
|
|
119
|
+
"code_rules_gate: staged test group rooted at {group_root} "
|
|
120
|
+
"failed under pytest; commit blocked."
|
|
121
|
+
)
|
|
@@ -2418,3 +2418,163 @@ def test_run_staged_test_files_runs_pytest_without_git_environment(
|
|
|
2418
2418
|
monkeypatch.setenv("GIT_AUTHOR_NAME", "Hostile Hook Environment")
|
|
2419
2419
|
|
|
2420
2420
|
assert gate_module.run_staged_test_files(repository_root) == 0
|
|
2421
|
+
|
|
2422
|
+
|
|
2423
|
+
def _build_shadowing_skill(
|
|
2424
|
+
repository_root: Path,
|
|
2425
|
+
skill_name: str,
|
|
2426
|
+
module_name: str,
|
|
2427
|
+
) -> Path:
|
|
2428
|
+
skill_directory = repository_root / skill_name
|
|
2429
|
+
write_file(skill_directory / "pytest.ini", "[pytest]\n")
|
|
2430
|
+
write_file(skill_directory / "scripts" / "__init__.py", "")
|
|
2431
|
+
write_file(
|
|
2432
|
+
skill_directory / "scripts" / f"{module_name}.py",
|
|
2433
|
+
f"{module_name}_marker = 1\n",
|
|
2434
|
+
)
|
|
2435
|
+
test_relative_path = f"{skill_name}/test_{module_name}.py"
|
|
2436
|
+
write_file(
|
|
2437
|
+
repository_root / test_relative_path,
|
|
2438
|
+
f"from scripts.{module_name} import {module_name}_marker\n"
|
|
2439
|
+
"\n"
|
|
2440
|
+
"\n"
|
|
2441
|
+
f"def test_{module_name}_marker_is_one() -> None:\n"
|
|
2442
|
+
f" assert {module_name}_marker == 1\n",
|
|
2443
|
+
)
|
|
2444
|
+
stage_file(repository_root, test_relative_path)
|
|
2445
|
+
return repository_root / test_relative_path
|
|
2446
|
+
|
|
2447
|
+
|
|
2448
|
+
def test_run_staged_test_files_passes_when_same_named_package_would_shadow(
|
|
2449
|
+
temporary_git_repository: Path,
|
|
2450
|
+
) -> None:
|
|
2451
|
+
"""Two skill roots each own a top-level ``scripts`` package. One combined
|
|
2452
|
+
pytest session binds ``scripts`` to whichever imports first and the other
|
|
2453
|
+
skill's ``from scripts.<module>`` then raises ImportError. Per-root sessions
|
|
2454
|
+
keep each ``scripts`` isolated, so the gate passes."""
|
|
2455
|
+
first_test_path = _build_shadowing_skill(
|
|
2456
|
+
temporary_git_repository, "skill_alpha", "widget"
|
|
2457
|
+
)
|
|
2458
|
+
second_test_path = _build_shadowing_skill(
|
|
2459
|
+
temporary_git_repository, "skill_beta", "gadget"
|
|
2460
|
+
)
|
|
2461
|
+
|
|
2462
|
+
combined_session = subprocess.run(
|
|
2463
|
+
[sys.executable, "-m", "pytest", "-q", str(first_test_path), str(second_test_path)],
|
|
2464
|
+
cwd=str(temporary_git_repository),
|
|
2465
|
+
capture_output=True,
|
|
2466
|
+
text=True,
|
|
2467
|
+
encoding="utf-8",
|
|
2468
|
+
errors="replace",
|
|
2469
|
+
check=False,
|
|
2470
|
+
)
|
|
2471
|
+
assert combined_session.returncode != 0, (
|
|
2472
|
+
"one combined pytest session must fail on the same-named scripts package "
|
|
2473
|
+
"shadow; if it does not, the shadow scenario is not being reproduced"
|
|
2474
|
+
)
|
|
2475
|
+
|
|
2476
|
+
assert gate_module.run_staged_test_files(temporary_git_repository) == 0, (
|
|
2477
|
+
"per-root pytest sessions must keep each scripts package isolated so both "
|
|
2478
|
+
"staged suites pass"
|
|
2479
|
+
)
|
|
2480
|
+
|
|
2481
|
+
|
|
2482
|
+
def test_resolve_owning_test_root_returns_pyproject_config_directory(
|
|
2483
|
+
tmp_path: Path,
|
|
2484
|
+
) -> None:
|
|
2485
|
+
package_root = tmp_path / "package_root"
|
|
2486
|
+
write_file(
|
|
2487
|
+
package_root / "pyproject.toml",
|
|
2488
|
+
"[tool.pytest.ini_options]\ntestpaths = ['tests']\n",
|
|
2489
|
+
)
|
|
2490
|
+
test_path = package_root / "tests" / "test_behavior.py"
|
|
2491
|
+
write_file(test_path, "def test_behavior() -> None:\n assert True\n")
|
|
2492
|
+
|
|
2493
|
+
resolved_root = gate_module._resolve_owning_test_root(test_path, tmp_path)
|
|
2494
|
+
|
|
2495
|
+
assert resolved_root == package_root.resolve()
|
|
2496
|
+
|
|
2497
|
+
|
|
2498
|
+
def test_resolve_owning_test_root_returns_pytest_ini_directory(
|
|
2499
|
+
tmp_path: Path,
|
|
2500
|
+
) -> None:
|
|
2501
|
+
package_root = tmp_path / "package_root"
|
|
2502
|
+
write_file(package_root / "pytest.ini", "[pytest]\n")
|
|
2503
|
+
test_path = package_root / "suite" / "test_behavior.py"
|
|
2504
|
+
write_file(test_path, "def test_behavior() -> None:\n assert True\n")
|
|
2505
|
+
|
|
2506
|
+
resolved_root = gate_module._resolve_owning_test_root(test_path, tmp_path)
|
|
2507
|
+
|
|
2508
|
+
assert resolved_root == package_root.resolve()
|
|
2509
|
+
|
|
2510
|
+
|
|
2511
|
+
def test_resolve_owning_test_root_falls_back_to_repository_root(
|
|
2512
|
+
tmp_path: Path,
|
|
2513
|
+
) -> None:
|
|
2514
|
+
test_path = tmp_path / "nested" / "deeper" / "test_behavior.py"
|
|
2515
|
+
write_file(test_path, "def test_behavior() -> None:\n assert True\n")
|
|
2516
|
+
|
|
2517
|
+
resolved_root = gate_module._resolve_owning_test_root(test_path, tmp_path)
|
|
2518
|
+
|
|
2519
|
+
assert resolved_root == tmp_path.resolve()
|
|
2520
|
+
|
|
2521
|
+
|
|
2522
|
+
def test_resolve_owning_test_root_skips_pyproject_without_pytest_section(
|
|
2523
|
+
tmp_path: Path,
|
|
2524
|
+
) -> None:
|
|
2525
|
+
outer_root = tmp_path / "outer"
|
|
2526
|
+
write_file(
|
|
2527
|
+
outer_root / "pyproject.toml",
|
|
2528
|
+
"[tool.pytest.ini_options]\ntestpaths = ['tests']\n",
|
|
2529
|
+
)
|
|
2530
|
+
write_file(outer_root / "inner" / "pyproject.toml", "[project]\nname = 'inner'\n")
|
|
2531
|
+
test_path = outer_root / "inner" / "test_behavior.py"
|
|
2532
|
+
write_file(test_path, "def test_behavior() -> None:\n assert True\n")
|
|
2533
|
+
|
|
2534
|
+
resolved_root = gate_module._resolve_owning_test_root(test_path, tmp_path)
|
|
2535
|
+
|
|
2536
|
+
assert resolved_root == outer_root.resolve()
|
|
2537
|
+
|
|
2538
|
+
|
|
2539
|
+
def test_group_staged_tests_by_root_shares_a_session_for_same_root(
|
|
2540
|
+
tmp_path: Path,
|
|
2541
|
+
) -> None:
|
|
2542
|
+
package_root = tmp_path / "package_root"
|
|
2543
|
+
write_file(package_root / "pytest.ini", "[pytest]\n")
|
|
2544
|
+
first_test_path = package_root / "tests" / "test_first.py"
|
|
2545
|
+
second_test_path = package_root / "tests" / "test_second.py"
|
|
2546
|
+
write_file(first_test_path, "def test_first() -> None:\n assert True\n")
|
|
2547
|
+
write_file(second_test_path, "def test_second() -> None:\n assert True\n")
|
|
2548
|
+
|
|
2549
|
+
tests_by_root = gate_module._group_staged_tests_by_root(
|
|
2550
|
+
[first_test_path, second_test_path], tmp_path
|
|
2551
|
+
)
|
|
2552
|
+
|
|
2553
|
+
assert set(tests_by_root) == {package_root.resolve()}
|
|
2554
|
+
assert sorted(tests_by_root[package_root.resolve()]) == sorted(
|
|
2555
|
+
[first_test_path, second_test_path]
|
|
2556
|
+
)
|
|
2557
|
+
|
|
2558
|
+
|
|
2559
|
+
def test_run_staged_test_files_fails_when_one_group_fails(
|
|
2560
|
+
temporary_git_repository: Path,
|
|
2561
|
+
capsys: pytest.CaptureFixture[str],
|
|
2562
|
+
) -> None:
|
|
2563
|
+
write_file(temporary_git_repository / "passing_skill" / "pytest.ini", "[pytest]\n")
|
|
2564
|
+
write_file(
|
|
2565
|
+
temporary_git_repository / "passing_skill" / "test_pass.py",
|
|
2566
|
+
"def test_pass() -> None:\n assert True\n",
|
|
2567
|
+
)
|
|
2568
|
+
stage_file(temporary_git_repository, "passing_skill/test_pass.py")
|
|
2569
|
+
write_file(temporary_git_repository / "failing_skill" / "pytest.ini", "[pytest]\n")
|
|
2570
|
+
write_file(
|
|
2571
|
+
temporary_git_repository / "failing_skill" / "test_fail.py",
|
|
2572
|
+
"def test_fail() -> None:\n assert False\n",
|
|
2573
|
+
)
|
|
2574
|
+
stage_file(temporary_git_repository, "failing_skill/test_fail.py")
|
|
2575
|
+
|
|
2576
|
+
exit_code = gate_module.run_staged_test_files(temporary_git_repository)
|
|
2577
|
+
|
|
2578
|
+
assert exit_code != 0
|
|
2579
|
+
captured_error = capsys.readouterr().err
|
|
2580
|
+
assert "failing_skill" in captured_error
|
package/agents/clean-coder.md
CHANGED
|
@@ -159,7 +159,7 @@ Use logging for application and runtime output. `print()` is allowed when stdout
|
|
|
159
159
|
|
|
160
160
|
### Comment preservation
|
|
161
161
|
|
|
162
|
-
Existing comments on lines
|
|
162
|
+
Existing comments on lines you leave otherwise unchanged stay exactly as you found them. The hook treats the two directions differently: the gate fires and blocks the edit on a new inline `#` or `//` in production code, while an existing comment disappearing from a line you touched prints a stderr advisory and lets the edit through. New code self-documents via names; new docstrings on functions, methods, classes, and modules stay allowed.
|
|
163
163
|
|
|
164
164
|
### Centralized configuration
|
|
165
165
|
|
|
@@ -338,7 +338,7 @@ These gates are checked by `code_rules_enforcer.py`. Satisfying each gate lets y
|
|
|
338
338
|
| Rule | What this rule looks for |
|
|
339
339
|
|------|--------------------------|
|
|
340
340
|
| Self-documenting names only | New `#` or `//` in production code (shebangs, `# type:`, `# noqa`, eslint-directives, docstrings exempt) |
|
|
341
|
-
| Comment preservation |
|
|
341
|
+
| Comment preservation | Advisory — removal of existing comments on lines you leave otherwise unchanged prints a stderr advisory and does not block |
|
|
342
342
|
| Imports at top | `import` statements placed inside function bodies |
|
|
343
343
|
| Logging format | `log_*(f"...")` — replace with `log_*("...", arg)` |
|
|
344
344
|
| File length | Advisory at 400 lines (soft), strong nudge at 1000 — emitted to stderr; the write proceeds |
|
package/agents/code-verifier.md
CHANGED
|
@@ -3,7 +3,6 @@ name: code-verifier
|
|
|
3
3
|
description: Post-hoc verification agent for the two-phase code workflow. Spawned by the main session after coder agents finish. Runs every check itself in a fresh context — named gates, tests against recorded baselines, two-way diff-vs-task reading — and ends with a fenced verdict block the verifier_verdict_minter hook turns into the commit-gate verdict. Read and execute only; it never edits files.
|
|
4
4
|
tools: Read, Grep, Glob, Bash
|
|
5
5
|
model: sonnet
|
|
6
|
-
effort: medium
|
|
7
6
|
color: orange
|
|
8
7
|
---
|
|
9
8
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Behavior tests for agent-definition YAML frontmatter.
|
|
2
|
+
|
|
3
|
+
Every agent `.md` in this directory opens with a frontmatter block the Claude
|
|
4
|
+
Code subagent loader reads. The loader accepts a fixed key set; an unrecognized
|
|
5
|
+
top-level key breaks the spawn — the subagent starts with a broken definition,
|
|
6
|
+
idles, and dies without a report::
|
|
7
|
+
|
|
8
|
+
ok: name / description / tools / model / color
|
|
9
|
+
flag: effort <- unrecognized, subagent dies delivering nothing
|
|
10
|
+
|
|
11
|
+
The accepted set is the one `agents/CLAUDE.md` documents (name, description,
|
|
12
|
+
tools, color) plus the `model` pin the working agents carry (clean-coder pins
|
|
13
|
+
opus, pr-description-writer pins haiku). Top-level keys are read with a line
|
|
14
|
+
scan so an agent whose `description` embeds informal `<example>` prose is not
|
|
15
|
+
mistaken for one carrying extra keys.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import pytest
|
|
24
|
+
import yaml
|
|
25
|
+
|
|
26
|
+
ACCEPTED_FRONTMATTER_KEYS = frozenset(
|
|
27
|
+
{"name", "description", "tools", "model", "color"}
|
|
28
|
+
)
|
|
29
|
+
FRONTMATTER_FENCE = "---"
|
|
30
|
+
FRONTMATTER_SEGMENT_COUNT = 3
|
|
31
|
+
CODE_VERIFIER_AGENT_NAME = "code-verifier"
|
|
32
|
+
TOP_LEVEL_KEY_PATTERN = re.compile(r"^([a-z][a-z0-9_]*):", re.MULTILINE)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _agent_definition_paths() -> list[Path]:
|
|
36
|
+
agents_directory = Path(__file__).parent
|
|
37
|
+
all_markdown_files = sorted(agents_directory.glob("*.md"))
|
|
38
|
+
return [
|
|
39
|
+
each_markdown_file
|
|
40
|
+
for each_markdown_file in all_markdown_files
|
|
41
|
+
if each_markdown_file.read_text(encoding="utf-8").startswith(FRONTMATTER_FENCE)
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _frontmatter_block(agent_definition_path: Path) -> str:
|
|
46
|
+
agent_text = agent_definition_path.read_text(encoding="utf-8")
|
|
47
|
+
fence_segments = agent_text.split(FRONTMATTER_FENCE, FRONTMATTER_SEGMENT_COUNT - 1)
|
|
48
|
+
return fence_segments[1]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _top_level_keys(frontmatter_block: str) -> set[str]:
|
|
52
|
+
return set(TOP_LEVEL_KEY_PATTERN.findall(frontmatter_block))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.parametrize(
|
|
56
|
+
"agent_definition_path",
|
|
57
|
+
_agent_definition_paths(),
|
|
58
|
+
ids=lambda each_path: each_path.name,
|
|
59
|
+
)
|
|
60
|
+
def test_agent_frontmatter_uses_only_accepted_keys(
|
|
61
|
+
agent_definition_path: Path,
|
|
62
|
+
) -> None:
|
|
63
|
+
declared_keys = _top_level_keys(_frontmatter_block(agent_definition_path))
|
|
64
|
+
unaccepted_keys = declared_keys - ACCEPTED_FRONTMATTER_KEYS
|
|
65
|
+
assert not unaccepted_keys, (
|
|
66
|
+
f"{agent_definition_path.name} carries frontmatter keys the subagent "
|
|
67
|
+
f"loader does not accept: {sorted(unaccepted_keys)}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_code_verifier_frontmatter_parses_and_names_the_agent() -> None:
|
|
72
|
+
agents_directory = Path(__file__).parent
|
|
73
|
+
code_verifier_block = _frontmatter_block(
|
|
74
|
+
agents_directory / f"{CODE_VERIFIER_AGENT_NAME}.md"
|
|
75
|
+
)
|
|
76
|
+
parsed_frontmatter = yaml.safe_load(code_verifier_block)
|
|
77
|
+
assert parsed_frontmatter["name"] == CODE_VERIFIER_AGENT_NAME
|
|
78
|
+
assert set(parsed_frontmatter) <= ACCEPTED_FRONTMATTER_KEYS
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
| J5 | Abbreviations | `ctx`, `cfg`, `msg`, `btn`, `idx`, `cnt`, `elem`, `val`, `tmp`, `str`, `num`, `arr`, `obj`, `fn`, `cb`, `req`, `res`. (Loop counters `i`/`j`/`k` and `e` for exceptions are exempt.) |
|
|
25
25
|
| J6 | Vague names | `result`, `data`, `output`, `response`, `value`, `item`, `temp`, `info`, `stuff`, `thing`. Vague prefixes: `handle`, `process`, `manage`, `do`. |
|
|
26
26
|
| J7 | Type hints | Missing type annotation on a parameter or return; presence of `Any` or `# type: ignore`. |
|
|
27
|
-
| J8 | New inline comments | New `#` or `//` comments in production code added by this diff. (
|
|
27
|
+
| J8 | New inline comments | New `#` or `//` comments in production code added by this diff. (Removing an existing comment is a Comment Preservation concern the hook flags with a stderr advisory rather than a block.) |
|
|
28
28
|
| J9 | Logging format | `log_*(f"...")` rather than `log_*("...", arg)`. |
|
|
29
29
|
| J10 | Imports inside functions | `import` statements placed inside function bodies. |
|
|
30
30
|
| J11 | sys.path.insert dedup | `sys.path.insert(0, X)` must be guarded by `if X not in sys.path:` (test files exempt). |
|
|
@@ -69,7 +69,7 @@ A `test_*.py` name or a `.mjs` extension takes the line out of the write-time ga
|
|
|
69
69
|
**J8. New inline comments**
|
|
70
70
|
- Every `#` or `//` comment line **added** by this diff in production code — flag, except for exempt markers (shebangs, `# type:`, `# noqa`, `# pylint:`, `# pragma:`, `// @ts-`, `// eslint-`, `// prettier-`, `/// `).
|
|
71
71
|
- Module/function/class docstrings are always allowed.
|
|
72
|
-
- Existing comments are NEVER removed (Comment Preservation rule); if the diff removes an existing comment, that is a separate violation outside J8 (
|
|
72
|
+
- Existing comments are NEVER removed (Comment Preservation rule); if the diff removes an existing comment, that is a separate violation outside J8 (the hook emits a stderr advisory for it rather than blocking).
|
|
73
73
|
- Test files are exempt.
|
|
74
74
|
- Adversarial probes: (a) is there any `# type:` or marker comment that is actually inert prose rather than a real type-checker / linter directive? (b) is any docstring carrying inline-comment content (line-level explanations rather than module/function description)? (c) does any newly-added blank line between code stanzas function as a comment substitute, suggesting the author wanted to add a comment but couldn't?
|
|
75
75
|
|
package/bin/install.mjs
CHANGED
|
@@ -147,6 +147,7 @@ const INSTALL_GROUPS = {
|
|
|
147
147
|
core: {
|
|
148
148
|
description: 'Development standards, hooks, agents, commands',
|
|
149
149
|
skills: [
|
|
150
|
+
'team-advisor', 'team-advisor-refresh',
|
|
150
151
|
'anthropic-plan', 'everything-search',
|
|
151
152
|
'pr-review-responder',
|
|
152
153
|
'recall', 'remember', 'task-build', 'verified-build'
|
package/docs/CODE_RULES.md
CHANGED
|
@@ -4,9 +4,9 @@ Compact reference for agents. ⚡ marks rules enforced by `code_rules_enforcer.p
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
## COMMENT PRESERVATION
|
|
7
|
+
## COMMENT PRESERVATION
|
|
8
8
|
|
|
9
|
-
**
|
|
9
|
+
**Keep existing comments in place.** Only evaluate comments on lines you are actively changing. Do not add new inline comments in production code — write self-documenting code. Docstrings (module/class/function) are always allowed. Test files are exempt. The hook treats the two directions differently: adding a new inline comment blocks the edit, while removing an existing comment prints a stderr advisory and lets the edit through.
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
package/hooks/blocking/CLAUDE.md
CHANGED
|
@@ -24,7 +24,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
24
24
|
| `code_rules_banned_identifiers.py` | Banned short names (`ctx`, `cfg`, `msg`, etc.), banned prefixes (`handle_`, `process_`, etc.) |
|
|
25
25
|
| `code_rules_boolean_mustcheck.py` | Boolean naming (`is_`/`has_`/… prefixes) and must-check return values |
|
|
26
26
|
| `code_rules_command_dispatch.py` | A `hooks/blocking/` command classifier matching a multi-word command regex without a start anchor or first-word tokenization |
|
|
27
|
-
| `code_rules_comments.py` | No new inline comments;
|
|
27
|
+
| `code_rules_comments.py` | No new inline comments; advisory on deletion of existing ones |
|
|
28
28
|
| `code_rules_constants_config.py` | Constants must live in `config/`; file-global constant use-count |
|
|
29
29
|
| `code_rules_dead_argparse_argument.py` | Argparse arguments with no references in the same file |
|
|
30
30
|
| `code_rules_dead_config_field.py` | `*Config` / `*Selectors` dataclass fields with no live references |
|
|
@@ -78,10 +78,11 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
78
78
|
| `hook_prose_detector_consistency.py` | PreToolUse (Write/Edit) | Hook docstrings/messages that claim a trigger the detector cannot fire on |
|
|
79
79
|
| `intent_only_ending_blocker.py` | Stop | Responses that end on a plan or intent without doing the work |
|
|
80
80
|
| `open_questions_in_plans_blocker.py` | PreToolUse (Write/Edit) | Plan documents with unresolved open questions |
|
|
81
|
+
| `nas_ssh_binary_enforcer.py` | PreToolUse (Bash) | A bare `ssh`/`scp`/`sftp` command word targeting the NAS at `192.168.1.100` (Git Bash's MSYS ssh stalls on an interactive password prompt), or the full `System32/OpenSSH` binary to that host without `-o BatchMode=yes` |
|
|
81
82
|
| `package_inventory_stale_blocker.py` | PreToolUse (Write) | A new production code file created in a directory whose `README.md`/`CLAUDE.md` inventory (or a parent skill's `SKILL.md` Layout table mapping the `scripts/` subdirectory) names two or more sibling files but no entry for the new file |
|
|
82
83
|
| `plain_language_blocker.py` | PreToolUse (Write/Edit/AskUserQuestion) | Heavy or jargon words in user-facing prose |
|
|
83
84
|
| `pr_converge_bugteam_enforcer.py` | PreToolUse | Enforces that bugteam runs in parallel with bugbot in pr-converge loops |
|
|
84
|
-
| `pr_description_enforcer.py` | PreToolUse (Bash) | `gh pr create`/`edit`/`comment` bodies that fail the Anthropic claude-code style audit |
|
|
85
|
+
| `pr_description_enforcer.py` | PreToolUse (Bash) | `gh pr create`/`edit`/`comment` bodies that fail the Anthropic claude-code style audit, proof-shaped `gh pr comment` bodies missing proof-of-work parts, and `gh pr ready` while the PR carries no passing proof comment |
|
|
85
86
|
| `precommit_code_rules_gate.py` | PreToolUse (Bash) | Staged changes that fail the CODE_RULES gate at commit time |
|
|
86
87
|
| `pytest_testpaths_orphan_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | New `test_*.py` files created under a directory absent from a package's explicit pytest `testpaths` allowlist |
|
|
87
88
|
| `question_to_user_enforcer.py` | Stop | User-directed questions not routed through `AskUserQuestion` |
|
|
@@ -98,6 +99,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
98
99
|
| `verified_commit_gate.py` | PreToolUse (Bash/PowerShell) | `git commit`/`git push` without a passing verifier verdict |
|
|
99
100
|
| `verified_commit_message_accuracy_blocker.py` | PreToolUse | Commit messages that misstate what the diff has |
|
|
100
101
|
| `verifier_verdict_minter.py` | SubagentStop | Mints a passing verdict file when a `code-verifier` agent finishes cleanly |
|
|
102
|
+
| `volatile_path_in_post_blocker.py` | PreToolUse (Bash/MCP GitHub) | `gh` post commands and GitHub MCP post tools whose body references a volatile path (job scratch dir, worktree, or system temp) that outlives the durable post |
|
|
101
103
|
| `windows_rmtree_blocker.py` | PreToolUse (Write/Edit) | `shutil.rmtree` with `ignore_errors=True` on Windows |
|
|
102
104
|
| `workflow_substitution_slot_blocker.py` | PreToolUse (Write/Edit) | Workflow templates with bare per-iteration tokens missing angle-bracket slots |
|
|
103
105
|
| `write_existing_file_blocker.py` | PreToolUse (Write) | Write to a path where a file already exists |
|
|
@@ -110,8 +112,10 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
110
112
|
| `pr_description_body_audit.py` | Body audit logic for `pr_description_enforcer.py` |
|
|
111
113
|
| `pr_description_command_parser.py` | `gh` command parsing for `pr_description_enforcer.py` |
|
|
112
114
|
| `pr_description_pr_number.py` | PR number extraction logic |
|
|
115
|
+
| `pr_description_proof_of_work.py` | Proof-of-work comment audit and `gh pr ready` gate logic for `pr_description_enforcer.py` |
|
|
113
116
|
| `pr_description_readability.py` | Readability checks on PR description bodies |
|
|
114
117
|
| `verification_verdict_store.py` | Reads and writes verdict files under `~/.claude/verification/` |
|
|
118
|
+
| `verified_commit_config_bootstrap.py` | Binds `config.verified_commit_constants` to the sibling `config/` file by explicit location, so the gate family resolves its constants regardless of a foreign `config` package's `sys.path` order |
|
|
115
119
|
|
|
116
120
|
## Conventions
|
|
117
121
|
|
|
@@ -147,7 +147,7 @@ def check_comment_changes(old_content: str, new_content: str, file_path: str) ->
|
|
|
147
147
|
|
|
148
148
|
Inline comments (after code on same line): BLOCK when added.
|
|
149
149
|
Standalone comment lines: NUDGE (print advisory) when added.
|
|
150
|
-
Existing comments being removed:
|
|
150
|
+
Existing comments being removed: NUDGE (print advisory) — comment preservation is advisory.
|
|
151
151
|
|
|
152
152
|
When the file is Python and either *old_content* or *new_content* cannot
|
|
153
153
|
be tokenized (common for mid-edit Edit fragments), the comparison is
|
|
@@ -188,7 +188,7 @@ def check_comment_changes(old_content: str, new_content: str, file_path: str) ->
|
|
|
188
188
|
code_was_removed = new_line_count < old_line_count - len(removed_comments)
|
|
189
189
|
if not code_was_removed:
|
|
190
190
|
sample = next(iter(removed_comments))
|
|
191
|
-
|
|
191
|
+
print(f"[CODE_RULES advisory] Existing comment removed: {sample[:60]} - comment preservation is advisory", file=sys.stderr)
|
|
192
192
|
|
|
193
193
|
return issues
|
|
194
194
|
|