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.
- package/bin/install.mjs +0 -1
- package/bin/install.policy-lint.test.mjs +1 -2
- package/bin/install.test.mjs +0 -1
- package/hooks/advisory/msys_path_conversion_advisor.py +200 -0
- package/hooks/advisory/pr_done_reminder.py +7 -13
- package/hooks/advisory/test_msys_path_conversion_advisor.py +223 -0
- package/hooks/advisory/test_pr_done_reminder.py +1 -0
- package/hooks/blocking/bash_post_call_dispatcher.py +6 -11
- package/hooks/blocking/msys_rev_path_rewriter.py +192 -0
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +16 -3
- package/hooks/blocking/test_bash_post_call_dispatcher.py +2 -0
- package/hooks/blocking/test_msys_rev_path_rewriter.py +141 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +9 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +6 -1
- package/hooks/hooks_constants/msys_path_conversion_advisor_constants.py +48 -0
- package/hooks/hooks_constants/msys_rev_path_rewriter_constants.py +37 -0
- package/hooks/hooks_constants/post_tool_use_context.py +34 -0
- package/hooks/hooks_constants/pr_done_reminder_constants.py +0 -2
- package/hooks/hooks_constants/pre_tool_use_allow_output.py +48 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +9 -2
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +9 -2
- package/hooks/hooks_constants/test_failure_recorder_constants.py +1 -2
- package/hooks/hooks_constants/test_post_tool_use_context.py +22 -0
- package/hooks/hooks_constants/test_pre_tool_use_allow_output.py +27 -0
- package/hooks/observability/test_failure_recorder.py +3 -1
- package/package.json +1 -1
- package/rules/ask-user-question-required.md +3 -3
- package/rules/code-standards.md +3 -2
- package/rules/confirm-implementation-forks.md +2 -0
- package/rules/destructive-commands.md +19 -19
- package/rules/doc-inventory-integrity.md +4 -4
- package/rules/docstring-prose-matches-implementation.md +2 -2
- package/rules/failure-blast-radius.md +4 -2
- package/rules/filesystem-search.md +1 -1
- package/rules/git-workflow.md +34 -0
- package/rules/long-horizon-autonomy.md +2 -0
- package/rules/nas-ssh-invocation.md +3 -3
- package/rules/no-cross-skill-duplicate-helpers.md +6 -6
- package/rules/orphan-css-class.md +5 -5
- package/rules/paired-test-coverage.md +6 -6
- package/rules/plain-illustrative-docstrings.md +3 -3
- package/rules/re-stage-before-commit.md +7 -7
- package/rules/retired-hook-prose.md +16 -0
- package/rules/shell-invocation.md +17 -1
- package/rules/testing.md +3 -5
- package/rules/verify-runtime-state.md +1 -1
- package/rules/windows-filesystem-safe.md +2 -2
- package/rules/workflow-substitution-slots.md +1 -1
- package/scripts/durable_post_lint_config/config/constants.py +0 -3
- package/scripts/durable_post_lint_config/config/test_constants.py +5 -0
- package/scripts/policy_lint/adapter_configuration.py +24 -0
- package/scripts/policy_lint/adapter_retired_hook_prose.py +201 -0
- package/scripts/policy_lint/adapters.py +36 -1
- package/scripts/policy_lint/config/constants.py +74 -0
- package/scripts/policy_lint/registry.py +5 -0
- package/scripts/policy_lint/selection.py +37 -18
- package/scripts/policy_lint/selection_git.py +6 -2
- package/scripts/test_durable_post_lint.py +42 -9
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +197 -0
- package/scripts/tests/test_policy_lint_selection.py +107 -0
- package/hooks/hooks_constants/convergence_branch_constants.py +0 -8
- package/hooks/hooks_constants/destructive_command_environment_constants.py +0 -18
- package/hooks/hooks_constants/test_text_stripping.py +0 -39
- 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.
|
|
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', () => {
|
package/bin/install.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
)
|
|
@@ -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
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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:
|