claude-dev-env 2.17.0 → 2.19.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 (117) hide show
  1. package/.agents/skills/AGENTS.md +4 -4
  2. package/.agents/skills/_shared/AGENTS.md +1 -0
  3. package/.agents/skills/_shared/pr-loop/AGENTS.md +1 -0
  4. package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +89 -0
  5. package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
  6. package/.agents/skills/orchestrator/SKILL.md +6 -4
  7. package/.agents/skills/orchestrator-refresh/SKILL.md +9 -5
  8. package/.agents/skills/pr-cleanup/SKILL.md +17 -85
  9. package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
  10. package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
  11. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
  12. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
  13. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
  14. package/.agents/skills/pr-refinement/SKILL.md +31 -0
  15. package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
  16. package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
  17. package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
  18. package/.agents/skills/skill-builder/SKILL.md +1 -0
  19. package/.agents/skills/team-advisor/SKILL.md +5 -5
  20. package/.agents/skills-archived/bugteam/SKILL.md +1 -1
  21. package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
  22. package/.agents/skills-archived/comments/SKILL.md +1 -1
  23. package/.agents/skills-archived/descriptions/SKILL.md +1 -1
  24. package/.agents/skills-archived/reviews/SKILL.md +1 -1
  25. package/AGENTS.md +1 -1
  26. package/_shared/advisor/AGENTS.md +5 -5
  27. package/_shared/advisor/advisor-protocol.md +54 -31
  28. package/_shared/advisor/reference/advisor-block.md +5 -1
  29. package/_shared/advisor/reference/consult-format.md +1 -1
  30. package/_shared/advisor/reference/identity.md +28 -0
  31. package/_shared/advisor/reference/lifecycle.md +8 -1
  32. package/_shared/advisor/reference/sol-rung.md +12 -7
  33. package/_shared/advisor/reference/spawn-walk-log.md +6 -5
  34. package/_shared/advisor/reference/third-party-bind.md +5 -6
  35. package/_shared/advisor/reference/warm-up.md +9 -2
  36. package/_shared/advisor/scripts/codex_sol_advisor.py +66 -11
  37. package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +9 -0
  38. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +16 -4
  39. package/_shared/advisor/scripts/config/advisor_scripts_constants/sol_advisor_constants.py +11 -10
  40. package/_shared/advisor/scripts/model_tier_run_validator.py +50 -23
  41. package/_shared/advisor/scripts/tests/test_codex_sol_advisor.py +108 -3
  42. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +171 -82
  43. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +43 -0
  44. package/_shared/advisor/scripts/tier_model_ids.py +75 -7
  45. package/_shared/pr-loop/AGENTS.md +2 -1
  46. package/_shared/pr-loop/preflight-proposal.md +56 -0
  47. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
  48. package/_shared/pr-loop/worker-spawn.md +4 -3
  49. package/bin/ever-shipped-skills.mjs +14 -0
  50. package/bin/install-constants.mjs +5 -0
  51. package/bin/install.agents-home.test.mjs +23 -0
  52. package/bin/install.codex-rules.test.mjs +61 -6
  53. package/bin/install.mjs +57 -9
  54. package/bin/install.prune.test.mjs +65 -3
  55. package/bin/install.test.mjs +202 -2
  56. package/docs/CODE_RULES.md +4 -0
  57. package/docs/references/team-advisor-skill.md +2 -2
  58. package/hooks/advisory/migration_safety_advisor.py +14 -14
  59. package/hooks/advisory/test_migration_safety_advisor.py +120 -0
  60. package/hooks/atomic_file_writer.py +81 -0
  61. package/hooks/blocking/AGENTS.md +2 -1
  62. package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
  63. package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
  64. package/hooks/blocking/destructive_command_blocker.py +8 -7
  65. package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
  66. package/hooks/blocking/precommit_code_rules_gate.py +14 -139
  67. package/hooks/blocking/sensitive_file_protector.py +8 -6
  68. package/hooks/blocking/session_edit_stage_gate.py +31 -1
  69. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
  70. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
  71. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
  72. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
  73. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
  74. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +308 -0
  75. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
  76. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
  77. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
  78. package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
  79. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
  80. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +42 -25
  81. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
  82. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
  83. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
  84. package/hooks/blocking/test_hook_subprocess_support.py +296 -0
  85. package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
  86. package/hooks/blocking/test_pii_scanner.py +81 -0
  87. package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
  88. package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
  89. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +168 -0
  90. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
  91. package/hooks/blocking/test_sensitive_file_protector.py +145 -5
  92. package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
  93. package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
  94. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
  95. package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
  96. package/hooks/blocking/test_test_preflight_check.py +80 -0
  97. package/hooks/git-hooks/pre_commit.py +6 -2
  98. package/hooks/git-hooks/test_pre_push.py +57 -0
  99. package/hooks/hooks_constants/AGENTS.md +3 -1
  100. package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
  101. package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
  102. package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
  103. package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
  104. package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
  105. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
  106. package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
  107. package/hooks/json_file_reader.py +22 -0
  108. package/hooks/pending_sidecars.py +33 -0
  109. package/hooks/test_atomic_file_writer.py +73 -0
  110. package/hooks/test_json_file_reader.py +24 -0
  111. package/hooks/test_pending_sidecars.py +25 -0
  112. package/package.json +1 -1
  113. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
  114. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
  115. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
  116. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
  117. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
@@ -11,6 +11,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
11
11
  | `ask_user_question_shape.py` | Pure AskUserQuestion lean-block shape analyzer: grades question and option-description prose against shared chat-detail markers and sentence/word caps with no hook I/O |
12
12
  | `banned_identifiers_constants.py` | The set of banned short identifiers and banned function-name prefixes |
13
13
  | `bash_pre_tool_use_dispatcher_constants.py` | Permission outcomes, tool-name sets, and the ordered hosted-hook roster for the Bash PreToolUse dispatcher (also covers PowerShell-shared gates) |
14
+ | `pre_tool_use_dispatcher_constants.py` | Shared Write, Edit, and MultiEdit tool-name set and the ordered hosted-hook roster for the Write/Edit/MultiEdit PreToolUse dispatcher |
14
15
  | `blocking_check_limits.py` | Max issue counts and preview lengths for blocking hooks |
15
16
  | `bot_mention_comment_blocker_constants.py` | Patterns for detecting bot @-mentions in PR comments |
16
17
  | `claude_md_orphan_file_blocker_constants.py` | Table patterns, file extensions, scan budget, and block-message text for the CLAUDE.md orphan-file blocker |
@@ -24,6 +25,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
24
25
  | `dead_config_field_constants.py` | Patterns for detecting unused `*Config` / `*Selectors` dataclass fields |
25
26
  | `dead_dataclass_field_constants.py` | Patterns for detecting unused dataclass fields |
26
27
  | `dead_module_constant_constants.py` | Patterns for detecting unexported `UPPER_SNAKE` constants in `*_constants.py` modules |
28
+ | `destructive_command_environment_constants.py` | Environment keys and shared truthy values used by destructive-command hooks |
27
29
  | `destructive_command_segment_constants.py` | The list of destructive shell command patterns the blocker matches |
28
30
  | `docstring_rule_gate_count_blocker_constants.py` | Target rule basename, spelled-out-number lookup, count-clause and `check_*` validator patterns, and block-message text for the docstring-rule gate-count staleness blocker |
29
31
  | `duplicate_function_body_constants.py` | Hashing and comparison config for the duplicate-body check |
@@ -62,7 +64,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
62
64
  | `python_style_checks_constants.py` | Command-line argument count and blank-line count between top-level functions for the style validator |
63
65
  | `ruff_integration_constants.py` | ``NO_COLOR`` / ``FORCE_COLOR`` environment variable names for plain ruff diagnostics |
64
66
  | `send_user_file_open_locally_blocker_constants.py` | Tool name, proactive status, and the block message for the open-locally attach blocker |
65
- | `sensitive_file_protector_constants.py` | Sensitive filename patterns, the committed-template suffixes that earn an exemption, the write/edit tool names, and the deny decision and message template for `sensitive_file_protector` |
67
+ | `sensitive_file_protector_constants.py` | Sensitive filename patterns, committed-template suffixes that earn an exemption, and the deny decision and message template for `sensitive_file_protector` |
66
68
  | `session_edit_stage_gate_constants.py` | Tracker filename prefix/suffix, JSON payload key, edit tool name set, session-id sanitize pattern, lock filename suffix and lock-acquire timing, git diff command, commit flag escapes, and deny-message template shared by the session edit stage gate trio |
67
69
  | `session_env_cleanup_constants.py` | Stale-age threshold and directory names for the session-env cleanup hook |
68
70
  | `session_start_injector.py` | Pure SessionStart context injector: normalize `source` (`startup`/`resume`/`clear`/`compact`), honor enable/timeout, return structured injection results for starter hooks |
@@ -0,0 +1,4 @@
1
+ """Constants for atomic hook-state text replacement."""
2
+
3
+ TEXT_WRITE_MODE = "w"
4
+ ATOMIC_WRITE_TEMPORARY_SUFFIX = ".tmp"
@@ -38,6 +38,7 @@ ALL_BANNED_NOUN_WORDS: frozenset[str] = frozenset(
38
38
  "temp",
39
39
  }
40
40
  )
41
+ BANNED_NOUN_COMPATIBILITY_COMMENT: str = "# pragma: no-banned-noun"
41
42
  CAMEL_CASE_WORD_PATTERN: re.Pattern[str] = re.compile(
42
43
  r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+"
43
44
  )
@@ -0,0 +1,18 @@
1
+ """Environment keys and values used by destructive-command hooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ ALL_KNOWN_TEMPORARY_ENVIRONMENT_VARIABLE_NAMES: frozenset[str] = frozenset(
6
+ {
7
+ "TEMP",
8
+ "TMP",
9
+ "TMPDIR",
10
+ "CLAUDE_JOB_DIR",
11
+ }
12
+ )
13
+ ALL_TRUTHY_ENV_VALUES: frozenset[str] = frozenset({"1", "true", "yes", "on"})
14
+ DESTRUCTIVE_DENY_MODE_ENV_VAR: str = "CLAUDE_DESTRUCTIVE_DENY_MODE"
15
+ EPHEMERAL_AUTO_ALLOW_DISABLE_ENV_VAR: str = (
16
+ "CLAUDE_DESTRUCTIVE_DISABLE_EPHEMERAL_AUTO_ALLOW"
17
+ )
18
+ GH_REDIRECT_ACTIVE_ENV_VAR: str = "CLAUDE_GH_REDIRECT_ACTIVE"
@@ -1,4 +1,4 @@
1
- """Segment-splitting, command-name, and deny-mode constants for the destructive command blocker."""
1
+ """Segment-splitting and command-name constants for the destructive command blocker."""
2
2
 
3
3
  ALL_SHELL_CONTROL_OPERATOR_TOKENS: frozenset[str] = frozenset({"&&", "||", ";", "|&", "|", "&", "\n", "\r"})
4
4
  ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS: frozenset[str] = frozenset(
@@ -183,13 +183,3 @@ ALL_LAUNCHER_OPTIONS_TAKING_SEPARATE_VALUE: frozenset[str] = frozenset(
183
183
  }
184
184
  )
185
185
  ALL_SUBSHELL_GROUPING_CHARACTERS: str = "({"
186
- ALL_KNOWN_TEMPORARY_ENVIRONMENT_VARIABLE_NAMES: frozenset[str] = frozenset(
187
- {
188
- "TEMP",
189
- "TMP",
190
- "TMPDIR",
191
- "CLAUDE_JOB_DIR",
192
- }
193
- )
194
- DESTRUCTIVE_DENY_MODE_ENV_VAR: str = "CLAUDE_DESTRUCTIVE_DENY_MODE"
195
- ALL_TRUTHY_ENV_VALUES: frozenset[str] = frozenset({"1", "true", "yes", "on"})
@@ -4,9 +4,15 @@ import re
4
4
 
5
5
  HARDCODED_USER_PATH_PATTERN: re.Pattern[str] = re.compile(
6
6
  r"(?:"
7
- r"[A-Za-z]:[\\/](?i:users)[\\/](?!(?i:Public|Shared|All Users)(?:[\\/]|$))[^\\/]+(?=[\\/]|$)"
8
- r"|(?<![A-Za-z:])/Users/(?!(?i:Shared|Public)(?:/|$))[^/]+(?=/|$)"
9
- r"|/home/[^/]+(?=/|$))"
7
+ r"[A-Za-z]:[\\/]+(?i:users)[\\/]+(?!(?i:Public|Shared|All Users)(?:[\\/]|$))[^\\/]+(?=[\\/]|$)"
8
+ r"|\\{2,}(?:[^\\/\r\n]+[\\/]+)+(?i:users)[\\/]+(?!(?i:Public|Shared|All Users)(?:[\\/]|$))[^\\/]+(?=[\\/]|$)"
9
+ r"|(?<!:)//(?:[^\\/\r\n]+/)+(?i:users)/(?!(?i:Public|Shared|All Users)(?:/|$))[^/]+(?=/|$)"
10
+ r"|(?i:file):///Users/(?!(?i:Shared|Public)(?:/|$))[^/]+(?=/|$)"
11
+ r"|(?i:file)://(?:[^/\r\n]+/)+(?i:Users)/(?!(?i:Shared|Public)(?:/|$))[^/]+(?=/|$)"
12
+ r"|(?i:file):///home/[^/]+(?=/|$)"
13
+ r"|(?i:file)://(?:[^/\r\n]+/)+(?i:home)/[^/]+(?=/|$)"
14
+ r"|(?<![A-Za-z0-9._:/\-\]])/Users/(?!(?i:Shared|Public)(?:/|$))[^/]+(?=/|$)"
15
+ r"|(?<![A-Za-z0-9._:/\-\]])/home/[^/]+(?=/|$))"
10
16
  )
11
17
  MAX_HARDCODED_USER_PATH_ISSUES: int = 25
12
18
  HARDCODED_USER_PATH_GUIDANCE: str = "use pathlib.Path.home() or os.path.expanduser('~') instead of a hardcoded user directory"
@@ -82,7 +82,7 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
82
82
  ),
83
83
  HostedHookEntry(
84
84
  script_relative_path="blocking/sensitive_file_protector.py",
85
- applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
85
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
86
86
  ),
87
87
  HostedHookEntry(
88
88
  script_relative_path="blocking/pii_prevention_blocker.py",
@@ -1,7 +1,7 @@
1
1
  """Constants for the ``sensitive_file_protector`` PreToolUse hook.
2
2
 
3
- The hook denies a Write or Edit whose target basename names a live secret or a
4
- lock file, and steps aside for a placeholders-only committed template.
3
+ The hook denies a Write, Edit, or MultiEdit whose target basename names a live
4
+ secret or a lock file, and steps aside for a placeholders-only committed template.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
@@ -28,8 +28,6 @@ ALL_SENSITIVE_PATTERNS = (
28
28
 
29
29
  ALL_TEMPLATE_SUFFIXES = (".example", ".sample", ".template")
30
30
 
31
- ALL_WRITE_EDIT_TOOLS = ("Write", "Edit")
32
-
33
31
  HOOK_SCRIPT_NAME = "sensitive_file_protector.py"
34
32
 
35
33
  HOOK_EVENT_NAME = "PreToolUse"
@@ -0,0 +1,22 @@
1
+ """Safe JSON-object reads for hook-state files."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+
7
+ def read_json_object(file_path: Path, encoding: str) -> dict[str, object] | None:
8
+ """Return one decoded JSON object.
9
+
10
+ Args:
11
+ file_path: The JSON file path.
12
+ encoding: The text encoding.
13
+
14
+ Returns:
15
+ The decoded object, or ``None`` for missing, unreadable, or malformed data.
16
+ """
17
+ try:
18
+ raw_text = file_path.read_text(encoding=encoding)
19
+ parsed_payload = json.loads(raw_text)
20
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
21
+ return None
22
+ return parsed_payload if isinstance(parsed_payload, dict) else None
@@ -0,0 +1,33 @@
1
+ """Pending-sidecar discovery and cleanup for hook state."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def pending_sidecar_paths(
7
+ main_file: Path, pending_marker: str, pending_suffix: str
8
+ ) -> tuple[Path, ...]:
9
+ """Return pending sidecars for one main state file.
10
+
11
+ Args:
12
+ main_file: The primary state file.
13
+ pending_marker: The marker before the unique sidecar token.
14
+ pending_suffix: The sidecar filename suffix.
15
+
16
+ Returns:
17
+ The matching pending sidecar paths.
18
+ """
19
+ pending_pattern = f"{main_file.name}{pending_marker}*{pending_suffix}"
20
+ return tuple(main_file.parent.glob(pending_pattern))
21
+
22
+
23
+ def remove_pending_sidecars(all_pending_files: tuple[Path, ...]) -> None:
24
+ """Remove a captured pending-sidecar set.
25
+
26
+ Args:
27
+ all_pending_files: The exact sidecars already merged or consumed.
28
+ """
29
+ for each_pending_file in all_pending_files:
30
+ try:
31
+ each_pending_file.unlink(missing_ok=True)
32
+ except OSError:
33
+ continue
@@ -0,0 +1,73 @@
1
+ """Behavior tests for atomic hook-state text replacement."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ _hooks_directory = str(Path(__file__).resolve().parent)
9
+ if _hooks_directory not in sys.path:
10
+ sys.path.insert(0, _hooks_directory)
11
+
12
+ import atomic_file_writer
13
+ from atomic_file_writer import write_text_atomically
14
+
15
+
16
+ def test_write_text_atomically_creates_parent_and_replaces_complete_text(
17
+ tmp_path: Path,
18
+ ) -> None:
19
+ target_path = tmp_path / "state" / "cache.json"
20
+
21
+ write_text_atomically(
22
+ target_path,
23
+ '{"complete": true}',
24
+ encoding="utf-8",
25
+ temporary_prefix=".cache-",
26
+ temporary_suffix=".tmp",
27
+ should_reap_orphans=False,
28
+ )
29
+
30
+ assert target_path.read_text(encoding="utf-8") == '{"complete": true}'
31
+
32
+
33
+ def test_write_text_atomically_preserves_target_and_cleans_temp_after_replace_error(
34
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
35
+ ) -> None:
36
+ target_path = tmp_path / "cache.json"
37
+ target_path.write_text("stable", encoding="utf-8")
38
+
39
+ def _raise_replace_error(source_path: object, destination_path: object) -> None:
40
+ del source_path, destination_path
41
+ raise OSError("replace failed")
42
+
43
+ monkeypatch.setattr(atomic_file_writer.os, "replace", _raise_replace_error)
44
+
45
+ with pytest.raises(OSError, match="replace failed"):
46
+ write_text_atomically(
47
+ target_path,
48
+ "replacement",
49
+ encoding="utf-8",
50
+ temporary_prefix=".cache-",
51
+ temporary_suffix=".tmp",
52
+ should_reap_orphans=False,
53
+ )
54
+
55
+ assert target_path.read_text(encoding="utf-8") == "stable"
56
+ assert list(tmp_path.glob(".cache-*.tmp")) == []
57
+
58
+
59
+ def test_write_text_atomically_reaps_orphans(tmp_path: Path) -> None:
60
+ target_path = tmp_path / "cache.json"
61
+ orphan_path = tmp_path / ".cache-orphan.tmp"
62
+ orphan_path.write_text("orphan", encoding="utf-8")
63
+
64
+ write_text_atomically(
65
+ target_path,
66
+ "replacement",
67
+ encoding="utf-8",
68
+ temporary_prefix=".cache-",
69
+ temporary_suffix=".tmp",
70
+ should_reap_orphans=True,
71
+ )
72
+
73
+ assert not orphan_path.exists()
@@ -0,0 +1,24 @@
1
+ """Behavior tests for safe JSON-object reads."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ _hooks_directory = str(Path(__file__).resolve().parent)
7
+ if _hooks_directory not in sys.path:
8
+ sys.path.insert(0, _hooks_directory)
9
+
10
+ from json_file_reader import read_json_object
11
+
12
+
13
+ def test_read_json_object_returns_decoded_mapping(tmp_path: Path) -> None:
14
+ file_path = tmp_path / "state.json"
15
+ file_path.write_text('{"status": "ready"}', encoding="utf-8")
16
+
17
+ assert read_json_object(file_path, "utf-8") == {"status": "ready"}
18
+
19
+
20
+ def test_read_json_object_returns_none_for_invalid_bytes(tmp_path: Path) -> None:
21
+ file_path = tmp_path / "state.json"
22
+ file_path.write_bytes(b"\xff")
23
+
24
+ assert read_json_object(file_path, "utf-8") is None
@@ -0,0 +1,25 @@
1
+ """Behavior tests for pending-sidecar discovery and cleanup."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ _hooks_directory = str(Path(__file__).resolve().parent)
7
+ if _hooks_directory not in sys.path:
8
+ sys.path.insert(0, _hooks_directory)
9
+
10
+ from pending_sidecars import pending_sidecar_paths, remove_pending_sidecars
11
+
12
+
13
+ def test_pending_sidecar_paths_and_remove_pending_sidecars(tmp_path: Path) -> None:
14
+ main_file = tmp_path / "state.json"
15
+ first_pending = tmp_path / "state.json.pending-first.json"
16
+ second_pending = tmp_path / "state.json.pending-second.json"
17
+ unrelated_file = tmp_path / "other.json"
18
+ for each_file in (first_pending, second_pending, unrelated_file):
19
+ each_file.write_text("state", encoding="utf-8")
20
+
21
+ all_pending_files = pending_sidecar_paths(main_file, ".pending-", ".json")
22
+ remove_pending_sidecars(all_pending_files)
23
+
24
+ assert sorted(all_pending_files) == sorted((first_pending, second_pending))
25
+ assert unrelated_file.is_file()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.17.0",
3
+ "version": "2.19.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {