claude-dev-env 8.10.0 → 8.11.1

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 (81) 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 +233 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +47 -0
  5. package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +186 -0
  6. package/bin/codex-skill-load-block.mjs +93 -0
  7. package/bin/codex-skill-load-block.test.mjs +134 -0
  8. package/bin/install.agents-home.test.mjs +76 -1
  9. package/bin/install.mjs +55 -31
  10. package/bin/install.test.mjs +57 -0
  11. package/bin/install.transaction.test.mjs +29 -7
  12. package/bin/seed-codex-pstack-models.mjs +27 -0
  13. package/bin/seed-codex-pstack-models.test.mjs +72 -0
  14. package/docs/CODE_RULES.md +5 -1
  15. package/hooks/blocking/code_rules_enforcer.py +1 -3
  16. package/hooks/blocking/code_rules_magic_values.py +1 -3
  17. package/hooks/blocking/code_rules_paired_test.py +5 -6
  18. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +3 -3
  19. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -1
  20. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -46
  21. package/hooks/blocking/test_pre_tool_use_dispatcher.py +2 -40
  22. package/hooks/blocking/tests/test_repository_exemption.py +7 -0
  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 +15 -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/test_messages.py +1 -7
  30. package/hooks/lifecycle/nested_project_hooks.py +395 -0
  31. package/hooks/lifecycle/test_nested_project_hooks.py +251 -0
  32. package/hooks/test_followup_ledger.py +18 -0
  33. package/package.json +2 -1
  34. package/pstack-codex-models.md +37 -0
  35. package/rules/bdd.md +1 -1
  36. package/rules/build-needs-a-user.md +53 -0
  37. package/rules/code-standards.md +1 -1
  38. package/rules/flag-non-breaking-findings.md +7 -0
  39. package/rules/paired-test-coverage.md +4 -2
  40. package/rules/testing.md +5 -1
  41. package/scripts/cde_lint.py +47 -1
  42. package/scripts/policy_lint/adapter_callers.py +124 -0
  43. package/scripts/policy_lint/adapter_pairing.py +4 -2
  44. package/scripts/policy_lint/adapter_support.py +4 -3
  45. package/scripts/policy_lint/adapters.py +15 -0
  46. package/scripts/policy_lint/check_catalog.py +24 -0
  47. package/scripts/policy_lint/config/approved_test_pairs.py +3 -2
  48. package/scripts/policy_lint/config/check_catalog_constants.py +13 -0
  49. package/scripts/policy_lint/config/constants.py +18 -0
  50. package/scripts/policy_lint/engine.py +9 -3
  51. package/scripts/policy_lint/model.py +6 -2
  52. package/scripts/policy_lint/registry.py +6 -0
  53. package/scripts/policy_lint/selection.py +2 -0
  54. package/scripts/repository_checks/tracked_private_terms.py +15 -1
  55. package/scripts/tests/test_cde_lint.py +50 -0
  56. package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
  57. package/scripts/tests/test_policy_lint_rules_callers.py +140 -0
  58. package/scripts/tests/test_policy_lint_rules_check_catalog.py +25 -1
  59. package/scripts/tests/test_policy_lint_rules_pairing.py +31 -2
  60. package/scripts/tests/test_policy_lint_selection.py +35 -0
  61. package/scripts/tests/test_tracked_private_terms.py +35 -0
  62. package/hooks/blocking/tdd_enforcer.py +0 -195
  63. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +0 -142
  64. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +0 -1
  65. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +0 -49
  66. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +0 -298
  67. package/hooks/blocking/tdd_enforcer_parts/decisions.py +0 -96
  68. package/hooks/blocking/tdd_enforcer_parts/freshness.py +0 -95
  69. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +0 -132
  70. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +0 -119
  71. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +0 -31
  72. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +0 -256
  73. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +0 -46
  74. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +0 -28
  75. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +0 -111
  76. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +0 -36
  77. package/hooks/blocking/test_tdd_enforcer.py +0 -1506
  78. package/hooks/blocking/test_tdd_enforcer_restore.py +0 -108
  79. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +0 -105
  80. package/scripts/codex_compat_watcher.py +0 -502
  81. package/scripts/tests/test_codex_compat_watcher.py +0 -599
@@ -0,0 +1,72 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { seedCodexPstackModels } from './seed-codex-pstack-models.mjs';
7
+
8
+ test('fresh Codex home gets the selected roles and hook setting', (context) => {
9
+ const root = mkdtempSync(join(tmpdir(), 'cde-pstack-models-'));
10
+ context.after(() => rmSync(root, { recursive: true, force: true }));
11
+ const codexHome = join(root, '.codex');
12
+ const claudeHome = join(root, '.claude');
13
+ mkdirSync(claudeHome);
14
+ writeFileSync(join(claudeHome, 'CLAUDE.md'), 'Claude guidance\n');
15
+
16
+ const paths = seedCodexPstackModels(codexHome);
17
+ const sheet = readFileSync(join(codexHome, 'pstack-models.md'), 'utf8');
18
+ const agents = readFileSync(join(codexHome, 'AGENTS.md'), 'utf8');
19
+ assert.deepEqual(paths, [join(codexHome, 'pstack-models.md'), join(codexHome, 'AGENTS.md')]);
20
+ assert.match(sheet, /^feature, refactoring: gpt-6-sol$/m);
21
+ assert.match(sheet, /^bug-fix: gpt-6-astra$/m);
22
+ assert.match(sheet, /^how explorer: gpt-6-luna$/m);
23
+ assert.match(sheet, /^arena runners: gpt-6-astra, gpt-6-sol, gpt-6-luna$/m);
24
+ assert.match(sheet, /^session hook: on$/m);
25
+ assert.match(agents, /^feature, refactoring: gpt-6-sol$/m);
26
+ assert.doesNotMatch(agents, /^session hook:/m);
27
+ assert.equal(readFileSync(join(claudeHome, 'CLAUDE.md'), 'utf8'), 'Claude guidance\n');
28
+ assert.equal(existsSync(join(claudeHome, 'pstack-models.md')), false);
29
+ });
30
+
31
+ test('existing Codex configuration stays untouched on repeat installs', (context) => {
32
+ const root = mkdtempSync(join(tmpdir(), 'cde-pstack-models-'));
33
+ context.after(() => rmSync(root, { recursive: true, force: true }));
34
+ const codexHome = join(root, '.codex');
35
+ seedCodexPstackModels(codexHome);
36
+ writeFileSync(join(codexHome, 'AGENTS.md'), 'Custom agents\n');
37
+
38
+ assert.equal(seedCodexPstackModels(codexHome), null);
39
+ assert.equal(readFileSync(join(codexHome, 'AGENTS.md'), 'utf8'), 'Custom agents\n');
40
+ });
41
+
42
+ test('a failed AGENTS write removes only the sheet created by that attempt', (context) => {
43
+ const root = mkdtempSync(join(tmpdir(), 'cde-pstack-models-'));
44
+ context.after(() => rmSync(root, { recursive: true, force: true }));
45
+ const codexHome = join(root, '.codex');
46
+ const claudeHome = join(root, '.claude');
47
+ mkdirSync(claudeHome);
48
+ writeFileSync(join(claudeHome, 'CLAUDE.md'), 'Existing Claude guidance\n');
49
+
50
+ const competingWrite = (agentsPath) => {
51
+ writeFileSync(agentsPath, 'Existing Codex guidance\n');
52
+ throw new Error('AGENTS.md already exists');
53
+ };
54
+ assert.throws(() => seedCodexPstackModels(codexHome, competingWrite), /already exists/);
55
+ assert.equal(existsSync(join(codexHome, 'pstack-models.md')), false);
56
+ assert.equal(readFileSync(join(codexHome, 'AGENTS.md'), 'utf8'), 'Existing Codex guidance\n');
57
+ assert.equal(readFileSync(join(claudeHome, 'CLAUDE.md'), 'utf8'), 'Existing Claude guidance\n');
58
+ });
59
+
60
+ for (const existingFile of ['AGENTS.md', 'pstack-models.md']) {
61
+ test(`partial Codex setup with ${existingFile} is preserved`, (context) => {
62
+ const root = mkdtempSync(join(tmpdir(), 'cde-pstack-models-'));
63
+ context.after(() => rmSync(root, { recursive: true, force: true }));
64
+ const codexHome = join(root, '.codex');
65
+ mkdirSync(codexHome);
66
+ writeFileSync(join(codexHome, existingFile), 'Custom configuration\n');
67
+
68
+ assert.equal(seedCodexPstackModels(codexHome), null);
69
+ assert.equal(readFileSync(join(codexHome, existingFile), 'utf8'), 'Custom configuration\n');
70
+ assert.equal(existsSync(join(codexHome, existingFile === 'AGENTS.md' ? 'pstack-models.md' : 'AGENTS.md')), false);
71
+ });
72
+ }
@@ -75,7 +75,11 @@ Parameters follow YAGNI: add an optional parameter when a caller varies the valu
75
75
 
76
76
  ## 8. TDD PROCESS
77
77
 
78
- 1. **RED** — failing test first. 2. **GREEN** — minimum code to pass. 3. **REFACTOR** — only if valuable.
78
+ 1. **RED** — a failing test. 2. **GREEN** — minimum code to pass. 3. **REFACTOR** — only if valuable.
79
+
80
+ This loop is the default for a bug fix and for new behavior, and the TDD skill (`pstack:tdd`) carries the procedure. No hook or lint checks the order. A prototype may run ahead of its tests and adds them before the pull request goes ready. A bug fix ships with a test that reproduces the bug.
81
+
82
+ **Proof of check.** Each pull request body states how the change was checked: a test, a run, a screenshot, or a measurement, named so a reviewer can repeat it. A reviewer flags a pull request whose body names no proof.
79
83
 
80
84
  ## 9. SELF-CONTAINED COMPONENTS
81
85
 
@@ -11,9 +11,7 @@ Advisory only (non-blocking):
11
11
 
12
12
  Companion tests live alongside this file as
13
13
  ``test_code_rules_enforcer_<suffix>.py``; the ``<suffix>`` split keeps each
14
- concern focused. The separate ``tdd_enforcer.py`` hook accepts any
15
- ``test_code_rules_enforcer_*.py`` sibling as a test candidate for the
16
- ``code_rules_*`` module family, so the suffix files satisfy its gate.
14
+ concern focused.
17
15
  """
18
16
  import json
19
17
  import sys
@@ -36,9 +36,7 @@ def _mask_string_literals_preserving_length(source_line: str) -> str:
36
36
 
37
37
  Matching tests live in
38
38
  ``test_code_rules_enforcer_magic_string_masking.py``, one of the
39
- ``test_code_rules_enforcer_<suffix>.py`` family files the
40
- ``tdd_enforcer.py`` hook accepts as test candidates for the
41
- ``code_rules_*`` module family.
39
+ ``test_code_rules_enforcer_<suffix>.py`` family files.
42
40
  """
43
41
 
44
42
  string_literal_pattern = re.compile(
@@ -1,10 +1,9 @@
1
1
  """Public-function paired-test coverage check for ``code_rules_enforcer``.
2
2
 
3
- The TDD gate ``tdd_enforcer.py`` requires a fresh test file to exist before a
4
- production module is written, but it judges coverage at file granularity: a
5
- module whose dedicated test file exercises some public functions while leaving
6
- one untested still satisfies it. This check closes that gap from both write
7
- orders, so a forgotten function gets a behavioral test before the
3
+ The staged policy lint's test-pairing rule judges coverage at file
4
+ granularity: a module whose dedicated test file exercises some public functions
5
+ while leaving one untested still satisfies it. This check closes that gap from
6
+ both write orders, so a forgotten function gets a behavioral test before the
8
7
  partially-covered module lands.
9
8
 
10
9
  ``check_public_function_missing_paired_test`` fires on the production-module
@@ -25,7 +24,7 @@ Both checks stay conservative to keep false positives near zero:
25
24
 
26
25
  - The production-side check runs only on a production module whose stem-matched
27
26
  test file already exists; a module with no dedicated test file is out of scope
28
- and left to the file-level TDD gate. The test-side check runs only on a
27
+ and left to the staged lint's test-pairing rule. The test-side check runs only on a
29
28
  stem-matched test file whose paired production module exists on disk.
30
29
  - The production-side check fires only when the suite already exercises the
31
30
  module — covering at least one of its public functions, or referencing one of
@@ -64,7 +64,7 @@ def repository_allowlisted_values(repository_root: Path) -> frozenset[str]:
64
64
  The exact literal values the repository's commits and writes may carry
65
65
  past the PII scan, or an empty set when the repository has none.
66
66
  """
67
- origin_slug = _repository_origin_slug(repository_root)
67
+ origin_slug = repository_origin_slug(repository_root)
68
68
  if origin_slug is None:
69
69
  return frozenset()
70
70
  return pii_allowlisted_values_by_repository().get(origin_slug, frozenset())
@@ -171,7 +171,7 @@ def _owner_repo_slug_from_origin_url(origin_url: str) -> str | None:
171
171
  return _owner_repo_slug_from_path(repository_path)
172
172
 
173
173
 
174
- def _repository_origin_slug(repository_root: Path) -> str | None:
174
+ def repository_origin_slug(repository_root: Path) -> str | None:
175
175
  """Return the lowercased owner/repo slug of the origin remote, or None.
176
176
 
177
177
  Args:
@@ -211,7 +211,7 @@ def _is_repository_exempt_from_pii_scan(repository_root: Path) -> bool:
211
211
  Returns:
212
212
  True when the repository's origin slug is in the exempt set.
213
213
  """
214
- origin_slug = _repository_origin_slug(repository_root)
214
+ origin_slug = repository_origin_slug(repository_root)
215
215
  if origin_slug is None:
216
216
  return False
217
217
  return origin_slug in pii_exempt_repository_slugs()
@@ -1 +1 @@
1
- """Decomposed concern modules the ``tdd_enforcer`` entry hook wires together."""
1
+ """Content analysis the policy lint's adapter pairing rule loads to exempt constants-only and import-only edits."""
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import json
6
5
  import os
7
6
  import subprocess
8
7
  import sys
@@ -21,13 +20,11 @@ if _HOOKS_DIRECTORY not in sys.path:
21
20
  from code_rules_enforcer import main as enforcer_main # noqa: E402
22
21
  from code_rules_shared import is_ephemeral_script_path # noqa: E402
23
22
  from code_rules_enforcer_test_support import ( # noqa: E402
24
- build_write_payload,
25
23
  run_enforcer_cli,
26
24
  run_write_entrypoint,
27
25
  )
28
26
 
29
27
  _ENFORCER_SCRIPT = Path(__file__).resolve().parent / "code_rules_enforcer.py"
30
- _TDD_SCRIPT = Path(__file__).resolve().parent / "tdd_enforcer.py"
31
28
 
32
29
  _VIOLATING_PRODUCTION_SOURCE = "def process_data(payload: str) -> None:\n print(payload)\n"
33
30
 
@@ -56,44 +53,6 @@ def _run_main_with_write_payload(
56
53
  return run_write_entrypoint(enforcer_main, file_path, content)
57
54
 
58
55
 
59
- def _run_tdd_with_write_payload(
60
- file_path: str,
61
- content: str,
62
- ) -> subprocess.CompletedProcess[str]:
63
- """Drive the TDD enforcer through subprocess with a Write payload.
64
-
65
- Args:
66
- file_path: The destination path the Write targets.
67
- content: The production-looking content to write.
68
-
69
- Returns:
70
- The completed process carrying stdout, stderr, and the exit code.
71
- """
72
- write_payload = build_write_payload(file_path, content)
73
- return subprocess.run(
74
- [sys.executable, str(_TDD_SCRIPT)],
75
- input=write_payload,
76
- capture_output=True,
77
- text=True,
78
- )
79
-
80
-
81
- def _decision_from(completed: subprocess.CompletedProcess[str]) -> str | None:
82
- """Extract the permissionDecision from a hook's JSON stdout.
83
-
84
- Args:
85
- completed: The completed subprocess carrying the hook's stdout.
86
-
87
- Returns:
88
- The permissionDecision string, or None when stdout is empty.
89
- """
90
- if not completed.stdout:
91
- return None
92
- parsed = json.loads(completed.stdout)
93
- hook_output = parsed.get("hookSpecificOutput", {})
94
- return hook_output.get("permissionDecision")
95
-
96
-
97
56
  def test_should_return_true_for_claude_job_dir_tmp_path(
98
57
  tmp_path: Path,
99
58
  monkeypatch: pytest.MonkeyPatch,
@@ -320,7 +279,7 @@ def test_should_exempt_same_path_set_on_both_gates(
320
279
  tmp_path: Path,
321
280
  monkeypatch: pytest.MonkeyPatch,
322
281
  ) -> None:
323
- """B21: every ephemeral path exits 0 on both enforcer main and TDD enforcer main."""
282
+ """B21: every ephemeral path exits 0 on enforcer main."""
324
283
  monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
325
284
  all_ephemeral_paths = [
326
285
  str(tmp_path / "tmp" / "scratch.py"),
@@ -339,7 +298,3 @@ def test_should_exempt_same_path_set_on_both_gates(
339
298
  assert "deny" not in captured_stdout.lower(), (
340
299
  f"enforcer must not deny ephemeral path {each_ephemeral_path!r}"
341
300
  )
342
- completed = _run_tdd_with_write_payload(each_ephemeral_path, _VIOLATING_PRODUCTION_SOURCE)
343
- assert _decision_from(completed) != "deny", (
344
- f"TDD enforcer must not deny ephemeral path {each_ephemeral_path!r}"
345
- )
@@ -496,8 +496,8 @@ def test_aggregate_exit_code_zero_with_no_output_allows() -> None:
496
496
  def test_aggregate_explicit_allow_payload_signals_allow_decision() -> None:
497
497
  """An explicit permissionDecision allow from a hosted hook signals an allow decision.
498
498
 
499
- tdd_enforcer writes an explicit allow payload on its allow path, which
500
- auto-approves the write standalone. The aggregator must surface that as an
499
+ A hosted hook can write an explicit allow payload, which auto-approves the
500
+ write standalone. The aggregator must surface that as an
501
501
  explicit allow decision so the dispatcher re-emits it rather than silently
502
502
  falling back to the default permission flow.
503
503
  """
@@ -680,43 +680,6 @@ def _orphan_claude_md_payload(tmp_path: Path) -> str:
680
680
  return _write_payload(claude_md_path, orphan_table)
681
681
 
682
682
 
683
- def _parse_hook_allow(completed_process: subprocess.CompletedProcess[str]) -> bool:
684
- """Parse one hook's subprocess result for an explicit permissionDecision allow.
685
-
686
- Args:
687
- completed_process: The completed subprocess from running a hook.
688
-
689
- Returns:
690
- True when the hook emitted an explicit allow decision.
691
- """
692
- stdout_text = completed_process.stdout.strip()
693
- if not stdout_text:
694
- return False
695
- try:
696
- parsed_output = json.loads(stdout_text)
697
- except json.JSONDecodeError:
698
- return False
699
- hook_specific = parsed_output.get("hookSpecificOutput", {})
700
- if not isinstance(hook_specific, dict):
701
- return False
702
- return hook_specific.get("permissionDecision") == "allow"
703
-
704
-
705
- def test_retired_tdd_hook_cannot_auto_approve_the_write(tmp_path: Path) -> None:
706
- """Pairing belongs to staged lint; its edit-time auto-approval is absent."""
707
- config_target_path = str(tmp_path / "config" / "timing.py")
708
- constants_only_content = (
709
- '"""Timing constants."""\n\nMAXIMUM_RETRIES = 3\nRETRY_DELAY_SECONDS = 5\n'
710
- )
711
- payload_text = _write_payload(config_target_path, constants_only_content)
712
- standalone_result = _run_hook_subprocess("blocking/tdd_enforcer.py", payload_text)
713
- assert _parse_hook_allow(standalone_result)
714
- dispatcher_result = _run_dispatcher(payload_text)
715
- dispatcher_is_deny, reason = _parse_hook_decision(dispatcher_result)
716
- assert not dispatcher_is_deny, reason
717
- assert not _parse_hook_allow(dispatcher_result)
718
-
719
-
720
683
  def test_runpy_hosted_hook_sees_its_own_argv_not_the_dispatchers(tmp_path: Path) -> None:
721
684
  """A runpy-hosted hook resolves its own script path as sys.argv, not the dispatcher's.
722
685
 
@@ -851,7 +814,6 @@ ALL_RETAINED_PATHS = {
851
814
  ALL_RETIRED_PATHS = {
852
815
  "validation/hook_format_validator.py",
853
816
  "blocking/code_rules_enforcer.py",
854
- "blocking/tdd_enforcer.py",
855
817
  "blocking/windows_rmtree_blocker.py",
856
818
  "blocking/state_description_blocker.py",
857
819
  "blocking/subprocess_budget_completeness.py",
@@ -19,6 +19,7 @@ from pii_prevention_blocker_parts.repository_exemption import (
19
19
  _is_repository_exempt_from_pii_scan,
20
20
  _owner_repo_slug_from_origin_url,
21
21
  repository_allowlisted_values,
22
+ repository_origin_slug,
22
23
  )
23
24
 
24
25
  _ALLOWED_VALUE = "owner.fixture" + "@" + "acme-corp" + ".example" + ".io"
@@ -103,3 +104,9 @@ def test_repository_without_origin_is_never_exempt(tmp_path: Path) -> None:
103
104
 
104
105
  def test_owner_repo_slug_strips_a_trailing_slash_origin() -> None:
105
106
  assert _owner_repo_slug_from_origin_url("https://github.com/Owner/Repo/") == "owner/repo"
107
+
108
+
109
+ def test_repository_origin_slug_reads_the_origin_remote(tmp_path: Path) -> None:
110
+ repository_root = tmp_path / "repo"
111
+ _init_repo_with_github_origin(repository_root, "SlugOwner/slug-repo")
112
+ assert repository_origin_slug(repository_root) == "slugowner/slug-repo"
@@ -31,6 +31,8 @@ from hooks_constants.followup_ledger_constants import (
31
31
  GIT_REFERENCE_PREFIX,
32
32
  LEDGER_APPEND_MODE,
33
33
  LEDGER_ENCODING,
34
+ LEDGER_IGNORE_FILE_NAME,
35
+ LEDGER_IGNORE_TEXT,
34
36
  MESSAGE_KEY,
35
37
  ORIGIN_COMMIT_KEY,
36
38
  RULE_ID_KEY,
@@ -88,7 +90,9 @@ def record_followup_finding(repository_root: Path, finding: FollowupFinding) ->
88
90
  """Append one finding to the repository's ledger, once.
89
91
 
90
92
  A finding already present in the ledger is left alone, so a gate that runs
91
- on every commit records a standing smell a single time. Every filesystem
93
+ on every commit records a standing smell a single time. The ledger
94
+ directory carries a ``.gitignore`` matching every file in it, so the
95
+ ledger stays out of ``git status`` in any repository. Every filesystem
92
96
  error is swallowed, so a ledger failure leaves the caller's gate decision
93
97
  unchanged.
94
98
 
@@ -116,6 +120,9 @@ def record_followup_finding(repository_root: Path, finding: FollowupFinding) ->
116
120
  )
117
121
  try:
118
122
  ledger_path.parent.mkdir(parents=True, exist_ok=True)
123
+ ignore_path = ledger_path.parent / LEDGER_IGNORE_FILE_NAME
124
+ if not ignore_path.exists():
125
+ ignore_path.write_text(LEDGER_IGNORE_TEXT, encoding=LEDGER_ENCODING)
119
126
  with ledger_path.open(LEDGER_APPEND_MODE, encoding=LEDGER_ENCODING) as ledger_file:
120
127
  ledger_file.write(record_text + "\n")
121
128
  except OSError:
@@ -14,7 +14,6 @@ from test_native_hook_support import run_git
14
14
 
15
15
  ALL_RETIRED_HOOK_PATHS = (
16
16
  "blocking/code_rules_enforcer.py",
17
- "blocking/tdd_enforcer.py",
18
17
  "blocking/windows_rmtree_blocker.py",
19
18
  "blocking/state_description_blocker.py",
20
19
  "blocking/subprocess_budget_completeness.py",
@@ -26,6 +25,7 @@ ALL_RETIRED_HOOK_PATHS = (
26
25
  "blocking/plain_language_blocker.py",
27
26
  "lifecycle/config_change_guard.py",
28
27
  )
28
+ ALL_ARCHIVED_HOOK_PATHS = ("blocking/tdd_enforcer.py",)
29
29
  PACKAGE_ROOT = Path(__file__).resolve().parents[2]
30
30
  ALL_RULE_FIXTURES = (
31
31
  ("code-rules", "src/worker.py", "from typing import Any\n\ndef worker() -> Any:\n return None\n"),
@@ -80,7 +80,7 @@ def _legacy_settings_bytes(managed_root: Path, foreign_command: str) -> bytes:
80
80
  "type": "command",
81
81
  "command": f"python {managed_root.as_posix()}/hooks/{each_path}",
82
82
  "timeout": 10,
83
- } for each_path in ALL_RETIRED_HOOK_PATHS),
83
+ } for each_path in (*ALL_RETIRED_HOOK_PATHS, *ALL_ARCHIVED_HOOK_PATHS)),
84
84
  ],
85
85
  }]},
86
86
  }
@@ -135,6 +135,8 @@ def _assert_retired_registrations(managed_root: Path, settings_bytes: bytes, for
135
135
  source = PACKAGE_ROOT / "hooks" / each_relative_path
136
136
  installed = managed_root / "hooks" / each_relative_path
137
137
  assert hashlib.sha256(installed.read_bytes()).digest() == hashlib.sha256(source.read_bytes()).digest()
138
+ for each_relative_path in ALL_ARCHIVED_HOOK_PATHS:
139
+ assert not any(each_relative_path in each_command for each_command in all_commands)
138
140
 
139
141
 
140
142
  @pytest.fixture(scope="module")
package/hooks/hooks.json CHANGED
@@ -51,6 +51,16 @@
51
51
  "timeout": 25
52
52
  }
53
53
  ]
54
+ },
55
+ {
56
+ "matcher": "*",
57
+ "hooks": [
58
+ {
59
+ "type": "command",
60
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/nested_project_hooks.py",
61
+ "timeout": 120
62
+ }
63
+ ]
54
64
  }
55
65
  ],
56
66
  "UserPromptSubmit": [
@@ -108,6 +118,11 @@
108
118
  "type": "command",
109
119
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/issue_tracker_session_starter.py",
110
120
  "timeout": 10
121
+ },
122
+ {
123
+ "type": "command",
124
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/nested_project_hooks.py",
125
+ "timeout": 1800
111
126
  }
112
127
  ]
113
128
  },
@@ -6,6 +6,8 @@ __all__ = [
6
6
  "ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS",
7
7
  "LEDGER_ENCODING",
8
8
  "LEDGER_APPEND_MODE",
9
+ "LEDGER_IGNORE_FILE_NAME",
10
+ "LEDGER_IGNORE_TEXT",
9
11
  "RULE_ID_KEY",
10
12
  "FILE_PATH_KEY",
11
13
  "MESSAGE_KEY",
@@ -24,6 +26,8 @@ __all__ = [
24
26
  ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS: tuple[str, ...] = (".claude", "followups", "smells.jsonl")
25
27
  LEDGER_ENCODING: str = "utf-8"
26
28
  LEDGER_APPEND_MODE: str = "a"
29
+ LEDGER_IGNORE_FILE_NAME: str = ".gitignore"
30
+ LEDGER_IGNORE_TEXT: str = "*\n"
27
31
 
28
32
  RULE_ID_KEY: str = "rule_id"
29
33
  FILE_PATH_KEY: str = "file_path"
@@ -1,6 +1,5 @@
1
1
  """User-facing notice messages for blocking hooks."""
2
2
 
3
- USER_FACING_TDD_NOTICE = "TDD gate held - writing the failing test first..."
4
3
  USER_FACING_ASKUSERQUESTION_NOTICE = (
5
4
  "Agent asked the user in prose - rerouting through AskUserQuestion..."
6
5
  )
@@ -0,0 +1,37 @@
1
+ """Configuration constants for the ``nested_project_hooks`` forwarder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ PROJECT_DIRECTORY_ENVIRONMENT_VARIABLE: str = "CLAUDE_PROJECT_DIR"
6
+ CHECKOUT_MARKER_NAME: str = ".git"
7
+ SETTINGS_DIRECTORY_NAME: str = ".claude"
8
+ SETTINGS_FILE_NAME: str = "settings.json"
9
+ SETTINGS_ENCODING: str = "utf-8"
10
+
11
+ HOOKS_KEY: str = "hooks"
12
+ MATCHER_KEY: str = "matcher"
13
+ HOOK_TYPE_KEY: str = "type"
14
+ COMMAND_HOOK_TYPE: str = "command"
15
+ COMMAND_KEY: str = "command"
16
+ TIMEOUT_KEY: str = "timeout"
17
+ DEFAULT_HOOK_TIMEOUT_SECONDS: float = 600.0
18
+ ALL_MATCH_EVERYTHING_MATCHERS: frozenset[str] = frozenset({"", "*"})
19
+
20
+ HOOK_EVENT_NAME_KEY: str = "hook_event_name"
21
+ PRE_TOOL_USE_EVENT: str = "PreToolUse"
22
+ SESSION_START_EVENT: str = "SessionStart"
23
+ ALL_MATCH_TARGET_KEYS_BY_EVENT: dict[str, str] = {
24
+ PRE_TOOL_USE_EVENT: "tool_name",
25
+ SESSION_START_EVENT: "source",
26
+ }
27
+
28
+ ALL_SHELL_PROGRAM_NAMES: tuple[str, ...] = ("bash", "sh")
29
+ SHELL_COMMAND_FLAG: str = "-c"
30
+
31
+ BLOCKING_EXIT_CODE: int = 2
32
+ HOOK_SPECIFIC_OUTPUT_KEY: str = "hookSpecificOutput"
33
+ HOOK_EVENT_NAME_OUTPUT_KEY: str = "hookEventName"
34
+ PERMISSION_DECISION_KEY: str = "permissionDecision"
35
+ ALL_FORWARDED_PERMISSION_DECISIONS: frozenset[str] = frozenset({"deny", "ask"})
36
+ ADDITIONAL_CONTEXT_KEY: str = "additionalContext"
37
+ CONTEXT_SECTION_SEPARATOR: str = "\n\n"
@@ -4,17 +4,11 @@ from hooks_constants import messages
4
4
 
5
5
 
6
6
  def test_module_carries_no_hedging_or_intent_ending_notices() -> None:
7
- """The hedging and intent-only-ending Stop hooks are removed; their
8
- dedicated notices no longer live here."""
7
+ """The module carries no hedging or intent-only-ending notice."""
9
8
  assert not hasattr(messages, "USER_FACING_NOTICE")
10
9
  assert not hasattr(messages, "USER_FACING_INTENT_ENDING_NOTICE")
11
10
 
12
11
 
13
- def test_user_facing_tdd_notice_is_nonempty_string() -> None:
14
- assert isinstance(messages.USER_FACING_TDD_NOTICE, str)
15
- assert messages.USER_FACING_TDD_NOTICE
16
-
17
-
18
12
  def test_user_facing_askuserquestion_notice_is_nonempty_string() -> None:
19
13
  assert isinstance(messages.USER_FACING_ASKUSERQUESTION_NOTICE, str)
20
14
  assert messages.USER_FACING_ASKUSERQUESTION_NOTICE