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
package/bin/install.mjs CHANGED
@@ -1292,7 +1292,6 @@ export const RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS = new Set([
1292
1292
  'blocking/nas_ssh_binary_enforcer.py',
1293
1293
  'blocking/block_main_commit.py',
1294
1294
  'blocking/session_edit_stage_gate.py',
1295
- 'blocking/bash_pre_tool_use_dispatcher.py',
1296
1295
  'blocking/stop_dispatcher.py',
1297
1296
  'blocking/bot_mention_comment_blocker.py',
1298
1297
  'blocking/fable_spawn_gate.py',
@@ -36,7 +36,6 @@ const allRetiredPolicyRegistrations = [
36
36
  'blocking/nas_ssh_binary_enforcer.py',
37
37
  'blocking/block_main_commit.py',
38
38
  'blocking/session_edit_stage_gate.py',
39
- 'blocking/bash_pre_tool_use_dispatcher.py',
40
39
  'blocking/stop_dispatcher.py',
41
40
  'blocking/bot_mention_comment_blocker.py',
42
41
  'blocking/fable_spawn_gate.py',
@@ -117,7 +116,7 @@ test('upgrade prunes legacy direct file checks while retaining foreign hooks', (
117
116
  });
118
117
  assert.ok(commands.every(command => !command.includes('/blocking/stop_dispatcher.py')));
119
118
  assert.ok(commands.some(command => command.includes('/blocking/pre_tool_use_dispatcher.py')));
120
- assert.ok(commands.every(command => !command.includes('/blocking/bash_pre_tool_use_dispatcher.py')));
119
+ assert.ok(commands.some(command => command.includes('/blocking/bash_pre_tool_use_dispatcher.py')));
121
120
  });
122
121
 
123
122
  test('reinstall does not restore retired direct checks or duplicate foreign hooks', () => {
@@ -1195,7 +1195,6 @@ test('retired hook registrations stay managed so reinstall removes them', () =>
1195
1195
  'blocking/nas_ssh_binary_enforcer.py',
1196
1196
  'blocking/block_main_commit.py',
1197
1197
  'blocking/session_edit_stage_gate.py',
1198
- 'blocking/bash_pre_tool_use_dispatcher.py',
1199
1198
  'blocking/stop_dispatcher.py',
1200
1199
  'blocking/bot_mention_comment_blocker.py',
1201
1200
  'blocking/fable_spawn_gate.py',
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUse context advisory: Git Bash rewrote a ``<rev>:<path>`` argument.
3
+
4
+ This hook never blocks. It watches every Bash call finish and, when git failed
5
+ with either its ambiguous-argument error or its invalid-object-name error and
6
+ the quoted argument carries the marks of MSYS path conversion, adds one loud
7
+ note to the agent's context::
8
+
9
+ === MSYS PATH CONVERSION (context reminder, never a block) ===
10
+ Git saw: origin\\main;.claude\\skills\\x\\test_run_evals.py
11
+ Git Bash rewrote a <rev>:<path> argument before git saw it, turning the
12
+ colon into a semicolon and the slashes into backslashes.
13
+ Fix: export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'
14
+ Put that export and the git command in the same command.
15
+
16
+ Why a reminder and not a gate: the rewriting happens inside the shell, so the
17
+ command text the agent wrote looks correct and the error names an argument the
18
+ agent never typed. The note arrives at the moment the failure lands, which is
19
+ the only moment the two names line up.
20
+
21
+ Six quiet branches emit nothing: a non-Bash tool, a zero-exit call, a failure
22
+ carrying neither marker, a quoted argument whose closing quote never arrives, a
23
+ quoted argument free of both mangling marks, and a command that already names
24
+ either workaround variable.
25
+
26
+ The verdict rests on the failure text alone and never parses the command, so
27
+ two shapes get the note without deserving it: an argument the agent typed with
28
+ a semicolon or a backslash of its own, and a failing call whose stdout quotes
29
+ one of the two git messages. Both are rare, and the note costs a reader one
30
+ paragraph, so the detector keeps its single read of the failure text.
31
+
32
+ Hosted by ``blocking/bash_post_call_dispatcher.py``, which forwards the
33
+ ``hookSpecificOutput.additionalContext`` this hook prints.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import sys
39
+ from pathlib import Path
40
+
41
+ try:
42
+ _hooks_root_directory = str(Path(__file__).resolve().parent.parent)
43
+ if _hooks_root_directory not in sys.path:
44
+ sys.path.insert(0, _hooks_root_directory)
45
+ from hooks_constants.bash_post_call_dispatcher_constants import (
46
+ EXIT_CODE_ERROR_PREFIX,
47
+ )
48
+ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
49
+ ALL_BASH_ONLY_TOOL_NAMES,
50
+ )
51
+ from hooks_constants.msys_path_conversion_advisor_constants import (
52
+ ADVISORY_HEADER,
53
+ ADVISORY_LINE_SEPARATOR,
54
+ ALL_MANGLED_ARGUMENT_MARKERS,
55
+ ALL_MSYS_MANGLING_CHARACTERS,
56
+ ALL_MSYS_PATH_CONVERSION_VARIABLE_NAMES,
57
+ FIX_LINE,
58
+ MANGLED_ARGUMENT_CLOSING_QUOTE,
59
+ MANGLED_ARGUMENT_LINE_TEMPLATE,
60
+ MANGLING_EXPLANATION_LINE,
61
+ SAME_COMMAND_LINE,
62
+ )
63
+ from hooks_constants.post_tool_use_context import (
64
+ write_post_tool_use_context_to_stdout,
65
+ )
66
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
67
+ except ImportError as import_error:
68
+ raise ImportError(
69
+ "msys_path_conversion_advisor is unable to import its dependencies; "
70
+ "ensure the hooks directory is importable."
71
+ ) from import_error
72
+
73
+
74
+ def text_after_first_mangled_argument_marker(response_text: str) -> str | None:
75
+ """Return what follows the first marker present in a git failure, else None.
76
+
77
+ Git names a mangled ``<rev>:<path>`` argument under two messages, one for a
78
+ path after the colon and one for an absolute path after the colon. The
79
+ markers are walked in order and the first one present wins.
80
+
81
+ Args:
82
+ response_text: The harness response text for the failed call.
83
+
84
+ Returns:
85
+ The text following the opening quote of the first marker present, or
86
+ None when the response carries no marker.
87
+ """
88
+ for each_marker in ALL_MANGLED_ARGUMENT_MARKERS:
89
+ _, marker_text, text_after_marker = response_text.partition(each_marker)
90
+ if marker_text:
91
+ return text_after_marker
92
+ return None
93
+
94
+
95
+ def argument_carries_msys_mangling_marks(argument_text: str) -> bool:
96
+ """Return whether an argument carries a mark left behind by path conversion.
97
+
98
+ Path conversion leaves a backslash or a semicolon in the argument, and that
99
+ is what separates this failure from a revision that is simply absent.
100
+
101
+ Args:
102
+ argument_text: The quoted argument git named in its failure.
103
+
104
+ Returns:
105
+ True when one of those marks is present, else False.
106
+ """
107
+ return any(each_character in argument_text for each_character in ALL_MSYS_MANGLING_CHARACTERS)
108
+
109
+
110
+ def command_names_a_path_conversion_variable(command_text: str) -> bool:
111
+ """Return whether the command already names a variable that stops conversion.
112
+
113
+ The advisory names two exports, and either one on its own suppresses the
114
+ rewriting, so a command carrying either has nothing left to learn.
115
+
116
+ Args:
117
+ command_text: The Bash command text the agent ran.
118
+
119
+ Returns:
120
+ True when one of those variable names is present, else False.
121
+ """
122
+ return any(
123
+ each_variable_name in command_text
124
+ for each_variable_name in ALL_MSYS_PATH_CONVERSION_VARIABLE_NAMES
125
+ )
126
+
127
+
128
+ def mangled_revision_path_argument(command_text: str, tool_response: object) -> str | None:
129
+ """Return the argument git reported, when MSYS mangled it, else None.
130
+
131
+ Four conditions all hold before an argument is returned. The harness
132
+ reported a non-zero exit status, which arrives as a string carrying the
133
+ exit-code prefix. Git printed either its ambiguous-argument error or its
134
+ invalid-object-name error, and the quoted argument after it closes. That
135
+ argument carries a path-conversion mark, and the command names neither of
136
+ the two variables the workaround exports.
137
+
138
+ Args:
139
+ command_text: The Bash command text the agent ran.
140
+ tool_response: The harness response for the finished call.
141
+
142
+ Returns:
143
+ The quoted argument word for word, or None to stay quiet.
144
+ """
145
+ if not isinstance(tool_response, str) or not tool_response.startswith(EXIT_CODE_ERROR_PREFIX):
146
+ return None
147
+ text_after_marker = text_after_first_mangled_argument_marker(tool_response)
148
+ if text_after_marker is None:
149
+ return None
150
+ argument_text, closing_quote, _ = text_after_marker.partition(MANGLED_ARGUMENT_CLOSING_QUOTE)
151
+ if not closing_quote:
152
+ return None
153
+ if not argument_carries_msys_mangling_marks(argument_text):
154
+ return None
155
+ if command_names_a_path_conversion_variable(command_text):
156
+ return None
157
+ return argument_text
158
+
159
+
160
+ def build_advisory_context(mangled_argument: str) -> str:
161
+ """Build the advisory text naming the mangled argument and the two exports.
162
+
163
+ Args:
164
+ mangled_argument: The argument git reported, word for word.
165
+
166
+ Returns:
167
+ The multi-line note the dispatcher forwards as additionalContext.
168
+ """
169
+ all_lines = [
170
+ ADVISORY_HEADER,
171
+ MANGLED_ARGUMENT_LINE_TEMPLATE.format(argument=mangled_argument),
172
+ MANGLING_EXPLANATION_LINE,
173
+ FIX_LINE,
174
+ SAME_COMMAND_LINE,
175
+ ]
176
+ return ADVISORY_LINE_SEPARATOR.join(all_lines)
177
+
178
+
179
+ def main() -> None:
180
+ """Add the MSYS path-conversion note after a git call the shell mangled.
181
+
182
+ Reads the PostToolUse payload from stdin. Every quiet branch returns with
183
+ no output, so this hook can never alter or block a tool call.
184
+ """
185
+ hook_payload = read_hook_input_dictionary_from_stdin()
186
+ if hook_payload is None or hook_payload.get("tool_name") not in ALL_BASH_ONLY_TOOL_NAMES:
187
+ return
188
+ tool_input = hook_payload.get("tool_input")
189
+ command_text = tool_input.get("command", "") if isinstance(tool_input, dict) else ""
190
+ if not isinstance(command_text, str):
191
+ return
192
+ mangled_argument = mangled_revision_path_argument(
193
+ command_text, hook_payload.get("tool_response")
194
+ )
195
+ if mangled_argument is not None:
196
+ write_post_tool_use_context_to_stdout(build_advisory_context(mangled_argument))
197
+
198
+
199
+ if __name__ == "__main__":
200
+ main()
@@ -43,13 +43,18 @@ try:
43
43
  from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
44
44
  ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
45
45
  )
46
+ from hooks_constants.bash_post_call_dispatcher_constants import (
47
+ EXIT_CODE_ERROR_PREFIX,
48
+ )
49
+ from hooks_constants.post_tool_use_context import (
50
+ write_post_tool_use_context_to_stdout,
51
+ )
46
52
  from hooks_constants.pr_done_reminder_constants import (
47
53
  ADD_LABEL_COMMAND_TEMPLATE,
48
54
  ALL_FAILING_CHECK_CONCLUSIONS,
49
55
  ALL_PASSING_CHECK_CONCLUSIONS,
50
56
  CHECK_COMPLETED_STATUS,
51
57
  DONE_LABEL_NAME,
52
- EXIT_CODE_ERROR_PREFIX,
53
58
  GH_PR_CREATE_ACTION,
54
59
  GH_PR_SUBCOMMAND,
55
60
  ALL_GH_PR_VIEW_ARGUMENTS,
@@ -304,17 +309,6 @@ def _probe_pull_request(cwd: str) -> str | None:
304
309
  return build_reminder_context(all_pr_fields)
305
310
 
306
311
 
307
- def _emit_context(context_text: str) -> None:
308
- payload = {
309
- "hookSpecificOutput": {
310
- "hookEventName": "PostToolUse",
311
- "additionalContext": context_text,
312
- }
313
- }
314
- sys.stdout.write(json.dumps(payload))
315
- sys.stdout.flush()
316
-
317
-
318
312
  def main() -> None:
319
313
  """Add the PR done checklist to context after a successful push or PR creation.
320
314
 
@@ -335,7 +329,7 @@ def main() -> None:
335
329
  return
336
330
  context_text = _probe_pull_request(str(hook_payload.get("cwd") or ""))
337
331
  if context_text is not None:
338
- _emit_context(context_text)
332
+ write_post_tool_use_context_to_stdout(context_text)
339
333
 
340
334
 
341
335
  if __name__ == "__main__":
@@ -0,0 +1,223 @@
1
+ """Behavior tests for the MSYS path-conversion advisory hook.
2
+
3
+ The hook is a PostToolUse observer on Bash. It never blocks. These tests pin
4
+ which failed git call wakes it, which lookalike failures stay quiet, and what
5
+ the advisory text says.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import io
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+
17
+ try:
18
+ advisory_directory = str(Path(__file__).resolve().parent)
19
+ if advisory_directory not in sys.path:
20
+ sys.path.insert(0, advisory_directory)
21
+ import msys_path_conversion_advisor
22
+ except ImportError as import_error:
23
+ raise ImportError(
24
+ "test_msys_path_conversion_advisor: cannot import its sibling modules; "
25
+ "ensure the advisory directory is importable."
26
+ ) from import_error
27
+
28
+
29
+ _MANGLED_ARGUMENT = r"origin\main;.claude\skills\x\test_run_evals.py"
30
+ _MANGLED_FAILURE_RESPONSE = (
31
+ "Error: Exit code 128\n"
32
+ r"fatal: ambiguous argument 'origin\main;.claude\skills\x\test_run_evals.py': "
33
+ "unknown revision or path not in the working tree."
34
+ )
35
+ _MISSING_REVISION_RESPONSE = (
36
+ "Error: Exit code 128\n"
37
+ "fatal: ambiguous argument 'origin/nope:packages/app/main.py': "
38
+ "unknown revision or path not in the working tree."
39
+ )
40
+ _MANGLED_OBJECT_NAME_RESPONSE = (
41
+ "Error: Exit code 128\n" + r"fatal: invalid object name 'origin\main;C'."
42
+ )
43
+ _MISSING_OBJECT_NAME_RESPONSE = (
44
+ "Error: Exit code 128\nfatal: invalid object name 'nosuchbranch'."
45
+ )
46
+ _SHOW_COMMAND = "git show origin/main:.claude/skills/x/test_run_evals.py"
47
+ _ABSOLUTE_PATH_SHOW_COMMAND = "git show origin/main:/nope"
48
+ _MISSING_OBJECT_SHOW_COMMAND = "git show nosuchbranch:AGENTS.md"
49
+ _EXPORT_LINE = "export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'"
50
+
51
+
52
+ def _payload(command: str, tool_response: object) -> str:
53
+ return json.dumps(
54
+ {
55
+ "session_id": "msys-session",
56
+ "cwd": "C:/repo",
57
+ "tool_name": "Bash",
58
+ "tool_input": {"command": command},
59
+ "tool_response": tool_response,
60
+ }
61
+ )
62
+
63
+
64
+ def _run_main(
65
+ monkeypatch: pytest.MonkeyPatch,
66
+ capsys: pytest.CaptureFixture[str],
67
+ payload_text: str,
68
+ ) -> str:
69
+ monkeypatch.setattr(sys, "stdin", io.StringIO(payload_text))
70
+ msys_path_conversion_advisor.main()
71
+ return capsys.readouterr().out
72
+
73
+
74
+ def test_should_return_the_mangled_argument_word_for_word() -> None:
75
+ mangled_argument = msys_path_conversion_advisor.mangled_revision_path_argument(
76
+ _SHOW_COMMAND, _MANGLED_FAILURE_RESPONSE
77
+ )
78
+
79
+ assert mangled_argument == r"origin\main;.claude\skills\x\test_run_evals.py"
80
+
81
+
82
+ def test_should_return_the_mangled_argument_from_an_invalid_object_name_failure() -> None:
83
+ mangled_argument = msys_path_conversion_advisor.mangled_revision_path_argument(
84
+ _ABSOLUTE_PATH_SHOW_COMMAND, _MANGLED_OBJECT_NAME_RESPONSE
85
+ )
86
+
87
+ assert mangled_argument == r"origin\main;C"
88
+
89
+
90
+ def test_should_stay_quiet_when_the_object_name_failure_names_an_unmangled_argument() -> None:
91
+ assert (
92
+ msys_path_conversion_advisor.mangled_revision_path_argument(
93
+ _MISSING_OBJECT_SHOW_COMMAND, _MISSING_OBJECT_NAME_RESPONSE
94
+ )
95
+ is None
96
+ )
97
+
98
+
99
+ def test_should_stay_quiet_when_the_command_succeeded() -> None:
100
+ zero_exit_response = {"stdout": "", "stderr": ""}
101
+
102
+ assert (
103
+ msys_path_conversion_advisor.mangled_revision_path_argument(
104
+ _SHOW_COMMAND, zero_exit_response
105
+ )
106
+ is None
107
+ )
108
+
109
+
110
+ def test_should_stay_quiet_when_the_quoted_argument_is_unmangled() -> None:
111
+ assert (
112
+ msys_path_conversion_advisor.mangled_revision_path_argument(
113
+ "git show origin/nope:packages/app/main.py", _MISSING_REVISION_RESPONSE
114
+ )
115
+ is None
116
+ )
117
+
118
+
119
+ def test_should_stay_quiet_when_the_command_already_exports_the_workaround() -> None:
120
+ already_fixed_command = (
121
+ "export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' && "
122
+ "git show origin/main:.claude/skills/x/test_run_evals.py"
123
+ )
124
+
125
+ assert (
126
+ msys_path_conversion_advisor.mangled_revision_path_argument(
127
+ already_fixed_command, _MANGLED_FAILURE_RESPONSE
128
+ )
129
+ is None
130
+ )
131
+
132
+
133
+ def test_should_stay_quiet_when_the_command_exports_only_the_argument_exclusion() -> None:
134
+ argument_exclusion_only_command = (
135
+ "export MSYS2_ARG_CONV_EXCL='*' && "
136
+ "git show origin/main:.claude/skills/x/test_run_evals.py"
137
+ )
138
+
139
+ assert (
140
+ msys_path_conversion_advisor.mangled_revision_path_argument(
141
+ argument_exclusion_only_command, _MANGLED_FAILURE_RESPONSE
142
+ )
143
+ is None
144
+ )
145
+
146
+
147
+ def test_should_stay_quiet_without_the_ambiguous_argument_marker() -> None:
148
+ other_failure = "Error: Exit code 1\nfatal: not a git repository"
149
+
150
+ assert (
151
+ msys_path_conversion_advisor.mangled_revision_path_argument(_SHOW_COMMAND, other_failure)
152
+ is None
153
+ )
154
+
155
+
156
+ def test_should_stay_quiet_when_the_quoted_argument_never_closes() -> None:
157
+ unterminated_failure = (
158
+ "Error: Exit code 128\n" + r"fatal: ambiguous argument 'origin\main;.claude"
159
+ )
160
+
161
+ assert (
162
+ msys_path_conversion_advisor.mangled_revision_path_argument(
163
+ _SHOW_COMMAND, unterminated_failure
164
+ )
165
+ is None
166
+ )
167
+
168
+
169
+ def test_advisory_should_name_the_argument_and_the_export_line() -> None:
170
+ advisory_text = msys_path_conversion_advisor.build_advisory_context(_MANGLED_ARGUMENT)
171
+
172
+ assert advisory_text.startswith("=== MSYS PATH CONVERSION")
173
+ assert "never a block" in advisory_text
174
+ assert r"origin\main;.claude\skills\x\test_run_evals.py" in advisory_text
175
+ assert _EXPORT_LINE in advisory_text
176
+ assert "Git Bash rewrote a <rev>:<path> argument" in advisory_text
177
+ assert "same command" in advisory_text
178
+
179
+
180
+ def test_main_should_emit_additional_context_after_a_mangled_failure(
181
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
182
+ ) -> None:
183
+ stdout_text = _run_main(monkeypatch, capsys, _payload(_SHOW_COMMAND, _MANGLED_FAILURE_RESPONSE))
184
+
185
+ payload = json.loads(stdout_text)
186
+ assert payload["hookSpecificOutput"]["hookEventName"] == "PostToolUse"
187
+ additional_context = str(payload["hookSpecificOutput"]["additionalContext"])
188
+ assert _EXPORT_LINE in additional_context
189
+ assert r"origin\main;.claude\skills\x\test_run_evals.py" in additional_context
190
+
191
+
192
+ def test_main_should_stay_quiet_for_a_non_bash_tool(
193
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
194
+ ) -> None:
195
+ payload = json.loads(_payload(_SHOW_COMMAND, _MANGLED_FAILURE_RESPONSE))
196
+ payload["tool_name"] = "PowerShell"
197
+
198
+ assert _run_main(monkeypatch, capsys, json.dumps(payload)) == ""
199
+
200
+
201
+ def test_should_return_an_argument_whose_only_mangling_mark_is_the_semicolon() -> None:
202
+ semicolon_only_response = (
203
+ "Error: Exit code 128\nfatal: invalid object name 'HEAD;.gitignore'."
204
+ )
205
+
206
+ mangled_argument = msys_path_conversion_advisor.mangled_revision_path_argument(
207
+ "git show HEAD:.gitignore", semicolon_only_response
208
+ )
209
+
210
+ assert mangled_argument == "HEAD;.gitignore"
211
+
212
+
213
+ def test_should_stay_quiet_when_the_command_exports_only_the_path_conversion_switch() -> None:
214
+ path_conversion_only_command = (
215
+ "export MSYS_NO_PATHCONV=1 && git show origin/main:.claude/skills/x/test_run_evals.py"
216
+ )
217
+
218
+ assert (
219
+ msys_path_conversion_advisor.mangled_revision_path_argument(
220
+ path_conversion_only_command, _MANGLED_FAILURE_RESPONSE
221
+ )
222
+ is None
223
+ )
@@ -247,3 +247,4 @@ def test_main_should_stay_quiet_on_any_other_gh_failure(
247
247
  gh_down = _fake_gh(None, stderr="error connecting to api.github.com")
248
248
 
249
249
  assert _run_main(monkeypatch, capsys, _payload("git push"), gh_down) == ""
250
+
@@ -18,7 +18,8 @@ decision -- it is an observer, not a gate -- so this dispatcher runs the whole
18
18
  roster unconditionally and never blocks. The one thing it forwards is context:
19
19
  a hosted hook may print a ``hookSpecificOutput.additionalContext`` string (the
20
20
  PR done reminder does), and the dispatcher joins every such string into one
21
- PostToolUse payload. A hook that prints nothing adds nothing.
21
+ PostToolUse payload through the shared emitter every PostToolUse hook here
22
+ writes with. A hook that prints nothing adds nothing.
22
23
 
23
24
  A single hosted hook crash fails open: ``run_hook_capturing_output`` isolates
24
25
  it, so it contributes nothing and does not stop the remaining hosted hooks.
@@ -30,7 +31,6 @@ imports load once into this process rather than once per hook.
30
31
  from __future__ import annotations
31
32
 
32
33
  import json
33
- import sys
34
34
 
35
35
  import _path_setup # noqa: F401
36
36
 
@@ -39,8 +39,8 @@ from hooks_constants.bash_post_call_dispatcher_constants import (
39
39
  ADDITIONAL_CONTEXT_KEY,
40
40
  ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES,
41
41
  HOOK_SPECIFIC_OUTPUT_KEY,
42
- POST_TOOL_USE_HOOK_EVENT_NAME,
43
42
  )
43
+ from hooks_constants.post_tool_use_context import write_post_tool_use_context_to_stdout
44
44
  from hooks_constants.bash_pre_tool_use_dispatcher_constants import BashHostedHookEntry
45
45
  from hooks_constants.hosted_hook_runner import (
46
46
  run_hook_capturing_output,
@@ -98,14 +98,9 @@ def dispatch(payload_text: str, tool_name: str) -> None:
98
98
  all_additional_context.append(additional_context)
99
99
  if not all_additional_context:
100
100
  return
101
- payload = {
102
- HOOK_SPECIFIC_OUTPUT_KEY: {
103
- "hookEventName": POST_TOOL_USE_HOOK_EVENT_NAME,
104
- ADDITIONAL_CONTEXT_KEY: ADDITIONAL_CONTEXT_JOIN_SEPARATOR.join(all_additional_context),
105
- }
106
- }
107
- sys.stdout.write(json.dumps(payload))
108
- sys.stdout.flush()
101
+ write_post_tool_use_context_to_stdout(
102
+ ADDITIONAL_CONTEXT_JOIN_SEPARATOR.join(all_additional_context)
103
+ )
109
104
 
110
105
 
111
106
  def main() -> None: