claude-dev-env 7.4.1 → 7.5.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 (66) hide show
  1. package/bin/install.mjs +0 -1
  2. package/bin/install.policy-lint.test.mjs +1 -2
  3. package/bin/install.test.mjs +0 -1
  4. package/hooks/advisory/msys_path_conversion_advisor.py +200 -0
  5. package/hooks/advisory/pr_done_reminder.py +7 -13
  6. package/hooks/advisory/test_msys_path_conversion_advisor.py +223 -0
  7. package/hooks/advisory/test_pr_done_reminder.py +1 -0
  8. package/hooks/blocking/bash_post_call_dispatcher.py +6 -11
  9. package/hooks/blocking/msys_rev_path_rewriter.py +192 -0
  10. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +16 -3
  11. package/hooks/blocking/test_bash_post_call_dispatcher.py +2 -0
  12. package/hooks/blocking/test_msys_rev_path_rewriter.py +141 -0
  13. package/hooks/hooks.json +10 -0
  14. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +9 -0
  15. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +6 -1
  16. package/hooks/hooks_constants/msys_path_conversion_advisor_constants.py +48 -0
  17. package/hooks/hooks_constants/msys_rev_path_rewriter_constants.py +37 -0
  18. package/hooks/hooks_constants/post_tool_use_context.py +34 -0
  19. package/hooks/hooks_constants/pr_done_reminder_constants.py +0 -2
  20. package/hooks/hooks_constants/pre_tool_use_allow_output.py +48 -0
  21. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +9 -2
  22. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +9 -2
  23. package/hooks/hooks_constants/test_failure_recorder_constants.py +1 -2
  24. package/hooks/hooks_constants/test_post_tool_use_context.py +22 -0
  25. package/hooks/hooks_constants/test_pre_tool_use_allow_output.py +27 -0
  26. package/hooks/observability/test_failure_recorder.py +3 -1
  27. package/package.json +1 -1
  28. package/rules/ask-user-question-required.md +3 -3
  29. package/rules/code-standards.md +3 -2
  30. package/rules/confirm-implementation-forks.md +2 -0
  31. package/rules/destructive-commands.md +19 -19
  32. package/rules/doc-inventory-integrity.md +4 -4
  33. package/rules/docstring-prose-matches-implementation.md +2 -2
  34. package/rules/failure-blast-radius.md +4 -2
  35. package/rules/filesystem-search.md +1 -1
  36. package/rules/git-workflow.md +34 -0
  37. package/rules/long-horizon-autonomy.md +2 -0
  38. package/rules/nas-ssh-invocation.md +3 -3
  39. package/rules/no-cross-skill-duplicate-helpers.md +6 -6
  40. package/rules/orphan-css-class.md +5 -5
  41. package/rules/paired-test-coverage.md +6 -6
  42. package/rules/plain-illustrative-docstrings.md +3 -3
  43. package/rules/re-stage-before-commit.md +7 -7
  44. package/rules/retired-hook-prose.md +16 -0
  45. package/rules/shell-invocation.md +17 -1
  46. package/rules/testing.md +3 -5
  47. package/rules/verify-runtime-state.md +1 -1
  48. package/rules/windows-filesystem-safe.md +2 -2
  49. package/rules/workflow-substitution-slots.md +1 -1
  50. package/scripts/durable_post_lint_config/config/constants.py +0 -3
  51. package/scripts/durable_post_lint_config/config/test_constants.py +5 -0
  52. package/scripts/policy_lint/adapter_configuration.py +24 -0
  53. package/scripts/policy_lint/adapter_retired_hook_prose.py +201 -0
  54. package/scripts/policy_lint/adapters.py +36 -1
  55. package/scripts/policy_lint/config/constants.py +74 -0
  56. package/scripts/policy_lint/registry.py +5 -0
  57. package/scripts/policy_lint/selection.py +37 -18
  58. package/scripts/policy_lint/selection_git.py +6 -2
  59. package/scripts/test_durable_post_lint.py +42 -9
  60. package/scripts/tests/test_adapter_configuration.py +8 -0
  61. package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +197 -0
  62. package/scripts/tests/test_policy_lint_selection.py +107 -0
  63. package/hooks/hooks_constants/convergence_branch_constants.py +0 -8
  64. package/hooks/hooks_constants/destructive_command_environment_constants.py +0 -18
  65. package/hooks/hooks_constants/test_text_stripping.py +0 -39
  66. package/hooks/hooks_constants/text_stripping.py +0 -36
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse rewriter: keep Git Bash from converting a ``<rev>:<path>`` argument.
3
+
4
+ Git Bash rewrites a git argument such as ``origin/main:.claude/settings.json``
5
+ into a backslash-separated form joined by a semicolon before git sees it, and
6
+ git then fails with ``fatal: ambiguous argument``. This hook reads the command
7
+ before it runs, finds every token MSYS would convert, and prepends one
8
+ ``export MSYS2_ARG_CONV_EXCL='<rev1>:;<rev2>:'; `` naming exactly those
9
+ prefixes. Every other argument in the command keeps converting as before.
10
+
11
+ A token is converted only when the part before its first colon carries a slash
12
+ and the part after that colon opens a repository-root path, so ``HEAD:.gitignore``
13
+ and ``origin/main:packages/app.py`` both reach git untouched and neither is
14
+ collected.
15
+
16
+ Quiet branches, each emitting nothing: a non-Bash tool, a command that already
17
+ names one of the MSYS conversion variables, and a command from which no prefix
18
+ was collected.
19
+
20
+ Hosted by ``blocking/bash_pre_tool_use_dispatcher.py``, which carries the
21
+ ``updatedInput`` this hook prints through to the harness.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import shlex
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ try:
31
+ _hooks_root_directory = str(Path(__file__).resolve().parent.parent)
32
+ if _hooks_root_directory not in sys.path:
33
+ sys.path.insert(0, _hooks_root_directory)
34
+ from hooks_constants.bash_pre_tool_use_dispatcher_constants import BASH_TOOL_NAME
35
+ from hooks_constants.msys_path_conversion_advisor_constants import (
36
+ ALL_MSYS_PATH_CONVERSION_VARIABLE_NAMES,
37
+ )
38
+ from hooks_constants.msys_rev_path_rewriter_constants import (
39
+ ALL_CONVERTED_PATH_START_CHARACTERS,
40
+ ALL_REVISION_FORBIDDEN_CHARACTERS,
41
+ ALL_UNCONVERTED_PATH_START_PREFIXES,
42
+ EXCLUSION_EXPORT_TEMPLATE,
43
+ EXCLUSION_PREFIX_JOIN_SEPARATOR,
44
+ GIT_PROGRAM_NAME,
45
+ REVISION_PATH_SEPARATOR,
46
+ REVISION_PATH_SPLIT_COUNT,
47
+ REVISION_SLASH_CHARACTER,
48
+ )
49
+ from hooks_constants.pre_tool_use_allow_output import (
50
+ write_pre_tool_use_allow_to_stdout,
51
+ )
52
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
53
+ from hooks_constants.shell_command_segments import (
54
+ effective_leading_program,
55
+ split_into_segments,
56
+ token_basename,
57
+ )
58
+ except ImportError as import_error:
59
+ raise ImportError(
60
+ "The MSYS revision-path rewriter cannot import its dependencies; "
61
+ "ensure the hooks directory is importable."
62
+ ) from import_error
63
+
64
+
65
+ def _all_git_segment_tokens(command: str) -> list[list[str]]:
66
+ """Return the token lists of the command's segments that run git."""
67
+ try:
68
+ all_command_tokens = shlex.split(command, posix=True)
69
+ except ValueError:
70
+ all_command_tokens = command.split()
71
+ all_git_segments: list[list[str]] = []
72
+ for each_segment in split_into_segments(all_command_tokens):
73
+ program_token = effective_leading_program(each_segment)
74
+ if program_token is None:
75
+ continue
76
+ if token_basename(program_token) == GIT_PROGRAM_NAME:
77
+ all_git_segments.append(each_segment)
78
+ return all_git_segments
79
+
80
+
81
+ def _is_revision_shaped(revision_text: str) -> bool:
82
+ """Return True when the text can name a git revision.
83
+
84
+ ::
85
+
86
+ origin/main -> True
87
+ HEAD -> False, no slash
88
+ don't touch a/b -> False, a quoted message, not a revision
89
+
90
+ Args:
91
+ revision_text: The part of a token before its first colon.
92
+ """
93
+ if not revision_text or REVISION_SLASH_CHARACTER not in revision_text:
94
+ return False
95
+ if any(each_character.isspace() for each_character in revision_text):
96
+ return False
97
+ return not any(
98
+ each_character in revision_text for each_character in ALL_REVISION_FORBIDDEN_CHARACTERS
99
+ )
100
+
101
+
102
+ def _converted_revision_prefix(token: str) -> str | None:
103
+ """Return the ``<rev>:`` prefix MSYS would convert in this token, or None.
104
+
105
+ Args:
106
+ token: One shell token from a git segment.
107
+ """
108
+ if REVISION_PATH_SEPARATOR not in token:
109
+ return None
110
+ revision_text, path_text = token.split(REVISION_PATH_SEPARATOR, REVISION_PATH_SPLIT_COUNT)
111
+ if not _is_revision_shaped(revision_text):
112
+ return None
113
+ if not path_text.startswith(ALL_CONVERTED_PATH_START_CHARACTERS):
114
+ return None
115
+ if path_text.startswith(ALL_UNCONVERTED_PATH_START_PREFIXES):
116
+ return None
117
+ return revision_text + REVISION_PATH_SEPARATOR
118
+
119
+
120
+ def _extend_with_new_prefixes(
121
+ all_ordered_prefixes: list[str], all_segment_tokens: list[str]
122
+ ) -> None:
123
+ """Append each convertible prefix this segment adds, skipping repeats.
124
+
125
+ Args:
126
+ all_ordered_prefixes: The prefixes collected so far, appended in place.
127
+ all_segment_tokens: One git segment's shell tokens.
128
+ """
129
+ for each_token in all_segment_tokens:
130
+ revision_prefix = _converted_revision_prefix(each_token)
131
+ if revision_prefix is not None and revision_prefix not in all_ordered_prefixes:
132
+ all_ordered_prefixes.append(revision_prefix)
133
+
134
+
135
+ def all_exclusion_prefixes(command: str) -> tuple[str, ...]:
136
+ """Return the ``<rev>:`` prefixes MSYS would convert, deduplicated in order.
137
+
138
+ Args:
139
+ command: The Bash command text the agent is about to run.
140
+
141
+ Returns:
142
+ Each distinct prefix in first-appearance order, empty when the command
143
+ carries no token Git Bash would convert.
144
+ """
145
+ all_ordered_prefixes: list[str] = []
146
+ for each_segment in _all_git_segment_tokens(command):
147
+ _extend_with_new_prefixes(all_ordered_prefixes, each_segment)
148
+ return tuple(all_ordered_prefixes)
149
+
150
+
151
+ def command_with_exclusion_export(command: str) -> str:
152
+ """Return the command prefixed with the surgical exclusion export.
153
+
154
+ Returns the command unchanged when it already names an MSYS conversion
155
+ variable or when no convertible token was found.
156
+
157
+ Args:
158
+ command: The Bash command text the agent is about to run.
159
+ """
160
+ if any(
161
+ each_variable_name in command
162
+ for each_variable_name in ALL_MSYS_PATH_CONVERSION_VARIABLE_NAMES
163
+ ):
164
+ return command
165
+ all_prefixes = all_exclusion_prefixes(command)
166
+ if not all_prefixes:
167
+ return command
168
+ export_prefix = EXCLUSION_EXPORT_TEMPLATE.format(
169
+ all_prefixes=EXCLUSION_PREFIX_JOIN_SEPARATOR.join(all_prefixes)
170
+ )
171
+ return export_prefix + command
172
+
173
+
174
+ def main() -> None:
175
+ """Emit an allow carrying the exclusion-prefixed command, or stay quiet."""
176
+ hook_payload = read_hook_input_dictionary_from_stdin()
177
+ if hook_payload is None or hook_payload.get("tool_name") != BASH_TOOL_NAME:
178
+ return
179
+ tool_input = hook_payload.get("tool_input")
180
+ if not isinstance(tool_input, dict):
181
+ return
182
+ command = tool_input.get("command", "")
183
+ if not isinstance(command, str):
184
+ return
185
+ rewritten_command = command_with_exclusion_export(command)
186
+ if rewritten_command == command:
187
+ return
188
+ write_pre_tool_use_allow_to_stdout({**tool_input, "command": rewritten_command})
189
+
190
+
191
+ if __name__ == "__main__":
192
+ main()
@@ -1,4 +1,10 @@
1
- """Tests for the retired Bash and PowerShell blocking roster."""
1
+ """Tests for what the Bash and PowerShell PreToolUse chain costs to run.
2
+
3
+ A Bash tool call starts one interpreter, the dispatcher's own. Each roster
4
+ entry then runs in that process through runpy, so the roster's exact content is
5
+ the chain's cost after that single start. One allow-and-rewrite hook is the
6
+ whole roster; a blocking hook added here fails this test.
7
+ """
2
8
 
3
9
  import sys
4
10
  from pathlib import Path
@@ -9,8 +15,15 @@ if str(_HOOKS_ROOT) not in sys.path:
9
15
 
10
16
  from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
11
17
  ALL_BASH_HOSTED_HOOK_ENTRIES,
18
+ ALL_BASH_ONLY_TOOL_NAMES,
19
+ BashHostedHookEntry,
12
20
  )
13
21
 
14
22
 
15
- def test_retired_bash_roster_has_no_effective_blocking_hooks() -> None:
16
- assert ALL_BASH_HOSTED_HOOK_ENTRIES == ()
23
+ def test_bash_roster_starts_only_the_msys_rewriter_and_no_blocking_hook() -> None:
24
+ assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
25
+ BashHostedHookEntry(
26
+ script_relative_path="blocking/msys_rev_path_rewriter.py",
27
+ applicable_tool_names=ALL_BASH_ONLY_TOOL_NAMES,
28
+ ),
29
+ )
@@ -28,6 +28,7 @@ from tdd_enforcer_parts import content_hash_store
28
28
  _ALL_EXPECTED_ROSTER_PATHS = [
29
29
  "observability/test_failure_recorder.py",
30
30
  "advisory/pr_done_reminder.py",
31
+ "advisory/msys_path_conversion_advisor.py",
31
32
  ]
32
33
 
33
34
 
@@ -66,6 +67,7 @@ def test_dispatch_forwards_hosted_hook_additional_context_as_one_payload(
66
67
  "pr_done_reminder.py": json.dumps(
67
68
  {"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "PR #42"}}
68
69
  ),
70
+ "msys_path_conversion_advisor.py": "",
69
71
  }
70
72
 
71
73
  def _fake_run_hook(script_path: str, payload_text: str) -> HostedHookRun:
@@ -0,0 +1,141 @@
1
+ """Tests for msys_rev_path_rewriter, the MSYS <rev>:<path> exclusion rewriter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from io import StringIO
7
+ from unittest.mock import patch
8
+
9
+ import msys_rev_path_rewriter as rewriter
10
+
11
+ SETTINGS_SHOW_COMMAND = "git show origin/main:.claude/settings.json"
12
+ SETTINGS_SHOW_REWRITTEN = (
13
+ "export MSYS2_ARG_CONV_EXCL='origin/main:'; git show origin/main:.claude/settings.json"
14
+ )
15
+
16
+
17
+ def _stdout_from_main(payload: dict[str, object]) -> str:
18
+ """Return what main() wrote to stdout for one hook payload."""
19
+ captured_stdout = StringIO()
20
+ with patch("sys.stdin", StringIO(json.dumps(payload))), patch("sys.stdout", captured_stdout):
21
+ try:
22
+ rewriter.main()
23
+ except SystemExit:
24
+ pass
25
+ return captured_stdout.getvalue()
26
+
27
+
28
+ def test_settings_path_after_slashed_revision_yields_one_prefix() -> None:
29
+ assert rewriter.all_exclusion_prefixes(SETTINGS_SHOW_COMMAND) == ("origin/main:",)
30
+
31
+
32
+ def test_settings_path_command_gains_the_surgical_export_prefix() -> None:
33
+ assert rewriter.command_with_exclusion_export(SETTINGS_SHOW_COMMAND) == SETTINGS_SHOW_REWRITTEN
34
+
35
+
36
+ def test_unslashed_revision_beside_a_slashed_one_stays_out_of_the_prefixes() -> None:
37
+ assert rewriter.all_exclusion_prefixes(
38
+ "git diff origin/main:.gitignore HEAD:.gitignore"
39
+ ) == ("origin/main:",)
40
+
41
+
42
+ def test_two_slashed_revisions_join_with_a_semicolon_in_first_appearance_order() -> None:
43
+ assert rewriter.command_with_exclusion_export(
44
+ "git diff origin/main:.gitignore origin/develop:.gitignore"
45
+ ) == (
46
+ "export MSYS2_ARG_CONV_EXCL='origin/main:;origin/develop:'; "
47
+ "git diff origin/main:.gitignore origin/develop:.gitignore"
48
+ )
49
+
50
+
51
+ def test_one_slashed_revision_used_twice_yields_one_prefix() -> None:
52
+ assert rewriter.all_exclusion_prefixes(
53
+ "git diff origin/main:.gitignore origin/main:.gitattributes"
54
+ ) == ("origin/main:",)
55
+
56
+
57
+ def test_dot_file_after_the_colon_is_collected() -> None:
58
+ assert rewriter.all_exclusion_prefixes("git show origin/main:.gitignore") == ("origin/main:",)
59
+
60
+
61
+ def test_plain_directory_path_after_the_colon_is_left_alone() -> None:
62
+ command = "git show origin/main:packages/app.py"
63
+ assert rewriter.all_exclusion_prefixes(command) == ()
64
+ assert rewriter.command_with_exclusion_export(command) == command
65
+
66
+
67
+ def test_revision_without_a_slash_is_left_alone() -> None:
68
+ command = "git show HEAD:.claude/settings.json"
69
+ assert rewriter.all_exclusion_prefixes(command) == ()
70
+ assert rewriter.command_with_exclusion_export(command) == command
71
+
72
+
73
+ def test_dot_slash_path_after_the_colon_is_left_alone() -> None:
74
+ assert rewriter.all_exclusion_prefixes("git show origin/main:./settings.json") == ()
75
+
76
+
77
+ def test_parent_directory_path_after_the_colon_is_left_alone() -> None:
78
+ assert rewriter.all_exclusion_prefixes("git show origin/main:../settings.json") == ()
79
+
80
+
81
+ def test_command_already_naming_the_exclusion_variable_is_left_alone() -> None:
82
+ command = "MSYS2_ARG_CONV_EXCL='*' git show origin/main:.gitignore"
83
+ assert rewriter.command_with_exclusion_export(command) == command
84
+
85
+
86
+ def test_command_already_naming_the_path_conversion_variable_is_left_alone() -> None:
87
+ command = "export MSYS_NO_PATHCONV=1; git show origin/main:.gitignore"
88
+ assert rewriter.command_with_exclusion_export(command) == command
89
+
90
+
91
+ def test_colon_token_in_a_non_git_segment_is_left_alone() -> None:
92
+ assert rewriter.all_exclusion_prefixes("echo origin/main:.gitignore") == ()
93
+
94
+
95
+ def test_quoted_message_token_carrying_a_slash_and_a_dot_is_left_alone() -> None:
96
+ command = "git commit -m \"don't touch a/b:.x\""
97
+ assert rewriter.all_exclusion_prefixes(command) == ()
98
+ assert rewriter.command_with_exclusion_export(command) == command
99
+
100
+
101
+ def test_url_token_in_a_curl_command_is_left_alone() -> None:
102
+ assert rewriter.all_exclusion_prefixes("curl https://example.com/x") == ()
103
+
104
+
105
+ def test_git_segment_after_a_non_git_segment_still_contributes() -> None:
106
+ assert rewriter.all_exclusion_prefixes(
107
+ "echo start && git show origin/main:.gitignore"
108
+ ) == ("origin/main:",)
109
+
110
+
111
+ def test_main_emits_an_allow_carrying_the_rewritten_command() -> None:
112
+ emitted_text = _stdout_from_main(
113
+ {
114
+ "tool_name": "Bash",
115
+ "tool_input": {"command": SETTINGS_SHOW_COMMAND, "description": "read settings"},
116
+ }
117
+ )
118
+ assert json.loads(emitted_text) == {
119
+ "hookSpecificOutput": {
120
+ "hookEventName": "PreToolUse",
121
+ "permissionDecision": "allow",
122
+ "updatedInput": {
123
+ "command": SETTINGS_SHOW_REWRITTEN,
124
+ "description": "read settings",
125
+ },
126
+ }
127
+ }
128
+
129
+
130
+ def test_main_emits_nothing_for_a_non_bash_tool() -> None:
131
+ emitted_text = _stdout_from_main(
132
+ {"tool_name": "PowerShell", "tool_input": {"command": SETTINGS_SHOW_COMMAND}}
133
+ )
134
+ assert emitted_text == ""
135
+
136
+
137
+ def test_main_emits_nothing_for_a_command_with_no_collected_prefix() -> None:
138
+ emitted_text = _stdout_from_main(
139
+ {"tool_name": "Bash", "tool_input": {"command": "git show HEAD:.gitignore"}}
140
+ )
141
+ assert emitted_text == ""
package/hooks/hooks.json CHANGED
@@ -12,6 +12,16 @@
12
12
  }
13
13
  ]
14
14
  },
15
+ {
16
+ "matcher": "Bash",
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/bash_pre_tool_use_dispatcher.py",
21
+ "timeout": 60
22
+ }
23
+ ]
24
+ },
15
25
  {
16
26
  "matcher": "EnterWorktree",
17
27
  "hooks": [
@@ -5,6 +5,10 @@ finishes. Reuses ``BashHostedHookEntry`` and the Bash-only tool-name set from
5
5
  ``bash_pre_tool_use_dispatcher_constants`` -- the entry shape and the tool-name
6
6
  membership question are identical on the PostToolUse side, so this module adds
7
7
  no second copy of either.
8
+
9
+ Also the one home for the harness PostToolUse output contract every hook on
10
+ this roster shares: the exit-code prefix a failed call's response text opens
11
+ with, and the three payload keys that carry a context note back to the agent.
8
12
  """
9
13
 
10
14
  from __future__ import annotations
@@ -17,8 +21,10 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
17
21
 
18
22
  __all__ = [
19
23
  "ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES",
24
+ "EXIT_CODE_ERROR_PREFIX",
20
25
  "POST_TOOL_USE_HOOK_EVENT_NAME",
21
26
  "HOOK_SPECIFIC_OUTPUT_KEY",
27
+ "HOOK_EVENT_NAME_KEY",
22
28
  "ADDITIONAL_CONTEXT_KEY",
23
29
  "ADDITIONAL_CONTEXT_JOIN_SEPARATOR",
24
30
  ]
@@ -26,9 +32,12 @@ __all__ = [
26
32
  ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
27
33
  BashHostedHookEntry("observability/test_failure_recorder.py", ALL_BASH_ONLY_TOOL_NAMES),
28
34
  BashHostedHookEntry("advisory/pr_done_reminder.py", ALL_BASH_AND_POWERSHELL_TOOL_NAMES),
35
+ BashHostedHookEntry("advisory/msys_path_conversion_advisor.py", ALL_BASH_ONLY_TOOL_NAMES),
29
36
  )
30
37
 
38
+ EXIT_CODE_ERROR_PREFIX: str = "Error: Exit code "
31
39
  POST_TOOL_USE_HOOK_EVENT_NAME: str = "PostToolUse"
32
40
  HOOK_SPECIFIC_OUTPUT_KEY: str = "hookSpecificOutput"
41
+ HOOK_EVENT_NAME_KEY: str = "hookEventName"
33
42
  ADDITIONAL_CONTEXT_KEY: str = "additionalContext"
34
43
  ADDITIONAL_CONTEXT_JOIN_SEPARATOR: str = "\n\n"
@@ -54,4 +54,9 @@ class BashHostedHookEntry:
54
54
  applicable_tool_names: frozenset[str]
55
55
 
56
56
 
57
- ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = ()
57
+ ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
58
+ BashHostedHookEntry(
59
+ script_relative_path="blocking/msys_rev_path_rewriter.py",
60
+ applicable_tool_names=ALL_BASH_ONLY_TOOL_NAMES,
61
+ ),
62
+ )
@@ -0,0 +1,48 @@
1
+ """Constants for the MSYS path-conversion context advisory.
2
+
3
+ The advisory is a PostToolUse observer on Bash. It never blocks. When a git
4
+ call fails because Git Bash rewrote a ``<rev>:<path>`` argument before git saw
5
+ it, the advisory adds one loud note naming the two exports that turn the
6
+ rewriting off.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __all__ = [
12
+ "ALL_MANGLED_ARGUMENT_MARKERS",
13
+ "MANGLED_ARGUMENT_CLOSING_QUOTE",
14
+ "ALL_MSYS_MANGLING_CHARACTERS",
15
+ "ALL_MSYS_PATH_CONVERSION_VARIABLE_NAMES",
16
+ "ADVISORY_HEADER",
17
+ "MANGLED_ARGUMENT_LINE_TEMPLATE",
18
+ "MANGLING_EXPLANATION_LINE",
19
+ "FIX_LINE",
20
+ "SAME_COMMAND_LINE",
21
+ "ADVISORY_LINE_SEPARATOR",
22
+ ]
23
+
24
+ AMBIGUOUS_ARGUMENT_MARKER: str = "fatal: ambiguous argument '"
25
+ INVALID_OBJECT_NAME_MARKER: str = "fatal: invalid object name '"
26
+ ALL_MANGLED_ARGUMENT_MARKERS: tuple[str, ...] = (
27
+ AMBIGUOUS_ARGUMENT_MARKER,
28
+ INVALID_OBJECT_NAME_MARKER,
29
+ )
30
+ MANGLED_ARGUMENT_CLOSING_QUOTE: str = "'"
31
+ ALL_MSYS_MANGLING_CHARACTERS: frozenset[str] = frozenset({"\\", ";"})
32
+ MSYS_PATH_CONVERSION_VARIABLE_NAME: str = "MSYS_NO_PATHCONV"
33
+ MSYS_ARGUMENT_CONVERSION_EXCLUSION_VARIABLE_NAME: str = "MSYS2_ARG_CONV_EXCL"
34
+ ALL_MSYS_PATH_CONVERSION_VARIABLE_NAMES: tuple[str, ...] = (
35
+ MSYS_PATH_CONVERSION_VARIABLE_NAME,
36
+ MSYS_ARGUMENT_CONVERSION_EXCLUSION_VARIABLE_NAME,
37
+ )
38
+ MSYS_EXPORT_FIX_LINE: str = "export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'"
39
+
40
+ ADVISORY_HEADER: str = "=== MSYS PATH CONVERSION (context reminder, never a block) ==="
41
+ MANGLED_ARGUMENT_LINE_TEMPLATE: str = "Git saw: {argument}"
42
+ MANGLING_EXPLANATION_LINE: str = (
43
+ "Git Bash rewrote a <rev>:<path> argument before git saw it, turning the "
44
+ "colon into a semicolon and the slashes into backslashes."
45
+ )
46
+ FIX_LINE: str = "Fix: " + MSYS_EXPORT_FIX_LINE
47
+ SAME_COMMAND_LINE: str = "Put that export and the git command in the same command."
48
+ ADVISORY_LINE_SEPARATOR: str = "\n"
@@ -0,0 +1,37 @@
1
+ """Constants for the MSYS ``<rev>:<path>`` argument-exclusion rewriter.
2
+
3
+ Git Bash converts a git argument such as ``origin/main:.claude/settings.json``
4
+ before git sees it. ``MSYS2_ARG_CONV_EXCL`` takes a semicolon-separated list of
5
+ argument prefixes to leave alone, so naming one ``<rev>:`` prefix per detected
6
+ token keeps every other argument in the command converting as before.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from hooks_constants.msys_path_conversion_advisor_constants import (
12
+ MSYS_ARGUMENT_CONVERSION_EXCLUSION_VARIABLE_NAME,
13
+ )
14
+
15
+ __all__ = [
16
+ "GIT_PROGRAM_NAME",
17
+ "REVISION_PATH_SEPARATOR",
18
+ "REVISION_PATH_SPLIT_COUNT",
19
+ "REVISION_SLASH_CHARACTER",
20
+ "ALL_CONVERTED_PATH_START_CHARACTERS",
21
+ "ALL_UNCONVERTED_PATH_START_PREFIXES",
22
+ "ALL_REVISION_FORBIDDEN_CHARACTERS",
23
+ "EXCLUSION_PREFIX_JOIN_SEPARATOR",
24
+ "EXCLUSION_EXPORT_TEMPLATE",
25
+ ]
26
+
27
+ GIT_PROGRAM_NAME: str = "git"
28
+ REVISION_PATH_SEPARATOR: str = ":"
29
+ REVISION_PATH_SPLIT_COUNT: int = 1
30
+ REVISION_SLASH_CHARACTER: str = "/"
31
+ ALL_CONVERTED_PATH_START_CHARACTERS: tuple[str, ...] = ("/", ".")
32
+ ALL_UNCONVERTED_PATH_START_PREFIXES: tuple[str, ...] = ("./", "../")
33
+ ALL_REVISION_FORBIDDEN_CHARACTERS: tuple[str, ...] = ("'", '"')
34
+ EXCLUSION_PREFIX_JOIN_SEPARATOR: str = ";"
35
+ EXCLUSION_EXPORT_TEMPLATE: str = (
36
+ "export " + MSYS_ARGUMENT_CONVERSION_EXCLUSION_VARIABLE_NAME + "='{all_prefixes}'; "
37
+ )
@@ -0,0 +1,34 @@
1
+ """Shared stdout emitter for PostToolUse hooks that add context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ from hooks_constants.bash_post_call_dispatcher_constants import (
9
+ ADDITIONAL_CONTEXT_KEY,
10
+ HOOK_EVENT_NAME_KEY,
11
+ HOOK_SPECIFIC_OUTPUT_KEY,
12
+ POST_TOOL_USE_HOOK_EVENT_NAME,
13
+ )
14
+
15
+
16
+ def write_post_tool_use_context_to_stdout(context_text: str) -> None:
17
+ """Write one PostToolUse additionalContext payload to stdout.
18
+
19
+ A hosted hook writes its note here and the Bash PostToolUse dispatcher
20
+ reads it back, joins it with the other hooks' notes, and writes the joined
21
+ text here in turn. Both ends of that hop share this one payload shape
22
+ rather than each carrying a copy of it.
23
+
24
+ Args:
25
+ context_text: The advisory text the dispatcher forwards.
26
+ """
27
+ payload = {
28
+ HOOK_SPECIFIC_OUTPUT_KEY: {
29
+ HOOK_EVENT_NAME_KEY: POST_TOOL_USE_HOOK_EVENT_NAME,
30
+ ADDITIONAL_CONTEXT_KEY: context_text,
31
+ }
32
+ }
33
+ sys.stdout.write(json.dumps(payload))
34
+ sys.stdout.flush()
@@ -19,7 +19,6 @@ __all__ = [
19
19
  "ALL_GH_PR_VIEW_ARGUMENTS",
20
20
  "GH_PR_VIEW_TIMEOUT_SECONDS",
21
21
  "NO_PULL_REQUEST_MARKER",
22
- "EXIT_CODE_ERROR_PREFIX",
23
22
  "DONE_LABEL_NAME",
24
23
  "MERGEABLE_CLEAN_VALUE",
25
24
  "MERGEABLE_CONFLICTING_VALUE",
@@ -59,7 +58,6 @@ ALL_GH_PR_VIEW_ARGUMENTS: tuple[str, ...] = (
59
58
  )
60
59
  GH_PR_VIEW_TIMEOUT_SECONDS: int = 20
61
60
  NO_PULL_REQUEST_MARKER: str = "no pull requests found"
62
- EXIT_CODE_ERROR_PREFIX: str = "Error: Exit code "
63
61
 
64
62
  DONE_LABEL_NAME: str = "done"
65
63
 
@@ -0,0 +1,48 @@
1
+ """Shared stdout emitter for PreToolUse hooks that allow a rewritten tool input."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
9
+ ALLOW_DECISION,
10
+ HOOK_EVENT_NAME,
11
+ )
12
+
13
+ __all__ = [
14
+ "HOOK_SPECIFIC_OUTPUT_KEY",
15
+ "HOOK_EVENT_NAME_KEY",
16
+ "PERMISSION_DECISION_KEY",
17
+ "UPDATED_INPUT_KEY",
18
+ "write_pre_tool_use_allow_to_stdout",
19
+ ]
20
+
21
+ HOOK_SPECIFIC_OUTPUT_KEY: str = "hookSpecificOutput"
22
+ HOOK_EVENT_NAME_KEY: str = "hookEventName"
23
+ PERMISSION_DECISION_KEY: str = "permissionDecision"
24
+ UPDATED_INPUT_KEY: str = "updatedInput"
25
+
26
+
27
+ def write_pre_tool_use_allow_to_stdout(
28
+ all_updated_tool_input_fields: dict[str, object],
29
+ ) -> None:
30
+ """Write one PreToolUse allow payload carrying a rewritten tool input.
31
+
32
+ A rewriter writes its replacement input here and the Bash PreToolUse
33
+ dispatcher reads it back, carrying the updatedInput through to the harness.
34
+ Both ends of that hop share this one payload shape rather than each holding
35
+ a copy of it.
36
+
37
+ Args:
38
+ all_updated_tool_input_fields: The full tool_input the tool call should run with.
39
+ """
40
+ payload = {
41
+ HOOK_SPECIFIC_OUTPUT_KEY: {
42
+ HOOK_EVENT_NAME_KEY: HOOK_EVENT_NAME,
43
+ PERMISSION_DECISION_KEY: ALLOW_DECISION,
44
+ UPDATED_INPUT_KEY: all_updated_tool_input_fields,
45
+ }
46
+ }
47
+ sys.stdout.write(json.dumps(payload))
48
+ sys.stdout.flush()
@@ -7,22 +7,29 @@ from hooks_constants.bash_post_call_dispatcher_constants import (
7
7
  from hooks_constants.bash_pre_tool_use_dispatcher_constants import BASH_TOOL_NAME
8
8
 
9
9
 
10
- def test_roster_runs_the_recorder_then_the_pr_done_reminder() -> None:
10
+ def test_roster_runs_the_recorder_then_the_pr_reminder_then_the_msys_advisor() -> None:
11
11
  all_script_paths = [
12
12
  each_entry.script_relative_path for each_entry in ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES
13
13
  ]
14
14
  assert all_script_paths == [
15
15
  "observability/test_failure_recorder.py",
16
16
  "advisory/pr_done_reminder.py",
17
+ "advisory/msys_path_conversion_advisor.py",
17
18
  ]
18
19
 
19
20
 
20
21
  def test_pr_done_reminder_also_serves_the_powershell_tool() -> None:
21
- reminder_entry = ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES[-1]
22
+ reminder_entry = ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES[1]
22
23
  assert reminder_entry.script_relative_path == "advisory/pr_done_reminder.py"
23
24
  assert "PowerShell" in reminder_entry.applicable_tool_names
24
25
 
25
26
 
27
+ def test_msys_advisor_serves_the_bash_tool_only() -> None:
28
+ advisor_entry = ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES[-1]
29
+ assert advisor_entry.script_relative_path == "advisory/msys_path_conversion_advisor.py"
30
+ assert advisor_entry.applicable_tool_names == frozenset({"Bash"})
31
+
32
+
26
33
  def test_context_forwarding_keys_match_the_hook_output_contract() -> None:
27
34
  assert constants.POST_TOOL_USE_HOOK_EVENT_NAME == "PostToolUse"
28
35
  assert constants.HOOK_SPECIFIC_OUTPUT_KEY == "hookSpecificOutput"