claude-dev-env 8.9.0 → 8.11.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 (82) hide show
  1. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +85 -3
  2. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +34 -0
  3. package/_shared/pr-loop/scripts/fix_pr_test_proof.py +132 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +43 -0
  5. package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +126 -0
  6. package/bin/codex-skill-load-block.mjs +45 -0
  7. package/bin/codex-skill-load-block.test.mjs +72 -0
  8. package/bin/install-constants.mjs +5 -0
  9. package/bin/install.agents-home.test.mjs +76 -1
  10. package/bin/install.cursor-rules.test.mjs +6 -0
  11. package/bin/install.mjs +55 -33
  12. package/bin/install.test.mjs +37 -0
  13. package/bin/install.transaction.test.mjs +29 -7
  14. package/bin/seed-codex-pstack-models.mjs +27 -0
  15. package/bin/seed-codex-pstack-models.test.mjs +72 -0
  16. package/docs/CODE_RULES.md +5 -1
  17. package/hooks/blocking/code_rules_enforcer.py +1 -3
  18. package/hooks/blocking/code_rules_magic_values.py +1 -3
  19. package/hooks/blocking/code_rules_paired_test.py +5 -6
  20. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -1
  21. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -46
  22. package/hooks/blocking/test_pre_tool_use_dispatcher.py +2 -40
  23. package/hooks/followup_ledger.py +8 -1
  24. package/hooks/git-hooks/test_installed_policy_lint.py +4 -2
  25. package/hooks/hooks.json +27 -0
  26. package/hooks/hooks_constants/followup_ledger_constants.py +4 -0
  27. package/hooks/hooks_constants/messages.py +0 -1
  28. package/hooks/hooks_constants/nested_project_hooks_constants.py +37 -0
  29. package/hooks/hooks_constants/skill_loaded_reminder_constants.py +16 -4
  30. package/hooks/hooks_constants/test_messages.py +1 -7
  31. package/hooks/lifecycle/nested_project_hooks.py +395 -0
  32. package/hooks/lifecycle/test_nested_project_hooks.py +251 -0
  33. package/hooks/session/skill_loaded_reminder.py +28 -13
  34. package/hooks/session/test_skill_loaded_reminder.py +49 -2
  35. package/hooks/test_followup_ledger.py +18 -0
  36. package/package.json +2 -1
  37. package/pstack-codex-models.md +37 -0
  38. package/rules/bdd.md +1 -1
  39. package/rules/build-needs-a-user.md +53 -0
  40. package/rules/code-standards.md +1 -1
  41. package/rules/flag-non-breaking-findings.md +7 -0
  42. package/rules/paired-test-coverage.md +4 -2
  43. package/rules/testing.md +5 -1
  44. package/scripts/cde_lint.py +47 -1
  45. package/scripts/policy_lint/adapter_callers.py +124 -0
  46. package/scripts/policy_lint/adapter_pairing.py +4 -2
  47. package/scripts/policy_lint/adapter_support.py +4 -3
  48. package/scripts/policy_lint/adapters.py +15 -0
  49. package/scripts/policy_lint/check_catalog.py +24 -0
  50. package/scripts/policy_lint/config/approved_test_pairs.py +3 -2
  51. package/scripts/policy_lint/config/check_catalog_constants.py +13 -0
  52. package/scripts/policy_lint/config/constants.py +18 -0
  53. package/scripts/policy_lint/engine.py +9 -3
  54. package/scripts/policy_lint/model.py +6 -2
  55. package/scripts/policy_lint/registry.py +6 -0
  56. package/scripts/policy_lint/selection.py +2 -0
  57. package/scripts/tests/test_cde_lint.py +50 -0
  58. package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
  59. package/scripts/tests/test_policy_lint_rules_callers.py +126 -0
  60. package/scripts/tests/test_policy_lint_rules_check_catalog.py +25 -1
  61. package/scripts/tests/test_policy_lint_rules_pairing.py +31 -2
  62. package/scripts/tests/test_policy_lint_selection.py +35 -0
  63. package/hooks/blocking/tdd_enforcer.py +0 -195
  64. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +0 -142
  65. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +0 -1
  66. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +0 -49
  67. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +0 -298
  68. package/hooks/blocking/tdd_enforcer_parts/decisions.py +0 -96
  69. package/hooks/blocking/tdd_enforcer_parts/freshness.py +0 -95
  70. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +0 -132
  71. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +0 -119
  72. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +0 -31
  73. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +0 -256
  74. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +0 -46
  75. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +0 -28
  76. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +0 -111
  77. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +0 -36
  78. package/hooks/blocking/test_tdd_enforcer.py +0 -1506
  79. package/hooks/blocking/test_tdd_enforcer_restore.py +0 -108
  80. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +0 -105
  81. package/scripts/codex_compat_watcher.py +0 -502
  82. package/scripts/tests/test_codex_compat_watcher.py +0 -599
@@ -385,11 +385,13 @@ def _first_nonzero(all_exit_codes: Iterable[int]) -> int:
385
385
  return 0
386
386
 
387
387
 
388
- def _add_baseline_worktree(repository_root: Path, baseline_worktree: Path) -> bool:
389
- """Attach a detached-HEAD checkout at *baseline_worktree*; True when git created it."""
388
+ def _add_baseline_worktree(
389
+ repository_root: Path, baseline_worktree: Path, revision: str = GIT_HEAD_REVISION
390
+ ) -> bool:
391
+ """Attach a detached checkout of *revision* at *baseline_worktree*; True when git created it."""
390
392
  worktree_added = _run_git(
391
393
  repository_root,
392
- (*ALL_GIT_WORKTREE_ADD_DETACH_ARGS, str(baseline_worktree), GIT_HEAD_REVISION),
394
+ (*ALL_GIT_WORKTREE_ADD_DETACH_ARGS, str(baseline_worktree), revision),
393
395
  )
394
396
  return worktree_added.returncode == 0
395
397
 
@@ -515,6 +517,86 @@ def run_grouped_tests_with_regression_gate(
515
517
  return first_failing_exit_code
516
518
 
517
519
 
520
+ def _overlay_files_into_worktree(
521
+ all_overlay_paths: list[Path], repository_root: Path, baseline_worktree: Path
522
+ ) -> None:
523
+ """Copy each working-tree file onto the same relative path inside the worktree."""
524
+ for each_path in all_overlay_paths:
525
+ worktree_path = _path_under_baseline_worktree(
526
+ each_path, repository_root, baseline_worktree
527
+ )
528
+ worktree_path.parent.mkdir(parents=True, exist_ok=True)
529
+ worktree_path.write_bytes(each_path.read_bytes())
530
+
531
+
532
+ def _revision_outcomes(
533
+ all_tests_by_root: dict[Path, list[Path]],
534
+ repository_root: Path,
535
+ revision: str,
536
+ all_overlay_paths: list[Path],
537
+ junit_root: Path,
538
+ ) -> dict[Path, GroupOutcome] | None:
539
+ """Run every group in a worktree of *revision* with the overlay files copied in."""
540
+ with tempfile.TemporaryDirectory(
541
+ prefix=REGRESSION_BASELINE_WORKTREE_TEMP_DIRECTORY_PREFIX,
542
+ ignore_cleanup_errors=True,
543
+ ) as baseline_parent_text:
544
+ baseline_worktree = (
545
+ Path(baseline_parent_text) / REGRESSION_BASELINE_WORKTREE_DIRECTORY_NAME
546
+ )
547
+ if not _add_baseline_worktree(repository_root, baseline_worktree, revision):
548
+ return None
549
+ try:
550
+ _overlay_files_into_worktree(
551
+ all_overlay_paths, repository_root, baseline_worktree
552
+ )
553
+ return _baseline_outcomes_for_failing_groups(
554
+ repository_root, all_tests_by_root, junit_root, baseline_worktree
555
+ )
556
+ finally:
557
+ _remove_baseline_worktree(repository_root, baseline_worktree)
558
+
559
+
560
+ def working_tree_and_revision_outcomes(
561
+ all_tests_by_root: dict[Path, list[Path]],
562
+ repository_root: Path,
563
+ revision: str,
564
+ all_overlay_paths: list[Path],
565
+ ) -> tuple[dict[Path, GroupOutcome], dict[Path, GroupOutcome] | None]:
566
+ """Run the test groups on the working tree, then on *revision* with overlays copied in.
567
+
568
+ ::
569
+
570
+ working tree: pkg/test_calc.py passes -> exit 0
571
+ revision: base code + head pkg/test_calc.py -> fails, the test proves the fix
572
+
573
+ The revision run reuses the baseline worktree and its import isolation, so
574
+ a run that read working-tree code reports no failures.
575
+
576
+ Args:
577
+ all_tests_by_root: Test files grouped by owning pytest-config root.
578
+ repository_root: The repository the test files belong to.
579
+ revision: The git revision the second run checks out.
580
+ all_overlay_paths: Working-tree files copied into the revision checkout
581
+ before it runs, such as the test files a change adds or edits.
582
+
583
+ Returns:
584
+ The working-tree outcomes, and the revision outcomes keyed by the same
585
+ group roots, or None when git could not create the revision worktree.
586
+ """
587
+ with tempfile.TemporaryDirectory(
588
+ prefix=REGRESSION_JUNIT_TEMP_DIRECTORY_PREFIX
589
+ ) as junit_root_text:
590
+ junit_root = Path(junit_root_text)
591
+ working_tree_outcomes = _run_staged_groups(
592
+ all_tests_by_root, repository_root, junit_root
593
+ )
594
+ revision_outcomes = _revision_outcomes(
595
+ all_tests_by_root, repository_root, revision, all_overlay_paths, junit_root
596
+ )
597
+ return working_tree_outcomes, revision_outcomes
598
+
599
+
518
600
  def run_staged_test_files(repository_root: Path) -> int:
519
601
  """Discover the staged test files and run them under the regression gate.
520
602
 
@@ -307,3 +307,37 @@ def test_run_staged_test_files_passes_when_config_less_packages_share_a_module_n
307
307
  _stage_package_owning_a_bare_config(repository_root, "beta_package", "beta")
308
308
 
309
309
  assert staged_test_regression.run_staged_test_files(repository_root) == 0
310
+
311
+
312
+ def test_a_revision_run_executes_an_overlaid_test_against_the_older_code(
313
+ tmp_path: Path,
314
+ ) -> None:
315
+ repository_root = repository_with_root_pytest_config(tmp_path)
316
+ write_and_stage(repository_root, "pkg_a/calc.py", "VALUE = 'old'\n")
317
+ run_git(repository_root, "commit", "--no-verify", "-m", "old value")
318
+ base_revision = (
319
+ run_git(repository_root, "rev-parse", "HEAD").stdout.decode().strip()
320
+ )
321
+ write_and_stage(repository_root, "pkg_a/calc.py", "VALUE = 'new'\n")
322
+ test_path = write_and_stage(
323
+ repository_root,
324
+ "pkg_a/test_calc.py",
325
+ "from calc import VALUE\n\n\ndef test_value_is_new() -> None:\n"
326
+ " assert VALUE == 'new'\n",
327
+ )
328
+ run_git(repository_root, "commit", "--no-verify", "-m", "new value")
329
+
330
+ working_tree_outcomes, revision_outcomes = (
331
+ staged_test_regression.working_tree_and_revision_outcomes(
332
+ {repository_root.resolve(): [test_path]},
333
+ repository_root,
334
+ base_revision,
335
+ [test_path],
336
+ )
337
+ )
338
+
339
+ assert [each.exit_code for each in working_tree_outcomes.values()] == [0]
340
+ assert revision_outcomes is not None
341
+ assert [
342
+ len(each.failing_identities) for each in revision_outcomes.values()
343
+ ] == [1]
@@ -0,0 +1,132 @@
1
+ """Require a fix pull request to carry a test that fails on its base and passes on its head.
2
+
3
+ ::
4
+
5
+ title "docs: ..." -> pass, nothing to prove
6
+ title "fix: ..." changing only docs or .github -> pass, nothing to prove
7
+ title "fix: ..." with no changed Python test -> fail
8
+ changed test passes on head, fails on base -> pass
9
+ changed test passes on both -> fail
10
+
11
+ The base run reuses the regression gate's detached worktree: the changed test
12
+ files are copied onto the base checkout, which runs them with import isolation.
13
+ Python tests only.
14
+ """
15
+
16
+ import argparse
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from code_rules_gate_parts import staged_test_regression, staged_test_running
21
+ from code_rules_gate_parts.git_file_sets import paths_from_git_diff
22
+ from code_rules_gate_parts.wrapper_plumb_check import is_test_path
23
+ from pr_loop_shared_constants.fix_pr_test_proof_constants import (
24
+ ALL_PRODUCTION_CODE_SUFFIXES,
25
+ CI_CONFIG_DIRECTORY_NAME,
26
+ FAILED_EXIT_CODE,
27
+ FIX_TITLE_PATTERN,
28
+ HEAD_FAILURE_MESSAGE,
29
+ NO_BASE_FAILURE_MESSAGE,
30
+ NO_CHANGED_TEST_MESSAGE,
31
+ NO_PRODUCTION_CHANGE_MESSAGE,
32
+ NOT_A_FIX_MESSAGE,
33
+ PASSED_EXIT_CODE,
34
+ PROVEN_MESSAGE,
35
+ WORKTREE_FAILED_MESSAGE,
36
+ )
37
+
38
+
39
+ def _parse_arguments(all_arguments: list[str]) -> argparse.Namespace:
40
+ parser = argparse.ArgumentParser(prog="fix_pr_test_proof")
41
+ parser.add_argument("--title", required=True)
42
+ parser.add_argument("--base", dest="base_revision", required=True)
43
+ parser.add_argument("--repository-root", default=".")
44
+ return parser.parse_args(all_arguments)
45
+
46
+
47
+ def is_production_code_path(file_path: Path, repository_root: Path) -> bool:
48
+ """Return whether a changed path is production code a fix must prove.
49
+
50
+ Args:
51
+ file_path: An absolute changed path.
52
+ repository_root: The repository the path belongs to.
53
+
54
+ Returns:
55
+ True for a code file outside the test patterns and outside ``.github``.
56
+ """
57
+ relative_path = file_path.relative_to(repository_root)
58
+ if relative_path.parts and relative_path.parts[0] == CI_CONFIG_DIRECTORY_NAME:
59
+ return False
60
+ if file_path.suffix not in ALL_PRODUCTION_CODE_SUFFIXES:
61
+ return False
62
+ return not is_test_path(relative_path.as_posix())
63
+
64
+
65
+ def _changed_python_tests(all_changed_paths: list[Path]) -> list[Path]:
66
+ return [
67
+ each_path
68
+ for each_path in all_changed_paths
69
+ if each_path.suffix == ".py"
70
+ and is_test_path(each_path.as_posix())
71
+ and each_path.is_file()
72
+ ]
73
+
74
+
75
+ def _proof_exit_code(
76
+ all_changed_tests: list[Path], repository_root: Path, base_revision: str
77
+ ) -> int:
78
+ all_targets = staged_test_running._pytest_target_paths(all_changed_tests)
79
+ all_tests_by_root = staged_test_running._group_staged_tests_by_root(
80
+ all_targets, repository_root
81
+ )
82
+ head_outcomes, base_outcomes = (
83
+ staged_test_regression.working_tree_and_revision_outcomes(
84
+ all_tests_by_root, repository_root, base_revision, all_changed_tests
85
+ )
86
+ )
87
+ for each_group_root, each_outcome in sorted(head_outcomes.items()):
88
+ if each_outcome.exit_code != 0:
89
+ sys.stderr.write(HEAD_FAILURE_MESSAGE.format(group_root=each_group_root) + "\n")
90
+ return FAILED_EXIT_CODE
91
+ if base_outcomes is None:
92
+ sys.stderr.write(WORKTREE_FAILED_MESSAGE.format(revision=base_revision) + "\n")
93
+ return FAILED_EXIT_CODE
94
+ base_failure_count = sum(
95
+ len(each_outcome.failing_identities) for each_outcome in base_outcomes.values()
96
+ )
97
+ if base_failure_count == 0:
98
+ sys.stderr.write(NO_BASE_FAILURE_MESSAGE.format(revision=base_revision) + "\n")
99
+ return FAILED_EXIT_CODE
100
+ sys.stdout.write(PROVEN_MESSAGE.format(count=base_failure_count) + "\n")
101
+ return PASSED_EXIT_CODE
102
+
103
+
104
+ def main(all_arguments: list[str]) -> int:
105
+ """Run the fix test-proof check.
106
+
107
+ Args:
108
+ all_arguments: Command arguments naming the title, base, and repository.
109
+
110
+ Returns:
111
+ 0 when the pull request passes, 1 when a fix lacks its proof test.
112
+ """
113
+ parsed_arguments = _parse_arguments(all_arguments)
114
+ if not FIX_TITLE_PATTERN.match(parsed_arguments.title):
115
+ sys.stdout.write(NOT_A_FIX_MESSAGE + "\n")
116
+ return PASSED_EXIT_CODE
117
+ repository_root = Path(parsed_arguments.repository_root).resolve()
118
+ all_changed_paths = paths_from_git_diff(repository_root, parsed_arguments.base_revision)
119
+ if not any(
120
+ is_production_code_path(each_path, repository_root) for each_path in all_changed_paths
121
+ ):
122
+ sys.stdout.write(NO_PRODUCTION_CHANGE_MESSAGE + "\n")
123
+ return PASSED_EXIT_CODE
124
+ all_changed_tests = _changed_python_tests(all_changed_paths)
125
+ if not all_changed_tests:
126
+ sys.stderr.write(NO_CHANGED_TEST_MESSAGE + "\n")
127
+ return FAILED_EXIT_CODE
128
+ return _proof_exit_code(all_changed_tests, repository_root, parsed_arguments.base_revision)
129
+
130
+
131
+ if __name__ == "__main__":
132
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,43 @@
1
+ """Constants for the fix pull request test-proof check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ FIX_TITLE_PATTERN: re.Pattern[str] = re.compile(r"^fix(\([^)]*\))?!?:")
8
+ ALL_PRODUCTION_CODE_SUFFIXES: tuple[str, ...] = (
9
+ ".py",
10
+ ".mjs",
11
+ ".js",
12
+ ".cjs",
13
+ ".ts",
14
+ ".tsx",
15
+ ".ps1",
16
+ ".sh",
17
+ )
18
+ CI_CONFIG_DIRECTORY_NAME: str = ".github"
19
+ PASSED_EXIT_CODE: int = 0
20
+ FAILED_EXIT_CODE: int = 1
21
+
22
+ NOT_A_FIX_MESSAGE: str = "fix_pr_test_proof: the title is outside the fix type; nothing to prove."
23
+ NO_PRODUCTION_CHANGE_MESSAGE: str = (
24
+ "fix_pr_test_proof: the fix changes no production code; nothing to prove."
25
+ )
26
+ NO_CHANGED_TEST_MESSAGE: str = (
27
+ "fix_pr_test_proof: this fix changes production code and no Python test. "
28
+ "Add a test that fails on the base and passes on the head."
29
+ )
30
+ WORKTREE_FAILED_MESSAGE: str = (
31
+ "fix_pr_test_proof: git could not check out the base revision {revision}."
32
+ )
33
+ HEAD_FAILURE_MESSAGE: str = (
34
+ "fix_pr_test_proof: a changed test fails on the head in {group_root}. "
35
+ "The proof test must pass once the fix is in."
36
+ )
37
+ NO_BASE_FAILURE_MESSAGE: str = (
38
+ "fix_pr_test_proof: every changed test passes on the base {revision}. "
39
+ "Add a test that fails without the fix."
40
+ )
41
+ PROVEN_MESSAGE: str = (
42
+ "fix_pr_test_proof: {count} changed test(s) fail on the base and pass on the head."
43
+ )
@@ -0,0 +1,126 @@
1
+ """Behavioral tests for the fix pull request test-proof check.
2
+
3
+ Every case drives a git repository under ``tmp_path`` and a pytest subprocess,
4
+ the same shape the CI job runs.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ import fix_pr_test_proof
12
+ from code_rules_gate_parts.tests._repo_test_helpers import (
13
+ repository_with_root_pytest_config,
14
+ run_git,
15
+ )
16
+
17
+ BUGGY_PRODUCTION_TEXT = (
18
+ "def add(left: int, right: int) -> int:\n return left - right\n"
19
+ )
20
+ FIXED_PRODUCTION_TEXT = (
21
+ "def add(left: int, right: int) -> int:\n return left + right\n"
22
+ )
23
+ PROOF_TEST_TEXT = (
24
+ "from calc import add\n\n\ndef test_add_sums_both_operands() -> None:\n"
25
+ " assert add(1, 2) == 3\n"
26
+ )
27
+ ALWAYS_PASSING_TEST_TEXT = (
28
+ "def test_nothing_in_particular() -> None:\n assert True\n"
29
+ )
30
+
31
+
32
+ def _commit_files(repository_root: Path, all_file_texts: dict[str, str]) -> None:
33
+ for each_relative_path, each_text in all_file_texts.items():
34
+ file_path = repository_root / each_relative_path
35
+ file_path.parent.mkdir(parents=True, exist_ok=True)
36
+ file_path.write_text(each_text, encoding="utf-8")
37
+ run_git(repository_root, "add", "-A")
38
+ run_git(repository_root, "commit", "--no-verify", "-m", "change")
39
+
40
+
41
+ def _head_revision(repository_root: Path) -> str:
42
+ return run_git(repository_root, "rev-parse", "HEAD").stdout.decode().strip()
43
+
44
+
45
+ def _repository_at_buggy_base(tmp_path: Path) -> tuple[Path, str]:
46
+ repository_root = repository_with_root_pytest_config(tmp_path)
47
+ _commit_files(repository_root, {"pkg/calc.py": BUGGY_PRODUCTION_TEXT})
48
+ return repository_root, _head_revision(repository_root)
49
+
50
+
51
+ def _run_check(repository_root: Path, title: str, base_revision: str) -> int:
52
+ return fix_pr_test_proof.main(
53
+ [
54
+ "--title",
55
+ title,
56
+ "--base",
57
+ base_revision,
58
+ "--repository-root",
59
+ str(repository_root),
60
+ ]
61
+ )
62
+
63
+
64
+ def test_a_title_outside_the_fix_type_passes(tmp_path: Path) -> None:
65
+ repository_root, base_revision = _repository_at_buggy_base(tmp_path)
66
+ _commit_files(repository_root, {"pkg/calc.py": FIXED_PRODUCTION_TEXT})
67
+
68
+ assert _run_check(repository_root, "feat: add sums", base_revision) == 0
69
+
70
+
71
+ def test_a_fix_that_changes_no_production_code_passes(tmp_path: Path) -> None:
72
+ repository_root, base_revision = _repository_at_buggy_base(tmp_path)
73
+ _commit_files(repository_root, {"docs/notes.md": "# Notes\n"})
74
+
75
+ assert _run_check(repository_root, "fix(docs): correct a link", base_revision) == 0
76
+
77
+
78
+ def test_a_fix_with_no_changed_test_fails(
79
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
80
+ ) -> None:
81
+ repository_root, base_revision = _repository_at_buggy_base(tmp_path)
82
+ _commit_files(repository_root, {"pkg/calc.py": FIXED_PRODUCTION_TEXT})
83
+
84
+ assert _run_check(repository_root, "fix: sum operands", base_revision) == 1
85
+ assert "no Python test" in capsys.readouterr().err
86
+
87
+
88
+ def test_a_fix_whose_test_fails_on_base_and_passes_on_head_passes(
89
+ tmp_path: Path,
90
+ ) -> None:
91
+ repository_root, base_revision = _repository_at_buggy_base(tmp_path)
92
+ _commit_files(
93
+ repository_root,
94
+ {"pkg/calc.py": FIXED_PRODUCTION_TEXT, "pkg/test_calc.py": PROOF_TEST_TEXT},
95
+ )
96
+
97
+ assert _run_check(repository_root, "fix(calc): sum operands", base_revision) == 0
98
+
99
+
100
+ def test_a_fix_whose_changed_test_also_passes_on_base_fails(
101
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
102
+ ) -> None:
103
+ repository_root, base_revision = _repository_at_buggy_base(tmp_path)
104
+ _commit_files(
105
+ repository_root,
106
+ {
107
+ "pkg/calc.py": FIXED_PRODUCTION_TEXT,
108
+ "pkg/test_calc.py": ALWAYS_PASSING_TEST_TEXT,
109
+ },
110
+ )
111
+
112
+ assert _run_check(repository_root, "fix!: sum operands", base_revision) == 1
113
+ assert "passes on the base" in capsys.readouterr().err
114
+
115
+
116
+ def test_a_fix_whose_changed_test_fails_on_head_fails(
117
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
118
+ ) -> None:
119
+ repository_root, base_revision = _repository_at_buggy_base(tmp_path)
120
+ _commit_files(
121
+ repository_root,
122
+ {"pkg/test_calc.py": PROOF_TEST_TEXT, "pkg/extra.py": "VALUE = 1\n"},
123
+ )
124
+
125
+ assert _run_check(repository_root, "fix: sum operands", base_revision) == 1
126
+ assert "fails on the head" in capsys.readouterr().err
@@ -0,0 +1,45 @@
1
+ import { lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export const SKILL_LOAD_BLOCK_START = '<!-- claude-dev-env skill load: start -->';
5
+ export const SKILL_LOAD_BLOCK_END = '<!-- claude-dev-env skill load: end -->';
6
+ export const SKILL_LOAD_INSTRUCTION = 'Load the pstack:poteto-mode skill before your first tool call or reply: '
7
+ + 'open its SKILL.md from your skills list and follow it. This binds every Codex agent, '
8
+ + 'including a helper started with spawn_agent, whatever its task message says.';
9
+
10
+ const SKILL_LOAD_BLOCK = `${SKILL_LOAD_BLOCK_START}\n${SKILL_LOAD_INSTRUCTION}\n${SKILL_LOAD_BLOCK_END}\n`;
11
+
12
+ /**
13
+ * Return the Codex guidance text with the package's skill-load block in it.
14
+ *
15
+ * Codex starts a spawn_agent helper without running any hook, so the global
16
+ * AGENTS.md is the one channel that reaches every helper. A block already in
17
+ * the text is replaced where it stands; otherwise the block goes first.
18
+ */
19
+ export function withSkillLoadBlock(guidanceText) {
20
+ const startIndex = guidanceText.indexOf(SKILL_LOAD_BLOCK_START);
21
+ const endIndex = startIndex === -1 ? -1 : guidanceText.indexOf(SKILL_LOAD_BLOCK_END, startIndex);
22
+ if (endIndex === -1) {
23
+ return guidanceText ? `${SKILL_LOAD_BLOCK}\n${guidanceText}` : SKILL_LOAD_BLOCK;
24
+ }
25
+ let afterBlockIndex = endIndex + SKILL_LOAD_BLOCK_END.length;
26
+ if (guidanceText[afterBlockIndex] === '\n') afterBlockIndex += 1;
27
+ return guidanceText.slice(0, startIndex) + SKILL_LOAD_BLOCK + guidanceText.slice(afterBlockIndex);
28
+ }
29
+
30
+ /**
31
+ * Write the skill-load block into CODEX_HOME/AGENTS.md.
32
+ *
33
+ * Returns the file path when the file changed, and null when it already held
34
+ * the current block.
35
+ */
36
+ export function writeCodexSkillLoadBlock(codexHome) {
37
+ const agentsPath = join(codexHome, 'AGENTS.md');
38
+ const hasGuidance = lstatSync(agentsPath, { throwIfNoEntry: false }) !== undefined;
39
+ const currentText = hasGuidance ? readFileSync(agentsPath, 'utf8') : '';
40
+ const updatedText = withSkillLoadBlock(currentText);
41
+ if (updatedText === currentText) return null;
42
+ mkdirSync(codexHome, { recursive: true });
43
+ writeFileSync(agentsPath, updatedText, 'utf8');
44
+ return agentsPath;
45
+ }
@@ -0,0 +1,72 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import {
7
+ SKILL_LOAD_BLOCK_END,
8
+ SKILL_LOAD_BLOCK_START,
9
+ SKILL_LOAD_INSTRUCTION,
10
+ withSkillLoadBlock,
11
+ writeCodexSkillLoadBlock,
12
+ } from './codex-skill-load-block.mjs';
13
+
14
+ const EXPECTED_BLOCK = `${SKILL_LOAD_BLOCK_START}\n${SKILL_LOAD_INSTRUCTION}\n${SKILL_LOAD_BLOCK_END}\n`;
15
+
16
+ function makeCodexHome(context) {
17
+ const root = mkdtempSync(join(tmpdir(), 'cde-skill-load-'));
18
+ context.after(() => rmSync(root, { recursive: true, force: true }));
19
+ return join(root, '.codex');
20
+ }
21
+
22
+ test('the instruction names the skill and binds spawned helpers', () => {
23
+ assert.match(SKILL_LOAD_INSTRUCTION, /pstack:poteto-mode/);
24
+ assert.match(SKILL_LOAD_INSTRUCTION, /spawn_agent/);
25
+ });
26
+
27
+ test('a missing Codex guidance file is created holding only the block', (context) => {
28
+ const codexHome = makeCodexHome(context);
29
+
30
+ const writtenPath = writeCodexSkillLoadBlock(codexHome);
31
+
32
+ assert.equal(writtenPath, join(codexHome, 'AGENTS.md'));
33
+ assert.equal(readFileSync(writtenPath, 'utf8'), EXPECTED_BLOCK);
34
+ });
35
+
36
+ test('existing guidance keeps every line and gains the block first', (context) => {
37
+ const codexHome = makeCodexHome(context);
38
+ mkdirSync(codexHome);
39
+ writeFileSync(join(codexHome, 'AGENTS.md'), '# pstack model configuration\n\nbug-fix: gpt-6-astra\n');
40
+
41
+ writeCodexSkillLoadBlock(codexHome);
42
+
43
+ assert.equal(
44
+ readFileSync(join(codexHome, 'AGENTS.md'), 'utf8'),
45
+ `${EXPECTED_BLOCK}\n# pstack model configuration\n\nbug-fix: gpt-6-astra\n`,
46
+ );
47
+ });
48
+
49
+ test('a repeat install leaves the file unchanged and reports no write', (context) => {
50
+ const codexHome = makeCodexHome(context);
51
+ mkdirSync(codexHome);
52
+ writeFileSync(join(codexHome, 'AGENTS.md'), 'Custom agents\n');
53
+ writeCodexSkillLoadBlock(codexHome);
54
+ const afterFirstInstall = readFileSync(join(codexHome, 'AGENTS.md'), 'utf8');
55
+
56
+ assert.equal(writeCodexSkillLoadBlock(codexHome), null);
57
+ assert.equal(readFileSync(join(codexHome, 'AGENTS.md'), 'utf8'), afterFirstInstall);
58
+ });
59
+
60
+ test('an outdated block is replaced in place and the text around it is kept', () => {
61
+ const outdated = `Intro\n${SKILL_LOAD_BLOCK_START}\nOld wording\n${SKILL_LOAD_BLOCK_END}\nOutro\n`;
62
+
63
+ assert.equal(withSkillLoadBlock(outdated), `Intro\n${EXPECTED_BLOCK}Outro\n`);
64
+ });
65
+
66
+ test('a start marker with no end marker gets a fresh block and keeps the stray text', () => {
67
+ const broken = `${SKILL_LOAD_BLOCK_START}\nHalf a block\n`;
68
+
69
+ const repaired = withSkillLoadBlock(broken);
70
+
71
+ assert.equal(repaired, `${EXPECTED_BLOCK}\n${broken}`);
72
+ });
@@ -179,3 +179,8 @@ export const WINDOWS_PYTHON_LAUNCHER_COMMAND = 'py -3';
179
179
  export const PYTHON_PROBE_TIMEOUT_MILLISECONDS = 10_000;
180
180
  export const ADDITIONAL_SETTINGS_BLOB_PREFIX = 'settings-';
181
181
  export const CODEX_NATIVE_ROUTING_MATCHER = 'multi_agent_v1__spawn_agent';
182
+ export const CODEX_SUBAGENT_PROMPT_MATCHER = 'Agent|Task';
183
+ export const ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS = new Set([
184
+ CODEX_NATIVE_ROUTING_MATCHER,
185
+ CODEX_SUBAGENT_PROMPT_MATCHER,
186
+ ]);
@@ -18,7 +18,7 @@ import {
18
18
  writeFileSync,
19
19
  } from 'node:fs';
20
20
  import { tmpdir } from 'node:os';
21
- import { dirname, join } from 'node:path';
21
+ import { dirname, join, resolve } from 'node:path';
22
22
  import { fileURLToPath } from 'node:url';
23
23
  import { CONTENT_DIRECTORIES } from './install.mjs';
24
24
  import {
@@ -209,6 +209,81 @@ test('a full install writes skills and agents under .agents and points .claude a
209
209
  }
210
210
  });
211
211
 
212
+ /**
213
+ * @param {string} hubPath
214
+ * @returns {string}
215
+ */
216
+ function resolveHubImportTarget(hubPath) {
217
+ const hubText = readFileSync(hubPath, 'utf8').trim();
218
+ assert.match(hubText, /^@\S+$/, 'the Claude hub holds one import line');
219
+ return resolve(dirname(hubPath), hubText.slice(1));
220
+ }
221
+
222
+ for (const eachLayout of [
223
+ { label: 'default home', extraArguments: [], claudeHomeName: '.claude', agentsHomeName: '.agents' },
224
+ {
225
+ label: 'profile root',
226
+ extraArguments: ['--target', '.claude-profile-a'],
227
+ claudeHomeName: '.claude-profile-a',
228
+ agentsHomeName: '.claude-profile-a.agents',
229
+ },
230
+ ]) {
231
+ test(`a ${eachLayout.label} install loads the package guidance from the agents home through the Claude hub`, () => {
232
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-agents-hub-'));
233
+ const claudeHome = join(homeDirectory, eachLayout.claudeHomeName);
234
+ const sharedGuidancePath = join(homeDirectory, eachLayout.agentsHomeName, 'AGENTS.md');
235
+ try {
236
+ runInstaller(homeDirectory, eachLayout.extraArguments.map(
237
+ (eachArgument) => (eachArgument.startsWith('.') ? join(homeDirectory, eachArgument) : eachArgument),
238
+ ));
239
+
240
+ assert.equal(
241
+ readFileSync(sharedGuidancePath, 'utf8'),
242
+ readFileSync(join(PACKAGE_DIRECTORY, 'AGENTS.md'), 'utf8'),
243
+ 'the package guidance lives once in the agents home',
244
+ );
245
+ assert.equal(
246
+ resolveHubImportTarget(join(claudeHome, 'CLAUDE.md')),
247
+ sharedGuidancePath,
248
+ 'the Claude hub import resolves to the shared guidance file',
249
+ );
250
+ assert.equal(
251
+ existsSync(join(claudeHome, 'AGENTS.md')),
252
+ false,
253
+ 'no second guidance copy lands in the Claude home',
254
+ );
255
+ } finally {
256
+ rmSync(homeDirectory, { recursive: true, force: true });
257
+ }
258
+ });
259
+ }
260
+
261
+ test('an upgrade moves the guidance copy an earlier install left in the Claude home into the run backup', () => {
262
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-agents-hub-upgrade-'));
263
+ const claudeHome = join(homeDirectory, '.claude');
264
+ const retiredGuidancePath = join(claudeHome, 'AGENTS.md');
265
+ const retiredGuidanceText = 'guidance an earlier install copied here\n';
266
+ try {
267
+ runInstaller(homeDirectory, []);
268
+ writeFileSync(retiredGuidancePath, retiredGuidanceText);
269
+ const manifestPath = join(claudeHome, '.claude-dev-env-manifest.json');
270
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
271
+ manifest.files.push(retiredGuidancePath);
272
+ writeFileSync(manifestPath, JSON.stringify(manifest));
273
+
274
+ runInstaller(homeDirectory, []);
275
+
276
+ assert.equal(existsSync(retiredGuidancePath), false, 'the old Claude-home copy is gone');
277
+ const backupRoot = join(claudeHome, '.claude-dev-env-pruned');
278
+ const backupName = readdirSync(backupRoot, { recursive: true })
279
+ .find(name => name.endsWith('AGENTS.md'));
280
+ assert.ok(backupName, 'the old copy sits in the run backup');
281
+ assert.equal(readFileSync(join(backupRoot, backupName), 'utf8'), retiredGuidanceText);
282
+ } finally {
283
+ rmSync(homeDirectory, { recursive: true, force: true });
284
+ }
285
+ });
286
+
212
287
  test('installs omit retired agents in each active agents home', () => {
213
288
  const runRoot = mkdtempSync(join(tmpdir(), 'cdev-active-roots-'));
214
289
  const homeDirectory = join(runRoot, 'home');
@@ -148,6 +148,12 @@ test('seeds one editable policy and one native Codex routing hook', () => {
148
148
  assert.equal(firstRoutingGroups.length, 1);
149
149
  assert.equal(firstRoutingGroups[0].hooks.length, 1);
150
150
  assert.match(firstRoutingGroups[0].hooks[0].command, /subagent_model_routing\.mjs/);
151
+ const firstSpawnPromptGroups = firstCodexHooks.hooks.PreToolUse.filter(
152
+ group => group.matcher === 'Agent|Task',
153
+ );
154
+ assert.equal(firstSpawnPromptGroups.length, 1);
155
+ assert.match(firstSpawnPromptGroups[0].hooks[0].command, /skill_loaded_reminder\.py/);
156
+ assert.deepEqual(Object.keys(firstCodexHooks.hooks), ['PreToolUse']);
151
157
  assert.equal(
152
158
  firstCodexHooks.hooks.PreToolUse.some(group => group.matcher === 'Write|Edit|MultiEdit|apply_patch'),
153
159
  false,