claude-dev-env 2.30.1 → 2.31.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/.agents/agents/pr-description-writer.md +1 -1
- package/AGENTS.md +6 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
- package/bin/install.prune.test.mjs +4 -2
- package/docs/CODE_RULES.md +11 -0
- package/hooks/advisory/migration_safety_advisor.py +25 -1
- package/hooks/advisory/refactor_guard.py +85 -18
- package/hooks/advisory/test_migration_safety_advisor.py +89 -5
- package/hooks/advisory/test_refactor_guard.py +87 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
- package/hooks/blocking/code_rules_constants_config.py +7 -6
- package/hooks/blocking/code_rules_enforcer.py +57 -31
- package/hooks/blocking/codex_apply_patch.py +136 -21
- package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
- package/hooks/blocking/conftest.py +70 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
- package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
- package/hooks/blocking/pii_payload_scan.py +61 -0
- package/hooks/blocking/pii_prevention_blocker.py +5 -0
- package/hooks/blocking/pr_description_writer_gate.py +263 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
- package/hooks/blocking/sensitive_file_protector.py +18 -3
- package/hooks/blocking/shell_substitution_blocker.py +25 -47
- package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
- package/hooks/blocking/state_description_blocker.py +39 -26
- package/hooks/blocking/subprocess_budget_completeness.py +17 -43
- package/hooks/blocking/tdd_enforcer.py +57 -14
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
- package/hooks/blocking/test_codex_apply_patch.py +43 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
- package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
- package/hooks/blocking/test_pii_payload_scan.py +65 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
- package/hooks/blocking/test_pr_description_writer_gate.py +216 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -4
- package/hooks/blocking/test_sensitive_file_protector.py +42 -2
- package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
- package/hooks/blocking/test_state_description_blocker.py +68 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
- package/hooks/blocking/test_tdd_enforcer.py +182 -2
- package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
- package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
- package/hooks/blocking/windows_rmtree_blocker.py +21 -7
- package/hooks/blocking/write_existing_file_blocker.py +75 -29
- package/hooks/conftest.py +47 -0
- package/hooks/hooks.json +7 -12
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
- package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
- package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +39 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
- package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +102 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +70 -0
- package/hooks/hooks_constants/validation_phase_constants.py +2 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
- package/hooks/observability/pr_description_writer_spawn_tracker.py +121 -0
- package/hooks/observability/session_file_edit_tracker.py +25 -4
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +146 -0
- package/hooks/observability/test_session_file_edit_tracker.py +32 -0
- package/hooks/validation/hook_format_validator.py +23 -9
- package/hooks/validation/test_hook_format_validator.py +58 -0
- package/hooks/validators/README.md +1 -1
- package/hooks/validators/abbreviation_checks.py +3 -5
- package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
- package/hooks/validators/config/fast_save_validator_constants.py +32 -0
- package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
- package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
- package/hooks/validators/fast_save_validators.py +332 -0
- package/hooks/validators/hook_timing_harness.py +8 -4
- package/hooks/validators/python_style_checks.py +34 -60
- package/hooks/validators/python_style_helpers.py +16 -2
- package/hooks/validators/python_style_import_bootstrap.py +83 -0
- package/hooks/validators/run_all_validators.py +42 -41
- package/hooks/validators/test_abbreviation_checks.py +20 -0
- package/hooks/validators/test_fast_save_validators.py +172 -0
- package/hooks/validators/test_python_style_checks.py +9 -0
- package/hooks/validators/test_python_style_checks_import_order.py +81 -0
- package/hooks/validators/test_python_style_helpers.py +35 -0
- package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
- package/hooks/validators/test_run_all_validators.py +46 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +2 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/git-workflow.md +2 -0
- package/rules/testing.md +4 -0
- package/scripts/codex_compat_materializer.py +5 -0
- package/scripts/tests/test_codex_compat_materializer.py +37 -11
- package/hooks/blocking/tests/conftest.py +0 -10
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""PreToolUse
|
|
2
|
+
"""PreToolUse hook — blocks Write and apply_patch "Add File" onto an existing target.
|
|
3
3
|
|
|
4
|
-
Agents should use Edit for modifying existing files
|
|
4
|
+
Agents should use Edit for modifying existing files, and an apply_patch
|
|
5
|
+
"Update File" section rather than "Add File" for a path that already exists.
|
|
5
6
|
Exemptions: Jupyter notebooks (.ipynb) and files in ~/.claude/hooks/ (standalone scripts).
|
|
7
|
+
|
|
8
|
+
MultiEdit carries no create-or-clobber path this hook needs to guard: every
|
|
9
|
+
MultiEdit edit must match an ``old_string`` already present in the target file,
|
|
10
|
+
so a MultiEdit can never introduce content nobody read the way a blind Write or
|
|
11
|
+
an apply_patch "Add File" can. This hook allows MultiEdit unconditionally.
|
|
6
12
|
"""
|
|
7
13
|
|
|
8
14
|
import json
|
|
@@ -14,7 +20,22 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
14
20
|
if _hooks_dir not in sys.path:
|
|
15
21
|
sys.path.insert(0, _hooks_dir)
|
|
16
22
|
|
|
17
|
-
|
|
23
|
+
try:
|
|
24
|
+
from codex_apply_patch import (
|
|
25
|
+
CODEX_ADD_OPERATION,
|
|
26
|
+
CodexPatchError,
|
|
27
|
+
codex_patch_operation_targets,
|
|
28
|
+
payload_patch_command,
|
|
29
|
+
)
|
|
30
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
31
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
32
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import APPLY_PATCH_TOOL_NAME
|
|
33
|
+
from sensitive_file_protector import build_deny_response
|
|
34
|
+
except ImportError as import_error:
|
|
35
|
+
raise ImportError(
|
|
36
|
+
"write_existing_file_blocker: cannot import its sibling modules; "
|
|
37
|
+
"ensure the hooks directory is importable."
|
|
38
|
+
) from import_error
|
|
18
39
|
|
|
19
40
|
JUPYTER_EXTENSION = ".ipynb"
|
|
20
41
|
HOOKS_DIRECTORY = os.path.normpath(os.path.expanduser("~/.claude/hooks"))
|
|
@@ -29,48 +50,73 @@ def is_inside_hooks_directory(file_path: str) -> bool:
|
|
|
29
50
|
return normalized_path.startswith(HOOKS_DIRECTORY)
|
|
30
51
|
|
|
31
52
|
|
|
32
|
-
def
|
|
53
|
+
def _apply_patch_add_deny(payload: dict, tool_input: dict) -> tuple[str, str] | None:
|
|
54
|
+
"""Return (deny_reason, offending_path) for an apply_patch 'Add File' onto an existing path."""
|
|
55
|
+
command, working_directory = payload_patch_command(payload, tool_input)
|
|
56
|
+
if not command:
|
|
57
|
+
return None
|
|
33
58
|
try:
|
|
34
|
-
|
|
35
|
-
except
|
|
36
|
-
|
|
59
|
+
all_operation_targets = codex_patch_operation_targets(command, working_directory)
|
|
60
|
+
except CodexPatchError:
|
|
61
|
+
return None
|
|
62
|
+
for each_operation, each_target_path in all_operation_targets:
|
|
63
|
+
if each_operation != CODEX_ADD_OPERATION:
|
|
64
|
+
continue
|
|
65
|
+
if os.path.exists(each_target_path):
|
|
66
|
+
deny_reason = (
|
|
67
|
+
f"BLOCKED: apply_patch 'Add File' on existing file {each_target_path}. "
|
|
68
|
+
"Use an 'Update File' section instead."
|
|
69
|
+
)
|
|
70
|
+
return deny_reason, each_target_path
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _write_onto_existing_file_deny(tool_input: dict) -> tuple[str, str] | None:
|
|
75
|
+
"""Return (deny_reason, offending_path) for a Write onto an existing target."""
|
|
76
|
+
target_file_path = tool_input.get("file_path", "")
|
|
77
|
+
if not target_file_path:
|
|
78
|
+
return None
|
|
79
|
+
if is_jupyter_notebook(target_file_path):
|
|
80
|
+
return None
|
|
81
|
+
if is_inside_hooks_directory(target_file_path):
|
|
82
|
+
return None
|
|
83
|
+
if not os.path.exists(target_file_path):
|
|
84
|
+
return None
|
|
85
|
+
deny_reason = f"BLOCKED: Write on existing file {target_file_path}. Use Edit tool instead."
|
|
86
|
+
return deny_reason, target_file_path
|
|
37
87
|
|
|
38
|
-
tool_name = input_payload.get("tool_name", "")
|
|
39
|
-
tool_input = input_payload.get("tool_input", {})
|
|
40
88
|
|
|
89
|
+
def _resolve_deny(tool_name: str, payload: dict, tool_input: dict) -> tuple[str, str] | None:
|
|
90
|
+
"""Return (deny_reason, offending_path) for the payload, or None to allow."""
|
|
91
|
+
if tool_name == APPLY_PATCH_TOOL_NAME:
|
|
92
|
+
return _apply_patch_add_deny(payload, tool_input)
|
|
41
93
|
if tool_name != "Write":
|
|
42
|
-
|
|
94
|
+
return None
|
|
95
|
+
return _write_onto_existing_file_deny(tool_input)
|
|
43
96
|
|
|
44
|
-
target_file_path = tool_input.get("file_path", "")
|
|
45
97
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if is_jupyter_notebook(target_file_path):
|
|
98
|
+
def main() -> None:
|
|
99
|
+
input_payload = read_hook_input_dictionary_from_stdin()
|
|
100
|
+
if input_payload is None:
|
|
50
101
|
sys.exit(0)
|
|
51
102
|
|
|
52
|
-
|
|
53
|
-
|
|
103
|
+
raw_tool_name = input_payload.get("tool_name", "")
|
|
104
|
+
raw_tool_input = input_payload.get("tool_input", {})
|
|
105
|
+
tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
|
|
106
|
+
tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
|
|
54
107
|
|
|
55
|
-
|
|
108
|
+
resolved_deny = _resolve_deny(tool_name, input_payload, tool_input)
|
|
109
|
+
if resolved_deny is None:
|
|
56
110
|
sys.exit(0)
|
|
111
|
+
deny_reason, offending_path = resolved_deny
|
|
57
112
|
|
|
58
|
-
deny_reason = f"BLOCKED: Write on existing file {target_file_path}. Use Edit tool instead."
|
|
59
|
-
denial = {
|
|
60
|
-
"hookSpecificOutput": {
|
|
61
|
-
"hookEventName": "PreToolUse",
|
|
62
|
-
"permissionDecision": "deny",
|
|
63
|
-
"permissionDecisionReason": deny_reason,
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
113
|
log_hook_block(
|
|
67
114
|
calling_hook_name="write_existing_file_blocker.py",
|
|
68
115
|
hook_event="PreToolUse",
|
|
69
116
|
block_reason=deny_reason,
|
|
70
|
-
|
|
71
|
-
offending_input_preview=target_file_path,
|
|
117
|
+
offending_input_preview=offending_path,
|
|
72
118
|
)
|
|
73
|
-
print(json.dumps(
|
|
119
|
+
print(json.dumps(build_deny_response(deny_reason)))
|
|
74
120
|
sys.exit(0)
|
|
75
121
|
|
|
76
122
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Shared pytest fixtures for the hooks/ suite, visible under blocking/ and validators/."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
_HOOKS_DIRECTORY = Path(__file__).resolve().parent
|
|
12
|
+
_BLOCKING_DIRECTORY = _HOOKS_DIRECTORY / "blocking"
|
|
13
|
+
for each_directory in (_HOOKS_DIRECTORY, _BLOCKING_DIRECTORY):
|
|
14
|
+
if str(each_directory) not in sys.path:
|
|
15
|
+
sys.path.insert(0, str(each_directory))
|
|
16
|
+
|
|
17
|
+
_is_ephemeral_script_path = importlib.import_module("code_rules_shared").is_ephemeral_script_path
|
|
18
|
+
_EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME = importlib.import_module(
|
|
19
|
+
"hooks_constants.code_rules_enforcer_constants"
|
|
20
|
+
).EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME
|
|
21
|
+
|
|
22
|
+
_ROOT_ANCHORED_PROBE_PATH = "/tmp/scratch.py"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def ephemeral_exempt_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
27
|
+
"""Turn off the ephemeral scratch-path exemption for the current test.
|
|
28
|
+
|
|
29
|
+
Sets ``CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT`` so a gate under test
|
|
30
|
+
judges the test's own scratch targets for real, rather than stepping aside
|
|
31
|
+
because they sit under a temp root.
|
|
32
|
+
|
|
33
|
+
The probe path is root-anchored ``/tmp``, which the classifier treats as
|
|
34
|
+
ephemeral on every platform, so the assert fails on any change that stops
|
|
35
|
+
reading the disable flag. A probe on the shared OS temp root would pass on
|
|
36
|
+
Windows whether the flag is read or not, since the classifier already
|
|
37
|
+
excludes that root by design.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
monkeypatch: Pytest's environment-variable patcher, torn down after the test.
|
|
41
|
+
"""
|
|
42
|
+
monkeypatch.setenv(_EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME, "1")
|
|
43
|
+
assert _is_ephemeral_script_path(_ROOT_ANCHORED_PROBE_PATH) is False, (
|
|
44
|
+
"ephemeral_exempt_off must turn off the scratch-path exemption; a change "
|
|
45
|
+
"that stops reading the disable flag must fail here, not silently return "
|
|
46
|
+
"every test in the file to deciding nothing"
|
|
47
|
+
)
|
package/hooks/hooks.json
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
]
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
|
-
"matcher": "Write|Edit|MultiEdit",
|
|
16
|
+
"matcher": "Write|Edit|MultiEdit|apply_patch",
|
|
17
17
|
"hooks": [
|
|
18
18
|
{
|
|
19
19
|
"type": "command",
|
|
@@ -22,16 +22,6 @@
|
|
|
22
22
|
}
|
|
23
23
|
]
|
|
24
24
|
},
|
|
25
|
-
{
|
|
26
|
-
"matcher": "apply_patch",
|
|
27
|
-
"hooks": [
|
|
28
|
-
{
|
|
29
|
-
"type": "command",
|
|
30
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/code_rules_enforcer.py",
|
|
31
|
-
"timeout": 60
|
|
32
|
-
}
|
|
33
|
-
]
|
|
34
|
-
},
|
|
35
25
|
{
|
|
36
26
|
"matcher": "Bash|PowerShell",
|
|
37
27
|
"hooks": [
|
|
@@ -245,7 +235,7 @@
|
|
|
245
235
|
]
|
|
246
236
|
},
|
|
247
237
|
{
|
|
248
|
-
"matcher": "Write|Edit|MultiEdit",
|
|
238
|
+
"matcher": "Write|Edit|MultiEdit|apply_patch",
|
|
249
239
|
"hooks": [
|
|
250
240
|
{
|
|
251
241
|
"type": "command",
|
|
@@ -261,6 +251,11 @@
|
|
|
261
251
|
"type": "command",
|
|
262
252
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/workflow/investigation_tracker_reset.py",
|
|
263
253
|
"timeout": 30
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
"type": "command",
|
|
257
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/observability/pr_description_writer_spawn_tracker.py",
|
|
258
|
+
"timeout": 30
|
|
264
259
|
}
|
|
265
260
|
]
|
|
266
261
|
},
|
|
@@ -76,4 +76,7 @@ ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
|
|
|
76
76
|
BashHostedHookEntry("blocking/test_preflight_check.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
77
77
|
BashHostedHookEntry("blocking/windows_rmtree_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
78
78
|
BashHostedHookEntry("blocking/gh_pr_author_enforcer.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
79
|
+
BashHostedHookEntry(
|
|
80
|
+
"blocking/pr_description_writer_gate.py", ALL_BASH_AND_POWERSHELL_TOOL_NAMES
|
|
81
|
+
),
|
|
79
82
|
)
|
|
@@ -6,6 +6,10 @@ Extracted from code_rules_enforcer.py to satisfy the constants-location rule.
|
|
|
6
6
|
import re
|
|
7
7
|
import tokenize
|
|
8
8
|
|
|
9
|
+
from hooks_constants.multi_edit_reconstruction import ( # noqa: F401 re-exported for code_rules_enforcer.py
|
|
10
|
+
apply_edits,
|
|
11
|
+
edits_for_tool,
|
|
12
|
+
)
|
|
9
13
|
from hooks_constants.validation_phase_constants import ( # noqa: F401 re-exported for code_rules_enforcer.py
|
|
10
14
|
ALL_VALIDATION_PHASES,
|
|
11
15
|
EDIT_LANE_PHASE,
|
|
@@ -24,3 +24,6 @@ ALL_EXEMPT_PATH_FRAGMENTS: tuple[str, ...] = (
|
|
|
24
24
|
|
|
25
25
|
ALL_EXEMPT_TEST_FILE_PREFIXES: tuple[str, ...] = ("test_",)
|
|
26
26
|
ALL_EXEMPT_TEST_FILE_SUFFIXES: tuple[str, ...] = ("_test.py",)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
ALL_APPLICABLE_TOOL_NAMES: frozenset[str] = frozenset({"Write", "Edit", "MultiEdit"})
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
"""Configuration constants for the hook_prose_detector_consistency PreToolUse hook."""
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
# The dispatcher constants module is the single source for tool names.
|
|
4
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: F401
|
|
5
|
+
EDIT_TOOL_NAME,
|
|
6
|
+
MULTI_EDIT_TOOL_NAME,
|
|
7
|
+
WRITE_TOOL_NAME,
|
|
8
|
+
)
|
|
9
|
+
|
|
5
10
|
|
|
6
11
|
HOOK_MODULE_PATH_SEGMENT: str = "/hooks/"
|
|
7
12
|
PYTHON_FILE_SUFFIX: str = ".py"
|
|
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|
|
12
12
|
__all__ = [
|
|
13
13
|
"apply_edits",
|
|
14
14
|
"edits_for_tool",
|
|
15
|
+
"joined_new_strings",
|
|
15
16
|
]
|
|
16
17
|
|
|
17
18
|
|
|
@@ -24,9 +25,19 @@ def apply_edits(existing_content: str, all_edits: list[dict]) -> str:
|
|
|
24
25
|
``edits`` list, each a mapping with an ``old_string`` and a
|
|
25
26
|
``new_string``.
|
|
26
27
|
|
|
28
|
+
An edit carrying ``replace_all`` rewrites every occurrence, matching what
|
|
29
|
+
MultiEdit itself does; every other edit rewrites the first one::
|
|
30
|
+
|
|
31
|
+
{"old_string": "old", "new_string": "new", "replace_all": True}
|
|
32
|
+
"old old old" -> "new new new"
|
|
33
|
+
{"old_string": "old", "new_string": "new"}
|
|
34
|
+
"old old old" -> "new old old"
|
|
35
|
+
|
|
36
|
+
Each edit reads its own flag, so one replace_all edit in a list leaves the
|
|
37
|
+
edits beside it alone.
|
|
38
|
+
|
|
27
39
|
Returns:
|
|
28
|
-
The content after
|
|
29
|
-
``old_string`` with its ``new_string``, in list order.
|
|
40
|
+
The content after applying each edit in list order.
|
|
30
41
|
"""
|
|
31
42
|
edited_content = existing_content
|
|
32
43
|
for each_edit in all_edits:
|
|
@@ -35,7 +46,10 @@ def apply_edits(existing_content: str, all_edits: list[dict]) -> str:
|
|
|
35
46
|
old_string = each_edit.get("old_string", "")
|
|
36
47
|
new_string = each_edit.get("new_string", "")
|
|
37
48
|
if isinstance(old_string, str) and isinstance(new_string, str) and old_string:
|
|
38
|
-
|
|
49
|
+
is_replace_all = bool(each_edit.get("replace_all"))
|
|
50
|
+
edited_content = edited_content.replace(
|
|
51
|
+
old_string, new_string, -1 if is_replace_all else 1
|
|
52
|
+
)
|
|
39
53
|
return edited_content
|
|
40
54
|
|
|
41
55
|
|
|
@@ -54,3 +68,30 @@ def edits_for_tool(tool_name: str, tool_input: dict) -> list[dict]:
|
|
|
54
68
|
return [tool_input]
|
|
55
69
|
all_edits = tool_input.get("edits", [])
|
|
56
70
|
return all_edits if isinstance(all_edits, list) else []
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def joined_new_strings(tool_input: dict) -> str:
|
|
74
|
+
"""Return one scannable text holding every string a MultiEdit introduces.
|
|
75
|
+
|
|
76
|
+
::
|
|
77
|
+
|
|
78
|
+
edits = [{"new_string": "import os"}, {"new_string": "shutil.rmtree(p)"}]
|
|
79
|
+
joined_new_strings(...) -> "import os\\nshutil.rmtree(p)"
|
|
80
|
+
|
|
81
|
+
A blocker scans that single text in one pass. The joining newline keeps one
|
|
82
|
+
edit's last line apart from the next edit's first line, so a phrase neither
|
|
83
|
+
edit wrote never appears across the seam.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
tool_input: The MultiEdit tool input payload.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Every string-valued new_string in the payload, joined on a newline.
|
|
90
|
+
"""
|
|
91
|
+
multi_edit_new_string_join_separator = "\n"
|
|
92
|
+
all_new_strings = [
|
|
93
|
+
each_edit.get("new_string", "")
|
|
94
|
+
for each_edit in edits_for_tool("MultiEdit", tool_input)
|
|
95
|
+
if isinstance(each_edit, dict) and isinstance(each_edit.get("new_string"), str)
|
|
96
|
+
]
|
|
97
|
+
return multi_edit_new_string_join_separator.join(all_new_strings)
|
|
@@ -6,15 +6,17 @@ import re
|
|
|
6
6
|
|
|
7
7
|
from hooks_constants.hardcoded_user_path_constants import HARDCODED_USER_PATH_PATTERN
|
|
8
8
|
|
|
9
|
+
# The dispatcher constants module is the single source for tool names.
|
|
10
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: F401
|
|
11
|
+
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
12
|
+
APPLY_PATCH_TOOL_NAME,
|
|
13
|
+
EDIT_TOOL_NAME,
|
|
14
|
+
MULTI_EDIT_TOOL_NAME,
|
|
15
|
+
WRITE_TOOL_NAME,
|
|
16
|
+
)
|
|
17
|
+
|
|
9
18
|
BASH_TOOL_NAME: str = "Bash"
|
|
10
19
|
POWERSHELL_TOOL_NAME: str = "PowerShell"
|
|
11
|
-
WRITE_TOOL_NAME: str = "Write"
|
|
12
|
-
EDIT_TOOL_NAME: str = "Edit"
|
|
13
|
-
MULTI_EDIT_TOOL_NAME: str = "MultiEdit"
|
|
14
|
-
|
|
15
|
-
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES: frozenset[str] = frozenset(
|
|
16
|
-
{WRITE_TOOL_NAME, EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}
|
|
17
|
-
)
|
|
18
20
|
|
|
19
21
|
ALL_SHELL_TOOL_NAMES: frozenset[str] = frozenset(
|
|
20
22
|
{BASH_TOOL_NAME, POWERSHELL_TOOL_NAME}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Constants for the pr-description-writer spawn tracker and its gh pr create gate.
|
|
2
|
+
|
|
3
|
+
The tracker records one fact per session: the session spawned the
|
|
4
|
+
``pr-description-writer`` agent. The gate reads that fact when the session runs
|
|
5
|
+
``gh pr create`` and denies the command when the fact is absent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
PR_DESCRIPTION_WRITER_SUBAGENT_TYPE: str = "pr-description-writer"
|
|
11
|
+
|
|
12
|
+
ALL_AGENT_SPAWN_TOOL_NAMES: frozenset[str] = frozenset({"Agent", "Task"})
|
|
13
|
+
|
|
14
|
+
SPAWN_MARKER_FILE_PREFIX: str = "claude-session-pr-description-writer-"
|
|
15
|
+
SPAWN_MARKER_FILE_SUFFIX: str = ".marker"
|
|
16
|
+
|
|
17
|
+
SUBAGENT_TYPE_FIELD_NAME: str = "subagent_type"
|
|
18
|
+
TOOL_INPUT_FIELD_NAME: str = "tool_input"
|
|
19
|
+
TOOL_NAME_FIELD_NAME: str = "tool_name"
|
|
20
|
+
COMMAND_FIELD_NAME: str = "command"
|
|
21
|
+
|
|
22
|
+
ALL_GH_EXECUTABLE_BASENAMES: frozenset[str] = frozenset({"gh", "gh.exe", "gh.cmd"})
|
|
23
|
+
PR_SUBCOMMAND_TOKEN: str = "pr"
|
|
24
|
+
CREATE_SUBCOMMAND_TOKEN: str = "create"
|
|
25
|
+
GH_PR_CREATE_MINIMUM_TOKEN_COUNT: int = 3
|
|
26
|
+
|
|
27
|
+
SPAWN_BYPASS_MARKER: str = "# pr-description-skip"
|
|
28
|
+
|
|
29
|
+
CORRECTIVE_MESSAGE: str = (
|
|
30
|
+
"BLOCKED: [PR_DESCRIPTION_WRITER] This session runs `gh pr create` with no "
|
|
31
|
+
"`pr-description-writer` agent spawn recorded.\n\n"
|
|
32
|
+
"The agent writes the title and body from the current diff, then returns the "
|
|
33
|
+
"markdown and a body-file path for you to publish.\n\n"
|
|
34
|
+
"Two ways forward:\n"
|
|
35
|
+
" 1. Spawn the agent (Agent tool, subagent_type `pr-description-writer`), "
|
|
36
|
+
"then run `gh pr create --body-file <path>` with what it returns.\n"
|
|
37
|
+
" 2. Append the trailing comment `# pr-description-skip` to the command when "
|
|
38
|
+
"you are writing the body yourself on purpose."
|
|
39
|
+
)
|
|
@@ -21,8 +21,11 @@ __all__ = [
|
|
|
21
21
|
"WRITE_TOOL_NAME",
|
|
22
22
|
"EDIT_TOOL_NAME",
|
|
23
23
|
"MULTI_EDIT_TOOL_NAME",
|
|
24
|
+
"APPLY_PATCH_TOOL_NAME",
|
|
24
25
|
"ALL_WRITE_AND_EDIT_TOOL_NAMES",
|
|
25
26
|
"ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES",
|
|
27
|
+
"ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES",
|
|
28
|
+
"ALL_IMMEDIATE_HARM_SCRIPT_PATHS",
|
|
26
29
|
"STATE_DESCRIPTION_BLOCKER_MODULE_NAME",
|
|
27
30
|
"SYSTEM_MESSAGE_JOIN_SEPARATOR",
|
|
28
31
|
"HostedHookEntry",
|
|
@@ -42,11 +45,25 @@ CONTEXT_JOIN_SEPARATOR = "\n"
|
|
|
42
45
|
WRITE_TOOL_NAME = "Write"
|
|
43
46
|
EDIT_TOOL_NAME = "Edit"
|
|
44
47
|
MULTI_EDIT_TOOL_NAME = "MultiEdit"
|
|
48
|
+
APPLY_PATCH_TOOL_NAME = "apply_patch"
|
|
45
49
|
|
|
46
50
|
ALL_WRITE_AND_EDIT_TOOL_NAMES: frozenset[str] = frozenset({WRITE_TOOL_NAME, EDIT_TOOL_NAME})
|
|
47
51
|
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES: frozenset[str] = frozenset(
|
|
48
52
|
{WRITE_TOOL_NAME, EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}
|
|
49
53
|
)
|
|
54
|
+
ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES: frozenset[str] = frozenset(
|
|
55
|
+
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES | {APPLY_PATCH_TOOL_NAME}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# apply_patch reaches this narrower roster: the gates whose miss causes immediate
|
|
59
|
+
# harm, plus the TDD gate and the edit tracker. It stays off the full lint surface.
|
|
60
|
+
ALL_IMMEDIATE_HARM_SCRIPT_PATHS: tuple[str, ...] = (
|
|
61
|
+
"blocking/pii_prevention_blocker.py",
|
|
62
|
+
"blocking/sensitive_file_protector.py",
|
|
63
|
+
"blocking/write_existing_file_blocker.py",
|
|
64
|
+
"blocking/code_rules_enforcer.py",
|
|
65
|
+
"blocking/tdd_enforcer.py",
|
|
66
|
+
)
|
|
50
67
|
|
|
51
68
|
|
|
52
69
|
STATE_DESCRIPTION_BLOCKER_MODULE_NAME = "state_description_blocker"
|
|
@@ -78,52 +95,52 @@ class HostedHookEntry:
|
|
|
78
95
|
ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
|
|
79
96
|
HostedHookEntry(
|
|
80
97
|
script_relative_path="blocking/write_existing_file_blocker.py",
|
|
81
|
-
applicable_tool_names=
|
|
98
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
|
|
82
99
|
),
|
|
83
100
|
HostedHookEntry(
|
|
84
101
|
script_relative_path="blocking/sensitive_file_protector.py",
|
|
85
|
-
applicable_tool_names=
|
|
102
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
|
|
86
103
|
),
|
|
87
104
|
HostedHookEntry(
|
|
88
105
|
script_relative_path="blocking/pii_prevention_blocker.py",
|
|
89
|
-
applicable_tool_names=
|
|
106
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
|
|
90
107
|
),
|
|
91
108
|
HostedHookEntry(
|
|
92
109
|
script_relative_path="validation/hook_format_validator.py",
|
|
93
|
-
applicable_tool_names=
|
|
110
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
94
111
|
),
|
|
95
112
|
HostedHookEntry(
|
|
96
113
|
script_relative_path="blocking/code_rules_enforcer.py",
|
|
97
|
-
applicable_tool_names=
|
|
114
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
|
|
98
115
|
),
|
|
99
116
|
HostedHookEntry(
|
|
100
117
|
script_relative_path="blocking/tdd_enforcer.py",
|
|
101
|
-
applicable_tool_names=
|
|
118
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
|
|
102
119
|
),
|
|
103
120
|
HostedHookEntry(
|
|
104
121
|
script_relative_path="blocking/windows_rmtree_blocker.py",
|
|
105
|
-
applicable_tool_names=
|
|
122
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
106
123
|
),
|
|
107
124
|
HostedHookEntry(
|
|
108
125
|
script_relative_path="blocking/duplicate_rmtree_helper_blocker.py",
|
|
109
|
-
applicable_tool_names=
|
|
126
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
110
127
|
),
|
|
111
128
|
HostedHookEntry(
|
|
112
129
|
script_relative_path="blocking/state_description_blocker.py",
|
|
113
|
-
applicable_tool_names=
|
|
130
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
114
131
|
native_module_name=STATE_DESCRIPTION_BLOCKER_MODULE_NAME,
|
|
115
132
|
),
|
|
116
133
|
HostedHookEntry(
|
|
117
134
|
script_relative_path="blocking/stale_comment_reference_blocker.py",
|
|
118
|
-
applicable_tool_names=frozenset({EDIT_TOOL_NAME}),
|
|
135
|
+
applicable_tool_names=frozenset({EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}),
|
|
119
136
|
),
|
|
120
137
|
HostedHookEntry(
|
|
121
138
|
script_relative_path="blocking/subprocess_budget_completeness.py",
|
|
122
|
-
applicable_tool_names=
|
|
139
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
123
140
|
),
|
|
124
141
|
HostedHookEntry(
|
|
125
142
|
script_relative_path="blocking/hook_prose_detector_consistency.py",
|
|
126
|
-
applicable_tool_names=
|
|
143
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
127
144
|
),
|
|
128
145
|
HostedHookEntry(
|
|
129
146
|
script_relative_path="blocking/workflow_substitution_slot_blocker.py",
|
|
@@ -155,12 +172,12 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
|
|
|
155
172
|
),
|
|
156
173
|
HostedHookEntry(
|
|
157
174
|
script_relative_path="advisory/refactor_guard.py",
|
|
158
|
-
applicable_tool_names=frozenset({EDIT_TOOL_NAME}),
|
|
175
|
+
applicable_tool_names=frozenset({EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}),
|
|
159
176
|
is_blocking=False,
|
|
160
177
|
),
|
|
161
178
|
HostedHookEntry(
|
|
162
179
|
script_relative_path="advisory/migration_safety_advisor.py",
|
|
163
|
-
applicable_tool_names=frozenset({EDIT_TOOL_NAME}),
|
|
180
|
+
applicable_tool_names=frozenset({EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}),
|
|
164
181
|
is_blocking=False,
|
|
165
182
|
),
|
|
166
183
|
)
|
|
@@ -18,6 +18,15 @@ from __future__ import annotations
|
|
|
18
18
|
|
|
19
19
|
import re
|
|
20
20
|
|
|
21
|
+
# The dispatcher constants module is the single source for tool names.
|
|
22
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: F401
|
|
23
|
+
APPLY_PATCH_TOOL_NAME,
|
|
24
|
+
EDIT_TOOL_NAME,
|
|
25
|
+
MULTI_EDIT_TOOL_NAME,
|
|
26
|
+
WRITE_TOOL_NAME,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
21
30
|
SESSION_EDIT_FILE_PREFIX: str = "claude-session-edits-"
|
|
22
31
|
SESSION_EDIT_FILE_SUFFIX: str = ".json"
|
|
23
32
|
ALL_EDITED_FILE_PATHS_KEY: str = "all_edited_file_paths"
|
|
@@ -27,7 +36,12 @@ SESSION_ID_UNSAFE_CHARACTERS_PATTERN: re.Pattern[str] = re.compile(r"[^A-Za-z0-9
|
|
|
27
36
|
SESSION_START_SOURCE_PAYLOAD_KEY: str = "source"
|
|
28
37
|
SESSION_START_SOURCE_FRESH_STARTUP: str = "startup"
|
|
29
38
|
|
|
30
|
-
ALL_TRACKED_EDIT_TOOL_NAMES: tuple[str, ...] = (
|
|
39
|
+
ALL_TRACKED_EDIT_TOOL_NAMES: tuple[str, ...] = (
|
|
40
|
+
WRITE_TOOL_NAME,
|
|
41
|
+
EDIT_TOOL_NAME,
|
|
42
|
+
MULTI_EDIT_TOOL_NAME,
|
|
43
|
+
APPLY_PATCH_TOOL_NAME,
|
|
44
|
+
)
|
|
31
45
|
|
|
32
46
|
STATE_FILE_ATOMIC_WRITE_SUFFIX: str = ".tmp"
|
|
33
47
|
STATE_FILE_JSON_INDENT_SPACES: int = 2
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Resolve the post-edit content a Write, Edit, or MultiEdit payload leaves on disk.
|
|
2
|
+
|
|
3
|
+
Shared by subprocess_budget_completeness.py, which parses this content's AST to
|
|
4
|
+
find a named budget helper that omits a reachable subprocess timeout.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from hooks_constants.multi_edit_reconstruction import apply_edits
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"existing_file_content",
|
|
13
|
+
"reconstructed_edit_content",
|
|
14
|
+
"reconstructed_multi_edit_content",
|
|
15
|
+
"resolved_content",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def existing_file_content(file_path: str) -> str | None:
|
|
20
|
+
try:
|
|
21
|
+
with open(file_path, "r", encoding="utf-8") as existing_file:
|
|
22
|
+
return existing_file.read()
|
|
23
|
+
except (FileNotFoundError, OSError, UnicodeDecodeError):
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def reconstructed_edit_content(all_tool_input_fields: dict[str, object]) -> str:
|
|
28
|
+
file_path = all_tool_input_fields.get("file_path")
|
|
29
|
+
old_string = all_tool_input_fields.get("old_string")
|
|
30
|
+
new_string = all_tool_input_fields.get("new_string")
|
|
31
|
+
if not isinstance(file_path, str) or not isinstance(old_string, str):
|
|
32
|
+
return ""
|
|
33
|
+
if not isinstance(new_string, str) or not old_string:
|
|
34
|
+
return ""
|
|
35
|
+
existing_content = existing_file_content(file_path)
|
|
36
|
+
if existing_content is None or old_string not in existing_content:
|
|
37
|
+
return ""
|
|
38
|
+
return existing_content.replace(old_string, new_string, 1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def reconstructed_multi_edit_content(
|
|
42
|
+
all_tool_input_fields: dict[str, object], all_edits: list[object]
|
|
43
|
+
) -> str:
|
|
44
|
+
"""Return the whole file MultiEdit's edit list would leave on disk.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
all_tool_input_fields: The MultiEdit payload's tool_input mapping.
|
|
48
|
+
all_edits: The payload's ``edits`` list.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The reconstructed content, or an empty string when the target file
|
|
52
|
+
cannot be read.
|
|
53
|
+
"""
|
|
54
|
+
file_path = all_tool_input_fields.get("file_path")
|
|
55
|
+
if not isinstance(file_path, str):
|
|
56
|
+
return ""
|
|
57
|
+
existing_content = existing_file_content(file_path)
|
|
58
|
+
if existing_content is None:
|
|
59
|
+
return ""
|
|
60
|
+
all_edit_dicts = [each_edit for each_edit in all_edits if isinstance(each_edit, dict)]
|
|
61
|
+
return apply_edits(existing_content, all_edit_dicts)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resolved_content(all_tool_input_fields: dict[str, object]) -> str:
|
|
65
|
+
written_content = all_tool_input_fields.get("content")
|
|
66
|
+
if isinstance(written_content, str):
|
|
67
|
+
return written_content
|
|
68
|
+
all_edits = all_tool_input_fields.get("edits")
|
|
69
|
+
if isinstance(all_edits, list):
|
|
70
|
+
return reconstructed_multi_edit_content(all_tool_input_fields, all_edits)
|
|
71
|
+
return reconstructed_edit_content(all_tool_input_fields)
|