claude-dev-env 8.2.0 → 8.4.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 (70) hide show
  1. package/.agents/agents/poteto-agent.md +6 -0
  2. package/.agents/agents/test_agent_frontmatter.py +9 -9
  3. package/.agents/agents/test_pr_description_writer_style.py +1 -1
  4. package/.agents/agents-archived/ARCHIVE-MANIFEST.md +15 -0
  5. package/.agents/{agents → agents-archived}/code-quality-agent.md +1 -1
  6. package/.agents/{agents → agents-archived}/session-advisor.md +1 -1
  7. package/.agents/skills/_shared/end-of-run-gotcha-recommendations.md +2 -2
  8. package/.agents/skills/grok-spawn/SKILL.md +9 -6
  9. package/.agents/skills/grok-spawn/reference/flag-profiles.md +12 -8
  10. package/.agents/skills/issue-tracker/SKILL.md +2 -2
  11. package/.agents/skills/orchestrator/SKILL.md +16 -15
  12. package/.agents/skills/orchestrator-refresh/SKILL.md +2 -0
  13. package/.agents/skills/pull-request/SKILL.md +14 -5
  14. package/.agents/skills/pull-request/reference/publication-tasks.md +1 -1
  15. package/.agents/skills/team-advisor/SKILL.md +13 -8
  16. package/_shared/advisor/reference/spawn-walk-log.md +2 -1
  17. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +3 -0
  18. package/_shared/advisor/scripts/model_tier_run_validator.py +169 -14
  19. package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +20 -0
  20. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +99 -0
  21. package/bin/install.agents-home.test.mjs +54 -94
  22. package/bin/install.mjs +31 -24
  23. package/bin/install.test.mjs +35 -0
  24. package/bin/resolve-package-managed-directory.test.mjs +1 -1
  25. package/bin/subagent-model-policy.test.mjs +15 -8
  26. package/docs/references/advisor-tool.md +32 -0
  27. package/docs/references/team-advisor-skill.md +1 -1
  28. package/docs/subagent-model-routing.md +6 -5
  29. package/hooks/blocking/tdd_enforcer.py +2 -3
  30. package/hooks/blocking/test_bash_post_call_dispatcher.py +7 -24
  31. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +0 -3
  32. package/hooks/hooks_constants/hosted_hook_runner.py +1 -1
  33. package/hooks/hooks_constants/msys_path_conversion_advisor_constants.py +3 -35
  34. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +2 -10
  35. package/hooks/routing/subagent_model_routing.mjs +15 -0
  36. package/hooks/routing/subagent_model_routing.test.mjs +58 -25
  37. package/hooks/validators/README.md +0 -1
  38. package/hooks/validators/test_files/EXECUTIVE_SUMMARY.md +1 -2
  39. package/package.json +1 -1
  40. package/rules/git-workflow.md +3 -3
  41. package/rules/shell-invocation.md +0 -2
  42. package/rules/subagent-model-policy.json +5 -13
  43. package/rules/testing.md +0 -2
  44. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +3 -3
  45. package/scripts/grok_headless_runner.py +19 -1
  46. package/scripts/grok_worker_preflight.py +67 -1
  47. package/scripts/policy_lint/config/approved_test_pairs.py +9 -0
  48. package/scripts/resolve_advisor_model_route.mjs +2 -2
  49. package/scripts/subagent_model_policy.mjs +4 -1
  50. package/scripts/test_grok_headless_runner.py +35 -1
  51. package/scripts/test_grok_worker_preflight.py +73 -0
  52. package/scripts/tests/resolve_advisor_model_route.test.mjs +3 -3
  53. package/scripts/tests/test_policy_lint_rules_pairing.py +16 -0
  54. package/scripts/tests/test_rules.py +5 -5
  55. package/scripts/windows/pstack/pstack-command-shim.ps1 +41 -0
  56. package/scripts/windows/pstack/ship-pr.cmd +3 -0
  57. package/scripts/windows/pstack/watch-pr.cmd +3 -0
  58. package/hooks/advisory/msys_path_conversion_advisor.py +0 -200
  59. package/hooks/advisory/test_msys_path_conversion_advisor.py +0 -223
  60. package/hooks/hooks_constants/test_failure_recorder_constants.py +0 -34
  61. package/hooks/observability/test_failure_recorder.py +0 -221
  62. package/hooks/observability/test_test_failure_recorder.py +0 -173
  63. package/hooks/validators/VALIDATION_REPORT.md +0 -287
  64. package/hooks/validators/validator_defaults.py +0 -5
  65. /package/.agents/{agents → agents-archived}/clean-coder.md +0 -0
  66. /package/.agents/{agents → agents-archived}/git-commit-crafter.md +0 -0
  67. /package/.agents/{agents → agents-archived}/issue-tracker.md +0 -0
  68. /package/.agents/{agents → agents-archived}/plan-packet-validator.md +0 -0
  69. /package/.agents/{agents → agents-archived}/pr-description-writer.md +0 -0
  70. /package/.agents/{agents → agents-archived}/skill-writer-agent.md +0 -0
@@ -1,200 +0,0 @@
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()
@@ -1,223 +0,0 @@
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
- )
@@ -1,34 +0,0 @@
1
- """Constants for the failing-test-run PostToolUse recorder hook.
2
-
3
- Holds the cheap substring check the hook runs before any shell parsing, the
4
- chaining and redirection operator
5
- tokens that keep the recognizer to one unchained command, the pytest program
6
- and Python interpreter basenames, and the flag-token marker the path
7
- extractor uses to skip an option rather than an existing test file argument.
8
-
9
- The pytest-program and interpreter recognition here is deliberately its own small, stdlib-only
10
- check rather than a reuse of ``piped_pytest_blocker_constants`` /
11
- ``pytest_invocation``: those solve the harder pipe-and-wrapper problem and
12
- pull in a much larger transitive import for every Bash call regardless of
13
- content, working against the point of the recognizer's own cheap prefilter.
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- import re
19
-
20
- BASH_TOOL_NAME = "Bash"
21
-
22
- ALL_PYTEST_MENTION_SUBSTRINGS: tuple[str, ...] = ("pytest", "py.test")
23
- OPTION_TOKEN_PREFIX: str = "-"
24
- NODE_ID_FILE_PATH_SEPARATOR: str = "::"
25
- ALL_CHAINING_OPERATOR_SUBSTRINGS: tuple[str, ...] = ("&&", "||", ";", "|", "\n")
26
- ALL_REDIRECTION_TOKENS: frozenset[str] = frozenset(
27
- {">", ">>", "<", "<<", "2>&1", "1>&2", "2>", "1>"}
28
- )
29
- ALL_PYTEST_PROGRAM_BASENAMES: frozenset[str] = frozenset({"pytest", "py.test"})
30
- MODULE_RUN_FLAG: str = "-m"
31
- PYTEST_MODULE_NAME: str = "pytest"
32
- PYTHON_INTERPRETER_BASENAME_PATTERN: re.Pattern[str] = re.compile(
33
- r"^(?:python|py|pypy)[0-9.]*(?:\.exe)?$"
34
- )
@@ -1,221 +0,0 @@
1
- #!/usr/bin/env python3
2
- """PostToolUse hook: record a failing single pytest run as TDD-gate evidence.
3
-
4
- A file-content edit is not the same claim as a test that ran and failed. The
5
- content-hash store in ``tdd_enforcer_parts/content_hash_store.py`` already
6
- tracks whether a candidate test file's content changed; this hook adds a
7
- stronger form of evidence -- a real RED -- by watching every Bash call finish
8
- and, for a genuine failing pytest run, recording the command, its real exit
9
- status, and the test file paths it named into that same store.
10
-
11
- ::
12
-
13
- pytest test_orders.py (exit 1) -> record test_orders.py
14
- pytest test_orders.py -k fulfill (exit 1) -> record test_orders.py
15
- pytest test_orders.py::test_fulfill (exit 1) -> record test_orders.py
16
- pytest -q (exit 1) -> no path named, no record
17
- cd repo && pytest test_orders.py (exit 1) -> chained, no record
18
- pytest test_orders.py | tee run.log (blocked by piped_pytest_blocker
19
- first -- this hook never sees it)
20
- npm test (exit 1) -> not pytest, no record
21
- pytest test_orders.py (exit 0) -> passed, no record
22
-
23
- What this hook matches, and why
24
- --------------------------------
25
-
26
- Only a single, unchained Bash command whose own program is ``pytest`` /
27
- ``py.test``, or a Python interpreter running ``-m pytest``. A chained command
28
- (``&&``, ``;``, ``|``, ``||``, a newline) is rejected outright: this repo's own
29
- ``piped_pytest_blocker.py`` already tells the caller to "run pytest alone" for
30
- exactly this reason, and a chain's reported exit status may belong to a
31
- command other than pytest, or to none of them if an earlier link short-
32
- circuited before pytest even ran. ``npm test``, ``node --test``, and
33
- ``vitest`` are deliberately not matched: ``npm test`` is a script indirection
34
- whose real test runner lives in ``package.json``, not on the command line,
35
- and the other two have no existing, tested classification helper in this
36
- repository the way pytest does. Extending to them would mean inventing new
37
- parsing rather than reusing anything proven, which is the over-reach this
38
- mechanism is built to avoid.
39
-
40
- The recognizer here is deliberately its own small, stdlib-only ``shlex``
41
- check rather than a reuse of ``hooks_constants.pytest_invocation`` /
42
- ``shell_command_pipeline``. Those solve the harder pipe-and-wrapper problem a
43
- PreToolUse blocker needs, and this repository's own style rule forbids an
44
- import inside a function body, so importing that larger machinery here would
45
- mean paying its full transitive import cost on every single Bash call this
46
- hook sees, pytest or not -- working directly against the point of watching
47
- every Bash call. The narrower, single-unchained-command scope this hook
48
- accepts does not need that machinery.
49
-
50
- Where the exit status comes from
51
- ---------------------------------
52
-
53
- Not from scraping the command's output text for words like "failed" or
54
- "error". The Bash tool's own PostToolUse ``tool_response`` takes two shapes
55
- observed directly in this session's own transcript: a dict with ``stdout``/
56
- ``stderr`` keys when the command's reported exit status is 0, and the literal
57
- string ``"Error: Exit code <N>\\n<output>"`` when it is not. This hook reads
58
- that harness-authored ``<N>`` directly; it is a real Bash mechanism doing the
59
- work correctly, not a lookalike that only mimics one.
60
-
61
- Where the test paths come from
62
- --------------------------------
63
-
64
- The command's own arguments, not its output. A bare ``pytest`` or ``pytest
65
- -q`` names no path and is left unrecorded on purpose: recording it against
66
- every test file pytest might have discovered would satisfy the gate for a
67
- file this run never demonstrably covered, which is the exact bypass the
68
- audit calls out. Only a token that is not an option flag and resolves to a
69
- real file on disk, relative to the command's own ``cwd``, counts.
70
-
71
- What stops a bypass
72
- ---------------------
73
-
74
- Three things, together:
75
-
76
- 1. ``content_hash_store.record_test_command_failure`` keys each entry
77
- exactly as the gate looks candidates up, so a failure never satisfies a
78
- sibling file the same command also happened to run (see
79
- ``test_content_hash_store.py::test_recorded_failure_does_not_satisfy_a_different_candidate``).
80
- 2. The gate consults a recorded failure only while the file's *current*
81
- content hash still matches what actually failed (see
82
- ``test_recorded_failure_does_not_apply_once_content_no_longer_matches``).
83
- Editing the test again clears it as evidence.
84
- 3. A record ages out after the same freshness window every other form of
85
- evidence uses (see ``test_recorded_failure_expires_after_the_freshness_window``).
86
-
87
- The hook never blocks a tool call. A non-Bash tool, a malformed payload, a
88
- command that does not mention "pytest", a chained command, a command with no
89
- real path argument, or a failed record each returns quietly.
90
- """
91
-
92
- from __future__ import annotations
93
-
94
- import shlex
95
- import sys
96
- from pathlib import Path
97
-
98
- try:
99
- _hooks_root_directory = str(Path(__file__).resolve().parent.parent)
100
- _blocking_directory = str(Path(_hooks_root_directory) / "blocking")
101
- for each_bootstrap_directory in (_hooks_root_directory, _blocking_directory):
102
- if each_bootstrap_directory not in sys.path:
103
- sys.path.insert(0, each_bootstrap_directory)
104
- from hooks_constants.bash_post_call_dispatcher_constants import (
105
- EXIT_CODE_ERROR_PREFIX,
106
- )
107
- from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
108
- from hooks_constants.test_failure_recorder_constants import (
109
- ALL_CHAINING_OPERATOR_SUBSTRINGS,
110
- ALL_PYTEST_MENTION_SUBSTRINGS,
111
- ALL_PYTEST_PROGRAM_BASENAMES,
112
- ALL_REDIRECTION_TOKENS,
113
- BASH_TOOL_NAME,
114
- MODULE_RUN_FLAG,
115
- NODE_ID_FILE_PATH_SEPARATOR,
116
- OPTION_TOKEN_PREFIX,
117
- PYTEST_MODULE_NAME,
118
- PYTHON_INTERPRETER_BASENAME_PATTERN,
119
- )
120
- from tdd_enforcer_parts import content_hash_store
121
- except ImportError as import_error:
122
- raise ImportError(
123
- "test_failure_recorder: cannot import its dependencies; "
124
- "ensure the hooks directory is importable."
125
- ) from import_error
126
-
127
-
128
- def _mentions_pytest(command: str) -> bool:
129
- lowered_command = command.lower()
130
- return any(
131
- each_substring in lowered_command for each_substring in ALL_PYTEST_MENTION_SUBSTRINGS
132
- )
133
-
134
-
135
- def _harness_reported_exit_status(tool_response: object) -> int | None:
136
- if not isinstance(tool_response, str) or not tool_response.startswith(EXIT_CODE_ERROR_PREFIX):
137
- return None
138
- remainder = tool_response[len(EXIT_CODE_ERROR_PREFIX) :]
139
- digit_count = 0
140
- while digit_count < len(remainder) and remainder[digit_count].isdigit():
141
- digit_count += 1
142
- return int(remainder[:digit_count]) if digit_count else None
143
-
144
-
145
- def _is_single_unchained_command(command: str) -> bool:
146
- return not any(each_operator in command for each_operator in ALL_CHAINING_OPERATOR_SUBSTRINGS)
147
-
148
-
149
- def _runs_pytest_via_module_flag(all_tokens: list[str]) -> bool:
150
- if MODULE_RUN_FLAG not in all_tokens:
151
- return False
152
- flag_index = all_tokens.index(MODULE_RUN_FLAG)
153
- return flag_index + 1 < len(all_tokens) and all_tokens[flag_index + 1] == PYTEST_MODULE_NAME
154
-
155
-
156
- def _command_tokens_run_pytest(all_tokens: list[str]) -> bool:
157
- if not all_tokens:
158
- return False
159
- first_basename = Path(all_tokens[0]).name
160
- if first_basename in ALL_PYTEST_PROGRAM_BASENAMES:
161
- return True
162
- if not PYTHON_INTERPRETER_BASENAME_PATTERN.match(first_basename):
163
- return False
164
- return _runs_pytest_via_module_flag(all_tokens)
165
-
166
-
167
- def _existing_test_file_paths(all_tokens: list[str], cwd: str) -> list[Path]:
168
- all_paths: list[Path] = []
169
- for each_token in all_tokens:
170
- if each_token.startswith(OPTION_TOKEN_PREFIX) or each_token in ALL_REDIRECTION_TOKENS:
171
- continue
172
- file_path_text = each_token.split(NODE_ID_FILE_PATH_SEPARATOR, 1)[0]
173
- candidate_path = Path(cwd, file_path_text) if cwd else Path(file_path_text)
174
- if candidate_path.is_file():
175
- all_paths.append(candidate_path)
176
- return all_paths
177
-
178
-
179
- def _command_tokens(command: str) -> list[str]:
180
- try:
181
- return shlex.split(command)
182
- except ValueError:
183
- return []
184
-
185
-
186
- def _record_pytest_failure(hook_payload: dict, command: str) -> None:
187
- exit_status = _harness_reported_exit_status(hook_payload.get("tool_response"))
188
- if exit_status is None or not _is_single_unchained_command(command):
189
- return
190
- all_tokens = _command_tokens(command)
191
- if not _command_tokens_run_pytest(all_tokens):
192
- return
193
- cwd = str(hook_payload.get("cwd") or "")
194
- all_test_file_paths = _existing_test_file_paths(all_tokens, cwd)
195
- if not all_test_file_paths:
196
- return
197
- session_id = str(hook_payload.get("session_id") or "")
198
- content_hash_store.record_test_command_failure(
199
- all_test_file_paths, command, exit_status, session_id, cwd
200
- )
201
-
202
-
203
- def main() -> None:
204
- """Record one failing pytest run's evidence from a PostToolUse Bash payload.
205
-
206
- Reads the payload from stdin. Returns quietly on every branch that is not
207
- a genuine, single, unchained, path-naming pytest failure, so a logging
208
- problem never surfaces as a blocked or altered tool call.
209
- """
210
- hook_payload = read_hook_input_dictionary_from_stdin()
211
- if hook_payload is None or hook_payload.get("tool_name") != BASH_TOOL_NAME:
212
- return
213
- tool_input = hook_payload.get("tool_input")
214
- command = tool_input.get("command", "") if isinstance(tool_input, dict) else ""
215
- if not isinstance(command, str) or not _mentions_pytest(command):
216
- return
217
- _record_pytest_failure(hook_payload, command)
218
-
219
-
220
- if __name__ == "__main__":
221
- main()